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:
@@ -239,6 +239,15 @@ class CastleNATSClient:
|
||||
raise RuntimeError("config bucket not available")
|
||||
await self._config_kv.put(key, value.encode())
|
||||
|
||||
async def list_shared_config(self) -> list[str]:
|
||||
"""All shared-config keys (empty if none/unavailable)."""
|
||||
if self._config_kv is None:
|
||||
return []
|
||||
try:
|
||||
return sorted(await self._config_kv.keys())
|
||||
except Exception:
|
||||
return [] # empty bucket raises NoKeysError in nats-py
|
||||
|
||||
async def _config_watch_loop(self) -> None:
|
||||
"""Watch shared config; announce changes so followers can reconcile.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from castle_api.config import get_registry, settings
|
||||
from castle_api.mesh import mesh_state
|
||||
@@ -11,6 +12,42 @@ from castle_api.models import DeploymentSummary, MeshStatus, NodeDetail, NodeSum
|
||||
router = APIRouter(tags=["nodes"])
|
||||
|
||||
|
||||
class ConfigValue(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
@router.get("/mesh/config")
|
||||
async def list_mesh_config(request: Request) -> dict:
|
||||
"""List shared-config keys + this node's role (only the authority may write)."""
|
||||
client = getattr(request.app.state, "nats_client", None)
|
||||
if client is None:
|
||||
return {"keys": [], "role": get_registry().node.role}
|
||||
return {"keys": await client.list_shared_config(), "role": client.role}
|
||||
|
||||
|
||||
@router.get("/mesh/config/{key:path}")
|
||||
async def get_mesh_config(key: str, request: Request) -> dict:
|
||||
"""Read a shared-config value."""
|
||||
client = getattr(request.app.state, "nats_client", None)
|
||||
value = await client.get_shared_config(key) if client else None
|
||||
if value is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, f"config key '{key}' not set")
|
||||
return {"key": key, "value": value}
|
||||
|
||||
|
||||
@router.put("/mesh/config/{key:path}")
|
||||
async def set_mesh_config(key: str, body: ConfigValue, request: Request) -> dict:
|
||||
"""Write a shared-config value (authority only)."""
|
||||
client = getattr(request.app.state, "nats_client", None)
|
||||
if client is None:
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "mesh not enabled")
|
||||
try:
|
||||
await client.put_shared_config(key, body.value)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(exc)) from exc
|
||||
return {"key": key, "ok": True}
|
||||
|
||||
|
||||
def _local_node_summary(registry: object) -> NodeSummary:
|
||||
"""Build a NodeSummary for the local node from the registry."""
|
||||
return NodeSummary(
|
||||
|
||||
Reference in New Issue
Block a user