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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion pkg/config/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -845,7 +845,9 @@ func setSettingsConfig(atmosConfig *schema.AtmosConfiguration, configAndStacksIn
return nil
}

// processStoreConfig creates a store registry from the provided stores config and assigns it to the atmosConfig.
// processStoreConfig creates a store registry from the provided stores config and assigns it to
// the atmosConfig. A misconfigured individual store no longer fails this (and thus the whole
// config load); store.NewStoreRegistry logs it as a warning and omits it from the registry.
func processStoreConfig(atmosConfig *schema.AtmosConfiguration) error {
if len(atmosConfig.StoresConfig) > 0 {
log.Debug("processStoreConfig", "atmosConfig.StoresConfig", fmt.Sprintf("%v", atmosConfig.StoresConfig))
Expand Down
12 changes: 7 additions & 5 deletions pkg/store/providers/registry_secret_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ var (
)

// TestNewStoreRegistry_SecretOnIncapableBackendErrors verifies that marking a backend that
// cannot encrypt at rest (Redis, Artifactory) as `secret: true` is a hard error at load,
// regardless of whether the backend is selected via the legacy `type` or the new `kind`.
// cannot encrypt at rest (Redis, Artifactory) as `secret: true` is skipped (logged as a
// warning) rather than constructed, regardless of whether the backend is selected via the
// legacy `type` or the new `kind`. It no longer fails the whole registry build — see
// https://github.com/cloudposse/atmos/issues/2930: one misconfigured store must not take down
// every other store's config load.
func TestNewStoreRegistry_SecretOnIncapableBackendErrors(t *testing.T) {
tests := []struct {
name string
Expand All @@ -34,9 +37,8 @@ func TestNewStoreRegistry_SecretOnIncapableBackendErrors(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
cfg := store.StoresConfig{"store": tt.config}
registry, err := store.NewStoreRegistry(&cfg)
require.Error(t, err)
assert.ErrorIs(t, err, store.ErrSecretBackendNotEncrypted)
assert.Nil(t, registry)
require.NoError(t, err)
assert.NotContains(t, registry, "store")
})
}
}
Expand Down
25 changes: 20 additions & 5 deletions pkg/store/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,27 +153,42 @@ func Reset() {
// factories registered by the provider packages. Import the provider package
// (e.g. with a blank import of pkg/store/providers) so the built-in backends are
// registered before this runs.
//
// A store that fails to resolve or construct (unresolvable kind, an incompatible
// `secret: true`, or a factory error) is skipped and logged as a warning naming the
// specific store, rather than aborting the whole build: `stores:` config load must not
// fail globally because one store — possibly one that nothing in the stack even references —
// is misconfigured. Code that actually looks up a skipped store by name still gets a clear
// error at the point of use, since it is simply absent from the returned registry.
// See https://github.com/cloudposse/atmos/issues/2930.
func NewStoreRegistry(config *StoresConfig) (StoreRegistry, error) {
registry := make(StoreRegistry)
for name, storeConfig := range *config {
kind := resolveKind(storeConfig)

// Fail fast on a misconfiguration: marking a backend that cannot encrypt at rest as
// `secret: true` is rejected before the store is ever constructed.
// A backend that cannot encrypt at rest must never be marked `secret: true`; skip it
// rather than construct a store that would silently write secrets in plaintext.
if storeConfig.Secret && isSecretIncapableKind(kind) {
return nil, fmt.Errorf("%w: store %q uses backend %q", ErrSecretBackendNotEncrypted, name, kind)
log.Warn("Skipping store: cannot mark as secret, backend does not encrypt values at rest",
"store", name, "kind", kind,
"error", fmt.Errorf("%w: store %q uses backend %q", ErrSecretBackendNotEncrypted, name, kind))
continue
}

storeFactoriesMu.RLock()
factory, ok := storeFactories[kind]
storeFactoriesMu.RUnlock()
if !ok {
return nil, fmt.Errorf("%w: %s", ErrStoreTypeNotFound, kind)
log.Warn("Skipping store: no backend registered for this kind",
"store", name, "kind", kind,
"error", fmt.Errorf("%w: store %q kind %q", ErrStoreTypeNotFound, name, kind))
continue
}

s, err := factory(name, storeConfig)
if err != nil {
return nil, err
log.Warn("Skipping store: failed to initialize", "store", name, "kind", kind, "error", err)
continue
}

// A `secret: true` store writes the sensitive at-rest variant when supported.
Expand Down
95 changes: 82 additions & 13 deletions pkg/store/registry_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package store

import (
"bytes"
"errors"
"fmt"
"sync"
"testing"

"github.com/stretchr/testify/assert"

log "github.com/cloudposse/atmos/pkg/logger"
)

// TestRegisterConcurrentWithNewStoreRegistry verifies that the storeFactories map
Expand Down Expand Up @@ -38,7 +41,8 @@ func TestRegisterConcurrentWithNewStoreRegistry(t *testing.T) {
config := &StoresConfig{
"probe": StoreConfig{Type: "definitely-not-registered"},
}
// Returns ErrStoreTypeNotFound; we only care that the map read is race-free.
// The unregistered type is skipped with a warning, not an error; we only care
// that the map read is race-free.
_, _ = NewStoreRegistry(config)
}()
}
Expand Down Expand Up @@ -73,34 +77,99 @@ func TestReset_ClearsFactories(t *testing.T) {
Register("reset-type", noopFactory)

// Sanity check: the type resolves before Reset.
_, err := NewStoreRegistry(&StoresConfig{"s": StoreConfig{Type: "reset-type"}})
registry, err := NewStoreRegistry(&StoresConfig{"s": StoreConfig{Type: "reset-type"}})
assert.NoError(t, err)
assert.Contains(t, registry, "s")

Reset()

// After Reset the type is gone.
_, err = NewStoreRegistry(&StoresConfig{"s": StoreConfig{Type: "reset-type"}})
assert.ErrorIs(t, err, ErrStoreTypeNotFound)
// After Reset the type no longer resolves: the store is skipped (warned), not fatal.
registry, err = NewStoreRegistry(&StoresConfig{"s": StoreConfig{Type: "reset-type"}})
assert.NoError(t, err)
assert.NotContains(t, registry, "s")
}

// TestNewStoreRegistry_UnknownType verifies the not-found error for unregistered types.
func TestNewStoreRegistry_UnknownType(t *testing.T) {
// TestNewStoreRegistry_UnknownTypeIsSkippedWithoutError verifies that an unresolvable store
// kind is omitted from the registry with a warning instead of failing the whole build. See
// https://github.com/cloudposse/atmos/issues/2930.
func TestNewStoreRegistry_UnknownTypeIsSkippedWithoutError(t *testing.T) {
t.Cleanup(Reset)
Reset()

_, err := NewStoreRegistry(&StoresConfig{"s": StoreConfig{Type: "no-such-type"}})
assert.ErrorIs(t, err, ErrStoreTypeNotFound)
registry, err := NewStoreRegistry(&StoresConfig{"s": StoreConfig{Type: "no-such-type"}})
assert.NoError(t, err, "an unresolvable store must not fail the whole registry build")
assert.NotContains(t, registry, "s")
}

// TestNewStoreRegistry_FactoryError verifies that a factory error propagates.
func TestNewStoreRegistry_FactoryError(t *testing.T) {
// TestNewStoreRegistry_FactoryErrorIsSkippedWithoutError verifies that a factory error for one
// store is warned and skipped rather than aborting the whole registry build.
func TestNewStoreRegistry_FactoryErrorIsSkippedWithoutError(t *testing.T) {
t.Cleanup(Reset)

sentinel := errors.New("factory boom")
Register("err-type", func(_ string, _ StoreConfig) (Store, error) {
return nil, sentinel
})

_, err := NewStoreRegistry(&StoresConfig{"s": StoreConfig{Type: "err-type"}})
assert.ErrorIs(t, err, sentinel)
registry, err := NewStoreRegistry(&StoresConfig{"s": StoreConfig{Type: "err-type"}})
assert.NoError(t, err, "a factory error for one store must not fail the whole registry build")
assert.NotContains(t, registry, "s")
}

// TestNewStoreRegistry_SecretIncapableIsSkippedWithoutError verifies that marking a
// non-encrypting backend `secret: true` is warned and skipped, not fatal to the build.
func TestNewStoreRegistry_SecretIncapableIsSkippedWithoutError(t *testing.T) {
t.Cleanup(Reset)
Reset()

Register(KindRedis, noopFactory)

registry, err := NewStoreRegistry(&StoresConfig{"s": StoreConfig{Type: KindRedis, Secret: true}})
assert.NoError(t, err)
assert.NotContains(t, registry, "s")
}

// TestNewStoreRegistry_OneBadStoreDoesNotBlockOthers verifies the blast-radius hardening: a
// single misconfigured store (unresolvable kind) must not prevent the other, valid stores in
// the same config from being built. Before this fix, one bad store — even one nothing in any
// stack referenced — failed the entire atmos.yaml config load. See
// https://github.com/cloudposse/atmos/issues/2930.
func TestNewStoreRegistry_OneBadStoreDoesNotBlockOthers(t *testing.T) {
t.Cleanup(Reset)
Reset()

Register("good-type", noopFactory)

registry, err := NewStoreRegistry(&StoresConfig{
"bad": StoreConfig{Type: "no-such-type"},
"good": StoreConfig{Type: "good-type"},
})

assert.NoError(t, err)
assert.NotContains(t, registry, "bad")
assert.Contains(t, registry, "good")
}

// TestNewStoreRegistry_UnresolvedKindWarningNamesStore verifies the diagnosability hardening:
// the warning logged for an unresolvable store kind names the specific store, not just the
// kind, so a config with several stores can be triaged from the log alone. See
// https://github.com/cloudposse/atmos/issues/2930.
func TestNewStoreRegistry_UnresolvedKindWarningNamesStore(t *testing.T) {
t.Cleanup(Reset)
Reset()

originalLogger := log.Default()
buffer := &bytes.Buffer{}
testLogger := log.New()
testLogger.SetOutput(buffer)
testLogger.SetLevel(log.WarnLevel)
testLogger.SetReportTimestamp(false)
log.SetDefault(testLogger)
t.Cleanup(func() { log.SetDefault(originalLogger) })

_, err := NewStoreRegistry(&StoresConfig{"my-broken-store": StoreConfig{Type: "no-such-type"}})
assert.NoError(t, err)

logged := buffer.String()
assert.Contains(t, logged, "my-broken-store", "the warning must name the specific store, not just its kind")
}
2 changes: 1 addition & 1 deletion website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@
"postcss@^8": "^8.5.18",
"postcss-selector-parser@^6": "^6.1.3",
"postcss-selector-parser@^7": "^7.1.3",
"qs@^6": "^6.15.2",
"qs@^6": "^6.16.0",
"serialize-javascript@^6": "^7.0.5",
"shell-quote@^1": "^1.8.4",
"svgo@^3": "^3.3.4",
Expand Down
12 changes: 6 additions & 6 deletions website/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading