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

@@ -4,7 +4,7 @@ from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from dataclasses import InitVar, dataclass, field
from pathlib import Path
import yaml
@@ -139,6 +139,20 @@ class GatewayConfig:
cert_hook: bool = False
# Deployment kinds and the CastleConfig store each lives in. Kind is STRUCTURAL —
# a deployment's identity is (name, kind), so names are unique within a kind but may
# collide across kinds (a `backup` tool + service + job coexist). `kind_for` (manifest)
# stays only to validate that a spec's manager/schedule matches the store it's in.
KINDS = ("service", "job", "tool", "static", "reference")
_KIND_STORE = {
"service": "services",
"job": "jobs",
"tool": "tools",
"static": "statics",
"reference": "references",
}
@dataclass
class CastleConfig:
"""Full castle configuration."""
@@ -147,45 +161,61 @@ class CastleConfig:
gateway: GatewayConfig
repo: Path | None
programs: dict[str, ProgramSpec]
# The one deployment concept (manager-discriminated). service/job/tool/static/
# reference are *derived views* over this, filtered by kind_for — see below.
deployments: dict[str, DeploymentSpec]
# Per-kind deployment stores (the primary representation). Each is name-keyed and
# unique within its kind; a name may appear in more than one store. There is no
# single flat `deployments` dict — use `all_deployments()` / `deployments_named()`
# / the kind store directly (`config.services[name]`).
services: dict[str, DeploymentSpec] = field(default_factory=dict)
jobs: dict[str, DeploymentSpec] = field(default_factory=dict)
tools: dict[str, DeploymentSpec] = field(default_factory=dict)
statics: dict[str, DeploymentSpec] = field(default_factory=dict)
references: dict[str, DeploymentSpec] = field(default_factory=dict)
# Launchable agent CLIs for the dashboard terminal UX (assistant-agnostic).
# Optional; empty means the API falls back to a built-in default set.
agents: dict[str, AgentSpec] = field(default_factory=dict)
# Construction convenience only (not stored): a flat name→spec dict is routed
# into the per-kind stores by kind_for. Lets callers/tests hand us a flat map
# without pre-splitting it; there is still no flat `deployments` attribute.
deployments: InitVar[dict[str, DeploymentSpec] | None] = None
def deployments_of(self, name: str) -> list[tuple[str, str]]:
def __post_init__(self, deployments: dict[str, DeploymentSpec] | None) -> None:
for name, spec in (deployments or {}).items():
self.store_for(kind_for(spec))[name] = spec
def store_for(self, kind: str) -> dict[str, DeploymentSpec]:
"""The name-keyed deployment store for a kind (service|job|tool|static|reference)."""
return getattr(self, _KIND_STORE[kind])
def all_deployments(self) -> list[tuple[str, str, DeploymentSpec]]:
"""Every deployment as `(kind, name, spec)`, kind-then-name ordered. The single
shared iterate-all — the converge loop dispatches on `spec.manager`, so the
machinery stays shared; only the namespace is split by kind."""
out: list[tuple[str, str, DeploymentSpec]] = []
for kind in KINDS:
store = self.store_for(kind)
out.extend((kind, name, store[name]) for name in sorted(store))
return out
def deployment(self, kind: str, name: str) -> DeploymentSpec | None:
"""A single deployment by its `(kind, name)` identity, or None."""
return self.store_for(kind).get(name)
def deployments_named(self, name: str) -> list[tuple[str, DeploymentSpec]]:
"""`(kind, spec)` for every kind that has a deployment with this bare name
(≤5). Used where a caller has only a name and no kind (apply/restart/redirect)."""
return [(kind, spec) for kind, n, spec in self.all_deployments() if n == name]
def deployments_of(self, program: str) -> list[tuple[str, str]]:
"""A program's deployments as (deployment-name, kind) pairs, name-sorted.
A program has no kind of its own — it *has deployments*, each with a kind.
A deployment belongs to a program when it names it (`program:`) or shares
its name (the 1:1 tool/static case). Empty for a bare, undeployed program.
A deployment belongs to a program when it names it (`program:`) or shares its
name (the 1:1 tool/static case). Empty for a bare, undeployed program.
"""
out = [
(dname, kind_for(dep))
for dname, dep in self.deployments.items()
if dname == name or dep.program == name
]
return sorted(out)
@property
def services(self) -> dict[str, DeploymentSpec]:
"""Deployments whose derived kind is `service` (a continuous systemd process)."""
return {n: d for n, d in self.deployments.items() if kind_for(d) == "service"}
@property
def jobs(self) -> dict[str, DeploymentSpec]:
"""Deployments whose derived kind is `job` (a scheduled systemd timer)."""
return {n: d for n, d in self.deployments.items() if kind_for(d) == "job"}
@property
def tools(self) -> dict[str, ProgramSpec]:
"""Programs with a PATH (tool) deployment — derived, not a stored label."""
return {
k: v
for k, v in self.programs.items()
if any(kind == "tool" for _, kind in self.deployments_of(k))
}
return sorted(
(name, kind)
for kind, name, dep in self.all_deployments()
if name == program or dep.program == program
)
@property
def frontends(self) -> dict[str, ProgramSpec]:
@@ -398,17 +428,8 @@ def load_config(root: Path | None = None) -> CastleConfig:
prog.source = str(root / prog.source)
programs[name] = prog
# New layout: one deployments/ dir. Legacy: services/ + jobs/ (normalized on
# read) — used only until the one-shot migration rewrites everything.
raw = _load_resource_dir(root / "deployments")
if not raw:
raw = {
**_load_resource_dir(root / "services"),
**_load_resource_dir(root / "jobs"),
}
deployments: dict[str, DeploymentSpec] = {
name: _parse_deployment(name, dep_data) for name, dep_data in raw.items()
}
stores = _load_deployments(root)
_validate_subdomains(stores)
agents: dict[str, AgentSpec] = {
name: AgentSpec.model_validate(spec or {})
@@ -420,12 +441,62 @@ def load_config(root: Path | None = None) -> CastleConfig:
repo=repo_path,
gateway=gateway,
programs=programs,
deployments=deployments,
agents=agents,
**stores,
)
return config
def _validate_subdomains(stores: dict[str, dict[str, DeploymentSpec]]) -> None:
"""A gateway subdomain (``<name>.<domain>``) must be globally unique across kinds.
Only HTTP-exposed kinds claim one: a proxied service, or a static site. A name may
still be a tool/service/job trio (only the service is HTTP-exposed), but a service
and a static can't share a name (they'd fight over the same subdomain)."""
claimants: dict[str, list[str]] = {}
for name, spec in stores["services"].items():
if getattr(spec, "http_exposed", False):
claimants.setdefault(name, []).append("service")
for name in stores["statics"]:
claimants.setdefault(name, []).append("static")
for name, kinds in claimants.items():
if len(kinds) > 1:
raise ValueError(
f"subdomain '{name}' is claimed by multiple HTTP-exposed deployments "
f"({', '.join(kinds)}); a name can be HTTP-exposed by at most one kind"
)
def _load_deployments(root: Path) -> dict[str, dict[str, DeploymentSpec]]:
"""Load the per-kind deployment stores for a config root.
New layout: ``deployments/<store>/<name>.yaml`` (store = services|jobs|tools|
statics|references). Read-compat for the pre-migration layouts: flat
``deployments/*.yaml`` files, and the older ``services/``+``jobs/`` split. Every
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()}
def route(name: str, data: dict) -> None:
spec = _parse_deployment(name, data)
stores[_KIND_STORE[kind_for(spec)]][name] = spec
dep_dir = root / "deployments"
# New per-kind subdirs.
for store in _KIND_STORE.values():
for name, data in _load_resource_dir(dep_dir / store).items():
route(name, data)
# Legacy flat deployments/*.yaml (top-level only — subdirs handled above).
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
def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> object:
"""Recursively remove empty lists and non-structural empty dicts."""
if preserve_keys is None:
@@ -565,10 +636,17 @@ def save_config(config: CastleConfig) -> None:
config.root / "programs",
{n: _program_to_yaml_dict(s, config) for n, s in config.programs.items()},
)
_write_resource_dir(
config.root / "deployments",
{n: _spec_to_yaml_dict(d) for n, d in config.deployments.items()},
)
# Per-kind: deployments/<store>/<name>.yaml (each store pruned independently).
dep_dir = config.root / "deployments"
for kind, store in _KIND_STORE.items():
_write_resource_dir(
dep_dir / store,
{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() -> None:

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)

View File

@@ -75,24 +75,25 @@ def _local_routes(
falls back to the deployed registry snapshot when config isn't available.
"""
out: list[tuple[str, str, str]] = []
deployments = getattr(config, "deployments", None)
if deployments is not None:
for name, dep in sorted(deployments.items()):
if config is not None:
# Only HTTP-exposed kinds route: static (file-serve) and service (proxy).
# jobs/tools/references never do.
for kind, name, dep in config.all_deployments():
# A disabled deployment is defined but not running — no route (else it
# would 502). `castle apply` converges it off.
if not dep.enabled:
continue
if isinstance(dep, CaddyDeployment):
if kind == "static" and isinstance(dep, CaddyDeployment):
src = _program_source(config, dep.program)
if src is not None:
out.append((name, "static", str(src / dep.root)))
elif isinstance(dep, SystemdDeployment):
elif kind == "service" and isinstance(dep, SystemdDeployment):
expose, port, base_url = service_proxy_targets(name, dep)
if expose and (port or base_url):
out.append((name, "proxy", base_url or f"localhost:{port}"))
return out
# No config → route from the deployed registry snapshot.
for name, d in sorted(registry.deployed.items()):
for _kind, name, d in registry.all():
if not d.enabled:
continue
if d.static_root:

View File

@@ -17,14 +17,26 @@ UNIT_PREFIX = "castle-"
SECRET_ENV_DIR = SECRETS_DIR / "env"
def unit_name(service_name: str) -> str:
"""Get the systemd unit name for a service."""
return f"{UNIT_PREFIX}{service_name}.service"
def unit_basename(name: str, kind: str = "service") -> str:
"""The systemd unit stem for a deployment. Jobs carry a ``-job`` marker so a
service and a job can share a name (`castle-<name>.service` vs
`castle-<name>-job.{service,timer}`); everything else is `castle-<name>`."""
return f"{UNIT_PREFIX}{name}-job" if kind == "job" else f"{UNIT_PREFIX}{name}"
def secret_env_path(service_name: str) -> Path:
def unit_name(service_name: str, kind: str = "service") -> str:
"""Get the systemd `.service` unit name for a deployment of the given kind."""
return f"{unit_basename(service_name, kind)}.service"
def timer_name(service_name: str, kind: str = "job") -> str:
"""Get the systemd `.timer` unit name (timers exist only for jobs)."""
return f"{unit_basename(service_name, kind)}.timer"
def secret_env_path(service_name: str, kind: str = "service") -> Path:
"""Path to a deployment's generated secret env file (1:1 with its unit name)."""
return SECRET_ENV_DIR / f"{unit_name(service_name)}.env"
return SECRET_ENV_DIR / f"{unit_name(service_name, kind)}.env"
def unit_env_file(deployed: Deployment, name: str) -> Path | None:
@@ -36,12 +48,7 @@ def unit_env_file(deployed: Deployment, name: str) -> Path | None:
"""
if deployed.launcher == "container" or not deployed.secret_env_keys:
return None
return secret_env_path(name)
def timer_name(service_name: str) -> str:
"""Get the systemd timer name for a scheduled service."""
return f"{UNIT_PREFIX}{service_name}.timer"
return secret_env_path(name, deployed.kind)
def cron_to_oncalendar(cron: str) -> str:

View File

@@ -41,7 +41,7 @@ def public_deployments(registry: NodeRegistry) -> list[tuple[str, Deployment]]:
"""The deployed services flagged public (and actually routed), name-sorted."""
return sorted(
(name, d)
for name, d in registry.deployed.items()
for _kind, name, d in registry.all()
if d.public and d.subdomain
)

View File

@@ -87,15 +87,15 @@ def tool_installed(name: str) -> bool:
return _on_path(name)
def _svc_manager(name: str, config: CastleConfig) -> str | None:
"""The manager for a deployed name, or None if not deployed."""
dep = config.deployments.get(name)
def _svc_manager(name: str, kind: str, config: CastleConfig) -> str | None:
"""The manager for a deployment (name, kind), or None if not in config."""
dep = config.deployment(kind, name)
return dep.manager if dep is not None else None
def _static_built(name: str, config: CastleConfig) -> bool:
"""Whether a static (caddy) deployment's served dir exists (assets are built)."""
dep = config.deployments.get(name)
dep = config.statics.get(name)
if not isinstance(dep, CaddyDeployment):
return False
comp = config.programs.get(dep.program or name)
@@ -107,11 +107,11 @@ def _static_built(name: str, config: CastleConfig) -> bool:
# ---------------------------------------------------------------------------
def is_active(name: str, config: CastleConfig) -> bool:
"""Whether a deployment is available in its mode, dispatched by its manager."""
manager = _svc_manager(name, config)
def is_active(name: str, kind: str, config: CastleConfig) -> bool:
"""Whether a deployment (name, kind) is available in its mode, by manager."""
manager = _svc_manager(name, kind, config)
if manager == "systemd":
unit = timer_name(name) if name in config.jobs else unit_name(name)
unit = timer_name(name) if kind == "job" else unit_name(name, kind)
return _systemctl_active(unit)
if manager == "caddy":
return _static_built(name, config) # served once its assets exist
@@ -131,31 +131,31 @@ def is_active(name: str, config: CastleConfig) -> bool:
# ---------------------------------------------------------------------------
def enable_service(name: str, config: CastleConfig) -> ActionResult:
def enable_service(name: str, kind: str, config: CastleConfig) -> ActionResult:
"""Generate+install the unit (and timer) from the registry, enable and start it."""
if not REGISTRY_PATH.exists():
return ActionResult(
name, "activate", "error", "No registry. Run 'castle deploy' first."
)
registry = load_registry()
if name not in registry.deployed:
deployed = registry.get(kind, name)
if deployed is None:
return ActionResult(
name, "activate", "error", f"'{name}' not in registry; run 'castle deploy'."
)
deployed = registry.deployed[name]
if not deployed.managed:
return ActionResult(
name, "activate", "error", f"'{name}' is not a managed service."
)
systemd_spec = None
dep = config.deployments.get(name)
dep = config.deployment(kind, name)
manage = getattr(dep, "manage", None)
if manage:
systemd_spec = manage.systemd
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
svc_unit = unit_name(name)
svc_unit = unit_name(name, kind)
(SYSTEMD_USER_DIR / svc_unit).write_text(
generate_unit_from_deployed(
name, deployed, systemd_spec, env_file=unit_env_file(deployed, name)
@@ -180,16 +180,19 @@ def enable_service(name: str, config: CastleConfig) -> ActionResult:
)
def disable_service(name: str) -> ActionResult:
"""Stop, disable, and remove the unit (and timer) for a service/job."""
for unit in (timer_name(name), unit_name(name)):
def disable_service(name: str, kind: str) -> ActionResult:
"""Stop, disable, and remove the unit (and timer) for a service/job of a kind."""
units = [unit_name(name, kind)]
if kind == "job":
units.append(timer_name(name))
for unit in units:
path = SYSTEMD_USER_DIR / unit
if path.exists():
subprocess.run(["systemctl", "--user", "stop", unit], check=False)
subprocess.run(["systemctl", "--user", "disable", unit], check=False)
path.unlink()
# Drop the generated secret env file alongside the unit.
secret_env_path(name).unlink(missing_ok=True)
secret_env_path(name, kind).unlink(missing_ok=True)
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
return ActionResult(name, "deactivate", "ok", f"{name}: deactivated")
@@ -199,16 +202,16 @@ def disable_service(name: str) -> ActionResult:
# ---------------------------------------------------------------------------
def _program_for(name: str, config: CastleConfig):
def _program_for(name: str, kind: str, config: CastleConfig):
"""The program a deployment runs (its `program` ref, defaulting to the name)."""
dep = config.deployments.get(name)
dep = config.deployment(kind, name)
prog = (dep.program if dep else None) or name
return prog, config.programs.get(prog)
async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult:
"""Make a deployment available in its mode, dispatched by its manager."""
manager = _svc_manager(name, config)
async def activate(name: str, kind: str, config: CastleConfig, root: Path) -> ActionResult:
"""Make a deployment (name, kind) available in its mode, dispatched by manager."""
manager = _svc_manager(name, kind, config)
if manager == "systemd":
# Ensure the program's binary is on PATH first (python), then enable the
@@ -218,7 +221,7 @@ async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult:
res = await run_action("install", name, comp, root)
if res.status != "ok":
return res
return enable_service(name, config)
return enable_service(name, kind, config)
if manager == "caddy":
# Served by the gateway — reload it so the route is live. Building the
@@ -229,7 +232,7 @@ async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult:
return ActionResult(name, "activate", "ok", f"{name}: served via gateway")
if manager == "path":
prog, comp = _program_for(name, config)
prog, comp = _program_for(name, kind, config)
if comp is None:
return ActionResult(name, "activate", "error", f"unknown program '{prog}'")
if _on_path(prog): # already installed — skip the (slow) editable reinstall
@@ -246,19 +249,19 @@ async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult:
return ActionResult(name, "activate", "error", f"'{name}' not found")
async def deactivate(name: str, config: CastleConfig, root: Path) -> ActionResult:
"""Take a deployment offline in its mode, dispatched by its manager."""
manager = _svc_manager(name, config)
async def deactivate(name: str, kind: str, config: CastleConfig, root: Path) -> ActionResult:
"""Take a deployment (name, kind) offline in its mode, dispatched by manager."""
manager = _svc_manager(name, kind, config)
if manager == "systemd":
return disable_service(name)
return disable_service(name, kind)
if manager == "caddy":
return ActionResult(
name, "deactivate", "ok",
f"{name}: gateway-served — remove/disable the service to drop the route.",
)
if manager == "path":
prog, comp = _program_for(name, config)
prog, comp = _program_for(name, kind, config)
if comp is None:
return ActionResult(name, "deactivate", "error", f"unknown program '{prog}'")
return await run_action("uninstall", prog, comp, root)

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)

View File

@@ -106,7 +106,7 @@ def derive_repos(config: CastleConfig) -> dict[str, Repo]:
or git.git_status(Path(top), fetch=False).branch
)
deps = sorted(
d for d, dep in config.deployments.items() if _program_of(d, dep) in progs
d for _k, d, dep in config.all_deployments() if _program_of(d, dep) in progs
)
repos[_slug(Path(top).name, used)] = Repo("", top, url, ref, progs, deps)
for key, repo in repos.items():
@@ -118,14 +118,16 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
"""The full requirement set for a deployment: its own ``requires`` plus its
program's ``requires`` and ``system_dependencies`` (the ``kind: system`` alias),
de-duplicated by (kind, ref)."""
dep = config.deployments[dep_name]
prog = config.programs.get(_program_of(dep_name, dep))
reqs: list[Requirement] = list(getattr(dep, "requires", []) or [])
if prog:
reqs += list(prog.requires)
reqs += [
Requirement(kind="system", ref=pkg) for pkg in prog.system_dependencies
]
reqs: list[Requirement] = []
# A bare name may span kinds — union their requirements (plus their program's).
for _kind, dep in config.deployments_named(dep_name):
reqs += list(getattr(dep, "requires", []) or [])
prog = config.programs.get(_program_of(dep_name, dep))
if prog:
reqs += list(prog.requires)
reqs += [
Requirement(kind="system", ref=pkg) for pkg in prog.system_dependencies
]
seen: set[tuple[str, str]] = set()
out: list[Requirement] = []
for r in reqs:
@@ -155,7 +157,7 @@ def _check(config: CastleConfig, req: Requirement) -> bool:
# package manager (the real meaning of 'installed').
return shutil.which(req.ref) is not None or _dpkg_installed(req.ref)
if req.kind == "deployment":
return req.ref in config.deployments
return bool(config.deployments_named(req.ref))
return True
@@ -173,7 +175,6 @@ def build_model(
predicate (left ``None`` when the caller has no runtime view).
- ``freshness``: also evaluate ``fresh?`` per repo (a ``git status``, no fetch —
last-known — so it stays a local, network-free probe over many repos)."""
from castle_core.manifest import kind_for
repos = derive_repos(config)
if freshness:
@@ -186,14 +187,14 @@ def build_model(
fresh_of = {key: r.fresh for key, r in repos.items()}
edges: list[Edge] = []
for name in config.deployments:
for _k, name, _d in config.all_deployments():
for r in requirements_of(config, name):
edges.append(Edge(name, r.ref, r.kind, r.bind))
fan_in = Counter(e.dst for e in edges if e.kind == "deployment")
nodes: list[Node] = []
for name, dep in config.deployments.items():
for _nk, name, dep in config.all_deployments():
unmet = (
[
f"{r.kind}:{r.ref}"
@@ -208,7 +209,7 @@ def build_model(
Node(
name=name,
program=_program_of(name, dep),
kind=kind_for(dep),
kind=_nk,
repo=repo_key,
depended_on_by=fan_in.get(name, 0),
unmet=unmet,

View File

@@ -157,7 +157,7 @@ def materialize_all(
process until the next ``castle tls reconcile``)."""
msgs = messages if messages is not None else []
scope = set(only) if only is not None else None
for name, dep in sorted(config.deployments.items()):
for _kind, name, dep in config.all_deployments():
if scope is not None and name not in scope:
continue
if _tls_of(dep) is None:
@@ -188,7 +188,7 @@ def wait_for_wildcard(
needs = [
n
for n in names
if (dep := config.deployments.get(n)) is not None and _tls_of(dep) is not None
if any(_tls_of(spec) is not None for _k, spec in config.deployments_named(n))
]
if not needs:
return msgs
@@ -227,7 +227,7 @@ def reconcile_tls(config: CastleConfig, messages: list[str] | None = None) -> li
a no-op when nothing rotated. Invoked by ``castle tls reconcile`` and the Caddy
``cert_obtained`` hook. Logs its own outcome (the hook's ``exec`` swallows it)."""
msgs = messages if messages is not None else []
for name, dep in sorted(config.deployments.items()):
for _kind, name, dep in config.all_deployments():
tls = _tls_of(dep)
if tls is None:
continue