Skip to content

fix(e2e): fail on a failed release of cilium and csi as on the parent, and report addon remediation - #3594

Merged
Aleksei Sviridkin (lexfrei) merged 2 commits into
mainfrom
fix/remediation-guard-distinguish-teardown
Oct 5, 2026
Merged

Aleksei Sviridkin (lexfrei) merged 2 commits into
mainfrom
fix/remediation-guard-distinguish-teardown

Conversation

@lexfrei

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

Copy link
Copy Markdown
Contributor

What this PR does

The history guard in the tenant Kubernetes e2e suite checked only the parent kubernetes-<test> HelmRelease. A failed action on one of the addon releases it installs left nothing the suite looked at, so a run showed only the downstream symptom, usually the node-join deadline, and never named the release behind it.

The guard now reads every addon release of the parent. It selects them from the cluster by name prefix, so a release added to the chart later is covered too. The rule:

A transient cilium or csi failure that later recovers on a retry still turns the run red, by design, as long as its Snapshot still reads failed. One recovery leaves no such Snapshot: when an install fails and the very next attempt succeeds, Helm marks the failed install superseded, because there is no deployed release to supersede, and the history reads like an ordinary upgrade. The guard cannot see that case from the statuses alone. The code and the docs now say so, a test pins it, and detection is left to a follow-up.

An earlier revision of this PR tried to tell a teardown apart from a retried failure and made only uninstalled fatal. That split does not hold. In helm-controller v1.5.0, the version the platform installs, a release with an active retry strategy never remediates. atomic_release.go returns an upgrade for a failed release before it reaches the remediation switch, and the two other paths that uninstall (a changed release target, and deletion) both clear history afterwards. So on these releases uninstalled never shows up, and failed is the only signal. The parent keeps the rule it already had on main, and cilium and csi now follow it too.

The failure paths get the same reading. The guard runs after the assertions, while the deadlines a failed addon breaks end the script earlier, so the same classification is also printed, without a verdict, from the node-join collector and from the exit handler. That handler is now armed at the tenant CR apply, so the management-cluster waits before the tenant answers are covered too. Smaller changes that came with this:

  • Every guard read is bounded, and a failed read is never folded into "no history".
  • The parent must read back a non-empty strategy. An empty value there means the jsonpath stopped resolving, and without this check every footprint would quietly turn into a note.
  • The history read uses HELMRELEASE_HISTORY_JSONPATH, and the upstream-type pin in internal/fluxcontract now accepts the bounded wrapper the read goes through.

docs/agents/e2e-testing.md §6 and checklist item 8 describe the new rule.

On related issues: this adds the local that #3774 reports missing in helmrelease_has_remediation_cycle, but not the sweep over the other _lib/ helpers that the issue also asks for. For #3773 it rewrites the guard comment the issue quotes. The waits without a backstop are not touched.

Closes #3553.

Screenshots

Not a UI change.

Downstream repositories

I walked the trigger map against the diff. It touches hack/e2e-chainsaw/_lib/, hack/*.bats, one Go test under internal/fluxcontract, two spend-order comments in the kubernetes suites, and docs/agents/e2e-testing.md. No package, schema, CRD, make target or user-facing document changes.

Release note

fix(e2e): the HelmRelease history guard now also covers the tenant addon releases. A failed or uninstalled Snapshot fails the run on the parent, cilium and csi, which retry in place; other addons get a note. The same classification is printed on the failure paths where the guard itself cannot run.

@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files area/testing Issues or PRs related to testing (e2e, bats, unit tests) kind/bug Categorizes issue or PR as related to a bug labels Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The E2E remediation checks distinguish teardown from failed remediation, validate parent and addon HelmReleases, handle API and history states, and keep the failure snapshot trap active during validation.

Changes

HelmRelease remediation guards

Layer / File(s) Summary
Remediation history classification
hack/e2e-chainsaw/_lib/remediation-guard.sh, hack/remediation-guard.bats
Adds teardown detection for uninstalled snapshots and tests it separately from failed remediation history.
Parent and addon guard flow
hack/e2e-chainsaw/_lib/run-kubernetes.sh
Validates parent and matching addon HelmReleases using strategy, history, and Ready state. The Kubernetes test preserves the failure snapshot trap until all checks complete.
Guard validation and release coverage
hack/run-kubernetes-remediation_test.bats, docs/agents/e2e-testing.md
Tests failures, empty histories, filtering, warnings, API errors, guard ordering, and remediation outcomes. The guidance requires history guards for HelmRelease install and upgrade changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant KubernetesTest
  participant cozy_guard_all_helmreleases
  participant cozy_guard_helmrelease
  participant kubectl
  KubernetesTest->>cozy_guard_all_helmreleases: validate parent and addon HelmReleases
  cozy_guard_all_helmreleases->>cozy_guard_helmrelease: validate each release
  cozy_guard_helmrelease->>kubectl: read strategy, history, and Ready state
  kubectl-->>cozy_guard_helmrelease: return release state or read error
  cozy_guard_helmrelease-->>cozy_guard_all_helmreleases: return release result
  cozy_guard_all_helmreleases-->>KubernetesTest: preserve aggregate failures
Loading

Possibly related issues

Possibly related PRs

Suggested labels: area/kubernetes

Suggested reviewers: myasnikovdaniil

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes separate teardown detection from remediation-cycle detection and extend guards to tenant addon HelmReleases as required by issue #3553.
Out of Scope Changes check ✅ Passed The implementation, tests, and documentation changes directly support issue #3553 and the stated PR objectives.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title identifies the addon remediation guard change, but it does not clearly capture the main change: distinguishing teardowns from retried failures and applying strategy-aware checks to parent an…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/remediation-guard-distinguish-teardown

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.

@lexfrei

Copy link
Copy Markdown
Contributor Author

Reopening to pick up the multus fix on main.

IvanHunters
IvanHunters previously approved these changes Aug 7, 2026

@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. Verified statically and hermetically (no cluster): the fix premise checks out against the code — the aggregated apiserver unconditionally stamps RetryOnFailure on every Application HR (rest.go:1601-1611), cilium/csi set it explicitly, and addons without a strategy fall back to the default, which matches the guard's teardown-vs-retry logic. The guard aborts the run for real (set -eu in the chainsaw step, not decorative), there's no local-assignment status masking, and the empty-selection path is fail-closed, not fail-open. 30/30 unit tests pass; non-vacuity confirmed by two mutations.

Non-blocking notes:

  • The guard's three kubectl reads are not wrapped in the existing kubectl_wait_retry helper. In the etcd-noisy sandbox a single transient "leader changed" would redden the whole run. Fail-closed is the right default here; wrapping the reads in a bounded retry (distinguishing a transient error from a missing field) would remove a new false-red source.
  • Addon-guard coverage is bounded by DisableWait (an addon torn down after the check point won't be seen) — best-effort, already acknowledged.
  • grep "^${_prefix}" treats the prefix as a regex; input is controlled so no practical risk, but grep -F would be stricter.

@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/remediation-guard-distinguish-teardown branch from e85e915 to bf5ad40 Compare August 7, 2026 09:43
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/remediation-guard-distinguish-teardown branch from bf5ad40 to 904285f Compare August 7, 2026 12:38
@github-actions github-actions Bot added size/XXL This PR changes 1000+ lines, ignoring generated files and removed size/XL This PR changes 500-999 lines, ignoring generated files labels Aug 7, 2026

@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: 1

🧹 Nitpick comments (1)
hack/e2e-chainsaw/_lib/run-kubernetes.sh (1)

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

Add diagnostics to the parent readiness wait.

If the parent HelmRelease does not become Ready in 5m, kubectl wait fails and errexit ends the run. The only output is kubectl's timeout message. The guard below prints describe hr on its own failure paths, but this wait prints nothing about the release state, so the stuck release is not named. Every other long wait in this function (node join, backend readiness, scheduling gate) dumps scoped diagnostics before exit 1.

Note that the tenant snapshot trap captures the tenant cluster, not the management-cluster HelmRelease objects, so it does not cover this gap.

♻️ Proposed diagnostics on the wait path
-  kubectl wait hr -n tenant-test "kubernetes-${test_name}" --timeout=5m --for=condition=ready
+  if ! kubectl wait hr -n tenant-test "kubernetes-${test_name}" --timeout=5m --for=condition=ready; then
+    echo "=== parent HelmRelease kubernetes-${test_name} did not become Ready within 5m — diagnostics follow ===" >&2
+    kubectl -n tenant-test get hr >&2 || true
+    kubectl -n tenant-test describe hr "kubernetes-${test_name}" >&2 || true
+    exit 1
+  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/e2e-chainsaw/_lib/run-kubernetes.sh` at line 1659, Update the parent
HelmRelease readiness wait around kubectl wait for kubernetes-${test_name} to
handle timeout failure explicitly, print scoped diagnostics for that HelmRelease
with kubectl describe hr in the relevant namespace, then exit with failure while
preserving the existing 5-minute wait and success path.
🤖 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-chainsaw/_lib/run-kubernetes.sh`:
- Around line 740-751: Source remediation-guard.sh in the runtime path before
the cozy_guard_all_helmreleases helper is invoked. Update
hack/e2e-chainsaw/_lib/run-kubernetes.sh so helmrelease_has_teardown and
helmrelease_has_remediation_cycle are defined before this guard logic executes.

---

Nitpick comments:
In `@hack/e2e-chainsaw/_lib/run-kubernetes.sh`:
- Line 1659: Update the parent HelmRelease readiness wait around kubectl wait
for kubernetes-${test_name} to handle timeout failure explicitly, print scoped
diagnostics for that HelmRelease with kubectl describe hr in the relevant
namespace, then exit with failure while preserving the existing 5-minute wait
and success path.
🪄 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: 525b4c53-2d96-4269-8447-f1b4043c1a48

📥 Commits

Reviewing files that changed from the base of the PR and between bf5ad40 and 904285f.

📒 Files selected for processing (4)
  • docs/agents/e2e-testing.md
  • hack/e2e-chainsaw/_lib/remediation-guard.sh
  • hack/e2e-chainsaw/_lib/run-kubernetes.sh
  • hack/run-kubernetes-remediation_test.bats
🚧 Files skipped from review as they are similar to previous changes (2)
  • hack/run-kubernetes-remediation_test.bats
  • hack/e2e-chainsaw/_lib/remediation-guard.sh

Comment thread hack/e2e-chainsaw/_lib/run-kubernetes.sh Outdated

@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: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/agents/e2e-testing.md (1)

46-47: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Remove the EXIT-trap exceptions from this convention.

The supplied path instruction requires no EXIT or RETURN traps. Lines 46-47 and 157 currently permit EXIT traps inside script steps and BATS subshells. Keep cleanup explicit, or update the governing instruction and all related examples together.

As per path instructions, use no EXIT or RETURN traps.

Also applies to: 157-157

🤖 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 `@docs/agents/e2e-testing.md` around lines 46 - 47, Update the convention in
this document to prohibit all EXIT and RETURN traps, removing the exceptions for
single script steps and explicit BATS subshells. Revise the related guidance and
examples, including the references to hack/e2e-test-openapi.bats and sourced
shell libraries, so cleanup is performed explicitly without either trap type.

Source: Path instructions

🤖 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 `@docs/agents/e2e-testing.md`:
- Line 65: Revise the guidance around .status.history to state that
helm-controller/api is only a pinned dependency and does not contain this
repository’s controller behavior for reading or truncating history. Keep claims
about retention and strategy-specific truncation explicitly attributed to the
upstream helm-controller source, while preserving the existing e2e timing and
shared remediation-guard helper guidance.
- Line 66: Update the paragraph describing the helmrelease_has_teardown rule to
match cozy_guard_helmrelease’s implemented behavior: document that an
uninstalled Snapshot is treated as unexplained only when
.spec.install.strategy.name is RetryOnFailure. Remove claims about
remediation.retries or upgrade remediation strategy unless the guard is expanded
to read and handle those fields.

In `@hack/e2e-chainsaw/_lib/run-kubernetes.sh`:
- Around line 1001-1010: Bound every kubectl read in the guard helpers: wrap the
strategy and history reads near lines 1001-1010, the Ready read near lines
1025-1029, and the HelmRelease list read near lines 1103-1107 in timeout -k 5
30, adding --request-timeout=30s to each kubectl invocation.
- Around line 1995-2005: Wrap the parent HelmRelease wait for
“kubernetes-${test_name}” in a failure branch that, when kubectl wait fails,
prints the HelmRelease YAML/status conditions and relevant namespace events,
then exits immediately with failure. Keep the existing 5-minute readiness wait
and ensure the remediation guard does not run after a timeout.

---

Outside diff comments:
In `@docs/agents/e2e-testing.md`:
- Around line 46-47: Update the convention in this document to prohibit all EXIT
and RETURN traps, removing the exceptions for single script steps and explicit
BATS subshells. Revise the related guidance and examples, including the
references to hack/e2e-test-openapi.bats and sourced shell libraries, so cleanup
is performed explicitly without either trap type.
🪄 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: 179fb467-b311-4da5-8636-7508a55bb033

📥 Commits

Reviewing files that changed from the base of the PR and between 904285f and ed56999.

📒 Files selected for processing (2)
  • docs/agents/e2e-testing.md
  • hack/e2e-chainsaw/_lib/run-kubernetes.sh

Comment thread docs/agents/e2e-testing.md Outdated
Comment thread docs/agents/e2e-testing.md Outdated
Comment thread hack/e2e-chainsaw/_lib/run-kubernetes.sh Outdated
Comment thread hack/e2e-chainsaw/_lib/run-kubernetes.sh

@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.

This PR splits the old single helmrelease_has_remediation_cycle reading into a narrow helmrelease_has_teardown (only an uninstalled Snapshot) and the existing broad reading, and uses .spec.install.strategy.name to decide which one is fatal. It also extends the guard from the parent HelmRelease to the tenant addon HelmReleases via cozy_guard_addon_helmreleases/cozy_guard_all_helmreleases in hack/e2e-chainsaw/_lib/run-kubernetes.sh. I verified the wiring, the ordering guarantee, and the test coverage directly against the PR head. I agree with the design. LGTM with a couple of non-blocking notes.

What I verified

  • cozy_guard_all_helmreleases tenant-test "kubernetes-${test_name}" is called at hack/e2e-chainsaw/_lib/run-kubernetes.sh:2029, and the tenant-snapshot trap - EXIT is at line 2048 — the guard genuinely runs before the trap is disarmed, matching the ordering claim in the new hack/run-kubernetes-remediation_test.bats test.
  • helmrelease_has_remediation_cycle in hack/e2e-chainsaw/_lib/remediation-guard.sh gains a missing local statuses declaration alongside the new helmrelease_has_teardown, closing a real global-variable leak — a genuine correctness fix, not just a comment change.
  • The new hack/run-kubernetes-remediation_test.bats fixture-driven kubectl stub distinguishes "read failed" (*.FAILS) from "field absent" (missing fixture) and from "kubectl warns on stderr but exits 0" (*.WARNS), and there are dedicated tests proving a stray stderr warning doesn't silently downgrade a real teardown or mask an empty history. That's the right level of paranoia for this kind of guard.
  • The ordering-pin test in hack/run-kubernetes-serial-console_test.bats was re-anchored from grep -n 'helmrelease_has_remediation_cycle "${history_statuses}"' to grep -n '^ *cozy_guard_all_helmreleases ', i.e. on the call site rather than on the callee's now-renamed local variables — this correctly avoids brittleness from the refactor.

Non-blocking / notes

  1. One of the open CodeRabbit threads (hack/e2e-chainsaw/_lib/run-kubernetes.sh, originally line 751) claims remediation-guard.sh "is not sourced from the current repository and is not referenced by run-kubernetes.sh." That's incorrect — line 3 of run-kubernetes.sh at the very commit CodeRabbit reviewed and at current head both read . hack/e2e-chainsaw/_lib/remediation-guard.sh. No action needed on the code, but worth resolving/dismissing the thread explicitly so it doesn't linger as a false "unaddressed Major finding."
  2. The new guard's kubectl reads (cozy_guard_helmrelease's strategy/history/Ready reads, cozy_guard_addon_helmreleases's listing) have no --request-timeout/timeout wrapper, unlike other reads added elsewhere in this file. Both a prior human review and a later CodeRabbit pass flagged this independently: a wedged apiserver at guard time (which runs late, near the tenant-snapshot trap) could stall and, per the guard's own doc comments, cost part of the diagnostic budget. Worth a follow-up.
  3. docs/agents/e2e-testing.md's new bullet describes two configurations that can produce an uninstalled Snapshot (default-strategy install remediation, and an upgrade remediation set to strategy: uninstall), but cozy_guard_helmrelease only reads .spec.install.strategy.name — it doesn't check .spec.install.remediation.retries or .spec.upgrade.remediation.strategy. This gap is honestly self-documented in the code's own comment block ("the rule implemented is narrower than the rule stated..."), and no current addon is in the affected shape (retries: -1 everywhere), so it's a real but currently-inert limitation rather than a bug.
  4. The plain kubectl wait hr -n tenant-test "kubernetes-${test_name}" --timeout=5m --for=condition=ready call (unchanged by this diff, only the surrounding comment was rewritten) still prints nothing but kubectl's own timeout message on failure. CodeRabbit raised this twice; it predates this PR and is arguably out of scope for a remediation-guard fix, but it's a reasonable follow-up since the guard right below it depends on this wait having succeeded.
  5. Per the metadata, this PR is CONFLICTING against main and needs a rebase before it can merge — noted as a mergeability item, not a logic concern; nothing in the diff suggests the conflict hides a real semantic clash.

Overall: the core reasoning (RetryOnFailure is stamped on every Application-served HelmRelease, so a failed Snapshot there is not evidence of a teardown) is correctly implemented and heavily unit-tested. The open notes above are worth acting on before or shortly after merge, but none of them constitute a regression or a broken assertion.

@myasnikovdaniil myasnikovdaniil 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.

The classification is right and the tests are the best part of this PR. Requesting changes on where the guard is wired in, and on the field that arms it.

On the tests first, because they deserve saying. I mutated 23 ways, gate removed, gate inverted, teardown predicate widened to failed, || true on each of the two reads, stderr folded into each capture, the addon loop swallowing verdicts, the composite dropping either verdict, read order swapped, index() traded for an anchored grep, the call deleted, the call suffixed with || true. Every one turns a test red, each with a case that exists for it. Both suites behave identically under bats and under hack/cozytest.sh, 24 of 24 and 12 of 12. Every repo fact the change rests on holds too: the parent gets RetryOnFailure on both install and upgrade, cilium and csi set it, all 19 addons set retries: -1, nothing under packages/ sets an uninstalling upgrade remediation, and both truncation variants behave as the comments say.

The guard cannot run in the case it was written for. Its own comment says a child remediation surfaces only as whatever downstream deadline it eventually breaks, a node that never turns Ready, a PVC that never binds, with nothing in the run naming the teardown that preceded it. The call is at run-kubernetes.sh:2520 and every one of those deadlines is upstream of it, the node-Ready wait at :1942 with exit 1 at :1954, the csi HelmRelease wait at :2156, then the LB and NFS checks. Under set -eu the script ends there.

This PR's own CI is the demonstration. Tenant cilium never went Ready, coredns, csi, ingress-nginx, metrics-server and ouroboros all sat on dependency '...-cilium' is not ready, the run died at the node-join deadline, and cozy_guard_all_helmreleases was never reached. Had cilium been torn down instead of retried the output would have been identical and the teardown still unnamed. cozy_report_node_join_failure prints each HelmRelease's Ready message and not .status.history, so the failing path is exactly as blind as before this PR. Calling a print-only classification from cozy_report_node_join_failure, or from _tenant_snapshot_on_fail at :1915, closes it. If the happy-path-only placement is deliberate then say so in the comment and in the docs bullet, because both currently claim more.

The field that arms the guard fails soft and nothing pins it. The fatal verdict is a string equality against a value read by kubectl -o jsonpath, and with --allow-missing-template-keys at its default a path that stops resolving prints nothing and exits 0, so empty takes the soft branch. That is the same class of silent failure the empty-history check exists to catch, applied to .status.history and not to the new read. Bump helm-controller/api past a rename of spec.install.strategy and every teardown, including a real removal of the tenant CNI, becomes a NOTE about its own install remediation, green run, all 24 unit tests still passing because they feed the strategy from a fixture rather than through the jsonpath. The printed evidence does not help either, (install strategy <unset>) is the expected line for 17 of the 19 addons. Worth knowing the existing pin does not cover this shape, remediation-guard.bats:143 pins .status.history[].status against a hand-written YAML fixture rather than against the module in go.mod, so the docs claim that the shape is pinned against helm-controller/api v2 is stronger than what that test does. Cheapest fix is a canary, the parent is RetryOnFailure unconditionally so an empty strategy on the parent should be fatal.

Smaller things, none blocking alone. The retries: 0 gap you document at :1596-1605 is one more field in the same jsonpath, appending @{.spec.install.remediation.retries} and treating either condition as fatal closes it and deletes ten lines explaining why it is open. The enumerate-from-the-cluster design was chosen precisely so a release added later is covered without editing this file, and that is the shape where it does not hold. The read-order argument at :1543-1546 says no truncation variant can empty a non-empty history, which is true, but the controller also calls Status.ClearHistory() outright on an unmanaged release, so if that lands between the two reads the guard sees Ready true and empty history and fails the run with the wrong explanation. Tiny window, but the claim should be the narrower one. And it is worth naming in the docs that three releases out of twenty can actually fail the run on history content, since for the other seventeen every history shape is a note. That is a defensible design, it just is not what covering the tenant addon HelmReleases reads as.

On the two failure directions. A real failure read as a fine teardown is reachable and you relaxed it deliberately, failed is now a note everywhere, and the justification is correct against the controller since an active retry returns an upgrade rather than uninstall remediation. But this run shows the limit of recovered by design, cilium logged UpgradeFailed ... stalled resources: [Deployment/cozy-cilium/cilium-operator status: 'Failed'] eleven times over ten minutes and never recovered, and in that state the guard prints its failed-Snapshot note and returns 0. Right call for this guard, the run is already red, but retried and wedged in a retry loop are the same output and the wording is the only difference. The opposite direction is effectively unreachable, a release pairing RetryOnFailure with an uninstalling upgrade remediation would trip the fatal branch on a removal it is configured to perform, and nothing under packages/ does that.

The red E2E is not yours. Both suites fail at the node-join deadline behind tenant cilium stuck on cilium-operator, #3780 which only moves documentation fails identically, #3790 shows the same cilium signature, and nightlies have been red seven days running. Everything this PR touches in the run lives below the line where those runs die, which is also why this code has never executed on a cluster, and why the placement point above matters more than it otherwise would.

# cozy_guard_all_helmreleases judges the parent and the addons and reports
# both verdicts rather than stopping at the parent's; see its comment for why
# that matters under errexit.
cozy_guard_all_helmreleases tenant-test "kubernetes-${test_name}"

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.

This is the placement I am blocking on. The function's own comment says a child remediation surfaces only as whatever downstream deadline it eventually breaks, a node that never turns Ready, a PVC that never binds. All of those are upstream of this line: the node-Ready wait at :1942 with its exit 1 at :1954, the csi HelmRelease wait at :2156, then LB and NFS. Under set -eu the script ends at the first of them.

So on this PR's own run the guard never executed. Tenant cilium never reached Ready, the addons sat on dependency '...-cilium' is not ready, and the run died at the node-join deadline. A teardown would have produced the same output and still gone unnamed, because cozy_report_node_join_failure prints Ready messages and not .status.history.

A print-only classification called from cozy_report_node_join_failure, or from _tenant_snapshot_on_fail at :1915, would give the failing path the coverage the comment promises. If happy-path-only is deliberate, the comment and the docs bullet should say so.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You are right, and it took two changes. The print-only classification you suggested is cozy_report_helmrelease_remediation, called from cozy_report_node_join_failure and from the EXIT handler that covers every other failure. It reuses the guard's own enumeration rather than a second copy of the selection, so the two readings cannot drift apart.

The second change your comment led me to and I had missed. The EXIT handler was armed only once the tenant LB answered, so even with the collector in place it would have been absent from the whole stretch between the CR apply and that point, which is management-cluster waits on the parent HelmRelease and on what it installs. That stretch is the window the guard exists for: a helm-wait budget expiring while admin-kubeconfig is still being provisioned is what triggers the uninstall remediation it reads. It arms at the apply now, in 91cbb69, and the snapshot half stays a no-op until the tenant kubeconfig exists.

Inside the node-join collector the classification runs after the serial console rather than before it (98f7d8c). The console is the only artefact covering a worker that never reached apid, and its worst case takes most of the phase budget, so anything ahead of it competes with it on the slow runs it exists for. What the classification reads outlives the run and can be read again afterwards; the console cannot. Both walks are bounded by a request timeout and a wall clock checked before the listing and before each release, and a release the budget cut off is named rather than dropped.

Head is 98f7d8c, rebased onto current main. The classification, its budget, the arming point, the collector order and the wiring are pinned in hack/run-kubernetes-remediation_test.bats and in the two order guards in hack/run-kubernetes-node-join_test.bats.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Rebased since my last reply, so the line numbers in your comment and the SHAs in mine no longer resolve. Current positions on the head you would read now.

The EXIT handler arms at 5357, right after the tenant CR apply, instead of once the tenant LB answers. Everything you named sits below that point: the node-Ready wait and its failure path at 5523-5540, the csi HelmRelease wait at 5789. Under errexit each of them now enters _tenant_failure_on_exit rather than ending the script above the classification.

cozy_report_helmrelease_remediation is at 5122 and is reached from two places: line 412 inside the EXIT handler, ahead of the tenant snapshot, and line 4422 inside cozy_report_node_join_failure, after the serial console capture at 4398. Print-only holds the way the comment promises: every cozy_guard_helmrelease call inside it is || true, it returns 0 on all paths including the budget cut-offs, and both call sites are || true as well.

Your 1942, 1954 and 2156 are from before the rebase and the file was rewritten around them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Rebased onto current main. The file was rewritten under the branch, so the line numbers in my earlier reply no longer point anywhere.

The wiring you blocked on is pinned by tests now. cozy_report_helmrelease_remediation is reached from cozy_report_node_join_failure and from the EXIT handler, and the handler is armed at the tenant CR apply, above every wait you named.

Nothing covered the assignment itself. The handler classifies whatever CURRENT_TENANT_PARENT_HR holds, so deleting that one line leaves it empty: the collector returns on its empty-parent gate, every failure path goes quiet, and the suite stays green. There is a test for that now, and for the assignment landing before the arming.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

myasnikovdaniil everything from your review is addressed at the current head, so could you take another look? The branch is two commits now: the code is in 8cb1023 and the docs in b42805b. Line numbers below are at that head.

  • The exit handler _tenant_failure_on_exit is armed right after the tenant CR apply (run-kubernetes.sh:5798), above every wait you listed. It calls the print-only cozy_report_helmrelease_remediation (:771), and so does the node-join collector (:4833).
  • failed is fatal again on every RetryOnFailure release, which means the parent, cilium and csi.
  • An empty or non-RetryOnFailure strategy on the parent fails the run (:5293 and :5301).
  • The ClearHistory window is described as open (:5254).
  • The docs point at internal/fluxcontract for the history jsonpath pin, and they list which conditions can fail a run.

The retries: 0 gap went away with the retry-budget branch. A default-strategy addon gets a note whatever its retries value. One thing is still open: an addon added later with retries: 0 would also get a note, and nothing in this PR checks that every addon sets -1.

The guard bats suites and internal/fluxcontract pass at this head.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor

Re-read this against head while my block was standing, and found one thing I missed the first time that I think changes the approach rather than the diff.

The condition this makes fatal cannot happen. In helm-controller v1.5.1, internal/reconcile/atomic_release.go under case ReleaseStatusFailed: returns NewUpgrade(...) as soon as GetActiveRetry() != nil, which is before GetActiveRemediation() and before the remediation switch is reached at all. So a release carrying RetryOnFailure never remediates and never lands in uninstalled. Meanwhile failed, which the guard stops treating as fatal, was reachable. Net in-product detection on parent, cilium and csi goes to zero, which is the opposite of what the PR is for.

Two smaller things while I was in there. The guard call still sits after every failure exit in the file, so on the failure path its own comment names as the motivation it does not run, which is the block I already have on that line and it is unaddressed at head. And the new reads at :1547, :1554 and :1655 carry no --request-timeout in a file that uses one on 31 other reads, so that is 8 to 25 unbounded unretried gets added to the tail of a 50m op.

The discriminator itself is fine, for the record. ^uninstalled$ against ^(failed|uninstalled)$ genuinely takes different branches, and the test suite around it is real: I mutated it seventeen ways, including inverting the discriminator, the strategy gate, the read order and the || true on the wired call, and each one was caught by a named test, with declared equal to executed on all four bats files.

@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/remediation-guard-distinguish-teardown branch 3 times, most recently from 4e822f1 to f6262a6 Compare August 16, 2026 18:03
@lexfrei

Copy link
Copy Markdown
Contributor Author

myasnikovdaniil I reworked the PR around your objection. On a RetryOnFailure release helm-controller never remediates: in v1.5.0 atomic_release.go returns an upgrade for a failed release before it reaches the remediation switch, and the other two uninstall paths clear history afterwards. So uninstalled can't show up on the parent, cilium or csi, and the fatal branch I had was dead code.

At the new head a failed or uninstalled Snapshot fails the run on any RetryOnFailure release. That is main's rule for the parent, now also applied to cilium and csi. The default-strategy addons still get a note. The teardown predicate and the retry-budget branch are gone, together with the text around them. A cilium or csi failure that recovers on a retry now reds the run, and the PR body says that is intended. The failure-path classification and the early handler arming from your first review stay as they were.

@lexfrei

Copy link
Copy Markdown
Contributor Author

IvanHunters Your 09-03 notes are handled at the new head, after a rebase onto main. The runner-canary pin now anchors on CURRENT_TENANT_KC=, where the tenant becomes reachable, so the bound matches its comment again. The node-join spend-order filter stops at the end of cozy_report_node_join_failure. The comments and docs no longer quote addon counts, since those drift with the chart.

I left the reads unretried. docs/agents/e2e-testing.md rules out retries on steps with test logic, a failed read reds the run, which is the safe direction, and each read is bounded. The Ready-condition jsonpath rename is still open. As you said, it is second-order: a real footprint always comes with a non-empty history.

The rule itself changed after myasnikovdaniil's objection. failed is fatal again on the RetryOnFailure releases (the parent, cilium, csi), and the PR body has the controller mechanism behind that.

@lexfrei

Copy link
Copy Markdown
Contributor Author

myasnikovdaniil the placement you blocked on is fixed and pinned, and the empty-strategy canary on the parent and the narrower ClearHistory claim are in. 2294faf is rebased onto current main, with only a comment clarified. Could you take another look? E2E is skipped right now because Unit & controller tests is red on main, which #4729 fixes.

IvanHunters
IvanHunters previously approved these changes Oct 2, 2026

@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.

Verdict

LGTM

The teardown-vs-retry split now fails the run on a failed or uninstalled Snapshot of a RetryOnFailure release (the parent, cilium, csi) and only notes it on the default-strategy addons, which is the opposite of the fail-soft-on-failed shape the earlier objection was raised against. I reproduced the central guarantee by mutation: flipping the fatal branch to return 0 reddens 8 tests in hack/run-kubernetes-remediation_test.bats. The fatal-path tests assert failure in the positive form, so they are not among the negated assertions cozytest.sh swallows. Reviewed hermetically at head 2294fafa1, no cluster. One correctness limitation below is worth fixing but does not hold the review (a test-harness false-negative, not a product or data risk), and the code already improves on main.

Two things gate the merge and neither is in the diff. First, myasnikovdaniil's CHANGES_REQUESTED is still open on the PR; the rework answers its substance (a failed Snapshot on a RetryOnFailure release is now fatal, not soft), but the review has not been dismissed or re-submitted, so a human block stands until that reviewer looks again. Second, the Run unit and controller tests check (job 110989675196) is red on this head and it is not this PR: internal/controller/workloadmonitor_controller_test.go fails to build against sigs.k8s.io/controller-runtime v0.25.1, whose controllertest.FakeInformer has Synced as a method and no SyncedLock field, while the test assigns fi.Synced = true and locks fi.SyncedLock; that file, the fake and the go.mod pin are byte-identical at the merge-base and untouched here, and the PR's own Go test (internal/fluxcontract) builds and passes. The breakage is on main itself and it is what keeps E2E from running at this head.

Findings

  • [MINOR] hack/e2e-chainsaw/_lib/run-kubernetes.sh:5191, A single failed install that recovers on one retry leaves no failed Snapshot, so the guard passes on exactly the race it targets
  • [NIT] hack/e2e-chainsaw/_lib/run-kubernetes.sh:5290, Ready-condition jsonpath rename silently retires the empty-history-on-Ready canary
  • [MINOR] hack/e2e-chainsaw/_lib/run-kubernetes.sh:5243, Guard reads are fail-closed and unretried: one transient apiserver error on any release reds the run

Still open from my earlier rounds

The Ready-condition half of the empty-history canary is still soft. The parent now fails the run on an empty .spec.install.strategy.name, which closes the strategy-rename half; a rename of the Ready-condition jsonpath is not covered, because _ready would read empty everywhere, a Ready release with an empty history would take the "not Ready ... not inspected" branch and pass, and the canary would be off with nothing to notice. Latent and second-order (a release with a real teardown carries a non-empty history), so it does not hold the review.

Checked and correct

The guard runs before the EXIT trap is disarmed, so a fatal verdict on the passing path still triggers the tenant snapshot, an ordering pinned by the re-anchored serial-console test. _COZY_GUARD_REPORT_DONE dedups the classification across all three call sites (guard, node-join collector, exit handler). The trap is armed at the CR apply and the snapshot half no-ops until CURRENT_TENANT_KC is set, so a wait that times out before the tenant is reachable still gets its history printed. Reads are bounded (timeout -k + --request-timeout), a cut-off read keeps its non-zero status rather than being folded into "no history", and stderr is kept out of the captures. The stale denominators from the earlier comments are gone and the runner-canary pin is re-anchored on CURRENT_TENANT_KC=.

# tenant cilium and csi, whose templates set it on both actions. Under that
# strategy the controller never remediates: a failed action is retried as an
# upgrade with its manifests left in place, so what it leaves behind is a
# "failed" Snapshot, and that Snapshot is the only record the release keeps of

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.

[MINOR] A single failed install that recovers on one retry leaves no failed Snapshot, so the guard passes on exactly the race it targets

The guard matches only failed/uninstalled (helmrelease_has_remediation_cycle, :5308). But under RetryOnFailure a failed install retried as a successful upgrade is recorded superseded, not failed: Helm v4.1.1 picks the failed release as originalRelease when no deployed release exists (pkg/action/upgrade.go:258-262) and rewrites it failed->superseded on success (:511-512), and helm-controller v1.5.1 recordOnObject replaces the prior failed snapshot in .status.history with that observation (internal/reconcile/release.go:96-110). History ends [deployed, superseded] and the guard passes green. The repo's own green test pins coredns history deployed/superseded as clean (:311). A failed entry survives only after >=2 consecutive failed attempts. The single install-timeout-then-recover case is the headline scenario this guard cites, and the claim here (:5191-5193) and in docs/agents/e2e-testing.md:92 that the failed Snapshot is the only record and a recovered retry still fails the run is therefore false. The predicate gap is pre-existing (merge-base used the same match at :6085); the overstated claim is new. Harness false-negative, non-blocking: either strengthen detection (a recovered RetryOnFailure release leaves >1 history entry where the suite ran no upgrade of its own) or correct the two claims to state the real limitation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed at helm-controller v1.5.0 / Helm v4.1.1: with no deployed release, the retry upgrades from the failed install, upgrade.go:258-262 and :511-512 mark it superseded, and recordOnObject (release.go:96-110) rewrites that Snapshot in place, so the history reads deployed, superseded and the guard passes. A failed upgrade keeps its failed Snapshot, since Helm supersedes the last deployed release instead, and so does an install that fails twice, so the gap is exactly the one-failed-install-then-recover case.

I took the second option: 8cb1023 corrects the comment in cozy_guard_helmrelease and in remediation-guard.sh, fixes the RetryOnFailure test fixture to the history a real recovery leaves (deployed, superseded, failed), and adds a test pinning the blind spot as a pass so the prose cannot drift back. b42805b corrects the e2e-testing.md bullet and both commit messages drop the claim.

I did not strengthen detection here. "More than one entry" would false-red the OIDC suite, which upgrades the parent on purpose at run-kubernetes.sh:6604 before the guard runs at :6617, so this needs a signal beyond the statuses. I'm filing it separately together with the same overstatement at e2e-testing.md:77, which predates this PR.

_cozy_guard_kubectl -n "$_ns" describe hr "${_hr}" >&2
return 1
fi
# An empty history is read against this release's own readiness. A Ready

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.

[NIT] Ready-condition jsonpath rename silently retires the empty-history-on-Ready canary

The parent now fails the run on an empty .spec.install.strategy.name, closing the strategy-rename half of this. The Ready-condition jsonpath is still read soft: a rename in a future helm-controller empties _ready on every release, a Ready release with empty history takes the not-Ready/not-inspected branch and passes, and the canary is off with nothing to notice. Latent and second-order (a real teardown carries non-empty history), so non-blocking. Carried from the 2026-09-03 round.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think the canary can go dark this way on the passing path. The guard at run-kubernetes.sh:6617 runs after an unconditional kubectl wait hr ... --for=condition=ready on the parent at :6592, under errexit, and after the same wait on csi (:6239) and the waited addons (:6365-6375). Those waits read the same .status.conditions[type=Ready] the probe at :5261 reads. If that condition is renamed, those waits time out and the run goes red before the guard is reached. If only the history path is renamed, _ready still reads True and the canary fires. Both have to move at once for the scenario you describe, and the first of them already reds the run, so I left this unchanged.

# Snapshot, and neither truncation variant in helm-controller/api empties a
# non-empty history - Truncate returns early below two Snapshots,
# TruncateIgnoringPreviousSnapshots cuts only above five.
#

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.

[MINOR] Guard reads are fail-closed and unretried: one transient apiserver error on any release reds the run

Each release costs a listing plus up to three bounded reads, each reddening the run on a non-zero exit, and nothing is retried. The reads are now bounded so a wedged apiserver cannot eat the tenant snapshot, and fail-closed is deliberate (a cut-off read must not pass for no-history). Flagging the decision, not asking for a retry: a transient blip on any guard read is a red run, and the suite's dominant documented flake is a degraded management apiserver. Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deliberate, and I'd keep it for now. kubectl_wait_retry (run-kubernetes.sh:63) exists because a kubectl wait holds a watch for minutes and dies on the first server error anywhere in that window. Each guard read is one bounded GET, exposed for a single round-trip, the same as the unretried single reads elsewhere in run_kubernetes_test, such as the admin-kubeconfig fetch at :5847 and the LB IP read at :5884. The guard adds two of those per release to a passing path that already makes many. If a red run ever shows a guard read lost to one of the signatures that helper allowlists, wrapping the reads with the same allowlist is a small change that keeps fail-closed intact, since an exhausted retry still returns non-zero. Until then I'd rather not add a retry with no measured need for it.

@IvanHunters
IvanHunters enabled auto-merge October 2, 2026 20:53
auto-merge was automatically disabled October 2, 2026 22:42

Pull request was closed

@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/remediation-guard-distinguish-teardown branch 5 times, most recently from fe15946 to fa7706a Compare October 3, 2026 03:46
The history guard covered only the parent Kubernetes HelmRelease. A
failed action on one of the addon releases it installs left no mark
the suite inspected, so a run surfaced only the downstream symptom,
typically the node-join deadline, with nothing naming the release
behind it. The tenant CNI and CSI are the sharp cases, since they
carry a cluster-wide precondition.

The guard now reads every addon release of the parent, selected from
the cluster by name prefix so a release added later is covered too.
A "failed" or "uninstalled" Snapshot fails the run on a release whose
install strategy is RetryOnFailure: the parent, which the aggregated
apiserver stamps that way, and cilium and csi. The same footprint on
the other addons, which pair the default strategy with retries: -1,
is printed as a note and leaves the run green.

The footprint is not split into a teardown and a retried failure.
In helm-controller v1.5.0, the version the platform installs, and
unchanged in v1.5.1, a release with an active retry strategy never
remediates: atomic_release.go returns an upgrade for a failed
release before it reaches the remediation switch (lines 465-475), and
the two other paths that uninstall, a changed release target and
deletion, both call ClearHistory afterwards. On a RetryOnFailure
release an "uninstalled" Snapshot is therefore unreachable, and the
"failed" one is the only signal. A retry that later recovered still
fails the run, by design, with one blind spot the statuses cannot
close: a failed install that the very next attempt recovers is
marked superseded by Helm, because there is no deployed release to
supersede instead, and its history reads like an ordinary upgrade.

The guard runs after the assertions, while every deadline a failed
addon breaks ends the script earlier, so the same classification is
also printed without a verdict from the node-join collector and from
the exit handler. That handler is now armed at the tenant CR apply,
so the management-cluster waits before the tenant answers are
covered too. Every read is bounded and never folded into "no
history", and the parent must read back RetryOnFailure: an empty
value there means the jsonpath stopped resolving, and any other value
means the apiserver stamp is gone, either of which would turn its
footprint into a note.

The history read takes its jsonpath from HELMRELEASE_HISTORY_JSONPATH,
and the upstream-type pin accepts the bounded wrapper the read goes
through.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
The conventions still described a guard on the parent release alone.
They now state which footprint fails a run and on which releases,
why a footprint on a RetryOnFailure release is fatal even after a
retry recovered it and which recovery leaves no footprint at all,
why the default-strategy addons only get a note, how the strategy
read is kept from failing soft, and why the failure paths carry the
same classification without a verdict.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/remediation-guard-distinguish-teardown branch from fa7706a to b42805b Compare October 3, 2026 03:59
@lexfrei

Copy link
Copy Markdown
Contributor Author

IvanHunters the recovered-install gap is real. I corrected the claim in the code, the docs and both commit messages (8cb1023, b42805b) rather than widening detection in this PR, and the thread says why. Detection is #4736. The other two threads stay as they are, with the mechanism in each. The unguarded pool release is #4737.

@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.

Verdict

LGTM

The rework since my last pass is a claim correction, not a behaviour change, plus one hardening branch, and it lands well. Reviewed hermetically at head b42805bb, no cluster.

Last round I left this at LGTM with three non-blocking notes and flagged two gates that were not in the diff: a red Unit & controller tests and an open block from another reviewer. The CI gate is gone. #4729 merged on 2026-10-02 and the rebase onto current main picked it up, so Unit & controller tests is green and E2E (in-tree) actually ran and passed on this head rather than being skipped behind a red main.

What changed, and how my earlier notes landed:

  • The recovered-install gap I raised is now stated as a limitation rather than denied. cozy_guard_helmrelease, remediation-guard.sh, docs/agents/e2e-testing.md and both commit messages drop the "a recovered retry still fails" overstatement; the RetryOnFailure fixture moves to the history a real double-failure-then-recover leaves (deployed, superseded, failed), and a new test pins the one-failure-then-recover blind spot as a deliberate pass. Not widening detection here is the right call: "more than one history entry" would false-red the OIDC suite, which upgrades the parent on purpose before the guard runs. Tracked in #4736.
  • The strategy canary is tighter. On the parent, any .spec.install.strategy.name other than RetryOnFailure now fails the run, not only an empty one, so a future rename of spec.install.strategy can no longer route every footprint to the note branch on a green run. I mutated the branch to return 0 and the new parent-strategy test goes red, so the assertion is live and not one of the negated shapes the runner swallows.

I confirmed the tests are real by mutation: neutralising the new parent-strategy branch reds the parent guard fails when its install strategy is anything but RetryOnFailure, and dropping the node-join pointer from the cilium message reds a failed cilium install is not blamed on the chart without naming a late node join. All 44 cases in hack/run-kubernetes-remediation_test.bats pass at head.

The Ready-condition canary note from before does not hold up as a risk: the unconditional kubectl wait hr --for=condition=ready on the parent at run-kubernetes.sh:6592, under set -eu, reds the run before the guard is reached if that condition is renamed, so both the probe jsonpath and the wait condition would have to move at once and the first of them fails first. Leaving it unchanged is fine.

One merge gate is still outside the diff: myasnikovdaniil's CHANGES_REQUESTED is unresolved on the PR. The substance of it is addressed (a failed Snapshot on a RetryOnFailure release is now fatal), but the review has not been dismissed or re-submitted, so a human block stands until that reviewer looks again.

Findings

  • [MINOR] hack/e2e-chainsaw/_lib/run-kubernetes.sh:5190, Single-release guard cannot see a failed install the next retry recovers
  • [MINOR] hack/e2e-chainsaw/_lib/run-kubernetes.sh:5251, Guard reads are fail-closed and unretried
  • [MINOR] hack/e2e-chainsaw/_lib/run-kubernetes.sh:5449, Guard does not read the KubernetesNodes pool release

# tenant cilium and csi, whose templates set it on both actions. Under that
# strategy the controller never remediates: a failed action is retried as an
# upgrade with its manifests left in place, so what it leaves behind is a
# "failed" Snapshot, and that Snapshot is the only record the release keeps of

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.

[MINOR] Single-release guard cannot see a failed install the next retry recovers

A failed install with no prior deployed release recovers as an upgrade from the failed release, which Helm marks superseded and helm-controller rewrites in place, so the history reads deployed, superseded with no failed Snapshot left for this guard to read. This round documents the limitation and pins it with a test instead of widening detection, which is the right call here: a count-based heuristic would false-red the OIDC suite, which upgrades the parent on purpose before the guard runs. Tracked in #4736. Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll leave detection to #4736.

# Snapshot, and neither truncation variant in helm-controller/api empties a
# non-empty history - Truncate returns early below two Snapshots,
# TruncateIgnoringPreviousSnapshots cuts only above five.
#

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.

[MINOR] Guard reads are fail-closed and unretried

Each release costs a listing plus bounded reads, and any non-zero exit reds the whole run, so one transient apiserver error on any release is a red run. Deliberate and fail-safe, and consistent with the other single-GET reads in this file (admin-kubeconfig, LB IP). If a red run ever shows a read lost to a known transient signature, wrapping these in the existing retry helper keeps fail-closed intact. Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leaving them unretried for now. If a red run shows a guard read lost to one of the signatures kubectl_wait_retry allowlists, I'll wrap the reads then.

# addons are not, because most of them legitimately set none. See the branch
# in cozy_guard_helmrelease that reads this argument.
cozy_guard_helmrelease "$_ns" "${_parent}" required || _failed=1
cozy_guard_addon_helmreleases "$_ns" "${_parent}-" || _failed=1

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.

[MINOR] Guard does not read the KubernetesNodes pool release

The guard selects addon releases by name prefix off the parent, which does not cover the KubernetesNodes pool HelmRelease. Tracked in #4737. This is a coverage gap, not a wrong verdict on what it does read. Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That stays with #4737. The guard reads only the releases the parent prefix selects.

@myasnikovdaniil myasnikovdaniil 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.

Approving, both blockers from my previous review are fixed. I checked wiring by mutation: removing trap at CR apply, report call in node-join collector, CURRENT_TENANT_PARENT_HR assignment, or running snapshot before classification makes run-kubernetes-remediation_test.bats red, same for empty and non-RetryOnFailure strategy branches on parent. Out of 30 mutants on code added since my last review 26 are caught.

Guard also ran on real cluster now, E2E on this head read parent and all addon releases in both suites and found nothing.

Not blocking: phase budget gates in cozy_report_helmrelease_remediation are not tested. No test sets _COZY_DIAG_PHASE_DEADLINE, so cozy_diag_phase_has_time always says yes, and deleting per-release gate at run-kubernetes.sh:5550 keeps everything green. Without it walk on node-join path can go past spent phase for its own 90s plus already started reads, and that time is taken from tenant snapshot. One case with deadline in the past that checks decline note and no further reads would cover all three gates. Smaller ones of same shape: required on parent call at :5542 and _cozy_guard_read_bounds at :5237 can be dropped and no test notices.

@lexfrei
Aleksei Sviridkin (lexfrei) merged commit 415430b into main Oct 5, 2026
59 of 62 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the fix/remediation-guard-distinguish-teardown branch October 5, 2026 14:12
myasnikovdaniil added a commit that referenced this pull request Oct 5, 2026
#4751)

## What this PR does

Unit tests on main are red, and that blocks E2E on every open PR. Three
contracts broke the same way. In each case a check from one PR and a
line from another merged a few minutes apart on 2026-10-05, with no
textual conflict. Any one of them alone keeps the job red, so they are
fixed together.

The first is the pin on how a suite reads HelmRelease history. #4689
added it. It accepts `$(kubectl ...` and, since #3594, the bounded
`$(_cozy_guard_kubectl ...` wrapper. Nothing in front of kubectl is
allowed. The monitoring suite from #4678 bounds that read with
`$(timeout 30 kubectl ...`, and the bound is wanted. So the pin now
accepts an optional `timeout [-k N] N` in front of kubectl. The prefix
is spelled out on purpose. Any text would also let through `$(echo
kubectl ...)`, which names the shared expansion without running it, and
that form is now a refuse case. I checked it with mutations: the new
accept cases fail against the old pattern, and a pin that takes any
prefix fails a refuse case.

The second is the flux install in workflows. #4645 forbids running
fluxcd.io/install.sh and moved every workflow to the SHA-pinned
fluxcd/flux2 action. The isp-hosted lane from #4712 still ran the
script. It now uses the same action and version.

The third is the negated-assertion audit. It reads the find operand in
`\( ! -type d` in select-e2e_test.bats as a shell negation and fails.
The line now says `-not -type d`. This is the same change as #4748, so
this PR supersedes it. The audit bug itself is #4752.

After the Go fix, the full log of the unit job had only these two bats
failures left.

Closes #4750
Closes #4755

### Screenshots

### Downstream repositories

- [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:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
fix(ci): fix three unit-test contracts that went red on main after adjacent merges.
```


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

* **Bug Fixes**
* History checks now recognize direct `kubectl` reads and reads through
the supported wrapper, including timeout-bounded commands with an
optional kill-grace setting. Commands that only include `kubectl` as an
argument, use similarly named commands, or place a timeout before the
wrapper are not treated as reads.
* **Chores**
* The hosted end-to-end workflow now uses a pinned Flux version, and
suite selection handles directory exclusions consistently.
<!-- 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.

e2e: no remediation-cycle guard covers the tenant addon HelmReleases, and the existing helper cannot tell a retry from a teardown

3 participants