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