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 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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"}
|
|
||||||
|
|||||||
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:
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user