From 0cb41851cfd3c1661d51eabd6a3f747c02017527 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 6 Jul 2026 02:44:25 -0700 Subject: [PATCH] cli: resolve deployments by (name, kind), not bare name Thread kind through every CLI resolver so a tool, service, and job can share a name. Reads go through config.deployment(kind,name) / deployments_named(name) / all_deployments(); writes target the per-kind store (config.services/jobs/tools/statics). delete cascades over (kind,name) tuples; run/info resolve via registry.named(). --- cli/src/castle_cli/commands/add.py | 7 +-- cli/src/castle_cli/commands/apply.py | 6 +-- cli/src/castle_cli/commands/create.py | 10 ++-- cli/src/castle_cli/commands/delete.py | 31 ++++++----- cli/src/castle_cli/commands/deploy_create.py | 6 +-- cli/src/castle_cli/commands/doctor.py | 34 +++++------- cli/src/castle_cli/commands/info.py | 5 +- cli/src/castle_cli/commands/list_cmd.py | 36 ++++++------- cli/src/castle_cli/commands/logs.py | 2 +- cli/src/castle_cli/commands/run_cmd.py | 6 ++- cli/src/castle_cli/commands/service.py | 56 ++++++++++---------- cli/src/castle_cli/commands/tls.py | 2 +- cli/src/castle_cli/commands/tool.py | 9 +--- cli/src/castle_cli/main.py | 8 +-- cli/tests/test_add.py | 6 +-- cli/tests/test_create.py | 8 ++- 16 files changed, 106 insertions(+), 126 deletions(-) diff --git a/cli/src/castle_cli/commands/add.py b/cli/src/castle_cli/commands/add.py index 115a3c7..624ba88 100644 --- a/cli/src/castle_cli/commands/add.py +++ b/cli/src/castle_cli/commands/add.py @@ -16,10 +16,7 @@ from castle_cli.manifest import BuildSpec, CommandsSpec, ProgramSpec def _is_git_url(s: str) -> bool: - return ( - s.startswith(("http://", "https://", "git@", "ssh://")) - or s.endswith(".git") - ) + return s.startswith(("http://", "https://", "git@", "ssh://")) or s.endswith(".git") def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]: @@ -87,7 +84,7 @@ def run_add(args: argparse.Namespace) -> int: source = str(src_path) name = args.name or src_path.name - if name in config.programs or name in config.deployments: + if name in config.programs or config.deployments_named(name): print(f"Error: '{name}' already exists in castle.yaml") return 1 diff --git a/cli/src/castle_cli/commands/apply.py b/cli/src/castle_cli/commands/apply.py index db1b939..e5e005f 100644 --- a/cli/src/castle_cli/commands/apply.py +++ b/cli/src/castle_cli/commands/apply.py @@ -13,9 +13,9 @@ from __future__ import annotations import argparse _C = { - "activate": "\033[32m", # green - "restart": "\033[33m", # yellow - "deactivate": "\033[31m", # red + "activate": "\033[32m", # green + "restart": "\033[33m", # yellow + "deactivate": "\033[31m", # red "reset": "\033[0m", "dim": "\033[90m", } diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 31fad48..3282d0e 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -51,7 +51,7 @@ STACK_REQUIRES: dict[str, list[Requirement]] = { def next_available_port(config: object) -> int: """Find the next available port starting from 9001 (9000 is reserved for gateway).""" used_ports = set() - for dep in config.deployments.values(): + for _k, _n, dep in config.all_deployments(): expose = getattr(dep, "expose", None) if expose and expose.http: used_ports.add(expose.http.internal.port) @@ -71,7 +71,7 @@ def run_create(args: argparse.Namespace) -> int: stack = args.stack kind = STACK_DEFAULTS.get(stack) if stack else None - if name in config.programs or name in config.deployments: + if name in config.programs or config.deployments_named(name): print(f"Error: '{name}' already exists in castle.yaml") return 1 @@ -125,12 +125,12 @@ def run_create(args: argparse.Namespace) -> int: ) if kind == "tool": # A PATH-managed deployment: installed via `uv tool install`, no unit/route. - config.deployments[name] = PathDeployment( + config.tools[name] = PathDeployment( id=name, manager="path", program=name, description=description ) elif kind == "static": # A caddy-managed static deployment: no systemd unit, served from the build dir. - config.deployments[name] = CaddyDeployment( + config.statics[name] = CaddyDeployment( id=name, manager="caddy", program=name, @@ -139,7 +139,7 @@ def run_create(args: argparse.Namespace) -> int: ) elif kind == "service": prefix = name.replace("-", "_").upper() - config.deployments[name] = SystemdDeployment( + config.services[name] = SystemdDeployment( id=name, manager="systemd", program=name, diff --git a/cli/src/castle_cli/commands/delete.py b/cli/src/castle_cli/commands/delete.py index 9ec1041..67cba85 100644 --- a/cli/src/castle_cli/commands/delete.py +++ b/cli/src/castle_cli/commands/delete.py @@ -31,7 +31,8 @@ def run_delete(args: argparse.Namespace) -> int: # single deployments/ collection — the kind is derived, not a separate section. _DEPLOY_RESOURCES = (None, "service", "job", "tool", "static", "deployment") in_programs = name in config.programs and resource in (None, "program") - in_deployment = name in config.deployments and resource in _DEPLOY_RESOURCES + named = config.deployments_named(name) # [(kind, spec), ...] sharing this name + in_deployment = bool(named) and resource in _DEPLOY_RESOURCES if not (in_programs or in_deployment): where = f" {resource}" if resource else "" print(f"Error: no{where} '{name}' in castle.yaml") @@ -44,13 +45,16 @@ def run_delete(args: argparse.Namespace) -> int: # Cascade: every deployment referencing this program is torn down and removed # (a program and its 1:1 service/tool/static are one thing to the user). A # deployment-only delete targets just the co-named deployment. - deployments_to_remove: list[str] = [] + # Each entry is (kind, name) — the deployment's identity. + deployments_to_remove: list[tuple[str, str]] = [] if in_programs: deployments_to_remove = [ - d for d, spec in config.deployments.items() if spec.program == name + (kind, n) for kind, n, spec in config.all_deployments() if spec.program == name ] - if in_deployment and name not in deployments_to_remove: - deployments_to_remove.append(name) + if in_deployment: + for kind, _spec in named: + if (kind, name) not in deployments_to_remove: + deployments_to_remove.append((kind, name)) # Capture remnant facts BEFORE mutating config: public CNAMEs, and the program # entry itself — its stack may own persistent data (a DB schema) that survives @@ -71,7 +75,10 @@ def run_delete(args: argparse.Namespace) -> int: purge_data = getattr(args, "purge_data", False) print(f"Will remove '{name}' from castle.yaml ({', '.join(where)}).") if deployments_to_remove: - print(f"Will tear down deployment(s): {', '.join(deployments_to_remove)}") + print( + "Will tear down deployment(s): " + + ", ".join(f"{n} ({k})" for k, n in deployments_to_remove) + ) if args.source and source_dir: print(f"Will ALSO delete source directory: {source_dir}") if owns_data and purge_data: @@ -95,14 +102,14 @@ def run_delete(args: argparse.Namespace) -> int: from castle_core.lifecycle import deactivate - for d in deployments_to_remove: + for kind, d in deployments_to_remove: try: - res = asyncio.run(deactivate(d, config, config.root)) + res = asyncio.run(deactivate(d, kind, config, config.root)) if getattr(res, "message", None): print(f" {res.message}") except Exception as e: print(f" warning: teardown of '{d}' failed: {e}") - del config.deployments[d] + config.store_for(kind).pop(d, None) if in_programs: del config.programs[name] @@ -155,7 +162,7 @@ def run_delete(args: argparse.Namespace) -> int: return 0 -def _public_hosts(config, deployment_names: list[str]) -> list[str]: +def _public_hosts(config, deployments: list[tuple[str, str]]) -> list[str]: """The Cloudflare CNAMEs (.) of any public deployments being removed — surfaced so the operator can clean up DNS.""" gw = getattr(config, "gateway", None) @@ -163,8 +170,8 @@ def _public_hosts(config, deployment_names: list[str]) -> list[str]: if not public_domain: return [] hosts: list[str] = [] - for d in deployment_names: - spec = config.deployments.get(d) + for kind, d in deployments: + spec = config.deployment(kind, d) if spec is None or not getattr(spec, "public", False): continue sub = getattr(spec, "subdomain", None) or d diff --git a/cli/src/castle_cli/commands/deploy_create.py b/cli/src/castle_cli/commands/deploy_create.py index d999c03..7894df5 100644 --- a/cli/src/castle_cli/commands/deploy_create.py +++ b/cli/src/castle_cli/commands/deploy_create.py @@ -43,7 +43,7 @@ def _run_spec(launcher: str, target: str, name: str) -> RunPython | RunCommand: def _check_new(config: object, name: str, label: str) -> str | None: """Return an error message if the deployment name is taken, else None.""" - if name in config.deployments: # type: ignore[attr-defined] + if config.deployments_named(name): return f"Error: {label} '{name}' already exists." return None @@ -70,7 +70,7 @@ def run_service_create(args: argparse.Namespace) -> int: # Expose at . (the subdomain is the service name). reach = Reach.OFF if args.no_proxy else Reach.INTERNAL - config.deployments[name] = SystemdDeployment( + config.services[name] = SystemdDeployment( id=name, manager="systemd", program=args.program, @@ -104,7 +104,7 @@ def run_job_create(args: argparse.Namespace) -> int: run = _run_spec(args.launcher, args.run or args.program or name, name) # A job is a systemd deployment with a schedule (→ a .timer). - config.deployments[name] = SystemdDeployment( + config.jobs[name] = SystemdDeployment( id=name, manager="systemd", program=args.program, diff --git a/cli/src/castle_cli/commands/doctor.py b/cli/src/castle_cli/commands/doctor.py index cb9f097..c6734a0 100644 --- a/cli/src/castle_cli/commands/doctor.py +++ b/cli/src/castle_cli/commands/doctor.py @@ -136,11 +136,9 @@ def _check_configuration(config) -> list[Check]: ) ) - missing = [n for n in (_GATEWAY, _API, _DASHBOARD) if n not in config.deployments] + missing = [n for n in (_GATEWAY, _API, _DASHBOARD) if not config.deployments_named(n)] if not missing: - checks.append( - Check(OK, "control plane registered", detail="gateway, api, dashboard") - ) + checks.append(Check(OK, "control plane registered", detail="gateway, api, dashboard")) else: checks.append( Check( @@ -158,7 +156,7 @@ def _check_configuration(config) -> list[Check]: def _check_dashboard_built(config) -> Check: from castle_core.lifecycle import _static_built - if _DASHBOARD not in config.deployments: + if not config.deployments_named(_DASHBOARD): return Check(WARN, "dashboard not registered", detail="skipping build check") if _static_built(_DASHBOARD, config): return Check(OK, "dashboard built", detail="app/dist/") @@ -174,7 +172,7 @@ def _check_dashboard_built(config) -> Check: def _deployment_port(config, name: str) -> int | None: - dep = config.deployments.get(name) + dep = next((d for _k, d in config.deployments_named(name)), None) expose = getattr(dep, "expose", None) http = getattr(expose, "http", None) internal = getattr(http, "internal", None) @@ -189,7 +187,7 @@ def _check_runtime(config) -> list[Check]: # Gateway: active + actually listening on its port. gw_port = config.gateway.port - if is_active(_GATEWAY, config): + if is_active(_GATEWAY, "service", config): if _port_open(gw_port): checks.append(Check(OK, "gateway running", detail=f"listening on :{gw_port}")) else: @@ -212,7 +210,7 @@ def _check_runtime(config) -> list[Check]: # API: active + listening on its port. api_port = _deployment_port(config, _API) - if is_active(_API, config): + if is_active(_API, "service", config): if api_port and not _port_open(api_port): checks.append( Check( @@ -226,9 +224,7 @@ def _check_runtime(config) -> list[Check]: detail = f"listening on :{api_port}" if api_port else "" checks.append(Check(OK, "castle-api running", detail=detail)) else: - checks.append( - Check(FAIL, "castle-api not running", hint="castle apply") - ) + checks.append(Check(FAIL, "castle-api not running", hint="castle apply")) # Generated artifacts. registry = SPECS_DIR / "registry.yaml" @@ -236,9 +232,7 @@ def _check_runtime(config) -> list[Check]: if registry.exists() and caddyfile.exists(): checks.append(Check(OK, "registry + Caddyfile generated")) else: - missing = [ - p.name for p in (registry, caddyfile) if not p.exists() - ] + missing = [p.name for p in (registry, caddyfile) if not p.exists()] checks.append( Check( FAIL, @@ -319,14 +313,12 @@ def _check_tls_exposure(config) -> list[Check]: checks.append(_check_privileged_ports()) # Public exposure (only relevant if a deployment opts in). - public = [n for n, d in config.deployments.items() if getattr(d, "public", False)] + public = [n for _k, n, d in config.all_deployments() if getattr(d, "public", False)] if public: from castle_core.lifecycle import is_active if gw.public_domain and gw.tunnel_id: - checks.append( - Check(OK, "tunnel configured", detail=f"{len(public)} public service(s)") - ) + checks.append(Check(OK, "tunnel configured", detail=f"{len(public)} public service(s)")) else: missing = [] if not gw.public_domain: @@ -341,7 +333,7 @@ def _check_tls_exposure(config) -> list[Check]: hint="see docs/tunnel-setup.md", ) ) - if not is_active("castle-tunnel", config): + if not is_active("castle-tunnel", "service", config): checks.append( Check( WARN, @@ -418,9 +410,7 @@ def _check_public_dns(config) -> Check: def _check_privileged_ports() -> Check: try: - val = int( - Path("/proc/sys/net/ipv4/ip_unprivileged_port_start").read_text().strip() - ) + val = int(Path("/proc/sys/net/ipv4/ip_unprivileged_port_start").read_text().strip()) except (OSError, ValueError): return Check(WARN, "cannot read unprivileged port floor") if val <= 80: diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index 57f6842..02fed47 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -23,7 +23,7 @@ def _load_deployed_program(name: str) -> object | None: from castle_core.registry import load_registry registry = load_registry() - return registry.deployed.get(name) + return next(iter(registry.named(name)), None) except (FileNotFoundError, ValueError): return None @@ -127,8 +127,7 @@ def run_info(args: argparse.Namespace) -> int: # Raw-TCP reach is internal-only (public TCP is rejected at load, see # SystemdDeployment._validate_reach), so there's no "public" state here. print( - f" {BOLD}tcp{RESET}: " - f"{name}.:{service.tcp_port} (internal)" + f" {BOLD}tcp{RESET}: {name}.:{service.tcp_port} (internal)" ) if service.manage and service.manage.systemd: sd = service.manage.systemd diff --git a/cli/src/castle_cli/commands/list_cmd.py b/cli/src/castle_cli/commands/list_cmd.py index b3ff1a3..f7fe2c2 100644 --- a/cli/src/castle_cli/commands/list_cmd.py +++ b/cli/src/castle_cli/commands/list_cmd.py @@ -7,8 +7,6 @@ import argparse import json import logging -from castle_core.manifest import kind_for - from castle_cli.config import load_config log = logging.getLogger(__name__) @@ -16,7 +14,8 @@ log = logging.getLogger(__name__) def _deployments_of_kind(config: object, kind: str) -> dict: """The deployments whose derived kind matches (a lens over config.deployments).""" - return {n: d for n, d in config.deployments.items() if kind_for(d) == kind} + return config.store_for(kind) + # Terminal colors BOLD = "\033[1m" @@ -87,8 +86,8 @@ def run_list(args: argparse.Namespace) -> int: if getattr(args, "json", False): return _list_json(config, filter_kind, filter_stack) - def dot(name: str) -> str: - return f"{GREEN}●{RESET}" if is_active(name, config) else f"{RED}○{RESET}" + def dot(name: str, kind: str = "service") -> str: + return f"{GREEN}●{RESET}" if is_active(name, kind, config) else f"{RED}○{RESET}" any_output = False @@ -114,12 +113,11 @@ def run_list(args: argparse.Namespace) -> int: print(f"{CYAN}{'─' * 40}{RESET}") for name, comp in progs.items(): kinds = prog_kinds(name) - kinds_str = "".join( - f" {KIND_COLORS.get(k, '')}{k}{RESET}" for k in kinds - ) + kinds_str = "".join(f" {KIND_COLORS.get(k, '')}{k}{RESET}" for k in kinds) stack_str = f" {DIM}{comp.stack}{RESET}" if comp.stack else "" desc = f" {DIM}{comp.description}{RESET}" if comp.description else "" - print(f" {dot(name)} {BOLD}{name}{RESET}{kinds_str}{stack_str}{desc}") + pk = (prog_kinds(name) or ["service"])[0] + print(f" {dot(name, pk)} {BOLD}{name}{RESET}{kinds_str}{stack_str}{desc}") # Services + Jobs (deployment views) — independent of behavior, so only shown # when no behavior filter is applied. Each gated by its own resource scope. @@ -137,7 +135,7 @@ def run_list(args: argparse.Namespace) -> int: stack = _resolve_stack(config, name) stack_str = f" {DIM}{stack}{RESET}" if stack else "" desc = f" {DIM}{svc.description}{RESET}" if svc.description else "" - print(f" {dot(name)} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}") + print(f" {dot(name, 'service')} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}") if not filter_kind and resource in (None, "job"): jobs = _filter_by_stack(config.jobs, config, filter_stack) @@ -148,7 +146,7 @@ def run_list(args: argparse.Namespace) -> int: for name, job in jobs.items(): sched = f" {DIM}[{job.schedule}]{RESET}" desc = f" {DIM}{job.description}{RESET}" if job.description else "" - print(f" {dot(name)} {BOLD}{name}{RESET}{sched}{desc}") + print(f" {dot(name, 'job')} {BOLD}{name}{RESET}{sched}{desc}") if not filter_kind and resource in (None, "tool"): tools = _filter_by_stack(_deployments_of_kind(config, "tool"), config, filter_stack) @@ -161,7 +159,7 @@ def run_list(args: argparse.Namespace) -> int: stack = _resolve_stack(config, name) stack_str = f" {DIM}{stack}{RESET}" if stack else "" desc = f" {DIM}{d.description}{RESET}" if d.description else "" - print(f" {dot(name)} {BOLD}{name}{RESET}{stack_str}{desc}") + print(f" {dot(name, 'tool')} {BOLD}{name}{RESET}{stack_str}{desc}") if not filter_kind and resource in (None, "static"): statics = _filter_by_stack(_deployments_of_kind(config, "static"), config, filter_stack) @@ -173,7 +171,7 @@ def run_list(args: argparse.Namespace) -> int: for name, d in statics.items(): sub = f" {DIM}{name}.{RESET}" desc = f" {DIM}{d.description}{RESET}" if d.description else "" - print(f" {dot(name)} {BOLD}{name}{RESET}{sub}{desc}") + print(f" {dot(name, 'static')} {BOLD}{name}{RESET}{sub}{desc}") if not any_output: print(f"No {resource or 'program'}s found.") @@ -191,9 +189,7 @@ def _filter_by_stack( if not filter_stack: return items return { - name: item - for name, item in items.items() - if _resolve_stack(config, name) == filter_stack + name: item for name, item in items.items() if _resolve_stack(config, name) == filter_stack } @@ -217,7 +213,7 @@ def _list_json( entry: dict = { "name": name, "kinds": kinds, - "active": is_active(name, config), + "active": is_active(name, (kinds or ["service"])[0], config), } if comp.stack: entry["stack"] = comp.stack @@ -231,7 +227,7 @@ def _list_json( stack = _resolve_stack(config, name) if filter_stack and stack != filter_stack: continue - entry = {"name": name, "kind": "service", "active": is_active(name, config)} + entry = {"name": name, "kind": "service", "active": is_active(name, "service", config)} if stack: entry["stack"] = stack if svc.description: @@ -247,7 +243,7 @@ def _list_json( entry = { "name": name, "kind": "job", - "active": is_active(name, config), + "active": is_active(name, "job", config), "schedule": job.schedule, } if stack: @@ -261,7 +257,7 @@ def _list_json( stack = _resolve_stack(config, name) if filter_stack and stack != filter_stack: continue - entry = {"name": name, "kind": kind, "active": is_active(name, config)} + entry = {"name": name, "kind": kind, "active": is_active(name, kind, config)} if stack: entry["stack"] = stack if d.description: diff --git a/cli/src/castle_cli/commands/logs.py b/cli/src/castle_cli/commands/logs.py index 4f51be5..9f29592 100644 --- a/cli/src/castle_cli/commands/logs.py +++ b/cli/src/castle_cli/commands/logs.py @@ -14,7 +14,7 @@ def run_logs(args: argparse.Namespace) -> int: config = load_config() name = args.name - dep = config.deployments.get(name) + dep = next((d for _k, d in config.deployments_named(name)), None) if dep is not None and dep.manager == "systemd": if dep.run.launcher == "container": return _container_logs(name, args) diff --git a/cli/src/castle_cli/commands/run_cmd.py b/cli/src/castle_cli/commands/run_cmd.py index 1fdb9bd..f88a644 100644 --- a/cli/src/castle_cli/commands/run_cmd.py +++ b/cli/src/castle_cli/commands/run_cmd.py @@ -86,11 +86,13 @@ def run_run(args: argparse.Namespace) -> int: return 1 registry = load_registry() - if name not in registry.deployed: + matches = registry.named(name) + if not matches: print(f"Error: '{name}' is not a deployed service. Run 'castle apply' first.") return 1 - deployed = registry.deployed[name] + # A name may span kinds; run the one that has a run command (service/job). + deployed = next((d for d in matches if d.run_cmd), matches[0]) cmd = list(deployed.run_cmd) + extra_args env = dict(os.environ) env.update(deployed.env) diff --git a/cli/src/castle_cli/commands/service.py b/cli/src/castle_cli/commands/service.py index e4c4245..c31db2d 100644 --- a/cli/src/castle_cli/commands/service.py +++ b/cli/src/castle_cli/commands/service.py @@ -10,7 +10,6 @@ from castle_core.generators.systemd import ( timer_name, unit_name, ) -from castle_core.manifest import kind_for from castle_cli.config import ( CastleConfig, @@ -46,50 +45,53 @@ def run_service_cmd(args: argparse.Namespace) -> int: Lifecycle (deploy/enable/disable/start/stop) is now convergence: `castle apply`. """ config = load_config() - return _unit_action(config, args.name, "restart", is_job=False) + return _unit_action(config, args.name, "restart", "service") def run_job_cmd(args: argparse.Namespace) -> int: """`castle job restart ` — bounce the job's timer.""" config = load_config() - return _unit_action(config, args.name, "restart", is_job=True) + return _unit_action(config, args.name, "restart", "job") def run_restart(args: argparse.Namespace) -> int: """Top-level `castle restart [name]` — bounce one deployment, or all of them. An imperative op: it re-actualizes current desired state, it does not change it - (that's `castle apply`). + (that's `castle apply`). A bare name bounces every kind sharing it. """ config = load_config() name = getattr(args, "name", None) if not name: return _services_restart(config) - dep = config.deployments.get(name) - if dep is None: + named = config.deployments_named(name) + if not named: print(f"Error: no deployment '{name}'.") return 1 - return _unit_action(config, name, "restart", is_job=(kind_for(dep) == "job")) + rc = 0 + for kind, _spec in named: + rc |= _unit_action(config, name, "restart", kind) + return rc _GATEWAY_NAME = "castle-gateway" -def _unit_action(config: CastleConfig, name: str, action: str, is_job: bool) -> int: - """start/stop/restart one service or job, dispatched by its manager. +def _unit_action(config: CastleConfig, name: str, action: str, kind: str) -> int: + """start/stop/restart one deployment (name, kind), dispatched by its manager. systemd (a process/timer) → `systemctl`; caddy (static) → reload the gateway; path (a tool) → install/uninstall; none (remote) → nothing to do. """ - dep = config.deployments.get(name) + dep = config.deployment(kind, name) if dep is None: - print(f"Error: no deployment '{name}'.") + print(f"Error: no {kind} '{name}'.") return 1 manager = dep.manager if manager != "systemd": - return _managed_lifecycle(config, name, action, manager) + return _managed_lifecycle(config, name, action, manager, kind) # A scheduled systemd deployment (a job) is driven by its .timer. - unit = timer_name(name) if kind_for(dep) == "job" else unit_name(name) + unit = timer_name(name) if kind == "job" else unit_name(name, kind) result = subprocess.run(["systemctl", "--user", action, unit], check=False) if result.returncode != 0: print(f"Error: failed to {action} {unit}") @@ -98,26 +100,26 @@ def _unit_action(config: CastleConfig, name: str, action: str, is_job: bool) -> return 0 -def _managed_lifecycle(config: CastleConfig, name: str, action: str, manager: str) -> int: +def _managed_lifecycle( + config: CastleConfig, name: str, action: str, manager: str, kind: str +) -> int: """Lifecycle for non-systemd managers (no unit to systemctl).""" if manager == "caddy": if action == "stop": print(f" {name}: gateway-served — disable or remove it to drop the route.") return 0 # start/restart → reload the gateway so current routes take effect. - subprocess.run( - ["systemctl", "--user", "reload", unit_name(_GATEWAY_NAME)], check=False - ) + subprocess.run(["systemctl", "--user", "reload", unit_name(_GATEWAY_NAME)], check=False) print(f" {name}: gateway reloaded ({_PAST[action]}).") return 0 if manager == "path": - return _path_lifecycle(config, name, action) + return _path_lifecycle(config, name, action, kind) # none (remote): external, nothing local to act on. print(f" {name}: external ({manager}) — nothing to {action}.") return 0 -def _path_lifecycle(config: CastleConfig, name: str, action: str) -> int: +def _path_lifecycle(config: CastleConfig, name: str, action: str, kind: str) -> int: """A `path` (tool) deployment's lifecycle is install/uninstall on PATH.""" import asyncio @@ -125,7 +127,7 @@ def _path_lifecycle(config: CastleConfig, name: str, action: str) -> int: # stop → uninstall; start/restart → ensure installed (activate skips if on PATH). coro = deactivate if action == "stop" else activate - res = asyncio.run(coro(name, config, config.root)) + res = asyncio.run(coro(name, kind, config, config.root)) print(f" {res.output}") return 0 if res.status == "ok" else 1 @@ -136,14 +138,14 @@ def _services_restart(config: CastleConfig) -> int: caddy/path/none deployments have no unit — they ride along with the gateway restart (static) or are stateless (remote), so we don't systemctl them here. """ - for name, dep in config.deployments.items(): + for kind, name, dep in config.all_deployments(): if dep.manager != "systemd": continue - if kind_for(dep) == "job": + if kind == "job": subprocess.run(["systemctl", "--user", "restart", timer_name(name)], check=False) print(f" {name}: restarted (timer)") else: - subprocess.run(["systemctl", "--user", "restart", unit_name(name)], check=False) + subprocess.run(["systemctl", "--user", "restart", unit_name(name, kind)], check=False) print(f" {name}: restarted") return 0 @@ -166,8 +168,9 @@ def run_status(args: argparse.Namespace) -> int: if catalog: print(f"{'─' * 50}") print("Programs") - for name, comp in catalog.items(): - on = is_active(name, config) + for name, _comp in catalog.items(): + _pk = sorted({k for _, k in config.deployments_of(name)}) + on = is_active(name, _pk[0] if _pk else "tool", config) color = "\033[92m" if on else "\033[90m" label = "active" if on else "inactive" kinds = sorted({k for _, k in config.deployments_of(name)}) @@ -185,7 +188,7 @@ def _service_status(config: CastleConfig) -> int: print("=" * 50) for name, svc in config.services.items(): - active = is_active(name, config) # manager-aware (systemd/caddy/path/none) + active = is_active(name, "service", config) # manager-aware manager = svc.manager color = "\033[92m" if active else "\033[90m" reset = "\033[0m" @@ -218,4 +221,3 @@ def _service_status(config: CastleConfig) -> int: print() return 0 - diff --git a/cli/src/castle_cli/commands/tls.py b/cli/src/castle_cli/commands/tls.py index 47057d1..62730cd 100644 --- a/cli/src/castle_cli/commands/tls.py +++ b/cli/src/castle_cli/commands/tls.py @@ -55,7 +55,7 @@ def _tls_status() -> int: print(f"wildcard source: *.{domain} " + (f"[{src_fp}]" if src_fp else "(not found)")) rows = [] - for name, dep in sorted(config.deployments.items()): + for _kind, name, dep in config.all_deployments(): if _tls_of(dep) is None: continue config_key = dep.program or name diff --git a/cli/src/castle_cli/commands/tool.py b/cli/src/castle_cli/commands/tool.py index 3a3cefd..0169632 100644 --- a/cli/src/castle_cli/commands/tool.py +++ b/cli/src/castle_cli/commands/tool.py @@ -30,11 +30,7 @@ def _is_tool(config: object, name: str) -> bool: def _tool_programs(config: object) -> dict: """Programs with a tool (path) deployment, name-sorted.""" - return { - name: comp - for name, comp in sorted(config.programs.items()) - if _is_tool(config, name) - } + return {name: comp for name, comp in sorted(config.programs.items()) if _is_tool(config, name)} def _executables(comp: object) -> list[str]: @@ -77,8 +73,7 @@ def run_tool_list(args: argparse.Namespace) -> int: config = load_config() tools = _tool_programs(config) records = [ - _tool_record(config, name, comp, tool_installed(name)) - for name, comp in tools.items() + _tool_record(config, name, comp, tool_installed(name)) for name, comp in tools.items() ] if getattr(args, "json", False): diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index 828ab18..d656cc2 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -184,17 +184,13 @@ def build_parser() -> argparse.ArgumentParser: "apply", help="Converge the running system to match config (render + reconcile)" ) p.add_argument("name", nargs="?", help="Single deployment to converge (default: all)") - p.add_argument( - "--plan", action="store_true", help="Show the diff without changing anything" - ) + p.add_argument("--plan", action="store_true", help="Show the diff without changing anything") # Imperative ops (don't change desired state) p = subparsers.add_parser("restart", help="Restart deployment(s) — an imperative bounce") p.add_argument("name", nargs="?", help="Deployment to restart (default: all)") subparsers.add_parser("status", help="Show status across the platform") - subparsers.add_parser( - "doctor", help="Diagnose setup + runtime health, with next-step hints" - ) + subparsers.add_parser("doctor", help="Diagnose setup + runtime health, with next-step hints") # Relationship model — repos, requires edges, and derived status. p = subparsers.add_parser( diff --git a/cli/tests/test_add.py b/cli/tests/test_add.py index a026939..a8d9fa2 100644 --- a/cli/tests/test_add.py +++ b/cli/tests/test_add.py @@ -50,7 +50,7 @@ class TestAdd: def test_adopt_rust_declares_commands(self, castle_root: Path, tmp_path: Path) -> None: repo = tmp_path / "rusty" repo.mkdir() - (repo / "Cargo.toml").write_text("[package]\nname = \"rusty\"\n") + (repo / "Cargo.toml").write_text('[package]\nname = "rusty"\n') rc, config = _run_add(castle_root, target=str(repo)) assert rc == 0 prog = config.programs["rusty"] @@ -62,9 +62,7 @@ class TestAdd: assert prog.commands.run == [["cargo", "run"]] def test_adopt_git_url_records_repo(self, castle_root: Path) -> None: - rc, config = _run_add( - castle_root, target="https://github.com/someone/widget.git" - ) + rc, config = _run_add(castle_root, target="https://github.com/someone/widget.git") assert rc == 0 prog = config.programs["widget"] assert prog.repo == "https://github.com/someone/widget.git" diff --git a/cli/tests/test_create.py b/cli/tests/test_create.py index 8acff36..6218a15 100644 --- a/cli/tests/test_create.py +++ b/cli/tests/test_create.py @@ -74,7 +74,7 @@ class TestCreateCommand: assert (project_dir / "CLAUDE.md").exists() assert "my-tool2" in config.programs # A tool is a PATH deployment: manager=path, derived kind=tool. - assert config.deployments["my-tool2"].manager == "path" + assert config.tools["my-tool2"].manager == "path" assert config.deployments_of("my-tool2") == [("my-tool2", "tool")] def test_create_supabase_app(self, castle_root: Path, tmp_path: Path) -> None: @@ -91,9 +91,7 @@ class TestCreateCommand: from castle_cli.commands.create import run_create - args = Namespace( - name="guestbook", stack="supabase", description="Guestbook", port=None - ) + args = Namespace(name="guestbook", stack="supabase", description="Guestbook", port=None) result = run_create(args) assert result == 0 @@ -110,7 +108,7 @@ class TestCreateCommand: # The supabase stack seeds a `requires` on the substrate so the graph shows # the dependency (stack stays uncoupled — it just declares the edge once). assert [(r.kind, r.ref) for r in comp.requires] == [("deployment", "supabase")] - dep = config.deployments["guestbook"] + dep = config.statics["guestbook"] assert dep.manager == "caddy" assert dep.root == "public" assert config.deployments_of("guestbook") == [("guestbook", "static")]