- Introduced `uv.lock` for dependency management with various packages including `pytest`, `colorama`, and `pluggy`. - Added `pyrightconfig.json` for Python type checking configuration. - Expanded `recommendations.md` with detailed scaling recommendations and project management strategies. - Created shared `ruff.toml` for consistent linting across projects. - Developed `Castle Tools` with various utilities including Android backup, browser automation, document conversion, and search tools. - Implemented `backup-collect` and `schedule` tools for system administration tasks. - Enhanced `search` functionality with indexing and querying capabilities using Tantivy. - Added comprehensive documentation for each tool, including usage examples and installation instructions.
518 lines
16 KiB
Python
518 lines
16 KiB
Python
"""castle service / castle services - manage systemd service units."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from castle_cli.config import (
|
|
GENERATED_DIR,
|
|
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-"
|
|
|
|
|
|
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.cwd 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}
|
|
"""
|
|
|
|
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 _generate_gateway_unit(config: CastleConfig) -> str:
|
|
"""Generate a systemd unit for the Caddy gateway."""
|
|
caddy_path = shutil.which("caddy") or "caddy"
|
|
caddyfile = GENERATED_DIR / "Caddyfile"
|
|
|
|
return f"""[Unit]
|
|
Description=Castle Gateway (Caddy)
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
ExecStart={caddy_path} run --config {caddyfile} --adapter caddyfile
|
|
ExecReload={caddy_path} reload --config {caddyfile} --adapter caddyfile
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
"""
|
|
|
|
|
|
def _install_unit(unit_name: 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.write_text(content)
|
|
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
|
|
|
|
|
def _remove_unit(unit_name: str) -> None:
|
|
"""Remove a systemd unit file."""
|
|
unit_path = SYSTEMD_USER_DIR / unit_name
|
|
if unit_path.exists():
|
|
unit_path.unlink()
|
|
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
|
|
|
|
|
def run_service(args: argparse.Namespace) -> int:
|
|
"""Manage individual services."""
|
|
if not args.service_command:
|
|
print("Usage: castle service {enable|disable|status}")
|
|
return 1
|
|
|
|
config = load_config()
|
|
|
|
if args.service_command == "enable":
|
|
if getattr(args, "dry_run", False):
|
|
return _service_dry_run(config, args.name)
|
|
return _service_enable(config, args.name)
|
|
elif args.service_command == "disable":
|
|
return _service_disable(args.name)
|
|
elif args.service_command == "status":
|
|
return _service_status(config)
|
|
|
|
return 1
|
|
|
|
|
|
def run_services(args: argparse.Namespace) -> int:
|
|
"""Manage all services together."""
|
|
if not args.services_command:
|
|
print("Usage: castle services {start|stop}")
|
|
return 1
|
|
|
|
config = load_config()
|
|
|
|
if args.services_command == "start":
|
|
return _services_start(config)
|
|
elif args.services_command == "stop":
|
|
return _services_stop(config)
|
|
|
|
return 1
|
|
|
|
|
|
def _service_enable(config: CastleConfig, name: str) -> int:
|
|
"""Enable and start a single service (or timer for scheduled jobs)."""
|
|
managed = config.managed
|
|
if name not in managed:
|
|
print(f"Error: '{name}' is not a managed service")
|
|
return 1
|
|
|
|
manifest = managed[name]
|
|
if not manifest.run:
|
|
print(f"Error: '{name}' has no run spec defined")
|
|
return 1
|
|
|
|
ensure_dirs()
|
|
|
|
# Generate and install the service unit
|
|
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)
|
|
if timer_content:
|
|
timer_unit = _timer_name(name)
|
|
_install_unit(timer_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)
|
|
|
|
result = subprocess.run(
|
|
["systemctl", "--user", "is-active", timer_unit],
|
|
capture_output=True, text=True,
|
|
)
|
|
status = result.stdout.strip()
|
|
if status in ("active", "waiting"):
|
|
print(f" {name}: timer active")
|
|
else:
|
|
print(f" {name}: timer {status}")
|
|
else:
|
|
print(f"Enabling {name}...")
|
|
subprocess.run(["systemctl", "--user", "enable", svc_unit], check=False)
|
|
subprocess.run(["systemctl", "--user", "start", svc_unit], check=False)
|
|
|
|
result = subprocess.run(
|
|
["systemctl", "--user", "is-active", svc_unit],
|
|
capture_output=True, text=True,
|
|
)
|
|
status = result.stdout.strip()
|
|
port_str = ""
|
|
if manifest.expose and manifest.expose.http:
|
|
port_str = f" (port {manifest.expose.http.internal.port})"
|
|
if status == "active":
|
|
print(f" {name}: running{port_str}")
|
|
else:
|
|
print(f" {name}: {status}")
|
|
print(f" Check logs: journalctl --user -u {svc_unit}")
|
|
|
|
return 0
|
|
|
|
|
|
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)
|
|
|
|
print(f"Disabling {name}...")
|
|
|
|
# Stop and disable timer if exists
|
|
timer_path = SYSTEMD_USER_DIR / timer_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", svc_unit], check=False)
|
|
subprocess.run(["systemctl", "--user", "disable", svc_unit], check=False)
|
|
_remove_unit(svc_unit)
|
|
print(f" {name}: disabled")
|
|
|
|
return 0
|
|
|
|
|
|
def _service_status(config: CastleConfig) -> int:
|
|
"""Show status of all managed services."""
|
|
print("\nCastle Services")
|
|
print("=" * 50)
|
|
|
|
for name, manifest in config.managed.items():
|
|
is_scheduled = _get_schedule_trigger(manifest) is not None
|
|
|
|
if is_scheduled:
|
|
timer_unit = _timer_name(name)
|
|
result = subprocess.run(
|
|
["systemctl", "--user", "is-active", timer_unit],
|
|
capture_output=True, text=True,
|
|
)
|
|
status = result.stdout.strip()
|
|
if status in ("active", "waiting"):
|
|
color = "\033[92m"
|
|
elif status == "inactive":
|
|
color = "\033[90m"
|
|
else:
|
|
color = "\033[91m"
|
|
reset = "\033[0m"
|
|
print(f" {color}{status:10s}{reset} {name} (timer)")
|
|
else:
|
|
unit_name = _unit_name(name)
|
|
result = subprocess.run(
|
|
["systemctl", "--user", "is-active", unit_name],
|
|
capture_output=True, text=True,
|
|
)
|
|
status = result.stdout.strip()
|
|
if status == "active":
|
|
color = "\033[92m"
|
|
elif status == "inactive":
|
|
color = "\033[90m"
|
|
else:
|
|
color = "\033[91m"
|
|
reset = "\033[0m"
|
|
|
|
port_str = ""
|
|
if manifest.expose and manifest.expose.http:
|
|
port_str = f":{manifest.expose.http.internal.port}"
|
|
print(f" {color}{status:10s}{reset} {name}{port_str}")
|
|
|
|
# Gateway status
|
|
gw_unit = f"{UNIT_PREFIX}gateway.service"
|
|
result = subprocess.run(
|
|
["systemctl", "--user", "is-active", gw_unit],
|
|
capture_output=True, text=True,
|
|
)
|
|
gw_status = result.stdout.strip()
|
|
color = "\033[92m" if gw_status == "active" else "\033[90m"
|
|
reset = "\033[0m"
|
|
print(f" {color}{gw_status:10s}{reset} gateway :{config.gateway.port}")
|
|
|
|
print()
|
|
return 0
|
|
|
|
|
|
def _service_dry_run(config: CastleConfig, name: str) -> int:
|
|
"""Print the generated systemd unit(s) without installing."""
|
|
managed = config.managed
|
|
if name not in managed:
|
|
print(f"Error: '{name}' is not a managed service")
|
|
return 1
|
|
|
|
manifest = managed[name]
|
|
if not manifest.run:
|
|
print(f"Error: '{name}' has no run spec defined")
|
|
return 1
|
|
|
|
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)
|
|
if timer_content:
|
|
print(f"# {_timer_name(name)}")
|
|
print(timer_content)
|
|
|
|
return 0
|
|
|
|
|
|
def _services_start(config: CastleConfig) -> int:
|
|
"""Start all managed services and gateway."""
|
|
ensure_dirs()
|
|
|
|
from castle_cli.commands.gateway import _write_generated_files
|
|
|
|
print("Generating gateway configuration...")
|
|
_write_generated_files(config)
|
|
|
|
if shutil.which("caddy"):
|
|
gw_unit_name = f"{UNIT_PREFIX}gateway.service"
|
|
gw_content = _generate_gateway_unit(config)
|
|
_install_unit(gw_unit_name, gw_content)
|
|
subprocess.run(["systemctl", "--user", "enable", gw_unit_name], check=False)
|
|
subprocess.run(["systemctl", "--user", "start", gw_unit_name], check=False)
|
|
print(f"Gateway: started on port {config.gateway.port}")
|
|
else:
|
|
print("Warning: caddy not installed, skipping gateway")
|
|
|
|
for name, manifest in config.managed.items():
|
|
if not manifest.run:
|
|
continue
|
|
_service_enable(config, name)
|
|
|
|
print(f"\nDashboard: http://localhost:{config.gateway.port}")
|
|
return 0
|
|
|
|
|
|
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
|
|
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)
|
|
print(f" {name}: stopped")
|
|
|
|
gw_unit = f"{UNIT_PREFIX}gateway.service"
|
|
subprocess.run(["systemctl", "--user", "stop", gw_unit], check=False)
|
|
print(" gateway: stopped")
|
|
|
|
return 0
|