refactor: Separate runtime from build. Enhance configuration and registry management, migrate to registry-based component handling
This commit is contained in:
@@ -4,11 +4,13 @@ from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from castle_core.config import CastleConfig, load_config
|
||||
from castle_core.registry import NodeRegistry, load_registry
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Service settings loaded from environment variables."""
|
||||
|
||||
castle_root: Path = Path("/data/repos/castle")
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 9020
|
||||
|
||||
@@ -19,3 +21,32 @@ class Settings(BaseSettings):
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def get_registry() -> NodeRegistry:
|
||||
"""Load the node registry. Raises if not found."""
|
||||
return load_registry()
|
||||
|
||||
|
||||
def get_castle_root() -> Path | None:
|
||||
"""Get the castle repo root from the registry, if available."""
|
||||
try:
|
||||
registry = load_registry()
|
||||
if registry.node.castle_root:
|
||||
return Path(registry.node.castle_root)
|
||||
except (FileNotFoundError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_config() -> CastleConfig:
|
||||
"""Load castle.yaml via the registry's castle_root.
|
||||
|
||||
Raises FileNotFoundError if repo not available.
|
||||
"""
|
||||
root = get_castle_root()
|
||||
if root is None:
|
||||
raise FileNotFoundError(
|
||||
"Castle repo not available. Set castle_root in registry."
|
||||
)
|
||||
return load_config(root)
|
||||
|
||||
@@ -9,10 +9,10 @@ import yaml
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from castle_core.config import load_config, save_config
|
||||
from castle_core.config import save_config
|
||||
from castle_core.manifest import ComponentManifest
|
||||
|
||||
from castle_api.config import settings
|
||||
from castle_api.config import get_castle_root, get_config, get_registry
|
||||
from castle_api.stream import broadcast
|
||||
|
||||
router = APIRouter(prefix="/config", tags=["config"])
|
||||
@@ -42,16 +42,29 @@ class ComponentConfigRequest(BaseModel):
|
||||
config: dict
|
||||
|
||||
|
||||
def _require_repo() -> None:
|
||||
"""Raise 503 if repo is not available."""
|
||||
if get_castle_root() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Castle repo not available on this node.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ConfigResponse)
|
||||
def get_config() -> ConfigResponse:
|
||||
def get_config_yaml() -> ConfigResponse:
|
||||
"""Get the raw castle.yaml content."""
|
||||
config_path = settings.castle_root / "castle.yaml"
|
||||
_require_repo()
|
||||
root = get_castle_root()
|
||||
config_path = root / "castle.yaml"
|
||||
return ConfigResponse(yaml_content=config_path.read_text())
|
||||
|
||||
|
||||
@router.put("", response_model=ConfigSaveResponse)
|
||||
def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
|
||||
"""Validate and save castle.yaml. Does NOT apply changes."""
|
||||
_require_repo()
|
||||
root = get_castle_root()
|
||||
errors: list[str] = []
|
||||
|
||||
# Parse YAML
|
||||
@@ -87,7 +100,7 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
|
||||
)
|
||||
|
||||
# Backup and save
|
||||
config_path = settings.castle_root / "castle.yaml"
|
||||
config_path = root / "castle.yaml"
|
||||
backup_path = config_path.with_suffix(".yaml.bak")
|
||||
shutil.copy2(config_path, backup_path)
|
||||
config_path.write_text(request.yaml_content)
|
||||
@@ -98,6 +111,8 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
|
||||
@router.put("/components/{name}")
|
||||
def save_component(name: str, request: ComponentConfigRequest) -> dict:
|
||||
"""Update a single component's config in castle.yaml."""
|
||||
_require_repo()
|
||||
|
||||
# Validate
|
||||
try:
|
||||
comp_data = dict(request.config)
|
||||
@@ -109,7 +124,7 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict:
|
||||
detail=f"Invalid component config: {e}",
|
||||
)
|
||||
|
||||
config = load_config(settings.castle_root)
|
||||
config = get_config()
|
||||
config.components[name] = ComponentManifest.model_validate(
|
||||
{**request.config, "id": name}
|
||||
)
|
||||
@@ -120,7 +135,7 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict:
|
||||
@router.delete("/components/{name}")
|
||||
def delete_component(name: str) -> dict:
|
||||
"""Remove a component from castle.yaml."""
|
||||
config = load_config(settings.castle_root)
|
||||
config = get_config()
|
||||
if name not in config.components:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -133,13 +148,15 @@ def delete_component(name: str) -> dict:
|
||||
|
||||
@router.post("/apply", response_model=ApplyResponse)
|
||||
async def apply_config() -> ApplyResponse:
|
||||
"""Apply config: regenerate systemd units for managed services + reload gateway."""
|
||||
config = load_config(settings.castle_root)
|
||||
"""Apply config: restart managed services + regenerate and reload gateway."""
|
||||
registry = get_registry()
|
||||
actions: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
# Regenerate and restart managed services
|
||||
for name in config.managed:
|
||||
# Restart managed services
|
||||
for name, deployed in registry.deployed.items():
|
||||
if not deployed.managed:
|
||||
continue
|
||||
unit = f"castle-{name}.service"
|
||||
ok, output = await _systemctl("restart", unit)
|
||||
if ok:
|
||||
@@ -149,17 +166,17 @@ async def apply_config() -> ApplyResponse:
|
||||
|
||||
# Reload gateway
|
||||
from castle_core.config import GENERATED_DIR, ensure_dirs
|
||||
from castle_core.generators import generate_caddyfile
|
||||
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
||||
|
||||
ensure_dirs()
|
||||
caddyfile_path = GENERATED_DIR / "Caddyfile"
|
||||
caddyfile_path.write_text(generate_caddyfile(config))
|
||||
caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
|
||||
actions.append("Generated Caddyfile")
|
||||
|
||||
if shutil.which("caddy"):
|
||||
ok, output = await _run("caddy", "reload",
|
||||
"--config", str(caddyfile_path),
|
||||
"--adapter", "caddyfile")
|
||||
ok, output = await _run(
|
||||
"caddy", "reload", "--config", str(caddyfile_path), "--adapter", "caddyfile"
|
||||
)
|
||||
if ok:
|
||||
actions.append("Reloaded gateway")
|
||||
else:
|
||||
@@ -171,7 +188,10 @@ async def apply_config() -> ApplyResponse:
|
||||
|
||||
async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"systemctl", "--user", action, unit,
|
||||
"systemctl",
|
||||
"--user",
|
||||
action,
|
||||
unit,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
@@ -7,23 +7,18 @@ import time
|
||||
|
||||
import httpx
|
||||
|
||||
from castle_core.config import CastleConfig
|
||||
from castle_core.registry import NodeRegistry
|
||||
|
||||
from castle_api.models import HealthStatus
|
||||
|
||||
|
||||
async def check_all_health(config: CastleConfig) -> list[HealthStatus]:
|
||||
"""Check health of all components with expose.http and a health_path."""
|
||||
async def check_all_health(registry: NodeRegistry) -> list[HealthStatus]:
|
||||
"""Check health of all deployed components with a port and health_path."""
|
||||
targets: list[tuple[str, str]] = []
|
||||
for name, manifest in config.components.items():
|
||||
if not (manifest.expose and manifest.expose.http):
|
||||
for name, deployed in registry.deployed.items():
|
||||
if not deployed.port or not deployed.health_path:
|
||||
continue
|
||||
http = manifest.expose.http
|
||||
if not http.health_path:
|
||||
continue
|
||||
host = http.internal.host or "127.0.0.1"
|
||||
port = http.internal.port
|
||||
url = f"http://{host}:{port}{http.health_path}"
|
||||
url = f"http://127.0.0.1:{deployed.port}{deployed.health_path}"
|
||||
targets.append((name, url))
|
||||
|
||||
if not targets:
|
||||
|
||||
@@ -42,7 +42,13 @@ async def get_logs(
|
||||
|
||||
# Static tail
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"journalctl", "--user", "-u", unit, "-n", str(n), "--no-pager",
|
||||
"journalctl",
|
||||
"--user",
|
||||
"-u",
|
||||
unit,
|
||||
"-n",
|
||||
str(n),
|
||||
"--no-pager",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -54,7 +60,14 @@ async def get_logs(
|
||||
async def _follow_logs(unit: str, n: int) -> AsyncGenerator[str, None]:
|
||||
"""Stream journalctl -f output as SSE events."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"journalctl", "--user", "-u", unit, "-n", str(n), "-f", "--no-pager",
|
||||
"journalctl",
|
||||
"--user",
|
||||
"-u",
|
||||
unit,
|
||||
"-n",
|
||||
str(n),
|
||||
"-f",
|
||||
"--no-pager",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,12 @@ from castle_api.logs import router as logs_router
|
||||
from castle_api.routes import router as dashboard_router
|
||||
from castle_api.secrets import router as secrets_router
|
||||
from castle_api.services import router as services_router
|
||||
from castle_api.stream import close_all_subscribers, health_poll_loop, subscribe, unsubscribe
|
||||
from castle_api.stream import (
|
||||
close_all_subscribers,
|
||||
health_poll_loop,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
)
|
||||
from castle_api.tools import router as tools_router
|
||||
|
||||
# Set by _watch_shutdown when uvicorn begins its shutdown sequence.
|
||||
|
||||
@@ -7,9 +7,9 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from castle_core.config import load_config
|
||||
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
||||
|
||||
from castle_api.config import settings
|
||||
from castle_api.config import get_castle_root, get_registry
|
||||
from castle_api.health import check_all_health
|
||||
from castle_api.models import (
|
||||
ComponentDetail,
|
||||
@@ -22,8 +22,43 @@ from castle_api.models import (
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
|
||||
"""Build a ComponentSummary from a DeployedComponent."""
|
||||
managed = deployed.managed
|
||||
|
||||
systemd_info: SystemdInfo | None = None
|
||||
if managed:
|
||||
unit_name = f"castle-{name}.service"
|
||||
unit_path = str(Path("~/.config/systemd/user") / unit_name)
|
||||
has_timer = deployed.schedule is not None
|
||||
systemd_info = SystemdInfo(
|
||||
unit_name=unit_name,
|
||||
unit_path=unit_path,
|
||||
timer=has_timer,
|
||||
)
|
||||
|
||||
# Check if tool is installed on PATH
|
||||
installed: bool | None = None
|
||||
if "tool" in deployed.roles:
|
||||
installed = shutil.which(name) is not None
|
||||
|
||||
return ComponentSummary(
|
||||
id=name,
|
||||
description=deployed.description,
|
||||
roles=deployed.roles,
|
||||
runner=deployed.runner,
|
||||
port=deployed.port,
|
||||
health_path=deployed.health_path,
|
||||
proxy_path=deployed.proxy_path,
|
||||
managed=managed,
|
||||
systemd=systemd_info,
|
||||
schedule=deployed.schedule,
|
||||
installed=installed,
|
||||
)
|
||||
|
||||
|
||||
def _summary_from_manifest(name: str, manifest: object, root: Path) -> ComponentSummary:
|
||||
"""Build a ComponentSummary from a manifest."""
|
||||
"""Build a ComponentSummary from a manifest (for non-deployed components)."""
|
||||
port = None
|
||||
health_path = None
|
||||
proxy_path = None
|
||||
@@ -37,26 +72,25 @@ def _summary_from_manifest(name: str, manifest: object, root: Path) -> Component
|
||||
manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable
|
||||
)
|
||||
|
||||
# Systemd info for managed components
|
||||
systemd_info: SystemdInfo | None = None
|
||||
if managed:
|
||||
unit_name = f"castle-{name}.service"
|
||||
unit_path = str(Path("~/.config/systemd/user") / unit_name)
|
||||
has_timer = any(getattr(t, "type", None) == "schedule" for t in manifest.triggers)
|
||||
has_timer = any(
|
||||
getattr(t, "type", None) == "schedule" for t in manifest.triggers
|
||||
)
|
||||
systemd_info = SystemdInfo(
|
||||
unit_name=unit_name,
|
||||
unit_path=unit_path,
|
||||
timer=has_timer,
|
||||
)
|
||||
|
||||
# Extract cron schedule from first schedule trigger, if any
|
||||
schedule = None
|
||||
for t in manifest.triggers:
|
||||
if t.type == "schedule":
|
||||
schedule = t.cron
|
||||
break
|
||||
|
||||
# Infer runner — from run block or from tool source
|
||||
runner = manifest.run.runner if manifest.run else None
|
||||
if runner is None and manifest.tool and manifest.tool.source:
|
||||
source_dir = root / manifest.tool.source
|
||||
@@ -65,7 +99,6 @@ def _summary_from_manifest(name: str, manifest: object, root: Path) -> Component
|
||||
elif source_dir.is_file():
|
||||
runner = "command"
|
||||
|
||||
# Check if tool is actually installed on PATH
|
||||
installed: bool | None = None
|
||||
if manifest.install and manifest.install.path:
|
||||
alias = manifest.install.path.alias or name
|
||||
@@ -91,53 +124,95 @@ def _summary_from_manifest(name: str, manifest: object, root: Path) -> Component
|
||||
|
||||
@router.get("/components", response_model=list[ComponentSummary])
|
||||
def list_components() -> list[ComponentSummary]:
|
||||
"""List all registered components."""
|
||||
config = load_config(settings.castle_root)
|
||||
return [
|
||||
_summary_from_manifest(name, m, config.root)
|
||||
for name, m in config.components.items()
|
||||
]
|
||||
"""List all components — deployed from registry, non-deployed from castle.yaml."""
|
||||
registry = get_registry()
|
||||
summaries: list[ComponentSummary] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
# Deployed components from registry
|
||||
for name, deployed in registry.deployed.items():
|
||||
summaries.append(_summary_from_deployed(name, deployed))
|
||||
seen.add(name)
|
||||
|
||||
# Non-deployed components from castle.yaml (if repo available)
|
||||
root = get_castle_root()
|
||||
if root:
|
||||
try:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
for name, manifest in config.components.items():
|
||||
if name not in seen:
|
||||
summaries.append(_summary_from_manifest(name, manifest, root))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
return summaries
|
||||
|
||||
|
||||
@router.get("/components/{name}", response_model=ComponentDetail)
|
||||
def get_component(name: str) -> ComponentDetail:
|
||||
"""Get detailed info for a single component."""
|
||||
config = load_config(settings.castle_root)
|
||||
if name not in config.components:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Component '{name}' not found",
|
||||
)
|
||||
manifest = config.components[name]
|
||||
summary = _summary_from_manifest(name, manifest, config.root)
|
||||
raw = manifest.model_dump(mode="json", exclude_none=True)
|
||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
||||
registry = get_registry()
|
||||
|
||||
if name in registry.deployed:
|
||||
deployed = registry.deployed[name]
|
||||
summary = _summary_from_deployed(name, deployed)
|
||||
raw = {
|
||||
"runner": deployed.runner,
|
||||
"run_cmd": deployed.run_cmd,
|
||||
"env": deployed.env,
|
||||
"port": deployed.port,
|
||||
"health_path": deployed.health_path,
|
||||
"proxy_path": deployed.proxy_path,
|
||||
"managed": deployed.managed,
|
||||
"roles": deployed.roles,
|
||||
}
|
||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
# Fall back to castle.yaml
|
||||
root = get_castle_root()
|
||||
if root:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
if name in config.components:
|
||||
manifest = config.components[name]
|
||||
summary = _summary_from_manifest(name, manifest, root)
|
||||
raw = manifest.model_dump(mode="json", exclude_none=True)
|
||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Component '{name}' not found",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=StatusResponse)
|
||||
async def get_status() -> StatusResponse:
|
||||
"""Get live health status for all exposed services."""
|
||||
config = load_config(settings.castle_root)
|
||||
statuses = await check_all_health(config)
|
||||
"""Get live health status for all deployed services."""
|
||||
registry = get_registry()
|
||||
statuses = await check_all_health(registry)
|
||||
return StatusResponse(statuses=statuses)
|
||||
|
||||
|
||||
@router.get("/gateway", response_model=GatewayInfo)
|
||||
def get_gateway() -> GatewayInfo:
|
||||
"""Get gateway configuration summary."""
|
||||
config = load_config(settings.castle_root)
|
||||
registry = get_registry()
|
||||
deployed_count = len(registry.deployed)
|
||||
service_count = sum(1 for d in registry.deployed.values() if d.port is not None)
|
||||
managed_count = sum(1 for d in registry.deployed.values() if d.managed)
|
||||
return GatewayInfo(
|
||||
port=config.gateway.port,
|
||||
component_count=len(config.components),
|
||||
service_count=len(config.services),
|
||||
managed_count=len(config.managed),
|
||||
port=registry.node.gateway_port,
|
||||
component_count=deployed_count,
|
||||
service_count=service_count,
|
||||
managed_count=managed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/gateway/caddyfile")
|
||||
def get_caddyfile() -> dict[str, str]:
|
||||
"""Return the generated Caddyfile content."""
|
||||
from castle_core.generators import generate_caddyfile
|
||||
|
||||
config = load_config(settings.castle_root)
|
||||
return {"content": generate_caddyfile(config)}
|
||||
registry = get_registry()
|
||||
return {"content": generate_caddyfile_from_registry(registry)}
|
||||
|
||||
@@ -8,9 +8,12 @@ import time
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from castle_core.config import load_config
|
||||
from castle_core.generators.systemd import (
|
||||
generate_timer,
|
||||
generate_unit_from_deployed,
|
||||
)
|
||||
|
||||
from castle_api.config import settings
|
||||
from castle_api.config import get_castle_root, get_registry
|
||||
from castle_api.health import check_all_health
|
||||
from castle_api.models import HealthStatus
|
||||
from castle_api.stream import broadcast
|
||||
@@ -24,7 +27,10 @@ SELF_NAME = "castle-api"
|
||||
async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
|
||||
"""Run a systemctl --user command. Returns (success, output)."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"systemctl", "--user", action, unit,
|
||||
"systemctl",
|
||||
"--user",
|
||||
action,
|
||||
unit,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -36,7 +42,10 @@ async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
|
||||
async def _get_unit_status(unit: str) -> str:
|
||||
"""Get the active status of a systemd unit."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"systemctl", "--user", "is-active", unit,
|
||||
"systemctl",
|
||||
"--user",
|
||||
"is-active",
|
||||
unit,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -45,9 +54,9 @@ async def _get_unit_status(unit: str) -> str:
|
||||
|
||||
|
||||
def _validate_managed(name: str) -> None:
|
||||
"""Raise 404 if the component isn't systemd-managed."""
|
||||
config = load_config(settings.castle_root)
|
||||
if name not in config.managed:
|
||||
"""Raise 404 if the component isn't managed in the registry."""
|
||||
registry = get_registry()
|
||||
if name not in registry.deployed or not registry.deployed[name].managed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"'{name}' is not a managed service",
|
||||
@@ -58,25 +67,29 @@ async def _broadcast_health_with_override(
|
||||
override_name: str, override_status: str
|
||||
) -> None:
|
||||
"""Run health checks but override one component's status from systemd."""
|
||||
config = load_config(settings.castle_root)
|
||||
statuses = await check_all_health(config)
|
||||
registry = get_registry()
|
||||
statuses = await check_all_health(registry)
|
||||
|
||||
# Replace the overridden component's status with the systemd truth
|
||||
result = []
|
||||
for s in statuses:
|
||||
if s.id == override_name:
|
||||
result.append(HealthStatus(
|
||||
id=override_name,
|
||||
status="down" if override_status != "active" else "up",
|
||||
latency_ms=None,
|
||||
))
|
||||
result.append(
|
||||
HealthStatus(
|
||||
id=override_name,
|
||||
status="down" if override_status != "active" else "up",
|
||||
latency_ms=None,
|
||||
)
|
||||
)
|
||||
else:
|
||||
result.append(s)
|
||||
|
||||
await broadcast("health", {
|
||||
"statuses": [s.model_dump() for s in result],
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
await broadcast(
|
||||
"health",
|
||||
{
|
||||
"statuses": [s.model_dump() for s in result],
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _deferred_systemctl(action: str, unit: str, delay: float = 0.5) -> None:
|
||||
@@ -104,7 +117,6 @@ async def _do_action(name: str, action: str) -> JSONResponse:
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail=output or f"Failed to {action}")
|
||||
|
||||
# Broadcast immediately with systemd status as the source of truth
|
||||
await _broadcast_health_with_override(name, unit_status)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -115,13 +127,25 @@ async def _do_action(name: str, action: str) -> JSONResponse:
|
||||
@router.get("/{name}/unit")
|
||||
def get_unit(name: str) -> dict[str, str | None]:
|
||||
"""Return the generated systemd unit file(s) for a managed component."""
|
||||
from castle_core.generators import generate_timer, generate_unit
|
||||
|
||||
_validate_managed(name)
|
||||
config = load_config(settings.castle_root)
|
||||
manifest = config.managed[name]
|
||||
unit = generate_unit(config, name, manifest)
|
||||
timer = generate_timer(name, manifest)
|
||||
registry = get_registry()
|
||||
deployed = registry.deployed[name]
|
||||
|
||||
# Get systemd spec from manifest if repo available
|
||||
systemd_spec = None
|
||||
root = get_castle_root()
|
||||
manifest = None
|
||||
if root:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
if name in config.components:
|
||||
manifest = config.components[name]
|
||||
if manifest.manage and manifest.manage.systemd:
|
||||
systemd_spec = manifest.manage.systemd
|
||||
|
||||
unit = generate_unit_from_deployed(name, deployed, systemd_spec)
|
||||
timer = generate_timer(name, manifest) if manifest else None
|
||||
return {"service": unit, "timer": timer}
|
||||
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from castle_core.config import load_config
|
||||
|
||||
from castle_api.config import settings
|
||||
from castle_api.config import get_registry
|
||||
from castle_api.health import check_all_health
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -60,12 +58,15 @@ async def health_poll_loop(interval: float = 10.0) -> None:
|
||||
"""Background task that polls health and broadcasts updates."""
|
||||
while True:
|
||||
try:
|
||||
config = load_config(settings.castle_root)
|
||||
statuses = await check_all_health(config)
|
||||
await broadcast("health", {
|
||||
"statuses": [s.model_dump() for s in statuses],
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
registry = get_registry()
|
||||
statuses = await check_all_health(registry)
|
||||
await broadcast(
|
||||
"health",
|
||||
{
|
||||
"statuses": [s.model_dump() for s in statuses],
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Health poll failed")
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
@@ -7,20 +7,23 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from castle_core.config import load_config
|
||||
from castle_core.manifest import ComponentManifest
|
||||
|
||||
from castle_api.config import settings
|
||||
from castle_api.config import get_config
|
||||
from castle_api.models import ToolDetail, ToolSummary
|
||||
|
||||
router = APIRouter(tags=["tools"])
|
||||
|
||||
|
||||
def _tool_summary(name: str, manifest: ComponentManifest, root: Path | None = None) -> ToolSummary:
|
||||
def _tool_summary(
|
||||
name: str, manifest: ComponentManifest, root: Path | None = None
|
||||
) -> ToolSummary:
|
||||
"""Build a ToolSummary from a manifest that has a tool spec."""
|
||||
t = manifest.tool
|
||||
assert t is not None
|
||||
installed = bool(manifest.install and manifest.install.path and manifest.install.path.enable)
|
||||
installed = bool(
|
||||
manifest.install and manifest.install.path and manifest.install.path.enable
|
||||
)
|
||||
|
||||
# Infer runner from run block or source directory
|
||||
runner = manifest.run.runner if manifest.run else None
|
||||
@@ -44,12 +47,15 @@ def _tool_summary(name: str, manifest: ComponentManifest, root: Path | None = No
|
||||
|
||||
@router.get("/tools", response_model=list[ToolSummary])
|
||||
def list_tools() -> list[ToolSummary]:
|
||||
"""List all registered tools."""
|
||||
config = load_config(settings.castle_root)
|
||||
"""List all registered tools (requires repo access)."""
|
||||
config = get_config()
|
||||
tools = {k: v for k, v in config.components.items() if v.tool}
|
||||
|
||||
return sorted(
|
||||
[_tool_summary(name, manifest, config.root) for name, manifest in tools.items()],
|
||||
[
|
||||
_tool_summary(name, manifest, config.root)
|
||||
for name, manifest in tools.items()
|
||||
],
|
||||
key=lambda t: t.id,
|
||||
)
|
||||
|
||||
@@ -57,7 +63,7 @@ def list_tools() -> list[ToolSummary]:
|
||||
@router.get("/tools/{name}", response_model=ToolDetail)
|
||||
def get_tool(name: str) -> ToolDetail:
|
||||
"""Get detailed info for a single tool."""
|
||||
config = load_config(settings.castle_root)
|
||||
config = get_config()
|
||||
|
||||
if name not in config.components:
|
||||
raise HTTPException(
|
||||
@@ -79,20 +85,31 @@ def get_tool(name: str) -> ToolDetail:
|
||||
@router.post("/tools/{name}/install")
|
||||
async def install_tool(name: str) -> dict:
|
||||
"""Install a tool to PATH via uv tool install."""
|
||||
config = load_config(settings.castle_root)
|
||||
config = get_config()
|
||||
if name not in config.components:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
|
||||
)
|
||||
|
||||
manifest = config.components[name]
|
||||
if not manifest.tool or not manifest.tool.source:
|
||||
raise HTTPException(status_code=400, detail=f"'{name}' has no tool source to install")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"'{name}' has no tool source to install"
|
||||
)
|
||||
|
||||
source_dir = config.root / manifest.tool.source
|
||||
if not (source_dir / "pyproject.toml").exists():
|
||||
raise HTTPException(status_code=400, detail=f"No pyproject.toml in {manifest.tool.source}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"No pyproject.toml in {manifest.tool.source}"
|
||||
)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"uv", "tool", "install", "--editable", str(source_dir), "--force",
|
||||
"uv",
|
||||
"tool",
|
||||
"install",
|
||||
"--editable",
|
||||
str(source_dir),
|
||||
"--force",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -108,9 +125,11 @@ async def install_tool(name: str) -> dict:
|
||||
@router.post("/tools/{name}/uninstall")
|
||||
async def uninstall_tool(name: str) -> dict:
|
||||
"""Uninstall a tool from PATH via uv tool uninstall."""
|
||||
config = load_config(settings.castle_root)
|
||||
config = get_config()
|
||||
if name not in config.components:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
|
||||
)
|
||||
|
||||
manifest = config.components[name]
|
||||
if not manifest.tool or not manifest.tool.source:
|
||||
@@ -118,17 +137,20 @@ async def uninstall_tool(name: str) -> dict:
|
||||
|
||||
# uv tool uninstall uses the package name from pyproject.toml
|
||||
source_dir = config.root / manifest.tool.source
|
||||
# Try to read the package name; fall back to the source dir name
|
||||
pkg_name = source_dir.name
|
||||
pyproject = source_dir / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
import tomllib
|
||||
|
||||
with open(pyproject, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
pkg_name = data.get("project", {}).get("name", pkg_name)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"uv", "tool", "uninstall", pkg_name,
|
||||
"uv",
|
||||
"tool",
|
||||
"uninstall",
|
||||
pkg_name,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
@@ -7,8 +7,14 @@ import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from castle_api.config import settings
|
||||
import castle_api.config as api_config
|
||||
from castle_api.main import app
|
||||
from castle_core.registry import (
|
||||
DeployedComponent,
|
||||
NodeConfig,
|
||||
NodeRegistry,
|
||||
save_registry,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -20,10 +26,10 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
"components": {
|
||||
"test-svc": {
|
||||
"description": "Test service",
|
||||
"source": "test-svc",
|
||||
"run": {
|
||||
"runner": "python_uv_tool",
|
||||
"tool": "test-svc",
|
||||
"cwd": "test-svc",
|
||||
},
|
||||
"expose": {
|
||||
"http": {
|
||||
@@ -52,15 +58,67 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
},
|
||||
}
|
||||
castle_yaml.write_text(yaml.dump(config, default_flow_style=False))
|
||||
|
||||
original = settings.castle_root
|
||||
settings.castle_root = tmp_path
|
||||
yield tmp_path
|
||||
settings.castle_root = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(castle_root: Path) -> Generator[TestClient, None, None]:
|
||||
"""Create a test client pointing to temporary castle root."""
|
||||
def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, None]:
|
||||
"""Create a temporary registry.yaml and patch the module to use it."""
|
||||
reg_path = tmp_path / "registry.yaml"
|
||||
registry = NodeRegistry(
|
||||
node=NodeConfig(
|
||||
hostname="test-node",
|
||||
castle_root=str(castle_root),
|
||||
gateway_port=9000,
|
||||
),
|
||||
deployed={
|
||||
"test-svc": DeployedComponent(
|
||||
runner="python_uv_tool",
|
||||
run_cmd=["uv", "run", "test-svc"],
|
||||
env={
|
||||
"TEST_SVC_PORT": "19000",
|
||||
"TEST_SVC_DATA_DIR": "/data/castle/test-svc",
|
||||
},
|
||||
description="Test service",
|
||||
roles=["service"],
|
||||
port=19000,
|
||||
health_path="/health",
|
||||
proxy_path="/test-svc",
|
||||
managed=True,
|
||||
),
|
||||
},
|
||||
)
|
||||
save_registry(registry, reg_path)
|
||||
|
||||
# Patch the registry path and helper functions
|
||||
import castle_core.registry as reg_mod
|
||||
|
||||
original_path = reg_mod.REGISTRY_PATH
|
||||
reg_mod.REGISTRY_PATH = reg_path
|
||||
|
||||
original_get_registry = api_config.get_registry
|
||||
original_get_castle_root = api_config.get_castle_root
|
||||
|
||||
def _get_registry() -> NodeRegistry:
|
||||
from castle_core.registry import load_registry
|
||||
|
||||
return load_registry(reg_path)
|
||||
|
||||
def _get_castle_root() -> Path | None:
|
||||
return castle_root
|
||||
|
||||
api_config.get_registry = _get_registry
|
||||
api_config.get_castle_root = _get_castle_root
|
||||
|
||||
yield reg_path
|
||||
|
||||
reg_mod.REGISTRY_PATH = original_path
|
||||
api_config.get_registry = original_get_registry
|
||||
api_config.get_castle_root = original_get_castle_root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(registry_path: Path) -> Generator[TestClient, None, None]:
|
||||
"""Create a test client with temporary registry."""
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
|
||||
@@ -55,7 +55,7 @@ class TestComponentDetail:
|
||||
data = response.json()
|
||||
assert data["id"] == "test-svc"
|
||||
assert "manifest" in data
|
||||
assert data["manifest"]["run"]["runner"] == "python_uv_tool"
|
||||
assert data["manifest"]["runner"] == "python_uv_tool"
|
||||
|
||||
def test_not_found(self, client: TestClient) -> None:
|
||||
"""Returns 404 for unknown component."""
|
||||
@@ -67,11 +67,12 @@ class TestGateway:
|
||||
"""Gateway info endpoint tests."""
|
||||
|
||||
def test_gateway_info(self, client: TestClient) -> None:
|
||||
"""Returns gateway configuration."""
|
||||
"""Returns gateway configuration from registry."""
|
||||
response = client.get("/gateway")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["port"] == 9000
|
||||
assert data["component_count"] == 3
|
||||
# Registry has 1 deployed component (test-svc)
|
||||
assert data["component_count"] == 1
|
||||
assert data["service_count"] == 1
|
||||
assert data["managed_count"] == 1
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Tests for tools endpoints."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user