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

@@ -127,11 +127,13 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
registry = NodeRegistry(node=node)
# Deploy every deployment, dispatched by its manager (systemd/caddy/path/none).
for name, dep in config.deployments.items():
# target_name (if given) matches every kind sharing that bare name.
for _kind, name, dep in config.all_deployments():
if target_name and name != target_name:
continue
deployed = _build_deployed(config, name, dep, result.messages)
registry.deployed[name] = deployed
deployed.name = name
registry.put(deployed)
result.deployed_count += 1
result.messages.append(_format_deployed(name, deployed))
@@ -191,14 +193,14 @@ def _node_config(config: CastleConfig) -> NodeConfig:
)
def _unit_file_for(name: str, is_job: bool) -> Path:
def _unit_file_for(name: str, kind: str) -> Path:
"""On-disk systemd unit path for a deployment (timer if it's a job)."""
return SYSTEMD_USER_DIR / (timer_name(name) if is_job else unit_name(name))
return SYSTEMD_USER_DIR / (timer_name(name) if kind == "job" else unit_name(name, kind))
def _unit_bytes(name: str, is_job: bool) -> str | None:
def _unit_bytes(name: str, kind: str) -> str | None:
"""Current unit-file contents, or None if it isn't written yet."""
path = _unit_file_for(name, is_job)
path = _unit_file_for(name, kind)
return path.read_text() if path.exists() else None
@@ -223,39 +225,52 @@ def apply(
from castle_core.lifecycle import activate, deactivate, is_active
config = load_config(root)
names = [n for n in config.deployments if not target_name or n == target_name]
is_job = {n: (n in config.jobs) for n in names}
# Each item is (kind, name, spec); target_name matches every kind of that name.
# Identity is (kind, name) — two kinds may share a bare name.
items = [
(k, n, d)
for k, n, d in config.all_deployments()
if not target_name or n == target_name
]
names = [n for _k, n, _d in items]
# Snapshot BEFORE rendering: liveness + current unit bytes (for restart-on-change).
before_active = {n: is_active(n, config) for n in names}
before_unit = {n: _unit_bytes(n, is_job[n]) for n in names}
before_active = {(k, n): is_active(n, k, config) for k, n, _ in items}
before_unit = {(k, n): _unit_bytes(n, k) for k, n, _ in items}
# Desired state, rendered in memory to classify each deployment. For a real run
# this is recomputed by deploy() below (which also writes it); cheap and keeps
# the plan/apply classification identical.
desired = {n: _build_deployed(config, n, config.deployments[n], []) for n in names}
desired: dict[tuple[str, str], Deployment] = {}
for k, n, spec in items:
dep = _build_deployed(config, n, spec, [])
dep.name = n
desired[(k, n)] = dep
def _classify(name: str, after_unit: str | None) -> str:
dep = desired[name]
def _classify(ident: tuple[str, str], after_unit: str | None) -> str:
dep = desired[ident]
if not dep.enabled:
return "deactivate" if before_active[name] else "unchanged"
if not before_active[name]:
return "deactivate" if before_active[ident] else "unchanged"
if not before_active[ident]:
return "activate"
if dep.manager == "systemd" and before_unit[name] != after_unit:
if dep.manager == "systemd" and before_unit[ident] != after_unit:
return "restart"
return "unchanged"
result = ApplyResult(
registry=NodeRegistry(node=_node_config(config), deployed=desired)
registry=NodeRegistry(
node=_node_config(config),
deployed={NodeRegistry.key(k, n): d for (k, n), d in desired.items()},
)
)
if plan:
# No writes: for systemd, predict the new unit bytes by rendering to a string
# so "would restart" is accurate; other managers never restart.
result.planned = True
for name in names:
after = _render_unit_preview(config, name, desired[name], is_job[name])
_record(result, name, _classify(name, after))
for k, n, _ in items:
after = _render_unit_preview(config, n, desired[(k, n)], k)
_record(result, n, _classify((k, n), after))
return result
# Real run: render everything (writes units/Caddyfile/tunnel, daemon-reload,
@@ -277,21 +292,21 @@ def apply(
wait_for_wildcard(config, names, result.messages)
materialize_all(config, result.messages, only=names)
for name in names:
after_unit = _unit_bytes(name, is_job[name])
action = _classify(name, after_unit)
for k, n, _ in items:
after_unit = _unit_bytes(n, k)
action = _classify((k, n), after_unit)
if action == "activate":
asyncio.run(activate(name, config, config.root))
result.activated.append(name)
asyncio.run(activate(n, k, config, config.root))
result.activated.append(n)
elif action == "deactivate":
asyncio.run(deactivate(name, config, config.root))
result.deactivated.append(name)
asyncio.run(deactivate(n, k, config, config.root))
result.deactivated.append(n)
elif action == "restart":
unit = timer_name(name) if is_job[name] else unit_name(name)
unit = timer_name(n) if k == "job" else unit_name(n, k)
subprocess.run(["systemctl", "--user", "restart", unit], check=False)
result.restarted.append(name)
result.restarted.append(n)
else:
result.unchanged.append(name)
result.unchanged.append(n)
return result
@@ -306,7 +321,7 @@ def _record(result: ApplyResult, name: str, action: str) -> None:
def _render_unit_preview(
config: CastleConfig, name: str, dep: Deployment, is_job: bool
config: CastleConfig, name: str, dep: Deployment, kind: str
) -> str | None:
"""The unit bytes `deploy` would write for the deployment we'd restart (the
.timer for a job, the .service for a service), for --plan restart detection.
@@ -314,7 +329,7 @@ def _render_unit_preview(
files = _render_unit_files(config, name, dep)
if not files:
return None
return files.get(timer_name(name) if is_job else unit_name(name))
return files.get(timer_name(name) if kind == "job" else unit_name(name, kind))
# Gateway service name in the registry → its systemd unit (castle-castle-gateway).
@@ -474,8 +489,12 @@ def _public_url(
def _target_url(config: CastleConfig, target_name: str) -> str | None:
"""The base URL another deployment is reachable at — how a ``{kind: deployment,
bind: VAR}`` requirement projects its target into the consumer's env."""
dep = config.deployments.get(target_name)
bind: VAR}`` requirement projects its target into the consumer's env. A name may
span kinds; the HTTP-exposed one is what has a URL, so prefer it."""
matches = config.deployments_named(target_name)
dep = next((s for _k, s in matches if getattr(s, "http_exposed", False)), None)
if dep is None:
dep = matches[0][1] if matches else None
if dep is None:
return None
expose = getattr(dep, "expose", None)
@@ -484,11 +503,12 @@ def _target_url(config: CastleConfig, target_name: str) -> str | None:
return _public_url(config, target_name, getattr(dep, "http_exposed", False), tport)
def _requires_env(config: CastleConfig, name: str, config_key: str) -> dict[str, str]:
def _requires_env(
config: CastleConfig, dep: DeploymentSpec, config_key: str
) -> dict[str, str]:
"""Env generated FROM a deployment's ``requires`` — a ``{kind: deployment,
bind: VAR}`` requirement sets ``VAR`` to the target's URL. Env is derived from
the dependency, never scraped back into one (see docs/relationships.md)."""
dep = config.deployments[name]
prog = config.programs.get(config_key)
reqs = list(getattr(dep, "requires", []) or [])
if prog:
@@ -656,7 +676,7 @@ def _build_deployed(
raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
# Env generated from `requires` ({kind: deployment, bind: VAR} → target URL).
# An explicit defaults.env value always wins — a hand-set var is never clobbered.
for var, url in _requires_env(config, name, config_key).items():
for var, url in _requires_env(config, dep, config_key).items():
raw_env.setdefault(var, url)
public_url = _public_url(config, name, expose, port)
ctx = _env_context(
@@ -951,12 +971,12 @@ def _format_deployed(name: str, deployed: Deployment) -> str:
def _desired_unit_files(registry: NodeRegistry) -> set[str]:
"""Exact set of unit filenames that should exist on disk for this registry."""
files: set[str] = set()
for name, deployed in registry.deployed.items():
for _key, deployed in registry.deployed.items():
if not deployed.managed:
continue
files.add(unit_name(name))
files.add(unit_name(deployed.name, deployed.kind))
if deployed.schedule:
files.add(timer_name(name))
files.add(timer_name(deployed.name))
return files
@@ -1004,13 +1024,13 @@ def _render_unit_files(
if not deployed.managed:
return {}
systemd_spec = None
dep = config.deployments.get(name)
dep = config.deployment(deployed.kind, name)
manage = getattr(dep, "manage", None)
if manage and manage.systemd:
systemd_spec = manage.systemd
files = {
unit_name(name): generate_unit_from_deployed(
unit_name(name, deployed.kind): generate_unit_from_deployed(
name, deployed, systemd_spec, env_file=unit_env_file(deployed, name)
)
}
@@ -1025,6 +1045,6 @@ def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> Non
"""Generate systemd units from the registry."""
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
for name, deployed in registry.deployed.items():
for fname, content in _render_unit_files(config, name, deployed).items():
for _key, deployed in registry.deployed.items():
for fname, content in _render_unit_files(config, deployed.name, deployed).items():
(SYSTEMD_USER_DIR / fname).write_text(content)