testing/mesh-lab: add bloom-storm chaos suite dispatch with CPU-pinning sidecar

Wires the bloom-storm chaos scenario into the mesh-lab harness as
a first-class suite, with optional per-container CPU pinning to
mimic GitHub Actions' 2-core ubuntu-latest budget.

Dispatch path — three new run-loop.sh functions plus the
dispatch_suite and dispatch_mechanism_match case-arm additions:

- `run_bloom_storm` invokes `bash testing/chaos/scripts/chaos.sh
  bloom-storm` and captures stdout+stderr into the rep's
  test-output.log. Chaos uses its own python sim runner
  (`python3 -m sim`), not docker-compose, so this suite gets no
  per-container compose override, no separate `docker logs`
  capture, and no in-container netem injection — the chaos
  scenario yaml owns its own netem and link-swap config.

- `parse_bloom_storm` extracts the bloom_send_rate result
  (pass/fail/unknown), ceiling, max-observed per-node delta,
  offenders list, full per-node delta distribution, the companion
  min_parent_switches result, and panic + error counts. Lands in
  the rep's signature.json. Two parser details: assertion greps
  are anchored on `^(PASS|FAIL)` so they only match the bare
  end-of-run summary line, not python-logger-prefixed lines that
  contain the same substring; and `grep -c` panic/error counts
  use `; true` + a defensive empty-string check instead of the
  common `|| echo 0` fallback (`grep -c` exits 1 on zero matches
  while also printing "0", so the fallback would corrupt the
  count to "0\\n0").

- `mechanism_match_bloom_storm` returns true when a rep both
  fails the bloom_send_rate assertion and the FAIL line carries
  a named offender (filtering the harness-side "failed to sample
  window endpoints" sub-failure out of the mechanism count).

CPU-pinning sidecar — bloom-storm's chaos sim spawns containers
directly via the docker SDK, so the mesh-lab compose-resource-
limits override does not apply. A poll-and-pin loop around the
chaos.sh invocation lists \`fips-*\` containers every 0.5 s and
applies \`docker update --cpuset-cpus <set>\` to each. Pinning is
idempotent (re-applying the same cpuset is a no-op). Default
cpuset \`0,1\` mimics the GHA 2-core budget; override via
\`FIPS_BLOOM_STORM_CPUSET=<set>\` (any comma-separated CPU list),
or set to the empty string to disable. Only applies to the
bloom-storm suite; other suites' dispatch paths are unchanged.

README's "Suites supported" entry covers the assertion class, and
the \`FIPS_BLOOM_STORM_CPUSET\` knob is documented alongside the
other mesh-lab env-var knobs.
This commit is contained in:
Johnathan Corgan
2026-05-24 12:25:09 +00:00
parent de78c94d58
commit ce0eb71722
2 changed files with 192 additions and 3 deletions
+18 -2
View File
@@ -53,8 +53,15 @@ Initial target set:
- `rekey`, `rekey-accept-off`, `rekey-outbound-only` — rekey-suite
Phase 5 post-second-rekey connectivity flake class.
- `nat-lan` — two-node NAT-traversal handshake-completion flake class.
- `bloom-storm` — chaos scenario; logged for future opportunistic
checks, not in the initial active scope.
- `bloom-storm` — chaos scenario; covers the
[ISSUE-2026-0026](../../../prj/fips/issues/ISSUE-2026-0026.md)
`bloom_send_rate` per-node ceiling exceedance class. Note that
chaos uses its own python sim runner (not docker-compose), so the
mesh-lab `compose-resource-limits.yml` and `compose-trace.yml`
overrides do not apply to this suite; per-rep evidence comes from
the captured `test-output.log` and the parsed `signature.json`
(which extracts the `bloom_send_rate` and `min_parent_switches`
assertion outcomes plus per-node delta distribution).
Adding more is straightforward — see the `dispatch_suite` function in
[run-loop.sh](run-loop.sh).
@@ -100,6 +107,15 @@ each rep does, set them in the invoking shell:
evidence collection for routing-state stalls effectively
impossible.
- **`FIPS_BLOOM_STORM_CPUSET`** — comma-separated CPU set for the
bloom-storm dispatch's container-pinning sidecar (default
`0,1`). The sidecar polls for `fips-*` containers as the chaos
sim spawns them and applies `docker update --cpuset-cpus <set>`
to each, mimicking the 2-core constraint of a GHA
`ubuntu-latest` runner. Set to a wider set (e.g. `0,1,2,3`) to
relax, or to the empty string to disable the sidecar entirely.
Only applies to the `bloom-storm` suite; other suites ignore it.
- **`FIPS_MESH_LAB_RUNS_DIR`** — root directory for harness output
(the `runs/<timestamp>/` tree). When unset, the harness falls back
to an in-tree path under `testing/mesh-lab/` and prints a warning
+174 -1
View File
@@ -376,6 +376,172 @@ mechanism_match_nat_lan() {
fi
}
# ── bloom-storm ──────────────────────────────────────────────────────
# The bloom-storm chaos scenario (see
# testing/chaos/scenarios/bloom-storm.yaml) drives a six-node mesh
# with an induced n04 parent-flap and asserts a per-node ceiling
# on `stats.bloom.sent` deltas over the trailing 30s window of the
# 180s run. The flake class tracked here is a single node spiking
# above the ceiling while peers stay well under (asymmetric
# distribution), seen on master CI as ISSUE-2026-0026.
#
# Unlike rekey and nat-lan, chaos doesn't use docker-compose — the
# python sim runner under `python3 -m sim` owns the container
# lifecycle. So the dispatch is a thin wrapper: invoke chaos.sh,
# capture stdout/stderr, and parse the assertion outcomes from the
# captured log. Per-container docker logs aren't separately exposed
# by the sim, so the test-output.log is the primary evidence stream.
run_bloom_storm() {
local REP_DIR="$1"
local rc=0
# Optional CPU-pinning sidecar. Chaos spawns containers under
# `python3 -m sim`, not docker-compose, so the mesh-lab
# `compose-resource-limits.yml` override does not apply. The
# cheapest way to constrain the actual daemon containers' CPU
# allocation is to poll for `fips-*` containers as the sim
# spawns them and apply `docker update --cpuset-cpus <set>` to
# each. Pinning is idempotent — re-applying the same cpuset to
# a container that already has it is a no-op. The default
# `0,1` mimics a GHA 2-core runner constraint; set the env var
# to a wider set (e.g. `0,1,2,3`) to relax, or to the empty
# string to disable the sidecar and run with the host's full
# CPU set.
local cpuset="${FIPS_BLOOM_STORM_CPUSET-0,1}"
local pinning_pid=""
if [ -n "$cpuset" ]; then
(
while true; do
for c in $(docker ps --filter "name=fips-" --format '{{.Names}}' 2>/dev/null); do
docker update --cpuset-cpus "$cpuset" "$c" >/dev/null 2>&1 || true
done
sleep 0.5
done
) &
pinning_pid=$!
echo "bloom-storm: cpu-pinning sidecar PID $pinning_pid (cpuset=$cpuset)" \
>"$REP_DIR/setup.log"
fi
(
cd "$REPO_ROOT" || exit 1
bash testing/chaos/scripts/chaos.sh bloom-storm
) >"$REP_DIR/test-output.log" 2>&1 || rc=$?
if [ -n "$pinning_pid" ]; then
kill "$pinning_pid" 2>/dev/null || true
wait "$pinning_pid" 2>/dev/null || true
fi
return "$rc"
}
parse_bloom_storm() {
local REP_DIR="$1"
local log="$REP_DIR/test-output.log"
# bloom_send_rate assertion. The sim runner emits the assertion
# in two forms — once through the python logger (prefixed with
# `HH:MM:SS INFO sim.runner: `) and once as a bare summary line
# at end-of-run. Anchor on `^(PASS|FAIL)` so we always read the
# bare line, not the timestamped logger line. Output shapes
# (testing/chaos/sim/assertions.py):
# PASS bloom_send_rate: max per-node delta N <= ceiling M over trailing Ss (per-node: n01=X, ...)
# FAIL bloom_send_rate: K node(s) exceeded ceiling of M bloom_sent over trailing Ss — offenders: nXX=Y, ... (all per-node deltas: ...)
local bsr_line
bsr_line=$(grep -E '^(PASS|FAIL) bloom_send_rate:' "$log" | head -1 || true)
local bsr_result="unknown"
local bsr_offenders=""
local bsr_deltas=""
local bsr_ceiling=""
local bsr_max_obs=""
if [[ -n "$bsr_line" ]]; then
if [[ "$bsr_line" == FAIL* ]]; then
bsr_result="fail"
bsr_offenders=$(echo "$bsr_line" \
| sed -n 's/.*offenders: \(.*\) (all per-node.*/\1/p' \
| sed 's/^ *//;s/ *$//')
bsr_deltas=$(echo "$bsr_line" \
| sed -n 's/.*all per-node deltas: \([^)]*\).*/\1/p' \
| sed 's/^ *//;s/ *$//')
bsr_ceiling=$(echo "$bsr_line" \
| grep -oE 'ceiling of [0-9]+' | grep -oE '[0-9]+' | head -1)
elif [[ "$bsr_line" == PASS* ]]; then
bsr_result="pass"
bsr_deltas=$(echo "$bsr_line" \
| sed -n 's/.*(per-node: \([^)]*\)).*/\1/p' \
| sed 's/^ *//;s/ *$//')
bsr_ceiling=$(echo "$bsr_line" \
| grep -oE 'ceiling [0-9]+' | grep -oE '[0-9]+' | head -1)
bsr_max_obs=$(echo "$bsr_line" \
| grep -oE 'max per-node delta [0-9]+' | grep -oE '[0-9]+' | head -1)
fi
fi
# Companion assertion (always present in bloom-storm scenario).
# Same `^(PASS|FAIL)` anchoring as bloom_send_rate above.
local mps_line
mps_line=$(grep -E '^(PASS|FAIL) min_parent_switches:' "$log" | head -1 || true)
local mps_result="unknown"
if [[ "$mps_line" == PASS* ]]; then
mps_result="pass"
elif [[ "$mps_line" == FAIL* ]]; then
mps_result="fail"
fi
# Global negative checks. Use `grep | wc -l` instead of `grep -c`:
# grep -c returns exit 1 on zero matches, which makes `|| echo 0`
# fire alongside grep's own `0` stdout, emitting `0\n0` and
# corrupting the JSON. `grep | wc -l` exits 0 either way and
# emits exactly one number.
local panics errors
panics=$(grep -cE 'PANIC|panicked' "$log" 2>/dev/null; true)
[[ -z "$panics" ]] && panics=0
errors=$(grep -cE '\bERROR\b' "$log" 2>/dev/null; true)
[[ -z "$errors" ]] && errors=0
# JSON-safe: shell-quote any field that could embed special chars
# by writing as JSON strings; the per-node delta string can have
# commas but no quotes/backslashes (sim runner output).
cat <<EOF >"$REP_DIR/signature.json"
{
"suite": "bloom-storm",
"bloom_send_rate": {
"result": "$bsr_result",
"ceiling": "$bsr_ceiling",
"max_observed": "$bsr_max_obs",
"offenders": "$bsr_offenders",
"per_node_deltas": "$bsr_deltas"
},
"min_parent_switches": {
"result": "$mps_result"
},
"panics": $panics,
"errors": $errors
}
EOF
}
mechanism_match_bloom_storm() {
local REP_DIR="$1"
local log="$REP_DIR/test-output.log"
# The ISSUE-2026-0026 mechanism is a bloom_send_rate FAIL with
# at least one named offender (i.e., not the "failed to sample
# window endpoints" sub-failure mode, which is harness rather
# than mechanism). The asymmetric-distribution check (one node
# spiking while peers stay under) is implicit in the FAIL
# shape: the assertion only fires when at least one node is
# over while at least one other is at or under the ceiling.
if grep -qE '^FAIL bloom_send_rate:' "$log" 2>/dev/null \
&& grep -qE 'offenders: [a-z][0-9]+=[0-9]+' "$log" 2>/dev/null; then
echo "true"
else
echo "false"
fi
}
# Dispatch — returns rc of suite, side-effects signature.json
dispatch_suite() {
local REP_DIR="$1"
@@ -390,9 +556,14 @@ dispatch_suite() {
run_nat_lan "$REP_DIR" || rc=$?
parse_nat_lan "$REP_DIR"
return "$rc" ;;
bloom-storm)
local rc=0
run_bloom_storm "$REP_DIR" || rc=$?
parse_bloom_storm "$REP_DIR"
return "$rc" ;;
*)
echo "ERROR: unsupported suite '$SUITE' in this lab harness (initial scaffolding)" >&2
echo "Supported: rekey, rekey-accept-off, rekey-outbound-only, nat-lan" >&2
echo "Supported: rekey, rekey-accept-off, rekey-outbound-only, nat-lan, bloom-storm" >&2
return 99 ;;
esac
}
@@ -405,6 +576,8 @@ dispatch_mechanism_match() {
mechanism_match_rekey "$REP_DIR" ;;
nat-lan)
mechanism_match_nat_lan "$REP_DIR" ;;
bloom-storm)
mechanism_match_bloom_storm "$REP_DIR" ;;
*)
echo "unknown" ;;
esac