From 5d15d18e1dcd3ef122e041c9edb4e7fe64915202 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 2 Jul 2026 11:41:00 -0700 Subject: [PATCH] api: POST /apply converge endpoint; enabled in summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- castle-api/src/castle_api/config_editor.py | 55 +++++++++----------- castle-api/src/castle_api/deploy_routes.py | 58 ++++++++++++++-------- castle-api/src/castle_api/models.py | 3 ++ castle-api/src/castle_api/programs.py | 4 +- castle-api/src/castle_api/routes.py | 8 ++- castle-api/src/castle_api/services.py | 19 +++---- castle-api/tests/conftest.py | 2 - castle-api/tests/test_health.py | 7 +++ 8 files changed, 86 insertions(+), 70 deletions(-) diff --git a/castle-api/src/castle_api/config_editor.py b/castle-api/src/castle_api/config_editor.py index 9fda783..e672ba1 100644 --- a/castle-api/src/castle_api/config_editor.py +++ b/castle-api/src/castle_api/config_editor.py @@ -21,7 +21,7 @@ from castle_core.config import ( ) 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 router = APIRouter(prefix="/config", tags=["config"]) @@ -347,42 +347,33 @@ def delete_job(name: str) -> dict: @router.post("/apply", response_model=ApplyResponse) async def apply_config() -> ApplyResponse: - """Apply config: rebuild runtime from castle.yaml, then restart services. - - Runs a full ``deploy`` so the registry, systemd units, and Caddyfile are all - regenerated from the current castle.yaml (and the gateway reloaded) — this is - what keeps the running config from drifting behind an edit. Then restarts the - 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. + """Converge the running system to match castle.yaml (a thin wrapper on core + ``apply``). Renders units/Caddyfile/tunnel, then reconciles the runtime — + activating what's enabled and down, restarting only what changed, deactivating + the disabled. Kept as ``/config/apply`` for compatibility; ``/apply`` exposes + the same converge with per-deployment targeting and ``--plan``. """ - 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] = [] - errors: list[str] = [] - - # Rebuild registry + units + Caddyfile from castle.yaml off the event loop - # (deploy is blocking: it shells out to systemctl and the gateway). - try: - result = await asyncio.to_thread(deploy) - except Exception as e: - return ApplyResponse(ok=False, actions=actions, errors=[f"Deploy failed: {e}"]) - actions.extend(result.messages) - - # 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}") + for verb, names in ( + ("Activated", result.activated), + ("Restarted", result.restarted), + ("Deactivated", result.deactivated), + ): + if names: + actions.append(f"{verb} {', '.join(sorted(names))}") + if not result.changed: + actions.append("Already converged — nothing to do") 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]: diff --git a/castle-api/src/castle_api/deploy_routes.py b/castle-api/src/castle_api/deploy_routes.py index 5c44814..2d47901 100644 --- a/castle-api/src/castle_api/deploy_routes.py +++ b/castle-api/src/castle_api/deploy_routes.py @@ -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 fastapi import APIRouter, HTTPException 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): - """Optional request body for deploy.""" +class ApplyRequest(BaseModel): + """Optional request body for apply.""" name: str | None = None + plan: bool = False -class DeployResponse(BaseModel): - """Response from a deploy operation.""" +class ApplyResponse(BaseModel): + """The diff a converge enacted (or would enact, for a plan).""" status: str - deployed_count: int + planned: bool + changed: bool + activated: list[str] + restarted: list[str] + deactivated: list[str] + unchanged: list[str] messages: list[str] -@router.post("/deploy", response_model=DeployResponse) -def run_deploy(request: DeployRequest | None = None) -> DeployResponse: - """Deploy services and jobs from castle.yaml to runtime. +@router.post("/apply", response_model=ApplyResponse) +def run_apply(request: ApplyRequest | None = None) -> ApplyResponse: + """Converge the running system to match castle.yaml. - Resolves env vars and secrets, generates systemd units and Caddyfile, - copies frontend build outputs, and reloads systemd. - - Optionally pass a name to deploy a single service or job. + Renders systemd units + the Caddyfile + tunnel config, then reconciles the + runtime: activate enabled deployments that are down, restart any whose unit + changed, deactivate disabled ones. Pass a name to converge one deployment, + 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: - result = deploy(target_name=target_name) + result = apply(target_name=name, plan=plan) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) from e except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e - return DeployResponse( + return ApplyResponse( 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, - ) \ No newline at end of file + ) diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index 24485b7..d2459c3 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -35,6 +35,7 @@ class DeploymentSummary(BaseModel): schedule: str | None = None installed: bool | None = None 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 @@ -65,6 +66,7 @@ class ServiceSummary(BaseModel): systemd: SystemdInfo | None = None program: str | None = None # the program this deployment references, if any source: str | None = None + enabled: bool = True # declared desired state; `apply` converges to it node: str | None = None @@ -87,6 +89,7 @@ class JobSummary(BaseModel): systemd: SystemdInfo | None = None program: str | None = None # the program this deployment references, if any source: str | None = None + enabled: bool = True # declared desired state; `apply` converges to it node: str | None = None diff --git a/castle-api/src/castle_api/programs.py b/castle-api/src/castle_api/programs.py index ccad9df..77abadd 100644 --- a/castle-api/src/castle_api/programs.py +++ b/castle-api/src/castle_api/programs.py @@ -21,14 +21,14 @@ def list_stacks() -> list[str]: # 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 = { "build", "test", "lint", "type-check", "check", - "install", - "uninstall", } diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index a3bd836..7aefc2f 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import shutil import subprocess from pathlib import Path @@ -104,6 +103,7 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary: schedule=deployed.schedule, installed=installed, active=active, + enabled=deployed.enabled, ) @@ -152,6 +152,7 @@ def _summary_from_service( managed=managed, systemd=systemd_info, source=source, + enabled=svc.enabled, ) @@ -187,6 +188,7 @@ def _summary_from_job(name: str, job: SystemdDeployment, config: object) -> Depl systemd=systemd_info, schedule=job.schedule, source=source, + enabled=job.enabled, ) @@ -276,6 +278,7 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary: subdomain=deployed.subdomain, managed=deployed.managed, systemd=systemd_info, + enabled=deployed.enabled, ) @@ -317,6 +320,7 @@ def _service_from_spec(name: str, svc: SystemdDeployment, config: object) -> Ser systemd=systemd_info, program=svc.program, source=source, + enabled=svc.enabled, ) @@ -333,6 +337,7 @@ def _job_from_deployed(name: str, deployed: object) -> JobSummary: schedule=deployed.schedule, managed=deployed.managed, systemd=systemd_info, + enabled=deployed.enabled, ) @@ -362,6 +367,7 @@ def _job_from_spec(name: str, job: SystemdDeployment, config: object) -> JobSumm systemd=systemd_info, program=job.program, source=source, + enabled=job.enabled, ) diff --git a/castle-api/src/castle_api/services.py b/castle-api/src/castle_api/services.py index f77d2d7..57b2a33 100644 --- a/castle-api/src/castle_api/services.py +++ b/castle-api/src/castle_api/services.py @@ -153,19 +153,12 @@ def get_unit(name: str) -> dict[str, str | None]: 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") 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") diff --git a/castle-api/tests/conftest.py b/castle-api/tests/conftest.py index 907ca96..a5a99a1 100644 --- a/castle-api/tests/conftest.py +++ b/castle-api/tests/conftest.py @@ -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, "nodes.get_registry": nodes_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, } @@ -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"] nodes_mod.get_registry = originals["nodes.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"] diff --git a/castle-api/tests/test_health.py b/castle-api/tests/test_health.py index 0cea79c..2af7c57 100644 --- a/castle-api/tests/test_health.py +++ b/castle-api/tests/test_health.py @@ -99,6 +99,13 @@ class TestServicesList: svc = next(s for s in data if s["id"] == "test-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: """ServiceSummary does not have installed field.""" response = client.get("/services")