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.
164 lines
5.9 KiB
Python
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)
|