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:
@@ -38,7 +38,6 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
"programs": {
|
||||
"test-tool": {
|
||||
"description": "Test tool",
|
||||
"behavior": "tool",
|
||||
},
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) == []
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user