mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 00:04:54 +00:00
Move examples/docker-network/ to testing/static/ and add testing/chaos/ as a new stochastic simulation harness. testing/static/ — Static 5-node test harness: - Fixed mesh, chain, and mesh-public topologies with docker compose - Manual test scripts (ping, iperf, netem) - Build script, config generation, key derivation testing/chaos/ — Stochastic network simulation: - Python orchestrator generating N-node FIPS meshes with dynamic network conditions, driven by reproducible YAML scenarios - Topology generation: random geometric, Erdos-Renyi, or chain graphs with BFS connectivity guarantee - Per-link netem: HTB classful qdiscs with u32 filters for per-peer impairment (delay, loss, jitter), stochastic mutation across configurable policy profiles - Per-link bandwidth pacing: HTB rate limiting with configurable tiers (1/10/100/1000 mbps) randomly assigned per edge - Link flaps: tc netem 100% loss with graph connectivity protection - Node churn: docker stop/start with netem re-application on restart, shared down_nodes tracking across all managers - Traffic generation: random iperf3 sessions between node pairs - Down-node guards: all docker exec callers check container liveness, auto-detect crashed containers via is_container_running() safety net - Log collection and post-run analysis (panics, errors, sessions, MMP metrics, tree reconvergence) - chaos.sh wrapper with --seed, --duration, --verbose, --list options - Four scenarios: smoke-10, chaos-10, churn-10, churn-20
194 lines
6.2 KiB
Python
194 lines
6.2 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,
|
|
):
|
|
self.topology = topology
|
|
self.config = config
|
|
self.rng = rng
|
|
self.netem_mgr = netem_mgr
|
|
self.down_nodes = down_nodes or set()
|
|
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-apply netem after a brief delay for the container to initialize
|
|
if self.netem_mgr:
|
|
# Small delay for container networking to be ready
|
|
time.sleep(1)
|
|
self.netem_mgr.setup_node(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)
|