Skip to content

fix(apps): make the post-delete cleanup hooks report failure - #3593

Merged
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
fix/pvc-cleanup-hooks-report-failure
Aug 14, 2026
Merged

Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
fix/pvc-cleanup-hooks-report-failure

Conversation

@lexfrei

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

Copy link
Copy Markdown
Contributor

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-succeeded removed 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 -e would 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/mariadb already uses this shape.

Both hooks also consumed kubectl get output directly: harbor by iterating $(kubectl get ...), qdrant by piping it into grep, 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 a kubectl get that failed left $pvcs empty 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-succeeded does 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-creation only fires when the hook runs again, and a post-delete hook 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/clickhouse and packages/apps/mariadb already 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 challenge fails 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.bats renders each hook's script out of its chart and runs it against a fake kubectl, 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 a NotFound on the delete itself.

The fake kubectl models --ignore-not-found instead 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 own ERROR: 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: adding hook-failed to 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.md against the diff, file by file. It touches two app chart templates and adds one file under hack/. No package added, renamed or removed, no values.schema.json, no values.yaml default, no version enum, no ApplicationDefinition, no namespace, no node prerequisite, and neither hack/package.mk nor hack/update-crd.sh.

Release note

fix(apps): the qdrant and harbor post-delete cleanup hooks now exit non-zero when a delete or a listing fails, instead of reporting success on every path. `helm uninstall` reports the failure rather than returning clean, and the hook Job and its pods stay in the namespace so the log survives to be read. Nothing collects them on its own: they remain until a release of that name is installed and uninstalled again, or until the namespace is removed.

Summary by CodeRabbit

  • Bug Fixes

    • Improved Harbor and Qdrant cleanup reliability by detecting listing and deletion failures.
    • Cleanup jobs now retry once and report failures with a non-zero status.
    • Cleanup continues processing remaining resources after individual failures.
    • Successful cleanup remains safe and repeatable, including when resources are already missing.
    • Harbor cleanup remains limited to resources associated with the configured Harbor host.
  • Tests

    • Added comprehensive coverage for successful, repeated, partial, and failed cleanup scenarios.

@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/bug Categorizes issue or PR as related to a bug labels Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 773aac01-75b3-49fe-95cd-3e6e2a612f96

📥 Commits

Reviewing files that changed from the base of the PR and between 1fcb62d and 733e01d.

📒 Files selected for processing (1)
  • packages/apps/harbor/tests/cleanup_hook_test.yaml
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/apps/harbor/tests/cleanup_hook_test.yaml

📝 Walkthrough

Walkthrough

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

Changes

Cleanup hook behavior

Layer / File(s) Summary
Rendered hook test harness
hack/app-cleanup-hooks_test.bats
Adds chart-rendering helpers, fake kubectl behavior, and guard tests for missing charts or Jobs.
Qdrant cleanup failure handling
packages/apps/qdrant/templates/hooks/cleanup-pvc.yaml, packages/apps/qdrant/tests/cleanup_pvc_test.yaml, hack/app-cleanup-hooks_test.bats, hack/e2e-chainsaw/qdrant/chainsaw-test.yaml
Qdrant cleanup tracks listing and deletion failures, retries once, exits non-zero on failure, and retains failed Jobs. Tests cover success, idempotency, failures, and diagnostics.
Harbor cleanup failure handling
packages/apps/harbor/templates/hooks/cleanup.yaml, packages/apps/harbor/tests/cleanup_hook_test.yaml, hack/app-cleanup-hooks_test.bats, hack/e2e-chainsaw/harbor/chainsaw-test.yaml
Harbor cleanup tracks PVC, Certificate, Challenge, and solver Ingress failures, retries once, checks listing errors, and retains failed Jobs. Tests cover continuation, error attribution, idempotency, and diagnostics.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested labels: area/testing

Suggested reviewers: ivanhunters

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: reporting failures from post-delete cleanup hooks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pvc-cleanup-hooks-report-failure

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
hack/app-cleanup-hooks_test.bats (1)

96-111: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The EXISTING check inspects only the last positional argument.

The qdrant hook deletes all PVCs in one xargs kubectl delete call. The loop at Lines 69-76 overwrites obj on each positional, so only the final name is matched against EXISTING. 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
 done

Then iterate $objs in the NotFound branch instead of testing $obj alone.

🤖 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

📥 Commits

Reviewing files that changed from the base of the PR and between b4e2031 and 1fcb62d.

📒 Files selected for processing (7)
  • hack/app-cleanup-hooks_test.bats
  • hack/e2e-chainsaw/harbor/chainsaw-test.yaml
  • hack/e2e-chainsaw/qdrant/chainsaw-test.yaml
  • packages/apps/harbor/templates/hooks/cleanup.yaml
  • packages/apps/harbor/tests/cleanup_hook_test.yaml
  • packages/apps/qdrant/templates/hooks/cleanup-pvc.yaml
  • packages/apps/qdrant/tests/cleanup_pvc_test.yaml

Comment on lines +141 to +166
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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/templates

Repository: 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 -200

Repository: 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:


🌐 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:


🌐 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:


🌐 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:


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

@lexfrei

Copy link
Copy Markdown
Contributor Author

Reopening to pick up the multus fix on main.

@kvaps Andrei Kvapil (kvaps) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:142 appends the post-delete hook error to errs instead of returning it, :147 sets StatusUninstalled, :154-156 runs purgeReleases regardless, :163 returns uninstallation 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.go reaches r.failure(...) and returns the error, reconcileReleaseDeletion tolerates only ErrNoLatest, so reconcileDelete never reaches RemoveFinalizer. The next reconcile is what clears it — Helm's storage no longer holds the release, so Releases.History returns driver.ErrReleaseNotFound, helm-controller marks UninstallSucceeded and drops the finalizer. Same outcome via the "is already deleted" branch when spec.uninstall.keepHistory is 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,98 is the same ACME sweep as harbor's. Every delete is still || echo "WARNING: …" and the script still ends on an unconditional echo "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.

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

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 -e is 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). Every fail call 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 qdrant kubectl get | grep ... || true and harbor for x in $(kubectl get ...) bug.
  • --ignore-not-found is 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-policy is deliberately left at before-hook-creation,hook-succeeded (no hook-failed) on the Job in both charts, so a failed Job's pod survives for postmortem. This is pinned by new helm-unittest assertions in packages/apps/harbor/tests/cleanup_hook_test.yaml and packages/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 fake kubectl, 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

  1. Hook can still hang past Helm's timeout without producing the new error message. Neither kubectl get nor kubectl delete in either script sets --request-timeout, and no delete uses --wait=false; the Job has no activeDeadlineSeconds. A stuck finalizer (e.g. kubernetes.io/pvc-protection while a -system pod 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's ERROR:/Cleanup FAILED message — the exact failure mode most likely to occur in production bypasses the new reporting path entirely. packages/apps/bucket/templates/hooks/cleanup-acme.yaml already guards the equivalent ACME sweep with activeDeadlineSeconds: 120, backoffLimit: 0, --request-timeout=30s, --wait=false.
  2. Leftover accounting is incomplete. Verified directly in the rendered head templates: the ServiceAccount/Role/RoleBinding for both charts still carry only before-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 granting delete on 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.
  3. backoffLimit: 1 (packages/apps/harbor/templates/hooks/cleanup.yaml, packages/apps/qdrant/templates/hooks/cleanup-pvc.yaml) is unreconciled with bucket's backoffLimit: 0 for the same class of sweep — both carry a multi-line rationale comment arguing in a different direction.
  4. Scope. packages/apps/bucket/templates/hooks/cleanup-acme.yaml has the identical swallow-every-failure shape this PR fixes in harbor/qdrant and is untouched; mariadb/clickhouse are 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.
  5. Minor: CodeRabbit's nit on hack/app-cleanup-hooks_test.bats — the fake kubectl's EXISTING matcher only inspects the last positional in a multi-name xargs kubectl delete call — 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.

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]>
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/pvc-cleanup-hooks-report-failure branch from 9ad75f2 to e9411d6 Compare August 12, 2026 15:43

@myasnikovdaniil myasnikovdaniil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@lexfrei
Aleksei Sviridkin (lexfrei) merged commit 9dc49de into main Aug 14, 2026
16 of 18 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the fix/pvc-cleanup-hooks-report-failure branch August 14, 2026 07:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review 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.

4 participants