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.
This commit is contained in:
2026-07-01 10:23:03 -07:00
parent 00e8d58c6a
commit 317232ca6a
73 changed files with 1525 additions and 1442 deletions

View File

@@ -38,7 +38,6 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"programs": {
"test-tool": {
"description": "Test tool",
"behavior": "tool",
},
},
"services": {

View File

@@ -10,13 +10,14 @@ from castle_core.generators.caddyfile import (
generate_caddyfile_from_registry,
)
from castle_core.manifest import (
CaddyDeployment,
DeploymentSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
ProgramSpec,
RunPython,
RunStatic,
ServiceSpec,
SystemdDeployment,
)
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
@@ -52,10 +53,14 @@ def _make_registry(
)
def _dep(port: int, *, expose: bool, name: str | None = None, runner: str = "python") -> Deployment:
def _dep(port: int, *, expose: bool, name: str | None = None, launcher: str = "python") -> Deployment:
"""A deployed service; exposed at <name>.<domain> when expose=True."""
return Deployment(
runner=runner, run_cmd=["x"], port=port, subdomain=(name if expose else None)
manager="systemd",
launcher=launcher,
run_cmd=["x"],
port=port,
subdomain=(name if expose else None),
)
@@ -109,9 +114,9 @@ class TestAcmeMode:
import castle_core.config as config_mod
cfg = _config(
services={
"castle": ServiceSpec(
program="castle", run=RunStatic(runner="static", root="dist")
deployments={
"castle": CaddyDeployment(
manager="caddy", program="castle", root="dist"
)
},
programs={"castle": ProgramSpec(source="/data/repos/castle/app")},
@@ -144,24 +149,24 @@ class TestOffMode:
assert "litellm" not in cf # no subdomains without a domain
def _service(port: int, *, expose: bool) -> ServiceSpec:
return ServiceSpec(
run=RunPython(runner="python", program="svc"),
def _service(port: int, *, expose: bool) -> SystemdDeployment:
return SystemdDeployment(
manager="systemd",
run=RunPython(launcher="python", program="svc"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=port))),
proxy=expose,
)
def _config(
services: dict[str, ServiceSpec], programs: dict[str, ProgramSpec] | None = None
deployments: dict[str, DeploymentSpec], programs: dict[str, ProgramSpec] | None = None
) -> CastleConfig:
return CastleConfig(
root=None, # type: ignore[arg-type]
gateway=GatewayConfig(port=9000),
repo=None,
programs=programs or {},
services=services,
jobs={},
deployments=deployments,
)

View File

@@ -13,7 +13,7 @@ from castle_core.config import (
resolve_env_vars,
save_config,
)
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
from castle_core.manifest import ProgramSpec, SystemdDeployment
class TestLoadConfig:
@@ -32,8 +32,10 @@ class TestLoadConfig:
"""Each section produces the correct spec type."""
config = load_config(castle_root)
assert isinstance(config.programs["test-tool"], ProgramSpec)
assert isinstance(config.services["test-svc"], ServiceSpec)
assert isinstance(config.jobs["test-job"], JobSpec)
# Both a service and a job are systemd deployments; the kind (service/job)
# is derived from whether a schedule is present.
assert isinstance(config.services["test-svc"], SystemdDeployment)
assert isinstance(config.jobs["test-job"], SystemdDeployment)
def test_service_expose(self, castle_root: Path) -> None:
"""Service has correct expose spec."""
@@ -49,10 +51,10 @@ class TestLoadConfig:
assert svc.proxy is True # exposed at <name>.<gateway.domain>
def test_service_run_spec(self, castle_root: Path) -> None:
"""Service has correct RunSpec."""
"""Service has correct launch spec (legacy runner normalized to launcher)."""
config = load_config(castle_root)
svc = config.services["test-svc"]
assert svc.run.runner == "python"
assert svc.run.launcher == "python"
assert svc.run.program == "test-svc"
def test_service_component_ref(self, castle_root: Path) -> None:
@@ -114,25 +116,26 @@ class TestSaveConfig:
assert svc.manage.systemd is not None
def test_writes_directory_layout(self, castle_root: Path) -> None:
"""Save writes one file per resource under programs/services/jobs dirs."""
"""Save writes one file per resource under programs/ and one deployments/ dir."""
config = load_config(castle_root)
save_config(config)
assert (castle_root / "programs" / "test-tool.yaml").exists()
assert (castle_root / "services" / "test-svc.yaml").exists()
assert (castle_root / "jobs" / "test-job.yaml").exists()
# service, job, and tool all live under the single deployments/ dir now.
assert (castle_root / "deployments" / "test-svc.yaml").exists()
assert (castle_root / "deployments" / "test-job.yaml").exists()
assert (castle_root / "deployments" / "test-tool.yaml").exists()
# Global file holds gateway only, no resource sections
global_data = yaml.safe_load((castle_root / "castle.yaml").read_text())
assert global_data["gateway"]["port"] == 18000
assert "programs" not in global_data
assert "services" not in global_data
assert "jobs" not in global_data
assert "deployments" not in global_data
def test_delete_prunes_file(self, castle_root: Path) -> None:
"""Removing a resource and saving deletes its on-disk file."""
"""Removing a deployment and saving deletes its on-disk file."""
config = load_config(castle_root)
del config.services["test-svc"]
del config.deployments["test-svc"]
save_config(config)
assert not (castle_root / "services" / "test-svc.yaml").exists()
assert not (castle_root / "deployments" / "test-svc.yaml").exists()
config2 = load_config(castle_root)
assert "test-svc" not in config2.services
assert "test-tool" in config2.programs

View File

@@ -12,7 +12,8 @@ from castle_core.registry import Deployment, NodeConfig, NodeRegistry
def _svc(managed: bool = True, schedule: str | None = None) -> Deployment:
return Deployment(
runner="python",
manager="systemd",
launcher="python",
run_cmd=["x"],
env={},
managed=managed,

View File

@@ -11,7 +11,7 @@ from castle_core.manifest import RunCompose, RunContainer, RunNode, RunPython
def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
"""A python service with a real source dir launches via `uv run --project`."""
run = RunPython(runner="python", program="my-svc")
run = RunPython(launcher="python", program="my-svc")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"):
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
assert cmd == [
@@ -25,7 +25,7 @@ def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
def test_python_runner_appends_args(tmp_path: Path) -> None:
run = RunPython(runner="python", program="my-svc", args=["--flag", "x"])
run = RunPython(launcher="python", program="my-svc", args=["--flag", "x"])
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"):
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
assert cmd[-2:] == ["--flag", "x"]
@@ -34,7 +34,7 @@ def test_python_runner_appends_args(tmp_path: Path) -> None:
def test_python_runner_falls_back_to_path_without_source() -> None:
"""No resolvable source → PATH lookup of the script (no uv run)."""
run = RunPython(runner="python", program="my-svc")
run = RunPython(launcher="python", program="my-svc")
with patch(
"castle_core.deploy.shutil.which", return_value="/home/u/.local/bin/my-svc"
):
@@ -44,7 +44,7 @@ def test_python_runner_falls_back_to_path_without_source() -> None:
def test_python_runner_warns_when_unresolvable() -> None:
"""No source and not on PATH → a warning, bare program name as last resort."""
run = RunPython(runner="python", program="my-svc")
run = RunPython(launcher="python", program="my-svc")
messages: list[str] = []
with patch("castle_core.deploy.shutil.which", return_value=None):
cmd = _build_run_cmd("my-svc", run, {}, messages, source_dir=None)
@@ -54,7 +54,7 @@ def test_python_runner_warns_when_unresolvable() -> None:
def test_node_runner_bakes_source_dir(tmp_path: Path) -> None:
"""A node service runs the script in its source dir via `--dir` (no unit cwd)."""
run = RunNode(runner="node", script="gateway:watch:raw", package_manager="pnpm")
run = RunNode(launcher="node", script="gateway:watch:raw", package_manager="pnpm")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path)
assert cmd == ["/usr/bin/pnpm", "--dir", str(tmp_path), "run", "gateway:watch:raw"]
@@ -62,7 +62,7 @@ def test_node_runner_bakes_source_dir(tmp_path: Path) -> None:
def test_node_runner_appends_args(tmp_path: Path) -> None:
run = RunNode(
runner="node", script="start", package_manager="pnpm", args=["--port", "18789"]
launcher="node", script="start", package_manager="pnpm", args=["--port", "18789"]
)
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path)
@@ -79,7 +79,7 @@ def test_node_runner_appends_args(tmp_path: Path) -> None:
def test_node_runner_without_source_omits_dir() -> None:
"""No resolvable source → bare `pnpm run` (package manager still PATH-resolved)."""
run = RunNode(runner="node", script="start", package_manager="pnpm")
run = RunNode(launcher="node", script="start", package_manager="pnpm")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
cmd = _build_run_cmd("oc", run, {}, [], source_dir=None)
assert cmd == ["/usr/bin/pnpm", "run", "start"]
@@ -87,7 +87,7 @@ def test_node_runner_without_source_omits_dir() -> None:
def test_container_secrets_use_env_file_not_argv() -> None:
"""Secrets go through --env-file; plain vars stay as -e; no secret in argv."""
run = RunContainer(runner="container", image="img:latest", env={"PLAIN": "1"})
run = RunContainer(launcher="container", image="img:latest", env={"PLAIN": "1"})
env_file = Path("/home/u/.castle/secrets/env/castle-svc.service.env")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {"PORT": "9001"}, [], secret_env_file=env_file)
@@ -101,7 +101,7 @@ def test_container_secrets_use_env_file_not_argv() -> None:
def test_container_without_secrets_has_no_env_file() -> None:
run = RunContainer(runner="container", image="img:latest")
run = RunContainer(launcher="container", image="img:latest")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {}, [], secret_env_file=None)
assert "--env-file" not in cmd
@@ -109,7 +109,7 @@ def test_container_without_secrets_has_no_env_file() -> None:
def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None:
"""A compose service runs `docker compose -p <project> -f <abs-file> up`."""
run = RunCompose(runner="compose", file="docker-compose.yml")
run = RunCompose(launcher="compose", file="docker-compose.yml")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("supabase", run, {}, [], source_dir=tmp_path)
assert cmd == [
@@ -125,7 +125,7 @@ def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None:
def test_compose_runner_secrets_never_hit_argv(tmp_path: Path) -> None:
"""Compose reads env from the process (systemd), not --env-file/-e — argv is clean."""
run = RunCompose(runner="compose", file="docker-compose.yml")
run = RunCompose(launcher="compose", file="docker-compose.yml")
env_file = Path("/home/u/.castle/secrets/env/castle-supabase.service.env")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd(
@@ -139,7 +139,7 @@ def test_compose_runner_secrets_never_hit_argv(tmp_path: Path) -> None:
def test_compose_project_name_override(tmp_path: Path) -> None:
run = RunCompose(runner="compose", file="stack.yml", project_name="myproj")
run = RunCompose(launcher="compose", file="stack.yml", project_name="myproj")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("s", run, {}, [], source_dir=tmp_path)
assert cmd[:6] == [
@@ -149,7 +149,7 @@ def test_compose_project_name_override(tmp_path: Path) -> None:
def test_compose_stop_cmd_is_down(tmp_path: Path) -> None:
"""The teardown command mirrors up but ends in `down` (same project + file)."""
run = RunCompose(runner="compose", file="docker-compose.yml")
run = RunCompose(launcher="compose", file="docker-compose.yml")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
stop = _build_stop_cmd("supabase", run, tmp_path)
assert stop == [
@@ -164,5 +164,5 @@ def test_compose_stop_cmd_is_down(tmp_path: Path) -> None:
def test_non_compose_runner_has_no_stop_cmd(tmp_path: Path) -> None:
run = RunPython(runner="python", program="svc")
run = RunPython(launcher="python", program="svc")
assert _build_stop_cmd("svc", run, tmp_path) == []

View File

@@ -65,7 +65,7 @@ class TestRegistrySecretKeys:
node=NodeConfig(hostname="h"),
deployed={
"svc": Deployment(
runner="container",
manager="systemd", launcher="container",
run_cmd=["docker", "run"],
env={"PORT": "9001"},
secret_env_keys=["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],

View File

@@ -34,37 +34,38 @@ class TestIsActive:
config = load_config(castle_root)
assert lifecycle.is_active("does-not-exist", config) is False
def test_path_service_checks_path(self, castle_root: Path) -> None:
# A `runner: path` service (a tool) is active when on PATH.
from castle_core.manifest import ProgramSpec, RunPath, ServiceSpec, manager_for
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
assert manager_for("path") == "path"
config = load_config(castle_root)
config.programs["mytool"] = ProgramSpec(id="mytool", source="/tmp/mytool")
config.services["mytool"] = ServiceSpec(program="mytool", run=RunPath(runner="path"))
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_service_is_active(self, castle_root: Path) -> None:
# A remote service has no local process; the manager is `none` → available.
from castle_core.manifest import RunRemote, ServiceSpec
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.services["ext"] = ServiceSpec(
program="ext", run=RunRemote(runner="remote", base_url="http://x")
config.deployments["ext"] = RemoteDeployment(
manager="none", program="ext", base_url="http://x"
)
assert lifecycle.is_active("ext", config) is True
def test_static_service_active_when_dist_built(self, castle_root: Path, tmp_path: Path) -> None:
# A frontend is a `runner: static` service; active once its served dir exists.
from castle_core.manifest import ProgramSpec, RunStatic, ServiceSpec
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.services["fe"] = ServiceSpec(
program="fe", run=RunStatic(runner="static", root="dist")
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

View File

@@ -5,49 +5,49 @@ from __future__ import annotations
import pytest
from castle_core.manifest import (
BuildSpec,
ProgramSpec,
CaddyDeployment,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
JobSpec,
ManageSpec,
PathDeployment,
ProgramSpec,
RunCommand,
RunPython,
RunRemote,
ServiceSpec,
RemoteDeployment,
SystemdDeployment,
SystemdSpec,
kind_for,
)
class TestProgramSpec:
"""Tests for component (software catalog) model."""
"""Tests for program (software catalog) model."""
def test_minimal(self) -> None:
"""Minimal component just needs an id."""
"""Minimal program just needs an id."""
c = ProgramSpec(id="bare")
assert c.description is None
assert c.source is None
assert c.behavior is None
# `kind` is derived at load time; a bare spec has none.
assert c.kind is None
assert c.build is None
def test_tool_component(self) -> None:
"""Component with tool behavior and system_dependencies."""
def test_tool_program(self) -> None:
"""Program with source and system_dependencies."""
c = ProgramSpec(
id="my-tool",
description="A tool",
source="my-tool/",
behavior="tool",
system_dependencies=["pandoc"],
)
assert c.source == "my-tool/"
assert c.behavior == "tool"
assert c.system_dependencies == ["pandoc"]
def test_frontend_component(self) -> None:
"""Component with build spec."""
def test_frontend_program(self) -> None:
"""Program with build spec."""
c = ProgramSpec(
id="my-app",
behavior="frontend",
build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]),
)
assert c.build.outputs == ["dist/"]
@@ -63,109 +63,91 @@ class TestProgramSpec:
assert c.source_dir is None
class TestServiceSpec:
"""Tests for service (long-running daemon) model."""
class TestSystemdDeployment:
"""Tests for the systemd deployment (service or job)."""
def test_basic_service(self) -> None:
"""Service with run and expose."""
s = ServiceSpec(
"""A systemd deployment with a launcher and expose is a service."""
s = SystemdDeployment(
id="svc",
run=RunPython(runner="python", program="svc"),
manager="systemd",
run=RunPython(launcher="python", program="svc"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
)
assert s.run.runner == "python"
assert s.run.launcher == "python"
assert s.expose.http.internal.port == 8000
assert kind_for(s) == "service"
def test_service_with_component_ref(self) -> None:
"""Service can reference a component."""
s = ServiceSpec(
id="svc",
program="my-component",
run=RunPython(runner="python", program="svc"),
)
assert s.program == "my-component"
def test_service_with_proxy(self) -> None:
"""Service with proxy spec."""
s = ServiceSpec(
id="svc",
run=RunPython(runner="python", program="svc"),
proxy=True,
)
assert s.proxy is True
def test_service_with_manage(self) -> None:
"""Service with systemd management."""
s = ServiceSpec(
id="svc",
run=RunCommand(runner="command", argv=["bin"]),
manage=ManageSpec(systemd=SystemdSpec()),
)
assert s.manage.systemd.enable is True
def test_remote_with_systemd_raises(self) -> None:
"""Remote runner + systemd management is invalid."""
with pytest.raises(
ValueError, match="manage.systemd cannot be enabled for runner=remote"
):
ServiceSpec(
id="bad",
run=RunRemote(runner="remote", base_url="http://example.com"),
manage=ManageSpec(systemd=SystemdSpec()),
)
def test_no_run_is_invalid(self) -> None:
"""Service requires a run spec."""
with pytest.raises(Exception):
ServiceSpec(id="bad")
class TestJobSpec:
"""Tests for job (scheduled task) model."""
def test_basic_job(self) -> None:
"""Job with run and schedule."""
j = JobSpec(
def test_scheduled_is_a_job(self) -> None:
"""A systemd deployment with a schedule derives kind `job`."""
j = SystemdDeployment(
id="my-job",
run=RunCommand(runner="command", argv=["backup"]),
manager="systemd",
run=RunCommand(launcher="command", argv=["backup"]),
schedule="0 2 * * *",
)
assert j.schedule == "0 2 * * *"
assert j.timezone == "America/Los_Angeles"
assert kind_for(j) == "job"
def test_job_with_component_ref(self) -> None:
"""Job can reference a component."""
j = JobSpec(
id="sync",
program="protonmail",
run=RunCommand(runner="command", argv=["protonmail", "sync"]),
schedule="*/5 * * * *",
def test_program_ref(self) -> None:
"""A deployment can reference a program."""
s = SystemdDeployment(
id="svc",
manager="systemd",
program="my-program",
run=RunPython(launcher="python", program="svc"),
)
assert j.program == "protonmail"
assert s.program == "my-program"
def test_job_requires_schedule(self) -> None:
"""Job without schedule is invalid."""
with pytest.raises(Exception):
JobSpec(
def test_with_manage(self) -> None:
"""A deployment with systemd management."""
s = SystemdDeployment(
id="svc",
manager="systemd",
run=RunCommand(launcher="command", argv=["bin"]),
manage=ManageSpec(systemd=SystemdSpec()),
)
assert s.manage.systemd.enable is True
def test_public_requires_proxy(self) -> None:
"""public without proxy is invalid (public needs an exposed process)."""
with pytest.raises(ValueError, match="public requires proxy"):
SystemdDeployment(
id="bad",
run=RunCommand(runner="command", argv=["x"]),
manager="systemd",
run=RunPython(launcher="python", program="svc"),
public=True,
)
def test_job_custom_timezone(self) -> None:
"""Job with custom timezone."""
j = JobSpec(
id="job",
run=RunCommand(runner="command", argv=["x"]),
schedule="0 0 * * *",
timezone="UTC",
)
assert j.timezone == "UTC"
def test_no_run_is_invalid(self) -> None:
"""A systemd deployment requires a run (launch) spec."""
with pytest.raises(Exception):
SystemdDeployment(id="bad", manager="systemd")
class TestOtherManagers:
"""Tests for the non-systemd managers and derived kinds."""
def test_caddy_is_static(self) -> None:
c = CaddyDeployment(id="fe", manager="caddy", program="fe", root="dist")
assert c.root == "dist"
assert kind_for(c) == "static"
def test_path_is_tool(self) -> None:
p = PathDeployment(id="cli", manager="path", program="cli")
assert kind_for(p) == "tool"
def test_none_is_reference(self) -> None:
r = RemoteDeployment(id="ext", manager="none", base_url="http://example.com")
assert r.base_url == "http://example.com"
assert kind_for(r) == "reference"
class TestModelSerialization:
"""Tests for model_dump behavior."""
def test_dump_component_excludes_none(self) -> None:
def test_dump_program_excludes_none(self) -> None:
"""model_dump with exclude_none drops None fields."""
c = ProgramSpec(id="test", description="Test")
data = c.model_dump(exclude_none=True, exclude={"id"})
@@ -173,11 +155,12 @@ class TestModelSerialization:
assert "build" not in data
def test_dump_service(self) -> None:
"""Full service serializes correctly."""
s = ServiceSpec(
"""Full systemd deployment serializes correctly."""
s = SystemdDeployment(
id="svc",
manager="systemd",
description="A service",
run=RunPython(runner="python", program="svc"),
run=RunPython(launcher="python", program="svc"),
expose=ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=9001), health_path="/health"
@@ -187,6 +170,7 @@ class TestModelSerialization:
manage=ManageSpec(systemd=SystemdSpec()),
)
data = s.model_dump(exclude_none=True, exclude={"id"})
assert data["run"]["runner"] == "python"
assert data["manager"] == "systemd"
assert data["run"]["launcher"] == "python"
assert data["expose"]["http"]["internal"]["port"] == 9001
assert data["proxy"] is True

View File

@@ -29,7 +29,7 @@ class TestUnitFromDeployed:
def test_contains_description(self) -> None:
"""Unit file has service description."""
deployed = Deployment(
runner="python",
manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={"TEST_SVC_PORT": "19000"},
description="Test service",
@@ -40,7 +40,7 @@ class TestUnitFromDeployed:
def test_no_working_directory(self) -> None:
"""Unit file has no WorkingDirectory (source/runtime separation)."""
deployed = Deployment(
runner="python",
manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={},
description="Test service",
@@ -51,7 +51,7 @@ class TestUnitFromDeployed:
def test_contains_environment(self) -> None:
"""Unit file has environment variables from deployed config."""
deployed = Deployment(
runner="python",
manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={"TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc"},
description="Test service",
@@ -62,7 +62,7 @@ class TestUnitFromDeployed:
def test_contains_restart_policy(self) -> None:
"""Unit file has restart configuration."""
deployed = Deployment(
runner="python",
manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={},
description="Test service",
@@ -73,7 +73,7 @@ class TestUnitFromDeployed:
def test_exec_start_from_run_cmd(self) -> None:
"""Unit file ExecStart uses resolved run_cmd."""
deployed = Deployment(
runner="python",
manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={},
description="Test service",
@@ -84,7 +84,7 @@ class TestUnitFromDeployed:
def test_basic_service(self) -> None:
"""Generate a unit from a deployed component."""
deployed = Deployment(
runner="python",
manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
env={
"MY_SVC_PORT": "9001",
@@ -103,7 +103,7 @@ class TestUnitFromDeployed:
def test_scheduled_job(self) -> None:
"""Scheduled component generates oneshot unit."""
deployed = Deployment(
runner="command",
manager="systemd", launcher="command",
run_cmd=["/home/user/.local/bin/my-job"],
env={},
description="Nightly job",
@@ -116,7 +116,7 @@ class TestUnitFromDeployed:
def test_no_repo_paths(self) -> None:
"""Generated units must not reference repo paths."""
deployed = Deployment(
runner="python",
manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
env={"DATA_DIR": "/home/user/.castle/data/my-svc"},
description="Test",
@@ -127,7 +127,7 @@ class TestUnitFromDeployed:
def test_exec_stop_emitted_for_compose(self) -> None:
"""A compose deployment's stop_cmd becomes ExecStop= (clean teardown)."""
deployed = Deployment(
runner="compose",
manager="systemd", launcher="compose",
run_cmd=["/usr/bin/docker", "compose", "-p", "castle-x", "-f", "c.yml", "up"],
stop_cmd=["/usr/bin/docker", "compose", "-p", "castle-x", "-f", "c.yml", "down"],
description="Stack",
@@ -138,7 +138,7 @@ class TestUnitFromDeployed:
def test_no_exec_stop_without_stop_cmd(self) -> None:
"""Runners without a stop_cmd rely on SIGTERM — no ExecStop line."""
deployed = Deployment(
runner="python", run_cmd=["/uv", "run", "x"], description="X"
manager="systemd", launcher="python", run_cmd=["/uv", "run", "x"], description="X"
)
unit = generate_unit_from_deployed("x", deployed)
assert "ExecStop=" not in unit
@@ -149,7 +149,7 @@ class TestSecretEnvFile:
def test_environment_file_added_for_simple_unit(self) -> None:
deployed = Deployment(
runner="python",
manager="systemd", launcher="python",
run_cmd=["/uv", "run", "my-svc"],
env={"PORT": "9001"},
secret_env_keys=["API_KEY"],
@@ -162,7 +162,7 @@ class TestSecretEnvFile:
def test_environment_file_added_for_oneshot_job(self) -> None:
deployed = Deployment(
runner="command",
manager="systemd", launcher="command",
run_cmd=["/bin/job"],
env={},
schedule="0 2 * * *",
@@ -174,14 +174,14 @@ class TestSecretEnvFile:
assert f"EnvironmentFile={path}" in unit
def test_no_environment_file_when_none(self) -> None:
deployed = Deployment(runner="python", run_cmd=["/uv", "run", "x"], env={})
deployed = Deployment(manager="systemd", launcher="python", run_cmd=["/uv", "run", "x"], env={})
unit = generate_unit_from_deployed("x", deployed, env_file=None)
assert "EnvironmentFile" not in unit
def test_secret_values_never_in_unit(self) -> None:
"""The unit references the file path; resolved secret values never appear."""
deployed = Deployment(
runner="python",
manager="systemd", launcher="python",
run_cmd=["/uv", "run", "x"],
env={"PORT": "9001"},
secret_env_keys=["API_KEY"],
@@ -195,16 +195,16 @@ class TestUnitEnvFile:
"""unit_env_file decides which runners get an EnvironmentFile= path."""
def test_none_without_secrets(self) -> None:
d = Deployment(runner="python", run_cmd=[], env={}, secret_env_keys=[])
d = Deployment(manager="systemd", launcher="python", run_cmd=[], env={}, secret_env_keys=[])
assert unit_env_file(d, "x") is None
def test_path_for_python_with_secrets(self) -> None:
d = Deployment(runner="python", run_cmd=[], env={}, secret_env_keys=["K"])
d = Deployment(manager="systemd", launcher="python", run_cmd=[], env={}, secret_env_keys=["K"])
assert unit_env_file(d, "x") == secret_env_path("x")
def test_none_for_container(self) -> None:
"""Containers load secrets via docker --env-file, not systemd."""
d = Deployment(runner="container", run_cmd=[], env={}, secret_env_keys=["K"])
d = Deployment(manager="systemd", launcher="container", run_cmd=[], env={}, secret_env_keys=["K"])
assert unit_env_file(d, "x") is None

View File

@@ -28,9 +28,9 @@ def _registry(
),
deployed=deployed
or {
"app": Deployment(runner="python", run_cmd=["x"], port=9001,
"app": Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=9001,
subdomain="app", public=True),
"private": Deployment(runner="python", run_cmd=["y"], port=9002,
"private": Deployment(manager="systemd", launcher="python", run_cmd=["y"], port=9002,
subdomain="private", public=False),
},
)
@@ -60,7 +60,7 @@ def test_private_service_not_in_public_dns() -> None:
def test_none_when_no_public_services() -> None:
only_private = {
"x": Deployment(runner="python", run_cmd=["x"], subdomain="x", public=False)
"x": Deployment(manager="systemd", launcher="python", run_cmd=["x"], subdomain="x", public=False)
}
assert generate_tunnel_config(_registry(deployed=only_private)) is None
@@ -74,7 +74,7 @@ def test_public_static_frontend_gets_ingress() -> None:
# A `static` (frontend) service can be public too — the toggle composes for
# any exposed deployment, process-backed or not.
reg = _registry(deployed={
"guestbook": Deployment(runner="static", run_cmd=[], subdomain="guestbook",
"guestbook": Deployment(manager="caddy", run_cmd=[], subdomain="guestbook",
static_root="/data/repos/guestbook/public", public=True),
})
hosts = {r["hostname"] for r in yaml.safe_load(generate_tunnel_config(reg))["ingress"]