Browse Source

feat: make cache generic, refactor feature flags (#1640)

Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>

Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>
Moritz Johner 3 years ago
parent
commit
5ef3b23a68

+ 12 - 15
cmd/root.go

@@ -39,8 +39,7 @@ import (
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret"
 	"github.com/external-secrets/external-secrets/pkg/controllers/pushsecret"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore"
-	awsauth "github.com/external-secrets/external-secrets/pkg/provider/aws/auth"
-	"github.com/external-secrets/external-secrets/pkg/provider/vault"
+	"github.com/external-secrets/external-secrets/pkg/feature"
 )
 
 var (
@@ -72,9 +71,6 @@ var (
 	crdRequeueInterval                    time.Duration
 	certCheckInterval                     time.Duration
 	certLookaheadInterval                 time.Duration
-	enableAWSSession                      bool
-	enableVaultTokenCache                 bool
-	vaultTokenCacheSize                   int
 	tlsCiphers                            string
 	tlsMinVersion                         string
 )
@@ -205,19 +201,19 @@ var rootCmd = &cobra.Command{
 				os.Exit(1)
 			}
 		}
-		if enableAWSSession {
-			awsauth.EnableCache = true
-		}
-		if enableVaultTokenCache {
-			vault.EnableCache = true
-			vault.VaultClientCache.Size = vaultTokenCacheSize
+
+		fs := feature.Features()
+		for _, f := range fs {
+			if f.Initialize == nil {
+				continue
+			}
+			f.Initialize()
 		}
 		setupLog.Info("starting manager")
 		if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
 			setupLog.Error(err, "problem running manager")
 			os.Exit(1)
 		}
-
 	},
 }
 
@@ -244,7 +240,8 @@ func init() {
 	rootCmd.Flags().BoolVar(&enableConfigMapsCache, "enable-configmaps-caching", false, "Enable secrets caching for external-secrets pod.")
 	rootCmd.Flags().DurationVar(&storeRequeueInterval, "store-requeue-interval", time.Minute*5, "Default Time duration between reconciling (Cluster)SecretStores")
 	rootCmd.Flags().BoolVar(&enableFloodGate, "enable-flood-gate", true, "Enable flood gate. External secret will be reconciled only if the ClusterStore or Store have an healthy or unknown state.")
-	rootCmd.Flags().BoolVar(&enableAWSSession, "experimental-enable-aws-session-cache", false, "Enable experimental AWS session cache. External secret will reuse the AWS session without creating a new one on each request.")
-	rootCmd.Flags().BoolVar(&enableVaultTokenCache, "experimental-enable-vault-token-cache", false, "Enable experimental Vault token cache. External secrets will reuse the Vault token without creating a new one on each request.")
-	rootCmd.Flags().IntVar(&vaultTokenCacheSize, "experimental-vault-token-cache-size", 100, "Maximum size of Vault token cache. Only used if --experimental-enable-vault-token-cache is set.")
+	fs := feature.Features()
+	for _, f := range fs {
+		rootCmd.Flags().AddFlagSet(f.Flags)
+	}
 }

+ 16 - 0
e2e/framework/addon/eso.go

@@ -57,6 +57,22 @@ func NewESO(mutators ...MutationFunc) *ESO {
 					Key:   installCRDsVar,
 					Value: "false",
 				},
+				{
+					Key:   "concurrent",
+					Value: "100",
+				},
+				{
+					Key:   "extraArgs.experimental-enable-vault-token-cache",
+					Value: "true",
+				},
+				{
+					Key:   "extraArgs.experimental-enable-aws-session-cache",
+					Value: "true",
+				},
+				{
+					Key:   "extraArgs.experimental-vault-token-cache-size",
+					Value: "10",
+				},
 			},
 		},
 	}

+ 55 - 0
e2e/framework/eso.go

@@ -20,11 +20,16 @@ import (
 	"encoding/json"
 	"time"
 
+	//nolint
+	. "github.com/onsi/gomega"
+
 	v1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/apimachinery/pkg/util/wait"
 
+	"github.com/external-secrets/external-secrets-e2e/framework/log"
 	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
 )
 
@@ -45,6 +50,56 @@ func (f *Framework) WaitForSecretValue(namespace, name string, expected *v1.Secr
 	return secret, err
 }
 
+func (f *Framework) printESDebugLogs(esName, esNamespace string) {
+	// fetch es and print status condition
+	var es esv1beta1.ExternalSecret
+	err := f.CRClient.Get(context.Background(), types.NamespacedName{
+		Name:      esName,
+		Namespace: esNamespace,
+	}, &es)
+	Expect(err).ToNot(HaveOccurred())
+	log.Logf("resourceVersion=%s", es.Status.SyncedResourceVersion)
+	for _, cond := range es.Status.Conditions {
+		log.Logf("condition: status=%s type=%s reason=%s message=%s", cond.Status, cond.Type, cond.Reason, cond.Message)
+	}
+	// list events for given
+	evs, err := f.KubeClientSet.CoreV1().Events(esNamespace).List(context.Background(), metav1.ListOptions{
+		FieldSelector: "involvedObject.name=" + esName + ",involvedObject.kind=ExternalSecret",
+	})
+	Expect(err).ToNot(HaveOccurred())
+	for _, ev := range evs.Items {
+		log.Logf("ev reason=%s message=%s", ev.Reason, ev.Message)
+	}
+
+	// print most recent logs of default eso installation
+	podList, err := f.KubeClientSet.CoreV1().Pods("default").List(
+		context.Background(),
+		metav1.ListOptions{LabelSelector: "app.kubernetes.io/instance=eso,app.kubernetes.io/name=external-secrets"})
+	Expect(err).ToNot(HaveOccurred())
+	numLines := int64(60)
+	for i := range podList.Items {
+		pod := podList.Items[i]
+		for _, con := range pod.Spec.Containers {
+			for _, b := range []bool{true, false} {
+				resp := f.KubeClientSet.CoreV1().Pods(pod.Namespace).GetLogs(pod.Name, &v1.PodLogOptions{
+					Container: con.Name,
+					Previous:  b,
+					TailLines: &numLines,
+				}).Do(context.TODO())
+				err := resp.Error()
+				if err != nil {
+					continue
+				}
+				logs, err := resp.Raw()
+				if err != nil {
+					continue
+				}
+				log.Logf("[%s]: %s", "eso", string(logs))
+			}
+		}
+	}
+}
+
 func equalSecrets(exp, ts *v1.Secret) bool {
 	if exp.Type != ts.Type {
 		return false

+ 2 - 2
e2e/framework/log/log.go

@@ -3,7 +3,7 @@ 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
 
-    http://www.apache.org/licenses/LICENSE-2.0
+	http://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,
@@ -19,5 +19,5 @@ import (
 
 // Logf logs the format string to ginkgo stdout.
 func Logf(format string, args ...interface{}) {
-	ginkgo.GinkgoWriter.Printf(format, args...)
+	ginkgo.GinkgoWriter.Printf(format+"\n", args...)
 }

+ 1 - 0
e2e/framework/testcase.go

@@ -96,6 +96,7 @@ func TableFunc(f *Framework, prov SecretStoreProvider) func(...func(*TestCase))
 		// wait for Kind=Secret to have the expected data
 		secret, err := tc.Framework.WaitForSecretValue(tc.Framework.Namespace.Name, TargetSecretName, tc.ExpectedSecret)
 		if err != nil {
+			f.printESDebugLogs(tc.ExternalSecret.Name, tc.ExternalSecret.Namespace)
 			log.Logf("Did not match. Expected: %+v, Got: %+v", tc.ExpectedSecret, secret)
 		}
 

+ 1 - 1
go.mod

@@ -98,6 +98,7 @@ require (
 	github.com/hashicorp/golang-lru v0.5.4
 	github.com/maxbrunsfeld/counterfeiter/v6 v6.5.0
 	github.com/sethvargo/go-password v0.2.0
+	github.com/spf13/pflag v1.0.5
 	sigs.k8s.io/yaml v1.3.0
 )
 
@@ -206,7 +207,6 @@ require (
 	github.com/shopspring/decimal v1.3.1 // indirect
 	github.com/sony/gobreaker v0.5.0 // indirect
 	github.com/spf13/cast v1.5.0 // indirect
-	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/tidwall/match v1.1.1 // indirect
 	github.com/tidwall/pretty v1.2.0 // indirect
 	github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect

+ 99 - 0
pkg/cache/cache.go

@@ -0,0 +1,99 @@
+/*
+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
+
+    http://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 cache
+
+import (
+	"fmt"
+
+	lru "github.com/hashicorp/golang-lru"
+)
+
+// Cache is a generic lru cache that allows you to
+// lookup values using a key and a version.
+// By design, this cache allows access to only a single version of a given key.
+// A version mismatch is considered a cache miss and the key gets evicted if it exists.
+// When a key is evicted a optional cleanup function is called.
+type Cache[T any] struct {
+	lru         *lru.Cache
+	size        int
+	cleanupFunc cleanupFunc[T]
+}
+
+// Key is the cache lookup key.
+type Key struct {
+	Name      string
+	Namespace string
+	Kind      string
+}
+
+type value[T any] struct {
+	Version string
+	Client  T
+}
+
+type cleanupFunc[T any] func(client T)
+
+// New constructs a new lru cache with the desired size and cleanup func.
+func New[T any](size int, cleanup cleanupFunc[T]) (*Cache[T], error) {
+	lruCache, err := lru.NewWithEvict(size, func(_, val any) {
+		if cleanup == nil {
+			return
+		}
+		cleanup(val.(value[T]).Client)
+	})
+	if err != nil {
+		return nil, fmt.Errorf("unable to create lru: %w", err)
+	}
+	return &Cache[T]{
+		lru:         lruCache,
+		size:        size,
+		cleanupFunc: cleanup,
+	}, nil
+}
+
+// Must creates a new lru cache with the desired size and cleanup func
+// This function panics if a error occurrs.
+func Must[T any](size int, cleanup cleanupFunc[T]) *Cache[T] {
+	c, err := New(size, cleanup)
+	if err != nil {
+		panic(err)
+	}
+	return c
+}
+
+// Get retrieves the desired value using the key and
+// compares the version. If there is a mismatch
+// it is considered a cache miss and the existing key is purged.
+func (c *Cache[T]) Get(version string, key Key) (T, bool) {
+	val, ok := c.lru.Get(key)
+	if ok {
+		cachedClient := val.(value[T])
+		if cachedClient.Version == version {
+			return cachedClient.Client, true
+		}
+		c.lru.Remove(key)
+	}
+	return value[T]{}.Client, false
+}
+
+// Add adds a new value for the given key/version.
+func (c *Cache[T]) Add(version string, key Key, client T) {
+	c.lru.Add(key, value[T]{Version: version, Client: client})
+}
+
+// Contains returns true if a value with the given key exists.
+func (c *Cache[T]) Contains(key Key) bool {
+	return c.lru.Contains(key)
+}

+ 99 - 0
pkg/cache/cache_test.go

@@ -0,0 +1,99 @@
+/*
+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
+
+	http://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 cache
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+type client struct{}
+
+var cacheKey = Key{Name: "foo"}
+
+func TestCacheAdd(t *testing.T) {
+	c, err := New[client](1, nil)
+	if err != nil {
+		t.Fail()
+	}
+
+	cl := client{}
+	c.Add("", cacheKey, cl)
+	cachedVal, _ := c.Get("", cacheKey)
+
+	assert.EqualValues(t, cl, cachedVal)
+}
+
+func TestCacheContains(t *testing.T) {
+	c, err := New[client](1, nil)
+	if err != nil {
+		t.Fail()
+	}
+
+	cl := client{}
+	c.Add("", cacheKey, cl)
+	exists := c.Contains(cacheKey)
+	notExists := c.Contains(Key{Name: "does not exist"})
+
+	assert.True(t, exists)
+	assert.False(t, notExists)
+	assert.Nil(t, err)
+}
+
+func TestCacheGet(t *testing.T) {
+	c, err := New[*client](1, nil)
+	if err != nil {
+		t.Fail()
+	}
+	cachedVal, ok := c.Get("", cacheKey)
+
+	assert.Nil(t, cachedVal)
+	assert.False(t, ok)
+}
+
+func TestCacheGetInvalidVersion(t *testing.T) {
+	var cleanupCalled bool
+	c, err := New(1, func(client *client) {
+		cleanupCalled = true
+	})
+	if err != nil {
+		t.Fail()
+	}
+	cl := &client{}
+	c.Add("", cacheKey, cl)
+	cachedVal, ok := c.Get("invalid", cacheKey)
+
+	assert.Nil(t, cachedVal)
+	assert.False(t, ok)
+	assert.True(t, cleanupCalled)
+}
+
+func TestCacheEvict(t *testing.T) {
+	var cleanupCalled bool
+	c, err := New(1, func(client client) {
+		cleanupCalled = true
+	})
+	if err != nil {
+		t.Fail()
+	}
+
+	// add first version
+	c.Add("", Key{Name: "foo"}, client{})
+	assert.False(t, cleanupCalled)
+
+	// adding a second version should evict old one
+	c.Add("", Key{Name: "bar"}, client{})
+	assert.True(t, cleanupCalled)
+}

+ 38 - 0
pkg/feature/feature.go

@@ -0,0 +1,38 @@
+/*
+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
+
+	http://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 feature
+
+import (
+	"github.com/spf13/pflag"
+)
+
+// Feature contains the CLI flags that a provider exposes to a user.
+// A optional Initialize func is called once the flags have been parsed.
+// A provider can use this to do late-initialization using the defined cli args.
+type Feature struct {
+	Flags      *pflag.FlagSet
+	Initialize func()
+}
+
+var features = make([]Feature, 0)
+
+// Features returns all registered features.
+func Features() []Feature {
+	return features
+}
+
+// Register registers a new feature.
+func Register(f Feature) {
+	features = append(features, f)
+}

+ 23 - 19
pkg/provider/aws/auth/auth.go

@@ -26,6 +26,7 @@ import (
 	"github.com/aws/aws-sdk-go/aws/session"
 	"github.com/aws/aws-sdk-go/service/sts"
 	"github.com/aws/aws-sdk-go/service/sts/stsiface"
+	"github.com/spf13/pflag"
 	v1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/kubernetes"
@@ -34,6 +35,8 @@ import (
 	ctrlcfg "sigs.k8s.io/controller-runtime/pkg/client/config"
 
 	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
+	"github.com/external-secrets/external-secrets/pkg/cache"
+	"github.com/external-secrets/external-secrets/pkg/feature"
 	"github.com/external-secrets/external-secrets/pkg/provider/aws/util"
 )
 
@@ -44,17 +47,10 @@ type Config struct {
 	APIRetries int
 }
 
-type SessionCache struct {
-	Name            string
-	Namespace       string
-	Kind            string
-	ResourceVersion string
-}
-
 var (
-	log         = ctrl.Log.WithName("provider").WithName("aws")
-	sessions    = make(map[SessionCache]*session.Session)
-	EnableCache bool
+	log                = ctrl.Log.WithName("provider").WithName("aws")
+	enableSessionCache bool
+	sessionCache       *cache.Cache[*session.Session]
 )
 
 const (
@@ -71,6 +67,15 @@ const (
 	errMissingAKID                             = "missing AccessKeyID"
 )
 
+func init() {
+	fs := pflag.NewFlagSet("aws-auth", pflag.ExitOnError)
+	fs.BoolVar(&enableSessionCache, "experimental-enable-aws-session-cache", false, "Enable experimental AWS session cache. External secret will reuse the AWS session without creating a new one on each request.")
+	feature.Register(feature.Feature{
+		Flags: fs,
+	})
+	sessionCache = cache.Must[*session.Session](1024, nil)
+}
+
 // New creates a new aws session based on the provided store
 // it uses the following authentication mechanisms in order:
 // * service-account token authentication via AssumeRoleWithWebIdentity
@@ -111,7 +116,7 @@ func New(ctx context.Context, store esv1beta1.GenericStore, kube client.Client,
 		config.WithRegion(prov.Region)
 	}
 
-	sess, err := getAWSSession(config, EnableCache, store.GetName(), store.GetTypeMeta().Kind, namespace, store.GetObjectMeta().ResourceVersion)
+	sess, err := getAWSSession(config, enableSessionCache, store.GetName(), store.GetTypeMeta().Kind, namespace, store.GetObjectMeta().ResourceVersion)
 	if err != nil {
 		return nil, err
 	}
@@ -327,17 +332,16 @@ func DefaultSTSProvider(sess *session.Session) stsiface.STSAPI {
 // getAWSSession checks if an AWS session should be reused
 // it returns the aws session or an error.
 func getAWSSession(config *aws.Config, enableCache bool, name, kind, namespace, resourceVersion string) (*session.Session, error) {
-	tmpSession := SessionCache{
-		Name:            name,
-		Namespace:       namespace,
-		Kind:            kind,
-		ResourceVersion: resourceVersion,
+	key := cache.Key{
+		Name:      name,
+		Namespace: namespace,
+		Kind:      kind,
 	}
 
 	if enableCache {
-		sess, ok := sessions[tmpSession]
+		sess, ok := sessionCache.Get(resourceVersion, key)
 		if ok {
-			log.Info("reusing aws session", "SecretStore", tmpSession.Name, "namespace", tmpSession.Namespace, "kind", tmpSession.Kind, "resourceversion", tmpSession.ResourceVersion)
+			log.Info("reusing aws session", "SecretStore", key.Name, "namespace", key.Namespace, "kind", key.Kind, "resourceversion", resourceVersion)
 			return sess, nil
 		}
 	}
@@ -354,7 +358,7 @@ func getAWSSession(config *aws.Config, enableCache bool, name, kind, namespace,
 	}
 
 	if enableCache {
-		sessions[tmpSession] = sess
+		sessionCache.Add(resourceVersion, key, sess)
 	}
 	return sess, nil
 }

+ 0 - 106
pkg/provider/vault/cache.go

@@ -1,106 +0,0 @@
-/*
-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
-
-    http://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 vault
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"sync"
-
-	lru "github.com/hashicorp/golang-lru"
-
-	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
-)
-
-type clientCache struct {
-	cache       *lru.Cache
-	Size        int
-	initialized bool
-	mu          sync.Mutex
-}
-
-type clientCacheKey struct {
-	Name      string
-	Namespace string
-	Kind      string
-}
-
-type clientCacheValue struct {
-	ResourceVersion string
-	Client          Client
-}
-
-func (c *clientCache) initialize() error {
-	if !c.initialized {
-		var err error
-		c.cache, err = lru.New(c.Size)
-		if err != nil {
-			return fmt.Errorf(errVaultCacheCreate, err)
-		}
-		c.initialized = true
-	}
-	return nil
-}
-
-func (c *clientCache) get(ctx context.Context, store esv1beta1.GenericStore, key clientCacheKey) (Client, bool, error) {
-	value, ok := c.cache.Get(key)
-	if ok {
-		cachedClient := value.(clientCacheValue)
-		if cachedClient.ResourceVersion == store.GetObjectMeta().ResourceVersion {
-			return cachedClient.Client, true, nil
-		}
-		// revoke token and clear old item from cache if resource has been updated
-		err := revokeTokenIfValid(ctx, cachedClient.Client)
-		if err != nil {
-			return nil, false, err
-		}
-		c.cache.Remove(key)
-	}
-	return nil, false, nil
-}
-
-func (c *clientCache) add(ctx context.Context, store esv1beta1.GenericStore, key clientCacheKey, client Client) error {
-	// don't let the LRU cache evict items
-	// remove the oldest item manually when needed so we can do some cleanup
-	for c.cache.Len() >= c.Size {
-		_, value, ok := c.cache.RemoveOldest()
-		if !ok {
-			return errors.New(errVaultCacheRemove)
-		}
-		cachedClient := value.(clientCacheValue)
-		err := revokeTokenIfValid(ctx, cachedClient.Client)
-		if err != nil {
-			return fmt.Errorf(errVaultRevokeToken, err)
-		}
-	}
-	evicted := c.cache.Add(key, clientCacheValue{ResourceVersion: store.GetObjectMeta().ResourceVersion, Client: client})
-	if evicted {
-		return errors.New(errVaultCacheEviction)
-	}
-	return nil
-}
-
-func (c *clientCache) contains(key clientCacheKey) bool {
-	return c.cache.Contains(key)
-}
-
-func (c *clientCache) lock() {
-	c.mu.Lock()
-}
-
-func (c *clientCache) unlock() {
-	c.mu.Unlock()
-}

+ 43 - 37
pkg/provider/vault/vault.go

@@ -32,6 +32,7 @@ import (
 	approle "github.com/hashicorp/vault/api/auth/approle"
 	authkubernetes "github.com/hashicorp/vault/api/auth/kubernetes"
 	authldap "github.com/hashicorp/vault/api/auth/ldap"
+	"github.com/spf13/pflag"
 	"github.com/tidwall/gjson"
 	authenticationv1 "k8s.io/api/authentication/v1"
 	corev1 "k8s.io/api/core/v1"
@@ -45,15 +46,18 @@ import (
 
 	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
 	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+	"github.com/external-secrets/external-secrets/pkg/cache"
+	"github.com/external-secrets/external-secrets/pkg/feature"
 	"github.com/external-secrets/external-secrets/pkg/find"
 	"github.com/external-secrets/external-secrets/pkg/utils"
 )
 
 var (
-	_                esv1beta1.Provider      = &connector{}
-	_                esv1beta1.SecretsClient = &client{}
-	EnableCache      bool
-	VaultClientCache clientCache
+	_           esv1beta1.Provider      = &connector{}
+	_           esv1beta1.SecretsClient = &client{}
+	enableCache bool
+	logger      = ctrl.Log.WithName("provider").WithName("vault")
+	clientCache *cache.Cache[Client]
 )
 
 const (
@@ -198,14 +202,6 @@ type client struct {
 	storeKind string
 }
 
-func init() {
-	esv1beta1.Register(&connector{
-		newVaultClient: newVaultClient,
-	}, &esv1beta1.SecretStoreProvider{
-		Vault: &esv1beta1.VaultProvider{},
-	})
-}
-
 func newVaultClient(c *vault.Config) (Client, error) {
 	cl, err := vault.NewClient(c)
 	if err != nil {
@@ -227,30 +223,17 @@ func newVaultClient(c *vault.Config) (Client, error) {
 	return out, nil
 }
 
-func getVaultClient(ctx context.Context, c *connector, store esv1beta1.GenericStore, cfg *vault.Config) (Client, error) {
+func getVaultClient(c *connector, store esv1beta1.GenericStore, cfg *vault.Config) (Client, error) {
 	isStaticToken := store.GetSpec().Provider.Vault.Auth.TokenSecretRef != nil
-	useCache := EnableCache && !isStaticToken
-
-	if useCache {
-		VaultClientCache.lock()
-		defer VaultClientCache.unlock()
+	useCache := enableCache && !isStaticToken
 
-		err := VaultClientCache.initialize()
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	key := clientCacheKey{
+	key := cache.Key{
 		Name:      store.GetObjectMeta().Name,
 		Namespace: store.GetObjectMeta().Namespace,
 		Kind:      store.GetTypeMeta().Kind,
 	}
 	if useCache {
-		client, ok, err := VaultClientCache.get(ctx, store, key)
-		if err != nil {
-			return nil, err
-		}
+		client, ok := clientCache.Get(store.GetObjectMeta().ResourceVersion, key)
 		if ok {
 			return client, nil
 		}
@@ -261,11 +244,8 @@ func getVaultClient(ctx context.Context, c *connector, store esv1beta1.GenericSt
 		return nil, fmt.Errorf(errVaultClient, err)
 	}
 
-	if useCache && !VaultClientCache.contains(key) {
-		err = VaultClientCache.add(ctx, store, key, client)
-		if err != nil {
-			return nil, err
-		}
+	if useCache && !clientCache.Contains(key) {
+		clientCache.Add(store.GetObjectMeta().ResourceVersion, key, client)
 	}
 	return client, nil
 }
@@ -306,7 +286,7 @@ func (c *connector) newClient(ctx context.Context, store esv1beta1.GenericStore,
 		kube:      kube,
 		corev1:    corev1,
 		store:     vaultSpec,
-		log:       ctrl.Log.WithName("provider").WithName("vault"),
+		log:       logger,
 		namespace: namespace,
 		storeKind: store.GetObjectKind().GroupVersionKind().Kind,
 	}
@@ -316,7 +296,7 @@ func (c *connector) newClient(ctx context.Context, store esv1beta1.GenericStore,
 		return nil, err
 	}
 
-	client, err := getVaultClient(ctx, c, store, cfg)
+	client, err := getVaultClient(c, store, cfg)
 	if err != nil {
 		return nil, fmt.Errorf(errVaultClient, err)
 	}
@@ -723,7 +703,7 @@ func getTypedKey(data map[string]interface{}, key string) ([]byte, error) {
 func (v *client) Close(ctx context.Context) error {
 	// Revoke the token if we have one set, it wasn't sourced from a TokenSecretRef,
 	// and token caching isn't enabled
-	if !EnableCache && v.client.Token() != "" && v.store.Auth.TokenSecretRef == nil {
+	if !enableCache && v.client.Token() != "" && v.store.Auth.TokenSecretRef == nil {
 		err := revokeTokenIfValid(ctx, v.client)
 		if err != nil {
 			return err
@@ -1415,3 +1395,29 @@ func (v *client) requestTokenWithCertAuth(ctx context.Context, certAuth *esv1bet
 	v.client.SetToken(token)
 	return nil
 }
+
+func init() {
+	var vaultTokenCacheSize int
+	fs := pflag.NewFlagSet("vault", pflag.ExitOnError)
+	fs.BoolVar(&enableCache, "experimental-enable-vault-token-cache", false, "Enable experimental Vault token cache. External secrets will reuse the Vault token without creating a new one on each request.")
+	fs.IntVar(&vaultTokenCacheSize, "experimental-vault-token-cache-size", 100, "Maximum size of Vault token cache. Only used if --experimental-enable-vault-token-cache is set.")
+	lateInit := func() {
+		logger.Info("initializing vault cache with size=%d", vaultTokenCacheSize)
+		clientCache = cache.Must(vaultTokenCacheSize, func(client Client) {
+			err := revokeTokenIfValid(context.Background(), client)
+			if err != nil {
+				logger.Error(err, "unable to revoke cached token on eviction")
+			}
+		})
+	}
+	feature.Register(feature.Feature{
+		Flags:      fs,
+		Initialize: lateInit,
+	})
+
+	esv1beta1.Register(&connector{
+		newVaultClient: newVaultClient,
+	}, &esv1beta1.SecretStoreProvider{
+		Vault: &esv1beta1.VaultProvider{},
+	})
+}