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

@@ -9,7 +9,7 @@ expose:
internal: internal:
port: 9020 port: 9020
health_path: /health health_path: /health
proxy: true reach: internal
manage: manage:
systemd: {} systemd: {}
defaults: defaults:

View File

@@ -13,7 +13,6 @@ from castle_core.config import (
KINDS, KINDS,
CastleConfig, CastleConfig,
_DEPLOYMENT_ADAPTER, _DEPLOYMENT_ADAPTER,
_normalize_deployment_dict,
_program_to_yaml_dict, _program_to_yaml_dict,
_spec_to_yaml_dict, _spec_to_yaml_dict,
load_config, load_config,
@@ -160,16 +159,11 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
except Exception as e: except Exception as e:
errors.append(f"programs.{name}: {e}") errors.append(f"programs.{name}: {e}")
# Validate deployments (accepting a legacy services:/jobs: split too, which # Validate deployments (a flat name→spec map in the manager-discriminated shape).
# the normalizer folds into the single manager-discriminated collection).
deployments = {} deployments = {}
raw_deps: dict = dict(data.get("deployments") or {}) for name, dep_data in (data.get("deployments") or {}).items():
for legacy in ("services", "jobs"):
raw_deps.update(data.get(legacy) or {})
for name, dep_data in raw_deps.items():
try: try:
dep_copy = _normalize_deployment_dict(dict(dep_data) if dep_data else {}) dep_copy = dict(dep_data) if dep_data else {}
dep_copy = dict(dep_copy)
dep_copy["id"] = name dep_copy["id"] = name
deployments[name] = _DEPLOYMENT_ADAPTER.validate_python(dep_copy) deployments[name] = _DEPLOYMENT_ADAPTER.validate_python(dep_copy)
except Exception as e: except Exception as e:
@@ -331,9 +325,7 @@ def _save_deployment(name: str, config_dict: dict, kind: str | None = None) -> d
existing = named[0][1] existing = named[0][1]
else: else:
try: try:
probe = _DEPLOYMENT_ADAPTER.validate_python( probe = _DEPLOYMENT_ADAPTER.validate_python({**incoming, "id": name})
_normalize_deployment_dict({**incoming, "id": name})
)
existing = config.deployment(kind_for(probe), name) existing = config.deployment(kind_for(probe), name)
except Exception: except Exception:
existing = None existing = None
@@ -352,7 +344,7 @@ def _save_deployment(name: str, config_dict: dict, kind: str | None = None) -> d
merged["description"] = config.programs[prog].description merged["description"] = config.programs[prog].description
try: try:
dep = _DEPLOYMENT_ADAPTER.validate_python(_normalize_deployment_dict(merged)) dep = _DEPLOYMENT_ADAPTER.validate_python(merged)
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,

View File

@@ -69,20 +69,68 @@ def nats_url() -> Generator[str, None, None]:
subprocess.run(["docker", "rm", "-f", name], capture_output=True) subprocess.run(["docker", "rm", "-f", name], capture_output=True)
def _modernize_deployment(spec: dict) -> dict:
"""Translate a test's terse legacy deployment dict to the current
manager-discriminated shape (production dropped this read-compat post-migration).
``proxy``/``public`` → ``reach``; ``run.runner`` → ``manager`` (+ ``run.launcher``)."""
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:
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: def _write_castle_config(root: Path, config: dict) -> None:
"""Scatter a nested castle config dict into the directory-per-resource layout.""" """Scatter a nested castle config dict into the on-disk layout: castle.yaml globals,
programs/<name>.yaml, and deployments/<kind>/<name>.yaml (fields modernized)."""
globals_data = {k: v for k, v in config.items() if k in ("gateway", "repo")} 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)) (root / "castle.yaml").write_text(yaml.dump(globals_data, default_flow_style=False))
for section in ("programs", "services", "jobs"): programs = config.get("programs") or {}
entries = config.get(section) or {} if programs:
if not entries: (root / "programs").mkdir(parents=True, exist_ok=True)
continue for name, spec in programs.items():
section_dir = root / section (root / "programs" / f"{name}.yaml").write_text(
section_dir.mkdir(parents=True, exist_ok=True)
for name, spec in entries.items():
(section_dir / f"{name}.yaml").write_text(
yaml.dump(spec, default_flow_style=False) 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 @pytest.fixture
@@ -161,7 +209,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
gateway_port=9000, gateway_port=9000,
), ),
deployed={ deployed={
"test-svc": Deployment( NodeRegistry.key("service", "test-svc"): Deployment(
manager="systemd", manager="systemd",
launcher="python", launcher="python",
run_cmd=["uv", "run", "test-svc"], run_cmd=["uv", "run", "test-svc"],
@@ -170,6 +218,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
"TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc", "TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc",
}, },
description="Test service", description="Test service",
name="test-svc",
kind="service", kind="service",
port=19000, port=19000,
health_path="/health", health_path="/health",
@@ -177,10 +226,11 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
managed=True, managed=True,
), ),
# A deployed tool (path) — must NOT leak into the /services list. # A deployed tool (path) — must NOT leak into the /services list.
"test-tool": Deployment( NodeRegistry.key("tool", "test-tool"): Deployment(
manager="path", manager="path",
run_cmd=[], run_cmd=[],
description="Test tool", description="Test tool",
name="test-tool",
kind="tool", kind="tool",
), ),
}, },

View File

@@ -74,9 +74,12 @@ def public_client(
"reach": "internal", "reach": "internal",
}, },
} }
(root / "deployments").mkdir() _store = {"caddy": "statics", "path": "tools", "none": "references"}
for name, spec in deps.items(): for name, spec in deps.items():
(root / "deployments" / f"{name}.yaml").write_text(yaml.dump(spec)) store = "jobs" if spec.get("schedule") else _store.get(spec["manager"], "services")
store_dir = root / "deployments" / store
store_dir.mkdir(parents=True, exist_ok=True)
(store_dir / f"{name}.yaml").write_text(yaml.dump(spec))
reg = NodeRegistry( reg = NodeRegistry(
node=NodeConfig( node=NodeConfig(
@@ -88,38 +91,42 @@ def public_client(
public_domain="pub.test", public_domain="pub.test",
), ),
deployed={ deployed={
"calc": Deployment( NodeRegistry.key("static", "calc"): Deployment(
manager="caddy", manager="caddy",
run_cmd=[], run_cmd=[],
name="calc",
kind="static", kind="static",
subdomain="calc", subdomain="calc",
public=True, public=True,
static_root=str(root / "calc" / "public"), static_root=str(root / "calc" / "public"),
), ),
"web": Deployment( NodeRegistry.key("service", "web"): Deployment(
manager="systemd", manager="systemd",
launcher="python", launcher="python",
run_cmd=["x"], run_cmd=["x"],
name="web",
kind="service", kind="service",
port=9001, port=9001,
subdomain="web", subdomain="web",
public=True, public=True,
managed=True, managed=True,
), ),
"intern": Deployment( NodeRegistry.key("service", "intern"): Deployment(
manager="systemd", manager="systemd",
launcher="python", launcher="python",
run_cmd=["x"], run_cmd=["x"],
name="intern",
kind="service", kind="service",
port=9002, port=9002,
subdomain="intern", subdomain="intern",
public=False, public=False,
managed=True, managed=True,
), ),
"pg": Deployment( NodeRegistry.key("service", "pg"): Deployment(
manager="systemd", manager="systemd",
launcher="container", launcher="container",
run_cmd=["x"], run_cmd=["x"],
name="pg",
kind="service", kind="service",
port=None, port=None,
subdomain=None, subdomain=None,

View File

@@ -21,15 +21,17 @@ from castle_core.config import load_config
# so the trio passes subdomain-uniqueness validation. # so the trio passes subdomain-uniqueness validation.
_SVC = { _SVC = {
"program": "backup", "program": "backup",
"run": {"runner": "python", "program": "backup"}, "manager": "systemd",
"run": {"launcher": "python", "program": "backup"},
"manage": {"systemd": {}}, "manage": {"systemd": {}},
} }
_JOB = { _JOB = {
"program": "backup", "program": "backup",
"run": {"runner": "command", "argv": ["backup"]}, "manager": "systemd",
"run": {"launcher": "command", "argv": ["backup"]},
"schedule": "0 3 * * *", "schedule": "0 3 * * *",
} }
_TOOL = {"program": "backup", "run": {"runner": "path"}} _TOOL = {"program": "backup", "manager": "path"}
def _put(client: TestClient, section: str, name: str, cfg: dict) -> None: def _put(client: TestClient, section: str, name: str, cfg: dict) -> None:

View File

@@ -9,18 +9,64 @@ import pytest
import yaml import yaml
def _modernize_deployment(spec: dict) -> dict:
"""Translate a test's terse legacy deployment dict to the current
manager-discriminated shape (production dropped this read-compat post-migration).
``proxy``/``public`` → ``reach``; ``run.runner`` → ``manager`` (+ ``run.launcher``)."""
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:
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: def _write_castle_config(root: Path, config: dict) -> None:
"""Scatter a nested castle config dict into the directory-per-resource layout.""" """Scatter a nested castle config dict into the on-disk layout: castle.yaml globals,
programs/<name>.yaml, and deployments/<kind>/<name>.yaml (fields modernized)."""
globals_data = {k: v for k, v in config.items() if k in ("gateway", "repo")} 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)) (root / "castle.yaml").write_text(yaml.dump(globals_data, default_flow_style=False))
for section in ("programs", "services", "jobs"): programs = config.get("programs") or {}
entries = config.get(section) or {} if programs:
if not entries: (root / "programs").mkdir(parents=True, exist_ok=True)
continue for name, spec in programs.items():
section_dir = root / section (root / "programs" / f"{name}.yaml").write_text(yaml.dump(spec, default_flow_style=False))
section_dir.mkdir(parents=True, exist_ok=True) for section in ("services", "jobs", "deployments"):
for name, spec in entries.items(): for name, spec in (config.get(section) or {}).items():
(section_dir / f"{name}.yaml").write_text(yaml.dump(spec, default_flow_style=False)) 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 @pytest.fixture

View File

@@ -352,42 +352,9 @@ def _parse_program(name: str, data: dict) -> ProgramSpec:
return ProgramSpec.model_validate(data_copy) return ProgramSpec.model_validate(data_copy)
def _normalize_deployment_dict(data: dict) -> dict:
"""Map a legacy service/job entry to the manager-discriminated shape.
Legacy entries carry `run.runner` (including static/path/remote); new entries
carry `manager` and (for systemd) `run.launcher`. New-shape entries pass through.
"""
if "manager" in data:
return data
d = dict(data)
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"
if run.get("base_url"):
d["base_url"] = run["base_url"]
if run.get("health_url"):
d["health_url"] = run["health_url"]
else:
# A process launcher (python/command/container/compose/node) → systemd.
d["manager"] = "systemd"
launch = {k: v for k, v in run.items() if k != "runner"}
launch["launcher"] = runner
d["run"] = launch
return d
def _parse_deployment(name: str, data: dict) -> DeploymentSpec: def _parse_deployment(name: str, data: dict) -> DeploymentSpec:
"""Parse a deployment entry (new or legacy shape) into a DeploymentSpec.""" """Parse a deployment entry (manager-discriminated shape) into a DeploymentSpec."""
data_copy = _normalize_deployment_dict(data) data_copy = dict(data)
data_copy = dict(data_copy)
data_copy["id"] = name data_copy["id"] = name
return _DEPLOYMENT_ADAPTER.validate_python(data_copy) return _DEPLOYMENT_ADAPTER.validate_python(data_copy)
@@ -515,31 +482,16 @@ def _validate_subdomains(stores: dict[str, dict[str, DeploymentSpec]]) -> None:
def _load_deployments(root: Path) -> dict[str, dict[str, DeploymentSpec]]: def _load_deployments(root: Path) -> dict[str, dict[str, DeploymentSpec]]:
"""Load the per-kind deployment stores for a config root. """Load the per-kind deployment stores for a config root.
New layout: ``deployments/<store>/<name>.yaml`` (store = services|jobs|tools| Layout: ``deployments/<store>/<name>.yaml`` (store = services|jobs|tools|statics|
statics|references). Read-compat for the pre-migration layouts: flat references). Every file is routed to its store by ``kind_for(spec)`` — the dir is
``deployments/*.yaml`` files, and the older ``services/``+``jobs/`` split. Every a namespace, the spec's manager/schedule is the source of truth.
file is routed to its store by ``kind_for(spec)`` — the dir is a namespace, the
spec's manager/schedule is the source of truth (they must agree post-migration).
""" """
stores: dict[str, dict[str, DeploymentSpec]] = {s: {} for s in _KIND_STORE.values()} stores: dict[str, dict[str, DeploymentSpec]] = {s: {} for s in _KIND_STORE.values()}
def route(name: str, data: dict) -> None:
spec = _parse_deployment(name, data)
stores[_KIND_STORE[kind_for(spec)]][name] = spec
dep_dir = root / "deployments" dep_dir = root / "deployments"
# New per-kind subdirs.
for store in _KIND_STORE.values(): for store in _KIND_STORE.values():
for name, data in _load_resource_dir(dep_dir / store).items(): for name, data in _load_resource_dir(dep_dir / store).items():
route(name, data) spec = _parse_deployment(name, data)
# Legacy flat deployments/*.yaml (top-level only — subdirs handled above). stores[_KIND_STORE[kind_for(spec)]][name] = spec
for name, data in _load_resource_dir(dep_dir).items():
route(name, data)
# Oldest layout: services/ + jobs/ dirs, only if there's no deployments/ dir.
if not dep_dir.is_dir():
for legacy in ("services", "jobs"):
for name, data in _load_resource_dir(root / legacy).items():
route(name, data)
return stores return stores
@@ -750,10 +702,6 @@ def save_config(config: CastleConfig) -> None:
dep_dir / store, dep_dir / store,
{n: _spec_to_yaml_dict(d) for n, d in config.store_for(kind).items()}, {n: _spec_to_yaml_dict(d) for n, d in config.store_for(kind).items()},
) )
# Migration cleanup: drop any pre-migration flat deployments/*.yaml files.
if dep_dir.is_dir():
for path in dep_dir.glob("*.yaml"):
path.unlink()
def ensure_dirs(config: CastleConfig) -> None: def ensure_dirs(config: CastleConfig) -> None:

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from enum import Enum from enum import Enum
from typing import Annotated, Literal, Union from typing import Annotated, Literal, Union
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
EnvMap = dict[str, str] EnvMap = dict[str, str]
@@ -25,8 +25,7 @@ class Reach(str, Enum):
``public`` → *also* projected to the internet (HTTP via the tunnel origin; ``public`` → *also* projected to the internet (HTTP via the tunnel origin;
TCP via ``cloudflared access tcp``). Implies ``internal``. TCP via ``cloudflared access tcp``). Implies ``internal``.
Replaces the old ``proxy``/``public`` booleans; ``proxy``/``public`` survive as ``proxy``/``public`` survive as derived read-only accessors on the deployment.
derived read-only accessors and as accepted *legacy input* (normalized below).
""" """
OFF = "off" OFF = "off"
@@ -34,29 +33,6 @@ class Reach(str, Enum):
PUBLIC = "public" PUBLIC = "public"
def _reach_from_legacy(data: object, default: Reach) -> object:
"""Map legacy ``proxy``/``public`` booleans on a raw deployment dict to ``reach``.
Runs as a ``mode="before"`` validator. When ``reach`` is given explicitly it
wins (legacy keys are dropped); otherwise ``reach`` is derived from the old
booleans: ``public`` → PUBLIC, ``proxy`` → INTERNAL, else ``default``. Non-dict
input (e.g. model re-validation) passes through untouched.
"""
if not isinstance(data, dict):
return data
d = dict(data)
proxy = bool(d.pop("proxy", False))
public = bool(d.pop("public", False))
if "reach" not in d:
if public:
d["reach"] = Reach.PUBLIC
elif proxy:
d["reach"] = Reach.INTERNAL
else:
d["reach"] = default
return d
# --------------------- # ---------------------
# Launch specs — how systemd starts a process (discriminated union on `launcher`) # Launch specs — how systemd starts a process (discriminated union on `launcher`)
# --------------------- # ---------------------
@@ -416,10 +392,8 @@ class DeploymentBase(BaseModel):
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)
id: str = "" id: str = ""
# The program this deployment materializes. (`component` = legacy alias.) # The program this deployment materializes.
program: str | None = Field( program: str | None = None
default=None, validation_alias=AliasChoices("program", "component")
)
description: str | None = None description: str | None = None
defaults: DefaultsSpec | None = None defaults: DefaultsSpec | None = None
# Deployment-to-deployment preconditions: other deployments this one needs # Deployment-to-deployment preconditions: other deployments this one needs
@@ -447,11 +421,6 @@ class SystemdDeployment(DeploymentBase):
reach: Reach = Reach.OFF reach: Reach = Reach.OFF
manage: ManageSpec | None = None manage: ManageSpec | None = None
@model_validator(mode="before")
@classmethod
def _normalize_reach(cls, data: object) -> object:
return _reach_from_legacy(data, default=Reach.OFF)
@model_validator(mode="after") @model_validator(mode="after")
def _validate_reach(self) -> SystemdDeployment: def _validate_reach(self) -> SystemdDeployment:
# An exposed reach needs a port to expose. Without an `expose` block the # An exposed reach needs a port to expose. Without an `expose` block the
@@ -519,11 +488,6 @@ class CaddyDeployment(DeploymentBase):
# `internal` or `public` (never `off`). `public` = also project via the tunnel. # `internal` or `public` (never `off`). `public` = also project via the tunnel.
reach: Reach = Reach.INTERNAL reach: Reach = Reach.INTERNAL
@model_validator(mode="before")
@classmethod
def _normalize_reach(cls, data: object) -> object:
return _reach_from_legacy(data, default=Reach.INTERNAL)
@model_validator(mode="after") @model_validator(mode="after")
def _validate_reach(self) -> CaddyDeployment: def _validate_reach(self) -> CaddyDeployment:
if self.reach == Reach.OFF: if self.reach == Reach.OFF:

View File

@@ -167,34 +167,12 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
deployed: dict[str, Deployment] = {} deployed: dict[str, Deployment] = {}
for key, comp_data in data.get("deployed", {}).items(): for key, comp_data in data.get("deployed", {}).items():
# Key is the composite "<kind>/<name>" (new) or a bare name (legacy). # Key is the composite "<kind>/<name>"; manager/kind are always present
key_kind, name = key.split("/", 1) if "/" in key else (None, key) # (save_registry writes them). registry.yaml is regenerated every apply.
# New shape carries manager/launcher/kind; legacy carries runner/behavior. kind, name = key.split("/", 1)
manager = comp_data.get("manager")
launcher = comp_data.get("launcher")
if manager is None:
runner = comp_data.get("runner", "command")
manager = {"static": "caddy", "path": "path", "remote": "none"}.get(
runner, "systemd"
)
if manager == "systemd":
launcher = runner
kind = comp_data.get("kind") or key_kind
if kind is None:
behavior = comp_data.get("behavior")
if comp_data.get("schedule"):
kind = "job"
elif manager == "caddy" or behavior == "frontend":
kind = "static"
elif manager == "path" or behavior == "tool":
kind = "tool"
elif manager == "none":
kind = "reference"
else:
kind = "service"
deployed[NodeRegistry.key(kind, name)] = Deployment( deployed[NodeRegistry.key(kind, name)] = Deployment(
manager=manager, manager=comp_data["manager"],
launcher=launcher, launcher=comp_data.get("launcher"),
run_cmd=comp_data.get("run_cmd", []), run_cmd=comp_data.get("run_cmd", []),
stop_cmd=comp_data.get("stop_cmd", []), stop_cmd=comp_data.get("stop_cmd", []),
env=comp_data.get("env", {}), env=comp_data.get("env", {}),

View File

@@ -14,26 +14,84 @@ import pytest
import yaml import yaml
def write_castle_config(root: Path, config: dict) -> None: def _modernize_deployment(spec: dict) -> dict:
"""Scatter a nested castle config dict into the directory-per-resource layout. """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 - ``proxy``/``public`` booleans → ``reach`` (internal/public).
programs/services/jobs mappings); this writes castle.yaml with globals and - ``run.runner`` → ``manager`` (+ ``run.launcher`` for the systemd process kinds).
one file per resource under programs/, services/, jobs/. """
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")} 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)) (root / "castle.yaml").write_text(yaml.dump(globals_data, default_flow_style=False))
for section in ("programs", "services", "jobs"):
entries = config.get(section) or {} programs = config.get("programs") or {}
if not entries: if programs:
continue (root / "programs").mkdir(parents=True, exist_ok=True)
section_dir = root / section for name, spec in programs.items():
section_dir.mkdir(parents=True, exist_ok=True) (root / "programs" / f"{name}.yaml").write_text(
for name, spec in entries.items():
(section_dir / f"{name}.yaml").write_text(
yaml.dump(spec, default_flow_style=False) 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 @pytest.fixture
def castle_root(tmp_path: Path) -> Generator[Path, None, None]: 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: def test_disabled_active_deployment_deactivates(self, castle_root: Path) -> None:
"""A deployment with enabled:false that's currently up → 'deactivate'.""" """A deployment with enabled:false that's currently up → 'deactivate'."""
# Turn the tool off in config. # 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") tool.write_text(tool.read_text() + "enabled: false\n")
result = _plan(castle_root, active={"test-tool": True}) 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: def test_disabled_inactive_is_unchanged(self, castle_root: Path) -> None:
"""enabled:false and already down → nothing to do.""" """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") tool.write_text(tool.read_text() + "enabled: false\n")
result = _plan(castle_root, active={}) result = _plan(castle_root, active={})

View File

@@ -16,6 +16,7 @@ from castle_core.manifest import (
HttpExposeSpec, HttpExposeSpec,
HttpInternal, HttpInternal,
ProgramSpec, ProgramSpec,
Reach,
RunPython, RunPython,
SystemdDeployment, SystemdDeployment,
) )
@@ -157,7 +158,7 @@ def _service(port: int, *, expose: bool) -> SystemdDeployment:
manager="systemd", manager="systemd",
run=RunPython(launcher="python", program="svc"), run=RunPython(launcher="python", program="svc"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=port))), 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: def _config_requiring_widget(root: Path) -> None:
(root / "castle.yaml").write_text(yaml.safe_dump({"gateway": {"port": 18000}})) (root / "castle.yaml").write_text(yaml.safe_dump({"gateway": {"port": 18000}}))
svc_dir = root / "services" svc_dir = root / "deployments" / "services"
svc_dir.mkdir() svc_dir.mkdir(parents=True)
(svc_dir / "consumer.yaml").write_text( (svc_dir / "consumer.yaml").write_text(
yaml.safe_dump( yaml.safe_dump(
{ {

View File

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

View File

@@ -351,13 +351,17 @@ seed_control_plane() {
printf 'repo: %s\n' "$CASTLE_ROOT" >> "${CASTLE_HOME}/castle.yaml" printf 'repo: %s\n' "$CASTLE_ROOT" >> "${CASTLE_HOME}/castle.yaml"
fi fi
local seeded=0 f dst local seeded=0 f dst rel
for f in "${CASTLE_ROOT}"/bootstrap/programs/*.yaml; do for f in "${CASTLE_ROOT}"/bootstrap/programs/*.yaml; do
dst="${CASTLE_HOME}/programs/$(basename "$f")" dst="${CASTLE_HOME}/programs/$(basename "$f")"
[ -f "$dst" ] || { cp "$f" "$dst"; seeded=1; } [ -f "$dst" ] || { cp "$f" "$dst"; seeded=1; }
done done
for f in "${CASTLE_ROOT}"/bootstrap/deployments/*.yaml; do # Deployments are seeded into the per-kind layout (deployments/<kind>/<name>.yaml),
dst="${CASTLE_HOME}/deployments/$(basename "$f")" # mirroring bootstrap/deployments/<kind>/ — the shape the loader reads.
for f in "${CASTLE_ROOT}"/bootstrap/deployments/*/*.yaml; do
rel="${f#"${CASTLE_ROOT}"/bootstrap/deployments/}"
dst="${CASTLE_HOME}/deployments/${rel}"
mkdir -p "$(dirname "$dst")"
[ -f "$dst" ] || { sed "s#__SPECS_DIR__#${specs}#g" "$f" > "$dst"; seeded=1; } [ -f "$dst" ] || { sed "s#__SPECS_DIR__#${specs}#g" "$f" > "$dst"; seeded=1; }
done done