feat: Implement multi-node support with MQTT and mDNS for service discovery and coordination
This commit is contained in:
@@ -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)
|
||||
|
||||
104
castle-api/tests/test_mesh.py
Normal file
104
castle-api/tests/test_mesh.py
Normal 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
|
||||
96
castle-api/tests/test_mqtt.py
Normal file
96
castle-api/tests/test_mqtt.py
Normal 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"
|
||||
91
castle-api/tests/test_nodes.py
Normal file
91
castle-api/tests/test_nodes.py
Normal 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
|
||||
Reference in New Issue
Block a user