master
go 367 lines 13.4 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package secretsctl
4
5 import (
6 "bytes"
7 "context"
8 "encoding/json"
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/agent/secrets/secretstore"
16 "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
17 "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
18 "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
19 "github.com/stretchr/testify/assert"
20 "github.com/stretchr/testify/require"
21 )
22
23 func TestControllerSeqExec(t *testing.T) {
24 tests := map[string]struct {
25 run func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams)
26 }{
27 "schema dispatch": {
28 run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
29 fn := dyncfg.NewFunction(functions.Function{
30 UID: "ss-schema",
31 Args: []string{ctl.templateID(secretstore.KindVault), string(dyncfg.CommandSchema)},
32 })
33 ctl.SeqExec(fn)
34
35 var payload any
36 mustDecodeFunctionPayload(t, out.String(), "ss-schema", &payload)
37 assert.NotNil(t, payload)
38 assert.Empty(t, seams.affectedJobsCalls)
39 assert.Empty(t, seams.restartCalls)
40 },
41 },
42 "test preview uses affected jobs seam": {
43 run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
44 addFn := dyncfg.NewFunction(functions.Function{
45 UID: "ss-add",
46 ContentType: "application/json",
47 Payload: mustJSON(t, map[string]any{"value": "one"}),
48 Args: []string{ctl.templateID(secretstore.KindVault), string(dyncfg.CommandAdd), "vault_prod"},
49 })
50 ctl.SeqExec(addFn)
51
52 key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
53 seams.affectedJobs[key] = []secretstore.JobRef{{ID: "mysql:prod", Display: "mysql:prod"}}
54 seams.restartableJobs[key] = []secretstore.JobRef{{ID: "mysql:prod", Display: "mysql:prod"}}
55
56 testFn := dyncfg.NewFunction(functions.Function{
57 UID: "ss-test",
58 Args: []string{ctl.configID(key), string(dyncfg.CommandTest)},
59 })
60 ctl.SeqExec(testFn)
61
62 var payload map[string]any
63 mustDecodeFunctionPayload(t, out.String(), "ss-test", &payload)
64 assert.Equal(t, float64(202), payload["status"])
65 assert.Contains(t, payload["message"], "This secretstore is used by jobs: mysql:prod.")
66 assert.Contains(t, payload["message"], "Running or failed jobs that would be restarted automatically by a change: mysql:prod.")
67 assert.Equal(t, []string{key}, seams.affectedJobsCalls)
68 assert.Equal(t, []string{key}, seams.restartableJobsCalls)
69 },
70 },
71 "remove blocks when dependent jobs exist": {
72 run: func(t *testing.T, ctl *Controller, out *bytes.Buffer, seams *controllerSeams) {
73 addFn := dyncfg.NewFunction(functions.Function{
74 UID: "ss-add-remove-blocked",
75 ContentType: "application/json",
76 Payload: mustJSON(t, map[string]any{"value": "one"}),
77 Args: []string{ctl.templateID(secretstore.KindVault), string(dyncfg.CommandAdd), "vault_prod"},
78 })
79 ctl.SeqExec(addFn)
80
81 key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
82 seams.affectedJobs[key] = []secretstore.JobRef{{ID: "mysql:prod", Display: "mysql:prod"}, {ID: "nginx:prod", Display: "nginx:prod"}}
83
84 removeFn := dyncfg.NewFunction(functions.Function{
85 UID: "ss-remove-blocked",
86 Args: []string{ctl.configID(key), string(dyncfg.CommandRemove)},
87 })
88 ctl.SeqExec(removeFn)
89
90 var payload map[string]any
91 mustDecodeFunctionPayload(t, out.String(), "ss-remove-blocked", &payload)
92 assert.Equal(t, float64(409), payload["status"])
93 assert.Equal(t, "The specified secretstore 'vault:vault_prod' is used by jobs (mysql:prod, nginx:prod).", payload["errorMessage"])
94 _, ok := ctl.Lookup(key)
95 assert.True(t, ok)
96 },
97 },
98 }
99
100 for name, tc := range tests {
101 t.Run(name, func(t *testing.T) {
102 ctl, out, seams := newControllerTestSubject()
103 tc.run(t, ctl, out, seams)
104 })
105 }
106 }
107
108 func TestControllerSetAPI_NilPreservesResponder(t *testing.T) {
109 tests := map[string]struct {
110 uid string
111 }{
112 "nil SetAPI keeps existing responder for schema responses": {
113 uid: "ss-schema-nil-rebind",
114 },
115 }
116
117 for name, tc := range tests {
118 t.Run(name, func(t *testing.T) {
119 ctl, out, _ := newControllerTestSubject()
120 ctl.SetAPI(nil)
121
122 fn := dyncfg.NewFunction(functions.Function{
123 UID: tc.uid,
124 Args: []string{ctl.templateID(secretstore.KindVault), string(dyncfg.CommandSchema)},
125 })
126 ctl.SeqExec(fn)
127
128 var payload any
129 mustDecodeFunctionPayload(t, out.String(), tc.uid, &payload)
130 assert.NotNil(t, payload)
131 })
132 }
133 }
134
135 func TestControllerSeqExec_TestStoredWithoutService_Returns400(t *testing.T) {
136 tests := map[string]struct {
137 storeKey string
138 }{
139 "stored validation without service returns controlled 400": {
140 storeKey: secretstore.StoreKey(secretstore.KindVault, "vault_prod"),
141 },
142 }
143
144 for name, tc := range tests {
145 t.Run(name, func(t *testing.T) {
146 var out bytes.Buffer
147 ctl := New(Options{
148 Logger: logger.New(),
149 API: dyncfg.NewResponder(netdataapi.New(safewriter.New(&out))),
150 Plugin: testPluginName,
151 })
152
153 cfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "one"}, confgroup.TypeDyncfg, confgroup.TypeDyncfg)
154 ctl.exposed.Add(&dyncfg.Entry[secretstore.Config]{
155 Cfg: cfg,
156 Status: dyncfg.StatusRunning,
157 })
158
159 fn := dyncfg.NewFunction(functions.Function{
160 UID: "ss-test-nil-service",
161 Args: []string{ctl.configID(tc.storeKey), string(dyncfg.CommandTest)},
162 })
163
164 ctl.SeqExec(fn)
165
166 var payload map[string]any
167 mustDecodeFunctionPayload(t, out.String(), "ss-test-nil-service", &payload)
168 assert.Equal(t, float64(400), payload["status"])
169 assert.Contains(t, payload["errorMessage"], "secretstore service is not available")
170 })
171 }
172 }
173
174 func TestRememberDiscoveredConfig_DrainsRestartFailureMessageFromNonHandlerStop(t *testing.T) {
175 ctl, _, seams := newControllerTestSubject()
176 existing := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "one"}, "/etc/netdata/secretstores.yaml", confgroup.TypeUser)
177 require.NoError(t, ctl.Service().Add(context.Background(), existing))
178 ctl.seen.Add(existing)
179 ctl.exposed.Add(&dyncfg.Entry[secretstore.Config]{
180 Cfg: existing,
181 Status: dyncfg.StatusRunning,
182 })
183
184 key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
185 seams.restartMessages[key] = "restart failed"
186
187 replacement := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "two"}, confgroup.TypeDyncfg, confgroup.TypeDyncfg)
188 entry, changed, err := ctl.RememberDiscoveredConfig(replacement)
189 require.NoError(t, err)
190 require.True(t, changed)
191 assert.Equal(t, dyncfg.StatusAccepted, entry.Status)
192 assert.Equal(t, []string{key}, seams.restartCalls)
193 _, ok := ctl.Service().GetStatus(key)
194 assert.False(t, ok)
195 assert.Equal(t, "", ctl.cb.TakeCommandMessage())
196 }
197
198 func TestControllerPublishExisting(t *testing.T) {
199 t.Run("initial valid config publishes running without enable disable commands", func(t *testing.T) {
200 cfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "one"}, "file=/etc/netdata/go.d/ss/vault.conf", confgroup.TypeUser)
201
202 var out bytes.Buffer
203 ctl := New(Options{
204 Logger: logger.New(),
205 API: dyncfg.NewResponder(netdataapi.New(safewriter.New(&out))),
206 Plugin: testPluginName,
207 Service: newTestSecretStoreService(),
208 Initial: []secretstore.Config{cfg},
209 })
210
211 ctl.PublishExisting()
212
213 entry, ok := ctl.Lookup(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
214 require.True(t, ok)
215 assert.Equal(t, dyncfg.StatusRunning, entry.Status)
216 _, ok = ctl.Service().GetStatus(entry.Cfg.ExposedKey())
217 assert.True(t, ok)
218 assert.Contains(t, out.String(), "schema get update test")
219 assert.NotContains(t, out.String(), "enable")
220 assert.NotContains(t, out.String(), "disable")
221 })
222
223 t.Run("keyable startup failure publishes failed", func(t *testing.T) {
224 cfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{}, "file=/etc/netdata/go.d/ss/vault.conf", confgroup.TypeUser)
225
226 ctl, _, _ := newControllerTestSubjectWithOptions(Options{
227 Initial: []secretstore.Config{cfg},
228 })
229 ctl.PublishExisting()
230
231 entry, ok := ctl.Lookup(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
232 require.True(t, ok)
233 assert.Equal(t, dyncfg.StatusFailed, entry.Status)
234 _, ok = ctl.Service().GetStatus(entry.Cfg.ExposedKey())
235 assert.False(t, ok)
236 })
237
238 t.Run("failed higher priority startup config shadows lower priority running config", func(t *testing.T) {
239 stockCfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "stock"}, "file=/usr/lib/netdata/conf.d/go.d/ss/vault.conf", confgroup.TypeStock)
240 userCfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{}, "file=/etc/netdata/go.d/ss/vault.conf", confgroup.TypeUser)
241
242 ctl, _, _ := newControllerTestSubjectWithOptions(Options{
243 Initial: []secretstore.Config{stockCfg, userCfg},
244 })
245 ctl.PublishExisting()
246
247 entry, ok := ctl.Lookup(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
248 require.True(t, ok)
249 assert.Equal(t, dyncfg.StatusFailed, entry.Status)
250 assert.Equal(t, confgroup.TypeUser, entry.Cfg.SourceType())
251 assert.Equal(t, "file=/etc/netdata/go.d/ss/vault.conf", entry.Cfg.Source())
252 _, ok = ctl.Service().GetStatus(entry.Cfg.ExposedKey())
253 assert.False(t, ok)
254 })
255
256 t.Run("later equal priority valid config replaces earlier failed config", func(t *testing.T) {
257 failedCfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{}, "file=/etc/netdata/go.d/ss/01-vault.conf", confgroup.TypeUser)
258 validCfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "good"}, "file=/etc/netdata/go.d/ss/02-vault.conf", confgroup.TypeUser)
259
260 ctl, _, _ := newControllerTestSubjectWithOptions(Options{
261 Initial: []secretstore.Config{failedCfg, validCfg},
262 })
263 ctl.PublishExisting()
264
265 entry, ok := ctl.Lookup(secretstore.StoreKey(secretstore.KindVault, "vault_prod"))
266 require.True(t, ok)
267 assert.Equal(t, dyncfg.StatusRunning, entry.Status)
268 assert.Equal(t, "file=/etc/netdata/go.d/ss/02-vault.conf", entry.Cfg.Source())
269 _, ok = ctl.Service().GetStatus(entry.Cfg.ExposedKey())
270 assert.True(t, ok)
271 })
272 }
273
274 type controllerSeams struct {
275 affectedJobs map[string][]secretstore.JobRef
276 affectedJobsCalls []string
277 restartableJobs map[string][]secretstore.JobRef
278 restartableJobsCalls []string
279 restartMessages map[string]string
280 restartCalls []string
281 }
282
283 func newControllerTestSubject() (*Controller, *bytes.Buffer, *controllerSeams) {
284 return newControllerTestSubjectWithOptions(Options{})
285 }
286
287 func newControllerTestSubjectWithOptions(opts Options) (*Controller, *bytes.Buffer, *controllerSeams) {
288 var out bytes.Buffer
289 seams := &controllerSeams{
290 affectedJobs: make(map[string][]secretstore.JobRef),
291 restartableJobs: make(map[string][]secretstore.JobRef),
292 restartMessages: make(map[string]string),
293 }
294 svc := opts.Service
295 if svc == nil {
296 svc = newTestSecretStoreService()
297 }
298 log := opts.Logger
299 if log == nil {
300 log = logger.New()
301 }
302 api := opts.API
303 if api == nil {
304 api = dyncfg.NewResponder(netdataapi.New(safewriter.New(&out)))
305 }
306 plugin := opts.Plugin
307 if plugin == "" {
308 plugin = testPluginName
309 }
310 affectedJobs := opts.AffectedJobs
311 if affectedJobs == nil {
312 affectedJobs = func(storeKey string) []secretstore.JobRef {
313 seams.affectedJobsCalls = append(seams.affectedJobsCalls, storeKey)
314 return seams.affectedJobs[storeKey]
315 }
316 }
317 restartableJobs := opts.RestartableAffectedJobs
318 if restartableJobs == nil {
319 restartableJobs = func(storeKey string) []secretstore.JobRef {
320 seams.restartableJobsCalls = append(seams.restartableJobsCalls, storeKey)
321 return seams.restartableJobs[storeKey]
322 }
323 }
324 restartDependentJobs := opts.RestartDependentJobs
325 if restartDependentJobs == nil {
326 restartDependentJobs = func(storeKey string) string {
327 seams.restartCalls = append(seams.restartCalls, storeKey)
328 return seams.restartMessages[storeKey]
329 }
330 }
331 ctl := New(Options{
332 Logger: log,
333 API: api,
334 Plugin: plugin,
335 Service: svc,
336 AffectedJobs: affectedJobs,
337 RestartableAffectedJobs: restartableJobs,
338 RestartDependentJobs: restartDependentJobs,
339 Initial: opts.Initial,
340 Seen: opts.Seen,
341 Exposed: opts.Exposed,
342 })
343 return ctl, &out, seams
344 }
345
346 func newSecretStoreConfigWithSource(t *testing.T, kind secretstore.StoreKind, name string, cfg map[string]any, source, sourceType string) secretstore.Config {
347 t.Helper()
348 bs, err := json.Marshal(cfg)
349 require.NoError(t, err)
350 var payload map[string]any
351 require.NoError(t, json.Unmarshal(bs, &payload))
352 out := secretstore.Config(payload)
353 out.SetName(name)
354 out.SetKind(kind)
355 out.SetSource(source)
356 out.SetSourceType(sourceType)
357 return out
358 }
359
360 func mustDecodeFunctionPayload(t *testing.T, output, uid string, dst any) {
361 t.Helper()
362
363 re := regexp.MustCompile(`(?s)FUNCTION_RESULT_BEGIN ` + regexp.QuoteMeta(uid) + ` [^\n]+\n(.*?)\nFUNCTION_RESULT_END`)
364 match := re.FindStringSubmatch(output)
365 require.Len(t, match, 2, "function result for uid '%s' not found in output:\n%s", uid, output)
366 require.NoError(t, json.Unmarshal([]byte(match[1]), dst))
367 }