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

@@ -80,7 +80,27 @@ class TestGateway:
assert response.status_code == 200
data = response.json()
assert data["port"] == 9000
assert data["hostname"] == "test-node"
# Registry has 1 deployed component (test-svc)
assert data["component_count"] == 1
assert data["service_count"] == 1
assert data["managed_count"] == 1
def test_gateway_routes(self, client: TestClient) -> None:
"""Returns proxy routes from deployed components."""
response = client.get("/gateway")
data = response.json()
routes = data["routes"]
assert len(routes) == 1
route = routes[0]
assert route["path"] == "/test-svc"
assert route["target_port"] == 19000
assert route["component"] == "test-svc"
assert route["node"] == "test-node"
def test_gateway_routes_sorted(self, client: TestClient) -> None:
"""Routes are sorted by path."""
response = client.get("/gateway")
data = response.json()
paths = [r["path"] for r in data["routes"]]
assert paths == sorted(paths)

View File

@@ -0,0 +1,104 @@
"""Tests for MeshStateManager."""
import time
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
from castle_api.mesh import STALE_TTL_SECONDS, MeshStateManager, RemoteNode
def _make_registry(hostname: str, deployed: dict | None = None) -> NodeRegistry:
return NodeRegistry(
node=NodeConfig(hostname=hostname, gateway_port=9000),
deployed=deployed or {},
)
class TestRemoteNode:
"""RemoteNode staleness tracking."""
def test_fresh_node_not_stale(self) -> None:
node = RemoteNode(registry=_make_registry("a"))
assert not node.is_stale
def test_old_node_is_stale(self) -> None:
node = RemoteNode(
registry=_make_registry("a"),
last_seen=time.time() - STALE_TTL_SECONDS - 1,
)
assert node.is_stale
class TestMeshStateManager:
"""MeshStateManager add/remove/stale operations."""
def test_update_and_get(self) -> None:
mgr = MeshStateManager()
reg = _make_registry("devbox")
mgr.update_node("devbox", reg)
node = mgr.get_node("devbox")
assert node is not None
assert node.registry.node.hostname == "devbox"
assert node.online is True
def test_set_offline(self) -> None:
mgr = MeshStateManager()
mgr.update_node("devbox", _make_registry("devbox"))
mgr.set_offline("devbox")
node = mgr.get_node("devbox")
assert node is not None
assert node.online is False
def test_remove_node(self) -> None:
mgr = MeshStateManager()
mgr.update_node("devbox", _make_registry("devbox"))
mgr.remove_node("devbox")
assert mgr.get_node("devbox") is None
def test_remove_nonexistent_is_safe(self) -> None:
mgr = MeshStateManager()
mgr.remove_node("nope") # should not raise
def test_all_nodes_excludes_stale(self) -> None:
mgr = MeshStateManager()
mgr.update_node("fresh", _make_registry("fresh"))
mgr._nodes["stale"] = RemoteNode(
registry=_make_registry("stale"),
last_seen=time.time() - STALE_TTL_SECONDS - 1,
)
result = mgr.all_nodes()
assert "fresh" in result
assert "stale" not in result
def test_all_nodes_includes_stale_when_requested(self) -> None:
mgr = MeshStateManager()
mgr._nodes["stale"] = RemoteNode(
registry=_make_registry("stale"),
last_seen=time.time() - STALE_TTL_SECONDS - 1,
)
result = mgr.all_nodes(include_stale=True)
assert "stale" in result
def test_prune_stale(self) -> None:
mgr = MeshStateManager()
mgr.update_node("fresh", _make_registry("fresh"))
mgr._nodes["stale"] = RemoteNode(
registry=_make_registry("stale"),
last_seen=time.time() - STALE_TTL_SECONDS - 1,
)
pruned = mgr.prune_stale()
assert pruned == ["stale"]
assert mgr.get_node("stale") is None
assert mgr.get_node("fresh") is not None
def test_update_replaces_existing(self) -> None:
mgr = MeshStateManager()
mgr.update_node("devbox", _make_registry("devbox"))
new_reg = _make_registry(
"devbox",
{"svc": DeployedComponent(runner="python", run_cmd=["svc"])},
)
mgr.update_node("devbox", new_reg)
node = mgr.get_node("devbox")
assert node is not None
assert "svc" in node.registry.deployed

View File

@@ -0,0 +1,96 @@
"""Tests for MQTT client serialization logic."""
import json
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
from castle_api.mqtt_client import _json_to_registry, _registry_to_json
def _make_registry() -> NodeRegistry:
return NodeRegistry(
node=NodeConfig(hostname="tower", castle_root="/data/repos/castle", gateway_port=9000),
deployed={
"my-svc": DeployedComponent(
runner="python",
run_cmd=["uv", "run", "my-svc"],
env={"PORT": "9001", "SECRET_KEY": "super-secret"},
description="My service",
category="service",
port=9001,
health_path="/health",
proxy_path="/my-svc",
managed=True,
),
"my-job": DeployedComponent(
runner="command",
run_cmd=["my-job"],
category="job",
schedule="0 2 * * *",
),
},
)
class TestRegistrySerialization:
"""Round-trip serialization of NodeRegistry to/from JSON."""
def test_round_trip(self) -> None:
original = _make_registry()
json_str = _registry_to_json(original)
restored = _json_to_registry(json_str)
assert restored.node.hostname == "tower"
assert restored.node.gateway_port == 9000
def test_deployed_components_preserved(self) -> None:
original = _make_registry()
restored = _json_to_registry(_registry_to_json(original))
assert "my-svc" in restored.deployed
svc = restored.deployed["my-svc"]
assert svc.runner == "python"
assert svc.port == 9001
assert svc.health_path == "/health"
assert svc.proxy_path == "/my-svc"
assert svc.managed is True
def test_job_fields_preserved(self) -> None:
original = _make_registry()
restored = _json_to_registry(_registry_to_json(original))
assert "my-job" in restored.deployed
job = restored.deployed["my-job"]
assert job.runner == "command"
assert job.schedule == "0 2 * * *"
assert job.category == "job"
def test_optional_fields_omitted(self) -> None:
"""Fields like port, health_path are None when not set."""
reg = NodeRegistry(
node=NodeConfig(hostname="minimal"),
deployed={
"bare": DeployedComponent(runner="command", run_cmd=["bare"]),
},
)
restored = _json_to_registry(_registry_to_json(reg))
bare = restored.deployed["bare"]
assert bare.port is None
assert bare.health_path is None
assert bare.proxy_path is None
assert bare.schedule is None
assert bare.managed is False
def test_no_secrets_in_payload(self) -> None:
"""env vars, run_cmd, and castle_root must not appear in MQTT payload."""
original = _make_registry()
json_str = _registry_to_json(original)
data = json.loads(json_str)
# No castle_root in node
assert "castle_root" not in data["node"]
# No env or run_cmd in any component
for name, comp in data["deployed"].items():
assert "env" not in comp, f"{name} has env in MQTT payload"
assert "run_cmd" not in comp, f"{name} has run_cmd in MQTT payload"

View File

@@ -0,0 +1,91 @@
"""Tests for nodes endpoints."""
from pathlib import Path
from fastapi.testclient import TestClient
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
from castle_api.mesh import MeshStateManager
class TestNodesList:
"""GET /nodes endpoint tests."""
def test_returns_local_node(self, client: TestClient) -> None:
"""Always returns the local node."""
response = client.get("/nodes")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
local = data[0]
assert local["hostname"] == "test-node"
assert local["is_local"] is True
assert local["online"] is True
def test_local_node_counts(self, client: TestClient) -> None:
"""Local node has correct deployment counts."""
response = client.get("/nodes")
data = response.json()
local = data[0]
assert local["deployed_count"] == 1 # test-svc
assert local["service_count"] == 1
def test_includes_remote_nodes(self, client: TestClient, registry_path: Path) -> None:
"""Remote nodes from mesh state are included."""
import castle_api.mesh as mesh_mod
original = mesh_mod.mesh_state
try:
mgr = MeshStateManager()
remote_reg = NodeRegistry(
node=NodeConfig(hostname="devbox", gateway_port=9000),
deployed={
"remote-svc": DeployedComponent(
runner="python",
run_cmd=["svc"],
port=9050,
category="service",
),
},
)
mgr.update_node("devbox", remote_reg)
mesh_mod.mesh_state = mgr
# Also patch the reference in the nodes module
import castle_api.nodes as nodes_mod
nodes_mod.mesh_state = mgr
response = client.get("/nodes")
data = response.json()
hostnames = [n["hostname"] for n in data]
assert "devbox" in hostnames
devbox = next(n for n in data if n["hostname"] == "devbox")
assert devbox["is_local"] is False
assert devbox["deployed_count"] == 1
finally:
mesh_mod.mesh_state = original
import castle_api.nodes as nodes_mod2
nodes_mod2.mesh_state = original
class TestNodeDetail:
"""GET /nodes/{hostname} endpoint tests."""
def test_local_node_detail(self, client: TestClient) -> None:
"""Returns local node detail with deployed components."""
response = client.get("/nodes/test-node")
assert response.status_code == 200
data = response.json()
assert data["hostname"] == "test-node"
assert data["is_local"] is True
assert len(data["deployed"]) == 1
assert data["deployed"][0]["id"] == "test-svc"
assert data["deployed"][0]["node"] == "test-node"
def test_unknown_node_returns_404(self, client: TestClient) -> None:
"""Returns 404 for unknown hostname."""
response = client.get("/nodes/nonexistent")
assert response.status_code == 404