fix(kubernetes): bound the remaining pre-delete hook deletions - #3604
Conversation
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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThe 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. ChangesPre-delete cleanup hook
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 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 |
IvanHunters
left a comment
There was a problem hiding this comment.
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 deletedefaults--wait=true, and with no--timeoutthe effective wait is168 * time.Hour(one week) — confirmed in upstreamk8s.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=falseis 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=falseleave 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.
watchis 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=falsenever 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 useslengthEqual: 6+ whole-rulecontainsrather 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 (backoffLimitrationale, lines ~11-23, and theactiveDeadlineSecondsrationale, 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=falseare 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 / thebucket/cleanup-acme.yamlreference). Not blocking — the content is genuinely useful and the Job is short-lived. - [INFO]
--request-timeoutresidual is honestly disclosed. The code itself notes that--wait=false/--timeoutbound the wait, not the request, so an unresponsive apiserver is still unbounded (--request-timeoutstays 0), and there is noactiveDeadlineSeconds. 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 thecleanup-acme.yamlshape). 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.
What this PR does
Three of the six steps in the
kubernetespre-delete hook callkubectl deletewith neither--wait=falsenor--timeout.--waitdefaults to true and--timeout=0is 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=falseleave the same cluster state, with the same objects finalizing in the background.Not waiting also keeps these steps under
set -ewith 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
watchverb goes away for those three resources. kubectl opens a watch only on the wait path, so the grant was unexercised.helmreleasesandtenantcontrolplaneskeep 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.backoffLimitgoes to 0, against a default of 6 thatrestartPolicy: Neverturns 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 raisingspec.uninstall.timeouton 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: 0a 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=falseand--timeoutbound 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.yamlgains 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
echoin between.The Role assertions use whole-rule
containspluslengthEqualrather than per-index verb checks.notContainspasses 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=falsefrom each of the three deletes, dropping--timeoutfrom either of the two that still wait,backoffLimitin both directions, addingwatchback, removingwatchfrom 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.yamldefault or version enum changes; noApplicationDefinitionsemantics, namespace, variant or release asset changes; nothing underhack/is touched and no make target changes behaviour.Release note
Summary by CodeRabbit