Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,13 @@ func main() {
}
defer authCache.Cleanup()

projectIDCache, err := inmemoryCache.NewResourceCache[string](30 * time.Minute)
if err != nil {
setupLog.Error(err, "unable to start resource cache")
os.Exit(1)
}
defer projectIDCache.Cleanup()

authStrategyResolver := auth.NewAuthStrategyResolver(mgr.GetClient(), authCache, ctrl.Log, isNamespaceScoped)

template.InitializeTemplateFunctions()
Expand Down Expand Up @@ -332,6 +339,7 @@ func main() {
BaseLogger: ctrl.Log,
IsNamespaceScoped: isNamespaceScoped,
AuthResolver: authStrategyResolver,
ProjectIDCache: projectIDCache,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "InfisicalStaticSecret")
os.Exit(1)
Expand Down
45 changes: 45 additions & 0 deletions internal/cache/resource_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package cache

import (
"fmt"
"time"

"github.com/dgraph-io/ristretto/v2"
)

type ResourceCache[V any] struct {
cache *ristretto.Cache[string, V]
ttl time.Duration
}

func NewResourceCache[V any](ttl time.Duration) (*ResourceCache[V], error) {
cache, err := ristretto.NewCache(&ristretto.Config[string, V]{
NumCounters: 1000,
MaxCost: 1 << 20,
BufferItems: 64,
IgnoreInternalCost: true,
})
if err != nil {
return nil, fmt.Errorf("failed to create resource cache: %w", err)
}
return &ResourceCache[V]{cache: cache, ttl: ttl}, nil
}

func (c *ResourceCache[V]) Get(key string) (V, bool) {
return c.cache.Get(key)
}

func (c *ResourceCache[V]) Set(key string, value V) {
c.cache.SetWithTTL(key, value, 1, c.ttl)
c.cache.Wait()
}

func (c *ResourceCache[V]) Cleanup() {
if c.cache != nil {
c.cache.Close()
}
}

func ProjectBySlugCacheKey(authNamespace, authName, slug string) string {
return fmt.Sprintf("project_by_slug_%s/%s/%s", authNamespace, authName, slug)
}
202 changes: 202 additions & 0 deletions internal/cache/resource_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package cache_test

import (
"fmt"
"sync"
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

"github.com/Infisical/infisical/k8-operator/internal/cache"
)

var _ = Describe("ResourceCache", func() {
var resourceCache *cache.ResourceCache[string]

AfterEach(func() {
if resourceCache != nil {
resourceCache.Cleanup()
}
})

Describe("basic operations", func() {
BeforeEach(func() {
var err error
resourceCache, err = cache.NewResourceCache[string](30 * time.Second)
Expect(err).NotTo(HaveOccurred())
})

It("should store and retrieve a value", func() {
resourceCache.Set("key-1", "value-1")

result, found := resourceCache.Get("key-1")
Expect(found).To(BeTrue())
Expect(result).To(Equal("value-1"))
})

It("should return false for a key that was never set", func() {
_, found := resourceCache.Get("nonexistent")
Expect(found).To(BeFalse())
})

It("should overwrite an existing key", func() {
resourceCache.Set("key-1", "original")
resourceCache.Set("key-1", "updated")

result, found := resourceCache.Get("key-1")
Expect(found).To(BeTrue())
Expect(result).To(Equal("updated"))
})

It("should store multiple distinct keys", func() {
resourceCache.Set("key-a", "value-a")
resourceCache.Set("key-b", "value-b")
resourceCache.Set("key-c", "value-c")

for _, pair := range []struct{ k, v string }{
{"key-a", "value-a"},
{"key-b", "value-b"},
{"key-c", "value-c"},
} {
result, found := resourceCache.Get(pair.k)
Expect(found).To(BeTrue())
Expect(result).To(Equal(pair.v))
}
})
})

Describe("TTL expiration", func() {
BeforeEach(func() {
var err error
resourceCache, err = cache.NewResourceCache[string](2 * time.Second)
Expect(err).NotTo(HaveOccurred())
})

It("should not find a key after its TTL expires", func() {
resourceCache.Set("ephemeral", "gone-soon")

result, found := resourceCache.Get("ephemeral")
Expect(found).To(BeTrue())
Expect(result).To(Equal("gone-soon"))

time.Sleep(3 * time.Second)

_, found = resourceCache.Get("ephemeral")
Expect(found).To(BeFalse())
})
})

Describe("parallel access", func() {
BeforeEach(func() {
var err error
resourceCache, err = cache.NewResourceCache[string](30 * time.Second)
Expect(err).NotTo(HaveOccurred())
})

It("should handle concurrent writes to different keys", func() {
const goroutines = 50
var wg sync.WaitGroup
wg.Add(goroutines)

for i := 0; i < goroutines; i++ {
go func(idx int) {
defer wg.Done()
key := fmt.Sprintf("concurrent-key-%d", idx)
value := fmt.Sprintf("concurrent-value-%d", idx)
resourceCache.Set(key, value)
}(i)
}
wg.Wait()

for i := 0; i < goroutines; i++ {
key := fmt.Sprintf("concurrent-key-%d", i)
expected := fmt.Sprintf("concurrent-value-%d", i)
result, found := resourceCache.Get(key)
Expect(found).To(BeTrue())
Expect(result).To(Equal(expected))
}
})

It("should handle concurrent reads and writes to the same key", func() {
resourceCache.Set("shared", "initial")

const goroutines = 50
var wg sync.WaitGroup
wg.Add(goroutines * 2)

for i := 0; i < goroutines; i++ {
go func(idx int) {
defer wg.Done()
resourceCache.Set("shared", fmt.Sprintf("writer-%d", idx))
}(i)

go func() {
defer wg.Done()
val, found := resourceCache.Get("shared")
if found {
Expect(val).NotTo(BeEmpty())
}
}()
}
wg.Wait()

result, found := resourceCache.Get("shared")
Expect(found).To(BeTrue())
Expect(result).To(HavePrefix("writer-"))
})

It("should return the correct value per key under parallel access", func() {
keys := []string{"project-a", "project-b", "project-c"}
values := []string{"data-a", "data-b", "data-c"}

for i, k := range keys {
resourceCache.Set(k, values[i])
}

const readersPerKey = 30
var wg sync.WaitGroup
wg.Add(len(keys) * readersPerKey)

for i, k := range keys {
expected := values[i]
for r := 0; r < readersPerKey; r++ {
go func(key, exp string) {
defer wg.Done()
result, found := resourceCache.Get(key)
Expect(found).To(BeTrue())
Expect(result).To(Equal(exp))
}(k, expected)
}
}
wg.Wait()
})
})

Describe("cleanup", func() {
It("should not panic when calling Cleanup on a valid cache", func() {
var err error
resourceCache, err = cache.NewResourceCache[string](10 * time.Second)
Expect(err).NotTo(HaveOccurred())

resourceCache.Set("before-cleanup", "value")
Expect(func() { resourceCache.Cleanup() }).NotTo(Panic())
resourceCache = nil
})
})

Describe("ProjectBySlugCacheKey", func() {
It("should produce a deterministic key from auth ref and slug", func() {
key := cache.ProjectBySlugCacheKey("default", "my-auth", "my-project")
Expect(key).To(Equal("project_by_slug_default/my-auth/my-project"))
})

It("should produce distinct keys for different slugs", func() {
Expect(cache.ProjectBySlugCacheKey("ns", "auth", "a")).NotTo(Equal(cache.ProjectBySlugCacheKey("ns", "auth", "b")))
})

It("should produce distinct keys for the same slug with different auth refs", func() {
Expect(cache.ProjectBySlugCacheKey("ns-a", "auth-a", "slug")).NotTo(Equal(cache.ProjectBySlugCacheKey("ns-b", "auth-b", "slug")))
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
secretsv1beta1 "github.com/Infisical/infisical/k8-operator/api/v1beta1"
"github.com/Infisical/infisical/k8-operator/internal/api"
"github.com/Infisical/infisical/k8-operator/internal/auth"
"github.com/Infisical/infisical/k8-operator/internal/cache"
"github.com/Infisical/infisical/k8-operator/internal/model"
"github.com/Infisical/infisical/k8-operator/internal/services/infisicalstaticsecret"
"github.com/Infisical/infisical/k8-operator/internal/util"
Expand Down Expand Up @@ -74,6 +75,7 @@ type InfisicalStaticSecretReconciler struct {
Scheme *runtime.Scheme
IsNamespaceScoped bool
AuthResolver *auth.AuthStrategyResolver
ProjectIDCache *cache.ResourceCache[string]
SourceCh chan event.TypedGenericEvent[client.Object]
}

Expand Down Expand Up @@ -125,7 +127,7 @@ func (r *InfisicalStaticSecretReconciler) Reconcile(ctx context.Context, req ctr

instantUpdates := staticSecretCRD.Spec.SyncOptions != nil && staticSecretCRD.Spec.SyncOptions.InstantUpdates

handler := infisicalstaticsecret.NewInfisicalStaticSecretHandler(r.Client, r.Scheme, r.IsNamespaceScoped, r.AuthResolver, logger)
handler := infisicalstaticsecret.NewInfisicalStaticSecretHandler(r.Client, r.Scheme, r.IsNamespaceScoped, r.AuthResolver, r.ProjectIDCache, logger)
secretsCount, err := handler.SyncSecrets(ctx, &staticSecretCRD)
if err != nil {
var rateLimitErr *api.TooManyRequestsError
Expand Down
3 changes: 3 additions & 0 deletions internal/services/infisicalstaticsecret/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/Infisical/infisical/k8-operator/api/v1beta1"
"github.com/Infisical/infisical/k8-operator/internal/api"
"github.com/Infisical/infisical/k8-operator/internal/auth"
"github.com/Infisical/infisical/k8-operator/internal/cache"
"github.com/Infisical/infisical/k8-operator/internal/model"
"github.com/Infisical/infisical/k8-operator/internal/util"
"github.com/Infisical/infisical/k8-operator/internal/util/sse"
Expand All @@ -25,6 +26,7 @@ func NewInfisicalStaticSecretHandler(
scheme *runtime.Scheme,
isNamespaceScoped bool,
authResolver *auth.AuthStrategyResolver,
projectIDCache *cache.ResourceCache[string],
logger logr.Logger,
) *InfisicalStaticSecretHandler {
return &InfisicalStaticSecretHandler{
Expand All @@ -38,6 +40,7 @@ func NewInfisicalStaticSecretHandler(
Scheme: scheme,
IsNamespaceScoped: isNamespaceScoped,
authResolver: authResolver,
projectIDCache: projectIDCache,
logger: logger,
},
}
Expand Down
27 changes: 25 additions & 2 deletions internal/services/infisicalstaticsecret/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import (
"github.com/Infisical/infisical/k8-operator/api/v1beta1"
"github.com/Infisical/infisical/k8-operator/internal/api"
"github.com/Infisical/infisical/k8-operator/internal/auth"
"github.com/Infisical/infisical/k8-operator/internal/cache"
"github.com/Infisical/infisical/k8-operator/internal/constants"
"github.com/Infisical/infisical/k8-operator/internal/crypto"
"github.com/Infisical/infisical/k8-operator/internal/model"
templatev1 "github.com/Infisical/infisical/k8-operator/internal/template/v1"
"github.com/Infisical/infisical/k8-operator/internal/util"

"github.com/go-logr/logr"
"github.com/go-resty/resty/v2"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
k8Errors "k8s.io/apimachinery/pkg/api/errors"
Expand Down Expand Up @@ -142,9 +144,30 @@ type InfisicalStaticSecretReconciler struct {
Scheme *runtime.Scheme
IsNamespaceScoped bool
authResolver *auth.AuthStrategyResolver
projectIDCache *cache.ResourceCache[string]
logger logr.Logger
}

func (r *InfisicalStaticSecretReconciler) getProjectIDBySlug(restClient *resty.Client, authRef v1beta1.NamespacedName, slug string) (string, error) {
cacheKey := cache.ProjectBySlugCacheKey(authRef.Namespace, authRef.Name, slug)
if r.projectIDCache != nil {
if cached, ok := r.projectIDCache.Get(cacheKey); ok {
return cached, nil
}
}

project, err := api.FindProjectBySlug(restClient, slug)
if err != nil {
return "", err
}

if r.projectIDCache != nil {
r.projectIDCache.Set(cacheKey, project.ID)
}

return project.ID, nil
}

func (r *InfisicalStaticSecretReconciler) Validate(infisicalStaticSecret *v1beta1.InfisicalStaticSecret) error {
if infisicalStaticSecret == nil {
return model.ErrInvalidStaticSecretObject
Expand Down Expand Up @@ -329,11 +352,11 @@ func (r *InfisicalStaticSecretReconciler) ListSecretsFromSources(ctx context.Con
for _, source := range infisicalStaticSecret.Spec.Sources {
projectID := source.ProjectId
if source.ProjectSlug != "" {
project, err := api.FindProjectBySlug(restClient, source.ProjectSlug)
resolvedID, err := r.getProjectIDBySlug(restClient, infisicalStaticSecret.Spec.InfisicalAuthRef, source.ProjectSlug)
if err != nil {
return nil, nil, fmt.Errorf("failed to list secrets: %w", err)
}
projectID = project.ID
projectID = resolvedID
}

requests = append(requests, api.ListSecretsRequest{
Expand Down
4 changes: 2 additions & 2 deletions internal/services/infisicalstaticsecret/reconciler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ import (
svc "github.com/Infisical/infisical/k8-operator/internal/services/infisicalstaticsecret"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
k8Errors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
k8Errors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
Expand Down
Loading