Sfoglia il codice sorgente

feat: wire core controllers to provider stores

Moritz Johner 2 mesi fa
parent
commit
7a86c58dc6
35 ha cambiato i file con 4099 aggiunte e 803 eliminazioni
  1. 4 0
      apis/externalsecrets/v1/provider.go
  2. 58 0
      cmd/controller/root.go
  3. 7 1
      go.mod
  4. 123 0
      pkg/controllers/clusterprovider/metrics.go
  5. 82 0
      pkg/controllers/clusterprovider/metrics_test.go
  6. 106 0
      pkg/controllers/clusterproviderclass/controller.go
  7. 75 0
      pkg/controllers/clusterproviderclass/controller_test.go
  8. 22 4
      pkg/controllers/externalsecret/externalsecret_controller.go
  9. 5 5
      pkg/controllers/externalsecret/externalsecret_controller_secret.go
  10. 32 0
      pkg/controllers/externalsecret/externalsecret_controller_test.go
  11. 108 0
      pkg/controllers/externalsecret/externalsecret_store_watch.go
  12. 174 0
      pkg/controllers/externalsecret/externalsecret_store_watch_test.go
  13. 4 0
      pkg/controllers/externalsecret/suite_test.go
  14. 138 0
      pkg/controllers/provider/metrics.go
  15. 99 0
      pkg/controllers/provider/metrics_test.go
  16. 97 0
      pkg/controllers/providerstore/clusterproviderstore_controller.go
  17. 207 0
      pkg/controllers/providerstore/common.go
  18. 315 0
      pkg/controllers/providerstore/common_test.go
  19. 88 0
      pkg/controllers/providerstore/providerstore_controller.go
  20. 97 0
      pkg/controllers/providerstore/providerstore_controller_test.go
  21. 5 265
      pkg/controllers/secretstore/client_manager.go
  22. 0 478
      pkg/controllers/secretstore/client_manager_test.go
  23. 11 6
      pkg/controllers/secretstore/common.go
  24. 62 0
      pkg/controllers/secretstore/storeutil/util.go
  25. 2 0
      pkg/controllers/secretstore/suite_test.go
  26. 3 7
      pkg/controllers/secretstore/util.go
  27. 532 0
      runtime/clientmanager/manager.go
  28. 924 0
      runtime/clientmanager/manager_test.go
  29. 123 0
      runtime/clientmanager/metrics.go
  30. 199 0
      runtime/clientmanager/providerstore.go
  31. 237 0
      runtime/clientmanager/providerstore_test.go
  32. 40 0
      runtime/clientmanager/runtime_ref.go
  33. 46 36
      runtime/go.mod
  34. 73 0
      runtime/go.sum
  35. 1 1
      runtime/testing/fake/fake.go

+ 4 - 0
apis/externalsecrets/v1/provider.go

@@ -64,6 +64,10 @@ type ProviderInterface interface {
 	Capabilities() SecretStoreCapabilities
 }
 
+// Provider is kept as a compatibility alias while the rest of the tree migrates
+// to the more explicit ProviderInterface name.
+type Provider = ProviderInterface
+
 // +kubebuilder:object:root=false
 // +kubebuilder:object:generate:false
 // +k8s:deepcopy-gen:interfaces=nil

+ 58 - 0
cmd/controller/root.go

@@ -30,15 +30,20 @@ import (
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/cache"
 	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller"
 	"sigs.k8s.io/controller-runtime/pkg/healthz"
+	crmetrics "sigs.k8s.io/controller-runtime/pkg/metrics"
 	"sigs.k8s.io/controller-runtime/pkg/metrics/server"
 	"sigs.k8s.io/controller-runtime/pkg/webhook"
 
 	esv1 "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"
 	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
 	"github.com/external-secrets/external-secrets/pkg/controllers/clusterexternalsecret"
 	"github.com/external-secrets/external-secrets/pkg/controllers/clusterexternalsecret/cesmetrics"
+	clusterprovidermetrics "github.com/external-secrets/external-secrets/pkg/controllers/clusterprovider"
+	"github.com/external-secrets/external-secrets/pkg/controllers/clusterproviderclass"
 	"github.com/external-secrets/external-secrets/pkg/controllers/clusterpushsecret"
 	"github.com/external-secrets/external-secrets/pkg/controllers/clusterpushsecret/cpsmetrics"
 	ctrlcommon "github.com/external-secrets/external-secrets/pkg/controllers/common"
@@ -46,11 +51,15 @@ import (
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret/esmetrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/generatorstate"
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
+	providermetrics "github.com/external-secrets/external-secrets/pkg/controllers/provider"
+	"github.com/external-secrets/external-secrets/pkg/controllers/providerstore"
 	"github.com/external-secrets/external-secrets/pkg/controllers/pushsecret"
 	"github.com/external-secrets/external-secrets/pkg/controllers/pushsecret/psmetrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore/cssmetrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore/ssmetrics"
+	grpccommon "github.com/external-secrets/external-secrets/providers/v2/common/grpc"
+	"github.com/external-secrets/external-secrets/runtime/clientmanager"
 	"github.com/external-secrets/external-secrets/runtime/feature"
 
 	// To allow using gcp auth.
@@ -116,6 +125,7 @@ func init() {
 	// external-secrets schemes
 	utilruntime.Must(esv1.AddToScheme(scheme))
 	utilruntime.Must(esv1alpha1.AddToScheme(scheme))
+	utilruntime.Must(esv2alpha1.AddToScheme(scheme))
 	utilruntime.Must(genv1alpha1.AddToScheme(scheme))
 }
 
@@ -128,6 +138,14 @@ var rootCmd = &cobra.Command{
 
 		ctrlmetrics.SetUpLabelNames(enableExtendedMetricLabels)
 		esmetrics.SetUpMetrics()
+		if err := clientmanager.RegisterMetrics(); err != nil {
+			setupLog.Error(err, "unable to register clientmanager metrics")
+			os.Exit(1)
+		}
+		if err := grpccommon.RegisterMetrics(crmetrics.Registry); err != nil {
+			setupLog.Error(err, "unable to register grpc metrics")
+			os.Exit(1)
+		}
 		config := ctrl.GetConfigOrDie()
 		config.QPS = clientQPS
 		config.Burst = clientBurst
@@ -213,7 +231,21 @@ var rootCmd = &cobra.Command{
 			}
 		}
 
+		if enableSecretStoreReconciler {
+			providermetrics.SetUpMetrics()
+			if err = (&providerstore.StoreReconciler{
+				Client:          mgr.GetClient(),
+				Log:             ctrl.Log.WithName("controllers").WithName("ProviderStore"),
+				Scheme:          mgr.GetScheme(),
+				RequeueInterval: storeRequeueInterval,
+			}).SetupWithManager(mgr, ctrlcommon.BuildControllerOptions(concurrent)); err != nil {
+				setupLog.Error(err, errCreateController, "controller", "ProviderStore")
+				os.Exit(1)
+			}
+		}
+
 		if enableClusterStoreReconciler {
+			clusterprovidermetrics.SetUpMetrics()
 			cssmetrics.SetUpMetrics()
 			if err = (&secretstore.ClusterStoreReconciler{
 				Client:            mgr.GetClient(),
@@ -227,6 +259,32 @@ var rootCmd = &cobra.Command{
 				os.Exit(1)
 			}
 		}
+
+		if enableClusterStoreReconciler {
+			if err = (&providerstore.ClusterStoreReconciler{
+				Client:          mgr.GetClient(),
+				Log:             ctrl.Log.WithName("controllers").WithName("ClusterProviderStore"),
+				Scheme:          mgr.GetScheme(),
+				RequeueInterval: storeRequeueInterval,
+			}).SetupWithManager(mgr, ctrlcommon.BuildControllerOptions(concurrent)); err != nil {
+				setupLog.Error(err, errCreateController, "controller", "ClusterProviderStore")
+				os.Exit(1)
+			}
+		}
+
+		if err = (&clusterproviderclass.Reconciler{
+			Client:          mgr.GetClient(),
+			Log:             ctrl.Log.WithName("controllers").WithName("ClusterProviderClass"),
+			Scheme:          mgr.GetScheme(),
+			RequeueInterval: storeRequeueInterval,
+		}).SetupWithManager(mgr, controller.Options{
+			MaxConcurrentReconciles: concurrent,
+			RateLimiter:             ctrlcommon.BuildRateLimiter(),
+		}); err != nil {
+			setupLog.Error(err, errCreateController, "controller", "ClusterProviderClass")
+			os.Exit(1)
+		}
+
 		if err = (&generatorstate.Reconciler{
 			Client:     mgr.GetClient(),
 			Log:        ctrl.Log.WithName("controllers").WithName("GeneratorState"),

+ 7 - 1
go.mod

@@ -19,6 +19,7 @@ replace (
 	github.com/external-secrets/external-secrets/generators/v1/uuid => ./generators/v1/uuid
 	github.com/external-secrets/external-secrets/generators/v1/vault => ./generators/v1/vault
 	github.com/external-secrets/external-secrets/generators/v1/webhook => ./generators/v1/webhook
+	github.com/external-secrets/external-secrets/proto => ./providers/v2/common/proto
 	github.com/external-secrets/external-secrets/providers/v1/akeyless => ./providers/v1/akeyless
 	github.com/external-secrets/external-secrets/providers/v1/aws => ./providers/v1/aws
 	github.com/external-secrets/external-secrets/providers/v1/azure => ./providers/v1/azure
@@ -58,6 +59,8 @@ replace (
 	github.com/external-secrets/external-secrets/providers/v1/volcengine => ./providers/v1/volcengine
 	github.com/external-secrets/external-secrets/providers/v1/webhook => ./providers/v1/webhook
 	github.com/external-secrets/external-secrets/providers/v1/yandex => ./providers/v1/yandex
+	github.com/external-secrets/external-secrets/providers/v2/adapter/store => ./providers/v2/adapter/store
+	github.com/external-secrets/external-secrets/providers/v2/common => ./providers/v2/common
 	github.com/external-secrets/external-secrets/runtime => ./runtime
 )
 
@@ -101,7 +104,7 @@ require (
 	golang.org/x/oauth2 v0.36.0 // indirect
 	google.golang.org/api v0.254.0 // indirect
 	google.golang.org/genproto v0.0.0-20251029180050-ab9386a59fda // indirect
-	google.golang.org/grpc v1.79.3 // indirect
+	google.golang.org/grpc v1.79.3
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	grpc.go4.org v0.0.0-20170609214715-11d0a25b4919 // indirect
 	k8s.io/api v0.35.2
@@ -132,6 +135,7 @@ require (
 	github.com/external-secrets/external-secrets/generators/v1/uuid v0.0.0-00010101000000-000000000000
 	github.com/external-secrets/external-secrets/generators/v1/vault v0.0.0-00010101000000-000000000000
 	github.com/external-secrets/external-secrets/generators/v1/webhook v0.0.0-00010101000000-000000000000
+	github.com/external-secrets/external-secrets/proto v0.0.0
 	github.com/external-secrets/external-secrets/providers/v1/akeyless v0.0.0-00010101000000-000000000000
 	github.com/external-secrets/external-secrets/providers/v1/aws v0.0.0-20251103072335-a9b233b6936f
 	github.com/external-secrets/external-secrets/providers/v1/azure v0.0.0-20251103072335-a9b233b6936f
@@ -171,6 +175,7 @@ require (
 	github.com/external-secrets/external-secrets/providers/v1/volcengine v0.0.0-00010101000000-000000000000
 	github.com/external-secrets/external-secrets/providers/v1/webhook v0.0.0-20251103080423-08fa383f42e5
 	github.com/external-secrets/external-secrets/providers/v1/yandex v0.0.0-00010101000000-000000000000
+	github.com/external-secrets/external-secrets/providers/v2/common v0.0.0
 	github.com/external-secrets/external-secrets/runtime v0.0.0
 	github.com/maxbrunsfeld/counterfeiter/v6 v6.12.0
 	sigs.k8s.io/yaml v1.6.0
@@ -247,6 +252,7 @@ require (
 	github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
 	github.com/emirpasic/gods v1.18.1 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+	github.com/external-secrets/external-secrets/providers/v2/adapter/store v0.0.0 // indirect
 	github.com/extism/go-sdk v1.7.1 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/fortanix/sdkms-client-go v0.4.1 // indirect

+ 123 - 0
pkg/controllers/clusterprovider/metrics.go

@@ -0,0 +1,123 @@
+/*
+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 clusterprovider exposes compatibility metrics for v2 ClusterProviderStore resources.
+package clusterprovider
+
+import (
+	"sync"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"sigs.k8s.io/controller-runtime/pkg/metrics"
+)
+
+const (
+	// ClusterProviderSubsystem is the subsystem name for ClusterProvider metrics.
+	ClusterProviderSubsystem = "clusterprovider"
+
+	// ClusterProviderReconcileDurationKey is the key for the reconcile duration metric.
+	ClusterProviderReconcileDurationKey = "reconcile_duration"
+
+	// StatusConditionKey is the key for the status condition metric.
+	StatusConditionKey = "status_condition"
+)
+
+var (
+	gaugeVecMetrics     = map[string]*prometheus.GaugeVec{}
+	registerMetricsOnce sync.Once
+)
+
+// SetUpMetrics initializes the metrics for the ClusterProvider controller.
+func SetUpMetrics() {
+	registerMetricsOnce.Do(func() {
+		clusterProviderReconcileDuration := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+			Subsystem: ClusterProviderSubsystem,
+			Name:      ClusterProviderReconcileDurationKey,
+			Help:      "The duration time to reconcile the ClusterProvider",
+		}, []string{"name"})
+
+		clusterProviderCondition := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+			Subsystem: ClusterProviderSubsystem,
+			Name:      StatusConditionKey,
+			Help:      "The status condition of a specific ClusterProvider",
+		}, []string{"name", "condition", "status"})
+
+		metrics.Registry.MustRegister(clusterProviderReconcileDuration, clusterProviderCondition)
+
+		gaugeVecMetrics = map[string]*prometheus.GaugeVec{
+			ClusterProviderReconcileDurationKey: clusterProviderReconcileDuration,
+			StatusConditionKey:                  clusterProviderCondition,
+		}
+	})
+}
+
+// GetGaugeVec returns the GaugeVec for the given key.
+func GetGaugeVec(key string) *prometheus.GaugeVec {
+	return gaugeVecMetrics[key]
+}
+
+// RemoveMetrics deletes all metrics published by the resource.
+func RemoveMetrics(name string) {
+	for _, gaugeVecMetric := range gaugeVecMetrics {
+		gaugeVecMetric.DeletePartialMatch(
+			map[string]string{
+				"name": name,
+			},
+		)
+	}
+}
+
+// UpdateStatusCondition updates the legacy ClusterProvider condition metrics for a v2 ClusterProviderStore.
+func UpdateStatusCondition(name, conditionType, conditionStatus string) {
+	clusterProviderConditionGauge := GetGaugeVec(StatusConditionKey)
+	if clusterProviderConditionGauge == nil {
+		return
+	}
+
+	if conditionType == "Ready" {
+		switch conditionStatus {
+		case "False":
+			clusterProviderConditionGauge.WithLabelValues(
+				name,
+				"Ready",
+				"True",
+			).Set(0)
+		case "True":
+			clusterProviderConditionGauge.WithLabelValues(
+				name,
+				"Ready",
+				"False",
+			).Set(0)
+		case "Unknown":
+			break
+		}
+	}
+
+	clusterProviderConditionGauge.WithLabelValues(
+		name,
+		conditionType,
+		conditionStatus,
+	).Set(1)
+}
+
+// RecordReconcileDuration updates the legacy ClusterProvider reconcile duration metric for a v2 ClusterProviderStore.
+func RecordReconcileDuration(name string, seconds float64) {
+	clusterProviderReconcileDurationGauge := GetGaugeVec(ClusterProviderReconcileDurationKey)
+	if clusterProviderReconcileDurationGauge == nil {
+		return
+	}
+	clusterProviderReconcileDurationGauge.WithLabelValues(name).Set(seconds)
+}

+ 82 - 0
pkg/controllers/clusterprovider/metrics_test.go

@@ -0,0 +1,82 @@
+/*
+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 clusterprovider
+
+import (
+	"testing"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/testutil"
+)
+
+func TestUpdateStatusCondition(t *testing.T) {
+	tmpGaugeVecMetrics := gaugeVecMetrics
+	defer func() {
+		gaugeVecMetrics = tmpGaugeVecMetrics
+	}()
+
+	conditionGauge := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+		Subsystem: "clusterprovider",
+		Name:      "status_condition_test",
+	}, []string{"name", "condition", "status"})
+	gaugeVecMetrics = map[string]*prometheus.GaugeVec{
+		StatusConditionKey: conditionGauge,
+	}
+
+	UpdateStatusCondition("aws-shared", "Ready", "False")
+
+	if got := testutil.CollectAndCount(conditionGauge); got != 2 {
+		t.Fatalf("unexpected number of condition samples: got %d, want 2", got)
+	}
+	if got := testutil.ToFloat64(conditionGauge.With(prometheus.Labels{
+		"name":      "aws-shared",
+		"condition": "Ready",
+		"status":    "True",
+	})); got != 0 {
+		t.Fatalf("unexpected Ready=True value: got %v, want 0", got)
+	}
+	if got := testutil.ToFloat64(conditionGauge.With(prometheus.Labels{
+		"name":      "aws-shared",
+		"condition": "Ready",
+		"status":    "False",
+	})); got != 1 {
+		t.Fatalf("unexpected Ready=False value: got %v, want 1", got)
+	}
+}
+
+func TestRecordReconcileDuration(t *testing.T) {
+	tmpGaugeVecMetrics := gaugeVecMetrics
+	defer func() {
+		gaugeVecMetrics = tmpGaugeVecMetrics
+	}()
+
+	durationGauge := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+		Subsystem: "clusterprovider",
+		Name:      "reconcile_duration_test",
+	}, []string{"name"})
+	gaugeVecMetrics = map[string]*prometheus.GaugeVec{
+		ClusterProviderReconcileDurationKey: durationGauge,
+	}
+
+	RecordReconcileDuration("aws-shared", 1.75)
+
+	if got := testutil.ToFloat64(durationGauge.With(prometheus.Labels{
+		"name": "aws-shared",
+	})); got != 1.75 {
+		t.Fatalf("unexpected reconcile duration: got %v, want 1.75", got)
+	}
+}

+ 106 - 0
pkg/controllers/clusterproviderclass/controller.go

@@ -0,0 +1,106 @@
+/*
+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 clusterproviderclass implements readiness reconciliation for ClusterProviderClass runtimes.
+package clusterproviderclass
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/go-logr/logr"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller"
+
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	grpccommon "github.com/external-secrets/external-secrets/providers/v2/common/grpc"
+)
+
+// Reconciler reconciles ClusterProviderClass resources by checking runtime health.
+type Reconciler struct {
+	client.Client
+	Log             logr.Logger
+	Scheme          *runtime.Scheme
+	RequeueInterval time.Duration
+	CheckHealth     func(context.Context, string, *grpccommon.TLSConfig) error
+}
+
+// Reconcile updates the Ready condition based on the runtime health check result.
+func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	log := r.Log.WithValues("ClusterProviderClass", req.NamespacedName)
+
+	var obj esv1alpha1.ClusterProviderClass
+	if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
+		if apierrors.IsNotFound(err) {
+			return ctrl.Result{}, nil
+		}
+		return ctrl.Result{}, err
+	}
+
+	tlsSecretNamespace := grpccommon.ResolveTLSSecretNamespace(obj.Spec.Address, "", "", "")
+	tlsConfig, err := grpccommon.LoadClientTLSConfig(ctx, r.Client, obj.Spec.Address, tlsSecretNamespace)
+	if err != nil {
+		log.Error(err, "failed to load runtime TLS config")
+		return r.updateStatus(ctx, &obj, metav1.ConditionFalse, "TLSConfigFailed", err.Error())
+	}
+
+	if err := r.reconcileHealth(ctx, &obj, tlsConfig); err != nil {
+		log.Error(err, "runtime health check failed")
+		return r.updateStatus(ctx, &obj, metav1.ConditionFalse, "HealthCheckFailed", err.Error())
+	}
+
+	return r.updateStatus(ctx, &obj, metav1.ConditionTrue, "Healthy", "runtime is serving")
+}
+
+func (r *Reconciler) reconcileHealth(ctx context.Context, obj *esv1alpha1.ClusterProviderClass, tlsConfig *grpccommon.TLSConfig) error {
+	checker := r.CheckHealth
+	if checker == nil {
+		checker = grpccommon.CheckHealth
+	}
+
+	return checker(ctx, obj.Spec.Address, tlsConfig)
+}
+
+func (r *Reconciler) updateStatus(ctx context.Context, obj *esv1alpha1.ClusterProviderClass, status metav1.ConditionStatus, reason, message string) (ctrl.Result, error) {
+	meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{
+		Type:               "Ready",
+		Status:             status,
+		Reason:             reason,
+		Message:            message,
+		LastTransitionTime: metav1.Now(),
+		ObservedGeneration: obj.GetGeneration(),
+	})
+
+	if err := r.Status().Update(ctx, obj); err != nil {
+		return ctrl.Result{}, fmt.Errorf("failed to update ClusterProviderClass status: %w", err)
+	}
+
+	return ctrl.Result{RequeueAfter: r.RequeueInterval}, nil
+}
+
+// SetupWithManager wires the controller into controller-runtime.
+func (r *Reconciler) SetupWithManager(mgr ctrl.Manager, opts controller.Options) error {
+	return ctrl.NewControllerManagedBy(mgr).
+		WithOptions(opts).
+		For(&esv1alpha1.ClusterProviderClass{}).
+		Complete(r)
+}

+ 75 - 0
pkg/controllers/clusterproviderclass/controller_test.go

@@ -0,0 +1,75 @@
+/*
+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 clusterproviderclass
+
+import (
+	"context"
+	"testing"
+
+	"github.com/go-logr/logr"
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/types"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	grpccommon "github.com/external-secrets/external-secrets/providers/v2/common/grpc"
+)
+
+func TestClusterProviderClassReconcileMarksReadyWhenHealthCheckSucceeds(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1alpha1.AddToScheme(scheme))
+
+	obj := &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{Name: "aws"},
+		Spec: esv1alpha1.ClusterProviderClassSpec{
+			Address: "provider-aws.external-secrets-system.svc:8080",
+		},
+	}
+
+	kubeClient := fake.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(obj).
+		WithStatusSubresource(obj).
+		Build()
+
+	r := &Reconciler{
+		Client: kubeClient,
+		Log:    logr.Discard(),
+		CheckHealth: func(context.Context, string, *grpccommon.TLSConfig) error {
+			return nil
+		},
+	}
+
+	_, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: "aws"}})
+	if err != nil {
+		t.Fatalf("Reconcile() error = %v", err)
+	}
+
+	updated := &esv1alpha1.ClusterProviderClass{}
+	if err := kubeClient.Get(context.Background(), types.NamespacedName{Name: "aws"}, updated); err != nil {
+		t.Fatalf("Get() error = %v", err)
+	}
+	if meta.FindStatusCondition(updated.Status.Conditions, "Ready") == nil {
+		t.Fatalf("expected Ready condition to be set")
+	}
+}

+ 22 - 4
pkg/controllers/externalsecret/externalsecret_controller.go

@@ -53,15 +53,14 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/reconcile"
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
-	// Metrics.
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret/esmetrics"
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
 	ctrlutil "github.com/external-secrets/external-secrets/pkg/controllers/util"
 	"github.com/external-secrets/external-secrets/runtime/esutils"
 	"github.com/external-secrets/external-secrets/runtime/esutils/resolvers"
 
-	// Loading registered generators.
-	_ "github.com/external-secrets/external-secrets/pkg/register"
+	_ "github.com/external-secrets/external-secrets/pkg/register" // Register generators for side-effect initialization.
 )
 
 const (
@@ -1035,7 +1034,9 @@ func getManagedFieldKeys(
 }
 
 func shouldSkipClusterSecretStore(r *Reconciler, es *esv1.ExternalSecret) bool {
-	return !r.ClusterSecretStoreEnabled && es.Spec.SecretStoreRef.Kind == esv1.ClusterSecretStoreKind
+	return !r.ClusterSecretStoreEnabled &&
+		(es.Spec.SecretStoreRef.Kind == esv1.ClusterSecretStoreKind ||
+			es.Spec.SecretStoreRef.Kind == esv1.ClusterProviderStoreKindStr)
 }
 
 // shouldSkipUnmanagedStore iterates over all secretStore references in the externalSecret spec,
@@ -1092,6 +1093,9 @@ func shouldSkipUnmanagedStore(ctx context.Context, namespace string, r *Reconcil
 		case esv1.ClusterSecretStoreKind:
 			store = &esv1.ClusterSecretStore{}
 			namespace = ""
+		case esv1.ProviderStoreKindStr, esv1.ClusterProviderStoreKindStr:
+			// Out-of-process provider-backed stores do not use controllerClass filtering.
+			return false, nil
 		default:
 			return false, fmt.Errorf("unsupported secret store kind: %s", ref.Kind)
 		}
@@ -1272,6 +1276,10 @@ func (r *Reconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opt
 		return err
 	}
 
+	if err := mgr.GetFieldIndexer().IndexField(ctx, &esv1.ExternalSecret{}, indexESV2StoreRefField, indexExternalSecretV2StoreRefs); err != nil {
+		return err
+	}
+
 	// predicate function to ignore secret events unless they have the "managed" label
 	secretHasESLabel := predicate.NewPredicateFuncs(func(object client.Object) bool {
 		value, hasLabel := object.GetLabels()[esv1.LabelManaged]
@@ -1295,6 +1303,16 @@ func (r *Reconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opt
 			&v1.Secret{},
 			handler.EnqueueRequestsFromMapFunc(r.findObjectsForSecret),
 			builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}, secretHasESLabel),
+		).
+		Watches(
+			&esv2alpha1.ProviderStore{},
+			handler.EnqueueRequestsFromMapFunc(r.findExternalSecretsForV2Store),
+			builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
+		).
+		Watches(
+			&esv2alpha1.ClusterProviderStore{},
+			handler.EnqueueRequestsFromMapFunc(r.findExternalSecretsForV2Store),
+			builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
 		)
 
 	// Watch generic targets dynamically via the informer manager

+ 5 - 5
pkg/controllers/externalsecret/externalsecret_controller_secret.go

@@ -30,7 +30,7 @@ import (
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
-	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore"
+	"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"
@@ -45,7 +45,7 @@ func (r *Reconciler) GetProviderSecretData(ctx context.Context, externalSecret *
 	// Clientmanager keeps track of the client instances
 	// that are created during the fetching process and closes clients
 	// if needed.
-	mgr := secretstore.NewManager(r.Client, r.ControllerClass, r.EnableFloodGate)
+	mgr := clientmanager.NewManager(r.Client, r.ControllerClass, r.EnableFloodGate)
 	defer func() {
 		_ = mgr.Close(ctx)
 	}()
@@ -121,7 +121,7 @@ func (r *Reconciler) GetProviderSecretData(ctx context.Context, externalSecret *
 	return providerData, nil
 }
 
-func (r *Reconciler) handleSecretData(ctx context.Context, externalSecret *esv1.ExternalSecret, secretRef esv1.ExternalSecretData, providerData map[string][]byte, cmgr *secretstore.Manager) error {
+func (r *Reconciler) handleSecretData(ctx context.Context, externalSecret *esv1.ExternalSecret, secretRef esv1.ExternalSecretData, providerData map[string][]byte, cmgr *clientmanager.Manager) error {
 	client, err := cmgr.Get(ctx, externalSecret.Spec.SecretStoreRef, externalSecret.Namespace, toStoreGenSourceRef(secretRef.SourceRef))
 	if err != nil {
 		return err
@@ -213,7 +213,7 @@ func (r *Reconciler) handleExtractSecrets(
 	ctx context.Context,
 	externalSecret *esv1.ExternalSecret,
 	remoteRef esv1.ExternalSecretDataFromRemoteRef,
-	cmgr *secretstore.Manager,
+	cmgr *clientmanager.Manager,
 	genState *statemanager.Manager,
 	i int,
 ) (map[string][]byte, error) {
@@ -264,7 +264,7 @@ func (r *Reconciler) handleFindAllSecrets(
 	ctx context.Context,
 	externalSecret *esv1.ExternalSecret,
 	remoteRef esv1.ExternalSecretDataFromRemoteRef,
-	cmgr *secretstore.Manager,
+	cmgr *clientmanager.Manager,
 	genState *statemanager.Manager,
 	i int,
 ) (map[string][]byte, error) {

+ 32 - 0
pkg/controllers/externalsecret/externalsecret_controller_test.go

@@ -24,6 +24,7 @@ import (
 	"fmt"
 	"os"
 	"strconv"
+	"testing"
 	"time"
 
 	"github.com/google/go-cmp/cmp"
@@ -37,6 +38,7 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"sigs.k8s.io/controller-runtime/pkg/client"
+	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
@@ -44,6 +46,7 @@ import (
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret/esmetrics"
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
 	ctrlutil "github.com/external-secrets/external-secrets/pkg/controllers/util"
+	fakeprovider "github.com/external-secrets/external-secrets/providers/v1/fake"
 	"github.com/external-secrets/external-secrets/runtime/esutils"
 	"github.com/external-secrets/external-secrets/runtime/testing/fake"
 
@@ -51,6 +54,34 @@ import (
 	. "github.com/onsi/gomega"
 )
 
+func TestShouldSkipClusterStoreReturnsTrueForClusterProviderStoreWhenDisabled(t *testing.T) {
+	r := &Reconciler{ClusterSecretStoreEnabled: false}
+	es := &esv1.ExternalSecret{}
+	es.Spec.SecretStoreRef.Kind = esv1.ClusterProviderStoreKindStr
+
+	if !shouldSkipClusterSecretStore(r, es) {
+		t.Fatal("expected ClusterProviderStore to be skipped when cluster stores are disabled")
+	}
+}
+
+func TestShouldSkipUnmanagedStoreAllowsProviderStore(t *testing.T) {
+	r := &Reconciler{
+		Client:          fakeclient.NewClientBuilder().Build(),
+		ControllerClass: "default",
+	}
+	es := &esv1.ExternalSecret{}
+	es.Namespace = "tenant-a"
+	es.Spec.SecretStoreRef = esv1.SecretStoreRef{Name: "aws-prod", Kind: esv1.ProviderStoreKindStr}
+
+	skip, err := shouldSkipUnmanagedStore(context.Background(), es.Namespace, r, es)
+	if err != nil {
+		t.Fatalf("shouldSkipUnmanagedStore() error = %v", err)
+	}
+	if skip {
+		t.Fatal("expected ProviderStore refs to be allowed")
+	}
+}
+
 const (
 	labelKey           = "label-key"
 	labelValue         = "label-value"
@@ -3249,6 +3280,7 @@ func init() {
 			Service: esv1.AWSServiceSecretsManager,
 		},
 	}, esv1.MaintenanceStatusMaintained)
+	esv1.ForceRegister(fakeprovider.NewProvider(), fakeprovider.ProviderSpec(), fakeprovider.MaintenanceStatus())
 
 	ctrlmetrics.SetUpLabelNames(false)
 	esmetrics.SetUpMetrics()

+ 108 - 0
pkg/controllers/externalsecret/externalsecret_store_watch.go

@@ -0,0 +1,108 @@
+/*
+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 externalsecret
+
+import (
+	"context"
+	"fmt"
+
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
+)
+
+const indexESV2StoreRefField = ".spec.v2StoreRefs"
+
+func v2StoreRefIndexKey(kind, namespace, name string) string {
+	if kind == esv1.ClusterProviderStoreKindStr {
+		return fmt.Sprintf("%s/%s", kind, name)
+	}
+	return fmt.Sprintf("%s/%s/%s", kind, namespace, name)
+}
+
+func indexExternalSecretV2StoreRefs(obj client.Object) []string {
+	es, ok := obj.(*esv1.ExternalSecret)
+	if !ok {
+		return nil
+	}
+
+	keys := make(map[string]struct{})
+	addExternalSecretV2StoreRefIndexKey(keys, es.Namespace, es.Spec.SecretStoreRef)
+
+	for i := range es.Spec.Data {
+		if es.Spec.Data[i].SourceRef == nil {
+			continue
+		}
+		addExternalSecretV2StoreRefIndexKey(keys, es.Namespace, es.Spec.Data[i].SourceRef.SecretStoreRef)
+	}
+
+	for i := range es.Spec.DataFrom {
+		if es.Spec.DataFrom[i].SourceRef == nil || es.Spec.DataFrom[i].SourceRef.SecretStoreRef == nil {
+			continue
+		}
+		addExternalSecretV2StoreRefIndexKey(keys, es.Namespace, *es.Spec.DataFrom[i].SourceRef.SecretStoreRef)
+	}
+
+	out := make([]string, 0, len(keys))
+	for key := range keys {
+		out = append(out, key)
+	}
+	return out
+}
+
+func addExternalSecretV2StoreRefIndexKey(keys map[string]struct{}, namespace string, ref esv1.SecretStoreRef) {
+	switch ref.Kind {
+	case esv1.ProviderStoreKindStr:
+		keys[v2StoreRefIndexKey(esv1.ProviderStoreKindStr, namespace, ref.Name)] = struct{}{}
+	case esv1.ClusterProviderStoreKindStr:
+		keys[v2StoreRefIndexKey(esv1.ClusterProviderStoreKindStr, "", ref.Name)] = struct{}{}
+	}
+}
+
+func (r *Reconciler) findExternalSecretsForV2Store(ctx context.Context, obj client.Object) []reconcile.Request {
+	var (
+		key         string
+		listOptions []client.ListOption
+	)
+
+	switch store := obj.(type) {
+	case *esv2alpha1.ProviderStore:
+		key = v2StoreRefIndexKey(esv1.ProviderStoreKindStr, store.Namespace, store.Name)
+		listOptions = append(listOptions, client.InNamespace(store.Namespace))
+	case *esv2alpha1.ClusterProviderStore:
+		key = v2StoreRefIndexKey(esv1.ClusterProviderStoreKindStr, "", store.Name)
+	default:
+		return nil
+	}
+
+	listOptions = append(listOptions, client.MatchingFields{indexESV2StoreRefField: key})
+
+	var externalSecrets esv1.ExternalSecretList
+	if err := r.List(ctx, &externalSecrets, listOptions...); err != nil {
+		return nil
+	}
+
+	requests := make([]reconcile.Request, 0, len(externalSecrets.Items))
+	for i := range externalSecrets.Items {
+		requests = append(requests, reconcile.Request{
+			NamespacedName: client.ObjectKeyFromObject(&externalSecrets.Items[i]),
+		})
+	}
+	return requests
+}

+ 174 - 0
pkg/controllers/externalsecret/externalsecret_store_watch_test.go

@@ -0,0 +1,174 @@
+/*
+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 externalsecret
+
+import (
+	"context"
+	"testing"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
+)
+
+const testNamespaceTeamA = "team-a"
+
+func TestIndexExternalSecretV2StoreRefs(t *testing.T) {
+	es := &esv1.ExternalSecret{}
+	es.Namespace = testNamespaceTeamA
+	es.Spec.SecretStoreRef = esv1.SecretStoreRef{
+		Name: "namespaced-store",
+		Kind: esv1.ProviderStoreKindStr,
+	}
+	es.Spec.Data = []esv1.ExternalSecretData{
+		{},
+		{
+			SourceRef: &esv1.StoreSourceRef{
+				SecretStoreRef: esv1.SecretStoreRef{
+					Name: "cluster-store",
+					Kind: esv1.ClusterProviderStoreKindStr,
+				},
+			},
+		},
+	}
+	es.Spec.DataFrom = []esv1.ExternalSecretDataFromRemoteRef{
+		{
+			SourceRef: &esv1.StoreGeneratorSourceRef{
+				SecretStoreRef: &esv1.SecretStoreRef{
+					Name: "secondary-store",
+					Kind: esv1.ProviderStoreKindStr,
+				},
+			},
+		},
+		{
+			SourceRef: &esv1.StoreGeneratorSourceRef{
+				SecretStoreRef: &esv1.SecretStoreRef{
+					Name: "ignored-v1-store",
+					Kind: esv1.SecretStoreKind,
+				},
+			},
+		},
+	}
+
+	got := indexExternalSecretV2StoreRefs(es)
+	want := map[string]struct{}{
+		v2StoreRefIndexKey(esv1.ClusterProviderStoreKindStr, "", "cluster-store"):             {},
+		v2StoreRefIndexKey(esv1.ProviderStoreKindStr, testNamespaceTeamA, "namespaced-store"): {},
+		v2StoreRefIndexKey(esv1.ProviderStoreKindStr, testNamespaceTeamA, "secondary-store"):  {},
+	}
+
+	if len(got) != len(want) {
+		t.Fatalf("expected %d index keys, got %d: %#v", len(want), len(got), got)
+	}
+	for _, key := range got {
+		if _, ok := want[key]; !ok {
+			t.Fatalf("unexpected index key %q in %#v", key, got)
+		}
+		delete(want, key)
+	}
+	if len(want) != 0 {
+		t.Fatalf("missing expected index keys: %#v", want)
+	}
+}
+
+func TestFindExternalSecretsForV2Store(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+	utilruntime.Must(esv2alpha1.AddToScheme(scheme))
+
+	matching := &esv1.ExternalSecret{}
+	matching.Namespace = testNamespaceTeamA
+	matching.Name = "matching"
+	matching.Spec.SecretStoreRef = esv1.SecretStoreRef{
+		Name: "aws-prod",
+		Kind: esv1.ProviderStoreKindStr,
+	}
+
+	nonMatching := &esv1.ExternalSecret{}
+	nonMatching.Namespace = testNamespaceTeamA
+	nonMatching.Name = "non-matching"
+	nonMatching.Spec.SecretStoreRef = esv1.SecretStoreRef{
+		Name: "aws-other",
+		Kind: esv1.ProviderStoreKindStr,
+	}
+
+	cl := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(matching, nonMatching).
+		WithIndex(&esv1.ExternalSecret{}, indexESV2StoreRefField, indexExternalSecretV2StoreRefs).
+		Build()
+
+	r := &Reconciler{Client: cl}
+	store := &esv2alpha1.ProviderStore{}
+	store.Namespace = testNamespaceTeamA
+	store.Name = "aws-prod"
+
+	got := r.findExternalSecretsForV2Store(context.Background(), store)
+	want := []reconcile.Request{{
+		NamespacedName: client.ObjectKeyFromObject(matching),
+	}}
+
+	if len(got) != len(want) {
+		t.Fatalf("expected %d requests, got %d: %#v", len(want), len(got), got)
+	}
+	if got[0] != want[0] {
+		t.Fatalf("expected requests %#v, got %#v", want, got)
+	}
+}
+
+func TestFindExternalSecretsForClusterProviderStore(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+	utilruntime.Must(esv2alpha1.AddToScheme(scheme))
+
+	matching := &esv1.ExternalSecret{}
+	matching.Namespace = testNamespaceTeamA
+	matching.Name = "matching"
+	matching.Spec.Data = []esv1.ExternalSecretData{
+		{
+			SourceRef: &esv1.StoreSourceRef{
+				SecretStoreRef: esv1.SecretStoreRef{
+					Name: "shared",
+					Kind: esv1.ClusterProviderStoreKindStr,
+				},
+			},
+		},
+	}
+
+	cl := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(matching).
+		WithIndex(&esv1.ExternalSecret{}, indexESV2StoreRefField, indexExternalSecretV2StoreRefs).
+		Build()
+
+	r := &Reconciler{Client: cl}
+	store := &esv2alpha1.ClusterProviderStore{}
+	store.Name = "shared"
+
+	got := r.findExternalSecretsForV2Store(context.Background(), store)
+	if len(got) != 1 || got[0].NamespacedName.Name != matching.Name || got[0].NamespacedName.Namespace != matching.Namespace {
+		t.Fatalf("unexpected requests: %#v", got)
+	}
+}

+ 4 - 0
pkg/controllers/externalsecret/suite_test.go

@@ -35,6 +35,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/metrics/server"
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
 	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
 	ctrlcommon "github.com/external-secrets/external-secrets/pkg/controllers/common"
 
@@ -79,6 +80,9 @@ var _ = BeforeSuite(func() {
 	err = genv1alpha1.AddToScheme(scheme.Scheme)
 	Expect(err).NotTo(HaveOccurred())
 
+	err = esv2alpha1.AddToScheme(scheme.Scheme)
+	Expect(err).NotTo(HaveOccurred())
+
 	k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{
 		Scheme: scheme.Scheme,
 		Metrics: server.Options{

+ 138 - 0
pkg/controllers/provider/metrics.go

@@ -0,0 +1,138 @@
+/*
+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 provider exposes compatibility metrics for v2 ProviderStore resources.
+package provider
+
+import (
+	"maps"
+	"sync"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"sigs.k8s.io/controller-runtime/pkg/metrics"
+
+	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
+)
+
+const (
+	// ProviderSubsystem is the subsystem name for Provider metrics.
+	ProviderSubsystem = "provider"
+
+	// ProviderReconcileDurationKey is the key for the reconcile duration metric.
+	ProviderReconcileDurationKey = "reconcile_duration"
+
+	// StatusConditionKey is the key for the status condition metric.
+	StatusConditionKey = "status_condition"
+)
+
+var (
+	gaugeVecMetrics     = map[string]*prometheus.GaugeVec{}
+	registerMetricsOnce sync.Once
+)
+
+// SetUpMetrics initializes the metrics for the Provider controller.
+func SetUpMetrics() {
+	registerMetricsOnce.Do(func() {
+		providerReconcileDuration := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+			Subsystem: ProviderSubsystem,
+			Name:      ProviderReconcileDurationKey,
+			Help:      "The duration time to reconcile the Provider",
+		}, ctrlmetrics.NonConditionMetricLabelNames)
+
+		providerCondition := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+			Subsystem: ProviderSubsystem,
+			Name:      StatusConditionKey,
+			Help:      "The status condition of a specific Provider",
+		}, ctrlmetrics.ConditionMetricLabelNames)
+
+		metrics.Registry.MustRegister(providerReconcileDuration, providerCondition)
+
+		gaugeVecMetrics = map[string]*prometheus.GaugeVec{
+			ProviderReconcileDurationKey: providerReconcileDuration,
+			StatusConditionKey:           providerCondition,
+		}
+	})
+}
+
+// GetGaugeVec returns the GaugeVec for the given key.
+func GetGaugeVec(key string) *prometheus.GaugeVec {
+	return gaugeVecMetrics[key]
+}
+
+// RemoveMetrics deletes all metrics published by the resource.
+func RemoveMetrics(namespace, name string) {
+	for _, gaugeVecMetric := range gaugeVecMetrics {
+		gaugeVecMetric.DeletePartialMatch(
+			map[string]string{
+				"namespace": namespace,
+				"name":      name,
+			},
+		)
+	}
+}
+
+// UpdateStatusCondition updates the legacy Provider condition metrics for a v2 ProviderStore.
+func UpdateStatusCondition(name, namespace string, labels map[string]string, conditionType, conditionStatus string) {
+	providerInfo := map[string]string{
+		"name":      name,
+		"namespace": namespace,
+	}
+	maps.Copy(providerInfo, labels)
+	conditionLabels := ctrlmetrics.RefineConditionMetricLabels(providerInfo)
+	providerConditionGauge := GetGaugeVec(StatusConditionKey)
+	if providerConditionGauge == nil {
+		return
+	}
+
+	if conditionType == "Ready" {
+		switch conditionStatus {
+		case "False":
+			providerConditionGauge.With(ctrlmetrics.RefineLabels(conditionLabels,
+				map[string]string{
+					"condition": "Ready",
+					"status":    "True",
+				})).Set(0)
+		case "True":
+			providerConditionGauge.With(ctrlmetrics.RefineLabels(conditionLabels,
+				map[string]string{
+					"condition": "Ready",
+					"status":    "False",
+				})).Set(0)
+		case "Unknown":
+			break
+		}
+	}
+
+	providerConditionGauge.With(ctrlmetrics.RefineLabels(conditionLabels,
+		map[string]string{
+			"condition": conditionType,
+			"status":    conditionStatus,
+		})).Set(1)
+}
+
+// RecordReconcileDuration updates the legacy Provider reconcile duration metric for a v2 ProviderStore.
+func RecordReconcileDuration(name, namespace string, labels map[string]string, seconds float64) {
+	providerInfo := map[string]string{
+		"name":      name,
+		"namespace": namespace,
+	}
+	maps.Copy(providerInfo, labels)
+	providerReconcileDurationGauge := GetGaugeVec(ProviderReconcileDurationKey)
+	if providerReconcileDurationGauge == nil {
+		return
+	}
+	providerReconcileDurationGauge.With(ctrlmetrics.RefineNonConditionMetricLabels(providerInfo)).Set(seconds)
+}

+ 99 - 0
pkg/controllers/provider/metrics_test.go

@@ -0,0 +1,99 @@
+/*
+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 provider
+
+import (
+	"testing"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/testutil"
+
+	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
+)
+
+func TestUpdateStatusCondition(t *testing.T) {
+	tmpConditionMetricLabels := ctrlmetrics.ConditionMetricLabels
+	defer func() {
+		ctrlmetrics.ConditionMetricLabels = tmpConditionMetricLabels
+	}()
+	ctrlmetrics.ConditionMetricLabels = map[string]string{"name": "", "namespace": "", "condition": "", "status": ""}
+
+	tmpGaugeVecMetrics := gaugeVecMetrics
+	defer func() {
+		gaugeVecMetrics = tmpGaugeVecMetrics
+	}()
+
+	conditionGauge := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+		Subsystem: "provider",
+		Name:      "status_condition_test",
+	}, []string{"name", "namespace", "condition", "status"})
+	gaugeVecMetrics = map[string]*prometheus.GaugeVec{
+		StatusConditionKey: conditionGauge,
+	}
+
+	UpdateStatusCondition("aws-prod", "tenant-a", map[string]string{"team": "platform"}, "Ready", "True")
+
+	if got := testutil.CollectAndCount(conditionGauge); got != 2 {
+		t.Fatalf("unexpected number of condition samples: got %d, want 2", got)
+	}
+	if got := testutil.ToFloat64(conditionGauge.With(prometheus.Labels{
+		"name":      "aws-prod",
+		"namespace": "tenant-a",
+		"condition": "Ready",
+		"status":    "True",
+	})); got != 1 {
+		t.Fatalf("unexpected Ready=True value: got %v, want 1", got)
+	}
+	if got := testutil.ToFloat64(conditionGauge.With(prometheus.Labels{
+		"name":      "aws-prod",
+		"namespace": "tenant-a",
+		"condition": "Ready",
+		"status":    "False",
+	})); got != 0 {
+		t.Fatalf("unexpected Ready=False value: got %v, want 0", got)
+	}
+}
+
+func TestRecordReconcileDuration(t *testing.T) {
+	tmpNonConditionMetricLabels := ctrlmetrics.NonConditionMetricLabels
+	defer func() {
+		ctrlmetrics.NonConditionMetricLabels = tmpNonConditionMetricLabels
+	}()
+	ctrlmetrics.NonConditionMetricLabels = map[string]string{"name": "", "namespace": ""}
+
+	tmpGaugeVecMetrics := gaugeVecMetrics
+	defer func() {
+		gaugeVecMetrics = tmpGaugeVecMetrics
+	}()
+
+	durationGauge := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+		Subsystem: "provider",
+		Name:      "reconcile_duration_test",
+	}, []string{"name", "namespace"})
+	gaugeVecMetrics = map[string]*prometheus.GaugeVec{
+		ProviderReconcileDurationKey: durationGauge,
+	}
+
+	RecordReconcileDuration("aws-prod", "tenant-a", map[string]string{"team": "platform"}, 2.5)
+
+	if got := testutil.ToFloat64(durationGauge.With(prometheus.Labels{
+		"name":      "aws-prod",
+		"namespace": "tenant-a",
+	})); got != 2.5 {
+		t.Fatalf("unexpected reconcile duration: got %v, want 2.5", got)
+	}
+}

+ 97 - 0
pkg/controllers/providerstore/clusterproviderstore_controller.go

@@ -0,0 +1,97 @@
+/*
+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 providerstore
+
+import (
+	"context"
+	"time"
+
+	"github.com/go-logr/logr"
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/runtime"
+	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"
+
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
+	clusterprovidermetrics "github.com/external-secrets/external-secrets/pkg/controllers/clusterprovider"
+)
+
+// ClusterStoreReconciler reconciles ClusterProviderStore resources.
+type ClusterStoreReconciler struct {
+	client.Client
+	Log             logr.Logger
+	Scheme          *runtime.Scheme
+	RequeueInterval time.Duration
+}
+
+// Reconcile validates the referenced runtime and backend for a ClusterProviderStore.
+func (r *ClusterStoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	start := time.Now()
+	log := r.Log.WithValues("ClusterProviderStore", req.NamespacedName)
+
+	var store esv2alpha1.ClusterProviderStore
+	if err := r.Get(ctx, req.NamespacedName, &store); err != nil {
+		if apierrors.IsNotFound(err) {
+			clusterprovidermetrics.RemoveMetrics(req.Name)
+			return ctrl.Result{}, nil
+		}
+		return ctrl.Result{}, err
+	}
+
+	if store.Spec.BackendRef.Namespace == "" {
+		if err := assertRuntimeClassReady(ctx, r.Client, store.Spec.RuntimeRef); err != nil {
+			setReadyCondition(&store, corev1.ConditionFalse, "RuntimeNotReady", err.Error())
+		} else {
+			setReadyCondition(&store, corev1.ConditionTrue, "RuntimeReady", "backend namespace resolved per caller namespace")
+		}
+		recordClusterProviderStoreCompatibilityMetrics(&store, time.Since(start))
+		return updateStatus(ctx, r.Status(), &store, r.RequeueInterval, log)
+	}
+
+	if err := validateStore(ctx, r.Client, &store, store.Spec.BackendRef.Namespace); err != nil {
+		setReadyCondition(&store, corev1.ConditionFalse, "ValidationFailed", err.Error())
+		recordClusterProviderStoreCompatibilityMetrics(&store, time.Since(start))
+		return updateStatus(ctx, r.Status(), &store, r.RequeueInterval, log)
+	}
+
+	setReadyCondition(&store, corev1.ConditionTrue, "StoreValid", "store validated")
+	recordClusterProviderStoreCompatibilityMetrics(&store, time.Since(start))
+	return updateStatus(ctx, r.Status(), &store, r.RequeueInterval, log)
+}
+
+// SetupWithManager registers the ClusterProviderStore controller and runtime-class watches.
+func (r *ClusterStoreReconciler) SetupWithManager(mgr ctrl.Manager, opts controller.Options) error {
+	return ctrl.NewControllerManagedBy(mgr).
+		WithOptions(opts).
+		For(&esv2alpha1.ClusterProviderStore{}).
+		Watches(
+			&esv1alpha1.ClusterProviderClass{},
+			handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrl.Request {
+				runtimeClass, ok := obj.(*esv1alpha1.ClusterProviderClass)
+				if !ok {
+					return nil
+				}
+
+				return findClusterProviderStoresForRuntimeClass(ctx, r.Client, runtimeClass)
+			}),
+		).
+		Complete(r)
+}

+ 207 - 0
pkg/controllers/providerstore/common.go

@@ -0,0 +1,207 @@
+/*
+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 providerstore
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/go-logr/logr"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	esv1 "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"
+	clusterprovidermetrics "github.com/external-secrets/external-secrets/pkg/controllers/clusterprovider"
+	providermetrics "github.com/external-secrets/external-secrets/pkg/controllers/provider"
+	"github.com/external-secrets/external-secrets/runtime/clientmanager"
+)
+
+const defaultRuntimeRefKind = "ClusterProviderClass"
+
+func setReadyCondition(store esv2alpha1.GenericStore, status corev1.ConditionStatus, reason, message string) {
+	condition := esv2alpha1.ProviderStoreCondition{
+		Type:               esv2alpha1.ProviderStoreReady,
+		Status:             status,
+		LastTransitionTime: metav1.Now(),
+		Reason:             reason,
+		Message:            message,
+	}
+
+	current := store.GetStoreStatus()
+	for i := range current.Conditions {
+		if current.Conditions[i].Type != condition.Type {
+			continue
+		}
+		if current.Conditions[i].Status == condition.Status {
+			condition.LastTransitionTime = current.Conditions[i].LastTransitionTime
+		}
+		current.Conditions[i] = condition
+		store.SetStoreStatus(current)
+		return
+	}
+
+	current.Conditions = append(current.Conditions, condition)
+	store.SetStoreStatus(current)
+}
+
+func validateStore(ctx context.Context, kubeClient client.Client, store esv2alpha1.GenericStore, sourceNamespace string) error {
+	mgr := clientmanager.NewManager(kubeClient, "", false)
+	defer func() {
+		_ = mgr.Close(ctx)
+	}()
+
+	secretClient, err := mgr.Get(ctx, esv1.SecretStoreRef{
+		Name: store.GetName(),
+		Kind: store.GetKind(),
+	}, sourceNamespace, nil)
+	if err != nil {
+		return err
+	}
+
+	_, err = secretClient.Validate()
+	return err
+}
+
+func assertRuntimeClassReady(ctx context.Context, kubeClient client.Client, runtimeRef esv2alpha1.StoreRuntimeRef) error {
+	runtimeKind := runtimeRef.Kind
+	if runtimeKind == "" {
+		runtimeKind = defaultRuntimeRefKind
+	}
+	if runtimeKind != defaultRuntimeRefKind {
+		return fmt.Errorf("unsupported runtimeRef kind %q", runtimeKind)
+	}
+
+	var runtimeClass esv1alpha1.ClusterProviderClass
+	if err := kubeClient.Get(ctx, client.ObjectKey{Name: runtimeRef.Name}, &runtimeClass); err != nil {
+		return fmt.Errorf("failed to get ClusterProviderClass %q: %w", runtimeRef.Name, err)
+	}
+
+	condition := meta.FindStatusCondition(runtimeClass.Status.Conditions, "Ready")
+	if condition == nil || condition.Status != metav1.ConditionTrue {
+		return fmt.Errorf("ClusterProviderClass %q is not ready", runtimeRef.Name)
+	}
+
+	return nil
+}
+
+func runtimeRefMatchesClusterProviderClass(runtimeRef esv2alpha1.StoreRuntimeRef, runtimeClass *esv1alpha1.ClusterProviderClass) bool {
+	runtimeKind := runtimeRef.Kind
+	if runtimeKind == "" {
+		runtimeKind = defaultRuntimeRefKind
+	}
+
+	return runtimeKind == defaultRuntimeRefKind && runtimeRef.Name == runtimeClass.Name
+}
+
+func findProviderStoresForRuntimeClass(ctx context.Context, kubeClient client.Client, runtimeClass *esv1alpha1.ClusterProviderClass) []ctrl.Request {
+	var stores esv2alpha1.ProviderStoreList
+	if err := kubeClient.List(ctx, &stores); err != nil {
+		return nil
+	}
+
+	requests := make([]ctrl.Request, 0, len(stores.Items))
+	for i := range stores.Items {
+		if !runtimeRefMatchesClusterProviderClass(stores.Items[i].Spec.RuntimeRef, runtimeClass) {
+			continue
+		}
+		requests = append(requests, ctrl.Request{
+			NamespacedName: client.ObjectKeyFromObject(&stores.Items[i]),
+		})
+	}
+
+	return requests
+}
+
+func findClusterProviderStoresForRuntimeClass(ctx context.Context, kubeClient client.Client, runtimeClass *esv1alpha1.ClusterProviderClass) []ctrl.Request {
+	var stores esv2alpha1.ClusterProviderStoreList
+	if err := kubeClient.List(ctx, &stores); err != nil {
+		return nil
+	}
+
+	requests := make([]ctrl.Request, 0, len(stores.Items))
+	for i := range stores.Items {
+		if !runtimeRefMatchesClusterProviderClass(stores.Items[i].Spec.RuntimeRef, runtimeClass) {
+			continue
+		}
+		requests = append(requests, ctrl.Request{
+			NamespacedName: client.ObjectKeyFromObject(&stores.Items[i]),
+		})
+	}
+
+	return requests
+}
+
+func updateStatus(ctx context.Context, statusWriter client.StatusWriter, store esv2alpha1.GenericStore, requeueAfter time.Duration, log logr.Logger) (ctrl.Result, error) {
+	if err := statusWriter.Update(ctx, store); err != nil {
+		log.Error(err, "failed to update status")
+		return ctrl.Result{}, err
+	}
+	return ctrl.Result{RequeueAfter: requeueAfter}, nil
+}
+
+func getReadyCondition(status esv2alpha1.ProviderStoreStatus) *esv2alpha1.ProviderStoreCondition {
+	for i := range status.Conditions {
+		if status.Conditions[i].Type == esv2alpha1.ProviderStoreReady {
+			return &status.Conditions[i]
+		}
+	}
+	return nil
+}
+
+func recordProviderStoreCompatibilityMetrics(store *esv2alpha1.ProviderStore, duration time.Duration) {
+	if store == nil {
+		return
+	}
+
+	providermetrics.RecordReconcileDuration(store.GetName(), store.GetNamespace(), store.GetLabels(), duration.Seconds())
+	condition := getReadyCondition(store.Status)
+	if condition == nil {
+		return
+	}
+
+	providermetrics.UpdateStatusCondition(
+		store.GetName(),
+		store.GetNamespace(),
+		store.GetLabels(),
+		string(condition.Type),
+		string(condition.Status),
+	)
+}
+
+func recordClusterProviderStoreCompatibilityMetrics(store *esv2alpha1.ClusterProviderStore, duration time.Duration) {
+	if store == nil {
+		return
+	}
+
+	clusterprovidermetrics.RecordReconcileDuration(store.GetName(), duration.Seconds())
+	condition := getReadyCondition(store.Status)
+	if condition == nil {
+		return
+	}
+
+	clusterprovidermetrics.UpdateStatusCondition(
+		store.GetName(),
+		string(condition.Type),
+		string(condition.Status),
+	)
+}

+ 315 - 0
pkg/controllers/providerstore/common_test.go

@@ -0,0 +1,315 @@
+/*
+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 providerstore
+
+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/testr"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials"
+	corev1 "k8s.io/api/core/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"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	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"
+)
+
+func providerStoreScheme(t *testing.T) *runtime.Scheme {
+	t.Helper()
+
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1alpha1.AddToScheme(scheme))
+	utilruntime.Must(esv2alpha1.AddToScheme(scheme))
+	return scheme
+}
+
+func newProviderStoreReconciler(t *testing.T, objects ...client.Object) *StoreReconciler {
+	t.Helper()
+
+	builder := fakeclient.NewClientBuilder().
+		WithScheme(providerStoreScheme(t)).
+		WithStatusSubresource(&esv2alpha1.ProviderStore{}, &esv2alpha1.ClusterProviderStore{}, &esv1alpha1.ClusterProviderClass{}).
+		WithObjects(objects...)
+
+	return &StoreReconciler{
+		Client:          builder.Build(),
+		Log:             testr.New(t),
+		Scheme:          providerStoreScheme(t),
+		RequeueInterval: time.Minute,
+	}
+}
+
+func newClusterProviderStoreReconciler(t *testing.T, objects ...client.Object) *ClusterStoreReconciler {
+	t.Helper()
+
+	builder := fakeclient.NewClientBuilder().
+		WithScheme(providerStoreScheme(t)).
+		WithStatusSubresource(&esv2alpha1.ProviderStore{}, &esv2alpha1.ClusterProviderStore{}, &esv1alpha1.ClusterProviderClass{}).
+		WithObjects(objects...)
+
+	return &ClusterStoreReconciler{
+		Client:          builder.Build(),
+		Log:             testr.New(t),
+		Scheme:          providerStoreScheme(t),
+		RequeueInterval: time.Minute,
+	}
+}
+
+func providerStoreReady(status esv2alpha1.ProviderStoreStatus) bool {
+	for _, condition := range status.Conditions {
+		if condition.Type == esv2alpha1.ProviderStoreReady && condition.Status == corev1.ConditionTrue {
+			return true
+		}
+	}
+	return false
+}
+
+type recordingProviderServer struct {
+	pb.UnimplementedSecretStoreProviderServer
+}
+
+func newProviderStoreGRPCServer(t *testing.T) (*grpc.Server, string, map[string][]byte) {
+	t.Helper()
+
+	serverCert, serverKey, clientCert, clientKey, caCert := newMutualTLSArtifacts(t, "127.0.0.1")
+
+	caPool := x509.NewCertPool()
+	require.True(t, caPool.AppendCertsFromPEM(caCert))
+
+	tlsCert, err := tls.X509KeyPair(serverCert, serverKey)
+	require.NoError(t, err)
+
+	lis, err := net.Listen("tcp", "127.0.0.1:0")
+	require.NoError(t, err)
+
+	grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{
+		MinVersion:   tls.VersionTLS12,
+		Certificates: []tls.Certificate{tlsCert},
+		ClientCAs:    caPool,
+		ClientAuth:   tls.RequireAndVerifyClientCert,
+	})))
+	pb.RegisterSecretStoreProviderServer(grpcServer, &recordingProviderServer{})
+
+	go func() {
+		_ = grpcServer.Serve(lis)
+	}()
+
+	t.Cleanup(func() {
+		grpcServer.Stop()
+		_ = lis.Close()
+	})
+
+	return grpcServer, lis.Addr().String(), map[string][]byte{
+		"ca.crt":     caCert,
+		"client.crt": clientCert,
+		"client.key": clientKey,
+	}
+}
+
+func (s *recordingProviderServer) Validate(_ context.Context, _ *pb.ValidateRequest) (*pb.ValidateResponse, error) {
+	return &pb.ValidateResponse{Valid: true}, nil
+}
+
+func newMutualTLSArtifacts(t *testing.T, host string) (serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM []byte) {
+	t.Helper()
+
+	caKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	require.NoError(t, err)
+
+	caTemplate := &x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject: pkix.Name{
+			CommonName: "test-ca",
+		},
+		NotBefore:             time.Now().Add(-time.Hour),
+		NotAfter:              time.Now().Add(time.Hour),
+		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
+		BasicConstraintsValid: true,
+		IsCA:                  true,
+	}
+
+	caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
+	require.NoError(t, err)
+
+	caCertPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
+	caKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(caKey)})
+
+	serverCertPEM, serverKeyPEM = newSignedCert(t, caDER, caKeyPEM, host, true)
+	clientCertPEM, clientKeyPEM = newSignedCert(t, caDER, caKeyPEM, "provider-client", false)
+
+	return serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM
+}
+
+func newSignedCert(t *testing.T, caDER, caKeyPEM []byte, commonName string, isServer bool) ([]byte, []byte) {
+	t.Helper()
+
+	certKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	require.NoError(t, err)
+
+	serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+	serialNumber, err := rand.Int(rand.Reader, serialLimit)
+	require.NoError(t, err)
+
+	template := &x509.Certificate{
+		SerialNumber: serialNumber,
+		Subject: pkix.Name{
+			CommonName: commonName,
+		},
+		NotBefore:   time.Now().Add(-time.Hour),
+		NotAfter:    time.Now().Add(time.Hour),
+		KeyUsage:    x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+		ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
+	}
+
+	if isServer {
+		template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
+		template.IPAddresses = []net.IP{net.ParseIP(commonName)}
+	}
+
+	caCert, err := x509.ParseCertificate(caDER)
+	require.NoError(t, err)
+
+	caKeyBlock, _ := pem.Decode(caKeyPEM)
+	require.NotNil(t, caKeyBlock)
+
+	caKey, err := x509.ParsePKCS1PrivateKey(caKeyBlock.Bytes)
+	require.NoError(t, err)
+
+	certDER, err := x509.CreateCertificate(rand.Reader, template, caCert, &certKey.PublicKey, caKey)
+	require.NoError(t, err)
+
+	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
+	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(certKey)})
+
+	return certPEM, keyPEM
+}
+
+func readyRuntimeClass(name string) *esv1alpha1.ClusterProviderClass {
+	return &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{Name: name},
+		Status: esv1alpha1.ClusterProviderClassStatus{
+			Conditions: []metav1.Condition{{
+				Type:   "Ready",
+				Status: metav1.ConditionTrue,
+				Reason: "Healthy",
+			}},
+		},
+	}
+}
+
+func reconcileRequest(obj client.Object) ctrl.Request {
+	return ctrl.Request{NamespacedName: client.ObjectKeyFromObject(obj)}
+}
+
+func TestFindProviderStoresForRuntimeClass(t *testing.T) {
+	runtimeClass := &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{Name: "shared-runtime"},
+	}
+
+	reconciler := newProviderStoreReconciler(
+		t,
+		runtimeClass,
+		&esv2alpha1.ProviderStore{
+			ObjectMeta: metav1.ObjectMeta{Name: "implicit-kind", Namespace: "team-a"},
+			Spec: esv2alpha1.ProviderStoreSpec{
+				RuntimeRef: esv2alpha1.StoreRuntimeRef{Name: "shared-runtime"},
+			},
+		},
+		&esv2alpha1.ProviderStore{
+			ObjectMeta: metav1.ObjectMeta{Name: "explicit-kind", Namespace: "team-b"},
+			Spec: esv2alpha1.ProviderStoreSpec{
+				RuntimeRef: esv2alpha1.StoreRuntimeRef{
+					Name: "shared-runtime",
+					Kind: "ClusterProviderClass",
+				},
+			},
+		},
+		&esv2alpha1.ProviderStore{
+			ObjectMeta: metav1.ObjectMeta{Name: "other-runtime", Namespace: "team-c"},
+			Spec: esv2alpha1.ProviderStoreSpec{
+				RuntimeRef: esv2alpha1.StoreRuntimeRef{Name: "other-runtime"},
+			},
+		},
+	)
+
+	requests := findProviderStoresForRuntimeClass(context.Background(), reconciler.Client, runtimeClass)
+
+	assert.ElementsMatch(t, []ctrl.Request{
+		{NamespacedName: client.ObjectKey{Name: "implicit-kind", Namespace: "team-a"}},
+		{NamespacedName: client.ObjectKey{Name: "explicit-kind", Namespace: "team-b"}},
+	}, requests)
+}
+
+func TestFindClusterProviderStoresForRuntimeClass(t *testing.T) {
+	runtimeClass := &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{Name: "shared-runtime"},
+	}
+
+	reconciler := newClusterProviderStoreReconciler(
+		t,
+		runtimeClass,
+		&esv2alpha1.ClusterProviderStore{
+			ObjectMeta: metav1.ObjectMeta{Name: "implicit-kind"},
+			Spec: esv2alpha1.ClusterProviderStoreSpec{
+				RuntimeRef: esv2alpha1.StoreRuntimeRef{Name: "shared-runtime"},
+			},
+		},
+		&esv2alpha1.ClusterProviderStore{
+			ObjectMeta: metav1.ObjectMeta{Name: "explicit-kind"},
+			Spec: esv2alpha1.ClusterProviderStoreSpec{
+				RuntimeRef: esv2alpha1.StoreRuntimeRef{
+					Name: "shared-runtime",
+					Kind: "ClusterProviderClass",
+				},
+			},
+		},
+		&esv2alpha1.ClusterProviderStore{
+			ObjectMeta: metav1.ObjectMeta{Name: "other-runtime"},
+			Spec: esv2alpha1.ClusterProviderStoreSpec{
+				RuntimeRef: esv2alpha1.StoreRuntimeRef{Name: "other-runtime"},
+			},
+		},
+	)
+
+	requests := findClusterProviderStoresForRuntimeClass(context.Background(), reconciler.Client, runtimeClass)
+
+	assert.ElementsMatch(t, []ctrl.Request{
+		{NamespacedName: client.ObjectKey{Name: "implicit-kind"}},
+		{NamespacedName: client.ObjectKey{Name: "explicit-kind"}},
+	}, requests)
+}

+ 88 - 0
pkg/controllers/providerstore/providerstore_controller.go

@@ -0,0 +1,88 @@
+/*
+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 providerstore implements controllers for ProviderStore and ClusterProviderStore resources.
+package providerstore
+
+import (
+	"context"
+	"time"
+
+	"github.com/go-logr/logr"
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/runtime"
+	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"
+
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
+	providermetrics "github.com/external-secrets/external-secrets/pkg/controllers/provider"
+)
+
+// StoreReconciler reconciles ProviderStore resources.
+type StoreReconciler struct {
+	client.Client
+	Log             logr.Logger
+	Scheme          *runtime.Scheme
+	RequeueInterval time.Duration
+}
+
+// Reconcile validates the referenced runtime and backend for a ProviderStore.
+func (r *StoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	start := time.Now()
+	log := r.Log.WithValues("ProviderStore", req.NamespacedName)
+
+	var store esv2alpha1.ProviderStore
+	if err := r.Get(ctx, req.NamespacedName, &store); err != nil {
+		if apierrors.IsNotFound(err) {
+			providermetrics.RemoveMetrics(req.Namespace, req.Name)
+			return ctrl.Result{}, nil
+		}
+		return ctrl.Result{}, err
+	}
+
+	if err := validateStore(ctx, r.Client, &store, store.Namespace); err != nil {
+		setReadyCondition(&store, corev1.ConditionFalse, "ValidationFailed", err.Error())
+		recordProviderStoreCompatibilityMetrics(&store, time.Since(start))
+		return updateStatus(ctx, r.Status(), &store, r.RequeueInterval, log)
+	}
+
+	setReadyCondition(&store, corev1.ConditionTrue, "StoreValid", "store validated")
+	recordProviderStoreCompatibilityMetrics(&store, time.Since(start))
+	return updateStatus(ctx, r.Status(), &store, r.RequeueInterval, log)
+}
+
+// SetupWithManager registers the ProviderStore controller and runtime-class watches.
+func (r *StoreReconciler) SetupWithManager(mgr ctrl.Manager, opts controller.Options) error {
+	return ctrl.NewControllerManagedBy(mgr).
+		WithOptions(opts).
+		For(&esv2alpha1.ProviderStore{}).
+		Watches(
+			&esv1alpha1.ClusterProviderClass{},
+			handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrl.Request {
+				runtimeClass, ok := obj.(*esv1alpha1.ClusterProviderClass)
+				if !ok {
+					return nil
+				}
+
+				return findProviderStoresForRuntimeClass(ctx, r.Client, runtimeClass)
+			}),
+		).
+		Complete(r)
+}

+ 97 - 0
pkg/controllers/providerstore/providerstore_controller_test.go

@@ -0,0 +1,97 @@
+/*
+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 providerstore
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
+)
+
+func TestProviderStoreReconcileMarksReadyWhenValidateSucceeds(t *testing.T) {
+	_, address, tlsSecret := newProviderStoreGRPCServer(t)
+
+	store := &esv2alpha1.ProviderStore{
+		ObjectMeta: metav1.ObjectMeta{Name: "aws-prod", Namespace: "tenant-a"},
+		Spec: esv2alpha1.ProviderStoreSpec{
+			RuntimeRef: esv2alpha1.StoreRuntimeRef{Name: "aws"},
+			BackendRef: esv2alpha1.BackendObjectReference{
+				APIVersion: "aws.external-secrets.io/v1alpha1",
+				Kind:       "SecretsManagerStore",
+				Name:       "prod",
+			},
+		},
+	}
+
+	runtimeClass := readyRuntimeClass("aws")
+	runtimeClass.Spec.Address = address
+
+	reconciler := newProviderStoreReconciler(
+		t,
+		store,
+		runtimeClass,
+		&corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "external-secrets-provider-tls",
+				Namespace: store.Namespace,
+			},
+			Data: tlsSecret,
+		},
+	)
+
+	_, err := reconciler.Reconcile(context.Background(), reconcileRequest(store))
+	require.NoError(t, err)
+
+	updated := &esv2alpha1.ProviderStore{}
+	err = reconciler.Get(context.Background(), client.ObjectKeyFromObject(store), updated)
+	require.NoError(t, err)
+	if !providerStoreReady(updated.Status) {
+		t.Fatalf("expected Ready condition, got %#v", updated.Status.Conditions)
+	}
+}
+
+func TestClusterProviderStoreReconcileUsesRuntimeOnlyValidationWhenBackendNamespaceIsOmitted(t *testing.T) {
+	store := &esv2alpha1.ClusterProviderStore{
+		ObjectMeta: metav1.ObjectMeta{Name: "aws-shared"},
+		Spec: esv2alpha1.ClusterProviderStoreSpec{
+			RuntimeRef: esv2alpha1.StoreRuntimeRef{Name: "aws"},
+			BackendRef: esv2alpha1.BackendObjectReference{
+				APIVersion: "aws.external-secrets.io/v1alpha1",
+				Kind:       "SecretsManagerStore",
+				Name:       "shared",
+			},
+		},
+	}
+
+	reconciler := newClusterProviderStoreReconciler(t, store, readyRuntimeClass("aws"))
+
+	_, err := reconciler.Reconcile(context.Background(), reconcileRequest(store))
+	require.NoError(t, err)
+
+	updated := &esv2alpha1.ClusterProviderStore{}
+	err = reconciler.Get(context.Background(), client.ObjectKeyFromObject(store), updated)
+	require.NoError(t, err)
+	if !providerStoreReady(updated.Status) {
+		t.Fatalf("expected Ready condition, got %#v", updated.Status.Conditions)
+	}
+}

+ 5 - 265
pkg/controllers/secretstore/client_manager.go

@@ -14,278 +14,18 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-// Package secretstore implements the controllers for managing SecretStore resources
 package secretstore
 
 import (
-	"context"
-	"errors"
-	"fmt"
-	"regexp"
-	"strings"
-
-	"github.com/go-logr/logr"
-	v1 "k8s.io/api/core/v1"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/labels"
-	"k8s.io/apimachinery/pkg/types"
-	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
-	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
-)
-
-const (
-	errGetClusterSecretStore = "could not get ClusterSecretStore %q, %w"
-	errGetSecretStore        = "could not get SecretStore %q, %w"
-	errSecretStoreNotReady   = "%s %q is not ready"
-	errClusterStoreMismatch  = "using cluster store %q is not allowed from namespace %q: denied by spec.condition"
+	"github.com/external-secrets/external-secrets/runtime/clientmanager"
 )
 
-// Manager stores instances of provider clients
-// At any given time we must have no more than one instance
-// of a client (due to limitations in GCP / see mutexlock there)
-// If the controller requests another instance of a given client
-// we will close the old client first and then construct a new one.
-type Manager struct {
-	log             logr.Logger
-	client          client.Client
-	controllerClass string
-	enableFloodgate bool
-
-	// store clients by provider type
-	clientMap map[clientKey]*clientVal
-}
-
-type clientKey struct {
-	providerType string
-}
-
-type clientVal struct {
-	client esv1.SecretsClient
-	store  esv1.GenericStore
-}
+// Manager is kept as a compatibility alias while call sites migrate to runtime/clientmanager.
+type Manager = clientmanager.Manager
 
-// NewManager constructs a new manager with defaults.
+// NewManager returns the shared runtime client manager implementation.
 func NewManager(ctrlClient client.Client, controllerClass string, enableFloodgate bool) *Manager {
-	log := ctrl.Log.WithName("clientmanager")
-	return &Manager{
-		log:             log,
-		client:          ctrlClient,
-		controllerClass: controllerClass,
-		enableFloodgate: enableFloodgate,
-		clientMap:       make(map[clientKey]*clientVal),
-	}
-}
-
-// GetFromStore returns a provider client from the given store.
-// Do not close the client returned from this func, instead close
-// the manager once you're done with reconciling the external secret.
-func (m *Manager) GetFromStore(ctx context.Context, store esv1.GenericStore, namespace string) (esv1.SecretsClient, error) {
-	storeProvider, err := esv1.GetProvider(store)
-	if err != nil {
-		return nil, err
-	}
-	secretClient := m.getStoredClient(ctx, storeProvider, store)
-	if secretClient != nil {
-		return secretClient, nil
-	}
-	m.log.V(1).Info("creating new client",
-		"provider", fmt.Sprintf("%T", storeProvider),
-		"store", fmt.Sprintf("%s/%s", store.GetNamespace(), store.GetName()))
-	// secret client is created only if we are going to refresh
-	// this skip an unnecessary check/request in the case we are not going to do anything
-	secretClient, err = storeProvider.NewClient(ctx, store, m.client, namespace)
-	if err != nil {
-		return nil, err
-	}
-	idx := storeKey(storeProvider)
-	m.clientMap[idx] = &clientVal{
-		client: secretClient,
-		store:  store,
-	}
-	return secretClient, nil
-}
-
-// Get returns a provider client from the given storeRef or sourceRef.secretStoreRef
-// while sourceRef.SecretStoreRef takes precedence over storeRef.
-// Do not close the client returned from this func, instead close
-// the manager once you're done with recinciling the external secret.
-func (m *Manager) Get(ctx context.Context, storeRef esv1.SecretStoreRef, namespace string, sourceRef *esv1.StoreGeneratorSourceRef) (esv1.SecretsClient, error) {
-	if sourceRef != nil && sourceRef.SecretStoreRef != nil {
-		storeRef = *sourceRef.SecretStoreRef
-	}
-	store, err := m.getStore(ctx, &storeRef, namespace)
-	if err != nil {
-		return nil, err
-	}
-	// check if store should be handled by this controller instance
-	if !ShouldProcessStore(store, m.controllerClass) {
-		return nil, errors.New("can not reference unmanaged store")
-	}
-	// when using ClusterSecretStore, validate the ClusterSecretStore namespace conditions
-	shouldProcess, err := m.shouldProcessSecret(store, namespace)
-	if err != nil {
-		return nil, err
-	}
-	if !shouldProcess {
-		return nil, fmt.Errorf(errClusterStoreMismatch, store.GetName(), namespace)
-	}
-
-	if m.enableFloodgate {
-		err := assertStoreIsUsable(store)
-		if err != nil {
-			return nil, err
-		}
-	}
-	return m.GetFromStore(ctx, store, namespace)
-}
-
-// returns a previously stored client from the cache if store and store-version match
-// if a client exists for the same provider which points to a different store or store version
-// it will be cleaned up.
-func (m *Manager) getStoredClient(ctx context.Context, storeProvider esv1.Provider, store esv1.GenericStore) esv1.SecretsClient {
-	idx := storeKey(storeProvider)
-	val, ok := m.clientMap[idx]
-	if !ok {
-		return nil
-	}
-	valGVK, err := m.client.GroupVersionKindFor(val.store)
-	if err != nil {
-		return nil
-	}
-	storeGVK, err := m.client.GroupVersionKindFor(store)
-	if err != nil {
-		return nil
-	}
-	storeName := fmt.Sprintf("%s/%s", store.GetNamespace(), store.GetName())
-	// return client if it points to the very same store
-	if val.store.GetObjectMeta().Generation == store.GetGeneration() &&
-		valGVK == storeGVK &&
-		val.store.GetName() == store.GetName() &&
-		val.store.GetNamespace() == store.GetNamespace() {
-		m.log.V(1).Info("reusing stored client",
-			"provider", fmt.Sprintf("%T", storeProvider),
-			"store", storeName)
-		return val.client
-	}
-	m.log.V(1).Info("cleaning up client",
-		"provider", fmt.Sprintf("%T", storeProvider),
-		"store", storeName)
-	// if we have a client, but it points to a different store
-	// we must clean it up
-	_ = val.client.Close(ctx)
-	delete(m.clientMap, idx)
-	return nil
-}
-
-func storeKey(storeProvider esv1.Provider) clientKey {
-	return clientKey{
-		providerType: fmt.Sprintf("%T", storeProvider),
-	}
-}
-
-// getStore fetches the (Cluster)SecretStore from the kube-apiserver
-// and returns a GenericStore representing it.
-func (m *Manager) getStore(ctx context.Context, storeRef *esv1.SecretStoreRef, namespace string) (esv1.GenericStore, error) {
-	ref := types.NamespacedName{
-		Name: storeRef.Name,
-	}
-	if storeRef.Kind == esv1.ClusterSecretStoreKind {
-		var store esv1.ClusterSecretStore
-		err := m.client.Get(ctx, ref, &store)
-		if err != nil {
-			return nil, fmt.Errorf(errGetClusterSecretStore, ref.Name, err)
-		}
-		return &store, nil
-	}
-	ref.Namespace = namespace
-	var store esv1.SecretStore
-	err := m.client.Get(ctx, ref, &store)
-	if err != nil {
-		return nil, fmt.Errorf(errGetSecretStore, ref.Name, err)
-	}
-	return &store, nil
-}
-
-// Close cleans up all clients.
-func (m *Manager) Close(ctx context.Context) error {
-	var errs []string
-	for key, val := range m.clientMap {
-		err := val.client.Close(ctx)
-		if err != nil {
-			errs = append(errs, err.Error())
-		}
-		delete(m.clientMap, key)
-	}
-	if len(errs) != 0 {
-		return fmt.Errorf("errors while closing clients: %s", strings.Join(errs, ", "))
-	}
-	return nil
-}
-
-func (m *Manager) shouldProcessSecret(store esv1.GenericStore, ns string) (bool, error) {
-	if store.GetKind() != esv1.ClusterSecretStoreKind {
-		return true, nil
-	}
-
-	if len(store.GetSpec().Conditions) == 0 {
-		return true, nil
-	}
-
-	namespace := v1.Namespace{}
-	if err := m.client.Get(context.Background(), client.ObjectKey{Name: ns}, &namespace); err != nil {
-		return false, fmt.Errorf("failed to get a namespace %q: %w", ns, err)
-	}
-
-	nsLabels := labels.Set(namespace.GetLabels())
-	for _, condition := range store.GetSpec().Conditions {
-		var labelSelectors []*metav1.LabelSelector
-		if condition.NamespaceSelector != nil {
-			labelSelectors = append(labelSelectors, condition.NamespaceSelector)
-		}
-		for _, n := range condition.Namespaces {
-			labelSelectors = append(labelSelectors, &metav1.LabelSelector{
-				MatchLabels: map[string]string{
-					"kubernetes.io/metadata.name": n,
-				},
-			})
-		}
-
-		for _, ls := range labelSelectors {
-			selector, err := metav1.LabelSelectorAsSelector(ls)
-			if err != nil {
-				return false, fmt.Errorf("failed to convert label selector into selector %v: %w", ls, err)
-			}
-			if selector.Matches(nsLabels) {
-				return true, nil
-			}
-		}
-
-		for _, reg := range condition.NamespaceRegexes {
-			match, err := regexp.MatchString(reg, ns)
-			if err != nil {
-				// Should not happen since store validation already verified the regexes.
-				return false, fmt.Errorf("failed to compile regex %v: %w", reg, err)
-			}
-
-			if match {
-				return true, nil
-			}
-		}
-	}
-
-	return false, nil
-}
-
-// assertStoreIsUsable assert that the store is ready to use.
-func assertStoreIsUsable(store esv1.GenericStore) error {
-	if store == nil {
-		return nil
-	}
-	condition := GetSecretStoreCondition(store.GetStatus(), esv1.SecretStoreReady)
-	if condition == nil || condition.Status != v1.ConditionTrue {
-		return fmt.Errorf(errSecretStoreNotReady, store.GetKind(), store.GetName())
-	}
-	return nil
+	return clientmanager.NewManager(ctrlClient, controllerClass, enableFloodgate)
 }

+ 0 - 478
pkg/controllers/secretstore/client_manager_test.go

@@ -1,478 +0,0 @@
-/*
-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 secretstore
-
-import (
-	"context"
-	"testing"
-
-	"github.com/go-logr/logr"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-	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"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
-	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
-
-	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
-)
-
-func TestManagerGet(t *testing.T) {
-	scheme := runtime.NewScheme()
-
-	// add kubernetes schemes
-	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
-	utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
-
-	// add external-secrets schemes
-	utilruntime.Must(esv1.AddToScheme(scheme))
-
-	// We have a test provider to control
-	// the behavior of the NewClient func.
-	fakeProvider := &WrapProvider{}
-	esv1.ForceRegister(fakeProvider, &esv1.SecretStoreProvider{
-		AWS: &esv1.AWSProvider{},
-	}, esv1.MaintenanceStatusMaintained)
-
-	// fake clients are re-used to compare the
-	// in-memory reference
-	clientA := &MockFakeClient{id: "1"}
-	clientB := &MockFakeClient{id: "2"}
-
-	const testNamespace = "foo"
-
-	readyStatus := esv1.SecretStoreStatus{
-		Conditions: []esv1.SecretStoreStatusCondition{
-			{
-				Type:   esv1.SecretStoreReady,
-				Status: corev1.ConditionTrue,
-			},
-		},
-	}
-
-	fakeSpec := esv1.SecretStoreSpec{
-		Provider: &esv1.SecretStoreProvider{
-			AWS: &esv1.AWSProvider{},
-		},
-	}
-
-	defaultStore := &esv1.SecretStore{
-		TypeMeta: metav1.TypeMeta{Kind: esv1.SecretStoreKind},
-		ObjectMeta: metav1.ObjectMeta{
-			Name:      "foo",
-			Namespace: testNamespace,
-		},
-		Spec:   fakeSpec,
-		Status: readyStatus,
-	}
-
-	otherStore := &esv1.SecretStore{
-		TypeMeta: metav1.TypeMeta{Kind: esv1.SecretStoreKind},
-		ObjectMeta: metav1.ObjectMeta{
-			Name:      "other",
-			Namespace: testNamespace,
-		},
-		Spec:   fakeSpec,
-		Status: readyStatus,
-	}
-
-	var mgr *Manager
-
-	provKey := clientKey{
-		providerType: "*secretstore.WrapProvider",
-	}
-
-	type fields struct {
-		client    client.Client
-		clientMap map[clientKey]*clientVal
-	}
-	type args struct {
-		storeRef  esv1.SecretStoreRef
-		namespace string
-		sourceRef *esv1.StoreGeneratorSourceRef
-	}
-	tests := []struct {
-		name              string
-		fields            fields
-		args              args
-		clientConstructor func(
-			ctx context.Context,
-			store esv1.GenericStore,
-			kube client.Client,
-			namespace string) (esv1.SecretsClient, error)
-		verify     func(esv1.SecretsClient)
-		afterClose func()
-		want       esv1.SecretsClient
-		wantErr    bool
-	}{
-		{
-			name:    "creates a new client from storeRef and stores it",
-			wantErr: false,
-			fields: fields{
-				client: fakeclient.NewClientBuilder().
-					WithScheme(scheme).
-					WithObjects(defaultStore).
-					Build(),
-				clientMap: make(map[clientKey]*clientVal),
-			},
-			args: args{
-				storeRef: esv1.SecretStoreRef{
-					Name: defaultStore.Name,
-					Kind: esv1.SecretStoreKind,
-				},
-				namespace: defaultStore.Namespace,
-				sourceRef: nil,
-			},
-			clientConstructor: func(_ context.Context, _ esv1.GenericStore, _ client.Client, _ string) (esv1.SecretsClient, error) {
-				return clientA, nil
-			},
-			verify: func(sc esv1.SecretsClient) {
-				// we now must have this provider in the clientMap
-				// and it mustbe the client defined in clientConstructor
-				assert.NotNil(t, sc)
-				c, ok := mgr.clientMap[provKey]
-				require.True(t, ok)
-				assert.Same(t, c.client, clientA)
-			},
-
-			afterClose: func() {
-				v, ok := mgr.clientMap[provKey]
-				assert.False(t, ok)
-				assert.Nil(t, v)
-			},
-		},
-		{
-			name:    "creates a new client using both storeRef and sourceRef",
-			wantErr: false,
-			fields: fields{
-				client: fakeclient.NewClientBuilder().
-					WithScheme(scheme).
-					WithObjects(otherStore).
-					Build(),
-				clientMap: make(map[clientKey]*clientVal),
-			},
-			args: args{
-				storeRef: esv1.SecretStoreRef{
-					Name: defaultStore.Name,
-					Kind: esv1.SecretStoreKind,
-				},
-				// this should take precedence
-				sourceRef: &esv1.StoreGeneratorSourceRef{
-					SecretStoreRef: &esv1.SecretStoreRef{
-						Name: otherStore.Name,
-						Kind: esv1.SecretStoreKind,
-					},
-				},
-				namespace: defaultStore.Namespace,
-			},
-			clientConstructor: func(_ context.Context, _ esv1.GenericStore, _ client.Client, _ string) (esv1.SecretsClient, error) {
-				return clientB, nil
-			},
-			verify: func(sc esv1.SecretsClient) {
-				// we now must have this provider in the clientMap
-				// and it mustbe the client defined in clientConstructor
-				assert.NotNil(t, sc)
-				c, ok := mgr.clientMap[provKey]
-				assert.True(t, ok)
-				assert.Same(t, c.client, clientB)
-			},
-
-			afterClose: func() {
-				v, ok := mgr.clientMap[provKey]
-				assert.False(t, ok)
-				assert.True(t, clientB.closeCalled)
-				assert.Nil(t, v)
-			},
-		},
-		{
-			name:    "retrieve cached client when store matches",
-			wantErr: false,
-			fields: fields{
-				client: fakeclient.NewClientBuilder().
-					WithScheme(scheme).
-					WithObjects(defaultStore).
-					Build(),
-				clientMap: map[clientKey]*clientVal{
-					provKey: {
-						client: clientA,
-						store:  defaultStore,
-					},
-				},
-			},
-			args: args{
-				storeRef: esv1.SecretStoreRef{
-					Name: defaultStore.Name,
-					Kind: esv1.SecretStoreKind,
-				},
-				namespace: defaultStore.Namespace,
-				sourceRef: nil,
-			},
-			clientConstructor: func(_ context.Context, _ esv1.GenericStore, _ client.Client, _ string) (esv1.SecretsClient, error) {
-				// constructor should not be called,
-				// the client from the cache should be returned instead
-				t.Fail()
-				return nil, nil
-			},
-			verify: func(sc esv1.SecretsClient) {
-				// verify that the secretsClient is the one from cache
-				assert.NotNil(t, sc)
-				c, ok := mgr.clientMap[provKey]
-				assert.True(t, ok)
-				assert.Same(t, c.client, clientA)
-				assert.Same(t, sc, clientA)
-			},
-
-			afterClose: func() {
-				v, ok := mgr.clientMap[provKey]
-				assert.False(t, ok)
-				assert.True(t, clientA.closeCalled)
-				assert.Nil(t, v)
-			},
-		},
-		{
-			name:    "create new client when store doesn't match",
-			wantErr: false,
-			fields: fields{
-				client: fakeclient.NewClientBuilder().
-					WithScheme(scheme).
-					WithObjects(otherStore).
-					Build(),
-				clientMap: map[clientKey]*clientVal{
-					provKey: {
-						// we have clientA in cache pointing at defaultStore
-						client: clientA,
-						store:  defaultStore,
-					},
-				},
-			},
-			args: args{
-				storeRef: esv1.SecretStoreRef{
-					Name: otherStore.Name,
-					Kind: esv1.SecretStoreKind,
-				},
-				namespace: otherStore.Namespace,
-				sourceRef: nil,
-			},
-			clientConstructor: func(_ context.Context, _ esv1.GenericStore, _ client.Client, _ string) (esv1.SecretsClient, error) {
-				// because there is a store mismatch
-				// we create a new client
-				return clientB, nil
-			},
-			verify: func(sc esv1.SecretsClient) {
-				// verify that SecretsClient is NOT the one from cache
-				assert.NotNil(t, sc)
-				c, ok := mgr.clientMap[provKey]
-				assert.True(t, ok)
-				assert.Same(t, c.client, clientB)
-				assert.Same(t, sc, clientB)
-				assert.True(t, clientA.closeCalled)
-			},
-			afterClose: func() {
-				v, ok := mgr.clientMap[provKey]
-				assert.False(t, ok)
-				assert.True(t, clientB.closeCalled)
-				assert.Nil(t, v)
-			},
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			mgr = &Manager{
-				log:             logr.Discard(),
-				client:          tt.fields.client,
-				enableFloodgate: true,
-				clientMap:       tt.fields.clientMap,
-			}
-			fakeProvider.newClientFunc = tt.clientConstructor
-			clientA.closeCalled = false
-			clientB.closeCalled = false
-			got, err := mgr.Get(context.Background(), tt.args.storeRef, tt.args.namespace, tt.args.sourceRef)
-			if (err != nil) != tt.wantErr {
-				t.Errorf("Manager.Get() error = %v, wantErr %v", err, tt.wantErr)
-				return
-			}
-			tt.verify(got)
-			mgr.Close(context.Background())
-			tt.afterClose()
-		})
-	}
-}
-
-func TestShouldProcessSecret(t *testing.T) {
-	scheme := runtime.NewScheme()
-
-	// add kubernetes schemes
-	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
-	utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
-
-	// add external-secrets schemes
-	utilruntime.Must(esv1.AddToScheme(scheme))
-
-	testNamespace := "test-a"
-	testCases := []struct {
-		name       string
-		conditions []esv1.ClusterSecretStoreCondition
-		namespace  *corev1.Namespace
-		wantErr    string
-		want       bool
-	}{
-		{
-			name: "processes a regex condition",
-			conditions: []esv1.ClusterSecretStoreCondition{
-				{
-					NamespaceRegexes: []string{`test-*`},
-				},
-			},
-			namespace: &corev1.Namespace{
-				ObjectMeta: metav1.ObjectMeta{
-					Name: testNamespace,
-				},
-			},
-			want: true,
-		},
-		{
-			name: "process multiple regexes",
-			conditions: []esv1.ClusterSecretStoreCondition{
-				{
-					NamespaceRegexes: []string{`nope`, `test-*`},
-				},
-			},
-			namespace: &corev1.Namespace{
-				ObjectMeta: metav1.ObjectMeta{
-					Name: testNamespace,
-				},
-			},
-			want: true,
-		},
-		{
-			name: "shouldn't process if nothing matches",
-			conditions: []esv1.ClusterSecretStoreCondition{
-				{
-					NamespaceRegexes: []string{`nope`},
-				},
-			},
-			namespace: &corev1.Namespace{
-				ObjectMeta: metav1.ObjectMeta{
-					Name: testNamespace,
-				},
-			},
-			want: false,
-		},
-	}
-
-	for _, tt := range testCases {
-		t.Run(tt.name, func(t *testing.T) {
-			fakeSpec := esv1.SecretStoreSpec{
-				Conditions: tt.conditions,
-			}
-
-			defaultStore := &esv1.ClusterSecretStore{
-				TypeMeta: metav1.TypeMeta{Kind: esv1.ClusterSecretStoreKind},
-				ObjectMeta: metav1.ObjectMeta{
-					Name:      "foo",
-					Namespace: tt.namespace.Name,
-				},
-				Spec: fakeSpec,
-			}
-
-			client := fakeclient.NewClientBuilder().WithScheme(scheme).WithObjects(defaultStore, tt.namespace).Build()
-			clientMap := make(map[clientKey]*clientVal)
-			mgr := &Manager{
-				log:             logr.Discard(),
-				client:          client,
-				enableFloodgate: true,
-				clientMap:       clientMap,
-			}
-
-			got, err := mgr.shouldProcessSecret(defaultStore, tt.namespace.Name)
-			require.NoError(t, err)
-
-			assert.Equal(t, tt.want, got)
-		})
-	}
-}
-
-type WrapProvider struct {
-	newClientFunc func(
-		context.Context,
-		esv1.GenericStore,
-		client.Client,
-		string) (esv1.SecretsClient, error)
-}
-
-// NewClient constructs a SecretsManager Provider.
-func (f *WrapProvider) NewClient(
-	ctx context.Context,
-	store esv1.GenericStore,
-	kube client.Client,
-	namespace string) (esv1.SecretsClient, error) {
-	return f.newClientFunc(ctx, store, kube, namespace)
-}
-
-func (f *WrapProvider) Capabilities() esv1.SecretStoreCapabilities {
-	return esv1.SecretStoreReadOnly
-}
-
-// ValidateStore checks if the provided store is valid.
-func (f *WrapProvider) ValidateStore(_ esv1.GenericStore) (admission.Warnings, error) {
-	return nil, nil
-}
-
-type MockFakeClient struct {
-	id          string
-	closeCalled bool
-}
-
-func (c *MockFakeClient) PushSecret(_ context.Context, _ *corev1.Secret, _ esv1.PushSecretData) error {
-	return nil
-}
-
-func (c *MockFakeClient) DeleteSecret(_ context.Context, _ esv1.PushSecretRemoteRef) error {
-	return nil
-}
-
-func (c *MockFakeClient) SecretExists(_ context.Context, _ esv1.PushSecretRemoteRef) (bool, error) {
-	return false, nil
-}
-
-func (c *MockFakeClient) GetSecret(_ context.Context, _ esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
-	return nil, nil
-}
-
-func (c *MockFakeClient) Validate() (esv1.ValidationResult, error) {
-	return esv1.ValidationResultReady, nil
-}
-
-// GetSecretMap returns multiple k/v pairs from the provider.
-func (c *MockFakeClient) GetSecretMap(_ context.Context, _ esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
-	return nil, nil
-}
-
-// GetAllSecrets returns multiple k/v pairs from the provider.
-func (c *MockFakeClient) GetAllSecrets(_ context.Context, _ esv1.ExternalSecretFind) (map[string][]byte, error) {
-	return nil, nil
-}
-
-func (c *MockFakeClient) Close(_ context.Context) error {
-	c.closeCalled = true
-	return nil
-}

+ 11 - 6
pkg/controllers/secretstore/common.go

@@ -36,6 +36,8 @@ import (
 	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/controllers/secretstore/storeutil"
+	"github.com/external-secrets/external-secrets/runtime/clientmanager"
 
 	// Load registered providers.
 	_ "github.com/external-secrets/external-secrets/pkg/register"
@@ -68,7 +70,7 @@ type Opts struct {
 }
 
 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) {
+	if !storeutil.ShouldProcessStore(ss, opts.ControllerClass) {
 		log.V(1).Info("skip store")
 		return ctrl.Result{}, nil
 	}
@@ -172,7 +174,7 @@ func reconcile(ctx context.Context, req ctrl.Request, ss esapi.GenericStore, cl
 // if it fails sets a condition and writes events.
 func validateStore(ctx context.Context, namespace, controllerClass string, store esapi.GenericStore,
 	client client.Client, gaugeVecGetter metrics.GaugeVevGetter, recorder record.EventRecorder) error {
-	mgr := NewManager(client, controllerClass, false)
+	mgr := clientmanager.NewManager(client, controllerClass, false)
 	defer func() {
 		_ = mgr.Close(ctx)
 	}()
@@ -201,12 +203,15 @@ func validateStore(ctx context.Context, namespace, controllerClass string, store
 }
 
 // ShouldProcessStore returns true if the store should be processed.
+// This is a wrapper around storeutil.ShouldProcessStore for backward compatibility.
 func ShouldProcessStore(store esapi.GenericStore, class string) bool {
-	if store == nil || store.GetSpec().Controller == "" || store.GetSpec().Controller == class {
-		return true
-	}
+	return storeutil.ShouldProcessStore(store, class)
+}
 
-	return false
+// AssertStoreIsUsable asserts that the store is ready to use.
+// This is a wrapper around storeutil.AssertStoreIsUsable for backward compatibility.
+func AssertStoreIsUsable(store esapi.GenericStore) error {
+	return storeutil.AssertStoreIsUsable(store)
 }
 
 // handleFinalizer manages the finalizer for ClusterSecretStores and SecretStores.

+ 62 - 0
pkg/controllers/secretstore/storeutil/util.go

@@ -0,0 +1,62 @@
+/*
+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 storeutil provides utility functions for SecretStore operations
+package storeutil
+
+import (
+	"fmt"
+
+	v1 "k8s.io/api/core/v1"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+const (
+	errSecretStoreNotReady = "%s %q is not ready"
+)
+
+// ShouldProcessStore returns true if the store should be processed.
+func ShouldProcessStore(store esv1.GenericStore, class string) bool {
+	if store == nil || store.GetSpec().Controller == "" || store.GetSpec().Controller == class {
+		return true
+	}
+
+	return false
+}
+
+// AssertStoreIsUsable asserts that the store is ready to use.
+func AssertStoreIsUsable(store esv1.GenericStore) error {
+	if store == nil {
+		return nil
+	}
+	condition := GetSecretStoreCondition(store.GetStatus(), esv1.SecretStoreReady)
+	if condition == nil || condition.Status != v1.ConditionTrue {
+		return fmt.Errorf(errSecretStoreNotReady, store.GetKind(), store.GetName())
+	}
+	return nil
+}
+
+// GetSecretStoreCondition returns the condition with the provided type.
+func GetSecretStoreCondition(status esv1.SecretStoreStatus, condType esv1.SecretStoreConditionType) *esv1.SecretStoreStatusCondition {
+	for i := range status.Conditions {
+		c := status.Conditions[i]
+		if c.Type == condType {
+			return &c
+		}
+	}
+	return nil
+}

+ 2 - 0
pkg/controllers/secretstore/suite_test.go

@@ -38,6 +38,7 @@ import (
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore/cssmetrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore/ssmetrics"
+	fakeprovider "github.com/external-secrets/external-secrets/providers/v1/fake"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -151,6 +152,7 @@ var _ = AfterSuite(func() {
 })
 
 func init() {
+	esapi.ForceRegister(fakeprovider.NewProvider(), fakeprovider.ProviderSpec(), fakeprovider.MaintenanceStatus())
 	ctrlmetrics.SetUpLabelNames(false)
 	cssmetrics.SetUpMetrics()
 	ssmetrics.SetUpMetrics()

+ 3 - 7
pkg/controllers/secretstore/util.go

@@ -26,6 +26,7 @@ import (
 	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/controllers/secretstore/storeutil"
 )
 
 // NewSecretStoreCondition a set of default options for creating an External Secret Condition.
@@ -40,14 +41,9 @@ func NewSecretStoreCondition(condType esapi.SecretStoreConditionType, status v1.
 }
 
 // GetSecretStoreCondition returns the condition with the provided type.
+// This is a wrapper around storeutil.GetSecretStoreCondition for backward compatibility.
 func GetSecretStoreCondition(status esapi.SecretStoreStatus, condType esapi.SecretStoreConditionType) *esapi.SecretStoreStatusCondition {
-	for i := range status.Conditions {
-		c := status.Conditions[i]
-		if c.Type == condType {
-			return &c
-		}
-	}
-	return nil
+	return storeutil.GetSecretStoreCondition(status, condType)
 }
 
 // SetExternalSecretCondition updates the external secret to include the provided

+ 532 - 0
runtime/clientmanager/manager.go

@@ -0,0 +1,532 @@
+/*
+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 clientmanager provides a Manager for provider clients
+package clientmanager
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"regexp"
+	"strings"
+	"sync"
+
+	"github.com/go-logr/logr"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/types"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	adapterstore "github.com/external-secrets/external-secrets/providers/v2/adapter/store"
+	"github.com/external-secrets/external-secrets/providers/v2/common/grpc"
+)
+
+const (
+	errGetClusterSecretStore      = "could not get ClusterSecretStore %q, %w"
+	errGetSecretStore             = "could not get SecretStore %q, %w"
+	errSecretStoreNotReady        = "%s %q is not ready"
+	errClusterStoreMismatch       = "using cluster store %q is not allowed from namespace %q: denied by spec.condition"
+	errClusterProviderStoreDenied = "using ClusterProviderStore %q is not allowed from namespace %q: denied by spec.conditions"
+
+	providerMetricsLabel        = "provider"
+	clusterProviderMetricsLabel = "cluster-provider"
+	cacheInvalidationGeneration = "generation_change"
+	cacheInvalidationMismatch   = "store_mismatch"
+	v2ProviderStoreCacheKey     = "v2-provider-store"
+	v2ClusterProviderStoreCache = "v2-cluster-provider-store"
+	runtimeRefCacheKeyType      = "runtime-ref"
+)
+
+var (
+	// globalV2ConnectionPool is a singleton connection pool for v2 gRPC providers.
+	// It persists across all reconciles and Manager instances to enable connection reuse.
+	// Initialized once on first use and shared globally.
+	globalV2ConnectionPool     *grpc.ConnectionPool
+	globalV2ConnectionPoolOnce sync.Once
+	globalV2ConnectionPoolLog  logr.Logger
+)
+
+// initGlobalV2ConnectionPool initializes the global connection pool for v2 providers.
+// This is called once on first use via sync.Once.
+func initGlobalV2ConnectionPool() {
+	globalV2ConnectionPoolLog = ctrl.Log.WithName("v2-connection-pool")
+	poolConfig := grpc.DefaultPoolConfig()
+	globalV2ConnectionPool = grpc.NewConnectionPool(poolConfig)
+	globalV2ConnectionPoolLog.Info("global v2 connection pool initialized",
+		"maxIdleTime", poolConfig.MaxIdleTime.String(),
+		"maxLifetime", poolConfig.MaxLifetime.String(),
+		"healthCheckInterval", poolConfig.HealthCheckInterval.String())
+}
+
+// getGlobalV2ConnectionPool returns the global connection pool, initializing it if needed.
+func getGlobalV2ConnectionPool() *grpc.ConnectionPool {
+	globalV2ConnectionPoolOnce.Do(initGlobalV2ConnectionPool)
+	return globalV2ConnectionPool
+}
+
+// v2PooledConnection tracks connection info needed to release connections back to the pool.
+type v2PooledConnection struct {
+	address   string
+	tlsConfig *grpc.TLSConfig
+}
+
+// Manager stores instances of provider clients
+// At any given time we must have no more than one instance
+// of a client (due to limitations in GCP / see mutexlock there)
+// If the controller requests another instance of a given client
+// we will close the old client first and then construct a new one.
+type Manager struct {
+	log             logr.Logger
+	client          client.Client
+	controllerClass string
+	enableFloodgate bool
+
+	// store clients by provider type
+	clientMap map[clientKey]*clientVal
+
+	// Track v2 provider connections for release back to pool
+	v2PooledConnections []v2PooledConnection
+}
+
+type clientKey struct {
+	providerType string
+	// For v2 providers, store the provider name and namespace
+	v2ProviderName         string
+	v2ProviderNamespace    string
+	runtimeSourceNamespace string
+}
+
+type clientVal struct {
+	client esv1.SecretsClient
+	store  esv1.GenericStore
+	// For v2 providers, store the generation for cache invalidation
+	v2ProviderGeneration int64
+}
+
+func providerMetricsLabelForScope(isClusterScoped bool) string {
+	if isClusterScoped {
+		return clusterProviderMetricsLabel
+	}
+
+	return providerMetricsLabel
+}
+
+func providerMetricsLabelForKey(key clientKey) string {
+	if key.v2ProviderName == "" {
+		return "unknown"
+	}
+
+	if key.v2ProviderNamespace == "" {
+		return clusterProviderMetricsLabel
+	}
+
+	return providerMetricsLabel
+}
+
+// NewManager constructs a new manager with defaults.
+func NewManager(ctrlClient client.Client, controllerClass string, enableFloodgate bool) *Manager {
+	log := ctrl.Log.WithName("clientmanager")
+	return &Manager{
+		log:             log,
+		client:          ctrlClient,
+		controllerClass: controllerClass,
+		enableFloodgate: enableFloodgate,
+		clientMap:       make(map[clientKey]*clientVal),
+	}
+}
+
+// GetFromStore returns a provider client from the given store.
+// Do not close the client returned from this func, instead close
+// the manager once you're done with reconciling the external secret.
+func (m *Manager) GetFromStore(ctx context.Context, store esv1.GenericStore, namespace string) (esv1.SecretsClient, error) {
+	if store.GetSpec().RuntimeRef != nil {
+		return m.getRuntimeRefClient(ctx, store, namespace)
+	}
+
+	storeProvider, err := esv1.GetProvider(store)
+	if err != nil {
+		return nil, err
+	}
+	secretClient := m.getStoredClient(ctx, storeProvider, store)
+	if secretClient != nil {
+		return secretClient, nil
+	}
+	m.log.V(1).Info("creating new client",
+		"provider", fmt.Sprintf("%T", storeProvider),
+		"store", fmt.Sprintf("%s/%s", store.GetNamespace(), store.GetName()))
+	// secret client is created only if we are going to refresh
+	// this skip an unnecessary check/request in the case we are not going to do anything
+	secretClient, err = storeProvider.NewClient(ctx, store, m.client, namespace)
+	if err != nil {
+		return nil, err
+	}
+	idx := storeKey(storeProvider)
+	m.clientMap[idx] = &clientVal{
+		client: secretClient,
+		store:  store,
+	}
+	return secretClient, nil
+}
+
+func (m *Manager) getRuntimeRefClient(ctx context.Context, store esv1.GenericStore, namespace string) (esv1.SecretsClient, error) {
+	runtimeRef := store.GetSpec().RuntimeRef
+	runtimeKind := runtimeRef.Kind
+	if runtimeKind == "" {
+		runtimeKind = runtimeRefKindClusterProviderClass
+	}
+	if runtimeKind != runtimeRefKindClusterProviderClass {
+		return nil, fmt.Errorf("unsupported runtimeRef kind %q", runtimeKind)
+	}
+
+	cacheKey := runtimeRefStoreKey(store, namespace)
+	if cached := m.getStoredRuntimeRefClient(ctx, cacheKey, store); cached != nil {
+		return cached, nil
+	}
+
+	var runtimeClass esv1alpha1.ClusterProviderClass
+	if err := m.client.Get(ctx, types.NamespacedName{Name: runtimeRef.Name}, &runtimeClass); err != nil {
+		return nil, fmt.Errorf("failed to get %s %q: %w", runtimeRefKindClusterProviderClass, runtimeRef.Name, err)
+	}
+
+	compatStore, err := buildCompatibilityStore(store)
+	if err != nil {
+		return nil, fmt.Errorf("failed to build compatibility store for %s %q: %w", store.GetKind(), store.GetName(), err)
+	}
+
+	tlsSecretNamespace := grpc.ResolveTLSSecretNamespace(runtimeClass.Spec.Address, "", "", "")
+	tlsConfig, err := grpc.LoadClientTLSConfig(ctx, m.client, runtimeClass.Spec.Address, tlsSecretNamespace)
+	if err != nil {
+		return nil, fmt.Errorf("failed to load TLS config for %s %q: %w", runtimeRefKindClusterProviderClass, runtimeRef.Name, err)
+	}
+
+	pool := getGlobalV2ConnectionPool()
+	grpcClient, err := pool.Get(ctx, runtimeClass.Spec.Address, tlsConfig)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get gRPC client from pool for %s %q: %w", runtimeRefKindClusterProviderClass, runtimeRef.Name, err)
+	}
+
+	compatibilityClient := adapterstore.NewCompatibilityClientWithCloser(grpcClient, compatStore, namespace, func(context.Context) error {
+		pool.Release(runtimeClass.Spec.Address, tlsConfig)
+		return nil
+	})
+
+	m.clientMap[cacheKey] = &clientVal{
+		client: compatibilityClient,
+		store:  store,
+	}
+
+	return compatibilityClient, nil
+}
+
+func runtimeRefStoreKey(store esv1.GenericStore, sourceNamespace string) clientKey {
+	return clientKey{
+		providerType:           runtimeRefCacheKeyType + ":" + store.GetKind(),
+		v2ProviderName:         store.GetName(),
+		v2ProviderNamespace:    store.GetNamespace(),
+		runtimeSourceNamespace: sourceNamespace,
+	}
+}
+
+func (m *Manager) getStoredRuntimeRefClient(ctx context.Context, key clientKey, store esv1.GenericStore) esv1.SecretsClient {
+	val, ok := m.clientMap[key]
+	if !ok {
+		return nil
+	}
+
+	valGVK, err := m.client.GroupVersionKindFor(val.store)
+	if err != nil {
+		return nil
+	}
+	storeGVK, err := m.client.GroupVersionKindFor(store)
+	if err != nil {
+		return nil
+	}
+
+	if val.store.GetObjectMeta().Generation == store.GetGeneration() &&
+		valGVK == storeGVK &&
+		val.store.GetName() == store.GetName() &&
+		val.store.GetNamespace() == store.GetNamespace() {
+		clientManagerMetrics.RecordCacheHit(providerMetricsLabelForKey(key))
+		return val.client
+	}
+
+	_ = val.client.Close(ctx)
+	delete(m.clientMap, key)
+
+	reason := cacheInvalidationMismatch
+	if val.store.GetObjectMeta().Generation != store.GetGeneration() {
+		reason = cacheInvalidationGeneration
+	}
+	clientManagerMetrics.RecordCacheInvalidation(providerMetricsLabelForKey(key), reason)
+
+	return nil
+}
+
+// Get returns a provider client from the given storeRef or sourceRef.secretStoreRef
+// while sourceRef.SecretStoreRef takes precedence over storeRef.
+// Do not close the client returned from this func, instead close
+// the manager once you're done with recinciling the external secret.
+func (m *Manager) Get(ctx context.Context, storeRef esv1.SecretStoreRef, namespace string, sourceRef *esv1.StoreGeneratorSourceRef) (esv1.SecretsClient, error) {
+	if sourceRef != nil && sourceRef.SecretStoreRef != nil {
+		storeRef = *sourceRef.SecretStoreRef
+	}
+
+	if storeRef.Kind == esv1.ProviderStoreKindStr {
+		return m.getV2ProviderStoreClient(ctx, storeRef.Name, namespace)
+	}
+	if storeRef.Kind == esv1.ClusterProviderStoreKindStr {
+		return m.getV2ClusterProviderStoreClient(ctx, storeRef.Name, namespace)
+	}
+
+	store, err := m.getStore(ctx, &storeRef, namespace)
+	if err != nil {
+		return nil, err
+	}
+	// check if store should be handled by this controller instance
+	if !ShouldProcessStore(store, m.controllerClass) {
+		return nil, errors.New("can not reference unmanaged store")
+	}
+	// when using ClusterSecretStore, validate the ClusterSecretStore namespace conditions
+	shouldProcess, err := m.shouldProcessSecret(store, namespace)
+	if err != nil {
+		return nil, err
+	}
+	if !shouldProcess {
+		return nil, fmt.Errorf(errClusterStoreMismatch, store.GetName(), namespace)
+	}
+
+	if m.enableFloodgate {
+		err := assertStoreIsUsable(store)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return m.GetFromStore(ctx, store, namespace)
+}
+
+// returns a previously stored client from the cache if store and store-version match
+// if a client exists for the same provider which points to a different store or store version
+// it will be cleaned up.
+func (m *Manager) getStoredClient(ctx context.Context, storeProvider esv1.ProviderInterface, store esv1.GenericStore) esv1.SecretsClient {
+	idx := storeKey(storeProvider)
+	val, ok := m.clientMap[idx]
+	if !ok {
+		return nil
+	}
+	valGVK, err := m.client.GroupVersionKindFor(val.store)
+	if err != nil {
+		return nil
+	}
+	storeGVK, err := m.client.GroupVersionKindFor(store)
+	if err != nil {
+		return nil
+	}
+	storeName := fmt.Sprintf("%s/%s", store.GetNamespace(), store.GetName())
+	// return client if it points to the very same store
+	if val.store.GetObjectMeta().Generation == store.GetGeneration() &&
+		valGVK == storeGVK &&
+		val.store.GetName() == store.GetName() &&
+		val.store.GetNamespace() == store.GetNamespace() {
+		m.log.V(1).Info("reusing stored client",
+			"provider", fmt.Sprintf("%T", storeProvider),
+			"store", storeName)
+		// Record cache hit
+		clientManagerMetrics.RecordCacheHit(providerMetricsLabelForKey(idx))
+		return val.client
+	}
+	m.log.V(1).Info("cleaning up client",
+		"provider", fmt.Sprintf("%T", storeProvider),
+		"store", storeName)
+	// if we have a client, but it points to a different store
+	// we must clean it up
+	_ = val.client.Close(ctx)
+	delete(m.clientMap, idx)
+
+	// Record cache invalidation
+	providerType := providerMetricsLabelForKey(idx)
+	reason := cacheInvalidationMismatch
+	if idx.v2ProviderName != "" {
+		if val.store.GetObjectMeta().Generation != store.GetGeneration() {
+			reason = cacheInvalidationGeneration
+		}
+	}
+	clientManagerMetrics.RecordCacheInvalidation(providerType, reason)
+
+	return nil
+}
+
+func storeKey(storeProvider esv1.ProviderInterface) clientKey {
+	return clientKey{
+		providerType: fmt.Sprintf("%T", storeProvider),
+	}
+}
+
+// getStore fetches the (Cluster)SecretStore from the kube-apiserver
+// and returns a GenericStore representing it.
+func (m *Manager) getStore(ctx context.Context, storeRef *esv1.SecretStoreRef, namespace string) (esv1.GenericStore, error) {
+	ref := types.NamespacedName{
+		Name: storeRef.Name,
+	}
+	if storeRef.Kind == esv1.ClusterSecretStoreKind {
+		var store esv1.ClusterSecretStore
+		err := m.client.Get(ctx, ref, &store)
+		if err != nil {
+			return nil, fmt.Errorf(errGetClusterSecretStore, ref.Name, err)
+		}
+		return &store, nil
+	}
+	ref.Namespace = namespace
+	var store esv1.SecretStore
+	err := m.client.Get(ctx, ref, &store)
+	if err != nil {
+		return nil, fmt.Errorf(errGetSecretStore, ref.Name, err)
+	}
+	return &store, nil
+}
+
+// Close cleans up all clients.
+// For v1 providers, it closes the clients directly.
+// For v2 providers, it releases connections back to the pool for reuse.
+func (m *Manager) Close(ctx context.Context) error {
+	var errs []string
+
+	// Release v2 pooled connections back to the pool
+	pool := getGlobalV2ConnectionPool()
+	for _, pooledConn := range m.v2PooledConnections {
+		pool.Release(pooledConn.address, pooledConn.tlsConfig)
+		m.log.V(1).Info("released v2 connection back to pool",
+			"address", pooledConn.address)
+	}
+	m.v2PooledConnections = nil
+
+	// Close v1 provider clients (they don't use the pool)
+	for key, val := range m.clientMap {
+		// Only close v1 clients; v2 clients are managed by the pool
+		if key.providerType != v2ProviderStoreCacheKey &&
+			key.providerType != v2ClusterProviderStoreCache {
+			err := val.client.Close(ctx)
+			if err != nil {
+				errs = append(errs, err.Error())
+			}
+		}
+		delete(m.clientMap, key)
+	}
+
+	if len(errs) != 0 {
+		return fmt.Errorf("errors while closing clients: %s", strings.Join(errs, ", "))
+	}
+	return nil
+}
+
+// validateNamespaceConditions checks if a namespace matches the given conditions.
+// Returns true if the namespace is allowed, false if denied.
+func (m *Manager) validateNamespaceConditions(conditions []esv1.ClusterSecretStoreCondition, ns string) (bool, error) {
+	if len(conditions) == 0 {
+		return true, nil
+	}
+
+	namespace := v1.Namespace{}
+	if err := m.client.Get(context.Background(), client.ObjectKey{Name: ns}, &namespace); err != nil {
+		return false, fmt.Errorf("failed to get a namespace %q: %w", ns, err)
+	}
+
+	nsLabels := labels.Set(namespace.GetLabels())
+	for _, condition := range conditions {
+		var labelSelectors []*metav1.LabelSelector
+		if condition.NamespaceSelector != nil {
+			labelSelectors = append(labelSelectors, condition.NamespaceSelector)
+		}
+		for _, n := range condition.Namespaces {
+			labelSelectors = append(labelSelectors, &metav1.LabelSelector{
+				MatchLabels: map[string]string{
+					"kubernetes.io/metadata.name": n,
+				},
+			})
+		}
+
+		for _, ls := range labelSelectors {
+			selector, err := metav1.LabelSelectorAsSelector(ls)
+			if err != nil {
+				return false, fmt.Errorf("failed to convert label selector into selector %v: %w", ls, err)
+			}
+			if selector.Matches(nsLabels) {
+				return true, nil
+			}
+		}
+
+		for _, reg := range condition.NamespaceRegexes {
+			match, err := regexp.MatchString(reg, ns)
+			if err != nil {
+				// Should not happen since store validation already verified the regexes.
+				return false, fmt.Errorf("failed to compile regex %v: %w", reg, err)
+			}
+
+			if match {
+				return true, nil
+			}
+		}
+	}
+
+	return false, nil
+}
+
+// shouldProcessSecret validates if a secret should be processed based on namespace conditions.
+// This is a wrapper around validateNamespaceConditions for backward compatibility with GenericStore.
+func (m *Manager) shouldProcessSecret(store esv1.GenericStore, ns string) (bool, error) {
+	// Only check conditions for cluster-scoped resources.
+	if store.GetKind() != esv1.ClusterSecretStoreKind {
+		return true, nil
+	}
+
+	return m.validateNamespaceConditions(store.GetSpec().Conditions, ns)
+}
+
+// assertStoreIsUsable asserts that the store is ready to use.
+func assertStoreIsUsable(store esv1.GenericStore) error {
+	if store == nil {
+		return nil
+	}
+	condition := GetSecretStoreCondition(store.GetStatus(), esv1.SecretStoreReady)
+	if condition == nil || condition.Status != v1.ConditionTrue {
+		return fmt.Errorf(errSecretStoreNotReady, store.GetKind(), store.GetName())
+	}
+	return nil
+}
+
+// ShouldProcessStore returns true if the store should be processed.
+func ShouldProcessStore(store esv1.GenericStore, class string) bool {
+	if store == nil || store.GetSpec().Controller == "" || store.GetSpec().Controller == class {
+		return true
+	}
+
+	return false
+}
+
+// GetSecretStoreCondition returns the condition with the provided type.
+func GetSecretStoreCondition(status esv1.SecretStoreStatus, condType esv1.SecretStoreConditionType) *esv1.SecretStoreStatusCondition {
+	for i := range status.Conditions {
+		c := status.Conditions[i]
+		if c.Type == condType {
+			return &c
+		}
+	}
+	return nil
+}

+ 924 - 0
runtime/clientmanager/manager_test.go

@@ -0,0 +1,924 @@
+/*
+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 clientmanager
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"math/big"
+	"net"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/go-logr/logr"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"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"
+	"k8s.io/apimachinery/pkg/types"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	pb "github.com/external-secrets/external-secrets/proto/provider"
+)
+
+func TestManagerGet(t *testing.T) {
+	scheme := runtime.NewScheme()
+
+	// add kubernetes schemes
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
+
+	// add external-secrets schemes
+	utilruntime.Must(esv1.AddToScheme(scheme))
+
+	// We have a test provider to control
+	// the behavior of the NewClient func.
+	fakeProvider := &WrapProvider{}
+	esv1.ForceRegister(fakeProvider, &esv1.SecretStoreProvider{
+		AWS: &esv1.AWSProvider{},
+	}, esv1.MaintenanceStatusMaintained)
+
+	// fake clients are re-used to compare the
+	// in-memory reference
+	clientA := &MockFakeClient{id: "1"}
+	clientB := &MockFakeClient{id: "2"}
+
+	const testNamespace = "foo"
+
+	readyStatus := esv1.SecretStoreStatus{
+		Conditions: []esv1.SecretStoreStatusCondition{
+			{
+				Type:   esv1.SecretStoreReady,
+				Status: corev1.ConditionTrue,
+			},
+		},
+	}
+
+	fakeSpec := esv1.SecretStoreSpec{
+		Provider: &esv1.SecretStoreProvider{
+			AWS: &esv1.AWSProvider{},
+		},
+	}
+
+	defaultStore := &esv1.SecretStore{
+		TypeMeta: metav1.TypeMeta{Kind: esv1.SecretStoreKind},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "foo",
+			Namespace: testNamespace,
+		},
+		Spec:   fakeSpec,
+		Status: readyStatus,
+	}
+
+	otherStore := &esv1.SecretStore{
+		TypeMeta: metav1.TypeMeta{Kind: esv1.SecretStoreKind},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "other",
+			Namespace: testNamespace,
+		},
+		Spec:   fakeSpec,
+		Status: readyStatus,
+	}
+
+	var mgr *Manager
+
+	provKey := storeKey(fakeProvider)
+
+	type fields struct {
+		client    client.Client
+		clientMap map[clientKey]*clientVal
+	}
+	type args struct {
+		storeRef  esv1.SecretStoreRef
+		namespace string
+		sourceRef *esv1.StoreGeneratorSourceRef
+	}
+	tests := []struct {
+		name              string
+		fields            fields
+		args              args
+		clientConstructor func(
+			ctx context.Context,
+			store esv1.GenericStore,
+			kube client.Client,
+			namespace string) (esv1.SecretsClient, error)
+		verify     func(esv1.SecretsClient)
+		afterClose func()
+		want       esv1.SecretsClient
+		wantErr    bool
+	}{
+		{
+			name:    "creates a new client from storeRef and stores it",
+			wantErr: false,
+			fields: fields{
+				client: fakeclient.NewClientBuilder().
+					WithScheme(scheme).
+					WithObjects(defaultStore).
+					Build(),
+				clientMap: make(map[clientKey]*clientVal),
+			},
+			args: args{
+				storeRef: esv1.SecretStoreRef{
+					Name: defaultStore.Name,
+					Kind: esv1.SecretStoreKind,
+				},
+				namespace: defaultStore.Namespace,
+				sourceRef: nil,
+			},
+			clientConstructor: func(_ context.Context, _ esv1.GenericStore, _ client.Client, _ string) (esv1.SecretsClient, error) {
+				return clientA, nil
+			},
+			verify: func(sc esv1.SecretsClient) {
+				// we now must have this provider in the clientMap
+				// and it mustbe the client defined in clientConstructor
+				assert.NotNil(t, sc)
+				c, ok := mgr.clientMap[provKey]
+				require.True(t, ok)
+				assert.Same(t, c.client, clientA)
+			},
+
+			afterClose: func() {
+				v, ok := mgr.clientMap[provKey]
+				assert.False(t, ok)
+				assert.Nil(t, v)
+			},
+		},
+		{
+			name:    "creates a new client using both storeRef and sourceRef",
+			wantErr: false,
+			fields: fields{
+				client: fakeclient.NewClientBuilder().
+					WithScheme(scheme).
+					WithObjects(otherStore).
+					Build(),
+				clientMap: make(map[clientKey]*clientVal),
+			},
+			args: args{
+				storeRef: esv1.SecretStoreRef{
+					Name: defaultStore.Name,
+					Kind: esv1.SecretStoreKind,
+				},
+				// this should take precedence
+				sourceRef: &esv1.StoreGeneratorSourceRef{
+					SecretStoreRef: &esv1.SecretStoreRef{
+						Name: otherStore.Name,
+						Kind: esv1.SecretStoreKind,
+					},
+				},
+				namespace: defaultStore.Namespace,
+			},
+			clientConstructor: func(_ context.Context, _ esv1.GenericStore, _ client.Client, _ string) (esv1.SecretsClient, error) {
+				return clientB, nil
+			},
+			verify: func(sc esv1.SecretsClient) {
+				// we now must have this provider in the clientMap
+				// and it mustbe the client defined in clientConstructor
+				assert.NotNil(t, sc)
+				c, ok := mgr.clientMap[provKey]
+				assert.True(t, ok)
+				assert.Same(t, c.client, clientB)
+			},
+
+			afterClose: func() {
+				v, ok := mgr.clientMap[provKey]
+				assert.False(t, ok)
+				assert.True(t, clientB.closeCalled)
+				assert.Nil(t, v)
+			},
+		},
+		{
+			name:    "retrieve cached client when store matches",
+			wantErr: false,
+			fields: fields{
+				client: fakeclient.NewClientBuilder().
+					WithScheme(scheme).
+					WithObjects(defaultStore).
+					Build(),
+				clientMap: map[clientKey]*clientVal{
+					provKey: {
+						client: clientA,
+						store:  defaultStore,
+					},
+				},
+			},
+			args: args{
+				storeRef: esv1.SecretStoreRef{
+					Name: defaultStore.Name,
+					Kind: esv1.SecretStoreKind,
+				},
+				namespace: defaultStore.Namespace,
+				sourceRef: nil,
+			},
+			clientConstructor: func(_ context.Context, _ esv1.GenericStore, _ client.Client, _ string) (esv1.SecretsClient, error) {
+				// constructor should not be called,
+				// the client from the cache should be returned instead
+				t.Fail()
+				return nil, nil
+			},
+			verify: func(sc esv1.SecretsClient) {
+				// verify that the secretsClient is the one from cache
+				assert.NotNil(t, sc)
+				c, ok := mgr.clientMap[provKey]
+				assert.True(t, ok)
+				assert.Same(t, c.client, clientA)
+				assert.Same(t, sc, clientA)
+			},
+
+			afterClose: func() {
+				v, ok := mgr.clientMap[provKey]
+				assert.False(t, ok)
+				assert.True(t, clientA.closeCalled)
+				assert.Nil(t, v)
+			},
+		},
+		{
+			name:    "create new client when store doesn't match",
+			wantErr: false,
+			fields: fields{
+				client: fakeclient.NewClientBuilder().
+					WithScheme(scheme).
+					WithObjects(otherStore).
+					Build(),
+				clientMap: map[clientKey]*clientVal{
+					provKey: {
+						// we have clientA in cache pointing at defaultStore
+						client: clientA,
+						store:  defaultStore,
+					},
+				},
+			},
+			args: args{
+				storeRef: esv1.SecretStoreRef{
+					Name: otherStore.Name,
+					Kind: esv1.SecretStoreKind,
+				},
+				namespace: otherStore.Namespace,
+				sourceRef: nil,
+			},
+			clientConstructor: func(_ context.Context, _ esv1.GenericStore, _ client.Client, _ string) (esv1.SecretsClient, error) {
+				// because there is a store mismatch
+				// we create a new client
+				return clientB, nil
+			},
+			verify: func(sc esv1.SecretsClient) {
+				// verify that SecretsClient is NOT the one from cache
+				assert.NotNil(t, sc)
+				c, ok := mgr.clientMap[provKey]
+				assert.True(t, ok)
+				assert.Same(t, c.client, clientB)
+				assert.Same(t, sc, clientB)
+				assert.True(t, clientA.closeCalled)
+			},
+			afterClose: func() {
+				v, ok := mgr.clientMap[provKey]
+				assert.False(t, ok)
+				assert.True(t, clientB.closeCalled)
+				assert.Nil(t, v)
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			mgr = &Manager{
+				log:             logr.Discard(),
+				client:          tt.fields.client,
+				enableFloodgate: true,
+				clientMap:       tt.fields.clientMap,
+			}
+			fakeProvider.newClientFunc = tt.clientConstructor
+			clientA.closeCalled = false
+			clientB.closeCalled = false
+			got, err := mgr.Get(context.Background(), tt.args.storeRef, tt.args.namespace, tt.args.sourceRef)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("Manager.Get() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			tt.verify(got)
+			mgr.Close(context.Background())
+			tt.afterClose()
+		})
+	}
+}
+
+func TestShouldProcessSecret(t *testing.T) {
+	scheme := runtime.NewScheme()
+
+	// add kubernetes schemes
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
+
+	// add external-secrets schemes
+	utilruntime.Must(esv1.AddToScheme(scheme))
+
+	testNamespace := "test-a"
+	testCases := []struct {
+		name       string
+		conditions []esv1.ClusterSecretStoreCondition
+		namespace  *corev1.Namespace
+		wantErr    string
+		want       bool
+	}{
+		{
+			name: "processes a regex condition",
+			conditions: []esv1.ClusterSecretStoreCondition{
+				{
+					NamespaceRegexes: []string{`test-*`},
+				},
+			},
+			namespace: &corev1.Namespace{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: testNamespace,
+				},
+			},
+			want: true,
+		},
+		{
+			name: "process multiple regexes",
+			conditions: []esv1.ClusterSecretStoreCondition{
+				{
+					NamespaceRegexes: []string{`nope`, `test-*`},
+				},
+			},
+			namespace: &corev1.Namespace{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: testNamespace,
+				},
+			},
+			want: true,
+		},
+		{
+			name: "shouldn't process if nothing matches",
+			conditions: []esv1.ClusterSecretStoreCondition{
+				{
+					NamespaceRegexes: []string{`nope`},
+				},
+			},
+			namespace: &corev1.Namespace{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: testNamespace,
+				},
+			},
+			want: false,
+		},
+	}
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			fakeSpec := esv1.SecretStoreSpec{
+				Conditions: tt.conditions,
+			}
+
+			defaultStore := &esv1.ClusterSecretStore{
+				TypeMeta: metav1.TypeMeta{Kind: esv1.ClusterSecretStoreKind},
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "foo",
+					Namespace: tt.namespace.Name,
+				},
+				Spec: fakeSpec,
+			}
+
+			client := fakeclient.NewClientBuilder().WithScheme(scheme).WithObjects(defaultStore, tt.namespace).Build()
+			clientMap := make(map[clientKey]*clientVal)
+			mgr := &Manager{
+				log:             logr.Discard(),
+				client:          client,
+				enableFloodgate: true,
+				clientMap:       clientMap,
+			}
+
+			got, err := mgr.shouldProcessSecret(defaultStore, tt.namespace.Name)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
+
+func TestBuildCompatibilityStoreSerializesStore(t *testing.T) {
+	store := &esv1.SecretStore{
+		TypeMeta: metav1.TypeMeta{Kind: esv1.SecretStoreKind},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:       "aws-prod",
+			Namespace:  "team-a",
+			UID:        types.UID("uid-1"),
+			Generation: 7,
+		},
+		Spec: esv1.SecretStoreSpec{
+			RuntimeRef: &esv1.StoreRuntimeRef{Kind: "ClusterProviderClass", Name: "aws"},
+			Provider: &esv1.SecretStoreProvider{
+				Fake: &esv1.FakeProvider{Data: []esv1.FakeProviderData{{Key: "db", Value: "s3cr3t"}}},
+			},
+		},
+	}
+
+	out, err := buildCompatibilityStore(store)
+	if err != nil {
+		t.Fatalf("buildCompatibilityStore() error = %v", err)
+	}
+	if out.StoreName != "aws-prod" || out.StoreNamespace != "team-a" || out.StoreKind != esv1.SecretStoreKind {
+		t.Fatalf("unexpected compatibility store metadata: %#v", out)
+	}
+	if out.StoreGeneration != 7 || out.StoreUid != "uid-1" {
+		t.Fatalf("unexpected compatibility store identity: %#v", out)
+	}
+	if !strings.Contains(string(out.StoreSpecJson), "\"runtimeRef\"") || !strings.Contains(string(out.StoreSpecJson), "\"fake\"") {
+		t.Fatalf("expected serialized store spec, got %s", string(out.StoreSpecJson))
+	}
+}
+
+func TestGetFromStoreReturnsErrorWhenRuntimeClassMissing(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+	utilruntime.Must(esv1alpha1.AddToScheme(scheme))
+
+	store := &esv1.SecretStore{
+		TypeMeta: metav1.TypeMeta{Kind: esv1.SecretStoreKind},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:       "aws-prod",
+			Namespace:  "team-a",
+			UID:        types.UID("uid-1"),
+			Generation: 7,
+		},
+		Spec: esv1.SecretStoreSpec{
+			RuntimeRef: &esv1.StoreRuntimeRef{Kind: "ClusterProviderClass", Name: "aws"},
+			Provider: &esv1.SecretStoreProvider{
+				Fake: &esv1.FakeProvider{Data: []esv1.FakeProviderData{{Key: "db", Value: "s3cr3t"}}},
+			},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(store).
+		Build()
+
+	mgr := NewManager(kubeClient, "", false)
+
+	_, err := mgr.GetFromStore(context.Background(), store, "team-a")
+	if err == nil || !strings.Contains(err.Error(), "ClusterProviderClass") {
+		t.Fatalf("expected missing ClusterProviderClass error, got %v", err)
+	}
+}
+
+func TestGetFromStoreWithRuntimeRefReturnsClientThatValidates(t *testing.T) {
+	resetGlobalV2ConnectionPoolForTest(t)
+
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+	utilruntime.Must(esv1alpha1.AddToScheme(scheme))
+
+	server, address, tlsSecret := newRecordingProviderServer(t)
+
+	store := &esv1.SecretStore{
+		TypeMeta: metav1.TypeMeta{Kind: esv1.SecretStoreKind},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:       "aws-prod",
+			Namespace:  "team-a",
+			UID:        types.UID("uid-1"),
+			Generation: 7,
+		},
+		Spec: esv1.SecretStoreSpec{
+			RuntimeRef: &esv1.StoreRuntimeRef{Kind: "ClusterProviderClass", Name: "aws-runtime"},
+			Provider: &esv1.SecretStoreProvider{
+				Fake: &esv1.FakeProvider{Data: []esv1.FakeProviderData{{Key: "db", Value: "s3cr3t"}}},
+			},
+		},
+	}
+
+	runtimeClass := &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "aws-runtime",
+		},
+		Spec: esv1alpha1.ClusterProviderClassSpec{
+			Address: address,
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			store,
+			runtimeClass,
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: "",
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	mgr := NewManager(kubeClient, "", false)
+
+	client, err := mgr.GetFromStore(context.Background(), store, "team-a")
+	require.NoError(t, err)
+
+	result, err := client.Validate()
+	require.NoError(t, err)
+	assert.Equal(t, esv1.ValidationResultReady, result)
+	assert.Equal(t, 1, server.ValidateCallCount())
+	require.Len(t, mgr.v2PooledConnections, 0)
+}
+
+func TestGetFromStoreWithRuntimeRefReusesCachedClient(t *testing.T) {
+	resetGlobalV2ConnectionPoolForTest(t)
+
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+	utilruntime.Must(esv1alpha1.AddToScheme(scheme))
+
+	server, address, tlsSecret := newRecordingProviderServer(t)
+
+	store := &esv1.SecretStore{
+		TypeMeta: metav1.TypeMeta{Kind: esv1.SecretStoreKind},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:       "aws-prod",
+			Namespace:  "team-a",
+			UID:        types.UID("uid-1"),
+			Generation: 7,
+		},
+		Spec: esv1.SecretStoreSpec{
+			RuntimeRef: &esv1.StoreRuntimeRef{Kind: "ClusterProviderClass", Name: "aws-runtime"},
+			Provider: &esv1.SecretStoreProvider{
+				Fake: &esv1.FakeProvider{Data: []esv1.FakeProviderData{{Key: "db", Value: "s3cr3t"}}},
+			},
+		},
+	}
+
+	runtimeClass := &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{Name: "aws-runtime"},
+		Spec:       esv1alpha1.ClusterProviderClassSpec{Address: address},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			store,
+			runtimeClass,
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: "",
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	mgr := NewManager(kubeClient, "", false)
+
+	firstClient, err := mgr.GetFromStore(context.Background(), store, "team-a")
+	require.NoError(t, err)
+
+	secondClient, err := mgr.GetFromStore(context.Background(), store, "team-a")
+	require.NoError(t, err)
+
+	assert.Same(t, firstClient, secondClient)
+	require.Len(t, mgr.v2PooledConnections, 0)
+
+	result, err := firstClient.Validate()
+	require.NoError(t, err)
+	assert.Equal(t, esv1.ValidationResultReady, result)
+	assert.Equal(t, 1, server.ValidateCallCount())
+}
+
+func TestGetFromStoreWithRuntimeRefDoesNotReuseClientAcrossSourceNamespaces(t *testing.T) {
+	resetGlobalV2ConnectionPoolForTest(t)
+
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+	utilruntime.Must(esv1alpha1.AddToScheme(scheme))
+
+	server, address, tlsSecret := newRecordingProviderServer(t)
+
+	store := &esv1.ClusterSecretStore{
+		TypeMeta: metav1.TypeMeta{Kind: esv1.ClusterSecretStoreKind},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:       "aws-prod",
+			UID:        types.UID("uid-1"),
+			Generation: 7,
+		},
+		Spec: esv1.SecretStoreSpec{
+			RuntimeRef: &esv1.StoreRuntimeRef{Kind: "ClusterProviderClass", Name: "aws-runtime"},
+			Provider: &esv1.SecretStoreProvider{
+				Fake: &esv1.FakeProvider{Data: []esv1.FakeProviderData{{Key: "db", Value: "s3cr3t"}}},
+			},
+		},
+	}
+
+	runtimeClass := &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{Name: "aws-runtime"},
+		Spec:       esv1alpha1.ClusterProviderClassSpec{Address: address},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			store,
+			runtimeClass,
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: "",
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	mgr := NewManager(kubeClient, "", false)
+
+	firstClient, err := mgr.GetFromStore(context.Background(), store, "team-a")
+	require.NoError(t, err)
+
+	secondClient, err := mgr.GetFromStore(context.Background(), store, "team-b")
+	require.NoError(t, err)
+
+	assert.NotSame(t, firstClient, secondClient)
+
+	result, err := firstClient.Validate()
+	require.NoError(t, err)
+	assert.Equal(t, esv1.ValidationResultReady, result)
+
+	result, err = secondClient.Validate()
+	require.NoError(t, err)
+	assert.Equal(t, esv1.ValidationResultReady, result)
+
+	assert.Equal(t, []string{"team-a", "team-b"}, server.ValidateSourceNamespaces())
+}
+
+type WrapProvider struct {
+	newClientFunc func(
+		context.Context,
+		esv1.GenericStore,
+		client.Client,
+		string) (esv1.SecretsClient, error)
+}
+
+// NewClient constructs a SecretsManager Provider.
+func (f *WrapProvider) NewClient(
+	ctx context.Context,
+	store esv1.GenericStore,
+	kube client.Client,
+	namespace string) (esv1.SecretsClient, error) {
+	return f.newClientFunc(ctx, store, kube, namespace)
+}
+
+func (f *WrapProvider) Capabilities() esv1.SecretStoreCapabilities {
+	return esv1.SecretStoreReadOnly
+}
+
+// ValidateStore checks if the provided store is valid.
+func (f *WrapProvider) ValidateStore(_ esv1.GenericStore) (admission.Warnings, error) {
+	return nil, nil
+}
+
+type MockFakeClient struct {
+	id          string
+	closeCalled bool
+}
+
+func (c *MockFakeClient) PushSecret(_ context.Context, _ *corev1.Secret, _ esv1.PushSecretData) error {
+	return nil
+}
+
+func (c *MockFakeClient) DeleteSecret(_ context.Context, _ esv1.PushSecretRemoteRef) error {
+	return nil
+}
+
+func (c *MockFakeClient) SecretExists(_ context.Context, _ esv1.PushSecretRemoteRef) (bool, error) {
+	return false, nil
+}
+
+func (c *MockFakeClient) GetSecret(_ context.Context, _ esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
+	return nil, nil
+}
+
+func (c *MockFakeClient) Validate() (esv1.ValidationResult, error) {
+	return esv1.ValidationResultReady, nil
+}
+
+// GetSecretMap returns multiple k/v pairs from the provider.
+func (c *MockFakeClient) GetSecretMap(_ context.Context, _ esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
+	return nil, nil
+}
+
+// GetAllSecrets returns multiple k/v pairs from the provider.
+func (c *MockFakeClient) GetAllSecrets(_ context.Context, _ esv1.ExternalSecretFind) (map[string][]byte, error) {
+	return nil, nil
+}
+
+func (c *MockFakeClient) Close(_ context.Context) error {
+	c.closeCalled = true
+	return nil
+}
+
+func newManagerTestScheme(t *testing.T) *runtime.Scheme {
+	t.Helper()
+
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+	return scheme
+}
+
+func resetGlobalV2ConnectionPoolForTest(t *testing.T) {
+	t.Helper()
+
+	if globalV2ConnectionPool != nil {
+		_ = globalV2ConnectionPool.Close()
+	}
+	globalV2ConnectionPool = nil
+	globalV2ConnectionPoolOnce = sync.Once{}
+	t.Cleanup(func() {
+		if globalV2ConnectionPool != nil {
+			_ = globalV2ConnectionPool.Close()
+		}
+		globalV2ConnectionPool = nil
+		globalV2ConnectionPoolOnce = sync.Once{}
+	})
+}
+
+type recordingProviderServer struct {
+	pb.UnimplementedSecretStoreProviderServer
+
+	mu               sync.Mutex
+	validateRequests []*pb.ValidateRequest
+}
+
+func newRecordingProviderServer(t *testing.T) (*recordingProviderServer, string, map[string][]byte) {
+	t.Helper()
+
+	serverCert, serverKey, clientCert, clientKey, caCert := newMutualTLSArtifacts(t, "127.0.0.1")
+
+	caPool := x509.NewCertPool()
+	require.True(t, caPool.AppendCertsFromPEM(caCert))
+
+	tlsCert, err := tls.X509KeyPair(serverCert, serverKey)
+	require.NoError(t, err)
+
+	lis, err := net.Listen("tcp", "127.0.0.1:0")
+	require.NoError(t, err)
+
+	recorder := &recordingProviderServer{}
+	grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{
+		MinVersion:   tls.VersionTLS12,
+		Certificates: []tls.Certificate{tlsCert},
+		ClientCAs:    caPool,
+		ClientAuth:   tls.RequireAndVerifyClientCert,
+	})))
+	pb.RegisterSecretStoreProviderServer(grpcServer, recorder)
+
+	go func() {
+		_ = grpcServer.Serve(lis)
+	}()
+
+	t.Cleanup(func() {
+		grpcServer.Stop()
+		_ = lis.Close()
+	})
+
+	return recorder, lis.Addr().String(), map[string][]byte{
+		"ca.crt":     caCert,
+		"client.crt": clientCert,
+		"client.key": clientKey,
+	}
+}
+
+func (s *recordingProviderServer) Validate(_ context.Context, req *pb.ValidateRequest) (*pb.ValidateResponse, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	s.validateRequests = append(s.validateRequests, req)
+	return &pb.ValidateResponse{Valid: true}, nil
+}
+
+func (s *recordingProviderServer) LastValidateRequest() *pb.ValidateRequest {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if len(s.validateRequests) == 0 {
+		return nil
+	}
+	return s.validateRequests[len(s.validateRequests)-1]
+}
+
+func (s *recordingProviderServer) ValidateCallCount() int {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	return len(s.validateRequests)
+}
+
+func (s *recordingProviderServer) ValidateSourceNamespaces() []string {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	out := make([]string, 0, len(s.validateRequests))
+	for _, req := range s.validateRequests {
+		out = append(out, req.GetSourceNamespace())
+	}
+
+	return out
+}
+
+func newMutualTLSArtifacts(t *testing.T, host string) (serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM []byte) {
+	t.Helper()
+
+	caKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	require.NoError(t, err)
+
+	caTemplate := &x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject: pkix.Name{
+			CommonName: "clientmanager-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)
+	require.NoError(t, err)
+	caCert, err := x509.ParseCertificate(caDER)
+	require.NoError(t, err)
+
+	serverCertPEM, serverKeyPEM = newSignedCertificateForTest(t, caCert, caKey, 2, host, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
+	clientCertPEM, clientKeyPEM = newSignedCertificateForTest(t, caCert, caKey, 3, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
+	caCertPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
+
+	return serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM
+}
+
+func newSignedCertificateForTest(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)
+	require.NoError(t, 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)
+	require.NoError(t, err)
+
+	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
+	return certPEM, keyPEM
+}

+ 123 - 0
runtime/clientmanager/metrics.go

@@ -0,0 +1,123 @@
+/*
+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 clientmanager
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"sigs.k8s.io/controller-runtime/pkg/metrics"
+)
+
+var (
+	// ClientManager gauges.
+	clientsCachedTotal = prometheus.NewGaugeVec(
+		prometheus.GaugeOpts{
+			Name: "clientmanager_clients_cached_total",
+			Help: "Total number of cached provider clients",
+		},
+		[]string{"provider_type"},
+	)
+
+	// ClientManager counters.
+	cacheHitsTotal = prometheus.NewCounterVec(
+		prometheus.CounterOpts{
+			Name: "clientmanager_cache_hits_total",
+			Help: "Total number of client cache hits",
+		},
+		[]string{"provider_type"},
+	)
+
+	cacheInvalidationsTotal = prometheus.NewCounterVec(
+		prometheus.CounterOpts{
+			Name: "clientmanager_cache_invalidations_total",
+			Help: "Total number of client cache invalidations",
+		},
+		[]string{"provider_type", "reason"},
+	)
+)
+
+// Metrics provides test hooks for client manager metrics.
+type Metrics interface {
+	RecordCacheHit(providerType string)
+	RecordCacheMiss(providerType string)
+	RecordCacheInvalidation(providerType, reason string)
+	UpdateCachedClients(providerType string, count int)
+}
+
+// defaultMetrics implements Metrics using Prometheus.
+type defaultMetrics struct{}
+
+// RecordCacheHit records a cache hit.
+func (m *defaultMetrics) RecordCacheHit(providerType string) {
+	cacheHitsTotal.WithLabelValues(providerType).Inc()
+}
+
+// RecordCacheMiss records a cache miss.
+func (m *defaultMetrics) RecordCacheMiss(_ string) {
+	// Cache misses are implicit - we don't track them separately
+	// The absence of a hit implies a miss
+}
+
+// RecordCacheInvalidation records a cache invalidation.
+func (m *defaultMetrics) RecordCacheInvalidation(providerType, reason string) {
+	cacheInvalidationsTotal.WithLabelValues(providerType, reason).Inc()
+}
+
+// UpdateCachedClients updates the total cached clients gauge.
+func (m *defaultMetrics) UpdateCachedClients(providerType string, count int) {
+	clientsCachedTotal.WithLabelValues(providerType).Set(float64(count))
+}
+
+// Global instance.
+var clientManagerMetrics Metrics = &defaultMetrics{}
+
+// RegisterMetrics registers all client manager metrics with the controller-runtime metrics registry.
+func RegisterMetrics() error {
+	collectors := []prometheus.Collector{
+		clientsCachedTotal,
+		cacheHitsTotal,
+		cacheInvalidationsTotal,
+	}
+
+	for _, collector := range collectors {
+		if err := metrics.Registry.Register(collector); err != nil {
+			var alreadyRegistered prometheus.AlreadyRegisteredError
+			if errors.As(err, &alreadyRegistered) {
+				continue
+			}
+			return fmt.Errorf("failed to register clientmanager metric: %w", err)
+		}
+	}
+
+	// Initialize metrics with zero values so they appear in /metrics output
+	// This ensures metrics are visible even before any cache operations occur
+	for _, providerType := range []string{providerMetricsLabel, clusterProviderMetricsLabel} {
+		clientsCachedTotal.WithLabelValues(providerType).Set(0)
+		cacheHitsTotal.WithLabelValues(providerType).Add(0)
+		cacheInvalidationsTotal.WithLabelValues(providerType, cacheInvalidationGeneration).Add(0)
+		cacheInvalidationsTotal.WithLabelValues(providerType, cacheInvalidationMismatch).Add(0)
+	}
+
+	return nil
+}
+
+// GetClientManagerMetrics returns the client manager metrics instance for tests.
+func GetClientManagerMetrics() Metrics {
+	return clientManagerMetrics
+}

+ 199 - 0
runtime/clientmanager/providerstore.go

@@ -0,0 +1,199 @@
+/*
+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 clientmanager
+
+import (
+	"context"
+	"fmt"
+
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	esv1 "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"
+	pb "github.com/external-secrets/external-secrets/proto/provider"
+	adapterstore "github.com/external-secrets/external-secrets/providers/v2/adapter/store"
+	"github.com/external-secrets/external-secrets/providers/v2/common/grpc"
+)
+
+const runtimeRefKindClusterProviderClass = "ClusterProviderClass"
+
+func (m *Manager) getV2ProviderStoreClient(ctx context.Context, storeName, callerNamespace string) (esv1.SecretsClient, error) {
+	var store esv2alpha1.ProviderStore
+	storeKey := types.NamespacedName{
+		Name:      storeName,
+		Namespace: callerNamespace,
+	}
+	if err := m.client.Get(ctx, storeKey, &store); err != nil {
+		return nil, fmt.Errorf("failed to get ProviderStore %q: %w", storeName, err)
+	}
+
+	if m.enableFloodgate {
+		if err := assertV2StoreIsUsable(&store); err != nil {
+			return nil, err
+		}
+	}
+
+	return m.getOrCreateProviderStoreClient(ctx, &store, callerNamespace, callerNamespace)
+}
+
+func (m *Manager) getV2ClusterProviderStoreClient(ctx context.Context, storeName, callerNamespace string) (esv1.SecretsClient, error) {
+	var store esv2alpha1.ClusterProviderStore
+	storeKey := types.NamespacedName{
+		Name: storeName,
+	}
+	if err := m.client.Get(ctx, storeKey, &store); err != nil {
+		return nil, fmt.Errorf("failed to get ClusterProviderStore %q: %w", storeName, err)
+	}
+
+	shouldProcess, err := m.validateProviderStoreNamespaceConditions(store.Spec.Conditions, callerNamespace)
+	if err != nil {
+		return nil, err
+	}
+	if !shouldProcess {
+		return nil, fmt.Errorf(errClusterProviderStoreDenied, storeName, callerNamespace)
+	}
+
+	if m.enableFloodgate {
+		if err := assertV2StoreIsUsable(&store); err != nil {
+			return nil, err
+		}
+	}
+
+	effectiveBackendNamespace := store.Spec.BackendRef.Namespace
+	if effectiveBackendNamespace == "" {
+		effectiveBackendNamespace = callerNamespace
+	}
+
+	return m.getOrCreateProviderStoreClient(ctx, &store, callerNamespace, effectiveBackendNamespace)
+}
+
+func (m *Manager) getOrCreateProviderStoreClient(ctx context.Context, store esv2alpha1.GenericStore, callerNamespace, effectiveBackendNamespace string) (esv1.SecretsClient, error) {
+	cacheKeyType := v2ProviderStoreCacheKey
+	isClusterScoped := false
+	if store.GetKind() == esv1.ClusterProviderStoreKindStr {
+		cacheKeyType = v2ClusterProviderStoreCache
+		isClusterScoped = true
+	}
+
+	cacheKey := clientKey{
+		providerType:        cacheKeyType,
+		v2ProviderName:      store.GetName(),
+		v2ProviderNamespace: callerNamespace,
+	}
+
+	if cached, ok := m.clientMap[cacheKey]; ok {
+		if cached.v2ProviderGeneration == store.GetGeneration() {
+			clientManagerMetrics.RecordCacheHit(providerMetricsLabelForScope(isClusterScoped))
+			return cached.client, nil
+		}
+		clientManagerMetrics.RecordCacheInvalidation(providerMetricsLabelForScope(isClusterScoped), cacheInvalidationGeneration)
+		delete(m.clientMap, cacheKey)
+	}
+
+	runtimeRef := store.GetRuntimeRef()
+	runtimeKind := runtimeRef.Kind
+	if runtimeKind == "" {
+		runtimeKind = runtimeRefKindClusterProviderClass
+	}
+	if runtimeKind != runtimeRefKindClusterProviderClass {
+		return nil, fmt.Errorf("unsupported runtimeRef kind %q", runtimeKind)
+	}
+
+	var runtimeClass esv1alpha1.ClusterProviderClass
+	if err := m.client.Get(ctx, types.NamespacedName{Name: runtimeRef.Name}, &runtimeClass); err != nil {
+		return nil, fmt.Errorf("failed to get %s %q: %w", runtimeRefKindClusterProviderClass, runtimeRef.Name, err)
+	}
+
+	if runtimeClass.Spec.Address == "" {
+		return nil, fmt.Errorf("provider address is required in %s %q", runtimeRefKindClusterProviderClass, runtimeRef.Name)
+	}
+
+	tlsSecretNamespace := grpc.ResolveTLSSecretNamespace(runtimeClass.Spec.Address, "", "", effectiveBackendNamespace)
+	tlsConfig, err := grpc.LoadClientTLSConfig(ctx, m.client, runtimeClass.Spec.Address, tlsSecretNamespace)
+	if err != nil {
+		return nil, fmt.Errorf("failed to load TLS config for %s %q: %w", runtimeRefKindClusterProviderClass, runtimeRef.Name, err)
+	}
+
+	pool := getGlobalV2ConnectionPool()
+	grpcClient, err := pool.Get(ctx, runtimeClass.Spec.Address, tlsConfig)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get gRPC client from pool for %s %q: %w", runtimeRefKindClusterProviderClass, runtimeRef.Name, err)
+	}
+
+	m.v2PooledConnections = append(m.v2PooledConnections, v2PooledConnection{
+		address:   runtimeClass.Spec.Address,
+		tlsConfig: tlsConfig,
+	})
+
+	backendRef := store.GetBackendRef()
+	providerRef := &pb.ProviderReference{
+		ApiVersion:   backendRef.APIVersion,
+		Kind:         backendRef.Kind,
+		Name:         backendRef.Name,
+		Namespace:    effectiveBackendNamespace,
+		StoreRefKind: store.GetKind(),
+	}
+
+	wrappedClient := adapterstore.NewClient(grpcClient, providerRef, callerNamespace)
+	m.clientMap[cacheKey] = &clientVal{
+		client:               wrappedClient,
+		v2ProviderGeneration: store.GetGeneration(),
+	}
+
+	return wrappedClient, nil
+}
+
+func (m *Manager) validateProviderStoreNamespaceConditions(conditions []esv2alpha1.StoreNamespaceCondition, ns string) (bool, error) {
+	if len(conditions) == 0 {
+		return true, nil
+	}
+
+	translated := make([]esv1.ClusterSecretStoreCondition, 0, len(conditions))
+	for _, condition := range conditions {
+		translated = append(translated, esv1.ClusterSecretStoreCondition{
+			NamespaceSelector: condition.NamespaceSelector,
+			Namespaces:        append([]string(nil), condition.Namespaces...),
+			NamespaceRegexes:  append([]string(nil), condition.NamespaceRegexes...),
+		})
+	}
+
+	return m.validateNamespaceConditions(translated, ns)
+}
+
+func assertV2StoreIsUsable(store esv2alpha1.GenericStore) error {
+	if store == nil {
+		return nil
+	}
+
+	condition := getProviderStoreCondition(store.GetStoreStatus(), esv2alpha1.ProviderStoreReady)
+	if condition == nil || condition.Status != corev1.ConditionTrue {
+		return fmt.Errorf(errSecretStoreNotReady, store.GetKind(), store.GetName())
+	}
+
+	return nil
+}
+
+func getProviderStoreCondition(status esv2alpha1.ProviderStoreStatus, condType esv2alpha1.ProviderStoreConditionType) *esv2alpha1.ProviderStoreCondition {
+	for i := range status.Conditions {
+		if status.Conditions[i].Type == condType {
+			return &status.Conditions[i]
+		}
+	}
+	return nil
+}

+ 237 - 0
runtime/clientmanager/providerstore_test.go

@@ -0,0 +1,237 @@
+/*
+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 clientmanager
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	esv1 "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"
+)
+
+func TestManagerGetProviderStoreUsesStoreNamespaceForBackendRef(t *testing.T) {
+	resetGlobalV2ConnectionPoolForTest(t)
+
+	const callerNamespace = "tenant-a"
+
+	server, address, tlsSecret := newRecordingProviderServer(t)
+	store := &esv2alpha1.ProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "aws-prod",
+			Namespace: callerNamespace,
+		},
+		Spec: esv2alpha1.ProviderStoreSpec{
+			RuntimeRef: esv2alpha1.StoreRuntimeRef{Name: "aws"},
+			BackendRef: esv2alpha1.BackendObjectReference{
+				APIVersion: "aws.external-secrets.io/v1alpha1",
+				Kind:       "SecretsManagerStore",
+				Name:       "prod",
+			},
+		},
+		Status: readyProviderStoreStatus(),
+	}
+	runtimeClass := &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{Name: "aws"},
+		Spec:       esv1alpha1.ClusterProviderClassSpec{Address: address},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(providerStoreTestScheme(t)).
+		WithObjects(
+			store,
+			runtimeClass,
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: callerNamespace,
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	mgr := NewManager(kubeClient, "default", true)
+	defer func() {
+		_ = mgr.Close(context.Background())
+	}()
+
+	client, err := mgr.Get(context.Background(), esv1.SecretStoreRef{
+		Name: store.Name,
+		Kind: esv1.ProviderStoreKindStr,
+	}, callerNamespace, nil)
+	require.NoError(t, err)
+
+	result, err := client.Validate()
+	require.NoError(t, err)
+	assert.Equal(t, esv1.ValidationResultReady, result)
+
+	req := server.LastValidateRequest()
+	require.NotNil(t, req)
+	require.NotNil(t, req.ProviderRef)
+	assert.Equal(t, callerNamespace, req.ProviderRef.Namespace)
+	assert.Equal(t, esv1.ProviderStoreKindStr, req.ProviderRef.StoreRefKind)
+	assert.Equal(t, callerNamespace, req.SourceNamespace)
+}
+
+func TestManagerGetClusterProviderStoreDefaultsBackendNamespaceToCallerNamespace(t *testing.T) {
+	resetGlobalV2ConnectionPoolForTest(t)
+
+	const callerNamespace = "tenant-a"
+
+	server, address, tlsSecret := newRecordingProviderServer(t)
+	store := &esv2alpha1.ClusterProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "aws-shared",
+		},
+		Spec: esv2alpha1.ClusterProviderStoreSpec{
+			RuntimeRef: esv2alpha1.StoreRuntimeRef{Name: "aws"},
+			BackendRef: esv2alpha1.BackendObjectReference{
+				APIVersion: "aws.external-secrets.io/v1alpha1",
+				Kind:       "SecretsManagerStore",
+				Name:       "shared",
+			},
+		},
+		Status: readyProviderStoreStatus(),
+	}
+	runtimeClass := &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{Name: "aws"},
+		Spec:       esv1alpha1.ClusterProviderClassSpec{Address: address},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(providerStoreTestScheme(t)).
+		WithObjects(
+			store,
+			runtimeClass,
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: callerNamespace,
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	mgr := NewManager(kubeClient, "default", true)
+	defer func() {
+		_ = mgr.Close(context.Background())
+	}()
+
+	client, err := mgr.Get(context.Background(), esv1.SecretStoreRef{
+		Name: store.Name,
+		Kind: esv1.ClusterProviderStoreKindStr,
+	}, callerNamespace, nil)
+	require.NoError(t, err)
+
+	result, err := client.Validate()
+	require.NoError(t, err)
+	assert.Equal(t, esv1.ValidationResultReady, result)
+
+	req := server.LastValidateRequest()
+	require.NotNil(t, req)
+	require.NotNil(t, req.ProviderRef)
+	assert.Equal(t, callerNamespace, req.ProviderRef.Namespace)
+	assert.Equal(t, esv1.ClusterProviderStoreKindStr, req.ProviderRef.StoreRefKind)
+	assert.Equal(t, callerNamespace, req.SourceNamespace)
+}
+
+func TestManagerGetProviderStoreRejectsNotReadyStoreWhenFloodgateEnabled(t *testing.T) {
+	resetGlobalV2ConnectionPoolForTest(t)
+
+	const callerNamespace = "tenant-a"
+
+	_, address, tlsSecret := newRecordingProviderServer(t)
+
+	store := &esv2alpha1.ProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "aws-prod",
+			Namespace: callerNamespace,
+		},
+		Spec: esv2alpha1.ProviderStoreSpec{
+			RuntimeRef: esv2alpha1.StoreRuntimeRef{Name: "aws"},
+			BackendRef: esv2alpha1.BackendObjectReference{
+				APIVersion: "aws.external-secrets.io/v1alpha1",
+				Kind:       "SecretsManagerStore",
+				Name:       "prod",
+			},
+		},
+		Status: esv2alpha1.ProviderStoreStatus{},
+	}
+	runtimeClass := &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{Name: "aws"},
+		Spec:       esv1alpha1.ClusterProviderClassSpec{Address: address},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(providerStoreTestScheme(t)).
+		WithObjects(
+			store,
+			runtimeClass,
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: callerNamespace,
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	mgr := NewManager(kubeClient, "default", true)
+	defer func() {
+		_ = mgr.Close(context.Background())
+	}()
+
+	_, err := mgr.Get(context.Background(), esv1.SecretStoreRef{
+		Name: store.Name,
+		Kind: esv1.ProviderStoreKindStr,
+	}, callerNamespace, nil)
+	require.Error(t, err)
+	assert.ErrorContains(t, err, "ProviderStore")
+	assert.ErrorContains(t, err, "is not ready")
+}
+
+func providerStoreTestScheme(t *testing.T) *runtime.Scheme {
+	t.Helper()
+
+	scheme := newManagerTestScheme(t)
+	utilruntime.Must(esv1alpha1.AddToScheme(scheme))
+	utilruntime.Must(esv2alpha1.AddToScheme(scheme))
+	return scheme
+}
+
+func readyProviderStoreStatus() esv2alpha1.ProviderStoreStatus {
+	return esv2alpha1.ProviderStoreStatus{
+		Conditions: []esv2alpha1.ProviderStoreCondition{
+			{
+				Type:   esv2alpha1.ProviderStoreReady,
+				Status: corev1.ConditionTrue,
+			},
+		},
+	}
+}

+ 40 - 0
runtime/clientmanager/runtime_ref.go

@@ -0,0 +1,40 @@
+/*
+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 clientmanager
+
+import (
+	"encoding/json"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	pb "github.com/external-secrets/external-secrets/proto/provider"
+)
+
+func buildCompatibilityStore(store esv1.GenericStore) (*pb.CompatibilityStore, error) {
+	specJSON, err := json.Marshal(store.GetSpec())
+	if err != nil {
+		return nil, err
+	}
+
+	return &pb.CompatibilityStore{
+		StoreName:       store.GetName(),
+		StoreNamespace:  store.GetNamespace(),
+		StoreKind:       store.GetKind(),
+		StoreUid:        string(store.GetUID()),
+		StoreGeneration: store.GetGeneration(),
+		StoreSpecJson:   specJSON,
+	}, nil
+}

+ 46 - 36
runtime/go.mod

@@ -8,6 +8,9 @@ require (
 	github.com/Masterminds/semver/v3 v3.4.0
 	github.com/aws/aws-sdk-go-v2 v1.39.3
 	github.com/external-secrets/external-secrets/apis v0.0.0
+	github.com/external-secrets/external-secrets/proto v0.0.0
+	github.com/external-secrets/external-secrets/providers/v2/adapter/store v0.0.0
+	github.com/external-secrets/external-secrets/providers/v2/common v0.0.0
 	github.com/go-logr/logr v1.4.3
 	github.com/google/go-cmp v0.7.0
 	github.com/google/uuid v1.6.0
@@ -21,12 +24,13 @@ require (
 	github.com/spf13/cast v1.7.0
 	github.com/spf13/pflag v1.0.10
 	github.com/stretchr/testify v1.11.1
-	golang.org/x/crypto v0.47.0
-	k8s.io/api v0.35.0
-	k8s.io/apiextensions-apiserver v0.35.0
-	k8s.io/apimachinery v0.35.0
-	k8s.io/client-go v0.35.0
-	sigs.k8s.io/controller-runtime v0.23.1
+	golang.org/x/crypto v0.49.0
+	google.golang.org/grpc v1.79.3
+	k8s.io/api v0.35.2
+	k8s.io/apiextensions-apiserver v0.35.2
+	k8s.io/apimachinery v0.35.2
+	k8s.io/client-go v0.35.2
+	sigs.k8s.io/controller-runtime v0.23.3
 	sigs.k8s.io/yaml v1.6.0
 	software.sslmate.com/src/go-pkcs12 v0.6.0
 )
@@ -35,26 +39,26 @@ require (
 	github.com/aws/smithy-go v1.23.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
-	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
 	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
 	github.com/emicklei/go-restful/v3 v3.13.0 // indirect
 	github.com/evanphx/json-patch/v5 v5.9.11 // indirect
 	github.com/fsnotify/fsnotify v1.9.0 // indirect
 	github.com/fxamacker/cbor/v2 v2.9.0 // indirect
-	github.com/go-openapi/jsonpointer v0.22.4 // indirect
-	github.com/go-openapi/jsonreference v0.21.4 // indirect
-	github.com/go-openapi/swag v0.25.4 // indirect
-	github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
-	github.com/go-openapi/swag/conv v0.25.4 // indirect
-	github.com/go-openapi/swag/fileutils v0.25.4 // indirect
-	github.com/go-openapi/swag/jsonname v0.25.4 // indirect
-	github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
-	github.com/go-openapi/swag/loading v0.25.4 // indirect
-	github.com/go-openapi/swag/mangling v0.25.4 // indirect
-	github.com/go-openapi/swag/netutils v0.25.4 // indirect
-	github.com/go-openapi/swag/stringutils v0.25.4 // indirect
-	github.com/go-openapi/swag/typeutils v0.25.4 // indirect
-	github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
+	github.com/go-openapi/jsonpointer v0.22.5 // indirect
+	github.com/go-openapi/jsonreference v0.21.5 // indirect
+	github.com/go-openapi/swag v0.25.5 // indirect
+	github.com/go-openapi/swag/cmdutils v0.25.5 // indirect
+	github.com/go-openapi/swag/conv v0.25.5 // indirect
+	github.com/go-openapi/swag/fileutils v0.25.5 // indirect
+	github.com/go-openapi/swag/jsonname v0.25.5 // indirect
+	github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
+	github.com/go-openapi/swag/loading v0.25.5 // indirect
+	github.com/go-openapi/swag/mangling v0.25.5 // indirect
+	github.com/go-openapi/swag/netutils v0.25.5 // indirect
+	github.com/go-openapi/swag/stringutils v0.25.5 // indirect
+	github.com/go-openapi/swag/typeutils v0.25.5 // indirect
+	github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
 	github.com/goccy/go-json v0.10.3 // indirect
 	github.com/gofrs/flock v0.10.0 // indirect
 	github.com/google/btree v1.1.3 // indirect
@@ -69,34 +73,40 @@ require (
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
-	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/prometheus/client_model v0.6.2 // indirect
 	github.com/prometheus/common v0.67.5 // indirect
-	github.com/prometheus/procfs v0.19.2 // indirect
+	github.com/prometheus/procfs v0.20.1 // indirect
 	github.com/segmentio/asm v1.2.0 // indirect
 	github.com/sony/gobreaker v0.5.0 // indirect
 	github.com/x448/float16 v0.8.4 // indirect
 	github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
-	go.yaml.in/yaml/v2 v2.4.3 // indirect
+	go.yaml.in/yaml/v2 v2.4.4 // indirect
 	go.yaml.in/yaml/v3 v3.0.4 // indirect
-	golang.org/x/net v0.49.0 // indirect
-	golang.org/x/oauth2 v0.34.0 // indirect
-	golang.org/x/sync v0.19.0 // indirect
-	golang.org/x/sys v0.40.0 // indirect
-	golang.org/x/term v0.39.0 // indirect
-	golang.org/x/text v0.33.0 // indirect
-	golang.org/x/time v0.14.0 // indirect
+	golang.org/x/net v0.52.0 // indirect
+	golang.org/x/oauth2 v0.36.0 // indirect
+	golang.org/x/sync v0.20.0 // indirect
+	golang.org/x/sys v0.42.0 // indirect
+	golang.org/x/term v0.41.0 // indirect
+	golang.org/x/text v0.35.0 // indirect
+	golang.org/x/time v0.15.0 // indirect
 	gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
 	google.golang.org/protobuf v1.36.11 // indirect
 	gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
-	k8s.io/klog/v2 v2.130.1 // indirect
-	k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect
-	k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect
+	k8s.io/klog/v2 v2.140.0 // indirect
+	k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf // indirect
+	k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
 	sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
 	sigs.k8s.io/randfill v1.0.0 // indirect
-	sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect
+	sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
 )
 
-replace github.com/external-secrets/external-secrets/apis => ../apis
+replace (
+	github.com/external-secrets/external-secrets/apis => ../apis
+	github.com/external-secrets/external-secrets/proto => ../providers/v2/common/proto
+	github.com/external-secrets/external-secrets/providers/v2/adapter/store => ../providers/v2/adapter/store
+	github.com/external-secrets/external-secrets/providers/v2/common => ../providers/v2/common
+)

+ 73 - 0
runtime/go.sum

@@ -15,6 +15,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
 github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
@@ -35,34 +37,62 @@ github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
 github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
 github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
 github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
+github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
+github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
 github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
 github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
+github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
+github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
 github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
 github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
+github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU=
+github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA=
 github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
 github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
+github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c=
+github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
 github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
 github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
+github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
+github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
 github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
 github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
+github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk=
+github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc=
 github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
 github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
+github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
+github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
 github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
 github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
+github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
+github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
 github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
 github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
 github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
 github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
+github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
+github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
 github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
 github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
+github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw=
+github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY=
 github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
 github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
+github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU=
+github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14=
 github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
 github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
+github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
+github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
 github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
 github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
+github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
+github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
 github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
 github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
+github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
+github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
 github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
 github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
 github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
@@ -134,6 +164,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
 github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@@ -142,6 +174,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU
 github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
 github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
 github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
+github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
+github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
@@ -174,30 +208,51 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
 go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
 go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
 golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
 golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
 golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
 golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
 golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
 golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
 golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
 golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
 golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
 golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
 golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
 golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
+golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
+golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
 golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
 golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
 golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
 golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
 golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
 golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
 gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0=
 gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
+google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -212,26 +267,44 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
 k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
+k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw=
+k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60=
 k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4=
 k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU=
+k8s.io/apiextensions-apiserver v0.35.2 h1:iyStXHoJZsUXPh/nFAsjC29rjJWdSgUmG1XpApE29c0=
+k8s.io/apiextensions-apiserver v0.35.2/go.mod h1:OdyGvcO1FtMDWQ+rRh/Ei3b6X3g2+ZDHd0MSRGeS8rU=
 k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
 k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
+k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8=
+k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
 k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
 k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
+k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o=
+k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g=
 k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
 k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
+k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
 k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY=
 k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
+k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf h1:btPscg4cMql0XdYK2jLsJcNEKmACJz8l+U7geC06FiM=
+k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
 k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY=
 k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
 sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE=
 sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0=
+sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80=
+sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0=
 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
 sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
 sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
 sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs=
 sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
 sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
 sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
 software.sslmate.com/src/go-pkcs12 v0.6.0 h1:f3sQittAeF+pao32Vb+mkli+ZyT+VwKaD014qFGq6oU=

+ 1 - 1
runtime/testing/fake/fake.go

@@ -29,7 +29,7 @@ import (
 	"github.com/external-secrets/external-secrets/runtime/esutils"
 )
 
-var _ esv1.Provider = &Client{}
+var _ esv1.ProviderInterface = &Client{}
 
 type SetSecretCallArgs struct {
 	Value     []byte