diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index 5451b47..ca96b03 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -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 (``.``) 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//.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//.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: diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index bcd147e..2a2b0e4 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -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) diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index ddb0d9c..98298ef 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -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: diff --git a/core/src/castle_core/generators/systemd.py b/core/src/castle_core/generators/systemd.py index f6aca8f..10d437e 100644 --- a/core/src/castle_core/generators/systemd.py +++ b/core/src/castle_core/generators/systemd.py @@ -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-.service` vs + `castle--job.{service,timer}`); everything else is `castle-`.""" + 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: diff --git a/core/src/castle_core/generators/tunnel.py b/core/src/castle_core/generators/tunnel.py index 7603c5c..093ac60 100644 --- a/core/src/castle_core/generators/tunnel.py +++ b/core/src/castle_core/generators/tunnel.py @@ -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 ) diff --git a/core/src/castle_core/lifecycle.py b/core/src/castle_core/lifecycle.py index 2c90745..4a49f4b 100644 --- a/core/src/castle_core/lifecycle.py +++ b/core/src/castle_core/lifecycle.py @@ -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) diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py index b87f927..a6d53d9 100644 --- a/core/src/castle_core/registry.py +++ b/core/src/castle_core/registry.py @@ -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 ``"/"`` 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 "/" (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) diff --git a/core/src/castle_core/relations.py b/core/src/castle_core/relations.py index 91a0941..be36f27 100644 --- a/core/src/castle_core/relations.py +++ b/core/src/castle_core/relations.py @@ -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, diff --git a/core/src/castle_core/tls.py b/core/src/castle_core/tls.py index e690065..eeb9e9b 100644 --- a/core/src/castle_core/tls.py +++ b/core/src/castle_core/tls.py @@ -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 diff --git a/core/tests/test_apply.py b/core/tests/test_apply.py index 03c199c..fc29f45 100644 --- a/core/tests/test_apply.py +++ b/core/tests/test_apply.py @@ -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] diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py index 4c135be..86639f6 100644 --- a/core/tests/test_caddyfile.py +++ b/core/tests/test_caddyfile.py @@ -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: diff --git a/core/tests/test_config.py b/core/tests/test_config.py index 37ecfce..59d0fc6 100644 --- a/core/tests/test_config.py +++ b/core/tests/test_config.py @@ -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//.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 diff --git a/core/tests/test_deploy_prune.py b/core/tests/test_deploy_prune.py index 1c04f06..55f8332 100644 --- a/core/tests/test_deploy_prune.py +++ b/core/tests/test_deploy_prune.py @@ -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)) diff --git a/core/tests/test_deploy_secret_env.py b/core/tests/test_deploy_secret_env.py index 029355f..7df99ef 100644 --- a/core/tests/test_deploy_secret_env.py +++ b/core/tests/test_deploy_secret_env.py @@ -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"} diff --git a/core/tests/test_kind_identity.py b/core/tests/test_kind_identity.py new file mode 100644 index 0000000..ad0e532 --- /dev/null +++ b/core/tests/test_kind_identity.py @@ -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-.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) diff --git a/core/tests/test_lifecycle.py b/core/tests/test_lifecycle.py index 1db0fdb..341035b 100644 --- a/core/tests/test_lifecycle.py +++ b/core/tests/test_lifecycle.py @@ -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