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

@@ -110,14 +110,15 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
),
deployed={
"test-svc": Deployment(
runner="python",
manager="systemd",
launcher="python",
run_cmd=["uv", "run", "test-svc"],
env={
"TEST_SVC_PORT": "19000",
"TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc",
},
description="Test service",
behavior="daemon",
kind="service",
port=19000,
health_path="/health",
subdomain="test-svc",

View File

@@ -34,7 +34,7 @@ class TestComponents:
assert svc["health_path"] == "/health"
assert svc["subdomain"] == "test-svc"
assert svc["managed"] is True
assert svc["behavior"] == "daemon"
assert svc["kind"] == "service"
def test_tool_has_no_port(self, client: TestClient) -> None:
"""Tool component has no port."""
@@ -42,14 +42,14 @@ class TestComponents:
data = response.json()
tool = next(c for c in data if c["id"] == "test-tool")
assert tool["port"] is None
assert tool["behavior"] == "tool"
assert tool["kind"] == "tool"
def test_job_has_schedule(self, client: TestClient) -> None:
"""Job component has schedule."""
response = client.get("/deployments")
data = response.json()
job = next(c for c in data if c["id"] == "test-job")
assert job["behavior"] == "tool"
assert job["kind"] == "job"
assert job["schedule"] == "0 2 * * *"
@@ -63,7 +63,7 @@ class TestDeploymentDetail:
data = response.json()
assert data["id"] == "test-svc"
assert "manifest" in data
assert data["manifest"]["runner"] == "python"
assert data["manifest"]["launcher"] == "python"
def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown component."""
@@ -125,7 +125,7 @@ class TestServiceDetail:
assert data["id"] == "test-svc"
assert "manifest" in data
# manifest is the editable castle.yaml ServiceSpec (nested run spec)
assert data["manifest"]["run"]["runner"] == "python"
assert data["manifest"]["run"]["launcher"] == "python"
assert data["run_target"] == "test-svc"
def test_not_found(self, client: TestClient) -> None:
@@ -196,12 +196,12 @@ class TestProgramsList:
names = [p["id"] for p in data]
assert "test-tool" in names
def test_program_has_behavior(self, client: TestClient) -> None:
"""Program summary includes behavior."""
def test_program_has_kind(self, client: TestClient) -> None:
"""Program summary includes the derived kind."""
response = client.get("/programs")
data = response.json()
tool = next(p for p in data if p["id"] == "test-tool")
assert tool["behavior"] == "tool"
assert tool["kind"] == "tool"
def test_no_port_field(self, client: TestClient) -> None:
"""ProgramSummary does not have port field."""
@@ -228,7 +228,7 @@ class TestProgramDetail:
data = response.json()
assert data["id"] == "test-tool"
assert "manifest" in data
assert data["behavior"] == "tool"
assert data["kind"] == "tool"
def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown program."""
@@ -287,23 +287,23 @@ class TestConfigEditor:
assert response.status_code == 200
data = yaml.safe_load(response.json()["yaml_content"])
assert "test-tool" in data["programs"]
assert "test-svc" in data["services"]
assert "test-job" in data["jobs"]
# service, job, and tool all live under the single deployments section now.
assert "test-svc" in data["deployments"]
assert "test-job" in data["deployments"]
def test_put_scatters_and_prunes(self, client: TestClient, castle_root) -> None:
"""PUT /config writes resource files and prunes removed ones."""
import yaml
current = yaml.safe_load(client.get("/config").json()["yaml_content"])
current["services"].pop("test-svc")
current["deployments"].pop("test-svc")
current["programs"]["new-tool"] = {
"description": "Brand new",
"behavior": "tool",
}
resp = client.put("/config", json={"yaml_content": yaml.dump(current)})
assert resp.status_code == 200, resp.text
assert not (castle_root / "services" / "test-svc.yaml").exists()
assert not (castle_root / "deployments" / "test-svc.yaml").exists()
assert (castle_root / "programs" / "new-tool.yaml").exists()
after = yaml.safe_load(client.get("/config").json()["yaml_content"])
assert "new-tool" in after["programs"]
assert "test-svc" not in (after.get("services") or {})
assert "test-svc" not in (after.get("deployments") or {})

View File

@@ -96,7 +96,7 @@ class TestMeshStateManager:
mgr.update_node("devbox", _make_registry("devbox"))
new_reg = _make_registry(
"devbox",
{"svc": Deployment(runner="python", run_cmd=["svc"])},
{"svc": Deployment(manager="systemd", launcher="python", run_cmd=["svc"])},
)
mgr.update_node("devbox", new_reg)
node = mgr.get_node("devbox")

View File

@@ -12,11 +12,12 @@ def _make_registry() -> NodeRegistry:
node=NodeConfig(hostname="tower", castle_root="/data/repos/castle", gateway_port=9000),
deployed={
"my-svc": Deployment(
runner="python",
manager="systemd",
launcher="python",
run_cmd=["uv", "run", "my-svc"],
env={"PORT": "9001", "SECRET_KEY": "super-secret"},
description="My service",
behavior="daemon",
kind="service",
stack="python-fastapi",
port=9001,
health_path="/health",
@@ -24,9 +25,10 @@ def _make_registry() -> NodeRegistry:
managed=True,
),
"my-job": Deployment(
runner="command",
manager="systemd",
launcher="command",
run_cmd=["my-job"],
behavior="tool",
kind="job",
stack="python-cli",
schedule="0 2 * * *",
),
@@ -51,12 +53,13 @@ class TestRegistrySerialization:
assert "my-svc" in restored.deployed
svc = restored.deployed["my-svc"]
assert svc.runner == "python"
assert svc.manager == "systemd"
assert svc.launcher == "python"
assert svc.port == 9001
assert svc.health_path == "/health"
assert svc.subdomain == "my-svc"
assert svc.managed is True
assert svc.behavior == "daemon"
assert svc.kind == "service"
assert svc.stack == "python-fastapi"
def test_job_fields_preserved(self) -> None:
@@ -65,9 +68,9 @@ class TestRegistrySerialization:
assert "my-job" in restored.deployed
job = restored.deployed["my-job"]
assert job.runner == "command"
assert job.launcher == "command"
assert job.schedule == "0 2 * * *"
assert job.behavior == "tool"
assert job.kind == "job"
assert job.stack == "python-cli"
def test_optional_fields_omitted(self) -> None:
@@ -75,7 +78,7 @@ class TestRegistrySerialization:
reg = NodeRegistry(
node=NodeConfig(hostname="minimal"),
deployed={
"bare": Deployment(runner="command", run_cmd=["bare"]),
"bare": Deployment(manager="systemd", launcher="command", run_cmd=["bare"]),
},
)
restored = _json_to_registry(_registry_to_json(reg))

View File

@@ -42,10 +42,10 @@ class TestNodesList:
node=NodeConfig(hostname="devbox", gateway_port=9000),
deployed={
"remote-svc": Deployment(
runner="python",
manager="systemd", launcher="python",
run_cmd=["svc"],
port=9050,
behavior="daemon",
kind="service",
),
},
)

View File

@@ -24,9 +24,9 @@ class TestProgramCommands:
assert set(w["actions"]) >= {"lint", "test", "run"}
assert "build" not in w["actions"] # not declared, no stack
def test_tools_via_behavior_filter(self, client: TestClient) -> None:
"""Tools are reached via /programs?behavior=tool (no dedicated /tools)."""
resp = client.get("/programs", params={"behavior": "tool"})
def test_tools_via_kind_filter(self, client: TestClient) -> None:
"""Tools are reached via /programs?kind=tool (no dedicated /tools)."""
resp = client.get("/programs", params={"kind": "tool"})
assert resp.status_code == 200
ids = [p["id"] for p in resp.json()]
assert "wired-in" in ids and "test-tool" in ids