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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,10 @@ from castle_core.registry import Deployment
|
||||
|
||||
def _plan(castle_root: Path, active: dict[str, bool]):
|
||||
"""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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
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]
|
||||
|
||||
@@ -41,7 +41,7 @@ def _make_registry(
|
||||
gateway_domain: str | None = None,
|
||||
acme_email: str | None = None,
|
||||
) -> NodeRegistry:
|
||||
return NodeRegistry(
|
||||
reg = NodeRegistry(
|
||||
node=NodeConfig(
|
||||
hostname="test",
|
||||
gateway_port=gateway_port,
|
||||
@@ -49,8 +49,11 @@ def _make_registry(
|
||||
gateway_domain=gateway_domain,
|
||||
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:
|
||||
|
||||
@@ -120,10 +120,10 @@ class TestSaveConfig:
|
||||
config = load_config(castle_root)
|
||||
save_config(config)
|
||||
assert (castle_root / "programs" / "test-tool.yaml").exists()
|
||||
# service, job, and tool all live under the single deployments/ dir now.
|
||||
assert (castle_root / "deployments" / "test-svc.yaml").exists()
|
||||
assert (castle_root / "deployments" / "test-job.yaml").exists()
|
||||
assert (castle_root / "deployments" / "test-tool.yaml").exists()
|
||||
# Deployments live under per-kind subdirs (deployments/<store>/<name>.yaml).
|
||||
assert (castle_root / "deployments" / "services" / "test-svc.yaml").exists()
|
||||
assert (castle_root / "deployments" / "jobs" / "test-job.yaml").exists()
|
||||
assert (castle_root / "deployments" / "tools" / "test-tool.yaml").exists()
|
||||
# Global file holds gateway only, no resource sections
|
||||
global_data = yaml.safe_load((castle_root / "castle.yaml").read_text())
|
||||
assert global_data["gateway"]["port"] == 18000
|
||||
@@ -133,9 +133,9 @@ class TestSaveConfig:
|
||||
def test_delete_prunes_file(self, castle_root: Path) -> None:
|
||||
"""Removing a deployment and saving deletes its on-disk file."""
|
||||
config = load_config(castle_root)
|
||||
del config.deployments["test-svc"]
|
||||
del config.services["test-svc"]
|
||||
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)
|
||||
assert "test-svc" not in config2.services
|
||||
assert "test-tool" in config2.programs
|
||||
@@ -300,7 +300,7 @@ class TestConfigRoundTrip:
|
||||
assert loaded.gateway.domain == "civil.payne.io"
|
||||
|
||||
# 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.expose.tcp.port == 5432
|
||||
assert d.expose.tcp.tls.material == TlsMaterial.PAIR
|
||||
|
||||
@@ -18,11 +18,16 @@ def _svc(managed: bool = True, schedule: str | None = None) -> Deployment:
|
||||
env={},
|
||||
managed=managed,
|
||||
schedule=schedule,
|
||||
kind="job" if schedule else "service",
|
||||
)
|
||||
|
||||
|
||||
def _registry(**deployed: Deployment) -> NodeRegistry:
|
||||
return NodeRegistry(node=NodeConfig(castle_root="/x", gateway_port=9000), deployed=dict(deployed))
|
||||
reg = NodeRegistry(node=NodeConfig(castle_root="/x", gateway_port=9000))
|
||||
for name, d in deployed.items():
|
||||
d.name = name
|
||||
reg.put(d)
|
||||
return reg
|
||||
|
||||
|
||||
def _touch(d: Path, *names: str) -> None:
|
||||
@@ -37,7 +42,7 @@ class TestDesiredUnitFiles:
|
||||
|
||||
def test_scheduled_job_yields_service_and_timer(self) -> None:
|
||||
reg = _registry(job=_svc(schedule="0 2 * * *"))
|
||||
assert _desired_unit_files(reg) == {"castle-job.service", "castle-job.timer"}
|
||||
assert _desired_unit_files(reg) == {"castle-job-job.service", "castle-job-job.timer"}
|
||||
|
||||
def test_unmanaged_excluded(self) -> None:
|
||||
reg = _registry(foo=_svc(managed=False))
|
||||
|
||||
@@ -61,17 +61,16 @@ class TestWriteSecretEnvFile:
|
||||
class TestRegistrySecretKeys:
|
||||
def test_round_trip_persists_keys_only(self, tmp_path: Path) -> None:
|
||||
reg_path = tmp_path / "registry.yaml"
|
||||
registry = NodeRegistry(
|
||||
node=NodeConfig(hostname="h"),
|
||||
deployed={
|
||||
"svc": Deployment(
|
||||
manager="systemd", launcher="container",
|
||||
run_cmd=["docker", "run"],
|
||||
env={"PORT": "9001"},
|
||||
secret_env_keys=["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
||||
managed=True,
|
||||
)
|
||||
},
|
||||
registry = NodeRegistry(node=NodeConfig(hostname="h"))
|
||||
registry.put(
|
||||
Deployment(
|
||||
manager="systemd", launcher="container",
|
||||
run_cmd=["docker", "run"],
|
||||
env={"PORT": "9001"},
|
||||
secret_env_keys=["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
||||
managed=True,
|
||||
name="svc",
|
||||
)
|
||||
)
|
||||
save_registry(registry, reg_path)
|
||||
|
||||
@@ -80,8 +79,6 @@ class TestRegistrySecretKeys:
|
||||
assert "sk-ant" not in text # but no values ever
|
||||
|
||||
loaded = load_registry(reg_path)
|
||||
assert loaded.deployed["svc"].secret_env_keys == [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
]
|
||||
assert loaded.deployed["svc"].env == {"PORT": "9001"}
|
||||
svc = loaded.get("service", "svc")
|
||||
assert svc.secret_env_keys == ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]
|
||||
assert svc.env == {"PORT": "9001"}
|
||||
|
||||
84
core/tests/test_kind_identity.py
Normal file
84
core/tests/test_kind_identity.py
Normal 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)
|
||||
@@ -13,26 +13,26 @@ class TestIsActive:
|
||||
def test_service_uses_systemctl(self, castle_root: Path) -> None:
|
||||
config = load_config(castle_root)
|
||||
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")
|
||||
|
||||
def test_job_uses_timer(self, castle_root: Path) -> None:
|
||||
config = load_config(castle_root)
|
||||
with patch.object(lifecycle, "_systemctl_active", return_value=True) as mock:
|
||||
assert lifecycle.is_active("test-job", config) is True
|
||||
mock.assert_called_once_with("castle-test-job.timer")
|
||||
assert lifecycle.is_active("test-job", "job", config) is True
|
||||
mock.assert_called_once_with("castle-test-job-job.timer")
|
||||
|
||||
def test_tool_checks_path(self, castle_root: Path) -> None:
|
||||
config = load_config(castle_root)
|
||||
# give the tool a source so the tool branch is reachable
|
||||
config.programs["test-tool"].source = "/tmp/test-tool"
|
||||
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")
|
||||
|
||||
def test_unknown_is_inactive(self, castle_root: Path) -> None:
|
||||
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:
|
||||
# A `manager: path` deployment (a tool) is active when on PATH.
|
||||
@@ -40,9 +40,9 @@ class TestIsActive:
|
||||
|
||||
config = load_config(castle_root)
|
||||
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:
|
||||
assert lifecycle.is_active("mytool", config) is True
|
||||
assert lifecycle.is_active("mytool", "tool", config) is True
|
||||
mock.assert_called_once_with("mytool")
|
||||
|
||||
def test_remote_deployment_is_active(self, castle_root: Path) -> None:
|
||||
@@ -50,10 +50,10 @@ class TestIsActive:
|
||||
from castle_core.manifest import RemoteDeployment
|
||||
|
||||
config = load_config(castle_root)
|
||||
config.deployments["ext"] = RemoteDeployment(
|
||||
config.references["ext"] = RemoteDeployment(
|
||||
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(
|
||||
self, castle_root: Path, tmp_path: Path
|
||||
@@ -64,11 +64,11 @@ class TestIsActive:
|
||||
config = load_config(castle_root)
|
||||
repo = tmp_path / "fe"
|
||||
config.programs["fe"] = ProgramSpec(id="fe", source=str(repo))
|
||||
config.deployments["fe"] = CaddyDeployment(
|
||||
config.statics["fe"] = CaddyDeployment(
|
||||
manager="caddy", program="fe", root="dist"
|
||||
)
|
||||
# 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
|
||||
(repo / "dist").mkdir(parents=True)
|
||||
assert lifecycle.is_active("fe", config) is True
|
||||
assert lifecycle.is_active("fe", "static", config) is True
|
||||
|
||||
Reference in New Issue
Block a user