Runtime half: convergent deploy + unified active lifecycle
Convergent deploy (prefix-based prune): castle deploy now removes orphaned castle-* systemd units/timers no longer in the registry. Full deploy only; partial (--name) deploys preserve siblings. Fixes the orphaned-timer / stale- path drift class. Container runner: derive --name from the service name (castle-<name>) instead of the image name; skip the <PREFIX>_DATA_DIR env injection for containers (avoids colliding with image env namespaces like NEO4J_*). Unified active lifecycle (core/lifecycle.py): install/uninstall keep their names but become activate/deactivate, dispatching by behavior — tool→PATH, daemon/self-serving-frontend/job→systemd, static frontend→served. is_active reports a uniform state (PATH-independent for tools). The CLI install/uninstall and service enable/disable route through the extracted core. API + dashboard: surface a uniform `active` state on programs alongside `installed`; ProgramDetail shows active/inactive. Tests: test_deploy_prune (6), test_lifecycle (5). 168 tests pass; lint clean.
This commit is contained in:
@@ -109,6 +109,10 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
|
||||
_generate_systemd_units(config, registry)
|
||||
result.messages.append(f"Systemd units written: {SYSTEMD_USER_DIR}")
|
||||
|
||||
# Converge: prune orphan units (full deploy only — partial deploys preserve siblings)
|
||||
if target_name is None:
|
||||
_prune_orphans(registry, result.messages)
|
||||
|
||||
# Generate Caddyfile from registry
|
||||
caddyfile_path = SPECS_DIR / "Caddyfile"
|
||||
caddyfile_content = generate_caddyfile_from_registry(registry)
|
||||
@@ -154,11 +158,13 @@ def _build_deployed_service(
|
||||
prefix = _env_prefix(config_key)
|
||||
env: dict[str, str] = {}
|
||||
|
||||
# Data dir convention (for managed services)
|
||||
# Data dir convention (for managed services). Skipped for container runners:
|
||||
# they use volume mounts, not a data-dir env var, and the injected name can
|
||||
# collide with an image's own env namespace (e.g. neo4j claims NEO4J_*).
|
||||
managed = run.runner != "remote"
|
||||
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
|
||||
managed = False
|
||||
if managed:
|
||||
if managed and run.runner != "container":
|
||||
env[f"{prefix}_DATA_DIR"] = str(DATA_DIR / config_key)
|
||||
|
||||
# Port convention (if exposed)
|
||||
@@ -180,7 +186,7 @@ def _build_deployed_service(
|
||||
_ensure_python_tool(config, svc.component, messages)
|
||||
|
||||
# Build run_cmd
|
||||
run_cmd = _build_run_cmd(run, env, messages)
|
||||
run_cmd = _build_run_cmd(name, run, env, messages)
|
||||
|
||||
# Proxy path
|
||||
proxy_path = None
|
||||
@@ -228,7 +234,7 @@ def _build_deployed_job(
|
||||
|
||||
env = resolve_env_vars(env)
|
||||
_ensure_python_tool(config, job.component, messages)
|
||||
run_cmd = _build_run_cmd(run, env, messages)
|
||||
run_cmd = _build_run_cmd(name, run, env, messages)
|
||||
|
||||
stack = None
|
||||
if job.component and job.component in config.programs:
|
||||
@@ -297,7 +303,7 @@ def _ensure_python_tool(
|
||||
messages.append(f"Installed {component}")
|
||||
|
||||
|
||||
def _build_run_cmd(run: object, env: dict[str, str], messages: list[str]) -> list[str]:
|
||||
def _build_run_cmd(name: str, run: object, env: dict[str, str], messages: list[str]) -> list[str]:
|
||||
"""Build a run command list from a RunSpec."""
|
||||
match run.runner: # type: ignore[union-attr]
|
||||
case "python":
|
||||
@@ -319,8 +325,9 @@ def _build_run_cmd(run: object, env: dict[str, str], messages: list[str]) -> lis
|
||||
return cmd
|
||||
case "container":
|
||||
runtime = shutil.which("docker") or shutil.which("podman") or "docker"
|
||||
image_name = run.image.split("/")[-1].split(":")[0] # type: ignore[union-attr]
|
||||
cmd = [runtime, "run", "--rm", f"--name=castle-{image_name}"]
|
||||
# Container name derives from the SERVICE name (matches the systemd unit),
|
||||
# not the image name — so `castle-<service>` is stable and collision-free.
|
||||
cmd = [runtime, "run", "--rm", f"--name=castle-{name}"]
|
||||
for container_port, host_port in run.ports.items(): # type: ignore[union-attr]
|
||||
cmd.extend(["-p", f"{host_port}:{container_port}"])
|
||||
for vol in run.volumes: # type: ignore[union-attr]
|
||||
@@ -378,6 +385,46 @@ def _copy_app_static(config: CastleConfig, messages: list[str]) -> None:
|
||||
messages.append(f"Static: {src} → {dest}")
|
||||
|
||||
|
||||
def _desired_unit_files(registry: NodeRegistry) -> set[str]:
|
||||
"""Exact set of unit filenames that should exist on disk for this registry."""
|
||||
files: set[str] = set()
|
||||
for name, deployed in registry.deployed.items():
|
||||
if not deployed.managed:
|
||||
continue
|
||||
files.add(unit_name(name))
|
||||
if deployed.schedule:
|
||||
files.add(timer_name(name))
|
||||
return files
|
||||
|
||||
|
||||
def _teardown_unit(unit_file: str, messages: list[str]) -> None:
|
||||
"""Stop, disable, and unlink a systemd unit file. Caller batches daemon-reload."""
|
||||
path = SYSTEMD_USER_DIR / unit_file
|
||||
if not path.exists():
|
||||
return
|
||||
subprocess.run(["systemctl", "--user", "stop", unit_file], check=False)
|
||||
subprocess.run(["systemctl", "--user", "disable", unit_file], check=False)
|
||||
path.unlink()
|
||||
messages.append(f"Pruned orphan unit: {unit_file}")
|
||||
|
||||
|
||||
def _prune_orphans(registry: NodeRegistry, messages: list[str]) -> None:
|
||||
"""Remove castle-* units no longer backed by a managed registry entry.
|
||||
|
||||
The `castle-` prefix is the ownership namespace: any castle-*.service/.timer on
|
||||
disk that isn't in the desired set is an orphan (a removed/unmanaged/unscheduled
|
||||
component) and is torn down. Only call on a FULL deploy — the desired set must
|
||||
reflect the whole registry, not a single --target.
|
||||
"""
|
||||
desired = _desired_unit_files(registry)
|
||||
if not SYSTEMD_USER_DIR.is_dir():
|
||||
return
|
||||
for pattern in ("castle-*.service", "castle-*.timer"):
|
||||
for path in sorted(SYSTEMD_USER_DIR.glob(pattern)):
|
||||
if path.name not in desired:
|
||||
_teardown_unit(path.name, messages)
|
||||
|
||||
|
||||
def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> None:
|
||||
"""Generate systemd units from the registry."""
|
||||
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
166
core/src/castle_core/lifecycle.py
Normal file
166
core/src/castle_core/lifecycle.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Unified program lifecycle — a program is `active` when it's reachable in its mode.
|
||||
|
||||
`activate`/`deactivate` dispatch the right mechanism by behavior:
|
||||
- tool → on PATH (uv tool install / uninstall)
|
||||
- daemon / self-serving frontend / job → systemd enable+start / stop+disable
|
||||
- static frontend → served via the gateway (build + content present)
|
||||
|
||||
`is_active` reports the uniform state regardless of mechanism. The CLI/API verbs
|
||||
`install`/`uninstall` route through here (the words stay; the meaning is activate).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from castle_core.config import CONTENT_DIR, CastleConfig
|
||||
from castle_core.generators.systemd import (
|
||||
SYSTEMD_USER_DIR,
|
||||
generate_timer,
|
||||
generate_unit_from_deployed,
|
||||
timer_name,
|
||||
unit_name,
|
||||
)
|
||||
from castle_core.registry import REGISTRY_PATH, load_registry
|
||||
from castle_core.stacks import ActionResult, run_action
|
||||
|
||||
|
||||
def _systemctl_active(unit: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", unit], capture_output=True, text=True
|
||||
)
|
||||
return result.stdout.strip() in ("active", "waiting")
|
||||
|
||||
|
||||
def _on_path(name: str) -> bool:
|
||||
"""Whether a tool's console script is installed (PATH-independent).
|
||||
|
||||
uv tool install places scripts in ~/.local/bin; checking it directly avoids
|
||||
depending on the caller's PATH (the API/CLI may not have it exported).
|
||||
"""
|
||||
if shutil.which(name) is not None:
|
||||
return True
|
||||
return (Path.home() / ".local" / "bin" / name).exists()
|
||||
|
||||
|
||||
def _is_static_frontend(name: str, config: CastleConfig) -> bool:
|
||||
"""A frontend with no service/job of its own — served as static assets."""
|
||||
comp = config.programs.get(name)
|
||||
return (
|
||||
comp is not None
|
||||
and comp.behavior == "frontend"
|
||||
and name not in config.services
|
||||
and name not in config.jobs
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def is_active(name: str, config: CastleConfig) -> bool:
|
||||
"""Whether a program is reachable in its mode (uniform across behaviors)."""
|
||||
if name in config.services:
|
||||
return _systemctl_active(unit_name(name))
|
||||
if name in config.jobs:
|
||||
return _systemctl_active(timer_name(name))
|
||||
if _is_static_frontend(name, config):
|
||||
return (CONTENT_DIR / name).is_dir()
|
||||
comp = config.programs.get(name)
|
||||
if comp is not None and comp.source:
|
||||
return _on_path(name)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Systemd enable/disable (extracted core; the CLI service command calls these)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def enable_service(name: str, config: CastleConfig) -> ActionResult:
|
||||
"""Generate+install the unit (and timer) from the registry, enable and start it."""
|
||||
if not REGISTRY_PATH.exists():
|
||||
return ActionResult(name, "activate", "error", "No registry. Run 'castle deploy' first.")
|
||||
registry = load_registry()
|
||||
if name not in registry.deployed:
|
||||
return ActionResult(name, "activate", "error", f"'{name}' not in registry; run 'castle deploy'.")
|
||||
deployed = registry.deployed[name]
|
||||
if not deployed.managed:
|
||||
return ActionResult(name, "activate", "error", f"'{name}' is not a managed service.")
|
||||
|
||||
systemd_spec = None
|
||||
if name in config.services and config.services[name].manage:
|
||||
systemd_spec = config.services[name].manage.systemd
|
||||
elif name in config.jobs and config.jobs[name].manage:
|
||||
systemd_spec = config.jobs[name].manage.systemd
|
||||
|
||||
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
|
||||
svc_unit = unit_name(name)
|
||||
(SYSTEMD_USER_DIR / svc_unit).write_text(generate_unit_from_deployed(name, deployed, systemd_spec))
|
||||
primary = svc_unit
|
||||
if deployed.schedule:
|
||||
tmr_unit = timer_name(name)
|
||||
(SYSTEMD_USER_DIR / tmr_unit).write_text(
|
||||
generate_timer(name, schedule=deployed.schedule, description=deployed.description)
|
||||
)
|
||||
primary = tmr_unit
|
||||
|
||||
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
||||
subprocess.run(["systemctl", "--user", "enable", primary], check=False)
|
||||
subprocess.run(["systemctl", "--user", "start", primary], check=False)
|
||||
status = "active" if _systemctl_active(primary) else "inactive"
|
||||
return ActionResult(name, "activate", "ok" if status == "active" else "error",
|
||||
f"{name}: {status}")
|
||||
|
||||
|
||||
def disable_service(name: str) -> ActionResult:
|
||||
"""Stop, disable, and remove the unit (and timer) for a service/job."""
|
||||
for unit in (timer_name(name), unit_name(name)):
|
||||
path = SYSTEMD_USER_DIR / unit
|
||||
if path.exists():
|
||||
subprocess.run(["systemctl", "--user", "stop", unit], check=False)
|
||||
subprocess.run(["systemctl", "--user", "disable", unit], check=False)
|
||||
path.unlink()
|
||||
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
||||
return ActionResult(name, "deactivate", "ok", f"{name}: deactivated")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Activate / deactivate (dispatch by behavior)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult:
|
||||
"""Make a program reachable in its mode."""
|
||||
comp = config.programs.get(name)
|
||||
|
||||
# Process-backed: daemon, self-serving frontend, or job.
|
||||
if name in config.services or name in config.jobs:
|
||||
# Ensure the program's binary is on PATH first (python programs).
|
||||
if comp is not None and (comp.stack or comp.commands):
|
||||
res = await run_action("install", name, comp, root)
|
||||
if res.status != "ok":
|
||||
return res
|
||||
return enable_service(name, config)
|
||||
|
||||
# Static frontend: build the assets (publish handled by the build/serve path).
|
||||
if comp is not None and comp.behavior == "frontend":
|
||||
return await run_action("install", name, comp, root)
|
||||
|
||||
# Tool: install to PATH.
|
||||
if comp is not None:
|
||||
return await run_action("install", name, comp, root)
|
||||
return ActionResult(name, "activate", "error", f"'{name}' not found")
|
||||
|
||||
|
||||
async def deactivate(name: str, config: CastleConfig, root: Path) -> ActionResult:
|
||||
"""Take a program offline in its mode."""
|
||||
comp = config.programs.get(name)
|
||||
if name in config.services or name in config.jobs:
|
||||
return disable_service(name)
|
||||
if comp is not None:
|
||||
return await run_action("uninstall", name, comp, root)
|
||||
return ActionResult(name, "deactivate", "error", f"'{name}' not found")
|
||||
90
core/tests/test_deploy_prune.py
Normal file
90
core/tests/test_deploy_prune.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Tests for convergent-deploy orphan pruning (prefix-based)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from castle_core import deploy as deploy_mod
|
||||
from castle_core.deploy import _desired_unit_files, _prune_orphans
|
||||
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
|
||||
|
||||
|
||||
def _svc(managed: bool = True, schedule: str | None = None) -> DeployedComponent:
|
||||
return DeployedComponent(
|
||||
runner="python",
|
||||
run_cmd=["x"],
|
||||
env={},
|
||||
managed=managed,
|
||||
schedule=schedule,
|
||||
)
|
||||
|
||||
|
||||
def _registry(**deployed: DeployedComponent) -> NodeRegistry:
|
||||
return NodeRegistry(node=NodeConfig(castle_root="/x", gateway_port=9000), deployed=dict(deployed))
|
||||
|
||||
|
||||
def _touch(d: Path, *names: str) -> None:
|
||||
for n in names:
|
||||
(d / n).write_text("[Unit]\n")
|
||||
|
||||
|
||||
class TestDesiredUnitFiles:
|
||||
def test_managed_service_yields_service_file(self) -> None:
|
||||
reg = _registry(foo=_svc())
|
||||
assert _desired_unit_files(reg) == {"castle-foo.service"}
|
||||
|
||||
def test_scheduled_job_yields_service_and_timer(self) -> None:
|
||||
reg = _registry(job=_svc(schedule="0 2 * * *"))
|
||||
assert _desired_unit_files(reg) == {"castle-job.service", "castle-job.timer"}
|
||||
|
||||
def test_unmanaged_excluded(self) -> None:
|
||||
reg = _registry(foo=_svc(managed=False))
|
||||
assert _desired_unit_files(reg) == set()
|
||||
|
||||
|
||||
class TestPruneOrphans:
|
||||
def test_removes_orphan_keeps_desired(self, tmp_path: Path) -> None:
|
||||
units = tmp_path / "systemd"
|
||||
units.mkdir()
|
||||
# keep-me is in the registry; gone is not; gone.timer is also orphaned
|
||||
_touch(units, "castle-keep.service", "castle-gone.service", "castle-gone.timer")
|
||||
reg = _registry(keep=_svc())
|
||||
msgs: list[str] = []
|
||||
with (
|
||||
patch.object(deploy_mod, "SYSTEMD_USER_DIR", units),
|
||||
patch.object(deploy_mod.subprocess, "run") as mock_run,
|
||||
):
|
||||
_prune_orphans(reg, msgs)
|
||||
assert (units / "castle-keep.service").exists()
|
||||
assert not (units / "castle-gone.service").exists()
|
||||
assert not (units / "castle-gone.timer").exists()
|
||||
# stop + disable were invoked for each removed unit
|
||||
assert mock_run.call_count >= 2
|
||||
assert any("castle-gone.service" in m for m in msgs)
|
||||
|
||||
def test_unscheduled_job_drops_stale_timer_keeps_service(self, tmp_path: Path) -> None:
|
||||
units = tmp_path / "systemd"
|
||||
units.mkdir()
|
||||
_touch(units, "castle-job.service", "castle-job.timer")
|
||||
# job is still managed but no longer scheduled → its timer is now an orphan
|
||||
reg = _registry(job=_svc(schedule=None))
|
||||
with (
|
||||
patch.object(deploy_mod, "SYSTEMD_USER_DIR", units),
|
||||
patch.object(deploy_mod.subprocess, "run"),
|
||||
):
|
||||
_prune_orphans(reg, [])
|
||||
assert (units / "castle-job.service").exists()
|
||||
assert not (units / "castle-job.timer").exists()
|
||||
|
||||
def test_demoted_to_unmanaged_is_pruned(self, tmp_path: Path) -> None:
|
||||
units = tmp_path / "systemd"
|
||||
units.mkdir()
|
||||
_touch(units, "castle-foo.service")
|
||||
reg = _registry(foo=_svc(managed=False)) # still in registry but unmanaged
|
||||
with (
|
||||
patch.object(deploy_mod, "SYSTEMD_USER_DIR", units),
|
||||
patch.object(deploy_mod.subprocess, "run"),
|
||||
):
|
||||
_prune_orphans(reg, [])
|
||||
assert not (units / "castle-foo.service").exists()
|
||||
47
core/tests/test_lifecycle.py
Normal file
47
core/tests/test_lifecycle.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Tests for the unified active lifecycle (is_active dispatch)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from castle_core import lifecycle
|
||||
from castle_core.config import load_config
|
||||
|
||||
|
||||
class TestIsActive:
|
||||
def test_service_uses_systemctl(self, castle_root: Path) -> None:
|
||||
config = load_config(castle_root)
|
||||
with patch.object(lifecycle, "_systemctl_active", return_value=True) as mock:
|
||||
assert lifecycle.is_active("test-svc", config) is True
|
||||
mock.assert_called_once_with("castle-test-svc.service")
|
||||
|
||||
def test_job_uses_timer(self, castle_root: Path) -> None:
|
||||
config = load_config(castle_root)
|
||||
with patch.object(lifecycle, "_systemctl_active", return_value=True) as mock:
|
||||
assert lifecycle.is_active("test-job", config) is True
|
||||
mock.assert_called_once_with("castle-test-job.timer")
|
||||
|
||||
def test_tool_checks_path(self, castle_root: Path) -> None:
|
||||
config = load_config(castle_root)
|
||||
# give the tool a source so the tool branch is reachable
|
||||
config.programs["test-tool"].source = "/tmp/test-tool"
|
||||
with patch.object(lifecycle, "_on_path", return_value=True) as mock:
|
||||
assert lifecycle.is_active("test-tool", config) is True
|
||||
mock.assert_called_once_with("test-tool")
|
||||
|
||||
def test_unknown_is_inactive(self, castle_root: Path) -> None:
|
||||
config = load_config(castle_root)
|
||||
assert lifecycle.is_active("does-not-exist", config) is False
|
||||
|
||||
def test_static_frontend_checks_content_dir(self, castle_root: Path, tmp_path: Path) -> None:
|
||||
config = load_config(castle_root)
|
||||
config.programs["fe"] = config.programs["test-tool"].model_copy(
|
||||
update={"id": "fe", "behavior": "frontend", "source": "/tmp/fe"}
|
||||
)
|
||||
content = tmp_path / "content"
|
||||
(content / "fe").mkdir(parents=True)
|
||||
with patch.object(lifecycle, "CONTENT_DIR", content):
|
||||
assert lifecycle.is_active("fe", config) is True
|
||||
with patch.object(lifecycle, "CONTENT_DIR", tmp_path / "empty"):
|
||||
assert lifecycle.is_active("fe", config) is False
|
||||
Reference in New Issue
Block a user