Repository navigation
test(e2e): capture previous container logs and stop killing the snapshot - #3346
Conversation
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request improves the diagnostic capabilities of the E2E testing suite by addressing silent data loss during failure collection. It introduces a mechanism to capture logs from previous container instances, which are often critical for debugging crash-looping pods. Additionally, it refines the timeout and budget management for diagnostic tools like Highlights
New Features🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThe PR adds previous-instance container log capture to E2E failure handlers, bounds and records crust-gather output, adds helper tests, updates timeout guidance, and clarifies dataplane capture budget comments. ChangesE2E failure diagnostics
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant E2E as E2E failure handler
participant Logs as e2e-capture-previous-logs.sh
participant K8s as kubectl
participant Gather as crust-gather
participant Snapshot as snapshot artifacts
E2E->>Logs: capture restarted container logs
Logs->>K8s: list pods and fetch --previous logs
Logs->>Snapshot: write container log artifacts
E2E->>Gather: collect with bounded duration
Gather->>Snapshot: write crust-gather.log
E2E->>Snapshot: record completion or truncation status
Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces a mechanism to capture previous container instance logs for crash-looping pods during E2E test failures. It adds a new helper script hack/e2e-capture-previous-logs.sh along with BATS unit tests, integrates this script into both cozytest.sh and Chainsaw's global catch block, and configures explicit timeouts and durations for crust-gather collections to prevent silent truncation. The review feedback suggests improving robustness and portability by avoiding reliance on the executable bit (-x) when executing the capture script, and handling the 0 limit explicitly in prevlog_cap to prevent failures on BSD-based systems like macOS.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if command -v kubectl >/dev/null 2>&1 && [ -x "$(dirname "$0")/e2e-capture-previous-logs.sh" ]; then | ||
| _prev_rc=0 | ||
| timeout -k 30 300 "$(dirname "$0")/e2e-capture-previous-logs.sh" \ | ||
| "$_snap/previous-logs" "${COZY_TEST_NAMESPACE:-tenant-test}" 2>&1 || _prev_rc=$? |
There was a problem hiding this comment.
Relying on the executable bit ([ -x ...]) can be fragile if file permissions are not preserved during git operations or in certain environments. It is safer to check for file existence ([ -f ...]) and explicitly execute the script using sh.
| if command -v kubectl >/dev/null 2>&1 && [ -x "$(dirname "$0")/e2e-capture-previous-logs.sh" ]; then | |
| _prev_rc=0 | |
| timeout -k 30 300 "$(dirname "$0")/e2e-capture-previous-logs.sh" \ | |
| "$_snap/previous-logs" "${COZY_TEST_NAMESPACE:-tenant-test}" 2>&1 || _prev_rc=$? | |
| if command -v kubectl >/dev/null 2>&1 && [ -f "$(dirname "$0")/e2e-capture-previous-logs.sh" ]; then | |
| _prev_rc=0 | |
| timeout -k 30 300 sh "$(dirname "$0")/e2e-capture-previous-logs.sh" \ | |
| "$_snap/previous-logs" "${COZY_TEST_NAMESPACE:-tenant-test}" 2>&1 || _prev_rc=$? |
There was a problem hiding this comment.
Keeping [ -x ] here. The helper is committed with its executable bit (mode 100755) and git preserves mode bits on checkout, so the permission is reliable in the CI environment where this runs. All three call sites of the capture helpers use [ -x ] (hack/cozytest.sh, plus two in hack/e2e-chainsaw/.chainsaw.yaml), so this stays internally consistent; switching two of them to [ -f ] + sh would both diverge from the third and bypass the script's own #!/bin/sh shebang.
| if [ -x ../../e2e-capture-previous-logs.sh ]; then | ||
| timeout -k 30 300 ../../e2e-capture-previous-logs.sh \ | ||
| "${snap}/previous-logs" "${COZY_TEST_NAMESPACE:-tenant-test}" 2>&1 |
There was a problem hiding this comment.
Relying on the executable bit ([ -x ...]) can be fragile. It is safer to check for file existence ([ -f ...]) and explicitly execute the script using sh.
if [ -f ../../e2e-capture-previous-logs.sh ]; then
timeout -k 30 300 sh ../../e2e-capture-previous-logs.sh \
"${snap}/previous-logs" "${COZY_TEST_NAMESPACE:-tenant-test}" 2>&1There was a problem hiding this comment.
Same as the hack/cozytest.sh call site: keeping [ -x ]. The script is committed executable (mode 100755), git preserves that on checkout, and all three helper invocations use [ -x ] for consistency.
| prevlog_cap() { | ||
| _pc_max="$1" | ||
| case "$_pc_max" in | ||
| '' | *[!0-9]*) _pc_max=12 ;; | ||
| esac | ||
| # A zero cap emits nothing; the caller still reports the full overflow count, | ||
| # so the drop stays visible. (GNU head treats -n 0 as an empty result; BSD | ||
| # head rejects it outright, which lands in the same place here -- no rows | ||
| # through -- but is why this is not described as a portable no-op.) | ||
| head -n "$_pc_max" | ||
| } |
There was a problem hiding this comment.
On BSD-based systems (such as macOS), head -n 0 fails with an error (head: illegal line count -- 0). To make the script fully portable and robust across different development environments, we can handle the 0 limit explicitly by consuming the input without calling head.
| prevlog_cap() { | |
| _pc_max="$1" | |
| case "$_pc_max" in | |
| '' | *[!0-9]*) _pc_max=12 ;; | |
| esac | |
| # A zero cap emits nothing; the caller still reports the full overflow count, | |
| # so the drop stays visible. (GNU head treats -n 0 as an empty result; BSD | |
| # head rejects it outright, which lands in the same place here -- no rows | |
| # through -- but is why this is not described as a portable no-op.) | |
| head -n "$_pc_max" | |
| } | |
| prevlog_cap() { | |
| _pc_max="$1" | |
| case "$_pc_max" in | |
| '' | *[!0-9]*) _pc_max=12 ;; | |
| esac | |
| # A zero cap emits nothing; the caller still reports the full overflow count, | |
| # so the drop stays visible. (GNU head treats -n 0 as an empty result; BSD | |
| # head rejects it outright, which lands in the same place here -- no rows | |
| # through -- but is why this is not described as a portable no-op.) | |
| if [ "$_pc_max" -eq 0 ]; then | |
| cat >/dev/null | |
| else | |
| head -n "$_pc_max" | |
| fi | |
| } |
There was a problem hiding this comment.
Applied in f3a0f2d — special-cased a zero cap so it consumes stdin instead of calling head -n 0, which BSD/macOS head rejects. It only bites a local run with COZY_PREVLOG_MAX=0 (CI is GNU coreutils, where -n 0 is already an empty result), but the script targets /bin/sh portability elsewhere, so closing it is consistent.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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/e2e-capture-previous-logs.sh`:
- Around line 9-16: The documentation incorrectly describes kubectl logs
--previous as retrieving the first container instance. Update
hack/e2e-capture-previous-logs.sh lines 9-16, hack/cozytest.sh lines 101-109,
hack/e2e-chainsaw/.chainsaw.yaml lines 88-98, and docs/agents/e2e-testing.md
line 92 to consistently state that --previous captures only the immediately or
most recently terminated instance, with each restart replacing the retained
previous instance; remove any claim that it retrieves the first instance.
🪄 Autofix (Beta)
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
Run ID: d7af508e-79ff-4b6b-995f-14f4cdf1a15c
📒 Files selected for processing (7)
docs/agents/e2e-testing.mdhack/capture-previous-logs.batshack/cozytest.shhack/e2e-capture-dataplane.shhack/e2e-capture-previous-logs.shhack/e2e-chainsaw/.chainsaw.yamlhack/e2e-chainsaw/_lib/run-kubernetes.sh
|
I went looking for evidence to size the tradeoff this PR takes, and ended up auditing e2e diagnostics coverage more broadly — findings in #3368. Two of them bear directly on this PR, so rather than sit on them I want to put them here and let you decide the split. The premise behind the ceiling increase does not hold. GitHub's post-cancellation grace measured ~4m48s in both, and step 17 (a full crust-gather) consumes it, so the upload never starts. This matters more than a stale comment because the same reasoning is being written into On the arithmetic, which I think survives but with less margin than it looks. Marginal cost is +14m per failed test, not +19m, since today's cap is 5m. Against a 95.3m median / 106.9m p90 baseline, three failing tests lands at ~152m / 164m — 16m from the cliff before cleanup (delete 5m + cleanup 5m per test), the failing tests' own timeout burn, What I would suggest, entirely your call on where it lands: The single cheapest fix for both of the above is an internal deadline (~150m) around the chainsaw step, so the job always retains ~30m to collect and upload. That converts the So: would you rather fold the deadline (and the one-line comment correction) into this PR, or land this as-is and take them in a follow-up? Either works from my side. If you prefer to defer, I would still ask for the comment and the docs bullet to be corrected here, since the claim as written will outlive the PR. Worth saying plainly: the rest of this PR is well-evidenced and I verified the core claims independently.
One genuinely useful thing the audit turned up in this PR's favour: crust-gather never collects init or ephemeral container logs at all ( The tenant-cluster gap (previous-logs is wired to the host cluster only, while |
myasnikovdaniil
left a comment
There was a problem hiding this comment.
NOT LGTM, but narrowly — one false sentence in a comment is the only thing I am asking for. Everything else here I verified and want landed quickly.
Detail and evidence are in my earlier comment and in #3368; I will not repeat them.
Why I want this to land fast. It closes two items #3368 independently identified: every crust-gather budget in the repo was really 60s rather than the 300s it read as (--duration was never passed at any call site), and the global error.catch declared 960s of inner budget inside a 300s outer. That second one is not theoretical — 17 jobs in a 400-run window show signal: killed mid-collection having produced nothing, with catch durations bimodal at 60-62s and exactly 300-301s. Roughly a third of our catches currently collect nothing, and this fixes that. The per-op timeout: override is the highest-value line in the diff.
The new script also has a standalone win the PR undersells: crust-gather never collects init or ephemeral container logs at all (src/scanners/logs.rs:107 iterates pod.spec.containers only), so a crash-looping init container is invisible in the snapshot on both clusters. That makes this strictly additive rather than partly redundant.
The one blocking item is inline: the comment justifying the 5m → 19m ceiling asserts that if: always() upload survives a job timeout. It does not, and I measured it on two real jobs. It blocks only because the same reasoning is being added to docs/agents/e2e-testing.md, where it will outlive this PR and mislead the next reader. Correct both and I will approve.
Explicitly not blocking:
- The ~150m internal deadline around the chainsaw step. That is the real fix for the risk this ceiling adds, but it lives in
pull-requests.yamland is separate work — I would rather it not gate this. - The tenant-cluster gap (previous-logs is host-only, while
run-kubernetes.shgets the--durationfix but no previous-logs againstCURRENT_TENANT_KC). Tracked in #3368. - The verdict
caselabelling: crust-gather exits 1 on--durationelapse, never 124, so the most likely truncation currently lands in the*)FAILED arm. Mitigating — no--encodingis passed andEncoding::default() == Path, so the partial tree stays readable and servable.
Two optional fold-ins, entirely your call. You already touch hack/e2e-capture-dataplane.sh here (comment-only), and both of the "blind diagnostics" items in #3368 live in that file:
capture_lb_datapathis called at line 737, but lines 250-253exit 0when no NotReady pod exists — while the script's own header (lines 56-64, 472-474) says the LB section exists precisely for that case and is "independent of the NotReady-pod path above". An unreachable LB with all pods Ready is thekubernetes-*/ MetalLB signature, and the capture never runs for it.- The affected-pod filter requires a non-empty
podIP, but the ciliumendpointManagerleak manifests as pods that never receive an IP — so the script excludes its own headline failure mode.
Both are small and in a file already open here. Equally fine as a follow-up if you would rather keep this PR tight.
| # the report-upload steps are `if: always()` so a job timeout still yields the | ||
| # artifact. If this ever does start truncating runs, bound the number of |
There was a problem hiding this comment.
This is the blocking item, and it is measured rather than argued.
Two real jobs that hit timeout-minutes: 180:
JOB 87848132058 conclusion=cancelled
15 success Collect chainsaw report
16 success Upload chainsaw report
17 in_progress Collect report <- consumes the grace window
18 pending Upload cozyreport.tgz <- never ran
The post-cancellation grace measured ~4m48s in both, and step 17 (a full crust-gather) consumes all of it. Complete job never runs either, so the job log returns BlobNotFound. Artifact listings confirm neither run kept a cozyreport. One other run in the window did keep it, purely because its tail fitted the grace — so the outcome is luck-dependent, not guaranteed.
So a job timeout loses the artifact and the log, which is the inverse of what this sentence claims, and it is the failure mode the raised ceiling makes more likely.
| # the report-upload steps are `if: always()` so a job timeout still yields the | |
| # artifact. If this ever does start truncating runs, bound the number of | |
| # the report-upload steps are `if: always()`. That last point does NOT extend | |
| # to a job timeout: a 180-minute cap ends the job `cancelled`, and the ~4m48s | |
| # grace window is consumed by the earlier `Collect report` step, so both | |
| # cozyreport.tgz and the job log are lost (measured on runs 29557003429 and | |
| # 29568562337). If this ever does start truncating runs, bound the number of |
The matching bullet in docs/agents/e2e-testing.md needs the same correction — that is the copy I actually care about, since it becomes guidance.
There was a problem hiding this comment.
Corrected the comment to your wording in 1ff83f5 — if: always() upload does not survive a job timeout: the 180-minute cap ends the job cancelled and the grace window is spent on report collection, so both the artifact and the job log are lost.
On the docs side: I checked docs/agents/e2e-testing.md at HEAD and that reasoning isn't there. The bullets this PR adds cover previous-instance capture, sizing a catch's timeout: above its inner budgets, and setting crust-gather --duration — none of them claim a job timeout still yields the artifact. So the corrected comment is the only copy of that claim in the tree. If you'd still like an explicit job-timeout caveat written into the standing guidance, I'm happy to add one — just say the word.
Root-causing a crash-loop failure was blocked twice over by the diagnostics collected on failure. `kubectl logs` shows only the current container instance, so for a crash-looping pod the suites' `podLogs` collector captured the Nth restart -- a fresh replay of the same startup path -- while the evidence that explained the failure sat in the first, interrupted instance. Chainsaw's podLogs collector has no `previous` option, so add e2e-capture-previous-logs.sh and drive it from the global catch and from the BATS failure hook. It discovers containers with restartCount > 0 at runtime (init containers included, where an interrupted bootstrap lives), orders the app suites' namespace first, and dumps each to the job log and the snapshot tree. Containers that never restarted are skipped, so a healthy run emits one line rather than a page of "previous terminated container not found". Every way that capture can come up short is named rather than inferred, because kubectl exits 1 for a garbage-collected container, an RBAC denial and an unreachable node alike: the container cap, a pod list that never returned (which is not the same as nothing having restarted), a read that timed out versus one kubectl refused and the reason it gave, and a previous instance that produced no output. A partial capture is kept and marked truncated rather than deleted -- it is the only copy of a log already gone from the cluster. The crust-gather snapshot was killed by the Chainsaw op containing it. A script op is bounded by timeouts.exec (5m), and the catch's own wall-clock wrappers summed to 960s, so the script was SIGKILLed partway through -- taking out the process group mid-write and skipping the data-plane capture entirely. Give each catch op an explicit timeout above the budgets it contains, so an inner timeout fires instead and leaves a partial, still-servable snapshot behind. The data-plane leg drops from 600s to 300s at that call site so both legs fit one envelope; the BATS hook keeps the wider budget, having no containing op. Set crust-gather --duration explicitly as well. It is the collection phase's own budget, it defaults to 60s, and on elapse it abandons the collection and skips its finish step, leaving a truncated tree with no indication anything is missing. The outer wall-clock has to exceed it because the API discovery that runs first is not covered by that budget at all -- with an unhealthy aggregated APIService, the state a failed run is usually in, discovery alone can consume the whole timeout before a single object is written. Send each collector's output to a log beside its snapshot instead of /dev/null, with a complete/truncated/failed verdict, so that question is answerable from the artifact. Assisted-By: Claude <[email protected]> Signed-off-by: Aleksei Sviridkin <[email protected]>
7be8ff4 to
07c67a8
Compare
The catch-timeout comment justified the raised ceiling by asserting that if: always() upload survives a job timeout. It does not: a 180-minute cap ends the job cancelled, and the post-cancellation grace window is consumed by the earlier report-collection step, so both cozyreport.tgz and the job log are lost. Correct the comment to describe the real failure mode. Assisted-By: Claude <[email protected]> Signed-off-by: Aleksei Sviridkin <[email protected]>
kubectl logs --previous returns the immediately preceding terminated container instance, not the original first one — after multiple restarts the first instance is already gone. Reword the helper, the BATS and Chainsaw failure handlers, and the diagnostics doc to match the actual --previous semantics. Assisted-By: Claude <[email protected]> Signed-off-by: Aleksei Sviridkin <[email protected]>
A zero container cap reached head -n 0, which GNU head treats as an empty
result but BSD/macOS head rejects ("illegal line count -- 0"), aborting a
local run. Special-case the zero cap to consume stdin instead, so the
helper behaves the same on both coreutils flavours.
Assisted-By: Claude <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>
|
Thanks for the measured review and for verifying the core claims independently. Landed the blocking item: The Also corrected the The explicitly-not-blocking items — the ~150m internal deadline in |
myasnikovdaniil
left a comment
There was a problem hiding this comment.
LGTM.
Rechecked the current head after f3a0f2d:
- the blocking timeout comment now correctly states that a job timeout loses both the artifact and job log;
- previous-instance wording matches kubectl --previous semantics;
- the BSD head -n 0 case is covered;
- the focused previous-log Bats suite passes locally;
- required GitHub CI, including pre-commit and E2E, is green.
This resolves my earlier change request.
What this PR does
Root-causing a recent E2E failure was nearly blocked because the diagnostics captured on failure had collected almost nothing. Two independent mechanisms were responsible, and both had been silently in place for a long time.
The failing container's previous logs were never captured. The pod had crash-looped, so the decisive evidence lived in the first container instance, reachable only through
kubectl logs --previous. Chainsaw'spodLogsaction has nopreviousfield, so this could not be expressed per-suite; the conclusion had to be reconstructed from indirect evidence instead. A new capture runs from the global catch and from the BATS failure hook, selecting containers at runtime byrestartCount > 0across both init and regular containers, so a healthy run stays quiet and future suites are covered without per-suite wiring.The catch was structurally guaranteed to be killed. A Chainsaw
scriptop is bounded bytimeouts.exec, which is 5m, while the catch's own inner budgets summed to roughly 960s. It reportedSCRIPT | ERROR | signal: killedand produced nothing — and its second leg, the data-plane capture, had therefore never run at all. The kill signature was itself misleading:timeoutexits 124 and cannot produce that message, which comes from Go'sexec.Cmdkilling the process group. Each catch op now carries an explicit timeout above the budgets it contains, so the inner one fires and leaves a partial, usable artifact instead of losing everything.The snapshot was truncated at 60 seconds.
crust-gather --durationdefaults to 60s and was never passed, so every snapshot ever taken was cut short, with nothing in the artifact saying so. The API discovery it performs first is not covered by that budget at all, which is where the outer wall-clock was going on a cluster with a degraded aggregated APIService — precisely the state a failed run is in. Both budgets are now set explicitly, and the collector's output goes to a log inside the snapshot with a completeness verdict, so "is this snapshot complete?" is answerable from the artifact.Failure paths are reported rather than swallowed: an unreachable API says so instead of implying nothing had restarted, a truncated capture is kept and marked, and every dropped item is logged, since silent truncation reads as full coverage.
What is not verified here
The mechanism claims are read from the pinned versions of Chainsaw and crust-gather. Everything cluster-facing is not verified by this PR: the go-template's output shape against a live API server,
--previousreturning the first instance for a real crash-looper, and whether the new collection budgets suffice on a degraded cluster. The bats suite covers the selection helpers only, and those cases are mutation-tested.Release note
Summary by CodeRabbit