master
go 331 lines 11.3 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package jobmgr
4
5 import (
6 "bytes"
7 "context"
8 "fmt"
9 "testing"
10 "time"
11
12 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
13 "github.com/netdata/netdata/go/plugins/pkg/safewriter"
14 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/resolver"
15 "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore"
16 "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
17 "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
18 "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
19 "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
20 "github.com/stretchr/testify/assert"
21 "github.com/stretchr/testify/require"
22 )
23
24 type TestStoreConfig struct {
25 Value string `yaml:"value" json:"value"`
26 }
27
28 type testStore struct {
29 TestStoreConfig `yaml:",inline" json:""`
30 }
31
32 func (s *testStore) Configuration() any { return &s.TestStoreConfig }
33
34 func (s *testStore) Init(context.Context) error {
35 if s.TestStoreConfig.Value == "" {
36 return fmt.Errorf("value is required")
37 }
38 return nil
39 }
40
41 func (s *testStore) Publish() secretstore.PublishedStore {
42 return &testPublishedStore{value: s.TestStoreConfig.Value}
43 }
44
45 type testPublishedStore struct {
46 value string
47 }
48
49 func (s *testPublishedStore) Resolve(_ context.Context, req secretstore.ResolveRequest) (string, error) {
50 if req.Operand != "value" {
51 return "", fmt.Errorf("unexpected operand %q", req.Operand)
52 }
53 return s.value, nil
54 }
55
56 type secretAwareCollector struct {
57 collectorapi.Base
58 Config collectorapi.MockConfiguration `yaml:",inline" json:""`
59 }
60
61 func (c *secretAwareCollector) Configuration() any { return c.Config }
62 func (c *secretAwareCollector) Check(context.Context) error { return nil }
63 func (c *secretAwareCollector) Cleanup(context.Context) {}
64 func (c *secretAwareCollector) Charts() *collectorapi.Charts { return &collectorapi.Charts{} }
65 func (c *secretAwareCollector) Collect(context.Context) map[string]int64 {
66 return map[string]int64{"value": 1}
67 }
68
69 func (c *secretAwareCollector) Init(context.Context) error {
70 if c.Config.OptionStr != "good" {
71 return fmt.Errorf("secret is not usable: %s", c.Config.OptionStr)
72 }
73 return nil
74 }
75
76 func newTestSecretStoreService() secretstore.Service {
77 return secretstore.NewService(secretstore.Creator{
78 Kind: secretstore.KindVault,
79 DisplayName: "Vault",
80 Schema: `{"jsonSchema":{"type":"object","properties":{"value":{"type":"string"}}},"uiSchema":[]}`,
81 Create: func() secretstore.Store {
82 return &testStore{}
83 },
84 })
85 }
86
87 func TestApplyConfig_ResolvesStoreReferenceWithKindAndName(t *testing.T) {
88 svc := newTestSecretStoreService()
89 raw := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "resolved-secret"}, confgroup.TypeDyncfg, confgroup.TypeDyncfg)
90 require.NoError(t, svc.Add(context.Background(), raw))
91
92 cfg := prepareDyncfgCfg("success", "secret-job").
93 Set("option_str", "${store:vault:vault_prod:value}").
94 Set("option_int", 7)
95
96 module := &collectorapi.MockCollectorV1{}
97 err := applyConfig(t.Context(), cfg, module, secretresolver.New(), svc, svc.Capture())
98 require.NoError(t, err)
99
100 assert.Equal(t, "resolved-secret", module.Config.OptionStr)
101 assert.Equal(t, 7, module.Config.OptionInt)
102 }
103
104 func TestRun_StartupLoadedSecretStoreIsAvailableToFirstCollectorStart(t *testing.T) {
105 initial := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{"value": "good"}, "file=/etc/netdata/go.d/ss/vault.conf", confgroup.TypeUser)
106 mgr := New(Config{
107 PluginName: testPluginName,
108 SecretStores: []secretstore.Config{initial},
109 SecretStoreService: newTestSecretStoreService(),
110 })
111 mgr.modules = collectorapi.Registry{
112 "gated": {
113 Create: func() collectorapi.CollectorV1 { return &secretAwareCollector{} },
114 },
115 }
116
117 var out bytes.Buffer
118 mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&out))))
119 mgr.runModePolicy.AutoEnableDiscovered = true
120
121 ctx, cancel := context.WithCancel(context.Background())
122 in := make(chan []*confgroup.Group)
123 done := make(chan struct{})
124 go func() {
125 defer close(done)
126 mgr.Run(ctx, in)
127 }()
128
129 waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second)
130 defer waitCancel()
131 require.True(t, mgr.WaitStarted(waitCtx), "manager did not report started")
132
133 key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
134 entry, ok := mgr.lookupSecretStoreEntry(key)
135 require.True(t, ok)
136 assert.Equal(t, dyncfg.StatusRunning, entry.Status)
137 _, ok = mustSecretStoreService(t, mgr).GetStatus(key)
138 assert.True(t, ok)
139
140 cfg := prepareDyncfgCfg("gated", "startup").
141 Set("option_str", "${store:vault:vault_prod:value}").
142 Set("option_int", 1)
143 mgr.addConfig(cfg)
144
145 jobEntry, ok := mgr.lookupExposedByFullName(cfg.FullName())
146 require.True(t, ok)
147 assert.Equal(t, dyncfg.StatusRunning, jobEntry.Status)
148 require.Len(t, mgr.runningJobs.snapshot(), 1)
149 assert.Equal(t, cfg.FullName(), mgr.runningJobs.snapshot()[0].FullName())
150
151 cancel()
152 close(in)
153
154 select {
155 case <-done:
156 case <-time.After(2 * time.Second):
157 t.Fatal("manager did not stop after cancel")
158 }
159 }
160
161 func TestDyncfgSecretStoreUpdate_DependentRestartBehavior(t *testing.T) {
162 tests := map[string]struct {
163 run func(t *testing.T, mgr *Manager, out *bytes.Buffer)
164 }{
165 "restarts failed dependent after store is fixed": {
166 run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
167 mgr.modules["gated"] = collectorapi.Creator{
168 Create: func() collectorapi.CollectorV1 { return &secretAwareCollector{} },
169 }
170
171 key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
172 seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", map[string]any{"value": "good"}, dyncfg.StatusRunning)
173
174 cfg := prepareDyncfgCfg("gated", "mysql").
175 Set("option_str", "${store:vault:vault_prod:value}").
176 Set("option_int", 1)
177 mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
178 Cfg: cfg,
179 Status: dyncfg.StatusRunning,
180 })
181 mgr.syncSecretStoreDepsForConfig(cfg)
182 require.NoError(t, mgr.collectorCallbacks.Start(cfg))
183
184 _, running := mgr.secretStoreDeps.Impacted(key)
185 require.Len(t, running, 1)
186 assert.Equal(t, cfg.FullName(), running[0].ID)
187
188 badFn := dyncfg.NewFunction(functions.Function{
189 UID: "ss-update-bad",
190 ContentType: "application/json",
191 Payload: mustJSON(t, map[string]any{"value": "bad"}),
192 Args: []string{
193 mgr.dyncfgSecretStoreID(key),
194 string(dyncfg.CommandUpdate),
195 },
196 })
197 mgr.dyncfgSecretStoreSeqExec(badFn)
198
199 var badResp map[string]any
200 mustDecodeFunctionPayload(t, out.String(), "ss-update-bad", &badResp)
201 assert.Equal(t, float64(200), badResp["status"])
202 assert.Contains(t, badResp["message"], "Secretstore change applied, but dependent collector restarts failed")
203 assert.Contains(t, badResp["message"], "gated:mysql")
204
205 entry, ok := mgr.lookupExposedByFullName(cfg.FullName())
206 require.True(t, ok)
207 assert.Equal(t, dyncfg.StatusFailed, entry.Status)
208
209 _, running = mgr.secretStoreDeps.Impacted(key)
210 assert.Empty(t, running)
211
212 goodFn := dyncfg.NewFunction(functions.Function{
213 UID: "ss-update-good",
214 ContentType: "application/json",
215 Payload: mustJSON(t, map[string]any{"value": "good"}),
216 Args: []string{
217 mgr.dyncfgSecretStoreID(key),
218 string(dyncfg.CommandUpdate),
219 },
220 })
221 mgr.dyncfgSecretStoreSeqExec(goodFn)
222
223 var goodResp map[string]any
224 mustDecodeFunctionPayload(t, out.String(), "ss-update-good", &goodResp)
225 assert.Equal(t, float64(200), goodResp["status"])
226 assert.Equal(t, "", goodResp["message"])
227
228 entry, ok = mgr.lookupExposedByFullName(cfg.FullName())
229 require.True(t, ok)
230 assert.Equal(t, dyncfg.StatusRunning, entry.Status)
231
232 _, running = mgr.secretStoreDeps.Impacted(key)
233 require.Len(t, running, 1)
234 assert.Equal(t, cfg.FullName(), running[0].ID)
235 },
236 },
237 "ignores accepted and disabled dependents": {
238 run: func(t *testing.T, mgr *Manager, out *bytes.Buffer) {
239 key := secretstore.StoreKey(secretstore.KindVault, "vault_prod")
240 seedSecretStore(t, mgr, secretstore.KindVault, "vault_prod", map[string]any{"value": "good"}, dyncfg.StatusRunning)
241
242 acceptedCfg := prepareDyncfgCfg("success", "accepted")
243 mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
244 Cfg: acceptedCfg,
245 Status: dyncfg.StatusAccepted,
246 })
247 mgr.secretStoreDeps.SetActiveJobStores(acceptedCfg.FullName(), "success:accepted", []string{key})
248
249 disabledCfg := prepareDyncfgCfg("success", "disabled")
250 mgr.collectorExposed.Add(&dyncfg.Entry[confgroup.Config]{
251 Cfg: disabledCfg,
252 Status: dyncfg.StatusDisabled,
253 })
254 mgr.secretStoreDeps.SetActiveJobStores(disabledCfg.FullName(), "success:disabled", []string{key})
255
256 updateFn := dyncfg.NewFunction(functions.Function{
257 UID: "ss-update-ignored",
258 ContentType: "application/json",
259 Payload: mustJSON(t, map[string]any{"value": "better"}),
260 Args: []string{
261 mgr.dyncfgSecretStoreID(key),
262 string(dyncfg.CommandUpdate),
263 },
264 })
265 mgr.dyncfgSecretStoreSeqExec(updateFn)
266
267 var resp map[string]any
268 mustDecodeFunctionPayload(t, out.String(), "ss-update-ignored", &resp)
269 assert.Equal(t, float64(200), resp["status"])
270 assert.Equal(t, "", resp["message"])
271
272 acceptedEntry, ok := mgr.lookupExposedByFullName(acceptedCfg.FullName())
273 require.True(t, ok)
274 assert.Equal(t, dyncfg.StatusAccepted, acceptedEntry.Status)
275
276 disabledEntry, ok := mgr.lookupExposedByFullName(disabledCfg.FullName())
277 require.True(t, ok)
278 assert.Equal(t, dyncfg.StatusDisabled, disabledEntry.Status)
279
280 _, running := mgr.secretStoreDeps.Impacted(key)
281 assert.Empty(t, running)
282 },
283 },
284 }
285
286 for name, tc := range tests {
287 t.Run(name, func(t *testing.T) {
288 mgr, out := newDyncfgSecretStoreTestManagerWithService(newTestSecretStoreService())
289 tc.run(t, mgr, out)
290 })
291 }
292 }
293
294 func TestDyncfgSecretStoreGet_CanonicalJSONDoesNotExposeUnknownFields(t *testing.T) {
295 mgr, out := newDyncfgSecretStoreTestManagerWithService(newTestSecretStoreService())
296
297 cfg := newSecretStoreConfigWithSource(t, secretstore.KindVault, "vault_prod", map[string]any{
298 "value": "resolved-secret",
299 "ignored": "drop-me",
300 }, confgroup.TypeDyncfg, confgroup.TypeDyncfg)
301 _, changed, err := mgr.rememberSecretStoreConfig(cfg)
302 require.NoError(t, err)
303 require.True(t, changed)
304
305 getFn := dyncfg.NewFunction(functions.Function{
306 UID: "ss-get-canonical",
307 Args: []string{mgr.dyncfgSecretStoreID(secretstore.StoreKey(secretstore.KindVault, "vault_prod")), string(dyncfg.CommandGet)},
308 })
309 mgr.dyncfgSecretStoreSeqExec(getFn)
310
311 var got map[string]any
312 mustDecodeFunctionPayload(t, out.String(), "ss-get-canonical", &got)
313 assert.Equal(t, "resolved-secret", got["value"])
314 _, ok := got["ignored"]
315 assert.False(t, ok)
316 }
317
318 func TestSecretStoreConfigFromPayload_PreservesKindAndNameForStoreSyntax(t *testing.T) {
319 mgr, _ := newDyncfgSecretStoreTestManagerWithService(newTestSecretStoreService())
320
321 fn := dyncfg.NewFunction(functions.Function{
322 ContentType: "application/json",
323 Payload: mustJSON(t, map[string]any{"value": "resolved-secret"}),
324 })
325 cfg, err := mgr.secretStoreConfigFromPayload(fn, "vault_prod", secretstore.KindVault)
326 require.NoError(t, err)
327
328 assert.Equal(t, "vault_prod", cfg.Name())
329 assert.Equal(t, secretstore.KindVault, cfg.Kind())
330 assert.Equal(t, "resolved-secret", cfg["value"])
331 }