master
go 640 lines 19.9 KB
Raw
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 range 100 {
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 range 100 {
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 "build required params agent-wide omits __job": {},
288 }
289
290 for caseName := range cases {
291 t.Run(caseName, func(t *testing.T) {
292 switch caseName {
293 case "build accepted params":
294 sortDir := funcapi.FieldSortDescending
295 methodParams := []funcapi.ParamConfig{
296 {ID: "__sort", Selection: funcapi.ParamSelect, Options: []funcapi.ParamOption{{ID: "calls", Name: "Calls", Sort: &sortDir}}},
297 {ID: "db"},
298 {ID: "extra"},
299 }
300
301 assert.Equal(t, []string{"__job", "__sort", "db", "extra"}, buildAcceptedParams(methodParams, true))
302 assert.Equal(t, []string{"__sort", "db", "extra"}, buildAcceptedParams(methodParams, false))
303
304 case "build required params uses select type":
305 controller := New(Options{})
306 controller.RegisterModules(collectorapi.Registry{
307 "postgres": collectorapi.Creator{
308 Methods: func() []funcapi.MethodConfig {
309 return []funcapi.MethodConfig{{ID: "top-queries", Name: "Top Queries"}}
310 },
311 },
312 })
313 controller.registry.addJob("postgres", "master-db", newTestRuntimeJob("postgres", "master-db", true))
314
315 methodParams := []funcapi.ParamConfig{{
316 ID: "__sort",
317 Name: "Filter By",
318 Selection: funcapi.ParamSelect,
319 UniqueView: true,
320 Options: []funcapi.ParamOption{
321 {ID: "total_time", Name: "By Total Time", Default: true},
322 },
323 }}
324 params := controller.buildRequiredParams("postgres", methodParams, true)
325
326 assert.Len(t, params, 2)
327 for _, param := range params {
328 paramType, ok := param["type"]
329 assert.True(t, ok)
330 assert.Equal(t, "select", paramType)
331 assert.Contains(t, param, "id")
332 assert.Contains(t, param, "name")
333 assert.Contains(t, param, "options")
334 assert.Contains(t, param, "unique_view")
335 uniqueView, _ := param["unique_view"].(bool)
336 assert.True(t, uniqueView)
337 }
338
339 assert.Equal(t, "__job", params[0]["id"])
340 assert.Equal(t, "__sort", params[1]["id"])
341
342 case "build required params agent-wide omits __job":
343 controller := New(Options{})
344 controller.RegisterModules(collectorapi.Registry{
345 "snmp": collectorapi.Creator{
346 Methods: func() []funcapi.MethodConfig {
347 return []funcapi.MethodConfig{{ID: "topology:snmp", AgentWide: true}}
348 },
349 },
350 })
351 controller.registry.addJob("snmp", "router", newTestRuntimeJob("snmp", "router", true))
352
353 methodParams := []funcapi.ParamConfig{{
354 ID: "topology_view",
355 Name: "Topology View",
356 Selection: funcapi.ParamSelect,
357 Options: []funcapi.ParamOption{
358 {ID: "l2", Name: "L2", Default: true},
359 },
360 }}
361 params := controller.buildRequiredParams("snmp", methodParams, false)
362
363 assert.Len(t, params, 1)
364 assert.Equal(t, "topology_view", params[0]["id"])
365 }
366 })
367 }
368 }
369 })
370 }
371 }
372
373 func TestParseArgsParams(t *testing.T) {
374 args := []string{
375 "__job:snmp-a",
376 "view=detailed",
377 "labels:src_ip,dst_ip",
378 "info",
379 "invalid",
380 "empty:",
381 "=novalue",
382 }
383
384 got := parseArgsParams(args)
385
386 assert.Equal(t, []string{"snmp-a"}, got["__job"])
387 assert.Equal(t, []string{"detailed"}, got["view"])
388 assert.Equal(t, []string{"src_ip", "dst_ip"}, got["labels"])
389 assert.NotContains(t, got, "invalid")
390 assert.NotContains(t, got, "empty")
391 }
392
393 func TestControllerLifecycleHooks(t *testing.T) {
394 tests := map[string]struct{}{
395 "register modules does not register static methods yet": {},
396 "first job start registers static methods once": {},
397 "topology methods register direct alias": {},
398 "job stop unregisters job methods": {},
399 "cleanup unregisters static methods": {},
400 "cleanup with api configured still unregisters static methods": {},
401 }
402
403 for name := range tests {
404 t.Run(name, func(t *testing.T) {
405 reg := newTestFunctionRegistry()
406 controller := New(Options{FnReg: reg})
407
408 switch name {
409 case "register modules does not register static methods yet":
410 controller.RegisterModules(collectorapi.Registry{
411 "mod": collectorapi.Creator{
412 Methods: func() []funcapi.MethodConfig { return []funcapi.MethodConfig{{ID: "a"}} },
413 },
414 })
415
416 assert.Empty(t, reg.registeredNames())
417
418 case "first job start registers static methods once":
419 controller.RegisterModules(collectorapi.Registry{
420 "mod": collectorapi.Creator{
421 Methods: func() []funcapi.MethodConfig { return []funcapi.MethodConfig{{ID: "a"}} },
422 },
423 })
424
425 controller.OnJobStart(newTestRuntimeJob("mod", "job1", true))
426 controller.OnJobStart(newTestRuntimeJob("mod", "job2", true))
427
428 assert.Equal(t, []string{"mod:a"}, reg.registeredNames())
429
430 case "topology methods register direct alias":
431 controller.RegisterModules(collectorapi.Registry{
432 "snmp": collectorapi.Creator{
433 Methods: func() []funcapi.MethodConfig {
434 return []funcapi.MethodConfig{{ID: "topology:snmp", Aliases: []string{"topology:snmp"}}}
435 },
436 },
437 })
438
439 controller.OnJobStart(newTestRuntimeJob("snmp", "edge-router", true))
440
441 assert.ElementsMatch(t, []string{"snmp:topology:snmp", "topology:snmp"}, reg.registeredNames())
442
443 controller.Cleanup()
444
445 assert.ElementsMatch(t, []string{"snmp:topology:snmp", "topology:snmp"}, reg.unregisteredNames())
446
447 case "job stop unregisters job methods":
448 controller.RegisterModules(collectorapi.Registry{
449 "mod": collectorapi.Creator{
450 JobMethods: func(_ collectorapi.RuntimeJob) []funcapi.MethodConfig {
451 return []funcapi.MethodConfig{{ID: "job-method"}}
452 },
453 },
454 })
455
456 job := newTestRuntimeJob("mod", "job1", true)
457 controller.OnJobStart(job)
458 controller.OnJobStop(job)
459
460 assert.Contains(t, reg.unregisteredNames(), "mod:job-method")
461
462 case "cleanup unregisters static methods":
463 controller.RegisterModules(collectorapi.Registry{
464 "mod": collectorapi.Creator{
465 Methods: func() []funcapi.MethodConfig { return []funcapi.MethodConfig{{ID: "a"}} },
466 },
467 })
468
469 controller.OnJobStart(newTestRuntimeJob("mod", "job1", true))
470 controller.Cleanup()
471
472 assert.Contains(t, reg.unregisteredNames(), "mod:a")
473
474 case "cleanup with api configured still unregisters static methods":
475 var buf bytes.Buffer
476 controller = New(Options{
477 FnReg: reg,
478 API: dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))),
479 })
480 controller.RegisterModules(collectorapi.Registry{
481 "mod": collectorapi.Creator{
482 Methods: func() []funcapi.MethodConfig { return []funcapi.MethodConfig{{ID: "a"}} },
483 },
484 })
485
486 controller.OnJobStart(newTestRuntimeJob("mod", "job1", true))
487 assert.Contains(t, buf.String(), "FUNCTION GLOBAL \"mod:a\"")
488
489 controller.Cleanup()
490
491 assert.Contains(t, reg.unregisteredNames(), "mod:a")
492 }
493 })
494 }
495 }
496
497 func TestControllerRegisterJobMethods(t *testing.T) {
498 tests := map[string]struct{}{
499 "fail fast on collision with static method": {},
500 "fail fast on collision with other job": {},
501 "fail fast on duplicate within batch": {},
502 "registry is populated before handlers are callable": {},
503 "success commits all methods": {},
504 }
505
506 for name := range tests {
507 t.Run(name, func(t *testing.T) {
508 reg := newTestFunctionRegistry()
509 controller := New(Options{FnReg: reg})
510
511 switch name {
512 case "fail fast on collision with static method":
513 controller.RegisterModules(collectorapi.Registry{
514 "mod": collectorapi.Creator{
515 Methods: func() []funcapi.MethodConfig { return []funcapi.MethodConfig{{ID: "dup"}} },
516 },
517 })
518
519 controller.registerJobMethods(newTestRuntimeJob("mod", "job1", true), []funcapi.MethodConfig{{ID: "dup"}})
520
521 assert.Empty(t, reg.registeredNames())
522 assert.Empty(t, controller.registry.getJobMethods("mod", "job1"))
523
524 case "fail fast on collision with other job":
525 controller.registry.registerModule("mod", collectorapi.Creator{})
526 controller.registry.registerJobMethods("mod", "jobA", []funcapi.MethodConfig{{ID: "dup"}})
527
528 controller.registerJobMethods(newTestRuntimeJob("mod", "jobB", true), []funcapi.MethodConfig{{ID: "dup"}})
529
530 assert.Empty(t, reg.registeredNames())
531 assert.Empty(t, controller.registry.getJobMethods("mod", "jobB"))
532
533 case "fail fast on duplicate within batch":
534 controller.registry.registerModule("mod", collectorapi.Creator{})
535
536 controller.registerJobMethods(newTestRuntimeJob("mod", "job1", true), []funcapi.MethodConfig{{ID: "dup"}, {ID: "dup"}})
537
538 assert.Empty(t, reg.registeredNames())
539 assert.Empty(t, controller.registry.getJobMethods("mod", "job1"))
540
541 case "registry is populated before handlers are callable":
542 var gotCode int
543 var gotResp map[string]any
544
545 reg.onRegister = func(_ string, fn func(functions.Function)) {
546 fn(functions.Function{
547 UID: "during-register",
548 Args: []string{"info"},
549 })
550 }
551 controller = New(Options{
552 FnReg: reg,
553 JSONWriter: func(data []byte, code int) {
554 gotCode = code
555 require.NoError(t, json.Unmarshal(data, &gotResp))
556 },
557 })
558 controller.registry.registerModule("mod", collectorapi.Creator{})
559
560 controller.registerJobMethods(newTestRuntimeJob("mod", "job1", true), []funcapi.MethodConfig{{ID: "a", Help: "job method help"}})
561
562 assert.Equal(t, 200, gotCode)
563 assert.Equal(t, float64(200), gotResp["status"])
564 assert.Equal(t, "job method help", gotResp["help"])
565 assert.Len(t, controller.registry.getJobMethods("mod", "job1"), 1)
566
567 case "success commits all methods":
568 controller.registry.registerModule("mod", collectorapi.Creator{})
569
570 controller.registerJobMethods(newTestRuntimeJob("mod", "job1", true), []funcapi.MethodConfig{{ID: "a"}, {ID: "b"}})
571
572 assert.ElementsMatch(t, []string{"mod:a", "mod:b"}, reg.registeredNames())
573 assert.Len(t, controller.registry.getJobMethods("mod", "job1"), 2)
574 }
575 })
576 }
577 }
578
579 type testRuntimeJob struct {
580 fullName string
581 moduleName string
582 name string
583 running bool
584 }
585
586 func newTestRuntimeJob(moduleName, name string, running bool) *testRuntimeJob {
587 return &testRuntimeJob{
588 fullName: moduleName + "_" + name,
589 moduleName: moduleName,
590 name: name,
591 running: running,
592 }
593 }
594
595 func (j *testRuntimeJob) FullName() string { return j.fullName }
596 func (j *testRuntimeJob) ModuleName() string { return j.moduleName }
597 func (j *testRuntimeJob) Name() string { return j.name }
598 func (j *testRuntimeJob) IsRunning() bool { return j.running }
599 func (j *testRuntimeJob) Collector() any { return nil }
600
601 type testFunctionRegistry struct {
602 handlers map[string]func(functions.Function)
603 registered []string
604 unregistered []string
605 onRegister func(string, func(functions.Function))
606 }
607
608 func newTestFunctionRegistry() *testFunctionRegistry {
609 return &testFunctionRegistry{
610 handlers: make(map[string]func(functions.Function)),
611 }
612 }
613
614 func (r *testFunctionRegistry) Register(name string, fn func(functions.Function)) {
615 r.handlers[name] = fn
616 r.registered = append(r.registered, name)
617 if r.onRegister != nil {
618 r.onRegister(name, fn)
619 }
620 }
621
622 func (r *testFunctionRegistry) Unregister(name string) {
623 r.unregistered = append(r.unregistered, name)
624 delete(r.handlers, name)
625 }
626
627 func (r *testFunctionRegistry) RegisterPrefix(string, string, func(functions.Function)) {}
628 func (r *testFunctionRegistry) UnregisterPrefix(string, string) {}
629
630 func (r *testFunctionRegistry) registeredNames() []string {
631 out := make([]string, len(r.registered))
632 copy(out, r.registered)
633 return out
634 }
635
636 func (r *testFunctionRegistry) unregisteredNames() []string {
637 out := make([]string, len(r.unregistered))
638 copy(out, r.unregistered)
639 return out
640 }