Pārlūkot izejas kodu

feat: add finalizers to SecretStores when referenced by PushSecrets with DeletionPolicy=Delete (#5163)

* feat: add finalizers to SecretStores when referenced by PushSecrets with DeletionPolicy=Delete

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* docs: Fix provider stability and support table (#5161)

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>
Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* feat(helm): Add control of response to missing prometheus (#5087)

Signed-off-by: Pat Riehecky <riehecky@fnal.gov>
Co-authored-by: Gergely Brautigam <skarlso777@gmail.com>
Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* chore: Added release notes configuration (#5148)

* Added release notes configuration

Signed-off-by: Dmytro Bondar <git@bonddim.dev>

* Rename title for default category

Signed-off-by: Dmytro Bondar <git@bonddim.dev>

---------

Signed-off-by: Dmytro Bondar <git@bonddim.dev>
Co-authored-by: Gergely Brautigam <skarlso777@gmail.com>
Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* refactor: update fmt after make fmt

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* fix: adjust css and ss watches ps

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* test: using manager instead of k8s client for get the ps index correctly

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* chore: bump bitwarden helm chart version (#5044)

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>
Co-authored-by: Moritz Johner <moolen@users.noreply.github.com>
Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* chore(docs): update `ADOPTERS.md` to include SAP (#5165)

SAP is an active and collaborating company in the ESO ecosystem.
I have the admission of the company to admit us as Adopter.

Disclaimer: I work at SAP

Signed-off-by: Jakob Möller <jakob.moeller@sap.com>
Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* feat: use cmd.Context for index the syncedSecrets and deletionPolicy on ps controller

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* chore: using a single finalizer handler for all secret stores

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* docs: remove TODO comment from pushsecret controller test

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* chore: update the handlerFinalizer logic turnig more readable

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* test: create secretstore finalizer management suite

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* refactor: remove findForPushSecret code duplication on SetupWithManager CSS/SS

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* docs: update suite_test comments

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* fix: skip finalizer management when pushsecret feature is disable

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* fix: remove handle finalizers from generic_store and update the syncedPushSecrets index to use the entire storeKey

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* refactor: improves readability of hasPushSecretsWithDeletePolicy function and add some new comments

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* fix: update the code fmt, lint and move some util functions for util.go file

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

* Apply suggestions from code review

Co-authored-by: Jakob Möller <contact@jakob-moeller.com>
Signed-off-by: Matheus Mazzoni <54732019+matheusmazzoni@users.noreply.github.com>

* refactor: update the watcher based if the pushsecrets is enabled

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>

---------

Signed-off-by: Matheus Mazzoni <matheusdiasmazzoni@gmail.com>
Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>
Signed-off-by: Pat Riehecky <riehecky@fnal.gov>
Signed-off-by: Dmytro Bondar <git@bonddim.dev>
Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>
Signed-off-by: Jakob Möller <jakob.moeller@sap.com>
Signed-off-by: Matheus Mazzoni <54732019+matheusmazzoni@users.noreply.github.com>
Co-authored-by: Jonathan Stacks <jonstacks@users.noreply.github.com>
Co-authored-by: Pat Riehecky <3534830+jcpunk@users.noreply.github.com>
Co-authored-by: Gergely Brautigam <skarlso777@gmail.com>
Co-authored-by: Dmytro Bondar <git@bonddim.dev>
Co-authored-by: Moritz Johner <moolen@users.noreply.github.com>
Co-authored-by: Jakob Möller <jakob.moeller@sap.com>
Co-authored-by: Jakob Möller <contact@jakob-moeller.com>
Matheus Mazzoni 9 mēneši atpakaļ
vecāks
revīzija
8ed902d31e

+ 13 - 11
cmd/controller/root.go

@@ -191,11 +191,12 @@ var rootCmd = &cobra.Command{
 
 		ssmetrics.SetUpMetrics()
 		if err = (&secretstore.StoreReconciler{
-			Client:          mgr.GetClient(),
-			Log:             ctrl.Log.WithName("controllers").WithName("SecretStore"),
-			Scheme:          mgr.GetScheme(),
-			ControllerClass: controllerClass,
-			RequeueInterval: storeRequeueInterval,
+			Client:            mgr.GetClient(),
+			Log:               ctrl.Log.WithName("controllers").WithName("SecretStore"),
+			Scheme:            mgr.GetScheme(),
+			ControllerClass:   controllerClass,
+			RequeueInterval:   storeRequeueInterval,
+			PushSecretEnabled: enablePushSecretReconciler,
 		}).SetupWithManager(mgr, controller.Options{
 			MaxConcurrentReconciles: concurrent,
 			RateLimiter:             ctrlcommon.BuildRateLimiter(),
@@ -206,11 +207,12 @@ var rootCmd = &cobra.Command{
 		if enableClusterStoreReconciler {
 			cssmetrics.SetUpMetrics()
 			if err = (&secretstore.ClusterStoreReconciler{
-				Client:          mgr.GetClient(),
-				Log:             ctrl.Log.WithName("controllers").WithName("ClusterSecretStore"),
-				Scheme:          mgr.GetScheme(),
-				ControllerClass: controllerClass,
-				RequeueInterval: storeRequeueInterval,
+				Client:            mgr.GetClient(),
+				Log:               ctrl.Log.WithName("controllers").WithName("ClusterSecretStore"),
+				Scheme:            mgr.GetScheme(),
+				ControllerClass:   controllerClass,
+				RequeueInterval:   storeRequeueInterval,
+				PushSecretEnabled: enablePushSecretReconciler,
 			}).SetupWithManager(mgr, controller.Options{
 				MaxConcurrentReconciles: concurrent,
 				RateLimiter:             ctrlcommon.BuildRateLimiter(),
@@ -258,7 +260,7 @@ var rootCmd = &cobra.Command{
 				ControllerClass: controllerClass,
 				RestConfig:      mgr.GetConfig(),
 				RequeueInterval: time.Hour,
-			}).SetupWithManager(mgr, controller.Options{
+			}).SetupWithManager(cmd.Context(), mgr, controller.Options{
 				MaxConcurrentReconciles: concurrent,
 				RateLimiter:             ctrlcommon.BuildRateLimiter(),
 			}); err != nil {

+ 43 - 3
pkg/controllers/pushsecret/pushsecret_controller.go

@@ -72,9 +72,37 @@ type Reconciler struct {
 	ControllerClass string
 }
 
-func (r *Reconciler) SetupWithManager(mgr ctrl.Manager, opts controller.Options) error {
+func (r *Reconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opts controller.Options) error {
 	r.recorder = mgr.GetEventRecorderFor("pushsecret")
 
+	// Index PushSecrets by the stores they have pushed to (for finalizer management on store deletion)
+	// Refer to common.go for more details on the index function
+	if err := mgr.GetFieldIndexer().IndexField(ctx, &esapi.PushSecret{}, "status.syncedPushSecrets", func(obj client.Object) []string {
+		ps := obj.(*esapi.PushSecret)
+
+		// Only index PushSecrets with DeletionPolicy=Delete for efficiency
+		if ps.Spec.DeletionPolicy != esapi.PushSecretDeletionPolicyDelete {
+			return nil
+		}
+
+		// Format is typically "Kind/Name" (e.g., "SecretStore/store1", "ClusterSecretStore/clusterstore1")
+		storeKeys := make([]string, 0, len(ps.Status.SyncedPushSecrets))
+		for storeKey := range ps.Status.SyncedPushSecrets {
+			storeKeys = append(storeKeys, storeKey)
+		}
+		return storeKeys
+	}); err != nil {
+		return err
+	}
+
+	// Index PushSecrets by deletionPolicy for quick filtering
+	if err := mgr.GetFieldIndexer().IndexField(ctx, &esapi.PushSecret{}, "spec.deletionPolicy", func(obj client.Object) []string {
+		ps := obj.(*esapi.PushSecret)
+		return []string{string(ps.Spec.DeletionPolicy)}
+	}); err != nil {
+		return err
+	}
+
 	return ctrl.NewControllerManagedBy(mgr).
 		WithOptions(opts).
 		For(&esapi.PushSecret{}).
@@ -115,7 +143,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 
 	p := client.MergeFrom(ps.DeepCopy())
 	defer func() {
-		if err := r.Client.Status().Patch(ctx, &ps, p); err != nil {
+		err := r.Client.Status().Patch(ctx, &ps, p)
+		if err != nil && !apierrors.IsNotFound(err) {
 			log.Error(err, errPatchStatus)
 		}
 	}()
@@ -177,7 +206,18 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 		return ctrl.Result{}, err
 	}
 
-	secretStores, err = removeUnmanagedStores(ctx, req.Namespace, r, secretStores)
+	// Filter out SecretStores that are being deleted to avoid finalizer conflicts
+	activeSecretStores := make(map[esapi.PushSecretStoreRef]esv1.GenericStore, len(secretStores))
+	for ref, store := range secretStores {
+		// Skip stores that are being deleted
+		if !store.GetDeletionTimestamp().IsZero() {
+			log.Info("skipping SecretStore that is being deleted", "storeName", store.GetName(), "storeKind", store.GetKind())
+			continue
+		}
+		activeSecretStores[ref] = store
+	}
+
+	secretStores, err = removeUnmanagedStores(ctx, req.Namespace, r, activeSecretStores)
 	if err != nil {
 		r.markAsFailed(err.Error(), &ps, nil)
 		return ctrl.Result{}, err

+ 0 - 1
pkg/controllers/pushsecret/pushsecret_controller_test.go

@@ -1311,7 +1311,6 @@ var _ = Describe("PushSecret Controller Un/Managed Stores", func() {
 			},
 		})
 		// give a time for reconciler to remove finalizers before removing SecretStores
-		// TODO: Secret Stores should have finalizers bound to PushSecrets if DeletionPolicy == Delete
 		time.Sleep(2 * time.Second)
 		for _, psstore := range PushSecretStores {
 			k8sClient.Delete(context.Background(), &esv1.SecretStore{

+ 1 - 1
pkg/controllers/pushsecret/suite_test.go

@@ -98,7 +98,7 @@ var _ = BeforeSuite(func() {
 		Log:             ctrl.Log.WithName("controllers").WithName("PushSecret"),
 		RestConfig:      cfg,
 		RequeueInterval: time.Second,
-	}).SetupWithManager(k8sManager, controller.Options{
+	}).SetupWithManager(ctx, k8sManager, controller.Options{
 		MaxConcurrentReconciles: 1,
 		RateLimiter:             ctrlcommon.BuildRateLimiter(),
 	})

+ 25 - 8
pkg/controllers/secretstore/clustersecretstore_controller.go

@@ -25,8 +25,11 @@ import (
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/controller"
+	"sigs.k8s.io/controller-runtime/pkg/handler"
+	ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile"
 
 	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore/cssmetrics"
 
@@ -37,11 +40,12 @@ import (
 // ClusterStoreReconciler reconciles a SecretStore object.
 type ClusterStoreReconciler struct {
 	client.Client
-	Log             logr.Logger
-	Scheme          *runtime.Scheme
-	ControllerClass string
-	RequeueInterval time.Duration
-	recorder        record.EventRecorder
+	Log               logr.Logger
+	Scheme            *runtime.Scheme
+	ControllerClass   string
+	RequeueInterval   time.Duration
+	recorder          record.EventRecorder
+	PushSecretEnabled bool
 }
 
 func (r *ClusterStoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
@@ -63,7 +67,7 @@ func (r *ClusterStoreReconciler) Reconcile(ctx context.Context, req ctrl.Request
 		return ctrl.Result{}, err
 	}
 
-	return reconcile(ctx, req, &css, r.Client, log, Opts{
+	return reconcile(ctx, req, &css, r.Client, r.PushSecretEnabled, log, Opts{
 		ControllerClass: r.ControllerClass,
 		GaugeVecGetter:  cssmetrics.GetGaugeVec,
 		Recorder:        r.recorder,
@@ -75,8 +79,21 @@ func (r *ClusterStoreReconciler) Reconcile(ctx context.Context, req ctrl.Request
 func (r *ClusterStoreReconciler) SetupWithManager(mgr ctrl.Manager, opts controller.Options) error {
 	r.recorder = mgr.GetEventRecorderFor("cluster-secret-store")
 
-	return ctrl.NewControllerManagedBy(mgr).
-		WithOptions(opts).
+	builder := ctrl.NewControllerManagedBy(mgr)
+
+	if r.PushSecretEnabled {
+		return builder.WithOptions(opts).
+			For(&esapi.ClusterSecretStore{}).
+			Watches(
+				&esv1alpha1.PushSecret{},
+				handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrlreconcile.Request {
+					return findStoresForPushSecret(ctx, r.Client, obj, &esapi.ClusterSecretStoreList{})
+				}),
+			).
+			Complete(r)
+	}
+
+	return builder.WithOptions(opts).
 		For(&esapi.ClusterSecretStore{}).
 		Complete(r)
 }

+ 195 - 1
pkg/controllers/secretstore/common.go

@@ -22,12 +22,19 @@ import (
 
 	"github.com/go-logr/logr"
 	v1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/fields"
+	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/tools/record"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+	ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile"
 
 	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore/metrics"
+
+	_ "github.com/external-secrets/external-secrets/pkg/provider/register"
 )
 
 const (
@@ -40,6 +47,9 @@ const (
 
 	msgStoreValidated     = "store validated"
 	msgStoreNotMaintained = "store isn't currently maintained. Please plan and prepare accordingly."
+
+	// Finalizer for SecretStores when they have PushSecrets with DeletionPolicy=Delete.
+	secretStoreFinalizer = "secretstore.externalsecrets.io/finalizer"
 )
 
 var validationUnknownError = errors.New("could not determine validation status")
@@ -51,12 +61,27 @@ type Opts struct {
 	RequeueInterval time.Duration
 }
 
-func reconcile(ctx context.Context, req ctrl.Request, ss esapi.GenericStore, cl client.Client, log logr.Logger, opts Opts) (ctrl.Result, error) {
+func reconcile(ctx context.Context, req ctrl.Request, ss esapi.GenericStore, cl client.Client, isPushSecretEnabled bool, log logr.Logger, opts Opts) (ctrl.Result, error) {
 	if !ShouldProcessStore(ss, opts.ControllerClass) {
 		log.V(1).Info("skip store")
 		return ctrl.Result{}, nil
 	}
 
+	// Manage finalizer if PushSecret feature is enabled.
+	if isPushSecretEnabled {
+		finalizersUpdated, err := handleFinalizer(ctx, cl, ss)
+		if err != nil {
+			return ctrl.Result{}, err
+		}
+
+		if finalizersUpdated {
+			log.V(1).Info("updating resource with finalizer changes")
+			if err := cl.Update(ctx, ss); err != nil {
+				return ctrl.Result{}, err
+			}
+		}
+	}
+
 	requeueInterval := opts.RequeueInterval
 
 	if ss.GetSpec().RefreshInterval != 0 {
@@ -156,3 +181,172 @@ func ShouldProcessStore(store esapi.GenericStore, class string) bool {
 
 	return false
 }
+
+// handleFinalizer manages the finalizer for ClusterSecretStores and SecretStores.
+func handleFinalizer(ctx context.Context, cl client.Client, store esapi.GenericStore) (finalizersUpdated bool, err error) {
+	log := logr.FromContextOrDiscard(ctx)
+	hasPushSecretsWithDeletePolicy, err := hasPushSecretsWithDeletePolicy(ctx, cl, store)
+	if err != nil {
+		return false, fmt.Errorf("failed to check PushSecrets: %w", err)
+	}
+
+	storeKind := store.GetKind()
+
+	// If the store is being deleted and has the finalizer, check if we can remove it
+	if !store.GetObjectMeta().DeletionTimestamp.IsZero() {
+		if hasPushSecretsWithDeletePolicy {
+			log.Info("cannot remove finalizer, there are still PushSecrets with DeletionPolicy=Delete that reference this store")
+			return false, nil
+		}
+
+		if controllerutil.RemoveFinalizer(store, secretStoreFinalizer) {
+			log.Info(fmt.Sprintf("removed finalizer from %s during deletion", storeKind))
+			return true, nil
+		}
+
+		return false, nil
+	}
+
+	// If the store is not being deleted, manage the finalizer based on PushSecrets
+	if hasPushSecretsWithDeletePolicy {
+		if controllerutil.AddFinalizer(store, secretStoreFinalizer) {
+			log.Info(fmt.Sprintf("added finalizer to %s due to PushSecrets with DeletionPolicy=Delete", storeKind))
+			return true, nil
+		}
+	} else {
+		if controllerutil.RemoveFinalizer(store, secretStoreFinalizer) {
+			log.Info(fmt.Sprintf("removed finalizer from %s, no more PushSecrets with DeletionPolicy=Delete", storeKind))
+			return true, nil
+		}
+	}
+
+	return false, nil
+}
+
+// hasPushSecretsWithDeletePolicy checks if there are any PushSecrets with DeletionPolicy=Delete
+// that reference this SecretStore using the controller-runtime index.
+func hasPushSecretsWithDeletePolicy(ctx context.Context, cl client.Client, store esapi.GenericStore) (bool, error) {
+	// Search for PushSecrets that have already synced from this store.
+	found, err := hasSyncedPushSecrets(ctx, cl, store)
+	if err != nil {
+		return false, fmt.Errorf("failed to check for synced push secrets: %w", err)
+	}
+	if found {
+		return true, nil
+	}
+
+	// Search for PushSecrets that reference this store, but may not have synced yet.
+	found, err = hasUnsyncedPushSecretRefs(ctx, cl, store)
+	if err != nil {
+		return false, fmt.Errorf("failed to check for unsynced push secret refs: %w", err)
+	}
+
+	return found, nil
+}
+
+// hasSyncedPushSecrets uses the 'status.syncedPushSecrets' index from PushSecrets to efficiently find
+// PushSecrets with DeletionPolicy=Delete that have already been synced from the given store.
+func hasSyncedPushSecrets(ctx context.Context, cl client.Client, store esapi.GenericStore) (bool, error) {
+	storeKey := fmt.Sprintf("%s/%s", store.GetKind(), store.GetName())
+
+	opts := &client.ListOptions{
+		FieldSelector: fields.OneTermEqualSelector("status.syncedPushSecrets", storeKey),
+	}
+
+	if store.GetKind() == esapi.SecretStoreKind {
+		opts.Namespace = store.GetNamespace()
+	}
+
+	var pushSecretList esv1alpha1.PushSecretList
+	if err := cl.List(ctx, &pushSecretList, opts); err != nil {
+		return false, err
+	}
+
+	// If any PushSecrets are found, return true. The index ensures they have DeletionPolicy=Delete.
+	return len(pushSecretList.Items) > 0, nil
+}
+
+// hasUnsyncedPushSecretRefs searches for all PushSecrets with DeletionPolicy=Delete
+// and checks if any of them reference the given store (by name or labelSelector).
+// This is necessary for cases where the reference exists, but synchronization has not occurred yet.
+func hasUnsyncedPushSecretRefs(ctx context.Context, cl client.Client, store esapi.GenericStore) (bool, error) {
+	opts := &client.ListOptions{
+		FieldSelector: fields.OneTermEqualSelector("spec.deletionPolicy", string(esv1alpha1.PushSecretDeletionPolicyDelete)),
+	}
+
+	if store.GetKind() == esapi.SecretStoreKind {
+		opts.Namespace = store.GetNamespace()
+	}
+
+	var pushSecretList esv1alpha1.PushSecretList
+	if err := cl.List(ctx, &pushSecretList, opts); err != nil {
+		return false, err
+	}
+
+	for _, ps := range pushSecretList.Items {
+		for _, storeRef := range ps.Spec.SecretStoreRefs {
+			if storeMatchesRef(store, storeRef) {
+				return true, nil
+			}
+		}
+	}
+
+	return false, nil
+}
+
+// findStoresForPushSecret finds SecretStores or ClusterSecretStores that should be reconciled when a PushSecret changes.
+func findStoresForPushSecret(ctx context.Context, c client.Client, obj client.Object, storeList client.ObjectList) []ctrlreconcile.Request {
+	ps, ok := obj.(*esv1alpha1.PushSecret)
+	if !ok {
+		return nil
+	}
+
+	var isClusterScoped bool
+	switch storeList.(type) {
+	case *esapi.ClusterSecretStoreList:
+		isClusterScoped = true
+	case *esapi.SecretStoreList:
+		isClusterScoped = false
+	default:
+		return nil
+	}
+
+	listOpts := make([]client.ListOption, 0)
+	if !isClusterScoped {
+		listOpts = append(listOpts, client.InNamespace(ps.GetNamespace()))
+	}
+
+	if err := c.List(ctx, storeList, listOpts...); err != nil {
+		return nil
+	}
+
+	requests := make([]ctrlreconcile.Request, 0)
+	var stores []esapi.GenericStore
+
+	switch sl := storeList.(type) {
+	case *esapi.SecretStoreList:
+		for i := range sl.Items {
+			stores = append(stores, &sl.Items[i])
+		}
+	case *esapi.ClusterSecretStoreList:
+		for i := range sl.Items {
+			stores = append(stores, &sl.Items[i])
+		}
+	}
+
+	for _, store := range stores {
+		if shouldReconcileSecretStoreForPushSecret(store, ps) {
+			req := ctrlreconcile.Request{
+				NamespacedName: types.NamespacedName{
+					Name: store.GetName(),
+				},
+			}
+			if !isClusterScoped {
+				req.NamespacedName.Namespace = store.GetNamespace()
+			}
+			requests = append(requests, req)
+		}
+	}
+
+	return requests
+}

+ 403 - 150
pkg/controllers/secretstore/common_test.go

@@ -19,211 +19,438 @@ import (
 	"time"
 
 	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"
 
 	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
-
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 )
 
 type testCase struct {
 	store  esapi.GenericStore
+	ps     *esv1alpha1.PushSecret
 	assert func()
 }
 
-var _ = Describe("SecretStore reconcile", func() {
+const (
+	defaultStoreName       = "default-store"
+	defaultControllerClass = "test-ctrl"
+)
+
+var _ = Describe("SecretStore Controller", func() {
 	var test *testCase
 
 	BeforeEach(func() {
 		test = makeDefaultTestcase()
 	})
 
-	AfterEach(func() {
-		Expect(k8sClient.Delete(context.Background(), test.store)).ToNot(HaveOccurred())
+	Context("Reconcile Logic", func() {
+		AfterEach(func() {
+			Expect(k8sClient.Delete(context.Background(), test.store)).ToNot(HaveOccurred())
+		})
+
+		// an invalid provider config should be reflected
+		// in the store status condition
+		invalidProvider := func(tc *testCase) {
+			tc.assert = func() {
+				Eventually(func() bool {
+					ss := tc.store.Copy()
+					err := k8sClient.Get(context.Background(), types.NamespacedName{
+						Name:      defaultStoreName,
+						Namespace: ss.GetObjectMeta().Namespace,
+					}, ss)
+					if err != nil {
+						return false
+					}
+					status := ss.GetStatus()
+					if len(status.Conditions) != 1 {
+						return false
+					}
+					return status.Conditions[0].Reason == esapi.ReasonInvalidProviderConfig &&
+						hasEvent(tc.store.GetTypeMeta().Kind, ss.GetName(), esapi.ReasonInvalidProviderConfig)
+				}).
+					WithTimeout(time.Second * 10).
+					WithPolling(time.Second).
+					Should(BeTrue())
+			}
+		}
+
+		// if controllerClass does not match the controller
+		// should not touch this store
+		ignoreControllerClass := func(tc *testCase) {
+			spc := tc.store.GetSpec()
+			spc.Controller = "something-else"
+			tc.assert = func() {
+				Consistently(func() bool {
+					ss := tc.store.Copy()
+					err := k8sClient.Get(context.Background(), types.NamespacedName{
+						Name:      defaultStoreName,
+						Namespace: ss.GetObjectMeta().Namespace,
+					}, ss)
+					if err != nil {
+						return true
+					}
+					return len(ss.GetStatus().Conditions) == 0
+				}).
+					WithTimeout(time.Second * 3).
+					WithPolling(time.Millisecond * 500).
+					Should(BeTrue())
+			}
+		}
+
+		validProvider := func(tc *testCase) {
+			spc := tc.store.GetSpec()
+			spc.Provider.Vault = nil
+			spc.Provider.Fake = &esapi.FakeProvider{
+				Data: []esapi.FakeProviderData{},
+			}
+
+			tc.assert = func() {
+				Eventually(func() bool {
+					ss := tc.store.Copy()
+					err := k8sClient.Get(context.Background(), types.NamespacedName{
+						Name:      defaultStoreName,
+						Namespace: ss.GetNamespace(),
+					}, ss)
+					if err != nil {
+						return false
+					}
+
+					if len(ss.GetStatus().Conditions) != 1 {
+						return false
+					}
+
+					return ss.GetStatus().Conditions[0].Reason == esapi.ReasonStoreValid &&
+						ss.GetStatus().Conditions[0].Type == esapi.SecretStoreReady &&
+						ss.GetStatus().Conditions[0].Status == corev1.ConditionTrue &&
+						hasEvent(tc.store.GetTypeMeta().Kind, ss.GetName(), esapi.ReasonStoreValid)
+				}).
+					WithTimeout(time.Second * 10).
+					WithPolling(time.Second).
+					Should(BeTrue())
+			}
+
+		}
+
+		readWrite := func(tc *testCase) {
+			spc := tc.store.GetSpec()
+			spc.Provider.Vault = nil
+			spc.Provider.Fake = &esapi.FakeProvider{
+				Data: []esapi.FakeProviderData{},
+			}
+
+			tc.assert = func() {
+				Eventually(func() bool {
+					ss := tc.store.Copy()
+					err := k8sClient.Get(context.Background(), types.NamespacedName{
+						Name:      defaultStoreName,
+						Namespace: ss.GetNamespace(),
+					}, ss)
+					if err != nil {
+						return false
+					}
+
+					if ss.GetStatus().Capabilities != esapi.SecretStoreReadWrite {
+						return false
+					}
+
+					return true
+				}).
+					WithTimeout(time.Second * 10).
+					WithPolling(time.Second).
+					Should(BeTrue())
+			}
+
+		}
+
+		// an unknown store validation result should be reflected
+		// in the store status condition
+		validationUnknown := func(tc *testCase) {
+			spc := tc.store.GetSpec()
+			spc.Provider.Vault = nil
+			validationResultUnknown := esapi.ValidationResultUnknown
+			spc.Provider.Fake = &esapi.FakeProvider{
+				Data:             []esapi.FakeProviderData{},
+				ValidationResult: &validationResultUnknown,
+			}
+
+			tc.assert = func() {
+				Eventually(func() bool {
+					ss := tc.store.Copy()
+					err := k8sClient.Get(context.Background(), types.NamespacedName{
+						Name:      defaultStoreName,
+						Namespace: ss.GetNamespace(),
+					}, ss)
+					if err != nil {
+						return false
+					}
+
+					if len(ss.GetStatus().Conditions) != 1 {
+						return false
+					}
+
+					return ss.GetStatus().Conditions[0].Reason == esapi.ReasonValidationUnknown &&
+						ss.GetStatus().Conditions[0].Type == esapi.SecretStoreReady &&
+						ss.GetStatus().Conditions[0].Status == corev1.ConditionTrue &&
+						hasEvent(tc.store.GetTypeMeta().Kind, ss.GetName(), esapi.ReasonValidationUnknown)
+				}).
+					WithTimeout(time.Second * 5).
+					WithPolling(time.Second).
+					Should(BeTrue())
+			}
+		}
+
+		DescribeTable("Provider Configuration", func(muts ...func(tc *testCase)) {
+			for _, mut := range muts {
+				mut(test)
+			}
+			err := k8sClient.Create(context.Background(), test.store.Copy())
+			Expect(err).ToNot(HaveOccurred())
+			test.assert()
+		},
+			// Namespaced store tests
+			Entry("[namespace] invalid provider should set InvalidStore condition", invalidProvider),
+			Entry("[namespace] should ignore stores with non-matching controller class", ignoreControllerClass),
+			Entry("[namespace] valid provider should have status=ready", validProvider),
+			Entry("[namespace] valid provider should have capabilities=ReadWrite", readWrite),
+			Entry("[cluster] validation unknown status should set ValidationUnknown condition", validationUnknown),
+
+			// Cluster store tests
+			Entry("[cluster] invalid provider should set InvalidStore condition", invalidProvider, useClusterStore),
+			Entry("[cluster] should ignore stores with non-matching controller class", ignoreControllerClass, useClusterStore),
+			Entry("[cluster] valid provider should have status=ready", validProvider, useClusterStore),
+			Entry("[cluster] valid provider should have capabilities=ReadWrite", readWrite, useClusterStore),
+			Entry("[cluster] validation unknown status should set ValidationUnknown condition", validationUnknown, useClusterStore),
+		)
 	})
 
-	// an invalid provider config should be reflected
-	// in the store status condition
-	invalidProvider := func(tc *testCase) {
-		tc.assert = func() {
-			Eventually(func() bool {
-				ss := tc.store.Copy()
-				err := k8sClient.Get(context.Background(), types.NamespacedName{
-					Name:      defaultStoreName,
-					Namespace: ss.GetObjectMeta().Namespace,
-				}, ss)
-				if err != nil {
-					return false
-				}
-				status := ss.GetStatus()
-				if len(status.Conditions) != 1 {
-					return false
-				}
-				return status.Conditions[0].Reason == esapi.ReasonInvalidProviderConfig &&
-					hasEvent(tc.store.GetTypeMeta().Kind, ss.GetName(), esapi.ReasonInvalidProviderConfig)
+	Context("Finalizer Management", func() {
+		BeforeEach(func() {
+			// Setup valid provider for finalizer tests
+			spc := test.store.GetSpec()
+			spc.Provider.Vault = nil
+			spc.Provider.Fake = &esapi.FakeProvider{
+				Data: []esapi.FakeProviderData{},
+			}
+		})
+
+		AfterEach(func() {
+			cleanupResources(test)
+		})
+
+		DescribeTable("Finalizer Addition", func(muts ...func(tc *testCase)) {
+			for _, mut := range muts {
+				mut(test)
+			}
+
+			Expect(k8sClient.Create(context.Background(), test.store)).ToNot(HaveOccurred())
+			Expect(k8sClient.Create(context.Background(), test.ps)).ToNot(HaveOccurred())
+
+			Eventually(func() []string {
+				return getStoreFinalizers(test.store)
 			}).
 				WithTimeout(time.Second * 10).
 				WithPolling(time.Second).
-				Should(BeTrue())
-		}
-	}
+				Should(ContainElement(secretStoreFinalizer))
+		},
+			Entry("[namespace] should add finalizer when PushSecret with DeletionPolicy=Delete is created", usePushSecret),
+			Entry("[cluster] should add finalizer when PushSecret with DeletionPolicy=Delete is created", usePushSecret, useClusterStore),
+		)
 
-	// if controllerClass does not match the controller
-	// should not touch this store
-	ignoreControllerClass := func(tc *testCase) {
-		spc := tc.store.GetSpec()
-		spc.Controller = "something-else"
-		tc.assert = func() {
-			Consistently(func() bool {
-				ss := tc.store.Copy()
-				err := k8sClient.Get(context.Background(), types.NamespacedName{
-					Name:      defaultStoreName,
-					Namespace: ss.GetObjectMeta().Namespace,
-				}, ss)
-				if err != nil {
-					return true
-				}
-				return len(ss.GetStatus().Conditions) == 0
+		DescribeTable("Finalizer Removal on PushSecret Deletion", func(muts ...func(tc *testCase)) {
+			for _, mut := range muts {
+				mut(test)
+			}
+
+			test.store.SetFinalizers([]string{secretStoreFinalizer})
+			Expect(k8sClient.Create(context.Background(), test.store)).ToNot(HaveOccurred())
+			Expect(k8sClient.Create(context.Background(), test.ps)).ToNot(HaveOccurred())
+			Expect(k8sClient.Delete(context.Background(), test.ps)).ToNot(HaveOccurred())
+
+			Eventually(func() []string {
+				return getStoreFinalizers(test.store)
+			}).
+				WithTimeout(time.Second * 10).
+				WithPolling(time.Second).
+				ShouldNot(ContainElement(secretStoreFinalizer))
+		},
+			Entry("[namespace] should remove finalizer when PushSecret is deleted", usePushSecret),
+			Entry("[cluster] should remove finalizer when PushSecret is deleted", usePushSecret, useClusterStore),
+		)
+
+		DescribeTable("Store Deletion Prevention", func(muts ...func(tc *testCase)) {
+			for _, mut := range muts {
+				mut(test)
+			}
+
+			Expect(k8sClient.Create(context.Background(), test.store)).ToNot(HaveOccurred())
+			Expect(k8sClient.Create(context.Background(), test.ps)).ToNot(HaveOccurred())
+
+			// Wait for finalizer to be added
+			Eventually(func() []string {
+				return getStoreFinalizers(test.store)
+			}).
+				WithTimeout(time.Second * 10).
+				WithPolling(time.Second).
+				Should(ContainElement(secretStoreFinalizer))
+
+			Expect(k8sClient.Delete(context.Background(), test.store)).ToNot(HaveOccurred())
+
+			Consistently(func() []string {
+				return getStoreFinalizers(test.store)
 			}).
 				WithTimeout(time.Second * 3).
 				WithPolling(time.Millisecond * 500).
-				Should(BeTrue())
-		}
-	}
+				Should(ContainElement(secretStoreFinalizer))
+		},
+			Entry("[namespace] should prevent deletion when finalizer exists", usePushSecret),
+			Entry("[cluster] should prevent deletion when finalizer exists", usePushSecret, useClusterStore),
+		)
 
-	validProvider := func(tc *testCase) {
-		spc := tc.store.GetSpec()
-		spc.Provider.Vault = nil
-		spc.Provider.Fake = &esapi.FakeProvider{
-			Data: []esapi.FakeProviderData{},
-		}
+		DescribeTable("Complete Deletion Flow", func(muts ...func(tc *testCase)) {
+			for _, mut := range muts {
+				mut(test)
+			}
+
+			Expect(k8sClient.Create(context.Background(), test.store)).ToNot(HaveOccurred())
+			Expect(k8sClient.Create(context.Background(), test.ps)).ToNot(HaveOccurred())
+			Expect(k8sClient.Delete(context.Background(), test.store)).ToNot(HaveOccurred())
+			Expect(k8sClient.Delete(context.Background(), test.ps)).ToNot(HaveOccurred())
 
-		tc.assert = func() {
 			Eventually(func() bool {
-				ss := tc.store.Copy()
 				err := k8sClient.Get(context.Background(), types.NamespacedName{
-					Name:      defaultStoreName,
-					Namespace: ss.GetNamespace(),
-				}, ss)
-				if err != nil {
-					return false
-				}
-
-				if len(ss.GetStatus().Conditions) != 1 {
-					return false
-				}
-
-				return ss.GetStatus().Conditions[0].Reason == esapi.ReasonStoreValid &&
-					ss.GetStatus().Conditions[0].Type == esapi.SecretStoreReady &&
-					ss.GetStatus().Conditions[0].Status == corev1.ConditionTrue &&
-					hasEvent(tc.store.GetTypeMeta().Kind, ss.GetName(), esapi.ReasonStoreValid)
+					Name:      test.store.GetName(),
+					Namespace: test.store.GetNamespace(),
+				}, test.store)
+				return apierrors.IsNotFound(err)
 			}).
 				WithTimeout(time.Second * 10).
 				WithPolling(time.Second).
 				Should(BeTrue())
-		}
-
-	}
+		},
+			Entry("[namespace] should allow deletion when both Store and PushSecret are deleted", usePushSecret),
+			Entry("[cluster] should allow deletion when both Store and PushSecret are deleted", usePushSecret, useClusterStore),
+		)
 
-	readWrite := func(tc *testCase) {
-		spc := tc.store.GetSpec()
-		spc.Provider.Vault = nil
-		spc.Provider.Fake = &esapi.FakeProvider{
-			Data: []esapi.FakeProviderData{},
-		}
+		DescribeTable("Multiple PushSecrets Scenario", func(muts ...func(tc *testCase)) {
+			for _, mut := range muts {
+				mut(test)
+			}
 
-		tc.assert = func() {
-			Eventually(func() bool {
-				ss := tc.store.Copy()
-				err := k8sClient.Get(context.Background(), types.NamespacedName{
-					Name:      defaultStoreName,
-					Namespace: ss.GetNamespace(),
-				}, ss)
-				if err != nil {
-					return false
-				}
+			ps2 := test.ps.DeepCopy()
+			ps2.Name = "push-secret-2"
 
-				if ss.GetStatus().Capabilities != esapi.SecretStoreReadWrite {
-					return false
-				}
+			Expect(k8sClient.Create(context.Background(), test.store)).ToNot(HaveOccurred())
+			Expect(k8sClient.Create(context.Background(), test.ps)).ToNot(HaveOccurred())
+			Expect(k8sClient.Create(context.Background(), ps2)).ToNot(HaveOccurred())
 
-				return true
+			// Wait for finalizer to be added
+			Eventually(func() []string {
+				return getStoreFinalizers(test.store)
 			}).
 				WithTimeout(time.Second * 10).
 				WithPolling(time.Second).
-				Should(BeTrue())
-		}
+				Should(ContainElement(secretStoreFinalizer))
 
-	}
+			Expect(k8sClient.Delete(context.Background(), test.ps)).ToNot(HaveOccurred())
 
-	// an unknown store validation result should be reflected
-	// in the store status condition
-	validationUnknown := func(tc *testCase) {
-		spc := tc.store.GetSpec()
-		spc.Provider.Vault = nil
-		validationResultUnknown := esapi.ValidationResultUnknown
-		spc.Provider.Fake = &esapi.FakeProvider{
-			Data:             []esapi.FakeProviderData{},
-			ValidationResult: &validationResultUnknown,
-		}
+			// Finalizer should remain because ps2 still exists
+			Consistently(func() []string {
+				return getStoreFinalizers(test.store)
+			}).
+				WithTimeout(time.Second * 3).
+				WithPolling(time.Millisecond * 500).
+				Should(ContainElement(secretStoreFinalizer))
 
-		tc.assert = func() {
-			Eventually(func() bool {
-				ss := tc.store.Copy()
+			// Cleanup
+			Expect(k8sClient.Delete(context.Background(), ps2)).ToNot(HaveOccurred())
+		},
+			Entry("[namespace] finalizer should remain when other PushSecrets exist", usePushSecret),
+			Entry("[cluster] finalizer should remain when other PushSecrets exist", usePushSecret, useClusterStore),
+		)
+
+		DescribeTable("DeletionPolicy Change", func(muts ...func(tc *testCase)) {
+			for _, mut := range muts {
+				mut(test)
+			}
+
+			Expect(k8sClient.Create(context.Background(), test.store)).ToNot(HaveOccurred())
+			Expect(k8sClient.Create(context.Background(), test.ps)).ToNot(HaveOccurred())
+
+			// Wait for finalizer to be added
+			Eventually(func() []string {
+				return getStoreFinalizers(test.store)
+			}).
+				WithTimeout(time.Second * 10).
+				WithPolling(time.Second).
+				Should(ContainElement(secretStoreFinalizer))
+
+			// Update PushSecret to DeletionPolicy=None
+			Eventually(func() error {
 				err := k8sClient.Get(context.Background(), types.NamespacedName{
-					Name:      defaultStoreName,
-					Namespace: ss.GetNamespace(),
-				}, ss)
-				if err != nil {
-					return false
-				}
-
-				if len(ss.GetStatus().Conditions) != 1 {
-					return false
-				}
-
-				return ss.GetStatus().Conditions[0].Reason == esapi.ReasonValidationUnknown &&
-					ss.GetStatus().Conditions[0].Type == esapi.SecretStoreReady &&
-					ss.GetStatus().Conditions[0].Status == corev1.ConditionTrue &&
-					hasEvent(tc.store.GetTypeMeta().Kind, ss.GetName(), esapi.ReasonValidationUnknown)
+					Name:      test.ps.Name,
+					Namespace: test.ps.Namespace,
+				}, test.ps)
+				Expect(err).ToNot(HaveOccurred())
+				test.ps.Spec.DeletionPolicy = esv1alpha1.PushSecretDeletionPolicyNone
+				return k8sClient.Update(context.Background(), test.ps)
 			}).
-				WithTimeout(time.Second * 5).
+				WithTimeout(time.Second * 10).
 				WithPolling(time.Second).
-				Should(BeTrue())
+				Should(Succeed())
+
+			Eventually(func() []string {
+				return getStoreFinalizers(test.store)
+			}).
+				WithTimeout(time.Second * 10).
+				WithPolling(time.Second).
+				ShouldNot(ContainElement(secretStoreFinalizer))
+		},
+			Entry("[namespace] should remove finalizer when DeletionPolicy changes to None", usePushSecret),
+			Entry("[cluster] should remove finalizer when DeletionPolicy changes to None", usePushSecret, useClusterStore),
+		)
+	})
+
+})
+
+func cleanupResources(test *testCase) {
+	if test.ps != nil {
+		err := k8sClient.Delete(context.Background(), test.ps)
+		if err != nil && !apierrors.IsNotFound(err) {
+			Expect(err).ToNot(HaveOccurred())
 		}
 	}
 
-	DescribeTable("Controller Reconcile logic", func(muts ...func(tc *testCase)) {
-		for _, mut := range muts {
-			mut(test)
-		}
-		err := k8sClient.Create(context.Background(), test.store.Copy())
+	err := k8sClient.Delete(context.Background(), test.store)
+	if err != nil && !apierrors.IsNotFound(err) {
 		Expect(err).ToNot(HaveOccurred())
-		test.assert()
-	},
-		// namespaced store
-		Entry("[namespace] invalid provider with secretStore should set InvalidStore condition", invalidProvider),
-		Entry("[namespace] ignore stores with non-matching class", ignoreControllerClass),
-		Entry("[namespace] valid provider has status=ready", validProvider),
-		Entry("[namespace] valid provider has capabilities=ReadWrite", readWrite),
-		Entry("[namespace] validation unknown status should set ValidationUnknown condition", validationUnknown),
-
-		// cluster store
-		Entry("[cluster] invalid provider with secretStore should set InvalidStore condition", invalidProvider, useClusterStore),
-		Entry("[cluster] ignore stores with non-matching class", ignoreControllerClass, useClusterStore),
-		Entry("[cluster] valid provider has status=ready", validProvider, useClusterStore),
-		Entry("[cluster] valid provider has capabilities=ReadWrite", readWrite, useClusterStore),
-		Entry("[cluster] validation unknown status should set ValidationUnknown condition", validationUnknown, useClusterStore),
-	)
+	}
 
-})
+	Eventually(func() bool {
+		err := k8sClient.Get(context.Background(), types.NamespacedName{
+			Name:      test.store.GetName(),
+			Namespace: test.store.GetNamespace(),
+		}, test.store)
+		return apierrors.IsNotFound(err)
+	}).
+		WithTimeout(time.Second * 10).
+		WithPolling(time.Second).
+		Should(BeTrue())
+}
 
-const (
-	defaultStoreName       = "default-store"
-	defaultControllerClass = "test-ctrl"
-)
+func getStoreFinalizers(store esapi.GenericStore) []string {
+	err := k8sClient.Get(context.Background(), types.NamespacedName{
+		Name:      store.GetName(),
+		Namespace: store.GetNamespace(),
+	}, store)
+	if err != nil {
+		return []string{}
+	}
+	return store.GetFinalizers()
+}
 
 func makeDefaultTestcase() *testCase {
 	return &testCase{
@@ -267,6 +494,32 @@ func useClusterStore(tc *testCase) {
 		},
 		Spec: *spc,
 	}
+
+	if tc.ps != nil {
+		tc.ps.Spec.SecretStoreRefs[0].Kind = esapi.ClusterSecretStoreKind
+	}
+}
+
+func usePushSecret(tc *testCase) {
+	tc.ps = &esv1alpha1.PushSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "push-secret",
+			Namespace: "default",
+		},
+		Spec: esv1alpha1.PushSecretSpec{
+			DeletionPolicy: esv1alpha1.PushSecretDeletionPolicyDelete,
+			Selector: esv1alpha1.PushSecretSelector{
+				Secret: &esv1alpha1.PushSecretSecret{
+					Name: "foo",
+				},
+			},
+			SecretStoreRefs: []esv1alpha1.PushSecretStoreRef{
+				{
+					Name: defaultStoreName,
+				},
+			},
+		},
+	}
 }
 
 func hasEvent(involvedKind, name, reason string) bool {

+ 25 - 8
pkg/controllers/secretstore/secretstore_controller.go

@@ -25,8 +25,11 @@ import (
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/controller"
+	"sigs.k8s.io/controller-runtime/pkg/handler"
+	ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile"
 
 	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore/ssmetrics"
 
@@ -37,11 +40,12 @@ import (
 // StoreReconciler reconciles a SecretStore object.
 type StoreReconciler struct {
 	client.Client
-	Log             logr.Logger
-	Scheme          *runtime.Scheme
-	recorder        record.EventRecorder
-	RequeueInterval time.Duration
-	ControllerClass string
+	Log               logr.Logger
+	Scheme            *runtime.Scheme
+	recorder          record.EventRecorder
+	RequeueInterval   time.Duration
+	ControllerClass   string
+	PushSecretEnabled bool
 }
 
 func (r *StoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
@@ -63,7 +67,7 @@ func (r *StoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl
 		return ctrl.Result{}, err
 	}
 
-	return reconcile(ctx, req, &ss, r.Client, log, Opts{
+	return reconcile(ctx, req, &ss, r.Client, r.PushSecretEnabled, log, Opts{
 		ControllerClass: r.ControllerClass,
 		GaugeVecGetter:  ssmetrics.GetGaugeVec,
 		Recorder:        r.recorder,
@@ -75,8 +79,21 @@ func (r *StoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl
 func (r *StoreReconciler) SetupWithManager(mgr ctrl.Manager, opts controller.Options) error {
 	r.recorder = mgr.GetEventRecorderFor("secret-store")
 
-	return ctrl.NewControllerManagedBy(mgr).
-		WithOptions(opts).
+	builder := ctrl.NewControllerManagedBy(mgr)
+
+	if r.PushSecretEnabled {
+		return builder.WithOptions(opts).
+			For(&esapi.SecretStore{}).
+			Watches(
+				&esv1alpha1.PushSecret{},
+				handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrlreconcile.Request {
+					return findStoresForPushSecret(ctx, r.Client, obj, &esapi.SecretStoreList{})
+				}),
+			).
+			Complete(r)
+	}
+
+	return builder.WithOptions(opts).
 		For(&esapi.SecretStore{}).
 		Complete(r)
 }

+ 42 - 10
pkg/controllers/secretstore/suite_test.go

@@ -17,6 +17,7 @@ package secretstore
 import (
 	"context"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"k8s.io/client-go/kubernetes/scheme"
@@ -30,6 +31,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/metrics/server"
 
 	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
 	ctrlcommon "github.com/external-secrets/external-secrets/pkg/controllers/common"
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore/cssmetrics"
@@ -69,6 +71,9 @@ var _ = BeforeSuite(func() {
 	err = esapi.AddToScheme(scheme.Scheme)
 	Expect(err).NotTo(HaveOccurred())
 
+	err = esv1alpha1.AddToScheme(scheme.Scheme)
+	Expect(err).NotTo(HaveOccurred())
+
 	k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{
 		Scheme: scheme.Scheme,
 		Metrics: server.Options{
@@ -77,26 +82,53 @@ var _ = BeforeSuite(func() {
 	})
 	Expect(err).ToNot(HaveOccurred())
 
-	k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
-	Expect(err).ToNot(HaveOccurred())
+	k8sClient = k8sManager.GetClient()
 	Expect(k8sClient).ToNot(BeNil())
 
 	err = (&StoreReconciler{
-		Client:          k8sClient,
-		Scheme:          k8sManager.GetScheme(),
-		Log:             ctrl.Log.WithName("controllers").WithName("SecretStore"),
-		ControllerClass: defaultControllerClass,
+		Client:            k8sManager.GetClient(),
+		Scheme:            k8sManager.GetScheme(),
+		Log:               ctrl.Log.WithName("controllers").WithName("SecretStore"),
+		ControllerClass:   defaultControllerClass,
+		PushSecretEnabled: true, // enable PushSecret feature for testing
 	}).SetupWithManager(k8sManager, controller.Options{
 		MaxConcurrentReconciles: 1,
 		RateLimiter:             ctrlcommon.BuildRateLimiter(),
 	})
 	Expect(err).ToNot(HaveOccurred())
 
+	// Index PushSecret status.syncedPushSecrets to find all stores that have synced a specific PushSecret.
+	err = k8sManager.GetFieldIndexer().IndexField(context.Background(), &esv1alpha1.PushSecret{}, "status.syncedPushSecrets", func(obj client.Object) []string {
+		ps := obj.(*esv1alpha1.PushSecret)
+		var storeNames []string
+		if ps.Spec.DeletionPolicy != esv1alpha1.PushSecretDeletionPolicyDelete {
+			return nil
+		}
+		for storeKey := range ps.Status.SyncedPushSecrets {
+			if strings.Contains(storeKey, "/") {
+				parts := strings.SplitN(storeKey, "/", 2)
+				if len(parts) == 2 {
+					storeNames = append(storeNames, parts[1])
+				}
+			}
+		}
+		return storeNames
+	})
+	Expect(err).ToNot(HaveOccurred())
+
+	// Index PushSecret spec.deletionPolicy to find all PushSecrets with deletionPolicy: Delete.
+	err = k8sManager.GetFieldIndexer().IndexField(context.Background(), &esv1alpha1.PushSecret{}, "spec.deletionPolicy", func(obj client.Object) []string {
+		ps := obj.(*esv1alpha1.PushSecret)
+		return []string{string(ps.Spec.DeletionPolicy)}
+	})
+	Expect(err).ToNot(HaveOccurred())
+
 	err = (&ClusterStoreReconciler{
-		Client:          k8sClient,
-		Scheme:          k8sManager.GetScheme(),
-		ControllerClass: defaultControllerClass,
-		Log:             ctrl.Log.WithName("controllers").WithName("ClusterSecretStore"),
+		Client:            k8sManager.GetClient(),
+		Scheme:            k8sManager.GetScheme(),
+		ControllerClass:   defaultControllerClass,
+		Log:               ctrl.Log.WithName("controllers").WithName("ClusterSecretStore"),
+		PushSecretEnabled: true, // enable PushSecret feature for testing
 	}).SetupWithManager(k8sManager, controller.Options{
 		MaxConcurrentReconciles: 1,
 		RateLimiter:             ctrlcommon.BuildRateLimiter(),

+ 56 - 0
pkg/controllers/secretstore/util.go

@@ -15,10 +15,14 @@ limitations under the License.
 package secretstore
 
 import (
+	"fmt"
+
 	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/labels"
 
 	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore/metrics"
 )
 
@@ -76,3 +80,55 @@ func filterOutCondition(conditions []esapi.SecretStoreStatusCondition, condType
 	}
 	return newConditions
 }
+
+// storeMatchesRef checks if a given store matches a store reference (PushSecretStoreRef).
+// This helper function should be shared to avoid code duplication.
+// A match can be by name or by label selector, respecting the Kind.
+func storeMatchesRef(store esapi.GenericStore, ref esv1alpha1.PushSecretStoreRef) bool {
+	storeKind := store.GetKind()
+	storeName := store.GetName()
+
+	// Check if the Kind of the reference is compatible with the store's Kind.
+	// A reference with an empty Kind is compatible with both SecretStore and ClusterSecretStore.
+	kindMatches := (ref.Kind == storeKind) || (ref.Kind == "" && (storeKind == esapi.SecretStoreKind || storeKind == esapi.ClusterSecretStoreKind))
+	if !kindMatches {
+		return false
+	}
+
+	// Check for a name match.
+	if ref.Name == storeName {
+		return true
+	}
+
+	// Check for a label selector match.
+	if ref.LabelSelector != nil {
+		selector, err := metav1.LabelSelectorAsSelector(ref.LabelSelector)
+		// Skips invalid selectors.
+		if err != nil {
+			return false
+		}
+		if selector.Matches(labels.Set(store.GetLabels())) {
+			return true
+		}
+	}
+
+	return false
+}
+
+// shouldReconcileSecretStoreForPushSecret determines if a SecretStore should be reconciled
+// when a PushSecret changes, based on whether the PushSecret references this store.
+func shouldReconcileSecretStoreForPushSecret(store esapi.GenericStore, ps *esv1alpha1.PushSecret) bool {
+	// Check if this PushSecret has pushed to this store
+	storeKey := fmt.Sprintf("%s/%s", store.GetKind(), store.GetName())
+	if _, hasPushed := ps.Status.SyncedPushSecrets[storeKey]; hasPushed {
+		return true
+	}
+	// Also check if the PushSecret references this store in its spec
+	for _, storeRef := range ps.Spec.SecretStoreRefs {
+		if storeMatchesRef(store, storeRef) {
+			return true
+		}
+	}
+
+	return false
+}