gateway: converge, don't start/stop/reload

The gateway is a deployment (castle-gateway) — its lifecycle is the same
convergence as everything else, so the bespoke start/stop/reload verbs
(which still routed through the retired enable/disable path) are gone.

- CLI: `castle gateway` is now inspection only — bare or `status` shows
  the route table. Start/stop/reload it via `castle apply` (render routes
  + reload) or `castle restart castle-gateway`. Deletes the last users of
  `_service_enable`/`_service_disable`, so those are removed too.
- API: retire `POST /gateway/reload` (making routes live is `POST /apply`);
  `PUT /gateway/config` message + docstring now say apply, not deploy.
- UI: GatewayPanel's "Reload" button → "Apply" (useApply); drop the
  useGatewayReload hook; GatewaySettings points at `castle apply`.
- docs: `castle gateway reload|status` → `castle gateway` (status lens).

core 129 / cli 31 / api 59 pass; dashboard builds clean; live `castle
gateway` shows routes with no start/stop/reload; /gateway/reload removed
from the API.
This commit is contained in:
2026-07-02 12:12:00 -07:00
parent ca32e6f29f
commit ef588cb806
11 changed files with 32 additions and 197 deletions

View File

@@ -1,118 +1,24 @@
"""castle gateway - manage the Caddy reverse proxy gateway."""
"""castle gateway — inspect the Caddy reverse proxy gateway.
The gateway is itself a deployment (`castle-gateway`): start/stop/reload it the
same way as anything else — `castle apply` (render routes + reload), `castle
restart castle-gateway` (bounce), or `enabled: false` + apply (stop). This command
is the read-only inspection lens: is it up, and what's the route table.
"""
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
"""Show the gateway's status + route table (the only gateway verb)."""
return _gateway_status()
def _gateway_status() -> int:

View File

@@ -14,7 +14,6 @@ from castle_core.manifest import kind_for
from castle_cli.config import (
CastleConfig,
ensure_dirs,
load_config,
)
@@ -178,30 +177,6 @@ def run_status(args: argparse.Namespace) -> int:
return 0
def _service_enable(config: CastleConfig, name: str) -> int:
"""Enable a service in its mode (systemd unit / gateway route / PATH install)."""
import asyncio
from castle_core.lifecycle import activate
ensure_dirs()
result = asyncio.run(activate(name, config, config.root))
print(result.output)
return 0 if result.status == "ok" else 1
def _service_disable(config: CastleConfig, name: str) -> int:
"""Disable a service in its mode (stop unit / drop route / uninstall)."""
import asyncio
from castle_core.lifecycle import deactivate
print(f"Disabling {name}...")
result = asyncio.run(deactivate(name, config, config.root))
print(f" {result.output}")
return 0
def _service_status(config: CastleConfig) -> int:
"""Show status of all services and jobs, dispatched by manager."""
from castle_core.lifecycle import is_active