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

@@ -61,7 +61,9 @@ class Deployment:
# visibility (which secrets a deployment expects).
secret_env_keys: list[str] = field(default_factory=list)
description: str | None = None
# Derived kind: service | job | tool | static | reference.
# Deployment identity is (name, kind) — a name may be shared across kinds.
name: str = ""
# Kind: service | job | tool | static | reference (structural — its store).
kind: str = "service"
stack: str | None = None
port: int | None = None
@@ -88,11 +90,34 @@ class Deployment:
@dataclass
class NodeRegistry:
"""What's deployed on this node."""
"""What's deployed on this node.
`deployed` is keyed by the composite identity ``"<kind>/<name>"`` so a service
and a job (or tool) can share a bare name. Prefer the helpers over indexing
`deployed` directly: `all()` (iterate), `get(kind, name)`, `named(name)`, `put()`.
"""
node: NodeConfig
deployed: dict[str, Deployment] = field(default_factory=dict)
@staticmethod
def key(kind: str, name: str) -> str:
return f"{kind}/{name}"
def all(self) -> list[tuple[str, str, Deployment]]:
"""Every deployed unit as ``(kind, name, Deployment)``."""
return [(d.kind, d.name, d) for d in self.deployed.values()]
def get(self, kind: str, name: str) -> Deployment | None:
return self.deployed.get(self.key(kind, name))
def named(self, name: str) -> list[Deployment]:
"""Every deployed unit sharing a bare name (across kinds)."""
return [d for d in self.deployed.values() if d.name == name]
def put(self, deployed: Deployment) -> None:
self.deployed[self.key(deployed.kind, deployed.name)] = deployed
def load_registry(path: Path | None = None) -> NodeRegistry:
"""Load the node registry from ~/.castle/registry.yaml."""
@@ -126,7 +151,9 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
)
deployed: dict[str, Deployment] = {}
for name, 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_kind, name = key.split("/", 1) if "/" in key else (None, key)
# New shape carries manager/launcher/kind; legacy carries runner/behavior.
manager = comp_data.get("manager")
launcher = comp_data.get("launcher")
@@ -137,7 +164,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
)
if manager == "systemd":
launcher = runner
kind = comp_data.get("kind")
kind = comp_data.get("kind") or key_kind
if kind is None:
behavior = comp_data.get("behavior")
if comp_data.get("schedule"):
@@ -150,7 +177,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
kind = "reference"
else:
kind = "service"
deployed[name] = Deployment(
deployed[NodeRegistry.key(kind, name)] = Deployment(
manager=manager,
launcher=launcher,
run_cmd=comp_data.get("run_cmd", []),
@@ -159,6 +186,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
path_prepend=comp_data.get("path_prepend", []),
secret_env_keys=comp_data.get("secret_env_keys", []),
description=comp_data.get("description"),
name=name,
kind=kind,
stack=comp_data.get("stack"),
port=comp_data.get("port"),
@@ -209,7 +237,7 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
if registry.node.cert_hook:
data["node"]["cert_hook"] = registry.node.cert_hook
for name, comp in registry.deployed.items():
for key, comp in registry.deployed.items():
entry: dict = {
"manager": comp.manager,
"run_cmd": comp.run_cmd,
@@ -251,7 +279,7 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
# registries byte-identical and matches the load-side default.
if not comp.enabled:
entry["enabled"] = comp.enabled
data["deployed"][name] = entry
data["deployed"][key] = entry
with open(path, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)