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

@@ -81,7 +81,7 @@ castle list [--kind ...] [--stack ...] [--json] # all deployments
castle status # unified health/status castle status # unified health/status
castle doctor # diagnose setup + runtime, with fix hints castle doctor # diagnose setup + runtime, with fix hints
castle restart [name] # imperative bounce (one or all) castle restart [name] # imperative bounce (one or all)
castle gateway reload|status # the Caddy gateway castle gateway # gateway status + route table (inspection)
``` ```
`castle service`/`job`/`tool` are **views** over the one deployment set, filtered `castle service`/`job`/`tool` are **views** over the one deployment set, filtered
@@ -184,8 +184,8 @@ public: true # ALSO expose to the internet via Cloudflare tunnel (requires pro
- There are **no path-prefix routes** — a whole subdomain maps to the backend - There are **no path-prefix routes** — a whole subdomain maps to the backend
root, so root-relative assets and `window.location` WebSocket URLs just work. root, so root-relative assets and `window.location` WebSocket URLs just work.
Inspect routes: `castle gateway status` (or `GET /gateway`). Regenerate + reload: Inspect routes: `castle gateway` / `GET /gateway`. Regenerate routes + reload the
`castle gateway reload`. → Field-level detail: **`docs/registry.md`**. gateway: `castle apply` (converge). → Field-level detail: **`docs/registry.md`**.
--- ---

View File

@@ -182,7 +182,7 @@ castle list [--kind K] [--stack S] [--json] # catalog + every deployment view
castle status # unified runtime status castle status # unified runtime status
castle doctor # diagnose setup + health, with fix hints castle doctor # diagnose setup + health, with fix hints
castle restart [name] # imperative bounce (one or all) castle restart [name] # imperative bounce (one or all)
castle gateway reload|status castle gateway # status + route table (inspection)
``` ```
To turn a deployment off, set `enabled: false` in its config and `castle apply` To turn a deployment off, set `enabled: false` in its config and `castle apply`

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { Globe, RefreshCw, FileText, ExternalLink, Cable } from "lucide-react" import { Globe, RefreshCw, FileText, ExternalLink, Cable } from "lucide-react"
import type { GatewayInfo, HealthStatus } from "@/types" import type { GatewayInfo, HealthStatus } from "@/types"
import { useGatewayReload, useCaddyfile } from "@/services/api/hooks" import { useApply, useCaddyfile } from "@/services/api/hooks"
import { subdomainUrl } from "@/lib/labels" import { subdomainUrl } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge" import { HealthBadge } from "./HealthBadge"
import { GatewaySettings } from "./GatewaySettings" import { GatewaySettings } from "./GatewaySettings"
@@ -14,7 +14,7 @@ interface GatewayPanelProps {
export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) { export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
const statusMap = new Map(statuses.map((s) => [s.id, s])) const statusMap = new Map(statuses.map((s) => [s.id, s]))
const { mutate: reload, isPending: reloading } = useGatewayReload() const { mutate: apply, isPending: applying } = useApply()
const [showCaddyfile, setShowCaddyfile] = useState(false) const [showCaddyfile, setShowCaddyfile] = useState(false)
const { data: caddyfileData } = useCaddyfile(showCaddyfile) const { data: caddyfileData } = useCaddyfile(showCaddyfile)
@@ -36,13 +36,13 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={() => reload()} onClick={() => apply({})}
disabled={reloading} disabled={applying}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-[var(--border)] hover:bg-[var(--border)]/80 text-[var(--muted)] hover:text-[var(--foreground)] transition-colors disabled:opacity-40" className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-[var(--border)] hover:bg-[var(--border)]/80 text-[var(--muted)] hover:text-[var(--foreground)] transition-colors disabled:opacity-40"
title="Regenerate Caddyfile and reload Caddy" title="Converge: regenerate routes and reload the gateway"
> >
<RefreshCw size={12} className={reloading ? "animate-spin" : ""} /> <RefreshCw size={12} className={applying ? "animate-spin" : ""} />
Reload Apply
</button> </button>
<button <button
onClick={() => setShowCaddyfile((v) => !v)} onClick={() => setShowCaddyfile((v) => !v)}

View File

@@ -4,7 +4,7 @@ import type { GatewayInfo } from "@/types"
import { useSaveGatewayConfig } from "@/services/api/hooks" import { useSaveGatewayConfig } from "@/services/api/hooks"
/** Editable gateway routing + public-exposure settings (domain / public_domain / /** Editable gateway routing + public-exposure settings (domain / public_domain /
* tunnel / tls). Saves to castle.yaml; changes take effect on the next deploy. */ * tunnel / tls). Saves to castle.yaml; changes take effect on the next apply. */
export function GatewaySettings({ gateway }: { gateway: GatewayInfo }) { export function GatewaySettings({ gateway }: { gateway: GatewayInfo }) {
const { mutate: save, isPending, data: saved } = useSaveGatewayConfig() const { mutate: save, isPending, data: saved } = useSaveGatewayConfig()
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
@@ -44,7 +44,7 @@ export function GatewaySettings({ gateway }: { gateway: GatewayInfo }) {
<button onClick={() => setEditing(false)} className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-[var(--border)] text-[var(--muted)]"> <button onClick={() => setEditing(false)} className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-[var(--border)] text-[var(--muted)]">
<X size={12} /> Cancel <X size={12} /> Cancel
</button> </button>
<span className="text-xs text-[var(--muted)]">Run <span className="font-mono">castle deploy</span> to apply.</span> <span className="text-xs text-[var(--muted)]">Run <span className="font-mono">castle apply</span> (or the Apply button) to converge.</span>
</div> </div>
</div> </div>
) )

View File

@@ -226,16 +226,6 @@ export function useProgramAction() {
}) })
} }
export function useGatewayReload() {
const qc = useQueryClient()
return useMutation({
mutationFn: () => apiClient.post<{ status: string }>("/gateway/reload"),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["gateway"] })
},
})
}
export function useSaveGatewayConfig() { export function useSaveGatewayConfig() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({

View File

@@ -2,13 +2,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from castle_core.config import SPECS_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.manifest import ( from castle_core.manifest import (
ProgramSpec, ProgramSpec,
@@ -937,7 +935,7 @@ def get_gateway() -> GatewayInfo:
def save_gateway_config(request: GatewayConfigRequest) -> dict[str, str]: def save_gateway_config(request: GatewayConfigRequest) -> dict[str, str]:
"""Update the gateway's routing/exposure settings in castle.yaml. """Update the gateway's routing/exposure settings in castle.yaml.
Saves only; the caller runs deploy to regenerate the Caddyfile + tunnel config. Saves only; the caller runs apply to regenerate the Caddyfile + tunnel config.
An empty string clears a field. An empty string clears a field.
""" """
root = get_castle_root() root = get_castle_root()
@@ -952,7 +950,7 @@ def save_gateway_config(request: GatewayConfigRequest) -> dict[str, str]:
config.gateway.public_domain = norm(request.public_domain) config.gateway.public_domain = norm(request.public_domain)
config.gateway.tunnel_id = norm(request.tunnel_id) config.gateway.tunnel_id = norm(request.tunnel_id)
save_config(config) save_config(config)
return {"status": "saved", "message": "Saved. Run deploy to apply."} return {"status": "saved", "message": "Saved. Run apply to converge."}
@router.get("/gateway/caddyfile") @router.get("/gateway/caddyfile")
@@ -962,35 +960,5 @@ def get_caddyfile() -> dict[str, str]:
return {"content": generate_caddyfile_from_registry(registry)} return {"content": generate_caddyfile_from_registry(registry)}
@router.post("/gateway/reload") # Note: gateway reload is not a standalone endpoint. Making routes/config live is
async def reload_gateway() -> dict[str, str]: # convergence — POST /apply renders the Caddyfile (+ tunnel) and reloads Caddy.
"""Regenerate Caddyfile and reload Caddy."""
registry = get_registry()
SPECS_DIR.mkdir(parents=True, exist_ok=True)
caddyfile_path = SPECS_DIR / "Caddyfile"
# Include remote registries for cross-node routing
remote_regs = {h: n.registry for h, n in mesh_state.all_nodes().items()}
caddyfile_path.write_text(
generate_caddyfile_from_registry(
registry, remote_registries=remote_regs or None
)
)
proc = await asyncio.create_subprocess_exec(
"systemctl",
"--user",
"reload",
"castle-castle-gateway.service",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise HTTPException(
status_code=500,
detail=f"Reload failed: {(stderr or b'').decode().strip()}",
)
return {"status": "ok"}

View File

@@ -1,119 +1,25 @@
"""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 from __future__ import annotations
import argparse import argparse
import subprocess 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_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" 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: def run_gateway(args: argparse.Namespace) -> int:
"""Manage the Caddy gateway.""" """Show the gateway's status + route table (the only gateway verb)."""
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 _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: def _gateway_status() -> int:
"""Show gateway status + the full route table (static, proxy, remote).""" """Show gateway status + the full route table (static, proxy, remote)."""

View File

@@ -14,7 +14,6 @@ from castle_core.manifest import kind_for
from castle_cli.config import ( from castle_cli.config import (
CastleConfig, CastleConfig,
ensure_dirs,
load_config, load_config,
) )
@@ -178,30 +177,6 @@ def run_status(args: argparse.Namespace) -> int:
return 0 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: def _service_status(config: CastleConfig) -> int:
"""Show status of all services and jobs, dispatched by manager.""" """Show status of all services and jobs, dispatched by manager."""
from castle_core.lifecycle import is_active from castle_core.lifecycle import is_active

View File

@@ -159,15 +159,11 @@ def build_parser() -> argparse.ArgumentParser:
_build_deployment_group(subparsers, "job") _build_deployment_group(subparsers, "job")
_build_tool_group(subparsers) _build_tool_group(subparsers)
# Gateway (infrastructure) # Gateway (inspection). The gateway is a deployment — start/stop/reload it via
gw = subparsers.add_parser("gateway", help="Manage the Caddy gateway") # `castle apply` / `castle restart castle-gateway`; this lens just shows routes.
gw = subparsers.add_parser("gateway", help="Show the gateway's status + route table")
gw_sub = gw.add_subparsers(dest="gateway_command") gw_sub = gw.add_subparsers(dest="gateway_command")
p = gw_sub.add_parser("start", help="Start the gateway") gw_sub.add_parser("status", help="Show gateway status + routes (the default)")
p.add_argument("--dry-run", action="store_true")
gw_sub.add_parser("stop", help="Stop the gateway")
p = gw_sub.add_parser("reload", help="Reload gateway configuration")
p.add_argument("--dry-run", action="store_true")
gw_sub.add_parser("status", help="Show gateway status")
# Convergence — the one lifecycle verb. Renders units/Caddyfile/tunnel, then # Convergence — the one lifecycle verb. Renders units/Caddyfile/tunnel, then
# reconciles the runtime to match config (activate/restart/deactivate). # reconciles the runtime to match config (activate/restart/deactivate).

View File

@@ -51,7 +51,7 @@ for tools/libraries.
- Catalog + editing: `GET /programs[/{name}]`, `POST /programs/{name}/{action}`, - Catalog + editing: `GET /programs[/{name}]`, `POST /programs/{name}/{action}`,
`PUT|DELETE /programs/{name}`; likewise `/services`, `/jobs` `PUT|DELETE /programs/{name}`; likewise `/services`, `/jobs`
- Config: `GET|PUT /`, `POST /apply`, `POST /deploy` - Config: `GET|PUT /`, `POST /apply`, `POST /deploy`
- Gateway: `GET /gateway`, `GET /gateway/caddyfile`, `POST /gateway/reload` - Gateway: `GET /gateway`, `GET /gateway/caddyfile` (reload is convergence: `POST /apply`)
- Mesh: `GET /mesh/status`, `GET /nodes[/{hostname}]` - Mesh: `GET /mesh/status`, `GET /nodes[/{hostname}]`
- Agents (dashboard terminal UX): `GET /agents`, `GET /agents/sessions`, - Agents (dashboard terminal UX): `GET /agents`, `GET /agents/sessions`,
`GET /agents/history`, `DELETE /agents/sessions/{id}`, `WS /agents/{name}/session` `GET /agents/history`, `DELETE /agents/sessions/{id}`, `WS /agents/{name}/session`

View File

@@ -454,4 +454,4 @@ castle program create my-service --stack python-fastapi --description "Does some
``` ```
See @docs/registry.md for manifest fields, castle.yaml structure, See @docs/registry.md for manifest fields, castle.yaml structure,
and the full service lifecycle (enable, logs, gateway reload). and the full service lifecycle (`castle apply`, logs).