feat(go.d): implement secretstore-based secrets management (#21951)
Ilya Mashchenko committed
Mar 18, 2026 at 23:11 UTC
87946808680b0248c6c19314e2924e7fa30c0136
110 files changed
+14302
-2232
docs/.map/map.yaml
+4
@@ -225,6 +225,10 @@ sidebar:
225
- meta:
226
label: H2O
227
edit_url: https://github.com/netdata/netdata/edit/master/docs/netdata-agent/configuration/running-the-netdata-agent-behind-a-reverse-proxy/Running-behind-h2o.md
228
+ - meta:
229
+ label: Secrets Management
230
+ edit_url: https://github.com/netdata/netdata/edit/master/docs/netdata-agent/configuration/secrets-management.md
231
+ description: Use secret references to avoid storing credentials in plain text. Supports environment variables, files, and external vaults.
232
- meta:
233
label: Performance Optimization
234
edit_url: https://github.com/netdata/netdata/edit/master/docs/netdata-agent/configuration/optimize-the-netdata-agents-performance.md
docs/netdata-agent/configuration/secrets-management.md
new
+143
@@ -0,0 +1,143 @@
1
+# Secrets Management
2
+
3
+Netdata supports secrets management for collector configurations, so you don't need to store plain-text credentials in configuration files. Instead, you use secret references that are resolved when a collector starts.
4
+
5
+| Reference Type | Syntax | Use Case |
6
+|:---------------------|:-----------------------------------|:-----------------------------------------------------------|
7
+| Environment variable | `${env:VAR_NAME}` | Secrets available as environment variables |
8
+| File | `${file:/path/to/secret}` | Secrets stored in files on disk |
9
+| Command | `${cmd:/path/to/command args}` | Secrets retrieved by running a command |
10
+| Secretstore | `${store:<kind>:<name>:<operand>}` | Secrets stored in remote backends (Vault, AWS, Azure, GCP) |
11
+
12
+## Environment Variables
13
+
14
+Use `${env:VARIABLE_NAME}` to reference an environment variable.
15
+
16
+```yaml
17
+jobs:
18
+ - name: local
19
+ dsn: "${env:MYSQL_USER}:${env:MYSQL_PASSWORD}@tcp(127.0.0.1:3306)/"
20
+```
21
+
22
+## Files
23
+
24
+Use `${file:/absolute/path}` to read a secret from a file. Leading and trailing whitespace is trimmed automatically.
25
+
26
+```yaml
27
+jobs:
28
+ - name: myapp
29
+ password: "${file:/run/secrets/myapp_password}"
30
+```
31
+
32
+## Commands
33
+
34
+Use `${cmd:/absolute/path/to/command args}` to execute a command and use its stdout as the secret value.
35
+
36
+```yaml
37
+jobs:
38
+ - name: prod
39
+ password: "${cmd:/usr/bin/op read op://vault/netdata/mysql/password}"
40
+```
41
+
42
+:::warning
43
+
44
+- Command paths must be absolute.
45
+- Commands have a 10-second timeout.
46
+- Arguments are split on whitespace. Quoting, pipes, redirects, and variable expansion are not interpreted unless you run a shell explicitly (e.g., `${cmd:/bin/sh -c "your command here"}`).
47
+
48
+:::
49
+
50
+## Secretstores
51
+
52
+For remote secret backends (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, GCP Secret Manager), you configure a **secretstore** and then reference it from collector configurations.
53
+
54
+### Supported Providers
55
+
56
+| Kind | Provider | Operand Format | Example Operand |
57
+|:-----------|:--------------------|:---------------------------------------------|:-------------------------------------|
58
+| `vault` | HashiCorp Vault | `path#key` | `secret/data/netdata/mysql#password` |
59
+| `aws-sm` | AWS Secrets Manager | `secret-name` or `secret-name#key` | `netdata/mysql#password` |
60
+| `azure-kv` | Azure Key Vault | `vault-name/secret-name` | `my-keyvault/mysql-password` |
61
+| `gcp-sm` | GCP Secret Manager | `project/secret` or `project/secret/version` | `my-project/mysql-password` |
62
+
63
+### Reference Format
64
+
65
+```text
66
+${store:<kind>:<name>:<operand>}
67
+```
68
+
69
+| Part | Description |
70
+|:----------|:---------------------------------------------------------|
71
+| `kind` | Provider kind from the table above (e.g., `vault`) |
72
+| `name` | The name you gave the secretstore when you configured it |
73
+| `operand` | Provider-specific path to the secret (see table above) |
74
+
75
+### Examples
76
+
77
+```yaml
78
+jobs:
79
+ - name: mysql_prod
80
+ password: "${store:vault:vault_prod:secret/data/netdata/mysql#password}"
81
+
82
+ - name: redis_prod
83
+ password: "${store:aws-sm:aws_prod:netdata/redis#password}"
84
+
85
+ - name: api_prod
86
+ token: "${store:azure-kv:azure_prod:my-vault/api-token}"
87
+
88
+ - name: app_prod
89
+ password: "${store:gcp-sm:gcp_prod:my-project/mysql-password}"
90
+```
91
+
92
+### Configuring a Secretstore
93
+
94
+#### Option 1: Dynamic Configuration (UI)
95
+
96
+1. Open the Netdata Dynamic Configuration UI.
97
+2. Choose a provider kind and give your secretstore a name.
98
+3. Fill in the provider-specific settings (address, credentials, etc.).
99
+4. Use the reference syntax `${store:<kind>:<name>:<operand>}` in your collector configs.
100
+
101
+#### Option 2: Configuration Files
102
+
103
+You can define secretstores in configuration files. Each provider has its own file:
104
+
105
+| File | Provider |
106
+|:-------------------------------------|:--------------------|
107
+| `/etc/netdata/go.d/ss/vault.conf` | HashiCorp Vault |
108
+| `/etc/netdata/go.d/ss/aws-sm.conf` | AWS Secrets Manager |
109
+| `/etc/netdata/go.d/ss/azure-kv.conf` | Azure Key Vault |
110
+| `/etc/netdata/go.d/ss/gcp-sm.conf` | GCP Secret Manager |
111
+
112
+Each file contains a `jobs` array. The provider kind is determined by the filename.
113
+
114
+Example (`/etc/netdata/go.d/ss/vault.conf`):
115
+
116
+```yaml
117
+jobs:
118
+ - name: vault_prod
119
+ mode: token
120
+ mode_token:
121
+ token: your-vault-token
122
+ addr: https://vault.example.com
123
+```
124
+
125
+:::note
126
+
127
+File-based secretstores are loaded at agent startup. If you edit these files, restart the Netdata Agent to apply the changes.
128
+
129
+:::
130
+
131
+## How It Works
132
+
133
+- Secrets are resolved each time a collector job starts or restarts.
134
+- If a secret cannot be resolved, the collector job will fail to start and log an error.
135
+- Updating a secretstore automatically restarts running and failed collector jobs that use it, so they pick up the new credentials.
136
+- Accepted or disabled jobs keep their state and use the updated secretstore the next time they start.
137
+- If a secretstore change applies successfully but some dependent collector restarts fail, the command reports those restart failures.
138
+
139
+:::tip
140
+
141
+Avoid storing plain-text credentials in collector configurations. Use environment variables, files, commands, or secretstores instead.
142
+
143
+:::
src/go/plugin/agent/agent.go
+1
@@ -247,6 +247,7 @@ func (a *Agent) run(ctx context.Context) {
247
VarLibDir: a.VarLibDir,
248
FnReg: fnMgr,
249
Vnodes: a.setupVnodeRegistry(),
250
+ SecretStores: a.setupSecretStoreConfigs(),
251
AuditMode: a.auditDuration > 0,
252
AuditAnalyzer: a.auditAnalyzer,
253
AuditDataDir: a.auditDataDir,
src/go/plugin/agent/jobmgr/cache.go
+2
@@ -41,6 +41,8 @@ type (
41
}
42
43
retryingTasks struct {
44
+ // retryingTasks is intentionally lock-free. All access must remain serialized
45
+ // through manager-owned command flow and synchronous dyncfg handler callbacks.
46
// [cfg.UID()]
47
items map[string]*retryTask
48
}
src/go/plugin/agent/jobmgr/config_apply.go
new
+88
@@ -0,0 +1,88 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package jobmgr
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+
9
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/resolver"
10
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
11
+ "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
12
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
13
+ "gopkg.in/yaml.v2"
14
+)
15
+
16
+func newConfigModule(creator collectorapi.Creator) (configModule, error) {
17
+ if creator.CreateV2 != nil {
18
+ mod := creator.CreateV2()
19
+ if mod == nil {
20
+ return nil, fmt.Errorf("CreateV2 returned nil")
21
+ }
22
+ return mod, nil
23
+ }
24
+ if creator.Create == nil {
25
+ return nil, fmt.Errorf("no module creator is defined")
26
+ }
27
+ mod := creator.Create()
28
+ if mod == nil {
29
+ return nil, fmt.Errorf("Create returned nil")
30
+ }
31
+ return mod, nil
32
+}
33
+
34
+func applyConfig(
35
+ ctx context.Context,
36
+ cfg confgroup.Config,
37
+ module any,
38
+ resolver *secretresolver.Resolver,
39
+ storeService secretstore.Service,
40
+ storeSnapshot *secretstore.Snapshot,
41
+) error {
42
+ if resolver == nil {
43
+ return fmt.Errorf("secret resolver is nil")
44
+ }
45
+ cfgResolved, err := cfg.Clone()
46
+ if err != nil {
47
+ return fmt.Errorf("cloning config: %w", err)
48
+ }
49
+ storeResolver := secretresolver.StoreRefResolver(nil)
50
+ if storeService != nil {
51
+ storeResolver = func(resolveCtx context.Context, ref, original string) (string, error) {
52
+ if resolveCtx == nil {
53
+ resolveCtx = ctx
54
+ }
55
+ return storeService.Resolve(resolveCtx, storeSnapshot, ref, original)
56
+ }
57
+ }
58
+ if err := resolver.ResolveWithStoreResolver(ctx, cfgResolved, storeResolver); err != nil {
59
+ return fmt.Errorf("resolving secrets: %w", err)
60
+ }
61
+ bs, err := yaml.Marshal(cfgResolved)
62
+ if err != nil {
63
+ return err
64
+ }
65
+ return yaml.Unmarshal(bs, module)
66
+}
67
+
68
+// applyConfigRaw applies config without resolving secrets.
69
+// Used by dyncfg get to avoid exposing resolved secret values.
70
+func applyConfigRaw(cfg confgroup.Config, module any) error {
71
+ bs, err := yaml.Marshal(cfg)
72
+ if err != nil {
73
+ return err
74
+ }
75
+ return yaml.Unmarshal(bs, module)
76
+}
77
+
78
+func makeLabels(cfg confgroup.Config) map[string]string {
79
+ labels := make(map[string]string)
80
+ for name, value := range cfg.Labels() {
81
+ n, ok1 := name.(string)
82
+ v, ok2 := value.(string)
83
+ if ok1 && ok2 {
84
+ labels[n] = v
85
+ }
86
+ }
87
+ return labels
88
+}
src/go/plugin/agent/jobmgr/dyncfg.go
+12
-12
@@ -11,13 +11,13 @@ import (
11
func (m *Manager) dyncfgConfig(fn dyncfg.Function) {
12
if err := fn.ValidateArgs(2); err != nil {
13
m.Warningf("dyncfg: %v", err)
14
- m.dyncfgApi.SendCodef(fn, 400, "%v", err)
14
+ m.dyncfgResponder.SendCodef(fn, 400, "%v", err)
15
return
16
}
17
18
select {
19
case <-m.ctx.Done():
20
- m.dyncfgApi.SendCodef(fn, 503, "Job manager is shutting down.")
20
+ m.dyncfgResponder.SendCodef(fn, 503, "Job manager is shutting down.")
21
return
22
default:
23
}
@@ -26,27 +26,27 @@ func (m *Manager) dyncfgConfig(fn dyncfg.Function) {
26
}
27
28
func (m *Manager) dyncfgQueuedExec(fn dyncfg.Function) {
29
- id := fn.ID()
30
-
29
switch {
32
- case strings.HasPrefix(id, m.dyncfgCollectorPrefixValue()):
30
+ case strings.HasPrefix(fn.ID(), m.dyncfgSecretStorePrefixValue()):
31
+ m.dyncfgSecretStoreExec(fn)
32
+ case strings.HasPrefix(fn.ID(), m.dyncfgCollectorPrefixValue()):
33
m.dyncfgCollectorExec(fn)
34
- case strings.HasPrefix(id, m.dyncfgVnodePrefixValue()):
34
+ case strings.HasPrefix(fn.ID(), m.dyncfgVnodePrefixValue()):
35
m.dyncfgVnodeExec(fn)
36
default:
37
- m.dyncfgApi.SendCodef(fn, 503, "unknown function '%s' (%s).", fn.Fn().Name, id)
37
+ m.dyncfgResponder.SendCodef(fn, 503, "unknown function '%s' (%s).", fn.Fn().Name, fn.ID())
38
}
39
}
40
41
func (m *Manager) dyncfgSeqExec(fn dyncfg.Function) {
42
- id := fn.ID()
43
-
42
switch {
45
- case strings.HasPrefix(id, m.dyncfgCollectorPrefixValue()):
43
+ case strings.HasPrefix(fn.ID(), m.dyncfgSecretStorePrefixValue()):
44
+ m.dyncfgSecretStoreSeqExec(fn)
45
+ case strings.HasPrefix(fn.ID(), m.dyncfgCollectorPrefixValue()):
46
m.dyncfgCollectorSeqExec(fn)
47
- case strings.HasPrefix(id, m.dyncfgVnodePrefixValue()):
47
+ case strings.HasPrefix(fn.ID(), m.dyncfgVnodePrefixValue()):
48
m.dyncfgVnodeSeqExec(fn)
49
default:
50
- m.dyncfgApi.SendCodef(fn, 503, "unknown function '%s' (%s).", fn.Fn().Name, id)
50
+ m.dyncfgResponder.SendCodef(fn, 503, "unknown function '%s' (%s).", fn.Fn().Name, fn.ID())
51
}
52
}
src/go/plugin/agent/jobmgr/dyncfg_collector.go
+13
-484
@@ -3,19 +3,9 @@
3
package jobmgr
4
5
import (
6
- "context"
7
- "encoding/json"
6
"fmt"
9
- "log/slog"
10
- "slices"
11
- "strings"
12
- "time"
7
14
- "gopkg.in/yaml.v2"
15
-
16
- "github.com/netdata/netdata/go/plugins/logger"
8
"github.com/netdata/netdata/go/plugins/pkg/netdataapi"
18
- "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
9
"github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
10
"github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
11
)
@@ -48,7 +38,7 @@ func dyncfgCollectorModCmds() string {
38
}
39
40
func (m *Manager) dyncfgCollectorModuleCreate(name string) {
51
- m.dyncfgApi.ConfigCreate(netdataapi.ConfigOpts{
41
+ m.dyncfgResponder.ConfigCreate(netdataapi.ConfigOpts{
42
ID: m.dyncfgModID(name),
43
Status: dyncfg.StatusAccepted.String(),
44
ConfigType: dyncfg.ConfigTypeTemplate.String(),
@@ -65,15 +55,7 @@ func (m *Manager) exposedLookupByName(module, job string) (*dyncfg.Entry[confgro
55
if module == job {
56
key = job
57
}
68
- return m.exposed.LookupByKey(key)
69
-}
70
-
71
-type dyncfgCmdTestTask struct {
72
- fn dyncfg.Function
73
- moduleName string
74
- creator collectorapi.Creator
75
- cfg confgroup.Config
76
- timeout time.Duration
58
+ return m.collectorExposed.LookupByKey(key)
59
}
60
61
func (m *Manager) dyncfgCollectorExec(fn dyncfg.Function) {
@@ -90,21 +72,24 @@ func (m *Manager) dyncfgCollectorExec(fn dyncfg.Function) {
72
73
func (m *Manager) dyncfgCollectorSeqExec(fn dyncfg.Function) {
74
cmd := fn.Command()
93
- m.handler.SyncDecision(fn)
75
+ m.collectorHandler.SyncDecision(fn)
76
77
switch cmd {
78
case dyncfg.CommandAdd:
97
- m.handler.CmdAdd(fn)
79
+ m.collectorHandler.CmdAdd(fn)
80
+ m.syncSecretStoreDepsByFunction(fn)
81
case dyncfg.CommandUpdate:
99
- m.handler.CmdUpdate(fn)
82
+ m.collectorHandler.CmdUpdate(fn)
83
+ m.syncSecretStoreDepsByFunction(fn)
84
case dyncfg.CommandEnable:
101
- m.handler.CmdEnable(fn)
85
+ m.collectorHandler.CmdEnable(fn)
86
case dyncfg.CommandDisable:
103
- m.handler.CmdDisable(fn)
87
+ m.collectorHandler.CmdDisable(fn)
88
case dyncfg.CommandRemove:
105
- m.handler.CmdRemove(fn)
89
+ m.collectorHandler.CmdRemove(fn)
90
+ m.syncSecretStoreDepsByFunction(fn)
91
case dyncfg.CommandRestart:
107
- m.handler.CmdRestart(fn)
92
+ m.collectorHandler.CmdRestart(fn)
93
case dyncfg.CommandTest:
94
m.dyncfgCmdTest(fn)
95
case dyncfg.CommandSchema:
@@ -113,462 +98,6 @@ func (m *Manager) dyncfgCollectorSeqExec(fn dyncfg.Function) {
98
m.dyncfgCmdGet(fn)
99
default:
100
m.Warningf("dyncfg: function '%s' command '%s' not implemented", fn.Fn().Name, cmd)
116
- m.dyncfgApi.SendCodef(fn, 501, "Function '%s' command '%s' is not implemented.", fn.Fn().Name, cmd)
117
- }
118
-}
119
-
120
-func (m *Manager) dyncfgCmdUserconfig(fn dyncfg.Function) {
121
- cmd := fn.Command()
122
-
123
- id := fn.ID()
124
- jn := fn.JobName()
125
- if jn == "" {
126
- jn = "test"
127
- }
128
-
129
- mn, ok := m.extractModuleName(id)
130
- if !ok {
131
- m.Warningf("dyncfg: %s: could not extract module and job from id (%s)", cmd, id)
132
- m.dyncfgApi.SendCodef(fn, 400, "Invalid ID format. Could not extract module and job name from ID. Provided ID: %s.", id)
133
- return
134
- }
135
-
136
- creator, ok := m.modules.Lookup(mn)
137
- if !ok {
138
- m.Warningf("dyncfg: %s: module %s not found", cmd, mn)
139
- m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' is not registered.", mn)
140
- return
141
- }
142
-
143
- if creator.Config == nil || creator.Config() == nil {
144
- m.Warningf("dyncfg: %s: module %s: configuration not found", cmd, mn)
145
- m.dyncfgApi.SendCodef(fn, 500, "Module %s does not provide configuration.", mn)
146
- return
147
- }
148
-
149
- bs, err := userConfigFromPayload(creator.Config(), jn, fn)
150
- if err != nil {
151
- m.Warningf("dyncfg: %s: module %s: failed to create config from payload: %v", cmd, mn, err)
152
- m.dyncfgApi.SendCodef(fn, 400, "Invalid configuration format. Failed to create configuration from payload: %v.", err)
153
- return
154
- }
155
-
156
- m.dyncfgApi.SendYAML(fn, string(bs))
157
-}
158
-
159
-func (m *Manager) dyncfgCmdTest(fn dyncfg.Function) {
160
- cmd := fn.Command()
161
-
162
- id := fn.ID()
163
- mn, ok := m.extractModuleName(id)
164
- if !ok {
165
- m.Warningf("dyncfg: %s: could not extract module and job from id (%s)", cmd, id)
166
- m.dyncfgApi.SendCodef(fn, 400, "Invalid ID format. Could not extract module and job name from ID. Provided ID: %s.", id)
167
- return
168
- }
169
-
170
- jn := fn.JobName()
171
- if jn == "" {
172
- jn = "test"
173
- }
174
-
175
- m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
176
-
177
- if err := dyncfg.ValidateJobName(jn); err != nil {
178
- m.Warningf("dyncfg: %s: module %s: unacceptable job name '%s': %v", cmd, mn, jn, err)
179
- m.dyncfgApi.SendCodef(fn, 400, "Unacceptable job name '%s': %v.", jn, err)
180
- return
181
- }
182
-
183
- creator, ok := m.modules.Lookup(mn)
184
- if !ok {
185
- m.Warningf("dyncfg: %s: module %s not found", cmd, mn)
186
- m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' is not registered.", mn)
187
- return
188
- }
189
- cfg, err := configFromPayload(fn)
190
- if err != nil {
191
- m.Warningf("dyncfg: %s: module %s: failed to create config from payload: %v", cmd, mn, err)
192
- m.dyncfgApi.SendCodef(fn, 400, "Invalid configuration format. Failed to create configuration from payload: %v.", err)
193
- return
194
- }
195
-
196
- if cfg.Vnode() != "" {
197
- if _, ok := m.vnodes.Lookup(cfg.Vnode()); !ok {
198
- m.Warningf("dyncfg: %s: module %s: vnode %s not found", cmd, mn, cfg.Vnode())
199
- m.dyncfgApi.SendCodef(fn, 400, "The specified vnode '%s' is not registered.", cfg.Vnode())
200
- return
201
- }
202
- }
203
-
204
- cfg.SetModule(mn)
205
- cfg.SetName(jn)
206
-
207
- if err := m.baseContext().Err(); err != nil {
208
- m.dyncfgApi.SendCodef(fn, 503, "Job manager is shutting down.")
209
- return
210
- }
211
-
212
- select {
213
- case m.cmdTestSem <- struct{}{}:
214
- task := dyncfgCmdTestTask{
215
- fn: fn,
216
- moduleName: mn,
217
- creator: creator,
218
- cfg: cfg,
219
- timeout: m.dyncfgCmdTestTimeout(fn),
220
- }
221
- m.cmdTestWG.Go(func() {
222
- m.runDyncfgCmdTest(task)
223
- })
224
- default:
225
- m.Warningf("dyncfg: %s: module %s: too many concurrent test requests", cmd, mn)
226
- m.dyncfgApi.SendCodef(fn, 503, "Too many concurrent test requests, try again later.")
227
- }
228
-}
229
-
230
-func (m *Manager) runDyncfgCmdTest(task dyncfgCmdTestTask) {
231
- defer func() { <-m.cmdTestSem }()
232
-
233
- job, err := newConfigModule(task.creator)
234
- if err != nil {
235
- m.Warningf("dyncfg: test: module %s: failed to create module: %v", task.moduleName, err)
236
- m.dyncfgApi.SendCodef(task.fn, 500, "Module %s instantiation failed: %v.", task.moduleName, err)
237
- return
238
- }
239
-
240
- cleanupCtx, cleanupCancel := context.WithTimeout(m.baseContext(), cmdTestWorkerDrainWait)
241
- defer cleanupCancel()
242
- defer job.Cleanup(cleanupCtx)
243
-
244
- if err := applyConfig(task.cfg, job); err != nil {
245
- m.Warningf("dyncfg: test: module %s: failed to apply config: %v", task.moduleName, err)
246
- m.dyncfgApi.SendCodef(task.fn, 400, "Invalid configuration. Failed to apply configuration: %v.", err)
247
- return
248
- }
249
-
250
- job.GetBase().Logger = logger.New().With(
251
- slog.String("collector", task.cfg.Module()),
252
- slog.String("job", task.cfg.Name()),
253
- )
254
-
255
- ctx, cancel := context.WithTimeout(m.baseContext(), task.timeout)
256
- defer cancel()
257
-
258
- if err := job.Init(ctx); err != nil {
259
- m.dyncfgApi.SendCodef(task.fn, 422, "Job initialization failed: %v", err)
260
- return
261
- }
262
- if err := job.Check(ctx); err != nil {
263
- m.dyncfgApi.SendCodef(task.fn, 422, "Job check failed: %v", err)
264
- return
265
- }
266
-
267
- m.dyncfgApi.SendCodef(task.fn, 200, "")
268
-}
269
-
270
-func (m *Manager) dyncfgCmdTestTimeout(fn dyncfg.Function) time.Duration {
271
- if timeout := fn.Fn().Timeout; timeout > 0 {
272
- return timeout
273
- }
274
- return cmdTestDefaultTimeout
275
-}
276
-
277
-func (m *Manager) dyncfgCmdSchema(fn dyncfg.Function) {
278
- cmd := fn.Command()
279
-
280
- id := fn.ID()
281
- mn, ok := m.extractModuleName(id)
282
- if !ok {
283
- m.Warningf("dyncfg: %s: could not extract module from id (%s)", cmd, id)
284
- m.dyncfgApi.SendCodef(fn, 400, "Invalid ID format. Could not extract module name from ID. Provided ID: %s.", id)
285
- return
286
- }
287
-
288
- mod, ok := m.modules.Lookup(mn)
289
- if !ok {
290
- m.Warningf("dyncfg: %s: module %s not found", cmd, mn)
291
- m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' is not registered.", mn)
292
- return
293
- }
294
-
295
- m.Infof("dyncfg: %s: %s module by user '%s'", cmd, mn, fn.User())
296
-
297
- if mod.JobConfigSchema == "" {
298
- m.Warningf("dyncfg: schema: module %s: schema not found", mn)
299
- m.dyncfgApi.SendCodef(fn, 500, "Module %s configuration schema not found.", mn)
300
- return
301
- }
302
-
303
- m.dyncfgApi.SendJSON(fn, mod.JobConfigSchema)
304
-}
305
-
306
-func (m *Manager) dyncfgCmdGet(fn dyncfg.Function) {
307
- cmd := fn.Command()
308
-
309
- id := fn.ID()
310
- mn, jn, ok := m.extractModuleJobName(id)
311
- if !ok {
312
- m.Warningf("dyncfg: %s: could not extract module and job from id (%s)", cmd, id)
313
- m.dyncfgApi.SendCodef(fn, 400, "Invalid ID format. Could not extract module and job name from ID. Provided ID: %s.", id)
314
- return
315
- }
316
-
317
- creator, ok := m.modules.Lookup(mn)
318
- if !ok {
319
- m.Warningf("dyncfg: %s: module %s not found", cmd, mn)
320
- m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' is not registered.", mn)
321
- return
322
- }
323
-
324
- m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
325
-
326
- entry, ok := m.exposedLookupByName(mn, jn)
327
- if !ok {
328
- m.Warningf("dyncfg: %s: module %s job %s not found", cmd, mn, jn)
329
- m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' job '%s' is not registered.", mn, jn)
330
- return
101
+ m.dyncfgResponder.SendCodef(fn, 501, "Function '%s' command '%s' is not implemented.", fn.Fn().Name, cmd)
102
}
332
-
333
- mod, err := newConfigModule(creator)
334
- if err != nil {
335
- m.Warningf("dyncfg: %s: module %s job %s failed to create module: %v", cmd, mn, jn, err)
336
- m.dyncfgApi.SendCodef(fn, 500, "Module %s instantiation failed: %v.", mn, err)
337
- return
338
- }
339
-
340
- if err := applyConfig(entry.Cfg, mod); err != nil {
341
- m.Warningf("dyncfg: %s: module %s job %s failed to apply config: %v", cmd, mn, jn, err)
342
- m.dyncfgApi.SendCodef(fn, 400, "Invalid configuration. Failed to apply configuration: %v.", err)
343
- return
344
- }
345
-
346
- conf := mod.Configuration()
347
- if conf == nil {
348
- m.Warningf("dyncfg: %s: module %s: configuration not found", cmd, mn)
349
- m.dyncfgApi.SendCodef(fn, 500, "Module %s does not provide configuration.", mn)
350
- return
351
- }
352
-
353
- bs, err := json.Marshal(conf)
354
- if err != nil {
355
- m.Warningf("dyncfg: %s: module %s job %s failed to json marshal config: %v", cmd, mn, jn, err)
356
- m.dyncfgApi.SendCodef(fn, 500, "Failed to convert configuration into JSON: %v.", err)
357
- return
358
- }
359
-
360
- m.dyncfgApi.SendJSON(fn, string(bs))
361
-}
362
-
363
-func (m *Manager) dyncfgSetConfigMeta(cfg confgroup.Config, module, name string, fn dyncfg.Function) {
364
- cfg.SetProvider("dyncfg")
365
- cfg.SetSource(fn.Source())
366
- cfg.SetSourceType("dyncfg")
367
- cfg.SetModule(module)
368
- cfg.SetName(name)
369
- if def, ok := m.configDefaults.Lookup(module); ok {
370
- cfg.ApplyDefaults(def)
371
- }
372
-}
373
-
374
-// scheduleRetryTask schedules a retry if the job supports auto-detection retry.
375
-func (m *Manager) scheduleRetryTask(cfg confgroup.Config, job runtimeJob) {
376
- if !job.RetryAutoDetection() {
377
- return
378
- }
379
- m.Infof("%s[%s] job detection failed, will retry in %d seconds",
380
- cfg.Module(), cfg.Name(), job.AutoDetectionEvery())
381
-
382
- ctx, cancel := context.WithCancel(m.ctx)
383
- m.retryingTasks.add(cfg, &retryTask{cancel: cancel})
384
-
385
- go runRetryTask(ctx, m.addCh, cfg)
386
-}
387
-
388
-func userConfigFromPayload(cfg any, jobName string, fn dyncfg.Function) ([]byte, error) {
389
- if err := fn.UnmarshalPayload(cfg); err != nil {
390
- return nil, err
391
- }
392
-
393
- bs, err := yaml.Marshal(cfg)
394
- if err != nil {
395
- return nil, err
396
- }
397
-
398
- var yms yaml.MapSlice
399
- if err := yaml.Unmarshal(bs, &yms); err != nil {
400
- return nil, err
401
- }
402
-
403
- yms = slices.DeleteFunc(yms, func(item yaml.MapItem) bool { return item.Key == "name" })
404
-
405
- yms = append([]yaml.MapItem{{Key: "name", Value: jobName}}, yms...)
406
-
407
- v := map[string]any{
408
- "jobs": []any{yms},
409
- }
410
-
411
- return yaml.Marshal(v)
412
-}
413
-
414
-func configFromPayload(fn dyncfg.Function) (confgroup.Config, error) {
415
- var cfg confgroup.Config
416
-
417
- if fn.IsContentTypeJSON() {
418
- if err := json.Unmarshal(fn.Payload(), &cfg); err != nil {
419
- return nil, err
420
- }
421
-
422
- return cfg.Clone()
423
- }
424
-
425
- if err := yaml.Unmarshal(fn.Payload(), &cfg); err != nil {
426
- return nil, err
427
- }
428
-
429
- return cfg, nil
430
-}
431
-
432
-func (m *Manager) extractModuleJobName(id string) (mn string, jn string, ok bool) {
433
- if mn, ok = m.extractModuleName(id); !ok {
434
- return "", "", false
435
- }
436
- if jn, ok = extractJobName(id); !ok {
437
- return "", "", false
438
- }
439
- return mn, jn, true
440
-}
441
-
442
-func (m *Manager) extractModuleName(id string) (string, bool) {
443
- id = strings.TrimPrefix(id, m.dyncfgCollectorPrefixValue())
444
- i := strings.IndexByte(id, ':')
445
- if i == -1 {
446
- return id, id != ""
447
- }
448
- return id[:i], true
449
-}
450
-
451
-func extractJobName(id string) (string, bool) {
452
- i := strings.LastIndexByte(id, ':')
453
- if i == -1 {
454
- return "", false
455
- }
456
- return id[i+1:], true
457
-}
458
-
459
-// --- collectorCallbacks implements dyncfg.Callbacks[confgroup.Config] ---
460
-
461
-type collectorCallbacks struct {
462
- mgr *Manager
463
-}
464
-
465
-func (cb *collectorCallbacks) ExtractKey(fn dyncfg.Function) (key, name string, ok bool) {
466
- var mn, jn string
467
-
468
- if fn.Command() == dyncfg.CommandAdd {
469
- // For add: ID is module template, job name is in Args[2].
470
- mn, ok = cb.mgr.extractModuleName(fn.ID())
471
- if !ok {
472
- return "", "", false
473
- }
474
- jn = fn.JobName()
475
- if jn == "" {
476
- return "", "", false
477
- }
478
- } else {
479
- // For other commands: ID contains module:job.
480
- mn, jn, ok = cb.mgr.extractModuleJobName(fn.ID())
481
- if !ok {
482
- return "", "", false
483
- }
484
- }
485
-
486
- key = mn + "_" + jn
487
- if mn == jn {
488
- key = jn
489
- }
490
- return key, jn, true
491
-}
492
-
493
-func (cb *collectorCallbacks) ParseAndValidate(fn dyncfg.Function, name string) (confgroup.Config, error) {
494
- mn, ok := cb.mgr.extractModuleName(fn.ID())
495
- if !ok {
496
- return nil, fmt.Errorf("could not extract module name from ID: %s", fn.ID())
497
- }
498
-
499
- cfg, err := configFromPayload(fn)
500
- if err != nil {
501
- return nil, fmt.Errorf("invalid configuration format: failed to create configuration from payload: %v", err)
502
- }
503
-
504
- cb.mgr.dyncfgSetConfigMeta(cfg, mn, name, fn)
505
-
506
- if _, err := cb.mgr.createCollectorJob(cfg); err != nil {
507
- return nil, fmt.Errorf("invalid configuration: failed to apply configuration: %v", err)
508
- }
509
-
510
- return cfg, nil
103
}
512
-
513
-func (cb *collectorCallbacks) Start(cfg confgroup.Config) error {
514
- cb.mgr.retryingTasks.remove(cfg)
515
-
516
- job, err := cb.mgr.createCollectorJob(cfg)
517
- if err != nil {
518
- return &codedError{err: fmt.Errorf("invalid configuration: failed to apply configuration: %v", err), code: 400}
519
- }
520
-
521
- if err := job.AutoDetection(); err != nil {
522
- job.Cleanup()
523
- cb.mgr.scheduleRetryTask(cfg, job)
524
- return fmt.Errorf("job enable failed: %v", err)
525
- }
526
-
527
- cb.mgr.startRunningJob(job)
528
- return nil
529
-}
530
-
531
-func (cb *collectorCallbacks) Update(oldCfg, newCfg confgroup.Config) error {
532
- cb.mgr.retryingTasks.remove(oldCfg)
533
- cb.mgr.stopRunningJob(oldCfg.FullName())
534
- cb.mgr.fileStatus.remove(oldCfg)
535
-
536
- job, err := cb.mgr.createCollectorJob(newCfg)
537
- if err != nil {
538
- return fmt.Errorf("job update failed: %v", err)
539
- }
540
-
541
- if err := job.AutoDetection(); err != nil {
542
- job.Cleanup()
543
- cb.mgr.scheduleRetryTask(newCfg, job)
544
- return fmt.Errorf("job update failed: %v", err)
545
- }
546
-
547
- cb.mgr.startRunningJob(job)
548
- return nil
549
-}
550
-
551
-func (cb *collectorCallbacks) Stop(cfg confgroup.Config) {
552
- cb.mgr.retryingTasks.remove(cfg)
553
- cb.mgr.stopRunningJob(cfg.FullName())
554
- cb.mgr.fileStatus.remove(cfg)
555
-}
556
-
557
-func (cb *collectorCallbacks) OnStatusChange(entry *dyncfg.Entry[confgroup.Config], _ dyncfg.Status, _ dyncfg.Function) {
558
- if entry.Status == dyncfg.StatusRunning && isDyncfg(entry.Cfg) {
559
- cb.mgr.fileStatus.add(entry.Cfg, entry.Status.String())
560
- }
561
-}
562
-
563
-func (cb *collectorCallbacks) ConfigID(cfg confgroup.Config) string {
564
- return cb.mgr.dyncfgJobID(cfg)
565
-}
566
-
567
-// codedError wraps an error with an HTTP status code for the handler.
568
-type codedError struct {
569
- err error
570
- code int
571
-}
572
-
573
-func (e *codedError) Error() string { return e.err.Error() }
574
-func (e *codedError) Code() int { return e.code }
src/go/plugin/agent/jobmgr/dyncfg_collector_callbacks.go
new
+143
@@ -0,0 +1,143 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package jobmgr
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+
9
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
10
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
11
+)
12
+
13
+// scheduleRetryTask schedules a retry if the job supports auto-detection retry.
14
+func (m *Manager) scheduleRetryTask(cfg confgroup.Config, job runtimeJob) {
15
+ if !job.RetryAutoDetection() {
16
+ return
17
+ }
18
+ m.Infof("%s[%s] job detection failed, will retry in %d seconds",
19
+ cfg.Module(), cfg.Name(), job.AutoDetectionEvery())
20
+
21
+ ctx, cancel := context.WithCancel(m.ctx)
22
+ m.retryingTasks.add(cfg, &retryTask{cancel: cancel})
23
+
24
+ go runRetryTask(ctx, m.addCh, cfg)
25
+}
26
+
27
+// --- collectorCallbacks implements dyncfg.Callbacks[confgroup.Config] ---
28
+
29
+type collectorCallbacks struct {
30
+ mgr *Manager
31
+}
32
+
33
+func (cb *collectorCallbacks) ExtractKey(fn dyncfg.Function) (key, name string, ok bool) {
34
+ var mn, jn string
35
+
36
+ if fn.Command() == dyncfg.CommandAdd {
37
+ // For add: ID is module template, job name is in Args[2].
38
+ mn, ok = cb.mgr.extractModuleName(fn.ID())
39
+ if !ok {
40
+ return "", "", false
41
+ }
42
+ jn = fn.JobName()
43
+ if jn == "" {
44
+ return "", "", false
45
+ }
46
+ } else {
47
+ // For other commands: ID contains module:job.
48
+ mn, jn, ok = cb.mgr.extractModuleJobName(fn.ID())
49
+ if !ok {
50
+ return "", "", false
51
+ }
52
+ }
53
+
54
+ key = mn + "_" + jn
55
+ if mn == jn {
56
+ key = jn
57
+ }
58
+ return key, jn, true
59
+}
60
+
61
+func (cb *collectorCallbacks) ParseAndValidate(fn dyncfg.Function, name string) (confgroup.Config, error) {
62
+ mn, ok := cb.mgr.extractModuleName(fn.ID())
63
+ if !ok {
64
+ return nil, fmt.Errorf("could not extract module name from ID: %s", fn.ID())
65
+ }
66
+
67
+ cfg, err := configFromPayload(fn)
68
+ if err != nil {
69
+ return nil, fmt.Errorf("invalid configuration format: failed to create configuration from payload: %v", err)
70
+ }
71
+
72
+ cb.mgr.dyncfgSetConfigMeta(cfg, mn, name, fn)
73
+
74
+ if err := cb.mgr.validateCollectorJob(cfg); err != nil {
75
+ return nil, fmt.Errorf("invalid configuration: failed to apply configuration: %v", err)
76
+ }
77
+
78
+ return cfg, nil
79
+}
80
+
81
+func (cb *collectorCallbacks) Start(cfg confgroup.Config) error {
82
+ cb.mgr.retryingTasks.remove(cfg)
83
+
84
+ job, err := cb.mgr.createCollectorJob(cfg)
85
+ if err != nil {
86
+ return &codedError{err: fmt.Errorf("invalid configuration: failed to apply configuration: %v", err), code: 400}
87
+ }
88
+
89
+ if err := job.AutoDetection(); err != nil {
90
+ job.Cleanup()
91
+ cb.mgr.scheduleRetryTask(cfg, job)
92
+ return fmt.Errorf("job enable failed: %v", err)
93
+ }
94
+
95
+ cb.mgr.startRunningJob(job)
96
+ return nil
97
+}
98
+
99
+func (cb *collectorCallbacks) Update(oldCfg, newCfg confgroup.Config) error {
100
+ cb.mgr.retryingTasks.remove(oldCfg)
101
+ cb.mgr.stopRunningJob(oldCfg.FullName())
102
+ cb.mgr.fileStatus.remove(oldCfg)
103
+
104
+ job, err := cb.mgr.createCollectorJob(newCfg)
105
+ if err != nil {
106
+ return fmt.Errorf("job update failed: %v", err)
107
+ }
108
+
109
+ if err := job.AutoDetection(); err != nil {
110
+ job.Cleanup()
111
+ cb.mgr.scheduleRetryTask(newCfg, job)
112
+ return fmt.Errorf("job update failed: %v", err)
113
+ }
114
+
115
+ cb.mgr.startRunningJob(job)
116
+ return nil
117
+}
118
+
119
+func (cb *collectorCallbacks) Stop(cfg confgroup.Config) {
120
+ cb.mgr.retryingTasks.remove(cfg)
121
+ cb.mgr.stopRunningJob(cfg.FullName())
122
+ cb.mgr.fileStatus.remove(cfg)
123
+}
124
+
125
+func (cb *collectorCallbacks) OnStatusChange(entry *dyncfg.Entry[confgroup.Config], _ dyncfg.Status, _ dyncfg.Function) {
126
+ if entry.Status == dyncfg.StatusRunning && isDyncfg(entry.Cfg) {
127
+ cb.mgr.fileStatus.add(entry.Cfg, entry.Status.String())
128
+ }
129
+}
130
+
131
+func (cb *collectorCallbacks) ConfigID(cfg confgroup.Config) string {
132
+ return cb.mgr.dyncfgJobID(cfg)
133
+}
134
+
135
+// codedError wraps an error with an HTTP status code for the handler.
136
+type codedError struct {
137
+ err error
138
+ code int
139
+}
140
+
141
+func (e *codedError) Error() string { return e.err.Error() }
142
+func (e *codedError) Unwrap() error { return e.err }
143
+func (e *codedError) Code() int { return e.code }
src/go/plugin/agent/jobmgr/dyncfg_collector_cmds.go
new
+285
@@ -0,0 +1,285 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package jobmgr
4
+
5
+import (
6
+ "context"
7
+ "encoding/json"
8
+ "log/slog"
9
+ "time"
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/collectorapi"
14
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
15
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
16
+)
17
+
18
+type dyncfgCmdTestTask struct {
19
+ fn dyncfg.Function
20
+ moduleName string
21
+ creator collectorapi.Creator
22
+ cfg confgroup.Config
23
+ timeout time.Duration
24
+}
25
+
26
+func (m *Manager) requireModuleFromID(fn dyncfg.Function, target string) (string, collectorapi.Creator, bool) {
27
+ cmd := fn.Command()
28
+ id := fn.ID()
29
+
30
+ moduleName, ok := m.extractModuleName(id)
31
+ if !ok {
32
+ m.Warningf("dyncfg: %s: could not extract %s from id (%s)", cmd, target, id)
33
+ m.dyncfgResponder.SendCodef(fn, 400, "Invalid ID format. Could not extract %s from ID. Provided ID: %s.", target, id)
34
+ return "", collectorapi.Creator{}, false
35
+ }
36
+
37
+ creator, ok := m.modules.Lookup(moduleName)
38
+ if !ok {
39
+ m.Warningf("dyncfg: %s: module %s not found", cmd, moduleName)
40
+ m.dyncfgResponder.SendCodef(fn, 404, "The specified module '%s' is not registered.", moduleName)
41
+ return "", collectorapi.Creator{}, false
42
+ }
43
+
44
+ return moduleName, creator, true
45
+}
46
+
47
+func (m *Manager) requireTemplateModule(fn dyncfg.Function) (string, collectorapi.Creator, bool) {
48
+ return m.requireModuleFromID(fn, "module and job name")
49
+}
50
+
51
+func (m *Manager) requireModule(fn dyncfg.Function) (string, collectorapi.Creator, bool) {
52
+ return m.requireModuleFromID(fn, "module name")
53
+}
54
+
55
+func (m *Manager) requireModuleJob(fn dyncfg.Function) (string, string, collectorapi.Creator, bool) {
56
+ cmd := fn.Command()
57
+ id := fn.ID()
58
+
59
+ moduleName, jobName, ok := m.extractModuleJobName(id)
60
+ if !ok {
61
+ m.Warningf("dyncfg: %s: could not extract module and job from id (%s)", cmd, id)
62
+ m.dyncfgResponder.SendCodef(fn, 400, "Invalid ID format. Could not extract module and job name from ID. Provided ID: %s.", id)
63
+ return "", "", collectorapi.Creator{}, false
64
+ }
65
+
66
+ creator, ok := m.modules.Lookup(moduleName)
67
+ if !ok {
68
+ m.Warningf("dyncfg: %s: module %s not found", cmd, moduleName)
69
+ m.dyncfgResponder.SendCodef(fn, 404, "The specified module '%s' is not registered.", moduleName)
70
+ return "", "", collectorapi.Creator{}, false
71
+ }
72
+
73
+ return moduleName, jobName, creator, true
74
+}
75
+
76
+func (m *Manager) dyncfgCmdUserconfig(fn dyncfg.Function) {
77
+ jn := fn.JobName()
78
+ if jn == "" {
79
+ jn = "test"
80
+ }
81
+
82
+ mn, creator, ok := m.requireTemplateModule(fn)
83
+ if !ok {
84
+ return
85
+ }
86
+
87
+ if creator.Config == nil || creator.Config() == nil {
88
+ m.Warningf("dyncfg: %s: module %s: configuration not found", fn.Command(), mn)
89
+ m.dyncfgResponder.SendCodef(fn, 500, "Module %s does not provide configuration.", mn)
90
+ return
91
+ }
92
+
93
+ bs, err := userConfigFromPayload(creator.Config(), jn, fn)
94
+ if err != nil {
95
+ m.Warningf("dyncfg: %s: module %s: failed to create config from payload: %v", fn.Command(), mn, err)
96
+ m.dyncfgResponder.SendCodef(fn, 400, "Invalid configuration format. Failed to create configuration from payload: %v.", err)
97
+ return
98
+ }
99
+
100
+ m.dyncfgResponder.SendYAML(fn, string(bs))
101
+}
102
+
103
+func (m *Manager) dyncfgCmdTest(fn dyncfg.Function) {
104
+ cmd := fn.Command()
105
+
106
+ mn, creator, ok := m.requireTemplateModule(fn)
107
+ if !ok {
108
+ return
109
+ }
110
+
111
+ jn := fn.JobName()
112
+ if jn == "" {
113
+ jn = "test"
114
+ }
115
+
116
+ m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
117
+
118
+ if err := dyncfg.ValidateJobName(jn); err != nil {
119
+ m.Warningf("dyncfg: %s: module %s: unacceptable job name '%s': %v", cmd, mn, jn, err)
120
+ m.dyncfgResponder.SendCodef(fn, 400, "Unacceptable job name '%s': %v.", jn, err)
121
+ return
122
+ }
123
+ if !fn.HasPayload() {
124
+ m.Warningf("dyncfg: %s: module %s: missing configuration payload", cmd, mn)
125
+ m.dyncfgResponder.SendCodef(fn, 400, "Missing configuration payload.")
126
+ return
127
+ }
128
+
129
+ cfg, err := configFromPayload(fn)
130
+ if err != nil {
131
+ m.Warningf("dyncfg: %s: module %s: failed to create config from payload: %v", cmd, mn, err)
132
+ m.dyncfgResponder.SendCodef(fn, 400, "Invalid configuration format. Failed to create configuration from payload: %v.", err)
133
+ return
134
+ }
135
+
136
+ if cfg.Vnode() != "" {
137
+ if _, ok := m.vnodesCtl.Lookup(cfg.Vnode()); !ok {
138
+ m.Warningf("dyncfg: %s: module %s: vnode %s not found", cmd, mn, cfg.Vnode())
139
+ m.dyncfgResponder.SendCodef(fn, 400, "The specified vnode '%s' is not registered.", cfg.Vnode())
140
+ return
141
+ }
142
+ }
143
+
144
+ cfg.SetModule(mn)
145
+ cfg.SetName(jn)
146
+
147
+ if err := m.baseContext().Err(); err != nil {
148
+ m.dyncfgResponder.SendCodef(fn, 503, "Job manager is shutting down.")
149
+ return
150
+ }
151
+
152
+ select {
153
+ case m.cmdTestSem <- struct{}{}:
154
+ task := dyncfgCmdTestTask{
155
+ fn: fn,
156
+ moduleName: mn,
157
+ creator: creator,
158
+ cfg: cfg,
159
+ timeout: m.dyncfgCmdTestTimeout(fn),
160
+ }
161
+ m.cmdTestWG.Go(func() {
162
+ m.runDyncfgCmdTest(task)
163
+ })
164
+ default:
165
+ m.Warningf("dyncfg: %s: module %s: too many concurrent test requests", cmd, mn)
166
+ m.dyncfgResponder.SendCodef(fn, 503, "Too many concurrent test requests, try again later.")
167
+ }
168
+}
169
+
170
+func (m *Manager) runDyncfgCmdTest(task dyncfgCmdTestTask) {
171
+ defer func() { <-m.cmdTestSem }()
172
+
173
+ job, err := newConfigModule(task.creator)
174
+ if err != nil {
175
+ m.Warningf("dyncfg: test: module %s: failed to create module: %v", task.moduleName, err)
176
+ m.dyncfgResponder.SendCodef(task.fn, 500, "Module %s instantiation failed: %v.", task.moduleName, err)
177
+ return
178
+ }
179
+
180
+ cleanupCtx, cleanupCancel := context.WithTimeout(m.baseContext(), cmdTestWorkerDrainWait)
181
+ defer cleanupCancel()
182
+ defer job.Cleanup(cleanupCtx)
183
+
184
+ ctx, cancel := context.WithTimeout(m.baseContext(), task.timeout)
185
+ defer cancel()
186
+
187
+ secretStoreSvc := m.secretsCtl.Service()
188
+
189
+ storeSnapshot := (*secretstore.Snapshot)(nil)
190
+ if secretStoreSvc != nil {
191
+ storeSnapshot = secretStoreSvc.Capture()
192
+ }
193
+ if err := applyConfig(ctx, task.cfg, job, m.secretResolver, secretStoreSvc, storeSnapshot); err != nil {
194
+ m.Warningf("dyncfg: test: module %s: failed to apply config: %v", task.moduleName, err)
195
+ m.dyncfgResponder.SendCodef(task.fn, 400, "Invalid configuration. Failed to apply configuration: %v.", err)
196
+ return
197
+ }
198
+
199
+ job.GetBase().Logger = logger.New().With(
200
+ slog.String("collector", task.cfg.Module()),
201
+ slog.String("job", task.cfg.Name()),
202
+ )
203
+
204
+ if err := job.Init(ctx); err != nil {
205
+ m.dyncfgResponder.SendCodef(task.fn, 422, "Job initialization failed: %v", err)
206
+ return
207
+ }
208
+ if err := job.Check(ctx); err != nil {
209
+ m.dyncfgResponder.SendCodef(task.fn, 422, "Job check failed: %v", err)
210
+ return
211
+ }
212
+
213
+ m.dyncfgResponder.SendCodef(task.fn, 200, "")
214
+}
215
+
216
+func (m *Manager) dyncfgCmdTestTimeout(fn dyncfg.Function) time.Duration {
217
+ if timeout := fn.Fn().Timeout; timeout > 0 {
218
+ return timeout
219
+ }
220
+ return cmdTestDefaultTimeout
221
+}
222
+
223
+func (m *Manager) dyncfgCmdSchema(fn dyncfg.Function) {
224
+ mn, mod, ok := m.requireModule(fn)
225
+ if !ok {
226
+ return
227
+ }
228
+
229
+ m.Infof("dyncfg: %s: %s module by user '%s'", fn.Command(), mn, fn.User())
230
+
231
+ if mod.JobConfigSchema == "" {
232
+ m.Warningf("dyncfg: schema: module %s: schema not found", mn)
233
+ m.dyncfgResponder.SendCodef(fn, 500, "Module %s configuration schema not found.", mn)
234
+ return
235
+ }
236
+
237
+ m.dyncfgResponder.SendJSON(fn, mod.JobConfigSchema)
238
+}
239
+
240
+func (m *Manager) dyncfgCmdGet(fn dyncfg.Function) {
241
+ cmd := fn.Command()
242
+
243
+ mn, jn, creator, ok := m.requireModuleJob(fn)
244
+ if !ok {
245
+ return
246
+ }
247
+
248
+ m.Infof("dyncfg: %s: %s/%s job by user '%s'", fn.Command(), mn, jn, fn.User())
249
+
250
+ entry, ok := m.exposedLookupByName(mn, jn)
251
+ if !ok {
252
+ m.Warningf("dyncfg: %s: module %s job %s not found", cmd, mn, jn)
253
+ m.dyncfgResponder.SendCodef(fn, 404, "The specified module '%s' job '%s' is not registered.", mn, jn)
254
+ return
255
+ }
256
+
257
+ mod, err := newConfigModule(creator)
258
+ if err != nil {
259
+ m.Warningf("dyncfg: %s: module %s job %s failed to create module: %v", cmd, mn, jn, err)
260
+ m.dyncfgResponder.SendCodef(fn, 500, "Module %s instantiation failed: %v.", mn, err)
261
+ return
262
+ }
263
+
264
+ if err := applyConfigRaw(entry.Cfg, mod); err != nil {
265
+ m.Warningf("dyncfg: %s: module %s job %s failed to apply config: %v", cmd, mn, jn, err)
266
+ m.dyncfgResponder.SendCodef(fn, 400, "Invalid configuration. Failed to apply configuration: %v.", err)
267
+ return
268
+ }
269
+
270
+ conf := mod.Configuration()
271
+ if conf == nil {
272
+ m.Warningf("dyncfg: %s: module %s: configuration not found", cmd, mn)
273
+ m.dyncfgResponder.SendCodef(fn, 500, "Module %s does not provide configuration.", mn)
274
+ return
275
+ }
276
+
277
+ bs, err := json.Marshal(conf)
278
+ if err != nil {
279
+ m.Warningf("dyncfg: %s: module %s job %s failed to json marshal config: %v", cmd, mn, jn, err)
280
+ m.dyncfgResponder.SendCodef(fn, 500, "Failed to convert configuration into JSON: %v.", err)
281
+ return
282
+ }
283
+
284
+ m.dyncfgResponder.SendJSON(fn, string(bs))
285
+}
src/go/plugin/agent/jobmgr/dyncfg_collector_helpers.go
new
+95
@@ -0,0 +1,95 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package jobmgr
4
+
5
+import (
6
+ "encoding/json"
7
+ "slices"
8
+ "strings"
9
+
10
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
11
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
12
+ "gopkg.in/yaml.v2"
13
+)
14
+
15
+func (m *Manager) dyncfgSetConfigMeta(cfg confgroup.Config, module, name string, fn dyncfg.Function) {
16
+ cfg.SetProvider("dyncfg")
17
+ cfg.SetSource(fn.Source())
18
+ cfg.SetSourceType("dyncfg")
19
+ cfg.SetModule(module)
20
+ cfg.SetName(name)
21
+ if def, ok := m.configDefaults.Lookup(module); ok {
22
+ cfg.ApplyDefaults(def)
23
+ }
24
+}
25
+
26
+func userConfigFromPayload(cfg any, jobName string, fn dyncfg.Function) ([]byte, error) {
27
+ if err := fn.UnmarshalPayload(cfg); err != nil {
28
+ return nil, err
29
+ }
30
+
31
+ bs, err := yaml.Marshal(cfg)
32
+ if err != nil {
33
+ return nil, err
34
+ }
35
+
36
+ var yms yaml.MapSlice
37
+ if err := yaml.Unmarshal(bs, &yms); err != nil {
38
+ return nil, err
39
+ }
40
+
41
+ yms = slices.DeleteFunc(yms, func(item yaml.MapItem) bool { return item.Key == "name" })
42
+
43
+ yms = append([]yaml.MapItem{{Key: "name", Value: jobName}}, yms...)
44
+
45
+ v := map[string]any{
46
+ "jobs": []any{yms},
47
+ }
48
+
49
+ return yaml.Marshal(v)
50
+}
51
+
52
+func configFromPayload(fn dyncfg.Function) (confgroup.Config, error) {
53
+ var cfg confgroup.Config
54
+
55
+ if fn.IsContentTypeJSON() {
56
+ if err := json.Unmarshal(fn.Payload(), &cfg); err != nil {
57
+ return nil, err
58
+ }
59
+
60
+ return cfg.Clone()
61
+ }
62
+
63
+ if err := yaml.Unmarshal(fn.Payload(), &cfg); err != nil {
64
+ return nil, err
65
+ }
66
+
67
+ return cfg, nil
68
+}
69
+
70
+func (m *Manager) extractModuleJobName(id string) (mn string, jn string, ok bool) {
71
+ if mn, ok = m.extractModuleName(id); !ok {
72
+ return "", "", false
73
+ }
74
+ if jn, ok = extractJobName(id); !ok {
75
+ return "", "", false
76
+ }
77
+ return mn, jn, true
78
+}
79
+
80
+func (m *Manager) extractModuleName(id string) (string, bool) {
81
+ id = strings.TrimPrefix(id, m.dyncfgCollectorPrefixValue())
82
+ i := strings.IndexByte(id, ':')
83
+ if i == -1 {
84
+ return id, id != ""
85
+ }
86
+ return id[:i], true
87
+}
88
+
89
+func extractJobName(id string) (string, bool) {
90
+ i := strings.LastIndexByte(id, ':')
91
+ if i == -1 {
92
+ return "", false
93
+ }
94
+ return id[i+1:], true
95
+}
src/go/plugin/agent/jobmgr/dyncfg_collector_test.go
+800
@@ -6,6 +6,8 @@ import (
6
"bytes"
7
"context"
8
"encoding/json"
9
+ "errors"
10
+ "os"
11
"strings"
12
"testing"
13
"time"
@@ -15,8 +17,12 @@ import (
17
18
"github.com/netdata/netdata/go/plugins/pkg/netdataapi"
19
"github.com/netdata/netdata/go/plugins/pkg/safewriter"
20
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
21
+ "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
22
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
23
"github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
24
"github.com/netdata/netdata/go/plugins/plugin/framework/functions"
25
+ "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
26
)
27
28
func TestDyncfgConfigUserconfig_InvalidPayload_Returns400Only(t *testing.T) {
@@ -123,3 +129,797 @@ func TestDyncfgCmdTestTimeout_RequestTimeoutOverridesDefault(t *testing.T) {
129
withoutTimeout := dyncfg.NewFunction(functions.Function{})
130
assert.Equal(t, cmdTestDefaultTimeout, mgr.dyncfgCmdTestTimeout(withoutTimeout))
131
}
132
+
133
+func TestDyncfgCollectorSeqExec_SyncsSecretStoreDepsForMutatingCommands(t *testing.T) {
134
+ tests := map[string]struct {
135
+ command dyncfg.Command
136
+ oldCfg confgroup.Config
137
+ newCfg confgroup.Config
138
+ args []string
139
+ wantOldExposed int
140
+ wantNewExposed int
141
+ wantOldRunning int
142
+ wantNewRunning int
143
+ }{
144
+ "add syncs active deps from newly exposed config": {
145
+ command: dyncfg.CommandAdd,
146
+ newCfg: prepareDyncfgCfg("success", "job").Set("password", "${store:vault:vault_prod:secret/data/mysql#password}"),
147
+ args: []string{"success", string(dyncfg.CommandAdd), "job"},
148
+ wantOldExposed: 0,
149
+ wantNewExposed: 1,
150
+ wantOldRunning: 0,
151
+ wantNewRunning: 0,
152
+ },
153
+ "update replaces active deps with updated config": {
154
+ command: dyncfg.CommandUpdate,
155
+ oldCfg: prepareDyncfgCfg("success", "job").Set("password", "${store:vault:vault_old:secret/data/mysql#password}"),
156
+ newCfg: prepareDyncfgCfg("success", "job").Set("password", "${store:vault:vault_new:secret/data/mysql#password}"),
157
+ args: []string{"success:job", string(dyncfg.CommandUpdate)},
158
+ wantOldExposed: 0,
159
+ wantNewExposed: 1,
160
+ wantOldRunning: 0,
161
+ wantNewRunning: 0,
162
+ },
163
+ "remove clears active deps when config disappears": {
164
+ command: dyncfg.CommandRemove,
165
+ oldCfg: prepareDyncfgCfg("success", "job").Set("password", "${store:vault:vault_old:secret/data/mysql#password}"),
166
+ args: []string{"success:job", string(dyncfg.CommandRemove)},
167
+ wantOldExposed: 0,
168
+ wantNewExposed: 0,
169
+ wantOldRunning: 0,
170
+ wantNewRunning: 0,
171
+ },
172
+ }
173
+
174
+ for name, tc := range tests {
175
+ t.Run(name, func(t *testing.T) {
176
+ mgr := newCollectorTestManager()
177
+ cb := &collectorSeqTestCallbacks{mgr: mgr, parsed: map[dyncfg.Command]confgroup.Config{}}
178
+ if tc.newCfg != nil {
179
+ cb.parsed[tc.command] = tc.newCfg
180
+ }
181
+ mgr.collectorHandler = newCollectorTestHandler(mgr, cb)
182
+
183
+ if tc.oldCfg != nil {
184
+ seedCollectorEntry(mgr, tc.oldCfg, dyncfg.StatusDisabled)
185
+ mgr.syncSecretStoreDepsForConfig(tc.oldCfg)
186
+ }
187
+
188
+ var payload []byte
189
+ if tc.command == dyncfg.CommandAdd || tc.command == dyncfg.CommandUpdate {
190
+ payload = mustMarshalCollectorConfigPayload(t, tc.newCfg)
191
+ }
192
+
193
+ fn := dyncfg.NewFunction(functions.Function{
194
+ UID: name,
195
+ ContentType: "application/json",
196
+ Payload: payload,
197
+ Args: collectorTestArgs(mgr, tc.args...),
198
+ })
199
+
200
+ mgr.dyncfgCollectorSeqExec(fn)
201
+
202
+ oldExposed, oldRunning := mgr.secretStoreDeps.Impacted("vault:vault_old")
203
+ newExposed, newRunning := mgr.secretStoreDeps.Impacted("vault:vault_prod")
204
+ if tc.command == dyncfg.CommandUpdate {
205
+ newExposed, newRunning = mgr.secretStoreDeps.Impacted("vault:vault_new")
206
+ }
207
+
208
+ assert.Len(t, oldExposed, tc.wantOldExposed)
209
+ assert.Len(t, oldRunning, tc.wantOldRunning)
210
+ assert.Len(t, newExposed, tc.wantNewExposed)
211
+ assert.Len(t, newRunning, tc.wantNewRunning)
212
+ })
213
+ }
214
+}
215
+
216
+func TestDyncfgCollectorSeqExec_DoesNotSyncSecretStoreDepsForNonMutatingCommands(t *testing.T) {
217
+ tests := map[string]struct {
218
+ command dyncfg.Command
219
+ args []string
220
+ payload confgroup.Config
221
+ status dyncfg.Status
222
+ }{
223
+ "restart leaves deps unchanged": {
224
+ command: dyncfg.CommandRestart,
225
+ args: []string{"success:job", string(dyncfg.CommandRestart)},
226
+ status: dyncfg.StatusDisabled,
227
+ },
228
+ "test leaves deps unchanged": {
229
+ command: dyncfg.CommandTest,
230
+ args: []string{"success", string(dyncfg.CommandTest), "job"},
231
+ payload: prepareDyncfgCfg("success", "job"),
232
+ status: dyncfg.StatusDisabled,
233
+ },
234
+ "schema leaves deps unchanged": {
235
+ command: dyncfg.CommandSchema,
236
+ args: []string{"success", string(dyncfg.CommandSchema)},
237
+ status: dyncfg.StatusDisabled,
238
+ },
239
+ "get leaves deps unchanged": {
240
+ command: dyncfg.CommandGet,
241
+ args: []string{"success:job", string(dyncfg.CommandGet)},
242
+ status: dyncfg.StatusDisabled,
243
+ },
244
+ }
245
+
246
+ for name, tc := range tests {
247
+ t.Run(name, func(t *testing.T) {
248
+ mgr := newCollectorTestManager()
249
+ cfg := prepareDyncfgCfg("success", "job").Set("password", "${store:vault:vault_prod:secret/data/mysql#password}")
250
+ seedCollectorEntry(mgr, cfg, tc.status)
251
+ mgr.syncSecretStoreDepsForConfig(cfg)
252
+
253
+ beforeExposed, beforeRunning := mgr.secretStoreDeps.Impacted("vault:vault_prod")
254
+ require.Len(t, beforeExposed, 1)
255
+
256
+ var payload []byte
257
+ if tc.payload != nil {
258
+ payload = mustMarshalCollectorConfigPayload(t, tc.payload)
259
+ }
260
+
261
+ fn := dyncfg.NewFunction(functions.Function{
262
+ UID: name,
263
+ ContentType: "application/json",
264
+ Payload: payload,
265
+ Args: collectorTestArgs(mgr, tc.args...),
266
+ })
267
+
268
+ mgr.dyncfgCollectorSeqExec(fn)
269
+ if tc.command == dyncfg.CommandTest {
270
+ mgr.cmdTestWG.Wait()
271
+ }
272
+
273
+ afterExposed, afterRunning := mgr.secretStoreDeps.Impacted("vault:vault_prod")
274
+ assert.Equal(t, beforeExposed, afterExposed)
275
+ assert.Equal(t, beforeRunning, afterRunning)
276
+ })
277
+ }
278
+}
279
+
280
+func TestCollectorCallbacks_ParseAndValidate(t *testing.T) {
281
+ tests := map[string]struct {
282
+ args []string
283
+ cfg confgroup.Config
284
+ payload []byte
285
+ wantErr string
286
+ wantModule string
287
+ wantName string
288
+ wantProvider string
289
+ wantSourceTyp string
290
+ }{
291
+ "invalid id is rejected": {
292
+ args: []string{"", string(dyncfg.CommandAdd), "validated"},
293
+ cfg: prepareDyncfgCfg("success", "validated"),
294
+ wantErr: "could not extract module name from ID",
295
+ },
296
+ "invalid payload is rejected": {
297
+ args: []string{"success", string(dyncfg.CommandAdd), "validated"},
298
+ payload: []byte("{"),
299
+ wantErr: "invalid configuration format",
300
+ },
301
+ "valid payload is accepted and metadata is set": {
302
+ args: []string{"success", string(dyncfg.CommandAdd), "validated"},
303
+ cfg: prepareDyncfgCfg("success", "payload-name").Set("option_str", "one").Set("option_int", 2),
304
+ wantModule: "success",
305
+ wantName: "validated",
306
+ wantProvider: "dyncfg",
307
+ wantSourceTyp: confgroup.TypeDyncfg,
308
+ },
309
+ }
310
+
311
+ for name, tc := range tests {
312
+ t.Run(name, func(t *testing.T) {
313
+ mgr := newCollectorTestManager()
314
+ cb := &collectorCallbacks{mgr: mgr}
315
+ payload := tc.payload
316
+ if payload == nil && tc.cfg != nil {
317
+ payload = mustMarshalCollectorConfigPayload(t, tc.cfg)
318
+ }
319
+ fn := dyncfg.NewFunction(functions.Function{
320
+ UID: name,
321
+ ContentType: "application/json",
322
+ Payload: payload,
323
+ Args: collectorTestArgs(mgr, tc.args...),
324
+ })
325
+
326
+ cfg, err := cb.ParseAndValidate(fn, "validated")
327
+ if tc.wantErr != "" {
328
+ require.Error(t, err)
329
+ assert.Contains(t, err.Error(), tc.wantErr)
330
+ return
331
+ }
332
+
333
+ require.NoError(t, err)
334
+ assert.Equal(t, tc.wantModule, cfg.Module())
335
+ assert.Equal(t, tc.wantName, cfg.Name())
336
+ assert.Equal(t, tc.wantProvider, cfg.Provider())
337
+ assert.Equal(t, tc.wantSourceTyp, cfg.SourceType())
338
+ })
339
+ }
340
+}
341
+
342
+func TestCollectorCallbacks_ParseAndValidate_SuppressesAuditSideEffects(t *testing.T) {
343
+ tempDir := t.TempDir()
344
+ analyzer := &auditAnalyzerSpy{}
345
+ mgr := newCollectorTestManager()
346
+ mgr.auditAnalyzer = analyzer
347
+ mgr.auditDataDir = tempDir
348
+ cb := &collectorCallbacks{mgr: mgr}
349
+
350
+ cfg := prepareDyncfgCfg("success", "payload-name").Set("option_str", "one").Set("option_int", 2)
351
+ fn := dyncfg.NewFunction(functions.Function{
352
+ UID: "validation-audit-side-effects",
353
+ ContentType: "application/json",
354
+ Payload: mustMarshalCollectorConfigPayload(t, cfg),
355
+ Args: collectorTestArgs(mgr, "success", string(dyncfg.CommandAdd), "validated"),
356
+ })
357
+
358
+ _, err := cb.ParseAndValidate(fn, "validated")
359
+ require.NoError(t, err)
360
+
361
+ entries, err := os.ReadDir(tempDir)
362
+ require.NoError(t, err)
363
+ assert.Empty(t, entries)
364
+ assert.Empty(t, analyzer.registered)
365
+}
366
+
367
+func TestCollectorCallbacks_Start(t *testing.T) {
368
+ tests := map[string]struct {
369
+ cfg confgroup.Config
370
+ wantErr string
371
+ wantCode int
372
+ wantRunning bool
373
+ wantRetryPending bool
374
+ }{
375
+ "success starts the job": {
376
+ cfg: prepareDyncfgCfg("success", "job"),
377
+ wantRunning: true,
378
+ },
379
+ "autodetection failure schedules retry": {
380
+ cfg: prepareDyncfgCfg("retrycheck", "job").Set("autodetection_retry", 1),
381
+ wantErr: "job enable failed",
382
+ wantRetryPending: true,
383
+ },
384
+ "invalid config returns coded validation error": {
385
+ cfg: prepareDyncfgCfg("missing", "job"),
386
+ wantErr: "invalid configuration",
387
+ wantCode: 400,
388
+ },
389
+ }
390
+
391
+ for name, tc := range tests {
392
+ t.Run(name, func(t *testing.T) {
393
+ mgr := newCollectorTestManager()
394
+ cb := &collectorCallbacks{mgr: mgr}
395
+
396
+ err := cb.Start(tc.cfg)
397
+ if tc.wantErr != "" {
398
+ require.Error(t, err)
399
+ assert.Contains(t, err.Error(), tc.wantErr)
400
+ if tc.wantCode != 0 {
401
+ var coded interface{ Code() int }
402
+ require.ErrorAs(t, err, &coded)
403
+ assert.Equal(t, tc.wantCode, coded.Code())
404
+ }
405
+ } else {
406
+ require.NoError(t, err)
407
+ }
408
+
409
+ _, running := mgr.runningJobs.lookup(tc.cfg.FullName())
410
+ assert.Equal(t, tc.wantRunning, running)
411
+
412
+ _, retryPending := mgr.retryingTasks.lookup(tc.cfg)
413
+ assert.Equal(t, tc.wantRetryPending, retryPending)
414
+
415
+ if tc.wantRunning {
416
+ mgr.stopRunningJob(tc.cfg.FullName())
417
+ }
418
+ if tc.wantRetryPending {
419
+ mgr.retryingTasks.remove(tc.cfg)
420
+ }
421
+ })
422
+ }
423
+}
424
+
425
+func TestCollectorCallbacks_Update(t *testing.T) {
426
+ tests := map[string]struct {
427
+ oldCfg confgroup.Config
428
+ newCfg confgroup.Config
429
+ wantErr string
430
+ wantRunning bool
431
+ wantRetryPending bool
432
+ }{
433
+ "success restarts with the new config and clears old state": {
434
+ oldCfg: prepareDyncfgCfg("success", "job"),
435
+ newCfg: prepareDyncfgCfg("success", "job").Set("option_str", "changed"),
436
+ wantRunning: true,
437
+ },
438
+ "autodetection failure clears old state and schedules retry": {
439
+ oldCfg: prepareDyncfgCfg("retrycheck", "job"),
440
+ newCfg: prepareDyncfgCfg("retrycheck", "job").Set("autodetection_retry", 1),
441
+ wantErr: "job update failed",
442
+ wantRetryPending: true,
443
+ },
444
+ }
445
+
446
+ for name, tc := range tests {
447
+ t.Run(name, func(t *testing.T) {
448
+ mgr := newCollectorTestManager()
449
+ cb := &collectorCallbacks{mgr: mgr}
450
+ oldJob := &collectorProbeJob{
451
+ fullName: tc.oldCfg.FullName(),
452
+ moduleName: tc.oldCfg.Module(),
453
+ name: tc.oldCfg.Name(),
454
+ }
455
+
456
+ mgr.runningJobs.lock()
457
+ mgr.runningJobs.add(oldJob.FullName(), oldJob)
458
+ mgr.runningJobs.unlock()
459
+ mgr.fileStatus.add(tc.oldCfg, dyncfg.StatusRunning.String())
460
+
461
+ _, cancel := context.WithCancel(context.Background())
462
+ defer cancel()
463
+ mgr.retryingTasks.add(tc.oldCfg, &retryTask{cancel: cancel})
464
+
465
+ err := cb.Update(tc.oldCfg, tc.newCfg)
466
+ if tc.wantErr != "" {
467
+ require.Error(t, err)
468
+ assert.Contains(t, err.Error(), tc.wantErr)
469
+ } else {
470
+ require.NoError(t, err)
471
+ }
472
+
473
+ assert.True(t, oldJob.stopped)
474
+
475
+ _, oldRetryPending := mgr.retryingTasks.lookup(tc.oldCfg)
476
+ assert.False(t, oldRetryPending)
477
+
478
+ _, oldFileStatus := mgr.fileStatus.lookup(tc.oldCfg)
479
+ assert.False(t, oldFileStatus)
480
+
481
+ _, running := mgr.runningJobs.lookup(tc.newCfg.FullName())
482
+ assert.Equal(t, tc.wantRunning, running)
483
+
484
+ _, retryPending := mgr.retryingTasks.lookup(tc.newCfg)
485
+ assert.Equal(t, tc.wantRetryPending, retryPending)
486
+
487
+ if tc.wantRunning {
488
+ mgr.stopRunningJob(tc.newCfg.FullName())
489
+ }
490
+ if tc.wantRetryPending {
491
+ mgr.retryingTasks.remove(tc.newCfg)
492
+ }
493
+ })
494
+ }
495
+}
496
+
497
+func TestCollectorCallbacks_Stop(t *testing.T) {
498
+ tests := map[string]struct{}{
499
+ "stop removes retry task, running job, and file status": {},
500
+ }
501
+
502
+ for name := range tests {
503
+ t.Run(name, func(t *testing.T) {
504
+ mgr := newCollectorTestManager()
505
+ cb := &collectorCallbacks{mgr: mgr}
506
+ cfg := prepareDyncfgCfg("success", "job")
507
+ job := &collectorProbeJob{
508
+ fullName: cfg.FullName(),
509
+ moduleName: cfg.Module(),
510
+ name: cfg.Name(),
511
+ }
512
+
513
+ mgr.runningJobs.lock()
514
+ mgr.runningJobs.add(job.FullName(), job)
515
+ mgr.runningJobs.unlock()
516
+ mgr.fileStatus.add(cfg, dyncfg.StatusRunning.String())
517
+
518
+ _, cancel := context.WithCancel(context.Background())
519
+ defer cancel()
520
+ mgr.retryingTasks.add(cfg, &retryTask{cancel: cancel})
521
+
522
+ cb.Stop(cfg)
523
+
524
+ assert.True(t, job.stopped)
525
+ _, running := mgr.runningJobs.lookup(cfg.FullName())
526
+ assert.False(t, running)
527
+ _, retryPending := mgr.retryingTasks.lookup(cfg)
528
+ assert.False(t, retryPending)
529
+ _, fileStatus := mgr.fileStatus.lookup(cfg)
530
+ assert.False(t, fileStatus)
531
+ })
532
+ }
533
+}
534
+
535
+func TestCollectorCallbacks_OnStatusChange(t *testing.T) {
536
+ tests := map[string]struct {
537
+ cfg confgroup.Config
538
+ status dyncfg.Status
539
+ wantSeen bool
540
+ }{
541
+ "running dyncfg config is persisted to file status": {
542
+ cfg: prepareDyncfgCfg("success", "job"),
543
+ status: dyncfg.StatusRunning,
544
+ wantSeen: true,
545
+ },
546
+ "failed dyncfg config is ignored": {
547
+ cfg: prepareDyncfgCfg("success", "job"),
548
+ status: dyncfg.StatusFailed,
549
+ },
550
+ "running non-dyncfg config is ignored": {
551
+ cfg: prepareUserCfg("success", "job"),
552
+ status: dyncfg.StatusRunning,
553
+ },
554
+ }
555
+
556
+ for name, tc := range tests {
557
+ t.Run(name, func(t *testing.T) {
558
+ mgr := newCollectorTestManager()
559
+ cb := &collectorCallbacks{mgr: mgr}
560
+ entry := &dyncfg.Entry[confgroup.Config]{
561
+ Cfg: tc.cfg,
562
+ Status: tc.status,
563
+ }
564
+
565
+ cb.OnStatusChange(entry, dyncfg.StatusAccepted, dyncfg.NewFunction(functions.Function{}))
566
+
567
+ _, ok := mgr.fileStatus.lookup(tc.cfg)
568
+ assert.Equal(t, tc.wantSeen, ok)
569
+ })
570
+ }
571
+}
572
+
573
+func TestRunDyncfgCmdTest_CleanupIsDeferred(t *testing.T) {
574
+ tests := map[string]struct {
575
+ initErr string
576
+ checkErr string
577
+ wantStatus float64
578
+ }{
579
+ "cleanup runs after init failure": {
580
+ initErr: "init failed",
581
+ wantStatus: 422,
582
+ },
583
+ "cleanup runs after check failure": {
584
+ checkErr: "check failed",
585
+ wantStatus: 422,
586
+ },
587
+ "cleanup runs after success": {
588
+ wantStatus: 200,
589
+ },
590
+ }
591
+
592
+ for name, tc := range tests {
593
+ t.Run(name, func(t *testing.T) {
594
+ var buf bytes.Buffer
595
+ mgr := newCollectorTestManager()
596
+ mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
597
+
598
+ module := &collectorapi.MockCollectorV1{}
599
+ if tc.initErr != "" {
600
+ module.InitFunc = func(context.Context) error { return errors.New(tc.initErr) }
601
+ }
602
+ if tc.checkErr != "" {
603
+ module.CheckFunc = func(context.Context) error { return errors.New(tc.checkErr) }
604
+ }
605
+
606
+ task := dyncfgCmdTestTask{
607
+ fn: dyncfg.NewFunction(functions.Function{
608
+ UID: name,
609
+ }),
610
+ moduleName: "success",
611
+ creator: collectorapi.Creator{
612
+ Create: func() collectorapi.CollectorV1 {
613
+ return module
614
+ },
615
+ },
616
+ cfg: prepareDyncfgCfg("success", "job"),
617
+ timeout: time.Second,
618
+ }
619
+
620
+ mgr.cmdTestSem <- struct{}{}
621
+ mgr.runDyncfgCmdTest(task)
622
+
623
+ assert.True(t, module.CleanupDone)
624
+
625
+ var resp map[string]any
626
+ mustDecodeFunctionPayload(t, buf.String(), name, &resp)
627
+ assert.Equal(t, tc.wantStatus, resp["status"])
628
+ })
629
+ }
630
+}
631
+
632
+func TestRunDyncfgCmdTest_ApplyConfigUsesRequestTimeout(t *testing.T) {
633
+ tests := map[string]struct {
634
+ timeout time.Duration
635
+ }{
636
+ "secret resolution sees request deadline": {
637
+ timeout: 20 * time.Millisecond,
638
+ },
639
+ }
640
+
641
+ for name, tc := range tests {
642
+ t.Run(name, func(t *testing.T) {
643
+ var buf bytes.Buffer
644
+ blockingSvc := &blockingSecretStoreService{}
645
+ mgr := newCollectorTestManagerWithService(blockingSvc)
646
+ mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
647
+
648
+ task := dyncfgCmdTestTask{
649
+ fn: dyncfg.NewFunction(functions.Function{
650
+ UID: name,
651
+ }),
652
+ moduleName: "success",
653
+ creator: collectorapi.Creator{
654
+ Create: func() collectorapi.CollectorV1 {
655
+ return &collectorapi.MockCollectorV1{}
656
+ },
657
+ },
658
+ cfg: prepareDyncfgCfg("success", "job").
659
+ Set("password", "${store:vault:vault_prod:secret/data/mysql#password}"),
660
+ timeout: tc.timeout,
661
+ }
662
+
663
+ mgr.cmdTestSem <- struct{}{}
664
+ start := time.Now()
665
+ mgr.runDyncfgCmdTest(task)
666
+ elapsed := time.Since(start)
667
+
668
+ var resp map[string]any
669
+ mustDecodeFunctionPayload(t, buf.String(), name, &resp)
670
+ assert.Equal(t, float64(400), resp["status"])
671
+ assert.Contains(t, resp["errorMessage"], context.DeadlineExceeded.Error())
672
+ assert.True(t, blockingSvc.sawDeadline)
673
+ assert.Less(t, elapsed, tc.timeout+250*time.Millisecond)
674
+ })
675
+ }
676
+}
677
+
678
+func TestDyncfgCmdTest_ShutdownBeforeWorker_Returns503(t *testing.T) {
679
+ tests := map[string]struct{}{
680
+ "shutdown manager returns 503 before scheduling worker": {},
681
+ }
682
+
683
+ for name := range tests {
684
+ t.Run(name, func(t *testing.T) {
685
+ var buf bytes.Buffer
686
+ mgr := newCollectorTestManager()
687
+ mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
688
+
689
+ ctx, cancel := context.WithCancel(context.Background())
690
+ cancel()
691
+ mgr.ctx = ctx
692
+
693
+ fn := dyncfg.NewFunction(functions.Function{
694
+ UID: name,
695
+ ContentType: "application/json",
696
+ Payload: mustMarshalCollectorConfigPayload(t, prepareDyncfgCfg("success", "job")),
697
+ Args: []string{mgr.dyncfgModID("success"), string(dyncfg.CommandTest), "job"},
698
+ })
699
+
700
+ mgr.dyncfgCmdTest(fn)
701
+
702
+ var resp map[string]any
703
+ mustDecodeFunctionPayload(t, buf.String(), name, &resp)
704
+ assert.Equal(t, float64(503), resp["status"])
705
+ })
706
+ }
707
+}
708
+
709
+func TestDyncfgCmdTest_MissingPayload_Returns400(t *testing.T) {
710
+ tests := map[string]struct{}{
711
+ "missing payload is rejected before config parsing": {},
712
+ }
713
+
714
+ for name := range tests {
715
+ t.Run(name, func(t *testing.T) {
716
+ var buf bytes.Buffer
717
+ mgr := newCollectorTestManager()
718
+ mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
719
+
720
+ fn := dyncfg.NewFunction(functions.Function{
721
+ UID: name,
722
+ Args: []string{mgr.dyncfgModID("success"), string(dyncfg.CommandTest), "job"},
723
+ })
724
+
725
+ mgr.dyncfgCmdTest(fn)
726
+
727
+ var resp map[string]any
728
+ mustDecodeFunctionPayload(t, buf.String(), name, &resp)
729
+ assert.Equal(t, float64(400), resp["status"])
730
+ assert.Contains(t, resp["errorMessage"], "Missing configuration payload.")
731
+ })
732
+ }
733
+}
734
+
735
+func newCollectorTestManager() *Manager {
736
+ return newCollectorTestManagerWithService(nil)
737
+}
738
+
739
+func newCollectorTestManagerWithService(secretStoreSvc secretstore.Service) *Manager {
740
+ mgr := New(Config{
741
+ PluginName: testPluginName,
742
+ SecretStoreService: secretStoreSvc,
743
+ })
744
+ mgr.ctx = context.Background()
745
+ mgr.modules = prepareMockRegistry()
746
+ mgr.modules.Register("retrycheck", collectorapi.Creator{
747
+ Create: func() collectorapi.CollectorV1 {
748
+ return &collectorapi.MockCollectorV1{
749
+ CheckFunc: func(context.Context) error { return errors.New("mock failed check") },
750
+ }
751
+ },
752
+ })
753
+ mgr.fileStatus = newFileStatus()
754
+ return mgr
755
+}
756
+
757
+func newCollectorTestHandler(mgr *Manager, cb dyncfg.Callbacks[confgroup.Config]) *dyncfg.Handler[confgroup.Config] {
758
+ return dyncfg.NewHandler(dyncfg.HandlerOpts[confgroup.Config]{
759
+ Logger: mgr.Logger,
760
+ API: mgr.dyncfgResponder,
761
+ Seen: mgr.collectorSeen,
762
+ Exposed: mgr.collectorExposed,
763
+ Callbacks: cb,
764
+ WaitKey: func(cfg confgroup.Config) string {
765
+ return cfg.FullName()
766
+ },
767
+ WaitTimeout: waitDecisionTimeout,
768
+ Path: "/collectors/test/Jobs",
769
+ EnableFailCode: 200,
770
+ RemoveStockOnEnableFail: true,
771
+ JobCommands: []dyncfg.Command{
772
+ dyncfg.CommandSchema,
773
+ dyncfg.CommandGet,
774
+ dyncfg.CommandEnable,
775
+ dyncfg.CommandDisable,
776
+ dyncfg.CommandUpdate,
777
+ dyncfg.CommandRestart,
778
+ dyncfg.CommandTest,
779
+ dyncfg.CommandUserconfig,
780
+ },
781
+ })
782
+}
783
+
784
+func seedCollectorEntry(mgr *Manager, cfg confgroup.Config, status dyncfg.Status) {
785
+ mgr.collectorSeen.Add(cfg)
786
+ mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
787
+ Cfg: cfg,
788
+ Status: status,
789
+ })
790
+}
791
+
792
+func collectorTestArgs(mgr *Manager, args ...string) []string {
793
+ out := make([]string, len(args))
794
+ copy(out, args)
795
+ if len(out) == 0 {
796
+ return out
797
+ }
798
+
799
+ switch {
800
+ case strings.Contains(out[0], ":"):
801
+ out[0] = mgr.dyncfgCollectorPrefixValue() + out[0]
802
+ case out[0] != "":
803
+ out[0] = mgr.dyncfgModID(out[0])
804
+ }
805
+
806
+ return out
807
+}
808
+
809
+func mustMarshalCollectorConfigPayload(t *testing.T, cfg confgroup.Config) []byte {
810
+ t.Helper()
811
+
812
+ payload, err := json.Marshal(cfg)
813
+ require.NoError(t, err)
814
+ return payload
815
+}
816
+
817
+type collectorSeqTestCallbacks struct {
818
+ mgr *Manager
819
+ parsed map[dyncfg.Command]confgroup.Config
820
+}
821
+
822
+func (cb *collectorSeqTestCallbacks) ExtractKey(fn dyncfg.Function) (key, name string, ok bool) {
823
+ return cb.mgr.collectorCallbacks.ExtractKey(fn)
824
+}
825
+
826
+func (cb *collectorSeqTestCallbacks) ParseAndValidate(fn dyncfg.Function, _ string) (confgroup.Config, error) {
827
+ cfg, ok := cb.parsed[fn.Command()]
828
+ if !ok {
829
+ return nil, errors.New("unexpected parse request")
830
+ }
831
+ return cfg, nil
832
+}
833
+
834
+func (cb *collectorSeqTestCallbacks) Start(confgroup.Config) error { return nil }
835
+
836
+func (cb *collectorSeqTestCallbacks) Update(_, _ confgroup.Config) error { return nil }
837
+
838
+func (cb *collectorSeqTestCallbacks) Stop(confgroup.Config) {}
839
+
840
+func (cb *collectorSeqTestCallbacks) OnStatusChange(*dyncfg.Entry[confgroup.Config], dyncfg.Status, dyncfg.Function) {
841
+}
842
+
843
+func (cb *collectorSeqTestCallbacks) ConfigID(cfg confgroup.Config) string {
844
+ return cb.mgr.dyncfgJobID(cfg)
845
+}
846
+
847
+type collectorProbeJob struct {
848
+ fullName string
849
+ moduleName string
850
+ name string
851
+ stopped bool
852
+}
853
+
854
+func (j *collectorProbeJob) FullName() string { return j.fullName }
855
+func (j *collectorProbeJob) ModuleName() string { return j.moduleName }
856
+func (j *collectorProbeJob) Name() string { return j.name }
857
+func (j *collectorProbeJob) Collector() any { return nil }
858
+func (j *collectorProbeJob) Start() {}
859
+func (j *collectorProbeJob) Stop() { j.stopped = true }
860
+func (j *collectorProbeJob) Tick(int) {}
861
+func (j *collectorProbeJob) AutoDetection() error {
862
+ return nil
863
+}
864
+func (j *collectorProbeJob) AutoDetectionEvery() int { return 0 }
865
+func (j *collectorProbeJob) RetryAutoDetection() bool {
866
+ return false
867
+}
868
+
869
+type blockingSecretStoreService struct {
870
+ sawDeadline bool
871
+}
872
+
873
+func (s *blockingSecretStoreService) Capture() *secretstore.Snapshot { return nil }
874
+
875
+func (s *blockingSecretStoreService) Resolve(ctx context.Context, _ *secretstore.Snapshot, _, _ string) (string, error) {
876
+ _, s.sawDeadline = ctx.Deadline()
877
+ if !s.sawDeadline {
878
+ return "", errors.New("missing request deadline")
879
+ }
880
+ <-ctx.Done()
881
+ return "", ctx.Err()
882
+}
883
+
884
+func (*blockingSecretStoreService) Kinds() []secretstore.StoreKind { return nil }
885
+
886
+func (*blockingSecretStoreService) DisplayName(secretstore.StoreKind) (string, bool) {
887
+ return "", false
888
+}
889
+
890
+func (*blockingSecretStoreService) Schema(secretstore.StoreKind) (string, bool) { return "", false }
891
+
892
+func (*blockingSecretStoreService) New(secretstore.StoreKind) (secretstore.Store, bool) {
893
+ return nil, false
894
+}
895
+
896
+func (*blockingSecretStoreService) GetStatus(string) (secretstore.StoreStatus, bool) {
897
+ return secretstore.StoreStatus{}, false
898
+}
899
+
900
+func (*blockingSecretStoreService) Validate(secretstore.Config) error { return nil }
901
+
902
+func (*blockingSecretStoreService) ValidateStored(string) error { return nil }
903
+
904
+func (*blockingSecretStoreService) Add(secretstore.Config) error { return nil }
905
+
906
+func (*blockingSecretStoreService) Update(string, secretstore.Config) error { return nil }
907
+
908
+func (*blockingSecretStoreService) Remove(string) error { return nil }
909
+func (j *collectorProbeJob) Cleanup() {}
910
+func (j *collectorProbeJob) IsRunning() bool { return true }
911
+func (j *collectorProbeJob) Panicked() bool { return false }
912
+func (j *collectorProbeJob) Vnode() vnodes.VirtualNode { return vnodes.VirtualNode{} }
913
+func (j *collectorProbeJob) UpdateVnode(*vnodes.VirtualNode) {}
914
+
915
+type auditAnalyzerSpy struct {
916
+ registered []string
917
+}
918
+
919
+func (a *auditAnalyzerSpy) RegisterJob(jobName, moduleName, dir string) {
920
+ a.registered = append(a.registered, moduleName+":"+jobName+":"+dir)
921
+}
922
+
923
+func (*auditAnalyzerSpy) RecordJobStructure(string, string, *collectorapi.Charts) {}
924
+func (*auditAnalyzerSpy) UpdateJobStructure(string, string, *collectorapi.Charts) {}
925
+func (*auditAnalyzerSpy) RecordCollection(string, string, map[string]int64) {}
src/go/plugin/agent/jobmgr/dyncfg_handoff.go
+3
-3
@@ -22,12 +22,12 @@ func (m *Manager) enqueueDyncfgFunction(fn dyncfg.Function) {
22
return
23
case dyncfg.BoundedSendContextDone:
24
if m.baseContext().Err() != nil {
25
- m.dyncfgApi.SendCodef(fn, 503, dyncfgShuttingDownMsg)
25
+ m.dyncfgResponder.SendCodef(fn, 503, dyncfgShuttingDownMsg)
26
return
27
}
28
- m.dyncfgApi.SendCodef(fn, 503, dyncfgBusyMsg)
28
+ m.dyncfgResponder.SendCodef(fn, 503, dyncfgBusyMsg)
29
case dyncfg.BoundedSendTimeout:
30
- m.dyncfgApi.SendCodef(fn, 503, dyncfgBusyMsg)
30
+ m.dyncfgResponder.SendCodef(fn, 503, dyncfgBusyMsg)
31
}
32
}
33
src/go/plugin/agent/jobmgr/dyncfg_secretstore.go
new
+64
@@ -0,0 +1,64 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package jobmgr
4
+
5
+import (
6
+ "fmt"
7
+ "strings"
8
+
9
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
10
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
11
+ "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
12
+)
13
+
14
+func (m *Manager) dyncfgSecretStorePrefixValue() string {
15
+ return m.secretsCtl.Prefix()
16
+}
17
+
18
+func (m *Manager) dyncfgSecretStoreExec(fn dyncfg.Function) {
19
+ if fn.Command() == dyncfg.CommandSchema {
20
+ m.dyncfgSecretStoreSeqExec(fn)
21
+ return
22
+ }
23
+ m.enqueueDyncfgFunction(fn)
24
+}
25
+
26
+func (m *Manager) dyncfgSecretStoreSeqExec(fn dyncfg.Function) {
27
+ m.secretsCtl.SeqExec(fn)
28
+}
29
+
30
+func (m *Manager) restartDependentCollectorJob(fullName string) error {
31
+ entry, ok := m.lookupExposedByFullName(fullName)
32
+ if !ok {
33
+ return fmt.Errorf("job '%s' is not exposed", fullName)
34
+ }
35
+
36
+ oldStatus := entry.Status
37
+ switch oldStatus {
38
+ case dyncfg.StatusRunning, dyncfg.StatusFailed:
39
+ default:
40
+ return fmt.Errorf("job '%s' restart is not allowed in '%s' state", fullName, oldStatus)
41
+ }
42
+
43
+ m.collectorCallbacks.Stop(entry.Cfg)
44
+
45
+ if err := m.collectorCallbacks.Start(entry.Cfg); err != nil {
46
+ entry.Status = dyncfg.StatusFailed
47
+ m.collectorHandler.NotifyJobStatus(entry.Cfg, dyncfg.StatusFailed)
48
+ m.collectorCallbacks.OnStatusChange(entry, oldStatus, dyncfg.NewFunction(functions.Function{}))
49
+ return fmt.Errorf("job '%s' restart failed: %w", fullName, err)
50
+ }
51
+
52
+ entry.Status = dyncfg.StatusRunning
53
+ m.collectorHandler.NotifyJobStatus(entry.Cfg, dyncfg.StatusRunning)
54
+ m.collectorCallbacks.OnStatusChange(entry, oldStatus, dyncfg.NewFunction(functions.Function{}))
55
+ return nil
56
+}
57
+
58
+func (m *Manager) lookupExposedByFullName(fullName string) (*dyncfg.Entry[confgroup.Config], bool) {
59
+ fullName = strings.TrimSpace(fullName)
60
+ if fullName == "" {
61
+ return nil, false
62
+ }
63
+ return m.collectorExposed.LookupByKey(fullName)
64
+}
src/go/plugin/agent/jobmgr/dyncfg_secretstore_cache.go
new
+78
@@ -0,0 +1,78 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package jobmgr
4
+
5
+import (
6
+ "fmt"
7
+ "strings"
8
+
9
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
10
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
11
+)
12
+
13
+func (m *Manager) affectedJobs(key string) []secretstore.JobRef {
14
+ if m == nil || m.secretStoreDeps == nil {
15
+ return nil
16
+ }
17
+
18
+ exposed, _ := m.secretStoreDeps.Impacted(key)
19
+ return exposed
20
+}
21
+
22
+func (m *Manager) restartableAffectedJobs(key string) []secretstore.JobRef {
23
+ if m == nil || m.secretStoreDeps == nil {
24
+ return nil
25
+ }
26
+
27
+ exposed, _ := m.secretStoreDeps.Impacted(key)
28
+ refs := make([]secretstore.JobRef, 0, len(exposed))
29
+ for _, job := range exposed {
30
+ entry, ok := m.lookupExposedByFullName(job.ID)
31
+ if !ok {
32
+ continue
33
+ }
34
+ switch entry.Status {
35
+ case dyncfg.StatusRunning, dyncfg.StatusFailed:
36
+ refs = append(refs, job)
37
+ }
38
+ }
39
+ return refs
40
+}
41
+
42
+type secretStoreRestartFailure struct {
43
+ ref secretstore.JobRef
44
+ err error
45
+}
46
+
47
+func (m *Manager) restartDependentJobs(key string) string {
48
+ failures := m.restartDependentJobsBestEffort(key)
49
+ if len(failures) == 0 {
50
+ return ""
51
+ }
52
+
53
+ parts := make([]string, 0, len(failures))
54
+ for _, failure := range failures {
55
+ name := failure.ref.Display
56
+ if name == "" {
57
+ name = failure.ref.ID
58
+ }
59
+ parts = append(parts, fmt.Sprintf("%s (%v)", name, failure.err))
60
+ }
61
+
62
+ return fmt.Sprintf("Secretstore change applied, but dependent collector restarts failed: %s.", strings.Join(parts, "; "))
63
+}
64
+
65
+func (m *Manager) restartDependentJobsBestEffort(key string) []secretStoreRestartFailure {
66
+ if m == nil {
67
+ return nil
68
+ }
69
+
70
+ var failures []secretStoreRestartFailure
71
+ for _, job := range m.restartableAffectedJobs(key) {
72
+ if err := m.restartDependentCollectorJob(job.ID); err != nil {
73
+ m.Warningf("dyncfg: secretstore: failed to restart dependent job '%s' after store '%s' change: %v", job.ID, key, err)
74
+ failures = append(failures, secretStoreRestartFailure{ref: job, err: err})
75
+ }
76
+ }
77
+ return failures
78
+}
src/go/plugin/agent/jobmgr/dyncfg_secretstore_test.go
new
+811
@@ -0,0 +1,811 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package jobmgr
4
+
5
+import (
6
+ "bytes"
7
+ "context"
8
+ "encoding/json"
9
+ "regexp"
10
+ "testing"
11
+
12
+ "github.com/stretchr/testify/assert"
13
+ "github.com/stretchr/testify/require"
14
+ "gopkg.in/yaml.v2"
15
+
16
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
17
+ "github.com/netdata/netdata/go/plugins/pkg/safewriter"
18
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
19
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
20
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
21
+ "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
22
+)
23
+
24
+func TestDyncfgSecretStoreSeqExec(t *testing.T) {
25
+ tests := map[string]struct {
26
+ run func(t *testing.T, mgr *Manager, out *bytes.Buffer)
27
+ }{
28
+ "add and get": {
29
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
30
+ addFn := dyncfg.NewFunction(functions.Function{
31
+ UID: "ss-add",
32
+ ContentType: "application/json",
33
+ Payload: mustJSON(t, testVaultConfig()),
34
+ Args: []string{
35
+ mgr.dyncfgSecretStoreTemplateID(secretstore.KindVault),
36
+ string(dyncfg.CommandAdd),
37
+ "vault_prod",
38
+ },
39
+ })
40
+ mgr.dyncfgSecretStoreSeqExec(addFn)
41
+
42
+ var addResp map[string]any
43
+ mustDecodeFunctionPayload(t, out.String(), "ss-add", &addResp)
44
+ assert.Equal(t, float64(200), addResp["status"])
45
+ assert.Equal(t, "", addResp["message"])
46
+ entry, ok := mgr.lookupSecretStoreEntry(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
47
+ require.True(t, ok)
48
+ assert.Equal(t, dyncfg.StatusRunning, entry.Status)
49
+ _, ok = mustSecretStoreService(t, mgr).GetStatus(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
50
+ assert.True(t, ok)
51
+ assert.Contains(t, out.String(), "schema get update test remove")
52
+ assert.NotContains(t, out.String(), "enable")
53
+ assert.NotContains(t, out.String(), "disable")
54
+
55
+ getFn := dyncfg.NewFunction(functions.Function{
56
+ UID: "ss-get",
57
+ Args: []string{mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")), string(dyncfg.CommandGet)},
58
+ })
59
+ mgr.dyncfgSecretStoreSeqExec(getFn)
60
+
61
+ var cfg map[string]any
62
+ mustDecodeFunctionPayload(t, out.String(), "ss-get", &cfg)
63
+ _, ok = cfg["name"]
64
+ assert.False(t, ok)
65
+ _, ok = cfg["kind"]
66
+ assert.False(t, ok)
67
+ assert.Equal(t, "token", cfg["mode"])
68
+ },
69
+ },
70
+ "add and get from yaml payload": {
71
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
72
+ addFn := dyncfg.NewFunction(functions.Function{
73
+ UID: "ss-add-yaml",
74
+ Payload: []byte(`
75
+mode: token
76
+mode_token:
77
+ token: vault-token
78
+addr: https://vault.example
79
+`),
80
+ Args: []string{
81
+ mgr.dyncfgSecretStoreTemplateID(secretstore.KindVault),
82
+ string(dyncfg.CommandAdd),
83
+ "vault_prod",
84
+ },
85
+ })
86
+ mgr.dyncfgSecretStoreSeqExec(addFn)
87
+
88
+ var addResp map[string]any
89
+ mustDecodeFunctionPayload(t, out.String(), "ss-add-yaml", &addResp)
90
+ assert.Equal(t, float64(200), addResp["status"])
91
+
92
+ entry, ok := mgr.lookupSecretStoreEntry(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
93
+ require.True(t, ok)
94
+ assert.Equal(t, dyncfg.StatusRunning, entry.Status)
95
+
96
+ getFn := dyncfg.NewFunction(functions.Function{
97
+ UID: "ss-get-yaml",
98
+ Args: []string{mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")), string(dyncfg.CommandGet)},
99
+ })
100
+ mgr.dyncfgSecretStoreSeqExec(getFn)
101
+
102
+ var cfg map[string]any
103
+ mustDecodeFunctionPayload(t, out.String(), "ss-get-yaml", &cfg)
104
+ _, ok = cfg["name"]
105
+ assert.False(t, ok)
106
+ assert.Equal(t, "token", cfg["mode"])
107
+
108
+ modeToken, ok := cfg["mode_token"].(map[string]any)
109
+ require.True(t, ok)
110
+ assert.Equal(t, "vault-token", modeToken["token"])
111
+ },
112
+ },
113
+ "add activation failure publishes failed store": {
114
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
115
+ addFn := dyncfg.NewFunction(functions.Function{
116
+ UID: "ss-add-failed",
117
+ ContentType: "application/json",
118
+ Payload: mustJSON(t, map[string]any{"mode": "token"}),
119
+ Args: []string{
120
+ mgr.dyncfgSecretStoreTemplateID(secretstore.KindVault),
121
+ string(dyncfg.CommandAdd),
122
+ "vault_prod",
123
+ },
124
+ })
125
+ mgr.dyncfgSecretStoreSeqExec(addFn)
126
+
127
+ var addResp map[string]any
128
+ mustDecodeFunctionPayload(t, out.String(), "ss-add-failed", &addResp)
129
+ assert.Equal(t, float64(400), addResp["status"])
130
+ assert.Contains(t, addResp["errorMessage"], "mode_token")
131
+
132
+ entry, ok := mgr.lookupSecretStoreEntry(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
133
+ require.True(t, ok)
134
+ assert.Equal(t, dyncfg.StatusFailed, entry.Status)
135
+ assert.Equal(t, confgroup.TypeDyncfg, entry.Cfg.SourceType())
136
+ _, ok = mustSecretStoreService(t, mgr).GetStatus(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
137
+ assert.False(t, ok)
138
+ },
139
+ },
140
+ "duplicate add is rejected": {
141
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
142
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusRunning)
143
+
144
+ cfg := prepareDyncfgCfg("success", "mysql")
145
+ mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
146
+ Cfg: cfg,
147
+ Status: dyncfg.StatusRunning,
148
+ })
149
+ mgr.secretStoreDeps.SetActiveJobStores(cfg.FullName(), "success:mysql", []string{secretstore.StoreKey(secretstore.KindVault, "vault_prod")})
150
+ mgr.secretStoreDeps.setRunning(cfg.FullName(), true)
151
+
152
+ addFn := dyncfg.NewFunction(functions.Function{
153
+ UID: "ss-add-duplicate",
154
+ ContentType: "application/json",
155
+ Payload: mustJSON(t, testVaultConfigTokenFile()),
156
+ Args: []string{
157
+ mgr.dyncfgSecretStoreTemplateID(secretstore.KindVault),
158
+ string(dyncfg.CommandAdd),
159
+ "vault_prod",
160
+ },
161
+ })
162
+ mgr.dyncfgSecretStoreSeqExec(addFn)
163
+
164
+ var addResp map[string]any
165
+ mustDecodeFunctionPayload(t, out.String(), "ss-add-duplicate", &addResp)
166
+ assert.Equal(t, float64(409), addResp["status"])
167
+ assert.Contains(t, addResp["errorMessage"], "already exists")
168
+ assert.NotContains(t, out.String(), "CONFIG test:collector:success:mysql status running")
169
+
170
+ getFn := dyncfg.NewFunction(functions.Function{
171
+ UID: "ss-get-after-duplicate",
172
+ Args: []string{mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")), string(dyncfg.CommandGet)},
173
+ })
174
+ mgr.dyncfgSecretStoreSeqExec(getFn)
175
+
176
+ var got map[string]any
177
+ mustDecodeFunctionPayload(t, out.String(), "ss-get-after-duplicate", &got)
178
+ assert.Equal(t, "token", got["mode"])
179
+
180
+ _, ok := mustSecretStoreService(t, mgr).GetStatus(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
181
+ assert.True(t, ok)
182
+ },
183
+ },
184
+ "runtime-affecting update succeeds for running store": {
185
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
186
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusRunning)
187
+
188
+ cfg := prepareDyncfgCfg("success", "mysql")
189
+ mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
190
+ Cfg: cfg,
191
+ Status: dyncfg.StatusRunning,
192
+ })
193
+ mgr.secretStoreDeps.SetActiveJobStores(cfg.FullName(), "success:mysql", []string{secretstore.StoreKey(secretstore.KindVault, "vault_prod")})
194
+ mgr.secretStoreDeps.setRunning(cfg.FullName(), true)
195
+
196
+ updateFn := dyncfg.NewFunction(functions.Function{
197
+ UID: "ss-update",
198
+ ContentType: "application/json",
199
+ Payload: mustJSON(t, testVaultConfigTokenFile()),
200
+ Args: []string{
201
+ mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")),
202
+ string(dyncfg.CommandUpdate),
203
+ },
204
+ })
205
+ mgr.dyncfgSecretStoreSeqExec(updateFn)
206
+
207
+ var resp map[string]any
208
+ mustDecodeFunctionPayload(t, out.String(), "ss-update", &resp)
209
+ assert.Equal(t, float64(200), resp["status"])
210
+ assert.Equal(t, "", resp["message"])
211
+
212
+ getFn := dyncfg.NewFunction(functions.Function{
213
+ UID: "ss-get-updated",
214
+ Args: []string{mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")), string(dyncfg.CommandGet)},
215
+ })
216
+ mgr.dyncfgSecretStoreSeqExec(getFn)
217
+
218
+ var got map[string]any
219
+ mustDecodeFunctionPayload(t, out.String(), "ss-get-updated", &got)
220
+ assert.Equal(t, "token_file", got["mode"])
221
+ },
222
+ },
223
+ "unknown-field update is preserved in raw config but hidden from get": {
224
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
225
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusRunning)
226
+
227
+ cfg := prepareDyncfgCfg("success", "mysql")
228
+ mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
229
+ Cfg: cfg,
230
+ Status: dyncfg.StatusRunning,
231
+ })
232
+ mgr.secretStoreDeps.SetActiveJobStores(cfg.FullName(), "success:mysql", []string{secretstore.StoreKey(secretstore.KindVault, "vault_prod")})
233
+ mgr.secretStoreDeps.setRunning(cfg.FullName(), true)
234
+
235
+ updateCfg := testVaultConfig()
236
+ updateCfg["ui_note"] = "updated description"
237
+
238
+ updateFn := dyncfg.NewFunction(functions.Function{
239
+ UID: "ss-update-metadata",
240
+ ContentType: "application/json",
241
+ Payload: mustJSON(t, updateCfg),
242
+ Args: []string{
243
+ mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")),
244
+ string(dyncfg.CommandUpdate),
245
+ },
246
+ })
247
+ mgr.dyncfgSecretStoreSeqExec(updateFn)
248
+
249
+ var resp map[string]any
250
+ mustDecodeFunctionPayload(t, out.String(), "ss-update-metadata", &resp)
251
+ assert.Equal(t, float64(200), resp["status"])
252
+
253
+ getFn := dyncfg.NewFunction(functions.Function{
254
+ UID: "ss-get-metadata",
255
+ Args: []string{mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")), string(dyncfg.CommandGet)},
256
+ })
257
+ mgr.dyncfgSecretStoreSeqExec(getFn)
258
+
259
+ var got map[string]any
260
+ mustDecodeFunctionPayload(t, out.String(), "ss-get-metadata", &got)
261
+ _, ok := got["ui_note"]
262
+ assert.False(t, ok)
263
+
264
+ entry, ok := mgr.lookupSecretStoreEntry(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
265
+ require.True(t, ok)
266
+ assert.Equal(t, "updated description", entry.Cfg["ui_note"])
267
+ },
268
+ },
269
+ "test command reports affected jobs": {
270
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
271
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusRunning)
272
+
273
+ cfg := prepareDyncfgCfg("success", "mysql")
274
+ mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
275
+ Cfg: cfg,
276
+ Status: dyncfg.StatusRunning,
277
+ })
278
+ mgr.secretStoreDeps.SetActiveJobStores(cfg.FullName(), "success:mysql", []string{secretstore.StoreKey(secretstore.KindVault, "vault_prod")})
279
+ testFn := dyncfg.NewFunction(functions.Function{
280
+ UID: "ss-test-affected",
281
+ ContentType: "application/json",
282
+ Payload: mustJSON(t, testVaultConfigTokenFile()),
283
+ Args: []string{
284
+ mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")),
285
+ string(dyncfg.CommandTest),
286
+ },
287
+ })
288
+ mgr.dyncfgSecretStoreSeqExec(testFn)
289
+
290
+ var resp map[string]any
291
+ mustDecodeFunctionPayload(t, out.String(), "ss-test-affected", &resp)
292
+ assert.Equal(t, float64(202), resp["status"])
293
+ assert.Equal(t, "Updated configuration is used by jobs: success:mysql. Running or failed jobs that would be restarted automatically: success:mysql.", resp["message"])
294
+ },
295
+ },
296
+ "test command reports no-op for unchanged payload": {
297
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
298
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusRunning)
299
+
300
+ cfg := prepareDyncfgCfg("success", "mysql")
301
+ mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
302
+ Cfg: cfg,
303
+ Status: dyncfg.StatusRunning,
304
+ })
305
+ mgr.secretStoreDeps.SetActiveJobStores(cfg.FullName(), "success:mysql", []string{secretstore.StoreKey(secretstore.KindVault, "vault_prod")})
306
+
307
+ testFn := dyncfg.NewFunction(functions.Function{
308
+ UID: "ss-test-noop",
309
+ ContentType: "application/json",
310
+ Payload: mustJSON(t, testVaultConfig()),
311
+ Args: []string{
312
+ mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")),
313
+ string(dyncfg.CommandTest),
314
+ },
315
+ })
316
+ mgr.dyncfgSecretStoreSeqExec(testFn)
317
+
318
+ var resp map[string]any
319
+ mustDecodeFunctionPayload(t, out.String(), "ss-test-noop", &resp)
320
+ assert.Equal(t, float64(202), resp["status"])
321
+ assert.Equal(t, "Submitted configuration does not change the active secretstore.", resp["message"])
322
+ assert.NotContains(t, out.String(), "CONFIG test:collector:success:mysql status running")
323
+ },
324
+ },
325
+ "test command does not mutate generation": {
326
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
327
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusRunning)
328
+ before := mustSecretStoreService(t, mgr).Capture().Generation()
329
+
330
+ testCfg := testVaultConfig()
331
+ testCfg["mode_token"].(map[string]any)["extra"] = "ignored"
332
+
333
+ testFn := dyncfg.NewFunction(functions.Function{
334
+ UID: "ss-test",
335
+ ContentType: "application/json",
336
+ Payload: mustJSON(t, testCfg),
337
+ Args: []string{
338
+ mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")),
339
+ string(dyncfg.CommandTest),
340
+ },
341
+ })
342
+ mgr.dyncfgSecretStoreSeqExec(testFn)
343
+
344
+ var resp map[string]any
345
+ mustDecodeFunctionPayload(t, out.String(), "ss-test", &resp)
346
+ assert.Equal(t, float64(202), resp["status"])
347
+ assert.Equal(t, before, mustSecretStoreService(t, mgr).Capture().Generation())
348
+ },
349
+ },
350
+ "test command with empty payload validates stored config": {
351
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
352
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusRunning)
353
+ before := mustSecretStoreService(t, mgr).Capture().Generation()
354
+
355
+ testFn := dyncfg.NewFunction(functions.Function{
356
+ UID: "ss-test-empty",
357
+ Args: []string{mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")), string(dyncfg.CommandTest)},
358
+ })
359
+ mgr.dyncfgSecretStoreSeqExec(testFn)
360
+
361
+ var resp map[string]any
362
+ mustDecodeFunctionPayload(t, out.String(), "ss-test-empty", &resp)
363
+ assert.Equal(t, float64(202), resp["status"])
364
+ assert.Equal(t, "Stored configuration is valid. No jobs are currently using this secretstore.", resp["message"])
365
+
366
+ status, ok := mustSecretStoreService(t, mgr).GetStatus(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
367
+ require.True(t, ok)
368
+ require.NotNil(t, status.LastValidation)
369
+ assert.True(t, status.LastValidation.OK)
370
+ assert.Equal(t, before, mustSecretStoreService(t, mgr).Capture().Generation())
371
+ },
372
+ },
373
+ "test command with empty payload reports affected jobs": {
374
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
375
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusRunning)
376
+
377
+ cfg := prepareDyncfgCfg("success", "mysql")
378
+ mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
379
+ Cfg: cfg,
380
+ Status: dyncfg.StatusRunning,
381
+ })
382
+ mgr.secretStoreDeps.SetActiveJobStores(cfg.FullName(), "success:mysql", []string{secretstore.StoreKey(secretstore.KindVault, "vault_prod")})
383
+
384
+ testFn := dyncfg.NewFunction(functions.Function{
385
+ UID: "ss-test-empty-affected",
386
+ Args: []string{mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")), string(dyncfg.CommandTest)},
387
+ })
388
+ mgr.dyncfgSecretStoreSeqExec(testFn)
389
+
390
+ var resp map[string]any
391
+ mustDecodeFunctionPayload(t, out.String(), "ss-test-empty-affected", &resp)
392
+ assert.Equal(t, float64(202), resp["status"])
393
+ assert.Equal(t, "Stored configuration is valid. This secretstore is used by jobs: success:mysql. Running or failed jobs that would be restarted automatically by a change: success:mysql.", resp["message"])
394
+ },
395
+ },
396
+ "test command reports all dependent jobs and restartable subset": {
397
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
398
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusRunning)
399
+
400
+ runningCfg := prepareDyncfgCfg("success", "mysql")
401
+ mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
402
+ Cfg: runningCfg,
403
+ Status: dyncfg.StatusRunning,
404
+ })
405
+ mgr.secretStoreDeps.SetActiveJobStores(runningCfg.FullName(), "success:mysql", []string{secretstore.StoreKey(secretstore.KindVault, "vault_prod")})
406
+
407
+ acceptedCfg := prepareDyncfgCfg("success", "nginx")
408
+ mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
409
+ Cfg: acceptedCfg,
410
+ Status: dyncfg.StatusAccepted,
411
+ })
412
+ mgr.secretStoreDeps.SetActiveJobStores(acceptedCfg.FullName(), "success:nginx", []string{secretstore.StoreKey(secretstore.KindVault, "vault_prod")})
413
+
414
+ testFn := dyncfg.NewFunction(functions.Function{
415
+ UID: "ss-test-all-deps",
416
+ ContentType: "application/json",
417
+ Payload: mustJSON(t, testVaultConfigTokenFile()),
418
+ Args: []string{
419
+ mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")),
420
+ string(dyncfg.CommandTest),
421
+ },
422
+ })
423
+ mgr.dyncfgSecretStoreSeqExec(testFn)
424
+
425
+ var resp map[string]any
426
+ mustDecodeFunctionPayload(t, out.String(), "ss-test-all-deps", &resp)
427
+ assert.Equal(t, float64(202), resp["status"])
428
+ assert.Equal(t, "Updated configuration is used by jobs: success:mysql, success:nginx. Running or failed jobs that would be restarted automatically: success:mysql.", resp["message"])
429
+ },
430
+ },
431
+ "test command reports no affected jobs for changed payload": {
432
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
433
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusRunning)
434
+
435
+ testFn := dyncfg.NewFunction(functions.Function{
436
+ UID: "ss-test-no-affected",
437
+ ContentType: "application/json",
438
+ Payload: mustJSON(t, testVaultConfigTokenFile()),
439
+ Args: []string{
440
+ mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")),
441
+ string(dyncfg.CommandTest),
442
+ },
443
+ })
444
+ mgr.dyncfgSecretStoreSeqExec(testFn)
445
+
446
+ var resp map[string]any
447
+ mustDecodeFunctionPayload(t, out.String(), "ss-test-no-affected", &resp)
448
+ assert.Equal(t, float64(202), resp["status"])
449
+ assert.Equal(t, "No jobs currently use this secretstore.", resp["message"])
450
+ },
451
+ },
452
+ "enable is unsupported": {
453
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
454
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusRunning)
455
+
456
+ enableFn := dyncfg.NewFunction(functions.Function{
457
+ UID: "ss-enable-unsupported",
458
+ Args: []string{
459
+ mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")),
460
+ string(dyncfg.CommandEnable),
461
+ },
462
+ })
463
+ mgr.dyncfgSecretStoreSeqExec(enableFn)
464
+
465
+ var resp map[string]any
466
+ mustDecodeFunctionPayload(t, out.String(), "ss-enable-unsupported", &resp)
467
+ assert.Equal(t, float64(501), resp["status"])
468
+
469
+ entry, ok := mgr.lookupSecretStoreEntry(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
470
+ require.True(t, ok)
471
+ assert.Equal(t, dyncfg.StatusRunning, entry.Status)
472
+ },
473
+ },
474
+ "disable is unsupported": {
475
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
476
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusRunning)
477
+
478
+ disableFn := dyncfg.NewFunction(functions.Function{
479
+ UID: "ss-disable-unsupported",
480
+ Args: []string{mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")), string(dyncfg.CommandDisable)},
481
+ })
482
+ mgr.dyncfgSecretStoreSeqExec(disableFn)
483
+
484
+ var resp map[string]any
485
+ mustDecodeFunctionPayload(t, out.String(), "ss-disable-unsupported", &resp)
486
+ assert.Equal(t, float64(501), resp["status"])
487
+
488
+ entry, ok := mgr.lookupSecretStoreEntry(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
489
+ require.True(t, ok)
490
+ assert.Equal(t, dyncfg.StatusRunning, entry.Status)
491
+ },
492
+ },
493
+ "remove deletes store": {
494
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
495
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusRunning)
496
+
497
+ removeFn := dyncfg.NewFunction(functions.Function{
498
+ UID: "ss-remove",
499
+ Args: []string{mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")), string(dyncfg.CommandRemove)},
500
+ })
501
+ mgr.dyncfgSecretStoreSeqExec(removeFn)
502
+
503
+ var resp map[string]any
504
+ mustDecodeFunctionPayload(t, out.String(), "ss-remove", &resp)
505
+ assert.Equal(t, float64(200), resp["status"])
506
+ _, ok := mgr.lookupSecretStoreEntry(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
507
+ assert.False(t, ok)
508
+ _, ok = mustSecretStoreService(t, mgr).GetStatus(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
509
+ assert.False(t, ok)
510
+ },
511
+ },
512
+ "remove blocks when dependent jobs use the store": {
513
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
514
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusRunning)
515
+
516
+ runningCfg := prepareDyncfgCfg("success", "mysql")
517
+ mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
518
+ Cfg: runningCfg,
519
+ Status: dyncfg.StatusRunning,
520
+ })
521
+ mgr.secretStoreDeps.SetActiveJobStores(runningCfg.FullName(), "success:mysql", []string{secretstore.StoreKey(secretstore.KindVault, "vault_prod")})
522
+
523
+ disabledCfg := prepareDyncfgCfg("success", "nginx")
524
+ mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
525
+ Cfg: disabledCfg,
526
+ Status: dyncfg.StatusDisabled,
527
+ })
528
+ mgr.secretStoreDeps.SetActiveJobStores(disabledCfg.FullName(), "success:nginx", []string{secretstore.StoreKey(secretstore.KindVault, "vault_prod")})
529
+
530
+ removeFn := dyncfg.NewFunction(functions.Function{
531
+ UID: "ss-remove-blocked",
532
+ Args: []string{mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")), string(dyncfg.CommandRemove)},
533
+ })
534
+ mgr.dyncfgSecretStoreSeqExec(removeFn)
535
+
536
+ var resp map[string]any
537
+ mustDecodeFunctionPayload(t, out.String(), "ss-remove-blocked", &resp)
538
+ assert.Equal(t, float64(409), resp["status"])
539
+ assert.Equal(t, "The specified secretstore 'vault:vault_prod' is used by jobs (success:mysql, success:nginx).", resp["errorMessage"])
540
+
541
+ _, ok := mgr.lookupSecretStoreEntry(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
542
+ assert.True(t, ok)
543
+ _, ok = mustSecretStoreService(t, mgr).GetStatus(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
544
+ assert.True(t, ok)
545
+ },
546
+ },
547
+ "userconfig returns yaml from payload": {
548
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
549
+ userconfigFn := dyncfg.NewFunction(functions.Function{
550
+ UID: "ss-userconfig",
551
+ ContentType: "application/json",
552
+ Payload: mustJSON(t, testVaultConfig()),
553
+ Args: []string{
554
+ mgr.dyncfgSecretStoreTemplateID(secretstore.KindVault),
555
+ string(dyncfg.CommandUserconfig),
556
+ },
557
+ })
558
+ mgr.dyncfgSecretStoreSeqExec(userconfigFn)
559
+
560
+ re := regexp.MustCompile(`(?s)FUNCTION_RESULT_BEGIN ss-userconfig [^\n]+\n(.*?)\nFUNCTION_RESULT_END`)
561
+ match := re.FindStringSubmatch(out.String())
562
+ require.Len(t, match, 2)
563
+ var parsed map[string]any
564
+ require.NoError(t, yaml.Unmarshal([]byte(match[1]), &parsed))
565
+ _, ok := parsed["name"]
566
+ assert.False(t, ok)
567
+ _, ok = parsed["kind"]
568
+ assert.False(t, ok)
569
+ },
570
+ },
571
+ "test rejects wrapped config payload": {
572
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
573
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", testVaultConfig(), dyncfg.StatusAccepted)
574
+
575
+ testFn := dyncfg.NewFunction(functions.Function{
576
+ UID: "ss-test-wrapped-config-payload",
577
+ ContentType: "application/json",
578
+ Payload: mustJSON(t, map[string]any{
579
+ "config": testVaultConfigTokenFile(),
580
+ }),
581
+ Args: []string{
582
+ mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")),
583
+ string(dyncfg.CommandTest),
584
+ },
585
+ })
586
+ mgr.dyncfgSecretStoreSeqExec(testFn)
587
+
588
+ var resp map[string]any
589
+ mustDecodeFunctionPayload(t, out.String(), "ss-test-wrapped-config-payload", &resp)
590
+ assert.Equal(t, float64(400), resp["status"])
591
+ assert.Contains(t, resp["errorMessage"], "mode")
592
+ },
593
+ },
594
+ }
595
+
596
+ for name, tc := range tests {
597
+ t.Run(name, func(t *testing.T) {
598
+ mgr, out := newDyncfgSecretStoreTestManager()
599
+ tc.run(t, mgr, out)
600
+ })
601
+ }
602
+}
603
+
604
+func TestSecretStoreConfigFromPayload(t *testing.T) {
605
+ tests := map[string]struct {
606
+ fn functions.Function
607
+ name string
608
+ kind secretstore.StoreKind
609
+ wantErrContains string
610
+ assertConfig func(t *testing.T, cfg secretstore.Config)
611
+ }{
612
+ "json direct config payload": {
613
+ fn: functions.Function{
614
+ ContentType: "application/json",
615
+ Payload: mustJSON(t, testVaultConfig()),
616
+ },
617
+ name: "vault_prod",
618
+ kind: secretstore.KindVault,
619
+ assertConfig: func(t *testing.T, cfg secretstore.Config) {
620
+ require.NotNil(t, cfg)
621
+ assert.Equal(t, "vault_prod", cfg.Name())
622
+ assert.Equal(t, secretstore.KindVault, cfg.Kind())
623
+ },
624
+ },
625
+ "yaml direct config payload": {
626
+ fn: functions.Function{
627
+ Payload: []byte("mode: token\nmode_token:\n token: vault-token\naddr: https://vault.example\n"),
628
+ },
629
+ name: "vault_prod",
630
+ kind: secretstore.KindVault,
631
+ assertConfig: func(t *testing.T, cfg secretstore.Config) {
632
+ require.NotNil(t, cfg)
633
+ assert.Equal(t, "vault_prod", cfg.Name())
634
+ assert.Equal(t, secretstore.KindVault, cfg.Kind())
635
+ },
636
+ },
637
+ "missing payload": {
638
+ fn: functions.Function{
639
+ ContentType: "application/json",
640
+ },
641
+ name: "vault_prod",
642
+ kind: secretstore.KindVault,
643
+ wantErrContains: "missing configuration payload",
644
+ },
645
+ "wrapped config payload becomes invalid raw config": {
646
+ fn: functions.Function{
647
+ ContentType: "application/json",
648
+ Payload: mustJSON(t, map[string]any{
649
+ "config": testVaultConfig(),
650
+ }),
651
+ },
652
+ name: "vault_prod",
653
+ kind: secretstore.KindVault,
654
+ assertConfig: func(t *testing.T, cfg secretstore.Config) {
655
+ require.NotNil(t, cfg)
656
+ assert.Equal(t, "vault_prod", cfg.Name())
657
+ assert.Equal(t, secretstore.KindVault, cfg.Kind())
658
+ },
659
+ },
660
+ }
661
+
662
+ for name, tc := range tests {
663
+ t.Run(name, func(t *testing.T) {
664
+ mgr, _ := newDyncfgSecretStoreTestManager()
665
+ cfg, err := mgr.secretStoreConfigFromPayload(dyncfg.NewFunction(tc.fn), tc.name, tc.kind)
666
+ if tc.wantErrContains != "" {
667
+ require.Error(t, err)
668
+ assert.Contains(t, err.Error(), tc.wantErrContains)
669
+ return
670
+ }
671
+
672
+ require.NoError(t, err)
673
+ if tc.assertConfig != nil {
674
+ tc.assertConfig(t, cfg)
675
+ }
676
+ })
677
+ }
678
+}
679
+
680
+func TestNew_InitializesSecretStoreController(t *testing.T) {
681
+ tests := map[string]struct{}{
682
+ "new manager initializes secretstore controller and service": {},
683
+ }
684
+
685
+ for name := range tests {
686
+ t.Run(name, func(t *testing.T) {
687
+ mgr := New(Config{PluginName: testPluginName})
688
+
689
+ require.NotNil(t, mgr.secretsCtl)
690
+ require.NotNil(t, mgr.secretsCtl.Service())
691
+ assert.Equal(t, mgr.secretsCtl.Prefix(), mgr.dyncfgSecretStorePrefixValue())
692
+ })
693
+ }
694
+}
695
+
696
+func newDyncfgSecretStoreTestManager() (*Manager, *bytes.Buffer) {
697
+ return newDyncfgSecretStoreTestManagerWithService(nil)
698
+}
699
+
700
+func newDyncfgSecretStoreTestManagerWithService(secretStoreSvc secretstore.Service) (*Manager, *bytes.Buffer) {
701
+ var out bytes.Buffer
702
+
703
+ mgr := New(Config{
704
+ PluginName: testPluginName,
705
+ SecretStoreService: secretStoreSvc,
706
+ })
707
+ mgr.ctx = context.Background()
708
+ mgr.modules = prepareMockRegistry()
709
+ mgr.fileStatus = newFileStatus()
710
+ mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&out))))
711
+
712
+ return mgr, &out
713
+}
714
+
715
+func mustSecretStoreService(t *testing.T, mgr *Manager) secretstore.Service {
716
+ t.Helper()
717
+ require.NotNil(t, mgr.secretsCtl)
718
+ svc := mgr.secretsCtl.Service()
719
+ require.NotNil(t, svc)
720
+ return svc
721
+}
722
+
723
+func testVaultConfig() map[string]any {
724
+ return map[string]any{
725
+ "mode": "token",
726
+ "mode_token": map[string]any{
727
+ "token": "vault-token",
728
+ },
729
+ "addr": "https://vault.example",
730
+ }
731
+}
732
+
733
+func testVaultConfigTokenFile() map[string]any {
734
+ return map[string]any{
735
+ "mode": "token_file",
736
+ "mode_token_file": map[string]any{
737
+ "path": "/var/lib/netdata/vault.token",
738
+ },
739
+ "addr": "https://vault.example",
740
+ }
741
+}
742
+
743
+func newSecretStoreFromConfig(t *testing.T, svc secretstore.Service, kind secretstore.StoreKind, name string, cfg map[string]any) secretstore.Config {
744
+ t.Helper()
745
+ _ = svc
746
+ return newSecretStoreConfigWithSource(t, kind, name, cfg, confgroup.TypeDyncfg, confgroup.TypeDyncfg)
747
+}
748
+
749
+func newSecretStoreConfigWithSource(t *testing.T, kind secretstore.StoreKind, name string, cfg map[string]any, source, sourceType string) secretstore.Config {
750
+ t.Helper()
751
+ bs, err := json.Marshal(cfg)
752
+ require.NoError(t, err)
753
+ var payload map[string]any
754
+ require.NoError(t, json.Unmarshal(bs, &payload))
755
+ out := secretstore.Config(payload)
756
+ out.SetName(name)
757
+ out.SetKind(kind)
758
+ out.SetSource(source)
759
+ out.SetSourceType(sourceType)
760
+ return out
761
+}
762
+
763
+func seedSecretStore(t *testing.T, mgr *Manager, kind secretstore.StoreKind, name string, cfg map[string]any, status dyncfg.Status) secretstore.Config {
764
+ t.Helper()
765
+
766
+ switch status {
767
+ case dyncfg.StatusAccepted:
768
+ raw := newSecretStoreFromConfig(t, mustSecretStoreService(t, mgr), kind, name, cfg)
769
+ entry, changed, err := mgr.rememberSecretStoreConfig(raw)
770
+ require.NoError(t, err)
771
+ require.True(t, changed)
772
+ require.NotNil(t, entry)
773
+ return entry.Cfg
774
+ case dyncfg.StatusRunning, dyncfg.StatusFailed:
775
+ fn := dyncfg.NewFunction(functions.Function{
776
+ UID: "seed-" + string(kind) + "-" + name + "-" + status.String(),
777
+ ContentType: "application/json",
778
+ Payload: mustJSON(t, cfg),
779
+ Args: []string{
780
+ mgr.dyncfgSecretStoreTemplateID(kind),
781
+ string(dyncfg.CommandAdd),
782
+ name,
783
+ },
784
+ })
785
+ mgr.dyncfgSecretStoreSeqExec(fn)
786
+
787
+ entry, ok := mgr.lookupSecretStoreEntry(secretstore.StoreKey(kind, name))
788
+ require.True(t, ok)
789
+ require.Equal(t, status, entry.Status)
790
+ return entry.Cfg
791
+ default:
792
+ t.Fatalf("unsupported secretstore seed status %q", status)
793
+ return nil
794
+ }
795
+}
796
+
797
+func mustJSON(t *testing.T, v any) []byte {
798
+ t.Helper()
799
+ bs, err := json.Marshal(v)
800
+ require.NoError(t, err)
801
+ return bs
802
+}
803
+
804
+func mustDecodeFunctionPayload(t *testing.T, output, uid string, dst any) {
805
+ t.Helper()
806
+
807
+ re := regexp.MustCompile(`(?s)FUNCTION_RESULT_BEGIN ` + regexp.QuoteMeta(uid) + ` [^\n]+\n(.*?)\nFUNCTION_RESULT_END`)
808
+ match := re.FindStringSubmatch(output)
809
+ require.Len(t, match, 2, "function result for uid '%s' not found in output:\n%s", uid, output)
810
+ require.NoError(t, json.Unmarshal([]byte(match[1]), dst))
811
+}
src/go/plugin/agent/jobmgr/dyncfg_secretstore_test_helpers_test.go
new
+68
@@ -0,0 +1,68 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package jobmgr
4
+
5
+import (
6
+ "fmt"
7
+
8
+ "github.com/netdata/netdata/go/plugins/plugin/agent/jobmgr/secretsctl"
9
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
10
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
11
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
12
+)
13
+
14
+func (m *Manager) dyncfgSecretStoreTemplateID(kind secretstore.StoreKind) string {
15
+ return fmt.Sprintf("%s%s", m.dyncfgSecretStorePrefixValue(), kind)
16
+}
17
+
18
+func (m *Manager) dyncfgSecretStoreID(id string) string {
19
+ return fmt.Sprintf("%s%s", m.dyncfgSecretStorePrefixValue(), id)
20
+}
21
+
22
+func (m *Manager) secretStoreConfigFromPayload(fn dyncfg.Function, name string, kind secretstore.StoreKind) (secretstore.Config, error) {
23
+ if err := fn.ValidateHasPayload(); err != nil {
24
+ return nil, err
25
+ }
26
+
27
+ var payload secretstore.Config
28
+ if err := fn.UnmarshalPayload(&payload); err != nil {
29
+ return nil, fmt.Errorf("invalid configuration format: %w", err)
30
+ }
31
+ if payload == nil {
32
+ payload = secretstore.Config{}
33
+ }
34
+ payload.SetName(name)
35
+ payload.SetKind(kind)
36
+ payload.SetSource(confgroup.TypeDyncfg)
37
+ payload.SetSourceType(confgroup.TypeDyncfg)
38
+ return payload, nil
39
+}
40
+
41
+func (m *Manager) lookupSecretStoreEntry(key string) (*dyncfg.Entry[secretstore.Config], bool) {
42
+ entry, ok := m.secretsCtl.Lookup(key)
43
+ return dyncfgSecretStoreTestEntry(entry, ok)
44
+}
45
+
46
+func (m *Manager) rememberSecretStoreConfig(cfg secretstore.Config) (*dyncfg.Entry[secretstore.Config], bool, error) {
47
+ entry, changed, err := m.secretsCtl.RememberDiscoveredConfig(cfg)
48
+ if err != nil || !changed {
49
+ return nil, changed, err
50
+ }
51
+ got, _ := dyncfgSecretStoreTestEntry(entry, true)
52
+ return got, true, nil
53
+}
54
+
55
+func (m *Manager) removeSecretStoreConfig(cfg secretstore.Config) (*dyncfg.Entry[secretstore.Config], bool) {
56
+ entry, ok := m.secretsCtl.RemoveDiscoveredConfig(cfg)
57
+ return dyncfgSecretStoreTestEntry(entry, ok)
58
+}
59
+
60
+func dyncfgSecretStoreTestEntry(entry secretsctl.Entry, ok bool) (*dyncfg.Entry[secretstore.Config], bool) {
61
+ if !ok {
62
+ return nil, false
63
+ }
64
+ return &dyncfg.Entry[secretstore.Config]{
65
+ Cfg: entry.Cfg,
66
+ Status: entry.Status,
67
+ }, true
68
+}
src/go/plugin/agent/jobmgr/dyncfg_vnode.go
+15
-363
@@ -3,393 +3,45 @@
3
package jobmgr
4
5
import (
6
- "encoding/json"
6
"fmt"
8
- "strings"
7
10
- "github.com/google/uuid"
11
- "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
12
- "gopkg.in/yaml.v2"
13
-
14
- "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
8
"github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
9
"github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
17
-)
18
-
19
-const (
20
- dyncfgVnodeIDf = "%s:vnode"
21
- dyncfgVnodePath = "/collectors/%s/Vnodes"
10
+ "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
11
)
12
13
func (m *Manager) dyncfgVnodePrefixValue() string {
25
- return fmt.Sprintf(dyncfgVnodeIDf, m.pluginName)
26
-}
27
-
28
-func dyncfgVnodeModCmds() string {
29
- return dyncfg.JoinCommands(
30
- dyncfg.CommandAdd,
31
- dyncfg.CommandSchema,
32
- dyncfg.CommandUserconfig,
33
- dyncfg.CommandTest,
34
- )
35
-}
36
-func dyncfgVnodeJobCmds(isDyncfgJob bool) string {
37
- cmds := []dyncfg.Command{
38
- dyncfg.CommandUserconfig,
39
- dyncfg.CommandSchema,
40
- dyncfg.CommandGet,
41
- dyncfg.CommandUpdate,
42
- dyncfg.CommandTest,
43
- }
44
- if isDyncfgJob {
45
- cmds = append(cmds, dyncfg.CommandRemove)
46
- }
47
- return dyncfg.JoinCommands(cmds...)
48
-}
49
-
50
-func (m *Manager) dyncfgVnodeModuleCreate() {
51
- m.dyncfgApi.ConfigCreate(netdataapi.ConfigOpts{
52
- ID: m.dyncfgVnodePrefixValue(),
53
- Status: dyncfg.StatusAccepted.String(),
54
- ConfigType: dyncfg.ConfigTypeTemplate.String(),
55
- Path: fmt.Sprintf(dyncfgVnodePath, m.pluginName),
56
- SourceType: "internal",
57
- Source: "internal",
58
- SupportedCommands: dyncfgVnodeModCmds(),
59
- })
60
-}
61
-
62
-func (m *Manager) dyncfgVnodeJobCreate(cfg *vnodes.VirtualNode, status dyncfg.Status) {
63
- m.dyncfgApi.ConfigCreate(netdataapi.ConfigOpts{
64
- ID: fmt.Sprintf("%s:%s", m.dyncfgVnodePrefixValue(), cfg.Name),
65
- Status: status.String(),
66
- ConfigType: dyncfg.ConfigTypeJob.String(),
67
- Path: fmt.Sprintf(dyncfgVnodePath, m.pluginName),
68
- SourceType: cfg.SourceType,
69
- Source: cfg.Source,
70
- SupportedCommands: dyncfgVnodeJobCmds(cfg.SourceType == confgroup.TypeDyncfg),
71
- })
14
+ return m.vnodesCtl.Prefix()
15
}
16
17
func (m *Manager) dyncfgVnodeExec(fn dyncfg.Function) {
75
- cmd := fn.Command()
76
-
77
- switch cmd {
78
- case dyncfg.CommandUserconfig:
79
- m.dyncfgVnodeUserconfig(fn)
80
- return
81
- case dyncfg.CommandSchema:
82
- m.dyncfgApi.SendJSON(fn, vnodes.ConfigSchema)
18
+ switch fn.Command() {
19
+ case dyncfg.CommandSchema, dyncfg.CommandUserconfig:
20
+ m.dyncfgVnodeSeqExec(fn)
21
return
22
}
23
m.enqueueDyncfgFunction(fn)
24
}
25
26
func (m *Manager) dyncfgVnodeSeqExec(fn dyncfg.Function) {
89
- cmd := fn.Command()
90
-
91
- switch cmd {
92
- case dyncfg.CommandTest:
93
- m.dyncfgVnodeTest(fn)
94
- case dyncfg.CommandGet:
95
- m.dyncfgVnodeGet(fn)
96
- case dyncfg.CommandAdd:
97
- m.dyncfgVnodeAdd(fn)
98
- case dyncfg.CommandUpdate:
99
- m.dyncfgVnodeUpdate(fn)
100
- case dyncfg.CommandRemove:
101
- m.dyncfgVnodeRemove(fn)
102
- default:
103
- m.Warningf("dyncfg: function '%s' command '%s' not implemented", fn.Fn().Name, cmd)
104
- m.dyncfgApi.SendCodef(fn, 501, "Function '%s' command '%s' is not implemented.", fn.Fn().Name, cmd)
105
- }
106
-}
107
-
108
-func (m *Manager) dyncfgVnodeGet(fn dyncfg.Function) {
109
- cmd := dyncfg.CommandGet
110
-
111
- id := fn.ID()
112
- name := strings.TrimPrefix(id, m.dyncfgVnodePrefixValue()+":")
113
-
114
- cfg, ok := m.vnodes.Lookup(name)
115
- if !ok {
116
- m.Warningf("dyncfg: %s: vnode %s not found", cmd, name)
117
- m.dyncfgApi.SendCodef(fn, 404, "The specified vnode '%s' is not registered.", name)
118
- return
119
- }
120
-
121
- bs, err := json.Marshal(cfg)
122
- if err != nil {
123
- m.Warningf("dyncfg: %s: vnode job %s failed to json marshal config: %v", cmd, name, err)
124
- m.dyncfgApi.SendCodef(fn, 500, "Failed to convert configuration into JSON: %v.", err)
125
- return
126
- }
127
-
128
- m.dyncfgApi.SendJSON(fn, string(bs))
129
-}
130
-
131
-func (m *Manager) dyncfgVnodeAdd(fn dyncfg.Function) {
132
- cmd := dyncfg.CommandAdd
133
-
134
- if err := fn.ValidateArgs(3); err != nil {
135
- m.Warningf("dyncfg: %s: %v", cmd, err)
136
- m.dyncfgApi.SendCodef(fn, 400, "%v", err)
137
- return
138
- }
139
-
140
- name := fn.JobName()
141
-
142
- if !fn.HasPayload() {
143
- m.Warningf("dyncfg: %s: vnode job %s missing configuration payload.", cmd, name)
144
- m.dyncfgApi.SendCodef(fn, 400, "Missing configuration payload.")
145
- return
146
- }
147
-
148
- cfg, err := vnodeConfigFromPayload(fn)
149
- if err != nil {
150
- m.Warningf("dyncfg: %s: vnode job %s: failed to create config from payload: %v", cmd, name, err)
151
- m.dyncfgApi.SendCodef(fn, 400, "Failed to create configuration from payload. Invalid configuration format: %v.", err)
152
- return
153
- }
154
-
155
- if err := uuid.Validate(cfg.GUID); err != nil {
156
- m.Warningf("dyncfg: %s: vnode job %s: invalid guid: %v", cmd, name, err)
157
- m.dyncfgApi.SendCodef(fn, 400, "Failed to create configuration from payload. Invalid guid format: %v.", err)
158
- return
159
- }
160
-
161
- dyncfgUpdateVnodeConfig(cfg, name, fn)
162
-
163
- if err := m.verifyVnodeUnique(cfg); err != nil {
164
- m.Warningf("dyncfg: %s: vnode job %s: %v", cmd, name, err)
165
- m.dyncfgApi.SendCodef(fn, 400, "Failed to create configuration from payload: %v.", err)
166
- return
167
- }
168
-
169
- if orig, ok := m.vnodes.Lookup(name); ok && orig.Equal(cfg) {
170
- m.dyncfgApi.SendCodef(fn, 202, "")
171
- m.dyncfgVnodeJobCreate(cfg, dyncfg.StatusRunning)
172
- return
173
- }
174
-
175
- _, _, err = m.vnodes.Upsert(cfg)
176
- if err != nil {
177
- m.Warningf("dyncfg: %s: vnode job %s: %v", cmd, name, err)
178
- m.dyncfgApi.SendCodef(fn, 400, "Failed to update vnode configuration: %v.", err)
179
- return
180
- }
181
-
182
- for _, job := range m.runningJobs.snapshot() {
183
- if job.Vnode().Name == name {
184
- job.UpdateVnode(cfg)
185
- }
186
- }
187
-
188
- m.dyncfgApi.SendCodef(fn, 202, "")
189
- m.dyncfgVnodeJobCreate(cfg, dyncfg.StatusRunning)
190
-}
191
-
192
-func (m *Manager) dyncfgVnodeRemove(fn dyncfg.Function) {
193
- cmd := dyncfg.CommandRemove
194
-
195
- id := fn.ID()
196
- name := strings.TrimPrefix(id, m.dyncfgVnodePrefixValue()+":")
197
-
198
- vnode, ok := m.vnodes.Lookup(name)
199
- if !ok {
200
- m.Warningf("dyncfg: %s: vnode %s not found", cmd, name)
201
- m.dyncfgApi.SendCodef(fn, 404, "The specified vnode '%s' is not registered.", name)
202
- return
203
- }
204
- if vnode.SourceType != confgroup.TypeDyncfg {
205
- m.Warningf("dyncfg: %s: module vnode %s: can not remove vnode of type %s", cmd, vnode.Name, vnode.SourceType)
206
- m.dyncfgApi.SendCodef(fn, 405, "Removing vnode of type '%s' is not supported. Only 'dyncfg' vnodes can be removed.", vnode.SourceType)
207
- return
208
- }
209
-
210
- if s := m.dyncfgVnodeAffectedJobs(vnode.Name); s != "" {
211
- m.Warningf("dyncfg: %s: vnode %s has running jobs (%s)", cmd, name, s)
212
- m.dyncfgApi.SendCodef(fn, 404, "The specified vnode '%s' has running jobs (%s).", name, s)
213
- return
214
- }
215
-
216
- _, _ = m.vnodes.Remove(name)
217
-
218
- m.dyncfgApi.ConfigDelete(id)
219
- m.dyncfgApi.SendCodef(fn, 200, "")
220
-}
221
-
222
-func (m *Manager) dyncfgVnodeTest(fn dyncfg.Function) {
223
- cmd := dyncfg.CommandTest
224
-
225
- if err := fn.ValidateArgs(3); err != nil {
226
- m.Warningf("dyncfg: %s: %v", cmd, err)
227
- m.dyncfgApi.SendCodef(fn, 400, "%v", err)
228
- return
229
- }
230
-
231
- name := fn.JobName()
232
-
233
- cfg, err := vnodeConfigFromPayload(fn)
234
- if err != nil {
235
- m.Warningf("dyncfg: %s: vnode: failed to create config from payload: %v", cmd, err)
236
- m.dyncfgApi.SendCodef(fn, 400, "Invalid configuration format. Failed to create configuration from payload: %v.", err)
237
- return
238
- }
239
-
240
- if err := uuid.Validate(cfg.GUID); err != nil {
241
- m.Warningf("dyncfg: %s: vnode job %s: invalid guid: %v", cmd, name, err)
242
- m.dyncfgApi.SendCodef(fn, 400, "Failed to create configuration from payload. Invalid guid format: %v.", err)
243
- return
244
- }
245
-
246
- dyncfgUpdateVnodeConfig(cfg, name, fn)
247
-
248
- if err := m.verifyVnodeUnique(cfg); err != nil {
249
- m.Warningf("dyncfg: %s: vnode job %s: %v", cmd, name, err)
250
- m.dyncfgApi.SendCodef(fn, 400, "Failed to create configuration from payload: %v.", err)
251
- return
252
- }
253
-
254
- if s := m.dyncfgVnodeAffectedJobs(cfg.Name); s != "" {
255
- m.dyncfgApi.SendCodef(fn, 202, "Updated configuration will affect: %s.", s)
256
- } else {
257
- m.dyncfgApi.SendCodef(fn, 202, "No jobs will be affected by this change.")
258
- }
259
-}
260
-
261
-func (m *Manager) dyncfgVnodeUpdate(fn dyncfg.Function) {
262
- cmd := dyncfg.CommandUpdate
263
-
264
- id := fn.ID()
265
- name := strings.TrimPrefix(id, m.dyncfgVnodePrefixValue()+":")
266
-
267
- orig, ok := m.vnodes.Lookup(name)
268
- if !ok {
269
- m.Warningf("dyncfg: %s: vnode %s not found", cmd, name)
270
- m.dyncfgApi.SendCodef(fn, 404, "The specified vnode '%s' is not registered.", name)
271
- return
272
- }
273
-
274
- cfg, err := vnodeConfigFromPayload(fn)
275
- if err != nil {
276
- m.Warningf("dyncfg: %s: vnode: failed to create config from payload: %v", cmd, err)
277
- m.dyncfgApi.SendCodef(fn, 400, "Invalid configuration format. Failed to create configuration from payload: %v.", err)
278
- return
279
- }
280
-
281
- if err := uuid.Validate(cfg.GUID); err != nil {
282
- m.Warningf("dyncfg: %s: vnode job %s: invalid guid: %v", cmd, name, err)
283
- m.dyncfgApi.SendCodef(fn, 400, "Failed to create configuration from payload. Invalid guid format: %v.", err)
284
- return
285
- }
286
-
287
- dyncfgUpdateVnodeConfig(cfg, name, fn)
288
-
289
- if orig.Equal(cfg) {
290
- m.dyncfgApi.SendCodef(fn, 202, "")
291
- return
292
- }
293
-
294
- _, _, err = m.vnodes.Upsert(cfg)
295
- if err != nil {
296
- m.Warningf("dyncfg: %s: vnode job %s: %v", cmd, name, err)
297
- m.dyncfgApi.SendCodef(fn, 400, "Failed to update vnode configuration: %v.", err)
298
- return
299
- }
300
-
301
- for _, job := range m.runningJobs.snapshot() {
302
- if job.Vnode().Name == name {
303
- job.UpdateVnode(cfg)
304
- }
305
- }
306
-
307
- m.dyncfgApi.SendCodef(fn, 202, "")
308
- m.dyncfgVnodeJobCreate(cfg, dyncfg.StatusRunning)
27
+ m.vnodesCtl.SeqExec(fn)
28
}
29
311
-func (m *Manager) dyncfgVnodeUserconfig(fn dyncfg.Function) {
312
- cmd := dyncfg.CommandUserconfig
313
-
314
- bs, err := vnodeUserconfigFromPayload(fn)
315
- if err != nil {
316
- m.Warningf("dyncfg: %s: vnode: failed to create config from payload: %v", cmd, err)
317
- m.dyncfgApi.SendCodef(fn, 400, "Invalid configuration format. Failed to create configuration from payload: %v.", err)
318
- return
319
- }
320
-
321
- m.dyncfgApi.SendYAML(fn, string(bs))
322
-}
323
-
324
-func (m *Manager) dyncfgVnodeAffectedJobs(vnode string) string {
325
- var s strings.Builder
326
- m.exposed.ForEach(func(_ string, entry *dyncfg.Entry[confgroup.Config]) bool {
30
+func (m *Manager) affectedVnodeJobs(vnode string) []string {
31
+ var jobs []string
32
+ m.collectorExposed.ForEach(func(_ string, entry *dyncfg.Entry[confgroup.Config]) bool {
33
if entry.Cfg.Vnode() == vnode {
328
- if s.Len() > 0 {
329
- s.WriteString(", ")
330
- }
331
- s.WriteString(fmt.Sprintf("%s:%s", entry.Cfg.Module(), entry.Cfg.Name()))
34
+ jobs = append(jobs, fmt.Sprintf("%s:%s", entry.Cfg.Module(), entry.Cfg.Name()))
35
}
36
return true
37
})
335
- return s.String()
38
+ return jobs
39
}
40
338
-func (m *Manager) verifyVnodeUnique(newCfg *vnodes.VirtualNode) error {
339
- var err error
340
- m.vnodes.ForEach(func(cfg *vnodes.VirtualNode) bool {
341
- if cfg.Name == newCfg.Name {
342
- return true
343
- }
344
- if cfg.Hostname == newCfg.Hostname {
345
- err = fmt.Errorf("duplicate virtual node name detected (job '%s')", cfg.Name)
346
- return false
347
- }
348
- if cfg.GUID == newCfg.GUID {
349
- err = fmt.Errorf("duplicate virtual node guid detected (job '%s')", cfg.Name)
350
- return false
41
+func (m *Manager) applyVnodeUpdate(name string, cfg *vnodes.VirtualNode) {
42
+ for _, job := range m.runningJobs.snapshot() {
43
+ if job.Vnode().Name == name {
44
+ job.UpdateVnode(cfg)
45
}
352
- return true
353
- })
354
- return err
355
-}
356
-
357
-func dyncfgUpdateVnodeConfig(cfg *vnodes.VirtualNode, name string, fn dyncfg.Function) {
358
- cfg.SourceType = confgroup.TypeDyncfg
359
- cfg.Source = fn.Source()
360
- cfg.Name = name
361
- if cfg.Hostname == "" {
362
- cfg.Hostname = name
363
- }
364
-}
365
-
366
-func vnodeConfigFromPayload(fn dyncfg.Function) (*vnodes.VirtualNode, error) {
367
- var cfg vnodes.VirtualNode
368
-
369
- if err := fn.UnmarshalPayload(&cfg); err != nil {
370
- return nil, err
371
- }
372
-
373
- return &cfg, nil
374
-}
375
-
376
-func vnodeUserconfigFromPayload(fn dyncfg.Function) ([]byte, error) {
377
- cfg, err := vnodeConfigFromPayload(fn)
378
- if err != nil {
379
- return nil, err
380
- }
381
-
382
- name := fn.JobName()
383
- if name == "" {
384
- name = "test"
46
}
386
-
387
- dyncfgUpdateVnodeConfig(cfg, name, fn)
388
-
389
- bs, err := yaml.Marshal([]any{cfg})
390
- if err != nil {
391
- return nil, err
392
- }
393
-
394
- return bs, nil
47
}
src/go/plugin/agent/jobmgr/dyncfg_vnode_test.go
new
+338
@@ -0,0 +1,338 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package jobmgr
4
+
5
+import (
6
+ "bytes"
7
+ "context"
8
+ "encoding/json"
9
+ "strings"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
14
+ "github.com/netdata/netdata/go/plugins/pkg/safewriter"
15
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
16
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
17
+ "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
18
+ "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
19
+ "github.com/stretchr/testify/assert"
20
+ "github.com/stretchr/testify/require"
21
+)
22
+
23
+func TestDyncfgVnodeExec_Dispatch(t *testing.T) {
24
+ tests := map[string]struct {
25
+ fn func(t *testing.T, mgr *Manager) dyncfg.Function
26
+ wantQueuedCmd dyncfg.Command
27
+ assertDirect func(t *testing.T, out string)
28
+ }{
29
+ "schema stays direct": {
30
+ fn: func(t *testing.T, mgr *Manager) dyncfg.Function {
31
+ return dyncfg.NewFunction(functions.Function{UID: "vn-schema", Args: []string{mgr.dyncfgVnodePrefixValue(), string(dyncfg.CommandSchema)}})
32
+ },
33
+ assertDirect: func(t *testing.T, out string) {
34
+ assert.Contains(t, out, "FUNCTION_RESULT_BEGIN vn-schema 200 application/json")
35
+ },
36
+ },
37
+ "userconfig stays direct": {
38
+ fn: func(t *testing.T, mgr *Manager) dyncfg.Function {
39
+ return dyncfg.NewFunction(functions.Function{
40
+ UID: "vn-userconfig",
41
+ ContentType: "application/json",
42
+ Payload: mustJSON(t, map[string]any{"guid": "11111111-1111-1111-1111-111111111111"}),
43
+ Args: []string{mgr.dyncfgVnodePrefixValue(), string(dyncfg.CommandUserconfig), "db"},
44
+ })
45
+ },
46
+ assertDirect: func(t *testing.T, out string) {
47
+ assert.Contains(t, out, "FUNCTION_RESULT_BEGIN vn-userconfig 200 application/yaml")
48
+ assert.Contains(t, out, "name: db")
49
+ },
50
+ },
51
+ "add is queued": {
52
+ fn: func(t *testing.T, mgr *Manager) dyncfg.Function {
53
+ return dyncfg.NewFunction(functions.Function{
54
+ UID: "vn-add",
55
+ ContentType: "application/json",
56
+ Payload: mustJSON(t, map[string]any{"guid": "11111111-1111-1111-1111-111111111111"}),
57
+ Args: []string{mgr.dyncfgVnodePrefixValue(), string(dyncfg.CommandAdd), "db"},
58
+ })
59
+ },
60
+ wantQueuedCmd: dyncfg.CommandAdd,
61
+ },
62
+ "update is queued": {
63
+ fn: func(t *testing.T, mgr *Manager) dyncfg.Function {
64
+ return dyncfg.NewFunction(functions.Function{
65
+ UID: "vn-update",
66
+ ContentType: "application/json",
67
+ Payload: mustJSON(t, map[string]any{"guid": "11111111-1111-1111-1111-111111111111"}),
68
+ Args: []string{mgr.dyncfgVnodePrefixValue() + ":db", string(dyncfg.CommandUpdate)},
69
+ })
70
+ },
71
+ wantQueuedCmd: dyncfg.CommandUpdate,
72
+ },
73
+ "remove is queued": {
74
+ fn: func(t *testing.T, mgr *Manager) dyncfg.Function {
75
+ return dyncfg.NewFunction(functions.Function{UID: "vn-remove", Args: []string{mgr.dyncfgVnodePrefixValue() + ":db", string(dyncfg.CommandRemove)}})
76
+ },
77
+ wantQueuedCmd: dyncfg.CommandRemove,
78
+ },
79
+ "test is queued": {
80
+ fn: func(t *testing.T, mgr *Manager) dyncfg.Function {
81
+ return dyncfg.NewFunction(functions.Function{
82
+ UID: "vn-test",
83
+ ContentType: "application/json",
84
+ Payload: mustJSON(t, map[string]any{"guid": "11111111-1111-1111-1111-111111111111"}),
85
+ Args: []string{mgr.dyncfgVnodePrefixValue(), string(dyncfg.CommandTest), "db"},
86
+ })
87
+ },
88
+ wantQueuedCmd: dyncfg.CommandTest,
89
+ },
90
+ }
91
+
92
+ for name, tc := range tests {
93
+ t.Run(name, func(t *testing.T) {
94
+ var buf bytes.Buffer
95
+ mgr := New(Config{PluginName: testPluginName})
96
+ mgr.ctx = context.Background()
97
+ mgr.dyncfgCh = make(chan dyncfg.Function, 1)
98
+ mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
99
+
100
+ fn := tc.fn(t, mgr)
101
+ mgr.dyncfgVnodeExec(fn)
102
+
103
+ if tc.wantQueuedCmd != "" {
104
+ select {
105
+ case queued := <-mgr.dyncfgCh:
106
+ assert.Equal(t, tc.wantQueuedCmd, queued.Command())
107
+ assert.Equal(t, fn.UID(), queued.UID())
108
+ assert.Equal(t, "", strings.TrimSpace(buf.String()))
109
+ case <-time.After(time.Second):
110
+ t.Fatal("vnode command was not queued")
111
+ }
112
+ return
113
+ }
114
+
115
+ select {
116
+ case queued := <-mgr.dyncfgCh:
117
+ t.Fatalf("unexpected queued vnode command: %s", queued.Command())
118
+ default:
119
+ }
120
+ tc.assertDirect(t, buf.String())
121
+ })
122
+ }
123
+}
124
+
125
+func TestRun_PublishesExistingVnodesThroughController(t *testing.T) {
126
+ tests := map[string]struct {
127
+ run func(t *testing.T, out *bytes.Buffer, fnReg *recordingFunctionRegistry)
128
+ }{
129
+ "startup publishes vnode module and existing job": {
130
+ run: func(t *testing.T, out *bytes.Buffer, fnReg *recordingFunctionRegistry) {
131
+ assert.Contains(t, out.String(), "CONFIG test:vnode create accepted template /collectors/test/Vnodes")
132
+ assert.Contains(t, out.String(), "CONFIG test:vnode:db create running job /collectors/test/Vnodes")
133
+ prefixes := fnReg.registeredPrefixes()
134
+ assert.Contains(t, prefixes, registeredPrefix{name: "config", prefix: "test:vnode"})
135
+ },
136
+ },
137
+ }
138
+
139
+ for name, tc := range tests {
140
+ t.Run(name, func(t *testing.T) {
141
+ var buf bytes.Buffer
142
+ fnReg := &recordingFunctionRegistry{}
143
+ mgr := New(Config{
144
+ PluginName: testPluginName,
145
+ Out: &buf,
146
+ FnReg: fnReg,
147
+ Vnodes: map[string]*vnodes.VirtualNode{
148
+ "db": {
149
+ Name: "db",
150
+ Hostname: "db",
151
+ GUID: "11111111-1111-1111-1111-111111111111",
152
+ SourceType: confgroup.TypeDyncfg,
153
+ Source: confgroup.TypeDyncfg,
154
+ },
155
+ },
156
+ })
157
+
158
+ ctx, cancel := context.WithCancel(context.Background())
159
+ in := make(chan []*confgroup.Group)
160
+ done := make(chan struct{})
161
+ go func() {
162
+ mgr.Run(ctx, in)
163
+ close(done)
164
+ }()
165
+
166
+ waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second)
167
+ defer waitCancel()
168
+ require.True(t, mgr.WaitStarted(waitCtx))
169
+
170
+ cancel()
171
+ close(in)
172
+ select {
173
+ case <-done:
174
+ case <-time.After(2 * time.Second):
175
+ t.Fatal("manager did not stop after cancel")
176
+ }
177
+
178
+ tc.run(t, &buf, fnReg)
179
+ })
180
+ }
181
+}
182
+
183
+func TestCreateCollectorJob_UsesVnodeControllerLookup(t *testing.T) {
184
+ tests := map[string]struct {
185
+ cfg confgroup.Config
186
+ vnodes map[string]*vnodes.VirtualNode
187
+ wantErr string
188
+ run func(t *testing.T, job runtimeJob)
189
+ }{
190
+ "existing vnode is copied into created job": {
191
+ cfg: prepareDyncfgCfg("success", "mysql").Set("vnode", "db"),
192
+ vnodes: map[string]*vnodes.VirtualNode{
193
+ "db": {
194
+ Name: "db",
195
+ Hostname: "db",
196
+ GUID: "11111111-1111-1111-1111-111111111111",
197
+ SourceType: confgroup.TypeDyncfg,
198
+ Source: confgroup.TypeDyncfg,
199
+ },
200
+ },
201
+ run: func(t *testing.T, job runtimeJob) {
202
+ assert.Equal(t, "db", job.Vnode().Name)
203
+ },
204
+ },
205
+ "missing vnode returns an error": {
206
+ cfg: prepareDyncfgCfg("success", "mysql").Set("vnode", "db"),
207
+ wantErr: "vnode 'db' is not found",
208
+ },
209
+ }
210
+
211
+ for name, tc := range tests {
212
+ t.Run(name, func(t *testing.T) {
213
+ mgr := New(Config{PluginName: testPluginName, Vnodes: tc.vnodes})
214
+ mgr.modules = prepareMockRegistry()
215
+
216
+ job, err := mgr.createCollectorJob(tc.cfg)
217
+ if tc.wantErr != "" {
218
+ require.Error(t, err)
219
+ assert.Contains(t, err.Error(), tc.wantErr)
220
+ return
221
+ }
222
+
223
+ require.NoError(t, err)
224
+ tc.run(t, job)
225
+ })
226
+ }
227
+}
228
+
229
+func TestDyncfgCmdTest_ValidatesVnodeThroughControllerLookup(t *testing.T) {
230
+ tests := map[string]struct {
231
+ run func(t *testing.T, mgr *Manager, out *bytes.Buffer)
232
+ }{
233
+ "missing vnode returns 400 before worker execution": {
234
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
235
+ cfg := prepareDyncfgCfg("success", "job").Set("vnode", "missing")
236
+ payload, err := json.Marshal(cfg)
237
+ require.NoError(t, err)
238
+
239
+ fn := dyncfg.NewFunction(functions.Function{
240
+ UID: "collector-vnode-missing",
241
+ ContentType: "application/json",
242
+ Payload: payload,
243
+ Args: []string{mgr.dyncfgModID("success"), string(dyncfg.CommandTest), "job"},
244
+ })
245
+
246
+ mgr.dyncfgCmdTest(fn)
247
+
248
+ var resp map[string]any
249
+ mustDecodeFunctionPayload(t, out.String(), "collector-vnode-missing", &resp)
250
+ assert.Equal(t, float64(400), resp["status"])
251
+ assert.Contains(t, resp["errorMessage"], "missing")
252
+ },
253
+ },
254
+ }
255
+
256
+ for name, tc := range tests {
257
+ t.Run(name, func(t *testing.T) {
258
+ var buf bytes.Buffer
259
+ mgr := New(Config{PluginName: testPluginName})
260
+ mgr.modules = prepareMockRegistry()
261
+ mgr.ctx = context.Background()
262
+ mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
263
+ tc.run(t, mgr, &buf)
264
+ })
265
+ }
266
+}
267
+
268
+func TestApplyVnodeUpdate_UpdatesMatchingRunningJobs(t *testing.T) {
269
+ mgr := New(Config{PluginName: testPluginName})
270
+
271
+ dbJob := &vnodeUpdateProbeJob{
272
+ fullName: "success_db",
273
+ module: "success",
274
+ name: "db",
275
+ vnode: vnodes.VirtualNode{Name: "db"},
276
+ }
277
+ otherJob := &vnodeUpdateProbeJob{
278
+ fullName: "success_other",
279
+ module: "success",
280
+ name: "other",
281
+ vnode: vnodes.VirtualNode{Name: "other"},
282
+ }
283
+
284
+ mgr.runningJobs.lock()
285
+ mgr.runningJobs.add(dbJob.FullName(), dbJob)
286
+ mgr.runningJobs.add(otherJob.FullName(), otherJob)
287
+ mgr.runningJobs.unlock()
288
+
289
+ next := &vnodes.VirtualNode{
290
+ Name: "db",
291
+ Hostname: "db-new",
292
+ GUID: "11111111-1111-1111-1111-111111111111",
293
+ SourceType: confgroup.TypeDyncfg,
294
+ Source: confgroup.TypeDyncfg,
295
+ }
296
+
297
+ mgr.applyVnodeUpdate("db", next)
298
+
299
+ require.NotNil(t, dbJob.updated)
300
+ assert.Equal(t, "db-new", dbJob.updated.Hostname)
301
+ assert.Nil(t, otherJob.updated)
302
+}
303
+
304
+type vnodeUpdateProbeJob struct {
305
+ fullName string
306
+ module string
307
+ name string
308
+ vnode vnodes.VirtualNode
309
+ updated *vnodes.VirtualNode
310
+}
311
+
312
+func (j *vnodeUpdateProbeJob) FullName() string { return j.fullName }
313
+func (j *vnodeUpdateProbeJob) ModuleName() string { return j.module }
314
+func (j *vnodeUpdateProbeJob) Name() string { return j.name }
315
+func (j *vnodeUpdateProbeJob) Collector() any { return nil }
316
+func (j *vnodeUpdateProbeJob) Start() {}
317
+func (j *vnodeUpdateProbeJob) Stop() {}
318
+func (j *vnodeUpdateProbeJob) Tick(int) {}
319
+func (j *vnodeUpdateProbeJob) AutoDetection() error {
320
+ return nil
321
+}
322
+func (j *vnodeUpdateProbeJob) AutoDetectionEvery() int { return 0 }
323
+func (j *vnodeUpdateProbeJob) RetryAutoDetection() bool {
324
+ return false
325
+}
326
+func (j *vnodeUpdateProbeJob) Cleanup() {}
327
+func (j *vnodeUpdateProbeJob) IsRunning() bool { return true }
328
+func (j *vnodeUpdateProbeJob) Panicked() bool { return false }
329
+func (j *vnodeUpdateProbeJob) Vnode() vnodes.VirtualNode { return j.vnode }
330
+func (j *vnodeUpdateProbeJob) UpdateVnode(vnode *vnodes.VirtualNode) {
331
+ if vnode == nil {
332
+ j.updated = nil
333
+ return
334
+ }
335
+ copy := *vnode
336
+ j.updated = ©
337
+ j.vnode = copy
338
+}
src/go/plugin/agent/jobmgr/funcctl/controller.go
new
+268
@@ -0,0 +1,268 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package funcctl
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+
9
+ "github.com/netdata/netdata/go/plugins/logger"
10
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
11
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
12
+ "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
13
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
14
+ "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
15
+)
16
+
17
+type Options struct {
18
+ Logger *logger.Logger
19
+ FnReg functions.Registry
20
+ API *dyncfg.Responder
21
+ JSONWriter func([]byte, int)
22
+}
23
+
24
+type Controller struct {
25
+ *logger.Logger
26
+
27
+ api *dyncfg.Responder
28
+ jsonWriter func([]byte, int)
29
+ fnReg functions.Registry
30
+ ctx context.Context
31
+
32
+ registry *moduleFuncRegistry
33
+ staticMethodsSeen map[string]struct{}
34
+}
35
+
36
+func New(opts Options) *Controller {
37
+ log := opts.Logger
38
+ if log == nil {
39
+ log = logger.New()
40
+ }
41
+ reg := opts.FnReg
42
+ if reg == nil {
43
+ reg = noopRegistry{}
44
+ }
45
+
46
+ return &Controller{
47
+ Logger: log,
48
+ api: opts.API,
49
+ jsonWriter: opts.JSONWriter,
50
+ fnReg: reg,
51
+ registry: newModuleFuncRegistry(),
52
+ staticMethodsSeen: make(map[string]struct{}),
53
+ }
54
+}
55
+
56
+func (c *Controller) Init(ctx context.Context) {
57
+ c.ctx = ctx
58
+}
59
+
60
+func (c *Controller) SetAPI(api *dyncfg.Responder) {
61
+ if api == nil {
62
+ // Nil means "keep the current responder" rather than clearing output wiring.
63
+ return
64
+ }
65
+ c.api = api
66
+}
67
+
68
+func (c *Controller) RegisterModules(modules collectorapi.Registry) {
69
+ for name, creator := range modules {
70
+ if creator.Methods == nil && creator.JobMethods == nil {
71
+ continue
72
+ }
73
+ c.registry.registerModule(name, creator)
74
+ }
75
+}
76
+
77
+func (c *Controller) GetJobNames(moduleName string) []string {
78
+ return c.registry.getJobNames(moduleName)
79
+}
80
+
81
+func (c *Controller) OnJobStart(job collectorapi.RuntimeJob) {
82
+ if job == nil {
83
+ return
84
+ }
85
+
86
+ c.registry.addJob(job.ModuleName(), job.Name(), job)
87
+ c.registerModuleMethodsOnFirstJobStart(job.ModuleName())
88
+
89
+ creator, ok := c.registry.getCreator(job.ModuleName())
90
+ if !ok || creator.JobMethods == nil {
91
+ return
92
+ }
93
+
94
+ methods := creator.JobMethods(job)
95
+ if len(methods) > 0 {
96
+ c.registerJobMethods(job, methods)
97
+ }
98
+}
99
+
100
+func (c *Controller) OnJobStop(job collectorapi.RuntimeJob) {
101
+ if job == nil {
102
+ return
103
+ }
104
+
105
+ c.unregisterJobMethods(job)
106
+ c.registry.removeJob(job.ModuleName(), job.Name())
107
+}
108
+
109
+func (c *Controller) Cleanup() {
110
+ for name, creator := range c.registry.snapshotCreators() {
111
+ if creator.Methods == nil {
112
+ continue
113
+ }
114
+ for _, method := range creator.Methods() {
115
+ if method.ID == "" {
116
+ continue
117
+ }
118
+ funcName := fmt.Sprintf("%s:%s", name, method.ID)
119
+ c.fnReg.Unregister(funcName)
120
+ if c.api != nil {
121
+ c.api.FunctionRemove(funcName)
122
+ }
123
+ }
124
+ }
125
+}
126
+
127
+func (c *Controller) registerModuleMethodsOnFirstJobStart(moduleName string) {
128
+ if _, ok := c.staticMethodsSeen[moduleName]; ok {
129
+ return
130
+ }
131
+
132
+ creator, ok := c.registry.getCreator(moduleName)
133
+ if !ok || creator.Methods == nil {
134
+ return
135
+ }
136
+
137
+ for _, method := range creator.Methods() {
138
+ if method.ID == "" {
139
+ c.Warningf("skipping function registration for module '%s': empty method ID", moduleName)
140
+ continue
141
+ }
142
+
143
+ funcName := fmt.Sprintf("%s:%s", moduleName, method.ID)
144
+ c.fnReg.Register(funcName, c.makeMethodFuncHandler(moduleName, method.ID))
145
+
146
+ if c.api != nil {
147
+ help := method.Help
148
+ if help == "" {
149
+ help = fmt.Sprintf("%s %s data function", moduleName, method.ID)
150
+ }
151
+
152
+ const cloudAccess = "0x0013"
153
+ access := "0x0000"
154
+ if method.RequireCloud {
155
+ access = cloudAccess
156
+ }
157
+
158
+ c.api.FunctionGlobal(netdataapi.FunctionGlobalOpts{
159
+ Name: funcName,
160
+ Timeout: 60,
161
+ Help: help,
162
+ Tags: "top",
163
+ Access: access,
164
+ Priority: 100,
165
+ Version: 3,
166
+ })
167
+ }
168
+ }
169
+
170
+ c.staticMethodsSeen[moduleName] = struct{}{}
171
+}
172
+
173
+func (c *Controller) registerJobMethods(job collectorapi.RuntimeJob, methods []funcapi.MethodConfig) {
174
+ planned := make(map[string]struct{}, len(methods))
175
+
176
+ for _, method := range methods {
177
+ if method.ID == "" {
178
+ c.Warningf("skipping job method registration for %s[%s]: empty method ID", job.ModuleName(), job.Name())
179
+ continue
180
+ }
181
+
182
+ funcName := fmt.Sprintf("%s:%s", job.ModuleName(), method.ID)
183
+ if _, exists := planned[method.ID]; exists {
184
+ c.Errorf("job method registration aborted for %s[%s]: duplicate method ID in batch ('%s')", job.ModuleName(), job.Name(), funcName)
185
+ return
186
+ }
187
+ planned[method.ID] = struct{}{}
188
+
189
+ if collision, exists := c.registry.findMethodCollision(job.ModuleName(), job.Name(), method.ID); exists {
190
+ c.Errorf("job method registration aborted for %s[%s]: collision on '%s' (%s)", job.ModuleName(), job.Name(), funcName, collision)
191
+ return
192
+ }
193
+ }
194
+
195
+ // Record methods before publishing handlers so startup-time calls do not race a false 404.
196
+ c.registry.registerJobMethods(job.ModuleName(), job.Name(), methods)
197
+
198
+ for _, method := range methods {
199
+ if method.ID == "" {
200
+ continue
201
+ }
202
+
203
+ funcName := fmt.Sprintf("%s:%s", job.ModuleName(), method.ID)
204
+ c.fnReg.Register(funcName, c.makeJobMethodFuncHandler(job.ModuleName(), job.Name(), method.ID))
205
+
206
+ if c.api != nil {
207
+ help := method.Help
208
+ if help == "" {
209
+ help = fmt.Sprintf("%s %s data function", job.ModuleName(), method.ID)
210
+ }
211
+
212
+ const cloudAccess = "0x0013"
213
+ access := "0x0000"
214
+ if method.RequireCloud {
215
+ access = cloudAccess
216
+ }
217
+
218
+ c.api.FunctionGlobal(netdataapi.FunctionGlobalOpts{
219
+ Name: funcName,
220
+ Timeout: 60,
221
+ Help: help,
222
+ Tags: "top",
223
+ Access: access,
224
+ Priority: 100,
225
+ Version: 3,
226
+ })
227
+ }
228
+
229
+ c.Debugf("registered job method: %s for job %s[%s]", funcName, job.ModuleName(), job.Name())
230
+ }
231
+}
232
+
233
+func (c *Controller) unregisterJobMethods(job collectorapi.RuntimeJob) {
234
+ methods := c.registry.getJobMethods(job.ModuleName(), job.Name())
235
+ if len(methods) == 0 {
236
+ return
237
+ }
238
+
239
+ for _, method := range methods {
240
+ if method.ID == "" {
241
+ continue
242
+ }
243
+
244
+ funcName := fmt.Sprintf("%s:%s", job.ModuleName(), method.ID)
245
+ c.fnReg.Unregister(funcName)
246
+ if c.api != nil {
247
+ c.api.FunctionRemove(funcName)
248
+ }
249
+ c.Debugf("unregistered job method: %s for job %s[%s]", funcName, job.ModuleName(), job.Name())
250
+ }
251
+
252
+ c.registry.unregisterJobMethods(job.ModuleName(), job.Name())
253
+}
254
+
255
+func (c *Controller) baseContext() context.Context {
256
+ if c.ctx != nil {
257
+ return c.ctx
258
+ }
259
+ return context.Background()
260
+}
261
+
262
+type noopRegistry struct{}
263
+
264
+func (noopRegistry) Register(string, func(functions.Function)) {}
265
+func (noopRegistry) Unregister(string) {}
266
+func (noopRegistry) RegisterPrefix(string, string, func(functions.Function)) {
267
+}
268
+func (noopRegistry) UnregisterPrefix(string, string) {}
src/go/plugin/agent/jobmgr/funcctl/controller_test.go
new
+576
@@ -0,0 +1,576 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package funcctl
4
+
5
+import (
6
+ "bytes"
7
+ "encoding/json"
8
+ "testing"
9
+
10
+ "github.com/stretchr/testify/assert"
11
+ "github.com/stretchr/testify/require"
12
+
13
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
14
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
15
+ "github.com/netdata/netdata/go/plugins/pkg/safewriter"
16
+ "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
17
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
18
+ "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
19
+)
20
+
21
+func TestModuleFuncRegistry_RegisterModule(t *testing.T) {
22
+ tests := map[string]struct {
23
+ modules []string
24
+ expected []string
25
+ }{
26
+ "single module": {
27
+ modules: []string{"postgres"},
28
+ expected: []string{"postgres"},
29
+ },
30
+ "multiple modules": {
31
+ modules: []string{"postgres", "mysql", "mssql"},
32
+ expected: []string{"mysql", "mssql", "postgres"},
33
+ },
34
+ "duplicate registration overwrites": {
35
+ modules: []string{"postgres", "postgres"},
36
+ expected: []string{"postgres"},
37
+ },
38
+ }
39
+
40
+ for name, tc := range tests {
41
+ t.Run(name, func(t *testing.T) {
42
+ r := newModuleFuncRegistry()
43
+
44
+ for _, module := range tc.modules {
45
+ r.registerModule(module, collectorapi.Creator{
46
+ Methods: func() []funcapi.MethodConfig {
47
+ return []funcapi.MethodConfig{{ID: "test"}}
48
+ },
49
+ })
50
+ }
51
+
52
+ assert.Equal(t, len(tc.expected), len(r.modules))
53
+ for _, module := range tc.expected {
54
+ assert.True(t, r.isModuleRegistered(module))
55
+ }
56
+ })
57
+ }
58
+}
59
+
60
+func TestModuleFuncRegistry_Operations(t *testing.T) {
61
+ tests := map[string]struct{}{
62
+ "add remove job": {},
63
+ "job replacement increments generation": {},
64
+ "job re-add after removal increments generation": {},
65
+ "generation verification fails on wrong generation and missing job": {},
66
+ "get methods": {},
67
+ "get job names sorted": {},
68
+ "operations on unregistered module are no op": {},
69
+ "get creator": {},
70
+ }
71
+
72
+ for name := range tests {
73
+ t.Run(name, func(t *testing.T) {
74
+ r := newModuleFuncRegistry()
75
+
76
+ switch name {
77
+ case "add remove job":
78
+ r.registerModule("postgres", collectorapi.Creator{})
79
+
80
+ job1 := newTestRuntimeJob("postgres", "job1", true)
81
+ job2 := newTestRuntimeJob("postgres", "job2", true)
82
+
83
+ r.addJob("postgres", "job1", job1)
84
+ r.addJob("postgres", "job2", job2)
85
+
86
+ names := r.getJobNames("postgres")
87
+ assert.ElementsMatch(t, []string{"job1", "job2"}, names)
88
+
89
+ got1, ok := r.getJob("postgres", "job1")
90
+ assert.True(t, ok)
91
+ assert.Equal(t, job1, got1)
92
+
93
+ r.removeJob("postgres", "job1")
94
+
95
+ names = r.getJobNames("postgres")
96
+ assert.ElementsMatch(t, []string{"job2"}, names)
97
+
98
+ _, ok = r.getJob("postgres", "job1")
99
+ assert.False(t, ok)
100
+
101
+ case "job replacement increments generation":
102
+ r.registerModule("postgres", collectorapi.Creator{})
103
+
104
+ job1 := newTestRuntimeJob("postgres", "master", true)
105
+ job2 := newTestRuntimeJob("postgres", "master", true)
106
+
107
+ r.addJob("postgres", "master", job1)
108
+ _, gen1 := r.getJobWithGeneration("postgres", "master")
109
+ assert.Equal(t, uint64(1), gen1)
110
+
111
+ r.addJob("postgres", "master", job2)
112
+ got, gen2 := r.getJobWithGeneration("postgres", "master")
113
+ assert.Equal(t, uint64(2), gen2)
114
+ assert.Equal(t, job2, got)
115
+
116
+ case "job re-add after removal increments generation":
117
+ r.registerModule("postgres", collectorapi.Creator{})
118
+
119
+ job1 := newTestRuntimeJob("postgres", "master", true)
120
+ job2 := newTestRuntimeJob("postgres", "master", true)
121
+
122
+ r.addJob("postgres", "master", job1)
123
+ _, gen1 := r.getJobWithGeneration("postgres", "master")
124
+ assert.Equal(t, uint64(1), gen1)
125
+
126
+ r.removeJob("postgres", "master")
127
+ r.addJob("postgres", "master", job2)
128
+ got, gen2 := r.getJobWithGeneration("postgres", "master")
129
+ assert.Equal(t, uint64(2), gen2)
130
+ assert.Equal(t, job2, got)
131
+
132
+ case "generation verification fails on wrong generation and missing job":
133
+ r.registerModule("postgres", collectorapi.Creator{})
134
+
135
+ job := newTestRuntimeJob("postgres", "master", true)
136
+ r.addJob("postgres", "master", job)
137
+ _, gen := r.getJobWithGeneration("postgres", "master")
138
+
139
+ assert.False(t, r.verifyJobGeneration("postgres", "master", gen+1))
140
+ r.removeJob("postgres", "master")
141
+ assert.False(t, r.verifyJobGeneration("postgres", "master", gen))
142
+
143
+ case "get methods":
144
+ expectedMethods := []funcapi.MethodConfig{{ID: "top-queries", Name: "Top Queries"}}
145
+ r.registerModule("postgres", collectorapi.Creator{
146
+ Methods: func() []funcapi.MethodConfig { return expectedMethods },
147
+ })
148
+
149
+ assert.Equal(t, expectedMethods, r.getMethods("postgres"))
150
+ assert.Nil(t, r.getMethods("nonexistent"))
151
+
152
+ case "get job names sorted":
153
+ r.registerModule("postgres", collectorapi.Creator{})
154
+
155
+ r.addJob("postgres", "zebra-db", newTestRuntimeJob("postgres", "zebra-db", true))
156
+ r.addJob("postgres", "alpha-db", newTestRuntimeJob("postgres", "alpha-db", true))
157
+ r.addJob("postgres", "middle-db", newTestRuntimeJob("postgres", "middle-db", true))
158
+
159
+ assert.Equal(t, []string{"alpha-db", "middle-db", "zebra-db"}, r.getJobNames("postgres"))
160
+
161
+ case "operations on unregistered module are no op":
162
+ r.addJob("nonexistent", "job1", newTestRuntimeJob("nonexistent", "job1", true))
163
+ r.removeJob("nonexistent", "job1")
164
+
165
+ assert.False(t, r.isModuleRegistered("nonexistent"))
166
+ assert.Nil(t, r.getJobNames("nonexistent"))
167
+ assert.Nil(t, r.getMethods("nonexistent"))
168
+
169
+ _, ok := r.getJob("nonexistent", "job1")
170
+ assert.False(t, ok)
171
+
172
+ case "get creator":
173
+ creator := collectorapi.Creator{JobConfigSchema: "test-schema"}
174
+ r.registerModule("postgres", creator)
175
+
176
+ got, ok := r.getCreator("postgres")
177
+ require.True(t, ok)
178
+ assert.Equal(t, "test-schema", got.JobConfigSchema)
179
+
180
+ _, ok = r.getCreator("nonexistent")
181
+ assert.False(t, ok)
182
+ }
183
+ })
184
+ }
185
+}
186
+
187
+func TestModuleFuncRegistry_Concurrency(t *testing.T) {
188
+ r := newModuleFuncRegistry()
189
+ r.registerModule("postgres", collectorapi.Creator{
190
+ Methods: func() []funcapi.MethodConfig {
191
+ return []funcapi.MethodConfig{{ID: "test"}}
192
+ },
193
+ })
194
+
195
+ done := make(chan bool)
196
+
197
+ go func() {
198
+ for i := 0; i < 100; i++ {
199
+ job := newTestRuntimeJob("postgres", "job", true)
200
+ r.addJob("postgres", "job", job)
201
+ r.removeJob("postgres", "job")
202
+ }
203
+ done <- true
204
+ }()
205
+
206
+ go func() {
207
+ for i := 0; i < 100; i++ {
208
+ _ = r.getJobNames("postgres")
209
+ _ = r.getMethods("postgres")
210
+ _, _ = r.getJob("postgres", "job")
211
+ }
212
+ done <- true
213
+ }()
214
+
215
+ <-done
216
+ <-done
217
+}
218
+
219
+func TestModuleFuncRegistry_VerifyJobGeneration_JobStopped(t *testing.T) {
220
+ r := newModuleFuncRegistry()
221
+ r.registerModule("postgres", collectorapi.Creator{})
222
+
223
+ job := newTestRuntimeJob("postgres", "master", false)
224
+ r.addJob("postgres", "master", job)
225
+ _, gen := r.getJobWithGeneration("postgres", "master")
226
+
227
+ assert.False(t, r.verifyJobGeneration("postgres", "master", gen))
228
+}
229
+
230
+func TestDispatchHelpers(t *testing.T) {
231
+ tests := map[string]struct{}{
232
+ "extract param values": {},
233
+ "build params": {},
234
+ }
235
+
236
+ for name := range tests {
237
+ t.Run(name, func(t *testing.T) {
238
+ switch name {
239
+ case "extract param values":
240
+ cases := map[string]struct {
241
+ payload map[string]any
242
+ key string
243
+ expected []string
244
+ }{
245
+ "string value": {
246
+ payload: map[string]any{"__job": "local"},
247
+ key: "__job",
248
+ expected: []string{"local"},
249
+ },
250
+ "array value": {
251
+ payload: map[string]any{"__sort": []any{"calls", "total_time"}},
252
+ key: "__sort",
253
+ expected: []string{"calls", "total_time"},
254
+ },
255
+ "string array": {
256
+ payload: map[string]any{"__job": []string{"local"}},
257
+ key: "__job",
258
+ expected: []string{"local"},
259
+ },
260
+ "missing key": {
261
+ payload: map[string]any{"__job": "local"},
262
+ key: "__sort",
263
+ expected: nil,
264
+ },
265
+ "prefers selections": {
266
+ payload: map[string]any{
267
+ "__job": "root",
268
+ "selections": map[string]any{
269
+ "__job": []any{"selected"},
270
+ },
271
+ },
272
+ key: "__job",
273
+ expected: []string{"selected"},
274
+ },
275
+ }
276
+
277
+ for caseName, tc := range cases {
278
+ t.Run(caseName, func(t *testing.T) {
279
+ assert.Equal(t, tc.expected, extractParamValues(tc.payload, tc.key))
280
+ })
281
+ }
282
+
283
+ case "build params":
284
+ cases := map[string]struct{}{
285
+ "build accepted params": {},
286
+ "build required params uses select type": {},
287
+ }
288
+
289
+ for caseName := range cases {
290
+ t.Run(caseName, func(t *testing.T) {
291
+ switch caseName {
292
+ case "build accepted params":
293
+ sortDir := funcapi.FieldSortDescending
294
+ methodParams := []funcapi.ParamConfig{
295
+ {ID: "__sort", Selection: funcapi.ParamSelect, Options: []funcapi.ParamOption{{ID: "calls", Name: "Calls", Sort: &sortDir}}},
296
+ {ID: "db"},
297
+ {ID: "extra"},
298
+ }
299
+
300
+ assert.Equal(t, []string{"__job", "__sort", "db", "extra"}, buildAcceptedParams(methodParams))
301
+
302
+ case "build required params uses select type":
303
+ controller := New(Options{})
304
+ controller.RegisterModules(collectorapi.Registry{
305
+ "postgres": collectorapi.Creator{
306
+ Methods: func() []funcapi.MethodConfig {
307
+ return []funcapi.MethodConfig{{ID: "top-queries", Name: "Top Queries"}}
308
+ },
309
+ },
310
+ })
311
+ controller.registry.addJob("postgres", "master-db", newTestRuntimeJob("postgres", "master-db", true))
312
+
313
+ methodParams := []funcapi.ParamConfig{{
314
+ ID: "__sort",
315
+ Name: "Filter By",
316
+ Selection: funcapi.ParamSelect,
317
+ UniqueView: true,
318
+ Options: []funcapi.ParamOption{
319
+ {ID: "total_time", Name: "By Total Time", Default: true},
320
+ },
321
+ }}
322
+ params := controller.buildRequiredParams("postgres", methodParams)
323
+
324
+ assert.Len(t, params, 2)
325
+ for _, param := range params {
326
+ paramType, ok := param["type"]
327
+ assert.True(t, ok)
328
+ assert.Equal(t, "select", paramType)
329
+ assert.Contains(t, param, "id")
330
+ assert.Contains(t, param, "name")
331
+ assert.Contains(t, param, "options")
332
+ assert.Contains(t, param, "unique_view")
333
+ uniqueView, _ := param["unique_view"].(bool)
334
+ assert.True(t, uniqueView)
335
+ }
336
+
337
+ assert.Equal(t, "__job", params[0]["id"])
338
+ assert.Equal(t, "__sort", params[1]["id"])
339
+ }
340
+ })
341
+ }
342
+ }
343
+ })
344
+ }
345
+}
346
+
347
+func TestControllerLifecycleHooks(t *testing.T) {
348
+ tests := map[string]struct{}{
349
+ "register modules does not register static methods yet": {},
350
+ "first job start registers static methods once": {},
351
+ "job stop unregisters job methods": {},
352
+ "cleanup unregisters static methods": {},
353
+ "cleanup with api configured still unregisters static methods": {},
354
+ }
355
+
356
+ for name := range tests {
357
+ t.Run(name, func(t *testing.T) {
358
+ reg := newTestFunctionRegistry()
359
+ controller := New(Options{FnReg: reg})
360
+
361
+ switch name {
362
+ case "register modules does not register static methods yet":
363
+ controller.RegisterModules(collectorapi.Registry{
364
+ "mod": collectorapi.Creator{
365
+ Methods: func() []funcapi.MethodConfig { return []funcapi.MethodConfig{{ID: "a"}} },
366
+ },
367
+ })
368
+
369
+ assert.Empty(t, reg.registeredNames())
370
+
371
+ case "first job start registers static methods once":
372
+ controller.RegisterModules(collectorapi.Registry{
373
+ "mod": collectorapi.Creator{
374
+ Methods: func() []funcapi.MethodConfig { return []funcapi.MethodConfig{{ID: "a"}} },
375
+ },
376
+ })
377
+
378
+ controller.OnJobStart(newTestRuntimeJob("mod", "job1", true))
379
+ controller.OnJobStart(newTestRuntimeJob("mod", "job2", true))
380
+
381
+ assert.Equal(t, []string{"mod:a"}, reg.registeredNames())
382
+
383
+ case "job stop unregisters job methods":
384
+ controller.RegisterModules(collectorapi.Registry{
385
+ "mod": collectorapi.Creator{
386
+ JobMethods: func(_ collectorapi.RuntimeJob) []funcapi.MethodConfig {
387
+ return []funcapi.MethodConfig{{ID: "job-method"}}
388
+ },
389
+ },
390
+ })
391
+
392
+ job := newTestRuntimeJob("mod", "job1", true)
393
+ controller.OnJobStart(job)
394
+ controller.OnJobStop(job)
395
+
396
+ assert.Contains(t, reg.unregisteredNames(), "mod:job-method")
397
+
398
+ case "cleanup unregisters static methods":
399
+ controller.RegisterModules(collectorapi.Registry{
400
+ "mod": collectorapi.Creator{
401
+ Methods: func() []funcapi.MethodConfig { return []funcapi.MethodConfig{{ID: "a"}} },
402
+ },
403
+ })
404
+
405
+ controller.OnJobStart(newTestRuntimeJob("mod", "job1", true))
406
+ controller.Cleanup()
407
+
408
+ assert.Contains(t, reg.unregisteredNames(), "mod:a")
409
+
410
+ case "cleanup with api configured still unregisters static methods":
411
+ var buf bytes.Buffer
412
+ controller = New(Options{
413
+ FnReg: reg,
414
+ API: dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))),
415
+ })
416
+ controller.RegisterModules(collectorapi.Registry{
417
+ "mod": collectorapi.Creator{
418
+ Methods: func() []funcapi.MethodConfig { return []funcapi.MethodConfig{{ID: "a"}} },
419
+ },
420
+ })
421
+
422
+ controller.OnJobStart(newTestRuntimeJob("mod", "job1", true))
423
+ assert.Contains(t, buf.String(), "FUNCTION GLOBAL \"mod:a\"")
424
+
425
+ controller.Cleanup()
426
+
427
+ assert.Contains(t, reg.unregisteredNames(), "mod:a")
428
+ }
429
+ })
430
+ }
431
+}
432
+
433
+func TestControllerRegisterJobMethods(t *testing.T) {
434
+ tests := map[string]struct{}{
435
+ "fail fast on collision with static method": {},
436
+ "fail fast on collision with other job": {},
437
+ "fail fast on duplicate within batch": {},
438
+ "registry is populated before handlers are callable": {},
439
+ "success commits all methods": {},
440
+ }
441
+
442
+ for name := range tests {
443
+ t.Run(name, func(t *testing.T) {
444
+ reg := newTestFunctionRegistry()
445
+ controller := New(Options{FnReg: reg})
446
+
447
+ switch name {
448
+ case "fail fast on collision with static method":
449
+ controller.RegisterModules(collectorapi.Registry{
450
+ "mod": collectorapi.Creator{
451
+ Methods: func() []funcapi.MethodConfig { return []funcapi.MethodConfig{{ID: "dup"}} },
452
+ },
453
+ })
454
+
455
+ controller.registerJobMethods(newTestRuntimeJob("mod", "job1", true), []funcapi.MethodConfig{{ID: "dup"}})
456
+
457
+ assert.Empty(t, reg.registeredNames())
458
+ assert.Empty(t, controller.registry.getJobMethods("mod", "job1"))
459
+
460
+ case "fail fast on collision with other job":
461
+ controller.registry.registerModule("mod", collectorapi.Creator{})
462
+ controller.registry.registerJobMethods("mod", "jobA", []funcapi.MethodConfig{{ID: "dup"}})
463
+
464
+ controller.registerJobMethods(newTestRuntimeJob("mod", "jobB", true), []funcapi.MethodConfig{{ID: "dup"}})
465
+
466
+ assert.Empty(t, reg.registeredNames())
467
+ assert.Empty(t, controller.registry.getJobMethods("mod", "jobB"))
468
+
469
+ case "fail fast on duplicate within batch":
470
+ controller.registry.registerModule("mod", collectorapi.Creator{})
471
+
472
+ controller.registerJobMethods(newTestRuntimeJob("mod", "job1", true), []funcapi.MethodConfig{{ID: "dup"}, {ID: "dup"}})
473
+
474
+ assert.Empty(t, reg.registeredNames())
475
+ assert.Empty(t, controller.registry.getJobMethods("mod", "job1"))
476
+
477
+ case "registry is populated before handlers are callable":
478
+ var gotCode int
479
+ var gotResp map[string]any
480
+
481
+ reg.onRegister = func(_ string, fn func(functions.Function)) {
482
+ fn(functions.Function{
483
+ UID: "during-register",
484
+ Args: []string{"info"},
485
+ })
486
+ }
487
+ controller = New(Options{
488
+ FnReg: reg,
489
+ JSONWriter: func(data []byte, code int) {
490
+ gotCode = code
491
+ require.NoError(t, json.Unmarshal(data, &gotResp))
492
+ },
493
+ })
494
+ controller.registry.registerModule("mod", collectorapi.Creator{})
495
+
496
+ controller.registerJobMethods(newTestRuntimeJob("mod", "job1", true), []funcapi.MethodConfig{{ID: "a", Help: "job method help"}})
497
+
498
+ assert.Equal(t, 200, gotCode)
499
+ assert.Equal(t, float64(200), gotResp["status"])
500
+ assert.Equal(t, "job method help", gotResp["help"])
501
+ assert.Len(t, controller.registry.getJobMethods("mod", "job1"), 1)
502
+
503
+ case "success commits all methods":
504
+ controller.registry.registerModule("mod", collectorapi.Creator{})
505
+
506
+ controller.registerJobMethods(newTestRuntimeJob("mod", "job1", true), []funcapi.MethodConfig{{ID: "a"}, {ID: "b"}})
507
+
508
+ assert.ElementsMatch(t, []string{"mod:a", "mod:b"}, reg.registeredNames())
509
+ assert.Len(t, controller.registry.getJobMethods("mod", "job1"), 2)
510
+ }
511
+ })
512
+ }
513
+}
514
+
515
+type testRuntimeJob struct {
516
+ fullName string
517
+ moduleName string
518
+ name string
519
+ running bool
520
+}
521
+
522
+func newTestRuntimeJob(moduleName, name string, running bool) *testRuntimeJob {
523
+ return &testRuntimeJob{
524
+ fullName: moduleName + "_" + name,
525
+ moduleName: moduleName,
526
+ name: name,
527
+ running: running,
528
+ }
529
+}
530
+
531
+func (j *testRuntimeJob) FullName() string { return j.fullName }
532
+func (j *testRuntimeJob) ModuleName() string { return j.moduleName }
533
+func (j *testRuntimeJob) Name() string { return j.name }
534
+func (j *testRuntimeJob) IsRunning() bool { return j.running }
535
+func (j *testRuntimeJob) Collector() any { return nil }
536
+
537
+type testFunctionRegistry struct {
538
+ handlers map[string]func(functions.Function)
539
+ registered []string
540
+ unregistered []string
541
+ onRegister func(string, func(functions.Function))
542
+}
543
+
544
+func newTestFunctionRegistry() *testFunctionRegistry {
545
+ return &testFunctionRegistry{
546
+ handlers: make(map[string]func(functions.Function)),
547
+ }
548
+}
549
+
550
+func (r *testFunctionRegistry) Register(name string, fn func(functions.Function)) {
551
+ r.handlers[name] = fn
552
+ r.registered = append(r.registered, name)
553
+ if r.onRegister != nil {
554
+ r.onRegister(name, fn)
555
+ }
556
+}
557
+
558
+func (r *testFunctionRegistry) Unregister(name string) {
559
+ r.unregistered = append(r.unregistered, name)
560
+ delete(r.handlers, name)
561
+}
562
+
563
+func (r *testFunctionRegistry) RegisterPrefix(string, string, func(functions.Function)) {}
564
+func (r *testFunctionRegistry) UnregisterPrefix(string, string) {}
565
+
566
+func (r *testFunctionRegistry) registeredNames() []string {
567
+ out := make([]string, len(r.registered))
568
+ copy(out, r.registered)
569
+ return out
570
+}
571
+
572
+func (r *testFunctionRegistry) unregisteredNames() []string {
573
+ out := make([]string, len(r.unregistered))
574
+ copy(out, r.unregistered)
575
+ return out
576
+}
src/go/plugin/agent/jobmgr/funcctl/dispatch.go
renamed
+101
-230
@@ -1,6 +1,6 @@
1
// SPDX-License-Identifier: GPL-3.0-or-later
2
3
-package jobmgr
3
+package funcctl
4
5
import (
6
"context"
@@ -11,7 +11,6 @@ import (
11
12
"github.com/netdata/netdata/go/plugins/pkg/funcapi"
13
"github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
14
- "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
14
"github.com/netdata/netdata/go/plugins/plugin/framework/functions"
15
)
16
@@ -21,8 +20,6 @@ const (
20
21
type methodParamResolver func(ctx context.Context, methodCfg *funcapi.MethodConfig, handler funcapi.MethodHandler, methodID string) ([]funcapi.ParamConfig, bool, error)
22
24
-type methodResponseWriter func(dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int)
25
-
23
type methodExecutionInput struct {
24
fn functions.Function
25
moduleName string
@@ -39,38 +36,46 @@ type methodExecutionInput struct {
36
respond methodResponseWriter
37
}
38
42
-// executeMethodRequest runs the common method execution pipeline for both
43
-// module-level and job-bound method handlers.
44
-func (m *Manager) executeMethodRequest(in methodExecutionInput) {
45
- ctx, cancel := context.WithTimeout(m.baseContext(), in.fn.Timeout)
39
+func (c *Controller) ExecuteFunction(functionName string, fn functions.Function) {
40
+ moduleName, methodID, err := functions.SplitFunctionName(functionName)
41
+ if err != nil {
42
+ c.respondError(fn, 400, "%v", err)
43
+ return
44
+ }
45
+
46
+ c.makeMethodFuncHandler(moduleName, methodID)(fn)
47
+}
48
+
49
+func (c *Controller) executeMethodRequest(in methodExecutionInput) {
50
+ ctx, cancel := context.WithTimeout(c.baseContext(), in.fn.Timeout)
51
defer cancel()
52
53
if !in.job.IsRunning() {
49
- m.respondError(in.fn, 503, "job '%s' is no longer running", in.jobLabel)
54
+ c.respondError(in.fn, 503, "job '%s' is no longer running", in.jobLabel)
55
return
56
}
57
53
- creator, ok := m.moduleFuncs.getCreator(in.moduleName)
58
+ creator, ok := c.registry.getCreator(in.moduleName)
59
if !ok || creator.MethodHandler == nil {
55
- m.respondError(in.fn, 500, "module '%s' does not implement MethodHandler", in.moduleName)
60
+ c.respondError(in.fn, 500, "module '%s' does not implement MethodHandler", in.moduleName)
61
return
62
}
63
64
handler := creator.MethodHandler(in.job)
65
if handler == nil {
61
- m.respondError(in.fn, 500, "module '%s' returned nil handler for job '%s'", in.moduleName, in.jobName)
66
+ c.respondError(in.fn, 500, "module '%s' returned nil handler for job '%s'", in.moduleName, in.jobName)
67
return
68
}
69
70
methodParams, paramsFromJob, err := in.resolveParams(ctx, in.methodCfg, handler, in.methodID)
71
if err != nil {
67
- m.respondError(in.fn, 503, "job '%s' cannot provide parameters: %v", in.jobLabel, err)
72
+ c.respondError(in.fn, 503, "job '%s' cannot provide parameters: %v", in.jobLabel, err)
73
return
74
}
75
76
if paramsFromJob {
77
if err := validateParamValues(methodParams, in.argValues, in.payload, in.jobName); err != nil {
73
- m.respondError(in.fn, 400, "%v", err)
78
+ c.respondError(in.fn, 400, "%v", err)
79
return
80
}
81
}
@@ -86,8 +91,8 @@ func (m *Manager) executeMethodRequest(in methodExecutionInput) {
91
92
dataResp := handler.Handle(ctx, in.methodID, resolvedParams)
93
89
- if !m.moduleFuncs.verifyJobGeneration(in.moduleName, in.jobName, in.jobGen) {
90
- m.respondError(in.fn, 503, "job '%s' was replaced during request, please retry", in.jobLabel)
94
+ if !c.registry.verifyJobGeneration(in.moduleName, in.jobName, in.jobGen) {
95
+ c.respondError(in.fn, 503, "job '%s' was replaced during request, please retry", in.jobLabel)
96
return
97
}
98
@@ -99,47 +104,51 @@ func (m *Manager) executeMethodRequest(in methodExecutionInput) {
104
in.respond(dataResp, methodParams, updateEvery)
105
}
106
102
-// makeMethodFuncHandler creates a function handler for a module+method function (module:method).
103
-func (m *Manager) makeMethodFuncHandler(moduleName, methodID string) func(functions.Function) {
107
+func (c *Controller) makeMethodFuncHandler(moduleName, methodID string) func(functions.Function) {
108
return func(fn functions.Function) {
105
- // Check for "info" request
109
if slices.Contains(fn.Args, "info") {
107
- m.handleMethodFuncInfo(moduleName, methodID, fn)
110
+ c.handleMethodFuncInfo(moduleName, methodID, fn)
111
return
112
}
113
111
- methodCfg, ok := m.moduleFuncs.getMethod(moduleName, methodID)
114
+ methodCfg, ok := c.registry.getMethod(moduleName, methodID)
115
if !ok {
113
- m.respondError(fn, 404, "unknown method '%s' for module '%s'", methodID, moduleName)
116
+ c.respondError(fn, 404, "unknown method '%s' for module '%s'", methodID, moduleName)
117
return
118
}
119
120
payload := parsePayload(fn.Payload)
121
argValues := parseArgsParams(fn.Args)
122
120
- jobs := m.moduleFuncs.getJobNames(moduleName)
123
+ jobs := c.registry.getJobNames(moduleName)
124
if len(jobs) == 0 {
122
- m.respondError(fn, 422, "no %s instances configured", moduleName)
125
+ c.respondError(fn, 422, "no %s instances configured", moduleName)
126
return
127
}
128
jobParam := buildJobParamConfig(jobs)
129
jobValues := paramValues(argValues, payload, paramJob)
130
+ if len(jobValues) > 1 {
131
+ c.respondError(fn, 400, "parameter '%s' expects a single value", paramJob)
132
+ return
133
+ }
134
resolvedJob := funcapi.ResolveParam(jobParam, jobValues)
135
jobName := resolvedJob.GetOne()
136
+ if len(jobValues) > 0 && jobValues[0] != jobName {
137
+ c.respondError(fn, 404, "unknown job '%s', available: %v", jobValues[0], jobs)
138
+ return
139
+ }
140
if jobName == "" {
130
- m.respondError(fn, 404, "no %s instances configured", moduleName)
141
+ c.respondError(fn, 404, "no %s instances configured", moduleName)
142
return
143
}
144
134
- // Get job WITH generation for race condition detection
135
- // The generation increments when a job is replaced (config reload)
136
- job, jobGen := m.moduleFuncs.getJobWithGeneration(moduleName, jobName)
145
+ job, jobGen := c.registry.getJobWithGeneration(moduleName, jobName)
146
if job == nil {
138
- m.respondError(fn, 404, "unknown job '%s', available: %v", jobName, jobs)
147
+ c.respondError(fn, 404, "unknown job '%s', available: %v", jobName, jobs)
148
return
149
}
150
142
- m.executeMethodRequest(methodExecutionInput{
151
+ c.executeMethodRequest(methodExecutionInput{
152
fn: fn,
153
moduleName: moduleName,
154
jobName: jobName,
@@ -151,27 +160,25 @@ func (m *Manager) makeMethodFuncHandler(moduleName, methodID string) func(functi
160
payload: payload,
161
argValues: argValues,
162
resolveParams: func(ctx context.Context, methodCfg *funcapi.MethodConfig, handler funcapi.MethodHandler, methodID string) ([]funcapi.ParamConfig, bool, error) {
154
- return m.resolveMethodParamsForJob(ctx, moduleName, methodID, methodCfg, job, handler)
163
+ return c.resolveMethodParamsForJob(ctx, methodCfg, job, handler, methodID)
164
},
165
augmentParams: func(resolvedParams funcapi.ResolvedParams) {
166
resolvedParams[paramJob] = resolvedJob
167
},
168
respond: func(dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int) {
160
- m.respondWithParams(fn, moduleName, dataResp, methodParams, updateEvery)
169
+ c.respondWithParams(fn, moduleName, dataResp, methodParams, updateEvery)
170
},
171
})
172
}
173
}
174
166
-// handleMethodFuncInfo handles "info" requests for a module:method function
167
-func (m *Manager) handleMethodFuncInfo(moduleName, methodID string, fn functions.Function) {
168
- methodCfg, ok := m.moduleFuncs.getMethod(moduleName, methodID)
175
+func (c *Controller) handleMethodFuncInfo(moduleName, methodID string, fn functions.Function) {
176
+ methodCfg, ok := c.registry.getMethod(moduleName, methodID)
177
if !ok {
170
- m.respondError(fn, 404, "unknown method '%s' for module '%s'", methodID, moduleName)
178
+ c.respondError(fn, 404, "unknown method '%s' for module '%s'", methodID, moduleName)
179
return
180
}
181
174
- // Use static params for info. Actual requests return job-specific params in the response.
182
methodParams := methodCfg.RequiredParams
183
help := methodCfg.Help
184
if help == "" {
@@ -183,7 +190,7 @@ func (m *Manager) handleMethodFuncInfo(moduleName, methodID string, fn functions
190
updateEvery = methodCfg.UpdateEvery
191
}
192
186
- resp := map[string]any{
193
+ c.respondJSON(fn, map[string]any{
194
"v": 3,
195
"update_every": updateEvery,
196
"status": 200,
@@ -191,98 +198,14 @@ func (m *Manager) handleMethodFuncInfo(moduleName, methodID string, fn functions
198
"has_history": false,
199
"help": help,
200
"accepted_params": buildAcceptedParams(methodParams),
194
- "required_params": m.buildRequiredParams(moduleName, methodParams),
195
- }
196
-
197
- m.respondJSON(fn, resp)
198
-}
199
-
200
-// respondWithParams wraps the module's data response with current required_params
201
-func (m *Manager) respondWithParams(fn functions.Function, moduleName string, dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int) {
202
- m.respondMethodDataWithParams(
203
- fn,
204
- dataResp,
205
- methodParams,
206
- updateEvery,
207
- buildAcceptedParams,
208
- func(params []funcapi.ParamConfig) []map[string]any {
209
- return m.buildRequiredParams(moduleName, params)
210
- },
211
- )
201
+ "required_params": c.buildRequiredParams(moduleName, methodParams),
202
+ })
203
}
204
214
-func (m *Manager) respondMethodDataWithParams(
215
- fn functions.Function,
216
- dataResp *funcapi.FunctionResponse,
217
- methodParams []funcapi.ParamConfig,
218
- updateEvery int,
219
- buildAccepted func([]funcapi.ParamConfig) []string,
220
- buildRequired func([]funcapi.ParamConfig) []map[string]any,
221
-) {
222
- if dataResp == nil {
223
- m.respondError(fn, 500, "internal error: module returned nil response")
224
- return
225
- }
226
-
227
- if dataResp.Status >= 400 {
228
- m.respondError(fn, dataResp.Status, "%s", dataResp.Message)
229
- return
230
- }
231
-
232
- paramsForResponse := methodParams
233
- if len(dataResp.RequiredParams) > 0 {
234
- paramsForResponse = funcapi.MergeParamConfigs(paramsForResponse, dataResp.RequiredParams)
235
- }
236
-
237
- resp := map[string]any{
238
- "v": 3,
239
- "update_every": updateEvery,
240
- "status": dataResp.Status,
241
- "type": "table",
242
- "has_history": false,
243
- "help": dataResp.Help,
244
- "accepted_params": buildAccepted(paramsForResponse),
245
- "required_params": buildRequired(paramsForResponse),
246
- }
205
+func (c *Controller) buildRequiredParams(moduleName string, methodParams []funcapi.ParamConfig) []map[string]any {
206
+ jobs := c.registry.getJobNames(moduleName)
207
248
- if dataResp.Columns != nil {
249
- resp["columns"] = dataResp.Columns
250
- }
251
- if dataResp.Data != nil {
252
- resp["data"] = dataResp.Data
253
- }
254
- if dataResp.DefaultSortColumn != "" {
255
- resp["default_sort_column"] = dataResp.DefaultSortColumn
256
- }
257
- if len(dataResp.Charts) > 0 {
258
- resp["charts"] = dataResp.Charts
259
- }
260
- if len(dataResp.DefaultCharts) > 0 {
261
- resp["default_charts"] = dataResp.DefaultCharts.Build()
262
- }
263
- if len(dataResp.GroupBy) > 0 {
264
- resp["group_by"] = dataResp.GroupBy
265
- }
266
-
267
- m.respondJSON(fn, resp)
268
-}
269
-
270
-// respondError sends a minimal error response (status + errorMessage).
271
-func (m *Manager) respondError(fn functions.Function, status int, format string, args ...any) {
272
- resp := map[string]any{
273
- "status": status,
274
- "errorMessage": fmt.Sprintf(format, args...),
275
- }
276
- m.respondJSON(fn, resp)
277
-}
278
-
279
-// buildRequiredParams creates the required_params array with current job list
280
-func (m *Manager) buildRequiredParams(moduleName string, methodParams []funcapi.ParamConfig) []map[string]any {
281
- jobs := m.moduleFuncs.getJobNames(moduleName)
282
-
283
- paramConfigs := []funcapi.ParamConfig{
284
- buildJobParamConfig(jobs),
285
- }
208
+ paramConfigs := []funcapi.ParamConfig{buildJobParamConfig(jobs)}
209
paramConfigs = append(paramConfigs, methodParams...)
210
211
required := make([]map[string]any, 0, len(paramConfigs))
@@ -292,7 +215,7 @@ func (m *Manager) buildRequiredParams(moduleName string, methodParams []funcapi.
215
return required
216
}
217
295
-func (m *Manager) resolveMethodParamsForJob(ctx context.Context, moduleName, methodID string, methodCfg *funcapi.MethodConfig, job collectorapi.RuntimeJob, handler funcapi.MethodHandler) ([]funcapi.ParamConfig, bool, error) {
218
+func (c *Controller) resolveMethodParamsForJob(ctx context.Context, methodCfg *funcapi.MethodConfig, job collectorapi.RuntimeJob, handler funcapi.MethodHandler, methodID string) ([]funcapi.ParamConfig, bool, error) {
219
methodParams := methodCfg.RequiredParams
220
221
jobParams, err := handler.MethodParams(ctx, methodID)
@@ -316,9 +239,9 @@ func validateParamValues(methodParams []funcapi.ParamConfig, argValues map[strin
239
return fmt.Errorf("parameter '%s' expects a single value for job '%s'", cfg.ID, jobName)
240
}
241
allowed := allowedOptions(cfg.Options)
319
- for _, val := range values {
320
- if !allowed[val] {
321
- return fmt.Errorf("parameter '%s' option '%s' is not supported by job '%s'", cfg.ID, val, jobName)
242
+ for _, value := range values {
243
+ if !allowed[value] {
244
+ return fmt.Errorf("parameter '%s' option '%s' is not supported by job '%s'", cfg.ID, value, jobName)
245
}
246
}
247
}
@@ -336,41 +259,11 @@ func allowedOptions(options []funcapi.ParamOption) map[string]bool {
259
return allowed
260
}
261
339
-// respondJSON sends a JSON response to the function request
340
-// The HTTP status code is extracted from the "status" field in the response
341
-func (m *Manager) respondJSON(fn functions.Function, resp map[string]any) {
342
- data, err := json.Marshal(resp)
343
- if err != nil {
344
- m.Errorf("failed to marshal function response: %v", err)
345
- return
346
- }
347
-
348
- // Extract status code from response for pluginsd protocol
349
- // Default to 200 if not present or not an int
350
- code := 200
351
- if status, ok := resp["status"]; ok {
352
- switch v := status.(type) {
353
- case int:
354
- code = v
355
- case int64:
356
- code = int(v)
357
- case float64:
358
- code = int(v)
359
- }
360
- }
361
-
362
- if m.functionJSONWriter != nil {
363
- m.functionJSONWriter(data, code)
364
- return
365
- }
366
-
367
- m.dyncfgApi.SendJSONWithCode(dyncfg.NewFunction(fn), string(data), code)
368
-}
369
-
262
func parsePayload(raw []byte) map[string]any {
263
if len(raw) == 0 {
264
return nil
265
}
266
+
267
var payload map[string]any
268
if err := json.Unmarshal(raw, &payload); err != nil {
269
return nil
@@ -382,6 +275,7 @@ func parseArgsParams(args []string) map[string][]string {
275
if len(args) == 0 {
276
return nil
277
}
278
+
279
params := make(map[string][]string)
280
for _, arg := range args {
281
if arg == "info" {
@@ -391,8 +285,7 @@ func parseArgsParams(args []string) map[string][]string {
285
if len(parts) != 2 {
286
continue
287
}
394
- key := parts[0]
395
- value := parts[1]
288
+ key, value := parts[0], parts[1]
289
if key == "" || value == "" {
290
continue
291
}
@@ -403,36 +296,35 @@ func parseArgsParams(args []string) map[string][]string {
296
297
func paramValues(args map[string][]string, payload map[string]any, key string) []string {
298
if args != nil {
406
- if vals := args[key]; len(vals) > 0 {
407
- return vals
299
+ if values := args[key]; len(values) > 0 {
300
+ return values
301
}
302
}
303
return extractParamValues(payload, key)
304
}
305
413
-// extractParamValues extracts parameter values from payload, checking selections first.
306
func extractParamValues(payload map[string]any, key string) []string {
307
if payload == nil {
308
return nil
309
}
310
if selections, ok := payload["selections"].(map[string]any); ok {
419
- if vals := extractValues(selections[key]); len(vals) > 0 {
420
- return vals
311
+ if values := extractValues(selections[key]); len(values) > 0 {
312
+ return values
313
}
314
}
315
return extractValues(payload[key])
316
}
317
426
-func extractValues(val any) []string {
427
- switch v := val.(type) {
318
+func extractValues(value any) []string {
319
+ switch current := value.(type) {
320
case string:
429
- if v == "" {
321
+ if current == "" {
322
return nil
323
}
432
- return []string{v}
324
+ return []string{current}
325
case []any:
326
var out []string
435
- for _, item := range v {
327
+ for _, item := range current {
328
if s, ok := item.(string); ok && s != "" {
329
out = append(out, s)
330
}
@@ -440,9 +332,9 @@ func extractValues(val any) []string {
332
return out
333
case []string:
334
var out []string
443
- for _, s := range v {
444
- if s != "" {
445
- out = append(out, s)
335
+ for _, item := range current {
336
+ if item != "" {
337
+ out = append(out, item)
338
}
339
}
340
return out
@@ -455,13 +347,14 @@ func splitCSV(value string) []string {
347
if !strings.Contains(value, ",") {
348
return []string{value}
349
}
350
+
351
parts := strings.Split(value, ",")
352
out := make([]string, 0, len(parts))
460
- for _, p := range parts {
461
- if p == "" {
353
+ for _, part := range parts {
354
+ if part == "" {
355
continue
356
}
464
- out = append(out, p)
357
+ out = append(out, part)
358
}
359
return out
360
}
@@ -475,17 +368,18 @@ func buildJobParamConfig(jobs []string) funcapi.ParamConfig {
368
Disabled: true,
369
})
370
} else {
478
- for i, j := range jobs {
479
- opt := funcapi.ParamOption{
480
- ID: j,
481
- Name: j,
371
+ for i, job := range jobs {
372
+ option := funcapi.ParamOption{
373
+ ID: job,
374
+ Name: job,
375
}
376
if i == 0 {
484
- opt.Default = true
377
+ option.Default = true
378
}
486
- options = append(options, opt)
379
+ options = append(options, option)
380
}
381
}
382
+
383
return funcapi.ParamConfig{
384
ID: paramJob,
385
Name: "Instance",
@@ -498,42 +392,37 @@ func buildJobParamConfig(jobs []string) funcapi.ParamConfig {
392
393
func buildAcceptedParams(methodParams []funcapi.ParamConfig) []string {
394
accepted := []string{paramJob}
501
- for _, p := range methodParams {
502
- if !slices.Contains(accepted, p.ID) {
503
- accepted = append(accepted, p.ID)
395
+ for _, param := range methodParams {
396
+ if !slices.Contains(accepted, param.ID) {
397
+ accepted = append(accepted, param.ID)
398
}
399
}
400
return accepted
401
}
402
509
-// makeJobMethodFuncHandler creates a function handler for a job-specific method.
510
-// Unlike makeMethodFuncHandler, this handler routes directly to a specific job
511
-// without needing the __job parameter (the job is known from the function name).
512
-func (m *Manager) makeJobMethodFuncHandler(moduleName, jobName, methodID string) func(functions.Function) {
403
+func (c *Controller) makeJobMethodFuncHandler(moduleName, jobName, methodID string) func(functions.Function) {
404
return func(fn functions.Function) {
514
- // Check for "info" request
405
if slices.Contains(fn.Args, "info") {
516
- m.handleJobMethodFuncInfo(moduleName, jobName, methodID, fn)
406
+ c.handleJobMethodFuncInfo(moduleName, jobName, methodID, fn)
407
return
408
}
409
520
- methodCfg, ok := m.moduleFuncs.getJobMethod(moduleName, jobName, methodID)
410
+ methodCfg, ok := c.registry.getJobMethod(moduleName, jobName, methodID)
411
if !ok {
522
- m.respondError(fn, 404, "unknown method '%s' for job '%s:%s'", methodID, moduleName, jobName)
412
+ c.respondError(fn, 404, "unknown method '%s' for job '%s:%s'", methodID, moduleName, jobName)
413
return
414
}
415
416
payload := parsePayload(fn.Payload)
417
argValues := parseArgsParams(fn.Args)
418
529
- // Get job WITH generation for race condition detection
530
- job, jobGen := m.moduleFuncs.getJobWithGeneration(moduleName, jobName)
419
+ job, jobGen := c.registry.getJobWithGeneration(moduleName, jobName)
420
if job == nil {
532
- m.respondError(fn, 503, "job '%s:%s' is not running", moduleName, jobName)
421
+ c.respondError(fn, 503, "job '%s:%s' is not running", moduleName, jobName)
422
return
423
}
424
536
- m.executeMethodRequest(methodExecutionInput{
425
+ c.executeMethodRequest(methodExecutionInput{
426
fn: fn,
427
moduleName: moduleName,
428
jobName: jobName,
@@ -545,20 +434,19 @@ func (m *Manager) makeJobMethodFuncHandler(moduleName, jobName, methodID string)
434
payload: payload,
435
argValues: argValues,
436
resolveParams: func(ctx context.Context, methodCfg *funcapi.MethodConfig, handler funcapi.MethodHandler, methodID string) ([]funcapi.ParamConfig, bool, error) {
548
- return m.resolveJobMethodParams(ctx, methodCfg, handler, methodID)
437
+ return c.resolveJobMethodParams(ctx, methodCfg, handler, methodID)
438
},
439
respond: func(dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int) {
551
- m.respondJobMethodWithParams(fn, dataResp, methodParams, updateEvery)
440
+ c.respondJobMethodWithParams(fn, dataResp, methodParams, updateEvery)
441
},
442
})
443
}
444
}
445
557
-// handleJobMethodFuncInfo handles "info" requests for a job-specific method
558
-func (m *Manager) handleJobMethodFuncInfo(moduleName, jobName, methodID string, fn functions.Function) {
559
- methodCfg, ok := m.moduleFuncs.getJobMethod(moduleName, jobName, methodID)
446
+func (c *Controller) handleJobMethodFuncInfo(moduleName, jobName, methodID string, fn functions.Function) {
447
+ methodCfg, ok := c.registry.getJobMethod(moduleName, jobName, methodID)
448
if !ok {
561
- m.respondError(fn, 404, "unknown method '%s' for job '%s:%s'", methodID, moduleName, jobName)
449
+ c.respondError(fn, 404, "unknown method '%s' for job '%s:%s'", methodID, moduleName, jobName)
450
return
451
}
452
@@ -573,7 +461,7 @@ func (m *Manager) handleJobMethodFuncInfo(moduleName, jobName, methodID string,
461
updateEvery = methodCfg.UpdateEvery
462
}
463
576
- resp := map[string]any{
464
+ c.respondJSON(fn, map[string]any{
465
"v": 3,
466
"update_every": updateEvery,
467
"status": 200,
@@ -582,13 +470,10 @@ func (m *Manager) handleJobMethodFuncInfo(moduleName, jobName, methodID string,
470
"help": help,
471
"accepted_params": buildJobMethodAcceptedParams(methodParams),
472
"required_params": buildJobMethodRequiredParams(methodParams),
585
- }
586
-
587
- m.respondJSON(fn, resp)
473
+ })
474
}
475
590
-// resolveJobMethodParams resolves method parameters for a job-specific method
591
-func (m *Manager) resolveJobMethodParams(ctx context.Context, methodCfg *funcapi.MethodConfig, handler funcapi.MethodHandler, methodID string) ([]funcapi.ParamConfig, bool, error) {
476
+func (c *Controller) resolveJobMethodParams(ctx context.Context, methodCfg *funcapi.MethodConfig, handler funcapi.MethodHandler, methodID string) ([]funcapi.ParamConfig, bool, error) {
477
methodParams := methodCfg.RequiredParams
478
479
jobParams, err := handler.MethodParams(ctx, methodID)
@@ -602,30 +487,16 @@ func (m *Manager) resolveJobMethodParams(ctx context.Context, methodCfg *funcapi
487
return funcapi.MergeParamConfigs(methodParams, jobParams), true, nil
488
}
489
605
-// respondJobMethodWithParams wraps the module's data response for job-specific methods
606
-func (m *Manager) respondJobMethodWithParams(fn functions.Function, dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int) {
607
- m.respondMethodDataWithParams(
608
- fn,
609
- dataResp,
610
- methodParams,
611
- updateEvery,
612
- buildJobMethodAcceptedParams,
613
- buildJobMethodRequiredParams,
614
- )
615
-}
616
-
617
-// buildJobMethodAcceptedParams creates accepted_params for job-specific methods (no __job)
490
func buildJobMethodAcceptedParams(methodParams []funcapi.ParamConfig) []string {
491
accepted := make([]string, 0, len(methodParams))
620
- for _, p := range methodParams {
621
- if !slices.Contains(accepted, p.ID) {
622
- accepted = append(accepted, p.ID)
492
+ for _, param := range methodParams {
493
+ if !slices.Contains(accepted, param.ID) {
494
+ accepted = append(accepted, param.ID)
495
}
496
}
497
return accepted
498
}
499
628
-// buildJobMethodRequiredParams creates required_params for job-specific methods (no __job)
500
func buildJobMethodRequiredParams(methodParams []funcapi.ParamConfig) []map[string]any {
501
required := make([]map[string]any, 0, len(methodParams))
502
for _, cfg := range methodParams {
src/go/plugin/agent/jobmgr/funcctl/doc.go
new
+6
@@ -0,0 +1,6 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+// Package funcctl owns function and method dispatch state for jobmgr.
4
+// Runtime job lifecycle ownership remains in jobmgr, which feeds job start/stop
5
+// events into the controller.
6
+package funcctl
src/go/plugin/agent/jobmgr/funcctl/registry.go
new
+298
@@ -0,0 +1,298 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package funcctl
4
+
5
+import (
6
+ "sort"
7
+ "sync"
8
+
9
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
10
+ "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
11
+)
12
+
13
+type moduleFuncRegistry struct {
14
+ mu sync.RWMutex
15
+ modules map[string]*moduleFunc
16
+}
17
+
18
+type moduleFunc struct {
19
+ creator collectorapi.Creator
20
+ methods []funcapi.MethodConfig
21
+ methodsByID map[string]funcapi.MethodConfig
22
+ jobs map[string]*jobEntry
23
+ nextGeneration uint64
24
+ jobMethods map[string][]funcapi.MethodConfig
25
+}
26
+
27
+type jobEntry struct {
28
+ job collectorapi.RuntimeJob
29
+ generation uint64
30
+}
31
+
32
+func newModuleFuncRegistry() *moduleFuncRegistry {
33
+ return &moduleFuncRegistry{
34
+ modules: make(map[string]*moduleFunc),
35
+ }
36
+}
37
+
38
+func (r *moduleFuncRegistry) registerModule(name string, creator collectorapi.Creator) {
39
+ r.mu.Lock()
40
+ defer r.mu.Unlock()
41
+
42
+ var methods []funcapi.MethodConfig
43
+ if creator.Methods != nil {
44
+ methods = creator.Methods()
45
+ }
46
+
47
+ r.modules[name] = &moduleFunc{
48
+ creator: creator,
49
+ methods: methods,
50
+ methodsByID: indexMethods(methods),
51
+ jobs: make(map[string]*jobEntry),
52
+ jobMethods: make(map[string][]funcapi.MethodConfig),
53
+ }
54
+}
55
+
56
+func indexMethods(methods []funcapi.MethodConfig) map[string]funcapi.MethodConfig {
57
+ if len(methods) == 0 {
58
+ return nil
59
+ }
60
+
61
+ idx := make(map[string]funcapi.MethodConfig, len(methods))
62
+ for _, method := range methods {
63
+ if method.ID == "" {
64
+ continue
65
+ }
66
+ idx[method.ID] = method
67
+ }
68
+ return idx
69
+}
70
+
71
+func (r *moduleFuncRegistry) addJob(moduleName, jobName string, job collectorapi.RuntimeJob) {
72
+ r.mu.Lock()
73
+ defer r.mu.Unlock()
74
+
75
+ module, ok := r.modules[moduleName]
76
+ if !ok {
77
+ return
78
+ }
79
+
80
+ module.nextGeneration++
81
+ newGen := module.nextGeneration
82
+ module.jobs[jobName] = &jobEntry{
83
+ job: job,
84
+ generation: newGen,
85
+ }
86
+}
87
+
88
+func (r *moduleFuncRegistry) removeJob(moduleName, jobName string) {
89
+ r.mu.Lock()
90
+ defer r.mu.Unlock()
91
+
92
+ if module, ok := r.modules[moduleName]; ok {
93
+ delete(module.jobs, jobName)
94
+ }
95
+}
96
+
97
+func (r *moduleFuncRegistry) getJobWithGeneration(moduleName, jobName string) (collectorapi.RuntimeJob, uint64) {
98
+ r.mu.RLock()
99
+ defer r.mu.RUnlock()
100
+
101
+ module, ok := r.modules[moduleName]
102
+ if !ok {
103
+ return nil, 0
104
+ }
105
+ entry, ok := module.jobs[jobName]
106
+ if !ok {
107
+ return nil, 0
108
+ }
109
+ return entry.job, entry.generation
110
+}
111
+
112
+func (r *moduleFuncRegistry) verifyJobGeneration(moduleName, jobName string, expectedGen uint64) bool {
113
+ r.mu.RLock()
114
+ defer r.mu.RUnlock()
115
+
116
+ module, ok := r.modules[moduleName]
117
+ if !ok {
118
+ return false
119
+ }
120
+ entry, ok := module.jobs[jobName]
121
+ if !ok {
122
+ return false
123
+ }
124
+ if !entry.job.IsRunning() {
125
+ return false
126
+ }
127
+ return entry.generation == expectedGen
128
+}
129
+
130
+func (r *moduleFuncRegistry) getMethod(moduleName, methodID string) (*funcapi.MethodConfig, bool) {
131
+ r.mu.RLock()
132
+ defer r.mu.RUnlock()
133
+
134
+ module, ok := r.modules[moduleName]
135
+ if !ok || module.methodsByID == nil {
136
+ return nil, false
137
+ }
138
+ cfg, ok := module.methodsByID[methodID]
139
+ if !ok {
140
+ return nil, false
141
+ }
142
+ return &cfg, true
143
+}
144
+
145
+func (r *moduleFuncRegistry) getMethods(moduleName string) []funcapi.MethodConfig {
146
+ r.mu.RLock()
147
+ defer r.mu.RUnlock()
148
+
149
+ module, ok := r.modules[moduleName]
150
+ if !ok {
151
+ return nil
152
+ }
153
+ return module.methods
154
+}
155
+
156
+func (r *moduleFuncRegistry) getJobNames(moduleName string) []string {
157
+ r.mu.RLock()
158
+ defer r.mu.RUnlock()
159
+
160
+ module, ok := r.modules[moduleName]
161
+ if !ok {
162
+ return nil
163
+ }
164
+
165
+ names := make([]string, 0, len(module.jobs))
166
+ for name := range module.jobs {
167
+ names = append(names, name)
168
+ }
169
+ sort.Strings(names)
170
+ return names
171
+}
172
+
173
+func (r *moduleFuncRegistry) getJob(moduleName, jobName string) (collectorapi.RuntimeJob, bool) {
174
+ r.mu.RLock()
175
+ defer r.mu.RUnlock()
176
+
177
+ module, ok := r.modules[moduleName]
178
+ if !ok {
179
+ return nil, false
180
+ }
181
+ entry, ok := module.jobs[jobName]
182
+ if !ok {
183
+ return nil, false
184
+ }
185
+ return entry.job, true
186
+}
187
+
188
+func (r *moduleFuncRegistry) getCreator(moduleName string) (collectorapi.Creator, bool) {
189
+ r.mu.RLock()
190
+ defer r.mu.RUnlock()
191
+
192
+ module, ok := r.modules[moduleName]
193
+ if !ok {
194
+ return collectorapi.Creator{}, false
195
+ }
196
+ return module.creator, true
197
+}
198
+
199
+func (r *moduleFuncRegistry) isModuleRegistered(moduleName string) bool {
200
+ r.mu.RLock()
201
+ defer r.mu.RUnlock()
202
+
203
+ _, ok := r.modules[moduleName]
204
+ return ok
205
+}
206
+
207
+func (r *moduleFuncRegistry) registerJobMethods(moduleName, jobName string, methods []funcapi.MethodConfig) {
208
+ r.mu.Lock()
209
+ defer r.mu.Unlock()
210
+
211
+ module, ok := r.modules[moduleName]
212
+ if !ok {
213
+ return
214
+ }
215
+ module.jobMethods[jobName] = methods
216
+}
217
+
218
+func (r *moduleFuncRegistry) unregisterJobMethods(moduleName, jobName string) {
219
+ r.mu.Lock()
220
+ defer r.mu.Unlock()
221
+
222
+ module, ok := r.modules[moduleName]
223
+ if !ok {
224
+ return
225
+ }
226
+ delete(module.jobMethods, jobName)
227
+}
228
+
229
+func (r *moduleFuncRegistry) getJobMethods(moduleName, jobName string) []funcapi.MethodConfig {
230
+ r.mu.RLock()
231
+ defer r.mu.RUnlock()
232
+
233
+ module, ok := r.modules[moduleName]
234
+ if !ok {
235
+ return nil
236
+ }
237
+ return module.jobMethods[jobName]
238
+}
239
+
240
+func (r *moduleFuncRegistry) getJobMethod(moduleName, jobName, methodID string) (*funcapi.MethodConfig, bool) {
241
+ r.mu.RLock()
242
+ defer r.mu.RUnlock()
243
+
244
+ module, ok := r.modules[moduleName]
245
+ if !ok {
246
+ return nil, false
247
+ }
248
+ methods, ok := module.jobMethods[jobName]
249
+ if !ok {
250
+ return nil, false
251
+ }
252
+ for i := range methods {
253
+ if methods[i].ID == methodID {
254
+ return &methods[i], true
255
+ }
256
+ }
257
+ return nil, false
258
+}
259
+
260
+func (r *moduleFuncRegistry) findMethodCollision(moduleName, jobName, methodID string) (string, bool) {
261
+ r.mu.RLock()
262
+ defer r.mu.RUnlock()
263
+
264
+ module, ok := r.modules[moduleName]
265
+ if !ok {
266
+ return "", false
267
+ }
268
+
269
+ if module.methodsByID != nil {
270
+ if _, exists := module.methodsByID[methodID]; exists {
271
+ return "static method", true
272
+ }
273
+ }
274
+
275
+ for ownerJob, methods := range module.jobMethods {
276
+ if ownerJob == jobName {
277
+ continue
278
+ }
279
+ for _, method := range methods {
280
+ if method.ID == methodID {
281
+ return "job method on " + ownerJob, true
282
+ }
283
+ }
284
+ }
285
+
286
+ return "", false
287
+}
288
+
289
+func (r *moduleFuncRegistry) snapshotCreators() map[string]collectorapi.Creator {
290
+ r.mu.RLock()
291
+ defer r.mu.RUnlock()
292
+
293
+ out := make(map[string]collectorapi.Creator, len(r.modules))
294
+ for name, module := range r.modules {
295
+ out[name] = module.creator
296
+ }
297
+ return out
298
+}
src/go/plugin/agent/jobmgr/funcctl/response.go
new
+135
@@ -0,0 +1,135 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package funcctl
4
+
5
+import (
6
+ "encoding/json"
7
+ "fmt"
8
+
9
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
10
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
11
+ "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
12
+)
13
+
14
+type methodResponseWriter func(dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int)
15
+
16
+func (c *Controller) respondWithParams(fn functions.Function, moduleName string, dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int) {
17
+ c.respondMethodDataWithParams(
18
+ fn,
19
+ dataResp,
20
+ methodParams,
21
+ updateEvery,
22
+ buildAcceptedParams,
23
+ func(params []funcapi.ParamConfig) []map[string]any {
24
+ return c.buildRequiredParams(moduleName, params)
25
+ },
26
+ )
27
+}
28
+
29
+func (c *Controller) respondJobMethodWithParams(fn functions.Function, dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int) {
30
+ c.respondMethodDataWithParams(
31
+ fn,
32
+ dataResp,
33
+ methodParams,
34
+ updateEvery,
35
+ buildJobMethodAcceptedParams,
36
+ buildJobMethodRequiredParams,
37
+ )
38
+}
39
+
40
+func (c *Controller) respondMethodDataWithParams(
41
+ fn functions.Function,
42
+ dataResp *funcapi.FunctionResponse,
43
+ methodParams []funcapi.ParamConfig,
44
+ updateEvery int,
45
+ buildAccepted func([]funcapi.ParamConfig) []string,
46
+ buildRequired func([]funcapi.ParamConfig) []map[string]any,
47
+) {
48
+ if dataResp == nil {
49
+ c.respondError(fn, 500, "internal error: module returned nil response")
50
+ return
51
+ }
52
+ if dataResp.Status >= 400 {
53
+ c.respondError(fn, dataResp.Status, "%s", dataResp.Message)
54
+ return
55
+ }
56
+
57
+ paramsForResponse := methodParams
58
+ if len(dataResp.RequiredParams) > 0 {
59
+ paramsForResponse = funcapi.MergeParamConfigs(paramsForResponse, dataResp.RequiredParams)
60
+ }
61
+
62
+ resp := map[string]any{
63
+ "v": 3,
64
+ "update_every": updateEvery,
65
+ "status": dataResp.Status,
66
+ "type": "table",
67
+ "has_history": false,
68
+ "help": dataResp.Help,
69
+ "accepted_params": buildAccepted(paramsForResponse),
70
+ "required_params": buildRequired(paramsForResponse),
71
+ }
72
+
73
+ if dataResp.Columns != nil {
74
+ resp["columns"] = dataResp.Columns
75
+ }
76
+ if dataResp.Data != nil {
77
+ resp["data"] = dataResp.Data
78
+ }
79
+ if dataResp.DefaultSortColumn != "" {
80
+ resp["default_sort_column"] = dataResp.DefaultSortColumn
81
+ }
82
+ if len(dataResp.Charts) > 0 {
83
+ resp["charts"] = dataResp.Charts
84
+ }
85
+ if len(dataResp.DefaultCharts) > 0 {
86
+ resp["default_charts"] = dataResp.DefaultCharts.Build()
87
+ }
88
+ if len(dataResp.GroupBy) > 0 {
89
+ resp["group_by"] = dataResp.GroupBy
90
+ }
91
+
92
+ c.respondJSON(fn, resp)
93
+}
94
+
95
+func (c *Controller) respondError(fn functions.Function, status int, format string, args ...any) {
96
+ c.respondJSON(fn, map[string]any{
97
+ "status": status,
98
+ "errorMessage": fmt.Sprintf(format, args...),
99
+ })
100
+}
101
+
102
+func (c *Controller) respondJSON(fn functions.Function, resp map[string]any) {
103
+ data, err := json.Marshal(resp)
104
+ if err != nil {
105
+ c.Errorf("failed to marshal function response: %v", err)
106
+ c.sendJSON(fn, string(functions.BuildJSONPayload(500, "internal error: failed to encode response")), 500)
107
+ return
108
+ }
109
+
110
+ code := 200
111
+ if status, ok := resp["status"]; ok {
112
+ switch value := status.(type) {
113
+ case int:
114
+ code = value
115
+ case int64:
116
+ code = int(value)
117
+ case float64:
118
+ code = int(value)
119
+ }
120
+ }
121
+
122
+ c.sendJSON(fn, string(data), code)
123
+}
124
+
125
+func (c *Controller) sendJSON(fn functions.Function, payload string, code int) {
126
+ if c.jsonWriter != nil {
127
+ c.jsonWriter([]byte(payload), code)
128
+ return
129
+ }
130
+ if c.api == nil {
131
+ return
132
+ }
133
+
134
+ c.api.SendJSONWithCode(dyncfg.NewFunction(fn), payload, code)
135
+}
src/go/plugin/agent/jobmgr/funcdispatch_test.go
new
+644
@@ -0,0 +1,644 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package jobmgr
4
+
5
+import (
6
+ "bytes"
7
+ "context"
8
+ "encoding/json"
9
+ "sync"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/stretchr/testify/assert"
14
+ "github.com/stretchr/testify/require"
15
+
16
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
17
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
18
+ "github.com/netdata/netdata/go/plugins/pkg/safewriter"
19
+ "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
20
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
21
+ "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
22
+)
23
+
24
+type mockMethodHandler struct {
25
+ paramsFunc func(ctx context.Context, method string) ([]funcapi.ParamConfig, error)
26
+ handleFunc func(ctx context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse
27
+}
28
+
29
+func (m *mockMethodHandler) MethodParams(ctx context.Context, method string) ([]funcapi.ParamConfig, error) {
30
+ if m.paramsFunc != nil {
31
+ return m.paramsFunc(ctx, method)
32
+ }
33
+ return nil, nil
34
+}
35
+
36
+func (m *mockMethodHandler) Handle(ctx context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse {
37
+ if m.handleFunc != nil {
38
+ return m.handleFunc(ctx, method, params)
39
+ }
40
+ return nil
41
+}
42
+
43
+func (m *mockMethodHandler) Cleanup(context.Context) {}
44
+
45
+func TestExecuteFunction_ModuleMethodPaths(t *testing.T) {
46
+ tests := map[string]struct {
47
+ methods []funcapi.MethodConfig
48
+ fnArgs []string
49
+ fnPayload map[string]any
50
+ generationRace bool
51
+ wantStatus int
52
+ wantHelp string
53
+ wantAccepted []string
54
+ wantRequiredIDs []string
55
+ wantDataValue any
56
+ wantResolvedJob string
57
+ wantErrorContains string
58
+ }{
59
+ "success with __job resolution": {
60
+ methods: []funcapi.MethodConfig{{ID: "details", Help: "details help"}},
61
+ fnArgs: []string{"__job:job1"},
62
+ wantStatus: 200,
63
+ wantHelp: "details help",
64
+ wantAccepted: []string{"__job"},
65
+ wantRequiredIDs: []string{"__job"},
66
+ wantDataValue: "row",
67
+ wantResolvedJob: "job1",
68
+ },
69
+ "info response includes module and method params": {
70
+ methods: []funcapi.MethodConfig{{
71
+ ID: "details",
72
+ Help: "details help",
73
+ RequiredParams: []funcapi.ParamConfig{{
74
+ ID: "scope",
75
+ Name: "Scope",
76
+ Selection: funcapi.ParamSelect,
77
+ Options: []funcapi.ParamOption{{ID: "default", Name: "Default"}},
78
+ }},
79
+ }},
80
+ fnArgs: []string{"info"},
81
+ wantStatus: 200,
82
+ wantHelp: "details help",
83
+ wantAccepted: []string{"__job", "scope"},
84
+ wantRequiredIDs: []string{"__job", "scope"},
85
+ },
86
+ "generation race returns 503": {
87
+ methods: []funcapi.MethodConfig{{ID: "details"}},
88
+ fnArgs: []string{"__job:job1"},
89
+ generationRace: true,
90
+ wantStatus: 503,
91
+ wantErrorContains: "replaced during request",
92
+ },
93
+ "explicit unknown __job in args returns 404": {
94
+ methods: []funcapi.MethodConfig{{ID: "details"}},
95
+ fnArgs: []string{"__job:missing"},
96
+ wantStatus: 404,
97
+ wantErrorContains: "unknown job 'missing'",
98
+ },
99
+ "explicit unknown __job in payload returns 404": {
100
+ methods: []funcapi.MethodConfig{{ID: "details"}},
101
+ fnPayload: map[string]any{"__job": "missing"},
102
+ wantStatus: 404,
103
+ wantErrorContains: "unknown job 'missing'",
104
+ },
105
+ "multiple __job values return 400": {
106
+ methods: []funcapi.MethodConfig{{ID: "details"}},
107
+ fnPayload: map[string]any{"__job": []string{"job1", "job2"}},
108
+ wantStatus: 400,
109
+ wantErrorContains: "parameter '__job' expects a single value",
110
+ },
111
+ }
112
+
113
+ for name, tc := range tests {
114
+ t.Run(name, func(t *testing.T) {
115
+ writer := &jsonWriteCapture{}
116
+ var gotJob string
117
+ var mgr *Manager
118
+
119
+ methodHandler := &mockMethodHandler{}
120
+ if len(tc.fnArgs) == 0 || tc.fnArgs[0] != "info" {
121
+ methodHandler.handleFunc = func(ctx context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse {
122
+ if tc.generationRace {
123
+ mgr.funcCtl.OnJobStart(&lockProbeJob{fullName: "mod_job1", moduleName: "mod", name: "job1"})
124
+ return &funcapi.FunctionResponse{Status: 200, Help: "should be replaced"}
125
+ }
126
+
127
+ gotJob = params.GetOne("__job")
128
+ return &funcapi.FunctionResponse{
129
+ Status: 200,
130
+ Help: tc.wantHelp,
131
+ Columns: map[string]any{
132
+ "value": map[string]any{"name": "Value"},
133
+ },
134
+ Data: [][]any{{tc.wantDataValue}},
135
+ }
136
+ }
137
+ }
138
+
139
+ mgr = newModuleDispatchTestManager(t, nil, writer.write, methodHandler, tc.methods)
140
+ var payload []byte
141
+ if tc.fnPayload != nil {
142
+ var err error
143
+ payload, err = json.Marshal(tc.fnPayload)
144
+ require.NoError(t, err)
145
+ }
146
+ mgr.ExecuteFunction("mod:details", functions.Function{
147
+ UID: "module-test",
148
+ Timeout: time.Second,
149
+ Args: tc.fnArgs,
150
+ Payload: payload,
151
+ })
152
+
153
+ resp := writer.requireResponse(t)
154
+ assert.Equal(t, tc.wantStatus, writer.code)
155
+ assert.Equal(t, float64(tc.wantStatus), resp["status"])
156
+ if tc.wantErrorContains != "" {
157
+ assert.Contains(t, resp["errorMessage"], tc.wantErrorContains)
158
+ return
159
+ }
160
+
161
+ if tc.wantResolvedJob != "" {
162
+ assert.Equal(t, tc.wantResolvedJob, gotJob)
163
+ }
164
+ if tc.wantHelp != "" {
165
+ assert.Equal(t, tc.wantHelp, resp["help"])
166
+ }
167
+ assert.Equal(t, tc.wantAccepted, jsonArrayStrings(t, resp["accepted_params"]))
168
+
169
+ required := jsonObjectArray(t, resp["required_params"])
170
+ require.Len(t, required, len(tc.wantRequiredIDs))
171
+ for i, id := range tc.wantRequiredIDs {
172
+ assert.Equal(t, id, required[i]["id"])
173
+ }
174
+ if tc.wantDataValue != nil {
175
+ assert.Equal(t, tc.wantDataValue, jsonNestedArrayValue(t, resp["data"], 0, 0))
176
+ }
177
+ })
178
+ }
179
+}
180
+
181
+func TestExecuteFunction_ContextBehavior(t *testing.T) {
182
+ tests := map[string]struct {
183
+ managerCtx context.Context
184
+ wantMarker string
185
+ wantMarkerSet bool
186
+ wantHasDeadline bool
187
+ }{
188
+ "uses background fallback before manager context is set": {
189
+ wantHasDeadline: true,
190
+ },
191
+ "uses manager context when available": {
192
+ managerCtx: context.WithValue(context.Background(), dispatchContextKey("marker"), "manager"),
193
+ wantMarker: "manager",
194
+ wantMarkerSet: true,
195
+ wantHasDeadline: true,
196
+ },
197
+ }
198
+
199
+ for name, tc := range tests {
200
+ t.Run(name, func(t *testing.T) {
201
+ writer := &jsonWriteCapture{}
202
+ mgr := newModuleDispatchTestManager(t, nil, writer.write, &mockMethodHandler{
203
+ handleFunc: func(ctx context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse {
204
+ require.NotNil(t, ctx)
205
+ _, hasDeadline := ctx.Deadline()
206
+ assert.Equal(t, tc.wantHasDeadline, hasDeadline)
207
+ gotMarker := ctx.Value(dispatchContextKey("marker"))
208
+ if tc.wantMarkerSet {
209
+ assert.Equal(t, tc.wantMarker, gotMarker)
210
+ } else {
211
+ assert.Nil(t, gotMarker)
212
+ }
213
+ return &funcapi.FunctionResponse{Status: 200}
214
+ },
215
+ }, []funcapi.MethodConfig{{ID: "details"}})
216
+ if tc.managerCtx != nil {
217
+ mgr.funcCtl.Init(tc.managerCtx)
218
+ }
219
+
220
+ mgr.ExecuteFunction("mod:details", functions.Function{
221
+ UID: "module-context",
222
+ Timeout: time.Second,
223
+ Args: []string{"__job:job1"},
224
+ })
225
+
226
+ resp := writer.requireResponse(t)
227
+ assert.Equal(t, float64(200), resp["status"])
228
+ })
229
+ }
230
+}
231
+
232
+func TestJobMethodRegisteredHandlerPaths(t *testing.T) {
233
+ tests := map[string]struct {
234
+ fnArgs []string
235
+ wantRequiredLen int
236
+ wantDataValue any
237
+ }{
238
+ "success path omits __job": {
239
+ wantRequiredLen: 0,
240
+ wantDataValue: "job1",
241
+ },
242
+ "info path omits __job": {
243
+ fnArgs: []string{"info"},
244
+ wantRequiredLen: 0,
245
+ },
246
+ }
247
+
248
+ for name, tc := range tests {
249
+ t.Run(name, func(t *testing.T) {
250
+ writer := &jsonWriteCapture{}
251
+ fnReg := newCapturingFunctionRegistry()
252
+ mgr := newJobMethodDispatchTestManager(t, fnReg, writer.write, &mockMethodHandler{
253
+ handleFunc: func(ctx context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse {
254
+ return &funcapi.FunctionResponse{
255
+ Status: 200,
256
+ Help: "job details help",
257
+ Columns: map[string]any{
258
+ "value": map[string]any{"name": "Value"},
259
+ },
260
+ Data: [][]any{{"job1"}},
261
+ }
262
+ },
263
+ }, []funcapi.MethodConfig{{ID: "job-details", Help: "job details help"}})
264
+
265
+ handler := fnReg.requireHandler(t, "mod:job-details")
266
+ handler(functions.Function{
267
+ UID: "job-handler",
268
+ Timeout: time.Second,
269
+ Args: tc.fnArgs,
270
+ })
271
+
272
+ resp := writer.requireResponse(t)
273
+ assert.Equal(t, float64(200), resp["status"])
274
+ assert.NotContains(t, jsonArrayStrings(t, resp["accepted_params"]), "__job")
275
+ assert.Len(t, jsonObjectArray(t, resp["required_params"]), tc.wantRequiredLen)
276
+ if tc.wantDataValue != nil {
277
+ assert.Equal(t, tc.wantDataValue, jsonNestedArrayValue(t, resp["data"], 0, 0))
278
+ }
279
+ mgr.stopRunningJob("mod_job1")
280
+ })
281
+ }
282
+}
283
+
284
+func TestFunctionDispatch_ResponsePaths(t *testing.T) {
285
+ tests := map[string]struct {
286
+ useJSONWriter bool
287
+ rebindResponder bool
288
+ nilRebindResponder bool
289
+ marshalFail bool
290
+ wantWriterStatus int
291
+ wantResponderUID string
292
+ wantResponderJSON string
293
+ wantFirstUID string
294
+ wantSecondUID string
295
+ }{
296
+ "JSONWriter takes precedence when configured": {
297
+ useJSONWriter: true,
298
+ wantWriterStatus: 200,
299
+ wantResponderUID: "writer-first",
300
+ },
301
+ "responder fallback is used when JSONWriter is nil": {
302
+ wantResponderUID: "responder-fallback",
303
+ wantResponderJSON: "\"status\":200",
304
+ },
305
+ "responder rebinding updates only the responder-backed path": {
306
+ rebindResponder: true,
307
+ wantFirstUID: "before-rebind",
308
+ wantSecondUID: "after-rebind",
309
+ },
310
+ "nil responder rebinding preserves the current responder-backed path": {
311
+ nilRebindResponder: true,
312
+ wantFirstUID: "before-nil-rebind",
313
+ wantSecondUID: "after-nil-rebind",
314
+ },
315
+ "marshal failure falls back to JSONWriter with 500": {
316
+ useJSONWriter: true,
317
+ marshalFail: true,
318
+ wantWriterStatus: 500,
319
+ wantResponderUID: "writer-marshal-fail",
320
+ },
321
+ "marshal failure falls back to responder with 500": {
322
+ marshalFail: true,
323
+ wantResponderUID: "responder-marshal-fail",
324
+ wantResponderJSON: "\"status\":500",
325
+ },
326
+ }
327
+
328
+ for name, tc := range tests {
329
+ t.Run(name, func(t *testing.T) {
330
+ handler := &mockMethodHandler{
331
+ handleFunc: func(ctx context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse {
332
+ if tc.marshalFail {
333
+ return &funcapi.FunctionResponse{
334
+ Status: 200,
335
+ Data: [][]any{{make(chan int)}},
336
+ }
337
+ }
338
+ return &funcapi.FunctionResponse{Status: 200}
339
+ },
340
+ }
341
+
342
+ if tc.useJSONWriter {
343
+ writer := &jsonWriteCapture{}
344
+ var responderOut bytes.Buffer
345
+
346
+ mgr := newModuleDispatchTestManager(t, dyncfg.NewResponder(netdataapi.New(safewriter.New(&responderOut))), writer.write, handler, []funcapi.MethodConfig{{ID: "details"}})
347
+ mgr.ExecuteFunction("mod:details", functions.Function{
348
+ UID: tc.wantResponderUID,
349
+ Timeout: time.Second,
350
+ Args: []string{"__job:job1"},
351
+ })
352
+
353
+ resp := writer.requireResponse(t)
354
+ assert.Equal(t, float64(tc.wantWriterStatus), resp["status"])
355
+ if tc.marshalFail {
356
+ assert.Contains(t, resp["errorMessage"], "failed to encode response")
357
+ }
358
+ assert.NotContains(t, responderOut.String(), "FUNCTION_RESULT_BEGIN "+tc.wantResponderUID)
359
+ return
360
+ }
361
+
362
+ if tc.rebindResponder {
363
+ var firstOut bytes.Buffer
364
+ var secondOut bytes.Buffer
365
+
366
+ mgr := newModuleDispatchTestManager(t, dyncfg.NewResponder(netdataapi.New(safewriter.New(&firstOut))), nil, handler, []funcapi.MethodConfig{{ID: "details"}})
367
+ mgr.ExecuteFunction("mod:details", functions.Function{
368
+ UID: tc.wantFirstUID,
369
+ Timeout: time.Second,
370
+ Args: []string{"__job:job1"},
371
+ })
372
+
373
+ mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&secondOut))))
374
+ mgr.ExecuteFunction("mod:details", functions.Function{
375
+ UID: tc.wantSecondUID,
376
+ Timeout: time.Second,
377
+ Args: []string{"__job:job1"},
378
+ })
379
+
380
+ assert.Contains(t, firstOut.String(), "FUNCTION_RESULT_BEGIN "+tc.wantFirstUID)
381
+ assert.NotContains(t, firstOut.String(), "FUNCTION_RESULT_BEGIN "+tc.wantSecondUID)
382
+ assert.Contains(t, secondOut.String(), "FUNCTION_RESULT_BEGIN "+tc.wantSecondUID)
383
+ return
384
+ }
385
+
386
+ if tc.nilRebindResponder {
387
+ var responderOut bytes.Buffer
388
+
389
+ mgr := newModuleDispatchTestManager(t, dyncfg.NewResponder(netdataapi.New(safewriter.New(&responderOut))), nil, handler, []funcapi.MethodConfig{{ID: "details"}})
390
+ mgr.ExecuteFunction("mod:details", functions.Function{
391
+ UID: tc.wantFirstUID,
392
+ Timeout: time.Second,
393
+ Args: []string{"__job:job1"},
394
+ })
395
+
396
+ mgr.SetDyncfgResponder(nil)
397
+ mgr.ExecuteFunction("mod:details", functions.Function{
398
+ UID: tc.wantSecondUID,
399
+ Timeout: time.Second,
400
+ Args: []string{"__job:job1"},
401
+ })
402
+
403
+ assert.Contains(t, responderOut.String(), "FUNCTION_RESULT_BEGIN "+tc.wantFirstUID)
404
+ assert.Contains(t, responderOut.String(), "FUNCTION_RESULT_BEGIN "+tc.wantSecondUID)
405
+ return
406
+ }
407
+
408
+ var responderOut bytes.Buffer
409
+ mgr := newModuleDispatchTestManager(t, dyncfg.NewResponder(netdataapi.New(safewriter.New(&responderOut))), nil, handler, []funcapi.MethodConfig{{ID: "details"}})
410
+ mgr.ExecuteFunction("mod:details", functions.Function{
411
+ UID: tc.wantResponderUID,
412
+ Timeout: time.Second,
413
+ Args: []string{"__job:job1"},
414
+ })
415
+
416
+ assert.Contains(t, responderOut.String(), "FUNCTION_RESULT_BEGIN "+tc.wantResponderUID)
417
+ assert.Contains(t, responderOut.String(), tc.wantResponderJSON)
418
+ if tc.marshalFail {
419
+ assert.Contains(t, responderOut.String(), "failed to encode response")
420
+ }
421
+ })
422
+ }
423
+}
424
+
425
+func TestCleanup_UnregistersStaticFunctionsBeforeStoppingJobs(t *testing.T) {
426
+ fnReg := newCapturingFunctionRegistry()
427
+ mgr := New(Config{PluginName: testPluginName, FnReg: fnReg})
428
+
429
+ staticCreator := collectorapi.Creator{
430
+ Methods: func() []funcapi.MethodConfig {
431
+ return []funcapi.MethodConfig{{ID: "static-method"}}
432
+ },
433
+ }
434
+ jobCreator := collectorapi.Creator{
435
+ JobMethods: func(_ collectorapi.RuntimeJob) []funcapi.MethodConfig {
436
+ return []funcapi.MethodConfig{{ID: "job-method"}}
437
+ },
438
+ }
439
+
440
+ mgr.modules = collectorapi.Registry{
441
+ "staticmod": staticCreator,
442
+ "jobmod": jobCreator,
443
+ }
444
+ mgr.funcCtl.RegisterModules(mgr.modules)
445
+
446
+ mgr.startRunningJob(&lockProbeJob{fullName: "staticmod_job1", moduleName: "staticmod", name: "job1"})
447
+ mgr.startRunningJob(&lockProbeJob{fullName: "jobmod_job1", moduleName: "jobmod", name: "job1"})
448
+
449
+ mgr.cleanup()
450
+
451
+ unregistered := fnReg.unregisteredNames()
452
+ assert.Contains(t, unregistered, "staticmod:static-method")
453
+ assert.Contains(t, unregistered, "jobmod:job-method")
454
+ assert.Less(
455
+ t,
456
+ fnReg.unregisteredIndex("staticmod:static-method"),
457
+ fnReg.unregisteredIndex("jobmod:job-method"),
458
+ "static module cleanup must run before per-job stop cleanup",
459
+ )
460
+}
461
+
462
+type dispatchContextKey string
463
+
464
+type jsonWriteCapture struct {
465
+ calls int
466
+ code int
467
+ raw []byte
468
+}
469
+
470
+func (c *jsonWriteCapture) write(payload []byte, code int) {
471
+ c.calls++
472
+ c.code = code
473
+ c.raw = append([]byte(nil), payload...)
474
+}
475
+
476
+func (c *jsonWriteCapture) requireResponse(t *testing.T) map[string]any {
477
+ t.Helper()
478
+ require.Equal(t, 1, c.calls, "expected exactly one JSON writer call")
479
+
480
+ var resp map[string]any
481
+ require.NoError(t, json.Unmarshal(c.raw, &resp))
482
+ return resp
483
+}
484
+
485
+type capturingFunctionRegistry struct {
486
+ mu sync.Mutex
487
+ handlers map[string]func(functions.Function)
488
+ unregistered []string
489
+}
490
+
491
+func newCapturingFunctionRegistry() *capturingFunctionRegistry {
492
+ return &capturingFunctionRegistry{
493
+ handlers: make(map[string]func(functions.Function)),
494
+ }
495
+}
496
+
497
+func (r *capturingFunctionRegistry) Register(name string, fn func(functions.Function)) {
498
+ r.mu.Lock()
499
+ r.handlers[name] = fn
500
+ r.mu.Unlock()
501
+}
502
+
503
+func (r *capturingFunctionRegistry) Unregister(name string) {
504
+ r.mu.Lock()
505
+ r.unregistered = append(r.unregistered, name)
506
+ delete(r.handlers, name)
507
+ r.mu.Unlock()
508
+}
509
+
510
+func (r *capturingFunctionRegistry) RegisterPrefix(string, string, func(functions.Function)) {}
511
+func (r *capturingFunctionRegistry) UnregisterPrefix(string, string) {}
512
+
513
+func (r *capturingFunctionRegistry) requireHandler(t *testing.T, name string) func(functions.Function) {
514
+ t.Helper()
515
+ r.mu.Lock()
516
+ defer r.mu.Unlock()
517
+
518
+ handler, ok := r.handlers[name]
519
+ require.True(t, ok, "handler %q was not registered", name)
520
+ return handler
521
+}
522
+
523
+func (r *capturingFunctionRegistry) unregisteredNames() []string {
524
+ r.mu.Lock()
525
+ defer r.mu.Unlock()
526
+
527
+ out := make([]string, len(r.unregistered))
528
+ copy(out, r.unregistered)
529
+ return out
530
+}
531
+
532
+func (r *capturingFunctionRegistry) unregisteredIndex(name string) int {
533
+ r.mu.Lock()
534
+ defer r.mu.Unlock()
535
+
536
+ for i, got := range r.unregistered {
537
+ if got == name {
538
+ return i
539
+ }
540
+ }
541
+ return -1
542
+}
543
+
544
+func newModuleDispatchTestManager(
545
+ t *testing.T,
546
+ api *dyncfg.Responder,
547
+ jsonWriter func([]byte, int),
548
+ methodHandler funcapi.MethodHandler,
549
+ methods []funcapi.MethodConfig,
550
+) *Manager {
551
+ t.Helper()
552
+
553
+ mgr := New(Config{
554
+ PluginName: testPluginName,
555
+ FunctionJSONWriter: jsonWriter,
556
+ })
557
+ if api != nil {
558
+ mgr.SetDyncfgResponder(api)
559
+ }
560
+
561
+ creator := collectorapi.Creator{
562
+ Methods: func() []funcapi.MethodConfig { return methods },
563
+ MethodHandler: func(job collectorapi.RuntimeJob) funcapi.MethodHandler {
564
+ return methodHandler
565
+ },
566
+ }
567
+ mgr.modules = collectorapi.Registry{"mod": creator}
568
+ mgr.funcCtl.RegisterModules(mgr.modules)
569
+ mgr.funcCtl.OnJobStart(&lockProbeJob{fullName: "mod_job1", moduleName: "mod", name: "job1"})
570
+
571
+ return mgr
572
+}
573
+
574
+func newJobMethodDispatchTestManager(
575
+ t *testing.T,
576
+ fnReg FunctionRegistry,
577
+ jsonWriter func([]byte, int),
578
+ methodHandler funcapi.MethodHandler,
579
+ methods []funcapi.MethodConfig,
580
+) *Manager {
581
+ t.Helper()
582
+
583
+ mgr := New(Config{
584
+ PluginName: testPluginName,
585
+ FnReg: fnReg,
586
+ FunctionJSONWriter: jsonWriter,
587
+ })
588
+
589
+ creator := collectorapi.Creator{
590
+ JobMethods: func(_ collectorapi.RuntimeJob) []funcapi.MethodConfig { return methods },
591
+ MethodHandler: func(job collectorapi.RuntimeJob) funcapi.MethodHandler {
592
+ return methodHandler
593
+ },
594
+ }
595
+ mgr.modules = collectorapi.Registry{"mod": creator}
596
+ mgr.funcCtl.RegisterModules(mgr.modules)
597
+ mgr.startRunningJob(&lockProbeJob{fullName: "mod_job1", moduleName: "mod", name: "job1"})
598
+
599
+ return mgr
600
+}
601
+
602
+func jsonArrayStrings(t *testing.T, raw any) []string {
603
+ t.Helper()
604
+
605
+ items, ok := raw.([]any)
606
+ require.True(t, ok, "expected []any, got %T", raw)
607
+
608
+ out := make([]string, 0, len(items))
609
+ for _, item := range items {
610
+ s, ok := item.(string)
611
+ require.True(t, ok, "expected string item, got %T", item)
612
+ out = append(out, s)
613
+ }
614
+ return out
615
+}
616
+
617
+func jsonObjectArray(t *testing.T, raw any) []map[string]any {
618
+ t.Helper()
619
+
620
+ items, ok := raw.([]any)
621
+ require.True(t, ok, "expected []any, got %T", raw)
622
+
623
+ out := make([]map[string]any, 0, len(items))
624
+ for _, item := range items {
625
+ obj, ok := item.(map[string]any)
626
+ require.True(t, ok, "expected map[string]any item, got %T", item)
627
+ out = append(out, obj)
628
+ }
629
+ return out
630
+}
631
+
632
+func jsonNestedArrayValue(t *testing.T, raw any, row, col int) any {
633
+ t.Helper()
634
+
635
+ rows, ok := raw.([]any)
636
+ require.True(t, ok, "expected []any rows, got %T", raw)
637
+ require.Len(t, rows, row+1)
638
+
639
+ cols, ok := rows[row].([]any)
640
+ require.True(t, ok, "expected []any columns, got %T", rows[row])
641
+ require.Len(t, cols, col+1)
642
+
643
+ return cols[col]
644
+}
src/go/plugin/agent/jobmgr/funcshandler_test.go
deleted
-180
@@ -1,180 +0,0 @@
1
-// SPDX-License-Identifier: GPL-3.0-or-later
2
-
3
-package jobmgr
4
-
5
-import (
6
- "context"
7
- "testing"
8
-
9
- "github.com/netdata/netdata/go/plugins/pkg/funcapi"
10
- "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
11
- "github.com/netdata/netdata/go/plugins/plugin/framework/jobruntime"
12
- "github.com/stretchr/testify/assert"
13
-)
14
-
15
-// mockMethodHandler implements funcapi.MethodHandler for testing.
16
-type mockMethodHandler struct {
17
- job *jobruntime.Job
18
- paramsFunc func(ctx context.Context, method string) ([]funcapi.ParamConfig, error)
19
- handleFunc func(ctx context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse
20
-}
21
-
22
-func (m *mockMethodHandler) MethodParams(ctx context.Context, method string) ([]funcapi.ParamConfig, error) {
23
- if m.paramsFunc != nil {
24
- return m.paramsFunc(ctx, method)
25
- }
26
- return nil, nil
27
-}
28
-
29
-func (m *mockMethodHandler) Handle(ctx context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse {
30
- if m.handleFunc != nil {
31
- return m.handleFunc(ctx, method, params)
32
- }
33
- return nil
34
-}
35
-
36
-func (m *mockMethodHandler) Cleanup(ctx context.Context) {}
37
-
38
-func TestExtractParamValues(t *testing.T) {
39
- tests := map[string]struct {
40
- payload map[string]any
41
- key string
42
- expected []string
43
- }{
44
- "string value": {
45
- payload: map[string]any{"__job": "local"},
46
- key: "__job",
47
- expected: []string{"local"},
48
- },
49
- "array value (single element)": {
50
- payload: map[string]any{"__job": []any{"local"}},
51
- key: "__job",
52
- expected: []string{"local"},
53
- },
54
- "array value (multiple elements)": {
55
- payload: map[string]any{"__sort": []any{"calls", "total_time"}},
56
- key: "__sort",
57
- expected: []string{"calls", "total_time"},
58
- },
59
- "string array value": {
60
- payload: map[string]any{"__job": []string{"local"}},
61
- key: "__job",
62
- expected: []string{"local"},
63
- },
64
- "missing key": {
65
- payload: map[string]any{"__job": "local"},
66
- key: "__sort",
67
- expected: nil,
68
- },
69
- "empty payload": {
70
- payload: map[string]any{},
71
- key: "__job",
72
- expected: nil,
73
- },
74
- "nil in array": {
75
- payload: map[string]any{"__job": []any{nil, "test"}},
76
- key: "__job",
77
- expected: []string{"test"},
78
- },
79
- "empty array": {
80
- payload: map[string]any{"__job": []any{}},
81
- key: "__job",
82
- expected: nil,
83
- },
84
- "non-string value": {
85
- payload: map[string]any{"__job": 123},
86
- key: "__job",
87
- expected: nil,
88
- },
89
- "prefers selections": {
90
- payload: map[string]any{
91
- "__job": "root",
92
- "selections": map[string]any{
93
- "__job": []any{"selected"},
94
- },
95
- },
96
- key: "__job",
97
- expected: []string{"selected"},
98
- },
99
- }
100
-
101
- for name, tc := range tests {
102
- t.Run(name, func(t *testing.T) {
103
- result := extractParamValues(tc.payload, tc.key)
104
- assert.Equal(t, tc.expected, result)
105
- })
106
- }
107
-}
108
-
109
-func TestBuildParams(t *testing.T) {
110
- tests := map[string]struct {
111
- run func(t *testing.T)
112
- }{
113
- "build accepted params": {
114
- run: func(t *testing.T) {
115
- sortDir := funcapi.FieldSortDescending
116
- methodParams := []funcapi.ParamConfig{
117
- {ID: "__sort", Selection: funcapi.ParamSelect, Options: []funcapi.ParamOption{{ID: "calls", Name: "Calls", Sort: &sortDir}}},
118
- {ID: "db"},
119
- {ID: "extra"},
120
- }
121
-
122
- result := buildAcceptedParams(methodParams)
123
- assert.Equal(t, []string{"__job", "__sort", "db", "extra"}, result)
124
- },
125
- },
126
- "build required params uses select type": {
127
- run: func(t *testing.T) {
128
- // Setup a minimal manager with test data.
129
- r := newModuleFuncRegistry()
130
- r.registerModule("postgres", collectorapi.Creator{
131
- Methods: func() []funcapi.MethodConfig {
132
- return []funcapi.MethodConfig{{
133
- ID: "top-queries",
134
- Name: "Top Queries",
135
- }}
136
- },
137
- })
138
- r.addJob("postgres", "master-db", newTestModuleFuncsJob("master-db"))
139
-
140
- mgr := &Manager{moduleFuncs: r}
141
- methodParams := []funcapi.ParamConfig{
142
- {
143
- ID: "__sort",
144
- Name: "Filter By",
145
- Selection: funcapi.ParamSelect,
146
- UniqueView: true,
147
- Options: []funcapi.ParamOption{
148
- {ID: "total_time", Name: "By Total Time", Default: true},
149
- },
150
- },
151
- }
152
- params := mgr.buildRequiredParams("postgres", methodParams)
153
-
154
- assert.Len(t, params, 2, "should have 2 required params: __job, __sort")
155
- for _, param := range params {
156
- paramType, ok := param["type"]
157
- assert.True(t, ok, "param should have type field")
158
- assert.Equal(t, "select", paramType, "param type must be 'select' for single-select, not 'multiselect'")
159
-
160
- assert.Contains(t, param, "id", "param should have id")
161
- assert.Contains(t, param, "name", "param should have name")
162
- assert.Contains(t, param, "options", "param should have options")
163
- assert.Contains(t, param, "unique_view", "param should have unique_view")
164
-
165
- uniqueView, _ := param["unique_view"].(bool)
166
- assert.True(t, uniqueView, "unique_view should be true")
167
- }
168
-
169
- assert.Equal(t, "__job", params[0]["id"])
170
- assert.Equal(t, "__sort", params[1]["id"])
171
- },
172
- },
173
- }
174
-
175
- for name, tc := range tests {
176
- t.Run(name, func(t *testing.T) {
177
- tc.run(t)
178
- })
179
- }
180
-}
src/go/plugin/agent/jobmgr/job_factory.go
+44
-11
@@ -3,6 +3,7 @@
3
package jobmgr
4
5
import (
6
+ "context"
7
"fmt"
8
"io"
9
"os"
@@ -10,6 +11,8 @@ import (
11
12
"github.com/netdata/netdata/go/plugins/logger"
13
"github.com/netdata/netdata/go/plugins/plugin/agent/internal/naming"
14
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/resolver"
15
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
16
"github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
17
"github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
18
"github.com/netdata/netdata/go/plugins/plugin/framework/jobruntime"
@@ -22,35 +25,51 @@ import (
25
type jobFactory struct {
26
logger *logger.Logger
27
25
- pluginName string
26
- modules collectorapi.Registry
27
- vnodes *vnodeStore
28
- out io.Writer
28
+ pluginName string
29
+ modules collectorapi.Registry
30
+ vnodeLookup func(string) (*vnodes.VirtualNode, bool)
31
+ out io.Writer
32
+
33
+ validationOnly bool
34
35
auditMode bool
36
auditAnalyzer metricsaudit.Analyzer
37
auditDataDir string
38
39
runtimeService runtimecomp.Service
40
+
41
+ secretResolver *secretresolver.Resolver
42
+ secretStoreSvc secretstore.Service
43
+ ctx context.Context
44
}
45
46
func newJobFactory(m *Manager) *jobFactory {
47
return &jobFactory{
48
logger: m.Logger,
49
41
- pluginName: m.pluginName,
42
- modules: m.modules,
43
- vnodes: m.vnodes,
44
- out: m.out,
50
+ pluginName: m.pluginName,
51
+ modules: m.modules,
52
+ vnodeLookup: m.vnodesCtl.Lookup,
53
+ out: m.out,
54
55
auditMode: m.auditMode,
56
auditAnalyzer: m.auditAnalyzer,
57
auditDataDir: m.auditDataDir,
58
59
runtimeService: m.runtimeService,
60
+ secretResolver: m.secretResolver,
61
+ secretStoreSvc: m.secretsCtl.Service(),
62
+ ctx: m.baseContext(),
63
}
64
}
65
66
+func (f *jobFactory) validate(cfg confgroup.Config) error {
67
+ clone := *f
68
+ clone.validationOnly = true
69
+ _, err := clone.create(cfg)
70
+ return err
71
+}
72
+
73
func (f *jobFactory) create(cfg confgroup.Config) (runtimeJob, error) {
74
creator, ok := f.modules[cfg.Module()]
75
if !ok {
@@ -64,7 +83,10 @@ func (f *jobFactory) create(cfg confgroup.Config) (runtimeJob, error) {
83
84
var vnode *vnodes.VirtualNode
85
if cfg.Vnode() != "" {
67
- n, ok := f.vnodes.Lookup(cfg.Vnode())
86
+ if f.vnodeLookup == nil {
87
+ return nil, fmt.Errorf("vnode '%s' is not found", cfg.Vnode())
88
+ }
89
+ n, ok := f.vnodeLookup(cfg.Vnode())
90
if !ok || n == nil {
91
return nil, fmt.Errorf("vnode '%s' is not found", cfg.Vnode())
92
}
@@ -84,7 +106,11 @@ func (f *jobFactory) createV2(cfg confgroup.Config, creator collectorapi.Creator
106
if mod == nil {
107
return nil, fmt.Errorf("module %s CreateV2 returned nil", cfg.Module())
108
}
87
- if err := applyConfig(cfg, mod); err != nil {
109
+ storeSnapshot := (*secretstore.Snapshot)(nil)
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 {
114
return nil, err
115
}
116
@@ -119,7 +145,11 @@ func (f *jobFactory) createV1(cfg confgroup.Config, creator collectorapi.Creator
145
}
146
147
mod := creator.Create()
122
- if err := applyConfig(cfg, mod); err != nil {
148
+ storeSnapshot := (*secretstore.Snapshot)(nil)
149
+ if f.secretStoreSvc != nil {
150
+ storeSnapshot = f.secretStoreSvc.Capture()
151
+ }
152
+ if err := applyConfig(f.ctx, cfg, mod, f.secretResolver, f.secretStoreSvc, storeSnapshot); err != nil {
153
return nil, err
154
}
155
@@ -156,6 +186,9 @@ func (f *jobFactory) createV1(cfg confgroup.Config, creator collectorapi.Creator
186
}
187
188
func (f *jobFactory) createV1CaptureDir(cfg confgroup.Config) (string, error) {
189
+ if f.validationOnly {
190
+ return "", nil
191
+ }
192
if f.auditDataDir == "" {
193
return "", nil
194
}
src/go/plugin/agent/jobmgr/manager.go
+136
-294
@@ -12,10 +12,15 @@ import (
12
"time"
13
14
"github.com/netdata/netdata/go/plugins/logger"
15
- "github.com/netdata/netdata/go/plugins/pkg/funcapi"
15
"github.com/netdata/netdata/go/plugins/pkg/netdataapi"
16
"github.com/netdata/netdata/go/plugins/pkg/ticker"
17
+ "github.com/netdata/netdata/go/plugins/plugin/agent/jobmgr/funcctl"
18
+ "github.com/netdata/netdata/go/plugins/plugin/agent/jobmgr/secretsctl"
19
+ "github.com/netdata/netdata/go/plugins/plugin/agent/jobmgr/vnodectl"
20
"github.com/netdata/netdata/go/plugins/plugin/agent/policy"
21
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/resolver"
22
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
23
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/backends"
24
"github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
25
"github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
26
"github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
@@ -23,7 +28,6 @@ import (
28
"github.com/netdata/netdata/go/plugins/plugin/framework/metricsaudit"
29
"github.com/netdata/netdata/go/plugins/plugin/framework/runtimecomp"
30
"github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
26
- "gopkg.in/yaml.v2"
31
)
32
33
type Config struct {
@@ -36,6 +40,8 @@ type Config struct {
40
VarLibDir string
41
FnReg FunctionRegistry
42
Vnodes map[string]*vnodes.VirtualNode
43
+ SecretStores []secretstore.Config
44
+ SecretStoreService secretstore.Service
45
AuditMode bool
46
AuditAnalyzer metricsaudit.Analyzer
47
AuditDataDir string
@@ -72,32 +78,34 @@ func New(cfg Config) *Manager {
78
if vnodesReg == nil {
79
vnodesReg = make(map[string]*vnodes.VirtualNode)
80
}
81
+ secretStoreSvc := cfg.SecretStoreService
82
+ if secretStoreSvc == nil {
83
+ storeCreators := backends.Creators()
84
+ secretStoreSvc = secretstore.NewService(storeCreators...)
85
+ }
86
87
mgr := &Manager{
88
Logger: logger.New().With(
89
slog.String("component", "job manager"),
90
),
80
- pluginName: cfg.PluginName,
81
- out: out,
82
- runModePolicy: cfg.RunModePolicy,
83
- modules: cfg.Modules,
84
- runJob: cfg.RunJob,
85
- configDefaults: cfg.ConfigDefaults,
86
- varLibDir: cfg.VarLibDir,
87
- fnReg: fnReg,
88
- vnodes: newVnodeStore(vnodesReg),
89
-
90
- auditMode: cfg.AuditMode,
91
- auditAnalyzer: cfg.AuditAnalyzer,
92
- auditDataDir: cfg.AuditDataDir,
93
- functionJSONWriter: cfg.FunctionJSONWriter,
94
- runtimeService: cfg.RuntimeService,
95
-
96
- moduleFuncs: newModuleFuncRegistry(),
97
- staticMethodsSeen: make(map[string]struct{}),
91
+ pluginName: cfg.PluginName,
92
+ out: out,
93
+ runModePolicy: cfg.RunModePolicy,
94
+ modules: cfg.Modules,
95
+ runJobNames: cfg.RunJob,
96
+ configDefaults: cfg.ConfigDefaults,
97
+ varLibDir: cfg.VarLibDir,
98
+ fnReg: fnReg,
99
+ initialSecretStores: append([]secretstore.Config(nil), cfg.SecretStores...),
100
+
101
+ auditMode: cfg.AuditMode,
102
+ auditAnalyzer: cfg.AuditAnalyzer,
103
+ auditDataDir: cfg.AuditDataDir,
104
+
105
discoveredConfigs: newDiscoveredConfigsCache(),
99
- seen: seen,
100
- exposed: exposed,
106
+ collectorSeen: seen,
107
+ collectorExposed: exposed,
108
+ secretStoreDeps: newSecretStoreDeps(),
109
runningJobs: newRunningJobsCache(),
110
retryingTasks: newRetryingTasksCache(),
111
@@ -106,16 +114,25 @@ func New(cfg Config) *Manager {
114
rmCh: make(chan confgroup.Config),
115
dyncfgCh: make(chan dyncfg.Function),
116
cmdTestSem: make(chan struct{}, cmdTestWorkerCap),
109
- dyncfgApi: api,
117
+
118
+ dyncfgResponder: api,
119
+ runtimeService: cfg.RuntimeService,
120
+ secretResolver: secretresolver.New(),
121
}
122
+ mgr.funcCtl = funcctl.New(funcctl.Options{
123
+ Logger: mgr.Logger,
124
+ FnReg: fnReg,
125
+ API: api,
126
+ JSONWriter: cfg.FunctionJSONWriter,
127
+ })
128
112
- mgr.collectorCb = &collectorCallbacks{mgr: mgr}
113
- mgr.handler = dyncfg.NewHandler(dyncfg.HandlerOpts[confgroup.Config]{
129
+ mgr.collectorCallbacks = &collectorCallbacks{mgr: mgr}
130
+ mgr.collectorHandler = dyncfg.NewHandler(dyncfg.HandlerOpts[confgroup.Config]{
131
Logger: mgr.Logger,
132
API: api,
133
Seen: seen,
134
Exposed: exposed,
118
- Callbacks: mgr.collectorCb,
135
+ Callbacks: mgr.collectorCallbacks,
136
WaitKey: func(cfg confgroup.Config) string {
137
return cfg.FullName()
138
},
@@ -135,51 +152,75 @@ func New(cfg Config) *Manager {
152
dyncfg.CommandUserconfig,
153
},
154
})
155
+ mgr.vnodesCtl = vnodectl.New(vnodectl.Options{
156
+ Logger: mgr.Logger,
157
+ API: api,
158
+ Plugin: cfg.PluginName,
159
+ Initial: vnodesReg,
160
+ AffectedJobs: mgr.affectedVnodeJobs,
161
+ ApplyVnodeUpdate: mgr.applyVnodeUpdate,
162
+ })
163
+ mgr.secretsCtl = secretsctl.New(secretsctl.Options{
164
+ Logger: mgr.Logger,
165
+ API: mgr.dyncfgResponder,
166
+ Service: secretStoreSvc,
167
+ Plugin: mgr.pluginName,
168
+ Initial: mgr.initialSecretStores,
169
+ AffectedJobs: mgr.affectedJobs,
170
+ RestartableAffectedJobs: mgr.restartableAffectedJobs,
171
+ RestartDependentJobs: mgr.restartDependentJobs,
172
+ })
173
174
return mgr
175
}
176
177
// SetDyncfgResponder allows overriding the default responder (e.g., to silence output in CLI mode).
178
func (m *Manager) SetDyncfgResponder(responder *dyncfg.Responder) {
144
- if responder != nil && m.dyncfgApi != nil {
145
- responder.SetTerminalFinalizer(m.dyncfgApi.TerminalFinalizer())
179
+ if responder != nil && m.dyncfgResponder != nil {
180
+ responder.SetTerminalFinalizer(m.dyncfgResponder.TerminalFinalizer())
181
}
147
- dyncfg.BindResponder(&m.dyncfgApi, m.handler, responder)
182
+ dyncfg.BindResponder(&m.dyncfgResponder, m.collectorHandler, responder)
183
+ m.secretsCtl.SetAPI(responder)
184
+ m.vnodesCtl.SetAPI(responder)
185
+ m.funcCtl.SetAPI(responder)
186
}
187
188
type Manager struct {
189
*logger.Logger
190
153
- pluginName string
154
- out io.Writer
155
- runModePolicy policy.RunModePolicy
156
- modules collectorapi.Registry
157
- runJob []string
158
- configDefaults confgroup.Registry
159
- varLibDir string
160
- fnReg FunctionRegistry
161
- vnodes *vnodeStore
191
+ // Static configuration and injected dependencies.
192
+ pluginName string
193
+ out io.Writer
194
+ runModePolicy policy.RunModePolicy
195
+ modules collectorapi.Registry
196
+ runJobNames []string
197
+ configDefaults confgroup.Registry
198
+ varLibDir string
199
+ fnReg FunctionRegistry
200
+ initialSecretStores []secretstore.Config
201
202
// Metrics-audit mode.
203
auditMode bool
204
auditAnalyzer metricsaudit.Analyzer
205
auditDataDir string
206
168
- fileStatus *fileStatus
169
- moduleFuncs *moduleFuncRegistry
170
- // staticMethodsSeen tracks modules whose static methods were already exposed.
171
- // Registration is delayed until the first started job for a module.
172
- staticMethodsSeen map[string]struct{}
173
-
207
+ // Persistent caches and runtime state.
208
+ fileStatus *fileStatus
209
discoveredConfigs *discoveredConfigs
175
- seen *dyncfg.SeenCache[confgroup.Config]
176
- exposed *dyncfg.ExposedCache[confgroup.Config]
210
+ collectorSeen *dyncfg.SeenCache[confgroup.Config]
211
+ collectorExposed *dyncfg.ExposedCache[confgroup.Config]
212
+ secretStoreDeps *secretStoreDeps
213
retryingTasks *retryingTasks
214
runningJobs *runningJobs
215
180
- handler *dyncfg.Handler[confgroup.Config]
181
- collectorCb *collectorCallbacks
216
+ // Controllers and handlers.
217
+ funcCtl *funcctl.Controller
218
+ collectorHandler *dyncfg.Handler[confgroup.Config]
219
+ collectorCallbacks *collectorCallbacks
220
+ secretsCtl *secretsctl.Controller
221
+ vnodesCtl *vnodectl.Controller
222
223
+ // Runtime loop state.
224
ctx context.Context
225
started chan struct{}
226
addCh chan confgroup.Config
@@ -188,41 +229,37 @@ type Manager struct {
229
cmdTestSem chan struct{}
230
cmdTestWG sync.WaitGroup
231
191
- dyncfgApi *dyncfg.Responder
192
-
193
- // FunctionJSONWriter, when set, bypasses Netdata protocol output and writes raw JSON.
194
- functionJSONWriter func(payload []byte, code int)
232
+ // Shared service seams.
233
+ dyncfgResponder *dyncfg.Responder
234
235
// RuntimeService is an optional runtime/internal metrics registration seam.
236
// When set, V2 jobs may register per-job runtime components.
237
runtimeService runtimecomp.Service
238
+
239
+ secretResolver *secretresolver.Resolver
240
}
241
242
func (m *Manager) Run(ctx context.Context, in chan []*confgroup.Group) {
243
m.Info("instance is started")
244
defer func() { m.cleanup(); m.Info("instance is stopped") }()
245
m.ctx = ctx
246
+ m.funcCtl.Init(ctx)
247
206
- m.fnReg.RegisterPrefix("config", m.dyncfgCollectorPrefixValue(), dyncfg.WrapHandler(m.dyncfgConfig))
207
- m.fnReg.RegisterPrefix("config", m.dyncfgVnodePrefixValue(), dyncfg.WrapHandler(m.dyncfgConfig))
248
+ vnodePrefix := m.vnodesCtl.Prefix()
249
+ m.fnReg.RegisterPrefix("config", vnodePrefix, dyncfg.WrapHandler(m.dyncfgConfig))
250
+ m.fnReg.RegisterPrefix("config", m.dyncfgSecretStorePrefixValue(), dyncfg.WrapHandler(m.dyncfgConfig))
251
209
- m.dyncfgVnodeModuleCreate()
252
+ m.vnodesCtl.CreateTemplates()
253
+ m.vnodesCtl.PublishExisting(dyncfg.StatusRunning)
254
211
- m.vnodes.ForEach(func(cfg *vnodes.VirtualNode) bool {
212
- m.dyncfgVnodeJobCreate(cfg, dyncfg.StatusRunning)
213
- return true
214
- })
255
+ m.secretsCtl.CreateTemplates()
256
+ m.secretsCtl.PublishExisting()
257
216
- for name, creator := range m.modules {
258
+ m.fnReg.RegisterPrefix("config", m.dyncfgCollectorPrefixValue(), dyncfg.WrapHandler(m.dyncfgConfig))
259
+ for name := range m.modules {
260
m.dyncfgCollectorModuleCreate(name)
218
-
219
- // Register module if it provides static methods OR per-job methods
220
- if creator.Methods != nil || creator.JobMethods != nil {
221
- m.moduleFuncs.registerModule(name, creator)
222
- }
223
-
224
- // Note: Static module methods and per-job methods are registered in startRunningJob.
261
}
262
+ m.funcCtl.RegisterModules(m.modules)
263
264
m.loadFileStatus()
265
@@ -258,18 +295,12 @@ func (m *Manager) WaitStarted(ctx context.Context) bool {
295
296
// GetJobNames returns the currently running job names for a module.
297
func (m *Manager) GetJobNames(moduleName string) []string {
261
- return m.moduleFuncs.getJobNames(moduleName)
298
+ return m.funcCtl.GetJobNames(moduleName)
299
}
300
301
// ExecuteFunction executes a function handler directly (function name must be module:method).
302
func (m *Manager) ExecuteFunction(functionName string, fn functions.Function) {
266
- moduleName, methodID, err := functions.SplitFunctionName(functionName)
267
- if err != nil {
268
- m.respondError(fn, 400, "%v", err)
269
- return
270
- }
271
- handler := m.makeMethodFuncHandler(moduleName, methodID)
272
- handler(fn)
303
+ m.funcCtl.ExecuteFunction(functionName, fn)
304
}
305
306
func (m *Manager) runProcessConfGroups(in chan []*confgroup.Group) {
@@ -284,9 +315,9 @@ func (m *Manager) runProcessConfGroups(in chan []*confgroup.Group) {
315
for _, gr := range groups {
316
a, r := m.discoveredConfigs.add(gr)
317
m.Debugf("received configs: %d/+%d/-%d ('%s')", len(gr.Configs), len(a), len(r), gr.Source)
287
- if len(m.runJob) > 0 {
318
+ if len(m.runJobNames) > 0 {
319
a = slices.DeleteFunc(a, func(config confgroup.Config) bool {
289
- return !slices.ContainsFunc(m.runJob, func(name string) bool { return config.Name() == name })
320
+ return !slices.ContainsFunc(m.runJobNames, func(name string) bool { return config.Name() == name })
321
})
322
}
323
sendConfigs(m.ctx, m.rmCh, r...)
@@ -298,8 +329,8 @@ func (m *Manager) runProcessConfGroups(in chan []*confgroup.Group) {
329
330
func (m *Manager) run() {
331
for {
301
- if m.handler.WaitingForDecision() {
302
- step, ok := m.handler.NextWaitDecisionStep(m.ctx, m.dyncfgCh)
332
+ if m.collectorHandler.WaitingForDecision() {
333
+ step, ok := m.collectorHandler.NextWaitDecisionStep(m.ctx, m.dyncfgCh)
334
if !ok {
335
return
336
}
@@ -337,11 +368,11 @@ func (m *Manager) addConfig(cfg confgroup.Config) {
368
369
m.retryingTasks.remove(cfg)
370
340
- m.handler.RememberDiscoveredConfig(cfg)
371
+ m.collectorHandler.RememberDiscoveredConfig(cfg)
372
342
- entry, ok := m.exposed.LookupByKey(cfg.ExposedKey())
373
+ entry, ok := m.collectorExposed.LookupByKey(cfg.ExposedKey())
374
if !ok {
344
- entry = m.handler.AddDiscoveredConfig(cfg, dyncfg.StatusAccepted)
375
+ entry = m.collectorHandler.AddDiscoveredConfig(cfg, dyncfg.StatusAccepted)
376
} else {
377
sp, ep := cfg.SourceTypePriority(), entry.Cfg.SourceTypePriority()
378
if ep > sp || (ep == sp && entry.Status == dyncfg.StatusRunning) {
@@ -351,30 +382,33 @@ func (m *Manager) addConfig(cfg confgroup.Config) {
382
m.stopRunningJob(entry.Cfg.FullName())
383
m.fileStatus.remove(entry.Cfg)
384
}
354
- entry = m.handler.AddDiscoveredConfig(cfg, dyncfg.StatusAccepted) // replace existing exposed
385
+ entry = m.collectorHandler.AddDiscoveredConfig(cfg, dyncfg.StatusAccepted) // replace existing exposed
386
}
387
357
- m.handler.NotifyJobCreate(entry.Cfg, entry.Status)
388
+ m.syncSecretStoreDepsForConfig(entry.Cfg)
389
+ m.collectorHandler.NotifyJobCreate(entry.Cfg, entry.Status)
390
391
if m.runModePolicy.AutoEnableDiscovered {
360
- m.handler.CmdEnable(dyncfg.NewFunction(functions.Function{Args: []string{m.dyncfgJobID(entry.Cfg), "enable"}}))
392
+ m.collectorHandler.CmdEnable(dyncfg.NewFunction(functions.Function{Args: []string{m.dyncfgJobID(entry.Cfg), "enable"}}))
393
} else {
362
- m.handler.WaitForDecision(entry.Cfg)
394
+ m.collectorHandler.WaitForDecision(entry.Cfg)
395
}
396
}
397
398
func (m *Manager) removeConfig(cfg confgroup.Config) {
399
m.retryingTasks.remove(cfg)
400
369
- entry, ok := m.handler.RemoveDiscoveredConfig(cfg)
401
+ entry, ok := m.collectorHandler.RemoveDiscoveredConfig(cfg)
402
if !ok {
403
return
404
}
405
+ // Stop first so running-state cleanup is applied against existing dependency state.
406
m.stopRunningJob(cfg.FullName())
407
+ m.secretStoreDeps.RemoveActiveJob(entry.Cfg.FullName())
408
m.fileStatus.remove(cfg)
409
410
if !isStock(cfg) || entry.Status == dyncfg.StatusRunning {
377
- m.handler.NotifyJobRemove(cfg)
411
+ m.collectorHandler.NotifyJobRemove(cfg)
412
}
413
}
414
@@ -402,20 +436,13 @@ func (m *Manager) startRunningJob(job runtimeJob) {
436
m.runningJobs.lock()
437
m.runningJobs.add(job.FullName(), job)
438
m.runningJobs.unlock()
439
+ m.secretStoreDeps.setRunning(job.FullName(), true)
440
406
- // Track job for module function routing.
407
- m.moduleFuncs.addJob(job.ModuleName(), job.Name(), job)
408
- m.registerModuleMethodsOnFirstJobStart(job.ModuleName())
409
-
410
- // Register job-specific methods if module provides JobMethods callback
411
- creator, ok := m.modules.Lookup(job.ModuleName())
412
- if !ok || creator.JobMethods == nil {
413
- return
414
- }
415
- methods := creator.JobMethods(job)
416
- if len(methods) > 0 {
417
- m.registerJobMethods(job, methods)
418
- }
441
+ // Known behavior: Start runs asynchronously, so function handlers may be
442
+ // published before the job flips its running flag. Immediate function calls
443
+ // can transiently return 503; this is accepted for now to avoid adding a
444
+ // broader runtime readiness contract.
445
+ m.funcCtl.OnJobStart(job)
446
}
447
448
func (m *Manager) stopRunningJob(name string) {
@@ -425,35 +452,18 @@ func (m *Manager) stopRunningJob(name string) {
452
m.runningJobs.remove(name)
453
}
454
m.runningJobs.unlock()
428
-
455
if ok {
430
- // Unregister job-specific methods.
431
- m.unregisterJobMethods(job)
432
- // Remove job from module function registry.
433
- m.moduleFuncs.removeJob(job.ModuleName(), job.Name())
434
- // Static module methods remain registered for now. Once Netdata supports
435
- // function removal, this should unregister static methods when the last
436
- // running job for the module is removed.
456
+ m.secretStoreDeps.setRunning(name, false)
457
+ m.funcCtl.OnJobStop(job)
458
job.Stop()
459
}
460
}
461
462
func (m *Manager) cleanup() {
463
m.fnReg.UnregisterPrefix("config", m.dyncfgCollectorPrefixValue())
464
+ m.fnReg.UnregisterPrefix("config", m.dyncfgSecretStorePrefixValue())
465
m.fnReg.UnregisterPrefix("config", m.dyncfgVnodePrefixValue())
444
-
445
- // Unregister module functions
446
- for name, creator := range m.modules {
447
- if creator.Methods != nil {
448
- for _, method := range creator.Methods() {
449
- if method.ID == "" {
450
- continue
451
- }
452
- funcName := fmt.Sprintf("%s:%s", name, method.ID)
453
- m.fnReg.Unregister(funcName)
454
- }
455
- }
456
- }
466
+ m.funcCtl.Cleanup()
467
468
for _, job := range m.runningJobs.snapshot() {
469
m.stopRunningJob(job.FullName())
@@ -476,138 +486,12 @@ func (m *Manager) waitCmdTestWorkers() {
486
}
487
}
488
479
-func (m *Manager) registerModuleMethodsOnFirstJobStart(moduleName string) {
480
- if _, ok := m.staticMethodsSeen[moduleName]; ok {
481
- return
482
- }
483
-
484
- creator, ok := m.modules.Lookup(moduleName)
485
- if !ok || creator.Methods == nil {
486
- return
487
- }
488
-
489
- methods := creator.Methods()
490
- for _, method := range methods {
491
- if method.ID == "" {
492
- m.Warningf("skipping function registration for module '%s': empty method ID", moduleName)
493
- continue
494
- }
495
- funcName := fmt.Sprintf("%s:%s", moduleName, method.ID)
496
- m.fnReg.Register(funcName, m.makeMethodFuncHandler(moduleName, method.ID))
497
-
498
- help := method.Help
499
- if help == "" {
500
- help = fmt.Sprintf("%s %s data function", moduleName, method.ID)
501
- }
502
-
503
- // https://github.com/netdata/netdata/blob/1bc1775a17590b3c0fe3a4fe547dc6146d07be89/src/libnetdata/user-auth/http-access.h#L21
504
- const cloudAccess = "0x0013" // SIGNED_ID | SAME_SPACE | SENSITIVE_DATA
505
- access := "0x0000"
506
- if method.RequireCloud {
507
- access = cloudAccess
508
- }
509
- m.dyncfgApi.FunctionGlobal(netdataapi.FunctionGlobalOpts{
510
- Name: funcName,
511
- Timeout: 60,
512
- Help: help,
513
- Tags: "top",
514
- Access: access,
515
- Priority: 100,
516
- Version: 3,
517
- })
518
- }
519
-
520
- m.staticMethodsSeen[moduleName] = struct{}{}
521
-}
522
-
523
-// registerJobMethods registers methods for a specific job with Netdata
524
-func (m *Manager) registerJobMethods(job collectorapi.RuntimeJob, methods []funcapi.MethodConfig) {
525
- planned := make(map[string]struct{}, len(methods))
526
-
527
- for _, method := range methods {
528
- if method.ID == "" {
529
- m.Warningf("skipping job method registration for %s[%s]: empty method ID", job.ModuleName(), job.Name())
530
- continue
531
- }
532
-
533
- funcName := fmt.Sprintf("%s:%s", job.ModuleName(), method.ID)
534
-
535
- if _, exists := planned[method.ID]; exists {
536
- m.Errorf("job method registration aborted for %s[%s]: duplicate method ID in batch ('%s')", job.ModuleName(), job.Name(), funcName)
537
- return
538
- }
539
- planned[method.ID] = struct{}{}
540
-
541
- if collision, exists := m.moduleFuncs.findMethodCollision(job.ModuleName(), job.Name(), method.ID); exists {
542
- m.Errorf("job method registration aborted for %s[%s]: collision on '%s' (%s)", job.ModuleName(), job.Name(), funcName, collision)
543
- return
544
- }
545
- }
546
-
547
- for _, method := range methods {
548
- if method.ID == "" {
549
- continue
550
- }
551
-
552
- funcName := fmt.Sprintf("%s:%s", job.ModuleName(), method.ID)
553
-
554
- // Register Go handler for this function
555
- m.fnReg.Register(funcName, m.makeJobMethodFuncHandler(job.ModuleName(), job.Name(), method.ID))
556
-
557
- // Notify Netdata about this function
558
- help := method.Help
559
- if help == "" {
560
- help = fmt.Sprintf("%s %s data function", job.ModuleName(), method.ID)
561
- }
562
-
563
- const cloudAccess = "0x0013" // SIGNED_ID | SAME_SPACE | SENSITIVE_DATA
564
- access := "0x0000"
565
- if method.RequireCloud {
566
- access = cloudAccess
567
- }
568
-
569
- m.dyncfgApi.FunctionGlobal(netdataapi.FunctionGlobalOpts{
570
- Name: funcName,
571
- Timeout: 60,
572
- Help: help,
573
- Tags: "top",
574
- Access: access,
575
- Priority: 100,
576
- Version: 3,
577
- })
578
-
579
- m.Debugf("registered job method: %s for job %s[%s]", funcName, job.ModuleName(), job.Name())
580
- }
581
-
582
- // Store methods in registry for later unregistration
583
- m.moduleFuncs.registerJobMethods(job.ModuleName(), job.Name(), methods)
489
+func (m *Manager) createCollectorJob(cfg confgroup.Config) (runtimeJob, error) {
490
+ return newJobFactory(m).create(cfg)
491
}
492
586
-// unregisterJobMethods unregisters methods for a specific job
587
-func (m *Manager) unregisterJobMethods(job collectorapi.RuntimeJob) {
588
- methods := m.moduleFuncs.getJobMethods(job.ModuleName(), job.Name())
589
- if len(methods) == 0 {
590
- return
591
- }
592
-
593
- for _, method := range methods {
594
- if method.ID == "" {
595
- continue
596
- }
597
-
598
- funcName := fmt.Sprintf("%s:%s", job.ModuleName(), method.ID)
599
-
600
- // Unregister Go handler
601
- m.fnReg.Unregister(funcName)
602
-
603
- // Notify Netdata to remove function (no-op until Netdata supports it)
604
- m.dyncfgApi.FunctionRemove(funcName)
605
-
606
- m.Debugf("unregistered job method: %s for job %s[%s]", funcName, job.ModuleName(), job.Name())
607
- }
608
-
609
- // Remove from registry
610
- m.moduleFuncs.unregisterJobMethods(job.ModuleName(), job.Name())
493
+func (m *Manager) validateCollectorJob(cfg confgroup.Config) error {
494
+ return newJobFactory(m).validate(cfg)
495
}
496
497
func (m *Manager) baseContext() context.Context {
@@ -617,10 +501,6 @@ func (m *Manager) baseContext() context.Context {
501
return context.Background()
502
}
503
620
-func (m *Manager) createCollectorJob(cfg confgroup.Config) (runtimeJob, error) {
621
- return newJobFactory(m).create(cfg)
622
-}
623
-
504
func runRetryTask(ctx context.Context, out chan<- confgroup.Config, cfg confgroup.Config) {
505
t := time.NewTimer(time.Second * time.Duration(cfg.AutoDetectionRetry()))
506
defer t.Stop()
@@ -649,41 +529,3 @@ func isStock(cfg confgroup.Config) bool {
529
func isDyncfg(cfg confgroup.Config) bool {
530
return cfg.SourceType() == confgroup.TypeDyncfg
531
}
652
-
653
-func newConfigModule(creator collectorapi.Creator) (configModule, error) {
654
- if creator.CreateV2 != nil {
655
- mod := creator.CreateV2()
656
- if mod == nil {
657
- return nil, fmt.Errorf("CreateV2 returned nil")
658
- }
659
- return mod, nil
660
- }
661
- if creator.Create == nil {
662
- return nil, fmt.Errorf("no module creator is defined")
663
- }
664
- mod := creator.Create()
665
- if mod == nil {
666
- return nil, fmt.Errorf("Create returned nil")
667
- }
668
- return mod, nil
669
-}
670
-
671
-func applyConfig(cfg confgroup.Config, module any) error {
672
- bs, err := yaml.Marshal(cfg)
673
- if err != nil {
674
- return err
675
- }
676
- return yaml.Unmarshal(bs, module)
677
-}
678
-
679
-func makeLabels(cfg confgroup.Config) map[string]string {
680
- labels := make(map[string]string)
681
- for name, value := range cfg.Labels() {
682
- n, ok1 := name.(string)
683
- v, ok2 := value.(string)
684
- if ok1 && ok2 {
685
- labels[n] = v
686
- }
687
- }
688
- return labels
689
-}
src/go/plugin/agent/jobmgr/manager_process_test.go
+94
-49
@@ -3,7 +3,9 @@
3
package jobmgr
4
5
import (
6
+ "bytes"
7
"context"
8
+ "strings"
9
"sync"
10
"testing"
11
"time"
@@ -12,6 +14,7 @@ import (
14
"github.com/stretchr/testify/require"
15
16
"github.com/netdata/netdata/go/plugins/pkg/funcapi"
17
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
18
"github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
19
"github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
20
"github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
@@ -92,7 +95,7 @@ func TestRun_WaitTimeoutClearsGateAndKeepsAccepted(t *testing.T) {
95
cfg2 := prepareStockCfg("success", "wait2")
96
97
mgr.addCh <- cfg1
95
- require.Eventually(t, mgr.handler.WaitingForDecision, time.Second, 10*time.Millisecond)
98
+ require.Eventually(t, mgr.collectorHandler.WaitingForDecision, time.Second, 10*time.Millisecond)
99
100
secondSent := make(chan struct{})
101
go func() {
@@ -112,7 +115,7 @@ func TestRun_WaitTimeoutClearsGateAndKeepsAccepted(t *testing.T) {
115
t.Fatal("second add did not progress after wait timeout")
116
}
117
115
- entry1, ok := mgr.exposed.LookupByKey(cfg1.ExposedKey())
118
+ entry1, ok := mgr.collectorExposed.LookupByKey(cfg1.ExposedKey())
119
require.True(t, ok, "first config must stay exposed after timeout")
120
assert.Equal(t, dyncfg.StatusAccepted, entry1.Status)
121
}
@@ -217,7 +220,7 @@ func TestStartRunningJob_RegistersModuleMethodsOnFirstStartedJob(t *testing.T) {
220
},
221
}
222
mgr.modules = collectorapi.Registry{"mod": creator}
220
- mgr.moduleFuncs.registerModule("mod", creator)
223
+ mgr.funcCtl.RegisterModules(mgr.modules)
224
225
job := &lockProbeJob{fullName: "mod_job1", moduleName: "mod", name: "job1"}
226
mgr.startRunningJob(job)
@@ -234,7 +237,7 @@ func TestStartRunningJob_DoesNotReregisterModuleMethods(t *testing.T) {
237
},
238
}
239
mgr.modules = collectorapi.Registry{"mod": creator}
237
- mgr.moduleFuncs.registerModule("mod", creator)
240
+ mgr.funcCtl.RegisterModules(mgr.modules)
241
242
job1 := &lockProbeJob{fullName: "mod_job1", moduleName: "mod", name: "job1"}
243
mgr.startRunningJob(job1)
@@ -247,63 +250,87 @@ func TestStartRunningJob_DoesNotReregisterModuleMethods(t *testing.T) {
250
assert.ElementsMatch(t, []string{"mod:a", "mod:b"}, registered)
251
}
252
250
-func TestRegisterJobMethods_FailFastOnCollisionWithStaticMethod(t *testing.T) {
253
+func TestRun_RegistersDyncfgConfigPrefixes(t *testing.T) {
254
fnReg := &recordingFunctionRegistry{}
255
mgr := New(Config{PluginName: testPluginName, FnReg: fnReg})
253
- mgr.moduleFuncs.registerModule("mod", collectorapi.Creator{
254
- Methods: func() []funcapi.MethodConfig {
255
- return []funcapi.MethodConfig{{ID: "dup"}}
256
- },
257
- })
256
259
- job := &lockProbeJob{fullName: "mod_job1", moduleName: "mod", name: "job1"}
260
- mgr.registerJobMethods(job, []funcapi.MethodConfig{{ID: "dup"}})
257
+ ctx, cancel := context.WithCancel(context.Background())
258
+ defer cancel()
259
262
- assert.Empty(t, fnReg.registeredNames())
263
- assert.Empty(t, mgr.moduleFuncs.getJobMethods("mod", "job1"))
264
-}
260
+ in := make(chan []*confgroup.Group)
261
+ done := make(chan struct{})
262
+ go func() {
263
+ mgr.Run(ctx, in)
264
+ close(done)
265
+ }()
266
266
-func TestRegisterJobMethods_FailFastOnCollisionWithOtherJob(t *testing.T) {
267
- fnReg := &recordingFunctionRegistry{}
268
- mgr := New(Config{PluginName: testPluginName, FnReg: fnReg})
269
- mgr.moduleFuncs.registerModule("mod", collectorapi.Creator{})
270
- mgr.moduleFuncs.registerJobMethods("mod", "jobA", []funcapi.MethodConfig{{ID: "dup"}})
267
+ waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second)
268
+ defer waitCancel()
269
+ require.True(t, mgr.WaitStarted(waitCtx), "manager did not report started")
270
272
- job := &lockProbeJob{fullName: "mod_jobB", moduleName: "mod", name: "jobB"}
273
- mgr.registerJobMethods(job, []funcapi.MethodConfig{{ID: "dup"}})
271
+ cancel()
272
+ close(in)
273
+
274
+ select {
275
+ case <-done:
276
+ case <-time.After(2 * time.Second):
277
+ t.Fatal("manager did not stop after cancel")
278
+ }
279
275
- assert.Empty(t, fnReg.registeredNames())
276
- assert.Empty(t, mgr.moduleFuncs.getJobMethods("mod", "jobB"))
280
+ assert.Equal(t, []registeredPrefix{
281
+ {name: "config", prefix: mgr.dyncfgVnodePrefixValue()},
282
+ {name: "config", prefix: mgr.dyncfgSecretStorePrefixValue()},
283
+ {name: "config", prefix: mgr.dyncfgCollectorPrefixValue()},
284
+ }, fnReg.registeredPrefixes())
285
}
286
279
-func TestRegisterJobMethods_FailFastOnDuplicateWithinBatch(t *testing.T) {
287
+func TestRun_PublishesVnodesAndSecretstoresBeforeCollectorTemplates(t *testing.T) {
288
fnReg := &recordingFunctionRegistry{}
281
- mgr := New(Config{PluginName: testPluginName, FnReg: fnReg})
282
- mgr.moduleFuncs.registerModule("mod", collectorapi.Creator{})
283
-
284
- job := &lockProbeJob{fullName: "mod_job1", moduleName: "mod", name: "job1"}
285
- mgr.registerJobMethods(job, []funcapi.MethodConfig{
286
- {ID: "dup"},
287
- {ID: "dup"},
289
+ var buf bytes.Buffer
290
+
291
+ mgr := New(Config{
292
+ PluginName: testPluginName,
293
+ FnReg: fnReg,
294
+ Out: &buf,
295
+ Modules: collectorapi.Registry{
296
+ "mod": collectorapi.Creator{},
297
+ },
298
})
299
290
- assert.Empty(t, fnReg.registeredNames())
291
- assert.Empty(t, mgr.moduleFuncs.getJobMethods("mod", "job1"))
292
-}
300
+ ctx, cancel := context.WithCancel(context.Background())
301
+ defer cancel()
302
294
-func TestRegisterJobMethods_SuccessCommitsAllMethods(t *testing.T) {
295
- fnReg := &recordingFunctionRegistry{}
296
- mgr := New(Config{PluginName: testPluginName, FnReg: fnReg})
297
- mgr.moduleFuncs.registerModule("mod", collectorapi.Creator{})
303
+ in := make(chan []*confgroup.Group)
304
+ done := make(chan struct{})
305
+ go func() {
306
+ mgr.Run(ctx, in)
307
+ close(done)
308
+ }()
309
299
- job := &lockProbeJob{fullName: "mod_job1", moduleName: "mod", name: "job1"}
300
- mgr.registerJobMethods(job, []funcapi.MethodConfig{
301
- {ID: "a"},
302
- {ID: "b"},
303
- })
310
+ waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second)
311
+ defer waitCancel()
312
+ require.True(t, mgr.WaitStarted(waitCtx), "manager did not report started")
313
305
- assert.ElementsMatch(t, []string{"mod:a", "mod:b"}, fnReg.registeredNames())
306
- assert.Len(t, mgr.moduleFuncs.getJobMethods("mod", "job1"), 2)
314
+ cancel()
315
+ close(in)
316
+
317
+ select {
318
+ case <-done:
319
+ case <-time.After(2 * time.Second):
320
+ t.Fatal("manager did not stop after cancel")
321
+ }
322
+
323
+ output := buf.String()
324
+
325
+ vnodeIdx := strings.Index(output, "CONFIG "+mgr.dyncfgVnodePrefixValue()+" create accepted template /collectors/"+testPluginName+"/Vnodes")
326
+ secretIdx := strings.Index(output, "CONFIG "+mgr.dyncfgSecretStoreID(string(secretstore.KindVault))+" create accepted template /collectors/"+testPluginName+"/SecretStores")
327
+ collectorIdx := strings.Index(output, "CONFIG "+mgr.dyncfgModID("mod")+" create accepted template /collectors/"+testPluginName+"/Jobs")
328
+
329
+ require.NotEqual(t, -1, vnodeIdx, "vnode template publication not found")
330
+ require.NotEqual(t, -1, secretIdx, "secretstore template publication not found")
331
+ require.NotEqual(t, -1, collectorIdx, "collector template publication not found")
332
+ assert.Less(t, vnodeIdx, collectorIdx, "vnode publication must happen before collector template publication")
333
+ assert.Less(t, secretIdx, collectorIdx, "secretstore publication must happen before collector template publication")
334
}
335
336
type lockProbeJob struct {
@@ -341,6 +368,7 @@ func (j *lockProbeJob) UpdateVnode(_ *vnodes.VirtualNode) {}
368
type recordingFunctionRegistry struct {
369
mu sync.Mutex
370
registered []string
371
+ prefixes []registeredPrefix
372
}
373
374
func (r *recordingFunctionRegistry) Register(name string, _ func(functions.Function)) {
@@ -349,9 +377,13 @@ func (r *recordingFunctionRegistry) Register(name string, _ func(functions.Funct
377
r.mu.Unlock()
378
}
379
352
-func (r *recordingFunctionRegistry) Unregister(string) {}
353
-func (r *recordingFunctionRegistry) RegisterPrefix(string, string, func(functions.Function)) {}
354
-func (r *recordingFunctionRegistry) UnregisterPrefix(string, string) {}
380
+func (r *recordingFunctionRegistry) Unregister(string) {}
381
+func (r *recordingFunctionRegistry) RegisterPrefix(name, prefix string, _ func(functions.Function)) {
382
+ r.mu.Lock()
383
+ r.prefixes = append(r.prefixes, registeredPrefix{name: name, prefix: prefix})
384
+ r.mu.Unlock()
385
+}
386
+func (r *recordingFunctionRegistry) UnregisterPrefix(string, string) {}
387
388
func (r *recordingFunctionRegistry) registeredNames() []string {
389
r.mu.Lock()
@@ -360,3 +392,16 @@ func (r *recordingFunctionRegistry) registeredNames() []string {
392
copy(out, r.registered)
393
return out
394
}
395
+
396
+func (r *recordingFunctionRegistry) registeredPrefixes() []registeredPrefix {
397
+ r.mu.Lock()
398
+ defer r.mu.Unlock()
399
+ out := make([]registeredPrefix, len(r.prefixes))
400
+ copy(out, r.prefixes)
401
+ return out
402
+}
403
+
404
+type registeredPrefix struct {
405
+ name string
406
+ prefix string
407
+}
src/go/plugin/agent/jobmgr/manager_test.go
+44
@@ -530,6 +530,8 @@ CONFIG test:collector:fail:name delete
530
}
531
532
func TestManager_Run_Dyncfg_Get(t *testing.T) {
533
+ t.Setenv("DYNCFG_GET_SECRET", "resolved-secret-value")
534
+
535
tests := map[string]struct {
536
createSim func() *runSim
537
}{
@@ -596,6 +598,48 @@ CONFIG test:collector:success:test create accepted job /collectors/test/Jobs dyn
598
FUNCTION_RESULT_BEGIN 2-get 200 application/json
599
{"option_str":"1","option_int":1}
600
FUNCTION_RESULT_END
601
+`,
602
+ }
603
+ },
604
+ },
605
+ "[get] existing with secret ref remains unresolved": {
606
+ createSim: func() *runSim {
607
+ cfg := prepareDyncfgCfg("success", "test").
608
+ Set("option_str", "${env:DYNCFG_GET_SECRET}")
609
+ bs, _ := json.Marshal(cfg)
610
+
611
+ return &runSim{
612
+ do: func(mgr *Manager, _ chan []*confgroup.Group) {
613
+ mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
614
+ UID: "1-add",
615
+ Source: "type=dyncfg",
616
+ Args: []string{mgr.dyncfgModID(cfg.Module()), "add", cfg.Name()},
617
+ Payload: bs,
618
+ }))
619
+ mgr.dyncfgConfig(dyncfg.NewFunction(functions.Function{
620
+ UID: "2-get",
621
+ Args: []string{mgr.dyncfgJobID(cfg), "get"},
622
+ }))
623
+ },
624
+ wantDiscovered: nil,
625
+ wantSeen: []confgroup.Config{
626
+ cfg,
627
+ },
628
+ wantExposed: []wantExposedEntry{
629
+ {cfg: cfg, status: dyncfg.StatusAccepted},
630
+ },
631
+ wantRunning: nil,
632
+ wantDyncfg: `
633
+
634
+FUNCTION_RESULT_BEGIN 1-add 202 application/json
635
+{"status":202,"message":""}
636
+FUNCTION_RESULT_END
637
+
638
+CONFIG test:collector:success:test create accepted job /collectors/test/Jobs dyncfg 'type=dyncfg' 'schema get enable disable update restart test userconfig remove' 0x0000 0x0000
639
+
640
+FUNCTION_RESULT_BEGIN 2-get 200 application/json
641
+{"option_str":"${env:DYNCFG_GET_SECRET}","option_int":0}
642
+FUNCTION_RESULT_END
643
`,
644
}
645
},
src/go/plugin/agent/jobmgr/modulefuncs.go
deleted
-323
@@ -1,323 +0,0 @@
1
-// SPDX-License-Identifier: GPL-3.0-or-later
2
-
3
-package jobmgr
4
-
5
-import (
6
- "sort"
7
- "sync"
8
-
9
- "github.com/netdata/netdata/go/plugins/pkg/funcapi"
10
- "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
11
-)
12
-
13
-// moduleFuncRegistry tracks module-level functions and their running jobs
14
-type moduleFuncRegistry struct {
15
- mu sync.RWMutex
16
-
17
- // moduleName → moduleFunc
18
- // e.g., "postgres" → {methods: [...], jobs: {"master-db": job1, "replica-db": job2}}
19
- modules map[string]*moduleFunc
20
-}
21
-
22
-type moduleFunc struct {
23
- creator collectorapi.Creator // The module creator (has Methods())
24
- methods []funcapi.MethodConfig // Static methods from creator (ordered)
25
- methodsByID map[string]funcapi.MethodConfig
26
- jobs map[string]*jobEntry // jobName → job entry with generation
27
- lastGeneration map[string]uint64 // jobName → last known generation (persists across removals)
28
- jobMethods map[string][]funcapi.MethodConfig // jobName → methods registered for that job
29
-}
30
-
31
-// jobEntry wraps a job with a generation number for race detection
32
-type jobEntry struct {
33
- job collectorapi.RuntimeJob
34
- generation uint64 // Incremented each time this job name is replaced
35
-}
36
-
37
-func newModuleFuncRegistry() *moduleFuncRegistry {
38
- return &moduleFuncRegistry{
39
- modules: make(map[string]*moduleFunc),
40
- }
41
-}
42
-
43
-// registerModule is called at startup for each module that implements FunctionProvider
44
-func (r *moduleFuncRegistry) registerModule(name string, creator collectorapi.Creator) {
45
- r.mu.Lock()
46
- defer r.mu.Unlock()
47
-
48
- var methods []funcapi.MethodConfig
49
- if creator.Methods != nil {
50
- methods = creator.Methods()
51
- }
52
-
53
- r.modules[name] = &moduleFunc{
54
- creator: creator,
55
- methods: methods,
56
- methodsByID: indexMethods(methods),
57
- jobs: make(map[string]*jobEntry),
58
- lastGeneration: make(map[string]uint64),
59
- jobMethods: make(map[string][]funcapi.MethodConfig),
60
- }
61
-}
62
-
63
-func indexMethods(methods []funcapi.MethodConfig) map[string]funcapi.MethodConfig {
64
- if len(methods) == 0 {
65
- return nil
66
- }
67
- idx := make(map[string]funcapi.MethodConfig, len(methods))
68
- for _, m := range methods {
69
- if m.ID == "" {
70
- continue
71
- }
72
- idx[m.ID] = m
73
- }
74
- return idx
75
-}
76
-
77
-// addJob is called when jobs start.
78
-// NOTE: Job names MUST be unique per module. If a job with the same name
79
-// already exists, it is replaced (this handles config reload scenarios)
80
-func (r *moduleFuncRegistry) addJob(moduleName, jobName string, job collectorapi.RuntimeJob) {
81
- r.mu.Lock()
82
- defer r.mu.Unlock()
83
-
84
- mf, ok := r.modules[moduleName]
85
- if !ok {
86
- return // CollectorV1 not registered (doesn't implement FunctionProvider)
87
- }
88
-
89
- // Replace any existing job with the same name
90
- // Increment generation to invalidate any in-flight requests to old job
91
- // Use lastGeneration to ensure generation monotonically increases even across removals
92
- lastGen := mf.lastGeneration[jobName]
93
- newGen := lastGen + 1
94
- mf.jobs[jobName] = &jobEntry{
95
- job: job,
96
- generation: newGen,
97
- }
98
- mf.lastGeneration[jobName] = newGen
99
-}
100
-
101
-// removeJob is called when jobs stop
102
-func (r *moduleFuncRegistry) removeJob(moduleName, jobName string) {
103
- r.mu.Lock()
104
- defer r.mu.Unlock()
105
-
106
- if mf, ok := r.modules[moduleName]; ok {
107
- delete(mf.jobs, jobName)
108
- }
109
-}
110
-
111
-// getJobWithGeneration returns the job and its generation for race detection
112
-func (r *moduleFuncRegistry) getJobWithGeneration(moduleName, jobName string) (collectorapi.RuntimeJob, uint64) {
113
- r.mu.RLock()
114
- defer r.mu.RUnlock()
115
-
116
- mf, ok := r.modules[moduleName]
117
- if !ok {
118
- return nil, 0
119
- }
120
- entry, ok := mf.jobs[jobName]
121
- if !ok {
122
- return nil, 0
123
- }
124
- return entry.job, entry.generation
125
-}
126
-
127
-// verifyJobGeneration checks if the job still has the expected generation
128
-// Returns false if the job was replaced OR stopped during request processing
129
-func (r *moduleFuncRegistry) verifyJobGeneration(moduleName, jobName string, expectedGen uint64) bool {
130
- r.mu.RLock()
131
- defer r.mu.RUnlock()
132
-
133
- mf, ok := r.modules[moduleName]
134
- if !ok {
135
- return false
136
- }
137
- entry, ok := mf.jobs[jobName]
138
- if !ok {
139
- return false // Job was removed
140
- }
141
-
142
- // CRITICAL: Also check if job is still running
143
- // The job could be stopped (but not yet removed) during our request
144
- // This catches the race where generation matches but job.Stop() was called
145
- if !entry.job.IsRunning() {
146
- return false // Job was stopped
147
- }
148
-
149
- return entry.generation == expectedGen
150
-}
151
-
152
-// getMethods returns the method configurations for a module
153
-func (r *moduleFuncRegistry) getMethods(moduleName string) []funcapi.MethodConfig {
154
- r.mu.RLock()
155
- defer r.mu.RUnlock()
156
-
157
- mf, ok := r.modules[moduleName]
158
- if !ok {
159
- return nil
160
- }
161
- return mf.methods
162
-}
163
-
164
-// getMethod returns a method config by ID for a module.
165
-func (r *moduleFuncRegistry) getMethod(moduleName, methodID string) (*funcapi.MethodConfig, bool) {
166
- r.mu.RLock()
167
- defer r.mu.RUnlock()
168
-
169
- mf, ok := r.modules[moduleName]
170
- if !ok || mf.methodsByID == nil {
171
- return nil, false
172
- }
173
- cfg, ok := mf.methodsByID[methodID]
174
- if !ok {
175
- return nil, false
176
- }
177
- return &cfg, true
178
-}
179
-
180
-// getJobNames returns job names in STABLE alphabetical order
181
-// This ensures consistent UI presentation across refreshes
182
-func (r *moduleFuncRegistry) getJobNames(moduleName string) []string {
183
- r.mu.RLock()
184
- defer r.mu.RUnlock()
185
-
186
- mf, ok := r.modules[moduleName]
187
- if !ok {
188
- return nil
189
- }
190
-
191
- // Extract job names and sort for stable ordering
192
- names := make([]string, 0, len(mf.jobs))
193
- for name := range mf.jobs {
194
- names = append(names, name)
195
- }
196
- sort.Strings(names) // Alphabetical order for consistent UI
197
- return names
198
-}
199
-
200
-// getJob returns the job by name for routing requests
201
-func (r *moduleFuncRegistry) getJob(moduleName, jobName string) (collectorapi.RuntimeJob, bool) {
202
- r.mu.RLock()
203
- defer r.mu.RUnlock()
204
-
205
- mf, ok := r.modules[moduleName]
206
- if !ok {
207
- return nil, false
208
- }
209
- entry, ok := mf.jobs[jobName]
210
- if !ok {
211
- return nil, false
212
- }
213
- return entry.job, true
214
-}
215
-
216
-// getCreator returns the module creator for a registered module
217
-func (r *moduleFuncRegistry) getCreator(moduleName string) (collectorapi.Creator, bool) {
218
- r.mu.RLock()
219
- defer r.mu.RUnlock()
220
-
221
- mf, ok := r.modules[moduleName]
222
- if !ok {
223
- return collectorapi.Creator{}, false
224
- }
225
- return mf.creator, true
226
-}
227
-
228
-// isModuleRegistered checks if a module is registered (implements FunctionProvider)
229
-func (r *moduleFuncRegistry) isModuleRegistered(moduleName string) bool {
230
- r.mu.RLock()
231
- defer r.mu.RUnlock()
232
-
233
- _, ok := r.modules[moduleName]
234
- return ok
235
-}
236
-
237
-// registerJobMethods stores methods registered for a specific job
238
-func (r *moduleFuncRegistry) registerJobMethods(moduleName, jobName string, methods []funcapi.MethodConfig) {
239
- r.mu.Lock()
240
- defer r.mu.Unlock()
241
-
242
- mf, ok := r.modules[moduleName]
243
- if !ok {
244
- return
245
- }
246
- mf.jobMethods[jobName] = methods
247
-}
248
-
249
-// unregisterJobMethods removes methods registered for a specific job
250
-func (r *moduleFuncRegistry) unregisterJobMethods(moduleName, jobName string) {
251
- r.mu.Lock()
252
- defer r.mu.Unlock()
253
-
254
- mf, ok := r.modules[moduleName]
255
- if !ok {
256
- return
257
- }
258
- delete(mf.jobMethods, jobName)
259
-}
260
-
261
-// getJobMethods returns methods registered for a specific job
262
-func (r *moduleFuncRegistry) getJobMethods(moduleName, jobName string) []funcapi.MethodConfig {
263
- r.mu.RLock()
264
- defer r.mu.RUnlock()
265
-
266
- mf, ok := r.modules[moduleName]
267
- if !ok {
268
- return nil
269
- }
270
- return mf.jobMethods[jobName]
271
-}
272
-
273
-// getJobMethod returns a specific method registered for a job by method ID
274
-func (r *moduleFuncRegistry) getJobMethod(moduleName, jobName, methodID string) (*funcapi.MethodConfig, bool) {
275
- r.mu.RLock()
276
- defer r.mu.RUnlock()
277
-
278
- mf, ok := r.modules[moduleName]
279
- if !ok {
280
- return nil, false
281
- }
282
- methods, ok := mf.jobMethods[jobName]
283
- if !ok {
284
- return nil, false
285
- }
286
- for i := range methods {
287
- if methods[i].ID == methodID {
288
- return &methods[i], true
289
- }
290
- }
291
- return nil, false
292
-}
293
-
294
-// findMethodCollision checks whether module:method key would collide with already-registered methods.
295
-// It checks static module methods and job methods from other jobs within the same module.
296
-func (r *moduleFuncRegistry) findMethodCollision(moduleName, jobName, methodID string) (collision string, ok bool) {
297
- r.mu.RLock()
298
- defer r.mu.RUnlock()
299
-
300
- mf, ok := r.modules[moduleName]
301
- if !ok {
302
- return "", false
303
- }
304
-
305
- if mf.methodsByID != nil {
306
- if _, exists := mf.methodsByID[methodID]; exists {
307
- return "static method", true
308
- }
309
- }
310
-
311
- for ownerJob, methods := range mf.jobMethods {
312
- if ownerJob == jobName {
313
- continue
314
- }
315
- for _, method := range methods {
316
- if method.ID == methodID {
317
- return "job method on " + ownerJob, true
318
- }
319
- }
320
- }
321
-
322
- return "", false
323
-}
src/go/plugin/agent/jobmgr/modulefuncs_test.go
deleted
-258
@@ -1,258 +0,0 @@
1
-// SPDX-License-Identifier: GPL-3.0-or-later
2
-
3
-package jobmgr
4
-
5
-import (
6
- "context"
7
- "io"
8
- "testing"
9
-
10
- "github.com/netdata/netdata/go/plugins/pkg/funcapi"
11
- "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
12
- "github.com/netdata/netdata/go/plugins/plugin/framework/jobruntime"
13
- "github.com/stretchr/testify/assert"
14
- "github.com/stretchr/testify/require"
15
-)
16
-
17
-func TestModuleFuncRegistry_RegisterModule(t *testing.T) {
18
- tests := map[string]struct {
19
- modules []string
20
- expected []string
21
- }{
22
- "single module": {
23
- modules: []string{"postgres"},
24
- expected: []string{"postgres"},
25
- },
26
- "multiple modules": {
27
- modules: []string{"postgres", "mysql", "mssql"},
28
- expected: []string{"mysql", "mssql", "postgres"}, // sorted
29
- },
30
- "duplicate registration overwrites": {
31
- modules: []string{"postgres", "postgres"},
32
- expected: []string{"postgres"},
33
- },
34
- }
35
-
36
- for name, tc := range tests {
37
- t.Run(name, func(t *testing.T) {
38
- r := newModuleFuncRegistry()
39
-
40
- for _, m := range tc.modules {
41
- r.registerModule(m, collectorapi.Creator{
42
- Methods: func() []funcapi.MethodConfig {
43
- return []funcapi.MethodConfig{{ID: "test"}}
44
- },
45
- })
46
- }
47
-
48
- assert.Equal(t, len(tc.expected), len(r.modules))
49
- for _, m := range tc.expected {
50
- assert.True(t, r.isModuleRegistered(m))
51
- }
52
- })
53
- }
54
-}
55
-
56
-func TestModuleFuncRegistry_Operations(t *testing.T) {
57
- tests := map[string]struct {
58
- run func(t *testing.T, r *moduleFuncRegistry)
59
- }{
60
- "add/remove job": {
61
- run: func(t *testing.T, r *moduleFuncRegistry) {
62
- r.registerModule("postgres", collectorapi.Creator{})
63
-
64
- job1 := newTestModuleFuncsJob("job1")
65
- job2 := newTestModuleFuncsJob("job2")
66
-
67
- r.addJob("postgres", "job1", job1)
68
- r.addJob("postgres", "job2", job2)
69
-
70
- names := r.getJobNames("postgres")
71
- assert.ElementsMatch(t, []string{"job1", "job2"}, names)
72
-
73
- got1, ok := r.getJob("postgres", "job1")
74
- assert.True(t, ok)
75
- assert.Equal(t, job1, got1)
76
-
77
- r.removeJob("postgres", "job1")
78
-
79
- names = r.getJobNames("postgres")
80
- assert.ElementsMatch(t, []string{"job2"}, names)
81
-
82
- _, ok = r.getJob("postgres", "job1")
83
- assert.False(t, ok)
84
- },
85
- },
86
- "job replacement increments generation": {
87
- run: func(t *testing.T, r *moduleFuncRegistry) {
88
- r.registerModule("postgres", collectorapi.Creator{})
89
-
90
- job1 := newTestModuleFuncsJob("master")
91
- job2 := newTestModuleFuncsJob("master")
92
-
93
- r.addJob("postgres", "master", job1)
94
- _, gen1 := r.getJobWithGeneration("postgres", "master")
95
- assert.Equal(t, uint64(1), gen1)
96
-
97
- r.addJob("postgres", "master", job2)
98
- got, gen2 := r.getJobWithGeneration("postgres", "master")
99
- assert.Equal(t, uint64(2), gen2)
100
- assert.Equal(t, job2, got)
101
- },
102
- },
103
- "generation verification fails on wrong generation and missing job": {
104
- run: func(t *testing.T, r *moduleFuncRegistry) {
105
- r.registerModule("postgres", collectorapi.Creator{})
106
-
107
- job := newTestModuleFuncsJob("master")
108
- r.addJob("postgres", "master", job)
109
- _, gen := r.getJobWithGeneration("postgres", "master")
110
-
111
- assert.False(t, r.verifyJobGeneration("postgres", "master", gen+1))
112
- r.removeJob("postgres", "master")
113
- assert.False(t, r.verifyJobGeneration("postgres", "master", gen))
114
- },
115
- },
116
- "get methods": {
117
- run: func(t *testing.T, r *moduleFuncRegistry) {
118
- expectedMethods := []funcapi.MethodConfig{
119
- {ID: "top-queries", Name: "Top Queries"},
120
- }
121
-
122
- r.registerModule("postgres", collectorapi.Creator{
123
- Methods: func() []funcapi.MethodConfig {
124
- return expectedMethods
125
- },
126
- })
127
-
128
- assert.Equal(t, expectedMethods, r.getMethods("postgres"))
129
- assert.Nil(t, r.getMethods("nonexistent"))
130
- },
131
- },
132
- "get job names sorted": {
133
- run: func(t *testing.T, r *moduleFuncRegistry) {
134
- r.registerModule("postgres", collectorapi.Creator{})
135
-
136
- r.addJob("postgres", "zebra-db", newTestModuleFuncsJob("zebra"))
137
- r.addJob("postgres", "alpha-db", newTestModuleFuncsJob("alpha"))
138
- r.addJob("postgres", "middle-db", newTestModuleFuncsJob("middle"))
139
-
140
- assert.Equal(t, []string{"alpha-db", "middle-db", "zebra-db"}, r.getJobNames("postgres"))
141
- },
142
- },
143
- "operations on unregistered module are no-op": {
144
- run: func(t *testing.T, r *moduleFuncRegistry) {
145
- r.addJob("nonexistent", "job1", newTestModuleFuncsJob("job1"))
146
- r.removeJob("nonexistent", "job1")
147
-
148
- assert.False(t, r.isModuleRegistered("nonexistent"))
149
- assert.Nil(t, r.getJobNames("nonexistent"))
150
- assert.Nil(t, r.getMethods("nonexistent"))
151
-
152
- _, ok := r.getJob("nonexistent", "job1")
153
- assert.False(t, ok)
154
- },
155
- },
156
- "get creator": {
157
- run: func(t *testing.T, r *moduleFuncRegistry) {
158
- creator := collectorapi.Creator{
159
- JobConfigSchema: "test-schema",
160
- }
161
- r.registerModule("postgres", creator)
162
-
163
- got, ok := r.getCreator("postgres")
164
- require.True(t, ok)
165
- assert.Equal(t, "test-schema", got.JobConfigSchema)
166
-
167
- _, ok = r.getCreator("nonexistent")
168
- assert.False(t, ok)
169
- },
170
- },
171
- }
172
-
173
- for name, tc := range tests {
174
- t.Run(name, func(t *testing.T) {
175
- tc.run(t, newModuleFuncRegistry())
176
- })
177
- }
178
-}
179
-
180
-// newTestModuleFuncsJob creates a minimal job for testing modulefuncs
181
-func newTestModuleFuncsJob(name string) *jobruntime.Job {
182
- return jobruntime.NewJob(jobruntime.JobConfig{
183
- PluginName: "test",
184
- Name: name,
185
- ModuleName: "test",
186
- FullName: "test_" + name,
187
- Module: &collectorapi.MockCollectorV1{},
188
- Out: io.Discard,
189
- UpdateEvery: 1,
190
- AutoDetectEvery: 0,
191
- Priority: 1000,
192
- })
193
-}
194
-
195
-// TestModuleFuncRegistry_Concurrency tests thread safety
196
-func TestModuleFuncRegistry_Concurrency(t *testing.T) {
197
- r := newModuleFuncRegistry()
198
- r.registerModule("postgres", collectorapi.Creator{
199
- Methods: func() []funcapi.MethodConfig {
200
- return []funcapi.MethodConfig{{ID: "test"}}
201
- },
202
- })
203
-
204
- done := make(chan bool)
205
-
206
- // Writer goroutine
207
- go func() {
208
- for i := 0; i < 100; i++ {
209
- job := newTestModuleFuncsJob("job")
210
- r.addJob("postgres", "job", job)
211
- r.removeJob("postgres", "job")
212
- }
213
- done <- true
214
- }()
215
-
216
- // Reader goroutine
217
- go func() {
218
- for i := 0; i < 100; i++ {
219
- _ = r.getJobNames("postgres")
220
- _ = r.getMethods("postgres")
221
- _, _ = r.getJob("postgres", "job")
222
- }
223
- done <- true
224
- }()
225
-
226
- <-done
227
- <-done
228
-}
229
-
230
-// TestModuleFuncRegistry_VerifyJobGeneration_JobStopped tests race detection when job stops
231
-func TestModuleFuncRegistry_VerifyJobGeneration_JobStopped(t *testing.T) {
232
- r := newModuleFuncRegistry()
233
- r.registerModule("postgres", collectorapi.Creator{})
234
-
235
- job := jobruntime.NewJob(jobruntime.JobConfig{
236
- PluginName: "test",
237
- Name: "master",
238
- ModuleName: "postgres",
239
- FullName: "postgres_master",
240
- Module: &collectorapi.MockCollectorV1{
241
- InitFunc: func(context.Context) error { return nil },
242
- CheckFunc: func(context.Context) error { return nil },
243
- ChartsFunc: func() *collectorapi.Charts { return &collectorapi.Charts{} },
244
- CollectFunc: func(context.Context) map[string]int64 { return nil },
245
- },
246
- Out: io.Discard,
247
- UpdateEvery: 1,
248
- AutoDetectEvery: 0,
249
- Priority: 1000,
250
- })
251
-
252
- r.addJob("postgres", "master", job)
253
- _, gen := r.getJobWithGeneration("postgres", "master")
254
-
255
- // Job exists but is not running - verification should fail
256
- // (job.IsRunning() returns false because job hasn't started)
257
- assert.False(t, r.verifyJobGeneration("postgres", "master", gen))
258
-}
src/go/plugin/agent/jobmgr/output_test.go
+1
-1
@@ -21,7 +21,7 @@ func TestNew_UsesConfiguredOutForDyncfgResponder(t *testing.T) {
21
Out: &buf,
22
})
23
24
- mgr.dyncfgApi.ConfigCreate(netdataapi.ConfigOpts{
24
+ mgr.dyncfgResponder.ConfigCreate(netdataapi.ConfigOpts{
25
ID: "test:collector:module",
26
Status: dyncfg.StatusAccepted.String(),
27
ConfigType: dyncfg.ConfigTypeTemplate.String(),
src/go/plugin/agent/jobmgr/secretsctl/cache.go
new
+131
@@ -0,0 +1,131 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretsctl
4
+
5
+import (
6
+ "fmt"
7
+ "strings"
8
+
9
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
10
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
11
+)
12
+
13
+func (c *Controller) Lookup(key string) (Entry, bool) {
14
+ entry, ok := c.lookup(key)
15
+ if !ok {
16
+ return Entry{}, false
17
+ }
18
+ return entry, true
19
+}
20
+
21
+func (c *Controller) lookup(key string) (Entry, bool) {
22
+ entry, ok := c.lookupInternal(key)
23
+ if !ok {
24
+ return Entry{}, false
25
+ }
26
+ return entryFromDyncfg(entry), true
27
+}
28
+
29
+func (c *Controller) lookupInternal(key string) (*dyncfg.Entry[secretstore.Config], bool) {
30
+ if strings.TrimSpace(key) == "" || c.exposed == nil {
31
+ return nil, false
32
+ }
33
+ return c.exposed.LookupByKey(key)
34
+}
35
+
36
+func (c *Controller) RememberDiscoveredConfig(cfg secretstore.Config) (Entry, bool, error) {
37
+ entry, changed, err := c.rememberDiscoveredConfig(cfg)
38
+ if err != nil || !changed || entry == nil {
39
+ return Entry{}, changed, err
40
+ }
41
+ return entryFromDyncfg(entry), true, nil
42
+}
43
+
44
+func (c *Controller) rememberDiscoveredConfig(cfg secretstore.Config) (*dyncfg.Entry[secretstore.Config], bool, error) {
45
+ if err := c.validateConfig(cfg); err != nil {
46
+ return nil, false, err
47
+ }
48
+
49
+ c.handler.RememberDiscoveredConfig(cfg)
50
+
51
+ entry, ok := c.lookupInternal(cfg.ExposedKey())
52
+ if !ok {
53
+ entry = c.handler.AddDiscoveredConfig(cfg, dyncfg.StatusAccepted)
54
+ c.handler.NotifyJobCreate(cfg, dyncfg.StatusAccepted)
55
+ return entry, true, nil
56
+ }
57
+
58
+ sp, ep := cfg.SourceTypePriority(), entry.Cfg.SourceTypePriority()
59
+ if ep > sp || (ep == sp && entry.Status == dyncfg.StatusRunning) {
60
+ return entry, false, nil
61
+ }
62
+
63
+ if entry.Status == dyncfg.StatusRunning || entry.Status == dyncfg.StatusFailed {
64
+ c.cb.Stop(entry.Cfg)
65
+ c.cb.TakeCommandMessage()
66
+ }
67
+
68
+ entry = c.handler.AddDiscoveredConfig(cfg, dyncfg.StatusAccepted)
69
+ c.handler.NotifyJobCreate(cfg, dyncfg.StatusAccepted)
70
+ return entry, true, nil
71
+}
72
+
73
+func (c *Controller) RemoveDiscoveredConfig(cfg secretstore.Config) (Entry, bool) {
74
+ entry, ok := c.removeDiscoveredConfig(cfg)
75
+ if !ok || entry == nil {
76
+ return Entry{}, false
77
+ }
78
+ return entryFromDyncfg(entry), true
79
+}
80
+
81
+func (c *Controller) removeDiscoveredConfig(cfg secretstore.Config) (*dyncfg.Entry[secretstore.Config], bool) {
82
+ entry, ok := c.handler.RemoveDiscoveredConfig(cfg)
83
+ if !ok {
84
+ return nil, false
85
+ }
86
+
87
+ c.cb.Stop(entry.Cfg)
88
+ c.cb.TakeCommandMessage()
89
+ c.handler.NotifyJobRemove(entry.Cfg)
90
+ return entry, true
91
+}
92
+
93
+func (c *Controller) validateConfig(cfg secretstore.Config) error {
94
+ if err := cfg.Validate(); err != nil {
95
+ return err
96
+ }
97
+ if c.service == nil {
98
+ return fmt.Errorf("secretstore service is not available")
99
+ }
100
+ return c.service.Validate(cfg)
101
+}
102
+
103
+func (c *Controller) validateStored(key string) error {
104
+ entry, ok := c.lookupInternal(key)
105
+ if !ok {
106
+ return secretstore.ErrStoreNotFound
107
+ }
108
+ if c.service == nil {
109
+ return fmt.Errorf("secretstore service is not available")
110
+ }
111
+
112
+ if _, ok := c.service.GetStatus(key); ok {
113
+ return c.service.ValidateStored(key)
114
+ }
115
+
116
+ return c.validateConfig(entry.Cfg)
117
+}
118
+
119
+func (c *Controller) affectedJobsFor(storeKey string) []secretstore.JobRef {
120
+ if c.affectedJobs == nil {
121
+ return nil
122
+ }
123
+ return c.affectedJobs(storeKey)
124
+}
125
+
126
+func (c *Controller) restartableAffectedJobsFor(storeKey string) []secretstore.JobRef {
127
+ if c.restartableAffectedJobs == nil {
128
+ return nil
129
+ }
130
+ return c.restartableAffectedJobs(storeKey)
131
+}
src/go/plugin/agent/jobmgr/secretsctl/cache_ownership_test.go
new
+181
@@ -0,0 +1,181 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretsctl
4
+
5
+import (
6
+ "bytes"
7
+ "testing"
8
+
9
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
10
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/backends"
11
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
12
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
13
+ "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
14
+ "github.com/stretchr/testify/assert"
15
+ "github.com/stretchr/testify/require"
16
+)
17
+
18
+func TestControllerRememberDiscoveredConfig_InvalidDoesNotEnterCaches(t *testing.T) {
19
+ ctl, _, _ := newVaultControllerTestSubject()
20
+
21
+ raw := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{}, "/etc/netdata/secretstores.yaml", confgroup.TypeUser)
22
+
23
+ entry, changed, err := ctl.RememberDiscoveredConfig(raw)
24
+ require.Error(t, err)
25
+ assert.False(t, changed)
26
+ assert.Equal(t, Entry{}, entry)
27
+ assert.Zero(t, ctl.seen.Count())
28
+ assert.Zero(t, ctl.exposed.Count())
29
+}
30
+
31
+func TestControllerRememberDiscoveredConfig_PreservesUnknownFields(t *testing.T) {
32
+ ctl, _, _ := newVaultControllerTestSubject()
33
+
34
+ cfg := vaultModeTokenConfig()
35
+ cfg["ui_note"] = "kept"
36
+ cfg["mode_token"].(map[string]any)["extra"] = "kept"
37
+
38
+ raw := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", cfg, "/etc/netdata/secretstores.yaml", confgroup.TypeUser)
39
+ entry, changed, err := ctl.RememberDiscoveredConfig(raw)
40
+ require.NoError(t, err)
41
+ require.True(t, changed)
42
+
43
+ assert.Equal(t, "kept", entry.Cfg["ui_note"])
44
+
45
+ modeToken := entry.Cfg["mode_token"].(map[string]any)
46
+ assert.Equal(t, "kept", modeToken["extra"])
47
+}
48
+
49
+func TestControllerSeqExec_FileDefinedConfigBecomesDyncfgOverride(t *testing.T) {
50
+ ctl, out, _ := newVaultControllerTestSubject()
51
+ key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
52
+
53
+ 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
+ ctl.seen.Add(fileCfg)
56
+ ctl.exposed.Add(&dyncfg.Entry[secretstore.Config]{
57
+ Cfg: fileCfg,
58
+ Status: dyncfg.StatusRunning,
59
+ })
60
+
61
+ updateFn := dyncfg.NewFunction(functions.Function{
62
+ UID: "ss-file-update",
63
+ ContentType: "application/json",
64
+ Payload: mustJSON(t, vaultModeTokenFileConfig()),
65
+ Args: []string{
66
+ ctl.configID(key),
67
+ string(dyncfg.CommandUpdate),
68
+ },
69
+ })
70
+ ctl.SeqExec(updateFn)
71
+
72
+ var updateResp map[string]any
73
+ mustDecodeFunctionPayload(t, out.String(), "ss-file-update", &updateResp)
74
+ assert.Equal(t, float64(200), updateResp["status"])
75
+
76
+ entry, ok := ctl.Lookup(key)
77
+ require.True(t, ok)
78
+ assert.Equal(t, dyncfg.StatusRunning, entry.Status)
79
+ assert.Equal(t, confgroup.TypeDyncfg, entry.Cfg.SourceType())
80
+ assert.Equal(t, confgroup.TypeDyncfg, entry.Cfg.Source())
81
+ assert.Equal(t, "token_file", entry.Cfg["mode"])
82
+
83
+ removeFn := dyncfg.NewFunction(functions.Function{
84
+ UID: "ss-file-remove",
85
+ Args: []string{ctl.configID(key), string(dyncfg.CommandRemove)},
86
+ })
87
+ ctl.SeqExec(removeFn)
88
+
89
+ var removeResp map[string]any
90
+ mustDecodeFunctionPayload(t, out.String(), "ss-file-remove", &removeResp)
91
+ assert.Equal(t, float64(200), removeResp["status"])
92
+
93
+ _, ok = ctl.Lookup(key)
94
+ assert.False(t, ok)
95
+
96
+ seenUser, ok := ctl.seen.LookupByUID(fileCfg.UID())
97
+ require.True(t, ok)
98
+ assert.Equal(t, fileCfg.UID(), seenUser.UID())
99
+ assert.Equal(t, 1, ctl.seen.Count())
100
+ assert.Zero(t, ctl.exposed.Count())
101
+}
102
+
103
+func TestControllerRemoveDiscoveredConfig_DoesNotRevealLowerPrioritySeenConfig(t *testing.T) {
104
+ ctl, _, _ := newVaultControllerTestSubject()
105
+ key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
106
+
107
+ userCfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", vaultModeTokenConfig(), "/etc/netdata/secretstores.yaml", confgroup.TypeUser)
108
+ entry, changed, err := ctl.RememberDiscoveredConfig(userCfg)
109
+ require.NoError(t, err)
110
+ require.True(t, changed)
111
+ assert.Equal(t, userCfg.UID(), entry.Cfg.UID())
112
+
113
+ dyncfgCfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", vaultModeTokenFileConfig(), confgroup.TypeDyncfg, confgroup.TypeDyncfg)
114
+ entry, changed, err = ctl.RememberDiscoveredConfig(dyncfgCfg)
115
+ require.NoError(t, err)
116
+ require.True(t, changed)
117
+ assert.Equal(t, dyncfgCfg.UID(), entry.Cfg.UID())
118
+
119
+ removed, ok := ctl.RemoveDiscoveredConfig(dyncfgCfg)
120
+ require.True(t, ok)
121
+ assert.Equal(t, dyncfgCfg.UID(), removed.Cfg.UID())
122
+
123
+ _, ok = ctl.Lookup(key)
124
+ assert.False(t, ok)
125
+
126
+ seenUser, ok := ctl.seen.LookupByUID(userCfg.UID())
127
+ require.True(t, ok)
128
+ assert.Equal(t, userCfg.UID(), seenUser.UID())
129
+ assert.Equal(t, 1, ctl.seen.Count())
130
+ assert.Zero(t, ctl.exposed.Count())
131
+}
132
+
133
+func TestControllerSeqExec_RemoveFailedUnpublishedStoreDoesNotRestartDependents(t *testing.T) {
134
+ ctl, out, seams := newVaultControllerTestSubject()
135
+ key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
136
+
137
+ raw := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"mode": "token"}, confgroup.TypeDyncfg, confgroup.TypeDyncfg)
138
+ ctl.seen.Add(raw)
139
+ ctl.exposed.Add(&dyncfg.Entry[secretstore.Config]{
140
+ Cfg: raw,
141
+ Status: dyncfg.StatusFailed,
142
+ })
143
+
144
+ removeFn := dyncfg.NewFunction(functions.Function{
145
+ UID: "ss-remove-no-restart",
146
+ Args: []string{ctl.configID(key), string(dyncfg.CommandRemove)},
147
+ })
148
+ ctl.SeqExec(removeFn)
149
+
150
+ var resp map[string]any
151
+ mustDecodeFunctionPayload(t, out.String(), "ss-remove-no-restart", &resp)
152
+ assert.Equal(t, float64(200), resp["status"])
153
+ assert.Equal(t, "", resp["message"])
154
+ assert.Empty(t, seams.restartCalls)
155
+}
156
+
157
+func newVaultControllerTestSubject() (*Controller, *bytes.Buffer, *controllerSeams) {
158
+ return newControllerTestSubjectWithOptions(Options{
159
+ Service: secretstore.NewService(backends.Creators()...),
160
+ })
161
+}
162
+
163
+func vaultModeTokenConfig() map[string]any {
164
+ return map[string]any{
165
+ "mode": "token",
166
+ "mode_token": map[string]any{
167
+ "token": "vault-token",
168
+ },
169
+ "addr": "https://vault.example",
170
+ }
171
+}
172
+
173
+func vaultModeTokenFileConfig() map[string]any {
174
+ return map[string]any{
175
+ "mode": "token_file",
176
+ "mode_token_file": map[string]any{
177
+ "path": "/var/lib/netdata/vault.token",
178
+ },
179
+ "addr": "https://vault.example",
180
+ }
181
+}
src/go/plugin/agent/jobmgr/secretsctl/callbacks.go
new
+223
@@ -0,0 +1,223 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretsctl
4
+
5
+import (
6
+ "errors"
7
+ "fmt"
8
+ "strings"
9
+
10
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
11
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
12
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
13
+)
14
+
15
+type secretStoreCallbacks struct {
16
+ deps secretStoreCallbackDeps
17
+ // commandMessage is written by Start/Update/Stop and consumed by
18
+ // TakeCommandMessage. Safety relies on callbacks remaining serialized by
19
+ // the jobmgr dyncfg command flow.
20
+ commandMessage string
21
+}
22
+
23
+type secretStoreCallbackDeps struct {
24
+ pluginName string
25
+ service secretstore.Service
26
+ restartDependentJobs func(string) string
27
+}
28
+
29
+type codedError struct {
30
+ err error
31
+ code int
32
+}
33
+
34
+func (e *codedError) Error() string { return e.err.Error() }
35
+func (e *codedError) Unwrap() error { return e.err }
36
+func (e *codedError) Code() int { return e.code }
37
+
38
+func newSecretStoreCallbacks(deps secretStoreCallbackDeps) *secretStoreCallbacks {
39
+ return &secretStoreCallbacks{deps: deps}
40
+}
41
+
42
+func (d secretStoreCallbackDeps) restartDependentJobsMessage(storeKey string) string {
43
+ if d.restartDependentJobs == nil {
44
+ return ""
45
+ }
46
+ return d.restartDependentJobs(storeKey)
47
+}
48
+
49
+func (d secretStoreCallbackDeps) extractSecretStoreKindFromTemplateID(id string) (secretstore.StoreKind, bool) {
50
+ rest, ok := strings.CutPrefix(id, fmt.Sprintf(dyncfgSecretStorePrefixf, d.pluginName))
51
+ if !ok || rest == "" || strings.Contains(rest, ":") {
52
+ return "", false
53
+ }
54
+ kind := secretstore.StoreKind(rest)
55
+ if d.service == nil {
56
+ return "", false
57
+ }
58
+ _, ok = d.service.DisplayName(kind)
59
+ return kind, ok
60
+}
61
+
62
+func (d secretStoreCallbackDeps) extractSecretStoreKey(id string) (string, bool) {
63
+ rest, ok := strings.CutPrefix(id, fmt.Sprintf(dyncfgSecretStorePrefixf, d.pluginName))
64
+ if !ok || rest == "" {
65
+ return "", false
66
+ }
67
+ kind, name, err := secretstore.ParseStoreKey(rest)
68
+ if err != nil {
69
+ return "", false
70
+ }
71
+ return secretstore.StoreKey(kind, name), true
72
+}
73
+
74
+func (d secretStoreCallbackDeps) secretStoreConfigFromPayload(fn dyncfg.Function, name string, kind secretstore.StoreKind) (secretstore.Config, error) {
75
+ if err := fn.ValidateHasPayload(); err != nil {
76
+ return nil, err
77
+ }
78
+
79
+ var payload secretstore.Config
80
+ if err := fn.UnmarshalPayload(&payload); err != nil {
81
+ return nil, fmt.Errorf("invalid configuration format: %w", err)
82
+ }
83
+ if payload == nil {
84
+ payload = secretstore.Config{}
85
+ }
86
+ payload.SetName(name)
87
+ payload.SetKind(kind)
88
+ payload.SetSource(confgroup.TypeDyncfg)
89
+ payload.SetSourceType(confgroup.TypeDyncfg)
90
+ return payload, nil
91
+}
92
+
93
+func (d secretStoreCallbackDeps) validateSecretStoreConfig(cfg secretstore.Config) error {
94
+ if err := cfg.Validate(); err != nil {
95
+ return err
96
+ }
97
+ if d.service == nil {
98
+ return fmt.Errorf("secretstore service is not available")
99
+ }
100
+ return d.service.Validate(cfg)
101
+}
102
+
103
+func (d secretStoreCallbackDeps) dyncfgSecretStoreID(id string) string {
104
+ return fmt.Sprintf("%s%s", fmt.Sprintf(dyncfgSecretStorePrefixf, d.pluginName), id)
105
+}
106
+
107
+func (cb *secretStoreCallbacks) ExtractKey(fn dyncfg.Function) (key, name string, ok bool) {
108
+ if fn.Command() == dyncfg.CommandAdd {
109
+ kind, kindOK := cb.deps.extractSecretStoreKindFromTemplateID(fn.ID())
110
+ name = fn.JobName()
111
+ if !kindOK || name == "" {
112
+ return "", "", false
113
+ }
114
+ return secretstore.StoreKey(kind, name), name, true
115
+ }
116
+
117
+ key, ok = cb.deps.extractSecretStoreKey(fn.ID())
118
+ if !ok {
119
+ return "", "", false
120
+ }
121
+ _, name, err := secretstore.ParseStoreKey(key)
122
+ if err != nil {
123
+ return "", "", false
124
+ }
125
+ return key, name, true
126
+}
127
+
128
+func (cb *secretStoreCallbacks) ParseAndValidate(fn dyncfg.Function, name string) (secretstore.Config, error) {
129
+ var kind secretstore.StoreKind
130
+ if fn.Command() == dyncfg.CommandAdd {
131
+ var ok bool
132
+ kind, ok = cb.deps.extractSecretStoreKindFromTemplateID(fn.ID())
133
+ if !ok {
134
+ return nil, fmt.Errorf("invalid template ID for secretstore add: %s", fn.ID())
135
+ }
136
+ } else {
137
+ key, ok := cb.deps.extractSecretStoreKey(fn.ID())
138
+ if !ok {
139
+ return nil, fmt.Errorf("invalid secretstore ID: %s", fn.ID())
140
+ }
141
+ var err error
142
+ kind, name, err = secretstore.ParseStoreKey(key)
143
+ if err != nil {
144
+ return nil, err
145
+ }
146
+ }
147
+
148
+ cfg, err := cb.deps.secretStoreConfigFromPayload(fn, name, kind)
149
+ if err != nil {
150
+ return nil, err
151
+ }
152
+ if err := cb.deps.validateSecretStoreConfig(cfg); err != nil {
153
+ return nil, err
154
+ }
155
+ return cfg, nil
156
+}
157
+
158
+func (cb *secretStoreCallbacks) Start(cfg secretstore.Config) error {
159
+ cb.commandMessage = ""
160
+ key := cfg.ExposedKey()
161
+ if cb.deps.service == nil {
162
+ return &codedError{err: fmt.Errorf("secretstore service is not available"), code: 400}
163
+ }
164
+
165
+ if _, ok := cb.deps.service.GetStatus(key); ok {
166
+ if err := cb.deps.service.Update(key, cfg); err != nil {
167
+ return &codedError{err: err, code: secretStoreErrorCode(err)}
168
+ }
169
+ } else if err := cb.deps.service.Add(cfg); err != nil {
170
+ return &codedError{err: err, code: secretStoreErrorCode(err)}
171
+ }
172
+
173
+ cb.commandMessage = cb.deps.restartDependentJobsMessage(key)
174
+ return nil
175
+}
176
+
177
+func (cb *secretStoreCallbacks) Update(oldCfg, newCfg secretstore.Config) error {
178
+ cb.commandMessage = ""
179
+ key := oldCfg.ExposedKey()
180
+ if cb.deps.service == nil {
181
+ return &codedError{err: fmt.Errorf("secretstore service is not available"), code: 400}
182
+ }
183
+
184
+ if _, ok := cb.deps.service.GetStatus(key); ok {
185
+ if err := cb.deps.service.Update(key, newCfg); err != nil {
186
+ return &codedError{err: err, code: secretStoreErrorCode(err)}
187
+ }
188
+ } else if err := cb.deps.service.Add(newCfg); err != nil {
189
+ return &codedError{err: err, code: secretStoreErrorCode(err)}
190
+ }
191
+
192
+ cb.commandMessage = cb.deps.restartDependentJobsMessage(key)
193
+ return nil
194
+}
195
+
196
+func (cb *secretStoreCallbacks) Stop(cfg secretstore.Config) {
197
+ cb.commandMessage = ""
198
+ key := cfg.ExposedKey()
199
+ if cb.deps.service == nil {
200
+ return
201
+ }
202
+
203
+ if err := cb.deps.service.Remove(key); err != nil {
204
+ if errors.Is(err, secretstore.ErrStoreNotFound) {
205
+ return
206
+ }
207
+ return
208
+ }
209
+ cb.commandMessage = cb.deps.restartDependentJobsMessage(key)
210
+}
211
+
212
+func (*secretStoreCallbacks) OnStatusChange(*dyncfg.Entry[secretstore.Config], dyncfg.Status, dyncfg.Function) {
213
+}
214
+
215
+func (cb *secretStoreCallbacks) TakeCommandMessage() string {
216
+ msg := strings.TrimSpace(cb.commandMessage)
217
+ cb.commandMessage = ""
218
+ return msg
219
+}
220
+
221
+func (cb *secretStoreCallbacks) ConfigID(cfg secretstore.Config) string {
222
+ return cb.deps.dyncfgSecretStoreID(cfg.ExposedKey())
223
+}
src/go/plugin/agent/jobmgr/secretsctl/callbacks_test.go
new
+219
@@ -0,0 +1,219 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretsctl
4
+
5
+import (
6
+ "context"
7
+ "encoding/json"
8
+ "fmt"
9
+ "testing"
10
+
11
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
12
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
13
+ "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
14
+ "github.com/stretchr/testify/assert"
15
+ "github.com/stretchr/testify/require"
16
+)
17
+
18
+const testPluginName = "go.d"
19
+
20
+func TestSecretStoreCallbacks(t *testing.T) {
21
+ tests := map[string]struct {
22
+ run func(t *testing.T, cb *secretStoreCallbacks, svc secretstore.Service, restart *secretStoreCallbackRestartRecorder)
23
+ }{
24
+ "isolation without manager": {
25
+ run: func(t *testing.T, cb *secretStoreCallbacks, svc secretstore.Service, restart *secretStoreCallbackRestartRecorder) {
26
+ addFn := newSecretStoreCallbackFunction(t, "ss-add", testSecretStoreTemplateID(secretstore.KindVault), dyncfg.CommandAdd, "vault_prod", map[string]any{"value": "one"})
27
+ key, name, ok := cb.ExtractKey(addFn)
28
+ require.True(t, ok)
29
+ assert.Equal(t, secretstore.StoreKey(secretstore.KindVault, "vault_prod"), key)
30
+ assert.Equal(t, "vault_prod", name)
31
+
32
+ cfg, err := cb.ParseAndValidate(addFn, name)
33
+ require.NoError(t, err)
34
+ assert.Empty(t, restart.calls)
35
+ assert.Equal(t, "", cb.TakeCommandMessage())
36
+
37
+ require.NoError(t, cb.Start(cfg))
38
+ assert.Equal(t, []string{key}, restart.calls)
39
+ assert.Equal(t, "restart:"+key, cb.TakeCommandMessage())
40
+ assert.Equal(t, testSecretStoreConfigID(key), cb.ConfigID(cfg))
41
+
42
+ status, ok := svc.GetStatus(key)
43
+ require.True(t, ok)
44
+ assert.Equal(t, secretstore.KindVault, status.Kind)
45
+ assert.Equal(t, "vault_prod", status.Name)
46
+ assert.Equal(t, "one", resolveTestStoreValue(t, svc, key))
47
+
48
+ updateFn := newSecretStoreCallbackFunction(t, "ss-update", testSecretStoreConfigID(key), dyncfg.CommandUpdate, "", map[string]any{"value": "two"})
49
+ updateKey, updateName, ok := cb.ExtractKey(updateFn)
50
+ require.True(t, ok)
51
+ assert.Equal(t, key, updateKey)
52
+ assert.Equal(t, name, updateName)
53
+
54
+ updatedCfg, err := cb.ParseAndValidate(updateFn, "")
55
+ require.NoError(t, err)
56
+ assert.Len(t, restart.calls, 1)
57
+
58
+ require.NoError(t, cb.Update(cfg, updatedCfg))
59
+ assert.Equal(t, []string{key, key}, restart.calls)
60
+ assert.Equal(t, "restart:"+key, cb.TakeCommandMessage())
61
+ assert.Equal(t, "two", resolveTestStoreValue(t, svc, key))
62
+
63
+ cb.Stop(updatedCfg)
64
+ assert.Equal(t, []string{key, key, key}, restart.calls)
65
+ assert.Equal(t, "restart:"+key, cb.TakeCommandMessage())
66
+ _, ok = svc.GetStatus(key)
67
+ assert.False(t, ok)
68
+ },
69
+ },
70
+ "restart seam called only on mutation": {
71
+ run: func(t *testing.T, cb *secretStoreCallbacks, _ secretstore.Service, restart *secretStoreCallbackRestartRecorder) {
72
+ addFn := newSecretStoreCallbackFunction(t, "ss-mutate-add", testSecretStoreTemplateID(secretstore.KindVault), dyncfg.CommandAdd, "vault_prod", map[string]any{"value": "one"})
73
+ key, name, ok := cb.ExtractKey(addFn)
74
+ require.True(t, ok)
75
+
76
+ cfg, err := cb.ParseAndValidate(addFn, name)
77
+ require.NoError(t, err)
78
+ assert.Empty(t, restart.calls)
79
+
80
+ assert.Equal(t, testSecretStoreConfigID(key), cb.ConfigID(cfg))
81
+ assert.Equal(t, "", cb.TakeCommandMessage())
82
+ cb.OnStatusChange(nil, dyncfg.StatusAccepted, addFn)
83
+ assert.Empty(t, restart.calls)
84
+
85
+ require.NoError(t, cb.Start(cfg))
86
+ assert.Equal(t, []string{key}, restart.calls)
87
+ assert.Equal(t, "restart:"+key, cb.TakeCommandMessage())
88
+
89
+ updateFn := newSecretStoreCallbackFunction(t, "ss-mutate-update", testSecretStoreConfigID(key), dyncfg.CommandUpdate, "", map[string]any{"value": "two"})
90
+ updatedCfg, err := cb.ParseAndValidate(updateFn, "")
91
+ require.NoError(t, err)
92
+ assert.Len(t, restart.calls, 1)
93
+
94
+ require.NoError(t, cb.Update(cfg, updatedCfg))
95
+ assert.Equal(t, []string{key, key}, restart.calls)
96
+ assert.Equal(t, "restart:"+key, cb.TakeCommandMessage())
97
+
98
+ cb.Stop(updatedCfg)
99
+ assert.Equal(t, []string{key, key, key}, restart.calls)
100
+ assert.Equal(t, "restart:"+key, cb.TakeCommandMessage())
101
+ },
102
+ },
103
+ }
104
+
105
+ for name, tc := range tests {
106
+ t.Run(name, func(t *testing.T) {
107
+ cb, svc, restart := newSecretStoreCallbacksTestSubject()
108
+ tc.run(t, cb, svc, restart)
109
+ })
110
+ }
111
+}
112
+
113
+type secretStoreCallbackRestartRecorder struct {
114
+ calls []string
115
+}
116
+
117
+func newSecretStoreCallbacksTestSubject() (*secretStoreCallbacks, secretstore.Service, *secretStoreCallbackRestartRecorder) {
118
+ svc := newTestSecretStoreService()
119
+ restart := &secretStoreCallbackRestartRecorder{}
120
+ cb := newSecretStoreCallbacks(secretStoreCallbackDeps{
121
+ pluginName: testPluginName,
122
+ service: svc,
123
+ restartDependentJobs: func(storeKey string) string {
124
+ restart.calls = append(restart.calls, storeKey)
125
+ return "restart:" + storeKey
126
+ },
127
+ })
128
+ return cb, svc, restart
129
+}
130
+
131
+func newSecretStoreCallbackFunction(t *testing.T, uid, id string, cmd dyncfg.Command, jobName string, payload any) dyncfg.Function {
132
+ t.Helper()
133
+
134
+ args := []string{id, string(cmd)}
135
+ if jobName != "" {
136
+ args = append(args, jobName)
137
+ }
138
+
139
+ fn := functions.Function{
140
+ UID: uid,
141
+ Args: args,
142
+ }
143
+ if payload != nil {
144
+ fn.ContentType = "application/json"
145
+ fn.Payload = mustJSON(t, payload)
146
+ }
147
+ return dyncfg.NewFunction(fn)
148
+}
149
+
150
+func testSecretStoreTemplateID(kind secretstore.StoreKind) string {
151
+ return fmt.Sprintf("%s%s", fmt.Sprintf(dyncfgSecretStorePrefixf, testPluginName), kind)
152
+}
153
+
154
+func testSecretStoreConfigID(key string) string {
155
+ return fmt.Sprintf("%s%s", fmt.Sprintf(dyncfgSecretStorePrefixf, testPluginName), key)
156
+}
157
+
158
+func resolveTestStoreValue(t *testing.T, svc secretstore.Service, key string) string {
159
+ t.Helper()
160
+
161
+ kind, name, err := secretstore.ParseStoreKey(key)
162
+ require.NoError(t, err)
163
+
164
+ ref := fmt.Sprintf("%s:%s:value", kind, name)
165
+ original := fmt.Sprintf("${store:%s}", ref)
166
+ value, err := svc.Resolve(context.Background(), svc.Capture(), ref, original)
167
+ require.NoError(t, err)
168
+ return value
169
+}
170
+
171
+type testStoreConfig struct {
172
+ Value string `yaml:"value" json:"value"`
173
+}
174
+
175
+type testStore struct {
176
+ testStoreConfig `yaml:",inline" json:""`
177
+}
178
+
179
+func (s *testStore) Configuration() any { return &s.testStoreConfig }
180
+
181
+func (s *testStore) Init(context.Context) error {
182
+ if s.testStoreConfig.Value == "" {
183
+ return fmt.Errorf("value is required")
184
+ }
185
+ return nil
186
+}
187
+
188
+func (s *testStore) Publish() secretstore.PublishedStore {
189
+ return &testPublishedStore{value: s.testStoreConfig.Value}
190
+}
191
+
192
+type testPublishedStore struct {
193
+ value string
194
+}
195
+
196
+func (s *testPublishedStore) Resolve(_ context.Context, req secretstore.ResolveRequest) (string, error) {
197
+ if req.Operand != "value" {
198
+ return "", fmt.Errorf("unexpected operand %q", req.Operand)
199
+ }
200
+ return s.value, nil
201
+}
202
+
203
+func newTestSecretStoreService() secretstore.Service {
204
+ return secretstore.NewService(secretstore.Creator{
205
+ Kind: secretstore.KindVault,
206
+ DisplayName: "Vault",
207
+ Schema: `{"jsonSchema":{"type":"object","properties":{"value":{"type":"string"}}},"uiSchema":[]}`,
208
+ Create: func() secretstore.Store {
209
+ return &testStore{}
210
+ },
211
+ })
212
+}
213
+
214
+func mustJSON(t *testing.T, v any) []byte {
215
+ t.Helper()
216
+ bs, err := json.Marshal(v)
217
+ require.NoError(t, err)
218
+ return bs
219
+}
src/go/plugin/agent/jobmgr/secretsctl/controller.go
new
+164
@@ -0,0 +1,164 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretsctl
4
+
5
+import (
6
+ "fmt"
7
+
8
+ "github.com/netdata/netdata/go/plugins/logger"
9
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
10
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
11
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
12
+)
13
+
14
+const (
15
+ dyncfgSecretStorePrefixf = "%s:secretstore:"
16
+ dyncfgSecretStorePath = "/collectors/%s/SecretStores"
17
+)
18
+
19
+type Options struct {
20
+ Logger *logger.Logger
21
+ API *dyncfg.Responder
22
+ Seen *dyncfg.SeenCache[secretstore.Config]
23
+ Exposed *dyncfg.ExposedCache[secretstore.Config]
24
+ Service secretstore.Service
25
+ Plugin string
26
+ Initial []secretstore.Config
27
+
28
+ AffectedJobs func(string) []secretstore.JobRef
29
+ RestartableAffectedJobs func(string) []secretstore.JobRef
30
+ RestartDependentJobs func(string) string
31
+}
32
+
33
+type Entry struct {
34
+ Cfg secretstore.Config
35
+ Status dyncfg.Status
36
+}
37
+
38
+type Controller struct {
39
+ *logger.Logger
40
+
41
+ api *dyncfg.Responder
42
+ seen *dyncfg.SeenCache[secretstore.Config]
43
+ exposed *dyncfg.ExposedCache[secretstore.Config]
44
+ service secretstore.Service
45
+ pluginName string
46
+ initial []secretstore.Config
47
+
48
+ affectedJobs func(string) []secretstore.JobRef
49
+ restartableAffectedJobs func(string) []secretstore.JobRef
50
+ restartDependentJobs func(string) string
51
+
52
+ handler *dyncfg.Handler[secretstore.Config]
53
+ cb *secretStoreCallbacks
54
+}
55
+
56
+func New(opts Options) *Controller {
57
+ seen := opts.Seen
58
+ if seen == nil {
59
+ seen = dyncfg.NewSeenCache[secretstore.Config]()
60
+ }
61
+ exposed := opts.Exposed
62
+ if exposed == nil {
63
+ exposed = dyncfg.NewExposedCache[secretstore.Config]()
64
+ }
65
+
66
+ c := &Controller{
67
+ Logger: opts.Logger,
68
+ api: opts.API,
69
+ seen: seen,
70
+ exposed: exposed,
71
+ service: opts.Service,
72
+ pluginName: opts.Plugin,
73
+ initial: append([]secretstore.Config(nil), opts.Initial...),
74
+ affectedJobs: opts.AffectedJobs,
75
+ restartableAffectedJobs: opts.RestartableAffectedJobs,
76
+ restartDependentJobs: opts.RestartDependentJobs,
77
+ }
78
+ c.cb = newSecretStoreCallbacks(secretStoreCallbackDeps{
79
+ pluginName: c.pluginName,
80
+ service: c.service,
81
+ restartDependentJobs: c.restartDependentJobs,
82
+ })
83
+ c.handler = dyncfg.NewHandler(dyncfg.HandlerOpts[secretstore.Config]{
84
+ Logger: c.Logger,
85
+ API: c.api,
86
+ Seen: c.seen,
87
+ Exposed: c.exposed,
88
+ Callbacks: c.cb,
89
+ Path: fmt.Sprintf(dyncfgSecretStorePath, c.pluginName),
90
+ JobCommands: []dyncfg.Command{
91
+ dyncfg.CommandSchema,
92
+ dyncfg.CommandGet,
93
+ dyncfg.CommandUpdate,
94
+ dyncfg.CommandTest,
95
+ },
96
+ })
97
+ return c
98
+}
99
+
100
+func (c *Controller) Prefix() string {
101
+ return fmt.Sprintf(dyncfgSecretStorePrefixf, c.pluginName)
102
+}
103
+
104
+func (c *Controller) CreateTemplates() {
105
+ if c.service == nil || c.api == nil {
106
+ return
107
+ }
108
+ for _, kind := range c.service.Kinds() {
109
+ c.api.ConfigCreate(netdataapi.ConfigOpts{
110
+ ID: c.templateID(kind),
111
+ Status: dyncfg.StatusAccepted.String(),
112
+ ConfigType: dyncfg.ConfigTypeTemplate.String(),
113
+ Path: fmt.Sprintf(dyncfgSecretStorePath, c.pluginName),
114
+ SourceType: "internal",
115
+ Source: "internal",
116
+ SupportedCommands: dyncfgSecretStoreTemplateCmds(),
117
+ })
118
+ }
119
+}
120
+
121
+func (c *Controller) PublishExisting() {
122
+ if len(c.initial) != 0 {
123
+ for _, cfg := range c.initial {
124
+ c.publishInitialConfig(cfg)
125
+ }
126
+ c.initial = nil
127
+ return
128
+ }
129
+
130
+ c.exposed.ForEach(func(_ string, entry *dyncfg.Entry[secretstore.Config]) bool {
131
+ c.handler.NotifyJobCreate(entry.Cfg, entry.Status)
132
+ return true
133
+ })
134
+}
135
+
136
+func (c *Controller) SetAPI(api *dyncfg.Responder) {
137
+ if api == nil {
138
+ // Nil means "keep the current responder" rather than clearing output wiring.
139
+ return
140
+ }
141
+ c.api = api
142
+ if c.handler != nil {
143
+ c.handler.SetAPI(api)
144
+ }
145
+}
146
+
147
+func (c *Controller) Service() secretstore.Service {
148
+ return c.service
149
+}
150
+
151
+func (c *Controller) configID(id string) string {
152
+ return fmt.Sprintf("%s%s", c.Prefix(), id)
153
+}
154
+
155
+func (c *Controller) templateID(kind secretstore.StoreKind) string {
156
+ return fmt.Sprintf("%s%s", c.Prefix(), kind)
157
+}
158
+
159
+func entryFromDyncfg(entry *dyncfg.Entry[secretstore.Config]) Entry {
160
+ if entry == nil {
161
+ return Entry{}
162
+ }
163
+ return Entry{Cfg: entry.Cfg, Status: entry.Status}
164
+}
src/go/plugin/agent/jobmgr/secretsctl/controller_test.go
new
+366
@@ -0,0 +1,366 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretsctl
4
+
5
+import (
6
+ "bytes"
7
+ "encoding/json"
8
+ "regexp"
9
+ "testing"
10
+
11
+ "github.com/netdata/netdata/go/plugins/logger"
12
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
13
+ "github.com/netdata/netdata/go/plugins/pkg/safewriter"
14
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
15
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
16
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
17
+ "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
18
+ "github.com/stretchr/testify/assert"
19
+ "github.com/stretchr/testify/require"
20
+)
21
+
22
+func TestControllerSeqExec(t *testing.T) {
23
+ tests := map[string]struct {
24
+ run func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams)
25
+ }{
26
+ "schema dispatch": {
27
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
28
+ fn := dyncfg.NewFunction(functions.Function{
29
+ UID: "ss-schema",
30
+ Args: []string{ctl.templateID(secretstore.KindVault), string(dyncfg.CommandSchema)},
31
+ })
32
+ ctl.SeqExec(fn)
33
+
34
+ var payload any
35
+ mustDecodeFunctionPayload(t, out.String(), "ss-schema", &payload)
36
+ assert.NotNil(t, payload)
37
+ assert.Empty(t, seams.affectedJobsCalls)
38
+ assert.Empty(t, seams.restartCalls)
39
+ },
40
+ },
41
+ "test preview uses affected jobs seam": {
42
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
43
+ addFn := dyncfg.NewFunction(functions.Function{
44
+ UID: "ss-add",
45
+ ContentType: "application/json",
46
+ Payload: mustJSON(t, map[string]any{"value": "one"}),
47
+ Args: []string{ctl.templateID(secretstore.KindVault), string(dyncfg.CommandAdd), "vault_prod"},
48
+ })
49
+ ctl.SeqExec(addFn)
50
+
51
+ key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
52
+ seams.affectedJobs[key] = []secretstore.JobRef{{ID: "mysql:prod", Display: "mysql:prod"}}
53
+ seams.restartableJobs[key] = []secretstore.JobRef{{ID: "mysql:prod", Display: "mysql:prod"}}
54
+
55
+ testFn := dyncfg.NewFunction(functions.Function{
56
+ UID: "ss-test",
57
+ Args: []string{ctl.configID(key), string(dyncfg.CommandTest)},
58
+ })
59
+ ctl.SeqExec(testFn)
60
+
61
+ var payload map[string]any
62
+ mustDecodeFunctionPayload(t, out.String(), "ss-test", &payload)
63
+ assert.Equal(t, float64(202), payload["status"])
64
+ assert.Contains(t, payload["message"], "This secretstore is used by jobs: mysql:prod.")
65
+ assert.Contains(t, payload["message"], "Running or failed jobs that would be restarted automatically by a change: mysql:prod.")
66
+ assert.Equal(t, []string{key}, seams.affectedJobsCalls)
67
+ assert.Equal(t, []string{key}, seams.restartableJobsCalls)
68
+ },
69
+ },
70
+ "remove blocks when dependent jobs exist": {
71
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
72
+ addFn := dyncfg.NewFunction(functions.Function{
73
+ UID: "ss-add-remove-blocked",
74
+ ContentType: "application/json",
75
+ Payload: mustJSON(t, map[string]any{"value": "one"}),
76
+ Args: []string{ctl.templateID(secretstore.KindVault), string(dyncfg.CommandAdd), "vault_prod"},
77
+ })
78
+ ctl.SeqExec(addFn)
79
+
80
+ key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
81
+ seams.affectedJobs[key] = []secretstore.JobRef{{ID: "mysql:prod", Display: "mysql:prod"}, {ID: "nginx:prod", Display: "nginx:prod"}}
82
+
83
+ removeFn := dyncfg.NewFunction(functions.Function{
84
+ UID: "ss-remove-blocked",
85
+ Args: []string{ctl.configID(key), string(dyncfg.CommandRemove)},
86
+ })
87
+ ctl.SeqExec(removeFn)
88
+
89
+ var payload map[string]any
90
+ mustDecodeFunctionPayload(t, out.String(), "ss-remove-blocked", &payload)
91
+ assert.Equal(t, float64(409), payload["status"])
92
+ assert.Equal(t, "The specified secretstore 'vault:vault_prod' is used by jobs (mysql:prod, nginx:prod).", payload["errorMessage"])
93
+ _, ok := ctl.Lookup(key)
94
+ assert.True(t, ok)
95
+ },
96
+ },
97
+ }
98
+
99
+ for name, tc := range tests {
100
+ t.Run(name, func(t *testing.T) {
101
+ ctl, out, seams := newControllerTestSubject()
102
+ tc.run(t, ctl, out, seams)
103
+ })
104
+ }
105
+}
106
+
107
+func TestControllerSetAPI_NilPreservesResponder(t *testing.T) {
108
+ tests := map[string]struct {
109
+ uid string
110
+ }{
111
+ "nil SetAPI keeps existing responder for schema responses": {
112
+ uid: "ss-schema-nil-rebind",
113
+ },
114
+ }
115
+
116
+ for name, tc := range tests {
117
+ t.Run(name, func(t *testing.T) {
118
+ ctl, out, _ := newControllerTestSubject()
119
+ ctl.SetAPI(nil)
120
+
121
+ fn := dyncfg.NewFunction(functions.Function{
122
+ UID: tc.uid,
123
+ Args: []string{ctl.templateID(secretstore.KindVault), string(dyncfg.CommandSchema)},
124
+ })
125
+ ctl.SeqExec(fn)
126
+
127
+ var payload any
128
+ mustDecodeFunctionPayload(t, out.String(), tc.uid, &payload)
129
+ assert.NotNil(t, payload)
130
+ })
131
+ }
132
+}
133
+
134
+func TestControllerSeqExec_TestStoredWithoutService_Returns400(t *testing.T) {
135
+ tests := map[string]struct {
136
+ storeKey string
137
+ }{
138
+ "stored validation without service returns controlled 400": {
139
+ storeKey: secretstore.StoreKey(secretstore.KindVault, "vault_prod"),
140
+ },
141
+ }
142
+
143
+ for name, tc := range tests {
144
+ t.Run(name, func(t *testing.T) {
145
+ var out bytes.Buffer
146
+ ctl := New(Options{
147
+ Logger: logger.New(),
148
+ API: dyncfg.NewResponder(netdataapi.New(safewriter.New(&out))),
149
+ Plugin: testPluginName,
150
+ })
151
+
152
+ cfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "one"}, confgroup.TypeDyncfg, confgroup.TypeDyncfg)
153
+ ctl.exposed.Add(&dyncfg.Entry[secretstore.Config]{
154
+ Cfg: cfg,
155
+ Status: dyncfg.StatusRunning,
156
+ })
157
+
158
+ fn := dyncfg.NewFunction(functions.Function{
159
+ UID: "ss-test-nil-service",
160
+ Args: []string{ctl.configID(tc.storeKey), string(dyncfg.CommandTest)},
161
+ })
162
+
163
+ ctl.SeqExec(fn)
164
+
165
+ var payload map[string]any
166
+ mustDecodeFunctionPayload(t, out.String(), "ss-test-nil-service", &payload)
167
+ assert.Equal(t, float64(400), payload["status"])
168
+ assert.Contains(t, payload["errorMessage"], "secretstore service is not available")
169
+ })
170
+ }
171
+}
172
+
173
+func TestRememberDiscoveredConfig_DrainsRestartFailureMessageFromNonHandlerStop(t *testing.T) {
174
+ ctl, _, seams := newControllerTestSubject()
175
+ 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
+ ctl.seen.Add(existing)
178
+ ctl.exposed.Add(&dyncfg.Entry[secretstore.Config]{
179
+ Cfg: existing,
180
+ Status: dyncfg.StatusRunning,
181
+ })
182
+
183
+ key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
184
+ seams.restartMessages[key] = "restart failed"
185
+
186
+ replacement := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "two"}, confgroup.TypeDyncfg, confgroup.TypeDyncfg)
187
+ entry, changed, err := ctl.RememberDiscoveredConfig(replacement)
188
+ require.NoError(t, err)
189
+ require.True(t, changed)
190
+ assert.Equal(t, dyncfg.StatusAccepted, entry.Status)
191
+ assert.Equal(t, []string{key}, seams.restartCalls)
192
+ _, ok := ctl.Service().GetStatus(key)
193
+ assert.False(t, ok)
194
+ assert.Equal(t, "", ctl.cb.TakeCommandMessage())
195
+}
196
+
197
+func TestControllerPublishExisting(t *testing.T) {
198
+ t.Run("initial valid config publishes running without enable disable commands", func(t *testing.T) {
199
+ cfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "one"}, "file=/etc/netdata/go.d/ss/vault.conf", confgroup.TypeUser)
200
+
201
+ var out bytes.Buffer
202
+ ctl := New(Options{
203
+ Logger: logger.New(),
204
+ API: dyncfg.NewResponder(netdataapi.New(safewriter.New(&out))),
205
+ Plugin: testPluginName,
206
+ Service: newTestSecretStoreService(),
207
+ Initial: []secretstore.Config{cfg},
208
+ })
209
+
210
+ ctl.PublishExisting()
211
+
212
+ entry, ok := ctl.Lookup(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
213
+ require.True(t, ok)
214
+ assert.Equal(t, dyncfg.StatusRunning, entry.Status)
215
+ _, ok = ctl.Service().GetStatus(entry.Cfg.ExposedKey())
216
+ assert.True(t, ok)
217
+ assert.Contains(t, out.String(), "schema get update test")
218
+ assert.NotContains(t, out.String(), "enable")
219
+ assert.NotContains(t, out.String(), "disable")
220
+ })
221
+
222
+ t.Run("keyable startup failure publishes failed", func(t *testing.T) {
223
+ cfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{}, "file=/etc/netdata/go.d/ss/vault.conf", confgroup.TypeUser)
224
+
225
+ ctl, _, _ := newControllerTestSubjectWithOptions(Options{
226
+ Initial: []secretstore.Config{cfg},
227
+ })
228
+ ctl.PublishExisting()
229
+
230
+ entry, ok := ctl.Lookup(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
231
+ require.True(t, ok)
232
+ assert.Equal(t, dyncfg.StatusFailed, entry.Status)
233
+ _, ok = ctl.Service().GetStatus(entry.Cfg.ExposedKey())
234
+ assert.False(t, ok)
235
+ })
236
+
237
+ t.Run("failed higher priority startup config shadows lower priority running config", func(t *testing.T) {
238
+ stockCfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "stock"}, "file=/usr/lib/netdata/conf.d/go.d/ss/vault.conf", confgroup.TypeStock)
239
+ userCfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{}, "file=/etc/netdata/go.d/ss/vault.conf", confgroup.TypeUser)
240
+
241
+ ctl, _, _ := newControllerTestSubjectWithOptions(Options{
242
+ Initial: []secretstore.Config{stockCfg, userCfg},
243
+ })
244
+ ctl.PublishExisting()
245
+
246
+ entry, ok := ctl.Lookup(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
247
+ require.True(t, ok)
248
+ assert.Equal(t, dyncfg.StatusFailed, entry.Status)
249
+ assert.Equal(t, confgroup.TypeUser, entry.Cfg.SourceType())
250
+ assert.Equal(t, "file=/etc/netdata/go.d/ss/vault.conf", entry.Cfg.Source())
251
+ _, ok = ctl.Service().GetStatus(entry.Cfg.ExposedKey())
252
+ assert.False(t, ok)
253
+ })
254
+
255
+ t.Run("later equal priority valid config replaces earlier failed config", func(t *testing.T) {
256
+ failedCfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{}, "file=/etc/netdata/go.d/ss/01-vault.conf", confgroup.TypeUser)
257
+ validCfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "good"}, "file=/etc/netdata/go.d/ss/02-vault.conf", confgroup.TypeUser)
258
+
259
+ ctl, _, _ := newControllerTestSubjectWithOptions(Options{
260
+ Initial: []secretstore.Config{failedCfg, validCfg},
261
+ })
262
+ ctl.PublishExisting()
263
+
264
+ entry, ok := ctl.Lookup(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
265
+ require.True(t, ok)
266
+ assert.Equal(t, dyncfg.StatusRunning, entry.Status)
267
+ assert.Equal(t, "file=/etc/netdata/go.d/ss/02-vault.conf", entry.Cfg.Source())
268
+ _, ok = ctl.Service().GetStatus(entry.Cfg.ExposedKey())
269
+ assert.True(t, ok)
270
+ })
271
+}
272
+
273
+type controllerSeams struct {
274
+ affectedJobs map[string][]secretstore.JobRef
275
+ affectedJobsCalls []string
276
+ restartableJobs map[string][]secretstore.JobRef
277
+ restartableJobsCalls []string
278
+ restartMessages map[string]string
279
+ restartCalls []string
280
+}
281
+
282
+func newControllerTestSubject() (*Controller, *bytes.Buffer, *controllerSeams) {
283
+ return newControllerTestSubjectWithOptions(Options{})
284
+}
285
+
286
+func newControllerTestSubjectWithOptions(opts Options) (*Controller, *bytes.Buffer, *controllerSeams) {
287
+ var out bytes.Buffer
288
+ seams := &controllerSeams{
289
+ affectedJobs: make(map[string][]secretstore.JobRef),
290
+ restartableJobs: make(map[string][]secretstore.JobRef),
291
+ restartMessages: make(map[string]string),
292
+ }
293
+ svc := opts.Service
294
+ if svc == nil {
295
+ svc = newTestSecretStoreService()
296
+ }
297
+ log := opts.Logger
298
+ if log == nil {
299
+ log = logger.New()
300
+ }
301
+ api := opts.API
302
+ if api == nil {
303
+ api = dyncfg.NewResponder(netdataapi.New(safewriter.New(&out)))
304
+ }
305
+ plugin := opts.Plugin
306
+ if plugin == "" {
307
+ plugin = testPluginName
308
+ }
309
+ affectedJobs := opts.AffectedJobs
310
+ if affectedJobs == nil {
311
+ affectedJobs = func(storeKey string) []secretstore.JobRef {
312
+ seams.affectedJobsCalls = append(seams.affectedJobsCalls, storeKey)
313
+ return seams.affectedJobs[storeKey]
314
+ }
315
+ }
316
+ restartableJobs := opts.RestartableAffectedJobs
317
+ if restartableJobs == nil {
318
+ restartableJobs = func(storeKey string) []secretstore.JobRef {
319
+ seams.restartableJobsCalls = append(seams.restartableJobsCalls, storeKey)
320
+ return seams.restartableJobs[storeKey]
321
+ }
322
+ }
323
+ restartDependentJobs := opts.RestartDependentJobs
324
+ if restartDependentJobs == nil {
325
+ restartDependentJobs = func(storeKey string) string {
326
+ seams.restartCalls = append(seams.restartCalls, storeKey)
327
+ return seams.restartMessages[storeKey]
328
+ }
329
+ }
330
+ ctl := New(Options{
331
+ Logger: log,
332
+ API: api,
333
+ Plugin: plugin,
334
+ Service: svc,
335
+ AffectedJobs: affectedJobs,
336
+ RestartableAffectedJobs: restartableJobs,
337
+ RestartDependentJobs: restartDependentJobs,
338
+ Initial: opts.Initial,
339
+ Seen: opts.Seen,
340
+ Exposed: opts.Exposed,
341
+ })
342
+ return ctl, &out, seams
343
+}
344
+
345
+func newSecretStoreConfigWithSource(t *testing.T, kind secretstore.StoreKind, name string, cfg map[string]any, source, sourceType string) secretstore.Config {
346
+ t.Helper()
347
+ bs, err := json.Marshal(cfg)
348
+ require.NoError(t, err)
349
+ var payload map[string]any
350
+ require.NoError(t, json.Unmarshal(bs, &payload))
351
+ out := secretstore.Config(payload)
352
+ out.SetName(name)
353
+ out.SetKind(kind)
354
+ out.SetSource(source)
355
+ out.SetSourceType(sourceType)
356
+ return out
357
+}
358
+
359
+func mustDecodeFunctionPayload(t *testing.T, output, uid string, dst any) {
360
+ t.Helper()
361
+
362
+ re := regexp.MustCompile(`(?s)FUNCTION_RESULT_BEGIN ` + regexp.QuoteMeta(uid) + ` [^\n]+\n(.*?)\nFUNCTION_RESULT_END`)
363
+ match := re.FindStringSubmatch(output)
364
+ require.Len(t, match, 2, "function result for uid '%s' not found in output:\n%s", uid, output)
365
+ require.NoError(t, json.Unmarshal([]byte(match[1]), dst))
366
+}
src/go/plugin/agent/jobmgr/secretsctl/doc.go
new
+11
@@ -0,0 +1,11 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+// Package secretsctl owns the secretstore dyncfg control plane for jobmgr.
4
+//
5
+// It does not own:
6
+// - dyncfg prefix routing
7
+// - dyncfg handoff/channel ownership
8
+// - Manager.run() serialized execution
9
+// - collector-supervisor coordination such as dependent collector restarts
10
+// - dependency-index ownership or writes
11
+package secretsctl
src/go/plugin/agent/jobmgr/secretsctl/dyncfg.go
new
+368
@@ -0,0 +1,368 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretsctl
4
+
5
+import (
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "strings"
10
+
11
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
12
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
13
+ "gopkg.in/yaml.v2"
14
+)
15
+
16
+func dyncfgSecretStoreTemplateCmds() string {
17
+ return dyncfg.JoinCommands(dyncfg.CommandAdd, dyncfg.CommandSchema, dyncfg.CommandUserconfig)
18
+}
19
+
20
+func (c *Controller) SeqExec(fn dyncfg.Function) {
21
+ switch fn.Command() {
22
+ case dyncfg.CommandSchema:
23
+ c.dyncfgCmdSchema(fn)
24
+ case dyncfg.CommandGet:
25
+ c.dyncfgCmdGet(fn)
26
+ case dyncfg.CommandAdd:
27
+ c.dyncfgCmdAdd(fn)
28
+ case dyncfg.CommandUpdate:
29
+ // TODO: file/user -> dyncfg conversion currently reuses generic update ordering,
30
+ // which can tear down the old live store before the override is active.
31
+ c.handler.CmdUpdate(fn)
32
+ case dyncfg.CommandTest:
33
+ c.dyncfgCmdTest(fn)
34
+ case dyncfg.CommandUserconfig:
35
+ c.dyncfgCmdUserconfig(fn)
36
+ case dyncfg.CommandRemove:
37
+ c.dyncfgCmdRemove(fn)
38
+ default:
39
+ c.Warningf("dyncfg: function '%s' command '%s' not implemented", fn.Fn().Name, fn.Command())
40
+ c.api.SendCodef(fn, 501, "Function '%s' command '%s' is not implemented.", fn.Fn().Name, fn.Command())
41
+ }
42
+}
43
+
44
+func (c *Controller) dyncfgCmdAdd(fn dyncfg.Function) {
45
+ if err := fn.ValidateArgs(3); err != nil {
46
+ c.api.SendCodef(fn, 400, "%v", err)
47
+ return
48
+ }
49
+
50
+ key, name, ok := c.cb.ExtractKey(fn)
51
+ if !ok {
52
+ c.api.SendCodef(fn, 400, "invalid job ID format.")
53
+ return
54
+ }
55
+ if _, exists := c.lookup(key); exists {
56
+ c.api.SendCodef(fn, 409, "The specified secretstore '%s' already exists.", key)
57
+ return
58
+ }
59
+ if err := fn.ValidateHasPayload(); err != nil {
60
+ c.api.SendCodef(fn, 400, "%v", err)
61
+ return
62
+ }
63
+ if err := dyncfg.ValidateJobName(name); err != nil {
64
+ c.api.SendCodef(fn, 400, "invalid job name '%s': %v.", name, err)
65
+ return
66
+ }
67
+
68
+ kind, ok := c.dyncfgExtractSecretStoreKindFromTemplateID(fn.ID())
69
+ if !ok {
70
+ c.api.SendCodef(fn, 400, "Invalid template ID for secretstore add: %s.", fn.ID())
71
+ return
72
+ }
73
+
74
+ rawCfg, err := c.dyncfgSecretStoreConfigFromPayload(fn, name, kind)
75
+ if err != nil {
76
+ c.api.SendCodef(fn, 400, "%v", err)
77
+ return
78
+ }
79
+ if err := rawCfg.Validate(); err != nil {
80
+ c.api.SendCodef(fn, 400, "%v", err)
81
+ return
82
+ }
83
+
84
+ cfg, prepErr := c.prepareConfigCandidate(rawCfg)
85
+ c.seen.Add(cfg)
86
+ entry := &dyncfg.Entry[secretstore.Config]{Cfg: cfg, Status: dyncfg.StatusFailed}
87
+ c.exposed.Add(entry)
88
+
89
+ code := 200
90
+ msg := ""
91
+ if prepErr == nil {
92
+ if err := c.cb.Start(cfg); err == nil {
93
+ entry.Status = dyncfg.StatusRunning
94
+ msg = c.cb.TakeCommandMessage()
95
+ } else {
96
+ prepErr = err
97
+ }
98
+ }
99
+ if prepErr != nil {
100
+ code = secretStoreCommandCode(prepErr)
101
+ msg = prepErr.Error()
102
+ }
103
+
104
+ c.api.SendCodef(fn, code, "%s", msg)
105
+ c.handler.NotifyJobCreate(cfg, entry.Status)
106
+}
107
+
108
+func (c *Controller) dyncfgCmdSchema(fn dyncfg.Function) {
109
+ kind, ok := c.dyncfgExtractSecretStoreKindFromTemplateID(fn.ID())
110
+ if !ok {
111
+ storeKey, ok := c.dyncfgExtractSecretStoreKey(fn.ID())
112
+ if !ok {
113
+ c.api.SendCodef(fn, 400, "Invalid ID format for secretstore schema: %s.", fn.ID())
114
+ return
115
+ }
116
+ entry, ok := c.lookup(storeKey)
117
+ if !ok {
118
+ c.api.SendCodef(fn, 404, "The specified secretstore '%s' is not configured.", storeKey)
119
+ return
120
+ }
121
+ kind = entry.Cfg.Kind()
122
+ }
123
+
124
+ schema, ok := c.service.Schema(kind)
125
+ if !ok {
126
+ c.api.SendCodef(fn, 404, "The specified secretstore kind '%s' is not supported.", kind)
127
+ return
128
+ }
129
+
130
+ c.api.SendJSON(fn, schema)
131
+}
132
+
133
+func (c *Controller) dyncfgCmdGet(fn dyncfg.Function) {
134
+ storeKey, ok := c.dyncfgExtractSecretStoreKey(fn.ID())
135
+ if !ok {
136
+ c.api.SendCodef(fn, 400, "Invalid ID format for secretstore get: %s.", fn.ID())
137
+ return
138
+ }
139
+
140
+ entry, ok := c.lookup(storeKey)
141
+ if !ok {
142
+ c.api.SendCodef(fn, 404, "The specified secretstore '%s' is not configured.", storeKey)
143
+ return
144
+ }
145
+
146
+ cfg, err := c.dyncfgTypedConfigFromRaw(entry.Cfg)
147
+ if err != nil {
148
+ c.api.SendCodef(fn, 500, "Failed to materialize secretstore configuration: %v.", err)
149
+ return
150
+ }
151
+
152
+ bs, err := json.Marshal(cfg)
153
+ if err != nil {
154
+ c.api.SendCodef(fn, 500, "Failed to convert configuration into JSON: %v.", err)
155
+ return
156
+ }
157
+
158
+ c.api.SendJSON(fn, string(bs))
159
+}
160
+
161
+func (c *Controller) dyncfgCmdTest(fn dyncfg.Function) {
162
+ storeKey, ok := c.dyncfgExtractSecretStoreKey(fn.ID())
163
+ if !ok {
164
+ c.api.SendCodef(fn, 400, "Invalid ID format for secretstore test: %s.", fn.ID())
165
+ return
166
+ }
167
+
168
+ if !fn.HasPayload() {
169
+ if err := c.validateStored(storeKey); err != nil {
170
+ c.api.SendCodef(fn, secretStoreErrorCode(err), "%v", err)
171
+ return
172
+ }
173
+ c.dyncfgSendSecretStoreTestImpactMessage(fn, c.affectedJobsFor(storeKey), c.restartableAffectedJobsFor(storeKey), true)
174
+ return
175
+ }
176
+
177
+ entry, ok := c.lookup(storeKey)
178
+ if !ok {
179
+ c.api.SendCodef(fn, 404, "The specified secretstore '%s' is not configured.", storeKey)
180
+ return
181
+ }
182
+
183
+ cfg, err := c.dyncfgSecretStoreConfigFromPayload(fn, entry.Cfg.Name(), entry.Cfg.Kind())
184
+ if err != nil {
185
+ c.api.SendCodef(fn, 400, "%v", err)
186
+ return
187
+ }
188
+
189
+ cfg, err = c.prepareConfigCandidate(cfg)
190
+ if err != nil {
191
+ c.api.SendCodef(fn, 400, "%v", err)
192
+ return
193
+ }
194
+
195
+ if cfg.Hash() == entry.Cfg.Hash() {
196
+ c.api.SendCodef(fn, 202, "Submitted configuration does not change the active secretstore.")
197
+ return
198
+ }
199
+
200
+ c.dyncfgSendSecretStoreTestImpactMessage(fn, c.affectedJobsFor(storeKey), c.restartableAffectedJobsFor(storeKey), false)
201
+}
202
+
203
+func (c *Controller) dyncfgCmdUserconfig(fn dyncfg.Function) {
204
+ kind, ok := c.dyncfgExtractSecretStoreKindFromTemplateID(fn.ID())
205
+ if !ok {
206
+ c.api.SendCodef(fn, 400, "Invalid template ID for secretstore userconfig: %s.", fn.ID())
207
+ return
208
+ }
209
+ if err := fn.ValidateHasPayload(); err != nil {
210
+ c.api.SendCodef(fn, 400, "%v", err)
211
+ return
212
+ }
213
+
214
+ cfg, err := c.dyncfgTypedConfigFromPayload(fn, kind)
215
+ if err != nil {
216
+ c.api.SendCodef(fn, 400, "Invalid configuration format. Failed to create configuration from payload: %v.", err)
217
+ return
218
+ }
219
+
220
+ bs, err := yaml.Marshal(cfg)
221
+ if err != nil {
222
+ c.api.SendCodef(fn, 500, "Failed to convert configuration into YAML: %v.", err)
223
+ return
224
+ }
225
+
226
+ c.api.SendYAML(fn, string(bs))
227
+}
228
+
229
+func (c *Controller) dyncfgCmdRemove(fn dyncfg.Function) {
230
+ storeKey, ok := c.dyncfgExtractSecretStoreKey(fn.ID())
231
+ if !ok {
232
+ c.api.SendCodef(fn, 400, "Invalid ID format for secretstore remove: %s.", fn.ID())
233
+ return
234
+ }
235
+
236
+ if _, ok := c.lookup(storeKey); !ok {
237
+ c.api.SendCodef(fn, 404, "The specified secretstore '%s' is not configured.", storeKey)
238
+ return
239
+ }
240
+
241
+ if affected := formatAffectedJobs(c.affectedJobsFor(storeKey)); affected != "" {
242
+ c.api.SendCodef(fn, 409, "The specified secretstore '%s' is used by jobs (%s).", storeKey, affected)
243
+ return
244
+ }
245
+
246
+ c.handler.CmdRemove(fn)
247
+}
248
+
249
+func (c *Controller) dyncfgTypedConfigFromPayload(fn dyncfg.Function, kind secretstore.StoreKind) (any, error) {
250
+ cfg, err := c.dyncfgNewTypedConfig(kind)
251
+ if err != nil {
252
+ return nil, err
253
+ }
254
+ if err := fn.UnmarshalPayload(cfg); err != nil {
255
+ return nil, err
256
+ }
257
+ return cfg, nil
258
+}
259
+
260
+func (c *Controller) dyncfgTypedConfigFromRaw(rawCfg secretstore.Config) (any, error) {
261
+ cfg, err := c.dyncfgNewTypedConfig(rawCfg.Kind())
262
+ if err != nil {
263
+ return nil, err
264
+ }
265
+
266
+ bs, err := yaml.Marshal(rawCfg)
267
+ if err != nil {
268
+ return nil, err
269
+ }
270
+ if err := yaml.Unmarshal(bs, cfg); err != nil {
271
+ return nil, err
272
+ }
273
+ return cfg, nil
274
+}
275
+
276
+func (c *Controller) dyncfgNewTypedConfig(kind secretstore.StoreKind) (any, error) {
277
+ if c.service == nil {
278
+ return nil, fmt.Errorf("secretstore service is not available")
279
+ }
280
+
281
+ store, ok := c.service.New(kind)
282
+ if !ok {
283
+ return nil, fmt.Errorf("the specified secretstore kind '%s' is not supported", kind)
284
+ }
285
+
286
+ cfg := store.Configuration()
287
+ if cfg == nil {
288
+ return nil, fmt.Errorf("secretstore kind '%s' does not provide configuration", kind)
289
+ }
290
+ return cfg, nil
291
+}
292
+
293
+func (c *Controller) dyncfgExtractSecretStoreKindFromTemplateID(id string) (secretstore.StoreKind, bool) {
294
+ return c.cb.deps.extractSecretStoreKindFromTemplateID(id)
295
+}
296
+
297
+func (c *Controller) dyncfgExtractSecretStoreKey(id string) (string, bool) {
298
+ return c.cb.deps.extractSecretStoreKey(id)
299
+}
300
+
301
+func (c *Controller) dyncfgSecretStoreConfigFromPayload(fn dyncfg.Function, name string, kind secretstore.StoreKind) (secretstore.Config, error) {
302
+ return c.cb.deps.secretStoreConfigFromPayload(fn, name, kind)
303
+}
304
+
305
+func formatAffectedJobs(refs []secretstore.JobRef) string {
306
+ if len(refs) == 0 {
307
+ return ""
308
+ }
309
+
310
+ var b strings.Builder
311
+ for i, ref := range refs {
312
+ if i > 0 {
313
+ b.WriteString(", ")
314
+ }
315
+ if ref.Display != "" {
316
+ b.WriteString(ref.Display)
317
+ } else {
318
+ b.WriteString(ref.ID)
319
+ }
320
+ }
321
+ return b.String()
322
+}
323
+
324
+func (c *Controller) dyncfgSendSecretStoreTestImpactMessage(fn dyncfg.Function, refs, restartable []secretstore.JobRef, validationOnly bool) {
325
+ affected := formatAffectedJobs(refs)
326
+ restartableAffected := formatAffectedJobs(restartable)
327
+ if validationOnly {
328
+ if affected != "" {
329
+ if restartableAffected != "" {
330
+ c.api.SendCodef(fn, 202, "Stored configuration is valid. This secretstore is used by jobs: %s. Running or failed jobs that would be restarted automatically by a change: %s.", affected, restartableAffected)
331
+ return
332
+ }
333
+ c.api.SendCodef(fn, 202, "Stored configuration is valid. This secretstore is used by jobs: %s. No running or failed jobs would be restarted automatically by a change.", affected)
334
+ return
335
+ }
336
+ c.api.SendCodef(fn, 202, "Stored configuration is valid. No jobs are currently using this secretstore.")
337
+ return
338
+ }
339
+
340
+ if affected != "" {
341
+ if restartableAffected != "" {
342
+ c.api.SendCodef(fn, 202, "Updated configuration is used by jobs: %s. Running or failed jobs that would be restarted automatically: %s.", affected, restartableAffected)
343
+ return
344
+ }
345
+ c.api.SendCodef(fn, 202, "Updated configuration is used by jobs: %s. No running or failed jobs would be restarted automatically.", affected)
346
+ return
347
+ }
348
+ c.api.SendCodef(fn, 202, "No jobs currently use this secretstore.")
349
+}
350
+
351
+func secretStoreErrorCode(err error) int {
352
+ switch {
353
+ case errors.Is(err, secretstore.ErrStoreExists):
354
+ return 409
355
+ case errors.Is(err, secretstore.ErrStoreNotFound):
356
+ return 404
357
+ default:
358
+ return 400
359
+ }
360
+}
361
+
362
+func secretStoreCommandCode(err error) int {
363
+ var ce interface{ Code() int }
364
+ if errors.As(err, &ce) {
365
+ return ce.Code()
366
+ }
367
+ return secretStoreErrorCode(err)
368
+}
src/go/plugin/agent/jobmgr/secretsctl/initial.go
new
+49
@@ -0,0 +1,49 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretsctl
4
+
5
+import (
6
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
7
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
8
+)
9
+
10
+func (c *Controller) publishInitialConfig(rawCfg secretstore.Config) {
11
+ cfg, prepErr := c.prepareConfigCandidate(rawCfg)
12
+ c.handler.RememberDiscoveredConfig(cfg)
13
+
14
+ if existing, ok := c.lookupInternal(cfg.ExposedKey()); ok {
15
+ if shouldKeepExisting(cfg, existing) {
16
+ return
17
+ }
18
+ if existing.Status == dyncfg.StatusRunning || existing.Status == dyncfg.StatusFailed {
19
+ c.cb.Stop(existing.Cfg)
20
+ c.cb.TakeCommandMessage()
21
+ }
22
+ }
23
+
24
+ entry := &dyncfg.Entry[secretstore.Config]{Cfg: cfg, Status: dyncfg.StatusFailed}
25
+ if prepErr == nil {
26
+ if err := c.cb.Start(cfg); err == nil {
27
+ entry.Status = dyncfg.StatusRunning
28
+ }
29
+ }
30
+
31
+ c.exposed.Add(entry)
32
+ c.handler.NotifyJobCreate(cfg, entry.Status)
33
+}
34
+
35
+func (c *Controller) prepareConfigCandidate(cfg secretstore.Config) (secretstore.Config, error) {
36
+ return cfg, c.validateConfig(cfg)
37
+}
38
+
39
+func shouldKeepExisting(cfg secretstore.Config, existing *dyncfg.Entry[secretstore.Config]) bool {
40
+ sp, ep := cfg.SourceTypePriority(), existing.Cfg.SourceTypePriority()
41
+ if ep > sp {
42
+ return true
43
+ }
44
+ if ep < sp {
45
+ return false
46
+ }
47
+
48
+ return existing.Status == dyncfg.StatusRunning
49
+}
src/go/plugin/agent/jobmgr/secretstore_deps.go
new
+337
@@ -0,0 +1,337 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package jobmgr
4
+
5
+import (
6
+ "fmt"
7
+ "regexp"
8
+ "sort"
9
+ "strings"
10
+ "sync"
11
+
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"
15
+)
16
+
17
+var (
18
+ reSecretRef = regexp.MustCompile(`\$\{([^}]+)\}`)
19
+ reUpperShorthand = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`)
20
+)
21
+
22
+type secretStoreDeps struct {
23
+ mu sync.RWMutex
24
+
25
+ jobs map[string]*secretStoreJobState
26
+
27
+ // storeKey -> internal job key -> ref
28
+ exposed map[string]map[string]secretstore.JobRef
29
+ running map[string]map[string]secretstore.JobRef
30
+}
31
+
32
+type secretStoreJobState struct {
33
+ display string
34
+ stores map[string]struct{}
35
+ running bool
36
+}
37
+
38
+func newSecretStoreDeps() *secretStoreDeps {
39
+ return &secretStoreDeps{
40
+ jobs: make(map[string]*secretStoreJobState),
41
+ exposed: make(map[string]map[string]secretstore.JobRef),
42
+ running: make(map[string]map[string]secretstore.JobRef),
43
+ }
44
+}
45
+
46
+func (d *secretStoreDeps) SetActiveJobStores(internalKey, display string, storeKeys []string) {
47
+ internalKey = strings.TrimSpace(internalKey)
48
+ if internalKey == "" {
49
+ return
50
+ }
51
+ if display == "" {
52
+ display = internalKey
53
+ }
54
+
55
+ normalized := normalizeStoreKeys(storeKeys)
56
+
57
+ d.mu.Lock()
58
+ defer d.mu.Unlock()
59
+
60
+ state, ok := d.jobs[internalKey]
61
+ if !ok {
62
+ state = &secretStoreJobState{}
63
+ d.jobs[internalKey] = state
64
+ }
65
+
66
+ for storeKey := range state.stores {
67
+ d.removeRefLocked(d.exposed, storeKey, internalKey)
68
+ if state.running {
69
+ d.removeRefLocked(d.running, storeKey, internalKey)
70
+ }
71
+ }
72
+
73
+ state.display = display
74
+ state.stores = make(map[string]struct{}, len(normalized))
75
+ for _, storeKey := range normalized {
76
+ state.stores[storeKey] = struct{}{}
77
+ ref := secretstore.JobRef{ID: internalKey, Display: display}
78
+ d.addRefLocked(d.exposed, storeKey, ref)
79
+ if state.running {
80
+ d.addRefLocked(d.running, storeKey, ref)
81
+ }
82
+ }
83
+}
84
+
85
+func (d *secretStoreDeps) RemoveActiveJob(internalKey string) {
86
+ internalKey = strings.TrimSpace(internalKey)
87
+ if internalKey == "" {
88
+ return
89
+ }
90
+
91
+ d.mu.Lock()
92
+ defer d.mu.Unlock()
93
+
94
+ state, ok := d.jobs[internalKey]
95
+ if !ok {
96
+ return
97
+ }
98
+
99
+ for storeKey := range state.stores {
100
+ d.removeRefLocked(d.exposed, storeKey, internalKey)
101
+ d.removeRefLocked(d.running, storeKey, internalKey)
102
+ }
103
+ delete(d.jobs, internalKey)
104
+}
105
+
106
+func (d *secretStoreDeps) setRunning(internalKey string, running bool) {
107
+ internalKey = strings.TrimSpace(internalKey)
108
+ if internalKey == "" {
109
+ return
110
+ }
111
+
112
+ d.mu.Lock()
113
+ defer d.mu.Unlock()
114
+
115
+ state, ok := d.jobs[internalKey]
116
+ if !ok {
117
+ if !running {
118
+ return
119
+ }
120
+ state = &secretStoreJobState{display: internalKey, stores: map[string]struct{}{}}
121
+ d.jobs[internalKey] = state
122
+ }
123
+
124
+ if state.running == running {
125
+ return
126
+ }
127
+
128
+ state.running = running
129
+ for storeKey := range state.stores {
130
+ ref := secretstore.JobRef{ID: internalKey, Display: state.display}
131
+ if running {
132
+ d.addRefLocked(d.running, storeKey, ref)
133
+ } else {
134
+ d.removeRefLocked(d.running, storeKey, internalKey)
135
+ }
136
+ }
137
+}
138
+
139
+func (d *secretStoreDeps) Impacted(storeKey string) (exposed []secretstore.JobRef, running []secretstore.JobRef) {
140
+ storeKey = strings.TrimSpace(storeKey)
141
+ if storeKey == "" {
142
+ return nil, nil
143
+ }
144
+
145
+ d.mu.RLock()
146
+ defer d.mu.RUnlock()
147
+
148
+ exposed = collectSortedRefs(d.exposed[storeKey])
149
+ running = collectSortedRefs(d.running[storeKey])
150
+ return exposed, running
151
+}
152
+
153
+func (d *secretStoreDeps) addRefLocked(index map[string]map[string]secretstore.JobRef, storeKey string, ref secretstore.JobRef) {
154
+ jobs, ok := index[storeKey]
155
+ if !ok {
156
+ jobs = make(map[string]secretstore.JobRef)
157
+ index[storeKey] = jobs
158
+ }
159
+ jobs[ref.ID] = ref
160
+}
161
+
162
+func (d *secretStoreDeps) removeRefLocked(index map[string]map[string]secretstore.JobRef, storeKey, internalKey string) {
163
+ jobs, ok := index[storeKey]
164
+ if !ok {
165
+ return
166
+ }
167
+ delete(jobs, internalKey)
168
+ if len(jobs) == 0 {
169
+ delete(index, storeKey)
170
+ }
171
+}
172
+
173
+func collectSortedRefs(m map[string]secretstore.JobRef) []secretstore.JobRef {
174
+ if len(m) == 0 {
175
+ return nil
176
+ }
177
+ refs := make([]secretstore.JobRef, 0, len(m))
178
+ for _, ref := range m {
179
+ refs = append(refs, ref)
180
+ }
181
+ sort.Slice(refs, func(i, j int) bool {
182
+ if refs[i].ID == refs[j].ID {
183
+ return refs[i].Display < refs[j].Display
184
+ }
185
+ return refs[i].ID < refs[j].ID
186
+ })
187
+ return refs
188
+}
189
+
190
+func normalizeStoreKeys(storeKeys []string) []string {
191
+ if len(storeKeys) == 0 {
192
+ return nil
193
+ }
194
+ seen := make(map[string]struct{}, len(storeKeys))
195
+ for _, key := range storeKeys {
196
+ kind, name, err := secretstore.ParseStoreKey(key)
197
+ if err != nil {
198
+ continue
199
+ }
200
+ seen[secretstore.StoreKey(kind, name)] = struct{}{}
201
+ }
202
+ if len(seen) == 0 {
203
+ return nil
204
+ }
205
+ out := make([]string, 0, len(seen))
206
+ for key := range seen {
207
+ out = append(out, key)
208
+ }
209
+ sort.Strings(out)
210
+ return out
211
+}
212
+
213
+func extractSecretStoreKeys(cfg confgroup.Config) []string {
214
+ seen := make(map[string]struct{})
215
+ extractSecretStoreKeysFromValue(cfg, seen)
216
+ if len(seen) == 0 {
217
+ return nil
218
+ }
219
+ keys := make([]string, 0, len(seen))
220
+ for key := range seen {
221
+ keys = append(keys, key)
222
+ }
223
+ sort.Strings(keys)
224
+ return keys
225
+}
226
+
227
+func extractSecretStoreKeysFromValue(v any, seen map[string]struct{}) {
228
+ switch value := v.(type) {
229
+ case string:
230
+ extractSecretStoreKeysFromString(value, seen)
231
+ case confgroup.Config:
232
+ for k, entry := range value {
233
+ if isSecretStoreInternalKey(k) {
234
+ continue
235
+ }
236
+ extractSecretStoreKeysFromValue(entry, seen)
237
+ }
238
+ case map[string]any:
239
+ for k, entry := range value {
240
+ if isSecretStoreInternalKey(k) {
241
+ continue
242
+ }
243
+ extractSecretStoreKeysFromValue(entry, seen)
244
+ }
245
+ case map[any]any:
246
+ for rawKey, entry := range value {
247
+ if key, ok := rawKey.(string); ok && isSecretStoreInternalKey(key) {
248
+ continue
249
+ }
250
+ extractSecretStoreKeysFromValue(entry, seen)
251
+ }
252
+ case []any:
253
+ for _, entry := range value {
254
+ extractSecretStoreKeysFromValue(entry, seen)
255
+ }
256
+ }
257
+}
258
+
259
+func extractSecretStoreKeysFromString(value string, seen map[string]struct{}) {
260
+ if !strings.Contains(value, "${") {
261
+ return
262
+ }
263
+ matches := reSecretRef.FindAllStringSubmatch(value, -1)
264
+ for _, match := range matches {
265
+ if len(match) < 2 {
266
+ continue
267
+ }
268
+ inner := match[1]
269
+ scheme, rest, hasScheme := strings.Cut(inner, ":")
270
+ if !hasScheme {
271
+ if reUpperShorthand.MatchString(inner) {
272
+ continue
273
+ }
274
+ continue
275
+ }
276
+ if scheme != "store" {
277
+ continue
278
+ }
279
+ kindPart, tail, ok := strings.Cut(rest, ":")
280
+ if !ok {
281
+ continue
282
+ }
283
+ namePart, _, ok := strings.Cut(tail, ":")
284
+ if !ok {
285
+ continue
286
+ }
287
+ kind := secretstore.StoreKind(strings.TrimSpace(kindPart))
288
+ name := strings.TrimSpace(namePart)
289
+ if !kind.IsValid() {
290
+ continue
291
+ }
292
+ if err := dyncfg.ValidateJobName(name); err != nil {
293
+ continue
294
+ }
295
+ seen[secretstore.StoreKey(kind, name)] = struct{}{}
296
+ }
297
+}
298
+
299
+func isSecretStoreInternalKey(k string) bool {
300
+ return strings.HasPrefix(k, "__") && strings.HasSuffix(k, "__")
301
+}
302
+
303
+func secretStoreDisplay(cfg confgroup.Config) string {
304
+ module := strings.TrimSpace(cfg.Module())
305
+ job := strings.TrimSpace(cfg.Name())
306
+ switch {
307
+ case module != "" && job != "":
308
+ return fmt.Sprintf("%s:%s", module, job)
309
+ case cfg.FullName() != "":
310
+ return cfg.FullName()
311
+ default:
312
+ return job
313
+ }
314
+}
315
+
316
+func (m *Manager) syncSecretStoreDepsForConfig(cfg confgroup.Config) {
317
+ if m.secretStoreDeps == nil {
318
+ return
319
+ }
320
+ m.secretStoreDeps.SetActiveJobStores(cfg.FullName(), secretStoreDisplay(cfg), extractSecretStoreKeys(cfg))
321
+}
322
+
323
+func (m *Manager) syncSecretStoreDepsByFunction(fn dyncfg.Function) {
324
+ if m.secretStoreDeps == nil || m.collectorCallbacks == nil {
325
+ return
326
+ }
327
+ key, _, ok := m.collectorCallbacks.ExtractKey(fn)
328
+ if !ok {
329
+ return
330
+ }
331
+ entry, ok := m.collectorExposed.LookupByKey(key)
332
+ if !ok {
333
+ m.secretStoreDeps.RemoveActiveJob(key)
334
+ return
335
+ }
336
+ m.syncSecretStoreDepsForConfig(entry.Cfg)
337
+}
src/go/plugin/agent/jobmgr/secretstore_deps_test.go
new
+198
@@ -0,0 +1,198 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package jobmgr
4
+
5
+import (
6
+ "bytes"
7
+ "context"
8
+ "testing"
9
+
10
+ "github.com/stretchr/testify/assert"
11
+ "github.com/stretchr/testify/require"
12
+
13
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
14
+ "github.com/netdata/netdata/go/plugins/pkg/safewriter"
15
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
16
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
17
+ "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
18
+)
19
+
20
+func TestExtractSecretStoreKeys(t *testing.T) {
21
+ tests := map[string]struct {
22
+ cfg map[string]any
23
+ want []string
24
+ }{
25
+ "single store reference": {
26
+ cfg: map[string]any{
27
+ "password": "${store:aws-sm:aws_prod:db/password}",
28
+ },
29
+ want: []string{"aws-sm:aws_prod"},
30
+ },
31
+ "multiple refs with non-store refs ignored": {
32
+ cfg: map[string]any{
33
+ "dsn": "postgres://${env:USER}:${store:vault:vault_prod:secret/data/db#password}@host/${store:aws-sm:aws_prod:db#pw}",
34
+ },
35
+ want: []string{"aws-sm:aws_prod", "vault:vault_prod"},
36
+ },
37
+ "nested structures and internal keys": {
38
+ cfg: map[string]any{
39
+ "__source__": "${store:vault:ignored:foo}",
40
+ "outer": map[string]any{
41
+ "items": []any{
42
+ "${store:gcp-sm:gcp_prod:project/secret/latest}",
43
+ map[any]any{"x": "${store:azure-kv:az_prod:vault/secret}"},
44
+ },
45
+ },
46
+ },
47
+ want: []string{"azure-kv:az_prod", "gcp-sm:gcp_prod"},
48
+ },
49
+ "invalid store refs are ignored": {
50
+ cfg: map[string]any{
51
+ "a": "${store::bad:secret}",
52
+ "b": "${store:bad-id:thing:path}",
53
+ "c": "${store:vault:good_1:path}",
54
+ },
55
+ want: []string{"vault:good_1"},
56
+ },
57
+ }
58
+
59
+ for name, tc := range tests {
60
+ t.Run(name, func(t *testing.T) {
61
+ cfg := prepareUserCfg("mod", "job")
62
+ for k, v := range tc.cfg {
63
+ cfg[k] = v
64
+ }
65
+ got := extractSecretStoreKeys(cfg)
66
+ assert.Equal(t, tc.want, got)
67
+ })
68
+ }
69
+}
70
+
71
+func TestSecretStoreDepsImpacted(t *testing.T) {
72
+ tests := map[string]struct {
73
+ run func(d *secretStoreDeps)
74
+ storeKey string
75
+ wantExposed []secretstore.JobRef
76
+ wantRunning []secretstore.JobRef
77
+ }{
78
+ "set active and running": {
79
+ run: func(d *secretStoreDeps) {
80
+ d.SetActiveJobStores("mysql_prod", "mysql:prod", []string{"vault:vault_prod"})
81
+ d.setRunning("mysql_prod", true)
82
+ },
83
+ storeKey: "vault:vault_prod",
84
+ wantExposed: []secretstore.JobRef{
85
+ {ID: "mysql_prod", Display: "mysql:prod"},
86
+ },
87
+ wantRunning: []secretstore.JobRef{
88
+ {ID: "mysql_prod", Display: "mysql:prod"},
89
+ },
90
+ },
91
+ "replace active stores keeps running alignment": {
92
+ run: func(d *secretStoreDeps) {
93
+ d.SetActiveJobStores("mysql_prod", "mysql:prod", []string{"vault:vault_prod"})
94
+ d.setRunning("mysql_prod", true)
95
+ d.SetActiveJobStores("mysql_prod", "mysql:prod", []string{"aws-sm:aws_prod"})
96
+ },
97
+ storeKey: "aws-sm:aws_prod",
98
+ wantExposed: []secretstore.JobRef{
99
+ {ID: "mysql_prod", Display: "mysql:prod"},
100
+ },
101
+ wantRunning: []secretstore.JobRef{
102
+ {ID: "mysql_prod", Display: "mysql:prod"},
103
+ },
104
+ },
105
+ "remove active job clears impacted": {
106
+ run: func(d *secretStoreDeps) {
107
+ d.SetActiveJobStores("mysql_prod", "mysql:prod", []string{"vault:vault_prod"})
108
+ d.setRunning("mysql_prod", true)
109
+ d.RemoveActiveJob("mysql_prod")
110
+ },
111
+ storeKey: "vault:vault_prod",
112
+ wantExposed: nil,
113
+ wantRunning: nil,
114
+ },
115
+ "non-running job appears only in exposed": {
116
+ run: func(d *secretStoreDeps) {
117
+ d.SetActiveJobStores("redis_prod", "redis:prod", []string{"gcp-sm:gcp_prod"})
118
+ d.setRunning("redis_prod", false)
119
+ },
120
+ storeKey: "gcp-sm:gcp_prod",
121
+ wantExposed: []secretstore.JobRef{
122
+ {ID: "redis_prod", Display: "redis:prod"},
123
+ },
124
+ wantRunning: nil,
125
+ },
126
+ }
127
+
128
+ for name, tc := range tests {
129
+ t.Run(name, func(t *testing.T) {
130
+ deps := newSecretStoreDeps()
131
+ tc.run(deps)
132
+
133
+ exposed, running := deps.Impacted(tc.storeKey)
134
+ assert.Equal(t, tc.wantExposed, exposed)
135
+ assert.Equal(t, tc.wantRunning, running)
136
+ })
137
+ }
138
+}
139
+
140
+func TestDyncfgTestDoesNotMutateSecretStoreDeps(t *testing.T) {
141
+ tests := map[string]struct{}{
142
+ "command test leaves dependency index unchanged": {},
143
+ }
144
+
145
+ for name := range tests {
146
+ t.Run(name, func(t *testing.T) {
147
+ var buf bytes.Buffer
148
+ mgr := newCollectorTestManager()
149
+ mgr.ctx = context.Background()
150
+ mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
151
+
152
+ cfg := prepareDyncfgCfg("success", "job")
153
+ cfg["password"] = "${store:vault:vault_prod:secret/data/mysql#password}"
154
+ mgr.syncSecretStoreDepsForConfig(cfg)
155
+ mgr.secretStoreDeps.setRunning(cfg.FullName(), true)
156
+
157
+ beforeExposed, beforeRunning := mgr.secretStoreDeps.Impacted("vault:vault_prod")
158
+ require.Len(t, beforeExposed, 1)
159
+ require.Len(t, beforeRunning, 1)
160
+
161
+ fn := dyncfg.NewFunction(functions.Function{
162
+ UID: "secretstore-deps-test",
163
+ ContentType: "application/json",
164
+ Payload: mustMarshalCollectorConfigPayload(t, prepareDyncfgCfg("success", "job")),
165
+ Args: []string{mgr.dyncfgModID("success"), string(dyncfg.CommandTest), "job"},
166
+ })
167
+
168
+ mgr.dyncfgCollectorSeqExec(fn)
169
+ mgr.cmdTestWG.Wait()
170
+
171
+ afterExposed, afterRunning := mgr.secretStoreDeps.Impacted("vault:vault_prod")
172
+ assert.Equal(t, beforeExposed, afterExposed)
173
+ assert.Equal(t, beforeRunning, afterRunning)
174
+ })
175
+ }
176
+}
177
+
178
+func TestSecretStoreDepsNoStateLeakOnRemoveThenStop(t *testing.T) {
179
+ tests := map[string]struct{}{
180
+ "setRunning false on missing state is no-op": {},
181
+ }
182
+
183
+ for name := range tests {
184
+ t.Run(name, func(t *testing.T) {
185
+ deps := newSecretStoreDeps()
186
+
187
+ deps.SetActiveJobStores("mysql_prod", "mysql:prod", []string{"vault:vault_prod"})
188
+ deps.setRunning("mysql_prod", true)
189
+ deps.RemoveActiveJob("mysql_prod")
190
+ deps.setRunning("mysql_prod", false)
191
+
192
+ assert.Empty(t, deps.jobs)
193
+ exposed, running := deps.Impacted("vault:vault_prod")
194
+ assert.Empty(t, exposed)
195
+ assert.Empty(t, running)
196
+ })
197
+ }
198
+}
src/go/plugin/agent/jobmgr/secretstore_flow_test.go
new
+331
@@ -0,0 +1,331 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package jobmgr
4
+
5
+import (
6
+ "bytes"
7
+ "context"
8
+ "fmt"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
13
+ "github.com/netdata/netdata/go/plugins/pkg/safewriter"
14
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/resolver"
15
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
16
+ "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
17
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
18
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
19
+ "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
20
+ "github.com/stretchr/testify/assert"
21
+ "github.com/stretchr/testify/require"
22
+)
23
+
24
+type TestStoreConfig struct {
25
+ Value string `yaml:"value" json:"value"`
26
+}
27
+
28
+type testStore struct {
29
+ TestStoreConfig `yaml:",inline" json:""`
30
+}
31
+
32
+func (s *testStore) Configuration() any { return &s.TestStoreConfig }
33
+
34
+func (s *testStore) Init(context.Context) error {
35
+ if s.TestStoreConfig.Value == "" {
36
+ return fmt.Errorf("value is required")
37
+ }
38
+ return nil
39
+}
40
+
41
+func (s *testStore) Publish() secretstore.PublishedStore {
42
+ return &testPublishedStore{value: s.TestStoreConfig.Value}
43
+}
44
+
45
+type testPublishedStore struct {
46
+ value string
47
+}
48
+
49
+func (s *testPublishedStore) Resolve(_ context.Context, req secretstore.ResolveRequest) (string, error) {
50
+ if req.Operand != "value" {
51
+ return "", fmt.Errorf("unexpected operand %q", req.Operand)
52
+ }
53
+ return s.value, nil
54
+}
55
+
56
+type secretAwareCollector struct {
57
+ collectorapi.Base
58
+ Config collectorapi.MockConfiguration `yaml:",inline" json:""`
59
+}
60
+
61
+func (c *secretAwareCollector) Configuration() any { return c.Config }
62
+func (c *secretAwareCollector) Check(context.Context) error { return nil }
63
+func (c *secretAwareCollector) Cleanup(context.Context) {}
64
+func (c *secretAwareCollector) Charts() *collectorapi.Charts { return &collectorapi.Charts{} }
65
+func (c *secretAwareCollector) Collect(context.Context) map[string]int64 {
66
+ return map[string]int64{"value": 1}
67
+}
68
+
69
+func (c *secretAwareCollector) Init(context.Context) error {
70
+ if c.Config.OptionStr != "good" {
71
+ return fmt.Errorf("secret is not usable: %s", c.Config.OptionStr)
72
+ }
73
+ return nil
74
+}
75
+
76
+func newTestSecretStoreService() secretstore.Service {
77
+ return secretstore.NewService(secretstore.Creator{
78
+ Kind: secretstore.KindVault,
79
+ DisplayName: "Vault",
80
+ Schema: `{"jsonSchema":{"type":"object","properties":{"value":{"type":"string"}}},"uiSchema":[]}`,
81
+ Create: func() secretstore.Store {
82
+ return &testStore{}
83
+ },
84
+ })
85
+}
86
+
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))
91
+
92
+ cfg := prepareDyncfgCfg("success", "secret-job").
93
+ Set("option_str", "${store:vault:vault_prod:value}").
94
+ Set("option_int", 7)
95
+
96
+ module := &collectorapi.MockCollectorV1{}
97
+ err := applyConfig(t.Context(), cfg, module, secretresolver.New(), svc, svc.Capture())
98
+ require.NoError(t, err)
99
+
100
+ assert.Equal(t, "resolved-secret", module.Config.OptionStr)
101
+ assert.Equal(t, 7, module.Config.OptionInt)
102
+}
103
+
104
+func TestRun_StartupLoadedSecretStoreIsAvailableToFirstCollectorStart(t *testing.T) {
105
+ initial := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "good"}, "file=/etc/netdata/go.d/ss/vault.conf", confgroup.TypeUser)
106
+ mgr := New(Config{
107
+ PluginName: testPluginName,
108
+ SecretStores: []secretstore.Config{initial},
109
+ SecretStoreService: newTestSecretStoreService(),
110
+ })
111
+ mgr.modules = collectorapi.Registry{
112
+ "gated": {
113
+ Create: func() collectorapi.CollectorV1 { return &secretAwareCollector{} },
114
+ },
115
+ }
116
+
117
+ var out bytes.Buffer
118
+ mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&out))))
119
+ mgr.runModePolicy.AutoEnableDiscovered = true
120
+
121
+ ctx, cancel := context.WithCancel(context.Background())
122
+ in := make(chan []*confgroup.Group)
123
+ done := make(chan struct{})
124
+ go func() {
125
+ defer close(done)
126
+ mgr.Run(ctx, in)
127
+ }()
128
+
129
+ waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second)
130
+ defer waitCancel()
131
+ require.True(t, mgr.WaitStarted(waitCtx), "manager did not report started")
132
+
133
+ key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
134
+ entry, ok := mgr.lookupSecretStoreEntry(key)
135
+ require.True(t, ok)
136
+ assert.Equal(t, dyncfg.StatusRunning, entry.Status)
137
+ _, ok = mustSecretStoreService(t, mgr).GetStatus(key)
138
+ assert.True(t, ok)
139
+
140
+ cfg := prepareDyncfgCfg("gated", "startup").
141
+ Set("option_str", "${store:vault:vault_prod:value}").
142
+ Set("option_int", 1)
143
+ mgr.addConfig(cfg)
144
+
145
+ jobEntry, ok := mgr.lookupExposedByFullName(cfg.FullName())
146
+ require.True(t, ok)
147
+ assert.Equal(t, dyncfg.StatusRunning, jobEntry.Status)
148
+ require.Len(t, mgr.runningJobs.snapshot(), 1)
149
+ assert.Equal(t, cfg.FullName(), mgr.runningJobs.snapshot()[0].FullName())
150
+
151
+ cancel()
152
+ close(in)
153
+
154
+ select {
155
+ case <-done:
156
+ case <-time.After(2 * time.Second):
157
+ t.Fatal("manager did not stop after cancel")
158
+ }
159
+}
160
+
161
+func TestDyncfgSecretStoreUpdate_DependentRestartBehavior(t *testing.T) {
162
+ tests := map[string]struct {
163
+ run func(t *testing.T, mgr *Manager, out *bytes.Buffer)
164
+ }{
165
+ "restarts failed dependent after store is fixed": {
166
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
167
+ mgr.modules["gated"] = collectorapi.Creator{
168
+ Create: func() collectorapi.CollectorV1 { return &secretAwareCollector{} },
169
+ }
170
+
171
+ key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
172
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", map[string]any{"value": "good"}, dyncfg.StatusRunning)
173
+
174
+ cfg := prepareDyncfgCfg("gated", "mysql").
175
+ Set("option_str", "${store:vault:vault_prod:value}").
176
+ Set("option_int", 1)
177
+ mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
178
+ Cfg: cfg,
179
+ Status: dyncfg.StatusRunning,
180
+ })
181
+ mgr.syncSecretStoreDepsForConfig(cfg)
182
+ require.NoError(t, mgr.collectorCallbacks.Start(cfg))
183
+
184
+ _, running := mgr.secretStoreDeps.Impacted(key)
185
+ require.Len(t, running, 1)
186
+ assert.Equal(t, cfg.FullName(), running[0].ID)
187
+
188
+ badFn := dyncfg.NewFunction(functions.Function{
189
+ UID: "ss-update-bad",
190
+ ContentType: "application/json",
191
+ Payload: mustJSON(t, map[string]any{"value": "bad"}),
192
+ Args: []string{
193
+ mgr.dyncfgSecretStoreID(key),
194
+ string(dyncfg.CommandUpdate),
195
+ },
196
+ })
197
+ mgr.dyncfgSecretStoreSeqExec(badFn)
198
+
199
+ var badResp map[string]any
200
+ mustDecodeFunctionPayload(t, out.String(), "ss-update-bad", &badResp)
201
+ assert.Equal(t, float64(200), badResp["status"])
202
+ assert.Contains(t, badResp["message"], "Secretstore change applied, but dependent collector restarts failed")
203
+ assert.Contains(t, badResp["message"], "gated:mysql")
204
+
205
+ entry, ok := mgr.lookupExposedByFullName(cfg.FullName())
206
+ require.True(t, ok)
207
+ assert.Equal(t, dyncfg.StatusFailed, entry.Status)
208
+
209
+ _, running = mgr.secretStoreDeps.Impacted(key)
210
+ assert.Empty(t, running)
211
+
212
+ goodFn := dyncfg.NewFunction(functions.Function{
213
+ UID: "ss-update-good",
214
+ ContentType: "application/json",
215
+ Payload: mustJSON(t, map[string]any{"value": "good"}),
216
+ Args: []string{
217
+ mgr.dyncfgSecretStoreID(key),
218
+ string(dyncfg.CommandUpdate),
219
+ },
220
+ })
221
+ mgr.dyncfgSecretStoreSeqExec(goodFn)
222
+
223
+ var goodResp map[string]any
224
+ mustDecodeFunctionPayload(t, out.String(), "ss-update-good", &goodResp)
225
+ assert.Equal(t, float64(200), goodResp["status"])
226
+ assert.Equal(t, "", goodResp["message"])
227
+
228
+ entry, ok = mgr.lookupExposedByFullName(cfg.FullName())
229
+ require.True(t, ok)
230
+ assert.Equal(t, dyncfg.StatusRunning, entry.Status)
231
+
232
+ _, running = mgr.secretStoreDeps.Impacted(key)
233
+ require.Len(t, running, 1)
234
+ assert.Equal(t, cfg.FullName(), running[0].ID)
235
+ },
236
+ },
237
+ "ignores accepted and disabled dependents": {
238
+ run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
239
+ key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
240
+ seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", map[string]any{"value": "good"}, dyncfg.StatusRunning)
241
+
242
+ acceptedCfg := prepareDyncfgCfg("success", "accepted")
243
+ mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
244
+ Cfg: acceptedCfg,
245
+ Status: dyncfg.StatusAccepted,
246
+ })
247
+ mgr.secretStoreDeps.SetActiveJobStores(acceptedCfg.FullName(), "success:accepted", []string{key})
248
+
249
+ disabledCfg := prepareDyncfgCfg("success", "disabled")
250
+ mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
251
+ Cfg: disabledCfg,
252
+ Status: dyncfg.StatusDisabled,
253
+ })
254
+ mgr.secretStoreDeps.SetActiveJobStores(disabledCfg.FullName(), "success:disabled", []string{key})
255
+
256
+ updateFn := dyncfg.NewFunction(functions.Function{
257
+ UID: "ss-update-ignored",
258
+ ContentType: "application/json",
259
+ Payload: mustJSON(t, map[string]any{"value": "better"}),
260
+ Args: []string{
261
+ mgr.dyncfgSecretStoreID(key),
262
+ string(dyncfg.CommandUpdate),
263
+ },
264
+ })
265
+ mgr.dyncfgSecretStoreSeqExec(updateFn)
266
+
267
+ var resp map[string]any
268
+ mustDecodeFunctionPayload(t, out.String(), "ss-update-ignored", &resp)
269
+ assert.Equal(t, float64(200), resp["status"])
270
+ assert.Equal(t, "", resp["message"])
271
+
272
+ acceptedEntry, ok := mgr.lookupExposedByFullName(acceptedCfg.FullName())
273
+ require.True(t, ok)
274
+ assert.Equal(t, dyncfg.StatusAccepted, acceptedEntry.Status)
275
+
276
+ disabledEntry, ok := mgr.lookupExposedByFullName(disabledCfg.FullName())
277
+ require.True(t, ok)
278
+ assert.Equal(t, dyncfg.StatusDisabled, disabledEntry.Status)
279
+
280
+ _, running := mgr.secretStoreDeps.Impacted(key)
281
+ assert.Empty(t, running)
282
+ },
283
+ },
284
+ }
285
+
286
+ for name, tc := range tests {
287
+ t.Run(name, func(t *testing.T) {
288
+ mgr, out := newDyncfgSecretStoreTestManagerWithService(newTestSecretStoreService())
289
+ tc.run(t, mgr, out)
290
+ })
291
+ }
292
+}
293
+
294
+func TestDyncfgSecretStoreGet_CanonicalJSONDoesNotExposeUnknownFields(t *testing.T) {
295
+ mgr, out := newDyncfgSecretStoreTestManagerWithService(newTestSecretStoreService())
296
+
297
+ cfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{
298
+ "value": "resolved-secret",
299
+ "ignored": "drop-me",
300
+ }, confgroup.TypeDyncfg, confgroup.TypeDyncfg)
301
+ _, changed, err := mgr.rememberSecretStoreConfig(cfg)
302
+ require.NoError(t, err)
303
+ require.True(t, changed)
304
+
305
+ getFn := dyncfg.NewFunction(functions.Function{
306
+ UID: "ss-get-canonical",
307
+ Args: []string{mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")), string(dyncfg.CommandGet)},
308
+ })
309
+ mgr.dyncfgSecretStoreSeqExec(getFn)
310
+
311
+ var got map[string]any
312
+ mustDecodeFunctionPayload(t, out.String(), "ss-get-canonical", &got)
313
+ assert.Equal(t, "resolved-secret", got["value"])
314
+ _, ok := got["ignored"]
315
+ assert.False(t, ok)
316
+}
317
+
318
+func TestSecretStoreConfigFromPayload_PreservesKindAndNameForStoreSyntax(t *testing.T) {
319
+ mgr, _ := newDyncfgSecretStoreTestManagerWithService(newTestSecretStoreService())
320
+
321
+ fn := dyncfg.NewFunction(functions.Function{
322
+ ContentType: "application/json",
323
+ Payload: mustJSON(t, map[string]any{"value": "resolved-secret"}),
324
+ })
325
+ cfg, err := mgr.secretStoreConfigFromPayload(fn, "vault_prod", secretstore.KindVault)
326
+ require.NoError(t, err)
327
+
328
+ assert.Equal(t, "vault_prod", cfg.Name())
329
+ assert.Equal(t, secretstore.KindVault, cfg.Kind())
330
+ assert.Equal(t, "resolved-secret", cfg["value"])
331
+}
src/go/plugin/agent/jobmgr/sim_test.go
+6
-6
@@ -110,8 +110,8 @@ func (s *runSim) run(t *testing.T) {
110
expectedResults := strings.Count(s.wantDyncfg, "FUNCTION_RESULT_END")
111
require.Eventually(t, func() bool {
112
return countDiscovered(mgr) == len(s.wantDiscovered) &&
113
- mgr.seen.Count() == len(s.wantSeen) &&
114
- mgr.exposed.Count() == len(s.wantExposed) &&
113
+ mgr.collectorSeen.Count() == len(s.wantSeen) &&
114
+ mgr.collectorExposed.Count() == len(s.wantExposed) &&
115
runningSetMatches(mgr.runningJobs.snapshot(), s.wantRunning) &&
116
out.FuncResultCount() >= expectedResults
117
}, timeout, 10*time.Millisecond, "manager state did not settle before shutdown")
@@ -172,19 +172,19 @@ func (s *runSim) run(t *testing.T) {
172
require.Truef(t, ok, "discoveredConfigs: source %s config %d is not found", cfg.Source(), cfg.Hash())
173
}
174
175
- wantLen, gotLen = len(s.wantSeen), mgr.seen.Count()
175
+ wantLen, gotLen = len(s.wantSeen), mgr.collectorSeen.Count()
176
require.Equalf(t, wantLen, gotLen, "seen: different len (want %d got %d)", wantLen, gotLen)
177
178
for _, cfg := range s.wantSeen {
179
- _, ok := mgr.seen.Lookup(cfg)
179
+ _, ok := mgr.collectorSeen.Lookup(cfg)
180
require.Truef(t, ok, "seen: config '%s' is not found", cfg.UID())
181
}
182
183
- wantLen, gotLen = len(s.wantExposed), mgr.exposed.Count()
183
+ wantLen, gotLen = len(s.wantExposed), mgr.collectorExposed.Count()
184
require.Equalf(t, wantLen, gotLen, "exposed: different len (want %d got %d)", wantLen, gotLen)
185
186
for _, we := range s.wantExposed {
187
- entry, ok := mgr.exposed.LookupByKey(we.cfg.ExposedKey())
187
+ entry, ok := mgr.collectorExposed.LookupByKey(we.cfg.ExposedKey())
188
require.Truef(t, ok && we.cfg.UID() == entry.Cfg.UID(), "exposed: config '%s' is not found", we.cfg.UID())
189
require.Truef(t, we.status == entry.Status, "exposed: wrong status for '%s', want %s got %s", we.cfg.UID(), we.status, entry.Status)
190
}
src/go/plugin/agent/jobmgr/vnodectl/controller.go
new
+118
@@ -0,0 +1,118 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package vnodectl
4
+
5
+import (
6
+ "fmt"
7
+
8
+ "github.com/netdata/netdata/go/plugins/logger"
9
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
10
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
11
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
12
+ "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
13
+)
14
+
15
+const (
16
+ dyncfgVnodeIDf = "%s:vnode"
17
+ dyncfgVnodePath = "/collectors/%s/Vnodes"
18
+)
19
+
20
+type Options struct {
21
+ Logger *logger.Logger
22
+ API *dyncfg.Responder
23
+ Plugin string
24
+ Initial map[string]*vnodes.VirtualNode
25
+
26
+ AffectedJobs func(string) []string
27
+ ApplyVnodeUpdate func(string, *vnodes.VirtualNode)
28
+}
29
+
30
+type Controller struct {
31
+ *logger.Logger
32
+
33
+ api *dyncfg.Responder
34
+ pluginName string
35
+ store *vnodeStore
36
+
37
+ affectedJobs func(string) []string
38
+ applyVnodeUpdate func(string, *vnodes.VirtualNode)
39
+}
40
+
41
+func New(opts Options) *Controller {
42
+ log := opts.Logger
43
+ if log == nil {
44
+ log = logger.New()
45
+ }
46
+
47
+ return &Controller{
48
+ Logger: log,
49
+ api: opts.API,
50
+ pluginName: opts.Plugin,
51
+ store: newVnodeStore(opts.Initial),
52
+ affectedJobs: opts.AffectedJobs,
53
+ applyVnodeUpdate: opts.ApplyVnodeUpdate,
54
+ }
55
+}
56
+
57
+func (c *Controller) Prefix() string {
58
+ return fmt.Sprintf(dyncfgVnodeIDf, c.pluginName)
59
+}
60
+
61
+func (c *Controller) SetAPI(api *dyncfg.Responder) {
62
+ if api == nil {
63
+ // Nil means "keep the current responder" rather than clearing output wiring.
64
+ return
65
+ }
66
+ c.api = api
67
+}
68
+
69
+func (c *Controller) Lookup(name string) (*vnodes.VirtualNode, bool) {
70
+ if c.store == nil {
71
+ return nil, false
72
+ }
73
+ return c.store.Lookup(name)
74
+}
75
+
76
+func (c *Controller) CreateTemplates() {
77
+ if c.api == nil {
78
+ return
79
+ }
80
+ c.api.ConfigCreate(netdataapi.ConfigOpts{
81
+ ID: c.Prefix(),
82
+ Status: dyncfg.StatusAccepted.String(),
83
+ ConfigType: dyncfg.ConfigTypeTemplate.String(),
84
+ Path: fmt.Sprintf(dyncfgVnodePath, c.pluginName),
85
+ SourceType: "internal",
86
+ Source: "internal",
87
+ SupportedCommands: dyncfgVnodeModCmds(),
88
+ })
89
+}
90
+
91
+func (c *Controller) PublishExisting(status dyncfg.Status) {
92
+ if c.store == nil {
93
+ return
94
+ }
95
+ c.store.ForEach(func(cfg *vnodes.VirtualNode) bool {
96
+ c.createJob(cfg, status)
97
+ return true
98
+ })
99
+}
100
+
101
+func (c *Controller) configID(name string) string {
102
+ return fmt.Sprintf("%s:%s", c.Prefix(), name)
103
+}
104
+
105
+func (c *Controller) createJob(cfg *vnodes.VirtualNode, status dyncfg.Status) {
106
+ if c.api == nil || cfg == nil {
107
+ return
108
+ }
109
+ c.api.ConfigCreate(netdataapi.ConfigOpts{
110
+ ID: c.configID(cfg.Name),
111
+ Status: status.String(),
112
+ ConfigType: dyncfg.ConfigTypeJob.String(),
113
+ Path: fmt.Sprintf(dyncfgVnodePath, c.pluginName),
114
+ SourceType: cfg.SourceType,
115
+ Source: cfg.Source,
116
+ SupportedCommands: dyncfgVnodeJobCmds(cfg.SourceType == confgroup.TypeDyncfg),
117
+ })
118
+}
src/go/plugin/agent/jobmgr/vnodectl/controller_test.go
new
+476
@@ -0,0 +1,476 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package vnodectl
4
+
5
+import (
6
+ "bytes"
7
+ "encoding/json"
8
+ "fmt"
9
+ "regexp"
10
+ "testing"
11
+
12
+ "github.com/netdata/netdata/go/plugins/logger"
13
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
14
+ "github.com/netdata/netdata/go/plugins/pkg/safewriter"
15
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
16
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
17
+ "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
18
+ "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
19
+ "github.com/stretchr/testify/assert"
20
+ "github.com/stretchr/testify/require"
21
+)
22
+
23
+const testPluginName = "test"
24
+
25
+func TestControllerSeqExec(t *testing.T) {
26
+ tests := map[string]struct {
27
+ initial map[string]*vnodes.VirtualNode
28
+ run func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams)
29
+ }{
30
+ "schema dispatch": {
31
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
32
+ fn := dyncfg.NewFunction(functions.Function{UID: "vn-schema", Args: []string{ctl.Prefix(), string(dyncfg.CommandSchema)}})
33
+ ctl.SeqExec(fn)
34
+
35
+ assert.Contains(t, out.String(), "FUNCTION_RESULT_BEGIN vn-schema 200 application/json")
36
+ assert.Empty(t, seams.affectedJobsCalls)
37
+ assert.Empty(t, seams.applyCalls)
38
+ },
39
+ },
40
+ "userconfig generation": {
41
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
42
+ fn := dyncfg.NewFunction(functions.Function{
43
+ UID: "vn-userconfig",
44
+ ContentType: "application/json",
45
+ Payload: mustJSON(t, map[string]any{
46
+ "guid": "11111111-1111-1111-1111-111111111111",
47
+ "labels": map[string]string{"env": "prod"},
48
+ }),
49
+ Args: []string{ctl.Prefix(), string(dyncfg.CommandUserconfig), "db"},
50
+ })
51
+ ctl.SeqExec(fn)
52
+
53
+ body := mustFunctionBody(t, out.String(), "vn-userconfig")
54
+ assert.Contains(t, out.String(), "FUNCTION_RESULT_BEGIN vn-userconfig 200 application/yaml")
55
+ assert.Contains(t, body, "name: db")
56
+ assert.Contains(t, body, "hostname: db")
57
+ assert.Empty(t, seams.affectedJobsCalls)
58
+ assert.Empty(t, seams.applyCalls)
59
+ },
60
+ },
61
+ "get returns stored config": {
62
+ initial: map[string]*vnodes.VirtualNode{
63
+ "db": testVnode("db", "db", "11111111-1111-1111-1111-111111111111", confgroup.TypeDyncfg),
64
+ },
65
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
66
+ fn := dyncfg.NewFunction(functions.Function{UID: "vn-get", Args: []string{ctl.configID("db"), string(dyncfg.CommandGet)}})
67
+ ctl.SeqExec(fn)
68
+
69
+ var payload map[string]any
70
+ mustDecodeFunctionPayload(t, out.String(), "vn-get", &payload)
71
+ assert.Equal(t, "db", payload["name"])
72
+ assert.Equal(t, "11111111-1111-1111-1111-111111111111", payload["guid"])
73
+ assert.Empty(t, seams.affectedJobsCalls)
74
+ assert.Empty(t, seams.applyCalls)
75
+ },
76
+ },
77
+ "add applies vnode update seam": {
78
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
79
+ fn := dyncfg.NewFunction(functions.Function{
80
+ UID: "vn-add",
81
+ ContentType: "application/json",
82
+ Payload: mustJSON(t, map[string]any{
83
+ "guid": "11111111-1111-1111-1111-111111111111",
84
+ "labels": map[string]string{"env": "prod"},
85
+ }),
86
+ Args: []string{ctl.Prefix(), string(dyncfg.CommandAdd), "db"},
87
+ })
88
+ ctl.SeqExec(fn)
89
+
90
+ var payload map[string]any
91
+ mustDecodeFunctionPayload(t, out.String(), "vn-add", &payload)
92
+ assert.Equal(t, float64(202), payload["status"])
93
+ assert.Equal(t, []string{"db"}, seams.applyCalls)
94
+ cfg, ok := ctl.Lookup("db")
95
+ require.True(t, ok)
96
+ assert.Equal(t, confgroup.TypeDyncfg, cfg.SourceType)
97
+ },
98
+ },
99
+ "add no-op keeps apply seam unused": {
100
+ initial: map[string]*vnodes.VirtualNode{
101
+ "db": testVnode("db", "db", "11111111-1111-1111-1111-111111111111", confgroup.TypeDyncfg),
102
+ },
103
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
104
+ fn := dyncfg.NewFunction(functions.Function{
105
+ UID: "vn-add-noop",
106
+ ContentType: "application/json",
107
+ Payload: mustJSON(t, map[string]any{"guid": "11111111-1111-1111-1111-111111111111"}),
108
+ Args: []string{ctl.Prefix(), string(dyncfg.CommandAdd), "db"},
109
+ })
110
+ ctl.SeqExec(fn)
111
+
112
+ var payload map[string]any
113
+ mustDecodeFunctionPayload(t, out.String(), "vn-add-noop", &payload)
114
+ assert.Equal(t, float64(202), payload["status"])
115
+ assert.Empty(t, seams.applyCalls)
116
+ },
117
+ },
118
+ "add equal user vnode rewrites stored source metadata to dyncfg": {
119
+ initial: map[string]*vnodes.VirtualNode{
120
+ "db": testVnode("db", "db", "11111111-1111-1111-1111-111111111111", confgroup.TypeUser),
121
+ },
122
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
123
+ fn := dyncfg.NewFunction(functions.Function{
124
+ UID: "vn-add-promote-user",
125
+ Source: "user=alice",
126
+ ContentType: "application/json",
127
+ Payload: mustJSON(t, map[string]any{"guid": "11111111-1111-1111-1111-111111111111"}),
128
+ Args: []string{ctl.Prefix(), string(dyncfg.CommandAdd), "db"},
129
+ })
130
+ ctl.SeqExec(fn)
131
+
132
+ var payload map[string]any
133
+ mustDecodeFunctionPayload(t, out.String(), "vn-add-promote-user", &payload)
134
+ assert.Equal(t, float64(202), payload["status"])
135
+ assert.Equal(t, []string{"db"}, seams.applyCalls)
136
+
137
+ cfg, ok := ctl.Lookup("db")
138
+ require.True(t, ok)
139
+ assert.Equal(t, confgroup.TypeDyncfg, cfg.SourceType)
140
+ assert.Equal(t, "user=alice", cfg.Source)
141
+ },
142
+ },
143
+ "duplicate hostname is rejected": {
144
+ initial: map[string]*vnodes.VirtualNode{
145
+ "other": testVnode("other", "shared", "22222222-2222-2222-2222-222222222222", confgroup.TypeUser),
146
+ },
147
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
148
+ fn := dyncfg.NewFunction(functions.Function{
149
+ UID: "vn-add-dup-host",
150
+ ContentType: "application/json",
151
+ Payload: mustJSON(t, map[string]any{
152
+ "guid": "33333333-3333-3333-3333-333333333333",
153
+ "hostname": "shared",
154
+ }),
155
+ Args: []string{ctl.Prefix(), string(dyncfg.CommandAdd), "db"},
156
+ })
157
+ ctl.SeqExec(fn)
158
+
159
+ var payload map[string]any
160
+ mustDecodeFunctionPayload(t, out.String(), "vn-add-dup-host", &payload)
161
+ assert.Equal(t, float64(400), payload["status"])
162
+ assert.Contains(t, fmt.Sprint(payload["errorMessage"]), "duplicate virtual node hostname")
163
+ assert.Empty(t, seams.applyCalls)
164
+ },
165
+ },
166
+ "update applies vnode update seam": {
167
+ initial: map[string]*vnodes.VirtualNode{
168
+ "db": testVnode("db", "db", "11111111-1111-1111-1111-111111111111", confgroup.TypeDyncfg),
169
+ },
170
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
171
+ fn := dyncfg.NewFunction(functions.Function{
172
+ UID: "vn-update",
173
+ ContentType: "application/json",
174
+ Payload: mustJSON(t, map[string]any{
175
+ "guid": "11111111-1111-1111-1111-111111111111",
176
+ "labels": map[string]string{"team": "db"},
177
+ }),
178
+ Args: []string{ctl.configID("db"), string(dyncfg.CommandUpdate)},
179
+ })
180
+ ctl.SeqExec(fn)
181
+
182
+ var payload map[string]any
183
+ mustDecodeFunctionPayload(t, out.String(), "vn-update", &payload)
184
+ assert.Equal(t, float64(202), payload["status"])
185
+ assert.Equal(t, []string{"db"}, seams.applyCalls)
186
+ },
187
+ },
188
+ "update no-op keeps apply seam unused": {
189
+ initial: map[string]*vnodes.VirtualNode{
190
+ "db": testVnode("db", "db", "11111111-1111-1111-1111-111111111111", confgroup.TypeDyncfg),
191
+ },
192
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
193
+ fn := dyncfg.NewFunction(functions.Function{
194
+ UID: "vn-update-noop",
195
+ ContentType: "application/json",
196
+ Payload: mustJSON(t, map[string]any{"guid": "11111111-1111-1111-1111-111111111111"}),
197
+ Args: []string{ctl.configID("db"), string(dyncfg.CommandUpdate)},
198
+ })
199
+ ctl.SeqExec(fn)
200
+
201
+ var payload map[string]any
202
+ mustDecodeFunctionPayload(t, out.String(), "vn-update-noop", &payload)
203
+ assert.Equal(t, float64(202), payload["status"])
204
+ assert.Empty(t, seams.applyCalls)
205
+ },
206
+ },
207
+ "update equal user vnode rewrites stored source metadata to dyncfg": {
208
+ initial: map[string]*vnodes.VirtualNode{
209
+ "db": testVnode("db", "db", "11111111-1111-1111-1111-111111111111", confgroup.TypeUser),
210
+ },
211
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
212
+ fn := dyncfg.NewFunction(functions.Function{
213
+ UID: "vn-update-promote-user",
214
+ Source: "user=alice",
215
+ ContentType: "application/json",
216
+ Payload: mustJSON(t, map[string]any{"guid": "11111111-1111-1111-1111-111111111111"}),
217
+ Args: []string{ctl.configID("db"), string(dyncfg.CommandUpdate)},
218
+ })
219
+ ctl.SeqExec(fn)
220
+
221
+ var payload map[string]any
222
+ mustDecodeFunctionPayload(t, out.String(), "vn-update-promote-user", &payload)
223
+ assert.Equal(t, float64(202), payload["status"])
224
+ assert.Equal(t, []string{"db"}, seams.applyCalls)
225
+
226
+ cfg, ok := ctl.Lookup("db")
227
+ require.True(t, ok)
228
+ assert.Equal(t, confgroup.TypeDyncfg, cfg.SourceType)
229
+ assert.Equal(t, "user=alice", cfg.Source)
230
+ },
231
+ },
232
+ "update rejects duplicate hostname": {
233
+ initial: map[string]*vnodes.VirtualNode{
234
+ "db": testVnode("db", "db", "11111111-1111-1111-1111-111111111111", confgroup.TypeDyncfg),
235
+ "other": testVnode("other", "shared", "22222222-2222-2222-2222-222222222222", confgroup.TypeUser),
236
+ },
237
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
238
+ fn := dyncfg.NewFunction(functions.Function{
239
+ UID: "vn-update-dup-host",
240
+ ContentType: "application/json",
241
+ Payload: mustJSON(t, map[string]any{
242
+ "guid": "11111111-1111-1111-1111-111111111111",
243
+ "hostname": "shared",
244
+ }),
245
+ Args: []string{ctl.configID("db"), string(dyncfg.CommandUpdate)},
246
+ })
247
+ ctl.SeqExec(fn)
248
+
249
+ var payload map[string]any
250
+ mustDecodeFunctionPayload(t, out.String(), "vn-update-dup-host", &payload)
251
+ assert.Equal(t, float64(400), payload["status"])
252
+ assert.Contains(t, fmt.Sprint(payload["errorMessage"]), "duplicate virtual node hostname")
253
+ assert.Empty(t, seams.applyCalls)
254
+ },
255
+ },
256
+ "update rejects duplicate guid": {
257
+ initial: map[string]*vnodes.VirtualNode{
258
+ "db": testVnode("db", "db", "11111111-1111-1111-1111-111111111111", confgroup.TypeDyncfg),
259
+ "other": testVnode("other", "other", "22222222-2222-2222-2222-222222222222", confgroup.TypeUser),
260
+ },
261
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
262
+ fn := dyncfg.NewFunction(functions.Function{
263
+ UID: "vn-update-dup-guid",
264
+ ContentType: "application/json",
265
+ Payload: mustJSON(t, map[string]any{
266
+ "guid": "22222222-2222-2222-2222-222222222222",
267
+ }),
268
+ Args: []string{ctl.configID("db"), string(dyncfg.CommandUpdate)},
269
+ })
270
+ ctl.SeqExec(fn)
271
+
272
+ var payload map[string]any
273
+ mustDecodeFunctionPayload(t, out.String(), "vn-update-dup-guid", &payload)
274
+ assert.Equal(t, float64(400), payload["status"])
275
+ assert.Contains(t, fmt.Sprint(payload["errorMessage"]), "duplicate virtual node guid")
276
+ assert.Empty(t, seams.applyCalls)
277
+ },
278
+ },
279
+ "invalid guid is rejected": {
280
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
281
+ fn := dyncfg.NewFunction(functions.Function{
282
+ UID: "vn-invalid-guid",
283
+ ContentType: "application/json",
284
+ Payload: mustJSON(t, map[string]any{"guid": "bad-guid"}),
285
+ Args: []string{ctl.Prefix(), string(dyncfg.CommandAdd), "db"},
286
+ })
287
+ ctl.SeqExec(fn)
288
+
289
+ var payload map[string]any
290
+ mustDecodeFunctionPayload(t, out.String(), "vn-invalid-guid", &payload)
291
+ assert.Equal(t, float64(400), payload["status"])
292
+ assert.Empty(t, seams.applyCalls)
293
+ },
294
+ },
295
+ "empty vnode name is rejected": {
296
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
297
+ fn := dyncfg.NewFunction(functions.Function{
298
+ UID: "vn-empty-name",
299
+ ContentType: "application/json",
300
+ Payload: mustJSON(t, map[string]any{"guid": "11111111-1111-1111-1111-111111111111"}),
301
+ Args: []string{ctl.Prefix(), string(dyncfg.CommandAdd), ""},
302
+ })
303
+ ctl.SeqExec(fn)
304
+
305
+ var payload map[string]any
306
+ mustDecodeFunctionPayload(t, out.String(), "vn-empty-name", &payload)
307
+ assert.Equal(t, float64(400), payload["status"])
308
+ assert.Contains(t, fmt.Sprint(payload["errorMessage"]), "Missing vnode name")
309
+ assert.Empty(t, seams.applyCalls)
310
+ },
311
+ },
312
+ "remove rejects non dyncfg source": {
313
+ initial: map[string]*vnodes.VirtualNode{
314
+ "db": testVnode("db", "db", "11111111-1111-1111-1111-111111111111", confgroup.TypeUser),
315
+ },
316
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
317
+ fn := dyncfg.NewFunction(functions.Function{UID: "vn-remove-user", Args: []string{ctl.configID("db"), string(dyncfg.CommandRemove)}})
318
+ ctl.SeqExec(fn)
319
+
320
+ var payload map[string]any
321
+ mustDecodeFunctionPayload(t, out.String(), "vn-remove-user", &payload)
322
+ assert.Equal(t, float64(405), payload["status"])
323
+ assert.Empty(t, seams.affectedJobsCalls)
324
+ },
325
+ },
326
+ "remove uses affected jobs seam": {
327
+ initial: map[string]*vnodes.VirtualNode{
328
+ "db": testVnode("db", "db", "11111111-1111-1111-1111-111111111111", confgroup.TypeDyncfg),
329
+ },
330
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
331
+ seams.affectedJobs["db"] = []string{"mysql:prod"}
332
+
333
+ fn := dyncfg.NewFunction(functions.Function{UID: "vn-remove-running", Args: []string{ctl.configID("db"), string(dyncfg.CommandRemove)}})
334
+ ctl.SeqExec(fn)
335
+
336
+ var payload map[string]any
337
+ mustDecodeFunctionPayload(t, out.String(), "vn-remove-running", &payload)
338
+ assert.Equal(t, float64(409), payload["status"])
339
+ assert.Contains(t, fmt.Sprint(payload["errorMessage"]), "referenced by configs")
340
+ assert.Equal(t, []string{"db"}, seams.affectedJobsCalls)
341
+ _, ok := ctl.Lookup("db")
342
+ assert.True(t, ok)
343
+ },
344
+ },
345
+ "test preview uses affected jobs seam": {
346
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
347
+ seams.affectedJobs["db"] = []string{"mysql:prod"}
348
+
349
+ fn := dyncfg.NewFunction(functions.Function{
350
+ UID: "vn-test",
351
+ ContentType: "application/json",
352
+ Payload: mustJSON(t, map[string]any{"guid": "11111111-1111-1111-1111-111111111111"}),
353
+ Args: []string{ctl.Prefix(), string(dyncfg.CommandTest), "db"},
354
+ })
355
+ ctl.SeqExec(fn)
356
+
357
+ var payload map[string]any
358
+ mustDecodeFunctionPayload(t, out.String(), "vn-test", &payload)
359
+ assert.Equal(t, float64(202), payload["status"])
360
+ assert.Equal(t, "Updated configuration will affect configs: mysql:prod.", payload["message"])
361
+ assert.Equal(t, []string{"db"}, seams.affectedJobsCalls)
362
+ assert.Empty(t, seams.applyCalls)
363
+ },
364
+ },
365
+ }
366
+
367
+ for name, tc := range tests {
368
+ t.Run(name, func(t *testing.T) {
369
+ ctl, out, seams := newControllerTestSubject(tc.initial)
370
+ tc.run(t, ctl, out, seams)
371
+ })
372
+ }
373
+}
374
+
375
+func TestControllerPublicationAndLookup(t *testing.T) {
376
+ tests := map[string]struct {
377
+ run func(t *testing.T, ctl *Controller, out *bytes.Buffer)
378
+ }{
379
+ "create module and publish existing": {
380
+ run: func(t *testing.T, ctl *Controller, out *bytes.Buffer) {
381
+ ctl.CreateTemplates()
382
+ ctl.PublishExisting(dyncfg.StatusRunning)
383
+
384
+ assert.Contains(t, out.String(), "CONFIG test:vnode create accepted template /collectors/test/Vnodes")
385
+ assert.Contains(t, out.String(), "CONFIG test:vnode:db create running job /collectors/test/Vnodes")
386
+
387
+ cfg, ok := ctl.Lookup("db")
388
+ require.True(t, ok)
389
+ assert.Equal(t, "db", cfg.Name)
390
+ },
391
+ },
392
+ }
393
+
394
+ for name, tc := range tests {
395
+ t.Run(name, func(t *testing.T) {
396
+ ctl, out, _ := newControllerTestSubject(map[string]*vnodes.VirtualNode{
397
+ "db": testVnode("db", "db", "11111111-1111-1111-1111-111111111111", confgroup.TypeDyncfg),
398
+ })
399
+ tc.run(t, ctl, out)
400
+ })
401
+ }
402
+}
403
+
404
+func TestControllerSetAPI_NilPreservesResponder(t *testing.T) {
405
+ tests := map[string]struct {
406
+ uid string
407
+ }{
408
+ "nil SetAPI keeps existing responder for schema responses": {
409
+ uid: "vn-schema-nil-rebind",
410
+ },
411
+ }
412
+
413
+ for name, tc := range tests {
414
+ t.Run(name, func(t *testing.T) {
415
+ ctl, out, _ := newControllerTestSubject(nil)
416
+ ctl.SetAPI(nil)
417
+
418
+ fn := dyncfg.NewFunction(functions.Function{
419
+ UID: tc.uid,
420
+ Args: []string{ctl.Prefix(), string(dyncfg.CommandSchema)},
421
+ })
422
+ ctl.SeqExec(fn)
423
+
424
+ assert.Contains(t, out.String(), "FUNCTION_RESULT_BEGIN "+tc.uid+" 200 application/json")
425
+ })
426
+ }
427
+}
428
+
429
+type controllerSeams struct {
430
+ affectedJobs map[string][]string
431
+ affectedJobsCalls []string
432
+ applyCalls []string
433
+}
434
+
435
+func newControllerTestSubject(initial map[string]*vnodes.VirtualNode) (*Controller, *bytes.Buffer, *controllerSeams) {
436
+ var out bytes.Buffer
437
+ seams := &controllerSeams{affectedJobs: make(map[string][]string)}
438
+ ctl := New(Options{
439
+ Logger: logger.New(),
440
+ API: dyncfg.NewResponder(netdataapi.New(safewriter.New(&out))),
441
+ Plugin: testPluginName,
442
+ Initial: initial,
443
+ AffectedJobs: func(vnode string) []string {
444
+ seams.affectedJobsCalls = append(seams.affectedJobsCalls, vnode)
445
+ return seams.affectedJobs[vnode]
446
+ },
447
+ ApplyVnodeUpdate: func(name string, _ *vnodes.VirtualNode) {
448
+ seams.applyCalls = append(seams.applyCalls, name)
449
+ },
450
+ })
451
+ return ctl, &out, seams
452
+}
453
+
454
+func testVnode(name, hostname, guid, sourceType string) *vnodes.VirtualNode {
455
+ return &vnodes.VirtualNode{Name: name, Hostname: hostname, GUID: guid, Source: sourceType, SourceType: sourceType}
456
+}
457
+
458
+func mustJSON(t *testing.T, v any) []byte {
459
+ t.Helper()
460
+ bs, err := json.Marshal(v)
461
+ require.NoError(t, err)
462
+ return bs
463
+}
464
+
465
+func mustFunctionBody(t *testing.T, output, uid string) string {
466
+ t.Helper()
467
+ re := regexp.MustCompile("(?s)FUNCTION_RESULT_BEGIN " + regexp.QuoteMeta(uid) + " [^\\n]+\\n(.*?)\\nFUNCTION_RESULT_END")
468
+ match := re.FindStringSubmatch(output)
469
+ require.Len(t, match, 2, "function result for uid '%s' not found in output:\n%s", uid, output)
470
+ return match[1]
471
+}
472
+
473
+func mustDecodeFunctionPayload(t *testing.T, output, uid string, dst any) {
474
+ t.Helper()
475
+ require.NoError(t, json.Unmarshal([]byte(mustFunctionBody(t, output, uid)), dst))
476
+}
src/go/plugin/agent/jobmgr/vnodectl/doc.go
new
+5
@@ -0,0 +1,5 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+// Package vnodectl owns vnode dyncfg control-plane logic and vnode store ownership.
4
+// Routing, handoff, and runtime coordination remain in jobmgr.
5
+package vnodectl
src/go/plugin/agent/jobmgr/vnodectl/dyncfg.go
new
+323
@@ -0,0 +1,323 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package vnodectl
4
+
5
+import (
6
+ "encoding/json"
7
+ "fmt"
8
+ "strings"
9
+
10
+ "github.com/google/uuid"
11
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
12
+ "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
13
+ "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
14
+ "gopkg.in/yaml.v2"
15
+)
16
+
17
+func dyncfgVnodeModCmds() string {
18
+ return dyncfg.JoinCommands(
19
+ dyncfg.CommandAdd,
20
+ dyncfg.CommandSchema,
21
+ dyncfg.CommandUserconfig,
22
+ dyncfg.CommandTest,
23
+ )
24
+}
25
+
26
+func dyncfgVnodeJobCmds(isDyncfgJob bool) string {
27
+ cmds := []dyncfg.Command{
28
+ dyncfg.CommandUserconfig,
29
+ dyncfg.CommandSchema,
30
+ dyncfg.CommandGet,
31
+ dyncfg.CommandUpdate,
32
+ dyncfg.CommandTest,
33
+ }
34
+ if isDyncfgJob {
35
+ cmds = append(cmds, dyncfg.CommandRemove)
36
+ }
37
+ return dyncfg.JoinCommands(cmds...)
38
+}
39
+
40
+func (c *Controller) SeqExec(fn dyncfg.Function) {
41
+ switch fn.Command() {
42
+ case dyncfg.CommandSchema:
43
+ c.dyncfgCmdSchema(fn)
44
+ case dyncfg.CommandUserconfig:
45
+ c.dyncfgCmdUserconfig(fn)
46
+ case dyncfg.CommandGet:
47
+ c.dyncfgCmdGet(fn)
48
+ case dyncfg.CommandAdd:
49
+ c.dyncfgCmdAdd(fn)
50
+ case dyncfg.CommandUpdate:
51
+ c.dyncfgCmdUpdate(fn)
52
+ case dyncfg.CommandRemove:
53
+ c.dyncfgCmdRemove(fn)
54
+ case dyncfg.CommandTest:
55
+ c.dyncfgCmdTest(fn)
56
+ default:
57
+ c.Warningf("dyncfg: function '%s' command '%s' not implemented", fn.Fn().Name, fn.Command())
58
+ c.api.SendCodef(fn, 501, "Function '%s' command '%s' is not implemented.", fn.Fn().Name, fn.Command())
59
+ }
60
+}
61
+
62
+func (c *Controller) dyncfgCmdSchema(fn dyncfg.Function) {
63
+ c.api.SendJSON(fn, vnodes.ConfigSchema)
64
+}
65
+
66
+func (c *Controller) dyncfgCmdUserconfig(fn dyncfg.Function) {
67
+ bs, err := dyncfgVnodeUserconfigFromPayload(fn)
68
+ if err != nil {
69
+ c.Warningf("dyncfg: %s: vnode: failed to create config from payload: %v", dyncfg.CommandUserconfig, err)
70
+ c.api.SendCodef(fn, 400, "Invalid configuration format. Failed to create configuration from payload: %v.", err)
71
+ return
72
+ }
73
+
74
+ c.api.SendYAML(fn, string(bs))
75
+}
76
+
77
+func (c *Controller) dyncfgCmdGet(fn dyncfg.Function) {
78
+ name := strings.TrimPrefix(fn.ID(), c.Prefix()+":")
79
+ cfg, ok := c.Lookup(name)
80
+ if !ok {
81
+ c.Warningf("dyncfg: %s: vnode %s not found", dyncfg.CommandGet, name)
82
+ c.api.SendCodef(fn, 404, "The specified vnode '%s' is not registered.", name)
83
+ return
84
+ }
85
+
86
+ bs, err := json.Marshal(cfg)
87
+ if err != nil {
88
+ c.Warningf("dyncfg: %s: vnode job %s failed to json marshal config: %v", dyncfg.CommandGet, name, err)
89
+ c.api.SendCodef(fn, 500, "Failed to convert configuration into JSON: %v.", err)
90
+ return
91
+ }
92
+
93
+ c.api.SendJSON(fn, string(bs))
94
+}
95
+
96
+func (c *Controller) dyncfgCmdAdd(fn dyncfg.Function) {
97
+ if err := fn.ValidateArgs(3); err != nil {
98
+ c.Warningf("dyncfg: %s: %v", dyncfg.CommandAdd, err)
99
+ c.api.SendCodef(fn, 400, "%v", err)
100
+ return
101
+ }
102
+ if !fn.HasPayload() {
103
+ c.Warningf("dyncfg: %s: vnode job %s missing configuration payload.", dyncfg.CommandAdd, fn.JobName())
104
+ c.api.SendCodef(fn, 400, "Missing configuration payload.")
105
+ return
106
+ }
107
+
108
+ name := fn.JobName()
109
+ if name == "" {
110
+ c.Warningf("dyncfg: %s: missing vnode name", dyncfg.CommandAdd)
111
+ c.api.SendCodef(fn, 400, "Missing vnode name.")
112
+ return
113
+ }
114
+ if err := dyncfg.ValidateJobName(name); err != nil {
115
+ c.Warningf("dyncfg: %s: unacceptable vnode name '%s': %v", dyncfg.CommandAdd, name, err)
116
+ c.api.SendCodef(fn, 400, "Unacceptable vnode name '%s': %v.", name, err)
117
+ return
118
+ }
119
+ cfg, err := dyncfgVnodeConfigFromPayload(fn)
120
+ if err != nil {
121
+ c.Warningf("dyncfg: %s: vnode job %s: failed to create config from payload: %v", dyncfg.CommandAdd, name, err)
122
+ c.api.SendCodef(fn, 400, "Failed to create configuration from payload. Invalid configuration format: %v.", err)
123
+ return
124
+ }
125
+ if err := uuid.Validate(cfg.GUID); err != nil {
126
+ c.Warningf("dyncfg: %s: vnode job %s: invalid guid: %v", dyncfg.CommandAdd, name, err)
127
+ c.api.SendCodef(fn, 400, "Failed to create configuration from payload. Invalid guid format: %v.", err)
128
+ return
129
+ }
130
+
131
+ dyncfgUpdateVnodeConfig(cfg, name, fn)
132
+ if err := c.verifyVnodeUnique(cfg); err != nil {
133
+ c.Warningf("dyncfg: %s: vnode job %s: %v", dyncfg.CommandAdd, name, err)
134
+ c.api.SendCodef(fn, 400, "Failed to create configuration from payload: %v.", err)
135
+ return
136
+ }
137
+
138
+ if orig, ok := c.Lookup(name); ok && sameStoredVnode(orig, cfg) {
139
+ c.api.SendCodef(fn, 202, "")
140
+ c.createJob(cfg, dyncfg.StatusRunning)
141
+ return
142
+ }
143
+
144
+ if _, err := c.store.Upsert(cfg); err != nil {
145
+ c.Warningf("dyncfg: %s: vnode job %s: %v", dyncfg.CommandAdd, name, err)
146
+ c.api.SendCodef(fn, 400, "Failed to update vnode configuration: %v.", err)
147
+ return
148
+ }
149
+
150
+ c.applyUpdate(name, cfg)
151
+ c.api.SendCodef(fn, 202, "")
152
+ c.createJob(cfg, dyncfg.StatusRunning)
153
+}
154
+
155
+func (c *Controller) dyncfgCmdUpdate(fn dyncfg.Function) {
156
+ name := strings.TrimPrefix(fn.ID(), c.Prefix()+":")
157
+ orig, ok := c.Lookup(name)
158
+ if !ok {
159
+ c.Warningf("dyncfg: %s: vnode %s not found", dyncfg.CommandUpdate, name)
160
+ c.api.SendCodef(fn, 404, "The specified vnode '%s' is not registered.", name)
161
+ return
162
+ }
163
+
164
+ cfg, err := dyncfgVnodeConfigFromPayload(fn)
165
+ if err != nil {
166
+ c.Warningf("dyncfg: %s: vnode: failed to create config from payload: %v", dyncfg.CommandUpdate, err)
167
+ c.api.SendCodef(fn, 400, "Invalid configuration format. Failed to create configuration from payload: %v.", err)
168
+ return
169
+ }
170
+ if err := uuid.Validate(cfg.GUID); err != nil {
171
+ c.Warningf("dyncfg: %s: vnode job %s: invalid guid: %v", dyncfg.CommandUpdate, name, err)
172
+ c.api.SendCodef(fn, 400, "Failed to create configuration from payload. Invalid guid format: %v.", err)
173
+ return
174
+ }
175
+
176
+ dyncfgUpdateVnodeConfig(cfg, name, fn)
177
+ if err := c.verifyVnodeUnique(cfg); err != nil {
178
+ c.Warningf("dyncfg: %s: vnode job %s: %v", dyncfg.CommandUpdate, name, err)
179
+ c.api.SendCodef(fn, 400, "Failed to create configuration from payload: %v.", err)
180
+ return
181
+ }
182
+ if sameStoredVnode(orig, cfg) {
183
+ c.api.SendCodef(fn, 202, "")
184
+ return
185
+ }
186
+
187
+ if _, err := c.store.Upsert(cfg); err != nil {
188
+ c.Warningf("dyncfg: %s: vnode job %s: %v", dyncfg.CommandUpdate, name, err)
189
+ c.api.SendCodef(fn, 400, "Failed to update vnode configuration: %v.", err)
190
+ return
191
+ }
192
+
193
+ c.applyUpdate(name, cfg)
194
+ c.api.SendCodef(fn, 202, "")
195
+ c.createJob(cfg, dyncfg.StatusRunning)
196
+}
197
+
198
+func (c *Controller) dyncfgCmdRemove(fn dyncfg.Function) {
199
+ name := strings.TrimPrefix(fn.ID(), c.Prefix()+":")
200
+ vnode, ok := c.Lookup(name)
201
+ if !ok {
202
+ c.Warningf("dyncfg: %s: vnode %s not found", dyncfg.CommandRemove, name)
203
+ c.api.SendCodef(fn, 404, "The specified vnode '%s' is not registered.", name)
204
+ return
205
+ }
206
+ if vnode.SourceType != confgroup.TypeDyncfg {
207
+ c.Warningf("dyncfg: %s: module vnode %s: can not remove vnode of type %s", dyncfg.CommandRemove, vnode.Name, vnode.SourceType)
208
+ c.api.SendCodef(fn, 405, "Removing vnode of type '%s' is not supported. Only 'dyncfg' vnodes can be removed.", vnode.SourceType)
209
+ return
210
+ }
211
+
212
+ if affected := c.affectedJobsFor(vnode.Name); affected != "" {
213
+ c.Warningf("dyncfg: %s: vnode %s is referenced by configs (%s)", dyncfg.CommandRemove, name, affected)
214
+ c.api.SendCodef(fn, 409, "The specified vnode '%s' is referenced by configs (%s).", name, affected)
215
+ return
216
+ }
217
+
218
+ c.store.Remove(name)
219
+ c.api.ConfigDelete(fn.ID())
220
+ c.api.SendCodef(fn, 200, "")
221
+}
222
+
223
+func (c *Controller) dyncfgCmdTest(fn dyncfg.Function) {
224
+ if err := fn.ValidateArgs(3); err != nil {
225
+ c.Warningf("dyncfg: %s: %v", dyncfg.CommandTest, err)
226
+ c.api.SendCodef(fn, 400, "%v", err)
227
+ return
228
+ }
229
+
230
+ name := fn.JobName()
231
+ cfg, err := dyncfgVnodeConfigFromPayload(fn)
232
+ if err != nil {
233
+ c.Warningf("dyncfg: %s: vnode: failed to create config from payload: %v", dyncfg.CommandTest, err)
234
+ c.api.SendCodef(fn, 400, "Invalid configuration format. Failed to create configuration from payload: %v.", err)
235
+ return
236
+ }
237
+ if err := uuid.Validate(cfg.GUID); err != nil {
238
+ c.Warningf("dyncfg: %s: vnode job %s: invalid guid: %v", dyncfg.CommandTest, name, err)
239
+ c.api.SendCodef(fn, 400, "Failed to create configuration from payload. Invalid guid format: %v.", err)
240
+ return
241
+ }
242
+
243
+ dyncfgUpdateVnodeConfig(cfg, name, fn)
244
+ if err := c.verifyVnodeUnique(cfg); err != nil {
245
+ c.Warningf("dyncfg: %s: vnode job %s: %v", dyncfg.CommandTest, name, err)
246
+ c.api.SendCodef(fn, 400, "Failed to create configuration from payload: %v.", err)
247
+ return
248
+ }
249
+
250
+ if affected := c.affectedJobsFor(cfg.Name); affected != "" {
251
+ c.api.SendCodef(fn, 202, "Updated configuration will affect configs: %s.", affected)
252
+ return
253
+ }
254
+ c.api.SendCodef(fn, 202, "No configs will be affected by this change.")
255
+}
256
+
257
+func (c *Controller) affectedJobsFor(vnode string) string {
258
+ if c.affectedJobs == nil {
259
+ return ""
260
+ }
261
+ return strings.Join(c.affectedJobs(vnode), ", ")
262
+}
263
+
264
+func (c *Controller) applyUpdate(name string, cfg *vnodes.VirtualNode) {
265
+ if c.applyVnodeUpdate != nil {
266
+ c.applyVnodeUpdate(name, cfg)
267
+ }
268
+}
269
+
270
+func (c *Controller) verifyVnodeUnique(newCfg *vnodes.VirtualNode) error {
271
+ var err error
272
+ c.store.ForEach(func(cfg *vnodes.VirtualNode) bool {
273
+ if cfg.Name == newCfg.Name {
274
+ return true
275
+ }
276
+ if cfg.Hostname == newCfg.Hostname {
277
+ err = fmt.Errorf("duplicate virtual node hostname detected (job '%s')", cfg.Name)
278
+ return false
279
+ }
280
+ if cfg.GUID == newCfg.GUID {
281
+ err = fmt.Errorf("duplicate virtual node guid detected (job '%s')", cfg.Name)
282
+ return false
283
+ }
284
+ return true
285
+ })
286
+ return err
287
+}
288
+
289
+func dyncfgUpdateVnodeConfig(cfg *vnodes.VirtualNode, name string, fn dyncfg.Function) {
290
+ cfg.SourceType = confgroup.TypeDyncfg
291
+ cfg.Source = fn.Source()
292
+ cfg.Name = name
293
+ if cfg.Hostname == "" {
294
+ cfg.Hostname = name
295
+ }
296
+}
297
+
298
+func dyncfgVnodeConfigFromPayload(fn dyncfg.Function) (*vnodes.VirtualNode, error) {
299
+ var cfg vnodes.VirtualNode
300
+ if err := fn.UnmarshalPayload(&cfg); err != nil {
301
+ return nil, err
302
+ }
303
+ return &cfg, nil
304
+}
305
+
306
+func dyncfgVnodeUserconfigFromPayload(fn dyncfg.Function) ([]byte, error) {
307
+ cfg, err := dyncfgVnodeConfigFromPayload(fn)
308
+ if err != nil {
309
+ return nil, err
310
+ }
311
+
312
+ name := fn.JobName()
313
+ if name == "" {
314
+ name = "test"
315
+ }
316
+ dyncfgUpdateVnodeConfig(cfg, name, fn)
317
+
318
+ bs, err := yaml.Marshal([]any{cfg})
319
+ if err != nil {
320
+ return nil, err
321
+ }
322
+ return bs, nil
323
+}
src/go/plugin/agent/jobmgr/vnodectl/store.go
renamed
+19
-11
@@ -1,6 +1,6 @@
1
// SPDX-License-Identifier: GPL-3.0-or-later
2
3
-package jobmgr
3
+package vnodectl
4
5
import (
6
"fmt"
@@ -8,8 +8,8 @@ import (
8
"github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
9
)
10
11
-// vnodeStore owns manager vnode state. It is intentionally lock-free because
12
-// mutations are serialized by Manager.run and startup initialization.
11
+// vnodeStore is intentionally lock-free because mutations are serialized by jobmgr.
12
+// Any caller outside that serialized path must add its own synchronization first.
13
type vnodeStore struct {
14
items map[string]*vnodes.VirtualNode
15
}
@@ -26,23 +26,23 @@ func (s *vnodeStore) Lookup(name string) (*vnodes.VirtualNode, bool) {
26
return cfg, ok
27
}
28
29
-func (s *vnodeStore) Upsert(cfg *vnodes.VirtualNode) (changed bool, affectedJobNames []string, err error) {
29
+func (s *vnodeStore) Upsert(cfg *vnodes.VirtualNode) (bool, error) {
30
if cfg == nil {
31
- return false, nil, fmt.Errorf("nil vnode config")
31
+ return false, fmt.Errorf("nil vnode config")
32
}
33
- if orig, ok := s.items[cfg.Name]; ok && orig.Equal(cfg) {
34
- return false, nil, nil
33
+ if orig, ok := s.items[cfg.Name]; ok && sameStoredVnode(orig, cfg) {
34
+ return false, nil
35
}
36
s.items[cfg.Name] = cfg
37
- return true, nil, nil
37
+ return true, nil
38
}
39
40
-func (s *vnodeStore) Remove(name string) (removed bool, err error) {
40
+func (s *vnodeStore) Remove(name string) bool {
41
if _, ok := s.items[name]; !ok {
42
- return false, nil
42
+ return false
43
}
44
delete(s.items, name)
45
- return true, nil
45
+ return true
46
}
47
48
func (s *vnodeStore) ForEach(fn func(cfg *vnodes.VirtualNode) bool) {
@@ -52,3 +52,11 @@ func (s *vnodeStore) ForEach(fn func(cfg *vnodes.VirtualNode) bool) {
52
}
53
}
54
}
55
+
56
+func sameStoredVnode(orig, next *vnodes.VirtualNode) bool {
57
+ if orig == nil || next == nil {
58
+ return orig == next
59
+ }
60
+ return orig.Equal(next) &&
61
+ orig.SourceType == next.SourceType
62
+}
src/go/plugin/agent/secrets/resolver/cmd.go
new
+46
@@ -0,0 +1,46 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretresolver
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "os/exec"
11
+ "path/filepath"
12
+ "strings"
13
+ "time"
14
+)
15
+
16
+func (r *Resolver) resolveCmd(ctx context.Context, cmdLine, original string) (string, error) {
17
+ parts := strings.Fields(cmdLine)
18
+ if len(parts) == 0 {
19
+ return "", fmt.Errorf("resolving secret '%s': empty command", original)
20
+ }
21
+ if !filepath.IsAbs(parts[0]) {
22
+ return "", fmt.Errorf("resolving secret '%s': command path must be absolute, got '%s'", original, parts[0])
23
+ }
24
+
25
+ if ctx == nil {
26
+ ctx = context.Background()
27
+ }
28
+ timeout := r.cmdTimeout
29
+ if timeout <= 0 {
30
+ timeout = 10 * time.Second
31
+ }
32
+ ctx, cancel := context.WithTimeout(ctx, timeout)
33
+ defer cancel()
34
+
35
+ cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
36
+ cmd.Stderr = io.Discard
37
+ out, err := cmd.Output()
38
+ if err != nil {
39
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
40
+ return "", fmt.Errorf("resolving secret '%s': command timed out after %s", original, timeout)
41
+ }
42
+ return "", fmt.Errorf("resolving secret '%s': command failed: %w", original, err)
43
+ }
44
+
45
+ return strings.TrimSpace(string(out)), nil
46
+}
src/go/plugin/agent/secrets/resolver/cmd_test.go
new
+98
@@ -0,0 +1,98 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretresolver
4
+
5
+import (
6
+ "os"
7
+ "path/filepath"
8
+ "runtime"
9
+ "testing"
10
+
11
+ "github.com/stretchr/testify/assert"
12
+ "github.com/stretchr/testify/require"
13
+)
14
+
15
+func TestResolveCmd(t *testing.T) {
16
+ tests := map[string]struct {
17
+ onWindowsSkip bool
18
+ buildCfg func(t *testing.T) map[string]any
19
+ wantErrContains string
20
+ wantValue string
21
+ field string
22
+ }{
23
+ "success": {
24
+ onWindowsSkip: true,
25
+ buildCfg: func(t *testing.T) map[string]any {
26
+ return map[string]any{"password": "${cmd:/bin/echo hello}"}
27
+ },
28
+ field: "password",
29
+ wantValue: "hello",
30
+ },
31
+ "trims output": {
32
+ onWindowsSkip: true,
33
+ buildCfg: func(t *testing.T) map[string]any {
34
+ return map[string]any{"val": "${cmd:/bin/echo secretval}"}
35
+ },
36
+ field: "val",
37
+ wantValue: "secretval",
38
+ },
39
+ "relative path rejected": {
40
+ buildCfg: func(t *testing.T) map[string]any {
41
+ return map[string]any{"val": "${cmd:echo hello}"}
42
+ },
43
+ wantErrContains: "command path must be absolute",
44
+ },
45
+ "empty command": {
46
+ buildCfg: func(t *testing.T) map[string]any {
47
+ return map[string]any{"val": "${cmd:}"}
48
+ },
49
+ wantErrContains: "empty command",
50
+ },
51
+ "nonexistent command": {
52
+ buildCfg: func(t *testing.T) map[string]any {
53
+ return map[string]any{"val": "${cmd:/nonexistent/command}"}
54
+ },
55
+ wantErrContains: "command failed",
56
+ },
57
+ "command with args": {
58
+ onWindowsSkip: true,
59
+ buildCfg: func(t *testing.T) map[string]any {
60
+ return map[string]any{"val": "${cmd:/usr/bin/printf %s secret}"}
61
+ },
62
+ field: "val",
63
+ wantValue: "secret",
64
+ },
65
+ "script file": {
66
+ onWindowsSkip: true,
67
+ buildCfg: func(t *testing.T) map[string]any {
68
+ dir := t.TempDir()
69
+ script := filepath.Join(dir, "secret.sh")
70
+ require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\necho mysecret\n"), 0700))
71
+ return map[string]any{"val": "${cmd:" + script + "}"}
72
+ },
73
+ field: "val",
74
+ wantValue: "mysecret",
75
+ },
76
+ }
77
+
78
+ for name, tc := range tests {
79
+ t.Run(name, func(t *testing.T) {
80
+ if tc.onWindowsSkip && runtime.GOOS == "windows" {
81
+ t.Skip("skipping on windows")
82
+ }
83
+
84
+ resolver := New()
85
+ cfg := tc.buildCfg(t)
86
+ err := resolver.Resolve(cfg)
87
+
88
+ if tc.wantErrContains != "" {
89
+ require.Error(t, err)
90
+ assert.Contains(t, err.Error(), tc.wantErrContains)
91
+ return
92
+ }
93
+
94
+ require.NoError(t, err)
95
+ assert.Equal(t, tc.wantValue, cfg[tc.field])
96
+ })
97
+ }
98
+}
src/go/plugin/agent/secrets/resolver/env.go
new
+18
@@ -0,0 +1,18 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretresolver
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "os"
9
+ "strings"
10
+)
11
+
12
+func (r *Resolver) resolveEnv(_ 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
18
+}
src/go/plugin/agent/secrets/resolver/file.go
new
+22
@@ -0,0 +1,22 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretresolver
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+)
12
+
13
+func (r *Resolver) resolveFile(_ 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
+ }
17
+ data, err := os.ReadFile(path)
18
+ if err != nil {
19
+ return "", fmt.Errorf("resolving secret '%s': %w", original, err)
20
+ }
21
+ return strings.TrimSpace(string(data)), nil
22
+}
src/go/plugin/agent/secrets/resolver/resolver.go
new
+143
@@ -0,0 +1,143 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretresolver
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "regexp"
9
+ "strings"
10
+)
11
+
12
+type StoreRefResolver func(ctx context.Context, ref, original string) (string, error)
13
+
14
+// reSecretRef matches ${...} references in strings.
15
+var reSecretRef = regexp.MustCompile(`\$\{([^}]+)\}`)
16
+
17
+// Resolve walks the config map and resolves all secret references in string values.
18
+func (r *Resolver) Resolve(cfg map[string]any) error {
19
+ return r.ResolveWithStoreResolver(context.Background(), cfg, nil)
20
+}
21
+
22
+func (r *Resolver) ResolveWithStoreResolver(ctx context.Context, cfg map[string]any, storeResolver StoreRefResolver) error {
23
+ if r == nil {
24
+ return fmt.Errorf("secret resolver is nil")
25
+ }
26
+ if ctx == nil {
27
+ ctx = context.Background()
28
+ }
29
+ r.ensureDefaults()
30
+ return r.resolveMap(ctx, cfg, storeResolver)
31
+}
32
+
33
+func (r *Resolver) resolveMap(ctx context.Context, m map[string]any, storeResolver StoreRefResolver) error {
34
+ for k, v := range m {
35
+ // skip internal keys (__key__)
36
+ if isInternalKey(k) {
37
+ continue
38
+ }
39
+ resolved, err := r.resolveValue(ctx, v, storeResolver)
40
+ if err != nil {
41
+ return err
42
+ }
43
+ m[k] = resolved
44
+ }
45
+ return nil
46
+}
47
+
48
+func (r *Resolver) resolveMapAny(ctx context.Context, m map[any]any, storeResolver StoreRefResolver) error {
49
+ for k, v := range m {
50
+ s, ok := k.(string)
51
+ if ok && isInternalKey(s) {
52
+ continue
53
+ }
54
+ resolved, err := r.resolveValue(ctx, v, storeResolver)
55
+ if err != nil {
56
+ return err
57
+ }
58
+ m[k] = resolved
59
+ }
60
+ return nil
61
+}
62
+
63
+func (r *Resolver) resolveSlice(ctx context.Context, s []any, storeResolver StoreRefResolver) error {
64
+ for i, v := range s {
65
+ resolved, err := r.resolveValue(ctx, v, storeResolver)
66
+ if err != nil {
67
+ return err
68
+ }
69
+ s[i] = resolved
70
+ }
71
+ return nil
72
+}
73
+
74
+func (r *Resolver) resolveValue(ctx context.Context, v any, storeResolver StoreRefResolver) (any, error) {
75
+ switch val := v.(type) {
76
+ case string:
77
+ return r.resolveString(ctx, val, storeResolver)
78
+ case map[string]any:
79
+ return val, r.resolveMap(ctx, val, storeResolver)
80
+ case map[any]any:
81
+ return val, r.resolveMapAny(ctx, val, storeResolver)
82
+ case []any:
83
+ return val, r.resolveSlice(ctx, val, storeResolver)
84
+ default:
85
+ return v, nil
86
+ }
87
+}
88
+
89
+func (r *Resolver) resolveString(ctx context.Context, s string, storeResolver StoreRefResolver) (string, error) {
90
+ if !strings.Contains(s, "${") {
91
+ return s, nil
92
+ }
93
+
94
+ var resolveErr error
95
+
96
+ result := reSecretRef.ReplaceAllStringFunc(s, func(match string) string {
97
+ if resolveErr != nil {
98
+ return match
99
+ }
100
+
101
+ // extract inner content between ${ and }
102
+ inner := match[2 : len(match)-1]
103
+
104
+ val, err := r.resolveRef(ctx, inner, match, storeResolver)
105
+ if err != nil {
106
+ resolveErr = err
107
+ return match
108
+ }
109
+ return val
110
+ })
111
+
112
+ if resolveErr != nil {
113
+ return "", resolveErr
114
+ }
115
+ return result, nil
116
+}
117
+
118
+func (r *Resolver) resolveRef(ctx context.Context, ref, original string, storeResolver StoreRefResolver) (string, error) {
119
+ scheme, name, hasScheme := strings.Cut(ref, ":")
120
+
121
+ if !hasScheme {
122
+ // no scheme — leave unchanged
123
+ return original, nil
124
+ }
125
+
126
+ if scheme == "store" {
127
+ if storeResolver == nil {
128
+ return "", fmt.Errorf("resolving secret '%s': secretstore resolver is not configured", original)
129
+ }
130
+ return storeResolver(ctx, name, original)
131
+ }
132
+
133
+ provider, ok := r.providers[scheme]
134
+ if !ok {
135
+ return "", fmt.Errorf("resolving secret '%s': unknown secret provider '%s'", original, scheme)
136
+ }
137
+
138
+ return provider(ctx, name, original)
139
+}
140
+
141
+func isInternalKey(k string) bool {
142
+ return strings.HasPrefix(k, "__") && strings.HasSuffix(k, "__")
143
+}
src/go/plugin/agent/secrets/resolver/resolver_object.go
new
+36
@@ -0,0 +1,36 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretresolver
4
+
5
+import (
6
+ "context"
7
+ "time"
8
+)
9
+
10
+// Resolver resolves secret references in config maps.
11
+// In v2 it only owns local builtin resolvers and the optional secretstore callback.
12
+type Resolver struct {
13
+ cmdTimeout time.Duration
14
+
15
+ providers map[string]func(context.Context, string, string) (string, error)
16
+}
17
+
18
+// New creates a resolver with local builtin provider defaults.
19
+func New() *Resolver {
20
+ r := &Resolver{}
21
+ r.ensureDefaults()
22
+ return r
23
+}
24
+
25
+func (r *Resolver) ensureDefaults() {
26
+ if r.cmdTimeout <= 0 {
27
+ r.cmdTimeout = 10 * time.Second
28
+ }
29
+ if r.providers == nil {
30
+ r.providers = map[string]func(context.Context, string, string) (string, error){
31
+ "env": r.resolveEnv,
32
+ "file": r.resolveFile,
33
+ "cmd": r.resolveCmd,
34
+ }
35
+ }
36
+}
src/go/plugin/agent/secrets/resolver/resolver_test.go
new
+433
@@ -0,0 +1,433 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretresolver
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "os"
9
+ "path/filepath"
10
+ "testing"
11
+
12
+ "github.com/stretchr/testify/assert"
13
+ "github.com/stretchr/testify/require"
14
+)
15
+
16
+func TestResolve(t *testing.T) {
17
+ tests := map[string]struct {
18
+ buildCfg func(t *testing.T) map[string]any
19
+ wantErrContains string
20
+ assertCfg func(t *testing.T, cfg map[string]any)
21
+ }{
22
+ "env ref": {
23
+ buildCfg: func(t *testing.T) map[string]any {
24
+ t.Setenv("TEST_SECRET_USER", "admin")
25
+ return map[string]any{"username": "${env:TEST_SECRET_USER}"}
26
+ },
27
+ assertCfg: func(t *testing.T, cfg map[string]any) {
28
+ assert.Equal(t, "admin", cfg["username"])
29
+ },
30
+ },
31
+ "env ref trims surrounding whitespace": {
32
+ buildCfg: func(t *testing.T) map[string]any {
33
+ t.Setenv("TEST_SECRET_TRIMMED", " admin \n")
34
+ return map[string]any{"username": "${env:TEST_SECRET_TRIMMED}"}
35
+ },
36
+ assertCfg: func(t *testing.T, cfg map[string]any) {
37
+ assert.Equal(t, "admin", cfg["username"])
38
+ },
39
+ },
40
+ "file ref": {
41
+ buildCfg: func(t *testing.T) map[string]any {
42
+ path := filepath.Join(t.TempDir(), "secret.txt")
43
+ require.NoError(t, os.WriteFile(path, []byte(" s3cret\n"), 0600))
44
+ return map[string]any{"password": "${file:" + path + "}"}
45
+ },
46
+ assertCfg: func(t *testing.T, cfg map[string]any) {
47
+ assert.Equal(t, "s3cret", cfg["password"])
48
+ },
49
+ },
50
+ "nested maps": {
51
+ buildCfg: func(t *testing.T) map[string]any {
52
+ t.Setenv("TEST_SECRET_HOST", "db.local")
53
+ return map[string]any{
54
+ "database": map[string]any{
55
+ "host": "${env:TEST_SECRET_HOST}",
56
+ "port": 5432,
57
+ },
58
+ }
59
+ },
60
+ assertCfg: func(t *testing.T, cfg map[string]any) {
61
+ inner := cfg["database"].(map[string]any)
62
+ assert.Equal(t, "db.local", inner["host"])
63
+ assert.Equal(t, 5432, inner["port"])
64
+ },
65
+ },
66
+ "map any any": {
67
+ buildCfg: func(t *testing.T) map[string]any {
68
+ t.Setenv("TEST_SECRET_LABEL", "prod")
69
+ return map[string]any{
70
+ "labels": map[any]any{"env": "${env:TEST_SECRET_LABEL}"},
71
+ }
72
+ },
73
+ assertCfg: func(t *testing.T, cfg map[string]any) {
74
+ labels := cfg["labels"].(map[any]any)
75
+ assert.Equal(t, "prod", labels["env"])
76
+ },
77
+ },
78
+ "array with strings": {
79
+ buildCfg: func(t *testing.T) map[string]any {
80
+ t.Setenv("TEST_SECRET_ITEM", "resolved")
81
+ return map[string]any{
82
+ "items": []any{"plain", "${env:TEST_SECRET_ITEM}", 42},
83
+ }
84
+ },
85
+ assertCfg: func(t *testing.T, cfg map[string]any) {
86
+ items := cfg["items"].([]any)
87
+ assert.Equal(t, "plain", items[0])
88
+ assert.Equal(t, "resolved", items[1])
89
+ assert.Equal(t, 42, items[2])
90
+ },
91
+ },
92
+ "multiple refs in one string": {
93
+ buildCfg: func(t *testing.T) map[string]any {
94
+ t.Setenv("TEST_SECRET_DB_USER", "root")
95
+ t.Setenv("TEST_SECRET_DB_PASS", "p@ss")
96
+ return map[string]any{
97
+ "dsn": "${env:TEST_SECRET_DB_USER}:${env:TEST_SECRET_DB_PASS}@tcp(localhost)/db",
98
+ }
99
+ },
100
+ assertCfg: func(t *testing.T, cfg map[string]any) {
101
+ assert.Equal(t, "root:p@ss@tcp(localhost)/db", cfg["dsn"])
102
+ },
103
+ },
104
+ "uppercase no-scheme left alone": {
105
+ buildCfg: func(t *testing.T) map[string]any {
106
+ return map[string]any{"token": "${MY_TOKEN}"}
107
+ },
108
+ assertCfg: func(t *testing.T, cfg map[string]any) {
109
+ assert.Equal(t, "${MY_TOKEN}", cfg["token"])
110
+ },
111
+ },
112
+ "lowercase no-scheme left alone": {
113
+ buildCfg: func(t *testing.T) map[string]any {
114
+ return map[string]any{"template": "${lower_case}"}
115
+ },
116
+ assertCfg: func(t *testing.T, cfg map[string]any) {
117
+ assert.Equal(t, "${lower_case}", cfg["template"])
118
+ },
119
+ },
120
+ "unknown scheme error": {
121
+ buildCfg: func(t *testing.T) map[string]any {
122
+ return map[string]any{"val": "${nosuchprovider:secret/data/pass}"}
123
+ },
124
+ wantErrContains: "unknown secret provider 'nosuchprovider'",
125
+ },
126
+ "legacy remote vault syntax is rejected": {
127
+ buildCfg: func(t *testing.T) map[string]any {
128
+ return map[string]any{"val": "${vault:secret/data/pass#key}"}
129
+ },
130
+ wantErrContains: "unknown secret provider 'vault'",
131
+ },
132
+ "legacy remote aws syntax is rejected": {
133
+ buildCfg: func(t *testing.T) map[string]any {
134
+ return map[string]any{"val": "${aws-sm:mysecret}"}
135
+ },
136
+ wantErrContains: "unknown secret provider 'aws-sm'",
137
+ },
138
+ "missing env var error": {
139
+ buildCfg: func(t *testing.T) map[string]any {
140
+ return map[string]any{"password": "${env:DEFINITELY_NOT_SET_12345}"}
141
+ },
142
+ wantErrContains: "environment variable 'DEFINITELY_NOT_SET_12345' is not set",
143
+ },
144
+ "missing file error": {
145
+ buildCfg: func(t *testing.T) map[string]any {
146
+ path := filepath.Join(t.TempDir(), "nonexistent_secret_file")
147
+ return map[string]any{"secret": "${file:" + path + "}"}
148
+ },
149
+ wantErrContains: "resolving secret '${file:",
150
+ },
151
+ "relative file path error": {
152
+ buildCfg: func(t *testing.T) map[string]any {
153
+ return map[string]any{"secret": "${file:relative/secret.txt}"}
154
+ },
155
+ wantErrContains: "file path must be absolute",
156
+ },
157
+ "internal keys skipped": {
158
+ buildCfg: func(t *testing.T) map[string]any {
159
+ return map[string]any{
160
+ "__source__": "${env:SHOULD_NOT_RESOLVE}",
161
+ "__source_type__": "${env:SHOULD_NOT_RESOLVE}",
162
+ "__provider__": "${env:SHOULD_NOT_RESOLVE}",
163
+ }
164
+ },
165
+ assertCfg: func(t *testing.T, cfg map[string]any) {
166
+ assert.Equal(t, "${env:SHOULD_NOT_RESOLVE}", cfg["__source__"])
167
+ assert.Equal(t, "${env:SHOULD_NOT_RESOLVE}", cfg["__source_type__"])
168
+ assert.Equal(t, "${env:SHOULD_NOT_RESOLVE}", cfg["__provider__"])
169
+ },
170
+ },
171
+ "non-string values untouched": {
172
+ buildCfg: func(t *testing.T) map[string]any {
173
+ return map[string]any{
174
+ "port": 8080,
175
+ "enabled": true,
176
+ "ratio": 3.14,
177
+ "nothing": nil,
178
+ }
179
+ },
180
+ assertCfg: func(t *testing.T, cfg map[string]any) {
181
+ assert.Equal(t, 8080, cfg["port"])
182
+ assert.Equal(t, true, cfg["enabled"])
183
+ assert.Equal(t, 3.14, cfg["ratio"])
184
+ assert.Nil(t, cfg["nothing"])
185
+ },
186
+ },
187
+ "no refs no changes": {
188
+ buildCfg: func(t *testing.T) map[string]any {
189
+ return map[string]any{"host": "localhost", "port": 3306}
190
+ },
191
+ assertCfg: func(t *testing.T, cfg map[string]any) {
192
+ assert.Equal(t, "localhost", cfg["host"])
193
+ assert.Equal(t, 3306, cfg["port"])
194
+ },
195
+ },
196
+ "empty map": {
197
+ buildCfg: func(t *testing.T) map[string]any { return map[string]any{} },
198
+ assertCfg: func(t *testing.T, cfg map[string]any) {
199
+ assert.Empty(t, cfg)
200
+ },
201
+ },
202
+ "missing uppercase no-scheme left alone": {
203
+ buildCfg: func(t *testing.T) map[string]any {
204
+ return map[string]any{"token": "${MISSING_SHORTHAND_VAR_12345}"}
205
+ },
206
+ assertCfg: func(t *testing.T, cfg map[string]any) {
207
+ assert.Equal(t, "${MISSING_SHORTHAND_VAR_12345}", cfg["token"])
208
+ },
209
+ },
210
+ "mixed refs and plain text": {
211
+ buildCfg: func(t *testing.T) map[string]any {
212
+ t.Setenv("TEST_SECRET_PROTO", "https")
213
+ return map[string]any{"url": "${env:TEST_SECRET_PROTO}://example.com/api"}
214
+ },
215
+ assertCfg: func(t *testing.T, cfg map[string]any) {
216
+ assert.Equal(t, "https://example.com/api", cfg["url"])
217
+ },
218
+ },
219
+ "deeply nested": {
220
+ buildCfg: func(t *testing.T) map[string]any {
221
+ t.Setenv("TEST_SECRET_DEEP", "found")
222
+ return map[string]any{
223
+ "level1": map[string]any{
224
+ "level2": map[string]any{"level3": "${env:TEST_SECRET_DEEP}"},
225
+ },
226
+ }
227
+ },
228
+ assertCfg: func(t *testing.T, cfg map[string]any) {
229
+ l1 := cfg["level1"].(map[string]any)
230
+ l2 := l1["level2"].(map[string]any)
231
+ assert.Equal(t, "found", l2["level3"])
232
+ },
233
+ },
234
+ "file ref and env ref together": {
235
+ buildCfg: func(t *testing.T) map[string]any {
236
+ t.Setenv("TEST_SECRET_FUSER", "admin")
237
+ path := filepath.Join(t.TempDir(), "pass.txt")
238
+ require.NoError(t, os.WriteFile(path, []byte("hunter2\n"), 0600))
239
+ return map[string]any{
240
+ "dsn": "${env:TEST_SECRET_FUSER}:${file:" + path + "}@host",
241
+ }
242
+ },
243
+ assertCfg: func(t *testing.T, cfg map[string]any) {
244
+ assert.Equal(t, "admin:hunter2@host", cfg["dsn"])
245
+ },
246
+ },
247
+ "internal key in nested map": {
248
+ buildCfg: func(t *testing.T) map[string]any {
249
+ return map[string]any{
250
+ "sub": map[string]any{
251
+ "__meta__": "${env:SHOULD_NOT_RESOLVE}",
252
+ "normal": "plain",
253
+ },
254
+ }
255
+ },
256
+ assertCfg: func(t *testing.T, cfg map[string]any) {
257
+ sub := cfg["sub"].(map[string]any)
258
+ assert.Equal(t, "${env:SHOULD_NOT_RESOLVE}", sub["__meta__"])
259
+ assert.Equal(t, "plain", sub["normal"])
260
+ },
261
+ },
262
+ "empty env name error": {
263
+ buildCfg: func(t *testing.T) map[string]any {
264
+ return map[string]any{"val": "${env:}"}
265
+ },
266
+ wantErrContains: "environment variable '' is not set",
267
+ },
268
+ "empty file name error": {
269
+ buildCfg: func(t *testing.T) map[string]any {
270
+ return map[string]any{"val": "${file:}"}
271
+ },
272
+ wantErrContains: "resolving secret",
273
+ },
274
+ "multiple refs one failure": {
275
+ buildCfg: func(t *testing.T) map[string]any {
276
+ t.Setenv("TEST_SECRET_OK", "good")
277
+ return map[string]any{
278
+ "dsn": "${env:TEST_SECRET_OK}:${env:MISSING_VAR_12345}@host",
279
+ }
280
+ },
281
+ wantErrContains: "MISSING_VAR_12345",
282
+ },
283
+ "array in nested map": {
284
+ buildCfg: func(t *testing.T) map[string]any {
285
+ t.Setenv("TEST_SECRET_ARR", "val")
286
+ return map[string]any{
287
+ "outer": map[string]any{
288
+ "list": []any{"${env:TEST_SECRET_ARR}", "static"},
289
+ },
290
+ }
291
+ },
292
+ assertCfg: func(t *testing.T, cfg map[string]any) {
293
+ outer := cfg["outer"].(map[string]any)
294
+ list := outer["list"].([]any)
295
+ assert.Equal(t, "val", list[0])
296
+ assert.Equal(t, "static", list[1])
297
+ },
298
+ },
299
+ }
300
+
301
+ for name, tc := range tests {
302
+ t.Run(name, func(t *testing.T) {
303
+ resolver := New()
304
+ cfg := tc.buildCfg(t)
305
+ err := resolver.Resolve(cfg)
306
+
307
+ if tc.wantErrContains != "" {
308
+ require.Error(t, err)
309
+ assert.Contains(t, err.Error(), tc.wantErrContains)
310
+ return
311
+ }
312
+
313
+ require.NoError(t, err)
314
+ if tc.assertCfg != nil {
315
+ tc.assertCfg(t, cfg)
316
+ }
317
+ })
318
+ }
319
+}
320
+
321
+func TestResolveWithStoreResolver(t *testing.T) {
322
+ tests := map[string]struct {
323
+ cfg map[string]any
324
+ storeResolver StoreRefResolver
325
+ wantErrContains string
326
+ assertCfg func(t *testing.T, cfg map[string]any)
327
+ }{
328
+ "store ref with resolver": {
329
+ cfg: map[string]any{
330
+ "password": "${store:vault:vault_prod:secret/data/mysql#password}",
331
+ },
332
+ storeResolver: func(ctx context.Context, ref, original string) (string, error) {
333
+ require.NotNil(t, ctx)
334
+ if ref == "vault:vault_prod:secret/data/mysql#password" && original == "${store:vault:vault_prod:secret/data/mysql#password}" {
335
+ return "resolved-secret", nil
336
+ }
337
+ return "", errors.New("unexpected ref")
338
+ },
339
+ assertCfg: func(t *testing.T, cfg map[string]any) {
340
+ assert.Equal(t, "resolved-secret", cfg["password"])
341
+ },
342
+ },
343
+ "store ref without resolver": {
344
+ cfg: map[string]any{
345
+ "password": "${store:vault:vault_prod:secret/data/mysql#password}",
346
+ },
347
+ wantErrContains: "secretstore resolver is not configured",
348
+ },
349
+ "store resolver error bubbles": {
350
+ cfg: map[string]any{
351
+ "password": "${store:vault:vault_prod:secret/data/mysql#password}",
352
+ },
353
+ storeResolver: func(ctx context.Context, ref, original string) (string, error) {
354
+ require.NotNil(t, ctx)
355
+ return "", errors.New("store not configured")
356
+ },
357
+ wantErrContains: "store not configured",
358
+ },
359
+ "mixed env and store refs": {
360
+ cfg: map[string]any{
361
+ "dsn": "${env:TEST_SR_USER}:${store:aws-sm:aws_prod:app/db#password}@host",
362
+ },
363
+ storeResolver: func(ctx context.Context, ref, original string) (string, error) {
364
+ require.NotNil(t, ctx)
365
+ assert.Equal(t, "aws-sm:aws_prod:app/db#password", ref)
366
+ return "p@ss", nil
367
+ },
368
+ assertCfg: func(t *testing.T, cfg map[string]any) {
369
+ assert.Equal(t, "admin:p@ss@host", cfg["dsn"])
370
+ },
371
+ },
372
+ "store resolver receives canceled context": {
373
+ cfg: map[string]any{
374
+ "password": "${store:vault:vault_prod:secret/data/mysql#password}",
375
+ },
376
+ storeResolver: func(ctx context.Context, ref, original string) (string, error) {
377
+ <-ctx.Done()
378
+ return "", ctx.Err()
379
+ },
380
+ wantErrContains: context.Canceled.Error(),
381
+ },
382
+ }
383
+
384
+ for name, tc := range tests {
385
+ t.Run(name, func(t *testing.T) {
386
+ if name == "mixed env and store refs" {
387
+ t.Setenv("TEST_SR_USER", "admin")
388
+ }
389
+
390
+ resolver := New()
391
+ ctx := context.Background()
392
+ if name == "store resolver receives canceled context" {
393
+ var cancel context.CancelFunc
394
+ ctx, cancel = context.WithCancel(context.Background())
395
+ cancel()
396
+ }
397
+
398
+ err := resolver.ResolveWithStoreResolver(ctx, tc.cfg, tc.storeResolver)
399
+
400
+ if tc.wantErrContains != "" {
401
+ require.Error(t, err)
402
+ assert.Contains(t, err.Error(), tc.wantErrContains)
403
+ return
404
+ }
405
+
406
+ require.NoError(t, err)
407
+ if tc.assertCfg != nil {
408
+ tc.assertCfg(t, tc.cfg)
409
+ }
410
+ })
411
+ }
412
+}
413
+
414
+func TestResolveRefUsesProviderRegistry(t *testing.T) {
415
+ resolver := New()
416
+
417
+ called := false
418
+ resolver.providers["stub"] = func(ctx context.Context, ref, original string) (string, error) {
419
+ called = true
420
+ require.NotNil(t, ctx)
421
+ assert.Equal(t, "name", ref)
422
+ assert.Equal(t, "${stub:name}", original)
423
+ return "resolved-by-stub", nil
424
+ }
425
+
426
+ cfg := map[string]any{
427
+ "value": "${stub:name}",
428
+ }
429
+
430
+ require.NoError(t, resolver.Resolve(cfg))
431
+ assert.True(t, called)
432
+ assert.Equal(t, "resolved-by-stub", cfg["value"])
433
+}
src/go/plugin/agent/secrets/secretstore/backends/aws/config_schema.json
new
+40
@@ -0,0 +1,40 @@
1
+{
2
+ "jsonSchema": {
3
+ "$schema": "http://json-schema.org/draft-07/schema#",
4
+ "type": "object",
5
+ "title": "AWS Secrets Manager secretstore configuration.",
6
+ "properties": {
7
+ "auth_mode": {
8
+ "title": "Auth mode",
9
+ "description": "How AWS credentials are obtained.",
10
+ "type": "string",
11
+ "enum": [
12
+ "env",
13
+ "ecs",
14
+ "imds"
15
+ ],
16
+ "default": "env"
17
+ },
18
+ "region": {
19
+ "title": "Region",
20
+ "description": "AWS region used for Secrets Manager requests.",
21
+ "type": "string"
22
+ }
23
+ },
24
+ "required": [
25
+ "auth_mode",
26
+ "region"
27
+ ]
28
+ },
29
+ "uiSchema": {
30
+ "uiOptions": {
31
+ "fullPage": true
32
+ },
33
+ "auth_mode": {
34
+ "ui:widget": "radio",
35
+ "ui:options": {
36
+ "inline": true
37
+ }
38
+ }
39
+ }
40
+}
src/go/plugin/agent/secrets/secretstore/backends/aws/init.go
new
+35
@@ -0,0 +1,35 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package aws
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "strings"
9
+
10
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
11
+)
12
+
13
+func (s *store) init(_ context.Context) error {
14
+ switch strings.TrimSpace(s.Config.AuthMode) {
15
+ case "env", "ecs", "imds":
16
+ s.Config.AuthMode = strings.TrimSpace(s.Config.AuthMode)
17
+ default:
18
+ return fmt.Errorf("auth_mode '%s' is invalid for kind '%s'", s.Config.AuthMode, secretstore.KindAWSSM)
19
+ }
20
+
21
+ region := strings.TrimSpace(s.Config.Region)
22
+ if region == "" {
23
+ return fmt.Errorf("region is required")
24
+ }
25
+ s.Config.Region = region
26
+
27
+ published := &publishedStore{
28
+ provider: s.provider,
29
+ mode: s.Config.AuthMode,
30
+ regionValue: region,
31
+ }
32
+
33
+ s.published = published
34
+ return nil
35
+}
src/go/plugin/agent/secrets/secretstore/backends/aws/provider.go
new
+71
@@ -0,0 +1,71 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package aws
4
+
5
+import (
6
+ "context"
7
+ _ "embed"
8
+ "net/http"
9
+ "time"
10
+
11
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
12
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/httpx"
13
+)
14
+
15
+//go:embed config_schema.json
16
+var configSchema string
17
+
18
+type Config struct {
19
+ AuthMode string `json:"auth_mode" yaml:"auth_mode"`
20
+ Region string `json:"region" yaml:"region"`
21
+}
22
+
23
+type credentials struct {
24
+ accessKeyID string
25
+ secretAccessKey string
26
+ sessionToken string
27
+}
28
+
29
+type provider struct {
30
+ apiClient *http.Client
31
+ imdsClient *http.Client
32
+ endpoint string
33
+ now func() time.Time
34
+}
35
+
36
+type store struct {
37
+ Config `yaml:",inline" json:""`
38
+ provider *provider
39
+ published *publishedStore
40
+}
41
+
42
+type publishedStore struct {
43
+ provider *provider
44
+ mode string
45
+ regionValue string
46
+}
47
+
48
+func New() secretstore.Creator {
49
+ p := &provider{
50
+ apiClient: httpx.APIClient(10 * time.Second),
51
+ imdsClient: httpx.NoProxyClient(2 * time.Second),
52
+ now: time.Now,
53
+ }
54
+
55
+ return secretstore.Creator{
56
+ Kind: secretstore.KindAWSSM,
57
+ DisplayName: "AWS Secrets Manager",
58
+ Schema: configSchema,
59
+ Create: p.create,
60
+ }
61
+}
62
+
63
+func (p *provider) create() secretstore.Store {
64
+ return &store{provider: p}
65
+}
66
+
67
+func (s *store) Configuration() any { return &s.Config }
68
+
69
+func (s *store) Init(ctx context.Context) error { return s.init(ctx) }
70
+
71
+func (s *store) Publish() secretstore.PublishedStore { return s.published }
src/go/plugin/agent/secrets/secretstore/backends/aws/resolve.go
new
+325
@@ -0,0 +1,325 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package aws
4
+
5
+import (
6
+ "context"
7
+ "crypto/hmac"
8
+ "crypto/sha256"
9
+ "encoding/hex"
10
+ "encoding/json"
11
+ "fmt"
12
+ "io"
13
+ "net/http"
14
+ "sort"
15
+ "strings"
16
+
17
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
18
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/envx"
19
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/httpx"
20
+)
21
+
22
+func (s *publishedStore) Resolve(ctx context.Context, req secretstore.ResolveRequest) (string, error) {
23
+ return s.resolve(ctx, req)
24
+}
25
+
26
+func (s *publishedStore) resolve(ctx context.Context, req secretstore.ResolveRequest) (string, error) {
27
+ secretName, jsonKey, _ := strings.Cut(req.Operand, "#")
28
+ if secretName == "" {
29
+ return "", fmt.Errorf("resolving secret '%s': store '%s': secret name is empty", req.Original, req.StoreKey)
30
+ }
31
+
32
+ creds, err := s.credentials(ctx)
33
+ if err != nil {
34
+ return "", fmt.Errorf("resolving secret '%s': store '%s': %w", req.Original, req.StoreKey, err)
35
+ }
36
+
37
+ region, err := s.region()
38
+ if err != nil {
39
+ return "", fmt.Errorf("resolving secret '%s': store '%s': %w", req.Original, req.StoreKey, err)
40
+ }
41
+
42
+ secretString, err := s.secretValue(ctx, creds, region, secretName, req.Original)
43
+ if err != nil {
44
+ return "", fmt.Errorf("resolving secret '%s': store '%s': %w", req.Original, req.StoreKey, err)
45
+ }
46
+
47
+ if jsonKey == "" {
48
+ return secretString, nil
49
+ }
50
+
51
+ var parsed map[string]any
52
+ if err := json.Unmarshal([]byte(secretString), &parsed); err != nil {
53
+ return "", fmt.Errorf("resolving secret '%s': store '%s': parsing SecretString as JSON: %w", req.Original, req.StoreKey, err)
54
+ }
55
+ val, ok := parsed[jsonKey]
56
+ if !ok {
57
+ return "", fmt.Errorf("resolving secret '%s': store '%s': key '%s' not found in SecretString JSON", req.Original, req.StoreKey, jsonKey)
58
+ }
59
+ if value, ok := val.(string); ok {
60
+ return value, nil
61
+ }
62
+ b, err := json.Marshal(val)
63
+ if err != nil {
64
+ return "", fmt.Errorf("resolving secret '%s': store '%s': encoding value for key '%s': %w", req.Original, req.StoreKey, jsonKey, err)
65
+ }
66
+ return string(b), nil
67
+}
68
+
69
+func (s *publishedStore) region() (string, error) {
70
+ if s.regionValue == "" {
71
+ return "", fmt.Errorf("region is required")
72
+ }
73
+ return s.regionValue, nil
74
+}
75
+
76
+func (s *publishedStore) credentials(ctx context.Context) (*credentials, error) {
77
+ switch s.mode {
78
+ case "env":
79
+ return envCredentials()
80
+ case "ecs":
81
+ uri, ok := envx.Lookup("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")
82
+ if !ok || uri == "" {
83
+ return nil, fmt.Errorf("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI is not set")
84
+ }
85
+ return s.ecsCredentials(ctx, uri)
86
+ case "imds":
87
+ return s.imdsCredentials(ctx)
88
+ default:
89
+ return nil, fmt.Errorf("auth_mode '%s' is invalid for aws-sm", s.mode)
90
+ }
91
+}
92
+
93
+func envCredentials() (*credentials, error) {
94
+ ak, ok := envx.Lookup("AWS_ACCESS_KEY_ID")
95
+ if !ok || ak == "" {
96
+ return nil, fmt.Errorf("AWS_ACCESS_KEY_ID is not set")
97
+ }
98
+ sk, ok := envx.Lookup("AWS_SECRET_ACCESS_KEY")
99
+ if !ok || sk == "" {
100
+ return nil, fmt.Errorf("AWS_SECRET_ACCESS_KEY is not set")
101
+ }
102
+ token, _ := envx.Lookup("AWS_SESSION_TOKEN")
103
+ return &credentials{accessKeyID: ak, secretAccessKey: sk, sessionToken: token}, nil
104
+}
105
+
106
+func (s *publishedStore) ecsCredentials(ctx context.Context, relativeURI string) (*credentials, error) {
107
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.170.2"+relativeURI, nil)
108
+ if err != nil {
109
+ return nil, fmt.Errorf("creating ECS credentials request: %w", err)
110
+ }
111
+ resp, err := s.provider.imdsClient.Do(req)
112
+ if err != nil {
113
+ return nil, fmt.Errorf("ECS credentials request failed: %w", err)
114
+ }
115
+ defer resp.Body.Close()
116
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
117
+ if err != nil {
118
+ return nil, fmt.Errorf("reading ECS credentials response: %w", err)
119
+ }
120
+ if resp.StatusCode != http.StatusOK {
121
+ return nil, fmt.Errorf("ECS credentials returned HTTP %d: %s", resp.StatusCode, httpx.TruncateBody(body))
122
+ }
123
+ var result struct {
124
+ AccessKeyID string `json:"AccessKeyId"`
125
+ SecretAccessKey string `json:"SecretAccessKey"`
126
+ Token string `json:"Token"`
127
+ }
128
+ if err := json.Unmarshal(body, &result); err != nil {
129
+ return nil, fmt.Errorf("parsing ECS credentials response: %w", err)
130
+ }
131
+ if result.AccessKeyID == "" || result.SecretAccessKey == "" {
132
+ return nil, fmt.Errorf("ECS credentials response missing required fields")
133
+ }
134
+ return &credentials{
135
+ accessKeyID: result.AccessKeyID,
136
+ secretAccessKey: result.SecretAccessKey,
137
+ sessionToken: result.Token,
138
+ }, nil
139
+}
140
+
141
+func (s *publishedStore) imdsCredentials(ctx context.Context) (*credentials, error) {
142
+ tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://169.254.169.254/latest/api/token", nil)
143
+ if err != nil {
144
+ return nil, fmt.Errorf("creating IMDS token request: %w", err)
145
+ }
146
+ tokenReq.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "21600")
147
+ tokenResp, err := s.provider.imdsClient.Do(tokenReq)
148
+ if err != nil {
149
+ return nil, fmt.Errorf("IMDS token request failed: %w", err)
150
+ }
151
+ defer tokenResp.Body.Close()
152
+ tokenBody, err := io.ReadAll(io.LimitReader(tokenResp.Body, 1<<20))
153
+ if err != nil {
154
+ return nil, fmt.Errorf("reading IMDS token response: %w", err)
155
+ }
156
+ if tokenResp.StatusCode != http.StatusOK {
157
+ return nil, fmt.Errorf("IMDS token request returned HTTP %d", tokenResp.StatusCode)
158
+ }
159
+ imdsToken := strings.TrimSpace(string(tokenBody))
160
+
161
+ roleReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.169.254/latest/meta-data/iam/security-credentials/", nil)
162
+ if err != nil {
163
+ return nil, fmt.Errorf("creating IMDS role request: %w", err)
164
+ }
165
+ roleReq.Header.Set("X-aws-ec2-metadata-token", imdsToken)
166
+ roleResp, err := s.provider.imdsClient.Do(roleReq)
167
+ if err != nil {
168
+ return nil, fmt.Errorf("IMDS role request failed: %w", err)
169
+ }
170
+ defer roleResp.Body.Close()
171
+ roleBody, err := io.ReadAll(io.LimitReader(roleResp.Body, 1<<20))
172
+ if err != nil {
173
+ return nil, fmt.Errorf("reading IMDS role response: %w", err)
174
+ }
175
+ if roleResp.StatusCode != http.StatusOK {
176
+ return nil, fmt.Errorf("IMDS role request returned HTTP %d", roleResp.StatusCode)
177
+ }
178
+ role := strings.TrimSpace(string(roleBody))
179
+ if role == "" {
180
+ return nil, fmt.Errorf("IMDS returned empty role name")
181
+ }
182
+
183
+ credReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.169.254/latest/meta-data/iam/security-credentials/"+role, nil)
184
+ if err != nil {
185
+ return nil, fmt.Errorf("creating IMDS credentials request: %w", err)
186
+ }
187
+ credReq.Header.Set("X-aws-ec2-metadata-token", imdsToken)
188
+ credResp, err := s.provider.imdsClient.Do(credReq)
189
+ if err != nil {
190
+ return nil, fmt.Errorf("IMDS credentials request failed: %w", err)
191
+ }
192
+ defer credResp.Body.Close()
193
+ credBody, err := io.ReadAll(io.LimitReader(credResp.Body, 1<<20))
194
+ if err != nil {
195
+ return nil, fmt.Errorf("reading IMDS credentials response: %w", err)
196
+ }
197
+ if credResp.StatusCode != http.StatusOK {
198
+ return nil, fmt.Errorf("IMDS credentials request returned HTTP %d", credResp.StatusCode)
199
+ }
200
+ var result struct {
201
+ AccessKeyID string `json:"AccessKeyId"`
202
+ SecretAccessKey string `json:"SecretAccessKey"`
203
+ Token string `json:"Token"`
204
+ }
205
+ if err := json.Unmarshal(credBody, &result); err != nil {
206
+ return nil, fmt.Errorf("parsing IMDS credentials response: %w", err)
207
+ }
208
+ if result.AccessKeyID == "" || result.SecretAccessKey == "" {
209
+ return nil, fmt.Errorf("IMDS credentials response missing required fields")
210
+ }
211
+ return &credentials{accessKeyID: result.AccessKeyID, secretAccessKey: result.SecretAccessKey, sessionToken: result.Token}, nil
212
+}
213
+
214
+func (s *publishedStore) secretValue(ctx context.Context, creds *credentials, region, secretName, original string) (string, error) {
215
+ endpoint := s.provider.endpoint
216
+ host := secretsManagerHost(region)
217
+ if endpoint == "" {
218
+ endpoint = "https://" + host + "/"
219
+ }
220
+ secretIDJSON, err := json.Marshal(secretName)
221
+ if err != nil {
222
+ return "", fmt.Errorf("resolving secret '%s': encoding secret name: %w", original, err)
223
+ }
224
+ payload := `{"SecretId":` + string(secretIDJSON) + `}`
225
+ now := s.provider.now().UTC()
226
+ timestamp := now.Format("20060102T150405Z")
227
+ datestamp := now.Format("20060102")
228
+ headers := map[string]string{
229
+ "host": host,
230
+ "x-amz-date": timestamp,
231
+ "x-amz-target": "secretsmanager.GetSecretValue",
232
+ "content-type": "application/x-amz-json-1.1",
233
+ }
234
+ if creds.sessionToken != "" {
235
+ headers["x-amz-security-token"] = creds.sessionToken
236
+ }
237
+ authHeader := sigV4Sign("POST", "/", "", headers, payload, creds, region, datestamp, timestamp)
238
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(payload))
239
+ if err != nil {
240
+ return "", fmt.Errorf("resolving secret '%s': creating request: %w", original, err)
241
+ }
242
+ for k, v := range headers {
243
+ httpReq.Header.Set(k, v)
244
+ }
245
+ httpReq.Host = host
246
+ httpReq.Header.Set("Authorization", authHeader)
247
+ resp, err := s.provider.apiClient.Do(httpReq)
248
+ if err != nil {
249
+ return "", fmt.Errorf("resolving secret '%s': request failed: %w", original, err)
250
+ }
251
+ defer resp.Body.Close()
252
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
253
+ if err != nil {
254
+ return "", fmt.Errorf("resolving secret '%s': reading response: %w", original, err)
255
+ }
256
+ if resp.StatusCode != http.StatusOK {
257
+ return "", fmt.Errorf("resolving secret '%s': AWS Secrets Manager returned HTTP %d: %s", original, resp.StatusCode, httpx.TruncateBody(body))
258
+ }
259
+ var result struct {
260
+ SecretString *string `json:"SecretString"`
261
+ }
262
+ if err := json.Unmarshal(body, &result); err != nil {
263
+ return "", fmt.Errorf("resolving secret '%s': parsing response: %w", original, err)
264
+ }
265
+ if result.SecretString == nil {
266
+ return "", fmt.Errorf("resolving secret '%s': SecretString is empty (binary secrets are not supported)", original)
267
+ }
268
+ return *result.SecretString, nil
269
+}
270
+
271
+func secretsManagerHost(region string) string {
272
+ suffix := "amazonaws.com"
273
+ if strings.HasPrefix(region, "cn-") {
274
+ suffix = "amazonaws.com.cn"
275
+ }
276
+ return fmt.Sprintf("secretsmanager.%s.%s", region, suffix)
277
+}
278
+
279
+func sigV4Sign(method, uri, query string, headers map[string]string, payload string, creds *credentials, region, datestamp, timestamp string) string {
280
+ canonicalHeaders, signedHeaders := canonicalHeaders(headers)
281
+ payloadHash := sha256Hex([]byte(payload))
282
+ canonicalRequest := strings.Join([]string{method, uri, query, canonicalHeaders, signedHeaders, payloadHash}, "\n")
283
+ scope := datestamp + "/" + region + "/secretsmanager/aws4_request"
284
+ stringToSign := strings.Join([]string{"AWS4-HMAC-SHA256", timestamp, scope, sha256Hex([]byte(canonicalRequest))}, "\n")
285
+ signingKey := deriveSigningKey(creds.secretAccessKey, datestamp, region)
286
+ signature := hex.EncodeToString(hmacSHA256(signingKey, []byte(stringToSign)))
287
+ return fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", creds.accessKeyID, scope, signedHeaders, signature)
288
+}
289
+
290
+func canonicalHeaders(headers map[string]string) (string, string) {
291
+ norm := make(map[string]string, len(headers))
292
+ keys := make([]string, 0, len(headers))
293
+ for k, v := range headers {
294
+ lk := strings.ToLower(k)
295
+ norm[lk] = v
296
+ keys = append(keys, lk)
297
+ }
298
+ sort.Strings(keys)
299
+ var canonical strings.Builder
300
+ for _, k := range keys {
301
+ canonical.WriteString(k)
302
+ canonical.WriteByte(':')
303
+ canonical.WriteString(strings.TrimSpace(norm[k]))
304
+ canonical.WriteByte('\n')
305
+ }
306
+ return canonical.String(), strings.Join(keys, ";")
307
+}
308
+
309
+func deriveSigningKey(secretKey, datestamp, region string) []byte {
310
+ kDate := hmacSHA256([]byte("AWS4"+secretKey), []byte(datestamp))
311
+ kRegion := hmacSHA256(kDate, []byte(region))
312
+ kService := hmacSHA256(kRegion, []byte("secretsmanager"))
313
+ return hmacSHA256(kService, []byte("aws4_request"))
314
+}
315
+
316
+func hmacSHA256(key, data []byte) []byte {
317
+ h := hmac.New(sha256.New, key)
318
+ _, _ = h.Write(data)
319
+ return h.Sum(nil)
320
+}
321
+
322
+func sha256Hex(data []byte) string {
323
+ sum := sha256.Sum256(data)
324
+ return hex.EncodeToString(sum[:])
325
+}
src/go/plugin/agent/secrets/secretstore/backends/aws/resolve_test.go
new
+80
@@ -0,0 +1,80 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package aws
4
+
5
+import (
6
+ "context"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/stretchr/testify/assert"
13
+ "github.com/stretchr/testify/require"
14
+)
15
+
16
+func TestSecretsManagerHost(t *testing.T) {
17
+ tests := map[string]struct {
18
+ region string
19
+ wantHost string
20
+ }{
21
+ "standard partition": {
22
+ region: "us-east-1",
23
+ wantHost: "secretsmanager.us-east-1.amazonaws.com",
24
+ },
25
+ "china partition": {
26
+ region: "cn-north-1",
27
+ wantHost: "secretsmanager.cn-north-1.amazonaws.com.cn",
28
+ },
29
+ }
30
+
31
+ for name, tc := range tests {
32
+ t.Run(name, func(t *testing.T) {
33
+ assert.Equal(t, tc.wantHost, secretsManagerHost(tc.region))
34
+ })
35
+ }
36
+}
37
+
38
+func TestSecretValue_CustomEndpointUsesSignedHostHeader(t *testing.T) {
39
+ tests := map[string]struct {
40
+ region string
41
+ wantHost string
42
+ }{
43
+ "standard partition": {
44
+ region: "us-east-1",
45
+ wantHost: "secretsmanager.us-east-1.amazonaws.com",
46
+ },
47
+ "china partition": {
48
+ region: "cn-north-1",
49
+ wantHost: "secretsmanager.cn-north-1.amazonaws.com.cn",
50
+ },
51
+ }
52
+
53
+ for name, tc := range tests {
54
+ t.Run(name, func(t *testing.T) {
55
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56
+ assert.Equal(t, tc.wantHost, r.Host)
57
+ w.Header().Set("Content-Type", "application/x-amz-json-1.1")
58
+ _, _ = w.Write([]byte(`{"SecretString":"value"}`))
59
+ }))
60
+ defer srv.Close()
61
+
62
+ store := &publishedStore{
63
+ provider: &provider{
64
+ apiClient: srv.Client(),
65
+ endpoint: srv.URL + "/",
66
+ now: func() time.Time {
67
+ return time.Date(2026, time.March, 18, 12, 0, 0, 0, time.UTC)
68
+ },
69
+ },
70
+ }
71
+
72
+ value, err := store.secretValue(context.Background(), &credentials{
73
+ accessKeyID: "AKID",
74
+ secretAccessKey: "SECRET",
75
+ }, tc.region, "db/password", "${store:aws-sm:aws_prod:db/password}")
76
+ require.NoError(t, err)
77
+ assert.Equal(t, "value", value)
78
+ })
79
+ }
80
+}
src/go/plugin/agent/secrets/secretstore/backends/azure/config_schema.json
new
+101
@@ -0,0 +1,101 @@
1
+{
2
+ "jsonSchema": {
3
+ "$schema": "http://json-schema.org/draft-07/schema#",
4
+ "type": "object",
5
+ "title": "Azure Key Vault secretstore configuration.",
6
+ "properties": {
7
+ "mode": {
8
+ "title": "Mode",
9
+ "description": "Azure authentication mode.",
10
+ "type": "string",
11
+ "enum": [
12
+ "client",
13
+ "managed_identity"
14
+ ],
15
+ "default": "client"
16
+ }
17
+ },
18
+ "required": [
19
+ "mode"
20
+ ],
21
+ "dependencies": {
22
+ "mode": {
23
+ "oneOf": [
24
+ {
25
+ "properties": {
26
+ "mode": {
27
+ "const": "client"
28
+ },
29
+ "mode_client": {
30
+ "title": "Client",
31
+ "description": "Client credentials used when mode is `client`.",
32
+ "type": "object",
33
+ "properties": {
34
+ "tenant_id": {
35
+ "title": "Tenant ID",
36
+ "description": "Azure tenant ID.",
37
+ "type": "string"
38
+ },
39
+ "client_id": {
40
+ "title": "Client ID",
41
+ "description": "Azure application / service principal client ID.",
42
+ "type": "string"
43
+ },
44
+ "client_secret": {
45
+ "title": "Client Secret",
46
+ "description": "Azure application / service principal client secret.",
47
+ "type": "string",
48
+ "sensitive": true
49
+ }
50
+ },
51
+ "required": [
52
+ "tenant_id",
53
+ "client_id",
54
+ "client_secret"
55
+ ]
56
+ }
57
+ },
58
+ "required": [
59
+ "mode_client"
60
+ ]
61
+ },
62
+ {
63
+ "properties": {
64
+ "mode": {
65
+ "const": "managed_identity"
66
+ },
67
+ "mode_managed_identity": {
68
+ "title": "Managed Identity",
69
+ "description": "Managed identity settings used when mode is `managed_identity`.",
70
+ "type": "object",
71
+ "properties": {
72
+ "client_id": {
73
+ "title": "Client ID",
74
+ "description": "Optional client ID of a user-assigned managed identity.",
75
+ "type": "string"
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+ ]
82
+ }
83
+ }
84
+ },
85
+ "uiSchema": {
86
+ "uiOptions": {
87
+ "fullPage": true
88
+ },
89
+ "mode": {
90
+ "ui:widget": "radio",
91
+ "ui:options": {
92
+ "inline": true
93
+ }
94
+ },
95
+ "mode_client": {
96
+ "client_secret": {
97
+ "ui:widget": "password"
98
+ }
99
+ }
100
+ }
101
+}
src/go/plugin/agent/secrets/secretstore/backends/azure/init.go
new
+61
@@ -0,0 +1,61 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package azure
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "strings"
9
+
10
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
11
+)
12
+
13
+func (s *store) init(_ context.Context) error {
14
+ published := &publishedStore{provider: s.provider}
15
+
16
+ switch strings.TrimSpace(s.Config.Mode) {
17
+ case "client":
18
+ if s.Config.ModeClient == nil {
19
+ return fmt.Errorf("mode_client is required when mode is 'client'")
20
+ }
21
+ tenantID := strings.TrimSpace(s.Config.ModeClient.TenantID)
22
+ if tenantID == "" {
23
+ return fmt.Errorf("mode_client.tenant_id is required")
24
+ }
25
+ clientID := strings.TrimSpace(s.Config.ModeClient.ClientID)
26
+ if clientID == "" {
27
+ return fmt.Errorf("mode_client.client_id is required")
28
+ }
29
+ clientSecret := strings.TrimSpace(s.Config.ModeClient.ClientSecret)
30
+ if clientSecret == "" {
31
+ return fmt.Errorf("mode_client.client_secret is required")
32
+ }
33
+ s.Config.Mode = "client"
34
+ s.Config.ModeClient.TenantID = tenantID
35
+ s.Config.ModeClient.ClientID = clientID
36
+ s.Config.ModeClient.ClientSecret = clientSecret
37
+ s.Config.ModeManagedIdentity = nil
38
+ published.mode = s.Config.Mode
39
+ published.clientTenantID = tenantID
40
+ published.clientID = clientID
41
+ published.clientSecret = clientSecret
42
+ case "managed_identity":
43
+ s.Config.Mode = "managed_identity"
44
+ s.Config.ModeClient = nil
45
+ published.mode = s.Config.Mode
46
+ if s.Config.ModeManagedIdentity != nil {
47
+ clientID := strings.TrimSpace(s.Config.ModeManagedIdentity.ClientID)
48
+ if clientID != "" {
49
+ s.Config.ModeManagedIdentity.ClientID = clientID
50
+ published.managedIdentityClientID = clientID
51
+ } else {
52
+ s.Config.ModeManagedIdentity = nil
53
+ }
54
+ }
55
+ default:
56
+ return fmt.Errorf("mode '%s' is invalid for kind '%s'", s.Config.Mode, secretstore.KindAzureKV)
57
+ }
58
+
59
+ s.published = published
60
+ return nil
61
+}
src/go/plugin/agent/secrets/secretstore/backends/azure/provider.go
new
+81
@@ -0,0 +1,81 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package azure
4
+
5
+import (
6
+ "context"
7
+ _ "embed"
8
+ "net/http"
9
+ "regexp"
10
+ "time"
11
+
12
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
13
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/httpx"
14
+)
15
+
16
+var (
17
+ //go:embed config_schema.json
18
+ configSchema string
19
+ reAzureSafeName = regexp.MustCompile(`^[a-zA-Z0-9-]+$`)
20
+)
21
+
22
+type Config struct {
23
+ Mode string `json:"mode" yaml:"mode"`
24
+ ModeClient *ModeClientConfig `json:"mode_client,omitempty" yaml:"mode_client,omitempty"`
25
+ ModeManagedIdentity *ModeManagedIdentityConfig `json:"mode_managed_identity,omitempty" yaml:"mode_managed_identity,omitempty"`
26
+}
27
+
28
+type ModeClientConfig struct {
29
+ TenantID string `json:"tenant_id" yaml:"tenant_id"`
30
+ ClientID string `json:"client_id" yaml:"client_id"`
31
+ ClientSecret string `json:"client_secret" yaml:"client_secret"`
32
+}
33
+
34
+type ModeManagedIdentityConfig struct {
35
+ ClientID string `json:"client_id,omitempty" yaml:"client_id,omitempty"`
36
+}
37
+
38
+type provider struct {
39
+ apiClient *http.Client
40
+ imdsClient *http.Client
41
+ loginEndpointURL string
42
+}
43
+
44
+type store struct {
45
+ Config `yaml:",inline" json:""`
46
+ provider *provider
47
+ published *publishedStore
48
+}
49
+
50
+type publishedStore struct {
51
+ provider *provider
52
+ mode string
53
+ clientTenantID string
54
+ clientID string
55
+ clientSecret string
56
+ managedIdentityClientID string
57
+}
58
+
59
+func New() secretstore.Creator {
60
+ p := &provider{
61
+ apiClient: httpx.APIClient(10 * time.Second),
62
+ imdsClient: httpx.NoProxyClient(2 * time.Second),
63
+ }
64
+
65
+ return secretstore.Creator{
66
+ Kind: secretstore.KindAzureKV,
67
+ DisplayName: "Azure Key Vault",
68
+ Schema: configSchema,
69
+ Create: p.create,
70
+ }
71
+}
72
+
73
+func (p *provider) create() secretstore.Store {
74
+ return &store{provider: p}
75
+}
76
+
77
+func (s *store) Configuration() any { return &s.Config }
78
+
79
+func (s *store) Init(ctx context.Context) error { return s.init(ctx) }
80
+
81
+func (s *store) Publish() secretstore.PublishedStore { return s.published }
src/go/plugin/agent/secrets/secretstore/backends/azure/resolve.go
new
+172
@@ -0,0 +1,172 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package azure
4
+
5
+import (
6
+ "context"
7
+ "encoding/json"
8
+ "fmt"
9
+ "io"
10
+ "net/http"
11
+ "net/url"
12
+ "strings"
13
+
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
+)
17
+
18
+func (s *publishedStore) Resolve(ctx context.Context, req secretstore.ResolveRequest) (string, error) {
19
+ return s.resolve(ctx, req)
20
+}
21
+
22
+func (s *publishedStore) resolve(ctx context.Context, req secretstore.ResolveRequest) (string, error) {
23
+ vaultName, secretName, ok := splitOperand(req.Operand)
24
+ if !ok {
25
+ return "", fmt.Errorf("resolving secret '%s': store '%s': operand must be in format 'vault-name/secret-name'", req.Original, req.StoreKey)
26
+ }
27
+ if !reAzureSafeName.MatchString(vaultName) {
28
+ return "", fmt.Errorf("resolving secret '%s': store '%s': invalid vault name '%s'", req.Original, req.StoreKey, vaultName)
29
+ }
30
+ if !reAzureSafeName.MatchString(secretName) {
31
+ return "", fmt.Errorf("resolving secret '%s': store '%s': invalid secret name '%s'", req.Original, req.StoreKey, secretName)
32
+ }
33
+
34
+ token, err := s.accessToken(ctx)
35
+ if err != nil {
36
+ return "", fmt.Errorf("resolving secret '%s': store '%s': %w", req.Original, req.StoreKey, err)
37
+ }
38
+
39
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://%s.vault.azure.net/secrets/%s?api-version=7.4", vaultName, secretName), nil)
40
+ if err != nil {
41
+ return "", fmt.Errorf("resolving secret '%s': store '%s': creating request: %w", req.Original, req.StoreKey, err)
42
+ }
43
+ httpReq.Header.Set("Authorization", "Bearer "+token)
44
+
45
+ resp, err := s.provider.apiClient.Do(httpReq)
46
+ if err != nil {
47
+ return "", fmt.Errorf("resolving secret '%s': store '%s': request failed: %w", req.Original, req.StoreKey, err)
48
+ }
49
+ defer resp.Body.Close()
50
+
51
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
52
+ if err != nil {
53
+ return "", fmt.Errorf("resolving secret '%s': store '%s': reading response: %w", req.Original, req.StoreKey, err)
54
+ }
55
+ if resp.StatusCode != http.StatusOK {
56
+ return "", fmt.Errorf("resolving secret '%s': store '%s': Azure Key Vault returned HTTP %d: %s", req.Original, req.StoreKey, resp.StatusCode, httpx.TruncateBody(body))
57
+ }
58
+
59
+ var result struct {
60
+ Value string `json:"value"`
61
+ }
62
+ if err := json.Unmarshal(body, &result); err != nil {
63
+ return "", fmt.Errorf("resolving secret '%s': store '%s': parsing response: %w", req.Original, req.StoreKey, err)
64
+ }
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
+ return result.Value, nil
69
+}
70
+
71
+func splitOperand(operand string) (string, string, bool) {
72
+ vaultName, secretName, ok := strings.Cut(operand, "/")
73
+ return vaultName, secretName, ok && vaultName != "" && secretName != ""
74
+}
75
+
76
+func (s *publishedStore) accessToken(ctx context.Context) (string, error) {
77
+ switch s.mode {
78
+ case "client":
79
+ if s.clientTenantID == "" {
80
+ return "", fmt.Errorf("mode_client.tenant_id is required")
81
+ }
82
+ if s.clientID == "" {
83
+ return "", fmt.Errorf("mode_client.client_id is required")
84
+ }
85
+ if s.clientSecret == "" {
86
+ return "", fmt.Errorf("mode_client.client_secret is required")
87
+ }
88
+ return s.clientCredentialsToken(ctx, s.clientTenantID, s.clientID, s.clientSecret)
89
+ case "managed_identity":
90
+ var clientID string
91
+ if s.managedIdentityClientID != "" {
92
+ clientID = s.managedIdentityClientID
93
+ }
94
+ return s.managedIdentityToken(ctx, clientID)
95
+ default:
96
+ return "", fmt.Errorf("mode '%s' is invalid for azure-kv", s.mode)
97
+ }
98
+}
99
+
100
+func (s *publishedStore) clientCredentialsToken(ctx context.Context, tenantID, clientID, clientSecret string) (string, error) {
101
+ tokenURL := s.provider.loginEndpointURL
102
+ if tokenURL == "" {
103
+ tokenURL = fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantID)
104
+ }
105
+ form := url.Values{
106
+ "client_id": {clientID},
107
+ "client_secret": {clientSecret},
108
+ "scope": {"https://vault.azure.net/.default"},
109
+ "grant_type": {"client_credentials"},
110
+ }
111
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
112
+ if err != nil {
113
+ return "", fmt.Errorf("creating client credentials token request: %w", err)
114
+ }
115
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
116
+ resp, err := s.provider.apiClient.Do(req)
117
+ if err != nil {
118
+ return "", fmt.Errorf("client credentials token request failed: %w", err)
119
+ }
120
+ defer resp.Body.Close()
121
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
122
+ if err != nil {
123
+ return "", fmt.Errorf("reading client credentials token response: %w", err)
124
+ }
125
+ if resp.StatusCode != http.StatusOK {
126
+ return "", fmt.Errorf("client credentials token request returned HTTP %d: %s", resp.StatusCode, httpx.TruncateBody(body))
127
+ }
128
+ var result struct {
129
+ AccessToken string `json:"access_token"`
130
+ }
131
+ if err := json.Unmarshal(body, &result); err != nil {
132
+ return "", fmt.Errorf("parsing client credentials token response: %w", err)
133
+ }
134
+ if result.AccessToken == "" {
135
+ return "", fmt.Errorf("client credentials token response missing access_token")
136
+ }
137
+ return result.AccessToken, nil
138
+}
139
+
140
+func (s *publishedStore) managedIdentityToken(ctx context.Context, clientID string) (string, error) {
141
+ reqURL := "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://vault.azure.net"
142
+ if clientID != "" {
143
+ reqURL += "&client_id=" + url.QueryEscape(clientID)
144
+ }
145
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
146
+ if err != nil {
147
+ return "", fmt.Errorf("creating managed identity token request: %w", err)
148
+ }
149
+ req.Header.Set("Metadata", "true")
150
+ resp, err := s.provider.imdsClient.Do(req)
151
+ if err != nil {
152
+ return "", fmt.Errorf("managed identity token request failed: %w", err)
153
+ }
154
+ defer resp.Body.Close()
155
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
156
+ if err != nil {
157
+ return "", fmt.Errorf("reading managed identity token response: %w", err)
158
+ }
159
+ if resp.StatusCode != http.StatusOK {
160
+ return "", fmt.Errorf("managed identity token request returned HTTP %d: %s", resp.StatusCode, httpx.TruncateBody(body))
161
+ }
162
+ var result struct {
163
+ AccessToken string `json:"access_token"`
164
+ }
165
+ if err := json.Unmarshal(body, &result); err != nil {
166
+ return "", fmt.Errorf("parsing managed identity token response: %w", err)
167
+ }
168
+ if result.AccessToken == "" {
169
+ return "", fmt.Errorf("managed identity token response missing access_token")
170
+ }
171
+ return result.AccessToken, nil
172
+}
src/go/plugin/agent/secrets/secretstore/backends/gcp/config_schema.json
new
+72
@@ -0,0 +1,72 @@
1
+{
2
+ "jsonSchema": {
3
+ "$schema": "http://json-schema.org/draft-07/schema#",
4
+ "type": "object",
5
+ "title": "Google Secret Manager secretstore configuration.",
6
+ "properties": {
7
+ "mode": {
8
+ "title": "Mode",
9
+ "description": "GCP authentication mode.",
10
+ "type": "string",
11
+ "enum": [
12
+ "metadata",
13
+ "service_account_file"
14
+ ],
15
+ "default": "metadata"
16
+ }
17
+ },
18
+ "required": [
19
+ "mode"
20
+ ],
21
+ "dependencies": {
22
+ "mode": {
23
+ "oneOf": [
24
+ {
25
+ "properties": {
26
+ "mode": {
27
+ "const": "metadata"
28
+ }
29
+ }
30
+ },
31
+ {
32
+ "properties": {
33
+ "mode": {
34
+ "const": "service_account_file"
35
+ },
36
+ "mode_service_account_file": {
37
+ "title": "Service Account File",
38
+ "description": "Service account file settings used when mode is `service_account_file`.",
39
+ "type": "object",
40
+ "properties": {
41
+ "path": {
42
+ "title": "Path",
43
+ "description": "Path to the service account JSON file.",
44
+ "type": "string",
45
+ "minLength": 1
46
+ }
47
+ },
48
+ "required": [
49
+ "path"
50
+ ]
51
+ }
52
+ },
53
+ "required": [
54
+ "mode_service_account_file"
55
+ ]
56
+ }
57
+ ]
58
+ }
59
+ }
60
+ },
61
+ "uiSchema": {
62
+ "uiOptions": {
63
+ "fullPage": true
64
+ },
65
+ "mode": {
66
+ "ui:widget": "radio",
67
+ "ui:options": {
68
+ "inline": true
69
+ }
70
+ }
71
+ }
72
+}
src/go/plugin/agent/secrets/secretstore/backends/gcp/init.go
new
+39
@@ -0,0 +1,39 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package gcp
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "strings"
9
+
10
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
11
+)
12
+
13
+func (s *store) init(_ context.Context) error {
14
+ published := &publishedStore{provider: s.provider}
15
+
16
+ switch strings.TrimSpace(s.Config.Mode) {
17
+ case "metadata":
18
+ s.Config.Mode = "metadata"
19
+ s.Config.ModeServiceAccountFile = nil
20
+ published.mode = s.Config.Mode
21
+ case "service_account_file":
22
+ if s.Config.ModeServiceAccountFile == nil {
23
+ return fmt.Errorf("mode_service_account_file is required when mode is 'service_account_file'")
24
+ }
25
+ path := strings.TrimSpace(s.Config.ModeServiceAccountFile.Path)
26
+ if path == "" {
27
+ return fmt.Errorf("mode_service_account_file.path is required")
28
+ }
29
+ s.Config.Mode = "service_account_file"
30
+ s.Config.ModeServiceAccountFile.Path = path
31
+ published.mode = s.Config.Mode
32
+ published.serviceAccountFilePath = path
33
+ default:
34
+ return fmt.Errorf("mode '%s' is invalid for kind '%s'", s.Config.Mode, secretstore.KindGCPSM)
35
+ }
36
+
37
+ s.published = published
38
+ return nil
39
+}
src/go/plugin/agent/secrets/secretstore/backends/gcp/provider.go
new
+74
@@ -0,0 +1,74 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package gcp
4
+
5
+import (
6
+ "context"
7
+ _ "embed"
8
+ "net/http"
9
+ "regexp"
10
+ "time"
11
+
12
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
13
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/httpx"
14
+)
15
+
16
+var (
17
+ //go:embed config_schema.json
18
+ configSchema string
19
+ reGCPSafeProjectID = regexp.MustCompile(`^[a-zA-Z0-9._:-]+$`)
20
+ reGCPSafeName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
21
+)
22
+
23
+type Config struct {
24
+ Mode string `json:"mode" yaml:"mode"`
25
+ ModeServiceAccountFile *ModeServiceAccountFileConfig `json:"mode_service_account_file,omitempty" yaml:"mode_service_account_file,omitempty"`
26
+}
27
+
28
+type ModeServiceAccountFileConfig struct {
29
+ Path string `json:"path" yaml:"path"`
30
+}
31
+
32
+type provider struct {
33
+ apiClient *http.Client
34
+ metadataClient *http.Client
35
+ secretEndpoint string
36
+ now func() time.Time
37
+}
38
+
39
+type store struct {
40
+ Config `yaml:",inline" json:""`
41
+ provider *provider
42
+ published *publishedStore
43
+}
44
+
45
+type publishedStore struct {
46
+ provider *provider
47
+ mode string
48
+ serviceAccountFilePath string
49
+}
50
+
51
+func New() secretstore.Creator {
52
+ p := &provider{
53
+ apiClient: httpx.APIClient(10 * time.Second),
54
+ metadataClient: httpx.NoProxyClient(2 * time.Second),
55
+ now: time.Now,
56
+ }
57
+
58
+ return secretstore.Creator{
59
+ Kind: secretstore.KindGCPSM,
60
+ DisplayName: "Google Secret Manager",
61
+ Schema: configSchema,
62
+ Create: p.create,
63
+ }
64
+}
65
+
66
+func (p *provider) create() secretstore.Store {
67
+ return &store{provider: p}
68
+}
69
+
70
+func (s *store) Configuration() any { return &s.Config }
71
+
72
+func (s *store) Init(ctx context.Context) error { return s.init(ctx) }
73
+
74
+func (s *store) Publish() secretstore.PublishedStore { return s.published }
src/go/plugin/agent/secrets/secretstore/backends/gcp/resolve.go
new
+232
@@ -0,0 +1,232 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package gcp
4
+
5
+import (
6
+ "context"
7
+ "crypto"
8
+ cryptorand "crypto/rand"
9
+ "crypto/rsa"
10
+ "crypto/sha256"
11
+ "crypto/x509"
12
+ "encoding/base64"
13
+ "encoding/json"
14
+ "encoding/pem"
15
+ "fmt"
16
+ "io"
17
+ "net/http"
18
+ "net/url"
19
+ "os"
20
+ "strings"
21
+
22
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
23
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/httpx"
24
+)
25
+
26
+func (s *publishedStore) Resolve(ctx context.Context, req secretstore.ResolveRequest) (string, error) {
27
+ return s.resolve(ctx, req)
28
+}
29
+
30
+func (s *publishedStore) resolve(ctx context.Context, req secretstore.ResolveRequest) (string, error) {
31
+ project, secretName, version, ok := parseOperand(req.Operand)
32
+ if !ok {
33
+ return "", fmt.Errorf("resolving secret '%s': store '%s': operand must be in format 'project/secret' or 'project/secret/version'", req.Original, req.StoreKey)
34
+ }
35
+ if !reGCPSafeProjectID.MatchString(project) {
36
+ return "", fmt.Errorf("resolving secret '%s': store '%s': invalid project ID '%s'", req.Original, req.StoreKey, project)
37
+ }
38
+ if !reGCPSafeName.MatchString(secretName) {
39
+ return "", fmt.Errorf("resolving secret '%s': store '%s': invalid secret name '%s'", req.Original, req.StoreKey, secretName)
40
+ }
41
+ if !reGCPSafeName.MatchString(version) {
42
+ return "", fmt.Errorf("resolving secret '%s': store '%s': invalid version '%s'", req.Original, req.StoreKey, version)
43
+ }
44
+
45
+ token, err := s.accessToken(ctx)
46
+ if err != nil {
47
+ return "", fmt.Errorf("resolving secret '%s': store '%s': %w", req.Original, req.StoreKey, err)
48
+ }
49
+
50
+ baseURL := s.provider.secretEndpoint
51
+ if baseURL == "" {
52
+ baseURL = "https://secretmanager.googleapis.com"
53
+ }
54
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/v1/projects/%s/secrets/%s/versions/%s:access", baseURL, project, secretName, version), nil)
55
+ if err != nil {
56
+ return "", fmt.Errorf("resolving secret '%s': store '%s': creating request: %w", req.Original, req.StoreKey, err)
57
+ }
58
+ httpReq.Header.Set("Authorization", "Bearer "+token)
59
+ resp, err := s.provider.apiClient.Do(httpReq)
60
+ if err != nil {
61
+ return "", fmt.Errorf("resolving secret '%s': store '%s': request failed: %w", req.Original, req.StoreKey, err)
62
+ }
63
+ defer resp.Body.Close()
64
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
65
+ if err != nil {
66
+ return "", fmt.Errorf("resolving secret '%s': store '%s': reading response: %w", req.Original, req.StoreKey, err)
67
+ }
68
+ if resp.StatusCode != http.StatusOK {
69
+ return "", fmt.Errorf("resolving secret '%s': store '%s': GCP Secret Manager returned HTTP %d: %s", req.Original, req.StoreKey, resp.StatusCode, httpx.TruncateBody(body))
70
+ }
71
+ var result struct {
72
+ Payload struct {
73
+ Data string `json:"data"`
74
+ } `json:"payload"`
75
+ }
76
+ if err := json.Unmarshal(body, &result); err != nil {
77
+ return "", fmt.Errorf("resolving secret '%s': store '%s': parsing response: %w", req.Original, req.StoreKey, err)
78
+ }
79
+ decoded, err := base64.StdEncoding.DecodeString(result.Payload.Data)
80
+ if err != nil {
81
+ return "", fmt.Errorf("resolving secret '%s': store '%s': decoding secret data: %w", req.Original, req.StoreKey, err)
82
+ }
83
+ return string(decoded), nil
84
+}
85
+
86
+func parseOperand(operand string) (string, string, string, bool) {
87
+ project, rest, ok := strings.Cut(operand, "/")
88
+ if !ok || project == "" || rest == "" {
89
+ return "", "", "", false
90
+ }
91
+ secretName, version, hasVersion := strings.Cut(rest, "/")
92
+ if !hasVersion || version == "" {
93
+ version = "latest"
94
+ }
95
+ return project, secretName, version, secretName != ""
96
+}
97
+
98
+func (s *publishedStore) accessToken(ctx context.Context) (string, error) {
99
+ switch s.mode {
100
+ case "metadata":
101
+ return s.metadataToken(ctx)
102
+ case "service_account_file":
103
+ path := s.serviceAccountFilePath
104
+ if path == "" {
105
+ return "", fmt.Errorf("mode_service_account_file.path is required")
106
+ }
107
+ return s.serviceAccountToken(ctx, path)
108
+ default:
109
+ return "", fmt.Errorf("mode '%s' is invalid for gcp-sm", s.mode)
110
+ }
111
+}
112
+
113
+func (s *publishedStore) metadataToken(ctx context.Context) (string, error) {
114
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", nil)
115
+ if err != nil {
116
+ return "", fmt.Errorf("creating metadata token request: %w", err)
117
+ }
118
+ req.Header.Set("Metadata-Flavor", "Google")
119
+ resp, err := s.provider.metadataClient.Do(req)
120
+ if err != nil {
121
+ return "", fmt.Errorf("metadata token request failed: %w", err)
122
+ }
123
+ defer resp.Body.Close()
124
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
125
+ if err != nil {
126
+ return "", fmt.Errorf("reading metadata token response: %w", err)
127
+ }
128
+ if resp.StatusCode != http.StatusOK {
129
+ return "", fmt.Errorf("metadata token request returned HTTP %d: %s", resp.StatusCode, httpx.TruncateBody(body))
130
+ }
131
+ var result struct {
132
+ AccessToken string `json:"access_token"`
133
+ }
134
+ if err := json.Unmarshal(body, &result); err != nil {
135
+ return "", fmt.Errorf("parsing metadata token response: %w", err)
136
+ }
137
+ if result.AccessToken == "" {
138
+ return "", fmt.Errorf("metadata token response missing access_token")
139
+ }
140
+ return result.AccessToken, nil
141
+}
142
+
143
+func (s *publishedStore) serviceAccountToken(ctx context.Context, credFile string) (string, error) {
144
+ data, err := os.ReadFile(credFile)
145
+ if err != nil {
146
+ return "", fmt.Errorf("reading service account file '%s': %w", credFile, err)
147
+ }
148
+ var sa struct {
149
+ ClientEmail string `json:"client_email"`
150
+ PrivateKey string `json:"private_key"`
151
+ TokenURI string `json:"token_uri"`
152
+ }
153
+ if err := json.Unmarshal(data, &sa); err != nil {
154
+ return "", fmt.Errorf("parsing service account JSON: %w", err)
155
+ }
156
+ if sa.ClientEmail == "" || sa.PrivateKey == "" || sa.TokenURI == "" {
157
+ return "", fmt.Errorf("service account JSON missing required fields (client_email, private_key, token_uri)")
158
+ }
159
+ now := s.provider.now().Unix()
160
+ signedJWT, err := createSignedJWT(sa.ClientEmail, sa.TokenURI, sa.PrivateKey, now)
161
+ if err != nil {
162
+ return "", err
163
+ }
164
+ form := url.Values{
165
+ "grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"},
166
+ "assertion": {signedJWT},
167
+ }
168
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, sa.TokenURI, strings.NewReader(form.Encode()))
169
+ if err != nil {
170
+ return "", fmt.Errorf("creating token exchange request: %w", err)
171
+ }
172
+ httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
173
+ resp, err := s.provider.apiClient.Do(httpReq)
174
+ if err != nil {
175
+ return "", fmt.Errorf("token exchange request failed: %w", err)
176
+ }
177
+ defer resp.Body.Close()
178
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
179
+ if err != nil {
180
+ return "", fmt.Errorf("reading token exchange response: %w", err)
181
+ }
182
+ if resp.StatusCode != http.StatusOK {
183
+ return "", fmt.Errorf("token exchange returned HTTP %d: %s", resp.StatusCode, httpx.TruncateBody(body))
184
+ }
185
+ var result struct {
186
+ AccessToken string `json:"access_token"`
187
+ }
188
+ if err := json.Unmarshal(body, &result); err != nil {
189
+ return "", fmt.Errorf("parsing token exchange response: %w", err)
190
+ }
191
+ if result.AccessToken == "" {
192
+ return "", fmt.Errorf("token exchange response missing access_token")
193
+ }
194
+ return result.AccessToken, nil
195
+}
196
+
197
+func createSignedJWT(clientEmail, tokenURI, privateKeyPEM string, nowUnix int64) (string, error) {
198
+ header := `{"alg":"RS256","typ":"JWT"}`
199
+ claimsMap := map[string]any{
200
+ "iss": clientEmail,
201
+ "scope": "https://www.googleapis.com/auth/cloud-platform",
202
+ "aud": tokenURI,
203
+ "iat": nowUnix,
204
+ "exp": nowUnix + 3600,
205
+ }
206
+ claimsJSON, err := json.Marshal(claimsMap)
207
+ if err != nil {
208
+ return "", fmt.Errorf("marshaling JWT claims: %w", err)
209
+ }
210
+ headerB64 := base64.RawURLEncoding.EncodeToString([]byte(header))
211
+ claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
212
+ unsigned := headerB64 + "." + claimsB64
213
+
214
+ block, _ := pem.Decode([]byte(privateKeyPEM))
215
+ if block == nil {
216
+ return "", fmt.Errorf("failed to decode PEM private key")
217
+ }
218
+ key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
219
+ if err != nil {
220
+ return "", fmt.Errorf("parsing private key: %w", err)
221
+ }
222
+ rsaKey, ok := key.(*rsa.PrivateKey)
223
+ if !ok {
224
+ return "", fmt.Errorf("private key is not RSA")
225
+ }
226
+ hashed := sha256.Sum256([]byte(unsigned))
227
+ sig, err := rsa.SignPKCS1v15(cryptorand.Reader, rsaKey, crypto.SHA256, hashed[:])
228
+ if err != nil {
229
+ return "", fmt.Errorf("signing JWT: %w", err)
230
+ }
231
+ return unsigned + "." + base64.RawURLEncoding.EncodeToString(sig), nil
232
+}
src/go/plugin/agent/secrets/secretstore/backends/registry.go
new
+20
@@ -0,0 +1,20 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package backends
4
+
5
+import (
6
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
7
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/backends/aws"
8
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/backends/azure"
9
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/backends/gcp"
10
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/backends/vault"
11
+)
12
+
13
+func Creators() []secretstore.Creator {
14
+ return []secretstore.Creator{
15
+ aws.New(),
16
+ azure.New(),
17
+ gcp.New(),
18
+ vault.New(),
19
+ }
20
+}
src/go/plugin/agent/secrets/secretstore/backends/vault/config_schema.json
new
+112
@@ -0,0 +1,112 @@
1
+{
2
+ "jsonSchema": {
3
+ "$schema": "http://json-schema.org/draft-07/schema#",
4
+ "type": "object",
5
+ "title": "Vault secretstore configuration.",
6
+ "properties": {
7
+ "mode": {
8
+ "title": "Mode",
9
+ "description": "How Vault authentication is provided.",
10
+ "type": "string",
11
+ "enum": [
12
+ "token",
13
+ "token_file"
14
+ ],
15
+ "default": "token"
16
+ },
17
+ "addr": {
18
+ "title": "Address",
19
+ "description": "Vault server address / base URL.",
20
+ "type": "string"
21
+ },
22
+ "namespace": {
23
+ "title": "Namespace",
24
+ "description": "Optional Vault namespace.",
25
+ "type": "string"
26
+ },
27
+ "tls_skip_verify": {
28
+ "title": "Skip TLS verification",
29
+ "description": "Disable TLS certificate verification for Vault requests.",
30
+ "type": "boolean",
31
+ "default": false
32
+ }
33
+ },
34
+ "required": [
35
+ "mode",
36
+ "addr"
37
+ ],
38
+ "dependencies": {
39
+ "mode": {
40
+ "oneOf": [
41
+ {
42
+ "properties": {
43
+ "mode": {
44
+ "const": "token"
45
+ },
46
+ "mode_token": {
47
+ "title": "Token",
48
+ "description": "Token settings used when mode is `token`.",
49
+ "type": "object",
50
+ "properties": {
51
+ "token": {
52
+ "title": "Token",
53
+ "description": "Vault token value.",
54
+ "type": "string",
55
+ "sensitive": true
56
+ }
57
+ },
58
+ "required": [
59
+ "token"
60
+ ]
61
+ }
62
+ },
63
+ "required": [
64
+ "mode_token"
65
+ ]
66
+ },
67
+ {
68
+ "properties": {
69
+ "mode": {
70
+ "const": "token_file"
71
+ },
72
+ "mode_token_file": {
73
+ "title": "Token File",
74
+ "description": "Token file settings used when mode is `token_file`.",
75
+ "type": "object",
76
+ "properties": {
77
+ "path": {
78
+ "title": "Path",
79
+ "description": "Path to a file containing the Vault token.",
80
+ "type": "string"
81
+ }
82
+ },
83
+ "required": [
84
+ "path"
85
+ ]
86
+ }
87
+ },
88
+ "required": [
89
+ "mode_token_file"
90
+ ]
91
+ }
92
+ ]
93
+ }
94
+ }
95
+ },
96
+ "uiSchema": {
97
+ "uiOptions": {
98
+ "fullPage": true
99
+ },
100
+ "mode": {
101
+ "ui:widget": "radio",
102
+ "ui:options": {
103
+ "inline": true
104
+ }
105
+ },
106
+ "mode_token": {
107
+ "token": {
108
+ "ui:widget": "password"
109
+ }
110
+ }
111
+ }
112
+}
src/go/plugin/agent/secrets/secretstore/backends/vault/init.go
new
+62
@@ -0,0 +1,62 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package vault
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "strings"
9
+
10
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
11
+)
12
+
13
+func (s *store) init(_ context.Context) error {
14
+ published := &publishedStore{
15
+ provider: s.provider,
16
+ }
17
+
18
+ switch strings.TrimSpace(s.Config.Mode) {
19
+ case "token":
20
+ if s.Config.ModeToken == nil {
21
+ return fmt.Errorf("mode_token is required when mode is 'token'")
22
+ }
23
+ token := strings.TrimSpace(s.Config.ModeToken.Token)
24
+ if token == "" {
25
+ return fmt.Errorf("mode_token.token is required")
26
+ }
27
+ s.Config.Mode = "token"
28
+ s.Config.ModeToken.Token = token
29
+ s.Config.ModeTokenFile = nil
30
+ published.mode = s.Config.Mode
31
+ published.tokenValue = token
32
+ case "token_file":
33
+ if s.Config.ModeTokenFile == nil {
34
+ return fmt.Errorf("mode_token_file is required when mode is 'token_file'")
35
+ }
36
+ path := strings.TrimSpace(s.Config.ModeTokenFile.Path)
37
+ if path == "" {
38
+ return fmt.Errorf("mode_token_file.path is required")
39
+ }
40
+ s.Config.Mode = "token_file"
41
+ s.Config.ModeTokenFile.Path = path
42
+ s.Config.ModeToken = nil
43
+ published.mode = s.Config.Mode
44
+ published.tokenFilePath = path
45
+ default:
46
+ return fmt.Errorf("mode '%s' is invalid for kind '%s'", s.Config.Mode, secretstore.KindVault)
47
+ }
48
+
49
+ addr := strings.TrimSpace(s.Config.Addr)
50
+ if addr == "" {
51
+ return fmt.Errorf("addr is required")
52
+ }
53
+ s.Config.Addr = addr
54
+ published.addr = addr
55
+
56
+ s.Config.Namespace = strings.TrimSpace(s.Config.Namespace)
57
+ published.namespaceValue = s.Config.Namespace
58
+ published.tlsSkipVerify = s.Config.TLSSkipVerify
59
+
60
+ s.published = published
61
+ return nil
62
+}
src/go/plugin/agent/secrets/secretstore/backends/vault/provider.go
new
+78
@@ -0,0 +1,78 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package vault
4
+
5
+import (
6
+ "context"
7
+ _ "embed"
8
+ "net/http"
9
+ "time"
10
+
11
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
12
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/httpx"
13
+)
14
+
15
+//go:embed config_schema.json
16
+var configSchema string
17
+
18
+type Config struct {
19
+ Mode string `json:"mode" yaml:"mode"`
20
+ ModeToken *ModeTokenConfig `json:"mode_token,omitempty" yaml:"mode_token,omitempty"`
21
+ ModeTokenFile *ModeTokenFileConfig `json:"mode_token_file,omitempty" yaml:"mode_token_file,omitempty"`
22
+ Addr string `json:"addr" yaml:"addr"`
23
+ Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"`
24
+ TLSSkipVerify bool `json:"tls_skip_verify,omitempty" yaml:"tls_skip_verify,omitempty"`
25
+}
26
+
27
+type ModeTokenConfig struct {
28
+ Token string `json:"token" yaml:"token"`
29
+}
30
+
31
+type ModeTokenFileConfig struct {
32
+ Path string `json:"path" yaml:"path"`
33
+}
34
+
35
+type provider struct {
36
+ httpClient *http.Client
37
+ httpClientInsecure *http.Client
38
+}
39
+
40
+type store struct {
41
+ Config `yaml:",inline" json:""`
42
+ provider *provider
43
+ published *publishedStore
44
+}
45
+
46
+type publishedStore struct {
47
+ provider *provider
48
+ mode string
49
+ tokenValue string
50
+ tokenFilePath string
51
+ addr string
52
+ namespaceValue string
53
+ tlsSkipVerify bool
54
+}
55
+
56
+func New() secretstore.Creator {
57
+ p := &provider{
58
+ httpClient: httpx.VaultClient(10 * time.Second),
59
+ httpClientInsecure: httpx.VaultInsecureClient(10 * time.Second),
60
+ }
61
+
62
+ return secretstore.Creator{
63
+ Kind: secretstore.KindVault,
64
+ DisplayName: "Vault",
65
+ Schema: configSchema,
66
+ Create: p.create,
67
+ }
68
+}
69
+
70
+func (p *provider) create() secretstore.Store {
71
+ return &store{provider: p}
72
+}
73
+
74
+func (s *store) Configuration() any { return &s.Config }
75
+
76
+func (s *store) Init(ctx context.Context) error { return s.init(ctx) }
77
+
78
+func (s *store) Publish() secretstore.PublishedStore { return s.published }
src/go/plugin/agent/secrets/secretstore/backends/vault/resolve.go
new
+195
@@ -0,0 +1,195 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package vault
4
+
5
+import (
6
+ "context"
7
+ "encoding/json"
8
+ "fmt"
9
+ "io"
10
+ "net/http"
11
+ "os"
12
+ "strings"
13
+
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
+)
17
+
18
+func (s *publishedStore) Resolve(ctx context.Context, req secretstore.ResolveRequest) (string, error) {
19
+ return s.resolve(ctx, req)
20
+}
21
+
22
+func (s *publishedStore) resolve(ctx context.Context, req secretstore.ResolveRequest) (string, error) {
23
+ path, key, ok := strings.Cut(req.Operand, "#")
24
+ if !ok || key == "" {
25
+ return "", fmt.Errorf("resolving secret '%s': store '%s': operand must be in format 'path#key'", req.Original, req.StoreKey)
26
+ }
27
+ if path == "" {
28
+ return "", fmt.Errorf("resolving secret '%s': store '%s': vault path is empty", req.Original, req.StoreKey)
29
+ }
30
+ if strings.Contains(path, "..") || strings.ContainsAny(path, "?#") {
31
+ return "", fmt.Errorf("resolving secret '%s': store '%s': vault path contains invalid characters", req.Original, req.StoreKey)
32
+ }
33
+
34
+ addr, err := s.address()
35
+ if err != nil {
36
+ return "", fmt.Errorf("resolving secret '%s': store '%s': %w", req.Original, req.StoreKey, err)
37
+ }
38
+
39
+ token, err := s.token()
40
+ if err != nil {
41
+ return "", fmt.Errorf("resolving secret '%s': store '%s': %w", req.Original, req.StoreKey, err)
42
+ }
43
+
44
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(addr, "/")+"/v1/"+path, nil)
45
+ if err != nil {
46
+ return "", fmt.Errorf("resolving secret '%s': store '%s': %w", req.Original, req.StoreKey, err)
47
+ }
48
+ httpReq.Header.Set("X-Vault-Token", token)
49
+ if ns, ok := s.namespace(); ok {
50
+ httpReq.Header.Set("X-Vault-Namespace", ns)
51
+ }
52
+
53
+ client := s.provider.httpClient
54
+ if s.skipVerify() {
55
+ client = s.provider.httpClientInsecure
56
+ }
57
+
58
+ resp, err := client.Do(httpReq)
59
+ if err != nil {
60
+ return "", fmt.Errorf("resolving secret '%s': store '%s': vault request failed: %w", req.Original, req.StoreKey, err)
61
+ }
62
+ defer resp.Body.Close()
63
+
64
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
65
+ if err != nil {
66
+ return "", fmt.Errorf("resolving secret '%s': store '%s': reading vault response: %w", req.Original, req.StoreKey, err)
67
+ }
68
+ if resp.StatusCode != http.StatusOK {
69
+ return "", fmt.Errorf("resolving secret '%s': store '%s': vault returned HTTP %d: %s", req.Original, req.StoreKey, resp.StatusCode, httpx.TruncateBody(body))
70
+ }
71
+ return parseResponse(body, key, req)
72
+}
73
+
74
+func (s *publishedStore) address() (string, error) {
75
+ if s.addr == "" {
76
+ return "", fmt.Errorf("addr is required")
77
+ }
78
+ return s.addr, nil
79
+}
80
+
81
+func (s *publishedStore) namespace() (string, bool) {
82
+ if s.namespaceValue == "" {
83
+ return "", false
84
+ }
85
+ return s.namespaceValue, true
86
+}
87
+
88
+func (s *publishedStore) skipVerify() bool {
89
+ return s.tlsSkipVerify
90
+}
91
+
92
+func (s *publishedStore) token() (string, error) {
93
+ switch s.mode {
94
+ case "token":
95
+ if s.tokenValue == "" {
96
+ return "", fmt.Errorf("mode_token.token is required")
97
+ }
98
+ return s.tokenValue, nil
99
+ case "token_file":
100
+ path := s.tokenFilePath
101
+ if path == "" {
102
+ return "", fmt.Errorf("mode_token_file.path is required")
103
+ }
104
+ data, err := os.ReadFile(path)
105
+ if err != nil {
106
+ return "", fmt.Errorf("cannot read token file '%s': %w", path, err)
107
+ }
108
+ token := strings.TrimSpace(string(data))
109
+ if token == "" {
110
+ return "", fmt.Errorf("token file '%s' is empty", path)
111
+ }
112
+ return token, nil
113
+ default:
114
+ return "", fmt.Errorf("mode '%s' is invalid for vault", s.mode)
115
+ }
116
+}
117
+
118
+func parseResponse(body []byte, key string, req secretstore.ResolveRequest) (string, error) {
119
+ var resp vaultReadResponse
120
+ if err := json.Unmarshal(body, &resp); err != nil {
121
+ return "", fmt.Errorf("resolving secret '%s': store '%s': parsing vault response: %w", req.Original, req.StoreKey, err)
122
+ }
123
+
124
+ var kvV2 vaultKV2ReadPayload
125
+ if err := json.Unmarshal(resp.Data, &kvV2); err != nil {
126
+ return "", fmt.Errorf("resolving secret '%s': store '%s': parsing vault response data: %w", req.Original, req.StoreKey, err)
127
+ }
128
+ if kvV2.isDetected() {
129
+ data, err := unmarshalVaultDataPayload(kvV2.Data)
130
+ if err != nil {
131
+ return "", fmt.Errorf("resolving secret '%s': store '%s': parsing vault response data: %w", req.Original, req.StoreKey, err)
132
+ }
133
+ if val, ok := data[key]; ok {
134
+ return valueToString(val)
135
+ }
136
+ return "", fmt.Errorf("resolving secret '%s': store '%s': key '%s' not found in vault response", req.Original, req.StoreKey, key)
137
+ }
138
+
139
+ payload, err := unmarshalVaultDataPayload(resp.Data)
140
+ if err != nil {
141
+ return "", fmt.Errorf("resolving secret '%s': store '%s': parsing vault response data: %w", req.Original, req.StoreKey, err)
142
+ }
143
+ if val, ok := payload[key]; ok {
144
+ return valueToString(val)
145
+ }
146
+
147
+ return "", fmt.Errorf("resolving secret '%s': store '%s': key '%s' not found in vault response", req.Original, req.StoreKey, key)
148
+}
149
+
150
+type vaultReadResponse struct {
151
+ Data json.RawMessage `json:"data"`
152
+}
153
+
154
+type vaultKV2ReadPayload struct {
155
+ Data json.RawMessage `json:"data"`
156
+ Metadata *vaultKV2Metadata `json:"metadata"`
157
+}
158
+
159
+type vaultKV2Metadata struct {
160
+ CreatedTime *string `json:"created_time"`
161
+ DeletionTime *string `json:"deletion_time"`
162
+ Destroyed *bool `json:"destroyed"`
163
+ Version *int `json:"version"`
164
+}
165
+
166
+func (p vaultKV2ReadPayload) isDetected() bool {
167
+ // KV v2 read responses expose secret values under data.data and include
168
+ // version metadata alongside it. Requiring the standard metadata fields
169
+ // avoids misclassifying KV v1 secrets that happen to have top-level
170
+ // keys named "data" and "metadata".
171
+ return len(p.Data) != 0 && p.Metadata != nil &&
172
+ p.Metadata.CreatedTime != nil &&
173
+ p.Metadata.DeletionTime != nil &&
174
+ p.Metadata.Destroyed != nil &&
175
+ p.Metadata.Version != nil
176
+}
177
+
178
+func unmarshalVaultDataPayload(data json.RawMessage) (map[string]any, error) {
179
+ var payload map[string]any
180
+ if err := json.Unmarshal(data, &payload); err != nil {
181
+ return nil, err
182
+ }
183
+ return payload, nil
184
+}
185
+
186
+func valueToString(val any) (string, error) {
187
+ if s, ok := val.(string); ok {
188
+ return s, nil
189
+ }
190
+ b, err := json.Marshal(val)
191
+ if err != nil {
192
+ return "", fmt.Errorf("encoding vault value: %w", err)
193
+ }
194
+ return string(b), nil
195
+}
src/go/plugin/agent/secrets/secretstore/backends/vault/resolve_test.go
new
+69
@@ -0,0 +1,69 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package vault
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
9
+ "github.com/stretchr/testify/assert"
10
+ "github.com/stretchr/testify/require"
11
+)
12
+
13
+func TestParseResponse(t *testing.T) {
14
+ req := secretstore.ResolveRequest{
15
+ StoreKey: "vault:vault_prod",
16
+ StoreKind: secretstore.KindVault,
17
+ StoreName: "vault_prod",
18
+ Original: "${store:vault:vault_prod:secret/data/mysql#password}",
19
+ }
20
+
21
+ const kv2Body = `{"data":{"data":{"password":"s3cr3t"},"metadata":{"created_time":"2024-01-01T00:00:00Z","deletion_time":"","destroyed":false,"version":3}}}`
22
+
23
+ tests := map[string]struct {
24
+ body string
25
+ key string
26
+ want string
27
+ wantErrContains string
28
+ }{
29
+ "kv1 returns requested key": {
30
+ body: `{"data":{"password":"s3cr3t","username":"netdata"}}`,
31
+ key: "password",
32
+ want: "s3cr3t",
33
+ },
34
+ "kv1 with top-level data key still uses kv1 lookup": {
35
+ body: `{"data":{"data":{"nested":"value"},"password":"s3cr3t"}}`,
36
+ key: "password",
37
+ want: "s3cr3t",
38
+ },
39
+ "kv2 returns requested nested key": {
40
+ body: kv2Body,
41
+ key: "password",
42
+ want: "s3cr3t",
43
+ },
44
+ "kv2 does not leak metadata envelope field": {
45
+ body: kv2Body,
46
+ key: "metadata",
47
+ wantErrContains: "key 'metadata' not found",
48
+ },
49
+ "kv2 does not leak data envelope field": {
50
+ body: kv2Body,
51
+ key: "data",
52
+ wantErrContains: "key 'data' not found",
53
+ },
54
+ }
55
+
56
+ for name, tc := range tests {
57
+ t.Run(name, func(t *testing.T) {
58
+ got, err := parseResponse([]byte(tc.body), tc.key, req)
59
+ if tc.wantErrContains != "" {
60
+ require.Error(t, err)
61
+ assert.Contains(t, err.Error(), tc.wantErrContains)
62
+ return
63
+ }
64
+
65
+ require.NoError(t, err)
66
+ assert.Equal(t, tc.want, got)
67
+ })
68
+ }
69
+}
src/go/plugin/agent/secrets/secretstore/compile.go
new
+37
@@ -0,0 +1,37 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore
4
+
5
+import "gopkg.in/yaml.v2"
6
+
7
+func cloneConfig(in Config) Config {
8
+ if len(in) == 0 {
9
+ return nil
10
+ }
11
+
12
+ type plain Config
13
+ bs, err := yaml.Marshal((plain)(in))
14
+ if err != nil {
15
+ return nil
16
+ }
17
+
18
+ var out Config
19
+ if err := yaml.Unmarshal(bs, &out); err != nil {
20
+ return nil
21
+ }
22
+ return out
23
+}
24
+
25
+func cloneStoreStatus(status StoreStatus) StoreStatus {
26
+ out := status
27
+ out.LastValidation = cloneValidationStatus(status.LastValidation)
28
+ return out
29
+}
30
+
31
+func cloneValidationStatus(status *ValidationStatus) *ValidationStatus {
32
+ if status == nil {
33
+ return nil
34
+ }
35
+ copyStatus := *status
36
+ return ©Status
37
+}
src/go/plugin/agent/secrets/secretstore/doc.go
new
+12
@@ -0,0 +1,12 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+// Package secretstore defines control-plane contracts for go.d v2 secretstores.
4
+//
5
+// Current scope:
6
+// - core data model types
7
+// - raw-config and runtime contracts
8
+// - manager and snapshot provider interfaces
9
+//
10
+// Runtime behavior (validation, normalization, publication, and resolution)
11
+// is implemented by the service/runtime packages.
12
+package secretstore
src/go/plugin/agent/secrets/secretstore/fileconfig.go
new
+123
@@ -0,0 +1,123 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore
4
+
5
+import (
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+
11
+ "github.com/netdata/netdata/go/plugins/pkg/pluginconfig"
12
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
13
+ "gopkg.in/yaml.v2"
14
+)
15
+
16
+type fileRootConfig struct {
17
+ Jobs []map[string]any `yaml:"jobs"`
18
+}
19
+
20
+func LoadFileConfigs(roots []string) ([]Config, []error) {
21
+ var cfgs []Config
22
+ var errs []error
23
+
24
+ for _, root := range roots {
25
+ root = strings.TrimSpace(root)
26
+ if root == "" {
27
+ continue
28
+ }
29
+
30
+ dir := filepath.Join(root, "ss")
31
+ entries, err := os.ReadDir(dir)
32
+ if err != nil {
33
+ continue
34
+ }
35
+
36
+ for _, entry := range entries {
37
+ if !entry.Type().IsRegular() {
38
+ continue
39
+ }
40
+
41
+ ext := strings.ToLower(filepath.Ext(entry.Name()))
42
+ if ext != ".conf" && ext != ".yaml" && ext != ".yml" {
43
+ continue
44
+ }
45
+
46
+ path := filepath.Join(dir, entry.Name())
47
+ stem := strings.TrimSpace(strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())))
48
+
49
+ jobs, jobErrs := loadFileConfigJobs(path)
50
+ errs = append(errs, jobErrs...)
51
+ if len(jobs) == 0 {
52
+ continue
53
+ }
54
+
55
+ sourceType := fileConfigSourceType(path)
56
+ source := "file=" + path
57
+ for i, job := range jobs {
58
+ cfg := Config(job)
59
+ if strings.TrimSpace(cfg.Name()) == "" {
60
+ errs = append(errs, fmt.Errorf("secretstore file config '%s' job %d: store name is required", path, i+1))
61
+ continue
62
+ }
63
+ if cfg.Kind() == "" {
64
+ cfg.SetKind(StoreKind(stem))
65
+ }
66
+ cfg.SetSource(source)
67
+ cfg.SetSourceType(sourceType)
68
+ if err := validateFileConfig(cfg); err != nil {
69
+ errs = append(errs, fmt.Errorf("secretstore file config '%s' job %d: %w", path, i+1, err))
70
+ continue
71
+ }
72
+ cfgs = append(cfgs, cfg)
73
+ }
74
+ }
75
+ }
76
+
77
+ return cfgs, errs
78
+}
79
+
80
+func loadFileConfigJobs(path string) ([]map[string]any, []error) {
81
+ data, err := os.ReadFile(path)
82
+ if err != nil {
83
+ return nil, []error{fmt.Errorf("secretstore file config '%s': %w", path, err)}
84
+ }
85
+
86
+ var root fileRootConfig
87
+ if err := yaml.Unmarshal(data, &root); err != nil {
88
+ return nil, []error{fmt.Errorf("secretstore file config '%s': %w", path, err)}
89
+ }
90
+
91
+ return root.Jobs, nil
92
+}
93
+
94
+func fileConfigSourceType(path string) string {
95
+ if pluginconfig.IsStock(path) {
96
+ return confgroup.TypeStock
97
+ }
98
+ return confgroup.TypeUser
99
+}
100
+
101
+func validateFileConfig(cfg Config) error {
102
+ if cfg == nil {
103
+ return fmt.Errorf("store config is nil")
104
+ }
105
+ if strings.TrimSpace(cfg.Name()) == "" {
106
+ return fmt.Errorf("store name is required")
107
+ }
108
+ if err := validateStoreName(cfg.Name()); err != nil {
109
+ return fmt.Errorf("invalid store name '%s': %w", cfg.Name(), err)
110
+ }
111
+ if strings.TrimSpace(string(cfg.Kind())) == "" {
112
+ return fmt.Errorf("store kind is required")
113
+ }
114
+ if strings.TrimSpace(cfg.Source()) == "" {
115
+ return fmt.Errorf("store source is required")
116
+ }
117
+ switch cfg.SourceType() {
118
+ case confgroup.TypeUser, confgroup.TypeStock:
119
+ return nil
120
+ default:
121
+ return fmt.Errorf("invalid store source type '%s'", cfg.SourceType())
122
+ }
123
+}
src/go/plugin/agent/secrets/secretstore/fileconfig_test.go
new
+132
@@ -0,0 +1,132 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore_test
4
+
5
+import (
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
12
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
13
+ "github.com/stretchr/testify/assert"
14
+ "github.com/stretchr/testify/require"
15
+)
16
+
17
+func TestLoadFileConfigs(t *testing.T) {
18
+ t.Run("loads user and stock roots with filename defaults", func(t *testing.T) {
19
+ base := t.TempDir()
20
+ userRoot := filepath.Join(base, "etc", "netdata", "go.d")
21
+ stockRoot := filepath.Join(base, "usr", "lib", "netdata", "conf.d", "go.d")
22
+
23
+ mustWriteSecretStoreConfigFile(t, filepath.Join(userRoot, "ss", "vault.conf"), `
24
+jobs:
25
+ - name: vault_prod
26
+ mode: token
27
+ mode_token:
28
+ token: vault-token
29
+ addr: https://vault.example
30
+`)
31
+ mustWriteSecretStoreConfigFile(t, filepath.Join(userRoot, "ss", "azure-kv.yaml"), `
32
+jobs:
33
+ - name: azure_prod
34
+ mode: managed_identity
35
+`)
36
+ mustWriteSecretStoreConfigFile(t, filepath.Join(stockRoot, "ss", "aws-sm.conf"), `
37
+jobs:
38
+ - name: aws_prod
39
+ auth_mode: env
40
+ region: us-east-1
41
+`)
42
+ mustWriteSecretStoreConfigFile(t, filepath.Join(stockRoot, "ss", "gcp-sm.yml"), `
43
+jobs:
44
+ - name: gcp_prod
45
+ mode: metadata
46
+`)
47
+
48
+ cfgs, errs := secretstore.LoadFileConfigs([]string{userRoot, stockRoot})
49
+ require.Empty(t, errs)
50
+ require.Len(t, cfgs, 4)
51
+
52
+ assert.Equal(t, secretstore.KindAzureKV, cfgs[0].Kind())
53
+ assert.Equal(t, confgroup.TypeUser, cfgs[0].SourceType())
54
+ assert.Contains(t, cfgs[0].Source(), "file="+filepath.Join(userRoot, "ss", "azure-kv.yaml"))
55
+
56
+ assert.Equal(t, secretstore.KindVault, cfgs[1].Kind())
57
+ assert.Equal(t, confgroup.TypeUser, cfgs[1].SourceType())
58
+
59
+ assert.Equal(t, secretstore.KindAWSSM, cfgs[2].Kind())
60
+ assert.Equal(t, confgroup.TypeStock, cfgs[2].SourceType())
61
+
62
+ assert.Equal(t, secretstore.KindGCPSM, cfgs[3].Kind())
63
+ assert.Equal(t, confgroup.TypeStock, cfgs[3].SourceType())
64
+ })
65
+
66
+ t.Run("keeps explicit kind and allows unknown file stem", func(t *testing.T) {
67
+ base := t.TempDir()
68
+ userRoot := filepath.Join(base, "etc", "netdata", "go.d")
69
+
70
+ mustWriteSecretStoreConfigFile(t, filepath.Join(userRoot, "ss", "custom.conf"), `
71
+jobs:
72
+ - name: explicit_kind
73
+ kind: vault
74
+ mode: token
75
+ mode_token:
76
+ token: vault-token
77
+ addr: https://vault.example
78
+ - name: inferred_kind
79
+ mode: token
80
+ mode_token:
81
+ token: custom-token
82
+ addr: https://vault.example
83
+`)
84
+
85
+ cfgs, errs := secretstore.LoadFileConfigs([]string{userRoot})
86
+ require.Empty(t, errs)
87
+ require.Len(t, cfgs, 2)
88
+ assert.Equal(t, secretstore.KindVault, cfgs[0].Kind())
89
+ assert.Equal(t, secretstore.StoreKind("custom"), cfgs[1].Kind())
90
+ })
91
+
92
+ t.Run("skips malformed files and non-keyable jobs", func(t *testing.T) {
93
+ base := t.TempDir()
94
+ userRoot := filepath.Join(base, "etc", "netdata", "go.d")
95
+
96
+ mustWriteSecretStoreConfigFile(t, filepath.Join(userRoot, "ss", "vault.conf"), `
97
+jobs:
98
+ - mode: token
99
+ mode_token:
100
+ token: missing-name
101
+ addr: https://vault.example
102
+ - name: vault_prod
103
+ mode: token
104
+ mode_token:
105
+ token: vault-token
106
+ addr: https://vault.example
107
+`)
108
+ mustWriteSecretStoreConfigFile(t, filepath.Join(userRoot, "ss", "gcp-sm.conf"), `
109
+jobs:
110
+ - name: gcp_prod
111
+ mode: [
112
+`)
113
+
114
+ cfgs, errs := secretstore.LoadFileConfigs([]string{userRoot})
115
+ require.Len(t, cfgs, 1)
116
+ assert.Equal(t, "vault_prod", cfgs[0].Name())
117
+ assert.Len(t, errs, 2)
118
+ var messages []string
119
+ for _, err := range errs {
120
+ messages = append(messages, err.Error())
121
+ }
122
+ joined := strings.Join(messages, "\n")
123
+ assert.Contains(t, joined, "store name is required")
124
+ assert.Contains(t, joined, "secretstore file config")
125
+ })
126
+}
127
+
128
+func mustWriteSecretStoreConfigFile(t *testing.T, path, content string) {
129
+ t.Helper()
130
+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
131
+ require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
132
+}
src/go/plugin/agent/secrets/secretstore/helpers.go
new
+9
@@ -0,0 +1,9 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore
4
+
5
+import "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
6
+
7
+func validateStoreName(name string) error {
8
+ return dyncfg.ValidateJobName(name)
9
+}
src/go/plugin/agent/secrets/secretstore/internal/envx/envx.go
new
+68
@@ -0,0 +1,68 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package envx
4
+
5
+import (
6
+ "fmt"
7
+ "os"
8
+ "regexp"
9
+ "strings"
10
+)
11
+
12
+var reEnvVarID = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
13
+
14
+func Lookup(name string) (string, bool) {
15
+ v, ok := os.LookupEnv(name)
16
+ if !ok {
17
+ return "", false
18
+ }
19
+ return strings.TrimSpace(v), true
20
+}
21
+
22
+func ResolveRequired(selector, fieldPath string) (string, error) {
23
+ selector = strings.TrimSpace(selector)
24
+ if selector == "" {
25
+ return "", fmt.Errorf("%s selector is empty", fieldPath)
26
+ }
27
+ val, ok := Lookup(selector)
28
+ if !ok || strings.TrimSpace(val) == "" {
29
+ return "", fmt.Errorf("%s references env '%s' which is not set", fieldPath, selector)
30
+ }
31
+ return strings.TrimSpace(val), nil
32
+}
33
+
34
+func ResolveOptional(selector string) (string, bool) {
35
+ selector = strings.TrimSpace(selector)
36
+ if selector == "" {
37
+ return "", false
38
+ }
39
+ val, ok := Lookup(selector)
40
+ if !ok || strings.TrimSpace(val) == "" {
41
+ return "", false
42
+ }
43
+ return strings.TrimSpace(val), true
44
+}
45
+
46
+func ValidateSelector(name, fieldPath string) error {
47
+ name = strings.TrimSpace(name)
48
+ if name == "" {
49
+ return fmt.Errorf("%s is required", fieldPath)
50
+ }
51
+ if !reEnvVarID.MatchString(name) {
52
+ return fmt.Errorf("%s must reference an environment variable name", fieldPath)
53
+ }
54
+ return nil
55
+}
56
+
57
+func ParseBool(name string) bool {
58
+ v, ok := Lookup(name)
59
+ if !ok {
60
+ return false
61
+ }
62
+ switch strings.ToLower(strings.TrimSpace(v)) {
63
+ case "1", "true", "yes":
64
+ return true
65
+ default:
66
+ return false
67
+ }
68
+}
src/go/plugin/agent/secrets/secretstore/internal/httpx/httpx.go
new
+68
@@ -0,0 +1,68 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package httpx
4
+
5
+import (
6
+ "crypto/tls"
7
+ "net/http"
8
+ "strings"
9
+ "time"
10
+)
11
+
12
+func noRedirect(*http.Request, []*http.Request) error {
13
+ return http.ErrUseLastResponse
14
+}
15
+
16
+func APIClient(timeout time.Duration) *http.Client {
17
+ return &http.Client{Timeout: timeout}
18
+}
19
+
20
+func NoProxyClient(timeout time.Duration) *http.Client {
21
+ transport := cloneDefaultTransport()
22
+ transport.Proxy = nil
23
+
24
+ return &http.Client{
25
+ Timeout: timeout,
26
+ Transport: transport,
27
+ }
28
+}
29
+
30
+func VaultClient(timeout time.Duration) *http.Client {
31
+ return &http.Client{
32
+ Timeout: timeout,
33
+ CheckRedirect: noRedirect,
34
+ }
35
+}
36
+
37
+func VaultInsecureClient(timeout time.Duration) *http.Client {
38
+ transport := cloneDefaultTransport()
39
+ if transport.TLSClientConfig != nil {
40
+ transport.TLSClientConfig = transport.TLSClientConfig.Clone()
41
+ } else {
42
+ transport.TLSClientConfig = &tls.Config{}
43
+ }
44
+ transport.TLSClientConfig.InsecureSkipVerify = true
45
+
46
+ return &http.Client{
47
+ Timeout: timeout,
48
+ Transport: transport,
49
+ CheckRedirect: noRedirect,
50
+ }
51
+}
52
+
53
+func cloneDefaultTransport() *http.Transport {
54
+ transport, ok := http.DefaultTransport.(*http.Transport)
55
+ if !ok || transport == nil {
56
+ return &http.Transport{}
57
+ }
58
+ return transport.Clone()
59
+}
60
+
61
+func TruncateBody(body []byte) string {
62
+ const maxLen = 200
63
+ s := strings.TrimSpace(string(body))
64
+ if len(s) > maxLen {
65
+ return s[:maxLen] + "..."
66
+ }
67
+ return s
68
+}
src/go/plugin/agent/secrets/secretstore/internal/httpx/httpx_test.go
new
+83
@@ -0,0 +1,83 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package httpx
4
+
5
+import (
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/stretchr/testify/assert"
12
+ "github.com/stretchr/testify/require"
13
+)
14
+
15
+func TestVaultInsecureClient_PreservesDefaultTransportBehavior(t *testing.T) {
16
+ client := VaultInsecureClient(5 * time.Second)
17
+
18
+ transport, ok := client.Transport.(*http.Transport)
19
+ require.True(t, ok)
20
+
21
+ defaultTransport, ok := http.DefaultTransport.(*http.Transport)
22
+ require.True(t, ok)
23
+ require.NotNil(t, defaultTransport.Proxy)
24
+
25
+ assert.Equal(t, 5*time.Second, client.Timeout)
26
+ require.NotNil(t, client.CheckRedirect)
27
+
28
+ assert.NotSame(t, defaultTransport, transport)
29
+ assert.NotNil(t, transport.Proxy)
30
+ assert.Equal(t, defaultTransport.MaxIdleConns, transport.MaxIdleConns)
31
+ assert.Equal(t, defaultTransport.IdleConnTimeout, transport.IdleConnTimeout)
32
+ assert.Equal(t, defaultTransport.TLSHandshakeTimeout, transport.TLSHandshakeTimeout)
33
+
34
+ require.NotNil(t, transport.TLSClientConfig)
35
+ assert.True(t, transport.TLSClientConfig.InsecureSkipVerify)
36
+}
37
+
38
+func TestVaultClient_DoesNotFollowTemporaryRedirects(t *testing.T) {
39
+ tokenSeen := make(chan string, 1)
40
+ target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41
+ tokenSeen <- r.Header.Get("X-Vault-Token")
42
+ w.WriteHeader(http.StatusOK)
43
+ }))
44
+ defer target.Close()
45
+
46
+ redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47
+ http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
48
+ }))
49
+ defer redirect.Close()
50
+
51
+ client := VaultClient(5 * time.Second)
52
+ req, err := http.NewRequest(http.MethodGet, redirect.URL, nil)
53
+ require.NoError(t, err)
54
+ req.Header.Set("X-Vault-Token", "vault-token")
55
+
56
+ resp, err := client.Do(req)
57
+ require.NoError(t, err)
58
+ defer resp.Body.Close()
59
+
60
+ assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
61
+ select {
62
+ case seen := <-tokenSeen:
63
+ t.Fatalf("unexpected redirected request carrying token %q", seen)
64
+ default:
65
+ }
66
+}
67
+
68
+func TestNoProxyClient_PreservesDefaultTransportBehavior(t *testing.T) {
69
+ client := NoProxyClient(2 * time.Second)
70
+
71
+ transport, ok := client.Transport.(*http.Transport)
72
+ require.True(t, ok)
73
+
74
+ defaultTransport, ok := http.DefaultTransport.(*http.Transport)
75
+ require.True(t, ok)
76
+
77
+ assert.Equal(t, 2*time.Second, client.Timeout)
78
+ assert.NotSame(t, defaultTransport, transport)
79
+ assert.Nil(t, transport.Proxy)
80
+ assert.Equal(t, defaultTransport.MaxIdleConns, transport.MaxIdleConns)
81
+ assert.Equal(t, defaultTransport.IdleConnTimeout, transport.IdleConnTimeout)
82
+ assert.Equal(t, defaultTransport.TLSHandshakeTimeout, transport.TLSHandshakeTimeout)
83
+}
src/go/plugin/agent/secrets/secretstore/internal/specutil/specutil.go
new
+57
@@ -0,0 +1,57 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package specutil
4
+
5
+import (
6
+ "fmt"
7
+ "regexp"
8
+ "strings"
9
+
10
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
11
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/internal/envx"
12
+)
13
+
14
+var reStoreName = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
15
+
16
+func NormalizeStoreName(name string) (string, error) {
17
+ name = strings.TrimSpace(name)
18
+ if name == "" {
19
+ return "", fmt.Errorf("store name is required")
20
+ }
21
+ if !reStoreName.MatchString(name) {
22
+ return "", fmt.Errorf("store name '%s' must match %s", name, reStoreName.String())
23
+ }
24
+ return name, nil
25
+}
26
+
27
+func ValidateStoreKind(key string, kind, expected secretstore.StoreKind) error {
28
+ if !kind.IsValid() {
29
+ return fmt.Errorf("store '%s': invalid kind '%s'", key, kind)
30
+ }
31
+ if kind != expected {
32
+ return fmt.Errorf("store '%s': invalid kind '%s'", key, kind)
33
+ }
34
+ return nil
35
+}
36
+
37
+func RequireEnvSelector(v, path string) (string, error) {
38
+ v = strings.TrimSpace(v)
39
+ if v == "" {
40
+ return "", fmt.Errorf("%s is required", path)
41
+ }
42
+ if err := envx.ValidateSelector(v, path); err != nil {
43
+ return "", err
44
+ }
45
+ return v, nil
46
+}
47
+
48
+func OptionalEnvSelector(v, path string) (string, error) {
49
+ v = strings.TrimSpace(v)
50
+ if v == "" {
51
+ return "", nil
52
+ }
53
+ if err := envx.ValidateSelector(v, path); err != nil {
54
+ return "", err
55
+ }
56
+ return v, nil
57
+}
src/go/plugin/agent/secrets/secretstore/provider_parity_test.go
new
+144
@@ -0,0 +1,144 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore_test
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
9
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/backends"
10
+ "github.com/stretchr/testify/assert"
11
+ "github.com/stretchr/testify/require"
12
+)
13
+
14
+func TestProviderSchemaAndValidationParity(t *testing.T) {
15
+ svc := secretstore.NewService(backends.Creators()...)
16
+
17
+ tests := map[string]struct {
18
+ kind secretstore.StoreKind
19
+ valid map[string]any
20
+ invalid map[string]any
21
+ wantErrContains string
22
+ assertSchemaShape func(t *testing.T, schema map[string]any)
23
+ }{
24
+ "aws": {
25
+ kind: secretstore.KindAWSSM,
26
+ valid: map[string]any{
27
+ "name": "aws_prod",
28
+ "auth_mode": "env",
29
+ "region": "us-east-1",
30
+ },
31
+ invalid: map[string]any{
32
+ "name": "aws_prod",
33
+ "auth_mode": "env",
34
+ },
35
+ wantErrContains: "region is required",
36
+ assertSchemaShape: func(t *testing.T, schema map[string]any) {
37
+ jsonSchema := schema["jsonSchema"].(map[string]any)
38
+ assert.Contains(t, jsonSchema["required"], "auth_mode")
39
+ assert.Contains(t, jsonSchema["required"], "region")
40
+ _, ok := jsonSchema["allOf"]
41
+ assert.False(t, ok)
42
+ },
43
+ },
44
+ "azure": {
45
+ kind: secretstore.KindAzureKV,
46
+ valid: map[string]any{
47
+ "name": "azure_prod",
48
+ "mode": "managed_identity",
49
+ },
50
+ invalid: map[string]any{
51
+ "name": "azure_prod",
52
+ "mode": "client",
53
+ "mode_client": map[string]any{
54
+ "client_id": "client-id",
55
+ },
56
+ },
57
+ wantErrContains: "mode_client.tenant_id is required",
58
+ assertSchemaShape: func(t *testing.T, schema map[string]any) {
59
+ jsonSchema := schema["jsonSchema"].(map[string]any)
60
+ uiSchema := schema["uiSchema"].(map[string]any)
61
+ assert.Contains(t, jsonSchema["required"], "mode")
62
+ deps := jsonSchema["dependencies"].(map[string]any)
63
+ assert.Contains(t, deps, "mode")
64
+ modeClient := uiSchema["mode_client"].(map[string]any)
65
+ clientSecret := modeClient["client_secret"].(map[string]any)
66
+ assert.Equal(t, "password", clientSecret["ui:widget"])
67
+ },
68
+ },
69
+ "gcp": {
70
+ kind: secretstore.KindGCPSM,
71
+ valid: map[string]any{
72
+ "name": "gcp_prod",
73
+ "mode": "metadata",
74
+ },
75
+ invalid: map[string]any{
76
+ "name": "gcp_prod",
77
+ "mode": "service_account_file",
78
+ "mode_service_account_file": map[string]any{},
79
+ },
80
+ wantErrContains: "mode_service_account_file.path is required",
81
+ assertSchemaShape: func(t *testing.T, schema map[string]any) {
82
+ jsonSchema := schema["jsonSchema"].(map[string]any)
83
+ assert.Contains(t, jsonSchema["required"], "mode")
84
+ deps := jsonSchema["dependencies"].(map[string]any)
85
+ assert.Contains(t, deps, "mode")
86
+ },
87
+ },
88
+ "vault": {
89
+ kind: secretstore.KindVault,
90
+ valid: map[string]any{
91
+ "name": "vault_prod",
92
+ "mode": "token",
93
+ "mode_token": map[string]any{
94
+ "token": "vault-token",
95
+ },
96
+ "addr": "https://vault.example",
97
+ },
98
+ invalid: map[string]any{
99
+ "name": "vault_prod",
100
+ "mode": "token_file",
101
+ "addr": "https://vault.example",
102
+ },
103
+ wantErrContains: "mode_token_file is required",
104
+ assertSchemaShape: func(t *testing.T, schema map[string]any) {
105
+ jsonSchema := schema["jsonSchema"].(map[string]any)
106
+ uiSchema := schema["uiSchema"].(map[string]any)
107
+ assert.Contains(t, jsonSchema["required"], "addr")
108
+ assert.Contains(t, jsonSchema["required"], "mode")
109
+ assert.NotContains(t, jsonSchema["required"], "kind")
110
+ deps := jsonSchema["dependencies"].(map[string]any)
111
+ assert.Contains(t, deps, "mode")
112
+ modeToken := uiSchema["mode_token"].(map[string]any)
113
+ token := modeToken["token"].(map[string]any)
114
+ assert.Equal(t, "password", token["ui:widget"])
115
+ },
116
+ },
117
+ }
118
+
119
+ for name, tc := range tests {
120
+ t.Run(name, func(t *testing.T) {
121
+ displayName, ok := svc.DisplayName(tc.kind)
122
+ require.True(t, ok)
123
+ assert.NotEmpty(t, displayName)
124
+
125
+ schema, ok := svc.Schema(tc.kind)
126
+ require.True(t, ok)
127
+ schemaObj := decodeSchema(t, schema)
128
+ _, ok = schemaObj["jsonSchema"].(map[string]any)
129
+ require.True(t, ok)
130
+ _, ok = schemaObj["uiSchema"].(map[string]any)
131
+ require.True(t, ok)
132
+ if tc.assertSchemaShape != nil {
133
+ tc.assertSchemaShape(t, schemaObj)
134
+ }
135
+
136
+ err := svc.Validate(newStoreFromConfig(t, svc, tc.kind, tc.valid))
137
+ require.NoError(t, err)
138
+
139
+ err = svc.Validate(newStoreFromConfig(t, svc, tc.kind, tc.invalid))
140
+ require.Error(t, err)
141
+ assert.ErrorContains(t, err, tc.wantErrContains)
142
+ })
143
+ }
144
+}
src/go/plugin/agent/secrets/secretstore/raw_config.go
new
+154
@@ -0,0 +1,154 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore
4
+
5
+import (
6
+ "fmt"
7
+ "strings"
8
+
9
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
10
+
11
+ "github.com/gohugoio/hashstructure"
12
+)
13
+
14
+const (
15
+ keyName = "name"
16
+ keyKind = "kind"
17
+
18
+ ikeySource = "__source__"
19
+ ikeySourceType = "__source_type__"
20
+)
21
+
22
+// Config is the external raw secretstore object shape used by dyncfg/cache layers.
23
+// It carries structural metadata alongside provider payload and excludes __ metadata
24
+// from content hashing.
25
+type Config map[string]any
26
+
27
+func (c Config) Set(key string, value any) Config { c[key] = value; return c }
28
+
29
+func StoreKey(kind StoreKind, name string) string {
30
+ kind = StoreKind(strings.TrimSpace(string(kind)))
31
+ name = strings.TrimSpace(name)
32
+ if kind == "" || name == "" {
33
+ return ""
34
+ }
35
+ return string(kind) + ":" + name
36
+}
37
+
38
+func ParseStoreKey(key string) (StoreKind, string, error) {
39
+ key = strings.TrimSpace(key)
40
+ if key == "" {
41
+ return "", "", fmt.Errorf("store key is required")
42
+ }
43
+
44
+ kindPart, name, ok := strings.Cut(key, ":")
45
+ if !ok {
46
+ return "", "", fmt.Errorf("store key '%s' must be in format 'kind:name'", key)
47
+ }
48
+
49
+ kind := StoreKind(strings.TrimSpace(kindPart))
50
+ name = strings.TrimSpace(name)
51
+ if !kind.IsValid() {
52
+ return "", "", fmt.Errorf("invalid store kind '%s'", kind)
53
+ }
54
+ if err := validateStoreName(name); err != nil {
55
+ return "", "", fmt.Errorf("invalid store name '%s': %w", name, err)
56
+ }
57
+
58
+ return kind, name, nil
59
+}
60
+
61
+func (c Config) Name() string {
62
+ v, _ := c[keyName].(string)
63
+ return v
64
+}
65
+
66
+func (c Config) Kind() StoreKind {
67
+ switch v := c[keyKind].(type) {
68
+ case StoreKind:
69
+ return v
70
+ case string:
71
+ return StoreKind(v)
72
+ default:
73
+ return ""
74
+ }
75
+}
76
+
77
+func (c Config) Source() string {
78
+ v, _ := c[ikeySource].(string)
79
+ return v
80
+}
81
+
82
+func (c Config) SourceType() string {
83
+ v, _ := c[ikeySourceType].(string)
84
+ return v
85
+}
86
+
87
+func (c Config) SetName(v string) Config { return c.Set(keyName, v) }
88
+func (c Config) SetKind(v StoreKind) Config { return c.Set(keyKind, string(v)) }
89
+func (c Config) SetSource(v string) Config { return c.Set(ikeySource, v) }
90
+func (c Config) SetSourceType(v string) Config { return c.Set(ikeySourceType, v) }
91
+
92
+func (c Config) ExposedKey() string {
93
+ return StoreKey(c.Kind(), c.Name())
94
+}
95
+
96
+func (c Config) UID() string {
97
+ if c.Source() == "" || c.ExposedKey() == "" {
98
+ return ""
99
+ }
100
+ return c.Source() + ":" + c.ExposedKey()
101
+}
102
+
103
+func (c Config) SourceTypePriority() int {
104
+ switch c.SourceType() {
105
+ case confgroup.TypeDyncfg:
106
+ return 16
107
+ case confgroup.TypeUser:
108
+ return 8
109
+ case confgroup.TypeStock:
110
+ return 2
111
+ default:
112
+ return 0
113
+ }
114
+}
115
+
116
+func (c Config) HashIncludeMap(_ string, k, _ any) (bool, error) {
117
+ s := k.(string)
118
+ return !strings.HasPrefix(s, "__") && !strings.HasSuffix(s, "__"), nil
119
+}
120
+
121
+func (c Config) Hash() uint64 {
122
+ hash, _ := hashstructure.Hash(c, nil)
123
+ return hash
124
+}
125
+
126
+func (c Config) Validate() error {
127
+ if c == nil {
128
+ return fmt.Errorf("store config is nil")
129
+ }
130
+
131
+ name := c.Name()
132
+ if name == "" {
133
+ return fmt.Errorf("store name is required")
134
+ }
135
+ if err := validateStoreName(name); err != nil {
136
+ return fmt.Errorf("invalid store name '%s': %w", name, err)
137
+ }
138
+
139
+ kind := c.Kind()
140
+ if !kind.IsValid() {
141
+ return fmt.Errorf("invalid store kind '%s'", kind)
142
+ }
143
+
144
+ if c.Source() == "" {
145
+ return fmt.Errorf("store source is required")
146
+ }
147
+
148
+ switch c.SourceType() {
149
+ case confgroup.TypeDyncfg, confgroup.TypeUser, confgroup.TypeStock:
150
+ return nil
151
+ default:
152
+ return fmt.Errorf("invalid store source type '%s'", c.SourceType())
153
+ }
154
+}
src/go/plugin/agent/secrets/secretstore/raw_config_test.go
new
+175
@@ -0,0 +1,175 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
9
+ "github.com/stretchr/testify/assert"
10
+ "github.com/stretchr/testify/require"
11
+ "gopkg.in/yaml.v2"
12
+)
13
+
14
+func TestConfigValidateAndIdentity(t *testing.T) {
15
+ cfg := Config{
16
+ "name": "prod",
17
+ "kind": string(KindVault),
18
+ "__source__": "/etc/netdata/secretstores.yaml",
19
+ "__source_type__": confgroup.TypeUser,
20
+ "auth": map[string]any{
21
+ "mode": "token",
22
+ },
23
+ }
24
+
25
+ require.NoError(t, cfg.Validate())
26
+ assert.Equal(t, "prod", cfg.Name())
27
+ assert.Equal(t, KindVault, cfg.Kind())
28
+ assert.Equal(t, "vault:prod", cfg.ExposedKey())
29
+ assert.Equal(t, "/etc/netdata/secretstores.yaml:vault:prod", cfg.UID())
30
+}
31
+
32
+func TestConfigValidateRejectsInvalidStructuralFields(t *testing.T) {
33
+ tests := map[string]struct {
34
+ cfg Config
35
+ want string
36
+ }{
37
+ "nil": {
38
+ cfg: nil,
39
+ want: "store config is nil",
40
+ },
41
+ "missing name": {
42
+ cfg: Config{
43
+ "kind": string(KindVault),
44
+ "__source__": "src",
45
+ "__source_type__": confgroup.TypeDyncfg,
46
+ },
47
+ want: "store name is required",
48
+ },
49
+ "invalid name": {
50
+ cfg: Config{
51
+ "name": "prod:west",
52
+ "kind": string(KindVault),
53
+ "__source__": "src",
54
+ "__source_type__": confgroup.TypeDyncfg,
55
+ },
56
+ want: "invalid store name",
57
+ },
58
+ "invalid kind": {
59
+ cfg: Config{
60
+ "name": "prod",
61
+ "kind": "wat",
62
+ "__source__": "src",
63
+ "__source_type__": confgroup.TypeDyncfg,
64
+ },
65
+ want: "invalid store kind",
66
+ },
67
+ "missing source": {
68
+ cfg: Config{
69
+ "name": "prod",
70
+ "kind": string(KindVault),
71
+ "__source_type__": confgroup.TypeDyncfg,
72
+ },
73
+ want: "store source is required",
74
+ },
75
+ "invalid source type": {
76
+ cfg: Config{
77
+ "name": "prod",
78
+ "kind": string(KindVault),
79
+ "__source__": "src",
80
+ "__source_type__": "invalid",
81
+ },
82
+ want: "invalid store source type",
83
+ },
84
+ }
85
+
86
+ for name, tc := range tests {
87
+ t.Run(name, func(t *testing.T) {
88
+ err := tc.cfg.Validate()
89
+ require.Error(t, err)
90
+ assert.ErrorContains(t, err, tc.want)
91
+ })
92
+ }
93
+}
94
+
95
+func TestConfigHashExcludesMetadataKeys(t *testing.T) {
96
+ base := Config{
97
+ "name": "prod",
98
+ "kind": string(KindVault),
99
+ "auth": map[string]any{
100
+ "mode": "token",
101
+ "mode_token": map[string]any{
102
+ "token": "vault-token",
103
+ },
104
+ },
105
+ "__source__": "user.yaml",
106
+ "__source_type__": confgroup.TypeUser,
107
+ }
108
+
109
+ metaChanged := Config{
110
+ "name": "prod",
111
+ "kind": string(KindVault),
112
+ "auth": map[string]any{
113
+ "mode": "token",
114
+ "mode_token": map[string]any{
115
+ "token": "vault-token",
116
+ },
117
+ },
118
+ "__source__": "dyncfg",
119
+ "__source_type__": confgroup.TypeDyncfg,
120
+ }
121
+
122
+ payloadChanged := Config{
123
+ "name": "prod",
124
+ "kind": string(KindVault),
125
+ "auth": map[string]any{
126
+ "mode": "token",
127
+ "mode_token": map[string]any{
128
+ "token": "other-token",
129
+ },
130
+ },
131
+ "__source__": "user.yaml",
132
+ "__source_type__": confgroup.TypeUser,
133
+ }
134
+
135
+ assert.Equal(t, base.Hash(), metaChanged.Hash())
136
+ assert.NotEqual(t, base.Hash(), payloadChanged.Hash())
137
+}
138
+
139
+func TestConfigHashMatchesAcrossEquivalentMapShapes(t *testing.T) {
140
+ rawMap := Config{
141
+ "name": "prod",
142
+ "kind": string(KindVault),
143
+ "__source__": "user.yaml",
144
+ "__source_type__": confgroup.TypeUser,
145
+ "auth": map[string]any{
146
+ "mode": "token",
147
+ "selectors": []any{
148
+ "VAULT_TOKEN",
149
+ map[string]any{"alt": "VAULT_TOKEN_ALT"},
150
+ },
151
+ },
152
+ }
153
+
154
+ var rawYAML Config
155
+ require.NoError(t, yaml.Unmarshal([]byte(`
156
+name: prod
157
+kind: vault
158
+__source__: user.yaml
159
+__source_type__: user
160
+auth:
161
+ mode: token
162
+ selectors:
163
+ - VAULT_TOKEN
164
+ - alt: VAULT_TOKEN_ALT
165
+`), &rawYAML))
166
+
167
+ assert.Equal(t, rawMap.Hash(), rawYAML.Hash())
168
+}
169
+
170
+func TestConfigSourceTypePriority(t *testing.T) {
171
+ assert.Equal(t, 16, Config{"__source_type__": confgroup.TypeDyncfg}.SourceTypePriority())
172
+ assert.Equal(t, 8, Config{"__source_type__": confgroup.TypeUser}.SourceTypePriority())
173
+ assert.Equal(t, 2, Config{"__source_type__": confgroup.TypeStock}.SourceTypePriority())
174
+ assert.Equal(t, 0, Config{"__source_type__": "other"}.SourceTypePriority())
175
+}
src/go/plugin/agent/secrets/secretstore/runtime_resolver.go
new
+61
@@ -0,0 +1,61 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "strings"
9
+)
10
+
11
+// runtimeResolver resolves ${store:<kind>:<name>:<operand>} references against a captured snapshot.
12
+type runtimeResolver struct{}
13
+
14
+func newRuntimeResolver() *runtimeResolver { return &runtimeResolver{} }
15
+
16
+func (r *runtimeResolver) resolveContext(ctx context.Context, snapshot *Snapshot, ref, original string) (string, error) {
17
+ if ctx == nil {
18
+ ctx = context.Background()
19
+ }
20
+ select {
21
+ case <-ctx.Done():
22
+ return "", ctx.Err()
23
+ default:
24
+ }
25
+
26
+ kindPart, rest, ok := strings.Cut(ref, ":")
27
+ if !ok {
28
+ return "", fmt.Errorf("resolving secret '%s': store reference must be in format 'kind:name:operand'", original)
29
+ }
30
+ namePart, operand, ok := strings.Cut(rest, ":")
31
+ if !ok {
32
+ return "", fmt.Errorf("resolving secret '%s': store reference must be in format 'kind:name:operand'", original)
33
+ }
34
+
35
+ kind := StoreKind(strings.TrimSpace(kindPart))
36
+ name := strings.TrimSpace(namePart)
37
+ operand = strings.TrimSpace(operand)
38
+ if !kind.IsValid() || name == "" || operand == "" {
39
+ return "", fmt.Errorf("resolving secret '%s': store reference must be in format 'kind:name:operand'", original)
40
+ }
41
+ storeKey := StoreKey(kind, name)
42
+
43
+ if snapshot == nil {
44
+ return "", fmt.Errorf("resolving secret '%s': secretstore '%s' is not configured", original, storeKey)
45
+ }
46
+ store, ok := snapshot.lookupStore(storeKey)
47
+ if !ok {
48
+ return "", fmt.Errorf("resolving secret '%s': secretstore '%s' is not configured", original, storeKey)
49
+ }
50
+ if store.published == nil {
51
+ return "", fmt.Errorf("resolving secret '%s': secretstore '%s' has no published resolver state", original, storeKey)
52
+ }
53
+
54
+ return store.published.Resolve(ctx, ResolveRequest{
55
+ StoreKey: storeKey,
56
+ StoreKind: kind,
57
+ StoreName: name,
58
+ Operand: operand,
59
+ Original: original,
60
+ })
61
+}
src/go/plugin/agent/secrets/secretstore/runtime_resolver_provider_test.go
new
+58
@@ -0,0 +1,58 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore_test
4
+
5
+import (
6
+ "context"
7
+ "testing"
8
+
9
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
10
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/backends"
11
+ "github.com/stretchr/testify/assert"
12
+ "github.com/stretchr/testify/require"
13
+)
14
+
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))
19
+ require.NoError(t, err)
20
+ }
21
+
22
+ snapshot := svc.Capture()
23
+
24
+ tests := map[string]struct {
25
+ ref string
26
+ original string
27
+ wantErrContains string
28
+ }{
29
+ "aws": {
30
+ ref: "aws-sm:aws_prod:#jsonKey",
31
+ original: "${store:aws-sm:aws_prod:#jsonKey}",
32
+ wantErrContains: "secret name is empty",
33
+ },
34
+ "azure": {
35
+ ref: "azure-kv:azure_prod:not-a-secret-ref",
36
+ original: "${store:azure-kv:azure_prod:not-a-secret-ref}",
37
+ wantErrContains: "operand must be in format 'vault-name/secret-name'",
38
+ },
39
+ "gcp": {
40
+ ref: "gcp-sm:gcp_prod:not-a-secret-ref",
41
+ original: "${store:gcp-sm:gcp_prod:not-a-secret-ref}",
42
+ wantErrContains: "operand must be in format 'project/secret' or 'project/secret/version'",
43
+ },
44
+ "vault": {
45
+ ref: "vault:vault_prod:not-a-vault-ref",
46
+ original: "${store:vault:vault_prod:not-a-vault-ref}",
47
+ wantErrContains: "operand must be in format 'path#key'",
48
+ },
49
+ }
50
+
51
+ for name, tc := range tests {
52
+ t.Run(name, func(t *testing.T) {
53
+ _, err := svc.Resolve(context.Background(), snapshot, tc.ref, tc.original)
54
+ require.Error(t, err)
55
+ assert.Contains(t, err.Error(), tc.wantErrContains)
56
+ })
57
+ }
58
+}
src/go/plugin/agent/secrets/secretstore/runtime_resolver_test.go
new
+52
@@ -0,0 +1,52 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore_test
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
9
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/backends"
10
+ "github.com/stretchr/testify/assert"
11
+ "github.com/stretchr/testify/require"
12
+)
13
+
14
+func TestRuntimeResolverResolveErrors(t *testing.T) {
15
+ enabledSvc := secretstore.NewService(backends.Creators()...)
16
+ err := enabledSvc.Add(newStoreFromConfig(t, enabledSvc, secretstore.KindVault, testSingleVaultConfig()))
17
+ require.NoError(t, err)
18
+
19
+ tests := map[string]struct {
20
+ snapshot *secretstore.Snapshot
21
+ ref string
22
+ original string
23
+ wantErrContains string
24
+ }{
25
+ "invalid store ref format": {
26
+ snapshot: enabledSvc.Capture(),
27
+ ref: "invalid",
28
+ original: "${store:invalid}",
29
+ wantErrContains: "store reference must be in format",
30
+ },
31
+ "store not configured": {
32
+ snapshot: secretstore.NewService(backends.Creators()...).Capture(),
33
+ ref: "vault:missing:secret/data/app#password",
34
+ original: "${store:vault:missing:secret/data/app#password}",
35
+ wantErrContains: "secretstore 'vault:missing' is not configured",
36
+ },
37
+ "bad vault operand": {
38
+ snapshot: enabledSvc.Capture(),
39
+ ref: "vault:vault_prod:secret/data/app",
40
+ original: "${store:vault:vault_prod:secret/data/app}",
41
+ wantErrContains: "operand must be in format 'path#key'",
42
+ },
43
+ }
44
+
45
+ for name, tc := range tests {
46
+ t.Run(name, func(t *testing.T) {
47
+ _, err := enabledSvc.Resolve(t.Context(), tc.snapshot, tc.ref, tc.original)
48
+ require.Error(t, err)
49
+ assert.Contains(t, err.Error(), tc.wantErrContains)
50
+ })
51
+ }
52
+}
src/go/plugin/agent/secrets/secretstore/service.go
new
+40
@@ -0,0 +1,40 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore
4
+
5
+import "context"
6
+
7
+type Service interface {
8
+ Capture() *Snapshot
9
+ Resolve(ctx context.Context, snapshot *Snapshot, ref, original string) (string, error)
10
+
11
+ Kinds() []StoreKind
12
+ DisplayName(kind StoreKind) (string, bool)
13
+ Schema(kind StoreKind) (string, bool)
14
+ New(kind StoreKind) (Store, bool)
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
22
+ Remove(key string) error
23
+}
24
+
25
+type Creator struct {
26
+ Kind StoreKind
27
+ DisplayName string
28
+ Schema string
29
+ Create func() Store
30
+}
31
+
32
+type Store interface {
33
+ Configuration() any
34
+ Init(context.Context) error
35
+ Publish() PublishedStore
36
+}
37
+
38
+type PublishedStore interface {
39
+ Resolve(ctx context.Context, req ResolveRequest) (string, error)
40
+}
src/go/plugin/agent/secrets/secretstore/service_hardening_test.go
new
+354
@@ -0,0 +1,354 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore_test
4
+
5
+import (
6
+ "context"
7
+ "encoding/json"
8
+ "errors"
9
+ "sync"
10
+ "sync/atomic"
11
+ "testing"
12
+
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 fakePublished struct {
19
+ blockOnCtx *atomic.Bool
20
+ requireNonNilCtx *atomic.Bool
21
+}
22
+
23
+func (p *fakePublished) Resolve(ctx context.Context, req secretstore.ResolveRequest) (string, error) {
24
+ if p.requireNonNilCtx != nil {
25
+ p.requireNonNilCtx.Store(ctx != nil)
26
+ if ctx == nil {
27
+ return "", errors.New("nil context")
28
+ }
29
+ }
30
+ if p.blockOnCtx != nil && p.blockOnCtx.Load() {
31
+ <-ctx.Done()
32
+ return "", ctx.Err()
33
+ }
34
+ return req.Operand, nil
35
+}
36
+
37
+type fakeConfig struct {
38
+ Auth map[string]any `json:"auth,omitempty" yaml:"auth,omitempty"`
39
+}
40
+
41
+type fakeStore struct {
42
+ cfg fakeConfig
43
+ failInit *atomic.Bool
44
+ blockOnCtx *atomic.Bool
45
+ requireNonNilCtx *atomic.Bool
46
+ published secretstore.PublishedStore
47
+}
48
+
49
+func (s *fakeStore) Configuration() any { return &s.cfg }
50
+func (s *fakeStore) Publish() secretstore.PublishedStore {
51
+ return s.published
52
+}
53
+
54
+func (s *fakeStore) Init(context.Context) error {
55
+ if s.failInit != nil && s.failInit.Load() {
56
+ return errors.New("simulated validation error")
57
+ }
58
+ if len(s.cfg.Auth) == 0 {
59
+ return errors.New("auth is required")
60
+ }
61
+ s.published = &fakePublished{
62
+ blockOnCtx: s.blockOnCtx,
63
+ requireNonNilCtx: s.requireNonNilCtx,
64
+ }
65
+ return nil
66
+}
67
+
68
+type validateRaceStore struct {
69
+ cfg fakeConfig
70
+ initCount *atomic.Int32
71
+ validateStarted chan struct{}
72
+ validateRelease <-chan struct{}
73
+ published secretstore.PublishedStore
74
+}
75
+
76
+func (s *validateRaceStore) Configuration() any { return &s.cfg }
77
+func (s *validateRaceStore) Publish() secretstore.PublishedStore {
78
+ return s.published
79
+}
80
+
81
+func (s *validateRaceStore) Init(context.Context) error {
82
+ if s.initCount != nil && s.initCount.Add(1) == 2 {
83
+ close(s.validateStarted)
84
+ <-s.validateRelease
85
+ }
86
+ if len(s.cfg.Auth) == 0 {
87
+ return errors.New("auth is required")
88
+ }
89
+ s.published = &fakePublished{}
90
+ return nil
91
+}
92
+
93
+func newFakeCreator(kind secretstore.StoreKind, failInit, blockOnCtx *atomic.Bool) secretstore.Creator {
94
+ return newFakeCreatorWithCtxProbe(kind, failInit, blockOnCtx, nil)
95
+}
96
+
97
+func newFakeCreatorWithCtxProbe(kind secretstore.StoreKind, failInit, blockOnCtx, requireNonNilCtx *atomic.Bool) secretstore.Creator {
98
+ schema := map[string]any{
99
+ "jsonSchema": map[string]any{
100
+ "type": "object",
101
+ "properties": map[string]any{
102
+ "auth": map[string]any{"type": "object"},
103
+ },
104
+ "required": []any{"auth"},
105
+ },
106
+ "uiSchema": map[string]any{},
107
+ }
108
+ bs, err := json.Marshal(schema)
109
+ if err != nil {
110
+ panic(err)
111
+ }
112
+
113
+ return secretstore.Creator{
114
+ Kind: kind,
115
+ DisplayName: "Fake Provider",
116
+ Schema: string(bs),
117
+ Create: func() secretstore.Store {
118
+ return &fakeStore{
119
+ failInit: failInit,
120
+ blockOnCtx: blockOnCtx,
121
+ requireNonNilCtx: requireNonNilCtx,
122
+ }
123
+ },
124
+ }
125
+}
126
+
127
+func newValidateRaceCreator(kind secretstore.StoreKind, validateStarted chan struct{}, validateRelease <-chan struct{}) secretstore.Creator {
128
+ schema := map[string]any{
129
+ "jsonSchema": map[string]any{
130
+ "type": "object",
131
+ "properties": map[string]any{
132
+ "auth": map[string]any{"type": "object"},
133
+ },
134
+ "required": []any{"auth"},
135
+ },
136
+ "uiSchema": map[string]any{},
137
+ }
138
+ bs, err := json.Marshal(schema)
139
+ if err != nil {
140
+ panic(err)
141
+ }
142
+
143
+ var initCount atomic.Int32
144
+ return secretstore.Creator{
145
+ Kind: kind,
146
+ DisplayName: "Fake Provider",
147
+ Schema: string(bs),
148
+ Create: func() secretstore.Store {
149
+ return &validateRaceStore{
150
+ initCount: &initCount,
151
+ validateStarted: validateStarted,
152
+ validateRelease: validateRelease,
153
+ }
154
+ },
155
+ }
156
+}
157
+
158
+func newFakeStore(_ *testing.T, _ secretstore.Service, kind secretstore.StoreKind, cfg fakeConfig, name string) secretstore.Config {
159
+ bs, err := json.Marshal(cfg)
160
+ if err != nil {
161
+ panic(err)
162
+ }
163
+ var payload map[string]any
164
+ if err := json.Unmarshal(bs, &payload); err != nil {
165
+ panic(err)
166
+ }
167
+ out := secretstore.Config(payload)
168
+ out.SetName(name)
169
+ out.SetKind(kind)
170
+ out.SetSource("dyncfg")
171
+ out.SetSourceType("dyncfg")
172
+ return out
173
+}
174
+
175
+func TestServiceStatusLifecycle(t *testing.T) {
176
+ var failInit atomic.Bool
177
+ svc := secretstore.NewService(newFakeCreator(secretstore.KindVault, &failInit, nil))
178
+
179
+ store := newFakeStore(t, svc, secretstore.KindVault, fakeConfig{
180
+ Auth: map[string]any{"mode": "token_env"},
181
+ }, "vault_prod")
182
+
183
+ err := svc.Add(store)
184
+ require.NoError(t, err)
185
+
186
+ failInit.Store(true)
187
+ storeKey := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
188
+ err = svc.ValidateStored(storeKey)
189
+ require.Error(t, err)
190
+
191
+ status, ok := svc.GetStatus(storeKey)
192
+ require.True(t, ok)
193
+ require.NotNil(t, status.LastValidation)
194
+ assert.False(t, status.LastValidation.OK)
195
+ assert.Equal(t, "simulated validation error", status.LastErrorSummary)
196
+
197
+ failInit.Store(false)
198
+ err = svc.ValidateStored(storeKey)
199
+ require.NoError(t, err)
200
+
201
+ status, ok = svc.GetStatus(storeKey)
202
+ require.True(t, ok)
203
+ require.NotNil(t, status.LastValidation)
204
+ assert.True(t, status.LastValidation.OK)
205
+ assert.Empty(t, status.LastErrorSummary)
206
+}
207
+
208
+func TestServiceResolveHonorsCanceledContext(t *testing.T) {
209
+ var blockOnCtx atomic.Bool
210
+ blockOnCtx.Store(true)
211
+
212
+ svc := secretstore.NewService(newFakeCreator(secretstore.KindVault, nil, &blockOnCtx))
213
+ store := newFakeStore(t, svc, secretstore.KindVault, fakeConfig{
214
+ Auth: map[string]any{"mode": "token_env"},
215
+ }, "vault_prod")
216
+ err := svc.Add(store)
217
+ require.NoError(t, err)
218
+
219
+ ctx, cancel := context.WithCancel(context.Background())
220
+ cancel()
221
+
222
+ _, err = svc.Resolve(ctx, svc.Capture(), "vault:vault_prod:secret", "${store:vault:vault_prod:secret}")
223
+ require.ErrorIs(t, err, context.Canceled)
224
+}
225
+
226
+func TestServiceResolve_NormalizesNilContext(t *testing.T) {
227
+ var requireNonNilCtx atomic.Bool
228
+
229
+ svc := secretstore.NewService(newFakeCreatorWithCtxProbe(secretstore.KindVault, nil, nil, &requireNonNilCtx))
230
+ store := newFakeStore(t, svc, secretstore.KindVault, fakeConfig{
231
+ Auth: map[string]any{"mode": "token_env"},
232
+ }, "vault_prod")
233
+ err := svc.Add(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}")
237
+ require.NoError(t, err)
238
+ assert.Equal(t, "secret/data/app#key", val)
239
+ assert.True(t, requireNonNilCtx.Load())
240
+}
241
+
242
+func TestServiceConcurrentResolveAndMutation(t *testing.T) {
243
+ svc := secretstore.NewService(newFakeCreator(secretstore.KindVault, nil, nil))
244
+ baseCfg := fakeConfig{
245
+ Auth: map[string]any{"mode": "token_env"},
246
+ }
247
+
248
+ err := svc.Add(newFakeStore(t, svc, secretstore.KindVault, baseCfg, "vault_prod"))
249
+ require.NoError(t, err)
250
+
251
+ var wg sync.WaitGroup
252
+ errCh := make(chan error, 32)
253
+
254
+ wg.Add(1)
255
+ go func() {
256
+ defer wg.Done()
257
+ for i := 0; i < 100; i++ {
258
+ snapshot := svc.Capture()
259
+ val, err := svc.Resolve(context.Background(), snapshot, "vault:vault_prod:secret/data/app#key", "${store:vault:vault_prod:secret/data/app#key}")
260
+ if err != nil {
261
+ errCh <- err
262
+ return
263
+ }
264
+ if val != "secret/data/app#key" {
265
+ errCh <- errors.New("unexpected resolved value")
266
+ return
267
+ }
268
+ }
269
+ }()
270
+
271
+ wg.Add(1)
272
+ go func() {
273
+ defer wg.Done()
274
+ for i := 0; i < 100; i++ {
275
+ updateCfg := baseCfg
276
+ if i%2 == 0 {
277
+ updateCfg.Auth = map[string]any{
278
+ "mode": "token_env",
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 {
283
+ errCh <- err
284
+ return
285
+ }
286
+ }
287
+ }()
288
+
289
+ wg.Wait()
290
+ close(errCh)
291
+
292
+ for err := range errCh {
293
+ require.NoError(t, err)
294
+ }
295
+}
296
+
297
+func TestServiceValidateStored_RemovedDuringValidationReturnsNotFound(t *testing.T) {
298
+ validateStarted := make(chan struct{})
299
+ validateRelease := make(chan struct{})
300
+
301
+ svc := secretstore.NewService(newValidateRaceCreator(secretstore.KindVault, validateStarted, validateRelease))
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))
306
+
307
+ storeKey := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
308
+ errCh := make(chan error, 1)
309
+
310
+ go func() {
311
+ errCh <- svc.ValidateStored(storeKey)
312
+ }()
313
+
314
+ <-validateStarted
315
+ require.NoError(t, svc.Remove(storeKey))
316
+ close(validateRelease)
317
+
318
+ err := <-errCh
319
+ require.Error(t, err)
320
+ assert.ErrorIs(t, err, secretstore.ErrStoreNotFound)
321
+}
322
+
323
+func TestServiceValidateStored_UpdatedDuringValidationReturnsRetryWithoutOverwritingStatus(t *testing.T) {
324
+ validateStarted := make(chan struct{})
325
+ validateRelease := make(chan struct{})
326
+
327
+ svc := secretstore.NewService(newValidateRaceCreator(secretstore.KindVault, validateStarted, validateRelease))
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))
332
+
333
+ storeKey := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
334
+ errCh := make(chan error, 1)
335
+
336
+ go func() {
337
+ errCh <- svc.ValidateStored(storeKey)
338
+ }()
339
+
340
+ <-validateStarted
341
+ require.NoError(t, svc.Update(storeKey, newFakeStore(t, svc, secretstore.KindVault, fakeConfig{
342
+ Auth: map[string]any{"mode": "token_env", "tag": "new"},
343
+ }, "vault_prod")))
344
+ close(validateRelease)
345
+
346
+ err := <-errCh
347
+ require.Error(t, err)
348
+ assert.Contains(t, err.Error(), "changed during validation")
349
+
350
+ status, ok := svc.GetStatus(storeKey)
351
+ require.True(t, ok)
352
+ assert.Nil(t, status.LastValidation)
353
+ assert.Empty(t, status.LastErrorSummary)
354
+}
src/go/plugin/agent/secrets/secretstore/service_impl.go
new
+436
@@ -0,0 +1,436 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "sort"
9
+ "sync"
10
+ "sync/atomic"
11
+ "time"
12
+
13
+ "gopkg.in/yaml.v2"
14
+)
15
+
16
+type storeRecord struct {
17
+ rawConfig Config
18
+ configHash uint64
19
+ status StoreStatus
20
+ published PublishedStore
21
+}
22
+
23
+type preparedStore struct {
24
+ key string
25
+ rawConfig Config
26
+ configHash uint64
27
+ status StoreStatus
28
+ published PublishedStore
29
+}
30
+
31
+type serviceState struct {
32
+ snapshot *Snapshot
33
+ records map[string]storeRecord
34
+}
35
+
36
+type creatorRegistry struct {
37
+ kinds []StoreKind
38
+ byKind map[StoreKind]Creator
39
+}
40
+
41
+type inMemoryService struct {
42
+ mu sync.Mutex
43
+ state atomic.Pointer[serviceState]
44
+ now func() time.Time
45
+ resolver *runtimeResolver
46
+ registry creatorRegistry
47
+}
48
+
49
+func NewService(creators ...Creator) Service {
50
+ return newInMemoryService(creators...)
51
+}
52
+
53
+func newInMemoryService(creators ...Creator) Service {
54
+ s := &inMemoryService{
55
+ now: time.Now,
56
+ resolver: newRuntimeResolver(),
57
+ registry: newCreatorRegistry(creators...),
58
+ }
59
+ s.state.Store(&serviceState{
60
+ snapshot: &Snapshot{
61
+ generation: 0,
62
+ publishedAt: s.now().UTC(),
63
+ stores: map[string]publishedRecord{},
64
+ },
65
+ records: map[string]storeRecord{},
66
+ })
67
+ return s
68
+}
69
+
70
+func (s *inMemoryService) Capture() *Snapshot {
71
+ state := s.state.Load()
72
+ if state == nil {
73
+ return &Snapshot{stores: map[string]publishedRecord{}}
74
+ }
75
+ return cloneSnapshot(state.snapshot)
76
+}
77
+
78
+func (s *inMemoryService) Resolve(ctx context.Context, snapshot *Snapshot, ref, original string) (string, error) {
79
+ if ctx != nil {
80
+ select {
81
+ case <-ctx.Done():
82
+ return "", ctx.Err()
83
+ default:
84
+ }
85
+ }
86
+ return s.resolver.resolveContext(ctx, snapshot, ref, original)
87
+}
88
+
89
+func (s *inMemoryService) Kinds() []StoreKind {
90
+ return append([]StoreKind(nil), s.registry.kinds...)
91
+}
92
+
93
+func (s *inMemoryService) DisplayName(kind StoreKind) (string, bool) {
94
+ creator, ok := s.registry.byKind[kind]
95
+ if !ok {
96
+ return "", false
97
+ }
98
+ return creator.DisplayName, true
99
+}
100
+
101
+func (s *inMemoryService) Schema(kind StoreKind) (string, bool) {
102
+ creator, ok := s.registry.byKind[kind]
103
+ if !ok {
104
+ return "", false
105
+ }
106
+ return creator.Schema, true
107
+}
108
+
109
+func (s *inMemoryService) New(kind StoreKind) (Store, bool) {
110
+ creator, ok := s.registry.byKind[kind]
111
+ if !ok || creator.Create == nil {
112
+ return nil, false
113
+ }
114
+ store := creator.Create()
115
+ if store == nil {
116
+ return nil, false
117
+ }
118
+ return store, true
119
+}
120
+
121
+func (s *inMemoryService) GetStatus(key string) (StoreStatus, bool) {
122
+ key, err := normalizeStoreKey(key)
123
+ if err != nil {
124
+ return StoreStatus{}, false
125
+ }
126
+
127
+ state := s.state.Load()
128
+ if state == nil {
129
+ return StoreStatus{}, false
130
+ }
131
+ record, ok := state.records[key]
132
+ if !ok {
133
+ return StoreStatus{}, false
134
+ }
135
+ return cloneStoreStatus(record.status), true
136
+}
137
+
138
+func (s *inMemoryService) Validate(cfg Config) error {
139
+ _, err := s.prepareConfig(context.Background(), cfg)
140
+ return err
141
+}
142
+
143
+func (s *inMemoryService) ValidateStored(key string) error {
144
+ key, err := normalizeStoreKey(key)
145
+ if err != nil {
146
+ return err
147
+ }
148
+
149
+ state := s.state.Load()
150
+ if state == nil {
151
+ return storeNotConfiguredError(key)
152
+ }
153
+ record, ok := state.records[key]
154
+ if !ok {
155
+ return storeNotConfiguredError(key)
156
+ }
157
+ validatedHash := record.configHash
158
+
159
+ _, err = s.prepareConfig(context.Background(), record.rawConfig)
160
+
161
+ validation := &ValidationStatus{
162
+ CheckedAt: s.now().UTC(),
163
+ OK: err == nil,
164
+ }
165
+
166
+ s.mu.Lock()
167
+ defer s.mu.Unlock()
168
+
169
+ current := s.state.Load()
170
+ if current == nil {
171
+ return storeNotConfiguredError(key)
172
+ }
173
+ updated, ok := current.records[key]
174
+ if !ok {
175
+ return storeNotConfiguredError(key)
176
+ }
177
+ if updated.configHash != validatedHash {
178
+ return fmt.Errorf("store '%s' changed during validation; retry", key)
179
+ }
180
+ updated.status.LastValidation = validation
181
+ if err != nil {
182
+ updated.status.LastErrorSummary = err.Error()
183
+ } else {
184
+ updated.status.LastErrorSummary = ""
185
+ }
186
+
187
+ records := cloneRecords(current.records)
188
+ records[key] = updated
189
+ s.state.Store(&serviceState{
190
+ snapshot: current.snapshot,
191
+ records: records,
192
+ })
193
+
194
+ return err
195
+}
196
+
197
+func (s *inMemoryService) Add(cfg Config) error {
198
+ prepared, err := s.prepareConfig(context.Background(), cfg)
199
+ if err != nil {
200
+ return err
201
+ }
202
+
203
+ s.mu.Lock()
204
+ defer s.mu.Unlock()
205
+
206
+ state := s.state.Load()
207
+ if state == nil {
208
+ state = &serviceState{
209
+ snapshot: &Snapshot{stores: map[string]publishedRecord{}},
210
+ records: map[string]storeRecord{},
211
+ }
212
+ }
213
+ if _, ok := state.records[prepared.key]; ok {
214
+ return storeAlreadyExistsError(prepared.key)
215
+ }
216
+
217
+ records := cloneRecords(state.records)
218
+ records[prepared.key] = prepared.record()
219
+ snapshot := newSnapshot(state.snapshot.Generation()+1, s.now().UTC(), records)
220
+ s.state.Store(&serviceState{snapshot: snapshot, records: records})
221
+ return nil
222
+}
223
+
224
+func (s *inMemoryService) Update(key string, cfg Config) error {
225
+ key, err := normalizeStoreKey(key)
226
+ if err != nil {
227
+ return err
228
+ }
229
+
230
+ state := s.state.Load()
231
+ if state == nil {
232
+ return storeNotConfiguredError(key)
233
+ }
234
+ before, ok := state.records[key]
235
+ if !ok {
236
+ return storeNotConfiguredError(key)
237
+ }
238
+
239
+ prepared, err := s.prepareConfig(context.Background(), cfg)
240
+ if err != nil {
241
+ return err
242
+ }
243
+ if prepared.key != key {
244
+ return fmt.Errorf("store key mismatch: path key '%s' differs from config key '%s'", key, prepared.key)
245
+ }
246
+
247
+ s.mu.Lock()
248
+ defer s.mu.Unlock()
249
+
250
+ current := s.state.Load()
251
+ if current == nil {
252
+ return storeNotConfiguredError(key)
253
+ }
254
+ before, ok = current.records[key]
255
+ if !ok {
256
+ return storeNotConfiguredError(key)
257
+ }
258
+
259
+ if before.configHash == prepared.configHash {
260
+ return nil
261
+ }
262
+
263
+ records := cloneRecords(current.records)
264
+ records[key] = prepared.record()
265
+ snapshot := newSnapshot(current.snapshot.Generation()+1, s.now().UTC(), records)
266
+ s.state.Store(&serviceState{snapshot: snapshot, records: records})
267
+ return nil
268
+}
269
+
270
+func (s *inMemoryService) Remove(key string) error {
271
+ key, err := normalizeStoreKey(key)
272
+ if err != nil {
273
+ return err
274
+ }
275
+
276
+ s.mu.Lock()
277
+ defer s.mu.Unlock()
278
+
279
+ state := s.state.Load()
280
+ if state == nil {
281
+ return storeNotConfiguredError(key)
282
+ }
283
+ if _, ok := state.records[key]; !ok {
284
+ return storeNotConfiguredError(key)
285
+ }
286
+
287
+ records := cloneRecords(state.records)
288
+ delete(records, key)
289
+ snapshot := newSnapshot(state.snapshot.Generation()+1, s.now().UTC(), records)
290
+ s.state.Store(&serviceState{snapshot: snapshot, records: records})
291
+ return nil
292
+}
293
+
294
+func newCreatorRegistry(creators ...Creator) creatorRegistry {
295
+ reg := creatorRegistry{
296
+ byKind: make(map[StoreKind]Creator, len(creators)),
297
+ }
298
+ for _, creator := range creators {
299
+ if creator.Kind == "" || creator.Create == nil {
300
+ continue
301
+ }
302
+ reg.byKind[creator.Kind] = creator
303
+ }
304
+ reg.kinds = make([]StoreKind, 0, len(reg.byKind))
305
+ for kind := range reg.byKind {
306
+ reg.kinds = append(reg.kinds, kind)
307
+ }
308
+ sort.Slice(reg.kinds, func(i, j int) bool { return reg.kinds[i] < reg.kinds[j] })
309
+ return reg
310
+}
311
+
312
+func (s *inMemoryService) prepareConfig(ctx context.Context, cfg Config) (preparedStore, error) {
313
+ if cfg == nil {
314
+ return preparedStore{}, fmt.Errorf("store config is nil")
315
+ }
316
+ if ctx == nil {
317
+ ctx = context.Background()
318
+ }
319
+
320
+ raw := cloneConfig(cfg)
321
+ if raw == nil {
322
+ return preparedStore{}, fmt.Errorf("store config is nil")
323
+ }
324
+ if err := raw.Validate(); err != nil {
325
+ return preparedStore{}, err
326
+ }
327
+
328
+ kind := raw.Kind()
329
+ name := raw.Name()
330
+ key := raw.ExposedKey()
331
+
332
+ store, ok := s.New(kind)
333
+ if !ok {
334
+ return preparedStore{}, fmt.Errorf("store kind '%s' is not supported", kind)
335
+ }
336
+ if store.Configuration() == nil {
337
+ return preparedStore{}, fmt.Errorf("store '%s': configuration is nil", key)
338
+ }
339
+
340
+ bs, err := yaml.Marshal(raw)
341
+ if err != nil {
342
+ return preparedStore{}, fmt.Errorf("store '%s': marshaling raw config: %w", key, err)
343
+ }
344
+ if err := yaml.Unmarshal(bs, store.Configuration()); err != nil {
345
+ return preparedStore{}, fmt.Errorf("store '%s': invalid provider payload: %w", key, err)
346
+ }
347
+
348
+ if err := store.Init(ctx); err != nil {
349
+ return preparedStore{}, err
350
+ }
351
+
352
+ published := store.Publish()
353
+ if published == nil {
354
+ return preparedStore{}, fmt.Errorf("store '%s': published resolver state is nil", key)
355
+ }
356
+
357
+ return preparedStore{
358
+ key: key,
359
+ rawConfig: raw,
360
+ configHash: raw.Hash(),
361
+ status: StoreStatus{
362
+ Name: name,
363
+ Kind: kind,
364
+ },
365
+ published: published,
366
+ }, nil
367
+}
368
+
369
+func newSnapshot(generation uint64, publishedAt time.Time, records map[string]storeRecord) *Snapshot {
370
+ stores := make(map[string]publishedRecord, len(records))
371
+ for key, record := range records {
372
+ stores[key] = publishedRecord{
373
+ published: record.published,
374
+ }
375
+ }
376
+ return &Snapshot{
377
+ generation: generation,
378
+ publishedAt: publishedAt,
379
+ stores: stores,
380
+ }
381
+}
382
+
383
+func cloneRecords(in map[string]storeRecord) map[string]storeRecord {
384
+ if len(in) == 0 {
385
+ return map[string]storeRecord{}
386
+ }
387
+ out := make(map[string]storeRecord, len(in))
388
+ for key, record := range in {
389
+ out[key] = storeRecord{
390
+ rawConfig: cloneConfig(record.rawConfig),
391
+ configHash: record.configHash,
392
+ status: cloneStoreStatus(record.status),
393
+ published: record.published,
394
+ }
395
+ }
396
+ return out
397
+}
398
+
399
+func normalizeStoreKey(key string) (string, error) {
400
+ kind, name, err := ParseStoreKey(key)
401
+ if err != nil {
402
+ return "", err
403
+ }
404
+ return StoreKey(kind, name), nil
405
+}
406
+
407
+func (p preparedStore) record() storeRecord {
408
+ return storeRecord{
409
+ rawConfig: cloneConfig(p.rawConfig),
410
+ configHash: p.configHash,
411
+ status: cloneStoreStatus(p.status),
412
+ published: p.published,
413
+ }
414
+}
415
+
416
+type wrappedStoreError struct {
417
+ msg string
418
+ err error
419
+}
420
+
421
+func (e wrappedStoreError) Error() string { return e.msg }
422
+func (e wrappedStoreError) Unwrap() error { return e.err }
423
+
424
+func storeAlreadyExistsError(key string) error {
425
+ return wrappedStoreError{
426
+ msg: fmt.Sprintf("store '%s' already exists", key),
427
+ err: ErrStoreExists,
428
+ }
429
+}
430
+
431
+func storeNotConfiguredError(key string) error {
432
+ return wrappedStoreError{
433
+ msg: fmt.Sprintf("store '%s' is not configured", key),
434
+ err: ErrStoreNotFound,
435
+ }
436
+}
src/go/plugin/agent/secrets/secretstore/service_impl_test.go
new
+182
@@ -0,0 +1,182 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore_test
4
+
5
+import (
6
+ "errors"
7
+ "testing"
8
+
9
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
10
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/backends"
11
+ "github.com/stretchr/testify/assert"
12
+ "github.com/stretchr/testify/require"
13
+ "gopkg.in/yaml.v2"
14
+)
15
+
16
+func TestServiceRegistryMetadata(t *testing.T) {
17
+ svc := secretstore.NewService(backends.Creators()...)
18
+
19
+ assert.Equal(t, []secretstore.StoreKind{secretstore.KindAWSSM, secretstore.KindAzureKV, secretstore.KindGCPSM, secretstore.KindVault}, svc.Kinds())
20
+
21
+ name, ok := svc.DisplayName(secretstore.KindVault)
22
+ require.True(t, ok)
23
+ assert.Equal(t, "Vault", name)
24
+
25
+ schema, ok := svc.Schema(secretstore.KindVault)
26
+ require.True(t, ok)
27
+ schemaObject := decodeSchema(t, schema)
28
+ jsonSchema, ok := schemaObject["jsonSchema"].(map[string]any)
29
+ require.True(t, ok)
30
+ _, ok = jsonSchema["properties"].(map[string]any)["kind"]
31
+ assert.False(t, ok)
32
+ _, ok = schemaObject["uiSchema"].(map[string]any)
33
+ assert.True(t, ok)
34
+}
35
+
36
+func TestServiceStatusAndGenerationLifecycle(t *testing.T) {
37
+ svc := secretstore.NewService(backends.Creators()...)
38
+
39
+ config := testSingleVaultConfig()
40
+ err := svc.Add(newStoreFromConfig(t, svc, secretstore.KindVault, config))
41
+ require.NoError(t, err)
42
+ assert.Equal(t, uint64(1), svc.Capture().Generation())
43
+
44
+ storeKey := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
45
+ status, ok := svc.GetStatus(storeKey)
46
+ require.True(t, ok)
47
+ assert.Equal(t, "vault_prod", status.Name)
48
+ assert.Equal(t, secretstore.KindVault, status.Kind)
49
+ assert.Nil(t, status.LastValidation)
50
+
51
+ runtimeUpdate := testSingleVaultConfig()
52
+ runtimeUpdate["mode"] = "token_file"
53
+ runtimeUpdate["mode_token"] = nil
54
+ runtimeUpdate["mode_token_file"] = map[string]any{
55
+ "path": "/var/lib/netdata/vault.token",
56
+ }
57
+ err = svc.Update(storeKey, newStoreFromConfig(t, svc, secretstore.KindVault, runtimeUpdate))
58
+ require.NoError(t, err)
59
+ assert.Equal(t, uint64(2), svc.Capture().Generation())
60
+
61
+ err = svc.Update(storeKey, newStoreFromConfig(t, svc, secretstore.KindVault, runtimeUpdate))
62
+ require.NoError(t, err)
63
+ assert.Equal(t, uint64(2), svc.Capture().Generation())
64
+
65
+ err = svc.ValidateStored(storeKey)
66
+ require.NoError(t, err)
67
+
68
+ status, ok = svc.GetStatus(storeKey)
69
+ require.True(t, ok)
70
+ require.NotNil(t, status.LastValidation)
71
+ assert.True(t, status.LastValidation.OK)
72
+}
73
+
74
+func TestServiceUpdate_UnknownFieldOnlyChangeCountsAsChange(t *testing.T) {
75
+ svc := secretstore.NewService(backends.Creators()...)
76
+
77
+ base := testSingleVaultConfig()
78
+ err := svc.Add(newStoreFromConfig(t, svc, secretstore.KindVault, base))
79
+ require.NoError(t, err)
80
+ assert.Equal(t, uint64(1), svc.Capture().Generation())
81
+
82
+ changed := testSingleVaultConfig()
83
+ changed["ui_note"] = "kept"
84
+ err = svc.Update(secretstore.StoreKey(secretstore.KindVault, "vault_prod"), newStoreFromConfig(t, svc, secretstore.KindVault, changed))
85
+ require.NoError(t, err)
86
+ assert.Equal(t, uint64(2), svc.Capture().Generation())
87
+}
88
+
89
+func TestServiceValidateAcceptsYAMLDecodedNestedMaps(t *testing.T) {
90
+ svc := secretstore.NewService(backends.Creators()...)
91
+
92
+ var cfg secretstore.Config
93
+ require.NoError(t, yaml.Unmarshal([]byte(`
94
+mode: token
95
+mode_token:
96
+ token: vault-token
97
+addr: https://vault.example
98
+`), &cfg))
99
+ cfg.SetName("vault_prod")
100
+ cfg.SetKind(secretstore.KindVault)
101
+ cfg.SetSource("dyncfg")
102
+ cfg.SetSourceType("dyncfg")
103
+
104
+ require.NoError(t, svc.Validate(cfg))
105
+}
106
+
107
+func TestServiceUsesSentinelErrors(t *testing.T) {
108
+ svc := secretstore.NewService(backends.Creators()...)
109
+
110
+ err := svc.Add(newStoreFromConfig(t, svc, secretstore.KindVault, testSingleVaultConfig()))
111
+ require.NoError(t, err)
112
+
113
+ err = svc.Add(newStoreFromConfig(t, svc, secretstore.KindVault, testSingleVaultConfig()))
114
+ require.Error(t, err)
115
+ assert.ErrorIs(t, err, secretstore.ErrStoreExists)
116
+
117
+ missing := testSingleVaultConfig()
118
+ missing["name"] = "missing"
119
+ err = svc.Update(secretstore.StoreKey(secretstore.KindVault, "missing"), newStoreFromConfig(t, svc, secretstore.KindVault, missing))
120
+ require.Error(t, err)
121
+ assert.ErrorIs(t, err, secretstore.ErrStoreNotFound)
122
+
123
+ err = svc.Remove(secretstore.StoreKey(secretstore.KindVault, "missing"))
124
+ require.Error(t, err)
125
+ assert.True(t, errors.Is(err, secretstore.ErrStoreNotFound))
126
+}
127
+
128
+func TestProviderBackedValidationContracts(t *testing.T) {
129
+ svc := secretstore.NewService(backends.Creators()...)
130
+
131
+ err := svc.Validate(newStoreFromConfig(t, svc, secretstore.KindAWSSM, map[string]any{
132
+ "name": "aws_prod",
133
+ "auth_mode": "env",
134
+ }))
135
+ require.Error(t, err)
136
+ assert.ErrorContains(t, err, "region is required")
137
+
138
+ err = svc.Validate(newStoreFromConfig(t, svc, secretstore.KindVault, map[string]any{
139
+ "name": "vault_prod",
140
+ "mode": "token",
141
+ "mode_token": map[string]any{
142
+ "token": "vault-token",
143
+ },
144
+ }))
145
+ require.Error(t, err)
146
+ assert.ErrorContains(t, err, "addr is required")
147
+
148
+ awsSchema, ok := svc.Schema(secretstore.KindAWSSM)
149
+ require.True(t, ok)
150
+ awsSchemaObject := decodeSchema(t, awsSchema)
151
+ awsJSONSchema, ok := awsSchemaObject["jsonSchema"].(map[string]any)
152
+ require.True(t, ok)
153
+ assert.Contains(t, awsJSONSchema["required"], "auth_mode")
154
+ assert.NotContains(t, awsJSONSchema["required"], "kind")
155
+ assert.Contains(t, awsJSONSchema["required"], "region")
156
+
157
+ vaultSchema, ok := svc.Schema(secretstore.KindVault)
158
+ require.True(t, ok)
159
+ vaultSchemaObject := decodeSchema(t, vaultSchema)
160
+ vaultJSONSchema, ok := vaultSchemaObject["jsonSchema"].(map[string]any)
161
+ require.True(t, ok)
162
+ assert.Contains(t, vaultJSONSchema["required"], "addr")
163
+}
164
+
165
+func TestProviderBackedAddAcrossKinds(t *testing.T) {
166
+ svc := secretstore.NewService(backends.Creators()...)
167
+
168
+ for _, entry := range providerBackedConfigs() {
169
+ entry := entry
170
+ t.Run(string(entry.kind), func(t *testing.T) {
171
+ err := svc.Add(newStoreFromConfig(t, svc, entry.kind, entry.config))
172
+ require.NoError(t, err)
173
+
174
+ status, ok := svc.GetStatus(secretstore.StoreKey(entry.kind, entry.name))
175
+ require.True(t, ok)
176
+ assert.Equal(t, entry.kind, status.Kind)
177
+
178
+ err = svc.ValidateStored(secretstore.StoreKey(entry.kind, entry.name))
179
+ require.NoError(t, err)
180
+ })
181
+ }
182
+}
src/go/plugin/agent/secrets/secretstore/snapshot.go
new
+62
@@ -0,0 +1,62 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore
4
+
5
+import "time"
6
+
7
+type publishedRecord struct {
8
+ published PublishedStore
9
+}
10
+
11
+type Snapshot struct {
12
+ generation uint64
13
+ publishedAt time.Time
14
+ stores map[string]publishedRecord
15
+}
16
+
17
+func (s *Snapshot) Generation() uint64 {
18
+ if s == nil {
19
+ return 0
20
+ }
21
+ return s.generation
22
+}
23
+
24
+func (s *Snapshot) PublishedAt() time.Time {
25
+ if s == nil {
26
+ return time.Time{}
27
+ }
28
+ return s.publishedAt
29
+}
30
+
31
+func (s *Snapshot) lookupStore(key string) (publishedRecord, bool) {
32
+ if s == nil {
33
+ return publishedRecord{}, false
34
+ }
35
+ store, ok := s.stores[key]
36
+ if !ok {
37
+ return publishedRecord{}, false
38
+ }
39
+ return store, true
40
+}
41
+
42
+func cloneSnapshot(s *Snapshot) *Snapshot {
43
+ if s == nil {
44
+ return &Snapshot{stores: map[string]publishedRecord{}}
45
+ }
46
+ return &Snapshot{
47
+ generation: s.generation,
48
+ publishedAt: s.publishedAt,
49
+ stores: clonePublishedRecords(s.stores),
50
+ }
51
+}
52
+
53
+func clonePublishedRecords(in map[string]publishedRecord) map[string]publishedRecord {
54
+ if len(in) == 0 {
55
+ return map[string]publishedRecord{}
56
+ }
57
+ out := make(map[string]publishedRecord, len(in))
58
+ for id, store := range in {
59
+ out[id] = store
60
+ }
61
+ return out
62
+}
src/go/plugin/agent/secrets/secretstore/snapshot_test.go
new
+65
@@ -0,0 +1,65 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore
4
+
5
+import (
6
+ "context"
7
+ "testing"
8
+
9
+ "github.com/stretchr/testify/assert"
10
+)
11
+
12
+type testPublishedStore struct{}
13
+
14
+func (testPublishedStore) Resolve(_ context.Context, _ ResolveRequest) (string, error) {
15
+ return "", nil
16
+}
17
+
18
+func TestSnapshotLookupStore(t *testing.T) {
19
+ tests := map[string]struct {
20
+ snapshot *Snapshot
21
+ id string
22
+ wantOK bool
23
+ }{
24
+ "nil snapshot": {
25
+ snapshot: nil,
26
+ id: "s1",
27
+ wantOK: false,
28
+ },
29
+ "empty stores map": {
30
+ snapshot: &Snapshot{stores: map[string]publishedRecord{}},
31
+ id: "s1",
32
+ wantOK: false,
33
+ },
34
+ "not found": {
35
+ snapshot: &Snapshot{
36
+ stores: map[string]publishedRecord{
37
+ "s1": {},
38
+ },
39
+ },
40
+ id: "s2",
41
+ wantOK: false,
42
+ },
43
+ "found": {
44
+ snapshot: &Snapshot{
45
+ stores: map[string]publishedRecord{
46
+ "s1": {published: testPublishedStore{}},
47
+ },
48
+ },
49
+ id: "s1",
50
+ wantOK: true,
51
+ },
52
+ }
53
+
54
+ for name, tc := range tests {
55
+ t.Run(name, func(t *testing.T) {
56
+ store, ok := tc.snapshot.lookupStore(tc.id)
57
+ if !assert.Equal(t, tc.wantOK, ok, "lookupStore(%q) ok mismatch", tc.id) {
58
+ return
59
+ }
60
+ if ok {
61
+ assert.NotNil(t, store.published)
62
+ }
63
+ })
64
+ }
65
+}
src/go/plugin/agent/secrets/secretstore/test_helpers_test.go
new
+136
@@ -0,0 +1,136 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore_test
4
+
5
+import (
6
+ "encoding/json"
7
+ "testing"
8
+
9
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
10
+ "github.com/stretchr/testify/require"
11
+)
12
+
13
+type providerBackedConfig struct {
14
+ kind secretstore.StoreKind
15
+ name string
16
+ config map[string]any
17
+}
18
+
19
+func providerBackedConfigs() []providerBackedConfig {
20
+ return []providerBackedConfig{
21
+ {
22
+ kind: secretstore.KindAWSSM,
23
+ name: "aws_prod",
24
+ config: map[string]any{
25
+ "name": "aws_prod",
26
+ "auth_mode": "env",
27
+ "region": "us-east-1",
28
+ },
29
+ },
30
+ {
31
+ kind: secretstore.KindAzureKV,
32
+ name: "azure_prod",
33
+ config: map[string]any{
34
+ "name": "azure_prod",
35
+ "mode": "managed_identity",
36
+ },
37
+ },
38
+ {
39
+ kind: secretstore.KindGCPSM,
40
+ name: "gcp_prod",
41
+ config: map[string]any{
42
+ "name": "gcp_prod",
43
+ "mode": "metadata",
44
+ },
45
+ },
46
+ {
47
+ kind: secretstore.KindVault,
48
+ name: "vault_prod",
49
+ config: map[string]any{
50
+ "name": "vault_prod",
51
+ "mode": "token",
52
+ "mode_token": map[string]any{
53
+ "token": "vault-token",
54
+ },
55
+ "addr": "https://vault.example",
56
+ },
57
+ },
58
+ }
59
+}
60
+
61
+func testSingleVaultConfig() map[string]any {
62
+ return map[string]any{
63
+ "name": "vault_prod",
64
+ "mode": "token",
65
+ "mode_token": map[string]any{
66
+ "token": "vault-token",
67
+ },
68
+ "addr": "https://vault.example",
69
+ }
70
+}
71
+
72
+func newStoreFromConfig(t *testing.T, _ secretstore.Service, kind secretstore.StoreKind, cfg map[string]any) secretstore.Config {
73
+ t.Helper()
74
+
75
+ name, _ := cfg["name"].(string)
76
+ if name == "" {
77
+ name, _ = cfg["id"].(string)
78
+ }
79
+ require.NotEmpty(t, name)
80
+
81
+ payload := secretstore.Config(cloneTestMap(cfg))
82
+ delete(payload, "name")
83
+ delete(payload, "id")
84
+ delete(payload, "kind")
85
+ delete(payload, "enabled")
86
+ delete(payload, "description")
87
+ payload.SetName(name)
88
+ payload.SetKind(kind)
89
+ payload.SetSource("dyncfg")
90
+ payload.SetSourceType("dyncfg")
91
+ return payload
92
+}
93
+
94
+func decodeSchema(t *testing.T, raw string) map[string]any {
95
+ t.Helper()
96
+
97
+ var out map[string]any
98
+ require.NoError(t, json.Unmarshal([]byte(raw), &out))
99
+ return out
100
+}
101
+
102
+func cloneTestMap(in map[string]any) map[string]any {
103
+ if len(in) == 0 {
104
+ return nil
105
+ }
106
+ out := make(map[string]any, len(in))
107
+ for k, v := range in {
108
+ switch tv := v.(type) {
109
+ case map[string]any:
110
+ out[k] = cloneTestMap(tv)
111
+ case []any:
112
+ out[k] = cloneTestSlice(tv)
113
+ default:
114
+ out[k] = tv
115
+ }
116
+ }
117
+ return out
118
+}
119
+
120
+func cloneTestSlice(in []any) []any {
121
+ if len(in) == 0 {
122
+ return nil
123
+ }
124
+ out := make([]any, 0, len(in))
125
+ for _, v := range in {
126
+ switch tv := v.(type) {
127
+ case map[string]any:
128
+ out = append(out, cloneTestMap(tv))
129
+ case []any:
130
+ out = append(out, cloneTestSlice(tv))
131
+ default:
132
+ out = append(out, tv)
133
+ }
134
+ }
135
+ return out
136
+}
src/go/plugin/agent/secrets/secretstore/types.go
new
+57
@@ -0,0 +1,57 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore
4
+
5
+import "time"
6
+
7
+type StoreKind string
8
+
9
+const (
10
+ KindVault StoreKind = "vault"
11
+ KindAWSSM StoreKind = "aws-sm"
12
+ KindAzureKV StoreKind = "azure-kv"
13
+ KindGCPSM StoreKind = "gcp-sm"
14
+)
15
+
16
+func (k StoreKind) IsValid() bool {
17
+ switch k {
18
+ case KindVault, KindAWSSM, KindAzureKV, KindGCPSM:
19
+ return true
20
+ default:
21
+ return false
22
+ }
23
+}
24
+
25
+type ValidationStatus struct {
26
+ CheckedAt time.Time `json:"checked_at" yaml:"checked_at"`
27
+ OK bool `json:"ok" yaml:"ok"`
28
+}
29
+
30
+type StoreStatus struct {
31
+ Name string `json:"name" yaml:"name"`
32
+ Kind StoreKind `json:"kind" yaml:"kind"`
33
+ LastValidation *ValidationStatus `json:"last_validation,omitempty" yaml:"last_validation,omitempty"`
34
+ LastErrorSummary string `json:"last_error_summary,omitempty" yaml:"last_error_summary,omitempty"`
35
+}
36
+
37
+type ResolveRequest struct {
38
+ StoreKey string `json:"store_key" yaml:"store_key"`
39
+ StoreKind StoreKind `json:"store_kind" yaml:"store_kind"`
40
+ StoreName string `json:"store_name" yaml:"store_name"`
41
+ Operand string `json:"operand" yaml:"operand"`
42
+ Original string `json:"original" yaml:"original"`
43
+}
44
+
45
+var (
46
+ ErrStoreExists = errStore("store already exists")
47
+ ErrStoreNotFound = errStore("store is not configured")
48
+)
49
+
50
+type errStore string
51
+
52
+func (e errStore) Error() string { return string(e) }
53
+
54
+type JobRef struct {
55
+ ID string `json:"id" yaml:"id"`
56
+ Display string `json:"display" yaml:"display"`
57
+}
src/go/plugin/agent/secrets/secretstore/types_test.go
new
+29
@@ -0,0 +1,29 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package secretstore
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/stretchr/testify/assert"
9
+)
10
+
11
+func TestStoreKindIsValid(t *testing.T) {
12
+ tests := map[string]struct {
13
+ kind StoreKind
14
+ want bool
15
+ }{
16
+ "vault": {kind: KindVault, want: true},
17
+ "aws-sm": {kind: KindAWSSM, want: true},
18
+ "azure-kv": {kind: KindAzureKV, want: true},
19
+ "gcp-sm": {kind: KindGCPSM, want: true},
20
+ "empty": {kind: "", want: false},
21
+ "unknown": {kind: "foobar", want: false},
22
+ }
23
+
24
+ for name, tc := range tests {
25
+ t.Run(name, func(t *testing.T) {
26
+ assert.Equal(t, tc.want, tc.kind.IsValid())
27
+ })
28
+ }
29
+}
src/go/plugin/agent/setup.go
+17
@@ -8,6 +8,7 @@ import (
8
9
"github.com/netdata/netdata/go/plugins/pkg/pluginconfig"
10
"github.com/netdata/netdata/go/plugins/plugin/agent/discovery"
11
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
12
"github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
13
"github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
14
"github.com/netdata/netdata/go/plugins/plugin/framework/functions"
@@ -182,6 +183,22 @@ func (a *Agent) setupVnodeRegistry() map[string]*vnodes.VirtualNode {
183
return reg
184
}
185
186
+func (a *Agent) setupSecretStoreConfigs() []secretstore.Config {
187
+ a.Debugf("looking for 'ss/' in %v", a.CollectorsConfDir)
188
+ if len(a.CollectorsConfDir) == 0 {
189
+ return nil
190
+ }
191
+
192
+ cfgs, errs := secretstore.LoadFileConfigs(a.CollectorsConfDir)
193
+ for _, err := range errs {
194
+ a.Warningf("%v", err)
195
+ }
196
+ if len(cfgs) > 0 {
197
+ a.Infof("loaded %d secretstore configs from 'ss/'", len(cfgs))
198
+ }
199
+ return cfgs
200
+}
201
+
202
func loadYAML(conf any, path string) error {
203
f, err := os.Open(path)
204
if err != nil {
src/go/plugin/agent/setup_test.go
+61
@@ -3,10 +3,14 @@
3
package agent
4
5
import (
6
+ "os"
7
+ "path/filepath"
8
"testing"
9
10
"github.com/netdata/netdata/go/plugins/plugin/agent/discovery"
11
+ "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
12
"github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
13
+ "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
14
15
"github.com/stretchr/testify/assert"
16
"github.com/stretchr/testify/require"
@@ -209,6 +213,63 @@ func TestIsStockConfig(t *testing.T) {
213
assert.False(t, isStockConfig("/etc/netdata/go.d/module.conf"))
214
}
215
216
+func TestAgent_setupSecretStoreConfigs(t *testing.T) {
217
+ t.Run("loads merged configs from collectors dirs only", func(t *testing.T) {
218
+ base := t.TempDir()
219
+ configRoot := filepath.Join(base, "etc", "netdata")
220
+ userCollectors := filepath.Join(base, "etc", "netdata", "go.d")
221
+ stockCollectors := filepath.Join(base, "usr", "lib", "netdata", "conf.d", "go.d")
222
+
223
+ mustWriteAgentSecretStoreConfigFile(t, filepath.Join(configRoot, "ss", "vault.conf"), `
224
+jobs:
225
+ - name: ignored
226
+ mode: token
227
+ mode_token:
228
+ token: config-dir-should-not-load
229
+ addr: https://vault.example
230
+`)
231
+ mustWriteAgentSecretStoreConfigFile(t, filepath.Join(userCollectors, "ss", "vault.conf"), `
232
+jobs:
233
+ - name: vault_prod
234
+ mode: token
235
+ mode_token:
236
+ token: user-token
237
+ addr: https://vault.example
238
+`)
239
+ mustWriteAgentSecretStoreConfigFile(t, filepath.Join(stockCollectors, "ss", "aws-sm.conf"), `
240
+jobs:
241
+ - name: aws_prod
242
+ auth_mode: env
243
+ region: us-east-1
244
+`)
245
+
246
+ agent := &Agent{
247
+ ConfigDir: []string{configRoot},
248
+ CollectorsConfDir: []string{userCollectors, stockCollectors},
249
+ }
250
+
251
+ cfgs := agent.setupSecretStoreConfigs()
252
+ require.Len(t, cfgs, 2)
253
+ assert.Equal(t, "vault_prod", cfgs[0].Name())
254
+ assert.Equal(t, secretstore.KindVault, cfgs[0].Kind())
255
+ assert.Equal(t, confgroup.TypeUser, cfgs[0].SourceType())
256
+ assert.Equal(t, "aws_prod", cfgs[1].Name())
257
+ assert.Equal(t, secretstore.KindAWSSM, cfgs[1].Kind())
258
+ assert.Equal(t, confgroup.TypeStock, cfgs[1].SourceType())
259
+ })
260
+
261
+ t.Run("no collectors config dirs returns nil", func(t *testing.T) {
262
+ agent := &Agent{}
263
+ assert.Nil(t, agent.setupSecretStoreConfigs())
264
+ })
265
+}
266
+
267
+func mustWriteAgentSecretStoreConfigFile(t *testing.T, path, content string) {
268
+ t.Helper()
269
+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
270
+ require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
271
+}
272
+
273
func TestAgent_buildDiscoveryConf(t *testing.T) {
274
providers := []discovery.ProviderFactory{
275
discovery.NewProviderFactory("noop", nil),
src/go/plugin/framework/dyncfg/handler.go
+19
-5
@@ -50,6 +50,12 @@ type CodedError interface {
50
Code() int
51
}
52
53
+// CommandMessageSource optionally provides a success/warning message
54
+// for the command that just completed.
55
+type CommandMessageSource interface {
56
+ TakeCommandMessage() string
57
+}
58
+
59
// HandlerOpts configures the handler with component-specific settings.
60
type HandlerOpts[C Config] struct {
61
Logger *logger.Logger
@@ -82,6 +88,14 @@ type Handler[C Config] struct {
88
waitGate *waitGate[C]
89
}
90
91
+func takeCommandMessage[C Config](cb Callbacks[C]) string {
92
+ msgSrc, ok := any(cb).(CommandMessageSource)
93
+ if !ok {
94
+ return ""
95
+ }
96
+ return msgSrc.TakeCommandMessage()
97
+}
98
+
99
// WaitTimeoutEvent describes a wait gate timeout transition.
100
type WaitTimeoutEvent struct {
101
Key string
@@ -506,7 +520,7 @@ func (h *Handler[C]) CmdEnable(fn Function) {
520
}
521
522
entry.Status = StatusRunning
509
- h.api.SendCodef(fn, 200, "")
523
+ h.api.SendCodef(fn, 200, "%s", takeCommandMessage(h.cb))
524
h.NotifyJobStatus(entry.Cfg, StatusRunning)
525
h.cb.OnStatusChange(entry, oldStatus, fn)
526
}
@@ -537,7 +551,7 @@ func (h *Handler[C]) CmdDisable(fn Function) {
551
h.cb.Stop(entry.Cfg)
552
553
entry.Status = StatusDisabled
540
- h.api.SendCodef(fn, 200, "")
554
+ h.api.SendCodef(fn, 200, "%s", takeCommandMessage(h.cb))
555
h.NotifyJobStatus(entry.Cfg, StatusDisabled)
556
h.cb.OnStatusChange(entry, oldStatus, fn)
557
}
@@ -565,7 +579,7 @@ func (h *Handler[C]) CmdRemove(fn Function) {
579
h.exposed.Remove(entry.Cfg)
580
h.cb.Stop(entry.Cfg)
581
568
- h.api.SendCodef(fn, 200, "")
582
+ h.api.SendCodef(fn, 200, "%s", takeCommandMessage(h.cb))
583
h.NotifyJobRemove(entry.Cfg)
584
}
585
@@ -632,7 +646,7 @@ func (h *Handler[C]) CmdUpdate(fn Function) {
646
if isConversion {
647
h.NotifyJobCreate(newCfg, StatusDisabled)
648
}
635
- h.api.SendCodef(fn, 200, "")
649
+ h.api.SendCodef(fn, 200, "%s", takeCommandMessage(h.cb))
650
h.NotifyJobStatus(newCfg, StatusDisabled)
651
h.cb.OnStatusChange(newEntry, oldStatus, fn)
652
return
@@ -672,7 +686,7 @@ func (h *Handler[C]) CmdUpdate(fn Function) {
686
if isConversion {
687
h.NotifyJobCreate(newCfg, StatusRunning)
688
}
675
- h.api.SendCodef(fn, 200, "")
689
+ h.api.SendCodef(fn, 200, "%s", takeCommandMessage(h.cb))
690
h.NotifyJobStatus(newCfg, StatusRunning)
691
h.cb.OnStatusChange(newEntry, oldStatus, fn)
692
}
src/go/plugin/framework/dyncfg/helpers.go
+1
@@ -14,6 +14,7 @@ func WrapHandler(handler func(Function)) func(functions.Function) {
14
// BindResponder swaps a component responder and keeps handler API in sync.
15
func BindResponder[C Config](dst **Responder, handler *Handler[C], responder *Responder) {
16
if responder == nil {
17
+ // Nil means "keep the current responder" rather than clearing output wiring.
18
return
19
}
20
*dst = responder
src/go/plugin/go.d/config/go.d/ss/aws-sm.conf
new
+15
@@ -0,0 +1,15 @@
1
+## Uncomment and adjust the secretstores you want Netdata to load at startup.
2
+## File-defined secretstores are loaded from go.d/ss during agent startup.
3
+
4
+#jobs:
5
+# - name: aws_prod
6
+# auth_mode: env
7
+# region: us-east-1
8
+#
9
+# - name: aws_ecs
10
+# auth_mode: ecs
11
+# region: us-east-1
12
+#
13
+# - name: aws_imds
14
+# auth_mode: imds
15
+# region: us-east-1
src/go/plugin/go.d/config/go.d/ss/azure-kv.conf
new
+15
@@ -0,0 +1,15 @@
1
+## Uncomment and adjust the secretstores you want Netdata to load at startup.
2
+## File-defined secretstores are loaded from go.d/ss during agent startup.
3
+
4
+#jobs:
5
+# - name: azure_prod
6
+# mode: client
7
+# mode_client:
8
+# tenant_id: 00000000-0000-0000-0000-000000000000
9
+# client_id: 00000000-0000-0000-0000-000000000000
10
+# client_secret: your-client-secret
11
+#
12
+# - name: azure_vm
13
+# mode: managed_identity
14
+# mode_managed_identity:
15
+# client_id: 00000000-0000-0000-0000-000000000000
src/go/plugin/go.d/config/go.d/ss/gcp-sm.conf
new
+11
@@ -0,0 +1,11 @@
1
+## Uncomment and adjust the secretstores you want Netdata to load at startup.
2
+## File-defined secretstores are loaded from go.d/ss during agent startup.
3
+
4
+#jobs:
5
+# - name: gcp_metadata
6
+# mode: metadata
7
+#
8
+# - name: gcp_service_account
9
+# mode: service_account_file
10
+# mode_service_account_file:
11
+# path: /etc/netdata/gcp-service-account.json
src/go/plugin/go.d/config/go.d/ss/vault.conf
new
+17
@@ -0,0 +1,17 @@
1
+## Uncomment and adjust the secretstores you want Netdata to load at startup.
2
+## File-defined secretstores are loaded from go.d/ss during agent startup.
3
+
4
+#jobs:
5
+# - name: vault_prod
6
+# mode: token
7
+# mode_token:
8
+# token: your-vault-token
9
+# addr: https://vault.example
10
+# namespace: admin
11
+# tls_skip_verify: false
12
+#
13
+# - name: vault_prod_file_token
14
+# mode: token_file
15
+# mode_token_file:
16
+# path: /var/lib/netdata/vault.token
17
+# addr: https://vault.example
src/go/plugin/ibm.d/config/ibm.d/websphere_pmi.conf
+2
-2
@@ -108,7 +108,7 @@
108
# # - name: prod_was_node1
109
# # url: https://was-prod-01.example.com:9443/wasPerfTool/servlet/perfservlet
110
# # username: monitor
111
-# # password: ${WEBSPHERE_PASSWORD} # Use environment variable
111
+# # password: ${env:WEBSPHERE_PASSWORD} # Use environment variable
112
# # tls_skip_verify: true
113
# # cluster_name: ProdCluster
114
# # node_name: ProdNode01
@@ -122,7 +122,7 @@
122
# # - name: dmgr
123
# # url: https://dmgr.example.com:9043/wasPerfTool/servlet/perfservlet
124
# # username: wasadmin
125
-# # password: ${DMGR_PASSWORD}
125
+# # password: ${env:DMGR_PASSWORD}
126
# # server_type: dmgr
127
# # collect_cluster_metrics: true
128
# # collect_webapp_metrics: false