mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
Consolidate Docker test harness infrastructure
Replace 4 near-identical per-harness Docker setups with unified shared infrastructure. Net result: -463 lines across 55 files, faster CI (8.5 min vs ~13.5 min), 14 scenarios (down from 21). Unified Docker image (testing/docker/): - Single Dockerfile (trixie-slim) with FIPS_TEST_MODE env var for mode dispatch: default, chaos, sidecar, tor-socks5, tor-directory - Single entrypoint.sh with conditional logic per mode - Replaces 5 Dockerfiles, 3 entrypoints, 4 resolv.conf copies Shared build and libraries (testing/scripts/, testing/lib/): - testing/scripts/build.sh: single build script with macOS zigbuild support, replaces 4 per-harness copies - testing/lib/derive_keys.py: shared key derivation module, replaces 3 copies of derive-keys.py - testing/lib/log_analysis.py: shared log analysis extracted from chaos sim/logs.py, with CLI interface and rekey cutover tracking - testing/lib/wait-converge.sh: shared convergence wait helpers (wait_for_links, wait_for_peers) using fipsctl JSON polling Scenario consolidation: - Remove 7 redundant scenarios: tcp-chain (subsumed by tcp-mesh), tcp-only (subsumed by tcp-mesh), chaos-10 (replaced by churn-mixed --nodes 10), churn-10/churn-20/churn-20-mixed (subsumed by parameterized churn-mixed), cost-mixed-7node (overlaps mixed-technology) - Add churn-mixed scenario with --nodes flag for scale testing - Reduce idle scenario durations: smoke-10 60s→30s, ethernet-only 90s→30s, cost-avoidance 120s→45s, depth-vs-cost 120s→45s, bottleneck-parent 120s→60s, mixed-technology 180s→90s CI updates: - ci-local.sh: unified image build, structured chaos suite entries with per-scenario flags, --skip-build for sidecar - ci.yml: shared binary install + image build step, updated scenario matrix, chaos_flags support for parameterized scenarios - Add tcp-mesh and congestion-stress to CI matrix - Static ping test uses active peer convergence detection instead of hardcoded 5s sleep Chaos infrastructure improvements (from discovery-rework branch): - Pre-built Docker image instead of per-service build at scale - --nodes flag in chaos.sh for runtime topology size override
This commit is contained in:
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Derive deterministic nostr nsec/npub from mesh-name and node-name.
|
||||
|
||||
Usage: derive-keys.py <mesh-name> <node-name>
|
||||
Output: nsec=<hex>\nnpub=<bech32>
|
||||
|
||||
Derivation: nsec = sha256(mesh_name + "|" + node_name)
|
||||
npub = bech32("npub", secp256k1_pubkey_x(nsec))
|
||||
|
||||
Pure Python, no external dependencies.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
|
||||
# --- secp256k1 ---
|
||||
|
||||
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
|
||||
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
|
||||
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
|
||||
|
||||
|
||||
def _modinv(a, m):
|
||||
return pow(a, m - 2, m)
|
||||
|
||||
|
||||
def _point_add(p1, p2):
|
||||
if p1 is None:
|
||||
return p2
|
||||
if p2 is None:
|
||||
return p1
|
||||
x1, y1 = p1
|
||||
x2, y2 = p2
|
||||
if x1 == x2 and y1 != y2:
|
||||
return None
|
||||
if x1 == x2:
|
||||
lam = (3 * x1 * x1) * _modinv(2 * y1, P) % P
|
||||
else:
|
||||
lam = (y2 - y1) * _modinv(x2 - x1, P) % P
|
||||
x3 = (lam * lam - x1 - x2) % P
|
||||
y3 = (lam * (x1 - x3) - y1) % P
|
||||
return (x3, y3)
|
||||
|
||||
|
||||
def _scalar_mult(k, point):
|
||||
result = None
|
||||
addend = point
|
||||
while k:
|
||||
if k & 1:
|
||||
result = _point_add(result, addend)
|
||||
addend = _point_add(addend, addend)
|
||||
k >>= 1
|
||||
return result
|
||||
|
||||
|
||||
# --- bech32 (BIP-173) ---
|
||||
|
||||
_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
|
||||
def _bech32_polymod(values):
|
||||
gen = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
||||
chk = 1
|
||||
for v in values:
|
||||
b = chk >> 25
|
||||
chk = (chk & 0x1FFFFFF) << 5 ^ v
|
||||
for i in range(5):
|
||||
chk ^= gen[i] if ((b >> i) & 1) else 0
|
||||
return chk
|
||||
|
||||
|
||||
def _bech32_encode(hrp, data_5bit):
|
||||
hrp_expand = [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
|
||||
polymod = _bech32_polymod(hrp_expand + data_5bit + [0] * 6) ^ 1
|
||||
checksum = [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
||||
return hrp + "1" + "".join(_CHARSET[d] for d in data_5bit + checksum)
|
||||
|
||||
|
||||
def _convertbits(data, frombits, tobits):
|
||||
acc, bits, ret = 0, 0, []
|
||||
maxv = (1 << tobits) - 1
|
||||
for value in data:
|
||||
acc = (acc << frombits) | value
|
||||
bits += frombits
|
||||
while bits >= tobits:
|
||||
bits -= tobits
|
||||
ret.append((acc >> bits) & maxv)
|
||||
if bits:
|
||||
ret.append((acc << (tobits - bits)) & maxv)
|
||||
return ret
|
||||
|
||||
|
||||
# --- public API ---
|
||||
|
||||
def derive(mesh_name, node_name):
|
||||
nsec_hex = hashlib.sha256(f"{mesh_name}|{node_name}".encode()).hexdigest()
|
||||
k = int(nsec_hex, 16)
|
||||
pub = _scalar_mult(k, (Gx, Gy))
|
||||
x_hex = format(pub[0], "064x")
|
||||
data_5bit = _convertbits(list(bytes.fromhex(x_hex)), 8, 5)
|
||||
npub = _bech32_encode("npub", data_5bit)
|
||||
return nsec_hex, npub
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} <mesh-name> <node-name>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
nsec, npub = derive(sys.argv[1], sys.argv[2])
|
||||
print(f"nsec={nsec}")
|
||||
print(f"npub={npub}")
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared log analysis for FIPS integration tests.
|
||||
|
||||
Parses structured tracing output from FIPS daemons and categorizes
|
||||
events (panics, errors, sessions, parent switches, etc.).
|
||||
|
||||
CLI usage:
|
||||
python3 -m lib.log_analysis <logfile> [<logfile> ...]
|
||||
python3 -m lib.log_analysis --from-docker <container> [<container> ...]
|
||||
|
||||
Exit codes:
|
||||
0 — no panics detected
|
||||
2 — panics detected
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
# Regex to strip ANSI escape codes from tracing output
|
||||
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes from text."""
|
||||
return ANSI_RE.sub("", text)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisResult:
|
||||
"""Categorized events extracted from FIPS daemon logs."""
|
||||
|
||||
errors: list[tuple[str, str]] = field(default_factory=list)
|
||||
warnings: list[tuple[str, str]] = field(default_factory=list)
|
||||
sessions_established: list[tuple[str, str]] = field(default_factory=list)
|
||||
peers_promoted: list[tuple[str, str]] = field(default_factory=list)
|
||||
peer_removals: list[tuple[str, str]] = field(default_factory=list)
|
||||
parent_switches: list[tuple[str, str]] = field(default_factory=list)
|
||||
mmp_link_metrics: list[tuple[str, str]] = field(default_factory=list)
|
||||
mmp_session_metrics: list[tuple[str, str]] = field(default_factory=list)
|
||||
handshake_timeouts: list[tuple[str, str]] = field(default_factory=list)
|
||||
panics: list[tuple[str, str]] = field(default_factory=list)
|
||||
congestion_detected: list[tuple[str, str]] = field(default_factory=list)
|
||||
kernel_drop_events: list[tuple[str, str]] = field(default_factory=list)
|
||||
rekey_cutovers: list[tuple[str, str]] = field(default_factory=list)
|
||||
|
||||
def summary(self) -> str:
|
||||
"""Format a human-readable summary of the analysis."""
|
||||
lines = [
|
||||
"=== Log Analysis ===",
|
||||
"",
|
||||
f"Panics: {len(self.panics)}",
|
||||
f"Errors: {len(self.errors)}",
|
||||
f"Warnings: {len(self.warnings)}",
|
||||
f"Sessions established: {len(self.sessions_established)}",
|
||||
f"Peers promoted: {len(self.peers_promoted)}",
|
||||
f"Peer removals: {len(self.peer_removals)}",
|
||||
f"Parent switches: {len(self.parent_switches)}",
|
||||
f"Handshake timeouts: {len(self.handshake_timeouts)}",
|
||||
f"MMP link samples: {len(self.mmp_link_metrics)}",
|
||||
f"MMP session samples: {len(self.mmp_session_metrics)}",
|
||||
f"Congestion events: {len(self.congestion_detected)}",
|
||||
f"Kernel drop events: {len(self.kernel_drop_events)}",
|
||||
f"Rekey cutovers: {len(self.rekey_cutovers)}",
|
||||
]
|
||||
|
||||
if self.panics:
|
||||
lines.append("")
|
||||
lines.append("--- PANICS ---")
|
||||
for source, line in self.panics[:10]:
|
||||
lines.append(f" [{source}] {line.strip()}")
|
||||
|
||||
if self.errors:
|
||||
lines.append("")
|
||||
lines.append("--- ERRORS (first 20) ---")
|
||||
for source, line in self.errors[:20]:
|
||||
lines.append(f" [{source}] {line.strip()}")
|
||||
|
||||
if self.handshake_timeouts:
|
||||
lines.append("")
|
||||
lines.append("--- HANDSHAKE TIMEOUTS (first 10) ---")
|
||||
for source, line in self.handshake_timeouts[:10]:
|
||||
lines.append(f" [{source}] {line.strip()}")
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
@property
|
||||
def has_panics(self) -> bool:
|
||||
return len(self.panics) > 0
|
||||
|
||||
|
||||
def analyze_text(log_text: str, source: str = "") -> AnalysisResult:
|
||||
"""Analyze a single log text and return categorized events."""
|
||||
result = AnalysisResult()
|
||||
_analyze_lines(result, source, log_text)
|
||||
return result
|
||||
|
||||
|
||||
def analyze_logs(logs: dict[str, str]) -> AnalysisResult:
|
||||
"""Analyze logs from multiple sources (keyed by source name)."""
|
||||
result = AnalysisResult()
|
||||
for source, log_text in logs.items():
|
||||
_analyze_lines(result, source, log_text)
|
||||
return result
|
||||
|
||||
|
||||
def _analyze_lines(result: AnalysisResult, source: str, log_text: str):
|
||||
"""Parse log lines and append to result."""
|
||||
for raw_line in log_text.splitlines():
|
||||
line = strip_ansi(raw_line)
|
||||
|
||||
# Panics
|
||||
if "panicked" in line or "PANIC" in line:
|
||||
result.panics.append((source, line))
|
||||
# Errors and warnings
|
||||
elif " ERROR " in line:
|
||||
result.errors.append((source, line))
|
||||
elif " WARN " in line:
|
||||
result.warnings.append((source, line))
|
||||
|
||||
# Session establishment
|
||||
if "Session established" in line:
|
||||
result.sessions_established.append((source, line))
|
||||
# Peer promotion
|
||||
if "Inbound peer promoted" in line or "Outbound handshake completed" in line:
|
||||
result.peers_promoted.append((source, line))
|
||||
# Peer removal
|
||||
if "Peer removed" in line:
|
||||
result.peer_removals.append((source, line))
|
||||
# Parent switches
|
||||
if "Parent switched" in line:
|
||||
result.parent_switches.append((source, line))
|
||||
# Handshake timeouts
|
||||
if "timed out" in line and ("handshake" in line.lower() or "Handshake" in line):
|
||||
result.handshake_timeouts.append((source, line))
|
||||
# MMP metrics
|
||||
if "MMP link metrics" in line:
|
||||
result.mmp_link_metrics.append((source, line))
|
||||
if "MMP session metrics" in line:
|
||||
result.mmp_session_metrics.append((source, line))
|
||||
# Congestion events
|
||||
if "Congestion detected" in line:
|
||||
result.congestion_detected.append((source, line))
|
||||
if "Kernel recv drops first observed" in line:
|
||||
result.kernel_drop_events.append((source, line))
|
||||
# Rekey cutovers
|
||||
if "Rekey cutover complete" in line or "FSP rekey cutover complete" in line:
|
||||
result.rekey_cutovers.append((source, line))
|
||||
|
||||
|
||||
def collect_docker_logs(containers: list[str]) -> dict[str, str]:
|
||||
"""Collect logs from Docker containers, stripping ANSI codes."""
|
||||
logs = {}
|
||||
for name in containers:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "logs", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
raw = result.stdout + result.stderr
|
||||
logs[name] = strip_ansi(raw)
|
||||
except (subprocess.TimeoutExpired, Exception):
|
||||
logs[name] = ""
|
||||
return logs
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point."""
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} [--from-docker] <source> [<source> ...]",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
from_docker = False
|
||||
args = sys.argv[1:]
|
||||
if args[0] == "--from-docker":
|
||||
from_docker = True
|
||||
args = args[1:]
|
||||
|
||||
if not args:
|
||||
print("Error: no sources specified", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if from_docker:
|
||||
logs = collect_docker_logs(args)
|
||||
else:
|
||||
logs = {}
|
||||
for path in args:
|
||||
with open(path) as f:
|
||||
logs[path] = f.read()
|
||||
|
||||
result = analyze_logs(logs)
|
||||
print(result.summary())
|
||||
sys.exit(2 if result.has_panics else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# Shared convergence wait helpers for FIPS integration tests.
|
||||
#
|
||||
# Source this file to get wait_for_links() and wait_for_peers().
|
||||
#
|
||||
# Usage:
|
||||
# source "$(dirname "$0")/../../lib/wait-converge.sh"
|
||||
# wait_for_links <container> <min_links> [timeout_secs]
|
||||
# wait_for_peers <container> <min_peers> [timeout_secs]
|
||||
|
||||
# Wait until a container has at least min_links active links.
|
||||
# Returns 0 on success, 1 on timeout.
|
||||
wait_for_links() {
|
||||
local container="$1"
|
||||
local min_links="$2"
|
||||
local timeout="${3:-30}"
|
||||
|
||||
for i in $(seq 1 "$timeout"); do
|
||||
local count
|
||||
count=$(docker exec "$container" fipsctl show links 2>/dev/null \
|
||||
| python3 -c "import sys,json; print(len(json.load(sys.stdin).get('links',[])))" 2>/dev/null || echo 0)
|
||||
if [ "$count" -ge "$min_links" ]; then
|
||||
echo " $container: $count link(s) after ${i}s"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " $container: TIMEOUT waiting for $min_links link(s) after ${timeout}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait until a container has at least min_peers connected peers.
|
||||
# Returns 0 on success, 1 on timeout.
|
||||
wait_for_peers() {
|
||||
local container="$1"
|
||||
local min_peers="$2"
|
||||
local timeout="${3:-30}"
|
||||
|
||||
for i in $(seq 1 "$timeout"); do
|
||||
local count
|
||||
count=$(docker exec "$container" fipsctl show peers 2>/dev/null \
|
||||
| python3 -c "import sys,json; print(sum(1 for p in json.load(sys.stdin).get('peers',[]) if p.get('connectivity')=='connected'))" 2>/dev/null || echo 0)
|
||||
if [ "$count" -ge "$min_peers" ]; then
|
||||
echo " $container: $count peer(s) after ${i}s"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " $container: TIMEOUT waiting for $min_peers peer(s) after ${timeout}s"
|
||||
return 1
|
||||
}
|
||||
Reference in New Issue
Block a user