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

@@ -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,
)
)