master
go 982 lines 29 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 "errors"
10 "os"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/netdata/netdata/go/plugins/logger"
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18
19 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
20 "github.com/netdata/netdata/go/plugins/pkg/safewriter"
21 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
22 "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
23 "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
24 "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
25 "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
26 "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
27 )
28
29 func TestDyncfgConfigUserconfig_InvalidPayload_Returns400Only(t *testing.T) {
30 tests := map[string]struct {
31 contentType string
32 payload []byte
33 }{
34 "invalid json payload should stop after 400": {
35 contentType: "application/json",
36 payload: []byte("{"),
37 },
38 }
39
40 for name, tc := range tests {
41 t.Run(name, func(t *testing.T) {
42 var buf bytes.Buffer
43
44 mgr := New(Config{PluginName: testPluginName})
45 mgr.modules = prepareMockRegistry()
46 mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
47
48 fn := dyncfg.NewFunction(functions.Function{
49 UID: "bad-userconfig",
50 ContentType: tc.contentType,
51 Payload: tc.payload,
52 Args: []string{
53 mgr.dyncfgModID("success"),
54 string(dyncfg.CommandUserconfig),
55 "test",
56 },
57 })
58
59 mgr.dyncfgCmdUserconfig(fn)
60
61 out := buf.String()
62 assert.Equal(t, 1, strings.Count(out, "FUNCTION_RESULT_BEGIN bad-userconfig"))
63 assert.Contains(t, out, "\"status\":400")
64 assert.NotContains(t, out, "application/yaml")
65 })
66 }
67 }
68
69 func TestDyncfgCollectorExec_TestCommandQueued(t *testing.T) {
70 mgr := New(Config{PluginName: testPluginName})
71 mgr.ctx = context.Background()
72 mgr.dyncfgCh = make(chan dyncfg.Function, 1)
73
74 fn := dyncfg.NewFunction(functions.Function{
75 UID: "queued-test",
76 Args: []string{mgr.dyncfgModID("success"), "test", "job"},
77 })
78
79 mgr.dyncfgCollectorExec(fn)
80
81 select {
82 case queued := <-mgr.dyncfgCh:
83 assert.Equal(t, dyncfg.CommandTest, queued.Command())
84 assert.Equal(t, fn.UID(), queued.UID())
85 case <-time.After(time.Second):
86 t.Fatal("test command was not queued")
87 }
88 }
89
90 func TestDyncfgCmdTest_WhenWorkerPoolFull_Returns503(t *testing.T) {
91 var buf bytes.Buffer
92
93 mgr := New(Config{PluginName: testPluginName})
94 mgr.modules = prepareMockRegistry()
95 mgr.ctx = context.Background()
96 mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
97
98 for i := 0; i < cap(mgr.cmdTestSem); i++ {
99 mgr.cmdTestSem <- struct{}{}
100 }
101
102 cfg := prepareDyncfgCfg("success", "job")
103 payload, err := json.Marshal(cfg)
104 require.NoError(t, err)
105
106 fn := dyncfg.NewFunction(functions.Function{
107 UID: "pool-full",
108 ContentType: "application/json",
109 Payload: payload,
110 Args: []string{
111 mgr.dyncfgModID("success"),
112 string(dyncfg.CommandTest),
113 "job",
114 },
115 })
116
117 mgr.dyncfgCmdTest(fn)
118
119 out := buf.String()
120 assert.Equal(t, 1, strings.Count(out, "FUNCTION_RESULT_BEGIN pool-full"))
121 assert.Contains(t, out, "\"status\":503")
122 }
123
124 func TestDyncfgCmdTestTimeout_RequestTimeoutOverridesDefault(t *testing.T) {
125 mgr := New(Config{PluginName: testPluginName})
126
127 withRequestTimeout := dyncfg.NewFunction(functions.Function{Timeout: 7 * time.Second})
128 assert.Equal(t, 7*time.Second, mgr.dyncfgCmdTestTimeout(withRequestTimeout))
129
130 withoutTimeout := dyncfg.NewFunction(functions.Function{})
131 assert.Equal(t, cmdTestDefaultTimeout, mgr.dyncfgCmdTestTimeout(withoutTimeout))
132 }
133
134 func TestDyncfgCollectorSeqExec_SyncsSecretStoreDepsForMutatingCommands(t *testing.T) {
135 tests := map[string]struct {
136 command dyncfg.Command
137 oldCfg confgroup.Config
138 newCfg confgroup.Config
139 args []string
140 wantOldExposed int
141 wantNewExposed int
142 wantOldRunning int
143 wantNewRunning int
144 }{
145 "add syncs active deps from newly exposed config": {
146 command: dyncfg.CommandAdd,
147 newCfg: prepareDyncfgCfg("success", "job").Set("password", "${store:vault:vault_prod:secret/data/mysql#password}"),
148 args: []string{"success", string(dyncfg.CommandAdd), "job"},
149 wantOldExposed: 0,
150 wantNewExposed: 1,
151 wantOldRunning: 0,
152 wantNewRunning: 0,
153 },
154 "update replaces active deps with updated config": {
155 command: dyncfg.CommandUpdate,
156 oldCfg: prepareDyncfgCfg("success", "job").Set("password", "${store:vault:vault_old:secret/data/mysql#password}"),
157 newCfg: prepareDyncfgCfg("success", "job").Set("password", "${store:vault:vault_new:secret/data/mysql#password}"),
158 args: []string{"success:job", string(dyncfg.CommandUpdate)},
159 wantOldExposed: 0,
160 wantNewExposed: 1,
161 wantOldRunning: 0,
162 wantNewRunning: 0,
163 },
164 "remove clears active deps when config disappears": {
165 command: dyncfg.CommandRemove,
166 oldCfg: prepareDyncfgCfg("success", "job").Set("password", "${store:vault:vault_old:secret/data/mysql#password}"),
167 args: []string{"success:job", string(dyncfg.CommandRemove)},
168 wantOldExposed: 0,
169 wantNewExposed: 0,
170 wantOldRunning: 0,
171 wantNewRunning: 0,
172 },
173 }
174
175 for name, tc := range tests {
176 t.Run(name, func(t *testing.T) {
177 mgr := newCollectorTestManager()
178 cb := &collectorSeqTestCallbacks{mgr: mgr, parsed: map[dyncfg.Command]confgroup.Config{}}
179 if tc.newCfg != nil {
180 cb.parsed[tc.command] = tc.newCfg
181 }
182 mgr.collectorHandler = newCollectorTestHandler(mgr, cb)
183
184 if tc.oldCfg != nil {
185 seedCollectorEntry(mgr, tc.oldCfg, dyncfg.StatusDisabled)
186 mgr.syncSecretStoreDepsForConfig(tc.oldCfg)
187 }
188
189 var payload []byte
190 if tc.command == dyncfg.CommandAdd || tc.command == dyncfg.CommandUpdate {
191 payload = mustMarshalCollectorConfigPayload(t, tc.newCfg)
192 }
193
194 fn := dyncfg.NewFunction(functions.Function{
195 UID: name,
196 ContentType: "application/json",
197 Payload: payload,
198 Args: collectorTestArgs(mgr, tc.args...),
199 })
200
201 mgr.dyncfgCollectorSeqExec(fn)
202
203 oldExposed, oldRunning := mgr.secretStoreDeps.Impacted("vault:vault_old")
204 newExposed, newRunning := mgr.secretStoreDeps.Impacted("vault:vault_prod")
205 if tc.command == dyncfg.CommandUpdate {
206 newExposed, newRunning = mgr.secretStoreDeps.Impacted("vault:vault_new")
207 }
208
209 assert.Len(t, oldExposed, tc.wantOldExposed)
210 assert.Len(t, oldRunning, tc.wantOldRunning)
211 assert.Len(t, newExposed, tc.wantNewExposed)
212 assert.Len(t, newRunning, tc.wantNewRunning)
213 })
214 }
215 }
216
217 func TestDyncfgCollectorSeqExec_DoesNotSyncSecretStoreDepsForNonMutatingCommands(t *testing.T) {
218 tests := map[string]struct {
219 command dyncfg.Command
220 args []string
221 payload confgroup.Config
222 status dyncfg.Status
223 }{
224 "restart leaves deps unchanged": {
225 command: dyncfg.CommandRestart,
226 args: []string{"success:job", string(dyncfg.CommandRestart)},
227 status: dyncfg.StatusDisabled,
228 },
229 "test leaves deps unchanged": {
230 command: dyncfg.CommandTest,
231 args: []string{"success", string(dyncfg.CommandTest), "job"},
232 payload: prepareDyncfgCfg("success", "job"),
233 status: dyncfg.StatusDisabled,
234 },
235 "schema leaves deps unchanged": {
236 command: dyncfg.CommandSchema,
237 args: []string{"success", string(dyncfg.CommandSchema)},
238 status: dyncfg.StatusDisabled,
239 },
240 "get leaves deps unchanged": {
241 command: dyncfg.CommandGet,
242 args: []string{"success:job", string(dyncfg.CommandGet)},
243 status: dyncfg.StatusDisabled,
244 },
245 }
246
247 for name, tc := range tests {
248 t.Run(name, func(t *testing.T) {
249 mgr := newCollectorTestManager()
250 cfg := prepareDyncfgCfg("success", "job").Set("password", "${store:vault:vault_prod:secret/data/mysql#password}")
251 seedCollectorEntry(mgr, cfg, tc.status)
252 mgr.syncSecretStoreDepsForConfig(cfg)
253
254 beforeExposed, beforeRunning := mgr.secretStoreDeps.Impacted("vault:vault_prod")
255 require.Len(t, beforeExposed, 1)
256
257 var payload []byte
258 if tc.payload != nil {
259 payload = mustMarshalCollectorConfigPayload(t, tc.payload)
260 }
261
262 fn := dyncfg.NewFunction(functions.Function{
263 UID: name,
264 ContentType: "application/json",
265 Payload: payload,
266 Args: collectorTestArgs(mgr, tc.args...),
267 })
268
269 mgr.dyncfgCollectorSeqExec(fn)
270 if tc.command == dyncfg.CommandTest {
271 mgr.cmdTestWG.Wait()
272 }
273
274 afterExposed, afterRunning := mgr.secretStoreDeps.Impacted("vault:vault_prod")
275 assert.Equal(t, beforeExposed, afterExposed)
276 assert.Equal(t, beforeRunning, afterRunning)
277 })
278 }
279 }
280
281 func TestCollectorCallbacks_ParseAndValidate(t *testing.T) {
282 tests := map[string]struct {
283 args []string
284 cfg confgroup.Config
285 payload []byte
286 wantErr string
287 wantModule string
288 wantName string
289 wantProvider string
290 wantSourceTyp string
291 }{
292 "invalid id is rejected": {
293 args: []string{"", string(dyncfg.CommandAdd), "validated"},
294 cfg: prepareDyncfgCfg("success", "validated"),
295 wantErr: "could not extract module name from ID",
296 },
297 "invalid payload is rejected": {
298 args: []string{"success", string(dyncfg.CommandAdd), "validated"},
299 payload: []byte("{"),
300 wantErr: "invalid configuration format",
301 },
302 "valid payload is accepted and metadata is set": {
303 args: []string{"success", string(dyncfg.CommandAdd), "validated"},
304 cfg: prepareDyncfgCfg("success", "payload-name").Set("option_str", "one").Set("option_int", 2),
305 wantModule: "success",
306 wantName: "validated",
307 wantProvider: "dyncfg",
308 wantSourceTyp: confgroup.TypeDyncfg,
309 },
310 }
311
312 for name, tc := range tests {
313 t.Run(name, func(t *testing.T) {
314 mgr := newCollectorTestManager()
315 cb := &collectorCallbacks{mgr: mgr}
316 payload := tc.payload
317 if payload == nil && tc.cfg != nil {
318 payload = mustMarshalCollectorConfigPayload(t, tc.cfg)
319 }
320 fn := dyncfg.NewFunction(functions.Function{
321 UID: name,
322 ContentType: "application/json",
323 Payload: payload,
324 Args: collectorTestArgs(mgr, tc.args...),
325 })
326
327 cfg, err := cb.ParseAndValidate(fn, "validated")
328 if tc.wantErr != "" {
329 require.Error(t, err)
330 assert.Contains(t, err.Error(), tc.wantErr)
331 return
332 }
333
334 require.NoError(t, err)
335 assert.Equal(t, tc.wantModule, cfg.Module())
336 assert.Equal(t, tc.wantName, cfg.Name())
337 assert.Equal(t, tc.wantProvider, cfg.Provider())
338 assert.Equal(t, tc.wantSourceTyp, cfg.SourceType())
339 })
340 }
341 }
342
343 func TestCollectorCallbacks_ParseAndValidate_SuppressesAuditSideEffects(t *testing.T) {
344 tempDir := t.TempDir()
345 analyzer := &auditAnalyzerSpy{}
346 mgr := newCollectorTestManager()
347 mgr.auditAnalyzer = analyzer
348 mgr.auditDataDir = tempDir
349 cb := &collectorCallbacks{mgr: mgr}
350
351 cfg := prepareDyncfgCfg("success", "payload-name").Set("option_str", "one").Set("option_int", 2)
352 fn := dyncfg.NewFunction(functions.Function{
353 UID: "validation-audit-side-effects",
354 ContentType: "application/json",
355 Payload: mustMarshalCollectorConfigPayload(t, cfg),
356 Args: collectorTestArgs(mgr, "success", string(dyncfg.CommandAdd), "validated"),
357 })
358
359 _, err := cb.ParseAndValidate(fn, "validated")
360 require.NoError(t, err)
361
362 entries, err := os.ReadDir(tempDir)
363 require.NoError(t, err)
364 assert.Empty(t, entries)
365 assert.Empty(t, analyzer.registered)
366 }
367
368 func TestCollectorCallbacks_ApplyConfigLoggingHonorsValidationMode(t *testing.T) {
369 tests := map[string]struct {
370 run func(t *testing.T, mgr *Manager, logBuf *bytes.Buffer)
371 wantLogMessage bool
372 }{
373 "validation suppresses expected applyConfig error logs": {
374 run: func(t *testing.T, mgr *Manager, _ *bytes.Buffer) {
375 cb := &collectorCallbacks{mgr: mgr}
376 cfg := prepareDyncfgCfg("success", "payload-name").Set("option_str", "one").Set("option_int", "bad")
377 fn := dyncfg.NewFunction(functions.Function{
378 UID: "validation-no-log",
379 ContentType: "application/json",
380 Payload: mustMarshalCollectorConfigPayload(t, cfg),
381 Args: collectorTestArgs(mgr, "success", string(dyncfg.CommandAdd), "validated"),
382 })
383
384 _, err := cb.ParseAndValidate(fn, "validated")
385 require.Error(t, err)
386 assert.Contains(t, err.Error(), "failed to apply configuration")
387 },
388 },
389 "runtime creation still logs applyConfig errors": {
390 run: func(t *testing.T, mgr *Manager, _ *bytes.Buffer) {
391 cfg := prepareDyncfgCfg("success", "runtime-job").Set("option_str", "one").Set("option_int", "bad")
392
393 _, err := mgr.createCollectorJob(cfg)
394 require.Error(t, err)
395 assert.Contains(t, err.Error(), "cannot unmarshal")
396 },
397 wantLogMessage: true,
398 },
399 }
400
401 for name, tc := range tests {
402 t.Run(name, func(t *testing.T) {
403 var logBuf bytes.Buffer
404 mgr := newCollectorTestManager()
405 mgr.Logger = logger.NewWithWriter(&logBuf)
406
407 tc.run(t, mgr, &logBuf)
408
409 const msg = "failed to apply config for"
410 if tc.wantLogMessage {
411 assert.Contains(t, logBuf.String(), msg)
412 } else {
413 assert.NotContains(t, logBuf.String(), msg)
414 }
415 })
416 }
417 }
418
419 func TestCollectorCallbacks_Start(t *testing.T) {
420 tests := map[string]struct {
421 cfg confgroup.Config
422 wantErr string
423 wantCode int
424 wantRunning bool
425 wantRetryPending bool
426 }{
427 "success starts the job": {
428 cfg: prepareDyncfgCfg("success", "job"),
429 wantRunning: true,
430 },
431 "autodetection failure schedules retry": {
432 cfg: prepareDyncfgCfg("retrycheck", "job").Set("autodetection_retry", 1),
433 wantErr: "job enable failed",
434 wantRetryPending: true,
435 },
436 "invalid config returns coded validation error": {
437 cfg: prepareDyncfgCfg("missing", "job"),
438 wantErr: "invalid configuration",
439 wantCode: 400,
440 },
441 }
442
443 for name, tc := range tests {
444 t.Run(name, func(t *testing.T) {
445 mgr := newCollectorTestManager()
446 cb := &collectorCallbacks{mgr: mgr}
447
448 err := cb.Start(tc.cfg)
449 if tc.wantErr != "" {
450 require.Error(t, err)
451 assert.Contains(t, err.Error(), tc.wantErr)
452 if tc.wantCode != 0 {
453 var coded interface{ Code() int }
454 require.ErrorAs(t, err, &coded)
455 assert.Equal(t, tc.wantCode, coded.Code())
456 }
457 } else {
458 require.NoError(t, err)
459 }
460
461 _, running := mgr.runningJobs.lookup(tc.cfg.FullName())
462 assert.Equal(t, tc.wantRunning, running)
463
464 _, retryPending := mgr.retryingTasks.lookup(tc.cfg)
465 assert.Equal(t, tc.wantRetryPending, retryPending)
466
467 if tc.wantRunning {
468 mgr.stopRunningJob(tc.cfg.FullName())
469 }
470 if tc.wantRetryPending {
471 mgr.retryingTasks.remove(tc.cfg)
472 }
473 })
474 }
475 }
476
477 func TestCollectorCallbacks_Update(t *testing.T) {
478 tests := map[string]struct {
479 oldCfg confgroup.Config
480 newCfg confgroup.Config
481 wantErr string
482 wantRunning bool
483 wantRetryPending bool
484 }{
485 "success restarts with the new config and clears old state": {
486 oldCfg: prepareDyncfgCfg("success", "job"),
487 newCfg: prepareDyncfgCfg("success", "job").Set("option_str", "changed"),
488 wantRunning: true,
489 },
490 "autodetection failure clears old state and schedules retry": {
491 oldCfg: prepareDyncfgCfg("retrycheck", "job"),
492 newCfg: prepareDyncfgCfg("retrycheck", "job").Set("autodetection_retry", 1),
493 wantErr: "job update failed",
494 wantRetryPending: true,
495 },
496 }
497
498 for name, tc := range tests {
499 t.Run(name, func(t *testing.T) {
500 mgr := newCollectorTestManager()
501 cb := &collectorCallbacks{mgr: mgr}
502 oldJob := &collectorProbeJob{
503 fullName: tc.oldCfg.FullName(),
504 moduleName: tc.oldCfg.Module(),
505 name: tc.oldCfg.Name(),
506 }
507
508 mgr.runningJobs.lock()
509 mgr.runningJobs.add(oldJob.FullName(), oldJob)
510 mgr.runningJobs.unlock()
511 mgr.fileStatus.add(tc.oldCfg, dyncfg.StatusRunning.String())
512
513 _, cancel := context.WithCancel(context.Background())
514 defer cancel()
515 mgr.retryingTasks.add(tc.oldCfg, &retryTask{cancel: cancel})
516
517 err := cb.Update(tc.oldCfg, tc.newCfg)
518 if tc.wantErr != "" {
519 require.Error(t, err)
520 assert.Contains(t, err.Error(), tc.wantErr)
521 } else {
522 require.NoError(t, err)
523 }
524
525 assert.True(t, oldJob.stopped)
526
527 _, oldRetryPending := mgr.retryingTasks.lookup(tc.oldCfg)
528 assert.False(t, oldRetryPending)
529
530 _, oldFileStatus := mgr.fileStatus.lookup(tc.oldCfg)
531 assert.False(t, oldFileStatus)
532
533 _, running := mgr.runningJobs.lookup(tc.newCfg.FullName())
534 assert.Equal(t, tc.wantRunning, running)
535
536 _, retryPending := mgr.retryingTasks.lookup(tc.newCfg)
537 assert.Equal(t, tc.wantRetryPending, retryPending)
538
539 if tc.wantRunning {
540 mgr.stopRunningJob(tc.newCfg.FullName())
541 }
542 if tc.wantRetryPending {
543 mgr.retryingTasks.remove(tc.newCfg)
544 }
545 })
546 }
547 }
548
549 func TestCollectorCallbacks_Stop(t *testing.T) {
550 tests := map[string]struct{}{
551 "stop removes retry task, running job, and file status": {},
552 }
553
554 for name := range tests {
555 t.Run(name, func(t *testing.T) {
556 mgr := newCollectorTestManager()
557 cb := &collectorCallbacks{mgr: mgr}
558 cfg := prepareDyncfgCfg("success", "job")
559 job := &collectorProbeJob{
560 fullName: cfg.FullName(),
561 moduleName: cfg.Module(),
562 name: cfg.Name(),
563 }
564
565 mgr.runningJobs.lock()
566 mgr.runningJobs.add(job.FullName(), job)
567 mgr.runningJobs.unlock()
568 mgr.fileStatus.add(cfg, dyncfg.StatusRunning.String())
569
570 _, cancel := context.WithCancel(context.Background())
571 defer cancel()
572 mgr.retryingTasks.add(cfg, &retryTask{cancel: cancel})
573
574 cb.Stop(cfg)
575
576 assert.True(t, job.stopped)
577 _, running := mgr.runningJobs.lookup(cfg.FullName())
578 assert.False(t, running)
579 _, retryPending := mgr.retryingTasks.lookup(cfg)
580 assert.False(t, retryPending)
581 _, fileStatus := mgr.fileStatus.lookup(cfg)
582 assert.False(t, fileStatus)
583 })
584 }
585 }
586
587 func TestCollectorCallbacks_OnStatusChange(t *testing.T) {
588 tests := map[string]struct {
589 cfg confgroup.Config
590 status dyncfg.Status
591 wantSeen bool
592 }{
593 "running dyncfg config is persisted to file status": {
594 cfg: prepareDyncfgCfg("success", "job"),
595 status: dyncfg.StatusRunning,
596 wantSeen: true,
597 },
598 "failed dyncfg config is ignored": {
599 cfg: prepareDyncfgCfg("success", "job"),
600 status: dyncfg.StatusFailed,
601 },
602 "running non-dyncfg config is ignored": {
603 cfg: prepareUserCfg("success", "job"),
604 status: dyncfg.StatusRunning,
605 },
606 }
607
608 for name, tc := range tests {
609 t.Run(name, func(t *testing.T) {
610 mgr := newCollectorTestManager()
611 cb := &collectorCallbacks{mgr: mgr}
612 entry := &dyncfg.Entry[confgroup.Config]{
613 Cfg: tc.cfg,
614 Status: tc.status,
615 }
616
617 cb.OnStatusChange(entry, dyncfg.StatusAccepted, dyncfg.NewFunction(functions.Function{}))
618
619 _, ok := mgr.fileStatus.lookup(tc.cfg)
620 assert.Equal(t, tc.wantSeen, ok)
621 })
622 }
623 }
624
625 func TestRunDyncfgCmdTest_CleanupIsDeferred(t *testing.T) {
626 tests := map[string]struct {
627 initErr string
628 checkErr string
629 wantStatus float64
630 }{
631 "cleanup runs after init failure": {
632 initErr: "init failed",
633 wantStatus: 422,
634 },
635 "cleanup runs after check failure": {
636 checkErr: "check failed",
637 wantStatus: 422,
638 },
639 "cleanup runs after success": {
640 wantStatus: 200,
641 },
642 }
643
644 for name, tc := range tests {
645 t.Run(name, func(t *testing.T) {
646 var buf bytes.Buffer
647 mgr := newCollectorTestManager()
648 mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
649
650 module := &collectorapi.MockCollectorV1{}
651 if tc.initErr != "" {
652 module.InitFunc = func(context.Context) error { return errors.New(tc.initErr) }
653 }
654 if tc.checkErr != "" {
655 module.CheckFunc = func(context.Context) error { return errors.New(tc.checkErr) }
656 }
657
658 task := dyncfgCmdTestTask{
659 fn: dyncfg.NewFunction(functions.Function{
660 UID: name,
661 }),
662 moduleName: "success",
663 creator: collectorapi.Creator{
664 Create: func() collectorapi.CollectorV1 {
665 return module
666 },
667 },
668 cfg: prepareDyncfgCfg("success", "job"),
669 timeout: time.Second,
670 }
671
672 mgr.cmdTestSem <- struct{}{}
673 mgr.runDyncfgCmdTest(task)
674
675 assert.True(t, module.CleanupDone)
676
677 var resp map[string]any
678 mustDecodeFunctionPayload(t, buf.String(), name, &resp)
679 assert.Equal(t, tc.wantStatus, resp["status"])
680 })
681 }
682 }
683
684 func TestRunDyncfgCmdTest_ApplyConfigUsesRequestTimeout(t *testing.T) {
685 tests := map[string]struct {
686 timeout time.Duration
687 }{
688 "secret resolution sees request deadline": {
689 timeout: 20 * time.Millisecond,
690 },
691 }
692
693 for name, tc := range tests {
694 t.Run(name, func(t *testing.T) {
695 var buf bytes.Buffer
696 blockingSvc := &blockingSecretStoreService{}
697 mgr := newCollectorTestManagerWithService(blockingSvc)
698 mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
699
700 task := dyncfgCmdTestTask{
701 fn: dyncfg.NewFunction(functions.Function{
702 UID: name,
703 }),
704 moduleName: "success",
705 creator: collectorapi.Creator{
706 Create: func() collectorapi.CollectorV1 {
707 return &collectorapi.MockCollectorV1{}
708 },
709 },
710 cfg: prepareDyncfgCfg("success", "job").
711 Set("password", "${store:vault:vault_prod:secret/data/mysql#password}"),
712 timeout: tc.timeout,
713 }
714
715 mgr.cmdTestSem <- struct{}{}
716 start := time.Now()
717 mgr.runDyncfgCmdTest(task)
718 elapsed := time.Since(start)
719
720 var resp map[string]any
721 mustDecodeFunctionPayload(t, buf.String(), name, &resp)
722 assert.Equal(t, float64(400), resp["status"])
723 assert.Contains(t, resp["errorMessage"], context.DeadlineExceeded.Error())
724 assert.True(t, blockingSvc.sawDeadline)
725 assert.Less(t, elapsed, tc.timeout+250*time.Millisecond)
726 })
727 }
728 }
729
730 func TestDyncfgCmdTest_ShutdownBeforeWorker_Returns503(t *testing.T) {
731 tests := map[string]struct{}{
732 "shutdown manager returns 503 before scheduling worker": {},
733 }
734
735 for name := range tests {
736 t.Run(name, func(t *testing.T) {
737 var buf bytes.Buffer
738 mgr := newCollectorTestManager()
739 mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
740
741 ctx, cancel := context.WithCancel(context.Background())
742 cancel()
743 mgr.ctx = ctx
744
745 fn := dyncfg.NewFunction(functions.Function{
746 UID: name,
747 ContentType: "application/json",
748 Payload: mustMarshalCollectorConfigPayload(t, prepareDyncfgCfg("success", "job")),
749 Args: []string{mgr.dyncfgModID("success"), string(dyncfg.CommandTest), "job"},
750 })
751
752 mgr.dyncfgCmdTest(fn)
753
754 var resp map[string]any
755 mustDecodeFunctionPayload(t, buf.String(), name, &resp)
756 assert.Equal(t, float64(503), resp["status"])
757 })
758 }
759 }
760
761 func TestDyncfgCmdTest_MissingPayload_Returns400(t *testing.T) {
762 tests := map[string]struct{}{
763 "missing payload is rejected before config parsing": {},
764 }
765
766 for name := range tests {
767 t.Run(name, func(t *testing.T) {
768 var buf bytes.Buffer
769 mgr := newCollectorTestManager()
770 mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
771
772 fn := dyncfg.NewFunction(functions.Function{
773 UID: name,
774 Args: []string{mgr.dyncfgModID("success"), string(dyncfg.CommandTest), "job"},
775 })
776
777 mgr.dyncfgCmdTest(fn)
778
779 var resp map[string]any
780 mustDecodeFunctionPayload(t, buf.String(), name, &resp)
781 assert.Equal(t, float64(400), resp["status"])
782 assert.Contains(t, resp["errorMessage"], "Missing configuration payload.")
783 })
784 }
785 }
786
787 func newCollectorTestManager() *Manager {
788 return newCollectorTestManagerWithService(nil)
789 }
790
791 func newCollectorTestManagerWithService(secretStoreSvc secretstore.Service) *Manager {
792 mgr := New(Config{
793 PluginName: testPluginName,
794 SecretStoreService: secretStoreSvc,
795 })
796 mgr.ctx = context.Background()
797 mgr.modules = prepareMockRegistry()
798 mgr.modules.Register("retrycheck", collectorapi.Creator{
799 Create: func() collectorapi.CollectorV1 {
800 return &collectorapi.MockCollectorV1{
801 CheckFunc: func(context.Context) error { return errors.New("mock failed check") },
802 }
803 },
804 })
805 mgr.fileStatus = newFileStatus()
806 return mgr
807 }
808
809 func newCollectorTestHandler(mgr *Manager, cb dyncfg.Callbacks[confgroup.Config]) *dyncfg.Handler[confgroup.Config] {
810 return dyncfg.NewHandler(dyncfg.HandlerOpts[confgroup.Config]{
811 Logger: mgr.Logger,
812 API: mgr.dyncfgResponder,
813 Seen: mgr.collectorSeen,
814 Exposed: mgr.collectorExposed,
815 Callbacks: cb,
816 WaitKey: func(cfg confgroup.Config) string {
817 return cfg.FullName()
818 },
819 Path: "/collectors/test/Jobs",
820 EnableFailCode: 200,
821 RemoveStockOnEnableFail: true,
822 JobCommands: []dyncfg.Command{
823 dyncfg.CommandSchema,
824 dyncfg.CommandGet,
825 dyncfg.CommandEnable,
826 dyncfg.CommandDisable,
827 dyncfg.CommandUpdate,
828 dyncfg.CommandRestart,
829 dyncfg.CommandTest,
830 dyncfg.CommandUserconfig,
831 },
832 })
833 }
834
835 func seedCollectorEntry(mgr *Manager, cfg confgroup.Config, status dyncfg.Status) {
836 mgr.collectorSeen.Add(cfg)
837 mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
838 Cfg: cfg,
839 Status: status,
840 })
841 }
842
843 func collectorTestArgs(mgr *Manager, args ...string) []string {
844 out := make([]string, len(args))
845 copy(out, args)
846 if len(out) == 0 {
847 return out
848 }
849
850 switch {
851 case strings.Contains(out[0], ":"):
852 out[0] = mgr.dyncfgCollectorPrefixValue() + out[0]
853 case out[0] != "":
854 out[0] = mgr.dyncfgModID(out[0])
855 }
856
857 return out
858 }
859
860 func mustMarshalCollectorConfigPayload(t *testing.T, cfg confgroup.Config) []byte {
861 t.Helper()
862
863 payload, err := json.Marshal(cfg)
864 require.NoError(t, err)
865 return payload
866 }
867
868 type collectorSeqTestCallbacks struct {
869 mgr *Manager
870 parsed map[dyncfg.Command]confgroup.Config
871 }
872
873 func (cb *collectorSeqTestCallbacks) ExtractKey(fn dyncfg.Function) (key, name string, ok bool) {
874 return cb.mgr.collectorCallbacks.ExtractKey(fn)
875 }
876
877 func (cb *collectorSeqTestCallbacks) ValidateJobName(name string) error {
878 return dyncfg.JobNameRuleStrict(name)
879 }
880
881 func (cb *collectorSeqTestCallbacks) ParseAndValidate(fn dyncfg.Function, _ string) (confgroup.Config, error) {
882 cfg, ok := cb.parsed[fn.Command()]
883 if !ok {
884 return nil, errors.New("unexpected parse request")
885 }
886 return cfg, nil
887 }
888
889 func (cb *collectorSeqTestCallbacks) Start(confgroup.Config) error { return nil }
890
891 func (cb *collectorSeqTestCallbacks) Update(_, _ confgroup.Config) error { return nil }
892
893 func (cb *collectorSeqTestCallbacks) Stop(confgroup.Config) {}
894
895 func (cb *collectorSeqTestCallbacks) OnStatusChange(*dyncfg.Entry[confgroup.Config], dyncfg.Status, dyncfg.Function) {
896 }
897
898 func (cb *collectorSeqTestCallbacks) ConfigID(cfg confgroup.Config) string {
899 return cb.mgr.dyncfgJobID(cfg)
900 }
901
902 type collectorProbeJob struct {
903 fullName string
904 moduleName string
905 name string
906 stopped bool
907 }
908
909 func (j *collectorProbeJob) FullName() string { return j.fullName }
910 func (j *collectorProbeJob) ModuleName() string { return j.moduleName }
911 func (j *collectorProbeJob) Name() string { return j.name }
912 func (j *collectorProbeJob) Collector() any { return nil }
913 func (j *collectorProbeJob) Start() {}
914 func (j *collectorProbeJob) Stop() { j.stopped = true }
915 func (j *collectorProbeJob) Tick(int) {}
916 func (j *collectorProbeJob) AutoDetection() error {
917 return nil
918 }
919 func (j *collectorProbeJob) AutoDetectionEvery() int { return 0 }
920 func (j *collectorProbeJob) RetryAutoDetection() bool {
921 return false
922 }
923
924 type blockingSecretStoreService struct {
925 sawDeadline bool
926 }
927
928 func (s *blockingSecretStoreService) Capture() *secretstore.Snapshot { return nil }
929
930 func (s *blockingSecretStoreService) Resolve(ctx context.Context, _ *secretstore.Snapshot, _, _ string) (string, error) {
931 _, s.sawDeadline = ctx.Deadline()
932 if !s.sawDeadline {
933 return "", errors.New("missing request deadline")
934 }
935 <-ctx.Done()
936 return "", ctx.Err()
937 }
938
939 func (*blockingSecretStoreService) Kinds() []secretstore.StoreKind { return nil }
940
941 func (*blockingSecretStoreService) DisplayName(secretstore.StoreKind) (string, bool) {
942 return "", false
943 }
944
945 func (*blockingSecretStoreService) Schema(secretstore.StoreKind) (string, bool) { return "", false }
946
947 func (*blockingSecretStoreService) New(secretstore.StoreKind) (secretstore.Store, bool) {
948 return nil, false
949 }
950
951 func (*blockingSecretStoreService) GetStatus(string) (secretstore.StoreStatus, bool) {
952 return secretstore.StoreStatus{}, false
953 }
954
955 func (*blockingSecretStoreService) Validate(context.Context, secretstore.Config) error { return nil }
956
957 func (*blockingSecretStoreService) ValidateStored(context.Context, string) error { return nil }
958
959 func (*blockingSecretStoreService) Add(context.Context, secretstore.Config) error { return nil }
960
961 func (*blockingSecretStoreService) Update(context.Context, string, secretstore.Config) error {
962 return nil
963 }
964
965 func (*blockingSecretStoreService) Remove(string) error { return nil }
966 func (j *collectorProbeJob) Cleanup() {}
967 func (j *collectorProbeJob) IsRunning() bool { return true }
968 func (j *collectorProbeJob) Panicked() bool { return false }
969 func (j *collectorProbeJob) Vnode() vnodes.VirtualNode { return vnodes.VirtualNode{} }
970 func (j *collectorProbeJob) UpdateVnode(*vnodes.VirtualNode) {}
971
972 type auditAnalyzerSpy struct {
973 registered []string
974 }
975
976 func (a *auditAnalyzerSpy) RegisterJob(jobName, moduleName, dir string) {
977 a.registered = append(a.registered, moduleName+":"+jobName+":"+dir)
978 }
979
980 func (*auditAnalyzerSpy) RecordJobStructure(string, string, *collectorapi.Charts) {}
981 func (*auditAnalyzerSpy) UpdateJobStructure(string, string, *collectorapi.Charts) {}
982 func (*auditAnalyzerSpy) RecordCollection(string, string, map[string]int64) {}