feat(cli): castle mesh command + shared-config API + NATS integration tests
- castle mesh status|nodes|config (list/get/set) — hits the local castle-api;
surfaces the shared-config write path (authority-gated)
- API: GET/PUT /mesh/config/{key:path}, GET /mesh/config; nats_client gains
list_shared_config
- tests: NATS integration suite against a real broker via a docker fixture
(peer discovery, presence, graceful-offline, shared config, secrets-off-wire);
plus HTTP-layer /mesh/config tests. Closes the runtime-coverage gap.
- AGENTS.md §8 updated for NATS + the mesh CLI
100 api + 211 core tests pass.
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
"""Test fixtures for castle-api."""
|
||||
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,6 +21,48 @@ from castle_core.registry import (
|
||||
)
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
s = socket.socket()
|
||||
s.bind(("", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
return port
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
try:
|
||||
return subprocess.run(
|
||||
["docker", "info"], capture_output=True, timeout=5
|
||||
).returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nats_url() -> Generator[str, None, None]:
|
||||
"""A throwaway NATS+JetStream broker in docker (fresh per test for clean
|
||||
buckets). Skips if docker is unavailable."""
|
||||
if not _docker_available():
|
||||
pytest.skip("docker unavailable — skipping NATS integration tests")
|
||||
cport, mport = _free_port(), _free_port()
|
||||
name = f"castle-test-nats-{cport}"
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--rm", "--name", name,
|
||||
"-p", f"{cport}:4222", "-p", f"{mport}:8222", "nats:2", "-js", "-m", "8222"],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
try:
|
||||
for _ in range(50): # wait for readiness
|
||||
try:
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{mport}/healthz", timeout=1)
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.2)
|
||||
yield f"nats://127.0.0.1:{cport}"
|
||||
finally:
|
||||
subprocess.run(["docker", "rm", "-f", name], capture_output=True)
|
||||
|
||||
|
||||
def _write_castle_config(root: Path, config: dict) -> None:
|
||||
"""Scatter a nested castle config dict into the directory-per-resource layout."""
|
||||
globals_data = {k: v for k, v in config.items() if k in ("gateway", "repo")}
|
||||
|
||||
26
castle-api/tests/test_mesh_config_api.py
Normal file
26
castle-api/tests/test_mesh_config_api.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""HTTP-layer tests for the /mesh/config endpoints (mesh-disabled paths).
|
||||
|
||||
The write/read-through-the-client behavior is covered by test_nats_integration;
|
||||
here we pin the endpoint wiring when no mesh client is attached.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_list_config_reports_role_when_mesh_disabled(client: TestClient) -> None:
|
||||
r = client.get("/mesh/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["keys"] == []
|
||||
assert body["role"] == "follower" # test-node has no explicit role
|
||||
|
||||
|
||||
def test_get_missing_config_is_404(client: TestClient) -> None:
|
||||
assert client.get("/mesh/config/does/not/exist").status_code == 404
|
||||
|
||||
|
||||
def test_write_without_mesh_is_503(client: TestClient) -> None:
|
||||
r = client.put("/mesh/config/fleet/motd", json={"value": "x"})
|
||||
assert r.status_code == 503
|
||||
117
castle-api/tests/test_nats_integration.py
Normal file
117
castle-api/tests/test_nats_integration.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Integration tests for CastleNATSClient against a real NATS/JetStream broker.
|
||||
|
||||
Exercises the runtime the unit tests can't: connect, KV publish, peer discovery
|
||||
via watch, presence, graceful-offline, and shared-config. Requires docker (the
|
||||
`nats_url` fixture); skipped otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
|
||||
|
||||
import castle_api.nats_client as ncmod
|
||||
from castle_api.mesh import mesh_state
|
||||
from castle_api.nats_client import CastleNATSClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate(monkeypatch):
|
||||
"""Stub the gateway-regen side effect (so tests never touch the host's real
|
||||
Caddyfile) and clear the shared mesh_state singleton around each test."""
|
||||
|
||||
async def _stub(*_a, **_k):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(ncmod, "refresh_remote_routes", _stub)
|
||||
mesh_state._nodes.clear()
|
||||
yield
|
||||
mesh_state._nodes.clear()
|
||||
|
||||
|
||||
def _reg(host: str, deployed=None, role: str = "follower") -> NodeRegistry:
|
||||
return NodeRegistry(
|
||||
node=NodeConfig(hostname=host, role=role), deployed=deployed or {}
|
||||
)
|
||||
|
||||
|
||||
def _widget_reg(host: str) -> NodeRegistry:
|
||||
w = Deployment(
|
||||
manager="systemd", launcher="python", run_cmd=[], name="widget",
|
||||
kind="service", port=9099, subdomain="widget",
|
||||
)
|
||||
return _reg(host, {NodeRegistry.key("service", "widget"): w})
|
||||
|
||||
|
||||
def test_publish_peer_discovery_and_offline(nats_url: str) -> None:
|
||||
async def run() -> None:
|
||||
a = CastleNATSClient("node-a", _reg("node-a"), servers=nats_url)
|
||||
b = CastleNATSClient("node-b", _widget_reg("node-b"), servers=nats_url)
|
||||
await a.start()
|
||||
await b.start()
|
||||
await asyncio.sleep(1.0) # let watches propagate
|
||||
|
||||
nodes = mesh_state.all_nodes(include_stale=True)
|
||||
assert "node-b" in nodes, "node-a should discover node-b via the KV watch"
|
||||
assert nodes["node-b"].registry.get("service", "widget") is not None
|
||||
|
||||
# Graceful stop deletes b's key -> the DELETE watch marks it offline.
|
||||
await b.stop()
|
||||
await asyncio.sleep(1.0)
|
||||
nb = mesh_state.get_node("node-b")
|
||||
assert nb is None or not nb.online
|
||||
|
||||
await a.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_presence_key_written(nats_url: str) -> None:
|
||||
async def run() -> None:
|
||||
c = CastleNATSClient("solo", _reg("solo"), servers=nats_url)
|
||||
await c.start()
|
||||
keys = await c._presence_kv.keys()
|
||||
assert "solo" in keys
|
||||
await c.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_shared_config_authority_write_read(nats_url: str) -> None:
|
||||
async def run() -> None:
|
||||
auth = CastleNATSClient("auth", _reg("auth", role="authority"), servers=nats_url)
|
||||
await auth.start()
|
||||
await auth.put_shared_config("fleet/motd", "hello")
|
||||
assert await auth.get_shared_config("fleet/motd") == "hello"
|
||||
assert "fleet/motd" in await auth.list_shared_config()
|
||||
await auth.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_secrets_never_on_the_wire(nats_url: str) -> None:
|
||||
"""The registry a peer receives must carry no env/run_cmd."""
|
||||
async def run() -> None:
|
||||
secretful = Deployment(
|
||||
manager="systemd", launcher="python",
|
||||
run_cmd=["uv", "run", "svc"], env={"API_KEY": "s3cr3t"},
|
||||
name="svc", kind="service", port=9001, subdomain="svc",
|
||||
)
|
||||
a = CastleNATSClient("wa", _reg("wa"), servers=nats_url)
|
||||
b = CastleNATSClient(
|
||||
"wb", _reg("wb", {NodeRegistry.key("service", "svc"): secretful}),
|
||||
servers=nats_url,
|
||||
)
|
||||
await a.start()
|
||||
await b.start()
|
||||
await asyncio.sleep(1.0)
|
||||
svc = mesh_state.get_node("wb").registry.get("service", "svc")
|
||||
assert svc is not None
|
||||
assert not svc.env, "env must not cross the wire"
|
||||
assert not svc.run_cmd, "run_cmd must not cross the wire"
|
||||
await a.stop()
|
||||
await b.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
Reference in New Issue
Block a user