Files
cozystack/hack/run-kubernetes-serial-console_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

1630 lines
66 KiB
Bash

#!/usr/bin/env bats
# Regression coverage for tenant-worker guest serial console 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.
#
# The capture exists for the one failure class no other collector reaches: a
# worker that stalls in the guest before Talos apid answers. Its defining
# property is therefore what it does NOT need — no talosctl, no reader
# certificate, no helper Pod, nothing inside the guest. These tests pin that
# property, because a capture that grew an in-guest dependency would still look
# healthy while being blind to exactly the runs it was written for.
load test_helper
kubectl_calls=/dev/null
kubectl_pod_names=
kubectl_list_rc=0
kubectl_list_stderr=
kubectl_init_names=
kubectl_init_rc=0
kubectl_wedge_rows=
kubectl_wedge_rc=0
kubectl_wedge_stderr=
mktemp_fail=0
kubectl_logs_output='mock console output'
kubectl_logs_rc=0
kubectl_logs_stderr=
kubectl_timeline_rows='name=virt-launcher-worker-a-11111 phase=Running podStartTime=2026-08-15T14:35:26Z computeStartedAt=2026-08-15T14:35:47Z consoleStartedAt=2026-08-15T14:35:47Z'
kubectl_timeline_rc=0
kubectl_timeline_stderr=
talosctl_calls=/dev/null
timeout_calls=/dev/null
timeout_fail_pod=
date_now=
date() {
# Same shape the node-join suite uses: a fixed clock so the passing path's
# remaining-time arithmetic is decided by the test rather than by when it ran.
if [ -n "${date_now}" ] && [ "${1:-}" = +%s ]; then
printf '%s\n' "${date_now}"
return 0
fi
command date "$@"
}
kubectl() {
printf '%s\n' "$*" >>"${kubectl_calls}"
if [ "${3:-}" = get ] && [ "${4:-}" = pods ]; then
case "$*" in
*computeStartedAt*)
# Rows first, status after, so a cut-off read can be modelled as what it
# is: a prefix that arrived plus a non-zero status. Returning early on
# the status instead would make "failed" and "returned nothing" the same
# scenario, and the branch that tells them apart untestable.
#
# stderr is staged separately for the same reason every other arm here
# stages it: kubectl writes warnings beside a healthy read as well as
# errors beside a failed one, and which file those land in is decided by
# the status rather than by anything in the text.
[ -z "${kubectl_timeline_stderr}" ] || printf '%s\n' "${kubectl_timeline_stderr}" >&2
[ -z "${kubectl_timeline_rows}" ] || printf '%s\n' "${kubectl_timeline_rows}"
return "${kubectl_timeline_rc}"
;;
*initContainerStatuses*)
[ -z "${kubectl_wedge_stderr}" ] || printf '%s\n' "${kubectl_wedge_stderr}" >&2
[ "${kubectl_wedge_rc}" -eq 0 ] || return "${kubectl_wedge_rc}"
[ -z "${kubectl_wedge_rows}" ] || printf '%s\n' "${kubectl_wedge_rows}"
return 0
;;
*initContainers*)
[ "${kubectl_init_rc}" -eq 0 ] || return "${kubectl_init_rc}"
[ -z "${kubectl_init_names}" ] || printf '%s\n' "${kubectl_init_names}"
return 0
;;
esac
[ -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:-}" = logs ]; then
[ -z "${kubectl_logs_stderr}" ] || printf '%s\n' "${kubectl_logs_stderr}" >&2
[ "${kubectl_logs_rc}" -eq 0 ] || return "${kubectl_logs_rc}"
[ -z "${kubectl_logs_output}" ] || printf '%s for %s\n' "${kubectl_logs_output}" "${4:-}"
return 0
fi
return 0
}
talosctl() {
printf '%s\n' "$*" >>"${talosctl_calls}"
}
timeout() {
local command_rc=0
printf '%s\n' "$*" >>"${timeout_calls}"
[ "${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}"
}
mktemp() {
# The scratch file the wedge check allocates is the one failure the function
# cannot report through kubectl, so it needs its own knob. `-d` is left
# working because the tests themselves use it for their own temp dirs.
if [ "${mktemp_fail}" = 1 ] && [ "${1:-}" != -d ]; then
return 1
fi
command mktemp "$@"
}
assert_file_contains() {
local needle="$1"
local file="$2"
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: awk exits 2 on an
# unreadable path, which is indistinguishable from "no line matched" once the
# status is folded into an if.
if [ ! -f "${file}" ]; then
printf 'expected %s to exist so it could be checked for: %s\n' "${file}" "${pattern}" >&2
return 1
fi
if awk -v pattern="${pattern}" '$0 ~ pattern { found = 1 } END { exit found ? 0 : 1 }' "${file}"; then
printf 'expected %s not to match: %s\n' "${file}" "${pattern}" >&2
return 1
fi
}
# 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 is emitted 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 would
# put in them.
run_capture() {
local _rc=0
( set +x; cozy_capture_tenant_serial_console "${1:-capture staged by a unit test}" "${2:-6}" ) || _rc=$?
return "${_rc}"
}
@test "a wedged console container is named in the headline, not buried" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_wedge_rows="$(printf 'false PodInitializing virt-launcher-worker-a-11111\nfalse PodInitializing virt-launcher-worker-b-22222')"
( set +x; cozy_report_guest_console_wedge ) >"$tmp/out" 2>&1
# If kubevirt/kubevirt#15989 still bites, the suite's own headline reads
# "fewer than 2 tenant nodes Ready within 29m" -- byte-identical to the
# failure this instrumentation exists to study. A triager would file the
# experiment's answer as the known flake. The diagnostic has to name its
# own worst case before the noise starts.
assert_file_contains 'guest-console-log' "$tmp/out"
assert_file_contains '15989' "$tmp/out"
assert_file_contains 'virt-launcher-worker-a-11111' "$tmp/out"
rm -rf "$tmp"
}
@test "a read that never answered is not reported as a healthy console" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_wedge_rc=1
kubectl_wedge_rows="$(printf 'false PodInitializing virt-launcher-worker-a-11111')"
( set +x; cozy_report_guest_console_wedge ) >"$tmp/out" 2>&1
# The guard against a false headline must not become a false all-clear.
# An apiserver blip here would otherwise leave the reader with no line at
# all, which reads as "the console container is fine" -- nothing was
# checked. Same rule the sibling attach check follows.
assert_file_lacks_pattern '15989' "$tmp/out"
assert_file_contains 'unknown, not fine' "$tmp/out"
rm -rf "$tmp"
}
@test "the wedge row carries the waiting reason, not just the ready bit" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_wedge_rows="$(printf 'false PodInitializing virt-launcher-worker-a-11111')"
( set +x; cozy_report_guest_console_wedge ) >"$tmp/out" 2>&1
# ready=false on a sidecar means "not running", which a terminated Pod
# satisfies as well as a wedged one. The reason keeps the two apart.
assert_file_contains 'PodInitializing' "$tmp/out"
assert_file_contains 'state.waiting.reason' "$kubectl_calls"
rm -rf "$tmp"
}
@test "the taxonomy does not claim to be exhaustive" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_wedge_rows="$(printf 'false OOMKilled virt-launcher-worker-a-11111')"
( set +x; cozy_report_guest_console_wedge ) >"$tmp/out" 2>&1
# Two named reasons do not cover the space -- OOMKilled and ContainerCreating
# reach this line too, and a superseded Pod killed past its grace period
# reads Error. A headline that enumerates two and stops implies the rest
# cannot happen, which is the over-claim this whole line was rewritten to
# stop making.
assert_file_contains 'any other reason, or none, is not covered' "$tmp/out"
rm -rf "$tmp"
}
@test "the wedge taxonomy names CrashLoopBackOff as the bug, not as a superseded Pod" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_wedge_rows="$(printf 'false CrashLoopBackOff virt-launcher-worker-a-11111')"
( set +x; cozy_report_guest_console_wedge ) >"$tmp/out" 2>&1
# The sidecar restarts on failure, so the hang settles into CrashLoopBackOff
# and passes through Error. Filing those under "superseded Pod" would hand a
# triager the experiment's own failure labelled as noise -- the precise
# inversion this function exists to prevent.
assert_file_contains 'CrashLoopBackOff or Error below is the wedge shape' "$tmp/out"
assert_file_lacks_pattern 'Completed or Error below means a superseded' "$tmp/out"
rm -rf "$tmp"
}
@test "a superseded Pod is labelled, not called a wedge" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_wedge_rows="$(printf 'false Completed virt-launcher-worker-a-11111')"
( set +x; cozy_report_guest_console_wedge ) >"$tmp/out" 2>&1
# ContainerState is a union, so a waiting reason alone is blank for the
# terminated case and cannot separate it from anything else at ready=false.
# The terminated reason is what labels the superseded Pod, and Completed is
# the shape that means superseded rather than wedged.
assert_file_contains 'Completed' "$tmp/out"
assert_file_contains 'state.terminated.reason' "$kubectl_calls"
rm -rf "$tmp"
}
@test "the headline points at the describe rather than concluding" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_wedge_rows="$(printf 'false virt-launcher-worker-a-11111')"
( set +x; cozy_report_guest_console_wedge ) >"$tmp/out" 2>&1
# The bit cannot separate every shape, so the line must not assert one. A
# headline that concludes more than it established is the same mislabel this
# function exists to prevent, pointing the other way.
assert_file_contains 'if these are current Pods' "$tmp/out"
assert_file_contains 'describe that follows settles it' "$tmp/out"
rm -rf "$tmp"
}
@test "a scratch file that could not be allocated is not silence either" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_wedge_rows="$(printf 'false CrashLoopBackOff virt-launcher-worker-a-11111')"
mktemp_fail=1
( set +x; cozy_report_guest_console_wedge ) >"$tmp/out" 2>&1
mktemp_fail=0
# The function argues twelve lines further down that silence would read as
# "the console container is fine" when nothing was checked. That argument
# does not stop applying one branch earlier.
assert_file_contains 'unknown, not fine' "$tmp/out"
assert_file_lacks_pattern '15989' "$tmp/out"
rm -rf "$tmp"
}
@test "a failed read keeps the reason it failed" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_wedge_rc=1
kubectl_wedge_stderr="Error from server: etcdserver: leader changed"
( set +x; cozy_report_guest_console_wedge ) >"$tmp/out" 2>&1
# Discarding stderr here would report a bare exit code for a failure whose
# cause kubectl already named, on the one path that exists to stop a reader
# guessing. Same rule the sibling attach check follows.
assert_file_contains 'leader changed' "$tmp/out"
rm -rf "$tmp"
}
@test "a healthy console container says nothing in the headline" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_wedge_rows="$(printf 'true virt-launcher-worker-a-11111')"
( set +x; cozy_report_guest_console_wedge ) >"$tmp/out" 2>&1
# A marker that fires on healthy runs is one a reader learns to skip.
assert_file_lacks_pattern '15989' "$tmp/out"
rm -rf "$tmp"
}
@test "the wedge check runs before the rest of the node-join diagnostics" {
lib=hack/e2e-chainsaw/_lib/run-kubernetes.sh
head=$(grep -n 'node-join failed: fewer than 2 tenant nodes Ready' "$lib" | head -n 1 | cut -d: -f1)
wedge=$(grep -n '^ *cozy_report_guest_console_wedge || true$' "$lib" | head -n 1 | cut -d: -f1)
first=$(grep -n 'describe nodes' "$lib" | head -n 1 | cut -d: -f1)
if [ -z "$wedge" ]; then
echo "expected the node-join failure path to check for a wedged console container" >&2
return 1
fi
if [ "$wedge" -le "$head" ] || [ "$wedge" -ge "$first" ]; then
echo "the wedge check (line $wedge) must sit between the headline ($head) and the first diagnostic ($first)" >&2
return 1
fi
}
@test "console capture needs nothing from inside the guest" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
talosctl_calls="$tmp/talosctl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="virt-launcher-worker-a-11111"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-smoke
run_capture
assert_file_contains 'logs virt-launcher-worker-a-11111 -c guest-console-log' "$kubectl_calls"
assert_file_lacks_pattern 'certificate|exec|apply|talosconfig' "$kubectl_calls"
[ ! -s "$talosctl_calls" ]
assert_file_contains 'mock console output' "$COZY_REPORT_DIR/snapshots/console-smoke/tenant-serial-console/virt-launcher-worker-a-11111.log"
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-worker-a-11111"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-bounded
run_capture
assert_file_contains '-k 5 30 kubectl -n tenant-test logs virt-launcher-worker-a-11111 -c guest-console-log' "$timeout_calls"
assert_file_contains '[capture exit code: 0]' "$COZY_REPORT_DIR/snapshots/console-bounded/tenant-serial-console/virt-launcher-worker-a-11111.log"
rm -rf "$tmp"
}
@test "the read keeps the boot output instead of the tail" {
. 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-worker-a-11111"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-head
run_capture
# A guest that stalls before apid is diagnosed from what it printed while
# booting, and a guest that instead repaints the console indefinitely would
# push that boot output out of any --tail window. --limit-bytes truncates
# from the far end instead, which keeps whatever the kubelet still holds.
# It cannot recover what container-log rotation already dropped.
assert_file_contains '--limit-bytes=' "$kubectl_calls"
assert_file_lacks_pattern '--tail' "$kubectl_calls"
rm -rf "$tmp"
}
@test "a pod whose read times out does not stop the next 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-worker-a-11111 virt-launcher-worker-b-22222"
timeout_fail_pod=virt-launcher-worker-a-11111
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-partial
run_capture
assert_file_contains '[capture exit code: 124]' "$COZY_REPORT_DIR/snapshots/console-partial/tenant-serial-console/virt-launcher-worker-a-11111.log"
assert_file_contains '[capture exit code: 0]' "$COZY_REPORT_DIR/snapshots/console-partial/tenant-serial-console/virt-launcher-worker-b-22222.log"
rm -rf "$tmp"
}
@test "an empty virt-launcher list is recorded rather than passed over" {
. 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=console-empty
capture_rc=0
run_capture || capture_rc=$?
[ "$capture_rc" -eq 1 ]
assert_file_contains 'no virt-launcher Pod found' "$COZY_REPORT_DIR/snapshots/console-empty/tenant-serial-console/setup-error.log"
assert_file_lacks_pattern 'guest-console-log' "$timeout_calls"
rm -rf "$tmp"
}
@test "a failed Pod listing is recorded rather than passed over" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_list_rc=1
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-list-error
capture_rc=0
run_capture || capture_rc=$?
[ "$capture_rc" -eq 1 ]
assert_file_contains 'failed to list tenant virt-launcher Pods' "$COZY_REPORT_DIR/snapshots/console-list-error/tenant-serial-console/setup-error.log"
assert_file_lacks_pattern 'guest-console-log' "$timeout_calls"
rm -rf "$tmp"
}
@test "a warning on a successful listing is kept apart from a setup 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-worker-a-11111"
kubectl_list_stderr="W0807 partial results warning"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-warn
run_capture
# kubectl writes to stderr and exits 0 for warnings and for "No resources
# found" alike, so the exit status decides the file. A warning filed as a
# setup error reads as a broken collector on an otherwise healthy run.
assert_file_contains 'partial results warning' "$COZY_REPORT_DIR/snapshots/console-warn/tenant-serial-console/READ-WARNINGS.txt"
[ ! -f "$COZY_REPORT_DIR/snapshots/console-warn/tenant-serial-console/setup-error.log" ]
rm -rf "$tmp"
}
@test "a healthy listing leaves no warnings file behind" {
. 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-worker-a-11111"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-quiet
run_capture
[ ! -f "$COZY_REPORT_DIR/snapshots/console-quiet/tenant-serial-console/READ-WARNINGS.txt" ]
[ ! -f "$COZY_REPORT_DIR/snapshots/console-quiet/tenant-serial-console/setup-error.log" ]
rm -rf "$tmp"
}
@test "a failed listing keeps the stderr that explains it" {
. 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="Error from server (Forbidden): pods is forbidden"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-forbidden
capture_rc=0
run_capture || capture_rc=$?
[ "$capture_rc" -eq 1 ]
assert_file_contains 'Forbidden' "$COZY_REPORT_DIR/snapshots/console-forbidden/tenant-serial-console/setup-error.log"
rm -rf "$tmp"
}
@test "the attach check passes when the console container is present" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_init_names="virt-launcher-worker-a-11111=guest-console-log"
attach_rc=0
( set +x; cozy_assert_guest_console_attached ) || attach_rc=$?
[ "$attach_rc" -eq 0 ]
rm -rf "$tmp"
}
@test "a matched Pod with no init containers at all is the finding, not a miss" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_init_names="virt-launcher-worker-a-11111="
attach_rc=0
( set +x; cozy_assert_guest_console_attached ) || attach_rc=$?
# This is the shape an inert setting actually produces here: guest-console-log
# is the only init container these Pods ever get, so "the field did not take
# effect" means initContainers is absent, not that some other name appears.
# Reading a bare list of container names would render it byte-identical to
# "no Pod matched" and let the suite pass over the one thing this checks.
[ "$attach_rc" -eq 2 ]
rm -rf "$tmp"
}
@test "a read that never answered is not reported as an inert setting" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_init_rc=1
attach_rc=0
( set +x; cozy_assert_guest_console_attached ) 2>"$tmp/err" || attach_rc=$?
# Piping the read into grep would collapse this onto the absent-container
# branch: no output, grep fails, and an API blip gets published as "the
# override did not take effect" -- a cause nothing established. The message
# is asserted, not just the code: a failed read and a read that matched
# nothing share an exit code but are different facts, and only the status
# tells them apart.
[ "$attach_rc" -eq 1 ]
assert_file_contains 'could not read tenant virt-launcher Pods' "$tmp/err"
rm -rf "$tmp"
}
@test "a read that matched no Pod is not reported as an inert setting either" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_init_names=
attach_rc=0
( set +x; cozy_assert_guest_console_attached ) 2>"$tmp/err" || attach_rc=$?
# A selector that matched nothing says nothing about any Pod's containers,
# so it belongs with the failed read rather than with the absent container.
[ "$attach_rc" -eq 1 ]
assert_file_contains 'no tenant virt-launcher Pod matched' "$tmp/err"
rm -rf "$tmp"
}
@test "the absent-container diagnostics are bounded like the check itself" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_init_names="virt-launcher-worker-a-11111="
attach_rc=0
( set +x; cozy_assert_guest_console_attached ) || attach_rc=$?
[ "$attach_rc" -eq 2 ]
# These run on a path that ends in exit 1; an unbounded hang here would take
# the step's deadline and the tenant snapshot the exit is meant to reach.
[ "$(grep -c 'get virtualmachineinstances' "$timeout_calls")" -eq 1 ]
[ "$(grep -c 'get pods' "$timeout_calls")" -eq 2 ]
rm -rf "$tmp"
}
@test "a silent read records whether the container ever started" {
. 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-worker-a-11111"
kubectl_logs_output=
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-silent
run_capture
# An empty console log has two causes that look identical in the artifact:
# the guest printed nothing, or the container never started so there was no
# stream at all. The second is what kubevirt/kubevirt#15989 produces, and it
# is the reading that would otherwise be filed as "the guest was silent".
# The Pod's own state is the only thing that separates them.
d="$COZY_REPORT_DIR/snapshots/console-silent/tenant-serial-console"
assert_file_contains 'describe pod' "$d/POD-STATE.txt"
assert_file_contains 'events' "$d/POD-STATE.txt"
assert_file_contains 'returned nothing' "$d/virt-launcher-worker-a-11111.log"
rm -rf "$tmp"
}
@test "a container that never started is not called truncated or silent" {
. 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-worker-a-11111"
kubectl_logs_output=
kubectl_logs_rc=1
kubectl_logs_stderr="Error from server (BadRequest): container guest-console-log is not valid for pod virt-launcher-worker-a-11111"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-absent
run_capture
# This is the shape kubevirt/kubevirt#15989 produces and the headline case
# for the whole collector. Folding stderr into the capture would make the
# file non-empty and the console would read as having said something;
# nothing was truncated, because nothing was ever read.
d="$COZY_REPORT_DIR/snapshots/console-absent/tenant-serial-console"
assert_file_contains 'no console at all' "$d/virt-launcher-worker-a-11111.log"
assert_file_lacks_pattern 'truncated' "$d/virt-launcher-worker-a-11111.log"
assert_file_contains 'is not valid for pod' "$d/virt-launcher-worker-a-11111.read-error.log"
[ -f "$d/POD-STATE.txt" ]
rm -rf "$tmp"
}
@test "a read killed without a word does not point at a missing file" {
. 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-worker-a-11111"
kubectl_logs_output=
timeout_fail_pod=virt-launcher-worker-a-11111
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-killed
run_capture
# timeout kills the child without a word, so both streams can be empty.
# Naming read-error.log then sends the reader after evidence that was
# never written.
d="$COZY_REPORT_DIR/snapshots/console-killed/tenant-serial-console"
[ ! -f "$d/virt-launcher-worker-a-11111.read-error.log" ]
assert_file_contains 'failed silently' "$d/virt-launcher-worker-a-11111.log"
assert_file_lacks_pattern 'see read-error.log' "$d/virt-launcher-worker-a-11111.log"
rm -rf "$tmp"
}
@test "a warning beside a healthy capture is not filed as a read error" {
. 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-worker-a-11111"
kubectl_logs_stderr="Warning: deprecated flag"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-podwarn
run_capture
# Same rule the Pod-list read follows: kubectl writes warnings on stderr
# with a zero status, so presence of stderr does not make it an error.
d="$COZY_REPORT_DIR/snapshots/console-podwarn/tenant-serial-console"
assert_file_contains 'deprecated flag' "$d/virt-launcher-worker-a-11111.READ-WARNINGS.txt"
[ ! -f "$d/virt-launcher-worker-a-11111.read-error.log" ]
[ ! -f "$d/POD-STATE.txt" ]
rm -rf "$tmp"
}
@test "a healthy read leaves no read-error file" {
. 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-worker-a-11111"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-noerr
run_capture
[ ! -f "$COZY_REPORT_DIR/snapshots/console-noerr/tenant-serial-console/virt-launcher-worker-a-11111.read-error.log" ]
rm -rf "$tmp"
}
@test "a truncated read is not reported as silence" {
. 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-worker-a-11111"
timeout_fail_pod=virt-launcher-worker-a-11111
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-truncated
out=$( ( set +x; cozy_capture_tenant_serial_console 'a unit test' 6 ) 2>/dev/null )
printf '%s\n' "$out" >"$tmp/out"
# The read broke off mid-stream, so the file holds a prefix. Calling that
# "no console output" is the same unestablished claim about silence the
# Pod-state capture exists to prevent.
f="$COZY_REPORT_DIR/snapshots/console-truncated/tenant-serial-console/virt-launcher-worker-a-11111.log"
assert_file_contains 'mock console output' "$f"
assert_file_contains 'truncated' "$f"
assert_file_lacks_pattern 'no console output' "$f"
# And the summary line the passing path reads must not undo that. It is fed
# by the same counter that gates the Pod-state read, which a cut-short console
# needs as much as an empty one -- so counting them together is right and
# naming them together is not. On the green path this line is the only thing
# anyone sees, and a partial baseline reported as no baseline sends the reader
# past evidence that is sitting in the artifact.
assert_file_contains 'of the 1 guest consoles read, 0 came back empty and 1 were cut short' "$tmp/out"
rm -rf "$tmp"
}
@test "a failed read records the Pod state too" {
. 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-worker-a-11111"
timeout_fail_pod=virt-launcher-worker-a-11111
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-failed-read
run_capture
assert_file_contains 'describe pod' "$COZY_REPORT_DIR/snapshots/console-failed-read/tenant-serial-console/POD-STATE.txt"
rm -rf "$tmp"
}
@test "a healthy capture leaves no Pod-state file" {
. 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-worker-a-11111"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-healthy
run_capture
[ ! -f "$COZY_REPORT_DIR/snapshots/console-healthy/tenant-serial-console/POD-STATE.txt" ]
rm -rf "$tmp"
}
@test "the Pod-state reads are bounded and taken once for the whole selector" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="p1 p2 p3"
kubectl_logs_output=
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-once
run_capture
# One describe and one events read for the selector, not per Pod: three
# silent workers is one finding, and paying for it per Pod would put an
# unbounded term back into the failure path the cap exists to bound.
[ "$(grep -c 'describe pods' "$timeout_calls")" -eq 1 ]
[ "$(grep -c 'get events' "$timeout_calls")" -eq 1 ]
rm -rf "$tmp"
}
@test "the attach check accepts the container wherever KubeVirt puts it" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
# It is an init container today. The question the check asks is whether the
# container exists at all, so reading both lists costs nothing and removes
# the one way a KubeVirt-side move would red a green run over a diagnostic.
kubectl_init_names="virt-launcher-worker-a-11111= guest-console-log"
accept_rc=0
( set +x; cozy_assert_guest_console_attached ) || accept_rc=$?
[ "$accept_rc" -eq 0 ]
kubectl_init_names="virt-launcher-worker-a-11111="
attach_rc=0
( set +x; cozy_assert_guest_console_attached ) || attach_rc=$?
[ "$attach_rc" -eq 2 ]
assert_file_contains '{.spec.containers[*].name}' "$kubectl_calls"
rm -rf "$tmp"
}
@test "a passing attach check records the positive outcome, not just the negative" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_init_names="$(printf 'p1=\np2=guest-console-log')"
( set +x; cozy_assert_guest_console_attached ) >"$tmp/out" 2>&1
# This run doubles as the revalidation of a platform-wide workaround, so the
# positive outcome has to leave an artifact too. Returning 0 in silence
# leaves "the override worked" to be inferred from the absence of a
# complaint -- the exact inference this collector refuses to make elsewhere.
assert_file_contains 'attached on 1 of 2 virt-launcher Pods' "$tmp/out"
rm -rf "$tmp"
}
@test "a truncated read names the error file when there is 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-worker-a-11111"
kubectl_logs_stderr="Warning: stream reset"
timeout_fail_pod=virt-launcher-worker-a-11111
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-trunc-err
run_capture
# Same discipline as the silent branch: name a file only when it is there.
d="$COZY_REPORT_DIR/snapshots/console-trunc-err/tenant-serial-console"
assert_file_contains 'truncated' "$d/virt-launcher-worker-a-11111.log"
assert_file_contains 'read-error.log' "$d/virt-launcher-worker-a-11111.log"
assert_file_contains 'stream reset' "$d/virt-launcher-worker-a-11111.read-error.log"
rm -rf "$tmp"
}
@test "a truncated read with no error file does not name 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-worker-a-11111"
timeout_fail_pod=virt-launcher-worker-a-11111
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-trunc-noerr
run_capture
d="$COZY_REPORT_DIR/snapshots/console-trunc-noerr/tenant-serial-console"
assert_file_contains 'truncated' "$d/virt-launcher-worker-a-11111.log"
assert_file_lacks_pattern 'read-error.log' "$d/virt-launcher-worker-a-11111.log"
rm -rf "$tmp"
}
@test "the typical-cost figure in both chainsaw comments counts the Pod-state reads" {
# A comment carrying a number that the code contradicts is worse than no
# number: it outlives the session and is trusted by the next reader.
for f in hack/e2e-chainsaw/kubernetes-latest/chainsaw-test.yaml \
hack/e2e-chainsaw/kubernetes-previous/chainsaw-test.yaml; do
grep -q '4m05s' "$f" || {
echo "expected $f to state the minReplicas-2 cost including the wedge and Pod-state reads" >&2
return 1
}
grep -q '6m25s' "$f" || {
echo "expected $f to state the worst-case cost including the wedge read" >&2
return 1
}
done
}
@test "the attach read gives every matched Pod a non-empty line" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_init_names="virt-launcher-worker-a-11111=guest-console-log"
( set +x; cozy_assert_guest_console_attached )
# Asserted on the query rather than the answer, because no mock can stand in
# for kubectl's jsonpath engine and this is the property the answer depends
# on. Without the Pod name, a Pod whose initContainers list is absent emits a
# bare newline that command substitution strips, so "Pods matched, none
# carries the container" arrives byte-identical to "no Pod matched" -- and
# the check silently stops being able to report the one thing it is for.
assert_file_contains '{.metadata.name}{"="}{.spec.initContainers[*].name}' "$kubectl_calls"
rm -rf "$tmp"
}
@test "the failure-path diagnostics carry a client deadline too" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_init_names="virt-launcher-worker-a-11111="
attach_rc=0
( set +x; cozy_assert_guest_console_attached ) || attach_rc=$?
[ "$attach_rc" -eq 2 ]
# timeout bounds the process; --request-timeout bounds the call the process
# makes. The wrapper alone leaves kubectl free to sit in its own retry loop
# for the whole 30s and return nothing.
[ "$(grep -c -- '--request-timeout=30s' "$kubectl_calls")" -eq 3 ]
rm -rf "$tmp"
}
@test "one Pod carrying the container is enough to pass" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_init_names="$(printf 'virt-launcher-worker-a-11111=\nvirt-launcher-worker-b-22222=guest-console-log')"
attach_rc=0
( set +x; cozy_assert_guest_console_attached ) || attach_rc=$?
# Deliberately "any", not "every": the namespace is not scoped to this
# release, so an every-Pod rule would fail a run over a Pod that is not
# this test's. It can miss; it cannot false-fail.
[ "$attach_rc" -eq 0 ]
rm -rf "$tmp"
}
@test "the attach read is bounded like every other read here" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_init_names="virt-launcher-worker-a-11111=guest-console-log"
( set +x; cozy_assert_guest_console_attached )
assert_file_contains '-k 5 30 kubectl -n tenant-test get pods' "$timeout_calls"
rm -rf "$tmp"
}
@test "only an answered read that lacks the container fails the suite" {
lib=hack/e2e-chainsaw/_lib/run-kubernetes.sh
# The two non-zero outcomes must not be treated alike: exit 1 says the read
# did not answer and is not evidence, exit 2 says it did and the container
# is absent.
grep -q 'if \[ "${attach_rc}" -eq 2 \]; then' "$lib"
}
@test "the console capture stops at a cap and records what it dropped" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
kubectl_calls="$tmp/kubectl.calls"
timeout_calls="$tmp/timeout.calls"
kubectl_pod_names="p1 p2 p3 p4 p5 p6 p7 p8"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-capped
run_capture
d="$COZY_REPORT_DIR/snapshots/console-capped/tenant-serial-console"
[ -f "$d/p6.log" ]
[ ! -f "$d/p7.log" ]
assert_file_contains 'stopped after 6 Pods; 8 matched in total' "$d/COLLECTION-TRUNCATED.txt"
rm -rf "$tmp"
}
@test "an uncapped pool 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="p1 p2"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-uncapped
run_capture
[ ! -f "$COZY_REPORT_DIR/snapshots/console-uncapped/tenant-serial-console/COLLECTION-TRUNCATED.txt" ]
rm -rf "$tmp"
}
@test "the attach check sits on the passing path, after the functional block" {
lib=hack/e2e-chainsaw/_lib/run-kubernetes.sh
# Two placements are wrong and this pins against both. In the failure block
# it would answer only in the run where the console can no longer be
# recovered. Ahead of the functional assertions it would let a diagnostic
# preempt the checks the suite exists for.
gate=$(grep -n '^ *cozy_assert_guest_console_attached || attach_rc=$?$' "$lib" | head -n 1 | cut -d: -f1)
capture=$(grep -n "^ *cozy_capture_tenant_serial_console 'node-join failed" "$lib" | head -n 1 | cut -d: -f1)
if [ -z "$gate" ]; then
echo "expected the suite to verify guest-console-log attached on the passing path" >&2
return 1
fi
if [ -z "$capture" ]; then
echo "expected to find the failure-path console capture in $lib" >&2
return 1
fi
if [ "$gate" -le "$capture" ]; then
echo "the attach check (line $gate) must sit after node-join succeeds, not in the failure block (line $capture)" >&2
return 1
fi
# Anchored on the LAST functional assertion in the function, not a
# mid-file one, or everything between it and the gate is unprotected.
#
# On the call rather than on the text of its arguments. The arguments name
# local variables of the library, which a refactor there is free to rename
# without touching what this test asserts; an anchor that spells them out
# stops matching on that rename, and the failure lands on whoever rebases
# second rather than on either author. The call is what the ordering is
# about, so it is what the anchor reads.
last=$(grep -n '^ *cozy_guard_all_helmreleases ' "$lib" | tail -n 1 | cut -d: -f1)
if [ -z "$last" ]; then
echo "expected to find the HelmRelease remediation guard in $lib" >&2
return 1
fi
if [ "$gate" -le "$last" ]; then
echo "the attach check (line $gate) must sit after every functional assertion (last at line $last)" >&2
return 1
fi
}
@test "the node-join failure path captures the console before the talosctl capture" {
lib=hack/e2e-chainsaw/_lib/run-kubernetes.sh
# Ordering is the whole point: the talosctl capture needs an apid that the
# failure class this collects never reached, so running it first spends the
# failure path's budget on the collector that cannot answer.
console=$(grep -n "^ *cozy_capture_tenant_serial_console 'node-join failed" "$lib" | head -n 1 | cut -d: -f1)
talos=$(grep -n '^ *cozy_capture_tenant_talos "${test_name}" || true$' "$lib" | head -n 1 | cut -d: -f1)
if [ -z "$console" ]; then
echo "expected the node-join failure path to capture the guest serial console" >&2
return 1
fi
if [ -z "$talos" ]; then
echo "expected to find the talosctl capture call in $lib" >&2
return 1
fi
if [ "$console" -ge "$talos" ]; then
echo "serial console capture (line $console) must run before the talosctl capture (line $talos)" >&2
return 1
fi
}
@test "the passing path captures the console and leaves teardown to Chainsaw finally" {
lib=hack/e2e-chainsaw/_lib/run-kubernetes.sh
# An instrument that only runs on the failure path answers in one direction.
# Every console this tree has ever captured came from a run that failed, so
# the boot stages a healthy worker goes through, and how long each takes, have
# no measured value to compare a failing run against -- and "slower than
# usual" is the claim the failure keeps resting on. The passing run is where
# that value comes from, and it exists only until the tenant is deleted.
gate=$(grep -n '^ *cozy_assert_guest_console_attached || attach_rc=$?$' "$lib" | head -n 1 | cut -d: -f1)
green=$(grep -n "^ *if ! cozy_capture_tenant_serial_console 'the suite passed" "$lib" | head -n 1 | cut -d: -f1)
function_end=$(awk -v start="$green" 'NR > start && /^}$/ { print NR; exit }' "$lib")
for v in gate green function_end; do
eval "n=\$$v"
if [ -z "$n" ]; then
echo "expected to locate $v in $lib" >&2
return 1
fi
done
# After the attach check, because that check is what says there is a container
# to read. The function must not start an asynchronous tenant teardown after
# the capture: each suite's finally block owns the single authoritative delete.
if [ "$green" -le "$gate" ]; then
echo "the passing-path capture (line $green) must sit after the attach check ($gate)" >&2
return 1
fi
if sed -n "${green},${function_end}p" "$lib" | grep -Eq 'delete kubernetes(nodeses|es)[.]apps[.]cozystack[.]io'; then
echo "run_kubernetes_test still starts tenant teardown on its success path" >&2
return 1
fi
for suite in latest previous; do
grep -Fq "cozy_cleanup test-${suite}-version" "hack/e2e-chainsaw/kubernetes-${suite}/chainsaw-test.yaml" || {
echo "kubernetes-${suite} finally does not own authoritative tenant teardown" >&2
return 1
}
done
}
@test "the passing path asks for the pool minimum, not the pool" {
lib=hack/e2e-chainsaw/_lib/run-kubernetes.sh
# The failure path runs inside a phase whose budget is derived against the
# whole operation, so it can afford the pool at its maximum. The passing path
# has neither a phase nor a budget: it runs inside the same 67m operation the
# suite has already spent most of, and every read it issues is bounded
# individually but not as a group. A walk over the maximum pool could
# therefore turn a suite that proved everything it exists to prove into a red
# run for collecting a debugging aid slowly. Two workers booting is the same
# baseline as ten, so that is what it asks for.
green=$(grep -n "cozy_capture_tenant_serial_console 'the suite passed[^']*' 2;" "$lib" | head -n 1 | cut -d: -f1)
red=$(grep -n "cozy_capture_tenant_serial_console 'node-join failed[^']*' 6 || true" "$lib" | head -n 1 | cut -d: -f1)
if [ -z "$green" ]; then
echo "expected the passing path to cap its console walk at the pool minimum" >&2
return 1
fi
if [ -z "$red" ]; then
echo "expected the failure path to keep covering the pool at its maximum" >&2
return 1
fi
}
@test "the walk covers exactly the number of Pods the caller asked for" {
. 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"
kubectl_logs_output=
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-cap-arg
out=$( ( set +x; cozy_capture_tenant_serial_console 'a unit test' 2 ) 2>/dev/null )
printf '%s\n' "$out" >"$tmp/out"
# The cap is the caller's, not a literal in the walk, or the passing path
# cannot be cheaper than the failure path and the argument above is decorative.
dir="$COZY_REPORT_DIR/snapshots/console-cap-arg/tenant-serial-console"
[ -f "$dir/virt-launcher-a.log" ]
[ -f "$dir/virt-launcher-b.log" ]
[ ! -f "$dir/virt-launcher-c.log" ]
assert_file_contains 'capture stopped after 2 Pods; 3 matched in total' \
"$dir/COLLECTION-TRUNCATED.txt"
# And the count on stdout is over the Pods actually read. The cap probe runs
# on the Pod past the cap, so the counter driving it is one higher than the
# walk; reported as the denominator it would name a number that is neither
# what was read nor what matched, and disagree with the truncation marker
# written beside it -- on the path where nobody opens that marker.
assert_file_contains 'of the 2 guest consoles read, 2 came back empty and 0 were cut short' "$tmp/out"
rm -rf "$tmp"
}
@test "a passing-path capture that failed is said out loud instead of failing the suite" {
lib=hack/e2e-chainsaw/_lib/run-kubernetes.sh
# Two failure modes, and this pins against both. Left bare, a non-zero return
# would fail a suite that proved everything it exists to prove, on a
# debugging aid. Silenced with `|| true`, a capture that collected nothing
# would leave a green run whose only trace of the gap is an artifact nobody
# opens on a green run.
green=$(grep -n "^ *if ! cozy_capture_tenant_serial_console 'the suite passed" "$lib" | head -n 1 | cut -d: -f1)
if [ -z "$green" ]; then
echo "expected the passing path to capture the console through a tested call" >&2
return 1
fi
# Bounded by the branch rather than by a line count: the warning is what the
# branch is for, and a fixed offset would break on a comment being added to it
# while a whole-file search would accept a warning belonging to something
# else.
warn=$(awk -v g="$green" '
NR <= g { next }
/^ fi$/ { exit }
/WARNING: the guest console capture/ { print NR; exit }
' "$lib")
if [ -z "$warn" ]; then
echo "expected the failed passing-path capture to warn in the job log" >&2
return 1
fi
}
@test "the capture says which run produced it" {
. 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-worker-a-11111"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-context
run_capture 'the suite passed: captured after the last assertion'
# Both paths write the same directory, and the two readings mean opposite
# things: one is the failure being studied, the other is the baseline it is
# compared against. Which one a tarball holds is otherwise recoverable only
# from the run's verdict somewhere else entirely.
assert_file_contains 'the suite passed: captured after the last assertion' \
"$COZY_REPORT_DIR/snapshots/console-context/tenant-serial-console/CAPTURE-CONTEXT.txt"
rm -rf "$tmp"
}
@test "a walk that came back with empty consoles says so on stdout" {
. 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-worker-a-11111"
kubectl_logs_output=
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-silent-count
out=$( ( set +x; cozy_capture_tenant_serial_console 'a unit test' 6 ) 2>/dev/null )
printf '%s\n' "$out" >"$tmp/out"
# The per-pod notes and POD-STATE.txt already record this in the report, and
# on the failure path that is enough because the report gets opened. The
# passing path is the one that does not: a baseline that came back empty
# there would otherwise be visible only to whoever downloads a green run's
# artifact. The count is not a failure and the capture still returns zero, so
# stdout is the only place the two callers share.
assert_file_contains 'of the 1 guest consoles read, 1 came back empty and 0 were cut short' "$tmp/out"
rm -rf "$tmp"
}
@test "a healthy walk says nothing about silent consoles" {
. 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-worker-a-11111"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-silent-none
out=$( ( set +x; cozy_capture_tenant_serial_console 'a unit test' 6 ) 2>/dev/null )
printf '%s\n' "$out" >"$tmp/out"
# The other half of the pair. A line printed on every run is a line nobody
# reads, and this one is meant to be read on the green path.
assert_file_lacks_pattern 'guest consoles read, ' "$tmp/out"
rm -rf "$tmp"
}
@test "the context is written even when there is nothing to capture" {
. 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=console-context-empty
rc=0
run_capture 'the suite passed: captured after the last assertion' || rc=$?
# The empty case is the one where the context matters most: a directory
# holding only a failure note is where a reader most needs to know which run
# left it, and the early return is the path most likely to skip writing it.
[ "$rc" -ne 0 ]
assert_file_contains 'the suite passed' \
"$COZY_REPORT_DIR/snapshots/console-context-empty/tenant-serial-console/CAPTURE-CONTEXT.txt"
rm -rf "$tmp"
}
@test "each console says when it was read, so its last kernel stamp has a wall clock to sit against" {
. 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-worker-a-11111"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-read-instant
run_capture
# A console log is stamped in guest seconds since the guest's own kernel
# started, and it ends wherever the guest had got to when the read ran. Those
# two facts alone cannot say whether a short log means a guest that started
# late or a guest that went quiet, because the instant of the read is not in
# the file. Without this line the only trace of it is the artifact's mtime,
# which is metadata rather than content: nothing in the report says it, and a
# reader has to know to go looking outside the file for it.
f="$COZY_REPORT_DIR/snapshots/console-read-instant/tenant-serial-console/virt-launcher-worker-a-11111.log"
assert_file_contains 'console read started at ' "$f"
assert_file_contains ' epoch seconds' "$f"
rm -rf "$tmp"
}
@test "the capture records when each guest started, beside the console it captured" {
. 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-worker-a-11111"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-timeline
run_capture
# The other end of the subtraction. With the read instant above and the
# compute container's start here, how long the guest had been alive when the
# console was read is one subtraction away, and comparing that against the
# last stamp in the log says which of the two happened: a guest that had not
# lived long enough to reach a milestone, or one that lived and stopped
# printing. Without it both look like the same short file.
f="$COZY_REPORT_DIR/snapshots/console-timeline/tenant-serial-console/CAPTURE-TIMELINE.txt"
assert_file_contains 'computeStartedAt=2026-08-15T14:35:47Z' "$f"
# The subtraction is spelled out rather than left to be reconstructed. This
# file is read by whoever opens a failed run's tarball months later, and the
# relation between a guest-relative kernel stamp and two absolute instants is
# exactly the step a reader gets wrong.
assert_file_contains 'epoch seconds' "$f"
rm -rf "$tmp"
}
@test "a start-time read that failed is named rather than left out" {
. 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-worker-a-11111"
# Non-zero AND nothing written: the read that never got a row across, which is
# a different fact from the one cut off part way through and takes a different
# note. Both have to be staged explicitly or the mock decides which arm runs.
kubectl_timeline_rc=9
kubectl_timeline_rows=
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-timeline-failed
run_capture
# An absent file reads as a capture that had no reason to write one, which is
# the silence every collector here is built to refuse. The read failing is a
# statement about this machine's view of the cluster, not about when the
# guests started, and the file has to say which of those it is holding.
f="$COZY_REPORT_DIR/snapshots/console-timeline-failed/tenant-serial-console/CAPTURE-TIMELINE.txt"
assert_file_contains 'exit 9' "$f"
assert_file_lacks_pattern 'computeStartedAt=2026' "$f"
rm -rf "$tmp"
}
@test "a start-time read cut off part way is not reported as having observed nothing" {
. 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-worker-a-11111"
kubectl_timeline_rc=124
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-timeline-cut
run_capture
# A read killed at its bound can leave rows behind and still report failure.
# Saying "when these guests started was not observed" under start times that
# are plainly sitting there makes the file contradict itself, and a reader who
# believes the note discards the rows -- which for a capture that exists to be
# subtracted is the whole value of the file.
f="$COZY_REPORT_DIR/snapshots/console-timeline-cut/tenant-serial-console/CAPTURE-TIMELINE.txt"
assert_file_contains 'computeStartedAt=2026-08-15T14:35:47Z' "$f"
assert_file_contains 'cut off (exit 124)' "$f"
assert_file_lacks_pattern 'was not observed, which is not the same' "$f"
rm -rf "$tmp"
}
@test "a start-time read that answered with nothing is not reported as a start time" {
. 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-worker-a-11111"
kubectl_timeline_rows=
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-timeline-empty
run_capture
# The third outcome, and the one that looks most like success: the read
# answered, and answered with nothing. A zero-length file here would be read
# as a capture that ran and found the guests had no start times, which is not
# a state a Pod can be in -- the walk above just read consoles from these
# same Pods.
f="$COZY_REPORT_DIR/snapshots/console-timeline-empty/tenant-serial-console/CAPTURE-TIMELINE.txt"
assert_file_contains 'returned no rows' "$f"
rm -rf "$tmp"
}
@test "the start-time read is bounded like every other read in this capture" {
. 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-worker-a-11111"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-timeline-bound
run_capture
# This read runs on the same failure path as the rest, where the apiserver is
# the component least likely to answer. An unbounded one does not lose only
# itself: it holds the op until the op is killed, and the tenant snapshot
# queued behind it is lost rather than truncated.
assert_file_contains 'computeStartedAt' "$timeout_calls"
assert_file_contains 'request-timeout' "$kubectl_calls"
rm -rf "$tmp"
}
@test "a warning beside a healthy start-time read is not filed as an error" {
. 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-worker-a-11111"
kubectl_timeline_stderr='Warning: v1 Pod is deprecated in this build'
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-timeline-warn
run_capture
# kubectl writes deprecation and partial-result warnings on stderr with a zero
# exit, so what a message means is decided by the status and not by the fact
# that something was written. Filed as an error, a healthy read sends the next
# reader after a failure that did not happen; folded into the rows, it sits
# inside the data the success probe reads.
dir="$COZY_REPORT_DIR/snapshots/console-timeline-warn/tenant-serial-console"
assert_file_contains 'v1 Pod is deprecated' "$dir/CAPTURE-TIMELINE-warnings.txt"
[ ! -f "$dir/CAPTURE-TIMELINE-error.log" ]
assert_file_lacks_pattern 'deprecated' "$dir/CAPTURE-TIMELINE.txt"
rm -rf "$tmp"
}
@test "the stderr of a start-time read that failed is kept as the diagnosis" {
. 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-worker-a-11111"
kubectl_timeline_rc=1
kubectl_timeline_rows=
kubectl_timeline_stderr='error: the server could not find the requested resource'
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-timeline-err
run_capture
# The other direction: on a failed read that message IS the diagnosis, and
# dropping it leaves a note saying the read failed with nothing saying why.
# The note has to name the file, or the evidence sits in a directory the
# reader has no reason to open.
dir="$COZY_REPORT_DIR/snapshots/console-timeline-err/tenant-serial-console"
assert_file_contains 'could not find the requested resource' "$dir/CAPTURE-TIMELINE-error.log"
[ ! -f "$dir/CAPTURE-TIMELINE-warnings.txt" ]
assert_file_contains 'CAPTURE-TIMELINE-error.log' "$dir/CAPTURE-TIMELINE.txt"
rm -rf "$tmp"
}
@test "a start-time read that failed without a word does not point at a missing file" {
. 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-worker-a-11111"
kubectl_timeline_rc=124
kubectl_timeline_rows=
kubectl_timeline_stderr=
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-timeline-silent
run_capture
# timeout kills the child without a word, so this read can fail having written
# to neither stream. Naming the error log unconditionally would then send the
# reader after a file that was never created, which is the same trap the
# per-Pod notes above are written to avoid.
dir="$COZY_REPORT_DIR/snapshots/console-timeline-silent/tenant-serial-console"
[ ! -f "$dir/CAPTURE-TIMELINE-error.log" ]
assert_file_contains 'gave no reason on either stream' "$dir/CAPTURE-TIMELINE.txt"
assert_file_lacks_pattern 'reason it gave is in' "$dir/CAPTURE-TIMELINE.txt"
rm -rf "$tmp"
}
@test "the console container's start is asked for in both status lists" {
. 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-worker-a-11111"
COZY_REPORT_DIR="$tmp/report"
COZY_SNAPSHOT_NAME=console-timeline-lists
run_capture
# KubeVirt runs guest-console-log as an init container, so its runtime state
# lives under initContainerStatuses. Asking only containerStatuses returns
# empty for a container that started perfectly well, and an empty start time
# here is supposed to mean the container never ran -- the one reading this
# field exists to support. The attach check reads both spec lists for the
# same reason; a missing key costs an empty string, so naming both is free.
assert_file_contains 'initContainerStatuses[?(@.name=="guest-console-log")]' "$kubectl_calls"
assert_file_contains 'containerStatuses[?(@.name=="compute")]' "$kubectl_calls"
rm -rf "$tmp"
}
@test "a run near its operation ceiling declines the baseline instead of being killed collecting it" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
# The capture costs real wall clock and runs after everything the suite
# proves. Inside an operation with a fixed ceiling that makes it a way for a
# green run to end red, which is the one thing a passing-path collector may
# not do. Driven rather than grepped: the direction of the comparison IS the
# safety property, and an inverted one runs the capture on exactly the runs
# that cannot afford it while every structural check still passes.
date_now=1000000000
_COZY_RUN_STARTED_AT=$(( date_now - (COZY_OP_CEILING - COZY_GREEN_CAPTURE_RESERVE) - 1 ))
rc=0
( set +x; cozy_green_capture_has_room 'the baseline' ) >"$tmp/out" 2>&1 || rc=$?
[ "$rc" -eq 1 ]
# And the decline is spoken. A collector that stops running silently leaves a
# green run that looks exactly like one where the baseline was collected and
# came back empty, which is the reading every note in this file exists to
# prevent.
assert_file_contains 'not collected' "$tmp/out"
assert_file_contains 'the baseline' "$tmp/out"
rm -rf "$tmp"
}
@test "a run with the operation's room to spare collects the baseline" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
# The other direction, and the one an inverted comparison breaks silently: a
# gate that declines a run with forty minutes left costs the baseline on every
# healthy run, and the only symptom is a diagnostic that stopped appearing.
date_now=1000000000
_COZY_RUN_STARTED_AT=$(( date_now - 60 ))
rc=0
( set +x; cozy_green_capture_has_room 'the baseline' ) >"$tmp/out" 2>&1 || rc=$?
[ "$rc" -eq 0 ]
assert_file_lacks_pattern 'not collected' "$tmp/out"
rm -rf "$tmp"
}
@test "a run whose start was never stamped declines rather than dying on the arithmetic" {
. hack/e2e-chainsaw/_lib/run-kubernetes.sh
tmp=$(mktemp -d)
# The stamp is set by run_kubernetes_test, which no unit test invokes, so
# nothing else here would notice it being deleted. Under the `set -eu` the
# chainsaw script runs with, an unbound name inside the arithmetic aborts the
# whole function -- ending a passing run red, which is the outcome this gate
# exists to prevent. Declared zero at file scope, it fails closed instead.
date_now=1000000000
rc=0
( set -u; set +x; cozy_green_capture_has_room 'the baseline' ) >"$tmp/out" 2>&1 || rc=$?
[ "$rc" -eq 1 ]
assert_file_contains 'not collected' "$tmp/out"
rm -rf "$tmp"
}
@test "the passing path decides through the gate rather than around it" {
lib=hack/e2e-chainsaw/_lib/run-kubernetes.sh
# The behavioural tests above cover the predicate. Its wiring is what they
# cannot reach: run_kubernetes_test is not callable from a unit test, so the
# stamp it takes and the gate it calls are only assertable as source. Both
# halves fail the same silent way -- a correct predicate nothing calls
# protects nothing, and a predicate called without the stamp reads the whole
# epoch as elapsed and declines the baseline on every run forever.
stamp=$(grep -n '^ _COZY_RUN_STARTED_AT=\$(date +%s)$' "$lib" | head -n 1 | cut -d: -f1)
gate=$(grep -n 'cozy_green_capture_has_room ' "$lib" | tail -n 1 | cut -d: -f1)
green=$(grep -n "cozy_capture_tenant_serial_console 'the suite passed" "$lib" | head -n 1 | cut -d: -f1)
for v in stamp gate green; do
eval "n=\$$v"
if [ -z "$n" ]; then
echo "expected to locate $v in $lib" >&2
return 1
fi
done
# Inside the function whose elapsed time it measures, not at file scope: taken
# once when the library is sourced, it would time the sourcing rather than the
# run, and every suite after the first would read as already over its ceiling.
fn=$(grep -n '^run_kubernetes_test() {$' "$lib" | head -n 1 | cut -d: -f1)
if [ -z "$fn" ] || [ "$stamp" -le "$fn" ]; then
echo "the run's start stamp (line $stamp) must be taken inside run_kubernetes_test (line $fn)" >&2
return 1
fi
if [ "$stamp" -ge "$gate" ] || [ "$gate" -ge "$green" ]; then
echo "expected the stamp ($stamp) before the gate ($gate) before the capture ($green)" >&2
return 1
fi
}
@test "the operation ceiling the reserve is measured against is the one the suites give" {
lib=hack/e2e-chainsaw/_lib/run-kubernetes.sh
# A reserve is only meaningful against the right ceiling. This number is a
# restatement of one that lives in the suite files, so it is read from both
# sides rather than trusted: a suite that raises or lowers its `timeout:`
# otherwise leaves the reserve protecting a ceiling that moved, and the
# decline either stops firing when it should or starts firing when it should
# not.
ceiling=$(grep -oE '^COZY_OP_CEILING=[0-9]+' "$lib" | head -n 1 | sed -E 's/.*=//')
reserve=$(grep -oE '^COZY_GREEN_CAPTURE_RESERVE=[0-9]+' "$lib" | head -n 1 | sed -E 's/.*=//')
for v in ceiling reserve; do
eval "n=\$$v"
if [ -z "$n" ]; then
echo "expected to read $v from $lib; without it this guard reports success for having lost its input" >&2
return 1
fi
done
for f in hack/e2e-chainsaw/kubernetes-latest/chainsaw-test.yaml \
hack/e2e-chainsaw/kubernetes-previous/chainsaw-test.yaml; do
# The first `timeout:` in each file is the bringup op's, which is the one
# this whole helper runs inside; the teardown step below it has its own.
minutes=$(grep -oE '^ *timeout: [0-9]+m$' "$f" | head -n 1 | grep -oE '[0-9]+')
if [ -z "$minutes" ]; then
echo "expected to read the operation timeout from $f" >&2
return 1
fi
if [ "$((minutes * 60))" -ne "$ceiling" ]; then
echo "$f gives the operation ${minutes}m but the reserve is measured against ${ceiling}s" >&2
return 1
fi
done
# And the reserve has to leave room for the capture it is reserving for, or it
# protects nothing: the walk it admits would still run past the ceiling.
if [ "$reserve" -lt 210 ]; then
echo "the reserve ${reserve}s is under the 210s the capture can cost at this caller's cap" >&2
return 1
fi
}
@test "the e2e tenant node group asks KubeVirt for the guest console log" {
# Anchored to a bare two-space-indented key so a comment or a prose mention
# of the field cannot stand in for the setting itself. Since the Phase 2 split
# the worker pool is a separate KubernetesNodes CR whose fields sit two spaces
# under spec, not six spaces under a nodeGroups.<name> block. It does not prove
# the key sits in that CR specifically -- the render is what would catch that,
# and the chart's own suites cover it.
grep -q '^ logSerialConsole: true$' hack/e2e-chainsaw/_lib/run-kubernetes.sh
}