KB-029: Chart migration deletes keep-annotated CRDs (postRenderer removal races the chart swap)¶
Status: Incident during the snapshot-controller migration off the piraeus chart (#4187, 2026-08-10); fully recovered, no primary data loss. The mechanism is general: it applies to any migration off a chart that templates its CRDs when the protective postRenderer is removed in the same commit as the chart swap.
Symptom¶
Minutes after merging a chart migration, the snapshot API vanishes and a rollback loop starts:
-
The new controller pod crash-loops with a startup CRD check:
-
kubectl get volumesnapshotclassreturnsthe server doesn't have a resource type, andkubectl get crd | grep snapshotcomes back near-empty. - The HelmRelease cycles: upgrade times out waiting on the crash-looping Deployment →
RemediateOnFailurerolls back → retry. Each retry deletes the CRDs again and each rollback recreates them, so spot checks give contradictory answers depending on when you look. - Every Flux Kustomization downstream of the app blocks on
dependency not ready(here: volsync → rook-ceph-cluster → kube-prometheus-stack and friends).
The rollback-loop shape looks like KB-015, but the driver is missing CRDs, not image pulls.
Cause¶
The migration PR changed two things in one commit: the OCIRepository (piraeus chart → home-operations chart) and the HelmRelease spec (removed the postRenderer that stamped helm.sh/resource-policy: keep onto the piraeus chart's templated CRDs). helm-controller reconciled those as two upgrades, one second apart:
- The HelmRelease spec change reconciled first, against the old chart (the new OCI artifact had not been fetched yet). Re-rendering the old chart without the postRenderer meant the release manifest no longer carried the keep annotation, and Helm's three-way apply stripped the annotation from the live CRD objects.
- One second later the artifact swap upgraded to the new chart, whose manifest contains no CRDs (it ships them in
crds/). Helm diffed six CRDs out of the release, found no keep policy on them any more, and deleted them all, taking everyVolumeSnapshotClass/VolumeSnapshotContentwith them.
The subtlety: Helm's keep decision reads the previous release's stored manifest, and the postRenderer output is part of that manifest. The protection therefore dies at the first reconcile after the postRenderer's removal, one step before the chart swap it was guarding against.
Fix¶
Recovery, in the order that worked:
- Stop the loop first:
flux suspend hr snapshot-controller -n kube-system. Until the HelmRelease is suspended, every retry re-deletes whatever you restore. -
Restore the CRDs from the new chart's bundle, pinned to the version actually being deployed, server-side:
helm show crds oci://ghcr.io/home-operations/charts/snapshot-controller --version <deployed-tag> \ | kubectl apply --server-side --force-conflicts -f -Without
--version,helm show crdsresolves to whatever the registry currently reports as latest, not necessarily the version the wedged release was mid-upgrade to. Get<deployed-tag>from the app'socirepository.yaml(spec.ref.tag), or from the pending/failed revision inhelm history -n <namespace> snapshot-controller, but note itsCHARTcolumn prints<chart>-<version>(e.g.snapshot-controller-0.1.0);--versionwants only the bare version suffix (0.1.0), and the registry has no tag by the combined name. -
With the CRDs present, the crash-looping pod starts and the in-flight upgrade completes on its own (
helm historyflips todeployed). Thenflux resume hr. -
Recreate the cluster-scoped CRs the CRD deletion destroyed. Helm will not self-heal deleted objects without drift detection, so force the owners:
-
Verify end-to-end: all three
VolumeSnapshotClassobjects back, downstream Kustomizations Ready, and one manually-triggered ReplicationSource (spec.trigger.manual) completesSuccessful.
Impact boundary: source PVCs and the Kopia repositories (the real backups) are untouched. What is lost is the cached VolumeSnapshot/VolumeSnapshotContent restore points, which the next scheduled VolSync cycles rebuild.
Prevention¶
-
Annotate the live CRDs out-of-band before merging any migration off a CRD-templating chart:
An annotation set by kubectl's field manager is not part of Helm's manifests, so no Helm apply will strip it, unlike the postRenderer, which lives and dies with the release.
-
Keep a local copy of the new chart's CRDs (
helm show crds …) before merging; recovery is onekubectl applywhen the copy already exists. - The end state is the safe one: with CRDs shipped in
crds/, Helm never deletes them again. Plain Helm would also never upgrade them, but that is not what runs here: the root Kustomization patches every HelmRelease withinstall.crds: CreateReplaceandupgrade.crds: CreateReplace(kubernetes/flux/cluster/ks.yaml), so helm-controller server-side applies the chart'scrds/on every install and upgrade. Themonitoring.coreos.comCRDs show this in theirmanagedFields, updated byhelm-controlleron the 87.17.0 and 88.5.4 kube-prometheus-stack bumps with no manual step. A manualkubectl apply --server-side --force-conflictsis only the break-glass if a HelmRelease wedges before its CRD pass.