Runtime half: convergent deploy + unified active lifecycle
Convergent deploy (prefix-based prune): castle deploy now removes orphaned castle-* systemd units/timers no longer in the registry. Full deploy only; partial (--name) deploys preserve siblings. Fixes the orphaned-timer / stale- path drift class. Container runner: derive --name from the service name (castle-<name>) instead of the image name; skip the <PREFIX>_DATA_DIR env injection for containers (avoids colliding with image env namespaces like NEO4J_*). Unified active lifecycle (core/lifecycle.py): install/uninstall keep their names but become activate/deactivate, dispatching by behavior — tool→PATH, daemon/self-serving-frontend/job→systemd, static frontend→served. is_active reports a uniform state (PATH-independent for tools). The CLI install/uninstall and service enable/disable route through the extracted core. API + dashboard: surface a uniform `active` state on programs alongside `installed`; ProgramDetail shows active/inactive. Tests: test_deploy_prune (6), test_lifecycle (5). 168 tests pass; lint clean.
This commit is contained in:
@@ -85,9 +85,40 @@ def run_check(args: argparse.Namespace) -> int:
|
||||
return run_verb(args, "check")
|
||||
|
||||
|
||||
def _lifecycle(config: CastleConfig, name: str, deactivate: bool) -> bool:
|
||||
"""Activate or deactivate a single program. Returns True on success."""
|
||||
from castle_core.lifecycle import activate
|
||||
from castle_core.lifecycle import deactivate as do_deactivate
|
||||
|
||||
if name not in config.programs and name not in config.services and name not in config.jobs:
|
||||
print(f"Unknown program: {name}")
|
||||
return False
|
||||
verb = "deactivate" if deactivate else "activate"
|
||||
print(f"\n{'─' * 40}\n {verb}: {name}\n{'─' * 40}")
|
||||
fn = do_deactivate if deactivate else activate
|
||||
result = asyncio.run(fn(name, config, config.root))
|
||||
if result.output:
|
||||
print(result.output)
|
||||
return result.status == "ok"
|
||||
|
||||
|
||||
def run_install(args: argparse.Namespace) -> int:
|
||||
return run_verb(args, "install")
|
||||
"""Activate (install/deploy/serve) a program — verb word kept, meaning unified."""
|
||||
config = load_config()
|
||||
if getattr(args, "project", None):
|
||||
return 0 if _lifecycle(config, args.project, deactivate=False) else 1
|
||||
ok = all(
|
||||
_lifecycle(config, name, deactivate=False)
|
||||
for name, comp in config.programs.items()
|
||||
if comp.source
|
||||
)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def run_uninstall(args: argparse.Namespace) -> int:
|
||||
return run_verb(args, "uninstall")
|
||||
"""Deactivate (uninstall/stop/unpublish) a program."""
|
||||
config = load_config()
|
||||
if getattr(args, "project", None):
|
||||
return 0 if _lifecycle(config, args.project, deactivate=True) else 1
|
||||
print("Refusing to deactivate all programs; name a target.")
|
||||
return 1
|
||||
|
||||
@@ -78,103 +78,21 @@ def run_services(args: argparse.Namespace) -> int:
|
||||
|
||||
def _service_enable(config: CastleConfig, name: str) -> int:
|
||||
"""Enable and start a single service (or timer for scheduled jobs)."""
|
||||
if not REGISTRY_PATH.exists():
|
||||
print("Error: no registry found. Run 'castle deploy' first.")
|
||||
return 1
|
||||
|
||||
registry = load_registry()
|
||||
if name not in registry.deployed:
|
||||
print(f"Error: '{name}' not found in registry. Run 'castle deploy' first.")
|
||||
return 1
|
||||
|
||||
deployed = registry.deployed[name]
|
||||
if not deployed.managed:
|
||||
print(f"Error: '{name}' is not a managed service")
|
||||
return 1
|
||||
from castle_core.lifecycle import enable_service
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
# Get systemd spec from config (services or jobs)
|
||||
systemd_spec = None
|
||||
if name in config.services:
|
||||
svc = config.services[name]
|
||||
if svc.manage and svc.manage.systemd:
|
||||
systemd_spec = svc.manage.systemd
|
||||
elif name in config.jobs:
|
||||
job = config.jobs[name]
|
||||
if job.manage and job.manage.systemd:
|
||||
systemd_spec = job.manage.systemd
|
||||
|
||||
# Generate and install the service unit from registry
|
||||
svc_unit = unit_name(name)
|
||||
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
|
||||
_install_unit(svc_unit, svc_content)
|
||||
|
||||
# Check for timer (jobs have schedule)
|
||||
if deployed.schedule:
|
||||
timer_content = generate_timer(
|
||||
name,
|
||||
schedule=deployed.schedule,
|
||||
description=deployed.description,
|
||||
)
|
||||
tmr_unit = timer_name(name)
|
||||
_install_unit(tmr_unit, timer_content)
|
||||
|
||||
print(f"Enabling {name} (scheduled)...")
|
||||
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", tmr_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 deployed.port:
|
||||
port_str = f" (port {deployed.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
|
||||
result = enable_service(name, config)
|
||||
print(result.output)
|
||||
return 0 if result.status == "ok" else 1
|
||||
|
||||
|
||||
def _service_disable(name: str) -> int:
|
||||
"""Stop and disable a service (and timer if present)."""
|
||||
svc_unit = unit_name(name)
|
||||
tmr_unit = timer_name(name)
|
||||
from castle_core.lifecycle import disable_service
|
||||
|
||||
print(f"Disabling {name}...")
|
||||
|
||||
timer_path = SYSTEMD_USER_DIR / tmr_unit
|
||||
if timer_path.exists():
|
||||
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)
|
||||
_remove_unit(svc_unit)
|
||||
print(f" {name}: disabled")
|
||||
|
||||
result = disable_service(name)
|
||||
print(f" {result.output}")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user