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.
75 lines
3.3 KiB
Python
75 lines
3.3 KiB
Python
"""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_path_deployment_checks_path(self, castle_root: Path) -> None:
|
|
# A `manager: path` deployment (a tool) is active when on PATH.
|
|
from castle_core.manifest import PathDeployment, ProgramSpec
|
|
|
|
config = load_config(castle_root)
|
|
config.programs["mytool"] = ProgramSpec(id="mytool", source="/tmp/mytool")
|
|
config.deployments["mytool"] = PathDeployment(manager="path", program="mytool")
|
|
with patch.object(lifecycle, "_on_path", return_value=True) as mock:
|
|
assert lifecycle.is_active("mytool", config) is True
|
|
mock.assert_called_once_with("mytool")
|
|
|
|
def test_remote_deployment_is_active(self, castle_root: Path) -> None:
|
|
# A remote deployment has no local process; the manager is `none` → available.
|
|
from castle_core.manifest import RemoteDeployment
|
|
|
|
config = load_config(castle_root)
|
|
config.deployments["ext"] = RemoteDeployment(
|
|
manager="none", program="ext", base_url="http://x"
|
|
)
|
|
assert lifecycle.is_active("ext", config) is True
|
|
|
|
def test_static_deployment_active_when_dist_built(
|
|
self, castle_root: Path, tmp_path: Path
|
|
) -> None:
|
|
# A static (caddy) deployment is active once its served dir exists.
|
|
from castle_core.manifest import CaddyDeployment, ProgramSpec
|
|
|
|
config = load_config(castle_root)
|
|
repo = tmp_path / "fe"
|
|
config.programs["fe"] = ProgramSpec(id="fe", source=str(repo))
|
|
config.deployments["fe"] = CaddyDeployment(
|
|
manager="caddy", program="fe", root="dist"
|
|
)
|
|
# No dist yet → inactive (caddy manager checks the served dir)
|
|
assert lifecycle.is_active("fe", config) is False
|
|
# Built dist → served in place → active
|
|
(repo / "dist").mkdir(parents=True)
|
|
assert lifecycle.is_active("fe", config) is True
|