master
go 811 lines 30.3 KB
Raw
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 userconfig 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 }