diff --git a/AGENTS.md b/AGENTS.md
index 77fa58b..a457769 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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`**.
---
diff --git a/README.md b/README.md
index e908b0f..0cc97a8 100644
--- a/README.md
+++ b/README.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` —
diff --git a/app/src/components/GatewayPanel.tsx b/app/src/components/GatewayPanel.tsx
index 82c8426..442ef27 100644
--- a/app/src/components/GatewayPanel.tsx
+++ b/app/src/components/GatewayPanel.tsx
@@ -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) {
)
diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts
index a68e3fa..7a18157 100644
--- a/app/src/services/api/hooks.ts
+++ b/app/src/services/api/hooks.ts
@@ -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({
diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py
index 7aefc2f..9500497 100644
--- a/castle-api/src/castle_api/routes.py
+++ b/castle-api/src/castle_api/routes.py
@@ -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.
diff --git a/cli/src/castle_cli/commands/gateway.py b/cli/src/castle_cli/commands/gateway.py
index bb6ea13..32faa47 100644
--- a/cli/src/castle_cli/commands/gateway.py
+++ b/cli/src/castle_cli/commands/gateway.py
@@ -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:
diff --git a/cli/src/castle_cli/commands/service.py b/cli/src/castle_cli/commands/service.py
index fe85287..e4c4245 100644
--- a/cli/src/castle_cli/commands/service.py
+++ b/cli/src/castle_cli/commands/service.py
@@ -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
diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py
index f3dc587..95fd93e 100644
--- a/cli/src/castle_cli/main.py
+++ b/cli/src/castle_cli/main.py
@@ -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).
diff --git a/docs/developing-castle.md b/docs/developing-castle.md
index 4cd15c4..8eed038 100644
--- a/docs/developing-castle.md
+++ b/docs/developing-castle.md
@@ -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`
diff --git a/docs/stacks/python-fastapi.md b/docs/stacks/python-fastapi.md
index e2de164..27cc087 100644
--- a/docs/stacks/python-fastapi.md
+++ b/docs/stacks/python-fastapi.md
@@ -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).