Files
wild-pc/core/tests/test_deploy_prune.py
Paul Payne 317232ca6a Manager-first deployment model: split runner, merge service/job, frontend→static
Replace the conflated `runner` axis with two orthogonal ones: `manager`
(systemd|caddy|path|none) — who supervises/realizes a deployment — and, for
systemd only, a nested `launcher` (python|command|container|compose|node) — how
the process starts. ServiceSpec and JobSpec collapse into one manager-
discriminated DeploymentSpec union (Systemd/Caddy/Path/Remote); the services/
and jobs/ config dirs collapse into one deployments/ dir. The human "kind"
(service|job|tool|static|reference) is fully derived (kind_for), never stored —
the frontend kind is renamed static. behavior is gone.

- core: DeploymentSpec union + LaunchSpec + kind_for; legacy-aware loader
  normalizes old runner shapes; CastleConfig.deployments with derived
  services/jobs/tools views; registry.Deployment carries manager/launcher/kind.
- cli: service/job/tool as filtered views + a deployment group; --behavior→--kind,
  create --runner→--launcher; lifecycle dispatches over config.deployments.
- castle-api: /deployments primary with /services,/jobs as views; summaries
  derive kind; PUT/DELETE /config/deployments/{name} (services/jobs aliased).
- app: KindBadge, frontend→static everywhere, pick-a-kind creation wizard,
  per-kind config editors.
- docs: single deployments/ layout, manager/launcher, static kind throughout.

Live migration verified byte-identical: regenerated Caddyfile and every unit
ExecStart line unchanged, so nothing restarted. Suites: core 124, cli 25,
castle-api 55; dashboard build + type-check clean.
2026-07-01 10:23:03 -07:00

92 lines
3.4 KiB
Python

"""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 Deployment, NodeConfig, NodeRegistry
def _svc(managed: bool = True, schedule: str | None = None) -> Deployment:
return Deployment(
manager="systemd",
launcher="python",
run_cmd=["x"],
env={},
managed=managed,
schedule=schedule,
)
def _registry(**deployed: Deployment) -> 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()