Files
fips/testing/static/scripts/bench-multirun.sh
T
Johnathan Corgan e4a854f6b0 Stop concurrent local CI runs from clobbering each other's containers
Two runs on one host destroyed each other's containers, producing
mid-test "No such container" failures that look like real defects. The
automated builder runs a full local CI on the same box every few minutes,
so the machine is contended almost always and this has red-ed both a hand
run and an automated gate.

Two independent causes are fixed here. Container names were hardcoded, and
docker names are global rather than scoped by compose project, so two runs
collided on the same name; every name now takes an optional suffix that
the harness sets from the run id. And the cleanup sweep matched a label
shared by every run, so one run's teardown force-removed another's
containers; resources now also carry a per-run label and the sweep can be
narrowed to it.

A third hazard turned up that was not in the original report: the sidecar
suite passes explicit compose project names, which override the run-scoped
project and put it outside the shared prefix entirely. Its project names,
network, and derived container references are now scoped too.

Both are default-off. With the suffix unset, names render exactly as they
do today and a bare compose invocation is unchanged, which is what keeps
the hosted CI and the documentation correct. A cleanup run with no run id
still reaps everything, which is what a manual "clear the box" wants.

The literal-name sweep was not sufficient: six scripts build container
names dynamically from node labels, and two suites create their own
containers outside compose. Those are handled at their construction sites.

Verified: syntax check on all modified scripts; compose validation on
every modified file with the suffix both set and unset; and a synthetic
two-run reproduction that shows the old cleanup destroying a bystander run
and the new one leaving it alone.

Known gap: subnets are still hardcoded, so two concurrent full runs will
still collide on address-pool overlap. That fix reaches into topology
configs, chaos scenarios, diagrams and production source, so it is left
for its own change rather than half-done here.
2026-07-19 06:41:25 +00:00

428 lines
16 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# Multi-run iperf3 + ping bench for FIPS mesh. Repeats each test N times
# (default 3), reports median + min/max + flags outliers >20% from
# median. Adds round-trip latency (ping) and a TCP-retransmit count
# (proxy for packet loss across the FIPS overlay) per path.
#
# Usage:
# ./bench-multirun.sh [mesh|chain]
#
# Environment:
# FIPS_BENCH_RUNS=3 Number of iperf3 repetitions per path
# FIPS_BENCH_DURATION=15 Seconds per iperf3 run
# FIPS_BENCH_PARALLEL=1 Parallel TCP streams (1 = single-stream)
# FIPS_BENCH_PING_COUNT=30 ICMP probes per path
# FIPS_BENCH_OUTLIER_PCT=20 Flag run as outlier if Δ from median > N%
# FIPS_BENCH_OUTPUT=json|text (default text; json emits NDJSON)
set -e
trap 'echo ""; echo "Bench interrupted"; exit 130' INT
PROFILE="${1:-mesh}"
RUNS="${FIPS_BENCH_RUNS:-5}"
DURATION="${FIPS_BENCH_DURATION:-15}"
PARALLEL="${FIPS_BENCH_PARALLEL:-1}"
PING_COUNT="${FIPS_BENCH_PING_COUNT:-30}"
OUTLIER_PCT="${FIPS_BENCH_OUTLIER_PCT:-20}"
OUTPUT="${FIPS_BENCH_OUTPUT:-text}"
if ! [[ "$RUNS" =~ ^[1-9][0-9]*$ ]] || [ "$RUNS" -lt 1 ]; then
echo "FIPS_BENCH_RUNS must be ≥ 1" >&2; exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/../generated-configs/npubs.env"
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE not found. Run generate-configs.sh first." >&2
exit 1
fi
# shellcheck source=../generated-configs/npubs.env
source "$ENV_FILE"
# ── Helpers ────────────────────────────────────────────────────────────────
# Run iperf3 once, print "<mbps> <retransmits>" on success or "FAIL" on
# failure. Uses JSON output so we don't have to scrape human-readable
# SI prefixes.
iperf_once() {
local client="$1" dest_npub="$2"
local out
if ! out=$(docker exec "fips-${client}${FIPS_CI_NAME_SUFFIX:-}" iperf3 -c "${dest_npub}.fips" \
-t "$DURATION" -P "$PARALLEL" -J 2>&1); then
echo FAIL
return
fi
python3 - "$out" <<'PYEOF'
import json, sys
try:
d = json.loads(sys.argv[1])
bps = d['end']['sum_received']['bits_per_second']
# iperf3 reports TCP retransmits in 'sum_sent.retransmits' on
# the client side; missing on UDP or for the server-summary view.
retr = d.get('end', {}).get('sum_sent', {}).get('retransmits', 0)
print(f"{bps/1e6:.2f} {retr}")
except Exception as e:
print("FAIL")
PYEOF
}
# Median + min + max + CoV% + outlier indices over a whitespace-
# separated list of floats. CoV% = (stddev / mean) * 100, i.e. the
# coefficient of variation in percent — directly comparable across
# paths regardless of absolute throughput. Single-value runs print
# CoV=0.
# Prints: "<median> <min> <max> <cov_pct> <outliers_csv>"
stats() {
python3 - "$OUTLIER_PCT" "$@" <<'PYEOF'
import math
import sys
pct = float(sys.argv[1])
vals = [float(x) for x in sys.argv[2:]]
n = len(vals)
s = sorted(vals)
median = s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2
lo, hi = s[0], s[-1]
mean = sum(vals) / n if n else 0.0
if n > 1 and mean > 0:
variance = sum((v - mean) ** 2 for v in vals) / (n - 1) # sample stddev
cov_pct = math.sqrt(variance) / mean * 100
else:
cov_pct = 0.0
outliers = []
if median > 0:
for i, v in enumerate(vals):
if abs(v - median) / median * 100 > pct:
outliers.append(str(i + 1))
print(
f"{median:.2f} {lo:.2f} {hi:.2f} {cov_pct:.1f}% "
f"{','.join(outliers) if outliers else '-'}"
)
PYEOF
}
# Ping once, print "<min_ms> <avg_ms> <max_ms> <mdev_ms> <loss_pct>"
# or "FAIL".
ping_path() {
local client="$1" dest_npub="$2"
local out
if ! out=$(docker exec "fips-${client}${FIPS_CI_NAME_SUFFIX:-}" \
ping -c "$PING_COUNT" -i 0.2 -w "$((PING_COUNT * 2))" \
-q "${dest_npub}.fips" 2>&1); then
echo FAIL
return
fi
# "min/avg/max/mdev = 0.123/0.456/0.789/0.012 ms"
local rtt loss
rtt=$(echo "$out" | awk -F' = ' '/min\/avg\/max\/mdev/ {print $2}' | awk '{print $1}')
loss=$(echo "$out" | awk -F', ' '/packet loss/ {for (i=1;i<=NF;i++) if ($i ~ /packet loss/) print $i}' | awk '{print $1}')
if [ -z "$rtt" ]; then
echo FAIL
return
fi
echo "${rtt//\// } ${loss:-N/A}"
}
# ── Path definitions ───────────────────────────────────────────────────────
#
# Each entry is `<client-container> <dest-npub> <label>`. Labels are
# plain `client→dest` — the bench measures iperf3 throughput between
# the two named nodes. The static topology is printed at start so the
# operator can see the configured routing context.
#
# IMPORTANT — labels DO NOT encode hop count. With peer discovery
# enabled and all nodes on the same docker-bridge subnet, every node
# pair establishes a direct UDP path within a few ticks regardless of
# the static `peers:` list. The bench measures the post-convergence
# steady state, which is what real-world FIPS deployments see. To
# force on-wire multihop, isolate nodes on distinct docker networks.
case "$PROFILE" in
mesh|mesh-public)
# Static-peer paths only — without mDNS / a Nostr relay in the
# test container set, non-adjacent pairs (A↔B, A↔C) can't
# establish direct UDP and the bench would either fail
# convergence or measure multihop forwarding (apples-to-oranges
# vs builds that do have mDNS). Stick to pairs that are in
# each other's static `peers:` list so the measurement is
# deterministic and the same on every build.
PATHS=(
"node-a $NPUB_D A→D"
"node-a $NPUB_E A→E"
"node-e $NPUB_A E→A"
)
TOPOLOGY_LINES=(
"A peers with: D, E"
"D peers with: A, C, E"
"E peers with: A, C, D"
"(Static-peer pairs A↔D, A↔E only — non-adjacent pairs"
" are skipped because plain mesh.yaml has no discovery"
" transport that converges them onto direct UDP.)"
)
;;
chain)
# Chain topology forces intentional multihop forwarding for
# non-adjacent pairs: A peers only with B, B peers with A+C,
# etc. We bench just A→B (1 hop, direct static peer) so the
# comparison is again apples-to-apples without discovery.
PATHS=(
"node-a $NPUB_B A→B"
)
TOPOLOGY_LINES=(
"Static chain: A — B — C — D — E"
"(A↔B is the only static-peer pair we benchmark; multi-hop"
" forwarding to C/D/E needs reply-learned routing or mDNS"
" to settle, which is not deterministic on a fresh mesh.)"
)
;;
*)
echo "Unknown profile: $PROFILE" >&2; exit 1 ;;
esac
# ── Run ────────────────────────────────────────────────────────────────────
echo "=== FIPS multi-run bench ($PROFILE, ${RUNS}×${DURATION}s, P=${PARALLEL}) ==="
echo "Outlier flag: Δ from median > ${OUTLIER_PCT}%"
echo ""
echo "Topology (static \`peers:\` from $PROFILE.yaml):"
for line in "${TOPOLOGY_LINES[@]}"; do
echo " $line"
done
echo ""
# Wait for peer discovery to converge before measuring — otherwise
# the bench randomly measures either direct UDP (post-convergence
# steady state) or multihop forwarding through static peers (only
# until discovery converges). On the same docker-bridge subnet
# convergence should take seconds, not tens of seconds; this is the
# tight default. Bump FIPS_BENCH_CONVERGE_SECS for slower test setups
# (no Nostr relay, no mDNS, etc.).
#
# If any path hasn't converged by the deadline, the bench FAILS with
# a clear list of unconverged pairs — that's almost always a topology
# / discovery misconfiguration that would make the numbers noise.
CONVERGE_SECS="${FIPS_BENCH_CONVERGE_SECS:-15}"
peer_is_direct() {
local client="$1" dest_npub="$2"
docker exec "fips-${client}${FIPS_CI_NAME_SUFFIX:-}" fipsctl show peers 2>/dev/null \
| python3 -c '
import json, sys
target = sys.argv[1]
try:
d = json.load(sys.stdin)
for p in d.get("peers", []):
if p.get("npub") == target:
print("yes" if p.get("connectivity") == "connected" else "no")
sys.exit(0)
print("no")
except Exception:
print("no")
' "$dest_npub" 2>/dev/null
}
# Per-peer `stats.bytes_sent` snapshot for the given client. Used to
# verify that the iperf3 traffic actually went out the intended next-
# hop (i.e. the routing protocol picked the static-peer link, not a
# multihop alternative).
peer_bytes_sent_snapshot() {
local client="$1"
docker exec "fips-${client}${FIPS_CI_NAME_SUFFIX:-}" fipsctl show peers 2>/dev/null \
| python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
for p in d.get("peers", []):
npub = p.get("npub") or "?"
sent = (p.get("stats") or {}).get("bytes_sent", 0)
print(f"{npub} {sent}")
except Exception:
pass
'
}
# Compute deltas between two snapshots and identify which peer
# received the most bytes_sent growth, plus the absolute bytes for
# the requested target. Prints "<top_npub> <top_delta> <target_delta>".
peer_bytes_delta_winner() {
local target="$1"; shift
local before="$1"; shift
local after="$1"; shift
python3 - "$target" "$before" "$after" <<'PYEOF'
import sys
target, before, after = sys.argv[1], sys.argv[2], sys.argv[3]
b = {}
for line in before.splitlines():
if not line.strip(): continue
k, v = line.split()
b[k] = int(v)
a = {}
for line in after.splitlines():
if not line.strip(): continue
k, v = line.split()
a[k] = int(v)
deltas = {k: a.get(k, 0) - b.get(k, 0) for k in a}
target_delta = deltas.get(target, 0)
if deltas:
top_npub = max(deltas, key=deltas.get)
top_delta = deltas[top_npub]
else:
top_npub, top_delta = "-", 0
print(f"{top_npub} {top_delta} {target_delta}")
PYEOF
}
declare -A PEER_STATE
echo "Waiting for peer convergence (up to ${CONVERGE_SECS}s)…"
WAIT_START=$(date +%s)
while :; do
all_done=1
for path in "${PATHS[@]}"; do
read -r client npub label <<<"$path"
if [ "${PEER_STATE[$label]:-}" = "direct" ]; then
continue
fi
if [ "$(peer_is_direct "$client" "$npub")" = "yes" ]; then
PEER_STATE[$label]=direct
else
all_done=0
fi
done
if [ "$all_done" = 1 ]; then
break
fi
elapsed=$(( $(date +%s) - WAIT_START ))
if [ "$elapsed" -ge "$CONVERGE_SECS" ]; then
break
fi
sleep 1
done
unconverged=()
for path in "${PATHS[@]}"; do
read -r client npub label <<<"$path"
state="${PEER_STATE[$label]:-via-forward}"
PEER_STATE[$label]="$state"
printf ' %-10s %s\n' "$label" "$state"
if [ "$state" != "direct" ]; then
unconverged+=("$label")
fi
done
echo ""
if [ "${#unconverged[@]}" -gt 0 ]; then
echo "ERROR: peer discovery did not converge for ${#unconverged[@]} of ${#PATHS[@]} paths within ${CONVERGE_SECS}s:" >&2
for p in "${unconverged[@]}"; do
echo " - $p" >&2
done
echo "" >&2
echo "On a single docker-bridge subnet convergence should take seconds." >&2
echo "Check that: (a) all node IPs are reachable peer-to-peer, (b) the" >&2
echo "test build includes a working discovery transport (Nostr relay /" >&2
echo "mDNS / etc.), (c) FIPS_BENCH_CONVERGE_SECS is high enough for" >&2
echo "this setup. Bench aborted to avoid reporting noisy mixed-state" >&2
echo "measurements." >&2
exit 2
fi
# Header for text output. All bandwidth columns are Mbits/sec
# (Mbps). CoV% = sample coefficient of variation (stddev/mean) over
# the N runs, in percent — directly comparable across paths.
if [ "$OUTPUT" = "text" ]; then
printf '%-10s %-12s %12s %12s %12s %8s %10s | %10s %8s %10s\n' \
path session 'median Mbps' 'min Mbps' 'max Mbps' 'CoV %' outliers \
'avg RTT ms' 'loss %' 'TCP retr'
printf '%-10s %-12s %12s %12s %12s %8s %10s | %10s %8s %10s\n' \
'------' '------' '------' '------' '------' '------' '------' \
'------' '------' '------'
fi
FAIL_COUNT=0
ROUTE_ERRORS=()
for path in "${PATHS[@]}"; do
read -r client npub label <<<"$path"
# Snapshot per-peer bytes_sent before iperf3 — used after the
# runs to verify the routing protocol actually pushed the test
# traffic out the intended static-peer link (and not, say,
# via an alternative multihop route the cost-based router could
# in principle pick). Same defensive check the iperf-test.sh
# path measurement assumes implicitly.
bytes_before=$(peer_bytes_sent_snapshot "$client")
# iperf3 runs
bw_runs=()
retr_runs=()
for i in $(seq 1 "$RUNS"); do
result=$(iperf_once "$client" "$npub")
if [ "$result" = "FAIL" ]; then
FAIL_COUNT=$((FAIL_COUNT + 1))
bw_runs+=("0")
retr_runs+=("0")
else
bw=$(echo "$result" | awk '{print $1}')
retr=$(echo "$result" | awk '{print $2}')
bw_runs+=("$bw")
retr_runs+=("$retr")
fi
done
bytes_after=$(peer_bytes_sent_snapshot "$client")
# `delta_winner` is "<npub_with_largest_bytes_sent_delta> <its_bytes> <target_dest_bytes>"
delta_winner=$(peer_bytes_delta_winner "$npub" "$bytes_before" "$bytes_after")
read -r winning_npub winning_delta target_delta <<<"$delta_winner"
if [ "$winning_npub" != "$npub" ]; then
ROUTE_ERRORS+=("$label: traffic exited via $winning_npub ($winning_delta B) instead of $npub ($target_delta B)")
fi
# Stats on bandwidth (median / min / max / coefficient-of-variation / outlier indices)
bw_stats=$(stats "${bw_runs[@]}")
read -r bw_median bw_min bw_max bw_cov bw_outliers <<<"$bw_stats"
# Sum retransmits across runs (a proxy for packet loss volume).
retr_sum=$(python3 -c "print(sum(int(x) for x in '${retr_runs[*]}'.split()))")
# Ping (single run; ping gives its own internal stats already).
ping_result=$(ping_path "$client" "$npub")
if [ "$ping_result" = "FAIL" ]; then
rtt_avg="N/A"
loss="N/A"
else
# min avg max mdev loss
read -r _rtt_min rtt_avg _rtt_max _rtt_mdev loss <<<"$ping_result"
fi
session_state="${PEER_STATE[$label]:-via-forward}"
if [ "$OUTPUT" = "json" ]; then
printf '{"path":"%s","client":"%s","dest_npub":"%s","session":"%s","mbps_runs":[%s],"mbps_median":%s,"mbps_min":%s,"mbps_max":%s,"mbps_cov_pct":"%s","outlier_runs":"%s","rtt_avg_ms":"%s","loss":"%s","tcp_retr_total":%s}\n' \
"$label" "$client" "$npub" "$session_state" \
"$(IFS=, ; echo "${bw_runs[*]}")" \
"$bw_median" "$bw_min" "$bw_max" "$bw_cov" "$bw_outliers" \
"$rtt_avg" "$loss" "$retr_sum"
else
printf '%-10s %-12s %12s %12s %12s %8s %10s | %10s %8s %10s\n' \
"$label" "$session_state" "$bw_median" "$bw_min" "$bw_max" \
"$bw_cov" "$bw_outliers" \
"$rtt_avg" "$loss" "$retr_sum"
if [ "$bw_outliers" != "-" ]; then
printf '%-10s runs (Mbps): %s\n' " ↑outliers" "${bw_runs[*]}"
fi
fi
done
echo ""
if [ "${#ROUTE_ERRORS[@]}" -gt 0 ]; then
echo "ERROR: traffic for ${#ROUTE_ERRORS[@]} of ${#PATHS[@]} paths did not exit via the intended static-peer link:" >&2
for e in "${ROUTE_ERRORS[@]}"; do
echo " - $e" >&2
done
echo "" >&2
echo "The numbers above measured a different route than the path label" >&2
echo "claims. Inspect peer cost / tree topology before trusting them." >&2
exit 3
fi
if [ "$FAIL_COUNT" -gt 0 ]; then
echo "WARN: $FAIL_COUNT iperf3 runs failed"
exit 1
fi
echo "OK"