diff --git a/app/src/pages/ProgramDetail.tsx b/app/src/pages/ProgramDetail.tsx index 8faed97..ab458cf 100644 --- a/app/src/pages/ProgramDetail.tsx +++ b/app/src/pages/ProgramDetail.tsx @@ -85,10 +85,12 @@ export function ProgramDetailPage() { {runnerLabel(component.runner)} )} - {component.installed !== null && ( + {component.active !== null && ( <> - Installed - {component.installed ? "Yes" : "No"} + Active + + {component.active ? "● active" : "○ inactive"} + )} diff --git a/app/src/types/index.ts b/app/src/types/index.ts index f3bf1c8..e26efda 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -51,6 +51,7 @@ export interface ProgramSummary { commands: Record | null system_dependencies: string[] installed: boolean | null + active: boolean | null actions: string[] node: string | null } @@ -83,6 +84,7 @@ export interface ComponentSummary { system_dependencies: string[] schedule: string | null installed: boolean | null + active: boolean | null node: string | null } diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index 22abd88..a954503 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -33,6 +33,7 @@ class ComponentSummary(BaseModel): system_dependencies: list[str] = [] schedule: str | None = None installed: bool | None = None + active: bool | None = None # uniform lifecycle state (on PATH / running / served) node: str | None = None @@ -99,6 +100,7 @@ class ProgramSummary(BaseModel): commands: dict[str, list[list[str]]] | None = None system_dependencies: list[str] = [] installed: bool | None = None + active: bool | None = None # uniform lifecycle state (on PATH / running / served) actions: list[str] = [] node: str | None = None diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index 0899eca..4a560b7 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -342,6 +342,13 @@ def _program_from_spec( if comp.source and (comp.stack or comp.commands): installed = shutil.which(name) is not None + # Uniform lifecycle state (on PATH / running / served) — needs full config. + active: bool | None = None + if config is not None: + from castle_core.lifecycle import is_active + + active = is_active(name, config) + return ProgramSummary( id=name, description=comp.description, @@ -355,6 +362,7 @@ def _program_from_spec( commands=_declared_commands_dict(comp), system_dependencies=comp.system_dependencies, installed=installed, + active=active, actions=available_actions(comp), ) diff --git a/cli/src/castle_cli/commands/dev.py b/cli/src/castle_cli/commands/dev.py index 6e92e8d..1aedbd2 100644 --- a/cli/src/castle_cli/commands/dev.py +++ b/cli/src/castle_cli/commands/dev.py @@ -85,9 +85,40 @@ def run_check(args: argparse.Namespace) -> int: return run_verb(args, "check") +def _lifecycle(config: CastleConfig, name: str, deactivate: bool) -> bool: + """Activate or deactivate a single program. Returns True on success.""" + from castle_core.lifecycle import activate + from castle_core.lifecycle import deactivate as do_deactivate + + if name not in config.programs and name not in config.services and name not in config.jobs: + print(f"Unknown program: {name}") + return False + verb = "deactivate" if deactivate else "activate" + print(f"\n{'─' * 40}\n {verb}: {name}\n{'─' * 40}") + fn = do_deactivate if deactivate else activate + result = asyncio.run(fn(name, config, config.root)) + if result.output: + print(result.output) + return result.status == "ok" + + def run_install(args: argparse.Namespace) -> int: - return run_verb(args, "install") + """Activate (install/deploy/serve) a program — verb word kept, meaning unified.""" + config = load_config() + if getattr(args, "project", None): + return 0 if _lifecycle(config, args.project, deactivate=False) else 1 + ok = all( + _lifecycle(config, name, deactivate=False) + for name, comp in config.programs.items() + if comp.source + ) + return 0 if ok else 1 def run_uninstall(args: argparse.Namespace) -> int: - return run_verb(args, "uninstall") + """Deactivate (uninstall/stop/unpublish) a program.""" + config = load_config() + if getattr(args, "project", None): + return 0 if _lifecycle(config, args.project, deactivate=True) else 1 + print("Refusing to deactivate all programs; name a target.") + return 1 diff --git a/cli/src/castle_cli/commands/service.py b/cli/src/castle_cli/commands/service.py index 68057e3..86d3bc4 100644 --- a/cli/src/castle_cli/commands/service.py +++ b/cli/src/castle_cli/commands/service.py @@ -78,103 +78,21 @@ def run_services(args: argparse.Namespace) -> int: def _service_enable(config: CastleConfig, name: str) -> int: """Enable and start a single service (or timer for scheduled jobs).""" - if not REGISTRY_PATH.exists(): - print("Error: no registry found. Run 'castle deploy' first.") - return 1 - - registry = load_registry() - if name not in registry.deployed: - print(f"Error: '{name}' not found in registry. Run 'castle deploy' first.") - return 1 - - deployed = registry.deployed[name] - if not deployed.managed: - print(f"Error: '{name}' is not a managed service") - return 1 + from castle_core.lifecycle import enable_service ensure_dirs() - - # Get systemd spec from config (services or jobs) - systemd_spec = None - if name in config.services: - svc = config.services[name] - if svc.manage and svc.manage.systemd: - systemd_spec = svc.manage.systemd - elif name in config.jobs: - job = config.jobs[name] - if job.manage and job.manage.systemd: - systemd_spec = job.manage.systemd - - # Generate and install the service unit from registry - svc_unit = unit_name(name) - svc_content = generate_unit_from_deployed(name, deployed, systemd_spec) - _install_unit(svc_unit, svc_content) - - # Check for timer (jobs have schedule) - if deployed.schedule: - timer_content = generate_timer( - name, - schedule=deployed.schedule, - description=deployed.description, - ) - tmr_unit = timer_name(name) - _install_unit(tmr_unit, timer_content) - - print(f"Enabling {name} (scheduled)...") - subprocess.run(["systemctl", "--user", "enable", tmr_unit], check=False) - subprocess.run(["systemctl", "--user", "start", tmr_unit], check=False) - - result = subprocess.run( - ["systemctl", "--user", "is-active", tmr_unit], - capture_output=True, - text=True, - ) - status = result.stdout.strip() - if status in ("active", "waiting"): - print(f" {name}: timer active") - else: - print(f" {name}: timer {status}") - else: - print(f"Enabling {name}...") - subprocess.run(["systemctl", "--user", "enable", svc_unit], check=False) - subprocess.run(["systemctl", "--user", "start", svc_unit], check=False) - - result = subprocess.run( - ["systemctl", "--user", "is-active", svc_unit], - capture_output=True, - text=True, - ) - status = result.stdout.strip() - port_str = "" - if deployed.port: - port_str = f" (port {deployed.port})" - if status == "active": - print(f" {name}: running{port_str}") - else: - print(f" {name}: {status}") - print(f" Check logs: journalctl --user -u {svc_unit}") - - return 0 + result = enable_service(name, config) + print(result.output) + return 0 if result.status == "ok" else 1 def _service_disable(name: str) -> int: """Stop and disable a service (and timer if present).""" - svc_unit = unit_name(name) - tmr_unit = timer_name(name) + from castle_core.lifecycle import disable_service print(f"Disabling {name}...") - - timer_path = SYSTEMD_USER_DIR / tmr_unit - if timer_path.exists(): - subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False) - subprocess.run(["systemctl", "--user", "disable", tmr_unit], check=False) - _remove_unit(tmr_unit) - - subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False) - subprocess.run(["systemctl", "--user", "disable", svc_unit], check=False) - _remove_unit(svc_unit) - print(f" {name}: disabled") - + result = disable_service(name) + print(f" {result.output}") return 0 diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 9bc1003..7f568bf 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -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-` 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) diff --git a/core/src/castle_core/lifecycle.py b/core/src/castle_core/lifecycle.py new file mode 100644 index 0000000..a581ebd --- /dev/null +++ b/core/src/castle_core/lifecycle.py @@ -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") diff --git a/core/tests/test_deploy_prune.py b/core/tests/test_deploy_prune.py new file mode 100644 index 0000000..a0414dc --- /dev/null +++ b/core/tests/test_deploy_prune.py @@ -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() diff --git a/core/tests/test_lifecycle.py b/core/tests/test_lifecycle.py new file mode 100644 index 0000000..69a4af2 --- /dev/null +++ b/core/tests/test_lifecycle.py @@ -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