core+cli: castle apply — one converge verb replaces deploy/start/enable/…

Reconcile the running system to declared config in a single operation,
replacing the old deploy && start plus the per-kind enable/disable/
install/uninstall verbs. The mechanism varies by manager (systemd unit /
PATH install / gateway route); the verb never does.

Core (castle_core.deploy.apply):
- render (units, Caddyfile, tunnel) then reconcile: activate every
  enabled deployment that's down, restart any whose unit bytes changed,
  deactivate the disabled ones. Returns ApplyResult (the enacted diff).
- --plan computes the diff and touches nothing.
- restart-on-change is exact: _render_unit_files is the single source of
  truth for unit bytes, used both to write units and to predict restarts,
  so the plan can't drift from what deploy writes.

Desired state:
- DeploymentBase.enabled (default True) — the declarative on/off. apply
  converges to it; a disabled deployment is defined but not running and
  gets no gateway route (else it would 502). Registry omits enabled when
  True, keeping existing registries byte-identical.

CLI:
- add `castle apply [name] [--plan]`; keep `restart` as the one
  imperative bounce, plus status/doctor/logs/gateway/program.
- retire deploy, start, stop (top-level); service/job deploy|enable|
  disable|start|stop; tool install|uninstall; program install|uninstall.
- next-step strings + doctor hints now say `castle apply`.

Verified: core 129 + cli 31 green; live `castle apply` on a converged
node reports "nothing to do" with no spurious restarts; --plan shows an
empty diff; gateway + api stay up.
This commit is contained in:
2026-07-02 11:35:34 -07:00
parent 9028d15ec6
commit d0a206f7d6
18 changed files with 406 additions and 240 deletions

View File

@@ -20,7 +20,7 @@ def _write_generated_files() -> None:
ensure_dirs()
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
print("Error: no registry found. Run 'castle apply' first.")
return
registry = load_registry()
@@ -56,7 +56,7 @@ def run_gateway(args: argparse.Namespace) -> int:
def _gateway_dry_run() -> int:
"""Print generated Caddyfile without applying."""
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
print("Error: no registry found. Run 'castle apply' first.")
return 1
registry = load_registry()
@@ -126,7 +126,7 @@ def _gateway_status() -> int:
print(f"Gateway: {'running' if status == 'active' else status}")
if not REGISTRY_PATH.exists():
print(" (no registry — run 'castle deploy')")
print(" (no registry — run 'castle apply')")
return 0
from castle_core.generators.caddyfile import compute_routes