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) {
- Run castle deploy to apply. + Run castle apply (or the Apply button) to converge.
) 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).