fix(apps/kubernetes): report the pre-delete cleanup outcome honestly - #3592
Conversation
The pre-delete cleanup Job ended with an unconditional "Cleanup completed successfully". Several of its steps swallow a failure behind `|| echo "WARNING: ..."` so that `set -e` cannot end the run, so that line was printed in exactly the situation the warnings exist to report: child HelmReleases still holding finalizers, or a Kamaji datastore Secret still pinning its namespace in Terminating. Record what a step leaves behind and close with either the success line or the list of steps that left something. Residue is the thing worth recording, not every fallback: a bounded delete that times out has a remedy right behind it -- Step 2 falls through to force-clearing finalizers, Step 4 falls through to Step 4b -- and whether anything survived is decided by that remedy, which reports its own outcome. A tenant with no working nodes takes that route as a matter of course, which is why the wait is bounded in the first place, and calling such a run incomplete would send an operator hunting for objects that are not there. A read that fails looks exactly like one that found nothing: an empty list and a failed list both give a loop nothing to iterate, and an absent object and an unreachable apiserver both make a probe non-zero. Every read the summary rests on now keeps its status. Step 2's force-clear re-list reports when it cannot list what it was about to clear, because everything it could not return is still holding its finalizer. Step 4b's two probes became three-way -- present, absent or unknown -- so a Secret whose existence could not be established is no longer announced as absent. Step 1's list is the one read still swallowing its status, and that is a decision rather than an omission: Step 2 deletes by label rather than from that list, so a suspend that never happened leaves nothing of its own behind and reporting it would name residue that does not exist. Making the TenantControlPlane probe three-way also repairs the gate above it, and that is intended rather than incidental. The gate exists so the datastore Secret's finalizer is not stripped while Kamaji may still own it, and a two-way probe defeated it in the one direction that does damage: any error reads as "confirmed absent" and the strip goes ahead. Its likeliest cause, a throttled or briefly unreachable apiserver, is also likeliest exactly when a broken tenant is being torn down. Step 4b declining to strip a finalizer while the TenantControlPlane is still alive is the one residue that no failing command marks: every call in that branch succeeds and the namespace stays in Terminating regardless. It now reports, and names the TenantControlPlane as the thing holding the namespace up. The exit code is deliberately unchanged and still zero. These are backstops, not the main path: they run after the normal deletion has already timed out, and they exist to push teardown further along, so failing there would abort the Helm uninstall at the very moment the backstop was written for, leaving the release in storage and the same teardown retried forever. That makes the closing message the only outcome report an operator gets, which is why it has to be accurate. The steps that are not backstopped still end the run under `set -e` and never reach that message. Rework the runtime warning and the RBAC comment that pointed at the tracking issue for the Kamaji datastore-secret leak. That issue is closed, and closed by this very step rather than by a fix in Kamaji, so a reader who followed the pointer found a resolved report and no reason for the code to exist. The step comment now says the ordering it covers is still reachable, and the warning names the finalizer and the object to clear by hand. Cover the behaviour with a bats file that renders the Job, extracts the script and runs it against a stub kubectl, since the helm-unittest cases match the script's source text and cannot tell whether the closing message is reachable. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
📝 WalkthroughWalkthroughThe pre-delete cleanup hook now records recoverable cleanup failures, distinguishes API errors from absent resources, preserves finalizers when state is unknown, and reports residue while exiting successfully. A new Bats suite validates rendered-hook behavior with a stubbed ChangesPre-delete cleanup residue handling
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Bats
participant PreDeleteHook
participant kubectlStub
Bats->>PreDeleteHook: render and execute cleanup script
PreDeleteHook->>kubectlStub: enumerate and probe resources
kubectlStub-->>PreDeleteHook: configured status and resource state
PreDeleteHook-->>Bats: cleanup output and exit status
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/apps/kubernetes/templates/delete.yaml`:
- Around line 143-145: Update the HelmRelease cleanup patch handling in
packages/apps/kubernetes/templates/delete.yaml#L143-L145 to re-probe status
after a failed patch and record residue only when the object still exists; treat
NotFound/absence as clean. Apply the same status-aware re-probe to Secret patch
handling at packages/apps/kubernetes/templates/delete.yaml#L227-L230. Add a bats
case at hack/kubernetes-pre-delete-hook.bats#L192-L213 where the Secret is
initially present, patch returns NotFound, re-probe reports absence, and the
summary is clean.
🪄 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: 4d62fcf2-5323-431f-93e6-eedef16d4fc6
📒 Files selected for processing (2)
hack/kubernetes-pre-delete-hook.batspackages/apps/kubernetes/templates/delete.yaml
| kubectl -n {{ .Release.Namespace }} patch "$hr" \ | ||
| --type=merge -p '{"metadata":{"finalizers":null}}' \ | ||
| || echo "WARNING: failed to clear finalizers on $hr" >&2 | ||
| || backstop "step 2 (child HelmRelease finalizers)" "failed to clear finalizers on $hr" |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Do not report residue for a patch that races with object deletion.
An object can disappear after its successful list or probe and before kubectl patch. The patch then returns NotFound, but no finalizer remains. The current code adds the residue label and reports incomplete cleanup.
packages/apps/kubernetes/templates/delete.yaml#L143-L145: After a failed HelmRelease patch, use a status-aware re-probe. Do not record residue when the HelmRelease is absent.packages/apps/kubernetes/templates/delete.yaml#L227-L230: After a failed Secret patch, use a status-aware re-probe. Do not record residue when the Secret is absent.hack/kubernetes-pre-delete-hook.bats#L192-L213: Add a case where the initial Secret probe returns present, the patch returnsNotFound, and the re-probe returns absent. Assert a clean summary.
📍 Affects 2 files
packages/apps/kubernetes/templates/delete.yaml#L143-L145(this comment)packages/apps/kubernetes/templates/delete.yaml#L227-L230hack/kubernetes-pre-delete-hook.bats#L192-L213
🤖 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 `@packages/apps/kubernetes/templates/delete.yaml` around lines 143 - 145,
Update the HelmRelease cleanup patch handling in
packages/apps/kubernetes/templates/delete.yaml#L143-L145 to re-probe status
after a failed patch and record residue only when the object still exists; treat
NotFound/absence as clean. Apply the same status-aware re-probe to Secret patch
handling at packages/apps/kubernetes/templates/delete.yaml#L227-L230. Add a bats
case at hack/kubernetes-pre-delete-hook.bats#L192-L213 where the Secret is
initially present, patch returns NotFound, re-probe reports absence, and the
summary is clean.
|
Reopening to pick up the multus fix on main. |
Andrei Kvapil (kvaps)
left a comment
There was a problem hiding this comment.
LGTM once rebased. No blockers.
The CI failure is not yours: hack/cozyreport.bats trips on the EXIT-trap freeze list (multus-install-cni-plugins.bats=12 present in found, absent from frozen). That was fixed by #3584, which is on main (6c82988); this branch is based on b4e2031 (#3581). A rebase turns it green.
Verified locally: the new bats suite is 10/10, make -C packages/apps/kubernetes test is 21 suites / 197 tests green, shellcheck -s sh over the rendered script is clean, and the RBAC covers both get tenantcontrolplanes and get/patch on the named Secret, so the new probes cannot fail on permissions. The hack/cozytest.sh ^}$ rewrite hazards the file documents are real (hack/cozytest.sh:190-194) and are handled correctly. The bats file is picked up automatically — Makefile:161 globs hack/*.bats with no _test suffix requirement.
The part of this I value most is not the wording. Making the TenantControlPlane probe three-way (templates/delete.yaml:202-207) repairs the gate itself: a two-way probe read any read failure as "confirmed absent" and stripped a finalizer whose owner might still exist, which is the opposite of what #3078 added the gate for. Declining to record a changed route as residue is also the right call — a tenant with no working nodes takes the fallback as a matter of course.
One thing worth doing before merge (one line)
templates/delete.yaml:10 — set backoffLimit: 1.
#3593 argues this at length for its own hooks: with the Kubernetes default of six, a Job that genuinely fails outlives the hook timeout and Helm reports that timeout instead of the hook's own error. This hook still has non-zero exit paths (Steps 3, 5, 6 and the patch inside Step 1 end the run under set -e), so the same reasoning applies here — and the budget is tighter than in #3593. The generated HelmRelease sets install and upgrade timeouts only (pkg/registry/apps/application/rest.go:1600,1607); uninstall falls back to the HelmRelease default of 5m, and this hook's two bounded waits already spend 240s of it. Every attempt past the first is spent past the deadline.
This is also the only place where the two PRs disagree on mechanics for no reason rooted in the hook type — the rest of the divergence (set -e vs set -u, label list vs counter, exit 0 vs exit 1) follows correctly from pre-delete aborting the uninstall while post-delete does not.
Notes
-
templates/delete.yaml:143-145—kubectl patchhas no--ignore-not-found. If a child HelmRelease finishes deleting between thegetat :136 and itspatch— precisely when Flux is catching up on the wait that just expired — the patch returns NotFound and the summary names residue that is not there. That is the same defect the fallback case guards against, pointing the other way. The three-way shape this PR introduces elsewhere fixes it: on a failed patch, re-probe withget "$hr" --ignore-not-found -o nameand record only if the object is still present. -
templates/delete.yaml:105-107— the Step 1 patch is not backstopped, so a NotFound there aborts the uninstall, which is the outcome the whole design avoids. By the reasoning you give for Step 1's list (Step 2 deletes by label, so a suspend that never happened leaves nothing behind), this patch deserves the same treatment. Reachable when the Job is retried. Pre-existing. -
templates/delete.yaml:196-201— the comment records "NotFound exits 0" as inferred rather than measured. There is a working precedent in-tree:hack/e2e-chainsaw/_lib/etcd-cleanup.sh:53-55usesget ... --ignore-not-found -o name ... || var=errin e2e, and if NotFound were non-zero those helpers would classify every deleted object as an error on every run. Worth citing in place of the caveat. -
templates/delete.yaml:203,220—2>/dev/nulldrops the reason. In the "state unknown" branch the operator learns that the script could not find out but not why (RBAC, apiserver, discovery). For a change about reporting the outcome honestly that seems like the one thing worth keeping. -
Steps 3, 5 and 6 (
:149-151,:237-239,:242-245) runkubectl deletewith no--timeout, so their waits are effectively unbounded, and the Job has noactiveDeadlineSeconds. Only Steps 2 and 4 are bounded. Against the 5m uninstall budget that is the same class of problem as #3271. Pre-existing; a follow-up rather than something for this PR. -
The bats suite has no case for the one read you deliberately leave swallowing its status (Step 1's list). A case pinning "Step 1's list failed and the run is still reported clean" would guard the decision, which is currently only documented in a comment.
-
packages/apps/kubernetes/tests/delete_hook_test.yaml:1— the suite title still reads(issue #3062)although this PR deliberately removes that pointer from the template comments for the same reason.
On reachability
You are right that the message is truthful but not reachable, and I agree the delivery channel is a separate change. Worth filing as an issue rather than leaving in the PR body: as it stands the closing line only helps someone tailing kubectl logs -f during the delete, since hook-succeeded (:8) reaps the Job and its pod on exactly the path that produces the honest message. The inversion is worth naming in that issue — the exit-code failures, which Helm surfaces anyway, are the ones whose Job is kept.
What this PR does
The pre-delete cleanup Job for the
kubernetesapp ended with an unconditionalecho "Cleanup completed successfully". Several of its steps swallow a failure behind|| echo "WARNING: ..."so thatset -ecannot end the run, which means that line was printed in exactly the situation those warnings exist to report: child HelmReleases still holding Flux finalizers, or a Kamaji datastore Secret still pinning its namespace. Someone reading the Job log to find out why a namespace will not delete was told the cleanup had worked.The Job now records what a step left behind and closes with either the success line or the list of steps that left something.
Only residue gets recorded. A bounded delete that times out has a remedy right behind it (Step 2 falls through to force-clearing finalizers, Step 4 falls through to Step 4b), and whether anything survived is decided by that remedy, which reports its own outcome. A tenant with no working nodes takes that route as a matter of course, which is the reason the wait is bounded at all, so recording the timeout itself would report a finished teardown as incomplete on the commonest path there is and send an operator hunting for objects that are not there. That is the same defect as the one being fixed, pointing the other way.
A read that fails looks exactly like one that found nothing. An empty list and a failed list both give a loop nothing to iterate; an absent object and an unreachable apiserver both make a probe non-zero. So the whole script was walked for that shape instead of patching the instances as they were reported. Four reads reach the closing line. Step 2's force-clear re-list now keeps its status and reports when it cannot list what it was about to clear. Step 4b's two probes became three-way, present, absent or unknown, so a Secret whose existence could not be established is no longer announced as absent. Step 1's list is the one left swallowing its status, and that is deliberate: Step 2 deletes by label rather than from that list, so a suspend that never happened leaves nothing of its own behind, and reporting it would name residue that does not exist.
Making the TenantControlPlane probe three-way also repairs the gate above it, and that is intended. The gate is there so the datastore Secret's finalizer is not stripped while Kamaji may still own it, and a two-way probe defeated it in the one direction that does damage: any error read as "confirmed absent" and the strip went ahead. Its likeliest cause, a throttled or briefly unreachable apiserver, is also likeliest exactly when a broken tenant is being torn down.
Step 4b declining to strip a finalizer while the TenantControlPlane is still alive is the one residue that no failing command marks: every call in that branch succeeds, and the namespace stays blocked regardless. It now reports, and names the TenantControlPlane as the thing holding it up.
What this PR does not do: the hook still exits zero, even with every backstop engaged. That is the difference between a main path and a backstop.
packages/apps/tenant/templates/cleanup-job.yamlis the same kind of hook and handles it the opposite way, correctly: deleting the HelmReleases is that hook's whole purpose, so a failure there means the cleanup did not happen, andset -eending the run before the success line is honest. The swallowed sites here are backstops. They run only after the normal deletion has already timed out, and they exist to push teardown further along. Failing there aborts the Helm uninstall before the release is removed, so the release stays in storage and the same doomed teardown is retried forever, at precisely the moment the backstop was written for. The steps that are not backstopped still end the run underset -eand never reach the closing message at all.This makes the message truthful. It does not make it reachable. The Job carries
helm.sh/hook-delete-policy: hook-succeeded, and the hook exits zero by design on every backstopped run, so Helm reaps the Job, and its pod log, right after the hook completes. Whether the closing line survives long enough to be read is a question about the delivery channel, not about its wording, and changing that channel (an Event on the CR, or keeping the Job when something was left behind) has a different blast radius. Left for a separate change.The step comment and the runtime warning both cited the tracking issue for the Kamaji datastore-secret leak as though it were live. It is closed, and closed by this step rather than by a fix in Kamaji, so following the pointer showed a resolved report and no reason for the code to exist. The comment now says the ordering it covers is still reachable, and the warning names the finalizer and the object to clear by hand instead of an issue number. The workaround itself is untouched: nothing indicates the upstream cause has gone away.
hack/kubernetes-pre-delete-hook.batsrenders the Job, extracts the script and runs it against a stubkubectl, covering a clean run, a fallback route that succeeded, a TenantControlPlane outliving Step 4, each probe that could not answer, every remedy failing at once, an unbackstopped step ending the run, and the zero-exit invariant. The helm-unittest cases match the script's source text and cannot tell whether the closing message is reachable. Every case was checked by mutation, including reinstating the unconditional success line and reinstating the over-reporting the fallback case guards against.Screenshots
Not a UI change.
Downstream repositories
Release note
Summary by CodeRabbit
Bug Fixes
Tests