Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changes/unreleased/operator-Fixed-20260326-000000.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
project: operator
kind: Fixed
body: Orphaned CRs with finalizers could block namespace deletion when the operator was uninstalled while CRs still existed. An opt-in post-delete helm hook (`finalizerRemoval.enabled=true`) now strips operator finalizers from all managed CRs after uninstall, ensuring the operator is no longer running and cannot re-add finalizers.
time: 2026-03-26T00:00:00.000000-05:00
12 changes: 12 additions & 0 deletions operator/chart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,18 @@ Secret name and key where the license key is stored.

**Default:** `{"key":""}`

### [finalizerRemoval](https://artifacthub.io/packages/helm/redpanda-data/operator?modal=values&path=finalizerRemoval)

Opt-in post-delete job that removes finalizers from all operator-managed CRs after uninstall, preventing orphaned resources from blocking namespace deletion.

**Default:** `{"enabled":false}`

### [finalizerRemoval.enabled](https://artifacthub.io/packages/helm/redpanda-data/operator?modal=values&path=finalizerRemoval.enabled)

Enables the post-delete finalizer removal job.

**Default:** `false`

### [fullnameOverride](https://artifacthub.io/packages/helm/redpanda-data/operator?modal=values&path=fullnameOverride)

Overrides the `redpanda-operator.fullname` template.
Expand Down
2 changes: 2 additions & 0 deletions operator/chart/chart.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ func render(dot *helmette.Dot) []kube.Object {
CRDJobServiceAccount(dot),
PostUpgradeMigrationJob(dot),
MigrationJobServiceAccount(dot),
PostDeleteFinalizerRemovalJob(dot),
PostDeleteFinalizerRemovalJobServiceAccount(dot),
}

for _, svc := range StretchClusterService(dot) {
Expand Down
87 changes: 87 additions & 0 deletions operator/chart/post_delete_finalizer_removal_job.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright 2026 Redpanda Data, Inc.
//
// Use of this software is governed by the Business Source License
// included in the file licenses/BSL.md
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0

// +gotohelm:filename=_post-delete-finalizer-removal-job.go.tpl
package operator

import (
"fmt"

batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"

"github.com/redpanda-data/redpanda-operator/gotohelm/helmette"
)

// PostDeleteFinalizerRemovalJob is a post-delete hook job that removes
// finalizers from all operator-managed CRs after uninstall. Running
// post-delete ensures the operator is already gone before finalizers are
// stripped, eliminating any race where the controller could re-add them.
func PostDeleteFinalizerRemovalJob(dot *helmette.Dot) *batchv1.Job {
values := helmette.Unwrap[Values](dot.Values)

if !values.FinalizerRemoval.Enabled {
return nil
}

return &batchv1.Job{
TypeMeta: metav1.TypeMeta{
APIVersion: "batch/v1",
Kind: "Job",
},
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-finalizer-removal", Fullname(dot)),
Namespace: dot.Release.Namespace,
Labels: helmette.Merge(Labels(dot)),
Annotations: map[string]string{
"helm.sh/hook": "post-delete",
"helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded,hook-failed",
"helm.sh/hook-weight": "-5",
},
},
Spec: batchv1.JobSpec{
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Annotations: values.PodAnnotations,
Labels: helmette.Merge(SelectorLabels(dot), values.PodLabels),
},
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyOnFailure,
AutomountServiceAccountToken: ptr.To(false),
TerminationGracePeriodSeconds: ptr.To(int64(10)),
ImagePullSecrets: values.ImagePullSecrets,
ServiceAccountName: PostDeleteFinalizerRemovalJobServiceAccountName(dot),
NodeSelector: values.NodeSelector,
Tolerations: values.Tolerations,
Volumes: []corev1.Volume{serviceAccountTokenVolume()},
Containers: finalizerRemovalJobContainers(dot),
},
},
},
}
}

func finalizerRemovalJobContainers(dot *helmette.Dot) []corev1.Container {
values := helmette.Unwrap[Values](dot.Values)

return []corev1.Container{
{
Name: "finalizer-removal",
Image: containerImage(dot),
ImagePullPolicy: values.Image.PullPolicy,
Command: []string{"/redpanda-operator"},
Args: []string{"finalizer-removal"},
SecurityContext: &corev1.SecurityContext{AllowPrivilegeEscalation: ptr.To(false)},
VolumeMounts: []corev1.VolumeMount{serviceAccountTokenVolumeMount()},
Resources: values.Resources,
},
}
}
14 changes: 14 additions & 0 deletions operator/chart/rbac.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,20 @@ func rbacBundles(dot *helmette.Dot) []RBACBundle {
RuleFiles: bundles[0].RuleFiles,
})

// the finalizer removal job needs the same general RBAC policy as the operator itself
// (v2-manager.ClusterRole.yaml already covers list+patch on all CR finalizers)
bundles = append(bundles, RBACBundle{
Name: PostDeleteFinalizerRemovalJobServiceAccountName(dot),
Enabled: values.FinalizerRemoval.Enabled,
Subject: PostDeleteFinalizerRemovalJobServiceAccountName(dot),
Annotations: map[string]string{
"helm.sh/hook": "post-delete",
"helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded,hook-failed",
"helm.sh/hook-weight": "-10",
},
RuleFiles: bundles[0].RuleFiles,
})

return bundles
}

Expand Down
38 changes: 38 additions & 0 deletions operator/chart/serviceaccount.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ func MigrationJobServiceAccountName(dot *helmette.Dot) string {
return ServiceAccountName(dot) + "-migration-job"
}

func PostDeleteFinalizerRemovalJobServiceAccountName(dot *helmette.Dot) string {
return ServiceAccountName(dot) + "-finalizer-removal-job"
}

func ServiceAccount(dot *helmette.Dot) *corev1.ServiceAccount {
values := helmette.Unwrap[Values](dot.Values)

Expand Down Expand Up @@ -88,6 +92,40 @@ func CRDJobServiceAccount(dot *helmette.Dot) *corev1.ServiceAccount {
}
}

// PostDeleteFinalizerRemovalJobServiceAccount returns a ServiceAccount used by
// [PostDeleteFinalizerRemovalJob]. Helm will delete it after the job completes.
func PostDeleteFinalizerRemovalJobServiceAccount(dot *helmette.Dot) *corev1.ServiceAccount {
values := helmette.Unwrap[Values](dot.Values)

if !values.FinalizerRemoval.Enabled {
return nil
}

return &corev1.ServiceAccount{
TypeMeta: metav1.TypeMeta{
Kind: "ServiceAccount",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: PostDeleteFinalizerRemovalJobServiceAccountName(dot),
Labels: Labels(dot),
Namespace: dot.Release.Namespace,
Annotations: helmette.Merge(
helmette.Default(
map[string]string{},
values.ServiceAccount.Annotations,
),
map[string]string{
"helm.sh/hook": "post-delete",
"helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded,hook-failed",
"helm.sh/hook-weight": "-10",
},
),
},
AutomountServiceAccountToken: ptr.To(false),
}
}

// MigrationJobServiceAccount returns a ServiceAccount that's used by
// [PostUpgradeMigrationJob]. Helm will delete it after the job succeeds.
func MigrationJobServiceAccount(dot *helmette.Dot) *corev1.ServiceAccount {
Expand Down
2 changes: 1 addition & 1 deletion operator/chart/templates/_chart.go.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
{{- $dot := (index .a 0) -}}
{{- range $_ := (list 1) -}}
{{- $_is_returning := false -}}
{{- $manifests := (list (get (fromJson (include "operator.Issuer" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.Certificate" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.ConfigMap" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.MetricsService" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.WebhookService" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.MutatingWebhookConfiguration" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.ValidatingWebhookConfiguration" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.ServiceAccount" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.ServiceMonitor" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.Deployment" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.PreInstallCRDJob" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.CRDJobServiceAccount" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.PostUpgradeMigrationJob" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.MigrationJobServiceAccount" (dict "a" (list $dot)))) "r")) -}}
{{- $manifests := (list (get (fromJson (include "operator.Issuer" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.Certificate" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.ConfigMap" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.MetricsService" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.WebhookService" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.MutatingWebhookConfiguration" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.ValidatingWebhookConfiguration" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.ServiceAccount" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.ServiceMonitor" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.Deployment" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.PreInstallCRDJob" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.CRDJobServiceAccount" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.PostUpgradeMigrationJob" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.MigrationJobServiceAccount" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.PostDeleteFinalizerRemovalJob" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.PostDeleteFinalizerRemovalJobServiceAccount" (dict "a" (list $dot)))) "r")) -}}
{{- range $_, $svc := (get (fromJson (include "operator.StretchClusterService" (dict "a" (list $dot)))) "r") -}}
{{- $manifests = (concat (default (list) $manifests) (list $svc)) -}}
{{- end -}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{{- /* GENERATED FILE DO NOT EDIT */ -}}
{{- /* Transpiled by gotohelm from "github.com/redpanda-data/redpanda-operator/operator/chart/post_delete_finalizer_removal_job.go" */ -}}

{{- define "operator.PostDeleteFinalizerRemovalJob" -}}
{{- $dot := (index .a 0) -}}
{{- range $_ := (list 1) -}}
{{- $_is_returning := false -}}
{{- $values := $dot.Values.AsMap -}}
{{- if (not $values.finalizerRemoval.enabled) -}}
{{- $_is_returning = true -}}
{{- (dict "r" (coalesce nil)) | toJson -}}
{{- break -}}
{{- end -}}
{{- $_is_returning = true -}}
{{- (dict "r" (mustMergeOverwrite (dict "metadata" (dict) "spec" (dict "template" (dict "metadata" (dict) "spec" (dict "containers" (coalesce nil)))) "status" (dict)) (mustMergeOverwrite (dict) (dict "apiVersion" "batch/v1" "kind" "Job")) (dict "metadata" (mustMergeOverwrite (dict) (dict "name" (printf "%s-finalizer-removal" (get (fromJson (include "operator.Fullname" (dict "a" (list $dot)))) "r")) "namespace" $dot.Release.Namespace "labels" (merge (dict) (get (fromJson (include "operator.Labels" (dict "a" (list $dot)))) "r")) "annotations" (dict "helm.sh/hook" "post-delete" "helm.sh/hook-delete-policy" "before-hook-creation,hook-succeeded,hook-failed" "helm.sh/hook-weight" "-5"))) "spec" (mustMergeOverwrite (dict "template" (dict "metadata" (dict) "spec" (dict "containers" (coalesce nil)))) (dict "template" (mustMergeOverwrite (dict "metadata" (dict) "spec" (dict "containers" (coalesce nil))) (dict "metadata" (mustMergeOverwrite (dict) (dict "annotations" $values.podAnnotations "labels" (merge (dict) (get (fromJson (include "operator.SelectorLabels" (dict "a" (list $dot)))) "r") $values.podLabels))) "spec" (mustMergeOverwrite (dict "containers" (coalesce nil)) (dict "restartPolicy" "OnFailure" "automountServiceAccountToken" false "terminationGracePeriodSeconds" ((10 | int64) | int64) "imagePullSecrets" $values.imagePullSecrets "serviceAccountName" (get (fromJson (include "operator.PostDeleteFinalizerRemovalJobServiceAccountName" (dict "a" (list $dot)))) "r") "nodeSelector" $values.nodeSelector "tolerations" $values.tolerations "volumes" (list (get (fromJson (include "operator.serviceAccountTokenVolume" (dict "a" (list)))) "r")) "containers" (get (fromJson (include "operator.finalizerRemovalJobContainers" (dict "a" (list $dot)))) "r")))))))))) | toJson -}}
{{- break -}}
{{- end -}}
{{- end -}}

{{- define "operator.finalizerRemovalJobContainers" -}}
{{- $dot := (index .a 0) -}}
{{- range $_ := (list 1) -}}
{{- $_is_returning := false -}}
{{- $values := $dot.Values.AsMap -}}
{{- $_is_returning = true -}}
{{- (dict "r" (list (mustMergeOverwrite (dict "name" "" "resources" (dict)) (dict "name" "finalizer-removal" "image" (get (fromJson (include "operator.containerImage" (dict "a" (list $dot)))) "r") "imagePullPolicy" $values.image.pullPolicy "command" (list "/redpanda-operator") "args" (list "finalizer-removal") "securityContext" (mustMergeOverwrite (dict) (dict "allowPrivilegeEscalation" false)) "volumeMounts" (list (get (fromJson (include "operator.serviceAccountTokenVolumeMount" (dict "a" (list)))) "r")) "resources" $values.resources)))) | toJson -}}
{{- break -}}
{{- end -}}
{{- end -}}

1 change: 1 addition & 0 deletions operator/chart/templates/_rbac.go.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
{{- $values := $dot.Values.AsMap -}}
{{- $bundles := (list (mustMergeOverwrite (dict "Enabled" false "Name" "" "Subject" "" "RuleFiles" (coalesce nil) "Annotations" (coalesce nil)) (dict "Name" (get (fromJson (include "operator.Fullname" (dict "a" (list $dot)))) "r") "Enabled" true "Subject" (get (fromJson (include "operator.ServiceAccountName" (dict "a" (list $dot)))) "r") "RuleFiles" (dict "files/rbac/console.ClusterRole.yaml" true "files/rbac/leader-election.ClusterRole.yaml" true "files/rbac/leader-election.Role.yaml" true "files/rbac/pvcunbinder.ClusterRole.yaml" true "files/rbac/pvcunbinder.Role.yaml" true "files/rbac/rack-awareness.ClusterRole.yaml" true "files/rbac/rpk-debug-bundle.Role.yaml" true "files/rbac/sidecar.Role.yaml" true "files/rbac/v1-manager.ClusterRole.yaml" $values.vectorizedControllers.enabled "files/rbac/v1-manager.Role.yaml" $values.vectorizedControllers.enabled "files/rbac/v2-manager.ClusterRole.yaml" true "files/rbac/multicluster-manager.ClusterRole.yaml" $values.multicluster.enabled))) (mustMergeOverwrite (dict "Enabled" false "Name" "" "Subject" "" "RuleFiles" (coalesce nil) "Annotations" (coalesce nil)) (dict "Name" (get (fromJson (include "operator.cleanForK8sWithSuffix" (dict "a" (list (get (fromJson (include "operator.Fullname" (dict "a" (list $dot)))) "r") "additional-controllers")))) "r") "Enabled" $values.rbac.createAdditionalControllerCRs "Subject" (get (fromJson (include "operator.ServiceAccountName" (dict "a" (list $dot)))) "r") "RuleFiles" (dict "files/rbac/decommission.ClusterRole.yaml" true "files/rbac/decommission.Role.yaml" true "files/rbac/node-watcher.ClusterRole.yaml" true "files/rbac/node-watcher.Role.yaml" true "files/rbac/old-decommission.ClusterRole.yaml" true "files/rbac/old-decommission.Role.yaml" true "files/rbac/pvcunbinder.ClusterRole.yaml" true "files/rbac/pvcunbinder.Role.yaml" true))) (mustMergeOverwrite (dict "Enabled" false "Name" "" "Subject" "" "RuleFiles" (coalesce nil) "Annotations" (coalesce nil)) (dict "Name" (get (fromJson (include "operator.CRDJobServiceAccountName" (dict "a" (list $dot)))) "r") "Enabled" (or $values.crds.enabled $values.crds.experimental) "Subject" (get (fromJson (include "operator.CRDJobServiceAccountName" (dict "a" (list $dot)))) "r") "Annotations" (dict "helm.sh/hook" "pre-install,pre-upgrade" "helm.sh/hook-delete-policy" "before-hook-creation,hook-succeeded,hook-failed" "helm.sh/hook-weight" "-10") "RuleFiles" (dict "files/rbac/crd-installation.ClusterRole.yaml" true)))) -}}
{{- $bundles = (concat (default (list) $bundles) (list (mustMergeOverwrite (dict "Enabled" false "Name" "" "Subject" "" "RuleFiles" (coalesce nil) "Annotations" (coalesce nil)) (dict "Name" (get (fromJson (include "operator.MigrationJobServiceAccountName" (dict "a" (list $dot)))) "r") "Enabled" true "Subject" (get (fromJson (include "operator.MigrationJobServiceAccountName" (dict "a" (list $dot)))) "r") "Annotations" (dict "helm.sh/hook" "post-upgrade" "helm.sh/hook-delete-policy" "before-hook-creation,hook-succeeded,hook-failed" "helm.sh/hook-weight" "-10") "RuleFiles" (index $bundles (0 | int)).RuleFiles)))) -}}
{{- $bundles = (concat (default (list) $bundles) (list (mustMergeOverwrite (dict "Enabled" false "Name" "" "Subject" "" "RuleFiles" (coalesce nil) "Annotations" (coalesce nil)) (dict "Name" (get (fromJson (include "operator.PostDeleteFinalizerRemovalJobServiceAccountName" (dict "a" (list $dot)))) "r") "Enabled" $values.finalizerRemoval.enabled "Subject" (get (fromJson (include "operator.PostDeleteFinalizerRemovalJobServiceAccountName" (dict "a" (list $dot)))) "r") "Annotations" (dict "helm.sh/hook" "post-delete" "helm.sh/hook-delete-policy" "before-hook-creation,hook-succeeded,hook-failed" "helm.sh/hook-weight" "-10") "RuleFiles" (index $bundles (0 | int)).RuleFiles)))) -}}
{{- $_is_returning = true -}}
{{- (dict "r" $bundles) | toJson -}}
{{- break -}}
Expand Down
26 changes: 26 additions & 0 deletions operator/chart/templates/_serviceaccount.go.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@
{{- end -}}
{{- end -}}

{{- define "operator.PostDeleteFinalizerRemovalJobServiceAccountName" -}}
{{- $dot := (index .a 0) -}}
{{- range $_ := (list 1) -}}
{{- $_is_returning := false -}}
{{- $_is_returning = true -}}
{{- (dict "r" (printf "%s%s" (get (fromJson (include "operator.ServiceAccountName" (dict "a" (list $dot)))) "r") "-finalizer-removal-job")) | toJson -}}
{{- break -}}
{{- end -}}
{{- end -}}

{{- define "operator.ServiceAccount" -}}
{{- $dot := (index .a 0) -}}
{{- range $_ := (list 1) -}}
Expand Down Expand Up @@ -64,6 +74,22 @@
{{- end -}}
{{- end -}}

{{- define "operator.PostDeleteFinalizerRemovalJobServiceAccount" -}}
{{- $dot := (index .a 0) -}}
{{- range $_ := (list 1) -}}
{{- $_is_returning := false -}}
{{- $values := $dot.Values.AsMap -}}
{{- if (not $values.finalizerRemoval.enabled) -}}
{{- $_is_returning = true -}}
{{- (dict "r" (coalesce nil)) | toJson -}}
{{- break -}}
{{- end -}}
{{- $_is_returning = true -}}
{{- (dict "r" (mustMergeOverwrite (dict "metadata" (dict)) (mustMergeOverwrite (dict) (dict "kind" "ServiceAccount" "apiVersion" "v1")) (dict "metadata" (mustMergeOverwrite (dict) (dict "name" (get (fromJson (include "operator.PostDeleteFinalizerRemovalJobServiceAccountName" (dict "a" (list $dot)))) "r") "labels" (get (fromJson (include "operator.Labels" (dict "a" (list $dot)))) "r") "namespace" $dot.Release.Namespace "annotations" (merge (dict) (default (dict) $values.serviceAccount.annotations) (dict "helm.sh/hook" "post-delete" "helm.sh/hook-delete-policy" "before-hook-creation,hook-succeeded,hook-failed" "helm.sh/hook-weight" "-10")))) "automountServiceAccountToken" false))) | toJson -}}
{{- break -}}
{{- end -}}
{{- end -}}

{{- define "operator.MigrationJobServiceAccount" -}}
{{- $dot := (index .a 0) -}}
{{- range $_ := (list 1) -}}
Expand Down
Loading
Loading