refactor: Migrate CLI and API components to use castle-core for configuration and manifest handling
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user