Files
fips/testing/chaos/sim/control.py
T
Johnathan Corgan 0d93a19e07 Implement cost-based parent selection with periodic re-evaluation
Cost-based parent selection:
- Replace depth-only parent selection with effective_depth = depth + link_cost
- link_cost computed from locally measured MMP metrics: etx * (1.0 + srtt_ms / 100.0)
- Prevents bottleneck subtrees in heterogeneous networks where a LoRa link
  at depth 1 would otherwise always beat fiber at depth 2
- Configurable hysteresis (default 0.2) prevents marginal parent switches
- Configurable hold-down timer (default 30s) suppresses re-evaluation
  after parent switch
- Mandatory switches (parent lost, root change) bypass both safeguards
- Link costs passed as HashMap parameter to keep TreeState pure

Periodic re-evaluation:
- evaluate_parent() was only called on TreeAnnounce receipt or parent loss;
  after tree stabilization, link degradation went undetected
- Added timer-based re-evaluation (reeval_interval_secs, default 60s) that
  calls evaluate_parent() from the tick handler with current MMP link costs
- Respects existing hold-down and hysteresis safeguards
- Short-circuits when disabled or <2 peers

Design documentation:
- Update 7 design docs to reflect cost-based parent selection
- Replace depth-only algorithm descriptions with effective_depth model
- Replace rejected cumulative path cost spec with local-only design rationale
- Rewrite Example 2 (heterogeneous links) for local-only cost model
- Update config docs: parent_switch_threshold replaced by parent_hysteresis,
  hold_down_secs, reeval_interval_secs

Chaos simulation enhancements:
- fips_overrides with deep merge for per-scenario FIPS config customization
- Explicit topology algorithm for deterministic test graphs
- Control socket querying via fipsctl for tree/MMP snapshot collection
- Edge existence validation in netem manager
- Per-link netem policy overrides
- 9 new chaos scenarios covering cost avoidance, depth-vs-cost tradeoffs,
  stability, mixed topologies, periodic re-evaluation, and bottleneck parent

12 new unit tests, 667 total passing, clippy clean.
2026-02-23 17:15:20 +00:00

104 lines
3.3 KiB
Python

"""Control socket querying via docker exec.
Queries FIPS nodes' control sockets to observe runtime state (tree
structure, MMP metrics, peers) without requiring fipsctl in the
container. Uses a Python one-liner inside docker exec since python3
is available in the Docker image.
"""
from __future__ import annotations
import json
import logging
from .docker_exec import docker_exec, docker_exec_quiet
from .topology import SimTopology
log = logging.getLogger(__name__)
# Default control socket path inside containers (XDG_RUNTIME_DIR is
# typically unset in Docker, so fips falls back to /tmp).
CONTROL_SOCKET = "/tmp/fips-control.sock"
def query_node(container: str, command: str, timeout: int = 10) -> dict | None:
"""Send a command to a node's control socket, return the data dict.
Returns None if the query fails (node down, socket not ready, etc.).
"""
# Python one-liner that connects to the Unix socket, sends the JSON
# command, and prints the response. Runs inside the container.
script = (
"import socket,json,sys; "
"s=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM); "
f"s.connect('{CONTROL_SOCKET}'); "
f"s.sendall(json.dumps({{'command':'{command}'}}).encode()+b'\\n'); "
"s.shutdown(socket.SHUT_WR); "
"chunks=[]; "
"[chunks.append(d) for d in iter(lambda:s.recv(65536),b'')]; "
"print(b''.join(chunks).decode())"
)
stdout = docker_exec_quiet(container, f"python3 -c \"{script}\"", timeout=timeout)
if stdout is None:
return None
try:
response = json.loads(stdout.strip())
except json.JSONDecodeError as e:
log.warning("Invalid JSON from %s: %s", container, e)
return None
if response.get("status") != "ok":
msg = response.get("message", "unknown error")
log.warning("Control query %s on %s failed: %s", command, container, msg)
return None
return response.get("data", {})
def query_tree(container: str) -> dict | None:
"""Query a node's spanning tree state."""
return query_node(container, "show_tree")
def query_mmp(container: str) -> dict | None:
"""Query a node's MMP metrics."""
return query_node(container, "show_mmp")
def query_peers(container: str) -> dict | None:
"""Query a node's peer list with MMP metrics."""
return query_node(container, "show_peers")
def snapshot_all_trees(topology: SimTopology) -> dict[str, dict]:
"""Query show_tree on all nodes, return {node_id: tree_data}.
Nodes that fail to respond are omitted from the result.
"""
result = {}
for node_id in sorted(topology.nodes):
container = topology.container_name(node_id)
data = query_tree(container)
if data is not None:
result[node_id] = data
else:
log.warning("No tree data from %s", node_id)
return result
def snapshot_all_mmp(topology: SimTopology) -> dict[str, dict]:
"""Query show_mmp on all nodes, return {node_id: mmp_data}.
Nodes that fail to respond are omitted from the result.
"""
result = {}
for node_id in sorted(topology.nodes):
container = topology.container_name(node_id)
data = query_mmp(container)
if data is not None:
result[node_id] = data
else:
log.warning("No MMP data from %s", node_id)
return result