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:
2026-06-13 18:08:54 -07:00
parent 400e0b253b
commit 2953aea548
10 changed files with 414 additions and 101 deletions

View File

@@ -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