feat(core): kind-scoped deployment identity (name, kind)
A deployment's identity becomes (name, kind), so a tool, service, and job can
share a name (a `backup` trio). Kind is structural — which per-kind store / dir —
not a derived discriminator threaded around.
- CastleConfig holds per-kind stores (services/jobs/tools/statics/references);
all_deployments()/deployments_named()/deployment(kind,name) replace the single
flat `deployments` dict. A construction-only `deployments` InitVar routes a flat
map into the stores by kind_for.
- Storage: deployments/<store>/<name>.yaml (per-kind dirs), with read-compat for
the old flat + services/+jobs/ layouts.
- Registry keyed by composite "<kind>/<name>" + name field; get()/all()/named()/put().
- Job units carry a -job marker (castle-<name>-job.{service,timer}); services and
everything else unchanged — so a service and a job can coexist.
- deploy/apply/lifecycle thread kind through unit naming, activate/deactivate,
is_active; caddyfile/tunnel/relations iterate via all_deployments/registry.all.
- Subdomain uniqueness validated across kinds (a service and a static can't share one).
182 core tests pass, incl. a new same-name-trio suite (round-trip, distinct units,
subdomain guard). API/CLI/UI updated in follow-up phases.
This commit is contained in:
@@ -17,7 +17,10 @@ from castle_core.registry import Deployment
|
||||
|
||||
def _plan(castle_root: Path, active: dict[str, bool]):
|
||||
"""Run apply(plan=True) with is_active stubbed to `active` (default False)."""
|
||||
with patch("castle_core.lifecycle.is_active", side_effect=lambda n, c: active.get(n, False)):
|
||||
with patch(
|
||||
"castle_core.lifecycle.is_active",
|
||||
side_effect=lambda n, k, c: active.get(n, False),
|
||||
):
|
||||
return apply(root=castle_root, plan=True)
|
||||
|
||||
|
||||
@@ -71,4 +74,4 @@ def test_render_unit_preview_none_for_non_systemd() -> None:
|
||||
A path deployment is unmanaged, so the renderer returns before touching config.
|
||||
"""
|
||||
tool = Deployment(manager="path", run_cmd=[], kind="tool")
|
||||
assert _render_unit_preview(None, "x", tool, is_job=False) is None # type: ignore[arg-type]
|
||||
assert _render_unit_preview(None, "x", tool, "tool") is None # type: ignore[arg-type]
|
||||
|
||||
@@ -41,7 +41,7 @@ def _make_registry(
|
||||
gateway_domain: str | None = None,
|
||||
acme_email: str | None = None,
|
||||
) -> NodeRegistry:
|
||||
return NodeRegistry(
|
||||
reg = NodeRegistry(
|
||||
node=NodeConfig(
|
||||
hostname="test",
|
||||
gateway_port=gateway_port,
|
||||
@@ -49,8 +49,11 @@ def _make_registry(
|
||||
gateway_domain=gateway_domain,
|
||||
acme_email=acme_email,
|
||||
),
|
||||
deployed=deployed or {},
|
||||
)
|
||||
for name, d in (deployed or {}).items():
|
||||
d.name = name
|
||||
reg.put(d)
|
||||
return reg
|
||||
|
||||
|
||||
def _dep(port: int, *, expose: bool, name: str | None = None, launcher: str = "python") -> Deployment:
|
||||
|
||||
@@ -120,10 +120,10 @@ class TestSaveConfig:
|
||||
config = load_config(castle_root)
|
||||
save_config(config)
|
||||
assert (castle_root / "programs" / "test-tool.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()
|
||||
# Deployments live under per-kind subdirs (deployments/<store>/<name>.yaml).
|
||||
assert (castle_root / "deployments" / "services" / "test-svc.yaml").exists()
|
||||
assert (castle_root / "deployments" / "jobs" / "test-job.yaml").exists()
|
||||
assert (castle_root / "deployments" / "tools" / "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
|
||||
@@ -133,9 +133,9 @@ class TestSaveConfig:
|
||||
def test_delete_prunes_file(self, castle_root: Path) -> None:
|
||||
"""Removing a deployment and saving deletes its on-disk file."""
|
||||
config = load_config(castle_root)
|
||||
del config.deployments["test-svc"]
|
||||
del config.services["test-svc"]
|
||||
save_config(config)
|
||||
assert not (castle_root / "deployments" / "test-svc.yaml").exists()
|
||||
assert not (castle_root / "deployments" / "services" / "test-svc.yaml").exists()
|
||||
config2 = load_config(castle_root)
|
||||
assert "test-svc" not in config2.services
|
||||
assert "test-tool" in config2.programs
|
||||
@@ -300,7 +300,7 @@ class TestConfigRoundTrip:
|
||||
assert loaded.gateway.domain == "civil.payne.io"
|
||||
|
||||
# Deployment: reach + full TCP/TLS + container user/tmpfs must survive.
|
||||
d = loaded.deployments["pg"]
|
||||
d = loaded.services["pg"]
|
||||
assert d.reach == Reach.INTERNAL
|
||||
assert d.expose.tcp.port == 5432
|
||||
assert d.expose.tcp.tls.material == TlsMaterial.PAIR
|
||||
|
||||
@@ -18,11 +18,16 @@ def _svc(managed: bool = True, schedule: str | None = None) -> Deployment:
|
||||
env={},
|
||||
managed=managed,
|
||||
schedule=schedule,
|
||||
kind="job" if schedule else "service",
|
||||
)
|
||||
|
||||
|
||||
def _registry(**deployed: Deployment) -> NodeRegistry:
|
||||
return NodeRegistry(node=NodeConfig(castle_root="/x", gateway_port=9000), deployed=dict(deployed))
|
||||
reg = NodeRegistry(node=NodeConfig(castle_root="/x", gateway_port=9000))
|
||||
for name, d in deployed.items():
|
||||
d.name = name
|
||||
reg.put(d)
|
||||
return reg
|
||||
|
||||
|
||||
def _touch(d: Path, *names: str) -> None:
|
||||
@@ -37,7 +42,7 @@ class TestDesiredUnitFiles:
|
||||
|
||||
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"}
|
||||
assert _desired_unit_files(reg) == {"castle-job-job.service", "castle-job-job.timer"}
|
||||
|
||||
def test_unmanaged_excluded(self) -> None:
|
||||
reg = _registry(foo=_svc(managed=False))
|
||||
|
||||
@@ -61,17 +61,16 @@ class TestWriteSecretEnvFile:
|
||||
class TestRegistrySecretKeys:
|
||||
def test_round_trip_persists_keys_only(self, tmp_path: Path) -> None:
|
||||
reg_path = tmp_path / "registry.yaml"
|
||||
registry = NodeRegistry(
|
||||
node=NodeConfig(hostname="h"),
|
||||
deployed={
|
||||
"svc": Deployment(
|
||||
manager="systemd", launcher="container",
|
||||
run_cmd=["docker", "run"],
|
||||
env={"PORT": "9001"},
|
||||
secret_env_keys=["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
||||
managed=True,
|
||||
)
|
||||
},
|
||||
registry = NodeRegistry(node=NodeConfig(hostname="h"))
|
||||
registry.put(
|
||||
Deployment(
|
||||
manager="systemd", launcher="container",
|
||||
run_cmd=["docker", "run"],
|
||||
env={"PORT": "9001"},
|
||||
secret_env_keys=["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
||||
managed=True,
|
||||
name="svc",
|
||||
)
|
||||
)
|
||||
save_registry(registry, reg_path)
|
||||
|
||||
@@ -80,8 +79,6 @@ class TestRegistrySecretKeys:
|
||||
assert "sk-ant" not in text # but no values ever
|
||||
|
||||
loaded = load_registry(reg_path)
|
||||
assert loaded.deployed["svc"].secret_env_keys == [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
]
|
||||
assert loaded.deployed["svc"].env == {"PORT": "9001"}
|
||||
svc = loaded.get("service", "svc")
|
||||
assert svc.secret_env_keys == ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]
|
||||
assert svc.env == {"PORT": "9001"}
|
||||
|
||||
84
core/tests/test_kind_identity.py
Normal file
84
core/tests/test_kind_identity.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""A deployment's identity is (name, kind): a tool, a service, and a job can all be
|
||||
named the same. These lock the per-kind stores, the storage round-trip, distinct
|
||||
unit names (job-suffix), and the subdomain-uniqueness guard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from castle_core.config import load_config, save_config
|
||||
from castle_core.deploy import _build_deployed, _desired_unit_files
|
||||
from castle_core.registry import NodeConfig, NodeRegistry
|
||||
|
||||
|
||||
def _write(root: Path, store: str, name: str, spec: dict) -> None:
|
||||
d = root / "deployments" / store
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / f"{name}.yaml").write_text(yaml.dump(spec))
|
||||
|
||||
|
||||
def _trio(root: Path) -> None:
|
||||
(root / "castle.yaml").write_text(yaml.dump({"gateway": {"port": 9000}}))
|
||||
_write(root, "services", "backup", {
|
||||
"manager": "systemd", "run": {"launcher": "python", "program": "backup"},
|
||||
})
|
||||
_write(root, "jobs", "backup", {
|
||||
"manager": "systemd", "run": {"launcher": "command", "argv": ["backup"]},
|
||||
"schedule": "0 2 * * *",
|
||||
})
|
||||
_write(root, "tools", "backup", {"manager": "path", "program": "backup"})
|
||||
|
||||
|
||||
def test_same_name_across_kinds_loads_into_distinct_stores(tmp_path: Path) -> None:
|
||||
_trio(tmp_path)
|
||||
config = load_config(tmp_path)
|
||||
assert "backup" in config.services
|
||||
assert "backup" in config.jobs
|
||||
assert "backup" in config.tools
|
||||
# all_deployments yields all three with their kinds
|
||||
kinds = {kind for kind, name, _ in config.all_deployments() if name == "backup"}
|
||||
assert kinds == {"service", "job", "tool"}
|
||||
# deployments_named collects the trio
|
||||
assert {k for k, _ in config.deployments_named("backup")} == {"service", "job", "tool"}
|
||||
|
||||
|
||||
def test_trio_round_trips_through_per_kind_dirs(tmp_path: Path) -> None:
|
||||
_trio(tmp_path)
|
||||
config = load_config(tmp_path)
|
||||
save_config(config)
|
||||
# written under per-kind subdirs
|
||||
assert (tmp_path / "deployments" / "services" / "backup.yaml").exists()
|
||||
assert (tmp_path / "deployments" / "jobs" / "backup.yaml").exists()
|
||||
assert (tmp_path / "deployments" / "tools" / "backup.yaml").exists()
|
||||
reloaded = load_config(tmp_path)
|
||||
assert "backup" in reloaded.services and "backup" in reloaded.jobs
|
||||
assert "backup" in reloaded.tools
|
||||
|
||||
|
||||
def test_service_and_job_get_distinct_units(tmp_path: Path) -> None:
|
||||
_trio(tmp_path)
|
||||
config = load_config(tmp_path)
|
||||
registry = NodeRegistry(node=NodeConfig(hostname="h", gateway_port=9000))
|
||||
for _kind, name, spec in config.all_deployments():
|
||||
dep = _build_deployed(config, name, spec, [])
|
||||
dep.name = name
|
||||
registry.put(dep)
|
||||
units = _desired_unit_files(registry)
|
||||
# service keeps castle-<name>.service; job carries the -job marker; tool has none.
|
||||
assert "castle-backup.service" in units
|
||||
assert "castle-backup-job.service" in units
|
||||
assert "castle-backup-job.timer" in units
|
||||
|
||||
|
||||
def test_service_and_static_cannot_share_a_subdomain(tmp_path: Path) -> None:
|
||||
(tmp_path / "castle.yaml").write_text(yaml.dump({"gateway": {"port": 9000}}))
|
||||
_write(tmp_path, "services", "app", {
|
||||
"manager": "systemd", "run": {"launcher": "python", "program": "app"},
|
||||
"reach": "internal", "expose": {"http": {"internal": {"port": 9001}}},
|
||||
})
|
||||
_write(tmp_path, "statics", "app", {"manager": "caddy", "program": "app", "root": "dist"})
|
||||
with pytest.raises(ValueError, match="subdomain 'app'"):
|
||||
load_config(tmp_path)
|
||||
@@ -13,26 +13,26 @@ 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
|
||||
assert lifecycle.is_active("test-svc", "service", 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")
|
||||
assert lifecycle.is_active("test-job", "job", config) is True
|
||||
mock.assert_called_once_with("castle-test-job-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
|
||||
assert lifecycle.is_active("test-tool", "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
|
||||
assert lifecycle.is_active("does-not-exist", "service", 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.
|
||||
@@ -40,9 +40,9 @@ class TestIsActive:
|
||||
|
||||
config = load_config(castle_root)
|
||||
config.programs["mytool"] = ProgramSpec(id="mytool", source="/tmp/mytool")
|
||||
config.deployments["mytool"] = PathDeployment(manager="path", program="mytool")
|
||||
config.tools["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
|
||||
assert lifecycle.is_active("mytool", "tool", config) is True
|
||||
mock.assert_called_once_with("mytool")
|
||||
|
||||
def test_remote_deployment_is_active(self, castle_root: Path) -> None:
|
||||
@@ -50,10 +50,10 @@ class TestIsActive:
|
||||
from castle_core.manifest import RemoteDeployment
|
||||
|
||||
config = load_config(castle_root)
|
||||
config.deployments["ext"] = RemoteDeployment(
|
||||
config.references["ext"] = RemoteDeployment(
|
||||
manager="none", program="ext", base_url="http://x"
|
||||
)
|
||||
assert lifecycle.is_active("ext", config) is True
|
||||
assert lifecycle.is_active("ext", "reference", config) is True
|
||||
|
||||
def test_static_deployment_active_when_dist_built(
|
||||
self, castle_root: Path, tmp_path: Path
|
||||
@@ -64,11 +64,11 @@ class TestIsActive:
|
||||
config = load_config(castle_root)
|
||||
repo = tmp_path / "fe"
|
||||
config.programs["fe"] = ProgramSpec(id="fe", source=str(repo))
|
||||
config.deployments["fe"] = CaddyDeployment(
|
||||
config.statics["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
|
||||
assert lifecycle.is_active("fe", "static", config) is False
|
||||
# Built dist → served in place → active
|
||||
(repo / "dist").mkdir(parents=True)
|
||||
assert lifecycle.is_active("fe", config) is True
|
||||
assert lifecycle.is_active("fe", "static", config) is True
|
||||
|
||||
Reference in New Issue
Block a user