refactor: Enhance component info and list commands with deployed state from registry

This commit is contained in:
2026-02-23 00:07:42 -08:00
parent 5e3e01a5b6
commit 72d35f2641
7 changed files with 221 additions and 281 deletions

View File

@@ -13,6 +13,19 @@ BOLD = "\033[1m"
RESET = "\033[0m" RESET = "\033[0m"
CYAN = "\033[96m" CYAN = "\033[96m"
DIM = "\033[2m" 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: def run_info(args: argparse.Namespace) -> int:
@@ -25,6 +38,7 @@ def run_info(args: argparse.Namespace) -> int:
return 1 return 1
manifest = config.components[name] manifest = config.components[name]
deployed = _load_deployed_component(name)
if getattr(args, "json", False): if getattr(args, "json", False):
data = manifest.model_dump(exclude_none=True) 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) claude_md = _find_claude_md(config.root, manifest.source_dir or name)
if claude_md: if claude_md:
data["claude_md"] = 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)) print(json.dumps(data, indent=2))
return 0 return 0
@@ -107,6 +129,21 @@ def run_info(args: argparse.Namespace) -> int:
for cap in manifest.consumes: for cap in manifest.consumes:
print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else "")) 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 # Show CLAUDE.md if it exists
source_dir = manifest.source_dir or name source_dir = manifest.source_dir or name
claude_md = _find_claude_md(config.root, source_dir) claude_md = _find_claude_md(config.root, source_dir)

View File

@@ -4,14 +4,19 @@ from __future__ import annotations
import argparse import argparse
import json import json
import logging
from castle_cli.config import load_config from castle_cli.config import load_config
from castle_cli.manifest import Role from castle_cli.manifest import Role
log = logging.getLogger(__name__)
# Terminal colors # Terminal colors
BOLD = "\033[1m" BOLD = "\033[1m"
RESET = "\033[0m" RESET = "\033[0m"
DIM = "\033[2m" DIM = "\033[2m"
GREEN = "\033[92m"
RED = "\033[91m"
ROLE_COLORS: dict[str, str] = { ROLE_COLORS: dict[str, str] = {
Role.SERVICE: "\033[92m", # green 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: def run_list(args: argparse.Namespace) -> int:
"""List all components.""" """List all components."""
config = load_config() config = load_config()
deployed = _load_deployed()
components = config.components components = config.components
@@ -40,11 +57,16 @@ def run_list(args: argparse.Namespace) -> int:
entry: dict = { entry: dict = {
"name": name, "name": name,
"roles": [r.value for r in manifest.roles], "roles": [r.value for r in manifest.roles],
"deployed": deployed is not None and name in deployed,
} }
if manifest.description: if manifest.description:
entry["description"] = manifest.description entry["description"] = manifest.description
if manifest.expose and manifest.expose.http: if manifest.expose and manifest.expose.http:
entry["port"] = manifest.expose.http.internal.port 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) output.append(entry)
print(json.dumps(output, indent=2)) print(json.dumps(output, indent=2))
return 0 return 0
@@ -72,8 +94,18 @@ def run_list(args: argparse.Namespace) -> int:
port_str = "" port_str = ""
if manifest.expose and manifest.expose.http: if manifest.expose and manifest.expose.http:
port_str = f" :{manifest.expose.http.internal.port}" 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 "" 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() print()
return 0 return 0

View File

@@ -1,35 +1,25 @@
"""Castle infrastructure generators.""" """Castle infrastructure generators."""
from castle_core.generators.caddyfile import ( from castle_core.generators.caddyfile import (
find_app_dist,
generate_caddyfile,
generate_caddyfile_from_registry, generate_caddyfile_from_registry,
) )
from castle_core.generators.systemd import ( from castle_core.generators.systemd import (
build_podman_command,
cron_to_interval_sec, cron_to_interval_sec,
cron_to_oncalendar, cron_to_oncalendar,
generate_timer, generate_timer,
generate_unit,
generate_unit_from_deployed, generate_unit_from_deployed,
get_schedule_trigger, get_schedule_trigger,
manifest_to_exec_start,
timer_name, timer_name,
unit_name, unit_name,
) )
__all__ = [ __all__ = [
"build_podman_command",
"cron_to_interval_sec", "cron_to_interval_sec",
"cron_to_oncalendar", "cron_to_oncalendar",
"find_app_dist",
"generate_caddyfile",
"generate_caddyfile_from_registry", "generate_caddyfile_from_registry",
"generate_timer", "generate_timer",
"generate_unit",
"generate_unit_from_deployed", "generate_unit_from_deployed",
"get_schedule_trigger", "get_schedule_trigger",
"manifest_to_exec_start",
"timer_name", "timer_name",
"unit_name", "unit_name",
] ]

View File

@@ -1,66 +1,11 @@
"""Caddyfile generation from castle config.""" """Caddyfile generation from node registry."""
from __future__ import annotations 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 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: def generate_caddyfile_from_registry(registry: NodeRegistry) -> str:
"""Generate Caddyfile from the node registry. """Generate Caddyfile from the node registry.

View File

@@ -1,11 +1,10 @@
"""Systemd unit and timer generation from castle manifests.""" """Systemd unit and timer generation."""
from __future__ import annotations from __future__ import annotations
import shutil import shutil
from pathlib import Path from pathlib import Path
from castle_core.config import CastleConfig, resolve_env_vars
from castle_core.manifest import ComponentManifest, RestartPolicy, SystemdSpec from castle_core.manifest import ComponentManifest, RestartPolicy, SystemdSpec
from castle_core.registry import DeployedComponent from castle_core.registry import DeployedComponent
@@ -81,135 +80,6 @@ def cron_to_interval_sec(cron: str) -> int | None:
return 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( def generate_unit_from_deployed(
name: str, name: str,
deployed: DeployedComponent, deployed: DeployedComponent,

View File

@@ -1,60 +1,115 @@
"""Tests for Caddyfile generation.""" """Tests for Caddyfile generation from registry."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from castle_core.config import load_config import pytest
from castle_core.generators.caddyfile import generate_caddyfile
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: @pytest.fixture(autouse=True)
"""Tests for Caddyfile generation.""" 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.""" """Caddyfile uses the configured gateway port."""
config = load_config(castle_root) registry = _make_registry(gateway_port=18000)
caddyfile = generate_caddyfile(config) caddyfile = generate_caddyfile_from_registry(registry)
assert ":18000 {" in caddyfile assert ":18000 {" in caddyfile
def test_contains_service_routes(self, castle_root: Path) -> None: def test_contains_service_routes(self) -> None:
"""Caddyfile has reverse proxy routes for services with proxy.caddy.""" """Caddyfile has reverse proxy routes for deployed services."""
config = load_config(castle_root) registry = _make_registry(
caddyfile = generate_caddyfile(config) 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 "handle_path /test-svc/*" in caddyfile
assert "reverse_proxy" in caddyfile assert "reverse_proxy localhost:19000" in caddyfile
assert "19000" in caddyfile
def test_skips_tools(self, castle_root: Path) -> None: def test_skips_non_proxied(self) -> None:
"""Tools without proxy are not in Caddyfile.""" """Components without proxy_path are not in Caddyfile."""
config = load_config(castle_root) registry = _make_registry(
caddyfile = generate_caddyfile(config) deployed={
"test-tool": DeployedComponent(
runner="command",
run_cmd=["test-tool"],
),
}
)
caddyfile = generate_caddyfile_from_registry(registry)
assert "test-tool" not in caddyfile assert "test-tool" not in caddyfile
def test_fallback_when_no_dist(self, castle_root: Path) -> None: def test_fallback_when_no_static(self) -> None:
"""Uses fallback dashboard path when dist/ doesn't exist.""" """Uses fallback dashboard path when static dir doesn't exist."""
config = load_config(castle_root) registry = _make_registry()
caddyfile = generate_caddyfile(config) caddyfile = generate_caddyfile_from_registry(registry)
# No dashboard/dist exists in tmp, so should use fallback
assert "handle / {" in caddyfile assert "handle / {" in caddyfile
assert "file_server" in caddyfile assert "file_server" in caddyfile
def test_spa_serving_when_dist_exists(self, castle_root: Path) -> None: def test_proxy_routes_before_dashboard(self) -> 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("<html></html>")
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:
"""Service proxy routes appear before the dashboard catch-all.""" """Service proxy routes appear before the dashboard catch-all."""
config = load_config(castle_root) registry = _make_registry(
caddyfile = generate_caddyfile(config) 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") proxy_pos = caddyfile.index("handle_path")
handle_pos = caddyfile.index("handle /") handle_pos = caddyfile.index("handle /")
assert proxy_pos < handle_pos 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

View File

@@ -2,11 +2,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from castle_core.config import load_config
from castle_core.generators.systemd import ( from castle_core.generators.systemd import (
generate_unit,
generate_unit_from_deployed, generate_unit_from_deployed,
unit_name, unit_name,
) )
@@ -22,49 +18,64 @@ class TestUnitName:
assert unit_name("my-svc") == "castle-my-svc.service" 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: class TestUnitFromDeployed:
"""Tests for registry-based systemd unit generation.""" """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: def test_basic_service(self) -> None:
"""Generate a unit from a deployed component.""" """Generate a unit from a deployed component."""
deployed = DeployedComponent( deployed = DeployedComponent(