Skip to content

fix(apps/kubernetes): report the pre-delete cleanup outcome honestly - #3592

Merged
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
fix/kubernetes-pre-delete-honest-outcome
Aug 7, 2026
Merged

fix(apps/kubernetes): report the pre-delete cleanup outcome honestly#3592
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
fix/kubernetes-pre-delete-honest-outcome

Conversation

@lexfrei

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

Copy link
Copy Markdown
Contributor

What this PR does

The pre-delete cleanup Job for the kubernetes app ended with an unconditional echo "Cleanup completed successfully". Several of its steps swallow a failure behind || echo "WARNING: ..." so that set -e cannot 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.yaml is 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, and set -e ending 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 under set -e and 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.bats renders the Job, extracts the script and runs it against a stub kubectl, 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

  • No downstream repository is affected by this change

Release note

fix(kubernetes): the pre-delete cleanup hook no longer reports success when a cleanup step left something behind, and no longer reports a completed teardown as incomplete when it merely fell back to force-clearing finalizers. The Job log now names the steps that left residue. The hook still exits zero so the Helm uninstall can remove the release.

Summary by CodeRabbit

  • Bug Fixes

    • Improved Kubernetes cleanup handling during application deletion.
    • Cleanup now distinguishes unavailable resource checks from confirmed absence, avoiding unsafe finalizer removal.
    • Reports leftover resources when recoverable cleanup steps fail while still completing the deletion process.
    • Improved handling of child releases, control-plane resources, and datastore secrets.
  • Tests

    • Added comprehensive coverage for successful cleanup, partial failures, resource residue, probe errors, and unrecoverable steps.

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>
@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files area/kubernetes Issues or PRs related to the tenant Kubernetes app 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

📝 Walkthrough

Walkthrough

The 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 kubectl.

Changes

Pre-delete cleanup residue handling

Layer / File(s) Summary
Track cleanup residue and resource state
packages/apps/kubernetes/templates/delete.yaml
The hook deduplicates residue labels, records HelmRelease cleanup failures, probes resources with three-state handling, and reports incomplete cleanup while exiting zero.
Render and execute the hook in isolation
hack/kubernetes-pre-delete-hook.bats
The test harness renders the Helm command, installs a configurable kubectl stub, isolates kubeconfig access, and captures output and status.
Validate cleanup outcomes and exit behavior
hack/kubernetes-pre-delete-hook.bats
Tests cover clean runs, fallback paths, residue sources, probe errors, all-failure summaries, unrecoverable Step 3 failures, and exit statuses.

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
Loading

Possibly related PRs

Suggested labels: area/testing

Suggested reviewers: ivanhunters, myasnikovdaniil

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: accurate reporting of the Kubernetes pre-delete cleanup outcome.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/kubernetes-pre-delete-honest-outcome

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.

@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

🤖 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

📥 Commits

Reviewing files that changed from the base of the PR and between b4e2031 and 0827bcb.

📒 Files selected for processing (2)
  • hack/kubernetes-pre-delete-hook.bats
  • packages/apps/kubernetes/templates/delete.yaml

Comment on lines 143 to +145
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"

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.

🎯 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 returns NotFound, 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-L230
  • hack/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.

@lexfrei

Copy link
Copy Markdown
Contributor Author

Reopening to pick up the multus fix on main.

@kvaps Andrei Kvapil (kvaps) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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-145kubectl patch has no --ignore-not-found. If a child HelmRelease finishes deleting between the get at :136 and its patch — 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 with get "$hr" --ignore-not-found -o name and 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-55 uses get ... --ignore-not-found -o name ... || var=err in 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,2202>/dev/null drops 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) run kubectl delete with no --timeout, so their waits are effectively unbounded, and the Job has no activeDeadlineSeconds. 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.

@lexfrei
Aleksei Sviridkin (lexfrei) merged commit 0b7a8f6 into main Aug 7, 2026
44 of 47 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the fix/kubernetes-pre-delete-honest-outcome branch August 7, 2026 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/kubernetes Issues or PRs related to the tenant Kubernetes app kind/bug Categorizes issue or PR as related to a bug size/XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants