@cryptotaxi247 / netdata-1 / commits / 31d01ab26

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 +}