From 39acd159137cd0f83135c13f17e97bf4fc9a06f0 Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Mon, 18 May 2026 11:05:21 +0300 Subject: [PATCH 01/31] chore: empty commit to keep the parent PR opened From d3b72145c874dfe04620bebeb70292afb13b6c7e Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Mon, 18 May 2026 11:11:44 +0300 Subject: [PATCH 02/31] feat: add interfaces, errors and config types --- kv/config.go | 60 +++++++++++++++++++++++++++++++++ kv/errors.go | 38 +++++++++++++++++++++ kv/provider.go | 75 +++++++++++++++++++++++++++++++++++++++++ kv/registry/registry.go | 73 +++++++++++++++++++++++++++++++++++++++ kv/resolver/resolver.go | 54 +++++++++++++++++++++++++++++ 5 files changed, 300 insertions(+) create mode 100644 kv/config.go create mode 100644 kv/errors.go create mode 100644 kv/provider.go create mode 100644 kv/registry/registry.go create mode 100644 kv/resolver/resolver.go diff --git a/kv/config.go b/kv/config.go new file mode 100644 index 00000000..ad1d364d --- /dev/null +++ b/kv/config.go @@ -0,0 +1,60 @@ +package kv + +import ( + "encoding/json" +) + +// KVConfig represents the top-level "kv" configuration block in component configs. +// It contains global settings and named store definitions. +// +// Example JSON structure: +// +// { +// "kv": { +// "cache": {"enabled": true, "ttl": "60s"}, +// "stores": { +// "vault-prod": {"type": "vault", "required": true, "config": {...}} +// } +// } +// } +type KVConfig struct { + Stores map[string]StoreConfig `json:"stores"` + Cache CacheConfig `json:"cache"` +} + +// StoreConfig defines the configuration for a single named KV store instance. +type StoreConfig struct { + // Type specifies which provider factory to use. + Type string `json:"type"` + + // Required determines startup behavior if the store fails to initialize. + Required bool `json:"required"` + + // Config contains provider-specific configuration as raw JSON. + // Each provider's factory knows how to parse its own config format. + Config json.RawMessage `json:"config"` +} + +// CacheConfig controls the caching behavior for resolved secrets. +type CacheConfig struct { + // Enabled controls whether resolved secrets are cached in memory + Enabled bool `json:"enabled"` + + // TTL specifies how long cached values remain valid before refresh. + // Format: Go duration string (e.g., "60s", "5m", "1h") + TTL string `json:"ttl"` + + // RefreshBeforeExpiry specifies the threshold before TTL expiration when a background + // refresh is proactively triggered. It must be less than TTL. + // Format: Go duration string (e.g., "10s"). 0s or empty disables background refresh. + RefreshBeforeExpiry string `json:"refresh_before_expiry"` + + // NegativeTTLNotFound specifies how long to cache "key not found" errors. + // This is typically longer than transient errors as missing keys rarely resolve quickly. + NegativeTTLNotFound string `json:"negative_ttl_not_found"` + + // NegativeTTLTransient specifies how long to cache transient provider errors + // (e.g., network timeouts, service unavailable) to prevent hammering a failing provider. + // This should typically be short to allow quick recovery. + NegativeTTLTransient string `json:"negative_ttl_transient"` +} diff --git a/kv/errors.go b/kv/errors.go new file mode 100644 index 00000000..a7f0f9f1 --- /dev/null +++ b/kv/errors.go @@ -0,0 +1,38 @@ +package kv + +import ( + "errors" + "fmt" +) + +// ErrStoreNotFound is returned when referencing an unregistered store name. +var ErrStoreNotFound = errors.New("store not found") + +func NewStoreNotFoundError(storeName string) error { + return fmt.Errorf("store %q: %w", storeName, ErrStoreNotFound) +} + +// KeyNotFoundError indicates the store is reachable but the key does not exist. +type KeyNotFoundError struct { + StoreName string + KeyPath string +} + +func (e *KeyNotFoundError) Error() string { + return fmt.Sprintf("key %q not found in store %q", e.KeyPath, e.StoreName) +} + +// StoreUnavailableError indicates a transient failure reaching the store. +type StoreUnavailableError struct { + StoreName string + KeyPath string + Err error +} + +func (e *StoreUnavailableError) Error() string { + return fmt.Sprintf("store %q unavailable when fetching key %q: %v", e.StoreName, e.KeyPath, e.Err) +} + +func (e *StoreUnavailableError) Unwrap() error { + return e.Err +} diff --git a/kv/provider.go b/kv/provider.go new file mode 100644 index 00000000..6732a93f --- /dev/null +++ b/kv/provider.go @@ -0,0 +1,75 @@ +package kv + +import ( + "context" + "encoding/json" +) + +// KeyValueRetriever defines the core read capability for retrieving values by key. +type KeyValueRetriever interface { + Get(ctx context.Context, key string) (string, error) +} + +// Provider is the composite interface that all KV providers must implement. +// Currently only requires read access via KeyValueRetriever, but designed +// for future expansion. +// +// Providers may optionally implement Initializer, Closer, HealthChecker, +// or Lister interfaces for additional capabilities that will be detected +// via type assertion during registry operations. +type Provider interface { + KeyValueRetriever +} + +// ProviderFactory creates a specific provider instance from raw JSON configuration. +// Each provider type registers its own factory function that knows how to parse +// its specific configuration format and return a configured Provider. +// +// The factory pattern allows the registry to create providers dynamically +// without compile-time dependencies on specific provider implementations. +type ProviderFactory func(config json.RawMessage) (Provider, error) + +// Initializer is an optional interface for providers that require network +// initialization or connection establishment before use. +type Initializer interface { + Init(ctx context.Context) error +} + +// Lister is an optional interface for providers that support enumerating +// keys by prefix. This enables dynamic discovery of available secrets +// and operational tooling. +type Lister interface { + List(ctx context.Context, prefix string) ([]string, error) +} + +// Closer is an optional interface for providers that need graceful shutdown +// or resource cleanup when the registry is closed. +type Closer interface { + Close(ctx context.Context) error +} + +// AsLister attempts to extract a Lister from a Provider, automatically unwrapping decorators. +func AsLister(p Provider) (Lister, bool) { + if l, ok := p.(Lister); ok { + return l, true + } + + if wrapper, ok := p.(interface{ Unwrap() Provider }); ok { + return AsLister(wrapper.Unwrap()) + } + + return nil, false +} + +// AsInitializer attempts to extract an Initializer from a Provider. +func AsInitializer(p Provider) (Initializer, bool) { + if i, ok := p.(Initializer); ok { + return i, true + } + + if wrapper, ok := p.(interface{ Unwrap() Provider }); ok { + return AsInitializer(wrapper.Unwrap()) + } + + return nil, false +} diff --git a/kv/registry/registry.go b/kv/registry/registry.go new file mode 100644 index 00000000..177ffdea --- /dev/null +++ b/kv/registry/registry.go @@ -0,0 +1,73 @@ +package registry + +import ( + "context" + "sync" + + "github.com/TykTechnologies/storage/kv" +) + +// Registry manages provider factories and initialized stores without global state. +// It provides a clean separation between provider registration (factories) and +// runtime instances (stores), enabling components to control their own KV lifecycle. +// +// All operations are safe for concurrent use. +type Registry struct { + factories map[string]kv.ProviderFactory + stores map[string]kv.Provider + mu sync.RWMutex +} + +// NewRegistry creates a new empty registry with no registered factories or stores. +func NewRegistry() *Registry { + return &Registry{ + factories: make(map[string]kv.ProviderFactory), + stores: make(map[string]kv.Provider), + } +} + +// Add registers a provider factory for the given provider type. +// The providerType should match the "type" field used in store configurations. +// +// Adding a factory with the same providerType will overwrite the previous factory. +func (r *Registry) Add(providerType string, factory kv.ProviderFactory) { + r.mu.Lock() + r.factories[providerType] = factory + r.mu.Unlock() +} + +// InitStores initializes named store instances using registered provider factories. +// The configs map keys become the store names used in KV references. +// +// If a store is marked as required:true and fails to initialize, InitStores +// returns an error. Optional stores (required:false) log warnings but don't +// fail the initialization process. +// +// Example config: +// +// { +// "vault-prod": {"type": "vault", "required": true, "config": {...}}, +// "aws-dev": {"type": "aws", "required": false, "config": {...}} +// } +func (r *Registry) InitStores(configs map[string]kv.StoreConfig) error { + return nil +} + +// GetStore retrieves an initialized store by name. +// Returns ErrStoreNotFound if no store with the given name was initialized. +func (r *Registry) GetStore(name string) (kv.Provider, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + store, ok := r.stores[name] + if !ok { + return nil, kv.NewStoreNotFoundError(name) + } + + return store, nil +} + +// Close gracefully shuts down all initialized stores. +func (r *Registry) Close(ctx context.Context) error { + return nil +} diff --git a/kv/resolver/resolver.go b/kv/resolver/resolver.go new file mode 100644 index 00000000..d354d3ee --- /dev/null +++ b/kv/resolver/resolver.go @@ -0,0 +1,54 @@ +package resolver + +import ( + "context" + + "github.com/TykTechnologies/storage/kv/registry" +) + +// Resolver handles string replacement for KV references in configuration strings. +// It supports two syntax patterns: +// - Whole-value references: "kv://store-name/path/to/secret#field" +// - Inline references: "https://$kv{store-name:path/to/secret#field}/api/v1" +// +// The resolver works against a registry of named stores, allowing the same +// syntax to work across different provider types (Vault, Consul, AWS, etc.). +// +// JSON field extraction is supported via the #field syntax using JSON Pointer +// notation for nested field access. +type Resolver interface { + // Resolve processes the input string and replaces any KV references with + // their resolved values from the configured stores. + // + // Returns the resolved string with all KV references replaced, or an error + // if any reference cannot be resolved. + // + // If the input contains no KV references, it is returned unchanged. + Resolve(ctx context.Context, input string) (string, error) +} + +// ResolveConfig processes an entire configuration and resolves any +// KV references found within string fields. +// +// This function enables config-level resolution during component startup, +// allowing any string field in any configuration structure to contain +// KV references that will be resolved before the config is used. +// +// The resolver traverses the JSON structure recursively, applying Resolve() +// to all string values while preserving the overall structure and non-string +// fields unchanged. +func ResolveConfig(ctx context.Context, resolver Resolver, rawConfig []byte) ([]byte, error) { + return nil, nil +} + +type resolver struct { + registry *registry.Registry +} + +func NewResolver(registry *registry.Registry) Resolver { + return &resolver{registry: registry} +} + +func (r *resolver) Resolve(ctx context.Context, input string) (string, error) { + return "", nil +} From b2df469544301b78ba6b1db6e9a39ff3ec6e802f Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Mon, 18 May 2026 13:44:29 +0300 Subject: [PATCH 03/31] build: add test-kv to make sonarqube to get corrent coverage --- .github/workflows/ci-tests.yml | 4 ++++ Taskfile.yml | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index b4b199de..7c514b55 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -114,6 +114,10 @@ jobs: database: redis version: 7.0.0 instancetype: TLS + - storagetype: kv + database: none + version: none + instancetype: none steps: - name: Generate GitHub App token diff --git a/Taskfile.yml b/Taskfile.yml index 731f9437..e05eda32 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -62,3 +62,11 @@ tasks: DB_VERSION: "{{.DB_VERSION}}" - mkdir -p coverage/temporal - cp temporal/*.cov coverage/temporal/ + + test-kv: + desc: "Run tests for kv storage" + cmds: + - task: run-tests + vars: + STORAGE_TYPE: kv + DB: "none" From 42c043f89c32ed487e60eea8237b6d304cb975e5 Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Tue, 19 May 2026 07:31:32 +0300 Subject: [PATCH 04/31] feat: add AsCloser extracting func --- kv/provider.go | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/kv/provider.go b/kv/provider.go index 6732a93f..2a1367e8 100644 --- a/kv/provider.go +++ b/kv/provider.go @@ -48,7 +48,8 @@ type Closer interface { Close(ctx context.Context) error } -// AsLister attempts to extract a Lister from a Provider, automatically unwrapping decorators. +// AsLister attempts to extract a Lister from a Provider, +// automatically unwrapping decorators. func AsLister(p Provider) (Lister, bool) { if l, ok := p.(Lister); ok { return l, true @@ -61,7 +62,8 @@ func AsLister(p Provider) (Lister, bool) { return nil, false } -// AsInitializer attempts to extract an Initializer from a Provider. +// AsInitializer attempts to extract an Initializer from a Provider, +// automatically unwrapping decorators. func AsInitializer(p Provider) (Initializer, bool) { if i, ok := p.(Initializer); ok { return i, true @@ -73,3 +75,17 @@ func AsInitializer(p Provider) (Initializer, bool) { return nil, false } + +// AsCloser attempts to extract an Closer from a Provider, +// automatically unwrapping decorators. +func AsCloser(p Provider) (Closer, bool) { + if c, ok := p.(Closer); ok { + return c, true + } + + if wrapper, ok := p.(interface{ Unwrap() Provider }); ok { + return AsCloser(wrapper.Unwrap()) + } + + return nil, false +} From f55c322278d8b0ccba97484ae688442ca74aca1c Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Wed, 20 May 2026 12:26:36 +0300 Subject: [PATCH 05/31] feat: add logger --- kv/logger.go | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 kv/logger.go diff --git a/kv/logger.go b/kv/logger.go new file mode 100644 index 00000000..f7f20cad --- /dev/null +++ b/kv/logger.go @@ -0,0 +1,11 @@ +package kv + +type Logger interface { + Warn(msg string, fields map[string]any) + Warnf(format string, args ...any) +} + +type NoopLogger struct{} + +func (NoopLogger) Warn(_ string, _ map[string]any) {} +func (NoopLogger) Warnf(_ string, _ ...any) {} From 4431a720f50ffcde36cb2ab8c348025a28e8b87d Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Wed, 20 May 2026 14:55:08 +0300 Subject: [PATCH 06/31] fix: change KVConfig to Config name for type --- kv/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kv/config.go b/kv/config.go index ad1d364d..3da5abb5 100644 --- a/kv/config.go +++ b/kv/config.go @@ -4,7 +4,7 @@ import ( "encoding/json" ) -// KVConfig represents the top-level "kv" configuration block in component configs. +// Config represents the top-level "kv" configuration block in component configs. // It contains global settings and named store definitions. // // Example JSON structure: @@ -17,7 +17,7 @@ import ( // } // } // } -type KVConfig struct { +type Config struct { Stores map[string]StoreConfig `json:"stores"` Cache CacheConfig `json:"cache"` } From 92b89d597a08217061880bb63e202875a64bb30e Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Thu, 21 May 2026 09:23:50 +0300 Subject: [PATCH 07/31] refactor: rework as funcs to avoid recursion and self-reference problem --- kv/provider.go | 50 ++++++++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/kv/provider.go b/kv/provider.go index 2a1367e8..b8f16417 100644 --- a/kv/provider.go +++ b/kv/provider.go @@ -51,41 +51,43 @@ type Closer interface { // AsLister attempts to extract a Lister from a Provider, // automatically unwrapping decorators. func AsLister(p Provider) (Lister, bool) { - if l, ok := p.(Lister); ok { - return l, true - } - - if wrapper, ok := p.(interface{ Unwrap() Provider }); ok { - return AsLister(wrapper.Unwrap()) - } - - return nil, false + return As[Lister](p) } // AsInitializer attempts to extract an Initializer from a Provider, // automatically unwrapping decorators. func AsInitializer(p Provider) (Initializer, bool) { - if i, ok := p.(Initializer); ok { - return i, true - } - - if wrapper, ok := p.(interface{ Unwrap() Provider }); ok { - return AsInitializer(wrapper.Unwrap()) - } - - return nil, false + return As[Initializer](p) } // AsCloser attempts to extract an Closer from a Provider, // automatically unwrapping decorators. func AsCloser(p Provider) (Closer, bool) { - if c, ok := p.(Closer); ok { - return c, true - } + return As[Closer](p) +} + +// As attempts to extract an interface of type T from a Provider, +// automatically unwrapping decorators up to a maximum depth. +func As[T any](p Provider) (T, bool) { + const maxDepth = 100 + var zero T + + for range maxDepth { + if p == nil { + return zero, false + } + + if v, ok := p.(T); ok { + return v, true + } + + wrapper, ok := p.(interface{ Unwrap() Provider }) + if !ok { + return zero, false + } - if wrapper, ok := p.(interface{ Unwrap() Provider }); ok { - return AsCloser(wrapper.Unwrap()) + p = wrapper.Unwrap() } - return nil, false + return zero, false } From cd76f9c98f4730f058929034ff48713d04c77a9f Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Thu, 21 May 2026 10:16:38 +0300 Subject: [PATCH 08/31] feat: add provider type with constants --- kv/config.go | 2 +- kv/provider.go | 36 ++++++++++++++++++++++++++++++++++++ kv/registry/registry.go | 8 ++++---- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/kv/config.go b/kv/config.go index 3da5abb5..f8e6ed56 100644 --- a/kv/config.go +++ b/kv/config.go @@ -25,7 +25,7 @@ type Config struct { // StoreConfig defines the configuration for a single named KV store instance. type StoreConfig struct { // Type specifies which provider factory to use. - Type string `json:"type"` + Type ProviderType `json:"type"` // Required determines startup behavior if the store fails to initialize. Required bool `json:"required"` diff --git a/kv/provider.go b/kv/provider.go index b8f16417..afa23e1b 100644 --- a/kv/provider.go +++ b/kv/provider.go @@ -5,6 +5,42 @@ import ( "encoding/json" ) +// ProviderType represents the unique string identifier for a KV provider. +type ProviderType string + +const ( + // --- Open Source (OSS) Providers --- + + // Env resolves secrets from environment variables. + Env ProviderType = "env" + + // Inline resolves secrets from plain text in the configuration. + Inline ProviderType = "inline" + + // Vault resolves secrets from HashiCorp Vault. + Vault ProviderType = "hashicorp_vault" + + // Consul resolves secrets from HashiCorp Consul. + Consul ProviderType = "hashicorp_consul" + + // K8s resolves secrets from Kubernetes Secrets mounted as files. + K8s ProviderType = "k8s_files" + + // --- Enterprise Edition (EE) Providers --- + + // AWS resolves secrets from AWS Secrets Manager. + AWS ProviderType = "aws_secrets_manager" + + // GCP resolves secrets from Google Cloud Secret Manager. + GCP ProviderType = "gcp_secret_manager" + + // Azure resolves secrets from Azure Key Vault. + Azure ProviderType = "azure_key_vault" + + // Conjur resolves secrets from CyberArk Conjur. + Conjur ProviderType = "cyberark_conjur" +) + // KeyValueRetriever defines the core read capability for retrieving values by key. type KeyValueRetriever interface { Get(ctx context.Context, key string) (string, error) diff --git a/kv/registry/registry.go b/kv/registry/registry.go index 177ffdea..02e8590f 100644 --- a/kv/registry/registry.go +++ b/kv/registry/registry.go @@ -13,7 +13,7 @@ import ( // // All operations are safe for concurrent use. type Registry struct { - factories map[string]kv.ProviderFactory + factories map[kv.ProviderType]kv.ProviderFactory stores map[string]kv.Provider mu sync.RWMutex } @@ -21,7 +21,7 @@ type Registry struct { // NewRegistry creates a new empty registry with no registered factories or stores. func NewRegistry() *Registry { return &Registry{ - factories: make(map[string]kv.ProviderFactory), + factories: make(map[kv.ProviderType]kv.ProviderFactory), stores: make(map[string]kv.Provider), } } @@ -30,9 +30,9 @@ func NewRegistry() *Registry { // The providerType should match the "type" field used in store configurations. // // Adding a factory with the same providerType will overwrite the previous factory. -func (r *Registry) Add(providerType string, factory kv.ProviderFactory) { +func (r *Registry) Add(pt kv.ProviderType, factory kv.ProviderFactory) { r.mu.Lock() - r.factories[providerType] = factory + r.factories[pt] = factory r.mu.Unlock() } From 05403cd05ec0490393e8aaea52298d3fb31b825e Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi <109525963+vladzabolotnyi@users.noreply.github.com> Date: Mon, 25 May 2026 15:38:03 +0300 Subject: [PATCH 09/31] TT-17238: Implement core caching and secret store wrapper (#136) * feat: add core interfaces * feat: add resolver interface * feat: add registry type with methods * feat: add config types, registry type with empty methods and resolver type and empty methods * feat: add errors and cache surface implementation * feat: update registry and secret store with base impl * feat: add get and set base impl for the cache * feat: update the errors with adding custom types for key not found and store unavailable that would be used by providers * feat: update cache implementation with a set of optimizations * chore: fixing linter errors * fix: move checking for expired keys to the get method with RLock * test: add concurrency test to the cache * chore: clear unused errors * fix: update errors file with linting fixes * feat: add secret store implementation * fix: replace batch approah on cache cleanup with write lock and clean in place to avoid race conditions and make code simpler * feat: update negative caching to avoid storing context errors * feat: update secret store implementation with correct handling of singleflight and add tests validating the logic * test: update store tests with missing edge cases and reworked tests structure to distinguish edge cases * docs: update config docs string * test: add missing tests for cache * refactor: separate logic for ttl selection * refactor: update tests with using parallel and testing context * docs: add docs to cache get method * refactor: split single package solution to multiple to apply better structure and readability * refactor: use t.Context() instead of background as we have >1.24 Go * feat: rework secret store to make it fit a Provider interface with Unwrap() method and unwrap helpers * refactor: move errors to kv package * fix: update the cache clenup interval to min ttl to avoid casees when ttl is set as too high to rely on * fix: updat foreground fetch with removing handling canceled context because it can be only timed out * feat: rework foreground fetch for secret to avoid blocking request goroutines until they're timeout * feat: rework cache cleanup with separate locks and adding condition to check if its expired between locks * chore: return errro value instead of nil for background fetch * test: update store tests with wrapping them with synctest * refactor: use wg.Go() instead of legacy pattern * refactor: move config to kv package * build: add test-kv to make sonarqube to get corrent coverage * refactor: decrease cognitive complexity for NewCache func * fix: return error intead of loggin * fix: add generic error if the provider returns a non-string which is unreal * fix: pass value instead of whole result type to error * docs: add comment explaining the logic behind a test * chore: make generic error less descriptive * chore: replace fmt.sprintf with string concat * feat: add close methods * feat: add conditions to avoid calling Get methods on secret store and avoid store or return value if its closed * feat: cleanup the cache entries when its closed * fix: add mu locks to the cache close method and tests * feat: add provider timeout enforcement * feat: add separate singleflight group to avoid name collision between foreground and background tasks * fix: add handling of empty ttl field on cache config * TT-17239: Imlement registry (#138) * feat: add implementation for Add and InitStores methods * feat: update init stores with secret store wrapper * feat: add close imlementation to registry * feat: add logger * feat: add initial registry impl * fix: update init stores logic with clearing defer and redundant mutex locks * test: update registry test for close method and init stores edge case * test: update concurrency test * refactor: update registry tests with changing structure * chore: update kv config name * test: add more tests for init stores func * test: add more tests covering edge cases * refactor: smash two defers to one as they reference similar condition * feat: rework Close method to run closing concurrently * feat: add provider type with constants to make code more documented and understandable * chore: update comment * test: add tests covering concurrent logic when close is called while init stores are running * chore: replace literals with constants on condition * feat: update return err logic to avoid redundant assigning with explanatory docs * test: add test to check circular dependency to avoid stack-overflow if self referencing * chore: replace dependency * refactor: simplify logic with isInitialize * refactor: add private func with init a single store to avoid boilerplate code * revert: isInitialized should be used with swap to prevent concurrent requests to initiStores * refactor: update init stores func * refactor: update init stores with detached func * feat: add direct provider interface to fixing open-closed principle * fix: typo * feat: update registry logic with using standalone and timeouter interfaces * feat: rework init stores and make it fully concurrent * chore: remove check for empty stores * chore: add t.Parallel() to tests * fix: distinguish init context with lifetime context and remove dependency between cache context cancelation and init ctx * fix: minor adjustments and comment clean-up --------- Co-authored-by: Vlad Zabolotnyi --------- Co-authored-by: Vlad Zabolotnyi --- kv/errors.go | 14 +- kv/internal/cache/cache.go | 288 ++++++++++++++ kv/internal/cache/cache_test.go | 583 ++++++++++++++++++++++++++++ kv/internal/store/store.go | 180 +++++++++ kv/internal/store/store_test.go | 669 ++++++++++++++++++++++++++++++++ kv/provider.go | 23 ++ kv/provider_test.go | 27 ++ kv/registry/registry.go | 261 ++++++++++++- kv/registry/registry_test.go | 592 ++++++++++++++++++++++++++++ 9 files changed, 2621 insertions(+), 16 deletions(-) create mode 100644 kv/internal/cache/cache.go create mode 100644 kv/internal/cache/cache_test.go create mode 100644 kv/internal/store/store.go create mode 100644 kv/internal/store/store_test.go create mode 100644 kv/provider_test.go create mode 100644 kv/registry/registry_test.go diff --git a/kv/errors.go b/kv/errors.go index a7f0f9f1..8baf8e2d 100644 --- a/kv/errors.go +++ b/kv/errors.go @@ -5,8 +5,18 @@ import ( "fmt" ) -// ErrStoreNotFound is returned when referencing an unregistered store name. -var ErrStoreNotFound = errors.New("store not found") +var ( + // ErrStoreNotFound is returned when referencing an unregistered store name. + ErrStoreNotFound = errors.New("store not found") + + // ErrContractViolation indicates that an underlying KV provider returned data + // violates the expected API contract (e.g., type assertion failures) + ErrContractViolation = errors.New("provider contract violation") + + // ErrStoreClosed is returned when an operation is attempted on closed store or + // provider that has already been shut down via its Close method. + ErrStoreClosed = errors.New("secret store is closed") +) func NewStoreNotFoundError(storeName string) error { return fmt.Errorf("store %q: %w", storeName, ErrStoreNotFound) diff --git a/kv/internal/cache/cache.go b/kv/internal/cache/cache.go new file mode 100644 index 00000000..c6a313bc --- /dev/null +++ b/kv/internal/cache/cache.go @@ -0,0 +1,288 @@ +package cache + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/TykTechnologies/storage/kv" +) + +const ( + defaultTTL = 60 * time.Second + defaultNegativeTTLNotFound = 60 * time.Second + defaultNegativeTTLTransient = 5 * time.Second +) + +// Cache provides TTL-based in-memory caching for secret values. +// It's thread-safe and automatically expires entries based on configured TTL. +type Cache struct { + entries map[string]*cacheEntry + enabled bool + ttl time.Duration + refreshBeforeExpiry time.Duration + negativeTTLNotFound time.Duration + negativeTTLTransient time.Duration + mu sync.RWMutex + done chan struct{} + closeOnce sync.Once + isClosed atomic.Bool +} + +// cacheEntry holds a Cached value with its expiration time +type cacheEntry struct { + value string + err error + expiresAt time.Time +} + +// Get retrieves a Cached value by key and returns metadata about Cache state. +// +// Returns: +// - value: the Cached string value (empty if Cache miss or expired) +// - found: true if a valid (non-expired) Cache entry exists +// - needsRefresh: true if entry exists but is within refreshBeforeExpiry window +// - err: the Cached error from the original fetch operation (nil for successful Cached values) +func (c *Cache) Get(key string) (string, bool, bool, error) { + if !c.enabled || c.isClosed.Load() { + return "", false, false, nil + } + + entry, exists, expired := c.get(key) + if !exists || expired { + return "", false, false, nil + } + + var needsRefresh bool + if c.refreshBeforeExpiry > 0 && time.Until(entry.expiresAt) <= c.refreshBeforeExpiry { + needsRefresh = true + } + + return entry.value, true, needsRefresh, entry.err +} + +func (c *Cache) Set(key, value string, err error) { + if !c.enabled || c.isClosed.Load() { + return + } + + // Context errors should NOT be Cached - they indicate caller abandonment, not provider failure + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return + } + + ttl, shouldCache := c.selectTTL(err) + if !shouldCache { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + + if c.entries == nil { + return + } + + c.entries[key] = &cacheEntry{ + value: value, + expiresAt: time.Now().Add(ttl), + err: err, + } +} + +// Close stops the cleanup goroutine and releases resources. +// It is fully thread-safe and safe to call multiple times. +func (c *Cache) Close() { + if c.done != nil { + c.closeOnce.Do(func() { + c.isClosed.Store(true) + close(c.done) + + c.mu.Lock() + c.entries = nil + c.mu.Unlock() + }) + } +} + +func (c *Cache) selectTTL(err error) (time.Duration, bool) { + if err == nil { + return c.ttl, true + } + + var notFoundErr *kv.KeyNotFoundError + var transientErr *kv.StoreUnavailableError + + if errors.As(err, ¬FoundErr) { + return c.negativeTTLNotFound, true + } + + if errors.As(err, &transientErr) { + return c.negativeTTLTransient, true + } + + return 0, false +} + +func (c *Cache) get(key string) (*cacheEntry, bool, bool) { + now := time.Now() + + c.mu.RLock() + defer c.mu.RUnlock() + + entry, exists := c.entries[key] + + var expired bool + if entry != nil && !now.Before(entry.expiresAt) { + expired = true + } + + return entry, exists, expired +} + +func (c *Cache) cleanupLoop() { + interval := min(c.ttl, c.negativeTTLNotFound, c.negativeTTLTransient) + if interval < time.Second { + interval = time.Second + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-c.done: + return + case <-ticker.C: + c.cleanup() + } + } +} + +func (c *Cache) cleanup() { + now := time.Now() + var expired []string + + c.mu.RLock() + for k, v := range c.entries { + if v == nil || !now.Before(v.expiresAt) { + expired = append(expired, k) + } + } + c.mu.RUnlock() + + if len(expired) == 0 { + return + } + + // Recompute the current time under the write lock to avoid deleting entries + // that were written (or renewed) during the window between RUnlock and Lock. + deleteNow := time.Now() + + c.mu.Lock() + for _, k := range expired { + entry := c.entries[k] + if entry == nil || !deleteNow.Before(entry.expiresAt) { + delete(c.entries, k) + } + } + c.mu.Unlock() +} + +// NewCache creates a TTL-based in-memory cache. When Enabled is true, callers +// must call Close() to stop the background cleanup goroutine and release resources. +func NewCache(config kv.CacheConfig) (*Cache, error) { + if !config.Enabled { + return &Cache{enabled: false, entries: make(map[string]*cacheEntry)}, nil + } + + ttl, err := parseOptionalDuration(config.TTL, defaultTTL, "ttl") + if err != nil { + return nil, err + } + + refreshBeforeExpiry, err := parseRefreshBeforeExpiry(config.RefreshBeforeExpiry, ttl) + if err != nil { + return nil, err + } + + negativeTTLNotFound, err := parseOptionalDuration( + config.NegativeTTLNotFound, + defaultNegativeTTLNotFound, + "negative_ttl_not_found", + ) + if err != nil { + return nil, err + } + + negativeTTLTransient, err := parseOptionalDuration( + config.NegativeTTLTransient, + defaultNegativeTTLTransient, + "negative_ttl_transient", + ) + if err != nil { + return nil, err + } + + c := &Cache{ + entries: make(map[string]*cacheEntry), + enabled: config.Enabled, + ttl: ttl, + refreshBeforeExpiry: refreshBeforeExpiry, + negativeTTLNotFound: negativeTTLNotFound, + negativeTTLTransient: negativeTTLTransient, + done: make(chan struct{}), + } + + go c.cleanupLoop() + + return c, nil +} + +// parseOptionalDuration parses a duration string, returning a default value if empty. +// It also validates that the parsed duration is strictly positive. +func parseOptionalDuration(val string, defaultVal time.Duration, name string) (time.Duration, error) { + if val == "" { + return defaultVal, nil + } + + d, err := time.ParseDuration(val) + if err != nil { + return 0, fmt.Errorf("invalid cache %s value: %w", name, err) + } + + if d <= 0 { + return 0, fmt.Errorf("cache %s must be positive, got %v", name, val) + } + + return d, nil +} + +// parseRefreshBeforeExpiry parses and validates the refresh_before_expiry configuration. +func parseRefreshBeforeExpiry(val string, ttl time.Duration) (time.Duration, error) { + if val == "" { + return 0, nil + } + + d, err := time.ParseDuration(val) + if err != nil { + return 0, fmt.Errorf("invalid cache refresh_before_expiry value: %w", err) + } + + if d < 0 { + return 0, fmt.Errorf("cache refresh_before_expiry must be positive, got %v", val) + } + + if d >= ttl { + return 0, fmt.Errorf( + "refresh_before_expiry(%v) must be less than ttl(%v)", + d, + ttl, + ) + } + + return d, nil +} diff --git a/kv/internal/cache/cache_test.go b/kv/internal/cache/cache_test.go new file mode 100644 index 00000000..988afda7 --- /dev/null +++ b/kv/internal/cache/cache_test.go @@ -0,0 +1,583 @@ +package cache + +import ( + "context" + "fmt" + "sync" + "testing" + "testing/synctest" + "time" + + "github.com/TykTechnologies/storage/kv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCache(t *testing.T) { + t.Parallel() + + t.Run("disable cache", func(t *testing.T) { + cfg := kv.CacheConfig{Enabled: false} + c, err := NewCache(cfg) + require.NoError(t, err) + require.NotNil(t, c) + require.False(t, c.enabled) + }) + + t.Run("invalid TTL", func(t *testing.T) { + cfg := kv.CacheConfig{Enabled: true, TTL: "invalid"} + c, err := NewCache(cfg) + require.Error(t, err) + require.Nil(t, c) + require.Contains(t, err.Error(), "invalid cache ttl") + }) + + t.Run("negative TTL", func(t *testing.T) { + cfg := kv.CacheConfig{Enabled: true, TTL: "-5s"} + c, err := NewCache(cfg) + require.Error(t, err) + require.Nil(t, c) + require.Contains(t, err.Error(), "cache ttl must be positive") + }) + + t.Run("invalid refresh before expiry", func(t *testing.T) { + cfg := kv.CacheConfig{Enabled: true, TTL: "1s", RefreshBeforeExpiry: "some"} + c, err := NewCache(cfg) + require.Error(t, err) + require.Nil(t, c) + require.Contains(t, err.Error(), "invalid cache refresh_before_expiry") + }) + + t.Run("negative refresh before expiry", func(t *testing.T) { + cfg := kv.CacheConfig{Enabled: true, TTL: "1s", RefreshBeforeExpiry: "-1s"} + c, err := NewCache(cfg) + require.Error(t, err) + require.Nil(t, c) + require.Contains(t, err.Error(), "refresh_before_expiry must be positive") + }) + + t.Run("invalid negative ttl not found", func(t *testing.T) { + cfg := kv.CacheConfig{Enabled: true, TTL: "1s", NegativeTTLNotFound: "some"} + c, err := NewCache(cfg) + require.Error(t, err) + require.Nil(t, c) + require.Contains(t, err.Error(), "invalid cache negative_ttl_not_found") + }) + + t.Run("negative value for negative ttl not found", func(t *testing.T) { + cfg := kv.CacheConfig{Enabled: true, TTL: "1s", NegativeTTLNotFound: "-1s"} + c, err := NewCache(cfg) + require.Error(t, err) + require.Nil(t, c) + require.Contains(t, err.Error(), "negative_ttl_not_found must be positive") + }) + + t.Run("invalid negative ttl transient", func(t *testing.T) { + cfg := kv.CacheConfig{Enabled: true, TTL: "1s", NegativeTTLTransient: "some"} + c, err := NewCache(cfg) + require.Error(t, err) + require.Nil(t, c) + require.Contains(t, err.Error(), "invalid cache negative_ttl_transient") + }) + + t.Run("negative value for negative ttl transient", func(t *testing.T) { + cfg := kv.CacheConfig{Enabled: true, TTL: "1s", NegativeTTLTransient: "-1s"} + c, err := NewCache(cfg) + require.Error(t, err) + require.Nil(t, c) + require.Contains(t, err.Error(), "negative_ttl_transient must be positive") + }) + + t.Run("returns error when refresh before expiry >= ttl", func(t *testing.T) { + cfg := kv.CacheConfig{Enabled: true, TTL: "1s", RefreshBeforeExpiry: "1s"} + c, err := NewCache(cfg) + require.Error(t, err) + require.Contains(t, err.Error(), "must be less than ttl") + require.Nil(t, c) + }) + + t.Run("sets correct defaults", func(t *testing.T) { + cfg := kv.CacheConfig{Enabled: true} + c, err := NewCache(cfg) + require.NoError(t, err) + require.NotNil(t, c) + require.Equal(t, defaultTTL, c.ttl) + require.Empty(t, c.refreshBeforeExpiry) + require.Equal(t, defaultNegativeTTLNotFound, c.negativeTTLNotFound) + require.Equal(t, defaultNegativeTTLTransient, c.negativeTTLTransient) + }) + + t.Run("valid config", func(t *testing.T) { + cfg := kv.CacheConfig{ + Enabled: true, + TTL: "100ms", + RefreshBeforeExpiry: "50ms", + NegativeTTLNotFound: "20s", + NegativeTTLTransient: "2s", + } + c := newTestCache(t, cfg) + require.NotNil(t, c) + require.Equal(t, 100*time.Millisecond, c.ttl) + require.Equal(t, 50*time.Millisecond, c.refreshBeforeExpiry) + require.Equal(t, 20*time.Second, c.negativeTTLNotFound) + require.Equal(t, 2*time.Second, c.negativeTTLTransient) + require.NotNil(t, c.entries) + }) +} + +func TestCache_GetSet(t *testing.T) { + t.Parallel() + + defaultConfig := kv.CacheConfig{ + Enabled: true, + TTL: "500ms", + } + c, err := NewCache(defaultConfig) + require.NoError(t, err) + + t.Run("cache disabled", func(t *testing.T) { + cfg := kv.CacheConfig{Enabled: false, TTL: "500ms", RefreshBeforeExpiry: "200ms"} + c := newTestCache(t, cfg) + require.NoError(t, err) + + c.Set("cache-disabled", "some", nil) + + val, exists, needsRefresh, err := c.Get("cache-disabled") + assert.False(t, exists) + assert.False(t, needsRefresh) + assert.Empty(t, val) + assert.NoError(t, err) + }) + + t.Run("cache miss", func(t *testing.T) { + val, exists, needsRefresh, err := c.Get("non-existent") + assert.False(t, exists) + assert.False(t, needsRefresh) + assert.Empty(t, val) + assert.NoError(t, err) + }) + + t.Run("cache hit", func(t *testing.T) { + c.Set("key1", "value1", nil) + + val, exists, needsRefresh, err := c.Get("key1") + assert.True(t, exists) + assert.False(t, needsRefresh) + assert.Equal(t, "value1", val) + assert.NoError(t, err) + }) + + t.Run("negative caching with KeyNotFoundError", func(t *testing.T) { + expectedErr := &kv.KeyNotFoundError{} + c.Set("key2", "", expectedErr) + + val, exists, needsRefresh, err := c.Get("key2") + assert.True(t, exists) + assert.False(t, needsRefresh) + assert.Empty(t, val) + assert.ErrorAs(t, err, &expectedErr) + + entry, _, _ := c.get("key2") + expectedTTL := time.Now().Add(defaultNegativeTTLNotFound) + require.WithinDuration(t, expectedTTL, entry.expiresAt, time.Second) + }) + + t.Run("negative caching with StoreUnavailableError", func(t *testing.T) { + expectedErr := &kv.StoreUnavailableError{} + c.Set("key3", "", expectedErr) + + val, exists, needsRefresh, err := c.Get("key3") + assert.True(t, exists) + assert.False(t, needsRefresh) + assert.Empty(t, val) + assert.ErrorAs(t, err, &expectedErr) + + entry, _, _ := c.get("key3") + expectedTTL := time.Now().Add(defaultNegativeTTLTransient) + require.WithinDuration(t, expectedTTL, entry.expiresAt, time.Second) + }) + + t.Run("negative caching is disabled for context errors", func(t *testing.T) { + c.Set("key4", "value4", context.Canceled) + c.Set("key5", "value5", context.DeadlineExceeded) + + val, exists, needsRefresh, err := c.Get("key4") + assert.False(t, exists) + assert.False(t, needsRefresh) + assert.Empty(t, val) + assert.NoError(t, err) + + val, exists, needsRefresh, err = c.Get("key5") + assert.False(t, exists) + assert.False(t, needsRefresh) + assert.Empty(t, val) + assert.NoError(t, err) + + entry, _, _ := c.get("key4") + require.Empty(t, entry) + + entry, _, _ = c.get("key5") + require.Empty(t, entry) + }) +} + +func TestCache_RefreshBeforeExpiry(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + cfg := kv.CacheConfig{ + Enabled: true, + TTL: "2s", + RefreshBeforeExpiry: "1s", + } + c := newTestCache(t, cfg) + c.Set("key1", "value1", nil) + time.Sleep(time.Second) + + val, exists, needsRefresh, err := c.Get("key1") + assert.True(t, exists) + assert.True(t, needsRefresh) + assert.Equal(t, "value1", val) + assert.NoError(t, err) + }) +} + +func TestCache_RefreshBeforeExpiryBoundary(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + cfg := kv.CacheConfig{ + Enabled: true, + TTL: "1s", + RefreshBeforeExpiry: "500ms", + } + c := newTestCache(t, cfg) + c.Set("key1", "value1", nil) + + // Just before refresh window + time.Sleep(490 * time.Millisecond) + synctest.Wait() + + _, _, needsRefresh, err := c.Get("key1") + assert.NoError(t, err) + assert.False(t, needsRefresh, "Should not need refresh yet") + + time.Sleep(20 * time.Millisecond) + synctest.Wait() + + _, _, needsRefresh, err = c.Get("key1") + assert.NoError(t, err) + assert.True(t, needsRefresh, "Should need refresh now") + }) +} + +func TestCache_ZeroRefreshBeforeExpiry(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + cfg := kv.CacheConfig{ + Enabled: true, + TTL: "1s", + RefreshBeforeExpiry: "0s", + } + c := newTestCache(t, cfg) + + c.Set("key1", "value1", nil) + + time.Sleep(900 * time.Millisecond) + synctest.Wait() + + _, _, needsRefresh, err := c.Get("key1") + require.NoError(t, err) + assert.False(t, needsRefresh, "Zero refresh window should never trigger refresh") + }) +} + +func TestCache_NegativeCachingBoundaries(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + cfg := kv.CacheConfig{ + Enabled: true, + TTL: "1s", + NegativeTTLNotFound: "100ms", + } + c := newTestCache(t, cfg) + + c.Set("short-not-found", "", &kv.KeyNotFoundError{}) + + time.Sleep(60 * time.Millisecond) + synctest.Wait() + + _, exists, _, err := c.Get("short-not-found") + require.Error(t, err) + assert.True(t, exists, "Should still exist before negative TTL expires") + + time.Sleep(50 * time.Millisecond) + synctest.Wait() + + _, exists, _, err = c.Get("short-not-found") + require.NoError(t, err) + assert.False(t, exists, "Should expire after negative TTL") + }) +} + +func TestCache_OverwriteExistingEntry(t *testing.T) { + t.Parallel() + + cfg := kv.CacheConfig{Enabled: true, TTL: "1s"} + c := newTestCache(t, cfg) + + // Set initial value + c.Set("key1", "value1", nil) + val, exists, _, err := c.Get("key1") + assert.True(t, exists) + assert.Equal(t, "value1", val) + assert.NoError(t, err) + + // Overwrite with error + c.Set("key1", "", &kv.KeyNotFoundError{}) + val, exists, _, err = c.Get("key1") + assert.True(t, exists) + assert.Empty(t, val) + assert.Error(t, err) + + // Overwrite error with success + c.Set("key1", "value2", nil) + val, exists, _, err = c.Get("key1") + assert.True(t, exists) + assert.Equal(t, "value2", val) + assert.NoError(t, err) +} + +func TestCache_UnknownErrorTypes(t *testing.T) { + t.Parallel() + + cfg := kv.CacheConfig{Enabled: true, TTL: "1s"} + c := newTestCache(t, cfg) + + unknownErr := fmt.Errorf("some random error") + c.Set("key1", "value1", unknownErr) + + _, exists, _, err := c.Get("key1") + require.NoError(t, err) + assert.False(t, exists, "Unknown errors should not be cached") +} + +func TestCache_CleanupIntervalScaling(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + cfg := kv.CacheConfig{ + Enabled: true, + TTL: "100ms", + } + c := newTestCache(t, cfg) + + c.Set("key1", "value1", nil) + + time.Sleep(150 * time.Millisecond) + synctest.Wait() + + // Value is expired but still physically exists + _, exists, _, err := c.Get("key1") + require.NoError(t, err) + assert.False(t, exists) + + _, physicallyExists, _ := c.get("key1") + assert.True(t, physicallyExists, "Should be physically present as cleanup hasn't run yet") + + // Wait for cleanup interval (should be 1s minimum) + time.Sleep(2 * time.Second) + synctest.Wait() + + _, physicallyExists, _ = c.get("key1") + assert.False(t, physicallyExists, "Should be physically removed after cleanup") + }) +} + +func TestCache_CleanupExpiredEntries(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + cfg := kv.CacheConfig{ + Enabled: true, + TTL: "2s", + NegativeTTLNotFound: "10s", + NegativeTTLTransient: "4s", + } + c := newTestCache(t, cfg) + + testEntries := []struct { + key string + value string + err error + description string + }{ + {"key1", "value1", nil, "normal entry (2s TTL)"}, + {"key2", "value2", &kv.KeyNotFoundError{}, "not found entry (10s TTL)"}, + {"key3", "value3", &kv.StoreUnavailableError{}, "transient error entry (5s TTL)"}, + } + + for _, entry := range testEntries { + c.Set(entry.key, entry.value, entry.err) + } + + // Phase 1: Verify all entries are initially present and accessible + assertCacheEntry(t, c, "key1", "value1", true, false, "normal entry should be accessible") + assertCacheEntry(t, c, "key2", "value2", true, true, "not found entry should be accessible with error") + assertCacheEntry(t, c, "key3", "value3", true, true, "transient error entry should be accessible with error") + + // Phase 2: After 2s - normal entry (key1) should expire, negative entries should remain + time.Sleep(2 * time.Second) + synctest.Wait() + + assertCacheEntry(t, c, "key1", "", false, false, "normal entry should be expired") + assertCacheEntry(t, c, "key2", "value2", true, true, "not found entry should still be present") + assertCacheEntry(t, c, "key3", "value3", true, true, "transient error entry should still be present") + + _, exists, _ := c.get("key1") + assert.False(t, exists, "key1 should be removed from internal storage") + + // Phase 3: After 4s total - transient error entry (key3) should expire + time.Sleep(2 * time.Second) + synctest.Wait() + + assertCacheEntry(t, c, "key1", "", false, false, "normal entry should still be expired") + assertCacheEntry(t, c, "key2", "value2", true, true, "not found entry should still be present") + assertCacheEntry(t, c, "key3", "", false, false, "transient error entry should now be expired") + + _, exists, _ = c.get("key3") + assert.False(t, exists, "key3 should be removed from internal storage") + + // Phase 4: After 10s total - not found error entry (key2) should expire + time.Sleep(6 * time.Second) + synctest.Wait() + + assertCacheEntry(t, c, "key1", "", false, false, "normal entry should still be expired") + assertCacheEntry(t, c, "key2", "", false, false, "not found entry should now be expired") + assertCacheEntry(t, c, "key3", "", false, false, "transient error entry should still be expired") + + _, exists, _ = c.get("key2") + assert.False(t, exists, "key2 should be removed from internal storage") + }) +} + +func TestCache_Concurrency(t *testing.T) { + t.Parallel() + + cfg := kv.CacheConfig{Enabled: true, TTL: "10m"} + c := newTestCache(t, cfg) + + var wg sync.WaitGroup + + for range 50 { + wg.Go(func() { + for j := range 100 { + key := fmt.Sprintf("key-%d", j) + c.Set(key, "value", nil) + } + }) + } + + for range 50 { + wg.Go(func() { + for j := range 100 { + key := fmt.Sprintf("key-%d", j) + _, _, _, err := c.Get(key) + assert.NoError(t, err) + } + }) + } + + wg.Wait() + + c.mu.RLock() + assert.Greater(t, len(c.entries), 0) + c.mu.RUnlock() +} + +func TestCache_Close(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + cache := newTestCache(t, kv.CacheConfig{Enabled: true, TTL: "1s"}) + + cache.Set("key", "value", nil) + + time.Sleep(time.Second) + synctest.Wait() + + // Value is cleared by normal cleanup work + _, exists, _, err := cache.Get("key") + require.NoError(t, err) + assert.False(t, exists) + + cache.Set("key2", "value2", nil) + + _, exists, _, err = cache.Get("key2") + require.NoError(t, err) + assert.True(t, exists) + + // The cleanup goroutine stopped + cache.Close() + + // Advance time past TTL + time.Sleep(time.Second) + synctest.Wait() + + _, exists, _, err = cache.Get("key2") + require.NoError(t, err) + assert.False(t, exists) + + entry, _, _ := cache.get("key2") + require.Empty(t, entry) + + // Stress test concurrent idempotency of Close() + var wg sync.WaitGroup + for range 10 { + wg.Go(func() { + cache.Close() + }) + } + }) +} + +func newTestCache(t *testing.T, cfg kv.CacheConfig) *Cache { + t.Helper() + + cache, err := NewCache(cfg) + require.NoError(t, err) + t.Cleanup(func() { + cache.Close() + }) + + return cache +} + +func assertCacheEntry( + t *testing.T, + c *Cache, + key, + expectedValue string, + shouldExist, + shouldHaveError bool, + description string, +) { + t.Helper() + + val, exists, _, err := c.Get(key) + + if shouldExist { + assert.True(t, exists, "%s: key %s should exist in cache", description, key) + assert.Equal(t, expectedValue, val, "%s: key %s should have expected value", description, key) + } else { + assert.False(t, exists, "%s: key %s should not exist in cache", description, key) + } + + if shouldHaveError { + assert.Error(t, err, "%s: key %s should return an error", description, key) + } else { + assert.NoError(t, err, "%s: key %s should not return an error", description, key) + } +} diff --git a/kv/internal/store/store.go b/kv/internal/store/store.go new file mode 100644 index 00000000..59e6ead0 --- /dev/null +++ b/kv/internal/store/store.go @@ -0,0 +1,180 @@ +package store + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "time" + + "github.com/TykTechnologies/storage/kv" + "github.com/TykTechnologies/storage/kv/internal/cache" + "golang.org/x/sync/singleflight" +) + +const defaultProviderTimeout = 5 * time.Second + +// SecretStore is an internal decorator that adds caching and singleflight to a Provider. +type SecretStore struct { + name string + provider kv.Provider + cache *cache.Cache + sf *singleflight.Group + sfRefresh *singleflight.Group + isClosed atomic.Bool + timeout time.Duration +} + +// Option defines a functional option for configuring the SecretStore. +type Option func(*SecretStore) + +// Get retrieves a secret value with caching and deduplication. +func (s *SecretStore) Get(ctx context.Context, path string) (string, error) { + if s.isClosed.Load() { + return "", kv.ErrStoreClosed + } + + val, exists, needsRefresh, err := s.cache.Get(path) + if exists { + // Fail fast on cached errors + if err != nil { + return "", err + } + + // If value is almost expired on cache, the process should refresh it + // on background which is called "stale-while-revalidate" strategy + if needsRefresh && !s.isClosed.Load() { + s.triggerBackgroundRefreshOnce(path) + } + + return val, err + } + + if s.isClosed.Load() { + return "", kv.ErrStoreClosed + } + + ch := s.sf.DoChan(path, func() (any, error) { + fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.timeout) + defer cancel() + + newVal, err := s.provider.Get(fetchCtx, path) + + // Return earlier to prevent cache poisoning with context errors + if errors.Is(err, context.DeadlineExceeded) { + return "", fmt.Errorf("timeout fetching %q: %w", path, err) + } + + if !s.isClosed.Load() { + s.cache.Set(path, newVal, err) + } + + return newVal, err + }) + + select { + case <-ctx.Done(): + return "", ctx.Err() + case res := <-ch: + if res.Err != nil { + return "", res.Err + } + + v, ok := res.Val.(string) + if !ok { + return "", fmt.Errorf( + "%w: path %q returned non-string type", + kv.ErrContractViolation, + path, + ) + } + + return v, nil + } +} + +// Unwrap allows callers to access the underlying provider for optional interfaces (like Lister) +func (s *SecretStore) Unwrap() kv.Provider { + return s.provider +} + +func (s *SecretStore) Close(ctx context.Context) error { + if s.isClosed.Swap(true) { + return nil + } + + s.cache.Close() + + if closer, ok := kv.AsCloser(s.provider); ok { + return closer.Close(ctx) + } + + return nil +} + +func (s *SecretStore) triggerBackgroundRefreshOnce(path string) { + ch := s.sfRefresh.DoChan(path, func() (any, error) { + return s.doBackgroundRefresh(path) + }) + _ = ch +} + +func (s *SecretStore) doBackgroundRefresh(path string) (any, error) { + if s.isClosed.Load() { + return "", kv.ErrStoreClosed + } + + // We're creating a new context for background refresh because we don't want + // a cancelled HTTP request to abort a cache refresh that benefits all future callers. + ctx, cancel := context.WithTimeout(context.Background(), s.timeout) + defer cancel() + + newVal, err := s.provider.Get(ctx, path) + // Update the cache on success to ensure errors don't overwrite valid entries. + if err == nil && !s.isClosed.Load() { + s.cache.Set(path, newVal, nil) + } + + return newVal, err +} + +// WithTimeout overrides the global default provider timeout. +func WithTimeout(timeout time.Duration) Option { + return func(store *SecretStore) { + if timeout > 0 { + store.timeout = timeout + } + } +} + +// NewSecretStore instantiates the store wrapper with optional configurations. +func NewSecretStore( + name string, + provider kv.Provider, + cacheConfig kv.CacheConfig, + opts ...Option, +) (*SecretStore, error) { + if provider == nil { + return nil, fmt.Errorf("failed to create a secret store with name %q: provider cannot be nil", name) + } + + cache, err := cache.NewCache(cacheConfig) + if err != nil { + return nil, fmt.Errorf("failed to create secret store: %w", err) + } + + s := &SecretStore{ + name: name, + provider: provider, + cache: cache, + sf: &singleflight.Group{}, + sfRefresh: &singleflight.Group{}, + timeout: defaultProviderTimeout, + } + + for _, opt := range opts { + opt(s) + } + + return s, nil +} diff --git a/kv/internal/store/store_test.go b/kv/internal/store/store_test.go new file mode 100644 index 00000000..f33616b0 --- /dev/null +++ b/kv/internal/store/store_test.go @@ -0,0 +1,669 @@ +package store + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/TykTechnologies/storage/kv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockProvider struct { + calls atomic.Int32 + delay time.Duration + mockGetFunc func(ctx context.Context, path string) (string, error) + closed atomic.Bool +} + +func (m *mockProvider) Get(ctx context.Context, path string) (string, error) { + m.calls.Add(1) + + if m.delay > 0 { + select { + case <-time.After(m.delay): + case <-ctx.Done(): + return "", ctx.Err() + } + } + + if m.mockGetFunc != nil { + return m.mockGetFunc(ctx, path) + } + + return "mock-secret", nil +} + +func (m *mockProvider) Close(_ context.Context) error { + m.closed.Store(true) + return nil +} + +func TestNewSecretStore(t *testing.T) { + t.Parallel() + + t.Run("nil provider", func(t *testing.T) { + store, err := NewSecretStore("test", nil, kv.CacheConfig{Enabled: true, TTL: "1m"}) + require.Error(t, err) + require.Nil(t, store) + require.Contains(t, err.Error(), "provider cannot be nil") + }) + + t.Run("invalid cache config", func(t *testing.T) { + provider := &mockProvider{} + store, err := NewSecretStore("test", provider, kv.CacheConfig{ + Enabled: true, + TTL: "invalid-duration", + }) + require.Error(t, err) + require.Nil(t, store) + require.Contains(t, err.Error(), "failed to create secret store") + }) + + t.Run("negative TTL", func(t *testing.T) { + provider := &mockProvider{} + store, err := NewSecretStore("test", provider, kv.CacheConfig{ + Enabled: true, + TTL: "-10s", + }) + require.Error(t, err) + require.Nil(t, store) + }) + + t.Run("assigns default values", func(t *testing.T) { + provider := &mockProvider{} + store, err := NewSecretStore("test", provider, kv.CacheConfig{ + Enabled: false, + }) + require.NoError(t, err) + require.NotNil(t, store) + require.Equal(t, defaultProviderTimeout, store.timeout) + }) + + t.Run("cache disabled", func(t *testing.T) { + provider := &mockProvider{} + store, err := NewSecretStore("test", provider, kv.CacheConfig{ + Enabled: false, + }) + require.NoError(t, err) + require.NotNil(t, store) + + // Every call should hit provider + _, err = store.Get(t.Context(), "key1") + require.NoError(t, err) + _, err = store.Get(t.Context(), "key1") + require.NoError(t, err) + require.Equal(t, int32(2), provider.calls.Load()) + }) +} + +func TestGet_CacheMissAndHit(t *testing.T) { + t.Parallel() + + provider := &mockProvider{} + cfg := kv.CacheConfig{Enabled: true, TTL: "1m"} + store, err := NewSecretStore("test-store", provider, cfg) + require.NoError(t, err) + + // First call: cache miss + val, err := store.Get(t.Context(), "secret-1") + require.NoError(t, err) + assert.Equal(t, "mock-secret", val) + assert.Equal(t, int32(1), provider.calls.Load(), "cache miss should call provider") + + // Second call: cache hit + val, err = store.Get(t.Context(), "secret-1") + require.NoError(t, err) + assert.Equal(t, "mock-secret", val) + assert.Equal(t, int32(1), provider.calls.Load(), "cache hit should not call provider") +} + +func TestGet_ProviderErrorReturned(t *testing.T) { + t.Parallel() + + expectedErr := &kv.KeyNotFoundError{} + provider := &mockProvider{ + mockGetFunc: func(ctx context.Context, path string) (string, error) { + return "", expectedErr + }, + } + cfg := kv.CacheConfig{Enabled: true, TTL: "1m"} + store, err := NewSecretStore("test-store", provider, cfg) + require.NoError(t, err) + + val, err := store.Get(t.Context(), "secret-err") + require.Error(t, err) + require.ErrorAs(t, err, &expectedErr) + assert.Empty(t, val) + assert.Equal(t, int32(1), provider.calls.Load()) +} + +func TestGet_NegativeCachingForKeyNotFoundError(t *testing.T) { + t.Parallel() + + expectedErr := &kv.KeyNotFoundError{} + provider := &mockProvider{ + mockGetFunc: func(ctx context.Context, path string) (string, error) { + return "secret", expectedErr + }, + } + cfg := kv.CacheConfig{ + Enabled: true, + TTL: "1m", + NegativeTTLNotFound: "30s", + } + store, err := NewSecretStore("test-store", provider, cfg) + require.NoError(t, err) + + val, err := store.Get(t.Context(), "secret-err") + require.Error(t, err) + require.ErrorAs(t, err, &expectedErr) + assert.Empty(t, val, "value should be empty even if provider returned non-empty string") + assert.Equal(t, int32(1), provider.calls.Load()) + + val, err = store.Get(t.Context(), "secret-err") + require.Error(t, err) + require.ErrorAs(t, err, &expectedErr) + assert.Empty(t, val) + assert.Equal(t, int32(1), provider.calls.Load(), "cached error should prevent provider call") +} + +func TestGet_SingleFlightDeduplication(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + provider := &mockProvider{ + delay: time.Second, + } + cfg := kv.CacheConfig{Enabled: true, TTL: "10s"} + store, err := NewSecretStore("test-store", provider, cfg) + require.NoError(t, err) + + t.Cleanup(func() { + store.Close(t.Context()) + }) + + var wg sync.WaitGroup + + start := time.Now() + + for range 100 { + wg.Go(func() { + val, err := store.Get(t.Context(), "concurrent-secret") + require.NoError(t, err) + assert.Equal(t, "mock-secret", val) + }) + } + + wg.Wait() + + require.Less( + t, + time.Since(start), + 1001*time.Millisecond, + "all 100 requests will return after first success singleflight call", + ) + assert.Equal( + t, + int32(1), + provider.calls.Load(), + "100 concurrent requests should deduplicate to 1 provider call", + ) + }) +} + +func TestGet_CacheDisabled_AlwaysCallsProvider(t *testing.T) { + t.Parallel() + + provider := &mockProvider{} + cfg := kv.CacheConfig{Enabled: false} + store, err := NewSecretStore("test-store", provider, cfg) + require.NoError(t, err) + + val, err := store.Get(t.Context(), "key1") + require.NoError(t, err) + assert.Equal(t, "mock-secret", val) + assert.Equal(t, int32(1), provider.calls.Load()) + + val, err = store.Get(t.Context(), "key1") + require.NoError(t, err) + assert.Equal(t, "mock-secret", val) + assert.Equal( + t, + int32(2), + provider.calls.Load(), + "cache disabled should call provider every time", + ) +} + +func TestGet_DifferentKeysIndependent(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + provider := &mockProvider{ + mockGetFunc: func(ctx context.Context, path string) (string, error) { + callCount.Add(1) + return fmt.Sprintf("secret-%s", path), nil + }, + } + cfg := kv.CacheConfig{Enabled: true, TTL: "1m"} + store, err := NewSecretStore("test-store", provider, cfg) + require.NoError(t, err) + + val1, err := store.Get(t.Context(), "key1") + require.NoError(t, err) + assert.Equal(t, "secret-key1", val1) + + val2, err := store.Get(t.Context(), "key2") + require.NoError(t, err) + assert.Equal(t, "secret-key2", val2) + + assert.Equal(t, int32(2), callCount.Load(), "different keys should trigger separate provider calls") + + val1, err = store.Get(t.Context(), "key1") + require.NoError(t, err) + assert.Equal(t, "secret-key1", val1) + assert.Equal(t, int32(2), callCount.Load(), "refetch should use cache") +} + +func TestGet_TimeoutEnforcement(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts []Option + expectedTimeout time.Duration + }{ + { + name: "Use default timeout if not explicitly provided", + opts: nil, + expectedTimeout: 5 * time.Second, + }, + { + name: "Override default timeout with custom duration", + opts: []Option{WithTimeout(10 * time.Second)}, + expectedTimeout: 10 * time.Second, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + provider := &mockProvider{ + delay: 30 * time.Second, + } + cfg := kv.CacheConfig{Enabled: true, TTL: "1m"} + + store, err := NewSecretStore("test-store", provider, cfg, tt.opts...) + require.NotNil(t, store) + require.NoError(t, err) + t.Cleanup(func() { + store.Close(t.Context()) + }) + + start := time.Now() + + var wg sync.WaitGroup + wg.Go(func() { + val, err := store.Get(t.Context(), "slow-key") + require.Error(t, err) + require.Contains(t, err.Error(), "timeout fetching") + assert.Empty(t, val) + }) + + synctest.Wait() + wg.Wait() + + elapsed := time.Since(start) + assert.Equal(t, tt.expectedTimeout, elapsed) + }) + }) + } +} + +func TestStaleWhileRevalidate(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + var callCount int32 + + provider := &mockProvider{ + mockGetFunc: func(ctx context.Context, path string) (string, error) { + count := atomic.AddInt32(&callCount, 1) + return fmt.Sprintf("secret-v%d", count), nil + }, + } + cfg := kv.CacheConfig{Enabled: true, TTL: "5s", RefreshBeforeExpiry: "1s"} + store := newTestStore(t, provider, cfg) + + // Cache miss + val, err := store.Get(t.Context(), "stale-secret") + require.NoError(t, err) + assert.Equal(t, "secret-v1", val) + + time.Sleep(4 * time.Second) + + // Cache hit and triggers background refresh + val, err = store.Get(t.Context(), "stale-secret") + require.NoError(t, err) + assert.Equal(t, "secret-v1", val) + + // Wait for background refresh to finish + synctest.Wait() + + // Refreshed value + start := time.Now() + val, err = store.Get(t.Context(), "stale-secret") + require.NoError(t, err) + assert.Equal(t, "secret-v2", val) + assert.Equal(t, int32(2), callCount) + + latency := time.Since(start) + require.Less(t, latency, 10*time.Millisecond, "should return stale value immediately") + }) +} + +func TestBackgroundRefreshDeduplication(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + provider := &mockProvider{ + delay: 100 * time.Millisecond, + } + + cfg := kv.CacheConfig{ + Enabled: true, + TTL: "1s", + RefreshBeforeExpiry: "500ms", + } + store := newTestStore(t, provider, cfg) + + _, err := store.Get(t.Context(), "key1") + require.NoError(t, err) + + // Advance time to enter RefreshBeforeExpiry window + time.Sleep(600 * time.Millisecond) + + var wg sync.WaitGroup + + for range 100 { + wg.Go(func() { + _, err := store.Get(t.Context(), "key1") + require.NoError(t, err) + }) + } + + wg.Wait() + + // We wanna be sure that second request to provider is finished + time.Sleep(100 * time.Millisecond) + synctest.Wait() + + require.Equal(t, int32(2), provider.calls.Load()) + }) +} + +func TestBackgroundRefreshSurvivesRequestCancellation(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + var callCount atomic.Int32 + provider := &mockProvider{ + delay: 100 * time.Millisecond, + mockGetFunc: func(ctx context.Context, path string) (string, error) { + count := callCount.Add(1) + return fmt.Sprintf("secret-v%d", count), nil + }, + } + + cfg := kv.CacheConfig{ + Enabled: true, + TTL: "2s", + RefreshBeforeExpiry: "1s", + } + store := newTestStore(t, provider, cfg) + + // Initial fetch + val, err := store.Get(t.Context(), "key1") + require.NoError(t, err) + require.Equal(t, "secret-v1", val) + + // Advance time to enter the RefreshBeforeExpiry window + time.Sleep(time.Second) + + cancelCtx, cancel := context.WithCancel(context.Background()) + val, err = store.Get(cancelCtx, "key1") + require.NoError(t, err) + require.Equal(t, "secret-v1", val) + + cancel() + + // Wait for background refresh to complete + time.Sleep(100 * time.Millisecond) + synctest.Wait() + + // Verify fresh value was cached despite cancellation + val, err = store.Get(t.Context(), "key1") + require.NoError(t, err) + require.Equal(t, "secret-v2", val) + require.Equal(t, int32(2), callCount.Load()) + }) +} + +func TestConcurrentBackgroundRefreshDifferentKeys(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + var key1Calls, key2Calls atomic.Int32 + provider := &mockProvider{ + delay: 100 * time.Millisecond, + mockGetFunc: func(ctx context.Context, path string) (string, error) { + if path == "key1" { + key1Calls.Add(1) + return "secret-key1", nil + } + + key2Calls.Add(1) + return "secret-key2", nil + }, + } + + cfg := kv.CacheConfig{ + Enabled: true, + TTL: "2s", + RefreshBeforeExpiry: "1s", + } + store := newTestStore(t, provider, cfg) + + _, err := store.Get(t.Context(), "key1") + require.NoError(t, err) + _, err = store.Get(t.Context(), "key2") + require.NoError(t, err) + + time.Sleep(time.Second) + + var wg sync.WaitGroup + + wg.Go(func() { + _, err := store.Get(t.Context(), "key1") + require.NoError(t, err) + }) + wg.Go(func() { + _, err := store.Get(t.Context(), "key2") + require.NoError(t, err) + }) + + wg.Wait() + + time.Sleep(100 * time.Millisecond) // Wait for refreshes to complete + synctest.Wait() + + // Each key should have exactly 2 calls (initial + 1 refresh) + require.Equal(t, int32(2), key1Calls.Load()) + require.Equal(t, int32(2), key2Calls.Load()) + }) +} + +func TestContextCancellationDoesNotPoisonCache(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + provider := &mockProvider{ + // Each call to provider will end-up deadline exceeded + // if context is not canceled before. + delay: 10 * time.Second, + } + + cfg := kv.CacheConfig{Enabled: true, TTL: "5s"} + store := newTestStore(t, provider, cfg) + + go func() { + // Foreground fetch with canceled request. + // The select immediately returns an error and provider + // hasn't been called. + ctx, cancel := context.WithCancel(t.Context()) + + cancel() + + val, err := store.Get(ctx, "cancel-secret") + require.Error(t, err) + require.Contains(t, err.Error(), "context canceled") + require.Empty(t, val) + }() + + val, err := store.Get(t.Context(), "cancel-secret") + require.Error(t, err) + require.Contains(t, err.Error(), "timeout fetching ") + require.Empty(t, val) + + val, err = store.Get(t.Context(), "cancel-secret") + require.Error(t, err) + require.Contains(t, err.Error(), "timeout fetching ") + require.Empty(t, val) + + time.Sleep(10 * time.Second) + synctest.Wait() + + require.Equal(t, int32(2), provider.calls.Load()) + }) +} + +func TestClose_LifecycleBoundaries(t *testing.T) { + t.Parallel() + + t.Run("Get rejects calls immediately after close", func(t *testing.T) { + provider := &mockProvider{} + cfg := kv.CacheConfig{Enabled: true, TTL: "1m"} + store := newTestStore(t, provider, cfg) + + err := store.Close(t.Context()) + require.NoError(t, err) + + val, err := store.Get(t.Context(), "any-key") + assert.ErrorIs(t, err, kv.ErrStoreClosed) + assert.Empty(t, val) + assert.Equal(t, int32(0), provider.calls.Load(), "Should never hit provider once closed") + }) + + t.Run("In-flight foreground fetches do not write to cache on mid-flight close", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + provider := &mockProvider{ + delay: 2 * time.Second, + } + cfg := kv.CacheConfig{Enabled: true, TTL: "10s"} + store := newTestStore(t, provider, cfg) + + var wg sync.WaitGroup + wg.Go(func() { + _, err := store.Get(t.Context(), "mid-flight-key") + require.NoError(t, err) + }) + + // Give the goroutine a small virtual tick to enter the provider block + time.Sleep(100 * time.Millisecond) + + // Suddenly close the store while the provider call is working + err := store.Close(t.Context()) + require.NoError(t, err) + + // Let the provider finish its work + time.Sleep(2 * time.Second) + synctest.Wait() + wg.Wait() + + // Because store was closed mid-flight, the singleflight shouldn't poison/write to cache. + _, err = store.Get(t.Context(), "mid-flight-key") + assert.ErrorIs(t, err, kv.ErrStoreClosed) + }) + }) + + t.Run("Background refresh drops writes if closed mid-execution", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + provider := &mockProvider{ + delay: 500 * time.Millisecond, + } + cfg := kv.CacheConfig{ + Enabled: true, + TTL: "2s", + RefreshBeforeExpiry: "1s", + } + store := newTestStore(t, provider, cfg) + + _, err := store.Get(t.Context(), "refresh-key") + require.NoError(t, err) + + // Move virtual clock into the refresh window + time.Sleep(1200 * time.Millisecond) + + // Trigger the background task by asking for it + _, err = store.Get(t.Context(), "refresh-key") + require.NoError(t, err) + + err = store.Close(t.Context()) + require.NoError(t, err) + + // Advance past the provider delay so background worker wraps up + time.Sleep(600 * time.Millisecond) + synctest.Wait() + + assert.True(t, provider.closed.Load()) + }) + }) + + t.Run("Close is idempotent", func(t *testing.T) { + provider := &mockProvider{} + cfg := kv.CacheConfig{Enabled: true, TTL: "1s"} + store := newTestStore(t, provider, cfg) + + var wg sync.WaitGroup + for range 10 { + wg.Go(func() { + err := store.Close(t.Context()) + require.NoError(t, err) + }) + } + + wg.Wait() + + assert.True(t, provider.closed.Load()) + }) +} + +func newTestStore(t *testing.T, provider kv.Provider, cfg kv.CacheConfig) *SecretStore { + t.Helper() + + store, err := NewSecretStore("test", provider, cfg) + require.NoError(t, err) + t.Cleanup(func() { + store.Close(t.Context()) + }) + + return store +} diff --git a/kv/provider.go b/kv/provider.go index afa23e1b..81b3ce8b 100644 --- a/kv/provider.go +++ b/kv/provider.go @@ -3,6 +3,7 @@ package kv import ( "context" "encoding/json" + "time" ) // ProviderType represents the unique string identifier for a KV provider. @@ -84,6 +85,18 @@ type Closer interface { Close(ctx context.Context) error } +// Standalone is an optional interface for providers that do not need +// to be combined with caching or singleflight mechanisms. +type Standalone interface { + IsStandalone() bool +} + +// Timeouter is an optional interface for providers that expose a custom +// duration configuration for operations. +type Timeouter interface { + Timeout() time.Duration +} + // AsLister attempts to extract a Lister from a Provider, // automatically unwrapping decorators. func AsLister(p Provider) (Lister, bool) { @@ -102,6 +115,16 @@ func AsCloser(p Provider) (Closer, bool) { return As[Closer](p) } +// AsStandalone attempts to extract a Standalone from a Provider. +func AsStandalone(p Provider) (Standalone, bool) { + return As[Standalone](p) +} + +// AsTimeouter attempts to extract a Timeouter from a Provider. +func AsTimeouter(p Provider) (Timeouter, bool) { + return As[Timeouter](p) +} + // As attempts to extract an interface of type T from a Provider, // automatically unwrapping decorators up to a maximum depth. func As[T any](p Provider) (T, bool) { diff --git a/kv/provider_test.go b/kv/provider_test.go new file mode 100644 index 00000000..f72b3320 --- /dev/null +++ b/kv/provider_test.go @@ -0,0 +1,27 @@ +package kv + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +type mockProvider struct{} + +func (m *mockProvider) Get(ctx context.Context, path string) (string, error) { + return "", nil +} + +func (m *mockProvider) Unwrap() Provider { + return m +} + +func TestAs_CircularDependencyWontFail(t *testing.T) { + t.Parallel() + + m := &mockProvider{} + _, ok := As[Closer](m) + + require.False(t, ok) +} diff --git a/kv/registry/registry.go b/kv/registry/registry.go index 02e8590f..1490aaf0 100644 --- a/kv/registry/registry.go +++ b/kv/registry/registry.go @@ -2,9 +2,16 @@ package registry import ( "context" + "errors" + "fmt" "sync" + "sync/atomic" + "time" "github.com/TykTechnologies/storage/kv" + "github.com/TykTechnologies/storage/kv/internal/store" + + "golang.org/x/sync/errgroup" ) // Registry manages provider factories and initialized stores without global state. @@ -13,27 +20,73 @@ import ( // // All operations are safe for concurrent use. type Registry struct { - factories map[kv.ProviderType]kv.ProviderFactory - stores map[string]kv.Provider - mu sync.RWMutex + factories map[kv.ProviderType]kv.ProviderFactory + stores map[string]kv.Provider + mu sync.RWMutex + isInitialized atomic.Bool + logger kv.Logger +} + +type Option func(r *Registry) + +func WithLogger(l kv.Logger) Option { + return func(r *Registry) { + if l != nil { + r.logger = l + } + } } // NewRegistry creates a new empty registry with no registered factories or stores. -func NewRegistry() *Registry { - return &Registry{ +func NewRegistry(opts ...Option) *Registry { + r := &Registry{ factories: make(map[kv.ProviderType]kv.ProviderFactory), stores: make(map[string]kv.Provider), + logger: kv.NoopLogger{}, } + + for _, opt := range opts { + opt(r) + } + + return r +} + +// NewDefaultRegistry creates a registry with added OSS providers. +func NewDefaultRegistry(opts ...Option) *Registry { + r := NewRegistry(opts...) + + // TODO: Uncomment provider registration when implementation is added + // r.Add(kv.Env, env.NewFactory()) + // r.Add(kv.Inline, inline.NewFactory()) + // r.Add(kv.Vault, vault.NewFactory()) + // r.Add(kv.Consul, consul.NewFactory()) + // r.Add(kv.K8s, k8s.NewFactory()) + + return r } // Add registers a provider factory for the given provider type. -// The providerType should match the "type" field used in store configurations. -// -// Adding a factory with the same providerType will overwrite the previous factory. -func (r *Registry) Add(pt kv.ProviderType, factory kv.ProviderFactory) { +func (r *Registry) Add(pt kv.ProviderType, factory kv.ProviderFactory) error { + if pt == "" { + return errors.New("provider type cannot be empty") + } + + if factory == nil { + return errors.New("factory cannot be nil") + } + r.mu.Lock() + defer r.mu.Unlock() + + // Safe check within the write lock prevents the TOCTOU race condition + if _, ok := r.factories[pt]; ok { + return fmt.Errorf("factory for type %q is already provided; override is not allowed", pt) + } + r.factories[pt] = factory - r.mu.Unlock() + + return nil } // InitStores initializes named store instances using registered provider factories. @@ -46,10 +99,123 @@ func (r *Registry) Add(pt kv.ProviderType, factory kv.ProviderFactory) { // Example config: // // { -// "vault-prod": {"type": "vault", "required": true, "config": {...}}, -// "aws-dev": {"type": "aws", "required": false, "config": {...}} +// "kv": { +// "cache": { +// "enabled": true, +// "ttl": "60s" +// }, +// "stores": { +// "vault-prod": { +// "type": "vault", +// "required": true, +// "config": { +// "address": "https://vault.internal:8200", +// "token": "kv://env/VAULT_TOKEN" +// } +// } +// } +// } // } -func (r *Registry) InitStores(configs map[string]kv.StoreConfig) error { +func (r *Registry) InitStores(ctx context.Context, config *kv.Config) (err error) { + r.mu.RLock() + factoriesCount := len(r.factories) + r.mu.RUnlock() + + if factoriesCount == 0 { + return errors.New("factories must be added before initialize stores") + } + + if config == nil || config.Stores == nil { + return nil + } + + if r.isInitialized.Swap(true) { + return errors.New("stores have been initialized") + } + + var tempMu sync.Mutex + tempStores := make(map[string]kv.Provider, len(config.Stores)) + + // This defer block guarantees cleanup of partially initialized stores if the + // overall initialization process fails, preventing resource leaks. + defer func() { + if err != nil { + r.isInitialized.Store(false) + + cleanupCtx := context.WithoutCancel(ctx) + + tempMu.Lock() + defer tempMu.Unlock() + + for _, store := range tempStores { + if closer, ok := kv.AsCloser(store); ok { + _ = closer.Close(cleanupCtx) + } + } + } + }() + + eg, egCtx := errgroup.WithContext(ctx) + + for name, storeCfg := range config.Stores { + r.mu.RLock() + factory, ok := r.factories[storeCfg.Type] + r.mu.RUnlock() + + if !ok { + initErr := fmt.Errorf("unknown provider type %q for store %q", storeCfg.Type, name) + if storeCfg.Required { + return initErr + } + + r.logger.Warn("Skipping optional store initialization", map[string]any{ + "store": name, + "error": initErr, + }) + + continue + } + + eg.Go(func() error { + store, initErr := buildSingleStore(egCtx, name, storeCfg, config.Cache, factory) + if initErr != nil { + if storeCfg.Required { + return initErr + } + + r.logger.Warn("Skipping optional store initialization", map[string]any{ + "store": name, + "error": initErr, + }) + + return nil + } + + tempMu.Lock() + tempStores[name] = store + tempMu.Unlock() + + return nil + }) + } + + err = eg.Wait() + if err != nil { + return err + } + + r.mu.Lock() + defer r.mu.Unlock() + + // Double-check registry wasn't closed during the initialization + if !r.isInitialized.Load() { + return errors.New("registry was closed during initialization") + } + + for name, store := range tempStores { + r.stores[name] = store + } + return nil } @@ -69,5 +235,72 @@ func (r *Registry) GetStore(name string) (kv.Provider, error) { // Close gracefully shuts down all initialized stores. func (r *Registry) Close(ctx context.Context) error { - return nil + r.mu.Lock() + stores := r.stores + r.stores = make(map[string]kv.Provider) + r.isInitialized.Store(false) + r.mu.Unlock() + + var ( + mu sync.Mutex + errs []error + wg sync.WaitGroup + ) + + for name, store := range stores { + wg.Go(func() { + if closer, ok := kv.AsCloser(store); ok { + if err := closer.Close(ctx); err != nil { + mu.Lock() + errs = append(errs, fmt.Errorf("failed to close store %q: %w", name, err)) + mu.Unlock() + } + } + }) + } + + wg.Wait() + + return errors.Join(errs...) +} + +func buildSingleStore( + ctx context.Context, + name string, + storeCfg kv.StoreConfig, + cacheCfg kv.CacheConfig, + factory kv.ProviderFactory, +) (kv.Provider, error) { + provider, err := factory(storeCfg.Config) + if err != nil { + return nil, fmt.Errorf("failed to create provider %q (type: %s): %w", name, storeCfg.Type, err) + } + + if initializer, ok := kv.AsInitializer(provider); ok { + err := initializer.Init(ctx) + if err != nil { + return nil, fmt.Errorf("failed to initialize store %q (type: %s): %w", name, storeCfg.Type, err) + } + } + + if s, ok := kv.AsStandalone(provider); ok && s.IsStandalone() { + return provider, nil + } + + var timeout time.Duration + if t, ok := kv.AsTimeouter(provider); ok { + timeout = t.Timeout() + } + + ss, err := store.NewSecretStore( + name, + provider, + cacheCfg, + store.WithTimeout(timeout), + ) + if err != nil { + return nil, fmt.Errorf("failed to wrap store %q: %w", name, err) + } + + return ss, nil } diff --git a/kv/registry/registry_test.go b/kv/registry/registry_test.go new file mode 100644 index 00000000..1d5928bf --- /dev/null +++ b/kv/registry/registry_test.go @@ -0,0 +1,592 @@ +package registry + +import ( + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/TykTechnologies/storage/kv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockProvider struct { + initFunc func(ctx context.Context) error + closeFunc func(ctx context.Context) error + isStandalone bool + calls atomic.Int32 +} + +func (m *mockProvider) Get(ctx context.Context, key string) (string, error) { + m.calls.Add(1) + return "value", nil +} + +func (m *mockProvider) Init(ctx context.Context) error { + if m.initFunc != nil { + return m.initFunc(ctx) + } + + return nil +} + +func (m *mockProvider) Close(ctx context.Context) error { + if m.closeFunc != nil { + return m.closeFunc(ctx) + } + + return nil +} + +func (m *mockProvider) IsStandalone() bool { + return m.isStandalone +} + +type mockLogger struct { + warnCalls int +} + +func (l *mockLogger) Warn(_ string, _ map[string]any) { + l.warnCalls++ +} +func (*mockLogger) Warnf(_ string, _ ...any) {} + +func newFactory(initFunc, closeFunc func(ctx context.Context) error) kv.ProviderFactory { + return func(config json.RawMessage) (kv.Provider, error) { + return &mockProvider{ + initFunc: initFunc, + closeFunc: closeFunc, + }, nil + } +} + +func TestNewRegistry(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + require.NotNil(t, registry) + require.NotNil(t, registry.stores) + require.NotNil(t, registry.factories) +} + +// TODO: Update the test case when providers are set, to assert +// that all OSS providers are registered. +func TestNewDefaultRegistry(t *testing.T) {} + +func TestAddFactory(t *testing.T) { + t.Parallel() + + t.Run("successful registration", func(t *testing.T) { + r := NewRegistry() + + err := r.Add(kv.Env, newFactory(nil, nil)) + require.NoError(t, err) + + err = r.Add(kv.Inline, newFactory(nil, nil)) + require.NoError(t, err) + + require.Len(t, r.factories, 2) + }) + + t.Run("reject empty provider type", func(t *testing.T) { + r := NewRegistry() + err := r.Add("", nil) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot be empty") + + require.Len(t, r.factories, 0) + }) + + t.Run("prevent factory duplication override", func(t *testing.T) { + r := NewRegistry() + err := r.Add("env", newFactory(nil, nil)) + require.NoError(t, err) + + err = r.Add("env", newFactory(nil, nil)) + require.Error(t, err) + require.Contains(t, err.Error(), "is already provided") + + require.Len(t, r.factories, 1) + }) + + t.Run("reject nil factory", func(t *testing.T) { + r := NewRegistry() + err := r.Add("env", nil) + require.Error(t, err) + require.Contains(t, err.Error(), "factory cannot be nil") + require.Len(t, r.factories, 0) + }) +} + +func TestGetStore(t *testing.T) { + t.Parallel() + + r := NewRegistry() + + err := r.Add("valid", newFactory(nil, nil)) + require.NoError(t, err) + + err = r.InitStores(t.Context(), &kv.Config{ + Stores: map[string]kv.StoreConfig{ + "valid-1": {Type: "valid", Required: true}, + }, + }) + require.NoError(t, err) + + p, err := r.GetStore("valid-1") + require.NoError(t, err) + require.NotNil(t, p) +} + +func TestInitStores_BlastRadius(t *testing.T) { + t.Parallel() + + type testCase struct { + name string + storeType kv.ProviderType + required bool + factoryErr error + initErr error + expectError bool + } + + table := []testCase{ + { + name: "required store initializes perfectly", + storeType: kv.Env, + required: true, + expectError: false, + }, + { + name: "unregistered provider type fails if required", + storeType: kv.Inline, + required: true, + expectError: true, + }, + { + name: "unregistered provider type skipped if optional", + storeType: kv.Inline, + required: false, + expectError: false, + }, + { + name: "factory generation failure blocks startup if required", + storeType: kv.Env, + required: true, + factoryErr: errors.New("bad config content"), + expectError: true, + }, + { + name: "factory generation failure skipped if optional", + storeType: kv.Env, + required: false, + factoryErr: errors.New("bad config content"), + expectError: false, + }, + { + name: "network init phase failure blocks startup if required", + storeType: kv.Env, + required: true, + initErr: errors.New("vault unreachable"), + expectError: true, + }, + { + name: "network init phase failure skipped if not required", + storeType: kv.Env, + required: false, + initErr: errors.New("consul dead"), + expectError: false, + }, + } + + for _, tc := range table { + t.Run(tc.name, func(t *testing.T) { + reg := NewRegistry() + + // Adding factory for env provider + err := reg.Add(kv.Env, func(cfg json.RawMessage) (kv.Provider, error) { + if tc.factoryErr != nil { + return nil, tc.factoryErr + } + + return &mockProvider{ + initFunc: func(ctx context.Context) error { + return tc.initErr + }, + }, nil + }) + require.NoError(t, err) + + config := &kv.Config{ + Stores: map[string]kv.StoreConfig{ + "target-store": { + Type: tc.storeType, + Required: tc.required, + }, + }, + } + + err = reg.InitStores(t.Context(), config) + if tc.expectError { + require.Error(t, err) + } + }) + } +} + +func TestInitStores_EdgeCases(t *testing.T) { + t.Parallel() + + t.Run("returns nil early without initializing stores when config is empty", func(t *testing.T) { + r := NewRegistry() + err := r.Add("mock", newFactory(nil, nil)) + require.NoError(t, err) + + err = r.InitStores(t.Context(), nil) + assert.NoError(t, err) + + err = r.InitStores(t.Context(), &kv.Config{}) + assert.NoError(t, err) + + assert.False(t, r.isInitialized.Load()) + }) + + t.Run("returns error if no factory provided", func(t *testing.T) { + r := NewRegistry() + err := r.InitStores(t.Context(), &kv.Config{}) + require.Error(t, err) + require.Contains(t, err.Error(), "factories must be added before initialize stores") + require.False(t, r.isInitialized.Load()) + }) + + t.Run("should be called once unless Close() was called", func(t *testing.T) { + reg := NewRegistry() + + err := reg.Add("mock", newFactory(nil, nil)) + require.NoError(t, err) + + config := &kv.Config{ + Stores: map[string]kv.StoreConfig{ + "target-store": { + Type: "mock", + Required: true, + }, + }, + } + + err = reg.InitStores(t.Context(), config) + require.NoError(t, err) + + err = reg.InitStores(t.Context(), config) + require.Error(t, err) + require.Contains(t, err.Error(), "stores have been initialized") + + err = reg.Close(t.Context()) + require.NoError(t, err) + + err = reg.InitStores(t.Context(), config) + require.NoError(t, err) + }) + + t.Run("should close temporarily added stores when required store failed", func(t *testing.T) { + // We have to iterate over until valid store is initialized because + // we have map non-deterministic iteration order. + for { + r := NewRegistry() + + var validInitialized bool + var closeFuncCalled bool + validFactory := newFactory(func(ctx context.Context) error { + validInitialized = true + return nil + }, func(ctx context.Context) error { + closeFuncCalled = true + return nil + }) + + err := r.Add("valid", validFactory) + require.NoError(t, err) + + invalidFactory := newFactory(func(ctx context.Context) error { + return errors.New("init error") + }, nil) + err = r.Add("invalid", invalidFactory) + require.NoError(t, err) + + err = r.InitStores(t.Context(), &kv.Config{ + Stores: map[string]kv.StoreConfig{ + "valid-1": {Type: "valid", Required: true}, + "invalid-1": {Type: "invalid", Required: true}, + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to initialize store") + + if validInitialized { + require.True( + t, + closeFuncCalled, + "expected temporarily added store to be closed, but Close was not called", + ) + require.False(t, r.isInitialized.Load()) + + break + } + } + }) + + t.Run("should handle error returned by secret store wrapper", func(t *testing.T) { + r := NewRegistry() + + err := r.Add("valid", newFactory(nil, nil)) + require.NoError(t, err) + + err = r.InitStores(t.Context(), &kv.Config{ + Stores: map[string]kv.StoreConfig{ + "valid-1": {Type: "valid", Required: true}, + }, + Cache: kv.CacheConfig{ + Enabled: true, + TTL: "-10s", + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to wrap store") + require.False(t, r.isInitialized.Load()) + }) + + t.Run("should log warning when optional store failed one of the steps", func(t *testing.T) { + l := &mockLogger{} + r := NewRegistry(WithLogger(l)) + + err := r.Add("valid", newFactory(nil, nil)) + require.NoError(t, err) + + err = r.InitStores(t.Context(), &kv.Config{ + Stores: map[string]kv.StoreConfig{ + "valid-1": {Type: "valid", Required: false}, + }, + Cache: kv.CacheConfig{ + Enabled: true, + TTL: "-10s", + }, + }) + require.NoError(t, err) + require.Equal(t, 1, l.warnCalls) + }) + + t.Run("should skip secret store wrapping if provider is standalone", func(t *testing.T) { + r := NewRegistry() + + err := r.Add("valid", func(_ json.RawMessage) (kv.Provider, error) { + return &mockProvider{isStandalone: true}, nil + }) + require.NoError(t, err) + + err = r.InitStores(t.Context(), &kv.Config{ + Stores: map[string]kv.StoreConfig{ + "valid-1": {Type: "valid", Required: true}, + }, + }) + require.NoError(t, err) + require.Len(t, r.stores, 1) + }) + + t.Run("should initialize stores concurrently", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + r := NewRegistry() + + err := r.Add("valid", newFactory(func(ctx context.Context) error { + time.Sleep(10 * time.Second) + return nil + }, nil)) + require.NoError(t, err) + + start := time.Now() + err = r.InitStores(t.Context(), &kv.Config{ + Stores: map[string]kv.StoreConfig{ + "valid-1": {Type: "valid", Required: true}, + "valid-2": {Type: "valid", Required: true}, + "valid-3": {Type: "valid", Required: true}, + "valid-4": {Type: "valid", Required: true}, + "valid-5": {Type: "valid", Required: true}, + }, + }) + require.NoError(t, err) + synctest.Wait() + + elapsed := time.Since(start) + + require.Equal(t, 10*time.Second, elapsed) + require.Len(t, r.stores, 5) + }) + }) +} + +func TestClose(t *testing.T) { + t.Parallel() + + t.Run("can be called multiple times without error", func(t *testing.T) { + reg := NewRegistry() + err := reg.Close(t.Context()) + require.NoError(t, err) + err = reg.Close(t.Context()) + require.NoError(t, err) + err = reg.Close(t.Context()) + require.NoError(t, err) + }) + + t.Run("aggregates multiple errors", func(t *testing.T) { + reg := NewRegistry() + + err := reg.Add("mock", func(cfg json.RawMessage) (kv.Provider, error) { + return &mockProvider{ + closeFunc: func(ctx context.Context) error { + return errors.New("cleanup failed") + }, + }, nil + }) + require.NoError(t, err) + + config := &kv.Config{ + Stores: map[string]kv.StoreConfig{ + "store-1": {Type: "mock", Required: true}, + "store-2": {Type: "mock", Required: true}, + }, + } + + err = reg.InitStores(t.Context(), config) + require.NoError(t, err) + + closeErr := reg.Close(t.Context()) + require.Error(t, closeErr) + + if err, ok := closeErr.(interface{ Unwrap() []error }); ok { + er := err.Unwrap() + require.Len(t, er, 2) + } else { + t.Error("close error must be a result of errors.Join which implements Unwrap") + } + }) +} + +func TestRegistry_Concurrency(t *testing.T) { + t.Parallel() + + reg := NewRegistry() + + err := reg.Add("static-type", newFactory(nil, nil)) + require.NoError(t, err) + + var wg sync.WaitGroup + + wg.Go(func() { + err := reg.Add(kv.Env, newFactory(nil, nil)) + require.NoError(t, err) + }) + + wg.Go(func() { + _, err := reg.GetStore("any-store") + require.ErrorIs(t, err, kv.ErrStoreNotFound) + }) + + wg.Wait() +} + +func TestConcurrentInitStoresAndCloseAreHandledCorrectly(t *testing.T) { + t.Parallel() + + reg := NewRegistry() + + inInit := make(chan struct{}) + closeDone := make(chan struct{}) + + err := reg.Add("mock", newFactory(func(ctx context.Context) error { + close(inInit) + <-closeDone + + return nil + }, nil)) + require.NoError(t, err) + + var wg sync.WaitGroup + + // While InitStores is running another goroutine calls the Close method + wg.Go(func() { + config := &kv.Config{ + Stores: map[string]kv.StoreConfig{ + "store-1": {Type: "mock", Required: true}, + }, + } + + err := reg.InitStores(t.Context(), config) + require.Error(t, err) + require.Contains(t, err.Error(), "registry was closed during initialization") + }) + + wg.Go(func() { + <-inInit + + err := reg.Close(t.Context()) + require.NoError(t, err) + + close(closeDone) + }) + + wg.Wait() +} + +func TestInitStores_CacheCleanupSurvivesInitialization(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + r := NewRegistry() + + t.Cleanup(func() { + r.Close(t.Context()) + }) + + p := &mockProvider{} + err := r.Add("test", func(config json.RawMessage) (kv.Provider, error) { + return p, nil + }) + require.NoError(t, err) + + cfg := &kv.Config{ + Cache: kv.CacheConfig{ + Enabled: true, + TTL: "1s", + }, + Stores: map[string]kv.StoreConfig{ + "test-store": {Type: "test", Required: true}, + }, + } + + err = r.InitStores(t.Context(), cfg) + require.NoError(t, err) + + store, err := r.GetStore("test-store") + require.NoError(t, err) + + // Populate cache + val, err := store.Get(t.Context(), "key1") + require.NoError(t, err) + require.Equal(t, "value", val) + require.Equal(t, int32(1), p.calls.Load()) + + // Second call should hit cache (no provider call) + _, err = store.Get(t.Context(), "key1") + require.NoError(t, err) + require.Equal(t, int32(1), p.calls.Load(), "should hit cache") + + time.Sleep(time.Second) + synctest.Wait() + + _, err = store.Get(t.Context(), "key1") + require.NoError(t, err) + require.Equal(t, int32(2), p.calls.Load(), "cache should have cleaned up expired entry") + }) +} From 7cbd904fd7fdd2c20a3a05e491fed838913ba1ba Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi <109525963+vladzabolotnyi@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:55:22 +0300 Subject: [PATCH 10/31] TT-17240: Add resolver component (#141) * feat: add implementation to Resolve func * feat: add resolveAll implementation * test: add test cases for json pointer extraction * refactor: update error messages and make error handlin more consistent * refactor: update resolve tests * refactor: update resolveAll tests with grouping error cases * fix: added malformed err with tests, added docs string * test: add test suite with path routing * fix: remove redundant assertions and update comment * test: add test case that resolve all resolved mixed values * fix: use decoding with numbers to avoid losing large integers and update doc strings explaining inner behavior * fix: add checks with tests to avoid processing values when store or key is empty string * fix: remove spaces around em-dashes and add missing tests for missing stores and keys * chore: remove redundant comments * fix: address sonarqube comment with avoiding duplication * fix: add json validation on document even if it doesn't contain kv refs * refactor: split resolver for public and private parts to hide inner logic with lenient mode in future * chore: replace background context with test one --------- Co-authored-by: Vlad Zabolotnyi --- kv/internal/resolve/errors.go | 18 + kv/internal/resolve/helpers.go | 75 ++++ kv/internal/resolve/helpers_test.go | 171 ++++++++ kv/internal/resolve/resolver.go | 195 +++++++++ kv/internal/resolve/resolver_test.go | 631 +++++++++++++++++++++++++++ kv/provider.go | 5 + kv/resolver/errors.go | 9 + kv/resolver/resolver.go | 57 +-- kv/resolver/resolver_test.go | 138 ++++++ 9 files changed, 1274 insertions(+), 25 deletions(-) create mode 100644 kv/internal/resolve/errors.go create mode 100644 kv/internal/resolve/helpers.go create mode 100644 kv/internal/resolve/helpers_test.go create mode 100644 kv/internal/resolve/resolver.go create mode 100644 kv/internal/resolve/resolver_test.go create mode 100644 kv/resolver/errors.go create mode 100644 kv/resolver/resolver_test.go diff --git a/kv/internal/resolve/errors.go b/kv/internal/resolve/errors.go new file mode 100644 index 00000000..e30580d4 --- /dev/null +++ b/kv/internal/resolve/errors.go @@ -0,0 +1,18 @@ +package resolve + +import ( + "errors" + "fmt" +) + +var ( + ErrInvalidJSON = errors.New("invalid JSON") + ErrFieldNotFound = errors.New("field not found") + ErrMalformedReference = errors.New("malformed KV reference") +) + +// fieldNotFoundErr wraps ErrFieldNotFound with the JSON pointer segment that +// could not be resolved. +func fieldNotFoundError(field string) error { + return fmt.Errorf("%w: %q", ErrFieldNotFound, field) +} diff --git a/kv/internal/resolve/helpers.go b/kv/internal/resolve/helpers.go new file mode 100644 index 00000000..37928818 --- /dev/null +++ b/kv/internal/resolve/helpers.go @@ -0,0 +1,75 @@ +package resolve + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +func extractJSONPointer(raw, fragment string) (string, error) { + // UseNumber keeps numeric leaves as json.Number — a float64 round-trip + // silently corrupts integers above 2^53. + dec := json.NewDecoder(strings.NewReader(raw)) + dec.UseNumber() + + var doc any + if err := dec.Decode(&doc); err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidJSON, err) + } + + // Normalize fragment with leading "/" + if !strings.HasPrefix(fragment, "/") { + fragment = "/" + fragment + } + + // Split on "/" and skip first empty segment + segments := strings.Split(fragment, "/")[1:] + + current := doc + + for _, seg := range segments { + // Unescape RFC 6901: ~1 → /, ~0 → ~ (order matters) + seg = strings.ReplaceAll(seg, "~1", "/") + seg = strings.ReplaceAll(seg, "~0", "~") + + switch v := current.(type) { + case map[string]any: + val, ok := v[seg] + if !ok { + return "", fieldNotFoundError(seg) + } + + current = val + + case []any: + idx, err := strconv.Atoi(seg) + if err != nil || idx < 0 || idx >= len(v) { + return "", fieldNotFoundError(seg) + } + + current = v[idx] + + default: + return "", fieldNotFoundError(seg) + } + } + + switch v := current.(type) { + case string: + return v, nil + case json.Number: + return v.String(), nil + case bool: + return strconv.FormatBool(v), nil + case nil: + return "null", nil + default: + b, err := json.Marshal(v) + if err != nil { + return "", err + } + + return string(b), nil + } +} diff --git a/kv/internal/resolve/helpers_test.go b/kv/internal/resolve/helpers_test.go new file mode 100644 index 00000000..43ef9292 --- /dev/null +++ b/kv/internal/resolve/helpers_test.go @@ -0,0 +1,171 @@ +package resolve + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractJSONPointer(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + fragment string + want string + wantErr error + }{ + // --- happy path: leaf types --- + { + name: "top-level string field", + raw: `{"password":"hunter2"}`, + fragment: "password", + want: "hunter2", + }, + { + name: "top-level string field with leading slash", + raw: `{"password":"hunter2"}`, + fragment: "/password", + want: "hunter2", + }, + { + name: "top-level number field", + raw: `{"port":5432}`, + fragment: "/port", + want: "5432", + }, + { + name: "top-level bool field", + raw: `{"enabled":true}`, + fragment: "/enabled", + want: "true", + }, + { + name: "top-level null field", + raw: `{"value":null}`, + fragment: "/value", + want: "null", + }, + // --- happy path: traversal --- + { + name: "nested object", + raw: `{"db":{"host":"localhost"}}`, + fragment: "/db/host", + want: "localhost", + }, + { + name: "array index zero", + raw: `{"hosts":["primary","replica"]}`, + fragment: "/hosts/0", + want: "primary", + }, + { + name: "array index non-zero", + raw: `{"hosts":["primary","replica"]}`, + fragment: "/hosts/1", + want: "replica", + }, + { + name: "object leaf re-serialized as JSON", + raw: `{"db":{"host":"localhost","port":5432}}`, + fragment: "/db", + want: `{"host":"localhost","port":5432}`, + }, + { + name: "object then object then array index", + raw: `{"wow":{"some":["first","second"]}}`, + fragment: "/wow/some/1", + want: "second", + }, + // --- happy path: RFC 6901 escaping --- + { + name: "tilde-one unescaped to slash in key", + raw: `{"a/b":"value"}`, + fragment: "/a~1b", + want: "value", + }, + { + name: "tilde-zero unescaped to tilde in key", + raw: `{"a~b":"value"}`, + fragment: "/a~0b", + want: "value", + }, + { + name: "combined tilde escapes", + raw: `{"a/b":{"c~d":"value"}}`, + fragment: "/a~1b/c~0d", + want: "value", + }, + // --- error: missing field --- + { + name: "missing top-level key", + raw: `{"password":"secret"}`, + fragment: "/username", + wantErr: ErrFieldNotFound, + }, + { + name: "missing nested key", + raw: `{"db":{"host":"localhost"}}`, + fragment: "/db/port", + wantErr: ErrFieldNotFound, + }, + { + name: "array index out of bounds", + raw: `{"hosts":["primary"]}`, + fragment: "/hosts/5", + wantErr: ErrFieldNotFound, + }, + { + name: "array index negative", + raw: `{"hosts":["primary"]}`, + fragment: "/hosts/-1", + wantErr: ErrFieldNotFound, + }, + { + name: "traversing into a scalar", + raw: `{"port":5432}`, + fragment: "/port/nested", + wantErr: ErrFieldNotFound, + }, + { + name: "object then object then array index out of bounds", + raw: `{"wow":{"some":["first","second"]}}`, + fragment: "/wow/some/5", + wantErr: ErrFieldNotFound, + }, + // --- error: invalid JSON --- + { + name: "payload is not JSON", + raw: "not-json", + fragment: "/field", + wantErr: ErrInvalidJSON, + }, + { + name: "empty payload", + raw: "", + fragment: "/field", + wantErr: ErrInvalidJSON, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := extractJSONPointer(tc.raw, tc.fragment) + + if tc.wantErr != nil { + require.Error(t, err) + assert.True(t, errors.Is(err, tc.wantErr), "want %v, got %v", tc.wantErr, err) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/kv/internal/resolve/resolver.go b/kv/internal/resolve/resolver.go new file mode 100644 index 00000000..b166508f --- /dev/null +++ b/kv/internal/resolve/resolver.go @@ -0,0 +1,195 @@ +package resolve + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/TykTechnologies/storage/kv" +) + +type Resolver struct { + registry kv.StoreGetter +} + +type Option func(*Resolver) + +func NewResolver(registry kv.StoreGetter, opts ...Option) *Resolver { + r := &Resolver{registry: registry} + for _, opt := range opts { + opt(r) + } + + return r +} + +var inlineRe = regexp.MustCompile(`\$kv\{([^}]+)\}`) + +func (r *Resolver) Resolve(ctx context.Context, input string) (string, error) { + if strings.HasPrefix(input, "kv://") { + trimmed := strings.TrimPrefix(input, "kv://") + + slashIdx := strings.IndexByte(trimmed, '/') + if slashIdx < 0 { + return "", fmt.Errorf( + "%w: missing path separator in %q", + ErrMalformedReference, + input, + ) + } + + storeName := trimmed[:slashIdx] + rest := trimmed[slashIdx+1:] + path, fragment, _ := strings.Cut(rest, "#") + + if storeName == "" || path == "" { + return "", fmt.Errorf( + "%w: empty store name or path in %q", + ErrMalformedReference, + input, + ) + } + + return r.fetchAndExtract(ctx, storeName, path, fragment) + } + + var resolveErrs []error + result := inlineRe.ReplaceAllStringFunc(input, func(match string) string { + // strip "$kv{" prefix and "}" suffix + inner := match[4 : len(match)-1] + + colonIdx := strings.IndexByte(inner, ':') + if colonIdx < 0 { + resolveErrs = append(resolveErrs, fmt.Errorf( + "%w: missing store separator in %q", + ErrMalformedReference, + match, + )) + + return match + } + + storeName := inner[:colonIdx] + rest := inner[colonIdx+1:] + path, fragment, _ := strings.Cut(rest, "#") + + if storeName == "" || path == "" { + resolveErrs = append(resolveErrs, fmt.Errorf( + "%w: empty store name or path in %q", + ErrMalformedReference, + match, + )) + + return match + } + + val, err := r.fetchAndExtract(ctx, storeName, path, fragment) + if err != nil { + resolveErrs = append(resolveErrs, err) + return match + } + + return val + }) + + if len(resolveErrs) > 0 { + return "", errors.Join(resolveErrs...) + } + + return result, nil +} + +func (r *Resolver) ResolveAll(ctx context.Context, rawJSON []byte) ([]byte, error) { + // Fast path: skip unmarshal/remarshal entirely when no KV syntax is present, + // preserving the original bytes and avoiding unnecessary allocations. + if !bytes.Contains(rawJSON, []byte("kv://")) && !bytes.Contains(rawJSON, []byte("$kv{")) { + // Without this check, JSON validation would depend on whether + // the document happens to contain KV syntax. + if !json.Valid(rawJSON) { + return nil, fmt.Errorf("%w: invalid document", ErrInvalidJSON) + } + + return rawJSON, nil + } + + // UseNumber keeps numbers as json.Number instead of float64. A float64 + // round-trip silently corrupts integers above 2^53 (IDs, nanosecond + // timestamps). + dec := json.NewDecoder(bytes.NewReader(rawJSON)) + dec.UseNumber() + + var doc any + if err := dec.Decode(&doc); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidJSON, err) + } + + resolved, err := r.walkAndResolve(ctx, doc) + if err != nil { + return nil, err + } + + // Encoder with SetEscapeHTML(false) keeps &, <, > literal + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + + if err := enc.Encode(resolved); err != nil { + return nil, err + } + + // Encode appends a trailing newline; Marshal does not. + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} + +func (r *Resolver) fetchAndExtract(ctx context.Context, storeName, path, fragment string) (string, error) { + store, err := r.registry.GetStore(storeName) + if err != nil { + return "", err + } + + raw, err := store.Get(ctx, path) + if err != nil { + return "", err + } + + if fragment == "" { + return raw, nil + } + + return extractJSONPointer(raw, fragment) +} + +func (r *Resolver) walkAndResolve(ctx context.Context, node any) (any, error) { + switch v := node.(type) { + case string: + return r.Resolve(ctx, v) + case map[string]any: + for key, value := range v { + resolved, err := r.walkAndResolve(ctx, value) + if err != nil { + return nil, fmt.Errorf("field %q: %w", key, err) + } + + v[key] = resolved + } + + return v, nil + case []any: + for i, value := range v { + resolved, err := r.walkAndResolve(ctx, value) + if err != nil { + return nil, fmt.Errorf("index %d: %w", i, err) + } + + v[i] = resolved + } + + return v, nil + default: + return v, nil + } +} diff --git a/kv/internal/resolve/resolver_test.go b/kv/internal/resolve/resolver_test.go new file mode 100644 index 00000000..7a23dfec --- /dev/null +++ b/kv/internal/resolve/resolver_test.go @@ -0,0 +1,631 @@ +package resolve_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/TykTechnologies/storage/kv" + "github.com/TykTechnologies/storage/kv/internal/resolve" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockProvider struct { + value string + err error + lastKey string +} + +func (m *mockProvider) Get(_ context.Context, key string) (string, error) { + m.lastKey = key + return m.value, m.err +} + +type mockStoreGetter struct { + stores map[string]kv.Provider +} + +func (m *mockStoreGetter) GetStore(name string) (kv.Provider, error) { + p, ok := m.stores[name] + if !ok { + return nil, kv.NewStoreNotFoundError(name) + } + + return p, nil +} + +func newGetter(stores map[string]kv.Provider) kv.StoreGetter { + return &mockStoreGetter{stores: stores} +} + +func TestResolve(t *testing.T) { + t.Parallel() + + const jsonCreds = `{"username":"admin","password":"hunter2"}` + + tests := []struct { + name string + stores map[string]kv.Provider + input string + want string + wantErr error + }{ + // plain strings + { + name: "plain string passes through unchanged", + input: "just-a-plain-string", + want: "just-a-plain-string", + }, + { + name: "empty string passes through unchanged", + input: "", + want: "", + }, + // kv:// whole-value + { + name: "whole-value replacement", + stores: map[string]kv.Provider{"vault": &mockProvider{value: "s3cr3t"}}, + input: "kv://vault/db/password", + want: "s3cr3t", + }, + { + name: "whole-value with fragment", + stores: map[string]kv.Provider{"vault": &mockProvider{value: jsonCreds}}, + input: "kv://vault/db/creds#password", + want: "hunter2", + }, + // $kv{} inline + { + name: "single inline token", + stores: map[string]kv.Provider{"env": &mockProvider{value: "myhost.com"}}, + input: "https://$kv{env:API_HOST}/v1", + want: "https://myhost.com/v1", + }, + { + name: "multiple inline tokens", + stores: map[string]kv.Provider{"env": &mockProvider{value: "resolved"}}, + input: "$kv{env:A}/$kv{env:B}/$kv{env:C}", + want: "resolved/resolved/resolved", + }, + { + name: "inline token with fragment", + stores: map[string]kv.Provider{"vault": &mockProvider{value: `{"host":"db.internal"}`}}, + input: "postgres://$kv{vault:db/creds#host}:5432/mydb", + want: "postgres://db.internal:5432/mydb", + }, + // JSON Pointer traversal + { + name: "nested object field", + stores: map[string]kv.Provider{"vault": &mockProvider{value: `{"db":{"host":"localhost","port":5432}}`}}, + input: "kv://vault/secret#/db/host", + want: "localhost", + }, + { + name: "array index", + stores: map[string]kv.Provider{"consul": &mockProvider{value: `{"hosts":["primary","replica"]}`}}, + input: "kv://consul/service/db#/hosts/1", + want: "replica", + }, + { + name: "RFC 6901 escaped segments", + stores: map[string]kv.Provider{"env": &mockProvider{value: `{"a/b":{"c~d":"value"}}`}}, + input: "kv://env/KEY#/a~1b/c~0d", + want: "value", + }, + // no recursive resolution + { + name: "resolved value containing kv syntax is not re-resolved", + stores: map[string]kv.Provider{"env": &mockProvider{value: "kv://vault/another-secret"}}, + input: "kv://env/SOME_KEY", + want: "kv://vault/another-secret", + }, + // errors + { + name: "store not found", + input: "kv://nonexistent/key", + wantErr: kv.ErrStoreNotFound, + }, + { + name: "invalid JSON when fragment requested", + stores: map[string]kv.Provider{"vault": &mockProvider{value: "not-json"}}, + input: "kv://vault/secret#field", + wantErr: resolve.ErrInvalidJSON, + }, + { + name: "field not found in JSON payload", + stores: map[string]kv.Provider{"vault": &mockProvider{value: `{"password":"secret"}`}}, + input: "kv://vault/secret#username", + wantErr: resolve.ErrFieldNotFound, + }, + { + name: "inline token store not found", + input: "prefix-$kv{ghost:key}-suffix", + wantErr: kv.ErrStoreNotFound, + }, + { + name: "malformed kv:// missing slash", + input: "kv://nopath", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "malformed $kv{} missing colon", + input: "$kv{nocolon}", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "malformed kv:// empty path", + stores: map[string]kv.Provider{"vault": &mockProvider{value: "x"}}, + input: "kv://vault/", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "malformed kv:// empty store name", + input: "kv:///path", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "malformed kv:// empty path with fragment", + stores: map[string]kv.Provider{"vault": &mockProvider{value: "x"}}, + input: "kv://vault/#field", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "malformed $kv{} empty path", + stores: map[string]kv.Provider{"vault": &mockProvider{value: "x"}}, + input: "$kv{vault:}", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "malformed $kv{} empty store name", + input: "$kv{:path}", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "malformed kv:// empty store name and path", + input: "kv:///", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "malformed $kv{} empty store name and path", + input: "$kv{:}", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "malformed $kv{} empty path with fragment", + stores: map[string]kv.Provider{"vault": &mockProvider{value: "x"}}, + input: "$kv{vault:#field}", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "whole-value takes precedence over inline token in path", + stores: map[string]kv.Provider{"vault": &mockProvider{value: "resolved"}}, + input: "kv://vault/$kv{env:SUFFIX}", + want: "resolved", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + r := resolve.NewResolver(newGetter(tc.stores)) + got, err := r.Resolve(t.Context(), tc.input) + + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestResolve_PathRouting(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantKey string + }{ + { + name: "kv:// single-segment path", + input: "kv://vault/secret", + wantKey: "secret", + }, + { + name: "kv:// multi-segment path", + input: "kv://vault/db/creds", + wantKey: "db/creds", + }, + { + name: "kv:// path with fragment stripped before Get", + input: "kv://vault/db/creds#password", + wantKey: "db/creds", + }, + { + name: "inline $kv{} path", + input: "$kv{vault:db/creds}", + wantKey: "db/creds", + }, + { + name: "inline $kv{} path with fragment stripped before Get", + input: "$kv{vault:db/creds#password}", + wantKey: "db/creds", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + provider := &mockProvider{value: `{"password":"secret"}`} + getter := &mockStoreGetter{stores: map[string]kv.Provider{"vault": provider}} + r := resolve.NewResolver(getter) + + _, err := r.Resolve(t.Context(), tc.input) + assert.NoError(t, err) + assert.Equal(t, tc.wantKey, provider.lastKey) + }) + } +} + +func TestResolve_MultipleFailedTokens_AllReported(t *testing.T) { + t.Parallel() + + r := resolve.NewResolver(newGetter(nil)) + + _, err := r.Resolve(t.Context(), "$kv{ghost1:a}/$kv{ghost2:b}") + + require.Error(t, err) + assert.ErrorIs(t, err, kv.ErrStoreNotFound) + assert.Contains(t, err.Error(), "ghost1") + assert.Contains(t, err.Error(), "ghost2") +} + +func TestResolve_MixedFailedAndResolvableTokens_ErrorWins(t *testing.T) { + t.Parallel() + + getter := newGetter(map[string]kv.Provider{ + "env": &mockProvider{value: "ok"}, + }) + r := resolve.NewResolver(getter) + + got, err := r.Resolve(t.Context(), "$kv{env:GOOD}/$kv{ghost:bad}") + + assert.ErrorIs(t, err, kv.ErrStoreNotFound) + assert.Empty(t, got) +} + +func TestResolve_KeyNotFound(t *testing.T) { + t.Parallel() + + getter := newGetter(map[string]kv.Provider{ + "env": &mockProvider{err: &kv.KeyNotFoundError{StoreName: "env", KeyPath: "MISSING"}}, + }) + r := resolve.NewResolver(getter) + + _, err := r.Resolve(t.Context(), "kv://env/MISSING") + + var target *kv.KeyNotFoundError + assert.ErrorAs(t, err, &target) +} + +func TestResolve_WholeValue_FragmentNormalization(t *testing.T) { + t.Parallel() + + getter := newGetter(map[string]kv.Provider{ + "env": &mockProvider{value: `{"password":"secret"}`}, + }) + r := resolve.NewResolver(getter) + + withoutSlash, err := r.Resolve(t.Context(), "kv://env/MY_KEY#password") + assert.NoError(t, err) + + withSlash, err := r.Resolve(t.Context(), "kv://env/MY_KEY#/password") + assert.NoError(t, err) + + assert.Equal(t, withoutSlash, withSlash) + assert.Equal(t, "secret", withSlash) +} + +func TestResolve_Inline_FragmentNormalization(t *testing.T) { + t.Parallel() + + getter := newGetter(map[string]kv.Provider{ + "env": &mockProvider{value: `{"password":"secret"}`}, + }) + r := resolve.NewResolver(getter) + + withoutSlash, err := r.Resolve(t.Context(), "prefix-$kv{env:MY_KEY#password}-suffix") + assert.NoError(t, err) + + withSlash, err := r.Resolve(t.Context(), "prefix-$kv{env:MY_KEY#/password}-suffix") + assert.NoError(t, err) + + assert.Equal(t, withoutSlash, withSlash) + assert.Equal(t, "prefix-secret-suffix", withSlash) +} + +func TestResolve_JSONPointer_ObjectLeaf_ReserializedAsJSON(t *testing.T) { + t.Parallel() + + getter := newGetter(map[string]kv.Provider{ + "vault": &mockProvider{value: `{"db":{"host":"localhost","port":5432}}`}, + }) + r := resolve.NewResolver(getter) + + got, err := r.Resolve(t.Context(), "kv://vault/secret#/db") + assert.NoError(t, err) + + var reserialized map[string]any + assert.NoError(t, json.Unmarshal([]byte(got), &reserialized)) + assert.Equal(t, "localhost", reserialized["host"]) + assert.Equal(t, float64(5432), reserialized["port"]) +} + +func TestResolveAll_FlatDocument(t *testing.T) { + t.Parallel() + + getter := newGetter(map[string]kv.Provider{ + "env": &mockProvider{value: "resolved-value"}, + }) + r := resolve.NewResolver(getter) + + input := []byte(`{"host":"kv://env/HOST","port":"5432"}`) + + got, err := r.ResolveAll(t.Context(), input) + assert.NoError(t, err) + + var result map[string]string + require.NoError(t, json.Unmarshal(got, &result)) + assert.Equal(t, "resolved-value", result["host"]) + assert.Equal(t, "5432", result["port"]) +} + +func TestResolveAll_MixedSyntax(t *testing.T) { + t.Parallel() + + provider := &mockProvider{value: "resolved"} + getter := newGetter(map[string]kv.Provider{"env": provider}) + r := resolve.NewResolver(getter) + + input := []byte(`{ + "host": "kv://env/HOST", + "dsn": "postgres://$kv{env:USER}@localhost/db" + }`) + + got, err := r.ResolveAll(t.Context(), input) + require.NoError(t, err) + + var result map[string]string + require.NoError(t, json.Unmarshal(got, &result)) + assert.Equal(t, "resolved", result["host"]) + assert.Equal(t, "postgres://resolved@localhost/db", result["dsn"]) +} + +func TestResolveAll_NestedObjectAndArray(t *testing.T) { + t.Parallel() + + getter := newGetter(map[string]kv.Provider{ + "env": &mockProvider{value: "deep-value"}, + }) + r := resolve.NewResolver(getter) + + input := []byte(`{"outer":{"inner":"kv://env/KEY","list":["kv://env/A","plain"]}}`) + + got, err := r.ResolveAll(t.Context(), input) + assert.NoError(t, err) + + type nested struct { + Outer struct { + Inner string `json:"inner"` + List []string `json:"list"` + } `json:"outer"` + } + var result nested + require.NoError(t, json.Unmarshal(got, &result)) + assert.Equal(t, "deep-value", result.Outer.Inner) + assert.Equal(t, "deep-value", result.Outer.List[0]) + assert.Equal(t, "plain", result.Outer.List[1]) +} + +func TestResolveAll_NonStringValuesPreserved(t *testing.T) { + t.Parallel() + + getter := newGetter(map[string]kv.Provider{ + "env": &mockProvider{value: "ok"}, + }) + r := resolve.NewResolver(getter) + + input := []byte(`{"flag":true,"count":42,"nothing":null,"name":"kv://env/NAME"}`) + + got, err := r.ResolveAll(t.Context(), input) + assert.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(got, &result)) + assert.Equal(t, true, result["flag"]) + assert.Equal(t, float64(42), result["count"]) + assert.Nil(t, result["nothing"]) + assert.Equal(t, "ok", result["name"]) +} + +func TestResolveAll_NoKVReferences_ReturnedUnchanged(t *testing.T) { + t.Parallel() + + r := resolve.NewResolver(newGetter(nil)) + + input := []byte(`{"host":"localhost","port":5432}`) + + got, err := r.ResolveAll(t.Context(), input) + assert.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(got, &result)) + assert.Equal(t, "localhost", result["host"]) + assert.Equal(t, float64(5432), result["port"]) +} + +func TestResolveAll_EmptyDocument(t *testing.T) { + t.Parallel() + + r := resolve.NewResolver(newGetter(nil)) + + got, err := r.ResolveAll(t.Context(), []byte(`{}`)) + assert.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(got, &result)) + assert.Empty(t, result) +} + +func TestResolveAll_Errors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stores map[string]kv.Provider + input []byte + wantErr error + }{ + { + name: "unresolvable kv reference returns store error", + input: []byte(`{"a":"kv://missing/key","b":"plain"}`), + wantErr: kv.ErrStoreNotFound, + }, + { + name: "invalid JSON input returns error", + input: []byte(`not json containing kv://store/key`), + wantErr: resolve.ErrInvalidJSON, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + r := resolve.NewResolver(newGetter(tc.stores)) + _, err := r.ResolveAll(t.Context(), tc.input) + + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + return + } + }) + } +} + +func TestResolveAll_LargeIntegersPreserved(t *testing.T) { + t.Parallel() + + getter := newGetter(map[string]kv.Provider{ + "env": &mockProvider{value: "resolved"}, + }) + r := resolve.NewResolver(getter) + + input := []byte(`{"id":9007199254740993,"nested":{"ts":1749600000000000001},"host":"kv://env/HOST"}`) + + got, err := r.ResolveAll(t.Context(), input) + require.NoError(t, err) + + assert.Contains(t, string(got), "9007199254740993") + assert.Contains(t, string(got), "1749600000000000001") + assert.Contains(t, string(got), `"host":"resolved"`) +} + +func TestResolveAll_FloatsPreserved(t *testing.T) { + t.Parallel() + + getter := newGetter(map[string]kv.Provider{ + "env": &mockProvider{value: "resolved"}, + }) + r := resolve.NewResolver(getter) + + input := []byte(`{"ratio":0.25,"host":"kv://env/HOST"}`) + + got, err := r.ResolveAll(t.Context(), input) + require.NoError(t, err) + + assert.Contains(t, string(got), "0.25") +} + +func TestResolveAll_InvalidJSONWithoutRefs_ReturnsError(t *testing.T) { + t.Parallel() + + r := resolve.NewResolver(newGetter(nil)) + + _, err := r.ResolveAll(t.Context(), []byte(`{definitely not json`)) + assert.ErrorIs(t, err, resolve.ErrInvalidJSON) +} + +func TestResolveAll_ValidJSONWithoutRefs_ByteIdentical(t *testing.T) { + t.Parallel() + + r := resolve.NewResolver(newGetter(nil)) + + input := []byte("{\n \"z\": 1,\n \"a\": \"plain\"\n}") + + got, err := r.ResolveAll(t.Context(), input) + assert.NoError(t, err) + assert.Equal(t, input, got) +} + +func TestExtractJSONPointer_LargeIntegerLeaf(t *testing.T) { + t.Parallel() + + getter := newGetter(map[string]kv.Provider{ + "vault": &mockProvider{value: `{"big_id":9007199254740993}`}, + }) + r := resolve.NewResolver(getter) + + got, err := r.Resolve(t.Context(), "kv://vault/secret#big_id") + require.NoError(t, err) + assert.Equal(t, "9007199254740993", got) +} + +func TestResolveAll_NoHTMLEscaping(t *testing.T) { + t.Parallel() + + getter := newGetter(map[string]kv.Provider{ + "env": &mockProvider{value: "https://upstream.internal/?a=1&b=<2>"}, + }) + r := resolve.NewResolver(getter) + + input := []byte(`{"url":"https://example.com/?x=1&y=2","target":"kv://env/UPSTREAM"}`) + + got, err := r.ResolveAll(t.Context(), input) + require.NoError(t, err) + + assert.Contains(t, string(got), "https://example.com/?x=1&y=2") + assert.Contains(t, string(got), "https://upstream.internal/?a=1&b=<2>") + assert.NotContains(t, string(got), `\u0026`) + assert.NotContains(t, string(got), `\u003c`) +} + +// -------------------------------------------------------------------------- +// Benchmarks +// -------------------------------------------------------------------------- + +func BenchmarkResolve_ThreeInlineTokensWithFragment(b *testing.B) { + payload := `{"host":"db.internal","port":"5432"}` + getter := newGetter(map[string]kv.Provider{ + "vault": &mockProvider{value: payload}, + "env": &mockProvider{value: "simple-value"}, + }) + r := resolve.NewResolver(getter) + input := "postgres://$kv{vault:db/creds#host}:$kv{vault:db/creds#port}/$kv{env:DB_NAME}" + ctx := context.Background() + + b.ResetTimer() + + for b.Loop() { + _, err := r.Resolve(ctx, input) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/kv/provider.go b/kv/provider.go index 81b3ce8b..0b80ce8a 100644 --- a/kv/provider.go +++ b/kv/provider.go @@ -58,6 +58,11 @@ type Provider interface { KeyValueRetriever } +// StoreGetter retrieves an initialized store by name. +type StoreGetter interface { + GetStore(name string) (Provider, error) +} + // ProviderFactory creates a specific provider instance from raw JSON configuration. // Each provider type registers its own factory function that knows how to parse // its specific configuration format and return a configured Provider. diff --git a/kv/resolver/errors.go b/kv/resolver/errors.go new file mode 100644 index 00000000..3a1bd9c7 --- /dev/null +++ b/kv/resolver/errors.go @@ -0,0 +1,9 @@ +package resolver + +import "github.com/TykTechnologies/storage/kv/internal/resolve" + +var ( + ErrInvalidJSON = resolve.ErrInvalidJSON + ErrFieldNotFound = resolve.ErrFieldNotFound + ErrMalformedReference = resolve.ErrMalformedReference +) diff --git a/kv/resolver/resolver.go b/kv/resolver/resolver.go index d354d3ee..ef11439b 100644 --- a/kv/resolver/resolver.go +++ b/kv/resolver/resolver.go @@ -3,7 +3,8 @@ package resolver import ( "context" - "github.com/TykTechnologies/storage/kv/registry" + "github.com/TykTechnologies/storage/kv" + "github.com/TykTechnologies/storage/kv/internal/resolve" ) // Resolver handles string replacement for KV references in configuration strings. @@ -21,34 +22,40 @@ type Resolver interface { // their resolved values from the configured stores. // // Returns the resolved string with all KV references replaced, or an error - // if any reference cannot be resolved. + // if any reference cannot be resolved. When several inline tokens fail, + // all failures are reported in a single joined error. // // If the input contains no KV references, it is returned unchanged. + // + // Precedence: an input starting with "kv://" is treated as a whole-value + // reference — everything after the store name is the path, including any + // "$kv{...}" text, which is NOT expanded. Inline tokens are only processed + // in strings that do not start with "kv://". An inline path cannot contain + // "}" — use the whole-value form for such keys. Resolve(ctx context.Context, input string) (string, error) -} - -// ResolveConfig processes an entire configuration and resolves any -// KV references found within string fields. -// -// This function enables config-level resolution during component startup, -// allowing any string field in any configuration structure to contain -// KV references that will be resolved before the config is used. -// -// The resolver traverses the JSON structure recursively, applying Resolve() -// to all string values while preserving the overall structure and non-string -// fields unchanged. -func ResolveConfig(ctx context.Context, resolver Resolver, rawConfig []byte) ([]byte, error) { - return nil, nil -} - -type resolver struct { - registry *registry.Registry -} -func NewResolver(registry *registry.Registry) Resolver { - return &resolver{registry: registry} + // ResolveAll walks a raw JSON document recursively and applies Resolve to + // every string value found at any depth, including inside nested objects + // and arrays. Non-string scalars (numbers, booleans, null) are left as-is; + // number values preserve their exact representation (no float64 precision + // loss for large integers). + // + // Returns the re-serialized document with all KV references replaced, or an + // error if any reference cannot be resolved. On error the document is not + // partially written. + // + // If the input is not valid JSON, ErrInvalidJSON is returned. A valid + // document containing no KV syntax (no "kv://" or "$kv{" substrings) is + // returned byte-for-byte unchanged. Documents that do contain KV syntax + // are re-serialized: the output normalizes formatting (object keys sorted, + // insignificant whitespace removed) while preserving all values. + // HTML characters (&, <, >) are NOT escaped in the output. + ResolveAll(ctx context.Context, rawJSON []byte) ([]byte, error) } -func (r *resolver) Resolve(ctx context.Context, input string) (string, error) { - return "", nil +// NewResolver returns a Resolver that resolves references against the given +// store getter (typically *registry.Registry). Unresolvable references are +// always errors. +func NewResolver(registry kv.StoreGetter) Resolver { + return resolve.NewResolver(registry) } diff --git a/kv/resolver/resolver_test.go b/kv/resolver/resolver_test.go new file mode 100644 index 00000000..4f4e8b84 --- /dev/null +++ b/kv/resolver/resolver_test.go @@ -0,0 +1,138 @@ +// This file is a facade-level smoke test: +// it proves the public API delegates to the engine and +// that the re-exported error sentinels keep their identity, so +// errors.Is works for external callers. +package resolver_test + +import ( + "context" + "testing" + + "github.com/TykTechnologies/storage/kv" + "github.com/TykTechnologies/storage/kv/resolver" + "github.com/stretchr/testify/require" +) + +type stubProvider struct { + value string + err error +} + +func (s stubProvider) Get(_ context.Context, _ string) (string, error) { + return s.value, s.err +} + +type stubStores map[string]kv.Provider + +func (s stubStores) GetStore(name string) (kv.Provider, error) { + p, ok := s[name] + if !ok { + return nil, kv.NewStoreNotFoundError(name) + } + + return p, nil +} + +func TestNewResolverDelegatesToEngine(t *testing.T) { + t.Parallel() + + res := resolver.NewResolver(stubStores{ + "vault": stubProvider{value: `{"password":"hunter2"}`}, + "env": stubProvider{value: "myhost.com"}, + }) + + t.Run("whole-value reference", func(t *testing.T) { + t.Parallel() + + got, err := res.Resolve(t.Context(), "kv://env/API_HOST") + require.NoError(t, err) + require.Equal(t, "myhost.com", got) + }) + + t.Run("whole-value reference with fragment extraction", func(t *testing.T) { + t.Parallel() + + got, err := res.Resolve(t.Context(), "kv://vault/db/creds#password") + require.NoError(t, err) + require.Equal(t, "hunter2", got) + }) + + t.Run("inline token inside a larger string", func(t *testing.T) { + t.Parallel() + + got, err := res.Resolve(t.Context(), "https://$kv{env:API_HOST}/v1") + require.NoError(t, err) + require.Equal(t, "https://myhost.com/v1", got) + }) + + t.Run("ResolveAll resolves string values across a document", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{"url":"https://$kv{env:API_HOST}/v1","secret":"kv://vault/db/creds#password","port":8080}`) + + got, err := res.ResolveAll(t.Context(), doc) + require.NoError(t, err) + require.JSONEq(t, `{"url":"https://myhost.com/v1","secret":"hunter2","port":8080}`, string(got)) + }) + + t.Run("ResolveAll returns document without KV syntax unchanged", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{"plain": "value", "n": 1}`) + + got, err := res.ResolveAll(t.Context(), doc) + require.NoError(t, err) + require.Equal(t, doc, got) + }) +} + +func TestPublicErrorSentinelsSurviveTheFacade(t *testing.T) { + t.Parallel() + + res := resolver.NewResolver(stubStores{ + "vault": stubProvider{value: `{"user":"admin"}`}, + }) + + t.Run("malformed reference matches resolver.ErrMalformedReference", func(t *testing.T) { + t.Parallel() + + _, err := res.Resolve(t.Context(), "kv://no-path-separator") + require.ErrorIs(t, err, resolver.ErrMalformedReference) + }) + + t.Run("missing JSON field matches resolver.ErrFieldNotFound", func(t *testing.T) { + t.Parallel() + + _, err := res.Resolve(t.Context(), "kv://vault/creds#missing_field") + require.ErrorIs(t, err, resolver.ErrFieldNotFound) + }) + + t.Run("invalid document matches resolver.ErrInvalidJSON", func(t *testing.T) { + t.Parallel() + + _, err := res.ResolveAll(t.Context(), []byte(`{not json`)) + require.ErrorIs(t, err, resolver.ErrInvalidJSON) + }) + + t.Run("unknown store matches kv.ErrStoreNotFound", func(t *testing.T) { + t.Parallel() + + _, err := res.Resolve(t.Context(), "kv://absent/some/key") + require.ErrorIs(t, err, kv.ErrStoreNotFound) + }) +} + +func TestProviderErrorsPropagateThroughFacade(t *testing.T) { + t.Parallel() + + keyErr := &kv.KeyNotFoundError{StoreName: "vault", KeyPath: "db/creds"} + res := resolver.NewResolver(stubStores{ + "vault": stubProvider{err: keyErr}, + }) + + _, err := res.Resolve(t.Context(), "kv://vault/db/creds") + + var got *kv.KeyNotFoundError + require.ErrorAs(t, err, &got) + require.Equal(t, "vault", got.StoreName) +} From 46774a68d982825f482e59ad7928240e694151b3 Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi <109525963+vladzabolotnyi@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:04:57 +0300 Subject: [PATCH 11/31] TT-17242: Implement config-driven registry initialization API (#142) * feat: add implementation to Resolve func * feat: add resolveAll implementation * test: add test cases for json pointer extraction * refactor: update error messages and make error handlin more consistent * refactor: update resolve tests * refactor: update resolveAll tests with grouping error cases * fix: added malformed err with tests, added docs string * test: add test suite with path routing * fix: remove redundant assertions and update comment * test: add test case that resolve all resolved mixed values * fix: use decoding with numbers to avoid losing large integers and update doc strings explaining inner behavior * fix: add checks with tests to avoid processing values when store or key is empty string * fix: remove spaces around em-dashes and add missing tests for missing stores and keys * chore: remove redundant comments * fix: address sonarqube comment with avoiding duplication * fix: add json validation on document even if it doesn't contain kv refs * refactor: split resolver for public and private parts to hide inner logic with lenient mode in future * chore: replace background context with test one * feat: add cache bypass context logic to skip serving the value from cache when its desired * feat: add lenient mode to the resolver * feat: add base implementation of new from config * refactor: update implementation with clear func separation and code simplification; add docs * fix: lint errors * chore: remove redundant comments * feat: add file provider as a type with registry processing it as a local type provider * refactor: add more comments and explanation about phase1 and phase2 and why we need them to increase readability * refactor: remove isLocalType func and attach the logic to provider type to avoid OCP and increase discoverability * fix: missing pieces from last commit * docs: update docstring and comment explaining better why process should resolved value for non-local stores only * feat: add ability to override default factories when WithFactories opt is used * test: add tests covering logic with factories overriding --------- Co-authored-by: Vlad Zabolotnyi --- kv/context.go | 24 + kv/internal/resolve/lenient_test.go | 177 ++++++ kv/internal/resolve/resolver.go | 33 +- kv/internal/store/cache_bypass_test.go | 231 ++++++++ kv/internal/store/store.go | 2 +- kv/provider.go | 18 +- kv/provider_test.go | 29 + kv/registry/from_config.go | 199 +++++++ kv/registry/from_config_test.go | 764 +++++++++++++++++++++++++ kv/registry/registry.go | 20 +- 10 files changed, 1491 insertions(+), 6 deletions(-) create mode 100644 kv/context.go create mode 100644 kv/internal/resolve/lenient_test.go create mode 100644 kv/internal/store/cache_bypass_test.go create mode 100644 kv/registry/from_config.go create mode 100644 kv/registry/from_config_test.go diff --git a/kv/context.go b/kv/context.go new file mode 100644 index 00000000..85bb6337 --- /dev/null +++ b/kv/context.go @@ -0,0 +1,24 @@ +package kv + +import "context" + +type cacheBypassKey struct{} + +// WithCacheBypass returns a context that instructs SecretStore.Get to skip +// the cache read, fetch from the backend, and re-populate the cache entry. +// It is context-carried because the call path (Resolver.Resolve → +// Registry.GetStore → Provider.Get) has fixed signatures with no room for +// an options parameter. +func WithCacheBypass(ctx context.Context) context.Context { + return context.WithValue(ctx, cacheBypassKey{}, true) +} + +// IsCacheBypassed reports whether context carries the cache-bypass directive. +func IsCacheBypassed(ctx context.Context) bool { + bypassed, ok := ctx.Value(cacheBypassKey{}).(bool) + if !ok { + return false + } + + return bypassed +} diff --git a/kv/internal/resolve/lenient_test.go b/kv/internal/resolve/lenient_test.go new file mode 100644 index 00000000..aabd278c --- /dev/null +++ b/kv/internal/resolve/lenient_test.go @@ -0,0 +1,177 @@ +package resolve_test + +import ( + "context" + "errors" + "testing" + + "github.com/TykTechnologies/storage/kv" + "github.com/TykTechnologies/storage/kv/internal/resolve" + "github.com/stretchr/testify/require" +) + +func TestLenientResolve(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stores map[string]kv.Provider + input string + want string + wantErr error + }{ + { + name: "whole-value reference to absent store returned unchanged", + input: "kv://vault/db/creds#password", + want: "kv://vault/db/creds#password", + }, + { + name: "inline token to absent store left in place", + stores: map[string]kv.Provider{"env": &mockProvider{value: "myhost.com"}}, + input: "https://$kv{env:API_HOST}/$kv{vault:db/creds#password}", + want: "https://myhost.com/$kv{vault:db/creds#password}", + }, + { + name: "multiple inline tokens to absent stores all left in place", + input: "$kv{vault:a}/$kv{consul:b}", + want: "$kv{vault:a}/$kv{consul:b}", + }, + { + name: "fully resolvable input resolves exactly as in strict mode", + stores: map[string]kv.Provider{"env": &mockProvider{value: "s3cr3t"}}, + input: "kv://env/TOKEN", + want: "s3cr3t", + }, + { + name: "plain string without references passes through", + input: "no references here", + want: "no references here", + }, + { + name: "present store with missing key still errors", + stores: map[string]kv.Provider{ + "env": &mockProvider{err: &kv.KeyNotFoundError{StoreName: "env", KeyPath: "MISSING"}}, + }, + input: "kv://env/MISSING", + wantErr: &kv.KeyNotFoundError{}, + }, + { + name: "malformed whole-value reference still errors", + input: "kv://no-path-separator", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "malformed inline token still errors", + input: "prefix-$kv{missing-store-separator}-suffix", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "missing JSON field on a reachable store still errors", + stores: map[string]kv.Provider{"env": &mockProvider{value: `{"user":"admin"}`}}, + input: "kv://env/creds#missing_field", + + wantErr: resolve.ErrFieldNotFound, + }, + { + name: "absent-store token coexists with an error on a reachable store", + stores: map[string]kv.Provider{ + "env": &mockProvider{err: &kv.KeyNotFoundError{StoreName: "env", KeyPath: "A"}}, + }, + input: "$kv{env:A}/$kv{vault:b}", + wantErr: &kv.KeyNotFoundError{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := resolve.NewResolver(newGetter(tt.stores), resolve.WithLenientMode()) + + got, err := r.Resolve(t.Context(), tt.input) + + if tt.wantErr != nil { + var keyNotFound *kv.KeyNotFoundError + if errors.As(tt.wantErr, &keyNotFound) { + require.ErrorAs(t, err, &keyNotFound) + } else { + require.ErrorIs(t, err, tt.wantErr) + } + + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestLenientResolveAll(t *testing.T) { + t.Parallel() + + t.Run("absent-store references pass through, resolvable ones resolve", func(t *testing.T) { + t.Parallel() + + r := resolve.NewResolver( + newGetter(map[string]kv.Provider{"env": &mockProvider{value: "hvs.secret-token"}}), + resolve.WithLenientMode(), + ) + + doc := []byte(`{ + "token": "kv://env/VAULT_TOKEN", + "credentials": "kv://vault/aws-creds", + "url": "https://$kv{env:VAULT_TOKEN}@$kv{consul:host}" + }`) + + got, err := r.ResolveAll(context.Background(), doc) + require.NoError(t, err) + require.JSONEq(t, `{ + "token": "hvs.secret-token", + "credentials": "kv://vault/aws-creds", + "url": "https://hvs.secret-token@$kv{consul:host}" + }`, string(got)) + }) + + t.Run("malformed reference inside the document still errors", func(t *testing.T) { + t.Parallel() + + r := resolve.NewResolver(newGetter(nil), resolve.WithLenientMode()) + + _, err := r.ResolveAll(context.Background(), []byte(`{"bad": "kv://no-path-separator"}`)) + require.ErrorIs(t, err, resolve.ErrMalformedReference) + }) + + t.Run("invalid JSON still errors", func(t *testing.T) { + t.Parallel() + + r := resolve.NewResolver(newGetter(nil), resolve.WithLenientMode()) + + _, err := r.ResolveAll(context.Background(), []byte(`{not json`)) + require.ErrorIs(t, err, resolve.ErrInvalidJSON) + }) +} + +// Strict mode is the default and must be completely unaffected by the +// existence of the lenient option. +func TestStrictModeRemainsDefault(t *testing.T) { + t.Parallel() + + t.Run("whole-value reference to absent store errors", func(t *testing.T) { + t.Parallel() + + r := resolve.NewResolver(newGetter(nil)) + + _, err := r.Resolve(context.Background(), "kv://vault/db/creds") + require.ErrorIs(t, err, kv.ErrStoreNotFound) + }) + + t.Run("inline token to absent store errors", func(t *testing.T) { + t.Parallel() + + r := resolve.NewResolver(newGetter(nil)) + + _, err := r.Resolve(context.Background(), "https://$kv{vault:host}/v1") + require.ErrorIs(t, err, kv.ErrStoreNotFound) + }) +} diff --git a/kv/internal/resolve/resolver.go b/kv/internal/resolve/resolver.go index b166508f..a5f8a852 100644 --- a/kv/internal/resolve/resolver.go +++ b/kv/internal/resolve/resolver.go @@ -14,10 +14,31 @@ import ( type Resolver struct { registry kv.StoreGetter + lenient bool } type Option func(*Resolver) +// WithLenientMode makes the resolver leave any reference that targets an +// unknown store (kv.ErrStoreNotFound) unchanged, instead of failing. +// +// It exists for exactly one caller: Phase 1 of the registry bootstrap, +// which resolves store configs before the remote stores they may reference +// have been initialized. At that point the absent stores are precisely the +// remote ones, and their references must pass through verbatim so the +// caller's later strict pass can resolve them. +// +// Lenient mode tolerates absent stores only: malformed references, +// reachable stores with missing keys, and missing JSON fields still fail, +// so config typos are never silently masked. Deliberately not exposed +// through the public kv/resolver facade — exposing it would invite callers +// to suppress resolution errors wholesale. +func WithLenientMode() Option { + return func(r *Resolver) { + r.lenient = true + } +} + func NewResolver(registry kv.StoreGetter, opts ...Option) *Resolver { r := &Resolver{registry: registry} for _, opt := range opts { @@ -54,7 +75,12 @@ func (r *Resolver) Resolve(ctx context.Context, input string) (string, error) { ) } - return r.fetchAndExtract(ctx, storeName, path, fragment) + res, err := r.fetchAndExtract(ctx, storeName, path, fragment) + if r.lenient && errors.Is(err, kv.ErrStoreNotFound) { + return input, nil + } + + return res, err } var resolveErrs []error @@ -89,7 +115,12 @@ func (r *Resolver) Resolve(ctx context.Context, input string) (string, error) { val, err := r.fetchAndExtract(ctx, storeName, path, fragment) if err != nil { + if r.lenient && errors.Is(err, kv.ErrStoreNotFound) { + return match + } + resolveErrs = append(resolveErrs, err) + return match } diff --git a/kv/internal/store/cache_bypass_test.go b/kv/internal/store/cache_bypass_test.go new file mode 100644 index 00000000..14c3a1ea --- /dev/null +++ b/kv/internal/store/cache_bypass_test.go @@ -0,0 +1,231 @@ +package store + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/TykTechnologies/storage/kv" + "github.com/stretchr/testify/require" +) + +func sequencedProvider() *mockProvider { + var n int32 + + return &mockProvider{ + mockGetFunc: func(_ context.Context, _ string) (string, error) { + return fmt.Sprintf("v%d", atomic.AddInt32(&n, 1)), nil + }, + } +} + +func TestGetWithCacheBypass(t *testing.T) { + t.Parallel() + + enabledCache := kv.CacheConfig{Enabled: true, TTL: "1m"} + + t.Run("bypass skips cache read and returns fresh value", func(t *testing.T) { + t.Parallel() + + provider := sequencedProvider() + store := newTestStore(t, provider, enabledCache) + + first, err := store.Get(t.Context(), "db/password") + require.NoError(t, err) + require.Equal(t, "v1", first) + + cached, err := store.Get(t.Context(), "db/password") + require.NoError(t, err) + require.Equal(t, "v1", cached) + require.Equal(t, int32(1), provider.calls.Load(), "second Get must be served from cache") + + fresh, err := store.Get(kv.WithCacheBypass(t.Context()), "db/password") + require.NoError(t, err) + require.Equal(t, "v2", fresh) + require.Equal(t, int32(2), provider.calls.Load(), "bypass Get must hit the provider") + }) + + t.Run("bypass re-populates the cache entry", func(t *testing.T) { + t.Parallel() + + provider := sequencedProvider() + store := newTestStore(t, provider, enabledCache) + + _, err := store.Get(t.Context(), "db/password") // v1 cached + require.NoError(t, err) + + fresh, err := store.Get(kv.WithCacheBypass(t.Context()), "db/password") + require.NoError(t, err) + require.Equal(t, "v2", fresh) + + after, err := store.Get(t.Context(), "db/password") + require.NoError(t, err) + require.Equal(t, "v2", after) + require.Equal(t, int32(2), provider.calls.Load(), "post-bypass Get must be served from the re-populated cache") + }) + + t.Run("bypass on a never-cached key behaves like a normal miss", func(t *testing.T) { + t.Parallel() + + provider := sequencedProvider() + store := newTestStore(t, provider, enabledCache) + + fresh, err := store.Get(kv.WithCacheBypass(t.Context()), "db/password") + require.NoError(t, err) + require.Equal(t, "v1", fresh) + require.Equal(t, int32(1), provider.calls.Load()) + + cached, err := store.Get(t.Context(), "db/password") + require.NoError(t, err) + require.Equal(t, "v1", cached) + require.Equal(t, int32(1), provider.calls.Load(), "value fetched via bypass must be cached") + }) + + t.Run("bypass skips a cached negative entry", func(t *testing.T) { + t.Parallel() + + var n int32 + provider := &mockProvider{ + mockGetFunc: func(_ context.Context, _ string) (string, error) { + if atomic.AddInt32(&n, 1) == 1 { + return "", &kv.KeyNotFoundError{StoreName: "test", KeyPath: "db/password"} + } + + return "recovered", nil + }, + } + store := newTestStore(t, provider, enabledCache) + + _, err := store.Get(t.Context(), "db/password") + require.Error(t, err) + + _, err = store.Get(t.Context(), "db/password") + require.Error(t, err) + require.Equal(t, int32(1), provider.calls.Load(), "error must be served from negative cache") + + fresh, err := store.Get(kv.WithCacheBypass(t.Context()), "db/password") + require.NoError(t, err) + require.Equal(t, "recovered", fresh) + + cached, err := store.Get(t.Context(), "db/password") + require.NoError(t, err) + require.Equal(t, "recovered", cached) + require.Equal(t, int32(2), provider.calls.Load()) + }) + + t.Run("provider error during bypass propagates instead of stale value", func(t *testing.T) { + t.Parallel() + + var n int32 + provider := &mockProvider{ + mockGetFunc: func(_ context.Context, _ string) (string, error) { + if atomic.AddInt32(&n, 1) == 1 { + return "v1", nil + } + + return "", &kv.StoreUnavailableError{ + StoreName: "test", + KeyPath: "db/password", + Err: fmt.Errorf("backend down"), + } + }, + } + store := newTestStore(t, provider, enabledCache) + + _, err := store.Get(t.Context(), "db/password") + require.NoError(t, err) + + _, err = store.Get(kv.WithCacheBypass(t.Context()), "db/password") + require.Error( + t, + err, + "bypass asked for a fresh value; a stale fallback would hide rotation failures", + ) + + var unavailable *kv.StoreUnavailableError + require.ErrorAs(t, err, &unavailable) + }) + + t.Run("bypass with disabled cache behaves like a plain Get", func(t *testing.T) { + t.Parallel() + + provider := sequencedProvider() + store := newTestStore(t, provider, kv.CacheConfig{Enabled: false}) + + got, err := store.Get(kv.WithCacheBypass(t.Context()), "db/password") + require.NoError(t, err) + require.Equal(t, "v1", got) + + got, err = store.Get(kv.WithCacheBypass(t.Context()), "db/password") + require.NoError(t, err) + require.Equal(t, "v2", got) + }) + + t.Run("context without marker keeps existing cache behavior", func(t *testing.T) { + t.Parallel() + + provider := sequencedProvider() + store := newTestStore(t, provider, enabledCache) + + for range 5 { + got, err := store.Get(t.Context(), "db/password") + require.NoError(t, err) + require.Equal(t, "v1", got) + } + + require.Equal(t, int32(1), provider.calls.Load(), "plain Gets must keep serving from cache") + }) +} + +func TestGetWithCacheBypassConcurrency(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var n int32 + provider := &mockProvider{ + delay: time.Second, + mockGetFunc: func(_ context.Context, _ string) (string, error) { + return fmt.Sprintf("v%d", atomic.AddInt32(&n, 1)), nil + }, + } + store := newTestStore(t, provider, kv.CacheConfig{Enabled: true, TTL: "1m"}) + ctx := context.Background() + + seeded, err := store.Get(ctx, "db/password") + require.NoError(t, err) + require.Equal(t, "v1", seeded) + + var wg sync.WaitGroup + + // Bypass Gets block on the provider's delay; they must dedupe into + // one fetch via singleflight. + for range 5 { + wg.Go(func() { + got, err := store.Get(kv.WithCacheBypass(ctx), "db/password") + require.NoError(t, err) + require.Equal(t, "v2", got) + }) + } + + // Non-bypass Gets run while the bypass fetch is in flight: they are + // served the still-valid cached value without blocking. + for range 5 { + wg.Go(func() { + got, err := store.Get(ctx, "db/password") + require.NoError(t, err) + require.Equal(t, "v1", got) + }) + } + + wg.Wait() + + require.Equal(t, int32(2), provider.calls.Load(), "5 concurrent bypass Gets must collapse into one provider fetch") + + after, err := store.Get(ctx, "db/password") + require.NoError(t, err) + require.Equal(t, "v2", after) + require.Equal(t, int32(2), provider.calls.Load()) + }) +} diff --git a/kv/internal/store/store.go b/kv/internal/store/store.go index 59e6ead0..23a7c120 100644 --- a/kv/internal/store/store.go +++ b/kv/internal/store/store.go @@ -35,7 +35,7 @@ func (s *SecretStore) Get(ctx context.Context, path string) (string, error) { } val, exists, needsRefresh, err := s.cache.Get(path) - if exists { + if exists && !kv.IsCacheBypassed(ctx) { // Fail fast on cached errors if err != nil { return "", err diff --git a/kv/provider.go b/kv/provider.go index 0b80ce8a..c8cda1ef 100644 --- a/kv/provider.go +++ b/kv/provider.go @@ -18,15 +18,15 @@ const ( // Inline resolves secrets from plain text in the configuration. Inline ProviderType = "inline" + // File resolves secrets from files on the local filesystem. + File ProviderType = "file" + // Vault resolves secrets from HashiCorp Vault. Vault ProviderType = "hashicorp_vault" // Consul resolves secrets from HashiCorp Consul. Consul ProviderType = "hashicorp_consul" - // K8s resolves secrets from Kubernetes Secrets mounted as files. - K8s ProviderType = "k8s_files" - // --- Enterprise Edition (EE) Providers --- // AWS resolves secrets from AWS Secrets Manager. @@ -42,6 +42,18 @@ const ( Conjur ProviderType = "cyberark_conjur" ) +// IsLocal reports whether this provider type resolves secrets from resources +// available to the local process — environment variables, inline config data, +// or the filesystem — requiring no network and a literal, reference-free config. +func (t ProviderType) IsLocal() bool { + switch t { + case Env, Inline, File: + return true + default: + return false + } +} + // KeyValueRetriever defines the core read capability for retrieving values by key. type KeyValueRetriever interface { Get(ctx context.Context, key string) (string, error) diff --git a/kv/provider_test.go b/kv/provider_test.go index f72b3320..a0923814 100644 --- a/kv/provider_test.go +++ b/kv/provider_test.go @@ -25,3 +25,32 @@ func TestAs_CircularDependencyWontFail(t *testing.T) { require.False(t, ok) } + +func TestProviderType_IsLocal(t *testing.T) { + t.Parallel() + + local := []ProviderType{ + Env, + Inline, + File, + } + + remote := []ProviderType{ + Vault, + Consul, + AWS, + GCP, + Azure, + Conjur, + "unknown_provider", + "", + } + + for _, pt := range local { + require.Truef(t, pt.IsLocal(), "%q must initialize in Phase 1", pt) + } + + for _, pt := range remote { + require.Falsef(t, pt.IsLocal(), "%q must NOT be treated as a Phase 1 local store", pt) + } +} diff --git a/kv/registry/from_config.go b/kv/registry/from_config.go new file mode 100644 index 00000000..17a76404 --- /dev/null +++ b/kv/registry/from_config.go @@ -0,0 +1,199 @@ +package registry + +import ( + "context" + "encoding/json" + "fmt" + "maps" + + "github.com/TykTechnologies/storage/kv" + "github.com/TykTechnologies/storage/kv/internal/resolve" +) + +// initOptions collects the inputs to NewFromConfig that aren't the raw +// config document. +type initOptions struct { + factories map[kv.ProviderType]kv.ProviderFactory + defaultStores map[string]kv.StoreConfig +} + +// InitOption configures NewFromConfig. +type InitOption func(*initOptions) + +// WithFactories injects additional provider factories (enterprise or custom) +// on top of the OSS defaults. A nil map is a no-op — the OSS build path. +func WithFactories(f map[kv.ProviderType]kv.ProviderFactory) InitOption { + return func(o *initOptions) { + o.factories = f + } +} + +// WithDefaultStores supplies store definitions merged with LOWER precedence +// than the kv.stores block in rawConfig — a store defined in both places +// uses the rawConfig definition. +func WithDefaultStores(s map[string]kv.StoreConfig) InitOption { + return func(o *initOptions) { + o.defaultStores = s + } +} + +// NewFromConfig initializes a Registry from the kv section of rawConfig. +// Internally it runs a two-phase process so that store credentials (e.g. a +// Vault token) can themselves live in an env or inline store and be +// referenced from the config. +// +// Referenceability rules: +// +// - A store config may reference any local-type store by name — +// wherever it is defined (WithDefaultStores or kv.stores) — because all +// local-type stores initialize before store configs are resolved. +// - A store config may never reference a remote store (vault, consul, +// aws, ...): remote stores initialize concurrently, so this is an +// unresolvable bootstrap cycle. Such references reach the provider +// factory as literals and fail there, subject to the store's +// required flag. +// - Values inside an inline store's data map are always literal; a +// kv:// reference there is never resolved. +// +// NewFromConfig does not resolve the rest of the document — the single +// strict ResolveAll pass over the full config is owned by the caller. +// A nil or empty rawConfig is valid: the kv-section parse is skipped and +// the registry is built from WithDefaultStores alone. +// +// The caller owns the returned registry, including Close on shutdown. +func NewFromConfig( + ctx context.Context, + rawConfig []byte, + opts ...InitOption, +) (*Registry, error) { + // A store's own config can contain kv:// references — e.g. a Vault store + // whose token lives in an env var: {"token": "kv://env/VAULT_TOKEN"}. + // Resolving that requires the env store to already exist, which forces a + // two-phase init: + // + // Phase 1 — stand up the local-type stores (env, inline, file: literal + // config, no network) in a throwaway registry, and use them to resolve + // references inside the OTHER store configs. + // Phase 2 — build the real registry and open every store, now that all + // configs are resolved. Local stores re-init cheaply here; remote + // stores open their connections for the first time. + options := &initOptions{} + for _, opt := range opts { + opt(options) + } + + var config struct { + KV kv.Config `json:"kv"` + } + + if len(rawConfig) > 0 { + if err := json.Unmarshal(rawConfig, &config); err != nil { + return nil, fmt.Errorf("kv: failed to parse config: %w", err) + } + } + + merged := make(map[string]kv.StoreConfig, len(options.defaultStores)+len(config.KV.Stores)) + maps.Copy(merged, options.defaultStores) + maps.Copy(merged, config.KV.Stores) + + // Phase 1: resolve references in store configs against local stores. + if err := resolveStoreConfigReferences(ctx, merged, options.factories); err != nil { + return nil, err + } + + // Phase 2: build the registry and initialize all stores. + full, err := newRegistryWithFactories(options.factories) + if err != nil { + return nil, err + } + + if len(merged) > 0 { + err := full.InitStores(ctx, &kv.Config{Stores: merged, Cache: config.KV.Cache}) + if err != nil { + return nil, fmt.Errorf("kv: failed to initialize stores: %w", err) + } + } + + return full, nil +} + +// resolveStoreConfigReferences is Phase 1. It initializes the local-type stores +// from merged into a TEMPORARY registry, then resolves kv:// references found +// inside the remaining (remote) store configs against them, rewriting merged in +// place. The temporary registry is discarded; Phase 2 rebuilds everything. +// +// Resolution is lenient: a reference to a store absent from the temporary +// registry — i.e. a remote store, which only exists after Phase 2 — is left +// untouched instead of failing. It reaches the provider factory as a literal in +// Phase 2 and fails there if the store was required. This is why a remote store +// config cannot reference another remote store. +func resolveStoreConfigReferences( + ctx context.Context, + merged map[string]kv.StoreConfig, + factories map[kv.ProviderType]kv.ProviderFactory, +) error { + locals := make(map[string]kv.StoreConfig) + var hasRemotes bool + + for name, storeCfg := range merged { + if storeCfg.Type.IsLocal() { + locals[name] = storeCfg + } else { + hasRemotes = true + } + } + + // Only remote configs can carry references worth resolving; local configs + // are literal. If there are none, skip the bootstrap entirely. + if !hasRemotes { + return nil + } + + bootstrap, err := newRegistryWithFactories(factories) + if err != nil { + return err + } + + defer func() { + _ = bootstrap.Close(context.WithoutCancel(ctx)) + }() + + if len(locals) > 0 { + if err := bootstrap.InitStores(ctx, &kv.Config{Stores: locals}); err != nil { + return fmt.Errorf("kv: failed to initialize local stores for bootstrap: %w", err) + } + } + + lenient := resolve.NewResolver(bootstrap, resolve.WithLenientMode()) + + for name, storeCfg := range merged { + // Local store configs are literal and are initialized from the raw parse in + // Phase 1 (env/inline/file alike), so there is nothing to resolve here. + // Skip them, and skip stores with no config blob (ResolveAll(nil) errors). + if storeCfg.Type.IsLocal() || len(storeCfg.Config) == 0 { + continue + } + + resolved, err := lenient.ResolveAll(ctx, storeCfg.Config) + if err != nil { + return fmt.Errorf("kv: failed to resolve config of store %q: %w", name, err) + } + + storeCfg.Config = resolved + merged[name] = storeCfg + } + + return nil +} + +func newRegistryWithFactories(factories map[kv.ProviderType]kv.ProviderFactory) (*Registry, error) { + r := NewDefaultRegistry() + + for providerType, factory := range factories { + if err := r.set(providerType, factory); err != nil { + return nil, fmt.Errorf("kv: failed to register factory: %w", err) + } + } + + return r, nil +} diff --git a/kv/registry/from_config_test.go b/kv/registry/from_config_test.go new file mode 100644 index 00000000..3bf61f0e --- /dev/null +++ b/kv/registry/from_config_test.go @@ -0,0 +1,764 @@ +package registry_test + +import ( + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/TykTechnologies/storage/kv" + "github.com/TykTechnologies/storage/kv/registry" + "github.com/TykTechnologies/storage/kv/resolver" + "github.com/stretchr/testify/require" +) + +// fakeProvider serves a fixed data map. A nil map means every key resolves +// to "value". +type fakeProvider struct { + data map[string]string + initFunc func(ctx context.Context) error + calls atomic.Int32 + closed atomic.Bool +} + +func (p *fakeProvider) Get(_ context.Context, key string) (string, error) { + p.calls.Add(1) + + if p.data == nil { + return "value", nil + } + + v, ok := p.data[key] + if !ok { + return "", &kv.KeyNotFoundError{StoreName: "fake", KeyPath: key} + } + + return v, nil +} + +func (p *fakeProvider) Init(ctx context.Context) error { + if p.initFunc != nil { + return p.initFunc(ctx) + } + + return nil +} + +func (p *fakeProvider) Close(_ context.Context) error { + p.closed.Store(true) + return nil +} + +// configRecorder captures every config a factory receives. +type configRecorder struct { + mu sync.Mutex + configs []json.RawMessage +} + +func (r *configRecorder) record(cfg json.RawMessage) { + r.mu.Lock() + defer r.mu.Unlock() + + r.configs = append(r.configs, cfg) +} + +func (r *configRecorder) all() []json.RawMessage { + r.mu.Lock() + defer r.mu.Unlock() + + return append([]json.RawMessage(nil), r.configs...) +} + +// single asserts the factory was called exactly once and returns the config. +func (r *configRecorder) single(t *testing.T) json.RawMessage { + t.Helper() + + configs := r.all() + require.Len(t, configs, 1) + + return configs[0] +} + +func recordingFactory(rec *configRecorder, p kv.Provider, err error) kv.ProviderFactory { + return func(cfg json.RawMessage) (kv.Provider, error) { + if rec != nil { + rec.record(cfg) + } + + if err != nil { + return nil, err + } + + return p, nil + } +} + +func dataFactory(rec *configRecorder) kv.ProviderFactory { + return func(cfg json.RawMessage) (kv.Provider, error) { + if rec != nil { + rec.record(cfg) + } + + var parsed struct { + Data map[string]string `json:"data"` + } + + if err := json.Unmarshal(cfg, &parsed); err != nil { + return nil, err + } + + return &fakeProvider{data: parsed.Data}, nil + } +} + +func tokenOf(t *testing.T, cfg json.RawMessage) string { + t.Helper() + + var parsed struct { + Token string `json:"token"` + } + require.NoError(t, json.Unmarshal(cfg, &parsed)) + + return parsed.Token +} + +func newRegistry(t *testing.T, rawConfig []byte, opts ...registry.InitOption) *registry.Registry { + t.Helper() + + reg, err := registry.NewFromConfig(t.Context(), rawConfig, opts...) + require.NoError(t, err) + require.NotNil(t, reg) + t.Cleanup(func() { + _ = reg.Close(context.WithoutCancel(t.Context())) + }) + + return reg +} + +func TestNewFromConfigInitializesStoresFromKVSection(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "listen_port": 8080, + "kv": { + "stores": { + "my-values": {"type": "inline", "config": {"data": {"greeting": "hello"}}}, + "vault": {"type": "hashicorp_vault", "config": {"token": "literal-token"}} + } + } + }`) + + reg := newRegistry(t, doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Inline: dataFactory(nil), + kv.Vault: recordingFactory(nil, &fakeProvider{}, nil), + })) + + inline, err := reg.GetStore("my-values") + require.NoError(t, err) + + got, err := inline.Get(t.Context(), "greeting") + require.NoError(t, err) + require.Equal(t, "hello", got) + + _, err = reg.GetStore("vault") + require.NoError(t, err) + + _, err = reg.GetStore("unknown") + require.ErrorIs(t, err, kv.ErrStoreNotFound) +} + +func TestNewFromConfigPhase1Resolution(t *testing.T) { + t.Parallel() + + t.Run("remote config referencing an env store is resolved before the factory runs", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "env": {"type": "env", "config": {"data": {"VAULT_TOKEN": "hvs.from-env"}}}, + "vault": {"type": "hashicorp_vault", "config": {"token": "kv://env/VAULT_TOKEN"}} + } + } + }`) + + vaultRec := &configRecorder{} + newRegistry(t, doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Env: dataFactory(nil), + kv.Vault: recordingFactory(vaultRec, &fakeProvider{}, nil), + })) + + require.Equal(t, "hvs.from-env", tokenOf(t, vaultRec.single(t))) + }) + + t.Run("remote config referencing a user-defined inline store is resolved", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "my-values": {"type": "inline", "config": {"data": {"vault_token": "hvs.from-inline"}}}, + "vault": {"type": "hashicorp_vault", "config": {"token": "kv://my-values/vault_token"}} + } + } + }`) + + vaultRec := &configRecorder{} + newRegistry(t, doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Inline: dataFactory(nil), + kv.Vault: recordingFactory(vaultRec, &fakeProvider{}, nil), + })) + + require.Equal(t, "hvs.from-inline", tokenOf(t, vaultRec.single(t))) + }) + + t.Run("remote config referencing a file store is resolved", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "vault": {"type": "hashicorp_vault", "config": {"token": "kv://file/vault_token"}} + } + } + }`) + + vaultRec := &configRecorder{} + newRegistry( + t, doc, + registry.WithDefaultStores(map[string]kv.StoreConfig{ + "file": {Type: kv.File, Required: true, Config: json.RawMessage(`{"base_path": "/etc/some"}`)}, + }), + registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Vault: recordingFactory(vaultRec, &fakeProvider{}, nil), + kv.File: recordingFactory(nil, &fakeProvider{data: map[string]string{ + "vault_token": "random_vault_token", + }}, nil), + }), + ) + + require.Equal(t, "random_vault_token", tokenOf(t, vaultRec.single(t))) + }) + + t.Run("references inside WithDefaultStores configs are resolved too", func(t *testing.T) { + t.Parallel() + + // The promoted-legacy-store shape: the default vault store's token + // references an env store defined in the user's kv.stores block. + doc := []byte(`{ + "kv": { + "stores": { + "env": {"type": "env", "config": {"data": {"VAULT_TOKEN": "hvs.for-default"}}} + } + } + }`) + + vaultRec := &configRecorder{} + newRegistry( + t, doc, + registry.WithDefaultStores(map[string]kv.StoreConfig{ + "vault": {Type: kv.Vault, Required: true, Config: json.RawMessage(`{"token": "kv://env/VAULT_TOKEN"}`)}, + }), + registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Env: dataFactory(nil), + kv.Vault: recordingFactory(vaultRec, &fakeProvider{}, nil), + }), + ) + + require.Equal(t, "hvs.for-default", tokenOf(t, vaultRec.single(t))) + }) + + t.Run("inline store data values are never resolved", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "env": {"type": "env", "config": {"data": {"REAL": "resolved-value"}}}, + "my-values": {"type": "inline", "config": {"data": {"keep": "kv://env/REAL"}}} + } + } + }`) + + inlineRec := &configRecorder{} + reg := newRegistry(t, doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Env: dataFactory(nil), + kv.Inline: dataFactory(inlineRec), + })) + + // The literal must survive end to end: in every config the inline + // factory ever received, and through Get on the initialized store. + for _, cfg := range inlineRec.all() { + var parsed struct { + Data map[string]string `json:"data"` + } + require.NoError(t, json.Unmarshal(cfg, &parsed)) + require.Equal(t, "kv://env/REAL", parsed.Data["keep"]) + } + + store, err := reg.GetStore("my-values") + require.NoError(t, err) + + got, err := store.Get(t.Context(), "keep") + require.NoError(t, err) + require.Equal(t, "kv://env/REAL", got) + }) +} + +func TestNewFromConfigUnresolvableReferences(t *testing.T) { + t.Parallel() + + // errsOnUnresolved simulates a real remote provider rejecting a config + // whose credential is still a kv:// literal. + errsOnUnresolved := func(rec *configRecorder) kv.ProviderFactory { + return func(cfg json.RawMessage) (kv.Provider, error) { + rec.record(cfg) + + var parsed struct { + Token string `json:"token"` + } + + if err := json.Unmarshal(cfg, &parsed); err != nil { + return nil, err + } + + if len(parsed.Token) >= 5 && parsed.Token[:5] == "kv://" { + return nil, errors.New("invalid token: looks like an unresolved KV reference") + } + + return &fakeProvider{}, nil + } + } + + t.Run("remote-to-remote reference passes through and fails a required store", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "consul": {"type": "hashicorp_consul", "config": {"token": "literal"}}, + "vault": {"type": "hashicorp_vault", "required": true, "config": {"token": "kv://consul/token"}} + } + } + }`) + + vaultRec := &configRecorder{} + reg, err := registry.NewFromConfig(t.Context(), doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Consul: recordingFactory(nil, &fakeProvider{}, nil), + kv.Vault: errsOnUnresolved(vaultRec), + })) + require.Error(t, err) + require.Nil(t, reg) + + // Phase 1 must have left the remote-to-remote reference verbatim. + require.Equal(t, "kv://consul/token", tokenOf(t, vaultRec.single(t))) + }) + + t.Run("optional store with unresolvable reference is skipped, others initialize", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "consul": {"type": "hashicorp_consul", "config": {"token": "literal"}}, + "vault": {"type": "hashicorp_vault", "required": false, "config": {"token": "kv://absent-store/token"}} + } + } + }`) + + vaultRec := &configRecorder{} + reg := newRegistry(t, doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Consul: recordingFactory(nil, &fakeProvider{}, nil), + kv.Vault: errsOnUnresolved(vaultRec), + })) + + require.Equal(t, "kv://absent-store/token", tokenOf(t, vaultRec.single(t))) + + _, err := reg.GetStore("vault") + require.ErrorIs(t, err, kv.ErrStoreNotFound) + + _, err = reg.GetStore("consul") + require.NoError(t, err) + }) + + t.Run("malformed reference in a store config fails fast, remote factory never runs", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "vault": {"type": "hashicorp_vault", "config": {"token": "kv://no-path-separator"}} + } + } + }`) + + vaultRec := &configRecorder{} + reg, err := registry.NewFromConfig(t.Context(), doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Vault: recordingFactory(vaultRec, &fakeProvider{}, nil), + })) + require.ErrorIs(t, err, resolver.ErrMalformedReference) + require.Nil(t, reg) + require.Empty(t, vaultRec.all(), "Phase 1 must fail before any remote factory is invoked") + }) +} + +func TestNewFromConfigDefaultStoresMerge(t *testing.T) { + t.Parallel() + + t.Run("nil rawConfig builds the registry from defaults alone", func(t *testing.T) { + t.Parallel() + + reg := newRegistry( + t, nil, + registry.WithDefaultStores(map[string]kv.StoreConfig{ + "env": {Type: kv.Env, Required: true, Config: json.RawMessage(`{"data": {"KEY": "from-default"}}`)}, + }), + registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Env: dataFactory(nil), + }), + ) + + store, err := reg.GetStore("env") + require.NoError(t, err) + + got, err := store.Get(t.Context(), "KEY") + require.NoError(t, err) + require.Equal(t, "from-default", got) + }) + + t.Run("kv.stores definition wins over a same-named default store", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "vault": {"type": "hashicorp_vault", "config": {"token": "from-config"}} + } + } + }`) + + vaultRec := &configRecorder{} + newRegistry( + t, doc, + registry.WithDefaultStores(map[string]kv.StoreConfig{ + "vault": {Type: kv.Vault, Config: json.RawMessage(`{"token": "from-defaults"}`)}, + }), + registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Vault: recordingFactory(vaultRec, &fakeProvider{}, nil), + }), + ) + + require.Equal(t, "from-config", tokenOf(t, vaultRec.single(t)), + "user-defined kv.stores entry must override the promoted default") + }) +} + +func TestNewFromConfigDocumentHandling(t *testing.T) { + t.Parallel() + + t.Run("references outside the kv section are ignored", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "secret": "kv://not-a-store/key", + "broken": "kv://no-path-separator", + "kv": { + "stores": { + "my-values": {"type": "inline", "config": {"data": {"k": "v"}}} + } + } + }`) + + reg := newRegistry(t, doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Inline: dataFactory(nil), + })) + + _, err := reg.GetStore("my-values") + require.NoError(t, err) + }) + + t.Run("empty and kv-less documents build a defaults-only registry", func(t *testing.T) { + t.Parallel() + + for _, doc := range [][]byte{ + nil, + {}, + []byte(`{}`), + []byte(`{"listen_port": 8080}`), + []byte(`{"kv": {}}`), + } { + reg := newRegistry( + t, doc, + registry.WithDefaultStores(map[string]kv.StoreConfig{ + "env": {Type: kv.Env, Config: json.RawMessage(`{"data": {}}`)}, + }), + registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Env: dataFactory(nil), + }), + ) + + _, err := reg.GetStore("env") + require.NoError(t, err, "doc: %q", doc) + } + }) + + t.Run("no options and nil config still yield a usable empty registry", func(t *testing.T) { + t.Parallel() + + reg, err := registry.NewFromConfig(t.Context(), nil) + require.NoError(t, err) + require.NotNil(t, reg) + + _, err = reg.GetStore("anything") + require.ErrorIs(t, err, kv.ErrStoreNotFound) + }) + + t.Run("nil WithFactories map is accepted", func(t *testing.T) { + t.Parallel() + + reg, err := registry.NewFromConfig(t.Context(), nil, registry.WithFactories(nil)) + require.NoError(t, err) + require.NotNil(t, reg) + }) + + t.Run("invalid JSON errors", func(t *testing.T) { + t.Parallel() + + reg, err := registry.NewFromConfig(t.Context(), []byte(`{not json`)) + require.Error(t, err) + require.Nil(t, reg) + }) +} + +func TestNewFromConfigUnknownProviderType(t *testing.T) { + t.Parallel() + + t.Run("required store with unknown type fails initialization", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "mystery": {"type": "does_not_exist", "required": true, "config": {}} + } + } + }`) + + reg, err := registry.NewFromConfig(t.Context(), doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Inline: dataFactory(nil), + })) + require.Error(t, err) + require.Contains(t, err.Error(), "does_not_exist") + require.Nil(t, reg) + }) + + t.Run("optional store with unknown type is skipped, others initialize", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "mystery": {"type": "does_not_exist", "required": false, "config": {}}, + "my-values": {"type": "inline", "config": {"data": {"k": "v"}}} + } + } + }`) + + reg := newRegistry(t, doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Inline: dataFactory(nil), + })) + + _, err := reg.GetStore("mystery") + require.ErrorIs(t, err, kv.ErrStoreNotFound) + + _, err = reg.GetStore("my-values") + require.NoError(t, err) + }) +} + +func TestNewFromConfigCacheConfig(t *testing.T) { + t.Parallel() + + t.Run("kv.cache settings reach the store wrapper", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "cache": {"enabled": true, "ttl": "1m"}, + "stores": { + "remote": {"type": "hashicorp_vault", "config": {}} + } + } + }`) + + provider := &fakeProvider{} + reg := newRegistry(t, doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Vault: recordingFactory(nil, provider, nil), + })) + + store, err := reg.GetStore("remote") + require.NoError(t, err) + + for range 3 { + _, err = store.Get(t.Context(), "some/key") + require.NoError(t, err) + } + + require.Equal(t, int32(1), provider.calls.Load(), "enabled cache must serve repeat Gets") + }) + + t.Run("without cache config every Get reaches the provider", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "remote": {"type": "hashicorp_vault", "config": {}} + } + } + }`) + + provider := &fakeProvider{} + reg := newRegistry(t, doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Vault: recordingFactory(nil, provider, nil), + })) + + store, err := reg.GetStore("remote") + require.NoError(t, err) + + for range 3 { + _, err = store.Get(t.Context(), "some/key") + require.NoError(t, err) + } + + require.Equal(t, int32(3), provider.calls.Load()) + }) +} + +func TestNewFromConfigLifecycle(t *testing.T) { + t.Run("context cancellation propagates and partial stores are cleaned up", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + doc := []byte(`{ + "kv": { + "stores": { + "blocked": {"type": "hashicorp_vault", "required": true, "config": {}}, + "healthy": {"type": "hashicorp_consul", "required": true, "config": {}} + } + } + }`) + + healthy := &fakeProvider{} + blocked := &fakeProvider{ + initFunc: func(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() + }, + } + + ctx, cancel := context.WithCancel(t.Context()) + go func() { + time.Sleep(time.Second) + cancel() + }() + + reg, err := registry.NewFromConfig(ctx, doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Vault: recordingFactory(nil, blocked, nil), + kv.Consul: recordingFactory(nil, healthy, nil), + })) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, reg) + + synctest.Wait() + require.True(t, healthy.closed.Load(), "stores initialized before the failure must be closed") + }) + }) + + t.Run("Close shuts down every initialized store", func(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "a": {"type": "hashicorp_vault", "config": {}}, + "b": {"type": "hashicorp_consul", "config": {}} + } + } + }`) + + providerA := &fakeProvider{} + providerB := &fakeProvider{} + + reg, err := registry.NewFromConfig(t.Context(), doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Vault: recordingFactory(nil, providerA, nil), + kv.Consul: recordingFactory(nil, providerB, nil), + })) + require.NoError(t, err) + + require.NoError(t, reg.Close(t.Context())) + require.True(t, providerA.closed.Load()) + require.True(t, providerB.closed.Load()) + + _, err = reg.GetStore("a") + require.ErrorIs(t, err, kv.ErrStoreNotFound) + }) +} + +func TestNewFromConfigWithFactoriesOverridesDefault(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "file": {"type": "file", "config": {}} + } + } + }`) + + override := &fakeProvider{data: map[string]string{"my-key": "from-override"}} + + reg := newRegistry(t, doc, registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.File: recordingFactory(nil, override, nil), + })) + + store, err := reg.GetStore("file") + require.NoError(t, err) + + got, err := store.Get(t.Context(), "my-key") + require.NoError(t, err) + require.Equal(t, "from-override", got, + "the WithFactories kv.File factory must override the OSS default, not error on collision") +} + +func TestNewFromConfigRejectsInvalidFactories(t *testing.T) { + t.Parallel() + + t.Run("empty provider type", func(t *testing.T) { + t.Parallel() + + reg, err := registry.NewFromConfig(t.Context(), nil, registry.WithFactories( + map[kv.ProviderType]kv.ProviderFactory{ + "": recordingFactory(nil, &fakeProvider{}, nil), + }, + )) + require.Error(t, err) + require.Nil(t, reg) + require.ErrorContains(t, err, "provider type cannot be empty") + }) + + t.Run("nil factory", func(t *testing.T) { + t.Parallel() + + reg, err := registry.NewFromConfig(t.Context(), nil, registry.WithFactories( + map[kv.ProviderType]kv.ProviderFactory{ + kv.AWS: nil, + }, + )) + require.Error(t, err) + require.Nil(t, reg) + require.ErrorContains(t, err, "factory cannot be nil") + }) +} diff --git a/kv/registry/registry.go b/kv/registry/registry.go index 1490aaf0..fcd3af1c 100644 --- a/kv/registry/registry.go +++ b/kv/registry/registry.go @@ -61,7 +61,7 @@ func NewDefaultRegistry(opts ...Option) *Registry { // r.Add(kv.Inline, inline.NewFactory()) // r.Add(kv.Vault, vault.NewFactory()) // r.Add(kv.Consul, consul.NewFactory()) - // r.Add(kv.K8s, k8s.NewFactory()) + // r.Add(kv.File, file.NewFactory()) return r } @@ -219,6 +219,24 @@ func (r *Registry) InitStores(ctx context.Context, config *kv.Config) (err error return nil } +// set registers a factory, replacing any existing one. Unline Add, it permits +// override - used by the composition layer so WithFactories wins over OSS defaults. +func (r *Registry) set(pt kv.ProviderType, factory kv.ProviderFactory) error { + if pt == "" { + return errors.New("provider type cannot be empty") + } + + if factory == nil { + return errors.New("factory cannot be nil") + } + + r.mu.Lock() + defer r.mu.Unlock() + r.factories[pt] = factory + + return nil +} + // GetStore retrieves an initialized store by name. // Returns ErrStoreNotFound if no store with the given name was initialized. func (r *Registry) GetStore(name string) (kv.Provider, error) { From 7a8d7cb35d9e7e8c0b46c862f91f26b16c2f105d Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi <109525963+vladzabolotnyi@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:10:53 +0300 Subject: [PATCH 12/31] TT-17487: Add File provider (#143) * feat: add implementation to Resolve func * feat: add resolveAll implementation * test: add test cases for json pointer extraction * refactor: update error messages and make error handlin more consistent * refactor: update resolve tests * refactor: update resolveAll tests with grouping error cases * fix: added malformed err with tests, added docs string * test: add test suite with path routing * fix: remove redundant assertions and update comment * test: add test case that resolve all resolved mixed values * fix: use decoding with numbers to avoid losing large integers and update doc strings explaining inner behavior * fix: add checks with tests to avoid processing values when store or key is empty string * fix: remove spaces around em-dashes and add missing tests for missing stores and keys * chore: remove redundant comments * fix: address sonarqube comment with avoiding duplication * fix: add json validation on document even if it doesn't contain kv refs * refactor: split resolver for public and private parts to hide inner logic with lenient mode in future * chore: replace background context with test one * feat: add cache bypass context logic to skip serving the value from cache when its desired * feat: add lenient mode to the resolver * feat: add base implementation of new from config * refactor: update implementation with clear func separation and code simplification; add docs * fix: lint errors * chore: remove redundant comments * feat: add file provider as a type with registry processing it as a local type provider * refactor: add more comments and explanation about phase1 and phase2 and why we need them to increase readability * refactor: remove isLocalType func and attach the logic to provider type to avoid OCP and increase discoverability * fix: missing pieces from last commit * docs: update docstring and comment explaining better why process should resolved value for non-local stores only * feat: add file provider with tests * feat: add sentinel errors and rework current errors * feat: update returning error when file is not exist on the provided key * feat: adapt the KeyNotFoundError to the standalone providers * refactor: put the provider_test to kv_test to test public methods and interfaces * feat: add ability to override default factories when WithFactories opt is used * feat: add file provider registration at the default registry * test: add tests covering logic with factories overriding * fix: update the logic with rejecting retrieving when basePath is not provided * fix: add case to cover empty keys * fix: add guard against non-abs for base_path --------- Co-authored-by: Vlad Zabolotnyi --- kv/errors.go | 4 + kv/errors_test.go | 39 ++++ kv/provider_test.go | 29 +-- kv/providers/file/errors.go | 12 ++ kv/providers/file/file.go | 149 ++++++++++++++ kv/providers/file/file_internal_test.go | 35 ++++ kv/providers/file/file_test.go | 251 ++++++++++++++++++++++++ kv/registry/registry.go | 9 +- kv/registry/registry_test.go | 6 +- 9 files changed, 518 insertions(+), 16 deletions(-) create mode 100644 kv/errors_test.go create mode 100644 kv/providers/file/errors.go create mode 100644 kv/providers/file/file.go create mode 100644 kv/providers/file/file_internal_test.go create mode 100644 kv/providers/file/file_test.go diff --git a/kv/errors.go b/kv/errors.go index 8baf8e2d..77749029 100644 --- a/kv/errors.go +++ b/kv/errors.go @@ -29,6 +29,10 @@ type KeyNotFoundError struct { } func (e *KeyNotFoundError) Error() string { + if e.StoreName == "" { + return fmt.Sprintf("key %q not found", e.KeyPath) + } + return fmt.Sprintf("key %q not found in store %q", e.KeyPath, e.StoreName) } diff --git a/kv/errors_test.go b/kv/errors_test.go new file mode 100644 index 00000000..8d40a07d --- /dev/null +++ b/kv/errors_test.go @@ -0,0 +1,39 @@ +package kv_test + +import ( + "errors" + "testing" + + "github.com/TykTechnologies/storage/kv" + "github.com/stretchr/testify/require" +) + +func TestKeyNotFoundError_Error(t *testing.T) { + t.Parallel() + + t.Run("omits the store clause when StoreName is empty", func(t *testing.T) { + t.Parallel() + + // Standalone providers (e.g. file) cannot know their registry name, + // so they leave StoreName empty; the message must still read cleanly. + err := &kv.KeyNotFoundError{KeyPath: "db/password"} + require.Equal(t, `key "db/password" not found`, err.Error()) + }) + + t.Run("includes the store name when present", func(t *testing.T) { + t.Parallel() + + err := &kv.KeyNotFoundError{StoreName: "vault", KeyPath: "db/password"} + require.Equal(t, `key "db/password" not found in store "vault"`, err.Error()) + }) +} + +func TestKeyNotFoundError_IsMatchable(t *testing.T) { + t.Parallel() + + wrapped := errors.Join(errors.New("context"), &kv.KeyNotFoundError{KeyPath: "k"}) + + var notFound *kv.KeyNotFoundError + require.ErrorAs(t, wrapped, ¬Found) + require.Equal(t, "k", notFound.KeyPath) +} diff --git a/kv/provider_test.go b/kv/provider_test.go index a0923814..a36e4ff5 100644 --- a/kv/provider_test.go +++ b/kv/provider_test.go @@ -1,9 +1,10 @@ -package kv +package kv_test import ( "context" "testing" + "github.com/TykTechnologies/storage/kv" "github.com/stretchr/testify/require" ) @@ -13,7 +14,7 @@ func (m *mockProvider) Get(ctx context.Context, path string) (string, error) { return "", nil } -func (m *mockProvider) Unwrap() Provider { +func (m *mockProvider) Unwrap() kv.Provider { return m } @@ -21,7 +22,7 @@ func TestAs_CircularDependencyWontFail(t *testing.T) { t.Parallel() m := &mockProvider{} - _, ok := As[Closer](m) + _, ok := kv.As[kv.Closer](m) require.False(t, ok) } @@ -29,19 +30,19 @@ func TestAs_CircularDependencyWontFail(t *testing.T) { func TestProviderType_IsLocal(t *testing.T) { t.Parallel() - local := []ProviderType{ - Env, - Inline, - File, + local := []kv.ProviderType{ + kv.Env, + kv.Inline, + kv.File, } - remote := []ProviderType{ - Vault, - Consul, - AWS, - GCP, - Azure, - Conjur, + remote := []kv.ProviderType{ + kv.Vault, + kv.Consul, + kv.AWS, + kv.GCP, + kv.Azure, + kv.Conjur, "unknown_provider", "", } diff --git a/kv/providers/file/errors.go b/kv/providers/file/errors.go new file mode 100644 index 00000000..57bca76b --- /dev/null +++ b/kv/providers/file/errors.go @@ -0,0 +1,12 @@ +package file + +import "errors" + +var ( + ErrBasePathRequired = errors.New("file: base_path required") + ErrBasePathNotAbsolute = errors.New("file: base_path must be an absolute path") + ErrAbsoluteRejected = errors.New("file: absolute path rejected when base_path is set") + ErrTraversal = errors.New("file: path traversal detected") + ErrSymlinkEscape = errors.New("file: symlink escapes base_path") + ErrEmptyKey = errors.New("file: key must not be empty") +) diff --git a/kv/providers/file/file.go b/kv/providers/file/file.go new file mode 100644 index 00000000..b064d9b8 --- /dev/null +++ b/kv/providers/file/file.go @@ -0,0 +1,149 @@ +// Package file provides a KV provider that reads secrets from the local +// filesystem — plain files and Kubernetes Secrets mounted as files. It is a +// Standalone provider (no caching) so rotated secrets are visible on the next read. +package file + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/TykTechnologies/storage/kv" +) + +// Config is the file provider's configuration. +type Config struct { + // BasePath is the mandatory security boundary for file references. + // + // It must be an absolute path: a relative value is rejected at construction + // so the boundary's location is explicit and never depends on the process + // working directory. When set, keys must be relative paths that resolve + // within this directory — absolute keys and ".." traversal are rejected, + // and symlinks that escape the directory after resolution are rejected. + // + // When empty, the provider resolves nothing: every key is rejected, so file + // references are effectively disabled until a base_path is configured. + BasePath string `json:"base_path"` +} + +// NewFactory returns a ProviderFactory for filesystem-backed stores. +// +// An empty or absent config is accepted and yields a provider with no +// base_path; that provider rejects every Get (see Config.BasePath). This lets +// callers register the store unconditionally and treat "no base_path" as +// "file references disabled" rather than a construction error. +func NewFactory() kv.ProviderFactory { + return func(config json.RawMessage) (kv.Provider, error) { + if len(config) == 0 { + return &fileProvider{}, nil + } + + var cfg Config + + if err := json.Unmarshal(config, &cfg); err != nil { + return nil, fmt.Errorf("file: invalid config: %w", err) + } + + if cfg.BasePath != "" && !filepath.IsAbs(cfg.BasePath) { + return nil, fmt.Errorf("%w: %q", ErrBasePathNotAbsolute, cfg.BasePath) + } + + return &fileProvider{basePath: cfg.BasePath}, nil + } +} + +type fileProvider struct { + basePath string +} + +// IsStandalone reports that the provider needs no cache wrapper: filesystem +// reads are cheap. +func (fp *fileProvider) IsStandalone() bool { + return true +} + +// Get reads the file addressed by key and returns its contents with trailing +// newlines trimmed. key must be a relative path confined to base_path (see +// Config.BasePath). A missing file returns *kv.KeyNotFoundError. +func (fp *fileProvider) Get(ctx context.Context, key string) (string, error) { + path, err := resolveKeyPath(fp.basePath, key) + if err != nil { + return "", err + } + + // Resolve K8s AtomicWriter symlinks (e.g. ..data -> ..2024_01_01_00_00_00). + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", &kv.KeyNotFoundError{KeyPath: key} + } + + return "", fmt.Errorf("file: cannot resolve path %q: %w", path, err) + } + + // Re-verify after symlink resolution: a symlink inside basePath can point + // outside (symlink escape). + canonicalBase, err := filepath.EvalSymlinks(fp.basePath) + // EvalSymlinks failure here requires a race (basePath symlink broken between + // resolving the file path above and this call). Not worth a flaky test. + if err != nil { + return "", fmt.Errorf("file: cannot resolve base_path %q: %w", fp.basePath, err) + } + + if !confined(canonicalBase, resolved) { + return "", fmt.Errorf("%w: key %q resolved to %q", ErrSymlinkEscape, key, resolved) + } + + data, err := os.ReadFile(resolved) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", &kv.KeyNotFoundError{KeyPath: key} + } + + return "", fmt.Errorf("file: cannot read file %q: %w", resolved, err) + } + + return strings.TrimRight(string(data), "\r\n"), nil +} + +// resolveKeyPath applies the base_path boundary policy and returns the +// candidate file path. +func resolveKeyPath(basePath, key string) (string, error) { + if basePath == "" { + return "", fmt.Errorf( + "%w: key %q (set base_path)", + ErrBasePathRequired, + key, + ) + } + + if key == "" { + return "", ErrEmptyKey + } + + if filepath.IsAbs(key) { + return "", fmt.Errorf( + "%w: %q (use a path relative to base_path)", + ErrAbsoluteRejected, + key, + ) + } + + joined := filepath.Join(basePath, key) + if !confined(basePath, joined) { + return "", fmt.Errorf("%w: key %q", ErrTraversal, key) + } + + return joined, nil +} + +// confined reports whether target resolves to a location within base, +// using lexical analysis only. +func confined(base, target string) bool { + rel, err := filepath.Rel(base, target) + return err == nil && filepath.IsLocal(rel) +} diff --git a/kv/providers/file/file_internal_test.go b/kv/providers/file/file_internal_test.go new file mode 100644 index 00000000..d759fa6a --- /dev/null +++ b/kv/providers/file/file_internal_test.go @@ -0,0 +1,35 @@ +package file + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConfined(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + base string + target string + want bool + }{ + {"direct child", "/base", "/base/secret", true}, + {"nested child", "/base", "/base/sub/dir/secret", true}, + {"base itself", "/base", "/base", true}, + {"cleaned dotdot stays inside", "/base", "/base/sub/../secret", true}, + {"parent escape", "/base", "/base/../secret", false}, + {"sibling escape", "/base", "/other", false}, + {"prefix confusion is not confinement", "/base", "/base-evil", false}, + {"absolute outside", "/base", "/etc/passwd", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.want, confined(tc.base, tc.target)) + }) + } +} diff --git a/kv/providers/file/file_test.go b/kv/providers/file/file_test.go new file mode 100644 index 00000000..5a95c657 --- /dev/null +++ b/kv/providers/file/file_test.go @@ -0,0 +1,251 @@ +package file_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/TykTechnologies/storage/kv" + "github.com/TykTechnologies/storage/kv/providers/file" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newProvider builds the provider through its factory with the given base_path, +// exactly as the registry would. +func newProvider(t *testing.T, basePath string) kv.Provider { + t.Helper() + + cfg, err := json.Marshal(file.Config{BasePath: basePath}) + require.NoError(t, err) + + p, err := file.NewFactory()(cfg) + require.NoError(t, err) + require.NotNil(t, p) + + return p +} + +func TestNewFactory(t *testing.T) { + t.Parallel() + + t.Run("valid config builds a provider", func(t *testing.T) { + t.Parallel() + + p, err := file.NewFactory()(json.RawMessage(`{"base_path":"/etc/tyk/secrets"}`)) + require.NoError(t, err) + require.NotNil(t, p) + }) + + t.Run("empty config builds a provider (file refs rejected until base_path is set)", func(t *testing.T) { + t.Parallel() + + for _, cfg := range []json.RawMessage{nil, {}, json.RawMessage(`{}`)} { + p, err := file.NewFactory()(cfg) + require.NoError(t, err, "config %q", string(cfg)) + require.NotNil(t, p) + } + }) + + t.Run("invalid JSON config errors", func(t *testing.T) { + t.Parallel() + + _, err := file.NewFactory()(json.RawMessage(`{not json`)) + require.Error(t, err) + }) + + t.Run("rejects a relative base_path", func(t *testing.T) { + t.Parallel() + + _, err := file.NewFactory()(json.RawMessage(`{"base_path":"secrets"}`)) + require.ErrorIs(t, err, file.ErrBasePathNotAbsolute) + }) +} + +func TestProviderIsStandalone(t *testing.T) { + t.Parallel() + + p := newProvider(t, "") + + standalone, ok := kv.AsStandalone(p) + require.True(t, ok, "file provider must implement Standalone") + require.True(t, standalone.IsStandalone(), + "file reads are cheap and must reflect rotation immediately — no cache wrapper") +} + +func TestProviderGet(t *testing.T) { + t.Parallel() + + t.Run("strips a trailing LF newline", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("my-secret-value\n"), 0o600)) + + got, err := newProvider(t, dir).Get(t.Context(), "secret.txt") + require.NoError(t, err) + assert.Equal(t, "my-secret-value", got) + }) + + t.Run("strips a trailing CRLF newline", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("my-secret-value\r\n"), 0o600)) + + got, err := newProvider(t, dir).Get(t.Context(), "secret.txt") + require.NoError(t, err) + assert.Equal(t, "my-secret-value", got) + }) + + t.Run("preserves internal newlines, trims only the trailing one", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + pem := "-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJ\n-----END CERTIFICATE-----\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "cert.pem"), []byte(pem), 0o600)) + + got, err := newProvider(t, dir).Get(t.Context(), "cert.pem") + require.NoError(t, err) + assert.Equal(t, "-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJ\n-----END CERTIFICATE-----", got) + }) + + t.Run("missing relative key under base_path returns kv.KeyNotFoundError", func(t *testing.T) { + t.Parallel() + + // Cross-provider consistency: an absent key is a not-found, detectable + // via errors.As regardless of which provider produced it. + dir := t.TempDir() // exists, but contains no "absent" file + _, err := newProvider(t, dir).Get(t.Context(), "absent") + + var notFound *kv.KeyNotFoundError + require.ErrorAs(t, err, ¬Found) + }) + + t.Run("base_path is mandatory: every key is rejected when it is empty", func(t *testing.T) { + t.Parallel() + + for _, key := range []string{"my-cert", "/etc/passwd"} { + _, err := newProvider(t, "").Get(t.Context(), key) + require.ErrorIs(t, err, file.ErrBasePathRequired, "key %q", key) + } + }) + + t.Run("rejects an empty key", func(t *testing.T) { + t.Parallel() + + _, err := newProvider(t, t.TempDir()).Get(t.Context(), "") + require.ErrorIs(t, err, file.ErrEmptyKey) + }) + + t.Run("resolves a relative key under base_path", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "api-key"), []byte("the-api-key"), 0o600)) + + got, err := newProvider(t, dir).Get(t.Context(), "api-key") + require.NoError(t, err) + assert.Equal(t, "the-api-key", got) + }) + + t.Run("rejects an absolute path when base_path is set", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + f := filepath.Join(dir, "secret") + require.NoError(t, os.WriteFile(f, []byte("abs-value"), 0o600)) + + _, err := newProvider(t, "/some/other/base").Get(t.Context(), f) + require.ErrorIs(t, err, file.ErrAbsoluteRejected) + }) + + t.Run("rejects an absolute path even when it points inside base_path", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + f := filepath.Join(dir, "secret") + require.NoError(t, os.WriteFile(f, []byte("abs-value"), 0o600)) + + _, err := newProvider(t, dir).Get(t.Context(), f) + require.ErrorIs(t, err, file.ErrAbsoluteRejected) + }) + + t.Run("rejects dotdot traversal when base_path is set", func(t *testing.T) { + t.Parallel() + + _, err := newProvider(t, t.TempDir()).Get(t.Context(), "../etc/passwd") + require.ErrorIs(t, err, file.ErrTraversal) + }) + + t.Run("rejects embedded dotdot traversal", func(t *testing.T) { + t.Parallel() + + _, err := newProvider(t, t.TempDir()).Get(t.Context(), "subdir/../../etc/passwd") + require.ErrorIs(t, err, file.ErrTraversal) + }) + + t.Run("rejects a symlink that escapes base_path after EvalSymlinks", func(t *testing.T) { + t.Parallel() + + base := t.TempDir() + target := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(target, "passwd"), []byte("root:x:0:0"), 0o600)) + // A symlink inside base/ pointing outside to target/passwd. + require.NoError(t, os.Symlink(filepath.Join(target, "passwd"), filepath.Join(base, "evil-link"))) + + _, err := newProvider(t, base).Get(t.Context(), "evil-link") + require.ErrorIs(t, err, file.ErrSymlinkEscape) + }) + + t.Run("follows K8s AtomicWriter symlinks", func(t *testing.T) { + t.Parallel() + + // Simulate a K8s secret mount: + // /..2024_01_01_00_00_00/my-key (actual data) + // /..data -> ..2024_01_01_00_00_00 + // /my-key -> ..data/my-key + dir := t.TempDir() + dataDir := filepath.Join(dir, "..2024_01_01_00_00_00") + require.NoError(t, os.Mkdir(dataDir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dataDir, "my-key"), []byte("secret-from-k8s"), 0o600)) + require.NoError(t, os.Symlink("..2024_01_01_00_00_00", filepath.Join(dir, "..data"))) + require.NoError(t, os.Symlink("..data/my-key", filepath.Join(dir, "my-key"))) + + got, err := newProvider(t, dir).Get(t.Context(), "my-key") + require.NoError(t, err) + assert.Equal(t, "secret-from-k8s", got) + }) +} + +// TestProviderGet_PicksUpRotation proves the standalone (no-cache) contract: +// after a K8s AtomicWriter symlink swap, the very next Get returns the new +// value with no invalidation step. +func TestProviderGet_PicksUpRotation(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + v1 := filepath.Join(dir, "..2024_01_01_00_00_00") + require.NoError(t, os.Mkdir(v1, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(v1, "my-key"), []byte("old-secret"), 0o600)) + require.NoError(t, os.Symlink("..2024_01_01_00_00_00", filepath.Join(dir, "..data"))) + require.NoError(t, os.Symlink("..data/my-key", filepath.Join(dir, "my-key"))) + + provider := newProvider(t, dir) + + got, err := provider.Get(t.Context(), "my-key") + require.NoError(t, err) + require.Equal(t, "old-secret", got) + + // Rotate: new version dir, atomic swap of the ..data symlink. + v2 := filepath.Join(dir, "..2024_06_01_00_00_00") + require.NoError(t, os.Mkdir(v2, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(v2, "my-key"), []byte("new-secret"), 0o600)) + require.NoError(t, os.Remove(filepath.Join(dir, "..data"))) + require.NoError(t, os.Symlink("..2024_06_01_00_00_00", filepath.Join(dir, "..data"))) + + got, err = provider.Get(t.Context(), "my-key") + require.NoError(t, err) + require.Equal(t, "new-secret", got, "standalone provider must reflect rotation on the next read") +} diff --git a/kv/registry/registry.go b/kv/registry/registry.go index fcd3af1c..de3d6bd5 100644 --- a/kv/registry/registry.go +++ b/kv/registry/registry.go @@ -10,6 +10,7 @@ import ( "github.com/TykTechnologies/storage/kv" "github.com/TykTechnologies/storage/kv/internal/store" + "github.com/TykTechnologies/storage/kv/providers/file" "golang.org/x/sync/errgroup" ) @@ -56,12 +57,18 @@ func NewRegistry(opts ...Option) *Registry { func NewDefaultRegistry(opts ...Option) *Registry { r := NewRegistry(opts...) + err := r.Add(kv.File, file.NewFactory()) + if err != nil { + r.logger.Warn("Failed to add default file factory", map[string]any{ + "error": err, + }) + } + // TODO: Uncomment provider registration when implementation is added // r.Add(kv.Env, env.NewFactory()) // r.Add(kv.Inline, inline.NewFactory()) // r.Add(kv.Vault, vault.NewFactory()) // r.Add(kv.Consul, consul.NewFactory()) - // r.Add(kv.File, file.NewFactory()) return r } diff --git a/kv/registry/registry_test.go b/kv/registry/registry_test.go index 1d5928bf..5d8fb02b 100644 --- a/kv/registry/registry_test.go +++ b/kv/registry/registry_test.go @@ -76,7 +76,11 @@ func TestNewRegistry(t *testing.T) { // TODO: Update the test case when providers are set, to assert // that all OSS providers are registered. -func TestNewDefaultRegistry(t *testing.T) {} +func TestNewDefaultRegistry(t *testing.T) { + r := NewDefaultRegistry() + require.NotNil(t, r) + require.NotEmpty(t, r.factories[kv.File]) +} func TestAddFactory(t *testing.T) { t.Parallel() From b9add2b8c4a3370c8ebbf31ddd39a64b6a5433f4 Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi <109525963+vladzabolotnyi@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:13:30 +0300 Subject: [PATCH 13/31] TT-17244: Add env provider (#144) * feat: add implementation to Resolve func * feat: add resolveAll implementation * test: add test cases for json pointer extraction * refactor: update error messages and make error handlin more consistent * refactor: update resolve tests * refactor: update resolveAll tests with grouping error cases * fix: added malformed err with tests, added docs string * test: add test suite with path routing * fix: remove redundant assertions and update comment * test: add test case that resolve all resolved mixed values * fix: use decoding with numbers to avoid losing large integers and update doc strings explaining inner behavior * fix: add checks with tests to avoid processing values when store or key is empty string * fix: remove spaces around em-dashes and add missing tests for missing stores and keys * chore: remove redundant comments * fix: address sonarqube comment with avoiding duplication * fix: add json validation on document even if it doesn't contain kv refs * refactor: split resolver for public and private parts to hide inner logic with lenient mode in future * chore: replace background context with test one * feat: add cache bypass context logic to skip serving the value from cache when its desired * feat: add lenient mode to the resolver * feat: add base implementation of new from config * refactor: update implementation with clear func separation and code simplification; add docs * fix: lint errors * chore: remove redundant comments * feat: add file provider as a type with registry processing it as a local type provider * refactor: add more comments and explanation about phase1 and phase2 and why we need them to increase readability * refactor: remove isLocalType func and attach the logic to provider type to avoid OCP and increase discoverability * fix: missing pieces from last commit * docs: update docstring and comment explaining better why process should resolved value for non-local stores only * feat: add file provider with tests * feat: add sentinel errors and rework current errors * feat: update returning error when file is not exist on the provided key * feat: adapt the KeyNotFoundError to the standalone providers * refactor: put the provider_test to kv_test to test public methods and interfaces * feat: add ability to override default factories when WithFactories opt is used * feat: add file provider registration at the default registry * test: add tests covering logic with factories overriding * fix: update the logic with rejecting retrieving when basePath is not provided * fix: add case to cover empty keys * fix: add guard against non-abs for base_path * feat: add base implementation for env provider * feat: force mandatory prefix to prevent accessing undesired env variables * chore: remove comment --------- Co-authored-by: Vlad Zabolotnyi --- kv/providers/env/env.go | 96 ++++++++++++++++++++++ kv/providers/env/env_test.go | 152 +++++++++++++++++++++++++++++++++++ kv/registry/registry.go | 8 ++ kv/registry/registry_test.go | 1 + 4 files changed, 257 insertions(+) create mode 100644 kv/providers/env/env.go create mode 100644 kv/providers/env/env_test.go diff --git a/kv/providers/env/env.go b/kv/providers/env/env.go new file mode 100644 index 00000000..decc1990 --- /dev/null +++ b/kv/providers/env/env.go @@ -0,0 +1,96 @@ +// Package env provides a KV provider that reads secrets from the process +// environment. It is a Standalone provider (no caching): environment lookups +// are in-process and cheap, and a rotated value is visible on the next read. +// +// A lookup name is built as Prefix + key, with the key optionally uppercased +// (see Config). +// +// Security: the Prefix is a mandatory confinement boundary — the env analogue of +// the file provider's base_path. Every key is read as Prefix+key, so a reference +// can only ever reach variables under that prefix (there is no traversal escape +// for environment names). A store with no prefix would let any reference read any +// process variable (cloud credentials, tokens, PATH, …), so an unprefixed store +// is disabled: every Get returns ErrPrefixRequired and resolves nothing. +package env + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/TykTechnologies/storage/kv" +) + +// ErrPrefixRequired is returned by Get when the provider has no prefix +// configured. +var ErrPrefixRequired = errors.New("env: prefix is required") + +// Config is the env provider's configuration. +type Config struct { + // Prefix is prepended literally to the (optionally uppercased) key before the + // environment lookup; it is never uppercased itself. It is mandatory: it is + // the store's security boundary, so a provider with an empty prefix rejects + // every Get with ErrPrefixRequired (see the package doc). + Prefix string `json:"prefix"` + + // Uppercase, when true, uppercases the key — not the prefix — before lookup, + // so Get("my_key") with prefix "TYK_SECRET_" reads "TYK_SECRET_MY_KEY". + Uppercase bool `json:"uppercase"` +} + +// NewFactory returns a ProviderFactory for environment-backed stores. +// +// An empty or absent config still builds a provider, so the store can be +// registered unconditionally. +func NewFactory() kv.ProviderFactory { + return func(config json.RawMessage) (kv.Provider, error) { + if len(config) == 0 { + return &envProvider{}, nil + } + + var cfg Config + + if err := json.Unmarshal(config, &cfg); err != nil { + return nil, fmt.Errorf("env: invalid config: %w", err) + } + + return &envProvider{ + prefix: cfg.Prefix, + uppercase: cfg.Uppercase, + }, nil + } +} + +type envProvider struct { + prefix string + uppercase bool +} + +// Get reads the environment variable named Prefix + (uppercased key if +// Uppercase) and returns its value. +// +// If no prefix is configured it returns ErrPrefixRequired for every key — the +// prefix guard is checked first, so even an empty key is rejected before any +// lookup. With a prefix set, the result mirrors os.Getenv exactly: a missing +// variable and a variable set to "" are indistinguishable, and both return +// ("", nil); an empty key reads os.Getenv(Prefix) and likewise returns no error. +func (ep *envProvider) Get(_ context.Context, key string) (string, error) { + if ep.prefix == "" { + return "", ErrPrefixRequired + } + + if ep.uppercase { + key = strings.ToUpper(key) + } + + return os.Getenv(ep.prefix + key), nil +} + +// IsStandalone reports that the provider needs no cache wrapper: environment +// reads are in-process and cheap, and there is nothing to refresh. +func (ep *envProvider) IsStandalone() bool { + return true +} diff --git a/kv/providers/env/env_test.go b/kv/providers/env/env_test.go new file mode 100644 index 00000000..2b99a676 --- /dev/null +++ b/kv/providers/env/env_test.go @@ -0,0 +1,152 @@ +package env_test + +import ( + "encoding/json" + "testing" + + "github.com/TykTechnologies/storage/kv" + "github.com/TykTechnologies/storage/kv/providers/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newProvider builds the provider through its factory with the given config, +// exactly as the registry would. +func newProvider(t *testing.T, cfg env.Config) kv.Provider { + t.Helper() + + raw, err := json.Marshal(cfg) + require.NoError(t, err) + + p, err := env.NewFactory()(raw) + require.NoError(t, err) + require.NotNil(t, p) + + return p +} + +func TestNewFactory(t *testing.T) { + t.Parallel() + + t.Run("valid config builds a provider", func(t *testing.T) { + t.Parallel() + + p, err := env.NewFactory()(json.RawMessage(`{"prefix":"TYK_SECRET_","uppercase":true}`)) + require.NoError(t, err) + require.NotNil(t, p) + }) + + t.Run("empty or absent config builds a provider (env refs rejected until prefix is set)", func(t *testing.T) { + t.Parallel() + + for _, cfg := range []json.RawMessage{nil, {}, json.RawMessage(`{}`)} { + p, err := env.NewFactory()(cfg) + require.NoError(t, err, "config %q", string(cfg)) + require.NotNil(t, p) + } + }) + + t.Run("invalid JSON config errors", func(t *testing.T) { + t.Parallel() + + _, err := env.NewFactory()(json.RawMessage(`{not json`)) + require.Error(t, err) + }) +} + +func TestProviderIsStandalone(t *testing.T) { + t.Parallel() + + p := newProvider(t, env.Config{}) + + standalone, ok := kv.AsStandalone(p) + require.True(t, ok, "env provider must implement Standalone") + require.True(t, standalone.IsStandalone(), + "env reads are in-process and cheap — no cache wrapper") +} + +func TestProviderGet(t *testing.T) { + t.Run("legacy compat: prefix + uppercase reads TYK_SECRET_", func(t *testing.T) { + t.Setenv("TYK_SECRET_MY_KEY", "value") + + got, err := newProvider(t, env.Config{Prefix: "TYK_SECRET_", Uppercase: true}). + Get(t.Context(), "my_key") + require.NoError(t, err) + assert.Equal(t, "value", got) + }) + + t.Run("unset variable returns empty string and no error", func(t *testing.T) { + got, err := newProvider(t, env.Config{Prefix: "TYK_SECRET_", Uppercase: true}). + Get(t.Context(), "definitely_not_set_var") + require.NoError(t, err) + assert.Equal(t, "", got) + }) + + t.Run("missing variable never returns kv.KeyNotFoundError", func(t *testing.T) { + _, err := newProvider(t, env.Config{Prefix: "TYK_SECRET_", Uppercase: true}). + Get(t.Context(), "definitely_not_set_var") + require.NoError(t, err) + + var notFound *kv.KeyNotFoundError + require.NotErrorAs(t, err, ¬Found, "env must never report not-found") + }) + + t.Run("variable set to empty string returns empty string and no error", func(t *testing.T) { + t.Setenv("TYK_SECRET_EMPTY_KEY", "") + + got, err := newProvider(t, env.Config{Prefix: "TYK_SECRET_", Uppercase: true}). + Get(t.Context(), "empty_key") + require.NoError(t, err) + assert.Equal(t, "", got) + }) + + t.Run("uppercase false uses the key as-is (no case folding)", func(t *testing.T) { + t.Setenv("TYK_SECRET_FOO", "bar") + + p := newProvider(t, env.Config{Prefix: "TYK_SECRET_", Uppercase: false}) + + got, err := p.Get(t.Context(), "FOO") + require.NoError(t, err) + assert.Equal(t, "bar", got) + + got, err = p.Get(t.Context(), "foo") + require.NoError(t, err) + assert.Equal(t, "", got, "no case folding: lowercase key must not match TYK_SECRET_FOO") + }) + + t.Run("empty prefix is rejected: every key errors with ErrPrefixRequired", func(t *testing.T) { + t.Setenv("BARE_KEY", "bare-value") + + p := newProvider(t, env.Config{Prefix: "", Uppercase: true}) + + for _, key := range []string{"bare_key", "BARE_KEY", ""} { + _, err := p.Get(t.Context(), key) + require.ErrorIs(t, err, env.ErrPrefixRequired, "key %q", key) + } + }) + + t.Run("prefix is literal: uppercase never applies to the prefix", func(t *testing.T) { + t.Setenv("tyk_K", "lit") + + got, err := newProvider(t, env.Config{Prefix: "tyk_", Uppercase: true}). + Get(t.Context(), "k") + require.NoError(t, err) + assert.Equal(t, "lit", got) + }) + + t.Run("value is returned byte-exact (no trailing-newline trim)", func(t *testing.T) { + t.Setenv("TYK_SECRET_RAW", "line\n") + + got, err := newProvider(t, env.Config{Prefix: "TYK_SECRET_", Uppercase: true}). + Get(t.Context(), "raw") + require.NoError(t, err) + assert.Equal(t, "line\n", got) + }) + + t.Run("empty key with a prefix set returns empty string and no error (no key guard)", func(t *testing.T) { + got, err := newProvider(t, env.Config{Prefix: "TYK_SECRET_", Uppercase: true}). + Get(t.Context(), "") + require.NoError(t, err) + assert.Equal(t, "", got) + }) +} diff --git a/kv/registry/registry.go b/kv/registry/registry.go index de3d6bd5..b669188e 100644 --- a/kv/registry/registry.go +++ b/kv/registry/registry.go @@ -10,6 +10,7 @@ import ( "github.com/TykTechnologies/storage/kv" "github.com/TykTechnologies/storage/kv/internal/store" + "github.com/TykTechnologies/storage/kv/providers/env" "github.com/TykTechnologies/storage/kv/providers/file" "golang.org/x/sync/errgroup" @@ -64,6 +65,13 @@ func NewDefaultRegistry(opts ...Option) *Registry { }) } + err = r.Add(kv.Env, env.NewFactory()) + if err != nil { + r.logger.Warn("Failed to add default env factory", map[string]any{ + "error": err, + }) + } + // TODO: Uncomment provider registration when implementation is added // r.Add(kv.Env, env.NewFactory()) // r.Add(kv.Inline, inline.NewFactory()) diff --git a/kv/registry/registry_test.go b/kv/registry/registry_test.go index 5d8fb02b..62f0d511 100644 --- a/kv/registry/registry_test.go +++ b/kv/registry/registry_test.go @@ -80,6 +80,7 @@ func TestNewDefaultRegistry(t *testing.T) { r := NewDefaultRegistry() require.NotNil(t, r) require.NotEmpty(t, r.factories[kv.File]) + require.NotEmpty(t, r.factories[kv.Env]) } func TestAddFactory(t *testing.T) { From 0dec5e7b39a458c772791fb9a2832254cfd5f0ff Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi <109525963+vladzabolotnyi@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:15:19 +0300 Subject: [PATCH 14/31] TT-17243: Add inline provider (#145) * feat: add implementation to Resolve func * feat: add resolveAll implementation * test: add test cases for json pointer extraction * refactor: update error messages and make error handlin more consistent * refactor: update resolve tests * refactor: update resolveAll tests with grouping error cases * fix: added malformed err with tests, added docs string * test: add test suite with path routing * fix: remove redundant assertions and update comment * test: add test case that resolve all resolved mixed values * fix: use decoding with numbers to avoid losing large integers and update doc strings explaining inner behavior * fix: add checks with tests to avoid processing values when store or key is empty string * fix: remove spaces around em-dashes and add missing tests for missing stores and keys * chore: remove redundant comments * fix: address sonarqube comment with avoiding duplication * fix: add json validation on document even if it doesn't contain kv refs * refactor: split resolver for public and private parts to hide inner logic with lenient mode in future * chore: replace background context with test one * feat: add cache bypass context logic to skip serving the value from cache when its desired * feat: add lenient mode to the resolver * feat: add base implementation of new from config * refactor: update implementation with clear func separation and code simplification; add docs * fix: lint errors * chore: remove redundant comments * feat: add file provider as a type with registry processing it as a local type provider * refactor: add more comments and explanation about phase1 and phase2 and why we need them to increase readability * refactor: remove isLocalType func and attach the logic to provider type to avoid OCP and increase discoverability * fix: missing pieces from last commit * docs: update docstring and comment explaining better why process should resolved value for non-local stores only * feat: add file provider with tests * feat: add sentinel errors and rework current errors * feat: update returning error when file is not exist on the provided key * feat: adapt the KeyNotFoundError to the standalone providers * refactor: put the provider_test to kv_test to test public methods and interfaces * feat: add ability to override default factories when WithFactories opt is used * feat: add file provider registration at the default registry * test: add tests covering logic with factories overriding * fix: update the logic with rejecting retrieving when basePath is not provided * fix: add case to cover empty keys * fix: add guard against non-abs for base_path * feat: add base implementation for env provider * feat: force mandatory prefix to prevent accessing undesired env variables * chore: remove comment * feat: add inline provider * test: add integration tests to prove that NewFromConfig is working with local providers like gateway expects it * docs: add doc comment explaining when provider factories MUST return an error to let other devs learn the principle * fix: address sonarqube issues --------- Co-authored-by: Vlad Zabolotnyi --- kv/provider.go | 12 +++ kv/providers/inline/inline.go | 72 +++++++++++++ kv/providers/inline/inline_test.go | 140 ++++++++++++++++++++++++++ kv/registry/integration_test.go | 156 +++++++++++++++++++++++++++++ kv/registry/registry.go | 10 +- kv/registry/registry_test.go | 1 + 6 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 kv/providers/inline/inline.go create mode 100644 kv/providers/inline/inline_test.go create mode 100644 kv/registry/integration_test.go diff --git a/kv/provider.go b/kv/provider.go index c8cda1ef..8fe09c83 100644 --- a/kv/provider.go +++ b/kv/provider.go @@ -81,6 +81,18 @@ type StoreGetter interface { // // The factory pattern allows the registry to create providers dynamically // without compile-time dependencies on specific provider implementations. +// +// Config validation convention. A factory returns an error only for config that +// is present but invalid — a value that can never produce correct behavior +// (malformed JSON, or a security-critical field set to an unusable value, e.g. +// a relative path where an absolute one is required). It must NOT error for +// merely-absent optional config: an unset value is a valid state, so the factory +// builds a provider that runs in its default or disabled mode (e.g. an env +// provider with no prefix, or a file provider with no base_path that then +// rejects every Get). Whether to create a store at all for an unconfigured +// feature is the caller's decision, not the factory's. The effect: configuration +// mistakes surface once, at construction, while unused features stay quiet +// instead of failing on every Get. type ProviderFactory func(config json.RawMessage) (Provider, error) // Initializer is an optional interface for providers that require network diff --git a/kv/providers/inline/inline.go b/kv/providers/inline/inline.go new file mode 100644 index 00000000..e38248e2 --- /dev/null +++ b/kv/providers/inline/inline.go @@ -0,0 +1,72 @@ +// Package inline provides a KV provider that serves secrets from a literal +// key/value map embedded in the configuration. It is a Standalone provider (no +// caching): lookups are an in-memory map read, with nothing to refresh. +package inline + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/TykTechnologies/storage/kv" +) + +// ErrEmptyKey is returned by Get for an empty key. +var ErrEmptyKey = errors.New("inline: key must not be empty") + +// Config is the inline provider's configuration. +type Config struct { + // Data holds literal key/value secrets. + Data map[string]string `json:"data"` +} + +// NewFactory returns a ProviderFactory for inline stores. +// +// An empty or absent config is valid and yields a provider with no data: the +// store registers unconditionally and every Get is not-found until data is +// supplied. Invalid JSON returns an error. +func NewFactory() kv.ProviderFactory { + return func(config json.RawMessage) (kv.Provider, error) { + if len(config) == 0 { + return &inlineProvider{}, nil + } + + var cfg Config + if err := json.Unmarshal(config, &cfg); err != nil { + return nil, fmt.Errorf("inline: invalid config: %w", err) + } + + return &inlineProvider{data: cfg.Data}, nil + } +} + +// inlineProvider serves secrets from a map built once at construction and never +// mutated, so concurrent Get reads are safe without locking. +type inlineProvider struct { + data map[string]string +} + +// Get returns the value stored under key. +// +// An empty key returns ErrEmptyKey. A key that is absent from the map returns +// *kv.KeyNotFoundError. The lookup is exact: no prefix, no case folding. +// A key present with value "" is distinct from a missing key and returns ("", nil). +func (ip *inlineProvider) Get(_ context.Context, key string) (string, error) { + if key == "" { + return "", ErrEmptyKey + } + + v, ok := ip.data[key] + if !ok { + return "", &kv.KeyNotFoundError{KeyPath: key} + } + + return v, nil +} + +// IsStandalone reports that the provider needs no cache wrapper: inline data is +// in-memory and literal, and there is nothing to refresh. +func (ip *inlineProvider) IsStandalone() bool { + return true +} diff --git a/kv/providers/inline/inline_test.go b/kv/providers/inline/inline_test.go new file mode 100644 index 00000000..e6657bf6 --- /dev/null +++ b/kv/providers/inline/inline_test.go @@ -0,0 +1,140 @@ +package inline_test + +import ( + "encoding/json" + "testing" + + "github.com/TykTechnologies/storage/kv" + "github.com/TykTechnologies/storage/kv/providers/inline" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newProvider builds the provider through its factory with the given config, +// exactly as the registry would. +func newProvider(t *testing.T, cfg inline.Config) kv.Provider { + t.Helper() + + raw, err := json.Marshal(cfg) + require.NoError(t, err) + + p, err := inline.NewFactory()(raw) + require.NoError(t, err) + require.NotNil(t, p) + + return p +} + +func TestNewFactory(t *testing.T) { + t.Parallel() + + t.Run("valid config builds a provider", func(t *testing.T) { + t.Parallel() + + p, err := inline.NewFactory()(json.RawMessage(`{"data":{"token":"hvs.xxx"}}`)) + require.NoError(t, err) + require.NotNil(t, p) + }) + + t.Run("empty or absent config builds a provider with an empty map", func(t *testing.T) { + t.Parallel() + + for _, cfg := range []json.RawMessage{nil, {}, json.RawMessage(`{}`)} { + p, err := inline.NewFactory()(cfg) + require.NoError(t, err, "config %q", string(cfg)) + require.NotNil(t, p) + } + }) + + t.Run("invalid JSON config errors", func(t *testing.T) { + t.Parallel() + + _, err := inline.NewFactory()(json.RawMessage(`{not json`)) + require.Error(t, err) + }) +} + +func TestProviderIsStandalone(t *testing.T) { + t.Parallel() + + p := newProvider(t, inline.Config{}) + + standalone, ok := kv.AsStandalone(p) + require.True(t, ok, "inline provider must implement Standalone") + require.True(t, standalone.IsStandalone(), + "inline data is in-memory and literal — no cache wrapper") +} + +func TestProviderGet(t *testing.T) { + t.Parallel() + + t.Run("returns the stored value for a present key", func(t *testing.T) { + t.Parallel() + + got, err := newProvider(t, inline.Config{Data: map[string]string{"token": "hvs.xxx"}}). + Get(t.Context(), "token") + require.NoError(t, err) + assert.Equal(t, "hvs.xxx", got) + }) + + t.Run("a key present with an empty value returns empty string and no error", func(t *testing.T) { + t.Parallel() + + got, err := newProvider(t, inline.Config{Data: map[string]string{"blank": ""}}). + Get(t.Context(), "blank") + require.NoError(t, err) + assert.Equal(t, "", got) + }) + + t.Run("a missing key returns kv.KeyNotFoundError", func(t *testing.T) { + t.Parallel() + + _, err := newProvider(t, inline.Config{Data: map[string]string{"present": "v"}}). + Get(t.Context(), "absent") + + var notFound *kv.KeyNotFoundError + require.ErrorAs(t, err, ¬Found) + assert.Equal(t, "absent", notFound.KeyPath) + }) + + t.Run("keys match exactly: no case folding", func(t *testing.T) { + t.Parallel() + + p := newProvider(t, inline.Config{Data: map[string]string{"Foo": "bar"}}) + + got, err := p.Get(t.Context(), "Foo") + require.NoError(t, err) + assert.Equal(t, "bar", got) + + _, err = p.Get(t.Context(), "foo") + var notFound *kv.KeyNotFoundError + require.ErrorAs(t, err, ¬Found, "lookup is case-sensitive, no transformation") + }) + + t.Run("values are returned verbatim: a kv:// string is not resolved", func(t *testing.T) { + t.Parallel() + + got, err := newProvider(t, inline.Config{Data: map[string]string{"ref": "kv://vault/secret#field"}}). + Get(t.Context(), "ref") + require.NoError(t, err) + assert.Equal(t, "kv://vault/secret#field", got) + }) + + t.Run("nil/empty data map yields not-found for any key without panicking", func(t *testing.T) { + t.Parallel() + + for _, cfg := range []inline.Config{{}, {Data: map[string]string{}}} { + _, err := newProvider(t, cfg).Get(t.Context(), "anything") + var notFound *kv.KeyNotFoundError + require.ErrorAs(t, err, ¬Found) + } + }) + + t.Run("empty key errors", func(t *testing.T) { + t.Parallel() + + _, err := newProvider(t, inline.Config{Data: map[string]string{"present": "v"}}). + Get(t.Context(), "") + require.ErrorIs(t, err, inline.ErrEmptyKey) + }) +} diff --git a/kv/registry/integration_test.go b/kv/registry/integration_test.go new file mode 100644 index 00000000..ce9ab44b --- /dev/null +++ b/kv/registry/integration_test.go @@ -0,0 +1,156 @@ +package registry_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/TykTechnologies/storage/kv" + "github.com/TykTechnologies/storage/kv/providers/env" + "github.com/TykTechnologies/storage/kv/registry" + "github.com/TykTechnologies/storage/kv/resolver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func promotedDefaults(t *testing.T, basePath string, secrets map[string]string) map[string]kv.StoreConfig { + t.Helper() + + envCfg, err := json.Marshal(map[string]any{"prefix": "TYK_SECRET_", "uppercase": true}) + require.NoError(t, err) + + stores := map[string]kv.StoreConfig{ + "env": {Type: kv.Env, Config: envCfg}, + } + + if basePath != "" { + fileCfg, err := json.Marshal(map[string]any{"base_path": basePath}) + require.NoError(t, err) + + stores["file"] = kv.StoreConfig{Type: kv.File, Config: fileCfg} + } + + if secrets != nil { + inlineCfg, err := json.Marshal(map[string]any{"data": secrets}) + require.NoError(t, err) + + stores["secrets"] = kv.StoreConfig{Type: kv.Inline, Config: inlineCfg} + } + + return stores +} + +// TestIntegrationResolveAllLocalProviders resolves a whole config document that +// mixes every local provider, both reference syntaxes, and a JSON-pointer +// fragment — proving the providers cooperate through the resolver exactly as the +// caller expects. +func TestIntegrationResolveAllLocalProviders(t *testing.T) { + t.Setenv("TYK_SECRET_DB_PASSWORD", "s3cret") + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "tls.crt"), []byte("CERTDATA\n"), 0o600)) + + secrets := map[string]string{ + "api_key": "inline-key", + "blob": `{"user":"admin","pass":"pw"}`, + } + + reg := newRegistry(t, nil, registry.WithDefaultStores(promotedDefaults(t, dir, secrets))) + res := resolver.NewResolver(reg) + + doc := []byte(`{ + "db_password": "kv://env/db_password", + "api_key": "kv://secrets/api_key", + "cert": "kv://file/tls.crt", + "url": "https://$kv{secrets:blob#user}.example.com", + "admin_pass": "kv://secrets/blob#pass", + "missing_env": "kv://env/NOT_SET_ANYWHERE" + }`) + + out, err := res.ResolveAll(t.Context(), doc) + require.NoError(t, err) + + var got struct { + DBPassword string `json:"db_password"` + APIKey string `json:"api_key"` + Cert string `json:"cert"` + URL string `json:"url"` + AdminPass string `json:"admin_pass"` + MissingEnv string `json:"missing_env"` + } + require.NoError(t, json.Unmarshal(out, &got)) + + assert.Equal(t, "s3cret", got.DBPassword, "env: TYK_SECRET_ prefix + uppercase") + assert.Equal(t, "inline-key", got.APIKey, "inline: whole-value lookup") + assert.Equal(t, "CERTDATA", got.Cert, "file: read with trailing newline trimmed") + assert.Equal(t, "https://admin.example.com", got.URL, "inline + $kv{} inline token + #fragment") + assert.Equal(t, "pw", got.AdminPass, "inline: kv:// whole-value with #fragment") + assert.Equal(t, "", got.MissingEnv, "env: missing variable resolves to empty, no error") +} + +func TestIntegrationInlineMissingKeyIsFatal(t *testing.T) { + t.Parallel() + + reg := newRegistry(t, nil, registry.WithDefaultStores( + promotedDefaults(t, "", map[string]string{"present": "v"}), + )) + res := resolver.NewResolver(reg) + + _, err := res.Resolve(t.Context(), "kv://secrets/absent") + + var notFound *kv.KeyNotFoundError + require.ErrorAs(t, err, ¬Found) + assert.Equal(t, "absent", notFound.KeyPath) +} + +func TestIntegrationEnvMissingKeyIsNotFatal(t *testing.T) { + t.Parallel() + + reg := newRegistry(t, nil, registry.WithDefaultStores(promotedDefaults(t, "", nil))) + res := resolver.NewResolver(reg) + + got, err := res.Resolve(t.Context(), "kv://env/UNSET_OPTIONAL_SECRET") + require.NoError(t, err) + assert.Equal(t, "", got) +} + +func TestIntegrationPhase1ResolvesRemoteTokenFromRealEnv(t *testing.T) { + t.Setenv("TYK_SECRET_VAULT_TOKEN", "hvs.real") + + doc := []byte(`{ + "kv": { + "stores": { + "vault": {"type": "hashicorp_vault", "config": {"token": "kv://env/VAULT_TOKEN"}} + } + } + }`) + + rec := &configRecorder{} + reg := newRegistry(t, doc, + registry.WithDefaultStores(promotedDefaults(t, "", nil)), + registry.WithFactories(map[kv.ProviderType]kv.ProviderFactory{ + kv.Vault: recordingFactory(rec, &fakeProvider{}, nil), + }), + ) + + _, err := reg.GetStore("vault") + require.NoError(t, err) + assert.Equal(t, "hvs.real", tokenOf(t, rec.single(t)), + "real env provider must resolve the vault token in Phase 1") +} + +func TestIntegrationEnvEmptyPrefixRejectedThroughStack(t *testing.T) { + t.Parallel() + + openEnv, err := json.Marshal(map[string]any{"prefix": "", "uppercase": true}) + require.NoError(t, err) + + reg := newRegistry(t, nil, registry.WithDefaultStores(map[string]kv.StoreConfig{ + "openenv": {Type: kv.Env, Config: openEnv}, + })) + res := resolver.NewResolver(reg) + + _, err = res.Resolve(t.Context(), "kv://openenv/PATH") + require.ErrorIs(t, err, env.ErrPrefixRequired) +} diff --git a/kv/registry/registry.go b/kv/registry/registry.go index b669188e..3f39d685 100644 --- a/kv/registry/registry.go +++ b/kv/registry/registry.go @@ -12,6 +12,7 @@ import ( "github.com/TykTechnologies/storage/kv/internal/store" "github.com/TykTechnologies/storage/kv/providers/env" "github.com/TykTechnologies/storage/kv/providers/file" + "github.com/TykTechnologies/storage/kv/providers/inline" "golang.org/x/sync/errgroup" ) @@ -72,9 +73,14 @@ func NewDefaultRegistry(opts ...Option) *Registry { }) } + err = r.Add(kv.Inline, inline.NewFactory()) + if err != nil { + r.logger.Warn("Failed to add default inline factory", map[string]any{ + "error": err, + }) + } + // TODO: Uncomment provider registration when implementation is added - // r.Add(kv.Env, env.NewFactory()) - // r.Add(kv.Inline, inline.NewFactory()) // r.Add(kv.Vault, vault.NewFactory()) // r.Add(kv.Consul, consul.NewFactory()) diff --git a/kv/registry/registry_test.go b/kv/registry/registry_test.go index 62f0d511..af846448 100644 --- a/kv/registry/registry_test.go +++ b/kv/registry/registry_test.go @@ -81,6 +81,7 @@ func TestNewDefaultRegistry(t *testing.T) { require.NotNil(t, r) require.NotEmpty(t, r.factories[kv.File]) require.NotEmpty(t, r.factories[kv.Env]) + require.NotEmpty(t, r.factories[kv.Inline]) } func TestAddFactory(t *testing.T) { From 58a72b2fc1ea6ee16b7de3530b0c4938e25be99e Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi <109525963+vladzabolotnyi@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:18:09 +0300 Subject: [PATCH 15/31] TT-17245: Add vault provider (#150) * feat: add implementation to Resolve func * feat: add resolveAll implementation * test: add test cases for json pointer extraction * refactor: update error messages and make error handlin more consistent * refactor: update resolve tests * refactor: update resolveAll tests with grouping error cases * fix: added malformed err with tests, added docs string * test: add test suite with path routing * fix: remove redundant assertions and update comment * test: add test case that resolve all resolved mixed values * fix: use decoding with numbers to avoid losing large integers and update doc strings explaining inner behavior * fix: add checks with tests to avoid processing values when store or key is empty string * fix: remove spaces around em-dashes and add missing tests for missing stores and keys * chore: remove redundant comments * fix: address sonarqube comment with avoiding duplication * fix: add json validation on document even if it doesn't contain kv refs * refactor: split resolver for public and private parts to hide inner logic with lenient mode in future * chore: replace background context with test one * feat: add cache bypass context logic to skip serving the value from cache when its desired * feat: add lenient mode to the resolver * feat: add base implementation of new from config * refactor: update implementation with clear func separation and code simplification; add docs * fix: lint errors * chore: remove redundant comments * feat: add file provider as a type with registry processing it as a local type provider * refactor: add more comments and explanation about phase1 and phase2 and why we need them to increase readability * refactor: remove isLocalType func and attach the logic to provider type to avoid OCP and increase discoverability * fix: missing pieces from last commit * docs: update docstring and comment explaining better why process should resolved value for non-local stores only * feat: add file provider with tests * feat: add sentinel errors and rework current errors * feat: update returning error when file is not exist on the provided key * feat: adapt the KeyNotFoundError to the standalone providers * refactor: put the provider_test to kv_test to test public methods and interfaces * feat: add ability to override default factories when WithFactories opt is used * feat: add file provider registration at the default registry * test: add tests covering logic with factories overriding * fix: update the logic with rejecting retrieving when basePath is not provided * fix: add case to cover empty keys * fix: add guard against non-abs for base_path * feat: add base implementation for env provider * feat: force mandatory prefix to prevent accessing undesired env variables * chore: remove comment * feat: add inline provider * test: add integration tests to prove that NewFromConfig is working with local providers like gateway expects it * docs: add doc comment explaining when provider factories MUST return an error to let other devs learn the principle * fix: address sonarqube issues * feat: add init implementaiton of vault provider * docs: update docs for the NewFactory * fix: json.Encoder is adding a new line symbol in the end of the encoded value, reworked with test * fix: add comments explaining why some decisions are made * docs: add more comments * fix: address linter comment with huge param and passing config as pointer instead * chore: add nolint:errcheck to silence an error * refactor: fold tests to table tests * feat: update adding vault to default registry and add integration test * fix: remove structviewer as it doesn't used on the lib * fix: tidy deps * feat: add mount_path config to adapt provider to not hardcoded mount pathes * fix: add path.Clean() to reject traversal paths --------- Co-authored-by: Vlad Zabolotnyi --- go.mod | 23 +- go.sum | 59 +++- kv/providers/vault/vault.go | 253 +++++++++++++++ kv/providers/vault/vault_test.go | 540 +++++++++++++++++++++++++++++++ kv/registry/integration_test.go | 97 ++++++ kv/registry/registry.go | 9 +- kv/registry/registry_test.go | 1 + 7 files changed, 972 insertions(+), 10 deletions(-) create mode 100644 kv/providers/vault/vault.go create mode 100644 kv/providers/vault/vault_test.go diff --git a/go.mod b/go.mod index 208d4d0c..a96e5a41 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,13 @@ go 1.25.0 require ( github.com/google/go-cmp v0.5.9 + github.com/hashicorp/vault/api v1.23.0 github.com/lib/pq v1.10.9 github.com/redis/go-redis/v9 v9.3.1 github.com/stretchr/testify v1.11.1 go.mongodb.org/mongo-driver v1.13.1 - golang.org/x/text v0.29.0 + golang.org/x/sync v0.18.0 + golang.org/x/text v0.31.0 gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 gorm.io/driver/postgres v1.5.0 gorm.io/gorm v1.30.1 @@ -26,20 +28,35 @@ require ( ) require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-jose/go-jose/v4 v4.1.1 // indirect github.com/golang/snappy v0.0.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/klauspost/compress v1.13.6 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sync v0.17.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/time v0.12.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bbd43315..b4443ea1 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -12,11 +14,40 @@ 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= +github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= github.com/helloeave/json v1.15.3 h1:roUxUEGhsSvhuhi80c4qmLiW633d5uf0mkzUGzBMfX8= github.com/helloeave/json v1.15.3/go.mod h1:uTHhuUsgnrpm9cc7Gi3tfIUwgf1dq/7+uLfpUFLBFEQ= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -47,6 +78,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -56,6 +95,8 @@ github.com/redis/go-redis/v9 v9.3.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 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/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -83,19 +124,21 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -103,6 +146,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -112,8 +157,10 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/kv/providers/vault/vault.go b/kv/providers/vault/vault.go new file mode 100644 index 00000000..26ca9c13 --- /dev/null +++ b/kv/providers/vault/vault.go @@ -0,0 +1,253 @@ +// Package vault provides a kv.Provider backed by HashiCorp Vault. It reads +// secrets from Vault's KV engine (v1 or v2) and returns each secret's data map +// serialized as JSON, leaving field selection to the resolver's "#field" syntax. +// +// Unlike the local providers (env, file, inline), vault is remote: it is not +// Standalone and exposes a Timeouter, so the registry wraps it in the caching / +// singleflight SecretStore. The Vault client is created without any network I/O; +// the connection is established lazily on the first Get. +package vault + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path" + "strings" + "time" + + "github.com/TykTechnologies/storage/kv" + "github.com/hashicorp/vault/api" +) + +// Config is the JSON "config" block of a vault store. +type Config struct { + // Address is the Vault server URL, e.g. "https://vault.example.com:8200". + // Optional: when empty the Vault client default is used (the VAULT_ADDR + // environment variable, otherwise https://127.0.0.1:8200). + Address string `json:"address"` + + // AgentAddress is the URL of a local Vault Agent, e.g. "http://127.0.0.1:8100". + // When set, the client routes requests through the agent instead of Address. + // A token is still required. + AgentAddress string `json:"agent_address"` + + // MaxRetries caps how many times the client retries a request after a + // server (5xx) error. Applied only when > 0; otherwise the Vault client + // default is kept. + MaxRetries int `json:"max_retries"` + + // Timeout bounds each Vault request. It is a Go duration string such as + // "5s" or "500ms"; an empty value means "unset", leaving the SecretStore to + // apply its own default. + Timeout string `json:"timeout"` + + // Token authenticates requests to Vault. Required. + Token string `json:"token"` + + // KVVersion selects the KV secrets engine version. Any value other than 1 + // means v2 (the default): secrets live under "/data/" and are + // wrapped in a "data" envelope. 1 selects v1, where the path is used as-is. + KVVersion int `json:"kv_version"` + + // MountPath is the path the KV secrets engine is mounted at, e.g. "secret" + // or a nested "tenants/a/kv". It is OPTIONAL and only affects KV v2. + // + // The key passed to Get is always the full logical path under this mount + // (it must start with MountPath). When set, the provider inserts the v2 + // "/data/" segment immediately after MountPath instead of assuming the mount + // is the first path segment — which is what makes nested mounts work. A key + // that is not under MountPath is rejected. + // + // When empty, the provider falls back to the legacy behavior of injecting + // "/data" after the first segment, so existing single-segment-mount configs + // are unaffected. Ignored for KV v1 (which has no data segment). + MountPath string `json:"mount_path"` +} + +// NewFactory returns a kv.ProviderFactory for HashiCorp Vault stores. +// +// The factory parses the provider Config and constructs a Vault API client, but +// performs no network I/O: the connection to Vault is established lazily on the +// first Get. It returns an error only for config that is present but unusable: +// - malformed JSON, +// - an unparseable timeout (must be a Go duration string, e.g. "5s"), +// - a missing token. Vault has no usable zero value, so a token is required +// even when agent_address is set. +// +// The resulting provider is remote: it is NOT Standalone and exposes its timeout +// via the Timeouter interface, so the registry wraps it in the caching / +// singleflight SecretStore and bounds each Get with the configured timeout. +func NewFactory() kv.ProviderFactory { + return func(rawJSON json.RawMessage) (kv.Provider, error) { + // Empty/absent config: json.Unmarshal would fail cryptically on it, so + // reject it with a clear message. A present-but-empty object ("{}") is + // left to the token check below. + if len(rawJSON) == 0 { + return nil, errors.New("vault: config is missing") + } + + var conf Config + + if err := json.Unmarshal(rawJSON, &conf); err != nil { + return nil, fmt.Errorf("vault: invalid config: %w", err) + } + + if conf.Token == "" { + return nil, errors.New("vault: token is required") + } + + defaultCfg := api.DefaultConfig() + + if conf.Address != "" { + defaultCfg.Address = conf.Address + } + + if conf.AgentAddress != "" { + defaultCfg.AgentAddress = conf.AgentAddress + } + + if conf.MaxRetries > 0 { + defaultCfg.MaxRetries = conf.MaxRetries + } + + var timeout time.Duration + + if conf.Timeout != "" { + d, err := time.ParseDuration(conf.Timeout) + if err != nil { + return nil, fmt.Errorf( + "vault: invalid timeout %q: %w", + conf.Timeout, + err, + ) + } + + timeout = d + } + + if timeout > 0 { + defaultCfg.Timeout = timeout + } + + client, err := api.NewClient(defaultCfg) + if err != nil { + return nil, fmt.Errorf("vault: failed to create a client: %w", err) + } + + client.SetToken(conf.Token) + + kvv2 := conf.KVVersion != 1 + + // trim trailing slash(es) so "tenants/a/kv/" and "tenants/a/kv" behave the same + mountPath := strings.TrimRight(conf.MountPath, "/") + + return &vaultProvider{ + client: client, + timeout: timeout, + kvv2: kvv2, + mountPath: mountPath, + }, nil + } +} + +// vaultProvider is a kv.Provider backed by a configured Vault API client. +type vaultProvider struct { + // client is the Vault API client. The resolved Config (address, token, + // retries, timeout) is already baked into it at construction. + client *api.Client + + // timeout is the parsed Config.Timeout, surfaced via Timeout() so the + // SecretStore wrapper can bound each Get with it. 0 means "unset", letting + // the store use its own default. + timeout time.Duration + + // kvv2 selects the read path: when true, Get injects "/data" after the + // mount and unwraps the v2 "data" envelope; when false it reads the path + // as-is (KV v1). + kvv2 bool + + // mountPath is the normalized (trailing slash trimmed) Config.MountPath. When + // non-empty it overrides the legacy first-segment "/data" injection for KV v2 + // and confines keys to that mount. + mountPath string +} + +// Get reads the secret at key and returns its data map serialized as JSON. +// Field selection (the "#field" fragment) is the resolver's responsibility, so +// Get returns the whole secret, never a single value. When mount_path is +// configured, key must be the full logical path under that mount. +// +// A missing secret returns *kv.KeyNotFoundError; a backend or transport failure +// returns *kv.StoreUnavailableError. +func (vp *vaultProvider) Get(ctx context.Context, key string) (string, error) { + apiPath, err := vp.physicalPath(key) + if err != nil { + return "", err + } + + secret, err := vp.client.Logical().ReadWithContext(ctx, apiPath) + if err != nil { + return "", &kv.StoreUnavailableError{KeyPath: key, Err: err} + } + + if secret == nil { + return "", &kv.KeyNotFoundError{KeyPath: key} + } + + data := secret.Data + + if vp.kvv2 { + var ok bool + // KV v2 wraps the secret in an inner "data" field. Its absence means the + // path holds no v2 secret, which we treat as not-found. + data, ok = data["data"].(map[string]any) + if !ok { + return "", &kv.KeyNotFoundError{KeyPath: key} + } + } + + b, err := json.Marshal(data) + if err != nil { + return "", fmt.Errorf("vault: failed to encode secret %q: %w", key, err) + } + + return string(b), nil +} + +func (vp *vaultProvider) Timeout() time.Duration { + return vp.timeout +} + +// physicalPath maps the caller's logical key to the physical path the Vault HTTP +// API expects. For KV v1 the logical and physical paths are identical. For KV v2 +// the engine stores secrets under "/data/", so the "/data/" segment +// is inserted after the mount: +// +// - With mount_path set, immediately after that (possibly multi-segment) mount. +// key must be the full logical path under the mount; otherwise it is rejected. +// The check is on the path-segment boundary (mountPath + "/"), so a mount of +// "secret" does not match an unrelated "secrets/..." key. +// - With mount_path empty, after the first path segment — the legacy assumption +// that the mount is a single top-level segment. The injection is intentionally +// blind: a segment legitimately named "data" must not be special-cased. +func (vp *vaultProvider) physicalPath(key string) (string, error) { + if !vp.kvv2 { + return key, nil + } + + if vp.mountPath != "" { + clean := path.Clean(key) + if !strings.HasPrefix(clean, vp.mountPath+"/") { + return "", fmt.Errorf("vault: key %q is not under mount_path %q", key, vp.mountPath) + } + + return vp.mountPath + "/data" + strings.TrimPrefix(clean, vp.mountPath), nil + } + + splitted := strings.Split(key, "/") + splitted[0] += "/data" + + return strings.Join(splitted, "/"), nil +} diff --git a/kv/providers/vault/vault_test.go b/kv/providers/vault/vault_test.go new file mode 100644 index 00000000..ffde76ba --- /dev/null +++ b/kv/providers/vault/vault_test.go @@ -0,0 +1,540 @@ +package vault_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/TykTechnologies/storage/kv" + "github.com/TykTechnologies/storage/kv/providers/vault" + "github.com/TykTechnologies/storage/kv/registry" + "github.com/TykTechnologies/storage/kv/resolver" + "github.com/stretchr/testify/require" +) + +// How these tests work: +// +// The provider is exercised through the real github.com/hashicorp/vault/api +// client against an in-process HTTP server (vaultStub) that stands in for Vault. +// Each test points Config.Address (or AgentAddress) at the stub's URL, so the +// client builds genuine Vault requests and the stub returns canned KV responses. +// +// This means the tests cover the actual client wiring — URL/path construction, +// the KV v2 "/data" injection (asserted via the request path the stub records), +// token handling, and response parsing — rather than a mock of it, while staying +// hermetic and millisecond-fast. +// +// Note on t.Parallel: every test clears the VAULT_* environment via clearVaultEnv +// (t.Setenv) so a developer's shell can't perturb client construction. t.Setenv is +// incompatible with t.Parallel by design, so these tests are intentionally serial +// — the suite runs in well under a second, so there is nothing to gain from +// parallelism anyway. + +// clearVaultEnv blanks the VAULT_* environment so client construction is +// hermetic. +func clearVaultEnv(t *testing.T) { + t.Helper() + + for _, k := range []string{ + "VAULT_ADDR", "VAULT_AGENT_ADDR", "VAULT_TOKEN", + "VAULT_CACERT", "VAULT_CAPATH", "VAULT_CLIENT_CERT", + "VAULT_CLIENT_KEY", "VAULT_SKIP_VERIFY", "VAULT_TLS_SERVER_NAME", + "VAULT_MAX_RETRIES", "VAULT_CLIENT_TIMEOUT", "VAULT_RATE_LIMIT", + } { + t.Setenv(k, "") + } +} + +// vaultStub is an httptest server that records the requests it receives and +// delegates response construction to a per-test handler. +type vaultStub struct { + url string + mu sync.Mutex + got []string +} + +// requests returns a copy of the recorded "METHOD /path" entries. +func (s *vaultStub) requests() []string { + s.mu.Lock() + defer s.mu.Unlock() + + return append([]string(nil), s.got...) +} + +func newVaultStub(t *testing.T, handler http.HandlerFunc) *vaultStub { + t.Helper() + + s := &vaultStub{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + s.got = append(s.got, r.Method+" "+r.URL.Path) + s.mu.Unlock() + + handler(w, r) + })) + t.Cleanup(srv.Close) + + s.url = srv.URL + + return s +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + if body != nil { + //nolint:errcheck + _ = json.NewEncoder(w).Encode(body) + } +} + +// kvv2Envelope wraps secret data the way Vault's KV v2 engine does on the wire: +// {"data": {"data": , "metadata": {...}}}. +func kvv2Envelope(data map[string]any) map[string]any { + return map[string]any{ + "data": map[string]any{ + "data": data, + "metadata": map[string]any{"version": 1}, + }, + } +} + +// kvv1Envelope wraps secret data the way Vault's KV v1 engine does: +// {"data": }. +func kvv1Envelope(data map[string]any) map[string]any { + return map[string]any{"data": data} +} + +// newVaultProvider builds the provider through its factory, exactly as the +// registry would, with a hermetic environment. +func newVaultProvider(t *testing.T, cfg *vault.Config) kv.Provider { + t.Helper() + + clearVaultEnv(t) + + raw, err := json.Marshal(cfg) + require.NoError(t, err) + + p, err := vault.NewFactory()(raw) + require.NoError(t, err) + require.NotNil(t, p) + + return p +} + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + + b, err := json.Marshal(v) + require.NoError(t, err) + + return b +} + +func TestNewFactory(t *testing.T) { + tests := []struct { + name string + config string + wantErr bool + wantErrContains string + }{ + { + name: "valid full config", + config: `{"address":"http://vault.test:8200","token":"root","kv_version":2}`, + }, + { + name: "token only (address is optional)", + config: `{"token":"root"}`, + }, + { + name: "agent_address with token", + config: `{"agent_address":"http://127.0.0.1:8100","token":"root"}`, + }, + + // Vault has no usable zero value: a token is required even in agent mode, + // so every tokenless config is rejected. + { + name: "empty bytes", + config: ``, + wantErr: true, + }, + { + name: "empty object", + config: `{}`, + wantErr: true, + }, + { + name: "address without token", + config: `{"address":"http://vault.test:8200"}`, + wantErr: true, + }, + { + name: "agent_address without token", + config: `{"agent_address":"http://127.0.0.1:8100"}`, + wantErr: true, + }, + { + name: "address and agent_address without token", + config: `{"address":"http://vault.test:8200","agent_address":"http://127.0.0.1:8100"}`, + wantErr: true, + }, + + // Present-but-invalid config. The factory namespaces these with "vault:". + { + name: "invalid json", + config: `{not json`, + wantErr: true, + wantErrContains: "vault", + }, + { + name: "invalid timeout (not a duration string)", + config: `{"token":"root","timeout":"5x"}`, + wantErr: true, + wantErrContains: "vault", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearVaultEnv(t) + + p, err := vault.NewFactory()(json.RawMessage(tt.config)) + + if tt.wantErr { + require.Error(t, err) + require.Nil(t, p) + + if tt.wantErrContains != "" { + require.ErrorContains(t, err, tt.wantErrContains) + } + + return + } + + require.NoError(t, err) + require.NotNil(t, p) + }) + } +} + +func TestProvider_ReportsConfiguredTimeout(t *testing.T) { + p := newVaultProvider(t, &vault.Config{Token: "root", Timeout: "7s"}) + + to, ok := kv.AsTimeouter(p) + require.True(t, ok, "vault must expose Timeouter so the registry can bound its operations") + require.Equal(t, 7*time.Second, to.Timeout()) +} + +func TestProvider_TimeoutUnsetReportsZero(t *testing.T) { + p := newVaultProvider(t, &vault.Config{Token: "root"}) + + to, ok := kv.AsTimeouter(p) + require.True(t, ok) + require.Zero(t, to.Timeout(), "an unset timeout must report 0 so the store applies its own default") +} + +func TestProvider_IsNotStandalone(t *testing.T) { + p := newVaultProvider(t, &vault.Config{Token: "root"}) + + // Vault is remote and must be wrapped in the registry's cache/singleflight + // decorator, so it must NOT report itself standalone. + s, ok := kv.AsStandalone(p) + require.False(t, ok && s.IsStandalone(), + "vault must not be standalone (the registry must wrap it in the cache)") +} + +func TestGet_ReadsSecret(t *testing.T) { + tests := []struct { + name string + key string + kvVersion int + secret map[string]any + wantPath string + wantJSON string + }{ + { + name: "kv2 multi-segment path returns the whole data map", + key: "secret/myapp/config", + kvVersion: 2, + secret: map[string]any{"api_key": "abc123", "username": "bob"}, + wantPath: "GET /v1/secret/data/myapp/config", + wantJSON: `{"api_key":"abc123","username":"bob"}`, + }, + { + name: "kv2 default version (0) injects /data", + key: "secret/myapp/config", + kvVersion: 0, + secret: map[string]any{"api_key": "abc123"}, + wantPath: "GET /v1/secret/data/myapp/config", + wantJSON: `{"api_key":"abc123"}`, + }, + { + name: "kv2 single-segment path injects /data after the mount", + key: "mysecret", + kvVersion: 2, + secret: map[string]any{"api_key": "abc123"}, + wantPath: "GET /v1/mysecret/data", + wantJSON: `{"api_key":"abc123"}`, + }, + { + name: "kv1 reads the path as-is with no unwrap", + key: "secret/myapp", + kvVersion: 1, + secret: map[string]any{"api_key": "abc123"}, + wantPath: "GET /v1/secret/myapp", + wantJSON: `{"api_key":"abc123"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stub := newVaultStub(t, func(w http.ResponseWriter, _ *http.Request) { + if tt.kvVersion == 1 { + writeJSON(w, http.StatusOK, kvv1Envelope(tt.secret)) + return + } + + writeJSON(w, http.StatusOK, kvv2Envelope(tt.secret)) + }) + + p := newVaultProvider(t, &vault.Config{ + Address: stub.url, + Token: "root", + KVVersion: tt.kvVersion, + }) + + got, err := p.Get(t.Context(), tt.key) + require.NoError(t, err) + require.JSONEq(t, tt.wantJSON, got) + require.Equal(t, []string{tt.wantPath}, stub.requests()) + }) + } +} + +func TestGet_MountPath(t *testing.T) { + tests := []struct { + name string + mountPath string + key string + kvVersion int + wantPath string + wantErr bool + }{ + { + name: "kv2 nested mount injects /data after the configured mount", + mountPath: "tenants/a/kv", + key: "tenants/a/kv/myapp/config", + kvVersion: 2, + wantPath: "GET /v1/tenants/a/kv/data/myapp/config", + }, + { + name: "kv2 single-segment mount matches the legacy result", + mountPath: "secret", + key: "secret/myapp", + kvVersion: 2, + wantPath: "GET /v1/secret/data/myapp", + }, + { + name: "kv2 trailing slash on mount_path is normalized", + mountPath: "tenants/a/kv/", + key: "tenants/a/kv/myapp", + kvVersion: 2, + wantPath: "GET /v1/tenants/a/kv/data/myapp", + }, + { + name: "kv1 ignores mount_path and reads the path as-is", + mountPath: "secret", + key: "secret/myapp", + kvVersion: 1, + wantPath: "GET /v1/secret/myapp", + }, + { + name: "kv2 key outside the mount is rejected before any request", + mountPath: "tenants/a/kv", + key: "other/secret", + kvVersion: 2, + wantErr: true, + }, + { + name: "kv2 key sharing a string prefix but not under the mount is rejected", + mountPath: "secret", + key: "secrets/myapp", + kvVersion: 2, + wantErr: true, + }, + { + name: "kv2 key using ../ to escape the mount is rejected", + mountPath: "tenants/a/kv", + key: "tenants/a/kv/../../b/kv/secret", + kvVersion: 2, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stub := newVaultStub(t, func(w http.ResponseWriter, _ *http.Request) { + if tt.kvVersion == 1 { + writeJSON(w, http.StatusOK, kvv1Envelope(map[string]any{"k": "v"})) + return + } + + writeJSON(w, http.StatusOK, kvv2Envelope(map[string]any{"k": "v"})) + }) + + p := newVaultProvider(t, &vault.Config{ + Address: stub.url, + Token: "root", + KVVersion: tt.kvVersion, + MountPath: tt.mountPath, + }) + + _, err := p.Get(t.Context(), tt.key) + + if tt.wantErr { + require.Error(t, err) + require.Empty(t, stub.requests(), + "a key outside mount_path must be rejected before any Vault request") + + return + } + + require.NoError(t, err) + require.Equal(t, []string{tt.wantPath}, stub.requests()) + }) + } +} + +func TestGet_ReturnsCompactJSONWithoutTrailingNewline(t *testing.T) { + stub := newVaultStub(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, kvv2Envelope(map[string]any{"api_key": "abc123"})) + }) + + p := newVaultProvider(t, &vault.Config{Address: stub.url, Token: "root", KVVersion: 2}) + + got, err := p.Get(t.Context(), "secret/myapp/config") + require.NoError(t, err) + require.Equal(t, `{"api_key":"abc123"}`, got) +} + +func TestGet_SecretNotFoundReturnsKeyNotFound(t *testing.T) { + stub := newVaultStub(t, func(w http.ResponseWriter, _ *http.Request) { + // Empty-body 404: vault's client returns (nil, nil) for a missing path. + w.WriteHeader(http.StatusNotFound) + }) + + p := newVaultProvider(t, &vault.Config{Address: stub.url, Token: "root", KVVersion: 2}) + + _, err := p.Get(t.Context(), "secret/missing") + + var notFound *kv.KeyNotFoundError + require.ErrorAs(t, err, ¬Found, + "a missing secret must map to *kv.KeyNotFoundError for negative_ttl_not_found caching") +} + +func TestGet_KVv2MissingDataEnvelopeReturnsKeyNotFound(t *testing.T) { + stub := newVaultStub(t, func(w http.ResponseWriter, _ *http.Request) { + // 200, but the inner "data" key is absent (only metadata) → the KVv2 + // data.data unwrap fails. + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"metadata": map[string]any{"version": 1}}, + }) + }) + + p := newVaultProvider(t, &vault.Config{Address: stub.url, Token: "root", KVVersion: 2}) + + _, err := p.Get(t.Context(), "secret/myapp/config") + + var notFound *kv.KeyNotFoundError + require.ErrorAs(t, err, ¬Found) +} + +func TestGet_BackendErrorReturnsStoreUnavailable(t *testing.T) { + stub := newVaultStub(t, func(w http.ResponseWriter, _ *http.Request) { + // A 200 is not retried by the vault client, so an unparseable body fails + // fast and deterministically exercises the transport/parse error branch + // (avoids the multi-second retry backoff a 5xx would incur). + w.WriteHeader(http.StatusOK) + + _, err := w.Write([]byte("{ this is not valid vault json")) + if err != nil { + t.Error(err) + } + }) + + p := newVaultProvider(t, &vault.Config{Address: stub.url, Token: "root", KVVersion: 2}) + + _, err := p.Get(t.Context(), "secret/myapp/config") + + var unavailable *kv.StoreUnavailableError + require.ErrorAs(t, err, &unavailable, + "a backend failure must map to *kv.StoreUnavailableError for negative_ttl_transient caching") +} + +func TestGet_PropagatesContextCancellation(t *testing.T) { + stub := newVaultStub(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, kvv2Envelope(map[string]any{"api_key": "abc123"})) + }) + + p := newVaultProvider(t, &vault.Config{Address: stub.url, Token: "root", KVVersion: 2}) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := p.Get(ctx, "secret/myapp/config") + require.ErrorIs(t, err, context.Canceled) +} + +func TestGet_AgentModeRoutesToAgentAddress(t *testing.T) { + agent := newVaultStub(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, kvv2Envelope(map[string]any{"api_key": "abc123"})) + }) + + // agent_address (with a token, per the legacy contract) must route requests + // to the agent, not the default server address. + p := newVaultProvider(t, &vault.Config{AgentAddress: agent.url, Token: "root", KVVersion: 2}) + + got, err := p.Get(t.Context(), "secret/myapp/config") + require.NoError(t, err) + require.JSONEq(t, `{"api_key":"abc123"}`, got) + require.NotEmpty(t, agent.requests(), "the request must be routed to the Vault agent address") +} + +func TestResolver_ExtractsFieldFromVaultSecret(t *testing.T) { + clearVaultEnv(t) + + stub := newVaultStub(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, kvv2Envelope(map[string]any{ + "api_key": "abc123", + "password": "s3cr3t", + })) + }) + + r := registry.NewRegistry() + require.NoError(t, r.Add(kv.Vault, vault.NewFactory())) + + ctx := context.Background() + require.NoError(t, r.InitStores(ctx, &kv.Config{ + Stores: map[string]kv.StoreConfig{ + "vault": { + Type: kv.Vault, + Config: mustJSON(t, vault.Config{Address: stub.url, Token: "root", KVVersion: 2}), + }, + }, + })) + t.Cleanup(func() { _ = r.Close(ctx) }) + + res := resolver.NewResolver(r) + + // End-to-end: the provider returns the whole secret as JSON, the resolver + // extracts the #field via JSON pointer. This pins the core contract change + // (field extraction lives in the resolver, not the provider). + got, err := res.Resolve(ctx, "kv://vault/secret/myapp/config#api_key") + require.NoError(t, err) + require.Equal(t, "abc123", got) +} diff --git a/kv/registry/integration_test.go b/kv/registry/integration_test.go index ce9ab44b..15551198 100644 --- a/kv/registry/integration_test.go +++ b/kv/registry/integration_test.go @@ -2,8 +2,12 @@ package registry_test import ( "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" + "sync/atomic" "testing" "github.com/TykTechnologies/storage/kv" @@ -41,6 +45,18 @@ func promotedDefaults(t *testing.T, basePath string, secrets map[string]string) return stores } +func clearVaultEnv(t *testing.T) { + t.Helper() + + for _, k := range []string{ + "VAULT_ADDR", "VAULT_AGENT_ADDR", "VAULT_TOKEN", + "VAULT_CACERT", "VAULT_CAPATH", "VAULT_CLIENT_CERT", + "VAULT_CLIENT_KEY", "VAULT_SKIP_VERIFY", "VAULT_TLS_SERVER_NAME", + } { + t.Setenv(k, "") + } +} + // TestIntegrationResolveAllLocalProviders resolves a whole config document that // mixes every local provider, both reference syntaxes, and a JSON-pointer // fragment — proving the providers cooperate through the resolver exactly as the @@ -154,3 +170,84 @@ func TestIntegrationEnvEmptyPrefixRejectedThroughStack(t *testing.T) { _, err = res.Resolve(t.Context(), "kv://openenv/PATH") require.ErrorIs(t, err, env.ErrPrefixRequired) } + +func TestIntegrationVaultProviderResolvesThroughDefaultRegistry(t *testing.T) { + clearVaultEnv(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + // KV v2 envelope: {"data": {"data": , "metadata": {...}}}. + //nolint:errcheck + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "data": map[string]any{"password": "s3cr3t"}, + "metadata": map[string]any{"version": 1}, + }, + }) + })) + t.Cleanup(srv.Close) + + doc := []byte(fmt.Sprintf(`{ + "kv": { + "stores": { + "vault": { + "type": "hashicorp_vault", + "required": true, + "config": {"address": %q, "token": "root", "kv_version": 2} + } + } + } + }`, srv.URL)) + + reg := newRegistry(t, doc) + res := resolver.NewResolver(reg) + + got, err := res.Resolve(t.Context(), "kv://vault/secret/myapp#password") + require.NoError(t, err) + assert.Equal(t, "s3cr3t", got) +} + +func TestIntegrationVaultStoreServesRepeatedReadsFromCache(t *testing.T) { + clearVaultEnv(t) + + var hits atomic.Int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + + w.Header().Set("Content-Type", "application/json") + //nolint:errcheck + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "data": map[string]any{"password": "s3cr3t"}, + "metadata": map[string]any{"version": 1}, + }, + }) + })) + t.Cleanup(srv.Close) + + doc := []byte(fmt.Sprintf(`{ + "kv": { + "cache": {"enabled": true, "ttl": "1m"}, + "stores": { + "vault": { + "type": "hashicorp_vault", + "required": true, + "config": {"address": %q, "token": "root", "kv_version": 2} + } + } + } + }`, srv.URL)) + + reg := newRegistry(t, doc) + res := resolver.NewResolver(reg) + + for range 3 { + got, err := res.Resolve(t.Context(), "kv://vault/secret/myapp#password") + require.NoError(t, err) + assert.Equal(t, "s3cr3t", got) + } + + assert.Equal(t, int32(1), hits.Load(), + "3 resolves of the same key must hit the vault backend only once (SecretStore cache)") +} diff --git a/kv/registry/registry.go b/kv/registry/registry.go index 3f39d685..2eb311c2 100644 --- a/kv/registry/registry.go +++ b/kv/registry/registry.go @@ -13,6 +13,7 @@ import ( "github.com/TykTechnologies/storage/kv/providers/env" "github.com/TykTechnologies/storage/kv/providers/file" "github.com/TykTechnologies/storage/kv/providers/inline" + "github.com/TykTechnologies/storage/kv/providers/vault" "golang.org/x/sync/errgroup" ) @@ -80,8 +81,14 @@ func NewDefaultRegistry(opts ...Option) *Registry { }) } + err = r.Add(kv.Vault, vault.NewFactory()) + if err != nil { + r.logger.Warn("Failed to add default vault factory", map[string]any{ + "error": err, + }) + } + // TODO: Uncomment provider registration when implementation is added - // r.Add(kv.Vault, vault.NewFactory()) // r.Add(kv.Consul, consul.NewFactory()) return r diff --git a/kv/registry/registry_test.go b/kv/registry/registry_test.go index af846448..1c2ded83 100644 --- a/kv/registry/registry_test.go +++ b/kv/registry/registry_test.go @@ -82,6 +82,7 @@ func TestNewDefaultRegistry(t *testing.T) { require.NotEmpty(t, r.factories[kv.File]) require.NotEmpty(t, r.factories[kv.Env]) require.NotEmpty(t, r.factories[kv.Inline]) + require.NotEmpty(t, r.factories[kv.Vault]) } func TestAddFactory(t *testing.T) { From 0e55cf337188c9e8609c37b7c3998186e35cb630 Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi <109525963+vladzabolotnyi@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:22:12 +0300 Subject: [PATCH 16/31] TT-17246: Add consul provider (#152) * feat: add implementation to Resolve func * feat: add resolveAll implementation * test: add test cases for json pointer extraction * refactor: update error messages and make error handlin more consistent * refactor: update resolve tests * refactor: update resolveAll tests with grouping error cases * fix: added malformed err with tests, added docs string * test: add test suite with path routing * fix: remove redundant assertions and update comment * test: add test case that resolve all resolved mixed values * fix: use decoding with numbers to avoid losing large integers and update doc strings explaining inner behavior * fix: add checks with tests to avoid processing values when store or key is empty string * fix: remove spaces around em-dashes and add missing tests for missing stores and keys * chore: remove redundant comments * fix: address sonarqube comment with avoiding duplication * fix: add json validation on document even if it doesn't contain kv refs * refactor: split resolver for public and private parts to hide inner logic with lenient mode in future * chore: replace background context with test one * feat: add cache bypass context logic to skip serving the value from cache when its desired * feat: add lenient mode to the resolver * feat: add base implementation of new from config * refactor: update implementation with clear func separation and code simplification; add docs * fix: lint errors * chore: remove redundant comments * feat: add file provider as a type with registry processing it as a local type provider * refactor: add more comments and explanation about phase1 and phase2 and why we need them to increase readability * refactor: remove isLocalType func and attach the logic to provider type to avoid OCP and increase discoverability * fix: missing pieces from last commit * docs: update docstring and comment explaining better why process should resolved value for non-local stores only * feat: add file provider with tests * feat: add sentinel errors and rework current errors * feat: update returning error when file is not exist on the provided key * feat: adapt the KeyNotFoundError to the standalone providers * refactor: put the provider_test to kv_test to test public methods and interfaces * feat: add ability to override default factories when WithFactories opt is used * feat: add file provider registration at the default registry * test: add tests covering logic with factories overriding * fix: update the logic with rejecting retrieving when basePath is not provided * fix: add case to cover empty keys * fix: add guard against non-abs for base_path * feat: add base implementation for env provider * feat: force mandatory prefix to prevent accessing undesired env variables * chore: remove comment * feat: add inline provider * test: add integration tests to prove that NewFromConfig is working with local providers like gateway expects it * docs: add doc comment explaining when provider factories MUST return an error to let other devs learn the principle * fix: address sonarqube issues * feat: add init implementaiton of vault provider * docs: update docs for the NewFactory * fix: json.Encoder is adding a new line symbol in the end of the encoded value, reworked with test * fix: add comments explaining why some decisions are made * docs: add more comments * fix: address linter comment with huge param and passing config as pointer instead * chore: add nolint:errcheck to silence an error * refactor: fold tests to table tests * feat: update adding vault to default registry and add integration test * fix: remove structviewer as it doesn't used on the lib * fix: tidy deps * feat: add mount_path config to adapt provider to not hardcoded mount pathes * fix: add path.Clean() to reject traversal paths * deps: add consul library * feat: add NewFactory with tests * feat: add implementation with tests for the Get method * feat: add registration of consul default factory on registry * test: add consul integration test on the registry side * refactor: add docs comments and isolate the processing of TLS config * chore: remove redundant comments --------- Co-authored-by: Vlad Zabolotnyi --- go.mod | 17 +- go.sum | 172 ++++++++++++- kv/providers/consul/consul.go | 177 +++++++++++++ kv/providers/consul/consul_test.go | 401 +++++++++++++++++++++++++++++ kv/registry/integration_test.go | 136 ++++++++++ kv/registry/registry.go | 9 +- kv/registry/registry_test.go | 4 +- 7 files changed, 905 insertions(+), 11 deletions(-) create mode 100644 kv/providers/consul/consul.go create mode 100644 kv/providers/consul/consul_test.go diff --git a/go.mod b/go.mod index a96e5a41..0cfbf5ee 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,8 @@ module github.com/TykTechnologies/storage go 1.25.0 require ( - github.com/google/go-cmp v0.5.9 + github.com/google/go-cmp v0.6.0 + github.com/hashicorp/consul/api v1.31.2 github.com/hashicorp/vault/api v1.23.0 github.com/lib/pq v1.10.9 github.com/redis/go-redis/v9 v9.3.1 @@ -28,26 +29,34 @@ require ( ) require ( + github.com/armon/go-metrics v0.4.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.2.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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fatih/color v1.18.0 // indirect github.com/go-jose/go-jose/v4 v4.1.1 // indirect github.com/golang/snappy v0.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/serf v0.10.1 // indirect github.com/klauspost/compress v1.13.6 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect @@ -55,7 +64,9 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect golang.org/x/crypto v0.45.0 // indirect + golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/time v0.12.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index b4443ea1..852b147f 100644 --- a/go.sum +++ b/go.sum @@ -1,39 +1,90 @@ +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/TykTechnologies/gorm v1.20.7-0.20260417144542-d6d1a2e12d9d h1:wePfwBWzjkC+lc/jEQsvcbQ3Rt0u+7MIj3jXWPnU6Qo= github.com/TykTechnologies/gorm v1.20.7-0.20260417144542-d6d1a2e12d9d/go.mod h1:hz0d/E0QBTYarOnYtdcNnBWN/NYxVMP7nZNDT6E/fFM= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/hashicorp/consul/api v1.31.2 h1:NicObVJHcCmyOIl7Z9iHPvvFrocgTYo9cITSGg0/7pw= +github.com/hashicorp/consul/api v1.31.2/go.mod h1:Z8YgY0eVPukT/17ejW+l+C7zJmKwgPHtjU1q16v/Y40= +github.com/hashicorp/consul/sdk v0.16.1 h1:V8TxTnImoPD5cj0U9Spl0TUxcytjcbbJeADFF07KdHg= +github.com/hashicorp/consul/sdk v0.16.1/go.mod h1:fSXvwxB2hmh1FMZCNl6PwX0Q/1wdWtHJcZ7Ea5tns0s= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= +github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= @@ -42,10 +93,27 @@ github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRct github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= +github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM= +github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= +github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= +github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= github.com/helloeave/json v1.15.3 h1:roUxUEGhsSvhuhi80c4qmLiW633d5uf0mkzUGzBMfX8= @@ -66,8 +134,13 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -78,37 +151,89 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/redis/go-redis/v9 v9.3.1 h1:KqdY8U+3X6z+iACvumCNxnoluToB+9Me+TvyFa21Mds= github.com/redis/go-redis/v9 v9.3.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 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/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= @@ -120,38 +245,70 @@ github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7Jul github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk= go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -162,17 +319,24 @@ golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 h1:VpOs+IwYnYBaFnrNAeB8UUWtL3vEUnzSCL1nVjPhqrw= gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/kv/providers/consul/consul.go b/kv/providers/consul/consul.go new file mode 100644 index 00000000..becdb661 --- /dev/null +++ b/kv/providers/consul/consul.go @@ -0,0 +1,177 @@ +// Package consul provides a kv.Provider backed by HashiCorp Consul's KV store. +// It performs direct per-key reads (GET /v1/kv/) and returns each key's +// value verbatim, leaving "#field" extraction to the resolver. +// +// Consul is a remote provider: it is not Standalone, so the registry wraps it in +// the caching / singleflight SecretStore. The client is built without any +// network I/O; the connection is established lazily on the first Get. +package consul + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/TykTechnologies/storage/kv" + "github.com/hashicorp/consul/api" +) + +// Config is the JSON "config" block of a consul store. +type Config struct { + // Address of the consul agent as host:port; the scheme comes from Scheme. + Address string `json:"address"` + + // Scheme is the URI scheme ("http" or "https"). Defaults to "http". + Scheme string `json:"scheme"` + + // Datacenter to query. Empty uses the agent's default datacenter. + Datacenter string `json:"datacenter"` + + // HttpAuth holds optional HTTP Basic Auth credentials. + HttpAuth struct { + Username string `json:"username"` + Password string `json:"password"` + } `json:"http_auth"` + + // WaitTime is consul's blocking-query (watch) timeout, as a Go duration + // string such as "5s". It is NOT a per-operation timeout — our plain reads + // never block on it — which is why the provider does not implement Timeouter. + // Empty or "0s" leaves the agent default. + WaitTime string `json:"wait_time"` + + // Token is used to provide a per-request ACL token + // which overrides the agent's default token. + Token string `json:"token"` + + // TLSConfig configures TLS for https connections. + TLSConfig struct { + Address string `json:"address"` + CAFile string `json:"ca_file"` + CAPath string `json:"ca_path"` + CertFile string `json:"cert_file"` + KeyFile string `json:"key_file"` + InsecureSkipVerify bool `json:"insecure_skip_verify"` + } `json:"tls_config"` +} + +// NewFactory returns a kv.ProviderFactory for HashiCorp Consul stores. +// +// The factory parses Config and builds a consul API client, but performs no +// network I/O. It errors only for config that is present but unusable: malformed +// JSON, an unparseable wait_time, or TLS settings the client rejects (e.g. an +// unreadable ca_file). Absent config is valid — an empty blob builds a client +// against consul's default local agent. +func NewFactory() kv.ProviderFactory { + return func(rawJSON json.RawMessage) (kv.Provider, error) { + var conf Config + + // Absent config is a valid state: skip the unmarshal so nil/empty input + // falls through to consul's defaults instead of failing to parse. + if len(rawJSON) != 0 { + if err := json.Unmarshal(rawJSON, &conf); err != nil { + return nil, fmt.Errorf("consul: invalid config: %w", err) + } + } + + clientCfg := api.DefaultConfig() + + if conf.Address != "" { + clientCfg.Address = conf.Address + } + + if conf.Scheme != "" { + clientCfg.Scheme = conf.Scheme + } + + if conf.Datacenter != "" { + clientCfg.Datacenter = conf.Datacenter + } + + if conf.HttpAuth.Username != "" || conf.HttpAuth.Password != "" { + clientCfg.HttpAuth = &api.HttpBasicAuth{ + Username: conf.HttpAuth.Username, + Password: conf.HttpAuth.Password, + } + } + + if conf.WaitTime != "" { + waitTime, err := time.ParseDuration(conf.WaitTime) + if err != nil { + return nil, fmt.Errorf("consul: invalid wait_time %q: %w", conf.WaitTime, err) + } + + if waitTime > 0 { + clientCfg.WaitTime = waitTime + } + } + + if conf.Token != "" { + clientCfg.Token = conf.Token + } + + applyTLSConfig(clientCfg, &conf) + + client, err := api.NewClient(clientCfg) + if err != nil { + return nil, fmt.Errorf("consul: failed to create client: %w", err) + } + + return &consulProvider{kvClient: client.KV()}, nil + } +} + +func applyTLSConfig(clientCfg *api.Config, conf *Config) { + tls := conf.TLSConfig + + if tls.Address != "" { + clientCfg.TLSConfig.Address = tls.Address + } + + if tls.CAFile != "" { + clientCfg.TLSConfig.CAFile = tls.CAFile + } + + if tls.CAPath != "" { + clientCfg.TLSConfig.CAPath = tls.CAPath + } + + if tls.CertFile != "" { + clientCfg.TLSConfig.CertFile = tls.CertFile + } + + if tls.KeyFile != "" { + clientCfg.TLSConfig.KeyFile = tls.KeyFile + } + + if tls.InsecureSkipVerify { + clientCfg.TLSConfig.InsecureSkipVerify = true + } +} + +// consulProvider is a kv.Provider backed by a configured consul KV client. +type consulProvider struct { + // kvClient is consul's KV endpoint, with the resolved Config already baked + // into the underlying client. + kvClient *api.KV +} + +// Get reads the value at key and returns it verbatim: no trimming, no key +// transformation, no interpretation ("#field" extraction is the resolver's job). +// +// A missing key returns *kv.KeyNotFoundError and a transport/backend failure +// returns *kv.StoreUnavailableError — the two error types the negative cache +// distinguishes. ctx bounds the request via QueryOptions, so the SecretStore's +// per-operation deadline is honored. +func (cp *consulProvider) Get(ctx context.Context, key string) (string, error) { + pair, _, err := cp.kvClient.Get(key, (&api.QueryOptions{}).WithContext(ctx)) + if err != nil { + return "", &kv.StoreUnavailableError{KeyPath: key, Err: err} + } + + if pair == nil { + return "", &kv.KeyNotFoundError{KeyPath: key} + } + + return string(pair.Value), nil +} diff --git a/kv/providers/consul/consul_test.go b/kv/providers/consul/consul_test.go new file mode 100644 index 00000000..0628be89 --- /dev/null +++ b/kv/providers/consul/consul_test.go @@ -0,0 +1,401 @@ +package consul_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/TykTechnologies/storage/kv" + "github.com/TykTechnologies/storage/kv/providers/consul" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// How these tests work: +// +// The provider is exercised through the real github.com/hashicorp/consul/api +// client against an in-process HTTP server (consulStub) that mimics consul's KV +// HTTP API. Each test points Config.Address at the stub, so the client builds +// genuine consul requests and the stub returns canned KV responses. This covers +// the actual client wiring — URL/path construction, base64 value decoding, +// 404→not-found, basic-auth headers — rather than a mock of it, while staying +// hermetic and millisecond-fast. +// + +// clearConsulEnv blanks the CONSUL_* environment that consulapi.DefaultConfig +// reads, so client construction is hermetic and reproducible. +func clearConsulEnv(t *testing.T) { + t.Helper() + + for _, k := range []string{ + "CONSUL_HTTP_ADDR", "CONSUL_HTTP_TOKEN", "CONSUL_HTTP_TOKEN_FILE", + "CONSUL_HTTP_AUTH", "CONSUL_HTTP_SSL", "CONSUL_HTTP_SSL_VERIFY", + "CONSUL_CACERT", "CONSUL_CAPATH", "CONSUL_CLIENT_CERT", + "CONSUL_CLIENT_KEY", "CONSUL_TLS_SERVER_NAME", "CONSUL_NAMESPACE", + } { + t.Setenv(k, "") + } +} + +// consulStub is an httptest server that records the requests it receives and +// delegates response construction to a per-test handler. +type consulStub struct { + url string + mu sync.Mutex + got []string + auth []string +} + +// requests returns a copy of the recorded "METHOD /path" entries. +func (s *consulStub) requests() []string { + s.mu.Lock() + defer s.mu.Unlock() + + return append([]string(nil), s.got...) +} + +// lastAuth returns the Authorization header of the most recent request. +func (s *consulStub) lastAuth() string { + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.auth) == 0 { + return "" + } + + return s.auth[len(s.auth)-1] +} + +func newConsulStub(t *testing.T, handler http.HandlerFunc) *consulStub { + t.Helper() + + s := &consulStub{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + s.got = append(s.got, r.Method+" "+r.URL.Path) + s.auth = append(s.auth, r.Header.Get("Authorization")) + s.mu.Unlock() + + handler(w, r) + })) + t.Cleanup(srv.Close) + + s.url = srv.URL + + return s +} + +// writeConsulValue writes the wire shape consul's KV API returns for a present +// key: a JSON array of one KVPair whose Value is base64-encoded. Go marshals a +// []byte field to base64, exactly as consul does and consulapi expects. +func writeConsulValue(w http.ResponseWriter, key, value string) { + w.Header().Set("Content-Type", "application/json") + + //nolint:errcheck + _ = json.NewEncoder(w).Encode([]struct { + Key string `json:"Key"` + Value []byte `json:"Value"` + }{ + {Key: key, Value: []byte(value)}, + }) +} + +// addrOf strips the scheme so the stub URL is usable as a consul Config.Address +// (host:port), matching how the gateway promotes "consul.internal:8500". +func addrOf(url string) string { + return url[len("http://"):] +} + +// newConsulProvider builds the provider through its factory, exactly as the +// registry would, with a hermetic environment. +func newConsulProvider(t *testing.T, cfg *consul.Config) kv.Provider { + t.Helper() + + clearConsulEnv(t) + + raw, err := json.Marshal(cfg) + require.NoError(t, err) + + p, err := consul.NewFactory()(raw) + require.NoError(t, err) + require.NotNil(t, p) + + return p +} + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + + b, err := json.Marshal(v) + require.NoError(t, err) + + return b +} + +func TestNewFactory(t *testing.T) { + tests := []struct { + name string + config string + wantErr bool + wantErrContains string + }{ + { + name: "valid full config", + config: `{"address":"127.0.0.1:8500","scheme":"http","datacenter":"dc1","token":"root","wait_time":"5s"}`, + }, + { + name: "address only", + config: `{"address":"consul.internal:8500"}`, + }, + { + name: "empty object is permissive", + config: `{}`, + }, + { + name: "empty bytes is permissive", + config: ``, + }, + { + name: "nil is permissive", + config: "\x00null-sentinel", // replaced with nil below + }, + + { + name: "wait_time 0s (unset duration) is accepted", + config: `{"wait_time":"0s"}`, + }, + { + name: "invalid json", + config: `{not json`, + wantErr: true, + wantErrContains: "consul", + }, + { + name: "invalid wait_time (not a duration string)", + config: `{"wait_time":"5x"}`, + wantErr: true, + wantErrContains: "consul", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearConsulEnv(t) + + raw := json.RawMessage(tt.config) + if tt.config == "\x00null-sentinel" { + raw = nil + } + + p, err := consul.NewFactory()(raw) + + if tt.wantErr { + require.Error(t, err) + require.Nil(t, p) + + if tt.wantErrContains != "" { + require.ErrorContains(t, err, tt.wantErrContains) + } + + return + } + + require.NoError(t, err) + require.NotNil(t, p) + }) + } +} + +func TestNewFactory_InvalidTLSCAFileErrors(t *testing.T) { + clearConsulEnv(t) + + var cfg consul.Config + cfg.Address = "127.0.0.1:8500" + cfg.TLSConfig.CAFile = "/nonexistent/ca-does-not-exist.pem" + + p, err := consul.NewFactory()(mustJSON(t, cfg)) + require.Error(t, err) + require.Nil(t, p) + require.ErrorContains(t, err, "consul") +} + +func TestProvider_IsNotStandalone(t *testing.T) { + p := newConsulProvider(t, &consul.Config{}) + + s, ok := kv.AsStandalone(p) + require.False(t, ok && s.IsStandalone(), + "consul must not be standalone (the registry must wrap it in the cache)") +} + +func TestProvider_DoesNotExposeTimeouter(t *testing.T) { + p := newConsulProvider(t, &consul.Config{WaitTime: "30s"}) + + _, ok := kv.AsTimeouter(p) + require.False(t, ok, + "consul must not expose Timeouter; wait_time is a watch timeout, not a per-op timeout") +} + +func TestGet_ReadsValue(t *testing.T) { + tests := []struct { + name string + key string + value string + wantPath string + }{ + { + name: "single-segment key", + key: "mykey", + value: "myvalue", + wantPath: "GET /v1/kv/mykey", + }, + { + name: "multi-segment key preserved verbatim (no transform)", + key: "services/redis/host", + value: "cache01.internal", + wantPath: "GET /v1/kv/services/redis/host", + }, + { + name: "value returned byte-exact with no trailing-newline trim", + key: "raw", + value: "line\n", + wantPath: "GET /v1/kv/raw", + }, + { + name: "binary-safe value (embedded NUL survives base64 round-trip)", + key: "bin", + value: "a\x00b", + wantPath: "GET /v1/kv/bin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stub := newConsulStub(t, func(w http.ResponseWriter, _ *http.Request) { + writeConsulValue(w, tt.key, tt.value) + }) + + p := newConsulProvider(t, &consul.Config{Address: addrOf(stub.url)}) + + got, err := p.Get(t.Context(), tt.key) + require.NoError(t, err) + + assert.Equal(t, tt.value, got) + assert.Equal(t, []string{tt.wantPath}, stub.requests()) + }) + } +} + +func TestGet_MissingKeyReturnsKeyNotFound(t *testing.T) { + stub := newConsulStub(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + p := newConsulProvider(t, &consul.Config{Address: addrOf(stub.url)}) + + _, err := p.Get(t.Context(), "services/absent") + + var notFound *kv.KeyNotFoundError + require.ErrorAs(t, err, ¬Found, + "a missing key must map to *kv.KeyNotFoundError for negative_ttl_not_found caching") + require.Equal(t, "services/absent", notFound.KeyPath) +} + +func TestGet_BackendErrorReturnsStoreUnavailable(t *testing.T) { + stub := newConsulStub(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + p := newConsulProvider(t, &consul.Config{Address: addrOf(stub.url)}) + + _, err := p.Get(t.Context(), "services/redis") + + var unavailable *kv.StoreUnavailableError + require.ErrorAs(t, err, &unavailable, + "a backend failure must map to *kv.StoreUnavailableError for negative_ttl_transient caching") +} + +func TestGet_UsesQueryContextForBasicAuth(t *testing.T) { + stub := newConsulStub(t, func(w http.ResponseWriter, _ *http.Request) { + writeConsulValue(w, "k", "v") + }) + + var cfg consul.Config + cfg.Address = addrOf(stub.url) + cfg.HttpAuth.Username = "user" + cfg.HttpAuth.Password = "pass" + + p := newConsulProvider(t, &cfg) + + got, err := p.Get(t.Context(), "k") + require.NoError(t, err) + require.Equal(t, "v", got) + + want := "Basic " + base64.StdEncoding.EncodeToString([]byte("user:pass")) + require.Equal(t, want, stub.lastAuth()) +} + +func TestGet_PropagatesContextCancellation(t *testing.T) { + stub := newConsulStub(t, func(w http.ResponseWriter, _ *http.Request) { + writeConsulValue(w, "k", "v") + }) + + p := newConsulProvider(t, &consul.Config{Address: addrOf(stub.url)}) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := p.Get(ctx, "k") + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) +} + +func TestGet_HonorsContextDeadline(t *testing.T) { + stub := newConsulStub(t, func(w http.ResponseWriter, _ *http.Request) { + // Sleep past the caller's deadline so the request is aborted in flight. + // The provider must attach ctx via QueryOptions.WithContext for the + // SecretStore's per-op deadline to actually bound the call. + time.Sleep(200 * time.Millisecond) + writeConsulValue(w, "k", "v") + }) + + p := newConsulProvider(t, &consul.Config{Address: addrOf(stub.url)}) + + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + + _, err := p.Get(ctx, "k") + require.Error(t, err) + require.ErrorIs(t, err, context.DeadlineExceeded) +} + +func TestBackwardCompatParity_ConsulGet(t *testing.T) { + t.Run("key exists -> value returned", func(t *testing.T) { + stub := newConsulStub(t, func(w http.ResponseWriter, _ *http.Request) { + writeConsulValue(w, "tyk-apis/my_service_url", "https://upstream.internal") + }) + + p := newConsulProvider(t, &consul.Config{Address: addrOf(stub.url)}) + + got, err := p.Get(t.Context(), "tyk-apis/my_service_url") + require.NoError(t, err) + require.Equal(t, "https://upstream.internal", got) + }) + + t.Run("key absent -> not found", func(t *testing.T) { + stub := newConsulStub(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + p := newConsulProvider(t, &consul.Config{Address: addrOf(stub.url)}) + + _, err := p.Get(t.Context(), "tyk-apis/missing") + + var notFound *kv.KeyNotFoundError + require.ErrorAs(t, err, ¬Found) + }) +} diff --git a/kv/registry/integration_test.go b/kv/registry/integration_test.go index 15551198..cb4eb60e 100644 --- a/kv/registry/integration_test.go +++ b/kv/registry/integration_test.go @@ -57,6 +57,35 @@ func clearVaultEnv(t *testing.T) { } } +func clearConsulEnv(t *testing.T) { + t.Helper() + + for _, k := range []string{ + "CONSUL_HTTP_ADDR", "CONSUL_HTTP_TOKEN", "CONSUL_HTTP_TOKEN_FILE", + "CONSUL_HTTP_AUTH", "CONSUL_HTTP_SSL", "CONSUL_HTTP_SSL_VERIFY", + "CONSUL_CACERT", "CONSUL_CAPATH", "CONSUL_CLIENT_CERT", + "CONSUL_CLIENT_KEY", "CONSUL_TLS_SERVER_NAME", "CONSUL_NAMESPACE", + } { + t.Setenv(k, "") + } +} + +func consulKVResponse(w http.ResponseWriter, key, value string) { + w.Header().Set("Content-Type", "application/json") + + //nolint:errcheck + _ = json.NewEncoder(w).Encode([]struct { + Key string `json:"Key"` + Value []byte `json:"Value"` + }{ + {Key: key, Value: []byte(value)}, + }) +} + +func consulAddr(url string) string { + return url[len("http://"):] +} + // TestIntegrationResolveAllLocalProviders resolves a whole config document that // mixes every local provider, both reference syntaxes, and a JSON-pointer // fragment — proving the providers cooperate through the resolver exactly as the @@ -251,3 +280,110 @@ func TestIntegrationVaultStoreServesRepeatedReadsFromCache(t *testing.T) { assert.Equal(t, int32(1), hits.Load(), "3 resolves of the same key must hit the vault backend only once (SecretStore cache)") } + +func TestIntegrationConsulProviderResolvesThroughDefaultRegistry(t *testing.T) { + clearConsulEnv(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + consulKVResponse(w, "services/redis", `{"host":"cache01","port":"6379"}`) + })) + t.Cleanup(srv.Close) + + doc := []byte(fmt.Sprintf(`{ + "kv": { + "stores": { + "consul": { + "type": "hashicorp_consul", + "required": true, + "config": {"address": %q} + } + } + } + }`, consulAddr(srv.URL))) + + reg := newRegistry(t, doc) + res := resolver.NewResolver(reg) + + whole, err := res.Resolve(t.Context(), "kv://consul/services/redis") + require.NoError(t, err) + assert.Equal(t, `{"host":"cache01","port":"6379"}`, whole) + + got, err := res.Resolve(t.Context(), "kv://consul/services/redis#host") + require.NoError(t, err) + assert.Equal(t, "cache01", got) +} + +func TestIntegrationConsulStoreServesRepeatedReadsFromCache(t *testing.T) { + clearConsulEnv(t) + + var hits atomic.Int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + consulKVResponse(w, "services/redis", "cache01") + })) + t.Cleanup(srv.Close) + + doc := []byte(fmt.Sprintf(`{ + "kv": { + "cache": {"enabled": true, "ttl": "1m"}, + "stores": { + "consul": { + "type": "hashicorp_consul", + "required": true, + "config": {"address": %q} + } + } + } + }`, consulAddr(srv.URL))) + + reg := newRegistry(t, doc) + res := resolver.NewResolver(reg) + + for range 3 { + got, err := res.Resolve(t.Context(), "kv://consul/services/redis") + require.NoError(t, err) + assert.Equal(t, "cache01", got) + } + + assert.Equal(t, int32(1), hits.Load(), + "3 resolves of the same key must hit the consul backend only once (SecretStore cache)") +} + +func TestIntegrationConsulStoreNegativeCachesNotFound(t *testing.T) { + clearConsulEnv(t) + + var hits atomic.Int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + + doc := []byte(fmt.Sprintf(`{ + "kv": { + "cache": {"enabled": true, "ttl": "1m", "negative_ttl_not_found": "1m"}, + "stores": { + "consul": { + "type": "hashicorp_consul", + "required": true, + "config": {"address": %q} + } + } + } + }`, consulAddr(srv.URL))) + + reg := newRegistry(t, doc) + res := resolver.NewResolver(reg) + + for range 3 { + _, err := res.Resolve(t.Context(), "kv://consul/services/absent") + + var notFound *kv.KeyNotFoundError + require.ErrorAs(t, err, ¬Found) + } + + assert.Equal(t, int32(1), hits.Load(), + "a not-found must be negatively cached (negative_ttl_not_found bucket), not re-fetched") +} diff --git a/kv/registry/registry.go b/kv/registry/registry.go index 2eb311c2..5ac1dadf 100644 --- a/kv/registry/registry.go +++ b/kv/registry/registry.go @@ -10,6 +10,7 @@ import ( "github.com/TykTechnologies/storage/kv" "github.com/TykTechnologies/storage/kv/internal/store" + "github.com/TykTechnologies/storage/kv/providers/consul" "github.com/TykTechnologies/storage/kv/providers/env" "github.com/TykTechnologies/storage/kv/providers/file" "github.com/TykTechnologies/storage/kv/providers/inline" @@ -88,8 +89,12 @@ func NewDefaultRegistry(opts ...Option) *Registry { }) } - // TODO: Uncomment provider registration when implementation is added - // r.Add(kv.Consul, consul.NewFactory()) + err = r.Add(kv.Consul, consul.NewFactory()) + if err != nil { + r.logger.Warn("Failed to add default consul factory", map[string]any{ + "error": err, + }) + } return r } diff --git a/kv/registry/registry_test.go b/kv/registry/registry_test.go index 1c2ded83..12d8982c 100644 --- a/kv/registry/registry_test.go +++ b/kv/registry/registry_test.go @@ -74,15 +74,15 @@ func TestNewRegistry(t *testing.T) { require.NotNil(t, registry.factories) } -// TODO: Update the test case when providers are set, to assert -// that all OSS providers are registered. func TestNewDefaultRegistry(t *testing.T) { r := NewDefaultRegistry() + require.NotNil(t, r) require.NotEmpty(t, r.factories[kv.File]) require.NotEmpty(t, r.factories[kv.Env]) require.NotEmpty(t, r.factories[kv.Inline]) require.NotEmpty(t, r.factories[kv.Vault]) + require.NotEmpty(t, r.factories[kv.Consul]) } func TestAddFactory(t *testing.T) { From d9e58de24faebad11f53862a5c2cf5a827581bd9 Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Tue, 7 Jul 2026 14:17:52 +0300 Subject: [PATCH 17/31] feat: add WithInitLogger func to allow caller pass the logger for bootstrap logic --- kv/registry/from_config.go | 23 ++++++++++++---- kv/registry/from_config_test.go | 47 +++++++++++++++++++++++++++++++++ kv/registry/registry_test.go | 6 ++--- 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/kv/registry/from_config.go b/kv/registry/from_config.go index 17a76404..685b3182 100644 --- a/kv/registry/from_config.go +++ b/kv/registry/from_config.go @@ -15,6 +15,7 @@ import ( type initOptions struct { factories map[kv.ProviderType]kv.ProviderFactory defaultStores map[string]kv.StoreConfig + logger kv.Logger } // InitOption configures NewFromConfig. @@ -37,6 +38,12 @@ func WithDefaultStores(s map[string]kv.StoreConfig) InitOption { } } +func WithInitLogger(l kv.Logger) InitOption { + return func(o *initOptions) { + o.logger = l + } +} + // NewFromConfig initializes a Registry from the kv section of rawConfig. // Internally it runs a two-phase process so that store credentials (e.g. a // Vault token) can themselves live in an env or inline store and be @@ -97,12 +104,12 @@ func NewFromConfig( maps.Copy(merged, config.KV.Stores) // Phase 1: resolve references in store configs against local stores. - if err := resolveStoreConfigReferences(ctx, merged, options.factories); err != nil { + if err := resolveStoreConfigReferences(ctx, merged, options.factories, options.logger); err != nil { return nil, err } // Phase 2: build the registry and initialize all stores. - full, err := newRegistryWithFactories(options.factories) + full, err := newRegistryWithFactories(options.factories, options.logger) if err != nil { return nil, err } @@ -131,6 +138,7 @@ func resolveStoreConfigReferences( ctx context.Context, merged map[string]kv.StoreConfig, factories map[kv.ProviderType]kv.ProviderFactory, + logger kv.Logger, ) error { locals := make(map[string]kv.StoreConfig) var hasRemotes bool @@ -149,7 +157,7 @@ func resolveStoreConfigReferences( return nil } - bootstrap, err := newRegistryWithFactories(factories) + bootstrap, err := newRegistryWithFactories(factories, logger) if err != nil { return err } @@ -186,8 +194,13 @@ func resolveStoreConfigReferences( return nil } -func newRegistryWithFactories(factories map[kv.ProviderType]kv.ProviderFactory) (*Registry, error) { - r := NewDefaultRegistry() +func newRegistryWithFactories(factories map[kv.ProviderType]kv.ProviderFactory, logger kv.Logger) (*Registry, error) { + var rOpts []Option + if logger != nil { + rOpts = append(rOpts, WithLogger(logger)) + } + + r := NewDefaultRegistry(rOpts...) for providerType, factory := range factories { if err := r.set(providerType, factory); err != nil { diff --git a/kv/registry/from_config_test.go b/kv/registry/from_config_test.go index 3bf61f0e..2fd0718d 100644 --- a/kv/registry/from_config_test.go +++ b/kv/registry/from_config_test.go @@ -139,6 +139,15 @@ func newRegistry(t *testing.T, rawConfig []byte, opts ...registry.InitOption) *r return reg } +type mockLogger struct { + warnCalls atomic.Int32 +} + +func (l *mockLogger) Warn(_ string, _ map[string]any) { + l.warnCalls.Add(1) +} +func (*mockLogger) Warnf(_ string, _ ...any) {} + func TestNewFromConfigInitializesStoresFromKVSection(t *testing.T) { t.Parallel() @@ -762,3 +771,41 @@ func TestNewFromConfigRejectsInvalidFactories(t *testing.T) { require.ErrorContains(t, err, "factory cannot be nil") }) } + +func TestNewFromConfig_WithLogger_ForwardsSkipWarning(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "mystery": {"type": "does_not_exist", "required": false, "config": {}} + } + } + }`) + + l := &mockLogger{} + + reg, err := registry.NewFromConfig(t.Context(), doc, + registry.WithInitLogger(l), + ) + require.NoError(t, err, "an optional store with an unknown type is skipped, not fatal") + require.NotNil(t, reg) + + require.Positive(t, l.warnCalls.Load()) +} + +func TestNewFromConfig_NoLogger_DoesNotPanic(t *testing.T) { + t.Parallel() + + doc := []byte(`{ + "kv": { + "stores": { + "mystery": {"type": "does_not_exist", "required": false, "config": {}} + } + } + }`) + + reg, err := registry.NewFromConfig(t.Context(), doc) + require.NoError(t, err) + require.NotNil(t, reg) +} diff --git a/kv/registry/registry_test.go b/kv/registry/registry_test.go index 12d8982c..4a46f8f4 100644 --- a/kv/registry/registry_test.go +++ b/kv/registry/registry_test.go @@ -48,11 +48,11 @@ func (m *mockProvider) IsStandalone() bool { } type mockLogger struct { - warnCalls int + warnCalls atomic.Int32 } func (l *mockLogger) Warn(_ string, _ map[string]any) { - l.warnCalls++ + l.warnCalls.Add(1) } func (*mockLogger) Warnf(_ string, _ ...any) {} @@ -384,7 +384,7 @@ func TestInitStores_EdgeCases(t *testing.T) { }, }) require.NoError(t, err) - require.Equal(t, 1, l.warnCalls) + require.Equal(t, int32(1), l.warnCalls.Load()) }) t.Run("should skip secret store wrapping if provider is standalone", func(t *testing.T) { From 4fcfa69bab462f9fc5c6b693d339fa7fe98ea1cc Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Wed, 8 Jul 2026 12:03:11 +0300 Subject: [PATCH 18/31] fix: update error messages on registry with removing redundant store type mentioning --- kv/registry/registry.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kv/registry/registry.go b/kv/registry/registry.go index 5ac1dadf..e0373d59 100644 --- a/kv/registry/registry.go +++ b/kv/registry/registry.go @@ -324,13 +324,13 @@ func buildSingleStore( ) (kv.Provider, error) { provider, err := factory(storeCfg.Config) if err != nil { - return nil, fmt.Errorf("failed to create provider %q (type: %s): %w", name, storeCfg.Type, err) + return nil, fmt.Errorf("failed to create provider: %w", err) } if initializer, ok := kv.AsInitializer(provider); ok { err := initializer.Init(ctx) if err != nil { - return nil, fmt.Errorf("failed to initialize store %q (type: %s): %w", name, storeCfg.Type, err) + return nil, fmt.Errorf("failed to initialize store %q: %w", name, err) } } From b03a9360a9ff49cb17476d8f7b0205fdabb2fff3 Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Wed, 8 Jul 2026 12:06:51 +0300 Subject: [PATCH 19/31] fix: update error message and test msg to match actual behavior without misleading message --- kv/providers/file/errors.go | 2 +- kv/providers/file/file_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/kv/providers/file/errors.go b/kv/providers/file/errors.go index 57bca76b..2c3f782e 100644 --- a/kv/providers/file/errors.go +++ b/kv/providers/file/errors.go @@ -5,7 +5,7 @@ import "errors" var ( ErrBasePathRequired = errors.New("file: base_path required") ErrBasePathNotAbsolute = errors.New("file: base_path must be an absolute path") - ErrAbsoluteRejected = errors.New("file: absolute path rejected when base_path is set") + ErrAbsoluteRejected = errors.New("file: absolute path rejected") ErrTraversal = errors.New("file: path traversal detected") ErrSymlinkEscape = errors.New("file: symlink escapes base_path") ErrEmptyKey = errors.New("file: key must not be empty") diff --git a/kv/providers/file/file_test.go b/kv/providers/file/file_test.go index 5a95c657..efa98736 100644 --- a/kv/providers/file/file_test.go +++ b/kv/providers/file/file_test.go @@ -150,7 +150,7 @@ func TestProviderGet(t *testing.T) { assert.Equal(t, "the-api-key", got) }) - t.Run("rejects an absolute path when base_path is set", func(t *testing.T) { + t.Run("rejects an absolute path", func(t *testing.T) { t.Parallel() dir := t.TempDir() From 0f3e908a3c445fd0235e5d3b687eb3794c4ec56a Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Thu, 9 Jul 2026 12:50:19 +0300 Subject: [PATCH 20/31] fix: add logic to return error when $kv{ is not closed --- kv/internal/resolve/resolver.go | 37 ++++++++++++++++++++++++++++ kv/internal/resolve/resolver_test.go | 22 +++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/kv/internal/resolve/resolver.go b/kv/internal/resolve/resolver.go index a5f8a852..ba5cde92 100644 --- a/kv/internal/resolve/resolver.go +++ b/kv/internal/resolve/resolver.go @@ -83,6 +83,17 @@ func (r *Resolver) Resolve(ctx context.Context, input string) (string, error) { return res, err } + // The token regex requires a closing brace, so an unclosed "$kv{" can + // never match — without this check a typo'd reference would silently pass + // through as a literal value. + if idx := unclosedInlineToken(input); idx >= 0 { + return "", fmt.Errorf( + "%w: unclosed $kv{ reference in %q", + ErrMalformedReference, + input, + ) + } + var resolveErrs []error result := inlineRe.ReplaceAllStringFunc(input, func(match string) string { // strip "$kv{" prefix and "}" suffix @@ -194,6 +205,32 @@ func (r *Resolver) fetchAndExtract(ctx context.Context, storeName, path, fragmen return extractJSONPointer(raw, fragment) } +// unclosedInlineToken returns the index of the first "$kv{" occurrence in +// input that is not the start of a well-formed $kv{...} token, or -1 when +// every occurrence is properly closed. +func unclosedInlineToken(input string) int { + starts := make(map[int]struct{}) + for _, m := range inlineRe.FindAllStringIndex(input, -1) { + starts[m[0]] = struct{}{} + } + + offset := 0 + + for { + i := strings.Index(input[offset:], "$kv{") + if i < 0 { + return -1 + } + + abs := offset + i + if _, ok := starts[abs]; !ok { + return abs + } + + offset = abs + len("$kv{") + } +} + func (r *Resolver) walkAndResolve(ctx context.Context, node any) (any, error) { switch v := node.(type) { case string: diff --git a/kv/internal/resolve/resolver_test.go b/kv/internal/resolve/resolver_test.go index 7a23dfec..f9e29011 100644 --- a/kv/internal/resolve/resolver_test.go +++ b/kv/internal/resolve/resolver_test.go @@ -203,6 +203,28 @@ func TestResolve(t *testing.T) { input: "kv://vault/$kv{env:SUFFIX}", want: "resolved", }, + { + name: "malformed $kv{} unclosed token", + input: "$kv{unclosed", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "malformed $kv{} unclosed token after a valid token", + stores: map[string]kv.Provider{"env": &mockProvider{value: "v"}}, + input: "$kv{env:KEY} and then $kv{oops", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "malformed $kv{} unclosed token inside a larger string", + input: "https://$kv{unclosed/path", + wantErr: resolve.ErrMalformedReference, + }, + { + name: "resolved value containing an unclosed marker is not an error", + stores: map[string]kv.Provider{"vault": &mockProvider{value: "literal-$kv{-inside"}}, + input: "x-$kv{vault:secret}-y", + want: "x-literal-$kv{-inside-y", + }, } for _, tc := range tests { From 1a194809808948c40ec19a1ca60f71c9fc653b5c Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Fri, 10 Jul 2026 11:51:14 +0300 Subject: [PATCH 21/31] test: add benchmarks to the code logic --- kv/internal/resolve/resolver_test.go | 49 ++++++++++++++++++++++++ kv/internal/store/store_test.go | 44 +++++++++++++++++++++ kv/registry/integration_test.go | 57 ++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) diff --git a/kv/internal/resolve/resolver_test.go b/kv/internal/resolve/resolver_test.go index f9e29011..576fa87c 100644 --- a/kv/internal/resolve/resolver_test.go +++ b/kv/internal/resolve/resolver_test.go @@ -3,6 +3,7 @@ package resolve_test import ( "context" "encoding/json" + "fmt" "testing" "github.com/TykTechnologies/storage/kv" @@ -651,3 +652,51 @@ func BenchmarkResolve_ThreeInlineTokensWithFragment(b *testing.B) { } } } + +// BenchmarkResolveAll measures the whole-document resolution cost the caller +// pays at startup as a function of reference count. Providers are +// in-memory mocks, so the numbers isolate the library's own overhead +// — parse, walk, substitute, re-serialize — from backend latency. +func BenchmarkResolveAll(b *testing.B) { + getter := newGetter(map[string]kv.Provider{ + "env": &mockProvider{value: "resolved-value"}, + "vault": &mockProvider{value: `{"username":"admin","password":"hunter2"}`}, + }) + r := resolve.NewResolver(getter) + ctx := context.Background() + + for _, n := range []int{20, 50, 100} { + doc := buildBenchDoc(b, n) + + b.Run(fmt.Sprintf("refs=%d", n), func(b *testing.B) { + for b.Loop() { + if _, err := r.ResolveAll(ctx, doc); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func buildBenchDoc(b *testing.B, n int) []byte { + b.Helper() + + fields := make(map[string]any, n*2) + for i := 0; i < n; i++ { + key := fmt.Sprintf("field_%d", i) + switch i % 3 { + case 0: + fields[key] = "kv://env/SOME_KEY" + case 1: + fields[key] = fmt.Sprintf("https://$kv{env:HOST_%d}/v1", i) + case 2: + fields[key] = "kv://vault/db/creds#password" + } + fields[fmt.Sprintf("plain_%d", i)] = "no reference here" + } + + doc, err := json.Marshal(map[string]any{"config": fields}) + require.NoError(b, err) + + return doc +} diff --git a/kv/internal/store/store_test.go b/kv/internal/store/store_test.go index f33616b0..89ab753d 100644 --- a/kv/internal/store/store_test.go +++ b/kv/internal/store/store_test.go @@ -667,3 +667,47 @@ func newTestStore(t *testing.T, provider kv.Provider, cfg kv.CacheConfig) *Secre return store } + +// BenchmarkSecretStoreGet measures the per-request cost of a Get through the +// SecretStore wrapper — the "no measurable per-request latency when values are +// cache-resident" acceptance criterion. +func BenchmarkSecretStoreGet(b *testing.B) { + ctx := context.Background() + + b.Run("cache-hit", func(b *testing.B) { + s, err := NewSecretStore("bench", &mockProvider{}, kv.CacheConfig{Enabled: true, TTL: "1h"}) + if err != nil { + b.Fatal(err) + } + defer s.Close(ctx) + + // Warm the cache so every measured Get is a hit. + if _, err := s.Get(ctx, "path"); err != nil { + b.Fatal(err) + } + + b.ResetTimer() + + for b.Loop() { + if _, err := s.Get(ctx, "path"); err != nil { + b.Fatal(err) + } + } + }) + + b.Run("cache-disabled", func(b *testing.B) { + s, err := NewSecretStore("bench", &mockProvider{}, kv.CacheConfig{Enabled: false}) + if err != nil { + b.Fatal(err) + } + defer s.Close(ctx) + + b.ResetTimer() + + for b.Loop() { + if _, err := s.Get(ctx, "path"); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/kv/registry/integration_test.go b/kv/registry/integration_test.go index cb4eb60e..1b2a8346 100644 --- a/kv/registry/integration_test.go +++ b/kv/registry/integration_test.go @@ -1,6 +1,7 @@ package registry_test import ( + "context" "encoding/json" "fmt" "net/http" @@ -387,3 +388,59 @@ func TestIntegrationConsulStoreNegativeCachesNotFound(t *testing.T) { assert.Equal(t, int32(1), hits.Load(), "a not-found must be negatively cached (negative_ttl_not_found bucket), not re-fetched") } + +// BenchmarkVaultStoreGet measures a Get against a real vault provider talking +// to a co-located (in-process httptest) KVv2 backend, through the full +// production stack: NewFromConfig registry → SecretStore wrapper → vault API +// client → HTTP. The cache-off run is the true cost of one backend round trip +// (the "co-located provider" startup criterion); the cache-on run is the +// steady-state request path. +func BenchmarkVaultStoreGet(b *testing.B) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"data":{"password":"hunter2"}}}`)) + })) + defer srv.Close() + + ctx := context.Background() + + for _, tc := range []struct { + name string + cacheEnabled bool + }{ + {name: "cache-off", cacheEnabled: false}, + {name: "cache-on", cacheEnabled: true}, + } { + rawConfig := fmt.Sprintf(`{ + "kv": { + "cache": {"enabled": %t, "ttl": "1h"}, + "stores": { + "vault": { + "type": "hashicorp_vault", + "config": {"address": %q, "token": "bench-token", "kv_version": 2} + } + } + } + }`, tc.cacheEnabled, srv.URL) + + reg, err := registry.NewFromConfig(ctx, []byte(rawConfig)) + if err != nil { + b.Fatal(err) + } + + store, err := reg.GetStore("vault") + if err != nil { + b.Fatal(err) + } + + b.Run(tc.name, func(b *testing.B) { + for b.Loop() { + if _, err := store.Get(ctx, "secret/bench"); err != nil { + b.Fatal(err) + } + } + }) + + _ = reg.Close(ctx) + } +} From ab59c39fb42b47330efda834a23096aa40d495bb Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Fri, 10 Jul 2026 12:32:32 +0300 Subject: [PATCH 22/31] docs: add doc comments to the withLogger funcs explaining better why we have two similar funcs on the same package --- kv/internal/resolve/resolver_test.go | 3 +++ kv/registry/from_config.go | 2 ++ kv/registry/integration_test.go | 1 + kv/registry/registry.go | 1 + 4 files changed, 7 insertions(+) diff --git a/kv/internal/resolve/resolver_test.go b/kv/internal/resolve/resolver_test.go index 576fa87c..20bb99b1 100644 --- a/kv/internal/resolve/resolver_test.go +++ b/kv/internal/resolve/resolver_test.go @@ -682,8 +682,10 @@ func buildBenchDoc(b *testing.B, n int) []byte { b.Helper() fields := make(map[string]any, n*2) + for i := 0; i < n; i++ { key := fmt.Sprintf("field_%d", i) + switch i % 3 { case 0: fields[key] = "kv://env/SOME_KEY" @@ -692,6 +694,7 @@ func buildBenchDoc(b *testing.B, n int) []byte { case 2: fields[key] = "kv://vault/db/creds#password" } + fields[fmt.Sprintf("plain_%d", i)] = "no reference here" } diff --git a/kv/registry/from_config.go b/kv/registry/from_config.go index 685b3182..a8f7cec5 100644 --- a/kv/registry/from_config.go +++ b/kv/registry/from_config.go @@ -38,6 +38,8 @@ func WithDefaultStores(s map[string]kv.StoreConfig) InitOption { } } +// WithInitLogger sets the logger that receives warnings emitted during +// registry initialization. func WithInitLogger(l kv.Logger) InitOption { return func(o *initOptions) { o.logger = l diff --git a/kv/registry/integration_test.go b/kv/registry/integration_test.go index 1b2a8346..4ee96e6f 100644 --- a/kv/registry/integration_test.go +++ b/kv/registry/integration_test.go @@ -398,6 +398,7 @@ func TestIntegrationConsulStoreNegativeCachesNotFound(t *testing.T) { func BenchmarkVaultStoreGet(b *testing.B) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") + // nolint:errcheck _, _ = w.Write([]byte(`{"data":{"data":{"password":"hunter2"}}}`)) })) defer srv.Close() diff --git a/kv/registry/registry.go b/kv/registry/registry.go index e0373d59..78068f34 100644 --- a/kv/registry/registry.go +++ b/kv/registry/registry.go @@ -34,6 +34,7 @@ type Registry struct { type Option func(r *Registry) +// WithLogger sets the logger the registry emits warnings. func WithLogger(l kv.Logger) Option { return func(r *Registry) { if l != nil { From e889a780f054c90445b8c08ecb6211dfc876149e Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Fri, 10 Jul 2026 14:34:44 +0300 Subject: [PATCH 23/31] fix: update error messages with using best practices without redundant 'failed' annotation --- kv/internal/store/store.go | 4 ++-- kv/internal/store/store_test.go | 2 +- kv/providers/consul/consul.go | 2 +- kv/providers/vault/vault.go | 4 ++-- kv/registry/from_config.go | 10 +++++----- kv/registry/registry.go | 8 ++++---- kv/registry/registry_test.go | 4 ++-- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/kv/internal/store/store.go b/kv/internal/store/store.go index 23a7c120..e1b9e256 100644 --- a/kv/internal/store/store.go +++ b/kv/internal/store/store.go @@ -155,12 +155,12 @@ func NewSecretStore( opts ...Option, ) (*SecretStore, error) { if provider == nil { - return nil, fmt.Errorf("failed to create a secret store with name %q: provider cannot be nil", name) + return nil, fmt.Errorf("secret store %q: provider cannot be nil", name) } cache, err := cache.NewCache(cacheConfig) if err != nil { - return nil, fmt.Errorf("failed to create secret store: %w", err) + return nil, fmt.Errorf("secret store %q: %w", name, err) } s := &SecretStore{ diff --git a/kv/internal/store/store_test.go b/kv/internal/store/store_test.go index 89ab753d..a86e3945 100644 --- a/kv/internal/store/store_test.go +++ b/kv/internal/store/store_test.go @@ -62,7 +62,7 @@ func TestNewSecretStore(t *testing.T) { }) require.Error(t, err) require.Nil(t, store) - require.Contains(t, err.Error(), "failed to create secret store") + require.Contains(t, err.Error(), `secret store "test"`) }) t.Run("negative TTL", func(t *testing.T) { diff --git a/kv/providers/consul/consul.go b/kv/providers/consul/consul.go index becdb661..a0d26624 100644 --- a/kv/providers/consul/consul.go +++ b/kv/providers/consul/consul.go @@ -114,7 +114,7 @@ func NewFactory() kv.ProviderFactory { client, err := api.NewClient(clientCfg) if err != nil { - return nil, fmt.Errorf("consul: failed to create client: %w", err) + return nil, fmt.Errorf("consul: create client: %w", err) } return &consulProvider{kvClient: client.KV()}, nil diff --git a/kv/providers/vault/vault.go b/kv/providers/vault/vault.go index 26ca9c13..3be81195 100644 --- a/kv/providers/vault/vault.go +++ b/kv/providers/vault/vault.go @@ -133,7 +133,7 @@ func NewFactory() kv.ProviderFactory { client, err := api.NewClient(defaultCfg) if err != nil { - return nil, fmt.Errorf("vault: failed to create a client: %w", err) + return nil, fmt.Errorf("vault: create client: %w", err) } client.SetToken(conf.Token) @@ -210,7 +210,7 @@ func (vp *vaultProvider) Get(ctx context.Context, key string) (string, error) { b, err := json.Marshal(data) if err != nil { - return "", fmt.Errorf("vault: failed to encode secret %q: %w", key, err) + return "", fmt.Errorf("vault: encode secret %q: %w", key, err) } return string(b), nil diff --git a/kv/registry/from_config.go b/kv/registry/from_config.go index a8f7cec5..46adb5cb 100644 --- a/kv/registry/from_config.go +++ b/kv/registry/from_config.go @@ -97,7 +97,7 @@ func NewFromConfig( if len(rawConfig) > 0 { if err := json.Unmarshal(rawConfig, &config); err != nil { - return nil, fmt.Errorf("kv: failed to parse config: %w", err) + return nil, fmt.Errorf("kv: parse config: %w", err) } } @@ -119,7 +119,7 @@ func NewFromConfig( if len(merged) > 0 { err := full.InitStores(ctx, &kv.Config{Stores: merged, Cache: config.KV.Cache}) if err != nil { - return nil, fmt.Errorf("kv: failed to initialize stores: %w", err) + return nil, fmt.Errorf("kv: initialize stores: %w", err) } } @@ -170,7 +170,7 @@ func resolveStoreConfigReferences( if len(locals) > 0 { if err := bootstrap.InitStores(ctx, &kv.Config{Stores: locals}); err != nil { - return fmt.Errorf("kv: failed to initialize local stores for bootstrap: %w", err) + return fmt.Errorf("kv: initialize bootstrap local stores: %w", err) } } @@ -186,7 +186,7 @@ func resolveStoreConfigReferences( resolved, err := lenient.ResolveAll(ctx, storeCfg.Config) if err != nil { - return fmt.Errorf("kv: failed to resolve config of store %q: %w", name, err) + return fmt.Errorf("kv: resolve config of store %q: %w", name, err) } storeCfg.Config = resolved @@ -206,7 +206,7 @@ func newRegistryWithFactories(factories map[kv.ProviderType]kv.ProviderFactory, for providerType, factory := range factories { if err := r.set(providerType, factory); err != nil { - return nil, fmt.Errorf("kv: failed to register factory: %w", err) + return nil, fmt.Errorf("kv: register factory: %w", err) } } diff --git a/kv/registry/registry.go b/kv/registry/registry.go index 78068f34..a45801a3 100644 --- a/kv/registry/registry.go +++ b/kv/registry/registry.go @@ -304,7 +304,7 @@ func (r *Registry) Close(ctx context.Context) error { if closer, ok := kv.AsCloser(store); ok { if err := closer.Close(ctx); err != nil { mu.Lock() - errs = append(errs, fmt.Errorf("failed to close store %q: %w", name, err)) + errs = append(errs, fmt.Errorf("close store %q: %w", name, err)) mu.Unlock() } } @@ -325,13 +325,13 @@ func buildSingleStore( ) (kv.Provider, error) { provider, err := factory(storeCfg.Config) if err != nil { - return nil, fmt.Errorf("failed to create provider: %w", err) + return nil, fmt.Errorf("create provider for %q store: %w", name, err) } if initializer, ok := kv.AsInitializer(provider); ok { err := initializer.Init(ctx) if err != nil { - return nil, fmt.Errorf("failed to initialize store %q: %w", name, err) + return nil, fmt.Errorf("initialize store %q: %w", name, err) } } @@ -351,7 +351,7 @@ func buildSingleStore( store.WithTimeout(timeout), ) if err != nil { - return nil, fmt.Errorf("failed to wrap store %q: %w", name, err) + return nil, err } return ss, nil diff --git a/kv/registry/registry_test.go b/kv/registry/registry_test.go index 4a46f8f4..c252f3cc 100644 --- a/kv/registry/registry_test.go +++ b/kv/registry/registry_test.go @@ -332,7 +332,7 @@ func TestInitStores_EdgeCases(t *testing.T) { }, }) require.Error(t, err) - require.Contains(t, err.Error(), "failed to initialize store") + require.Contains(t, err.Error(), "initialize store") if validInitialized { require.True( @@ -363,7 +363,7 @@ func TestInitStores_EdgeCases(t *testing.T) { }, }) require.Error(t, err) - require.Contains(t, err.Error(), "failed to wrap store") + require.Contains(t, err.Error(), `secret store "valid-1"`) require.False(t, r.isInitialized.Load()) }) From 21ec7b1a536c3b4ffdf893fe02efab7d8a4ac999 Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Fri, 24 Jul 2026 09:31:51 +0300 Subject: [PATCH 24/31] feat: add default operation timeout so other providers could use it --- kv/internal/store/store.go | 4 +--- kv/internal/store/store_test.go | 2 +- kv/provider.go | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/kv/internal/store/store.go b/kv/internal/store/store.go index e1b9e256..649622cf 100644 --- a/kv/internal/store/store.go +++ b/kv/internal/store/store.go @@ -12,8 +12,6 @@ import ( "golang.org/x/sync/singleflight" ) -const defaultProviderTimeout = 5 * time.Second - // SecretStore is an internal decorator that adds caching and singleflight to a Provider. type SecretStore struct { name string @@ -169,7 +167,7 @@ func NewSecretStore( cache: cache, sf: &singleflight.Group{}, sfRefresh: &singleflight.Group{}, - timeout: defaultProviderTimeout, + timeout: kv.DefaultOperationTimeout, } for _, opt := range opts { diff --git a/kv/internal/store/store_test.go b/kv/internal/store/store_test.go index a86e3945..b9dbd20a 100644 --- a/kv/internal/store/store_test.go +++ b/kv/internal/store/store_test.go @@ -82,7 +82,7 @@ func TestNewSecretStore(t *testing.T) { }) require.NoError(t, err) require.NotNil(t, store) - require.Equal(t, defaultProviderTimeout, store.timeout) + require.Equal(t, kv.DefaultOperationTimeout, store.timeout) }) t.Run("cache disabled", func(t *testing.T) { diff --git a/kv/provider.go b/kv/provider.go index 8fe09c83..e51aa669 100644 --- a/kv/provider.go +++ b/kv/provider.go @@ -42,6 +42,10 @@ const ( Conjur ProviderType = "cyberark_conjur" ) +// DefaultOperationTimeout bounds a single provider Get/Set when neither the store +// config nor the SecretStore wrapper supplies one. +const DefaultOperationTimeout = 5 * time.Second + // IsLocal reports whether this provider type resolves secrets from resources // available to the local process — environment variables, inline config data, // or the filesystem — requiring no network and a literal, reference-free config. @@ -179,3 +183,13 @@ func As[T any](p Provider) (T, bool) { return zero, false } + +// EffectiveTimeout resolves a configured timeout to the value actually used: +// the configured value when positive, else the default. +func EffectiveTimeout(configured time.Duration) time.Duration { + if configured > 0 { + return configured + } + + return DefaultOperationTimeout +} From b0873ccb2ba25707e98cc45963d0e4b54eaf7e51 Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Mon, 27 Jul 2026 12:33:03 +0300 Subject: [PATCH 25/31] feat: add allow no prefix config field to the env provider and cover it with unit tests --- kv/providers/env/env.go | 53 ++++++++++++++++++++++-------------- kv/providers/env/env_test.go | 50 +++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 22 deletions(-) diff --git a/kv/providers/env/env.go b/kv/providers/env/env.go index decc1990..20cc879e 100644 --- a/kv/providers/env/env.go +++ b/kv/providers/env/env.go @@ -5,12 +5,14 @@ // A lookup name is built as Prefix + key, with the key optionally uppercased // (see Config). // -// Security: the Prefix is a mandatory confinement boundary — the env analogue of -// the file provider's base_path. Every key is read as Prefix+key, so a reference -// can only ever reach variables under that prefix (there is no traversal escape -// for environment names). A store with no prefix would let any reference read any -// process variable (cloud credentials, tokens, PATH, …), so an unprefixed store -// is disabled: every Get returns ErrPrefixRequired and resolves nothing. +// Security: the Prefix is a confinement boundary. Every key is read as +// Prefix+key, so a reference can only reach variables the operator deliberately +// placed under that prefix — never arbitrary process variables (cloud +// credentials, tokens, PATH, …). This matters when references come from a +// less-trusted source than the host. An empty prefix removes that boundary, +// so it is rejected by default: every Get returns ErrPrefixRequired. +// Setting AllowNoPrefix opts back into reading process env directly, +// and is only safe when every reference source is as trusted as the host. package env import ( @@ -24,21 +26,27 @@ import ( "github.com/TykTechnologies/storage/kv" ) -// ErrPrefixRequired is returned by Get when the provider has no prefix -// configured. +// ErrPrefixRequired is returned by Get when the provider has an empty prefix and +// AllowNoPrefix is not set. var ErrPrefixRequired = errors.New("env: prefix is required") // Config is the env provider's configuration. type Config struct { // Prefix is prepended literally to the (optionally uppercased) key before the - // environment lookup; it is never uppercased itself. It is mandatory: it is - // the store's security boundary, so a provider with an empty prefix rejects - // every Get with ErrPrefixRequired (see the package doc). + // environment lookup; it is never uppercased itself. By default it is + // required: it is the store's security boundary, so a provider with an empty + // prefix rejects every Get with ErrPrefixRequired unless AllowNoPrefix is set. Prefix string `json:"prefix"` // Uppercase, when true, uppercases the key — not the prefix — before lookup, // so Get("my_key") with prefix "TYK_SECRET_" reads "TYK_SECRET_MY_KEY". Uppercase bool `json:"uppercase"` + + // AllowNoPrefix permits an empty prefix, reading process environment + // variables directly with no confinement. It defaults to false so a forgotten + // prefix stays fail-closed rather than silently exposing every process + // variable. + AllowNoPrefix bool `json:"allow_no_prefix"` } // NewFactory returns a ProviderFactory for environment-backed stores. @@ -58,27 +66,30 @@ func NewFactory() kv.ProviderFactory { } return &envProvider{ - prefix: cfg.Prefix, - uppercase: cfg.Uppercase, + prefix: cfg.Prefix, + uppercase: cfg.Uppercase, + allowNoPrefix: cfg.AllowNoPrefix, }, nil } } type envProvider struct { - prefix string - uppercase bool + prefix string + uppercase bool + allowNoPrefix bool } // Get reads the environment variable named Prefix + (uppercased key if // Uppercase) and returns its value. // -// If no prefix is configured it returns ErrPrefixRequired for every key — the -// prefix guard is checked first, so even an empty key is rejected before any -// lookup. With a prefix set, the result mirrors os.Getenv exactly: a missing -// variable and a variable set to "" are indistinguishable, and both return -// ("", nil); an empty key reads os.Getenv(Prefix) and likewise returns no error. +// If the prefix is empty and AllowNoPrefix is not set it returns +// ErrPrefixRequired for every key — the prefix guard is checked first, so even +// an empty key is rejected before any lookup. Otherwise the result mirrors +// os.Getenv exactly: a missing variable and a variable set to "" are +// indistinguishable, and both return ("", nil); an empty key reads +// os.Getenv(Prefix) and likewise returns no error. func (ep *envProvider) Get(_ context.Context, key string) (string, error) { - if ep.prefix == "" { + if ep.prefix == "" && !ep.allowNoPrefix { return "", ErrPrefixRequired } diff --git a/kv/providers/env/env_test.go b/kv/providers/env/env_test.go index 2b99a676..09bb2af9 100644 --- a/kv/providers/env/env_test.go +++ b/kv/providers/env/env_test.go @@ -114,9 +114,10 @@ func TestProviderGet(t *testing.T) { assert.Equal(t, "", got, "no case folding: lowercase key must not match TYK_SECRET_FOO") }) - t.Run("empty prefix is rejected: every key errors with ErrPrefixRequired", func(t *testing.T) { + t.Run("empty prefix is rejected by default: every key errors with ErrPrefixRequired", func(t *testing.T) { t.Setenv("BARE_KEY", "bare-value") + // AllowNoPrefix defaults to false — the fail-closed guard. p := newProvider(t, env.Config{Prefix: "", Uppercase: true}) for _, key := range []string{"bare_key", "BARE_KEY", ""} { @@ -125,6 +126,53 @@ func TestProviderGet(t *testing.T) { } }) + t.Run("allow_no_prefix reads raw process env when the prefix is empty", func(t *testing.T) { + t.Setenv("BARE_KEY", "bare-value") + + got, err := newProvider(t, env.Config{Prefix: "", Uppercase: true, AllowNoPrefix: true}). + Get(t.Context(), "bare_key") + require.NoError(t, err) + assert.Equal(t, "bare-value", got) + }) + + t.Run("allow_no_prefix with uppercase false uses the key verbatim", func(t *testing.T) { + t.Setenv("raw_lower", "x") + + got, err := newProvider(t, env.Config{AllowNoPrefix: true}). + Get(t.Context(), "raw_lower") + require.NoError(t, err) + assert.Equal(t, "x", got) + }) + + t.Run("allow_no_prefix still returns empty string and no error for a missing var", func(t *testing.T) { + got, err := newProvider(t, env.Config{AllowNoPrefix: true, Uppercase: true}). + Get(t.Context(), "definitely_not_set_var") + require.NoError(t, err) + assert.Equal(t, "", got) + }) + + t.Run("allow_no_prefix is inert when a prefix is set: the prefix still confines", func(t *testing.T) { + t.Setenv("TYK_SECRET_K", "confined") + t.Setenv("K", "bare") + + got, err := newProvider(t, env.Config{Prefix: "TYK_SECRET_", Uppercase: true, AllowNoPrefix: true}). + Get(t.Context(), "k") + require.NoError(t, err) + assert.Equal(t, "confined", got, + "AllowNoPrefix must not disable a configured prefix") + }) + + t.Run("allow_no_prefix json tag is wired to the raw config", func(t *testing.T) { + t.Setenv("RAW_TAG_KEY", "ok") + + p, err := env.NewFactory()(json.RawMessage(`{"allow_no_prefix":true,"uppercase":true}`)) + require.NoError(t, err) + + got, err := p.Get(t.Context(), "raw_tag_key") + require.NoError(t, err) + assert.Equal(t, "ok", got) + }) + t.Run("prefix is literal: uppercase never applies to the prefix", func(t *testing.T) { t.Setenv("tyk_K", "lit") From 1ea7c5f9806cfa0ee8b496d8232155e66507b19e Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Mon, 27 Jul 2026 16:13:13 +0300 Subject: [PATCH 26/31] feat: update the logger interface to avoid breaking change in future --- kv/logger.go | 8 +++++--- kv/registry/registry_test.go | 13 +++++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/kv/logger.go b/kv/logger.go index f7f20cad..c513e155 100644 --- a/kv/logger.go +++ b/kv/logger.go @@ -1,11 +1,13 @@ package kv type Logger interface { + Debug(msg string, fields map[string]any) Warn(msg string, fields map[string]any) - Warnf(format string, args ...any) + Error(msg string, fields map[string]any) } type NoopLogger struct{} -func (NoopLogger) Warn(_ string, _ map[string]any) {} -func (NoopLogger) Warnf(_ string, _ ...any) {} +func (NoopLogger) Debug(_ string, _ map[string]any) {} +func (NoopLogger) Warn(_ string, _ map[string]any) {} +func (NoopLogger) Error(_ string, _ map[string]any) {} diff --git a/kv/registry/registry_test.go b/kv/registry/registry_test.go index 1d5928bf..3fc7a8fe 100644 --- a/kv/registry/registry_test.go +++ b/kv/registry/registry_test.go @@ -48,13 +48,22 @@ func (m *mockProvider) IsStandalone() bool { } type mockLogger struct { - warnCalls int + debugCalls int + warnCalls int + errorCalls int +} + +func (l *mockLogger) Debug(_ string, _ map[string]any) { + l.debugCalls++ } func (l *mockLogger) Warn(_ string, _ map[string]any) { l.warnCalls++ } -func (*mockLogger) Warnf(_ string, _ ...any) {} + +func (l *mockLogger) Error(_ string, _ map[string]any) { + l.errorCalls++ +} func newFactory(initFunc, closeFunc func(ctx context.Context) error) kv.ProviderFactory { return func(config json.RawMessage) (kv.Provider, error) { From 584253755a6525633e082f84deba4ebc52c1bc84 Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Mon, 27 Jul 2026 16:19:51 +0300 Subject: [PATCH 27/31] fix: address sonarqube issues --- kv/logger.go | 17 ++++++++++++++--- kv/provider.go | 10 +++++----- kv/registry/registry.go | 9 +-------- kv/registry/registry_test.go | 2 -- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/kv/logger.go b/kv/logger.go index c513e155..a41772bf 100644 --- a/kv/logger.go +++ b/kv/logger.go @@ -8,6 +8,17 @@ type Logger interface { type NoopLogger struct{} -func (NoopLogger) Debug(_ string, _ map[string]any) {} -func (NoopLogger) Warn(_ string, _ map[string]any) {} -func (NoopLogger) Error(_ string, _ map[string]any) {} +func (NoopLogger) Debug(_ string, _ map[string]any) { + // Intentionally empty: NoopLogger discards all logs so the library + // works without a caller-provided logger. +} + +func (NoopLogger) Warn(_ string, _ map[string]any) { + // Intentionally empty: NoopLogger discards all logs so the library + // works without a caller-provided logger. +} + +func (NoopLogger) Error(_ string, _ map[string]any) { + // Intentionally empty: NoopLogger discards all logs so the library + // works without a caller-provided logger. +} diff --git a/kv/provider.go b/kv/provider.go index 0b80ce8a..b7d94e10 100644 --- a/kv/provider.go +++ b/kv/provider.go @@ -90,9 +90,9 @@ type Closer interface { Close(ctx context.Context) error } -// Standalone is an optional interface for providers that do not need +// Standaloner is an optional interface for providers that do not need // to be combined with caching or singleflight mechanisms. -type Standalone interface { +type Standaloner interface { IsStandalone() bool } @@ -120,9 +120,9 @@ func AsCloser(p Provider) (Closer, bool) { return As[Closer](p) } -// AsStandalone attempts to extract a Standalone from a Provider. -func AsStandalone(p Provider) (Standalone, bool) { - return As[Standalone](p) +// AsStandaloner attempts to extract a Standaloner from a Provider. +func AsStandaloner(p Provider) (Standaloner, bool) { + return As[Standaloner](p) } // AsTimeouter attempts to extract a Timeouter from a Provider. diff --git a/kv/registry/registry.go b/kv/registry/registry.go index 1490aaf0..8778aac2 100644 --- a/kv/registry/registry.go +++ b/kv/registry/registry.go @@ -56,13 +56,6 @@ func NewRegistry(opts ...Option) *Registry { func NewDefaultRegistry(opts ...Option) *Registry { r := NewRegistry(opts...) - // TODO: Uncomment provider registration when implementation is added - // r.Add(kv.Env, env.NewFactory()) - // r.Add(kv.Inline, inline.NewFactory()) - // r.Add(kv.Vault, vault.NewFactory()) - // r.Add(kv.Consul, consul.NewFactory()) - // r.Add(kv.K8s, k8s.NewFactory()) - return r } @@ -283,7 +276,7 @@ func buildSingleStore( } } - if s, ok := kv.AsStandalone(provider); ok && s.IsStandalone() { + if s, ok := kv.AsStandaloner(provider); ok && s.IsStandalone() { return provider, nil } diff --git a/kv/registry/registry_test.go b/kv/registry/registry_test.go index 3fc7a8fe..4bf26289 100644 --- a/kv/registry/registry_test.go +++ b/kv/registry/registry_test.go @@ -83,8 +83,6 @@ func TestNewRegistry(t *testing.T) { require.NotNil(t, registry.factories) } -// TODO: Update the test case when providers are set, to assert -// that all OSS providers are registered. func TestNewDefaultRegistry(t *testing.T) {} func TestAddFactory(t *testing.T) { From b8db2de7f4025c45cf0058cdfc89fe96fdeb2117 Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Mon, 27 Jul 2026 16:27:20 +0300 Subject: [PATCH 28/31] refactor: decrease cognitive complexity of init stores func --- kv/registry/registry.go | 118 +++++++++++++++++++++++++--------------- 1 file changed, 74 insertions(+), 44 deletions(-) diff --git a/kv/registry/registry.go b/kv/registry/registry.go index 8778aac2..d9814bc1 100644 --- a/kv/registry/registry.go +++ b/kv/registry/registry.go @@ -129,74 +129,92 @@ func (r *Registry) InitStores(ctx context.Context, config *kv.Config) (err error var tempMu sync.Mutex tempStores := make(map[string]kv.Provider, len(config.Stores)) + collect := func(name string, store kv.Provider) { + tempMu.Lock() + tempStores[name] = store + tempMu.Unlock() + } + // This defer block guarantees cleanup of partially initialized stores if the // overall initialization process fails, preventing resource leaks. defer func() { if err != nil { r.isInitialized.Store(false) - cleanupCtx := context.WithoutCancel(ctx) - tempMu.Lock() - defer tempMu.Unlock() - - for _, store := range tempStores { - if closer, ok := kv.AsCloser(store); ok { - _ = closer.Close(cleanupCtx) - } - } + closeStores(ctx, tempStores) + tempMu.Unlock() } }() eg, egCtx := errgroup.WithContext(ctx) for name, storeCfg := range config.Stores { - r.mu.RLock() - factory, ok := r.factories[storeCfg.Type] - r.mu.RUnlock() - - if !ok { - initErr := fmt.Errorf("unknown provider type %q for store %q", storeCfg.Type, name) - if storeCfg.Required { - return initErr - } + if scheduleErr := r.scheduleStoreInit(egCtx, eg, name, storeCfg, config.Cache, collect); scheduleErr != nil { + return scheduleErr + } + } - r.logger.Warn("Skipping optional store initialization", map[string]any{ - "store": name, - "error": initErr, - }) + if err = eg.Wait(); err != nil { + return err + } - continue - } + return r.commitStores(tempStores) +} - eg.Go(func() error { - store, initErr := buildSingleStore(egCtx, name, storeCfg, config.Cache, factory) - if initErr != nil { - if storeCfg.Required { - return initErr - } +// scheduleStoreInit looks up the factory for a single store and, when found, +// schedules its (potentially blocking) initialization on the errgroup. It +// returns a non-nil error only when a required store cannot be scheduled; +// optional-store failures are logged and swallowed. +func (r *Registry) scheduleStoreInit( + ctx context.Context, + eg *errgroup.Group, + name string, + storeCfg kv.StoreConfig, + cacheCfg kv.CacheConfig, + collect func(name string, store kv.Provider), +) error { + r.mu.RLock() + factory, ok := r.factories[storeCfg.Type] + r.mu.RUnlock() - r.logger.Warn("Skipping optional store initialization", map[string]any{ - "store": name, - "error": initErr, - }) + if !ok { + return r.handleStoreInitError(name, storeCfg.Required, + fmt.Errorf("unknown provider type %q for store %q", storeCfg.Type, name)) + } - return nil - } + eg.Go(func() error { + store, initErr := buildSingleStore(ctx, name, storeCfg, cacheCfg, factory) + if initErr != nil { + return r.handleStoreInitError(name, storeCfg.Required, initErr) + } - tempMu.Lock() - tempStores[name] = store - tempMu.Unlock() + collect(name, store) - return nil - }) - } + return nil + }) - err = eg.Wait() - if err != nil { + return nil +} + +// handleStoreInitError propagates initialization errors for required stores and +// logs-and-swallows them for optional ones. +func (r *Registry) handleStoreInitError(name string, required bool, err error) error { + if required { return err } + r.logger.Warn("Skipping optional store initialization", map[string]any{ + "store": name, + "error": err, + }) + + return nil +} + +// commitStores publishes the successfully initialized stores into the registry, +// unless the registry was closed while initialization was in flight. +func (r *Registry) commitStores(tempStores map[string]kv.Provider) error { r.mu.Lock() defer r.mu.Unlock() @@ -212,6 +230,18 @@ func (r *Registry) InitStores(ctx context.Context, config *kv.Config) (err error return nil } +// closeStores closes any store that implements Closer, using a cancellation-free +// context so cleanup runs even when the original context is already done. +func closeStores(ctx context.Context, stores map[string]kv.Provider) { + cleanupCtx := context.WithoutCancel(ctx) + + for _, store := range stores { + if closer, ok := kv.AsCloser(store); ok { + _ = closer.Close(cleanupCtx) + } + } +} + // GetStore retrieves an initialized store by name. // Returns ErrStoreNotFound if no store with the given name was initialized. func (r *Registry) GetStore(name string) (kv.Provider, error) { From a08fe4feb59f8d4d9e7b69ed9265ce9cf483ba19 Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Mon, 27 Jul 2026 16:46:38 +0300 Subject: [PATCH 29/31] deps: add vault and consul sdk dependencies --- go.mod | 32 ++++++++++-- go.sum | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index c323d464..7de72339 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,14 @@ go 1.25.0 require ( github.com/google/go-cmp v0.7.0 + github.com/hashicorp/consul/api v1.31.2 + github.com/hashicorp/vault/api v1.23.0 github.com/lib/pq v1.10.9 github.com/redis/go-redis/v9 v9.11.0 github.com/stretchr/testify v1.11.1 go.mongodb.org/mongo-driver v1.17.7 golang.org/x/oauth2 v0.36.0 + golang.org/x/sync v0.21.0 golang.org/x/text v0.38.0 google.golang.org/api v0.287.1 gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 @@ -30,19 +33,41 @@ require ( cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/armon/go-metrics v0.4.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/serf v0.10.1 // indirect github.com/klauspost/compress v1.16.7 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/montanaflynn/stats v0.7.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect @@ -54,9 +79,10 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/crypto v0.53.0 // indirect + golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect + golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect google.golang.org/grpc v1.82.0 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index a86c84ab..306a03fb 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,7 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/TykTechnologies/gorm v1.20.7-0.20260417144542-d6d1a2e12d9d h1:wePfwBWzjkC+lc/jEQsvcbQ3Rt0u+7MIj3jXWPnU6Qo= github.com/TykTechnologies/gorm v1.20.7-0.20260417144542-d6d1a2e12d9d/go.mod h1:hz0d/E0QBTYarOnYtdcNnBWN/NYxVMP7nZNDT6E/fFM= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -24,8 +25,13 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -33,19 +39,43 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -54,6 +84,60 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/hashicorp/consul/api v1.31.2 h1:NicObVJHcCmyOIl7Z9iHPvvFrocgTYo9cITSGg0/7pw= +github.com/hashicorp/consul/api v1.31.2/go.mod h1:Z8YgY0eVPukT/17ejW+l+C7zJmKwgPHtjU1q16v/Y40= +github.com/hashicorp/consul/sdk v0.16.1 h1:V8TxTnImoPD5cj0U9Spl0TUxcytjcbbJeADFF07KdHg= +github.com/hashicorp/consul/sdk v0.16.1/go.mod h1:fSXvwxB2hmh1FMZCNl6PwX0Q/1wdWtHJcZ7Ea5tns0s= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= +github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= +github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM= +github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= +github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= +github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= +github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= +github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= github.com/helloeave/json v1.15.3 h1:roUxUEGhsSvhuhi80c4qmLiW633d5uf0mkzUGzBMfX8= github.com/helloeave/json v1.15.3/go.mod h1:uTHhuUsgnrpm9cc7Gi3tfIUwgf1dq/7+uLfpUFLBFEQ= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -72,8 +156,13 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -85,10 +174,60 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= @@ -145,12 +284,15 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -158,12 +300,15 @@ golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= 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.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -171,6 +316,9 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -181,6 +329,8 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -189,6 +339,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -197,6 +348,7 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= @@ -209,6 +361,7 @@ golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.287.1 h1:LiyJx32VU3cwQfLchn/513qKhc25hq0pEANYJoWNnnI= @@ -223,6 +376,7 @@ google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From d3b24be8028f38c9c4c01cf035695208b27ac378 Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Mon, 27 Jul 2026 17:33:23 +0300 Subject: [PATCH 30/31] refactor: decrease cognitive complexity of resolve func --- kv/internal/resolve/resolver.go | 129 +++++++++++++++++--------------- 1 file changed, 69 insertions(+), 60 deletions(-) diff --git a/kv/internal/resolve/resolver.go b/kv/internal/resolve/resolver.go index ba5cde92..489eeaf4 100644 --- a/kv/internal/resolve/resolver.go +++ b/kv/internal/resolve/resolver.go @@ -52,37 +52,32 @@ var inlineRe = regexp.MustCompile(`\$kv\{([^}]+)\}`) func (r *Resolver) Resolve(ctx context.Context, input string) (string, error) { if strings.HasPrefix(input, "kv://") { - trimmed := strings.TrimPrefix(input, "kv://") - - slashIdx := strings.IndexByte(trimmed, '/') - if slashIdx < 0 { - return "", fmt.Errorf( - "%w: missing path separator in %q", - ErrMalformedReference, - input, - ) - } + return r.resolveURI(ctx, input) + } - storeName := trimmed[:slashIdx] - rest := trimmed[slashIdx+1:] - path, fragment, _ := strings.Cut(rest, "#") + return r.resolveInline(ctx, input) +} - if storeName == "" || path == "" { - return "", fmt.Errorf( - "%w: empty store name or path in %q", - ErrMalformedReference, - input, - ) - } +// resolveURI resolves a whole-value "kv://store/path#fragment" reference. +func (r *Resolver) resolveURI(ctx context.Context, input string) (string, error) { + body := strings.TrimPrefix(input, "kv://") - res, err := r.fetchAndExtract(ctx, storeName, path, fragment) - if r.lenient && errors.Is(err, kv.ErrStoreNotFound) { - return input, nil - } + storeName, path, fragment, err := parseReference(body, '/', input) + if err != nil { + return "", err + } - return res, err + res, err := r.fetchAndExtract(ctx, storeName, path, fragment) + if r.lenient && errors.Is(err, kv.ErrStoreNotFound) { + return input, nil } + return res, err +} + +// resolveInline resolves every embedded "$kv{store:path#fragment}" token in a +// larger string, accumulating errors so a single call reports all failures. +func (r *Resolver) resolveInline(ctx context.Context, input string) (string, error) { // The token regex requires a closing brace, so an unclosed "$kv{" can // never match — without this check a typo'd reference would silently pass // through as a literal value. @@ -95,44 +90,11 @@ func (r *Resolver) Resolve(ctx context.Context, input string) (string, error) { } var resolveErrs []error - result := inlineRe.ReplaceAllStringFunc(input, func(match string) string { - // strip "$kv{" prefix and "}" suffix - inner := match[4 : len(match)-1] - - colonIdx := strings.IndexByte(inner, ':') - if colonIdx < 0 { - resolveErrs = append(resolveErrs, fmt.Errorf( - "%w: missing store separator in %q", - ErrMalformedReference, - match, - )) - - return match - } - - storeName := inner[:colonIdx] - rest := inner[colonIdx+1:] - path, fragment, _ := strings.Cut(rest, "#") - - if storeName == "" || path == "" { - resolveErrs = append(resolveErrs, fmt.Errorf( - "%w: empty store name or path in %q", - ErrMalformedReference, - match, - )) - - return match - } - val, err := r.fetchAndExtract(ctx, storeName, path, fragment) + result := inlineRe.ReplaceAllStringFunc(input, func(match string) string { + val, err := r.resolveInlineToken(ctx, match) if err != nil { - if r.lenient && errors.Is(err, kv.ErrStoreNotFound) { - return match - } - resolveErrs = append(resolveErrs, err) - - return match } return val @@ -145,6 +107,53 @@ func (r *Resolver) Resolve(ctx context.Context, input string) (string, error) { return result, nil } +func (r *Resolver) resolveInlineToken(ctx context.Context, match string) (string, error) { + // strip "$kv{" prefix and "}" suffix + inner := match[4 : len(match)-1] + + storeName, path, fragment, err := parseReference(inner, ':', match) + if err != nil { + return match, err + } + + val, err := r.fetchAndExtract(ctx, storeName, path, fragment) + if err != nil { + if r.lenient && errors.Is(err, kv.ErrStoreNotFound) { + return match, nil + } + + return match, err + } + + return val, nil +} + +// parseReference splits "storepath#fragment" into its parts. raw is the +// original reference text, used only for error messages. +func parseReference(body string, sep byte, raw string) (storeName, path, fragment string, err error) { + sepIdx := strings.IndexByte(body, sep) + if sepIdx < 0 { + return "", "", "", fmt.Errorf( + "%w: missing store/path separator in %q", + ErrMalformedReference, + raw, + ) + } + + storeName = body[:sepIdx] + path, fragment, _ = strings.Cut(body[sepIdx+1:], "#") + + if storeName == "" || path == "" { + return "", "", "", fmt.Errorf( + "%w: empty store name or path in %q", + ErrMalformedReference, + raw, + ) + } + + return storeName, path, fragment, nil +} + func (r *Resolver) ResolveAll(ctx context.Context, rawJSON []byte) ([]byte, error) { // Fast path: skip unmarshal/remarshal entirely when no KV syntax is present, // preserving the original bytes and avoiding unnecessary allocations. From a0418c236f961f6d62bbdfd31f567eb5fc0b3886 Mon Sep 17 00:00:00 2001 From: Vlad Zabolotnyi Date: Mon, 27 Jul 2026 18:00:57 +0300 Subject: [PATCH 31/31] refactor: decrease cognitive complexity on vault and consul and place factories in separate file to increase readability --- kv/internal/resolve/resolver.go | 2 +- kv/providers/consul/config.go | 168 ++++++++++++++++++++++++++++++++ kv/providers/consul/consul.go | 141 +-------------------------- kv/providers/vault/config.go | 163 +++++++++++++++++++++++++++++++ kv/providers/vault/vault.go | 136 +------------------------- 5 files changed, 337 insertions(+), 273 deletions(-) create mode 100644 kv/providers/consul/config.go create mode 100644 kv/providers/vault/config.go diff --git a/kv/internal/resolve/resolver.go b/kv/internal/resolve/resolver.go index 489eeaf4..2b4a8a00 100644 --- a/kv/internal/resolve/resolver.go +++ b/kv/internal/resolve/resolver.go @@ -81,7 +81,7 @@ func (r *Resolver) resolveInline(ctx context.Context, input string) (string, err // The token regex requires a closing brace, so an unclosed "$kv{" can // never match — without this check a typo'd reference would silently pass // through as a literal value. - if idx := unclosedInlineToken(input); idx >= 0 { + if unclosedInlineToken(input) >= 0 { return "", fmt.Errorf( "%w: unclosed $kv{ reference in %q", ErrMalformedReference, diff --git a/kv/providers/consul/config.go b/kv/providers/consul/config.go new file mode 100644 index 00000000..323e805d --- /dev/null +++ b/kv/providers/consul/config.go @@ -0,0 +1,168 @@ +package consul + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/TykTechnologies/storage/kv" + consulsdk "github.com/hashicorp/consul/api" +) + +// Config is the JSON "config" block of a consul store. +type Config struct { + // Address of the consul agent as host:port; the scheme comes from Scheme. + Address string `json:"address"` + + // Scheme is the URI scheme ("http" or "https"). Defaults to "http". + Scheme string `json:"scheme"` + + // Datacenter to query. Empty uses the agent's default datacenter. + Datacenter string `json:"datacenter"` + + // HttpAuth holds optional HTTP Basic Auth credentials. + HttpAuth struct { + Username string `json:"username"` + Password string `json:"password"` + } `json:"http_auth"` + + // WaitTime is consul's blocking-query (watch) timeout, as a Go duration + // string such as "5s". It is NOT a per-operation timeout — our plain reads + // never block on it — which is why the provider does not implement Timeouter. + // Empty or "0s" leaves the agent default. + WaitTime string `json:"wait_time"` + + // Token is used to provide a per-request ACL token + // which overrides the agent's default token. + Token string `json:"token"` + + // TLSConfig configures TLS for https connections. + TLSConfig struct { + Address string `json:"address"` + CAFile string `json:"ca_file"` + CAPath string `json:"ca_path"` + CertFile string `json:"cert_file"` + KeyFile string `json:"key_file"` + InsecureSkipVerify bool `json:"insecure_skip_verify"` + } `json:"tls_config"` +} + +// NewFactory returns a kv.ProviderFactory for HashiCorp Consul stores. +// +// The factory parses Config and builds a consul API client, but performs no +// network I/O. It errors only for config that is present but unusable: malformed +// JSON, an unparseable wait_time, or TLS settings the client rejects (e.g. an +// unreadable ca_file). Absent config is valid — an empty blob builds a client +// against consul's default local agent. +func NewFactory() kv.ProviderFactory { + return func(raw json.RawMessage) (kv.Provider, error) { + var conf Config + if err := parseConfig(raw, &conf); err != nil { + return nil, err + } + + clientCfg, err := conf.clientConfig() + if err != nil { + return nil, err + } + + client, err := consulsdk.NewClient(clientCfg) + if err != nil { + return nil, fmt.Errorf("consul: create client: %w", err) + } + + return &consulProvider{kvClient: client.KV()}, nil + } +} + +func parseConfig(raw json.RawMessage, conf *Config) error { + if len(raw) == 0 { + return nil + } + + if err := json.Unmarshal(raw, conf); err != nil { + return fmt.Errorf("consul: invalid config: %w", err) + } + + return nil +} + +func (conf *Config) clientConfig() (*consulsdk.Config, error) { + clientCfg := consulsdk.DefaultConfig() + + if conf.Address != "" { + clientCfg.Address = conf.Address + } + + if conf.Scheme != "" { + clientCfg.Scheme = conf.Scheme + } + + if conf.Datacenter != "" { + clientCfg.Datacenter = conf.Datacenter + } + + if conf.Token != "" { + clientCfg.Token = conf.Token + } + + if conf.HttpAuth.Username != "" || conf.HttpAuth.Password != "" { + clientCfg.HttpAuth = &consulsdk.HttpBasicAuth{ + Username: conf.HttpAuth.Username, + Password: conf.HttpAuth.Password, + } + } + + if err := conf.applyWaitTime(clientCfg); err != nil { + return nil, err + } + + conf.applyTLSConfig(clientCfg) + + return clientCfg, nil +} + +func (conf *Config) applyWaitTime(clientCfg *consulsdk.Config) error { + if conf.WaitTime == "" { + return nil + } + + waitTime, err := time.ParseDuration(conf.WaitTime) + if err != nil { + return fmt.Errorf("consul: invalid wait_time %q: %w", conf.WaitTime, err) + } + + if waitTime > 0 { + clientCfg.WaitTime = waitTime + } + + return nil +} + +func (conf *Config) applyTLSConfig(clientCfg *consulsdk.Config) { + tls := conf.TLSConfig + + if tls.Address != "" { + clientCfg.TLSConfig.Address = tls.Address + } + + if tls.CAFile != "" { + clientCfg.TLSConfig.CAFile = tls.CAFile + } + + if tls.CAPath != "" { + clientCfg.TLSConfig.CAPath = tls.CAPath + } + + if tls.CertFile != "" { + clientCfg.TLSConfig.CertFile = tls.CertFile + } + + if tls.KeyFile != "" { + clientCfg.TLSConfig.KeyFile = tls.KeyFile + } + + if tls.InsecureSkipVerify { + clientCfg.TLSConfig.InsecureSkipVerify = true + } +} diff --git a/kv/providers/consul/consul.go b/kv/providers/consul/consul.go index a0d26624..6418900d 100644 --- a/kv/providers/consul/consul.go +++ b/kv/providers/consul/consul.go @@ -9,151 +9,16 @@ package consul import ( "context" - "encoding/json" - "fmt" - "time" "github.com/TykTechnologies/storage/kv" - "github.com/hashicorp/consul/api" + consulsdk "github.com/hashicorp/consul/api" ) -// Config is the JSON "config" block of a consul store. -type Config struct { - // Address of the consul agent as host:port; the scheme comes from Scheme. - Address string `json:"address"` - - // Scheme is the URI scheme ("http" or "https"). Defaults to "http". - Scheme string `json:"scheme"` - - // Datacenter to query. Empty uses the agent's default datacenter. - Datacenter string `json:"datacenter"` - - // HttpAuth holds optional HTTP Basic Auth credentials. - HttpAuth struct { - Username string `json:"username"` - Password string `json:"password"` - } `json:"http_auth"` - - // WaitTime is consul's blocking-query (watch) timeout, as a Go duration - // string such as "5s". It is NOT a per-operation timeout — our plain reads - // never block on it — which is why the provider does not implement Timeouter. - // Empty or "0s" leaves the agent default. - WaitTime string `json:"wait_time"` - - // Token is used to provide a per-request ACL token - // which overrides the agent's default token. - Token string `json:"token"` - - // TLSConfig configures TLS for https connections. - TLSConfig struct { - Address string `json:"address"` - CAFile string `json:"ca_file"` - CAPath string `json:"ca_path"` - CertFile string `json:"cert_file"` - KeyFile string `json:"key_file"` - InsecureSkipVerify bool `json:"insecure_skip_verify"` - } `json:"tls_config"` -} - -// NewFactory returns a kv.ProviderFactory for HashiCorp Consul stores. -// -// The factory parses Config and builds a consul API client, but performs no -// network I/O. It errors only for config that is present but unusable: malformed -// JSON, an unparseable wait_time, or TLS settings the client rejects (e.g. an -// unreadable ca_file). Absent config is valid — an empty blob builds a client -// against consul's default local agent. -func NewFactory() kv.ProviderFactory { - return func(rawJSON json.RawMessage) (kv.Provider, error) { - var conf Config - - // Absent config is a valid state: skip the unmarshal so nil/empty input - // falls through to consul's defaults instead of failing to parse. - if len(rawJSON) != 0 { - if err := json.Unmarshal(rawJSON, &conf); err != nil { - return nil, fmt.Errorf("consul: invalid config: %w", err) - } - } - - clientCfg := api.DefaultConfig() - - if conf.Address != "" { - clientCfg.Address = conf.Address - } - - if conf.Scheme != "" { - clientCfg.Scheme = conf.Scheme - } - - if conf.Datacenter != "" { - clientCfg.Datacenter = conf.Datacenter - } - - if conf.HttpAuth.Username != "" || conf.HttpAuth.Password != "" { - clientCfg.HttpAuth = &api.HttpBasicAuth{ - Username: conf.HttpAuth.Username, - Password: conf.HttpAuth.Password, - } - } - - if conf.WaitTime != "" { - waitTime, err := time.ParseDuration(conf.WaitTime) - if err != nil { - return nil, fmt.Errorf("consul: invalid wait_time %q: %w", conf.WaitTime, err) - } - - if waitTime > 0 { - clientCfg.WaitTime = waitTime - } - } - - if conf.Token != "" { - clientCfg.Token = conf.Token - } - - applyTLSConfig(clientCfg, &conf) - - client, err := api.NewClient(clientCfg) - if err != nil { - return nil, fmt.Errorf("consul: create client: %w", err) - } - - return &consulProvider{kvClient: client.KV()}, nil - } -} - -func applyTLSConfig(clientCfg *api.Config, conf *Config) { - tls := conf.TLSConfig - - if tls.Address != "" { - clientCfg.TLSConfig.Address = tls.Address - } - - if tls.CAFile != "" { - clientCfg.TLSConfig.CAFile = tls.CAFile - } - - if tls.CAPath != "" { - clientCfg.TLSConfig.CAPath = tls.CAPath - } - - if tls.CertFile != "" { - clientCfg.TLSConfig.CertFile = tls.CertFile - } - - if tls.KeyFile != "" { - clientCfg.TLSConfig.KeyFile = tls.KeyFile - } - - if tls.InsecureSkipVerify { - clientCfg.TLSConfig.InsecureSkipVerify = true - } -} - // consulProvider is a kv.Provider backed by a configured consul KV client. type consulProvider struct { // kvClient is consul's KV endpoint, with the resolved Config already baked // into the underlying client. - kvClient *api.KV + kvClient *consulsdk.KV } // Get reads the value at key and returns it verbatim: no trimming, no key @@ -164,7 +29,7 @@ type consulProvider struct { // distinguishes. ctx bounds the request via QueryOptions, so the SecretStore's // per-operation deadline is honored. func (cp *consulProvider) Get(ctx context.Context, key string) (string, error) { - pair, _, err := cp.kvClient.Get(key, (&api.QueryOptions{}).WithContext(ctx)) + pair, _, err := cp.kvClient.Get(key, (&consulsdk.QueryOptions{}).WithContext(ctx)) if err != nil { return "", &kv.StoreUnavailableError{KeyPath: key, Err: err} } diff --git a/kv/providers/vault/config.go b/kv/providers/vault/config.go new file mode 100644 index 00000000..f026eecf --- /dev/null +++ b/kv/providers/vault/config.go @@ -0,0 +1,163 @@ +package vault + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/TykTechnologies/storage/kv" + vaultsdk "github.com/hashicorp/vault/api" +) + +// Config is the JSON "config" block of a vault store. +type Config struct { + // Address is the Vault server URL, e.g. "https://vault.example.com:8200". + // Optional: when empty the Vault client default is used (the VAULT_ADDR + // environment variable, otherwise https://127.0.0.1:8200). + Address string `json:"address"` + + // AgentAddress is the URL of a local Vault Agent, e.g. "http://127.0.0.1:8100". + // When set, the client routes requests through the agent instead of Address. + // A token is still required. + AgentAddress string `json:"agent_address"` + + // MaxRetries caps how many times the client retries a request after a + // server (5xx) error. Applied only when > 0; otherwise the Vault client + // default is kept. + MaxRetries int `json:"max_retries"` + + // Timeout bounds each Vault request. It is a Go duration string such as + // "5s" or "500ms"; an empty value means "unset", leaving the SecretStore to + // apply its own default. + Timeout string `json:"timeout"` + + // Token authenticates requests to Vault. Required. + Token string `json:"token"` + + // KVVersion selects the KV secrets engine version. Any value other than 1 + // means v2 (the default): secrets live under "/data/" and are + // wrapped in a "data" envelope. 1 selects v1, where the path is used as-is. + KVVersion int `json:"kv_version"` + + // MountPath is the path the KV secrets engine is mounted at, e.g. "secret" + // or a nested "tenants/a/kv". It is OPTIONAL and only affects KV v2. + // + // The key passed to Get is always the full logical path under this mount + // (it must start with MountPath). When set, the provider inserts the v2 + // "/data/" segment immediately after MountPath instead of assuming the mount + // is the first path segment — which is what makes nested mounts work. A key + // that is not under MountPath is rejected. + // + // When empty, the provider falls back to the legacy behavior of injecting + // "/data" after the first segment, so existing single-segment-mount configs + // are unaffected. Ignored for KV v1 (which has no data segment). + MountPath string `json:"mount_path"` +} + +// NewFactory returns a kv.ProviderFactory for HashiCorp Vault stores. +// +// The factory parses the provider Config and constructs a Vault API client, but +// performs no network I/O: the connection to Vault is established lazily on the +// first Get. It returns an error only for config that is present but unusable: +// - malformed JSON, +// - an unparseable timeout (must be a Go duration string, e.g. "5s"), +// - a missing token. Vault has no usable zero value, so a token is required +// even when agent_address is set. +// +// The resulting provider is remote: it is NOT Standalone and exposes its timeout +// via the Timeouter interface, so the registry wraps it in the caching / +// singleflight SecretStore and bounds each Get with the configured timeout. +func NewFactory() kv.ProviderFactory { + return func(raw json.RawMessage) (kv.Provider, error) { + var conf Config + if err := parseConfig(raw, &conf); err != nil { + return nil, err + } + + if err := conf.validate(); err != nil { + return nil, err + } + + timeout, err := conf.parsedTimeout() + if err != nil { + return nil, err + } + + client, err := conf.newClient(timeout) + if err != nil { + return nil, err + } + + client.SetToken(conf.Token) + + return &vaultProvider{ + client: client, + timeout: timeout, + kvv2: conf.KVVersion != 1, + // trim trailing slash(es) so "tenants/a/kv/" and "tenants/a/kv" behave the same + mountPath: strings.TrimRight(conf.MountPath, "/"), + }, nil + } +} + +func parseConfig(raw json.RawMessage, conf *Config) error { + if len(raw) == 0 { + return errors.New("vault: config is missing") + } + + if err := json.Unmarshal(raw, conf); err != nil { + return fmt.Errorf("vault: invalid config: %w", err) + } + + return nil +} + +func (conf *Config) validate() error { + if conf.Token == "" { + return errors.New("vault: token is required") + } + + return nil +} + +func (conf *Config) parsedTimeout() (time.Duration, error) { + if conf.Timeout == "" { + return 0, nil + } + + d, err := time.ParseDuration(conf.Timeout) + if err != nil { + return 0, fmt.Errorf("vault: invalid timeout %q: %w", conf.Timeout, err) + } + + return d, nil +} + +func (conf *Config) newClient(timeout time.Duration) (*vaultsdk.Client, error) { + defaultCfg := vaultsdk.DefaultConfig() + + if conf.Address != "" { + defaultCfg.Address = conf.Address + } + + if conf.AgentAddress != "" { + defaultCfg.AgentAddress = conf.AgentAddress + } + + if conf.MaxRetries > 0 { + defaultCfg.MaxRetries = conf.MaxRetries + } + + if timeout > 0 { + defaultCfg.Timeout = timeout + } + + client, err := vaultsdk.NewClient(defaultCfg) + if err != nil { + return nil, fmt.Errorf("vault: create client: %w", err) + } + + return client, nil +} diff --git a/kv/providers/vault/vault.go b/kv/providers/vault/vault.go index 3be81195..92f47f75 100644 --- a/kv/providers/vault/vault.go +++ b/kv/providers/vault/vault.go @@ -11,152 +11,20 @@ package vault import ( "context" "encoding/json" - "errors" "fmt" "path" "strings" "time" "github.com/TykTechnologies/storage/kv" - "github.com/hashicorp/vault/api" + vaultsdk "github.com/hashicorp/vault/api" ) -// Config is the JSON "config" block of a vault store. -type Config struct { - // Address is the Vault server URL, e.g. "https://vault.example.com:8200". - // Optional: when empty the Vault client default is used (the VAULT_ADDR - // environment variable, otherwise https://127.0.0.1:8200). - Address string `json:"address"` - - // AgentAddress is the URL of a local Vault Agent, e.g. "http://127.0.0.1:8100". - // When set, the client routes requests through the agent instead of Address. - // A token is still required. - AgentAddress string `json:"agent_address"` - - // MaxRetries caps how many times the client retries a request after a - // server (5xx) error. Applied only when > 0; otherwise the Vault client - // default is kept. - MaxRetries int `json:"max_retries"` - - // Timeout bounds each Vault request. It is a Go duration string such as - // "5s" or "500ms"; an empty value means "unset", leaving the SecretStore to - // apply its own default. - Timeout string `json:"timeout"` - - // Token authenticates requests to Vault. Required. - Token string `json:"token"` - - // KVVersion selects the KV secrets engine version. Any value other than 1 - // means v2 (the default): secrets live under "/data/" and are - // wrapped in a "data" envelope. 1 selects v1, where the path is used as-is. - KVVersion int `json:"kv_version"` - - // MountPath is the path the KV secrets engine is mounted at, e.g. "secret" - // or a nested "tenants/a/kv". It is OPTIONAL and only affects KV v2. - // - // The key passed to Get is always the full logical path under this mount - // (it must start with MountPath). When set, the provider inserts the v2 - // "/data/" segment immediately after MountPath instead of assuming the mount - // is the first path segment — which is what makes nested mounts work. A key - // that is not under MountPath is rejected. - // - // When empty, the provider falls back to the legacy behavior of injecting - // "/data" after the first segment, so existing single-segment-mount configs - // are unaffected. Ignored for KV v1 (which has no data segment). - MountPath string `json:"mount_path"` -} - -// NewFactory returns a kv.ProviderFactory for HashiCorp Vault stores. -// -// The factory parses the provider Config and constructs a Vault API client, but -// performs no network I/O: the connection to Vault is established lazily on the -// first Get. It returns an error only for config that is present but unusable: -// - malformed JSON, -// - an unparseable timeout (must be a Go duration string, e.g. "5s"), -// - a missing token. Vault has no usable zero value, so a token is required -// even when agent_address is set. -// -// The resulting provider is remote: it is NOT Standalone and exposes its timeout -// via the Timeouter interface, so the registry wraps it in the caching / -// singleflight SecretStore and bounds each Get with the configured timeout. -func NewFactory() kv.ProviderFactory { - return func(rawJSON json.RawMessage) (kv.Provider, error) { - // Empty/absent config: json.Unmarshal would fail cryptically on it, so - // reject it with a clear message. A present-but-empty object ("{}") is - // left to the token check below. - if len(rawJSON) == 0 { - return nil, errors.New("vault: config is missing") - } - - var conf Config - - if err := json.Unmarshal(rawJSON, &conf); err != nil { - return nil, fmt.Errorf("vault: invalid config: %w", err) - } - - if conf.Token == "" { - return nil, errors.New("vault: token is required") - } - - defaultCfg := api.DefaultConfig() - - if conf.Address != "" { - defaultCfg.Address = conf.Address - } - - if conf.AgentAddress != "" { - defaultCfg.AgentAddress = conf.AgentAddress - } - - if conf.MaxRetries > 0 { - defaultCfg.MaxRetries = conf.MaxRetries - } - - var timeout time.Duration - - if conf.Timeout != "" { - d, err := time.ParseDuration(conf.Timeout) - if err != nil { - return nil, fmt.Errorf( - "vault: invalid timeout %q: %w", - conf.Timeout, - err, - ) - } - - timeout = d - } - - if timeout > 0 { - defaultCfg.Timeout = timeout - } - - client, err := api.NewClient(defaultCfg) - if err != nil { - return nil, fmt.Errorf("vault: create client: %w", err) - } - - client.SetToken(conf.Token) - - kvv2 := conf.KVVersion != 1 - - // trim trailing slash(es) so "tenants/a/kv/" and "tenants/a/kv" behave the same - mountPath := strings.TrimRight(conf.MountPath, "/") - - return &vaultProvider{ - client: client, - timeout: timeout, - kvv2: kvv2, - mountPath: mountPath, - }, nil - } -} - // vaultProvider is a kv.Provider backed by a configured Vault API client. type vaultProvider struct { // client is the Vault API client. The resolved Config (address, token, // retries, timeout) is already baked into it at construction. - client *api.Client + client *vaultsdk.Client // timeout is the parsed Config.Timeout, surfaced via Timeout() so the // SecretStore wrapper can bound each Get with it. 0 means "unset", letting