feat(mesh): fleet role + shared config/presence KV (Phase 2)
- add static 'role' (authority|follower) to NodeConfig/CastleConfig, wired from castle.yaml through the registry to the mesh wire; civil pinned authority - castle-presence: TTL KV bucket each node renews (churn signal); delete-on-stop - castle-config: authority-gated get/put + change-watch SSE (follower reconcile hook hangs off it) - bound the NATS drain on stop() so shutdown can't hang - tests: role config/registry round-trip, wire round-trip, role write-gating Single-node verified; follower reconcile pending the second node.
This commit is contained in:
@@ -184,6 +184,9 @@ class CastleConfig:
|
||||
# built-in defaults so tests/callers that don't care stay valid.
|
||||
data_dir: Path = field(default_factory=lambda: _DEFAULT_DATA_DIR)
|
||||
repos_dir: Path = field(default_factory=lambda: _DEFAULT_REPOS_DIR)
|
||||
# Fleet role: "authority" (may write shared config/secrets to the mesh) or
|
||||
# "follower" (reconciles from it). Static — pinned here, no election.
|
||||
role: str = "follower"
|
||||
# Construction convenience only (not stored): a flat name→spec dict is routed
|
||||
# into the per-kind stores by kind_for. Lets callers/tests hand us a flat map
|
||||
# without pre-splitting it; there is still no flat `deployments` attribute.
|
||||
@@ -465,6 +468,7 @@ def load_config(root: Path | None = None) -> CastleConfig:
|
||||
agents=agents,
|
||||
data_dir=data_dir,
|
||||
repos_dir=repos_dir,
|
||||
role=data.get("role", "follower"),
|
||||
**stores,
|
||||
)
|
||||
return config
|
||||
|
||||
@@ -189,6 +189,7 @@ def _node_config(config: CastleConfig) -> NodeConfig:
|
||||
public_domain=config.gateway.public_domain,
|
||||
tunnel_id=config.gateway.tunnel_id,
|
||||
cert_hook=config.gateway.cert_hook,
|
||||
role=config.role,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ class NodeConfig:
|
||||
tunnel_id: str | None = None
|
||||
# Emit the cert_obtained → `castle tls reconcile` hook (needs events-exec plugin).
|
||||
cert_hook: bool = False
|
||||
# Fleet role: "authority" may write shared config/secrets to the mesh;
|
||||
# "follower" reconciles from it. Static (no election) — the authority is
|
||||
# pinned in castle.yaml. When the authority is down, shared state is
|
||||
# read-only and every node keeps serving its own deployments from cache.
|
||||
role: str = "follower"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.hostname:
|
||||
@@ -152,6 +157,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
||||
public_domain=node_data.get("public_domain"),
|
||||
tunnel_id=node_data.get("tunnel_id"),
|
||||
cert_hook=node_data.get("cert_hook", False),
|
||||
role=node_data.get("role", "follower"),
|
||||
)
|
||||
|
||||
deployed: dict[str, Deployment] = {}
|
||||
@@ -241,6 +247,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
||||
data["node"]["tunnel_id"] = registry.node.tunnel_id
|
||||
if registry.node.cert_hook:
|
||||
data["node"]["cert_hook"] = registry.node.cert_hook
|
||||
if registry.node.role and registry.node.role != "follower":
|
||||
data["node"]["role"] = registry.node.role
|
||||
|
||||
for key, comp in registry.deployed.items():
|
||||
entry: dict = {
|
||||
|
||||
48
core/tests/test_fleet_role.py
Normal file
48
core/tests/test_fleet_role.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Fleet role (authority/follower) — config loading + registry round-trip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from castle_core.config import load_config
|
||||
from castle_core.registry import (
|
||||
NodeConfig,
|
||||
NodeRegistry,
|
||||
load_registry,
|
||||
save_registry,
|
||||
)
|
||||
|
||||
|
||||
def _write_min_config(root: Path, role: str | None) -> None:
|
||||
data: dict = {"gateway": {"port": 18000}}
|
||||
if role is not None:
|
||||
data["role"] = role
|
||||
(root / "castle.yaml").write_text(yaml.safe_dump(data))
|
||||
|
||||
|
||||
def test_role_defaults_to_follower(tmp_path: Path) -> None:
|
||||
_write_min_config(tmp_path, None)
|
||||
assert load_config(tmp_path).role == "follower"
|
||||
|
||||
|
||||
def test_role_loaded_from_yaml(tmp_path: Path) -> None:
|
||||
_write_min_config(tmp_path, "authority")
|
||||
assert load_config(tmp_path).role == "authority"
|
||||
|
||||
|
||||
def test_registry_role_round_trip(tmp_path: Path) -> None:
|
||||
reg = NodeRegistry(
|
||||
node=NodeConfig(hostname="civil", role="authority"), deployed={}
|
||||
)
|
||||
path = tmp_path / "registry.yaml"
|
||||
save_registry(reg, path)
|
||||
assert load_registry(path).node.role == "authority"
|
||||
|
||||
|
||||
def test_registry_role_defaults_follower(tmp_path: Path) -> None:
|
||||
"""A node with no explicit role round-trips as follower."""
|
||||
reg = NodeRegistry(node=NodeConfig(hostname="n"), deployed={})
|
||||
path = tmp_path / "registry.yaml"
|
||||
save_registry(reg, path)
|
||||
assert load_registry(path).node.role == "follower"
|
||||
Reference in New Issue
Block a user