Procházet zdrojové kódy

feat: split pushsecret v2 store support

Moritz Johner před 2 měsíci
rodič
revize
48d5002b4b

+ 0 - 1
pkg/controllers/clusterpushsecret/clusterpushsecret_controller_test.go

@@ -518,7 +518,6 @@ var _ = Describe("ClusterPushSecret controller", func() {
 							SecretStoreRefs: []v1alpha1.PushSecretStoreRef{
 								{
 									Name: updateStoreName,
-									Kind: "SecretStore",
 								},
 							},
 							UpdatePolicy: "Replace",

+ 111 - 56
pkg/controllers/pushsecret/pushsecret_controller.go

@@ -45,11 +45,12 @@ import (
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
 	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/pushsecret/psmetrics"
-	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore"
 	ctrlutil "github.com/external-secrets/external-secrets/pkg/controllers/util"
+	"github.com/external-secrets/external-secrets/runtime/clientmanager"
 	"github.com/external-secrets/external-secrets/runtime/esutils"
 	"github.com/external-secrets/external-secrets/runtime/esutils/resolvers"
 	"github.com/external-secrets/external-secrets/runtime/statemanager"
@@ -147,7 +148,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 	defer func() { pushSecretReconcileDuration.With(resourceLabels).Set(float64(time.Since(start))) }()
 
 	var ps esapi.PushSecret
-	mgr := secretstore.NewManager(r.Client, r.ControllerClass, false)
+	mgr := clientmanager.NewManager(r.Client, r.ControllerClass, false)
 	defer func() {
 		_ = mgr.Close(ctx)
 	}()
@@ -176,6 +177,48 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 			log.Error(err, errPatchStatus)
 		}
 	}()
+	// Get secret stores early so they can be used for finalizer deletion
+	secretStoresV2, err := r.GetSecretStoresV2(ctx, ps)
+	if err != nil {
+		r.markAsFailed(err.Error(), &ps, nil)
+		return ctrl.Result{}, err
+	}
+
+	// Filter and prepare stores (this logic was moved from later)
+	activeSecretStores := make(map[esapi.PushSecretStoreRef]any, len(secretStoresV2))
+	for ref, store := range secretStoresV2 {
+		if v1Store, ok := store.(esv1.GenericStore); ok {
+			if !v1Store.GetDeletionTimestamp().IsZero() {
+				log.Info("skipping SecretStore that is being deleted", "storeName", v1Store.GetName(), "storeKind", v1Store.GetKind())
+				continue
+			}
+		}
+		activeSecretStores[ref] = store
+	}
+
+	activeSecretStoresV1 := make(map[esapi.PushSecretStoreRef]esv1.GenericStore)
+	for ref, store := range activeSecretStores {
+		if v1Store, ok := store.(esv1.GenericStore); ok {
+			activeSecretStoresV1[ref] = v1Store
+		}
+	}
+
+	filteredV1Stores, err := removeUnmanagedStores(ctx, req.Namespace, r, activeSecretStoresV1)
+	if err != nil {
+		r.markAsFailed(err.Error(), &ps, nil)
+		return ctrl.Result{}, err
+	}
+
+	finalStores := make(map[esapi.PushSecretStoreRef]any)
+	for ref, store := range filteredV1Stores {
+		finalStores[ref] = store
+	}
+	for ref, store := range activeSecretStores {
+		if _, ok := store.(esv1.GenericStore); !ok {
+			finalStores[ref] = store
+		}
+	}
+
 	switch ps.Spec.DeletionPolicy {
 	case esapi.PushSecretDeletionPolicyDelete:
 		// finalizer logic. Only added if we should delete the secrets
@@ -188,7 +231,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 			}
 		} else if controllerutil.ContainsFinalizer(&ps, pushSecretFinalizer) {
 			// trigger a cleanup with no Synced Map
-			badState, err := r.DeleteSecretFromProviders(ctx, &ps, esapi.SyncedPushSecretsMap{}, mgr)
+			badState, err := r.DeleteSecretFromProvidersV2(ctx, &ps, esapi.SyncedPushSecretsMap{}, finalStores)
 			if err != nil {
 				msg := fmt.Sprintf("Failed to Delete Secrets from Provider: %v", err)
 				r.markAsFailed(msg, &ps, badState)
@@ -226,6 +269,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 		return ctrl.Result{}, err
 	}
 
+	// if no stores are managed by this controller
+	if len(finalStores) == 0 {
+		return ctrl.Result{}, nil
+	}
+
 	secrets, err := r.resolveSecrets(ctx, &ps)
 	if err != nil {
 		isSecretSelector := ps.Spec.Selector.Secret != nil && ps.Spec.Selector.Secret.Name != ""
@@ -237,35 +285,15 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 		r.markAsFailed(errFailedGetSecret, &ps, nil)
 		return ctrl.Result{}, err
 	}
-	secretStores, err := r.GetSecretStores(ctx, ps)
-	if err != nil {
-		r.markAsFailed(err.Error(), &ps, nil)
-
-		return ctrl.Result{}, err
-	}
 
-	// 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
+	resolvedStores := make([]storeInfo, 0, len(finalStores))
+	for ref, store := range finalStores {
+		if si, ok := resolvedStoreInfo(ref, store); ok {
+			resolvedStores = append(resolvedStores, si)
 		}
-		activeSecretStores[ref] = store
 	}
 
-	secretStores, err = removeUnmanagedStores(ctx, req.Namespace, r, activeSecretStores)
-	if err != nil {
-		r.markAsFailed(err.Error(), &ps, nil)
-		return ctrl.Result{}, err
-	}
-	// if no stores are managed by this controller
-	if len(secretStores) == 0 {
-		return ctrl.Result{}, nil
-	}
-
-	if err := validateDataToMatchesResolvedStores(ps.Spec.DataTo, secretStores); err != nil {
+	if err := validateDataToMatchesResolvedStores(ps.Spec.DataTo, resolvedStores); err != nil {
 		r.markAsFailed(err.Error(), &ps, nil)
 		return ctrl.Result{}, err
 	}
@@ -276,7 +304,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 			return ctrl.Result{}, err
 		}
 
-		syncedSecrets, err := r.PushSecretToProviders(ctx, secretStores, ps, &secret, mgr)
+		syncedSecrets, err := r.PushSecretToProvidersV2(ctx, finalStores, ps, &secret, mgr)
 		if err != nil {
 			if errors.Is(err, locks.ErrConflict) {
 				log.Info("retry to acquire lock to update the secret later", "error", err)
@@ -291,7 +319,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 		}
 		switch ps.Spec.DeletionPolicy {
 		case esapi.PushSecretDeletionPolicyDelete:
-			badSyncState, err := r.DeleteSecretFromProviders(ctx, &ps, syncedSecrets, mgr)
+			badSyncState, err := r.DeleteSecretFromProvidersV2(ctx, &ps, syncedSecrets, finalStores)
 			if err != nil {
 				msg := fmt.Sprintf("Failed to Delete Secrets from Provider: %v", err)
 				r.markAsFailed(msg, &ps, badSyncState)
@@ -310,7 +338,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 }
 
 // handleSourceSecretDeleted cleans up provider secrets when source Secret is unavailable.
-func (r *Reconciler) handleSourceSecretDeleted(ctx context.Context, ps *esapi.PushSecret, mgr *secretstore.Manager) error {
+func (r *Reconciler) handleSourceSecretDeleted(ctx context.Context, ps *esapi.PushSecret, mgr *clientmanager.Manager) error {
 	log := r.Log.WithValues("pushsecret", client.ObjectKeyFromObject(ps))
 	log.Info("source secret unavailable, cleaning up provider secrets", "syncedSecrets", len(ps.Status.SyncedPushSecrets))
 
@@ -391,7 +419,7 @@ func mergeSecretState(newMap, old esapi.SyncedPushSecretsMap) esapi.SyncedPushSe
 // DeleteSecretFromProviders removes secrets from providers that are no longer needed.
 // It compares the existing synced secrets in the PushSecret status with the new desired state,
 // and deletes any secrets that are no longer present in the new state.
-func (r *Reconciler) DeleteSecretFromProviders(ctx context.Context, ps *esapi.PushSecret, newMap esapi.SyncedPushSecretsMap, mgr *secretstore.Manager) (esapi.SyncedPushSecretsMap, error) {
+func (r *Reconciler) DeleteSecretFromProviders(ctx context.Context, ps *esapi.PushSecret, newMap esapi.SyncedPushSecretsMap, mgr *clientmanager.Manager) (esapi.SyncedPushSecretsMap, error) {
 	out := mergeSecretState(newMap, ps.Status.SyncedPushSecrets)
 	for storeName, oldData := range ps.Status.SyncedPushSecrets {
 		storeRef := esv1.SecretStoreRef{
@@ -449,7 +477,7 @@ func (r *Reconciler) PushSecretToProviders(
 	stores map[esapi.PushSecretStoreRef]esv1.GenericStore,
 	ps esapi.PushSecret,
 	secret *v1.Secret,
-	mgr *secretstore.Manager,
+	mgr *clientmanager.Manager,
 ) (esapi.SyncedPushSecretsMap, error) {
 	out := make(esapi.SyncedPushSecretsMap)
 	var err error
@@ -468,7 +496,7 @@ func (r *Reconciler) handlePushSecretDataForStore(
 	ps esapi.PushSecret,
 	secret *v1.Secret,
 	out esapi.SyncedPushSecretsMap,
-	mgr *secretstore.Manager,
+	mgr *clientmanager.Manager,
 	si storeInfo,
 ) (esapi.SyncedPushSecretsMap, error) {
 	storeKey := fmt.Sprintf("%v/%v", si.Kind, si.Name)
@@ -798,25 +826,11 @@ func statusRef(ref esv1.PushSecretData) string {
 
 // removeUnmanagedStores iterates over all SecretStore references and evaluates the controllerClass property.
 // Returns a map containing only managed stores.
-func removeUnmanagedStores(ctx context.Context, namespace string, r *Reconciler, ss map[esapi.PushSecretStoreRef]esv1.GenericStore) (map[esapi.PushSecretStoreRef]esv1.GenericStore, error) {
-	for ref := range ss {
-		var store esv1.GenericStore
-		switch ref.Kind {
-		case esv1.SecretStoreKind:
-			store = &esv1.SecretStore{}
-		case esv1.ClusterSecretStoreKind:
-			store = &esv1.ClusterSecretStore{}
-			namespace = ""
-		}
-		err := r.Client.Get(ctx, types.NamespacedName{
-			Name:      ref.Name,
-			Namespace: namespace,
-		}, store)
-
-		if err != nil {
-			return ss, err
+func removeUnmanagedStores(_ context.Context, _ string, r *Reconciler, ss map[esapi.PushSecretStoreRef]esv1.GenericStore) (map[esapi.PushSecretStoreRef]esv1.GenericStore, error) {
+	for ref, store := range ss {
+		if store == nil {
+			return ss, fmt.Errorf("secretStoreRef %q resolved to nil store", ref.Name)
 		}
-
 		class := store.GetSpec().Controller
 		if class != "" && class != r.ControllerClass {
 			delete(ss, ref)
@@ -1088,10 +1102,51 @@ func storeRefExistsInList(ref *esapi.PushSecretStoreRef, storeRefs []esapi.PushS
 	return false
 }
 
+func resolvedStoreInfo(ref esapi.PushSecretStoreRef, store any) (storeInfo, bool) {
+	if genericStore, ok := store.(esv1.GenericStore); ok {
+		kind := resolvedPushStoreKind(ref.Kind, genericStore)
+		return storeInfo{
+			Name:   genericStore.GetName(),
+			Kind:   kind,
+			Labels: genericStore.GetLabels(),
+		}, true
+	}
+
+	if obj, ok := store.(client.Object); ok {
+		kind := resolvedPushStoreKind(ref.Kind, obj)
+		return storeInfo{
+			Name:   obj.GetName(),
+			Kind:   kind,
+			Labels: obj.GetLabels(),
+		}, true
+	}
+
+	return storeInfo{}, false
+}
+
+func resolvedPushStoreKind(refKind string, store any) string {
+	if refKind != "" {
+		return refKind
+	}
+
+	if genericStore, ok := store.(esv1.GenericStore); ok {
+		return genericStore.GetKind()
+	}
+
+	switch store.(type) {
+	case *esv2alpha1.ProviderStore:
+		return esv1.ProviderStoreKindStr
+	case *esv2alpha1.ClusterProviderStore:
+		return esv1.ClusterProviderStoreKindStr
+	default:
+		return esv1.SecretStoreKind
+	}
+}
+
 // validateDataToMatchesResolvedStores checks that every dataTo entry with a
 // labelSelector actually matches at least one resolved store. Without this,
 // a misconfigured labelSelector silently becomes a no-op.
-func validateDataToMatchesResolvedStores(dataToList []esapi.PushSecretDataTo, stores map[esapi.PushSecretStoreRef]esv1.GenericStore) error {
+func validateDataToMatchesResolvedStores(dataToList []esapi.PushSecretDataTo, stores []storeInfo) error {
 	for i, dataTo := range dataToList {
 		if dataTo.StoreRef == nil || dataTo.StoreRef.LabelSelector == nil {
 			continue
@@ -1119,9 +1174,9 @@ func validateDataToMatchesResolvedStores(dataToList []esapi.PushSecretDataTo, st
 
 // anyStoreMatchesSelector returns true if at least one resolved store matches
 // the given kind and label selector.
-func anyStoreMatchesSelector(kind string, selector labels.Selector, stores map[esapi.PushSecretStoreRef]esv1.GenericStore) bool {
-	for ref, store := range stores {
-		if ref.Kind == kind && selector.Matches(labels.Set(store.GetLabels())) {
+func anyStoreMatchesSelector(kind string, selector labels.Selector, stores []storeInfo) bool {
+	for _, store := range stores {
+		if store.Kind == kind && selector.Matches(labels.Set(store.Labels)) {
 			return true
 		}
 	}

+ 32 - 2
pkg/controllers/pushsecret/pushsecret_controller_test.go

@@ -178,6 +178,36 @@ var _ = Describe("PushSecret controller", func() {
 		storePrefixTemplate = "SecretStore/%v"
 	)
 
+	It("keeps managed stores when secretStoreRef kind is omitted", func() {
+		store := &esv1.SecretStore{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      PushSecretStore,
+				Namespace: PushSecretNamespace,
+			},
+			Spec: esv1.SecretStoreSpec{
+				Provider: &esv1.SecretStoreProvider{
+					Fake: &esv1.FakeProvider{
+						Data: []esv1.FakeProviderData{
+							{Key: "key", Value: "value"},
+						},
+					},
+				},
+			},
+		}
+		Expect(k8sClient.Create(context.Background(), store)).To(Succeed())
+
+		stores, err := removeUnmanagedStores(context.Background(), PushSecretNamespace, &Reconciler{
+			Client: k8sClient,
+		}, map[v1alpha1.PushSecretStoreRef]esv1.GenericStore{
+			{
+				Name:       PushSecretStore,
+				APIVersion: esv1.SchemeGroupVersion.String(),
+			}: store,
+		})
+		Expect(err).ToNot(HaveOccurred())
+		Expect(stores).To(HaveLen(1))
+	})
+
 	makeDefaultTestcase := func() *testCase {
 		return &testCase{
 			pushsecret: &v1alpha1.PushSecret{
@@ -417,8 +447,8 @@ var _ = Describe("PushSecret controller", func() {
 			Spec: v1alpha1.PushSecretSpec{
 				SecretStoreRefs: []v1alpha1.PushSecretStoreRef{
 					{
-						Name: PushSecretStore,
-						Kind: "SecretStore",
+						Name:       PushSecretStore,
+						APIVersion: esv1.SchemeGroupVersion.String(),
 					},
 				},
 				Selector: v1alpha1.PushSecretSelector{

+ 246 - 0
pkg/controllers/pushsecret/pushsecret_controller_v2.go

@@ -0,0 +1,246 @@
+/*
+Copyright © The ESO Authors
+
+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 pushsecret
+
+import (
+	"context"
+	"fmt"
+	"maps"
+	"strings"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
+	"github.com/external-secrets/external-secrets/runtime/clientmanager"
+)
+
+// GetSecretStoresV2 retrieves both v1 stores and clean v2 ProviderStores.
+func (r *Reconciler) GetSecretStoresV2(ctx context.Context, ps esv1alpha1.PushSecret) (map[esv1alpha1.PushSecretStoreRef]any, error) {
+	stores := make(map[esv1alpha1.PushSecretStoreRef]any)
+
+	for _, refStore := range ps.Spec.SecretStoreRefs {
+		if refStore.LabelSelector != nil {
+			resolvedStores, err := r.getSecretStoresFromSelectorV2(ctx, refStore, ps.Namespace)
+			if err != nil {
+				return nil, err
+			}
+			maps.Copy(stores, resolvedStores)
+			continue
+		}
+
+		store, ok, err := r.resolveV2Store(ctx, refStore, ps.Namespace)
+		if err != nil {
+			return nil, err
+		}
+		if ok {
+			stores[refStore] = store
+			continue
+		}
+
+		// Get v1 SecretStore (existing implementation)
+		store, err = r.getSecretStoreFromName(ctx, refStore, ps.Namespace)
+		if err != nil {
+			return nil, err
+		}
+		stores[refStore] = store
+	}
+
+	return stores, nil
+}
+
+func isCleanStoreRef(ref esv1alpha1.PushSecretStoreRef) bool {
+	switch ref.Kind {
+	case esapi.ProviderStoreKindStr, esapi.ClusterProviderStoreKindStr:
+		return true
+	}
+	return ref.APIVersion == esv2alpha1.SchemeGroupVersion.String()
+}
+
+func (r *Reconciler) getSecretStoresFromSelectorV2(ctx context.Context, storeRef esv1alpha1.PushSecretStoreRef, namespace string) (map[esv1alpha1.PushSecretStoreRef]any, error) {
+	selector, err := metav1.LabelSelectorAsSelector(storeRef.LabelSelector)
+	if err != nil {
+		return nil, fmt.Errorf("could not convert labels: %w", err)
+	}
+
+	listOptions := &client.ListOptions{LabelSelector: selector}
+	stores := make(map[esv1alpha1.PushSecretStoreRef]any)
+
+	switch storeRef.Kind {
+	case esapi.ProviderStoreKindStr:
+		listOptions.Namespace = namespace
+		var storeList esv2alpha1.ProviderStoreList
+		if err := r.List(ctx, &storeList, listOptions); err != nil {
+			return nil, fmt.Errorf("could not list ProviderStores: %w", err)
+		}
+		for i := range storeList.Items {
+			store := &storeList.Items[i]
+			stores[esv1alpha1.PushSecretStoreRef{Name: store.Name, Kind: esapi.ProviderStoreKindStr}] = store
+		}
+	case esapi.ClusterProviderStoreKindStr:
+		var storeList esv2alpha1.ClusterProviderStoreList
+		if err := r.List(ctx, &storeList, listOptions); err != nil {
+			return nil, fmt.Errorf("could not list ClusterProviderStores: %w", err)
+		}
+		for i := range storeList.Items {
+			store := &storeList.Items[i]
+			stores[esv1alpha1.PushSecretStoreRef{Name: store.Name, Kind: esapi.ClusterProviderStoreKindStr}] = store
+		}
+	case esapi.ClusterSecretStoreKind:
+		var storeList esapi.ClusterSecretStoreList
+		if err := r.List(ctx, &storeList, listOptions); err != nil {
+			return nil, fmt.Errorf("could not list cluster Secret Stores: %w", err)
+		}
+		for i := range storeList.Items {
+			store := &storeList.Items[i]
+			stores[esv1alpha1.PushSecretStoreRef{Name: store.Name, Kind: esapi.ClusterSecretStoreKind}] = store
+		}
+	default:
+		listOptions.Namespace = namespace
+		var storeList esapi.SecretStoreList
+		if err := r.List(ctx, &storeList, listOptions); err != nil {
+			return nil, fmt.Errorf("could not list Secret Stores: %w", err)
+		}
+		for i := range storeList.Items {
+			store := &storeList.Items[i]
+			stores[esv1alpha1.PushSecretStoreRef{Name: store.Name, Kind: esapi.SecretStoreKind}] = store
+		}
+	}
+
+	return stores, nil
+}
+
+func (r *Reconciler) resolveV2Store(ctx context.Context, storeRef esv1alpha1.PushSecretStoreRef, namespace string) (any, bool, error) {
+	if storeRef.APIVersion != "" && storeRef.APIVersion != esapi.SchemeGroupVersion.String() && !isCleanStoreRef(storeRef) {
+		return nil, false, nil
+	}
+	if storeRef.Name == "" {
+		return nil, false, nil
+	}
+
+	switch storeRef.Kind {
+	case esapi.ProviderStoreKindStr:
+		var store esv2alpha1.ProviderStore
+		storeKey := types.NamespacedName{Name: storeRef.Name, Namespace: namespace}
+		if err := r.Client.Get(ctx, storeKey, &store); err != nil {
+			return nil, true, fmt.Errorf("failed to get v2 ProviderStore %s: %w", storeRef.Name, err)
+		}
+		return &store, true, nil
+	case esapi.ClusterProviderStoreKindStr:
+		var store esv2alpha1.ClusterProviderStore
+		storeKey := types.NamespacedName{Name: storeRef.Name}
+		if err := r.Client.Get(ctx, storeKey, &store); err != nil {
+			return nil, true, fmt.Errorf("failed to get v2 ClusterProviderStore %s: %w", storeRef.Name, err)
+		}
+		return &store, true, nil
+	case "":
+		var providerStore esv2alpha1.ProviderStore
+		providerStoreKey := types.NamespacedName{Name: storeRef.Name, Namespace: namespace}
+		if err := r.Client.Get(ctx, providerStoreKey, &providerStore); err == nil {
+			return &providerStore, true, nil
+		}
+
+		var clusterProviderStore esv2alpha1.ClusterProviderStore
+		clusterProviderStoreKey := types.NamespacedName{Name: storeRef.Name}
+		if err := r.Client.Get(ctx, clusterProviderStoreKey, &clusterProviderStore); err == nil {
+			return &clusterProviderStore, true, nil
+		}
+	}
+
+	return nil, false, nil
+}
+
+// PushSecretToProvidersV2 pushes secret data to both v1 stores and v2 providers.
+func (r *Reconciler) PushSecretToProvidersV2(
+	ctx context.Context,
+	stores map[esv1alpha1.PushSecretStoreRef]any,
+	ps esv1alpha1.PushSecret,
+	secret *corev1.Secret,
+	mgr *clientmanager.Manager,
+) (esv1alpha1.SyncedPushSecretsMap, error) {
+	out := make(esv1alpha1.SyncedPushSecretsMap)
+	for ref, store := range stores {
+		si, ok := resolvedStoreInfo(ref, store)
+		if !ok {
+			continue
+		}
+
+		var err error
+		out, err = r.handlePushSecretDataForStore(ctx, ps, secret, out, mgr, si)
+		if err != nil {
+			return out, err
+		}
+	}
+	return out, nil
+}
+
+// DeleteSecretFromProvidersV2 removes secrets from v2 providers when they're no longer needed.
+func (r *Reconciler) DeleteSecretFromProvidersV2(
+	ctx context.Context,
+	ps *esv1alpha1.PushSecret,
+	newMap esv1alpha1.SyncedPushSecretsMap,
+	_ map[esv1alpha1.PushSecretStoreRef]any,
+) (esv1alpha1.SyncedPushSecretsMap, error) {
+	out := mergeSecretState(newMap, ps.Status.SyncedPushSecrets)
+	mgr := clientmanager.NewManager(r.Client, r.ControllerClass, false)
+	defer func() {
+		_ = mgr.Close(ctx)
+	}()
+
+	for storeName, oldData := range ps.Status.SyncedPushSecrets {
+		// Parse store name format "Kind/Name"
+		parts := strings.Split(storeName, "/")
+		if len(parts) != 2 {
+			continue
+		}
+		storeKind := parts[0]
+		storeNameOnly := parts[1]
+
+		secretClient, err := mgr.Get(ctx, esapi.SecretStoreRef{
+			Name: storeNameOnly,
+			Kind: storeKind,
+		}, ps.Namespace, nil)
+		if err != nil {
+			return out, fmt.Errorf("could not get secrets client for store %v: %w", storeName, err)
+		}
+
+		newData, ok := newMap[storeName]
+		if !ok {
+			err = r.DeleteAllSecretsFromStore(ctx, secretClient, oldData)
+			if err != nil {
+				return out, err
+			}
+			delete(out, storeName)
+			continue
+		}
+		for oldEntry, oldRef := range oldData {
+			if _, stillExists := newData[oldEntry]; !stillExists {
+				err = r.DeleteSecretFromStore(ctx, secretClient, oldRef)
+				if err != nil {
+					return out, err
+				}
+				delete(out[storeName], oldEntry)
+			}
+		}
+	}
+
+	return out, nil
+}

+ 879 - 0
pkg/controllers/pushsecret/pushsecret_controller_v2_test.go

@@ -0,0 +1,879 @@
+/*
+Copyright © The ESO Authors
+
+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 pushsecret
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"math/big"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/go-logr/logr"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials"
+	corev1 "k8s.io/api/core/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
+	pb "github.com/external-secrets/external-secrets/proto/provider"
+	"github.com/external-secrets/external-secrets/runtime/clientmanager"
+)
+
+type pushsecretRecordingProviderServer struct {
+	pb.UnimplementedSecretStoreProviderServer
+	pushRequest   *pb.PushSecretRequest
+	deleteRequest *pb.DeleteSecretRequest
+}
+
+const (
+	pushSecretManifestNamespace = "tenant-a"
+	pushSecretRemoteKey         = "remote/path"
+	pushSecretProperty          = "property"
+	pushSecretSecretKey         = "token"
+)
+
+func (s *pushsecretRecordingProviderServer) PushSecret(_ context.Context, req *pb.PushSecretRequest) (*pb.PushSecretResponse, error) {
+	s.pushRequest = req
+	return &pb.PushSecretResponse{}, nil
+}
+
+func (s *pushsecretRecordingProviderServer) DeleteSecret(_ context.Context, req *pb.DeleteSecretRequest) (*pb.DeleteSecretResponse, error) {
+	s.deleteRequest = req
+	return &pb.DeleteSecretResponse{}, nil
+}
+
+func (s *pushsecretRecordingProviderServer) SecretExists(_ context.Context, _ *pb.SecretExistsRequest) (*pb.SecretExistsResponse, error) {
+	return &pb.SecretExistsResponse{Exists: false}, nil
+}
+
+func TestResolvedStoreInfoSupportsCleanStoreKinds(t *testing.T) {
+	providerStoreInfo, ok := resolvedStoreInfo(esapi.PushSecretStoreRef{
+		Name: "provider-store",
+		Kind: esv1.ProviderStoreKindStr,
+	}, &esv2alpha1.ProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   "provider-store",
+			Labels: map[string]string{"team": "a"},
+		},
+	})
+	if !ok {
+		t.Fatal("expected provider store info to resolve")
+	}
+	if providerStoreInfo.Name != "provider-store" || providerStoreInfo.Kind != esv1.ProviderStoreKindStr || providerStoreInfo.Labels["team"] != "a" {
+		t.Fatalf("unexpected provider store info: %#v", providerStoreInfo)
+	}
+
+	clusterProviderStoreInfo, ok := resolvedStoreInfo(esapi.PushSecretStoreRef{
+		Name: "cluster-provider-store",
+		Kind: esv1.ClusterProviderStoreKindStr,
+	}, &esv2alpha1.ClusterProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   "cluster-provider-store",
+			Labels: map[string]string{"scope": "cluster"},
+		},
+	})
+	if !ok {
+		t.Fatal("expected cluster provider store info to resolve")
+	}
+	if clusterProviderStoreInfo.Name != "cluster-provider-store" || clusterProviderStoreInfo.Kind != esv1.ClusterProviderStoreKindStr || clusterProviderStoreInfo.Labels["scope"] != "cluster" {
+		t.Fatalf("unexpected cluster provider store info: %#v", clusterProviderStoreInfo)
+	}
+}
+
+func TestResolvedStoreInfoInfersOmittedCleanStoreKinds(t *testing.T) {
+	providerStoreInfo, ok := resolvedStoreInfo(esapi.PushSecretStoreRef{
+		Name: "provider-store",
+	}, &esv2alpha1.ProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   "provider-store",
+			Labels: map[string]string{"team": "a"},
+		},
+	})
+	if !ok {
+		t.Fatal("expected provider store info to resolve")
+	}
+	if providerStoreInfo.Kind != esv1.ProviderStoreKindStr {
+		t.Fatalf("expected kind %q, got %#v", esv1.ProviderStoreKindStr, providerStoreInfo)
+	}
+
+	clusterProviderStoreInfo, ok := resolvedStoreInfo(esapi.PushSecretStoreRef{
+		Name: "cluster-provider-store",
+	}, &esv2alpha1.ClusterProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   "cluster-provider-store",
+			Labels: map[string]string{"scope": "cluster"},
+		},
+	})
+	if !ok {
+		t.Fatal("expected cluster provider store info to resolve")
+	}
+	if clusterProviderStoreInfo.Kind != esv1.ClusterProviderStoreKindStr {
+		t.Fatalf("expected kind %q, got %#v", esv1.ClusterProviderStoreKindStr, clusterProviderStoreInfo)
+	}
+}
+
+func TestValidateDataToMatchesResolvedStoresSupportsCleanStoreKinds(t *testing.T) {
+	err := validateDataToMatchesResolvedStores([]esapi.PushSecretDataTo{
+		{
+			StoreRef: &esapi.PushSecretStoreRef{
+				Kind: esv1.ProviderStoreKindStr,
+				LabelSelector: &metav1.LabelSelector{
+					MatchLabels: map[string]string{"team": "a"},
+				},
+			},
+			RemoteKey: "bundle",
+		},
+	}, []storeInfo{
+		{Name: "provider-store", Kind: esv1.ProviderStoreKindStr, Labels: map[string]string{"team": "a"}},
+	})
+	if err != nil {
+		t.Fatalf("expected provider store label selector to match, got %v", err)
+	}
+
+	err = validateDataToMatchesResolvedStores([]esapi.PushSecretDataTo{
+		{
+			StoreRef: &esapi.PushSecretStoreRef{
+				Kind: esv1.ClusterProviderStoreKindStr,
+				LabelSelector: &metav1.LabelSelector{
+					MatchLabels: map[string]string{"scope": "missing"},
+				},
+			},
+			RemoteKey: "bundle",
+		},
+	}, []storeInfo{
+		{Name: "cluster-provider-store", Kind: esv1.ClusterProviderStoreKindStr, Labels: map[string]string{"scope": "cluster"}},
+	})
+	if err == nil || err.Error() != "dataTo[0]: labelSelector does not match any store in secretStoreRefs" {
+		t.Fatalf("unexpected error: %v", err)
+	}
+}
+
+func TestPushSecretToProvidersV2UsesProviderStorePath(t *testing.T) {
+	scheme := newPushSecretTestScheme(t)
+	server, address, tlsSecret := newPushSecretProviderServer(t)
+
+	store := &esv2alpha1.ProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "aws-prod",
+			Namespace: pushSecretManifestNamespace,
+			Labels:    map[string]string{"team": "a"},
+		},
+		Spec: esv2alpha1.ProviderStoreSpec{
+			RuntimeRef: esv2alpha1.StoreRuntimeRef{Name: "aws"},
+			BackendRef: esv2alpha1.BackendObjectReference{
+				APIVersion: "provider.aws.external-secrets.io/v2alpha1",
+				Kind:       "SecretsManager",
+				Name:       "backend",
+			},
+		},
+	}
+	runtimeClass := &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{Name: "aws"},
+		Spec:       esv1alpha1.ClusterProviderClassSpec{Address: address},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			store,
+			runtimeClass,
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: pushSecretManifestNamespace,
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+	mgr := clientmanager.NewManager(kubeClient, "", false)
+	defer func() {
+		_ = mgr.Close(context.Background())
+	}()
+
+	ps := esapi.PushSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "pushsecret",
+			Namespace: "tenant-a",
+		},
+		Spec: esapi.PushSecretSpec{
+			SecretStoreRefs: []esapi.PushSecretStoreRef{{
+				Name: store.Name,
+				Kind: esv1.ProviderStoreKindStr,
+			}},
+			Data: []esapi.PushSecretData{{
+				Match: esapi.PushSecretMatch{
+					SecretKey: pushSecretSecretKey,
+					RemoteRef: esapi.PushSecretRemoteRef{
+						RemoteKey: pushSecretRemoteKey,
+						Property:  pushSecretProperty,
+					},
+				},
+				Metadata: &apiextensionsv1.JSON{Raw: []byte(`{"owner":"eso"}`)},
+			}},
+		},
+	}
+
+	secret := &corev1.Secret{
+		Data: map[string][]byte{pushSecretSecretKey: []byte("value")},
+	}
+
+	synced, err := r.PushSecretToProvidersV2(context.Background(), map[esapi.PushSecretStoreRef]any{
+		{Name: store.Name, Kind: esv1.ProviderStoreKindStr}: store,
+	}, ps, secret, mgr)
+	if err != nil {
+		t.Fatalf("PushSecretToProvidersV2() error = %v", err)
+	}
+
+	if server.pushRequest == nil {
+		t.Fatal("expected push request to be recorded")
+	}
+	if server.pushRequest.SourceNamespace != pushSecretManifestNamespace {
+		t.Fatalf("unexpected source namespace: %q", server.pushRequest.SourceNamespace)
+	}
+	if server.pushRequest.ProviderRef == nil || server.pushRequest.ProviderRef.Name != "backend" {
+		t.Fatalf("unexpected provider ref: %#v", server.pushRequest.ProviderRef)
+	}
+	if server.pushRequest.ProviderRef.Namespace != pushSecretManifestNamespace || server.pushRequest.ProviderRef.StoreRefKind != esv1.ProviderStoreKindStr {
+		t.Fatalf("unexpected provider ref namespace/kind: %#v", server.pushRequest.ProviderRef)
+	}
+	if string(server.pushRequest.SecretData[pushSecretSecretKey]) != "value" {
+		t.Fatalf("unexpected secret data: %#v", server.pushRequest.SecretData)
+	}
+	if server.pushRequest.PushSecretData == nil || server.pushRequest.PushSecretData.RemoteKey != pushSecretRemoteKey || server.pushRequest.PushSecretData.Property != pushSecretProperty {
+		t.Fatalf("unexpected push payload: %#v", server.pushRequest.PushSecretData)
+	}
+	if string(server.pushRequest.PushSecretData.Metadata) != `{"owner":"eso"}` {
+		t.Fatalf("unexpected metadata: %q", string(server.pushRequest.PushSecretData.Metadata))
+	}
+	if synced["ProviderStore/aws-prod"]["remote/path/property"].Match.SecretKey != pushSecretSecretKey {
+		t.Fatalf("unexpected synced map: %#v", synced)
+	}
+}
+
+func TestDeleteSecretFromProvidersV2UsesClusterProviderStorePath(t *testing.T) {
+	scheme := newPushSecretTestScheme(t)
+	server, address, tlsSecret := newPushSecretProviderServer(t)
+
+	store := &esv2alpha1.ClusterProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   "aws-shared",
+			Labels: map[string]string{"scope": "cluster"},
+		},
+		Spec: esv2alpha1.ClusterProviderStoreSpec{
+			RuntimeRef: esv2alpha1.StoreRuntimeRef{Name: "aws"},
+			BackendRef: esv2alpha1.BackendObjectReference{
+				APIVersion: "provider.aws.external-secrets.io/v2alpha1",
+				Kind:       "SecretsManager",
+				Name:       "backend",
+			},
+		},
+	}
+	runtimeClass := &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{Name: "aws"},
+		Spec:       esv1alpha1.ClusterProviderClassSpec{Address: address},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			store,
+			runtimeClass,
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: pushSecretManifestNamespace,
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+	ps := &esapi.PushSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "pushsecret",
+			Namespace: "tenant-a",
+		},
+		Status: esapi.PushSecretStatus{
+			SyncedPushSecrets: esapi.SyncedPushSecretsMap{
+				"ClusterProviderStore/aws-shared": {
+					"remote/path": {
+						Match: esapi.PushSecretMatch{
+							SecretKey: "token",
+							RemoteRef: esapi.PushSecretRemoteRef{
+								RemoteKey: "remote/path",
+								Property:  "property",
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	result, err := r.DeleteSecretFromProvidersV2(context.Background(), ps, esapi.SyncedPushSecretsMap{}, map[esapi.PushSecretStoreRef]any{
+		{Name: store.Name, Kind: esv1.ClusterProviderStoreKindStr}: store,
+	})
+	if err != nil {
+		t.Fatalf("DeleteSecretFromProvidersV2() error = %v", err)
+	}
+
+	if server.deleteRequest == nil {
+		t.Fatal("expected delete request to be recorded")
+	}
+	if server.deleteRequest.SourceNamespace != pushSecretManifestNamespace {
+		t.Fatalf("unexpected source namespace: %q", server.deleteRequest.SourceNamespace)
+	}
+	if server.deleteRequest.ProviderRef == nil ||
+		server.deleteRequest.ProviderRef.Namespace != pushSecretManifestNamespace ||
+		server.deleteRequest.ProviderRef.StoreRefKind != esv1.ClusterProviderStoreKindStr {
+		t.Fatalf("unexpected provider ref: %#v", server.deleteRequest.ProviderRef)
+	}
+	if server.deleteRequest.RemoteRef == nil || server.deleteRequest.RemoteRef.RemoteKey != pushSecretRemoteKey || server.deleteRequest.RemoteRef.Property != pushSecretProperty {
+		t.Fatalf("unexpected delete ref: %#v", server.deleteRequest.RemoteRef)
+	}
+	if _, ok := result["ClusterProviderStore/aws-shared"]; ok {
+		t.Fatalf("expected synced state to be cleaned up, got %#v", result)
+	}
+}
+
+func TestGetSecretStoresV2ResolvesProviderStoreWhenAPIVersionOmitted(t *testing.T) {
+	scheme := newPushSecretTestScheme(t)
+	store := &esv2alpha1.ProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "aws-prod",
+			Namespace: "tenant-a",
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(store).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+	ps := esapi.PushSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "pushsecret",
+			Namespace: "tenant-a",
+		},
+		Spec: esapi.PushSecretSpec{
+			SecretStoreRefs: []esapi.PushSecretStoreRef{{
+				Name: "aws-prod",
+				Kind: esv1.ProviderStoreKindStr,
+			}},
+		},
+	}
+
+	stores, err := r.GetSecretStoresV2(context.Background(), ps)
+	if err != nil {
+		t.Fatalf("GetSecretStoresV2() error = %v", err)
+	}
+	if _, ok := stores[ps.Spec.SecretStoreRefs[0]].(*esv2alpha1.ProviderStore); !ok {
+		t.Fatalf("expected ProviderStore, got %#v", stores)
+	}
+}
+
+func TestGetSecretStoresV2PrefersProviderStoreWhenKindOmitted(t *testing.T) {
+	scheme := newPushSecretTestScheme(t)
+	namespacedStore := &esv2alpha1.ProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "aws-shared",
+			Namespace: "tenant-a",
+		},
+	}
+	clusterStore := &esv2alpha1.ClusterProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "aws-shared",
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(namespacedStore, clusterStore).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+	ps := esapi.PushSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "pushsecret",
+			Namespace: "tenant-a",
+		},
+		Spec: esapi.PushSecretSpec{
+			SecretStoreRefs: []esapi.PushSecretStoreRef{{
+				Name: "aws-shared",
+			}},
+		},
+	}
+
+	stores, err := r.GetSecretStoresV2(context.Background(), ps)
+	if err != nil {
+		t.Fatalf("GetSecretStoresV2() error = %v", err)
+	}
+
+	store, ok := stores[ps.Spec.SecretStoreRefs[0]]
+	if !ok {
+		t.Fatalf("expected resolved store, got %#v", stores)
+	}
+	if _, ok := store.(*esv2alpha1.ProviderStore); !ok {
+		t.Fatalf("expected ProviderStore to win omitted-kind lookup, got %T", store)
+	}
+}
+
+func TestGetSecretStoresV2ResolvesClusterProviderStoreBySelector(t *testing.T) {
+	scheme := newPushSecretTestScheme(t)
+	store := &esv2alpha1.ClusterProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   "aws-shared",
+			Labels: map[string]string{"team": "shared"},
+		},
+	}
+	otherKindStore := &esv2alpha1.ProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "aws-tenant",
+			Namespace: "tenant-a",
+			Labels:    map[string]string{"team": "shared"},
+		},
+	}
+	nonMatchingStore := &esv2alpha1.ClusterProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   "aws-other",
+			Labels: map[string]string{"team": "other"},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(store, otherKindStore, nonMatchingStore).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+	ps := esapi.PushSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "pushsecret",
+			Namespace: "tenant-a",
+		},
+		Spec: esapi.PushSecretSpec{
+			SecretStoreRefs: []esapi.PushSecretStoreRef{{
+				Kind: esv1.ClusterProviderStoreKindStr,
+				LabelSelector: &metav1.LabelSelector{
+					MatchLabels: map[string]string{"team": "shared"},
+				},
+			}},
+		},
+	}
+
+	stores, err := r.GetSecretStoresV2(context.Background(), ps)
+	if err != nil {
+		t.Fatalf("GetSecretStoresV2() error = %v", err)
+	}
+	if len(stores) != 1 {
+		t.Fatalf("expected one resolved store, got %d", len(stores))
+	}
+
+	selectedStore, ok := stores[esapi.PushSecretStoreRef{Name: "aws-shared", Kind: esv1.ClusterProviderStoreKindStr}]
+	if !ok {
+		t.Fatalf("expected selected cluster provider store, got %#v", stores)
+	}
+	if _, ok := selectedStore.(*esv2alpha1.ClusterProviderStore); !ok {
+		t.Fatalf("expected ClusterProviderStore, got %T", selectedStore)
+	}
+	if _, ok := stores[esapi.PushSecretStoreRef{Name: "aws-tenant", Kind: esv1.ProviderStoreKindStr}]; ok {
+		t.Fatalf("expected selector to stay within cluster provider store kind, got %#v", stores)
+	}
+}
+
+func TestGetSecretStoresV2SupportsSecretStoreLabelSelectors(t *testing.T) {
+	scheme := newPushSecretTestScheme(t)
+	selectedStore := &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "selected",
+			Namespace: "tenant-a",
+			Labels:    map[string]string{"env": "test"},
+		},
+	}
+	otherNamespaceStore := &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "other-namespace",
+			Namespace: "tenant-b",
+			Labels:    map[string]string{"env": "test"},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(selectedStore, otherNamespaceStore).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+	ps := esapi.PushSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "pushsecret",
+			Namespace: "tenant-a",
+		},
+		Spec: esapi.PushSecretSpec{
+			SecretStoreRefs: []esapi.PushSecretStoreRef{{
+				Kind: esv1.SecretStoreKind,
+				LabelSelector: &metav1.LabelSelector{
+					MatchLabels: map[string]string{"env": "test"},
+				},
+			}},
+		},
+	}
+
+	stores, err := r.GetSecretStoresV2(context.Background(), ps)
+	if err != nil {
+		t.Fatalf("GetSecretStoresV2() error = %v", err)
+	}
+
+	if len(stores) != 1 {
+		t.Fatalf("expected one resolved store, got %#v", stores)
+	}
+
+	store, ok := stores[esapi.PushSecretStoreRef{Name: "selected", Kind: esv1.SecretStoreKind}]
+	if !ok {
+		t.Fatalf("expected selected store, got %#v", stores)
+	}
+	if _, ok := store.(*esv1.SecretStore); !ok {
+		t.Fatalf("expected SecretStore, got %T", store)
+	}
+}
+
+func TestDeleteSecretFromProvidersV2DeletesRemovedStoreEvenWhenNoLongerReferenced(t *testing.T) {
+	scheme := newPushSecretTestScheme(t)
+	server, address, tlsSecret := newPushSecretProviderServer(t)
+
+	store := &esv2alpha1.ClusterProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "aws-shared",
+		},
+		Spec: esv2alpha1.ClusterProviderStoreSpec{
+			RuntimeRef: esv2alpha1.StoreRuntimeRef{Name: "aws"},
+			BackendRef: esv2alpha1.BackendObjectReference{
+				APIVersion: "provider.aws.external-secrets.io/v2alpha1",
+				Kind:       "SecretsManager",
+				Name:       "backend",
+			},
+		},
+	}
+	runtimeClass := &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{Name: "aws"},
+		Spec:       esv1alpha1.ClusterProviderClassSpec{Address: address},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			store,
+			runtimeClass,
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: pushSecretManifestNamespace,
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+	ps := &esapi.PushSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "pushsecret",
+			Namespace: "tenant-a",
+		},
+		Status: esapi.PushSecretStatus{
+			SyncedPushSecrets: esapi.SyncedPushSecretsMap{
+				"ClusterProviderStore/aws-shared": {
+					"remote/path": {
+						Match: esapi.PushSecretMatch{
+							SecretKey: "token",
+							RemoteRef: esapi.PushSecretRemoteRef{
+								RemoteKey: "remote/path",
+								Property:  "property",
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	result, err := r.DeleteSecretFromProvidersV2(context.Background(), ps, esapi.SyncedPushSecretsMap{}, map[esapi.PushSecretStoreRef]any{})
+	if err != nil {
+		t.Fatalf("DeleteSecretFromProvidersV2() error = %v", err)
+	}
+
+	if server.deleteRequest == nil {
+		t.Fatal("expected delete request to be recorded")
+	}
+	if server.deleteRequest.RemoteRef == nil || server.deleteRequest.RemoteRef.RemoteKey != "remote/path" {
+		t.Fatalf("unexpected delete ref: %#v", server.deleteRequest.RemoteRef)
+	}
+	if _, ok := result["ClusterProviderStore/aws-shared"]; ok {
+		t.Fatalf("expected synced state to be cleaned up, got %#v", result)
+	}
+}
+
+func TestDeleteSecretFromProvidersV2DeletesOnlyRemovedEntriesForClusterProviderStore(t *testing.T) {
+	scheme := newPushSecretTestScheme(t)
+	server, address, tlsSecret := newPushSecretProviderServer(t)
+
+	store := &esv2alpha1.ClusterProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "aws-shared",
+		},
+		Spec: esv2alpha1.ClusterProviderStoreSpec{
+			RuntimeRef: esv2alpha1.StoreRuntimeRef{Name: "aws"},
+			BackendRef: esv2alpha1.BackendObjectReference{
+				APIVersion: "provider.aws.external-secrets.io/v2alpha1",
+				Kind:       "SecretsManager",
+				Name:       "backend",
+			},
+		},
+	}
+	runtimeClass := &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{Name: "aws"},
+		Spec:       esv1alpha1.ClusterProviderClassSpec{Address: address},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			store,
+			runtimeClass,
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: pushSecretManifestNamespace,
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+	ps := &esapi.PushSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "pushsecret",
+			Namespace: "tenant-a",
+		},
+		Status: esapi.PushSecretStatus{
+			SyncedPushSecrets: esapi.SyncedPushSecretsMap{
+				"ClusterProviderStore/aws-shared": {
+					"remote/keep/property": {
+						Match: esapi.PushSecretMatch{
+							SecretKey: "keep",
+							RemoteRef: esapi.PushSecretRemoteRef{
+								RemoteKey: "remote/keep",
+								Property:  "property",
+							},
+						},
+					},
+					"remote/delete/property": {
+						Match: esapi.PushSecretMatch{
+							SecretKey: "delete",
+							RemoteRef: esapi.PushSecretRemoteRef{
+								RemoteKey: "remote/delete",
+								Property:  "property",
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	newMap := esapi.SyncedPushSecretsMap{
+		"ClusterProviderStore/aws-shared": {
+			"remote/keep/property": ps.Status.SyncedPushSecrets["ClusterProviderStore/aws-shared"]["remote/keep/property"],
+		},
+	}
+
+	result, err := r.DeleteSecretFromProvidersV2(context.Background(), ps, newMap, map[esapi.PushSecretStoreRef]any{
+		{Name: store.Name, Kind: esv1.ClusterProviderStoreKindStr}: store,
+	})
+	if err != nil {
+		t.Fatalf("DeleteSecretFromProvidersV2() error = %v", err)
+	}
+
+	if server.deleteRequest == nil {
+		t.Fatal("expected delete request to be recorded")
+	}
+	if server.deleteRequest.SourceNamespace != pushSecretManifestNamespace {
+		t.Fatalf("unexpected source namespace: %q", server.deleteRequest.SourceNamespace)
+	}
+	if server.deleteRequest.RemoteRef == nil || server.deleteRequest.RemoteRef.RemoteKey != "remote/delete" || server.deleteRequest.RemoteRef.Property != "property" {
+		t.Fatalf("unexpected delete ref: %#v", server.deleteRequest.RemoteRef)
+	}
+
+	storeState, ok := result["ClusterProviderStore/aws-shared"]
+	if !ok {
+		t.Fatalf("expected synced state for cluster provider store, got %#v", result)
+	}
+	if len(storeState) != 1 {
+		t.Fatalf("expected one remaining synced entry, got %#v", storeState)
+	}
+	if _, ok := storeState["remote/keep/property"]; !ok {
+		t.Fatalf("expected keep entry to remain, got %#v", storeState)
+	}
+	if _, ok := storeState["remote/delete/property"]; ok {
+		t.Fatalf("expected delete entry to be removed, got %#v", storeState)
+	}
+}
+
+func newPushSecretTestScheme(t *testing.T) *runtime.Scheme {
+	t.Helper()
+
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+	utilruntime.Must(esapi.AddToScheme(scheme))
+	utilruntime.Must(esv1alpha1.AddToScheme(scheme))
+	utilruntime.Must(esv2alpha1.AddToScheme(scheme))
+	return scheme
+}
+
+func newPushSecretProviderServer(t *testing.T) (*pushsecretRecordingProviderServer, string, map[string][]byte) {
+	t.Helper()
+
+	serverCert, serverKey, clientCert, clientKey, caCert := newPushSecretTLSArtifacts(t, "127.0.0.1")
+
+	caPool := x509.NewCertPool()
+	if !caPool.AppendCertsFromPEM(caCert) {
+		t.Fatal("failed to append CA cert")
+	}
+	tlsCert, err := tls.X509KeyPair(serverCert, serverKey)
+	if err != nil {
+		t.Fatalf("X509KeyPair() error = %v", err)
+	}
+
+	lis, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("Listen() error = %v", err)
+	}
+
+	server := &pushsecretRecordingProviderServer{}
+	grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{
+		MinVersion:   tls.VersionTLS12,
+		Certificates: []tls.Certificate{tlsCert},
+		ClientCAs:    caPool,
+		ClientAuth:   tls.RequireAndVerifyClientCert,
+	})))
+	pb.RegisterSecretStoreProviderServer(grpcServer, server)
+	go func() {
+		_ = grpcServer.Serve(lis)
+	}()
+
+	t.Cleanup(func() {
+		grpcServer.Stop()
+		_ = lis.Close()
+	})
+
+	return server, lis.Addr().String(), map[string][]byte{
+		"ca.crt":     caCert,
+		"client.crt": clientCert,
+		"client.key": clientKey,
+	}
+}
+
+func newPushSecretTLSArtifacts(t *testing.T, host string) (serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM []byte) {
+	t.Helper()
+
+	caKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("GenerateKey() error = %v", err)
+	}
+
+	caTemplate := &x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject: pkix.Name{
+			CommonName: "pushsecret-test-ca",
+		},
+		NotBefore:             time.Now().Add(-time.Hour),
+		NotAfter:              time.Now().Add(24 * time.Hour),
+		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
+		BasicConstraintsValid: true,
+		IsCA:                  true,
+	}
+
+	caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
+	if err != nil {
+		t.Fatalf("CreateCertificate() error = %v", err)
+	}
+	caCert, err := x509.ParseCertificate(caDER)
+	if err != nil {
+		t.Fatalf("ParseCertificate() error = %v", err)
+	}
+
+	serverCertPEM, serverKeyPEM = newPushSecretSignedTLSCert(t, caCert, caKey, 2, host, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
+	clientCertPEM, clientKeyPEM = newPushSecretSignedTLSCert(t, caCert, caKey, 3, host, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
+	caCertPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
+	return serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM
+}
+
+func newPushSecretSignedTLSCert(t *testing.T, caCert *x509.Certificate, caKey *rsa.PrivateKey, serial int64, host string, usages []x509.ExtKeyUsage) ([]byte, []byte) {
+	t.Helper()
+
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("GenerateKey() error = %v", err)
+	}
+
+	template := &x509.Certificate{
+		SerialNumber: big.NewInt(serial),
+		Subject: pkix.Name{
+			CommonName: host,
+		},
+		NotBefore:   time.Now().Add(-time.Hour),
+		NotAfter:    time.Now().Add(24 * time.Hour),
+		KeyUsage:    x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+		ExtKeyUsage: usages,
+	}
+
+	if ip := net.ParseIP(host); ip != nil {
+		template.IPAddresses = []net.IP{ip}
+	} else {
+		template.DNSNames = []string{host}
+	}
+
+	der, err := x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey)
+	if err != nil {
+		t.Fatalf("CreateCertificate() error = %v", err)
+	}
+
+	return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
+		pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
+}