Files
cozystack/docs/agents/e2e-testing.md
Myasnikov DaniilandClaude Opus 4.8 d522c5b5c5 docs(agents): add E2E and CI testing conventions
Capture the e2e stabilization conventions established over the last few
months as a routed agent doc: no retries on deterministic CI steps,
event-driven backstops over fixed sleeps, no EXIT/RETURN traps,
fail-fast HelmRelease-readiness gating, the status.history remediation
guard, first-class failure diagnostics, and a reviewer checklist. Also
documents Test-Impact Analysis (TIA) and its real scope — it trims only
the per-app loop, runs after install, and most PRs escalate to the full
suite. Wire it into AGENTS.md so every agent reads it before e2e/CI work.

Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-06-16 17:12:16 +05:00

107 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-apps/`, `hack/e2e-*.bats`, `hack/*.sh` test helpers, the E2E CI workflows (`.github/workflows/pull-requests.yaml`, `.github/workflows/release-e2e.yaml`), or `packages/core/testing/`.
## 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.
## Conventions
### 1. No retries on deterministic steps; retry only pure infrastructure
- `Run E2E tests` and `Install Cozystack` run **once**. On failure they print `❌ ... (no retry — see diagnostics below)` and dump state. Do not reintroduce a retry loop around them.
- `Prepare environment` keeps 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. Replace fixed `sleep`/`timeout` with event-driven backstops
The canonical wait pattern — an **existence backstop** before every `kubectl wait`:
```bash
timeout 60 sh -ec "until kubectl -n tenant-test get hr postgres-$name >/dev/null 2>&1; do sleep 2; done"
kubectl -n tenant-test wait hr postgres-$name --timeout=5m --for=condition=ready
```
- `kubectl wait` against a not-yet-created resource sits **silent for the full timeout**. The `until kubectl get` backstop makes "resource never appeared" fail in seconds with a clear message.
- A bare `sleep N` is allowed **only** when no Kubernetes API condition models the thing being awaited (external LB HTTP path, MetalLB advertisement, the "all platform HRs emitted" heuristic). When you must use one, annotate it with a `TODO(e2e-replace-fixed-timeouts):` comment explaining why no condition exists. See `hack/e2e-install-cozystack.bats` and `hack/e2e-apps/run-kubernetes.sh` for the sanctioned exceptions.
### 3. No `EXIT`/`RETURN` traps for cleanup
Trap-based cleanup was the single biggest source of false failures: 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.
- Do **pre-cleanup at test start** (delete stale resources, blocking) plus **inline cleanup at test end**.
- Do not manage port-forwards or temp files via `EXIT`/`RETURN` traps.
### 4. Do not mask cleanup or teardown failures
- Drop `|| true` from teardown deletes — a stuck finalizer, RBAC error, or wait timeout must fail the test, not leave stale state (HelmRelease, Secret, PVC) for the next test to trip over. `--ignore-not-found` still covers the legitimate clean-run case.
- For nested tenants, tear down **child → parent with a hard `kubectl wait ... --for=delete` 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 file — starving whichever app test runs next. See `hack/e2e-apps/gateway.bats`.
### 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), and `exit 1` on 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.history` instead — a `failed` or `uninstalled` Snapshot survives a later successful reconcile. Use the shared helper in `hack/e2e-apps/remediation-guard.sh` (`helmrelease_has_remediation_cycle`).
### 7. Test-Impact Analysis (TIA), default-on
`hack/select-e2e.sh` walks the `packages/core/platform/sources/*.yaml` dependency graph and runs only the bats files affected by a diff.
- Conservative escalation: edits to `packages/library/`, `packages/core/`, `api/`, `cmd/`, `internal/`, shared `hack/*.sh|*.bats` helpers, the `Makefile`, or the E2E workflows escalate to the **full suite**. A per-app bats edit selects **only** that app.
- The `full-e2e` PR label forces the whole suite.
- Companion: `.github/workflows/release-e2e.yaml` runs the **full** suite on every release tag, closing the coverage gap TIA opens on PRs.
When adding a new app package, confirm `select-e2e.sh` maps it correctly (it has a unit test, `hack/select-e2e_test.bats`).
#### 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 the per-app test loop, not the expensive stages.** The `build` job (image + Talos build, unit/Go tests), `Prepare environment`, and `Install Cozystack` all run regardless of the selection. TIA only decides which `make test-apps-<app>` targets run in the final step. A perfect narrow saves the matrix tail, not the bulk of wall-time.
- **`Select E2E tests` runs *after* `Install Cozystack` and has no `always()`.** GitHub implicitly ANDs a step's `if:` with `success()`, so if install fails the selector (and `Run 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 under `api/`/`internal/`, helpers under `hack/`, plus the `Makefile` — touching any one triggers the full suite. So "all apps selected" is usually correct, not a failure to narrow.
- **Two skip layers, with a gap.** The coarse `detect-changes` job (`dorny/paths-filter`, filter `code: '!docs/**'`) skips the whole build+E2E pipeline only for `docs/**`-only PRs. TIA's finer skip (`select-e2e.sh` ignoring `docs/`, `dashboards/`, and `*.md`) only trims the app loop. A PR touching only `dashboards/*.json` therefore still runs the full build + install, then skips just the app tests — near-zero savings. If you need such PRs to skip the pipeline, widen the `detect-changes` filter, not `select-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`, a `describe` of each non-Ready HR, and sorted events, all under collapsible `::group::` blocks.
- Per-app failures additionally dump COSI bucket/claim/access readiness columns (those CRDs ship no printer columns, so plain `get` shows only NAME/AGE).
- `make collect-report` → `hack/cozyreport.sh` uploads a full `cozyreport.tgz` (operator/Flux/cert-manager logs, LINSTOR, Kamaji, Talos dmesg, COSI YAML) on every run.
- Inside a test, dump **scoped** state on assertion failure with a `... || { echo ...; kubectl get/describe ...; false; }` block rather than letting a bare failure through. See `hack/e2e-apps/harbor.bats`.
### 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.
## Reviewer checklist for a new or changed E2E test
1. No new retry loop unless the step is pure infra (image pull / VM boot / network).
2. Every `kubectl wait` is preceded by an `until kubectl get …; do sleep 2; done` existence backstop.
3. Any bare `sleep` carries a `TODO(e2e-replace-fixed-timeouts):` justification.
4. No `EXIT`/`RETURN` trap — pre-cleanup at start, inline cleanup at end.
5. Teardown deletes drop `|| true`, keep `--ignore-not-found`; nested tenants delete child → parent with `wait --for=delete` between.
6. Standard HR-Ready wait is **5m**; longer waits (harbor 10m, NFS 10m, platform-wide install 15m) are justified in-line.
7. Failure path dumps scoped diagnostics (`|| { …; false; }`), never a silent pass.
8. If it touches parent-HR behavior, add the `status.history` remediation guard.
## 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:
- **BATS → Kyverno Chainsaw migration** — declarative asserts replace the `until … wait` boilerplate, with automatic events/describe/podLogs capture. Imperative suites (openbao unseal, vminstance, gateway, the kubernetes cluster tests) stay as script steps. Gotcha: Chainsaw v0.2.15 needs condition assertions in **filter-as-list** form `(conditions[?type == 'Ready'])`; the `(...)[0]` indexed form throws "field not found".
- **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.
- **Pin the e2e management cluster Kubernetes version** to dodge the kube-controller-manager ValidatingAdmissionPolicy type-checker panic on `additionalProperties: true` schemas (kubernetes/kubernetes#135155).
- **Cluster state snapshot/restore** between test groups instead of reinstalling.