feat(go.d.plugin): add function-only mode for modules and jobs (#21646)
Ilya Mashchenko committed
Jan 27, 2026 at 00:03 UTC
31d01ab26e9b99a051d24ae683de49c22ebc4203
9 files changed
+318
-16
src/go/plugin/go.d/agent/confgroup/config.go
+9
-7
@@ -14,13 +14,14 @@ import (
14
)
15
16
const (
17
- keyName = "name"
18
- keyModule = "module"
19
- keyUpdateEvery = "update_every"
20
- keyDetectRetry = "autodetection_retry"
21
- keyPriority = "priority"
22
- keyLabels = "labels"
23
- keyVnode = "vnode"
17
+ keyName = "name"
18
+ keyModule = "module"
19
+ keyUpdateEvery = "update_every"
20
+ keyDetectRetry = "autodetection_retry"
21
+ keyPriority = "priority"
22
+ keyLabels = "labels"
23
+ keyVnode = "vnode"
24
+ keyFunctionOnly = "function_only"
25
26
ikeySource = "__source__"
27
ikeySourceType = "__source_type__"
@@ -53,6 +54,7 @@ func (c Config) Priority() int { v, _ := c.Get(keyPriority).(int); ret
54
func (c Config) Labels() map[any]any { v, _ := c.Get(keyLabels).(map[any]any); return v }
55
func (c Config) Hash() uint64 { return calcHash(c) }
56
func (c Config) Vnode() string { v, _ := c.Get(keyVnode).(string); return v }
57
+func (c Config) FunctionOnly() bool { v, _ := c.Get(keyFunctionOnly).(bool); return v }
58
59
func (c Config) SetName(v string) Config { return c.Set(keyName, v) }
60
func (c Config) SetModule(v string) Config { return c.Set(keyModule, v) }
src/go/plugin/go.d/agent/confgroup/config_test.go
+19
@@ -322,3 +322,22 @@ func TestConfig_Apply(t *testing.T) {
322
})
323
}
324
}
325
+
326
+func TestConfig_FunctionOnly(t *testing.T) {
327
+ tests := map[string]struct {
328
+ cfg Config
329
+ expected bool
330
+ }{
331
+ "true": {cfg: Config{"function_only": true}, expected: true},
332
+ "false": {cfg: Config{"function_only": false}, expected: false},
333
+ "not bool": {cfg: Config{"function_only": "true"}, expected: false},
334
+ "not set": {cfg: Config{}, expected: false},
335
+ "nil cfg": {expected: false},
336
+ }
337
+
338
+ for name, test := range tests {
339
+ t.Run(name, func(t *testing.T) {
340
+ assert.Equal(t, test.expected, test.cfg.FunctionOnly())
341
+ })
342
+ }
343
+}
src/go/plugin/go.d/agent/jobmgr/manager.go
+10
@@ -391,6 +391,15 @@ func (m *Manager) createCollectorJob(cfg confgroup.Config) (*module.Job, error)
391
return nil, fmt.Errorf("can not find %s module", cfg.Module())
392
}
393
394
+ // Determine if job is function-only (module-level OR config-level)
395
+ functionOnly := creator.FunctionOnly || cfg.FunctionOnly()
396
+
397
+ // Reject if config sets function_only but module has no methods
398
+ // Note: module-level FunctionOnly without Methods is caught at registration time
399
+ if cfg.FunctionOnly() && creator.Methods == nil {
400
+ return nil, fmt.Errorf("function_only is set but %s module has no methods defined", cfg.Module())
401
+ }
402
+
403
var vnode *vnodes.VirtualNode
404
405
if cfg.Vnode() != "" {
@@ -437,6 +446,7 @@ func (m *Manager) createCollectorJob(cfg confgroup.Config) (*module.Job, error)
446
Out: m.Out,
447
DumpMode: m.DumpMode,
448
DumpAnalyzer: m.DumpAnalyzer,
449
+ FunctionOnly: functionOnly,
450
}
451
452
if vnode != nil {
src/go/plugin/go.d/agent/jobmgr/manager_test.go
+120
@@ -1914,3 +1914,123 @@ func prepareDyncfgCfg(module, job string) confgroup.Config {
1914
SetModule(module).
1915
SetName(job)
1916
}
1917
+
1918
+func prepareFunctionOnlyCfg(module, job string) confgroup.Config {
1919
+ return confgroup.Config{}.
1920
+ SetSourceType(confgroup.TypeUser).
1921
+ SetProvider("test").
1922
+ SetSource(fmt.Sprintf("type=user,module=%s,job=%s", module, job)).
1923
+ SetModule(module).
1924
+ SetName(job).
1925
+ Set("function_only", true)
1926
+}
1927
+
1928
+func TestManager_Run_FunctionOnly(t *testing.T) {
1929
+ tests := map[string]struct {
1930
+ createSim func() *runSim
1931
+ }{
1932
+ "function_only config for module without methods => error": {
1933
+ createSim: func() *runSim {
1934
+ cfg := prepareFunctionOnlyCfg("nofuncs", "test")
1935
+
1936
+ return &runSim{
1937
+ do: func(mgr *Manager, in chan []*confgroup.Group) {
1938
+ sendConfGroup(in, cfg.Source(), cfg)
1939
+ mgr.dyncfgConfig(functions.Function{
1940
+ UID: "1-enable",
1941
+ Args: []string{mgr.dyncfgJobID(cfg), "enable"},
1942
+ })
1943
+ },
1944
+ wantDiscovered: []confgroup.Config{cfg},
1945
+ wantSeen: []seenConfig{
1946
+ {cfg: cfg, status: dyncfg.StatusFailed},
1947
+ },
1948
+ wantExposed: []seenConfig{
1949
+ {cfg: cfg, status: dyncfg.StatusFailed},
1950
+ },
1951
+ wantRunning: nil,
1952
+ wantDyncfg: `
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,"message":"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
1960
+`,
1961
+ }
1962
+ },
1963
+ },
1964
+ "function_only config for module with methods => ok": {
1965
+ createSim: func() *runSim {
1966
+ cfg := prepareFunctionOnlyCfg("withfuncs", "test")
1967
+
1968
+ return &runSim{
1969
+ do: func(mgr *Manager, in chan []*confgroup.Group) {
1970
+ sendConfGroup(in, cfg.Source(), cfg)
1971
+ mgr.dyncfgConfig(functions.Function{
1972
+ UID: "1-enable",
1973
+ Args: []string{mgr.dyncfgJobID(cfg), "enable"},
1974
+ })
1975
+ },
1976
+ wantDiscovered: []confgroup.Config{cfg},
1977
+ wantSeen: []seenConfig{
1978
+ {cfg: cfg, status: dyncfg.StatusRunning},
1979
+ },
1980
+ wantExposed: []seenConfig{
1981
+ {cfg: cfg, status: dyncfg.StatusRunning},
1982
+ },
1983
+ wantRunning: []string{cfg.FullName()},
1984
+ wantDyncfg: `
1985
+CONFIG test:collector:withfuncs:test create accepted job /collectors/test/Jobs user 'type=user,module=withfuncs,job=test' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
1986
+
1987
+FUNCTION_RESULT_BEGIN 1-enable 200 application/json
1988
+{"status":200,"message":""}
1989
+FUNCTION_RESULT_END
1990
+
1991
+CONFIG test:collector:withfuncs:test status running
1992
+`,
1993
+ }
1994
+ },
1995
+ },
1996
+ "FunctionOnly module => ok": {
1997
+ createSim: func() *runSim {
1998
+ cfg := prepareUserCfg("funconly", "test")
1999
+
2000
+ return &runSim{
2001
+ do: func(mgr *Manager, in chan []*confgroup.Group) {
2002
+ sendConfGroup(in, cfg.Source(), cfg)
2003
+ mgr.dyncfgConfig(functions.Function{
2004
+ UID: "1-enable",
2005
+ Args: []string{mgr.dyncfgJobID(cfg), "enable"},
2006
+ })
2007
+ },
2008
+ wantDiscovered: []confgroup.Config{cfg},
2009
+ wantSeen: []seenConfig{
2010
+ {cfg: cfg, status: dyncfg.StatusRunning},
2011
+ },
2012
+ wantExposed: []seenConfig{
2013
+ {cfg: cfg, status: dyncfg.StatusRunning},
2014
+ },
2015
+ wantRunning: []string{cfg.FullName()},
2016
+ wantDyncfg: `
2017
+CONFIG test:collector:funconly:test create accepted job /collectors/test/Jobs user 'type=user,module=funconly,job=test' 'schema get enable disable update restart test userconfig' 0x0000 0x0000
2018
+
2019
+FUNCTION_RESULT_BEGIN 1-enable 200 application/json
2020
+{"status":200,"message":""}
2021
+FUNCTION_RESULT_END
2022
+
2023
+CONFIG test:collector:funconly:test status running
2024
+`,
2025
+ }
2026
+ },
2027
+ },
2028
+ }
2029
+
2030
+ for name, test := range tests {
2031
+ t.Run(name, func(t *testing.T) {
2032
+ sim := test.createSim()
2033
+ sim.run(t)
2034
+ })
2035
+ }
2036
+}
src/go/plugin/go.d/agent/jobmgr/sim_test.go
+45
@@ -10,6 +10,7 @@ import (
10
"testing"
11
"time"
12
13
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
14
"github.com/netdata/netdata/go/plugins/pkg/netdataapi"
15
"github.com/netdata/netdata/go/plugins/pkg/safewriter"
16
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/confgroup"
@@ -68,6 +69,9 @@ func (s *runSim) run(t *testing.T) {
69
if strings.HasPrefix(s, "CONFIG") && strings.Contains(s, " template ") {
70
continue
71
}
72
+ if strings.HasPrefix(s, "FUNCTION GLOBAL") {
73
+ continue
74
+ }
75
if strings.HasPrefix(s, "FUNCTION_RESULT_BEGIN") {
76
parts := strings.Fields(s)
77
s = strings.Join(parts[:len(parts)-1], " ") // remove timestamp
@@ -150,5 +154,46 @@ func prepareMockRegistry() module.Registry {
154
},
155
})
156
157
+ // Module without Methods - for testing function_only config rejection
158
+ reg.Register("nofuncs", module.Creator{
159
+ Create: func() module.Module {
160
+ return &module.MockModule{
161
+ ChartsFunc: func() *module.Charts {
162
+ return &module.Charts{&module.Chart{ID: "id", Title: "title", Units: "units", Dims: module.Dims{{ID: "id1"}}}}
163
+ },
164
+ CollectFunc: func(context.Context) map[string]int64 { return map[string]int64{"id1": 1} },
165
+ }
166
+ },
167
+ })
168
+
169
+ // Module with Methods - for testing config-level function_only
170
+ reg.Register("withfuncs", module.Creator{
171
+ Create: func() module.Module {
172
+ return &module.MockModule{
173
+ ChartsFunc: func() *module.Charts {
174
+ return &module.Charts{&module.Chart{ID: "id", Title: "title", Units: "units", Dims: module.Dims{{ID: "id1"}}}}
175
+ },
176
+ CollectFunc: func(context.Context) map[string]int64 { return map[string]int64{"id1": 1} },
177
+ }
178
+ },
179
+ Methods: func() []funcapi.MethodConfig {
180
+ return []funcapi.MethodConfig{{ID: "test-method", Name: "Test Method"}}
181
+ },
182
+ })
183
+
184
+ // FunctionOnly module - for testing module-level function-only
185
+ reg.Register("funconly", module.Creator{
186
+ FunctionOnly: true,
187
+ Create: func() module.Module {
188
+ return &module.MockModule{
189
+ ChartsFunc: func() *module.Charts { return nil },
190
+ CollectFunc: func(context.Context) map[string]int64 { return nil },
191
+ }
192
+ },
193
+ Methods: func() []funcapi.MethodConfig {
194
+ return []funcapi.MethodConfig{{ID: "test-method", Name: "Test Method"}}
195
+ },
196
+ })
197
+
198
return reg
199
}
src/go/plugin/go.d/agent/module/job.go
+23
-8
@@ -80,6 +80,7 @@ type JobConfig struct {
80
Vnode vnodes.VirtualNode
81
DumpMode bool
82
DumpAnalyzer interface{}
83
+ FunctionOnly bool
84
}
85
86
const (
@@ -106,6 +107,7 @@ func NewJob(cfg JobConfig) *Job {
107
updateEvery: cfg.UpdateEvery,
108
priority: cfg.Priority,
109
isStock: cfg.IsStock,
110
+ functionOnly: cfg.FunctionOnly,
111
module: cfg.Module,
112
labels: cfg.Labels,
113
out: cfg.Out,
@@ -149,7 +151,8 @@ type Job struct {
151
152
*logger.Logger
153
152
- isStock bool
154
+ isStock bool
155
+ functionOnly bool
156
157
module Module
158
@@ -309,7 +312,7 @@ func (j *Job) Tick(clock int) {
312
select {
313
case j.tick <- clock:
314
default:
312
- if j.shouldCollect(clock) {
315
+ if !j.functionOnly && j.shouldCollect(clock) {
316
j.skipStateMu.Lock()
317
j.consecutiveSkips++
318
consecutiveSkips := j.consecutiveSkips
@@ -339,10 +342,19 @@ func (j *Job) Module() Module {
342
return j.module
343
}
344
345
+// IsFunctionOnly returns true if this job is function-only (no metrics collection).
346
+func (j *Job) IsFunctionOnly() bool {
347
+ return j.functionOnly
348
+}
349
+
350
// Start starts job main loop.
351
func (j *Job) Start() {
352
j.running.Store(true)
345
- j.Infof("started, data collection interval %ds", j.updateEvery)
353
+ if j.functionOnly {
354
+ j.Info("started in function-only mode")
355
+ } else {
356
+ j.Infof("started, data collection interval %ds", j.updateEvery)
357
+ }
358
defer func() {
359
j.running.Store(false)
360
j.Info("stopped")
@@ -354,7 +366,7 @@ LOOP:
366
case <-j.stop:
367
break LOOP
368
case t := <-j.tick:
357
- if j.shouldCollect(t) {
369
+ if !j.functionOnly && j.shouldCollect(t) {
370
j.skipStateMu.Lock()
371
if j.consecutiveSkips > 0 {
372
if j.collectStopTime.IsZero() {
@@ -464,13 +476,16 @@ func (j *Job) check() error {
476
}
477
478
func (j *Job) postCheck() error {
467
- if j.charts = j.module.Charts(); j.charts == nil {
479
+ j.charts = j.module.Charts()
480
+ if j.charts == nil && !j.functionOnly {
481
j.Error("nil charts")
482
return errors.New("nil charts")
483
}
471
- if err := checkCharts(*j.charts...); err != nil {
472
- j.Errorf("charts check: %v", err)
473
- return err
484
+ if j.charts != nil {
485
+ if err := checkCharts(*j.charts...); err != nil {
486
+ j.Errorf("charts check: %v", err)
487
+ return err
488
+ }
489
}
490
return nil
491
}
src/go/plugin/go.d/agent/module/job_test.go
+72
@@ -290,3 +290,75 @@ func TestJob_Tick(t *testing.T) {
290
job.Tick(i)
291
}
292
}
293
+
294
+func newTestFunctionOnlyJob() *Job {
295
+ return NewJob(
296
+ JobConfig{
297
+ PluginName: pluginName,
298
+ Name: jobName,
299
+ ModuleName: modName,
300
+ FullName: modName + "_" + jobName,
301
+ Module: nil,
302
+ Out: io.Discard,
303
+ UpdateEvery: 0,
304
+ AutoDetectEvery: 0,
305
+ Priority: 0,
306
+ FunctionOnly: true,
307
+ },
308
+ )
309
+}
310
+
311
+func TestJob_IsFunctionOnly(t *testing.T) {
312
+ job := newTestJob()
313
+ assert.False(t, job.IsFunctionOnly())
314
+
315
+ foJob := newTestFunctionOnlyJob()
316
+ assert.True(t, foJob.IsFunctionOnly())
317
+}
318
+
319
+func TestJob_AutoDetection_FunctionOnly_NilCharts(t *testing.T) {
320
+ job := newTestFunctionOnlyJob()
321
+ m := &MockModule{
322
+ InitFunc: func(context.Context) error {
323
+ return nil
324
+ },
325
+ CheckFunc: func(context.Context) error {
326
+ return nil
327
+ },
328
+ ChartsFunc: func() *Charts {
329
+ return nil
330
+ },
331
+ }
332
+ job.module = m
333
+
334
+ assert.NoError(t, job.AutoDetection())
335
+}
336
+
337
+func TestJob_Start_FunctionOnly(t *testing.T) {
338
+ collectCalled := false
339
+ m := &MockModule{
340
+ ChartsFunc: func() *Charts {
341
+ return nil
342
+ },
343
+ CollectFunc: func(context.Context) map[string]int64 {
344
+ collectCalled = true
345
+ return map[string]int64{"id1": 1}
346
+ },
347
+ }
348
+ job := newTestFunctionOnlyJob()
349
+ job.module = m
350
+ job.updateEvery = 1
351
+
352
+ go func() {
353
+ for i := 1; i < 3; i++ {
354
+ job.Tick(i)
355
+ time.Sleep(time.Second)
356
+ }
357
+ job.Stop()
358
+ }()
359
+
360
+ job.Start()
361
+
362
+ assert.False(t, collectCalled, "Collect should not be called for function-only jobs")
363
+ assert.True(t, m.CleanupDone)
364
+}
src/go/plugin/go.d/agent/module/registry.go
+8
@@ -42,6 +42,11 @@ type (
42
// - Handle(ctx, method, params) for request handling
43
// When nil, methods are disabled for this module.
44
MethodHandler func(job *Job) funcapi.MethodHandler
45
+
46
+ // FunctionOnly indicates this module provides only functions, no metrics.
47
+ // Jobs created from this module skip data collection and chart creation.
48
+ // The module must still implement Init() and Check() for connectivity validation.
49
+ FunctionOnly bool
50
}
51
// Registry is a collection of Creators.
52
Registry map[string]Creator
@@ -60,6 +65,9 @@ func (r Registry) Register(name string, creator Creator) {
65
if _, ok := r[name]; ok {
66
panic(fmt.Sprintf("%s is already in registry", name))
67
}
68
+ if creator.FunctionOnly && creator.Methods == nil {
69
+ panic(fmt.Sprintf("%s is FunctionOnly but has no Methods defined", name))
70
+ }
71
r[name] = creator
72
}
73
src/go/plugin/go.d/agent/module/registry_test.go
+12
-1
@@ -24,7 +24,7 @@ func TestRegister(t *testing.T) {
24
25
require.True(t, exist)
26
27
- // Panic case
27
+ // Panic case: duplicate registration
28
assert.Panics(
29
t,
30
func() {
@@ -32,3 +32,14 @@ func TestRegister(t *testing.T) {
32
})
33
34
}
35
+
36
+func TestRegister_FunctionOnlyWithoutMethods(t *testing.T) {
37
+ registry := make(Registry)
38
+
39
+ // Panic case: FunctionOnly without Methods
40
+ assert.Panics(
41
+ t,
42
+ func() {
43
+ registry.Register("funcOnly", Creator{FunctionOnly: true})
44
+ })
45
+}