diff --git a/internal/backend/secrets_cache.go b/internal/backend/secrets_cache.go new file mode 100644 index 0000000..b5f493c --- /dev/null +++ b/internal/backend/secrets_cache.go @@ -0,0 +1,114 @@ +package backend + +import ( + "path/filepath" + "sync" + + "github.com/rvolykh/vui/internal/models" +) + +type cacheEntry struct { + secrets []*models.SecretNode + secret *models.SecretNode +} + +type secretCache struct { + cache map[string]cacheEntry + mu sync.RWMutex +} + +func newSecretCache() *secretCache { + return &secretCache{ + cache: make(map[string]cacheEntry), + } +} + +// normalizePath normalizes a path for consistent caching +func normalizePath(path string) string { + if path == "" { + return "" + } + // Clean the path and ensure consistent format + normalized := filepath.Clean(path) + if normalized == "." || normalized == "/" { + return "" + } + return normalized +} + +// GetListSecrets retrieves cached list of secrets, returns (secrets, found) +func (c *secretCache) GetListSecrets(path string) ([]*models.SecretNode, bool) { + normalizedPath := normalizePath(path) + + c.mu.RLock() + defer c.mu.RUnlock() + + if entry, found := c.cache[normalizedPath]; found && entry.secrets != nil { + return entry.secrets, true + } + return nil, false +} + +// GetSecret retrieves cached secret, returns (secret, found) +func (c *secretCache) GetSecret(path string) (*models.SecretNode, bool) { + normalizedPath := normalizePath(path) + + c.mu.RLock() + defer c.mu.RUnlock() + + if entry, found := c.cache[normalizedPath]; found && entry.secret != nil { + return entry.secret, true + } + return nil, false +} + +// SetListSecrets stores a list of secrets in the cache +func (c *secretCache) SetListSecrets(path string, secrets []*models.SecretNode) { + normalizedPath := normalizePath(path) + + c.mu.Lock() + defer c.mu.Unlock() + + if entry, found := c.cache[normalizedPath]; found { + entry.secrets = secrets + c.cache[normalizedPath] = entry + } else { + c.cache[normalizedPath] = cacheEntry{secrets: secrets} + } +} + +// SetSecret stores a secret in the cache +func (c *secretCache) SetSecret(path string, secret *models.SecretNode) { + normalizedPath := normalizePath(path) + + c.mu.Lock() + defer c.mu.Unlock() + + if entry, found := c.cache[normalizedPath]; found { + entry.secret = secret + c.cache[normalizedPath] = entry + } else { + c.cache[normalizedPath] = cacheEntry{secret: secret} + } +} + +// Invalidate invalidates cache entries for the given path and all parent paths +func (c *secretCache) Invalidate(path string) { + c.mu.Lock() + defer c.mu.Unlock() + + // Normalize the path first + normalizedPath := normalizePath(path) + + // Invalidate the path itself and all parent paths + currentPath := normalizedPath + for { + delete(c.cache, currentPath) + if currentPath == "" { + break + } + currentPath = filepath.Dir(currentPath) + // Normalize the parent path + currentPath = normalizePath(currentPath) + } +} diff --git a/internal/backend/secrets_cache_test.go b/internal/backend/secrets_cache_test.go new file mode 100644 index 0000000..b0c5949 --- /dev/null +++ b/internal/backend/secrets_cache_test.go @@ -0,0 +1,474 @@ +package backend + +import ( + "sync" + "testing" + + "github.com/rvolykh/vui/internal/models" + "github.com/stretchr/testify/assert" +) + +func TestNewSecretCache(t *testing.T) { + cache := newSecretCache() + + assert.NotNil(t, cache) + assert.NotNil(t, cache.cache) + assert.Equal(t, 0, len(cache.cache)) +} + +func TestNormalizePath(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "empty path", + input: "", + expected: "", + }, + { + name: "simple path", + input: "path/to/secret", + expected: "path/to/secret", + }, + { + name: "path with dot", + input: ".", + expected: "", + }, + { + name: "path with slash", + input: "/", + expected: "", + }, + { + name: "path with trailing slash", + input: "path/to/", + expected: "path/to", + }, + { + name: "path with double slashes", + input: "path//to//secret", + expected: "path/to/secret", + }, + { + name: "path with dot segments", + input: "path/./to/../secret", + expected: "path/secret", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizePath(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSecretCache_GetListSecrets(t *testing.T) { + t.Run("cache hit", func(t *testing.T) { + cache := newSecretCache() + secrets := []*models.SecretNode{ + {Name: "secret1", Path: "secret1", IsSecret: true}, + {Name: "secret2", Path: "secret2", IsSecret: true}, + } + cache.SetListSecrets("path/to", secrets) + + result, found := cache.GetListSecrets("path/to") + + assert.True(t, found) + assert.Equal(t, secrets, result) + }) + + t.Run("cache miss", func(t *testing.T) { + cache := newSecretCache() + + result, found := cache.GetListSecrets("path/to") + + assert.False(t, found) + assert.Nil(t, result) + }) + + t.Run("cache miss with nil secrets", func(t *testing.T) { + cache := newSecretCache() + secret := &models.SecretNode{Name: "secret", Path: "path/to/secret", IsSecret: true} + cache.SetSecret("path/to/secret", secret) + + result, found := cache.GetListSecrets("path/to/secret") + + assert.False(t, found) + assert.Nil(t, result) + }) + + t.Run("path normalization", func(t *testing.T) { + cache := newSecretCache() + secrets := []*models.SecretNode{{Name: "secret", Path: "secret", IsSecret: true}} + cache.SetListSecrets("path/to", secrets) + + result, found := cache.GetListSecrets("path//to") + + assert.True(t, found) + assert.Equal(t, secrets, result) + }) + + t.Run("empty path", func(t *testing.T) { + cache := newSecretCache() + secrets := []*models.SecretNode{{Name: "secret", Path: "secret", IsSecret: true}} + cache.SetListSecrets("", secrets) + + result, found := cache.GetListSecrets("") + + assert.True(t, found) + assert.Equal(t, secrets, result) + }) +} + +func TestSecretCache_GetSecret(t *testing.T) { + t.Run("cache hit", func(t *testing.T) { + cache := newSecretCache() + secret := &models.SecretNode{Name: "secret", Path: "path/to/secret", IsSecret: true} + cache.SetSecret("path/to/secret", secret) + + result, found := cache.GetSecret("path/to/secret") + + assert.True(t, found) + assert.Equal(t, secret, result) + }) + + t.Run("cache miss", func(t *testing.T) { + cache := newSecretCache() + + result, found := cache.GetSecret("path/to/secret") + + assert.False(t, found) + assert.Nil(t, result) + }) + + t.Run("cache miss with nil secret", func(t *testing.T) { + cache := newSecretCache() + secrets := []*models.SecretNode{{Name: "secret", Path: "path/to/secret", IsSecret: true}} + cache.SetListSecrets("path/to/secret", secrets) + + result, found := cache.GetSecret("path/to/secret") + + assert.False(t, found) + assert.Nil(t, result) + }) + + t.Run("path normalization", func(t *testing.T) { + cache := newSecretCache() + secret := &models.SecretNode{Name: "secret", Path: "path/to/secret", IsSecret: true} + cache.SetSecret("path/to/secret", secret) + + result, found := cache.GetSecret("path//to//secret") + + assert.True(t, found) + assert.Equal(t, secret, result) + }) + + t.Run("empty path", func(t *testing.T) { + cache := newSecretCache() + secret := &models.SecretNode{Name: "secret", Path: "", IsSecret: true} + cache.SetSecret("", secret) + + result, found := cache.GetSecret("") + + assert.True(t, found) + assert.Equal(t, secret, result) + }) +} + +func TestSecretCache_SetListSecrets(t *testing.T) { + t.Run("set new entry", func(t *testing.T) { + cache := newSecretCache() + secrets := []*models.SecretNode{{Name: "secret", Path: "secret", IsSecret: true}} + + cache.SetListSecrets("path/to", secrets) + + result, found := cache.GetListSecrets("path/to") + assert.True(t, found) + assert.Equal(t, secrets, result) + }) + + t.Run("update existing entry", func(t *testing.T) { + cache := newSecretCache() + secrets1 := []*models.SecretNode{{Name: "secret1", Path: "secret1", IsSecret: true}} + secrets2 := []*models.SecretNode{{Name: "secret2", Path: "secret2", IsSecret: true}} + + cache.SetListSecrets("path/to", secrets1) + cache.SetListSecrets("path/to", secrets2) + + result, found := cache.GetListSecrets("path/to") + assert.True(t, found) + assert.Equal(t, secrets2, result) + }) + + t.Run("preserve secret when setting list", func(t *testing.T) { + cache := newSecretCache() + secret := &models.SecretNode{Name: "secret", Path: "path/to/secret", IsSecret: true} + secrets := []*models.SecretNode{{Name: "list", Path: "list", IsSecret: true}} + + cache.SetSecret("path/to/secret", secret) + cache.SetListSecrets("path/to/secret", secrets) + + // Should still get the secret + result, found := cache.GetSecret("path/to/secret") + assert.True(t, found) + assert.Equal(t, secret, result) + + // Should also get the list + listResult, found := cache.GetListSecrets("path/to/secret") + assert.True(t, found) + assert.Equal(t, secrets, listResult) + }) + + t.Run("empty path", func(t *testing.T) { + cache := newSecretCache() + secrets := []*models.SecretNode{{Name: "secret", Path: "secret", IsSecret: true}} + + cache.SetListSecrets("", secrets) + + result, found := cache.GetListSecrets("") + assert.True(t, found) + assert.Equal(t, secrets, result) + }) +} + +func TestSecretCache_SetSecret(t *testing.T) { + t.Run("set new entry", func(t *testing.T) { + cache := newSecretCache() + secret := &models.SecretNode{Name: "secret", Path: "path/to/secret", IsSecret: true} + + cache.SetSecret("path/to/secret", secret) + + result, found := cache.GetSecret("path/to/secret") + assert.True(t, found) + assert.Equal(t, secret, result) + }) + + t.Run("update existing entry", func(t *testing.T) { + cache := newSecretCache() + secret1 := &models.SecretNode{Name: "secret1", Path: "path/to/secret", IsSecret: true} + secret2 := &models.SecretNode{Name: "secret2", Path: "path/to/secret", IsSecret: true} + + cache.SetSecret("path/to/secret", secret1) + cache.SetSecret("path/to/secret", secret2) + + result, found := cache.GetSecret("path/to/secret") + assert.True(t, found) + assert.Equal(t, secret2, result) + }) + + t.Run("preserve list when setting secret", func(t *testing.T) { + cache := newSecretCache() + secrets := []*models.SecretNode{{Name: "list", Path: "list", IsSecret: true}} + secret := &models.SecretNode{Name: "secret", Path: "path/to/secret", IsSecret: true} + + cache.SetListSecrets("path/to/secret", secrets) + cache.SetSecret("path/to/secret", secret) + + // Should still get the list + result, found := cache.GetListSecrets("path/to/secret") + assert.True(t, found) + assert.Equal(t, secrets, result) + + // Should also get the secret + secretResult, found := cache.GetSecret("path/to/secret") + assert.True(t, found) + assert.Equal(t, secret, secretResult) + }) + + t.Run("empty path", func(t *testing.T) { + cache := newSecretCache() + secret := &models.SecretNode{Name: "secret", Path: "", IsSecret: true} + + cache.SetSecret("", secret) + + result, found := cache.GetSecret("") + assert.True(t, found) + assert.Equal(t, secret, result) + }) +} + +func TestSecretCache_Invalidate(t *testing.T) { + t.Run("invalidate single path", func(t *testing.T) { + cache := newSecretCache() + secret := &models.SecretNode{Name: "secret", Path: "path/to/secret", IsSecret: true} + cache.SetSecret("path/to/secret", secret) + + cache.Invalidate("path/to/secret") + + result, found := cache.GetSecret("path/to/secret") + assert.False(t, found) + assert.Nil(t, result) + }) + + t.Run("invalidate parent paths", func(t *testing.T) { + cache := newSecretCache() + secret1 := &models.SecretNode{Name: "secret1", Path: "path/to/secret1", IsSecret: true} + secret2 := &models.SecretNode{Name: "secret2", Path: "path/to/secret2", IsSecret: true} + list1 := []*models.SecretNode{{Name: "list", Path: "path/to", IsSecret: false}} + list2 := []*models.SecretNode{{Name: "list", Path: "path", IsSecret: false}} + + cache.SetSecret("path/to/secret1", secret1) + cache.SetSecret("path/to/secret2", secret2) + cache.SetListSecrets("path/to", list1) + cache.SetListSecrets("path", list2) + + cache.Invalidate("path/to/secret1") + + // The secret itself should be invalidated + result, found := cache.GetSecret("path/to/secret1") + assert.False(t, found) + assert.Nil(t, result) + + // Parent path should be invalidated + resultList, found := cache.GetListSecrets("path/to") + assert.False(t, found) + assert.Nil(t, resultList) + + // Grandparent path should be invalidated + resultList2, found := cache.GetListSecrets("path") + assert.False(t, found) + assert.Nil(t, resultList2) + + // Root should be invalidated + resultRoot, found := cache.GetListSecrets("") + assert.False(t, found) + assert.Nil(t, resultRoot) + + // Sibling secret should still be cached (only parent paths are invalidated, not siblings) + // Note: This is the expected behavior - invalidating a path removes it and its ancestors, + // but not sibling paths at the same level + result2, found := cache.GetSecret("path/to/secret2") + assert.True(t, found) + assert.Equal(t, secret2, result2) + }) + + t.Run("invalidate root path", func(t *testing.T) { + cache := newSecretCache() + secret := &models.SecretNode{Name: "secret", Path: "path/to/secret", IsSecret: true} + list := []*models.SecretNode{{Name: "list", Path: "path", IsSecret: false}} + cache.SetSecret("path/to/secret", secret) + cache.SetListSecrets("path", list) + cache.SetListSecrets("", []*models.SecretNode{}) + + cache.Invalidate("") + + result, found := cache.GetListSecrets("") + assert.False(t, found) + assert.Nil(t, result) + + // Child paths should still be cached (only root was invalidated) + resultSecret, found := cache.GetSecret("path/to/secret") + assert.True(t, found) + assert.Equal(t, secret, resultSecret) + }) + + t.Run("invalidate with path normalization", func(t *testing.T) { + cache := newSecretCache() + secret := &models.SecretNode{Name: "secret", Path: "path/to/secret", IsSecret: true} + cache.SetSecret("path/to/secret", secret) + + cache.Invalidate("path//to//secret") + + result, found := cache.GetSecret("path/to/secret") + assert.False(t, found) + assert.Nil(t, result) + }) + + t.Run("invalidate empty path", func(t *testing.T) { + cache := newSecretCache() + list := []*models.SecretNode{{Name: "list", Path: "", IsSecret: false}} + cache.SetListSecrets("", list) + + cache.Invalidate("") + + result, found := cache.GetListSecrets("") + assert.False(t, found) + assert.Nil(t, result) + }) +} + +func TestSecretCache_ConcurrentAccess(t *testing.T) { + cache := newSecretCache() + const numGoroutines = 100 + const numOperations = 10 + + var wg sync.WaitGroup + + // Concurrent writes + wg.Add(numGoroutines) + for i := 0; i < numGoroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + secret := &models.SecretNode{ + Name: "secret", + Path: "path/to/secret", + IsSecret: true, + } + cache.SetSecret("path/to/secret", secret) + cache.GetSecret("path/to/secret") + cache.SetListSecrets("path/to", []*models.SecretNode{secret}) + cache.GetListSecrets("path/to") + } + }(i) + } + + // Concurrent invalidations + wg.Add(numGoroutines) + for i := 0; i < numGoroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + cache.Invalidate("path/to/secret") + } + }(i) + } + + wg.Wait() + + // Verify cache is still functional + secret := &models.SecretNode{Name: "final", Path: "final", IsSecret: true} + cache.SetSecret("final", secret) + result, found := cache.GetSecret("final") + assert.True(t, found) + assert.Equal(t, secret, result) +} + +func TestSecretCache_CombinedOperations(t *testing.T) { + cache := newSecretCache() + + // Set both list and secret for the same path + secrets := []*models.SecretNode{ + {Name: "secret1", Path: "path/to/secret1", IsSecret: true}, + {Name: "secret2", Path: "path/to/secret2", IsSecret: true}, + } + secret := &models.SecretNode{Name: "secret", Path: "path/to/secret", IsSecret: true} + + cache.SetListSecrets("path/to", secrets) + cache.SetSecret("path/to/secret", secret) + + // Both should be retrievable + listResult, found := cache.GetListSecrets("path/to") + assert.True(t, found) + assert.Equal(t, secrets, listResult) + + secretResult, found := cache.GetSecret("path/to/secret") + assert.True(t, found) + assert.Equal(t, secret, secretResult) + + // Invalidate should remove both + cache.Invalidate("path/to/secret") + + listResult2, found := cache.GetListSecrets("path/to") + assert.False(t, found) + assert.Nil(t, listResult2) + + secretResult2, found := cache.GetSecret("path/to/secret") + assert.False(t, found) + assert.Nil(t, secretResult2) +} diff --git a/internal/backend/secrets_interactor.go b/internal/backend/secrets_interactor.go index a3c7073..4fbc49f 100644 --- a/internal/backend/secrets_interactor.go +++ b/internal/backend/secrets_interactor.go @@ -16,15 +16,91 @@ type SecretsInteractor interface { type secretsInteractor struct { name string logger *logrus.Logger - engines.SecretClient + client engines.SecretClient + cache *secretCache } func newSecretsInteractor(logger *logrus.Logger, name string, client engines.SecretClient) SecretsInteractor { return &secretsInteractor{ - logger: logger, - name: name, - SecretClient: client, + logger: logger, + name: name, + client: client, + cache: newSecretCache(), + } +} + +// ListSecrets retrieves secrets at the given path, using cache if available +func (i *secretsInteractor) ListSecrets(path string) ([]*models.SecretNode, error) { + // Check cache + if secrets, found := i.cache.GetListSecrets(path); found { + return secrets, nil + } + + // Cache miss, fetch from underlying client + secrets, err := i.client.ListSecrets(path) + if err != nil { + return nil, err + } + + // Store in cache + i.cache.SetListSecrets(path, secrets) + + return secrets, nil +} + +// GetSecret retrieves a secret at the given path, using cache if available +func (i *secretsInteractor) GetSecret(path string) (*models.SecretNode, error) { + // Check cache + if secret, found := i.cache.GetSecret(path); found { + return secret, nil + } + + // Cache miss, fetch from underlying client + secret, err := i.client.GetSecret(path) + if err != nil { + return nil, err } + + // Store in cache + i.cache.SetSecret(path, secret) + + return secret, nil +} + +// CreateSecret creates a secret and invalidates relevant cache entries +func (i *secretsInteractor) CreateSecret(path string, data map[string]any) error { + err := i.client.CreateSecret(path, data) + if err != nil { + return err + } + + // Invalidate cache for this path and parent paths + i.cache.Invalidate(path) + return nil +} + +// UpdateSecret updates a secret and invalidates relevant cache entries +func (i *secretsInteractor) UpdateSecret(path string, data map[string]any) error { + err := i.client.UpdateSecret(path, data) + if err != nil { + return err + } + + // Invalidate cache for this path and parent paths + i.cache.Invalidate(path) + return nil +} + +// DeleteSecret deletes a secret and invalidates relevant cache entries +func (i *secretsInteractor) DeleteSecret(path string) error { + err := i.client.DeleteSecret(path) + if err != nil { + return err + } + + // Invalidate cache for this path and parent paths + i.cache.Invalidate(path) + return nil } func (i *secretsInteractor) BuildTree(rootPath string, maxDepth int) (*models.SecretNode, error) {