Repo-side rename only (Phases 1-3 of the migration plan); the live box (~/.castle, systemd units, /data/castle, domains) is a separate cutover. - Slug `castle` -> `wildpc`: CLI command, module names (wildpc_core/cli/api), dist names, entry point `wildpc = wildpc_cli.main:main`. - Identifiers: CastleConfig/NATSClient/DirError/MDNS -> Wildpc*. - Env/constants: CASTLE_* -> WILDPC_*; ~/.castle -> ~/.wildpc, castle.yaml -> wildpc.yaml, /data/castle -> /data/wildpc. - Systemd UNIT_PREFIX castle- -> wildpc-; own programs castle-api/gateway/etc. - Display prose "Castle" -> "Wild PC" in docs, agent-guide files, README, frontend. - Package dirs and bootstrap yaml renamed via git mv; lockfiles regenerated; redundant nested uv.lock files dropped (workspace root lock is authoritative). Tests: core 273, cli 47, wildpc-api 120 all pass. Frontend type-checks + builds. Fixed a stale test fixture (secret_env_path kind arg) broken pre-rename.
224 lines
7.6 KiB
Python
224 lines
7.6 KiB
Python
"""wildpc service / wildpc job — manage systemd service & timer units."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import subprocess
|
|
|
|
from wildpc_core.generators.systemd import (
|
|
SYSTEMD_USER_DIR,
|
|
timer_name,
|
|
unit_name,
|
|
)
|
|
|
|
from wildpc_cli.config import (
|
|
WildpcConfig,
|
|
load_config,
|
|
)
|
|
|
|
# Re-export for use by other commands
|
|
UNIT_PREFIX = "wildpc-"
|
|
|
|
|
|
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 / uname
|
|
unit_path.write_text(content)
|
|
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
|
|
|
|
|
def _remove_unit(uname: str) -> None:
|
|
"""Remove a systemd unit file."""
|
|
unit_path = SYSTEMD_USER_DIR / uname
|
|
if unit_path.exists():
|
|
unit_path.unlink()
|
|
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
|
|
|
|
|
_PAST = {"start": "started", "stop": "stopped", "restart": "restarted"}
|
|
|
|
|
|
def run_service_cmd(args: argparse.Namespace) -> int:
|
|
"""`wildpc service restart <name>` — the imperative bounce (only verb left).
|
|
|
|
Lifecycle (deploy/enable/disable/start/stop) is now convergence: `wildpc apply`.
|
|
"""
|
|
config = load_config()
|
|
return _unit_action(config, args.name, "restart", "service")
|
|
|
|
|
|
def run_job_cmd(args: argparse.Namespace) -> int:
|
|
"""`wildpc job restart <name>` — bounce the job's timer."""
|
|
config = load_config()
|
|
return _unit_action(config, args.name, "restart", "job")
|
|
|
|
|
|
def run_restart(args: argparse.Namespace) -> int:
|
|
"""Top-level `wildpc restart [name]` — bounce one deployment, or all of them.
|
|
|
|
An imperative op: it re-actualizes current desired state, it does not change it
|
|
(that's `wildpc apply`). A bare name bounces every kind sharing it.
|
|
"""
|
|
config = load_config()
|
|
name = getattr(args, "name", None)
|
|
if not name:
|
|
return _services_restart(config)
|
|
named = config.deployments_named(name)
|
|
if not named:
|
|
print(f"Error: no deployment '{name}'.")
|
|
return 1
|
|
rc = 0
|
|
for kind, _spec in named:
|
|
rc |= _unit_action(config, name, "restart", kind)
|
|
return rc
|
|
|
|
|
|
_GATEWAY_NAME = "wildpc-gateway"
|
|
|
|
|
|
def _unit_action(config: WildpcConfig, name: str, action: str, kind: str) -> int:
|
|
"""start/stop/restart one deployment (name, kind), dispatched by its manager.
|
|
|
|
systemd (a process/timer) → `systemctl`; caddy (static) → reload the gateway;
|
|
path (a tool) → install/uninstall; none (remote) → nothing to do.
|
|
"""
|
|
dep = config.deployment(kind, name)
|
|
if dep is None:
|
|
print(f"Error: no {kind} '{name}'.")
|
|
return 1
|
|
manager = dep.manager
|
|
if manager != "systemd":
|
|
return _managed_lifecycle(config, name, action, manager, kind)
|
|
# A scheduled systemd deployment (a job) is driven by its .timer.
|
|
unit = timer_name(name) if kind == "job" else unit_name(name, kind)
|
|
result = subprocess.run(["systemctl", "--user", action, unit], check=False)
|
|
if result.returncode != 0:
|
|
print(f"Error: failed to {action} {unit}")
|
|
return 1
|
|
print(f" {name}: {_PAST[action]}")
|
|
return 0
|
|
|
|
|
|
def _managed_lifecycle(
|
|
config: WildpcConfig, name: str, action: str, manager: str, kind: str
|
|
) -> int:
|
|
"""Lifecycle for non-systemd managers (no unit to systemctl)."""
|
|
if manager == "caddy":
|
|
if action == "stop":
|
|
print(f" {name}: gateway-served — disable or remove it to drop the route.")
|
|
return 0
|
|
# start/restart → reload the gateway so current routes take effect.
|
|
subprocess.run(["systemctl", "--user", "reload", unit_name(_GATEWAY_NAME)], check=False)
|
|
print(f" {name}: gateway reloaded ({_PAST[action]}).")
|
|
return 0
|
|
if manager == "path":
|
|
return _path_lifecycle(config, name, action, kind)
|
|
# none (remote): external, nothing local to act on.
|
|
print(f" {name}: external ({manager}) — nothing to {action}.")
|
|
return 0
|
|
|
|
|
|
def _path_lifecycle(config: WildpcConfig, name: str, action: str, kind: str) -> int:
|
|
"""A `path` (tool) deployment's lifecycle is install/uninstall on PATH."""
|
|
import asyncio
|
|
|
|
from wildpc_core.lifecycle import activate, deactivate
|
|
|
|
# stop → uninstall; start/restart → ensure installed (activate skips if on PATH).
|
|
coro = deactivate if action == "stop" else activate
|
|
res = asyncio.run(coro(name, kind, config, config.root))
|
|
print(f" {res.output}")
|
|
return 0 if res.status == "ok" else 1
|
|
|
|
|
|
def _services_restart(config: WildpcConfig) -> int:
|
|
"""Restart every systemd-managed deployment (service or job) unit.
|
|
|
|
caddy/path/none deployments have no unit — they ride along with the gateway
|
|
restart (static) or are stateless (remote), so we don't systemctl them here.
|
|
"""
|
|
for kind, name, dep in config.all_deployments():
|
|
if dep.manager != "systemd":
|
|
continue
|
|
if kind == "job":
|
|
subprocess.run(["systemctl", "--user", "restart", timer_name(name)], check=False)
|
|
print(f" {name}: restarted (timer)")
|
|
else:
|
|
subprocess.run(["systemctl", "--user", "restart", unit_name(name, kind)], check=False)
|
|
print(f" {name}: restarted")
|
|
return 0
|
|
|
|
|
|
def run_status(args: argparse.Namespace) -> int:
|
|
"""Unified status across the platform: services + jobs + programs."""
|
|
from wildpc_core.lifecycle import is_active
|
|
|
|
config = load_config()
|
|
|
|
# Services + jobs (deployment state); the gateway appears here as a service.
|
|
_service_status(config)
|
|
|
|
# Programs (catalog activation: tools on PATH, statics served by the gateway)
|
|
catalog = {
|
|
n: c
|
|
for n, c in config.programs.items()
|
|
if n not in config.services and n not in config.jobs
|
|
}
|
|
if catalog:
|
|
print(f"{'─' * 50}")
|
|
print("Programs")
|
|
for name, _comp in catalog.items():
|
|
_pk = sorted({k for _, k in config.deployments_of(name)})
|
|
on = is_active(name, _pk[0] if _pk else "tool", config)
|
|
color = "\033[92m" if on else "\033[90m"
|
|
label = "active" if on else "inactive"
|
|
kinds = sorted({k for _, k in config.deployments_of(name)})
|
|
tag = ", ".join(kinds) if kinds else "program"
|
|
print(f" {color}{label:10s}\033[0m {name} ({tag})")
|
|
print()
|
|
return 0
|
|
|
|
|
|
def _service_status(config: WildpcConfig) -> int:
|
|
"""Show status of all services and jobs, dispatched by manager."""
|
|
from wildpc_core.lifecycle import is_active
|
|
|
|
print("\nWild PC Services")
|
|
print("=" * 50)
|
|
|
|
for name, svc in config.services.items():
|
|
active = is_active(name, "service", config) # manager-aware
|
|
manager = svc.manager
|
|
color = "\033[92m" if active else "\033[90m"
|
|
reset = "\033[0m"
|
|
label = "active" if active else "inactive"
|
|
|
|
port_str = ""
|
|
if svc.expose and svc.expose.http:
|
|
port_str = f":{svc.expose.http.internal.port}"
|
|
print(f" {color}{label:10s}{reset} {name}{port_str} \033[90m[{manager}]{reset}")
|
|
|
|
if config.jobs:
|
|
print(f"\n{'─' * 50}")
|
|
print("Jobs")
|
|
for name in config.jobs:
|
|
tmr_unit = timer_name(name)
|
|
result = subprocess.run(
|
|
["systemctl", "--user", "is-active", tmr_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)")
|
|
|
|
print()
|
|
return 0
|