Files
cozystack/hack/run-kubernetes-thread-cpu_test.bats
Myasnikov Daniil cc942fed68 test(tests): run unit suites with Bats and retain POSIX coverage
The shell translator rewrites test bodies and loses Bats reporting.
The unit lane needs complete TAP results and JUnit reports without
changing how live-cluster suites run.

Sourced production helpers still need a dash pass, and unit test bodies
need nounset without exporting shell options into subprocesses.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-10-07 11:04:00 +03:00

955 lines
40 KiB
Bash

#!/usr/bin/env bats
# Regression coverage for the tenant-worker per-thread CPU capture in
# hack/e2e-chainsaw/_lib/run-kubernetes.sh. cozytest.sh ends an @test block at
# the first bare closing brace, so command mocks stay at top level.
#
# What this capture answers, and why the collector beside it cannot: the
# throttling capture reads the compute container's cgroup, and that cgroup holds
# every thread QEMU runs -- the guest's vCPUs, the emulator thread, and the IO
# helpers. A container burning most of its quota while the guest reports no
# progress is therefore two different findings wearing the same number, and the
# container-level counter cannot separate them. Only the per-thread split can,
# and it exists nowhere else in this tree. These tests pin the split and the
# separation between an unread container and a quiet one, not the reading.
load test_helper
kubectl_calls=/dev/null
kubectl_pod_names=
kubectl_list_rc=0
kubectl_list_stderr=
kubectl_exec_rc=0
kubectl_exec_stderr=
# Two stat lines from one process, in the shape /proc writes them. The second
# is the one every field-counting reader gets wrong: a KVM vCPU thread is named
# "CPU 0/KVM", the space sits inside the parentheses, and every field after it
# shifts by one.
kubectl_exec_output='clock ticks per second: 100
process 41 comm qemu-kvm
41 (qemu-kvm) S 1 41 41 0 -1 4194560 8100 0 0 0 4210 1180 0 0 20 0 9 0 5512
57 (CPU 0/KVM) R 1 41 41 0 -1 4194368 120 0 0 0 39120 2210 0 0 20 0 9 0 5530'
timeout_calls=/dev/null
timeout_fail_pod=
kubectl() {
printf '%s\n' "$*" >>"${kubectl_calls}"
if [ "${3:-}" = get ] && [ "${4:-}" = pods ]; then
[ -z "${kubectl_list_stderr}" ] || printf '%s\n' "${kubectl_list_stderr}" >&2
[ "${kubectl_list_rc}" -eq 0 ] || return "${kubectl_list_rc}"
[ -z "${kubectl_pod_names}" ] || printf '%s\n' ${kubectl_pod_names}
return 0
fi
if [ "${3:-}" = exec ]; then
[ -z "${kubectl_exec_stderr}" ] || printf '%s\n' "${kubectl_exec_stderr}" >&2
# Output before the status, so the branch that labels a partial capture is
# reachable: a read cut off part way through has already written what it
# managed to read, and a stub that returns first can only ever stage
# empty-plus-failure.
[ -z "${kubectl_exec_output}" ] || printf '%s\n' "${kubectl_exec_output}"
return "${kubectl_exec_rc}"
fi
return 0
}
timeout() {
local command_rc=0
printf '%s\n' "$*" >>"${timeout_calls}"
# Return 97 rather than running the command when the wrapper is not the
# bounded form: a read that lost its ceiling must not pass as a read.
[ "${1:-}" = -k ] || return 97
shift 3
"$@" || command_rc=$?
if [ -n "${timeout_fail_pod}" ]; then
case " $* " in
*"${timeout_fail_pod}"*) return 124 ;;
esac
fi
return "${command_rc}"
}
assert_file_contains() {
local needle="$1"
local file="$2"
if [ ! -f "${file}" ]; then
printf 'expected %s to exist so it could be checked for: %s\n' "${file}" "${needle}" >&2
return 1
fi
case "$(cat "${file}")" in
*"${needle}"*) return 0 ;;
esac
printf 'expected %s to contain: %s\n' "${file}" "${needle}" >&2
return 1
}
assert_file_lacks_pattern() {
local pattern="$1"
local file="$2"
# A missing file must fail rather than vacuously pass: a bare `! grep -q`
# succeeds on an unreadable path, which is indistinguishable from "the file
# exists and does not carry the label".
if [ ! -f "${file}" ]; then
printf 'expected %s to exist so it could be checked for: %s\n' "${file}" "${pattern}" >&2
return 1
fi
# Branched on awk's exact status. awk exits 1 for "no line matched" and 2 for
# "I could not evaluate this"; folded into an `if`, both land in the passing
# branch and the assertion "this pattern is absent" is satisfied by the
# matcher giving up. Every claim in this file about something NOT appearing
# rests on the three-way split below.
local _rc=0
awk -v pattern="${pattern}" '$0 ~ pattern { found = 1 } END { exit found ? 0 : 1 }' "${file}" || _rc=$?
case "${_rc}" in
0)
printf 'expected %s not to match: %s\n' "${file}" "${pattern}" >&2
return 1
;;
1) return 0 ;;
*)
printf 'awk could not evaluate pattern %s against %s\n' "${pattern}" "${file}" >&2
return 1
;;
esac
}
# The function under test redirects a command's stderr into a report file and
# decides which artifact to write from it. cozytest.sh runs under `set -x`, so
# the tracer's own line for that command lands on the very stderr being
# redirected, and every assertion about those files would be reading the
# tracer. Call through this wrapper so the sinks hold what production writes.
run_capture() {
local _rc=0
( set +x; cozy_capture_tenant_worker_thread_cpu 1 ) || _rc=$?
return "${_rc}"
}
@test "a negative assertion fails when its own matcher cannot evaluate" {
# The helper below every "must not appear" claim in this file. What rides on
# it here is the collector's contract stated negatively -- that an unreachable
# container is not written up as one that answered, that an incomplete read
# carries no pairing instruction -- so a matcher that fails open turns each of
# them into a sentence nobody checked.
tmp=$(mktemp -d)
printf 'anything\n' >"$tmp/subject"
if assert_file_lacks_pattern '*(' "$tmp/subject" 2>/dev/null; then
echo "FAIL: an unparseable pattern was reported as absent" >&2
rm -rf "$tmp"
return 1
fi
assert_file_lacks_pattern 'nothinglikethis' "$tmp/subject"
if assert_file_lacks_pattern 'anything' "$tmp/subject" 2>/dev/null; then
echo "FAIL: a present pattern was reported as absent" >&2
rm -rf "$tmp"
return 1
fi
rm -rf "$tmp"
}
@test "the threads are read from inside the compute container of each worker Pod" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
run_capture
# The container has to be named. virt-launcher runs several, and kubectl's
# default is the first one in the spec rather than the one running QEMU, so an
# unnamed exec reads a stranger's threads or fails on a container with no shell.
assert_file_contains 'exec virt-launcher-a -c compute' "$kubectl_calls"
# The projection is a parameter now, and the two callers of that listing pass
# different ones from twelve hundred lines apart. Swapped, this collector
# would exec into Pods named after nodes and the cAdvisor one would ask
# kubelets named after Pods: both dead, neither with an error, and every
# artifact full of sentences saying the subject was not reached. Nothing else
# in either suite sees the argument, because the mocks answer any listing
# regardless of what was asked for.
assert_file_contains 'jsonpath={range .items[?(@.status.phase=="Running")]}{.metadata.name}' "$kubectl_calls"
capture="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1/virt-launcher-a.txt"
# The vCPU line and the emulator line both, because the whole question is
# which of them carries the time: a capture that kept only one of the two
# would answer it by construction.
assert_file_contains '(CPU 0/KVM)' "$capture"
assert_file_contains '(qemu-kvm)' "$capture"
assert_file_lacks_pattern 'incomplete' "$capture"
assert_file_lacks_pattern 'unknown' "$capture"
dir="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1"
[ ! -f "$dir/virt-launcher-a.exec-error.log" ]
[ ! -f "$dir/COLLECTION-FAILED.txt" ]
rm -rf "$tmp"
}
@test "each read is bounded and keeps its exit code" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
run_capture
# An unbounded exec would hold the whole diagnostics block against the wedged
# kubelet this collector exists to study, and the phase budget bounds when a
# read may START, never how long one already running may take.
# Named by their verbs: both reads carry the same bound, so a bare '-k 5 20'
# would be satisfied twice by whichever one survived.
assert_file_contains '-k 5 20 kubectl -n tenant-test get pods -l kubevirt.io=virt-launcher' "$timeout_calls"
assert_file_contains '-k 5 20 kubectl -n tenant-test exec' "$timeout_calls"
# The listing is a request and carries the second bound too; the exec is a
# streamed connection and the tree's other exec captures bound it with the
# wrapper alone.
assert_file_contains '--request-timeout=20s' "$kubectl_calls"
assert_file_contains '[capture exit code: 0]' \
"$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1/virt-launcher-a.txt"
rm -rf "$tmp"
}
@test "the sample number decides which reading a file belongs to" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
( set +x; cozy_capture_tenant_worker_thread_cpu 2 )
# utime and stime are cumulative since the thread started, so one reading is
# an average over an uptime and says nothing about which thread burned the
# window the deadline covered. A second reading written over the first leaves
# one file and no interval.
assert_file_contains '(CPU 0/KVM)' \
"$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-2/virt-launcher-a.txt"
[ ! -d "$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1" ]
rm -rf "$tmp"
}
@test "each reading records when it was taken" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
run_capture
capture="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1/virt-launcher-a.txt"
# The wait between the two passes is not the interval this subject's two
# readings span: the other two subjects' passes fall between them, and on the
# run this collector exists for those passes are the slow part. The stamps are
# what make the real interval readable off the pair.
assert_file_contains 'read attempted from' "$capture"
stamp=$(sed -n 's/.*read attempted from \([0-9][0-9]*\) to \([0-9][0-9]*\) epoch seconds.*/\1 \2/p' "$capture")
if [ -z "$stamp" ]; then
echo "expected a bare epoch stamp in $capture" >&2
cat "$capture" >&2
return 1
fi
rm -rf "$tmp"
}
@test "the legend names the field positions and the thread name that shifts them" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
run_capture
capture="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1/virt-launcher-a.txt"
# Raw lines with no legend are a trap rather than data. The two numbers a
# reader wants sit at fixed positions in /proc/<pid>/task/<tid>/stat, and the
# thread this capture exists to identify is the one whose name breaks the
# count: awk over whitespace reads the wrong column for exactly the vCPU
# threads and the right one for every other thread, which is the shape of a
# wrong answer that looks right.
assert_file_contains 'utime' "$capture"
assert_file_contains 'stime' "$capture"
assert_file_contains 'CPU 0/KVM' "$capture"
# The escape from the trap, not just a warning about it. A legend that names
# the hazard without naming the parse leaves the next reader to invent one.
assert_file_contains 'last )' "$capture"
rm -rf "$tmp"
}
@test "the legend names which thread each comm belongs to" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
run_capture
capture="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1/virt-launcher-a.txt"
# The whole discriminator is which of four kinds of thread holds the time, so
# a capture that does not say which name is which kind leaves the reader to
# guess the answer it was collected to settle.
assert_file_contains 'emulator' "$capture"
rm -rf "$tmp"
}
@test "the capture tells the reader how to pair it with its sibling sample" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
run_capture
capture="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1/virt-launcher-a.txt"
assert_file_contains 'subtract this Pod file from its sibling under the other sample directory' "$capture"
rm -rf "$tmp"
}
@test "a read that did not finish is not told to pair with a sibling" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
# Lines arrived and the read still failed, which is a stream cut off part way
# through the thread list.
kubectl_exec_rc=124
run_capture
capture="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1/virt-launcher-a.txt"
assert_file_contains 'these thread counters are incomplete' "$capture"
# Telling a reader to subtract two files asserts that both hold a whole
# reading. A difference taken against a truncated one understates whichever
# threads the read did not reach, and the threads it did not reach are the
# ones listed last -- so the understated set is not even random.
assert_file_lacks_pattern 'subtract this Pod file from its sibling' "$capture"
# And a legend that explains a column layout the file may not carry in full
# is the same overclaim one line down.
assert_file_lacks_pattern 'last \)' "$capture"
rm -rf "$tmp"
}
@test "a compute container that was not reached is recorded as unknown, not as an idle guest" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
kubectl_exec_rc=1
kubectl_exec_output=
kubectl_exec_stderr='Error from server: error dialing backend: remote error: tls: internal error'
run_capture
dir="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1"
capture="$dir/virt-launcher-a.txt"
# An empty file here reads as a guest whose threads were all idle, which is
# the one conclusion this collector must never manufacture: the container
# dying is a routine outcome on this failure path and it is not a reading.
assert_file_contains 'was not reached' "$capture"
assert_file_contains 'exec-error.log' "$capture"
assert_file_lacks_pattern 'subtract this Pod file from its sibling' "$capture"
assert_file_contains 'error dialing backend' "$dir/virt-launcher-a.exec-error.log"
rm -rf "$tmp"
}
@test "an exec that died without a word is not reported as a container that answered" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
# timeout kills its child without a word and kubectl dies on SIGTERM the same
# way, so the dominant failure here arrives non-zero with an empty error log.
# Keyed on stderr rather than on the status, the artifact would say the
# opposite of what happened.
kubectl_exec_rc=137
kubectl_exec_output=
kubectl_exec_stderr=
run_capture
dir="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1"
capture="$dir/virt-launcher-a.txt"
assert_file_contains 'was not reached' "$capture"
assert_file_contains 'without a word on either stream' "$capture"
# Naming a file that was never written sends the reader looking for evidence
# that does not exist.
[ ! -f "$dir/virt-launcher-a.exec-error.log" ]
assert_file_contains '[exit 137:' "$capture"
rm -rf "$tmp"
}
@test "a container that answered and said nothing is not reported as unreachable" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
kubectl_exec_rc=0
kubectl_exec_output=
run_capture
capture="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1/virt-launcher-a.txt"
# Two different problems that leave the same empty file: an exec that never
# landed, and a shell that ran and printed nothing. They are fixed by looking
# in different places, so they must not share a sentence.
assert_file_contains 'answered and returned nothing' "$capture"
assert_file_lacks_pattern 'was not reached' "$capture"
rm -rf "$tmp"
}
@test "a container whose QEMU is gone carries no legend for columns it does not have" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
kubectl_exec_rc=0
kubectl_exec_output='clock ticks per second: 100
NO-QEMU-PROCESS: no process whose name starts with qemu- exists in this container, so it had no threads to read'
run_capture
capture="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1/virt-launcher-a.txt"
# A clean read that found nothing to read is a finding about the failure, and
# a different one from every arm around it.
assert_file_contains 'nothing to split' "$capture"
# A legend explaining a column layout over a file with no columns is the same
# overclaim as a pairing instruction over a partial read, and it would send
# the reader looking for stat lines that are not there.
assert_file_lacks_pattern 'last \)' "$capture"
assert_file_lacks_pattern 'subtract this Pod file from its sibling' "$capture"
rm -rf "$tmp"
}
@test "a warning beside a healthy listing is not filed as a failure" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
kubectl_list_stderr='Warning: v1 Pod is deprecated in this cluster'
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
run_capture
dir="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1"
# kubectl writes deprecation and partial-result warnings with a zero exit, so
# a sink chosen by "something was written" files a healthy read as a failed
# one and the capture beside it reads as salvage.
[ ! -f "$dir/COLLECTION-FAILED.txt" ]
assert_file_contains 'deprecated' "$dir/READ-WARNINGS.txt"
rm -rf "$tmp"
}
@test "a warning beside a healthy exec is not filed as a failure" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
kubectl_exec_rc=0
kubectl_exec_stderr='Defaulted container "compute" out of: compute, volumecontainerdisk'
run_capture
dir="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1"
# kubectl writes deprecation and defaulted-container notices on stderr with a
# zero exit, so the sink is chosen by the status rather than by something
# having been written. Filed as an error, a healthy exec would look failed and
# the capture beside it would read as salvage; discarded, a real notice about
# the read would be gone. The two sibling collectors pin this branch, and the
# round that suppressed the probe's own stderr rests on it.
assert_file_contains 'Defaulted container' "$dir/virt-launcher-a.READ-WARNINGS.txt"
[ ! -f "$dir/virt-launcher-a.exec-error.log" ]
assert_file_contains '(CPU 0/KVM)' "$dir/virt-launcher-a.txt"
rm -rf "$tmp"
}
@test "a Pod list that never returned is not reported as no workers" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_list_rc=1
kubectl_list_stderr='Unable to connect to the server: dial tcp: i/o timeout'
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
run_capture || true
dir="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1"
# An apiserver that never answered and a namespace with no worker in it are
# different findings, and an empty directory is how they become one.
assert_file_contains 'failed to list' "$dir/COLLECTION-FAILED.txt"
assert_file_contains 'i/o timeout' "$dir/COLLECTION-FAILED.txt"
rm -rf "$tmp"
}
@test "a namespace with no worker Pod says so instead of leaving an empty tree" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names=
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
run_capture || true
dir="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1"
# Named for the filter the query carries. A namespace whose launchers are all
# Pending is a different finding from one with no launcher at all, and the
# sentence has to be true of the listing that was actually issued.
assert_file_contains 'no Running virt-launcher Pod' "$dir/COLLECTION-FAILED.txt"
rm -rf "$tmp"
}
@test "the walk is capped and names both counts when it stops" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a virt-launcher-b virt-launcher-c virt-launcher-d virt-launcher-e"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
run_capture
dir="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1"
# A short listing otherwise reads as a small pool rather than a truncated
# walk, and the two counts are what separate them. Asserted as the whole
# sentence, the way the sibling suites assert theirs: the marker's claim is
# that the two numbers DIFFER, and a pair of bare digit checks is satisfied by
# a marker reading "stopped after 4 Pods; 4 matched in total".
assert_file_contains 'capture stopped after 4 Pods; 5 matched in total' \
"$dir/COLLECTION-TRUNCATED.txt"
# And the cap's VALUE, not just its wording. The marker prints max_pods rather
# than what the walk read, so an off-by-one in the boundary test leaves the
# sentence byte-identical while a Pod goes unread -- and max_pods is the term
# the node-join budget guard multiplies, so the arithmetic and the marker
# would misstate the same run together.
[ -f "$dir/virt-launcher-d.txt" ]
[ ! -f "$dir/virt-launcher-e.txt" ]
rm -rf "$tmp"
}
@test "a pool below the cap leaves no truncation marker" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a virt-launcher-b"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
run_capture
dir="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1"
# A marker on every run is a marker nobody reads, and this one is the only
# thing that says a capture answered for part of the pool.
[ ! -f "$dir/COLLECTION-TRUNCATED.txt" ]
[ -f "$dir/virt-launcher-b.txt" ]
rm -rf "$tmp"
}
@test "a Pod whose read times out does not stop the next one" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a virt-launcher-b"
timeout_fail_pod=virt-launcher-a
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
run_capture
dir="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1"
# The two workers of one cluster are the comparison this capture is for, so a
# walk that abandons the rest after one wedged container answers half the
# question while looking like it answered all of it.
assert_file_contains '[exit 124:' "$dir/virt-launcher-a.txt"
assert_file_contains '(CPU 0/KVM)' "$dir/virt-launcher-b.txt"
rm -rf "$tmp"
}
@test "the probe finds the qemu process and reads every one of its threads" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
# A /proc the size of the one inside a virt-launcher: the QEMU process, a
# neighbour that is not it, and two threads under QEMU.
mkdir -p "$tmp/proc/41/task/41" "$tmp/proc/41/task/57" "$tmp/proc/9/task/9"
printf 'qemu-kvm\n' >"$tmp/proc/41/comm"
printf 'virt-launcher\n' >"$tmp/proc/9/comm"
printf '41 (qemu-kvm) S 1 41 41 0 -1 4194560 8100 0 0 0 4210 1180\n' >"$tmp/proc/41/task/41/stat"
printf '57 (CPU 0/KVM) R 1 41 41 0 -1 4194368 120 0 0 0 39120 2210\n' >"$tmp/proc/41/task/57/stat"
printf '9 (virt-launcher) S 0 9 9 0 -1 4194304 900 0 0 0 12 4\n' >"$tmp/proc/9/task/9/stat"
out=$(sh -c "$(_cozy_thread_cpu_probe)" probe "$tmp/proc")
printf '%s\n' "$out" >"$tmp/out"
# Both threads of the QEMU process, or the split this capture exists for is
# taken over a subset chosen by the probe rather than by the guest.
assert_file_contains '(qemu-kvm)' "$tmp/out"
assert_file_contains '(CPU 0/KVM)' "$tmp/out"
# And nothing from the neighbour: virt-launcher's own threads are outside the
# question and would be read as QEMU's under a heading that says QEMU's.
assert_file_lacks_pattern 'virt-launcher\) S' "$tmp/out"
rm -rf "$tmp"
}
@test "a process that vanished mid-walk leaves nothing on the probe's stderr" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
mkdir -p "$tmp/proc/41/task/41" "$tmp/proc/99"
printf 'qemu-kvm\n' >"$tmp/proc/41/comm"
printf '41 (qemu-kvm) S 1 41 41 0 -1 0 0 0 0 0 4210 1180\n' >"$tmp/proc/41/task/41/stat"
# An entry the glob finds and the read cannot open, which is what a thread
# exiting between the two looks like. A dangling symlink stages it the same
# way whatever uid the suite runs as, where a chmod would not.
ln -s /definitely-not-here "$tmp/proc/99/comm"
sh -c "$(_cozy_thread_cpu_probe)" probe "$tmp/proc" >"$tmp/out" 2>"$tmp/err"
# The probe's stderr is the collector's error artifact. A shell complaint
# about a process that had already exited lands there, and on a clean read the
# collector files it as a read warning -- a healthy capture shipping a warning
# about something that did not affect the reading, which is the misfiling
# every branch in this collector is written against.
if [ -s "$tmp/err" ]; then
echo "expected the probe to say nothing on stderr; it wrote:" >&2
cat "$tmp/err" >&2
return 1
fi
# And the suppression did not cost the reading it was protecting.
assert_file_contains '(qemu-kvm)' "$tmp/out"
rm -rf "$tmp"
}
@test "the probe says so when no qemu process exists rather than returning nothing" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
mkdir -p "$tmp/proc/9/task/9"
printf 'virt-launcher\n' >"$tmp/proc/9/comm"
printf '9 (virt-launcher) S 0 9 9 0 -1 4194304 900 0 0 0 12 4\n' >"$tmp/proc/9/task/9/stat"
out=$(sh -c "$(_cozy_thread_cpu_probe)" probe "$tmp/proc")
printf '%s\n' "$out" >"$tmp/out"
# A container whose QEMU has already exited is a finding about the failure,
# and silence from the probe would reach the artifact as a container that
# answered and said nothing -- a different problem, in a different place.
assert_file_contains 'NO-QEMU-PROCESS' "$tmp/out"
# And it reports having read the neighbour, because that is what separates
# this from a probe that could read nothing at all.
assert_file_lacks_pattern 'NO-PROC-READ' "$tmp/out"
rm -rf "$tmp"
}
@test "the probe says so when QEMU is named and its threads are already gone" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
# The race the probe runs into by construction: the comm read succeeds and
# the task directory is gone by the time it is opened. Staged as a process
# with no task tree at all, which is what the walk sees either way.
mkdir -p "$tmp/proc/41"
printf 'qemu-kvm\n' >"$tmp/proc/41/comm"
out=$(sh -c "$(_cozy_thread_cpu_probe)" probe "$tmp/proc")
printf '%s\n' "$out" >"$tmp/out"
# The heading alone makes the capture non-empty, so nothing downstream can
# tell this from a reading unless the probe says it. Reported as its own
# outcome rather than as "no QEMU": QEMU was there, which is a different
# sentence about the guest.
assert_file_contains 'NO-THREAD-LINES' "$tmp/out"
assert_file_lacks_pattern 'NO-QEMU-PROCESS' "$tmp/out"
rm -rf "$tmp"
}
@test "a capture carrying only a process heading is not given a column legend" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
kubectl_exec_rc=0
kubectl_exec_output='clock ticks per second: 100
process 41 comm qemu-kvm
NO-THREAD-LINES: a qemu-* process was named and not one of its task stat files could be read, so it exited between the two reads'
run_capture
capture="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1/virt-launcher-a.txt"
assert_file_contains 'threads were gone before they could be read' "$capture"
# The whole hazard: a heading is enough to make the file non-empty, so every
# "was it read" arm above passes and the legend would describe columns this
# capture does not have.
assert_file_lacks_pattern 'last \)' "$capture"
assert_file_lacks_pattern 'subtract this Pod file from its sibling' "$capture"
rm -rf "$tmp"
}
@test "the probe reports a proc it could not read apart from a container without QEMU" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
# A proc mount with nothing readable under it. The two outcomes are fixed in
# different places, and only one of them is about the guest: reported as the
# other, the artifact would state that QEMU had exited on the strength of a
# read that never happened.
mkdir -p "$tmp/proc"
out=$(sh -c "$(_cozy_thread_cpu_probe)" probe "$tmp/proc")
printf '%s\n' "$out" >"$tmp/out"
assert_file_contains 'NO-PROC-READ' "$tmp/out"
assert_file_lacks_pattern 'NO-QEMU-PROCESS' "$tmp/out"
rm -rf "$tmp"
}
@test "a proc the probe could not read is not written up as a guest without QEMU" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-a"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=thread-smoke
kubectl_exec_rc=0
kubectl_exec_output='clock ticks per second: 100
NO-PROC-READ: not one process could be read under the proc mount, so this says nothing about what the container was running'
run_capture
capture="$COZY_REPORT_DIR/snapshots/thread-smoke/tenant-thread-cpu/sample-1/virt-launcher-a.txt"
# The collector has to carry the distinction the probe drew, or drawing it
# bought nothing: a statement about the probe filed as a statement about the
# guest is the conflation this whole collector is built against.
assert_file_contains 'the thread split is unknown' "$capture"
assert_file_lacks_pattern 'nothing to split' "$capture"
assert_file_lacks_pattern 'last \)' "$capture"
rm -rf "$tmp"
}
@test "the probe reports the tick rate its two numbers are counted in" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
mkdir -p "$tmp/proc/9/task/9"
printf 'virt-launcher\n' >"$tmp/proc/9/comm"
out=$(sh -c "$(_cozy_thread_cpu_probe)" probe "$tmp/proc")
printf '%s\n' "$out" >"$tmp/out"
# utime and stime are clock ticks, and the figure this capture is read
# against is cores. Without the rate on the same page the reader supplies one
# from memory, which is where a factor of ten enters a measurement.
assert_file_contains 'clock ticks per second' "$tmp/out"
rm -rf "$tmp"
}
@test "with timeout off PATH the exec still runs instead of exiting 127" {
# The fallback arm is what the phase-start warning promises for this
# collector: on a runner without the binary it runs UNBOUNDED rather than
# exiting 127 and leaving an empty directory. A promise the code does not keep
# is what this collector's every other branch is against, so the arm is
# entered here rather than inspected.
#
# A subprocess with a stripped PATH, because this file mocks `timeout` as a
# shell function and `command -v` finds functions -- the condition cannot be
# reached from inside a test body.
tmp=$(mktemp -d)
mkdir -p "$tmp/bin"
# Everything the collector shells out to, minus `timeout` itself. Missing one
# would fail this test for the wrong reason and read as the arm being broken.
for c in mkdir sort grep mv rm wc tr cat date; do
for d in /bin /usr/bin /usr/local/bin /opt/homebrew/bin; do
if [ -x "$d/$c" ]; then
ln -sf "$d/$c" "$tmp/bin/$c"
break
fi
done
done
for c in mkdir sort grep mv rm wc tr cat date; do
if [ ! -x "$tmp/bin/$c" ]; then
echo "FAIL: could not stage $c in the stripped PATH; the check below would be vacuous" >&2
return 1
fi
done
# Only the staged directory is checked. `command -v timeout` is always true
# here -- this file defines it as a shell function, which is the very fact the
# subprocess exists to escape.
if [ -e "$tmp/bin/timeout" ]; then
echo "FAIL: timeout leaked into the stripped PATH; this test would prove nothing" >&2
return 1
fi
out=$(bash -c '
set -eu
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
kubectl() {
# To a file, not to stderr. The collector redirects the stderr of every
# read into its own artifact, so a stub that announces itself there is
# captured by the code under test and never reaches this output.
printf "KUBECTL %s\n" "$*" >>"$CALLS"
if [ "${3:-}" = get ] && [ "${4:-}" = pods ]; then printf "virt-launcher-a\n"; return 0; fi
if [ "${3:-}" = exec ]; then printf "41 (qemu-kvm) S 1 41 41 0 -1 0 0 0 0 0 4210 1180\n"; return 0; fi
return 0
}
CALLS='"$tmp"'/calls
COZY_REPORT_DIR='"$tmp"'/report
COZY_SNAPSHOT_NAME=fallback
PATH='"$tmp"'/bin
cozy_capture_tenant_worker_thread_cpu 1
' 2>&1) || true
printf '%s\n' "$out" >"$tmp/out"
# The read ran at all -- bounded into exit 127 would leave nothing here.
assert_file_contains 'KUBECTL -n tenant-test exec virt-launcher-a -c compute' "$tmp/calls"
# And produced thread lines rather than an "unknown" label.
assert_file_contains '(qemu-kvm)' \
"$tmp/report/snapshots/fallback/tenant-thread-cpu/sample-1/virt-launcher-a.txt"
rm -rf "$tmp"
}
@test "the collector's own caps stay what its comments claim" {
lib=hack/e2e-chainsaw/_lib/run-kubernetes.sh
# The Pod listing lives in the body this collector shares with the cAdvisor
# captures, so the subject here is the code the collector runs rather than the
# function that carries its name; reading only the named function would leave
# the listing assertions vacuously true against a body it does not contain.
fn=$(awk '/^cozy_capture_tenant_worker_thread_cpu\(\)/,/^}/' "$lib"
awk '/^_cozy_virt_launcher_listing\(\)/,/^}/' "$lib")
if [ -z "$fn" ]; then
echo "expected to find the collector in $lib" >&2
return 1
fi
case "$fn" in
*'max_pods=4'*) ;;
*) echo "expected the collector to cap the walk at the 4 Pods the comments state" >&2; return 1 ;;
esac
# The knob, not a literal that equals it today. A hardcoded 20 keeps its value
# when the block's budget is lowered, so the one read that ignores the knob is
# the one that outlives it.
case "$fn" in
*'timeout -k "${COZY_DIAG_READ_GRACE}" "${COZY_DIAG_READ_TIMEOUT}"'*) ;;
*) echo "expected each read to take the block's read-budget knob" >&2; return 1 ;;
esac
case "$fn" in
*'timeout -k 5 20'*) echo "expected no hardcoded bound to survive beside the knob" >&2; return 1 ;;
esac
# The listing is an apiserver request and carries the HTTP bound as well as
# the wall-clock one; without it a client retrying against a wedged apiserver
# is bounded only by the wrapper.
reads=$(printf '%s\n' "$fn" | grep -c 'kubectl -n tenant-test get pods' || true)
bounded=$(printf '%s\n' "$fn" | grep -A3 'kubectl -n tenant-test get pods' | grep -c -- '--request-timeout=' || true)
if [ "$reads" -eq 0 ] || [ "$reads" -ne "$bounded" ]; then
echo "every Pod listing must carry --request-timeout; $bounded of $reads do" >&2
return 1
fi
}
@test "the capture is declined by the phase budget rather than running unconditionally" {
lib=hack/e2e-chainsaw/_lib/run-kubernetes.sh
gate=$(grep -n "^ *if cozy_diag_phase_has_time '(d) tenant worker CPU counters, sandbox node CPU time and worker per-thread CPU time'; then$" \
"$lib" | head -n 1 | cut -d: -f1)
call=$(grep -n '^ *cozy_capture_tenant_worker_thread_cpu "${_sample}" || true$' "$lib" | head -n 1 | cut -d: -f1)
if [ -z "$gate" ] || [ -z "$call" ]; then
echo "expected the capture to sit behind a cozy_diag_phase_has_time gate" >&2
return 1
fi
# THIS gate, not just some gate above the call. A line comparison alone is
# satisfied by any earlier gate in the block, which would leave the capture
# guarded by a decision taken about something else.
next=$(awk -v g="$gate" 'NR > g && /cozy_diag_phase_has_time/ { print NR; exit }' "$lib")
if [ -z "$next" ]; then
echo "expected another phase gate after line $gate, so this test could bound the section" >&2
return 1
fi
if [ "$call" -le "$gate" ] || [ "$call" -ge "$next" ]; then
echo "the capture (line $call) must sit between its gate ($gate) and the next one ($next)" >&2
return 1
fi
}
@test "the capture shares the pass the other two subjects already wait for" {
lib=hack/e2e-chainsaw/_lib/run-kubernetes.sh
# A second wait would be paid on every failing run for a second rate over the
# same window, and the window is already the scarce term: the phase declines
# whatever has not started, so an added pause is taken out of the collectors
# below it rather than out of slack.
waits=$(grep -c '^ *sleep "\${COZY_DIAG_RATE_INTERVAL}"$' "$lib" || true)
if [ "$waits" -ne 1 ]; then
echo "expected exactly one measurement wait in the block, found $waits" >&2
return 1
fi
loop=$(grep -n '^ *for _sample in 1 2; do$' "$lib" | head -n 1 | cut -d: -f1)
done_line=$(awk -v l="$loop" 'NR > l && /^ *done$/ { print NR; exit }' "$lib")
call=$(grep -n '^ *cozy_capture_tenant_worker_thread_cpu "${_sample}" || true$' "$lib" | head -n 1 | cut -d: -f1)
for v in loop done_line call; do
eval "n=\$$v"
if [ -z "$n" ]; then
echo "expected to locate $v in $lib" >&2
return 1
fi
done
if [ "$call" -le "$loop" ] || [ "$call" -ge "$done_line" ]; then
echo "the capture (line $call) must run inside the existing sampling loop ($loop..$done_line)" >&2
return 1
fi
}
@test "the capture runs behind the console it could otherwise starve" {
lib=hack/e2e-chainsaw/_lib/run-kubernetes.sh
call=$(grep -n '^ *cozy_capture_tenant_worker_thread_cpu "${_sample}" || true$' "$lib" | head -n 1 | cut -d: -f1)
console=$(grep -n "cozy_capture_tenant_serial_console 'node-join failed" "$lib" | head -n 1 | cut -d: -f1)
if [ -z "$call" ] || [ -z "$console" ]; then
echo "expected the failure path to call both the console and this capture" >&2
return 1
fi
# Admission gates only when a collector may START, so a collector read twice
# and placed ahead of the console can leave the console never started rather
# than cut short -- and the console is the only surface that survives a worker
# which never reached apid, the shape this failure usually takes.
if [ "$call" -le "$console" ]; then
echo "the thread capture (line $call) must run after the console capture (line $console)" >&2
return 1
fi
}