hack/run-kubernetes-thread-cpu_test.bats
Rama main · réplica de lectura del repositorio original.
#!/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
}