@cryptotaxi247 / netdata-1 / commits / 7208b8353

refactor(go.d): extract shared dyncfg handler from jobmgr and sd (#21718)

* refactor(go.d/dyncfg): add shared handler, caches, and validate Add generic Handler[C Config] that implements the shared dyncfg command state machine (add/update/enable/disable/remove) with component-specific behavior delegated to a Callbacks interface. New files: - cache.go: SeenCache[C] and ExposedCache[C] with mutex protection - handler.go: Config interface, Entry, Callbacks (7 methods), Handler - validate.go: ValidateJobName (extracted from jobmgr and SD) - cache_test.go, handler_test.go: 44 tests covering all command flows Also adds Key() method to confgroup.Config (returns FullName()). This is step 1 of the dyncfg handler refactoring. The handler is not yet wired into jobmgr or SD — that happens in subsequent steps. * refactor(go.d/jobmgr): migrate to shared dyncfg handler Replace jobmgr's 5 inline dyncfg command handlers (add, enable, disable, remove, update) with the shared generic Handler from agent/dyncfg. Key changes: - Replace seenConfigs/exposedConfigs with shared SeenCache/ExposedCache - Add collectorCallbacks implementing dyncfg.Callbacks interface - Wire handler in Manager.New() with HandlerConfig - Route state-changing commands through handler in dyncfgCollectorSeqExec - Keep read-only commands (get, schema, test, userconfig, restart) in jobmgr - Update dyncfg_vnode.go to use exposed.ForEach() - Add Cfg() accessor and Count() to shared handler/cache types - Fix ExtractKey to handle add commands (module template ID vs job ID) - Update tests for new cache types (SeenCache stores config only, no status) * refactor(go.d/sd): migrate to shared dyncfg handler Replace SD-specific dyncfg command handling (CmdAdd, CmdEnable, CmdDisable, CmdRemove, CmdUpdate) with the shared generic Handler[sdConfig] from agent/dyncfg. - Implement sdCallbacks (ExtractKey, ParseAndValidate, Start, Update, Stop, OnStatusChange, ConfigID) to adapt SD logic to the handler - Replace seenSDConfigs/exposedSDConfigs with shared SeenCache/ExposedCache - Remove ikeyStatus from sdConfig (status now in Entry.Status) - Add ForEach to dyncfg.SeenCache for SD's removePipeline/removeOldConfigs - Keep read-only commands (schema, get, test, userconfig) in SD - Net removal of ~413 lines of duplicated code * refactor(go.d/dyncfg): move restart command to shared handler Add CmdRestart to the shared Handler, replacing the jobmgr-specific dyncfgConfigRestart implementation. Uses Stop+Start pattern with hardcoded 422 failure code. Rejects Accepted/Disabled states (405). * refactor(go.d/dyncfg): fix code style and add missing tests - Use errors.As instead of type assertions for CodedError - Lowercase error messages per Go conventions - Remove trailing periods from error format strings - Remove dead validateJobName from sd/dyncfg_parse.go - Add ExtractKey failure tests for all 6 handler commands - Add CmdUpdate conversion+Start failure and missing payload tests - Update test expectations across dyncfg, jobmgr, and sd packages * refactor(go.d/dyncfg): run gofmt on modified files * fix(go.d/sd): fix data race in priority test on entry.Status Move exposed config assertions to after cancel+wait so the SD goroutine has fully stopped before the test reads entry.Status. * refactor(go.d/dyncfg): add nil guard and concurrency comments - Add nil guard to SD's SetDyncfgResponder matching jobmgr's pattern - Document ExposedCache concurrency contract for Entry.Status * refactor(go.d/dyncfg): rename Config.Key() to Config.ExposedKey() ExposedKey() better communicates the method's purpose: identifying the logical slot in ExposedCache (one active config per slot). * refactor(go.d/dyncfg): replace HandlerConfig with HandlerOpts - Flatten all handler dependencies into a single HandlerOpts struct - Replace hardcoded job commands list with component-provided JobCommands (handler appends CommandRemove implicitly for dyncfg configs) - Add SetAPI method, eliminating handler rebuild in SetDyncfgResponder - Remove SupportRestart bool and Cfg() accessor

Ilya Mashchenko committed Feb 6, 2026 at 14:04 UTC 7208b8353becd8c49767f8c2a9fcc38545cbeaa8
18 files changed +2383 -1357
src/go/plugin/go.d/agent/confgroup/config.go
+1
@@ -48,6 +48,7 @@ func (c Config) Get(key string) any { return c[key] }
48 func (c Config) Name() string { v, _ := c.Get(keyName).(string); return v }
49 func (c Config) Module() string { v, _ := c.Get(keyModule).(string); return v }
50 func (c Config) FullName() string { return fullName(c.Name(), c.Module()) }
51 +func (c Config) ExposedKey() string { return c.FullName() }
52 func (c Config) UpdateEvery() int { v, _ := c.Get(keyUpdateEvery).(int); return v }
53 func (c Config) AutoDetectionRetry() int { v, _ := c.Get(keyDetectRetry).(int); return v }
54 func (c Config) Priority() int { v, _ := c.Get(keyPriority).(int); return v }
src/go/plugin/go.d/agent/discovery/sd/dyncfg.go
+85 -398
@@ -55,22 +55,6 @@ func dyncfgSDTemplateCmds() string {
55 )
56 }
57
58 -func dyncfgSDJobCmds(isDyncfgJob bool) string {
59 - cmds := []dyncfg.Command{
60 - dyncfg.CommandSchema,
61 - dyncfg.CommandGet,
62 - dyncfg.CommandTest,
63 - dyncfg.CommandEnable,
64 - dyncfg.CommandDisable,
65 - dyncfg.CommandUpdate,
66 - dyncfg.CommandUserconfig,
67 - }
68 - if isDyncfgJob {
69 - cmds = append(cmds, dyncfg.CommandRemove)
70 - }
71 - return dyncfg.JoinCommands(cmds...)
72 -}
73 -
58 func (d *ServiceDiscovery) dyncfgSDTemplateCreate(discovererType string) {
59 d.dyncfgApi.ConfigCreate(netdataapi.ConfigOpts{
60 ID: d.dyncfgTemplateID(discovererType),
@@ -83,26 +67,69 @@ func (d *ServiceDiscovery) dyncfgSDTemplateCreate(discovererType string) {
67 })
68 }
69
86 -func (d *ServiceDiscovery) dyncfgSDJobCreate(discovererType, name, sourceType, source string, status dyncfg.Status) {
87 - isDyncfg := sourceType == "dyncfg"
88 - cmds := dyncfgSDJobCmds(isDyncfg)
89 - d.dyncfgApi.ConfigCreate(netdataapi.ConfigOpts{
90 - ID: d.dyncfgJobID(discovererType, name),
91 - Status: status.String(),
92 - ConfigType: dyncfg.ConfigTypeJob.String(),
93 - Path: fmt.Sprintf(dyncfgSDPath, executable.Name),
94 - SourceType: sourceType,
95 - Source: source,
96 - SupportedCommands: cmds,
97 - })
70 +// sdCallbacks implements dyncfg.Callbacks[sdConfig]
71 +type sdCallbacks struct {
72 + sd *ServiceDiscovery
73 +}
74 +
75 +func (cb *sdCallbacks) ExtractKey(fn dyncfg.Function) (key, name string, ok bool) {
76 + id := fn.ID()
77 + if fn.Command() == dyncfg.CommandAdd {
78 + dt, _, _ := cb.sd.extractDiscovererAndName(id)
79 + if dt == "" || !isValidDiscovererType(dt) {
80 + return "", "", false
81 + }
82 + name = fn.JobName()
83 + if name == "" {
84 + return "", "", false
85 + }
86 + return dt + ":" + name, name, true
87 + }
88 + dt, name, isJob := cb.sd.extractDiscovererAndName(id)
89 + if !isJob || name == "" {
90 + return "", "", false
91 + }
92 + return dt + ":" + name, name, true
93 +}
94 +
95 +func (cb *sdCallbacks) ParseAndValidate(fn dyncfg.Function, name string) (sdConfig, error) {
96 + dt, _, _ := cb.sd.extractDiscovererAndName(fn.ID())
97 + if _, err := parseDyncfgPayload(fn.Payload(), dt, cb.sd.configDefaults); err != nil {
98 + return nil, err
99 + }
100 + pkey := pipelineKey(dt, name)
101 + cfg, err := newSDConfigFromJSON(fn.Payload(), name, fn.Source(), confgroup.TypeDyncfg, dt, pkey)
102 + if err != nil {
103 + return nil, err
104 + }
105 + return cfg, nil
106 +}
107 +
108 +func (cb *sdCallbacks) Start(cfg sdConfig) error {
109 + pipelineCfg, err := cfg.ToPipelineConfig(cb.sd.configDefaults)
110 + if err != nil {
111 + return err
112 + }
113 + return cb.sd.mgr.Start(cb.sd.ctx, cfg.PipelineKey(), pipelineCfg)
114 +}
115 +
116 +func (cb *sdCallbacks) Update(oldCfg, newCfg sdConfig) error {
117 + pipelineCfg, err := newCfg.ToPipelineConfig(cb.sd.configDefaults)
118 + if err != nil {
119 + return err
120 + }
121 + return cb.sd.mgr.Restart(cb.sd.ctx, newCfg.PipelineKey(), pipelineCfg)
122 +}
123 +
124 +func (cb *sdCallbacks) Stop(cfg sdConfig) {
125 + cb.sd.mgr.Stop(cfg.PipelineKey())
126 }
127
100 -func (d *ServiceDiscovery) dyncfgSDJobRemove(discovererType, name string) {
101 - d.dyncfgApi.ConfigDelete(d.dyncfgJobID(discovererType, name))
128 +func (cb *sdCallbacks) OnStatusChange(_ *dyncfg.Entry[sdConfig], _ dyncfg.Status, _ dyncfg.Function) {
129 }
130
104 -func (d *ServiceDiscovery) dyncfgSDJobStatus(discovererType, name string, status dyncfg.Status) {
105 - d.dyncfgApi.ConfigStatus(d.dyncfgJobID(discovererType, name), status)
131 +func (cb *sdCallbacks) ConfigID(cfg sdConfig) string {
132 + return cb.sd.dyncfgJobID(cfg.DiscovererType(), cfg.Name())
133 }
134
135 // dyncfgConfigHandler wraps dyncfgConfig to convert functions.Function to dyncfg.Function.
@@ -148,17 +175,28 @@ func (d *ServiceDiscovery) dyncfgConfig(fn dyncfg.Function) {
175
176 // dyncfgSeqExec executes state-changing dyncfg commands serially.
177 func (d *ServiceDiscovery) dyncfgSeqExec(fn dyncfg.Function) {
178 + // Clear waitCfgOnOff before processing enable/disable
179 + if fn.Command() == dyncfg.CommandEnable || fn.Command() == dyncfg.CommandDisable {
180 + if key, _, ok := d.sdCb.ExtractKey(fn); ok {
181 + if entry, ok := d.exposed.LookupByKey(key); ok {
182 + if entry.Cfg.PipelineKey() == d.waitCfgOnOff {
183 + d.waitCfgOnOff = ""
184 + }
185 + }
186 + }
187 + }
188 +
189 switch fn.Command() {
190 case dyncfg.CommandAdd:
153 - d.dyncfgCmdAdd(fn)
191 + d.handler.CmdAdd(fn)
192 case dyncfg.CommandUpdate:
155 - d.dyncfgCmdUpdate(fn)
193 + d.handler.CmdUpdate(fn)
194 case dyncfg.CommandEnable:
157 - d.dyncfgCmdEnable(fn)
195 + d.handler.CmdEnable(fn)
196 case dyncfg.CommandDisable:
159 - d.dyncfgCmdDisable(fn)
197 + d.handler.CmdDisable(fn)
198 case dyncfg.CommandRemove:
161 - d.dyncfgCmdRemove(fn)
199 + d.handler.CmdRemove(fn)
200 default:
201 d.Warningf("dyncfg: command '%s' not implemented", fn.Command())
202 d.dyncfgApi.SendCodef(fn, 501, "Command '%s' is not implemented.", fn.Command())
@@ -197,7 +235,7 @@ func (d *ServiceDiscovery) dyncfgCmdGet(fn dyncfg.Function) {
235 return
236 }
237
200 - cfg, ok := d.exposedConfigs.lookup(newLookupConfig(dt, name))
238 + entry, ok := d.exposed.LookupByKey(dt + ":" + name)
239 if !ok {
240 d.Warningf("dyncfg: get: config '%s:%s' not found", dt, name)
241 d.dyncfgApi.SendCodef(fn, 404, "Config '%s:%s' not found.", dt, name)
@@ -205,7 +243,7 @@ func (d *ServiceDiscovery) dyncfgCmdGet(fn dyncfg.Function) {
243 }
244
245 // Convert stored config to JSON via typed struct for consistent field ordering
208 - bs, err := configToJSON(cfg.DataJSON())
246 + bs, err := configToJSON(entry.Cfg.DataJSON())
247 if err != nil {
248 d.Warningf("dyncfg: get: failed to convert config '%s:%s' to JSON: %v", dt, name, err)
249 d.dyncfgApi.SendCodef(fn, 500, "Failed to convert config to JSON: %v", err)
@@ -215,79 +253,6 @@ func (d *ServiceDiscovery) dyncfgCmdGet(fn dyncfg.Function) {
253 d.dyncfgApi.SendJSON(fn, string(bs))
254 }
255
218 -// dyncfgCmdAdd handles the add command for templates (creates a new job)
219 -func (d *ServiceDiscovery) dyncfgCmdAdd(fn dyncfg.Function) {
220 - if err := fn.ValidateArgs(3); err != nil {
221 - d.Warningf("dyncfg: add: %v", err)
222 - d.dyncfgApi.SendCodef(fn, 400, "%v", err)
223 - return
224 - }
225 -
226 - id := fn.ID()
227 - name := fn.JobName()
228 -
229 - dt, _, _ := d.extractDiscovererAndName(id)
230 - if dt == "" || !isValidDiscovererType(dt) {
231 - d.Warningf("dyncfg: add: invalid discoverer type in ID '%s'", id)
232 - d.dyncfgApi.SendCodef(fn, 400, "Invalid discoverer type in ID: %s", id)
233 - return
234 - }
235 -
236 - if name == "" {
237 - d.Warningf("dyncfg: add: missing job name")
238 - d.dyncfgApi.SendCodef(fn, 400, "Missing job name.")
239 - return
240 - }
241 -
242 - if err := fn.ValidateHasPayload(); err != nil {
243 - d.Warningf("dyncfg: add: %v for '%s:%s'", err, dt, name)
244 - d.dyncfgApi.SendCodef(fn, 400, "%v", err)
245 - return
246 - }
247 -
248 - if err := validateJobName(name); err != nil {
249 - d.Warningf("dyncfg: add: unacceptable job name '%s': %v", name, err)
250 - d.dyncfgApi.SendCodef(fn, 400, "Unacceptable job name '%s': %v.", name, err)
251 - return
252 - }
253 -
254 - // Validate config by parsing it
255 - if _, err := parseDyncfgPayload(fn.Payload(), dt, d.configDefaults); err != nil {
256 - d.Warningf("dyncfg: add: invalid config for '%s:%s': %v", dt, name, err)
257 - d.dyncfgApi.SendCodef(fn, 400, "Invalid config: %v", err)
258 - return
259 - }
260 -
261 - // Create sdConfig from JSON payload
262 - pkey := pipelineKey(dt, name)
263 - cfg, err := newSDConfigFromJSON(fn.Payload(), name, fn.Source(), confgroup.TypeDyncfg, dt, pkey)
264 - if err != nil {
265 - d.Warningf("dyncfg: add: failed to create config '%s:%s': %v", dt, name, err)
266 - d.dyncfgApi.SendCodef(fn, 400, "Failed to create config: %v", err)
267 - return
268 - }
269 -
270 - d.Infof("dyncfg: add: %s:%s by user '%s'", dt, name, fn.User())
271 -
272 - // If config with same key already exists, replace it (matching jobmgr pattern)
273 - if ecfg, ok := d.exposedConfigs.lookup(cfg); ok {
274 - // Only remove from seenConfigs if it's a dyncfg config
275 - // (file-based configs are removed via other codepath when file is deleted)
276 - if scfg, ok := d.seenConfigs.lookup(ecfg); ok && scfg.SourceType() == confgroup.TypeDyncfg {
277 - d.seenConfigs.remove(ecfg)
278 - }
279 - d.exposedConfigs.remove(ecfg)
280 - d.mgr.Stop(ecfg.PipelineKey())
281 - }
282 -
283 - // Add to both caches
284 - d.seenConfigs.add(cfg)
285 - d.exposedConfigs.add(cfg)
286 -
287 - d.dyncfgApi.SendCodef(fn, 202, "")
288 - d.dyncfgSDJobCreate(dt, name, cfg.SourceType(), cfg.Source(), cfg.Status())
289 -}
290 -
256 // dyncfgCmdTest handles the test command for templates and jobs (validates config without applying it)
257 func (d *ServiceDiscovery) dyncfgCmdTest(fn dyncfg.Function) {
258 id := fn.ID()
@@ -321,292 +286,6 @@ func (d *ServiceDiscovery) dyncfgCmdTest(fn dyncfg.Function) {
286 d.dyncfgApi.SendCodef(fn, 200, "")
287 }
288
324 -// dyncfgCmdUpdate handles the update command for jobs
325 -func (d *ServiceDiscovery) dyncfgCmdUpdate(fn dyncfg.Function) {
326 - id := fn.ID()
327 - dt, name, isJob := d.extractDiscovererAndName(id)
328 -
329 - if !isJob || name == "" {
330 - d.Warningf("dyncfg: update: invalid job ID format '%s'", id)
331 - d.dyncfgApi.SendCodef(fn, 400, "Invalid job ID format: %s", id)
332 - return
333 - }
334 -
335 - ecfg, ok := d.exposedConfigs.lookup(newLookupConfig(dt, name))
336 - if !ok {
337 - d.Warningf("dyncfg: update: config '%s:%s' not found", dt, name)
338 - d.dyncfgApi.SendCodef(fn, 404, "Config '%s:%s' not found.", dt, name)
339 - return
340 - }
341 -
342 - if err := fn.ValidateHasPayload(); err != nil {
343 - d.Warningf("dyncfg: update: %v for '%s:%s'", err, dt, name)
344 - d.dyncfgApi.SendCodef(fn, 400, "%v", err)
345 - return
346 - }
347 -
348 - // Parse the new config to validate it
349 - pipelineCfg, err := parseDyncfgPayload(fn.Payload(), dt, d.configDefaults)
350 - if err != nil {
351 - d.Warningf("dyncfg: update: failed to parse config '%s:%s': %v", dt, name, err)
352 - d.dyncfgApi.SendCodef(fn, 400, "Failed to parse config: %v", err)
353 - return
354 - }
355 -
356 - // Updating a non-dyncfg config converts it to dyncfg (creates an override).
357 - // This ensures changes persist and take priority over file configs.
358 - isConversion := ecfg.SourceType() != confgroup.TypeDyncfg
359 - var newSource, newSourceType, newPipelineKey string
360 -
361 - if isConversion {
362 - newSource = fn.Source()
363 - newSourceType = confgroup.TypeDyncfg
364 - newPipelineKey = pipelineKey(dt, name)
365 - pipelineCfg.Source = fmt.Sprintf("dyncfg=%s", newSource)
366 - } else {
367 - newSource = fn.Source()
368 - newSourceType = confgroup.TypeDyncfg
369 - newPipelineKey = ecfg.PipelineKey()
370 - pipelineCfg.Source = fmt.Sprintf("dyncfg=%s", newSource)
371 - }
372 -
373 - // Create updated sdConfig
374 - newCfg, err := newSDConfigFromJSON(fn.Payload(), name, newSource, newSourceType, dt, newPipelineKey)
375 - if err != nil {
376 - d.Warningf("dyncfg: update: failed to create config '%s:%s': %v", dt, name, err)
377 - d.dyncfgApi.SendCodef(fn, 400, "Failed to create config: %v", err)
378 - return
379 - }
380 -
381 - // If running, not a conversion, and config unchanged, return early (optimization)
382 - // Skip this optimization for conversions (file->dyncfg) since we need to change source type
383 - if !isConversion && ecfg.Status() == dyncfg.StatusRunning && ecfg.Hash() == newCfg.Hash() {
384 - d.dyncfgApi.SendCodef(fn, 200, "")
385 - d.dyncfgSDJobStatus(dt, name, ecfg.Status())
386 - return
387 - }
388 -
389 - // Update not allowed in Accepted state (matching jobmgr pattern)
390 - if ecfg.Status() == dyncfg.StatusAccepted {
391 - d.Warningf("dyncfg: update: config '%s:%s': updating not allowed in %s state", dt, name, ecfg.Status())
392 - d.dyncfgApi.SendCodef(fn, 403, "Updating is not allowed in '%s' state.", ecfg.Status())
393 - d.dyncfgSDJobStatus(dt, name, ecfg.Status())
394 - return
395 - }
396 -
397 - d.Infof("dyncfg: update: %s:%s by user '%s'", dt, name, fn.User())
398 -
399 - // Update caches
400 - // When old was dyncfg: remove old from seenConfigs (cleanup stale entry)
401 - // When old was file: keep in seenConfigs (for re-exposure if dyncfg removed later)
402 - if !isConversion {
403 - d.seenConfigs.remove(ecfg)
404 - }
405 - d.seenConfigs.add(newCfg)
406 - d.exposedConfigs.add(newCfg)
407 -
408 - // For conversion: remove old dyncfg job, will create new one below
409 - if isConversion {
410 - d.dyncfgSDJobRemove(dt, name)
411 - }
412 -
413 - // If old status was Accepted or Disabled, preserve it (don't auto-start)
414 - if ecfg.Status() == dyncfg.StatusAccepted || ecfg.Status() == dyncfg.StatusDisabled {
415 - newCfg.SetStatus(ecfg.Status())
416 - d.exposedConfigs.updateStatus(newCfg, ecfg.Status())
417 - if isConversion {
418 - d.dyncfgSDJobCreate(dt, name, newSourceType, newSource, ecfg.Status())
419 - }
420 - d.dyncfgApi.SendCodef(fn, 200, "")
421 - d.dyncfgSDJobStatus(dt, name, ecfg.Status())
422 - return
423 - }
424 -
425 - // Restart/start pipeline with new config
426 - if isConversion {
427 - // Conversion: pipeline keys differ, need Stop + Start
428 - d.mgr.Stop(ecfg.PipelineKey())
429 - err = d.mgr.Start(d.ctx, newPipelineKey, pipelineCfg)
430 - } else {
431 - // Non-conversion: same pipeline key, use Restart for graceful transition
432 - // Restart validates new config before stopping old, uses grace period
433 - err = d.mgr.Restart(d.ctx, newPipelineKey, pipelineCfg)
434 - }
435 -
436 - if err != nil {
437 - d.Errorf("dyncfg: update: failed to start pipeline '%s:%s': %v", dt, name, err)
438 - newCfg.SetStatus(dyncfg.StatusFailed)
439 - d.exposedConfigs.updateStatus(newCfg, dyncfg.StatusFailed)
440 - if isConversion {
441 - d.dyncfgSDJobCreate(dt, name, newSourceType, newSource, dyncfg.StatusFailed)
442 - }
443 - d.dyncfgApi.SendCodef(fn, 200, "")
444 - d.dyncfgSDJobStatus(dt, name, dyncfg.StatusFailed)
445 - return
446 - }
447 -
448 - newCfg.SetStatus(dyncfg.StatusRunning)
449 - d.exposedConfigs.updateStatus(newCfg, dyncfg.StatusRunning)
450 - if isConversion {
451 - d.dyncfgSDJobCreate(dt, name, newSourceType, newSource, dyncfg.StatusRunning)
452 - }
453 - d.dyncfgApi.SendCodef(fn, 200, "")
454 - d.dyncfgSDJobStatus(dt, name, dyncfg.StatusRunning)
455 -}
456 -
457 -// dyncfgCmdEnable handles the enable command for jobs
458 -func (d *ServiceDiscovery) dyncfgCmdEnable(fn dyncfg.Function) {
459 - id := fn.ID()
460 - dt, name, isJob := d.extractDiscovererAndName(id)
461 -
462 - if !isJob || name == "" {
463 - d.Warningf("dyncfg: enable: invalid job ID format '%s'", id)
464 - d.dyncfgApi.SendCodef(fn, 400, "Invalid job ID format: %s", id)
465 - return
466 - }
467 -
468 - cfg, ok := d.exposedConfigs.lookup(newLookupConfig(dt, name))
469 - if !ok {
470 - d.Warningf("dyncfg: enable: config '%s:%s' not found", dt, name)
471 - d.dyncfgApi.SendCodef(fn, 404, "Config '%s:%s' not found.", dt, name)
472 - return
473 - }
474 -
475 - pkey := cfg.PipelineKey()
476 -
477 - // Clear wait flag if this is the config we're waiting for
478 - if pkey == d.waitCfgOnOff {
479 - d.waitCfgOnOff = ""
480 - }
481 -
482 - switch cfg.Status() {
483 - case dyncfg.StatusAccepted, dyncfg.StatusDisabled, dyncfg.StatusFailed:
484 - // proceed with enable
485 - case dyncfg.StatusRunning:
486 - // already running, return success (idempotent)
487 - d.dyncfgApi.SendCodef(fn, 200, "")
488 - d.dyncfgSDJobStatus(dt, name, cfg.Status())
489 - return
490 - default:
491 - d.Warningf("dyncfg: enable: config '%s:%s': enabling not allowed in %s state", dt, name, cfg.Status())
492 - d.dyncfgApi.SendCodef(fn, 405, "Enabling is not allowed in '%s' state.", cfg.Status())
493 - d.dyncfgSDJobStatus(dt, name, cfg.Status())
494 - return
495 - }
496 -
497 - // Convert sdConfig to pipeline.Config
498 - pipelineCfg, err := cfg.ToPipelineConfig(d.configDefaults)
499 - if err != nil {
500 - d.Warningf("dyncfg: enable: failed to parse config '%s:%s': %v", dt, name, err)
501 - d.exposedConfigs.updateStatus(cfg, dyncfg.StatusFailed)
502 - d.dyncfgSDJobStatus(dt, name, dyncfg.StatusFailed)
503 - d.dyncfgApi.SendCodef(fn, 422, "Failed to parse config: %v", err)
504 - return
505 - }
506 -
507 - if cfg.Status() == dyncfg.StatusDisabled {
508 - d.Infof("dyncfg: enable: %s:%s by user '%s'", dt, name, fn.User())
509 - }
510 -
511 - if err := d.mgr.Start(d.ctx, pkey, pipelineCfg); err != nil {
512 - d.Errorf("dyncfg: enable: failed to start pipeline '%s:%s': %v", dt, name, err)
513 - d.exposedConfigs.updateStatus(cfg, dyncfg.StatusFailed)
514 - d.dyncfgSDJobStatus(dt, name, dyncfg.StatusFailed)
515 - d.dyncfgApi.SendCodef(fn, 422, "Failed to start pipeline: %v", err)
516 - return
517 - }
518 -
519 - d.exposedConfigs.updateStatus(cfg, dyncfg.StatusRunning)
520 - d.dyncfgApi.SendCodef(fn, 200, "")
521 - d.dyncfgSDJobStatus(dt, name, dyncfg.StatusRunning)
522 -}
523 -
524 -// dyncfgCmdDisable handles the disable command for jobs
525 -func (d *ServiceDiscovery) dyncfgCmdDisable(fn dyncfg.Function) {
526 - id := fn.ID()
527 - dt, name, isJob := d.extractDiscovererAndName(id)
528 -
529 - if !isJob || name == "" {
530 - d.Warningf("dyncfg: disable: invalid job ID format '%s'", id)
531 - d.dyncfgApi.SendCodef(fn, 400, "Invalid job ID format: %s", id)
532 - return
533 - }
534 -
535 - cfg, ok := d.exposedConfigs.lookup(newLookupConfig(dt, name))
536 - if !ok {
537 - d.Warningf("dyncfg: disable: config '%s:%s' not found", dt, name)
538 - d.dyncfgApi.SendCodef(fn, 404, "Config '%s:%s' not found.", dt, name)
539 - return
540 - }
541 -
542 - pkey := cfg.PipelineKey()
543 -
544 - // Clear wait flag if this is the config we're waiting for
545 - if pkey == d.waitCfgOnOff {
546 - d.waitCfgOnOff = ""
547 - }
548 -
549 - switch cfg.Status() {
550 - case dyncfg.StatusDisabled:
551 - // already disabled, return success (idempotent)
552 - d.dyncfgApi.SendCodef(fn, 200, "")
553 - d.dyncfgSDJobStatus(dt, name, cfg.Status())
554 - return
555 - case dyncfg.StatusRunning:
556 - d.mgr.Stop(pkey)
557 - default:
558 - // Accepted, Failed - just proceed to set Disabled
559 - }
560 -
561 - d.Infof("dyncfg: disable: %s:%s by user '%s'", dt, name, fn.User())
562 -
563 - d.exposedConfigs.updateStatus(cfg, dyncfg.StatusDisabled)
564 - d.dyncfgApi.SendCodef(fn, 200, "")
565 - d.dyncfgSDJobStatus(dt, name, dyncfg.StatusDisabled)
566 -}
567 -
568 -// dyncfgCmdRemove handles the remove command for dyncfg jobs
569 -func (d *ServiceDiscovery) dyncfgCmdRemove(fn dyncfg.Function) {
570 - id := fn.ID()
571 - dt, name, isJob := d.extractDiscovererAndName(id)
572 -
573 - if !isJob || name == "" {
574 - d.Warningf("dyncfg: remove: invalid job ID format '%s'", id)
575 - d.dyncfgApi.SendCodef(fn, 400, "Invalid job ID format: %s", id)
576 - return
577 - }
578 -
579 - cfg, ok := d.exposedConfigs.lookup(newLookupConfig(dt, name))
580 - if !ok {
581 - d.Warningf("dyncfg: remove: config '%s:%s' not found", dt, name)
582 - d.dyncfgApi.SendCodef(fn, 404, "Config '%s:%s' not found.", dt, name)
583 - return
584 - }
585 -
586 - if cfg.SourceType() != confgroup.TypeDyncfg {
587 - d.Warningf("dyncfg: remove: cannot remove non-dyncfg config '%s:%s' (source: %s)", dt, name, cfg.SourceType())
588 - d.dyncfgApi.SendCodef(fn, 405, "Cannot remove non-dyncfg configs. Source type: %s", cfg.SourceType())
589 - return
590 - }
591 -
592 - d.Infof("dyncfg: remove: removing config '%s:%s'", dt, name)
593 -
594 - d.mgr.Stop(cfg.PipelineKey())
595 -
596 - // Remove from both caches
597 - d.seenConfigs.remove(cfg)
598 - d.exposedConfigs.remove(cfg)
599 -
600 - // TODO: After removing dyncfg config, check if a lower-priority config (user/stock file)
601 - // exists in seenConfigs with the same Key(). If so, promote it to exposedConfigs and
602 - // recreate the dyncfg job. This would allow file configs to "take over" when dyncfg
603 - // override is removed.
604 -
605 - // Response before delete (matching jobmgr pattern)
606 - d.dyncfgApi.SendCodef(fn, 200, "")
607 - d.dyncfgSDJobRemove(dt, name)
608 -}
609 -
289 // dyncfgCmdUserconfig handles the userconfig command for templates and jobs
290 // Returns YAML representation of the config for user-friendly file format
291 func (d *ServiceDiscovery) dyncfgCmdUserconfig(fn dyncfg.Function) {
@@ -696,3 +375,11 @@ func (d *ServiceDiscovery) unregisterDyncfgTemplates() {
375
376 d.fnReg.UnregisterPrefix("config", d.dyncfgSDPrefixValue())
377 }
378 +
379 +// autoEnableConfig enables a config without waiting for netdata's enable command.
380 +func (d *ServiceDiscovery) autoEnableConfig(cfg sdConfig) {
381 + fn := dyncfg.NewFunction(functions.Function{
382 + Args: []string{d.dyncfgJobID(cfg.DiscovererType(), cfg.Name()), "enable"},
383 + })
384 + d.handler.CmdEnable(fn)
385 +}
src/go/plugin/go.d/agent/discovery/sd/dyncfg_cache.go
+4 -154
@@ -6,11 +6,9 @@ import (
6 "encoding/json"
7 "fmt"
8 "strings"
9 - "sync"
9
10 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
11 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/pipeline"
13 - "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/dyncfg"
12
13 "github.com/gohugoio/hashstructure"
14 "gopkg.in/yaml.v2"
@@ -22,7 +20,6 @@ const (
20 ikeySourceType = "__source_type__"
21 ikeyDiscovererType = "__discoverer_type__"
22 ikeyPipelineKey = "__pipeline_key__"
25 - ikeyStatus = "__status__"
23 )
24
25 // sdConfig represents a service discovery pipeline configuration.
@@ -36,11 +33,6 @@ func (c sdConfig) DiscovererType() string { v, _ := c[ikeyDiscovererType].(strin
33 func (c sdConfig) PipelineKey() string { v, _ := c[ikeyPipelineKey].(string); return v }
34 func (c sdConfig) Name() string { v, _ := c["name"].(string); return v }
35
39 -func (c sdConfig) Status() dyncfg.Status {
40 - v, _ := c[ikeyStatus].(dyncfg.Status)
41 - return v
42 -}
43 -
36 // HashIncludeMap implements hashstructure.HashIncludeMap to exclude __ metadata keys from hashing.
37 // Same pattern as confgroup.Config.
38 func (c sdConfig) HashIncludeMap(_ string, k, _ any) (bool, error) {
@@ -59,16 +51,15 @@ func (c sdConfig) SetSource(v string) sdConfig { c[ikeySource] = v; retu
51 func (c sdConfig) SetSourceType(v string) sdConfig { c[ikeySourceType] = v; return c }
52 func (c sdConfig) SetDiscovererType(v string) sdConfig { c[ikeyDiscovererType] = v; return c }
53 func (c sdConfig) SetPipelineKey(v string) sdConfig { c[ikeyPipelineKey] = v; return c }
62 -func (c sdConfig) SetStatus(v dyncfg.Status) sdConfig { c[ikeyStatus] = v; return c }
54
64 -// Key returns the logical key for exposedConfigs: "discovererType:name"
65 -func (c sdConfig) Key() string {
55 +// ExposedKey returns the logical key for ExposedCache: "discovererType:name"
56 +func (c sdConfig) ExposedKey() string {
57 return c.DiscovererType() + ":" + c.Name()
58 }
59
60 // UID returns the unique key for seenConfigs: "source:discovererType:name"
61 func (c sdConfig) UID() string {
71 - return c.Source() + ":" + c.Key()
62 + return c.Source() + ":" + c.ExposedKey()
63 }
64
65 // SourceTypePriority returns priority based on source type.
@@ -86,35 +77,6 @@ func (c sdConfig) SourceTypePriority() int {
77 }
78 }
79
89 -// Clone returns a deep copy of the config using JSON marshal/unmarshal.
90 -func (c sdConfig) Clone() sdConfig {
91 - data, err := json.Marshal(c)
92 - if err != nil {
93 - // Fallback to shallow copy if marshal fails (shouldn't happen)
94 - clone := make(sdConfig, len(c))
95 - for k, v := range c {
96 - clone[k] = v
97 - }
98 - return clone
99 - }
100 - var clone sdConfig
101 - if err := json.Unmarshal(data, &clone); err != nil {
102 - // Fallback to shallow copy
103 - clone = make(sdConfig, len(c))
104 - for k, v := range c {
105 - clone[k] = v
106 - }
107 - return clone
108 - }
109 - // Restore metadata from original (JSON may lose type info for type aliases)
110 - clone.SetSource(c.Source())
111 - clone.SetSourceType(c.SourceType())
112 - clone.SetDiscovererType(c.DiscovererType())
113 - clone.SetPipelineKey(c.PipelineKey())
114 - clone.SetStatus(c.Status())
115 - return clone
116 -}
117 -
80 // ToPipelineConfig converts sdConfig to pipeline.Config for actually running the pipeline.
81 // This parses the config data (excluding __ fields) into the typed struct.
82 func (c sdConfig) ToPipelineConfig(configDefaults confgroup.Registry) (pipeline.Config, error) {
@@ -190,7 +152,6 @@ func newSDConfigFromYAML(data []byte, source, sourceType, pipelineKey string) (s
152 m.SetSourceType(sourceType)
153 m.SetDiscovererType(cfg.Discoverer.Type())
154 m.SetPipelineKey(pipelineKey)
193 - m.SetStatus(dyncfg.StatusAccepted)
155
156 return m, nil
157 }
@@ -208,7 +169,7 @@ func newSDConfigFromJSON(data []byte, name, source, sourceType, discovererType,
169 }
170
171 // Force name from dyncfg job ID (matching jobmgr pattern: cfg.SetName(name))
211 - // This ensures sdConfig.Key() matches the dyncfg job ID regardless of payload content
172 + // This ensures sdConfig.ExposedKey() matches the dyncfg job ID regardless of payload content
173 m["name"] = cleanName(name)
174
175 // Add metadata
@@ -216,7 +177,6 @@ func newSDConfigFromJSON(data []byte, name, source, sourceType, discovererType,
177 m.SetSourceType(sourceType)
178 m.SetDiscovererType(discovererType)
179 m.SetPipelineKey(pipelineKey)
219 - m.SetStatus(dyncfg.StatusAccepted)
180
181 return m, nil
182 }
@@ -230,113 +190,3 @@ func sourceTypeFromPath(path string) string {
190 }
191 return confgroup.TypeStock
192 }
233 -
234 -// newLookupConfig creates a minimal sdConfig for cache lookups.
235 -// Only sets fields needed for Key() derivation: discovererType and name.
236 -func newLookupConfig(discovererType, name string) sdConfig {
237 - return sdConfig{
238 - ikeyDiscovererType: discovererType,
239 - "name": name,
240 - }
241 -}
242 -
243 -// seenSDConfigs tracks all discovered SD configs by unique ID (source + key).
244 -// Multiple sources can produce configs with the same logical name.
245 -type seenSDConfigs struct {
246 - mux sync.RWMutex
247 - items map[string]sdConfig // [UID()]
248 -}
249 -
250 -func newSeenSDConfigs() *seenSDConfigs {
251 - return &seenSDConfigs{
252 - items: make(map[string]sdConfig),
253 - }
254 -}
255 -
256 -func (c *seenSDConfigs) add(cfg sdConfig) {
257 - c.mux.Lock()
258 - defer c.mux.Unlock()
259 - c.items[cfg.UID()] = cfg
260 -}
261 -
262 -func (c *seenSDConfigs) remove(cfg sdConfig) {
263 - c.mux.Lock()
264 - defer c.mux.Unlock()
265 - delete(c.items, cfg.UID())
266 -}
267 -
268 -// lookup returns a deep copy of the config to avoid data races.
269 -// Key is derived from cfg.UID() internally.
270 -func (c *seenSDConfigs) lookup(cfg sdConfig) (sdConfig, bool) {
271 - c.mux.RLock()
272 - defer c.mux.RUnlock()
273 - v, ok := c.items[cfg.UID()]
274 - if !ok {
275 - return nil, false
276 - }
277 - return v.Clone(), true
278 -}
279 -
280 -// lookupBySource returns deep copies of configs from the given source.
281 -func (c *seenSDConfigs) lookupBySource(source string) []sdConfig {
282 - c.mux.RLock()
283 - defer c.mux.RUnlock()
284 - var result []sdConfig
285 - for _, cfg := range c.items {
286 - if cfg.Source() == source {
287 - result = append(result, cfg.Clone())
288 - }
289 - }
290 - return result
291 -}
292 -
293 -// exposedSDConfigs tracks SD configs exposed via dyncfg UI by logical key.
294 -// Only one config per logical key (discovererType:name) is exposed at a time.
295 -type exposedSDConfigs struct {
296 - mux sync.RWMutex
297 - items map[string]sdConfig // [Key()]
298 -}
299 -
300 -func newExposedSDConfigs() *exposedSDConfigs {
301 - return &exposedSDConfigs{
302 - items: make(map[string]sdConfig),
303 - }
304 -}
305 -
306 -func (c *exposedSDConfigs) add(cfg sdConfig) {
307 - c.mux.Lock()
308 - defer c.mux.Unlock()
309 - c.items[cfg.Key()] = cfg
310 -}
311 -
312 -func (c *exposedSDConfigs) remove(cfg sdConfig) {
313 - c.mux.Lock()
314 - defer c.mux.Unlock()
315 - delete(c.items, cfg.Key())
316 -}
317 -
318 -// lookup returns a deep copy of the config to avoid data races.
319 -// Key is derived from cfg.Key() internally.
320 -func (c *exposedSDConfigs) lookup(cfg sdConfig) (sdConfig, bool) {
321 - c.mux.RLock()
322 - defer c.mux.RUnlock()
323 - v, ok := c.items[cfg.Key()]
324 - if !ok {
325 - return nil, false
326 - }
327 - return v.Clone(), true
328 -}
329 -
330 -func (c *exposedSDConfigs) updateStatus(cfg sdConfig, status dyncfg.Status) {
331 - c.mux.Lock()
332 - defer c.mux.Unlock()
333 - if v, ok := c.items[cfg.Key()]; ok {
334 - v.SetStatus(status)
335 - }
336 -}
337 -
338 -func (c *exposedSDConfigs) count() int {
339 - c.mux.RLock()
340 - defer c.mux.RUnlock()
341 - return len(c.items)
342 -}
src/go/plugin/go.d/agent/discovery/sd/dyncfg_parse.go
-17
@@ -4,9 +4,7 @@ package sd
4
5 import (
6 "encoding/json"
7 - "errors"
7 "fmt"
9 - "unicode"
8
9 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
10 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/pipeline"
@@ -89,18 +87,3 @@ func userConfigFromPayload(payload []byte, discovererType, jobName string) ([]by
87
88 return bs, nil
89 }
92 -
93 -// validateJobName validates a job name for dyncfg.
94 -// Job names cannot contain spaces, '.', or ':' characters.
95 -func validateJobName(jobName string) error {
96 - for _, r := range jobName {
97 - if unicode.IsSpace(r) {
98 - return errors.New("contains spaces")
99 - }
100 - switch r {
101 - case '.', ':':
102 - return fmt.Errorf("contains '%c'", r)
103 - }
104 - }
105 - return nil
106 -}
src/go/plugin/go.d/agent/discovery/sd/dyncfg_test.go
+74 -63
@@ -7,12 +7,14 @@ import (
7 "context"
8 "encoding/json"
9 "errors"
10 + "fmt"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/netdata/netdata/go/plugins/logger"
16 "github.com/netdata/netdata/go/plugins/pkg/confopt"
17 + "github.com/netdata/netdata/go/plugins/pkg/executable"
18 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
19 "github.com/netdata/netdata/go/plugins/pkg/safewriter"
20 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
@@ -106,15 +108,35 @@ func (s *dyncfgSim) run(t *testing.T) {
108
109 var buf bytes.Buffer
110 sd := &ServiceDiscovery{
109 - Logger: logger.New(),
110 - dyncfgApi: dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))),
111 - seenConfigs: newSeenSDConfigs(),
112 - exposedConfigs: newExposedSDConfigs(),
113 - dyncfgCh: make(chan dyncfg.Function, 1),
111 + Logger: logger.New(),
112 + dyncfgApi: dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))),
113 + seen: dyncfg.NewSeenCache[sdConfig](),
114 + exposed: dyncfg.NewExposedCache[sdConfig](),
115 + dyncfgCh: make(chan dyncfg.Function, 1),
116 newPipeline: func(cfg pipeline.Config) (sdPipeline, error) {
117 return newTestPipeline(cfg.Name), nil
118 },
119 }
120 + sd.sdCb = &sdCallbacks{sd: sd}
121 + sd.handler = dyncfg.NewHandler(dyncfg.HandlerOpts[sdConfig]{
122 + Logger: sd.Logger,
123 + API: sd.dyncfgApi,
124 + Seen: sd.seen,
125 + Exposed: sd.exposed,
126 + Callbacks: sd.sdCb,
127 +
128 + Path: fmt.Sprintf(dyncfgSDPath, executable.Name),
129 + EnableFailCode: 422,
130 + JobCommands: []dyncfg.Command{
131 + dyncfg.CommandSchema,
132 + dyncfg.CommandGet,
133 + dyncfg.CommandEnable,
134 + dyncfg.CommandDisable,
135 + dyncfg.CommandUpdate,
136 + dyncfg.CommandTest,
137 + dyncfg.CommandUserconfig,
138 + },
139 + })
140
141 done := make(chan struct{})
142 ctx, cancel := context.WithCancel(context.Background())
@@ -191,14 +213,14 @@ func (s *dyncfgSim) run(t *testing.T) {
213
214 // Verify exposed configs
215 if s.wantExposed != nil {
194 - wantLen, gotLen := len(s.wantExposed), sd.exposedConfigs.count()
216 + wantLen, gotLen := len(s.wantExposed), sd.exposed.Count()
217 require.Equalf(t, wantLen, gotLen, "exposedConfigs: different len (want %d got %d)", wantLen, gotLen)
218
219 for _, want := range s.wantExposed {
198 - cfg, ok := sd.exposedConfigs.lookup(newLookupConfig(want.discovererType, want.name))
220 + entry, ok := sd.exposed.LookupByKey(want.discovererType + ":" + want.name)
221 require.Truef(t, ok, "exposedConfigs: config '%s:%s' not found", want.discovererType, want.name)
200 - assert.Equal(t, want.sourceType, cfg.SourceType(), "exposedConfigs: wrong sourceType for '%s:%s'", want.discovererType, want.name)
201 - assert.Equal(t, want.status, cfg.Status(), "exposedConfigs: wrong status for '%s:%s'", want.discovererType, want.name)
222 + assert.Equal(t, want.sourceType, entry.Cfg.SourceType(), "exposedConfigs: wrong sourceType for '%s:%s'", want.discovererType, want.name)
223 + assert.Equal(t, want.status, entry.Status, "exposedConfigs: wrong status for '%s:%s'", want.discovererType, want.name)
224 }
225 }
226
@@ -316,7 +338,7 @@ FUNCTION_RESULT_BEGIN 1-add 202 application/json
338 {"status":202,"message":""}
339 FUNCTION_RESULT_END
340
319 -CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
341 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
342 `,
343 }
344 },
@@ -368,13 +390,13 @@ FUNCTION_RESULT_BEGIN 1-add 202 application/json
390 {"status":202,"message":""}
391 FUNCTION_RESULT_END
392
371 -CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
393 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
394
395 FUNCTION_RESULT_BEGIN 2-add 202 application/json
396 {"status":202,"message":""}
397 FUNCTION_RESULT_END
398
377 -CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
399 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
400 `,
401 }
402 },
@@ -484,7 +506,7 @@ FUNCTION_RESULT_BEGIN 1-add 202 application/json
506 {"status":202,"message":""}
507 FUNCTION_RESULT_END
508
487 -CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
509 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
510
511 FUNCTION_RESULT_BEGIN 2-enable 200 application/json
512 {"status":200,"message":""}
@@ -531,7 +553,7 @@ FUNCTION_RESULT_BEGIN 1-add 202 application/json
553 {"status":202,"message":""}
554 FUNCTION_RESULT_END
555
534 -CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
556 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
557
558 FUNCTION_RESULT_BEGIN 2-enable 200 application/json
559 {"status":200,"message":""}
@@ -558,7 +580,7 @@ CONFIG test:sd:net_listeners:test-job status disabled
580 },
581 wantDyncfg: `
582 FUNCTION_RESULT_BEGIN 1-enable 404 application/json
561 -{"status":404,"errorMessage":"Config 'net_listeners:non-existent' not found."}
583 +{"status":404,"errorMessage":"job not found."}
584 FUNCTION_RESULT_END
585 `,
586 }
@@ -620,7 +642,7 @@ FUNCTION_RESULT_BEGIN 1-add 202 application/json
642 {"status":202,"message":""}
643 FUNCTION_RESULT_END
644
623 -CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
645 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
646
647 FUNCTION_RESULT_BEGIN 2-enable 200 application/json
648 {"status":200,"message":""}
@@ -676,10 +698,10 @@ FUNCTION_RESULT_BEGIN 1-add 202 application/json
698 {"status":202,"message":""}
699 FUNCTION_RESULT_END
700
679 -CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
701 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
702
703 FUNCTION_RESULT_BEGIN 2-update 403 application/json
682 -{"status":403,"errorMessage":"Updating is not allowed in 'accepted' state."}
704 +{"status":403,"errorMessage":"updating is not allowed in 'accepted' state."}
705 FUNCTION_RESULT_END
706
707 CONFIG test:sd:net_listeners:test-job status accepted
@@ -700,7 +722,7 @@ CONFIG test:sd:net_listeners:test-job status accepted
722 },
723 wantDyncfg: `
724 FUNCTION_RESULT_BEGIN 1-update 404 application/json
703 -{"status":404,"errorMessage":"Config 'net_listeners:non-existent' not found."}
725 +{"status":404,"errorMessage":"job not found."}
726 FUNCTION_RESULT_END
727 `,
728 }
@@ -744,7 +766,7 @@ FUNCTION_RESULT_BEGIN 1-add 202 application/json
766 {"status":202,"message":""}
767 FUNCTION_RESULT_END
768
747 -CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
769 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
770
771 FUNCTION_RESULT_BEGIN 2-remove 200 application/json
772 {"status":200,"message":""}
@@ -784,7 +806,7 @@ FUNCTION_RESULT_BEGIN 1-add 202 application/json
806 {"status":202,"message":""}
807 FUNCTION_RESULT_END
808
787 -CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
809 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
810
811 FUNCTION_RESULT_BEGIN 2-enable 200 application/json
812 {"status":200,"message":""}
@@ -811,7 +833,7 @@ CONFIG test:sd:net_listeners:test-job delete
833 },
834 wantDyncfg: `
835 FUNCTION_RESULT_BEGIN 1-remove 404 application/json
814 -{"status":404,"errorMessage":"Config 'net_listeners:non-existent' not found."}
836 +{"status":404,"errorMessage":"job not found."}
837 FUNCTION_RESULT_END
838 `,
839 }
@@ -895,16 +917,15 @@ func TestServiceDiscovery_DyncfgFileConfig(t *testing.T) {
917 createSim: func() *dyncfgSim {
918 return &dyncfgSim{
919 do: func(sd *ServiceDiscovery) {
898 - // Manually add a file-based config to exposedConfigs
920 + // Manually add a file-based config to exposed cache
921 cfg := sdConfig{
922 "name": "file-config",
923 ikeyDiscovererType: DiscovererNetListeners,
924 ikeyPipelineKey: "/etc/netdata/sd/test.conf",
925 ikeySource: "/etc/netdata/sd/test.conf",
926 ikeySourceType: "file",
905 - ikeyStatus: dyncfg.StatusRunning,
927 }
907 - sd.exposedConfigs.add(cfg)
928 + sd.exposed.Add(&dyncfg.Entry[sdConfig]{Cfg: cfg, Status: dyncfg.StatusRunning})
929
930 // Try to remove
931 sendDyncfgCmd(sd, "1-remove",
@@ -921,7 +942,7 @@ func TestServiceDiscovery_DyncfgFileConfig(t *testing.T) {
942 },
943 wantDyncfg: `
944 FUNCTION_RESULT_BEGIN 1-remove 405 application/json
924 -{"status":405,"errorMessage":"Cannot remove non-dyncfg configs. Source type: file"}
945 +{"status":405,"errorMessage":"removing jobs of type 'file' is not supported, only 'dyncfg' jobs can be removed."}
946 FUNCTION_RESULT_END
947 `,
948 }
@@ -967,7 +988,7 @@ FUNCTION_RESULT_BEGIN 1-add 202 application/json
988 {"status":202,"message":""}
989 FUNCTION_RESULT_END
990
970 -CONFIG test:sd:docker:docker-test create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
991 +CONFIG test:sd:docker:docker-test create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
992 `,
993 }
994 },
@@ -1072,7 +1093,7 @@ FUNCTION_RESULT_BEGIN 1-add 202 application/json
1093 {"status":202,"message":""}
1094 FUNCTION_RESULT_END
1095
1075 -CONFIG test:sd:k8s:k8s-test create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
1096 +CONFIG test:sd:k8s:k8s-test create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
1097 `,
1098 }
1099 },
@@ -1176,7 +1197,7 @@ FUNCTION_RESULT_BEGIN 1-add 202 application/json
1197 {"status":202,"message":""}
1198 FUNCTION_RESULT_END
1199
1179 -CONFIG test:sd:snmp:snmp-test create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
1200 +CONFIG test:sd:snmp:snmp-test create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
1201 `,
1202 }
1203 },
@@ -1305,7 +1326,7 @@ FUNCTION_RESULT_BEGIN 1-add 202 application/json
1326 {"status":202,"message":""}
1327 FUNCTION_RESULT_END
1328
1308 -CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
1329 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
1330
1331 FUNCTION_RESULT_BEGIN 2-enable 200 application/json
1332 {"status":200,"message":""}
@@ -1623,13 +1644,13 @@ FUNCTION_RESULT_BEGIN 1-add 202 application/json
1644 {"status":202,"message":""}
1645 FUNCTION_RESULT_END
1646
1626 -CONFIG test:sd:net_listeners:job1 create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
1647 +CONFIG test:sd:net_listeners:job1 create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
1648
1649 FUNCTION_RESULT_BEGIN 2-add 202 application/json
1650 {"status":202,"message":""}
1651 FUNCTION_RESULT_END
1652
1632 -CONFIG test:sd:net_listeners:job2 create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
1653 +CONFIG test:sd:net_listeners:job2 create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
1654
1655 FUNCTION_RESULT_BEGIN 3-enable 200 application/json
1656 {"status":200,"message":""}
@@ -1687,10 +1708,9 @@ func TestServiceDiscovery_DyncfgPriority(t *testing.T) {
1708 ikeyPipelineKey: "/etc/netdata/sd.d/test.conf",
1709 ikeySource: "/etc/netdata/sd.d/test.conf",
1710 ikeySourceType: confgroup.TypeUser,
1690 - ikeyStatus: dyncfg.StatusRunning,
1711 }
1692 - sd.seenConfigs.add(fileCfg)
1693 - sd.exposedConfigs.add(fileCfg)
1712 + sd.seen.Add(fileCfg)
1713 + sd.exposed.Add(&dyncfg.Entry[sdConfig]{Cfg: fileCfg, Status: dyncfg.StatusRunning})
1714
1715 // Start the pipeline to simulate running state
1716 pipelineCfg := pipeline.Config{Name: "test-job"}
@@ -1733,10 +1753,9 @@ func TestServiceDiscovery_DyncfgPriority(t *testing.T) {
1753 ikeyPipelineKey: "/usr/lib/netdata/conf.d/sd/test.conf",
1754 ikeySource: "/usr/lib/netdata/conf.d/sd/test.conf",
1755 ikeySourceType: confgroup.TypeStock,
1736 - ikeyStatus: dyncfg.StatusAccepted,
1756 }
1738 - sd.seenConfigs.add(fileCfg)
1739 - sd.exposedConfigs.add(fileCfg)
1757 + sd.seen.Add(fileCfg)
1758 + sd.exposed.Add(&dyncfg.Entry[sdConfig]{Cfg: fileCfg, Status: dyncfg.StatusAccepted})
1759
1760 // Dyncfg add with same name - should replace stock config
1761 sendDyncfgCmd(sd, "1-add",
@@ -1775,10 +1794,9 @@ func TestServiceDiscovery_DyncfgPriority(t *testing.T) {
1794 ikeyPipelineKey: "dyncfg:net_listeners:test-job",
1795 ikeySource: "type=dyncfg,user=admin",
1796 ikeySourceType: confgroup.TypeDyncfg,
1778 - ikeyStatus: dyncfg.StatusRunning,
1797 }
1780 - sd.seenConfigs.add(dyncfgCfg)
1781 - sd.exposedConfigs.add(dyncfgCfg)
1798 + sd.seen.Add(dyncfgCfg)
1799 + sd.exposed.Add(&dyncfg.Entry[sdConfig]{Cfg: dyncfgCfg, Status: dyncfg.StatusRunning})
1800
1801 // Start the pipeline to simulate running state
1802 pipelineCfg := pipeline.Config{Name: "test-job"}
@@ -1855,7 +1873,7 @@ FUNCTION_RESULT_BEGIN 1-add 202 application/json
1873 {"status":202,"message":""}
1874 FUNCTION_RESULT_END
1875
1858 -CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get test enable disable update userconfig remove' 0x0000 0x0000
1876 +CONFIG test:sd:net_listeners:test-job create accepted job /collectors/test/ServiceDiscovery dyncfg 'type=dyncfg,user=test' 'schema get enable disable update test userconfig remove' 0x0000 0x0000
1877
1878 FUNCTION_RESULT_BEGIN 2-enable 200 application/json
1879 {"status":200,"message":""}
@@ -1900,10 +1918,9 @@ func TestServiceDiscovery_DyncfgUpdateFailedState(t *testing.T) {
1918 ikeyPipelineKey: "dyncfg:net_listeners:test-job",
1919 ikeySource: "type=dyncfg,user=test",
1920 ikeySourceType: confgroup.TypeDyncfg,
1903 - ikeyStatus: dyncfg.StatusFailed,
1921 }
1905 - sd.seenConfigs.add(failedCfg)
1906 - sd.exposedConfigs.add(failedCfg)
1922 + sd.seen.Add(failedCfg)
1923 + sd.exposed.Add(&dyncfg.Entry[sdConfig]{Cfg: failedCfg, Status: dyncfg.StatusFailed})
1924
1925 // Update should restart the pipeline
1926 sendDyncfgCmd(sd, "1-update",
@@ -1951,7 +1968,6 @@ func TestServiceDiscovery_DyncfgEnableFromFailed(t *testing.T) {
1968 ikeyPipelineKey: "dyncfg:net_listeners:test-job",
1969 ikeySource: "type=dyncfg,user=test",
1970 ikeySourceType: confgroup.TypeDyncfg,
1954 - ikeyStatus: dyncfg.StatusFailed,
1971 "discoverer": map[string]any{
1972 "net_listeners": map[string]any{},
1973 },
@@ -1959,8 +1975,8 @@ func TestServiceDiscovery_DyncfgEnableFromFailed(t *testing.T) {
1975 map[string]any{"id": "test-rule", "match": "true"},
1976 },
1977 }
1962 - sd.seenConfigs.add(failedCfg)
1963 - sd.exposedConfigs.add(failedCfg)
1978 + sd.seen.Add(failedCfg)
1979 + sd.exposed.Add(&dyncfg.Entry[sdConfig]{Cfg: failedCfg, Status: dyncfg.StatusFailed})
1980
1981 // Enable should start the pipeline
1982 sendDyncfgCmd(sd, "1-enable",
@@ -2011,7 +2027,6 @@ func TestServiceDiscovery_DyncfgConversionUpdate(t *testing.T) {
2027 ikeyPipelineKey: "/etc/netdata/sd.d/test.conf",
2028 ikeySource: "/etc/netdata/sd.d/test.conf",
2029 ikeySourceType: confgroup.TypeUser,
2014 - ikeyStatus: dyncfg.StatusRunning,
2030 "discoverer": map[string]any{
2031 "net_listeners": map[string]any{},
2032 },
@@ -2019,8 +2034,8 @@ func TestServiceDiscovery_DyncfgConversionUpdate(t *testing.T) {
2034 map[string]any{"id": "test-rule", "match": "true"},
2035 },
2036 }
2022 - sd.seenConfigs.add(fileCfg)
2023 - sd.exposedConfigs.add(fileCfg)
2037 + sd.seen.Add(fileCfg)
2038 + sd.exposed.Add(&dyncfg.Entry[sdConfig]{Cfg: fileCfg, Status: dyncfg.StatusRunning})
2039
2040 // Start the file pipeline
2041 pipelineCfg := pipeline.Config{Name: "test-job"}
@@ -2041,8 +2056,7 @@ func TestServiceDiscovery_DyncfgConversionUpdate(t *testing.T) {
2056 },
2057 wantRunning: []string{"dyncfg:net_listeners:test-job"}, // New pipeline key
2058 wantDyncfgFunc: func(t *testing.T, got string) {
2044 - // Should see: delete old job, create new dyncfg job
2045 - assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job delete")
2059 + // ConfigCreate acts as upsert (no delete needed)
2060 assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job create running job")
2061 assert.Contains(t, got, "dyncfg") // New source type
2062 assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-update 200 application/json")
@@ -2064,7 +2078,6 @@ func TestServiceDiscovery_DyncfgConversionUpdate(t *testing.T) {
2078 ikeyPipelineKey: "/etc/netdata/sd.d/test.conf",
2079 ikeySource: "/etc/netdata/sd.d/test.conf",
2080 ikeySourceType: confgroup.TypeUser,
2067 - ikeyStatus: dyncfg.StatusDisabled,
2081 "discoverer": map[string]any{
2082 "net_listeners": map[string]any{},
2083 },
@@ -2072,8 +2085,8 @@ func TestServiceDiscovery_DyncfgConversionUpdate(t *testing.T) {
2085 map[string]any{"id": "test-rule", "match": "true"},
2086 },
2087 }
2075 - sd.seenConfigs.add(fileCfg)
2076 - sd.exposedConfigs.add(fileCfg)
2088 + sd.seen.Add(fileCfg)
2089 + sd.exposed.Add(&dyncfg.Entry[sdConfig]{Cfg: fileCfg, Status: dyncfg.StatusDisabled})
2090
2091 // Update via dyncfg - should convert but stay disabled
2092 sendDyncfgCmd(sd, "1-update",
@@ -2090,7 +2103,7 @@ func TestServiceDiscovery_DyncfgConversionUpdate(t *testing.T) {
2103 },
2104 wantRunning: []string{}, // Not running
2105 wantDyncfgFunc: func(t *testing.T, got string) {
2093 - assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job delete")
2106 + // ConfigCreate acts as upsert (no delete needed)
2107 assert.Contains(t, got, "CONFIG test:sd:net_listeners:test-job create disabled job")
2108 assert.Contains(t, got, "FUNCTION_RESULT_BEGIN 1-update 200 application/json")
2109 },
@@ -2184,16 +2197,15 @@ func TestServiceDiscovery_DyncfgFileRemovalWithDyncfgOverride(t *testing.T) {
2197 createSim: func() *dyncfgSim {
2198 return &dyncfgSim{
2199 do: func(sd *ServiceDiscovery) {
2187 - // Add file config to seenConfigs (simulating it was seen from file)
2200 + // Add file config to seen cache (simulating it was seen from file)
2201 fileCfg := sdConfig{
2202 "name": "test-job",
2203 ikeyDiscovererType: DiscovererNetListeners,
2204 ikeyPipelineKey: "/etc/netdata/sd.d/test.conf",
2205 ikeySource: "/etc/netdata/sd.d/test.conf",
2206 ikeySourceType: confgroup.TypeUser,
2194 - ikeyStatus: dyncfg.StatusAccepted,
2207 }
2196 - sd.seenConfigs.add(fileCfg)
2208 + sd.seen.Add(fileCfg)
2209
2210 // Add dyncfg override (higher priority) to both caches
2211 dyncfgCfg := sdConfig{
@@ -2202,10 +2214,9 @@ func TestServiceDiscovery_DyncfgFileRemovalWithDyncfgOverride(t *testing.T) {
2214 ikeyPipelineKey: "dyncfg:net_listeners:test-job",
2215 ikeySource: "type=dyncfg,user=test",
2216 ikeySourceType: confgroup.TypeDyncfg,
2205 - ikeyStatus: dyncfg.StatusRunning,
2217 }
2207 - sd.seenConfigs.add(dyncfgCfg)
2208 - sd.exposedConfigs.add(dyncfgCfg)
2218 + sd.seen.Add(dyncfgCfg)
2219 + sd.exposed.Add(&dyncfg.Entry[sdConfig]{Cfg: dyncfgCfg, Status: dyncfg.StatusRunning})
2220
2221 // Start the dyncfg pipeline
2222 pipelineCfg := pipeline.Config{Name: "test-job"}
src/go/plugin/go.d/agent/discovery/sd/sd.go
+81 -41
@@ -4,11 +4,13 @@ package sd
4
5 import (
6 "context"
7 + "fmt"
8 "log/slog"
9 "os"
10 "sync"
11
12 "github.com/netdata/netdata/go/plugins/logger"
13 + "github.com/netdata/netdata/go/plugins/pkg/executable"
14 "github.com/netdata/netdata/go/plugins/pkg/multipath"
15 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
16 "github.com/netdata/netdata/go/plugins/pkg/safewriter"
@@ -45,13 +47,33 @@ func NewServiceDiscovery(cfg Config) (*ServiceDiscovery, error) {
47 configDefaults: cfg.ConfigDefaults,
48 fnReg: cfg.FnReg,
49 dyncfgApi: dyncfg.NewResponder(netdataapi.New(safewriter.Stdout)),
48 - seenConfigs: newSeenSDConfigs(),
49 - exposedConfigs: newExposedSDConfigs(),
50 + seen: dyncfg.NewSeenCache[sdConfig](),
51 + exposed: dyncfg.NewExposedCache[sdConfig](),
52 dyncfgCh: make(chan dyncfg.Function, 1),
53 newPipeline: func(config pipeline.Config) (sdPipeline, error) {
54 return pipeline.New(config)
55 },
56 }
57 + d.sdCb = &sdCallbacks{sd: d}
58 + d.handler = dyncfg.NewHandler(dyncfg.HandlerOpts[sdConfig]{
59 + Logger: d.Logger,
60 + API: d.dyncfgApi,
61 + Seen: d.seen,
62 + Exposed: d.exposed,
63 + Callbacks: d.sdCb,
64 +
65 + Path: fmt.Sprintf(dyncfgSDPath, executable.Name),
66 + EnableFailCode: 422,
67 + JobCommands: []dyncfg.Command{
68 + dyncfg.CommandSchema,
69 + dyncfg.CommandGet,
70 + dyncfg.CommandEnable,
71 + dyncfg.CommandDisable,
72 + dyncfg.CommandUpdate,
73 + dyncfg.CommandTest,
74 + dyncfg.CommandUserconfig,
75 + },
76 + })
77
78 return d, nil
79 }
@@ -65,8 +87,10 @@ type (
87 configDefaults confgroup.Registry
88 fnReg functions.Registry
89 dyncfgApi *dyncfg.Responder
68 - seenConfigs *seenSDConfigs // All discovered configs by UID
69 - exposedConfigs *exposedSDConfigs // Configs exposed to dyncfg by Key
90 + seen *dyncfg.SeenCache[sdConfig]
91 + exposed *dyncfg.ExposedCache[sdConfig]
92 + handler *dyncfg.Handler[sdConfig]
93 + sdCb *sdCallbacks
94 dyncfgCh chan dyncfg.Function
95 newPipeline func(config pipeline.Config) (sdPipeline, error)
96
@@ -87,6 +111,14 @@ type (
111 }
112 )
113
114 +// SetDyncfgResponder allows overriding the default responder (e.g., to silence output in tests).
115 +func (d *ServiceDiscovery) SetDyncfgResponder(api *dyncfg.Responder) {
116 + if api != nil {
117 + d.dyncfgApi = api
118 + d.handler.SetAPI(api)
119 + }
120 +}
121 +
122 func (d *ServiceDiscovery) String() string {
123 return "service discovery"
124 }
@@ -161,7 +193,15 @@ func (d *ServiceDiscovery) run(ctx context.Context) {
193 }
194
195 func (d *ServiceDiscovery) removePipeline(conf confFile) {
164 - seenCfgs := d.seenConfigs.lookupBySource(conf.source)
196 + // Collect configs from this source (can't call Remove inside ForEach)
197 + var seenCfgs []sdConfig
198 + d.seen.ForEach(func(_ string, cfg sdConfig) bool {
199 + if cfg.Source() == conf.source {
200 + seenCfgs = append(seenCfgs, cfg)
201 + }
202 + return true
203 + })
204 +
205 if len(seenCfgs) == 0 {
206 return
207 }
@@ -170,11 +210,11 @@ func (d *ServiceDiscovery) removePipeline(conf confFile) {
210
211 for _, scfg := range seenCfgs {
212 // Remove from seen cache
173 - d.seenConfigs.remove(scfg)
213 + d.seen.Remove(scfg)
214
215 // Check if this was the exposed config
176 - ecfg, ok := d.exposedConfigs.lookup(scfg)
177 - if !ok || scfg.UID() != ecfg.UID() {
216 + entry, ok := d.exposed.LookupByKey(scfg.ExposedKey())
217 + if !ok || entry.Cfg.UID() != scfg.UID() {
218 // Not exposed or different config is exposed - skip dyncfg remove
219 continue
220 }
@@ -184,9 +224,9 @@ func (d *ServiceDiscovery) removePipeline(conf confFile) {
224 d.mgr.Stop(scfg.PipelineKey())
225 }
226
187 - d.exposedConfigs.remove(scfg)
227 + d.exposed.Remove(scfg)
228 if !disableDyncfg {
189 - d.dyncfgSDJobRemove(scfg.DiscovererType(), scfg.Name())
229 + d.handler.NotifyJobRemove(scfg)
230 }
231 }
232 }
@@ -227,25 +267,24 @@ func (d *ServiceDiscovery) addConfig(ctx context.Context, scfg sdConfig) {
267 // For file sources: One file = one config. If the file previously provided a different config,
268 // remove the old one first. This handles the case where a file config name changes.
269 if scfg.SourceType() != confgroup.TypeDyncfg {
230 - d.removeOldConfigsFromSource(scfg.Source(), scfg.Key())
270 + d.removeOldConfigsFromSource(scfg.Source(), scfg.ExposedKey())
271 }
272
273 // Always add to seen cache
234 - d.seenConfigs.add(scfg)
274 + d.seen.Add(scfg)
275
276 // Check if there's an existing exposed config with the same key
237 - ecfg, exists := d.exposedConfigs.lookup(scfg)
277 + entry, exists := d.exposed.LookupByKey(scfg.ExposedKey())
278
279 if !exists {
280 // No existing config - expose this one
241 - scfg.SetStatus(dyncfg.StatusAccepted)
242 - d.exposedConfigs.add(scfg)
281 + d.exposed.Add(&dyncfg.Entry[sdConfig]{Cfg: scfg, Status: dyncfg.StatusAccepted})
282
283 if disableDyncfg {
284 // Dyncfg disabled - start pipeline directly
285 d.startPipelineDirectly(ctx, scfg)
286 } else {
248 - d.dyncfgSDJobCreate(scfg.DiscovererType(), scfg.Name(), scfg.SourceType(), scfg.Source(), scfg.Status())
287 + d.handler.NotifyJobCreate(scfg, dyncfg.StatusAccepted)
288 if isTerminal || d.dyncfgCh == nil {
289 // Auto-enable in terminal mode or tests
290 d.autoEnableConfig(scfg)
@@ -258,33 +297,32 @@ func (d *ServiceDiscovery) addConfig(ctx context.Context, scfg sdConfig) {
297 }
298
299 // Existing config found - apply priority rules
261 - sp, ep := scfg.SourceTypePriority(), ecfg.SourceTypePriority()
300 + sp, ep := scfg.SourceTypePriority(), entry.Cfg.SourceTypePriority()
301
302 // Higher priority wins. If same priority and existing is running, keep existing (stability).
264 - if ep > sp || (ep == sp && ecfg.Status() == dyncfg.StatusRunning) {
303 + if ep > sp || (ep == sp && entry.Status == dyncfg.StatusRunning) {
304 d.Debugf("config '%s': keeping existing (priority: existing=%d new=%d, status=%s)",
266 - scfg.Key(), ep, sp, ecfg.Status())
305 + scfg.ExposedKey(), ep, sp, entry.Status)
306 return
307 }
308
309 // New config wins - stop existing if running
271 - d.Infof("config '%s': replacing existing (priority: existing=%d new=%d)", scfg.Key(), ep, sp)
310 + d.Infof("config '%s': replacing existing (priority: existing=%d new=%d)", scfg.ExposedKey(), ep, sp)
311
273 - if ecfg.Status() == dyncfg.StatusRunning {
274 - d.mgr.Stop(ecfg.PipelineKey())
312 + if entry.Status == dyncfg.StatusRunning {
313 + d.mgr.Stop(entry.Cfg.PipelineKey())
314 }
315
316 // Replace in exposed cache
278 - scfg.SetStatus(dyncfg.StatusAccepted)
279 - d.exposedConfigs.add(scfg)
317 + d.exposed.Add(&dyncfg.Entry[sdConfig]{Cfg: scfg, Status: dyncfg.StatusAccepted})
318
319 if disableDyncfg {
320 // Dyncfg disabled - start pipeline directly
321 d.startPipelineDirectly(ctx, scfg)
322 } else {
323 // Update dyncfg (remove old, create new with new source)
286 - d.dyncfgSDJobRemove(ecfg.DiscovererType(), ecfg.Name())
287 - d.dyncfgSDJobCreate(scfg.DiscovererType(), scfg.Name(), scfg.SourceType(), scfg.Source(), scfg.Status())
324 + d.handler.NotifyJobRemove(entry.Cfg)
325 + d.handler.NotifyJobCreate(scfg, dyncfg.StatusAccepted)
326
327 if isTerminal || d.dyncfgCh == nil {
328 d.autoEnableConfig(scfg)
@@ -300,34 +338,34 @@ func (d *ServiceDiscovery) addConfig(ctx context.Context, scfg sdConfig) {
338 // PipelineManager.Start (which stops any existing pipeline with the same key).
339 // This ensures that if the new config fails to start, the old pipeline keeps running.
340 func (d *ServiceDiscovery) removeOldConfigsFromSource(source, newKey string) {
303 - oldCfgs := d.seenConfigs.lookupBySource(source)
341 + // Collect configs from this source (can't call Remove inside ForEach)
342 + var oldCfgs []sdConfig
343 + d.seen.ForEach(func(_ string, cfg sdConfig) bool {
344 + if cfg.Source() == source {
345 + oldCfgs = append(oldCfgs, cfg)
346 + }
347 + return true
348 + })
349 +
350 for _, oldCfg := range oldCfgs {
305 - if oldCfg.Key() == newKey {
351 + if oldCfg.ExposedKey() == newKey {
352 continue // Same config, skip
353 }
354
355 // Different config from same source - remove from caches
310 - d.seenConfigs.remove(oldCfg)
356 + d.seen.Remove(oldCfg)
357
358 // If it was exposed, remove from exposed cache and dyncfg
359 // But DON'T stop the pipeline - let the new config's enable handle that
314 - if ecfg, ok := d.exposedConfigs.lookup(oldCfg); ok && ecfg.UID() == oldCfg.UID() {
315 - d.exposedConfigs.remove(oldCfg)
360 + if entry, ok := d.exposed.LookupByKey(oldCfg.ExposedKey()); ok && entry.Cfg.UID() == oldCfg.UID() {
361 + d.exposed.Remove(oldCfg)
362 if !disableDyncfg {
317 - d.dyncfgSDJobRemove(oldCfg.DiscovererType(), oldCfg.Name())
363 + d.handler.NotifyJobRemove(oldCfg)
364 }
365 }
366 }
367 }
368
323 -// autoEnableConfig enables a config without waiting for netdata's enable command.
324 -func (d *ServiceDiscovery) autoEnableConfig(cfg sdConfig) {
325 - fn := dyncfg.NewFunction(functions.Function{
326 - Args: []string{d.dyncfgJobID(cfg.DiscovererType(), cfg.Name()), "enable"},
327 - })
328 - d.dyncfgCmdEnable(fn)
329 -}
330 -
369 // startPipelineDirectly starts a pipeline without dyncfg integration.
370 // Used when disableDyncfg is true.
371 func (d *ServiceDiscovery) startPipelineDirectly(ctx context.Context, cfg sdConfig) {
@@ -342,7 +380,9 @@ func (d *ServiceDiscovery) startPipelineDirectly(ctx context.Context, cfg sdConf
380 return
381 }
382
345 - d.exposedConfigs.updateStatus(cfg, dyncfg.StatusRunning)
383 + if entry, ok := d.exposed.LookupByKey(cfg.ExposedKey()); ok {
384 + entry.Status = dyncfg.StatusRunning
385 + }
386 }
387
388 // pipelineKeyFromSource extracts a pipeline key from a file source path.
src/go/plugin/go.d/agent/discovery/sd/sim_test.go
+62 -21
@@ -6,11 +6,13 @@ import (
6 "bytes"
7 "context"
8 "errors"
9 + "fmt"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/netdata/netdata/go/plugins/logger"
15 + "github.com/netdata/netdata/go/plugins/pkg/executable"
16 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
17 "github.com/netdata/netdata/go/plugins/pkg/safewriter"
18 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
@@ -54,11 +56,31 @@ func (sim *discoverySimExt) run(t *testing.T) {
56 confFiles: sim.configs,
57 ch: make(chan confFile),
58 },
57 - dyncfgApi: dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))),
58 - seenConfigs: newSeenSDConfigs(),
59 - exposedConfigs: newExposedSDConfigs(),
59 + dyncfgApi: dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))),
60 + seen: dyncfg.NewSeenCache[sdConfig](),
61 + exposed: dyncfg.NewExposedCache[sdConfig](),
62 // dyncfgCh is intentionally nil to trigger auto-enable in tests
63 }
64 + mgr.sdCb = &sdCallbacks{sd: mgr}
65 + mgr.handler = dyncfg.NewHandler(dyncfg.HandlerOpts[sdConfig]{
66 + Logger: mgr.Logger,
67 + API: mgr.dyncfgApi,
68 + Seen: mgr.seen,
69 + Exposed: mgr.exposed,
70 + Callbacks: mgr.sdCb,
71 +
72 + Path: fmt.Sprintf(dyncfgSDPath, executable.Name),
73 + EnableFailCode: 422,
74 + JobCommands: []dyncfg.Command{
75 + dyncfg.CommandSchema,
76 + dyncfg.CommandGet,
77 + dyncfg.CommandEnable,
78 + dyncfg.CommandDisable,
79 + dyncfg.CommandUpdate,
80 + dyncfg.CommandTest,
81 + dyncfg.CommandUserconfig,
82 + },
83 + })
84
85 in := make(chan<- []*confgroup.Group)
86 done := make(chan struct{})
@@ -72,21 +94,6 @@ func (sim *discoverySimExt) run(t *testing.T) {
94 if sim.wantPipelines != nil {
95 assert.Equalf(t, sim.wantPipelines, fact.pipelines, "pipelines mismatch")
96 }
75 -
76 - // Check exposed configs count
77 - if sim.wantExposedCount > 0 {
78 - assert.Equal(t, sim.wantExposedCount, mgr.exposedConfigs.count(), "exposed configs count")
79 - }
80 -
81 - // Check specific exposed configs
82 - for _, want := range sim.wantExposed {
83 - cfg, ok := mgr.exposedConfigs.lookup(newLookupConfig(want.discovererType, want.name))
84 - if !assert.Truef(t, ok, "exposed config '%s:%s' not found", want.discovererType, want.name) {
85 - continue
86 - }
87 - assert.Equal(t, want.sourceType, cfg.SourceType(), "exposed config '%s:%s' sourceType", want.discovererType, want.name)
88 - assert.Equal(t, want.status, cfg.Status(), "exposed config '%s:%s' status", want.discovererType, want.name)
89 - }
97 lock.Unlock()
98
99 cancel()
@@ -97,6 +104,20 @@ func (sim *discoverySimExt) run(t *testing.T) {
104 case <-time.After(timeout):
105 t.Errorf("sd failed to exit in %s", timeout)
106 }
107 +
108 + // Check exposed configs after SD goroutine has stopped (no race on entry.Status).
109 + // Caches survive shutdown — StopAll only stops pipelines, doesn't clear caches.
110 + if sim.wantExposedCount > 0 {
111 + assert.Equal(t, sim.wantExposedCount, mgr.exposed.Count(), "exposed configs count")
112 + }
113 + for _, want := range sim.wantExposed {
114 + entry, ok := mgr.exposed.LookupByKey(want.discovererType + ":" + want.name)
115 + if !assert.Truef(t, ok, "exposed config '%s:%s' not found", want.discovererType, want.name) {
116 + continue
117 + }
118 + assert.Equal(t, want.sourceType, entry.Cfg.SourceType(), "exposed config '%s:%s' sourceType", want.discovererType, want.name)
119 + assert.Equal(t, want.status, entry.Status, "exposed config '%s:%s' status", want.discovererType, want.name)
120 + }
121 }
122
123 func (sim *discoverySim) run(t *testing.T) {
@@ -111,12 +132,32 @@ func (sim *discoverySim) run(t *testing.T) {
132 confFiles: sim.configs,
133 ch: make(chan confFile),
134 },
114 - dyncfgApi: dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))),
115 - seenConfigs: newSeenSDConfigs(),
116 - exposedConfigs: newExposedSDConfigs(),
135 + dyncfgApi: dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))),
136 + seen: dyncfg.NewSeenCache[sdConfig](),
137 + exposed: dyncfg.NewExposedCache[sdConfig](),
138 // dyncfgCh is intentionally nil to trigger auto-enable in tests
139 // (simulates terminal mode where netdata is not available)
140 }
141 + mgr.sdCb = &sdCallbacks{sd: mgr}
142 + mgr.handler = dyncfg.NewHandler(dyncfg.HandlerOpts[sdConfig]{
143 + Logger: mgr.Logger,
144 + API: mgr.dyncfgApi,
145 + Seen: mgr.seen,
146 + Exposed: mgr.exposed,
147 + Callbacks: mgr.sdCb,
148 +
149 + Path: fmt.Sprintf(dyncfgSDPath, executable.Name),
150 + EnableFailCode: 422,
151 + JobCommands: []dyncfg.Command{
152 + dyncfg.CommandSchema,
153 + dyncfg.CommandGet,
154 + dyncfg.CommandEnable,
155 + dyncfg.CommandDisable,
156 + dyncfg.CommandUpdate,
157 + dyncfg.CommandTest,
158 + dyncfg.CommandUserconfig,
159 + },
160 + })
161
162 in := make(chan<- []*confgroup.Group)
163 done := make(chan struct{})
src/go/plugin/go.d/agent/dyncfg/cache.go new
+126
@@ -0,0 +1,126 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package dyncfg
4 +
5 +import "sync"
6 +
7 +// Config is the constraint interface for configs stored in handler caches.
8 +type Config interface {
9 + UID() string // SeenCache key (globally unique per source)
10 + ExposedKey() string // ExposedCache key (one per logical name)
11 + SourceType() string // "dyncfg", "user", "stock"
12 + SourceTypePriority() int // dyncfg=16, user=8, stock=2
13 + Source() string // source identifier
14 + Hash() uint64 // content hash for change detection
15 +}
16 +
17 +// Entry pairs a config with its current dyncfg status.
18 +type Entry[C Config] struct {
19 + Cfg C
20 + Status Status
21 +}
22 +
23 +// SeenCache stores all discovered configs keyed by UID().
24 +// Thread-safe: SD dispatches read-only commands concurrently.
25 +type SeenCache[C Config] struct {
26 + mux sync.RWMutex
27 + items map[string]C
28 +}
29 +
30 +func NewSeenCache[C Config]() *SeenCache[C] {
31 + return &SeenCache[C]{items: make(map[string]C)}
32 +}
33 +
34 +func (c *SeenCache[C]) Add(cfg C) {
35 + c.mux.Lock()
36 + defer c.mux.Unlock()
37 + c.items[cfg.UID()] = cfg
38 +}
39 +
40 +func (c *SeenCache[C]) Remove(cfg C) {
41 + c.mux.Lock()
42 + defer c.mux.Unlock()
43 + delete(c.items, cfg.UID())
44 +}
45 +
46 +func (c *SeenCache[C]) Lookup(cfg C) (C, bool) {
47 + return c.LookupByUID(cfg.UID())
48 +}
49 +
50 +func (c *SeenCache[C]) LookupByUID(uid string) (C, bool) {
51 + c.mux.RLock()
52 + defer c.mux.RUnlock()
53 + v, ok := c.items[uid]
54 + return v, ok
55 +}
56 +
57 +func (c *SeenCache[C]) Count() int {
58 + c.mux.RLock()
59 + defer c.mux.RUnlock()
60 + return len(c.items)
61 +}
62 +
63 +// ForEach iterates over all entries. Return false to stop iteration.
64 +func (c *SeenCache[C]) ForEach(fn func(uid string, cfg C) bool) {
65 + c.mux.RLock()
66 + defer c.mux.RUnlock()
67 + for uid, cfg := range c.items {
68 + if !fn(uid, cfg) {
69 + return
70 + }
71 + }
72 +}
73 +
74 +// ExposedCache stores active config+status per logical name, keyed by ExposedKey().
75 +// LookupByKey returns a pointer to the stored Entry — mutations to Status
76 +// are visible through the pointer. The mutex protects map access only.
77 +// Entry.Status is written exclusively by the serialized command goroutine
78 +// (via Handler.Cmd*) and is not read by any concurrent code path in production.
79 +// Concurrent read-only commands (schema/get/test/userconfig) access only
80 +// Entry.Cfg, which is immutable after creation.
81 +type ExposedCache[C Config] struct {
82 + mux sync.RWMutex
83 + items map[string]*Entry[C]
84 +}
85 +
86 +func NewExposedCache[C Config]() *ExposedCache[C] {
87 + return &ExposedCache[C]{items: make(map[string]*Entry[C])}
88 +}
89 +
90 +// Add inserts or overwrites an entry by cfg.ExposedKey().
91 +func (c *ExposedCache[C]) Add(entry *Entry[C]) {
92 + c.mux.Lock()
93 + defer c.mux.Unlock()
94 + c.items[entry.Cfg.ExposedKey()] = entry
95 +}
96 +
97 +func (c *ExposedCache[C]) Remove(cfg C) {
98 + c.mux.Lock()
99 + defer c.mux.Unlock()
100 + delete(c.items, cfg.ExposedKey())
101 +}
102 +
103 +// LookupByKey returns a pointer to the stored entry.
104 +func (c *ExposedCache[C]) LookupByKey(key string) (*Entry[C], bool) {
105 + c.mux.RLock()
106 + defer c.mux.RUnlock()
107 + v, ok := c.items[key]
108 + return v, ok
109 +}
110 +
111 +// ForEach iterates over all entries. Return false to stop iteration.
112 +func (c *ExposedCache[C]) ForEach(fn func(key string, entry *Entry[C]) bool) {
113 + c.mux.RLock()
114 + defer c.mux.RUnlock()
115 + for k, e := range c.items {
116 + if !fn(k, e) {
117 + return
118 + }
119 + }
120 +}
121 +
122 +func (c *ExposedCache[C]) Count() int {
123 + c.mux.RLock()
124 + defer c.mux.RUnlock()
125 + return len(c.items)
126 +}
src/go/plugin/go.d/agent/dyncfg/cache_test.go new
+191
@@ -0,0 +1,191 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package dyncfg
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/assert"
9 + "github.com/stretchr/testify/require"
10 +)
11 +
12 +type testConfig struct {
13 + uid string
14 + key string
15 + sourceType string
16 + priority int
17 + source string
18 + hash uint64
19 +}
20 +
21 +func (c testConfig) UID() string { return c.uid }
22 +func (c testConfig) ExposedKey() string { return c.key }
23 +func (c testConfig) SourceType() string { return c.sourceType }
24 +func (c testConfig) SourceTypePriority() int { return c.priority }
25 +func (c testConfig) Source() string { return c.source }
26 +func (c testConfig) Hash() uint64 { return c.hash }
27 +
28 +func TestSeenCache_AddAndLookup(t *testing.T) {
29 + c := NewSeenCache[testConfig]()
30 + cfg := testConfig{uid: "uid1", key: "key1"}
31 +
32 + c.Add(cfg)
33 +
34 + got, ok := c.Lookup(cfg)
35 + require.True(t, ok)
36 + assert.Equal(t, cfg, got)
37 +}
38 +
39 +func TestSeenCache_LookupByUID(t *testing.T) {
40 + c := NewSeenCache[testConfig]()
41 + cfg := testConfig{uid: "uid1", key: "key1"}
42 +
43 + c.Add(cfg)
44 +
45 + got, ok := c.LookupByUID("uid1")
46 + require.True(t, ok)
47 + assert.Equal(t, cfg, got)
48 +
49 + _, ok = c.LookupByUID("nonexistent")
50 + assert.False(t, ok)
51 +}
52 +
53 +func TestSeenCache_Remove(t *testing.T) {
54 + c := NewSeenCache[testConfig]()
55 + cfg := testConfig{uid: "uid1", key: "key1"}
56 +
57 + c.Add(cfg)
58 + c.Remove(cfg)
59 +
60 + _, ok := c.Lookup(cfg)
61 + assert.False(t, ok)
62 +}
63 +
64 +func TestSeenCache_AddOverwrites(t *testing.T) {
65 + c := NewSeenCache[testConfig]()
66 + cfg1 := testConfig{uid: "uid1", key: "key1", hash: 100}
67 + cfg2 := testConfig{uid: "uid1", key: "key1", hash: 200}
68 +
69 + c.Add(cfg1)
70 + c.Add(cfg2)
71 +
72 + got, ok := c.LookupByUID("uid1")
73 + require.True(t, ok)
74 + assert.Equal(t, uint64(200), got.Hash())
75 +}
76 +
77 +func TestSeenCache_RemoveNonexistent(t *testing.T) {
78 + c := NewSeenCache[testConfig]()
79 + cfg := testConfig{uid: "uid1"}
80 +
81 + // Should not panic.
82 + c.Remove(cfg)
83 +}
84 +
85 +func TestExposedCache_AddAndLookup(t *testing.T) {
86 + c := NewExposedCache[testConfig]()
87 + cfg := testConfig{uid: "uid1", key: "key1"}
88 + entry := &Entry[testConfig]{Cfg: cfg, Status: StatusAccepted}
89 +
90 + c.Add(entry)
91 +
92 + got, ok := c.LookupByKey("key1")
93 + require.True(t, ok)
94 + assert.Equal(t, StatusAccepted, got.Status)
95 + assert.Equal(t, cfg, got.Cfg)
96 +}
97 +
98 +func TestExposedCache_LookupByKey_NotFound(t *testing.T) {
99 + c := NewExposedCache[testConfig]()
100 +
101 + _, ok := c.LookupByKey("nonexistent")
102 + assert.False(t, ok)
103 +}
104 +
105 +func TestExposedCache_Remove(t *testing.T) {
106 + c := NewExposedCache[testConfig]()
107 + cfg := testConfig{uid: "uid1", key: "key1"}
108 + entry := &Entry[testConfig]{Cfg: cfg, Status: StatusAccepted}
109 +
110 + c.Add(entry)
111 + c.Remove(cfg)
112 +
113 + _, ok := c.LookupByKey("key1")
114 + assert.False(t, ok)
115 +}
116 +
117 +func TestExposedCache_AddOverwritesByKey(t *testing.T) {
118 + c := NewExposedCache[testConfig]()
119 + cfg1 := testConfig{uid: "uid1", key: "key1"}
120 + cfg2 := testConfig{uid: "uid2", key: "key1"} // same Key, different UID
121 + entry1 := &Entry[testConfig]{Cfg: cfg1, Status: StatusRunning}
122 + entry2 := &Entry[testConfig]{Cfg: cfg2, Status: StatusAccepted}
123 +
124 + c.Add(entry1)
125 + c.Add(entry2)
126 +
127 + got, ok := c.LookupByKey("key1")
128 + require.True(t, ok)
129 + assert.Equal(t, "uid2", got.Cfg.UID())
130 + assert.Equal(t, StatusAccepted, got.Status)
131 +}
132 +
133 +func TestExposedCache_PointerMutationVisible(t *testing.T) {
134 + c := NewExposedCache[testConfig]()
135 + cfg := testConfig{uid: "uid1", key: "key1"}
136 + entry := &Entry[testConfig]{Cfg: cfg, Status: StatusAccepted}
137 +
138 + c.Add(entry)
139 +
140 + got, ok := c.LookupByKey("key1")
141 + require.True(t, ok)
142 +
143 + // Mutating through the returned pointer updates the cache.
144 + got.Status = StatusRunning
145 +
146 + got2, _ := c.LookupByKey("key1")
147 + assert.Equal(t, StatusRunning, got2.Status)
148 +}
149 +
150 +func TestExposedCache_Count(t *testing.T) {
151 + c := NewExposedCache[testConfig]()
152 + assert.Equal(t, 0, c.Count())
153 +
154 + c.Add(&Entry[testConfig]{Cfg: testConfig{key: "a"}})
155 + c.Add(&Entry[testConfig]{Cfg: testConfig{key: "b"}})
156 + assert.Equal(t, 2, c.Count())
157 +
158 + c.Remove(testConfig{key: "a"})
159 + assert.Equal(t, 1, c.Count())
160 +}
161 +
162 +func TestExposedCache_ForEach(t *testing.T) {
163 + c := NewExposedCache[testConfig]()
164 + c.Add(&Entry[testConfig]{Cfg: testConfig{key: "a"}, Status: StatusRunning})
165 + c.Add(&Entry[testConfig]{Cfg: testConfig{key: "b"}, Status: StatusDisabled})
166 +
167 + keys := make(map[string]Status)
168 + c.ForEach(func(key string, entry *Entry[testConfig]) bool {
169 + keys[key] = entry.Status
170 + return true
171 + })
172 +
173 + assert.Len(t, keys, 2)
174 + assert.Equal(t, StatusRunning, keys["a"])
175 + assert.Equal(t, StatusDisabled, keys["b"])
176 +}
177 +
178 +func TestExposedCache_ForEach_EarlyStop(t *testing.T) {
179 + c := NewExposedCache[testConfig]()
180 + c.Add(&Entry[testConfig]{Cfg: testConfig{key: "a"}})
181 + c.Add(&Entry[testConfig]{Cfg: testConfig{key: "b"}})
182 + c.Add(&Entry[testConfig]{Cfg: testConfig{key: "c"}})
183 +
184 + count := 0
185 + c.ForEach(func(_ string, _ *Entry[testConfig]) bool {
186 + count++
187 + return false // stop after first
188 + })
189 +
190 + assert.Equal(t, 1, count)
191 +}
src/go/plugin/go.d/agent/dyncfg/handler.go new
+449
@@ -0,0 +1,449 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package dyncfg
4 +
5 +import (
6 + "errors"
7 +
8 + "github.com/netdata/netdata/go/plugins/logger"
9 + "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
10 +)
11 +
12 +// Callbacks defines component-specific operations for the handler.
13 +type Callbacks[C Config] interface {
14 + // ExtractKey parses dyncfg function ID into cache key + job name.
15 + ExtractKey(fn Function) (key, name string, ok bool)
16 +
17 + // ParseAndValidate parses payload into a config with dyncfg metadata set.
18 + // Includes all validation (including heavy checks like module instantiation).
19 + ParseAndValidate(fn Function, name string) (C, error)
20 +
21 + // Start creates a work unit and starts it. Owns the full start lifecycle
22 + // including pre-start cleanup and post-fail retry scheduling.
23 + // Return CodedError to override EnableFailCode.
24 + // Used by CmdEnable and CmdUpdate (conversion only).
25 + Start(cfg C) error
26 +
27 + // Update handles non-conversion config updates (dyncfg->dyncfg).
28 + // Called after caches are already updated. SD uses mgr.Restart
29 + // for graceful transition; jobmgr uses Stop+Start.
30 + Update(oldCfg, newCfg C) error
31 +
32 + // Stop stops all work and cleans up all component state for a config.
33 + // Safe to call for non-running configs (all ops are no-ops).
34 + Stop(cfg C)
35 +
36 + // OnStatusChange is called after status transitions in enable/disable/update.
37 + // Not called in CmdAdd or CmdRemove.
38 + OnStatusChange(entry *Entry[C], oldStatus Status, fn Function)
39 +
40 + // ConfigID returns the dyncfg wire protocol ID for a config.
41 + ConfigID(cfg C) string
42 +}
43 +
44 +// CodedError allows callbacks to override the default response code.
45 +type CodedError interface {
46 + error
47 + Code() int
48 +}
49 +
50 +// HandlerOpts configures the handler with component-specific settings.
51 +type HandlerOpts[C Config] struct {
52 + Logger *logger.Logger
53 + API *Responder
54 + Seen *SeenCache[C]
55 + Exposed *ExposedCache[C]
56 + Callbacks Callbacks[C]
57 +
58 + Path string // dyncfg path (e.g. "/collectors/go.d/Jobs")
59 + EnableFailCode int // response code for enable failure (jobmgr: 200, SD: 422)
60 + RemoveStockOnEnableFail bool // remove stock config from exposed on enable failure
61 + JobCommands []Command // base commands for jobs; CommandRemove is added implicitly for dyncfg configs
62 +}
63 +
64 +// Handler implements the shared dyncfg command state machine.
65 +// It manages two caches (seen/exposed) borrowed from the component,
66 +// and delegates domain-specific work to Callbacks.
67 +type Handler[C Config] struct {
68 + *logger.Logger
69 + api *Responder
70 + seen *SeenCache[C]
71 + exposed *ExposedCache[C]
72 + cb Callbacks[C]
73 + path string
74 + enableFailCode int
75 + removeStockOnEnableFail bool
76 + jobCommands []Command
77 +}
78 +
79 +func NewHandler[C Config](opts HandlerOpts[C]) *Handler[C] {
80 + return &Handler[C]{
81 + Logger: opts.Logger,
82 + api: opts.API,
83 + seen: opts.Seen,
84 + exposed: opts.Exposed,
85 + cb: opts.Callbacks,
86 + path: opts.Path,
87 + enableFailCode: opts.EnableFailCode,
88 + removeStockOnEnableFail: opts.RemoveStockOnEnableFail,
89 + jobCommands: opts.JobCommands,
90 + }
91 +}
92 +
93 +func (h *Handler[C]) Seen() *SeenCache[C] { return h.seen }
94 +func (h *Handler[C]) Exposed() *ExposedCache[C] { return h.exposed }
95 +
96 +// SetAPI replaces the responder (e.g. to silence output in CLI mode).
97 +func (h *Handler[C]) SetAPI(api *Responder) { h.api = api }
98 +
99 +// NotifyJobCreate registers/updates a config in the dyncfg API (upsert).
100 +func (h *Handler[C]) NotifyJobCreate(cfg C, status Status) {
101 + isDyncfg := cfg.SourceType() == "dyncfg"
102 + h.api.ConfigCreate(netdataapi.ConfigOpts{
103 + ID: h.cb.ConfigID(cfg),
104 + Status: status.String(),
105 + ConfigType: ConfigTypeJob.String(),
106 + Path: h.path,
107 + SourceType: cfg.SourceType(),
108 + Source: cfg.Source(),
109 + SupportedCommands: h.jobSupportedCommands(isDyncfg),
110 + })
111 +}
112 +
113 +// NotifyJobStatus sends a status update for a config.
114 +func (h *Handler[C]) NotifyJobStatus(cfg C, status Status) {
115 + h.api.ConfigStatus(h.cb.ConfigID(cfg), status)
116 +}
117 +
118 +// NotifyJobRemove removes a config from the dyncfg API.
119 +func (h *Handler[C]) NotifyJobRemove(cfg C) {
120 + h.api.ConfigDelete(h.cb.ConfigID(cfg))
121 +}
122 +
123 +func (h *Handler[C]) jobSupportedCommands(isDyncfg bool) string {
124 + cmds := make([]Command, len(h.jobCommands))
125 + copy(cmds, h.jobCommands)
126 + if isDyncfg {
127 + cmds = append(cmds, CommandRemove)
128 + }
129 + return JoinCommands(cmds...)
130 +}
131 +
132 +// CmdAdd handles the "add" command.
133 +func (h *Handler[C]) CmdAdd(fn Function) {
134 + if err := fn.ValidateArgs(3); err != nil {
135 + h.api.SendCodef(fn, 400, "%v", err)
136 + return
137 + }
138 +
139 + key, name, ok := h.cb.ExtractKey(fn)
140 + if !ok {
141 + h.api.SendCodef(fn, 400, "invalid job ID format.")
142 + return
143 + }
144 +
145 + if err := fn.ValidateHasPayload(); err != nil {
146 + h.api.SendCodef(fn, 400, "%v", err)
147 + return
148 + }
149 +
150 + if err := ValidateJobName(name); err != nil {
151 + h.api.SendCodef(fn, 400, "invalid job name '%s': %v.", name, err)
152 + return
153 + }
154 +
155 + newCfg, err := h.cb.ParseAndValidate(fn, name)
156 + if err != nil {
157 + h.api.SendCodef(fn, 400, "%v", err)
158 + return
159 + }
160 +
161 + // Replace existing config at the same key, if any.
162 + if existing, ok := h.exposed.LookupByKey(key); ok {
163 + if _, found := h.seen.Lookup(existing.Cfg); found && existing.Cfg.SourceType() == "dyncfg" {
164 + h.seen.Remove(existing.Cfg)
165 + }
166 + h.exposed.Remove(existing.Cfg)
167 + h.cb.Stop(existing.Cfg)
168 + }
169 +
170 + h.seen.Add(newCfg)
171 + newEntry := &Entry[C]{Cfg: newCfg, Status: StatusAccepted}
172 + h.exposed.Add(newEntry)
173 +
174 + h.api.SendCodef(fn, 202, "")
175 + h.NotifyJobCreate(newCfg, StatusAccepted)
176 +}
177 +
178 +// CmdEnable handles the "enable" command.
179 +func (h *Handler[C]) CmdEnable(fn Function) {
180 + key, _, ok := h.cb.ExtractKey(fn)
181 + if !ok {
182 + h.api.SendCodef(fn, 400, "invalid job ID format.")
183 + return
184 + }
185 +
186 + entry, ok := h.exposed.LookupByKey(key)
187 + if !ok {
188 + h.api.SendCodef(fn, 404, "job not found.")
189 + return
190 + }
191 +
192 + oldStatus := entry.Status
193 +
194 + switch entry.Status {
195 + case StatusRunning:
196 + h.api.SendCodef(fn, 200, "")
197 + h.NotifyJobStatus(entry.Cfg, StatusRunning)
198 + return
199 + case StatusAccepted, StatusDisabled, StatusFailed:
200 + // proceed to start
201 + default:
202 + h.api.SendCodef(fn, 405, "enabling is not allowed in '%s' state.", entry.Status)
203 + h.NotifyJobStatus(entry.Cfg, entry.Status)
204 + return
205 + }
206 +
207 + err := h.cb.Start(entry.Cfg)
208 +
209 + if err != nil {
210 + entry.Status = StatusFailed
211 +
212 + code := h.enableFailCode
213 + var ce CodedError
214 + if errors.As(err, &ce) {
215 + code = ce.Code()
216 + }
217 + h.api.SendCodef(fn, code, "%v", err)
218 +
219 + // Stock removal only for non-CodedError failures (runtime detection failures).
220 + // CodedError = validation error (e.g. createCollectorJob → 400, no stock removal).
221 + if h.removeStockOnEnableFail && !isCodedError(err) && entry.Cfg.SourceType() == "stock" {
222 + h.exposed.Remove(entry.Cfg)
223 + h.NotifyJobRemove(entry.Cfg)
224 + } else {
225 + h.NotifyJobStatus(entry.Cfg, StatusFailed)
226 + }
227 +
228 + h.cb.OnStatusChange(entry, oldStatus, fn)
229 + return
230 + }
231 +
232 + entry.Status = StatusRunning
233 + h.api.SendCodef(fn, 200, "")
234 + h.NotifyJobStatus(entry.Cfg, StatusRunning)
235 + h.cb.OnStatusChange(entry, oldStatus, fn)
236 +}
237 +
238 +// CmdDisable handles the "disable" command.
239 +func (h *Handler[C]) CmdDisable(fn Function) {
240 + key, _, ok := h.cb.ExtractKey(fn)
241 + if !ok {
242 + h.api.SendCodef(fn, 400, "invalid job ID format.")
243 + return
244 + }
245 +
246 + entry, ok := h.exposed.LookupByKey(key)
247 + if !ok {
248 + h.api.SendCodef(fn, 404, "job not found.")
249 + return
250 + }
251 +
252 + oldStatus := entry.Status
253 +
254 + if entry.Status == StatusDisabled {
255 + h.api.SendCodef(fn, 200, "")
256 + h.NotifyJobStatus(entry.Cfg, StatusDisabled)
257 + return
258 + }
259 +
260 + // Unconditional for all non-Disabled statuses.
261 + h.cb.Stop(entry.Cfg)
262 +
263 + entry.Status = StatusDisabled
264 + h.api.SendCodef(fn, 200, "")
265 + h.NotifyJobStatus(entry.Cfg, StatusDisabled)
266 + h.cb.OnStatusChange(entry, oldStatus, fn)
267 +}
268 +
269 +// CmdRemove handles the "remove" command.
270 +func (h *Handler[C]) CmdRemove(fn Function) {
271 + key, _, ok := h.cb.ExtractKey(fn)
272 + if !ok {
273 + h.api.SendCodef(fn, 400, "invalid job ID format.")
274 + return
275 + }
276 +
277 + entry, ok := h.exposed.LookupByKey(key)
278 + if !ok {
279 + h.api.SendCodef(fn, 404, "job not found.")
280 + return
281 + }
282 +
283 + if entry.Cfg.SourceType() != "dyncfg" {
284 + h.api.SendCodef(fn, 405, "removing jobs of type '%s' is not supported, only 'dyncfg' jobs can be removed.", entry.Cfg.SourceType())
285 + return
286 + }
287 +
288 + h.seen.Remove(entry.Cfg)
289 + h.exposed.Remove(entry.Cfg)
290 + h.cb.Stop(entry.Cfg)
291 +
292 + h.api.SendCodef(fn, 200, "")
293 + h.NotifyJobRemove(entry.Cfg)
294 +}
295 +
296 +// CmdUpdate handles the "update" command.
297 +func (h *Handler[C]) CmdUpdate(fn Function) {
298 + key, name, ok := h.cb.ExtractKey(fn)
299 + if !ok {
300 + h.api.SendCodef(fn, 400, "invalid job ID format.")
301 + return
302 + }
303 +
304 + entry, ok := h.exposed.LookupByKey(key)
305 + if !ok {
306 + h.api.SendCodef(fn, 404, "job not found.")
307 + return
308 + }
309 +
310 + if err := fn.ValidateHasPayload(); err != nil {
311 + h.api.SendCodef(fn, 400, "%v", err)
312 + return
313 + }
314 +
315 + newCfg, err := h.cb.ParseAndValidate(fn, name)
316 + if err != nil {
317 + h.api.SendCodef(fn, 400, "%v", err)
318 + h.NotifyJobStatus(entry.Cfg, entry.Status)
319 + return
320 + }
321 +
322 + isConversion := entry.Cfg.SourceType() != "dyncfg"
323 +
324 + // No-op: running dyncfg config with same hash.
325 + if !isConversion && entry.Status == StatusRunning && entry.Cfg.Hash() == newCfg.Hash() {
326 + h.api.SendCodef(fn, 200, "")
327 + h.NotifyJobStatus(entry.Cfg, StatusRunning)
328 + return
329 + }
330 +
331 + if entry.Status == StatusAccepted {
332 + h.api.SendCodef(fn, 403, "updating is not allowed in '%s' state.", entry.Status)
333 + h.NotifyJobStatus(entry.Cfg, StatusAccepted)
334 + return
335 + }
336 +
337 + oldStatus := entry.Status
338 + oldCfg := entry.Cfg
339 +
340 + // For conversion: stop old before cache update (matching jobmgr line 681).
341 + if isConversion {
342 + h.cb.Stop(oldCfg)
343 + }
344 +
345 + // Update caches.
346 + if !isConversion {
347 + h.seen.Remove(oldCfg)
348 + }
349 + h.seen.Add(newCfg)
350 + newEntry := &Entry[C]{Cfg: newCfg, Status: StatusAccepted}
351 + h.exposed.Add(newEntry)
352 +
353 + // Preserve Disabled status.
354 + if oldStatus == StatusDisabled {
355 + newEntry.Status = StatusDisabled
356 + if isConversion {
357 + h.NotifyJobCreate(newCfg, StatusDisabled)
358 + }
359 + h.api.SendCodef(fn, 200, "")
360 + h.NotifyJobStatus(newCfg, StatusDisabled)
361 + h.cb.OnStatusChange(newEntry, oldStatus, fn)
362 + return
363 + }
364 +
365 + // Start or update.
366 + if isConversion {
367 + err = h.cb.Start(newCfg)
368 + } else {
369 + err = h.cb.Update(oldCfg, newCfg)
370 + }
371 +
372 + if err != nil {
373 + newEntry.Status = StatusFailed
374 + if isConversion {
375 + h.NotifyJobCreate(newCfg, StatusFailed)
376 + }
377 + h.api.SendCodef(fn, 200, "%v", err)
378 + h.NotifyJobStatus(newCfg, StatusFailed)
379 + h.cb.OnStatusChange(newEntry, oldStatus, fn)
380 + return
381 + }
382 +
383 + newEntry.Status = StatusRunning
384 + if isConversion {
385 + h.NotifyJobCreate(newCfg, StatusRunning)
386 + }
387 + h.api.SendCodef(fn, 200, "")
388 + h.NotifyJobStatus(newCfg, StatusRunning)
389 + h.cb.OnStatusChange(newEntry, oldStatus, fn)
390 +}
391 +
392 +// CmdRestart handles the "restart" command.
393 +// Stops the existing work and starts the same config again.
394 +// Only allowed for Running/Failed configs (rejects Accepted/Disabled).
395 +func (h *Handler[C]) CmdRestart(fn Function) {
396 + key, _, ok := h.cb.ExtractKey(fn)
397 + if !ok {
398 + h.api.SendCodef(fn, 400, "invalid job ID format.")
399 + return
400 + }
401 +
402 + entry, ok := h.exposed.LookupByKey(key)
403 + if !ok {
404 + h.api.SendCodef(fn, 404, "job not found.")
405 + return
406 + }
407 +
408 + switch entry.Status {
409 + case StatusAccepted, StatusDisabled:
410 + h.api.SendCodef(fn, 405, "restarting is not allowed in '%s' state.", entry.Status)
411 + h.NotifyJobStatus(entry.Cfg, entry.Status)
412 + return
413 + case StatusRunning, StatusFailed:
414 + // proceed
415 + default:
416 + h.api.SendCodef(fn, 405, "restarting is not allowed in '%s' state.", entry.Status)
417 + h.NotifyJobStatus(entry.Cfg, entry.Status)
418 + return
419 + }
420 +
421 + oldStatus := entry.Status
422 +
423 + h.cb.Stop(entry.Cfg)
424 +
425 + err := h.cb.Start(entry.Cfg)
426 +
427 + if err != nil {
428 + entry.Status = StatusFailed
429 + code := 422
430 + var ce CodedError
431 + if errors.As(err, &ce) {
432 + code = ce.Code()
433 + }
434 + h.api.SendCodef(fn, code, "job restart failed: %v", err)
435 + h.NotifyJobStatus(entry.Cfg, StatusFailed)
436 + h.cb.OnStatusChange(entry, oldStatus, fn)
437 + return
438 + }
439 +
440 + entry.Status = StatusRunning
441 + h.api.SendCodef(fn, 200, "")
442 + h.NotifyJobStatus(entry.Cfg, StatusRunning)
443 + h.cb.OnStatusChange(entry, oldStatus, fn)
444 +}
445 +
446 +func isCodedError(err error) bool {
447 + var ce CodedError
448 + return errors.As(err, &ce)
449 +}
src/go/plugin/go.d/agent/dyncfg/handler_test.go new
+939
@@ -0,0 +1,939 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package dyncfg
4 +
5 +import (
6 + "bytes"
7 + "errors"
8 + "fmt"
9 + "strings"
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/go.d/agent/functions"
16 +
17 + "github.com/stretchr/testify/assert"
18 + "github.com/stretchr/testify/require"
19 +)
20 +
21 +// codedErr implements CodedError for testing.
22 +type codedErr struct {
23 + err error
24 + code int
25 +}
26 +
27 +func (e *codedErr) Error() string { return e.err.Error() }
28 +func (e *codedErr) Code() int { return e.code }
29 +
30 +// mockCallbacks records all callback invocations for verification.
31 +type mockCallbacks struct {
32 + extractKeyFn func(fn Function) (string, string, bool)
33 + parseAndValidateFn func(fn Function, name string) (testConfig, error)
34 + startFn func(cfg testConfig) error
35 + updateFn func(oldCfg, newCfg testConfig) error
36 + stopFn func(cfg testConfig)
37 + onStatusChangeFn func(entry *Entry[testConfig], oldStatus Status, fn Function)
38 + configIDFn func(cfg testConfig) string
39 +
40 + startCalls []testConfig
41 + updateCalls []updateCall
42 + stopCalls []testConfig
43 + statusCalls []statusChangeCall
44 +}
45 +
46 +type updateCall struct {
47 + oldCfg, newCfg testConfig
48 +}
49 +
50 +type statusChangeCall struct {
51 + entry *Entry[testConfig]
52 + oldStatus Status
53 +}
54 +
55 +func (m *mockCallbacks) ExtractKey(fn Function) (string, string, bool) {
56 + if m.extractKeyFn != nil {
57 + return m.extractKeyFn(fn)
58 + }
59 + // Default: extract key from ID like "prefix:name".
60 + parts := strings.SplitN(fn.ID(), ":", 2)
61 + if len(parts) != 2 || parts[1] == "" {
62 + return "", "", false
63 + }
64 + return parts[1], parts[1], true
65 +}
66 +
67 +func (m *mockCallbacks) ParseAndValidate(fn Function, name string) (testConfig, error) {
68 + if m.parseAndValidateFn != nil {
69 + return m.parseAndValidateFn(fn, name)
70 + }
71 + return testConfig{uid: "dyncfg:" + name, key: name, sourceType: "dyncfg", source: "test"}, nil
72 +}
73 +
74 +func (m *mockCallbacks) Start(cfg testConfig) error {
75 + m.startCalls = append(m.startCalls, cfg)
76 + if m.startFn != nil {
77 + return m.startFn(cfg)
78 + }
79 + return nil
80 +}
81 +
82 +func (m *mockCallbacks) Update(oldCfg, newCfg testConfig) error {
83 + m.updateCalls = append(m.updateCalls, updateCall{oldCfg, newCfg})
84 + if m.updateFn != nil {
85 + return m.updateFn(oldCfg, newCfg)
86 + }
87 + return nil
88 +}
89 +
90 +func (m *mockCallbacks) Stop(cfg testConfig) {
91 + m.stopCalls = append(m.stopCalls, cfg)
92 + if m.stopFn != nil {
93 + m.stopFn(cfg)
94 + }
95 +}
96 +
97 +func (m *mockCallbacks) OnStatusChange(entry *Entry[testConfig], oldStatus Status, fn Function) {
98 + m.statusCalls = append(m.statusCalls, statusChangeCall{entry: entry, oldStatus: oldStatus})
99 + if m.onStatusChangeFn != nil {
100 + m.onStatusChangeFn(entry, oldStatus, fn)
101 + }
102 +}
103 +
104 +func (m *mockCallbacks) ConfigID(cfg testConfig) string {
105 + if m.configIDFn != nil {
106 + return m.configIDFn(cfg)
107 + }
108 + return "test:" + cfg.ExposedKey()
109 +}
110 +
111 +func newTestHandler(cb *mockCallbacks) *Handler[testConfig] {
112 + var buf bytes.Buffer
113 + api := NewResponder(netdataapi.New(safewriter.New(&buf)))
114 + return NewHandler(HandlerOpts[testConfig]{
115 + Logger: logger.New(),
116 + API: api,
117 + Seen: NewSeenCache[testConfig](),
118 + Exposed: NewExposedCache[testConfig](),
119 + Callbacks: cb,
120 +
121 + Path: "/test/path",
122 + EnableFailCode: 200,
123 + RemoveStockOnEnableFail: true,
124 + JobCommands: []Command{
125 + CommandSchema,
126 + CommandGet,
127 + CommandEnable,
128 + CommandDisable,
129 + CommandUpdate,
130 + CommandRestart,
131 + CommandTest,
132 + CommandUserconfig,
133 + },
134 + })
135 +}
136 +
137 +func newTestFn(id, cmd, name string, payload []byte) Function {
138 + args := []string{id, cmd}
139 + if name != "" {
140 + args = append(args, name)
141 + }
142 + return NewFunction(functions.Function{
143 + UID: "test-uid",
144 + Args: args,
145 + Payload: payload,
146 + })
147 +}
148 +
149 +// --- ExtractKey Failure Tests ---
150 +
151 +func TestCmdAdd_ExtractKeyFailure(t *testing.T) {
152 + cb := &mockCallbacks{}
153 + h := newTestHandler(cb)
154 +
155 + // ID without ":" causes default ExtractKey to return false.
156 + fn := newTestFn("badid", "add", "job1", []byte(`{}`))
157 + h.CmdAdd(fn)
158 +
159 + assert.Equal(t, 0, h.exposed.Count())
160 +}
161 +
162 +func TestCmdEnable_ExtractKeyFailure(t *testing.T) {
163 + cb := &mockCallbacks{}
164 + h := newTestHandler(cb)
165 +
166 + fn := newTestFn("badid", "enable", "", nil)
167 + h.CmdEnable(fn)
168 +
169 + assert.Len(t, cb.startCalls, 0)
170 +}
171 +
172 +func TestCmdDisable_ExtractKeyFailure(t *testing.T) {
173 + cb := &mockCallbacks{}
174 + h := newTestHandler(cb)
175 +
176 + fn := newTestFn("badid", "disable", "", nil)
177 + h.CmdDisable(fn)
178 +
179 + assert.Len(t, cb.stopCalls, 0)
180 +}
181 +
182 +func TestCmdRemove_ExtractKeyFailure(t *testing.T) {
183 + cb := &mockCallbacks{}
184 + h := newTestHandler(cb)
185 +
186 + fn := newTestFn("badid", "remove", "", nil)
187 + h.CmdRemove(fn)
188 +
189 + assert.Len(t, cb.stopCalls, 0)
190 +}
191 +
192 +func TestCmdUpdate_ExtractKeyFailure(t *testing.T) {
193 + cb := &mockCallbacks{}
194 + h := newTestHandler(cb)
195 +
196 + fn := newTestFn("badid", "update", "", []byte(`{}`))
197 + h.CmdUpdate(fn)
198 +
199 + assert.Len(t, cb.updateCalls, 0)
200 +}
201 +
202 +func TestCmdRestart_ExtractKeyFailure(t *testing.T) {
203 + cb := &mockCallbacks{}
204 + h := newTestHandler(cb)
205 +
206 + fn := newTestFn("badid", "restart", "", nil)
207 + h.CmdRestart(fn)
208 +
209 + assert.Len(t, cb.stopCalls, 0)
210 + assert.Len(t, cb.startCalls, 0)
211 +}
212 +
213 +// --- CmdAdd Tests ---
214 +
215 +func TestCmdAdd_Success(t *testing.T) {
216 + cb := &mockCallbacks{}
217 + h := newTestHandler(cb)
218 +
219 + fn := newTestFn("test:job1", "add", "job1", []byte(`{}`))
220 + h.CmdAdd(fn)
221 +
222 + // Config should be in both caches.
223 + _, ok := h.seen.LookupByUID("dyncfg:job1")
224 + assert.True(t, ok, "config should be in seen cache")
225 +
226 + entry, ok := h.exposed.LookupByKey("job1")
227 + require.True(t, ok, "config should be in exposed cache")
228 + assert.Equal(t, StatusAccepted, entry.Status)
229 +}
230 +
231 +func TestCmdAdd_InvalidArgs(t *testing.T) {
232 + cb := &mockCallbacks{}
233 + h := newTestHandler(cb)
234 +
235 + // Only 2 args (need 3).
236 + fn := newTestFn("test:job1", "add", "", nil)
237 + fn.fn.Args = fn.fn.Args[:2]
238 + h.CmdAdd(fn)
239 +
240 + assert.Equal(t, 0, h.exposed.Count())
241 +}
242 +
243 +func TestCmdAdd_NoPayload(t *testing.T) {
244 + cb := &mockCallbacks{}
245 + h := newTestHandler(cb)
246 +
247 + fn := newTestFn("test:job1", "add", "job1", nil)
248 + h.CmdAdd(fn)
249 +
250 + assert.Equal(t, 0, h.exposed.Count())
251 +}
252 +
253 +func TestCmdAdd_InvalidJobName(t *testing.T) {
254 + cb := &mockCallbacks{}
255 + h := newTestHandler(cb)
256 +
257 + cb.extractKeyFn = func(fn Function) (string, string, bool) {
258 + return "bad.name", "bad.name", true
259 + }
260 +
261 + fn := newTestFn("test:bad.name", "add", "bad.name", []byte(`{}`))
262 + h.CmdAdd(fn)
263 +
264 + assert.Equal(t, 0, h.exposed.Count())
265 +}
266 +
267 +func TestCmdAdd_ParseError(t *testing.T) {
268 + cb := &mockCallbacks{}
269 + cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
270 + return testConfig{}, errors.New("bad config")
271 + }
272 + h := newTestHandler(cb)
273 +
274 + fn := newTestFn("test:job1", "add", "job1", []byte(`{}`))
275 + h.CmdAdd(fn)
276 +
277 + assert.Equal(t, 0, h.exposed.Count())
278 +}
279 +
280 +func TestCmdAdd_ReplacesExisting(t *testing.T) {
281 + cb := &mockCallbacks{}
282 + h := newTestHandler(cb)
283 +
284 + oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
285 + h.seen.Add(oldCfg)
286 + h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
287 +
288 + fn := newTestFn("test:job1", "add", "job1", []byte(`{}`))
289 + h.CmdAdd(fn)
290 +
291 + // Old should be stopped, new should be in cache.
292 + require.Len(t, cb.stopCalls, 1)
293 + assert.Equal(t, "job1", cb.stopCalls[0].ExposedKey())
294 +
295 + entry, ok := h.exposed.LookupByKey("job1")
296 + require.True(t, ok)
297 + assert.Equal(t, StatusAccepted, entry.Status)
298 +}
299 +
300 +func TestCmdAdd_ReplacesExisting_KeepsNonDyncfgInSeen(t *testing.T) {
301 + cb := &mockCallbacks{}
302 + h := newTestHandler(cb)
303 +
304 + // Existing is a stock config — should NOT be removed from seen.
305 + oldCfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock"}
306 + h.seen.Add(oldCfg)
307 + h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
308 +
309 + fn := newTestFn("test:job1", "add", "job1", []byte(`{}`))
310 + h.CmdAdd(fn)
311 +
312 + // Stock config stays in seen (for re-promotion).
313 + _, ok := h.seen.LookupByUID("stock:job1")
314 + assert.True(t, ok, "stock config should remain in seen cache")
315 +}
316 +
317 +// --- CmdEnable Tests ---
318 +
319 +func TestCmdEnable_Success(t *testing.T) {
320 + cb := &mockCallbacks{}
321 + h := newTestHandler(cb)
322 +
323 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
324 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
325 +
326 + fn := newTestFn("test:job1", "enable", "", nil)
327 + h.CmdEnable(fn)
328 +
329 + entry, _ := h.exposed.LookupByKey("job1")
330 + assert.Equal(t, StatusRunning, entry.Status)
331 + assert.Len(t, cb.startCalls, 1)
332 + assert.Len(t, cb.statusCalls, 1)
333 +}
334 +
335 +func TestCmdEnable_AlreadyRunning(t *testing.T) {
336 + cb := &mockCallbacks{}
337 + h := newTestHandler(cb)
338 +
339 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
340 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
341 +
342 + fn := newTestFn("test:job1", "enable", "", nil)
343 + h.CmdEnable(fn)
344 +
345 + // No Start called, no OnStatusChange.
346 + assert.Len(t, cb.startCalls, 0)
347 + assert.Len(t, cb.statusCalls, 0)
348 +}
349 +
350 +func TestCmdEnable_NotFound(t *testing.T) {
351 + cb := &mockCallbacks{}
352 + h := newTestHandler(cb)
353 +
354 + fn := newTestFn("test:job1", "enable", "", nil)
355 + h.CmdEnable(fn)
356 +
357 + assert.Len(t, cb.startCalls, 0)
358 +}
359 +
360 +func TestCmdEnable_StartFails_RegularError(t *testing.T) {
361 + cb := &mockCallbacks{}
362 + cb.startFn = func(_ testConfig) error { return errors.New("start failed") }
363 + h := newTestHandler(cb)
364 +
365 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "stock"}
366 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
367 +
368 + fn := newTestFn("test:job1", "enable", "", nil)
369 + h.CmdEnable(fn)
370 +
371 + // Stock config should be removed on regular (non-coded) error.
372 + _, ok := h.exposed.LookupByKey("job1")
373 + assert.False(t, ok, "stock config should be removed from exposed on enable failure")
374 +}
375 +
376 +func TestCmdEnable_StartFails_CodedError(t *testing.T) {
377 + cb := &mockCallbacks{}
378 + cb.startFn = func(_ testConfig) error {
379 + return &codedErr{err: errors.New("validation failed"), code: 400}
380 + }
381 + h := newTestHandler(cb)
382 +
383 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "stock"}
384 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
385 +
386 + fn := newTestFn("test:job1", "enable", "", nil)
387 + h.CmdEnable(fn)
388 +
389 + // Stock config should NOT be removed on coded error.
390 + entry, ok := h.exposed.LookupByKey("job1")
391 + require.True(t, ok, "stock config should stay on coded error")
392 + assert.Equal(t, StatusFailed, entry.Status)
393 +}
394 +
395 +func TestCmdEnable_FromDisabled(t *testing.T) {
396 + cb := &mockCallbacks{}
397 + h := newTestHandler(cb)
398 +
399 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
400 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusDisabled})
401 +
402 + fn := newTestFn("test:job1", "enable", "", nil)
403 + h.CmdEnable(fn)
404 +
405 + entry, _ := h.exposed.LookupByKey("job1")
406 + assert.Equal(t, StatusRunning, entry.Status)
407 + require.Len(t, cb.statusCalls, 1)
408 + assert.Equal(t, StatusDisabled, cb.statusCalls[0].oldStatus)
409 +}
410 +
411 +// --- CmdDisable Tests ---
412 +
413 +func TestCmdDisable_FromRunning(t *testing.T) {
414 + cb := &mockCallbacks{}
415 + h := newTestHandler(cb)
416 +
417 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
418 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
419 +
420 + fn := newTestFn("test:job1", "disable", "", nil)
421 + h.CmdDisable(fn)
422 +
423 + entry, _ := h.exposed.LookupByKey("job1")
424 + assert.Equal(t, StatusDisabled, entry.Status)
425 + assert.Len(t, cb.stopCalls, 1)
426 + require.Len(t, cb.statusCalls, 1)
427 + assert.Equal(t, StatusRunning, cb.statusCalls[0].oldStatus)
428 +}
429 +
430 +func TestCmdDisable_AlreadyDisabled(t *testing.T) {
431 + cb := &mockCallbacks{}
432 + h := newTestHandler(cb)
433 +
434 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
435 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusDisabled})
436 +
437 + fn := newTestFn("test:job1", "disable", "", nil)
438 + h.CmdDisable(fn)
439 +
440 + assert.Len(t, cb.stopCalls, 0)
441 + assert.Len(t, cb.statusCalls, 0)
442 +}
443 +
444 +func TestCmdDisable_FromFailed(t *testing.T) {
445 + cb := &mockCallbacks{}
446 + h := newTestHandler(cb)
447 +
448 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
449 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusFailed})
450 +
451 + fn := newTestFn("test:job1", "disable", "", nil)
452 + h.CmdDisable(fn)
453 +
454 + // Stop called unconditionally (may have retry tasks to cancel).
455 + assert.Len(t, cb.stopCalls, 1)
456 + entry, _ := h.exposed.LookupByKey("job1")
457 + assert.Equal(t, StatusDisabled, entry.Status)
458 +}
459 +
460 +func TestCmdDisable_NotFound(t *testing.T) {
461 + cb := &mockCallbacks{}
462 + h := newTestHandler(cb)
463 +
464 + fn := newTestFn("test:job1", "disable", "", nil)
465 + h.CmdDisable(fn)
466 +
467 + assert.Len(t, cb.stopCalls, 0)
468 +}
469 +
470 +// --- CmdRemove Tests ---
471 +
472 +func TestCmdRemove_DyncfgConfig(t *testing.T) {
473 + cb := &mockCallbacks{}
474 + h := newTestHandler(cb)
475 +
476 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
477 + h.seen.Add(cfg)
478 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
479 +
480 + fn := newTestFn("test:job1", "remove", "", nil)
481 + h.CmdRemove(fn)
482 +
483 + _, ok := h.seen.LookupByUID("dyncfg:job1")
484 + assert.False(t, ok, "should be removed from seen")
485 +
486 + _, ok = h.exposed.LookupByKey("job1")
487 + assert.False(t, ok, "should be removed from exposed")
488 +
489 + assert.Len(t, cb.stopCalls, 1)
490 +}
491 +
492 +func TestCmdRemove_NonDyncfg_Rejected(t *testing.T) {
493 + cb := &mockCallbacks{}
494 + h := newTestHandler(cb)
495 +
496 + cfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock"}
497 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
498 +
499 + fn := newTestFn("test:job1", "remove", "", nil)
500 + h.CmdRemove(fn)
501 +
502 + // Should still be in cache — removal rejected.
503 + _, ok := h.exposed.LookupByKey("job1")
504 + assert.True(t, ok, "non-dyncfg config should not be removed")
505 + assert.Len(t, cb.stopCalls, 0)
506 +}
507 +
508 +func TestCmdRemove_NotFound(t *testing.T) {
509 + cb := &mockCallbacks{}
510 + h := newTestHandler(cb)
511 +
512 + fn := newTestFn("test:job1", "remove", "", nil)
513 + h.CmdRemove(fn)
514 +
515 + assert.Len(t, cb.stopCalls, 0)
516 +}
517 +
518 +// --- CmdUpdate Tests ---
519 +
520 +func TestCmdUpdate_NonConversion_Success(t *testing.T) {
521 + cb := &mockCallbacks{}
522 + h := newTestHandler(cb)
523 +
524 + oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
525 + h.seen.Add(oldCfg)
526 + h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
527 +
528 + // ParseAndValidate returns config with different hash.
529 + cb.parseAndValidateFn = func(_ Function, name string) (testConfig, error) {
530 + return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200, source: "test"}, nil
531 + }
532 +
533 + fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
534 + h.CmdUpdate(fn)
535 +
536 + // Should call Update (not Stop+Start).
537 + assert.Len(t, cb.updateCalls, 1)
538 + assert.Len(t, cb.stopCalls, 0)
539 + assert.Len(t, cb.startCalls, 0)
540 +
541 + entry, ok := h.exposed.LookupByKey("job1")
542 + require.True(t, ok)
543 + assert.Equal(t, StatusRunning, entry.Status)
544 + assert.Equal(t, uint64(200), entry.Cfg.Hash())
545 +}
546 +
547 +func TestCmdUpdate_NonConversion_NoOp(t *testing.T) {
548 + cb := &mockCallbacks{}
549 + h := newTestHandler(cb)
550 +
551 + oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
552 + h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
553 +
554 + cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
555 + return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}, nil
556 + }
557 +
558 + fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
559 + h.CmdUpdate(fn)
560 +
561 + // No-op: same hash, running, not conversion.
562 + assert.Len(t, cb.updateCalls, 0)
563 + assert.Len(t, cb.stopCalls, 0)
564 + assert.Len(t, cb.startCalls, 0)
565 +}
566 +
567 +func TestCmdUpdate_Conversion_Success(t *testing.T) {
568 + cb := &mockCallbacks{}
569 + h := newTestHandler(cb)
570 +
571 + oldCfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock", hash: 100}
572 + h.seen.Add(oldCfg)
573 + h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
574 +
575 + cb.parseAndValidateFn = func(_ Function, name string) (testConfig, error) {
576 + return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200, source: "test"}, nil
577 + }
578 +
579 + fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
580 + h.CmdUpdate(fn)
581 +
582 + // Conversion uses Stop + Start, not Update.
583 + assert.Len(t, cb.stopCalls, 1)
584 + assert.Len(t, cb.startCalls, 1)
585 + assert.Len(t, cb.updateCalls, 0)
586 +
587 + entry, _ := h.exposed.LookupByKey("job1")
588 + assert.Equal(t, StatusRunning, entry.Status)
589 + assert.Equal(t, "dyncfg", entry.Cfg.SourceType())
590 +
591 + // Old stock config should still be in seen (for re-promotion).
592 + _, ok := h.seen.LookupByUID("stock:job1")
593 + assert.True(t, ok, "stock config should stay in seen for conversion")
594 +}
595 +
596 +func TestCmdUpdate_Disabled_PreservesStatus(t *testing.T) {
597 + cb := &mockCallbacks{}
598 + h := newTestHandler(cb)
599 +
600 + oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
601 + h.seen.Add(oldCfg)
602 + h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusDisabled})
603 +
604 + cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
605 + return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200}, nil
606 + }
607 +
608 + fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
609 + h.CmdUpdate(fn)
610 +
611 + // Should NOT start, should preserve Disabled.
612 + assert.Len(t, cb.startCalls, 0)
613 + assert.Len(t, cb.updateCalls, 0)
614 +
615 + entry, _ := h.exposed.LookupByKey("job1")
616 + assert.Equal(t, StatusDisabled, entry.Status)
617 +}
618 +
619 +func TestCmdUpdate_Accepted_Rejected(t *testing.T) {
620 + cb := &mockCallbacks{}
621 + h := newTestHandler(cb)
622 +
623 + oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
624 + h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusAccepted})
625 +
626 + cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
627 + return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200}, nil
628 + }
629 +
630 + fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
631 + h.CmdUpdate(fn)
632 +
633 + // Accepted configs can't be updated.
634 + assert.Len(t, cb.updateCalls, 0)
635 + assert.Len(t, cb.startCalls, 0)
636 +
637 + entry, _ := h.exposed.LookupByKey("job1")
638 + assert.Equal(t, StatusAccepted, entry.Status)
639 +}
640 +
641 +func TestCmdUpdate_NotFound(t *testing.T) {
642 + cb := &mockCallbacks{}
643 + h := newTestHandler(cb)
644 +
645 + fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
646 + h.CmdUpdate(fn)
647 +
648 + assert.Len(t, cb.updateCalls, 0)
649 +}
650 +
651 +func TestCmdUpdate_ParseError(t *testing.T) {
652 + cb := &mockCallbacks{}
653 + cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
654 + return testConfig{}, errors.New("bad config")
655 + }
656 + h := newTestHandler(cb)
657 +
658 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
659 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
660 +
661 + fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
662 + h.CmdUpdate(fn)
663 +
664 + // Parse error should not modify cache.
665 + entry, _ := h.exposed.LookupByKey("job1")
666 + assert.Equal(t, StatusRunning, entry.Status)
667 +}
668 +
669 +func TestCmdUpdate_NonConversion_StartFails(t *testing.T) {
670 + cb := &mockCallbacks{}
671 + cb.updateFn = func(_, _ testConfig) error { return errors.New("update failed") }
672 + h := newTestHandler(cb)
673 +
674 + oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
675 + h.seen.Add(oldCfg)
676 + h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
677 +
678 + cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
679 + return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200}, nil
680 + }
681 +
682 + fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
683 + h.CmdUpdate(fn)
684 +
685 + entry, _ := h.exposed.LookupByKey("job1")
686 + assert.Equal(t, StatusFailed, entry.Status)
687 +}
688 +
689 +func TestCmdUpdate_Conversion_StartFails(t *testing.T) {
690 + cb := &mockCallbacks{}
691 + cb.startFn = func(_ testConfig) error { return errors.New("start failed") }
692 + h := newTestHandler(cb)
693 +
694 + oldCfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock", hash: 100}
695 + h.seen.Add(oldCfg)
696 + h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
697 +
698 + cb.parseAndValidateFn = func(_ Function, name string) (testConfig, error) {
699 + return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200, source: "test"}, nil
700 + }
701 +
702 + fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
703 + h.CmdUpdate(fn)
704 +
705 + // Conversion uses Stop + Start; Start fails → Failed status.
706 + assert.Len(t, cb.stopCalls, 1)
707 + assert.Len(t, cb.startCalls, 1)
708 +
709 + entry, _ := h.exposed.LookupByKey("job1")
710 + assert.Equal(t, StatusFailed, entry.Status)
711 + assert.Equal(t, "dyncfg", entry.Cfg.SourceType())
712 +}
713 +
714 +func TestCmdUpdate_NoPayload(t *testing.T) {
715 + cb := &mockCallbacks{}
716 + h := newTestHandler(cb)
717 +
718 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
719 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
720 +
721 + // No payload (nil).
722 + fn := newTestFn("test:job1", "update", "job1", nil)
723 + h.CmdUpdate(fn)
724 +
725 + // Should fail with missing payload, not modify cache.
726 + assert.Len(t, cb.updateCalls, 0)
727 + entry, _ := h.exposed.LookupByKey("job1")
728 + assert.Equal(t, StatusRunning, entry.Status)
729 +}
730 +
731 +func TestCmdUpdate_Conversion_Disabled(t *testing.T) {
732 + cb := &mockCallbacks{}
733 + h := newTestHandler(cb)
734 +
735 + oldCfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock"}
736 + h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusDisabled})
737 +
738 + cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
739 + return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", source: "test"}, nil
740 + }
741 +
742 + fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
743 + h.CmdUpdate(fn)
744 +
745 + // Conversion with Disabled: Stop old, update caches, preserve Disabled.
746 + assert.Len(t, cb.stopCalls, 1)
747 + assert.Len(t, cb.startCalls, 0)
748 +
749 + entry, _ := h.exposed.LookupByKey("job1")
750 + assert.Equal(t, StatusDisabled, entry.Status)
751 +}
752 +
753 +// --- CmdRestart Tests ---
754 +
755 +func TestCmdRestart_FromRunning(t *testing.T) {
756 + cb := &mockCallbacks{}
757 + h := newTestHandler(cb)
758 +
759 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
760 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
761 +
762 + fn := newTestFn("test:job1", "restart", "", nil)
763 + h.CmdRestart(fn)
764 +
765 + assert.Len(t, cb.stopCalls, 1)
766 + assert.Len(t, cb.startCalls, 1)
767 +
768 + entry, _ := h.exposed.LookupByKey("job1")
769 + assert.Equal(t, StatusRunning, entry.Status)
770 + require.Len(t, cb.statusCalls, 1)
771 + assert.Equal(t, StatusRunning, cb.statusCalls[0].oldStatus)
772 +}
773 +
774 +func TestCmdRestart_FromFailed(t *testing.T) {
775 + cb := &mockCallbacks{}
776 + h := newTestHandler(cb)
777 +
778 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
779 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusFailed})
780 +
781 + fn := newTestFn("test:job1", "restart", "", nil)
782 + h.CmdRestart(fn)
783 +
784 + assert.Len(t, cb.stopCalls, 1)
785 + assert.Len(t, cb.startCalls, 1)
786 +
787 + entry, _ := h.exposed.LookupByKey("job1")
788 + assert.Equal(t, StatusRunning, entry.Status)
789 + require.Len(t, cb.statusCalls, 1)
790 + assert.Equal(t, StatusFailed, cb.statusCalls[0].oldStatus)
791 +}
792 +
793 +func TestCmdRestart_Accepted_Rejected(t *testing.T) {
794 + cb := &mockCallbacks{}
795 + h := newTestHandler(cb)
796 +
797 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
798 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
799 +
800 + fn := newTestFn("test:job1", "restart", "", nil)
801 + h.CmdRestart(fn)
802 +
803 + assert.Len(t, cb.stopCalls, 0)
804 + assert.Len(t, cb.startCalls, 0)
805 +
806 + entry, _ := h.exposed.LookupByKey("job1")
807 + assert.Equal(t, StatusAccepted, entry.Status)
808 +}
809 +
810 +func TestCmdRestart_Disabled_Rejected(t *testing.T) {
811 + cb := &mockCallbacks{}
812 + h := newTestHandler(cb)
813 +
814 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
815 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusDisabled})
816 +
817 + fn := newTestFn("test:job1", "restart", "", nil)
818 + h.CmdRestart(fn)
819 +
820 + assert.Len(t, cb.stopCalls, 0)
821 + assert.Len(t, cb.startCalls, 0)
822 +
823 + entry, _ := h.exposed.LookupByKey("job1")
824 + assert.Equal(t, StatusDisabled, entry.Status)
825 +}
826 +
827 +func TestCmdRestart_NotFound(t *testing.T) {
828 + cb := &mockCallbacks{}
829 + h := newTestHandler(cb)
830 +
831 + fn := newTestFn("test:job1", "restart", "", nil)
832 + h.CmdRestart(fn)
833 +
834 + assert.Len(t, cb.stopCalls, 0)
835 + assert.Len(t, cb.startCalls, 0)
836 +}
837 +
838 +func TestCmdRestart_StartFails(t *testing.T) {
839 + cb := &mockCallbacks{}
840 + cb.startFn = func(_ testConfig) error { return errors.New("restart failed") }
841 + h := newTestHandler(cb)
842 +
843 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
844 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
845 +
846 + fn := newTestFn("test:job1", "restart", "", nil)
847 + h.CmdRestart(fn)
848 +
849 + assert.Len(t, cb.stopCalls, 1)
850 + assert.Len(t, cb.startCalls, 1)
851 +
852 + entry, _ := h.exposed.LookupByKey("job1")
853 + assert.Equal(t, StatusFailed, entry.Status)
854 + require.Len(t, cb.statusCalls, 1)
855 + assert.Equal(t, StatusRunning, cb.statusCalls[0].oldStatus)
856 +}
857 +
858 +func TestCmdRestart_StartFails_CodedError(t *testing.T) {
859 + cb := &mockCallbacks{}
860 + cb.startFn = func(_ testConfig) error {
861 + return &codedErr{err: errors.New("bad config"), code: 400}
862 + }
863 + h := newTestHandler(cb)
864 +
865 + cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
866 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
867 +
868 + fn := newTestFn("test:job1", "restart", "", nil)
869 + h.CmdRestart(fn)
870 +
871 + entry, _ := h.exposed.LookupByKey("job1")
872 + assert.Equal(t, StatusFailed, entry.Status)
873 +}
874 +
875 +// --- Notify Tests ---
876 +
877 +func TestNotifyJobCreate_SupportedCommands(t *testing.T) {
878 + tests := []struct {
879 + name string
880 + commands []Command
881 + sourceType string
882 + wantRemove bool
883 + }{
884 + {"dyncfg with restart", []Command{CommandSchema, CommandGet, CommandRestart}, "dyncfg", true},
885 + {"dyncfg no restart", []Command{CommandSchema, CommandGet}, "dyncfg", true},
886 + {"stock with restart", []Command{CommandSchema, CommandGet, CommandRestart}, "stock", false},
887 + {"stock no restart", []Command{CommandSchema, CommandGet}, "stock", false},
888 + }
889 +
890 + for _, tt := range tests {
891 + t.Run(tt.name, func(t *testing.T) {
892 + cb := &mockCallbacks{}
893 + h := newTestHandler(cb)
894 + h.jobCommands = tt.commands
895 +
896 + cmds := h.jobSupportedCommands(tt.sourceType == "dyncfg")
897 +
898 + // Base commands should always be present.
899 + for _, cmd := range tt.commands {
900 + assert.Contains(t, cmds, string(cmd))
901 + }
902 + if tt.wantRemove {
903 + assert.Contains(t, cmds, "remove")
904 + } else {
905 + assert.NotContains(t, cmds, "remove")
906 + }
907 + })
908 + }
909 +}
910 +
911 +// --- ValidateJobName Tests ---
912 +
913 +func TestValidateJobName(t *testing.T) {
914 + tests := []struct {
915 + name string
916 + input string
917 + wantErr bool
918 + }{
919 + {"valid", "my_job", false},
920 + {"valid with numbers", "job123", false},
921 + {"valid with dashes", "my-job", false},
922 + {"space", "my job", true},
923 + {"tab", "my\tjob", true},
924 + {"dot", "my.job", true},
925 + {"colon", "my:job", true},
926 + {"empty", "", false},
927 + }
928 +
929 + for _, tt := range tests {
930 + t.Run(tt.name, func(t *testing.T) {
931 + err := ValidateJobName(tt.input)
932 + if tt.wantErr {
933 + assert.Error(t, err, fmt.Sprintf("ValidateJobName(%q) should fail", tt.input))
934 + } else {
935 + assert.NoError(t, err, fmt.Sprintf("ValidateJobName(%q) should pass", tt.input))
936 + }
937 + })
938 + }
939 +}
src/go/plugin/go.d/agent/dyncfg/validate.go new
+23
@@ -0,0 +1,23 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package dyncfg
4 +
5 +import (
6 + "errors"
7 + "fmt"
8 + "unicode"
9 +)
10 +
11 +// ValidateJobName checks that a job name contains no spaces, dots, or colons.
12 +func ValidateJobName(jobName string) error {
13 + for _, r := range jobName {
14 + if unicode.IsSpace(r) {
15 + return errors.New("contains spaces")
16 + }
17 + switch r {
18 + case '.', ':':
19 + return fmt.Errorf("contains '%c'", r)
20 + }
21 + }
22 + return nil
23 +}
src/go/plugin/go.d/agent/jobmgr/cache.go
-57
@@ -7,7 +7,6 @@ import (
7 "sync"
8
9 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
10 - "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/dyncfg"
10 "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
11 )
12
@@ -17,18 +16,6 @@ func newDiscoveredConfigsCache() *discoveredConfigs {
16 }
17 }
18
20 -func newSeenConfigCache() *seenConfigs {
21 - return &seenConfigs{
22 - items: make(map[string]*seenConfig),
23 - }
24 -}
25 -
26 -func newExposedConfigCache() *exposedConfigs {
27 - return &exposedConfigs{
28 - items: make(map[string]*seenConfig),
29 - }
30 -}
31 -
19 func newRunningJobsCache() *runningJobs {
20 return &runningJobs{
21 mux: sync.Mutex{},
@@ -48,19 +35,6 @@ type (
35 items map[string]map[uint64]confgroup.Config
36 }
37
51 - seenConfigs struct {
52 - // [cfg.UID()]
53 - items map[string]*seenConfig
54 - }
55 - exposedConfigs struct {
56 - // [cfg.FullName()]
57 - items map[string]*seenConfig
58 - }
59 - seenConfig struct {
60 - cfg confgroup.Config
61 - status dyncfg.Status
62 - }
63 -
38 runningJobs struct {
39 mux sync.Mutex
40 // [cfg.FullName()]
@@ -114,37 +88,6 @@ func (c *discoveredConfigs) add(group *confgroup.Group) (added, removed []confgr
88 return added, removed
89 }
90
117 -func (c *seenConfigs) add(sj *seenConfig) {
118 - c.items[sj.cfg.UID()] = sj
119 -}
120 -func (c *seenConfigs) remove(cfg confgroup.Config) {
121 - delete(c.items, cfg.UID())
122 -}
123 -func (c *seenConfigs) lookup(cfg confgroup.Config) (*seenConfig, bool) {
124 - v, ok := c.items[cfg.UID()]
125 - return v, ok
126 -}
127 -
128 -func (c *exposedConfigs) add(sj *seenConfig) {
129 - c.items[sj.cfg.FullName()] = sj
130 -}
131 -func (c *exposedConfigs) remove(cfg confgroup.Config) {
132 - delete(c.items, cfg.FullName())
133 -}
134 -func (c *exposedConfigs) lookup(cfg confgroup.Config) (*seenConfig, bool) {
135 - v, ok := c.items[cfg.FullName()]
136 - return v, ok
137 -}
138 -
139 -func (c *exposedConfigs) lookupByName(module, job string) (*seenConfig, bool) {
140 - key := module + "_" + job
141 - if module == job {
142 - key = job
143 - }
144 - v, ok := c.items[key]
145 - return v, ok
146 -}
147 -
91 func (c *runningJobs) lock() {
92 c.mux.Lock()
93 }
src/go/plugin/go.d/agent/jobmgr/dyncfg_collector.go
+150 -440
@@ -5,12 +5,10 @@ package jobmgr
5 import (
6 "context"
7 "encoding/json"
8 - "errors"
8 "fmt"
9 "log/slog"
10 "slices"
11 "strings"
13 - "unicode"
12
13 "gopkg.in/yaml.v2"
14
@@ -48,22 +46,6 @@ func dyncfgCollectorModCmds() string {
46 dyncfg.CommandTest,
47 dyncfg.CommandUserconfig)
48 }
51 -func dyncfgCollectorJobCmds(isDyncfgJob bool) string {
52 - cmds := []dyncfg.Command{
53 - dyncfg.CommandSchema,
54 - dyncfg.CommandGet,
55 - dyncfg.CommandEnable,
56 - dyncfg.CommandDisable,
57 - dyncfg.CommandUpdate,
58 - dyncfg.CommandRestart,
59 - dyncfg.CommandTest,
60 - dyncfg.CommandUserconfig,
61 - }
62 - if isDyncfgJob {
63 - cmds = append(cmds, dyncfg.CommandRemove)
64 - }
65 - return dyncfg.JoinCommands(cmds...)
66 -}
49
50 func (m *Manager) dyncfgCollectorModuleCreate(name string) {
51 m.dyncfgApi.ConfigCreate(netdataapi.ConfigOpts{
@@ -77,24 +59,13 @@ func (m *Manager) dyncfgCollectorModuleCreate(name string) {
59 })
60 }
61
80 -func (m *Manager) dyncfgCollectorJobCreate(cfg confgroup.Config, status dyncfg.Status) {
81 - m.dyncfgApi.ConfigCreate(netdataapi.ConfigOpts{
82 - ID: m.dyncfgJobID(cfg),
83 - Status: status.String(),
84 - ConfigType: dyncfg.ConfigTypeJob.String(),
85 - Path: fmt.Sprintf(dyncfgCollectorPath, executable.Name),
86 - SourceType: cfg.SourceType(),
87 - Source: cfg.Source(),
88 - SupportedCommands: dyncfgCollectorJobCmds(isDyncfg(cfg)),
89 - })
90 -}
91 -
92 -func (m *Manager) dyncfgJobRemove(cfg confgroup.Config) {
93 - m.dyncfgApi.ConfigDelete(m.dyncfgJobID(cfg))
94 -}
95 -
96 -func (m *Manager) dyncfgJobStatus(cfg confgroup.Config, status dyncfg.Status) {
97 - m.dyncfgApi.ConfigStatus(m.dyncfgJobID(cfg), status)
62 +// exposedLookupByName looks up an exposed config by module + job name.
63 +func (m *Manager) exposedLookupByName(module, job string) (*dyncfg.Entry[confgroup.Config], bool) {
64 + key := module + "_" + job
65 + if module == job {
66 + key = job
67 + }
68 + return m.exposed.LookupByKey(key)
69 }
70
71 func (m *Manager) dyncfgCollectorExec(fn dyncfg.Function) {
@@ -120,25 +91,37 @@ func (m *Manager) dyncfgCollectorExec(fn dyncfg.Function) {
91 func (m *Manager) dyncfgCollectorSeqExec(fn dyncfg.Function) {
92 cmd := fn.Command()
93
94 + // Clear waitCfgOnOff before enable/disable (component concern, not handler's).
95 + if cmd == dyncfg.CommandEnable || cmd == dyncfg.CommandDisable {
96 + key, _, ok := m.collectorCb.ExtractKey(fn)
97 + if ok {
98 + if entry, ok := m.exposed.LookupByKey(key); ok {
99 + if entry.Cfg.FullName() == m.waitCfgOnOff {
100 + m.waitCfgOnOff = ""
101 + }
102 + }
103 + }
104 + }
105 +
106 switch cmd {
107 + case dyncfg.CommandAdd:
108 + m.handler.CmdAdd(fn)
109 + case dyncfg.CommandUpdate:
110 + m.handler.CmdUpdate(fn)
111 + case dyncfg.CommandEnable:
112 + m.handler.CmdEnable(fn)
113 + case dyncfg.CommandDisable:
114 + m.handler.CmdDisable(fn)
115 + case dyncfg.CommandRemove:
116 + m.handler.CmdRemove(fn)
117 + case dyncfg.CommandRestart:
118 + m.handler.CmdRestart(fn)
119 case dyncfg.CommandTest:
120 m.dyncfgConfigTest(fn)
121 case dyncfg.CommandSchema:
122 m.dyncfgConfigSchema(fn)
123 case dyncfg.CommandGet:
124 m.dyncfgConfigGet(fn)
130 - case dyncfg.CommandRestart:
131 - m.dyncfgConfigRestart(fn)
132 - case dyncfg.CommandEnable:
133 - m.dyncfgConfigEnable(fn)
134 - case dyncfg.CommandDisable:
135 - m.dyncfgConfigDisable(fn)
136 - case dyncfg.CommandAdd:
137 - m.dyncfgConfigAdd(fn)
138 - case dyncfg.CommandRemove:
139 - m.dyncfgConfigRemove(fn)
140 - case dyncfg.CommandUpdate:
141 - m.dyncfgConfigUpdate(fn)
125 default:
126 m.Warningf("dyncfg: function '%s' command '%s' not implemented", fn.Fn().Name, cmd)
127 m.dyncfgApi.SendCodef(fn, 501, "Function '%s' command '%s' is not implemented.", fn.Fn().Name, cmd)
@@ -201,7 +184,7 @@ func (m *Manager) dyncfgConfigTest(fn dyncfg.Function) {
184
185 m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
186
204 - if err := validateJobName(jn); err != nil {
187 + if err := dyncfg.ValidateJobName(jn); err != nil {
188 m.Warningf("dyncfg: %s: module %s: unacceptable job name '%s': %v", cmd, mn, jn, err)
189 m.dyncfgApi.SendCodef(fn, 400, "Unacceptable job name '%s': %v.", jn, err)
190 return
@@ -308,7 +291,7 @@ func (m *Manager) dyncfgConfigGet(fn dyncfg.Function) {
291
292 m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
293
311 - ecfg, ok := m.exposedConfigs.lookupByName(mn, jn)
294 + entry, ok := m.exposedLookupByName(mn, jn)
295 if !ok {
296 m.Warningf("dyncfg: %s: module %s job %s not found", cmd, mn, jn)
297 m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' job '%s' is not registered.", mn, jn)
@@ -317,7 +300,7 @@ func (m *Manager) dyncfgConfigGet(fn dyncfg.Function) {
300
301 mod := creator.Create()
302
320 - if err := applyConfig(ecfg.cfg, mod); err != nil {
303 + if err := applyConfig(entry.Cfg, mod); err != nil {
304 m.Warningf("dyncfg: %s: module %s job %s failed to apply config: %v", cmd, mn, jn, err)
305 m.dyncfgApi.SendCodef(fn, 400, "Invalid configuration. Failed to apply configuration: %v.", err)
306 return
@@ -340,384 +323,6 @@ func (m *Manager) dyncfgConfigGet(fn dyncfg.Function) {
323 m.dyncfgApi.SendJSON(fn, string(bs))
324 }
325
343 -func (m *Manager) dyncfgConfigRestart(fn dyncfg.Function) {
344 - cmd := fn.Command()
345 -
346 - id := fn.ID()
347 - mn, jn, ok := m.extractModuleJobName(id)
348 - if !ok {
349 - m.Warningf("dyncfg: %s: could not extract module from id (%s)", cmd, id)
350 - m.dyncfgApi.SendCodef(fn, 400, "Invalid ID format. Could not extract module name from ID. Provided ID: %s.", id)
351 - return
352 - }
353 -
354 - ecfg, ok := m.exposedConfigs.lookupByName(mn, jn)
355 - if !ok {
356 - m.Warningf("dyncfg: %s: module %s job %s not found", cmd, mn, jn)
357 - m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' job '%s' is not registered.", mn, jn)
358 - return
359 - }
360 -
361 - job, err := m.createCollectorJob(ecfg.cfg)
362 - if err != nil {
363 - m.Warningf("dyncfg: %s: module %s job %s: failed to apply config: %v", cmd, mn, jn, err)
364 - m.dyncfgApi.SendCodef(fn, 400, "Invalid configuration. Failed to apply configuration: %v.", err)
365 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
366 - return
367 - }
368 -
369 - switch ecfg.status {
370 - case dyncfg.StatusAccepted, dyncfg.StatusDisabled:
371 - m.Warningf("dyncfg: %s: module %s job %s: restarting not allowed in '%s' state", cmd, mn, jn, ecfg.status)
372 - m.dyncfgApi.SendCodef(fn, 405, "Restarting data collection job is not allowed in '%s' state.", ecfg.status)
373 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
374 - return
375 - case dyncfg.StatusRunning:
376 - m.fileStatus.remove(ecfg.cfg)
377 - m.stopRunningJob(ecfg.cfg.FullName())
378 - default:
379 - }
380 -
381 - m.retryingTasks.remove(ecfg.cfg)
382 -
383 - m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
384 -
385 - if err := job.AutoDetection(); err != nil {
386 - job.Cleanup()
387 - ecfg.status = dyncfg.StatusFailed
388 - m.dyncfgApi.SendCodef(fn, 422, "Job restart failed: %v", err)
389 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
390 - m.runRetryTask(ecfg, job)
391 - return
392 - }
393 -
394 - ecfg.status = dyncfg.StatusRunning
395 -
396 - if isDyncfg(ecfg.cfg) {
397 - m.fileStatus.add(ecfg.cfg, ecfg.status.String())
398 - }
399 - m.startRunningJob(job)
400 -
401 - m.dyncfgApi.SendCodef(fn, 200, "")
402 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
403 -}
404 -
405 -func (m *Manager) dyncfgConfigEnable(fn dyncfg.Function) {
406 - cmd := fn.Command()
407 -
408 - id := fn.ID()
409 - mn, jn, ok := m.extractModuleJobName(id)
410 - if !ok {
411 - m.Warningf("dyncfg: %s: could not extract module and job from id (%s)", cmd, id)
412 - m.dyncfgApi.SendCodef(fn, 400, "Invalid ID format. Could not extract module and job name from ID. Provided ID: %s.", id)
413 - return
414 - }
415 -
416 - ecfg, ok := m.exposedConfigs.lookupByName(mn, jn)
417 - if !ok {
418 - m.Warningf("dyncfg: %s: module %s job %s not found", cmd, mn, jn)
419 - m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' job '%s' is not registered.", mn, jn)
420 - return
421 - }
422 -
423 - if ecfg.cfg.FullName() == m.waitCfgOnOff {
424 - m.waitCfgOnOff = ""
425 - }
426 -
427 - switch ecfg.status {
428 - case dyncfg.StatusAccepted, dyncfg.StatusDisabled, dyncfg.StatusFailed:
429 - case dyncfg.StatusRunning:
430 - // non-dyncfg update triggers enable/disable
431 - m.dyncfgApi.SendCodef(fn, 200, "")
432 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
433 - return
434 - default:
435 - m.Warningf("dyncfg: %s: module %s job %s: enabling not allowed in %s state", cmd, mn, jn, ecfg.status)
436 - m.dyncfgApi.SendCodef(fn, 405, "Enabling data collection job is not allowed in '%s' state.", ecfg.status)
437 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
438 - return
439 - }
440 -
441 - job, err := m.createCollectorJob(ecfg.cfg)
442 - if err != nil {
443 - ecfg.status = dyncfg.StatusFailed
444 - m.Warningf("dyncfg: %s: module %s job %s: failed to apply config: %v", cmd, mn, jn, err)
445 - m.dyncfgApi.SendCodef(fn, 400, "Invalid configuration. Failed to apply configuration: %v.", err)
446 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
447 - return
448 - }
449 -
450 - if ecfg.status == dyncfg.StatusDisabled {
451 - m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
452 - }
453 -
454 - m.retryingTasks.remove(ecfg.cfg)
455 -
456 - if err := job.AutoDetection(); err != nil {
457 - job.Cleanup()
458 - ecfg.status = dyncfg.StatusFailed
459 - m.dyncfgApi.SendCodef(fn, 200, "Job enable failed: %v.", err)
460 -
461 - if isStock(ecfg.cfg) {
462 - m.exposedConfigs.remove(ecfg.cfg)
463 - m.dyncfgJobRemove(ecfg.cfg)
464 - } else {
465 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
466 - }
467 -
468 - m.runRetryTask(ecfg, job)
469 - return
470 - }
471 -
472 - ecfg.status = dyncfg.StatusRunning
473 -
474 - if isDyncfg(ecfg.cfg) {
475 - m.fileStatus.add(ecfg.cfg, ecfg.status.String())
476 - }
477 -
478 - m.startRunningJob(job)
479 -
480 - m.dyncfgApi.SendCodef(fn, 200, "")
481 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
482 -}
483 -
484 -func (m *Manager) dyncfgConfigDisable(fn dyncfg.Function) {
485 - cmd := fn.Command()
486 -
487 - id := fn.ID()
488 - mn, jn, ok := m.extractModuleJobName(id)
489 - if !ok {
490 - m.Warningf("dyncfg: %s: could not extract module from id (%s)", cmd, id)
491 - m.dyncfgApi.SendCodef(fn, 400, "Invalid ID format. Could not extract module name from ID. Provided ID: %s.", id)
492 - return
493 - }
494 -
495 - ecfg, ok := m.exposedConfigs.lookupByName(mn, jn)
496 - if !ok {
497 - m.Warningf("dyncfg: %s: module %s job %s not found", cmd, mn, jn)
498 - m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' job '%s' is not registered.", mn, jn)
499 - return
500 - }
501 -
502 - if ecfg.cfg.FullName() == m.waitCfgOnOff {
503 - m.waitCfgOnOff = ""
504 - }
505 -
506 - switch ecfg.status {
507 - case dyncfg.StatusDisabled:
508 - m.dyncfgApi.SendCodef(fn, 200, "")
509 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
510 - return
511 - case dyncfg.StatusRunning:
512 - m.stopRunningJob(ecfg.cfg.FullName())
513 - if isDyncfg(ecfg.cfg) {
514 - m.fileStatus.remove(ecfg.cfg)
515 - }
516 - default:
517 - }
518 -
519 - m.retryingTasks.remove(ecfg.cfg)
520 -
521 - m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
522 -
523 - ecfg.status = dyncfg.StatusDisabled
524 - m.dyncfgApi.SendCodef(fn, 200, "")
525 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
526 -}
527 -
528 -func (m *Manager) dyncfgConfigAdd(fn dyncfg.Function) {
529 - cmd := fn.Command()
530 -
531 - if err := fn.ValidateArgs(3); err != nil {
532 - m.Warningf("dyncfg: %s: %v", cmd, err)
533 - m.dyncfgApi.SendCodef(fn, 400, "%v", err)
534 - return
535 - }
536 -
537 - id := fn.ID()
538 - jn := fn.JobName()
539 - mn, ok := m.extractModuleName(id)
540 - if !ok {
541 - m.Warningf("dyncfg: %s: could not extract module from id (%s)", cmd, id)
542 - m.dyncfgApi.SendCodef(fn, 400, "Invalid ID format. Could not extract module name from ID. Provided ID: %s.", id)
543 - return
544 - }
545 -
546 - if !fn.HasPayload() {
547 - m.Warningf("dyncfg: %s: module %s job %s missing configuration payload.", cmd, mn, jn)
548 - m.dyncfgApi.SendCodef(fn, 400, "Missing configuration payload.")
549 - return
550 - }
551 -
552 - if err := validateJobName(jn); err != nil {
553 - m.Warningf("dyncfg: %s: module %s: unacceptable job name '%s': %v", cmd, mn, jn, err)
554 - m.dyncfgApi.SendCodef(fn, 400, "Unacceptable job name '%s': %v.", jn, err)
555 - return
556 - }
557 -
558 - cfg, err := configFromPayload(fn)
559 - if err != nil {
560 - m.Warningf("dyncfg: %s: module %s job %s: failed to create config from payload: %v", cmd, mn, jn, err)
561 - m.dyncfgApi.SendCodef(fn, 400, "Invalid configuration format. Failed to create configuration from payload: %v.", err)
562 - return
563 - }
564 -
565 - m.dyncfgSetConfigMeta(cfg, mn, jn, fn)
566 -
567 - if _, err := m.createCollectorJob(cfg); err != nil {
568 - m.Warningf("dyncfg: %s: module %s job %s: failed to apply config: %v", cmd, mn, jn, err)
569 - m.dyncfgApi.SendCodef(fn, 400, "Invalid configuration. Failed to apply configuration: %v.", err)
570 - return
571 - }
572 -
573 - m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
574 -
575 - if ecfg, ok := m.exposedConfigs.lookup(cfg); ok {
576 - if scfg, ok := m.seenConfigs.lookup(ecfg.cfg); ok && isDyncfg(scfg.cfg) {
577 - m.seenConfigs.remove(ecfg.cfg)
578 - }
579 - m.exposedConfigs.remove(ecfg.cfg)
580 - m.retryingTasks.remove(ecfg.cfg)
581 - m.stopRunningJob(ecfg.cfg.FullName())
582 - }
583 -
584 - scfg := &seenConfig{cfg: cfg, status: dyncfg.StatusAccepted}
585 - ecfg := scfg
586 - m.seenConfigs.add(scfg)
587 - m.exposedConfigs.add(ecfg)
588 -
589 - m.dyncfgApi.SendCodef(fn, 202, "")
590 - m.dyncfgCollectorJobCreate(ecfg.cfg, ecfg.status)
591 -}
592 -
593 -func (m *Manager) dyncfgConfigRemove(fn dyncfg.Function) {
594 - cmd := fn.Command()
595 -
596 - id := fn.ID()
597 - mn, jn, ok := m.extractModuleJobName(id)
598 - if !ok {
599 - m.Warningf("dyncfg: %s: could not extract module and job from id (%s)", cmd, id)
600 - m.dyncfgApi.SendCodef(fn, 400, "Invalid ID format. Could not extract module and job name from ID. Provided ID: %s.", id)
601 - return
602 - }
603 -
604 - ecfg, ok := m.exposedConfigs.lookupByName(mn, jn)
605 - if !ok {
606 - m.Warningf("dyncfg: %s: module %s job %s not found", cmd, mn, jn)
607 - m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' job '%s' is not registered.", mn, jn)
608 - return
609 - }
610 -
611 - if !isDyncfg(ecfg.cfg) {
612 - m.Warningf("dyncfg: %s: module %s job %s: can not remove jobs of type %s", cmd, mn, jn, ecfg.cfg.SourceType())
613 - m.dyncfgApi.SendCodef(fn, 405, "Removing jobs of type '%s' is not supported. Only 'dyncfg' jobs can be removed.", ecfg.cfg.SourceType())
614 - return
615 - }
616 -
617 - m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
618 -
619 - m.retryingTasks.remove(ecfg.cfg)
620 - m.seenConfigs.remove(ecfg.cfg)
621 - m.exposedConfigs.remove(ecfg.cfg)
622 - m.stopRunningJob(ecfg.cfg.FullName())
623 - m.fileStatus.remove(ecfg.cfg)
624 -
625 - m.dyncfgApi.SendCodef(fn, 200, "")
626 - m.dyncfgJobRemove(ecfg.cfg)
627 -}
628 -
629 -func (m *Manager) dyncfgConfigUpdate(fn dyncfg.Function) {
630 - cmd := fn.Command()
631 -
632 - id := fn.ID()
633 - mn, jn, ok := m.extractModuleJobName(id)
634 - if !ok {
635 - m.Warningf("dyncfg: %s: could not extract module from id (%s)", cmd, id)
636 - m.dyncfgApi.SendCodef(fn, 400, "Invalid ID format. Could not extract module name from ID. Provided ID: %s.", id)
637 - return
638 - }
639 -
640 - ecfg, ok := m.exposedConfigs.lookupByName(mn, jn)
641 - if !ok {
642 - m.Warningf("dyncfg: %s: module %s job %s not found", cmd, mn, jn)
643 - m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' job '%s' is not registered.", mn, jn)
644 - return
645 - }
646 -
647 - cfg, err := configFromPayload(fn)
648 - if err != nil {
649 - m.Warningf("dyncfg: %s: module %s: failed to create config from payload: %v", cmd, mn, err)
650 - m.dyncfgApi.SendCodef(fn, 400, "Invalid configuration format. Failed to create configuration from payload: %v.", err)
651 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
652 - return
653 - }
654 -
655 - m.dyncfgSetConfigMeta(cfg, mn, jn, fn)
656 -
657 - if ecfg.status == dyncfg.StatusRunning && ecfg.cfg.UID() == cfg.UID() {
658 - m.dyncfgApi.SendCodef(fn, 200, "")
659 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
660 - return
661 - }
662 -
663 - job, err := m.createCollectorJob(cfg)
664 - if err != nil {
665 - m.Warningf("dyncfg: %s: module %s job %s: failed to apply config: %v", cmd, mn, jn, err)
666 - m.dyncfgApi.SendCodef(fn, 400, "Invalid configuration. Failed to apply configuration: %v.", err)
667 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
668 - return
669 - }
670 -
671 - if ecfg.status == dyncfg.StatusAccepted {
672 - m.Warningf("dyncfg: %s: module %s job %s: updating not allowed in %s", cmd, mn, jn, ecfg.status)
673 - m.dyncfgApi.SendCodef(fn, 403, "Updating data collection job is not allowed in '%s' state.", ecfg.status)
674 - m.dyncfgJobStatus(ecfg.cfg, ecfg.status)
675 - return
676 - }
677 -
678 - m.Infof("dyncfg: %s: %s/%s job by user '%s'", cmd, mn, jn, fn.User())
679 -
680 - m.exposedConfigs.remove(ecfg.cfg)
681 - m.stopRunningJob(ecfg.cfg.FullName())
682 -
683 - scfg := &seenConfig{cfg: cfg, status: dyncfg.StatusAccepted}
684 - m.seenConfigs.add(scfg)
685 - m.exposedConfigs.add(scfg)
686 -
687 - if isDyncfg(ecfg.cfg) {
688 - m.seenConfigs.remove(ecfg.cfg)
689 - } else {
690 - // Needed to update meta. There is no other way, unfortunately, but to send "create".
691 - defer m.dyncfgCollectorJobCreate(scfg.cfg, scfg.status)
692 - }
693 -
694 - if ecfg.status == dyncfg.StatusDisabled {
695 - scfg.status = dyncfg.StatusDisabled
696 -
697 - m.dyncfgApi.SendCodef(fn, 200, "")
698 - m.dyncfgJobStatus(cfg, scfg.status)
699 - return
700 - }
701 -
702 - m.retryingTasks.remove(ecfg.cfg)
703 -
704 - if err := job.AutoDetection(); err != nil {
705 - job.Cleanup()
706 - scfg.status = dyncfg.StatusFailed
707 -
708 - m.dyncfgApi.SendCodef(fn, 200, "Job update failed: %v", err)
709 - m.dyncfgJobStatus(scfg.cfg, scfg.status)
710 - m.runRetryTask(scfg, job)
711 - return
712 - }
713 -
714 - scfg.status = dyncfg.StatusRunning
715 - m.startRunningJob(job)
716 -
717 - m.dyncfgApi.SendCodef(fn, 200, "")
718 - m.dyncfgJobStatus(scfg.cfg, scfg.status)
719 -}
720 -
326 func (m *Manager) dyncfgSetConfigMeta(cfg confgroup.Config, module, name string, fn dyncfg.Function) {
327 cfg.SetProvider("dyncfg")
328 cfg.SetSource(fn.Source())
@@ -729,17 +334,18 @@ func (m *Manager) dyncfgSetConfigMeta(cfg confgroup.Config, module, name string,
334 }
335 }
336
732 -func (m *Manager) runRetryTask(ecfg *seenConfig, job *module.Job) {
337 +// scheduleRetryTask schedules a retry if the job supports auto-detection retry.
338 +func (m *Manager) scheduleRetryTask(cfg confgroup.Config, job *module.Job) {
339 if !job.RetryAutoDetection() {
340 return
341 }
342 m.Infof("%s[%s] job detection failed, will retry in %d seconds",
737 - ecfg.cfg.Module(), ecfg.cfg.Name(), job.AutoDetectionEvery())
343 + cfg.Module(), cfg.Name(), job.AutoDetectionEvery())
344
345 ctx, cancel := context.WithCancel(m.ctx)
740 - m.retryingTasks.add(ecfg.cfg, &retryTask{cancel: cancel})
346 + m.retryingTasks.add(cfg, &retryTask{cancel: cancel})
347
742 - go runRetryTask(ctx, m.addCh, ecfg.cfg)
348 + go runRetryTask(ctx, m.addCh, cfg)
349 }
350
351 func userConfigFromPayload(cfg any, jobName string, fn dyncfg.Function) ([]byte, error) {
@@ -813,15 +419,119 @@ func extractJobName(id string) (string, bool) {
419 return id[i+1:], true
420 }
421
816 -func validateJobName(jobName string) error {
817 - for _, r := range jobName {
818 - if unicode.IsSpace(r) {
819 - return errors.New("contains spaces")
422 +// --- collectorCallbacks implements dyncfg.Callbacks[confgroup.Config] ---
423 +
424 +type collectorCallbacks struct {
425 + mgr *Manager
426 +}
427 +
428 +func (cb *collectorCallbacks) ExtractKey(fn dyncfg.Function) (key, name string, ok bool) {
429 + var mn, jn string
430 +
431 + if fn.Command() == dyncfg.CommandAdd {
432 + // For add: ID is module template, job name is in Args[2].
433 + mn, ok = cb.mgr.extractModuleName(fn.ID())
434 + if !ok {
435 + return "", "", false
436 + }
437 + jn = fn.JobName()
438 + if jn == "" {
439 + return "", "", false
440 }
821 - switch r {
822 - case '.', ':':
823 - return fmt.Errorf("contains '%c'", r)
441 + } else {
442 + // For other commands: ID contains module:job.
443 + mn, jn, ok = cb.mgr.extractModuleJobName(fn.ID())
444 + if !ok {
445 + return "", "", false
446 }
447 }
448 +
449 + key = mn + "_" + jn
450 + if mn == jn {
451 + key = jn
452 + }
453 + return key, jn, true
454 +}
455 +
456 +func (cb *collectorCallbacks) ParseAndValidate(fn dyncfg.Function, name string) (confgroup.Config, error) {
457 + mn, ok := cb.mgr.extractModuleName(fn.ID())
458 + if !ok {
459 + return nil, fmt.Errorf("could not extract module name from ID: %s", fn.ID())
460 + }
461 +
462 + cfg, err := configFromPayload(fn)
463 + if err != nil {
464 + return nil, fmt.Errorf("invalid configuration format: failed to create configuration from payload: %v", err)
465 + }
466 +
467 + cb.mgr.dyncfgSetConfigMeta(cfg, mn, name, fn)
468 +
469 + if _, err := cb.mgr.createCollectorJob(cfg); err != nil {
470 + return nil, fmt.Errorf("invalid configuration: failed to apply configuration: %v", err)
471 + }
472 +
473 + return cfg, nil
474 +}
475 +
476 +func (cb *collectorCallbacks) Start(cfg confgroup.Config) error {
477 + cb.mgr.retryingTasks.remove(cfg)
478 +
479 + job, err := cb.mgr.createCollectorJob(cfg)
480 + if err != nil {
481 + return &codedError{err: fmt.Errorf("invalid configuration: failed to apply configuration: %v", err), code: 400}
482 + }
483 +
484 + if err := job.AutoDetection(); err != nil {
485 + job.Cleanup()
486 + cb.mgr.scheduleRetryTask(cfg, job)
487 + return fmt.Errorf("job enable failed: %v", err)
488 + }
489 +
490 + cb.mgr.startRunningJob(job)
491 return nil
492 }
493 +
494 +func (cb *collectorCallbacks) Update(oldCfg, newCfg confgroup.Config) error {
495 + cb.mgr.retryingTasks.remove(oldCfg)
496 + cb.mgr.stopRunningJob(oldCfg.FullName())
497 + cb.mgr.fileStatus.remove(oldCfg)
498 +
499 + job, err := cb.mgr.createCollectorJob(newCfg)
500 + if err != nil {
501 + return fmt.Errorf("job update failed: %v", err)
502 + }
503 +
504 + if err := job.AutoDetection(); err != nil {
505 + job.Cleanup()
506 + cb.mgr.scheduleRetryTask(newCfg, job)
507 + return fmt.Errorf("job update failed: %v", err)
508 + }
509 +
510 + cb.mgr.startRunningJob(job)
511 + return nil
512 +}
513 +
514 +func (cb *collectorCallbacks) Stop(cfg confgroup.Config) {
515 + cb.mgr.retryingTasks.remove(cfg)
516 + cb.mgr.stopRunningJob(cfg.FullName())
517 + cb.mgr.fileStatus.remove(cfg)
518 +}
519 +
520 +func (cb *collectorCallbacks) OnStatusChange(entry *dyncfg.Entry[confgroup.Config], _ dyncfg.Status, _ dyncfg.Function) {
521 + if entry.Status == dyncfg.StatusRunning && isDyncfg(entry.Cfg) {
522 + cb.mgr.fileStatus.add(entry.Cfg, entry.Status.String())
523 + }
524 +}
525 +
526 +func (cb *collectorCallbacks) ConfigID(cfg confgroup.Config) string {
527 + return cb.mgr.dyncfgJobID(cfg)
528 +}
529 +
530 +// codedError wraps an error with an HTTP status code for the handler.
531 +type codedError struct {
532 + err error
533 + code int
534 +}
535 +
536 +func (e *codedError) Error() string { return e.err.Error() }
537 +func (e *codedError) Code() int { return e.code }
src/go/plugin/go.d/agent/jobmgr/dyncfg_vnode.go
+5 -4
@@ -320,14 +320,15 @@ func (m *Manager) dyncfgVnodeUserconfig(fn dyncfg.Function) {
320
321 func (m *Manager) dyncfgVnodeAffectedJobs(vnode string) string {
322 var s strings.Builder
323 - for _, ecfg := range m.exposedConfigs.items {
324 - if ecfg.cfg.Vnode() == vnode {
323 + m.exposed.ForEach(func(_ string, entry *dyncfg.Entry[confgroup.Config]) bool {
324 + if entry.Cfg.Vnode() == vnode {
325 if s.Len() > 0 {
326 s.WriteString(", ")
327 }
328 - s.WriteString(fmt.Sprintf("%s:%s", ecfg.cfg.Module(), ecfg.cfg.Name()))
328 + s.WriteString(fmt.Sprintf("%s:%s", entry.Cfg.Module(), entry.Cfg.Name()))
329 }
330 - }
330 + return true
331 + })
332 return s.String()
333 }
334
src/go/plugin/go.d/agent/jobmgr/manager.go
+60 -33
@@ -15,6 +15,7 @@ import (
15 "time"
16
17 "github.com/netdata/netdata/go/plugins/logger"
18 + "github.com/netdata/netdata/go/plugins/pkg/executable"
19 "github.com/netdata/netdata/go/plugins/pkg/funcapi"
20 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
21 "github.com/netdata/netdata/go/plugins/pkg/safewriter"
@@ -32,6 +33,10 @@ import (
33 var isTerminal = isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsTerminal(os.Stdin.Fd())
34
35 func New() *Manager {
36 + seen := dyncfg.NewSeenCache[confgroup.Config]()
37 + exposed := dyncfg.NewExposedCache[confgroup.Config]()
38 + api := dyncfg.NewResponder(netdataapi.New(safewriter.Stdout))
39 +
40 mgr := &Manager{
41 Logger: logger.New().With(
42 slog.String("component", "job manager"),
@@ -43,8 +48,8 @@ func New() *Manager {
48
49 moduleFuncs: newModuleFuncRegistry(),
50 discoveredConfigs: newDiscoveredConfigsCache(),
46 - seenConfigs: newSeenConfigCache(),
47 - exposedConfigs: newExposedConfigCache(),
51 + seen: seen,
52 + exposed: exposed,
53 runningJobs: newRunningJobsCache(),
54 retryingTasks: newRetryingTasksCache(),
55
@@ -52,8 +57,31 @@ func New() *Manager {
57 addCh: make(chan confgroup.Config),
58 rmCh: make(chan confgroup.Config),
59 dyncfgCh: make(chan dyncfg.Function),
55 - dyncfgApi: dyncfg.NewResponder(netdataapi.New(safewriter.Stdout)),
56 - }
60 + dyncfgApi: api,
61 + }
62 +
63 + mgr.collectorCb = &collectorCallbacks{mgr: mgr}
64 + mgr.handler = dyncfg.NewHandler(dyncfg.HandlerOpts[confgroup.Config]{
65 + Logger: mgr.Logger,
66 + API: api,
67 + Seen: seen,
68 + Exposed: exposed,
69 + Callbacks: mgr.collectorCb,
70 +
71 + Path: fmt.Sprintf(dyncfgCollectorPath, executable.Name),
72 + EnableFailCode: 200,
73 + RemoveStockOnEnableFail: true,
74 + JobCommands: []dyncfg.Command{
75 + dyncfg.CommandSchema,
76 + dyncfg.CommandGet,
77 + dyncfg.CommandEnable,
78 + dyncfg.CommandDisable,
79 + dyncfg.CommandUpdate,
80 + dyncfg.CommandRestart,
81 + dyncfg.CommandTest,
82 + dyncfg.CommandUserconfig,
83 + },
84 + })
85
86 return mgr
87 }
@@ -62,6 +90,7 @@ func New() *Manager {
90 func (m *Manager) SetDyncfgResponder(responder *dyncfg.Responder) {
91 if responder != nil {
92 m.dyncfgApi = responder
93 + m.handler.SetAPI(responder)
94 }
95 }
96
@@ -86,11 +115,14 @@ type Manager struct {
115 moduleFuncs *moduleFuncRegistry
116
117 discoveredConfigs *discoveredConfigs
89 - seenConfigs *seenConfigs
90 - exposedConfigs *exposedConfigs
118 + seen *dyncfg.SeenCache[confgroup.Config]
119 + exposed *dyncfg.ExposedCache[confgroup.Config]
120 retryingTasks *retryingTasks
121 runningJobs *runningJobs
122
123 + handler *dyncfg.Handler[confgroup.Config]
124 + collectorCb *collectorCallbacks
125 +
126 ctx context.Context
127 started chan struct{}
128 //api dyncfgAPI
@@ -265,60 +297,55 @@ func (m *Manager) addConfig(cfg confgroup.Config) {
297
298 m.retryingTasks.remove(cfg)
299
268 - scfg, ok := m.seenConfigs.lookup(cfg)
269 - if !ok {
270 - scfg = &seenConfig{cfg: cfg}
271 - m.seenConfigs.add(scfg)
300 + if _, ok := m.seen.Lookup(cfg); !ok {
301 + m.seen.Add(cfg)
302 }
303
274 - ecfg, ok := m.exposedConfigs.lookup(cfg)
304 + entry, ok := m.exposed.LookupByKey(cfg.ExposedKey())
305 if !ok {
276 - scfg.status = dyncfg.StatusAccepted
277 - ecfg = scfg
278 - m.exposedConfigs.add(ecfg)
306 + entry = &dyncfg.Entry[confgroup.Config]{Cfg: cfg, Status: dyncfg.StatusAccepted}
307 + m.exposed.Add(entry)
308 } else {
280 - sp, ep := scfg.cfg.SourceTypePriority(), ecfg.cfg.SourceTypePriority()
281 - if ep > sp || (ep == sp && ecfg.status == dyncfg.StatusRunning) {
309 + sp, ep := cfg.SourceTypePriority(), entry.Cfg.SourceTypePriority()
310 + if ep > sp || (ep == sp && entry.Status == dyncfg.StatusRunning) {
311 return
312 }
284 - if ecfg.status == dyncfg.StatusRunning {
285 - m.stopRunningJob(ecfg.cfg.FullName())
286 - m.fileStatus.remove(ecfg.cfg)
313 + if entry.Status == dyncfg.StatusRunning {
314 + m.stopRunningJob(entry.Cfg.FullName())
315 + m.fileStatus.remove(entry.Cfg)
316 }
288 - scfg.status = dyncfg.StatusAccepted
289 - m.exposedConfigs.add(scfg) // replace existing exposed
290 - ecfg = scfg
317 + entry = &dyncfg.Entry[confgroup.Config]{Cfg: cfg, Status: dyncfg.StatusAccepted}
318 + m.exposed.Add(entry) // replace existing exposed
319 }
320
293 - m.dyncfgCollectorJobCreate(ecfg.cfg, ecfg.status)
321 + m.handler.NotifyJobCreate(entry.Cfg, entry.Status)
322
323 if isTerminal || m.PluginName == "nodyncfg" { // FIXME: quick fix of TestAgent_Run (agent_test.go)
296 - m.dyncfgConfigEnable(dyncfg.NewFunction(functions.Function{Args: []string{m.dyncfgJobID(ecfg.cfg), "enable"}}))
324 + m.handler.CmdEnable(dyncfg.NewFunction(functions.Function{Args: []string{m.dyncfgJobID(entry.Cfg), "enable"}}))
325 } else {
298 - m.waitCfgOnOff = ecfg.cfg.FullName()
326 + m.waitCfgOnOff = entry.Cfg.FullName()
327 }
328 }
329
330 func (m *Manager) removeConfig(cfg confgroup.Config) {
331 m.retryingTasks.remove(cfg)
332
305 - scfg, ok := m.seenConfigs.lookup(cfg)
306 - if !ok {
333 + if _, ok := m.seen.Lookup(cfg); !ok {
334 return
335 }
309 - m.seenConfigs.remove(cfg)
336 + m.seen.Remove(cfg)
337
311 - ecfg, ok := m.exposedConfigs.lookup(cfg)
312 - if !ok || scfg.cfg.UID() != ecfg.cfg.UID() {
338 + entry, ok := m.exposed.LookupByKey(cfg.ExposedKey())
339 + if !ok || cfg.UID() != entry.Cfg.UID() {
340 return
341 }
342
316 - m.exposedConfigs.remove(cfg)
343 + m.exposed.Remove(cfg)
344 m.stopRunningJob(cfg.FullName())
345 m.fileStatus.remove(cfg)
346
320 - if !isStock(cfg) || ecfg.status == dyncfg.StatusRunning {
321 - m.dyncfgJobRemove(cfg)
347 + if !isStock(cfg) || entry.Status == dyncfg.StatusRunning {
348 + m.handler.NotifyJobRemove(cfg)
349 }
350 }
351
src/go/plugin/go.d/agent/jobmgr/manager_test.go
+114 -114
@@ -61,8 +61,8 @@ CONFIG test:collector:success:name delete
61 }))
62 },
63 wantDiscovered: []confgroup.Config{cfg},
64 - wantSeen: []seenConfig{
65 - {cfg: cfg, status: dyncfg.StatusFailed},
64 + wantSeen: []confgroup.Config{
65 + cfg,
66 },
67 wantExposed: nil,
68 wantRunning: nil,
@@ -70,7 +70,7 @@ CONFIG test:collector:success:name delete
70 CONFIG test:collector:fail:name create accepted job /collectors/test/Jobs stock 'type=stock,module=fail,job=name' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
71
72 FUNCTION_RESULT_BEGIN 1-enable 200 application/json
73 -{"status":200,"message":"Job enable failed: mock failed init."}
73 +{"status":200,"message":"job enable failed: mock failed init"}
74 FUNCTION_RESULT_END
75
76 CONFIG test:collector:fail:name delete
@@ -100,7 +100,7 @@ CONFIG test:collector:fail:name delete
100 CONFIG test:collector:fail:name create accepted job /collectors/test/Jobs stock 'type=stock,module=fail,job=name' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
101
102 FUNCTION_RESULT_BEGIN 1-enable 200 application/json
103 -{"status":200,"message":"Job enable failed: mock failed init."}
103 +{"status":200,"message":"job enable failed: mock failed init"}
104 FUNCTION_RESULT_END
105
106 CONFIG test:collector:fail:name delete
@@ -162,7 +162,7 @@ CONFIG test:collector:success:name delete
162 CONFIG test:collector:fail:name create accepted job /collectors/test/Jobs user 'type=user,module=fail,job=name' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
163
164 FUNCTION_RESULT_BEGIN 1-enable 200 application/json
165 -{"status":200,"message":"Job enable failed: mock failed init."}
165 +{"status":200,"message":"job enable failed: mock failed init"}
166 FUNCTION_RESULT_END
167
168 CONFIG test:collector:fail:name status failed
@@ -226,7 +226,7 @@ CONFIG test:collector:success:name delete
226 CONFIG test:collector:fail:name create accepted job /collectors/test/Jobs discovered 'type=discovered,module=fail,job=name' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
227
228 FUNCTION_RESULT_BEGIN 1-enable 200 application/json
229 -{"status":200,"message":"Job enable failed: mock failed init."}
229 +{"status":200,"message":"job enable failed: mock failed init"}
230 FUNCTION_RESULT_END
231
232 CONFIG test:collector:fail:name status failed
@@ -267,12 +267,12 @@ CONFIG test:collector:fail:name delete
267 userCfg,
268 discCfg,
269 },
270 - wantSeen: []seenConfig{
271 - {cfg: stockCfg, status: dyncfg.StatusFailed},
272 - {cfg: discCfg, status: dyncfg.StatusFailed},
273 - {cfg: userCfg, status: dyncfg.StatusFailed},
270 + wantSeen: []confgroup.Config{
271 + stockCfg,
272 + discCfg,
273 + userCfg,
274 },
275 - wantExposed: []seenConfig{
275 + wantExposed: []wantExposedEntry{
276 {cfg: discCfg, status: dyncfg.StatusFailed},
277 {cfg: userCfg, status: dyncfg.StatusFailed},
278 },
@@ -281,7 +281,7 @@ CONFIG test:collector:fail:name delete
281 CONFIG test:collector:fail:stock create accepted job /collectors/test/Jobs stock 'type=stock,module=fail,job=stock' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
282
283 FUNCTION_RESULT_BEGIN 1-enable 200 application/json
284 -{"status":200,"message":"Job enable failed: mock failed init."}
284 +{"status":200,"message":"job enable failed: mock failed init"}
285 FUNCTION_RESULT_END
286
287 CONFIG test:collector:fail:stock delete
@@ -289,7 +289,7 @@ CONFIG test:collector:fail:stock delete
289 CONFIG test:collector:fail:discovered create accepted job /collectors/test/Jobs discovered 'type=discovered,module=fail,job=discovered' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
290
291 FUNCTION_RESULT_BEGIN 2-enable 200 application/json
292 -{"status":200,"message":"Job enable failed: mock failed init."}
292 +{"status":200,"message":"job enable failed: mock failed init"}
293 FUNCTION_RESULT_END
294
295 CONFIG test:collector:fail:discovered status failed
@@ -297,7 +297,7 @@ CONFIG test:collector:fail:discovered status failed
297 CONFIG test:collector:fail:user create accepted job /collectors/test/Jobs user 'type=user,module=fail,job=user' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
298
299 FUNCTION_RESULT_BEGIN 3-enable 200 application/json
300 -{"status":200,"message":"Job enable failed: mock failed init."}
300 +{"status":200,"message":"job enable failed: mock failed init"}
301 FUNCTION_RESULT_END
302
303 CONFIG test:collector:fail:user status failed
@@ -336,12 +336,12 @@ CONFIG test:collector:fail:user status failed
336 userCfg,
337 discCfg,
338 },
339 - wantSeen: []seenConfig{
340 - {cfg: stockCfg, status: dyncfg.StatusFailed},
341 - {cfg: discCfg, status: dyncfg.StatusFailed},
342 - {cfg: userCfg, status: dyncfg.StatusFailed},
339 + wantSeen: []confgroup.Config{
340 + stockCfg,
341 + discCfg,
342 + userCfg,
343 },
344 - wantExposed: []seenConfig{
344 + wantExposed: []wantExposedEntry{
345 {cfg: userCfg, status: dyncfg.StatusFailed},
346 },
347 wantRunning: nil,
@@ -349,7 +349,7 @@ CONFIG test:collector:fail:user status failed
349 CONFIG test:collector:fail:name create accepted job /collectors/test/Jobs stock 'type=stock,module=fail,job=name' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
350
351 FUNCTION_RESULT_BEGIN 1-enable 200 application/json
352 -{"status":200,"message":"Job enable failed: mock failed init."}
352 +{"status":200,"message":"job enable failed: mock failed init"}
353 FUNCTION_RESULT_END
354
355 CONFIG test:collector:fail:name delete
@@ -357,7 +357,7 @@ CONFIG test:collector:fail:name delete
357 CONFIG test:collector:fail:name create accepted job /collectors/test/Jobs discovered 'type=discovered,module=fail,job=name' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
358
359 FUNCTION_RESULT_BEGIN 2-enable 200 application/json
360 -{"status":200,"message":"Job enable failed: mock failed init."}
360 +{"status":200,"message":"job enable failed: mock failed init"}
361 FUNCTION_RESULT_END
362
363 CONFIG test:collector:fail:name status failed
@@ -365,7 +365,7 @@ CONFIG test:collector:fail:name status failed
365 CONFIG test:collector:fail:name create accepted job /collectors/test/Jobs user 'type=user,module=fail,job=name' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
366
367 FUNCTION_RESULT_BEGIN 3-enable 200 application/json
368 -{"status":200,"message":"Job enable failed: mock failed init."}
368 +{"status":200,"message":"job enable failed: mock failed init"}
369 FUNCTION_RESULT_END
370
371 CONFIG test:collector:fail:name status failed
@@ -411,7 +411,7 @@ CONFIG test:collector:fail:name status failed
411 CONFIG test:collector:fail:name create accepted job /collectors/test/Jobs stock 'type=stock,module=fail,job=name' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
412
413 FUNCTION_RESULT_BEGIN 1-enable 200 application/json
414 -{"status":200,"message":"Job enable failed: mock failed init."}
414 +{"status":200,"message":"job enable failed: mock failed init"}
415 FUNCTION_RESULT_END
416
417 CONFIG test:collector:fail:name delete
@@ -419,7 +419,7 @@ CONFIG test:collector:fail:name delete
419 CONFIG test:collector:fail:name create accepted job /collectors/test/Jobs discovered 'type=discovered,module=fail,job=name' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
420
421 FUNCTION_RESULT_BEGIN 2-enable 200 application/json
422 -{"status":200,"message":"Job enable failed: mock failed init."}
422 +{"status":200,"message":"job enable failed: mock failed init"}
423 FUNCTION_RESULT_END
424
425 CONFIG test:collector:fail:name status failed
@@ -427,7 +427,7 @@ CONFIG test:collector:fail:name status failed
427 CONFIG test:collector:fail:name create accepted job /collectors/test/Jobs user 'type=user,module=fail,job=name' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
428
429 FUNCTION_RESULT_BEGIN 3-enable 200 application/json
430 -{"status":200,"message":"Job enable failed: mock failed init."}
430 +{"status":200,"message":"job enable failed: mock failed init"}
431 FUNCTION_RESULT_END
432
433 CONFIG test:collector:fail:name status failed
@@ -459,12 +459,12 @@ CONFIG test:collector:fail:name delete
459 userCfg,
460 discCfg,
461 },
462 - wantSeen: []seenConfig{
463 - {cfg: userCfg, status: dyncfg.StatusFailed},
464 - {cfg: discCfg},
465 - {cfg: stockCfg},
462 + wantSeen: []confgroup.Config{
463 + userCfg,
464 + discCfg,
465 + stockCfg,
466 },
467 - wantExposed: []seenConfig{
467 + wantExposed: []wantExposedEntry{
468 {cfg: userCfg, status: dyncfg.StatusFailed},
469 },
470 wantRunning: nil,
@@ -472,7 +472,7 @@ CONFIG test:collector:fail:name delete
472 CONFIG test:collector:fail:name create accepted job /collectors/test/Jobs user 'type=user,module=fail,job=name' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
473
474 FUNCTION_RESULT_BEGIN 1-enable 200 application/json
475 -{"status":200,"message":"Job enable failed: mock failed init."}
475 +{"status":200,"message":"job enable failed: mock failed init"}
476 FUNCTION_RESULT_END
477
478 CONFIG test:collector:fail:name status failed
@@ -509,7 +509,7 @@ CONFIG test:collector:fail:name status failed
509 CONFIG test:collector:fail:name create accepted job /collectors/test/Jobs user 'type=user,module=fail,job=name' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
510
511 FUNCTION_RESULT_BEGIN 1-enable 200 application/json
512 -{"status":200,"message":"Job enable failed: mock failed init."}
512 +{"status":200,"message":"job enable failed: mock failed init"}
513 FUNCTION_RESULT_END
514
515 CONFIG test:collector:fail:name status failed
@@ -578,10 +578,10 @@ FUNCTION_RESULT_END
578 }))
579 },
580 wantDiscovered: nil,
581 - wantSeen: []seenConfig{
582 - {cfg: cfg, status: dyncfg.StatusAccepted},
581 + wantSeen: []confgroup.Config{
582 + cfg,
583 },
584 - wantExposed: []seenConfig{
584 + wantExposed: []wantExposedEntry{
585 {cfg: cfg, status: dyncfg.StatusAccepted},
586 },
587 wantRunning: nil,
@@ -692,10 +692,10 @@ func TestManager_Run_Dyncfg_Add(t *testing.T) {
692 }))
693 },
694 wantDiscovered: nil,
695 - wantSeen: []seenConfig{
696 - {cfg: cfg, status: dyncfg.StatusAccepted},
695 + wantSeen: []confgroup.Config{
696 + cfg,
697 },
698 - wantExposed: []seenConfig{
698 + wantExposed: []wantExposedEntry{
699 {cfg: cfg, status: dyncfg.StatusAccepted},
700 },
701 wantRunning: nil,
@@ -724,10 +724,10 @@ CONFIG test:collector:success:test create accepted job /collectors/test/Jobs dyn
724 }))
725 },
726 wantDiscovered: nil,
727 - wantSeen: []seenConfig{
728 - {cfg: cfg, status: dyncfg.StatusAccepted},
727 + wantSeen: []confgroup.Config{
728 + cfg,
729 },
730 - wantExposed: []seenConfig{
730 + wantExposed: []wantExposedEntry{
731 {cfg: cfg, status: dyncfg.StatusAccepted},
732 },
733 wantRunning: nil,
@@ -762,10 +762,10 @@ CONFIG test:collector:fail:test create accepted job /collectors/test/Jobs dyncfg
762 }))
763 },
764 wantDiscovered: nil,
765 - wantSeen: []seenConfig{
766 - {cfg: cfg, status: dyncfg.StatusAccepted},
765 + wantSeen: []confgroup.Config{
766 + cfg,
767 },
768 - wantExposed: []seenConfig{
768 + wantExposed: []wantExposedEntry{
769 {cfg: cfg, status: dyncfg.StatusAccepted},
770 },
771 wantRunning: nil,
@@ -818,7 +818,7 @@ func TestManager_Run_Dyncfg_Enable(t *testing.T) {
818 wantDyncfg: `
819
820 FUNCTION_RESULT_BEGIN 1-enable 404 application/json
821 -{"status":404,"errorMessage":"The specified module 'success' job 'test' is not registered."}
821 +{"status":404,"errorMessage":"job not found."}
822 FUNCTION_RESULT_END
823 `,
824 }
@@ -842,10 +842,10 @@ FUNCTION_RESULT_END
842 }))
843 },
844 wantDiscovered: nil,
845 - wantSeen: []seenConfig{
846 - {cfg: cfg, status: dyncfg.StatusRunning},
845 + wantSeen: []confgroup.Config{
846 + cfg,
847 },
848 - wantExposed: []seenConfig{
848 + wantExposed: []wantExposedEntry{
849 {cfg: cfg, status: dyncfg.StatusRunning},
850 },
851 wantRunning: []string{cfg.FullName()},
@@ -888,10 +888,10 @@ CONFIG test:collector:success:test status running
888 }))
889 },
890 wantDiscovered: nil,
891 - wantSeen: []seenConfig{
892 - {cfg: cfg, status: dyncfg.StatusRunning},
891 + wantSeen: []confgroup.Config{
892 + cfg,
893 },
894 - wantExposed: []seenConfig{
894 + wantExposed: []wantExposedEntry{
895 {cfg: cfg, status: dyncfg.StatusRunning},
896 },
897 wantRunning: []string{cfg.FullName()},
@@ -936,10 +936,10 @@ CONFIG test:collector:success:test status running
936 }))
937 },
938 wantDiscovered: nil,
939 - wantSeen: []seenConfig{
940 - {cfg: cfg, status: dyncfg.StatusFailed},
939 + wantSeen: []confgroup.Config{
940 + cfg,
941 },
942 - wantExposed: []seenConfig{
942 + wantExposed: []wantExposedEntry{
943 {cfg: cfg, status: dyncfg.StatusFailed},
944 },
945 wantRunning: nil,
@@ -952,7 +952,7 @@ FUNCTION_RESULT_END
952 CONFIG test:collector:fail:test create accepted job /collectors/test/Jobs dyncfg 'type=dyncfg' 'schema get enable disable update restart test userconfig remove' 0x0000 0x0000
953
954 FUNCTION_RESULT_BEGIN 2-enable 200 application/json
955 -{"status":200,"message":"Job enable failed: mock failed init."}
955 +{"status":200,"message":"job enable failed: mock failed init"}
956 FUNCTION_RESULT_END
957
958 CONFIG test:collector:fail:test status failed
@@ -982,10 +982,10 @@ CONFIG test:collector:fail:test status failed
982 }))
983 },
984 wantDiscovered: nil,
985 - wantSeen: []seenConfig{
986 - {cfg: cfg, status: dyncfg.StatusFailed},
985 + wantSeen: []confgroup.Config{
986 + cfg,
987 },
988 - wantExposed: []seenConfig{
988 + wantExposed: []wantExposedEntry{
989 {cfg: cfg, status: dyncfg.StatusFailed},
990 },
991 wantRunning: nil,
@@ -998,13 +998,13 @@ FUNCTION_RESULT_END
998 CONFIG test:collector:fail:test create accepted job /collectors/test/Jobs dyncfg 'type=dyncfg' 'schema get enable disable update restart test userconfig remove' 0x0000 0x0000
999
1000 FUNCTION_RESULT_BEGIN 2-enable 200 application/json
1001 -{"status":200,"message":"Job enable failed: mock failed init."}
1001 +{"status":200,"message":"job enable failed: mock failed init"}
1002 FUNCTION_RESULT_END
1003
1004 CONFIG test:collector:fail:test status failed
1005
1006 FUNCTION_RESULT_BEGIN 3-enable 200 application/json
1007 -{"status":200,"message":"Job enable failed: mock failed init."}
1007 +{"status":200,"message":"job enable failed: mock failed init"}
1008 FUNCTION_RESULT_END
1009
1010 CONFIG test:collector:fail:test status failed
@@ -1044,7 +1044,7 @@ func TestManager_Run_Dyncfg_Disable(t *testing.T) {
1044 wantDyncfg: `
1045
1046 FUNCTION_RESULT_BEGIN 1-disable 404 application/json
1047 -{"status":404,"errorMessage":"The specified module 'success' job 'test' is not registered."}
1047 +{"status":404,"errorMessage":"job not found."}
1048 FUNCTION_RESULT_END
1049 `,
1050 }
@@ -1068,10 +1068,10 @@ FUNCTION_RESULT_END
1068 }))
1069 },
1070 wantDiscovered: nil,
1071 - wantSeen: []seenConfig{
1072 - {cfg: cfg, status: dyncfg.StatusDisabled},
1071 + wantSeen: []confgroup.Config{
1072 + cfg,
1073 },
1074 - wantExposed: []seenConfig{
1074 + wantExposed: []wantExposedEntry{
1075 {cfg: cfg, status: dyncfg.StatusDisabled},
1076 },
1077 wantRunning: nil,
@@ -1114,10 +1114,10 @@ CONFIG test:collector:success:test status disabled
1114 }))
1115 },
1116 wantDiscovered: nil,
1117 - wantSeen: []seenConfig{
1118 - {cfg: cfg, status: dyncfg.StatusDisabled},
1117 + wantSeen: []confgroup.Config{
1118 + cfg,
1119 },
1120 - wantExposed: []seenConfig{
1120 + wantExposed: []wantExposedEntry{
1121 {cfg: cfg, status: dyncfg.StatusDisabled},
1122 },
1123 wantRunning: nil,
@@ -1162,10 +1162,10 @@ CONFIG test:collector:success:test status disabled
1162 }))
1163 },
1164 wantDiscovered: nil,
1165 - wantSeen: []seenConfig{
1166 - {cfg: cfg, status: dyncfg.StatusDisabled},
1165 + wantSeen: []confgroup.Config{
1166 + cfg,
1167 },
1168 - wantExposed: []seenConfig{
1168 + wantExposed: []wantExposedEntry{
1169 {cfg: cfg, status: dyncfg.StatusDisabled},
1170 },
1171 wantRunning: nil,
@@ -1208,10 +1208,10 @@ CONFIG test:collector:fail:test status disabled
1208 }))
1209 },
1210 wantDiscovered: nil,
1211 - wantSeen: []seenConfig{
1212 - {cfg: cfg, status: dyncfg.StatusDisabled},
1211 + wantSeen: []confgroup.Config{
1212 + cfg,
1213 },
1214 - wantExposed: []seenConfig{
1214 + wantExposed: []wantExposedEntry{
1215 {cfg: cfg, status: dyncfg.StatusDisabled},
1216 },
1217 wantRunning: nil,
@@ -1270,7 +1270,7 @@ func TestManager_Run_Dyncfg_Restart(t *testing.T) {
1270 wantDyncfg: `
1271
1272 FUNCTION_RESULT_BEGIN 1-restart 404 application/json
1273 -{"status":404,"errorMessage":"The specified module 'success' job 'test' is not registered."}
1273 +{"status":404,"errorMessage":"job not found."}
1274 FUNCTION_RESULT_END
1275 `,
1276 }
@@ -1294,10 +1294,10 @@ FUNCTION_RESULT_END
1294 }))
1295 },
1296 wantDiscovered: nil,
1297 - wantSeen: []seenConfig{
1298 - {cfg: cfg, status: dyncfg.StatusAccepted},
1297 + wantSeen: []confgroup.Config{
1298 + cfg,
1299 },
1300 - wantExposed: []seenConfig{
1300 + wantExposed: []wantExposedEntry{
1301 {cfg: cfg, status: dyncfg.StatusAccepted},
1302 },
1303 wantRunning: nil,
@@ -1310,7 +1310,7 @@ FUNCTION_RESULT_END
1310 CONFIG test:collector:success:test create accepted job /collectors/test/Jobs dyncfg 'type=dyncfg' 'schema get enable disable update restart test userconfig remove' 0x0000 0x0000
1311
1312 FUNCTION_RESULT_BEGIN 2-restart 405 application/json
1313 -{"status":405,"errorMessage":"Restarting data collection job is not allowed in 'accepted' state."}
1313 +{"status":405,"errorMessage":"restarting is not allowed in 'accepted' state."}
1314 FUNCTION_RESULT_END
1315
1316 CONFIG test:collector:success:test status accepted
@@ -1340,10 +1340,10 @@ CONFIG test:collector:success:test status accepted
1340 }))
1341 },
1342 wantDiscovered: nil,
1343 - wantSeen: []seenConfig{
1344 - {cfg: cfg, status: dyncfg.StatusRunning},
1343 + wantSeen: []confgroup.Config{
1344 + cfg,
1345 },
1346 - wantExposed: []seenConfig{
1346 + wantExposed: []wantExposedEntry{
1347 {cfg: cfg, status: dyncfg.StatusRunning},
1348 },
1349 wantRunning: []string{cfg.FullName()},
@@ -1392,10 +1392,10 @@ CONFIG test:collector:success:test status running
1392 }))
1393 },
1394 wantDiscovered: nil,
1395 - wantSeen: []seenConfig{
1396 - {cfg: cfg, status: dyncfg.StatusDisabled},
1395 + wantSeen: []confgroup.Config{
1396 + cfg,
1397 },
1398 - wantExposed: []seenConfig{
1398 + wantExposed: []wantExposedEntry{
1399 {cfg: cfg, status: dyncfg.StatusDisabled},
1400 },
1401 wantRunning: nil,
@@ -1414,7 +1414,7 @@ FUNCTION_RESULT_END
1414 CONFIG test:collector:success:test status disabled
1415
1416 FUNCTION_RESULT_BEGIN 3-restart 405 application/json
1417 -{"status":405,"errorMessage":"Restarting data collection job is not allowed in 'disabled' state."}
1417 +{"status":405,"errorMessage":"restarting is not allowed in 'disabled' state."}
1418 FUNCTION_RESULT_END
1419
1420 CONFIG test:collector:success:test status disabled
@@ -1448,10 +1448,10 @@ CONFIG test:collector:success:test status disabled
1448 }))
1449 },
1450 wantDiscovered: nil,
1451 - wantSeen: []seenConfig{
1452 - {cfg: cfg, status: dyncfg.StatusRunning},
1451 + wantSeen: []confgroup.Config{
1452 + cfg,
1453 },
1454 - wantExposed: []seenConfig{
1454 + wantExposed: []wantExposedEntry{
1455 {cfg: cfg, status: dyncfg.StatusRunning},
1456 },
1457 wantRunning: []string{cfg.FullName()},
@@ -1516,7 +1516,7 @@ func TestManager_Run_Dyncfg_Remove(t *testing.T) {
1516 wantDyncfg: `
1517
1518 FUNCTION_RESULT_BEGIN 1-remove 404 application/json
1519 -{"status":404,"errorMessage":"The specified module 'success' job 'test' is not registered."}
1519 +{"status":404,"errorMessage":"job not found."}
1520 FUNCTION_RESULT_END
1521 `,
1522 }
@@ -1566,12 +1566,12 @@ FUNCTION_RESULT_END
1566 userCfg,
1567 discCfg,
1568 },
1569 - wantSeen: []seenConfig{
1570 - {cfg: stockCfg, status: dyncfg.StatusRunning},
1571 - {cfg: userCfg, status: dyncfg.StatusRunning},
1572 - {cfg: discCfg, status: dyncfg.StatusRunning},
1569 + wantSeen: []confgroup.Config{
1570 + stockCfg,
1571 + userCfg,
1572 + discCfg,
1573 },
1574 - wantExposed: []seenConfig{
1574 + wantExposed: []wantExposedEntry{
1575 {cfg: stockCfg, status: dyncfg.StatusRunning},
1576 {cfg: userCfg, status: dyncfg.StatusRunning},
1577 {cfg: discCfg, status: dyncfg.StatusRunning},
@@ -1603,15 +1603,15 @@ FUNCTION_RESULT_END
1603 CONFIG test:collector:success:discovered status running
1604
1605 FUNCTION_RESULT_BEGIN 1-remove 405 application/json
1606 -{"status":405,"errorMessage":"Removing jobs of type 'stock' is not supported. Only 'dyncfg' jobs can be removed."}
1606 +{"status":405,"errorMessage":"removing jobs of type 'stock' is not supported, only 'dyncfg' jobs can be removed."}
1607 FUNCTION_RESULT_END
1608
1609 FUNCTION_RESULT_BEGIN 2-remove 405 application/json
1610 -{"status":405,"errorMessage":"Removing jobs of type 'user' is not supported. Only 'dyncfg' jobs can be removed."}
1610 +{"status":405,"errorMessage":"removing jobs of type 'user' is not supported, only 'dyncfg' jobs can be removed."}
1611 FUNCTION_RESULT_END
1612
1613 FUNCTION_RESULT_BEGIN 3-remove 405 application/json
1614 -{"status":405,"errorMessage":"Removing jobs of type 'discovered' is not supported. Only 'dyncfg' jobs can be removed."}
1614 +{"status":405,"errorMessage":"removing jobs of type 'discovered' is not supported, only 'dyncfg' jobs can be removed."}
1615 FUNCTION_RESULT_END
1616 `,
1617 }
@@ -1736,7 +1736,7 @@ func TestManager_Run_Dyncfg_Update(t *testing.T) {
1736 wantDyncfg: `
1737
1738 FUNCTION_RESULT_BEGIN 1-update 404 application/json
1739 -{"status":404,"errorMessage":"The specified module 'success' job 'test' is not registered."}
1739 +{"status":404,"errorMessage":"job not found."}
1740 FUNCTION_RESULT_END
1741 `,
1742 }
@@ -1771,10 +1771,10 @@ FUNCTION_RESULT_END
1771 }))
1772 },
1773 wantDiscovered: nil,
1774 - wantSeen: []seenConfig{
1775 - {cfg: updCfg, status: dyncfg.StatusRunning},
1774 + wantSeen: []confgroup.Config{
1775 + updCfg,
1776 },
1777 - wantExposed: []seenConfig{
1777 + wantExposed: []wantExposedEntry{
1778 {cfg: updCfg, status: dyncfg.StatusRunning},
1779 },
1780 wantRunning: []string{updCfg.FullName()},
@@ -1830,10 +1830,10 @@ CONFIG test:collector:success:test status running
1830 }))
1831 },
1832 wantDiscovered: nil,
1833 - wantSeen: []seenConfig{
1834 - {cfg: updCfg, status: dyncfg.StatusDisabled},
1833 + wantSeen: []confgroup.Config{
1834 + updCfg,
1835 },
1836 - wantExposed: []seenConfig{
1836 + wantExposed: []wantExposedEntry{
1837 {cfg: updCfg, status: dyncfg.StatusDisabled},
1838 },
1839 wantRunning: nil,
@@ -1942,10 +1942,10 @@ func TestManager_Run_FunctionOnly(t *testing.T) {
1942 }))
1943 },
1944 wantDiscovered: []confgroup.Config{cfg},
1945 - wantSeen: []seenConfig{
1946 - {cfg: cfg, status: dyncfg.StatusFailed},
1945 + wantSeen: []confgroup.Config{
1946 + cfg,
1947 },
1948 - wantExposed: []seenConfig{
1948 + wantExposed: []wantExposedEntry{
1949 {cfg: cfg, status: dyncfg.StatusFailed},
1950 },
1951 wantRunning: nil,
@@ -1953,7 +1953,7 @@ func TestManager_Run_FunctionOnly(t *testing.T) {
1953 CONFIG test:collector:nofuncs:test create accepted job /collectors/test/Jobs user 'type=user,module=nofuncs,job=test' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
1954
1955 FUNCTION_RESULT_BEGIN 1-enable 400 application/json
1956 -{"status":400,"errorMessage":"Invalid configuration. Failed to apply configuration: function_only is set but nofuncs module has no methods defined."}
1956 +{"status":400,"errorMessage":"invalid configuration: failed to apply configuration: function_only is set but nofuncs module has no methods defined"}
1957 FUNCTION_RESULT_END
1958
1959 CONFIG test:collector:nofuncs:test status failed
@@ -1974,10 +1974,10 @@ CONFIG test:collector:nofuncs:test status failed
1974 }))
1975 },
1976 wantDiscovered: []confgroup.Config{cfg},
1977 - wantSeen: []seenConfig{
1978 - {cfg: cfg, status: dyncfg.StatusRunning},
1977 + wantSeen: []confgroup.Config{
1978 + cfg,
1979 },
1980 - wantExposed: []seenConfig{
1980 + wantExposed: []wantExposedEntry{
1981 {cfg: cfg, status: dyncfg.StatusRunning},
1982 },
1983 wantRunning: []string{cfg.FullName()},
@@ -2006,10 +2006,10 @@ CONFIG test:collector:withfuncs:test status running
2006 }))
2007 },
2008 wantDiscovered: []confgroup.Config{cfg},
2009 - wantSeen: []seenConfig{
2010 - {cfg: cfg, status: dyncfg.StatusRunning},
2009 + wantSeen: []confgroup.Config{
2010 + cfg,
2011 },
2012 - wantExposed: []seenConfig{
2012 + wantExposed: []wantExposedEntry{
2013 {cfg: cfg, status: dyncfg.StatusRunning},
2014 },
2015 wantRunning: []string{cfg.FullName()},
src/go/plugin/go.d/agent/jobmgr/sim_test.go
+19 -15
@@ -21,12 +21,17 @@ import (
21 "github.com/stretchr/testify/require"
22 )
23
24 +type wantExposedEntry struct {
25 + cfg confgroup.Config
26 + status dyncfg.Status
27 +}
28 +
29 type runSim struct {
30 do func(mgr *Manager, in chan []*confgroup.Group)
31
32 wantDiscovered []confgroup.Config
28 - wantSeen []seenConfig
29 - wantExposed []seenConfig
33 + wantSeen []confgroup.Config
34 + wantExposed []wantExposedEntry
35 wantRunning []string
36 wantDyncfg string
37 }
@@ -38,7 +43,7 @@ func (s *runSim) run(t *testing.T) {
43
44 var buf bytes.Buffer
45 mgr := New()
41 - mgr.dyncfgApi = dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf)))
46 + mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
47 mgr.Modules = prepareMockRegistry()
48
49 done := make(chan struct{})
@@ -99,22 +104,21 @@ func (s *runSim) run(t *testing.T) {
104 require.Truef(t, ok, "discoveredConfigs: source %s config %d is not found", cfg.Source(), cfg.Hash())
105 }
106
102 - wantLen, gotLen = len(s.wantSeen), len(mgr.seenConfigs.items)
103 - require.Equalf(t, wantLen, gotLen, "seenConfigs: different len (want %d got %d)", wantLen, gotLen)
107 + wantLen, gotLen = len(s.wantSeen), mgr.seen.Count()
108 + require.Equalf(t, wantLen, gotLen, "seen: different len (want %d got %d)", wantLen, gotLen)
109
105 - for _, scfg := range s.wantSeen {
106 - v, ok := mgr.seenConfigs.lookup(scfg.cfg)
107 - require.Truef(t, ok, "seenConfigs: config '%s' is not found", scfg.cfg.UID())
108 - require.Truef(t, scfg.status == v.status, "seenConfigs: wrong status, want %s got %s", scfg.status, v.status)
110 + for _, cfg := range s.wantSeen {
111 + _, ok := mgr.seen.Lookup(cfg)
112 + require.Truef(t, ok, "seen: config '%s' is not found", cfg.UID())
113 }
114
111 - wantLen, gotLen = len(s.wantExposed), len(mgr.exposedConfigs.items)
112 - require.Equalf(t, wantLen, gotLen, "exposedConfigs: different len (want %d got %d)", wantLen, gotLen)
115 + wantLen, gotLen = len(s.wantExposed), mgr.exposed.Count()
116 + require.Equalf(t, wantLen, gotLen, "exposed: different len (want %d got %d)", wantLen, gotLen)
117
114 - for _, scfg := range s.wantExposed {
115 - v, ok := mgr.exposedConfigs.lookup(scfg.cfg)
116 - require.Truef(t, ok && scfg.cfg.UID() == v.cfg.UID(), "exposedConfigs: config '%s' is not found", scfg.cfg.UID())
117 - require.Truef(t, scfg.status == v.status, "exposedConfigs: wrong status, want %s got %s", scfg.status, v.status)
118 + for _, we := range s.wantExposed {
119 + entry, ok := mgr.exposed.LookupByKey(we.cfg.ExposedKey())
120 + require.Truef(t, ok && we.cfg.UID() == entry.Cfg.UID(), "exposed: config '%s' is not found", we.cfg.UID())
121 + require.Truef(t, we.status == entry.Status, "exposed: wrong status for '%s', want %s got %s", we.cfg.UID(), we.status, entry.Status)
122 }
123
124 wantLen, gotLen = len(s.wantRunning), len(mgr.runningJobs.items)