Merge refactor/gateway-converge: gateway converges via castle apply

This commit is contained in:
2026-07-02 12:15:33 -07:00
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 doctor # diagnose setup + runtime, with fix hints
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
@@ -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
root, so root-relative assets and `window.location` WebSocket URLs just work.
Inspect routes: `castle gateway status` (or `GET /gateway`). Regenerate + reload:
`castle gateway reload`. → Field-level detail: **`docs/registry.md`**.
Inspect routes: `castle gateway` / `GET /gateway`. Regenerate routes + reload the
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 doctor # diagnose setup + health, with fix hints
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`

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { Globe, RefreshCw, FileText, ExternalLink, Cable } from "lucide-react"
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 { HealthBadge } from "./HealthBadge"
import { GatewaySettings } from "./GatewaySettings"
@@ -14,7 +14,7 @@ interface GatewayPanelProps {
export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
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 { data: caddyfileData } = useCaddyfile(showCaddyfile)
@@ -36,13 +36,13 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</div>
<div className="flex items-center gap-1">
<button
onClick={() => reload()}
disabled={reloading}
onClick={() => apply({})}
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"
title="Regenerate Caddyfile and reload Caddy"
title="Converge: regenerate routes and reload the gateway"
>
<RefreshCw size={12} className={reloading ? "animate-spin" : ""} />
Reload
<RefreshCw size={12} className={applying ? "animate-spin" : ""} />
Apply
</button>
<button
onClick={() => setShowCaddyfile((v) => !v)}

View File

@@ -4,7 +4,7 @@ import type { GatewayInfo } from "@/types"
import { useSaveGatewayConfig } from "@/services/api/hooks"
/** 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 }) {
const { mutate: save, isPending, data: saved } = useSaveGatewayConfig()
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)]">
<X size={12} /> Cancel
</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>
)

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() {
const qc = useQueryClient()
return useMutation({

View File

@@ -2,13 +2,11 @@
from __future__ import annotations
import asyncio
import subprocess
from pathlib import Path
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.manifest import (
ProgramSpec,
@@ -937,7 +935,7 @@ def get_gateway() -> GatewayInfo:
def save_gateway_config(request: GatewayConfigRequest) -> dict[str, str]:
"""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.
"""
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.tunnel_id = norm(request.tunnel_id)
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")
@@ -962,35 +960,5 @@ def get_caddyfile() -> dict[str, str]:
return {"content": generate_caddyfile_from_registry(registry)}
@router.post("/gateway/reload")
async def reload_gateway() -> dict[str, str]:
"""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"}
# Note: gateway reload is not a standalone endpoint. Making routes/config live is
# convergence — POST /apply renders the Caddyfile (+ tunnel) and reloads Caddy.

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
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":
"""Show the gateway's status + route table (the only gateway verb)."""
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)."""

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

View File

@@ -159,15 +159,11 @@ def build_parser() -> argparse.ArgumentParser:
_build_deployment_group(subparsers, "job")
_build_tool_group(subparsers)
# Gateway (infrastructure)
gw = subparsers.add_parser("gateway", help="Manage the Caddy gateway")
# Gateway (inspection). The gateway is a deployment — start/stop/reload it via
# `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")
p = gw_sub.add_parser("start", help="Start the gateway")
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")
gw_sub.add_parser("status", help="Show gateway status + routes (the default)")
# Convergence — the one lifecycle verb. Renders units/Caddyfile/tunnel, then
# 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}`,
`PUT|DELETE /programs/{name}`; likewise `/services`, `/jobs`
- 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}]`
- Agents (dashboard terminal UX): `GET /agents`, `GET /agents/sessions`,
`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,
and the full service lifecycle (enable, logs, gateway reload).
and the full service lifecycle (`castle apply`, logs).