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:
105
cli/src/castle_cli/commands/mesh.py
Normal file
105
cli/src/castle_cli/commands/mesh.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""castle mesh — inspect the NATS mesh and manage shared config.
|
||||
|
||||
The mesh lives in the running castle-api (it holds the live peer state), so this
|
||||
command talks to the local API over HTTP rather than reading files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def _api_base() -> str:
|
||||
port = None
|
||||
try:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config()
|
||||
dep = next((d for _k, d in config.deployments_named("castle-api")), None)
|
||||
internal = getattr(getattr(getattr(dep, "expose", None), "http", None), "internal", None)
|
||||
port = getattr(internal, "port", None)
|
||||
except Exception:
|
||||
pass
|
||||
return f"http://localhost:{port or 9020}"
|
||||
|
||||
|
||||
def _get(path: str):
|
||||
with urllib.request.urlopen(_api_base() + path, timeout=5) as r: # noqa: S310
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def _put(path: str, body: dict):
|
||||
req = urllib.request.Request( # noqa: S310
|
||||
_api_base() + path,
|
||||
data=json.dumps(body).encode(),
|
||||
method="PUT",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as r: # noqa: S310
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def run_mesh(args: argparse.Namespace) -> int:
|
||||
sub = getattr(args, "mesh_command", None) or "status"
|
||||
try:
|
||||
if sub == "status":
|
||||
return _status()
|
||||
if sub == "nodes":
|
||||
return _nodes()
|
||||
if sub == "config":
|
||||
return _config(args)
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode(errors="replace")
|
||||
print(f"error: HTTP {e.code} — {detail}")
|
||||
return 1
|
||||
except urllib.error.URLError as e:
|
||||
print(f"castle-api not reachable ({e.reason}). Is it running + mesh enabled?")
|
||||
return 1
|
||||
print(f"unknown mesh command: {sub}")
|
||||
return 2
|
||||
|
||||
|
||||
def _status() -> int:
|
||||
s = _get("/mesh/status")
|
||||
on = "connected" if s.get("connected") else "disconnected"
|
||||
print(f"mesh: {'enabled' if s.get('enabled') else 'disabled'} ({on})")
|
||||
print(f" transport: {s.get('nats_url')}")
|
||||
print(f" peers ({s.get('peer_count', 0)}): {', '.join(s.get('peers', [])) or '—'}")
|
||||
return 0
|
||||
|
||||
|
||||
def _nodes() -> int:
|
||||
nodes = _get("/nodes")
|
||||
print(f"{'NODE':<14}{'STATUS':<10}{'DEPLOYED':<10}LOCAL")
|
||||
for n in nodes:
|
||||
if n.get("online"):
|
||||
status = "online"
|
||||
else:
|
||||
status = "stale" if n.get("is_stale") else "offline"
|
||||
local = "yes" if n.get("is_local") else ""
|
||||
print(f"{n['hostname']:<14}{status:<10}{n.get('deployed_count', 0):<10}{local}")
|
||||
return 0
|
||||
|
||||
|
||||
def _config(args: argparse.Namespace) -> int:
|
||||
cmd = getattr(args, "mesh_config_command", None) or "list"
|
||||
if cmd == "list":
|
||||
data = _get("/mesh/config")
|
||||
print(f"shared config (this node role: {data.get('role')}):")
|
||||
for k in data.get("keys", []):
|
||||
print(f" {k}")
|
||||
if not data.get("keys"):
|
||||
print(" (none)")
|
||||
return 0
|
||||
if cmd == "get":
|
||||
print(_get(f"/mesh/config/{args.key}").get("value", ""))
|
||||
return 0
|
||||
if cmd == "set":
|
||||
_put(f"/mesh/config/{args.key}", {"value": args.value})
|
||||
print(f"set {args.key}")
|
||||
return 0
|
||||
print(f"unknown config command: {cmd}")
|
||||
return 2
|
||||
@@ -168,6 +168,20 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
gw_sub = gw.add_subparsers(dest="gateway_command")
|
||||
gw_sub.add_parser("status", help="Show gateway status + routes (the default)")
|
||||
|
||||
# Mesh — inspect nodes + manage authority-written shared config.
|
||||
mesh = subparsers.add_parser("mesh", help="Inspect the mesh + shared config")
|
||||
mesh_sub = mesh.add_subparsers(dest="mesh_command")
|
||||
mesh_sub.add_parser("status", help="Mesh coordination status (the default)")
|
||||
mesh_sub.add_parser("nodes", help="List mesh nodes (local + remote)")
|
||||
mc = mesh_sub.add_parser("config", help="Shared config (only the authority writes)")
|
||||
mc_sub = mc.add_subparsers(dest="mesh_config_command")
|
||||
mc_sub.add_parser("list", help="List shared-config keys")
|
||||
mc_get = mc_sub.add_parser("get", help="Get a shared-config value")
|
||||
mc_get.add_argument("key")
|
||||
mc_set = mc_sub.add_parser("set", help="Set a shared-config value (authority only)")
|
||||
mc_set.add_argument("key")
|
||||
mc_set.add_argument("value")
|
||||
|
||||
# TLS material for raw-TCP services (cert cut from the gateway wildcard).
|
||||
tls = subparsers.add_parser(
|
||||
"tls", help="Manage castle-materialized TLS certs for raw-TCP services"
|
||||
@@ -320,6 +334,10 @@ def main() -> int:
|
||||
from castle_cli.commands.gateway import run_gateway
|
||||
|
||||
return run_gateway(args)
|
||||
if cmd == "mesh":
|
||||
from castle_cli.commands.mesh import run_mesh
|
||||
|
||||
return run_mesh(args)
|
||||
if cmd == "tls":
|
||||
from castle_cli.commands.tls import run_tls
|
||||
|
||||
|
||||
Reference in New Issue
Block a user