Files
wild-pc/castle-api/src/castle_api/nodes.py
Paul Payne b28645f2f4 mesh: carry gateway_domain so peers can launch remote apps
The mesh payload didn't include a node's acme domain, so a peer had no way
to build launch URLs for another machine's exposed apps. Publish
gateway_domain in the MQTT registry payload and surface it per-deployment on
/mesh/deployments as `domain`. The dashboard palette and System Map now build
`https://<subdomain>.<domain>` for remote http-exposed apps (references use
their base_url), making primer's apps launchable from civil.
2026-07-06 21:07:10 -07:00

164 lines
5.9 KiB
Python

"""Nodes router — discover and inspect mesh nodes."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, status
from castle_api.config import get_registry, settings
from castle_api.mesh import mesh_state
from castle_api.models import DeploymentSummary, MeshStatus, NodeDetail, NodeSummary
router = APIRouter(tags=["nodes"])
def _local_node_summary(registry: object) -> NodeSummary:
"""Build a NodeSummary for the local node from the registry."""
return NodeSummary(
hostname=registry.node.hostname,
gateway_port=registry.node.gateway_port,
deployed_count=len(registry.deployed),
service_count=sum(1 for d in registry.deployed.values() if d.port is not None),
is_local=True,
online=True,
is_stale=False,
)
def _remote_node_summary(hostname: str, remote: object) -> NodeSummary:
"""Build a NodeSummary from a RemoteNode."""
reg = remote.registry
return NodeSummary(
hostname=hostname,
gateway_port=reg.node.gateway_port,
deployed_count=len(reg.deployed),
service_count=sum(1 for d in reg.deployed.values() if d.port is not None),
is_local=False,
online=remote.online,
is_stale=remote.is_stale,
last_seen=remote.last_seen,
)
def _deployed_to_summaries(registry: object, hostname: str) -> list[DeploymentSummary]:
"""Convert deployed components from a registry into DeploymentSummary list."""
summaries = []
for _kind, name, d in registry.all():
summaries.append(
DeploymentSummary(
id=name,
category="job" if d.schedule else "service",
description=d.description,
kind=d.kind,
stack=d.stack,
manager=d.manager,
launcher=d.launcher,
port=d.port,
health_path=d.health_path,
subdomain=d.subdomain,
managed=d.managed,
schedule=d.schedule,
node=hostname,
)
)
return summaries
@router.get("/mesh/status", response_model=MeshStatus)
def get_mesh_status(request: Request) -> MeshStatus:
"""Get the current state of the mesh coordination layer."""
mqtt_client = getattr(request.app.state, "mqtt_client", None)
peers = list(mesh_state.all_nodes(include_stale=True).keys())
return MeshStatus(
enabled=settings.mqtt_enabled,
mqtt_connected=mqtt_client.connected if mqtt_client else False,
mqtt_broker_host=mqtt_client.broker_host if mqtt_client else None,
mqtt_broker_port=mqtt_client.broker_port if mqtt_client else None,
mdns_enabled=settings.mdns_enabled,
peer_count=len(peers),
peers=peers,
)
@router.get("/nodes", response_model=list[NodeSummary])
def list_nodes() -> list[NodeSummary]:
"""List all known nodes (local + discovered remote)."""
registry = get_registry()
nodes = [_local_node_summary(registry)]
for hostname, remote in mesh_state.all_nodes(include_stale=True).items():
nodes.append(_remote_node_summary(hostname, remote))
return nodes
_TCP_PROTOCOL = {5432: "pg", 7687: "bolt", 1883: "mqtt", 6379: "redis"}
def _endpoints_of_registry(d: object) -> list[dict]:
"""Derive display endpoints from a registry deployment. Mirrors the local
relations derivation, which gates the http endpoint on being exposed — here the
registry's `subdomain` is that signal (a reach:off service has none). Without
this, remote reach:off services show a phantom port (e.g. castle-gateway :9000)."""
eps: list[dict] = []
port = getattr(d, "port", None)
if port is not None and getattr(d, "subdomain", None):
eps.append({"protocol": "http", "port": port})
tcp = getattr(d, "tcp_port", None)
if tcp is not None:
eps.append({"protocol": _TCP_PROTOCOL.get(tcp, "tcp"), "port": tcp})
return eps
@router.get("/mesh/deployments")
def mesh_deployments() -> dict:
"""Flattened remote (mesh-discovered) deployments with derived endpoints — the
data the System Map needs to render other machines. Local node excluded (it's
already in /graph). Each entry carries its node's `domain` (gateway acme domain)
so peers can build launch URLs `<subdomain>.<domain>` for exposed apps."""
out: list[dict] = []
for hostname, remote in mesh_state.all_nodes(include_stale=True).items():
domain = getattr(remote.registry.node, "gateway_domain", None)
for _kind, name, d in remote.registry.all():
out.append(
{
"name": name,
"kind": d.kind,
"node": hostname,
"domain": domain,
"port": d.port,
"base_url": getattr(d, "base_url", None),
"subdomain": d.subdomain,
"endpoints": _endpoints_of_registry(d),
"requires": [
r.get("ref") for r in (getattr(d, "requires", None) or []) if r.get("ref")
],
}
)
return {"deployments": out}
@router.get("/nodes/{hostname}", response_model=NodeDetail)
def get_node(hostname: str) -> NodeDetail:
"""Get detailed info for a specific node."""
registry = get_registry()
# Local node
if hostname == registry.node.hostname:
summary = _local_node_summary(registry)
deployed = _deployed_to_summaries(registry, hostname)
return NodeDetail(**summary.model_dump(), deployed=deployed)
# Remote node
remote = mesh_state.get_node(hostname)
if remote is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Node '{hostname}' not found",
)
summary = _remote_node_summary(hostname, remote)
deployed = _deployed_to_summaries(remote.registry, hostname)
return NodeDetail(**summary.model_dump(), deployed=deployed)