From 19ba0881029d7629cd21b1350ea78e76871b32bd Mon Sep 17 00:00:00 2001 From: chrischapman Date: Thu, 26 Mar 2026 08:52:09 -0600 Subject: [PATCH 01/16] feat(operator/chart): add finalizerRemoval opt-in flag to values Adds a new `finalizerRemoval.enabled` boolean (default false) to the operator chart values. This will gate a pre-delete helm hook job that strips finalizers from all operator-managed CRs on uninstall. Co-Authored-By: Claude Sonnet 4.6 --- operator/chart/values.go | 5 +++++ operator/chart/values.yaml | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/operator/chart/values.go b/operator/chart/values.go index 9f5b8832c..07a57bacd 100644 --- a/operator/chart/values.go +++ b/operator/chart/values.go @@ -75,6 +75,7 @@ type Values struct { CRDs CRDs `json:"crds"` VectorizedControllers VectorizedControllers `json:"vectorizedControllers"` Multicluster Multicluster `json:"multicluster"` + FinalizerRemoval FinalizerRemoval `json:"finalizerRemoval"` } type VectorizedControllers struct { @@ -86,6 +87,10 @@ type CRDs struct { Experimental bool `json:"experimental"` } +type FinalizerRemoval struct { + Enabled bool `json:"enabled"` +} + type PodTemplateSpec struct { Metadata Metadata `json:"metadata,omitempty"` Spec corev1.PodSpec `json:"spec,omitempty" jsonschema:"required"` diff --git a/operator/chart/values.yaml b/operator/chart/values.yaml index 3e92bb877..dc8781232 100644 --- a/operator/chart/values.yaml +++ b/operator/chart/values.yaml @@ -203,3 +203,9 @@ multicluster: # address: ip/dns of operator host peers: [] servicePerOperatorDeployment: false + +# -- Opt-in pre-delete job that removes finalizers from all operator-managed +# CRs before uninstall, preventing orphaned resources from blocking namespace deletion. +finalizerRemoval: + # -- Enables the pre-delete finalizer removal job. + enabled: false From 5f5b32f595931775662a03a7a06439755138eee0 Mon Sep 17 00:00:00 2001 From: chrischapman Date: Thu, 26 Mar 2026 08:55:42 -0600 Subject: [PATCH 02/16] feat(operator/chart): add PreDeleteFinalizerRemovalJob service account Adds SA name helper and SA object for the upcoming pre-delete finalizer removal job, following the same pattern as MigrationJobServiceAccount. Co-Authored-By: Claude Sonnet 4.6 --- operator/chart/serviceaccount.go | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/operator/chart/serviceaccount.go b/operator/chart/serviceaccount.go index ced813521..42feba4f7 100644 --- a/operator/chart/serviceaccount.go +++ b/operator/chart/serviceaccount.go @@ -32,6 +32,10 @@ func MigrationJobServiceAccountName(dot *helmette.Dot) string { return ServiceAccountName(dot) + "-migration-job" } +func PreDeleteFinalizerRemovalJobServiceAccountName(dot *helmette.Dot) string { + return ServiceAccountName(dot) + "-finalizer-removal-job" +} + func ServiceAccount(dot *helmette.Dot) *corev1.ServiceAccount { values := helmette.Unwrap[Values](dot.Values) @@ -88,6 +92,40 @@ func CRDJobServiceAccount(dot *helmette.Dot) *corev1.ServiceAccount { } } +// PreDeleteFinalizerRemovalJobServiceAccount returns a ServiceAccount used by +// [PreDeleteFinalizerRemovalJob]. Helm will delete it after the job completes. +func PreDeleteFinalizerRemovalJobServiceAccount(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: PreDeleteFinalizerRemovalJobServiceAccountName(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": "pre-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 { From c53f918d8aa0af292264042f7749c86904ddd8da Mon Sep 17 00:00:00 2001 From: chrischapman Date: Thu, 26 Mar 2026 08:56:06 -0600 Subject: [PATCH 03/16] feat(operator/chart): add pre-delete finalizer removal Job Adds a pre-delete helm hook Job that runs the finalizer-removal subcommand on helm uninstall when finalizerRemoval.enabled=true. Mirrors the pattern from PostUpgradeMigrationJob (f1112cb). Co-Authored-By: Claude Sonnet 4.6 --- .../chart/pre_delete_finalizer_removal_job.go | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 operator/chart/pre_delete_finalizer_removal_job.go diff --git a/operator/chart/pre_delete_finalizer_removal_job.go b/operator/chart/pre_delete_finalizer_removal_job.go new file mode 100644 index 000000000..e712f43c9 --- /dev/null +++ b/operator/chart/pre_delete_finalizer_removal_job.go @@ -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=_pre-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" +) + +// PreDeleteFinalizerRemovalJob is a pre-delete hook job that removes finalizers +// from all operator-managed CRs before uninstall. This prevents orphaned +// resources from blocking namespace deletion when the operator is removed while +// CRs still exist. +func PreDeleteFinalizerRemovalJob(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": "pre-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: PreDeleteFinalizerRemovalJobServiceAccountName(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, + }, + } +} From 7d9cc4b82f62f7e504b0fa824bb5e71fe7ea1af8 Mon Sep 17 00:00:00 2001 From: chrischapman Date: Thu, 26 Mar 2026 08:56:55 -0600 Subject: [PATCH 04/16] feat(operator/chart): wire finalizer removal RBAC bundle and render() Adds RBAC bundle (reusing operator's v2-manager rules) for the pre-delete finalizer removal job and wires the job + SA into the chart render function. Co-Authored-By: Claude Sonnet 4.6 --- operator/chart/chart.go | 2 ++ operator/chart/rbac.go | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/operator/chart/chart.go b/operator/chart/chart.go index 0aff44291..bcd044191 100644 --- a/operator/chart/chart.go +++ b/operator/chart/chart.go @@ -63,6 +63,8 @@ func render(dot *helmette.Dot) []kube.Object { CRDJobServiceAccount(dot), PostUpgradeMigrationJob(dot), MigrationJobServiceAccount(dot), + PreDeleteFinalizerRemovalJob(dot), + PreDeleteFinalizerRemovalJobServiceAccount(dot), } for _, svc := range StretchClusterService(dot) { diff --git a/operator/chart/rbac.go b/operator/chart/rbac.go index 5031b11bf..3c115a1a0 100644 --- a/operator/chart/rbac.go +++ b/operator/chart/rbac.go @@ -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: PreDeleteFinalizerRemovalJobServiceAccountName(dot), + Enabled: values.FinalizerRemoval.Enabled, + Subject: PreDeleteFinalizerRemovalJobServiceAccountName(dot), + Annotations: map[string]string{ + "helm.sh/hook": "pre-delete", + "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded,hook-failed", + "helm.sh/hook-weight": "-10", + }, + RuleFiles: bundles[0].RuleFiles, + }) + return bundles } From d66a7f1768db997e096876f034552d3f1ff651d4 Mon Sep 17 00:00:00 2001 From: chrischapman Date: Thu, 26 Mar 2026 08:59:33 -0600 Subject: [PATCH 05/16] feat(operator/cmd): add finalizer-removal subcommand Adds the finalizer-removal CLI subcommand that lists all operator-managed CR types (RedpandaRole, User, Group, Topic, Schema, ShadowLink, Redpanda, NodePool) across all namespaces and removes the operator finalizer from each. If a CRD is not installed, the error is logged and skipped gracefully. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/finalizerremoval/finalizer_removal.go | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 operator/cmd/finalizerremoval/finalizer_removal.go diff --git a/operator/cmd/finalizerremoval/finalizer_removal.go b/operator/cmd/finalizerremoval/finalizer_removal.go new file mode 100644 index 000000000..39d9bda8e --- /dev/null +++ b/operator/cmd/finalizerremoval/finalizer_removal.go @@ -0,0 +1,137 @@ +// 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 + +// Package finalizerremoval contains a pre-delete job that removes finalizers +// from all operator-managed CRs to allow clean uninstall of the operator. +package finalizerremoval + +import ( + "context" + "errors" + "fmt" + "log" + + "github.com/spf13/cobra" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + redpandav1alpha2 "github.com/redpanda-data/redpanda-operator/operator/api/redpanda/v1alpha2" + "github.com/redpanda-data/redpanda-operator/operator/internal/controller" +) + +const finalizerKey = "operator.redpanda.com/finalizer" + +func Command() *cobra.Command { + return &cobra.Command{ + Use: "finalizer-removal", + Short: "Remove finalizers from operator-managed CRs to allow clean uninstall", + Run: func(cmd *cobra.Command, args []string) { + run(cmd.Context()) + }, + } +} + +func run(ctx context.Context) { + log.Printf("Removing finalizers from operator-managed CRs") + + k8sClient, err := client.New(ctrl.GetConfigOrDie(), client.Options{Scheme: controller.UnifiedScheme}) + if err != nil { + log.Fatalf("%s", fmt.Errorf("unable to create client: %w", err)) + } + + var errs []error + + errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.RedpandaRoleList{}, func(l *redpandav1alpha2.RedpandaRoleList) []client.Object { + out := make([]client.Object, len(l.Items)) + for i := range l.Items { + out[i] = &l.Items[i] + } + return out + })) + errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.UserList{}, func(l *redpandav1alpha2.UserList) []client.Object { + out := make([]client.Object, len(l.Items)) + for i := range l.Items { + out[i] = &l.Items[i] + } + return out + })) + errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.GroupList{}, func(l *redpandav1alpha2.GroupList) []client.Object { + out := make([]client.Object, len(l.Items)) + for i := range l.Items { + out[i] = &l.Items[i] + } + return out + })) + errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.TopicList{}, func(l *redpandav1alpha2.TopicList) []client.Object { + out := make([]client.Object, len(l.Items)) + for i := range l.Items { + out[i] = &l.Items[i] + } + return out + })) + errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.SchemaList{}, func(l *redpandav1alpha2.SchemaList) []client.Object { + out := make([]client.Object, len(l.Items)) + for i := range l.Items { + out[i] = &l.Items[i] + } + return out + })) + errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.ShadowLinkList{}, func(l *redpandav1alpha2.ShadowLinkList) []client.Object { + out := make([]client.Object, len(l.Items)) + for i := range l.Items { + out[i] = &l.Items[i] + } + return out + })) + errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.RedpandaList{}, func(l *redpandav1alpha2.RedpandaList) []client.Object { + out := make([]client.Object, len(l.Items)) + for i := range l.Items { + out[i] = &l.Items[i] + } + return out + })) + errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.NodePoolList{}, func(l *redpandav1alpha2.NodePoolList) []client.Object { + out := make([]client.Object, len(l.Items)) + for i := range l.Items { + out[i] = &l.Items[i] + } + return out + })) + + if err := errors.Join(errs...); err != nil { + log.Fatalf("%s", fmt.Errorf("errors while removing finalizers: %w", err)) + } + + log.Printf("Finalizer removal complete") +} + +func removeFinalizersForType[L client.ObjectList](ctx context.Context, k8sClient client.Client, list L, items func(L) []client.Object) error { + if err := k8sClient.List(ctx, list); err != nil { + // If the CRD isn't installed the API server returns a "no kind registered" or + // "resource not found" error. Log it and move on rather than failing the whole job. + log.Printf("skipping %T: %v", list, err) + return nil + } + + var errs []error + for _, obj := range items(list) { + if !controllerutil.ContainsFinalizer(obj, finalizerKey) { + continue + } + patch := client.MergeFrom(obj.DeepCopyObject().(client.Object)) + controllerutil.RemoveFinalizer(obj, finalizerKey) + if err := k8sClient.Patch(ctx, obj, patch); err != nil { + errs = append(errs, fmt.Errorf("patch %T %s/%s: %w", obj, obj.GetNamespace(), obj.GetName(), err)) + } else { + log.Printf("removed finalizer from %T %s/%s", obj, obj.GetNamespace(), obj.GetName()) + } + } + return errors.Join(errs...) +} From b331c65cfe9c6984de1a5442329523a11dcf5db6 Mon Sep 17 00:00:00 2001 From: chrischapman Date: Thu, 26 Mar 2026 09:02:29 -0600 Subject: [PATCH 06/16] feat(operator/cmd): add finalizer-removal subcommand Adds the finalizer-removal CLI subcommand that discovers all CR types in the operator's API groups (cluster.redpanda.com, redpanda.vectorized.io) from the scheme at runtime, then lists and removes the operator finalizer from each. New types are automatically picked up without code changes. If a CRD is not installed the error is logged and that type is skipped gracefully so the job succeeds even in partial-install scenarios. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/finalizerremoval/finalizer_removal.go | 108 +++++++----------- operator/cmd/main.go | 2 + 2 files changed, 45 insertions(+), 65 deletions(-) diff --git a/operator/cmd/finalizerremoval/finalizer_removal.go b/operator/cmd/finalizerremoval/finalizer_removal.go index 39d9bda8e..1d4ba8ee3 100644 --- a/operator/cmd/finalizerremoval/finalizer_removal.go +++ b/operator/cmd/finalizerremoval/finalizer_removal.go @@ -16,18 +16,26 @@ import ( "errors" "fmt" "log" + "strings" "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - redpandav1alpha2 "github.com/redpanda-data/redpanda-operator/operator/api/redpanda/v1alpha2" "github.com/redpanda-data/redpanda-operator/operator/internal/controller" ) const finalizerKey = "operator.redpanda.com/finalizer" +// operatorGroups are the API groups whose resources we manage and may have finalizers. +var operatorGroups = []string{ + "cluster.redpanda.com", + "redpanda.vectorized.io", +} + func Command() *cobra.Command { return &cobra.Command{ Use: "finalizer-removal", @@ -46,64 +54,26 @@ func run(ctx context.Context) { log.Fatalf("%s", fmt.Errorf("unable to create client: %w", err)) } - var errs []error - - errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.RedpandaRoleList{}, func(l *redpandav1alpha2.RedpandaRoleList) []client.Object { - out := make([]client.Object, len(l.Items)) - for i := range l.Items { - out[i] = &l.Items[i] - } - return out - })) - errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.UserList{}, func(l *redpandav1alpha2.UserList) []client.Object { - out := make([]client.Object, len(l.Items)) - for i := range l.Items { - out[i] = &l.Items[i] - } - return out - })) - errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.GroupList{}, func(l *redpandav1alpha2.GroupList) []client.Object { - out := make([]client.Object, len(l.Items)) - for i := range l.Items { - out[i] = &l.Items[i] - } - return out - })) - errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.TopicList{}, func(l *redpandav1alpha2.TopicList) []client.Object { - out := make([]client.Object, len(l.Items)) - for i := range l.Items { - out[i] = &l.Items[i] - } - return out - })) - errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.SchemaList{}, func(l *redpandav1alpha2.SchemaList) []client.Object { - out := make([]client.Object, len(l.Items)) - for i := range l.Items { - out[i] = &l.Items[i] - } - return out - })) - errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.ShadowLinkList{}, func(l *redpandav1alpha2.ShadowLinkList) []client.Object { - out := make([]client.Object, len(l.Items)) - for i := range l.Items { - out[i] = &l.Items[i] - } - return out - })) - errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.RedpandaList{}, func(l *redpandav1alpha2.RedpandaList) []client.Object { - out := make([]client.Object, len(l.Items)) - for i := range l.Items { - out[i] = &l.Items[i] + // Discover all non-list GVKs registered in our scheme for the operator groups. + // This automatically picks up any new types added in future without requiring + // changes to this command. + var gvks []schema.GroupVersionKind + for gvk := range controller.UnifiedScheme.AllKnownTypes() { + if strings.HasSuffix(gvk.Kind, "List") { + continue } - return out - })) - errs = append(errs, removeFinalizersForType(ctx, k8sClient, &redpandav1alpha2.NodePoolList{}, func(l *redpandav1alpha2.NodePoolList) []client.Object { - out := make([]client.Object, len(l.Items)) - for i := range l.Items { - out[i] = &l.Items[i] + for _, g := range operatorGroups { + if gvk.Group == g { + gvks = append(gvks, gvk) + break + } } - return out - })) + } + + var errs []error + for _, gvk := range gvks { + errs = append(errs, removeFinalizersForGVK(ctx, k8sClient, gvk)) + } if err := errors.Join(errs...); err != nil { log.Fatalf("%s", fmt.Errorf("errors while removing finalizers: %w", err)) @@ -112,25 +82,33 @@ func run(ctx context.Context) { log.Printf("Finalizer removal complete") } -func removeFinalizersForType[L client.ObjectList](ctx context.Context, k8sClient client.Client, list L, items func(L) []client.Object) error { +func removeFinalizersForGVK(ctx context.Context, k8sClient client.Client, gvk schema.GroupVersionKind) error { + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind + "List", + }) + if err := k8sClient.List(ctx, list); err != nil { - // If the CRD isn't installed the API server returns a "no kind registered" or - // "resource not found" error. Log it and move on rather than failing the whole job. - log.Printf("skipping %T: %v", list, err) + // If the CRD isn't installed, the API server returns a "no kind is registered" + // or "resource not found" error. Log it and continue rather than failing. + log.Printf("skipping %v: %v", gvk, err) return nil } var errs []error - for _, obj := range items(list) { + for i := range list.Items { + obj := &list.Items[i] if !controllerutil.ContainsFinalizer(obj, finalizerKey) { continue } - patch := client.MergeFrom(obj.DeepCopyObject().(client.Object)) + patch := client.MergeFrom(obj.DeepCopy()) controllerutil.RemoveFinalizer(obj, finalizerKey) if err := k8sClient.Patch(ctx, obj, patch); err != nil { - errs = append(errs, fmt.Errorf("patch %T %s/%s: %w", obj, obj.GetNamespace(), obj.GetName(), err)) + errs = append(errs, fmt.Errorf("patch %v %s/%s: %w", gvk, obj.GetNamespace(), obj.GetName(), err)) } else { - log.Printf("removed finalizer from %T %s/%s", obj, obj.GetNamespace(), obj.GetName()) + log.Printf("removed finalizer from %v %s/%s", gvk, obj.GetNamespace(), obj.GetName()) } } return errors.Join(errs...) diff --git a/operator/cmd/main.go b/operator/cmd/main.go index affdbf032..42d30072f 100644 --- a/operator/cmd/main.go +++ b/operator/cmd/main.go @@ -20,6 +20,7 @@ import ( "github.com/redpanda-data/redpanda-operator/operator/cmd/bootstrap" "github.com/redpanda-data/redpanda-operator/operator/cmd/configurator" "github.com/redpanda-data/redpanda-operator/operator/cmd/crd" + "github.com/redpanda-data/redpanda-operator/operator/cmd/finalizerremoval" "github.com/redpanda-data/redpanda-operator/operator/cmd/migration" "github.com/redpanda-data/redpanda-operator/operator/cmd/multicluster" "github.com/redpanda-data/redpanda-operator/operator/cmd/ready" @@ -57,6 +58,7 @@ func init() { syncclusterconfig.Command(), version.Command(), migration.Command(), + finalizerremoval.Command(), ) logOptions.BindFlags(rootCmd.PersistentFlags()) From 98397496b1f62b6411cfbbc8629124747f3ddda8 Mon Sep 17 00:00:00 2001 From: chrischapman Date: Thu, 26 Mar 2026 09:18:03 -0600 Subject: [PATCH 07/16] chore(operator/chart): regenerate gotohelm templates and schema Regenerates templates, schema, and partial values struct to include the new finalizerRemoval opt-in field and pre-delete hook job. Co-Authored-By: Claude Sonnet 4.6 --- operator/chart/templates/_chart.go.tpl | 2 +- .../_pre-delete-finalizer-removal-job.go.tpl | 30 +++++++++++++++++++ operator/chart/templates/_rbac.go.tpl | 1 + .../chart/templates/_serviceaccount.go.tpl | 26 ++++++++++++++++ operator/chart/values.schema.json | 9 ++++++ operator/chart/values_partial.gen.go | 5 ++++ 6 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 operator/chart/templates/_pre-delete-finalizer-removal-job.go.tpl diff --git a/operator/chart/templates/_chart.go.tpl b/operator/chart/templates/_chart.go.tpl index c247802ff..663588a57 100644 --- a/operator/chart/templates/_chart.go.tpl +++ b/operator/chart/templates/_chart.go.tpl @@ -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.PreDeleteFinalizerRemovalJob" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.PreDeleteFinalizerRemovalJobServiceAccount" (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 -}} diff --git a/operator/chart/templates/_pre-delete-finalizer-removal-job.go.tpl b/operator/chart/templates/_pre-delete-finalizer-removal-job.go.tpl new file mode 100644 index 000000000..84c60e6f6 --- /dev/null +++ b/operator/chart/templates/_pre-delete-finalizer-removal-job.go.tpl @@ -0,0 +1,30 @@ +{{- /* GENERATED FILE DO NOT EDIT */ -}} +{{- /* Transpiled by gotohelm from "github.com/redpanda-data/redpanda-operator/operator/chart/pre_delete_finalizer_removal_job.go" */ -}} + +{{- define "operator.PreDeleteFinalizerRemovalJob" -}} +{{- $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" "pre-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.PreDeleteFinalizerRemovalJobServiceAccountName" (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 -}} + diff --git a/operator/chart/templates/_rbac.go.tpl b/operator/chart/templates/_rbac.go.tpl index 9cde9d0e6..11846a3ea 100644 --- a/operator/chart/templates/_rbac.go.tpl +++ b/operator/chart/templates/_rbac.go.tpl @@ -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.PreDeleteFinalizerRemovalJobServiceAccountName" (dict "a" (list $dot)))) "r") "Enabled" $values.finalizerRemoval.enabled "Subject" (get (fromJson (include "operator.PreDeleteFinalizerRemovalJobServiceAccountName" (dict "a" (list $dot)))) "r") "Annotations" (dict "helm.sh/hook" "pre-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 -}} diff --git a/operator/chart/templates/_serviceaccount.go.tpl b/operator/chart/templates/_serviceaccount.go.tpl index caebddf39..f05244a90 100644 --- a/operator/chart/templates/_serviceaccount.go.tpl +++ b/operator/chart/templates/_serviceaccount.go.tpl @@ -32,6 +32,16 @@ {{- end -}} {{- end -}} +{{- define "operator.PreDeleteFinalizerRemovalJobServiceAccountName" -}} +{{- $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) -}} @@ -64,6 +74,22 @@ {{- end -}} {{- end -}} +{{- define "operator.PreDeleteFinalizerRemovalJobServiceAccount" -}} +{{- $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.PreDeleteFinalizerRemovalJobServiceAccountName" (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" "pre-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) -}} diff --git a/operator/chart/values.schema.json b/operator/chart/values.schema.json index 02062f8b1..75392ffc3 100644 --- a/operator/chart/values.schema.json +++ b/operator/chart/values.schema.json @@ -701,6 +701,15 @@ }, "type": "object" }, + "finalizerRemoval": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + } + }, + "type": "object" + }, "fullnameOverride": { "type": "string" }, diff --git a/operator/chart/values_partial.gen.go b/operator/chart/values_partial.gen.go index fa10c0110..3110d1474 100644 --- a/operator/chart/values_partial.gen.go +++ b/operator/chart/values_partial.gen.go @@ -49,6 +49,7 @@ type PartialValues struct { CRDs *PartialCRDs "json:\"crds,omitempty\"" VectorizedControllers *PartialVectorizedControllers "json:\"vectorizedControllers,omitempty\"" Multicluster *PartialMulticluster "json:\"multicluster,omitempty\"" + FinalizerRemoval *PartialFinalizerRemoval "json:\"finalizerRemoval,omitempty\"" } type PartialImage struct { @@ -103,6 +104,10 @@ type PartialMulticluster struct { Peers []PartialPeer "json:\"peers,omitempty\"" } +type PartialFinalizerRemoval struct { + Enabled *bool "json:\"enabled,omitempty\"" +} + type PartialEnterprise struct { LicenseSecretRef *corev1.SecretKeySelector "json:\"licenseSecretRef,omitempty\"" } From 6451366195fdab37352597a1d8fbaec0a7717dcd Mon Sep 17 00:00:00 2001 From: chrischapman Date: Thu, 26 Mar 2026 09:23:38 -0600 Subject: [PATCH 08/16] fix(operator): skip adding finalizer when namespace is terminating If a namespace is being deleted, the controller no longer adds the operator finalizer to CRs. Previously a re-created CR in a terminating namespace would have a finalizer added, blocking namespace deletion. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/redpanda/resource_controller.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/operator/internal/controller/redpanda/resource_controller.go b/operator/internal/controller/redpanda/resource_controller.go index 1a4150bc7..27fee9ca6 100644 --- a/operator/internal/controller/redpanda/resource_controller.go +++ b/operator/internal/controller/redpanda/resource_controller.go @@ -19,7 +19,9 @@ import ( "github.com/go-logr/logr" "github.com/redpanda-data/common-go/otelutil/log" apierrors "k8s.io/apimachinery/pkg/api/errors" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -120,6 +122,16 @@ func (r *ResourceController[T, U]) Reconcile(ctx context.Context, req mcreconcil return ctrl.Result{}, nil } + // Don't add the finalizer if the namespace is terminating — doing so would + // prevent the namespace from being deleted cleanly. + ns := &corev1.Namespace{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: object.GetNamespace()}, ns); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if !ns.GetDeletionTimestamp().IsZero() { + return ctrl.Result{}, nil + } + if !controllerutil.ContainsFinalizer(object, FinalizerKey) { patch := r.reconciler.FinalizerPatch(request) if patch != nil { From 6878afb2d422df004a6d4d75a4d9d5c5950b7079 Mon Sep 17 00:00:00 2001 From: chrischapman Date: Thu, 26 Mar 2026 09:29:53 -0600 Subject: [PATCH 09/16] test(operator): add test for skipping finalizer in terminating namespace Verifies that the reconciler does not add the operator finalizer when the object's namespace has DeletionTimestamp set. envtest has no namespace controller so deleting a namespace leaves its objects intact, exactly modelling the window the check guards against. Co-Authored-By: Claude Sonnet 4.6 --- .../redpanda/resource_controller_test.go | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/operator/internal/controller/redpanda/resource_controller_test.go b/operator/internal/controller/redpanda/resource_controller_test.go index 4f492bd3c..a206f46fa 100644 --- a/operator/internal/controller/redpanda/resource_controller_test.go +++ b/operator/internal/controller/redpanda/resource_controller_test.go @@ -607,6 +607,51 @@ func TestResourceController(t *testing.T) { // nolint:funlen // These tests have require.Equal(t, int32(size/2), reconciler.deletes.Load()) require.Equal(t, int32(size), reconciler.syncs.Load()) + + // Test that the finalizer is not added when the namespace is terminating. + // + // envtest does not run the namespace controller, so deleting a namespace sets + // its DeletionTimestamp without cascading to objects inside it. This lets us + // exercise the exact window the check guards: namespace is terminating but the + // object has not yet been marked for deletion. + t.Run("does not add finalizer when namespace is terminating", func(t *testing.T) { + // The namespace needs its own finalizer so it stays around (with + // DeletionTimestamp set) after we call Delete on it. + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-terminating", + Finalizers: []string{"test/protect"}, + }, + } + require.NoError(t, k8sClient.Create(ctx, ns)) + t.Cleanup(func() { + patch := client.MergeFrom(ns.DeepCopy()) + ns.Finalizers = nil + _ = k8sClient.Patch(ctx, ns, patch) + }) + + obj := &testObject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-finalizer-in-terminating-ns", + Namespace: ns.Name, + }, + } + require.NoError(t, k8sClient.Create(ctx, obj)) + + // Delete the namespace — sets DeletionTimestamp but, because envtest has no + // namespace controller, the object inside is not touched. + require.NoError(t, k8sClient.Delete(ctx, ns)) + require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(ns), ns)) + require.False(t, ns.GetDeletionTimestamp().IsZero(), "namespace should be terminating") + + key := client.ObjectKeyFromObject(obj) + req := mcreconcile.Request{Request: ctrl.Request{NamespacedName: key}, ClusterName: mcmanager.LocalCluster} + _, err = environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + + require.NoError(t, k8sClient.Get(ctx, key, obj)) + require.NotContains(t, obj.Finalizers, FinalizerKey, "finalizer must not be added when namespace is terminating") + }) } func TestIsNetworkDialError(t *testing.T) { From e2196d531c02248b091089aaaddaf979025003a2 Mon Sep 17 00:00:00 2001 From: chrischapman Date: Thu, 26 Mar 2026 09:33:00 -0600 Subject: [PATCH 10/16] chore: add changelog for finalizer removal fixes Co-Authored-By: Claude Sonnet 4.6 --- .changes/unreleased/operator-Fixed-20260326-000000.yaml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changes/unreleased/operator-Fixed-20260326-000000.yaml diff --git a/.changes/unreleased/operator-Fixed-20260326-000000.yaml b/.changes/unreleased/operator-Fixed-20260326-000000.yaml new file mode 100644 index 000000000..8841ad237 --- /dev/null +++ b/.changes/unreleased/operator-Fixed-20260326-000000.yaml @@ -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 pre-delete helm hook (`finalizerRemoval.enabled=true`) now strips operator finalizers from all managed CRs on uninstall. Additionally, the reconciler no longer adds finalizers to CRs in a terminating namespace, preventing the controller from re-blocking deletion in that window. +time: 2026-03-26T00:00:00.000000-05:00 From 27fc69786b1dcc6eca4efca87f2d5a3713c2f6ec Mon Sep 17 00:00:00 2001 From: chrischapman Date: Fri, 27 Mar 2026 13:34:40 -0600 Subject: [PATCH 11/16] refactor(operator/chart): switch finalizer removal to post-delete hook Running post-delete means the operator Deployment is already gone before finalizers are stripped, eliminating the race where the controller could re-add them. A failed job no longer blocks the uninstall since chart resources are already deleted by the time the hook runs. Co-Authored-By: Claude Sonnet 4.6 --- operator/chart/chart.go | 4 +-- ...o => post_delete_finalizer_removal_job.go} | 16 +++++----- operator/chart/rbac.go | 6 ++-- operator/chart/serviceaccount.go | 12 ++++---- operator/chart/templates/_chart.go.tpl | 2 +- .../_post-delete-finalizer-removal-job.go.tpl | 30 +++++++++++++++++++ operator/chart/templates/_rbac.go.tpl | 2 +- .../chart/templates/_serviceaccount.go.tpl | 6 ++-- 8 files changed, 54 insertions(+), 24 deletions(-) rename operator/chart/{pre_delete_finalizer_removal_job.go => post_delete_finalizer_removal_job.go} (81%) create mode 100644 operator/chart/templates/_post-delete-finalizer-removal-job.go.tpl diff --git a/operator/chart/chart.go b/operator/chart/chart.go index bcd044191..01884de16 100644 --- a/operator/chart/chart.go +++ b/operator/chart/chart.go @@ -63,8 +63,8 @@ func render(dot *helmette.Dot) []kube.Object { CRDJobServiceAccount(dot), PostUpgradeMigrationJob(dot), MigrationJobServiceAccount(dot), - PreDeleteFinalizerRemovalJob(dot), - PreDeleteFinalizerRemovalJobServiceAccount(dot), + PostDeleteFinalizerRemovalJob(dot), + PostDeleteFinalizerRemovalJobServiceAccount(dot), } for _, svc := range StretchClusterService(dot) { diff --git a/operator/chart/pre_delete_finalizer_removal_job.go b/operator/chart/post_delete_finalizer_removal_job.go similarity index 81% rename from operator/chart/pre_delete_finalizer_removal_job.go rename to operator/chart/post_delete_finalizer_removal_job.go index e712f43c9..2728e4f5c 100644 --- a/operator/chart/pre_delete_finalizer_removal_job.go +++ b/operator/chart/post_delete_finalizer_removal_job.go @@ -7,7 +7,7 @@ // the Business Source License, use of this software will be governed // by the Apache License, Version 2.0 -// +gotohelm:filename=_pre-delete-finalizer-removal-job.go.tpl +// +gotohelm:filename=_post-delete-finalizer-removal-job.go.tpl package operator import ( @@ -21,11 +21,11 @@ import ( "github.com/redpanda-data/redpanda-operator/gotohelm/helmette" ) -// PreDeleteFinalizerRemovalJob is a pre-delete hook job that removes finalizers -// from all operator-managed CRs before uninstall. This prevents orphaned -// resources from blocking namespace deletion when the operator is removed while -// CRs still exist. -func PreDeleteFinalizerRemovalJob(dot *helmette.Dot) *batchv1.Job { +// 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 { @@ -42,7 +42,7 @@ func PreDeleteFinalizerRemovalJob(dot *helmette.Dot) *batchv1.Job { Namespace: dot.Release.Namespace, Labels: helmette.Merge(Labels(dot)), Annotations: map[string]string{ - "helm.sh/hook": "pre-delete", + "helm.sh/hook": "post-delete", "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded,hook-failed", "helm.sh/hook-weight": "-5", }, @@ -58,7 +58,7 @@ func PreDeleteFinalizerRemovalJob(dot *helmette.Dot) *batchv1.Job { AutomountServiceAccountToken: ptr.To(false), TerminationGracePeriodSeconds: ptr.To(int64(10)), ImagePullSecrets: values.ImagePullSecrets, - ServiceAccountName: PreDeleteFinalizerRemovalJobServiceAccountName(dot), + ServiceAccountName: PostDeleteFinalizerRemovalJobServiceAccountName(dot), NodeSelector: values.NodeSelector, Tolerations: values.Tolerations, Volumes: []corev1.Volume{serviceAccountTokenVolume()}, diff --git a/operator/chart/rbac.go b/operator/chart/rbac.go index 3c115a1a0..2892e173c 100644 --- a/operator/chart/rbac.go +++ b/operator/chart/rbac.go @@ -95,11 +95,11 @@ func rbacBundles(dot *helmette.Dot) []RBACBundle { // 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: PreDeleteFinalizerRemovalJobServiceAccountName(dot), + Name: PostDeleteFinalizerRemovalJobServiceAccountName(dot), Enabled: values.FinalizerRemoval.Enabled, - Subject: PreDeleteFinalizerRemovalJobServiceAccountName(dot), + Subject: PostDeleteFinalizerRemovalJobServiceAccountName(dot), Annotations: map[string]string{ - "helm.sh/hook": "pre-delete", + "helm.sh/hook": "post-delete", "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded,hook-failed", "helm.sh/hook-weight": "-10", }, diff --git a/operator/chart/serviceaccount.go b/operator/chart/serviceaccount.go index 42feba4f7..3a9240ad3 100644 --- a/operator/chart/serviceaccount.go +++ b/operator/chart/serviceaccount.go @@ -32,7 +32,7 @@ func MigrationJobServiceAccountName(dot *helmette.Dot) string { return ServiceAccountName(dot) + "-migration-job" } -func PreDeleteFinalizerRemovalJobServiceAccountName(dot *helmette.Dot) string { +func PostDeleteFinalizerRemovalJobServiceAccountName(dot *helmette.Dot) string { return ServiceAccountName(dot) + "-finalizer-removal-job" } @@ -92,9 +92,9 @@ func CRDJobServiceAccount(dot *helmette.Dot) *corev1.ServiceAccount { } } -// PreDeleteFinalizerRemovalJobServiceAccount returns a ServiceAccount used by -// [PreDeleteFinalizerRemovalJob]. Helm will delete it after the job completes. -func PreDeleteFinalizerRemovalJobServiceAccount(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 { @@ -107,7 +107,7 @@ func PreDeleteFinalizerRemovalJobServiceAccount(dot *helmette.Dot) *corev1.Servi APIVersion: "v1", }, ObjectMeta: metav1.ObjectMeta{ - Name: PreDeleteFinalizerRemovalJobServiceAccountName(dot), + Name: PostDeleteFinalizerRemovalJobServiceAccountName(dot), Labels: Labels(dot), Namespace: dot.Release.Namespace, Annotations: helmette.Merge( @@ -116,7 +116,7 @@ func PreDeleteFinalizerRemovalJobServiceAccount(dot *helmette.Dot) *corev1.Servi values.ServiceAccount.Annotations, ), map[string]string{ - "helm.sh/hook": "pre-delete", + "helm.sh/hook": "post-delete", "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded,hook-failed", "helm.sh/hook-weight": "-10", }, diff --git a/operator/chart/templates/_chart.go.tpl b/operator/chart/templates/_chart.go.tpl index 663588a57..364220bf2 100644 --- a/operator/chart/templates/_chart.go.tpl +++ b/operator/chart/templates/_chart.go.tpl @@ -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") (get (fromJson (include "operator.PreDeleteFinalizerRemovalJob" (dict "a" (list $dot)))) "r") (get (fromJson (include "operator.PreDeleteFinalizerRemovalJobServiceAccount" (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 -}} diff --git a/operator/chart/templates/_post-delete-finalizer-removal-job.go.tpl b/operator/chart/templates/_post-delete-finalizer-removal-job.go.tpl new file mode 100644 index 000000000..eab65b6b4 --- /dev/null +++ b/operator/chart/templates/_post-delete-finalizer-removal-job.go.tpl @@ -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 -}} + diff --git a/operator/chart/templates/_rbac.go.tpl b/operator/chart/templates/_rbac.go.tpl index 11846a3ea..5dc161002 100644 --- a/operator/chart/templates/_rbac.go.tpl +++ b/operator/chart/templates/_rbac.go.tpl @@ -8,7 +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.PreDeleteFinalizerRemovalJobServiceAccountName" (dict "a" (list $dot)))) "r") "Enabled" $values.finalizerRemoval.enabled "Subject" (get (fromJson (include "operator.PreDeleteFinalizerRemovalJobServiceAccountName" (dict "a" (list $dot)))) "r") "Annotations" (dict "helm.sh/hook" "pre-delete" "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 -}} diff --git a/operator/chart/templates/_serviceaccount.go.tpl b/operator/chart/templates/_serviceaccount.go.tpl index f05244a90..4e61910dc 100644 --- a/operator/chart/templates/_serviceaccount.go.tpl +++ b/operator/chart/templates/_serviceaccount.go.tpl @@ -32,7 +32,7 @@ {{- end -}} {{- end -}} -{{- define "operator.PreDeleteFinalizerRemovalJobServiceAccountName" -}} +{{- define "operator.PostDeleteFinalizerRemovalJobServiceAccountName" -}} {{- $dot := (index .a 0) -}} {{- range $_ := (list 1) -}} {{- $_is_returning := false -}} @@ -74,7 +74,7 @@ {{- end -}} {{- end -}} -{{- define "operator.PreDeleteFinalizerRemovalJobServiceAccount" -}} +{{- define "operator.PostDeleteFinalizerRemovalJobServiceAccount" -}} {{- $dot := (index .a 0) -}} {{- range $_ := (list 1) -}} {{- $_is_returning := false -}} @@ -85,7 +85,7 @@ {{- 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.PreDeleteFinalizerRemovalJobServiceAccountName" (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" "pre-delete" "helm.sh/hook-delete-policy" "before-hook-creation,hook-succeeded,hook-failed" "helm.sh/hook-weight" "-10")))) "automountServiceAccountToken" false))) | toJson -}} +{{- (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 -}} From 0141375a857d28836fa1e0713833f579217eb821 Mon Sep 17 00:00:00 2001 From: david-yu Date: Fri, 27 Mar 2026 14:05:53 -0700 Subject: [PATCH 12/16] Fix lint: import ordering and generate chart README Swap import order of corev1 and apierrors in resource_controller.go to satisfy goimports, and add missing finalizerRemoval entries to the operator chart README. Co-Authored-By: Claude Opus 4.6 (1M context) --- operator/chart/README.md | 12 ++++++++++++ .../controller/redpanda/resource_controller.go | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/operator/chart/README.md b/operator/chart/README.md index f48c44479..e230c5140 100644 --- a/operator/chart/README.md +++ b/operator/chart/README.md @@ -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 pre-delete job that removes finalizers from all operator-managed CRs before 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 pre-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. diff --git a/operator/internal/controller/redpanda/resource_controller.go b/operator/internal/controller/redpanda/resource_controller.go index 27fee9ca6..7bf9ee92d 100644 --- a/operator/internal/controller/redpanda/resource_controller.go +++ b/operator/internal/controller/redpanda/resource_controller.go @@ -18,8 +18,8 @@ import ( "github.com/cockroachdb/errors" "github.com/go-logr/logr" "github.com/redpanda-data/common-go/otelutil/log" - apierrors "k8s.io/apimachinery/pkg/api/errors" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" From a8de6bb77f46a89d55e39f0a01f648c9e9818ed6 Mon Sep 17 00:00:00 2001 From: david-yu Date: Fri, 27 Mar 2026 19:20:17 -0700 Subject: [PATCH 13/16] ci: retrigger acceptance tests Co-Authored-By: Claude Opus 4.6 (1M context) From d2751c7acb0bfb916b9ee168c053a938fa3dee79 Mon Sep 17 00:00:00 2001 From: david-yu Date: Tue, 7 Apr 2026 15:16:13 -0700 Subject: [PATCH 14/16] fix: address review feedback on finalizer removal PR - Switch to post-delete hook (already done) and delete stale _pre-delete-finalizer-removal-job.go.tpl template - Use crds.All() instead of scheme reflection to discover operator CRD types for finalizer removal - Remove terminating namespace check from resource controller as the post-delete hook is the proper mechanism for orphaned CR cleanup - Remove corresponding terminating namespace test - Fix all pre-delete -> post-delete references in values.yaml, README, changelog, and package doc - Add envtest-based tests for finalizer removal covering orphaned CRs, multiple CR types, missing CRDs, and end-to-end deletion unblock Co-Authored-By: Claude Opus 4.6 (1M context) --- .../operator-Fixed-20260326-000000.yaml | 2 +- operator/chart/README.md | 4 +- .../_pre-delete-finalizer-removal-job.go.tpl | 30 ---- operator/chart/values.yaml | 6 +- .../cmd/finalizerremoval/finalizer_removal.go | 32 ++-- .../finalizer_removal_test.go | 157 ++++++++++++++++++ .../redpanda/resource_controller.go | 12 -- .../redpanda/resource_controller_test.go | 44 ----- 8 files changed, 175 insertions(+), 112 deletions(-) delete mode 100644 operator/chart/templates/_pre-delete-finalizer-removal-job.go.tpl create mode 100644 operator/cmd/finalizerremoval/finalizer_removal_test.go diff --git a/.changes/unreleased/operator-Fixed-20260326-000000.yaml b/.changes/unreleased/operator-Fixed-20260326-000000.yaml index 8841ad237..b93ac343b 100644 --- a/.changes/unreleased/operator-Fixed-20260326-000000.yaml +++ b/.changes/unreleased/operator-Fixed-20260326-000000.yaml @@ -1,4 +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 pre-delete helm hook (`finalizerRemoval.enabled=true`) now strips operator finalizers from all managed CRs on uninstall. Additionally, the reconciler no longer adds finalizers to CRs in a terminating namespace, preventing the controller from re-blocking deletion in that window. +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 diff --git a/operator/chart/README.md b/operator/chart/README.md index e230c5140..a10f67fe8 100644 --- a/operator/chart/README.md +++ b/operator/chart/README.md @@ -162,13 +162,13 @@ Secret name and key where the license key is stored. ### [finalizerRemoval](https://artifacthub.io/packages/helm/redpanda-data/operator?modal=values&path=finalizerRemoval) -Opt-in pre-delete job that removes finalizers from all operator-managed CRs before uninstall, preventing orphaned resources from blocking namespace deletion. +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 pre-delete finalizer removal job. +Enables the post-delete finalizer removal job. **Default:** `false` diff --git a/operator/chart/templates/_pre-delete-finalizer-removal-job.go.tpl b/operator/chart/templates/_pre-delete-finalizer-removal-job.go.tpl deleted file mode 100644 index 84c60e6f6..000000000 --- a/operator/chart/templates/_pre-delete-finalizer-removal-job.go.tpl +++ /dev/null @@ -1,30 +0,0 @@ -{{- /* GENERATED FILE DO NOT EDIT */ -}} -{{- /* Transpiled by gotohelm from "github.com/redpanda-data/redpanda-operator/operator/chart/pre_delete_finalizer_removal_job.go" */ -}} - -{{- define "operator.PreDeleteFinalizerRemovalJob" -}} -{{- $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" "pre-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.PreDeleteFinalizerRemovalJobServiceAccountName" (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 -}} - diff --git a/operator/chart/values.yaml b/operator/chart/values.yaml index dc8781232..1e4306555 100644 --- a/operator/chart/values.yaml +++ b/operator/chart/values.yaml @@ -204,8 +204,8 @@ multicluster: peers: [] servicePerOperatorDeployment: false -# -- Opt-in pre-delete job that removes finalizers from all operator-managed -# CRs before uninstall, preventing orphaned resources from blocking namespace deletion. +# -- Opt-in post-delete job that removes finalizers from all operator-managed +# CRs after uninstall, preventing orphaned resources from blocking namespace deletion. finalizerRemoval: - # -- Enables the pre-delete finalizer removal job. + # -- Enables the post-delete finalizer removal job. enabled: false diff --git a/operator/cmd/finalizerremoval/finalizer_removal.go b/operator/cmd/finalizerremoval/finalizer_removal.go index 1d4ba8ee3..46b3ef512 100644 --- a/operator/cmd/finalizerremoval/finalizer_removal.go +++ b/operator/cmd/finalizerremoval/finalizer_removal.go @@ -7,7 +7,7 @@ // the Business Source License, use of this software will be governed // by the Apache License, Version 2.0 -// Package finalizerremoval contains a pre-delete job that removes finalizers +// Package finalizerremoval contains a post-delete job that removes finalizers // from all operator-managed CRs to allow clean uninstall of the operator. package finalizerremoval @@ -16,7 +16,6 @@ import ( "errors" "fmt" "log" - "strings" "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -25,17 +24,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + crds "github.com/redpanda-data/redpanda-operator/operator/config/crd/bases" "github.com/redpanda-data/redpanda-operator/operator/internal/controller" ) const finalizerKey = "operator.redpanda.com/finalizer" -// operatorGroups are the API groups whose resources we manage and may have finalizers. -var operatorGroups = []string{ - "cluster.redpanda.com", - "redpanda.vectorized.io", -} - func Command() *cobra.Command { return &cobra.Command{ Use: "finalizer-removal", @@ -54,19 +48,17 @@ func run(ctx context.Context) { log.Fatalf("%s", fmt.Errorf("unable to create client: %w", err)) } - // Discover all non-list GVKs registered in our scheme for the operator groups. - // This automatically picks up any new types added in future without requiring - // changes to this command. + // Derive GVKs from the embedded CRD definitions. This automatically picks + // up any new types added in future without requiring changes to this command + // and avoids iterating over primitive Kubernetes types in the scheme. var gvks []schema.GroupVersionKind - for gvk := range controller.UnifiedScheme.AllKnownTypes() { - if strings.HasSuffix(gvk.Kind, "List") { - continue - } - for _, g := range operatorGroups { - if gvk.Group == g { - gvks = append(gvks, gvk) - break - } + for _, crd := range crds.All() { + for _, v := range crd.Spec.Versions { + gvks = append(gvks, schema.GroupVersionKind{ + Group: crd.Spec.Group, + Version: v.Name, + Kind: crd.Spec.Names.Kind, + }) } } diff --git a/operator/cmd/finalizerremoval/finalizer_removal_test.go b/operator/cmd/finalizerremoval/finalizer_removal_test.go new file mode 100644 index 000000000..804e6cd78 --- /dev/null +++ b/operator/cmd/finalizerremoval/finalizer_removal_test.go @@ -0,0 +1,157 @@ +// 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 + +package finalizerremoval + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + crds "github.com/redpanda-data/redpanda-operator/operator/config/crd/bases" + "github.com/redpanda-data/redpanda-operator/operator/internal/testutils" +) + +// TestRemoveFinalizersForGVK exercises the post-delete finalizer removal logic +// that runs as a helm hook after the operator is uninstalled. The primary +// scenario is orphaned CRs: the operator has been removed but CRs still carry +// the operator.redpanda.com/finalizer, which blocks Kubernetes from garbage +// collecting them and prevents namespace deletion from completing. +// +// Covered scenarios: +// - Orphaned CRs have their operator finalizer stripped so they no longer +// block garbage collection. +// - Multiple CR types (Topic, User, etc.) are handled independently, +// reflecting real deployments where several resource kinds coexist. +// - CRs that never had the operator finalizer are left untouched. +// - GVKs whose CRDs are not installed on the cluster are skipped gracefully, +// which happens when the operator manages types the cluster never used. +// - After finalizer removal, CRs can be deleted and the namespace teardown +// is no longer blocked. +func TestRemoveFinalizersForGVK(t *testing.T) { + ctx := context.Background() + testEnv := testutils.RedpandaTestEnv{} + cfg, err := testEnv.StartRedpandaTestEnv(false) + require.NoError(t, err) + + k8sClient, err := client.New(cfg, client.Options{}) + require.NoError(t, err) + + topicCRD := crds.Topic() + gvk := schema.GroupVersionKind{ + Group: topicCRD.Spec.Group, + Version: topicCRD.Spec.Versions[0].Name, + Kind: topicCRD.Spec.Names.Kind, + } + + userCRD := crds.User() + userGVK := schema.GroupVersionKind{ + Group: userCRD.Spec.Group, + Version: userCRD.Spec.Versions[0].Name, + Kind: userCRD.Spec.Names.Kind, + } + + // Helper to create an unstructured CR with the operator finalizer. + createCR := func(t *testing.T, gvk schema.GroupVersionKind, name, ns string) *unstructured.Unstructured { + t.Helper() + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + obj.SetName(name) + obj.SetNamespace(ns) + // Set required spec field so the API server accepts the object. + _ = unstructured.SetNestedMap(obj.Object, map[string]interface{}{}, "spec") + require.NoError(t, k8sClient.Create(ctx, obj)) + // Add finalizer via patch after creation. + patch := client.MergeFrom(obj.DeepCopy()) + controllerutil.AddFinalizer(obj, finalizerKey) + require.NoError(t, k8sClient.Patch(ctx, obj, patch)) + return obj + } + + // Orphaned CR: the operator is gone but the CR still carries the finalizer. + // The post-delete hook must strip it so the object is no longer stuck. + t.Run("removes finalizer from orphaned CRs", func(t *testing.T) { + topic := createCR(t, gvk, "orphaned-topic", "default") + + require.True(t, controllerutil.ContainsFinalizer(topic, finalizerKey)) + + require.NoError(t, removeFinalizersForGVK(ctx, k8sClient, gvk)) + + require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(topic), topic)) + require.False(t, controllerutil.ContainsFinalizer(topic, finalizerKey)) + }) + + // Real deployments typically have several CR types (Topics, Users, Roles, + // Schemas, etc.) all with orphaned finalizers after operator uninstall. + // Each type must be handled independently. + t.Run("removes finalizers from multiple CR types", func(t *testing.T) { + topic := createCR(t, gvk, "multi-topic", "default") + user := createCR(t, userGVK, "multi-user", "default") + + require.NoError(t, removeFinalizersForGVK(ctx, k8sClient, gvk)) + require.NoError(t, removeFinalizersForGVK(ctx, k8sClient, userGVK)) + + require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(topic), topic)) + require.False(t, controllerutil.ContainsFinalizer(topic, finalizerKey)) + + require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(user), user)) + require.False(t, controllerutil.ContainsFinalizer(user, finalizerKey)) + }) + + // CRs that were created but never reconciled by the operator (or whose + // finalizer was already removed) must not be modified. + t.Run("skips CRs without the operator finalizer", func(t *testing.T) { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + obj.SetName("no-finalizer-topic") + obj.SetNamespace("default") + _ = unstructured.SetNestedMap(obj.Object, map[string]interface{}{}, "spec") + require.NoError(t, k8sClient.Create(ctx, obj)) + + require.NoError(t, removeFinalizersForGVK(ctx, k8sClient, gvk)) + + require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(obj), obj)) + require.Empty(t, obj.GetFinalizers()) + }) + + // The operator manages many CRD types but not all may be installed on a + // given cluster. When the API server doesn't recognize a GVK the removal + // should log and continue rather than failing the entire job. + t.Run("skips unknown GVK without error", func(t *testing.T) { + unknownGVK := schema.GroupVersionKind{ + Group: "nonexistent.redpanda.com", + Version: "v1", + Kind: "Fake", + } + require.NoError(t, removeFinalizersForGVK(ctx, k8sClient, unknownGVK)) + }) + + // End-to-end: a CR with a finalizer cannot be garbage collected. After + // the finalizer is removed the CR should be deletable, which unblocks + // namespace deletion. + t.Run("orphaned CRs deletable after finalizer removal", func(t *testing.T) { + topic := createCR(t, gvk, "deletable-topic", "default") + + require.NoError(t, removeFinalizersForGVK(ctx, k8sClient, gvk)) + + require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(topic), topic)) + require.False(t, controllerutil.ContainsFinalizer(topic, finalizerKey)) + + // The object should now be deletable without the finalizer blocking. + require.NoError(t, k8sClient.Delete(ctx, topic)) + + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(topic), topic) + require.True(t, client.IgnoreNotFound(err) == nil, "object should be deleted") + }) +} diff --git a/operator/internal/controller/redpanda/resource_controller.go b/operator/internal/controller/redpanda/resource_controller.go index 7bf9ee92d..1a4150bc7 100644 --- a/operator/internal/controller/redpanda/resource_controller.go +++ b/operator/internal/controller/redpanda/resource_controller.go @@ -18,10 +18,8 @@ import ( "github.com/cockroachdb/errors" "github.com/go-logr/logr" "github.com/redpanda-data/common-go/otelutil/log" - corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -122,16 +120,6 @@ func (r *ResourceController[T, U]) Reconcile(ctx context.Context, req mcreconcil return ctrl.Result{}, nil } - // Don't add the finalizer if the namespace is terminating — doing so would - // prevent the namespace from being deleted cleanly. - ns := &corev1.Namespace{} - if err := k8sClient.Get(ctx, types.NamespacedName{Name: object.GetNamespace()}, ns); err != nil { - return ctrl.Result{}, client.IgnoreNotFound(err) - } - if !ns.GetDeletionTimestamp().IsZero() { - return ctrl.Result{}, nil - } - if !controllerutil.ContainsFinalizer(object, FinalizerKey) { patch := r.reconciler.FinalizerPatch(request) if patch != nil { diff --git a/operator/internal/controller/redpanda/resource_controller_test.go b/operator/internal/controller/redpanda/resource_controller_test.go index a206f46fa..7be75914c 100644 --- a/operator/internal/controller/redpanda/resource_controller_test.go +++ b/operator/internal/controller/redpanda/resource_controller_test.go @@ -608,50 +608,6 @@ func TestResourceController(t *testing.T) { // nolint:funlen // These tests have require.Equal(t, int32(size/2), reconciler.deletes.Load()) require.Equal(t, int32(size), reconciler.syncs.Load()) - // Test that the finalizer is not added when the namespace is terminating. - // - // envtest does not run the namespace controller, so deleting a namespace sets - // its DeletionTimestamp without cascading to objects inside it. This lets us - // exercise the exact window the check guards: namespace is terminating but the - // object has not yet been marked for deletion. - t.Run("does not add finalizer when namespace is terminating", func(t *testing.T) { - // The namespace needs its own finalizer so it stays around (with - // DeletionTimestamp set) after we call Delete on it. - ns := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-terminating", - Finalizers: []string{"test/protect"}, - }, - } - require.NoError(t, k8sClient.Create(ctx, ns)) - t.Cleanup(func() { - patch := client.MergeFrom(ns.DeepCopy()) - ns.Finalizers = nil - _ = k8sClient.Patch(ctx, ns, patch) - }) - - obj := &testObject{ - ObjectMeta: metav1.ObjectMeta{ - Name: "no-finalizer-in-terminating-ns", - Namespace: ns.Name, - }, - } - require.NoError(t, k8sClient.Create(ctx, obj)) - - // Delete the namespace — sets DeletionTimestamp but, because envtest has no - // namespace controller, the object inside is not touched. - require.NoError(t, k8sClient.Delete(ctx, ns)) - require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(ns), ns)) - require.False(t, ns.GetDeletionTimestamp().IsZero(), "namespace should be terminating") - - key := client.ObjectKeyFromObject(obj) - req := mcreconcile.Request{Request: ctrl.Request{NamespacedName: key}, ClusterName: mcmanager.LocalCluster} - _, err = environment.Reconciler.Reconcile(ctx, req) - require.NoError(t, err) - - require.NoError(t, k8sClient.Get(ctx, key, obj)) - require.NotContains(t, obj.Finalizers, FinalizerKey, "finalizer must not be added when namespace is terminating") - }) } func TestIsNetworkDialError(t *testing.T) { From b3c1ea485ead393876fbac7eb9c635e715bb8f49 Mon Sep 17 00:00:00 2001 From: david-yu Date: Tue, 7 Apr 2026 15:31:17 -0700 Subject: [PATCH 15/16] fix: address CI test and lint failures - Set spec.cluster.clusterRef.name on test CRs so they pass CRD validation (Topic/User require cluster or kafkaApiSpec) - Remove trailing blank line in resource_controller_test.go that caused git diff --exit-code lint failure Co-Authored-By: Claude Opus 4.6 (1M context) --- operator/cmd/finalizerremoval/finalizer_removal_test.go | 6 +++--- .../controller/redpanda/resource_controller_test.go | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/operator/cmd/finalizerremoval/finalizer_removal_test.go b/operator/cmd/finalizerremoval/finalizer_removal_test.go index 804e6cd78..ff99560a5 100644 --- a/operator/cmd/finalizerremoval/finalizer_removal_test.go +++ b/operator/cmd/finalizerremoval/finalizer_removal_test.go @@ -69,8 +69,8 @@ func TestRemoveFinalizersForGVK(t *testing.T) { obj.SetGroupVersionKind(gvk) obj.SetName(name) obj.SetNamespace(ns) - // Set required spec field so the API server accepts the object. - _ = unstructured.SetNestedMap(obj.Object, map[string]interface{}{}, "spec") + // CRDs require spec.cluster.clusterRef to pass validation. + _ = unstructured.SetNestedField(obj.Object, "dummy", "spec", "cluster", "clusterRef", "name") require.NoError(t, k8sClient.Create(ctx, obj)) // Add finalizer via patch after creation. patch := client.MergeFrom(obj.DeepCopy()) @@ -116,7 +116,7 @@ func TestRemoveFinalizersForGVK(t *testing.T) { obj.SetGroupVersionKind(gvk) obj.SetName("no-finalizer-topic") obj.SetNamespace("default") - _ = unstructured.SetNestedMap(obj.Object, map[string]interface{}{}, "spec") + _ = unstructured.SetNestedField(obj.Object, "dummy", "spec", "cluster", "clusterRef", "name") require.NoError(t, k8sClient.Create(ctx, obj)) require.NoError(t, removeFinalizersForGVK(ctx, k8sClient, gvk)) diff --git a/operator/internal/controller/redpanda/resource_controller_test.go b/operator/internal/controller/redpanda/resource_controller_test.go index 7be75914c..4f492bd3c 100644 --- a/operator/internal/controller/redpanda/resource_controller_test.go +++ b/operator/internal/controller/redpanda/resource_controller_test.go @@ -607,7 +607,6 @@ func TestResourceController(t *testing.T) { // nolint:funlen // These tests have require.Equal(t, int32(size/2), reconciler.deletes.Load()) require.Equal(t, int32(size), reconciler.syncs.Load()) - } func TestIsNetworkDialError(t *testing.T) { From 63ffeb66402ee0d9751122ae9aa9559d1cb7fd9f Mon Sep 17 00:00:00 2001 From: david-yu Date: Thu, 9 Apr 2026 21:49:48 -0700 Subject: [PATCH 16/16] fix: add template test case for finalizerRemoval.enabled Add a TestTemplate test case that exercises the finalizerRemoval toggle, ensuring the post-delete Job, ServiceAccount, ClusterRole, and ClusterRoleBinding are rendered correctly when enabled. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../testdata/template-cases.golden.txtar | 1927 +++++++++++++++++ operator/chart/testdata/template-cases.txtar | 4 + 2 files changed, 1931 insertions(+) diff --git a/operator/chart/testdata/template-cases.golden.txtar b/operator/chart/testdata/template-cases.golden.txtar index 2e6d6f1d9..29069e1a4 100644 --- a/operator/chart/testdata/template-cases.golden.txtar +++ b/operator/chart/testdata/template-cases.golden.txtar @@ -81482,6 +81482,1933 @@ spec: apiVersion: v1 fieldPath: metadata.namespace path: namespace +-- testdata/finalizer-removal-enabled.yaml.golden -- +--- +# Source: operator/templates/entry-point.yaml +apiVersion: v1 +automountServiceAccountToken: false +kind: ServiceAccount +metadata: + annotations: null + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator + namespace: default +--- +# Source: operator/templates/entry-point.yaml +apiVersion: v1 +data: + controller_manager_config.yaml: |- + apiVersion: controller-runtime.sigs.k8s.io/v1alpha1 + health: + healthProbeBindAddress: :8081 + kind: ControllerManagerConfig + leaderElection: + leaderElect: true + resourceName: aa9fc693.vectorized.io + metrics: + bindAddress: 127.0.0.1:8080 + webhook: + port: 9443 +kind: ConfigMap +metadata: + annotations: null + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-config + namespace: default +--- +# Source: operator/templates/entry-point.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + annotations: null + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-default-metrics-reader +rules: +- nonResourceURLs: + - /metrics + verbs: + - get +--- +# Source: operator/templates/entry-point.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-default +rules: +- apiGroups: + - "" + resources: + - configmaps + - secrets + - serviceaccounts + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - autoscaling + resources: + - horizontalpodautoscalers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - consoles + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - consoles/status + verbs: + - get + - patch + - update +- apiGroups: + - monitoring.coreos.com + resources: + - servicemonitors + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.k8s.io + resources: + - ingresses + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +- apiGroups: + - "" + resources: + - configmaps + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - persistentvolumes + verbs: + - get + - list + - patch + - watch +- apiGroups: + - "" + resources: + - persistentvolumeclaims + - pods + verbs: + - delete + - get + - list + - watch +- apiGroups: + - "" + resources: + - nodes + verbs: + - get +- apiGroups: + - "" + resources: + - configmaps + - endpoints + - events + - limitranges + - persistentvolumeclaims + - pods + - pods/log + - replicationcontrollers + - resourcequotas + - serviceaccounts + - services + verbs: + - get + - list +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - configmaps + - endpoints + - pods + - secrets + - serviceaccounts + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - controllerrevisions + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - deployments + - statefulsets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - autoscaling + resources: + - horizontalpodautoscalers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - batch + resources: + - jobs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cert-manager.io + resources: + - certificates + - issuers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - consoles + - nodepools + - redpandas + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - groups + - redpandaroles + - schemas + - shadowlinks + - stretchclusters + - topics + - users + verbs: + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - groups/finalizers + - nodepools/finalizers + - redpandaroles/finalizers + - redpandas/finalizers + - schemas/finalizers + - shadowlinks/finalizers + - stretchclusters/finalizers + - topics/finalizers + - users/finalizers + verbs: + - update +- apiGroups: + - cluster.redpanda.com + resources: + - groups/status + - nodepools/status + - redpandaroles/status + - redpandas/status + - schemas/status + - shadowlinks/status + - stretchclusters/status + - topics/status + - users/status + verbs: + - get + - patch + - update +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - discovery.k8s.io + resources: + - endpointslices + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - monitoring.coreos.com + resources: + - podmonitors + - servicemonitors + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - multicluster.x-k8s.io + resources: + - serviceexports + - serviceimports + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.k8s.io + resources: + - ingresses + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - policy + resources: + - poddisruptionbudgets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - rbac.authorization.k8s.io + resources: + - clusterrolebindings + - clusterroles + - rolebindings + - roles + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +--- +# Source: operator/templates/entry-point.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-additional-controllers-default +rules: +- apiGroups: + - "" + resources: + - persistentvolumes + verbs: + - patch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - "" + resources: + - persistentvolumeclaims + verbs: + - delete + - get + - list + - watch +- apiGroups: + - "" + resources: + - pods + - secrets + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - statefulsets + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - configmaps + - nodes + - secrets + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - persistentvolumes + verbs: + - delete + - get + - list + - patch + - update +- apiGroups: + - cluster.redpanda.com + resources: + - redpandas + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - persistentvolumeclaims + verbs: + - delete + - get + - list + - patch + - update +- apiGroups: + - "" + resources: + - configmaps + - pods + - secrets + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - persistentvolumeclaims + verbs: + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - persistentvolumes + verbs: + - get + - list + - patch + - update + - watch +- apiGroups: + - apps + resources: + - statefulsets/status + verbs: + - patch + - update +- apiGroups: + - cluster.redpanda.com + resources: + - redpandas + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - persistentvolumes + verbs: + - get + - list + - patch + - watch +- apiGroups: + - "" + resources: + - persistentvolumeclaims + - pods + verbs: + - delete + - get + - list + - watch +--- +# Source: operator/templates/entry-point.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: operator-default +subjects: +- kind: ServiceAccount + name: operator + namespace: default +--- +# Source: operator/templates/entry-point.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-additional-controllers-default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: operator-additional-controllers-default +subjects: +- kind: ServiceAccount + name: operator + namespace: default +--- +# Source: operator/templates/entry-point.yaml +apiVersion: v1 +kind: Service +metadata: + annotations: null + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-metrics-service + namespace: default +spec: + ports: + - name: https + port: 8443 + targetPort: https + selector: + app.kubernetes.io/instance: operator + app.kubernetes.io/name: operator +--- +# Source: operator/templates/entry-point.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: null + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: operator + app.kubernetes.io/name: operator + strategy: + type: RollingUpdate + template: + metadata: + annotations: {} + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/name: operator + spec: + automountServiceAccountToken: false + containers: + - args: + - --configurator-base-image=docker.redpanda.com/redpandadata/redpanda-operator + - --configurator-tag=v26.1.1 + - --enable-console=true + - --enable-vectorized-controllers=false + - --health-probe-bind-address=:8081 + - --leader-elect + - --log-level=info + - --metrics-bind-address=:8443 + - --webhook-enabled=false + command: + - /manager + env: [] + image: docker.redpanda.com/redpandadata/redpanda-operator:v26.1.1 + imagePullPolicy: IfNotPresent + livenessProbe: + httpGet: + path: /healthz/ + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + name: manager + ports: + - containerPort: 9443 + name: webhook-server + protocol: TCP + - containerPort: 8443 + name: https + protocol: TCP + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: {} + securityContext: + allowPrivilegeEscalation: false + volumeMounts: + - mountPath: /var/run/secrets/kubernetes.io/serviceaccount + name: kube-api-access + readOnly: true + ephemeralContainers: null + imagePullSecrets: [] + initContainers: [] + nodeSelector: {} + securityContext: + runAsUser: 65532 + serviceAccountName: operator + terminationGracePeriodSeconds: 10 + tolerations: [] + volumes: + - name: kube-api-access + projected: + defaultMode: 420 + sources: + - serviceAccountToken: + expirationSeconds: 3607 + path: token + - configMap: + items: + - key: ca.crt + path: ca.crt + name: kube-root-ca.crt + - downwardAPI: + items: + - fieldRef: + apiVersion: v1 + fieldPath: metadata.namespace + path: namespace +--- +# Source: operator/templates/entry-point.yaml +apiVersion: v1 +automountServiceAccountToken: false +kind: ServiceAccount +metadata: + annotations: + helm.sh/hook: post-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded,hook-failed + helm.sh/hook-weight: "-10" + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-migration-job + namespace: default +--- +# Source: operator/templates/entry-point.yaml +apiVersion: v1 +automountServiceAccountToken: false +kind: ServiceAccount +metadata: + annotations: + helm.sh/hook: post-delete + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded,hook-failed + helm.sh/hook-weight: "-10" + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-finalizer-removal-job + namespace: default +--- +# Source: operator/templates/entry-point.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + annotations: + helm.sh/hook: post-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded,hook-failed + helm.sh/hook-weight: "-10" + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-migration-job-default +rules: +- apiGroups: + - "" + resources: + - configmaps + - secrets + - serviceaccounts + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - autoscaling + resources: + - horizontalpodautoscalers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - consoles + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - consoles/status + verbs: + - get + - patch + - update +- apiGroups: + - monitoring.coreos.com + resources: + - servicemonitors + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.k8s.io + resources: + - ingresses + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +- apiGroups: + - "" + resources: + - configmaps + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - persistentvolumes + verbs: + - get + - list + - patch + - watch +- apiGroups: + - "" + resources: + - persistentvolumeclaims + - pods + verbs: + - delete + - get + - list + - watch +- apiGroups: + - "" + resources: + - nodes + verbs: + - get +- apiGroups: + - "" + resources: + - configmaps + - endpoints + - events + - limitranges + - persistentvolumeclaims + - pods + - pods/log + - replicationcontrollers + - resourcequotas + - serviceaccounts + - services + verbs: + - get + - list +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - configmaps + - endpoints + - pods + - secrets + - serviceaccounts + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - controllerrevisions + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - deployments + - statefulsets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - autoscaling + resources: + - horizontalpodautoscalers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - batch + resources: + - jobs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cert-manager.io + resources: + - certificates + - issuers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - consoles + - nodepools + - redpandas + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - groups + - redpandaroles + - schemas + - shadowlinks + - stretchclusters + - topics + - users + verbs: + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - groups/finalizers + - nodepools/finalizers + - redpandaroles/finalizers + - redpandas/finalizers + - schemas/finalizers + - shadowlinks/finalizers + - stretchclusters/finalizers + - topics/finalizers + - users/finalizers + verbs: + - update +- apiGroups: + - cluster.redpanda.com + resources: + - groups/status + - nodepools/status + - redpandaroles/status + - redpandas/status + - schemas/status + - shadowlinks/status + - stretchclusters/status + - topics/status + - users/status + verbs: + - get + - patch + - update +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - discovery.k8s.io + resources: + - endpointslices + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - monitoring.coreos.com + resources: + - podmonitors + - servicemonitors + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - multicluster.x-k8s.io + resources: + - serviceexports + - serviceimports + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.k8s.io + resources: + - ingresses + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - policy + resources: + - poddisruptionbudgets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - rbac.authorization.k8s.io + resources: + - clusterrolebindings + - clusterroles + - rolebindings + - roles + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +--- +# Source: operator/templates/entry-point.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + annotations: + helm.sh/hook: post-delete + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded,hook-failed + helm.sh/hook-weight: "-10" + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-finalizer-removal-job-default +rules: +- apiGroups: + - "" + resources: + - configmaps + - secrets + - serviceaccounts + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - autoscaling + resources: + - horizontalpodautoscalers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - consoles + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - consoles/status + verbs: + - get + - patch + - update +- apiGroups: + - monitoring.coreos.com + resources: + - servicemonitors + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.k8s.io + resources: + - ingresses + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +- apiGroups: + - "" + resources: + - configmaps + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - persistentvolumes + verbs: + - get + - list + - patch + - watch +- apiGroups: + - "" + resources: + - persistentvolumeclaims + - pods + verbs: + - delete + - get + - list + - watch +- apiGroups: + - "" + resources: + - nodes + verbs: + - get +- apiGroups: + - "" + resources: + - configmaps + - endpoints + - events + - limitranges + - persistentvolumeclaims + - pods + - pods/log + - replicationcontrollers + - resourcequotas + - serviceaccounts + - services + verbs: + - get + - list +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - configmaps + - endpoints + - pods + - secrets + - serviceaccounts + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - controllerrevisions + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - deployments + - statefulsets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - autoscaling + resources: + - horizontalpodautoscalers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - batch + resources: + - jobs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cert-manager.io + resources: + - certificates + - issuers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - consoles + - nodepools + - redpandas + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - groups + - redpandaroles + - schemas + - shadowlinks + - stretchclusters + - topics + - users + verbs: + - get + - list + - patch + - update + - watch +- apiGroups: + - cluster.redpanda.com + resources: + - groups/finalizers + - nodepools/finalizers + - redpandaroles/finalizers + - redpandas/finalizers + - schemas/finalizers + - shadowlinks/finalizers + - stretchclusters/finalizers + - topics/finalizers + - users/finalizers + verbs: + - update +- apiGroups: + - cluster.redpanda.com + resources: + - groups/status + - nodepools/status + - redpandaroles/status + - redpandas/status + - schemas/status + - shadowlinks/status + - stretchclusters/status + - topics/status + - users/status + verbs: + - get + - patch + - update +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - discovery.k8s.io + resources: + - endpointslices + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - monitoring.coreos.com + resources: + - podmonitors + - servicemonitors + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - multicluster.x-k8s.io + resources: + - serviceexports + - serviceimports + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.k8s.io + resources: + - ingresses + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - policy + resources: + - poddisruptionbudgets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - rbac.authorization.k8s.io + resources: + - clusterrolebindings + - clusterroles + - rolebindings + - roles + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +--- +# Source: operator/templates/entry-point.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + annotations: + helm.sh/hook: post-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded,hook-failed + helm.sh/hook-weight: "-10" + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-migration-job-default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: operator-migration-job-default +subjects: +- kind: ServiceAccount + name: operator-migration-job + namespace: default +--- +# Source: operator/templates/entry-point.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + annotations: + helm.sh/hook: post-delete + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded,hook-failed + helm.sh/hook-weight: "-10" + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-finalizer-removal-job-default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: operator-finalizer-removal-job-default +subjects: +- kind: ServiceAccount + name: operator-finalizer-removal-job + namespace: default +--- +# Source: operator/templates/entry-point.yaml +apiVersion: batch/v1 +kind: Job +metadata: + annotations: + helm.sh/hook: post-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded,hook-failed + helm.sh/hook-weight: "-4" + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-migration + namespace: default +spec: + template: + metadata: + annotations: {} + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/name: operator + spec: + automountServiceAccountToken: false + containers: + - args: + - migration + command: + - /redpanda-operator + image: docker.redpanda.com/redpandadata/redpanda-operator:v26.1.1 + imagePullPolicy: IfNotPresent + name: migration + resources: {} + securityContext: + allowPrivilegeEscalation: false + volumeMounts: + - mountPath: /var/run/secrets/kubernetes.io/serviceaccount + name: kube-api-access + readOnly: true + imagePullSecrets: [] + nodeSelector: {} + restartPolicy: OnFailure + serviceAccountName: operator-migration-job + terminationGracePeriodSeconds: 10 + tolerations: [] + volumes: + - name: kube-api-access + projected: + defaultMode: 420 + sources: + - serviceAccountToken: + expirationSeconds: 3607 + path: token + - configMap: + items: + - key: ca.crt + path: ca.crt + name: kube-root-ca.crt + - downwardAPI: + items: + - fieldRef: + apiVersion: v1 + fieldPath: metadata.namespace + path: namespace +--- +# Source: operator/templates/entry-point.yaml +apiVersion: batch/v1 +kind: Job +metadata: + annotations: + helm.sh/hook: post-delete + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded,hook-failed + helm.sh/hook-weight: "-5" + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: operator + app.kubernetes.io/version: v26.1.1 + helm.sh/chart: operator-26.1.1 + name: operator-finalizer-removal + namespace: default +spec: + template: + metadata: + annotations: {} + labels: + app.kubernetes.io/instance: operator + app.kubernetes.io/name: operator + spec: + automountServiceAccountToken: false + containers: + - args: + - finalizer-removal + command: + - /redpanda-operator + image: docker.redpanda.com/redpandadata/redpanda-operator:v26.1.1 + imagePullPolicy: IfNotPresent + name: finalizer-removal + resources: {} + securityContext: + allowPrivilegeEscalation: false + volumeMounts: + - mountPath: /var/run/secrets/kubernetes.io/serviceaccount + name: kube-api-access + readOnly: true + imagePullSecrets: [] + nodeSelector: {} + restartPolicy: OnFailure + serviceAccountName: operator-finalizer-removal-job + terminationGracePeriodSeconds: 10 + tolerations: [] + volumes: + - name: kube-api-access + projected: + defaultMode: 420 + sources: + - serviceAccountToken: + expirationSeconds: 3607 + path: token + - configMap: + items: + - key: ca.crt + path: ca.crt + name: kube-root-ca.crt + - downwardAPI: + items: + - fieldRef: + apiVersion: v1 + fieldPath: metadata.namespace + path: namespace -- testdata/license-with-key.yaml.golden -- --- # Source: operator/templates/entry-point.yaml diff --git a/operator/chart/testdata/template-cases.txtar b/operator/chart/testdata/template-cases.txtar index 599602f33..424143a0d 100644 --- a/operator/chart/testdata/template-cases.txtar +++ b/operator/chart/testdata/template-cases.txtar @@ -136,3 +136,7 @@ multicluster: enterprise: licenseSecretRef: name: my-secret + +-- finalizer-removal-enabled -- +finalizerRemoval: + enabled: true