fix(tenant): bound the pre-delete cleanup waits and the hook Job - #3598
Conversation
📝 WalkthroughWalkthroughThe tenant cleanup Job now has bounded execution, resource settings, and timed HelmRelease deletion commands. Helm-unittest and Bats coverage validates rendered lifecycle fields, cleanup ordering, timeout handling, and failure propagation. ChangesTenant cleanup Job
Estimated code review effort: 3 (Moderate) | ~25 minutes 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 |
kubectl reads --timeout=0 as "wait forever" and substitutes a week, so the two `kubectl delete helmreleases --wait=true` calls in the tenant pre-delete hook hang for as long as any child HelmRelease cannot finalize. helm returns from Uninstall.Run before deleting anything, so the release stays in storage and flux retries the same teardown; helm.sh/hook-delete-policy carries no hook-failed, so the waiting Job survives between attempts as well. Bound each wave at 90s. Neither is guarded: nothing stands behind these deletes, so a wave that did not finish ends the run rather than report a cleanup that did not happen, and ending it keeps the tenant modules from being torn out from under applications that are still deleting. Whatever was deleted stays deleted, so the next attempt resumes from there. The bound has a price and it is not zero. A nested Tenant, or an app of kind Kubernetes whose own hook spends two 120s waits first, misses 90s and converges across uninstall retries instead; neither fits the 300s budget as it stands either. Between those and the fast case sits a band that does pay: a wave inherits its slowest child, so one that settles around 95s completes inside the budget today and expires here instead, trading a clean attempt for one more retry. An unbounded wave does not fail, it spends the whole budget and reports a timeout naming nothing, so the trade is worth taking. Give the Job an activeDeadlineSeconds of 270. It bounds what the waits do not — the DELETE calls carry kubectl's default --request-timeout of 0 — and sits under the 300s uninstall budget flux applies when the HelmRelease carries no timeout of its own, because the deadline runs from the Job's startTime while that budget runs from just before the Job is created. At equal values helm gives up first, on a Job still running. Cap the pod's termination at 10s, which is part of that arithmetic rather than a detail of it. Since 1.31 the Job controller withholds the terminal condition until every pod has finished terminating, so helm learns of the failure at the deadline plus this period; the 30s default would land it at 270+30, level with the budget the deadline is set under. The period is spent in full: the SIGTERM goes to PID 1, which is a shell running a multi-command script, and a default disposition sent to PID 1 is discarded by the kernel with no handler installed to catch it. kubectl is a child and is never signalled. Set backoffLimit to 0 and restartPolicy to Never. The limit is needed under either policy, since the default of 6 keeps the failure from reaching helm inside the budget. The policy matters because under OnFailure the pod survives its own failure and the kubelet re-runs the container in place, with the controller applying the cap only afterwards by summing restartCount: the script re-enters the wave that just expired and is cut down mid-run. Never leaves a failed pod the controller sees directly. Add resource requests to match the sibling hook in packages/apps/kubernetes. Without them the pod is BestEffort and first out under node pressure, which a retry inside the Job used to absorb and a single attempt does not. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
adb7662 to
cf85716
Compare
IvanHunters
left a comment
There was a problem hiding this comment.
Verdict
LGTM
Correct, root-cause-targeted fix with strong test coverage. Two non-blocking notes below.
What this does
The tenant pre-delete cleanup hook deleted child HelmReleases with kubectl delete --wait=true and no --timeout. kubectl reads the default --timeout=0 as "wait forever" (a week), so any child that cannot finalize hangs the hook; helm returns from Uninstall.Run without removing the release, flux keeps the finalizer, and the teardown retries forever with no indication of what is stuck. This PR bounds it:
- both delete waves now carry
--timeout=90s(fail-closed: on expiryset -eends the run, no false "completed successfully"); - the Job gets
activeDeadlineSeconds: 270,backoffLimit: 0,restartPolicy: Never,terminationGracePeriodSeconds: 10, and CPU/memory requests; - the ordering (deadline + grace = 280s < helm's 300s uninstall budget) is chosen so helm learns of the failure before it gives up, and it is pinned by both a helm-unittest case and a new bats suite.
Verification
- Both
kubectl delete ... --wait=truecalls incleanup-job.yamlnow carry--timeout=90s; there is no remaining unbounded wait anywhere inpackages/apps/tenant/templates/. Completeness within this hook is full. - The "values match the sibling hook in packages/apps/kubernetes" claim is true:
packages/apps/kubernetes/templates/delete.yaml:42-47has exactly requests 10m/64Mi, limits 200m/128Mi. helm template --show-only templates/cleanup-job.yamlrenders cleanly. The chart-lintrender_erroris a pre-existing full-chart artifact (keycloakgroups.yamlindexing_cluster.oidc-enabled), not introduced here.- Fail-closed and wave-ordering behavior is covered by both helm-unittest (Job shape) and the new bats suite (script behavior against a stub kubectl).
- The budget math (270 + 10 grace < 300) and the PID-1/SIGTERM reasoning are sound; on the deadline path the Job's terminal condition (k8s ≥1.31) is withheld until pod termination, so deadline + grace is what helm actually waits on.
Findings
[MINOR] packages/apps/tenant/templates/cleanup-job.yaml — three large blocks of narrative # comments (~36, ~17, ~35 lines) are baked into the template. # YAML comments are not stripped at render (unlike {{/* */}}), so this multi-paragraph essay is copied verbatim into every rendered manifest in every customer cluster and will silently drift out of date as the code changes. The reasoning is excellent and worth keeping — but it belongs on the cozystack docs site (versioned, searchable). Suggest keeping a one-line pointer + link in the template, or at minimum converting the essays to {{/* ... */}} template comments so they do not survive into rendered output. Non-blocking.
[NIT] packages/apps/tenant/tests/cleanup_role_watch_test.yaml — the "cleanup Job pod is not BestEffort" case asserts documentIndex: 3 without an isKind: {of: Job} guard, unlike the three sibling cases you added. This is the exact index-addressing fragility that PRs #3605/#3606 harden elsewhere; adding the guard here keeps the suite honest if document order ever shifts. Non-blocking.
Caveats
- Cold image pull competes with the two 90s waves inside the 270s deadline; this is acknowledged in the template comments and the deadline is a backstop by design, not a regression.
- The kubernetes chart's own
delete.yamlpre-delete hook has the same unbounded-wait class and is intentionally out of scope here (follow-up PR #3604).
What this PR does
The tenant pre-delete hook deletes the tenant's HelmReleases in two waves with
kubectl delete --wait=trueand no--timeout. kubectl reads--timeout=0as "wait forever" and substitutes a week, so a wave hangs for as long as any child HelmRelease cannot finalize. helm then returns from the uninstall without deleting anything: the release stays in storage, flux keeps the finalizer, and the same teardown gets retried. The delete policy carries nohook-failed, so the waiting Job survives between attempts too.Each wave now gets 90s. Neither is guarded, on purpose. Nothing stands behind these deletes, so a wave that did not finish ends the run instead of reporting a cleanup that never happened. That also keeps the order the two waves exist for, since the tenant modules are what the applications run on. Whatever got deleted stays deleted, so the next attempt resumes from there.
The Job gets
activeDeadlineSeconds: 270. It bounds what the waits do not, since the DELETE calls carry kubectl's default--request-timeoutof 0, and it sits under the 300s uninstall budget flux applies when the HelmRelease has no timeout of its own. That is the budget for tenants cozystack-api generates;tenant-rootis the exception, a static release incozystack-basicscarryingtimeout: 15m0s, and the ordering holds there too since 280 is under 900. The 30s gap is not cosmetic. The deadline runs from the Job's.status.startTimewhile helm's budget runs from before the Job is created, so at equal values helm gives up first, on a Job that is still running. That is the state this change is meant to end, and it would look fixed, because a number would be there.terminationGracePeriodSeconds: 10is part of the same arithmetic. Since 1.31 the Job controller holds back the terminal condition until every pod has finished terminating, so what helm waits for is the deadline plus the termination. At the 30s default that lands on 270+30, level with the budget, which is the gap the deadline was set to buy. That period gets spent in full, every time. The SIGTERM goes to PID 1, which is the shell: the script runs several commands, so the shell never execs into kubectl and stays PID 1 itself. A signal carrying its default disposition is discarded by the kernel when the target is PID 1, and a non-interactive shell installs no SIGTERM handler, so nothing acts on it. kubectl is a child and was not signalled at all. The pod ends on SIGKILL. Nothing in that script needs to finish gracefully, the DELETE calls are already sent and the process is only watching.The pod also gets resource requests, matching the sibling hook in
packages/apps/kubernetes. Without them it is BestEffort and first to go under node pressure, which mattered less when a retry inside the Job absorbed an eviction. With a single attempt it does not.backoffLimit: 0andrestartPolicy: Nevergo together, and neither implies the other. At the default limit of 6 the Job does not reachFailedinside the budget, so helm falls back on its own timeout with the same nameless "timed out waiting for the condition" as before. UnderOnFailurethe pod survives its own failure and the kubelet re-runs the container in place, and the Job controller finds out afterwards by summingrestartCount. The script starts over, re-enters the applications wave that just expired, and gets cut down mid-run when the controller catches up.Neverputs a failed pod in front of the controller directly.What this PR does not do
No remedy behind the bounded waits. The
packages/apps/kuberneteshook force-clears child finalizers when its own wait expires. That needspatchon HelmReleases in the Role, and it is only safe there because the whole tenant control plane is going away in the same run. Here a wave that expires ends the attempt.packages/apps/kubernetes/templates/delete.yamlis untouched.It does not make a doomed teardown converge. A tenant whose children cannot finalize still gets its uninstall retried. What changes is that each attempt ends in bounded time, instead of eating the whole uninstall budget and leaving a pod behind.
90s is a guess, and it is tight. The first wave deletes every application HelmRelease in the namespace, including kinds that are slow to tear down: a nested Tenant runs this same hook one level down before its release finalizes, and an app of kind Kubernetes tears a control plane down. Either one can outrun 90s. The bound is what is left once the Job deadline sits under helm's budget instead of level with it, split between the two waves.
The 90s the deadline keeps back for scheduling and the image pull is not calibrated either, and I would rather say so than imply it was.
docker.io/clastix/kubectl:v1.32comes from Docker Hub with no mirror in front of it, and this repo has measured Docker Hub pulls of a smaller image at about 1m42s in CI. So that remainder is roughly the observed worst case rather than comfortably above it. A cold pull that slow lands on theactiveDeadlineSecondspath, which is the one that deletes the pod and leaves nothing to read.There is a band that pays for this, and I would rather name it than round it off. Neither shape above fits inside 300s today, so for them the bound costs nothing that works. But every generated child HelmRelease carries a wait strategy, so a wave inherits its slowest child, and a wave that settles in something like 95s does complete inside the budget today. After this change it expires instead, and that tenant trades one clean attempt for an extra flux retry. In wall clock that retry is about five minutes, since
helmrelease-intervaldefaults to5m, so a tenant in that band takes noticeably longer to delete than it does now. I think that is the right side of the trade: an unbounded wave does not fail, it spends the whole budget and leaves helm reporting a timeout that names nothing. If the band turns out to be populated, the waits and the deadline move together, and the bats suite pins their ordering rather than their values.One caveat on the diagnostics, since the comments in the chart used to promise more than they deliver. The failing wave leaves its output in the failed pod, which
ttlSecondsAfterFinished: 300then reaps while flux's uninstall backoff is still climbing. And on theactiveDeadlineSecondspath there is no log at all, because the Job controller deletes the active pods when the deadline expires. That path is the backstop for a kubectl that never returns; the expected path is the wave's own--timeout, where the script exits on its own and the pod survives to be read.Tests
hack/tenant-pre-delete-hook.batsrenders the chart, pulls the hook script out of the Job and runs it against a stubkubectlonPATH. It covers a clean run reporting success, every delete reaching kubectl with a non-zero bound, the ordering of the four numbers, and a wave that gave up ending the run without going on to the next one.--timeoutis implemented inside kubectl, so no stub can make that deadline actually elapse. The tests read the arguments that reached kubectl and inject the non-zero exit an expired delete produces. No EXIT traps.The ordering test reads the numbers out of the rendered Job instead of pinning them, so moving a value keeps it honest while a value that breaks the ordering still fails. An absent deadline or termination period is rejected explicitly, otherwise
nullsatisfies the numeric comparison and a Job with no bound at all passes.The Job's own shape is pinned in the chart's helm-unittest suite, since the script cannot observe that about itself.
Both suites are green locally, and I mutated each pinned value to confirm they fail for the right reason: dropping either
--timeout, droppingactiveDeadlineSeconds, droppingterminationGracePeriodSeconds, dropping the requests,backoffLimitback to 6,restartPolicyback toOnFailure, and a|| echoappended to the applications wave. Each one goes red in the test that claims that ground.Worth flagging so the check marks are not read as more than they are:
make bats-unit-testsis red at the merge base, unrelated to this branch, and the loop it runs exits on the first failing file.hack/cozyreport.batssorts ahead of this suite, so on CI the new bats file is not reached at all right now. It is not failing, it is not running. The helm-unittest cases are unaffected and do execute.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 tenant chart, its helm-unittest suite, and a new
hack/*.batsfile. No package is added, renamed or removed; novalues.schema.json,values.yamldefault or version enum changes; noApplicationDefinitionsemantics, namespace, variant or release asset changes; nothing underhack/is moved or renamed and no make target changes behaviour, sincebats-unit-testsalready globshack/*.bats.Release note
Summary by CodeRabbit
Reliability Improvements
Tests