Files
cozystack/hack/capture-dataplane.bats
Aleksei Sviridkin 18be4ee272 test(e2e): capture host-netns address attribution and a healthy-pod reference
The collector characterises the host->local-pod route for an affected pod
through ovn0 alone, so when a pod IP turns out to be a local address of the
host netns instead, nothing records which interface actually holds it. The
host netns capture now also takes `ip -o addr show` across every interface
and `ip route show table local`, so the owner can be named on the next
occurrence rather than only ruled out on ovn0.

The collector also inspects NotReady pods only, so a run has no same-window
baseline for what a normal route and conntrack state look like on the same
node. The per-pod capture becomes a reusable function and runs a second time
against one Ready, Running, non-hostNetwork pod on that node, into the same
file, so the comparison is in front of whoever opens it. hostNetwork is
excluded because such a pod's podIP IS the node's own address and would show
the same local-route fingerprint as the fault for an entirely ordinary
reason.

That baseline depends on the node and nothing else -- the candidates are the
rows of the one snapshot the affected set was computed from, and a pod
reporting Ready there is by definition not one this run is investigating --
so it is captured once per node and folded into each affected pod's file
from a cache. Several affected pods on one node is what a wedged
host->local-pod datapath looks like rather than an edge case, and capturing
the same healthy pod once per affected pod would spend the caller's envelope
re-asking a question already answered -- the cost the node-global dedup and
the lookup memo beside it exist to avoid.

This adds no read of its own. The cluster-wide pod list gains one column
instead of being asked a second time, and that single read's status is what
lets the artifact separate a node with no eligible pod from a list that
never answered: one line for both would make an unchecked baseline read as
an established absence. The two host-netns additions are best-effort execs,
like the captures they sit beside. The new row filter carries the field
count its jsonpath emits, so a list cut mid-record cannot hand over a
fragment as the pod every reading of the affected one is compared against.

The unit suite pins these on what they are rather than on what they leave
behind. Both host-netns captures are asserted through the kubectl argv the
collector issues, container included, because the section headers are
separate echoes that outlive the exec being removed. The baseline is pinned
from both sides: several affected pods on one node share a single capture,
and an affected pod is served the healthy pod from its own node rather than
from whichever node the walk reached first. The pod list's column order is
asserted against the positions its readers index, because every runtime
test answers that list from a stub written by hand and none of them can
observe the jsonpath at all.

Assisted-By: Claude <noreply@anthropic.com>

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-09 14:42:19 +03:00

1910 lines
79 KiB
Bash

#!/usr/bin/env bats
# EXIT-TRAP DEBT: 1 -- see hack/bats-no-exit-trap.bats; lower it as the traps go, delete it at zero.
# -----------------------------------------------------------------------------
# Unit tests for the pure decision/parsing helpers in
# hack/e2e-capture-dataplane.sh -- specifically the LoadBalancer-datapath
# section, which fires for a Service type=LoadBalancer whose external IP is
# unreachable while its backend stays Ready (the NotReady-pod path never sees
# this case). Only the pure logic is unit-testable here:
#
# - lb_filter_services -- keep only whole LoadBalancer rows that carry an
# ingress IP;
# - lb_first_ready_endpoint -- pick the first addressed, non-NotReady endpoint;
# - lb_announcer_node -- the speaker node of the most recent announce;
# - lb_capture_decision -- capture when probes failed, skip when one
# succeeded, unknown when none ran or none could.
# - pod_filter_affected -- retain scheduled NotReady pods even without IPs,
# and only from whole rows.
# - pod_first_ready -- pick the healthy-pod baseline for one node out
# of the cluster-wide snapshot, whole rows only.
#
# The tests come in two shapes, and neither needs a cluster.
#
# The first sources the script with E2E_CAPTURE_DATAPLANE_LIB set, which the
# sourcing guard honours by defining the helpers and returning before it touches
# $1 or runs any capture. Those tests call the pure helpers directly and assert
# with `[ ... ]`, matching this repo's plain-shell bats convention (no `run`
# helper). Mock IPs use the RFC 5737 / RFC 3849 documentation ranges.
#
# The second runs the script as a subprocess against a stub kubectl on PATH --
# one that hangs, refuses, or answers in part -- and reads what it wrote. That
# reaches the capture body itself, which no amount of sourcing can: the bounds,
# the notes and the difference between "nothing is there" and "the read never
# said" are all properties of the running script, and several of them are only
# observable in the artifact it leaves behind.
#
# A stub has to reach the branch a test is about. Several of these tests walk
# the LB heavy-capture path, which only runs when the probe reports the address
# unreachable, so their stubs answer `nc -z` with a failure on purpose. A stub
# that stops short leaves the test green against every implementation.
#
# Title syntax constraints (inherited from cozytest.sh's awk parser):
# - Titles delimited by ASCII double quotes; embedded quotes truncate.
# - Only [A-Za-z0-9] from the title survives into the function name, so keep
# titles distinctive in their alphanumeric run.
#
# Run with: hack/cozytest.sh hack/capture-dataplane.bats
# (or `bats hack/capture-dataplane.bats` if the bats binary is
# installed; cozytest.sh is the CI path.)
# -----------------------------------------------------------------------------
HACK_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")" && pwd)"
SCRIPT="$HACK_DIR/e2e-capture-dataplane.sh"
# Load the pure helpers. The guard returns before the capture body, so this is
# side-effect-free and needs no cluster.
E2E_CAPTURE_DATAPLANE_LIB=1
# shellcheck source=/dev/null
. "$SCRIPT"
@test "pod_filter_affected keeps a scheduled NotReady pod without a podIP" {
rows="$(printf '%s\n' \
'tenant|no-ip||node-a|False|Pending||eol' \
'tenant|with-ip|192.0.2.80|node-b|False|Running||eol')"
out="$(printf '%s\n' "$rows" | pod_filter_affected)"
[ "$(printf '%s\n' "$out" | awk 'END { print NR }')" -eq 2 ]
printf '%s\n' "$out" | awk '$0 == "tenant|no-ip||node-a|False|Pending||eol" { found = 1 } END { exit !found }'
}
@test "pod_filter_affected drops Ready unscheduled and terminal pods" {
rows="$(printf '%s\n' \
'tenant|ready|192.0.2.81|node-a|True|Running||eol' \
'tenant|unscheduled|||False|Pending||eol' \
'tenant|succeeded|192.0.2.82|node-b|False|Succeeded||eol' \
'tenant|failed|192.0.2.83|node-b|False|Failed||eol')"
[ -z "$(printf '%s\n' "$rows" | pod_filter_affected)" ]
}
@test "pod_first_ready picks the first Ready Running pod on the named node" {
# Rows are the cluster-wide snapshot, so the filter scopes to a node itself:
# ns|name|podIP|nodeName|ready|phase|hostNetwork.
rows="$(printf '%s\n' \
'tenant|not-ready|192.0.2.90|node-a|False|Running||eol' \
'tenant|pending|192.0.2.91|node-a|True|Pending||eol' \
'tenant|other-node|192.0.2.94|node-b|True|Running||eol' \
'tenant|healthy-1|192.0.2.92|node-a|True|Running||eol' \
'tenant|healthy-2|192.0.2.93|node-a|True|Running||eol')"
out="$(printf '%s\n' "$rows" | pod_first_ready node-a)"
[ "$out" = "tenant|healthy-1|192.0.2.92" ]
}
@test "pod_first_ready ignores an eligible pod on a different node" {
# The scoping is the whole reason this reads the cluster-wide list safely: a
# baseline from another machine would compare the wedged pod's route against
# a route that never shared a kernel with it.
rows="$(printf '%s\n' 'tenant|healthy|192.0.2.95|node-b|True|Running||eol')"
[ -z "$(printf '%s\n' "$rows" | pod_first_ready node-a)" ]
}
@test "pod_first_ready emits nothing when no pod on the node is Ready and Running" {
rows="$(printf '%s\n' \
'tenant|not-ready|192.0.2.94|node-a|False|Running||eol' \
'tenant|pending|192.0.2.95|node-a|True|Pending||eol')"
[ -z "$(printf '%s\n' "$rows" | pod_first_ready node-a)" ]
}
@test "pod_first_ready emits nothing on empty input" {
[ -z "$(printf '' | pod_first_ready node-a)" ]
}
@test "pod_first_ready rejects a hostNetwork row cut at the separator" {
# The half a value check cannot reach on its own. A stream cut RIGHT AFTER the
# sixth separator leaves the hostNetwork column empty, and empty is
# byte-identical to the omitempty-false the filter must accept -- so `$7 ==
# ""` alone seats a hostNetwork pod whose `true` was cut away entirely. The
# trailing constant is what separates them, and the two halves of that guard
# catch different cuts, so both are exercised here:
# row 1 loses its tail before the last separator -> NF is 7, not 8
# row 2 keeps the count but the sentinel is cut -> NF is 8, $8 is not eol
# Neither may be picked; the whole row behind them must be.
rows="$(printf '%s\n' \
'cozy-cilium|cilium-abcde|192.0.2.10|node-a|True|Running|' \
'cozy-cilium|cilium-fghij|192.0.2.13|node-a|True|Running||eo' \
'tenant|workload|192.0.2.12|node-a|True|Running||eol')"
out="$(printf '%s\n' "$rows" | pod_first_ready node-a)"
[ "$out" = "tenant|workload|192.0.2.12" ]
}
@test "pod_first_ready rejects a hostNetwork row cut inside its last value" {
# The one filter here where a truncated last value must NOT be kept.
# `tru` carries all eight fields and passes every other gate, so a deny-list
# (`$7 != "true"`) accepts it -- and because this filter stops at the row it
# takes, the baseline then IS a hostNetwork pod, whose podIP is the node's own
# address. That is the fingerprint the exclusion exists to keep out of the
# comparison, so the control would be the thing it excludes. The allow-list
# form rejects any value that is not an exact known state.
rows="$(printf '%s\n' \
'cozy-cilium|cilium-abcde|192.0.2.10|node-a|True|Running|tru|eol' \
'tenant|workload|192.0.2.11|node-a|True|Running||eol')"
out="$(printf '%s\n' "$rows" | pod_first_ready node-a)"
[ "$out" = "tenant|workload|192.0.2.11" ]
}
@test "pod_first_ready skips a Ready pod that has no podIP yet" {
# The filter stops at the row it takes, so an addressless pod does not just
# produce a thin baseline -- it consumes the only pick and the pod behind it
# is never considered.
rows="$(printf '%s\n' \
'tenant|no-ip-yet||node-a|True|Running||eol' \
'tenant|healthy|192.0.2.99|node-a|True|Running||eol')"
out="$(printf '%s\n' "$rows" | pod_first_ready node-a)"
[ "$out" = "tenant|healthy|192.0.2.99" ]
}
@test "pod_first_ready drops a pod row cut off mid-record" {
# The cut has to land where the fragment still satisfies every value test, or
# the row is rejected for an unrelated reason and the count is never
# consulted: here the stream ends right after `Running`, one separator short
# of the hostNetwork column.
rows="$(printf '%s\n' \
'tenant|cut-off|192.0.2.9|node-a|True|Running' \
'tenant|healthy|192.0.2.98|node-a|True|Running||eol')"
out="$(printf '%s\n' "$rows" | pod_first_ready node-a)"
[ "$out" = "tenant|healthy|192.0.2.98" ]
}
@test "pod_first_ready skips a hostNetwork pod even when it is Ready Running and first" {
# cilium-agent / kube-ovn-cni / ovs-ovn run hostNetwork on every node, and a
# hostNetwork pod's podIP IS the node's own address -- picking one as the
# baseline would resolve to a local route for an entirely ordinary reason,
# which is the same fingerprint the address-attribution capture exists to
# recognise, so the baseline would defeat itself.
rows="$(printf '%s\n' \
'cozy-cilium|cilium-agent-abcde|192.0.2.10|node-a|True|Running|true|eol' \
'tenant|workload|192.0.2.96|node-a|True|Running||eol')"
out="$(printf '%s\n' "$rows" | pod_first_ready node-a)"
[ "$out" = "tenant|workload|192.0.2.96" ]
}
@test "pod_first_ready treats a blank hostNetwork column as NOT hostNetwork" {
# The API omits spec.hostNetwork (omitempty) when it is false/unset, so
# kubectl's jsonpath emits an empty string, not the literal "false".
rows="$(printf '%s\n' 'tenant|workload|192.0.2.97|node-a|True|Running||eol')"
out="$(printf '%s\n' "$rows" | pod_first_ready node-a)"
[ "$out" = "tenant|workload|192.0.2.97" ]
}
@test "runtime checks LoadBalancers when there are no affected pods" {
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
calls="$tmp/kubectl.calls"
mkdir -p "$tmp/bin"
cat > "$tmp/bin/kubectl" <<'EOF'
#!/bin/sh
printf '%s\n' "$*" >> "$MOCK_KUBECTL_CALLS"
exit 0
EOF
chmod +x "$tmp/bin/kubectl"
MOCK_KUBECTL_CALLS="$calls" PATH="$tmp/bin:$PATH" \
"$SCRIPT" "$tmp/out" > "$tmp/stdout" 2>&1
awk '$0 ~ /^get svc -A / { found = 1 } END { exit !found }' "$calls"
awk 'index($0, "checking LoadBalancers independently") { found = 1 } END { exit !found }' "$tmp/stdout"
awk 'index($0, "no Service type=LoadBalancer") { found = 1 } END { exit !found }' "$tmp/stdout"
}
@test "lb_filter_services keeps only LoadBalancer rows that have an ingress IP" {
rows="$(printf '%s\n' \
'tenant|app|LoadBalancer|192.0.2.50|80|31000|Cluster' \
'kube-system|kube-dns|ClusterIP||53||Cluster' \
'tenant|pending|LoadBalancer||443|31443|Local' \
'tenant|db|LoadBalancer|192.0.2.51|5432|31543|Local')"
out="$(printf '%s\n' "$rows" | lb_filter_services)"
[ "$(printf '%s\n' "$out" | grep -c .)" -eq 2 ]
printf '%s\n' "$out" | grep -q '^tenant|app|LoadBalancer|192.0.2.50|'
printf '%s\n' "$out" | grep -q '^tenant|db|LoadBalancer|192.0.2.51|'
# `! cmd` is vacuous under cozytest's `set -e` (errexit is suppressed for a
# `!`-negated pipeline), so a filter regression that let these rows through
# would not fail the test. Assert the absence via `if cmd; then ...; false`.
if printf '%s\n' "$out" | grep -q 'kube-dns'; then echo "FAIL: lb_filter_services must drop the kube-dns row"; false; fi
if printf '%s\n' "$out" | grep -q 'pending'; then echo "FAIL: lb_filter_services must drop the pending (no external IP) row"; false; fi
}
@test "lb_filter_services drops a Service row cut off mid-record" {
# A bounded or interrupted list stops mid-record, and the fragment it leaves
# behind still satisfies a filter that only looks at values: what remains of
# `192.0.2.53` here is `192.0.2.5`, an address the cluster never had, and the
# row carrying it would be probed and written up as a Service. The jsonpath
# emits a fixed seven fields per Service, so a shorter row is a fragment by
# construction rather than by guess.
rows="$(printf '%s\n' \
'tenant|app|LoadBalancer|192.0.2.50|80|31000|Cluster' \
'tenant|db|LoadBalancer|192.0.2.5')"
out="$(printf '%s\n' "$rows" | lb_filter_services)"
[ "$(printf '%s\n' "$out" | grep -c .)" -eq 1 ]
# `! cmd` is vacuous under cozytest's `set -e`, hence the if/false form used
# by the sibling filter test above.
if printf '%s\n' "$out" | grep -q 'tenant|db'; then
echo "FAIL: lb_filter_services must drop the row cut off mid-record"
false
fi
}
@test "pod_filter_affected drops a pod row cut off mid-record" {
# The same cut over the eight-field pod jsonpath. The fragment ends inside the
# nodeName column, so the half-parsed `nod` reads as a scheduled pod on a node
# of that name, and the capture opens a node-nod.txt for it. Note the cut has
# to land in a column this filter reads: hostNetwork is last now, and a cut
# inside THAT one leaves every column this filter decides on intact.
rows="$(printf '%s\n' \
'tenant|wedged|192.0.2.80|node-b|False|Running||eol' \
'tenant|cut||nod')"
out="$(printf '%s\n' "$rows" | pod_filter_affected)"
[ "$(printf '%s\n' "$out" | grep -c .)" -eq 1 ]
if printf '%s\n' "$out" | grep -q 'tenant|cut'; then
echo "FAIL: pod_filter_affected must drop the row cut off mid-record"
false
fi
}
@test "lb_first_ready_endpoint picks the first addressed endpoint that is not NotReady" {
eps="$(printf '%s\n' \
'||tenant|virt-launcher-x|true' \
'192.0.2.60|worker-1|tenant|virt-launcher-x|false' \
'192.0.2.61|worker-2|tenant|virt-launcher-y|true' \
'192.0.2.62|worker-3|tenant|virt-launcher-z|true')"
out="$(printf '%s\n' "$eps" | lb_first_ready_endpoint)"
[ "$out" = "192.0.2.61|worker-2|tenant|virt-launcher-y" ]
}
@test "lb_first_ready_endpoint treats a blank ready column as ready" {
eps="$(printf '%s\n' '192.0.2.70|worker-9|tenant|app-0|')"
out="$(printf '%s\n' "$eps" | lb_first_ready_endpoint)"
[ "$out" = "192.0.2.70|worker-9|tenant|app-0" ]
}
@test "lb_first_ready_endpoint drops an endpoint row cut off mid-record" {
# The third record filter over a bounded list, and the same cut: the fragment
# has to come first, because this one stops at the first row it accepts. A
# half-parsed node name would otherwise be the node every endpoint-side
# capture in the LB section is aimed at.
eps="$(printf '%s\n' \
'192.0.2.60|worker-' \
'192.0.2.61|worker-2|tenant|app-1|true')"
out="$(printf '%s\n' "$eps" | lb_first_ready_endpoint)"
[ "$out" = "192.0.2.61|worker-2|tenant|app-1" ]
}
@test "lb_announcer_node returns the speaker node of the most recent announce" {
logs="$(printf '%s\t%s\n' \
'node-a' '{"event":"serviceAnnounced","ips":["192.0.2.50"],"node":"node-a"}' \
'node-b' '{"event":"serviceAnnounced","ips":["192.0.2.50"],"node":"node-b"}')"
out="$(printf '%s\n' "$logs" | lb_announcer_node '192.0.2.50')"
[ "$out" = "node-b" ]
}
@test "lb_announcer_node ignores withdraw lines and announces for other IPs" {
logs="$(printf '%s\t%s\n' \
'node-x' '{"event":"serviceAnnounced","ips":["198.51.100.9"],"node":"node-x"}' \
'node-a' '{"event":"serviceAnnounced","ips":["192.0.2.50"],"node":"node-a"}' \
'node-b' '{"event":"serviceWithdrawn","ips":["192.0.2.50"],"node":"node-b"}' \
'node-c' '{"event":"serviceAnnounced","ips":["192.0.2.50"],"node":"node-c"}')"
out="$(printf '%s\n' "$logs" | lb_announcer_node '192.0.2.50')"
[ "$out" = "node-c" ]
}
@test "lb_announcer_node emits nothing when the IP was never announced" {
logs="$(printf '%s\t%s\n' \
'node-a' '{"event":"serviceAnnounced","ips":["198.51.100.9"],"node":"node-a"}')"
out="$(printf '%s\n' "$logs" | lb_announcer_node '192.0.2.50')"
[ -z "$out" ]
}
@test "lb_announcer_node excludes a node whose last own event is a withdraw" {
# Case A: node-a announces the IP then withdraws it and never re-announces.
# Its last own IP-event is the withdraw, so it is NOT the current announcer and
# nothing must be reported. The pre-polish logic kept node-a (a withdraw did
# not retract a prior announce), so this pins the failover fix.
logs="$(printf '%s\t%s\n' \
'node-a' '{"event":"serviceAnnounced","ips":["192.0.2.50"],"node":"node-a"}' \
'node-a' '{"event":"serviceWithdrawn","ips":["192.0.2.50"],"node":"node-a"}')"
out="$(printf '%s\n' "$logs" | lb_announcer_node '192.0.2.50')"
[ -z "$out" ]
}
@test "lb_announcer_node ignores a stale owner that appears later in concat order" {
# Case B: node-c is the true current owner (announce, never withdrawn). Later
# in the input -- speaker logs are concatenated per-pod, NOT globally time-
# sorted -- a stale node-a announce+withdraw block appears. node-a's last own
# event is a withdraw, so the announcer is node-c, NOT the later-in-input
# node-a. The pre-polish logic returned node-a (most recent announce by concat
# order), so this pins robustness to concat order.
logs="$(printf '%s\t%s\n' \
'node-c' '{"event":"serviceAnnounced","ips":["192.0.2.50"],"node":"node-c"}' \
'node-a' '{"event":"serviceAnnounced","ips":["192.0.2.50"],"node":"node-a"}' \
'node-a' '{"event":"serviceWithdrawn","ips":["192.0.2.50"],"node":"node-a"}')"
out="$(printf '%s\n' "$logs" | lb_announcer_node '192.0.2.50')"
[ "$out" = "node-c" ]
}
@test "lb_announcer_node matches the IP as a whole token not a substring" {
# Case C: querying 192.0.2.5 must NOT match a 192.0.2.50 announce (the
# pre-polish index()/substring match returned node-d here). The exact 192.0.2.5
# announce on node-e is matched and wins.
logs="$(printf '%s\t%s\n' \
'node-d' '{"event":"serviceAnnounced","ips":["192.0.2.50"],"node":"node-d"}' \
'node-e' '{"event":"serviceAnnounced","ips":["192.0.2.5"],"node":"node-e"}')"
no_substr="$(printf '%s\n' "$logs" | lb_announcer_node '192.0.2.5')"
no_match="$(printf '%s\t%s\n' \
'node-d' '{"event":"serviceAnnounced","ips":["192.0.2.50"],"node":"node-d"}' \
| lb_announcer_node '192.0.2.5')"
[ "$no_substr" = "node-e" ]
[ -z "$no_match" ]
}
@test "lb_capture_decision returns capture when every probe failed" {
[ "$(printf 'fail\nfail\nfail\n' | lb_capture_decision)" = "capture" ]
[ "$(printf 'fail\n' | lb_capture_decision)" = "capture" ]
}
@test "lb_capture_decision returns skip when any probe succeeded" {
[ "$(printf 'fail\nok\nfail\n' | lb_capture_decision)" = "skip" ]
[ "$(printf 'ok\n' | lb_capture_decision)" = "skip" ]
}
@test "lb_capture_decision returns unknown when no probe ran at all" {
# Zero outcomes means nothing was ever attempted -- no probe client in the
# host netns, or an exec that could not run. Folding that into skip put a
# reachability verdict about the address into the artifact with not one probe
# behind it, which on a minimal image is every LB in the cluster.
[ "$(printf '' | lb_capture_decision)" = "unknown" ]
}
@test "lb_budget_ok yes below the cap and no once it is reached" {
[ "$(lb_budget_ok 0 6)" = "yes" ]
[ "$(lb_budget_ok 5 6)" = "yes" ]
[ "$(lb_budget_ok 6 6)" = "no" ]
[ "$(lb_budget_ok 7 6)" = "no" ]
}
@test "capture budget counts captured LBs so a late broken LB is still captured" {
# The MAX_LBS cap must bound LBs actually CAPTURED, not LBs enumerated: reachable
# (skipped) LBs must not consume the budget, so a broken LB enumerated after many
# reachable ones is still characterised. This mirrors the per-LB loop in
# capture_lb_datapath, which only consults lb_budget_ok / increments the counter
# on the capture branch -- here driven by a fixed decision sequence so no cluster
# is needed. With the pre-polish "increment per enumerated LB" logic and max=2,
# the loop would have broken at the 3rd LB and never reached the broken one.
max=2
captured=0
last=""
for decision in skip skip skip skip skip capture; do
if [ "$decision" != "capture" ]; then
last="skip"
continue
fi
if [ "$(lb_budget_ok "$captured" "$max")" != "yes" ]; then
last="dropped"
continue
fi
captured=$((captured + 1))
last="captured"
done
[ "$captured" -eq 1 ]
[ "$last" = "captured" ]
}
@test "capture budget stops after the cap worth of broken LBs" {
# Once MAX_LBS broken LBs are captured, further unreachable LBs are not
# characterised (left to the outer wall-clock backstop). max=2, three broken.
max=2
captured=0
dropped=0
for decision in capture capture capture; do
if [ "$(lb_budget_ok "$captured" "$max")" != "yes" ]; then
dropped=$((dropped + 1))
continue
fi
captured=$((captured + 1))
done
[ "$captured" -eq 2 ]
[ "$dropped" -eq 1 ]
}
# -----------------------------------------------------------------------------
# Behavioural: the reads are bounded, and a bound that fires says so.
#
# These run the script rather than sourcing it, so they need the guard NOT set;
# the runner is invoked as a subprocess, which leaves this file's sourced copy
# untouched. A stub kubectl that never returns stands in for the case the bounds
# exist for -- an apiserver that hangs rather than refuses. The bounds are read
# from the environment so the test does not have to wait out the real ones.
# -----------------------------------------------------------------------------
# Scratch dir with a kubectl that hangs forever, mimicking an apiserver that
# accepts the connection and never answers.
dp_hanging_kubectl_dir() {
_d=$(mktemp -d)
mkdir -p "$_d/bin"
printf '#!/bin/sh\nsleep 300\n' >"$_d/bin/kubectl"
chmod +x "$_d/bin/kubectl"
printf '%s' "$_d"
}
@test "a hung cluster-wide pod list is cut off rather than eating the envelope" {
d=$(dp_hanging_kubectl_dir)
# Without a per-read bound the first list runs until the CALLER's backstop and
# the script writes nothing at all, so the outer timeout here stands in for
# that backstop: reaching it means the read was never bounded.
PATH="$d/bin:$PATH" COZY_DATAPLANE_LIST_TIMEOUT=2 COZY_DATAPLANE_READ_TIMEOUT=2 \
timeout 20 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
grep -q 'cut off' "$d/log"
rm -rf "$d"
}
@test "the script survives a hung apiserver and still reaches its own end" {
# This stub ANSWERS the cluster-wide pod list and hangs on everything after
# it, so the walk over affected pods actually runs. A stub that hangs on every
# call leaves that list empty, skips the whole branch, and exercises two of
# the nine bounded reads while looking like it covered all of them: an
# unbounded read added inside the branch would not turn it red.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) echo 'tenant-test|wedged|10.0.0.1|node-a|False|Running||eol'; exit 0 ;;
esac
done
sleep 300
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" COZY_DATAPLANE_LIST_TIMEOUT=1 COZY_DATAPLANE_READ_TIMEOUT=1 \
timeout 90 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
# The closing line prints only if control reached it, which it cannot do while
# blocked on a read along the way.
grep -q 'skipping LB-datapath capture' "$d/log"
# And the per-pod branch was really walked, so the reads inside it are covered
# rather than assumed.
grep -q 'looking up the pod matching' "$d/log"
rm -rf "$d"
}
@test "a read that failed instantly is not reported as a timeout" {
# The bounds produce 124 or 137 and nothing else, so any other status is
# kubectl answering -- refused, denied, no such kind. Naming a timeout there
# puts a cause in the artifact that never happened, and because these reads
# send stderr to a sink rather than the log, that false cause would be the
# only thing a reader gets. This is the path the hung-apiserver tests above
# never reach.
d=$(mktemp -d)
mkdir -p "$d/bin"
printf '#!/bin/sh\necho "The connection to the server was refused" >&2\nexit 1\n' >"$d/bin/kubectl"
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 20 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
# Scoped to this script's own note lines: a bare grep over the whole log would
# go red on any future line that merely contains the word.
if grep '^\[capture-dataplane\]' "$d/log" | grep -q 'timeout'; then
echo "an instant failure was reported as a timeout:"
cat "$d/log"
exit 1
fi
# It must still say the read produced nothing, and say why, or the silence
# this suite exists to remove comes back.
grep -q 'kubectl exited 1' "$d/log"
# And that kubectl's own words come with it: the exit code alone reads the
# same whether the message was captured or dropped on the floor.
grep -q 'refused' "$d/out/capture-notes.txt"
rm -rf "$d"
}
@test "a kubectl error cannot forge a second verdict line in the log" {
# log() carries kubectl's stderr now, and under /bin/sh echo would expand a
# literal backslash-n in it into a real newline, printing a second
# "[capture-dataplane] ..." line that reads as this script's own finding. The
# jsonpath in these reads contains {"\n"} and kubectl quotes the expression
# back on a parse error, so this needs no malice to happen.
d=$(mktemp -d)
mkdir -p "$d/bin"
printf '#!/bin/sh\nprintf %%s "unauthorized: x\\\\n[capture-dataplane] no scheduled NotReady pods -- nothing was wrong" >&2\nexit 1\n' >"$d/bin/kubectl"
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 20 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
# Exactly one note about the pod list, and the forged sentence must not be
# sitting on a line of its own wearing this script's prefix.
# The note must exist before its shape means anything: a stub that stopped
# short of the read would satisfy a purely negative assertion.
grep -q 'listing pods failed' "$d/log"
forged=$(grep -c '^\[capture-dataplane\] no scheduled NotReady pods -- nothing was wrong' "$d/log" || true)
if [ "$forged" -ne 0 ]; then
echo "kubectl stderr forged a verdict line:"
cat "$d/log"
exit 1
fi
rm -rf "$d"
}
@test "a node with no matching pod is not reported as a failed read" {
# The per-node lookups ask for one pod by label on one node, and the ordinary
# answer is that there is none -- a node without a cilium-agent is the case
# this capture exists for. That answer must not arrive as a failed read now
# that a non-zero status carries a note.
#
# Reaching this path needs a kubectl that ANSWERS the cluster-wide list with
# one affected pod; the other tests never get here because their list fails
# first and leaves nothing to walk.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) echo 'tenant-test|wedged|10.0.0.1|node-a|False|Running||eol'; exit 0 ;;
esac
done
# The behaviour under test: client-go's evalArray has no allowMissingKeys
# escape, so indexing [0] into an empty list is a hard error and kubectl exits
# 1, while [*] returns empty and exits 0. Without this arm the stub answers 0
# to both forms and the test cannot see the difference it exists to pin.
case "$*" in
*'items[0]'*) echo 'error: array index out of bounds: index 0, length 0' >&2; exit 1 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 30 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
if grep -q 'looking up the pod matching' "$d/log"; then
echo "an absent pod was reported as a failed read:"
grep 'looking up the pod matching' "$d/log"
exit 1
fi
if grep -q 'looking up an ovn-central replica' "$d/log"; then
echo "an absent ovn-central was reported as a failed read:"
grep 'looking up an ovn-central replica' "$d/log"
exit 1
fi
# Positive anchors, because everything above is a negative: a stub that
# stopped short of the walk would satisfy all of it while asserting nothing.
# These are the lines the path is supposed to produce when it does run.
grep -q 'no cilium-agent pod found on node node-a' "$d/out/node-node-a.txt"
grep -q 'no ovn-central pod in' "$d/log"
rm -rf "$d"
}
@test "the same per-node pod lookup is asked once" {
# Each repeat costs its own bound against an apiserver that hangs, so a lookup
# asked three times per pod spends the caller's envelope on re-asking instead
# of on more pods. The stub records every per-node lookup it is given.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) echo 'tenant-test|wedged|10.0.0.1|node-a|False|Running||eol'; exit 0 ;;
esac
done
case "$*" in
*--field-selector*) echo "$*" >>"$STUB_CALLS" ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
STUB_CALLS="$d/calls" PATH="$d/bin:$PATH" timeout 60 "$SCRIPT" "$d/out" >/dev/null 2>&1 || true
[ -s "$d/calls" ]
dups=$(sort "$d/calls" | uniq -d | wc -l | tr -d ' ')
if [ "$dups" -ne 0 ]; then
echo "the same lookup was issued more than once:"
sort "$d/calls" | uniq -c | sort -rn | head -5
exit 1
fi
rm -rf "$d"
}
@test "an unanswered pod list is not reported as a cluster with nothing wrong" {
# The empty-list branch used to print the same line whether the list said
# "nothing is affected" or never answered, directly under the note saying the
# read had failed.
d=$(mktemp -d)
mkdir -p "$d/bin"
printf '#!/bin/sh\nexit 1\n' >"$d/bin/kubectl"
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 20 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
if grep -q 'no scheduled NotReady pods' "$d/log"; then
echo "a failed list was reported as nothing being affected:"
cat "$d/log"
exit 1
fi
grep -q 'whether any pod is affected is unknown' "$d/log"
rm -rf "$d"
}
@test "an unanswered ovn-central lookup is not reported as a cluster without it" {
# Mirror of the pod-list case: the note saying the lookup failed used to sit
# directly above a line asserting there is no ovn-central, which the script
# never observed.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) echo 'tenant-test|wedged|10.0.0.1|node-a|False|Running||eol'; exit 0 ;;
esac
done
case "$*" in
*'app=ovn-central'*) echo 'Error from server (Forbidden): pods is forbidden' >&2; exit 1 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 60 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
if grep -q 'no ovn-central pod in' "$d/log"; then
echo "a failed lookup was reported as the cluster not running ovn-central:"
cat "$d/log"
exit 1
fi
# The distinctive form: 'is unknown' alone also matches the Service-list note.
grep -q 'runs ovn-central is unknown' "$d/log"
rm -rf "$d"
}
@test "an unanswered service list is not reported as a cluster without LoadBalancers" {
# Third instance of the same rule, and the read this PR's stated purpose names
# directly: an unanswered list must not be reported as an empty one.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
case "$*" in
*'get svc'*) echo 'Error from server (Forbidden): services is forbidden' >&2; exit 1 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 30 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
if grep -q 'no Service type=LoadBalancer' "$d/log"; then
echo "a failed service list was reported as a cluster without LoadBalancers:"
cat "$d/log"
exit 1
fi
grep -q 'whether any LoadBalancer needs capturing is unknown' "$d/log"
rm -rf "$d"
}
@test "a partially answered service list is not captured as a complete one" {
# A list can fail after emitting rows: a bound firing mid-stream leaves output
# on stdout and 124 in $?. Gating the note on an empty result would let that
# partial inventory be captured with nothing saying it was partial.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
case "$*" in
*'get svc'*)
echo 'tenant|web|LoadBalancer|192.0.2.10|80|30080|Cluster'
exit 1 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 30 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
# It must both proceed with what it got and say the list did not finish.
grep -q 'probing 1 LoadBalancer service' "$d/log"
if ! grep -q 'listing services' "$d/log"; then
echo "a partial service list was captured with no note that it failed:"
cat "$d/log"
exit 1
fi
rm -rf "$d"
}
@test "a partially answered endpointslice read is not called an unread one" {
# The same shape as the service list above, at the two EndpointSlice reads: a
# bound firing mid-stream leaves rows on stdout and 124 in $?. The backend is
# then parsed from what did arrive and printed by name, so a note asserting
# what the artifact will say -- rather than what is missing from it -- lands
# two lines from a block that contradicts it. The sibling notes in this file
# avoid that by naming the omission, and pod_on_node names no consequence at
# all because it serves callers that do different things with the answer.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) exit 0 ;;
svc) echo 'tenant|web|LoadBalancer|192.0.2.10|80|30080|Cluster'; exit 0 ;;
# Complete rows AND a bound's status: the read answered in part.
endpointslices) echo '10.0.0.1|node-a|tenant-test|wedged|true'; exit 124 ;;
esac
done
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 60 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
# The rows that did arrive are used, so the artifact names a backend ...
grep -q 'ip=10.0.0.1' "$d/out/lb-tenant-web.txt"
# ... and the cut is on the record beside it.
grep -q 'was cut off' "$d/out/capture-notes.txt"
# Neither note may claim the value went in as unknown while the block above
# names it. Scoped to each note's own line: 'unknown' appears legitimately
# elsewhere in this file, including on the announcer line of this very block.
if grep 'endpointslices of tenant/web' "$d/out/capture-notes.txt" | grep -q 'unknown'; then
echo "the note calls the backend unknown while the capture names it:"
cat "$d/out/lb-tenant-web.txt"
grep 'endpointslices of tenant/web' "$d/out/capture-notes.txt"
exit 1
fi
if grep 'target port of tenant/web' "$d/out/capture-notes.txt" | grep -q 'unknown'; then
echo "the note calls the target port unknown while the capture names one:"
cat "$d/out/lb-tenant-web.txt"
grep 'target port of tenant/web' "$d/out/capture-notes.txt"
exit 1
fi
rm -rf "$d"
}
@test "a note does not claim a step was skipped when the step runs" {
# A lookup can fail after naming what it was asked for. Both of these notes
# used to state the consequence unconditionally, so the log said the OVN dump
# was skipped and the announcer unresolved while both were being produced from
# the partial answer.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) echo 'tenant-test|wedged|10.0.0.1|node-a|False|Running||eol'; exit 0 ;;
esac
done
case "$*" in
*'app=ovn-central'*) echo 'ovn-central-0'; echo 'boom' >&2; exit 1 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 60 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
# It named a replica, so the dump ran; the note must not say it was skipped.
[ -f "$d/out/ovn-lflows.txt" ]
# Greps the wording the skip branches actually use, not a phrase that only
# existed while this was being written: a dead string can never appear, so the
# assertion could never fire.
if grep -q 'skipping OVN logical-flow dump' "$d/log"; then
echo "the log says the dump was skipped while the dump ran:"
cat "$d/log"
exit 1
fi
rm -rf "$d"
}
@test "a partially answered pod list is not called unanswered" {
# The list can fail after emitting rows. If everything it named is Ready the
# affected set is empty and the status is non-zero, which is the same pair of
# conditions a total failure produces -- so this branch must not assert what
# the read did, only what is left unknown.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) echo 'tenant-test|healthy|10.0.0.9|node-a|True|Running||eol'; exit 1 ;;
esac
done
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 30 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
# Both assertions are positive because the wording they pin is the whole
# point: a branch that described the read instead would not emit either line.
grep -q 'whatever it did not name is missing' "$d/log"
grep -q 'whether any pod is affected is unknown' "$d/log"
rm -rf "$d"
}
@test "a cut-off node lookup is not written into the artifact as an absent pod" {
# The artifact is what a reader opens out of the cozyreport bundle, and it is
# where "(no cilium-agent pod found)" used to be written for a lookup that
# never answered. Bounding that read is what made this reachable: unbounded,
# the script hung until the caller's backstop killed it and no file was
# written at all, so the bound turned "no artifact" into "an artifact with a
# claim in it".
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) echo 'tenant-test|wedged|10.0.0.1|node-a|False|Running||eol'; exit 0 ;;
esac
done
case "$*" in
*--field-selector*spec.nodeName*) sleep 300 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" COZY_DATAPLANE_LIST_TIMEOUT=1 COZY_DATAPLANE_READ_TIMEOUT=1 \
timeout 90 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
[ -f "$d/out/node-node-a.txt" ]
if grep -q 'no cilium-agent pod found' "$d/out/node-node-a.txt"; then
echo "a cut-off lookup was recorded as an absent pod:"
cat "$d/out/node-node-a.txt"
exit 1
fi
grep -q 'could not determine whether a cilium-agent runs' "$d/out/node-node-a.txt"
rm -rf "$d"
}
@test "the host netns capture names every address, not only ovn0" {
# The reason this commit exists: when a podIP turns out to be a local address
# of the host netns, `ip addr show ovn0` cannot say which interface holds it.
# Nothing else in this suite reaches these two captures -- deleting both left
# every other test green -- so without this the collector could quietly lose
# the evidence the change was written to collect.
#
# Asserted on the kubectl argv, and the section headers are the secondary
# check rather than the primary one. A header is an `echo` the script emits
# before the exec, so dropping the exec and keeping the label leaves an empty
# section behind and every header grep green -- either capture alone, or both
# together, and the whole suite stays green. The argv also pins WHERE the
# capture runs -- `-c cni-server`, the container that shares the host netns --
# which is half the property and one a header cannot carry at all.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
case "$*" in
*'ip -o addr show'*|*'ip route show table local'*) echo "$*" >>"$STUB_CALLS" ;;
esac
for a in "$@"; do
case $a in
pods) echo 'tenant-test|wedged|10.0.0.1|node-a|False|Running||eol'; exit 0 ;;
esac
done
case "$*" in
*'app=kube-ovn-cni'*) echo 'cni-abc'; exit 0 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
STUB_CALLS="$d/calls" PATH="$d/bin:$PATH" timeout 120 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
[ -f "$d/out/node-node-a.txt" ]
# Positive control: the host-netns section ran, so a missing address capture
# below is that capture's absence and not the whole block being skipped.
grep -q 'host netns: ip rule' "$d/out/node-node-a.txt"
# The commands the collector actually issued into the host netns ...
grep -q -- '-c cni-server -- ip -o addr show' "$d/calls"
grep -q -- '-c cni-server -- ip route show table local' "$d/calls"
# ... and the labels a reader of the artifact finds them under.
grep -q 'ip -o addr show, all interfaces' "$d/out/node-node-a.txt"
grep -q 'ip route show table local' "$d/out/node-node-a.txt"
rm -rf "$d"
}
@test "a healthy pod on the node is captured beside the affected one" {
# The baseline is the point of the reference leg, and it only pays off in the
# SAME file as the affected pod: a reader comparing a suspicious route against
# a normal one should not have to go and find the normal one.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods)
echo 'tenant-test|wedged|10.0.0.1|node-a|False|Running||eol'
echo 'cozy-cilium|cilium-xyz|10.0.0.5|node-a|True|Running|true|eol'
echo 'tenant|healthy|10.0.0.9|node-a|True|Running||eol'
exit 0 ;;
esac
done
case "$*" in
# A cni-server on the node, so the IP-specific legs actually run. Without it
# they are skipped and the baseline would be a header with no comparison in
# it -- which is the whole thing this leg is for.
*'app=kube-ovn-cni'*) echo 'cni-abc'; exit 0 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 90 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
[ -f "$d/out/pod-tenant-test-wedged.txt" ]
grep -q 'POD tenant-test/wedged' "$d/out/pod-tenant-test-wedged.txt"
grep -q 'REFERENCE (Ready)' "$d/out/pod-tenant-test-wedged.txt"
grep -q 'POD tenant/healthy' "$d/out/pod-tenant-test-wedged.txt"
# And the baseline carries the evidence it exists to provide: the route and
# conntrack for the HEALTHY pod's address, not just a section header. A
# baseline without these compares nothing.
grep -q 'ip route get 10.0.0.9' "$d/out/pod-tenant-test-wedged.txt"
grep -q 'kernel conntrack for 10.0.0.9' "$d/out/pod-tenant-test-wedged.txt"
# The hostNetwork pod sorts first in the snapshot and must not have been
# taken: its podIP is the node's own address, the fingerprint the baseline
# exists to rule out.
if grep -q 'POD cozy-cilium/cilium-xyz' "$d/out/pod-tenant-test-wedged.txt"; then
echo "a hostNetwork pod was taken as the baseline:"
cat "$d/out/pod-tenant-test-wedged.txt"
exit 1
fi
rm -rf "$d"
}
@test "the baseline is captured at route-only scope, not the full one" {
# The baseline exists to show a normal route and conntrack beside a suspicious
# one. Cilium CT, the OVN port binding and the OVS ovn-installed flag describe
# how a pod was PROGRAMMED, and a pod chosen for being healthy is correctly
# programmed by definition -- they compare nothing and cost 105s of exec bound
# on the leg most likely to be cut. Dropping them is what keeps one affected
# pod on one node inside the 600s backstop, so the scope is load-bearing and
# not a preference.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods)
echo 'tenant-test|wedged|10.0.0.1|node-a|False|Running||eol'
echo 'tenant|healthy|10.0.0.9|node-a|True|Running||eol'
exit 0 ;;
esac
done
case "$*" in
# Everything the full scope needs is present, so its absence from the
# baseline is a decision and not a missing dependency.
*'k8s-app=cilium'*) echo 'cilium-xyz'; exit 0 ;;
*'app=ovs'*) echo 'ovs-xyz'; exit 0 ;;
*'app=kube-ovn-cni'*) echo 'cni-abc'; exit 0 ;;
*'app=ovn-central'*) echo 'ovn-central-0'; exit 0 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 120 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
f="$d/out/pod-tenant-test-wedged.txt"
[ -f "$f" ]
# Positive control: the affected pod gets the full scope, so the blocks exist
# and their absence below is scope and not a stub that answered nothing.
awk '/REFERENCE \(Ready\)/ { exit } { print }' "$f" | grep -q 'cilium-dbg bpf ct list global'
awk '/REFERENCE \(Ready\)/ { exit } { print }' "$f" | grep -q 'OVN Port_Binding'
# The baseline keeps what it is read for ...
awk '/REFERENCE \(Ready\)/,0' "$f" | grep -q 'ip route get 10.0.0.9'
awk '/REFERENCE \(Ready\)/,0' "$f" | grep -q 'kernel conntrack for 10.0.0.9'
# ... says in the artifact that the rest was a decision ...
awk '/REFERENCE \(Ready\)/,0' "$f" | grep -q 'scope: route and conntrack only'
awk '/REFERENCE \(Ready\)/,0' "$f" | grep -q 'absence below is a decision, not a lookup'
# ... and drops what compares nothing. Matched against SECTION HEADERS only:
# the marker line above names the very blocks it skips, so a bare substring
# search finds the explanation and calls it the block.
for section in 'cilium-dbg bpf ct list global' 'OVN Port_Binding' 'ovn-installed' 'Ready conditions'; do
if awk '/REFERENCE \(Ready\)/,0' "$f" | grep '^=== ' | grep -q "$section"; then
echo "the baseline was captured at full scope: section '$section' present after REFERENCE"
awk '/REFERENCE \(Ready\)/,0' "$f" | grep '^=== '
exit 1
fi
done
rm -rf "$d"
}
@test "the baseline column is read from the cluster-wide snapshot" {
# The seventh column is what lets the baseline be chosen without a second
# read. Drop it from the jsonpath and every row is a six-field fragment that
# the NF guards reject -- no affected pods, no baseline, nothing captured. The
# stub answers only a request that asks for hostNetwork, so a jsonpath that
# stopped asking produces an empty list and this test goes red.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
case "$*" in
*'spec.hostNetwork'*)
echo 'tenant-test|wedged|10.0.0.1|node-a|False|Running||eol'
echo 'tenant|healthy|10.0.0.9|node-a|True|Running||eol'
exit 0 ;;
esac
for a in "$@"; do
case $a in
pods) exit 0 ;;
esac
done
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 90 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
[ -f "$d/out/pod-tenant-test-wedged.txt" ]
grep -q 'POD tenant/healthy' "$d/out/pod-tenant-test-wedged.txt"
rm -rf "$d"
}
@test "two affected pods on one node share a single baseline capture" {
# The baseline depends on the node alone, and several affected pods on one
# node is what a wedged datapath looks like rather than an edge case. What
# must not repeat is the CAPTURE -- a second full per-pod capture of the same
# healthy pod, against a backstop the pod leg already strains. Counted by the
# baseline pod's own conditions read, which happens once per capture.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
case "$*" in
# The baseline runs at route-only scope, so its conditions read is gone --
# count the route exec, which is the read that scope does make.
*'ip route get 10.0.0.9'*) echo ref-capture >>"$STUB_CALLS" ;;
esac
for a in "$@"; do
case $a in
pods)
echo 'tenant-test|wedged-a|10.0.0.1|node-a|False|Running||eol'
echo 'tenant-test|wedged-b|10.0.0.2|node-a|False|Running||eol'
echo 'tenant|healthy|10.0.0.9|node-a|True|Running||eol'
exit 0 ;;
esac
done
case "$*" in
*'app=kube-ovn-cni'*) echo 'cni-abc'; exit 0 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
STUB_CALLS="$d/calls" PATH="$d/bin:$PATH" timeout 120 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
# Both pods carry the baseline: the saving must not cost one of them its copy.
grep -q 'POD tenant/healthy' "$d/out/pod-tenant-test-wedged-a.txt"
grep -q 'POD tenant/healthy' "$d/out/pod-tenant-test-wedged-b.txt"
n=$(grep -c ref-capture "$d/calls")
if [ "$n" -ne 1 ]; then
echo "the baseline was captured $n times for one node instead of once"
exit 1
fi
rm -rf "$d"
}
@test "the pod list jsonpath emits its columns in the order its readers index" {
# Every runtime test below answers the pod list from a stub that writes rows
# by hand, so none of them can observe the jsonpath's column ORDER: swap two
# fields in it and the suite stays green while the filters go on reading
# position 6 as phase and 7 as hostNetwork. Measured -- swapping
# `.status.phase` with `.spec.hostNetwork` passed 72 of 72.
#
# Against a cluster that swap makes pod_filter_affected compare a boolean
# against Succeeded/Failed and pod_first_ready compare a phase against
# "false", so the collector selects the wrong pods and says nothing. The
# positions are a contract between one jsonpath and three readers -- both awk
# filters and the `read` in the capture loop -- and no stub can hold it.
#
# Asserted as ORDER rather than as an exact string, so reformatting the
# jsonpath is free and reordering it is not.
#
# Each field carries its closing brace, and that is load-bearing rather than
# tidy: `.metadata.name` is a prefix of `.metadata.namespace`, so searching
# for it bare finds the namespace field and reports the two columns at one
# offset. The brace is what makes each search hit its own column.
line=$(grep -F '{range .items[*]}' "$SCRIPT" | grep -F 'spec.hostNetwork')
[ -n "$line" ]
prev=0
for f in '.metadata.namespace}' '.metadata.name}' '.status.podIP}' '.spec.nodeName}' \
'type=="Ready")].status}' '.status.phase}' '.spec.hostNetwork}' '|eol"}'; do
cur=$(printf '%s\n' "$line" | awk -v s="$f" '{ print index($0, s) }')
# index() returns 0 for absent, so this one comparison catches a field that
# moved and a field that vanished.
if [ "$cur" -le "$prev" ]; then
echo "the pod list jsonpath no longer emits its columns in the order its readers assume"
echo "field '$f' is at offset $cur, expected after $prev"
echo "line: $line"
exit 1
fi
prev=$cur
done
}
@test "an affected pod gets the baseline from its own node" {
# The complement of the test above, and the half a shared cache can break
# without breaking that one: the cache is keyed by node, and a key that
# stopped telling nodes apart would serve every node the first node's
# answer. The test above counts captures and a single shared answer is
# exactly one capture, so it would stay green.
#
# What the reader would then get is a route and a conntrack table read on
# one kernel, filed under a heading naming a different node, with nothing
# in the artifact saying so -- a capture reporting what it never observed,
# which is the failure this collector exists to rule out. So the assertion
# is on WHICH healthy pod each file carries, per file, not on how many
# captures ran.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods)
echo 'tenant-test|wedged-a|10.0.0.1|node-a|False|Running||eol'
echo 'tenant-test|wedged-b|10.0.0.2|node-b|False|Running||eol'
echo 'tenant|healthy-a|10.0.0.9|node-a|True|Running||eol'
echo 'tenant|healthy-b|10.0.0.8|node-b|True|Running||eol'
exit 0 ;;
esac
done
case "$*" in
*'app=kube-ovn-cni'*) echo 'cni-abc'; exit 0 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 120 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
fa="$d/out/pod-tenant-test-wedged-a.txt"
fb="$d/out/pod-tenant-test-wedged-b.txt"
[ -f "$fa" ]
[ -f "$fb" ]
# Each file names its own node's healthy pod ...
grep -q 'POD tenant/healthy-a' "$fa"
grep -q 'POD tenant/healthy-b' "$fb"
# ... and carries that pod's address in the evidence, not just in a heading.
grep -q 'ip route get 10.0.0.9' "$fa"
grep -q 'ip route get 10.0.0.8' "$fb"
# ... and neither carries the other node's, which is what a node-agnostic
# cache key produces: both files served whichever node was reached first.
if grep -q 'POD tenant/healthy-b' "$fa" || grep -q 'POD tenant/healthy-a' "$fb"; then
echo "a baseline crossed nodes:"
grep -H 'POD tenant/healthy' "$fa" "$fb"
exit 1
fi
rm -rf "$d"
}
@test "a partially answered pod list is not called unanswered by the baseline" {
# The absence branch fires on "read failed AND no eligible pod", which is what
# a partial answer produces: rows arrive, none of them can serve -- the
# affected pod is not Ready, the hostNetwork pod is excluded -- and the list
# then dies mid-stream. Saying it never answered would assert what the run did
# not observe. It did not FINISH, which is true either way.
#
# With one snapshot this is the only way to reach that branch at all: a list
# that answered nothing leaves no affected pods, so no pod file is written.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods)
echo 'tenant-test|wedged|10.0.0.1|node-a|False|Running||eol'
echo 'cozy-cilium|cilium-xyz|10.0.0.5|node-a|True|Running|true|eol'
exit 1 ;;
esac
done
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 90 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
[ -f "$d/out/pod-tenant-test-wedged.txt" ]
grep -q 'REFERENCE (Ready) pod on node=node-a: unknown' "$d/out/pod-tenant-test-wedged.txt"
grep -q 'the pod list did not finish' "$d/out/pod-tenant-test-wedged.txt"
rm -rf "$d"
}
@test "a node whose snapshot holds no eligible pod says none found" {
# The other side of the same branch, and the reason it needs two wordings: a
# complete list with nothing eligible on the node is an answer, not a gap.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods)
echo 'tenant-test|wedged|10.0.0.1|node-a|False|Running||eol'
echo 'cozy-cilium|cilium-xyz|10.0.0.5|node-a|True|Running|true|eol'
exit 0 ;;
esac
done
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 90 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
grep -q 'none found -- no baseline available' "$d/out/pod-tenant-test-wedged.txt"
if grep -q 'did not finish' "$d/out/pod-tenant-test-wedged.txt"; then
echo "a complete list was reported as unfinished:"
cat "$d/out/pod-tenant-test-wedged.txt"
exit 1
fi
rm -rf "$d"
}
@test "an affected pod is never offered as a healthy baseline" {
# Now true by construction rather than by exclusion: candidates come from the
# same rows the affected set came from, and one row cannot be both Ready and
# not-Ready. The test stays because the property is what matters, not the
# mechanism -- a future change that reads the node separately would reopen it.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods)
echo 'tenant-test|wedged-a|10.0.0.1|node-a|False|Running||eol'
echo 'tenant-test|wedged-b|10.0.0.2|node-a|False|Running||eol'
exit 0 ;;
esac
done
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 120 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
[ -f "$d/out/pod-tenant-test-wedged-a.txt" ]
[ -f "$d/out/pod-tenant-test-wedged-b.txt" ]
for f in "$d"/out/pod-tenant-test-wedged-a.txt "$d"/out/pod-tenant-test-wedged-b.txt; do
if grep 'REFERENCE' "$f" | grep -q 'tenant-test/wedged'; then
echo "a pod under investigation was used as the healthy baseline in $(basename "$f"):"
grep '^# POD' "$f"
exit 1
fi
done
grep -q 'none found -- no baseline available' "$d/out/pod-tenant-test-wedged-b.txt"
rm -rf "$d"
}
@test "a memo hit reports the same answer as the miss that filled it" {
# The status has to survive the memo. Every caller reads pod_on_node through a
# command substitution, so a status kept in a variable dies with that subshell
# and every hit then looks like a lookup that never answered -- the same node
# getting <none> from the miss and <unknown> from the hit, in one bundle.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) echo 'tenant-test|wedged|10.0.0.1|node-a|False|Running||eol'; exit 0 ;;
svc) echo 'tenant|web|LoadBalancer|192.0.2.10|80|30080|Cluster'; exit 0 ;;
endpointslices) echo '10.0.0.1|node-a|tenant-test|wedged|true'; exit 0 ;;
esac
done
case "$*" in
# Answers, and answers "there is no such pod" -- status 0, empty output.
*'app=ovs'*) exit 0 ;;
*'k8s-app=cilium'*) echo 'cilium-xyz'; exit 0 ;;
*'app=kube-ovn-cni'*) echo 'cni-abc'; exit 0 ;;
# The LB must probe UNREACHABLE, or the heavy capture is skipped and
# capture_lb_node -- the only consumer that reads this lookup from a memo
# hit -- never runs. Without this the test cannot observe the difference it
# exists to pin, whatever the code does.
*'nc -z'*) echo fail; exit 0 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 90 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
# A lookup that answered "none" must read as none wherever it is consumed.
# capture_lb_node is the only consumer that reads this lookup from a memo
# hit, and it runs only on the heavy-capture path. Pin that it ran, or stub
# drift makes both assertions below vacuous without a word.
grep -q 'ENDPOINT node=node-a' "$d/out/lb-tenant-web.txt"
if grep -rq 'ovs=<unknown>' "$d/out" 2>/dev/null; then
echo "a lookup that answered was reported as unanswered after a memo hit:"
grep -r 'ovs=' "$d/out" 2>/dev/null
exit 1
fi
# And the run must not leak shell diagnostics into the log.
if grep -q 'unbound variable\|parameter not set' "$d/log"; then
echo "the run leaked shell diagnostics:"
grep -n 'unbound variable\|parameter not set' "$d/log" | head -3
exit 1
fi
rm -rf "$d"
}
@test "an instant lookup failure is re-asked rather than cached" {
# Caching a refusal makes one transient permanent for the run. Only a cutoff
# is worth not repeating, because repeating that one spends another full
# bound. The stub refuses the first ovs lookup and answers the rest, so a
# cached failure would show as a second lookup never being issued.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) echo 'tenant-test|wedged|10.0.0.1|node-a|False|Running||eol'; exit 0 ;;
svc) echo 'tenant|web|LoadBalancer|192.0.2.10|80|30080|Cluster'; exit 0 ;;
endpointslices) echo '10.0.0.1|node-a|tenant-test|wedged|true'; exit 0 ;;
esac
done
case "$*" in
*'app=ovs'*)
echo "$*" >>"$STUB_CALLS"
echo 'Error from server (Forbidden): pods is forbidden' >&2
exit 1 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
STUB_CALLS="$d/calls" PATH="$d/bin:$PATH" timeout 90 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
# More than one ovs lookup means the refusal was not cached.
n=$(wc -l <"$d/calls" | tr -d ' ')
if [ "$n" -lt 2 ]; then
echo "an instant failure was cached instead of re-asked (ovs lookups: $n)"
cat "$d/calls"
exit 1
fi
rm -rf "$d"
}
@test "the reasons a capture is incomplete ship inside the capture" {
# A hung apiserver leaves dataplane/ with nothing collected. The lines saying
# why must be there too, or a reader holding only the uploaded report cannot
# tell a capture that found nothing from one that never ran -- the contract
# docs/agents/e2e-testing.md states for the sibling collectors, which this
# script's notes are modelled on.
d=$(mktemp -d)
mkdir -p "$d/bin"
printf '#!/bin/sh\nsleep 300\n' >"$d/bin/kubectl"
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" COZY_DATAPLANE_LIST_TIMEOUT=1 COZY_DATAPLANE_READ_TIMEOUT=1 \
timeout 90 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
[ -f "$d/out/capture-notes.txt" ]
grep -q 'cut off' "$d/out/capture-notes.txt"
grep -q 'whether any pod is affected is unknown' "$d/out/capture-notes.txt"
rm -rf "$d"
}
@test "dp_cutoff_desc tells a SIGKILL from a deadline and both from no bound" {
# Pure helper, reached through the sourcing guard like the lb_* ones above.
# Its three branches carry the difference between "the bound fired", "something
# killed the read and 137 cannot say which", and "this read had no ceiling at
# all" -- the last being what an absent `timeout` produces.
deadline=$(dp_cutoff_desc 124 20 "timeout -k 2 20")
case "$deadline" in
*"its own 20s timeout"*) : ;;
*) echo "124 did not name the deadline: $deadline"; exit 1 ;;
esac
killed=$(dp_cutoff_desc 137 20 "timeout -k 2 20")
case "$killed" in
*SIGKILL*) : ;;
*) echo "137 did not name a SIGKILL: $killed"; exit 1 ;;
esac
unbounded=$(dp_cutoff_desc 137 20 "")
case "$unbounded" in
*unbounded*) : ;;
*) echo "an empty bound was not described as unbounded: $unbounded"; exit 1 ;;
esac
}
@test "sourcing the script for its helpers leaves no temp files behind" {
# Everything above the sourcing guard runs in every unit test that sources
# this file, so a scratch file created up there is one leaked per test, and
# the two early exits would strand one on any host without kubectl.
d=$(mktemp -d)
( TMPDIR="$d"; export TMPDIR; E2E_CAPTURE_DATAPLANE_LIB=1 . "$SCRIPT" ) >/dev/null 2>&1
left=$(ls -1 "$d" 2>/dev/null | wc -l | tr -d ' ')
if [ "$left" -ne 0 ]; then
echo "sourcing left $left file(s) behind:"
ls -1 "$d"
exit 1
fi
rm -rf "$d"
}
@test "an LB whose probe never ran is not recorded as reachable" {
# Fifth site of the same class, and the last one the bounds made reachable.
# host_http_probe resolves a cni-server first; a cut-off lookup used to emit
# no probe outcome at all, which the decision helper read as "nothing failed"
# and the artifact stamped as reachable -- a verdict about an address the
# script never touched. At merge base the script never got this far: the
# unbounded lookup hung until the caller's backstop killed it.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) exit 0 ;;
svc) echo 'tenant|web|LoadBalancer|192.0.2.10|80|30080|Cluster'; exit 0 ;;
endpointslices) echo '10.0.0.1|node-a|tenant-test|wedged|true'; exit 0 ;;
esac
done
case "$*" in
*'app=kube-ovn-cni'*) sleep 300 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" COZY_DATAPLANE_LIST_TIMEOUT=1 COZY_DATAPLANE_READ_TIMEOUT=1 \
timeout 90 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
[ -f "$d/out/lb-tenant-web.txt" ]
# The specific claim, not the bare word: the honest line says "whether the LB
# is reachable ... is unknown" and contains it too.
if grep -q -- '-- reachable, skipped' "$d/out/lb-tenant-web.txt"; then
echo "an LB that was never probed was recorded as reachable:"
cat "$d/out/lb-tenant-web.txt"
exit 1
fi
grep -q 'is unknown' "$d/out/lb-tenant-web.txt"
# And this is the branch that MAY name the lookup, because here it genuinely
# did not answer. Pinning it positively is what keeps the two negative pins
# in the empty-outcome tests below from going vacuous: the phrase has to stay
# reachable somewhere for its absence elsewhere to mean anything.
grep -q 'the cni-server lookup did not answer' "$d/out/lb-tenant-web.txt"
rm -rf "$d"
}
@test "an unread endpointslice is written as unknown rather than as none" {
# The two EndpointSlice reads discarded stderr and were interpreted by
# emptiness alone, so a refused read produced the same `<none>` a Service with
# no endpoints does. A reader working from the artifact gets the assertion and
# never sees that nothing was read.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) exit 0 ;;
svc) echo 'tenant|web|LoadBalancer|192.0.2.10|80|30080|Cluster'; exit 0 ;;
endpointslices) echo 'Error from server (Forbidden): endpointslices is forbidden' >&2; exit 1 ;;
esac
done
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 60 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
[ -f "$d/out/lb-tenant-web.txt" ]
grep -q 'ip=<unknown>' "$d/out/lb-tenant-web.txt"
grep -q 'targetPort=<unknown>' "$d/out/lb-tenant-web.txt"
if grep -q 'ip=<none>' "$d/out/lb-tenant-web.txt"; then
echo "a read that never answered was recorded as an absent endpoint:"
cat "$d/out/lb-tenant-web.txt"
exit 1
fi
# And the reason ships beside the capture, not only in the job log.
grep -q 'endpointslices' "$d/out/capture-notes.txt"
rm -rf "$d"
}
@test "a Service with no endpointslices at all is not called an unread one" {
# The other direction, and a landmine the status threading arms: client-go's
# evalArray has no allowMissingKeys escape, so a jsonpath indexing [0] into an
# empty list is a hard error and kubectl exits 1, while [*] yields nothing and
# exits 0. With the status now reported, an ordinary Service without endpoints
# would start reporting as a failed read unless the read asks with [*].
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) exit 0 ;;
svc) echo 'tenant|web|LoadBalancer|192.0.2.10|80|30080|Cluster'; exit 0 ;;
esac
done
# The endpointslice reads fall through to here: an empty list, answered.
case "$*" in
*'items[0]'*) echo 'error: array index out of bounds: index 0, length 0' >&2; exit 1 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 60 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
[ -f "$d/out/lb-tenant-web.txt" ]
grep -q 'targetPort=<none>' "$d/out/lb-tenant-web.txt"
if grep -q 'targetPort=<unknown>' "$d/out/lb-tenant-web.txt"; then
echo "an empty endpointslice list was recorded as a read that never answered:"
cat "$d/out/lb-tenant-web.txt"
exit 1
fi
if grep -q 'endpointslices' "$d/out/capture-notes.txt"; then
echo "an empty endpointslice list was noted as a failed read:"
cat "$d/out/capture-notes.txt"
exit 1
fi
rm -rf "$d"
}
@test "an unknown announcer does not point the tcpdump at a pending pod" {
# pod_on_node builds `--field-selector spec.nodeName=$3`, so an empty node
# asks for pods whose nodeName is EMPTY -- the unscheduled ones. The announcer
# is unknown exactly when MetalLB is misbehaving, which is when this leg
# matters, and an unguarded lookup then hands the first pending kube-ovn-cni
# pod to a tcpdump labelled ANNOUNCER. The sibling uses of the announcer node
# in this same block are all guarded; this one was not.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) exit 0 ;;
svc) echo 'tenant|web|LoadBalancer|192.0.2.10|80|30080|Cluster'; exit 0 ;;
endpointslices) echo '10.0.0.1|node-a|tenant-test|wedged|true'; exit 0 ;;
esac
done
case "$*" in
*--field-selector*) echo "$*" >>"$STUB_CALLS" ;;
esac
case "$*" in
# Answers every per-node cni lookup, INCLUDING one made with an empty node --
# which is what an apiserver holding a pending pod returns for that selector.
# A stub that answered nothing there would hide the bug behind an empty result.
*'app=kube-ovn-cni'*) echo 'cni-abc'; exit 0 ;;
# The LB must probe UNREACHABLE or the heavy capture that carries the tcpdumps
# never runs and the test asserts nothing.
*'nc -z'*) echo fail; exit 0 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
STUB_CALLS="$d/calls" PATH="$d/bin:$PATH" timeout 90 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
# Positive controls: the heavy capture ran, and it ran with no announcer --
# no speaker logs were produced, so lb_announcer_node reported nothing.
grep -q 'UNREACHABLE' "$d/log"
grep -q 'announcer node: <unknown>' "$d/out/lb-tenant-web.txt"
# The lookup itself must not go out with an empty node. Trailing space, so this
# does not also match the endpoint node's own `spec.nodeName=node-a` lookup.
if grep -q -- '--field-selector spec.nodeName= ' "$d/calls"; then
echo "the announcer lookup was issued with an empty node selector:"
grep -- 'spec.nodeName=' "$d/calls"
exit 1
fi
# And no ANNOUNCER tcpdump artifact may exist for a node nobody identified.
if [ -f "$d/out/lb-tenant-web.tcpdump-announcer.txt" ]; then
echo "an announcer tcpdump ran with no announcer node:"
cat "$d/out/lb-tenant-web.tcpdump-announcer.txt"
exit 1
fi
# The skip is stated rather than silent: absence of a block is not a reason.
grep -q 'announcer node unknown' "$d/out/lb-tenant-web.txt"
rm -rf "$d"
}
@test "an unknown endpoint node does not point the tcpdump at a pending pod" {
# The endpoint half of the same defect, on the same block: an LB whose
# announcer IS known but whose Service has no ready endpoint reaches the heavy
# capture with an empty endpoint node, and the two lookups on that side --
# the cni-server pod and the backend's OVS interface -- both go out with
# `spec.nodeName=`, which matches unscheduled pods. No ready endpoint is
# exactly the shape of an LB outage, so this is not a rare corner of the leg.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) exit 0 ;;
svc) echo 'tenant|web|LoadBalancer|192.0.2.10|80|30080|Cluster'; exit 0 ;;
# No ready endpoint: the Service is enumerated, its backend is not.
endpointslices) exit 0 ;;
# The announcer, so the probe has somewhere to run from and the capture is
# reached with only the ENDPOINT side unknown.
logs) echo '{"event":"serviceAnnounced","ips":["192.0.2.10"],"node":"node-a"}'; exit 0 ;;
esac
done
case "$*" in
*'component=speaker'*) echo 'speaker-0|node-a'; exit 0 ;;
esac
case "$*" in
*--field-selector*) echo "$*" >>"$STUB_CALLS" ;;
esac
case "$*" in
# Answers every cni lookup, the one with an empty node included -- what an
# apiserver holding a pending pod returns for that selector.
*'app=kube-ovn-cni'*) echo 'cni-abc'; exit 0 ;;
*'nc -z'*) echo fail; exit 0 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
STUB_CALLS="$d/calls" PATH="$d/bin:$PATH" timeout 90 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
# Positive controls: the heavy capture ran, the announcer side was identified
# and did run, so what follows is about the endpoint side alone.
grep -q 'UNREACHABLE' "$d/log"
[ -f "$d/out/lb-tenant-web.tcpdump-announcer.txt" ]
if grep -q -- '--field-selector spec.nodeName= ' "$d/calls"; then
echo "an endpoint-side lookup was issued with an empty node selector:"
grep -- 'spec.nodeName=' "$d/calls"
exit 1
fi
if [ -f "$d/out/lb-tenant-web.tcpdump-endpoint.txt" ]; then
echo "an endpoint tcpdump ran with no endpoint node:"
cat "$d/out/lb-tenant-web.tcpdump-endpoint.txt"
exit 1
fi
grep -q 'endpoint node unknown' "$d/out/lb-tenant-web.txt"
rm -rf "$d"
}
@test "an LB whose probe answers is still recorded as reachable" {
# Positive control for the three unprobed cases below and for the cut-off one
# further up: each of those asserts the ABSENCE of the reachable line, and a
# change that routed every LB to unknown would satisfy all four while
# destroying the distinction they exist to protect. A probe that answers must
# still produce the reachable line and must still skip the heavy capture.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) exit 0 ;;
svc) echo 'tenant|web|LoadBalancer|192.0.2.10|80|30080|Cluster'; exit 0 ;;
endpointslices) echo '10.0.0.1|node-a|tenant-test|wedged|true'; exit 0 ;;
esac
done
case "$*" in
*'app=kube-ovn-cni'*) echo 'cni-abc'; exit 0 ;;
*'nc -z'*) echo ok; exit 0 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 60 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
grep -q -- '-- reachable, skipped' "$d/out/lb-tenant-web.txt"
if [ -f "$d/out/lb-tenant-web.tcpdump-endpoint.txt" ]; then
echo "a reachable LB got the heavy capture anyway:"
ls -1 "$d/out"
exit 1
fi
rm -rf "$d"
}
@test "an LB with no probe client in the host netns is not recorded as reachable" {
# The headline unprobed input: the exec runs, finds no nc/curl/wget, prints
# nothing and exits 0. Zero outcomes used to read as "nothing failed", so a
# minimal e2e image stamped every LB reachable and skipped the whole heavy
# capture -- which reads as a healthy datapath.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) exit 0 ;;
svc) echo 'tenant|web|LoadBalancer|192.0.2.10|80|30080|Cluster'; exit 0 ;;
endpointslices) echo '10.0.0.1|node-a|tenant-test|wedged|true'; exit 0 ;;
esac
done
case "$*" in
*'app=kube-ovn-cni'*) echo 'cni-abc'; exit 0 ;;
# The probe exec itself: no client in the image, so it emits nothing and
# succeeds. A stub that answered ok or fail here would never reach the branch.
*'nc -z'*) exit 0 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 60 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
# Positive control: the LB was enumerated and the probe leg was reached.
grep -q 'probing 1 LoadBalancer service' "$d/log"
[ -f "$d/out/lb-tenant-web.txt" ]
if grep -q -- '-- reachable, skipped' "$d/out/lb-tenant-web.txt"; then
echo "an LB no probe could be run against was recorded as reachable:"
cat "$d/out/lb-tenant-web.txt"
exit 1
fi
# The reason has to survive contact with what actually happened: the lookup
# answered and named a pod, and the exec ran -- it found nothing to run. A
# line blaming the lookup would be a cause this script never observed, the
# same rule dp_read_outcome states for the reads.
grep -q 'no probe outcome from node-a' "$d/out/lb-tenant-web.txt"
if grep -q 'the cni-server lookup did not answer' "$d/out/lb-tenant-web.txt"; then
echo "the artifact blames a lookup that answered:"
cat "$d/out/lb-tenant-web.txt"
exit 1
fi
rm -rf "$d"
}
@test "an LB whose probe node runs no cni-server is not blamed on the lookup" {
# The fifth zero-probe input, and the one missing from the enumeration until
# now: the lookup ANSWERS, and its answer is that the node has no
# kube-ovn-cni pod. host_http_probe returns without emitting an outcome, so
# this lands in the same empty-outcome branch as a missing probe client --
# but "the lookup did not answer" is false here in the most direct way.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) exit 0 ;;
svc) echo 'tenant|web|LoadBalancer|192.0.2.10|80|30080|Cluster'; exit 0 ;;
endpointslices) echo '10.0.0.1|node-a|tenant-test|wedged|true'; exit 0 ;;
esac
done
case "$*" in
# Answers, and the answer is "no such pod": status 0, empty output.
*'app=kube-ovn-cni'*) exit 0 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 60 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
grep -q 'probing 1 LoadBalancer service' "$d/log"
[ -f "$d/out/lb-tenant-web.txt" ]
if grep -q -- '-- reachable, skipped' "$d/out/lb-tenant-web.txt"; then
echo "an LB with no cni-server to probe from was recorded as reachable:"
cat "$d/out/lb-tenant-web.txt"
exit 1
fi
grep -q 'no probe outcome from node-a' "$d/out/lb-tenant-web.txt"
if grep -q 'the cni-server lookup did not answer' "$d/out/lb-tenant-web.txt"; then
echo "the artifact blames a lookup that answered 'no such pod':"
cat "$d/out/lb-tenant-web.txt"
exit 1
fi
rm -rf "$d"
}
@test "an LB with no node to probe from is not recorded as reachable" {
# Second unprobed input: neither the announcer nor the endpoint node resolved,
# so the probe block never ran. The decision defaulted to skip, and the
# artifact asserted reachability of an address the script never touched.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) exit 0 ;;
svc) echo 'tenant|web|LoadBalancer|192.0.2.10|80|30080|Cluster'; exit 0 ;;
esac
done
# Everything else answers empty: no endpointslices, so no endpoint node, and no
# metallb speakers, so no announcer.
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 60 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
grep -q 'probing 1 LoadBalancer service' "$d/log"
[ -f "$d/out/lb-tenant-web.txt" ]
if grep -q -- '-- reachable, skipped' "$d/out/lb-tenant-web.txt"; then
echo "an LB with nowhere to probe from was recorded as reachable:"
cat "$d/out/lb-tenant-web.txt"
exit 1
fi
grep -q 'nowhere to probe from' "$d/out/lb-tenant-web.txt"
rm -rf "$d"
}
@test "an LB whose Service names no port is not recorded as reachable" {
# Third unprobed input: the port column is empty, the probe block is gated on
# a non-zero port, and the same default carried a reachability verdict out.
d=$(mktemp -d)
mkdir -p "$d/bin"
cat >"$d/bin/kubectl" <<'STUB'
#!/bin/sh
for a in "$@"; do
case $a in
pods) exit 0 ;;
svc) echo 'tenant|web|LoadBalancer|192.0.2.10||30080|Cluster'; exit 0 ;;
endpointslices) echo '10.0.0.1|node-a|tenant-test|wedged|true'; exit 0 ;;
esac
done
case "$*" in
*'app=kube-ovn-cni'*) echo 'cni-abc'; exit 0 ;;
esac
exit 0
STUB
chmod +x "$d/bin/kubectl"
PATH="$d/bin:$PATH" timeout 60 "$SCRIPT" "$d/out" >"$d/log" 2>&1 || true
grep -q 'probing 1 LoadBalancer service' "$d/log"
[ -f "$d/out/lb-tenant-web.txt" ]
if grep -q -- '-- reachable, skipped' "$d/out/lb-tenant-web.txt"; then
echo "an LB with no port to probe was recorded as reachable:"
cat "$d/out/lb-tenant-web.txt"
exit 1
fi
grep -q 'no port to probe' "$d/out/lb-tenant-web.txt"
rm -rf "$d"
}
@test "lb_capture_decision keeps failures visible when one probe could not run" {
# An unrun probe adds no reason to doubt the ones that failed, so the mix must
# not read as reachable. Collapsing it toward skip is what stamps the artifact
# with a reachability verdict for an address whose probes failed.
[ "$(printf 'unknown\nfail\nfail\n' | lb_capture_decision)" = "capture" ]
}
@test "lb_capture_decision returns unknown only when nothing could be attempted" {
[ "$(printf 'unknown\nunknown\nunknown\n' | lb_capture_decision)" = "unknown" ]
# A success still wins: the address answered, whatever else could not be tried.
[ "$(printf 'unknown\nok\n' | lb_capture_decision)" = "skip" ]
}
@test "the Chainsaw caller also reports a capture its backstop cut short" {
# This collector has two callers, and the honesty contract has to hold at both
# or the reader learns to distrust the one that keeps it. hack/cozytest.sh's
# side is covered behaviourally in cozytest-capture-gate.bats; this runs the
# Chainsaw catch itself, extracted from the config it lives in, so neither
# side can quietly stop reporting. The Chainsaw backstop is the shorter of the
# two -- 300s against 600s, because it shares an op envelope with the snapshot
# leg -- so it is the likelier of the pair to fire.
#
# The backstop is simulated with a `timeout` on PATH that exits 124, for the
# same reason the cozytest.sh-side test does it: the call site sees a non-zero
# status and whatever landed, whichever way the kill arrived.
# Named here rather than left to fail downstream: without it a missing yq
# surfaces as the extraction guard below going red, which reads as the catch
# op having been renamed. Same form the other yq-using suites use.
command -v yq >/dev/null || { echo "yq (mikefarah v4+) is required to read the Chainsaw catch script" >&2; exit 1; }
d=$(mktemp -d)
mkdir -p "$d/bin" "$d/hack/e2e-chainsaw/suite"
printf '#!/bin/sh\nexit 124\n' >"$d/bin/timeout"
printf '#!/bin/sh\nexit 0\n' >"$d/bin/crust-gather"
# The catch resolves this collector as ../../e2e-capture-dataplane.sh from the
# failing suite's own directory, and gates the leg on it being executable.
printf '#!/bin/sh\nexit 0\n' >"$d/hack/e2e-capture-dataplane.sh"
chmod +x "$d/bin/timeout" "$d/bin/crust-gather" "$d/hack/e2e-capture-dataplane.sh"
yq -r '.spec.error.catch[] | select(.description == "crust-gather host snapshot on failure") | .script.content' \
"$HACK_DIR/e2e-chainsaw/.chainsaw.yaml" >"$d/catch.sh"
# The extraction found the right op: a renamed description would otherwise
# leave an empty script that satisfies nothing and reports nothing.
grep -q 'e2e-capture-dataplane.sh' "$d/catch.sh"
( cd "$d/hack/e2e-chainsaw/suite" \
&& PATH="$d/bin:$PATH" COZY_REPORT_DIR="$d/report" TEST_NAME=fixture sh "$d/catch.sh" ) \
>"$d/out" 2>&1 || true
# Positive control: the leg was reached, so the line below is about what it
# said and not about the catch having stopped earlier.
grep -q 'capturing host->pod data-plane' "$d/out"
grep -q 'data-plane capture INCOMPLETE (exit 124)' "$d/out"
rm -rf "$d"
}