feat: unified gateway route table (static · proxy · remote)
The gateway does two things — reverse-proxy services and serve static
frontends — but the dashboard/API route table only ever showed the proxy
routes, so 'serving a frontend' and 'proxying a service' looked like unrelated
features and castle-app/power-graph-app were invisible in the route view.
Now there's one concept: a route maps an address (path '/foo' or host 'foo.lan')
to a target of one kind — static (a built dist served by file_server), proxy
(a local service port), or remote (a service on another node).
- core: caddyfile.py gains compute_routes() — the single source of truth for
the route list; generate_caddyfile_from_registry renders it (output byte-for-
byte identical, verified against the live Caddyfile). Adds a GatewayRoute
dataclass.
- api: GatewayRoute model → {address, kind, target, name, node}; GET /gateway
builds from compute_routes (incl. config for static frontends + mesh for
remote), so the table matches what Caddy actually does.
- app: Gateway panel shows Address · Kind · Target for every route (static
frontends + host routes now appear); program detail shows 'Reachable at
/foo/ · served (static)' for static frontends.
- cli: 'castle gateway status' prints the full route table.
- docs: registry.md/design.md/CLAUDE.md describe routes as one concept with
three target kinds.
core 94 / cli 24 / api 52 green; ruff + app build clean; Caddyfile unchanged.
This commit is contained in:
10
CLAUDE.md
10
CLAUDE.md
@@ -128,8 +128,12 @@ code, artifacts, secrets; default `~/.castle`) and `CASTLE_DATA_DIR` (program
|
|||||||
data I/O on a dedicated volume; default `/data/castle`). Paths below use
|
data I/O on a dedicated volume; default `/data/castle`). Paths below use
|
||||||
`$CASTLE_HOME` and `$CASTLE_DATA_DIR` accordingly.
|
`$CASTLE_HOME` and `$CASTLE_DATA_DIR` accordingly.
|
||||||
|
|
||||||
- **Gateway**: Caddy reverse proxy at port 9000, config generated from `castle.yaml`
|
- **Gateway**: Caddy at port 9000 — both a reverse proxy (to local/remote
|
||||||
into `$CASTLE_HOME/artifacts/specs/Caddyfile`. Dashboard served at root.
|
services) and a static file server (for built frontends, served in place from
|
||||||
|
`<source>/<dist>`). Config generated from `castle.yaml` into
|
||||||
|
`$CASTLE_HOME/artifacts/specs/Caddyfile`. A route maps an address (path or
|
||||||
|
host) to a target of kind static/proxy/remote; `castle gateway status` lists
|
||||||
|
them. Dashboard (castle-app) served at root.
|
||||||
- **Systemd**: User units generated under `~/.config/systemd/user/castle-*.service`.
|
- **Systemd**: User units generated under `~/.config/systemd/user/castle-*.service`.
|
||||||
Use drop-in overrides (`*.service.d/*.conf`) for extra env vars that `castle deploy`
|
Use drop-in overrides (`*.service.d/*.conf`) for extra env vars that `castle deploy`
|
||||||
shouldn't overwrite (e.g., `CASTLE_API_MQTT_ENABLED`).
|
shouldn't overwrite (e.g., `CASTLE_API_MQTT_ENABLED`).
|
||||||
@@ -165,7 +169,7 @@ Config:
|
|||||||
- `POST /apply` — Apply registry changes; `POST /deploy` — Deploy to runtime
|
- `POST /apply` — Apply registry changes; `POST /deploy` — Deploy to runtime
|
||||||
|
|
||||||
Gateway:
|
Gateway:
|
||||||
- `GET /gateway` — Gateway info with route table and hostname
|
- `GET /gateway` — Gateway info + full route table (every route tagged kind=static|proxy|remote, with its address and target)
|
||||||
- `GET /gateway/caddyfile` — Generated Caddyfile content
|
- `GET /gateway/caddyfile` — Generated Caddyfile content
|
||||||
- `POST /gateway/reload` — Regenerate Caddyfile and reload Caddy
|
- `POST /gateway/reload` — Regenerate Caddyfile and reload Caddy
|
||||||
|
|
||||||
|
|||||||
@@ -57,14 +57,14 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Route table */}
|
{/* Route table — every gateway route, of every kind */}
|
||||||
{gateway.routes.length > 0 && (
|
{gateway.routes.length > 0 && (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[var(--border)] text-left">
|
<tr className="border-b border-[var(--border)] text-left">
|
||||||
<th className="px-4 py-2 font-medium text-[var(--muted)]">Path</th>
|
<th className="px-4 py-2 font-medium text-[var(--muted)]">Address</th>
|
||||||
<th className="px-4 py-2 font-medium text-[var(--muted)]">Program</th>
|
<th className="px-4 py-2 font-medium text-[var(--muted)]">Kind</th>
|
||||||
<th className="px-4 py-2 font-medium text-[var(--muted)]">Port</th>
|
<th className="px-4 py-2 font-medium text-[var(--muted)]">Target</th>
|
||||||
{multiNode && (
|
{multiNode && (
|
||||||
<th className="px-4 py-2 font-medium text-[var(--muted)]">Node</th>
|
<th className="px-4 py-2 font-medium text-[var(--muted)]">Node</th>
|
||||||
)}
|
)}
|
||||||
@@ -73,25 +73,26 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{gateway.routes.map((route) => {
|
{gateway.routes.map((route) => {
|
||||||
const health = statusMap.get(route.program)
|
// Health applies to proxy/remote targets (a running service);
|
||||||
|
// static targets are files on disk.
|
||||||
|
const health = route.kind !== "static" && route.name ? statusMap.get(route.name) : undefined
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={route.path}
|
key={`${route.address}-${route.node}`}
|
||||||
className="border-b border-[var(--border)] last:border-b-0 hover:bg-black/20 transition-colors"
|
className="border-b border-[var(--border)] last:border-b-0 hover:bg-black/20 transition-colors"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2 font-mono text-[var(--primary)]">
|
<td className="px-4 py-2 font-mono text-[var(--primary)]">{route.address}</td>
|
||||||
{route.path}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<Link
|
<KindBadge kind={route.kind} />
|
||||||
to={`/deployment/${route.program}`}
|
|
||||||
className="hover:text-[var(--primary)] transition-colors"
|
|
||||||
>
|
|
||||||
{route.program}
|
|
||||||
</Link>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 font-mono text-[var(--muted)]">
|
<td className="px-4 py-2 font-mono text-xs text-[var(--muted)]">
|
||||||
{route.target_port}
|
{route.name ? (
|
||||||
|
<Link to={`/deployment/${route.name}`} className="hover:text-[var(--primary)]">
|
||||||
|
{route.kind === "static" ? shortDir(route.target) : route.target}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
route.target
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
{multiNode && (
|
{multiNode && (
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
@@ -104,10 +105,10 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
|
|||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
{health ? (
|
{route.kind === "static" ? (
|
||||||
<HealthBadge status={health.status} latency={health.latency_ms} />
|
<span className="text-xs text-[var(--muted)]">—</span>
|
||||||
) : (
|
) : (
|
||||||
<HealthBadge status="unknown" />
|
<HealthBadge status={health?.status ?? "unknown"} latency={health?.latency_ms} />
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -119,7 +120,7 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
|
|||||||
|
|
||||||
{gateway.routes.length === 0 && (
|
{gateway.routes.length === 0 && (
|
||||||
<p className="px-4 py-6 text-center text-[var(--muted)] text-sm">
|
<p className="px-4 py-6 text-center text-[var(--muted)] text-sm">
|
||||||
No proxy routes configured.
|
No gateway routes configured.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -134,3 +135,24 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
|
|||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const KIND_STYLE: Record<string, string> = {
|
||||||
|
static: "bg-cyan-900/30 text-cyan-300 border-cyan-800",
|
||||||
|
proxy: "bg-green-900/30 text-green-300 border-green-800",
|
||||||
|
remote: "bg-purple-900/30 text-purple-300 border-purple-800",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caddy serves static files; proxy/remote forward to a process. */
|
||||||
|
function KindBadge({ kind }: { kind: string }) {
|
||||||
|
return (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded border ${KIND_STYLE[kind] ?? "text-[var(--muted)]"}`}>
|
||||||
|
{kind}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show the tail of a serve directory (…/app/dist). */
|
||||||
|
function shortDir(path: string): string {
|
||||||
|
const parts = path.split("/").filter(Boolean)
|
||||||
|
return parts.length <= 2 ? path : "…/" + parts.slice(-2).join("/")
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ export function ProgramDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A static frontend (frontend behavior, build outputs, no service) is served
|
||||||
|
// by the gateway in place — show where.
|
||||||
|
const buildOutputs = (deployment.manifest.build as { outputs?: string[] } | undefined)?.outputs
|
||||||
|
const servedAt =
|
||||||
|
deployment.behavior === "frontend" && deployment.services.length === 0 && buildOutputs?.length
|
||||||
|
? deployment.id === "castle-app"
|
||||||
|
? "/"
|
||||||
|
: `/${deployment.id}/`
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto px-6 py-8">
|
<div className="max-w-3xl mx-auto px-6 py-8">
|
||||||
<DetailHeader
|
<DetailHeader
|
||||||
@@ -101,6 +111,14 @@ export function ProgramDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{servedAt && (
|
||||||
|
<>
|
||||||
|
<span className="text-[var(--muted)]">Reachable at</span>
|
||||||
|
<a href={servedAt} className="font-mono text-[var(--primary)] hover:underline">
|
||||||
|
{servedAt} <span className="text-[var(--muted)]">· served (static)</span>
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{deployment.commands && Object.keys(deployment.commands).length > 0 && (
|
{deployment.commands && Object.keys(deployment.commands).length > 0 && (
|
||||||
|
|||||||
@@ -110,9 +110,10 @@ export interface StatusResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GatewayRoute {
|
export interface GatewayRoute {
|
||||||
path: string
|
address: string // "/foo" (path prefix) or "foo.lan" (host)
|
||||||
target_port: number
|
kind: "static" | "proxy" | "remote"
|
||||||
program: string
|
target: string // serve dir, "localhost:PORT", or "host:PORT"
|
||||||
|
name: string | null
|
||||||
node: string
|
node: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,11 +133,17 @@ class StatusResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class GatewayRoute(BaseModel):
|
class GatewayRoute(BaseModel):
|
||||||
"""A single route in the gateway's reverse proxy table."""
|
"""One gateway route: a public address mapped to a target.
|
||||||
|
|
||||||
path: str
|
kind is `static` (Caddy serves a built dir), `proxy` (reverse-proxy a local
|
||||||
target_port: int
|
service), or `remote` (reverse-proxy another node). address is a path prefix
|
||||||
program: str
|
(`/foo`) or a host (`foo.lan`); target is the serve dir or `host:port`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
address: str
|
||||||
|
kind: str
|
||||||
|
target: str
|
||||||
|
name: str | None = None
|
||||||
node: str
|
node: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -844,40 +844,41 @@ async def get_status() -> StatusResponse:
|
|||||||
|
|
||||||
@router.get("/gateway", response_model=GatewayInfo)
|
@router.get("/gateway", response_model=GatewayInfo)
|
||||||
def get_gateway() -> GatewayInfo:
|
def get_gateway() -> GatewayInfo:
|
||||||
"""Get gateway configuration summary."""
|
"""Get gateway configuration summary, including the full route table.
|
||||||
|
|
||||||
|
Routes are computed by the same function that generates the Caddyfile, so
|
||||||
|
the table matches reality: static-served frontends, path/host proxies, and
|
||||||
|
cross-node routes all appear, each tagged with its kind and target.
|
||||||
|
"""
|
||||||
|
from castle_core.generators.caddyfile import compute_routes
|
||||||
|
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
deployed_count = len(registry.deployed)
|
deployed_count = len(registry.deployed)
|
||||||
service_count = sum(1 for d in registry.deployed.values() if d.port is not None)
|
service_count = sum(1 for d in registry.deployed.values() if d.port is not None)
|
||||||
managed_count = sum(1 for d in registry.deployed.values() if d.managed)
|
managed_count = sum(1 for d in registry.deployed.values() if d.managed)
|
||||||
|
|
||||||
# Local routes
|
config = None
|
||||||
routes: list[GatewayRoute] = [
|
root = get_castle_root()
|
||||||
|
if root:
|
||||||
|
try:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
config = load_config(root)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
remote = {h: r.registry for h, r in mesh_state.all_nodes().items()}
|
||||||
|
routes = [
|
||||||
GatewayRoute(
|
GatewayRoute(
|
||||||
path=d.proxy_path,
|
address=r.address,
|
||||||
target_port=d.port,
|
kind=r.kind,
|
||||||
program=name,
|
target=r.target,
|
||||||
node=registry.node.hostname,
|
name=r.name,
|
||||||
|
node=r.node or registry.node.hostname,
|
||||||
)
|
)
|
||||||
for name, d in registry.deployed.items()
|
for r in compute_routes(registry, config, remote or None)
|
||||||
if d.proxy_path and d.port
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Remote routes from mesh (local paths take precedence)
|
|
||||||
local_paths = {r.path for r in routes}
|
|
||||||
for hostname, remote in mesh_state.all_nodes().items():
|
|
||||||
for name, d in remote.registry.deployed.items():
|
|
||||||
if d.proxy_path and d.port and d.proxy_path not in local_paths:
|
|
||||||
routes.append(
|
|
||||||
GatewayRoute(
|
|
||||||
path=d.proxy_path,
|
|
||||||
target_port=d.port,
|
|
||||||
program=name,
|
|
||||||
node=hostname,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
routes.sort(key=lambda r: r.path)
|
|
||||||
|
|
||||||
return GatewayInfo(
|
return GatewayInfo(
|
||||||
port=registry.node.gateway_port,
|
port=registry.node.gateway_port,
|
||||||
hostname=registry.node.hostname,
|
hostname=registry.node.hostname,
|
||||||
|
|||||||
@@ -252,20 +252,19 @@ class TestGateway:
|
|||||||
assert data["managed_count"] == 1
|
assert data["managed_count"] == 1
|
||||||
|
|
||||||
def test_gateway_routes(self, client: TestClient) -> None:
|
def test_gateway_routes(self, client: TestClient) -> None:
|
||||||
"""Returns proxy routes from deployed components."""
|
"""Returns the full route table, tagged with kind + target."""
|
||||||
response = client.get("/gateway")
|
response = client.get("/gateway")
|
||||||
data = response.json()
|
data = response.json()
|
||||||
routes = data["routes"]
|
route = next(r for r in data["routes"] if r["address"] == "/test-svc")
|
||||||
assert len(routes) == 1
|
assert route["kind"] == "proxy"
|
||||||
route = routes[0]
|
assert route["target"] == "localhost:19000"
|
||||||
assert route["path"] == "/test-svc"
|
assert route["name"] == "test-svc"
|
||||||
assert route["target_port"] == 19000
|
|
||||||
assert route["program"] == "test-svc"
|
|
||||||
assert route["node"] == "test-node"
|
assert route["node"] == "test-node"
|
||||||
|
|
||||||
def test_gateway_routes_sorted(self, client: TestClient) -> None:
|
def test_gateway_route_kinds_valid(self, client: TestClient) -> None:
|
||||||
"""Routes are sorted by path."""
|
"""Every route declares a known kind."""
|
||||||
response = client.get("/gateway")
|
response = client.get("/gateway")
|
||||||
data = response.json()
|
data = response.json()
|
||||||
paths = [r["path"] for r in data["routes"]]
|
assert data["routes"]
|
||||||
assert paths == sorted(paths)
|
for r in data["routes"]:
|
||||||
|
assert r["kind"] in ("static", "proxy", "remote")
|
||||||
|
|||||||
@@ -116,17 +116,30 @@ def _gateway_reload() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _gateway_status() -> int:
|
def _gateway_status() -> int:
|
||||||
"""Show gateway status via systemd."""
|
"""Show gateway status + the full route table (static, proxy, remote)."""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["systemctl", "--user", "is-active", GATEWAY_UNIT],
|
["systemctl", "--user", "is-active", GATEWAY_UNIT],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
status = result.stdout.strip()
|
status = result.stdout.strip()
|
||||||
|
print(f"Gateway: {'running' if status == 'active' else status}")
|
||||||
|
|
||||||
if status == "active":
|
if not REGISTRY_PATH.exists():
|
||||||
print("Gateway: running")
|
print(" (no registry — run 'castle deploy')")
|
||||||
else:
|
return 0
|
||||||
print(f"Gateway: {status}")
|
|
||||||
|
|
||||||
|
from castle_core.generators.caddyfile import compute_routes
|
||||||
|
|
||||||
|
routes = compute_routes(load_registry())
|
||||||
|
if not routes:
|
||||||
|
print(" No routes configured.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Each route: address → target, tagged by kind. static = files served in
|
||||||
|
# place; proxy/remote = reverse-proxied to a process.
|
||||||
|
print(f"\n {'ADDRESS':24} {'KIND':7} TARGET")
|
||||||
|
for r in routes:
|
||||||
|
target = r.target.replace("localhost:", ":") if r.kind != "static" else r.target
|
||||||
|
print(f" {r.address:24} {r.kind:7} {target}")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -1,138 +1,165 @@
|
|||||||
"""Caddyfile generation from node registry."""
|
"""Gateway routes + Caddyfile generation from the node registry.
|
||||||
|
|
||||||
|
A single source of truth: `compute_routes()` produces the structured list of
|
||||||
|
gateway routes; `generate_caddyfile_from_registry()` renders that list to a
|
||||||
|
Caddyfile, and the API serves the same list to the dashboard — so the route
|
||||||
|
table always matches what Caddy actually does.
|
||||||
|
|
||||||
|
A route maps a public **address** (a path prefix `/foo`, or a host `foo.lan`) to
|
||||||
|
a **target**, of one **kind**:
|
||||||
|
- ``static`` — a built frontend's `dist/`; Caddy serves files (`file_server`).
|
||||||
|
- ``proxy`` — a local service on a port; Caddy reverse-proxies.
|
||||||
|
- ``remote`` — a service on another node; Caddy reverse-proxies cross-node.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from castle_core.config import SPECS_DIR
|
from castle_core.config import SPECS_DIR
|
||||||
from castle_core.registry import NodeRegistry
|
from castle_core.registry import NodeRegistry
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GatewayRoute:
|
||||||
|
"""One gateway route: address → target."""
|
||||||
|
|
||||||
|
address: str # "/foo" (path prefix, served at /foo/*) or "foo.lan" (host)
|
||||||
|
kind: str # "static" | "proxy" | "remote"
|
||||||
|
target: str # static: serve dir; proxy: "localhost:PORT"/base_url; remote: "host:PORT"
|
||||||
|
name: str | None = None # backing program/service
|
||||||
|
node: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_host(self) -> bool:
|
||||||
|
return not self.address.startswith("/")
|
||||||
|
|
||||||
|
|
||||||
|
def compute_routes(
|
||||||
|
registry: NodeRegistry,
|
||||||
|
config: object | None = None,
|
||||||
|
remote_registries: dict[str, NodeRegistry] | None = None,
|
||||||
|
) -> list[GatewayRoute]:
|
||||||
|
"""Build the full ordered list of gateway routes (host, proxy, remote, static).
|
||||||
|
|
||||||
|
Order matters for Caddy precedence: host matchers first, then path proxies,
|
||||||
|
then cross-node, then static frontends (the root app last)."""
|
||||||
|
if config is None:
|
||||||
|
try:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
except Exception:
|
||||||
|
config = None
|
||||||
|
|
||||||
|
node = registry.node.hostname
|
||||||
|
routes: list[GatewayRoute] = []
|
||||||
|
local_paths: set[str] = set()
|
||||||
|
|
||||||
|
# Host-based proxy routes (whole host → backend root).
|
||||||
|
for name, d in registry.deployed.items():
|
||||||
|
if d.proxy_host and (d.port or d.base_url):
|
||||||
|
target = d.base_url or f"localhost:{d.port}"
|
||||||
|
routes.append(GatewayRoute(d.proxy_host, "proxy", target, name, node))
|
||||||
|
|
||||||
|
# Path-prefix proxy routes.
|
||||||
|
for name, d in registry.deployed.items():
|
||||||
|
if d.proxy_path and (d.port or d.base_url):
|
||||||
|
local_paths.add(d.proxy_path)
|
||||||
|
target = d.base_url or f"localhost:{d.port}"
|
||||||
|
routes.append(GatewayRoute(d.proxy_path, "proxy", target, name, node))
|
||||||
|
|
||||||
|
# Cross-node routes — local paths take precedence.
|
||||||
|
if remote_registries:
|
||||||
|
for hostname, remote_reg in remote_registries.items():
|
||||||
|
for name, d in remote_reg.deployed.items():
|
||||||
|
if d.proxy_path and d.port and d.proxy_path not in local_paths:
|
||||||
|
local_paths.add(d.proxy_path)
|
||||||
|
routes.append(
|
||||||
|
GatewayRoute(d.proxy_path, "remote", f"{hostname}:{d.port}", name, hostname)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Static frontends — a behavior=frontend program with build outputs and no
|
||||||
|
# service of its own; served in place from <source>/<dist>. castle-app is the
|
||||||
|
# root app (/), others mount at /<name>.
|
||||||
|
if config is not None:
|
||||||
|
for name, prog in sorted(config.programs.items()):
|
||||||
|
if prog.behavior != "frontend" or not prog.source:
|
||||||
|
continue
|
||||||
|
if not (prog.build and prog.build.outputs):
|
||||||
|
continue
|
||||||
|
if name in config.services: # self-serving frontend → already a proxy route
|
||||||
|
continue
|
||||||
|
serve_dir = str(Path(prog.source) / prog.build.outputs[0])
|
||||||
|
address = "/" if name == "castle-app" else f"/{name}"
|
||||||
|
if address != "/" and address in local_paths:
|
||||||
|
continue
|
||||||
|
routes.append(GatewayRoute(address, "static", serve_dir, name, node))
|
||||||
|
|
||||||
|
return routes
|
||||||
|
|
||||||
|
|
||||||
def generate_caddyfile_from_registry(
|
def generate_caddyfile_from_registry(
|
||||||
registry: NodeRegistry,
|
registry: NodeRegistry,
|
||||||
remote_registries: dict[str, NodeRegistry] | None = None,
|
remote_registries: dict[str, NodeRegistry] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate Caddyfile from the node registry.
|
"""Render the route list to a Caddyfile."""
|
||||||
|
routes = compute_routes(registry, None, remote_registries)
|
||||||
Static files served from ~/.castle/artifacts/content/castle-app/.
|
|
||||||
No repo-relative paths.
|
|
||||||
|
|
||||||
If remote_registries is provided, cross-node routes are added for
|
|
||||||
remote services whose proxy_path doesn't conflict with local ones.
|
|
||||||
"""
|
|
||||||
gw_port = registry.node.gateway_port
|
gw_port = registry.node.gateway_port
|
||||||
|
|
||||||
# Global options: the gateway is an internal HTTP-only reverse proxy on a
|
# HTTP-only internal gateway on a non-standard port → disable auto-HTTPS so a
|
||||||
# non-standard port. Disable automatic HTTPS so named hosts don't pull the
|
# named host doesn't pull the listener into TLS or try to bind :80/:443.
|
||||||
# listener into TLS or try to bind :80/:443 (which fails for a user service).
|
lines = ["{", " auto_https off", "}", "", f":{gw_port} {{"]
|
||||||
lines = ["{", " auto_https off", "}", ""]
|
|
||||||
|
|
||||||
lines.append(f":{gw_port} {{")
|
root_static: GatewayRoute | None = None
|
||||||
|
for r in routes:
|
||||||
# Track local proxy paths for precedence
|
if r.kind == "static" and r.address == "/":
|
||||||
local_paths: set[str] = set()
|
root_static = r # the root app is the catch-all, emitted last
|
||||||
|
|
||||||
# Host-based routes: a `host` matcher inside the single :9000 site (NOT a
|
|
||||||
# separate site block — that would split the listener and flip it to TLS).
|
|
||||||
# The whole host maps to the backend root, so a root-based SPA (base="/")
|
|
||||||
# serves unchanged. Emitted first so a host match wins over path routes.
|
|
||||||
for name, deployed in registry.deployed.items():
|
|
||||||
if not deployed.proxy_host:
|
|
||||||
continue
|
continue
|
||||||
if not deployed.port and not deployed.base_url:
|
if r.kind == "static":
|
||||||
continue
|
lines += [
|
||||||
target = deployed.base_url or f"localhost:{deployed.port}"
|
f" handle_path {r.address}/* {{",
|
||||||
matcher = f"@host_{name.replace('-', '_')}"
|
f" root * {r.target}",
|
||||||
lines.append(f" {matcher} host {deployed.proxy_host}")
|
" try_files {path} /index.html",
|
||||||
lines.append(f" handle {matcher} {{")
|
" file_server",
|
||||||
lines.append(f" reverse_proxy {target}")
|
" }",
|
||||||
lines.append(" }")
|
"",
|
||||||
lines.append("")
|
]
|
||||||
|
elif r.is_host: # host-based proxy
|
||||||
|
matcher = f"@host_{(r.name or r.address).replace('-', '_').replace('.', '_')}"
|
||||||
|
lines += [
|
||||||
|
f" {matcher} host {r.address}",
|
||||||
|
f" handle {matcher} {{",
|
||||||
|
f" reverse_proxy {r.target}",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
else: # path-prefix proxy (local or remote)
|
||||||
|
if r.kind == "remote":
|
||||||
|
lines.append(f" # {r.name} on {r.node}")
|
||||||
|
lines += [
|
||||||
|
f" handle_path {r.address}/* {{",
|
||||||
|
f" reverse_proxy {r.target}",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
for name, deployed in registry.deployed.items():
|
if root_static is not None:
|
||||||
if not deployed.proxy_path:
|
lines += [
|
||||||
continue
|
" handle {",
|
||||||
# Need either a local port or a remote base_url to proxy to
|
f" root * {root_static.target}",
|
||||||
if not deployed.port and not deployed.base_url:
|
" try_files {path} /index.html",
|
||||||
continue
|
" file_server",
|
||||||
|
" }",
|
||||||
local_paths.add(deployed.proxy_path)
|
]
|
||||||
target = deployed.base_url or f"localhost:{deployed.port}"
|
|
||||||
lines.append(f" handle_path {deployed.proxy_path}/* {{")
|
|
||||||
lines.append(f" reverse_proxy {target}")
|
|
||||||
lines.append(" }")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# Remote routes (cross-node) — local paths take precedence
|
|
||||||
if remote_registries:
|
|
||||||
for hostname, remote_reg in remote_registries.items():
|
|
||||||
for name, deployed in remote_reg.deployed.items():
|
|
||||||
if not deployed.proxy_path or not deployed.port:
|
|
||||||
continue
|
|
||||||
if deployed.proxy_path in local_paths:
|
|
||||||
continue
|
|
||||||
|
|
||||||
local_paths.add(deployed.proxy_path)
|
|
||||||
lines.append(f" # {name} on {hostname}")
|
|
||||||
lines.append(f" handle_path {deployed.proxy_path}/* {{")
|
|
||||||
lines.append(f" reverse_proxy {hostname}:{deployed.port}")
|
|
||||||
lines.append(" }")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# Static frontends — served IN PLACE from each program's repo build output.
|
|
||||||
# A behavior=frontend program with no service is static; Caddy roots directly
|
|
||||||
# at <source>/<build.outputs[0]> (no central copy). castle-app is the root app
|
|
||||||
# (served at /); other static frontends mount at /<name>.
|
|
||||||
root_serve = _root_static_serve(lines, local_paths)
|
|
||||||
|
|
||||||
if root_serve is not None:
|
|
||||||
lines.append(" handle {")
|
|
||||||
lines.append(f" root * {root_serve}")
|
|
||||||
lines.append(" try_files {path} /index.html")
|
|
||||||
lines.append(" file_server")
|
|
||||||
lines.append(" }")
|
|
||||||
else:
|
else:
|
||||||
fallback = SPECS_DIR / "app"
|
lines += [
|
||||||
lines.append(" handle / {")
|
" handle / {",
|
||||||
lines.append(f" root * {fallback}")
|
f" root * {SPECS_DIR / 'app'}",
|
||||||
lines.append(" file_server")
|
" file_server",
|
||||||
lines.append(" }")
|
" }",
|
||||||
|
]
|
||||||
|
|
||||||
lines.append("}")
|
lines.append("}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _root_static_serve(lines: list[str], local_paths: set[str]) -> Path | None:
|
|
||||||
"""Emit handle_path blocks for non-root static frontends; return the root app's
|
|
||||||
serve dir (castle-app), or None. Static frontends are served from their repo
|
|
||||||
build output in place — no copy into a central content dir."""
|
|
||||||
try:
|
|
||||||
from castle_core.config import load_config
|
|
||||||
|
|
||||||
config = load_config()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
root_serve: Path | None = None
|
|
||||||
for name, prog in sorted(config.programs.items()):
|
|
||||||
if prog.behavior != "frontend" or not prog.source:
|
|
||||||
continue
|
|
||||||
if not (prog.build and prog.build.outputs):
|
|
||||||
continue
|
|
||||||
if name in config.services: # self-serving frontend → handled as a proxy route
|
|
||||||
continue
|
|
||||||
serve_dir = Path(prog.source) / prog.build.outputs[0]
|
|
||||||
if name == "castle-app":
|
|
||||||
root_serve = serve_dir
|
|
||||||
continue
|
|
||||||
path_prefix = f"/{name}"
|
|
||||||
if path_prefix in local_paths:
|
|
||||||
continue
|
|
||||||
local_paths.add(path_prefix)
|
|
||||||
lines.append(f" handle_path {path_prefix}/* {{")
|
|
||||||
lines.append(f" root * {serve_dir}")
|
|
||||||
lines.append(" try_files {path} /index.html")
|
|
||||||
lines.append(" file_server")
|
|
||||||
lines.append(" }")
|
|
||||||
lines.append("")
|
|
||||||
return root_serve
|
|
||||||
|
|||||||
@@ -251,8 +251,12 @@ Coordination handles discovery and communication — both between
|
|||||||
programs on a single node and across multiple Castle nodes.
|
programs on a single node and across multiple Castle nodes.
|
||||||
|
|
||||||
**Intra-node coordination:**
|
**Intra-node coordination:**
|
||||||
- Components find each other through the gateway (path-based routing)
|
- Programs find each other through the gateway or direct port access via env
|
||||||
or direct port access via env vars.
|
vars. A gateway route maps an address (path prefix or host) to a target of one
|
||||||
|
kind: **proxy** (a local service port), **remote** (a service on another
|
||||||
|
node), or **static** (a built frontend's `dist/`, served as files). The same
|
||||||
|
computed route list drives the Caddyfile, `castle gateway status`, and the
|
||||||
|
dashboard, so they always agree.
|
||||||
- The registry (CLI/API) provides discoverability.
|
- The registry (CLI/API) provides discoverability.
|
||||||
- No service mesh or message broker required for basic operation.
|
- No service mesh or message broker required for basic operation.
|
||||||
|
|
||||||
|
|||||||
@@ -221,16 +221,31 @@ expose:
|
|||||||
health_path: /health # Used by health polling
|
health_path: /health # Used by health polling
|
||||||
```
|
```
|
||||||
|
|
||||||
### `proxy` — How to proxy it
|
### `proxy` — How the gateway routes to it
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
proxy:
|
proxy:
|
||||||
caddy:
|
caddy:
|
||||||
path_prefix: /my-service # Proxied at gateway:9000/my-service/
|
path_prefix: /my-service # reachable at gateway:9000/my-service/
|
||||||
|
host: my-service.lan # …or by hostname (whole host → backend root)
|
||||||
```
|
```
|
||||||
|
|
||||||
Castle generates a Caddyfile from these entries. Only needed for services
|
Castle generates the Caddyfile from these entries. Only needed for services
|
||||||
accessible through the gateway.
|
reachable through the gateway.
|
||||||
|
|
||||||
|
**Gateway routes — one concept, three target kinds.** The gateway (`:9000`) maps
|
||||||
|
a public **address** (a path prefix `/foo`, or a host `foo.lan`) to a **target**:
|
||||||
|
|
||||||
|
| Kind | Target | Declared by |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| **proxy** | a local service on a port — Caddy `reverse_proxy localhost:PORT` | a service's `proxy.caddy` |
|
||||||
|
| **remote** | a service on another node — `reverse_proxy host:PORT` | mesh discovery |
|
||||||
|
| **static** | a built frontend's `dist/` — Caddy `file_server` (no process) | a `frontend` program with `build.outputs` and **no** service (implicit; served at `/<name>/`, `castle-app` at `/`) |
|
||||||
|
|
||||||
|
"Serving a frontend" and "proxying a service" are the same thing — a route —
|
||||||
|
differing only in whether the target is files on disk or a live process. The
|
||||||
|
complete table (all kinds) is shown by `castle gateway status`, the dashboard
|
||||||
|
Gateway panel, and `GET /gateway`; the Caddyfile is generated from it.
|
||||||
|
|
||||||
### `manage` — How to manage it
|
### `manage` — How to manage it
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user