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 os
import re import re
from dataclasses import dataclass, field from dataclasses import InitVar, dataclass, field
from pathlib import Path from pathlib import Path
import yaml import yaml
@@ -139,6 +139,20 @@ class GatewayConfig:
cert_hook: bool = False 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 @dataclass
class CastleConfig: class CastleConfig:
"""Full castle configuration.""" """Full castle configuration."""
@@ -147,45 +161,61 @@ class CastleConfig:
gateway: GatewayConfig gateway: GatewayConfig
repo: Path | None repo: Path | None
programs: dict[str, ProgramSpec] programs: dict[str, ProgramSpec]
# The one deployment concept (manager-discriminated). service/job/tool/static/ # Per-kind deployment stores (the primary representation). Each is name-keyed and
# reference are *derived views* over this, filtered by kind_for — see below. # unique within its kind; a name may appear in more than one store. There is no
deployments: dict[str, DeploymentSpec] # 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). # Launchable agent CLIs for the dashboard terminal UX (assistant-agnostic).
# Optional; empty means the API falls back to a built-in default set. # Optional; empty means the API falls back to a built-in default set.
agents: dict[str, AgentSpec] = field(default_factory=dict) 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'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
A deployment belongs to a program when it names it (`program:`) or shares name (the 1:1 tool/static case). Empty for a bare, undeployed program.
its name (the 1:1 tool/static case). Empty for a bare, undeployed program.
""" """
out = [ return sorted(
(dname, kind_for(dep)) (name, kind)
for dname, dep in self.deployments.items() for kind, name, dep in self.all_deployments()
if dname == name or dep.program == name if name == program or dep.program == program
] )
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))
}
@property @property
def frontends(self) -> dict[str, ProgramSpec]: def frontends(self) -> dict[str, ProgramSpec]:
@@ -398,17 +428,8 @@ def load_config(root: Path | None = None) -> CastleConfig:
prog.source = str(root / prog.source) prog.source = str(root / prog.source)
programs[name] = prog programs[name] = prog
# New layout: one deployments/ dir. Legacy: services/ + jobs/ (normalized on stores = _load_deployments(root)
# read) — used only until the one-shot migration rewrites everything. _validate_subdomains(stores)
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()
}
agents: dict[str, AgentSpec] = { agents: dict[str, AgentSpec] = {
name: AgentSpec.model_validate(spec or {}) name: AgentSpec.model_validate(spec or {})
@@ -420,12 +441,62 @@ def load_config(root: Path | None = None) -> CastleConfig:
repo=repo_path, repo=repo_path,
gateway=gateway, gateway=gateway,
programs=programs, programs=programs,
deployments=deployments,
agents=agents, agents=agents,
**stores,
) )
return config 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: def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> object:
"""Recursively remove empty lists and non-structural empty dicts.""" """Recursively remove empty lists and non-structural empty dicts."""
if preserve_keys is None: if preserve_keys is None:
@@ -565,10 +636,17 @@ def save_config(config: CastleConfig) -> None:
config.root / "programs", config.root / "programs",
{n: _program_to_yaml_dict(s, config) for n, s in config.programs.items()}, {n: _program_to_yaml_dict(s, config) for n, s in config.programs.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( _write_resource_dir(
config.root / "deployments", dep_dir / store,
{n: _spec_to_yaml_dict(d) for n, d in config.deployments.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() -> None: 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) registry = NodeRegistry(node=node)
# Deploy every deployment, dispatched by its manager (systemd/caddy/path/none). # 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: if target_name and name != target_name:
continue continue
deployed = _build_deployed(config, name, dep, result.messages) deployed = _build_deployed(config, name, dep, result.messages)
registry.deployed[name] = deployed deployed.name = name
registry.put(deployed)
result.deployed_count += 1 result.deployed_count += 1
result.messages.append(_format_deployed(name, deployed)) 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).""" """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.""" """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 return path.read_text() if path.exists() else None
@@ -223,39 +225,52 @@ def apply(
from castle_core.lifecycle import activate, deactivate, is_active from castle_core.lifecycle import activate, deactivate, is_active
config = load_config(root) config = load_config(root)
names = [n for n in config.deployments if not target_name or n == target_name] # Each item is (kind, name, spec); target_name matches every kind of that name.
is_job = {n: (n in config.jobs) for n in names} # 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). # Snapshot BEFORE rendering: liveness + current unit bytes (for restart-on-change).
before_active = {n: is_active(n, config) for n in names} before_active = {(k, n): is_active(n, k, config) for k, n, _ in items}
before_unit = {n: _unit_bytes(n, is_job[n]) for n in names} 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 # 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 # this is recomputed by deploy() below (which also writes it); cheap and keeps
# the plan/apply classification identical. # 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: def _classify(ident: tuple[str, str], after_unit: str | None) -> str:
dep = desired[name] dep = desired[ident]
if not dep.enabled: if not dep.enabled:
return "deactivate" if before_active[name] else "unchanged" return "deactivate" if before_active[ident] else "unchanged"
if not before_active[name]: if not before_active[ident]:
return "activate" 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 "restart"
return "unchanged" return "unchanged"
result = ApplyResult( 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: if plan:
# No writes: for systemd, predict the new unit bytes by rendering to a string # No writes: for systemd, predict the new unit bytes by rendering to a string
# so "would restart" is accurate; other managers never restart. # so "would restart" is accurate; other managers never restart.
result.planned = True result.planned = True
for name in names: for k, n, _ in items:
after = _render_unit_preview(config, name, desired[name], is_job[name]) after = _render_unit_preview(config, n, desired[(k, n)], k)
_record(result, name, _classify(name, after)) _record(result, n, _classify((k, n), after))
return result return result
# Real run: render everything (writes units/Caddyfile/tunnel, daemon-reload, # Real run: render everything (writes units/Caddyfile/tunnel, daemon-reload,
@@ -277,21 +292,21 @@ def apply(
wait_for_wildcard(config, names, result.messages) wait_for_wildcard(config, names, result.messages)
materialize_all(config, result.messages, only=names) materialize_all(config, result.messages, only=names)
for name in names: for k, n, _ in items:
after_unit = _unit_bytes(name, is_job[name]) after_unit = _unit_bytes(n, k)
action = _classify(name, after_unit) action = _classify((k, n), after_unit)
if action == "activate": if action == "activate":
asyncio.run(activate(name, config, config.root)) asyncio.run(activate(n, k, config, config.root))
result.activated.append(name) result.activated.append(n)
elif action == "deactivate": elif action == "deactivate":
asyncio.run(deactivate(name, config, config.root)) asyncio.run(deactivate(n, k, config, config.root))
result.deactivated.append(name) result.deactivated.append(n)
elif action == "restart": 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) subprocess.run(["systemctl", "--user", "restart", unit], check=False)
result.restarted.append(name) result.restarted.append(n)
else: else:
result.unchanged.append(name) result.unchanged.append(n)
return result return result
@@ -306,7 +321,7 @@ def _record(result: ApplyResult, name: str, action: str) -> None:
def _render_unit_preview( def _render_unit_preview(
config: CastleConfig, name: str, dep: Deployment, is_job: bool config: CastleConfig, name: str, dep: Deployment, kind: str
) -> str | None: ) -> str | None:
"""The unit bytes `deploy` would write for the deployment we'd restart (the """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. .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) files = _render_unit_files(config, name, dep)
if not files: if not files:
return None 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). # 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: def _target_url(config: CastleConfig, target_name: str) -> str | None:
"""The base URL another deployment is reachable at — how a ``{kind: deployment, """The base URL another deployment is reachable at — how a ``{kind: deployment,
bind: VAR}`` requirement projects its target into the consumer's env.""" bind: VAR}`` requirement projects its target into the consumer's env. A name may
dep = config.deployments.get(target_name) 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: if dep is None:
return None return None
expose = getattr(dep, "expose", 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) 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, """Env generated FROM a deployment's ``requires`` — a ``{kind: deployment,
bind: VAR}`` requirement sets ``VAR`` to the target's URL. Env is derived from 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).""" the dependency, never scraped back into one (see docs/relationships.md)."""
dep = config.deployments[name]
prog = config.programs.get(config_key) prog = config.programs.get(config_key)
reqs = list(getattr(dep, "requires", []) or []) reqs = list(getattr(dep, "requires", []) or [])
if prog: if prog:
@@ -656,7 +676,7 @@ def _build_deployed(
raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {} raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
# Env generated from `requires` ({kind: deployment, bind: VAR} → target URL). # Env generated from `requires` ({kind: deployment, bind: VAR} → target URL).
# An explicit defaults.env value always wins — a hand-set var is never clobbered. # 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) raw_env.setdefault(var, url)
public_url = _public_url(config, name, expose, port) public_url = _public_url(config, name, expose, port)
ctx = _env_context( ctx = _env_context(
@@ -951,12 +971,12 @@ def _format_deployed(name: str, deployed: Deployment) -> str:
def _desired_unit_files(registry: NodeRegistry) -> set[str]: def _desired_unit_files(registry: NodeRegistry) -> set[str]:
"""Exact set of unit filenames that should exist on disk for this registry.""" """Exact set of unit filenames that should exist on disk for this registry."""
files: set[str] = set() files: set[str] = set()
for name, deployed in registry.deployed.items(): for _key, deployed in registry.deployed.items():
if not deployed.managed: if not deployed.managed:
continue continue
files.add(unit_name(name)) files.add(unit_name(deployed.name, deployed.kind))
if deployed.schedule: if deployed.schedule:
files.add(timer_name(name)) files.add(timer_name(deployed.name))
return files return files
@@ -1004,13 +1024,13 @@ def _render_unit_files(
if not deployed.managed: if not deployed.managed:
return {} return {}
systemd_spec = None systemd_spec = None
dep = config.deployments.get(name) dep = config.deployment(deployed.kind, name)
manage = getattr(dep, "manage", None) manage = getattr(dep, "manage", None)
if manage and manage.systemd: if manage and manage.systemd:
systemd_spec = manage.systemd systemd_spec = manage.systemd
files = { 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) 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.""" """Generate systemd units from the registry."""
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True) SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
for name, deployed in registry.deployed.items(): for _key, deployed in registry.deployed.items():
for fname, content in _render_unit_files(config, name, deployed).items(): for fname, content in _render_unit_files(config, deployed.name, deployed).items():
(SYSTEMD_USER_DIR / fname).write_text(content) (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. falls back to the deployed registry snapshot when config isn't available.
""" """
out: list[tuple[str, str, str]] = [] out: list[tuple[str, str, str]] = []
deployments = getattr(config, "deployments", None) if config is not None:
if deployments is not None: # Only HTTP-exposed kinds route: static (file-serve) and service (proxy).
for name, dep in sorted(deployments.items()): # 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 # A disabled deployment is defined but not running — no route (else it
# would 502). `castle apply` converges it off. # would 502). `castle apply` converges it off.
if not dep.enabled: if not dep.enabled:
continue continue
if isinstance(dep, CaddyDeployment): if kind == "static" and isinstance(dep, CaddyDeployment):
src = _program_source(config, dep.program) src = _program_source(config, dep.program)
if src is not None: if src is not None:
out.append((name, "static", str(src / dep.root))) 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) expose, port, base_url = service_proxy_targets(name, dep)
if expose and (port or base_url): if expose and (port or base_url):
out.append((name, "proxy", base_url or f"localhost:{port}")) out.append((name, "proxy", base_url or f"localhost:{port}"))
return out return out
# No config → route from the deployed registry snapshot. # 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: if not d.enabled:
continue continue
if d.static_root: if d.static_root:

View File

@@ -17,14 +17,26 @@ UNIT_PREFIX = "castle-"
SECRET_ENV_DIR = SECRETS_DIR / "env" SECRET_ENV_DIR = SECRETS_DIR / "env"
def unit_name(service_name: str) -> str: def unit_basename(name: str, kind: str = "service") -> str:
"""Get the systemd unit name for a service.""" """The systemd unit stem for a deployment. Jobs carry a ``-job`` marker so a
return f"{UNIT_PREFIX}{service_name}.service" 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).""" """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: 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: if deployed.launcher == "container" or not deployed.secret_env_keys:
return None return None
return secret_env_path(name) return secret_env_path(name, deployed.kind)
def timer_name(service_name: str) -> str:
"""Get the systemd timer name for a scheduled service."""
return f"{UNIT_PREFIX}{service_name}.timer"
def cron_to_oncalendar(cron: str) -> str: 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.""" """The deployed services flagged public (and actually routed), name-sorted."""
return sorted( return sorted(
(name, d) (name, d)
for name, d in registry.deployed.items() for _kind, name, d in registry.all()
if d.public and d.subdomain if d.public and d.subdomain
) )

View File

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

View File

@@ -61,7 +61,9 @@ class Deployment:
# visibility (which secrets a deployment expects). # visibility (which secrets a deployment expects).
secret_env_keys: list[str] = field(default_factory=list) secret_env_keys: list[str] = field(default_factory=list)
description: str | None = None 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" kind: str = "service"
stack: str | None = None stack: str | None = None
port: int | None = None port: int | None = None
@@ -88,11 +90,34 @@ class Deployment:
@dataclass @dataclass
class NodeRegistry: 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 node: NodeConfig
deployed: dict[str, Deployment] = field(default_factory=dict) 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: def load_registry(path: Path | None = None) -> NodeRegistry:
"""Load the node registry from ~/.castle/registry.yaml.""" """Load the node registry from ~/.castle/registry.yaml."""
@@ -126,7 +151,9 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
) )
deployed: dict[str, Deployment] = {} 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. # New shape carries manager/launcher/kind; legacy carries runner/behavior.
manager = comp_data.get("manager") manager = comp_data.get("manager")
launcher = comp_data.get("launcher") launcher = comp_data.get("launcher")
@@ -137,7 +164,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
) )
if manager == "systemd": if manager == "systemd":
launcher = runner launcher = runner
kind = comp_data.get("kind") kind = comp_data.get("kind") or key_kind
if kind is None: if kind is None:
behavior = comp_data.get("behavior") behavior = comp_data.get("behavior")
if comp_data.get("schedule"): if comp_data.get("schedule"):
@@ -150,7 +177,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
kind = "reference" kind = "reference"
else: else:
kind = "service" kind = "service"
deployed[name] = Deployment( deployed[NodeRegistry.key(kind, name)] = Deployment(
manager=manager, manager=manager,
launcher=launcher, launcher=launcher,
run_cmd=comp_data.get("run_cmd", []), 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", []), path_prepend=comp_data.get("path_prepend", []),
secret_env_keys=comp_data.get("secret_env_keys", []), secret_env_keys=comp_data.get("secret_env_keys", []),
description=comp_data.get("description"), description=comp_data.get("description"),
name=name,
kind=kind, kind=kind,
stack=comp_data.get("stack"), stack=comp_data.get("stack"),
port=comp_data.get("port"), port=comp_data.get("port"),
@@ -209,7 +237,7 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
if registry.node.cert_hook: if registry.node.cert_hook:
data["node"]["cert_hook"] = 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 = { entry: dict = {
"manager": comp.manager, "manager": comp.manager,
"run_cmd": comp.run_cmd, "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. # registries byte-identical and matches the load-side default.
if not comp.enabled: if not comp.enabled:
entry["enabled"] = comp.enabled entry["enabled"] = comp.enabled
data["deployed"][name] = entry data["deployed"][key] = entry
with open(path, "w") as f: with open(path, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False) 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 or git.git_status(Path(top), fetch=False).branch
) )
deps = sorted( 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) repos[_slug(Path(top).name, used)] = Repo("", top, url, ref, progs, deps)
for key, repo in repos.items(): for key, repo in repos.items():
@@ -118,9 +118,11 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
"""The full requirement set for a deployment: its own ``requires`` plus its """The full requirement set for a deployment: its own ``requires`` plus its
program's ``requires`` and ``system_dependencies`` (the ``kind: system`` alias), program's ``requires`` and ``system_dependencies`` (the ``kind: system`` alias),
de-duplicated by (kind, ref).""" de-duplicated by (kind, ref)."""
dep = config.deployments[dep_name] 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)) prog = config.programs.get(_program_of(dep_name, dep))
reqs: list[Requirement] = list(getattr(dep, "requires", []) or [])
if prog: if prog:
reqs += list(prog.requires) reqs += list(prog.requires)
reqs += [ reqs += [
@@ -155,7 +157,7 @@ def _check(config: CastleConfig, req: Requirement) -> bool:
# package manager (the real meaning of 'installed'). # package manager (the real meaning of 'installed').
return shutil.which(req.ref) is not None or _dpkg_installed(req.ref) return shutil.which(req.ref) is not None or _dpkg_installed(req.ref)
if req.kind == "deployment": if req.kind == "deployment":
return req.ref in config.deployments return bool(config.deployments_named(req.ref))
return True return True
@@ -173,7 +175,6 @@ def build_model(
predicate (left ``None`` when the caller has no runtime view). predicate (left ``None`` when the caller has no runtime view).
- ``freshness``: also evaluate ``fresh?`` per repo (a ``git status``, no fetch — - ``freshness``: also evaluate ``fresh?`` per repo (a ``git status``, no fetch —
last-known — so it stays a local, network-free probe over many repos).""" last-known — so it stays a local, network-free probe over many repos)."""
from castle_core.manifest import kind_for
repos = derive_repos(config) repos = derive_repos(config)
if freshness: if freshness:
@@ -186,14 +187,14 @@ def build_model(
fresh_of = {key: r.fresh for key, r in repos.items()} fresh_of = {key: r.fresh for key, r in repos.items()}
edges: list[Edge] = [] edges: list[Edge] = []
for name in config.deployments: for _k, name, _d in config.all_deployments():
for r in requirements_of(config, name): for r in requirements_of(config, name):
edges.append(Edge(name, r.ref, r.kind, r.bind)) edges.append(Edge(name, r.ref, r.kind, r.bind))
fan_in = Counter(e.dst for e in edges if e.kind == "deployment") fan_in = Counter(e.dst for e in edges if e.kind == "deployment")
nodes: list[Node] = [] nodes: list[Node] = []
for name, dep in config.deployments.items(): for _nk, name, dep in config.all_deployments():
unmet = ( unmet = (
[ [
f"{r.kind}:{r.ref}" f"{r.kind}:{r.ref}"
@@ -208,7 +209,7 @@ def build_model(
Node( Node(
name=name, name=name,
program=_program_of(name, dep), program=_program_of(name, dep),
kind=kind_for(dep), kind=_nk,
repo=repo_key, repo=repo_key,
depended_on_by=fan_in.get(name, 0), depended_on_by=fan_in.get(name, 0),
unmet=unmet, unmet=unmet,

View File

@@ -157,7 +157,7 @@ def materialize_all(
process until the next ``castle tls reconcile``).""" process until the next ``castle tls reconcile``)."""
msgs = messages if messages is not None else [] msgs = messages if messages is not None else []
scope = set(only) if only is not None else None 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: if scope is not None and name not in scope:
continue continue
if _tls_of(dep) is None: if _tls_of(dep) is None:
@@ -188,7 +188,7 @@ def wait_for_wildcard(
needs = [ needs = [
n n
for n in names 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: if not needs:
return msgs 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 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).""" ``cert_obtained`` hook. Logs its own outcome (the hook's ``exec`` swallows it)."""
msgs = messages if messages is not None else [] 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) tls = _tls_of(dep)
if tls is None: if tls is None:
continue continue

View File

@@ -17,7 +17,10 @@ from castle_core.registry import Deployment
def _plan(castle_root: Path, active: dict[str, bool]): def _plan(castle_root: Path, active: dict[str, bool]):
"""Run apply(plan=True) with is_active stubbed to `active` (default False).""" """Run apply(plan=True) with is_active stubbed to `active` (default False)."""
with patch("castle_core.lifecycle.is_active", side_effect=lambda n, c: active.get(n, False)): with patch(
"castle_core.lifecycle.is_active",
side_effect=lambda n, k, c: active.get(n, False),
):
return apply(root=castle_root, plan=True) return apply(root=castle_root, plan=True)
@@ -71,4 +74,4 @@ def test_render_unit_preview_none_for_non_systemd() -> None:
A path deployment is unmanaged, so the renderer returns before touching config. A path deployment is unmanaged, so the renderer returns before touching config.
""" """
tool = Deployment(manager="path", run_cmd=[], kind="tool") tool = Deployment(manager="path", run_cmd=[], kind="tool")
assert _render_unit_preview(None, "x", tool, is_job=False) is None # type: ignore[arg-type] assert _render_unit_preview(None, "x", tool, "tool") is None # type: ignore[arg-type]

View File

@@ -41,7 +41,7 @@ def _make_registry(
gateway_domain: str | None = None, gateway_domain: str | None = None,
acme_email: str | None = None, acme_email: str | None = None,
) -> NodeRegistry: ) -> NodeRegistry:
return NodeRegistry( reg = NodeRegistry(
node=NodeConfig( node=NodeConfig(
hostname="test", hostname="test",
gateway_port=gateway_port, gateway_port=gateway_port,
@@ -49,8 +49,11 @@ def _make_registry(
gateway_domain=gateway_domain, gateway_domain=gateway_domain,
acme_email=acme_email, acme_email=acme_email,
), ),
deployed=deployed or {},
) )
for name, d in (deployed or {}).items():
d.name = name
reg.put(d)
return reg
def _dep(port: int, *, expose: bool, name: str | None = None, launcher: str = "python") -> Deployment: def _dep(port: int, *, expose: bool, name: str | None = None, launcher: str = "python") -> Deployment:

View File

@@ -120,10 +120,10 @@ class TestSaveConfig:
config = load_config(castle_root) config = load_config(castle_root)
save_config(config) save_config(config)
assert (castle_root / "programs" / "test-tool.yaml").exists() assert (castle_root / "programs" / "test-tool.yaml").exists()
# service, job, and tool all live under the single deployments/ dir now. # Deployments live under per-kind subdirs (deployments/<store>/<name>.yaml).
assert (castle_root / "deployments" / "test-svc.yaml").exists() assert (castle_root / "deployments" / "services" / "test-svc.yaml").exists()
assert (castle_root / "deployments" / "test-job.yaml").exists() assert (castle_root / "deployments" / "jobs" / "test-job.yaml").exists()
assert (castle_root / "deployments" / "test-tool.yaml").exists() assert (castle_root / "deployments" / "tools" / "test-tool.yaml").exists()
# Global file holds gateway only, no resource sections # Global file holds gateway only, no resource sections
global_data = yaml.safe_load((castle_root / "castle.yaml").read_text()) global_data = yaml.safe_load((castle_root / "castle.yaml").read_text())
assert global_data["gateway"]["port"] == 18000 assert global_data["gateway"]["port"] == 18000
@@ -133,9 +133,9 @@ class TestSaveConfig:
def test_delete_prunes_file(self, castle_root: Path) -> None: def test_delete_prunes_file(self, castle_root: Path) -> None:
"""Removing a deployment and saving deletes its on-disk file.""" """Removing a deployment and saving deletes its on-disk file."""
config = load_config(castle_root) config = load_config(castle_root)
del config.deployments["test-svc"] del config.services["test-svc"]
save_config(config) save_config(config)
assert not (castle_root / "deployments" / "test-svc.yaml").exists() assert not (castle_root / "deployments" / "services" / "test-svc.yaml").exists()
config2 = load_config(castle_root) config2 = load_config(castle_root)
assert "test-svc" not in config2.services assert "test-svc" not in config2.services
assert "test-tool" in config2.programs assert "test-tool" in config2.programs
@@ -300,7 +300,7 @@ class TestConfigRoundTrip:
assert loaded.gateway.domain == "civil.payne.io" assert loaded.gateway.domain == "civil.payne.io"
# Deployment: reach + full TCP/TLS + container user/tmpfs must survive. # Deployment: reach + full TCP/TLS + container user/tmpfs must survive.
d = loaded.deployments["pg"] d = loaded.services["pg"]
assert d.reach == Reach.INTERNAL assert d.reach == Reach.INTERNAL
assert d.expose.tcp.port == 5432 assert d.expose.tcp.port == 5432
assert d.expose.tcp.tls.material == TlsMaterial.PAIR assert d.expose.tcp.tls.material == TlsMaterial.PAIR

View File

@@ -18,11 +18,16 @@ def _svc(managed: bool = True, schedule: str | None = None) -> Deployment:
env={}, env={},
managed=managed, managed=managed,
schedule=schedule, schedule=schedule,
kind="job" if schedule else "service",
) )
def _registry(**deployed: Deployment) -> NodeRegistry: 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: def _touch(d: Path, *names: str) -> None:
@@ -37,7 +42,7 @@ class TestDesiredUnitFiles:
def test_scheduled_job_yields_service_and_timer(self) -> None: def test_scheduled_job_yields_service_and_timer(self) -> None:
reg = _registry(job=_svc(schedule="0 2 * * *")) 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: def test_unmanaged_excluded(self) -> None:
reg = _registry(foo=_svc(managed=False)) reg = _registry(foo=_svc(managed=False))

View File

@@ -61,17 +61,16 @@ class TestWriteSecretEnvFile:
class TestRegistrySecretKeys: class TestRegistrySecretKeys:
def test_round_trip_persists_keys_only(self, tmp_path: Path) -> None: def test_round_trip_persists_keys_only(self, tmp_path: Path) -> None:
reg_path = tmp_path / "registry.yaml" reg_path = tmp_path / "registry.yaml"
registry = NodeRegistry( registry = NodeRegistry(node=NodeConfig(hostname="h"))
node=NodeConfig(hostname="h"), registry.put(
deployed={ Deployment(
"svc": Deployment(
manager="systemd", launcher="container", manager="systemd", launcher="container",
run_cmd=["docker", "run"], run_cmd=["docker", "run"],
env={"PORT": "9001"}, env={"PORT": "9001"},
secret_env_keys=["ANTHROPIC_API_KEY", "OPENAI_API_KEY"], secret_env_keys=["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
managed=True, managed=True,
name="svc",
) )
},
) )
save_registry(registry, reg_path) save_registry(registry, reg_path)
@@ -80,8 +79,6 @@ class TestRegistrySecretKeys:
assert "sk-ant" not in text # but no values ever assert "sk-ant" not in text # but no values ever
loaded = load_registry(reg_path) loaded = load_registry(reg_path)
assert loaded.deployed["svc"].secret_env_keys == [ svc = loaded.get("service", "svc")
"ANTHROPIC_API_KEY", assert svc.secret_env_keys == ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]
"OPENAI_API_KEY", assert svc.env == {"PORT": "9001"}
]
assert loaded.deployed["svc"].env == {"PORT": "9001"}

View File

@@ -0,0 +1,84 @@
"""A deployment's identity is (name, kind): a tool, a service, and a job can all be
named the same. These lock the per-kind stores, the storage round-trip, distinct
unit names (job-suffix), and the subdomain-uniqueness guard."""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from castle_core.config import load_config, save_config
from castle_core.deploy import _build_deployed, _desired_unit_files
from castle_core.registry import NodeConfig, NodeRegistry
def _write(root: Path, store: str, name: str, spec: dict) -> None:
d = root / "deployments" / store
d.mkdir(parents=True, exist_ok=True)
(d / f"{name}.yaml").write_text(yaml.dump(spec))
def _trio(root: Path) -> None:
(root / "castle.yaml").write_text(yaml.dump({"gateway": {"port": 9000}}))
_write(root, "services", "backup", {
"manager": "systemd", "run": {"launcher": "python", "program": "backup"},
})
_write(root, "jobs", "backup", {
"manager": "systemd", "run": {"launcher": "command", "argv": ["backup"]},
"schedule": "0 2 * * *",
})
_write(root, "tools", "backup", {"manager": "path", "program": "backup"})
def test_same_name_across_kinds_loads_into_distinct_stores(tmp_path: Path) -> None:
_trio(tmp_path)
config = load_config(tmp_path)
assert "backup" in config.services
assert "backup" in config.jobs
assert "backup" in config.tools
# all_deployments yields all three with their kinds
kinds = {kind for kind, name, _ in config.all_deployments() if name == "backup"}
assert kinds == {"service", "job", "tool"}
# deployments_named collects the trio
assert {k for k, _ in config.deployments_named("backup")} == {"service", "job", "tool"}
def test_trio_round_trips_through_per_kind_dirs(tmp_path: Path) -> None:
_trio(tmp_path)
config = load_config(tmp_path)
save_config(config)
# written under per-kind subdirs
assert (tmp_path / "deployments" / "services" / "backup.yaml").exists()
assert (tmp_path / "deployments" / "jobs" / "backup.yaml").exists()
assert (tmp_path / "deployments" / "tools" / "backup.yaml").exists()
reloaded = load_config(tmp_path)
assert "backup" in reloaded.services and "backup" in reloaded.jobs
assert "backup" in reloaded.tools
def test_service_and_job_get_distinct_units(tmp_path: Path) -> None:
_trio(tmp_path)
config = load_config(tmp_path)
registry = NodeRegistry(node=NodeConfig(hostname="h", gateway_port=9000))
for _kind, name, spec in config.all_deployments():
dep = _build_deployed(config, name, spec, [])
dep.name = name
registry.put(dep)
units = _desired_unit_files(registry)
# service keeps castle-<name>.service; job carries the -job marker; tool has none.
assert "castle-backup.service" in units
assert "castle-backup-job.service" in units
assert "castle-backup-job.timer" in units
def test_service_and_static_cannot_share_a_subdomain(tmp_path: Path) -> None:
(tmp_path / "castle.yaml").write_text(yaml.dump({"gateway": {"port": 9000}}))
_write(tmp_path, "services", "app", {
"manager": "systemd", "run": {"launcher": "python", "program": "app"},
"reach": "internal", "expose": {"http": {"internal": {"port": 9001}}},
})
_write(tmp_path, "statics", "app", {"manager": "caddy", "program": "app", "root": "dist"})
with pytest.raises(ValueError, match="subdomain 'app'"):
load_config(tmp_path)

View File

@@ -13,26 +13,26 @@ class TestIsActive:
def test_service_uses_systemctl(self, castle_root: Path) -> None: def test_service_uses_systemctl(self, castle_root: Path) -> None:
config = load_config(castle_root) config = load_config(castle_root)
with patch.object(lifecycle, "_systemctl_active", return_value=True) as mock: with patch.object(lifecycle, "_systemctl_active", return_value=True) as mock:
assert lifecycle.is_active("test-svc", config) is True assert lifecycle.is_active("test-svc", "service", config) is True
mock.assert_called_once_with("castle-test-svc.service") mock.assert_called_once_with("castle-test-svc.service")
def test_job_uses_timer(self, castle_root: Path) -> None: def test_job_uses_timer(self, castle_root: Path) -> None:
config = load_config(castle_root) config = load_config(castle_root)
with patch.object(lifecycle, "_systemctl_active", return_value=True) as mock: with patch.object(lifecycle, "_systemctl_active", return_value=True) as mock:
assert lifecycle.is_active("test-job", config) is True assert lifecycle.is_active("test-job", "job", config) is True
mock.assert_called_once_with("castle-test-job.timer") mock.assert_called_once_with("castle-test-job-job.timer")
def test_tool_checks_path(self, castle_root: Path) -> None: def test_tool_checks_path(self, castle_root: Path) -> None:
config = load_config(castle_root) config = load_config(castle_root)
# give the tool a source so the tool branch is reachable # give the tool a source so the tool branch is reachable
config.programs["test-tool"].source = "/tmp/test-tool" config.programs["test-tool"].source = "/tmp/test-tool"
with patch.object(lifecycle, "_on_path", return_value=True) as mock: with patch.object(lifecycle, "_on_path", return_value=True) as mock:
assert lifecycle.is_active("test-tool", config) is True assert lifecycle.is_active("test-tool", "tool", config) is True
mock.assert_called_once_with("test-tool") mock.assert_called_once_with("test-tool")
def test_unknown_is_inactive(self, castle_root: Path) -> None: def test_unknown_is_inactive(self, castle_root: Path) -> None:
config = load_config(castle_root) config = load_config(castle_root)
assert lifecycle.is_active("does-not-exist", config) is False assert lifecycle.is_active("does-not-exist", "service", config) is False
def test_path_deployment_checks_path(self, castle_root: Path) -> None: def test_path_deployment_checks_path(self, castle_root: Path) -> None:
# A `manager: path` deployment (a tool) is active when on PATH. # A `manager: path` deployment (a tool) is active when on PATH.
@@ -40,9 +40,9 @@ class TestIsActive:
config = load_config(castle_root) config = load_config(castle_root)
config.programs["mytool"] = ProgramSpec(id="mytool", source="/tmp/mytool") config.programs["mytool"] = ProgramSpec(id="mytool", source="/tmp/mytool")
config.deployments["mytool"] = PathDeployment(manager="path", program="mytool") config.tools["mytool"] = PathDeployment(manager="path", program="mytool")
with patch.object(lifecycle, "_on_path", return_value=True) as mock: with patch.object(lifecycle, "_on_path", return_value=True) as mock:
assert lifecycle.is_active("mytool", config) is True assert lifecycle.is_active("mytool", "tool", config) is True
mock.assert_called_once_with("mytool") mock.assert_called_once_with("mytool")
def test_remote_deployment_is_active(self, castle_root: Path) -> None: def test_remote_deployment_is_active(self, castle_root: Path) -> None:
@@ -50,10 +50,10 @@ class TestIsActive:
from castle_core.manifest import RemoteDeployment from castle_core.manifest import RemoteDeployment
config = load_config(castle_root) config = load_config(castle_root)
config.deployments["ext"] = RemoteDeployment( config.references["ext"] = RemoteDeployment(
manager="none", program="ext", base_url="http://x" manager="none", program="ext", base_url="http://x"
) )
assert lifecycle.is_active("ext", config) is True assert lifecycle.is_active("ext", "reference", config) is True
def test_static_deployment_active_when_dist_built( def test_static_deployment_active_when_dist_built(
self, castle_root: Path, tmp_path: Path self, castle_root: Path, tmp_path: Path
@@ -64,11 +64,11 @@ class TestIsActive:
config = load_config(castle_root) config = load_config(castle_root)
repo = tmp_path / "fe" repo = tmp_path / "fe"
config.programs["fe"] = ProgramSpec(id="fe", source=str(repo)) config.programs["fe"] = ProgramSpec(id="fe", source=str(repo))
config.deployments["fe"] = CaddyDeployment( config.statics["fe"] = CaddyDeployment(
manager="caddy", program="fe", root="dist" manager="caddy", program="fe", root="dist"
) )
# No dist yet → inactive (caddy manager checks the served dir) # No dist yet → inactive (caddy manager checks the served dir)
assert lifecycle.is_active("fe", config) is False assert lifecycle.is_active("fe", "static", config) is False
# Built dist → served in place → active # Built dist → served in place → active
(repo / "dist").mkdir(parents=True) (repo / "dist").mkdir(parents=True)
assert lifecycle.is_active("fe", config) is True assert lifecycle.is_active("fe", "static", config) is True