mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-23 03:27:50 +09:00
## What this PR does The kubernetes suites' LINSTOR cleanup check now compares dataset names per node, and prints the leftover names when it fails (#4651). This PR puts the rest of what a triager needs into `cozyreport.tgz` on every run, green or red: the size of each dataset, its `origin` and `creation`, the snapshots, the pool's pending frees, and the LINSTOR volume list. What `hack/cozyreport.sh` now collects: - `linstor/zfs-pools.txt`: the zpool behind each ZFS storage pool, per node, taken from `StorDriver/StorPoolName` in `linstor --machine-readable sp l`. - `linstor/zfs/<node>.txt`: that node's satellite runs `zpool get -p freeing,leaked,allocated,free` and `zfs list -p -t all -o name,used,avail,refer,origin,creation -r` over the node's pools. - `linstor/volumes.txt`: `linstor v l`, next to the existing `resources.txt`. Sizes are exact bytes, so the sizes of leftover datasets can be summed against the pool root's `avail`. That `avail` can differ from `storage-pools.txt` without anything leaking: on a node with only thick pools, `linstor sp l` serves the free capacity cached at the satellite's last report. A dataset LINSTOR could not destroy because snapshots depend on it is renamed to `_deleted_<name>` in the same pool, so a search for leftovers has to look for that prefix as well as `pvc-`. A pool that `zpool get` or `zfs list` cannot open does not cost the listing of the other pools. `zpool get`'s message and exit status go into the listing, and `zfs list` lists the rest. A node LINSTOR names no ZFS pool for gets a note instead of a listing, because `zfs list -r` with no pool argument lists every pool on the machine. A satellite whose `spec.nodeName` cannot be read gets a note that says so. All reads go through the bounded exec reader, like the other LINSTOR reads, and the time budget in the script header counts them. The second commit changes how that reader marks a read that wrote output and then exited non-zero without hitting the timeout. It used to append `TRUNCATED ... part way through, not because the command finished`. But `kubectl exec` passes the remote command's status through, and `zfs list` over one missing pool prints a complete listing of the others and exits 1. The marker now names the exit status and says the status can't tell an error after complete output from a cut stream. This changes the marker for every LINSTOR exec read. The timeout marker stays as it was. Tested with `hack/cozytest.sh hack/cozyreport.bats`. The new tests run the controller and satellite shells against stub `linstor`, `zpool` and `zfs`, with the real `jq`. They cover a failed and an unparsable storage-pool read, a pool row with a missing, empty or blank backing name, a non-ZFS pool, a pool name with a space, a pool that `zpool` and `zfs` cannot open, a failing `zfs`, and an unreadable node name. A test goes red if any of these break: the filter that keeps non-ZFS pools out, the filter that drops a missing, empty or blank pool name, the jq exit status, the `zpool` fallthrough, the node-name note, or the reader's new wording. Refs #4653 ### Screenshots Not a UI change. ### Downstream repositories - [x] No downstream repository is affected by this change - [ ] [cozystack/website](https://github.com/cozystack/website) - follow-up: - [ ] [cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack) - follow-up: - [ ] [cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack) - follow-up: - [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up: - [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up: - [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) - follow-up: - [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) - follow-up: - [ ] [cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server) - follow-up: - [ ] [cozystack/external-apps-example](https://github.com/cozystack/external-apps-example) - follow-up: - [ ] [cozystack/examples](https://github.com/cozystack/examples) - follow-up: - [ ] [cozystack/community](https://github.com/cozystack/community) - follow-up: ### Release note ```release-note test(e2e): cozyreport records the ZFS datasets of each node's LINSTOR pools in exact bytes, and the LINSTOR volume list, on every e2e run. ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Collection reports now include LINSTOR volumes, ZFS pool capacity and usage, and per-node ZFS datasets and snapshots with exact-byte details. Dataset listings are scoped to the ZFS pools LINSTOR reports for each node. * Reports include notes or warnings when node, pool, or dataset information is missing or cannot be collected. * **Documentation** * Clarified storage report contents, artifact markers, bounded reads, and how unsupported data, incomplete results, collection limits, and per-object warnings are reported. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
5353 lines
248 KiB
Bash
5353 lines
248 KiB
Bash
#!/usr/bin/env bats
|
|
# -----------------------------------------------------------------------------
|
|
# Unit tests for the pure selection helper in hack/cozyreport.sh that decides
|
|
# which pods the report collects evidence for, and which ones
|
|
# hack/cozyreport-summary.sh names as broken:
|
|
#
|
|
# - cozyreport_pods_not_ready -- keep every pod that is neither finished nor
|
|
# fully ready by its READY column. That column
|
|
# counts ready containers, not the pod's Ready
|
|
# condition; the one case the two disagree on is
|
|
# pinned below rather than claimed away.
|
|
#
|
|
# The kubectl calls those two scripts wrap around this helper are not
|
|
# unit-testable (they need a live cluster); the selection is, and it is the part
|
|
# that decides whether the pod that caused a failure appears in the uploaded
|
|
# report at all. The case that matters is a pod whose STATUS is Running while its
|
|
# READY column is 0/1: a readiness probe that never passes blocks everything
|
|
# downstream of it, and a STATUS-only filter drops it, so the artifact ends up
|
|
# with no trace of the pod the run died on.
|
|
#
|
|
# Strategy: source hack/cozyreport.sh with COZYREPORT_LIB set, which its
|
|
# sourcing guard honours by defining the helpers and returning before it starts a
|
|
# report -- so no cluster is required. Each @test then feeds the helper the exact
|
|
# text `kubectl get pod -A --no-headers` produces.
|
|
#
|
|
# Title syntax constraints (inherited from cozytest.sh's awk parser):
|
|
# - Titles delimited by ASCII double quotes; embedded quotes truncate.
|
|
# - Only [A-Za-z0-9] from the title survives into the function name, so keep
|
|
# titles distinctive in their alphanumeric run.
|
|
#
|
|
# Run from the repo root with `make bats-unit-tests`, or directly with
|
|
# `bats hack/cozyreport.bats`. The legacy `hack/cozytest.sh` runner remains a
|
|
# supported compatibility path.
|
|
# -----------------------------------------------------------------------------
|
|
|
|
load test_helper
|
|
|
|
HACK_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")" && pwd)"
|
|
SCRIPT="$HACK_DIR/cozyreport.sh"
|
|
|
|
COZYREPORT_LIB=1
|
|
# shellcheck source=/dev/null
|
|
. "$SCRIPT"
|
|
|
|
# One realistic cluster snapshot reused across the tests below: a running-but-
|
|
# unready replica, a healthy pod, a finished job, a multi-container pod that is
|
|
# partially ready, and the phase-based cases that were already collected.
|
|
SNAPSHOT="$(printf '%s\n' \
|
|
'cozy-keycloak keycloak-db-2 0/1 Running 0 6m' \
|
|
'kube-system coredns-abc 1/1 Running 0 2h' \
|
|
'tenant-test migrate-job-x 0/1 Completed 0 1h' \
|
|
'tenant-test backup-job-y 0/1 Succeeded 0 1h' \
|
|
'cozy-system puller-z 0/1 ImagePullBackOff 0 4m' \
|
|
'cozy-system unscheduled-w 0/1 Pending 0 4m' \
|
|
'cozy-linstor satellite-v 2/3 Running 1 3h')"
|
|
|
|
# Print a file with comment lines dropped and shell line continuations folded, so
|
|
# a guard can match a COMMAND rather than a physical line. Every source-scanning
|
|
# check below wants the former and, written the obvious way, gets the latter: a
|
|
# pattern anchored to one line passes the moment the thing it looks for sits one
|
|
# continuation down, which in shell is where it usually sits.
|
|
#
|
|
# Top level rather than inside a test, because cozytest.sh ends an @test at the
|
|
# first bare `}`.
|
|
# One spelling of "kubectl, however POSIX lets it be reached", shared by every
|
|
# scanner below so widening it is a single edit rather than four. `command` and
|
|
# `env` may prefix it, `env` may carry assignments, and any whitespace run
|
|
# separates the parts.
|
|
#
|
|
# What this deliberately does NOT cover: an absolute path, a variable, `sudo`, or
|
|
# a shell function wrapping the name. None appears in these files and a separate
|
|
# check fails if one ever does, so the limit is enforced rather than assumed.
|
|
KUBECTL_RE='(command[[:space:]]+|env[[:space:]]+([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*)*kubectl[[:space:]]'
|
|
|
|
fold_source() {
|
|
grep -v '^[[:space:]]*#' "$1" \
|
|
| awk '{ line = line $0
|
|
if (line ~ /\\$/) { sub(/\\$/, " ", line); next }
|
|
print line; line = "" }
|
|
END { if (line != "") print line }'
|
|
}
|
|
|
|
@test "the kamaji and linstor modules are gated on their own deployments" {
|
|
# The kamaji module was gated on cozy-linstor's controller, byte-identical to the
|
|
# condition opening the linstor module below it, so on a cluster without LINSTOR
|
|
# the kamaji controller log and the TenantControlPlanes were absent with nothing
|
|
# saying why -- and a tenant-kubernetes suite that times out on control-plane
|
|
# bootstrap is exactly the run that needs them. A copy-pasted gate is invisible
|
|
# to a reader precisely because it looks like its neighbour.
|
|
# Matched as a namespace/name PAIR. `*kamaji*` alone is satisfied by the
|
|
# `-n cozy-kamaji` that any gate in this module carries, so a gate naming a
|
|
# deployment that does not exist in that namespace -- which would keep the module
|
|
# shut forever -- passed unnoticed.
|
|
# Matched as "the gate line", not as "a line starting with kubectl": the gates
|
|
# run through cozyreport_probe, so the read no longer begins the line and a
|
|
# pattern anchored on it finds nothing and fails on the empty result rather
|
|
# than on a wrong gate.
|
|
gate=$(awk '/^# -- kamaji module/,/^ echo "Collecting kamaji/' "$SCRIPT" | grep -E '^if .*kubectl get')
|
|
case "$gate" in
|
|
*"cozy-kamaji kamaji"*) ;;
|
|
*) echo "FAIL: the kamaji module is not gated on cozy-kamaji/kamaji: $gate"; false ;;
|
|
esac
|
|
gate=$(awk '/^# -- linstor module/,/^ echo "Collecting linstor/' "$SCRIPT" | grep -E '^if .*kubectl get')
|
|
case "$gate" in
|
|
*"cozy-linstor linstor-controller"*) ;;
|
|
*) echo "FAIL: the linstor module is not gated on cozy-linstor/linstor-controller: $gate"; false ;;
|
|
esac
|
|
}
|
|
|
|
@test "the cdi module keeps the controller log and every CDI-labelled pod whatever its phase" {
|
|
# A DataVolume PVC stuck in Terminating can be held by an importer pod that
|
|
# CDI failed to delete after a successful import. That pod is Completed, so the
|
|
# broken-pod walk skips it, and the only record of why the delete did not land
|
|
# is the cdi-deployment log.
|
|
block=$(awk '/^# -- cdi module/,/^echo "Collecting services/' "$SCRIPT" | fold_source /dev/stdin)
|
|
[ -n "$block" ] || { echo "FAIL: no cdi module in $SCRIPT"; false; }
|
|
printf '%s\n' "$block" | grep -qE 'kubectl logs -n cozy-kubevirt-cdi deploy/cdi-deployment --tail=20000$' ||
|
|
{ echo "FAIL: the cdi module does not read the current cdi-deployment log"; false; }
|
|
printf '%s\n' "$block" | grep -qE 'kubectl logs -n cozy-kubevirt-cdi deploy/cdi-deployment --tail=20000 --previous$' ||
|
|
{ echo "FAIL: the cdi module does not read the previous cdi-deployment log"; false; }
|
|
printf '%s\n' "$block" | grep -qE 'kubectl get pod -A -l app=containerized-data-importer -o yaml$' ||
|
|
{ echo "FAIL: the cdi module does not read CDI-labelled pods across namespaces as YAML"; false; }
|
|
case "$block" in
|
|
*cozyreport_pods_not_ready*) echo "FAIL: a phase filter would drop the Completed importer pod"; false ;;
|
|
esac
|
|
}
|
|
|
|
@test "the cdi pods are read even when the cdi-deployment probe fails" {
|
|
# The importer pod is owned by its PVC, not by cdi-deployment, so it outlives a
|
|
# Deployment that is gone or a probe that got no answer. That pod is the
|
|
# evidence this module exists for, so only the log reads may sit behind the probe.
|
|
tmp=$(mktemp -d)
|
|
mkdir -p "$tmp/bin"
|
|
cat > "$tmp/bin/kubectl" <<'STUB'
|
|
#!/bin/sh
|
|
echo "kubectl $*" >> "$KUBECTL_CALL_LOG"
|
|
case "$*" in
|
|
"get deploy "*) exit 1 ;;
|
|
*"app=containerized-data-importer"*) printf 'items:\n- metadata:\n name: importer-prime-stuck\n' ;;
|
|
esac
|
|
exit 0
|
|
STUB
|
|
chmod +x "$tmp/bin/kubectl"
|
|
|
|
body=$(awk '/^# -- cdi module/,/^echo "Collecting services/' "$SCRIPT" | sed '$d' | fold_source /dev/stdin)
|
|
[ -n "$body" ] || { echo "FAIL: could not locate the cdi module"; false; }
|
|
|
|
KUBECTL_CALL_LOG="$tmp/calls.txt"
|
|
: > "$KUBECTL_CALL_LOG"
|
|
REPORT_DIR="$tmp/report"
|
|
export KUBECTL_CALL_LOG REPORT_DIR
|
|
( COZYREPORT_BOUND="" PATH="$tmp/bin:$PATH" eval "$body" ) >/dev/null
|
|
|
|
grep -q 'importer-prime-stuck' "$REPORT_DIR/cdi/pods.yaml" 2>/dev/null || {
|
|
echo "FAIL: a failed cdi-deployment probe dropped the CDI-labelled pods"
|
|
cat "$KUBECTL_CALL_LOG"
|
|
false
|
|
}
|
|
if grep -q '^kubectl logs' "$KUBECTL_CALL_LOG"; then
|
|
echo "FAIL: the cdi-deployment log was read although the probe failed"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "CSR capture lists and reads every request on the host cluster and each tenant kubeconfig on disk" {
|
|
host=$(fold_source "$SCRIPT" | awk '/^echo "Collecting CertificateSigningRequests/,/^echo "Collecting tenant kubernetes CertificateSigningRequests/')
|
|
[ -n "$host" ] || { echo "FAIL: could not locate the host CSR walk"; false; }
|
|
printf '%s\n' "$host" | grep -q '^cozyreport_read_object "\$REPORT_DIR/kubernetes/csr.txt" kubectl get csr -o wide$' || {
|
|
echo "FAIL: the host CSR listing is not a bounded read of every request"; false; }
|
|
printf '%s\n' "$host" | grep -q '^cozyreport_select_objects "csr" kubectl get csr --no-headers |$' || {
|
|
echo "FAIL: the host CSR walk does not select through the bounded selector"; false; }
|
|
printf '%s\n' "$host" | grep -q '^ cozyreport_read_object "\$DIR/csr.yaml" kubectl get csr "\$NAME" -o yaml$' || {
|
|
echo "FAIL: the host CSR walk does not read each request's yaml through the bounded reader"; false; }
|
|
|
|
tenant=$(fold_source "$SCRIPT" | awk '/^echo "Collecting tenant kubernetes CertificateSigningRequests/,/^done$/')
|
|
[ -n "$tenant" ] || { echo "FAIL: could not locate the tenant CSR walk"; false; }
|
|
printf '%s\n' "$tenant" | grep -q '^for TENANT_KC in tenantkubeconfig-\*; do$' || {
|
|
echo "FAIL: the tenant CSR walk does not glob every leftover tenant kubeconfig"; false; }
|
|
printf '%s\n' "$tenant" | grep -q '^ \[ -f "\$TENANT_KC" \] || continue$' || {
|
|
echo "FAIL: the tenant CSR walk does not skip a glob match with no matching file"; false; }
|
|
printf '%s\n' "$tenant" | grep -q '^ cozyreport_read_object "\$DIR/csr.txt" kubectl --kubeconfig "\$TENANT_KC" get csr -o wide$' || {
|
|
echo "FAIL: the tenant CSR listing is not a bounded read of every request"; false; }
|
|
printf '%s\n' "$tenant" | grep -q '^ cozyreport_select_objects "tenant csr (\$TENANT_NAME)" kubectl --kubeconfig "\$TENANT_KC" get csr --no-headers |$' || {
|
|
echo "FAIL: the tenant CSR walk does not select through the bounded selector"; false; }
|
|
printf '%s\n' "$tenant" | grep -q '^ cozyreport_read_object "\$DIR/\$NAME.yaml" kubectl --kubeconfig "\$TENANT_KC" get csr "\$NAME" -o yaml$' || {
|
|
echo "FAIL: the tenant CSR walk does not read each request's yaml through the bounded reader"; false; }
|
|
}
|
|
|
|
@test "no tenant kubeconfig on disk means no tenant CSR read is attempted" {
|
|
# tenantkubeconfig-<test> only exists while a kubernetes-* Chainsaw test is
|
|
# running or failed before its own cleanup (hack/e2e-chainsaw/_lib/run-
|
|
# kubernetes.sh). Every other suite, and a kubernetes suite that passed,
|
|
# leaves cwd with no such file, and the glob must not turn that absence into
|
|
# a literal `--kubeconfig tenantkubeconfig-*` read.
|
|
tmp=$(mktemp -d)
|
|
mkdir -p "$tmp/bin" "$tmp/work"
|
|
cat > "$tmp/bin/kubectl" <<'STUB'
|
|
#!/bin/sh
|
|
echo "kubectl $*" >> "$KUBECTL_CALL_LOG"
|
|
exit 0
|
|
STUB
|
|
chmod +x "$tmp/bin/kubectl"
|
|
|
|
body=$(fold_source "$SCRIPT" | awk '/^for TENANT_KC in tenantkubeconfig-\*; do$/,/^done$/')
|
|
[ -n "$body" ] || { echo "FAIL: could not locate the tenant CSR walk"; false; }
|
|
|
|
KUBECTL_CALL_LOG="$tmp/calls.txt"
|
|
: > "$KUBECTL_CALL_LOG"
|
|
REPORT_DIR="$tmp/report"
|
|
export KUBECTL_CALL_LOG REPORT_DIR
|
|
( cd "$tmp/work" && PATH="$tmp/bin:$PATH" eval "$body" )
|
|
|
|
[ ! -s "$KUBECTL_CALL_LOG" ] || {
|
|
echo "FAIL: kubectl was invoked with no tenant kubeconfig present on disk"
|
|
cat "$KUBECTL_CALL_LOG"
|
|
false
|
|
}
|
|
[ ! -e "$REPORT_DIR" ] || {
|
|
echo "FAIL: a report directory was created with nothing to walk"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "every Deployment this script names by hand is one somebody chose to name" {
|
|
# Pinning the two gates above only guards the two lines that were looked at.
|
|
# The same defect sat in the cozystack module the whole time: the image read
|
|
# asked for `cozy-system/cozystack`, a Deployment the installer stopped shipping
|
|
# when it moved to cozystack-operator, so `cozystack/image.txt` -- the first file
|
|
# of the report, the one naming the build that produced everything after it --
|
|
# held `deployments.apps "cozystack" not found` on every run. The line below it
|
|
# already asked for the right name, which is what makes this class invisible in
|
|
# review: the wrong name looks exactly like its correct neighbour.
|
|
#
|
|
# Frozen as a LIST rather than checked against the repo's manifests, because
|
|
# most of these Deployments come from upstream subcharts and exist nowhere in
|
|
# this tree as a literal. What the list buys is that renaming or adding one has
|
|
# to be typed here too, by someone who then has a reason to check it resolves.
|
|
#
|
|
# Literal names only. The flux and per-object loops build a name from the
|
|
# cluster's own output at runtime; there is nothing to pin and nothing to get
|
|
# stale.
|
|
# A trailing `;` is stripped from every name. The gates used to end in
|
|
# `>/dev/null 2>&1; then`, which put the shell separator on a field of its own;
|
|
# through cozyreport_probe the deployment name is the last word before it, so
|
|
# without this the same name appears twice, once as itself and once with the
|
|
# semicolon attached, and the guard reports a set that changed when nothing did.
|
|
found=$(awk '
|
|
/kubectl/ {
|
|
ns = ""
|
|
for (i = 1; i < NF; i++) if ($i == "-n") { ns = $(i + 1); break }
|
|
if (ns == "") next
|
|
for (i = 1; i <= NF; i++) {
|
|
if ($i ~ /^deploy\//) { n = $i; sub(/^deploy\//, "", n); sub(/;$/, "", n); print ns "/" n }
|
|
else if ($i == "deploy" && i < NF && $(i + 1) == "-n" && i + 2 < NF && $(i + 3) !~ /^-/) {
|
|
n = $(i + 3); sub(/;$/, "", n); print ns "/" n
|
|
}
|
|
}
|
|
}
|
|
' "$SCRIPT" | grep -v '\$' | LC_ALL=C sort -u | tr '\n' ' ')
|
|
|
|
expected='cozy-cert-manager/cert-manager cozy-cert-manager/cert-manager-webhook cozy-kamaji/kamaji cozy-kubevirt-cdi/cdi-deployment cozy-linstor/linstor-controller cozy-objectstorage-controller/container-object-storage-controller cozy-system/cozystack-operator '
|
|
|
|
if [ "$found" != "$expected" ]; then
|
|
echo "FAIL: the set of Deployments this script names changed."
|
|
echo "A name that exists on no cluster does not fail loudly -- it writes"
|
|
echo "kubectl's NotFound into the file that was supposed to hold evidence,"
|
|
echo "or holds a module shut for the whole run."
|
|
echo "expected: $expected"
|
|
echo "found: $found"
|
|
false
|
|
fi
|
|
}
|
|
|
|
@test "every Kubernetes kind these scripts name by hand is one somebody chose it" {
|
|
# A gate naming something that is not a CRD does not fail loudly, and neither
|
|
# does a mistyped kind in a list. The cozystack-apps section gated on `kubectl get crd
|
|
# applications.apps.cozystack.io`. apps.cozystack.io is an aggregated
|
|
# APIService, not a CRD, so the probe answered NotFound on every cluster and the
|
|
# section never ran once; the group serves one resource per ApplicationDefinition
|
|
# discovered from the cozyrds, so `applications` was not a resource in it under
|
|
# any gate, and the real ApplicationDefinition CRD is in group cozystack.io and
|
|
# cluster-scoped. Three wrong facts in one loop, none of which failed loudly --
|
|
# the section was simply skipped, for years, exactly like a cluster that has no
|
|
# such objects. It is deleted rather than repaired: collecting the cozyrd-derived
|
|
# kinds means enumerating the API group at runtime, which is a feature and not a
|
|
# correction.
|
|
#
|
|
# Frozen as a list for the same reason the Deployment names are: cert-manager's
|
|
# and COSI's CRDs come from upstream charts and appear nowhere in this tree, so
|
|
# checking against the manifests would be false-red for them and false-green for
|
|
# anything typed wrong. What the list buys is that adding a gate has to be typed
|
|
# here too, by someone who then has a reason to check the kind exists and is
|
|
# served as a CRD at all.
|
|
# Every spelling, not just the gates. `summary_crd "$kind"` inside a
|
|
# `for kind in ...` loop passes four Flux CRDs that a grep for literal arguments
|
|
# never sees, and the COSI block iterates five more. A typo anywhere in those
|
|
# lists is as quiet as a wrong gate -- the kind is simply never found and the
|
|
# tree beneath it is never written -- so the union is what gets frozen.
|
|
# Comments stripped first. Prose names these kinds too -- this very test's
|
|
# rationale does -- and a guard that reads them is answering "where is this
|
|
# string mentioned" while reading as "what does this script ask the cluster for".
|
|
code=$(mktemp)
|
|
{ fold_source "$SCRIPT"; fold_source "$HACK_DIR/cozyreport-summary.sh"; } > "$code"
|
|
found=$(
|
|
{
|
|
# Whitespace matched as a RUN, not as one space: folding a continuation
|
|
# leaves several spaces where the backslash was, and a pattern demanding
|
|
# exactly one is evaded by the very construct the folding exists to expose.
|
|
grep -oE 'get crd[[:space:]]+[a-z][a-z0-9.-]+' "$code" | sed 's/^get crd[[:space:]]*//'
|
|
grep -oE 'summary_crd[[:space:]]+[a-z][a-z0-9.-]+' "$code" | sed 's/^summary_crd[[:space:]]*//'
|
|
grep -E '^[[:space:]]*for kind in ' "$code" |
|
|
sed -e 's/^[[:space:]]*for kind in //' -e 's/;.*//' | tr ' ' '\n'
|
|
# And the kinds read directly, with no gate at all: a fully-qualified name
|
|
# is exactly as silent when mistyped, and dropping the gate is now one of
|
|
# the deliberate choices here rather than an oversight.
|
|
grep -oE 'kubectl get[[:space:]]+[a-z][a-z0-9-]*\.[a-z0-9.-]+' "$code" | sed 's/^kubectl get[[:space:]]*//'
|
|
} | grep -E '^[a-z][a-z0-9-]*\.[a-z0-9.-]+$' | LC_ALL=C sort -u | tr '\n' ' '
|
|
)
|
|
rm -f "$code"
|
|
expected='applicationdefinitions.cozystack.io bucketaccessclasses.objectstorage.k8s.io bucketaccesses.objectstorage.k8s.io bucketclaims.objectstorage.k8s.io bucketclasses.objectstorage.k8s.io buckets.objectstorage.k8s.io certificaterequests.cert-manager.io certificates.cert-manager.io challenges.acme.cert-manager.io externalartifacts.source.toolkit.fluxcd.io gitrepositories.source.toolkit.fluxcd.io helmreleases.helm.toolkit.fluxcd.io helmrepositories.source.toolkit.fluxcd.io kamajicontrolplanes.controlplane.cluster.x-k8s.io ocirepositories.source.toolkit.fluxcd.io orders.acme.cert-manager.io tenantcontrolplanes.kamaji.clastix.io tenants.apps.cozystack.io '
|
|
|
|
if [ "$found" != "$expected" ]; then
|
|
echo "FAIL: the set of CRDs these scripts gate on changed."
|
|
echo "A gate naming something that is not a CRD does not fail loudly --"
|
|
echo "it holds its section shut and reads as a cluster with no such objects."
|
|
echo "expected: $expected"
|
|
echo "found: $found"
|
|
false
|
|
fi
|
|
}
|
|
|
|
@test "no explanation in these files stops in the middle of its sentence" {
|
|
# A comment that ends mid-sentence is what an edit leaves behind when it cuts
|
|
# prose out of a block without rereading the seam: "Same guard, same reason, as"
|
|
# followed by a blank line, or a clause with its own middle removed. In files
|
|
# whose subject is that an explanation has to be trustworthy, a half-written
|
|
# explanation in the source is the same defect one level up, and it is the kind
|
|
# a person skims straight past.
|
|
#
|
|
# Checks the LAST line of each comment block only: a conjunction mid-block is
|
|
# just a wrapped sentence.
|
|
bad=$(for f in cozyreport.sh cozyreport-summary.sh e2e-capture-previous-logs.sh \
|
|
cozyreport.bats capture-previous-logs.bats cozyreport-talos.bats; do
|
|
awk -v f="$f" '
|
|
function flush( x) {
|
|
if (prev != "" && prev ~ /([ \t](as|and|the|of|for|to|a|an|is|was|by|with|that|than|because|rather|same|but|or|in|on|at|from|its|it)|,)$/)
|
|
printf "%s:%d: %s\n", f, prevn, prev
|
|
prev = ""
|
|
}
|
|
{
|
|
line = $0
|
|
sub(/^[[:space:]]*/, "", line)
|
|
if (line ~ /^#/) {
|
|
sub(/^#+[[:space:]]*/, "", line)
|
|
if (line != "") { prev = line; prevn = NR; next }
|
|
}
|
|
flush()
|
|
}
|
|
END { flush() }
|
|
' "$HACK_DIR/$f"
|
|
done)
|
|
|
|
if [ -n "$bad" ]; then
|
|
echo "FAIL: a comment block ends mid-sentence:"
|
|
printf '%s\n' "$bad"
|
|
false
|
|
fi
|
|
}
|
|
|
|
@test "every knob a script reads is named in that script's Environment block" {
|
|
# Same shape as the marker-parity guard above, for the other thing that drifts:
|
|
# behaviour gets added to a knob and its header block stays as it was. That is
|
|
# how COZY_PREVLOG_TAIL ended up validated, given a special -1 meaning and a
|
|
# per-log note while its documentation still read "lines per container
|
|
# (default 200)" -- and a knob whose only description is its default is one an
|
|
# operator will set to something the script now rejects.
|
|
#
|
|
# Read from the script rather than listed here: a hand-kept list is the thing
|
|
# that goes stale, which is the defect being guarded against.
|
|
# All three scripts, not the two that happened to have a block already: the
|
|
# test's name says "a script", and a guard whose file list is narrower than its
|
|
# claim is the same defect as a marker regex that enumerates known prefixes.
|
|
# cozyreport-summary.sh reads COZY_REPORT_PODS_MAX and had no block at all.
|
|
for f in cozyreport.sh cozyreport-summary.sh e2e-capture-previous-logs.sh; do
|
|
block=$(awk '/^# Environment:/,/^[^#]/' "$HACK_DIR/$f")
|
|
[ -n "$block" ] || { echo "FAIL: $f has no Environment block"; false; }
|
|
# Knobs reached through a sourced helper count too. Grepping only the file's
|
|
# own text answers "every knob whose name appears here", not "every knob this
|
|
# script reads" -- the claim in this test's name. COZY_REPORT_PREFER_NS
|
|
# reordered the summary's pod listing while appearing nowhere in that file,
|
|
# and this guard could not see it. Same defect the marker guard had: the check
|
|
# was narrower than the sentence describing it.
|
|
#
|
|
# Only the LIBRARY portion of the sourced file counts -- everything above the
|
|
# COZYREPORT_LIB guard, which is where the `.` returns. Scanning the whole
|
|
# file would attribute knobs the sourcing script never reaches: cozyreport.sh
|
|
# reads COZY_REPORT_DIR in its report body, far below that return, and the
|
|
# summary never executes it. A guard that over-reports is retired as noise
|
|
# just as fast as one that under-reports.
|
|
#
|
|
# Comments stripped before matching: every one of these files documents its
|
|
# knobs in prose right above the code, so scanning raw text finds the name in
|
|
# the Environment block and reports the knob as "read" by a file that only
|
|
# describes it. Three guards in this file need the same strip for the same
|
|
# reason: prose that explains a rule names the thing the rule forbids.
|
|
knobs=$(grep -Ev '^[[:space:]]*#' "$HACK_DIR/$f" | grep -oE 'COZY_[A-Z_]+' || true)
|
|
if grep -q '^\. "\$SCRIPT_DIR/cozyreport.sh"' "$HACK_DIR/$f"; then
|
|
# Only the knobs read by helpers this file actually CALLS. Sourcing makes
|
|
# every helper available, but availability is not use: the summary sources
|
|
# cozyreport_pod_tail and never invokes it, so attributing COZY_REPORT_POD_TAIL
|
|
# to it would demand documentation for a knob that changes nothing here. A
|
|
# guard that over-reports gets retired as noise exactly as fast as one that
|
|
# under-reports, so this walks call sites rather than the whole library.
|
|
for fn in $(grep -oE 'cozyreport_[a-z_]+' "$HACK_DIR/$f" | sort -u); do
|
|
body=$(awk -v fn="$fn" '$0 ~ "^"fn"\\(\\) \\{" {inb=1} inb {print} inb && /^}$/ {exit}' "$HACK_DIR/cozyreport.sh" | grep -Ev '^[[:space:]]*#')
|
|
knobs="$knobs
|
|
$(printf '%s\n' "$body" | grep -oE 'COZY_[A-Z_]+' || true)"
|
|
done
|
|
fi
|
|
for v in $(printf '%s\n' "$knobs" | sort -u); do
|
|
if ! printf '%s\n' "$block" | grep -q "$v"; then
|
|
echo "FAIL: $f reads $v but its Environment block never names it"
|
|
false
|
|
fi
|
|
done
|
|
done
|
|
}
|
|
|
|
@test "an exec is bounded through its own reader and classified by neither" {
|
|
# The cut-off classification rests on 124 meaning the deadline expired. That
|
|
# holds only because `timeout` passes a command's own status through and no
|
|
# command given to the wrapper returns 124: `get`, `logs` and `describe` do not.
|
|
# `kubectl exec` does whatever the remote command did, 124 included, and would
|
|
# turn a container's own exit status into "this read was cut off by its own 30s
|
|
# timeout" -- a sentence about the report's machine describing something that
|
|
# happened inside a pod.
|
|
#
|
|
# Three assertions, because the earlier one-line form was satisfied by leaving
|
|
# every exec unbounded, and that is the state this file was in: the sharpest
|
|
# reads in it -- a shell inside a pod, streaming a tarball -- had no ceiling of
|
|
# any kind, and the guard read as though that were the safe arrangement.
|
|
# Bounding them without the middle assertion is worse than leaving them alone,
|
|
# because a 124 would then be classified as this script's own deadline.
|
|
#
|
|
# Line continuations are folded first. Every form below routinely puts the argv
|
|
# on the next physical line, so matching per line asks whether the word `exec`
|
|
# shares a line with the call rather than whether it is in its arguments -- and
|
|
# passes while the exec sits one line down, which is where it would actually be.
|
|
code=$(fold_source "$SCRIPT")
|
|
|
|
# 1. No exec reaches the readers whose 124 means "our deadline fired".
|
|
# Every reader that reaches cozyreport_cutoff_desc, not just the two that were
|
|
# thought of first: cozyreport_probe and cozyreport_select_objects call it too,
|
|
# so an exec routed through either would be misclassified AND invisible here.
|
|
# The guard's reach has to equal the helper's, or it reports a clean sweep of
|
|
# the half it happens to look at.
|
|
bounded=$(printf '%s\n' "$code" | grep -E '\$COZYREPORT_BOUND[[:space:]]|cozyreport_(read_object|probe|select_objects)[[:space:]]' || true)
|
|
[ -n "$bounded" ] || { echo "FAIL: found no bounded reads at all"; false; }
|
|
if printf '%s\n' "$bounded" | grep -qE "[[:space:]]exec[[:space:]]"; then
|
|
echo "FAIL: a bounded read is an exec, whose exit status is the remote command's"
|
|
printf '%s\n' "$bounded" | grep -E "[[:space:]]exec[[:space:]]"
|
|
false
|
|
fi
|
|
|
|
# 2. And every exec IS bounded, through the reader written for one. Matched on
|
|
# the verb as a field rather than on the string `kubectl exec`: half of these
|
|
# calls spell it `kubectl -n cozy-linstor exec`, which that string never sees,
|
|
# and a guard blind to half its subject reports a clean sweep of the other half.
|
|
#
|
|
# The reader's own body is cut out first. It is the one place in this file
|
|
# whose subject IS the exec -- in its code and in the sentence it writes into
|
|
# the report -- so scanning it answers "where is an exec mentioned" while
|
|
# reading as "which reads are execs", and every future edit to that sentence
|
|
# fails this guard for a reason unrelated to it. Its boundedness is asserted
|
|
# on its own terms below instead of inferred from the scan.
|
|
sites=$(printf '%s\n' "$code" |
|
|
awk '/^cozyreport_read_exec\(\) \{/ {inb=1} !inb {print} inb && /^}$/ {inb=0}')
|
|
execs=$(printf '%s\n' "$sites" | grep -E "$KUBECTL_RE" | grep -E "[[:space:]]exec[[:space:]]" || true)
|
|
[ -n "$execs" ] || { echo "FAIL: found no exec reads at all, so this proves nothing"; false; }
|
|
unbounded=$(printf '%s\n' "$execs" | grep -v 'cozyreport_read_exec' || true)
|
|
if [ -n "$unbounded" ]; then
|
|
echo "FAIL: an exec read runs with no ceiling; a wedged pod holds it open forever"
|
|
printf '%s\n' "$unbounded"
|
|
false
|
|
fi
|
|
|
|
# 3. That reader states the ambiguity rather than resolving it. Reaching the
|
|
# shared describer from in there is the whole defect wearing a different call
|
|
# site: the sentence it returns names this script's timeout as the cause.
|
|
body=$(awk '/^cozyreport_read_exec\(\) \{/ {inb=1} inb {print} inb && /^}$/ {exit}' "$SCRIPT" |
|
|
grep -Ev '^[[:space:]]*#')
|
|
[ -n "$body" ] || { echo "FAIL: could not locate cozyreport_read_exec"; false; }
|
|
# Cutting its body out of the scan above costs the one assertion that scan
|
|
# would have made about it, so make that one here: the reader every exec now
|
|
# routes through has to be the thing that applies the ceiling.
|
|
printf '%s\n' "$body" | grep -q '\$COZYREPORT_BOUND' || {
|
|
echo "FAIL: the exec reader does not apply the wall-clock bound"
|
|
false
|
|
}
|
|
if printf '%s\n' "$body" | grep -q 'cozyreport_cutoff_desc'; then
|
|
echo "FAIL: the exec reader describes its cutoff with the helper written for reads whose 124 is unambiguous"
|
|
false
|
|
fi
|
|
}
|
|
|
|
# Stub PATH for the exec reader: a `kubectl` whose behaviour is chosen by
|
|
# STUB_EXEC_MODE and a `timeout` that runs its command rather than timing it, so
|
|
# the assertions do not depend on the host having GNU coreutils.
|
|
exec_stub_dir() {
|
|
_sd=$1
|
|
mkdir -p "$_sd/bin" "$_sd/out"
|
|
cat > "$_sd/bin/kubectl" <<'STUB'
|
|
#!/bin/sh
|
|
case "${STUB_EXEC_MODE:-}" in
|
|
# Killed mid-stream: the last line has no terminating newline, which is what a
|
|
# stream cut short actually leaves behind.
|
|
cut) printf 'Node | ONLINE\nsrv1 | partial ro'; exit 124 ;;
|
|
# Killed before the command inside the pod wrote a byte -- the wedged controller
|
|
# the ticket is about. No stdout AND no stderr, because there was nothing to say
|
|
# it with.
|
|
silentcut) exit 124 ;;
|
|
# Rows out, then a failure on the command's own terms rather than on the clock.
|
|
# A wedged controller does not have to produce 124.
|
|
partialfail) printf 'Node | Ready\nsrv1 | Yes\nsrv2 | Ye'
|
|
echo 'error: rpc failed' >&2; exit 1 ;;
|
|
refused) echo 'error: unable to upgrade connection: container not found' >&2; exit 1 ;;
|
|
# What `kubectl exec` really leaves behind when the remote command fails: the
|
|
# cause first, and its own `command terminated` line appended LAST.
|
|
linstor) echo 'ERROR: unable to connect to linstor://localhost:3370' >&2
|
|
echo 'command terminated with exit code 10' >&2; exit 10 ;;
|
|
*) echo 'Defaulted container "linstor-controller" out of: linstor-controller, init' >&2
|
|
printf 'Node | ONLINE\nsrv1 | Yes\n' ;;
|
|
esac
|
|
STUB
|
|
chmod +x "$_sd/bin/kubectl"
|
|
cat > "$_sd/bin/timeout" <<'STUB'
|
|
#!/bin/sh
|
|
[ "$1" = "-k" ] && shift 2
|
|
shift
|
|
exec "$@"
|
|
STUB
|
|
chmod +x "$_sd/bin/timeout"
|
|
COZYREPORT_BOUND="timeout -k 5 $COZYREPORT_READ_TIMEOUT"
|
|
}
|
|
|
|
@test "an exec cut off short says what its status cannot tell apart" {
|
|
tmp=$(mktemp -d)
|
|
exec_stub_dir "$tmp"
|
|
|
|
STUB_EXEC_MODE=cut PATH="$tmp/bin:$PATH" \
|
|
cozyreport_read_exec "$tmp/out/nodes.txt" kubectl exec -n ns deploy/c -- linstor n l
|
|
|
|
# The partial output is kept, never discarded: it is the only copy of what the
|
|
# read managed to get out of the pod.
|
|
grep -q 'partial ro' "$tmp/out/nodes.txt"
|
|
# And the table says so about ITSELF, like every other .txt in the tarball. A
|
|
# short node list with the explanation one directory up reads as a small
|
|
# cluster, and that walk is the one a triager does not make.
|
|
grep -q '^# \[cozyreport\] TRUNCATED' "$tmp/out/nodes.txt" || {
|
|
echo "FAIL: a cut-off table carries no marker of its own, or not in the shared form"
|
|
cat "$tmp/out/nodes.txt"
|
|
false
|
|
}
|
|
[ -f "$tmp/out/COLLECTION-FAILED.txt" ] || {
|
|
echo "FAIL: a cut-off exec left no note at all"; false; }
|
|
grep -q 'nodes.txt' "$tmp/out/COLLECTION-FAILED.txt"
|
|
grep -q 'passes the remote command' "$tmp/out/COLLECTION-FAILED.txt"
|
|
# And it does NOT borrow the sentence written for reads whose 124 is this
|
|
# script's own clock. That sentence is the false claim this path exists to
|
|
# avoid, so its absence is the assertion, not a nicety.
|
|
if grep -q "its own ${COZYREPORT_READ_TIMEOUT}s timeout" "$tmp/out/COLLECTION-FAILED.txt"; then
|
|
echo "FAIL: the note names a mechanism the status does not establish"
|
|
cat "$tmp/out/COLLECTION-FAILED.txt"
|
|
false
|
|
fi
|
|
|
|
# A refusal is a different fact and gets a different sentence. Pasting the
|
|
# ambiguity onto every failure would make the wording noise a reader skips,
|
|
# which costs exactly the case it was written for.
|
|
STUB_EXEC_MODE=refused PATH="$tmp/bin:$PATH" \
|
|
cozyreport_read_exec "$tmp/out/pools.txt" kubectl exec -n ns deploy/c -- linstor sp l
|
|
grep -q 'container not found' "$tmp/out/COLLECTION-FAILED.txt"
|
|
[ "$(grep -c 'passes the remote command' "$tmp/out/COLLECTION-FAILED.txt")" -eq 1 ]
|
|
|
|
# A refusal is what the LISTING has to say for itself, so it goes in the file
|
|
# too. Sent only beside it, `pools.txt` is zero bytes -- the "did it return
|
|
# nothing, or never run" question this collector exists to answer, and the shape
|
|
# the object reader argues against for exactly the same reason.
|
|
[ -s "$tmp/out/pools.txt" ] || {
|
|
echo "FAIL: a refused listing was left as a zero-byte file"; false; }
|
|
grep -q 'container not found' "$tmp/out/pools.txt"
|
|
|
|
# And the WHOLE message survives, not its last line. `kubectl exec` appends
|
|
# `command terminated with exit code N` after whatever the remote command said,
|
|
# so on this path the last line is the one line that never carries the cause --
|
|
# the opposite of the `kubectl get` klog preamble this file quotes tail -n 1 for.
|
|
STUB_EXEC_MODE=linstor PATH="$tmp/bin:$PATH" \
|
|
cozyreport_read_exec "$tmp/out/res.txt" kubectl exec -n ns deploy/c -- linstor r l
|
|
grep -q 'unable to connect to linstor' "$tmp/out/COLLECTION-FAILED.txt" || {
|
|
echo "FAIL: only kubectl's own trailer survived; the cause was dropped"
|
|
grep 'res.txt' -A3 "$tmp/out/COLLECTION-FAILED.txt"
|
|
false
|
|
}
|
|
grep -q 'unable to connect to linstor' "$tmp/out/res.txt"
|
|
|
|
# The archive sentence belongs only to the two reads that stream one. A cut-off
|
|
# .txt that carried it would be told about a mechanism its read never went
|
|
# through -- the same fault as naming a timeout that did not fire.
|
|
STUB_EXEC_MODE=cut PATH="$tmp/bin:$PATH" \
|
|
cozyreport_read_exec "$tmp/out/idx.txt" kubectl exec -n ns deploy/c -- linstor error-reports list
|
|
if grep -q 'idx.txt' "$tmp/out/COLLECTION-FAILED.txt" &&
|
|
grep -A2 'idx.txt' "$tmp/out/COLLECTION-FAILED.txt" | grep -q 'EITHER beside it'; then
|
|
echo "FAIL: a cut-off listing was described as an archive"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "both readers mark the same file type in the same greppable form" {
|
|
# Nothing held this and the two readers had drifted: the object reader wrote
|
|
# `# [cozyreport]` into a .txt and the exec reader wrote it bare. A triager greps
|
|
# `^# [cozyreport]` over the unpacked tarball, gets part of it, and has no way to
|
|
# know the rest exists.
|
|
#
|
|
# BOTH outcomes, because the first version of this test checked only the path
|
|
# where the command produced output -- and the three zero-output branches drifted
|
|
# underneath it, in the same commit that stated the rule. Zero output is the
|
|
# ticket's own case: a wedged controller killed before it writes a byte.
|
|
tmp=$(mktemp -d)
|
|
exec_stub_dir "$tmp"
|
|
printf '#!/bin/sh\nprintf "row one\\nrow tw"\nexit 1\n' > "$tmp/objstub"
|
|
chmod +x "$tmp/objstub"
|
|
|
|
STUB_EXEC_MODE=cut PATH="$tmp/bin:$PATH" \
|
|
cozyreport_read_exec "$tmp/out/exec-partial.txt" kubectl exec -n ns deploy/c -- linstor n l
|
|
STUB_EXEC_MODE=silentcut PATH="$tmp/bin:$PATH" \
|
|
cozyreport_read_exec "$tmp/out/exec-empty.txt" kubectl exec -n ns deploy/c -- linstor n l
|
|
COZYREPORT_BOUND="" cozyreport_read_object "$tmp/out/object-partial.txt" "$tmp/objstub"
|
|
|
|
for f in exec-partial.txt exec-empty.txt object-partial.txt; do
|
|
[ -s "$tmp/out/$f" ] || { echo "FAIL: $f is empty"; false; }
|
|
grep -q '^# \[cozyreport\]' "$tmp/out/$f" || {
|
|
echo "FAIL: $f does not carry the shared marker form"
|
|
tail -n 2 "$tmp/out/$f"
|
|
false
|
|
}
|
|
done
|
|
# And no `[cozyreport]` line anywhere in them escapes the form.
|
|
for f in exec-partial.txt exec-empty.txt object-partial.txt; do
|
|
if grep -q '^\[cozyreport\]' "$tmp/out/$f"; then
|
|
echo "FAIL: $f carries an unprefixed [cozyreport] line"
|
|
grep -n '^\[cozyreport\]' "$tmp/out/$f"
|
|
false
|
|
fi
|
|
done
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "the satellite walk points the selector at its own directory" {
|
|
# The behaviour is parameterised and the parameterisation is tested -- but the
|
|
# CALL SITE that sets it was held by nothing: deleting the assignment left every
|
|
# test green while satellite notes went back to the shared kubernetes file. That
|
|
# is the gap two other guards in this file exist for, and neither can see this
|
|
# one, because what is missing is a variable rather than a function.
|
|
body=$(fold_source "$SCRIPT" | awk '/^ DIR=\$REPORT_DIR\/linstor\/error-reports$/,/^ rmdir /')
|
|
[ -n "$body" ] || { echo "FAIL: could not locate the satellite walk"; false; }
|
|
printf '%s\n' "$body" | grep -q '^ COZYREPORT_SELECT_NOTE_DIR=\$DIR$' || {
|
|
echo "FAIL: the satellite walk does not point the selector at its own directory"
|
|
printf '%s\n' "$body" | head -5
|
|
false
|
|
}
|
|
printf '%s\n' "$body" | grep -q '^ COZYREPORT_SELECT_NOTE_DIR=""$' || {
|
|
echo "FAIL: the satellite walk leaves the note directory set for the next caller"
|
|
false
|
|
}
|
|
}
|
|
|
|
@test "a table that died part way through says so in the table itself" {
|
|
# Not every wedged controller produces 124. One that streams three rows and then
|
|
# exits 1 leaves a file that reads as a complete three-node listing, and the note
|
|
# a directory up is the walk a triager does not make -- the same argument the
|
|
# timeout branch already acts on, and the same one the object reader acts on for
|
|
# this exact shape.
|
|
tmp=$(mktemp -d)
|
|
exec_stub_dir "$tmp"
|
|
|
|
STUB_EXEC_MODE=partialfail PATH="$tmp/bin:$PATH" \
|
|
cozyreport_read_exec "$tmp/out/nodes.txt" kubectl exec -n ns deploy/c -- linstor n l
|
|
|
|
grep -q 'srv2 | Ye' "$tmp/out/nodes.txt"
|
|
grep -q '^# \[cozyreport\] EXIT 1 after this output' "$tmp/out/nodes.txt" || {
|
|
echo "FAIL: a partial table that failed on its own terms carries no marker, or not in the shared form"
|
|
cat "$tmp/out/nodes.txt"
|
|
false
|
|
}
|
|
# Nor does it claim a cut. kubectl exec passes the remote status through, and a
|
|
# command that lists everything it can and then exits 1 is complete; the status
|
|
# alone cannot separate that from a stream that broke.
|
|
if grep -q 'TRUNCATED' "$tmp/out/nodes.txt"; then
|
|
echo "FAIL: a non-zero exit after output is asserted to be a cut"; false; fi
|
|
if grep -q 'only what was streamed before' "$tmp/out/COLLECTION-FAILED.txt"; then
|
|
echo "FAIL: the note beside the table asserts a cut"; false; fi
|
|
# And it does not borrow the timeout branch's wording, which names a clock that
|
|
# did not fire.
|
|
if grep -q 'killed at exit' "$tmp/out/nodes.txt"; then
|
|
echo "FAIL: a non-timeout failure is described as a kill"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a table that got nothing still says why instead of shipping empty" {
|
|
# The sharpest case in the ticket: a wedged controller holds the stream open and
|
|
# the read is killed before the command writes a byte, so there is no stdout to
|
|
# mark and no stderr to copy. Left alone, `linstor/nodes.txt` ships at zero
|
|
# length and reads as a cluster with no nodes -- and unlike the two archive
|
|
# reads, the tables have no call-site emptiness check to remove it.
|
|
tmp=$(mktemp -d)
|
|
exec_stub_dir "$tmp"
|
|
|
|
STUB_EXEC_MODE=silentcut PATH="$tmp/bin:$PATH" \
|
|
cozyreport_read_exec "$tmp/out/nodes.txt" kubectl exec -n ns deploy/c -- linstor n l
|
|
|
|
[ -s "$tmp/out/nodes.txt" ] || {
|
|
echo "FAIL: a killed table shipped as a zero-byte file"; false; }
|
|
grep -q 'killed at exit 124 before the command inside the pod wrote anything' "$tmp/out/nodes.txt"
|
|
|
|
# And it must say what the emptiness is ABOUT. "This file is empty" is a fact
|
|
# about the file; without the contrast a reader supplies the other subject and
|
|
# concludes the command found nothing.
|
|
grep -q 'not because the command' "$tmp/out/nodes.txt" || {
|
|
echo "FAIL: the placeholder does not separate an empty file from an empty result"
|
|
cat "$tmp/out/nodes.txt"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "an exec read with nowhere to write stderr does not claim it was silent" {
|
|
# `mktemp` fails on a machine with no writable temp dir, so stderr goes to
|
|
# /dev/null and the reader cannot know whether the command spoke. Every other
|
|
# reader in this file carries that distinction; this one did not, and the merge
|
|
# base put the message in the file unconditionally through `2>&1`, so losing it
|
|
# here is a narrow regression rather than a gap.
|
|
tmp=$(mktemp -d)
|
|
exec_stub_dir "$tmp"
|
|
ro="$tmp/readonly"; mkdir -p "$ro"; chmod 500 "$ro"
|
|
|
|
STUB_EXEC_MODE=refused TMPDIR="$ro" PATH="$tmp/bin:$PATH" \
|
|
cozyreport_read_exec "$tmp/out/pools.txt" kubectl exec -n ns deploy/c -- linstor sp l
|
|
|
|
chmod 700 "$ro"
|
|
[ -s "$tmp/out/pools.txt" ] || {
|
|
echo "FAIL: the read left nothing at all behind"; false; }
|
|
grep -q 'could not be captured' "$tmp/out/pools.txt" || {
|
|
echo "FAIL: a lost message is reported as the command having said nothing"
|
|
cat "$tmp/out/pools.txt"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a cut-off bundle note says both of the two things that can happen to it" {
|
|
# The note used to assert one outcome and was wrong both times it tried. A
|
|
# stream that got members across is kept and truncated; a stream killed before
|
|
# its first byte leaves zero bytes and the call site's emptiness check removes
|
|
# it. This path runs before that check, so it cannot know which -- and the
|
|
# wedged controller the ticket is about is the second case, the one the
|
|
# confident wording denied.
|
|
tmp=$(mktemp -d)
|
|
exec_stub_dir "$tmp"
|
|
|
|
STUB_EXEC_MODE=cut PATH="$tmp/bin:$PATH" \
|
|
cozyreport_read_exec "$tmp/out/bundle.tgz" kubectl exec -n ns deploy/c -- sh -c 'true'
|
|
|
|
note="$tmp/out/COLLECTION-FAILED.txt"
|
|
# The archive gets NO in-file marker: a line of prose inside a gzip stream is a
|
|
# file nothing can open, which is the whole reason this reader writes beside.
|
|
if tr -d '\0' < "$tmp/out/bundle.tgz" 2>/dev/null | grep -q 'cozyreport'; then
|
|
echo "FAIL: prose was appended into an archive"
|
|
false
|
|
fi
|
|
grep -q 'EITHER beside it and truncated' "$note" || {
|
|
echo "FAIL: the note does not offer the kept-and-truncated outcome"; cat "$note"; false; }
|
|
grep -q 'OR gone' "$note" || {
|
|
echo "FAIL: the note does not offer the removed-because-empty outcome"; cat "$note"; false; }
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a read that is not kubectl is not reported as kubectl in its own marker" {
|
|
# This reader served kubectl alone until the sweep gave it talosctl and the four
|
|
# host commands. Its markers named kubectl and called every non-YAML target an
|
|
# object, so a `df` that hit a stale mount produced "kubectl exited 1 ... not
|
|
# because the object ended" -- two false statements in one line, in an artifact
|
|
# whose whole premise is that its notes can be believed.
|
|
tmp=$(mktemp -d)
|
|
mkdir -p "$tmp/bin" "$tmp/out"
|
|
cat > "$tmp/bin/df" <<'STUB'
|
|
#!/bin/sh
|
|
printf 'Filesystem Size\n/dev/disk1 100G\n'
|
|
echo 'df: cannot read table of mounted file systems' >&2
|
|
exit 1
|
|
STUB
|
|
chmod +x "$tmp/bin/df"
|
|
|
|
COZYREPORT_BOUND="" PATH="$tmp/bin:$PATH" \
|
|
cozyreport_read_object "$tmp/out/df.txt" df -h
|
|
|
|
marker=$(tail -n 1 "$tmp/out/df.txt")
|
|
case "$marker" in
|
|
*kubectl*) echo "FAIL: a df read is reported as kubectl: $marker"; false ;;
|
|
esac
|
|
case "$marker" in
|
|
*"the object ended"*) echo "FAIL: a df listing is called an object: $marker"; false ;;
|
|
esac
|
|
printf '%s\n' "$marker" | grep -q 'df exited 1'
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a probe cut off is not left looking like a component that is absent" {
|
|
tmp=$(mktemp -d)
|
|
exec_stub_dir "$tmp"
|
|
REPORT_DIR="$tmp/report"
|
|
mkdir -p "$REPORT_DIR/kubernetes"
|
|
|
|
# The gate keeps its meaning: a probe that did not answer is not a probe that
|
|
# answered "no", so the caller still skips the module.
|
|
rc=0
|
|
STUB_EXEC_MODE=cut PATH="$tmp/bin:$PATH" \
|
|
cozyreport_probe "linstor" kubectl get deploy -n cozy-linstor linstor-controller || rc=$?
|
|
[ "$rc" -ne 0 ] || { echo "FAIL: a cut-off probe reported the module as present"; false; }
|
|
|
|
# And the skip is recorded, because the tree it leaves behind is the tree a
|
|
# cluster without LINSTOR leaves: no directory, no marker, nothing to read.
|
|
[ -f "$REPORT_DIR/kubernetes/COLLECTION-FAILED.txt" ] || {
|
|
echo "FAIL: a cut-off probe skipped its section in silence"; false; }
|
|
grep -q 'linstor' "$REPORT_DIR/kubernetes/COLLECTION-FAILED.txt"
|
|
grep -q 'NOT a statement that the component is absent' "$REPORT_DIR/kubernetes/COLLECTION-FAILED.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a probe answering plainly leaves nothing behind either way" {
|
|
tmp=$(mktemp -d)
|
|
exec_stub_dir "$tmp"
|
|
REPORT_DIR="$tmp/report"
|
|
mkdir -p "$REPORT_DIR/kubernetes"
|
|
|
|
PATH="$tmp/bin:$PATH" cozyreport_probe "kamaji" kubectl get deploy -n cozy-kamaji kamaji
|
|
|
|
# A component that IS installed writes no note, and neither does one that is
|
|
# not: "this cluster does not run that" is the cluster answering correctly, and
|
|
# a marker that fires on every install without KubeVirt or without LINSTOR is
|
|
# one a triager learns to skip -- which costs the runs where it names something
|
|
# real. Only the read that never came back is worth a line.
|
|
rc=0
|
|
STUB_EXEC_MODE=refused PATH="$tmp/bin:$PATH" \
|
|
cozyreport_probe "cert-manager" kubectl get crd certificates.cert-manager.io || rc=$?
|
|
[ "$rc" -ne 0 ] || { echo "FAIL: an absent component was reported as present"; false; }
|
|
if [ -f "$REPORT_DIR/kubernetes/COLLECTION-FAILED.txt" ]; then
|
|
echo "FAIL: an ordinary probe filed a collection failure"
|
|
cat "$REPORT_DIR/kubernetes/COLLECTION-FAILED.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "an exec that finished leaves no failure note and no prose in its output" {
|
|
tmp=$(mktemp -d)
|
|
exec_stub_dir "$tmp"
|
|
|
|
PATH="$tmp/bin:$PATH" \
|
|
cozyreport_read_exec "$tmp/out/nodes.txt" kubectl exec -n ns deploy/c -- linstor n l
|
|
|
|
grep -q 'srv1 | Yes' "$tmp/out/nodes.txt"
|
|
if [ -f "$tmp/out/COLLECTION-FAILED.txt" ]; then
|
|
echo "FAIL: a read that finished was filed as a failure"
|
|
cat "$tmp/out/COLLECTION-FAILED.txt"
|
|
false
|
|
fi
|
|
# kubectl announces the container it defaulted to on every exec against a
|
|
# Deployment. Merged into the output it is a line at the top of a table a
|
|
# person reads, and in the two calls that stream a tarball it is a corrupt
|
|
# archive, so it goes beside the file the same way a warning on any other
|
|
# successful read does.
|
|
if grep -q 'Defaulted container' "$tmp/out/nodes.txt"; then
|
|
echo "FAIL: kubectl's own line was merged into the output it describes"
|
|
false
|
|
fi
|
|
grep -q 'Defaulted container' "$tmp/out/READ-WARNINGS.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "no read in the report body is taken without a wall clock ceiling" {
|
|
# This replaces a guard that froze WHICH sections were bounded, and a second one
|
|
# that froze how many unbounded reads each of the others had. Both were lists,
|
|
# and a list is the wrong shape for this claim: the header named four sections
|
|
# while the file had ten, and the counts went stale inside the commit that was
|
|
# relying on them. The claim worth holding is universal -- no read here runs
|
|
# without a ceiling -- and a universal claim is checked by finding a
|
|
# counterexample, not by matching a table.
|
|
#
|
|
# Scoped to the report BODY, below the library guard. Above it the same words
|
|
# appear in the sentences these readers write into the artifact, so a scan of
|
|
# the whole file answers "where is kubectl mentioned" rather than "what does
|
|
# this script run"; the readers themselves are asserted elsewhere, one by one.
|
|
guard=$(grep -n 'COZYREPORT_LIB:-' "$SCRIPT" | head -1 | cut -d: -f1)
|
|
[ -n "$guard" ] || { echo "FAIL: could not find the library guard"; false; }
|
|
body=$(awk -v g="$guard" 'NR>g' "$SCRIPT" | grep -v '^[[:space:]]*#' |
|
|
awk '{ line = line $0
|
|
if (line ~ /\\$/) { sub(/\\$/, " ", line); next }
|
|
print line; line = "" }
|
|
END { if (line != "") print line }')
|
|
|
|
# `command -V kubectl` is the dependency check, not a read: it never contacts a
|
|
# cluster and cannot hang on one.
|
|
#
|
|
# The four host commands are in the pattern too, anchored at command position so
|
|
# the words cannot match prose. They talk to no cluster, which is exactly why
|
|
# they were missed: `df` blocks forever on a stale mount and takes the tarball
|
|
# with it, and a scan that only knows about kubectl reports a clean sweep while
|
|
# the header's claim -- EVERY read -- is false about four lines of the file.
|
|
reads=$(printf '%s\n' "$body" |
|
|
grep -E '(kubectl|talosctl)[[:space:]]|^[[:space:]]*(df|free|ps|dmesg)[[:space:]]' |
|
|
grep -v 'command -V' || true)
|
|
# A scan that suddenly matches nothing is a broken scan, not a clean file, and
|
|
# it would report the strongest possible result while checking nothing at all.
|
|
n=$(printf '%s\n' "$reads" | grep -c . || true)
|
|
# A FLOOR against a scan that stopped matching, not a count of the reads: the
|
|
# body has around ninety, so a pattern that broke would fall far below this
|
|
# rather than just under it. It was set at 40 while the real figure was 94, which
|
|
# left room for a dozen reads to go invisible without the floor noticing -- the
|
|
# number is now close enough to the truth to be worth something, and deliberately
|
|
# not exact, because an exact one is a frozen count and this file has spent the
|
|
# whole change removing those.
|
|
[ "$n" -ge 80 ] || {
|
|
echo "FAIL: the scan found only $n reads in the report body, which is far fewer than this file has;"
|
|
echo "the pattern has stopped matching rather than the reads having gone away."
|
|
false
|
|
}
|
|
|
|
# Bounded means: through one of the four readers, through the collector that
|
|
# uses them, or through the bare wrapper at the sites where the reader cannot own
|
|
# the redirect -- a value captured into a variable, or a pipeline that transforms
|
|
# the output before anything lands. Described rather than counted: the header
|
|
# carried "three" of those while the file had five.
|
|
offenders=$(printf '%s\n' "$reads" |
|
|
grep -vE 'cozyreport_(read_object|read_exec|select_objects|probe|collect_pod|collect_broken_pods|collect_talos_node)|\$COZYREPORT_BOUND' || true)
|
|
if [ -n "$offenders" ]; then
|
|
echo "FAIL: a read in the report body runs with no wall-clock ceiling:"
|
|
printf '%s\n' "$offenders"
|
|
echo "The tarball is written on this script's last line, so a read that never"
|
|
echo "returns loses the whole artifact rather than truncating one file."
|
|
false
|
|
fi
|
|
|
|
# What this cannot see, said out loud rather than left to be found: a folded
|
|
# line carrying two commands satisfies it if either one is bounded. Nothing in
|
|
# this file is written that way, and the gates below hold the other spellings.
|
|
#
|
|
# And the header makes the same claim in words, beside the code that keeps it.
|
|
# A guard on the code alone leaves the sentence free to drift, which is the
|
|
# failure the list-shaped version of this test kept having.
|
|
head -n 60 "$SCRIPT" | grep -q '^# EVERY read below carries a wall-clock ceiling' || {
|
|
echo "FAIL: the header no longer claims that every read is bounded"
|
|
false
|
|
}
|
|
}
|
|
|
|
@test "the step that runs this collector has a ceiling of its own everywhere" {
|
|
# Every bound inside the script protects one read. None of them protects the
|
|
# STEP: the tarball is written on the collector's last line, the upload is the
|
|
# next step in the job, and a step with no ceiling of its own inherits only the
|
|
# job's -- so a read that never returns spends the rest of the job budget, the
|
|
# job is cancelled, and the upload never runs. That loses the artifact instead
|
|
# of truncating it, on the run where it is the only evidence left.
|
|
#
|
|
# All three workflows that run the collector, not the one that was looked at:
|
|
# the same step is copied into each, and a ceiling added to one of them leaves
|
|
# the other two with the defect while the fix looks done.
|
|
for wf in pull-requests nightly e2e-tag; do
|
|
f="$HACK_DIR/../.github/workflows/$wf.yaml"
|
|
[ -f "$f" ] || { echo "FAIL: .github/workflows/$wf.yaml is missing"; false; }
|
|
# The job's own cap is the last one seen above the step, told apart from the
|
|
# step's by indentation: job keys sit at four spaces, step keys at eight.
|
|
# Comparing against it is what makes the step's number mean something -- a
|
|
# ceiling at or above the job's is the same as no ceiling at all.
|
|
caps=$(awk '
|
|
/^ timeout-minutes: [0-9]+$/ { job = $2 }
|
|
/^ - name: Collect report$/ { inb = 1; next }
|
|
inb && /^ - name: / { inb = 0 }
|
|
inb && /^ timeout-minutes: [0-9]+$/ { step = $2; cap = job }
|
|
inb && /^ continue-on-error: true$/ { tol = "yes" }
|
|
END { print cap "/" step "/" tol }' "$f")
|
|
cap=${caps%%/*}
|
|
rest=${caps#*/}
|
|
step=${rest%/*}
|
|
tol=${rest#*/}
|
|
if [ -z "$step" ]; then
|
|
echo "FAIL: the Collect report step in $wf.yaml carries no timeout-minutes"
|
|
echo "Without one it inherits the job's, and a hung read costs the upload"
|
|
echo "that follows it rather than costing this step."
|
|
false
|
|
fi
|
|
[ -n "$cap" ] || { echo "FAIL: could not find the job cap in $wf.yaml"; false; }
|
|
if [ "$step" -ge "$cap" ]; then
|
|
echo "FAIL: the Collect report step in $wf.yaml is capped at $step minutes,"
|
|
echo "which the job's own $cap does not leave room under."
|
|
false
|
|
fi
|
|
# And the ceiling must not change what the job REPORTS. `|| true` on the make
|
|
# call is this step's standing statement that collecting diagnostics is never
|
|
# fatal; a step killed at its ceiling never reaches that `|| true`, because the
|
|
# runner ends the process rather than the shell. Without continue-on-error a
|
|
# passing E2E run whose collection ran long is published as a failed one, and
|
|
# every later `failure()` in the job starts meaning "the tests failed OR the
|
|
# report was slow" -- a ceiling added for diagnostics would then be deciding
|
|
# merges.
|
|
if [ "$tol" != "yes" ]; then
|
|
echo "FAIL: the Collect report step in $wf.yaml has a ceiling but no continue-on-error,"
|
|
echo "so hitting that ceiling turns a passing run red on a diagnostics overrun."
|
|
false
|
|
fi
|
|
# And the overrun stays visible. `continue-on-error` keeps a fired ceiling out
|
|
# of the job's result, which also keeps it out of everyone's view: the step
|
|
# renders green and the only trace is in the raw log. This collector's whole
|
|
# standard is that a bound which fires is recorded, and the outer bound is not
|
|
# exempt from it -- so the step is addressable by id and a later step turns its
|
|
# outcome into a warning annotation.
|
|
grep -q '^ id: collect_report$' "$f" || {
|
|
echo "FAIL: the Collect report step in $wf.yaml has no id, so nothing can key on its outcome"
|
|
false
|
|
}
|
|
grep -q "steps.collect_report.outcome == 'failure'" "$f" || {
|
|
echo "FAIL: nothing in $wf.yaml surfaces a Collect report overrun;"
|
|
echo "continue-on-error renders it green and the ceiling fires in silence."
|
|
false
|
|
}
|
|
done
|
|
}
|
|
|
|
@test "every helper these scripts define is wired to at least one call site" {
|
|
# Written as a shape rather than as a list of "these two calls must appear in
|
|
# those three loops", because that shape of guard is blind to the helper added
|
|
# next -- which is exactly how it failed here: cozyreport_objects_deadline_passed
|
|
# had its own passing test while one loop had stopped calling it, and the
|
|
# wiring assertion named other functions.
|
|
#
|
|
# A helper with no call site is the general form of that gap: the behaviour is
|
|
# covered, the wiring is not, and both tests stay green. Definition and use are
|
|
# counted separately so the definition line cannot satisfy the check.
|
|
orphans=""
|
|
for f in cozyreport.sh e2e-capture-previous-logs.sh; do
|
|
for h in $(grep -oE '^[a-z_][a-z0-9_]*\(\)' "$HACK_DIR/$f" | tr -d '()'); do
|
|
case "$h" in
|
|
# `log` in the capture script is called from every branch of the walk,
|
|
# but the name is too short for a word-boundary grep to separate it from
|
|
# ordinary prose. Its wiring is covered by the tests that assert on the
|
|
# lines it emits.
|
|
log) continue ;;
|
|
esac
|
|
uses=$(fold_source "$HACK_DIR/$f" \
|
|
| grep -vE "^[a-z_][a-z0-9_]*\(\)" \
|
|
| grep -cE "(^|[^a-zA-Z0-9_])$h([^a-zA-Z0-9_]|$)" || true)
|
|
# cozyreport.sh's helpers may legitimately be called only from the summary,
|
|
# which sources them.
|
|
if [ "$uses" -eq 0 ] && [ "$f" = cozyreport.sh ]; then
|
|
uses=$(fold_source "$HACK_DIR/cozyreport-summary.sh" \
|
|
| grep -cE "(^|[^a-zA-Z0-9_])$h([^a-zA-Z0-9_]|$)" || true)
|
|
fi
|
|
[ "$uses" -gt 0 ] || orphans="$orphans $f:$h"
|
|
done
|
|
done
|
|
|
|
if [ -n "$orphans" ]; then
|
|
echo "FAIL: helper defined but never called:$orphans"
|
|
echo "A helper with no call site is behaviour that is tested and not reached."
|
|
false
|
|
fi
|
|
}
|
|
|
|
@test "the pod and kubevirt per-object reads all go through the bounded reader" {
|
|
# The header scopes the bound by naming sections. Sharing one READY selector widened the pod and VMI sections and
|
|
# NARROWED the VM one: VM already selected on READY through a fixed $5, and $NF
|
|
# only drops the short rows where that $5 was spuriously empty. Only the
|
|
# pod section had been bounded, and
|
|
# nothing asserted the boundedness of the other two, so the selector change
|
|
# passed without touching a single assertion about ceilings. A VMI at
|
|
# Phase=Running whose guest agent never connected is the normal shape of a failed
|
|
# tenant-kubernetes run, so the new fan-out lands exactly where a degraded
|
|
# apiserver is most likely to hang a read.
|
|
# The pod section first: it is the change's primary behaviour, and its call site
|
|
# was the last unpinned seam -- replacing this line with a no-op left every test
|
|
# green, because the collector function is covered from every angle and nothing
|
|
# asserted that the report invokes it.
|
|
pods=$(awk '/^echo "Collecting pods/,/^echo "Collecting virtualmachines/' "$SCRIPT")
|
|
[ -n "$pods" ] || { echo "FAIL: could not locate the pod section"; false; }
|
|
printf '%s\n' "$pods" | grep -q '^cozyreport_collect_broken_pods "' || {
|
|
echo "FAIL: the pod section does not call the collector"
|
|
printf '%s\n' "$pods"
|
|
false
|
|
}
|
|
if printf '%s\n' "$pods" | grep -qE '^kubectl get pod .*\| *awk '; then
|
|
echo "FAIL: the pod section still selects inline"
|
|
false
|
|
fi
|
|
|
|
for section in vm vmi; do
|
|
# Comments stripped first: the selector helper's docstring quotes the same
|
|
# `kubectl get vm -A --no-headers` line it documents, and an unstripped range
|
|
# starts there and swallows everything down to the next `done`.
|
|
body=$(fold_source "$SCRIPT" | awk "/kubectl get $section -A --no-headers/,/^ done/")
|
|
[ -n "$body" ] || { echo "FAIL: could not locate the $section loop"; false; }
|
|
# Every kubectl in the loop goes through the bounded reader rather than a bare
|
|
# call, which is also what attaches a truncation marker when a read is cut off.
|
|
if printf '%s\n' "$body" | grep -qE "^[[:space:]]+$KUBECTL_RE"; then
|
|
echo "FAIL: the $section loop still has an unbounded kubectl read"
|
|
printf '%s\n' "$body" | grep -nE "^[[:space:]]+$KUBECTL_RE"
|
|
false
|
|
fi
|
|
printf '%s\n' "$body" | grep -q 'cozyreport_read_object' || {
|
|
echo "FAIL: the $section loop does not use the bounded reader"
|
|
false
|
|
}
|
|
# And the SELECTOR is wired in, not just the reader. Reverting either call site
|
|
# to its pre-change filter -- `awk '$4 != "Running"'` for vmi, `awk '$5 !=
|
|
# "True"'` for vm -- left every test green: the helper was covered, the wiring
|
|
# was not, and "the VMI section moved off a phase filter" is one of this
|
|
# change's three headline behaviours.
|
|
printf '%s\n' "$body" | grep -q 'cozyreport_kubevirt_not_ready' || {
|
|
echo "FAIL: the $section selection does not go through the shared helper"
|
|
printf '%s\n' "$body" | head -3
|
|
false
|
|
}
|
|
if printf '%s\n' "$body" | grep -qE "^kubectl get $section .*\| *awk "; then
|
|
echo "FAIL: the $section selection still filters inline"
|
|
false
|
|
fi
|
|
done
|
|
|
|
# Both object bounds are WIRED, not merely defined. The helpers have their own
|
|
# tests; a loop that stopped calling them would keep those green while losing
|
|
# the bound entirely, which is the same gap the selector call sites had.
|
|
for section in 'kubectl get vm -A --no-headers' 'kubectl get vmi -A --no-headers' 'kubectl get svc -A --no-headers'; do
|
|
loop=$(fold_source "$SCRIPT" | awk -v s="$section" 'index($0, s) > 0, /^ done/')
|
|
[ -n "$loop" ] || { echo "FAIL: could not locate the loop for $section"; false; }
|
|
printf '%s\n' "$loop" | grep -q 'cozyreport_admit_objects' || {
|
|
echo "FAIL: $section is not capped"; false
|
|
}
|
|
# The exact form, not just the name: `if ! cozyreport_objects_deadline_passed`
|
|
# satisfies a name check while inverting the bound -- a live budget would stop
|
|
# the walk on its first object and an expired one would let it run on.
|
|
printf '%s\n' "$loop" | grep -qE 'if[[:space:]]+cozyreport_objects_deadline_passed;[[:space:]]*then' || {
|
|
echo "FAIL: $section does not check the object budget in the expected form"
|
|
printf '%s\n' "$loop" | grep -n 'deadline_passed' || true
|
|
false
|
|
}
|
|
printf '%s\n' "$loop" | grep -q 'cozyreport_objects_budget_note' || {
|
|
echo "FAIL: $section breaks on the budget without recording that it did"; false
|
|
}
|
|
# And the SELECTOR read is bounded. The budget only starts counting once rows
|
|
# arrive, so an apiserver that hangs on the cluster-wide list spends the whole
|
|
# collection step without the deadline ever being consulted. Bounding the
|
|
# per-object reads and leaving the read that feeds them unbounded protects the
|
|
# cheap half of the loop and not the expensive one.
|
|
# Bounded AND status-keeping. A pipeline in dash has no pipefail, so a
|
|
# selector piped straight in loses its exit code: a read killed at 124 leaves
|
|
# no rows, no error and no marker, which is byte-identical to a cluster with
|
|
# nothing to select. cozyreport_select_objects does the read, keeps the
|
|
# status, and records the failure.
|
|
printf '%s\n' "$loop" | head -1 | grep -q 'cozyreport_select_objects' || {
|
|
echo "FAIL: the $section selector read does not keep its exit status"
|
|
printf '%s\n' "$loop" | head -1
|
|
false
|
|
}
|
|
done
|
|
|
|
# The services loop, on the same footing as vm/vmi: correcting its selector is
|
|
# what made the body reachable, so it is also what made a hang inside it
|
|
# possible. A section that never ran cannot have been bounded by accident.
|
|
svcloop=$(fold_source "$SCRIPT" | awk '/kubectl get svc -A --no-headers/,/^ done/')
|
|
[ -n "$svcloop" ] || { echo "FAIL: could not locate the services loop"; false; }
|
|
if printf '%s\n' "$svcloop" | grep -qE "^[[:space:]]+$KUBECTL_RE"; then
|
|
echo "FAIL: the services loop still has an unbounded kubectl read"
|
|
printf '%s\n' "$svcloop" | grep -nE "^[[:space:]]+$KUBECTL_RE"
|
|
false
|
|
fi
|
|
printf '%s\n' "$svcloop" | grep -q 'cozyreport_read_object' || {
|
|
echo "FAIL: the services loop does not use the bounded reader"
|
|
false
|
|
}
|
|
|
|
# The cozystack apps section, whose every read was gated on a CRD that does not
|
|
# exist. Two separate assertions, because the two kinds are reached two
|
|
# different ways and the old loop got both wrong the same way.
|
|
apps=$(fold_source "$SCRIPT" | awk '/^echo "Collecting cozystack apps/,/^echo "Collecting pods/')
|
|
[ -n "$apps" ] || { echo "FAIL: could not locate the cozystack apps section"; false; }
|
|
if printf '%s\n' "$apps" | grep -q 'get crd'; then
|
|
echo "FAIL: a cozystack apps read is gated on a CRD again"
|
|
printf '%s\n' "$apps" | grep -n 'get crd'
|
|
false
|
|
fi
|
|
# Tenants come from an aggregated APIService: a down API must arrive as
|
|
# kubectl's refusal in the file, which is what the bounded reader guarantees,
|
|
# rather than as an absent file that reads as a cluster with no tenants.
|
|
printf '%s\n' "$apps" | grep -q 'cozyreport_read_object "$DIR/tenants.txt"' || {
|
|
echo "FAIL: the tenant listing does not go through the bounded reader"
|
|
false
|
|
}
|
|
# ApplicationDefinition is cluster-scoped. `-A` there is the other half of the
|
|
# old loop's mistake, and it is silent: the namespace column prints <none>.
|
|
if printf '%s\n' "$apps" | grep -q 'applicationdefinitions.*-A'; then
|
|
echo "FAIL: a cluster-scoped kind is read with --all-namespaces"
|
|
false
|
|
fi
|
|
|
|
# Same wiring assertion for the services section, whose inline selector read the
|
|
# wrong column for its whole life. An inline `awk '$N == ...'` is unreachable
|
|
# from a test, so every one of them is a column index nobody has ever run
|
|
# against a real row -- which is how `$4 == "<pending>"` survived.
|
|
svc=$(fold_source "$SCRIPT" | awk '/kubectl get svc -A --no-headers/,/^ done/')
|
|
[ -n "$svc" ] || { echo "FAIL: could not locate the services loop"; false; }
|
|
printf '%s\n' "$svc" | grep -q 'cozyreport_svc_pending' || {
|
|
echo "FAIL: the services selection does not go through the shared helper"
|
|
printf '%s\n' "$svc" | head -3
|
|
false
|
|
}
|
|
if printf '%s\n' "$svc" | grep -qE '^kubectl get svc .*\| *awk '; then
|
|
echo "FAIL: the services selection still filters inline"
|
|
false
|
|
fi
|
|
|
|
# Asserting the two loops by name only guards the two that exist. The defect was
|
|
# that a section without a boundedness test drifted silently, and a section added
|
|
# next month would drift the same way -- so freeze the inventory of reads that do
|
|
# NOT go through the bounded reader. The file deliberately leaves those alone
|
|
# (its header says which sections are in scope and why), but the number must not
|
|
# grow without someone deciding to let it: a new unbounded section moves this
|
|
# count and fails here at the moment it is written, rather than when a degraded
|
|
# apiserver hangs it in production.
|
|
# Frozen as a per-section LIST, not as a total. A total does not distinguish the
|
|
# cases it is counted for: bounding one read in section A while adding an
|
|
# unbounded one to section B leaves the sum unchanged, so the guard stays silent
|
|
# while precisely the thing it exists to catch -- a new section reading straight
|
|
# into the tree -- ships. A swap of that shape leaves the total unmoved and the
|
|
# test green.
|
|
#
|
|
# Keyed by section rather than by line number: line numbers move with any edit
|
|
# above them, which would make this a standing false red that somebody deletes
|
|
# within a week. The list is also its own documentation -- it names which
|
|
# sections still read unbounded, so the follow-up that bounds them has its scope
|
|
# written down here rather than in a comment holding a third copy of the number.
|
|
#
|
|
# LC_ALL=C on the sort: without it the order depends on the caller's collation,
|
|
# so this guard passed on one machine and failed on another with an identical
|
|
# tree. A check whose verdict depends on the environment it runs in is not a
|
|
# check.
|
|
# Section header matched at ANY indentation, redirect matched by SHAPE.
|
|
#
|
|
# Both of those were wrong, and each blinded the guard to one of the two cases
|
|
# the comment above claims it catches. The header pattern was anchored at column
|
|
# zero, so the four sections that sit inside an `if` -- cert-manager,
|
|
# objectstorage, kamaji, linstor -- had their reads credited to whatever
|
|
# top-level section came last; that is why the frozen list said `pvcs=17` when
|
|
# the pvcs section has two. And the redirect pattern was an allowlist of three
|
|
# variable names, so the cert-manager loop writing into `$cdir` was invisible
|
|
# entirely. A new read added in that loop's own style moved nothing, and a swap
|
|
# between two sections sharing a bucket moved nothing either -- the exact swap
|
|
# the comment says per-section keying eliminated. It was verified once, between
|
|
# two sections that happened not to share a bucket.
|
|
#
|
|
# An allowlist of names is the same defect as the marker regex that enumerated
|
|
# known prefixes: it can only see the cases somebody already thought of.
|
|
# Folded before counting, and the redirect matched in both variable spellings:
|
|
# a read whose `>` sits one continuation down, or which writes to `${DIR}/x`
|
|
# rather than `$DIR/x`, is the same unbounded read and was invisible to a
|
|
# pattern that assumed one line and one form.
|
|
# The per-section inventory of unbounded reads that used to sit here is gone,
|
|
# and its replacement is the guard above: every read in the body is bounded, so
|
|
# the counts it froze are all zero and a table of zeroes says less than the
|
|
# sentence does. It was also blind by construction -- it matched a redirect to
|
|
# `$VAR/`, which no gate, selector or command substitution has -- so its numbers
|
|
# were "how many of a section's unbounded reads this pattern can see", and they
|
|
# were quoted as though they were all of them.
|
|
#
|
|
# What stays here is the BOUNDARY every one of these scans rests on. They all
|
|
# look for the bare name, so kubectl invoked by path, through a variable, or
|
|
# under sudo is invisible to them -- and an invisible read is reported as no
|
|
# read at all, which is the strongest possible result and the wrong one. Fail
|
|
# here instead, in the same commit that introduces the spelling.
|
|
offbeat=$(fold_source "$SCRIPT" \
|
|
| grep -nE '(^|[[:space:]|(])(/[A-Za-z0-9_/.-]*kubectl|sudo[[:space:]]+kubectl|\$\{?KUBECTL)' || true)
|
|
if [ -n "$offbeat" ]; then
|
|
echo "FAIL: kubectl is invoked in a form these scans cannot see:"
|
|
printf '%s\n' "$offbeat"
|
|
echo "Either keep invoking it by bare name, or widen the scans in the same commit."
|
|
false
|
|
fi
|
|
}
|
|
|
|
@test "a failed mktemp refuses to write the report to the filesystem root" {
|
|
tmp=$(mktemp -d)
|
|
bin="$tmp/bin"; mkdir -p "$bin"
|
|
for b in sh dash grep sed awk cat rm mkdir tail head cut ls date printf tar dirname basename; do
|
|
p=$(command -v "$b" 2>/dev/null) && ln -sf "$p" "$bin/$b"
|
|
done
|
|
printf '#!/bin/sh\nexit 1\n' > "$bin/mktemp"; chmod +x "$bin/mktemp"
|
|
printf '#!/bin/sh\nexit 0\n' > "$bin/kubectl"; chmod +x "$bin/kubectl"
|
|
|
|
# This file sets no errexit, so a failing `mktemp -d` leaves REPORT_PDIR empty
|
|
# and every path below becomes absolute from `/`. With no errexit a refused
|
|
# `mkdir -p` stops nothing: an unprivileged user gets an empty tree, but
|
|
# root in a container -- how CI runs this -- gets directories created at the
|
|
# filesystem root and a report written into the host's `/`.
|
|
out=$(cd "$tmp" && PATH="$bin" sh "$SCRIPT" 2>&1 || true)
|
|
|
|
printf '%s\n' "$out" | grep -q 'refusing to write a report to the filesystem root'
|
|
# And it stopped there: no section header from the body may appear.
|
|
if printf '%s\n' "$out" | grep -q 'Collecting Cozystack information'; then
|
|
echo "FAIL: the script carried on past an empty report directory"
|
|
printf '%s\n' "$out"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "no live jsonpath nests one filter inside another" {
|
|
# kubectl rejects `[?( ... [?( ... )] ... )]` outright with "unterminated filter",
|
|
# client-side, before the read reaches the apiserver: its parser breaks on the
|
|
# FIRST `)` outside a quoted string and never tracks nesting. With stderr sent to
|
|
# /dev/null the caller sees an empty result, which is indistinguishable from a
|
|
# healthy cluster -- so the section renders as "everything is Ready" forever, and
|
|
# nothing in the artifact says otherwise.
|
|
#
|
|
# Two sites had this. One was found and fixed; the other sat in this same file
|
|
# and was found only because someone grepped the tree for the PATTERN rather than
|
|
# re-reading the fix. That is what this test is: the grep, kept.
|
|
#
|
|
# Comments are stripped first, because both files legitimately quote the broken
|
|
# form while explaining it -- the same false positive the EXIT-trap guard hit.
|
|
offenders=""
|
|
for f in cozyreport.sh cozyreport-summary.sh e2e-capture-previous-logs.sh; do
|
|
# Folded: the two halves of a nested filter can sit either side of a line
|
|
# continuation and concatenate into the forbidden form at runtime, which no
|
|
# per-line pattern sees.
|
|
if fold_source "$HACK_DIR/$f" | grep -qoE '\[\?\([^]]*\[\?\('; then
|
|
offenders="$offenders $f"
|
|
fi
|
|
done
|
|
if [ -n "$offenders" ]; then
|
|
echo "FAIL: jsonpath with a filter nested inside a filter in:$offenders"
|
|
echo "kubectl refuses to parse it; use --output=custom-columns, which takes one filter per column."
|
|
false
|
|
fi
|
|
}
|
|
|
|
@test "the budget overshoot the header states is the one the container loop takes" {
|
|
# The header and e2e-testing.md both put a number on how far past
|
|
# COZY_REPORT_PODS_BUDGET a run can go, and that number is a count of reads
|
|
# taken after the last deadline check -- not something either file can derive
|
|
# for itself. It has already been wrong twice: first stated per POD, which grows
|
|
# without bound as the container count grows, then per container LOG, when each
|
|
# container is read twice, current and previous, with no check between them.
|
|
#
|
|
# So pin the two counts the sentence rests on. Changing either -- adding a third
|
|
# read, or moving the check between the two logs -- fails here, next to the
|
|
# prose that has to change with it, rather than in a report whose ceiling
|
|
# quietly stopped matching its own documentation.
|
|
loop=$(awk '/^ for _ccp_c in \$_ccp_containers; do/,/^ done/' "$SCRIPT")
|
|
[ -n "$loop" ] || { echo "FAIL: could not locate the container loop"; false; }
|
|
|
|
reads=$(printf '%s\n' "$loop" | grep -c 'cozyreport_read_container_log ')
|
|
[ "$reads" -eq 2 ] || {
|
|
echo "FAIL: the container loop takes $reads bounded reads, the docs assume 2"
|
|
false
|
|
}
|
|
# Counted by the clock read, not by the knob's name: the name also appears in
|
|
# the comment above the check and in the `-n` test guarding it, so counting it
|
|
# answers "how often is the knob mentioned" while reading as "how often is the
|
|
# deadline checked". Every check reads the clock exactly once, and nothing else
|
|
# in this loop reads it at all.
|
|
checks=$(printf '%s\n' "$loop" | grep -c 'date +%s')
|
|
[ "$checks" -eq 1 ] || {
|
|
echo "FAIL: the container loop checks the deadline $checks times, the docs assume 1"
|
|
false
|
|
}
|
|
|
|
# And both files say so in the same terms. A guard on the code alone leaves the
|
|
# sentence free to drift, which is the failure being prevented.
|
|
grep -q "current AND previous log reads" "$SCRIPT" || {
|
|
echo "FAIL: the header no longer states the overshoot in terms of both reads"
|
|
false
|
|
}
|
|
grep -q "both log reads of one container" "$HACK_DIR/../docs/agents/e2e-testing.md" || {
|
|
echo "FAIL: e2e-testing.md no longer states the overshoot in terms of both reads"
|
|
false
|
|
}
|
|
}
|
|
|
|
@test "every marker file the docs promise is one the script can write" {
|
|
# The doc bullet is how a reader learns which filenames to look for in the
|
|
# tarball. A marker the script writes but the docs never name leaves whoever
|
|
# finds it with nothing explaining it; a marker the docs promise but the script
|
|
# never writes sends them grepping for a file that cannot exist, and they
|
|
# conclude the case did not arise. Tests pin the code side, so without this
|
|
# nothing holds the doc side to it.
|
|
doc="$HACK_DIR/../docs/agents/e2e-testing.md"
|
|
[ -f "$doc" ]
|
|
# Matched on the SHAPE of a marker name, not on a list of the suffixes anybody
|
|
# has used so far. Two earlier spellings enumerated: first the prefixes
|
|
# `(COLLECTION|CONTAINER-LOGS)-`, which was blind to logs-UNAVAILABLE.txt, then
|
|
# the suffixes `(UNAVAILABLE|TRUNCATED|FAILED|UNBOUNDED|NOTES)`, which was blind
|
|
# to CONTAINER-LOGS-BOUND.txt the moment it was added -- by the same commit that
|
|
# was relying on this guard to notice. An enumeration cannot report the case
|
|
# nobody thought of, and each fix to it only moves which case that is.
|
|
#
|
|
# Markers in this tree are SHOUTING names: capitals and hyphens, ending .txt.
|
|
# Nothing else in either file matches that shape, so the guard now asks what a
|
|
# marker looks like rather than what markers exist.
|
|
pat='[A-Z][A-Z-]+\.txt'
|
|
doc_names=$(grep -oE "$pat" "$doc" | sort -u)
|
|
code_names=$(grep -oE "$pat" "$SCRIPT" | sort -u)
|
|
[ -n "$doc_names" ] && [ -n "$code_names" ]
|
|
for n in $doc_names; do
|
|
if ! grep -q -- "$n" "$SCRIPT"; then
|
|
echo "FAIL: docs/agents/e2e-testing.md promises $n but hack/cozyreport.sh never writes it"
|
|
false
|
|
fi
|
|
done
|
|
# And the other direction: a marker the script
|
|
# writes and the docs never name leaves a reader who finds it in the tarball
|
|
# with nothing telling them what it means.
|
|
for n in $code_names; do
|
|
if ! grep -q -- "$n" "$doc"; then
|
|
echo "FAIL: hack/cozyreport.sh writes $n but docs/agents/e2e-testing.md never names it"
|
|
false
|
|
fi
|
|
done
|
|
}
|
|
|
|
@test "pods not ready keeps a running pod whose readiness probe never passed" {
|
|
out="$(printf '%s\n' "$SNAPSHOT" | cozyreport_pods_not_ready)"
|
|
|
|
printf '%s\n' "$out" | grep -q 'keycloak-db-2'
|
|
}
|
|
|
|
@test "pods not ready keeps a partially ready multi container pod" {
|
|
out="$(printf '%s\n' "$SNAPSHOT" | cozyreport_pods_not_ready)"
|
|
|
|
printf '%s\n' "$out" | grep -q 'satellite-v'
|
|
}
|
|
|
|
@test "pods not ready still keeps the non running phases collected before" {
|
|
out="$(printf '%s\n' "$SNAPSHOT" | cozyreport_pods_not_ready)"
|
|
|
|
printf '%s\n' "$out" | grep -q 'puller-z'
|
|
printf '%s\n' "$out" | grep -q 'unscheduled-w'
|
|
}
|
|
|
|
@test "pods not ready drops a fully ready running pod" {
|
|
out="$(printf '%s\n' "$SNAPSHOT" | cozyreport_pods_not_ready)"
|
|
|
|
# `! cmd` is vacuous under cozytest's `set -e` (errexit is suppressed for
|
|
# any command whose status is inverted with `!`), so a regression that let
|
|
# this row through would not fail the test. Assert the absence via
|
|
# `if cmd; then ...; false`.
|
|
if printf '%s\n' "$out" | grep -q 'coredns-abc'; then echo "FAIL: a ready pod must not be collected"; false; fi
|
|
}
|
|
|
|
@test "a pod held unready by a readiness gate is outside what the column can see" {
|
|
# The known gap, pinned rather than claimed away. READY counts ready CONTAINERS;
|
|
# the pod's Ready condition is a different fact, and `spec.readinessGates` is
|
|
# where they part: every container passes its probe, the column prints 1/1, and
|
|
# `status.conditions[type=Ready]` is still False because a gate the kubelet does
|
|
# not evaluate has not been satisfied. Such a pod gets no per-pod evidence.
|
|
#
|
|
# This is a deliberate trade, not an oversight -- the alternative is a
|
|
# structured read per pod in place of one cluster-wide list, on the report of a
|
|
# cluster that is already failing -- and it is here so that changing the trade
|
|
# has to start by changing this test, and so that a reader chasing a missing pod
|
|
# directory finds the reason instead of an empty tree.
|
|
gated='tenant-a gated-0 1/1 Running 0 4m'
|
|
|
|
if [ -n "$(printf '%s\n' "$gated" | cozyreport_pods_not_ready)" ]; then
|
|
echo "FAIL: the selection now sees something this column does not carry"
|
|
false
|
|
fi
|
|
}
|
|
|
|
@test "pods not ready drops finished pods that legitimately end unready" {
|
|
out="$(printf '%s\n' "$SNAPSHOT" | cozyreport_pods_not_ready)"
|
|
|
|
# A Completed/Succeeded pod sits at READY=0/1 forever. Selecting it would bury
|
|
# the real failures under every job the suite ever ran.
|
|
if printf '%s\n' "$out" | grep -q 'migrate-job-x'; then echo "FAIL: a Completed pod must not be collected"; false; fi
|
|
if printf '%s\n' "$out" | grep -q 'backup-job-y'; then echo "FAIL: a Succeeded pod must not be collected"; false; fi
|
|
}
|
|
|
|
@test "pods not ready selects exactly the four broken rows of the snapshot" {
|
|
out="$(printf '%s\n' "$SNAPSHOT" | cozyreport_pods_not_ready)"
|
|
|
|
# Pins the count as well as the membership, so a widened predicate that starts
|
|
# dragging healthy pods into the report fails here rather than quietly
|
|
# doubling the size of every artifact.
|
|
[ "$(printf '%s\n' "$out" | grep -c .)" -eq 4 ]
|
|
}
|
|
|
|
@test "kubevirt not ready keeps a running vmi whose guest never became ready" {
|
|
# KubeVirt prints NAMESPACE NAME AGE PHASE IP NODENAME READY. A VMI at
|
|
# Phase=Running with Ready=False is a guest that never booted or a guest-agent
|
|
# that never connected, and a PHASE filter drops exactly that -- the same blind
|
|
# spot a STATUS-only pod filter has, in the same script.
|
|
rows="$(printf '%s\n' \
|
|
'tenant-test vm-booted 13h Running 10.244.0.5 node0 True' \
|
|
'tenant-test vm-no-agent 13h Running 10.244.0.6 node1 False' \
|
|
'tenant-test vm-pending 5m Pending False' \
|
|
'tenant-test vm-no-ip 13h Running node2 True')"
|
|
|
|
out="$(printf '%s\n' "$rows" | cozyreport_kubevirt_not_ready)"
|
|
|
|
printf '%s\n' "$out" | grep -q 'vm-no-agent'
|
|
# The Pending row has no IP and no node, so it prints five fields where a Running
|
|
# one prints seven.
|
|
printf '%s\n' "$out" | grep -q 'vm-pending'
|
|
if printf '%s\n' "$out" | grep -q 'vm-booted'; then echo "FAIL: collected a ready VMI"; false; fi
|
|
# Six fields: node assigned, IP not reported yet, and Ready=True. This is the row
|
|
# that makes $NF load-bearing -- a fixed $7 is empty here and would drag a ready
|
|
# VMI into the report.
|
|
if printf '%s\n' "$out" | grep -q 'vm-no-ip'; then echo "FAIL: read READY from a fixed column"; false; fi
|
|
[ "$(printf '%s\n' "$out" | grep -c .)" -eq 2 ]
|
|
}
|
|
|
|
@test "the vm selector narrows rather than widens on a short row" {
|
|
# The header's scoping argument names which sections changed and how. VM was
|
|
# already READY-based on main (`$5 != "True"`), so reading $NF NARROWS it: on a
|
|
# row whose STATUS is nil the fixed $5 is empty, empty is not "True", and a
|
|
# healthy VM was collected as broken. New is a strict subset of old here, unlike
|
|
# pods and VMIs which were genuinely widened. The header claimed all three were
|
|
# widened; this pins the distinction so the claim cannot drift back.
|
|
# Narrowing is only safe if it drops exactly the healthy rows. A selector that
|
|
# dropped every short row would trade a false positive for a MISS, and a miss is
|
|
# dearer in a collector: an over-collected object the operator sees and discards,
|
|
# a skipped one does not exist. So both short-row cases are pinned, not just the
|
|
# healthy one.
|
|
healthy_short='tenant-test vm-c 5m True'
|
|
broken_short='tenant-test vm-d 5m False'
|
|
|
|
[ -n "$(printf '%s\n' "$healthy_short" | awk '$5 != "True"')" ] ||
|
|
{ echo "FAIL: fixture no longer models the over-collection"; false; }
|
|
if [ -n "$(printf '%s\n' "$healthy_short" | cozyreport_kubevirt_not_ready)" ]; then
|
|
echo "FAIL: the new selector still collects a healthy VM on a short row"
|
|
false
|
|
fi
|
|
# The other half: a short row that is genuinely not Ready must survive. $NF is
|
|
# positional, but it is fail-safe by construction -- anything whose last field is
|
|
# not literally "True" is collected, so an absent READY column over-collects
|
|
# rather than skipping.
|
|
if [ -z "$(printf '%s\n' "$broken_short" | cozyreport_kubevirt_not_ready)" ]; then
|
|
echo "FAIL: a not-Ready VM on a short row was skipped"
|
|
false
|
|
fi
|
|
# The other section's direction, so "which one widened" has an executable
|
|
# counterpart rather than only prose: a VMI at Phase=Running with Ready=False is
|
|
# what the old `$4 != "Running"` filter dropped and the shared selector keeps.
|
|
vmi_widened='tenant-test worker-0 12m Running 10.244.1.5 node1 False'
|
|
[ -z "$(printf '%s\n' "$vmi_widened" | awk '$4 != "Running"')" ] ||
|
|
{ echo "FAIL: fixture no longer models the VMI blind spot"; false; }
|
|
if [ -z "$(printf '%s\n' "$vmi_widened" | cozyreport_kubevirt_not_ready)" ]; then
|
|
echo "FAIL: a Running VMI whose guest never became ready is still dropped"
|
|
false
|
|
fi
|
|
|
|
# And an absent READY column entirely: unknown readiness is collected, not
|
|
# assumed healthy.
|
|
for unknown in 'tenant-test vm-e 5m Running' 'tenant-test vm-f 5m'; do
|
|
if [ -z "$(printf '%s\n' "$unknown" | cozyreport_kubevirt_not_ready)" ]; then
|
|
echo "FAIL: a VM with no READY column was treated as Ready: $unknown"
|
|
false
|
|
fi
|
|
done
|
|
}
|
|
|
|
@test "kubevirt not ready reads the vm layout by the same last column" {
|
|
# The VM section shares this helper with the VMI section above, and its rows have
|
|
# a different shape: NAMESPACE NAME AGE STATUS READY, five fields against seven.
|
|
# Reading READY as $NF is what lets one predicate serve both, so both layouts are
|
|
# pinned here -- otherwise the VM half is a behaviour nobody can regress-test,
|
|
# which is how its selector drifted from its twin in the first place.
|
|
rows="$(printf '%s\n' \
|
|
'tenant-test vm-up 13h Running True' \
|
|
'tenant-test vm-stopped 13h Stopped False' \
|
|
'tenant-test vm-fresh 3s True' \
|
|
'tenant-test vm-prov 40s Provisioning False')"
|
|
|
|
out="$(printf '%s\n' "$rows" | cozyreport_kubevirt_not_ready)"
|
|
|
|
printf '%s\n' "$out" | grep -q 'vm-stopped'
|
|
printf '%s\n' "$out" | grep -q 'vm-prov'
|
|
if printf '%s\n' "$out" | grep -q 'vm-up'; then echo "FAIL: collected a ready VM"; false; fi
|
|
# The row that makes $NF load-bearing on this layout: `.status.printableStatus`
|
|
# is nil on a freshly created VM, so STATUS is absent and the row collapses to
|
|
# four fields. A fixed $5 reads empty here, and empty is not "True", so the old
|
|
# selector dragged this healthy VM into the report -- fail-safe in direction, but
|
|
# still a healthy VM reported as broken, on every cluster with a VM being created.
|
|
if printf '%s\n' "$out" | grep -q 'vm-fresh'; then echo "FAIL: read READY from a fixed column"; false; fi
|
|
[ "$(printf '%s\n' "$out" | grep -c .)" -eq 2 ]
|
|
}
|
|
|
|
@test "pods prioritize matches the namespace field and not a row substring" {
|
|
# Mirror of the sibling test in hack/capture-previous-logs.bats, which that
|
|
# file's comment says this helper follows. The claim was never checked here:
|
|
# swapping index($1, p) for index($0, p) left all 102 tests green.
|
|
#
|
|
# The fixture that bites is a pod NAMED like the prefix but living elsewhere --
|
|
# routine, since controllers are often named after what they manage. Matching the
|
|
# whole row promotes it ahead of the genuine tenant pod and spends the cap on it.
|
|
rows="$(printf '%s\n' \
|
|
'cozy-system tenant-controller-abc 0/1 Running 0 5m' \
|
|
'tenant-test real-one 0/1 Running 0 5m' \
|
|
'kube-system other 0/1 Running 0 5m')"
|
|
|
|
out="$(printf '%s\n' "$rows" | COZY_REPORT_PREFER_NS=tenant- cozyreport_pods_prioritize)"
|
|
|
|
first=$(printf '%s\n' "$out" | sed -n '1p' | awk '{print $2}')
|
|
if [ "$first" != "real-one" ]; then
|
|
echo "FAIL: expected the tenant-namespace pod first, got '$first' (matched the whole row)"
|
|
printf '%s\n' "$out"
|
|
false
|
|
fi
|
|
# Reordering never drops.
|
|
[ "$(printf '%s\n' "$out" | grep -c .)" -eq 3 ]
|
|
}
|
|
|
|
@test "kubevirt not ready drops the blank line an empty selection arrives as" {
|
|
# An unguarded predicate emits a blank row straight back: `$NF` on an empty line
|
|
# is `$0`, which is "" and therefore not "True". The caller's
|
|
# `while read NAMESPACE NAME` then runs once with both empty and makes a
|
|
# directory named for nothing.
|
|
#
|
|
# Counted with `wc -l` and not `grep -c .`, and deliberately not routed through
|
|
# `$(...)` first: a blank line is exactly what both of those erase. `grep -c .`
|
|
# counts non-empty lines, and command substitution strips the trailing newline,
|
|
# so the obvious spelling of this assertion passes whether or not the guard is
|
|
# there: that spelling passes with the guard removed.
|
|
count=$(printf '\n' | cozyreport_kubevirt_not_ready | wc -l | tr -d ' ')
|
|
|
|
[ "$count" -eq 0 ]
|
|
}
|
|
|
|
# One `kubectl get svc -A --no-headers` snapshot, covering every shape the
|
|
# EXTERNAL-IP column takes: a LoadBalancer waiting for an address, one that got
|
|
# it, a ClusterIP, and a headless service whose CLUSTER-IP is `None`.
|
|
svc_rows="$(printf '%s\n' \
|
|
'default kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 30d' \
|
|
'tenant-a ingress LoadBalancer 10.96.14.7 <pending> 80:31000/TCP,443:31001/TCP 4m' \
|
|
'tenant-b ingress LoadBalancer 10.96.14.9 198.51.100.10 80:31002/TCP 6d' \
|
|
'cozy-system etcd ClusterIP None <none> 2379/TCP 30d')"
|
|
|
|
@test "the object loops admit at most their cap and name what they left out" {
|
|
rows=""
|
|
i=1
|
|
while [ "$i" -le 30 ]; do
|
|
rows="$rows
|
|
tenant-a obj-$i <pending>"
|
|
i=$((i + 1))
|
|
done
|
|
|
|
tmp=$(mktemp -d)
|
|
REPORT_DIR="$tmp" mkdir -p "$tmp/kubernetes"
|
|
out=$(printf '%s\n' "$rows" | REPORT_DIR="$tmp" cozyreport_admit_objects "services")
|
|
|
|
# Thirty selected, the cap admits its own number, and the overflow is named in
|
|
# the artifact rather than dropped. The pod walk has had both bounds since this
|
|
# change; the object loops had only a per-read ceiling, which bounds one read
|
|
# and not their number -- and correcting the services selector is what made a
|
|
# large selection reachable in the first place.
|
|
[ "$(printf '%s\n' "$out" | grep -c .)" -eq "$COZYREPORT_OBJECTS_DEFAULT" ]
|
|
# "admitted", not "collected". This helper runs before the loop reads anything,
|
|
# so it can only report what the cap let through; on an expired budget the walk
|
|
# then collects none of them, and a note saying "25 collected" would name
|
|
# twenty-five directories that do not exist.
|
|
grep -q "services: 30 selected, $COZYREPORT_OBJECTS_DEFAULT admitted by the cap" \
|
|
"$tmp/kubernetes/COLLECTION-TRUNCATED.txt"
|
|
if grep -q "$COZYREPORT_OBJECTS_DEFAULT collected" "$tmp/kubernetes/COLLECTION-TRUNCATED.txt"; then
|
|
echo "FAIL: the cap note claims objects were collected before the walk ran"
|
|
cat "$tmp/kubernetes/COLLECTION-TRUNCATED.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a pod walk keeps the pods a dying list managed to name" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
rows="$(printf '%s\n' 'tenant-a broken-0 0/1 CrashLoopBackOff 7 4m' \
|
|
'tenant-a broken-1 0/1 CrashLoopBackOff 5 4m')"
|
|
|
|
PATH="$tmp:$PATH" STUB_PODLIST_FAIL=partial STUB_POD_ROWS="$rows" \
|
|
cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
# Before the read was moved into a variable, these rows flowed down the pipe and
|
|
# were collected. Keeping the exit status is the point of that move; throwing
|
|
# the rows away was not, and the note then said nothing was ever looked at while
|
|
# kubectl had just named two crash-looping tenants.
|
|
[ -d "$tmp/pods/tenant-a/broken-0" ] || {
|
|
echo "FAIL: a pod the list named before dying was not collected"
|
|
find "$tmp/pods" -maxdepth 3 | head
|
|
false
|
|
}
|
|
grep -q 'before it stopped, and there may be others' "$tmp/pods/COLLECTION-FAILED.txt" || {
|
|
echo "FAIL: a partial list is not described as partial"
|
|
cat "$tmp/pods/COLLECTION-FAILED.txt"
|
|
false
|
|
}
|
|
if grep -q 'nothing was ever looked at' "$tmp/pods/COLLECTION-FAILED.txt"; then
|
|
echo "FAIL: a partial list claims nothing was looked at"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a selector keeps the objects a dying read managed to name" {
|
|
tmp=$(mktemp -d); mkdir -p "$tmp/kubernetes"
|
|
fake="$tmp/fakectl"
|
|
printf '#!/bin/sh\nprintf "tenant-a ingress <pending>\\n"\necho "error: unexpected EOF" >&2\nexit 1\n' > "$fake"
|
|
chmod +x "$fake"
|
|
|
|
out=$(REPORT_DIR="$tmp" COZYREPORT_BOUND="" cozyreport_select_objects "services" "$fake")
|
|
|
|
printf '%s\n' "$out" | grep -q 'ingress' || {
|
|
echo "FAIL: the row the read named before dying was dropped"; false
|
|
}
|
|
grep -q 'before it stopped, and there may be others' "$tmp/kubernetes/COLLECTION-FAILED.txt" || {
|
|
echo "FAIL: the partial selection is described as empty"
|
|
cat "$tmp/kubernetes/COLLECTION-FAILED.txt"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a summary read keeps the rows a dying read managed to print" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
rows="$(printf '%s\n' 'tenant-a hr-1 4m False install failed one' \
|
|
'tenant-a hr-2 4m False install failed two')"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_CRD_PRESENT=1 STUB_HR_ROWS="$rows" STUB_HR_FAIL=partial \
|
|
sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# The first section of the first file a triager opens, and `kubectl get hr -A`
|
|
# on a broken cluster is the read most likely to be cut off at the 30s ceiling.
|
|
printf '%s\n' "$out" | grep -q 'hr-1' || {
|
|
echo "FAIL: rows printed before the failure were discarded"; printf '%s\n' "$out"; false
|
|
}
|
|
printf '%s\n' "$out" | grep -q 'before it stopped, and there may be others' || {
|
|
echo "FAIL: a partial read is described as an empty section"
|
|
printf '%s\n' "$out"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a selector read that never returned is not rendered as an empty cluster" {
|
|
tmp=$(mktemp -d); mkdir -p "$tmp/kubernetes"
|
|
fake="$tmp/fakectl"; printf '#!/bin/sh\nexit 124\n' > "$fake"; chmod +x "$fake"
|
|
|
|
out=$(REPORT_DIR="$tmp" COZYREPORT_BOUND="" cozyreport_select_objects "services" "$fake")
|
|
|
|
# A pipeline in dash has no pipefail, so piping the selector straight into the
|
|
# filter throws its exit code away: a read killed at its own deadline produces
|
|
# no rows, no error and no marker, and the section renders exactly like a
|
|
# cluster that has nothing pending. That is the same ambiguity the pod list is
|
|
# read separately to avoid.
|
|
[ -z "$out" ] || { echo "FAIL: a failed selector produced rows"; false; }
|
|
grep -q 'services: the selection read did not return (exit 124)' \
|
|
"$tmp/kubernetes/COLLECTION-FAILED.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a selector failing before its marker directory exists still records it" {
|
|
# Every note this function writes goes under $REPORT_DIR/kubernetes, and that
|
|
# directory is created part way down the report -- after the cozystack and
|
|
# cert-manager sections, both of which now select through here. A redirect into
|
|
# a directory that does not exist fails, `|| true` swallows the failure, and the
|
|
# marker is gone: the function whose whole job is that a failed selection cannot
|
|
# pass for an empty cluster would have failed silently in exactly the sections
|
|
# that run first.
|
|
#
|
|
# The directory is deliberately NOT created here, which is the whole test.
|
|
tmp=$(mktemp -d)
|
|
fake="$tmp/fakectl"; printf '#!/bin/sh\nexit 124\n' > "$fake"; chmod +x "$fake"
|
|
|
|
REPORT_DIR="$tmp" COZYREPORT_BOUND="" cozyreport_select_objects "cozystack configmaps" "$fake" >/dev/null
|
|
|
|
[ -f "$tmp/kubernetes/COLLECTION-FAILED.txt" ] || {
|
|
echo "FAIL: the marker was lost because its directory did not exist yet"
|
|
ls -R "$tmp"
|
|
false
|
|
}
|
|
grep -q 'cozystack configmaps' "$tmp/kubernetes/COLLECTION-FAILED.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "an empty listing says so in the file instead of arriving as zero bytes" {
|
|
# `kubectl get` writes `No resources found` to STDERR and exits 0
|
|
# (kubernetes/kubectl#1667), so an empty list reaches the reader as "succeeded,
|
|
# wrote nothing, and said something". Routed beside the file, that leaves a
|
|
# zero-byte `orders.txt` -- the exact "returned nothing, or never ran" question
|
|
# this collector exists to answer -- plus a READ-WARNINGS.txt on every healthy
|
|
# run, because `orders` and `challenges` are empty on any install with no ACME
|
|
# issuer. Before these listings moved onto the bounded reader their `2>&1` put
|
|
# the sentence in the file, where a person finds it.
|
|
tmp=$(mktemp -d)
|
|
fake="$tmp/fakectl"
|
|
printf '#!/bin/sh\necho "No resources found in cozy-system namespace." >&2\nexit 0\n' > "$fake"
|
|
chmod +x "$fake"
|
|
|
|
COZYREPORT_BOUND="" cozyreport_read_object "$tmp/orders.txt" "$fake"
|
|
|
|
[ -s "$tmp/orders.txt" ] || { echo "FAIL: an empty listing arrived as a zero-byte file"; false; }
|
|
grep -q 'No resources found' "$tmp/orders.txt"
|
|
if [ -f "$tmp/READ-WARNINGS.txt" ]; then
|
|
echo "FAIL: an ordinary empty list filed a warnings marker on a healthy run"
|
|
cat "$tmp/READ-WARNINGS.txt"
|
|
false
|
|
fi
|
|
|
|
# The YAML side keeps the opposite rule, and for its own reason: an unprefixed
|
|
# kubectl line in a document a parser reads costs the whole document. Fixing the
|
|
# listing by loosening this would trade one unreadable file for another.
|
|
COZYREPORT_BOUND="" cozyreport_read_object "$tmp/pod.yaml" "$fake"
|
|
if grep -q '^No resources found' "$tmp/pod.yaml" 2>/dev/null; then
|
|
echo "FAIL: a bare kubectl line landed in a YAML document"
|
|
cat "$tmp/pod.yaml"
|
|
false
|
|
fi
|
|
grep -q 'No resources found' "$tmp/READ-WARNINGS.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a truncated controller log is marked the way a log is, not the way an object is" {
|
|
# The bounded reader used to write objects, describes and section listings only.
|
|
# It now also writes thirteen controller logs, and its marker was shaped for the
|
|
# first set: a `#` prefix, which is a YAML comment and nothing at all in a log,
|
|
# and the words "not because the object ended" about a file that is not an
|
|
# object. The platform's controllers log JSON per line, so the prefix is the
|
|
# part that matters -- `# [cozyreport] ...` is a line no reader of that file can
|
|
# take.
|
|
#
|
|
# The marker still goes INSIDE the log, which is the other half of this test.
|
|
# cozyreport_read_container_log appends its own TRUNCATED line into a partial
|
|
# log, so moving this one beside the file would have two readers in one tarball
|
|
# answering the same question two ways -- worse than either answer on its own.
|
|
tmp=$(mktemp -d)
|
|
exec_stub_dir "$tmp"
|
|
|
|
STUB_EXEC_MODE=cut PATH="$tmp/bin:$PATH" \
|
|
cozyreport_read_object "$tmp/out/controller.log" kubectl logs deploy/x --tail=2000
|
|
|
|
grep -q 'TRUNCATED' "$tmp/out/controller.log"
|
|
if grep -q '^# \[cozyreport\]' "$tmp/out/controller.log"; then
|
|
echo "FAIL: a log carries the YAML-comment marker, which is not a comment in a log"
|
|
tail -n 1 "$tmp/out/controller.log"
|
|
false
|
|
fi
|
|
if grep -q 'because the object ended' "$tmp/out/controller.log"; then
|
|
echo "FAIL: the marker on a log calls it an object"
|
|
tail -n 1 "$tmp/out/controller.log"
|
|
false
|
|
fi
|
|
|
|
# And the object side is unchanged: a YAML target still gets the prefix, or the
|
|
# fix would have traded one unparseable file for another.
|
|
STUB_EXEC_MODE=cut PATH="$tmp/bin:$PATH" \
|
|
cozyreport_read_object "$tmp/out/pod.yaml" kubectl get pod -o yaml
|
|
grep -q '^# \[cozyreport\] TRUNCATED' "$tmp/out/pod.yaml" || {
|
|
echo "FAIL: an object lost the comment prefix its parser needs"
|
|
tail -n 1 "$tmp/out/pod.yaml"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "an empty selection does not run the loop body once with an empty name" {
|
|
# `printf '%s\n' ""` emits a blank LINE, not nothing, so a selector that
|
|
# succeeded with no rows used to hand its caller one empty row. Every loop that
|
|
# existed before filtered with `NF &&` and dropped it; the loops that moved onto
|
|
# this selector filter on a named column instead -- `$2 != "Ready"` and friends
|
|
# -- and a blank line satisfies every one of them, because an absent field is
|
|
# not the string it is compared against.
|
|
#
|
|
# The cost is not a wasted iteration. The body runs with an empty name, reads
|
|
# `kubectl get node "" -o yaml`, and the refusal lands in the file the reader
|
|
# opens: a `nodes/node.yaml` holding "resource name may not be empty" reads as a
|
|
# failed read of a real node. Before the selector was wired in, kubectl printed
|
|
# nothing on an empty list and the loop simply never ran.
|
|
#
|
|
# Fixed once in the selector rather than seven times in its callers: every one
|
|
# of them routes through it, and the next loop to be added would need the guard
|
|
# again.
|
|
tmp=$(mktemp -d)
|
|
fake="$tmp/fakectl"; printf '#!/bin/sh\nexit 0\n' > "$fake"; chmod +x "$fake"
|
|
|
|
# The caller's exact shape, filter included, since the filter is half the defect.
|
|
ran=$(REPORT_DIR="$tmp" COZYREPORT_BOUND="" cozyreport_select_objects "nodes" "$fake" |
|
|
awk '$2 != "Ready"' |
|
|
while read NAME _; do printf 'RAN[%s]\n' "$NAME"; done)
|
|
|
|
[ -z "$ran" ] || {
|
|
echo "FAIL: an empty selection still ran the loop body: $ran"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a selector note lands beside the walk it describes, not in a shared file" {
|
|
# Twelve callers read a Kubernetes listing and their notes belong under
|
|
# kubernetes/. The LINSTOR satellite walk does not: its bundles land under
|
|
# linstor/error-reports/, and a note there saying the pod list came back short is
|
|
# the difference between "three bundles because three satellites" and "three
|
|
# because the list was cut off". Filed in the shared kubernetes file it is in a
|
|
# directory that reader has no reason to open.
|
|
tmp=$(mktemp -d)
|
|
fake="$tmp/fakectl"; printf '#!/bin/sh\nexit 124\n' > "$fake"; chmod +x "$fake"
|
|
|
|
COZYREPORT_SELECT_NOTE_DIR="$tmp/linstor/error-reports" \
|
|
REPORT_DIR="$tmp" COZYREPORT_BOUND="" \
|
|
cozyreport_select_objects "linstor satellites" "$fake" >/dev/null
|
|
|
|
[ -f "$tmp/linstor/error-reports/COLLECTION-FAILED.txt" ] || {
|
|
echo "FAIL: the note did not land beside the walk"; ls -R "$tmp"; false; }
|
|
if [ -f "$tmp/kubernetes/COLLECTION-FAILED.txt" ]; then
|
|
echo "FAIL: the note also went to the shared kubernetes file"; false; fi
|
|
|
|
# And the default is unchanged for every other caller.
|
|
COZYREPORT_SELECT_NOTE_DIR="" REPORT_DIR="$tmp" COZYREPORT_BOUND="" \
|
|
cozyreport_select_objects "nodes" "$fake" >/dev/null
|
|
grep -q 'nodes' "$tmp/kubernetes/COLLECTION-FAILED.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a selector that succeeds leaves no marker directory behind it" {
|
|
# The other half: creating the directory on every call would put an empty
|
|
# kubernetes/ into reports that never needed one, and an empty directory in this
|
|
# tree is a thing a reader interprets.
|
|
tmp=$(mktemp -d)
|
|
fake="$tmp/fakectl"; printf '#!/bin/sh\nprintf "ns name\\n"\n' > "$fake"; chmod +x "$fake"
|
|
|
|
out=$(REPORT_DIR="$tmp" COZYREPORT_BOUND="" cozyreport_select_objects "nodes" "$fake")
|
|
|
|
[ "$out" = "ns name" ]
|
|
if [ -d "$tmp/kubernetes" ]; then
|
|
echo "FAIL: a clean selection created the failure-marker directory anyway"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a refused selector carries kubectl's reason, not only its exit code" {
|
|
tmp=$(mktemp -d); mkdir -p "$tmp/kubernetes"
|
|
fake="$tmp/fakectl"
|
|
# An RBAC refusal, deliberately NOT the "no such resource type" wording. Both
|
|
# exit 1, and "the selection read did not return (exit 1)" names a symptom with
|
|
# no cause -- but they are opposite findings: one is the cluster correctly saying
|
|
# it does not run KubeVirt, the other is this report being unable to look. The
|
|
# not-served case has its own test and lands in COLLECTION-NOTES.txt; this one is
|
|
# a real collection failure and has to keep both the marker and the message.
|
|
printf '#!/bin/sh\necho "Error from server (Forbidden): virtualmachines.kubevirt.io is forbidden: User \\"system:serviceaccount:cozy-system:reporter\\" cannot list resource \\"virtualmachines\\" in API group \\"kubevirt.io\\" at the cluster scope" >&2\nexit 1\n' > "$fake"
|
|
chmod +x "$fake"
|
|
|
|
REPORT_DIR="$tmp" COZYREPORT_BOUND="" cozyreport_select_objects "virtualmachines" "$fake" >/dev/null
|
|
|
|
grep -q "is forbidden" "$tmp/kubernetes/COLLECTION-FAILED.txt" || {
|
|
echo "FAIL: the refusal reached the artifact without its reason"
|
|
cat "$tmp/kubernetes/COLLECTION-FAILED.txt" 2>/dev/null || echo "(no COLLECTION-FAILED.txt at all)"
|
|
false
|
|
}
|
|
# And it must NOT be filed as an ordinary absence: a refusal this report cannot
|
|
# see past is exactly what the FAILED marker exists to name.
|
|
if [ -f "$tmp/kubernetes/COLLECTION-NOTES.txt" ]; then
|
|
echo "FAIL: an RBAC refusal was recorded as a resource type the cluster does not serve"
|
|
cat "$tmp/kubernetes/COLLECTION-NOTES.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a selector killed by its own bound names the cutoff, not a refusal" {
|
|
tmp=$(mktemp -d); mkdir -p "$tmp/kubernetes"
|
|
fake="$tmp/fakectl"; printf '#!/bin/sh\nexit 124\n' > "$fake"; chmod +x "$fake"
|
|
|
|
REPORT_DIR="$tmp" cozyreport_select_objects "services" "$fake" >/dev/null
|
|
|
|
# Timeout branch first, as everywhere else: a read killed before kubectl wrote
|
|
# anything has an empty stderr by construction, so leading with "without a
|
|
# message" would report the accident rather than the cause.
|
|
grep -q 'cut off by' "$tmp/kubernetes/COLLECTION-FAILED.txt" || {
|
|
echo "FAIL: a killed selector is not described as cut off"
|
|
cat "$tmp/kubernetes/COLLECTION-FAILED.txt"
|
|
false
|
|
}
|
|
if grep -q 'without a message' "$tmp/kubernetes/COLLECTION-FAILED.txt"; then
|
|
echo "FAIL: a killed selector is reported as a silent refusal"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a kind this cluster does not serve is a note, not a collection failure" {
|
|
tmp=$(mktemp -d); mkdir -p "$tmp/kubernetes"
|
|
fake="$tmp/fakectl"
|
|
# What kubectl says on an install without KubeVirt. `kubectl get vm -A` exits 1
|
|
# there, and that is the cluster answering correctly, not a read that failed.
|
|
printf '#!/bin/sh\necho %s >&2\nexit 1\n' \
|
|
"'error: the server doesn'\\''t have a resource type \"vm\"'" > "$fake"
|
|
chmod +x "$fake"
|
|
|
|
REPORT_DIR="$tmp" COZYREPORT_BOUND="" cozyreport_select_objects "virtualmachines" "$fake" >/dev/null
|
|
|
|
# COLLECTION-FAILED.txt is the filename a triager scans the tree for. Filing an
|
|
# ordinary absence under it makes the name cry wolf on every install without
|
|
# KubeVirt, and a marker that fires on healthy clusters is one a reader learns
|
|
# to skip -- which costs the runs where it names something real.
|
|
if [ -f "$tmp/kubernetes/COLLECTION-FAILED.txt" ]; then
|
|
echo "FAIL: a resource type this cluster does not serve was recorded as a failed read"
|
|
cat "$tmp/kubernetes/COLLECTION-FAILED.txt"
|
|
false
|
|
fi
|
|
grep -q 'does not serve that resource type' "$tmp/kubernetes/KIND-NOT-SERVED.txt" || {
|
|
echo "FAIL: the absence was not recorded anywhere at all"
|
|
ls -la "$tmp/kubernetes"
|
|
false
|
|
}
|
|
# kubectl's own words are kept: "absent" and "RBAC refused the list" both reach
|
|
# this branch only through what kubectl said, so the sentence has to carry it.
|
|
grep -q "resource type" "$tmp/kubernetes/KIND-NOT-SERVED.txt"
|
|
# And NOT under COLLECTION-NOTES.txt, which one directory down already means
|
|
# "a knob value the collector could not use". Two subjects behind one filename
|
|
# is a collision the marker guard cannot see, because it matches basenames and
|
|
# the doc names that file for the other meaning.
|
|
if [ -f "$tmp/kubernetes/COLLECTION-NOTES.txt" ]; then
|
|
echo "FAIL: reused the knob-note filename for a statement about the cluster"
|
|
cat "$tmp/kubernetes/COLLECTION-NOTES.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a selector that returned nothing says nothing at all" {
|
|
tmp=$(mktemp -d); mkdir -p "$tmp/kubernetes"
|
|
fake="$tmp/fakectl"; printf '#!/bin/sh\nexit 0\n' > "$fake"; chmod +x "$fake"
|
|
|
|
REPORT_DIR="$tmp" COZYREPORT_BOUND="" cozyreport_select_objects "services" "$fake" >/dev/null
|
|
|
|
# The converse: a healthy cluster with no pending LoadBalancer must not leave a
|
|
# failure marker. Reporting every empty selection as a failure would make the
|
|
# marker meaningless, which is the same defect in the other direction.
|
|
if [ -f "$tmp/kubernetes/COLLECTION-FAILED.txt" ]; then
|
|
echo "FAIL: an empty selection was recorded as a failed read"
|
|
cat "$tmp/kubernetes/COLLECTION-FAILED.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a log cut short at both ends names both bounds, not just the visible one" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=partial cozyreport_collect_pod ns hung-0 "$tmp/out"
|
|
|
|
# Two bounds applied to this file: `--tail` dropped its oldest lines and the cut
|
|
# dropped its newest. The end-truncation marker was recorded and the tail bound
|
|
# was not, because the tail note sat in the branch reached only after a clean
|
|
# read. Naming one while the other stays silent is worse than naming neither: it
|
|
# tells the reader the file begins where the container did.
|
|
grep -q '^\[cozyreport\] TRUNCATED' "$tmp/out/logs-app.txt"
|
|
grep -q 'holds at most the last 2000 lines' "$tmp/out/CONTAINER-LOGS-BOUND.txt" || {
|
|
echo "FAIL: a partial log does not record the tail bound it was read under"
|
|
ls "$tmp/out"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a walk stopped by the object budget says so instead of vanishing" {
|
|
tmp=$(mktemp -d); mkdir -p "$tmp/kubernetes"
|
|
|
|
REPORT_DIR="$tmp" cozyreport_objects_budget_note "services"
|
|
|
|
# The cap has a note and the budget did not, so objects removed by the clock
|
|
# disappeared with nothing recording it -- and when both bounds were in play the
|
|
# cap's note took the blame for omissions the clock caused. Every other bound in
|
|
# this tree names itself; this one was silent.
|
|
grep -q 'services: the .*s object budget elapsed' "$tmp/kubernetes/COLLECTION-TRUNCATED.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "an object loop under its cap reports no overflow at all" {
|
|
tmp=$(mktemp -d)
|
|
mkdir -p "$tmp/kubernetes"
|
|
out=$(printf 'tenant-a only-one <pending>\n' | REPORT_DIR="$tmp" cozyreport_admit_objects "services")
|
|
|
|
[ "$(printf '%s\n' "$out" | grep -c .)" -eq 1 ]
|
|
if [ -f "$tmp/kubernetes/COLLECTION-TRUNCATED.txt" ]; then
|
|
echo "FAIL: claimed an overflow that did not happen"
|
|
cat "$tmp/kubernetes/COLLECTION-TRUNCATED.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "the object walk stops on its budget instead of the job's outer limit" {
|
|
# The deadline is checked between objects, never inside one, so a file that
|
|
# exists is a file that was read whole. Past the deadline the loop admits
|
|
# nothing further.
|
|
COZYREPORT_OBJECTS_DEADLINE=$(( $(date +%s) - 1 ))
|
|
cozyreport_objects_deadline_passed || { echo "FAIL: an expired budget is not seen as expired"; false; }
|
|
|
|
COZYREPORT_OBJECTS_DEADLINE=$(( $(date +%s) + 600 ))
|
|
if cozyreport_objects_deadline_passed; then
|
|
echo "FAIL: a live budget is reported as spent"
|
|
false
|
|
fi
|
|
|
|
# And with no deadline set at all -- a caller driving the helpers directly --
|
|
# the walk is not silently stopped on its first object.
|
|
unset COZYREPORT_OBJECTS_DEADLINE
|
|
if cozyreport_objects_deadline_passed; then
|
|
echo "FAIL: an unset budget stops the walk"
|
|
false
|
|
fi
|
|
}
|
|
|
|
@test "a LoadBalancer waiting for an address is selected by its external column" {
|
|
out="$(printf '%s\n' "$svc_rows" | cozyreport_svc_pending)"
|
|
|
|
# `<pending>` is printed only for EXTERNAL-IP, which is $5. The selection read
|
|
# $4 -- CLUSTER-IP, which holds an address, `None` or `<none>` and never
|
|
# `<pending>` -- so it matched nothing on any cluster and the services directory
|
|
# has been empty since the section was written, which a reader takes to mean no
|
|
# LoadBalancer is stuck. A tenant whose ingress never gets an address is exactly
|
|
# the failure this section is for.
|
|
[ "$(printf '%s\n' "$out" | wc -l | tr -d ' ')" -eq 1 ]
|
|
case "$out" in
|
|
"tenant-a ingress"*) ;;
|
|
*) echo "FAIL: selected the wrong row: $out"; false ;;
|
|
esac
|
|
}
|
|
|
|
@test "a service that already has an address or never wanted one is left alone" {
|
|
# The converse of the test above: widening the column is only correct if it does
|
|
# not also start collecting every healthy service. `None` and `<none>` are the
|
|
# two spellings that would match a sloppier test for "no address yet".
|
|
for row in \
|
|
'default kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 30d' \
|
|
'tenant-b ingress LoadBalancer 10.96.14.9 198.51.100.10 80:31002/TCP 6d' \
|
|
'cozy-system etcd ClusterIP None <none> 2379/TCP 30d'
|
|
do
|
|
if [ -n "$(printf '%s\n' "$row" | cozyreport_svc_pending)" ]; then
|
|
echo "FAIL: collected a service that is not waiting: $row"
|
|
false
|
|
fi
|
|
done
|
|
}
|
|
|
|
@test "a service merely named like the pending marker is not collected" {
|
|
# Field-scoped, not an unanchored match on the row. A name cannot literally be
|
|
# `<pending>` on a cluster, but the reason this selection is written as a column
|
|
# test rather than a substring test is the same reason the pod prioritiser is
|
|
# field-scoped, and a whole-row match would pass every other test in this file.
|
|
row='tenant-a <pending> ClusterIP 10.96.14.7 <none> 80/TCP 4m'
|
|
|
|
if [ -n "$(printf '%s\n' "$row" | cozyreport_svc_pending)" ]; then
|
|
echo "FAIL: matched the name column"
|
|
false
|
|
fi
|
|
}
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# cozyreport_read_object -- the bounded whole-object read behind pod.yaml, #
|
|
# vm.yaml, vmi.yaml and the describes beside them. Three of those four are read #
|
|
# by a parser rather than by a person, which is what decides where stderr goes. #
|
|
# #
|
|
# cozytest.sh ends an @test at the first bare `}`, so the stub builder lives at #
|
|
# top level. #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
# A stand-in for kubectl that writes a chosen mix of stdout, stderr and exit
|
|
# status. read_object runs whatever argv it is handed, so nothing here has to be
|
|
# named kubectl.
|
|
object_stub() {
|
|
cat > "$1" <<'STUB'
|
|
#!/bin/sh
|
|
[ -z "$STUB_OUT" ] || printf '%s\n' "$STUB_OUT"
|
|
[ -z "$STUB_ERR" ] || printf '%s\n' "$STUB_ERR" >&2
|
|
exit "${STUB_RC:-0}"
|
|
STUB
|
|
chmod +x "$1"
|
|
}
|
|
|
|
@test "a warning on a read that succeeded stays out of the object it belongs to" {
|
|
tmp=$(mktemp -d)
|
|
object_stub "$tmp/fakectl"
|
|
|
|
COZYREPORT_BOUND="" \
|
|
STUB_OUT="apiVersion: v1
|
|
kind: Pod" \
|
|
STUB_ERR="Warning: v1 Pod is deprecated in this build" \
|
|
STUB_RC=0 \
|
|
cozyreport_read_object "$tmp/pod.yaml" "$tmp/fakectl"
|
|
|
|
# `2>&1` put the warning on line 1 and turned a complete object into a file no
|
|
# parser accepts -- and pod.yaml, vm.yaml and vmi.yaml are read by yq, not by
|
|
# eye. The same rule already applied two functions over in the log reader and in
|
|
# the summary's own read; this one was the exception, in the place where merging
|
|
# costs the most.
|
|
head -n 1 "$tmp/pod.yaml" | grep -q '^apiVersion:'
|
|
if grep -q 'Warning:' "$tmp/pod.yaml"; then
|
|
echo "FAIL: the warning landed in the object file"
|
|
cat "$tmp/pod.yaml"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a refusal with no object to show is still what the file holds" {
|
|
tmp=$(mktemp -d)
|
|
object_stub "$tmp/fakectl"
|
|
|
|
COZYREPORT_BOUND="" \
|
|
STUB_OUT="" \
|
|
STUB_ERR='Error from server (NotFound): pods "gone-0" not found' \
|
|
STUB_RC=1 \
|
|
cozyreport_read_object "$tmp/pod.yaml" "$tmp/fakectl"
|
|
|
|
# The converse of the test above, and the reason stderr is diverted rather than
|
|
# discarded: with no object to keep, kubectl's reason IS the evidence, and an
|
|
# empty pod.yaml says nothing about why. Keeping only the successful case would
|
|
# have traded one silent file for another.
|
|
grep -q 'pods "gone-0" not found' "$tmp/pod.yaml"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "an object read that died part way through says so instead of looking whole" {
|
|
tmp=$(mktemp -d)
|
|
object_stub "$tmp/fakectl"
|
|
|
|
COZYREPORT_BOUND="" \
|
|
STUB_OUT="apiVersion: v1
|
|
kind: Pod
|
|
metadata:" \
|
|
STUB_ERR='error: unexpected EOF' \
|
|
STUB_RC=1 \
|
|
cozyreport_read_object "$tmp/pod.yaml" "$tmp/fakectl"
|
|
|
|
# Diverting stderr opens a third outcome that the merged version could not have:
|
|
# kubectl wrote part of the object and then failed on its own terms. Without a
|
|
# marker the file is a valid-looking prefix and nothing says the rest was never
|
|
# read -- exactly the ambiguity the timeout marker exists to remove, arrived at
|
|
# by a different route. The reason rides on the marker line, not in the body,
|
|
# because the body is what gets parsed.
|
|
head -n 1 "$tmp/pod.yaml" | grep -q '^apiVersion:'
|
|
# Not anchored on the tool's name: the marker names whatever was run, which a
|
|
# test of its own pins. What matters here is that a partial object says so.
|
|
grep -q '^# \[cozyreport\] TRUNCATED.*exited 1 part way through' "$tmp/pod.yaml"
|
|
grep -q 'unexpected EOF' "$tmp/pod.yaml"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a read that returned neither an object nor a message still says so" {
|
|
tmp=$(mktemp -d)
|
|
object_stub "$tmp/fakectl"
|
|
|
|
# The fourth outcome, and the one that was missing: kubectl died on a signal
|
|
# that is not 124 or 137 -- a teardown SIGTERM -- writing neither an object nor
|
|
# a word about why. Every other classification site in these scripts covers it;
|
|
# this one left pod.yaml and describe.txt at zero bytes with nothing in them,
|
|
# in a directory whose emptiness e2e-testing.md reads as a claim about the pod.
|
|
COZYREPORT_BOUND="" STUB_OUT="" STUB_ERR="" STUB_RC=143 \
|
|
cozyreport_read_object "$tmp/pod.yaml" "$tmp/fakectl"
|
|
|
|
[ -s "$tmp/pod.yaml" ] || { echo "FAIL: the file is empty and explains nothing"; false; }
|
|
grep -q '^# \[cozyreport\] [a-z]* *exited 143' "$tmp/pod.yaml"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a lost stderr file is not turned into a claim that kubectl was silent" {
|
|
tmp=$(mktemp -d)
|
|
object_stub "$tmp/fakectl"
|
|
|
|
# The other way into that branch, and it is not about the cluster at all: with
|
|
# no writable temp dir the scratch file was never created, so stderr went to
|
|
# /dev/null. "kubectl said nothing" would then be a statement about the machine
|
|
# writing the report, dressed as one about the machine being reported on.
|
|
COZYREPORT_BOUND="" TMPDIR="$tmp/nonexistent" STUB_OUT="" \
|
|
STUB_ERR='Error from server (Forbidden): pods is forbidden' STUB_RC=1 \
|
|
cozyreport_read_object "$tmp/pod.yaml" "$tmp/fakectl" 2>/dev/null
|
|
|
|
grep -q 'could not be captured' "$tmp/pod.yaml"
|
|
if grep -q 'without a message' "$tmp/pod.yaml"; then
|
|
echo "FAIL: a lost message is reported as no message"
|
|
cat "$tmp/pod.yaml"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a warning on a successful read is kept beside the object, not dropped" {
|
|
tmp=$(mktemp -d)
|
|
object_stub "$tmp/fakectl"
|
|
|
|
COZYREPORT_BOUND="" \
|
|
STUB_OUT="apiVersion: v1
|
|
kind: Pod" \
|
|
STUB_ERR="Warning: v1 Pod is deprecated in this build" \
|
|
STUB_RC=0 \
|
|
cozyreport_read_object "$tmp/pod.yaml" "$tmp/fakectl"
|
|
|
|
# Diverting stderr keeps the object parseable, which is the point. But "not in
|
|
# the object" and "not in the report" are different outcomes, and the second is
|
|
# a silent loss of evidence in a tree whose entire claim is that there are none.
|
|
# `2>&1` keeps the warning, at the cost of the object; a scratch file keeps both.
|
|
if grep -q 'Warning:' "$tmp/pod.yaml"; then
|
|
echo "FAIL: the warning is back inside the object"; false
|
|
fi
|
|
grep -q 'Warning: v1 Pod is deprecated' "$tmp/READ-WARNINGS.txt" || {
|
|
echo "FAIL: the warning was dropped from the report entirely"
|
|
ls "$tmp"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "every warning on a successful read is kept, not just the last one" {
|
|
tmp=$(mktemp -d)
|
|
object_stub "$tmp/fakectl"
|
|
|
|
COZYREPORT_BOUND="" \
|
|
STUB_OUT="apiVersion: v1" \
|
|
STUB_ERR="Warning: v1 Pod is deprecated in this build
|
|
Warning: results are partial, 3 of 7 shards answered" \
|
|
STUB_RC=0 \
|
|
cozyreport_read_object "$tmp/pod.yaml" "$tmp/fakectl"
|
|
|
|
# `tail -n 1` is the right reduction for a FAILING read, where klog retry lines
|
|
# bury the cause and one line has to be chosen. A successful read has no
|
|
# preamble: every line is its own warning, and keeping the last one silently
|
|
# discards the rest. Two warnings on one read is ordinary -- a deprecation plus
|
|
# a partial-result notice is exactly what a degraded apiserver produces.
|
|
grep -q 'v1 Pod is deprecated' "$tmp/READ-WARNINGS.txt" || {
|
|
echo "FAIL: the first warning was dropped"; cat "$tmp/READ-WARNINGS.txt"; false
|
|
}
|
|
grep -q 'results are partial' "$tmp/READ-WARNINGS.txt" || {
|
|
echo "FAIL: the second warning was dropped"; cat "$tmp/READ-WARNINGS.txt"; false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a partial container log keeps every line kubectl said, not just the last" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" COZYREPORT_BOUND="" STUB_LOG_MODE=partial_rc1_multi \
|
|
cozyreport_read_container_log "$tmp/logs-app.txt" current ns pod-1 app
|
|
|
|
# A PARTIAL read is where kubectl has the most to say -- the reset, the refusal
|
|
# that stopped it mid-stream -- and it was the one case that kept only the last
|
|
# line, inside the marker, dropping every other line. The sibling capture in
|
|
# hack/e2e-capture-previous-logs.sh already copies stderr whole regardless of
|
|
# how the read ended; two collectors in one tarball answering the same question
|
|
# two ways is worse than either answer on its own.
|
|
grep -q 'results are partial' "$tmp/READ-WARNINGS.txt" || {
|
|
echo "FAIL: a partial read dropped every stderr line but the last"
|
|
[ -f "$tmp/READ-WARNINGS.txt" ] && cat "$tmp/READ-WARNINGS.txt" || echo "(no READ-WARNINGS.txt at all)"
|
|
false
|
|
}
|
|
grep -q 'unexpected EOF' "$tmp/READ-WARNINGS.txt"
|
|
# The exit status is named, so the file does not read as a clean read that
|
|
# merely carried a warning.
|
|
grep -q 'exited 1 part way through' "$tmp/READ-WARNINGS.txt"
|
|
# The log itself stays machine-readable: the warnings live beside it, and the
|
|
# partial log keeps its own TRUNCATED marker.
|
|
if grep -q 'results are partial' "$tmp/logs-app.txt"; then
|
|
echo "FAIL: a warning line landed inside the log a parser reads"
|
|
cat "$tmp/logs-app.txt"
|
|
false
|
|
fi
|
|
grep -q 'the decisive line' "$tmp/logs-app.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a clean read that returned no object keeps the warning out of it" {
|
|
tmp=$(mktemp -d)
|
|
object_stub "$tmp/fakectl"
|
|
|
|
COZYREPORT_BOUND="" STUB_OUT="" STUB_ERR="Warning: some deprecation notice" STUB_RC=0 \
|
|
cozyreport_read_object "$tmp/pod.yaml" "$tmp/fakectl"
|
|
|
|
# rc 0, no object, and kubectl still spoke. Copying stderr into the file on the
|
|
# strength of "no stdout" alone writes an unprefixed `Warning:` line where an
|
|
# object belongs -- unparseable, and unexplained, because the emptiness checks
|
|
# further down see a non-empty file and stay quiet. The refusal path is the one
|
|
# that earns a copy into the file; a warning on a successful read is not it.
|
|
if grep -q 'deprecation notice' "$tmp/pod.yaml" 2>/dev/null; then
|
|
echo "FAIL: a warning landed inside the object, with no marker to explain it"
|
|
cat "$tmp/pod.yaml"
|
|
false
|
|
fi
|
|
grep -q 'deprecation notice' "$tmp/READ-WARNINGS.txt" || {
|
|
echo "FAIL: the warning was dropped instead of kept beside the object"
|
|
[ -f "$tmp/READ-WARNINGS.txt" ] && cat "$tmp/READ-WARNINGS.txt" || echo "(no READ-WARNINGS.txt)"
|
|
false
|
|
}
|
|
grep -q 'returned no object' "$tmp/READ-WARNINGS.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a cut-off container read is not attributed to kubectl in the warnings file" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" COZYREPORT_BOUND="" STUB_LOG_MODE=partial_rc1_multi_124 \
|
|
cozyreport_read_container_log "$tmp/logs-app.txt" current ns pod-1 app
|
|
|
|
# 124 is this script's own clock, not a decision kubectl made. The TRUNCATED
|
|
# marker inside the log names the timeout; the warnings file beside it described
|
|
# the same read as "kubectl exited 124 part way through". Two files in one pod
|
|
# directory, one read, two mechanisms -- and only one of them was observed.
|
|
grep -q 'cut off by' "$tmp/READ-WARNINGS.txt" || {
|
|
echo "FAIL: a timeout is reported as a kubectl exit in the warnings file"
|
|
cat "$tmp/READ-WARNINGS.txt"
|
|
false
|
|
}
|
|
if grep -q 'kubectl exited 124' "$tmp/READ-WARNINGS.txt"; then
|
|
echo "FAIL: the warnings file contradicts the marker inside the log"
|
|
cat "$tmp/READ-WARNINGS.txt"
|
|
false
|
|
fi
|
|
grep -q 'cut off by' "$tmp/logs-app.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a clean container read with no log keeps the warning beside it, not in it" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" COZYREPORT_BOUND="" STUB_LOG_MODE=warned_empty \
|
|
cozyreport_read_container_log "$tmp/logs-app.txt" current ns pod-1 app
|
|
|
|
# Same shape one function down. An unprefixed kubectl line inside a container log
|
|
# is indistinguishable from a line the container wrote, which is precisely the
|
|
# fabricated cluster fact this tree exists to prevent.
|
|
if grep -q 'results are partial' "$tmp/logs-app.txt"; then
|
|
echo "FAIL: a warning landed inside the container log"
|
|
cat "$tmp/logs-app.txt"
|
|
false
|
|
fi
|
|
grep -q 'results are partial' "$tmp/READ-WARNINGS.txt" || {
|
|
echo "FAIL: the warning was dropped"
|
|
[ -f "$tmp/READ-WARNINGS.txt" ] && cat "$tmp/READ-WARNINGS.txt" || echo "(none)"
|
|
false
|
|
}
|
|
# The WORDING, not just the routing. "returned this log" about a read that
|
|
# returned none contradicts the placeholder written into the log file for the
|
|
# same read, and this is the file a triager opens first because it names the
|
|
# other one. The object reader's twin above asserts its own third-case wording
|
|
# for exactly this reason.
|
|
grep -q 'returned no log, exited 0' "$tmp/READ-WARNINGS.txt" || {
|
|
echo "FAIL: a read that returned nothing is described as having returned the log"
|
|
cat "$tmp/READ-WARNINGS.txt"
|
|
false
|
|
}
|
|
# The placeholder must not claim kubectl exited "without error" when it spoke.
|
|
if grep -q 'without output or error' "$tmp/logs-app.txt"; then
|
|
echo "FAIL: claimed a silent exit in the same run that recorded a message"
|
|
cat "$tmp/logs-app.txt"
|
|
false
|
|
fi
|
|
grep -q 'READ-WARNINGS.txt' "$tmp/logs-app.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a read with nothing on stderr leaves no warnings file at all" {
|
|
tmp=$(mktemp -d)
|
|
object_stub "$tmp/fakectl"
|
|
|
|
COZYREPORT_BOUND="" STUB_OUT="apiVersion: v1" STUB_ERR="" STUB_RC=0 \
|
|
cozyreport_read_object "$tmp/pod.yaml" "$tmp/fakectl"
|
|
|
|
# The converse: an empty warnings file beside every clean read would train the
|
|
# reader to ignore the name, which costs exactly as much as not writing it.
|
|
if [ -f "$tmp/READ-WARNINGS.txt" ]; then
|
|
echo "FAIL: a clean read left a warnings file"
|
|
cat "$tmp/READ-WARNINGS.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a truncation marker on an object file does not cost the parser the object" {
|
|
tmp=$(mktemp -d)
|
|
object_stub "$tmp/fakectl"
|
|
|
|
# kubectl can finish writing a whole object and still exit non-zero. The marker
|
|
# then lands after a complete mapping, and a BARE line there is a second
|
|
# top-level node, not a note: the file stops parsing. That is the same defect
|
|
# diverting stderr was meant to remove, reintroduced by the fix for it -- so the
|
|
# marker is written as a YAML comment. It stays visible to a person and invisible
|
|
# to yq, which is what reads these three files.
|
|
# Held in a variable rather than only in the call's environment: the comparison
|
|
# below needs the same bytes, and an env prefix on a function call does not
|
|
# outlive it.
|
|
obj='apiVersion: v1
|
|
kind: Pod
|
|
metadata:
|
|
name: broken-0'
|
|
|
|
COZYREPORT_BOUND="" \
|
|
STUB_OUT="$obj" \
|
|
STUB_ERR='error: unexpected EOF' \
|
|
STUB_RC=1 \
|
|
cozyreport_read_object "$tmp/pod.yaml" "$tmp/fakectl"
|
|
|
|
grep -q '^# \[cozyreport\] TRUNCATED' "$tmp/pod.yaml"
|
|
|
|
# The property, stated without a parser: strip the comment lines and what is
|
|
# left must be byte-for-byte the object kubectl wrote. That IS the proof, not a
|
|
# weaker stand-in for one: the fixture is a valid YAML document, comments are
|
|
# the one construct YAML defines as ignorable, and a file differing from a valid
|
|
# document only by added comment lines is therefore valid too. Any change to the
|
|
# body -- including a marker glued onto a partial final line -- breaks the
|
|
# comparison. A bare marker survives
|
|
# that strip and the comparison fails, which is exactly the regression. Written
|
|
# this way rather than by loading the file, because a check that runs only when
|
|
# the host happens to ship a YAML parser is not a check on the hosts that do
|
|
# not -- it silently degrades to "the line starts with a hash", which is the
|
|
# weaker half and passes even when the object is unloadable.
|
|
printf '%s\n' "$obj" > "$tmp/expected.yaml"
|
|
grep -v '^#' "$tmp/pod.yaml" > "$tmp/stripped.yaml"
|
|
if ! cmp -s "$tmp/expected.yaml" "$tmp/stripped.yaml"; then
|
|
echo "FAIL: the marker changed what a parser would see"
|
|
echo "--- expected ---"; cat "$tmp/expected.yaml"
|
|
echo "--- actual, comments stripped ---"; cat "$tmp/stripped.yaml"
|
|
false
|
|
fi
|
|
|
|
# And when a parser is available, load it for real. This is the belt to the
|
|
# braces above, not the assertion the test rests on.
|
|
if command -v yq >/dev/null 2>&1; then
|
|
yq --exit-status '.metadata.name == "broken-0"' "$tmp/pod.yaml" >/dev/null 2>&1 || {
|
|
echo "FAIL: the marked object no longer parses"
|
|
cat "$tmp/pod.yaml"
|
|
false
|
|
}
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# cozyreport_collect_pod -- everything the report keeps about one broken pod. #
|
|
# Driven against a stub kubectl (and a stub `timeout` that records its argv), so #
|
|
# the artifact these tests assert on is the one a reader of a failed run gets. #
|
|
# The placeholder text is the ONLY evidence for a pod that logged nothing, and #
|
|
# the --tail/timeout bounds are what stop a widened pod selection from costing #
|
|
# the whole report, so both are pinned here rather than left to review. #
|
|
# #
|
|
# cozytest.sh ends an @test at the first bare `}`, so the stub builder lives at #
|
|
# top level rather than nested inside a test. #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
collect_stub_dir() {
|
|
_sd="$1"
|
|
cat > "$_sd/kubectl" <<'STUB'
|
|
#!/bin/sh
|
|
for a in "$@"; do
|
|
case "$a" in
|
|
jsonpath*)
|
|
case "${STUB_JSONPATH_FAIL:-}" in
|
|
# Killed by its wrapper before writing: no stderr, exit 124.
|
|
timeout) exit 124 ;;
|
|
# Refused without saying why: not a timeout, and nothing to quote.
|
|
silent) exit 1 ;;
|
|
# Named some containers and then failed: the list is a prefix of the
|
|
# pod's containers, not the pod's containers.
|
|
partial)
|
|
printf 'app'
|
|
echo 'error: unexpected EOF' >&2
|
|
exit 1 ;;
|
|
'') ;;
|
|
noisy)
|
|
# Discovery is cold, so kubectl retries the API group list and prefixes
|
|
# the reason with one klog line per attempt. A single-pod read goes
|
|
# through the same discovery path as the cluster-wide one.
|
|
i=1
|
|
while [ "$i" -le 3 ]; do
|
|
echo 'E0728 11:51:48.306797 82827 memcache.go:265] "Unhandled Error" err="couldn'"'"'t get current server API group list: Get \"https://192.0.2.1:6443/api?timeout=32s\": dial tcp: lookup timed out after some considerable number of characters"' >&2
|
|
i=$((i + 1))
|
|
done
|
|
echo 'Error from server (NotFound): pods "gone-0" not found' >&2
|
|
exit 1 ;;
|
|
*)
|
|
echo 'Error from server (NotFound): pods "gone-0" not found' >&2
|
|
exit 1 ;;
|
|
esac
|
|
printf '%s' "${STUB_CONTAINERS-app}"
|
|
exit 0 ;;
|
|
esac
|
|
done
|
|
case "$*" in
|
|
*"get pod -A --no-headers"*)
|
|
case "${STUB_PODLIST_FAIL:-}" in
|
|
# Killed by its own wrapper before it could write: no stderr, exit 124.
|
|
timeout) exit 124 ;;
|
|
# Refused with nothing on stderr: the case that must NOT be described in
|
|
# terms of the read timeout.
|
|
silent) exit 1 ;;
|
|
# Named some pods and then died: a partial list, not an absent one.
|
|
partial)
|
|
printf '%s\n' "${STUB_POD_ROWS:-}"
|
|
echo 'error: unexpected EOF' >&2
|
|
exit 1 ;;
|
|
'') ;;
|
|
# A failing cluster-wide list is preceded by one klog discovery-retry line
|
|
# per attempt, each long enough to survive trimming and bury the cause.
|
|
*)
|
|
i=1
|
|
while [ "$i" -le 3 ]; do
|
|
echo 'E0728 11:51:48.306797 82827 memcache.go:265] "Unhandled Error" err="couldn'"'"'t get current server API group list: Get \"https://192.0.2.1:6443/api?timeout=32s\": dial tcp: lookup timed out after some considerable number of characters"' >&2
|
|
i=$((i + 1))
|
|
done
|
|
echo 'Error from server (Forbidden): pods is forbidden' >&2
|
|
exit 1 ;;
|
|
esac
|
|
printf '%s\n' "${STUB_POD_ROWS:-}"
|
|
exit 0 ;;
|
|
esac
|
|
case "$1" in
|
|
logs)
|
|
printf '%s\n' "$*" >> "$STUB_ARGV"
|
|
case "${STUB_LOG_MODE:-text}" in
|
|
empty) exit 0 ;;
|
|
error) echo 'Error from server (BadRequest): container "app" is waiting to start: ImagePullBackOff' >&2; exit 1 ;;
|
|
# Exits 0, writes NO log, and still warns. The combination none of the other
|
|
# modes produce, and the one all three had_stdout branches got wrong.
|
|
warned_empty) echo 'Warning: results are partial, 3 of 7 shards answered' >&2; exit 0 ;;
|
|
# Partial stream, a warning, and OUR OWN clock firing: the combination where
|
|
# the status must not be attributed to kubectl.
|
|
partial_rc1_multi_124)
|
|
printf 'startup line\nthe decisive line\n'
|
|
echo 'Warning: results are partial, 3 of 7 shards answered' >&2
|
|
exit 124 ;;
|
|
# Slow enough for the collection budget to run out mid-pod.
|
|
slow) sleep "${STUB_LOG_SLEEP:-2}"; printf 'the decisive line\n' ;;
|
|
# Killed by its wrapper: exit 124, nothing written.
|
|
killed) exit 124 ;;
|
|
# Exits non-zero having written neither a log nor a word about why. Distinct
|
|
# from `error`, which writes a reason, and from `empty`, which exits 0.
|
|
silent) exit 1 ;;
|
|
# Ignored SIGTERM and got SIGKILL from `timeout -k`: 137, nothing written.
|
|
killed9) exit 137 ;;
|
|
# Killed after writing part of the stream: exit 124, output kept.
|
|
partial) printf 'the decisive line\n'; exit 124 ;;
|
|
# Part of the stream, then kubectl failing on its own terms -- a reset to
|
|
# the kubelet mid-read. No clock fired, so this must not be described as a
|
|
# timeout, but the file is still a partial log and not a refusal.
|
|
partial_rc1)
|
|
printf 'startup line\nthe decisive line\n'
|
|
echo 'error: unexpected EOF' >&2
|
|
exit 1 ;;
|
|
# The same partial read, but kubectl says more than one thing on the way
|
|
# out. Two lines, because "copied whole" and "reduced to the last line" are
|
|
# indistinguishable when there is only one.
|
|
partial_rc1_multi)
|
|
printf 'startup line\nthe decisive line\n'
|
|
echo 'Warning: results are partial, 3 of 7 shards answered' >&2
|
|
echo 'error: unexpected EOF' >&2
|
|
exit 1 ;;
|
|
# A refusal with no stdout at all. The commonest shape in the tree: the
|
|
# --previous read of a container that never restarted.
|
|
norestart)
|
|
echo 'Error from server (BadRequest): previous terminated container "app" in pod "db-2" not found' >&2
|
|
exit 1 ;;
|
|
# Our timeout firing against cold discovery: klog retries on stderr, no
|
|
# stdout, exit 124.
|
|
timeout_noisy)
|
|
i=1
|
|
while [ "$i" -le 3 ]; do
|
|
echo 'E0728 11:51:48.306797 82827 memcache.go:265] "Unhandled Error" err="couldn'"'"'t get current server API group list: dial tcp: lookup timed out"' >&2
|
|
i=$((i + 1))
|
|
done
|
|
exit 124 ;;
|
|
# Killed mid-line, which is the normal shape of a SIGKILLed stream: the last
|
|
# line has no terminating newline.
|
|
partial_noeol) printf 'first line\nthe decisive partial line'; exit 124 ;;
|
|
# Same, but via the kill-after path.
|
|
partial9) printf 'the decisive line\n'; exit 137 ;;
|
|
*) printf 'the decisive line\n' ;;
|
|
esac ;;
|
|
# STUB_OBJ_MODE drives the two whole-object reads. `partial` is a read killed
|
|
# after writing a plausible prefix: describe.txt that stops before its Events
|
|
# section reads exactly like a pod that had no events, and pod.yaml that stops
|
|
# after metadata reads like an object with no status.
|
|
describe)
|
|
case "${STUB_OBJ_MODE:-}" in
|
|
partial) printf 'Name: pod-1\nStatus: Running\n'; exit 124 ;;
|
|
partial9) printf 'Name: pod-1\nStatus: Running\n'; exit 137 ;;
|
|
*) printf 'Events:\n Warning Unhealthy readiness probe failed\n' ;;
|
|
esac ;;
|
|
get)
|
|
case "${STUB_OBJ_MODE:-}" in
|
|
partial) printf 'apiVersion: v1\nkind: Pod\nmetadata:\n name: pod-1\n'; exit 124 ;;
|
|
# Same cut, delivered as SIGKILL rather than as an expired deadline.
|
|
partial9) printf 'apiVersion: v1\nkind: Pod\nmetadata:\n name: pod-1\n'; exit 137 ;;
|
|
# Cut off before the first byte: the file never starts.
|
|
killed) exit 124 ;;
|
|
*) printf 'apiVersion: v1\nkind: Pod\n' ;;
|
|
esac ;;
|
|
esac
|
|
exit 0
|
|
STUB
|
|
chmod +x "$_sd/kubectl"
|
|
# Records that the read really went through `timeout`, then runs the rest.
|
|
# `-k <grace>` is skipped so the recorded line stays the effective duration:
|
|
# the assertions are about the budget, not about how the kill is delivered.
|
|
cat > "$_sd/timeout" <<'STUB'
|
|
#!/bin/sh
|
|
if [ "$1" = "-k" ]; then
|
|
printf 'kill-after %s\n' "$2" >> "$STUB_ARGV"
|
|
shift 2
|
|
fi
|
|
printf 'timeout %s\n' "$1" >> "$STUB_ARGV"
|
|
shift
|
|
exec "$@"
|
|
STUB
|
|
chmod +x "$_sd/timeout"
|
|
# And point the bound at that stub. COZYREPORT_BOUND is resolved once, when this
|
|
# file sources cozyreport.sh, which happens before any test has a stub PATH -- so
|
|
# on a host with no `timeout` of its own it is empty here, and every assertion
|
|
# about a bounded read fails for a reason that has nothing to do with the code
|
|
# under test. Eight tests did exactly that. A suite whose verdict depends on what
|
|
# the host happens to have installed is not a suite; the same defect was fixed a
|
|
# commit earlier for sha256sum, and this is its other half.
|
|
COZYREPORT_BOUND="timeout -k 5 $COZYREPORT_READ_TIMEOUT"
|
|
}
|
|
|
|
@test "collect pod writes a placeholder when kubectl returns no output at all" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=empty cozyreport_collect_pod ns unready-0 "$tmp/out"
|
|
|
|
# A zero-byte log file is indistinguishable from a container that started and
|
|
# stayed quiet, which is the ambiguity this collector exists to remove.
|
|
[ -s "$tmp/out/logs-app.txt" ]
|
|
grep -q 'exited without output or error' "$tmp/out/logs-app.txt"
|
|
grep -q 'exited without output or error' "$tmp/out/logs-app-previous.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod does not report a timed out log read as a clean empty one" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=killed cozyreport_collect_pod ns hung-0 "$tmp/out"
|
|
|
|
# The read was killed by its own wrapper. "kubectl exited without output or
|
|
# error: it never started, or it started and logged nothing" is a claim about
|
|
# the cluster that nothing observed, on the likeliest path there is: a degraded
|
|
# apiserver is the state a failed run is in and the reason the timeout exists.
|
|
grep -q 'cut off by its own 30s timeout' "$tmp/out/logs-app.txt"
|
|
if grep -q 'exited without output or error' "$tmp/out/logs-app.txt"; then echo "FAIL: a killed read reported as a clean empty one"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod treats a kill after timeout the same as a plain one" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=killed9 cozyreport_collect_pod ns hung-0 "$tmp/out"
|
|
|
|
# `timeout -k` reports 137, not 124, when the command ignored SIGTERM and had to
|
|
# be killed. Recognising only 124 sends the more stubborn of the two cases into
|
|
# the branch that describes a clean exit.
|
|
grep -q 'cut off by' "$tmp/out/logs-app.txt"
|
|
if grep -q 'exited without output or error' "$tmp/out/logs-app.txt"; then echo "FAIL: a killed read reported as a clean empty one"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a kill is not asserted to be this script's own deadline firing" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=killed9 cozyreport_collect_pod ns hung-0 "$tmp/out"
|
|
|
|
# 124 comes from `timeout` and from nothing else. 137 is 128+SIGKILL, which our
|
|
# own `-k` grace produces and so does anything else that kills the read -- the
|
|
# OOM killer on a loaded runner, a teardown signalling the process group. The
|
|
# cut-off branch is right for both, since either way the file ends before the
|
|
# log did; the attribution is not. Reported flatly as "its own 30s timeout", a
|
|
# read killed at second two reads as one that waited the full thirty, and the
|
|
# triager goes looking at apiserver latency.
|
|
grep -q 'SIGKILL' "$tmp/out/logs-app.txt"
|
|
grep -q 'does not tell apart' "$tmp/out/logs-app.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a deadline that plainly expired is stated without a disjunction" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=killed cozyreport_collect_pod ns hung-0 "$tmp/out"
|
|
|
|
# The converse, and the reason the hedge is spelled per status rather than
|
|
# applied to both: 124 IS unambiguous, and hedging it too would trade one false
|
|
# note for a uselessly vague one everywhere.
|
|
grep -q "cut off by its own ${COZYREPORT_READ_TIMEOUT}s timeout" "$tmp/out/logs-app.txt"
|
|
if grep -q 'SIGKILL' "$tmp/out/logs-app.txt"; then
|
|
echo "FAIL: hedged a 124, which only timeout produces"
|
|
cat "$tmp/out/logs-app.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod marks a partial log cut short by the kill after bound" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=partial9 cozyreport_collect_pod ns hung-0 "$tmp/out"
|
|
|
|
# Unmarked, this file reads as a complete log that simply ends there.
|
|
grep -q 'the decisive line' "$tmp/out/logs-app.txt"
|
|
grep -q 'TRUNCATED' "$tmp/out/logs-app.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod does not name a read timeout it never applied" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
# No bound was applied, so a 137 came from something else -- runner teardown, an
|
|
# OOM kill. Quoting "its own 30s timeout" there states a mechanism that was not
|
|
# in play, in the file whose job is to be believed.
|
|
PATH="$tmp:$PATH" COZYREPORT_BOUND="" STUB_LOG_MODE=killed9 \
|
|
cozyreport_collect_pod ns hung-0 "$tmp/out"
|
|
|
|
if grep -q '30s timeout' "$tmp/out/logs-app.txt"; then
|
|
echo "FAIL: named a 30s timeout on a read that had no bound"
|
|
cat "$tmp/out/logs-app.txt"
|
|
false
|
|
fi
|
|
grep -q 'unbounded' "$tmp/out/logs-app.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod starts its marker on a line of its own after a killed stream" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=partial_noeol cozyreport_collect_pod ns hung-0 "$tmp/out"
|
|
|
|
# A killed read is SIGKILLed mid-stream, so an unterminated last line is the
|
|
# normal shape here. Appending blind glues the marker onto it: the obvious grep
|
|
# for the marker misses it, the decisive last line is corrupted, and in reverse a
|
|
# container whose own text ends mid-sentence looks like it emitted the marker.
|
|
grep -q '^\[cozyreport\] TRUNCATED' "$tmp/out/logs-app.txt"
|
|
grep -q '^the decisive partial line$' "$tmp/out/logs-app.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod keeps a partial log and marks it truncated" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=partial cozyreport_collect_pod ns hung-0 "$tmp/out"
|
|
|
|
# Whatever landed before the cut is still the evidence, and for a previous
|
|
# instance it is the only copy of something already gone from the cluster. Kept,
|
|
# and marked -- unmarked, the reader believes they hold the whole tail.
|
|
grep -q 'the decisive line' "$tmp/out/logs-app.txt"
|
|
grep -q '^\[cozyreport\] TRUNCATED' "$tmp/out/logs-app.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod distinguishes the two instances when both came back empty" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=empty cozyreport_collect_pod ns unready-0 "$tmp/out"
|
|
|
|
# A clean exit with no bytes is ambiguous, and the readings differ per instance:
|
|
# for the previous one, the first reading is that there was never a previous one.
|
|
grep -q 'it never started, or it started and logged nothing' "$tmp/out/logs-app.txt"
|
|
grep -q 'it never restarted, or the previous instance logged nothing' "$tmp/out/logs-app-previous.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod keeps the whole reason when the container list read is noisy" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_JSONPATH_FAIL=noisy cozyreport_collect_pod ns gone-0 "$tmp/out"
|
|
|
|
# This is a file of its own, not a log line with neighbours to bury, so there is
|
|
# nothing to gain by picking one line -- and picking is where it goes wrong: a
|
|
# cold discovery cache, which a multi-hour failed run produces, puts a klog retry
|
|
# line in front of the reason and each is long enough to survive trimming.
|
|
grep -q 'pods "gone-0" not found' "$tmp/out/logs-UNAVAILABLE.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod names the timeout when the container list read was killed" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_JSONPATH_FAIL=timeout cozyreport_collect_pod ns hung-0 "$tmp/out"
|
|
|
|
# A killed read writes no stderr, so quoting it yields a bare "kubectl said:"
|
|
# that names nothing. Same guard the pod-list path carries.
|
|
grep -q 'cut off by its own 30s timeout' "$tmp/out/logs-UNAVAILABLE.txt"
|
|
if grep -q 'kubectl said: *$' "$tmp/out/logs-UNAVAILABLE.txt"; then echo "FAIL: quoted an empty reason"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod says which tail bound each log was written under" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" cozyreport_collect_pod ns chatty-0 "$tmp/out"
|
|
|
|
# `--tail` drops the OLDEST lines, so a bounded read starts mid-stream.
|
|
# A file that now begins mid-stream looks exactly like a complete one, and for a
|
|
# pod that never came up the startup lines are the decisive part.
|
|
grep -q 'at most the last 2000 lines' "$tmp/out/CONTAINER-LOGS-BOUND.txt"
|
|
# One note per directory covers both instances: the bound is the same for the
|
|
# current log and the previous one, and repeating it in each file was what put
|
|
# prose into a machine-readable artifact in the first place.
|
|
[ "$(grep -c 'at most the last 2000 lines' "$tmp/out/CONTAINER-LOGS-BOUND.txt")" -eq 1 ]
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod claims no tail bound when the complete log was requested" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" COZY_REPORT_POD_TAIL=-1 cozyreport_collect_pod ns chatty-0 "$tmp/out"
|
|
|
|
grep -q -- '--tail=-1' "$tmp/argv"
|
|
if grep -q 'at most the last' "$tmp/out/logs-app.txt"; then echo "FAIL: claimed a bound that was not applied"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod refuses a zero tail that would empty every log" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" COZY_REPORT_POD_TAIL=0 cozyreport_collect_pod ns chatty-0 "$tmp/out"
|
|
|
|
# `--tail=0` returns no lines, which this script would then describe as a
|
|
# container that never started or stayed quiet: a statement about the cluster
|
|
# produced entirely by a typo.
|
|
grep -q -- '--tail=2000' "$tmp/argv"
|
|
if grep -q -- '--tail=0' "$tmp/argv"; then echo "FAIL: asked kubectl for nothing"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a log cut short by kubectl itself is marked, without blaming the timeout" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=partial_rc1 cozyreport_collect_pod ns reset-0 "$tmp/out"
|
|
|
|
# Three outcomes exist, not two: our timeout fired, kubectl failed on its own,
|
|
# or the read completed. Only the first and third were handled, so a log cut
|
|
# short by a connection reset carried no marker at all -- while the notes in the
|
|
# same tarball state that a read cut short says so on its last line.
|
|
file="$tmp/out/logs-app.txt"
|
|
grep -q 'the decisive line' "$file"
|
|
if ! grep -q '^\[cozyreport\] TRUNCATED' "$file"; then
|
|
echo "FAIL: a partial log left by a non-timeout failure carries no marker"
|
|
cat "$file"
|
|
false
|
|
fi
|
|
# It must not name our timeout, because no timeout fired.
|
|
if grep -qE 'cut off by its own|timeout' "$file"; then
|
|
echo "FAIL: blamed the read timeout for a failure that was not one"
|
|
cat "$file"
|
|
false
|
|
fi
|
|
# kubectl's own reason survives into the file.
|
|
grep -q 'unexpected EOF' "$file"
|
|
# And it is not described as a clean tail window.
|
|
if grep -q 'at most the last' "$file"; then
|
|
echo "FAIL: a truncated log claims to be a complete tail window"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod keeps the kubectl refusal instead of overwriting it" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=error cozyreport_collect_pod ns puller-0 "$tmp/out"
|
|
|
|
# kubectl's reason IS the finding; the placeholder must not replace it.
|
|
grep -q 'waiting to start: ImagePullBackOff' "$tmp/out/logs-app.txt"
|
|
if grep -q 'exited without output' "$tmp/out/logs-app.txt"; then echo "FAIL: placeholder overwrote kubectl's reason"; false; fi
|
|
# This file holds a refusal, not a log, so a line about how many lines of log it
|
|
# holds would describe something that is not in it.
|
|
if grep -q 'at most the last' "$tmp/out/logs-app.txt"; then echo "FAIL: described a refusal as a truncated log"; false; fi
|
|
# Nor was anything cut off: nothing was ever produced to cut. This assertion is
|
|
# the one that was missing -- the test drove this path already and checked only
|
|
# the tail note, so a refusal wrongly marked TRUNCATED passed here unnoticed.
|
|
if grep -q 'TRUNCATED' "$tmp/out/logs-app.txt"; then
|
|
echo "FAIL: a refusal is reported as a log that was cut off part way through"
|
|
cat "$tmp/out/logs-app.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "an uncapturable stderr is not reported as kubectl having said nothing" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
# TMPDIR at a path that does not exist, so mktemp fails and the stderr scratch
|
|
# file is never created. The read still runs -- that fallback is deliberate --
|
|
# but nothing captured what kubectl said. "Said nothing" is then a claim about
|
|
# the cluster manufactured by a local filesystem condition, which is the defect
|
|
# this collector is written against.
|
|
PATH="$tmp:$PATH" TMPDIR=/nonexistent-cozyreport-probe STUB_LOG_MODE=error \
|
|
cozyreport_collect_pod ns puller-0 "$tmp/out"
|
|
|
|
f="$tmp/out/logs-app.txt"
|
|
if grep -q 'without a message' "$f"; then
|
|
echo "FAIL: an uncapturable message is reported as kubectl having been silent"
|
|
cat "$f"
|
|
false
|
|
fi
|
|
grep -q 'could not be captured' "$f"
|
|
grep -q 'unknown' "$f"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a killed pod list names the cutoff even when its stderr could not be captured" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
# Both conditions at once: the read is killed at 124 AND mktemp has nowhere to
|
|
# write the scratch file. Each is modelled separately elsewhere in this suite;
|
|
# they were never combined, and that overlap is the only place the branch order
|
|
# is observable. With the no-temp-dir branch first this file said the message
|
|
# could not be captured, while the two sibling sites -- given the same pair --
|
|
# named the cutoff. One tarball, two answers, about a mechanism the script had
|
|
# in hand: a read cut off at 124 is killed before kubectl writes anything, so an
|
|
# empty stderr there is a consequence of the timeout, not a separate fact.
|
|
PATH="$tmp:$PATH" TMPDIR=/nonexistent-cozyreport-probe STUB_PODLIST_FAIL=timeout \
|
|
cozyreport_collect_broken_pods "$tmp/out"
|
|
|
|
f="$tmp/out/COLLECTION-FAILED.txt"
|
|
grep -q 'the read was cut off by' "$f"
|
|
if grep -q 'could not be captured' "$f"; then
|
|
echo "FAIL: led with the missing temp dir for a read the script knew was killed"
|
|
cat "$f"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "the per-pod collector answers that pair the same way" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
# The mirror of the test above, so the two cannot drift apart from the other
|
|
# side either. Same pair of conditions, same expected leading cause.
|
|
PATH="$tmp:$PATH" TMPDIR=/nonexistent-cozyreport-probe STUB_JSONPATH_FAIL=timeout \
|
|
cozyreport_collect_pod ns slow-0 "$tmp/out"
|
|
|
|
f="$tmp/out/logs-UNAVAILABLE.txt"
|
|
[ -f "$f" ] || { echo "FAIL: no container-list failure note written"; ls "$tmp/out"; false; }
|
|
grep -q 'cut off by' "$f"
|
|
if grep -q 'could not be captured' "$f"; then
|
|
echo "FAIL: the two sites answer the same pair differently"
|
|
cat "$f"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a pod list whose stderr could not be captured says so, not that it was silent" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" TMPDIR=/nonexistent-cozyreport-probe STUB_PODLIST_FAIL=1 \
|
|
cozyreport_collect_broken_pods "$tmp/out"
|
|
|
|
f="$tmp/out/COLLECTION-FAILED.txt"
|
|
if grep -q 'kubectl said nothing' "$f"; then
|
|
echo "FAIL: claimed silence when the message was never captured"
|
|
cat "$f"
|
|
false
|
|
fi
|
|
grep -q 'could not be captured' "$f"
|
|
# The section still refuses to read as a healthy cluster.
|
|
grep -q 'NOT a report that every pod was Ready' "$f"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a previous-instance refusal is not dressed up as a truncated log" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
# The commonest file in the whole pod tree: --previous against a container that
|
|
# simply never restarted. kubectl refuses, stdout is empty, and the refusal IS
|
|
# the finding. Marking it "cut off part way through" is false on one file per
|
|
# container of every collected pod, which retires the marker as a signal.
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=norestart cozyreport_collect_pod ns db-2 "$tmp/out"
|
|
|
|
file="$tmp/out/logs-app-previous.txt"
|
|
grep -q 'previous terminated container' "$file"
|
|
if grep -q 'TRUNCATED' "$file"; then
|
|
echo "FAIL: a container that never restarted is reported as a truncated log"
|
|
cat "$file"
|
|
false
|
|
fi
|
|
if grep -q 'at most the last' "$file"; then
|
|
echo "FAIL: described a refusal as a bounded log"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a timeout before kubectl wrote anything says so, and keeps the preamble" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
# Our timeout fires with cold discovery: exit 124, no stdout, klog retry lines on
|
|
# stderr. The accurate message for this case must survive, and the preamble that
|
|
# explains the apiserver state must not be overwritten by it.
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=timeout_noisy cozyreport_collect_pod ns slow-0 "$tmp/out"
|
|
|
|
file="$tmp/out/logs-app.txt"
|
|
grep -q 'before kubectl wrote anything' "$file"
|
|
grep -q "couldn't get current server API group list" "$file"
|
|
# It must not claim the container stopped writing: nothing was read at all.
|
|
if grep -q 'not because the container stopped writing' "$file"; then
|
|
echo "FAIL: claimed a log ended early when none was produced"
|
|
cat "$file"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod names why the container list was unavailable" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_JSONPATH_FAIL=1 cozyreport_collect_pod ns gone-0 "$tmp/out"
|
|
|
|
# Without this the pod directory carries a yaml and a describe and no hint
|
|
# that per-container logs were ever attempted.
|
|
[ -f "$tmp/out/logs-UNAVAILABLE.txt" ]
|
|
grep -q 'container list came back empty' "$tmp/out/logs-UNAVAILABLE.txt"
|
|
grep -q 'pods "gone-0" not found' "$tmp/out/logs-UNAVAILABLE.txt"
|
|
# The scratch stderr file must not ship in the artifact.
|
|
if [ -f "$tmp/out/.err" ]; then echo "FAIL: scratch stderr file left in the report tree"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod bounds every kubectl read with a wall clock timeout" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" cozyreport_collect_pod ns chatty-0 "$tmp/out"
|
|
|
|
# The tarball is written only at the end of the run, so anything hanging
|
|
# against a degraded apiserver costs the whole report rather than one file.
|
|
# All five reads for a single-container pod: get -o yaml, describe, the
|
|
# container list, and the current + previous log. Each carries the kill-after
|
|
# bound too: plain `timeout` sends TERM and then waits forever on a process that
|
|
# ignores it, which is the hang the budget exists to prevent.
|
|
[ "$(grep -c '^timeout 30$' "$tmp/argv")" -eq 5 ]
|
|
[ "$(grep -c '^kill-after 5$' "$tmp/argv")" -eq 5 ]
|
|
# Size is bounded too, on the two reads that can be unboundedly large. The
|
|
# widened selection reaches long-lived containers, which is exactly the class
|
|
# a readiness failure produces.
|
|
[ "$(grep -c -- '--tail=2000' "$tmp/argv")" -eq 2 ]
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod captures the current log of a container that never restarted" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" cozyreport_collect_pod ns unready-0 "$tmp/out"
|
|
|
|
grep -q 'the decisive line' "$tmp/out/logs-app.txt"
|
|
[ -f "$tmp/out/pod.yaml" ]
|
|
[ -f "$tmp/out/describe.txt" ]
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# hack/cozyreport-summary.sh, end to end against a stub kubectl. summary.txt is #
|
|
# the first thing a triager opens, so a row that misreports itself there costs #
|
|
# more than the same mistake buried in the per-pod tree. #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
summary_stub_dir() {
|
|
_ss="$1"
|
|
cat > "$_ss/kubectl" <<'STUB'
|
|
#!/bin/sh
|
|
# Only the pod list matters here; every other section is asked to be empty, and
|
|
# `get crd` fails so the CRD-gated sections skip.
|
|
# Every read killed by its own wrapper: the state a bounded read can reach and an
|
|
# unbounded one cannot, since an unbounded read can only hang.
|
|
[ -n "${STUB_ALL_TIMEOUT:-}" ] && exit 124
|
|
case "$*" in
|
|
*"get pod -A --no-headers"*)
|
|
case "${STUB_PODLIST_FAIL:-}" in
|
|
'') ;;
|
|
# Rows first, then a failure: a cluster-wide list can be answered in part
|
|
# and then cut off, which is not the same event as a refusal.
|
|
partial)
|
|
printf '%s\n' "${STUB_POD_ROWS:-}"
|
|
echo 'Error from server: unexpected EOF' >&2
|
|
exit 1 ;;
|
|
*) echo 'Error from server (Forbidden): pods is forbidden' >&2; exit 1 ;;
|
|
esac
|
|
printf '%s\n' "${STUB_POD_ROWS:-}" ;;
|
|
*"get crd"*)
|
|
# Three outcomes, not two: present, absent, and refused. The last exits
|
|
# non-zero exactly like the absent one and only kubectl's message separates
|
|
# them, which is the whole point of the case below.
|
|
[ -n "${STUB_CRD_FORBIDDEN:-}" ] && { echo 'Error from server (Forbidden): customresourcedefinitions.apiextensions.k8s.io is forbidden' >&2; exit 1; }
|
|
[ -n "${STUB_CRD_PRESENT:-}" ] && exit 0
|
|
# Exits non-zero having said nothing. Not a shortcut for "absent": kubectl is
|
|
# explicit about NotFound, so silence here is a fourth outcome and the one a
|
|
# caller must not file under absence.
|
|
[ -n "${STUB_CRD_SILENT:-}" ] && exit 1
|
|
# Absence as kubectl actually reports it. The stub used to exit non-zero in
|
|
# silence, which no real cluster does, and a fixture that cannot produce the
|
|
# message is a fixture that cannot tell absence from anything else.
|
|
echo 'Error from server (NotFound): customresourcedefinitions.apiextensions.k8s.io "'"$4"'" not found' >&2
|
|
exit 1 ;;
|
|
*"get hr -A"*)
|
|
# Succeeds AND writes to stderr. kubectl does this for deprecation notices and
|
|
# for partial-result warnings, so "wrote to stderr" and "failed" are not the
|
|
# same event and the reader must not conflate them.
|
|
[ -n "${STUB_HR_WARN:-}" ] && echo 'Warning: v1beta1 HelmRelease is deprecated in v2.1+, use v2' >&2
|
|
case "${STUB_HR_FAIL:-}" in
|
|
'') ;;
|
|
# Rows first, then a failure: the read was answered in part.
|
|
partial)
|
|
printf '%s\n' "${STUB_HR_ROWS:-}"
|
|
echo 'error: unexpected EOF' >&2
|
|
exit 1 ;;
|
|
# Two lines, and the actionable one LAST: a failing read is preceded by a
|
|
# klog discovery-retry line per attempt, so a helper that quotes the first
|
|
# line names the retry instead of the cause.
|
|
*) echo 'E0728 22:14:03.112233 1 memcache.go:265] couldn'"'"'t get current server API group list: the server is currently unable to handle the request' >&2
|
|
echo 'Error from server (Forbidden): helmreleases.helm.toolkit.fluxcd.io is forbidden' >&2
|
|
exit 1 ;;
|
|
esac
|
|
printf '%s\n' "${STUB_HR_ROWS:-}" ;;
|
|
# A real kubectl rejects a filter nested INSIDE another filter, which is what
|
|
# `{range .items[?(...conditions[?(...)]...)]}` is. Matched on that prefix rather
|
|
# than by counting `conditions[?(`, because several separate filters in one
|
|
# --output=custom-columns are legal and kubectl accepts them -- the node section
|
|
# below uses three.
|
|
*"range .items[?("*) echo 'error: error parsing jsonpath, unterminated filter' >&2; exit 1 ;;
|
|
*source.toolkit.fluxcd.io*) printf '%s\n' "${STUB_FLUX_ROWS:-}" ;;
|
|
*"get pvc -A"*) printf '%s\n' "${STUB_PVC_ROWS:-}" ;;
|
|
*"get pv "*|*"get pv"*)
|
|
printf '%s\n' "${STUB_PV_ROWS:-}" ;;
|
|
*"get certificates"*)
|
|
printf '%s\n' "${STUB_CERT_ROWS:-}" ;;
|
|
*"get events"*)
|
|
printf '%s\n' "${STUB_EVENT_ROWS:-}" ;;
|
|
*"get nodes"*) printf '%s\n' "${STUB_NODE_ROWS:-}" ;;
|
|
esac
|
|
exit 0
|
|
STUB
|
|
chmod +x "$_ss/kubectl"
|
|
}
|
|
|
|
@test "a leading-zero knob is refused rather than read as octal" {
|
|
# `$(( ))` reads a leading zero as octal; `[` reads the same string as decimal.
|
|
# So 010 is 8 to the deadline arithmetic and 10 to every comparison beside it,
|
|
# and 008 is not valid octal at all -- under dash, which is /bin/sh on the CI
|
|
# image, that aborts the script with "Illegal number: 008" at the deadline
|
|
# calculation, before the tarball is written. Losing the whole artifact to a
|
|
# typo in a knob is the one outcome every bound in this file exists to avoid.
|
|
for bad in 008 09 010 00; do
|
|
if cozyreport_is_count "$bad"; then
|
|
echo "FAIL: accepted '$bad', which arithmetic will read as octal or reject"
|
|
false
|
|
fi
|
|
done
|
|
# Plain zero still passes: a cap of zero is a supported request (collect nothing,
|
|
# and say so), and it is not ambiguous between the two bases.
|
|
cozyreport_is_count 0
|
|
cozyreport_is_count 40
|
|
cozyreport_is_count 600
|
|
}
|
|
|
|
@test "a leading-zero budget falls back to the default instead of killing the run" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
# Run through `/bin/sh`, unconditionally and by that name, because `/bin/sh` is
|
|
# what the collector is invoked as and what the claim is about. Selecting `dash`
|
|
# when the host happens to ship it and falling back to `sh` otherwise made the
|
|
# shell under test a property of the machine: the assertion still ran, but not
|
|
# necessarily against the semantics it names.
|
|
#
|
|
# What is proven here is shell-independent and that is the point: a value the
|
|
# validator rejects never reaches arithmetic, so nothing aborts and the walk
|
|
# completes. The stakes are not shell-independent, which is why the value is
|
|
# `008`: dash makes a rejected expansion fatal, so on the CI image this is the
|
|
# difference between a fallback and losing the tarball. `010` is worse still and
|
|
# not an error anywhere -- both shells read it as octal 8 while `[` reads the
|
|
# same string as decimal 10.
|
|
run_out=$(cd "$HACK_DIR" && PATH="$tmp:$PATH" STUB_POD_ROWS='ns p-0 0/1 Running 0 5m' \
|
|
/bin/sh -c 'COZYREPORT_LIB=1 . ./cozyreport.sh; COZY_REPORT_PODS_BUDGET=008 cozyreport_collect_broken_pods "$1"/out; echo "rc=$?"' _ "$tmp" 2>&1)
|
|
|
|
if printf '%s\n' "$run_out" | grep -q 'Illegal number'; then
|
|
echo "FAIL: a malformed knob aborted the collection instead of falling back"
|
|
printf '%s\n' "$run_out"
|
|
false
|
|
fi
|
|
printf '%s\n' "$run_out" | grep -q 'rc=0'
|
|
# And the fallback is not silent: the operator who set it is told it was ignored.
|
|
grep -rq 'COZY_REPORT_PODS_BUDGET' "$tmp/out/COLLECTION-NOTES.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "container logs name the container with -c, the documented form" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_CONTAINERS='app'
|
|
|
|
PATH="$tmp:$PATH" cozyreport_collect_pod ns pod-1 "$tmp/out"
|
|
|
|
# kubectl accepts `logs POD CONTAINER`, but documents `(POD|TYPE/NAME) [-c NAME]`.
|
|
# The undocumented form working today is not a promise about tomorrow.
|
|
if ! grep -q -- '-c app' "$tmp/argv"; then
|
|
echo "FAIL: container passed positionally instead of with -c"
|
|
cat "$tmp/argv"
|
|
false
|
|
fi
|
|
# Both instances, not just the current one.
|
|
[ "$(grep -c -- '-c app' "$tmp/argv")" -eq 2 ]
|
|
grep -q -- '--previous' "$tmp/argv"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "the docs name the file the tail bound actually lands in" {
|
|
# The note moved out of the logs and into a file beside them; the doc sentence
|
|
# and the script header stayed behind saying "inside the log itself". A reader
|
|
# told to look inside opens a file that looks complete, finds no marker, and
|
|
# concludes the log is whole -- which is the exact failure this collector is
|
|
# written against, produced by its own documentation.
|
|
doc="$HACK_DIR/../docs/agents/e2e-testing.md"
|
|
|
|
# A fixed window rather than "up to the next period": the destination filename
|
|
# contains one, so a sentence-terminated match stops inside the very word the
|
|
# assertion is looking for.
|
|
sentence=$(grep -o 'the tail bound each kept log was written under.\{0,160\}' "$doc" | head -1)
|
|
[ -n "$sentence" ] || { echo "FAIL: the doc no longer describes the tail bound"; false; }
|
|
case "$sentence" in
|
|
*"inside the log"*) echo "FAIL: the doc still says the bound is inside the log"; false ;;
|
|
esac
|
|
printf '%s\n' "$sentence" | grep -q 'capture-notes.txt' || {
|
|
echo "FAIL: the doc does not name the file the bound lands in"
|
|
printf ' %s\n' "$sentence"
|
|
false
|
|
}
|
|
|
|
# And the script's own header, which a reader of the script hits first.
|
|
hdr=$(sed -n '1,60p' "$HACK_DIR/e2e-capture-previous-logs.sh")
|
|
case "$hdr" in
|
|
*"names the bound it was written under"*)
|
|
echo "FAIL: the capture script's header still claims the bound is in the log"; false ;;
|
|
esac
|
|
printf '%s\n' "$hdr" | grep -q 'capture-notes.txt' || {
|
|
echo "FAIL: the capture script's header does not name capture-notes.txt"; false
|
|
}
|
|
}
|
|
|
|
@test "the docs name the file a discarded knob note actually lands in" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_PODLIST_FAIL=1 COZY_REPORT_PODS_MAX=10m \
|
|
cozyreport_collect_broken_pods "$tmp/out"
|
|
|
|
# Which file the note lands in, read from the tree rather than assumed.
|
|
landed=""
|
|
for f in "$tmp/out"/COLLECTION-*.txt; do
|
|
[ -f "$f" ] || continue
|
|
grep -q "COZY_REPORT_PODS_MAX='10m'" "$f" && landed=$(basename "$f")
|
|
done
|
|
[ -n "$landed" ] || { echo "FAIL: the knob note landed nowhere"; ls "$tmp/out"; false; }
|
|
|
|
# The doc promised two destinations and the note uses a third on this path. A
|
|
# reader who mistyped a knob AND hit an unreachable apiserver follows the doc to
|
|
# COLLECTION-NOTES.txt, finds nothing, and concludes the knob was accepted.
|
|
# The marker-parity guard cannot catch this: it checks that each filename is
|
|
# named somewhere in both doc and code, never which file a given note goes to.
|
|
# Scoped to the SENTENCE about knob-note destinations, not the whole document.
|
|
# COLLECTION-FAILED.txt is named elsewhere in the doc for an unrelated reason, so
|
|
# a document-wide grep passes whether or not it is listed as a destination -- the
|
|
# same weakness a document-wide match has anywhere.
|
|
sentence=$(sed -n 's/.*a knob value the collector could not use is named too\(.*\)/\1/p' \
|
|
"$HACK_DIR/../docs/agents/e2e-testing.md" | head -1)
|
|
[ -n "$sentence" ] || { echo "FAIL: could not locate the knob-note sentence in the docs"; false; }
|
|
if ! printf '%s\n' "$sentence" | grep -q "$landed"; then
|
|
echo "FAIL: the note landed in $landed, which that sentence never names as a destination"
|
|
printf ' sentence: %s\n' "$sentence"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a pod list refusal is not attributed to a timeout that did not fire" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
# Exit 1, no stderr: kubectl refused and said nothing. The old wording offered
|
|
# "at 124 or 137 the read was cut off by its own 30s timeout" to a reader who had
|
|
# just read "exited 1" -- a mechanism that was not in play, plus a conditional
|
|
# the code had already evaluated. Every other classification site in these three
|
|
# scripts branches instead.
|
|
PATH="$tmp:$PATH" STUB_PODLIST_FAIL=silent cozyreport_collect_broken_pods "$tmp/out"
|
|
|
|
f="$tmp/out/COLLECTION-FAILED.txt"
|
|
grep -q 'exited 1' "$f"
|
|
if grep -qE 'at 124 or 137|cut off by' "$f"; then
|
|
echo "FAIL: a plain refusal is described in terms of the read timeout"
|
|
cat "$f"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a pod list killed by the timeout says so without a disjunction" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_PODLIST_FAIL=timeout cozyreport_collect_broken_pods "$tmp/out"
|
|
|
|
f="$tmp/out/COLLECTION-FAILED.txt"
|
|
grep -q 'the read was cut off by' "$f"
|
|
# The reader is told which mechanism applied, not asked to evaluate a condition.
|
|
if grep -q 'at 124 or 137' "$f"; then
|
|
echo "FAIL: made the reader evaluate the exit code the script already knew"
|
|
cat "$f"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a discarded knob is reported even when the pod list never returned" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
# The failed-list branch returns before the note block at the end of the
|
|
# function, so an operator who both mistyped a bound and hit an unreachable
|
|
# apiserver was told about the second and never about the first -- while the
|
|
# comment on that block promises the value is named whether or not either bound
|
|
# fired. Low damage, but the comment is the contract the rest of the file is
|
|
# written against.
|
|
PATH="$tmp:$PATH" STUB_PODLIST_FAIL=1 COZY_REPORT_PODS_MAX=10m \
|
|
cozyreport_collect_broken_pods "$tmp/out"
|
|
|
|
[ -f "$tmp/out/COLLECTION-FAILED.txt" ]
|
|
if ! grep -q "COZY_REPORT_PODS_MAX='10m'" "$tmp/out/COLLECTION-FAILED.txt"; then
|
|
echo "FAIL: the discarded knob left no trace when the pod list failed"
|
|
cat "$tmp/out/COLLECTION-FAILED.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "the shared cap fixes the starting number, not the resulting counts" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
rows="$(i=1; while [ "$i" -le 3 ]; do printf 'ns-%s pod-%s 0/1 Running 0 5m\n' "$i" "$i"; i=$((i + 1)); done)"
|
|
|
|
# Two Environment blocks said the shared default means the listing and the
|
|
# evidence tree "cannot disagree" about how much was kept. They can: the
|
|
# collector also stops on its time budget, so it holds fewer pods than the
|
|
# summary lists, and that is a legitimate outcome rather than a contradiction.
|
|
# What the shared constant actually prevents is the two STARTING from different
|
|
# numbers. This pins the difference the header used to deny.
|
|
PATH="$tmp:$PATH" STUB_POD_ROWS="$rows" COZY_REPORT_PODS_BUDGET=0 \
|
|
cozyreport_collect_broken_pods "$tmp/out"
|
|
|
|
collected=$(find "$tmp/out" -mindepth 2 -maxdepth 2 -type d 2>/dev/null | wc -l | tr -d ' ')
|
|
# The budget stopped the walk before any pod, while all three remain listed in
|
|
# the summary's source. Counts differ, and the artifact explains why.
|
|
[ "$collected" -lt 3 ]
|
|
grep -q 'collection budget elapsed' "$tmp/out/COLLECTION-TRUNCATED.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "the unbounded note does not claim completeness the caps contradict" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
rows="$(printf '%s\n' \
|
|
'ns-1 p-1 0/1 Running 0 5m' \
|
|
'ns-2 p-2 0/1 Running 0 5m' \
|
|
'ns-3 p-3 0/1 Running 0 5m')"
|
|
|
|
# Both notes in one directory. A missing `timeout` removes only the wall-clock
|
|
# ceiling; the pod cap, the collection budget and --tail all still apply, so
|
|
# "the evidence here is complete" was false whenever any of them fired. The
|
|
# existing UNBOUNDED test feeds a single pod, so the cap never overflows and the
|
|
# two files never meet -- the claim was unfalsifiable rather than checked.
|
|
PATH="$tmp:$PATH" COZYREPORT_BOUND="" STUB_POD_ROWS="$rows" COZY_REPORT_PODS_MAX=1 \
|
|
cozyreport_collect_broken_pods "$tmp/out"
|
|
|
|
[ -f "$tmp/out/COLLECTION-UNBOUNDED.txt" ]
|
|
[ -f "$tmp/out/COLLECTION-TRUNCATED.txt" ]
|
|
grep -q 'collected 1 of 3' "$tmp/out/COLLECTION-TRUNCATED.txt"
|
|
for note in "$tmp/out"/COLLECTION-*.txt; do
|
|
if grep -qE 'evidence here is complete|is complete,' "$note"; then
|
|
echo "FAIL: $(basename "$note") claims completeness while a cap was reported beside it"
|
|
cat "$note"
|
|
false
|
|
fi
|
|
done
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "no truncation note claims completeness while a truncated file sits beside it" {
|
|
# Budget expires part way through a pod whose logs were ALSO being cut off by their own read timeout. That combination is not
|
|
# exotic -- the budget runs out because reads are slow, and the ordinary way for
|
|
# a read to be slow is to hit its ceiling -- so the two notes land in the same
|
|
# directory and must not contradict each other.
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_CONTAINERS='app sidecar'
|
|
|
|
# Both halves have to be true at once for this to test anything: the note must
|
|
# be written AND a file beside it must be truncated. An expired deadline writes
|
|
# the note (and stops the walk before any container), while STUB_OBJ_MODE cuts
|
|
# off the two object reads that happen before the loop. With only STUB_LOG_MODE
|
|
# and no deadline, no note is written at all and the loop below iterates over
|
|
# nothing, which is green regardless of the wording.
|
|
PATH="$tmp:$PATH" STUB_OBJ_MODE=partial COZYREPORT_COLLECT_DEADLINE=1 \
|
|
cozyreport_collect_pod ns wide-0 "$tmp/out"
|
|
|
|
[ -f "$tmp/out/CONTAINER-LOGS-TRUNCATED.txt" ]
|
|
grep -q '^# \[cozyreport\] TRUNCATED' "$tmp/out/pod.yaml"
|
|
grep -q '^# \[cozyreport\] TRUNCATED' "$tmp/out/describe.txt"
|
|
|
|
# Neither note may assert its neighbours are whole while those neighbours say
|
|
# otherwise on their own last line.
|
|
for note in "$tmp/out"/*TRUNCATED.txt "$tmp/out"/COLLECTION-*.txt; do
|
|
[ -f "$note" ] || continue
|
|
if grep -qE 'read in full|is complete|both of which were collected' "$note"; then
|
|
echo "FAIL: $(basename "$note") claims completeness next to a truncated file"
|
|
cat "$note"
|
|
false
|
|
fi
|
|
done
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod marks pod.yaml and describe.txt when their read was cut off" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_OBJ_MODE=partial cozyreport_collect_pod ns pod-1 "$tmp/out"
|
|
|
|
# Both are killed at the same ceiling as every other read, so a half-written
|
|
# file is an outcome they can produce. A describe.txt that stops before its Events section is
|
|
# byte-indistinguishable from a pod that had no events, and that reading is the
|
|
# exact one this collector exists to prevent.
|
|
for f in describe.txt pod.yaml; do
|
|
if ! grep -q '^# \[cozyreport\] TRUNCATED' "$tmp/out/$f"; then
|
|
echo "FAIL: $f was cut off mid-read and says nothing about it"
|
|
cat "$tmp/out/$f"
|
|
false
|
|
fi
|
|
done
|
|
# What did land is kept, not discarded: a partial describe still names the pod.
|
|
grep -q 'Status: Running' "$tmp/out/describe.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod leaves no truncation marker on a read that completed" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" cozyreport_collect_pod ns pod-1 "$tmp/out"
|
|
|
|
# The other half of the contract. A marker on every file would be as useless as
|
|
# a marker on none: the reader could not tell which reads actually stopped short.
|
|
for f in describe.txt pod.yaml; do
|
|
if grep -q 'TRUNCATED' "$tmp/out/$f"; then
|
|
echo "FAIL: $f completed but claims it was truncated"
|
|
false
|
|
fi
|
|
done
|
|
grep -q 'Events:' "$tmp/out/describe.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod stops reading containers once the collection deadline passed" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_CONTAINERS='app sidecar exporter'
|
|
|
|
PATH="$tmp:$PATH" COZYREPORT_COLLECT_DEADLINE=1 cozyreport_collect_pod ns wide-0 "$tmp/out"
|
|
|
|
# Without this the overshoot past the collection budget grows with the pod's
|
|
# container count, so a ceiling stated in terms of one container is not one.
|
|
[ -f "$tmp/out/CONTAINER-LOGS-TRUNCATED.txt" ]
|
|
# The note says what was ATTEMPTED and points at each file's own last line for
|
|
# whether that read finished. It must not claim the files beside it are whole:
|
|
# this note is written because reads were slow, and the ordinary way to be slow
|
|
# is to hit the per-read ceiling, so a neighbouring log carrying its own
|
|
# TRUNCATED marker is the likely case, and a test pinning "read in full" would
|
|
# hold that false claim in place.
|
|
grep -q 'were attempted' "$tmp/out/CONTAINER-LOGS-TRUNCATED.txt"
|
|
if grep -qE 'read in full|is complete' "$tmp/out/CONTAINER-LOGS-TRUNCATED.txt"; then
|
|
echo "FAIL: the truncation note claims neighbouring files are complete"
|
|
cat "$tmp/out/CONTAINER-LOGS-TRUNCATED.txt"
|
|
false
|
|
fi
|
|
if [ -f "$tmp/out/logs-app.txt" ]; then echo "FAIL: read a container past the deadline"; false; fi
|
|
# The two reads that are not per-container still happened: they are what makes
|
|
# the note above answerable, since they name the containers that were skipped.
|
|
[ -f "$tmp/out/pod.yaml" ]
|
|
[ -f "$tmp/out/describe.txt" ]
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect pod reads every container when no deadline was handed to it" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_CONTAINERS='app sidecar exporter'
|
|
|
|
PATH="$tmp:$PATH" cozyreport_collect_pod ns wide-0 "$tmp/out"
|
|
|
|
# A guard that fires when the caller set no budget would silently gut the
|
|
# report for every direct caller of this helper.
|
|
[ -f "$tmp/out/logs-app.txt" ]
|
|
[ -f "$tmp/out/logs-sidecar.txt" ]
|
|
[ -f "$tmp/out/logs-exporter.txt" ]
|
|
if [ -f "$tmp/out/CONTAINER-LOGS-TRUNCATED.txt" ]; then echo "FAIL: claimed a truncation with no budget set"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods walks the selection into one directory per pod" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS="$(printf '%s\n' \
|
|
'cozy-keycloak keycloak-db-2 0/1 Running 0 6m' \
|
|
'kube-system coredns-abc 1/1 Running 0 2h' \
|
|
'cozy-system puller-z 0/1 ImagePullBackOff 0 4m')"
|
|
|
|
PATH="$tmp:$PATH" cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
# Selection and collection are each pinned above; this pins the wiring, where
|
|
# a `read` eating the wrong field would leave a report quietly holding fewer
|
|
# pods than the cluster had, with no other symptom.
|
|
[ -f "$tmp/pods/cozy-keycloak/keycloak-db-2/pod.yaml" ]
|
|
[ -f "$tmp/pods/cozy-system/puller-z/pod.yaml" ]
|
|
if [ -d "$tmp/pods/kube-system/coredns-abc" ]; then echo "FAIL: collected a healthy pod"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods spends its cap on the tenant namespaces first" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
# kubectl emits namespace-alphabetical rows, so cozy-* arrives first and the pod
|
|
# the suite died on arrives last. The cap only bites on a broadly degraded
|
|
# cluster, which is exactly when the platform contributes those cozy-* rows.
|
|
export STUB_POD_ROWS="$(printf '%s\n' \
|
|
'cozy-a pod-a 0/1 Running 0 5m' \
|
|
'cozy-b pod-b 0/1 Running 0 5m' \
|
|
'cozy-c pod-c 0/1 Running 0 5m' \
|
|
'tenant-test the-pod-the-run-died-on 0/1 Running 0 5m')"
|
|
|
|
PATH="$tmp:$PATH" COZY_REPORT_PODS_MAX=1 cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
[ -f "$tmp/pods/tenant-test/the-pod-the-run-died-on/pod.yaml" ]
|
|
if [ -d "$tmp/pods/cozy-a" ]; then echo "FAIL: spent the cap on an unrelated namespace"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods collects a zero cap without aborting the report" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS='ns-1 pod-1 0/1 Running 0 5m'
|
|
|
|
PATH="$tmp:$PATH" COZY_REPORT_PODS_MAX=0 cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
# GNU head takes `-n 0` as an empty result; BSD/macOS head rejects it with
|
|
# "illegal line count -- 0", which under a local run aborts the collection. The
|
|
# drop still has to be visible.
|
|
[ "$(find "$tmp/pods" -name pod.yaml | grep -c . || true)" -eq 0 ]
|
|
grep -q 'collected 0 of 1' "$tmp/pods/COLLECTION-TRUNCATED.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods reports a malformed cap instead of applying it silently" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS='ns-1 pod-1 0/1 Running 0 5m'
|
|
|
|
PATH="$tmp:$PATH" COZY_REPORT_PODS_MAX=abc cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
# An operator who wrote a value and got the default with no trace reads the
|
|
# result as the bound they asked for. Reported even though nothing was truncated:
|
|
# the discarded value is the finding, not the truncation.
|
|
grep -q "COZY_REPORT_PODS_MAX='abc'" "$tmp/pods/COLLECTION-NOTES.txt"
|
|
grep -q 'used 40' "$tmp/pods/COLLECTION-NOTES.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods reports a malformed budget the same way" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS='ns-1 pod-1 0/1 Running 0 5m'
|
|
|
|
PATH="$tmp:$PATH" COZY_REPORT_PODS_BUDGET=10m cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
grep -q "COZY_REPORT_PODS_BUDGET='10m'" "$tmp/pods/COLLECTION-NOTES.txt"
|
|
grep -q 'used 600' "$tmp/pods/COLLECTION-NOTES.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods rejects a number too large for the shell to compute with" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS='ns-1 pod-1 0/1 Running 0 5m'
|
|
|
|
PATH="$tmp:$PATH" COZY_REPORT_PODS_BUDGET=999999999999999999999999 \
|
|
cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
# All-digits is not the same test as "this shell can compute with it".
|
|
# `$(( now + 999999999999999999999999 ))` is fatal in dash, which is /bin/sh on
|
|
# the CI image, so the report would end at the deadline calculation and never
|
|
# reach the tarball -- the one outcome every bound here exists to avoid.
|
|
grep -q "COZY_REPORT_PODS_BUDGET='999999999999999999999999'" "$tmp/pods/COLLECTION-NOTES.txt"
|
|
[ -f "$tmp/pods/ns-1/pod-1/pod.yaml" ]
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "pod tail rejects a number too large for the shell as well" {
|
|
[ "$(COZY_REPORT_POD_TAIL=999999999999999999999999 cozyreport_pod_tail)" = "2000" ]
|
|
[ -n "$(COZY_REPORT_POD_TAIL=999999999999999999999999 cozyreport_pod_tail_note)" ]
|
|
}
|
|
|
|
@test "collect broken pods appends a knob note to a truncation it also reported" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS="$(i=1; while [ "$i" -le 5 ]; do printf 'ns-%s pod-%s 0/1 Running 0 5m\n' "$i" "$i"; i=$((i + 1)); done)"
|
|
|
|
PATH="$tmp:$PATH" COZY_REPORT_PODS_MAX=2 COZY_REPORT_POD_TAIL=abc \
|
|
cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
# Only the standalone COLLECTION-NOTES.txt branch was covered; this is the other
|
|
# one, where a truncation note already exists and the knob note has to join it
|
|
# rather than land in a second file nobody reads.
|
|
grep -q 'collected 2 of 5' "$tmp/pods/COLLECTION-TRUNCATED.txt"
|
|
grep -q "COZY_REPORT_POD_TAIL='abc'" "$tmp/pods/COLLECTION-TRUNCATED.txt"
|
|
if [ -f "$tmp/pods/COLLECTION-NOTES.txt" ]; then echo "FAIL: split the note across two files"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods says nothing extra when both knobs are well formed" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS='ns-1 pod-1 0/1 Running 0 5m'
|
|
|
|
PATH="$tmp:$PATH" COZY_REPORT_PODS_MAX=5 COZY_REPORT_PODS_BUDGET=300 cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
if [ -f "$tmp/pods/COLLECTION-NOTES.txt" ]; then echo "FAIL: invented a note about a value it accepted"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods reports a malformed tail alongside the other knobs" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS='ns-1 pod-1 0/1 Running 0 5m'
|
|
|
|
PATH="$tmp:$PATH" COZY_REPORT_POD_TAIL=1O00 cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
# The header claims every numeric knob reports its fallback. Two of the three
|
|
# doing it is the documentation being wrong about the third.
|
|
grep -q "COZY_REPORT_POD_TAIL='1O00'" "$tmp/pods/COLLECTION-NOTES.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "pod tail value and the note about it never disagree" {
|
|
# Two `case` patterns written to mean the same thing drift: `00` is all-digits
|
|
# and non-zero as a string, so a split validator applies `--tail=00` (which is
|
|
# --tail=0, no lines at all, then described as a container that never logged)
|
|
# while the note claims the value was ignored. `10m` is the mirror image.
|
|
for v in '' 2000 -1 0 00 007 10m 1O00 2000x abc; do
|
|
value=$(COZY_REPORT_POD_TAIL="$v" cozyreport_pod_tail)
|
|
note=$(COZY_REPORT_POD_TAIL="$v" cozyreport_pod_tail_note)
|
|
if [ "$value" = "2000" ] && [ "$v" != "2000" ] && [ -n "$v" ] && [ -z "$note" ]; then
|
|
echo "FAIL: COZY_REPORT_POD_TAIL='$v' fell back to 2000 with no note"
|
|
false
|
|
fi
|
|
if [ "$value" != "2000" ] && [ -n "$note" ]; then
|
|
echo "FAIL: COZY_REPORT_POD_TAIL='$v' was applied as '$value' while the note called it malformed"
|
|
false
|
|
fi
|
|
done
|
|
}
|
|
|
|
@test "pod tail refuses a padded zero the same as a plain one" {
|
|
# `--tail=00` reaches kubectl as 0 and returns nothing, which this script would
|
|
# then report as a container that never started or stayed quiet.
|
|
[ "$(COZY_REPORT_POD_TAIL=00 cozyreport_pod_tail)" = "2000" ]
|
|
[ "$(COZY_REPORT_POD_TAIL=0 cozyreport_pod_tail)" = "2000" ]
|
|
}
|
|
|
|
@test "collect broken pods survives a read only temp dir without blaming the cluster" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS='ns-1 pod-1 0/1 Running 0 5m'
|
|
# A path that does not exist, not one that is unwritable: root holds
|
|
# CAP_DAC_OVERRIDE, so mktemp succeeds inside a 0500 directory and the test would
|
|
# exercise the happy path and pass without ever entering the fallback it exists to
|
|
# pin. The e2e sandbox runs as root. ENOENT is refused for everyone.
|
|
PATH="$tmp:$PATH" TMPDIR="$tmp/gone" cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
# With no fallback for the scratch file the redirect itself fails, the command
|
|
# substitution returns non-zero, and the report states that the pod list did not
|
|
# return: a false claim about the cluster caused by the local filesystem.
|
|
if [ -f "$tmp/pods/COLLECTION-FAILED.txt" ]; then
|
|
echo "FAIL: blamed the cluster for an unwritable TMPDIR"
|
|
cat "$tmp/pods/COLLECTION-FAILED.txt"
|
|
false
|
|
fi
|
|
[ -f "$tmp/pods/ns-1/pod-1/pod.yaml" ]
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods still collects when timeout is missing and says so" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS='ns-1 pod-1 0/1 Running 0 5m'
|
|
|
|
# An empty bound is what sourcing this script on a host without `timeout`
|
|
# produces, since the question is answered once at file scope. Note and proceed,
|
|
# not refuse: an unbounded read still collects the tree, so refusing would turn a
|
|
# missing local binary into a lost section.
|
|
PATH="$tmp:$PATH" COZYREPORT_BOUND="" cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
grep -q 'timeout is not on PATH' "$tmp/pods/COLLECTION-UNBOUNDED.txt"
|
|
grep -q 'not a statement about the cluster' "$tmp/pods/COLLECTION-UNBOUNDED.txt"
|
|
[ -f "$tmp/pods/ns-1/pod-1/pod.yaml" ]
|
|
[ -f "$tmp/pods/ns-1/pod-1/logs-app.txt" ]
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "the tail note states the bound applied, not the one that was asked for" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
# A value the script cannot use falls back to 2000. The note inside each log
|
|
# used to render that as `COZY_REPORT_POD_TAIL=2000`, which is a statement about
|
|
# the environment and was false: the variable held the rejected value. Two files
|
|
# in the same tarball then disagreed about what one variable was set to, and the
|
|
# one a reader is most likely to open was the wrong one.
|
|
PATH="$tmp:$PATH" COZY_REPORT_POD_TAIL=10m cozyreport_collect_pod ns chatty-0 "$tmp/out"
|
|
|
|
grep -q 'holds at most the last 2000 lines' "$tmp/out/CONTAINER-LOGS-BOUND.txt"
|
|
# And the log itself is left alone: it was read in full, so it is a complete
|
|
# artifact and a trailing prose line would break every parser that could read
|
|
# it before. Same reason pod.yaml carries its marker as a YAML comment; a log
|
|
# format has no comment, so the note lives beside the file instead.
|
|
if grep -q 'holds at most the last' "$tmp/out/logs-app.txt"; then
|
|
echo "FAIL: the bound note is inside a successfully read log"
|
|
false
|
|
fi
|
|
if grep -q 'COZY_REPORT_POD_TAIL=2000' "$tmp/out/CONTAINER-LOGS-BOUND.txt"; then
|
|
echo "FAIL: the note claims the environment holds a value it does not"
|
|
grep 'COZY_REPORT_POD_TAIL' "$tmp/out/CONTAINER-LOGS-BOUND.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a container log refused without a message is not filed as silence" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_LOG_MODE=silent cozyreport_collect_pod ns quiet-0 "$tmp/out"
|
|
|
|
# Exit non-zero, no log, and a scratch file that exists and is empty. kubectl
|
|
# refused and said nothing, which is not the same fact as a container that ran
|
|
# and logged nothing, and only one of the two is about the cluster.
|
|
grep -q 'without a message' "$tmp/out/logs-app.txt"
|
|
if grep -q 'started and logged nothing' "$tmp/out/logs-app.txt"; then
|
|
echo "FAIL: a refusal is reported as a silent container"
|
|
cat "$tmp/out/logs-app.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a container list that stopped part way says the rest is unaccounted for" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_JSONPATH_FAIL=partial cozyreport_collect_pod ns half-0 "$tmp/out"
|
|
|
|
# Names arrived and the read still failed, so the list is a prefix. The
|
|
# empty-list branch never fires -- the list is not empty, it is incomplete --
|
|
# and without a marker the directory holds logs for the containers kubectl got
|
|
# to and nothing for the rest, which a reader counts as the whole pod.
|
|
grep -q 'INCOMPLETE' "$tmp/out/CONTAINER-LIST-INCOMPLETE.txt" || {
|
|
echo "FAIL: a partial container list is not marked"
|
|
ls "$tmp/out"
|
|
false
|
|
}
|
|
grep -q 'unexpected EOF' "$tmp/out/CONTAINER-LIST-INCOMPLETE.txt"
|
|
# And the containers it did name are still collected: partial evidence is kept.
|
|
[ -f "$tmp/out/logs-app.txt" ] || { echo "FAIL: the named container was not read"; false; }
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a complete container list leaves no incompleteness marker" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" cozyreport_collect_pod ns whole-0 "$tmp/out"
|
|
|
|
if [ -f "$tmp/out/CONTAINER-LIST-INCOMPLETE.txt" ]; then
|
|
echo "FAIL: a clean container list was marked incomplete"
|
|
cat "$tmp/out/CONTAINER-LIST-INCOMPLETE.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "an empty container list refused without a message says which happened" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_JSONPATH_FAIL=silent cozyreport_collect_pod ns gone-0 "$tmp/out"
|
|
|
|
# Four ways the container list comes back empty, and this is the one with
|
|
# nothing to quote and no clock involved. Reported as its own outcome rather
|
|
# than folded into the timeout branch above it or the no-temp-dir branch below.
|
|
grep -q 'said nothing and exited 1' "$tmp/out/logs-UNAVAILABLE.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "an empty container list with nowhere to write its reason says that instead" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
# A refusal that is NOT a timeout, with no writable temp dir: the branch the
|
|
# sibling test could not reach, because it paired an unwritable TMPDIR with a
|
|
# timeout and the timeout branch is checked first.
|
|
PATH="$tmp:$PATH" TMPDIR=/nonexistent-cozyreport-probe STUB_JSONPATH_FAIL=silent \
|
|
cozyreport_collect_pod ns gone-0 "$tmp/out"
|
|
|
|
grep -q 'could not be captured' "$tmp/out/logs-UNAVAILABLE.txt"
|
|
if grep -q 'said nothing and exited' "$tmp/out/logs-UNAVAILABLE.txt"; then
|
|
echo "FAIL: a lost message is reported as kubectl having been silent"
|
|
cat "$tmp/out/logs-UNAVAILABLE.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a container list that names nothing is blank, not empty, and still reports" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
# The shape real kubectl produces, which the other tests in this group do not:
|
|
# the jsonpath holds two expressions with a literal space between them, and that
|
|
# space is emitted whatever the fields contain. A pod that names no container
|
|
# therefore comes back as " ", never as "". Command substitution strips trailing
|
|
# NEWLINES and not spaces, so a check written as `[ -z ]` is FALSE here and the
|
|
# read falls through every branch meant to catch it: no logs-UNAVAILABLE.txt, no
|
|
# CONTAINER-LIST-INCOMPLETE.txt, and a pod directory carrying pod.yaml and
|
|
# describe.txt with no log file and nothing saying why -- which reads as a pod
|
|
# whose containers were silent, the exact confusion this tree exists to remove.
|
|
#
|
|
# Exits 0 deliberately. A failing read writes no stdout, so it lands in the
|
|
# branch correctly even with the bug; only a SUCCESSFUL read that names nothing
|
|
# reaches this, which is why it survived the other tests here.
|
|
PATH="$tmp:$PATH" STUB_CONTAINERS=' ' cozyreport_collect_pod ns quiet-0 "$tmp/out"
|
|
|
|
if [ ! -f "$tmp/out/logs-UNAVAILABLE.txt" ]; then
|
|
echo "FAIL: a blank container list left the pod directory with no logs and no explanation"
|
|
ls -la "$tmp/out"
|
|
false
|
|
fi
|
|
grep -q 'container list came back empty' "$tmp/out/logs-UNAVAILABLE.txt"
|
|
# Not the prefix branch: nothing was named, so claiming kubectl named some
|
|
# containers and stopped would invent a partial read that never happened.
|
|
if [ -f "$tmp/out/CONTAINER-LIST-INCOMPLETE.txt" ]; then
|
|
echo "FAIL: a blank list reported as a truncated one"
|
|
cat "$tmp/out/CONTAINER-LIST-INCOMPLETE.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "an object cut off by a signal does not name the deadline as the cause" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_OBJ_MODE=partial9 cozyreport_collect_pod ns hung-0 "$tmp/out"
|
|
|
|
# 137 reaches the object files by the same path it reaches the logs, and has to
|
|
# hedge there too: the kill grace produces it and so does anything else that
|
|
# SIGKILLs the read. Proven for logs already; the object reader shares the
|
|
# helper but not the test, and a helper shared by two callers is only covered
|
|
# where it is called.
|
|
grep -q 'SIGKILL' "$tmp/out/pod.yaml"
|
|
grep -q 'does not tell apart' "$tmp/out/pod.yaml"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "an object read killed before its first byte does not claim the object ended" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_OBJ_MODE=killed cozyreport_collect_pod ns hung-0 "$tmp/out"
|
|
|
|
# The file never started, so "ends here" is the whole content. It still has to
|
|
# say the read was cut off rather than leave a zero-byte pod.yaml, which is what
|
|
# a pod that genuinely has no object would also leave.
|
|
[ -s "$tmp/out/pod.yaml" ] || { echo "FAIL: nothing written for a killed object read"; false; }
|
|
grep -q '^# \[cozyreport\] TRUNCATED' "$tmp/out/pod.yaml"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "the read bound is resolved from the host at load time" {
|
|
tmp=$(mktemp -d)
|
|
# Only the binaries sourcing the script needs, and deliberately no `timeout`.
|
|
for b in sh dirname; do
|
|
for d in /bin /usr/bin; do
|
|
[ -x "$d/$b" ] && { ln -sf "$d/$b" "$tmp/$b"; break; }
|
|
done
|
|
done
|
|
|
|
# Resolved once at file scope so a caller that drives cozyreport_collect_pod
|
|
# directly gets the same answer as the walk does. Defaulting to the bounded form
|
|
# and correcting it inside the walk would hand such a caller five reads that exit
|
|
# 127, and every log file would then blame the cluster for a missing binary.
|
|
bound=$(PATH="$tmp" sh -c 'COZYREPORT_LIB=1; . "$1"; printf "%s" "$COZYREPORT_BOUND"' _ "$SCRIPT")
|
|
if [ -n "$bound" ]; then echo "FAIL: claimed a bound of '$bound' with no timeout on PATH"; false; fi
|
|
|
|
# The other direction, and it supplies its own `timeout` rather than asking the
|
|
# host for one. Reading the ambient PATH here would make the verdict depend on
|
|
# what the machine happens to have installed, which is the same defect this pair
|
|
# of assertions exists to describe.
|
|
printf '#!/bin/sh\nshift 2\nexec "$@"\n' > "$tmp/timeout"
|
|
chmod +x "$tmp/timeout"
|
|
bound=$(PATH="$tmp" sh -c 'COZYREPORT_LIB=1; . "$1"; printf "%s" "$COZYREPORT_BOUND"' _ "$SCRIPT")
|
|
if [ -z "$bound" ]; then echo "FAIL: no bound with a timeout on PATH"; false; fi
|
|
case "$bound" in
|
|
timeout*) ;;
|
|
*) echo "FAIL: the bound does not start with timeout: $bound"; false ;;
|
|
esac
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "the cap never evicts a pod the uncapped collector always kept" {
|
|
# The regression this ordering exists to prevent. A selector of
|
|
# `$4 !~ /Running|Succeeded|Completed/` with no cap collects a CrashLoopBackOff
|
|
# pod always. Widening it to include `0/1 Running` and capping the walk by count
|
|
# are safe apart and only safe together if the wide class cannot displace the
|
|
# narrow one.
|
|
#
|
|
# Fixture is the shape a failed install actually produces: more not-Ready pods
|
|
# than the cap, nearly all of them newly-in-scope tenant pods, and one crash loop
|
|
# in a platform namespace. Namespace-first ranking puts that crash loop after
|
|
# every tenant row and the cap then drops it -- which is why the split is by
|
|
# evidence class first.
|
|
rows=$(i=1; while [ "$i" -le 60 ]; do
|
|
printf 'tenant-test unready-%02d 0/1 Running 0 5m\n' "$i"
|
|
i=$((i + 1))
|
|
done
|
|
printf 'cozy-system the-crashing-one 0/1 CrashLoopBackOff 7 9m\n')
|
|
|
|
out=$(printf '%s\n' "$rows" | COZY_REPORT_PREFER_NS=tenant- cozyreport_pods_prioritize)
|
|
|
|
# First, not merely present: the cap is a `head -n`, so surviving it means being
|
|
# ordered ahead of the rows that would fill it.
|
|
first=$(printf '%s\n' "$out" | sed -n '1p' | awk '{print $2}')
|
|
if [ "$first" != "the-crashing-one" ]; then
|
|
echo "FAIL: a crash-looping pod ranked behind newly-in-scope unready pods, so any cap drops it"
|
|
printf '%s\n' "$out" | head -n 3
|
|
false
|
|
fi
|
|
# It really would have been dropped: the competing rows outnumber the default cap.
|
|
[ "$(printf '%s\n' "$out" | grep -c .)" -eq 61 ]
|
|
# Tenant-first still holds WITHIN the newly-in-scope class -- the preference is
|
|
# narrowed to its own class, not abandoned.
|
|
after=$(printf '%s\n' "$out" | sed -n '2p' | awk '{print $1}')
|
|
[ "$after" = "tenant-test" ]
|
|
# No cleanup line here, deliberately: this test creates no directory. An
|
|
# `rm -rf "$tmp"` added out of habit does NOT no-op -- `tmp` is hack/cozytest.sh's
|
|
# OWN variable for the per-test scratch dir holding the captured log, and it is in
|
|
# scope inside the test body. Removing it takes the runner's log with it, and the
|
|
# test then fails after every assertion in it has already passed.
|
|
}
|
|
|
|
@test "an empty prefix disables the namespace order but not the class order" {
|
|
rows="$(printf '%s\n' \
|
|
'cozy-a pod-a 0/1 Running 0 5m' \
|
|
'tenant-test pod-t 0/1 Running 0 5m')"
|
|
|
|
out="$(printf '%s\n' "$rows" | COZY_REPORT_PREFER_NS='' cozyreport_pods_prioritize)"
|
|
|
|
# The documented disable switch, within one evidence class: input order is kept
|
|
# and no row is dropped. Without the guard the empty prefix matches every row in
|
|
# the first emission and nothing in the second, which happens to look right here
|
|
# and stops looking right the moment a caller relies on input order.
|
|
[ "$(printf '%s\n' "$out" | grep -c .)" -eq 2 ]
|
|
[ "$(printf '%s\n' "$out" | head -n 1 | awk '{print $2}')" = "pod-a" ]
|
|
}
|
|
|
|
@test "an empty prefix does not switch off the eviction guard" {
|
|
# The switch is documented as disabling the ordering, and a reader setting it is
|
|
# asking not to prefer a namespace. They are not asking for a crash-looping pod
|
|
# to lose its container logs to a cluster full of unready ones -- but if the
|
|
# class split sits behind the same guard as the namespace preference, that is
|
|
# what one env value quietly buys.
|
|
rows=$(i=1; while [ "$i" -le 60 ]; do
|
|
printf 'aaa-ns unready-%02d 0/1 Running 0 5m\n' "$i"
|
|
i=$((i + 1))
|
|
done
|
|
printf 'zzz-system the-crashing-one 0/1 CrashLoopBackOff 7 9m\n')
|
|
|
|
out=$(printf '%s\n' "$rows" | COZY_REPORT_PREFER_NS='' cozyreport_pods_prioritize)
|
|
|
|
# Namespace order is off, so `aaa-ns` would win any alphabetical or input-order
|
|
# ranking; the crash loop is first only because the class split still applies.
|
|
first=$(printf '%s\n' "$out" | sed -n '1p' | awk '{print $2}')
|
|
if [ "$first" != "the-crashing-one" ]; then
|
|
echo "FAIL: the empty prefix switched off the class ordering, so the cap drops the crash loop"
|
|
printf '%s\n' "$out" | head -n 3
|
|
false
|
|
fi
|
|
[ "$(printf '%s\n' "$out" | grep -c .)" -eq 61 ]
|
|
}
|
|
|
|
@test "prioritize treats the prefix as text and not as a pattern" {
|
|
# `tenantXtest` sorts first and matches `tenant.test` only if the prefix is read
|
|
# as a regular expression. The namespace the operator actually named is the one
|
|
# that must come first.
|
|
rows="$(printf '%s\n' \
|
|
'tenantXtest pod-regex 0/1 Running 0 5m' \
|
|
'tenant.test-a pod-literal 0/1 Running 0 5m' \
|
|
'cozy-a pod-a 0/1 Running 0 5m')"
|
|
|
|
out="$(printf '%s\n' "$rows" | COZY_REPORT_PREFER_NS='tenant.test' cozyreport_pods_prioritize)"
|
|
|
|
first=$(printf '%s\n' "$out" | head -n 1 | awk '{print $2}')
|
|
if [ "$first" != "pod-literal" ]; then
|
|
echo "FAIL: expected pod-literal first, got '$first' (prefix matched as a pattern)"
|
|
false
|
|
fi
|
|
}
|
|
|
|
@test "collect broken pods leaves no deadline behind for the next caller" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS='ns-1 pod-1 0/1 Running 0 5m'
|
|
unset COZYREPORT_COLLECT_DEADLINE
|
|
|
|
PATH="$tmp:$PATH" COZY_REPORT_PODS_BUDGET=900 cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
# Left set, it would silently bound whatever a later caller collects, against a
|
|
# deadline computed for a walk that has already finished. That the deadline is
|
|
# handed down in the first place is pinned by the budget-truncation test above,
|
|
# which can only pass if the per-container check was in effect.
|
|
if [ -n "${COZYREPORT_COLLECT_DEADLINE:-}" ]; then
|
|
echo "FAIL: left COZYREPORT_COLLECT_DEADLINE='$COZYREPORT_COLLECT_DEADLINE' in the caller's environment"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods records that it stopped at the pod cap" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS="$(i=1; while [ "$i" -le 5 ]; do printf 'ns-%s pod-%s 0/1 Running 0 5m\n' "$i" "$i"; i=$((i + 1)); done)"
|
|
|
|
PATH="$tmp:$PATH" COZY_REPORT_PODS_MAX=2 cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
[ "$(find "$tmp/pods" -name pod.yaml | grep -c .)" -eq 2 ]
|
|
grep -q 'collected 2 of 5 not-Ready pods' "$tmp/pods/COLLECTION-TRUNCATED.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods leaves no truncation marker when everything fits" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS='ns-1 pod-1 0/1 Running 0 5m'
|
|
|
|
PATH="$tmp:$PATH" cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
if [ -f "$tmp/pods/COLLECTION-TRUNCATED.txt" ]; then echo "FAIL: claimed a truncation that did not happen"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods records that it ran out of collection budget" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS="$(i=1; while [ "$i" -le 5 ]; do printf 'ns-%s pod-%s 0/1 Running 0 5m\n' "$i" "$i"; i=$((i + 1)); done)"
|
|
|
|
PATH="$tmp:$PATH" COZY_REPORT_PODS_MAX=4 COZY_REPORT_PODS_BUDGET=0 cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
# The per-read timeouts are individually bounded but accumulate, so the pod cap
|
|
# alone does not bound the walk. An overrun here is not a truncated pod section:
|
|
# the tarball is written after this, so the job's own limit takes the entire
|
|
# artifact, including everything collected before this point.
|
|
grep -q 'collection budget elapsed' "$tmp/pods/COLLECTION-TRUNCATED.txt"
|
|
# Denominator is what the walk could have reached (the cap already removed the
|
|
# fifth pod before the loop began), not the full selection. "0 of 5" claimed a
|
|
# reach the collector never had -- and this test pinned that claim, which is how
|
|
# it survived. The full total is still reported, on the cap line below.
|
|
grep -q 'stopped after 0 of 4 not-Ready pods it could reach' "$tmp/pods/COLLECTION-TRUNCATED.txt"
|
|
# Both bounds applied, so both are named. An operator who reads only the budget
|
|
# line and raises the budget gets 4 pods and no explanation for the fifth --
|
|
# a bound hiding its own effect, which is what these markers exist to stop.
|
|
grep -q 'COZY_REPORT_PODS_MAX=4 also applied' "$tmp/pods/COLLECTION-TRUNCATED.txt"
|
|
grep -q 'only 4 of 5 not-Ready pods were eligible' "$tmp/pods/COLLECTION-TRUNCATED.txt"
|
|
# The budget note still leads: it is the more proximate cause of stopping, and
|
|
# "reached the cap" alone would send a reader looking for pods never attempted.
|
|
head -n 1 "$tmp/pods/COLLECTION-TRUNCATED.txt" | grep -q 'collection budget elapsed'
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "the cap is not named when it did not apply" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS="$(i=1; while [ "$i" -le 3 ]; do printf 'ns-%s pod-%s 0/1 Running 0 5m\n' "$i" "$i"; i=$((i + 1)); done)"
|
|
|
|
# Cap above the selection, so only the budget bounded this walk. Naming a bound
|
|
# that did not fire is the same defect in the other direction: it sends an
|
|
# operator to raise a limit that was never reached.
|
|
PATH="$tmp:$PATH" COZY_REPORT_PODS_MAX=40 COZY_REPORT_PODS_BUDGET=0 cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
grep -q 'stopped after 0 of 3 not-Ready pods it could reach' "$tmp/pods/COLLECTION-TRUNCATED.txt"
|
|
if grep -q 'also applied' "$tmp/pods/COLLECTION-TRUNCATED.txt"; then
|
|
echo "FAIL: named the pod cap although it never bit"
|
|
cat "$tmp/pods/COLLECTION-TRUNCATED.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods names the budget when it ran out inside the last pod" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS="$(i=1; while [ "$i" -le 5 ]; do printf 'ns-%s pod-%s 0/1 Running 0 5m\n' "$i" "$i"; i=$((i + 1)); done)"
|
|
|
|
# Two containers, and the first read outlasts the budget, so the second is
|
|
# skipped and the pod really is short a log. The budget has headroom over the
|
|
# per-pod setup on purpose: `date +%s` is whole-second, so a budget of 1 leaves
|
|
# somewhere between 0 and 1 second for an mkdir plus three reads, and under load
|
|
# the deadline fires before any container is read. Then this test passes for the
|
|
# wrong reason and its sibling below fails on a loaded machine. The cap ends the loop, so no later
|
|
# iteration notices: a cap-only note would date the stop to the wrong bound and
|
|
# call that pod collected.
|
|
export STUB_CONTAINERS='app sidecar'
|
|
PATH="$tmp:$PATH" COZY_REPORT_PODS_MAX=1 COZY_REPORT_PODS_BUDGET=3 \
|
|
STUB_LOG_MODE=slow STUB_LOG_SLEEP=4 cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
[ -f "$tmp/pods/ns-1/pod-1/CONTAINER-LOGS-TRUNCATED.txt" ]
|
|
grep -q 'COZY_REPORT_PODS_MAX=1' "$tmp/pods/COLLECTION-TRUNCATED.txt"
|
|
grep -q 'collection budget' "$tmp/pods/COLLECTION-TRUNCATED.txt"
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods claims no budget truncation when nothing was skipped" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS="$(i=1; while [ "$i" -le 5 ]; do printf 'ns-%s pod-%s 0/1 Running 0 5m\n' "$i" "$i"; i=$((i + 1)); done)"
|
|
|
|
# One container and a budget large enough that nothing is ever skipped. The point
|
|
# is that "the clock is past the deadline" and "collection was cut short" are
|
|
# different questions: answering the first files a truncation that did not happen,
|
|
# pointing at a marker that does not exist. Driven without a sleep, so the
|
|
# assertion does not rest on a whole-second clock beating a stub.
|
|
export STUB_CONTAINERS='app'
|
|
PATH="$tmp:$PATH" COZY_REPORT_PODS_MAX=1 COZY_REPORT_PODS_BUDGET=600 \
|
|
cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
grep -q 'COZY_REPORT_PODS_MAX=1' "$tmp/pods/COLLECTION-TRUNCATED.txt"
|
|
if grep -q 'collection budget' "$tmp/pods/COLLECTION-TRUNCATED.txt"; then
|
|
echo "FAIL: reported a budget truncation with nothing skipped"
|
|
cat "$tmp/pods/COLLECTION-TRUNCATED.txt"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods files no truncation for a healthy cluster on a spent budget" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS='kube-system coredns-abc 1/1 Running 0 2h'
|
|
|
|
PATH="$tmp:$PATH" COZY_REPORT_PODS_BUDGET=0 cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
# Nothing was selected, so nothing was skipped, however long the clock says the
|
|
# run has been going.
|
|
if [ -d "$tmp/pods" ]; then
|
|
echo "FAIL: filed a truncation for a collection that had nothing to collect"
|
|
ls -la "$tmp/pods"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods says so when the pod list never returned" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_PODLIST_FAIL=refuse cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
# A failed list and a healthy cluster both end with no pod directories. Without
|
|
# this file the artifact cannot tell them apart, which is the same defect one
|
|
# level up from the pod tree's own.
|
|
[ -f "$tmp/pods/COLLECTION-FAILED.txt" ]
|
|
grep -q 'NOT a report that every pod was Ready' "$tmp/pods/COLLECTION-FAILED.txt"
|
|
# kubectl's actionable line comes last, after one discovery-retry line per
|
|
# attempt. Quoting the first one and trimming it to 200 characters ships a
|
|
# truncated klog message whose cause has been cut off the end.
|
|
grep -q 'pods is forbidden' "$tmp/pods/COLLECTION-FAILED.txt"
|
|
if grep -q 'Unhandled Error' "$tmp/pods/COLLECTION-FAILED.txt"; then echo "FAIL: quoted the discovery noise instead of the reason"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods tells a timed out pod list from a refused one" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
|
|
PATH="$tmp:$PATH" STUB_PODLIST_FAIL=timeout cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
# A read killed by its own timeout never got to write a reason, so quoting its
|
|
# empty stderr yields a bare `kubectl said:` that names nothing. An apiserver
|
|
# too slow to answer and one answering "forbidden" are different problems.
|
|
#
|
|
# The timeout branch names the mechanism rather than the exit code: this test
|
|
# asserted `exited 124` while the message combined both cases in one sentence,
|
|
# and that spelling survived the defect the sibling test now covers.
|
|
grep -q 'the read was cut off by' "$tmp/pods/COLLECTION-FAILED.txt"
|
|
if grep -q 'kubectl said: *$' "$tmp/pods/COLLECTION-FAILED.txt"; then echo "FAIL: quoted an empty reason"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "collect broken pods writes nothing at all when every pod is healthy" {
|
|
tmp=$(mktemp -d)
|
|
collect_stub_dir "$tmp"
|
|
export STUB_ARGV="$tmp/argv"
|
|
export STUB_POD_ROWS='kube-system coredns-abc 1/1 Running 0 2h'
|
|
|
|
PATH="$tmp:$PATH" cozyreport_collect_broken_pods "$tmp/pods"
|
|
|
|
# An empty selection reaches the walk as a single blank line; a pod directory
|
|
# named after nothing would be a broken pod that does not exist.
|
|
if [ -d "$tmp/pods" ]; then echo "FAIL: created a pod tree for a healthy cluster"; false; fi
|
|
# No pod was collected, so the only read that happened is the pod list, and it
|
|
# is bounded like the per-pod ones. It is the read that decides whether the
|
|
# report has a pod section at all, and it runs against the apiserver the run
|
|
# has just finished degrading.
|
|
[ "$(grep -c '^timeout 30$' "$tmp/argv")" -eq 1 ]
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary reads the age column correctly for a pod that has restarted" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
# kubectl prints RESTARTS as `7 (4m12s ago)` on a restarted pod -- three awk
|
|
# fields --
|
|
# which shifts AGE off $6. A restarted pod is the normal case in a failed run.
|
|
rows='cozy-keycloak keycloak-db-2 0/1 Running 7 (4m12s ago) 6m'
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_POD_ROWS="$rows" sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
printf '%s\n' "$out" | grep -q 'age=6m)'
|
|
if printf '%s\n' "$out" | grep -q 'age=(4m12s'; then echo "FAIL: age read from the shifted column"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary still reads the age column for a pod that never restarted" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
rows='cozy-system puller-z 0/1 ImagePullBackOff 0 4m'
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_POD_ROWS="$rows" sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
printf '%s\n' "$out" | grep -q 'restarts=0, age=4m)'
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary says how many not ready pods its listing left out" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
rows="$(i=1; while [ "$i" -le 45 ]; do printf 'ns-%s pod-%s 0/1 Running 0 5m\n' "$i" "$i"; i=$((i + 1)); done)"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_POD_ROWS="$rows" sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# 45 broken pods, 40 listed. A cap that hides the rest reads like there is no
|
|
# rest -- the same defect the per-pod capture reports rather than swallows.
|
|
printf '%s\n' "$out" | grep -q '5 more not-Ready pod(s) not listed'
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary says so in every section whose read was killed" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_ALL_TIMEOUT=1 sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# Wrapping these reads in a timeout without keeping the status is worse than
|
|
# leaving them unbounded: an unbounded read that hangs loses the tarball loudly,
|
|
# a bounded one killed at 30s renders the section byte-identical to a healthy
|
|
# cluster. An empty "HelmReleases not Ready" is the most misleading line this
|
|
# file can carry, and it is the first file a triager opens.
|
|
for section in 'HelmReleases not Ready' 'Recent OOMKilled' 'Recent Warning' 'Storage' 'Node Conditions'; do
|
|
if ! printf '%s\n' "$out" | sed -n "/## $section/,/^## /p" | grep -q 'did not return'; then
|
|
echo "FAIL: section '$section' rendered as a healthy cluster on a killed read"
|
|
printf '%s\n' "$out" | sed -n "/## $section/,/^## /p"
|
|
false
|
|
fi
|
|
done
|
|
# And the status it names is the read's, not 0. A compound `if` whose condition
|
|
# fails and which has no `else` leaves `$?` at 0, so a status read after the `if`
|
|
# reports "exit 0" for every failure -- a line that says the read did not return
|
|
# and then names a clean exit.
|
|
if printf '%s\n' "$out" | grep -q 'did not return (exit 0)'; then
|
|
echo "FAIL: reported a failed read as exit 0"
|
|
false
|
|
fi
|
|
printf '%s\n' "$out" | grep -q 'did not return (exit 124)'
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary names kubectl's reason when a section read is refused" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_CRD_PRESENT=1 STUB_HR_FAIL=1 sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
section="$(printf '%s\n' "$out" | sed -n '/## HelmReleases not Ready/,/^## /p')"
|
|
# "did not return (exit 1)" names a symptom and no cause. An apiserver too slow
|
|
# to answer and one answering "forbidden" are different problems with different
|
|
# fixes, and this is the first file a triager opens.
|
|
printf '%s\n' "$section" | grep -q 'did not return'
|
|
if ! printf '%s\n' "$section" | grep -qi 'forbidden'; then
|
|
echo "FAIL: dropped kubectl's reason for a refused read"
|
|
printf '%s\n' "$section"
|
|
false
|
|
fi
|
|
# The LAST stderr line, not the first: the stub emits a klog discovery-retry
|
|
# line ahead of the real message, which is what a failing cluster-wide read
|
|
# actually looks like. Quoting the first line names the retry, not the cause.
|
|
if printf '%s\n' "$section" | grep -q "couldn't get current server API group list"; then
|
|
echo "FAIL: quoted the klog retry line instead of kubectl's message"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary keeps a warning off a read that succeeded" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_CRD_PRESENT=1 STUB_HR_WARN=1 \
|
|
STUB_HR_ROWS='tenant-a app-1 5m False upgrade retries exhausted' \
|
|
sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
section="$(printf '%s\n' "$out" | sed -n '/## HelmReleases not Ready/,/^## /p')"
|
|
# kubectl writes deprecation and partial-result warnings to stderr while exiting
|
|
# 0. Capturing the read as `2>&1` would fold those into READ_OUT, and the awk
|
|
# below would then parse the warning as a HelmRelease row -- a broken release
|
|
# invented out of a notice, in the section a triager reads first. The reason is
|
|
# captured to a file and read only on the failure path, so this must stay clean.
|
|
if printf '%s\n' "$section" | grep -qi 'deprecated'; then
|
|
echo "FAIL: a stderr warning leaked into the section body"
|
|
printf '%s\n' "$section"
|
|
false
|
|
fi
|
|
# And the read still succeeded, so its rows are there and no failure is claimed.
|
|
printf '%s\n' "$section" | grep -q 'upgrade retries exhausted'
|
|
if printf '%s\n' "$section" | grep -q 'did not return'; then
|
|
echo "FAIL: reported a successful read as failed because it warned"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a CRD probe whose message was lost is not filed as an absent CRD" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
# mktemp fails, so the scratch file is never created and kubectl's stderr goes
|
|
# to /dev/null. The gate then has a non-zero status and nothing else.
|
|
printf '#!/bin/sh\nexit 1\n' > "$tmp/mktemp"; chmod +x "$tmp/mktemp"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_CRD_FORBIDDEN=1 sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# Without a branch for this, the gate falls off the end of its if/elif and
|
|
# returns silently, which is byte-identical to the CRD being absent. Every
|
|
# section behind the gate then disappears from the summary, starting with
|
|
# "## HelmReleases not Ready", on a report whose only real problem is that the
|
|
# machine writing it had nowhere to put a temp file.
|
|
printf '%s\n' "$out" | grep -q 'could not be captured' || {
|
|
echo "FAIL: a lost message is rendered as an absent CRD"
|
|
printf '%s\n' "$out"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a CRD probe that failed without saying anything says that much" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_CRD_SILENT=1 sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# kubectl is explicit about NotFound, so a non-zero exit with no message is not
|
|
# absence. Filing it as absence is the same error in the other direction from
|
|
# the test above: there, a local condition became a cluster fact; here, an
|
|
# unexplained refusal would.
|
|
printf '%s\n' "$out" | grep -q 'without a message' || {
|
|
echo "FAIL: an unexplained refusal is rendered as an absent CRD"
|
|
printf '%s\n' "$out"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary names an OOMKilled event rather than leaving the heading bare" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
rows="$(printf '%s\n' \
|
|
'tenant-a 3m Normal OOMKilling Node/worker-1 Memory cgroup out of memory: Killed process 4711 (postgres)')"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_EVENT_ROWS="$rows" sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# The heading is printed unconditionally, so an empty section and a section
|
|
# nobody could read look the same from outside. Nothing exercised the listing at
|
|
# all, which is how a section stays empty for years without anyone noticing.
|
|
section="$(printf '%s\n' "$out" | awk '/^## Recent OOMKilled/{f=1;next} f&&/^## /{exit} f')"
|
|
printf '%s\n' "$section" | grep -q 'Killed process 4711' || {
|
|
echo "FAIL: an OOMKilling event is not carried into the summary"
|
|
printf '%s\n' "$out"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary falls back on a malformed pod cap and says which value it used" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
rows='tenant-a broken-0 0/1 Running 0 5m'
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_POD_ROWS="$rows" COZY_REPORT_PODS_MAX=abc \
|
|
sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# The collector reports a rejected knob and the summary has the same branch, but
|
|
# only the collector's was covered. An operator who set the value is entitled to
|
|
# know it was not the bound that applied, in whichever of the two files they are
|
|
# reading.
|
|
printf '%s\n' "$out" | grep -q "ignored a malformed COZY_REPORT_PODS_MAX" || {
|
|
echo "FAIL: a malformed cap is discarded in silence"
|
|
printf '%s\n' "$out"
|
|
false
|
|
}
|
|
printf '%s\n' "$out" | grep -q 'broken-0' || {
|
|
echo "FAIL: the fallback did not list anything"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a zero pod cap lists nothing and still reports what it left out" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
rows="$(printf '%s\n' \
|
|
'tenant-a broken-0 0/1 Running 0 5m' \
|
|
'tenant-a broken-1 0/1 Running 0 6m')"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_POD_ROWS="$rows" COZY_REPORT_PODS_MAX=0 \
|
|
sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# Zero is a supported request, not a malformed one, and it is branched on rather
|
|
# than passed to `head -n 0`, which GNU treats as empty and BSD rejects outright.
|
|
# What must not happen is a silent zero: the overflow line is the only thing
|
|
# standing between "asked for nothing" and "found nothing".
|
|
if printf '%s\n' "$out" | grep -q 'broken-0'; then
|
|
echo "FAIL: a zero cap still listed a pod"; false
|
|
fi
|
|
printf '%s\n' "$out" | grep -q '2 more not-Ready pod(s) not listed' || {
|
|
echo "FAIL: a zero cap dropped two pods without saying so"
|
|
printf '%s\n' "$out"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary leaves a Ready HelmRelease out of the not-Ready listing" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
rows="$(printf '%s\n' \
|
|
'tenant-a broken 4m False Helm install failed for release tenant-a/broken' \
|
|
'tenant-a settled 9d True Release reconciliation succeeded')"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_CRD_PRESENT=1 STUB_HR_ROWS="$rows" \
|
|
sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# Every HelmRelease fixture in this file was already not-Ready, so the selection
|
|
# was only ever exercised in the direction that keeps rows. A section heading
|
|
# that says "not Ready" and lists everything is the same defect as one that
|
|
# lists nothing, and neither shows up without a row of the other kind.
|
|
printf '%s\n' "$out" | grep -q 'tenant-a/broken' || { echo "FAIL: a failed release is not listed"; false; }
|
|
if printf '%s\n' "$out" | grep -q 'tenant-a/settled'; then
|
|
echo "FAIL: a Ready release is listed as not Ready"
|
|
printf '%s\n' "$out"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "the pod cap does not silently bound the HelmRelease listing beside it" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
rows="$(printf '%s\n' \
|
|
'tenant-a hr-1 4m False Helm install failed one' \
|
|
'tenant-a hr-2 4m False Helm install failed two' \
|
|
'tenant-a hr-3 4m False Helm install failed three')"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_CRD_PRESENT=1 STUB_HR_ROWS="$rows" COZY_REPORT_PODS_MAX=1 \
|
|
sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# The header says this knob does not move the HelmRelease cap, which has its own
|
|
# at the same default. Sharing a default is not sharing a bound, and nothing
|
|
# checked that the two had not been wired together.
|
|
for hr in hr-1 hr-2 hr-3; do
|
|
printf '%s\n' "$out" | grep -q "tenant-a/$hr" || {
|
|
echo "FAIL: $hr was dropped by the pod cap"
|
|
printf '%s\n' "$out"
|
|
false
|
|
}
|
|
done
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a pod list cut off part way through is not called an empty section" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_PODLIST_FAIL=partial \
|
|
STUB_POD_ROWS='tenant-a broken-0 0/1 Running 0 5m' \
|
|
sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# kubectl printed a row and then failed. The rows are kept, as everywhere else
|
|
# here, because partial evidence is the only evidence there is -- but the
|
|
# sentence above them said the section was empty because nothing could be read,
|
|
# while the row sat three lines below it. One read, two contradictory statements,
|
|
# in the file a triager opens first.
|
|
printf '%s\n' "$out" | grep -q 'broken-0' || {
|
|
echo "FAIL: the rows that did arrive were discarded"; printf '%s\n' "$out"; false
|
|
}
|
|
printf '%s\n' "$out" | grep -q 'before it stopped, and there may be others' || {
|
|
echo "FAIL: a partial list is not described as partial"
|
|
printf '%s\n' "$out"
|
|
false
|
|
}
|
|
if printf '%s\n' "$out" | grep -q 'this section is empty because nothing could be read'; then
|
|
echo "FAIL: a section holding rows is called empty"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary lists a PVC that is not Bound and leaves the bound ones out" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
rows="$(printf '%s\n' \
|
|
'tenant-a data-0 Pending '' '' '' standard 4m' \
|
|
'tenant-a data-1 Bound pvc-aaa 10Gi RWO standard 9d')"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_PVC_ROWS="$rows" sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# STATUS is the third column of `kubectl get pvc -A`. An index picked by eye and
|
|
# never run against a row is how the services selector matched nothing for its
|
|
# whole life; this section has the same shape and had no fixture at all.
|
|
printf '%s\n' "$out" | grep -q 'PVC tenant-a/data-0' || {
|
|
echo "FAIL: an unbound PVC is not listed"; printf '%s\n' "$out"; false
|
|
}
|
|
if printf '%s\n' "$out" | grep -q 'data-1'; then
|
|
echo "FAIL: a Bound PVC is listed as a problem"; false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary lists a PV that is not Bound and leaves the bound ones out" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
# `kubectl get pv` has no NAMESPACE column, so STATUS lands at $5 rather than
|
|
# $3. Two sections, two different indexes, one line apart in the source.
|
|
rows="$(printf '%s\n' \
|
|
'pvc-aaa 10Gi RWO Delete Released tenant-a/data-9 standard 3d' \
|
|
'pvc-bbb 10Gi RWO Delete Bound tenant-a/data-1 standard 9d')"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_PV_ROWS="$rows" sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
printf '%s\n' "$out" | grep -q 'PV pvc-aaa' || {
|
|
echo "FAIL: a released PV is not listed"; printf '%s\n' "$out"; false
|
|
}
|
|
if printf '%s\n' "$out" | grep -q 'PV pvc-bbb'; then
|
|
echo "FAIL: a Bound PV is listed as a problem"; false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary lists a Certificate that is not Ready and leaves ready ones out" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
rows="$(printf '%s\n' \
|
|
'tenant-a wildcard False wildcard-tls 12m' \
|
|
'tenant-a api True api-tls 6d')"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_CRD_PRESENT=1 STUB_CERT_ROWS="$rows" \
|
|
sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
printf '%s\n' "$out" | grep -q 'tenant-a/wildcard' || {
|
|
echo "FAIL: a Certificate that is not Ready is not listed"; printf '%s\n' "$out"; false
|
|
}
|
|
if printf '%s\n' "$out" | grep -q 'tenant-a/api'; then
|
|
echo "FAIL: a Ready Certificate is listed as a problem"; false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary names a pod that cannot pull its image and skips the healthy ones" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
rows="$(printf '%s\n' \
|
|
'tenant-a puller-0 0/1 ImagePullBackOff 0 4m' \
|
|
'tenant-a puller-1 0/1 ErrImagePull 0 4m' \
|
|
'tenant-a fine-0 1/1 Running 0 4m')"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_POD_ROWS="$rows" sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# Scoped to the section, not to the whole document. Both of these pods are also
|
|
# not-Ready, so they appear under the pod listing above regardless, and an
|
|
# unscoped grep passes with this section deleted entirely.
|
|
section="$(printf '%s\n' "$out" | awk '/^## ImagePullBackOff/{f=1;next} f&&/^## /{exit} f')"
|
|
|
|
# Both spellings, because the section matches an alternation and dropping either
|
|
# half leaves the other passing. ErrImagePull is the one a cluster shows first.
|
|
printf '%s\n' "$section" | grep -q 'puller-0' || { echo "FAIL: ImagePullBackOff not named"; printf '%s\n' "$section"; false; }
|
|
printf '%s\n' "$section" | grep -q 'puller-1' || { echo "FAIL: ErrImagePull not named"; printf '%s\n' "$section"; false; }
|
|
if printf '%s\n' "$section" | grep -q 'fine-0'; then
|
|
echo "FAIL: a running pod appears under the image-pull heading"; false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary separates a CRD it may not read from one that is absent" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
|
|
absent="$(PATH="$tmp:$PATH" sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
refused="$(PATH="$tmp:$PATH" STUB_CRD_FORBIDDEN=1 sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# An absent CRD is not a finding: most clusters do not run every optional
|
|
# component, and a line about each one would be noise that trains the reader to
|
|
# skip the section.
|
|
if printf '%s\n' "$absent" | grep -q 'presence check failed'; then
|
|
echo "FAIL: reported an absent CRD as a failure"
|
|
false
|
|
fi
|
|
# A refusal skips the section identically, and only kubectl's message separates
|
|
# "not installed" from "not allowed to look" -- the difference between a cluster
|
|
# without cert-manager and a report taken with a token that cannot see it.
|
|
if ! printf '%s\n' "$refused" | grep -q 'presence check failed'; then
|
|
echo "FAIL: a refused CRD probe was indistinguishable from an absent CRD"
|
|
false
|
|
fi
|
|
printf '%s\n' "$refused" | grep -qi 'forbidden'
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary keeps the whole helmrelease failure message" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
export STUB_CRD_PRESENT=1
|
|
# Padded to kubectl's own column widths. With single spaces the row survives an
|
|
# awk that mishandles the field skip, so the fixture has to look like real output.
|
|
export STUB_HR_ROWS='cozy-keycloak keycloak 9d False Helm upgrade failed for release cozy-keycloak/keycloak: timeout waiting for the condition'
|
|
|
|
out="$(PATH="$tmp:$PATH" sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# STATUS is the Ready condition's message, a whole sentence -- see the printer
|
|
# columns on the CRD this repo ships. Taking one field renders every failed
|
|
# release as "Helm" and drops the part that says what broke, in the first section
|
|
# of the first file a triager opens.
|
|
#
|
|
# The whole rendered line is pinned, not just a substring of the message: an awk
|
|
# that skips the wrong number of fields leaves the namespace and name in the
|
|
# message too, and every substring assertion still passes in that state.
|
|
rendered=$(printf '%s\n' "$out" | grep -F 'cozy-keycloak/keycloak —')
|
|
[ "$rendered" = " cozy-keycloak/keycloak — Helm upgrade failed for release cozy-keycloak/keycloak: timeout waiting for the condition" ] || {
|
|
echo "FAIL: rendered line is not the namespace, name and message alone:"
|
|
printf '%s\n' "[$rendered]"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary ends with a newline" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
|
|
# The node section is the last thing the file prints, so it has to produce output
|
|
# for this to mean anything.
|
|
export STUB_NODE_ROWS='node0 True False False'
|
|
|
|
PATH="$tmp:$PATH" sh "$HACK_DIR/cozyreport-summary.sh" > "$tmp/summary.txt" 2>/dev/null
|
|
|
|
grep -q 'node0' "$tmp/summary.txt"
|
|
|
|
# Command substitution strips the trailing newline, so a section that prints
|
|
# $READ_OUT with a bare %s leaves the file ending mid-line.
|
|
[ -z "$(tail -c 1 "$tmp/summary.txt")" ]
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary says how many not ready helmreleases its listing left out" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
export STUB_CRD_PRESENT=1
|
|
export STUB_HR_ROWS="$(i=1; while [ "$i" -le 45 ]; do printf 'ns-%s hr-%s 1 False Helm upgrade failed for release ns-%s/hr-%s: timeout waiting for the condition\n' "$i" "$i" "$i" "$i"; i=$((i + 1)); done)"
|
|
|
|
out="$(PATH="$tmp:$PATH" sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# An install that fails early leaves well over 40 HelmReleases not Ready, and a
|
|
# listing that stops at 40 in silence reads as the complete set. Same defect the
|
|
# pod section one heading below was already fixed for.
|
|
printf '%s\n' "$out" | grep -q '5 more not-Ready HelmRelease(s) not listed'
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary distinguishes an absent crd from a check that was killed" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
|
|
absent="$(PATH="$tmp:$PATH" sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
killed="$(PATH="$tmp:$PATH" STUB_ALL_TIMEOUT=1 sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# `if $BOUND kubectl get crd X` collapses "not installed" and "could not ask"
|
|
# into one skip, and a slow aggregated discovery layer -- the state that made the
|
|
# bound necessary -- then hides the section as though the CRD were absent.
|
|
if printf '%s\n' "$absent" | grep -q 'presence check did not return'; then
|
|
echo "FAIL: called an absent CRD a failed check"
|
|
false
|
|
fi
|
|
printf '%s\n' "$killed" | grep -q 'presence check did not return'
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary lists a flux source that is not ready" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
export STUB_CRD_PRESENT=1
|
|
export STUB_FLUX_ROWS="$(printf '%s\n' \
|
|
'cozy-system cozystack-packages True' \
|
|
'cozy-system broken-repo False')"
|
|
|
|
out="$(PATH="$tmp:$PATH" sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# This section printed nothing on every report ever taken: its jsonpath nested a
|
|
# filter inside a filter, which kubectl rejects with "unterminated filter", and
|
|
# discarding stderr and the exit status hides it completely. An empty section
|
|
# reads as "all Flux sources are Ready", inside the file whose job is to say what
|
|
# is broken.
|
|
printf '%s\n' "$out" | sed -n '/## Flux Sources/,/^## /p' | grep -q 'broken-repo'
|
|
if printf '%s\n' "$out" | sed -n '/## Flux Sources/,/^## /p' | grep -q 'cozystack-packages'; then
|
|
echo "FAIL: listed a Ready source"
|
|
false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary says the pod list failed rather than showing an empty section" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_PODLIST_FAIL=refuse sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# summary.txt is the file a triager opens first. A refused pod list renders
|
|
# byte-identical to a healthy cluster there, which is the one run on which this
|
|
# section must not be the thing that says nothing is wrong.
|
|
printf '%s\n' "$out" | grep -q 'NOT because every pod was Ready'
|
|
printf '%s\n' "$out" | grep -q 'pods is forbidden'
|
|
# Every section derived from that same read has to inherit its failure. The
|
|
# ImagePullBackOff section is byte-identical between a healthy cluster and a
|
|
# refused list otherwise, and it is a heading a triager jumps straight to.
|
|
printf '%s\n' "$out" | sed -n '/## ImagePullBackOff/,/^## /p' | grep -q 'did not return'
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary lists the tenant pods before the platform ones" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
rows="$(printf '%s\n' \
|
|
'cozy-a pod-a 0/1 Running 0 5m' \
|
|
'tenant-test the-pod-the-run-died-on 0/1 Running 0 5m')"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_POD_ROWS="$rows" sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# Shares the collector's ordering, so the rows the summary's cap keeps are the
|
|
# rows whose evidence was actually collected.
|
|
# Both line numbers are captured before they are compared: a missing row makes
|
|
# `grep -n` empty, and `[ "" -lt "" ]` aborts the test body rather than failing
|
|
# it, which bats reports as a test that never ran instead of one that broke. A
|
|
# guard whose failure mode is to disappear is the same silent green this whole
|
|
# change is about.
|
|
pos_tenant=$(printf '%s\n' "$out" | grep -n 'the-pod-the-run-died-on' | head -n 1 | cut -d: -f1)
|
|
pos_cozy=$(printf '%s\n' "$out" | grep -n 'pod-a' | head -n 1 | cut -d: -f1)
|
|
if [ -z "$pos_tenant" ] || [ -z "$pos_cozy" ]; then
|
|
echo "FAIL: expected both rows listed, got tenant='$pos_tenant' cozy='$pos_cozy'"
|
|
false
|
|
fi
|
|
[ "$pos_tenant" -lt "$pos_cozy" ]
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary works without timeout and does not call that a cluster failure" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
# Only the binaries the summary genuinely needs, and deliberately no `timeout`.
|
|
# Resolved from absolute candidates rather than through `command -v`, which in an
|
|
# interactive shell can answer with a function name and leave a broken symlink.
|
|
for b in sh date awk grep head sed tail cut mktemp rm cat dirname; do
|
|
for d in /bin /usr/bin; do
|
|
[ -x "$d/$b" ] && { ln -sf "$d/$b" "$tmp/$b"; break; }
|
|
done
|
|
done
|
|
if [ -e "$tmp/timeout" ]; then echo "FAIL: the stub PATH must not provide timeout"; false; fi
|
|
|
|
out="$(PATH="$tmp" STUB_POD_ROWS='cozy-system broken-0 0/1 Running 0 5m' sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# Every read exits 127 without the fallback, and the section then reports that
|
|
# the cluster did not answer on a host whose only problem is a missing local
|
|
# binary. The collector says COLLECTION-UNBOUNDED.txt for that case, and the
|
|
# two must not contradict each other inside one tarball.
|
|
printf '%s\n' "$out" | grep -q 'broken-0'
|
|
if printf '%s\n' "$out" | grep -q 'NOT because every pod was Ready'; then echo "FAIL: blamed the cluster for a missing local timeout"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary lists no pod at all when nothing is broken" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_POD_ROWS='' sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
# An empty selection still reaches awk as one blank line. Printing it yields a
|
|
# nameless broken pod — on every green run, and on the run where the pod list
|
|
# itself failed, which is exactly when the summary must not invent findings.
|
|
if printf '%s\n' "$out" | grep -q '^ / —'; then echo "FAIL: invented a nameless not-Ready pod"; false; fi
|
|
if printf '%s\n' "$out" | grep -q 'ready= '; then echo "FAIL: printed a row of blank fields"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "summary prints no overflow line when everything fits" {
|
|
tmp=$(mktemp -d)
|
|
summary_stub_dir "$tmp"
|
|
rows='cozy-system puller-z 0/1 ImagePullBackOff 0 4m'
|
|
|
|
out="$(PATH="$tmp:$PATH" STUB_POD_ROWS="$rows" sh "$HACK_DIR/cozyreport-summary.sh" 2>/dev/null)"
|
|
|
|
if printf '%s\n' "$out" | grep -q 'more not-Ready pod(s) not listed'; then echo "FAIL: claimed an overflow that did not happen"; false; fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "pods not ready reads readiness from the ready column not the status text" {
|
|
# The split has to read $3. Proving that needs rows the split actually reaches,
|
|
# which means STATUS=Running: anything else is selected by the phase rule above
|
|
# it and never gets there, so a fixture like `Init:0/2` pins nothing at all no
|
|
# matter what its other columns say.
|
|
#
|
|
# `1/1 Running` with a slash-bearing STATUS is the row that decides it. Point
|
|
# the split at $4 and `Running` has no slash, so `split()` returns 1, the
|
|
# condition is false, and a healthy pod is dropped -- which is what the first
|
|
# assertion catches. The `0/1 Running` row on its own would survive that swap.
|
|
ready='tenant-test settled-0 1/1 Running 0 9m'
|
|
unready='tenant-test probing-0 0/1 Running 0 3m'
|
|
|
|
out="$(printf '%s\n' "$unready" | cozyreport_pods_not_ready)"
|
|
printf '%s\n' "$out" | grep -q 'probing-0' || {
|
|
echo "FAIL: a pod whose readiness probe never passed was dropped"
|
|
false
|
|
}
|
|
|
|
out="$(printf '%s\n' "$ready" | cozyreport_pods_not_ready)"
|
|
if printf '%s\n' "$out" | grep -q 'settled-0'; then
|
|
echo "FAIL: a fully ready running pod was selected"
|
|
false
|
|
fi
|
|
}
|
|
|
|
# Stub PATH for the linstor module: `kubectl exec` runs the command after `--` on
|
|
# this machine, where `linstor`, `zpool` and `zfs` are stubs that print what they
|
|
# were asked, and the real `jq` reads the stub's storage-pool JSON. That runs the
|
|
# shells the controller and satellite execs actually send, rather than matching
|
|
# their text.
|
|
linstor_module_stub_dir() {
|
|
_ld=$1
|
|
mkdir -p "$_ld/bin"
|
|
cat > "$_ld/bin/kubectl" <<'STUB'
|
|
#!/bin/sh
|
|
case "$*" in
|
|
"get deploy -n cozy-linstor linstor-controller") exit 0 ;;
|
|
*"get pods -l app.kubernetes.io/component=linstor-satellite -o name") echo pod/linstor-satellite-a; exit 0 ;;
|
|
*"get pod/linstor-satellite-a -o jsonpath="*) [ -z "${STUB_NODENAME_FAIL:-}" ] || exit 1; printf 'srv1'; exit 0 ;;
|
|
esac
|
|
while [ $# -gt 0 ] && [ "$1" != "--" ]; do shift; done
|
|
[ $# -gt 0 ] || exit 1
|
|
shift
|
|
exec "$@"
|
|
STUB
|
|
cat > "$_ld/bin/linstor" <<'STUB'
|
|
#!/bin/sh
|
|
case "$*" in
|
|
"--machine-readable sp l")
|
|
[ -z "${STUB_SP_FAIL:-}" ] || { echo 'ERROR: unable to connect to linstor://localhost:3370' >&2; exit 10; }
|
|
printf '%s\n' "$STUB_SP_JSON" ;;
|
|
*) echo "linstor $*" ;;
|
|
esac
|
|
STUB
|
|
cat > "$_ld/bin/zpool" <<'STUB'
|
|
#!/bin/sh
|
|
echo "zpool $*"
|
|
for a; do :; done; echo "zpool last argument: <$a>"
|
|
for a; do [ "$a" != "${STUB_MISSING:-}" ] || { echo "cannot open '$a': no such pool" >&2; exit 1; }; done
|
|
STUB
|
|
cat > "$_ld/bin/zfs" <<'STUB'
|
|
#!/bin/sh
|
|
[ -z "${STUB_ZFS_FAIL:-}" ] || { echo 'cannot open dataset: permission denied' >&2; exit 1; }
|
|
echo "zfs $*"
|
|
for a; do :; done; echo "zfs last argument: <$a>"
|
|
[ -z "${STUB_MISSING:-}" ] || for a; do
|
|
case $a in "$STUB_MISSING"|"$STUB_MISSING"/*) echo "cannot open '$a': dataset does not exist" >&2; exit 1 ;; esac
|
|
done
|
|
STUB
|
|
chmod +x "$_ld/bin/kubectl" "$_ld/bin/linstor" "$_ld/bin/zpool" "$_ld/bin/zfs"
|
|
}
|
|
|
|
# The storage pools one cluster reports: a thick ZFS pool on srv1, a thin one
|
|
# backed by a dataset inside a zpool rather than a whole zpool, a diskless pool,
|
|
# an LVM pool whose backing name zpool and zfs must never see, and srv2's own
|
|
# pool, which srv1's listing must not carry. The backing name is under
|
|
# StorDriver/StorPoolName, the one key the controller keeps: its REST layer
|
|
# renames StorDriver/ZPool and StorDriver/ZPoolThin to it on create.
|
|
LINSTOR_SP_JSON='[[
|
|
{"storage_pool_name":"DfltDisklessStorPool","node_name":"srv1","provider_kind":"DISKLESS","props":{}},
|
|
{"storage_pool_name":"data","node_name":"srv1","provider_kind":"ZFS","props":{"StorDriver/StorPoolName":"data-srv1"}},
|
|
{"storage_pool_name":"thin","node_name":"srv1","provider_kind":"ZFS_THIN","props":{"StorDriver/StorPoolName":"tank/thin"}},
|
|
{"storage_pool_name":"lvm","node_name":"srv1","provider_kind":"LVM_THIN","props":{"StorDriver/StorPoolName":"vg/thin"}},
|
|
{"storage_pool_name":"data","node_name":"srv2","provider_kind":"ZFS","props":{"StorDriver/StorPoolName":"data-srv2"}}
|
|
]]'
|
|
|
|
@test "the linstor module lists the datasets of the zfs pools linstor uses on each node" {
|
|
# Only the dataset list names what holds a pool's space. Exact bytes and
|
|
# `avail`, so the sizes of leftover datasets can be summed against the pool
|
|
# root's `avail`; a rounded size hides a sub-GiB difference on a 100 GiB pool.
|
|
# It is taken on green jobs too, so a red one has a listing to compare.
|
|
tmp=$(mktemp -d)
|
|
linstor_module_stub_dir "$tmp"
|
|
body=$(awk '/^# -- linstor module/,/^# -- sandbox-host module/' "$SCRIPT" | sed '$d' | fold_source /dev/stdin)
|
|
[ -n "$body" ] || { echo "FAIL: could not locate the linstor module"; false; }
|
|
|
|
REPORT_DIR="$tmp/report"
|
|
export REPORT_DIR
|
|
( export STUB_SP_JSON="$LINSTOR_SP_JSON" COZYREPORT_BOUND="" PATH="$tmp/bin:$PATH"; eval "$body" ) >/dev/null 2>&1
|
|
|
|
grep -qx 'linstor --no-color v l' "$REPORT_DIR/linstor/volumes.txt" 2>/dev/null || {
|
|
echo "FAIL: the LINSTOR volume list was not recorded"; false; }
|
|
out="$REPORT_DIR/linstor/zfs/srv1.txt"
|
|
grep -qx 'zpool get -p freeing,leaked,allocated,free data-srv1 tank' "$out" 2>/dev/null || {
|
|
echo "FAIL: the pool totals and pending frees of srv1's pools were not read exactly"
|
|
cat "$out" 2>/dev/null
|
|
false
|
|
}
|
|
grep -qx 'zfs list -p -t all -o name,used,avail,refer,origin,creation -r data-srv1 tank/thin' "$out" || {
|
|
echo "FAIL: srv1's pools were not listed in exact bytes with every dataset type and avail"
|
|
cat "$out"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a pool name with a space reaches zpool and zfs as one argument" {
|
|
# ZFS allows a space in pool and dataset names, so such a pool must be read,
|
|
# and its name must not be split on the way to the satellite.
|
|
tmp=$(mktemp -d)
|
|
linstor_module_stub_dir "$tmp"
|
|
body=$(awk '/^# -- linstor module/,/^# -- sandbox-host module/' "$SCRIPT" | sed '$d' | fold_source /dev/stdin)
|
|
REPORT_DIR="$tmp/report"
|
|
export REPORT_DIR
|
|
spaced='[[{"storage_pool_name":"data","node_name":"srv1","provider_kind":"ZFS","props":{"StorDriver/StorPoolName":"tank data/thin"}}]]'
|
|
( export STUB_SP_JSON="$spaced" COZYREPORT_BOUND="" PATH="$tmp/bin:$PATH"; eval "$body" ) >/dev/null 2>&1
|
|
|
|
out="$REPORT_DIR/linstor/zfs/srv1.txt"
|
|
grep -qx 'zpool last argument: <tank data>' "$out" 2>/dev/null || {
|
|
echo "FAIL: zpool did not get the spaced pool as one argument"
|
|
cat "$out" 2>/dev/null
|
|
false
|
|
}
|
|
grep -qx 'zfs last argument: <tank data/thin>' "$out" || {
|
|
echo "FAIL: zfs did not get the spaced dataset as one argument"
|
|
cat "$out"
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a node linstor names no zfs pool for gets a note and no zfs read" {
|
|
# `zfs list -r` with no pool argument lists every pool on the machine, so a
|
|
# node with nothing to scope it to must not reach zfs at all.
|
|
tmp=$(mktemp -d)
|
|
linstor_module_stub_dir "$tmp"
|
|
body=$(awk '/^# -- linstor module/,/^# -- sandbox-host module/' "$SCRIPT" | sed '$d' | fold_source /dev/stdin)
|
|
REPORT_DIR="$tmp/report"
|
|
export REPORT_DIR
|
|
# srv1's ZFS rows carry no backing name, an empty one or a blank one, and
|
|
# none may become a blank pool argument.
|
|
only_srv2='[[{"storage_pool_name":"odd","node_name":"srv1","provider_kind":"ZFS","props":{}},
|
|
{"storage_pool_name":"empty","node_name":"srv1","provider_kind":"ZFS","props":{"StorDriver/StorPoolName":""}},
|
|
{"storage_pool_name":"space","node_name":"srv1","provider_kind":"ZFS","props":{"StorDriver/StorPoolName":" "}},
|
|
{"storage_pool_name":"tab","node_name":"srv1","provider_kind":"ZFS","props":{"StorDriver/StorPoolName":"\t"}},
|
|
{"storage_pool_name":"data","node_name":"srv2","provider_kind":"ZFS","props":{"StorDriver/StorPoolName":"data-srv2"}}]]'
|
|
( export STUB_SP_JSON="$only_srv2" COZYREPORT_BOUND="" PATH="$tmp/bin:$PATH"; eval "$body" ) >/dev/null 2>&1
|
|
|
|
out="$REPORT_DIR/linstor/zfs/srv1.txt"
|
|
grep -q 'names no ZFS pool for node srv1' "$out" 2>/dev/null || {
|
|
echo "FAIL: a node without a ZFS pool in LINSTOR did not say so"
|
|
cat "$out" 2>/dev/null
|
|
false
|
|
}
|
|
if grep -q '^zfs \|^zpool ' "$out"; then
|
|
echo "FAIL: zfs or zpool ran with no pool to scope it"; false
|
|
fi
|
|
|
|
# The storage-pool read itself failing leaves the same note, and its own
|
|
# file carries LINSTOR's message rather than an empty table.
|
|
rm -rf "$REPORT_DIR"
|
|
( export STUB_SP_FAIL=1 COZYREPORT_BOUND="" PATH="$tmp/bin:$PATH"; eval "$body" ) >/dev/null 2>&1
|
|
grep -q 'unable to connect to linstor' "$REPORT_DIR/linstor/zfs-pools.txt" 2>/dev/null || {
|
|
echo "FAIL: a failed storage-pool read left no message in its file"
|
|
cat "$REPORT_DIR/linstor/zfs-pools.txt" 2>/dev/null
|
|
false
|
|
}
|
|
grep -q 'names no ZFS pool for node srv1' "$REPORT_DIR/linstor/zfs/srv1.txt" 2>/dev/null || {
|
|
echo "FAIL: a failed storage-pool read left the node without a note"; false; }
|
|
|
|
# Output that is not JSON fails the read with jq's status, rather than
|
|
# passing as an empty table.
|
|
rm -rf "$REPORT_DIR"
|
|
( export STUB_SP_JSON='not json' COZYREPORT_BOUND="" PATH="$tmp/bin:$PATH"; eval "$body" ) >/dev/null 2>&1
|
|
grep -q 'zfs-pools.txt' "$REPORT_DIR/linstor/COLLECTION-FAILED.txt" 2>/dev/null || {
|
|
echo "FAIL: a storage-pool listing jq could not parse was not recorded as a failed read"
|
|
cat "$REPORT_DIR/linstor/COLLECTION-FAILED.txt" 2>/dev/null
|
|
false
|
|
}
|
|
grep -q 'names no ZFS pool for node srv1' "$REPORT_DIR/linstor/zfs/srv1.txt" 2>/dev/null || {
|
|
echo "FAIL: an unparsable storage-pool listing left the node without a note"; false; }
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a satellite whose node name cannot be read says so instead of blaming the pool map" {
|
|
# The fallback key is the pod name, which never matches a LINSTOR node, so the
|
|
# note must name the unread spec.nodeName rather than send the reader to a
|
|
# zfs-pools.txt that does list the node. The module does not run under set -e,
|
|
# so neither does this.
|
|
tmp=$(mktemp -d)
|
|
linstor_module_stub_dir "$tmp"
|
|
body=$(awk '/^# -- linstor module/,/^# -- sandbox-host module/' "$SCRIPT" | sed '$d' | fold_source /dev/stdin)
|
|
REPORT_DIR="$tmp/report"
|
|
export REPORT_DIR
|
|
( set +e; export STUB_SP_JSON="$LINSTOR_SP_JSON" STUB_NODENAME_FAIL=1 COZYREPORT_BOUND="" PATH="$tmp/bin:$PATH"; eval "$body" ) >/dev/null 2>&1
|
|
|
|
out="$REPORT_DIR/linstor/zfs/linstor-satellite-a.txt"
|
|
grep -q 'spec.nodeName of pod/linstor-satellite-a could not be read' "$out" 2>/dev/null || {
|
|
echo "FAIL: an unread node name was not named as the reason for the missing listing"
|
|
cat "$out" 2>/dev/null
|
|
false
|
|
}
|
|
if grep -q 'names no ZFS pool' "$out"; then
|
|
echo "FAIL: an unread node name was blamed on zfs-pools.txt"; false
|
|
fi
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a pool that cannot be opened costs neither the other pools' listing nor a cut-short marker" {
|
|
# zpool and zfs both go on past a pool they cannot open, list the rest and exit
|
|
# 1. That is about the satellite, not about which pools LINSTOR uses, and the
|
|
# listing of the pools that are fine is complete.
|
|
tmp=$(mktemp -d)
|
|
linstor_module_stub_dir "$tmp"
|
|
body=$(awk '/^# -- linstor module/,/^# -- sandbox-host module/' "$SCRIPT" | sed '$d' | fold_source /dev/stdin)
|
|
REPORT_DIR="$tmp/report"
|
|
export REPORT_DIR
|
|
( export STUB_SP_JSON="$LINSTOR_SP_JSON" STUB_MISSING=tank COZYREPORT_BOUND="" PATH="$tmp/bin:$PATH"; eval "$body" ) >/dev/null 2>&1
|
|
|
|
out="$REPORT_DIR/linstor/zfs/srv1.txt"
|
|
if grep -q 'names no ZFS pool' "$out" 2>/dev/null; then
|
|
echo "FAIL: a failing zpool was reported as a node without a pool"
|
|
cat "$out"
|
|
false
|
|
fi
|
|
grep -q "cannot open 'tank': no such pool" "$out" 2>/dev/null || {
|
|
echo "FAIL: the failing zpool's message was not recorded in the listing"
|
|
cat "$out"
|
|
false
|
|
}
|
|
grep -qx 'zfs list -p -t all -o name,used,avail,refer,origin,creation -r data-srv1 tank/thin' "$out" || {
|
|
echo "FAIL: a failing zpool get dropped the dataset listing"
|
|
cat "$out"
|
|
false
|
|
}
|
|
if grep -q 'TRUNCATED' "$out"; then
|
|
echo "FAIL: a whole listing was marked as cut short because one pool could not be opened"
|
|
cat "$out"
|
|
false
|
|
fi
|
|
grep -q '^# \[cozyreport\] EXIT 1 after this output' "$out" || {
|
|
echo "FAIL: zfs list's non-zero exit was not marked in the listing"
|
|
cat "$out"
|
|
false
|
|
}
|
|
grep -q '^# \[cozyreport\] zpool get exited 1' "$out" || {
|
|
echo "FAIL: zpool get's status was not recorded in the listing"
|
|
cat "$out"
|
|
false
|
|
}
|
|
grep -q "cannot open 'tank/thin': dataset does not exist" "$REPORT_DIR/linstor/zfs/COLLECTION-FAILED.txt" 2>/dev/null || {
|
|
echo "FAIL: the dataset zfs list could not open was not recorded beside the listing"
|
|
cat "$REPORT_DIR/linstor/zfs/COLLECTION-FAILED.txt" 2>/dev/null
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
@test "a zfs list that fails is recorded as a failed read" {
|
|
tmp=$(mktemp -d)
|
|
linstor_module_stub_dir "$tmp"
|
|
body=$(awk '/^# -- linstor module/,/^# -- sandbox-host module/' "$SCRIPT" | sed '$d' | fold_source /dev/stdin)
|
|
REPORT_DIR="$tmp/report"
|
|
export REPORT_DIR
|
|
( export STUB_SP_JSON="$LINSTOR_SP_JSON" STUB_ZFS_FAIL=1 COZYREPORT_BOUND="" PATH="$tmp/bin:$PATH"; eval "$body" ) >/dev/null 2>&1
|
|
grep -q 'cannot open dataset: permission denied' "$REPORT_DIR/linstor/zfs/COLLECTION-FAILED.txt" 2>/dev/null || {
|
|
echo "FAIL: a failing zfs list was not recorded as a failed read"
|
|
cat "$REPORT_DIR/linstor/zfs/COLLECTION-FAILED.txt" 2>/dev/null
|
|
false
|
|
}
|
|
rm -rf "$tmp"
|
|
}
|
|
|