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.
147 lines
4.4 KiB
Python
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
|