Three of the four workflows on the escalation list cannot be exercised by the run they trigger. `e2e-fork` runs on `workflow_run`, which always takes the default-branch copy; `e2e-tag` runs on `workflow_call` from a tag push; `nightly` runs on a schedule. Only `pull-requests.yaml` is executed from the pull request's own head, so only there does escalating test the edit rather than the tree around it. That argument existed in the reference doc and was removed by this branch rather than answered, and the list it argued against is what replaced it. Measured over six months of main, 26 commits touch one of the three with no other escalating path; each now pays a full Chainsaw run it used to skip, on a suite whose flake rate is the reason people avoid running it for nothing. Keeping them is defensible: the rule stays one idea rather than two, and dropping them leaves those files inert, which reads as an oversight rather than a decision. What was not defensible was paying it silently. The trade now sits next to the pattern that makes it, with the number, so the next person weighing it starts from measurement instead of rediscovering the mechanism. The reference doc points there rather than carrying its own copy. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
44 KiB
E2E Testing Conventions
Guidance for writing, changing, and reviewing Cozystack's end-to-end (E2E) tests and the CI that runs them. Read this before touching anything under hack/e2e-chainsaw/, the bootstrap/OpenAPI BATS (hack/e2e-*.bats), hack/*.sh test helpers, either E2E CI workflow (.github/workflows/pull-requests.yaml for same-repo pull requests, .github/workflows/e2e-fork.yaml for forks), or packages/core/testing/. §10 covers how the two split the work and which invariants hold them together.
The app suite is Kyverno Chainsaw (hack/e2e-chainsaw/, one directory per app, each with a chainsaw-test.yaml). Cluster bootstrap (hack/e2e-install-cozystack.bats, hack/e2e-prepare-cluster.bats) and the OpenAPI checks (hack/e2e-test-openapi.bats) remain BATS.
The core principle
Retries do not recover flakes — they hide deterministic bugs and triple diagnostic wall-time. An audit of 30 PR runs found that across sampled failures, 25/25 retry attempts failed: the retry loop never once turned a red run green, it only delayed surfacing real bugs (Helm namespace conflicts, seaweedfs/harbor races, a vminstance disk race) that looked like flakes because the retry sometimes coincided with transient state clearing.
Every convention below follows from that finding: fail fast, fail loud, make the failure legible. A test that flakes is a test (or a product) with a real race to fix, not a test to wrap in a retry. Chainsaw's per-assertion polling is the structural expression of this: each assert waits on its own condition to its own timeout, and a failure is reported as a structured diff plus auto-captured events/describe/logs — not a generic non-zero exit.
Conventions
1. No retries on deterministic steps; retry only pure infrastructure
Run E2E testsandInstall Cozystackrun once. On failure they print❌ ... (no retry — see diagnostics below)and dump state. Do not reintroduce a retry loop around them. The E2E step is a singlechainsaw testinvocation — there is no per-app retry loop to bring back, because Chainsaw already polls each assertion to its own timeout.Prepare environmentkeeps its 3× retry — and only it — because it is pure infrastructure (Talos image download, sandbox VM boot, network) where a transient runner hiccup is a genuine flake with no application logic involved.- Rule of thumb: retry is justified only for a step that contains no product or test logic.
2. Prefer declarative asserts over imperative waits
A Chainsaw assert polls until the resource matches its expected shape or the per-operation timeout fires, so it subsumes both "the resource exists" and "the field/condition reached this value" in one operation — the old until kubectl get …; do sleep 2; done existence backstop followed by kubectl wait --for=… is no longer needed.
- assert:
timeout: 6m
resource:
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: postgres-test
status:
(conditions[?type == 'Ready']):
- status: "True"
- Condition checks use the filter-as-list form
(conditions[?type == 'Ready']). Chainsaw v0.2.15 throws "field not found" on the indexed(...)[0]form. - Imperative checks that no Kubernetes API condition models — S3 reachability through a port-forward, an external LB HTTP path, MetalLB advertisement — stay in a
scriptstep. A baresleep Ninside such a script is allowed only when nothing better models the wait; annotate it with aTODO(e2e-replace-fixed-timeouts):comment. The bootstrap BATS (hack/e2e-install-cozystack.bats,hack/e2e-chainsaw/_lib/run-kubernetes.sh) carries the sanctioneduntil kubectl getexceptions.
3. Let Chainsaw own cleanup; no test-level EXIT/RETURN traps
Trap-based cleanup was the single biggest source of false failures in the BATS suite: an EXIT trap ran cleanup in a context where shell variables were unset, set -u killed it, and a successful test was marked failed — then retried twice into real failures.
- Chainsaw deletes the resources it
apply-ed during its cleanup phase (bounded by thedelete/cleanuptimeouts inhack/e2e-chainsaw/.chainsaw.yaml). Do not hand-roll teardown for resources Chainsaw created. - A self-contained
trap '… ' EXITinside a singlescriptstep — to kill a port-forward or remove a temp dir — is fine, because it runs in a contained subprocess with its variables in scope. Seehack/e2e-chainsaw/bucket/chainsaw-test.yaml. What is banned is test-level trap-based cleanup of the BATS kind. - The ban extends to the unit BATS under
hack/*.bats, for a second reason worth knowing before you debug one: anEXITtrap inside an@testbody replaces the one thebatsbinary installs for its own bookkeeping, and a test that then fails prints no TAP line at all. It does not appear asnot ok; it disappears, and the run ends with# bats warning: Executed N instead of expected M testsand a non-zero exit. Verified with Bats 1.14.0. Anyone reading the tail of the output, or grepping it fornot ok, sees a green suite — and the CI runnerhack/cozytest.sh, which is not thebatsbinary, reports the same failure correctly, so the two disagree exactly when it matters. Clean up at the end of the test body instead: both runners set-e, so the cleanup is unreachable on failure and the scratch directory is left behind for inspection, which is what you want from a failed test anyway. When a suite reports zero failures, confirm it also reports how many tests it ran: an exit code answers "did anything fail", never "did anything run". A guard test inhack/cozyreport.batsenforces this over a named list of files, which grows as the remaining ones are converted; a file not yet on that list may still carry a trap, so reconcile its1..Nplan against itsokcount before believing a green-looking run.
4. Do not mask cleanup or teardown failures
- Resources a test creates that Chainsaw cannot reclaim — because a controller, not the test, owns them (e.g. the Velero
Backup,BackupStorageLocation, and credentials secret incozy-veleroin the disabledhack/e2e-chainsaw/backup/suite) — must be pruned explicitly. Do not|| trueover a stuck delete and leave stale state for the next run. - For nested tenants, tear down child → parent with a hard wait for deletion between each. Deleting the parent while a child is still uninstalling wedges the parent's cleanup Job on the child namespace, and both stuck uninstalls occupy helm-controller workers past the end of the test — starving whichever suite runs next. The kubernetes suites encode this ordering in
hack/e2e-chainsaw/_lib/run-kubernetes.sh.
5. The install gate must have teeth
The install test fails if any HelmRelease is not Ready. A toothless gate (backgrounded kubectl wait discarding child exit codes + a bare echo instead of exit 1) once shipped a permanently-failing platform HR through green CI for weeks.
- Use a single
kubectl wait hr --all -A, then an outcome-based re-list (covers HRs created after the snapshot), andexit 1on any non-Ready HR. - Dump the full Ready-condition message per non-Ready HR so the real error is in the test output. See
hack/e2e-install-cozystack.bats.
6. Assert the parent HelmRelease did not remediate — via status.history
A parent HelmRelease that hit its wait timeout, uninstalled, and reinstalled is a silent race we want to catch. Do not check .status.installFailures / .status.upgradeFailures: Flux's ClearFailures() zeroes those on every successful reconcile, so checking them after the HR is Ready is vacuous and passes against a reverted fix.
- Inspect
.status.historyinstead — afailedoruninstalledSnapshot survives a later successful reconcile. Use the shared helper inhack/e2e-chainsaw/_lib/remediation-guard.sh(helmrelease_has_remediation_cycle) from ascriptstep.
7. Test-Impact Analysis (TIA), default-on
hack/select-e2e.sh walks the packages/core/platform/sources/*.yaml dependency graph and runs only the Chainsaw suites affected by a diff. A suite is a directory under hack/e2e-chainsaw/ containing a chainsaw-test.yaml.
- Conservative escalation: edits to
packages/library/,packages/core/, the Go trees (api/,cmd/,internal/,pkg/), the codegen undertools/,go.mod/go.sum, top-levelhack/*.sh|*.batshelpers and thehack/lib/they source,hack/*.mk,hack/buildkitd.toml,hack/e2e-*.yaml, the sharedhack/e2e-chainsaw/_lib/, the chainsaw confighack/e2e-chainsaw/.chainsaw.yaml, theMakefile, or any workflow that runs the suite escalate to the full suite. Onlypull-requests.yamlis executed from the pull request's own head, so escalating on the other three buys generic regression coverage rather than coverage of the edit; the trade and its measured cost are written next tofull_suite_patternrather than repeated here. A per-suite edit (any file underhack/e2e-chainsaw/<app>/) selects only that suite. - Every path is classified, and an unclassified one escalates. The selector has three outcomes — full suite, a scoped set, or nothing — and each is reached by an explicit rule. Several rules produce the empty one, which is worth knowing before reading an empty selection as "the inert list matched": documentation, dashboards and
*.mdare dropped first wherever they live, aexamples/backups/<app>/edit whose app has no suite is dropped where backup examples are matched, andinert_config_patterncovers repo meta, agent config andhack/testdata/.full_suite_patterncovers shared build inputs and the e2e harness, and the graph lookup handlespackages/(apps|system|extra)/. A path matching none of them escalates to the full suite and logs why, because both lanes read an empty selection as "skip Chainsaw" and then postE2E Testsgreen — so a silent default is a green gate with nothing tested (#3392). When that escalation fires for something genuinely inert, add it toinert_config_pattern; do not relax the fall-through. The escalation list and the inert list are checked in that order, so a specific escalation beats a broad inert directory. - The one path the fall-through cannot catch is a new e2e workflow.
inert_config_patternmakes.github/inert as a directory, and the workflows that run the suite are exempted from it by name infull_suite_pattern. A workflow added under.github/workflows/therefore reads as inert until someone adds it to that enumeration, and the fall-through never sees it. That is why the list carries arg -l test-chainsaw .github/workflows/reminder: the enumeration is the guard, not a convenience. - The
full-e2ePR label forces the whole suite. - The selection is passed to
make test-chainsaw CHAINSAW_SUITES="<names>", which runschainsaw test <names>fromhack/e2e-chainsaw/(empty = the whole suite). - The coverage gap TIA opens on PRs is closed at release-cut time: the "Prepare release" commit bakes image digests into
packages/core/(and other packages), which TIA escalates to the full suite — so the release PR exercises the whole suite against the commit the tag will point at.
When adding a new app package, confirm select-e2e.sh maps it correctly (it has a unit test, hack/select-e2e_test.bats). A new app whose suite directory does not yet exist will not be selected — add hack/e2e-chainsaw/<app>/chainsaw-test.yaml first.
What TIA does and does not do
Be precise about TIA's scope — it is narrower than "skip E2E for unrelated PRs," and the wiring has consequences worth knowing before you rely on it or change it.
- It trims only which suites
chainsaw testruns, not the expensive stages.Prepare environmentandInstall Cozystack(the full platform install) run regardless of the selection. TIA only decides theCHAINSAW_SUITESpassed to the final E2E step. A perfect narrow saves the suite tail, not the bulk of wall-time. (Package image builds are scoped separately — byhack/build-matrix.sh, on the PR diff — while the Talos and installer/finalize legs still run for non-docs PRs; that is a distinct mechanism from TIA's suite trimming.) Select E2E testsruns afterInstall Cozystackand has noalways(). GitHub implicitly ANDs a step'sif:withsuccess(), so if install fails the selector (andRun E2E tests) are skipped entirely. On a red install you will not see TIA narrow anything — that is the ordering, not a selector bug.- Most PRs legitimately escalate to the full suite. Platform manifests live under
packages/core/, Go underapi//internal/, helpers underhack/, plus theMakefile— touching any one triggers the full suite. So "all suites selected" is usually correct, not a failure to narrow. - Two skip layers, with a gap. The
planjob computes a coarse docs-only gate (code, true for any change outsidedocs/**) that skips the whole build+E2E pipeline for docs-only PRs, plus a per-package build matrix (hack/build-matrix.sh) that scopes which images build. TIA's finer skip (select-e2e.shignoringdocs/,dashboards/,*.mdand the rest ofinert_config_pattern) only trims the suite selection. A PR touching onlydashboards/*.jsontherefore still runs install, then skips just the Chainsaw suites — near-zero savings. If you need such PRs to skip the pipeline, widen theplandocs-gate, notselect-e2e.sh.
8. Failure must be self-explanatory — diagnostics are first-class
- On install failure, the CI step dumps
kubectl get hr -A -o wide, adescribeof each non-Ready HR, and sorted events, all under collapsible::group::blocks. - On E2E failure, the CI step dumps
kubectl get hr -A, sorted events, and COSI bucket/claim/access readiness columns (those CRDs ship no printer columns, so plaingetshows only NAME/AGE). Thechainsaw testrun is one step, so this dump is at run level; Chainsaw's own per-Testcatchblocks add scoped events/describe/podLogs for the failing suite. - Inside a suite, attach scoped diagnostics with a top-level
catch:block (events,describe,podLogs,get) rather than letting a bare assert failure through — seehack/e2e-chainsaw/bucket/chainsaw-test.yamlandhack/e2e-chainsaw/harbor/chainsaw-test.yaml. This replaces the BATS... || { echo …; kubectl get/describe …; false; }idiom. make collect-report→hack/cozyreport.shuploads a fullcozyreport.tgz(operator/Flux/cert-manager logs, LINSTOR, Kamaji, Talos dmesg, COSI YAML) on every run, and the Chainsaw JUnit report (chainsaw-report.xml) is uploaded as its own artifact.- The report's per-pod evidence, under
kubernetes/pods/incozyreport.tgz, is selected by the READY column rather than by STATUS — so a pod left at0/1 Runningby a readiness probe that never passes is in scope, where before it was not. In scope is not the same as collected: the walk stops atCOZY_REPORT_PODS_MAXpods orCOZY_REPORT_PODS_BUDGETseconds, and what it did not reach is named in the artifact rather than dropped in silence, as the next point describes. Selecting on the STATUS column alone drops exactly that pod, and it is a common way for a suite to time out: the replica never joins, its HelmRelease sits inInProgress, and the artifact carries no trace of the pod the run died on. TerminalSucceeded/Completedpods stay excluded, since they legitimately end unready. Widening the selection and capping the walk are only safe together if the widening cannot displace what the uncapped walk always kept, so the collection order splits by evidence class before namespace: every pod that is notRunning— a crash loop,Pending,Error, the class the collector took before this — is ordered ahead of every pod that isRunningbut unready, andCOZY_REPORT_PREFER_NSorders within each of those two classes rather than across the whole selection. Otherwise a failed install with more not-Ready pods than the cap spends it on newly-in-scope tenant pods and drops the container logs of the platform pod that is actually crashing. - Every bound that the report's pod collection hits is named in the artifact, not only in the job log. The pod walk stops at
COZY_REPORT_PODS_MAXpods orCOZY_REPORT_PODS_BUDGETseconds and leavesCOLLECTION-TRUNCATED.txtnaming whichever fired; a pod it cut off part way through leavesCONTAINER-LOGS-TRUNCATED.txt; a pod list that never came back leavesCOLLECTION-FAILED.txtrather than the empty tree a healthy cluster produces; a missing localtimeoutleavesCOLLECTION-UNBOUNDED.txt, because the reads still run, just without a ceiling, and a missing dependency on the machine writing the report is not a statement about the cluster; a kind this cluster does not serve at all —kubectl get vm -Aon an install without KubeVirt, which exits non-zero — leavesKIND-NOT-SERVED.txtrather than a failure marker, since that is the cluster answering correctly and a marker that fires on healthy installs is one a reader learns to skip; and a knob value the collector could not use is named too, appended toCOLLECTION-TRUNCATED.txtwhen there is one, written toCOLLECTION-NOTES.txtwhen there is not, and appended toCOLLECTION-FAILED.txtinstead when the pod list itself never returned, so the note always sits beside the failure it accompanies rather than in a file the reader has no reason to open, and a bound that turns out to be the default is not mistaken for the bound that was asked for. A pod whose container list came back as a prefix — kubectl named some containers and then failed — leavesCONTAINER-LIST-INCOMPLETE.txtbeside the logs it did manage to read, because the containers it never named have no file at all and their absence would otherwise read as containers that were silent. A read that succeeded while kubectl also wrote to stderr — a deprecation notice, a partial-result warning — leaves that message inREAD-WARNINGS.txtbeside the object rather than inside it, sincepod.yamland the logs are machine-read and a warning line in them costs the parser the file, while dropping it altogether would be the silent loss of evidence this tree exists to prevent. A pod directory whose containers produced any log at all also carriesCONTAINER-LOGS-BOUND.txt, naming the--tailbound those logs were written under (a pod whose every container was silent has no bounded log to describe, so the file is absent), because--taildrops the oldest lines and nothing in a log says so; it sits beside the logs rather than inside them, since a log read in full is a complete artifact and the platform's controllers log JSON per line, so a trailing prose line would break every parser that could read the file before. A pod whose container list could not be read leaveslogs-UNAVAILABLE.txtin its own directory, carrying kubectl's reason, because a pod directory holdingpod.yamlanddescribe.txtand no logs at all otherwise reads as a pod whose containers were silent. The VM, VMI and services loops carry the same pair of bounds as constants rather than knobs, so a failed install that leaves every LoadBalancer without an address collects the first of them and names the rest instead of running the report step past its owntimeout-minutes: 30, which would lose the tarball rather than truncate it, since the collector writes it on its last line. That step ceiling is the outer backstop for a read with no bound of its own; it does not save this report, it saves the steps after it, the other uploads included. It carriescontinue-on-error: truealongside, because the step's|| trueis its standing statement that collecting diagnostics is never fatal and a step killed at its ceiling never reaches that|| true— without it a passing run whose collection ran long would be published as a failed one, and every laterfailure()in the job would start meaning "the tests failed or the report was slow". That tolerance also hides the overrun, since the step then renders green, so a following step turns the collect step'soutcomeinto a::warning::annotation — a bound that fires is recorded, and the outer bound is not exempt from the rule the inner ones follow. The LINSTOR reads that run a command inside a pod carry a ceiling too — they are the only reads whose duration a workload sets rather than the apiserver, and a wedged controller holds the stream open with nothing on either end to close it — and they record a cutoff inCOLLECTION-FAILED.txtbeside their output, which for the two that stream a gzipped tarball is the only place it can go, since a line of prose inside one is an archive nothing opens; the four that produce a table carry the marker in the file as well. A LINSTOR table that got nothing at all — the wedged controller killed before it wrote a byte — says so in the file rather than shipping at zero length, and a read whose stderr could not be captured says that too instead of reporting silence. A LINSTOR table cut short carries the marker inside itself whether the clock or the command ended it, in the same# [cozyreport]form every other in-file marker uses so one anchored grep finds them all, and the note about a satellite pod list that came back short lands underlinstor/error-reports/beside the bundles rather than in the shared Kubernetes file, like every other.txtin the tarball, because a short node list whose explanation sits one directory up reads as a small cluster; the two archives cannot take one, since a line of prose inside a gzip stream is a file nothing opens. A cut-off bundle goes one of two ways and the note names both: a stream that got members across is kept and truncated, so a short member list there is the cutoff rather than a satellite with few reports, while a stream killed before its first byte leaves zero bytes and the emptiness check at the call site removes it. The reader runs before that check, so it says which two outcomes are possible instead of asserting one. The four LINSTOR reads whose output is a table keep the remote command's whole message in the file itself when the command produced no table of its own — a partial table plus an error keeps the message beside the file instead, so the table stays a table — becausekubectl execappends its owncommand terminated with exit code Nlast — so the last line there is the one line that never carries the cause, and a listing sent away from its own error is a zero-byte file saying nothing. That note states what the status leaves open instead of resolving it:kubectl execreturns the remote command's own exit status, so a 124 there is either the collector's deadline firing or the command itself exiting 124, and nothing separates the two. Every other read in the collector carries a ceiling too, gates included — anif kubectl get deploy …that decides whether a module runs is a read, and an unbounded one ends the whole report before the module it guards. A gate cut off short is recorded rather than treated as an answer, because skipping the module produces the same empty tree as a cluster that never had the component; a gate that answers "not installed" is recorded nowhere, since that is the cluster answering correctly and a marker firing on every install without KubeVirt is one a reader learns to skip. What a per-read ceiling does not give you is a ceiling on the report: the pod, VM, VMI and services walks carry a count cap and a time budget, and every other per-object walk in the collector carries neither, so their cost is the cluster's to set and a failed install with a hundred not-Ready HelmReleases spends four hundred bounded reads there, four per release. Described as "the walks outside those four" rather than listed by name, because a list of them drifts and reads as complete while it does so — which is the defect this same change removed from the collector's own header. The step'stimeout-minutesis what bounds that, not the per-read wrapper. Whichever bound stopped a walk says so inCOLLECTION-TRUNCATED.txt, count and clock alike, because objects removed by the clock while the count note stood alone would read as a cap that never fired. The read that feeds each loop is bounded too: the budget cannot start until rows arrive, so an apiserver hanging on the cluster-wide list would otherwise spend the whole collection step with the deadline never consulted. The budget is a deadline for starting a read rather than for finishing the walk, and it is rechecked between containers but not between a container's current and previous log, so the ceiling is the budget plus one pod's object reads plus both log reads of one container, which is the price of never leaving a half-written file behind. COZY_REPORT_POD_TAILbounds each container log at 2000 lines by default, and a log that was read successfully records which bound it was written under, since--taildrops the oldest lines and a file that begins mid-stream looks exactly like a complete one;-1restores kubectl's own default of the whole log.COZY_REPORT_PREFER_NS(defaulttenant-) is collected first, so a cap that only bites on a broadly degraded cluster is not spent on platform namespaces while the pod the suite died on falls off the end.- Capture the PREVIOUS container instance, not just the current one. For a crash-looping pod
kubectl logs(and the ChainsawpodLogscollector, which in v0.2.15 has nopreviousoption — it does acceptname/namespace/selector/container/tail/timeout/cluster/clusters) shows the Nth restart — a fresh replay of the same startup path — while the evidence that explains the failure sits in the immediately preceding instance, reachable only via--previousand lost for good once the kubelet garbage-collects that container.hack/e2e-capture-previous-logs.shcloses this once for every suite from the globalerror.catch(and fromhack/cozytest.shfor the BATS tests) rather than per-suite: it discovers containers withrestartCount > 0at runtime — init containers included, since an interrupted bootstrap lives there — orders the app suites'tenant-testnamespace first (a best-effort ordering only — it never restricts the capture, and for a suite whose pods live in a child tenant namespace it simply degrades to input order), and dumps each to the job log and tosnapshots/<test>/previous-logs/. Containers that never restarted are skipped, so a healthy run emits one line, not a page of "previous terminated container not found". Every way the capture can come up short is named rather than inferred: theCOZY_PREVLOG_MAXoverflow, a malformed cap, aCOZY_PREVLOG_TAILthat was rejected and fell back to 200 (a zero or leading-zero tail asks kubectl for nothing, which would otherwise be reported as the container having logged nothing), the tail bound each kept log was written under — named incapture-notes.txtbeside the logs rather than inside them, since--taildrops the oldest lines and nothing in the output says so while a log read in full is a complete artifact that a trailing prose line would stop any parser from reading, and suppressed on-1because no bound was applied then — a pod list that never returned (which is NOT the same as "nothing restarted" and must not be reported as such), a read that timed out versus one kubectl itself refused (kubelet GC, RBAC, an unreachable node — different problems that a blanket "kubelet GC" line would hide), a previous instance that produced no output, and a capture the outer backstop cut short. A partial log is kept and marked truncated, never deleted — it is the only copy of something already gone from the cluster. Each of those lines the script itself emits is written tosnapshots/<test>/previous-logs/capture-notes.txtas well as to the job log, and the directory is created before the first of them, so a reader who has only the uploaded artifact can still tell a capture that found nothing from one that never ran. The outer backstop firing is the exception: that line comes from the callers, after the script has already been killed, and reaches the job log only. - A catch script needs an explicit
timeout:that exceeds the inner budgets it contains. A Chainsawscriptop is bounded bytimeouts.exec(5m in.chainsaw.yaml) unless it overrides it, so a catch whose owntimeout -k …wrappers sum past that is structurally guaranteed to be killed — it reportsSCRIPT | ERROR | signal: killedand yields nothing. That kill is strictly worse than an innertimeoutfiring: SIGKILL takes out the process group, so the collector dies mid-write and every later step in the script is skipped, whereas an innertimeoutexits 124, leaves a partial capture on disk, and lets the rest of the catch run. Size the op timeout above the sum of the inner budgets and let the inner ones be what fires. - Set
crust-gather --durationexplicitly. It is crust-gather's own budget for the collection phase and it defaults to 60s; on elapse it abandons the collection and skips its finish step, leaving a partial tree with nothing to say the rest is missing. The outer wall-clock must exceed it, because the API discovery crust-gather runs first is not covered by that budget at all — with an unhealthy aggregated APIService (the state a failed e2e is usually in) aggregated discovery times out and the fallback re-walks every group, which can consume the entire outer timeout before a single object is written. Do not diagnose a killed snapshot by bumping the outer number alone; the two budgets bound different phases. Send the collector's output to a log inside the snapshot rather than/dev/nullso "complete or truncated?" is answerable from the artifact. - Per-failed-test crust-gather snapshots are the richest diagnostic — a
crust-gather serve-able archive of the cluster at the moment of failure, before cleanup. The globalerror.catchinhack/e2e-chainsaw/.chainsaw.yamlcaptures the host cluster into_out/cozyreport/snapshots/<test>/hoston any failure (the Chainsaw analog of thehack/cozytest.shEXIT-trap snapshot used by the BATS tests); the kubernetes suites additionally snapshot their tenant cluster fromhack/e2e-chainsaw/_lib/run-kubernetes.shwhile the tenant API LB is still routable.hack/cozyreport.shfoldssnapshots/into the artifact. Do not remove the global catch — without it the Chainsaw suite uploads a report with no per-test cluster state.
9. Keep the test environment deterministic
- Pre-pull every kubeovn / linstor / cert-manager image onto all nodes before those HelmReleases install (
hack/e2e-prepull-images.sh), so clustered workloads (OVN raft, LINSTOR, the cert-manager webhook) do not fail on per-node image-pull stagger. - Exclude loop devices from host LVM scanning in the Talos machine config so the host does not activate volume groups inside loop-mounted e2e disk images.
- Fail fast on node readiness (≈5m, then bail) rather than marching into LB/NFS tests that will also fail — it saves several minutes per attempt and keeps the real failure at the top of the log.
10. The E2E workflow split: same-repo vs fork, and the "E2E Tests" commit status
A fork's pull_request run carries no registry credentials, so it cannot push the images the e2e sandbox pulls. E2E therefore runs across two workflows:
.github/workflows/pull-requests.yamlruns e2e in-tree for same-repo PRs (thee2ejob, namedE2E (in-tree)). Fork PRs skip this job..github/workflows/e2e-fork.yamlruns e2e for fork PRs via a privilegedworkflow_runthat fires after the fork's Pull Request run completes. It pushes the fork-built images to the registry and runs the suite — the same Chainsaw + TIA path as in-tree (§7) — in an ephemeral sandbox that itself has no push credentials.
Consequences to keep in mind when touching either workflow:
-
Fork builds export, they do not push (
OCI_EXPORT_DIR). The fork's unprivileged build setsOCI_EXPORT_DIR(seehack/common-envs.mk) so every image is written to a per-image OCI archive artifact instead of being pushed; the privilegede2e-fork.yamllater pushes those archives to the registry by digest. Any build recipe that shells out to push directly — e.g. askopeo copy … docker://…that bypasses theimage-tags/oci-outputmacros — must honourOCI_EXPORT_DIRtoo, or a fork build dies with an anonymous-pushdenied(#3257). This is covered byhack/common-envs_test.bats. -
"E2E Tests" is a commit status — not a job, and not a check-run. The required merge-gate context named
E2E Testsis a GitHub commit status, posted by theplan/e2e-reportpair for same-repo PRs and bye2e-fork.yaml'sresolve/reportpair for forks — not thee2ejob itself. Each lane opens the context withpendingand concludes it when the suite is done, and the opener is not optional: a commit status never expires, so a head SHA that already went green would otherwise keep reading green from the moment a re-run, a reopen or afull-e2elabel starts a new run until that run reports, leaving the pull request mergeable across a suite that may be about to fail. While the gate was a job, a fresh non-success check run appeared automatically and closed that window. The two lanes close it at different speeds, and the fork lane does not close it entirely. Same-repo, the opener isplan's first step, so the window lasts seconds. On a fork the opener isresolve, which cannot run until the unprivileged build has finished, so re-running an already-green fork head leaves the old green readable for the length of that build. The gap is structural rather than an oversight: a fork'spull_requesttoken is read-only and cannot write a status at all, which is the same property the rest of this design relies on. It is bounded by the build and it is the one case where "the opener closes the window" is not literally true. Two reasons it is a status:- Not a job: a required job that is skipped (as the
e2ejob is for forks) counts as passed by branch protection — exactly how fork PRs used to merge with e2e never run (#3257). A status issuccessonly when the report step posts it; a never-posted required status stays "Expected" and blocks the merge. If you rename thee2ejob, do not name itE2E Testsand do not wire branch protection to a job name — the status must stay the gate. - Not a check-run: a check-run created with the default
GITHUB_TOKENfloats into an arbitrary GitHub Actions check-suite (in practice the earliest-registered one, a tiny labeler — that is whyE2E Testsonce rendered under "PR size label"), and an app-token check-run would need the COZYSTACK_CI app grantedchecks: write. A commit status has neither problem: branch protection matches it by context name and it carries no check-suite grouping. It is safe to post with the default token because a fork'spull_requesttoken is read-only and cannot post a status — so only same-repo runs (plan's opener ande2e-report, both guarded!fork) and the privilegedworkflow_runine2e-fork.yamlcan set it. Each publishing job carriespermissions: statuses: writeand callsrepos.createCommitStatus.plan's opener is additionallycontinue-on-error, since every build and test job hangs offplanand a transient status-API failure there must not discard the run; losing it only restores the stale-green window for that one run. Consequence for branch protection: require theE2E Testsstatus context (no GitHub App and no app-id pin needed).
- Not a job: a required job that is skipped (as the
-
Fork TIA reads trusted inputs, but the fork still writes the selector.
e2e-fork.yaml'se2ejob runs the sameselect-e2e.shTIA as in-tree (§7), and its inputs are hardened: the changed-file list and thefull-e2elabel come fromresolve's base-repo API lookups (pulls.listFiles/ the PR's labels), the same trusted source as the docs-only decision, passed in as outputs (changed_b64,full_e2e) rather than re-derived from agit diffof the fork-controlled merge ref. Keep that part as it is: re-deriving the selection from the checked-out tree would add a second bypass for no gain. -
What the fork lane is, and is not. The
e2ejob runs from the fork's tree, soselect-e2e.sh, the suites underhack/e2e-chainsaw/,hack/e2e-install-cozystack.batsandpackages/core/testing's recipes are all fork-authored. A fork that wants its own suite to report green can edit any of them, and hardening the TIA inputs does not change that. Read the lane accordingly: it is a correctness gate that stops an honest contributor shipping a regression, not a boundary against a hostile one. What contains a hostile fork is that no fork run executes without maintainer approval (below), that the job holds no push credential, and that a diff editing the test harness is visible in review. -
A fork PR that adds a new image cannot go green until that image exists on the base branch.
publishderives the set of archive names it will accept from the base parent (HEAD^1), so that a fork cannot mint arbitrary repositories under the CI registry by naming an artifact. An image the PR itself introduces is therefore not in that set, and the job fails withimage '<name>' is not in the base-tree allowlist. This is fail-closed by design and there is no label override — land the image on the base branch first, or run the suite from a same-repo branch. If a fork e2e run fails on that line, the contribution is not broken; the allowlist is doing its job. -
labeledevents run in their own concurrency group, and a cancelled run reports nothing. Because the gate is a status rather than a job, a run that dies without posting leaves the head SHA at "Expected" — which blocks the merge rather than passing it. Several separate pieces implement it, and weakening any one of them reopens the gap. Each lane's terminal reporter runs under!cancelled()rather thanalways(), so a run that was cancelled publishes nothing at all and thependingit opened stands until the run that superseded it reports. Each lane's concurrency key then keeps a run that is going to publish nothing out of the group of the run that is:pull-requests.yaml's appends-labelfor alabeledevent thatplandiscards, so labelling an open PR cannot cancel a liveopened/synchronizerun (the replacement run would never post the status the cancelled run was going to) — the condition is the complement ofplan's guard, so afull-e2elabel, which does publish, stays in the main group and supersedes the head run instead of racing it,e2e-fork.yaml's concurrency key appends the triggering run's id when that run concludedcancelledorskippedso a privileged run that is going to post nothing cannot cancel the one that is going to post, ande2e-fork.yaml'sresolvereturns early on acancelled/skippedtriggering run instead of postingfailurefor it. If you touch any of them, keep the invariant: exactly one run per head SHA is responsible for the status, and a run that is not that one must post nothing at all.
Security prerequisite (repo setting). The split is only safe if fork runs require maintainer approval before they execute. The repository must keep Settings → Actions → "Require approval for all outside collaborators" enabled, so a fork's pull_request run — which produces the artifacts e2e-fork.yaml consumes — cannot run untrusted code (or feed the privileged run) without a maintainer's go-ahead.
Chainsaw v0.2.15 gotchas
The suite is pinned to Chainsaw v0.2.15 (the latest release as of May 2026); none of the traps below are fixed upstream yet, so the workarounds stay until a bump is possible. Each one has already cost a debugging session — this is the "no one gets it right the first time" list, worth a read before writing a new assert.
- Condition asserts must use the filter-as-list form.
(conditions[?type == 'Ready'])evaluates to a list; assert against it with a list body (- status: "True"). The indexed(conditions[?type == 'Ready'])[0]form throwsfield not found(also enforced by convention 2). - No number literals / backticks in JMESPath.
ports[?port == `443`]mis-evaluates in v0.2.15 — the numeric comparison silently returns the wrong set. Assert ports (and any numeric field) with the exact-ordered projection form instead:(ports[*].port): [80, 443].sort()andcontains(list, <number>)are likewise broken with numeric arguments. error:fails hard on an unknown GVK. Unlike the BATS! kubectl get <kind>idiom (which passes when the type is absent), a Chainsawerror:op against a kind whose CRD is not installed errors the step rather than asserting absence. If a type may legitimately not exist (a version rename, an optional component), gate the check behind a positive precondition or do it in ascriptstep — never a bareerror:. This is what broke the etcd suite on thev1alpha1→v1alpha2rename.- No conditional Test-level
skip. There is noskip: <expr>; an env-var-gated test mustexit 0early inside ascriptstep (see theETCD_E2E_S3_ROUNDTRIPgate). A Test with no matching gate always runs. finallyis step-level, not test-level. To guarantee teardown of something Chainsaw did not create, put the work and its own cleanup in a singlescriptstep; there is no test-widefinallyblock to hang it on.- Cleanup is bounded and blocking — it surfaces latent teardown bugs. Chainsaw waits for the resources it applied to actually delete (within the
delete/cleanuptimeouts inhack/e2e-chainsaw/.chainsaw.yaml) and fails the test if they don't. This is deliberate — a stuck uninstall starves the next suite — but it means a teardown path the BATS suite masked with|| truenow surfaces ascontext deadline exceededat cleanup. The failure is honest, not new; fix the teardown rather than widening the timeout (see cozystack/cozystack#3271, a pre-delete hook that hung on a nodeless tenant). - Reading a failure diff: the "actual" side is a projection. Chainsaw projects the live object onto the expected fields, so
status: {}in a diff means "the expression matched nothing / evaluated empty," not "status is literally empty." The real reason is theerrorline above the diff — read that first. - A
catch/cleanupscript's cwd is the failing suite's directory (hack/e2e-chainsaw/<suite>/), so../../..is the repo root. The suitescd ../../..and then source shared helpers by repo-root-relative path (hack/e2e-chainsaw/_lib/…) rather than juggling../hops.
Reviewer checklist for a new or changed E2E test
- No new retry loop unless the step is pure infra (image pull / VM boot / network).
- Resource readiness uses a Chainsaw
assert(not an imperativeuntil kubectl get …; kubectl wait); condition checks use the filter-as-list form(conditions[?type == 'Ready']). - Imperative-only waits live in a
scriptstep; any baresleepcarries aTODO(e2e-replace-fixed-timeouts):justification. - No test-level
EXIT/RETURNtrap — rely on Chainsaw cleanup; a self-contained trap is allowed only inside a singlescriptstep (port-forward / temp dir). - Controller-created artifacts the test cannot reclaim are pruned explicitly; nested tenants delete child → parent with a wait-for-deletion between.
- Standard HR-Ready assert timeout is 5–6m; longer waits (harbor 10m, NFS 10m, VM image pulls, platform-wide install 15m) are justified in-line.
- Failure path attaches scoped diagnostics via a
catch:block, never a silent pass. - If it touches parent-HR behavior, add the
status.historyremediation guard (hack/e2e-chainsaw/_lib/remediation-guard.sh).
In-flight direction (not yet the merged standard)
These are being explored on branches and may become conventions; do not assume they are the current main behavior:
- Cilium orphaned-endpoint self-heal — an interim CI watchdog that evicts a single confirmed-orphan Cilium endpoint ("IP already in use", cilium/cilium#38313). Explicitly a mitigation to remove once a fixed Cilium ships; it refuses to touch an endpoint backing a live pod so real duplicate-IP bugs stay visible.
- Cluster state snapshot/restore between test groups instead of reinstalling.