Files
wild-pc/castle-api/src/castle_api/routes.py

445 lines
15 KiB
Python

"""API routes for the castle dashboard."""
from __future__ import annotations
import asyncio
import shutil
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from castle_core.config import GENERATED_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_api.config import get_castle_root, get_registry
from castle_api.mesh import mesh_state
from castle_api.health import check_all_health
from castle_api.models import (
ComponentDetail,
ComponentSummary,
GatewayInfo,
GatewayRoute,
StatusResponse,
SystemdInfo,
)
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 deployed.behavior == "tool":
installed = shutil.which(name) is not None
return ComponentSummary(
id=name,
description=deployed.description,
behavior=deployed.behavior,
stack=deployed.stack,
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_service(name: str, svc: ServiceSpec, config: object) -> ComponentSummary:
"""Build a ComponentSummary from a ServiceSpec (non-deployed)."""
port = None
health_path = None
proxy_path = None
if svc.expose and svc.expose.http:
port = svc.expose.http.internal.port
health_path = svc.expose.http.health_path
if svc.proxy and svc.proxy.caddy:
proxy_path = svc.proxy.caddy.path_prefix
managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable)
systemd_info: SystemdInfo | None = None
if managed:
unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name)
systemd_info = SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=False)
description = svc.description
source = None
stack = None
if svc.component and svc.component in config.components:
comp = config.components[svc.component]
if not description:
description = comp.description
source = comp.source
stack = comp.stack
runner = svc.run.runner
return ComponentSummary(
id=name,
description=description,
behavior="daemon",
stack=stack,
runner=runner,
port=port,
health_path=health_path,
proxy_path=proxy_path,
managed=managed,
systemd=systemd_info,
source=source,
)
def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSummary:
"""Build a ComponentSummary from a JobSpec (non-deployed)."""
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
systemd_info: SystemdInfo | None = None
if managed:
unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name)
systemd_info = SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=True)
description = job.description
source = None
stack = None
if job.component and job.component in config.components:
comp = config.components[job.component]
if not description:
description = comp.description
source = comp.source
stack = comp.stack
return ComponentSummary(
id=name,
description=description,
behavior="tool",
stack=stack,
runner=job.run.runner,
managed=managed,
systemd=systemd_info,
schedule=job.schedule,
source=source,
)
def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a ComponentSpec (tools/frontends)."""
# Determine behavior
is_tool = bool((comp.install and comp.install.path) or comp.tool)
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
if is_tool:
behavior = "tool"
elif is_frontend:
behavior = "frontend"
else:
behavior = None
source = comp.source
# Infer runner from source directory
runner = None
if source:
source_dir = root / source
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
runner = "command"
installed: bool | None = None
if comp.install and comp.install.path:
alias = comp.install.path.alias or name
installed = shutil.which(alias) is not None
return ComponentSummary(
id=name,
description=comp.description,
behavior=behavior,
stack=comp.stack,
runner=runner,
version=comp.tool.version if comp.tool else None,
source=source,
system_dependencies=comp.tool.system_dependencies if comp.tool else [],
installed=installed,
)
@router.get("/components", response_model=list[ComponentSummary])
def list_components(include_remote: bool = False) -> list[ComponentSummary]:
"""List all components — deployed from registry, non-deployed from castle.yaml.
Pass ?include_remote=true to include components from remote mesh nodes.
"""
registry = get_registry()
local_hostname = registry.node.hostname
summaries: list[ComponentSummary] = []
seen: set[str] = set()
# Deployed components from registry
for name, deployed in registry.deployed.items():
s = _summary_from_deployed(name, deployed)
s.node = local_hostname
summaries.append(s)
seen.add(name)
# Non-deployed from castle.yaml (if repo available)
root = get_castle_root()
if root:
try:
from castle_core.config import load_config
config = load_config(root)
# Services not in registry
for name, svc in config.services.items():
if name not in seen:
s = _summary_from_service(name, svc, config)
s.node = local_hostname
summaries.append(s)
seen.add(name)
# Jobs not in registry
for name, job in config.jobs.items():
if name not in seen:
s = _summary_from_job(name, job, config)
s.node = local_hostname
summaries.append(s)
seen.add(name)
# Backfill source from component refs for deployed items
for s in summaries:
if s.source is None and s.id in config.components:
s.source = config.components[s.id].source
elif s.source is None:
# Check if a service/job references a component
ref = None
if s.id in config.services:
ref = config.services[s.id].component
elif s.id in config.jobs:
ref = config.jobs[s.id].component
if ref and ref in config.components:
s.source = config.components[ref].source
# Components (tools/frontends) not already represented by a
# service or job entry. Skip if the component has no
# distinct behavior (e.g. it's just the software identity
# behind a service).
for name, comp in config.components.items():
if name in seen:
continue
summary = _summary_from_component(name, comp, root)
if summary.behavior is None:
continue
summary.node = local_hostname
summaries.append(summary)
seen.add(name)
except FileNotFoundError:
pass
# Remote components from mesh (local wins on name conflicts)
if include_remote:
for hostname, remote in mesh_state.all_nodes().items():
for name, d in remote.registry.deployed.items():
if name not in seen:
summaries.append(
ComponentSummary(
id=name,
description=d.description,
behavior=d.behavior,
stack=d.stack,
runner=d.runner,
port=d.port,
health_path=d.health_path,
proxy_path=d.proxy_path,
managed=d.managed,
schedule=d.schedule,
node=hostname,
)
)
seen.add(name)
return summaries
@router.get("/components/{name}", response_model=ComponentDetail)
def get_component(name: str) -> ComponentDetail:
"""Get detailed info for a single component."""
registry = get_registry()
if name in registry.deployed:
deployed = registry.deployed[name]
summary = _summary_from_deployed(name, deployed)
# Backfill source from castle.yaml component ref
root = get_castle_root()
if root and summary.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
if name in config.components:
summary.source = config.components[name].source
else:
ref = None
if name in config.services:
ref = config.services[name].component
elif name in config.jobs:
ref = config.jobs[name].component
if ref and ref in config.components:
summary.source = config.components[ref].source
except FileNotFoundError:
pass
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,
"behavior": deployed.behavior,
"stack": deployed.stack,
}
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.services:
svc = config.services[name]
summary = _summary_from_service(name, svc, config)
raw = svc.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
if name in config.jobs:
job = config.jobs[name]
summary = _summary_from_job(name, job, config)
raw = job.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
if name in config.components:
comp = config.components[name]
summary = _summary_from_component(name, comp, root)
raw = comp.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 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."""
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)
# Local routes
routes: list[GatewayRoute] = [
GatewayRoute(
path=d.proxy_path,
target_port=d.port,
component=name,
node=registry.node.hostname,
)
for name, d in registry.deployed.items()
if d.proxy_path and d.port
]
# Remote routes from mesh (local paths take precedence)
local_paths = {r.path for r in routes}
for hostname, remote in mesh_state.all_nodes().items():
for name, d in remote.registry.deployed.items():
if d.proxy_path and d.port and d.proxy_path not in local_paths:
routes.append(
GatewayRoute(
path=d.proxy_path,
target_port=d.port,
component=name,
node=hostname,
)
)
routes.sort(key=lambda r: r.path)
return GatewayInfo(
port=registry.node.gateway_port,
hostname=registry.node.hostname,
component_count=deployed_count,
service_count=service_count,
managed_count=managed_count,
routes=routes,
)
@router.get("/gateway/caddyfile")
def get_caddyfile() -> dict[str, str]:
"""Return the generated Caddyfile content."""
registry = get_registry()
return {"content": generate_caddyfile_from_registry(registry)}
@router.post("/gateway/reload")
async def reload_gateway() -> dict[str, str]:
"""Regenerate Caddyfile and reload Caddy."""
registry = get_registry()
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
caddyfile_path = GENERATED_DIR / "Caddyfile"
# Include remote registries for cross-node routing
remote_regs = {h: n.registry for h, n in mesh_state.all_nodes().items()}
caddyfile_path.write_text(
generate_caddyfile_from_registry(registry, remote_registries=remote_regs or None)
)
proc = await asyncio.create_subprocess_exec(
"systemctl", "--user", "reload", "castle-castle-gateway.service",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise HTTPException(
status_code=500,
detail=f"Reload failed: {(stderr or b'').decode().strip()}",
)
return {"status": "ok"}