Repository navigation
fix(apps): make the post-delete cleanup hooks report failure - #3593
Conversation
|
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 (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughHarbor and Qdrant cleanup Jobs now retry once, aggregate listing and deletion failures, continue later cleanup operations, and exit non-zero on failure. Helm, Bats, and Chainsaw tests cover success, idempotency, failure reporting, and Job retention. ChangesCleanup hook behavior
Estimated code review effort: 4 (Complex) | ~45 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 |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
hack/app-cleanup-hooks_test.bats (1)
96-111: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueThe
EXISTINGcheck inspects only the last positional argument.The qdrant hook deletes all PVCs in one
xargs kubectl deletecall. The loop at Lines 69-76 overwritesobjon each positional, so only the final name is matched againstEXISTING. A fixture where an earlier name is absent and the last name is present is modelled as fully existing. The current tests do not hit this case, but a later test that mixes present and absent names would silently pass.Consider collecting all positionals and checking each one.
♻️ Proposed change to check every positional
-obj="" +obj="" +objs="" skip=0 for a in "$@"; do if [ "$skip" = 1 ]; then skip=0; continue; fi case "$a" in -n|-l) skip=1 ;; -*) ;; - *) obj=$a ;; + *) obj=$a; objs="$objs $a" ;; esac doneThen iterate
$objsin the NotFound branch instead of testing$objalone.🤖 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 `@hack/app-cleanup-hooks_test.bats` around lines 96 - 111, Update the kubectl mock’s positional-argument handling so it collects all object names rather than overwriting obj in the loop. In the EXISTING check within the NotFound branch, iterate over the collected objs and return success only when every requested object is listed as existing; preserve the current behavior for ignore-not-found and failure cases.
🤖 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/harbor/templates/hooks/cleanup.yaml`:
- Around line 141-166: Update the cleanup hook’s Challenge and solver Ingress
sweeps to first verify that the cert-manager API is available using kubectl get
--raw /apis/acme.cert-manager.io/v1. When the API is absent or unavailable, skip
both list/delete loops without calling fail; otherwise preserve the existing
listing, deletion, and failure handling in the challenge and solver cleanup
blocks.
---
Nitpick comments:
In `@hack/app-cleanup-hooks_test.bats`:
- Around line 96-111: Update the kubectl mock’s positional-argument handling so
it collects all object names rather than overwriting obj in the loop. In the
EXISTING check within the NotFound branch, iterate over the collected objs and
return success only when every requested object is listed as existing; preserve
the current behavior for ignore-not-found and failure cases.
🪄 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: d40d8ccd-c4e0-4772-9da7-339e5985eddc
📒 Files selected for processing (7)
hack/app-cleanup-hooks_test.batshack/e2e-chainsaw/harbor/chainsaw-test.yamlhack/e2e-chainsaw/qdrant/chainsaw-test.yamlpackages/apps/harbor/templates/hooks/cleanup.yamlpackages/apps/harbor/tests/cleanup_hook_test.yamlpackages/apps/qdrant/templates/hooks/cleanup-pvc.yamlpackages/apps/qdrant/tests/cleanup_pvc_test.yaml
| # Each listing is captured before it is iterated. Iterating | ||
| # $(kubectl get ...) directly cannot distinguish a failed listing | ||
| # from an empty one: both leave the loop with nothing to do, so a | ||
| # sweep that never got to look reads exactly like a sweep that | ||
| # found nothing to sweep. kubectl's own diagnostic is left on | ||
| # stderr for the same reason — it is the only account of why the | ||
| # listing failed. | ||
| if challenges=$(kubectl get challenge -n {{ .Release.Namespace }} \ | ||
| -o jsonpath="{range .items[?(@.spec.dnsName=='$HOST')]}{.metadata.name} {end}"); then | ||
| for ch in $challenges; do | ||
| kubectl delete challenge -n {{ .Release.Namespace }} "$ch" --ignore-not-found \ | ||
| || fail "failed to delete Challenge {{ .Release.Namespace }}/$ch for host $HOST; orphaned solver may remain" | ||
| done | ||
| else | ||
| fail "failed to list Challenges in {{ .Release.Namespace }} for host $HOST; orphaned ACME solvers may remain" | ||
| fi | ||
| if solvers=$(kubectl get ingress -n {{ .Release.Namespace }} \ | ||
| -l acme.cert-manager.io/http01-solver=true \ | ||
| -o jsonpath="{range .items[?(@.spec.rules[0].host=='$HOST')]}{.metadata.name} {end}" 2>/dev/null); do | ||
| kubectl delete ingress -n {{ .Release.Namespace }} "$ing" --ignore-not-found \ | ||
| || echo "WARNING: failed to delete solver ingress $ing for {{ .Release.Name }}; orphaned solver may remain" >&2 | ||
| done | ||
| -o jsonpath="{range .items[?(@.spec.rules[0].host=='$HOST')]}{.metadata.name} {end}"); then | ||
| for ing in $solvers; do | ||
| kubectl delete ingress -n {{ .Release.Namespace }} "$ing" --ignore-not-found \ | ||
| || fail "failed to delete solver Ingress {{ .Release.Namespace }}/$ing for host $HOST; orphaned solver may remain" | ||
| done | ||
| else | ||
| fail "failed to list solver Ingresses in {{ .Release.Namespace }} for host $HOST; orphaned ACME solvers may remain" | ||
| fi |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Determine whether cert-manager (and its ACME CRDs) is always installed.
set -u
echo "== cert-manager package presence =="
fd -t d 'cert-manager' packages | head -50
echo "== conditional installation of cert-manager =="
rg -n -C4 'cert-manager' --glob '!**/charts/**' packages/core packages/system 2>/dev/null | head -80
echo "== harbor chart references to challenge/certificate kinds =="
fd -t f -e yaml . packages/apps/harbor --exec rg -n 'kind: Certificate|challenge|acme.cert-manager.io' {} \;
echo "== solver modes handled by the harbor chart =="
rg -n -C3 '_cluster.solver|solver' packages/apps/harbor/templatesRepository: cozystack/cozystack
Length of output: 18141
🏁 Script executed:
#!/bin/bash
set -u
echo "== files mentioning cert-manager crdr dependency/installation =="
rg -n -C3 'packages/system/cert-manager|cert-manager-crds|cert-manager- |dependencies:|Chart.lock|helm install|flux|kustomize|appVersion|chart.yaml' --glob '!**/charts/**' packages core 2>/dev/null | head -200
echo "== harbor Chart.yaml and values dependency/requirements =="
fd -t f -e yaml 'Chart.yaml|values.*yaml|values.yaml' packages/apps/harbor -x sh -c 'echo "--- $1"; sed -n "1,220p" "$1"' sh {}
echo "== cleanup hook relevant section =="
sed -n '100,175p' packages/apps/harbor/templates/hooks/cleanup.yaml
echo "== harbor templates that install/depend on cert-manager or CRDs =="
rg -n -C3 'cert-manager|Certificate|issuer|acme.cert-manager.io|dependency|dependsOn|reconcileTimeout|installCRDs' packages/apps/harbor/templates packages/apps/harbor 2>/dev/null | head -200
echo "== cluster solver value references =="
rg -n -C4 '"solver"|solver:' --glob '!**/charts/**' packages core templates 2>/dev/null | head -200Repository: cozystack/cozystack
Length of output: 50375
🌐 Web query:
kubectl get unknown kind error exit code CRD not installed
💡 Result:
When kubectl returns an error stating "the server doesn't have a resource type" or "no matches for kind," it indicates that the Kubernetes API server does not recognize the resource you are attempting to access or create [1][2][3]. This is commonly caused by one of the following scenarios: 1. CRD Not Installed: If you are working with a custom resource (e.g., Certificate, Rollout, Prometheus), the Custom Resource Definition (CRD) must be installed in the cluster before the API server can recognize that kind [1][2][4]. 2. Stale Discovery Cache: kubectl caches API resource information locally. If a CRD was recently deleted or modified, your local cache might be out of sync, causing this error even if the resource is no longer present [5]. 3. Configuration Issues: The error can occur if your kubeconfig is pointing to the wrong cluster or namespace, or if you are using an incorrect authentication context, which may cause the API server to fail to authorize or even discover the resource [6][7][8]. 4. Typos or API Version Changes: A simple typo in the resource name (e.g., "nodez" instead of "nodes") or using an apiVersion that has been removed or is unsupported in your cluster's Kubernetes version will trigger this error [1][3][7]. Recommended Troubleshooting Steps: - Verify the resource: Run kubectl api-resources to list all resource types currently recognized by the API server [1][2][3]. - Check CRD status: If you expect a custom resource to exist, run kubectl get crds to confirm it is installed and kubectl describe crd <crd-name> to ensure its status is "Established" [4]. - Clear local cache: If you suspect stale cache, delete your local kubectl cache directory (typically located at ~/.kube/cache) [8][5]. - Check context: Ensure you are connected to the correct cluster and namespace using kubectl config current-context [6][7]. - Inspect with verbosity: Use the --v=9 flag with your command to see detailed API request/response logs, which can reveal underlying authentication (401) or permission (403) issues that are masked by the generic resource error [8].
Citations:
- 1: https://latchkey.dev/learn/kubernetes/kubectl-unable-to-recognize
- 2: https://latchkey.dev/learn/kubernetes/kubectl-no-resource-type
- 3: https://devopsaitoolkit.com/blog/kubernetes-error-no-matches-for-kind/
- 4: https://adhdecode.com/debugging/kubernetes/unable-to-recognize-no-matches-for-kind/
- 5:
kubectl getreturns different error for removed custom type with stale discovery cache kubernetes/kubernetes#113980 - 6: https://bobcares.com/blog/kubectl-error-the-server-doesnt-have-a-resource-type/
- 7: https://www.baeldung.com/ops/kubectl-error-server-resource-type-node
- 8: https://stackoverflow.com/questions/55183961/kubernetes-the-server-doesnt-have-a-resource-type-deployments
🌐 Web query:
Kubernetes CLI unknown kind resource type error non-zero exit code kubectl get
💡 Result:
The Kubernetes CLI error regarding an unknown kind or resource type typically indicates that the Kubernetes API server does not recognize the resource you are requesting [1][2][3]. Because the API server cannot resolve the resource, kubectl returns a non-zero exit code [1][2]. Common causes and solutions include: 1. CRD Not Installed: If you are attempting to access a Custom Resource (e.g., Certificates, Prometheuses), the Custom Resource Definition (CRD) must be installed in the cluster before you can interact with the resource [1][2][4]. Use kubectl get crd to check if your custom resource is registered [1][2]. If missing, install the required operator or CRD manifests [1][5][4]. 2. Incorrect or Deprecated API Version: You may be using an API version that has been removed or is not supported by your cluster's Kubernetes version [1][6][5]. For example, certain resources moved from extensions/v1beta1 to apps/v1 in older versions [7][6]. Use kubectl api-resources to identify the correct API group and version currently supported by your cluster [1][2][5]. 3. Misspelled Resource or Wrong Context: You might have a typo in the resource name (e.g., using po instead of pods) or you may be pointed at the wrong Kubernetes cluster/context [2][3]. Verify your current context with kubectl config current-context and check the available resources using kubectl api-resources [2][5][3]. 4. Improper Flag Usage: Sometimes an unknown kind error is a side effect of malformed command arguments, such as passing a flag incorrectly, which causes kubectl to misinterpret the command structure [8]. Always ensure flags are formatted correctly (e.g., using double dashes -- for long-form flags) [8]. To troubleshoot effectively: - Run kubectl api-resources to confirm that the server actually supports the resource type you are targeting [1][2][4]. - Check kubectl config current-context to ensure you are operating against the intended cluster [5][3]. - If applying a manifest, ensure that CRDs are applied and fully established (via kubectl wait --for=condition=Established crd/...) before applying instances of the custom resource [5][4].
Citations:
- 1: https://devopsaitoolkit.com/blog/kubernetes-error-no-matches-for-kind/
- 2: https://latchkey.dev/learn/kubernetes/kubectl-no-resource-type
- 3: https://bobcares.com/blog/kubectl-error-the-server-doesnt-have-a-resource-type/
- 4: https://latchkey.dev/learn/kubernetes/kubernetes-unable-to-recognize-no-matches-for-kind-in-ci
- 5: https://latchkey.dev/learn/kubernetes/kubectl-unable-to-recognize
- 6: https://stackoverflow.com/questions/62188519/why-the-kubernetes-give-tips-unknown-resource-kind-daemonset-when-deploy
- 7: kubectl 1.5.1 vs k8s 1.6 gives
error: no kind "Deployment" is registered for version "apps/v1beta1"kubernetes/kubernetes#42392 - 8: kubectl get nodes -show-labels json parse error kubernetes/kubernetes#94030
🌐 Web query:
kubectl get no matches for kind exit code 1 CRD installed but not used
💡 Result:
The error "no matches for kind" when attempting to use a Custom Resource (CR) in Kubernetes indicates that the Kubernetes API server does not recognize the requested resource type [1][2]. Even if a Custom Resource Definition (CRD) exists, this error typically occurs because the CRD is not yet installed, not yet established, or the manifest uses an API version that the cluster does not support [1][3][4]. To resolve this issue, verify and address the following: 1. Confirm CRD Installation and Status: Check if the CRD is installed and in an Established state. Kubernetes must fully process the CRD before it can accept resources of that kind [1][4]. Run: kubectl get crd [4][5] Run: kubectl describe crd to ensure it is in the Established condition [4]. 2. Address Race Conditions (Ordering): If you are applying both the CRD and the CR in the same kubectl command or pipeline, the CR may be processed before the CRD is ready [1][2]. Solution: Always apply the CRD first, wait for it to be established, and then apply the CR [1][6]. Example command: kubectl apply -f crds/ kubectl wait --for=condition=Established crd/ --timeout=60s kubectl apply -f my-resource.yaml [1] 3. Validate API Version Compatibility: The error may trigger if the manifest uses an apiVersion that the cluster does not support (e.g., an outdated version removed in a cluster upgrade) [3][6][7]. Verify supported versions for your resource: Run: kubectl api-resources | grep [1][6] Ensure your manifest's apiVersion matches one of the versions returned by the command above [3]. 4. Check for Typographical Errors: Double-check that the kind and apiVersion in your YAML file are spelled correctly and match the definition expected by the cluster [2][8]. If the CRD is missing entirely, you must install the associated operator or CRD manifest before the API server can recognize the kind [2][5].
Citations:
- 1: https://latchkey.dev/learn/kubernetes/kubernetes-unable-to-recognize-no-matches-for-kind-in-ci
- 2: https://devopsaitoolkit.com/blog/kubernetes-error-no-matches-for-kind/
- 3: https://latchkey.dev/learn/kubernetes/k8s-deploy-no-matches-for-kind-apiversion
- 4: https://adhdecode.com/debugging/kubernetes/unable-to-recognize-no-matches-for-kind/
- 5: https://deadends.dev/kubernetes/unable-to-recognize/
- 6: https://latchkey.dev/learn/kubernetes/kubectl-server-doesnt-have-resource-type-crd
- 7: https://deadends.dev/kubernetes/crd-version-not-served/
- 8: https://lucaberton.com/blog/fix-kubernetes-no-matches-for-kind-error/
🌐 Web query:
kubectl delete CRD not installed exit code kubectl delete cert-manager Certificate
💡 Result:
When using kubectl to delete a resource, such as a cert-manager Certificate, the exit code behavior when the resource is not found depends on whether the --ignore-not-found flag is used [1][2]. Default Behavior: If you attempt to delete a resource that does not exist (e.g., via kubectl delete certificate ), kubectl will typically return a non-zero exit code and report an error because the server returns a 404 Not Found status [1][3]. Using --ignore-not-found: To avoid errors in scripts or pipelines when a resource may have already been deleted, use the --ignore-not-found flag [1][4]. When this flag is set to true (or when using --all), kubectl treats a "resource not found" error as a successful operation, resulting in an exit code of 0 [1][2][5]. Cert-Manager Considerations: When deleting a cert-manager Certificate resource, note that by default, the associated Secret is often not automatically deleted unless specific configuration (like the --enable-certificate-owner-ref flag) is set in the cert-manager controller [6][7]. Additionally, deleting a Certificate resource relies on the Kubernetes garbage collector and cert-manager controllers to clean up related resources (like CertificateRequests); race conditions or finalizers can sometimes cause these resources to hang in a deletion state [8][9]. Summary for Scripting: If you want your delete command to be idempotent—meaning it succeeds whether the resource exists or not—use: kubectl delete certificate --ignore-not-found=true
Citations:
- 1: https://kubernetes.io/docs/reference/kubectl/generated/kubectl_delete/
- 2: https://man.archlinux.org/man/kubectl-delete.1.en.txt
- 3: kubectl delete doesn't return error or print resource not found kubernetes/kubernetes#8218
- 4: https://github.com/kubernetes/kubernetes/pull/41433/files
- 5: https://dokk.org/manpages/debian/12/kubernetes-client/kubectl-delete.1.en
- 6: https://cert-manager.io/docs/usage/certificate/
- 7: Deleting a certificate resource does not delete its secret cert-manager/cert-manager#296
- 8: Certificates are stuck in deletion because of a race condition between Kubernetes GC and cert-manager cert-manager/cert-manager#7358
- 9: fix: don't create certificaterequests while being deleted cert-manager/cert-manager#7361
🏁 Script executed:
#!/bin/bash
set -u
echo "== system packages referencing harbor/cert-manager/solver =="
rg -n -C3 'harbor|packages/apps/harbor|Solver|http01|dns01|cert-manager' --glob '!**/charts/**' packages/system packages/core 2>/dev/null | head -300
echo "== docs mentioning harbor cert-manager dependency/solver =="
rg -n -i -C3 'harbor|cert-manager|http01|dns01|solver' --glob '!**/charts/**' . 2>/dev/null | head -300
echo "== Chart.lock dependencies for harbor =="
fd -t f 'Chart.lock|Chart.yaml' packages/apps/harbor -x sh -c 'echo "--- $1"; sed -n "1,160p" "$1"' sh {}Repository: cozystack/cozystack
Length of output: 50375
Treat unregistered ACME resources as skipped cleanup
kubectl get challenge exits non-zero when the acme.cert-manager.io CRD/API is not present, so the uninstall hook fails at line 155 and leaves the Helm uninstall waiting on the Job pods. Gate the Challenge/Ingress list with a pre-check such as kubectl get --raw /apis/acme.cert-manager.io/v1, and skip those sweeps when the API is missing.
🤖 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/harbor/templates/hooks/cleanup.yaml` around lines 141 - 166,
Update the cleanup hook’s Challenge and solver Ingress sweeps to first verify
that the cert-manager API is available using kubectl get --raw
/apis/acme.cert-manager.io/v1. When the API is absent or unavailable, skip both
list/delete loops without calling fail; otherwise preserve the existing listing,
deletion, and failure handling in the challenge and solver cleanup blocks.
|
Reopening to pick up the multus fix on main. |
Andrei Kvapil (kvaps)
left a comment
There was a problem hiding this comment.
The substance is right and the diagnosis holds. Not blocking. Two things I'd ask for before merge, two more worth deciding explicitly rather than leaving implicit.
Verified independently
Deletion scope is unchanged. Every selector is byte-identical to main — qdrant's ordinal-anchored ^persistentvolumeclaim/qdrant-storage-<release>-[0-9]+$, harbor's -l release=<release>-system, the <release>-ingress-tls by-name delete, and both host-scoped jsonpath filters. Only indentation moved. Nothing new is in range.
The uninstall does not strand the release, but the Flux layer needs saying. Checked against the sources rather than the description:
helm/pkg/action/uninstall.go:142appends the post-delete hook error toerrsinstead of returning it,:147setsStatusUninstalled,:154-156runspurgeReleasesregardless,:163returnsuninstallation completed with N error(s). The release does leave storage, as described.- Under helm-controller v1.4.3 the first reconcile still fails:
internal/reconcile/uninstall.goreachesr.failure(...)and returns the error,reconcileReleaseDeletiontolerates onlyErrNoLatest, soreconcileDeletenever reachesRemoveFinalizer. The next reconcile is what clears it — Helm's storage no longer holds the release, soReleases.Historyreturnsdriver.ErrReleaseNotFound, helm-controller marksUninstallSucceededand drops the finalizer. Same outcome via the"is already deleted"branch whenspec.uninstall.keepHistoryis set.
So kubectl delete harbor/qdrant <name> completes after one reconcile backoff, with UninstallFailed visible on the HelmRelease in between. Worth adding to the description: "the release itself is not stranded" is true of Helm, and the layer that actually drives these releases is the part a reader will worry about.
The tests earn their place. I reverted three things independently: harbor's Certificate fail back to || echo "WARNING…" (bats red, named the object), qdrant's --ignore-not-found on the xargs delete (bats red on the already-gone case), and hook-failed added to both delete-policy annotations (both helm-unittest suites red). The mutation claims hold.
Runs: qdrant 60/60, harbor 17/17, full make helm-unit-tests green, the new bats suite 11/11, and image-pin-consistency / downstream-trigger-map / pod-label-census_test / md-no-hardwrap all pass. Both charts render in http01, dns01 and custom-host modes.
1. The new error path is defeated by the failure most likely to reach it
Neither hook bounds a single call. kubectl get carries no --request-timeout, and kubectl delete waits for the object to be finalized with no timeout — harbor/templates/hooks/cleanup.yaml:106,124,148,157 and qdrant/templates/hooks/cleanup-pvc.yaml:101,107. A throttled or unreachable apiserver, or a PVC still held by kubernetes.io/pvc-protection because a -system pod is still terminating, produces no ERROR: line at all: the hook hangs until Helm's hook timeout and the operator gets Helm's deadline instead. That is precisely the outcome the backoffLimit comment argues against, arriving by a shorter route than seven pods of backoff.
packages/apps/bucket/templates/hooks/cleanup-acme.yaml:13-16 already solved this in what is otherwise the same ACME sweep, and says why:
# Bound the whole cleanup: never let a stuck finalizer or a hung API call
# leave this post-delete hook running indefinitely and block the uninstall.
activeDeadlineSeconds: 120
backoffLimit: 0
plus --request-timeout=30s on every call and --wait=false on every delete.
--request-timeout=30s on the reads is the part I'd ask for outright: it turns a hung read into this hook's own non-zero exit with its own message, which is what the PR is for, with nothing traded away. --wait=false on the deletes and an activeDeadlineSeconds outer bound are a judgement call worth making explicitly — activeDeadlineSeconds does cost the pod log, since the Job controller terminates the pods when it fires, so it is a weaker instrument here than in bucket. Either way it should be a decision rather than an omission.
2. Three of the five cleanup hooks still have the defect this PR is about
packages/apps/bucket/templates/hooks/cleanup-acme.yaml:66,87,98is the same ACME sweep as harbor's. Every delete is still|| echo "WARNING: …"and the script still ends on an unconditionalecho "ACME cleanup complete.". It has the listing-capture fix already and none of the exit-code fix. It is not mentioned in the description.- mariadb and clickhouse: the description diagnoses their missing retry cap correctly — a real failure spends the full default backoff and reaches the operator as a timeout rather than as the error the hook wrote — and then leaves it in place. One line per chart.
Scoping a PR is legitimate and I am not asking to grow this one indefinitely. But the title says "the post-delete cleanup hooks" and the result is five hooks in three dialects with three still wrong in ways this PR names. Either pull bucket in (same code, ~10 lines) and cap mariadb/clickhouse, or state in the description which are deliberately out of scope and where they are tracked.
3. The leftover accounting is short by three objects
helm/pkg/action/hooks.go:112-121: the hook-succeeded sweep is a second loop that runs only once every hook in the event has succeeded, and deleteHookByPolicy(h, HookFailed, …) on failure applies to the failing hook alone. So when the Job (weight 10) fails, the ServiceAccount (weight 0), Role and RoleBinding (weight 5) have already succeeded and are never collected either. The comments and the release note both say "the Job and its pods".
Worth naming the rest. A ServiceAccount plus a Role carrying delete on persistentvolumeclaims, certificates, challenges and ingresses, left in a tenant namespace until the next uninstall of the same release name, is a different thing to leave behind than a pod holding a log — and unlike the pod it is not evidence. Adding hook-failed to the RBAC objects would not help, for the reason above; this is a documentation fix, and possibly an argument for a narrower Role.
One dead end worth a line in the comment so the next reader does not go looking: helm.sh/hook-output-log-policy: hook-failed looks like the way to get the log out without retaining anything, and it does nothing here — Helm defaults HookOutputFunc to io.Discard (pkg/action/action.go:429) and helm-controller never calls SetHookOutputFunc. Retaining the Job really is the only channel under Flux.
4. backoffLimit: 1 against bucket's backoffLimit: 0
Twelve lines argue that six is wrong and zero is wrong. A chart in this repo already picked zero, for the stated reason that the Job must be bounded. Either reconcile with it in the comment or align the two; as it stands the repo will hold both values with two mutually-unaware essays attached.
5. Duplication, and where this is heading
Roughly 35 lines — the backoffLimit paragraph and the failures/fail() preamble with its rationale — are copy-pasted verbatim into two charts, and the closing block once more in each. Across the five hooks there are now three accumulator shapes (failures here, cleanup_failed in mariadb, residue_from in #3592), two severity prefixes (ERROR: here, WARNING: in #3592) and two helper names (fail / backstop).
Both charts already depend on cozy-lib, and harbor already calls cozy-lib.image from this very file. A cozy-lib.cleanupHook.preamble / .epilogue pair would hold this once, and the rationale with it. It would also get the essay out of the rendered Job spec, where it ships to every cluster on every uninstall and has no reader.
On #3592 specifically: the substance is consistent — same diagnosis method (capture the listing, keep its status, walk the whole script for the shape) and same test method. The exit 0 / exit 1 asymmetry is correct and the sources back both sides of it (uninstall.go:109-111 returns on a pre-delete hook error, :142-143 accumulates on a post-delete one). It is only the reporting surface that has diverged, and since the two land together, aligning at least the prefix and the name-the-step shape seems cheap.
Nit
qdrant's hook image is docker.io/clastix/kubectl:v1.32 — not digest-pinned and not routed through cozy-lib.image — while harbor's is both, in the same pair of files, with harbor's comment explaining why it matters for mirrored and air-gapped installs. Out of scope here, but it means the qdrant hook cannot start at all on such an install, and that fails silently in the sense this PR is about: an ImagePullBackOff reaches the operator as a Helm hook timeout, never as the hook's own message.
733e01d to
6d59150
Compare
IvanHunters
left a comment
There was a problem hiding this comment.
Overview
This fixes the actual defect described: both harbor and qdrant post-delete cleanup hooks previously swallowed every failure (|| echo "WARNING..." >&2, no set -e, unconditional final echo), so the container always exited 0 and hook-delete-policy: hook-succeeded collected the Job/pod that held the only evidence of the failure. I read the full rendered scripts (not just the diff hunks) for both charts at head and confirm the fix holds up:
- No
set -eis introduced (correctly — it would abort the sweep at the first failure). Instead,failures=0/fail() { failures=$((failures+1)); echo "ERROR: $*" >&2; }accumulates, and the exit code is decided once at the end (if [ "$failures" -ne 0 ]; then ... exit 1; fi). Everyfailcall happens in the main shell (never inside a pipe/subshell), so there's no variable-scoping loss. - The listing-capture fix is portable POSIX and actually correct:
if all_pvcs=$(kubectl get pvc ... ); then/if challenges=$(kubectl get challenge ...); then(packages/apps/qdrant/templates/hooks/cleanup-pvc.yaml,packages/apps/harbor/templates/hooks/cleanup.yaml) — the exit status of a bare assignment is that of the command substitution, so this correctly distinguishes "listing failed" from "listing returned nothing," which was exactly the qdrantkubectl get | grep ... || trueand harborfor x in $(kubectl get ...)bug. --ignore-not-foundis retained on every delete, so already-gone objects stay a success (idempotent re-run) — verified in the rendered templates, not just the diff.helm.sh/hook-delete-policyis deliberately left atbefore-hook-creation,hook-succeeded(nohook-failed) on the Job in both charts, so a failed Job's pod survives for postmortem. This is pinned by newhelm-unittestassertions inpackages/apps/harbor/tests/cleanup_hook_test.yamlandpackages/apps/qdrant/tests/cleanup_pvc_test.yaml.hack/app-cleanup-hooks_test.bats(513 lines) extracts the real rendered script and runs it against a fakekubectl, covering success, idempotent re-run (empty listing and NotFound-on-delete shapes), delete-failure (with "sweep continues after a failure" assertions), and listing-failure for both hooks. This is the right strategy since the property under test (exit code) can't be established by matching manifest text.
A maintainer already did an independent, source-verified review (mutation-testing the bats suite, checking Helm/helm-controller behavior) and explicitly marked everything "Not blocking." I re-checked the current head against the diff and confirmed none of those points were incorporated — they're still open, still non-blocking, and not regressions against main.
Non-blocking / notes
- Hook can still hang past Helm's timeout without producing the new error message. Neither
kubectl getnorkubectl deletein either script sets--request-timeout, and no delete uses--wait=false; the Job has noactiveDeadlineSeconds. A stuck finalizer (e.g.kubernetes.io/pvc-protectionwhile a-systempod is still terminating) or a slow apiserver blocks the shell script indefinitely, so the operator sees Helm's own hook-timeout error instead of this PR'sERROR:/Cleanup FAILEDmessage — the exact failure mode most likely to occur in production bypasses the new reporting path entirely.packages/apps/bucket/templates/hooks/cleanup-acme.yamlalready guards the equivalent ACME sweep withactiveDeadlineSeconds: 120,backoffLimit: 0,--request-timeout=30s,--wait=false. - Leftover accounting is incomplete. Verified directly in the rendered head templates: the
ServiceAccount/Role/RoleBindingfor both charts still carry onlybefore-hook-creation,hook-succeeded, unchanged by this PR. On a failed cleanup they're collected as succeeded hooks while the Job/pods are retained — so a Role grantingdeleteon PVCs/Certificates/Challenges/Ingresses (plus its binding) is also left in the tenant namespace, not just "the Job and its pods" as the in-code comments and release note say. backoffLimit: 1(packages/apps/harbor/templates/hooks/cleanup.yaml,packages/apps/qdrant/templates/hooks/cleanup-pvc.yaml) is unreconciled withbucket'sbackoffLimit: 0for the same class of sweep — both carry a multi-line rationale comment arguing in a different direction.- Scope.
packages/apps/bucket/templates/hooks/cleanup-acme.yamlhas the identical swallow-every-failure shape this PR fixes in harbor/qdrant and is untouched;mariadb/clickhouseare named in the PR description as missing the same retry cap and left as-is. Reasonable to scope down, but worth stating explicitly as "out of scope, tracked" rather than leaving three of five hooks with the class of defect the title names generically. - Minor: CodeRabbit's nit on
hack/app-cleanup-hooks_test.bats— the fakekubectl'sEXISTINGmatcher only inspects the last positional in a multi-namexargs kubectl deletecall — is still unaddressed. Harmless today (no current fixture mixes present/absent names in one call) but worth a one-line fix if the suite grows.
None of the above are regressions against main; they're gaps in an otherwise sound, well-tested fix, all previously surfaced by a maintainer's review and explicitly called non-blocking there.
Good to merge as-is; the notes above are worth a follow-up issue or a short round of fixes if the author wants to fold them in.
6d59150 to
9ad75f2
Compare
The qdrant and harbor post-delete hooks guarded every delete with `|| echo "WARNING: ..." >&2`, set no `-e`, and ended on an unconditional completion message, so the container exited 0 on every path. Helm read that as a successful hook and the hook-succeeded delete policy removed the Job together with the pod that carried the warning, leaving an uninstall that reclaimed nothing indistinguishable from one that had nothing to reclaim. Count the failures instead and set the exit code at the end, so a failed delete does not skip the deletes that follow it. An object that is already gone stays a success: every delete keeps --ignore-not-found, which exits 0 in that case. This is the shape packages/apps/mariadb already uses for the same problem. Both hooks also consumed the output of `kubectl get` directly -- harbor by iterating it, qdrant by piping it into grep, whose status the pipeline then reports. A listing that failed therefore arrived as an empty list and the sweep was skipped rather than reported. Each listing is now captured before it is used, so a listing that failed and one that returned nothing are different events. A hook that exits non-zero keeps its Job and pods, which is the point: they hold the log. Give the Job one retry rather than the Kubernetes default of six or none at all. Every delete is idempotent, so a second attempt re-issues an API call rather than re-running any logic, and one short backoff is what separates a transient apiserver error from a real one -- these failures become visible for the first time here, and a signal that fires on noise stops being read, including on the day it is right. Six is the far end of the same mistake: ten minutes of backoff across seven pods outlasts the hook timeout, and Helm reports that timeout instead of the hook's own error. The qdrant Chainsaw suite explained its post-delete pod collector by quoting the guard string removed here and concluding the pod is always collected away. That is now backwards: a failed cleanup is exactly when the pod survives and the collector is the evidence. Rewrite the note, and the sentence in the harbor suite pointing at it. hack/app-cleanup-hooks_test.bats renders both scripts and runs them against a fake kubectl. There are eight failure paths across the two hooks -- six in harbor, two in qdrant -- and each has a case that goes red when that one path alone is reverted to a swallowed warning. Message assertions are anchored on the hook's own `ERROR: ` prefix, because the fake kubectl names the object in its own diagnostic too and an unanchored match is satisfied by the fixture. The helm-unittest suites pin the retry limit and the hook delete policy: adding hook-failed to that annotation hands the Job back to Helm to delete on failure and destroys the evidence this change exists to keep, and until these asserts that reversal left both suites green. Assisted-By: Claude <[email protected]> Signed-off-by: Aleksei Sviridkin <[email protected]>
9ad75f2 to
e9411d6
Compare
myasnikovdaniil
left a comment
There was a problem hiding this comment.
The fix is correct at the version this repo actually runs, the tests are load-bearing, and the question that decides whether this class of change is safe at all comes out the right way: a delete that can never succeed still completes.
That last point is the one I checked hardest, because this area has history. #3274 was closed as speculative and the real fix was #2826, and the settled principle for platform-wide hooks is that one tenant's broken state must not deadlock an operation for the whole fleet. So I traced what happens to an unsatisfiable delete. Helm appends the post-delete hook error to errs instead of returning, sets StatusUninstalled, purges, and returns uninstallation completed with N error(s). helm-controller keeps the finalizer on that failure, then the next reconcile gets ErrReleaseNotFound, returns success, and reconcileDelete removes the finalizer. kubectl delete qdrant X completes after one requeue, with nobody required to intervene for the delete itself. Same for all six harbor paths. No path leaves the app permanently undeletable.
On Andrei Kvapil (@kvaps)' leftover accounting, I read it differently and this is the substantive correction. In the helm this stack runs, a hook failure sweeps every hook that already succeeded: pkg/action/hooks.go:141 calls deleteHooksByPolicy(executingHooks[0:i], release.HookSucceeded, ...) and execHook invokes that closure on the error path too. The ServiceAccount at weight 0 and the Role/RoleBinding at weight 5 sort ahead of the Job at weight 10, so when the Job fails they are collected and only the failed Job and its pods remain, which is what the in-code comments and the release note say. The standing delete-on-PVCs Role does not outlive the uninstall. I would put a line in the comment saying this is helm v4 behaviour, since the older call graph had only the all-succeeded sweep and the accounting silently regresses if the stack ever pins back. IvanHunters' note 2 says both that the RBAC objects are collected and that the Role is left behind, and those cannot both hold.
Your point 1 on unbounded kubectl calls is right and I would sharpen the number. No call carries --request-timeout, no delete carries --timeout, neither Job carries activeDeadlineSeconds. But 30s is wrong by the repo's own arithmetic: hack/e2e-chainsaw/_lib/pod-label-census.sh:38-41 already records that against an unreachable apiserver a bounded call costs about five times the flag, since kubectl retries discovery five times, and that helper picks 15s for exactly that reason. I measured it, 2s gives 10s elapsed and 5s gives 25s. At 30s harbor's two listings alone are ~300s, which is the whole uninstall budget, since spec.uninstall is never set on app HelmReleases and the timeout falls through to the 5m default. Second refinement, --request-timeout bounds one HTTP request so it fixes the listings only, and a delete blocked on a finalizer is governed by --timeout, which the repo already distinguishes at talos-image-cache.sh:175. I would not take bucket's activeDeadlineSeconds here, you are right that it costs the log, and the log is the whole point of this PR.
The scenario that makes this the top item: harbor's <rel>-system child fails to tear down inside 5m, WaitForDelete gives up, the post-delete hook runs while a jobservice pod is still terminating, and kubectl delete pvc -l release=<rel>-system blocks on pvc-protection forever. The operator gets helm's deadline, never ERROR: failed to delete PVCs matching ..., and a Running Job pod stays in the tenant namespace after the release is gone, collected by nothing.
On backoffLimit: 1 versus bucket's 0, there are four dialects here and this PR picked the best one, so I would not reconcile downward. clickhouse carries before-hook-creation only, so it retains its Job after every uninstall including clean ones. bucket swallows all failures. mariadb exits non-zero with no cap. harbor and qdrant's before-hook-creation,hook-succeeded plus non-zero exit is the only combination that retains the Job exactly when there is something to read. Worth saying that in the comment so the next sweep converges here rather than on backoffLimit: 0. One nit inside it: on a deterministic failure, an absent ACME CRD say, the retry pays for nothing and you get two retained pods, and the twelve-line rationale reasons only about transient errors.
On CodeRabbit's discovery-probe suggestion, I would decline it. cozystack.cert-manager is emitted unconditionally in the system bundle and both paas and iaas fail without that bundle, so the window is cluster teardown only. And its premise is wrong, helm does not wait on a failed hook's pods. Adding a probe trades one silent-skip path for another, which is the thing this PR exists to stop.
Your scoping ask is fair. bucket still has the defect, cleanup-acme.yaml ends on an unconditional completion echo with every delete guarded by a warning, and mariadb and clickhouse already exit non-zero. Naming them and where they are tracked is cheap. Same for the unpinned kubectl image, which is the repo-wide norm rather than a qdrant oversight, six other packages use the same bare tag and harbor's digest is the exception, so that wants one sweep rather than a third state.
Mutations: each of the eight failure paths reverted individually reddens exactly the intended test, dropping --ignore-not-found reddens the already-gone cases, and both new manifest asserts fail on a changed backoffLimit, on removing the line entirely, and on adding hook-failed to the delete policy. Both packages define test: so neither is silently skipped, and the suite is 11 of 11 under both bats and hack/cozytest.sh.
What is not covered anywhere is the failure path end to end. Both chainsaw suites exercise a clean uninstall only, so that a failed Job survives collection, that the RBAC objects are swept anyway, and that helm-controller recovers on the second reconcile are established here by reading helm and helm-controller sources, not by a test. One dev-stand run settles all of it: install qdrant, break the hook's Role so the PVC delete 403s, delete the app, then watch the Job, the three RBAC objects, the HelmRelease conditions and how long the CR takes to disappear.
What this PR does
The qdrant and harbor post-delete cleanup hooks could not fail. Every delete was guarded by
|| echo "WARNING: ..." >&2, neither script set-e, and both ended on an unconditional completion message, so the container exited 0 on every path. Helm read that as a successful hook,helm.sh/hook-delete-policy: hook-succeededremoved the Job together with the pod that held the warning, and an uninstall that reclaimed nothing looked exactly like one that had nothing to reclaim. Written up in #3566.Both hooks now count failures and set the exit code at the end.
set -ewould abort at the first failed delete and skip the rest of the sweep, which is the opposite of what is wanted: every remaining object should still be attempted and the outcome reported once. Already-gone objects stay a success, since every delete keeps--ignore-not-found. Each failure message names the object it left behind.packages/apps/mariadbalready uses this shape.Both hooks also consumed
kubectl getoutput directly: harbor by iterating$(kubectl get ...), qdrant by piping it intogrep, whose status the pipeline then reports. A failed listing arrived as an empty list, and the sweep reported success without having looked. Each listing is now captured before it is used.#3566 attributes that shape to harbor's two loops alone. qdrant has it too:
pvcs=$(kubectl get pvc ... | grep -E ... || true)reports grep's status, so akubectl getthat failed left$pvcsempty and the hook announced "no data PVCs found; nothing to delete". Both instances are fixed here.The cost
A hook that exits non-zero keeps its Job and its pods, because
hook-succeededdoes not fire for it. That is the point, the pods hold the log, but it means a failed uninstall now leaves objects behind where it previously left none. Nothing collects them on its own:before-hook-creationonly fires when the hook runs again, and apost-deletehook runs only on an uninstall, so they stay until a release of that name is installed and uninstalled again, or until the namespace goes.The release itself is not stranded. Helm appends the post-delete hook error to its error list instead of returning on it, marks the release uninstalled, purges it from storage anyway, and only then reports
uninstallation completed with N error(s). The failure surfaces once and the app still goes away.Both Jobs get one retry instead of the Kubernetes default of six, and instead of none. Every delete is idempotent, so a second attempt re-issues an API call rather than re-running any logic, and one short backoff separates a transient apiserver error from a real one. These failures become visible for the first time with this change, and a signal that fires on noise stops being read, including on the day it is right. Six is the far end of the same mistake: ten minutes of backoff across seven pods outlasts the hook timeout, so Helm reports that timeout instead of the hook's own error.
packages/apps/clickhouseandpackages/apps/mariadbalready exit non-zero from their cleanup hooks and set no retry limit, so a real failure in either spends the full backoff and reaches the operator as a timeout rather than as the error the hook wrote. Adding the exit code without the cap reproduces that.One limitation is deliberate. harbor's ACME sweep cannot tell "this kind is not served" from "I could not look". If cert-manager is torn down before the release,
kubectl get challengefails and the uninstall is reported failed with nothing to sweep. cert-manager is a hard dependency of the bundle harbor ships in, so the window is narrow, and a CRD-existence probe would add a second silent-skip path of its own: it would swallow a discovery failure the way this change stops swallowing a listing failure.Tests
hack/app-cleanup-hooks_test.batsrenders each hook's script out of its chart and runs it against a fakekubectl, because the property under test is the container's exit code and matching against manifest text cannot establish it. There are eight failure paths across the two hooks, six in harbor and two in qdrant, and each has a case that goes red when that one path alone is reverted to a swallowed warning. Also covered: a clean delete, and an object that is already gone in both its shapes, an empty listing and aNotFoundon the delete itself.The fake
kubectlmodels--ignore-not-foundinstead of exiting 0 unconditionally, so dropping that flag from a hook turns the already-gone cases red. Message assertions are anchored on the hook's ownERROR:prefix, because the fake names the object in its own diagnostic too, and an unanchored match is satisfied by the fixture.Two manifest fields are pinned in each chart's existing helm-unittest suite, where manifest fields belong. The retry cap is one. The other is
helm.sh/hook-delete-policy: addinghook-failedto that annotation hands the failed Job straight back to Helm to delete, which undoes the whole change. I checked that before adding the assert, and the reversal left both suites fully green.The qdrant Chainsaw suite explained its post-delete pod collector by quoting the guard string this change removes, and concluded that Helm always collects the pod away. That conclusion inverts here, so the note is rewritten, along with the sentence in the harbor suite that points at it.
Screenshots
Not a UI change.
Downstream repositories
I walked the trigger map in
docs/agents/contributing.mdagainst the diff, file by file. It touches two app chart templates and adds one file underhack/. No package added, renamed or removed, novalues.schema.json, novalues.yamldefault, no version enum, noApplicationDefinition, no namespace, no node prerequisite, and neitherhack/package.mknorhack/update-crd.sh.Release note
Summary by CodeRabbit
Bug Fixes
Tests