refactor: Migrate CLI and API components to use castle-core for configuration and manifest handling

This commit is contained in:
2026-02-22 22:54:38 -08:00
parent 6d0332e32b
commit 41d4c574cb
31 changed files with 1699 additions and 904 deletions

View File

@@ -6,71 +6,22 @@ import argparse
import subprocess
from castle_cli.config import GENERATED_DIR, CastleConfig, ensure_dirs, load_config
from castle_core.generators.caddyfile import find_app_dist, generate_caddyfile
GATEWAY_COMPONENT = "castle-gateway"
GATEWAY_UNIT = "castle-castle-gateway.service"
def _find_app_dist(config: CastleConfig) -> str | None:
"""Find the app dist/ directory if it exists."""
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."""
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 _write_generated_files(config: CastleConfig) -> None:
"""Write generated Caddyfile."""
ensure_dirs()
caddyfile_path = GENERATED_DIR / "Caddyfile"
caddyfile_path.write_text(_generate_caddyfile(config))
caddyfile_path.write_text(generate_caddyfile(config))
print(f" Generated {caddyfile_path}")
app_dist = _find_app_dist(config)
app_dist = find_app_dist(config)
if app_dist:
print(f" App: {app_dist}")
else:
@@ -104,7 +55,7 @@ def run_gateway(args: argparse.Namespace) -> int:
def _gateway_dry_run(config: CastleConfig) -> int:
"""Print generated Caddyfile without applying."""
print("# Caddyfile")
print(_generate_caddyfile(config))
print(generate_caddyfile(config))
return 0

View File

@@ -3,254 +3,34 @@
from __future__ import annotations
import argparse
import shutil
import subprocess
from pathlib import Path
from castle_cli.config import (
CastleConfig,
ensure_dirs,
load_config,
resolve_env_vars,
)
from castle_cli.manifest import ComponentManifest, RestartPolicy
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
UNIT_PREFIX = "castle-"
from castle_core.generators.systemd import (
SYSTEMD_USER_DIR,
generate_timer,
generate_unit,
get_schedule_trigger,
timer_name,
unit_name,
)
def _unit_name(service_name: str) -> str:
"""Get the systemd unit name for a service."""
return f"{UNIT_PREFIX}{service_name}.service"
def _timer_name(service_name: str) -> str:
"""Get the systemd timer name for a scheduled service."""
return f"{UNIT_PREFIX}{service_name}.timer"
def _get_schedule_trigger(manifest: ComponentManifest) -> object | None:
"""Return the schedule trigger if one exists, else None."""
for t in manifest.triggers:
if getattr(t, "type", None) == "schedule":
return t
return None
def _cron_to_oncalendar(cron: str) -> str:
"""Best-effort conversion of cron expression to systemd OnCalendar.
Handles common patterns; falls back to using OnUnitActiveSec for the rest.
"""
parts = cron.strip().split()
if len(parts) != 5:
return ""
minute, hour, dom, month, dow = parts
# */N minutes → run every N minutes
if minute.startswith("*/") and hour == "*" and dom == "*" and month == "*" and dow == "*":
return "" # Use OnUnitActiveSec instead
# Specific time daily: "0 2 * * *" → "*-*-* 02:00:00"
if dom == "*" and month == "*" and dow == "*":
h = hour.zfill(2) if hour != "*" else "*"
m = minute.zfill(2) if minute != "*" else "*"
return f"*-*-* {h}:{m}:00"
return ""
def _cron_to_interval_sec(cron: str) -> int | None:
"""Extract interval seconds from */N cron patterns."""
parts = cron.strip().split()
if len(parts) != 5:
return None
minute, hour, dom, month, dow = parts
if minute.startswith("*/") and hour == "*" and dom == "*" and month == "*" and dow == "*":
try:
return int(minute[2:]) * 60
except ValueError:
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_tool":
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 "python_module":
python = run.python or shutil.which("python3") or "python3"
args_str = " ".join(run.args) if run.args else ""
cmd = f"{python} -m {run.module}"
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."""
run = manifest.run
if run is None:
raise ValueError(f"Component '{name}' has no run spec")
working_dir = config.root / (run.working_dir or name)
exec_start = _manifest_to_exec_start(manifest, config.root)
resolved_env = resolve_env_vars(run.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
WorkingDirectory={working_dir}
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
WorkingDirectory={working_dir}
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_timer(name: str, manifest: ComponentManifest) -> str | None:
"""Generate a systemd timer unit if the component has a schedule trigger."""
trigger = _get_schedule_trigger(manifest)
if trigger is None:
return None
description = manifest.description or name
# Try to convert cron to OnCalendar, fall back to OnUnitActiveSec
on_calendar = _cron_to_oncalendar(trigger.cron)
interval_sec = _cron_to_interval_sec(trigger.cron)
timer_lines = ""
if on_calendar:
timer_lines = f"OnCalendar={on_calendar}\n"
elif interval_sec:
timer_lines = f"OnBootSec=60\nOnUnitActiveSec={interval_sec}s\n"
else:
timer_lines = "OnBootSec=60\nOnUnitActiveSec=300\n"
return f"""[Unit]
Description=Castle timer: {description}
[Timer]
{timer_lines}Persistent=false
[Install]
WantedBy=timers.target
"""
def _install_unit(unit_name: str, content: str) -> None:
def _install_unit(uname: str, content: str) -> None:
"""Write a systemd unit file."""
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
unit_path = SYSTEMD_USER_DIR / unit_name
unit_path = SYSTEMD_USER_DIR / uname
unit_path.write_text(content)
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
def _remove_unit(unit_name: str) -> None:
def _remove_unit(uname: str) -> None:
"""Remove a systemd unit file."""
unit_path = SYSTEMD_USER_DIR / unit_name
unit_path = SYSTEMD_USER_DIR / uname
if unit_path.exists():
unit_path.unlink()
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
@@ -307,22 +87,22 @@ def _service_enable(config: CastleConfig, name: str) -> int:
ensure_dirs()
# Generate and install the service unit
svc_unit = _unit_name(name)
svc_content = _generate_unit(config, name, manifest)
svc_unit = unit_name(name)
svc_content = generate_unit(config, name, manifest)
_install_unit(svc_unit, svc_content)
# Check for timer
timer_content = _generate_timer(name, manifest)
timer_content = generate_timer(name, manifest)
if timer_content:
timer_unit = _timer_name(name)
_install_unit(timer_unit, timer_content)
tmr_unit = timer_name(name)
_install_unit(tmr_unit, timer_content)
print(f"Enabling {name} (scheduled)...")
subprocess.run(["systemctl", "--user", "enable", timer_unit], check=False)
subprocess.run(["systemctl", "--user", "start", timer_unit], check=False)
subprocess.run(["systemctl", "--user", "enable", tmr_unit], check=False)
subprocess.run(["systemctl", "--user", "start", tmr_unit], check=False)
result = subprocess.run(
["systemctl", "--user", "is-active", timer_unit],
["systemctl", "--user", "is-active", tmr_unit],
capture_output=True, text=True,
)
status = result.stdout.strip()
@@ -354,17 +134,17 @@ def _service_enable(config: CastleConfig, name: str) -> int:
def _service_disable(name: str) -> int:
"""Stop and disable a service (and timer if present)."""
svc_unit = _unit_name(name)
timer_unit = _timer_name(name)
svc_unit = unit_name(name)
tmr_unit = timer_name(name)
print(f"Disabling {name}...")
# Stop and disable timer if exists
timer_path = SYSTEMD_USER_DIR / timer_unit
timer_path = SYSTEMD_USER_DIR / tmr_unit
if timer_path.exists():
subprocess.run(["systemctl", "--user", "stop", timer_unit], check=False)
subprocess.run(["systemctl", "--user", "disable", timer_unit], check=False)
_remove_unit(timer_unit)
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
subprocess.run(["systemctl", "--user", "disable", tmr_unit], check=False)
_remove_unit(tmr_unit)
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
subprocess.run(["systemctl", "--user", "disable", svc_unit], check=False)
@@ -380,12 +160,12 @@ def _service_status(config: CastleConfig) -> int:
print("=" * 50)
for name, manifest in config.managed.items():
is_scheduled = _get_schedule_trigger(manifest) is not None
is_scheduled = get_schedule_trigger(manifest) is not None
if is_scheduled:
timer_unit = _timer_name(name)
tmr_unit = timer_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", timer_unit],
["systemctl", "--user", "is-active", tmr_unit],
capture_output=True, text=True,
)
status = result.stdout.strip()
@@ -398,9 +178,9 @@ def _service_status(config: CastleConfig) -> int:
reset = "\033[0m"
print(f" {color}{status:10s}{reset} {name} (timer)")
else:
unit_name = _unit_name(name)
svc_unit = unit_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", unit_name],
["systemctl", "--user", "is-active", svc_unit],
capture_output=True, text=True,
)
status = result.stdout.strip()
@@ -433,14 +213,14 @@ def _service_dry_run(config: CastleConfig, name: str) -> int:
print(f"Error: '{name}' has no run spec defined")
return 1
svc_unit = _unit_name(name)
svc_content = _generate_unit(config, name, manifest)
svc_unit = unit_name(name)
svc_content = generate_unit(config, name, manifest)
print(f"# {svc_unit}")
print(svc_content)
timer_content = _generate_timer(name, manifest)
timer_content = generate_timer(name, manifest)
if timer_content:
print(f"# {_timer_name(name)}")
print(f"# {timer_name(name)}")
print(timer_content)
return 0
@@ -468,12 +248,12 @@ def _services_start(config: CastleConfig) -> int:
def _services_stop(config: CastleConfig) -> int:
"""Stop all managed services and gateway."""
for name, manifest in config.managed.items():
is_scheduled = _get_schedule_trigger(manifest) is not None
is_scheduled = get_schedule_trigger(manifest) is not None
if is_scheduled:
timer_unit = _timer_name(name)
subprocess.run(["systemctl", "--user", "stop", timer_unit], check=False)
unit_name = _unit_name(name)
subprocess.run(["systemctl", "--user", "stop", unit_name], check=False)
tmr_unit = timer_name(name)
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
svc_unit = unit_name(name)
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
print(f" {name}: stopped")
return 0