@cryptotaxi247 / netdata-1 / commits / 3b48ac828

feat(go.d): add secretstore config resolution and logging (#22083)

Ilya Mashchenko committed Mar 30, 2026 at 14:35 UTC 3b48ac828cd5f35b562025584cf54186fb586817
39 files changed +788 -83
src/go/logger/context.go new
+27
@@ -0,0 +1,27 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package logger
4 +
5 +import "context"
6 +
7 +type contextKey struct{}
8 +
9 +// ContextWithLogger attaches a logger to a context.
10 +func ContextWithLogger(ctx context.Context, log *Logger) context.Context {
11 + if ctx == nil {
12 + ctx = context.Background()
13 + }
14 + if log == nil {
15 + return ctx
16 + }
17 + return context.WithValue(ctx, contextKey{}, log)
18 +}
19 +
20 +// LoggerFromContext returns a logger attached to a context.
21 +func LoggerFromContext(ctx context.Context) (*Logger, bool) {
22 + if ctx == nil {
23 + return nil, false
24 + }
25 + log, ok := ctx.Value(contextKey{}).(*Logger)
26 + return log, ok && log != nil
27 +}
src/go/logger/context_test.go new
+28
@@ -0,0 +1,28 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package logger
4 +
5 +import (
6 + "context"
7 + "testing"
8 +
9 + "github.com/stretchr/testify/assert"
10 +)
11 +
12 +func TestContextWithLoggerAndLoggerFromContext(t *testing.T) {
13 + log := New()
14 +
15 + ctx := ContextWithLogger(nil, log)
16 + got, ok := LoggerFromContext(ctx)
17 +
18 + assert.True(t, ok)
19 + assert.Same(t, log, got)
20 +}
21 +
22 +func TestContextWithLogger_IgnoresNilLogger(t *testing.T) {
23 + ctx := ContextWithLogger(context.Background(), nil)
24 + got, ok := LoggerFromContext(ctx)
25 +
26 + assert.False(t, ok)
27 + assert.Nil(t, got)
28 +}
src/go/logger/default.go
+2 -8
@@ -3,18 +3,12 @@
3 package logger
4
5 import (
6 - "log/slog"
6 + "os"
7 "time"
8 -
9 - "github.com/netdata/netdata/go/plugins/pkg/terminal"
8 )
9
10 func newDefaultLogger() *Logger {
13 - if terminal.IsTerminal() {
14 - // skip 2 slog pkg calls, 3 this pkg calls
15 - return &Logger{sl: slog.New(withTerminalCallDepth(5, newTerminalHandler())), rl: newRateLimiter()}
16 - }
17 - return &Logger{sl: slog.New(newTextHandler()).With(pluginAttr), rl: newRateLimiter()}
11 + return newLogger(os.Stderr, isTerm, 5)
12 }
13
14 var defaultLogger = newDefaultLogger()
src/go/logger/handler.go
+5 -5
@@ -2,16 +2,16 @@ package logger
2
3 import (
4 "context"
5 + "io"
6 "log/slog"
6 - "os"
7 "runtime"
8 "strings"
9
10 "github.com/lmittmann/tint"
11 )
12
13 -func newTextHandler() slog.Handler {
14 - return slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
13 +func newTextHandler(w io.Writer) slog.Handler {
14 + return slog.NewTextHandler(w, &slog.HandlerOptions{
15 Level: Level.lvl,
16 ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
17 switch a.Key {
@@ -32,8 +32,8 @@ func newTextHandler() slog.Handler {
32 })
33 }
34
35 -func newTerminalHandler() slog.Handler {
36 - return tint.NewHandler(os.Stderr, &tint.Options{
35 +func newTerminalHandler(w io.Writer) slog.Handler {
36 + return tint.NewHandler(w, &tint.Options{
37 NoColor: runtime.GOOS == "windows",
38 AddSource: true,
39 Level: Level.lvl,
src/go/logger/logger.go
+16 -3
@@ -5,7 +5,9 @@ package logger
5 import (
6 "context"
7 "fmt"
8 + "io"
9 "log/slog"
10 + "os"
11 "sync/atomic"
12
13 "github.com/netdata/netdata/go/plugins/pkg/executable"
@@ -19,11 +21,22 @@ var isJournal = isStderrConnectedToJournal()
21 var pluginAttr = slog.String("plugin", executable.Name)
22
23 func New() *Logger {
22 - if isTerm {
24 + return newLogger(os.Stderr, isTerm, 4)
25 +}
26 +
27 +func NewWithWriter(w io.Writer) *Logger {
28 + if w == nil {
29 + w = os.Stderr
30 + }
31 + return newLogger(w, false, 4)
32 +}
33 +
34 +func newLogger(w io.Writer, isTerminal bool, depth int) *Logger {
35 + if isTerminal {
36 // skip 2 slog pkg calls, 2 this pkg calls
24 - return &Logger{sl: slog.New(withTerminalCallDepth(4, newTerminalHandler())), rl: newRateLimiter()}
37 + return &Logger{sl: slog.New(withTerminalCallDepth(depth, newTerminalHandler(w))), rl: newRateLimiter()}
38 }
26 - return &Logger{sl: slog.New(newTextHandler()).With(pluginAttr), rl: newRateLimiter()}
39 + return &Logger{sl: slog.New(newTextHandler(w)).With(pluginAttr), rl: newRateLimiter()}
40 }
41
42 type Logger struct {
src/go/logger/logger_test.go
+22
@@ -1,6 +1,8 @@
1 package logger
2
3 import (
4 + "bytes"
5 + "strings"
6 "testing"
7 "time"
8
@@ -26,3 +28,23 @@ func TestNew(t *testing.T) {
28 })
29 }
30 }
31 +
32 +func TestNewWithWriter(t *testing.T) {
33 + var buf bytes.Buffer
34 +
35 + log := NewWithWriter(&buf)
36 + log.Info("captured")
37 +
38 + assert.Contains(t, buf.String(), "captured")
39 +}
40 +
41 +func TestNewWithWriter_WithKeepsWriter(t *testing.T) {
42 + var buf bytes.Buffer
43 +
44 + log := NewWithWriter(&buf).With("component", "test")
45 + log.Info("captured")
46 +
47 + out := buf.String()
48 + assert.Contains(t, out, "captured")
49 + assert.True(t, strings.Contains(out, "component=test") || strings.Contains(out, "\"component\":\"test\""))
50 +}
src/go/plugin/agent/jobmgr/dyncfg_collector_cmds.go
+2 -1
@@ -190,7 +190,8 @@ func (m *Manager) runDyncfgCmdTest(task dyncfgCmdTestTask) {
190 if secretStoreSvc != nil {
191 storeSnapshot = secretStoreSvc.Capture()
192 }
193 - if err := applyConfig(ctx, task.cfg, job, m.secretResolver, secretStoreSvc, storeSnapshot); err != nil {
193 + resolveCtx := collectorSecretResolveContext(ctx, m.Logger, task.cfg)
194 + if err := applyConfig(resolveCtx, task.cfg, job, m.secretResolver, secretStoreSvc, storeSnapshot); err != nil {
195 m.Warningf("dyncfg: test: module %s: failed to apply config: %v", task.moduleName, err)
196 m.dyncfgResponder.SendCodef(task.fn, 400, "Invalid configuration. Failed to apply configuration: %v.", err)
197 return
src/go/plugin/agent/jobmgr/dyncfg_collector_test.go
+6 -4
@@ -897,13 +897,15 @@ func (*blockingSecretStoreService) GetStatus(string) (secretstore.StoreStatus, b
897 return secretstore.StoreStatus{}, false
898 }
899
900 -func (*blockingSecretStoreService) Validate(secretstore.Config) error { return nil }
900 +func (*blockingSecretStoreService) Validate(context.Context, secretstore.Config) error { return nil }
901
902 -func (*blockingSecretStoreService) ValidateStored(string) error { return nil }
902 +func (*blockingSecretStoreService) ValidateStored(context.Context, string) error { return nil }
903
904 -func (*blockingSecretStoreService) Add(secretstore.Config) error { return nil }
904 +func (*blockingSecretStoreService) Add(context.Context, secretstore.Config) error { return nil }
905
906 -func (*blockingSecretStoreService) Update(string, secretstore.Config) error { return nil }
906 +func (*blockingSecretStoreService) Update(context.Context, string, secretstore.Config) error {
907 + return nil
908 +}
909
910 func (*blockingSecretStoreService) Remove(string) error { return nil }
911 func (j *collectorProbeJob) Cleanup() {}
src/go/plugin/agent/jobmgr/job_factory.go
+4 -2
@@ -110,7 +110,8 @@ func (f *jobFactory) createV2(cfg confgroup.Config, creator collectorapi.Creator
110 if f.secretStoreSvc != nil {
111 storeSnapshot = f.secretStoreSvc.Capture()
112 }
113 - if err := applyConfig(f.ctx, cfg, mod, f.secretResolver, f.secretStoreSvc, storeSnapshot); err != nil {
113 + resolveCtx := collectorSecretResolveContext(f.ctx, f.logger, cfg)
114 + if err := applyConfig(resolveCtx, cfg, mod, f.secretResolver, f.secretStoreSvc, storeSnapshot); err != nil {
115 return nil, err
116 }
117
@@ -149,7 +150,8 @@ func (f *jobFactory) createV1(cfg confgroup.Config, creator collectorapi.Creator
150 if f.secretStoreSvc != nil {
151 storeSnapshot = f.secretStoreSvc.Capture()
152 }
152 - if err := applyConfig(f.ctx, cfg, mod, f.secretResolver, f.secretStoreSvc, storeSnapshot); err != nil {
153 + resolveCtx := collectorSecretResolveContext(f.ctx, f.logger, cfg)
154 + if err := applyConfig(resolveCtx, cfg, mod, f.secretResolver, f.secretStoreSvc, storeSnapshot); err != nil {
155 return nil, err
156 }
157
src/go/plugin/agent/jobmgr/secretlogctx.go new
+24
@@ -0,0 +1,24 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package jobmgr
4 +
5 +import (
6 + "context"
7 + "log/slog"
8 +
9 + "github.com/netdata/netdata/go/plugins/logger"
10 + "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
11 +)
12 +
13 +func collectorSecretResolveContext(ctx context.Context, log *logger.Logger, cfg confgroup.Config) context.Context {
14 + if cfg == nil {
15 + return logger.ContextWithLogger(ctx, log)
16 + }
17 + if log != nil {
18 + log = log.With(
19 + slog.String("collector", cfg.Module()),
20 + slog.String("job", cfg.Name()),
21 + )
22 + }
23 + return logger.ContextWithLogger(ctx, log)
24 +}
src/go/plugin/agent/jobmgr/secretsctl/cache.go
+2 -2
@@ -97,7 +97,7 @@ func (c *Controller) validateConfig(cfg secretstore.Config) error {
97 if c.service == nil {
98 return fmt.Errorf("secretstore service is not available")
99 }
100 - return c.service.Validate(cfg)
100 + return c.service.Validate(secretStoreResolveContext(c.Logger, cfg), cfg)
101 }
102
103 func (c *Controller) validateStored(key string) error {
@@ -110,7 +110,7 @@ func (c *Controller) validateStored(key string) error {
110 }
111
112 if _, ok := c.service.GetStatus(key); ok {
113 - return c.service.ValidateStored(key)
113 + return c.service.ValidateStored(secretStoreResolveContextForKey(c.Logger, key, entry.Cfg.Kind(), entry.Cfg.Name()), key)
114 }
115
116 return c.validateConfig(entry.Cfg)
src/go/plugin/agent/jobmgr/secretsctl/cache_ownership_test.go
+2 -1
@@ -4,6 +4,7 @@ package secretsctl
4
5 import (
6 "bytes"
7 + "context"
8 "testing"
9
10 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
@@ -51,7 +52,7 @@ func TestControllerSeqExec_FileDefinedConfigBecomesDyncfgOverride(t *testing.T)
52 key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
53
54 fileCfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", vaultModeTokenConfig(), "file=/etc/netdata/go.d/ss/vault.conf", confgroup.TypeUser)
54 - require.NoError(t, ctl.Service().Add(fileCfg))
55 + require.NoError(t, ctl.Service().Add(context.Background(), fileCfg))
56 ctl.seen.Add(fileCfg)
57 ctl.exposed.Add(&dyncfg.Entry[secretstore.Config]{
58 Cfg: fileCfg,
src/go/plugin/agent/jobmgr/secretsctl/callbacks.go
+12 -5
@@ -3,10 +3,12 @@
3 package secretsctl
4
5 import (
6 + "context"
7 "errors"
8 "fmt"
9 "strings"
10
11 + "github.com/netdata/netdata/go/plugins/logger"
12 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
13 "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
14 "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
@@ -22,6 +24,7 @@ type secretStoreCallbacks struct {
24
25 type secretStoreCallbackDeps struct {
26 pluginName string
27 + log *logger.Logger
28 service secretstore.Service
29 restartDependentJobs func(string) string
30 }
@@ -97,7 +100,11 @@ func (d secretStoreCallbackDeps) validateSecretStoreConfig(cfg secretstore.Confi
100 if d.service == nil {
101 return fmt.Errorf("secretstore service is not available")
102 }
100 - return d.service.Validate(cfg)
103 + return d.service.Validate(d.resolveContext(cfg), cfg)
104 +}
105 +
106 +func (d secretStoreCallbackDeps) resolveContext(cfg secretstore.Config) context.Context {
107 + return secretStoreResolveContext(d.log, cfg)
108 }
109
110 func (d secretStoreCallbackDeps) dyncfgSecretStoreID(id string) string {
@@ -163,10 +170,10 @@ func (cb *secretStoreCallbacks) Start(cfg secretstore.Config) error {
170 }
171
172 if _, ok := cb.deps.service.GetStatus(key); ok {
166 - if err := cb.deps.service.Update(key, cfg); err != nil {
173 + if err := cb.deps.service.Update(cb.deps.resolveContext(cfg), key, cfg); err != nil {
174 return &codedError{err: err, code: secretStoreErrorCode(err)}
175 }
169 - } else if err := cb.deps.service.Add(cfg); err != nil {
176 + } else if err := cb.deps.service.Add(cb.deps.resolveContext(cfg), cfg); err != nil {
177 return &codedError{err: err, code: secretStoreErrorCode(err)}
178 }
179
@@ -182,10 +189,10 @@ func (cb *secretStoreCallbacks) Update(oldCfg, newCfg secretstore.Config) error
189 }
190
191 if _, ok := cb.deps.service.GetStatus(key); ok {
185 - if err := cb.deps.service.Update(key, newCfg); err != nil {
192 + if err := cb.deps.service.Update(cb.deps.resolveContext(newCfg), key, newCfg); err != nil {
193 return &codedError{err: err, code: secretStoreErrorCode(err)}
194 }
188 - } else if err := cb.deps.service.Add(newCfg); err != nil {
195 + } else if err := cb.deps.service.Add(cb.deps.resolveContext(newCfg), newCfg); err != nil {
196 return &codedError{err: err, code: secretStoreErrorCode(err)}
197 }
198
src/go/plugin/agent/jobmgr/secretsctl/controller.go
+1
@@ -77,6 +77,7 @@ func New(opts Options) *Controller {
77 }
78 c.cb = newSecretStoreCallbacks(secretStoreCallbackDeps{
79 pluginName: c.pluginName,
80 + log: c.Logger,
81 service: c.service,
82 restartDependentJobs: c.restartDependentJobs,
83 })
src/go/plugin/agent/jobmgr/secretsctl/controller_test.go
+2 -1
@@ -4,6 +4,7 @@ package secretsctl
4
5 import (
6 "bytes"
7 + "context"
8 "encoding/json"
9 "regexp"
10 "testing"
@@ -173,7 +174,7 @@ func TestControllerSeqExec_TestStoredWithoutService_Returns400(t *testing.T) {
174 func TestRememberDiscoveredConfig_DrainsRestartFailureMessageFromNonHandlerStop(t *testing.T) {
175 ctl, _, seams := newControllerTestSubject()
176 existing := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "one"}, "/etc/netdata/secretstores.yaml", confgroup.TypeUser)
176 - require.NoError(t, ctl.Service().Add(existing))
177 + require.NoError(t, ctl.Service().Add(context.Background(), existing))
178 ctl.seen.Add(existing)
179 ctl.exposed.Add(&dyncfg.Entry[secretstore.Config]{
180 Cfg: existing,
src/go/plugin/agent/jobmgr/secretsctl/logctx.go new
+29
@@ -0,0 +1,29 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package secretsctl
4 +
5 +import (
6 + "context"
7 + "log/slog"
8 +
9 + "github.com/netdata/netdata/go/plugins/logger"
10 + "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
11 +)
12 +
13 +func secretStoreResolveContext(log *logger.Logger, cfg secretstore.Config) context.Context {
14 + if cfg == nil {
15 + return logger.ContextWithLogger(context.Background(), log)
16 + }
17 + return secretStoreResolveContextForKey(log, cfg.ExposedKey(), cfg.Kind(), cfg.Name())
18 +}
19 +
20 +func secretStoreResolveContextForKey(log *logger.Logger, key string, kind secretstore.StoreKind, name string) context.Context {
21 + if log != nil {
22 + log = log.With(
23 + slog.String("secretstore", key),
24 + slog.String("secretstore_kind", string(kind)),
25 + slog.String("secretstore_name", name),
26 + )
27 + }
28 + return logger.ContextWithLogger(context.Background(), log)
29 +}
src/go/plugin/agent/jobmgr/secretstore_flow_test.go
+1 -1
@@ -87,7 +87,7 @@ func newTestSecretStoreService() secretstore.Service {
87 func TestApplyConfig_ResolvesStoreReferenceWithKindAndName(t *testing.T) {
88 svc := newTestSecretStoreService()
89 raw := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "resolved-secret"}, confgroup.TypeDyncfg, confgroup.TypeDyncfg)
90 - require.NoError(t, svc.Add(raw))
90 + require.NoError(t, svc.Add(context.Background(), raw))
91
92 cfg := prepareDyncfgCfg("success", "secret-job").
93 Set("option_str", "${store:vault:vault_prod:value}").
src/go/plugin/agent/secrets/resolver/cmd.go
+3 -1
@@ -42,5 +42,7 @@ func (r *Resolver) resolveCmd(ctx context.Context, cmdLine, original string) (st
42 return "", fmt.Errorf("resolving secret '%s': command failed: %w", original, err)
43 }
44
45 - return strings.TrimSpace(string(out)), nil
45 + value := strings.TrimSpace(string(out))
46 + logResolved(ctx, "resolved secret via command '%s'", parts[0])
47 + return value, nil
48 }
src/go/plugin/agent/secrets/resolver/env.go
+4 -2
@@ -9,10 +9,12 @@ import (
9 "strings"
10 )
11
12 -func (r *Resolver) resolveEnv(_ context.Context, name, original string) (string, error) {
12 +func (r *Resolver) resolveEnv(ctx context.Context, name, original string) (string, error) {
13 val, ok := os.LookupEnv(name)
14 if !ok {
15 return "", fmt.Errorf("resolving secret '%s': environment variable '%s' is not set", original, name)
16 }
17 - return strings.TrimSpace(val), nil
17 + value := strings.TrimSpace(val)
18 + logResolved(ctx, "resolved secret via env variable '%s'", name)
19 + return value, nil
20 }
src/go/plugin/agent/secrets/resolver/file.go
+4 -2
@@ -10,7 +10,7 @@ import (
10 "strings"
11 )
12
13 -func (r *Resolver) resolveFile(_ context.Context, path, original string) (string, error) {
13 +func (r *Resolver) resolveFile(ctx context.Context, path, original string) (string, error) {
14 if !filepath.IsAbs(path) {
15 return "", fmt.Errorf("resolving secret '%s': file path must be absolute, got '%s'", original, path)
16 }
@@ -18,5 +18,7 @@ func (r *Resolver) resolveFile(_ context.Context, path, original string) (string
18 if err != nil {
19 return "", fmt.Errorf("resolving secret '%s': %w", original, err)
20 }
21 - return strings.TrimSpace(string(data)), nil
21 + value := strings.TrimSpace(string(data))
22 + logResolved(ctx, "resolved secret via file '%s'", path)
23 + return value, nil
24 }
src/go/plugin/agent/secrets/resolver/logging.go new
+15
@@ -0,0 +1,15 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package secretresolver
4 +
5 +import (
6 + "context"
7 +
8 + "github.com/netdata/netdata/go/plugins/logger"
9 +)
10 +
11 +func logResolved(ctx context.Context, format string, args ...any) {
12 + if log, ok := logger.LoggerFromContext(ctx); ok {
13 + log.Infof(format, args...)
14 + }
15 +}
src/go/plugin/agent/secrets/resolver/resolver_test.go
+65
@@ -3,12 +3,15 @@
3 package secretresolver
4
5 import (
6 + "bytes"
7 "context"
8 "errors"
9 "os"
10 "path/filepath"
11 + "runtime"
12 "testing"
13
14 + "github.com/netdata/netdata/go/plugins/logger"
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17 )
@@ -431,3 +434,65 @@ func TestResolveRefUsesProviderRegistry(t *testing.T) {
434 assert.True(t, called)
435 assert.Equal(t, "resolved-by-stub", cfg["value"])
436 }
437 +
438 +func TestResolveWithStoreResolver_LogsDetailedBuiltinResolution(t *testing.T) {
439 + modeFile := filepath.Join(t.TempDir(), "secret.txt")
440 + require.NoError(t, os.WriteFile(modeFile, []byte("from-file\n"), 0o600))
441 +
442 + tests := map[string]struct {
443 + cfg map[string]any
444 + onWindowsSkip bool
445 + setup func(t *testing.T)
446 + wantLog string
447 + dontWantLogs []string
448 + }{
449 + "env": {
450 + cfg: map[string]any{"value": "${env:TEST_SECRET_ENV}"},
451 + setup: func(t *testing.T) {
452 + t.Setenv("TEST_SECRET_ENV", "from-env")
453 + },
454 + wantLog: "resolved secret via env variable 'TEST_SECRET_ENV'",
455 + dontWantLogs: []string{"from-env"},
456 + },
457 + "file": {
458 + cfg: map[string]any{"value": "${file:" + modeFile + "}"},
459 + wantLog: "resolved secret via file '" + modeFile + "'",
460 + dontWantLogs: []string{"from-file"},
461 + },
462 + "cmd": {
463 + cfg: map[string]any{"value": "${cmd:/bin/echo from-cmd}"},
464 + onWindowsSkip: true,
465 + wantLog: "resolved secret via command '/bin/echo'",
466 + dontWantLogs: []string{"from-cmd"},
467 + },
468 + }
469 +
470 + for name, tc := range tests {
471 + t.Run(name, func(t *testing.T) {
472 + if tc.onWindowsSkip && runtime.GOOS == "windows" {
473 + t.Skip("skipping on windows")
474 + }
475 + if tc.setup != nil {
476 + tc.setup(t)
477 + }
478 +
479 + out := captureResolverLoggerOutput(t, func(log *logger.Logger) {
480 + ctx := logger.ContextWithLogger(context.Background(), log)
481 + require.NoError(t, New().ResolveWithStoreResolver(ctx, tc.cfg, nil))
482 + })
483 +
484 + assert.Contains(t, out, tc.wantLog)
485 + for _, s := range tc.dontWantLogs {
486 + assert.NotContains(t, out, s)
487 + }
488 + })
489 + }
490 +}
491 +
492 +func captureResolverLoggerOutput(t *testing.T, fn func(log *logger.Logger)) string {
493 + t.Helper()
494 +
495 + var buf bytes.Buffer
496 + fn(logger.NewWithWriter(&buf))
497 + return buf.String()
498 +}
src/go/plugin/agent/secrets/secretstore/backends/aws/resolve.go
+16
@@ -14,6 +14,7 @@ import (
14 "sort"
15 "strings"
16
17 + "github.com/netdata/netdata/go/plugins/logger"
18 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
19 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/envx"
20 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/httpx"
@@ -45,6 +46,7 @@ func (s *publishedStore) resolve(ctx context.Context, req secretstore.ResolveReq
46 }
47
48 if jsonKey == "" {
49 + logResolvedRequest(ctx, req, secretName, "")
50 return secretString, nil
51 }
52
@@ -57,15 +59,29 @@ func (s *publishedStore) resolve(ctx context.Context, req secretstore.ResolveReq
59 return "", fmt.Errorf("resolving secret '%s': store '%s': key '%s' not found in SecretString JSON", req.Original, req.StoreKey, jsonKey)
60 }
61 if value, ok := val.(string); ok {
62 + logResolvedRequest(ctx, req, secretName, jsonKey)
63 return value, nil
64 }
65 b, err := json.Marshal(val)
66 if err != nil {
67 return "", fmt.Errorf("resolving secret '%s': store '%s': encoding value for key '%s': %w", req.Original, req.StoreKey, jsonKey, err)
68 }
69 + logResolvedRequest(ctx, req, secretName, jsonKey)
70 return string(b), nil
71 }
72
73 +func logResolvedRequest(ctx context.Context, req secretstore.ResolveRequest, secretName, jsonKey string) {
74 + log, ok := logger.LoggerFromContext(ctx)
75 + if !ok {
76 + return
77 + }
78 + if jsonKey == "" {
79 + log.Infof("resolved secret via aws-sm secretstore '%s' secret '%s'", req.StoreKey, secretName)
80 + return
81 + }
82 + log.Infof("resolved secret via aws-sm secretstore '%s' secret '%s' key '%s'", req.StoreKey, secretName, jsonKey)
83 +}
84 +
85 func (s *publishedStore) region() (string, error) {
86 if s.regionValue == "" {
87 return "", fmt.Errorf("region is required")
src/go/plugin/agent/secrets/secretstore/backends/aws/resolve_test.go
+55
@@ -3,16 +3,26 @@
3 package aws
4
5 import (
6 + "bytes"
7 "context"
8 + "io"
9 "net/http"
10 "net/http/httptest"
11 "testing"
12 "time"
13
14 + "github.com/netdata/netdata/go/plugins/logger"
15 + "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18 )
19
20 +type roundTripFunc func(*http.Request) (*http.Response, error)
21 +
22 +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
23 + return f(req)
24 +}
25 +
26 func TestSecretsManagerHost(t *testing.T) {
27 tests := map[string]struct {
28 region string
@@ -78,3 +88,48 @@ func TestSecretValue_CustomEndpointUsesSignedHostHeader(t *testing.T) {
88 })
89 }
90 }
91 +
92 +func TestPublishedStoreResolve_LogsDetailedResolution(t *testing.T) {
93 + t.Setenv("AWS_ACCESS_KEY_ID", "AKID")
94 + t.Setenv("AWS_SECRET_ACCESS_KEY", "SECRET")
95 +
96 + store := &publishedStore{
97 + provider: &provider{
98 + apiClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
99 + return &http.Response{
100 + StatusCode: http.StatusOK,
101 + Body: io.NopCloser(bytes.NewBufferString(`{"SecretString":"{\"password\":\"secret-value\"}"}`)),
102 + Header: make(http.Header),
103 + }, nil
104 + })},
105 + endpoint: "https://example.test/",
106 + now: func() time.Time {
107 + return time.Date(2026, time.March, 18, 12, 0, 0, 0, time.UTC)
108 + },
109 + },
110 + mode: "env",
111 + regionValue: "us-east-1",
112 + }
113 +
114 + out := captureLoggerOutput(t, func(log *logger.Logger) {
115 + ctx := logger.ContextWithLogger(context.Background(), log)
116 + value, err := store.Resolve(ctx, secretstore.ResolveRequest{
117 + StoreKey: "aws-sm:aws_prod",
118 + Operand: "db/password#password",
119 + Original: "${store:aws-sm:aws_prod:db/password#password}",
120 + })
121 + require.NoError(t, err)
122 + assert.Equal(t, "secret-value", value)
123 + })
124 +
125 + assert.Contains(t, out, "resolved secret via aws-sm secretstore 'aws-sm:aws_prod' secret 'db/password' key 'password'")
126 + assert.NotContains(t, out, "secret-value")
127 +}
128 +
129 +func captureLoggerOutput(t *testing.T, fn func(log *logger.Logger)) string {
130 + t.Helper()
131 +
132 + var buf bytes.Buffer
133 + fn(logger.NewWithWriter(&buf))
134 + return buf.String()
135 +}
src/go/plugin/agent/secrets/secretstore/backends/azure/resolve.go
+8
@@ -10,6 +10,7 @@ import (
10 "net/http"
11 "strings"
12
13 + "github.com/netdata/netdata/go/plugins/logger"
14 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
15 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/httpx"
16 )
@@ -64,9 +65,16 @@ func (s *publishedStore) resolve(ctx context.Context, req secretstore.ResolveReq
65 if result.Value == "" {
66 return "", fmt.Errorf("resolving secret '%s': store '%s': Azure Key Vault returned empty secret value", req.Original, req.StoreKey)
67 }
68 + logResolvedRequest(ctx, req, vaultName, secretName)
69 return result.Value, nil
70 }
71
72 +func logResolvedRequest(ctx context.Context, req secretstore.ResolveRequest, vaultName, secretName string) {
73 + if log, ok := logger.LoggerFromContext(ctx); ok {
74 + log.Infof("resolved secret via azure-kv secretstore '%s' vault '%s' secret '%s'", req.StoreKey, vaultName, secretName)
75 + }
76 +}
77 +
78 func splitOperand(operand string) (string, string, bool) {
79 vaultName, secretName, ok := strings.Cut(operand, "/")
80 return vaultName, secretName, ok && vaultName != "" && secretName != ""
src/go/plugin/agent/secrets/secretstore/backends/azure/resolve_test.go
+53
@@ -3,6 +3,7 @@
3 package azure
4
5 import (
6 + "bytes"
7 "context"
8 "errors"
9 "io"
@@ -13,6 +14,7 @@ import (
14
15 "github.com/Azure/azure-sdk-for-go/sdk/azcore"
16 "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
17 + "github.com/netdata/netdata/go/plugins/logger"
18 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
19 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
20 "github.com/stretchr/testify/assert"
@@ -112,3 +114,54 @@ func TestPublishedStoreResolve(t *testing.T) {
114 })
115 }
116 }
117 +
118 +func TestPublishedStoreResolve_LogsDetailedResolution(t *testing.T) {
119 + tokenProvider, err := cloudauth.NewTokenProvider(
120 + fakeTokenCredential{
121 + getToken: func(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
122 + return azcore.AccessToken{
123 + Token: "test-token",
124 + ExpiresOn: time.Now().Add(30 * time.Minute),
125 + }, nil
126 + },
127 + },
128 + []string{azureKeyVaultScope},
129 + time.Minute,
130 + )
131 + require.NoError(t, err)
132 +
133 + s := &publishedStore{
134 + provider: &provider{
135 + apiClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
136 + return &http.Response{
137 + StatusCode: http.StatusOK,
138 + Body: io.NopCloser(strings.NewReader(`{"value":"secret-value"}`)),
139 + Header: make(http.Header),
140 + }, nil
141 + })},
142 + },
143 + tokenProvider: tokenProvider,
144 + }
145 +
146 + out := captureLoggerOutput(t, func(log *logger.Logger) {
147 + ctx := logger.ContextWithLogger(context.Background(), log)
148 + value, err := s.Resolve(ctx, secretstore.ResolveRequest{
149 + StoreKey: "azure-kv:azure_prod",
150 + Operand: "my-vault/my-secret",
151 + Original: "${store:azure-kv:azure_prod:my-vault/my-secret}",
152 + })
153 + require.NoError(t, err)
154 + assert.Equal(t, "secret-value", value)
155 + })
156 +
157 + assert.Contains(t, out, "resolved secret via azure-kv secretstore 'azure-kv:azure_prod' vault 'my-vault' secret 'my-secret'")
158 + assert.NotContains(t, out, "secret-value")
159 +}
160 +
161 +func captureLoggerOutput(t *testing.T, fn func(log *logger.Logger)) string {
162 + t.Helper()
163 +
164 + var buf bytes.Buffer
165 + fn(logger.NewWithWriter(&buf))
166 + return buf.String()
167 +}
src/go/plugin/agent/secrets/secretstore/backends/gcp/resolve.go
+8
@@ -19,6 +19,7 @@ import (
19 "os"
20 "strings"
21
22 + "github.com/netdata/netdata/go/plugins/logger"
23 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
24 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/httpx"
25 )
@@ -80,9 +81,16 @@ func (s *publishedStore) resolve(ctx context.Context, req secretstore.ResolveReq
81 if err != nil {
82 return "", fmt.Errorf("resolving secret '%s': store '%s': decoding secret data: %w", req.Original, req.StoreKey, err)
83 }
84 + logResolvedRequest(ctx, req, project, secretName, version)
85 return string(decoded), nil
86 }
87
88 +func logResolvedRequest(ctx context.Context, req secretstore.ResolveRequest, project, secretName, version string) {
89 + if log, ok := logger.LoggerFromContext(ctx); ok {
90 + log.Infof("resolved secret via gcp-sm secretstore '%s' project '%s' secret '%s' version '%s'", req.StoreKey, project, secretName, version)
91 + }
92 +}
93 +
94 func parseOperand(operand string) (string, string, string, bool) {
95 project, rest, ok := strings.Cut(operand, "/")
96 if !ok || project == "" || rest == "" {
src/go/plugin/agent/secrets/secretstore/backends/gcp/resolve_test.go new
+69
@@ -0,0 +1,69 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package gcp
4 +
5 +import (
6 + "bytes"
7 + "context"
8 + "encoding/base64"
9 + "io"
10 + "net/http"
11 + "testing"
12 +
13 + "github.com/netdata/netdata/go/plugins/logger"
14 + "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
15 + "github.com/stretchr/testify/assert"
16 + "github.com/stretchr/testify/require"
17 +)
18 +
19 +type roundTripFunc func(*http.Request) (*http.Response, error)
20 +
21 +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
22 + return f(req)
23 +}
24 +
25 +func TestPublishedStoreResolve_LogsDetailedResolution(t *testing.T) {
26 + s := &publishedStore{
27 + provider: &provider{
28 + apiClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
29 + assert.Equal(t, "/v1/projects/my-project/secrets/my-secret/versions/latest:access", req.URL.Path)
30 + return &http.Response{
31 + StatusCode: http.StatusOK,
32 + Body: io.NopCloser(bytes.NewBufferString(`{"payload":{"data":"` +
33 + base64.StdEncoding.EncodeToString([]byte("secret-value")) + `"}}`)),
34 + Header: make(http.Header),
35 + }, nil
36 + })},
37 + metadataClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
38 + return &http.Response{
39 + StatusCode: http.StatusOK,
40 + Body: io.NopCloser(bytes.NewBufferString(`{"access_token":"test-token"}`)),
41 + Header: make(http.Header),
42 + }, nil
43 + })},
44 + },
45 + mode: "metadata",
46 + }
47 +
48 + out := captureLoggerOutput(t, func(log *logger.Logger) {
49 + ctx := logger.ContextWithLogger(context.Background(), log)
50 + value, err := s.Resolve(ctx, secretstore.ResolveRequest{
51 + StoreKey: "gcp-sm:gcp_prod",
52 + Operand: "my-project/my-secret/latest",
53 + Original: "${store:gcp-sm:gcp_prod:my-project/my-secret/latest}",
54 + })
55 + require.NoError(t, err)
56 + assert.Equal(t, "secret-value", value)
57 + })
58 +
59 + assert.Contains(t, out, "resolved secret via gcp-sm secretstore 'gcp-sm:gcp_prod' project 'my-project' secret 'my-secret' version 'latest'")
60 + assert.NotContains(t, out, "secret-value")
61 +}
62 +
63 +func captureLoggerOutput(t *testing.T, fn func(log *logger.Logger)) string {
64 + t.Helper()
65 +
66 + var buf bytes.Buffer
67 + fn(logger.NewWithWriter(&buf))
68 + return buf.String()
69 +}
src/go/plugin/agent/secrets/secretstore/backends/vault/resolve.go
+13 -1
@@ -11,6 +11,7 @@ import (
11 "os"
12 "strings"
13
14 + "github.com/netdata/netdata/go/plugins/logger"
15 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
16 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/httpx"
17 )
@@ -68,7 +69,18 @@ func (s *publishedStore) resolve(ctx context.Context, req secretstore.ResolveReq
69 if resp.StatusCode != http.StatusOK {
70 return "", fmt.Errorf("resolving secret '%s': store '%s': vault returned HTTP %d: %s", req.Original, req.StoreKey, resp.StatusCode, httpx.TruncateBody(body))
71 }
71 - return parseResponse(body, key, req)
72 + value, err := parseResponse(body, key, req)
73 + if err != nil {
74 + return "", err
75 + }
76 + logResolvedRequest(ctx, req, path, key)
77 + return value, nil
78 +}
79 +
80 +func logResolvedRequest(ctx context.Context, req secretstore.ResolveRequest, path, key string) {
81 + if log, ok := logger.LoggerFromContext(ctx); ok {
82 + log.Infof("resolved secret via vault secretstore '%s' path '%s' key '%s'", req.StoreKey, path, key)
83 + }
84 }
85
86 func (s *publishedStore) address() (string, error) {
src/go/plugin/agent/secrets/secretstore/backends/vault/resolve_test.go
+57
@@ -3,13 +3,24 @@
3 package vault
4
5 import (
6 + "bytes"
7 + "context"
8 + "io"
9 + "net/http"
10 "testing"
11
12 + "github.com/netdata/netdata/go/plugins/logger"
13 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16 )
17
18 +type roundTripFunc func(*http.Request) (*http.Response, error)
19 +
20 +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
21 + return f(req)
22 +}
23 +
24 func TestParseResponse(t *testing.T) {
25 req := secretstore.ResolveRequest{
26 StoreKey: "vault:vault_prod",
@@ -67,3 +78,49 @@ func TestParseResponse(t *testing.T) {
78 })
79 }
80 }
81 +
82 +func TestPublishedStoreResolve_LogsDetailedResolution(t *testing.T) {
83 + s := &publishedStore{
84 + provider: &provider{
85 + httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
86 + return &http.Response{
87 + StatusCode: http.StatusOK,
88 + Body: io.NopCloser(bytes.NewBufferString(`{"data":{"password":"secret-value"}}`)),
89 + Header: make(http.Header),
90 + }, nil
91 + })},
92 + httpClientInsecure: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
93 + return &http.Response{
94 + StatusCode: http.StatusOK,
95 + Body: io.NopCloser(bytes.NewBufferString(`{"data":{"password":"secret-value"}}`)),
96 + Header: make(http.Header),
97 + }, nil
98 + })},
99 + },
100 + mode: "token",
101 + tokenValue: "vault-token",
102 + addr: "https://vault.example",
103 + }
104 +
105 + out := captureLoggerOutput(t, func(log *logger.Logger) {
106 + ctx := logger.ContextWithLogger(context.Background(), log)
107 + value, err := s.Resolve(ctx, secretstore.ResolveRequest{
108 + StoreKey: "vault:vault_prod",
109 + Operand: "secret/data/mysql#password",
110 + Original: "${store:vault:vault_prod:secret/data/mysql#password}",
111 + })
112 + require.NoError(t, err)
113 + assert.Equal(t, "secret-value", value)
114 + })
115 +
116 + assert.Contains(t, out, "resolved secret via vault secretstore 'vault:vault_prod' path 'secret/data/mysql' key 'password'")
117 + assert.NotContains(t, out, "secret-value")
118 +}
119 +
120 +func captureLoggerOutput(t *testing.T, fn func(log *logger.Logger)) string {
121 + t.Helper()
122 +
123 + var buf bytes.Buffer
124 + fn(logger.NewWithWriter(&buf))
125 + return buf.String()
126 +}
src/go/plugin/agent/secrets/secretstore/config_resolver.go new
+34
@@ -0,0 +1,34 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package secretstore
4 +
5 +import (
6 + "context"
7 + "fmt"
8 +
9 + secretresolver "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/resolver"
10 +)
11 +
12 +func resolveProviderPayload(ctx context.Context, cfg Config) (Config, error) {
13 + if cfg == nil {
14 + return nil, nil
15 + }
16 +
17 + // prepareConfig already deep-clones the raw config before calling here.
18 + // Build a top-level payload view to keep store identity/source metadata static
19 + // while avoiding another YAML round-trip clone for provider payload resolution.
20 + payload := make(Config, len(cfg))
21 + for k, v := range cfg {
22 + switch k {
23 + case keyName, keyKind, ikeySource, ikeySourceType:
24 + continue
25 + default:
26 + payload[k] = v
27 + }
28 + }
29 +
30 + if err := secretresolver.New().ResolveWithStoreResolver(ctx, payload, nil); err != nil {
31 + return nil, fmt.Errorf("resolving provider payload secrets: %w", err)
32 + }
33 + return payload, nil
34 +}
src/go/plugin/agent/secrets/secretstore/provider_parity_test.go
+3 -2
@@ -3,6 +3,7 @@
3 package secretstore_test
4
5 import (
6 + "context"
7 "testing"
8
9 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
@@ -144,10 +145,10 @@ func TestProviderSchemaAndValidationParity(t *testing.T) {
145 tc.assertSchemaShape(t, schemaObj)
146 }
147
147 - err := svc.Validate(newStoreFromConfig(t, svc, tc.kind, tc.valid))
148 + err := svc.Validate(context.Background(), newStoreFromConfig(t, svc, tc.kind, tc.valid))
149 require.NoError(t, err)
150
150 - err = svc.Validate(newStoreFromConfig(t, svc, tc.kind, tc.invalid))
151 + err = svc.Validate(context.Background(), newStoreFromConfig(t, svc, tc.kind, tc.invalid))
152 require.Error(t, err)
153 assert.ErrorContains(t, err, tc.wantErrContains)
154 })
src/go/plugin/agent/secrets/secretstore/runtime_resolver_provider_test.go
+1 -1
@@ -15,7 +15,7 @@ import (
15 func TestProviderBackedRuntimeResolverOperandValidation(t *testing.T) {
16 svc := secretstore.NewService(backends.Creators()...)
17 for _, entry := range providerBackedConfigs() {
18 - err := svc.Add(newStoreFromConfig(t, svc, entry.kind, entry.config))
18 + err := svc.Add(context.Background(), newStoreFromConfig(t, svc, entry.kind, entry.config))
19 require.NoError(t, err)
20 }
21
src/go/plugin/agent/secrets/secretstore/runtime_resolver_test.go
+2 -1
@@ -3,6 +3,7 @@
3 package secretstore_test
4
5 import (
6 + "context"
7 "testing"
8
9 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
@@ -13,7 +14,7 @@ import (
14
15 func TestRuntimeResolverResolveErrors(t *testing.T) {
16 enabledSvc := secretstore.NewService(backends.Creators()...)
16 - err := enabledSvc.Add(newStoreFromConfig(t, enabledSvc, secretstore.KindVault, testSingleVaultConfig()))
17 + err := enabledSvc.Add(context.Background(), newStoreFromConfig(t, enabledSvc, secretstore.KindVault, testSingleVaultConfig()))
18 require.NoError(t, err)
19
20 tests := map[string]struct {
src/go/plugin/agent/secrets/secretstore/service.go
+4 -4
@@ -15,10 +15,10 @@ type Service interface {
15
16 GetStatus(key string) (StoreStatus, bool)
17
18 - Validate(cfg Config) error
19 - ValidateStored(key string) error
20 - Add(cfg Config) error
21 - Update(key string, cfg Config) error
18 + Validate(ctx context.Context, cfg Config) error
19 + ValidateStored(ctx context.Context, key string) error
20 + Add(ctx context.Context, cfg Config) error
21 + Update(ctx context.Context, key string, cfg Config) error
22 Remove(key string) error
23 }
24
src/go/plugin/agent/secrets/secretstore/service_hardening_test.go
+12 -12
@@ -180,12 +180,12 @@ func TestServiceStatusLifecycle(t *testing.T) {
180 Auth: map[string]any{"mode": "token_env"},
181 }, "vault_prod")
182
183 - err := svc.Add(store)
183 + err := svc.Add(context.Background(), store)
184 require.NoError(t, err)
185
186 failInit.Store(true)
187 storeKey := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
188 - err = svc.ValidateStored(storeKey)
188 + err = svc.ValidateStored(context.Background(), storeKey)
189 require.Error(t, err)
190
191 status, ok := svc.GetStatus(storeKey)
@@ -195,7 +195,7 @@ func TestServiceStatusLifecycle(t *testing.T) {
195 assert.Equal(t, "simulated validation error", status.LastErrorSummary)
196
197 failInit.Store(false)
198 - err = svc.ValidateStored(storeKey)
198 + err = svc.ValidateStored(context.Background(), storeKey)
199 require.NoError(t, err)
200
201 status, ok = svc.GetStatus(storeKey)
@@ -213,7 +213,7 @@ func TestServiceResolveHonorsCanceledContext(t *testing.T) {
213 store := newFakeStore(t, svc, secretstore.KindVault, fakeConfig{
214 Auth: map[string]any{"mode": "token_env"},
215 }, "vault_prod")
216 - err := svc.Add(store)
216 + err := svc.Add(context.Background(), store)
217 require.NoError(t, err)
218
219 ctx, cancel := context.WithCancel(context.Background())
@@ -230,7 +230,7 @@ func TestServiceResolve_NormalizesNilContext(t *testing.T) {
230 store := newFakeStore(t, svc, secretstore.KindVault, fakeConfig{
231 Auth: map[string]any{"mode": "token_env"},
232 }, "vault_prod")
233 - err := svc.Add(store)
233 + err := svc.Add(context.Background(), store)
234 require.NoError(t, err)
235
236 val, err := svc.Resolve(nil, svc.Capture(), "vault:vault_prod:secret/data/app#key", "${store:vault:vault_prod:secret/data/app#key}")
@@ -245,7 +245,7 @@ func TestServiceConcurrentResolveAndMutation(t *testing.T) {
245 Auth: map[string]any{"mode": "token_env"},
246 }
247
248 - err := svc.Add(newFakeStore(t, svc, secretstore.KindVault, baseCfg, "vault_prod"))
248 + err := svc.Add(context.Background(), newFakeStore(t, svc, secretstore.KindVault, baseCfg, "vault_prod"))
249 require.NoError(t, err)
250
251 var wg sync.WaitGroup
@@ -279,7 +279,7 @@ func TestServiceConcurrentResolveAndMutation(t *testing.T) {
279 "tag": "alt",
280 }
281 }
282 - if err := svc.Update(secretstore.StoreKey(secretstore.KindVault, "vault_prod"), newFakeStore(t, svc, secretstore.KindVault, updateCfg, "vault_prod")); err != nil {
282 + if err := svc.Update(context.Background(), secretstore.StoreKey(secretstore.KindVault, "vault_prod"), newFakeStore(t, svc, secretstore.KindVault, updateCfg, "vault_prod")); err != nil {
283 errCh <- err
284 return
285 }
@@ -302,13 +302,13 @@ func TestServiceValidateStored_RemovedDuringValidationReturnsNotFound(t *testing
302 store := newFakeStore(t, svc, secretstore.KindVault, fakeConfig{
303 Auth: map[string]any{"mode": "token_env"},
304 }, "vault_prod")
305 - require.NoError(t, svc.Add(store))
305 + require.NoError(t, svc.Add(context.Background(), store))
306
307 storeKey := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
308 errCh := make(chan error, 1)
309
310 go func() {
311 - errCh <- svc.ValidateStored(storeKey)
311 + errCh <- svc.ValidateStored(context.Background(), storeKey)
312 }()
313
314 <-validateStarted
@@ -328,17 +328,17 @@ func TestServiceValidateStored_UpdatedDuringValidationReturnsRetryWithoutOverwri
328 store := newFakeStore(t, svc, secretstore.KindVault, fakeConfig{
329 Auth: map[string]any{"mode": "token_env"},
330 }, "vault_prod")
331 - require.NoError(t, svc.Add(store))
331 + require.NoError(t, svc.Add(context.Background(), store))
332
333 storeKey := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
334 errCh := make(chan error, 1)
335
336 go func() {
337 - errCh <- svc.ValidateStored(storeKey)
337 + errCh <- svc.ValidateStored(context.Background(), storeKey)
338 }()
339
340 <-validateStarted
341 - require.NoError(t, svc.Update(storeKey, newFakeStore(t, svc, secretstore.KindVault, fakeConfig{
341 + require.NoError(t, svc.Update(context.Background(), storeKey, newFakeStore(t, svc, secretstore.KindVault, fakeConfig{
342 Auth: map[string]any{"mode": "token_env", "tag": "new"},
343 }, "vault_prod")))
344 close(validateRelease)
src/go/plugin/agent/secrets/secretstore/service_impl.go
+25 -10
@@ -135,12 +135,12 @@ func (s *inMemoryService) GetStatus(key string) (StoreStatus, bool) {
135 return cloneStoreStatus(record.status), true
136 }
137
138 -func (s *inMemoryService) Validate(cfg Config) error {
139 - _, err := s.prepareConfig(context.Background(), cfg)
138 +func (s *inMemoryService) Validate(ctx context.Context, cfg Config) error {
139 + _, err := s.prepareConfig(ctx, cfg)
140 return err
141 }
142
143 -func (s *inMemoryService) ValidateStored(key string) error {
143 +func (s *inMemoryService) ValidateStored(ctx context.Context, key string) error {
144 key, err := normalizeStoreKey(key)
145 if err != nil {
146 return err
@@ -156,7 +156,7 @@ func (s *inMemoryService) ValidateStored(key string) error {
156 }
157 validatedHash := record.configHash
158
159 - _, err = s.prepareConfig(context.Background(), record.rawConfig)
159 + _, err = s.prepareConfig(ctx, record.rawConfig)
160
161 validation := &ValidationStatus{
162 CheckedAt: s.now().UTC(),
@@ -194,8 +194,8 @@ func (s *inMemoryService) ValidateStored(key string) error {
194 return err
195 }
196
197 -func (s *inMemoryService) Add(cfg Config) error {
198 - prepared, err := s.prepareConfig(context.Background(), cfg)
197 +func (s *inMemoryService) Add(ctx context.Context, cfg Config) error {
198 + prepared, err := s.prepareConfig(ctx, cfg)
199 if err != nil {
200 return err
201 }
@@ -221,7 +221,7 @@ func (s *inMemoryService) Add(cfg Config) error {
221 return nil
222 }
223
224 -func (s *inMemoryService) Update(key string, cfg Config) error {
224 +func (s *inMemoryService) Update(ctx context.Context, key string, cfg Config) error {
225 key, err := normalizeStoreKey(key)
226 if err != nil {
227 return err
@@ -236,7 +236,7 @@ func (s *inMemoryService) Update(key string, cfg Config) error {
236 return storeNotConfiguredError(key)
237 }
238
239 - prepared, err := s.prepareConfig(context.Background(), cfg)
239 + prepared, err := s.prepareConfig(ctx, cfg)
240 if err != nil {
241 return err
242 }
@@ -324,6 +324,12 @@ func (s *inMemoryService) prepareConfig(ctx context.Context, cfg Config) (prepar
324 if err := raw.Validate(); err != nil {
325 return preparedStore{}, err
326 }
327 + rawConfig := cloneConfig(raw)
328 + rawHash := raw.Hash()
329 + resolvedPayload, err := resolveProviderPayload(ctx, raw)
330 + if err != nil {
331 + return preparedStore{}, err
332 + }
333
334 kind := raw.Kind()
335 name := raw.Name()
@@ -341,6 +347,15 @@ func (s *inMemoryService) prepareConfig(ctx context.Context, cfg Config) (prepar
347 if err != nil {
348 return preparedStore{}, fmt.Errorf("store '%s': marshaling raw config: %w", key, err)
349 }
350 + if len(resolvedPayload) != 0 {
351 + for k, v := range resolvedPayload {
352 + raw[k] = v
353 + }
354 + bs, err = yaml.Marshal(raw)
355 + if err != nil {
356 + return preparedStore{}, fmt.Errorf("store '%s': marshaling resolved config: %w", key, err)
357 + }
358 + }
359 if err := yaml.Unmarshal(bs, store.Configuration()); err != nil {
360 return preparedStore{}, fmt.Errorf("store '%s': invalid provider payload: %w", key, err)
361 }
@@ -356,8 +371,8 @@ func (s *inMemoryService) prepareConfig(ctx context.Context, cfg Config) (prepar
371
372 return preparedStore{
373 key: key,
359 - rawConfig: raw,
360 - configHash: raw.Hash(),
374 + rawConfig: rawConfig,
375 + configHash: rawHash,
376 status: StoreStatus{
377 Name: name,
378 Kind: kind,
src/go/plugin/agent/secrets/secretstore/service_impl_test.go
+141 -14
@@ -3,9 +3,14 @@
3 package secretstore_test
4
5 import (
6 + "context"
7 "errors"
8 + "os"
9 + "path/filepath"
10 + "runtime"
11 "testing"
12
13 + "github.com/netdata/netdata/go/plugins/logger"
14 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
15 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/backends"
16 "github.com/stretchr/testify/assert"
@@ -37,7 +42,7 @@ func TestServiceStatusAndGenerationLifecycle(t *testing.T) {
42 svc := secretstore.NewService(backends.Creators()...)
43
44 config := testSingleVaultConfig()
40 - err := svc.Add(newStoreFromConfig(t, svc, secretstore.KindVault, config))
45 + err := svc.Add(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, config))
46 require.NoError(t, err)
47 assert.Equal(t, uint64(1), svc.Capture().Generation())
48
@@ -54,15 +59,15 @@ func TestServiceStatusAndGenerationLifecycle(t *testing.T) {
59 runtimeUpdate["mode_token_file"] = map[string]any{
60 "path": "/var/lib/netdata/vault.token",
61 }
57 - err = svc.Update(storeKey, newStoreFromConfig(t, svc, secretstore.KindVault, runtimeUpdate))
62 + err = svc.Update(context.Background(), storeKey, newStoreFromConfig(t, svc, secretstore.KindVault, runtimeUpdate))
63 require.NoError(t, err)
64 assert.Equal(t, uint64(2), svc.Capture().Generation())
65
61 - err = svc.Update(storeKey, newStoreFromConfig(t, svc, secretstore.KindVault, runtimeUpdate))
66 + err = svc.Update(context.Background(), storeKey, newStoreFromConfig(t, svc, secretstore.KindVault, runtimeUpdate))
67 require.NoError(t, err)
68 assert.Equal(t, uint64(2), svc.Capture().Generation())
69
65 - err = svc.ValidateStored(storeKey)
70 + err = svc.ValidateStored(context.Background(), storeKey)
71 require.NoError(t, err)
72
73 status, ok = svc.GetStatus(storeKey)
@@ -75,13 +80,13 @@ func TestServiceUpdate_UnknownFieldOnlyChangeCountsAsChange(t *testing.T) {
80 svc := secretstore.NewService(backends.Creators()...)
81
82 base := testSingleVaultConfig()
78 - err := svc.Add(newStoreFromConfig(t, svc, secretstore.KindVault, base))
83 + err := svc.Add(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, base))
84 require.NoError(t, err)
85 assert.Equal(t, uint64(1), svc.Capture().Generation())
86
87 changed := testSingleVaultConfig()
88 changed["ui_note"] = "kept"
84 - err = svc.Update(secretstore.StoreKey(secretstore.KindVault, "vault_prod"), newStoreFromConfig(t, svc, secretstore.KindVault, changed))
89 + err = svc.Update(context.Background(), secretstore.StoreKey(secretstore.KindVault, "vault_prod"), newStoreFromConfig(t, svc, secretstore.KindVault, changed))
90 require.NoError(t, err)
91 assert.Equal(t, uint64(2), svc.Capture().Generation())
92 }
@@ -101,22 +106,22 @@ addr: https://vault.example
106 cfg.SetSource("dyncfg")
107 cfg.SetSourceType("dyncfg")
108
104 - require.NoError(t, svc.Validate(cfg))
109 + require.NoError(t, svc.Validate(context.Background(), cfg))
110 }
111
112 func TestServiceUsesSentinelErrors(t *testing.T) {
113 svc := secretstore.NewService(backends.Creators()...)
114
110 - err := svc.Add(newStoreFromConfig(t, svc, secretstore.KindVault, testSingleVaultConfig()))
115 + err := svc.Add(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, testSingleVaultConfig()))
116 require.NoError(t, err)
117
113 - err = svc.Add(newStoreFromConfig(t, svc, secretstore.KindVault, testSingleVaultConfig()))
118 + err = svc.Add(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, testSingleVaultConfig()))
119 require.Error(t, err)
120 assert.ErrorIs(t, err, secretstore.ErrStoreExists)
121
122 missing := testSingleVaultConfig()
123 missing["name"] = "missing"
119 - err = svc.Update(secretstore.StoreKey(secretstore.KindVault, "missing"), newStoreFromConfig(t, svc, secretstore.KindVault, missing))
124 + err = svc.Update(context.Background(), secretstore.StoreKey(secretstore.KindVault, "missing"), newStoreFromConfig(t, svc, secretstore.KindVault, missing))
125 require.Error(t, err)
126 assert.ErrorIs(t, err, secretstore.ErrStoreNotFound)
127
@@ -128,14 +133,14 @@ func TestServiceUsesSentinelErrors(t *testing.T) {
133 func TestProviderBackedValidationContracts(t *testing.T) {
134 svc := secretstore.NewService(backends.Creators()...)
135
131 - err := svc.Validate(newStoreFromConfig(t, svc, secretstore.KindAWSSM, map[string]any{
136 + err := svc.Validate(context.Background(), newStoreFromConfig(t, svc, secretstore.KindAWSSM, map[string]any{
137 "name": "aws_prod",
138 "auth_mode": "env",
139 }))
140 require.Error(t, err)
141 assert.ErrorContains(t, err, "region is required")
142
138 - err = svc.Validate(newStoreFromConfig(t, svc, secretstore.KindVault, map[string]any{
143 + err = svc.Validate(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, map[string]any{
144 "name": "vault_prod",
145 "mode": "token",
146 "mode_token": map[string]any{
@@ -168,15 +173,137 @@ func TestProviderBackedAddAcrossKinds(t *testing.T) {
173 for _, entry := range providerBackedConfigs() {
174 entry := entry
175 t.Run(string(entry.kind), func(t *testing.T) {
171 - err := svc.Add(newStoreFromConfig(t, svc, entry.kind, entry.config))
176 + err := svc.Add(context.Background(), newStoreFromConfig(t, svc, entry.kind, entry.config))
177 require.NoError(t, err)
178
179 status, ok := svc.GetStatus(secretstore.StoreKey(entry.kind, entry.name))
180 require.True(t, ok)
181 assert.Equal(t, entry.kind, status.Kind)
182
178 - err = svc.ValidateStored(secretstore.StoreKey(entry.kind, entry.name))
183 + err = svc.ValidateStored(context.Background(), secretstore.StoreKey(entry.kind, entry.name))
184 require.NoError(t, err)
185 })
186 }
187 }
188 +
189 +func TestServiceValidate_ResolvesBuiltinSecretsInProviderPayload(t *testing.T) {
190 + t.Setenv("TEST_VAULT_MODE", "token")
191 +
192 + modeFile := filepath.Join(t.TempDir(), "vault-mode")
193 + require.NoError(t, os.WriteFile(modeFile, []byte("token\n"), 0o644))
194 +
195 + tests := map[string]struct {
196 + modeRef string
197 + onWindowsSkip bool
198 + }{
199 + "env": {
200 + modeRef: "${env:TEST_VAULT_MODE}",
201 + },
202 + "file": {
203 + modeRef: "${file:" + modeFile + "}",
204 + },
205 + "cmd": {
206 + modeRef: "${cmd:/bin/echo token}",
207 + onWindowsSkip: true,
208 + },
209 + }
210 +
211 + for name, tc := range tests {
212 + t.Run(name, func(t *testing.T) {
213 + if tc.onWindowsSkip && runtime.GOOS == "windows" {
214 + t.Skip("skipping on windows")
215 + }
216 +
217 + svc := secretstore.NewService(backends.Creators()...)
218 +
219 + cfg := testSingleVaultConfig()
220 + cfg["mode"] = tc.modeRef
221 +
222 + require.NoError(t, svc.Validate(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, cfg)))
223 + })
224 + }
225 +}
226 +
227 +func TestServiceValidate_ResolvesBuiltinSecretsInNestedProviderPayload(t *testing.T) {
228 + t.Setenv("TEST_VAULT_TOKEN", "vault-token")
229 +
230 + svc := secretstore.NewService(backends.Creators()...)
231 +
232 + cfg := testSingleVaultConfig()
233 + cfg["mode_token"] = map[string]any{
234 + "token": "${env:TEST_VAULT_TOKEN}",
235 + }
236 +
237 + require.NoError(t, svc.Validate(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, cfg)))
238 +}
239 +
240 +func TestServiceAddUpdate_ResolvesBuiltinSecretsInProviderPayload(t *testing.T) {
241 + t.Setenv("TEST_VAULT_MODE", "token")
242 +
243 + modeFile := filepath.Join(t.TempDir(), "vault-mode")
244 + require.NoError(t, os.WriteFile(modeFile, []byte("token\n"), 0o644))
245 +
246 + svc := secretstore.NewService(backends.Creators()...)
247 + storeKey := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
248 +
249 + envCfg := testSingleVaultConfig()
250 + envCfg["mode"] = "${env:TEST_VAULT_MODE}"
251 + require.NoError(t, svc.Add(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, envCfg)))
252 + assert.Equal(t, uint64(1), svc.Capture().Generation())
253 +
254 + fileCfg := testSingleVaultConfig()
255 + fileCfg["mode"] = "${file:" + modeFile + "}"
256 + require.NoError(t, svc.Update(context.Background(), storeKey, newStoreFromConfig(t, svc, secretstore.KindVault, fileCfg)))
257 + assert.Equal(t, uint64(2), svc.Capture().Generation())
258 +
259 + t.Run("cmd", func(t *testing.T) {
260 + if runtime.GOOS == "windows" {
261 + t.Skip("skipping on windows")
262 + }
263 +
264 + cmdCfg := testSingleVaultConfig()
265 + cmdCfg["mode"] = "${cmd:/bin/echo token}"
266 + require.NoError(t, svc.Update(context.Background(), storeKey, newStoreFromConfig(t, svc, secretstore.KindVault, cmdCfg)))
267 + assert.Equal(t, uint64(3), svc.Capture().Generation())
268 + })
269 +}
270 +
271 +func TestServiceValidate_RejectsStoreRefsInProviderPayload(t *testing.T) {
272 + svc := secretstore.NewService(backends.Creators()...)
273 +
274 + cfg := testSingleVaultConfig()
275 + cfg["mode"] = "${store:vault:vault_prod:value}"
276 +
277 + err := svc.Validate(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, cfg))
278 + require.Error(t, err)
279 + assert.ErrorContains(t, err, "secretstore resolver is not configured")
280 +}
281 +
282 +func TestServiceValidate_KeepsMetadataStatic(t *testing.T) {
283 + t.Setenv("TEST_STORE_NAME", "vault_prod")
284 +
285 + svc := secretstore.NewService(backends.Creators()...)
286 +
287 + cfg := testSingleVaultConfig()
288 + cfg["name"] = "${env:TEST_STORE_NAME}"
289 +
290 + err := svc.Validate(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, cfg))
291 + require.Error(t, err)
292 + assert.ErrorContains(t, err, "invalid store name")
293 +}
294 +
295 +func TestServiceValidate_LogsBuiltinResolutionWithContext(t *testing.T) {
296 + t.Setenv("TEST_VAULT_MODE", "token")
297 +
298 + svc := secretstore.NewService(backends.Creators()...)
299 + cfg := testSingleVaultConfig()
300 + cfg["mode"] = "${env:TEST_VAULT_MODE}"
301 +
302 + out := captureLoggerOutput(t, func(log *logger.Logger) {
303 + ctx := logger.ContextWithLogger(context.Background(), log)
304 + require.NoError(t, svc.Validate(ctx, newStoreFromConfig(t, svc, secretstore.KindVault, cfg)))
305 + })
306 +
307 + assert.Contains(t, out, "resolved secret via env variable 'TEST_VAULT_MODE'")
308 + assert.NotContains(t, out, "token")
309 +}
src/go/plugin/agent/secrets/secretstore/test_helpers_test.go
+11
@@ -3,9 +3,11 @@
3 package secretstore_test
4
5 import (
6 + "bytes"
7 "encoding/json"
8 "testing"
9
10 + "github.com/netdata/netdata/go/plugins/logger"
11 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
12 "github.com/stretchr/testify/require"
13 )
@@ -134,3 +136,12 @@ func cloneTestSlice(in []any) []any {
136 }
137 return out
138 }
139 +
140 +func captureLoggerOutput(t *testing.T, fn func(log *logger.Logger)) string {
141 + t.Helper()
142 +
143 + var buf bytes.Buffer
144 + log := logger.NewWithWriter(&buf)
145 + fn(log)
146 + return buf.String()
147 +}