Skip to content

fix(tenant): bound the pre-delete cleanup waits and the hook Job - #3598

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

fix(tenant): bound the pre-delete cleanup waits and the hook Job#3598
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
fix/tenant-pre-delete-hook-bound

Conversation

@lexfrei

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

Copy link
Copy Markdown
Contributor

What this PR does

The tenant pre-delete hook deletes the tenant's HelmReleases in two waves with kubectl delete --wait=true and no --timeout. kubectl reads --timeout=0 as "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 no hook-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-timeout of 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-root is the exception, a static release in cozystack-basics carrying timeout: 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.startTime while 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: 10 is 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: 0 and restartPolicy: Never go together, and neither implies the other. At the default limit of 6 the Job does not reach Failed inside the budget, so helm falls back on its own timeout with the same nameless "timed out waiting for the condition" as before. Under OnFailure the pod survives its own failure and the kubelet re-runs the container in place, and the Job controller finds out afterwards by summing restartCount. The script starts over, re-enters the applications wave that just expired, and gets cut down mid-run when the controller catches up. Never puts a failed pod in front of the controller directly.

What this PR does not do

No remedy behind the bounded waits. The packages/apps/kubernetes hook force-clears child finalizers when its own wait expires. That needs patch on 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.yaml is 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.32 comes 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 the activeDeadlineSeconds path, 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-interval defaults to 5m, 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: 300 then reaps while flux's uninstall backoff is still climbing. And on the activeDeadlineSeconds path 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.bats renders the chart, pulls the hook script out of the Job and runs it against a stub kubectl on PATH. 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. --timeout is 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 null satisfies 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, dropping activeDeadlineSeconds, dropping terminationGracePeriodSeconds, dropping the requests, backoffLimit back to 6, restartPolicy back to OnFailure, and a || echo appended 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-tests is red at the merge base, unrelated to this branch, and the loop it runs exits on the first failing file. hack/cozyreport.bats sorts 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/*.bats file. 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 moved or renamed and no make target changes behaviour, since bats-unit-tests already globs hack/*.bats.

Release note

fix(tenant): bound the pre-delete cleanup hook's HelmRelease deletions and give its Job a wall-clock deadline, so a tenant whose children cannot finalize no longer leaves the hook waiting indefinitely and blocking the uninstall

Summary by CodeRabbit

  • Reliability Improvements

    • Tenant cleanup jobs now enforce execution deadlines and terminate gracefully.
    • Cleanup operations have bounded deletion timeouts and no automatic retries.
    • Failed cleanup steps now stop subsequent operations and correctly report failure.
    • Cleanup jobs now specify CPU and memory resource requirements.
  • Tests

    • Added comprehensive coverage for cleanup behavior, timeouts, failure handling, and job lifecycle settings.

@github-actions github-actions Bot added area/tenant Issues or PRs related to the tenant chart and multi-tenancy kind/bug Categorizes issue or PR as related to a bug size/XL This PR changes 500-999 lines, ignoring generated files labels Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

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

Changes

Tenant cleanup Job

Layer / File(s) Summary
Bounded cleanup Job configuration
packages/apps/tenant/templates/cleanup-job.yaml
The Job uses a 270-second deadline, no retries, Never restart policy, a 10-second termination grace period, resource requests and limits, and 90-second HelmRelease deletion timeouts.
Rendered Job lifecycle assertions
packages/apps/tenant/tests/cleanup_role_watch_test.yaml
Helm-unittest cases verify the configured deadline, resources, termination period, retry limit, and restart policy.
Rendered hook execution tests
hack/tenant-pre-delete-hook.bats
Bats tests render and execute the hook with a kubectl stub. They verify successful cleanup, bounded deletes, timing constraints, and failure propagation between cleanup waves.

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

Possibly related PRs

Suggested labels: area/testing

Suggested reviewers: myasnikovdaniil, ivanhunters

🚥 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 summarizes the main changes: bounded pre-delete cleanup waits and improved hook Job limits and lifecycle settings.
✨ 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/tenant-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.

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>
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/tenant-pre-delete-hook-bound branch from adb7662 to cf85716 Compare August 7, 2026 11:38

@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, 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 expiry set -e ends 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=true calls in cleanup-job.yaml now carry --timeout=90s; there is no remaining unbounded wait anywhere in packages/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-47 has exactly requests 10m/64Mi, limits 200m/128Mi.
  • helm template --show-only templates/cleanup-job.yaml renders cleanly. The chart-lint render_error is a pre-existing full-chart artifact (keycloakgroups.yaml indexing _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.yaml pre-delete hook has the same unbounded-wait class and is intentionally out of scope here (follow-up PR #3604).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/tenant Issues or PRs related to the tenant chart and multi-tenancy 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