feat: Implement multi-node support with MQTT and mDNS for service discovery and coordination

This commit is contained in:
2026-02-23 02:30:12 -08:00
parent eeaa5045d0
commit 3343e955fd
29 changed files with 1878 additions and 35 deletions

View File

@@ -2,20 +2,24 @@
from __future__ import annotations
import asyncio
import shutil
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from castle_core.config import GENERATED_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_api.config import get_castle_root, get_registry
from castle_api.mesh import mesh_state
from castle_api.health import check_all_health
from castle_api.models import (
ComponentDetail,
ComponentSummary,
GatewayInfo,
GatewayRoute,
StatusResponse,
SystemdInfo,
)
@@ -173,15 +177,21 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
@router.get("/components", response_model=list[ComponentSummary])
def list_components() -> list[ComponentSummary]:
"""List all components — deployed from registry, non-deployed from castle.yaml."""
def list_components(include_remote: bool = False) -> list[ComponentSummary]:
"""List all components — deployed from registry, non-deployed from castle.yaml.
Pass ?include_remote=true to include components from remote mesh nodes.
"""
registry = get_registry()
local_hostname = registry.node.hostname
summaries: list[ComponentSummary] = []
seen: set[str] = set()
# Deployed components from registry
for name, deployed in registry.deployed.items():
summaries.append(_summary_from_deployed(name, deployed))
s = _summary_from_deployed(name, deployed)
s.node = local_hostname
summaries.append(s)
seen.add(name)
# Non-deployed from castle.yaml (if repo available)
@@ -195,13 +205,17 @@ def list_components() -> list[ComponentSummary]:
# Services not in registry
for name, svc in config.services.items():
if name not in seen:
summaries.append(_summary_from_service(name, svc, config))
s = _summary_from_service(name, svc, config)
s.node = local_hostname
summaries.append(s)
seen.add(name)
# Jobs not in registry
for name, job in config.jobs.items():
if name not in seen:
summaries.append(_summary_from_job(name, job, config))
s = _summary_from_job(name, job, config)
s.node = local_hostname
summaries.append(s)
seen.add(name)
# Backfill source from component refs for deployed items
@@ -223,6 +237,7 @@ def list_components() -> list[ComponentSummary]:
# "what software exists", services/jobs are "how it runs".
for name, comp in config.components.items():
summary = _summary_from_component(name, comp, root)
summary.node = local_hostname
# Skip if this exact category is already represented
# (e.g. a deployed tool already in the list)
if not any(s.id == name and s.category == summary.category for s in summaries):
@@ -230,6 +245,27 @@ def list_components() -> list[ComponentSummary]:
except FileNotFoundError:
pass
# Remote components from mesh (local wins on name conflicts)
if include_remote:
for hostname, remote in mesh_state.all_nodes().items():
for name, d in remote.registry.deployed.items():
if name not in seen:
summaries.append(
ComponentSummary(
id=name,
description=d.description,
category=d.category,
runner=d.runner,
port=d.port,
health_path=d.health_path,
proxy_path=d.proxy_path,
managed=d.managed,
schedule=d.schedule,
node=hostname,
)
)
seen.add(name)
return summaries
@@ -320,11 +356,42 @@ def get_gateway() -> GatewayInfo:
deployed_count = len(registry.deployed)
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)
# Local routes
routes: list[GatewayRoute] = [
GatewayRoute(
path=d.proxy_path,
target_port=d.port,
component=name,
node=registry.node.hostname,
)
for name, d in registry.deployed.items()
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,
component=name,
node=hostname,
)
)
routes.sort(key=lambda r: r.path)
return GatewayInfo(
port=registry.node.gateway_port,
hostname=registry.node.hostname,
component_count=deployed_count,
service_count=service_count,
managed_count=managed_count,
routes=routes,
)
@@ -333,3 +400,32 @@ def get_caddyfile() -> dict[str, str]:
"""Return the generated Caddyfile content."""
registry = get_registry()
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()
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
caddyfile_path = GENERATED_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"}