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
+}