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:
2026-07-06 02:25:05 -07:00
parent b4b3db5327
commit c5cf3a1561
16 changed files with 435 additions and 205 deletions

View File

@@ -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))