Files
wild-pc/castle-api/src/castle_api/routes.py
Paul Payne a022a136fe Retire gateway.tls=internal; host routes use a bare subdomain label
acme mode fully replaced the internal-CA path (which required installing a private
CA on every device — the exact pain, esp. on Android, that acme avoids). Remove
`internal` entirely and simplify how services declare host routes.

- Generator (caddyfile.py): drop the `tls internal` branch — modes are now
  `off` | `acme`. In acme mode the published subdomain is the **first DNS label**
  of `proxy.caddy.host` (a bare `claw`, or a legacy `claw.civil.lan`, both →
  `claw.<domain>`), so services stay domain-agnostic and the declared value is
  authoritative again (no more silent service-name override). Shared
  `_host_matcher_block` reused by off-mode and the acme wildcard site.
- castle-api: delete `GET /gateway/ca.crt`, `_gateway_ca_pem`, `_ca_fingerprint`
  and the now-unused imports; drop `ca_fingerprint` from `GatewayInfo` (keep
  `tls`).
- Dashboard: remove the CA-cert download button + unused imports; drop
  `ca_fingerprint` from the `GatewayInfo` type.
- Tests: replace TestCaddyfileTlsInternal with an off-mode class (keeps the
  runner-agnostic host-route coverage); acme tests assert first-label derivation
  incl. label-wins-over-service-name; drop the castle-api CA-endpoint test.
- Docs: registry.md + dns-and-tls.md — two-mode tables (off|acme), remove the
  internal sections/CA-download, document the bare-label host convention; note a
  domain-less node stays on `off`.
2026-06-30 19:55:17 -07:00

947 lines
31 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 SPECS_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
from castle_core.stacks import available_actions
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 (
DeploymentDetail,
DeploymentSummary,
GatewayInfo,
GatewayRoute,
JobDetail,
JobSummary,
ProgramDetail,
ProgramSummary,
ServiceDetail,
ServiceSummary,
StatusResponse,
SystemdInfo,
)
router = APIRouter(tags=["dashboard"])
def _declared_commands_dict(comp: ProgramSpec) -> dict[str, list[list[str]]] | None:
"""Serialize a program's declared verbs for the API (build + CommandsSpec)."""
out: dict[str, list[list[str]]] = {}
if comp.build and comp.build.commands:
out["build"] = comp.build.commands
if comp.commands is not None:
for verb in (
"lint",
"test",
"type-check",
"check",
"run",
"install",
"uninstall",
):
cmds = comp.commands.for_verb(verb)
if cmds:
out[verb] = cmds
return out or None
def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
"""Build a DeploymentSummary from a Deployment."""
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
category = "job" if deployed.schedule else "service"
return DeploymentSummary(
id=name,
category=category,
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
) -> DeploymentSummary:
"""Build a DeploymentSummary 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.program and svc.program in config.programs:
comp = config.programs[svc.program]
if not description:
description = comp.description
source = comp.source
stack = comp.stack
runner = svc.run.runner
return DeploymentSummary(
id=name,
category="service",
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) -> DeploymentSummary:
"""Build a DeploymentSummary 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.program and job.program in config.programs:
comp = config.programs[job.program]
if not description:
description = comp.description
source = comp.source
stack = comp.stack
return DeploymentSummary(
id=name,
category="job",
description=description,
behavior="tool",
stack=stack,
runner=job.run.runner,
managed=managed,
systemd=systemd_info,
schedule=job.schedule,
source=source,
)
def _summary_from_program(
name: str, comp: ProgramSpec, root: Path
) -> DeploymentSummary:
"""Build a DeploymentSummary from a ProgramSpec (tools/frontends)."""
source = comp.source
# Infer runner from source directory
runner = None
if source:
source_dir = Path(source)
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
runner = "command"
installed: bool | None = None
if comp.source and (comp.stack or comp.commands):
installed = shutil.which(name) is not None
return DeploymentSummary(
id=name,
category="program",
description=comp.description,
behavior=comp.behavior,
stack=comp.stack,
runner=runner,
version=comp.version,
source=source,
repo=comp.repo,
ref=comp.ref,
commands=_declared_commands_dict(comp),
system_dependencies=comp.system_dependencies,
installed=installed,
)
# ---------------------------------------------------------------------------
# Typed builder functions — one per concept
# ---------------------------------------------------------------------------
def _make_systemd_info(name: str, timer: bool = False) -> SystemdInfo:
unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name)
return SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=timer)
def _backfill_source(name: str, config: object) -> str | None:
"""Resolve source path from program ref in config."""
if name in config.programs:
return config.programs[name].source
ref = None
if name in config.services:
ref = config.services[name].program
elif name in config.jobs:
ref = config.jobs[name].program
if ref and ref in config.programs:
return config.programs[ref].source
return None
def _run_target(run: object) -> str | None:
"""A human label for what a run spec executes (program / argv / image / …)."""
if run is None:
return None
for attr in ("program", "image", "script", "base_url"):
val = getattr(run, attr, None)
if val:
return str(val)
argv = getattr(run, "argv", None)
if argv:
return " ".join(argv)
return None
def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
"""Build a ServiceSummary from a Deployment."""
systemd_info = _make_systemd_info(name) if deployed.managed else None
run_target = " ".join(deployed.run_cmd) if deployed.run_cmd else None
return ServiceSummary(
id=name,
description=deployed.description,
stack=deployed.stack,
runner=deployed.runner,
run_target=run_target,
port=deployed.port,
health_path=deployed.health_path,
proxy_path=deployed.proxy_path,
proxy_host=deployed.proxy_host,
managed=deployed.managed,
systemd=systemd_info,
)
def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSummary:
"""Build a ServiceSummary from a ServiceSpec."""
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 = _make_systemd_info(name) if managed else None
description = svc.description
source = None
stack = None
if svc.program and svc.program in config.programs:
comp = config.programs[svc.program]
if not description:
description = comp.description
source = comp.source
stack = comp.stack
proxy_host = svc.proxy.caddy.host if (svc.proxy and svc.proxy.caddy) else None
return ServiceSummary(
id=name,
description=description,
stack=stack,
runner=svc.run.runner,
run_target=_run_target(svc.run),
port=port,
health_path=health_path,
proxy_path=proxy_path,
proxy_host=proxy_host,
managed=managed,
systemd=systemd_info,
program=svc.program,
source=source,
)
def _job_from_deployed(name: str, deployed: object) -> JobSummary:
"""Build a JobSummary from a Deployment."""
systemd_info = _make_systemd_info(name, timer=True) if deployed.managed else None
run_target = " ".join(deployed.run_cmd) if deployed.run_cmd else None
return JobSummary(
id=name,
description=deployed.description,
stack=deployed.stack,
runner=deployed.runner,
run_target=run_target,
schedule=deployed.schedule,
managed=deployed.managed,
systemd=systemd_info,
)
def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary:
"""Build a JobSummary from a JobSpec."""
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
systemd_info = _make_systemd_info(name, timer=True) if managed else None
description = job.description
source = None
stack = None
if job.program and job.program in config.programs:
comp = config.programs[job.program]
if not description:
description = comp.description
source = comp.source
stack = comp.stack
return JobSummary(
id=name,
description=description,
stack=stack,
runner=job.run.runner,
run_target=_run_target(job.run),
schedule=job.schedule,
managed=managed,
systemd=systemd_info,
program=job.program,
source=source,
)
def _program_from_spec(
name: str, comp: ProgramSpec, root: Path, config: object | None = None
) -> ProgramSummary:
"""Build a ProgramSummary from a ProgramSpec."""
source = comp.source
runner = None
if source:
source_dir = Path(source)
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
runner = "command"
installed: bool | None = None
if comp.source and (comp.stack or comp.commands):
installed = shutil.which(name) is not None
# Uniform lifecycle state (on PATH / running / served) — needs full config.
active: bool | None = None
services: list[str] = []
jobs: list[str] = []
if config is not None:
from castle_core.lifecycle import is_active
active = is_active(name, config)
# Deployments that reference this program (a program → 0-N services/jobs).
services = [s for s, spec in config.services.items() if spec.program == name]
jobs = [j for j, spec in config.jobs.items() if spec.program == name]
return ProgramSummary(
id=name,
description=comp.description,
behavior=comp.behavior,
stack=comp.stack,
runner=runner,
version=comp.version,
source=source,
repo=comp.repo,
ref=comp.ref,
commands=_declared_commands_dict(comp),
system_dependencies=comp.system_dependencies,
installed=installed,
active=active,
actions=available_actions(comp),
services=services,
jobs=jobs,
)
# ---------------------------------------------------------------------------
# Typed endpoints — /services, /jobs, /programs
# ---------------------------------------------------------------------------
@router.get("/services", response_model=list[ServiceSummary], tags=["services-data"])
def list_services(include_remote: bool = False) -> list[ServiceSummary]:
"""List all services — deployed from registry, non-deployed from castle.yaml."""
registry = get_registry()
hostname = registry.node.hostname
summaries: list[ServiceSummary] = []
seen: set[str] = set()
# Deployed services (non-scheduled)
for name, deployed in registry.deployed.items():
if deployed.schedule:
continue
s = _service_from_deployed(name, deployed)
s.node = hostname
# Backfill source
root = get_castle_root()
if root and s.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
s.source = _backfill_source(name, config)
except FileNotFoundError:
pass
summaries.append(s)
seen.add(name)
# Non-deployed from castle.yaml
root = get_castle_root()
if root:
try:
from castle_core.config import load_config
config = load_config(root)
for name, svc in config.services.items():
if name not in seen:
s = _service_from_spec(name, svc, config)
s.node = hostname
summaries.append(s)
seen.add(name)
except FileNotFoundError:
pass
# Remote
if include_remote:
for remote_host, remote in mesh_state.all_nodes().items():
for name, d in remote.registry.deployed.items():
if not d.schedule and name not in seen:
s = _service_from_deployed(name, d)
s.node = remote_host
summaries.append(s)
seen.add(name)
return summaries
@router.get(
"/services/{name}",
response_model=ServiceDetail,
tags=["services-data"],
)
def get_service(name: str) -> ServiceDetail:
"""Get detailed info for a single service.
The `manifest` is the editable castle.yaml ServiceSpec whenever the service
is declared there — that's the source of truth the config editor reads and
writes. Only services that exist *only* in the runtime registry (e.g. infra
not in castle.yaml) fall back to the flat deployed shape (display-only).
"""
root = get_castle_root()
config = None
if root:
try:
from castle_core.config import load_config
config = load_config(root)
except FileNotFoundError:
pass
if config is not None and name in config.services:
svc = config.services[name]
summary = _service_from_spec(name, svc, config)
manifest = svc.model_dump(mode="json", exclude_none=True)
return ServiceDetail(**summary.model_dump(), manifest=manifest)
registry = get_registry()
if name in registry.deployed and not registry.deployed[name].schedule:
deployed = registry.deployed[name]
summary = _service_from_deployed(name, deployed)
if config is not None and summary.source is None:
summary.source = _backfill_source(name, config)
manifest = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"secret_env_keys": deployed.secret_env_keys,
"port": deployed.port,
"health_path": deployed.health_path,
"proxy_path": deployed.proxy_path,
"managed": deployed.managed,
"behavior": deployed.behavior,
"stack": deployed.stack,
}
return ServiceDetail(**summary.model_dump(), manifest=manifest)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Service '{name}' not found",
)
@router.get("/jobs", response_model=list[JobSummary], tags=["jobs-data"])
def list_jobs(include_remote: bool = False) -> list[JobSummary]:
"""List all jobs — deployed from registry, non-deployed from castle.yaml."""
registry = get_registry()
hostname = registry.node.hostname
summaries: list[JobSummary] = []
seen: set[str] = set()
# Deployed jobs (scheduled)
for name, deployed in registry.deployed.items():
if not deployed.schedule:
continue
s = _job_from_deployed(name, deployed)
s.node = hostname
root = get_castle_root()
if root and s.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
s.source = _backfill_source(name, config)
except FileNotFoundError:
pass
summaries.append(s)
seen.add(name)
# Non-deployed from castle.yaml
root = get_castle_root()
if root:
try:
from castle_core.config import load_config
config = load_config(root)
for name, job in config.jobs.items():
if name not in seen:
s = _job_from_spec(name, job, config)
s.node = hostname
summaries.append(s)
seen.add(name)
except FileNotFoundError:
pass
# Remote
if include_remote:
for remote_host, remote in mesh_state.all_nodes().items():
for name, d in remote.registry.deployed.items():
if d.schedule and name not in seen:
s = _job_from_deployed(name, d)
s.node = remote_host
summaries.append(s)
seen.add(name)
return summaries
@router.get("/jobs/{name}", response_model=JobDetail, tags=["jobs-data"])
def get_job(name: str) -> JobDetail:
"""Get detailed info for a single job. `manifest` is the editable castle.yaml
JobSpec when declared there; falls back to the runtime registry otherwise."""
root = get_castle_root()
config = None
if root:
try:
from castle_core.config import load_config
config = load_config(root)
except FileNotFoundError:
pass
if config is not None and name in config.jobs:
job = config.jobs[name]
summary = _job_from_spec(name, job, config)
manifest = job.model_dump(mode="json", exclude_none=True)
return JobDetail(**summary.model_dump(), manifest=manifest)
registry = get_registry()
if name in registry.deployed and registry.deployed[name].schedule:
deployed = registry.deployed[name]
summary = _job_from_deployed(name, deployed)
if config is not None and summary.source is None:
summary.source = _backfill_source(name, config)
manifest = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"secret_env_keys": deployed.secret_env_keys,
"managed": deployed.managed,
"schedule": deployed.schedule,
"behavior": deployed.behavior,
"stack": deployed.stack,
}
return JobDetail(**summary.model_dump(), manifest=manifest)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job '{name}' not found",
)
@router.get("/programs", response_model=list[ProgramSummary], tags=["programs"])
def list_programs(behavior: str | None = None) -> list[ProgramSummary]:
"""List all programs from the software catalog (castle.yaml programs section).
Optionally filter by behavior: daemon, tool, or frontend.
"""
root = get_castle_root()
if not root:
return []
try:
from castle_core.config import load_config
config = load_config(root)
except FileNotFoundError:
return []
hostname = get_registry().node.hostname
summaries: list[ProgramSummary] = []
for name, comp in config.programs.items():
summary = _program_from_spec(name, comp, root, config)
if summary.behavior is None:
continue
if behavior and summary.behavior != behavior:
continue
summary.node = hostname
summaries.append(summary)
return summaries
@router.get("/programs/{name}", response_model=ProgramDetail, tags=["programs"])
def get_program(name: str) -> ProgramDetail:
"""Get detailed info for a single program."""
root = get_castle_root()
if root:
from castle_core.config import load_config
config = load_config(root)
if name in config.programs:
comp = config.programs[name]
summary = _program_from_spec(name, comp, root, config)
raw = comp.model_dump(mode="json", exclude_none=True)
return ProgramDetail(**summary.model_dump(), manifest=raw)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Program '{name}' not found",
)
# ---------------------------------------------------------------------------
# Legacy /components endpoint (compat shim)
# ---------------------------------------------------------------------------
@router.get("/deployments", response_model=list[DeploymentSummary])
def list_components(include_remote: bool = False) -> list[DeploymentSummary]:
"""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[DeploymentSummary] = []
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 program refs for deployed items
for s in summaries:
if s.source is None and s.id in config.programs:
s.source = config.programs[s.id].source
elif s.source is None:
# Check if a service/job references a program
ref = None
if s.id in config.services:
ref = config.services[s.id].program
elif s.id in config.jobs:
ref = config.jobs[s.id].program
if ref and ref in config.programs:
s.source = config.programs[ref].source
# Programs from the software catalog
for name, comp in config.programs.items():
summary = _summary_from_program(name, comp, root)
if summary.behavior is None:
continue
summary.node = local_hostname
summaries.append(summary)
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(
DeploymentSummary(
id=name,
category="job" if d.schedule else "service",
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("/deployments/{name}", response_model=DeploymentDetail)
def get_component(name: str) -> DeploymentDetail:
"""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 program 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.programs:
summary.source = config.programs[name].source
else:
ref = None
if name in config.services:
ref = config.services[name].program
elif name in config.jobs:
ref = config.jobs[name].program
if ref and ref in config.programs:
summary.source = config.programs[ref].source
except FileNotFoundError:
pass
raw = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"secret_env_keys": deployed.secret_env_keys,
"port": deployed.port,
"health_path": deployed.health_path,
"proxy_path": deployed.proxy_path,
"managed": deployed.managed,
"behavior": deployed.behavior,
"stack": deployed.stack,
}
return DeploymentDetail(**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 DeploymentDetail(**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 DeploymentDetail(**summary.model_dump(), manifest=raw)
if name in config.programs:
comp = config.programs[name]
summary = _summary_from_program(name, comp, root)
raw = comp.model_dump(mode="json", exclude_none=True)
return DeploymentDetail(**summary.model_dump(), manifest=raw)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"'{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, including the full route table.
Routes are computed by the same function that generates the Caddyfile, so
the table matches reality: static-served frontends, path/host proxies, and
cross-node routes all appear, each tagged with its kind and target.
"""
from castle_core.generators.caddyfile import compute_routes
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)
config = None
root = get_castle_root()
if root:
try:
from castle_core.config import load_config
config = load_config(root)
except FileNotFoundError:
pass
remote = {h: r.registry for h, r in mesh_state.all_nodes().items()}
routes = [
GatewayRoute(
address=r.address,
kind=r.kind,
target=r.target,
name=r.name,
node=r.node or registry.node.hostname,
)
for r in compute_routes(registry, config, remote or None)
]
# Caddyfile order is precedence-sensitive; the displayed table is alphabetical.
routes.sort(key=lambda r: r.address)
return GatewayInfo(
port=registry.node.gateway_port,
hostname=registry.node.hostname,
deployment_count=deployed_count,
service_count=service_count,
managed_count=managed_count,
routes=routes,
tls=registry.node.gateway_tls,
)
@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()
SPECS_DIR.mkdir(parents=True, exist_ok=True)
caddyfile_path = SPECS_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"}