api: POST /apply converge endpoint; enabled in summaries

- Replace POST /deploy with POST /apply (name + plan), returning the
  enacted diff (activated/restarted/deactivated/unchanged). /config/apply
  now delegates to core apply() too, so there's one convergence path.
- Retire /services/{name}/start and /stop (keep /restart as the
  imperative bounce). Drop install/uninstall from program actions — tool/
  static activation is convergence now.
- Surface `enabled` on ServiceSummary/JobSummary/DeploymentSummary so the
  UI can show and toggle desired state.
- conftest: drop the obsolete config_editor.get_registry patch (apply_config
  no longer reads the registry directly).

59 API tests pass; route table shows /apply (no /deploy), /restart only.
This commit is contained in:
2026-07-02 11:41:00 -07:00
parent d0a206f7d6
commit 5d15d18e1d
8 changed files with 86 additions and 70 deletions

View File

@@ -21,7 +21,7 @@ from castle_core.config import (
) )
from castle_core.manifest import ProgramSpec, kind_for from castle_core.manifest import ProgramSpec, kind_for
from castle_api.config import get_castle_root, get_config, get_registry from castle_api.config import get_castle_root, get_config
from castle_api.stream import broadcast from castle_api.stream import broadcast
router = APIRouter(prefix="/config", tags=["config"]) router = APIRouter(prefix="/config", tags=["config"])
@@ -347,42 +347,33 @@ def delete_job(name: str) -> dict:
@router.post("/apply", response_model=ApplyResponse) @router.post("/apply", response_model=ApplyResponse)
async def apply_config() -> ApplyResponse: async def apply_config() -> ApplyResponse:
"""Apply config: rebuild runtime from castle.yaml, then restart services. """Converge the running system to match castle.yaml (a thin wrapper on core
``apply``). Renders units/Caddyfile/tunnel, then reconciles the runtime —
Runs a full ``deploy`` so the registry, systemd units, and Caddyfile are all activating what's enabled and down, restarting only what changed, deactivating
regenerated from the current castle.yaml (and the gateway reloaded) — this is the disabled. Kept as ``/config/apply`` for compatibility; ``/apply`` exposes
what keeps the running config from drifting behind an edit. Then restarts the the same converge with per-deployment targeting and ``--plan``.
managed services so the freshly written units take effect (``deploy`` only
daemon-reloads; a running unit keeps its old ExecStart until restarted).
Scheduled jobs are left alone — applying config shouldn't fire every job.
""" """
from castle_core.deploy import deploy from castle_core.deploy import apply
# apply is blocking (systemctl + gateway reload) — run off the event loop.
try:
result = await asyncio.to_thread(apply)
except Exception as e:
return ApplyResponse(ok=False, actions=[], errors=[f"Apply failed: {e}"])
actions: list[str] = [] actions: list[str] = []
errors: list[str] = [] for verb, names in (
("Activated", result.activated),
# Rebuild registry + units + Caddyfile from castle.yaml off the event loop ("Restarted", result.restarted),
# (deploy is blocking: it shells out to systemctl and the gateway). ("Deactivated", result.deactivated),
try: ):
result = await asyncio.to_thread(deploy) if names:
except Exception as e: actions.append(f"{verb} {', '.join(sorted(names))}")
return ApplyResponse(ok=False, actions=actions, errors=[f"Deploy failed: {e}"]) if not result.changed:
actions.extend(result.messages) actions.append("Already converged — nothing to do")
# Restart managed services so the new units take effect (skip scheduled jobs).
registry = result.registry or get_registry()
for name, deployed in registry.deployed.items():
if not deployed.managed or deployed.schedule:
continue
unit = f"castle-{name}.service"
ok, output = await _systemctl("restart", unit)
if ok:
actions.append(f"Restarted {name}")
else:
errors.append(f"Failed to restart {name}: {output}")
await broadcast("config-changed", {"actions": actions}) await broadcast("config-changed", {"actions": actions})
return ApplyResponse(ok=len(errors) == 0, actions=actions, errors=errors) return ApplyResponse(ok=True, actions=actions, errors=[])
async def _systemctl(action: str, unit: str) -> tuple[bool, str]: async def _systemctl(action: str, unit: str) -> tuple[bool, str]:

View File

@@ -1,49 +1,67 @@
"""Deploy API endpoint.""" """Apply (converge) API endpoint.
`POST /apply` reconciles the running system to match config — render units/
Caddyfile/tunnel, then activate/restart/deactivate to match. It replaces the old
`/deploy` (+ separate start/enable calls). `?plan=true` returns the diff without
touching anything.
"""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from castle_core.deploy import deploy from castle_core.deploy import apply
router = APIRouter(tags=["deploy"]) router = APIRouter(tags=["apply"])
class DeployRequest(BaseModel): class ApplyRequest(BaseModel):
"""Optional request body for deploy.""" """Optional request body for apply."""
name: str | None = None name: str | None = None
plan: bool = False
class DeployResponse(BaseModel): class ApplyResponse(BaseModel):
"""Response from a deploy operation.""" """The diff a converge enacted (or would enact, for a plan)."""
status: str status: str
deployed_count: int planned: bool
changed: bool
activated: list[str]
restarted: list[str]
deactivated: list[str]
unchanged: list[str]
messages: list[str] messages: list[str]
@router.post("/deploy", response_model=DeployResponse) @router.post("/apply", response_model=ApplyResponse)
def run_deploy(request: DeployRequest | None = None) -> DeployResponse: def run_apply(request: ApplyRequest | None = None) -> ApplyResponse:
"""Deploy services and jobs from castle.yaml to runtime. """Converge the running system to match castle.yaml.
Resolves env vars and secrets, generates systemd units and Caddyfile, Renders systemd units + the Caddyfile + tunnel config, then reconciles the
copies frontend build outputs, and reloads systemd. runtime: activate enabled deployments that are down, restart any whose unit
changed, deactivate disabled ones. Pass a name to converge one deployment,
Optionally pass a name to deploy a single service or job. or `plan: true` to compute the diff without changing anything.
""" """
target_name = request.name if request else None name = request.name if request else None
plan = request.plan if request else False
try: try:
result = deploy(target_name=target_name) result = apply(target_name=name, plan=plan)
except FileNotFoundError as e: except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e raise HTTPException(status_code=404, detail=str(e)) from e
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e raise HTTPException(status_code=500, detail=str(e)) from e
return DeployResponse( return ApplyResponse(
status="ok", status="ok",
deployed_count=result.deployed_count, planned=result.planned,
changed=result.changed,
activated=result.activated,
restarted=result.restarted,
deactivated=result.deactivated,
unchanged=result.unchanged,
messages=result.messages, messages=result.messages,
) )

View File

@@ -35,6 +35,7 @@ class DeploymentSummary(BaseModel):
schedule: str | None = None schedule: str | None = None
installed: bool | None = None installed: bool | None = None
active: bool | None = None # uniform lifecycle state (on PATH / running / served) active: bool | None = None # uniform lifecycle state (on PATH / running / served)
enabled: bool = True # declared desired state; `apply` converges to it
node: str | None = None node: str | None = None
@@ -65,6 +66,7 @@ class ServiceSummary(BaseModel):
systemd: SystemdInfo | None = None systemd: SystemdInfo | None = None
program: str | None = None # the program this deployment references, if any program: str | None = None # the program this deployment references, if any
source: str | None = None source: str | None = None
enabled: bool = True # declared desired state; `apply` converges to it
node: str | None = None node: str | None = None
@@ -87,6 +89,7 @@ class JobSummary(BaseModel):
systemd: SystemdInfo | None = None systemd: SystemdInfo | None = None
program: str | None = None # the program this deployment references, if any program: str | None = None # the program this deployment references, if any
source: str | None = None source: str | None = None
enabled: bool = True # declared desired state; `apply` converges to it
node: str | None = None node: str | None = None

View File

@@ -21,14 +21,14 @@ def list_stacks() -> list[str]:
# Unified program action endpoint # Unified program action endpoint
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Dev verbs only. Activation (install/uninstall of tools/statics) is convergence:
# it happens through `POST /apply`, not as a program action.
_VALID_ACTIONS = { _VALID_ACTIONS = {
"build", "build",
"test", "test",
"lint", "lint",
"type-check", "type-check",
"check", "check",
"install",
"uninstall",
} }

View File

@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -104,6 +103,7 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
schedule=deployed.schedule, schedule=deployed.schedule,
installed=installed, installed=installed,
active=active, active=active,
enabled=deployed.enabled,
) )
@@ -152,6 +152,7 @@ def _summary_from_service(
managed=managed, managed=managed,
systemd=systemd_info, systemd=systemd_info,
source=source, source=source,
enabled=svc.enabled,
) )
@@ -187,6 +188,7 @@ def _summary_from_job(name: str, job: SystemdDeployment, config: object) -> Depl
systemd=systemd_info, systemd=systemd_info,
schedule=job.schedule, schedule=job.schedule,
source=source, source=source,
enabled=job.enabled,
) )
@@ -276,6 +278,7 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
subdomain=deployed.subdomain, subdomain=deployed.subdomain,
managed=deployed.managed, managed=deployed.managed,
systemd=systemd_info, systemd=systemd_info,
enabled=deployed.enabled,
) )
@@ -317,6 +320,7 @@ def _service_from_spec(name: str, svc: SystemdDeployment, config: object) -> Ser
systemd=systemd_info, systemd=systemd_info,
program=svc.program, program=svc.program,
source=source, source=source,
enabled=svc.enabled,
) )
@@ -333,6 +337,7 @@ def _job_from_deployed(name: str, deployed: object) -> JobSummary:
schedule=deployed.schedule, schedule=deployed.schedule,
managed=deployed.managed, managed=deployed.managed,
systemd=systemd_info, systemd=systemd_info,
enabled=deployed.enabled,
) )
@@ -362,6 +367,7 @@ def _job_from_spec(name: str, job: SystemdDeployment, config: object) -> JobSumm
systemd=systemd_info, systemd=systemd_info,
program=job.program, program=job.program,
source=source, source=source,
enabled=job.enabled,
) )

View File

@@ -153,19 +153,12 @@ def get_unit(name: str) -> dict[str, str | None]:
return {"service": unit, "timer": timer} return {"service": unit, "timer": timer}
@router.post("/{name}/start")
async def start_service(name: str) -> JSONResponse:
"""Start a systemd-managed service."""
return await _do_action(name, "start")
@router.post("/{name}/stop")
async def stop_service(name: str) -> JSONResponse:
"""Stop a systemd-managed service."""
return await _do_action(name, "stop")
@router.post("/{name}/restart") @router.post("/{name}/restart")
async def restart_service(name: str) -> JSONResponse: async def restart_service(name: str) -> JSONResponse:
"""Restart a systemd-managed service.""" """Restart a systemd-managed service — the imperative bounce.
Lifecycle (start/stop/enable/disable) is convergence now: set `enabled` in the
deployment config and POST /apply. Restart stays as a way to re-actualize the
current desired state without changing it.
"""
return await _do_action(name, "restart") return await _do_action(name, "restart")

View File

@@ -164,7 +164,6 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
"services.get_castle_root": services_mod.get_castle_root, "services.get_castle_root": services_mod.get_castle_root,
"nodes.get_registry": nodes_mod.get_registry, "nodes.get_registry": nodes_mod.get_registry,
"stream.get_registry": stream_mod.get_registry, "stream.get_registry": stream_mod.get_registry,
"config_editor.get_registry": config_editor_mod.get_registry,
"config_editor.get_castle_root": config_editor_mod.get_castle_root, "config_editor.get_castle_root": config_editor_mod.get_castle_root,
} }
@@ -192,7 +191,6 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
services_mod.get_castle_root = originals["services.get_castle_root"] services_mod.get_castle_root = originals["services.get_castle_root"]
nodes_mod.get_registry = originals["nodes.get_registry"] nodes_mod.get_registry = originals["nodes.get_registry"]
stream_mod.get_registry = originals["stream.get_registry"] stream_mod.get_registry = originals["stream.get_registry"]
config_editor_mod.get_registry = originals["config_editor.get_registry"]
config_editor_mod.get_castle_root = originals["config_editor.get_castle_root"] config_editor_mod.get_castle_root = originals["config_editor.get_castle_root"]

View File

@@ -99,6 +99,13 @@ class TestServicesList:
svc = next(s for s in data if s["id"] == "test-svc") svc = next(s for s in data if s["id"] == "test-svc")
assert "schedule" not in svc assert "schedule" not in svc
def test_enabled_flows_through(self, client: TestClient) -> None:
"""ServiceSummary surfaces the declared `enabled` state (default True)."""
response = client.get("/services")
data = response.json()
svc = next(s for s in data if s["id"] == "test-svc")
assert svc["enabled"] is True
def test_no_installed_field(self, client: TestClient) -> None: def test_no_installed_field(self, client: TestClient) -> None:
"""ServiceSummary does not have installed field.""" """ServiceSummary does not have installed field."""
response = client.get("/services") response = client.get("/services")