Skip to content

Commit f2ee4bd

Browse files
committed
topic: add annotation to allow deletion with missing credentials
When a namespace is deleted, Kubernetes may delete the credentials Secret before the Topic CR. The Topic controller then fails to create a Kafka client, returns an error, and never removes its finalizer. The namespace gets stuck in Terminating state indefinitely. Add opt-in annotation to handle this: operator.redpanda.com/allow-deletion-with-missing-credentials: "true" When set, the controller allows finalizer removal during deletion if it cannot connect due to missing credentials. The underlying Kafka topic may be orphaned, but that beats a stuck namespace. The annotation is off by default to preserve existing behavior. Error detection covers both Kafka protocol errors (via ignoreAllConnectionErrors) and K8s NotFound errors (via new isK8sNotFoundInChain helper). Integration tests verify both behaviors: - Without annotation: deletion fails when credentials missing - With annotation: finalizer removed, deletion proceeds
1 parent 75b1e21 commit f2ee4bd

File tree

3 files changed

+144
-1
lines changed

3 files changed

+144
-1
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
project: operator
2+
kind: Fixed
3+
body: Add annotation to allow Topic finalizer removal when credentials Secret is missing during deletion, preventing stuck namespaces.
4+
time: 2026-01-22T10:00:00.000000+01:00

operator/internal/controller/redpanda/topic_controller.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,16 @@ import (
4444

4545
const (
4646
NoneConstantString = "none"
47+
48+
// AllowDeletionWithMissingCredentialsAnnotation when set to "true" allows
49+
// Topic finalizer removal even if the credentials Secret is missing during
50+
// deletion. This prevents namespaces from getting stuck in Terminating state
51+
// when the Secret is deleted before the Topic.
52+
AllowDeletionWithMissingCredentialsAnnotation = "operator.redpanda.com/allow-deletion-with-missing-credentials"
4753
)
4854

4955
var (
50-
ErrScaleDownPartitionCount = errors.New("unable to scale down number of partition in topic")
56+
ErrScaleDownPartitionCount = errors.New("unable to scale down number of partition in topic")
5157
ErrEmptyTopicConfigDescription = errors.New("topic config description response is empty")
5258
ErrEmptyMetadataTopic = errors.New("metadata topic response is empty")
5359
ErrWrongCreateTopicResponse = errors.New("requested topic was not part of create topic response")
@@ -185,6 +191,20 @@ func (r *TopicReconciler) reconcile(ctx context.Context, recorder record.EventRe
185191

186192
kafkaClient, err := r.createKafkaClient(ctx, topic, l)
187193
if err != nil {
194+
// If topic is being deleted and the AllowDeletionWithMissingCredentials
195+
// annotation is set, allow finalizer removal when credentials are missing.
196+
// This prevents namespaces from getting stuck in Terminating state.
197+
//
198+
// We check both ignoreAllConnectionErrors (Kafka protocol/config errors)
199+
// and K8s NotFound (missing secrets/configmaps) since the latter isn't
200+
// covered by the general helper.
201+
if !topic.ObjectMeta.DeletionTimestamp.IsZero() {
202+
allowMissing := topic.GetAnnotations()[AllowDeletionWithMissingCredentialsAnnotation] == "true"
203+
if allowMissing && (ignoreAllConnectionErrors(l, err) == nil || isK8sNotFoundInChain(err)) {
204+
l.V(2).Info("Ignoring client error during deletion (annotation enabled)", "error", err)
205+
return redpandav1alpha2.TopicReady(topic), ctrl.Result{}, nil
206+
}
207+
}
188208
return redpandav1alpha2.TopicFailed(topic), ctrl.Result{}, err
189209
}
190210
defer kafkaClient.Close()
@@ -602,3 +622,16 @@ func (r *TopicReconciler) recordErrorEvent(err error, recorder record.EventRecor
602622
args = append(args, err)
603623
return fmt.Errorf(message+": %w", args...) // nolint:goerr113 // That is not dynamic error
604624
}
625+
626+
// isK8sNotFoundInChain walks the error chain to check if a K8s NotFound error
627+
// is wrapped anywhere. This is needed because secret loading wraps the underlying
628+
// K8s error, and apierrors.IsNotFound() doesn't traverse wrapped errors.
629+
func isK8sNotFoundInChain(err error) bool {
630+
for err != nil {
631+
if apierrors.IsNotFound(err) {
632+
return true
633+
}
634+
err = errors.Unwrap(err)
635+
}
636+
return false
637+
}

operator/internal/controller/redpanda/topic_controller_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/twmb/franz-go/pkg/kadm"
2222
"github.com/twmb/franz-go/pkg/kgo"
2323
"github.com/twmb/franz-go/pkg/kmsg"
24+
corev1 "k8s.io/api/core/v1"
2425
apierrors "k8s.io/apimachinery/pkg/api/errors"
2526
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2627
"k8s.io/apimachinery/pkg/types"
@@ -823,4 +824,109 @@ func TestReconcile(t *testing.T) { // nolint:funlen // These tests have clear su
823824

824825
assert.Equal(t, time.Duration(0), result.RequeueAfter)
825826
})
827+
t.Run("delete_topic_with_missing_credentials_errors_without_annotation", func(t *testing.T) {
828+
// Without the AllowDeletionWithMissingCredentials annotation, deletion
829+
// should fail when the credentials Secret is missing. This is the default
830+
// behavior to prevent accidental data loss.
831+
topicName := "delete-topic-missing-secret-no-annotation"
832+
833+
topic := redpandav1alpha2.Topic{
834+
ObjectMeta: metav1.ObjectMeta{
835+
Name: topicName,
836+
Namespace: testNamespace,
837+
Finalizers: []string{FinalizerKey},
838+
},
839+
Spec: redpandav1alpha2.TopicSpec{
840+
Partitions: ptr.To(1),
841+
ReplicationFactor: ptr.To(1),
842+
KafkaAPISpec: &redpandav1alpha2.KafkaAPISpec{
843+
Brokers: []string{seedBroker},
844+
SASL: &redpandav1alpha2.KafkaSASL{
845+
Username: "testuser",
846+
Password: &redpandav1alpha2.ValueSource{
847+
SecretKeyRef: &corev1.SecretKeySelector{
848+
LocalObjectReference: corev1.LocalObjectReference{
849+
Name: "non-existent-secret",
850+
},
851+
Key: "password",
852+
},
853+
},
854+
Mechanism: redpandav1alpha2.SASLMechanismScramSHA256,
855+
},
856+
},
857+
},
858+
}
859+
860+
err := c.Create(ctx, &topic)
861+
require.NoError(t, err)
862+
863+
err = c.Delete(ctx, &topic)
864+
require.NoError(t, err)
865+
866+
key := types.NamespacedName{Name: topicName, Namespace: testNamespace}
867+
req := mcreconcile.Request{Request: ctrl.Request{NamespacedName: key}, ClusterName: mcmanager.LocalCluster}
868+
869+
// Without annotation, reconcile should return an error
870+
_, err = tr.Reconcile(ctx, req)
871+
assert.Error(t, err, "reconciler should error when credentials secret is missing without annotation")
872+
assert.Contains(t, err.Error(), "non-existent-secret")
873+
874+
// Topic should still exist (finalizer not removed)
875+
err = c.Get(ctx, key, &topic)
876+
assert.NoError(t, err, "topic should still exist when deletion fails")
877+
})
878+
t.Run("delete_topic_with_missing_credentials_succeeds_with_annotation", func(t *testing.T) {
879+
// With the AllowDeletionWithMissingCredentials annotation set to "true",
880+
// deletion should succeed even when the credentials Secret is missing.
881+
// This prevents namespaces from getting stuck in Terminating state.
882+
topicName := "delete-topic-missing-secret-with-annotation"
883+
884+
topic := redpandav1alpha2.Topic{
885+
ObjectMeta: metav1.ObjectMeta{
886+
Name: topicName,
887+
Namespace: testNamespace,
888+
Finalizers: []string{FinalizerKey},
889+
Annotations: map[string]string{
890+
AllowDeletionWithMissingCredentialsAnnotation: "true",
891+
},
892+
},
893+
Spec: redpandav1alpha2.TopicSpec{
894+
Partitions: ptr.To(1),
895+
ReplicationFactor: ptr.To(1),
896+
KafkaAPISpec: &redpandav1alpha2.KafkaAPISpec{
897+
Brokers: []string{seedBroker},
898+
SASL: &redpandav1alpha2.KafkaSASL{
899+
Username: "testuser",
900+
Password: &redpandav1alpha2.ValueSource{
901+
SecretKeyRef: &corev1.SecretKeySelector{
902+
LocalObjectReference: corev1.LocalObjectReference{
903+
Name: "non-existent-secret",
904+
},
905+
Key: "password",
906+
},
907+
},
908+
Mechanism: redpandav1alpha2.SASLMechanismScramSHA256,
909+
},
910+
},
911+
},
912+
}
913+
914+
err := c.Create(ctx, &topic)
915+
require.NoError(t, err)
916+
917+
err = c.Delete(ctx, &topic)
918+
require.NoError(t, err)
919+
920+
key := types.NamespacedName{Name: topicName, Namespace: testNamespace}
921+
req := mcreconcile.Request{Request: ctrl.Request{NamespacedName: key}, ClusterName: mcmanager.LocalCluster}
922+
923+
// With annotation, reconcile should succeed
924+
result, err := tr.Reconcile(ctx, req)
925+
assert.NoError(t, err, "reconciler should not error when annotation is set")
926+
assert.Equal(t, time.Duration(0), result.RequeueAfter)
927+
928+
// Topic should be deleted (finalizer removed)
929+
err = c.Get(ctx, key, &topic)
930+
assert.True(t, apierrors.IsNotFound(err), "topic should be deleted after finalizer removal")
931+
})
826932
}

0 commit comments

Comments
 (0)