From 72d35f2641cbf1c1d13c4179a00fb68381909302 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 23 Feb 2026 00:07:42 -0800 Subject: [PATCH] refactor: Enhance component info and list commands with deployed state from registry --- cli/src/castle_cli/commands/info.py | 37 ++++++ cli/src/castle_cli/commands/list_cmd.py | 34 ++++- core/src/castle_core/generators/__init__.py | 10 -- core/src/castle_core/generators/caddyfile.py | 59 +-------- core/src/castle_core/generators/systemd.py | 132 +------------------ core/tests/test_caddyfile.py | 131 ++++++++++++------ core/tests/test_systemd.py | 99 +++++++------- 7 files changed, 221 insertions(+), 281 deletions(-) diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index 1822644..aa23509 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -13,6 +13,19 @@ BOLD = "\033[1m" RESET = "\033[0m" CYAN = "\033[96m" DIM = "\033[2m" +GREEN = "\033[92m" +RED = "\033[91m" + + +def _load_deployed_component(name: str) -> object | None: + """Try to load a specific deployed component from registry.""" + try: + from castle_core.registry import load_registry + + registry = load_registry() + return registry.deployed.get(name) + except (FileNotFoundError, ValueError): + return None def run_info(args: argparse.Namespace) -> int: @@ -25,6 +38,7 @@ def run_info(args: argparse.Namespace) -> int: return 1 manifest = config.components[name] + deployed = _load_deployed_component(name) if getattr(args, "json", False): data = manifest.model_dump(exclude_none=True) @@ -32,6 +46,14 @@ def run_info(args: argparse.Namespace) -> int: claude_md = _find_claude_md(config.root, manifest.source_dir or name) if claude_md: data["claude_md"] = claude_md + if deployed: + data["deployed"] = { + "runner": deployed.runner, + "run_cmd": deployed.run_cmd, + "env": deployed.env, + "port": deployed.port, + "managed": deployed.managed, + } print(json.dumps(data, indent=2)) return 0 @@ -107,6 +129,21 @@ def run_info(args: argparse.Namespace) -> int: for cap in manifest.consumes: print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else "")) + # Deployed state from registry + if deployed: + print(f"\n {GREEN}{BOLD}deployed{RESET}") + print(f" {'─' * 36}") + print(f" {BOLD}run_cmd{RESET}: {' '.join(deployed.run_cmd)}") + if deployed.port is not None: + print(f" {BOLD}port{RESET}: {deployed.port}") + print(f" {BOLD}managed{RESET}: {deployed.managed}") + if deployed.env: + print(f" {BOLD}env{RESET}:") + for key, val in deployed.env.items(): + print(f" {key}={val}") + else: + print(f"\n {DIM}not deployed (run 'castle deploy {name}'){RESET}") + # Show CLAUDE.md if it exists source_dir = manifest.source_dir or name claude_md = _find_claude_md(config.root, source_dir) diff --git a/cli/src/castle_cli/commands/list_cmd.py b/cli/src/castle_cli/commands/list_cmd.py index 480e746..904733b 100644 --- a/cli/src/castle_cli/commands/list_cmd.py +++ b/cli/src/castle_cli/commands/list_cmd.py @@ -4,14 +4,19 @@ from __future__ import annotations import argparse import json +import logging from castle_cli.config import load_config from castle_cli.manifest import Role +log = logging.getLogger(__name__) + # Terminal colors BOLD = "\033[1m" RESET = "\033[0m" DIM = "\033[2m" +GREEN = "\033[92m" +RED = "\033[91m" ROLE_COLORS: dict[str, str] = { Role.SERVICE: "\033[92m", # green @@ -24,9 +29,21 @@ ROLE_COLORS: dict[str, str] = { } +def _load_deployed() -> dict[str, object] | None: + """Try to load deployed state from registry, return None if unavailable.""" + try: + from castle_core.registry import load_registry + + registry = load_registry() + return registry.deployed + except (FileNotFoundError, ValueError): + return None + + def run_list(args: argparse.Namespace) -> int: """List all components.""" config = load_config() + deployed = _load_deployed() components = config.components @@ -40,11 +57,16 @@ def run_list(args: argparse.Namespace) -> int: entry: dict = { "name": name, "roles": [r.value for r in manifest.roles], + "deployed": deployed is not None and name in deployed, } if manifest.description: entry["description"] = manifest.description if manifest.expose and manifest.expose.http: entry["port"] = manifest.expose.http.internal.port + if deployed and name in deployed: + dep = deployed[name] + if dep.port is not None: + entry["port"] = dep.port output.append(entry) print(json.dumps(output, indent=2)) return 0 @@ -72,8 +94,18 @@ def run_list(args: argparse.Namespace) -> int: port_str = "" if manifest.expose and manifest.expose.http: port_str = f" :{manifest.expose.http.internal.port}" + + # Show deployed status indicator + if deployed is not None: + status = f"{GREEN}●{RESET}" if name in deployed else f"{RED}○{RESET}" + else: + status = f"{DIM}?{RESET}" + desc = f" {DIM}{manifest.description}{RESET}" if manifest.description else "" - print(f" {BOLD}{name}{RESET}{port_str}{desc}") + print(f" {status} {BOLD}{name}{RESET}{port_str}{desc}") + + if deployed is None: + print(f"\n{DIM}(no registry — run 'castle deploy' to generate){RESET}") print() return 0 diff --git a/core/src/castle_core/generators/__init__.py b/core/src/castle_core/generators/__init__.py index e2bc4ba..50fee9f 100644 --- a/core/src/castle_core/generators/__init__.py +++ b/core/src/castle_core/generators/__init__.py @@ -1,35 +1,25 @@ """Castle infrastructure generators.""" from castle_core.generators.caddyfile import ( - find_app_dist, - generate_caddyfile, generate_caddyfile_from_registry, ) from castle_core.generators.systemd import ( - build_podman_command, cron_to_interval_sec, cron_to_oncalendar, generate_timer, - generate_unit, generate_unit_from_deployed, get_schedule_trigger, - manifest_to_exec_start, timer_name, unit_name, ) __all__ = [ - "build_podman_command", "cron_to_interval_sec", "cron_to_oncalendar", - "find_app_dist", - "generate_caddyfile", "generate_caddyfile_from_registry", "generate_timer", - "generate_unit", "generate_unit_from_deployed", "get_schedule_trigger", - "manifest_to_exec_start", "timer_name", "unit_name", ] diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index b6fe6ee..4777187 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -1,66 +1,11 @@ -"""Caddyfile generation from castle config.""" +"""Caddyfile generation from node registry.""" from __future__ import annotations -from castle_core.config import GENERATED_DIR, STATIC_DIR, CastleConfig +from castle_core.config import GENERATED_DIR, STATIC_DIR from castle_core.registry import NodeRegistry -def find_app_dist(config: CastleConfig) -> str | None: - """Find the app dist/ directory if it exists (legacy, checks repo).""" - dist = config.root / "app" / "dist" - if dist.exists() and (dist / "index.html").exists(): - return str(dist) - return None - - -def generate_caddyfile(config: CastleConfig) -> str: - """Generate Caddyfile content from castle config (legacy, uses manifest). - - Prefer generate_caddyfile_from_registry() for registry-based generation. - """ - lines = [f":{config.gateway.port} {{"] - - # Reverse proxy for each component with proxy.caddy and expose.http - for name, manifest in config.components.items(): - if not ( - manifest.proxy and manifest.proxy.caddy and manifest.proxy.caddy.enable - ): - continue - if not (manifest.expose and manifest.expose.http): - continue - - caddy = manifest.proxy.caddy - http = manifest.expose.http - path_prefix = caddy.path_prefix or f"/{name}" - port = http.internal.port - host = http.internal.host or "localhost" - - lines.append(f" handle_path {path_prefix}/* {{") - lines.append(f" reverse_proxy {host}:{port}") - lines.append(" }") - lines.append("") - - # App SPA at root (must come after more-specific handle_path rules) - app_dist = find_app_dist(config) - if app_dist: - lines.append(" handle {") - lines.append(f" root * {app_dist}") - lines.append(" try_files {path} /index.html") - lines.append(" file_server") - lines.append(" }") - else: - # Fallback: serve from generated directory - fallback = GENERATED_DIR / "app" - lines.append(" handle / {") - lines.append(f" root * {fallback}") - lines.append(" file_server") - lines.append(" }") - - lines.append("}") - return "\n".join(lines) - - def generate_caddyfile_from_registry(registry: NodeRegistry) -> str: """Generate Caddyfile from the node registry. diff --git a/core/src/castle_core/generators/systemd.py b/core/src/castle_core/generators/systemd.py index dea671b..d81013c 100644 --- a/core/src/castle_core/generators/systemd.py +++ b/core/src/castle_core/generators/systemd.py @@ -1,11 +1,10 @@ -"""Systemd unit and timer generation from castle manifests.""" +"""Systemd unit and timer generation.""" from __future__ import annotations import shutil from pathlib import Path -from castle_core.config import CastleConfig, resolve_env_vars from castle_core.manifest import ComponentManifest, RestartPolicy, SystemdSpec from castle_core.registry import DeployedComponent @@ -81,135 +80,6 @@ def cron_to_interval_sec(cron: str) -> int | None: return None -def manifest_to_exec_start(manifest: ComponentManifest, root: Path) -> str: - """Convert a manifest's RunSpec to a systemd ExecStart command.""" - run = manifest.run - if run is None: - raise ValueError(f"Component '{manifest.id}' has no run spec") - - match run.runner: - case "python": - uv_path = shutil.which("uv") or "uv" - args_str = " ".join(run.args) if run.args else "" - cmd = f"{uv_path} run {run.tool}" - if args_str: - cmd += f" {args_str}" - return cmd - case "command": - argv = list(run.argv) - resolved = shutil.which(argv[0]) - if resolved: - argv[0] = resolved - return " ".join(argv) - case "container": - return build_podman_command(manifest) - case "node": - pm = run.package_manager - cmd = f"{pm} run {run.script}" - if run.args: - cmd += " " + " ".join(run.args) - return cmd - case _: - raise ValueError(f"Unsupported runner '{run.runner}' for systemd unit") - - -def build_podman_command(manifest: ComponentManifest) -> str: - """Build a podman/docker run command from a container RunSpec.""" - run = manifest.run - podman = shutil.which("podman") or shutil.which("docker") or "podman" - parts = [podman, "run", "--rm", f"--name=castle-{manifest.id}"] - - for container_port, host_port in run.ports.items(): - parts.append(f"-p {host_port}:{container_port}") - for vol in run.volumes: - parts.append(f"-v {vol}") - for key, val in run.env.items(): - parts.append(f"-e {key}={val}") - if run.workdir: - parts.append(f"-w {run.workdir}") - - parts.append(run.image) - if run.command: - parts.extend(run.command) - if run.args: - parts.extend(run.args) - - return " ".join(parts) - - -def generate_unit(config: CastleConfig, name: str, manifest: ComponentManifest) -> str: - """Generate a systemd user unit file for a component (legacy, uses manifest). - - Prefer generate_unit_from_deployed() for registry-based generation. - """ - run = manifest.run - if run is None: - raise ValueError(f"Component '{name}' has no run spec") - - exec_start = manifest_to_exec_start(manifest, config.root) - - # Env vars now come from manifest.defaults.env instead of run.env - raw_env = manifest.defaults.env if manifest.defaults else {} - resolved_env = resolve_env_vars(raw_env, manifest) - env_lines = "" - for key, value in resolved_env.items(): - env_lines += f"Environment={key}={value}\n" - - # Add PATH so tools are findable - env_lines += f'Environment="PATH={Path.home() / ".local/bin"}:/usr/local/bin:/usr/bin:/bin"\n' - - sd = None - if manifest.manage and manifest.manage.systemd: - sd = manifest.manage.systemd - - description = (sd and sd.description) or manifest.description or name - after = " ".join(sd.after) if sd and sd.after else "network.target" - wanted_by = " ".join(sd.wanted_by) if sd else "default.target" - - is_scheduled = get_schedule_trigger(manifest) is not None - - if is_scheduled: - # Oneshot service for timer-driven jobs - unit = f"""[Unit] -Description=Castle: {description} -After={after} - -[Service] -Type=oneshot -ExecStart={exec_start} -{env_lines}""" - else: - restart = (sd.restart if sd else RestartPolicy.ON_FAILURE).value - restart_sec = sd.restart_sec if sd else 5 - unit = f"""[Unit] -Description=Castle: {description} -After={after} - -[Service] -Type=simple -ExecStart={exec_start} -{env_lines}Restart={restart} -RestartSec={restart_sec} -SuccessExitStatus=143 -""" - - if sd and sd.exec_reload: - reload_argv = sd.exec_reload.split() - resolved_reload = shutil.which(reload_argv[0]) - if resolved_reload: - reload_argv[0] = resolved_reload - unit += f"ExecReload={' '.join(reload_argv)}\n" - - if sd and sd.no_new_privileges: - unit += "NoNewPrivileges=true\n" - - unit += f""" -[Install] -WantedBy={wanted_by} -""" - return unit - - def generate_unit_from_deployed( name: str, deployed: DeployedComponent, diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py index 142518e..f912b11 100644 --- a/core/tests/test_caddyfile.py +++ b/core/tests/test_caddyfile.py @@ -1,60 +1,115 @@ -"""Tests for Caddyfile generation.""" +"""Tests for Caddyfile generation from registry.""" from __future__ import annotations from pathlib import Path -from castle_core.config import load_config -from castle_core.generators.caddyfile import generate_caddyfile +import pytest + +import castle_core.generators.caddyfile as caddyfile_mod +from castle_core.generators.caddyfile import generate_caddyfile_from_registry +from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry -class TestCaddyfileGeneration: - """Tests for Caddyfile generation.""" +@pytest.fixture(autouse=True) +def _isolate_static_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Use a temp dir for STATIC_DIR so tests don't depend on real ~/.castle.""" + monkeypatch.setattr(caddyfile_mod, "STATIC_DIR", tmp_path / "static") - def test_contains_gateway_port(self, castle_root: Path) -> None: + +def _make_registry( + deployed: dict[str, DeployedComponent] | None = None, + gateway_port: int = 9000, +) -> NodeRegistry: + """Create a test registry.""" + return NodeRegistry( + node=NodeConfig(hostname="test", gateway_port=gateway_port), + deployed=deployed or {}, + ) + + +class TestCaddyfileFromRegistry: + """Tests for registry-based Caddyfile generation.""" + + def test_contains_gateway_port(self) -> None: """Caddyfile uses the configured gateway port.""" - config = load_config(castle_root) - caddyfile = generate_caddyfile(config) + registry = _make_registry(gateway_port=18000) + caddyfile = generate_caddyfile_from_registry(registry) assert ":18000 {" in caddyfile - def test_contains_service_routes(self, castle_root: Path) -> None: - """Caddyfile has reverse proxy routes for services with proxy.caddy.""" - config = load_config(castle_root) - caddyfile = generate_caddyfile(config) + def test_contains_service_routes(self) -> None: + """Caddyfile has reverse proxy routes for deployed services.""" + registry = _make_registry( + deployed={ + "test-svc": DeployedComponent( + runner="python", + run_cmd=["uv", "run", "test-svc"], + port=19000, + proxy_path="/test-svc", + ), + } + ) + caddyfile = generate_caddyfile_from_registry(registry) assert "handle_path /test-svc/*" in caddyfile - assert "reverse_proxy" in caddyfile - assert "19000" in caddyfile + assert "reverse_proxy localhost:19000" in caddyfile - def test_skips_tools(self, castle_root: Path) -> None: - """Tools without proxy are not in Caddyfile.""" - config = load_config(castle_root) - caddyfile = generate_caddyfile(config) + def test_skips_non_proxied(self) -> None: + """Components without proxy_path are not in Caddyfile.""" + registry = _make_registry( + deployed={ + "test-tool": DeployedComponent( + runner="command", + run_cmd=["test-tool"], + ), + } + ) + caddyfile = generate_caddyfile_from_registry(registry) assert "test-tool" not in caddyfile - def test_fallback_when_no_dist(self, castle_root: Path) -> None: - """Uses fallback dashboard path when dist/ doesn't exist.""" - config = load_config(castle_root) - caddyfile = generate_caddyfile(config) - # No dashboard/dist exists in tmp, so should use fallback + def test_fallback_when_no_static(self) -> None: + """Uses fallback dashboard path when static dir doesn't exist.""" + registry = _make_registry() + caddyfile = generate_caddyfile_from_registry(registry) assert "handle / {" in caddyfile assert "file_server" in caddyfile - def test_spa_serving_when_dist_exists(self, castle_root: Path) -> None: - """Serves SPA with try_files when dashboard/dist exists.""" - # Create a dashboard/dist with index.html - dist = castle_root / "app" / "dist" - dist.mkdir(parents=True) - (dist / "index.html").write_text("") - - config = load_config(castle_root) - caddyfile = generate_caddyfile(config) - assert "try_files {path} /index.html" in caddyfile - assert str(dist) in caddyfile - - def test_proxy_routes_before_dashboard(self, castle_root: Path) -> None: + def test_proxy_routes_before_dashboard(self) -> None: """Service proxy routes appear before the dashboard catch-all.""" - config = load_config(castle_root) - caddyfile = generate_caddyfile(config) + registry = _make_registry( + deployed={ + "test-svc": DeployedComponent( + runner="python", + run_cmd=["uv", "run", "test-svc"], + port=19000, + proxy_path="/test-svc", + ), + } + ) + caddyfile = generate_caddyfile_from_registry(registry) proxy_pos = caddyfile.index("handle_path") handle_pos = caddyfile.index("handle /") assert proxy_pos < handle_pos + + def test_multiple_services(self) -> None: + """Multiple services get separate proxy routes.""" + registry = _make_registry( + deployed={ + "svc-a": DeployedComponent( + runner="python", + run_cmd=["uv", "run", "svc-a"], + port=9001, + proxy_path="/svc-a", + ), + "svc-b": DeployedComponent( + runner="python", + run_cmd=["uv", "run", "svc-b"], + port=9002, + proxy_path="/svc-b", + ), + } + ) + caddyfile = generate_caddyfile_from_registry(registry) + assert "handle_path /svc-a/*" in caddyfile + assert "reverse_proxy localhost:9001" in caddyfile + assert "handle_path /svc-b/*" in caddyfile + assert "reverse_proxy localhost:9002" in caddyfile diff --git a/core/tests/test_systemd.py b/core/tests/test_systemd.py index a751184..e4aaac4 100644 --- a/core/tests/test_systemd.py +++ b/core/tests/test_systemd.py @@ -2,11 +2,7 @@ from __future__ import annotations -from pathlib import Path - -from castle_core.config import load_config from castle_core.generators.systemd import ( - generate_unit, generate_unit_from_deployed, unit_name, ) @@ -22,49 +18,64 @@ class TestUnitName: assert unit_name("my-svc") == "castle-my-svc.service" -class TestUnitGeneration: - """Tests for systemd unit file generation.""" - - def test_contains_description(self, castle_root: Path) -> None: - """Unit file has service description.""" - config = load_config(castle_root) - manifest = config.components["test-svc"] - unit = generate_unit(config, "test-svc", manifest) - assert "Description=Castle: Test service" in unit - - def test_no_working_directory(self, castle_root: Path) -> None: - """Unit file has no WorkingDirectory (source/runtime separation).""" - config = load_config(castle_root) - manifest = config.components["test-svc"] - unit = generate_unit(config, "test-svc", manifest) - assert "WorkingDirectory" not in unit - - def test_contains_environment(self, castle_root: Path) -> None: - """Unit file has environment variables from defaults.env.""" - config = load_config(castle_root) - manifest = config.components["test-svc"] - unit = generate_unit(config, "test-svc", manifest) - expected_data_dir = str(castle_root / "data" / "test-svc") - assert f"Environment=TEST_SVC_DATA_DIR={expected_data_dir}" in unit - - def test_contains_restart_policy(self, castle_root: Path) -> None: - """Unit file has restart configuration.""" - config = load_config(castle_root) - manifest = config.components["test-svc"] - unit = generate_unit(config, "test-svc", manifest) - assert "Restart=on-failure" in unit - - def test_uses_uv_run(self, castle_root: Path) -> None: - """Unit file ExecStart uses uv run for python runner.""" - config = load_config(castle_root) - manifest = config.components["test-svc"] - unit = generate_unit(config, "test-svc", manifest) - assert "run test-svc" in unit - - class TestUnitFromDeployed: """Tests for registry-based systemd unit generation.""" + def test_contains_description(self) -> None: + """Unit file has service description.""" + deployed = DeployedComponent( + runner="python", + run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], + env={"TEST_SVC_PORT": "19000"}, + description="Test service", + ) + unit = generate_unit_from_deployed("test-svc", deployed) + assert "Description=Castle: Test service" in unit + + def test_no_working_directory(self) -> None: + """Unit file has no WorkingDirectory (source/runtime separation).""" + deployed = DeployedComponent( + runner="python", + run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], + env={}, + description="Test service", + ) + unit = generate_unit_from_deployed("test-svc", deployed) + assert "WorkingDirectory" not in unit + + def test_contains_environment(self) -> None: + """Unit file has environment variables from deployed config.""" + deployed = DeployedComponent( + runner="python", + run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], + env={"TEST_SVC_DATA_DIR": "/data/castle/test-svc"}, + description="Test service", + ) + unit = generate_unit_from_deployed("test-svc", deployed) + assert "Environment=TEST_SVC_DATA_DIR=/data/castle/test-svc" in unit + + def test_contains_restart_policy(self) -> None: + """Unit file has restart configuration.""" + deployed = DeployedComponent( + runner="python", + run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], + env={}, + description="Test service", + ) + unit = generate_unit_from_deployed("test-svc", deployed) + assert "Restart=on-failure" in unit + + def test_exec_start_from_run_cmd(self) -> None: + """Unit file ExecStart uses resolved run_cmd.""" + deployed = DeployedComponent( + runner="python", + run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], + env={}, + description="Test service", + ) + unit = generate_unit_from_deployed("test-svc", deployed) + assert "ExecStart=/home/user/.local/bin/uv run test-svc" in unit + def test_basic_service(self) -> None: """Generate a unit from a deployed component.""" deployed = DeployedComponent(