Files
fips/testing/iface-binding/test.sh
T
Arjen b8c4a5584f test(iface-binding): cover an interface present before the daemon starts
Every scenario in the suite created its interface after the daemons were
already running — that ordering is the boot race the suite was written for.
But it means both nodes could only ever reach Present through binder_loop,
so the inline bind in start_async, which is the ordinary case on a booted
router, had no end-to-end coverage at all. That is where the churn guard
went unseeded and the first detach stopped reaching node health, and no
existing case could reach it: they all detach from a binding the loop
created, which seeds the guard as a side effect.

Case (f) adds a third node whose single required interface exists before its
daemon does. The gate is what buys that ordering — the harness needs a
running container to have a netns to move a veth into, but the daemon must
not start until after the move, so node-c comes up parked on a file and the
harness releases it once the interface is in place. Then one detach, on a
binding the loop did not create, and the node must degrade.

Verified against the defect rather than only against the fix: with the guard
seed reverted, cases (a) through (e) all still pass and (f) is the only
failure. A regression test that has never been seen to fail is a claim, not
a test.

It also asserts the reverse edge, so Degraded stays a level rather than a
latch on this path too.
2026-09-01 13:17:03 +01:00

511 lines
21 KiB
Bash
Executable File

#!/bin/bash
# Integration test for dynamic interface binding.
#
# Asserts the five behaviors the presence machine exists to provide, against
# real daemons and a real veth pair:
#
# (a) boot race — a daemon whose interface does not exist yet starts,
# reports the transport ABSENT, and reports Degraded
# (b) late attach — the interface appears; the daemon binds it with no
# restart, clears Degraded, and peers over it
# (c) flap — the interface goes down and comes back; presence and
# health follow it in BOTH directions
# (d) destroy/recreate — the interface is deleted outright and recreated; the
# daemon rebinds (the case the old ENXIO beacon hack
# half-covered)
# (e) optional — an interface that never appears logs at info and
# never moves node health
#
# plus the log-hygiene property the design is explicit about: absence is logged
# once on the edge, never once per retry.
#
# The daemons run under FIPS_TEST_MODE=default, NOT chaos: the chaos entrypoint
# waits for Ethernet interfaces before starting the daemon, which is exactly
# the workaround being retired. The daemon must wait for itself here.
#
# Usage: ./test.sh [--skip-build] [--keep-up]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TESTING_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml"
NODE_A="fips-ifb-node-a${FIPS_CI_NAME_SUFFIX:-}"
NODE_B="fips-ifb-node-b${FIPS_CI_NAME_SUFFIX:-}"
NODE_C="fips-ifb-node-c${FIPS_CI_NAME_SUFFIX:-}"
# The interface each node binds. Same name on both sides: they are in separate
# network namespaces, and using one name keeps the fixtures identical.
LAB_IFACE="ve-lab0"
# The interface that never exists. `optional: true` in both configs.
DOCK_IFACE="fips-dock0"
# node-c's interface, present before its daemon starts. See case (f).
BOOT_IFACE="ve-boot0"
# Host-side veth names, scoped per run: these live in the host (or Docker VM)
# namespace for the moment between creation and the move into the containers,
# where two concurrent runs would otherwise collide on one name.
HOST_VETH_A="vhifb${FIPS_CI_NAME_SUFFIX:-0}a"
HOST_VETH_B="vhifb${FIPS_CI_NAME_SUFFIX:-0}b"
HOST_VETH_C="vhifb${FIPS_CI_NAME_SUFFIX:-0}c"
HOST_VETH_D="vhifb${FIPS_CI_NAME_SUFFIX:-0}d"
SKIP_BUILD=false
KEEP_UP=false
while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=true; shift ;;
--keep-up) KEEP_UP=true; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
log() { echo "=== $*"; }
pass() { echo "PASS: $*"; }
fail() { echo "FAIL: $*" >&2; dump_diagnostics; exit 1; }
dump_diagnostics() {
echo "--- node-a transports ---" >&2
docker exec "$NODE_A" fipsctl show transports >&2 2>&1 || true
echo "--- node-a status ---" >&2
docker exec "$NODE_A" fipsctl show status >&2 2>&1 || true
echo "--- node-a log (tail) ---" >&2
docker logs --tail 80 "$NODE_A" >&2 2>&1 || true
echo "--- node-b log (tail) ---" >&2
docker logs --tail 80 "$NODE_B" >&2 2>&1 || true
return 0
}
cleanup() {
# Remove the veth pair wherever it survived: inside a container if the move
# succeeded, on the host if the run died between creation and the move.
docker exec "$NODE_A" ip link del "$LAB_IFACE" >/dev/null 2>&1 || true
ip_host "ip link del $HOST_VETH_A" >/dev/null 2>&1 || true
docker exec "$NODE_C" ip link del "$BOOT_IFACE" >/dev/null 2>&1 || true
ip_host "ip link del $HOST_VETH_C" >/dev/null 2>&1 || true
if [ "$KEEP_UP" = false ]; then
docker compose -f "$COMPOSE_FILE" down --volumes --remove-orphans >/dev/null 2>&1 || true
fi
return 0
}
# ── Host-namespace ip(8) ─────────────────────────────────────────────────
#
# Every `ip link` operation that touches the host network stack runs inside a
# short-lived privileged container sharing the host network and PID namespaces,
# for the reason testing/chaos/sim/veth.py documents at length: on macOS the
# containers live in the Docker VM, so running ip(8) on the macOS host could
# never reach them, while on Linux the shared namespaces make it identical to
# running ip(8) directly.
ip_host() {
docker run --rm --privileged --network host --pid host \
--entrypoint /bin/sh "$IMAGE" -c "$1"
}
# Docker's own view of a container, so the gate case can wait for a netns
# without implying the daemon inside it has started.
container_state() {
docker inspect -f '{{.State.Status}}' "$1" 2>/dev/null || true
}
container_pid() {
docker inspect -f '{{.State.Pid}}' "$1"
}
# Create the veth pair and move one end into each container.
create_veth() {
local pid_a pid_b
pid_a="$(container_pid "$NODE_A")"
pid_b="$(container_pid "$NODE_B")"
# One invocation, not three. The host-side names exist only between the
# `add` and the two `netns` moves, and ci-cleanup.sh's host-veth sweep is
# deliberately shaped to the chaos simulation's names and does not cover
# these — so the window in which a hard kill could strand them is kept to
# a single command, with the EXIT trap covering the rest.
ip_host "set -e
ip link add $HOST_VETH_A type veth peer name $HOST_VETH_B
ip link set $HOST_VETH_A netns $pid_a name $LAB_IFACE
ip link set $HOST_VETH_B netns $pid_b name $LAB_IFACE" >/dev/null
# A moved link arrives down. Presence is IFF_UP, so the daemon correctly
# does not bind until this runs — which is also why (c) can flap it with
# nothing but `ip link set down`.
docker exec "$NODE_A" ip link set "$LAB_IFACE" up
docker exec "$NODE_B" ip link set "$LAB_IFACE" up
return 0
}
# ── Daemon introspection ─────────────────────────────────────────────────
# Field of a named transport's `interface` block, or "" if the transport, the
# block, or the daemon is not there.
iface_field() {
docker exec "$1" fipsctl show transports 2>/dev/null | python3 -c '
import json, sys
try:
data = json.load(sys.stdin)
except Exception:
print(""); raise SystemExit
for t in data.get("transports", []):
if t.get("name") == sys.argv[1]:
print(t.get("interface", {}).get(sys.argv[2], ""))
break
else:
print("")
' "$2" "$3"
}
node_state() {
docker exec "$1" fipsctl show status 2>/dev/null | python3 -c '
import json, sys
try:
print(json.load(sys.stdin).get("state", ""))
except Exception:
print("")
'
}
peer_count() {
docker exec "$1" fipsctl show peers 2>/dev/null | python3 -c '
import json, sys
try:
data = json.load(sys.stdin)
except Exception:
print(0); raise SystemExit
print(len(data.get("peers", [])))
'
}
# Count of a literal in a container log. Used for the log-hygiene assertion.
log_count() {
docker logs "$1" 2>&1 | grep -c -- "$2" || true
}
# Poll `expr` until it prints `want`, up to `timeout` seconds.
# Usage: wait_for <timeout> <want> <command...>
wait_for() {
local timeout="$1" want="$2"; shift 2
local i got
for i in $(seq 1 "$timeout"); do
got="$("$@" || true)"
if [ "$got" = "$want" ]; then
return 0
fi
sleep 1
done
echo " (last value: '${got:-}', wanted '$want')" >&2
return 1
}
# Poll until the command prints a value that is at least `want`.
wait_for_at_least() {
local timeout="$1" want="$2"; shift 2
local i got
for i in $(seq 1 "$timeout"); do
got="$("$@" || true)"
if [ -n "$got" ] && [ "$got" -ge "$want" ] 2>/dev/null; then
return 0
fi
sleep 1
done
echo " (last value: '${got:-}', wanted >= '$want')" >&2
return 1
}
# ── Run ──────────────────────────────────────────────────────────────────
IMAGE="${FIPS_TEST_IMAGE:-fips-test:latest}"
trap cleanup EXIT
if [ "$SKIP_BUILD" = false ]; then
log "Building test image"
bash "$TESTING_DIR/scripts/build.sh"
fi
log "Generating fixtures"
LAB_IFACE="$LAB_IFACE" DOCK_IFACE="$DOCK_IFACE" BOOT_IFACE="$BOOT_IFACE" \
bash "$SCRIPT_DIR/generate-configs.sh"
log "Starting nodes with $LAB_IFACE absent"
docker compose -f "$COMPOSE_FILE" up -d
# The daemon must reach a serving state without the interface. Waiting on the
# control socket answering at all is the first half of assertion (a): a daemon
# that exited on `NoTransports` never answers.
if ! wait_for 40 "degraded" node_state "$NODE_A"; then
fail "(a) node-a did not come up Degraded with $LAB_IFACE absent"
fi
pass "(a) daemon started and serves with its only interface absent"
# ── (a) presence and policy are visible to an operator ───────────────────
[ "$(iface_field "$NODE_A" lab presence)" = "absent" ] \
|| fail "(a) lab transport is not reported ABSENT"
[ "$(iface_field "$NODE_A" lab policy)" = "required" ] \
|| fail "(a) lab transport is not reported required"
[ "$(iface_field "$NODE_A" lab name)" = "$LAB_IFACE" ] \
|| fail "(a) lab transport does not name $LAB_IFACE"
[ "$(iface_field "$NODE_A" dock presence)" = "absent" ] \
|| fail "(e) dock transport is not reported ABSENT"
[ "$(iface_field "$NODE_A" dock policy)" = "optional" ] \
|| fail "(e) dock transport is not reported optional"
pass "(a) presence and policy are visible in show_transports"
# ── log hygiene, measured across the absence ─────────────────────────────
#
# The required interface's absence must be logged once, on the edge — not once
# per retry. Measured over an interval long enough for many retries (the binder
# polls every second). The 12 s also carries the next assertion past the 10 s
# bring-up window, so both the edge rule and the deadline are covered by one
# wait.
# Matched on the edge line specifically. The deadline error below is a
# different line by design, and counting both here would read the deadline
# firing during the sleep as a repeated edge.
EDGE_LINE="Ethernet interface absent; waiting"
absent_before="$(log_count "$NODE_A" "$EDGE_LINE")"
sleep 12
absent_after="$(log_count "$NODE_A" "$EDGE_LINE")"
if [ "$absent_after" -ne "$absent_before" ]; then
fail "absence was logged $((absent_after - absent_before)) more times over 12s of retries; \
the edge must be logged once, not per attempt"
fi
# Two edges total: one for the required lab interface, one for the optional
# dock interface. More would mean the edge is not an edge.
[ "$absent_before" -eq 2 ] \
|| fail "expected exactly 2 absence edges at start, saw $absent_before"
pass "absence is logged once on the edge, not per retry"
# ── a sustained absence errors exactly once ──────────────────────────────
#
# The edge is not an error: an interface missing for a moment at boot and bound
# a moment later is the ordinary case this mechanism exists to absorb. Past the
# 10 s bring-up window it is no longer a race, and a *required* interface says
# so — once. The 12 s above put us on the far side of that window.
#
# Exactly one line, from lab. The optional dock interface has been absent just
# as long and must be silent, which is what `optional` means; two here would
# mean the policy is not being consulted.
startup_errors="$(log_count "$NODE_A" " ERROR ")"
if [ "$startup_errors" -ne 1 ]; then
docker logs "$NODE_A" 2>&1 | grep -- " ERROR " >&2 || true
fail "expected exactly 1 ERROR line for the required interface past the \
bring-up window, saw $startup_errors (an optional interface must contribute none)"
fi
[ "$(log_count "$NODE_A" "still missing past the bring-up window")" -eq 1 ] \
|| fail "the ERROR line is not the sustained-absence report"
pass "a required interface absent past the window errors, an optional one does not"
# ...and does not keep saying it. Duration is state, published as
# interface.since_secs and as Degraded; re-announcing it on a timer is what
# the old 1 m / 10 m / 1 h ladder did.
sleep 12
[ "$(log_count "$NODE_A" " ERROR ")" -eq 1 ] \
|| fail "the sustained-absence error repeated; it must be said once per episode"
pass "the sustained-absence error is said once, not on a schedule"
# ── (b) late attach ──────────────────────────────────────────────────────
log "Creating the veth pair"
create_veth
if ! wait_for 30 "present" iface_field "$NODE_A" lab presence; then
fail "(b) node-a did not bind $LAB_IFACE after it appeared"
fi
if ! wait_for 30 "present" iface_field "$NODE_B" lab presence; then
fail "(b) node-b did not bind $LAB_IFACE after it appeared"
fi
pass "(b) both daemons bound the interface with no restart"
# Health must clear. This is `Degraded` behaving as a level rather than a
# latch — the property that made the old monotonic failed-set wrong.
if ! wait_for 20 "running" node_state "$NODE_A"; then
fail "(b) node-a stayed Degraded after its interface returned"
fi
pass "(b) Degraded cleared when the interface came back"
# The optional interface is still absent and must still not matter.
[ "$(iface_field "$NODE_A" dock presence)" = "absent" ] \
|| fail "(e) dock unexpectedly bound"
pass "(e) an absent optional interface does not degrade the node"
# Discovery and peering over the late-bound interface: the point of binding at
# all. Without this the suite would prove the daemon can open a socket, not
# that traffic flows over it.
if ! wait_for_at_least 45 1 peer_count "$NODE_A"; then
fail "(b) node-a found no peer over the late-bound interface"
fi
if ! wait_for_at_least 45 1 peer_count "$NODE_B"; then
fail "(b) node-b found no peer over the late-bound interface"
fi
pass "(b) nodes discovered and peered over the late-bound interface"
# ── (c) flap ─────────────────────────────────────────────────────────────
errors_before_detach="$(log_count "$NODE_A" " ERROR ")"
log "Taking $LAB_IFACE down on node-a"
docker exec "$NODE_A" ip link set "$LAB_IFACE" down
detach_start=$SECONDS
if ! wait_for 20 "absent" iface_field "$NODE_A" lab presence; then
fail "(c) node-a did not notice the interface going down"
fi
detach_elapsed=$(( SECONDS - detach_start ))
if ! wait_for 20 "degraded" node_state "$NODE_A"; then
fail "(c) node-a did not report Degraded while its interface was down"
fi
pass "(c) a link going down is observed as absence and degrades health"
# A detach is reported, at warn. The edge is not an error — a link coming and
# going is the weather in a mesh daemon, and the error is the 10 s deadline's
# to give, not the edge's.
if ! wait_for_at_least 10 1 log_count "$NODE_A" "Ethernet interface detached"; then
fail "(c) a runtime detach was not reported"
fi
# Only meaningful while we are still inside the bring-up window. Detection is
# sub-second over netlink, so this is the ordinary path; if the runner was slow
# enough that the deadline could have fired, the check has nothing to say and
# says so rather than failing on the harness's own latency.
if [ "$detach_elapsed" -lt 8 ]; then
detach_errors="$(log_count "$NODE_A" " ERROR ")"
if [ "$detach_errors" -ne "$errors_before_detach" ]; then
docker logs "$NODE_A" 2>&1 | grep -- " ERROR " >&2 || true
fail "(c) the detach edge logged an ERROR after ${detach_elapsed}s; the \
edge is a warn and only outlasting the window earns an error"
fi
pass "(c) a detach is reported without crying error"
else
echo " (skipped the edge-not-an-error check: detach took ${detach_elapsed}s,"
echo " which is inside the deadline's reach)"
fi
log "Bringing $LAB_IFACE back up on node-a"
docker exec "$NODE_A" ip link set "$LAB_IFACE" up
if ! wait_for 30 "present" iface_field "$NODE_A" lab presence; then
fail "(c) node-a did not rebind after the interface came back"
fi
if ! wait_for 20 "running" node_state "$NODE_A"; then
fail "(c) node-a stayed Degraded after the interface came back"
fi
if ! wait_for_at_least 45 2 iface_field "$NODE_A" lab binds; then
fail "(c) the rebind was not counted"
fi
pass "(c) the interface flapped and the daemon followed it both ways"
# ── (d) destroy and recreate ─────────────────────────────────────────────
#
# Deleting the netdev outright is the case the old ENXIO beacon-socket reopen
# half-covered: the veth is gone, the socket underneath is stale, and the name
# comes back a moment later. One presence machine now covers it.
log "Deleting and recreating the veth pair"
docker exec "$NODE_A" ip link del "$LAB_IFACE"
if ! wait_for 20 "absent" iface_field "$NODE_A" lab presence; then
fail "(d) node-a did not notice the interface being deleted"
fi
if ! wait_for 20 "absent" iface_field "$NODE_B" lab presence; then
fail "(d) node-b did not notice its end of the pair disappearing"
fi
create_veth
if ! wait_for 30 "present" iface_field "$NODE_A" lab presence; then
fail "(d) node-a did not rebind the recreated interface"
fi
if ! wait_for 30 "present" iface_field "$NODE_B" lab presence; then
fail "(d) node-b did not rebind the recreated interface"
fi
if ! wait_for 20 "running" node_state "$NODE_A"; then
fail "(d) node-a stayed Degraded after the interface was recreated"
fi
pass "(d) a destroyed and recreated interface is rebound"
# Peering must re-establish over the new hardware. A recreated veth has a new
# MAC, so this also exercises the "same name, different hardware" path that
# drops cached neighbors instead of resuming onto them.
if ! wait_for_at_least 60 1 peer_count "$NODE_A"; then
fail "(d) node-a did not re-peer after the interface was recreated"
fi
pass "(d) peering re-established over the recreated interface"
# ── (f) an interface present before the daemon starts ────────────────────
#
# Everything above binds through the binder loop, because the interface does
# not exist until the harness makes it. The ordinary case on a booted router is
# the opposite one: the interface is already there and `start_async` binds it
# inline, before the loop is running.
#
# That path published its presence edge outside the churn guard, so the guard
# believed it had announced nothing and the *first* detach asked for no
# retraction. The node kept reporting Running with its only required interface
# gone, and stayed that way until a second detach happened to repair the guard.
# Nothing in cases (a)-(e) can reach it.
log "(f) starting node-c with its interface already present"
docker compose -f "$COMPOSE_FILE" up -d node-c
# The container parks on the gate, so this is the netns and not yet the daemon.
if ! wait_for 30 "running" container_state "$NODE_C"; then
fail "(f) node-c container did not start"
fi
pid_c="$(container_pid "$NODE_C")"
ip_host "set -e
ip link add $HOST_VETH_C type veth peer name $HOST_VETH_D
ip link set $HOST_VETH_C netns $pid_c name $BOOT_IFACE
ip link set $HOST_VETH_D netns $pid_c name ${BOOT_IFACE}p" >/dev/null
docker exec "$NODE_C" ip link set "$BOOT_IFACE" up
docker exec "$NODE_C" ip link set "${BOOT_IFACE}p" up
# Release the gate. The daemon now starts with the interface already up.
docker exec "$NODE_C" touch /tmp/fips-go
if ! wait_for 40 "running" node_state "$NODE_C"; then
fail "(f) node-c did not come up Running with its interface present at start"
fi
[ "$(iface_field "$NODE_C" boot presence)" = "present" ] \
|| fail "(f) node-c did not bind $BOOT_IFACE inline at start"
pass "(f) an interface present at start is bound inline and reports Running"
# The assertion. One detach, on a binding this loop did not create.
docker exec "$NODE_C" ip link set "$BOOT_IFACE" down
if ! wait_for 30 "absent" iface_field "$NODE_C" boot presence; then
fail "(f) node-c did not notice $BOOT_IFACE going down"
fi
if ! wait_for 30 "degraded" node_state "$NODE_C"; then
fail "(f) node-c stayed Running after its only required interface went \
away — the start-time bind never reached node health"
fi
pass "(f) the first detach after a clean start degrades the node"
# And it is still a level, not a latch, on this path too.
docker exec "$NODE_C" ip link set "$BOOT_IFACE" up
if ! wait_for 30 "running" node_state "$NODE_C"; then
fail "(f) node-c stayed Degraded after its interface returned"
fi
pass "(f) health clears again when the interface returns"
# ── final log hygiene ────────────────────────────────────────────────────
#
# Four outages happened above (start, down, delete, and node-b's end of the
# delete). A generous ceiling still catches the failure mode that matters: a
# retry loop logging per attempt would be in the hundreds by now.
edges="$(log_count "$NODE_A" "Ethernet interface")"
[ "$edges" -lt 40 ] \
|| fail "node-a logged $edges interface lines; the edges are not edges"
pass "log volume stayed proportional to edges, not to retries"
echo
echo "ALL PASSED"