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:
@@ -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`**.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` —
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user