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

@@ -6,23 +6,49 @@ from castle_core.config import GENERATED_DIR, STATIC_DIR
from castle_core.registry import NodeRegistry
def generate_caddyfile_from_registry(registry: NodeRegistry) -> str:
def generate_caddyfile_from_registry(
registry: NodeRegistry,
remote_registries: dict[str, NodeRegistry] | None = None,
) -> str:
"""Generate Caddyfile from the node registry.
Static files served from ~/.castle/static/castle-app/.
No repo-relative paths.
If remote_registries is provided, cross-node routes are added for
remote services whose proxy_path doesn't conflict with local ones.
"""
lines = [f":{registry.node.gateway_port} {{"]
# Track local proxy paths for precedence
local_paths: set[str] = set()
for name, deployed in registry.deployed.items():
if not deployed.proxy_path or not deployed.port:
continue
local_paths.add(deployed.proxy_path)
lines.append(f" handle_path {deployed.proxy_path}/* {{")
lines.append(f" reverse_proxy localhost:{deployed.port}")
lines.append(" }")
lines.append("")
# Remote routes (cross-node) — local paths take precedence
if remote_registries:
for hostname, remote_reg in remote_registries.items():
for name, deployed in remote_reg.deployed.items():
if not deployed.proxy_path or not deployed.port:
continue
if deployed.proxy_path in local_paths:
continue
local_paths.add(deployed.proxy_path)
lines.append(f" # {name} on {hostname}")
lines.append(f" handle_path {deployed.proxy_path}/* {{")
lines.append(f" reverse_proxy {hostname}:{deployed.port}")
lines.append(" }")
lines.append("")
# SPA from static dir
static_app = STATIC_DIR / "castle-app"
if (static_app / "index.html").exists():

View File

@@ -113,3 +113,64 @@ class TestCaddyfileFromRegistry:
assert "reverse_proxy localhost:9001" in caddyfile
assert "handle_path /svc-b/*" in caddyfile
assert "reverse_proxy localhost:9002" in caddyfile
class TestCaddyfileRemoteRegistries:
"""Tests for cross-node routing in Caddyfile."""
def test_remote_routes_included(self) -> None:
"""Remote services get reverse_proxy entries to their hostname."""
local = _make_registry(
deployed={
"local-svc": DeployedComponent(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/local"
),
}
)
remote = _make_registry(
deployed={
"remote-svc": DeployedComponent(
runner="python", run_cmd=["svc"], port=9050, proxy_path="/remote"
),
}
)
remote.node.hostname = "devbox"
caddyfile = generate_caddyfile_from_registry(local, remote_registries={"devbox": remote})
assert "reverse_proxy localhost:9001" in caddyfile
assert "reverse_proxy devbox:9050" in caddyfile
assert "handle_path /remote/*" in caddyfile
def test_local_takes_precedence(self) -> None:
"""If local and remote use the same path, local wins."""
local = _make_registry(
deployed={
"svc": DeployedComponent(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
),
}
)
remote = _make_registry(
deployed={
"svc": DeployedComponent(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
),
}
)
remote.node.hostname = "devbox"
caddyfile = generate_caddyfile_from_registry(local, remote_registries={"devbox": remote})
assert "reverse_proxy localhost:9001" in caddyfile
assert "devbox" not in caddyfile
def test_no_remote_when_none(self) -> None:
"""No remote routes when remote_registries is None."""
local = _make_registry(
deployed={
"svc": DeployedComponent(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
),
}
)
caddyfile = generate_caddyfile_from_registry(local, remote_registries=None)
assert "reverse_proxy localhost:9001" in caddyfile
# Only one reverse_proxy line
assert caddyfile.count("reverse_proxy") == 1