Files
wild-pc/cli/src/castle_cli/commands/gateway.py
Paul Payne d0a206f7d6 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.
2026-07-02 11:35:34 -07:00

147 lines
4.4 KiB
Python

"""castle gateway - manage the Caddy reverse proxy gateway."""
from __future__ import annotations
import argparse
import subprocess
from castle_core.config import SPECS_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.registry import REGISTRY_PATH, load_registry
from castle_cli.config import CastleConfig, ensure_dirs, load_config
GATEWAY_COMPONENT = "castle-gateway"
GATEWAY_UNIT = "castle-castle-gateway.service"
def _write_generated_files() -> None:
"""Write generated Caddyfile from registry."""
ensure_dirs()
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle apply' first.")
return
registry = load_registry()
caddyfile_path = SPECS_DIR / "Caddyfile"
caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
print(f" Generated {caddyfile_path}")
def run_gateway(args: argparse.Namespace) -> int:
"""Manage the Caddy gateway."""
if not args.gateway_command:
print("Usage: castle gateway {start|stop|reload|status}")
return 1
config = load_config()
if args.gateway_command == "start":
if getattr(args, "dry_run", False):
return _gateway_dry_run()
return _gateway_start(config)
elif args.gateway_command == "stop":
return _gateway_stop()
elif args.gateway_command == "reload":
if getattr(args, "dry_run", False):
return _gateway_dry_run()
return _gateway_reload()
elif args.gateway_command == "status":
return _gateway_status()
return 1
def _gateway_dry_run() -> int:
"""Print generated Caddyfile without applying."""
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle apply' first.")
return 1
registry = load_registry()
print("# Caddyfile")
print(generate_caddyfile_from_registry(registry))
return 0
def _gateway_start(config: CastleConfig) -> int:
"""Generate config and enable the gateway service."""
from castle_cli.commands.service import _service_enable
if GATEWAY_COMPONENT not in config.services:
print(f"Error: '{GATEWAY_COMPONENT}' not found in services section")
return 1
print("Generating gateway configuration...")
_write_generated_files()
print(f"\nStarting gateway on port {config.gateway.port}...")
return _service_enable(config, GATEWAY_COMPONENT)
def _gateway_stop() -> int:
"""Stop the gateway service."""
from castle_cli.commands.service import _service_disable
return _service_disable(GATEWAY_COMPONENT)
def _gateway_reload() -> int:
"""Regenerate config and reload Caddy."""
print("Regenerating gateway configuration...")
_write_generated_files()
result = subprocess.run(
["systemctl", "--user", "reload", GATEWAY_UNIT],
capture_output=True,
text=True,
)
if result.returncode == 0:
print("Gateway reloaded.")
else:
print("Reload signal sent. Verifying...")
result = subprocess.run(
["systemctl", "--user", "is-active", GATEWAY_UNIT],
capture_output=True,
text=True,
)
if result.stdout.strip() == "active":
print("Gateway running.")
else:
print("Warning: gateway may not be running. Try: castle gateway start")
return 0
def _gateway_status() -> int:
"""Show gateway status + the full route table (static, proxy, remote)."""
result = subprocess.run(
["systemctl", "--user", "is-active", GATEWAY_UNIT],
capture_output=True,
text=True,
)
status = result.stdout.strip()
print(f"Gateway: {'running' if status == 'active' else status}")
if not REGISTRY_PATH.exists():
print(" (no registry — run 'castle apply')")
return 0
from castle_core.generators.caddyfile import compute_routes
routes = compute_routes(load_registry())
if not routes:
print(" No routes configured.")
return 0
# Each route: address → target, tagged by kind. static = files served in
# place; proxy/remote = reverse-proxied to a process. (Caddyfile order is
# precedence-sensitive; this table is alphabetical.)
print(f"\n {'ADDRESS':24} {'KIND':7} TARGET")
for r in sorted(routes, key=lambda r: r.address):
target = r.target.replace("localhost:", ":") if r.kind != "static" else r.target
print(f" {r.address:24} {r.kind:7} {target}")
return 0