master
go 476 lines 18 KB
Raw
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 }