refactor: Enhance component info and list commands with deployed state from registry
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("<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:
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user