Skip to content

fix(e2e): collect diagnostics for unready pods and record every truncated capture - #3567

Merged
Aleksei Sviridkin (lexfrei) merged 3 commits into
mainfrom
fix/e2e-snapshot-unready-pod-logs
Aug 6, 2026
Merged

Aleksei Sviridkin (lexfrei) merged 3 commits into
mainfrom
fix/e2e-snapshot-unready-pod-logs

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

The e2e diagnostic tarball exists so a red run can be explained from the uploaded artifact alone. Several of its collectors answered "nothing is wrong" instead — some because they selected on a column that never carries the value they filtered for, others because they stopped early and left nothing saying they had. Both failure modes look identical to a healthy cluster from the outside, which is the worst possible shape for a diagnostic.

Four selectors in hack/cozyreport.sh and hack/cozyreport-summary.sh were reading the wrong thing:

  • Per-pod evidence was chosen by the STATUS column, so a pod left at 0/1 Running by a readiness probe that never passes was skipped. That is a common way for a suite to time out, and the artifact carried no trace of the pod the run died on. Selection now compares the two halves of the READY column; terminal Succeeded/Completed pods stay excluded.
  • The pending-LoadBalancer section filtered $4, which is CLUSTER-IP. CLUSTER-IP holds an address, None or <none> and never <pending>, so that loop had never executed. <pending> only ever appears in EXTERNAL-IP, $5.
  • The cozystack-apps section gated all three of its kinds on kubectl get crd <kind>.apps.cozystack.io. ApplicationDefinition is a CRD but in a different group, and Tenant comes from an aggregated APIService with no CRD to ask about, so the probe failed and the section never ran.
  • The kamaji section was gated on cozy-linstor's linstor-controller deployment rather than kamaji's own, so a cluster without LINSTOR got no kamaji logs and no TenantControlPlanes — on exactly the runs that need them.

The summary had three of its own: the HelmRelease listing printed $5 where STATUS is the Ready condition's whole message, reducing every failed release to one word like "Helm"; the Flux sources listing used a jsonpath with a nested filter that kubectl rejects outright, so it printed nothing on every report ever taken; and pod AGE was read as $6, which shifts whenever RESTARTS prints the 7 (4m12s ago) form — that is, for exactly the rows worth reading.

The second half is bounding. The pod, VM, VMI and services walks now run under a count cap and a wall-clock budget, and every read is individually time-boxed. The point is not the bounds themselves but that each one names itself when it fires: a walk stopped by the cap or the clock leaves COLLECTION-TRUNCATED.txt, a pod list that never returned leaves COLLECTION-FAILED.txt instead of the empty tree a healthy cluster produces, a log cut short says so on its own last line, and a --tail bound is recorded beside the logs rather than inside them, since the platform's controllers log JSON per line and a trailing prose line costs a parser the whole file. A knob value the collector could not use is reported too, so a truncation note quoting 600 is never mistaken for the 10m somebody actually set. Where timeout is absent the reads run unbounded rather than not at all, and the section says which of the two happened, because a missing local binary is not a statement about the cluster.

One behaviour change worth stating plainly, because it is a trade and not a pure gain: the per-pod section used to be uncapped and read container logs whole, and it is now bounded by COZY_REPORT_PODS_MAX (40), COZY_REPORT_PODS_BUDGET (600s) and COZY_REPORT_POD_TAIL (2000). Below those thresholds the new tree is a strict superset of the old one — the old selector missed Running 0/1 entirely and skipped logs for Pending pods. Above them the artifact is smaller than before, every drop is named in the tree, and COZY_REPORT_POD_TAIL=-1 with a larger COZY_REPORT_PODS_MAX restores the old reach. Because widening the selection and capping the walk in one change could otherwise let a newly-in-scope pod evict one the uncapped walk always collected, the ordering splits by evidence class before namespace: pods that are not Running — the class the collector took before this — all rank ahead of pods that are Running but unready, so the cap can only ever drop a pod the old collector did not keep either.

hack/e2e-capture-previous-logs.sh gets the same treatment: its notes now go to capture-notes.txt in the artifact as well as to the job log, and a pod list that never returned is reported as such rather than as "nothing restarted". That script now creates its own output directory before its first line of output, which makes the comment in hack/e2e-chainsaw/.chainsaw.yaml wrong about why the once-per-test guard there holds — the only change to that file is the corrected comment, saying the -d check keys on whether the catch block already ran rather than on what the helper found.

The rest is test coverage — a new hack/cozyreport.bats, an extended hack/capture-previous-logs.bats — and the removal of EXIT-trap cleanup from the bats files those tests now cover. That trap is worth its own note: inside an @test body it replaces the one the bats binary installs for its own bookkeeping, so a test that then fails prints no TAP line at all. It does not appear as not ok; it disappears, and only the # bats warning: Executed N instead of expected M tests line at the end distinguishes the run from a green one. A guard test enforces the ban over a named list of files that grows as the remaining ones are converted.

Testing

Every collector this touches is now covered by unit tests that run without a cluster: a new hack/cozyreport.bats (166 tests) and an extended hack/capture-previous-logs.bats (34), driven through hack/cozytest.sh. Ten hack/*.bats files run green locally, 265 tests in total, with each file's @test count reconciled against its passing count rather than trusted to the exit status — that reconciliation is the point of the trap change, since an exit code answers "did anything fail" and never "did anything run".

The guard test that bans EXIT-trap cleanup was checked by committing the violation rather than by reading it: a folded trap 'rm -rf "$tmp"' \ + EXIT added to a file on its list turns the guard red and names that file, and removing it turns the guard green again. shellcheck --shell=sh over the three changed scripts reports strictly fewer findings than before the change (hack/cozyreport.sh SC2086 170 to 116, SC2162 15 to 13; the three SC2102 in hack/cozyreport-summary.sh are gone), and hack/e2e-capture-previous-logs.sh is clean.

The parts that need a cluster — that the bounds fire, and that each one lands in the artifact — are exercised by the e2e run itself, since these collectors run on every failed suite.

Screenshots

Not a UI change.

Downstream repositories

Walked the trigger map in docs/agents/contributing.md against the diff. This PR touches hack/cozyreport.sh, hack/cozyreport-summary.sh, hack/e2e-capture-previous-logs.sh, hack/e2e-chainsaw/.chainsaw.yaml, ten hack/*.bats files and docs/agents/e2e-testing.md. Nothing is moved or renamed under hack/, no make target changes what it does, and the files downstream consumers key on (hack/package.mk, hack/common-envs.mk) are untouched. No package, values key, CRD, variant, namespace or release asset is involved.

Release note

fix(e2e): the diagnostic report now collects per-pod evidence for pods that are Running but not Ready, which it previously skipped, and several collector sections that silently produced nothing now report correctly. Collection that stops early on a bound now says so inside the artifact, so a truncated report can be told apart from a healthy cluster.

Summary by CodeRabbit

  • New Features

    • Diagnostic reports now prioritize relevant resources and collect bounded pod, VM, service, storage, certificate, and Flux data.
    • Reports preserve partial results and clearly identify timeouts, unavailable resources, truncation, warnings, and collection failures.
    • Previous-container logs now include bounded capture, namespace prioritization, failure details, and partial-read evidence.
    • Reports target the operator workload and safely handle missing dependencies or temporary storage failures.
  • Documentation

    • Expanded guidance for reliable end-to-end testing and diagnostic report collection.
  • Tests

    • Added comprehensive coverage for report generation, log capture, failure handling, limits, and portability.

A `trap ... EXIT` inside an `@test` replaces the one the bats binary
installs for its own bookkeeping, so a test that then fails prints no
TAP line at all. Not `not ok`, nothing: the run ends with a warning
that N of M tests executed, and anyone judging it by grepping for
`not ok` reads a green suite. Cleanup moves to the last line of each
body instead. Both runners set -e, so a failed test leaves its scratch
directory behind, which is what you want to look at anyway.

hack/promote-retag_test.bats was failing this way already, reporting
five passes and no failures while six of its eleven tests printed
nothing. Those six invoke the script for real, and it requires
sha256sum; they pin PATH to the stub directory plus /usr/bin and /bin,
which is where coreutils installs that binary and where macOS does not,
so the failure was of the platform rather than of the code and CI never
saw it. The stub directory already supplied yq and skopeo for the same
reason and now supplies sha256sum too, resolved from absolute
candidates because the stub itself is first on PATH.

Two file headers described the trap arrangement they no longer use and
are corrected rather than left to outlive it.

Assisted-By: Claude <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>
The capture wrote its reasons to the job log only. A reader holding the
uploaded artifact saw N logs in a directory with no way to tell N from
all of them: the container cap that dropped the rest, a pod list that
never returned, a previous instance that produced nothing, and a read
the outer backstop cut short all looked alike from outside. Every one
of those lines now goes to capture-notes.txt as well, and the directory
is created before the first of them, so a capture that found nothing is
distinguishable from one that never ran.

A pod list that failed is no longer reported as nothing having
restarted. That is a claim about the cluster which the script never
observed, and it is the claim most likely to talk a triager out of the
crash-loop hypothesis on exactly the run where it is right.

Exit 137 no longer asserts whose signal ended the read. 124 comes from
timeout and from nothing else, but 137 is 128+SIGKILL, which the kill
grace produces and so does the OOM killer or a teardown signalling the
process group. Both belong in the cut-off branch, since the capture
ends early either way, but reported flatly as the script's own timeout
a read killed at second two reads as one that waited the full eighteen.

COZY_PREVLOG_TAIL rejects a value kubectl cannot use and says so. A
zero tail asks for no lines while kubectl still exits 0, which the
empty-output branch would otherwise report as the container having
logged nothing, discarding the only remaining copy of a log the kubelet
has already collected. A leading zero is rejected because `$(( ))`
reads it as octal while `[` reads it as decimal, so one string means
two numbers. Each kept log names the bound it was written under, since
--tail drops the oldest lines and nothing else in the output says so,
and -1 suppresses that note because no bound was applied.

An output directory that cannot be written is reported as the local
condition it is. It arrives as an argument, so it can be a file, or
missing, or unwritable; the redirection then fails before kubectl runs
and hands the shell's own status to the branch that names kubectl,
which reported "no previous instance retrieved (kubectl exit 2)" once
per container.

Assisted-By: Claude <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>
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 <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds bounded Kubernetes and container-log collection, preserves partial diagnostic output, expands report and capture tests, and replaces BATS EXIT-trap cleanup with explicit cleanup commands.

Changes

Diagnostic collection

Layer / File(s) Summary
Bounded report collection
hack/cozyreport.sh
Adds bounded reads, timeout handling, prioritized pod selection, log collection, failure markers, truncation notes, and resource-specific report wiring.
Summary report integration
hack/cozyreport-summary.sh
Uses shared readers for Kubernetes resources and reports partial results, failures, caps, and unavailable data.
Previous-container log capture
hack/e2e-capture-previous-logs.sh
Adds bounded log reads, exact namespace matching, partial-log markers, stderr classification, and persistent capture notes.
Collection behavior validation
hack/cozyreport.bats, hack/capture-previous-logs.bats, hack/cozyreport-talos.bats
Adds stubs and tests for selection, limits, timeouts, failures, warnings, partial reads, configuration fallback, and report artifacts.
BATS cleanup and portability
hack/*_test.bats, hack/*-test.bats, docs/agents/e2e-testing.md, hack/e2e-chainsaw/.chainsaw.yaml
Replaces EXIT-trap cleanup with explicit successful-path cleanup, adds portable hashing support, and documents the cleanup and collection behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant cozyreport.sh
  participant kubectl
  participant ReportArtifacts
  cozyreport.sh->>kubectl: bounded resource and log reads
  kubectl-->>cozyreport.sh: data, partial output, status, and stderr
  cozyreport.sh->>ReportArtifacts: write reports and diagnostic notes
Loading

Suggested labels: kind/bug, size/XXL, area/testing, area/platform

Suggested reviewers: myasnikovdaniil

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 96.97% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: collecting diagnostics for unready pods and recording truncated captures, which are the core objectives of this PR.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/e2e-snapshot-unready-pod-logs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (5)
hack/capture-previous-logs.bats (1)

789-798: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the -1 suppression where the note is written.

The run-level tail note goes to capture-notes.txt, not into the .log file. Line 794 therefore passes whether or not the [ "$TAIL" != "-1" ] guard at hack/e2e-capture-previous-logs.sh Line 429 exists.

Assert on capture-notes.txt so the test fails when the suppression is removed.

♻️ Proposed change
-  file="$(ls "$out"/*.log)"
   # -1 is the documented spelling for "the whole log". A note reading "holds at
   # most the last -1 lines" would describe a bound that was never applied, which
   # is the same class as claiming a truncated file is complete: a statement about
   # the artifact that the artifact does not support.
-  if grep -q 'holds at most' "$file"; then
+  if grep -q 'holds at most' "$out/capture-notes.txt"; then
     echo "FAIL: an unbounded read claims a tail bound"
-    cat "$file"
+    cat "$out/capture-notes.txt"
     false
   fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/capture-previous-logs.bats` around lines 789 - 798, Update the test
around the `file` assignment to inspect `capture-notes.txt` instead of the
captured `.log` file, and retain the existing `holds at most` assertion so it
verifies the `TAIL=-1` suppression in `hack/e2e-capture-previous-logs.sh` rather
than unrelated log content.
hack/cozyreport.bats (1)

1907-1914: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the kill grace from the script instead of hardcoding 5.

The stub sets COZYREPORT_BOUND="timeout -k 5 $COZYREPORT_READ_TIMEOUT". The timeout is read from the sourced script, but the grace is a literal.

The assertion at Line 2380, grep -c '^kill-after 5$', then compares against a value this file supplied. If hack/cozyreport.sh changes its grace, the stub keeps 5 and that assertion stays green while the stub no longer models production.

Read the grace from the script the same way the timeout is read.

♻️ Proposed change
-  COZYREPORT_BOUND="timeout -k 5 $COZYREPORT_READ_TIMEOUT"
+  # Grace read from the script, not written here: a literal makes the
+  # `kill-after` assertions compare against a value this file chose.
+  _grace=$(sed -n 's/.*timeout -k \([0-9]*\) .*/\1/p' "$SCRIPT" | head -n 1)
+  [ -n "$_grace" ] || { echo "FAIL: could not read the kill grace from $SCRIPT"; return 1; }
+  COZYREPORT_BOUND="timeout -k $_grace $COZYREPORT_READ_TIMEOUT"

Then assert against $_grace rather than the literal 5 at Line 2380.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/cozyreport.bats` around lines 1907 - 1914, Update the test stub setup
around COZYREPORT_BOUND to read the timeout grace value from the sourced
cozyreport.sh, alongside the existing read of COZYREPORT_READ_TIMEOUT, and
construct the bound command with that variable instead of hardcoding 5. Update
the related assertion to compare against $_grace so the stub and production
script remain synchronized.
hack/cozyreport-summary.sh (1)

257-257: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Optional: add || true for parity with the collector's call.

This script runs under set -e. cozyreport_pods_prioritize returns 0 on both of its paths today, so the pipeline cannot abort the script now. The collector guards the identical pipeline with || true at hack/cozyreport.sh line 1107. Matching that here removes the dependency on the helper's exit status.

♻️ Proposed change
-POD_ROWS=$(printf '%s\n' "$POD_LIST" | cozyreport_pods_not_ready | cozyreport_pods_prioritize)
+POD_ROWS=$(printf '%s\n' "$POD_LIST" | cozyreport_pods_not_ready | cozyreport_pods_prioritize) || true
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/cozyreport-summary.sh` at line 257, Add `|| true` to the `POD_ROWS`
pipeline involving `cozyreport_pods_not_ready` and `cozyreport_pods_prioritize`,
matching the guarded collector pipeline so `set -e` does not abort the summary
script if the helper’s exit status changes.
hack/cozyreport.sh (2)

1424-1440: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: add -r to the three object loops, and clear COZYREPORT_OBJECTS_DEADLINE after the last walk.

ShellCheck flags lines 1431, 1451 and 1471 for read without -r (SC2162). Namespace and object names cannot contain backslashes, so the current form is safe today. -r removes the warning and matches the read -r used in cozyreport_collect_broken_pods.

COZYREPORT_OBJECTS_DEADLINE stays set after the services walk. Each walk overwrites it before use, so there is no current defect. cozyreport_collect_broken_pods clears COZYREPORT_COLLECT_DEADLINE at line 1205 for the stated reason that a stale deadline silently bounds a later reader. The same reason applies here.

♻️ Proposed changes
 cozyreport_select_objects "virtualmachines" kubectl get vm -A --no-headers | cozyreport_kubevirt_not_ready | cozyreport_admit_objects "virtualmachines" |
-  while read NAMESPACE NAME _; do
+  while read -r NAMESPACE NAME _; do

Apply the same change at lines 1451 and 1471, and clear the deadline after the services walk:

COZYREPORT_OBJECTS_DEADLINE=""
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/cozyreport.sh` around lines 1424 - 1440, Update all three
object-collection loops to use read -r, including the loop around
cozyreport_select_objects and the corresponding loops near the other object
walks. After the final services walk, clear COZYREPORT_OBJECTS_DEADLINE by
assigning it an empty value, matching the cleanup pattern used by
cozyreport_collect_broken_pods.

Source: Linters/SAST tools


1264-1271: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Quote the report output file paths.

REPORT_DIR derives from mktemp -d, so these paths should not be affected by filenames with spaces. ShellCheck still reports SC2086, so quote the mkdir and kubectl redirect paths for consistency.

♻️ Quoting fix
-mkdir -p $REPORT_DIR/cozystack
+mkdir -p "$REPORT_DIR/cozystack"
-kubectl get deploy -n cozy-system cozystack-operator -o jsonpath='{.spec.template.spec.containers[0].image}' > $REPORT_DIR/cozystack/image.txt 2>&1
+kubectl get deploy -n cozy-system cozystack-operator -o jsonpath='{.spec.template.spec.containers[0].image}' > "$REPORT_DIR/cozystack/image.txt" 2>&1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/cozyreport.sh` around lines 1264 - 1271, Quote the REPORT_DIR-derived
paths in the mkdir command and the kubectl output redirection within the
cozystack report block to resolve SC2086, preserving the existing directory and
report filenames.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@hack/capture-previous-logs.bats`:
- Around line 365-374: Fix the pairing assertion around the `pairs` validation
loop so failures propagate to the Bats test instead of being confined to a
pipeline subshell. Replace the piped `printf ... | while` structure with
process-substitution or capture offending pairs and assert afterward, while
preserving the existing accepted `$PREVLOG_READ_TIMEOUT/$PREVLOG_BOUND` and
`$PREVLOG_LIST_TIMEOUT/$PREVLOG_LIST_BOUND` mappings.
- Around line 351-354: Update the extraction guard in the test setup to capture
and validate each of the four sed-derived values independently: the list bound,
list timeout, read grace, and read timeout, along with the existing cap value.
Do not concatenate paired values before checking non-emptiness; ensure the
arithmetic checks at the following lines are only reached when every extracted
value is present.

---

Nitpick comments:
In `@hack/capture-previous-logs.bats`:
- Around line 789-798: Update the test around the `file` assignment to inspect
`capture-notes.txt` instead of the captured `.log` file, and retain the existing
`holds at most` assertion so it verifies the `TAIL=-1` suppression in
`hack/e2e-capture-previous-logs.sh` rather than unrelated log content.

In `@hack/cozyreport-summary.sh`:
- Line 257: Add `|| true` to the `POD_ROWS` pipeline involving
`cozyreport_pods_not_ready` and `cozyreport_pods_prioritize`, matching the
guarded collector pipeline so `set -e` does not abort the summary script if the
helper’s exit status changes.

In `@hack/cozyreport.bats`:
- Around line 1907-1914: Update the test stub setup around COZYREPORT_BOUND to
read the timeout grace value from the sourced cozyreport.sh, alongside the
existing read of COZYREPORT_READ_TIMEOUT, and construct the bound command with
that variable instead of hardcoding 5. Update the related assertion to compare
against $_grace so the stub and production script remain synchronized.

In `@hack/cozyreport.sh`:
- Around line 1424-1440: Update all three object-collection loops to use read
-r, including the loop around cozyreport_select_objects and the corresponding
loops near the other object walks. After the final services walk, clear
COZYREPORT_OBJECTS_DEADLINE by assigning it an empty value, matching the cleanup
pattern used by cozyreport_collect_broken_pods.
- Around line 1264-1271: Quote the REPORT_DIR-derived paths in the mkdir command
and the kubectl output redirection within the cozystack report block to resolve
SC2086, preserving the existing directory and report filenames.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9e1179f0-b399-4338-a513-8d427c7513ec

📥 Commits

Reviewing files that changed from the base of the PR and between df157da and e80b8e3.

📒 Files selected for processing (15)
  • docs/agents/e2e-testing.md
  • hack/admin-kubeconfig-invariant.bats
  • hack/capture-previous-logs.bats
  • hack/check-gpu-operator-variants.bats
  • hack/check-gpu-recording-rules.bats
  • hack/check-host-runtime.bats
  • hack/cozyreport-summary.sh
  • hack/cozyreport-talos.bats
  • hack/cozyreport.bats
  • hack/cozyreport.sh
  • hack/e2e-capture-previous-logs.sh
  • hack/e2e-chainsaw/.chainsaw.yaml
  • hack/promote-retag_test.bats
  • hack/remediation-guard.bats
  • hack/select-install_test.bats

Comment on lines +351 to +354
list="$(sed -n 's/^ PREVLOG_LIST_BOUND="timeout -k \([0-9]*\) .*"/\1/p' "$SCRIPT") $(sed -n 's/^PREVLOG_LIST_TIMEOUT=\([0-9]*\)/\1/p' "$SCRIPT")"
read_="$(sed -n 's/^PREVLOG_READ_GRACE=\([0-9]*\)/\1/p' "$SCRIPT") $(sed -n 's/^PREVLOG_READ_TIMEOUT=\([0-9]*\)/\1/p' "$SCRIPT")"
cap=$(sed -n 's/^MAX="${COZY_PREVLOG_MAX:-\([0-9]*\)}"/\1/p' "$SCRIPT")
[ -n "$list" ] && [ -n "$read_" ] && [ -n "$cap" ]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

The emptiness guard cannot fail.

list and read_ are each built as "$a $b", so both always contain the joining space. [ -n "$list" ] and [ -n "$read_" ] therefore always succeed, even when a sed pattern matches nothing.

If one pattern stops matching, cut -d' ' -f1 yields an empty string and the arithmetic at Line 356 or Line 357 becomes $(( + 28 )). Under dash that aborts the test body instead of reporting the intended failure, so the guard reports nothing at the moment it is needed.

Check the four extracted values individually.

🛠️ Proposed fix
-  list="$(sed -n 's/^  PREVLOG_LIST_BOUND="timeout -k \([0-9]*\) .*"/\1/p' "$SCRIPT") $(sed -n 's/^PREVLOG_LIST_TIMEOUT=\([0-9]*\)/\1/p' "$SCRIPT")"
-  read_="$(sed -n 's/^PREVLOG_READ_GRACE=\([0-9]*\)/\1/p' "$SCRIPT") $(sed -n 's/^PREVLOG_READ_TIMEOUT=\([0-9]*\)/\1/p' "$SCRIPT")"
+  list_grace=$(sed -n 's/^  PREVLOG_LIST_BOUND="timeout -k \([0-9]*\) .*"/\1/p' "$SCRIPT")
+  list_secs=$(sed -n 's/^PREVLOG_LIST_TIMEOUT=\([0-9]*\)/\1/p' "$SCRIPT")
+  read_grace=$(sed -n 's/^PREVLOG_READ_GRACE=\([0-9]*\)/\1/p' "$SCRIPT")
+  read_secs=$(sed -n 's/^PREVLOG_READ_TIMEOUT=\([0-9]*\)/\1/p' "$SCRIPT")
   cap=$(sed -n 's/^MAX="${COZY_PREVLOG_MAX:-\([0-9]*\)}"/\1/p' "$SCRIPT")
-  [ -n "$list" ] && [ -n "$read_" ] && [ -n "$cap" ]
+  for v in "$list_grace" "$list_secs" "$read_grace" "$read_secs" "$cap"; do
+    [ -n "$v" ] || { echo "FAIL: a bound could no longer be read from $SCRIPT"; false; }
+  done
 
-  list_ceiling=$(( $(echo "$list" | cut -d' ' -f1) + $(echo "$list" | cut -d' ' -f2) ))
-  read_ceiling=$(( $(echo "$read_" | cut -d' ' -f1) + $(echo "$read_" | cut -d' ' -f2) ))
+  list_ceiling=$(( list_grace + list_secs ))
+  read_ceiling=$(( read_grace + read_secs ))
   worst=$(( list_ceiling + cap * read_ceiling ))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/capture-previous-logs.bats` around lines 351 - 354, Update the
extraction guard in the test setup to capture and validate each of the four
sed-derived values independently: the list bound, list timeout, read grace, and
read timeout, along with the existing cap value. Do not concatenate paired
values before checking non-emptiness; ensure the arithmetic checks at the
following lines are only reached when every extracted value is present.

Comment on lines +365 to +374
pairs=$(grep -o 'prevlog_cutoff_desc "[^"]*" "\$[A-Z_]*" "\$[A-Z_]*"' "$SCRIPT" |
sed 's/.*"\(\$[A-Z_]*\)" "\(\$[A-Z_]*\)"/\1 \2/' | LC_ALL=C sort -u)
[ -n "$pairs" ] || { echo "FAIL: no cut-off description is derived from a bound"; false; }
printf '%s\n' "$pairs" | while read -r secs bound; do
case "$secs $bound" in
'$PREVLOG_READ_TIMEOUT $PREVLOG_BOUND') ;;
'$PREVLOG_LIST_TIMEOUT $PREVLOG_LIST_BOUND') ;;
*) echo "FAIL: $secs is quoted for a read bounded by $bound"; exit 1 ;;
esac
done

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

The pairing check cannot fail the test.

The while loop reads from a pipe, so it runs in a subshell. exit 1 at Line 372 ends only that subshell. The pipeline's status is never tested, so the test continues to the backstop loop and passes.

A prevlog_cutoff_desc call that quotes the wrong seconds variable therefore stays green, which is the failure mode this test exists to prevent.

Feed the loop without a pipe, or capture the offending pairs and assert on them.

🛠️ Proposed fix
-  printf '%s\n' "$pairs" | while read -r secs bound; do
-    case "$secs $bound" in
-      '$PREVLOG_READ_TIMEOUT $PREVLOG_BOUND') ;;
-      '$PREVLOG_LIST_TIMEOUT $PREVLOG_LIST_BOUND') ;;
-      *) echo "FAIL: $secs is quoted for a read bounded by $bound"; exit 1 ;;
-    esac
-  done
+  bad_pairs=""
+  while read -r secs bound; do
+    [ -n "$secs" ] || continue
+    case "$secs $bound" in
+      '$PREVLOG_READ_TIMEOUT $PREVLOG_BOUND') ;;
+      '$PREVLOG_LIST_TIMEOUT $PREVLOG_LIST_BOUND') ;;
+      *) bad_pairs="$bad_pairs
+$secs is quoted for a read bounded by $bound" ;;
+    esac
+  done <<EOF
+$pairs
+EOF
+  if [ -n "$bad_pairs" ]; then
+    echo "FAIL: a cut-off description quotes a budget its read never used:$bad_pairs"
+    false
+  fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pairs=$(grep -o 'prevlog_cutoff_desc "[^"]*" "\$[A-Z_]*" "\$[A-Z_]*"' "$SCRIPT" |
sed 's/.*"\(\$[A-Z_]*\)" "\(\$[A-Z_]*\)"/\1 \2/' | LC_ALL=C sort -u)
[ -n "$pairs" ] || { echo "FAIL: no cut-off description is derived from a bound"; false; }
printf '%s\n' "$pairs" | while read -r secs bound; do
case "$secs $bound" in
'$PREVLOG_READ_TIMEOUT $PREVLOG_BOUND') ;;
'$PREVLOG_LIST_TIMEOUT $PREVLOG_LIST_BOUND') ;;
*) echo "FAIL: $secs is quoted for a read bounded by $bound"; exit 1 ;;
esac
done
pairs=$(grep -o 'prevlog_cutoff_desc "[^"]*" "\$[A-Z_]*" "\$[A-Z_]*"' "$SCRIPT" |
sed 's/.*"\(\$[A-Z_]*\)" "\(\$[A-Z_]*\)"/\1 \2/' | LC_ALL=C sort -u)
[ -n "$pairs" ] || { echo "FAIL: no cut-off description is derived from a bound"; false; }
bad_pairs=""
while read -r secs bound; do
[ -n "$secs" ] || continue
case "$secs $bound" in
'$PREVLOG_READ_TIMEOUT $PREVLOG_BOUND') ;;
'$PREVLOG_LIST_TIMEOUT $PREVLOG_LIST_BOUND') ;;
*) bad_pairs="$bad_pairs
$secs is quoted for a read bounded by $bound" ;;
esac
done <<EOF
$pairs
EOF
if [ -n "$bad_pairs" ]; then
echo "FAIL: a cut-off description quotes a budget its read never used:$bad_pairs"
false
fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/capture-previous-logs.bats` around lines 365 - 374, Fix the pairing
assertion around the `pairs` validation loop so failures propagate to the Bats
test instead of being confined to a pipeline subshell. Replace the piped `printf
... | while` structure with process-substitution or capture offending pairs and
assert afterward, while preserving the existing accepted
`$PREVLOG_READ_TIMEOUT/$PREVLOG_BOUND` and
`$PREVLOG_LIST_TIMEOUT/$PREVLOG_LIST_BOUND` mappings.

@github-actions github-actions Bot added area/testing Issues or PRs related to testing (e2e, bats, unit tests) kind/bug Categorizes issue or PR as related to a bug size/XXL This PR changes 1000+ lines, ignoring generated files labels Aug 5, 2026
@lexfrei

Copy link
Copy Markdown
Contributor Author

The red is outside this diff. Run 31020254620 on e80b8e370 fails kubernetes-previous and kubernetes-latest, both with error: timed out waiting for the condition on deployments/test-*-backend. That wait is run-kubernetes.sh:628, and nothing in the changed files runs before it.

Timings come from the tenant crust-gather this run collected. kubernetes-previous: pod created 17:52:21 with FailedScheduling: 0/2 nodes are available: 1 node(s) had untolerated taint(s), 1 node(s) were unschedulable, then NotTriggerScaleUp: 1 node(s) had untolerated taint {node.cilium.io/agent-not-ready: } at 17:53:24, Scheduled at 17:54:39, Pulling image "nginx:alpine" at 17:55:36, Pulled ... in 1m42.435s at 17:57:19, container created at 17:57:20. The 300s budget ran out at 17:57:21, one second later, so the readiness probe never ran once. kubernetes-latest is the same shape: created 18:22:26 with both nodes tainted, same taint named at 18:23:14, Scheduled 18:24:23, Pulling 18:25:42, budget gone at 18:27:26 with the pull still going.

The collectors this PR changes did run, on live data. kubernetes/READ-WARNINGS.txt explains the two zero-byte files in the bundle: vms.txt: kubectl returned no object, exited 0, and said: No resources found, and the same for vmis.txt. capture-notes.txt landed in both failed snapshots and records the cap it hit, 3 more restarted containers not captured in one suite and 4 in the other, plus the 200-line tail that applies to every file in the directory.

COLLECTION-TRUNCATED.txt, COLLECTION-FAILED.txt and KIND-NOT-SERVED.txt are absent from the bundle. Nothing truncated, no read failed and every kind was served in this run, so they had no reason to appear. They are unexercised here.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Diagnostics-tooling bugfix only (hack/*; nothing under packages/, api/ or charts). The +7169 is mostly tests (cozyreport.bats +4356, capture-previous-logs.bats +711); real logic is ~2400 lines, no committed binary fixtures. The three column-selector fixes and two gate fixes are verified against real kubectl column layouts and confirmed by behavioural tests; error handling is fail-closed, not fail-open. Regression-free on supported paths.

Non-blocking notes:

  1. Some module gates (cozystack-operator-log, cert-manager, objectstorage/COSI, linstor) still collapse NotFound and can't-ask into one — a transient apiserver failure reads as "component absent", the exact class this PR fixes elsewhere. No live bug (NotFound dominates and summary_crd partially compensates), but inconsistent; consider routing them through the same discriminating reader.
  2. Header budget arithmetic is understated by ~100-150s (omits the per-pod container-list read and the per-section selector reads). Low impact since the header itself calls the number a FLOOR.
  3. cozyreport.sh object-helpers write to global $REPORT_DIR instead of an argument, unlike the sibling helper. No live bug today, but a future caller without REPORT_DIR would write from filesystem root and || true would swallow the failure — the "never truncate silently" mechanism silencing itself. Consider parameterizing as in _cbp_root.
  4. The capture-previous-logs loop has no internal wall-clock budget and relies on an external timeout in another file; raising COZY_PREVLOG_MAX past ~13 risks SIGKILL mid-loop with no "cut short" marker. Trade-off is documented in the docstring.

@lexfrei
Aleksei Sviridkin (lexfrei) merged commit 2527e91 into main Aug 6, 2026
16 of 17 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the fix/e2e-snapshot-unready-pod-logs branch August 6, 2026 12:25
Aleksei Sviridkin (lexfrei) added a commit that referenced this pull request Aug 7, 2026
The ban on EXIT-trap cleanup in hack/*.bats was enforced by two lists
inside hack/cozyreport.bats: one naming the files known to be clean, and
one freezing an exact trap count for each file that was not. Both had to
be edited from whatever change moved them, and that is where the guard
failed, and it never worked even once. The inventory landed in #3567 at
15:25:20; #3195 had landed multus-install-cni-plugins.bats carrying
twelve traps forty-three seconds earlier, so replaying the old guard
against the tree at the commit that introduced it already gives
found != frozen. Main stayed red for about twenty-two and three quarter
hours. The first repair, #3584, landed already red the next morning
because #3548 had brought in run-kubernetes-talos-diagnostics_test.bats
with eight traps two minutes ahead of it, so that fix bought no green
time at all; green came only with the second repair. Neither pair of
PRs shared a line, and each was green against its own base.

A change adding a trap to its own file had to edit a string in a suite
it otherwise never touches, so two
changes sharing no line still invalidated each other: each stayed green
against its own base, git merged both cleanly, and the guard went red
only once the second landed.
A file arriving with traps hit the same wall from the other side -- the
inventory did notice it, since the string it compared was built by
scanning the directory, but absorbing it meant an edit in a file nothing
in the author's diff pointed at.

Replace both lists with a declaration each file makes about itself: one
"# EXIT-TRAP DEBT: N" comment, exact rather than a ceiling. A file
carrying none must install no EXIT trap, which covers the converted
files, the files that never had one, and every file added later.
Growing or shedding a trap now fails in the file the change already
edits, so two changes that disagree about a count collide textually
instead of silently.

That collision is a steady-state property, and this change's own arrival
is the exception. A branch forked before the declaration existed has no
line to disagree with: it converts traps in its own file, merges clean,
and the count goes wrong only once both sides are on main -- verified
against a sibling branch that takes select-e2e_test.bats from fifteen
traps to zero. What the move buys even then is that the red names a file
that branch already edited, the repair is one line inside it, and
rebasing before merge catches it on the branch's own CI. None of those
three held against the inventory.

The declaration is read from the leading comment block, not from
anywhere in the file. A .bats file is shell that writes shell, so the
same line turns up inside a heredoc, a fixture writer or an
expected-output string, where it is data belonging to one test. Reading
it there as a statement about the whole file would let an unrelated
fixture excuse a real trap, and would do it silently, since nothing in
that test's own diff looks like a declaration. A comment block is the
region with no interior: stopping instead at the first @test would still
read a line out of a helper's heredoc.

Not every counted handler is debt. A trap inside an explicit subshell
does not replace the bats binary's own, so a test failing inside
`( ... )` still prints its `not ok`. hack/e2e-test-openapi.bats kills a
backgrounded kubectl proxy that way, and moving the kill to the end of
the body would leak a process holding a fixed port. Its declaration
records that rather than scheduling a conversion, and because the
ratchet is exact, removing the trap fails too -- the count protects the
construct. What the count cannot do is tell the two apart: substituting
a test-level trap for the subshell one keeps the total at 1 and stays
green, which the header states rather than leaves to be found.

Counting bounds the keyword and the signal the same way, at any
character that cannot be part of an identifier, and matches the signal
in either case. Whitespace on the right missed `trap ... EXIT; cd
"$tmp"`; whitespace on the left missed `tmp=$(mktemp -d);trap ... EXIT`
and `(trap ... EXIT; true)`; upper case missed `trap ... exit`, which
bash and dash both install. All are real handlers that scored zero, and
the left boundary matters most, since the inventory being replaced had
none and caught the semicolon form. A bare word boundary is not enough
either way: `bootstrap ` ends in `trap `, and it must keep scoring
nothing. A quoted signal counts for the same reason as the rest.

Two handlers sharing one line are reported rather than counted, since
the count is a count of lines
and the second would otherwise arrive without moving the total;
splitting the line properly needs a shell parser, a semicolon inside a
handler's own action not being a separator, and guessing wrong
undercounts -- the one direction a ratchet cannot afford.

The scan recurses, so hack/e2e-apps/*.bats is covered rather than
sitting one directory below the guard that claims the tree. It reads
.bats and nothing else, so a handler arriving through a sourced .sh
stays outside it -- hack/e2e-chainsaw/_lib/run-kubernetes.sh installs
two, each benign for its own reason rather than by design: one sits in
a function declared with `(` and so runs in a subshell, the other in a
brace function no @test calls. That boundary is stated in the header
rather than papered over.

The guard moves to hack/bats-no-exit-trap.bats: its subject is every
unit suite under hack/, not the report collector it grew up in. Its
fixture helpers assemble the trap keyword and the signal from separate
arguments, because the guard scans its own source and a fixture written
as a literal would be counted as a real trap in it.

Assisted-By: Claude <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>
Aleksei Sviridkin (lexfrei) added a commit that referenced this pull request Aug 8, 2026
…#3622)

<!-- Thank you for making a contribution! Here are some tips for you:
- Use Conventional Commits for the PR title: `type(scope): description`
- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- Scopes are not an exhaustive list — pick the most specific scope for
the change and extend the list when a genuinely new area appears.
Examples:
- System components: dashboard, platform, operator, cilium, kube-ovn,
linstor, fluxcd, cluster-api
- Managed apps: postgres, mariadb, redis, kafka, clickhouse,
virtual-machine, kubernetes
- Development and maintenance: api, hack, tests, ci, docs, maintenance
- Breaking changes: append `!` after type/scope (`feat(api)!: ...`) or
add a `BREAKING CHANGE:` footer
- If it's a work in progress, consider creating this PR as a draft.
- Don't hesistate to ask for opinion and review in the community chats,
even if it's still a draft.
- Add the label `backport` if it's a bugfix that needs to be backported
to a previous version.
-->

## What this PR does

The ban on EXIT-trap cleanup in `hack/*.bats` was enforced by two lists
inside `hack/cozyreport.bats`: one naming the files known to be clean,
one freezing an exact trap count for each file that was not. Both had to
be edited from whatever change moved them, and that is where the guard
kept failing.

It is worth being exact about how badly, because the history is sharper
than "it went stale a few times". **The inventory was never correct on
main for a single commit.** It landed in #3567 at 15:25:20. #3195 had
landed `multus-install-cni-plugins.bats` carrying twelve traps at
15:24:37, forty-three seconds earlier. Replaying the old guard's own
logic against the tree at the very commit that introduced it already
gives `found != frozen`. Main then stayed red for roughly twenty-two and
three quarter hours. The first repair, #3584, landed *already red* the
next morning at 10:42:43, because #3548 had brought in
`run-kubernetes-talos-diagnostics_test.bats` with eight traps at
10:40:03, under three minutes ahead of it. That fix was correct and
bought zero green time. Green arrived only with the second repair,
#3602.

Neither pair of PRs shared a line, and each was green against its own
base. That is the whole mechanism, and it is why a third one-line repair
is not the answer.

The mechanism is structural rather than careless. A change that adds a
trap to its own file had to edit a string in a suite it otherwise never
touches, so two changes sharing no line still invalidated each other:
each stayed green against its own base, git merged both cleanly, and the
guard went red only once the second one landed. A file *arriving* with
traps was worse still, because nothing in its author's diff pointed at
that string at all. That is exactly how
`run-kubernetes-talos-diagnostics_test.bats` got in, twice.

That contention is not in the past tense. Two open PRs are editing that
one line right now, and they disagree about what it should say: #3575
adds `run-kubernetes-talos-diagnostics_test.bats=8` to it, repeating a
repair that has already landed, and #3441 removes
`select-e2e_test.bats=15` from it, because it converts that file.
Neither PR is about EXIT traps. Both have to touch that string anyway,
and whichever lands second is wrong until someone edits it again.

So this PR is not fixing a red main. It removes the thing that keeps
making main red, which is why it is worth more than the one-line fix
that is now the established habit.

One honest caveat about its own landing. The textual-conflict property
is steady-state: a branch forked *before* the declaration exists has no
line to disagree with, so it converts traps in its own file, merges
clean, and the count only goes wrong once both sides are on main. I
checked this against #3441, which takes `select-e2e_test.bats` from
fifteen traps to zero. Merged after this, that file would declare
fifteen and hold none. What the move buys even in that case is that the
red names a file the branch already edited, the repair is one line
inside it, and rebasing before merge catches it on the branch's own CI.
None of those three held against the central inventory. Whoever merges
this should expect one such adjustment on the conversion branches still
in flight.

So this replaces both lists with a declaration each file makes about
itself: one `# EXIT-TRAP DEBT: N` comment in its leading comment block.
A file carrying no declaration must install no EXIT trap. Growing or
shedding a trap now fails in the file the change already edits, so two
changes that disagree about a count get a real textual conflict instead
of silently invalidating each other, and a change that leaves the traps
alone edits nothing.

**The include list is redundant, not lost.** It named the files proven
clean, so that a trap reappearing in one of them would fail. Under the
new rule those files carry no declaration, and a file with no
declaration must hold zero traps, so a trap reappearing in any of them
fails on its own, with no list to be on. Coverage widens rather than
narrows: the two lists named twenty files between them, and the rule
covers all fifty bats files under `hack/`, subdirectories included, plus
the ones added tomorrow.

Rebasing this branch onto current main is the property working. Main has
since gained `hack/kubernetes-pre-delete-hook.bats` and
`hack/tenant-pre-delete-hook.bats`, and `hack/cozyreport.bats` grew by
some eight hundred lines. Neither new file installs an EXIT trap, so
neither needed a declaration and neither needed an edit here; the rebase
took no conflict at all. Under the inventory, each arriving file was a
coin toss on whether somebody had remembered the string.

To be precise about what the inventory could and could not do, since it
is easy to overstate: it did *notice* a new file carrying traps. The
string it compared was built by scanning the directory, so an arriving
file appended a token and failed the comparison, which is exactly how
main went red. What it could not do is let that file arrive without an
edit in a foreign suite. Being seen and being absorbable are different
properties, and only the second one decides whether two changes can land
independently.

The declaration is pinned in both directions. Declaring N while holding
N+1 fails, obviously; declaring N while holding N−1 fails too. Without
that second half the number becomes a ceiling and rots upward: somebody
converts half a file, the declaration stays, and the guard quietly
licenses traps that were removed long ago.

It is read only from the leading comment block, under the shebang and
above the first line of code. A `.bats` file is shell that writes shell,
so the same line turns up inside a heredoc, a fixture writer or an
expected-output string, where it is data belonging to one test;
honouring it there would let an unrelated fixture excuse a real trap,
silently, with nothing in that test's own diff looking like a
declaration. A comment block is the region with no interior; stopping
instead at the first `@test` would still read a line out of a helper's
heredoc.

Not every counted handler is debt. A trap inside an explicit subshell
does not replace the one the `bats` binary installs, so a test failing
inside `( … )` still prints its `not ok`, checked against a test-level
trap in the same file, where the TAP line vanishes.
`hack/e2e-test-openapi.bats` kills a backgrounded `kubectl proxy`
exactly that way, and "convert it like the others" would leak a process
holding a fixed port and wedge the next run. Its declaration now records
the carve-out instead of scheduling a conversion, and because the
ratchet is exact in both directions, *removing* that trap fails too, so
the count protects the construct rather than marking it for deletion.
`docs/agents/e2e-testing.md` previously scoped this exception to
Chainsaw `script` steps only; it now names the BATS subshell case as
well.

The counting bounds the keyword and the signal the same way, at any
character that cannot be part of an identifier, and matches the signal
in either case. Whitespace on the right missed `trap … EXIT; cd "$tmp"`;
whitespace on the left missed `tmp=$(mktemp -d);trap … EXIT` and `(trap
… EXIT; true)`; upper case missed `trap … exit`, which bash and dash
both install. All of those are real handlers that scored zero. The left
boundary is the one worth dwelling on, because the inventory being
replaced had none at all and *did* catch the semicolon form. Getting it
wrong here would have narrowed coverage while the commit claimed to
widen it. A plain word boundary is not enough either: `bootstrap ` ends
in `trap `, and it has to keep scoring nothing, or the documented answer
to a red guard (add a debt line) would buy a file a permanent licence
for one real trap to silence a line that has none.

Two handlers sharing one line are reported rather than counted, because
the count is a count of lines and the second would otherwise arrive
free. Splitting such a line properly needs a shell parser, since a
semicolon inside a handler's own quoted action is not a separator, and
guessing wrong undercounts, the one direction a ratchet cannot afford.

**What this does not fix.** The declaration is still a loophole: a new
file can write `# EXIT-TRAP DEBT: 8` instead of cleaning up, and nothing
here makes that impossible. What changes is that the admission is local
and visible. It sits at the top of the file it excuses, in front of
whoever reviews that file, instead of being a number in a neighbouring
suite nobody in that review is reading. Today's loophole is the same
size and invisible.

Three more limits, all stated in the guard's own header rather than left
to be discovered. The scan is lexical, so a signal computed at runtime
and a quoted action spanning physical lines without a backslash are both
invisible. An exact count catches addition and removal but never
substitution: swap the openapi file's subshell trap for a test-level one
and the total stays 1. And the scan reads `.bats` only, so a handler
arriving through a sourced `.sh` is outside it.
`hack/e2e-chainsaw/_lib/run-kubernetes.sh` installs two right now, and
each is benign for its own reason rather than by design: the one in
`cozy_capture_tenant_talos` because that function is declared with `(`
and so runs in a subshell, the one in `run_kubernetes_test` because no
`@test` calls it despite being declared with `{`. Three `hack/*.bats`
source that library, and two tests in the converted file call
`cozy_capture_tenant_talos`, so flipping a single `(` to `{` reinstates
a test-level handler in both of them with the guard green. Widening the
scan to `.sh` would mean counting handlers that are correct in a script
and wrong only in a test body, so the honest answer is that this is
where the instrument stops.

Three further boundaries, recorded here so they land as known edges
rather than as surprises. The old include list also failed when a file
named on it disappeared from the tree; the new rule can only judge a
file that is present, so a deleted converted file goes unnoticed. That
is a genuinely smaller check, though its absence shows up in the diff
that deletes the file. The guard's own failure messages are code lines,
so they are scanned by the pattern they belong to: they pass today only
because no bare `EXIT` or `0` happens to follow the keyword in any of
them, and a rewording that introduced one would make the file demand a
debt of itself. It fails loudly rather than quietly, and the fixture
writers and test titles already split the keyword from the signal for
this reason, but the messages do not. Finally,
`docs/agents/e2e-testing.md` bans test-level `EXIT` *and* `RETURN`
traps, while every mechanical guard this repo has had, the one being
deleted included, matches only `EXIT` and `0`. `hack/` holds no RETURN
trap today, so nothing regresses here, but half of that documented rule
has never had an executor.

The guard moves out of `hack/cozyreport.bats` into
`hack/bats-no-exit-trap.bats`, because its subject is every unit suite
under `hack/` and not the report collector it grew up in. Living inside
`cozyreport.bats` is precisely why unrelated PRs kept converging on one
line. `hack/md-no-hardwrap.bats` is the neighbouring precedent.

Separately, the eight EXIT traps in
`hack/run-kubernetes-talos-diagnostics_test.bats` are converted to
end-of-body cleanup rather than given a declaration. That file was
written six weeks after the ban was documented, so "it predates the
rule" is not available to it, and handing it a debt is literally the
move that failed the last two times. The remaining files keep their
declarations, since a mass conversion would collide with the branches
that own them.

One note on where the prose sits. The guard's own header carries only
what a reader has to keep true: why the ban exists, the two carve-outs,
where the declaration lives and why there, that the count is exact in
both directions, why the marker is anchored at column zero, and where
the lexical scan stops. Everything above about *how* the inventory
failed (the timestamps, the PR numbers, the arithmetic) is deliberately
not in the file. It is true today and will rot on its own; a comment
nobody is obliged to maintain is how a file grows a paragraph that is
quietly false.

### Screenshots

<!-- REQUIRED for UI changes: attach screenshots or screen recordings
demonstrating
the visual impact of your changes. PRs with UI changes without
screenshots will not be merged. -->

### Downstream repositories

<!--
Cozystack is upstream for repositories that are NOT kept in sync with it
automatically, and nothing in CI compares the two sides, so a change
here can break them silently.

Which change forces what, and which file to touch, is the trigger map in
docs/agents/contributing.md under "Downstream Repositories". Walk it
before you tick anything.

IF YOU ARE AN AI AGENT OR AN LLM, THIS IS PART OF THE TASK, NOT A FORM
TO FILL IN:

1. Walk the trigger map against the actual diff of this PR, file by
file. Do not decide from the PR title or from your memory of what you
changed.
2. For every repository you tick, open the follow-up PR there and link
it on the same line. A ticked box with no link claims work that does not
exist, which is worse than an empty box. Search that repository for an
open PR or issue covering it first, and link that instead of filing a
duplicate.
3. When the follow-up is out of scope, or needs a decision that is not
yours to make, open an issue in that repository instead, link that, and
say so in the PR body. Do not push a speculative PR into another
repository just to fill a line here.
4. Do not tick "No downstream repository is affected" to make the
checklist go away. If you are unsure, leave every box empty and say so
in the PR body, so a human decides.
-->

- [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:

### Release note

<!--  Write a release note:
- Explain what has changed internally and for users.
- Start with the same `type(scope):` prefix as in the PR title
- Follow the guidelines at
https://github.com/kubernetes/community/blob/master/contributors/guide/release-notes.md.
-->

```release-note
test(tests): each hack/*.bats file now declares its own remaining EXIT-trap debt in a `# EXIT-TRAP DEBT: N` header comment, checked by hack/bats-no-exit-trap.bats, replacing the central inventory in hack/cozyreport.bats
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Testing**
- Added automated auditing for `EXIT` traps across Bats end-to-end
tests, including validation of tracking declarations and edge cases.
- Improved diagnostics tests by replacing trap-based temporary-directory
cleanup with explicit cleanup steps.
  - Added tracking annotations for remaining trap-related cleanup work.

- **Documentation**
- Clarified when traps are permitted inside self-contained subshells and
how remaining cleanup debt is reported.
  - Updated review guidance for consistent end-to-end test maintenance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/testing Issues or PRs related to testing (e2e, bats, unit tests) kind/bug Categorizes issue or PR as related to a bug size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants