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:
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user