core: drop pre-migration read-compat now both nodes are current-format

Both machines (civil, primer) are on the current on-disk layout, so the
read-compat for older formats is dead weight. Remove it:

- config loader: only deployments/<kind>/<name>.yaml (drop flat deployments/*.yaml
  and the services/+jobs/ split); drop the run.runner→manager normalizer.
- registry loader: require composite <kind>/<name> keys + manager/kind (registry.yaml
  is regenerated every apply, so it's always current).
- manifest: drop the proxy/public→reach legacy input and the component→program alias
  (the derived proxy/public read accessors stay). reach field defaults already match.
- config_editor: parse incoming specs directly (frontend sends the current shape).
- save_config: drop the flat-file migration cleanup.

Bootstrap seeds move to bootstrap/deployments/<kind>/ and install.sh seeds that
per-kind tree directly. The legacy→current translation for terse test fixtures
moves into each test conftest (out of production). All suites green (362).
This commit is contained in:
2026-07-07 09:16:03 -07:00
parent 0226538229
commit 5beebfd739
17 changed files with 247 additions and 206 deletions

View File

@@ -14,26 +14,84 @@ import pytest
import yaml
def write_castle_config(root: Path, config: dict) -> None:
"""Scatter a nested castle config dict into the directory-per-resource layout.
def _modernize_deployment(spec: dict) -> dict:
"""Translate a test's legacy deployment dict to the current manager-discriminated
shape. Production dropped this read-compat once every machine migrated; the tests
keep authoring the terse legacy shape, so the translation lives here instead.
`config` uses the legacy nested shape (gateway/repo at top level, plus
programs/services/jobs mappings); this writes castle.yaml with globals and
one file per resource under programs/, services/, jobs/.
- ``proxy``/``public`` booleans → ``reach`` (internal/public).
- ``run.runner`` → ``manager`` (+ ``run.launcher`` for the systemd process kinds).
"""
d = dict(spec)
proxy = bool(d.pop("proxy", False))
public = bool(d.pop("public", False))
if "reach" not in d:
if public:
d["reach"] = "public"
elif proxy:
d["reach"] = "internal"
if "manager" not in d:
run = dict(d.pop("run", None) or {})
runner = run.get("runner")
if runner == "static":
d["manager"] = "caddy"
if run.get("root"):
d["root"] = run["root"]
elif runner == "path":
d["manager"] = "path"
elif runner == "remote":
d["manager"] = "none"
for k in ("base_url", "health_url"):
if run.get(k):
d[k] = run[k]
else:
launch = {k: v for k, v in run.items() if k != "runner"}
launch["launcher"] = runner
d["manager"] = "systemd"
d["run"] = launch
return d
def _store_for(spec: dict) -> str:
"""The deployments/<store>/ subdir for a (modernized) deployment spec."""
if spec.get("schedule"):
return "jobs"
return {
"systemd": "services",
"caddy": "statics",
"path": "tools",
"none": "references",
}[spec["manager"]]
def write_castle_config(root: Path, config: dict) -> None:
"""Scatter a nested castle config dict into the on-disk layout.
`config` uses the terse nested shape (gateway/repo at top level, plus
programs/services/jobs mappings); this writes castle.yaml with globals, one file
per program under programs/, and each deployment under deployments/<kind>/ after
modernizing its legacy fields (see `_modernize_deployment`).
"""
globals_data = {k: v for k, v in config.items() if k in ("gateway", "repo")}
(root / "castle.yaml").write_text(yaml.dump(globals_data, default_flow_style=False))
for section in ("programs", "services", "jobs"):
entries = config.get(section) or {}
if not entries:
continue
section_dir = root / section
section_dir.mkdir(parents=True, exist_ok=True)
for name, spec in entries.items():
(section_dir / f"{name}.yaml").write_text(
programs = config.get("programs") or {}
if programs:
(root / "programs").mkdir(parents=True, exist_ok=True)
for name, spec in programs.items():
(root / "programs" / f"{name}.yaml").write_text(
yaml.dump(spec, default_flow_style=False)
)
for section in ("services", "jobs", "deployments"):
for name, spec in (config.get(section) or {}).items():
modern = _modernize_deployment(spec)
store_dir = root / "deployments" / _store_for(modern)
store_dir.mkdir(parents=True, exist_ok=True)
(store_dir / f"{name}.yaml").write_text(
yaml.dump(modern, default_flow_style=False)
)
@pytest.fixture
def castle_root(tmp_path: Path) -> Generator[Path, None, None]:

View File

@@ -37,7 +37,7 @@ class TestApplyPlan:
def test_disabled_active_deployment_deactivates(self, castle_root: Path) -> None:
"""A deployment with enabled:false that's currently up → 'deactivate'."""
# Turn the tool off in config.
tool = castle_root / "services" / "test-tool.yaml"
tool = castle_root / "deployments" / "tools" / "test-tool.yaml"
tool.write_text(tool.read_text() + "enabled: false\n")
result = _plan(castle_root, active={"test-tool": True})
@@ -47,7 +47,7 @@ class TestApplyPlan:
def test_disabled_inactive_is_unchanged(self, castle_root: Path) -> None:
"""enabled:false and already down → nothing to do."""
tool = castle_root / "services" / "test-tool.yaml"
tool = castle_root / "deployments" / "tools" / "test-tool.yaml"
tool.write_text(tool.read_text() + "enabled: false\n")
result = _plan(castle_root, active={})

View File

@@ -16,6 +16,7 @@ from castle_core.manifest import (
HttpExposeSpec,
HttpInternal,
ProgramSpec,
Reach,
RunPython,
SystemdDeployment,
)
@@ -157,7 +158,7 @@ def _service(port: int, *, expose: bool) -> SystemdDeployment:
manager="systemd",
run=RunPython(launcher="python", program="svc"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=port))),
proxy=expose,
reach=Reach.INTERNAL if expose else Reach.OFF,
)

View File

@@ -12,8 +12,8 @@ from castle_core.registry import Deployment, NodeConfig, NodeRegistry
def _config_requiring_widget(root: Path) -> None:
(root / "castle.yaml").write_text(yaml.safe_dump({"gateway": {"port": 18000}}))
svc_dir = root / "services"
svc_dir.mkdir()
svc_dir = root / "deployments" / "services"
svc_dir.mkdir(parents=True)
(svc_dir / "consumer.yaml").write_text(
yaml.safe_dump(
{

View File

@@ -111,9 +111,9 @@ class TestSystemdDeployment:
)
assert s.manage.systemd.enable is True
def test_reach_ladder_and_legacy_mapping(self) -> None:
"""`reach` is canonical; legacy proxy/public map to it, and the derived
proxy/public accessors reflect it (public implies internal)."""
def test_reach_ladder_and_accessors(self) -> None:
"""`reach` is canonical; the derived proxy/public accessors reflect it
(public implies internal)."""
# An exposed reach needs an expose block (see test_reach_requires_expose),
# so give the base one; reach off doesn't, tested separately below.
base = dict(
@@ -122,21 +122,15 @@ class TestSystemdDeployment:
run=RunPython(launcher="python", program="svc"),
expose={"http": {"internal": {"port": 9001}}},
)
# legacy input still parses
s_proxy = SystemdDeployment.model_validate({**base, "proxy": True})
assert s_proxy.reach == Reach.INTERNAL
assert s_proxy.proxy is True and s_proxy.public is False
s_pub = SystemdDeployment.model_validate({**base, "proxy": True, "public": True})
assert s_pub.reach == Reach.PUBLIC
s_internal = SystemdDeployment(**base, reach=Reach.INTERNAL)
assert s_internal.proxy is True and s_internal.public is False
s_pub = SystemdDeployment(**base, reach=Reach.PUBLIC)
assert s_pub.proxy is True and s_pub.public is True
# legacy public alone now simply means public (which implies internal)
assert SystemdDeployment.model_validate({**base, "public": True}).reach == Reach.PUBLIC
# new canonical field (reach off needs no expose block)
# reach off needs no expose block
no_expose = dict(
id="svc", manager="systemd", run=RunPython(launcher="python", program="svc")
)
assert SystemdDeployment(**no_expose, reach=Reach.OFF).reach == Reach.OFF
assert SystemdDeployment(**base, reach=Reach.PUBLIC).public is True
def test_reach_requires_expose(self) -> None:
"""An exposed reach with no expose block is rejected (it would otherwise
@@ -146,9 +140,6 @@ class TestSystemdDeployment:
for reach in ("internal", "public"):
with pytest.raises(ValueError, match="requires an `expose` block"):
SystemdDeployment.model_validate({**base, "reach": reach})
# legacy public: true with no expose maps to reach public → same rejection
with pytest.raises(ValueError, match="requires an `expose` block"):
SystemdDeployment.model_validate({**base, "public": True})
def test_tcp_exposure_is_not_http_exposed(self) -> None:
"""A raw-TCP service is reachable by name+port but never HTTP-routed."""