Files
Johnathan Corgan fed6cc6987 Fix chaos sim DNS pipeline, ephemeral npub tracking, and analysis
Three infrastructure bugs fixed:

- DNS resolution never populated the identity cache because Docker
  overwrites /etc/resolv.conf at container start. Fix: bind-mount
  resolv.conf in the chaos compose template, matching the static and
  sidecar test configs.

- Traffic generator used config-time npubs for iperf3 targets, but
  ephemeral nodes generate fresh keypairs at startup. Fix: share the
  peer churn manager's runtime npub cache with the traffic manager.

- Log analysis had no discovery counters and used wrong patterns.
  Fix: add discovery section with correct log message matching.

Also adds timestamped output directories (YYYYMMDD-HHMMSS-scenario),
coord_ttl_secs override in maelstrom.yaml, FIPS_SIM_OUTPUT env var
support, and maelstrom-sparse scenario for sparse topology testing.
2026-03-20 16:14:10 +00:00

174 lines
5.7 KiB
Python

"""Random iperf3 traffic generation between node pairs.
Spawns iperf3 clients as background processes in containers. The iperf3
server is already running in each container (started by the Dockerfile
entrypoint).
"""
from __future__ import annotations
import json
import logging
import random
import time
from dataclasses import dataclass, field
from .docker_exec import docker_exec_quiet
from .scenario import TrafficConfig
from .topology import SimTopology
log = logging.getLogger(__name__)
@dataclass
class TrafficSession:
client_node: str
server_node: str
started_at: float
duration_secs: int
container: str
result_file: str = ""
class TrafficManager:
"""Manages random iperf3 sessions across the mesh."""
def __init__(
self,
topology: SimTopology,
config: TrafficConfig,
rng: random.Random,
down_nodes: set[str] | None = None,
npub_cache: dict[str, str] | None = None,
):
self.topology = topology
self.config = config
self.rng = rng
self.down_nodes = down_nodes or set()
self.npub_cache = npub_cache or {}
self.active_sessions: list[TrafficSession] = []
self.completed_results: list[dict] = []
@property
def active_count(self) -> int:
return len(self.active_sessions)
def maybe_spawn(self):
"""Spawn a new iperf3 session if under the concurrency limit."""
if self.active_count >= self.config.max_concurrent:
log.debug(
"At max_concurrent (%d), skipping traffic spawn",
self.config.max_concurrent,
)
return
node_ids = [nid for nid in self.topology.nodes if nid not in self.down_nodes]
if len(node_ids) < 2:
return
# Pick random client and server (different nodes, both up)
client, server = self.rng.sample(node_ids, 2)
server_npub = self.npub_cache.get(server, self.topology.nodes[server].npub)
container = self.topology.container_name(client)
duration = int(
self.rng.uniform(
self.config.duration_secs.min,
self.config.duration_secs.max,
)
)
streams = self.config.parallel_streams
# Result file inside the container for JSON capture
ts = int(time.time())
result_file = f"/tmp/iperf3-{client}-{server}-{ts}.json"
# Start iperf3 in background with JSON output
cmd = (
f"nohup iperf3 -c {server_npub}.fips -t {duration} "
f"-P {streams} --json > {result_file} 2>&1 &"
)
result = docker_exec_quiet(container, cmd)
if result is not None:
session = TrafficSession(
client_node=client,
server_node=server,
started_at=time.time(),
duration_secs=duration,
container=container,
result_file=result_file,
)
self.active_sessions.append(session)
log.info(
"Traffic: %s -> %s (%ds, %d streams)",
client,
server,
duration,
streams,
)
else:
log.warning("Failed to start iperf3 on %s", container)
def cleanup_expired(self):
"""Remove sessions that have completed (based on time)."""
now = time.time()
grace = 5 # seconds after expected completion
still_active = []
for s in self.active_sessions:
if now - s.started_at >= s.duration_secs + grace:
self._collect_result(s)
else:
still_active.append(s)
removed = len(self.active_sessions) - len(still_active)
self.active_sessions = still_active
if removed > 0:
log.debug("Cleaned up %d expired traffic sessions", removed)
def _collect_result(self, session: TrafficSession):
"""Retrieve iperf3 JSON result from container."""
if not session.result_file:
return
if session.client_node in self.down_nodes:
return
stdout = docker_exec_quiet(
session.container,
f"cat {session.result_file} 2>/dev/null; rm -f {session.result_file}",
)
if stdout is None:
return
try:
data = json.loads(stdout.strip())
except (json.JSONDecodeError, ValueError):
log.debug("Could not parse iperf3 result for %s -> %s",
session.client_node, session.server_node)
return
data["_meta"] = {
"client": session.client_node,
"server": session.server_node,
"duration_secs": session.duration_secs,
}
self.completed_results.append(data)
def collect_results(self) -> list[dict]:
"""Return all completed iperf3 JSON results."""
# Collect any remaining active sessions that may have finished
for s in self.active_sessions:
self._collect_result(s)
return self.completed_results
def stop_all(self):
"""Kill all iperf3 client processes in running containers."""
seen = set()
for session in self.active_sessions:
if session.container not in seen:
if session.client_node not in self.down_nodes:
docker_exec_quiet(
session.container,
"killall iperf3 2>/dev/null; true",
)
seen.add(session.container)
# Collect results before clearing (iperf3 writes partial JSON on kill)
for s in self.active_sessions:
self._collect_result(s)
self.active_sessions.clear()