mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
Add runtime peer management to the FIPS daemon via control socket commands, and a new chaos simulation scenario that exercises dynamic topology mutation with ephemeral node identities. Daemon (connect/disconnect commands): - Extend control socket Request with optional params field - Add commands.rs module for mutating command dispatch, separate from read-only queries - Add api_connect() on Node: builds ephemeral PeerConfig (no auto- reconnect), pre-seeds identity cache, reuses initiate_peer_connection - Add api_disconnect() on Node: calls remove_active_peer(), clears retry_pending to suppress reconnection - Route non-show_* commands to async command dispatch in rx_loop fipsctl CLI: - Add Connect and Disconnect subcommands accepting npub or hostname - Resolve hostnames from /etc/fips/hosts before sending to daemon - Refactor socket I/O into reusable send_request helper Chaos simulator (maelstrom scenario): - Add PeerChurnManager: periodically disconnects a random active link and connects a random unconnected node pair via control socket - Add send_command() to control.py using base64-encoded JSON payloads to avoid shell quoting issues in docker exec - Add PeerChurnConfig to scenario with interval and ephemeral_fraction - Ephemeral identity support: nodes configured without nsec generate fresh keypairs on restart; simulator queries show_status for new npub and updates its cache via on_node_restart callback - Add maelstrom.yaml: all chaos dimensions (netem, link flaps, node churn, peer topology churn, traffic) with 50% ephemeral identity
207 lines
6.6 KiB
Python
207 lines
6.6 KiB
Python
"""Node churn simulation via docker stop/start.
|
|
|
|
Simulates node loss and recovery by stopping and restarting containers.
|
|
When a node restarts, its FIPS process starts fresh — all peer
|
|
connections, sessions, and routing state are lost. Peers detect the
|
|
loss via handshake/link timeouts and reconverge the spanning tree.
|
|
|
|
After restart, netem rules must be re-applied since tc state is lost
|
|
when the container stops.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import random
|
|
import time
|
|
from collections import deque
|
|
from dataclasses import dataclass, field
|
|
|
|
from .docker_exec import docker_exec_quiet, is_container_running
|
|
from .scenario import NodeChurnConfig
|
|
from .topology import SimTopology
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class NodeState:
|
|
node_id: str
|
|
is_down: bool = False
|
|
down_since: float | None = None
|
|
restore_at: float | None = None
|
|
|
|
|
|
class NodeManager:
|
|
"""Manages node stop/start lifecycle."""
|
|
|
|
def __init__(
|
|
self,
|
|
topology: SimTopology,
|
|
config: NodeChurnConfig,
|
|
rng: random.Random,
|
|
netem_mgr=None,
|
|
down_nodes: set[str] | None = None,
|
|
veth_mgr=None,
|
|
on_node_restart=None,
|
|
):
|
|
self.topology = topology
|
|
self.config = config
|
|
self.rng = rng
|
|
self.netem_mgr = netem_mgr
|
|
self.veth_mgr = veth_mgr
|
|
self.down_nodes = down_nodes or set()
|
|
self.on_node_restart = on_node_restart
|
|
self.node_states: dict[str, NodeState] = {
|
|
nid: NodeState(node_id=nid) for nid in topology.nodes
|
|
}
|
|
|
|
@property
|
|
def down_count(self) -> int:
|
|
return sum(1 for ns in self.node_states.values() if ns.is_down)
|
|
|
|
def maybe_kill(self):
|
|
"""Attempt to stop a random node."""
|
|
if self.down_count >= self.config.max_down_nodes:
|
|
log.debug(
|
|
"At max_down_nodes (%d), skipping churn",
|
|
self.config.max_down_nodes,
|
|
)
|
|
return
|
|
|
|
# Candidate: any node that's currently up
|
|
up_nodes = [nid for nid, ns in self.node_states.items() if not ns.is_down]
|
|
if not up_nodes:
|
|
return
|
|
|
|
self.rng.shuffle(up_nodes)
|
|
|
|
for node_id in up_nodes:
|
|
if self.config.protect_connectivity and self._would_disconnect(node_id):
|
|
log.debug("Skipping %s (would disconnect graph)", node_id)
|
|
continue
|
|
|
|
down_duration = self.rng.uniform(
|
|
self.config.down_duration_secs.min,
|
|
self.config.down_duration_secs.max,
|
|
)
|
|
self._stop_node(node_id, down_duration)
|
|
return
|
|
|
|
log.debug("No safe node to kill (all would disconnect)")
|
|
|
|
def restore_expired(self):
|
|
"""Restart nodes whose down duration has expired."""
|
|
now = time.time()
|
|
for nid, state in self.node_states.items():
|
|
if state.is_down and state.restore_at and now >= state.restore_at:
|
|
self._start_node(nid)
|
|
|
|
def restore_all(self):
|
|
"""Restart all stopped nodes (for teardown — needed for log collection)."""
|
|
for nid, state in list(self.node_states.items()):
|
|
if state.is_down:
|
|
self._start_node(nid)
|
|
|
|
def _stop_node(self, node_id: str, duration: float):
|
|
"""Stop a container."""
|
|
container = self.topology.container_name(node_id)
|
|
docker_exec_quiet(container, "kill 1", timeout=5) # SIGTERM to PID 1
|
|
# Use docker stop with a short grace period
|
|
import subprocess
|
|
|
|
subprocess.run(
|
|
["docker", "stop", "-t", "2", container],
|
|
capture_output=True,
|
|
timeout=15,
|
|
)
|
|
|
|
now = time.time()
|
|
state = self.node_states[node_id]
|
|
state.is_down = True
|
|
state.down_since = now
|
|
state.restore_at = now + duration
|
|
|
|
# Tell other managers to skip this node
|
|
self.down_nodes.add(node_id)
|
|
|
|
log.info("Node STOPPED: %s (restore in %.0fs)", node_id, duration)
|
|
|
|
def _start_node(self, node_id: str):
|
|
"""Start a stopped container and re-apply netem."""
|
|
container = self.topology.container_name(node_id)
|
|
import subprocess
|
|
|
|
result = subprocess.run(
|
|
["docker", "start", container],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
)
|
|
if result.returncode != 0:
|
|
log.warning("Failed to start %s: %s", container, result.stderr)
|
|
return
|
|
|
|
state = self.node_states[node_id]
|
|
down_for = time.time() - state.down_since if state.down_since else 0
|
|
state.is_down = False
|
|
state.down_since = None
|
|
state.restore_at = None
|
|
|
|
# Clear the down-node flag before re-applying netem
|
|
self.down_nodes.discard(node_id)
|
|
|
|
log.info("Node STARTED: %s (was down %.0fs)", node_id, down_for)
|
|
|
|
# Re-create veth pairs (container restart destroys netns)
|
|
if self.veth_mgr:
|
|
time.sleep(1)
|
|
self.veth_mgr.setup_node(node_id)
|
|
|
|
# Re-apply netem after a brief delay for the container to initialize
|
|
if self.netem_mgr:
|
|
if not self.veth_mgr:
|
|
time.sleep(1)
|
|
self.netem_mgr.setup_node(node_id)
|
|
|
|
# Notify callback (e.g., refresh npub for ephemeral identity nodes)
|
|
if self.on_node_restart:
|
|
self.on_node_restart(node_id)
|
|
|
|
def _would_disconnect(self, node_id: str) -> bool:
|
|
"""Check if removing this node (plus currently-down nodes) disconnects the graph.
|
|
|
|
Builds the subgraph of currently-up nodes (excluding the candidate)
|
|
and checks connectivity via BFS.
|
|
"""
|
|
# Active nodes: up and not the candidate
|
|
active_nodes = set()
|
|
for nid, state in self.node_states.items():
|
|
if not state.is_down and nid != node_id:
|
|
active_nodes.add(nid)
|
|
|
|
if len(active_nodes) <= 1:
|
|
return True # Can't remove from a 1-node graph
|
|
|
|
# Build adjacency for active nodes only
|
|
adj: dict[str, list[str]] = {nid: [] for nid in active_nodes}
|
|
for a, b in self.topology.edges:
|
|
if a in active_nodes and b in active_nodes:
|
|
adj[a].append(b)
|
|
adj[b].append(a)
|
|
|
|
# BFS
|
|
start = next(iter(active_nodes))
|
|
visited = set()
|
|
queue = deque([start])
|
|
while queue:
|
|
node = queue.popleft()
|
|
if node in visited:
|
|
continue
|
|
visited.add(node)
|
|
for neighbor in adj[node]:
|
|
if neighbor not in visited:
|
|
queue.append(neighbor)
|
|
|
|
return len(visited) < len(active_nodes)
|