Browse Source

fix: the informer can not register use GetInformer instead (#5931)

Gergely Bräutigam 1 month ago
parent
commit
413d1f9c07

+ 71 - 27
pkg/controllers/externalsecret/externalsecret_controller.go

@@ -616,18 +616,38 @@ func (r *Reconciler) reconcileGenericTarget(
 	resourceLabels map[string]string,
 	syncCallsError *prometheus.CounterVec,
 ) (ctrl.Result, error) {
-	// retrieve the provider secret data
+	var existing *unstructured.Unstructured
+	if externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyMerge ||
+		externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyOrphan ||
+		externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyOwner {
+		var getErr error
+		existing, getErr = r.getGenericResource(ctx, log, externalSecret)
+		if getErr != nil && !apierrors.IsNotFound(getErr) {
+			r.markAsFailed("could not get target resource", getErr, externalSecret, syncCallsError.With(resourceLabels), esv1.ConditionReasonResourceSyncedError)
+			return ctrl.Result{}, getErr
+		}
+	}
+
+	valid, err := isGenericTargetValid(existing, externalSecret)
+	if err != nil {
+		log.V(1).Info("unable to validate target", "error", err)
+		return ctrl.Result{}, err
+	}
+
+	if !shouldRefresh(externalSecret) && valid {
+		log.V(1).Info("skipping refresh of generic target")
+		return r.getRequeueResult(externalSecret), nil
+	}
+
 	dataMap, err := r.GetProviderSecretData(ctx, externalSecret)
 	if err != nil {
 		r.markAsFailed(msgErrorGetSecretData, err, externalSecret, syncCallsError.With(resourceLabels), esv1.ConditionReasonResourceSyncedError)
 		return ctrl.Result{}, err
 	}
 
-	// if no data was found, handle it according to deletion policy
 	if len(dataMap) == 0 {
 		switch externalSecret.Spec.Target.DeletionPolicy {
 		case esv1.DeletionPolicyDelete:
-			// safeguard that we only can delete resources we own
 			creationPolicy := externalSecret.Spec.Target.CreationPolicy
 			if creationPolicy != esv1.CreatePolicyOwner {
 				err = fmt.Errorf("unable to delete resource: creationPolicy=%s is not Owner", creationPolicy)
@@ -635,7 +655,6 @@ func (r *Reconciler) reconcileGenericTarget(
 				return ctrl.Result{}, nil
 			}
 
-			// delete the resource if it exists
 			err = r.deleteGenericResource(ctx, log, externalSecret)
 			if err != nil {
 				r.markAsFailed("could not delete resource", err, externalSecret, syncCallsError.With(resourceLabels), esv1.ConditionReasonResourceSyncedError)
@@ -650,20 +669,6 @@ func (r *Reconciler) reconcileGenericTarget(
 			return r.getRequeueResult(externalSecret), nil
 
 		case esv1.DeletionPolicyMerge:
-			// continue to process with empty data
-		}
-	}
-
-	// Check if we need to fetch existing resource first (for Merge and Owner/Orphan policies)
-	var existing *unstructured.Unstructured
-	if externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyMerge ||
-		externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyOrphan ||
-		externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyOwner {
-		var getErr error
-		existing, getErr = r.getGenericResource(ctx, log, externalSecret)
-		if getErr != nil && !apierrors.IsNotFound(getErr) {
-			r.markAsFailed("could not get target resource", getErr, externalSecret, syncCallsError.With(resourceLabels), esv1.ConditionReasonResourceSyncedError)
-			return ctrl.Result{}, getErr
 		}
 	}
 
@@ -720,15 +725,15 @@ func (r *Reconciler) reconcileGenericTarget(
 		return ctrl.Result{}, err
 	}
 
-	// Ensure an informer exists for this GVK to enable drift detection (only if not already managed)
-	gvk := getTargetGVK(externalSecret)
-	esName := types.NamespacedName{Name: externalSecret.Name, Namespace: externalSecret.Namespace}
-	if _, err := r.informerManager.EnsureInformer(ctx, gvk, esName); err != nil {
-		// Log the error but don't fail reconciliation - the resource was successfully created/updated
-		log.Error(err, "failed to register informer for generic target, drift detection may not work",
-			"group", gvk.Group,
-			"version", gvk.Version,
-			"kind", gvk.Kind)
+	if externalSecret.Spec.Target.CreationPolicy != esv1.CreatePolicyNone {
+		gvk := getTargetGVK(externalSecret)
+		esName := types.NamespacedName{Name: externalSecret.Name, Namespace: externalSecret.Namespace}
+		if _, err := r.informerManager.EnsureInformer(ctx, gvk, esName); err != nil {
+			log.Error(err, "failed to register informer for generic target, drift detection may not work",
+				"group", gvk.Group,
+				"version", gvk.Version,
+				"kind", gvk.Kind)
+		}
 	}
 
 	r.markAsDone(externalSecret, start, log, esv1.ConditionReasonResourceSynced, msgSynced)
@@ -1173,6 +1178,45 @@ func isSecretValid(existingSecret *v1.Secret, es *esv1.ExternalSecret) bool {
 	return true
 }
 
+func isGenericTargetValid(existingTarget *unstructured.Unstructured, es *esv1.ExternalSecret) (bool, error) {
+	if es.Spec.Target.CreationPolicy == esv1.CreatePolicyOrphan {
+		return true, nil
+	}
+
+	if existingTarget == nil || existingTarget.GetUID() == "" {
+		return false, nil
+	}
+
+	if existingTarget.GetLabels()[esv1.LabelManaged] != esv1.LabelManagedValue {
+		return false, nil
+	}
+
+	hash, err := genericTargetContentHash(existingTarget)
+	if err != nil {
+		return false, fmt.Errorf("failed to hash target: %w", err)
+	}
+
+	if existingTarget.GetAnnotations()[esv1.AnnotationDataHash] != hash {
+		return false, nil
+	}
+
+	return true, nil
+}
+
+// genericTargetContentHash computes a hash over the hashable content of an unstructured object.
+// It uses the "spec" field if present, otherwise falls back to "data".
+func genericTargetContentHash(obj *unstructured.Unstructured) (string, error) {
+	content := obj.Object
+	switch {
+	case content["spec"] != nil:
+		return esutils.ObjectHash(content["spec"]), nil
+	case content["data"] != nil:
+		return esutils.ObjectHash(content["data"]), nil
+	default:
+		return "", errors.New("generic target content does not have a spec or data field for content hashing")
+	}
+}
+
 // SetupWithManager returns a new controller builder that will be started by the provided Manager.
 func (r *Reconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opts controller.Options) error {
 	r.recorder = mgr.GetEventRecorderFor("external-secrets")

+ 31 - 5
pkg/controllers/externalsecret/externalsecret_controller_manifest.go

@@ -181,6 +181,12 @@ func (r *Reconciler) applyTemplateToManifest(ctx context.Context, es *esv1.Exter
 		obj.SetGroupVersionKind(gvk)
 		obj.SetName(getTargetName(es))
 		obj.SetNamespace(es.Namespace)
+		switch gvk.Kind {
+		case "ConfigMap", "Secret":
+			obj.Object["data"] = map[string]interface{}{}
+		default:
+			obj.Object["spec"] = map[string]interface{}{}
+		}
 	}
 
 	labels := obj.GetLabels()
@@ -206,15 +212,35 @@ func (r *Reconciler) applyTemplateToManifest(ctx context.Context, es *esv1.Exter
 	obj.SetLabels(labels)
 	obj.SetAnnotations(annotations)
 
+	var result *unstructured.Unstructured
+	var err error
 	if es.Spec.Target.Template == nil {
-		return r.createSimpleManifest(obj, dataMap)
+		result = r.createSimpleManifest(obj, dataMap)
+	} else {
+		result, err = r.renderTemplatedManifest(ctx, es, obj, dataMap)
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	ann := result.GetAnnotations()
+	if ann == nil {
+		ann = make(map[string]string)
 	}
 
-	return r.renderTemplatedManifest(ctx, es, obj, dataMap)
+	hash, err := genericTargetContentHash(result)
+	if err != nil {
+		return nil, fmt.Errorf("failed to hash target %q content: %w", es.Spec.Target.Name, err)
+	}
+
+	ann[esv1.AnnotationDataHash] = hash
+	result.SetAnnotations(ann)
+
+	return result, nil
 }
 
 // createSimpleManifest creates a simple resource without templates (e.g., ConfigMap with data field).
-func (r *Reconciler) createSimpleManifest(obj *unstructured.Unstructured, dataMap map[string][]byte) (*unstructured.Unstructured, error) {
+func (r *Reconciler) createSimpleManifest(obj *unstructured.Unstructured, dataMap map[string][]byte) *unstructured.Unstructured {
 	// For ConfigMaps and similar resources, put data in .data field
 	if obj.GetKind() == "ConfigMap" {
 		data := make(map[string]string)
@@ -223,7 +249,7 @@ func (r *Reconciler) createSimpleManifest(obj *unstructured.Unstructured, dataMa
 		}
 		obj.Object["data"] = data
 
-		return obj, nil
+		return obj
 	}
 
 	// For other resources, put in spec.data or just data
@@ -237,7 +263,7 @@ func (r *Reconciler) createSimpleManifest(obj *unstructured.Unstructured, dataMa
 	spec := obj.Object["spec"].(map[string]any)
 	spec["data"] = data
 
-	return obj, nil
+	return obj
 }
 
 // renderTemplatedManifest renders templates for a custom resource.

+ 159 - 3
pkg/controllers/externalsecret/externalsecret_controller_manifest_test.go

@@ -28,12 +28,14 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/kubernetes/scheme"
 	"k8s.io/utils/ptr"
 	ctrl "sigs.k8s.io/controller-runtime"
 	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	"github.com/external-secrets/external-secrets/runtime/esutils"
 )
 
 func TestIsGenericTarget(t *testing.T) {
@@ -327,9 +329,7 @@ func TestCreateSimpleManifest(t *testing.T) {
 			}
 			obj.SetKind(tt.kind)
 
-			result, err := r.createSimpleManifest(obj, tt.dataMap)
-
-			require.NoError(t, err)
+			result := r.createSimpleManifest(obj, tt.dataMap)
 			assert.NotNil(t, result)
 			if tt.validate != nil {
 				tt.validate(t, result)
@@ -715,3 +715,159 @@ func TestApplyTemplateToManifest_MergeBehavior(t *testing.T) {
 	assert.Equal(t, "test-uid-123", string(result.GetUID()), "uid should be preserved")
 	t.Logf("Result spec: %+v", result.Object["spec"])
 }
+
+func TestGenericTargetContentHash(t *testing.T) {
+	tests := []struct {
+		name    string
+		obj     *unstructured.Unstructured
+		wantErr bool
+	}{
+		{
+			name: "hashes spec field",
+			obj: &unstructured.Unstructured{
+				Object: map[string]interface{}{
+					"spec": map[string]interface{}{"key": "val"},
+				},
+			},
+		},
+		{
+			name: "hashes data field when no spec",
+			obj: &unstructured.Unstructured{
+				Object: map[string]interface{}{
+					"data": map[string]interface{}{"key": "val"},
+				},
+			},
+		},
+		{
+			name: "prefers spec over data",
+			obj: &unstructured.Unstructured{
+				Object: map[string]interface{}{
+					"spec": map[string]interface{}{"a": "1"},
+					"data": map[string]interface{}{"b": "2"},
+				},
+			},
+		},
+		{
+			name: "errors when neither spec nor data",
+			obj: &unstructured.Unstructured{
+				Object: map[string]interface{}{
+					"status": map[string]interface{}{"ready": true},
+				},
+			},
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			hash, err := genericTargetContentHash(tt.obj)
+			if tt.wantErr {
+				assert.Error(t, err)
+				assert.Empty(t, hash)
+				return
+			}
+			require.NoError(t, err)
+			assert.NotEmpty(t, hash)
+		})
+	}
+
+	t.Run("spec preferred over data produces spec hash", func(t *testing.T) {
+		specData := map[string]interface{}{"a": "1"}
+		obj := &unstructured.Unstructured{
+			Object: map[string]interface{}{
+				"spec": specData,
+				"data": map[string]interface{}{"b": "2"},
+			},
+		}
+		hash, err := genericTargetContentHash(obj)
+		require.NoError(t, err)
+		assert.Equal(t, esutils.ObjectHash(specData), hash)
+	})
+}
+
+func TestIsGenericTargetValid(t *testing.T) {
+	makeES := func(policy esv1.ExternalSecretCreationPolicy) *esv1.ExternalSecret {
+		return &esv1.ExternalSecret{
+			Spec: esv1.ExternalSecretSpec{
+				Target: esv1.ExternalSecretTarget{
+					CreationPolicy: policy,
+				},
+			},
+		}
+	}
+
+	makeTarget := func(uid string, labels map[string]string, annotations map[string]string, obj map[string]interface{}) *unstructured.Unstructured {
+		u := &unstructured.Unstructured{Object: obj}
+		if uid != "" {
+			u.SetUID(types.UID(uid))
+		}
+		u.SetLabels(labels)
+		u.SetAnnotations(annotations)
+		return u
+	}
+
+	t.Run("orphan policy always valid", func(t *testing.T) {
+		valid, err := isGenericTargetValid(nil, makeES(esv1.CreatePolicyOrphan))
+		require.NoError(t, err)
+		assert.True(t, valid)
+	})
+
+	t.Run("nil target is invalid", func(t *testing.T) {
+		valid, err := isGenericTargetValid(nil, makeES(esv1.CreatePolicyOwner))
+		require.NoError(t, err)
+		assert.False(t, valid)
+	})
+
+	t.Run("empty UID is invalid", func(t *testing.T) {
+		obj := &unstructured.Unstructured{Object: map[string]interface{}{}}
+		valid, err := isGenericTargetValid(obj, makeES(esv1.CreatePolicyOwner))
+		require.NoError(t, err)
+		assert.False(t, valid)
+	})
+
+	t.Run("not managed is invalid", func(t *testing.T) {
+		obj := makeTarget("some-uid", map[string]string{}, nil, map[string]interface{}{
+			"spec": map[string]interface{}{"key": "val"},
+		})
+		valid, err := isGenericTargetValid(obj, makeES(esv1.CreatePolicyOwner))
+		require.NoError(t, err)
+		assert.False(t, valid)
+	})
+
+	t.Run("hash mismatch is invalid", func(t *testing.T) {
+		obj := makeTarget(
+			"some-uid",
+			map[string]string{esv1.LabelManaged: esv1.LabelManagedValue},
+			map[string]string{esv1.AnnotationDataHash: "wrong-hash"},
+			map[string]interface{}{"spec": map[string]interface{}{"key": "val"}},
+		)
+		valid, err := isGenericTargetValid(obj, makeES(esv1.CreatePolicyOwner))
+		require.NoError(t, err)
+		assert.False(t, valid)
+	})
+
+	t.Run("matching hash is valid", func(t *testing.T) {
+		specData := map[string]interface{}{"key": "val"}
+		hash := esutils.ObjectHash(specData)
+		obj := makeTarget(
+			"some-uid",
+			map[string]string{esv1.LabelManaged: esv1.LabelManagedValue},
+			map[string]string{esv1.AnnotationDataHash: hash},
+			map[string]interface{}{"spec": specData},
+		)
+		valid, err := isGenericTargetValid(obj, makeES(esv1.CreatePolicyOwner))
+		require.NoError(t, err)
+		assert.True(t, valid)
+	})
+
+	t.Run("errors when target has no spec or data", func(t *testing.T) {
+		obj := makeTarget(
+			"some-uid",
+			map[string]string{esv1.LabelManaged: esv1.LabelManagedValue},
+			nil,
+			map[string]interface{}{"status": map[string]interface{}{}},
+		)
+		_, err := isGenericTargetValid(obj, makeES(esv1.CreatePolicyOwner))
+		assert.Error(t, err)
+	})
+}

+ 7 - 5
pkg/controllers/externalsecret/informer_manager.go

@@ -23,6 +23,7 @@ import (
 
 	"github.com/go-logr/logr"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/fields"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
@@ -113,8 +114,9 @@ func (m *DefaultInformerManager) EnsureInformer(ctx context.Context, gvk schema.
 		return false, fmt.Errorf("queue not initialized, call SetQueue first")
 	}
 
-	// Get or create informer for this GVK
-	informer, err := m.cache.GetInformerForKind(ctx, gvk)
+	obj := &unstructured.Unstructured{}
+	obj.SetGroupVersionKind(gvk)
+	informer, err := m.cache.GetInformer(ctx, obj)
 	if err != nil {
 		return false, fmt.Errorf("failed to get informer for %s: %w", key, err)
 	}
@@ -246,10 +248,10 @@ func (m *DefaultInformerManager) ReleaseInformer(ctx context.Context, gvk schema
 
 	// if no more ExternalSecrets are using this informer, remove it
 	if len(entry.externalSecrets) == 0 {
-		partial := &metav1.PartialObjectMetadata{}
-		partial.SetGroupVersionKind(gvk)
+		obj := &unstructured.Unstructured{}
+		obj.SetGroupVersionKind(gvk)
 
-		if err := m.cache.RemoveInformer(ctx, partial); err != nil {
+		if err := m.cache.RemoveInformer(ctx, obj); err != nil {
 			m.log.Error(err, "failed to remove informer, will clean up tracking anyway",
 				"gvk", key)
 		}

+ 207 - 0
pkg/controllers/externalsecret/informer_manager_test.go

@@ -0,0 +1,207 @@
+/*
+Copyright © 2025 ESO Maintainer Team
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package externalsecret
+
+import (
+	"context"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/types"
+	toolscache "k8s.io/client-go/tools/cache"
+	"k8s.io/client-go/util/workqueue"
+	ctrl "sigs.k8s.io/controller-runtime"
+	runtimecache "sigs.k8s.io/controller-runtime/pkg/cache"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+type fakeInformer struct{}
+
+func (f *fakeInformer) AddEventHandler(handler toolscache.ResourceEventHandler) (toolscache.ResourceEventHandlerRegistration, error) {
+	return nil, nil
+}
+
+func (f *fakeInformer) AddEventHandlerWithResyncPeriod(handler toolscache.ResourceEventHandler, _ time.Duration) (toolscache.ResourceEventHandlerRegistration, error) {
+	return nil, nil
+}
+
+func (f *fakeInformer) AddEventHandlerWithOptions(handler toolscache.ResourceEventHandler, _ toolscache.HandlerOptions) (toolscache.ResourceEventHandlerRegistration, error) {
+	return nil, nil
+}
+
+func (f *fakeInformer) RemoveEventHandler(_ toolscache.ResourceEventHandlerRegistration) error {
+	return nil
+}
+
+func (f *fakeInformer) AddIndexers(indexers toolscache.Indexers) error {
+	return nil
+}
+
+func (f *fakeInformer) HasSynced() bool {
+	return true
+}
+
+func (f *fakeInformer) IsStopped() bool {
+	return false
+}
+
+type fakeCache struct {
+	runtimecache.Cache
+	getInformerCalled bool
+	getInformerObj    client.Object
+	getInformerErr    error
+}
+
+func (f *fakeCache) GetInformer(ctx context.Context, obj client.Object, opts ...runtimecache.InformerGetOption) (runtimecache.Informer, error) {
+	f.getInformerCalled = true
+	f.getInformerObj = obj
+	if f.getInformerErr != nil {
+		return nil, f.getInformerErr
+	}
+	return &fakeInformer{}, nil
+}
+
+func TestEnsureInformer_UsesUnstructured(t *testing.T) {
+	fc := &fakeCache{}
+	log := ctrl.Log.WithName("test")
+	m := &DefaultInformerManager{
+		managerContext: context.Background(),
+		cache:          fc,
+		log:            log,
+		informers:      make(map[string]*informerEntry),
+		queue:          workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[ctrl.Request]()),
+	}
+
+	gvk := schema.GroupVersionKind{
+		Group:   "monitoring.example.io",
+		Version: "v1alpha1",
+		Kind:    "CustomNotifier",
+	}
+	es := types.NamespacedName{Name: "test-es", Namespace: "default"}
+
+	created, err := m.EnsureInformer(context.Background(), gvk, es)
+
+	require.NoError(t, err)
+	assert.True(t, created)
+	assert.True(t, fc.getInformerCalled, "GetInformer should be called")
+
+	obj, ok := fc.getInformerObj.(*unstructured.Unstructured)
+	require.True(t, ok, "GetInformer should be called with *unstructured.Unstructured")
+	assert.Equal(t, gvk, obj.GroupVersionKind())
+}
+
+func TestEnsureInformer_DeduplicatesExistingGVK(t *testing.T) {
+	fc := &fakeCache{}
+	log := ctrl.Log.WithName("test")
+	m := &DefaultInformerManager{
+		managerContext: context.Background(),
+		cache:          fc,
+		log:            log,
+		informers:      make(map[string]*informerEntry),
+		queue:          workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[ctrl.Request]()),
+	}
+
+	gvk := schema.GroupVersionKind{Group: "example.io", Version: "v1", Kind: "Foo"}
+	es1 := types.NamespacedName{Name: "es-1", Namespace: "default"}
+	es2 := types.NamespacedName{Name: "es-2", Namespace: "default"}
+
+	created, err := m.EnsureInformer(context.Background(), gvk, es1)
+	require.NoError(t, err)
+	assert.True(t, created)
+
+	fc.getInformerCalled = false
+
+	created, err = m.EnsureInformer(context.Background(), gvk, es2)
+	require.NoError(t, err)
+	assert.False(t, created)
+	assert.False(t, fc.getInformerCalled, "GetInformer should not be called again for same GVK")
+
+	entry := m.informers[gvk.String()]
+	assert.Len(t, entry.externalSecrets, 2)
+}
+
+func TestEnsureInformer_ErrorWhenQueueNotSet(t *testing.T) {
+	fc := &fakeCache{}
+	log := ctrl.Log.WithName("test")
+	m := &DefaultInformerManager{
+		managerContext: context.Background(),
+		cache:          fc,
+		log:            log,
+		informers:      make(map[string]*informerEntry),
+	}
+
+	gvk := schema.GroupVersionKind{Group: "example.io", Version: "v1", Kind: "Foo"}
+	es := types.NamespacedName{Name: "es-1", Namespace: "default"}
+
+	_, err := m.EnsureInformer(context.Background(), gvk, es)
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "queue not initialized")
+}
+
+func TestEnsureInformer_PropagatesCacheError(t *testing.T) {
+	fc := &fakeCache{
+		getInformerErr: fmt.Errorf("CRD not found"),
+	}
+	log := ctrl.Log.WithName("test")
+	m := &DefaultInformerManager{
+		managerContext: context.Background(),
+		cache:          fc,
+		log:            log,
+		informers:      make(map[string]*informerEntry),
+		queue:          workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[ctrl.Request]()),
+	}
+
+	gvk := schema.GroupVersionKind{Group: "example.io", Version: "v1", Kind: "Foo"}
+	es := types.NamespacedName{Name: "es-1", Namespace: "default"}
+
+	_, err := m.EnsureInformer(context.Background(), gvk, es)
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "CRD not found")
+}
+
+func TestReleaseInformer_RemovesES(t *testing.T) {
+	fc := &fakeCache{}
+	log := ctrl.Log.WithName("test")
+	m := &DefaultInformerManager{
+		managerContext: context.Background(),
+		cache:          fc,
+		log:            log,
+		informers:      make(map[string]*informerEntry),
+		queue:          workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[ctrl.Request]()),
+	}
+
+	gvk := schema.GroupVersionKind{Group: "example.io", Version: "v1", Kind: "Foo"}
+	es1 := types.NamespacedName{Name: "es-1", Namespace: "default"}
+	es2 := types.NamespacedName{Name: "es-2", Namespace: "default"}
+
+	_, err := m.EnsureInformer(context.Background(), gvk, es1)
+	require.NoError(t, err)
+	_, err = m.EnsureInformer(context.Background(), gvk, es2)
+	require.NoError(t, err)
+
+	err = m.ReleaseInformer(context.Background(), gvk, es1)
+	require.NoError(t, err)
+
+	assert.True(t, m.IsManaged(gvk))
+	entry := m.informers[gvk.String()]
+	assert.Len(t, entry.externalSecrets, 1)
+}