#!/bin/sh # e2e-capture-dataplane.sh - capture host->pod data-plane state for NotReady pods. # # DIAGNOSTIC ONLY. This script collects extra evidence on an already-failed # test; it never mutates the cluster and never changes the test's pass/fail # outcome. cozytest.sh invokes it from its on-failure hook, AFTER the # crust-gather snapshot and only when a test has already failed, writing into # the same snapshot dir so the output lands in the uploaded cozyreport artifact. # # Why this exists: a recurrent install failure is a CNI host->local-pod # data-plane transient -- kubelet on a node reaches a *local* pod's # readiness/startup probe with "connection refused" for several minutes while # overlay (pod->pod) traffic works, then it self-heals. This is rooted in the # cozystack cilium+kube-ovn chaining config (forced enable-host-legacy-routing, # CNI InstallEndpointRoute:false -> host->local-pod routing is delegated to # kube-ovn/ovn0). The standard crust-gather snapshot captures Kubernetes object # state but NOT the L3 forwarding state on the node, so the mechanism cannot be # root-caused after the fact. This collects exactly that state from the pod's # node so the next recurrence is dispositive. # # What it captures, per affected pod (NotReady and scheduled; podIP may still # be empty while CNI endpoint allocation is the thing that is stuck): # - cilium-agent on the node: cilium-dbg endpoint list; bpf ct entries for # the podIP; a short bounded `cilium-dbg monitor --type drop`; hubble # dropped-verdict observations (if the hubble CLI is present in the agent). # NOTE: under enable-host-legacy-routing the host->local-pod path traverses # the KERNEL netfilter stack, not cilium BPF, so the cilium CT/monitor view # is complementary -- the authoritative conntrack table for this path is the # kernel one captured below. Both are kept: cilium's view still covers # pod->pod and confirms the path is NOT in BPF, which is itself evidence. # - host netns on the node (via the kube-ovn cni-server, which is # hostNetwork+NET_ADMIN): `ip route get `, `ip neigh`, `ip rule`, # `ip addr show ovn0`, and the KERNEL conntrack entries for the podIP # (`conntrack -L`, falling back to /proc/net/nf_conntrack). These reveal a # kernel-path misforward / missing route / wrong rule / unresolved neighbor # -- the actual mechanism behind host->local-pod "connection refused" on the # legacy-routing path (the crux of the transient). # - OVS/OVN on the node: `ovs-ofctl dump-flows br-int`, and the OVN # Port_Binding / Logical_Switch_Port for the pod (LSP name = . in # kube-ovn) plus a bounded `ovn-sbctl lflow-list`. # - OVN flow-programming timing on the node (ovs-ovn pod, app=ovs -- its # openvswitch container also runs ovn-controller): the tail of # /var/log/ovn/ovn-controller.log plus a grep for the decisive lines # (physical_flow_output, if_status_mgr, "took ms", recompute, # "Unreasonably long ... poll interval"); the per-interface ovn-installed # flag + ovn-installed-ts on the pod's OVS interface; and the ovs-ovn # container's cgroup cpu.stat (nr_throttled / throttled_usec). Why: the # host->local-pod transient is consistent with OVN incremental-processing # lag -- ovn-installed=true (the kubelet/CNI "port ready" barrier) is set by # if_status_mgr BEFORE physical_flow_output installs the LSP's local-delivery # OpenFlow under burst, aggravated when ovn-controller is CPU-throttled (it # shares the ovs-ovn CPU limit with vswitchd). A `physical_flow_output ... # took ms` line spanning the failure window, an ovn-installed-ts that # predates that flow, and non-zero cpu.stat throttling together make a # recurrence dispositive. # # What it ALSO captures (added section) -- an UNREACHABLE LoadBalancer datapath: # The NotReady-pod trigger above misses a second, equally-recurrent flake. A # tenant `Service type=LoadBalancer` IP goes unreachable while its backend # pod/VMI stays Ready+Running -- so there is NO NotReady pod, the capture above # no-ops, and the host->cross-node->bridged-worker-VMI->tenant-nodePort # datapath (the suspected failing path) is never characterised. This section # closes that gap. It is gated by a LIVE reachability probe so it only does the # heavy capture for an LB that is actually broken; reachable LBs are enumerated # and explicitly recorded as "reachable, skipped". # - Enumerate every Service type=LoadBalancer that has a status ingress IP. # For each, derive: the EndpointSlice backend (endpoint IP + node + # targetPort), the Service nodePort + externalTrafficPolicy, and the # ANNOUNCER node from the MetalLB speaker logs (the hostNetwork speaker is # the L2 owner; the node of its most recent serviceAnnounced for the IP is # the announcer). # - Probe the LB IP:port a few times from a host netns (the kube-ovn # cni-server, so the probe traverses the same host-sourced datapath the # flake breaks). Only an LB whose every probe FAILS gets the heavy capture # below, and that same probe is the traffic the tcpdumps observe. # - For a FAILING LB, on BOTH the announcer node and the endpoint node # (reusing pod_on_node), labelled by node + role (ANNOUNCER vs ENDPOINT): # * `cilium-dbg bpf lb list` grepped for the LB IP + nodePort -- did the # HOST cilium program the LB->backend translation? This is the first # fork: host-cilium-not-programming vs kube-ovn-delivery-failure. # * kernel `conntrack -L -d ` + `ip neigh` (LB IP, endpoint IP) # via the cni-server host netns. # * `ovs-ofctl dump-flows br-int` filtered to the nodePort / endpoint IP. # * a bounded `tcpdump` on the announcer node's geneve tunnel iface and # the endpoint node's backend OVS/tap iface, run WHILE the probe is # replayed, to show whether the packet crosses announcer->endpoint and # reaches the backend's host-side interface. # * the ovn-controller.log decisive lines for the window. # In-guest capture INSIDE the worker VMI (tenant `cilium-dbg bpf lb list` / # `tcpdump eth0`) is the one hop this cannot reach from the host; it is left # as a documented stretch at the call site (it needs a tenant # kubeconfig/virtctl this EXIT-trap diagnostic does not have). The host-side # ANNOUNCER/ENDPOINT split is the deliverable and already localises the # failing hop to host-cilium vs kube-ovn delivery. # # Robustness contract (matches docs/agents/e2e-testing.md): pure diagnostics, # no retries, no behavior change, no traps. Every live capture is time-boxed # and every command is `|| true`, so a missing tool, an absent pod, or a hung # exec can never fail or stall the job. A wall-clock backstop wraps the whole # run at the call site. It no-ops cleanly when there are no affected pods. set -u # --------------------------------------------------------------------------- # # Pure, side-effect-free helpers for the LoadBalancer-datapath section below. # # Each takes text on stdin / in args and emits text -- no kubectl, no globals # # -- so hack/capture-dataplane.bats can source this file (with # # E2E_CAPTURE_DATAPLANE_LIB set, see the guard below) and unit-test the # # enumeration parsing, announcer-node detection, and capture-or-skip decision # # against mock input without a cluster. Keep them above the guard and free of # # any runtime state. # # --------------------------------------------------------------------------- # # lb_filter_services: stdin = `ns|name|type|lbip|port|nodePort|extPolicy` rows # (one Service per line, as emitted by the kubectl jsonpath in main). Emits only # the rows that are type=LoadBalancer AND carry a status ingress IP -- i.e. the # Services that actually have an external datapath to characterise. lb_filter_services() { awk -F'|' '$3 == "LoadBalancer" && $4 != "" { print }' } # lb_first_ready_endpoint: stdin = `ip|node|targetNs|targetName|ready` rows (one # EndpointSlice endpoint per line). Emits `ip|node|targetNs|targetName` for the # first endpoint that has an address and is not explicitly NotReady, then stops. # A blank `ready` (slice without conditions) counts as ready; only "false" is # excluded. This is the backend the LB IP is supposed to reach. lb_first_ready_endpoint() { awk -F'|' '$1 != "" && $5 != "false" { print $1 "|" $2 "|" $3 "|" $4; exit }' } # lb_announcer_node : stdin = MetalLB speaker logs, each line prefixed with # the emitting speaker pod's node and a TAB (`\t`). The speaker is # hostNetwork and only the elected L2 owner currently announces the IP. Reports a # node ONLY IF that node's LAST own IP-event for this exact IP is an announce: per # node we track its most recent announce/withdraw line for the IP, so a node that # announced then withdrew (last event = withdraw) is excluded and a node that is # still announcing (last event = announce) qualifies. This is robust to the # per-pod (not globally time-sorted) concat order of the speaker logs and to L2 # failover. If more than one node still qualifies (should not happen for an L2 # announce), the one whose last announce appears latest in the input wins. # The IP is matched as a whole token (not a substring), so a query for # `192.0.2.5` does NOT match a `192.0.2.50` line. Emits the announcer node, or # nothing when no node currently announces the IP. lb_announcer_node() { awk -F'\t' -v ip="$1" ' BEGIN { # Match the IP as a maximal IP-literal token: not flanked by another # IP char (digit / dot / hex / colon), so `192.0.2.5` != `192.0.2.50`. # `[.]` escapes each dot portably (no gsub-replacement backslash games). ipre = ip gsub(/\./, "[.]", ipre) re = "(^|[^0-9A-Fa-f:.])" ipre "([^0-9A-Fa-f:.]|$)" } $0 ~ re { if ($0 ~ /[Ww]ithdraw/) ev = "withdraw" else if ($0 ~ /[Aa]nnounc/) ev = "announce" else next last_ev[$1] = ev last_line[$1] = NR } END { best = ""; bestline = -1 for (n in last_ev) { if (last_ev[n] == "announce" && last_line[n] > bestline) { bestline = last_line[n] best = n } } if (best != "") print best }' } # lb_capture_decision: stdin = one probe outcome token per line ("ok" / "fail"). # Emits the gate decision for the heavy per-node capture: # - "capture" only when at least one probe ran AND every probe failed (the LB # IP is unreachable -- the symptom we want characterised); # - "skip" when any probe succeeded (LB reachable) OR no probe ran at all (no # HTTP/TCP client in the host netns -> cannot conclude unreachable, so only # the cheap metadata is kept, never the heavy capture). lb_capture_decision() { awk ' { if ($0 == "") next; n++; if ($0 == "ok") ok++ } END { if (n == 0) { print "skip"; exit } if (ok > 0) { print "skip"; exit } print "capture" }' } # lb_budget_ok : gate for the heavy per-LB capture budget. # Emits "yes" while fewer than LBs have been CAPTURED, "no" once the cap is # reached. The cap bounds LBs actually captured -- the caller increments only on # the capture branch, so reachable/skipped LBs never consume it and a broken LB # enumerated after many reachable ones is still characterised. The cap bounds # WORK, not wall-clock: at >=2 unreachable LBs (each heavy capture takes tens of # seconds) the real wall-clock bound is the outer `timeout -k 30 ` backstop at # the call site, not this cap. That budget differs per caller -- the cozytest.sh # EXIT trap runs with no containing operation and allows 600s, while the Chainsaw # global catch shares an op envelope with the crust-gather snapshot and allows # 300s -- so do not hard-code either number here. lb_budget_ok() { if [ "$1" -lt "$2" ]; then echo yes; else echo no; fi } # pod_filter_affected: stdin = # `namespace|name|podIP|nodeName|ready|phase` rows. Keep scheduled, non-terminal # pods whose Ready condition is not True. podIP is deliberately NOT a gate: the # cilium endpointManager leak this collector diagnoses can strand a pod before # an IP is assigned, and the node-global Cilium/OVN state plus pod events remain # useful in that state. IP-specific commands are gated later at their call sites. pod_filter_affected() { awk -F'|' '$4 != "" && $5 != "True" && $6 != "Succeeded" && $6 != "Failed"' } # Sourcing guard: hack/capture-dataplane.bats sets E2E_CAPTURE_DATAPLANE_LIB and # sources this file purely to reach the helpers above; return before touching $1 # or running any capture so the unit test never needs a cluster. The script's # only executing caller (the cozytest.sh EXIT trap) never sets this, so the # guard is a no-op there. if [ -n "${E2E_CAPTURE_DATAPLANE_LIB:-}" ]; then return 0 2>/dev/null fi OUT="${1:?Usage: e2e-capture-dataplane.sh }" CILIUM_NS="${COZY_CILIUM_NS:-cozy-cilium}" KUBEOVN_NS="${COZY_KUBEOVN_NS:-cozy-kubeovn}" # Cap how many pods we inspect so a fully-wedged cluster cannot explode the # runtime; the per-command timeouts and the call-site wall-clock wrapper are the # other two bounds. Node-global captures are deduped per node, so the effective # work is closer to (#affected-nodes) than (#affected-pods). MAX_PODS="${COZY_DATAPLANE_MAX_PODS:-12}" # LoadBalancer-datapath section tunables (see the header block and the # capture_lb_datapath function near the end of this file). The speaker selector # and geneve iface match the cozystack metallb/cilium+kube-ovn defaults but stay # overridable so a renamed component cannot silently blank the capture. METALLB_NS="${COZY_METALLB_NS:-cozy-metallb}" SPEAKER_SELECTOR="${COZY_METALLB_SPEAKER_SELECTOR:-app.kubernetes.io/component=speaker}" GENEVE_IFACE="${COZY_GENEVE_IFACE:-genev_sys_6081}" # Cap how many UNREACHABLE LBs get the heavy datapath capture; reachable/skipped # LBs never count toward it (see the captured-budget gate in capture_lb_datapath). # This bounds WORK, not wall-clock: at >=2 unreachable LBs the real wall-clock # bound is the outer `timeout -k 30 ` backstop at the call site (truncate + # hard-kill), since each heavy capture itself takes tens of seconds. That budget # is per-caller (600s from the cozytest.sh EXIT trap, 300s from the Chainsaw # global catch, which shares an op envelope with the snapshot leg). MAX_LBS="${COZY_DATAPLANE_MAX_LBS:-6}" command -v kubectl >/dev/null 2>&1 || exit 0 mkdir -p "$OUT" 2>/dev/null || exit 0 log() { echo "[capture-dataplane] $*"; } # Affected = scheduled (has nodeName), Ready!=True, and not already terminal. # A podIP is intentionally optional: a CNI endpoint leak can strand the pod # before allocation. Running pods with an IP cover the host->pod probe failure; # Pending pods without one cover the endpoint-allocation failure. Succeeded/ # Failed pods (completed Jobs, hook pods) are Ready!=True too but are not either # symptom, so they are excluded to keep the MAX_PODS budget on actual wedges. # jsonpath keeps this dependency-free (no jq / go-template reassignment). affected=$(kubectl get pods -A \ -o jsonpath='{range .items[*]}{.metadata.namespace}{"|"}{.metadata.name}{"|"}{.status.podIP}{"|"}{.spec.nodeName}{"|"}{.status.conditions[?(@.type=="Ready")].status}{"|"}{.status.phase}{"\n"}{end}' \ 2>/dev/null | pod_filter_affected) # Set even when the pod path is empty; the per-pod capture references it, while # the independent LoadBalancer path below does not. central="" # pod_on_node