Files
cozystack/hack/cozyreport-summary.sh
Aleksei Sviridkin e80b8e370f fix(e2e): collect logs for unready pods and record truncated captures
The failure snapshot selected pods by their STATUS column, so a pod
whose container runs while its readiness probe never passes -- STATUS
Running, READY 0/1 -- was dropped from the report entirely. That is a
common way for a suite to time out: the replica never joins, its owning
HelmRelease sits in InProgress, and the artifact carries no trace of the
pod the run died on. Select on the READY column instead, which also
covers the partially ready multi-container case, and keep every phase
that was collected before. On one degraded cluster that is four of the
seven broken pods the old filter reported nothing about. The VMI and VM
sections had the same blind spot and now share one selector that reads
READY as the last field, because a Pending object prints fewer columns
than a Running one and the two kinds differ only in how many columns
precede READY. READY counts ready containers rather than the pod's Ready
condition, so a pod held back by an unsatisfied readiness gate is out of
scope; a test pins that rather than leaving it to be found in an empty
directory.

Logs are requested for every container of a selected pod rather than
skipped for Pending ones, and the per-pod collection moved into a helper
so it can be driven against a stub kubectl. kubectl's refusal is the
finding, and an empty result is ambiguous on its own: a read against an
unscheduled pod exits 0 having printed nothing, which is what a
container that started and stayed quiet also leaves. Every site that
classifies a failed read now orders its branches the same way, naming
the timeout first where both a cutoff and an uncapturable stderr apply.
The CRD gate in the summary had no such branch at all: it returned in
silence, which is byte-identical to the CRD being absent, so every
section behind that gate vanished from a report whose only real problem
was that the machine writing it had nowhere to put a temp file.

Collection used to stop silently. A walk that hit its cap said so in the
job log and nowhere in the artifact. Now the pod walk leaves
COLLECTION-TRUNCATED.txt when it stops at COZY_REPORT_PODS_MAX or
COZY_REPORT_PODS_BUDGET, CONTAINER-LOGS-TRUNCATED.txt on a pod cut off
part way through, COLLECTION-FAILED.txt when the pod list never came
back rather than the empty tree a healthy cluster produces, and
COLLECTION-UNBOUNDED.txt when timeout is missing locally, which is a
fact about the machine writing the report and not about the cluster.
Tenant namespaces are collected first, so a cap that only bites on a
broadly degraded cluster is not spent on platform pods while the pod the
suite died on falls off the end.

Container logs are read with --tail=2000 where they were previously read
whole, and every bounded file states the bound it was written under,
because --tail drops the oldest lines and nothing else in the output
would say so. COZY_REPORT_POD_TAIL=-1 restores the complete log.

The bounded whole-object read no longer merges stderr into pod.yaml,
vm.yaml and vmi.yaml, which are read by yq rather than by eye: a
warning on an otherwise complete read left an unparseable file. stderr
goes to a scratch file and is copied in only when kubectl returned no
object, and the truncation markers those files carry are written as YAML
comments, since a bare marker line after a complete mapping is a second
top-level node.

Six latent faults surfaced while making these reads report. The kamaji
module was gated on the LINSTOR controller, so a cluster without LINSTOR
got no kamaji evidence at all, which is what a tenant-kubernetes
bootstrap timeout needs most. The Flux sources section nested one
jsonpath filter inside another, which kubectl rejects outright, so it
printed nothing on every report ever taken and read as "all sources
Ready". The HelmRelease listing took a single field as STATUS where
STATUS is the Ready condition's whole message, so every failed release
rendered as one word. The services section filtered for a pending
LoadBalancer address on the CLUSTER-IP column, which never carries that
value. The cozystack module read its image from a Deployment the
installer stopped shipping when it moved to the operator, putting
kubectl's NotFound in the first file of the report. And the cozystack
apps section gated all three of its kinds on a CRD in an aggregated API
group, so it never ran once; two of those names were wrong, and both
are collected again.

The tests avoid EXIT-trap cleanup, and e2e-testing.md says why: a bats
test that installs one and then fails prints no TAP line at all, so
output grepped for `not ok` reads as green, while hack/cozytest.sh
reports the same failure correctly. A guard enforces that over a named
list of files rather than a glob with exceptions, because a list of
exceptions is the same claim written so that forgetting to shorten it
makes it false.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-05 17:37:04 +03:00

350 lines
19 KiB
Bash
Executable File

#!/bin/sh
# Emit a human-readable summary of "what is broken" to a single file.
# Reads the live cluster (not the report dir) so it can use kubectl JSONPath.
# Usage: cozyreport-summary.sh > summary.txt
#
# Environment:
# COZY_REPORT_PODS_MAX how many not-Ready pods this summary lists before it
# reports the rest as an overflow (default 40, shared with
# the collector so the two never start from different
# numbers; the counts can still differ when the collector
# stops on its time budget, and its
# COLLECTION-TRUNCATED.txt explains that). A malformed value
# falls back to the default and says so in the section.
# Note it does not bound the HelmRelease listing, which has
# its own cap at the same default.
# COZY_REPORT_PREFER_NS namespace prefix listed first (default `tenant-`; empty
# disables the ordering). Reached through the selection
# helpers sourced from hack/cozyreport.sh rather than read
# by name here, so it appears nowhere in this file's CODE
# (only in this block) -- which is exactly why it went
# undocumented:
# a knob arriving by `.` is invisible to a guard that greps
# for its name. It changes which pods this summary lists
# first, and the collector uses the same value, so the two
# orderings stay in step.
set -eu
# Share the pod-selection helper with cozyreport.sh rather than restating it:
# the summary and the collected evidence disagreeing about which pods are broken
# is worse than either being wrong on its own. COZYREPORT_LIB makes the source
# return before it starts a report of its own.
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
# shellcheck disable=SC2034 # read by the sourcing guard in cozyreport.sh
COZYREPORT_LIB=1
# shellcheck source=hack/cozyreport.sh
. "$SCRIPT_DIR/cozyreport.sh"
# The wall-clock wrapper, taken from cozyreport.sh rather than resolved again so
# the two cannot hold different values. Empty when `timeout` is absent; the reads
# then run unbounded and the header says so.
BOUND=$COZYREPORT_BOUND
# How many rows any one section lists before it reports the rest as an overflow.
# Shares the collector's default so the two never START from different numbers.
# The counts can still differ, and legitimately: the collector also stops on its
# time budget, so it may hold fewer pods than this lists. Its own
# COLLECTION-TRUNCATED.txt says so; see the note on the pod listing below.
SUMMARY_CAP=$COZYREPORT_PODS_DEFAULT
# summary_read <what> <kubectl args...>
#
# One bounded read into READ_OUT. On failure it prints why the section is empty,
# because a killed read otherwise renders the section byte-identical to a healthy
# cluster -- and an empty "## HelmReleases not Ready" is the most misleading line
# this file can carry. CRD probes go through summary_crd, where a non-zero status
# has two meanings.
summary_read() {
_sr_what=$1
shift
# kubectl's own reason is kept rather than sent to /dev/null, for the reason the
# pod list below already keeps its own: "did not return (exit 1)" names a symptom
# and no cause, and the difference between an apiserver that is too slow and one
# answering "forbidden" is the whole finding. Discarding it here while the pod-list
# read below keeps its own would have two reads in one file answering the same
# question differently.
_sr_err=$(mktemp "${TMPDIR:-/tmp}/cozyreport-summary.XXXXXX" 2>/dev/null) || _sr_err=""
# The status is taken in an explicit `else`, not after the `if`: a compound `if`
# whose condition fails and which has no else leaves `$?` at 0, so reading it
# afterwards reports "exit 0" for every failure.
# shellcheck disable=SC2086 # empty BOUND must vanish, not become ""
if READ_OUT=$($BOUND "$@" 2>"${_sr_err:-/dev/null}"); then
[ -z "$_sr_err" ] || rm -f "$_sr_err"
return 0
else
_sr_rc=$?
fi
# The rows kubectl printed before it died are kept: they are the only ones it
# named, and before this refactor they flowed down the pipe and were processed.
# Discarding them turns a partial answer into no answer and then says nothing
# was readable, which is false in the most useful direction.
if [ -n "$READ_OUT" ]; then
echo " the $_sr_what read did not return (exit $_sr_rc); the rows below are the ones it printed before it stopped, and there may be others it never reached"
else
echo " the $_sr_what read did not return (exit $_sr_rc), so this section is empty because nothing could be read, NOT because there was nothing to report"
fi
# LAST line, not the first, matching the pod list: a failing read is preceded by
# one klog discovery-retry line per attempt and kubectl's own message comes after
# them. A read killed by its own timeout is killed before kubectl writes anything,
# so the file is empty and there is nothing to quote -- which is itself the
# difference between "too slow to answer" and "answered, and the answer was no".
if [ -n "$_sr_err" ] && [ -s "$_sr_err" ]; then
# printf, not echo: kubectl's text is untrusted and dash's echo expands
# backslash escapes, so a message carrying one could forge a line in a file a
# triager reads as fact.
printf ' kubectl said: %s\n' "$(tail -n 1 "$_sr_err" | cut -c1-200)"
fi
[ -z "$_sr_err" ] || rm -f "$_sr_err"
return 0
}
# summary_crd <crd>: true when the CRD is present, false otherwise. Absence is the
# only outcome that answers false in silence; a killed read, a refusal with a
# message, a refusal without one, and a message that could not be captured each
# say which of them happened before answering false.
#
# `if $BOUND kubectl get crd X` collapses those last two into one, and a slow
# aggregated discovery layer -- the state that made the bound necessary -- then
# skips the whole section as though the CRD were not installed.
summary_crd() {
# stderr kept for the same reason summary_read keeps it, but reported only on a
# killed read: a CRD that is simply absent also exits non-zero, with kubectl
# writing a NotFound to stderr, and quoting that would turn "this cluster does not
# run cert-manager" into a line that reads like a failure. The killed case is the
# one where the cause is not already implied by the outcome.
_sc_err=$(mktemp "${TMPDIR:-/tmp}/cozyreport-summary.XXXXXX" 2>/dev/null) || _sc_err=""
# shellcheck disable=SC2086
if $BOUND kubectl get crd "$1" >/dev/null 2>"${_sc_err:-/dev/null}"; then
[ -z "$_sc_err" ] || rm -f "$_sc_err"
return 0
else
_sc_rc=$?
fi
if cozyreport_timed_out "$_sc_rc"; then
echo " the $1 presence check did not return (exit $_sc_rc), so this section was skipped without knowing whether the CRD is installed"
elif [ -z "$_sc_err" ]; then
# No scratch file means no writable temp dir here, so kubectl's message went to
# /dev/null and is gone. Silence at this point would be the report's own local
# condition rendered as a cluster fact, and rendered as the most common one:
# every section this gate opens simply disappears, exactly as it does on a
# cluster that does not run the operator behind the CRD.
echo " the $1 presence check failed (exit $_sc_rc) and its message could not be captured (no writable temp dir on the machine producing this report), so this section was skipped without knowing whether the CRD is installed"
elif [ -s "$_sc_err" ]; then
# Not a timeout and not a plain NotFound: an RBAC refusal on the CRD read skips
# the section exactly like an absent CRD would, and only kubectl's message
# separates "not installed" from "not allowed to look".
if ! grep -qi 'not found' "$_sc_err"; then
printf ' the %s presence check failed, so this section was skipped without knowing whether the CRD is installed; kubectl said: %s\n' \
"$1" "$(tail -n 1 "$_sc_err" | cut -c1-200)"
fi
else
# Exited non-zero having said nothing. Not the same as NotFound, which kubectl
# is explicit about, so it cannot be filed under "the CRD is absent".
echo " the $1 presence check failed (exit $_sc_rc) without a message, so this section was skipped without knowing whether the CRD is installed"
fi
[ -z "$_sc_err" ] || rm -f "$_sc_err"
return 1
}
# Printed before the first section, not inside one: every read in this file goes
# through the same empty $BOUND, so a notice sitting under "## Pods not Ready"
# leaves the reader of "## HelmReleases not Ready" -- which is above it and is the
# first section anyone opens -- with no indication at all. The collector states the
# same fact for its whole tree in COLLECTION-UNBOUNDED.txt rather than per section.
if [ -z "$BOUND" ]; then
echo "(timeout is not on this host, so every read below is unbounded; this is a local dependency, not a cluster symptom)"
echo
fi
echo "# Cozystack E2E Diagnostic Summary"
echo "Generated: $(date -Iseconds)"
echo
echo "## HelmReleases not Ready"
echo
if summary_crd helmreleases.helm.toolkit.fluxcd.io; then
summary_read "HelmRelease" kubectl get hr -A --no-headers
# The whole STATUS field, not $5. `kubectl get hr` prints NAMESPACE NAME AGE
# READY STATUS, and STATUS is the Ready condition's *message* -- see the printer
# columns on the CRD this repo ships, internal/fluxinstall/manifests/fluxcd.yaml
# ("jsonPath: .status.conditions[?(@.type==\"Ready\")].message"). It is a whole
# sentence, so $5 rendered every failed release as "Helm" or "Reconciliation",
# dropping the part that says what broke, in the first section of the first file
# a triager opens. Take everything from the fifth field on.
# Rebuilt field by field rather than with a `{4}` interval expression: mawk
# 1.3.4, the awk on the CI image, mis-applies an exact interval to a
# variable-width group and strips one repetition instead of four. `sub()` still
# returns 1, so there is no error and no signal -- the row simply arrives with
# most of itself still in the message. Verified on ubuntu:24.04; gawk,
# one-true-awk and busybox awk all handle it, which is how it would have reached
# CI unnoticed.
HR_ROWS=$(printf '%s\n' "$READ_OUT" | awk 'NF && $4 != "True" {
msg = ""
for (i = 5; i <= NF; i++) msg = msg (i > 5 ? " " : "") $i
printf " %s/%s — %s\n", $1, $2, msg
}')
HR_TOTAL=$(printf '%s\n' "$HR_ROWS" | grep -c . || true)
if [ "$HR_TOTAL" -gt 0 ]; then
printf '%s\n' "$HR_ROWS" | head -n "$SUMMARY_CAP"
fi
# The cap is reported for the same reason the pod cap is: an install that fails
# early leaves well over 40 HelmReleases not Ready, and a listing that stops at 40
# without saying so reads as the complete set.
if [ "$HR_TOTAL" -gt "$SUMMARY_CAP" ]; then
echo " ... $((HR_TOTAL - SUMMARY_CAP)) more not-Ready HelmRelease(s) not listed here; the full set is in the report's kubernetes/helmreleases.txt"
fi
fi
echo
echo "## Pods not Ready"
echo
# AGE is read as $NF, not $6: a pod that has restarted prints RESTARTS as
# `7 (4m12s ago)`, three awk fields, which shifts every later column.
# A restarted pod is the normal case in a failed run, so $6 printed `age=(4m12s`
# for exactly the rows worth reading. AGE is last whichever form RESTARTS takes.
#
# The row cap is reported rather than applied in silence: this section is a
# reader's first answer to "what is broken", and a cap that hides the rest reads
# like there is no rest. The ordering is shared with the collector, so the rows
# this cap keeps are the rows whose evidence the collector reached for first --
# the same rows, not necessarily the same count, since the collector can also stop
# on its time budget, and then it holds fewer pods than are listed here. Its own
# COLLECTION-TRUNCATED.txt says so.
#
# The pod list is read on its own, keeping its exit status, before anything is
# selected from it. Piping it straight into the selection discards that status,
# and then a list the apiserver refused renders byte-identical to a cluster where
# every pod is Ready -- in the file a triager opens first, and on exactly the run
# where the summary must not be the thing that says nothing is wrong.
POD_LIST_ERR=$(mktemp "${TMPDIR:-/tmp}/cozyreport-summary.XXXXXX" 2>/dev/null) || POD_LIST_ERR=""
# Bounded like the collector's copy of this read. The summary runs immediately
# before the tarball is written, so a hang here costs the whole artifact rather
# than this one section, and `|| true` on the caller does not survive a hang.
#
# `timeout` is used when it is there and skipped when it is not, rather than being
# required: without the fallback every read here exits 127 and this section would
# report that the cluster did not answer, on a host where the only thing missing is
# a local coreutils binary. That is the same false claim the collector's
# COLLECTION-UNBOUNDED.txt exists to avoid, and the two must not disagree inside
# one tarball. It also keeps working where the old unbounded pipeline did.
# Unquoted on purpose: an empty BOUND must expand to no argument at all.
# shellcheck disable=SC2086
if POD_LIST=$($BOUND kubectl get pod -A --no-headers 2>"${POD_LIST_ERR:-/dev/null}"); then
POD_LIST_RC=0
else
POD_LIST_RC=$?
fi
if [ "$POD_LIST_RC" -ne 0 ]; then
# Two different failures, and the sentence has to match which one happened. A
# read that produced nothing leaves an empty section; a read that printed rows
# and then failed leaves the rows, and calling that section "empty" while the
# rows are listed three lines below puts two contradictory statements about the
# same read in one file. The partial rows are kept, as everywhere else in this
# tree, because they are the only evidence there is -- they are just not all of
# it, and the difference has to be said rather than implied.
if [ -n "$POD_LIST" ]; then
echo " the cluster-wide pod list did not return (kubectl exit $POD_LIST_RC); the pods listed below are the ones it printed before it stopped, and there may be others it never reached"
else
echo " the cluster-wide pod list did not return (kubectl exit $POD_LIST_RC), so this section is empty because nothing could be read, NOT because every pod was Ready"
fi
if [ -n "$POD_LIST_ERR" ] && [ -s "$POD_LIST_ERR" ]; then
# Last line: a failing cluster-wide list is preceded by one klog discovery
# retry line per attempt and kubectl's own message comes after them.
printf ' kubectl said: %s\n' "$(tail -n 1 "$POD_LIST_ERR" | cut -c1-200)"
fi
fi
[ -z "$POD_LIST_ERR" ] || rm -f "$POD_LIST_ERR"
POD_ROWS=$(printf '%s\n' "$POD_LIST" | cozyreport_pods_not_ready | cozyreport_pods_prioritize)
POD_TOTAL=$(printf '%s\n' "$POD_ROWS" | grep -c . || true)
POD_CAP=${COZY_REPORT_PODS_MAX:-$COZYREPORT_PODS_DEFAULT}
if ! cozyreport_is_count "$POD_CAP"; then
echo " (ignored a malformed COZY_REPORT_PODS_MAX='${COZY_REPORT_PODS_MAX:-}'; listing at most $COZYREPORT_PODS_DEFAULT)"
POD_CAP=$COZYREPORT_PODS_DEFAULT
fi
# `NF` guard: an empty selection arrives as one empty line, and an unguarded action
# would print a row of blanks -- a nameless broken pod on every green run.
POD_LISTING=$(printf '%s\n' "$POD_ROWS" \
| awk 'NF {printf " %s/%s — %s ready=%s (restarts=%s, age=%s)\n", $1, $2, $4, $3, $5, $NF}')
# A zero cap is branched on rather than written as `A && B || C`, which would send
# `head -n 0` the value this guard keeps away from it (GNU head returns nothing,
# BSD/macOS head errors out).
if [ "$POD_CAP" -gt 0 ] && [ -n "$POD_LISTING" ]; then
printf '%s\n' "$POD_LISTING" | head -n "$POD_CAP"
fi
if [ "${POD_TOTAL:-0}" -gt "$POD_CAP" ]; then
echo " ... $((POD_TOTAL - POD_CAP)) more not-Ready pod(s) not listed here; the full set is in kubernetes/pods.txt"
fi
echo
echo "## ImagePullBackOff / ErrImagePull"
echo
# Reuses the list already read above rather than calling the apiserver a second
# time for the same rows: one fewer cluster-wide read on the path that runs
# immediately before the tarball is written, and the two sections cannot disagree
# about what the cluster looked like.
#
# Which means it inherits that read's failure too. Without this guard the section
# is byte-identical between a healthy cluster and one whose pod list was refused,
# and a triager who jumps straight to this heading reads "nothing is failing to
# pull" off a list that never arrived.
if [ "$POD_LIST_RC" -ne 0 ]; then
echo " not known: this section is derived from the cluster-wide pod list above, which did not return"
else
printf '%s\n' "$POD_LIST" \
| awk 'NF && $4 ~ /ImagePullBackOff|ErrImagePull/ {printf " %s/%s — %s\n", $1, $2, $4}'
fi
echo
echo "## Recent OOMKilled events (last 20)"
echo
summary_read "OOMKilling event" kubectl get events -A --field-selector reason=OOMKilling --sort-by=.lastTimestamp
printf '%s\n' "$READ_OUT" | awk 'NF' | tail -20
echo
echo "## Recent Warning events (last 30)"
echo
summary_read "Warning event" kubectl get events -A --field-selector type=Warning --sort-by=.lastTimestamp
printf '%s\n' "$READ_OUT" | awk 'NF' | tail -30
echo
echo "## cert-manager: Certificates not Ready"
echo
if summary_crd certificates.cert-manager.io; then
summary_read "Certificate" kubectl get certificates.cert-manager.io -A --no-headers
printf '%s\n' "$READ_OUT" | awk 'NF && $3 != "True" {printf " %s/%s — Ready=%s\n", $1, $2, $3}'
fi
echo
echo "## Flux Sources not Ready"
echo
for kind in helmrepositories.source.toolkit.fluxcd.io ocirepositories.source.toolkit.fluxcd.io gitrepositories.source.toolkit.fluxcd.io externalartifacts.source.toolkit.fluxcd.io; do
summary_crd "$kind" || continue
short=${kind%%.*}
# custom-columns, not jsonpath. The jsonpath this replaces nested one filter
# inside another -- `.items[?(...conditions[?(@.type=="Ready")]...)]` -- which
# kubectl rejects outright with "unterminated filter", so this section printed
# nothing on every report ever taken and read as "all Flux sources are Ready".
# A form that sends stderr to /dev/null and drops the status hides that
# completely: the section renders empty and reads as "all sources Ready".
#
# Not the printer columns either: READY sits at a different index per kind, and
# STATUS carries spaces, so column counting is wrong for at least one of the four.
# custom-columns takes a single filter, the same shape the node section below uses.
summary_read "$short" kubectl get "$kind" -A --no-headers \
-o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,READY:.status.conditions[?(@.type=="Ready")].status'
printf '%s\n' "$READ_OUT" | awk -v k="$short" 'NF && $3 != "True" {printf " %s %s/%s — Ready=%s\n", k, $1, $2, $3}'
done
echo
echo "## Storage: PVCs not Bound, PVs not Bound"
echo
summary_read "PVC" kubectl get pvc -A --no-headers
printf '%s\n' "$READ_OUT" | awk 'NF && $3 != "Bound" {printf " PVC %s/%s — %s\n", $1, $2, $3}'
summary_read "PV" kubectl get pv --no-headers
printf '%s\n' "$READ_OUT" | awk 'NF && $5 != "Bound" {printf " PV %s — %s\n", $1, $5}'
echo
echo "## Node Conditions"
summary_read "node condition" kubectl get nodes -o 'custom-columns=NAME:.metadata.name,READY:.status.conditions[?(@.type=="Ready")].status,DISK:.status.conditions[?(@.type=="DiskPressure")].status,MEM:.status.conditions[?(@.type=="MemoryPressure")].status'
[ -z "$READ_OUT" ] || printf '%s\n' "$READ_OUT"