Skip to content

fix(kubernetes): bound the remaining pre-delete hook deletions - #3604

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

fix(kubernetes): bound the remaining pre-delete hook deletions#3604
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
fix/kubernetes-pre-delete-hook-bound

Conversation

@lexfrei

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

Copy link
Copy Markdown
Contributor

What this PR does

Three of the six steps in the kubernetes pre-delete hook call kubectl delete with neither --wait=false nor --timeout. --wait defaults to true and --timeout=0 is implemented as a week, so the KamajiControlPlane delete in step 3, the DataVolume delete in step 5 and the LoadBalancer Service delete in step 6 each park the hook for 168 hours on one stuck finalizer. A DataVolume held by a CDI importer or a PVC finalizer is the reachable case.

The waits in steps 2 and 4 were bounded earlier, in response to a report that named the child-HelmRelease wait and nothing else. The fix stopped where the report did, so the shape it described stayed in the three steps nobody had looked at.

None of the three waits now. Nothing downstream reads the result of steps 5 and 6. What matters after step 3 is the TenantControlPlane, and step 4 deletes that one directly and waits for it, so blocking on the KamajiControlPlane first only adds a second place for a finalizer to stall. An expired wait and --wait=false leave the same cluster state, with the same objects finalizing in the background.

Not waiting also keeps these steps under set -e with no guard, which matters more than it looks. A guard cannot tell an expired wait from a rejected request, so it would report a failed delete as cleanup that happened. With no timeout to expire, every non-zero exit left is a real failure.

The watch verb goes away for those three resources. kubectl opens a watch only on the wait path, so the grant was unexercised. helmreleases and tenantcontrolplanes keep it because steps 2 and 4 still wait, and there the verb is load-bearing in a way that is easy to miss: removing it does not produce an error, it hangs the delete. That is the failure this repo already hit once at the tenant level, so the whole rule set is pinned rather than left to the next reviewer of this Role.

backoffLimit goes to 0, against a default of 6 that restartPolicy: Never turns into seven pods in series, each restarting the script from step 1 and each paying its own image pull.

What this PR does not do

It does not add activeDeadlineSeconds, and that is arithmetic rather than oversight. A deadline has to sit above the sum of the remaining waits, or it cuts runs that were going to finish, and far enough under helm-controller's uninstall budget to be the thing helm reports rather than something helm waits through. Those two bounds do not currently leave a gap, and the image pull comes from Docker Hub with no mirror in front of it. Room comes from raising spec.uninstall.timeout on the generated HelmRelease first; squeezing the waits further is the wrong direction.

Worth stating plainly, because it is the one change here that is not purely a tightening: with backoffLimit: 0 a pod eviction now fails the whole uninstall attempt, where six retries previously absorbed it. That is deliberate. Both remaining waits are guarded, so an attempt can spend its entire budget without failing, and a retry would restart from step 1 with the clock already there; on a nodeless tenant it would also re-pay step 4, since the first attempt leaves the TenantControlPlane still terminating. The failure a retry would cover is an eviction, which is not bounded in time and costs most when it arrives late. Flux still repeats the uninstall on its own interval, so the end state is preserved and the trade is wall-clock for predictability.

It does not bound the requests. --wait=false and --timeout bound the waiting, so an apiserver that accepts the connection and then stops answering hangs this hook exactly as before. That is a different failure from the stuck finalizer, it is not made worse here, and it wants one answer across every call rather than a flag on the deletes.

Tests

packages/apps/kubernetes/tests/delete_hook_test.yaml gains cases for the bounds and for the Role. Every delete in the hook is asserted to carry one, the Job is pinned to a single attempt, and the rule set is pinned whole.

The regex patterns walk only continuation lines, the ones ending in a backslash, so each stays inside the command it names. A cross-line wildcard reaches a flag belonging to the next command instead, and RE2 has no lookahead to exclude the echo in between.

The Role assertions use whole-rule contains plus lengthEqual rather than per-index verb checks. notContains passes when its path does not resolve, so an index-addressed "and nowhere else" goes quiet the moment the rules list is reordered or shortened, which is exactly the edit it exists to catch. Matching the entire rule pins the verb set in both directions and names the resource when it fails.

Every pinned value was mutation-tested: dropping --wait=false from each of the three deletes, dropping --timeout from either of the two that still wait, backoffLimit in both directions, adding watch back, removing watch from a resource that waits, appending a rule past everything asserted, and reordering the rules list. Each one fails the assertion that claims that ground and no other.

Screenshots

None. This changes a Job manifest and its tests, with no user-visible surface.

Downstream repositories

Walked the trigger map against the diff. The diff is one Job manifest inside the kubernetes chart and its helm-unittest suite. No package is added, renamed or removed; no values.schema.json, values.yaml default or version enum changes; no ApplicationDefinition semantics, namespace, variant or release asset changes; nothing under hack/ is touched and no make target changes behaviour.

Release note

fix(kubernetes): bound the pre-delete hook's remaining unbounded deletions and limit its Job to a single attempt, so a stuck finalizer on a DataVolume, LoadBalancer Service or KamajiControlPlane no longer parks the hook and blocks the uninstall

Summary by CodeRabbit

  • Bug Fixes
    • Improved Kubernetes resource cleanup during deletion to avoid indefinite waits.
    • Cleanup operations now complete with bounded waiting and fewer retry attempts, improving reliability and responsiveness.
    • Removed unnecessary access permissions from cleanup operations, strengthening security.
  • Tests
    • Added coverage to verify deletion timeouts, retry behavior, and required permissions across supported resources.

Three of the six steps in the pre-delete hook call kubectl delete
with neither --wait=false nor --timeout. kubectl defaults --wait to
true and reads --timeout=0 as "wait forever", which it implements as
a week, so the KamajiControlPlane delete in step 3, the DataVolume
delete in step 5 and the LoadBalancer Service delete in step 6 each
park the hook for 168 hours on a single stuck finalizer. A DataVolume
held by a CDI importer or a PVC finalizer is the reachable case.

None of the three waits now. Nothing downstream reads the result of
steps 5 and 6, and what matters after step 3 is the
TenantControlPlane, which step 4 deletes and waits for directly;
blocking on the KamajiControlPlane first only adds a second place for
a CAPI finalizer to stall. An expired wait and --wait=false leave the
same cluster state either way, with the same objects finalizing in
the background once the hook has moved on.

Not waiting also keeps these steps under `set -e` with no guard. A
guard cannot tell an expired wait from a rejected request, so it
would report a failed delete as cleanup that happened; with no
timeout to expire, every non-zero exit left is a real failure.

Drop the watch verb from the three resources those steps delete, and
pin the whole rule set. kubectl opens a watch only on the wait path,
so the grant was unexercised for the three; for helmreleases and
tenantcontrolplanes its absence would not error but hang, which is
the failure mode hardest to read from the outside.

Set backoffLimit to 0, against a default of 6 that restartPolicy
Never turns into seven pods in series. Zero rather than one because
both remaining waits are guarded, so an attempt can spend its whole
budget without failing and a retry would start from step 1 with the
clock already there; on a nodeless tenant it would also re-pay step
4, since attempt one leaves the TenantControlPlane still terminating.
The failure a retry would cover is an eviction, which is not bounded
in time and is worst exactly when it arrives late. The price is that
such an eviction fails the whole attempt and flux repeats it on its
own interval.

No activeDeadlineSeconds: the waits and helm-controller's uninstall
budget leave no gap a deadline could sit in, once a Docker Hub image
pull is counted. That needs the budget raised first, through
spec.uninstall.timeout on the generated HelmRelease, and is left out
rather than set to a number that would cut runs which were going to
finish.

These bounds cover the waiting, not the request: --request-timeout is
still 0 on every call in the hook, so an unresponsive apiserver hangs
it as before. That is a different failure from the stuck finalizer,
and it wants one answer across every call rather than a flag on the
deletes.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ae7726c8-1afd-4f8f-8c22-afcc80240dee

📥 Commits

Reviewing files that changed from the base of the PR and between c6eb584 and b60c08f.

📒 Files selected for processing (2)
  • packages/apps/kubernetes/templates/delete.yaml
  • packages/apps/kubernetes/tests/delete_hook_test.yaml

📝 Walkthrough

Walkthrough

The pre-delete cleanup Job now runs without retries. Resource deletion uses explicit timeout or asynchronous behavior. Tests verify delete bounds, Job execution settings, and the six Kubernetes Role rules.

Changes

Pre-delete cleanup hook

Layer / File(s) Summary
Bounded cleanup execution and deletes
packages/apps/kubernetes/templates/delete.yaml, packages/apps/kubernetes/tests/delete_hook_test.yaml
The cleanup Job uses backoffLimit: 0. KamajiControlPlane, DataVolume, and Service deletes use --wait=false. HelmRelease and TenantControlPlane deletes retain positive timeouts. Tests verify these bounds and require restartPolicy: Never.
Reduced cleanup permissions
packages/apps/kubernetes/templates/delete.yaml, packages/apps/kubernetes/tests/delete_hook_test.yaml
The Role removes watch from KamajiControlPlane, DataVolume, and Service permissions. Tests verify exactly six rules and allow watch only for HelmReleases and TenantControlPlanes.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested labels: kind/bug, size/M, backport, area/kubernetes

Suggested reviewers: kvaps

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: bounding the remaining Kubernetes pre-delete hook deletions.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/kubernetes-pre-delete-hook-bound

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.

@github-actions github-actions Bot added area/kubernetes Issues or PRs related to the tenant Kubernetes app kind/bug Categorizes issue or PR as related to a bug size/L This PR changes 100-499 lines, ignoring generated files labels 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.

Verdict

LGTM

Correct, complete, and well-tested. This closes the gap that #3598 left open without repeating its mistake: I enumerated every kubectl delete in the hook and confirmed all five are now bounded — two by --wait=true --timeout=120s (Steps 2, 4) and three by --wait=false (Steps 3, 5, 6). No unbounded wait remains.

What I verified

  • The root-cause claim is real. kubectl delete defaults --wait=true, and with no --timeout the effective wait is 168 * time.Hour (one week) — confirmed in upstream k8s.io/kubectl/pkg/cmd/delete/delete.go (if effectiveTimeout == 0 { // wait forever -> a week). So Steps 3/5/6, which previously carried neither flag, were each one stuck finalizer away from parking the pre-delete hook for a week. The reachable trigger (a DataVolume held by a CDI importer finalizer, or a PVC finalizer) is accurate.
  • --wait=false is safe at these three sites. Nothing downstream reads their result, the DELETE is accepted either way, and the whole tenant is being torn down — an expired wait and --wait=false leave identical cluster state, one just sooner. Step 4 keeps a bounded wait because Step 4b legitimately depends on the TCP being gone.
  • RBAC tightening is correct least-privilege. watch is removed from exactly the three resources whose deletes no longer wait (kamajicontrolplanes, datavolumes, services) and retained on the two that do (helmreleases, tenantcontrolplanes). kubectl delete --wait=false never opens a watch, so the verb would have been dead grant.
  • Tests pass and are well-built. helm unittest -f tests/delete_hook_test.yaml → 7/7 green. The new asserts walk continuation lines only ((?:[^\n]*\\\n)*) so each pattern stays inside the command it names — correct for helm-unittest's RE2 engine (no lookahead). The Role test uses lengthEqual: 6 + whole-rule contains rather than index-addressed per-verb asserts, so it pins the verb set in both directions and survives a reorder/append — the same anti-pattern #3606 fixes elsewhere, avoided here proactively.

Non-blocking notes

  • [MINOR] Rendered comment bloat in the Job spec. The two Job-spec-level prose blocks (backoffLimit rationale, lines ~11-23, and the activeDeadlineSeconds rationale, lines ~25-41) are multi-paragraph essays that render verbatim into the Job manifest in every customer cluster (# YAML comments are not stripped like {{/* */}}) and will drift as the code changes. The flag-level shell comments on --wait=false are closer to the "non-obvious guard" that's worth keeping inline; the two spec-level blocks are the ones I'd trim to a one-to-two-line pointer (plus a link to the docs / the bucket/cleanup-acme.yaml reference). Not blocking — the content is genuinely useful and the Job is short-lived.
  • [INFO] --request-timeout residual is honestly disclosed. The code itself notes that --wait=false/--timeout bound the wait, not the request, so an unresponsive apiserver is still unbounded (--request-timeout stays 0), and there is no activeDeadlineSeconds. That is a strict improvement over the prior state (which had this same gap plus the week-long waits), and it is called out in-code as follow-up (copy the cleanup-acme.yaml shape). Fine to leave for a follow-up; flagging so it is not forgotten.

Nice work — the completeness pass (grep every delete, bound each, drop the now-dead watch grants, pin it all with tests) is exactly the right shape for a fix that exists because the previous one stopped short.

@lexfrei
Aleksei Sviridkin (lexfrei) merged commit ab86751 into main Aug 7, 2026
15 of 16 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the fix/kubernetes-pre-delete-hook-bound branch August 7, 2026 14:36
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/L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants