@cryptotaxi247 / netdata-1 / commits / a065b60d3

refactor(go/scripts.d): switch to v2 framework and fixes (#21908)

Ilya Mashchenko committed Mar 22, 2026 at 18:36 UTC a065b60d33658aa829ddf930f1d2f752e71f767b
96 files changed +5227 -5859
integrations/gen_docs_integrations.py
+7 -7
@@ -24,8 +24,8 @@ def cleanup(only_base_paths=None):
24 """
25 targets = [
26 "src/go/plugin/go.d/collector",
27 + "src/go/plugin/scripts.d/collector",
28 "src/go/plugin/ibm.d/modules",
28 - "src/go/plugin/scripts.d/modules",
29 "src/crates/netdata-otel",
30 "src/collectors",
31 "src/exporting",
@@ -205,7 +205,7 @@ learn_rel_path: "{learn_rel_path}"
205 if keywords:
206 md += f"keywords: {keywords}\n"
207
208 - md+=f"""message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE COLLECTOR'S metadata.yaml FILE"
208 + md += f"""message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE COLLECTOR'S metadata.yaml FILE"
209 endmeta-->
210
211 {create_overview(integration, integration['meta']['monitored_instance']['icon_filename'])}"""
@@ -238,7 +238,7 @@ learn_rel_path: "Exporting Metrics/Connectors"
238 if keywords:
239 md += f"keywords: {keywords}\n"
240
241 - md+=f"""message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE EXPORTER'S metadata.yaml FILE"
241 + md += f"""message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE EXPORTER'S metadata.yaml FILE"
242 endmeta-->
243
244 {create_overview(integration, integration['meta']['icon_filename'])}"""
@@ -265,7 +265,7 @@ learn_rel_path: "{learn_rel_path.replace("notifications", "Alerts & Notification
265 if keywords:
266 md += f"keywords: {keywords}\n"
267
268 - md+=f"""message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE NOTIFICATION'S metadata.yaml FILE"
268 + md += f"""message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE NOTIFICATION'S metadata.yaml FILE"
269 endmeta-->
270
271 {create_overview(integration, integration['meta']['icon_filename'], "overview")}"""
@@ -292,7 +292,7 @@ learn_rel_path: "{learn_rel_path.replace("notifications", "Alerts & Notification
292 if keywords:
293 md += f"keywords: {keywords}\n"
294
295 - md+=f"""message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE NOTIFICATION'S metadata.yaml FILE"
295 + md += f"""message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE NOTIFICATION'S metadata.yaml FILE"
296 endmeta-->
297
298 {create_overview(integration, integration['meta']['icon_filename'], "")}"""
@@ -319,7 +319,7 @@ learn_rel_path: "{learn_rel_path.replace("logs", "Logs")}"
319 if keywords:
320 md += f"keywords: {keywords}\n"
321
322 - md+=f"""message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE LOGS' metadata.yaml FILE"
322 + md += f"""message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE LOGS' metadata.yaml FILE"
323 endmeta-->
324
325 {create_overview(integration, integration['meta']['icon_filename'])}"""
@@ -344,7 +344,7 @@ learn_rel_path: "{learn_rel_path.replace("authentication", "Netdata Cloud/Authen
344 if keywords:
345 md += f"keywords: {keywords}\n"
346
347 - md+=f"""message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE AUTHENTICATION'S metadata.yaml FILE"
347 + md += f"""message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE AUTHENTICATION'S metadata.yaml FILE"
348 endmeta-->
349
350 {create_overview(integration, integration['meta']['icon_filename'])}"""
integrations/gen_integrations.py
+27 -11
@@ -30,9 +30,9 @@ COLLECTOR_SOURCES = [
30 (AGENT_REPO, REPO_PATH / 'src' / 'collectors' / 'python.d.plugin', True),
31 (AGENT_REPO, REPO_PATH / 'src' / 'collectors' / 'guides', True),
32 (AGENT_REPO, REPO_PATH / 'src' / 'go' / 'plugin' / 'go.d' / 'collector', True),
33 + (AGENT_REPO, REPO_PATH / 'src' / 'go' / 'plugin' / 'scripts.d' / 'collector', True),
34 (AGENT_REPO, REPO_PATH / 'src' / 'go' / 'plugin' / 'ibm.d' / 'modules', True),
35 (AGENT_REPO, REPO_PATH / 'src' / 'go' / 'plugin' / 'ibm.d' / 'modules' / 'websphere', True),
35 - (AGENT_REPO, REPO_PATH / 'src' / 'go' / 'plugin' / 'scripts.d' / 'modules', True),
36 (AGENT_REPO, REPO_PATH / 'src' / 'crates' / 'netdata-otel', True),
37 ]
38
@@ -295,7 +295,9 @@ def load_categories():
295 try:
296 CATEGORY_VALIDATOR.validate(categories)
297 except ValidationError as e:
298 - warn(f'Failed to validate {CATEGORIES_FILE} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})', CATEGORIES_FILE)
298 + warn(
299 + f'Failed to validate {CATEGORIES_FILE} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})',
300 + CATEGORIES_FILE)
301 sys.exit(1)
302
303 return categories
@@ -316,7 +318,9 @@ def load_collectors():
318 try:
319 COLLECTOR_VALIDATOR.validate(data)
320 except ValidationError as e:
319 - warn(f'Failed to validate {path} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})', path)
321 + warn(
322 + f'Failed to validate {path} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})',
323 + path)
324 continue
325
326 for idx, item in enumerate(data['modules']):
@@ -341,7 +345,9 @@ def _load_deploy_file(file, repo):
345 try:
346 DEPLOY_VALIDATOR.validate(data)
347 except ValidationError as e:
344 - warn(f'Failed to validate {file} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})', file)
348 + warn(
349 + f'Failed to validate {file} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})',
350 + file)
351 return []
352
353 for idx, item in enumerate(data):
@@ -377,7 +383,9 @@ def _load_exporter_file(file, repo):
383 try:
384 EXPORTER_VALIDATOR.validate(data)
385 except ValidationError as e:
380 - warn(f'Failed to validate {file} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})', file)
386 + warn(
387 + f'Failed to validate {file} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})',
388 + file)
389 return []
390
391 if 'id' in data:
@@ -423,7 +431,9 @@ def _load_agent_notification_file(file, repo):
431 try:
432 AGENT_NOTIFICATION_VALIDATOR.validate(data)
433 except ValidationError as e:
426 - warn(f'Failed to validate {file} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})', file)
434 + warn(
435 + f'Failed to validate {file} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})',
436 + file)
437 return []
438
439 if 'id' in data:
@@ -469,7 +479,9 @@ def _load_cloud_notification_file(file, repo):
479 try:
480 CLOUD_NOTIFICATION_VALIDATOR.validate(data)
481 except ValidationError as e:
472 - warn(f'Failed to validate {file} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})', file)
482 + warn(
483 + f'Failed to validate {file} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})',
484 + file)
485 return []
486
487 if 'id' in data:
@@ -515,7 +527,9 @@ def _load_logs_file(file, repo):
527 try:
528 LOGS_VALIDATOR.validate(data)
529 except ValidationError as e:
518 - warn(f'Failed to validate {file} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})', file)
530 + warn(
531 + f'Failed to validate {file} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})',
532 + file)
533 return []
534
535 if 'id' in data:
@@ -561,7 +575,9 @@ def _load_authentication_file(file, repo):
575 try:
576 AUTHENTICATION_VALIDATOR.validate(data)
577 except ValidationError as e:
564 - warn(f'Failed to validate {file} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})', file)
578 + warn(
579 + f'Failed to validate {file} against the schema: {e.message} (path: {"/".join(str(p) for p in e.absolute_path)})',
580 + file)
581 return []
582
583 if 'id' in data:
@@ -660,8 +676,8 @@ def render_collectors(categories, collectors, ids):
676 # Level 2: plugin_name + module_name (all instances of that module)
677 # Level 3: plugin_name (all modules of that plugin)
678 by_pm_instance = {} # (plugin, module, instance) -> [items]
663 - by_pm = {} # (plugin, module) -> [items]
664 - by_plugin = {} # plugin -> [items]
679 + by_pm = {} # (plugin, module) -> [items]
680 + by_plugin = {} # plugin -> [items]
681
682 for i in collectors:
683 m = i['meta']
src/collectors/COLLECTORS.md
-2
@@ -497,7 +497,6 @@ Need a dedicated integration? [Submit a feature request](https://github.com/netd
497 | [RADIUS](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/prometheus/integrations/radius.md) | Keep tabs on RADIUS (Remote Authentication Dial-In User Service) protocol metrics for efficient authentication and access management. |
498 | [Rspamd](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/rspamd/integrations/rspamd.md) | This collector monitors the activity and performance of Rspamd servers. |
499 | [SABnzbd](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/prometheus/integrations/sabnzbd.md) | Monitor SABnzbd Usenet client metrics for efficient file downloads and resource management. |
500 -| [scripts.d Scheduler](https://github.com/netdata/netdata/blob/master/src/go/plugin/scripts.d/modules/scheduler/integrations/scripts.d_scheduler.md) | The scheduler module manages the execution of jobs defined by the nagios and zabbix modules. |
500 | [Slurm](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/prometheus/integrations/slurm.md) | Track Slurm workload manager metrics for efficient high-performance computing (HPC) and cluster management. |
501 | [SpigotMC](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/spigotmc/integrations/spigotmc.md) | This collector monitors SpigotMC server server performance, in the form of ticks per second average, memory utilization, and active users. |
502 | [StatusPage](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/prometheus/integrations/statuspage.md) | Monitor StatusPage.io incident and status metrics for efficient incident management and communication. |
@@ -576,7 +575,6 @@ Need a dedicated integration? [Submit a feature request](https://github.com/netd
575 | [IOPing](https://github.com/netdata/netdata/blob/master/src/collectors/ioping.plugin/integrations/ioping.md) | Monitor IOPing metrics for efficient disk I/O latency tracking. |
576 | [Monit](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/monit/integrations/monit.md) | This collector monitors status of Monit's service checks. |
577 | [MQTT Blackbox](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/prometheus/integrations/mqtt_blackbox.md) | Track MQTT message transport performance using blackbox testing methods. |
579 -| [Nagios Plugins](https://github.com/netdata/netdata/blob/master/src/go/plugin/scripts.d/modules/nagios/integrations/nagios_plugins.md) | This module runs unmodified [Nagios plugins](https://www.nagios-plugins.org/) inside Netdata without any changes to the plugins themselves. |
578 | [Ping](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/ping/integrations/ping.md) | This module measures round-trip time and packet loss by sending ping messages to network hosts. |
579 | [Site 24x7](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/prometheus/integrations/site_24x7.md) | Monitor Site24x7 website and infrastructure monitoring metrics for efficient performance tracking and management. |
580 | [TCP/UDP Endpoints](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/portcheck/integrations/tcp-udp_endpoints.md) | Collector for monitoring service availability and response time. |
src/go/cmd/scriptsdplugin/main.go
+1 -2
@@ -26,8 +26,7 @@ import (
26 "github.com/netdata/netdata/go/plugins/pkg/pluginconfig"
27 "github.com/netdata/netdata/go/plugins/pkg/terminal"
28 "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
29 - _ "github.com/netdata/netdata/go/plugins/plugin/scripts.d/modules/nagios"
30 - _ "github.com/netdata/netdata/go/plugins/plugin/scripts.d/modules/scheduler"
29 + _ "github.com/netdata/netdata/go/plugins/plugin/scripts.d/collector/nagios"
30 )
31
32 func init() {
src/go/go.mod
+4 -5
@@ -51,13 +51,13 @@ require (
51 github.com/valyala/fastjson v1.6.10
52 github.com/vmware/govmomi v0.53.0
53 go.mongodb.org/mongo-driver v1.17.9
54 - go.opentelemetry.io/proto/otlp v1.9.0
54 + go.opentelemetry.io/proto/otlp v1.9.0 // indirect
55 go.uber.org/automaxprocs v1.6.0
56 golang.org/x/net v0.52.0
57 golang.org/x/sync v0.20.0
58 golang.org/x/text v0.35.0
59 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20220504211119-3d4a969bb56b
60 - google.golang.org/grpc v1.79.1
60 + google.golang.org/grpc v1.79.1 // indirect
61 gopkg.in/ini.v1 v1.67.1
62 gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2
63 gopkg.in/yaml.v2 v2.4.0
@@ -82,6 +82,7 @@ require (
82 )
83
84 require (
85 + cloud.google.com/go/compute/metadata v0.9.0 // indirect
86 dario.cat/mergo v1.0.1 // indirect
87 filippo.io/edwards25519 v1.1.1 // indirect
88 github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
@@ -119,7 +120,6 @@ require (
120 github.com/google/gnostic-models v0.7.0 // indirect
121 github.com/google/go-cmp v0.7.0 // indirect
122 github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
122 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
123 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
124 github.com/huandu/xstrings v1.5.0 // indirect
125 github.com/ibmruntimes/go-recordio/v2 v2.0.0-20240416213906-ae0ad556db70 // indirect
@@ -168,6 +168,7 @@ require (
168 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
169 go.opentelemetry.io/otel v1.39.0 // indirect
170 go.opentelemetry.io/otel/metric v1.39.0 // indirect
171 + go.opentelemetry.io/otel/sdk v1.39.0 // indirect
172 go.opentelemetry.io/otel/trace v1.39.0 // indirect
173 go.uber.org/atomic v1.11.0 // indirect
174 go.uber.org/multierr v1.11.0 // indirect
@@ -181,8 +182,6 @@ require (
182 golang.org/x/time v0.9.0 // indirect
183 golang.org/x/tools v0.42.0 // indirect
184 golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b // indirect
184 - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
185 - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
185 google.golang.org/protobuf v1.36.11 // indirect
186 gopkg.in/cenkalti/backoff.v2 v2.2.1 // indirect
187 gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
src/go/go.sum
+2 -5
@@ -2,7 +2,6 @@ cloud.google.com/go/auth v0.14.0 h1:A5C4dKV/Spdvxcl0ggWwWEzzP7AZMJSEIgrkngwhGYM=
2 cloud.google.com/go/auth v0.14.0/go.mod h1:CYsoRL1PdiDuqeQpZE0bP2pnPrGqFcOkI0nldEQis+A=
3 cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
4 cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
5 -cloud.google.com/go/compute v1.23.1 h1:V97tBoDaZHb6leicZ1G6DLK2BAaZLJ/7+9BB/En3hR0=
5 cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
6 cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
7 dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
@@ -184,6 +183,7 @@ github.com/gorcon/rcon v1.4.0 h1:pYwZ8Rhcgfh/LhdPBncecuEo5thoFvPIuMSWovz1FME=
183 github.com/gorcon/rcon v1.4.0/go.mod h1:M6v6sNmr/NET9YIf+2rq+cIjTBridoy62uzQ58WgC1I=
184 github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
185 github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
186 +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
187 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
188 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
189 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
@@ -427,8 +427,6 @@ go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF
427 go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
428 go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
429 go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
430 -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
431 -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
430 go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
431 go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
432 go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
@@ -524,10 +522,9 @@ golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b h1:J1CaxgLerRR5lgx
522 golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b/go.mod h1:tqur9LnfstdR9ep2LaJT4lFUl0EjlHtge+gAjmsHUG4=
523 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20220504211119-3d4a969bb56b h1:9JncmKXcUwE918my+H6xmjBdhK2jM/UTUNXxhRG1BAk=
524 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20220504211119-3d4a969bb56b/go.mod h1:yp4gl6zOlnDGOZeWeDfMwQcsdOIQnMdhuPx9mwwWBL4=
527 -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
528 -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
525 google.golang.org/api v0.218.0 h1:x6JCjEWeZ9PFCRe9z0FBrNwj7pB7DOAqT35N+IPnAUA=
526 google.golang.org/api v0.218.0/go.mod h1:5VGHBAkxrA/8EFjLVEYmMUJ8/8+gWWQ3s4cFH0FxG2M=
527 +google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA=
528 google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
529 google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
530 google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
src/go/plugin/framework/functions/runtime_metrics_test.go
+1
@@ -35,6 +35,7 @@ func (m *runtimeServiceMock) UnregisterComponent(name string) {
35 }
36
37 func (m *runtimeServiceMock) RegisterProducer(string, func() error) error { return nil }
38 +func (m *runtimeServiceMock) UnregisterProducer(string) {}
39
40 func (m *runtimeServiceMock) snapshot() ([]runtimecomp.ComponentConfig, []string) {
41 m.mu.Lock()
src/go/plugin/framework/jobruntime/job_v2.go
+4 -1
@@ -535,7 +535,10 @@ func (j *JobV2) moduleContext() context.Context {
535 ctx := j.runCtx
536 j.ctxMu.RUnlock()
537 if ctx == nil {
538 - return context.Background()
538 + ctx = context.Background()
539 + }
540 + if j.runtimeService != nil {
541 + return runtimecomp.ContextWithService(ctx, j.runtimeService)
542 }
543 return ctx
544 }
src/go/plugin/framework/jobruntime/job_v2_test.go
+32
@@ -58,6 +58,8 @@ func (m *mockRuntimeComponentService) RegisterProducer(_ string, _ func() error)
58 return nil
59 }
60
61 +func (m *mockRuntimeComponentService) UnregisterProducer(_ string) {}
62 +
63 func (m *mockModuleV2) Init(ctx context.Context) error {
64 if m.initFunc == nil {
65 return nil
@@ -370,6 +372,36 @@ END`)
372 assert.Equal(t, job.engine.RuntimeStore(), cfg.Store)
373 },
374 },
375 + "module context carries runtime component service when available": {
376 + run: func(t *testing.T) {
377 + store := metrix.NewCollectorStore()
378 + runtimeSvc := &mockRuntimeComponentService{}
379 + mod := &mockModuleV2{
380 + store: store,
381 + template: chartTemplateV2(),
382 + initFunc: func(ctx context.Context) error {
383 + got, ok := runtimecomp.ServiceFromContext(ctx)
384 + require.True(t, ok)
385 + require.NotNil(t, got)
386 + assert.Same(t, runtimeSvc, got)
387 + return nil
388 + },
389 + }
390 +
391 + job := NewJobV2(JobV2Config{
392 + PluginName: pluginName,
393 + Name: jobName,
394 + ModuleName: modName,
395 + FullName: modName + "_" + jobName,
396 + Module: mod,
397 + Out: &bytes.Buffer{},
398 + UpdateEvery: 1,
399 + RuntimeService: runtimeSvc,
400 + })
401 +
402 + require.NoError(t, job.AutoDetection())
403 + },
404 + },
405 "runtime registration failure is non-fatal for autodetection": {
406 run: func(t *testing.T) {
407 store := metrix.NewCollectorStore()
src/go/plugin/framework/runtimecomp/context.go new
+27
@@ -0,0 +1,27 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package runtimecomp
4 +
5 +import "context"
6 +
7 +type contextKey struct{}
8 +
9 +// ContextWithService attaches a runtime component service to a context.
10 +func ContextWithService(ctx context.Context, svc Service) context.Context {
11 + if ctx == nil {
12 + ctx = context.Background()
13 + }
14 + if svc == nil {
15 + return ctx
16 + }
17 + return context.WithValue(ctx, contextKey{}, svc)
18 +}
19 +
20 +// ServiceFromContext returns a runtime component service from a context.
21 +func ServiceFromContext(ctx context.Context) (Service, bool) {
22 + if ctx == nil {
23 + return nil, false
24 + }
25 + svc, ok := ctx.Value(contextKey{}).(Service)
26 + return svc, ok && svc != nil
27 +}
src/go/plugin/framework/runtimecomp/context_test.go new
+60
@@ -0,0 +1,60 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package runtimecomp
4 +
5 +import (
6 + "context"
7 + "testing"
8 +)
9 +
10 +type mockService struct{}
11 +
12 +func (mockService) RegisterComponent(ComponentConfig) error { return nil }
13 +func (mockService) UnregisterComponent(string) {}
14 +func (mockService) RegisterProducer(string, func() error) error {
15 + return nil
16 +}
17 +func (mockService) UnregisterProducer(string) {}
18 +
19 +func TestContextHelpers(t *testing.T) {
20 + tests := map[string]struct {
21 + ctx context.Context
22 + service Service
23 + wantOK bool
24 + }{
25 + "nil context and nil service": {
26 + ctx: nil,
27 + service: nil,
28 + wantOK: false,
29 + },
30 + "background context without service": {
31 + ctx: context.Background(),
32 + service: nil,
33 + wantOK: false,
34 + },
35 + "context with service": {
36 + ctx: context.Background(),
37 + service: mockService{},
38 + wantOK: true,
39 + },
40 + }
41 +
42 + for name, test := range tests {
43 + t.Run(name, func(t *testing.T) {
44 + ctx := ContextWithService(test.ctx, test.service)
45 + got, ok := ServiceFromContext(ctx)
46 + if ok != test.wantOK {
47 + t.Fatalf("ServiceFromContext() ok = %v, want %v", ok, test.wantOK)
48 + }
49 + if !test.wantOK {
50 + if got != nil {
51 + t.Fatalf("ServiceFromContext() service = %#v, want nil", got)
52 + }
53 + return
54 + }
55 + if got == nil {
56 + t.Fatalf("ServiceFromContext() service = nil, want non-nil")
57 + }
58 + })
59 + }
60 +}
src/go/plugin/framework/runtimecomp/types.go
+1
@@ -39,4 +39,5 @@ type Service interface {
39 RegisterComponent(cfg ComponentConfig) error
40 UnregisterComponent(name string)
41 RegisterProducer(name string, tickFn func() error) error
42 + UnregisterProducer(name string)
43 }
src/go/plugin/go.d/pkg/ndexec/ndexec.go
+62 -24
@@ -5,6 +5,7 @@ package ndexec
5 import (
6 "bytes"
7 "context"
8 + "errors"
9 "fmt"
10 "os"
11 "os/exec"
@@ -104,6 +105,20 @@ func RunUnprivilegedWithOptionsUsage(log *logger.Logger, timeout time.Duration,
105 return defaultRunner.run(log, timeout, opts.Dir, defaultRunner.ndRunPath, "RunUnprivileged", opts.Env, argv...)
106 }
107
108 +// RunUnprivilegedWithOptionsUsageContext runs binPath via nd-run using the caller
109 +// context as the ownership boundary for cancellation and stop/reload propagation.
110 +func RunUnprivilegedWithOptionsUsageContext(
111 + ctx context.Context,
112 + log *logger.Logger,
113 + timeout time.Duration,
114 + opts RunOptions,
115 + binPath string,
116 + args ...string,
117 +) ([]byte, string, ResourceUsage, error) {
118 + argv := append([]string{binPath}, args...)
119 + return defaultRunner.runContext(ctx, log, timeout, opts.Dir, defaultRunner.ndRunPath, "RunUnprivileged", opts.Env, argv...)
120 +}
121 +
122 // SetRunnerPathsForTests overrides the nd-run and ndsudo helper paths.
123 // It is intended for test environments that need to stub the helpers.
124 func SetRunnerPathsForTests(ndRunPath, ndSudoPath string) {
@@ -118,28 +133,24 @@ func SetRunnerPathsForTests(ndRunPath, ndSudoPath string) {
133 // RunDirect runs binPath directly with a timeout, without any wrapper (nd-run/ndsudo).
134 // Returns stdout. On error, includes the command string and a trimmed stderr snippet.
135 func RunDirect(log *logger.Logger, timeout time.Duration, binPath string, args ...string) ([]byte, error) {
121 - ctx, cancel := context.WithTimeout(context.Background(), timeout)
122 - defer cancel()
123 -
124 - cmd := exec.CommandContext(ctx, binPath, args...)
125 -
126 - if log != nil {
127 - log.Debugf("executing '%s'", cmd)
128 - }
129 -
130 - var stderr bytes.Buffer
131 - cmd.Stderr = &stderr
132 -
133 - bs, err := cmd.Output()
136 + out, cmd, _, err := RunDirectWithOptionsUsageContext(context.Background(), log, timeout, RunOptions{}, binPath, args...)
137 if err != nil {
135 - s := stderr.String()
136 - if len(s) > stderrLimit {
137 - s = s[:stderrLimit] + "… (truncated)"
138 - }
139 - return nil, fmt.Errorf("'%s' execution failed: %w (stderr: %s)", cmd, err, strings.TrimSpace(s))
138 + return out, fmt.Errorf("'%s' execution failed: %w", cmd, err)
139 }
140 + return out, nil
141 +}
142
142 - return bs, nil
143 +// RunDirectWithOptionsUsageContext runs binPath directly using the caller context
144 +// while honoring the provided environment and working-directory options.
145 +func RunDirectWithOptionsUsageContext(
146 + ctx context.Context,
147 + log *logger.Logger,
148 + timeout time.Duration,
149 + opts RunOptions,
150 + binPath string,
151 + args ...string,
152 +) ([]byte, string, ResourceUsage, error) {
153 + return defaultRunner.runContext(ctx, log, timeout, opts.Dir, binPath, "RunDirect", opts.Env, args...)
154 }
155
156 // FindBinary searches for a binary by trying names in PATH first,
@@ -165,10 +176,29 @@ func FindBinary(names []string, defaultPaths []string) (string, error) {
176 }
177
178 func (r *runner) run(log *logger.Logger, timeout time.Duration, dir string, helperPath, label string, env []string, argv ...string) ([]byte, string, ResourceUsage, error) {
168 - ctx, cancel := context.WithTimeout(context.Background(), timeout)
169 - defer cancel()
179 + return r.runContext(context.Background(), log, timeout, dir, helperPath, label, env, argv...)
180 +}
181 +
182 +func (r *runner) runContext(
183 + ctx context.Context,
184 + log *logger.Logger,
185 + timeout time.Duration,
186 + dir string,
187 + helperPath, label string,
188 + env []string,
189 + argv ...string,
190 +) ([]byte, string, ResourceUsage, error) {
191 + if ctx == nil {
192 + ctx = context.Background()
193 + }
194 + if timeout > 0 {
195 + var cancel context.CancelFunc
196 + ctx, cancel = context.WithTimeout(ctx, timeout)
197 + defer cancel()
198 + }
199
200 ex := exec.CommandContext(ctx, helperPath, argv...) // argv comes from trusted sources; no shell, args passed separately
201 + configureCommandCancellation(ex)
202 if dir != "" {
203 ex.Dir = dir
204 }
@@ -176,7 +206,9 @@ func (r *runner) run(log *logger.Logger, timeout time.Duration, dir string, help
206 ex.Env = env
207 }
208
179 - log.Debugf("executing: %v", ex)
209 + if log != nil {
210 + log.Debugf("executing: %v", ex)
211 + }
212
213 var stderr bytes.Buffer
214 ex.Stderr = &stderr
@@ -190,9 +222,15 @@ func (r *runner) run(log *logger.Logger, timeout time.Duration, dir string, help
222 if len(s) > stderrLimit {
223 s = s[:stderrLimit] + "… (truncated)"
224 }
193 - // Normalize context-related errors so callers can errors.Is(..., context.DeadlineExceeded)
225 + // Normalize context-related errors so callers can distinguish the
226 + // execution timeout cause from caller-owned cancellation.
227 if ctx.Err() != nil {
195 - err = ctx.Err()
228 + cause := context.Cause(ctx)
229 + if cause != nil && !errors.Is(cause, ctx.Err()) {
230 + err = cause
231 + } else {
232 + err = ctx.Err()
233 + }
234 }
235
236 return out, cmdStr, usage, fmt.Errorf("%s: %v: %w (stderr: %s)", label, ex, err, strings.TrimSpace(s))
src/go/plugin/go.d/pkg/ndexec/ndexec_test.go
+63
@@ -175,6 +175,69 @@ func TestRunDirect(t *testing.T) {
175 })
176 }
177
178 +func TestRunDirectWithOptionsUsageContext(t *testing.T) {
179 + if runtime.GOOS == "windows" {
180 + t.Skip("uses sh scripts")
181 + }
182 +
183 + tmp := t.TempDir()
184 + workdir := filepath.Join(tmp, "subdir")
185 + require.NoError(t, os.Mkdir(workdir, 0o755))
186 +
187 + writeExe := func(path, body string) {
188 + require.NoError(t, os.WriteFile(path, []byte(body), 0o755))
189 + }
190 +
191 + script := filepath.Join(tmp, "envpwd.sh")
192 + writeExe(script, "#!/bin/sh\nprintf 'PWD=%s\\nFOO=%s\\n' \"$PWD\" \"$FOO\"\n")
193 +
194 + sleeper := filepath.Join(tmp, "sleep.sh")
195 + writeExe(sleeper, "#!/bin/sh\nsleep 2\n")
196 +
197 + tests := map[string]struct {
198 + timeout time.Duration
199 + opts RunOptions
200 + binPath string
201 + args []string
202 + assert func(*testing.T, []byte, string, ResourceUsage, error)
203 + }{
204 + "honors working directory and explicit environment": {
205 + timeout: time.Second,
206 + opts: RunOptions{
207 + Dir: workdir,
208 + Env: []string{"FOO=bar"},
209 + },
210 + binPath: script,
211 + assert: func(t *testing.T, out []byte, cmd string, usage ResourceUsage, err error) {
212 + t.Helper()
213 + require.NoError(t, err)
214 + assert.Contains(t, cmd, script)
215 + assert.Contains(t, string(out), "\nFOO=bar\n")
216 + assert.Contains(t, string(out), "PWD=")
217 + assert.True(t, usage.User >= 0)
218 + assert.True(t, usage.System >= 0)
219 + },
220 + },
221 + "timeout is propagated through the direct helper": {
222 + timeout: 100 * time.Millisecond,
223 + opts: RunOptions{},
224 + binPath: sleeper,
225 + assert: func(t *testing.T, _ []byte, _ string, _ ResourceUsage, err error) {
226 + t.Helper()
227 + require.Error(t, err)
228 + assert.ErrorIs(t, err, context.DeadlineExceeded)
229 + },
230 + },
231 + }
232 +
233 + for name, tc := range tests {
234 + t.Run(name, func(t *testing.T) {
235 + out, cmd, usage, err := RunDirectWithOptionsUsageContext(context.Background(), nil, tc.timeout, tc.opts, tc.binPath, tc.args...)
236 + tc.assert(t, out, cmd, usage, err)
237 + })
238 + }
239 +}
240 +
241 func TestFindBinary(t *testing.T) {
242 tmp := t.TempDir()
243
src/go/plugin/go.d/pkg/ndexec/process_group_unix.go new
+44
@@ -0,0 +1,44 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +//go:build !windows
4 +
5 +package ndexec
6 +
7 +import (
8 + "errors"
9 + "os"
10 + "os/exec"
11 + "syscall"
12 + "time"
13 +)
14 +
15 +func configureCommandCancellation(cmd *exec.Cmd) {
16 + if cmd == nil {
17 + return
18 + }
19 +
20 + if cmd.SysProcAttr == nil {
21 + cmd.SysProcAttr = &syscall.SysProcAttr{}
22 + }
23 + cmd.SysProcAttr.Setpgid = true
24 + cmd.WaitDelay = 250 * time.Millisecond
25 + cmd.Cancel = func() error {
26 + if cmd.Process == nil {
27 + return nil
28 + }
29 + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
30 + return resolveDirectKillFallback(cmd.Process.Kill())
31 + }
32 + return nil
33 + }
34 +}
35 +
36 +func resolveDirectKillFallback(killErr error) error {
37 + if killErr == nil {
38 + return nil
39 + }
40 + if errors.Is(killErr, os.ErrProcessDone) {
41 + return os.ErrProcessDone
42 + }
43 + return killErr
44 +}
src/go/plugin/go.d/pkg/ndexec/process_group_unix_test.go new
+49
@@ -0,0 +1,49 @@
1 +//go:build !windows
2 +
3 +// SPDX-License-Identifier: GPL-3.0-or-later
4 +
5 +package ndexec
6 +
7 +import (
8 + "errors"
9 + "os"
10 + "testing"
11 +
12 + "github.com/stretchr/testify/assert"
13 +)
14 +
15 +func TestResolveDirectKillFallback(t *testing.T) {
16 + groupKillErr := errors.New("group kill failed")
17 +
18 + tests := map[string]struct {
19 + killErr error
20 + assertErr func(*testing.T, error)
21 + }{
22 + "successful direct kill becomes success": {
23 + assertErr: func(t *testing.T, err error) {
24 + t.Helper()
25 + assert.NoError(t, err)
26 + },
27 + },
28 + "process already done preserves os.ErrProcessDone semantics": {
29 + killErr: os.ErrProcessDone,
30 + assertErr: func(t *testing.T, err error) {
31 + t.Helper()
32 + assert.ErrorIs(t, err, os.ErrProcessDone)
33 + },
34 + },
35 + "direct kill failure is returned": {
36 + killErr: groupKillErr,
37 + assertErr: func(t *testing.T, err error) {
38 + t.Helper()
39 + assert.ErrorIs(t, err, groupKillErr)
40 + },
41 + },
42 + }
43 +
44 + for name, tc := range tests {
45 + t.Run(name, func(t *testing.T) {
46 + tc.assertErr(t, resolveDirectKillFallback(tc.killErr))
47 + })
48 + }
49 +}
src/go/plugin/go.d/pkg/ndexec/process_group_windows.go new
+9
@@ -0,0 +1,9 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +//go:build windows
4 +
5 +package ndexec
6 +
7 +import "os/exec"
8 +
9 +func configureCommandCancellation(cmd *exec.Cmd) {}
src/go/plugin/scripts.d/README.md
+136 -152
@@ -1,189 +1,173 @@
1 # scripts.d.plugin (preview)
2
3 -`scripts.d.plugin` runs stock Nagios checks inside Netdata without modifying the
4 -original plugins. Jobs are executed through a dedicated scheduler/executor,
5 -perfdata metrics become native Netdata charts, and every run emits structured
6 -logs (stdout/stderr/state transitions) over OTLP.
3 +`scripts.d.plugin` runs Nagios-style check scripts inside Netdata without changing
4 +plugin output format. The active collector is `nagios` (single collector surface),
5 +implemented as a normal V2 collector with collector-local scheduling/state.
6
8 -> **Status:** preview. The core execution pipeline, charts, and logging are in
9 -> place, but configuration options and documentation may still change before GA.
7 +> **Status:** preview. Core execution, retry/state tracking, and perfdata routing are
8 +> implemented; config/docs may still evolve.
9
11 -## Configuration overview
10 +## Configuration
11
13 -Each YAML file under `/etc/netdata/scripts.d/*.conf` (or the stock directory
14 -shipped in `usr/lib/netdata/conf.d/scripts.d/`) follows the standard go.d layout:
15 -one top-level `jobs:` array, where each entry is a complete Nagios job
16 -definition (plugin path, arguments, scheduler name, vnode, retries, etc.). This
17 -matches what dyncfg exposes—when you edit a job in the UI you are editing the
18 -same YAML object you would put on disk.
12 +- Plugin-level toggles: `/etc/netdata/scripts.d.conf`
13 +- Collector jobs: `/etc/netdata/scripts.d/nagios.conf`
14 +
15 +Each job is a Nagios check definition.
16
17 Example:
18
19 ```yaml
20 jobs:
21 - name: ping_localhost
25 - scheduler: default
22 plugin: "/usr/lib/nagios/plugins/check_ping"
23 args: ["-H", "127.0.0.1", "-w", "100.0,20%", "-c", "200.0,40%"]
28 - timeout: 60s
29 - retry_interval: 1m
24 + timeout: 5s
25 + check_interval: 1m
26 + retry_interval: 30s
27 max_check_attempts: 3
28 ```
29
33 -There is no nested structure anymore—module files only list explicit
34 -jobs, and every job is autonomous. Plugin-level toggles (for example enabling or
35 -disabling modules) live in `/etc/netdata/scripts.d.conf`, but all scheduling,
36 -macros, and chart definitions stay inside the module job files just like go.d.
37 -
38 -### Argument macros
30 +The `plugin` value must be an absolute path. If you need an interpreter, point
31 +`plugin` to the interpreter executable and pass the script path in `args`.
32
40 -Jobs can bind up to 32 `$ARGn$` macros via the optional `arg_values` array.
41 -Entries in `args` that reference `$ARG1$` … `$ARG32$` are replaced with the
42 -corresponding values before execution, and the same values are exposed through
43 -`NAGIOS_ARGn` environment variables for the plugin to consume.
33 +### Time Periods
34
45 -### Time periods
46 -
47 -`check_period` controls when a job is allowed to run. Define time periods inside
48 -the module configuration (for example `/etc/netdata/scripts.d/nagios.conf`) and
49 -reference them from individual jobs:
35 +`check_period` is supported. Custom periods are defined with `time_periods` inside
36 +the same job definition.
37
38 ```yaml
52 -# /etc/netdata/scripts.d/nagios.conf
53 -time_periods:
54 - - name: 24x7
55 - alias: Always on
56 - rules:
57 - - type: weekly
58 - days: [sunday, monday, tuesday, wednesday, thursday, friday, saturday]
59 - ranges: ["00:00-24:00"]
60 -
39 jobs:
40 - name: local_plugins
41 + plugin: "/usr/lib/nagios/plugins/check_dummy"
42 + args: ["0", "ok"]
43 check_period: 24x7
64 - logging:
65 - enabled: true
66 - otlp:
67 - endpoint: 127.0.0.1:4317
68 - tls: false
69 - timeout: 5s
44 + time_periods:
45 + - name: 24x7
46 + alias: Always on
47 + rules:
48 + - type: weekly
49 + days: [sunday, monday, tuesday, wednesday, thursday, friday, saturday]
50 + ranges: ["00:00-24:00"]
51 ```
52
72 -The `logging` block enables OTLP log forwarding to Netdata's `otel.plugin`
73 -instance. The sample config keeps `tls: false` so local collectors can start
74 -without certificates; set `tls: true` (and optionally `tls_ca` / `tls_cert` /
75 -`tls_key`) to enforce encryption, even when emitting to `127.0.0.1:4317`.
76 -Adjust the endpoint, timeout, or headers if your OTLP
77 -pipeline lives elsewhere or requires authentication.
78 -
79 -### Scheduling semantics & skip behavior
80 -
81 -Every job owns a dedicated timer. When the timer fires the scheduler enqueues
82 -the job unless it is already queued or executing—matching Nagios Core's
83 -"single-flight" behavior. If a run is skipped this way, the skip counter for
84 -that job increases and the next run occurs at the normal cadence (there is no
85 -catch-up burst). Use the `nagios.runtime` chart's `skipped` dimension or the
86 -stock `health.d/nagios_skipped.conf` rule to monitor how frequently this happens
87 -and to page when a job repeatedly overlaps.
88 -
89 -### Scheduler telemetry
90 -
91 -The `scheduler` module publishes native charts so you can monitor queue depth,
92 -job throughput, and the “time until next execution” for each worker pool. Look
93 -for contexts such as `nagios.scheduler.jobs`, `nagios.scheduler.rate`, and
94 -`nagios.scheduler.next` to confirm schedulers are keeping up with load. Changes
95 -to scheduler definitions (worker counts or queue sizes) are applied live—when
96 -you edit the scheduler job, the underlying runtime is recreated and existing
97 -jobs are reattached automatically.
98 -
99 -### Charts, labels, and units
100 -
101 -Each job now derives a deterministic chart identity from its scheduler name and
102 -the fully expanded plugin command line. That signature feeds every chart ID and
103 -context, so different executions of the same script (for different URLs, hosts,
104 -etc.) keep separate time-series across restarts.
105 -
106 -- **Contexts & families** – job charts live under `nagios.<script>.<measurement>`
107 - (for example `nagios.http_check.latency`). This keeps “apples with apples” in
108 - the Netdata menu even when multiple jobs share a script.
109 -- **Labels** – all charts share the same label set: `nagios_job`,
110 - `nagios_scheduler`, and `nagios_cmdline` (the fully expanded plugin
111 - invocation). Perfdata charts add `perf_label`. The uniform labels make filtering
112 - dashboards and health alerts straightforward.
113 -- **Titles** – chart titles describe the script + measurement (e.g. “Nagios
114 - http_check response time”) rather than the monitored endpoint, so all charts
115 - with a shared context render cleanly in the UI.
116 -
117 -#### Unit normalization & scaling
118 -
119 -Netdata stores integers, so scripts.d.plugin normalizes perfdata to base units
120 -before emitting metrics:
121 -
122 -| Input unit | Canonical unit | Scaling behaviour |
123 -|----------------------------|----------------|------------------------------------------------|
124 -| Bytes, KB, MB, GB, TB | bytes | Converted to raw bytes, divider = 1 |
125 -| Bytes per second (KB/s …) | bytes/s | Converted to bytes/s, divider = 1 |
126 -| Seconds, ms, µs, ns | seconds | Stored as nanoseconds, divider = 1 000 000 000 |
127 -| Percent (`%`) | % | Value ×1000, divider = 1000 |
128 -| Counters (`c`) | c | Stored as-is |
129 -| Any other unit or unitless | original text | Value ×1000, divider = 1000 |
130 -
131 -When a plugin flips between `KB` and `MB` (or `ms` and `s`) the collector still
132 -publishes a single chart in base units, so there are no spurious RRD resets. If
133 -a metric genuinely changes semantics (for example, from bytes to seconds) or a
134 -job’s cadence changes, scripts.d.plugin re-sends the CHART definition so Netdata
135 -can flush/recreate the series with the new metadata.
136 -
137 -The scheduler also exposes a `nagios.scheduler.next` chart showing
138 -“time until the next job fires” in seconds (nanosecond precision). Expect it to
139 -track the configured intervals; spikes indicate the executor is falling behind
140 -(worker starvation, very long-running checks, etc.).
141 -
142 -### Cadence (`update_every`)
143 -
144 -Jobs follow the same cadence controls as go.d collectors: set `update_every`
145 -(seconds) inside each job definition to run faster or slower than the default
146 -60 s interval. When omitted, scripts.d picks a conservative default (currently 60 s).
147 -
148 -### Logging over OTLP (TLS support)
149 -
150 -Structured logs are forwarded to OTEL via `logging.otlp`. Besides `endpoint`,
151 -`timeout`, `tls`, and `headers`, you can now set:
152 -
153 -- `tls_ca`: custom CA bundle for the collector
154 -- `tls_cert` / `tls_key`: client certificate pair for mutual TLS
155 -- `tls_server_name`: override the TLS SNI when the collector name differs from the
156 - endpoint host
157 -- `tls_skip_verify`: disable server certificate verification (not
158 - recommended outside of lab environments)
159 -
160 -When `tls` is `true`, the plugin always establishes a TLS 1.2 connection using
161 -these settings. Set `tls: false` only for loopback collectors or other trusted
162 -plaintext networks.
163 -
164 -### Mock integration tests
165 -
166 -A small suite of mock Nagios plugins lives under `tests/plugins/`. They exercise
167 -state handling, perfdata parsing, macro substitution, long output logging, and
168 -skip semantics without requiring external services. Run them with:
53 +## Writing Compatible Checks
54 +
55 +A compatible check returns a Nagios state with its exit code and prints a status
56 +line that Netdata can parse.
57 +
58 +- Exit codes:
59 + - `0` = OK
60 + - `1` = WARNING
61 + - `2` = CRITICAL
62 + - `3` = UNKNOWN
63 +- First-line output format:
64 + - `<summary text> | <perfdata>`
65 +- The `|` separator is optional:
66 + - text before `|` is the human-readable summary
67 + - text after `|` is performance data used for auto-generated charts
68 +- Each performance-data item follows:
69 + - `'label'=value[UOM];warn;crit;min;max`
70 +- Separate multiple metrics with spaces.
71 +- Common units include:
72 + - `%`, `s`, `ms`, `B`, `KB`, `MB`, `GB`, `c`
73 +- If the script prints multiple lines:
74 + - the first line is the summary
75 + - the remaining lines are kept as long output
76 +
77 +Minimal example:
78
79 ```bash
171 -cd src/go
172 -go test ./plugin/scripts.d/tests
80 +#!/bin/sh
81 +echo "CPU OK - 20% used | cpu=20%;80;90"
82 +exit 0
83 ```
84
175 -The tests stub `nd-run`, execute the mock scripts through the scheduler, and
176 -assert the emitted metrics/logs. Extend this suite whenever you add new
177 -collector features so we keep a fast end-to-end signal in CI.
85 +## Execution Model
86 +
87 +- Each `jobs:` entry becomes one V2 Nagios collector instance.
88 +- Script execution happens during `Collect()` only when the job is due.
89 +- `update_every` is the scheduling resolution.
90 +- If `update_every` is slower than `check_interval` or `retry_interval`, Netdata
91 + logs a warning and the effective cadence is limited by `update_every`.
92 +- Nagios semantics are preserved collector-side:
93 + - `check_interval`
94 + - `retry_interval`
95 + - `max_check_attempts`
96 + - `check_period`
97 +- If a check exceeds `timeout`, Netdata reports the job state as `timeout`.
98 +- If a check is due but the current time is outside `check_period`, Netdata does not execute it and reports the public job state as `paused`.
99 +- Non-due successful cycles replay the last cached perfdata values and threshold
100 + states so chartengine series stay alive between executions.
101 +- When a due run is blocked by `check_period`, perfdata value charts remain at their last observed values, but threshold-state charts are zeroed until the next successful execution.
102 +- Counter perfdata keeps counter semantics for the value series. Replayed raw
103 + totals naturally flatten to zero deltas between executions.
104 +
105 +Defaults:
106 +
107 +- `check_interval`: `5m`
108 +- `retry_interval`: `1m`
109 +- `timeout`: `5s`
110 +- `max_check_attempts`: `3`
111 +
112 +## Metrics and Charts
113 +
114 +Static template charts:
115 +
116 +- `nagios.job.state`
117 +- `nagios.job.execution_duration`
118 +- `nagios.job.execution_cpu_total`
119 +- `nagios.job.execution_max_rss`
120 +
121 +Perfdata is routed plugin-side and materialized via autogen (bounded lifecycle):
122 +
123 +- Unit classes: `time`, `bytes`, `bits`, `percent`, `counter`, `generic`
124 +- Metric identity: sanitized perfdata key (from Nagios perfdata label)
125 +- Unit-class changes create a new metric identity
126 +- Collision policy: deterministic keep-first, drop conflicting label
127 +- Each perfdata metric creates one value chart.
128 +- Non-counter perfdata also creates one derived threshold-state chart with:
129 + - `no_threshold`
130 + - `ok`
131 + - `warning`
132 + - `critical`
133 +- Counter perfdata currently does not emit a threshold-state chart.
134 +- Raw `min`, `max`, and raw threshold bounds are not charted.
135
179 -## Building
136 +## Alerts
137
181 -Enable the plugin during CMake configuration:
138 +- This preview collector does not currently ship built-in Netdata health alerts.
139 +- Use `nagios.job.state` and the derived non-counter perfdata threshold-state
140 + charts as the inputs for your own alert rules.
141 +
142 +## Logging
143 +
144 +Checks log through the collector/job logger path. There is no separate public runtime
145 +component or scheduler telemetry surface.
146 +
147 +## Windows Note
148 +
149 +- On Windows, the collector runs the command named in `plugin` directly.
150 +- Use an executable path, or point `plugin` to an interpreter such as
151 + `powershell.exe` and pass the script path in `args`.
152 +
153 +## Tests
154 +
155 +```bash
156 +cd src/go
157 +go test ./plugin/scripts.d/collector/nagios/... -count=1
158 +```
159 +
160 +## Build
161
162 ```bash
163 cmake -DENABLE_PLUGIN_SCRIPTS=On ..
164 cmake --build . --target scripts-plugin
165 ```
166
188 -The resulting binary lives at `usr/libexec/netdata/plugins.d/scripts.d.plugin`.
189 -Stock configuration ships in `usr/lib/netdata/conf.d/scripts.d/`.
167 +Binary path:
168 +
169 +- `usr/libexec/netdata/plugins.d/scripts.d.plugin`
170 +
171 +Stock config path:
172 +
173 +- `usr/lib/netdata/conf.d/scripts.d/`
src/go/plugin/scripts.d/charts/charts.go deleted
-39
@@ -1,39 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package charts
4 -
5 -import (
6 - "fmt"
7 -
8 - "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
9 -)
10 -
11 -const (
12 - ctxPrefix = "nagios"
13 -)
14 -
15 -func SchedulerChartID(scheduler, suffix string) string {
16 - return fmt.Sprintf("%s.scheduler.%s", scheduler, suffix)
17 -}
18 -
19 -func SchedulerMetricKey(scheduler, suffix, dim string) string {
20 - return fmt.Sprintf("%s.%s", SchedulerChartID(scheduler, suffix), dim)
21 -}
22 -
23 -func telemetryChartBase(meta JobIdentity, metric string) collectorapi.Chart {
24 - return collectorapi.Chart{
25 - Fam: "jobs",
26 - Ctx: fmt.Sprintf("%s.jobs.%s", ctxPrefix, metric),
27 - Type: collectorapi.Line,
28 - Labels: meta.Labels(),
29 - }
30 -}
31 -
32 -func perfdataChartBase(meta JobIdentity) collectorapi.Chart {
33 - return collectorapi.Chart{
34 - Fam: meta.ScriptKey,
35 - Ctx: fmt.Sprintf("%s.%s", ctxPrefix, meta.ScriptKey),
36 - Type: collectorapi.Line,
37 - Labels: meta.Labels(),
38 - }
39 -}
src/go/plugin/scripts.d/charts/identity.go deleted
-195
@@ -1,195 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package charts
4 -
5 -import (
6 - "crypto/sha1"
7 - "encoding/hex"
8 - "fmt"
9 - "path/filepath"
10 - "sort"
11 - "strings"
12 -
13 - "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
14 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/ids"
15 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
16 -)
17 -
18 -// JobIdentity encapsulates every bit of information needed to build stable chart IDs,
19 -// labels, and titles for a specific Nagios job execution.
20 -type JobIdentity struct {
21 - Scheduler string
22 - JobName string
23 - JobKey string
24 - PluginBase string
25 - ChartKey string
26 - ScriptKey string
27 - ScriptTitle string
28 - Cmdline string
29 -}
30 -
31 -// NewJobIdentity builds a deterministic identifier for the given job under the
32 -// provided scheduler. The resulting ChartKey is stable across restarts and unique for
33 -// a specific script + parameter combination (vnode is intentionally excluded).
34 -func NewJobIdentity(scheduler string, job spec.JobSpec) JobIdentity {
35 - cmdline := buildCmdline(job)
36 - pluginBase := pluginBasename(job)
37 - scriptKey, scriptTitle := scriptKeyForJob(job, pluginBase)
38 - chartKey := chartKeyForJob(job, cmdline, scriptKey)
39 - jobKey := jobKeyForJob(job)
40 -
41 - return JobIdentity{
42 - Scheduler: scheduler,
43 - JobName: job.Name,
44 - JobKey: jobKey,
45 - PluginBase: pluginBase,
46 - ChartKey: chartKey,
47 - ScriptKey: scriptKey,
48 - ScriptTitle: scriptTitle,
49 - Cmdline: cmdline,
50 - }
51 -}
52 -
53 -// Labels returns the canonical label set shared across every chart for this job.
54 -func (id JobIdentity) Labels() []collectorapi.Label {
55 - return []collectorapi.Label{
56 - {Key: "nagios_job", Value: id.JobName, Source: collectorapi.LabelSourceConf},
57 - {Key: "nagios_scheduler", Value: id.Scheduler, Source: collectorapi.LabelSourceConf},
58 - {Key: "nagios_plugin", Value: id.PluginBase, Source: collectorapi.LabelSourceConf},
59 - {Key: "nagios_cmdline", Value: id.Cmdline, Source: collectorapi.LabelSourceConf},
60 - }
61 -}
62 -
63 -func buildCmdline(job spec.JobSpec) string {
64 - parts := append([]string{strings.TrimSpace(job.Plugin)}, job.Args...)
65 - filtered := make([]string, 0, len(parts))
66 - for _, part := range parts {
67 - if part == "" {
68 - continue
69 - }
70 - filtered = append(filtered, part)
71 - }
72 - return strings.Join(filtered, " ")
73 -}
74 -
75 -func scriptKeyForJob(job spec.JobSpec, pluginBase string) (string, string) {
76 - base := pluginBase
77 - if base == "" {
78 - base = job.Name
79 - }
80 - base = strings.TrimSuffix(base, filepath.Ext(base))
81 - sanitized := ids.Sanitize(base)
82 - if sanitized == "" {
83 - sanitized = "nagios_job"
84 - }
85 - scriptTitle := strings.ReplaceAll(sanitized, "_", " ")
86 - return sanitized, scriptTitle
87 -}
88 -
89 -func pluginBasename(job spec.JobSpec) string {
90 - base := filepath.Base(strings.TrimSpace(job.Plugin))
91 - if base == "" {
92 - return job.Name
93 - }
94 - return base
95 -}
96 -
97 -func chartKeyForJob(job spec.JobSpec, cmdline, scriptKey string) string {
98 - snippet := sanitizeArgs(scriptKey, job.Args)
99 - signature := buildJobSignature(job, cmdline)
100 - hash := shortHash(signature)
101 - key := strings.Trim(strings.Join([]string{snippet, hash}, "_"), "_")
102 - if key == "" {
103 - key = hash
104 - }
105 - return ids.Sanitize(key)
106 -}
107 -
108 -func jobKeyForJob(job spec.JobSpec) string {
109 - key := ids.Sanitize(job.Name)
110 - if key == "" {
111 - key = "job"
112 - }
113 - return key
114 -}
115 -
116 -func sanitizeArgs(scriptKey string, args []string) string {
117 - parts := []string{scriptKey}
118 - for _, arg := range args {
119 - if arg == "" {
120 - continue
121 - }
122 - parts = append(parts, ids.Sanitize(arg))
123 - }
124 - return strings.Trim(strings.Join(parts, "_"), "_")
125 -}
126 -
127 -func buildJobSignature(job spec.JobSpec, cmdline string) string {
128 - var b strings.Builder
129 - b.WriteString(cmdline)
130 - b.WriteByte('|')
131 - b.WriteString(job.WorkingDirectory)
132 - b.WriteByte('|')
133 - appendMap(&b, job.Environment)
134 - b.WriteByte('|')
135 - appendMap(&b, job.CustomVars)
136 - b.WriteByte('|')
137 - appendSlice(&b, job.ArgValues)
138 - return b.String()
139 -}
140 -
141 -func appendMap(b *strings.Builder, m map[string]string) {
142 - if len(m) == 0 {
143 - return
144 - }
145 - keys := make([]string, 0, len(m))
146 - for k := range m {
147 - keys = append(keys, k)
148 - }
149 - sort.Strings(keys)
150 - for _, k := range keys {
151 - b.WriteString(k)
152 - b.WriteByte('=')
153 - b.WriteString(m[k])
154 - b.WriteByte(';')
155 - }
156 -}
157 -
158 -func appendSlice(b *strings.Builder, values []string) {
159 - for _, v := range values {
160 - if v == "" {
161 - continue
162 - }
163 - b.WriteString(v)
164 - b.WriteByte(';')
165 - }
166 -}
167 -
168 -func shortHash(data string) string {
169 - sum := sha1.Sum([]byte(data))
170 - return hex.EncodeToString(sum[:4])
171 -}
172 -
173 -func (id JobIdentity) TelemetryChartID(metric string) string {
174 - return fmt.Sprintf("%s.%s.%s.%s", ctxPrefix, id.Scheduler, id.JobKey, metric)
175 -}
176 -
177 -func (id JobIdentity) TelemetryMetricID(metric, dim string) string {
178 - return fmt.Sprintf("%s.%s", id.TelemetryChartID(metric), dim)
179 -}
180 -
181 -func (id JobIdentity) PerfdataChartID(labelID string) string {
182 - return fmt.Sprintf("%s.%s.%s.perf_%s", ctxPrefix, id.Scheduler, id.JobKey, labelID)
183 -}
184 -
185 -func (id JobIdentity) PerfdataMetricID(labelID, dim string) string {
186 - return fmt.Sprintf("%s.%s", id.PerfdataChartID(labelID), dim)
187 -}
188 -
189 -func (id JobIdentity) MetricPrefix() string {
190 - return fmt.Sprintf("%s.%s.%s.", ctxPrefix, id.Scheduler, id.JobKey)
191 -}
192 -
193 -func (id JobIdentity) OwnsMetric(key string) bool {
194 - return strings.HasPrefix(key, id.MetricPrefix())
195 -}
src/go/plugin/scripts.d/charts/registry.go deleted
-24
@@ -1,24 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package charts
4 -
5 -import "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
6 -
7 -func BuildJobCharts(meta JobIdentity, basePriority int) []*collectorapi.Chart {
8 - return []*collectorapi.Chart{
9 - StateChart(meta, basePriority),
10 - RuntimeChart(meta, basePriority+1),
11 - LatencyChart(meta, basePriority+2),
12 - CPUChart(meta, basePriority+3),
13 - MemoryChart(meta, basePriority+4),
14 - DiskChart(meta, basePriority+5),
15 - }
16 -}
17 -
18 -func BuildSchedulerCharts(scheduler string, basePriority int) []*collectorapi.Chart {
19 - return []*collectorapi.Chart{
20 - SchedulerJobsChart(scheduler, basePriority),
21 - SchedulerRateChart(scheduler, basePriority+1),
22 - SchedulerNextRunChart(scheduler, basePriority+2),
23 - }
24 -}
src/go/plugin/scripts.d/charts/state.go deleted
-200
@@ -1,200 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package charts
4 -
5 -import (
6 - "fmt"
7 -
8 - "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
9 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/ids"
10 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/units"
11 -)
12 -
13 -const (
14 - TelemetryStateMetric = "state"
15 - TelemetryRuntimeMetric = "runtime"
16 - TelemetryLatencyMetric = "latency"
17 - TelemetryCPUMetric = "cpu"
18 - TelemetryMemoryMetric = "mem"
19 - TelemetryDiskMetric = "disk"
20 - ChartSchedulerJobs = "jobs"
21 - ChartSchedulerRate = "rate"
22 - ChartSchedulerNext = "next"
23 -)
24 -
25 -func StateChart(meta JobIdentity, priority int) *collectorapi.Chart {
26 - chart := telemetryChartBase(meta, TelemetryStateMetric)
27 - chart.ID = meta.TelemetryChartID(TelemetryStateMetric)
28 - chart.Title = "Nagios Plugin State"
29 - chart.Units = "state"
30 - chart.Priority = priority
31 - chart.Dims = collectorapi.Dims{
32 - {ID: meta.TelemetryMetricID(TelemetryStateMetric, "ok"), Name: "OK", Algo: collectorapi.Absolute, Div: 1},
33 - {ID: meta.TelemetryMetricID(TelemetryStateMetric, "warning"), Name: "WARNING", Algo: collectorapi.Absolute, Div: 1},
34 - {ID: meta.TelemetryMetricID(TelemetryStateMetric, "critical"), Name: "CRITICAL", Algo: collectorapi.Absolute, Div: 1},
35 - {ID: meta.TelemetryMetricID(TelemetryStateMetric, "unknown"), Name: "UNKNOWN", Algo: collectorapi.Absolute, Div: 1},
36 - {ID: meta.TelemetryMetricID(TelemetryStateMetric, "attempt"), Name: "attempt", Algo: collectorapi.Absolute, DimOpts: collectorapi.DimOpts{Hidden: true}},
37 - {ID: meta.TelemetryMetricID(TelemetryStateMetric, "max_attempts"), Name: "max_attempts", Algo: collectorapi.Absolute, DimOpts: collectorapi.DimOpts{Hidden: true}},
38 - }
39 - chart.Opts.Detail = true
40 - return &chart
41 -}
42 -
43 -func RuntimeChart(meta JobIdentity, priority int) *collectorapi.Chart {
44 - chart := telemetryChartBase(meta, TelemetryRuntimeMetric)
45 - chart.ID = meta.TelemetryChartID(TelemetryRuntimeMetric)
46 - chart.Title = "Nagios Plugin Runtime State"
47 - chart.Units = "boolean"
48 - chart.Priority = priority
49 - chart.Dims = collectorapi.Dims{
50 - {ID: meta.TelemetryMetricID(TelemetryRuntimeMetric, "running"), Name: "running", Algo: collectorapi.Absolute},
51 - {ID: meta.TelemetryMetricID(TelemetryRuntimeMetric, "retrying"), Name: "retrying", Algo: collectorapi.Absolute},
52 - {ID: meta.TelemetryMetricID(TelemetryRuntimeMetric, "skipped"), Name: "skipped", Algo: collectorapi.Absolute},
53 - {ID: meta.TelemetryMetricID(TelemetryRuntimeMetric, "cpu_missing"), Name: "cpu_missing", Algo: collectorapi.Absolute, DimOpts: collectorapi.DimOpts{Hidden: true}},
54 - }
55 - chart.Opts.Detail = true
56 - return &chart
57 -}
58 -
59 -func LatencyChart(meta JobIdentity, priority int) *collectorapi.Chart {
60 - chart := telemetryChartBase(meta, TelemetryLatencyMetric)
61 - chart.ID = meta.TelemetryChartID(TelemetryLatencyMetric)
62 - chart.Title = "Nagios Plugin Execution Time"
63 - chart.Units = "seconds"
64 - chart.Priority = priority
65 - chart.Dims = collectorapi.Dims{{ID: meta.TelemetryMetricID(TelemetryLatencyMetric, "duration"), Name: "duration", Algo: collectorapi.Absolute, Div: 1_000_000_000}}
66 - return &chart
67 -}
68 -
69 -func CPUChart(meta JobIdentity, priority int) *collectorapi.Chart {
70 - chart := telemetryChartBase(meta, TelemetryCPUMetric)
71 - chart.ID = meta.TelemetryChartID(TelemetryCPUMetric)
72 - chart.Title = "Nagios Plugin CPU Usage"
73 - chart.Units = "seconds"
74 - chart.Priority = priority
75 - chart.Dims = collectorapi.Dims{{ID: meta.TelemetryMetricID(TelemetryCPUMetric, "cpu_time"), Name: "cpu", Algo: collectorapi.Absolute, Div: 1_000_000_000}}
76 - return &chart
77 -}
78 -
79 -func MemoryChart(meta JobIdentity, priority int) *collectorapi.Chart {
80 - chart := telemetryChartBase(meta, TelemetryMemoryMetric)
81 - chart.ID = meta.TelemetryChartID(TelemetryMemoryMetric)
82 - chart.Title = "Nagios Plugin Memory Usage"
83 - chart.Units = "bytes"
84 - chart.Priority = priority
85 - chart.Dims = collectorapi.Dims{{ID: meta.TelemetryMetricID(TelemetryMemoryMetric, "rss"), Name: "rss", Algo: collectorapi.Absolute}}
86 - return &chart
87 -}
88 -
89 -func DiskChart(meta JobIdentity, priority int) *collectorapi.Chart {
90 - chart := telemetryChartBase(meta, TelemetryDiskMetric)
91 - chart.ID = meta.TelemetryChartID(TelemetryDiskMetric)
92 - chart.Title = "Nagios Plugin Disk I/O"
93 - chart.Units = "bytes"
94 - chart.Priority = priority
95 - chart.Dims = collectorapi.Dims{
96 - {ID: meta.TelemetryMetricID(TelemetryDiskMetric, "read"), Name: "read", Algo: collectorapi.Absolute},
97 - {ID: meta.TelemetryMetricID(TelemetryDiskMetric, "write"), Name: "write", Algo: collectorapi.Absolute},
98 - }
99 - return &chart
100 -}
101 -
102 -func SchedulerJobsChart(scheduler string, priority int) *collectorapi.Chart {
103 - chart := schedulerChartBase(scheduler)
104 - chart.ID = SchedulerChartID(scheduler, ChartSchedulerJobs)
105 - chart.Title = "Nagios Scheduler Jobs Status"
106 - chart.Units = "jobs"
107 - chart.Priority = priority
108 - chart.Ctx = ctxPrefix + ".scheduler.jobs"
109 - chart.Dims = collectorapi.Dims{
110 - {ID: SchedulerMetricKey(scheduler, ChartSchedulerJobs, "running"), Name: "running", Algo: collectorapi.Absolute},
111 - {ID: SchedulerMetricKey(scheduler, ChartSchedulerJobs, "queued"), Name: "queued", Algo: collectorapi.Absolute},
112 - {ID: SchedulerMetricKey(scheduler, ChartSchedulerJobs, "scheduled"), Name: "scheduled", Algo: collectorapi.Absolute},
113 - }
114 - return &chart
115 -}
116 -
117 -func SchedulerRateChart(scheduler string, priority int) *collectorapi.Chart {
118 - chart := schedulerChartBase(scheduler)
119 - chart.ID = SchedulerChartID(scheduler, ChartSchedulerRate)
120 - chart.Title = "Nagios Scheduler Workload"
121 - chart.Units = "jobs"
122 - chart.Priority = priority
123 - chart.Ctx = ctxPrefix + ".scheduler.rate"
124 - chart.Dims = collectorapi.Dims{
125 - {ID: SchedulerMetricKey(scheduler, ChartSchedulerRate, "started"), Name: "started", Algo: collectorapi.Incremental},
126 - {ID: SchedulerMetricKey(scheduler, ChartSchedulerRate, "finished"), Name: "finished", Algo: collectorapi.Incremental},
127 - {ID: SchedulerMetricKey(scheduler, ChartSchedulerRate, "skipped"), Name: "skipped", Algo: collectorapi.Incremental},
128 - }
129 - chart.Opts.Detail = true
130 - return &chart
131 -}
132 -
133 -func SchedulerNextRunChart(scheduler string, priority int) *collectorapi.Chart {
134 - chart := schedulerChartBase(scheduler)
135 - chart.ID = SchedulerChartID(scheduler, ChartSchedulerNext)
136 - chart.Title = "Nagios Scheduler Next Run Time"
137 - chart.Units = "seconds"
138 - chart.Priority = priority
139 - chart.Ctx = ctxPrefix + ".scheduler.next"
140 - chart.Dims = collectorapi.Dims{{ID: SchedulerMetricKey(scheduler, ChartSchedulerNext, "next"), Name: "next", Algo: collectorapi.Absolute, Div: 1_000_000_000}}
141 - chart.Opts.Detail = true
142 - return &chart
143 -}
144 -
145 -func PerfdataChart(meta JobIdentity, label string, scale units.Scale, priority int) *collectorapi.Chart {
146 - chart := perfdataChartBase(meta)
147 - labelID := ids.Sanitize(label)
148 - if labelID == "" {
149 - labelID = "metric"
150 - }
151 - chart.ID = meta.PerfdataChartID(labelID)
152 - chart.Title = "Nagios Plugin Performance Data"
153 - chart.Units = canonicalUnit(scale, label)
154 - chart.Priority = priority
155 - chart.Ctx = fmt.Sprintf("%s.%s.%s", ctxPrefix, meta.ScriptKey, labelID)
156 - div := scale.Divisor
157 - if div <= 0 {
158 - div = 1
159 - }
160 - chart.Dims = collectorapi.Dims{
161 - {ID: meta.PerfdataMetricID(labelID, "value"), Name: "value", Algo: collectorapi.Absolute, Div: div},
162 - {ID: meta.PerfdataMetricID(labelID, "min"), Name: "min", Algo: collectorapi.Absolute, Div: div, DimOpts: collectorapi.DimOpts{Hidden: true}},
163 - {ID: meta.PerfdataMetricID(labelID, "max"), Name: "max", Algo: collectorapi.Absolute, Div: div, DimOpts: collectorapi.DimOpts{Hidden: true}},
164 - {ID: meta.PerfdataMetricID(labelID, "warn_low"), Name: "warn_low", Algo: collectorapi.Absolute, Div: div, DimOpts: collectorapi.DimOpts{Hidden: true}},
165 - {ID: meta.PerfdataMetricID(labelID, "warn_high"), Name: "warn_high", Algo: collectorapi.Absolute, Div: div, DimOpts: collectorapi.DimOpts{Hidden: true}},
166 - {ID: meta.PerfdataMetricID(labelID, "warn_low_defined"), Name: "warn_low_defined", Algo: collectorapi.Absolute, DimOpts: collectorapi.DimOpts{Hidden: true}},
167 - {ID: meta.PerfdataMetricID(labelID, "warn_high_defined"), Name: "warn_high_defined", Algo: collectorapi.Absolute, DimOpts: collectorapi.DimOpts{Hidden: true}},
168 - {ID: meta.PerfdataMetricID(labelID, "warn_defined"), Name: "warn_defined", Algo: collectorapi.Absolute, DimOpts: collectorapi.DimOpts{Hidden: true}},
169 - {ID: meta.PerfdataMetricID(labelID, "warn_inclusive"), Name: "warn_inclusive", Algo: collectorapi.Absolute, DimOpts: collectorapi.DimOpts{Hidden: true}},
170 - {ID: meta.PerfdataMetricID(labelID, "crit_low"), Name: "crit_low", Algo: collectorapi.Absolute, Div: div, DimOpts: collectorapi.DimOpts{Hidden: true}},
171 - {ID: meta.PerfdataMetricID(labelID, "crit_high"), Name: "crit_high", Algo: collectorapi.Absolute, Div: div, DimOpts: collectorapi.DimOpts{Hidden: true}},
172 - {ID: meta.PerfdataMetricID(labelID, "crit_low_defined"), Name: "crit_low_defined", Algo: collectorapi.Absolute, DimOpts: collectorapi.DimOpts{Hidden: true}},
173 - {ID: meta.PerfdataMetricID(labelID, "crit_high_defined"), Name: "crit_high_defined", Algo: collectorapi.Absolute, DimOpts: collectorapi.DimOpts{Hidden: true}},
174 - {ID: meta.PerfdataMetricID(labelID, "crit_defined"), Name: "crit_defined", Algo: collectorapi.Absolute, DimOpts: collectorapi.DimOpts{Hidden: true}},
175 - {ID: meta.PerfdataMetricID(labelID, "crit_inclusive"), Name: "crit_inclusive", Algo: collectorapi.Absolute, DimOpts: collectorapi.DimOpts{Hidden: true}},
176 - }
177 - chart.Labels = append(chart.Labels,
178 - collectorapi.Label{Key: "perf_label", Value: label, Source: collectorapi.LabelSourceConf},
179 - )
180 - chart.Opts.Detail = true
181 - return &chart
182 -}
183 -
184 -func canonicalUnit(scale units.Scale, fallback string) string {
185 - if scale.CanonicalUnit != "" {
186 - return scale.CanonicalUnit
187 - }
188 - return fallback
189 -}
190 -
191 -func schedulerChartBase(scheduler string) collectorapi.Chart {
192 - return collectorapi.Chart{
193 - Fam: "scheduler",
194 - Ctx: fmt.Sprintf("%s.scheduler", ctxPrefix),
195 - Type: collectorapi.Line,
196 - Labels: []collectorapi.Label{
197 - {Key: "nagios_scheduler", Value: scheduler, Source: collectorapi.LabelSourceConf},
198 - },
199 - }
200 -}
src/go/plugin/scripts.d/collector/nagios/charts.yaml new
+58
@@ -0,0 +1,58 @@
1 +version: v1
2 +context_namespace: nagios
3 +engine:
4 + autogen:
5 + enabled: true
6 + max_type_id_len: 200
7 + expire_after_success_cycles: 3
8 +groups:
9 + - family: Job/Status
10 + metrics:
11 + - nagios.job.state
12 + charts:
13 + - id: job_state
14 + title: Job State
15 + context: job_state
16 + units: state
17 + instances:
18 + by_labels: [nagios_job]
19 + dimensions:
20 + - selector: nagios.job.state
21 +
22 + - family: Job/Execution
23 + metrics:
24 + - nagios.job.execution_duration
25 + - nagios.job.execution_cpu_total
26 + - nagios.job.execution_max_rss
27 + charts:
28 + - id: job_execution_duration
29 + title: Execution Duration
30 + context: job_execution_duration
31 + units: seconds
32 + instances:
33 + by_labels: [nagios_job]
34 + dimensions:
35 + - selector: nagios.job.execution_duration
36 + name: duration
37 + options:
38 + float: true
39 + - id: job_execution_cpu
40 + title: Execution CPU Time
41 + context: job_execution_cpu
42 + units: seconds
43 + instances:
44 + by_labels: [nagios_job]
45 + dimensions:
46 + - selector: nagios.job.execution_cpu_total
47 + name: total
48 + options:
49 + float: true
50 + - id: job_execution_memory
51 + title: Execution Peak RSS
52 + context: job_execution_memory
53 + units: bytes
54 + instances:
55 + by_labels: [nagios_job]
56 + dimensions:
57 + - selector: nagios.job.execution_max_rss
58 + name: rss
src/go/plugin/scripts.d/collector/nagios/collect.go new
+158
@@ -0,0 +1,158 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "context"
7 + "runtime"
8 + "strings"
9 + "time"
10 +
11 + "github.com/netdata/netdata/go/plugins/pkg/metrix"
12 +)
13 +
14 +func (c *Collector) collect(ctx context.Context) error {
15 + execMetrics, err := c.collectIfDue(ctx)
16 + if err != nil {
17 + return err
18 + }
19 + c.emitMetrics(execMetrics)
20 + return nil
21 +}
22 +
23 +func (c *Collector) collectIfDue(ctx context.Context) (executionMetrics, error) {
24 + now := c.now()
25 + if !c.state.due(now) {
26 + return executionMetrics{}, nil
27 + }
28 +
29 + if c.skipDisallowedPeriod(now) {
30 + return executionMetrics{}, nil
31 + }
32 +
33 + res, err := c.executeDueCheck(ctx, now)
34 + if err != nil {
35 + return executionMetrics{}, err
36 + }
37 +
38 + c.completeDueCheck(now, res)
39 + return executionMetricsFromResult(res), nil
40 +}
41 +
42 +func (c *Collector) skipDisallowedPeriod(now time.Time) bool {
43 + if c.job.period == nil || c.job.period.Allows(now) {
44 + return false
45 + }
46 + c.state.recordPeriodBlocked()
47 + c.state.scheduleNextAllowed(now, c.job.config.CheckInterval.Duration(), c.job.period)
48 + return true
49 +}
50 +
51 +func (c *Collector) executeDueCheck(ctx context.Context, now time.Time) (checkRunResult, error) {
52 + res, err := c.runner.Run(ctx, checkRunRequest{
53 + Job: c.job.config,
54 + Vnode: vnodeInfoFromVirtualNode(c.VirtualNode(), c.job.config.Vnode),
55 + MacroState: c.state.macroState(),
56 + Now: now,
57 + Log: c.Logger,
58 + })
59 + if err != nil {
60 + if runErr := classifyRunError(ctx, res.ExitCode, err); runErr != nil {
61 + return checkRunResult{}, runErr
62 + }
63 + }
64 + return res, nil
65 +}
66 +
67 +func (c *Collector) completeDueCheck(now time.Time, res checkRunResult) {
68 + c.state.completeRun(now, res.ServiceState, res.JobState, c.router.route(c.job.config.Plugin, res.Parsed.Perfdata), c.job.config)
69 +}
70 +
71 +func (c *Collector) emitMetrics(execMetrics executionMetrics) {
72 + sm := c.store.Write().SnapshotMeter("nagios")
73 +
74 + jobName := c.job.config.Name
75 + if jobName == "" {
76 + jobName = c.Config.JobConfig.Name
77 + }
78 +
79 + jobLbl := sm.LabelSet(metrix.Label{Key: "nagios_job", Value: jobName})
80 + jobMeter := sm.WithLabelSet(jobLbl)
81 +
82 + jobMeter.StateSet(
83 + "job.state",
84 + metrix.WithStateSetMode(metrix.ModeEnum),
85 + metrix.WithStateSetStates("ok", "warning", "critical", "unknown", "timeout", "paused"),
86 + metrix.WithUnit("state"),
87 + ).Enable(normalizeJobStateForMetric(c.state.currentJobState()))
88 +
89 + jobMeter.Gauge(
90 + "job.execution_duration",
91 + metrix.WithUnit("seconds"),
92 + metrix.WithFloat(true),
93 + ).Observe(execMetrics.durationSeconds)
94 +
95 + if runtime.GOOS != "windows" {
96 + jobMeter.Gauge(
97 + "job.execution_cpu_total",
98 + metrix.WithUnit("seconds"),
99 + metrix.WithFloat(true),
100 + ).Observe(execMetrics.cpuTotalSeconds)
101 +
102 + jobMeter.Gauge(
103 + "job.execution_max_rss",
104 + metrix.WithUnit("bytes"),
105 + ).Observe(execMetrics.maxRSSBytes)
106 + }
107 +
108 + for _, measureSet := range c.state.perfValueSets() {
109 + fields := perfMeasureSetValues(measureSet.value)
110 + if measureSet.counter {
111 + jobMeter.MeasureSetCounter(
112 + measureSet.name,
113 + metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
114 + metrix.WithChartFamily(measureSet.scriptName),
115 + metrix.WithUnit(measureSet.unit),
116 + ).ObserveTotalFields(fields)
117 + } else {
118 + jobMeter.MeasureSetGauge(
119 + measureSet.name,
120 + metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
121 + metrix.WithChartFamily(measureSet.scriptName),
122 + metrix.WithUnit(measureSet.unit),
123 + ).ObserveFields(fields)
124 + }
125 + }
126 +
127 + for _, thresholdState := range c.state.perfThresholdStates() {
128 + inst := jobMeter.StateSet(
129 + thresholdState.name,
130 + metrix.WithStateSetMode(metrix.ModeBitSet),
131 + metrix.WithStateSetStates(perfThresholdStateNames...),
132 + metrix.WithChartFamily(thresholdState.scriptName),
133 + metrix.WithUnit("state"),
134 + )
135 + if thresholdState.state == "" {
136 + inst.ObserveStateSet(perfThresholdStatePoint(""))
137 + } else {
138 + inst.Enable(thresholdState.state)
139 + }
140 + }
141 +}
142 +
143 +func normalizeJobStateForMetric(state string) string {
144 + switch strings.ToUpper(strings.TrimSpace(state)) {
145 + case nagiosStateOK:
146 + return "ok"
147 + case nagiosStateWarning:
148 + return "warning"
149 + case nagiosStateCritical:
150 + return "critical"
151 + case jobStateTimeout:
152 + return "timeout"
153 + case jobStatePaused:
154 + return "paused"
155 + default:
156 + return "unknown"
157 + }
158 +}
src/go/plugin/scripts.d/collector/nagios/collector.go new
+87
@@ -0,0 +1,87 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "context"
7 + _ "embed"
8 + "time"
9 +
10 + "github.com/netdata/netdata/go/plugins/pkg/metrix"
11 + "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
12 + "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
13 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/timeperiod"
14 +)
15 +
16 +//go:embed config_schema.json
17 +var configSchema string
18 +
19 +//go:embed charts.yaml
20 +var nagiosChartTemplateV2 string
21 +
22 +func init() {
23 + collectorapi.Register("nagios", collectorapi.Creator{
24 + JobConfigSchema: configSchema,
25 + Defaults: collectorapi.Defaults{
26 + UpdateEvery: defaultCollectorUpdateEvery,
27 + },
28 + CreateV2: func() collectorapi.CollectorV2 { return New() },
29 + Config: func() any { return &Config{} },
30 + })
31 +}
32 +
33 +// Config is the public v2 config surface.
34 +type Config struct {
35 + UpdateEvery int `yaml:"update_every,omitempty" json:"update_every,omitempty"`
36 + AutoDetectEvery int `yaml:"autodetection_retry,omitempty" json:"autodetection_retry,omitempty"`
37 + JobConfig `yaml:",inline" json:",inline"`
38 + TimePeriods []timeperiod.Config `yaml:"time_periods,omitempty" json:"time_periods,omitempty"`
39 + Notes string `yaml:"notes,omitempty" json:"notes,omitempty"`
40 + DirectorySource string `yaml:"__directory_source__,omitempty" json:"-"`
41 +}
42 +
43 +// Collector is the v2 Nagios collector.
44 +type Collector struct {
45 + collectorapi.Base
46 + Config `yaml:",inline" json:",inline"`
47 +
48 + store metrix.CollectorStore
49 + router *perfdataRouter
50 + runner checkRunner
51 + now func() time.Time
52 + vnode vnodes.VirtualNode
53 +
54 + job compiledJob
55 + state collectState
56 +
57 + cadenceWarning string
58 +}
59 +
60 +func New() *Collector {
61 + return &Collector{
62 + Config: Config{
63 + UpdateEvery: defaultCollectorUpdateEvery,
64 + JobConfig: defaultedJobConfig(JobConfig{}),
65 + },
66 + store: metrix.NewCollectorStore(),
67 + router: newPerfdataRouter(defaultPerfdataMetricKeyBudget),
68 + runner: systemCheckRunner{},
69 + now: time.Now,
70 + }
71 +}
72 +
73 +func (c *Collector) Configuration() any { return c.Config }
74 +
75 +func (c *Collector) VirtualNode() *vnodes.VirtualNode { return &c.vnode }
76 +
77 +func (c *Collector) Init(context.Context) error { return c.initCollector() }
78 +
79 +func (c *Collector) Check(context.Context) error { return c.checkCollector() }
80 +
81 +func (c *Collector) Collect(ctx context.Context) error { return c.collect(ctx) }
82 +
83 +func (c *Collector) Cleanup(context.Context) {}
84 +
85 +func (c *Collector) MetricStore() metrix.CollectorStore { return c.store }
86 +
87 +func (c *Collector) ChartTemplateYAML() string { return nagiosChartTemplateV2 }
src/go/plugin/scripts.d/collector/nagios/collector_test.go new
+964
@@ -0,0 +1,964 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "context"
7 + "encoding/json"
8 + "errors"
9 + "os"
10 + "path/filepath"
11 + "runtime"
12 + "strings"
13 + "testing"
14 + "time"
15 +
16 + "github.com/netdata/netdata/go/plugins/pkg/confopt"
17 + "github.com/netdata/netdata/go/plugins/pkg/metrix"
18 + "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
19 + "github.com/netdata/netdata/go/plugins/plugin/framework/charttpl"
20 + "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
21 + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/collecttest"
22 + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/ndexec"
23 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/collector/nagios/internal/output"
24 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/timeperiod"
25 + "github.com/stretchr/testify/assert"
26 + "github.com/stretchr/testify/require"
27 +)
28 +
29 +func TestCollector_ChartTemplateYAML(t *testing.T) {
30 + templateYAML := New().ChartTemplateYAML()
31 + collecttest.AssertChartTemplateSchema(t, templateYAML)
32 +
33 + specYAML, err := charttpl.DecodeYAML([]byte(templateYAML))
34 + require.NoError(t, err)
35 + require.NoError(t, specYAML.Validate())
36 + _, err = chartengine.Compile(specYAML, 1)
37 + require.NoError(t, err)
38 +
39 + tests := map[string]struct {
40 + context string
41 + selector string
42 + wantFloat bool
43 + }{
44 + "execution duration dimension is float": {
45 + context: "job_execution_duration",
46 + selector: "nagios.job.execution_duration",
47 + wantFloat: true,
48 + },
49 + "execution cpu dimension is float": {
50 + context: "job_execution_cpu",
51 + selector: "nagios.job.execution_cpu_total",
52 + wantFloat: true,
53 + },
54 + "execution memory dimension is integer": {
55 + context: "job_execution_memory",
56 + selector: "nagios.job.execution_max_rss",
57 + wantFloat: false,
58 + },
59 + }
60 +
61 + for name, tc := range tests {
62 + t.Run(name, func(t *testing.T) {
63 + dim, ok := findChartDimensionByContext(specYAML, tc.context)
64 + require.True(t, ok, "missing chart context %q", tc.context)
65 + assert.Equal(t, tc.selector, dim.Selector)
66 + if tc.wantFloat {
67 + require.NotNil(t, dim.Options)
68 + assert.True(t, dim.Options.Float)
69 + return
70 + }
71 + if dim.Options != nil {
72 + assert.False(t, dim.Options.Float)
73 + }
74 + })
75 + }
76 +}
77 +
78 +func TestCollector_ConfigSchema(t *testing.T) {
79 + tests := map[string]struct {
80 + assert func(*testing.T, nagiosConfigSchemaDoc)
81 + }{
82 + "wrapped schema follows collector conventions": {
83 + assert: func(t *testing.T, doc nagiosConfigSchemaDoc) {
84 + t.Helper()
85 + assert.NotEmpty(t, doc.JSONSchema.Schema)
86 + _, hasPlugin := doc.JSONSchema.Properties["plugin"]
87 + _, hasName := doc.JSONSchema.Properties["name"]
88 + _, hasTimeoutState := doc.JSONSchema.Properties["timeout_state"]
89 + _, hasUIOptions := doc.UISchema["uiOptions"]
90 + assert.True(t, hasPlugin)
91 + assert.False(t, hasName)
92 + assert.False(t, hasTimeoutState)
93 + assert.True(t, hasUIOptions)
94 + },
95 + },
96 + }
97 +
98 + for name, tc := range tests {
99 + t.Run(name, func(t *testing.T) {
100 + var doc nagiosConfigSchemaDoc
101 + require.NoError(t, json.Unmarshal([]byte(configSchema), &doc))
102 + tc.assert(t, doc)
103 + })
104 + }
105 +}
106 +
107 +func TestCollector_New(t *testing.T) {
108 + tests := map[string]struct {
109 + assert func(*testing.T, *Collector)
110 + }{
111 + "exposes runtime defaults on the live collector config": {
112 + assert: func(t *testing.T, coll *Collector) {
113 + t.Helper()
114 + assert.Equal(t, defaultCollectorUpdateEvery, coll.Config.UpdateEvery)
115 + assert.Equal(t, confDuration(5*time.Second), coll.Config.JobConfig.Timeout)
116 + assert.Equal(t, confDuration(5*time.Minute), coll.Config.JobConfig.CheckInterval)
117 + assert.Equal(t, confDuration(1*time.Minute), coll.Config.JobConfig.RetryInterval)
118 + assert.Equal(t, 3, coll.Config.JobConfig.MaxCheckAttempts)
119 + assert.Equal(t, "24x7", coll.Config.JobConfig.CheckPeriod)
120 + require.NotNil(t, coll.Config.JobConfig.Environment)
121 + require.NotNil(t, coll.Config.JobConfig.CustomVars)
122 + assert.Empty(t, coll.Config.JobConfig.Environment)
123 + assert.Empty(t, coll.Config.JobConfig.CustomVars)
124 + },
125 + },
126 + }
127 +
128 + for name, tc := range tests {
129 + t.Run(name, func(t *testing.T) {
130 + coll := New()
131 + tc.assert(t, coll)
132 + })
133 + }
134 +}
135 +
136 +type nagiosConfigSchemaDoc struct {
137 + JSONSchema struct {
138 + Schema string `json:"$schema"`
139 + Properties map[string]json.RawMessage `json:"properties"`
140 + } `json:"jsonSchema"`
141 + UISchema map[string]json.RawMessage `json:"uiSchema"`
142 +}
143 +
144 +func TestCollector_Check(t *testing.T) {
145 + tests := map[string]struct {
146 + config Config
147 + wantErr bool
148 + errMatch string
149 + }{
150 + "missing plugin": {
151 + config: Config{JobConfig: JobConfig{Name: "invalid-without-plugin"}},
152 + wantErr: true,
153 + errMatch: "plugin path is required",
154 + },
155 + "update_every exceeds cadence": {
156 + config: Config{
157 + UpdateEvery: 10,
158 + JobConfig: JobConfig{
159 + Name: "cadence",
160 + Plugin: "/bin/true",
161 + CheckInterval: confDuration(5 * time.Second),
162 + RetryInterval: confDuration(5 * time.Second),
163 + },
164 + },
165 + wantErr: false,
166 + },
167 + "valid config": {
168 + config: Config{
169 + UpdateEvery: 1,
170 + JobConfig: JobConfig{
171 + Name: "valid",
172 + Plugin: "/bin/true",
173 + CheckInterval: confDuration(5 * time.Second),
174 + RetryInterval: confDuration(5 * time.Second),
175 + },
176 + },
177 + },
178 + }
179 +
180 + for name, tc := range tests {
181 + t.Run(name, func(t *testing.T) {
182 + coll := New()
183 + coll.runner = &fakeRunner{}
184 + coll.Config = tc.config
185 +
186 + err := coll.Check(context.Background())
187 + if tc.wantErr {
188 + require.Error(t, err)
189 + if tc.errMatch != "" {
190 + assert.Contains(t, err.Error(), tc.errMatch)
191 + }
192 + return
193 + }
194 + require.NoError(t, err)
195 + })
196 + }
197 +}
198 +
199 +func TestCompileCollectorConfig_CadenceWarning(t *testing.T) {
200 + tests := map[string]struct {
201 + config Config
202 + wantErr bool
203 + wantWarning bool
204 + }{
205 + "warning when update_every exceeds retry interval": {
206 + config: Config{
207 + UpdateEvery: 10,
208 + JobConfig: JobConfig{
209 + Name: "cadence-warning",
210 + Plugin: "/bin/true",
211 + CheckInterval: confDuration(10 * time.Second),
212 + RetryInterval: confDuration(2 * time.Second),
213 + },
214 + },
215 + wantWarning: true,
216 + },
217 + "no warning when cadence fits update_every": {
218 + config: Config{
219 + UpdateEvery: 1,
220 + JobConfig: JobConfig{
221 + Name: "cadence-ok",
222 + Plugin: "/bin/true",
223 + CheckInterval: confDuration(5 * time.Second),
224 + RetryInterval: confDuration(5 * time.Second),
225 + },
226 + },
227 + },
228 + "invalid config still fails": {
229 + config: Config{
230 + JobConfig: JobConfig{
231 + Name: "invalid",
232 + },
233 + },
234 + wantErr: true,
235 + },
236 + }
237 +
238 + for name, tc := range tests {
239 + t.Run(name, func(t *testing.T) {
240 + job, err := compileCollectorConfig(tc.config)
241 + if tc.wantErr {
242 + require.Error(t, err)
243 + return
244 + }
245 + require.NoError(t, err)
246 + assert.Equal(t, tc.wantWarning, job.cadenceWarning != "", "warning: %q", job.cadenceWarning)
247 + })
248 + }
249 +}
250 +
251 +func TestCollector_Init(t *testing.T) {
252 + tests := map[string]struct {
253 + config Config
254 + assert func(*testing.T, *Collector)
255 + }{
256 + "default timing comes from spec": {
257 + config: Config{
258 + JobConfig: JobConfig{
259 + Name: "defaults",
260 + Plugin: "/bin/true",
261 + },
262 + },
263 + assert: func(t *testing.T, coll *Collector) {
264 + t.Helper()
265 + assert.Equal(t, confDuration(5*time.Minute), coll.job.config.CheckInterval)
266 + assert.Equal(t, confDuration(1*time.Minute), coll.job.config.RetryInterval)
267 + },
268 + },
269 + }
270 +
271 + for name, tc := range tests {
272 + t.Run(name, func(t *testing.T) {
273 + coll := New()
274 + coll.runner = &fakeRunner{}
275 + coll.Config = tc.config
276 + require.NoError(t, coll.Init(context.Background()))
277 + tc.assert(t, coll)
278 + })
279 + }
280 +}
281 +
282 +func TestCollector_Collect(t *testing.T) {
283 + tests := map[string]struct {
284 + results []fakeRun
285 + config Config
286 + setup func(*Collector, *fakeRunner, *time.Time)
287 + run func(*testing.T, *Collector, *fakeRunner, *time.Time)
288 + }{
289 + "replays cached metrics when not due": {
290 + results: []fakeRun{
291 + {
292 + result: checkRunResult{
293 + ServiceState: "OK",
294 + JobState: "OK",
295 + Duration: 2500 * time.Millisecond,
296 + Usage: ndexec.ResourceUsage{
297 + User: 300 * time.Millisecond,
298 + System: 200 * time.Millisecond,
299 + MaxRSSBytes: 12345,
300 + },
301 + Parsed: output.ParsedOutput{
302 + Perfdata: []output.PerfDatum{
303 + {Label: "used", Unit: "KB", Value: 30},
304 + },
305 + },
306 + },
307 + },
308 + },
309 + config: Config{
310 + UpdateEvery: 1,
311 + JobConfig: JobConfig{
312 + Name: "check_disk",
313 + Plugin: "/bin/true",
314 + CheckInterval: confDuration(5 * time.Minute),
315 + RetryInterval: confDuration(1 * time.Minute),
316 + },
317 + },
318 + run: func(t *testing.T, coll *Collector, runner *fakeRunner, now *time.Time) {
319 + t.Helper()
320 + runCollectCycle(t, coll)
321 + assert.Equal(t, 1, runner.calls)
322 +
323 + read := coll.MetricStore().Read(metrix.ReadRaw())
324 + flat := coll.MetricStore().Read(metrix.ReadFlatten())
325 + assertMetricValue(t, flat, "nagios.job.state", metrix.Labels{"nagios_job": "check_disk", "nagios.job.state": "ok"}, 1)
326 + assertMetricValue(t, flat, "nagios.job.execution_duration", metrix.Labels{"nagios_job": "check_disk"}, 2.5)
327 + assertMetricMeta(t, flat, "nagios.job.execution_duration", "seconds", true)
328 + if runtime.GOOS != "windows" {
329 + assertMetricValue(t, flat, "nagios.job.execution_cpu_total", metrix.Labels{"nagios_job": "check_disk"}, 0.5)
330 + assertMetricValue(t, flat, "nagios.job.execution_max_rss", metrix.Labels{"nagios_job": "check_disk"}, 12345)
331 + assertMetricMeta(t, flat, "nagios.job.execution_cpu_total", "seconds", true)
332 + assertMetricMeta(t, flat, "nagios.job.execution_max_rss", "bytes", false)
333 + } else {
334 + assertMetricMissing(t, flat, "nagios.job.execution_cpu_total", metrix.Labels{"nagios_job": "check_disk"})
335 + assertMetricMissing(t, flat, "nagios.job.execution_max_rss", metrix.Labels{"nagios_job": "check_disk"})
336 + }
337 + assertMetricValue(t, flat, "nagios.true.bytes_used_value", metrix.Labels{"nagios_job": "check_disk", metrix.MeasureSetFieldLabel: "value"}, 30000)
338 + point, ok := read.MeasureSet("nagios.true.bytes_used", metrix.Labels{"nagios_job": "check_disk"})
339 + require.True(t, ok)
340 + assert.Equal(t, 30000.0, point.Values[0])
341 +
342 + *now = now.Add(1 * time.Second)
343 + runCollectCycle(t, coll)
344 + assert.Equal(t, 1, runner.calls)
345 +
346 + flat = coll.MetricStore().Read(metrix.ReadFlatten())
347 + assertMetricValue(t, flat, "nagios.job.execution_duration", metrix.Labels{"nagios_job": "check_disk"}, 0)
348 + if runtime.GOOS != "windows" {
349 + assertMetricValue(t, flat, "nagios.job.execution_cpu_total", metrix.Labels{"nagios_job": "check_disk"}, 0)
350 + assertMetricValue(t, flat, "nagios.job.execution_max_rss", metrix.Labels{"nagios_job": "check_disk"}, 0)
351 + } else {
352 + assertMetricMissing(t, flat, "nagios.job.execution_cpu_total", metrix.Labels{"nagios_job": "check_disk"})
353 + assertMetricMissing(t, flat, "nagios.job.execution_max_rss", metrix.Labels{"nagios_job": "check_disk"})
354 + }
355 + assertMetricValue(t, flat, "nagios.true.bytes_used_value", metrix.Labels{"nagios_job": "check_disk", metrix.MeasureSetFieldLabel: "value"}, 30000)
356 + },
357 + },
358 + "check period blocked cycles pause job state and zero threshold states": {
359 + results: []fakeRun{
360 + {
361 + result: checkRunResult{
362 + ServiceState: "OK",
363 + JobState: "OK",
364 + Parsed: output.ParsedOutput{
365 + Perfdata: []output.PerfDatum{
366 + func() output.PerfDatum {
367 + low := 0.0
368 + high := 20.0
369 + return output.PerfDatum{
370 + Label: "used",
371 + Unit: "KB",
372 + Value: 30,
373 + Warn: &output.ThresholdRange{Low: &low, High: &high},
374 + }
375 + }(),
376 + },
377 + },
378 + },
379 + },
380 + {
381 + result: checkRunResult{
382 + ServiceState: "OK",
383 + JobState: "OK",
384 + Parsed: output.ParsedOutput{
385 + Perfdata: []output.PerfDatum{
386 + func() output.PerfDatum {
387 + low := 0.0
388 + high := 20.0
389 + return output.PerfDatum{
390 + Label: "used",
391 + Unit: "KB",
392 + Value: 10,
393 + Warn: &output.ThresholdRange{Low: &low, High: &high},
394 + }
395 + }(),
396 + },
397 + },
398 + },
399 + },
400 + },
401 + config: Config{
402 + UpdateEvery: 1,
403 + JobConfig: JobConfig{
404 + Name: "period_job",
405 + Plugin: "/bin/true",
406 + CheckInterval: confDuration(1 * time.Hour),
407 + RetryInterval: confDuration(1 * time.Minute),
408 + CheckPeriod: "business",
409 + },
410 + TimePeriods: []timeperiod.Config{
411 + {
412 + Name: "business",
413 + Rules: []timeperiod.RuleConfig{
414 + {
415 + Type: "weekly",
416 + Days: []string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"},
417 + Ranges: []string{"09:00-18:00"},
418 + },
419 + },
420 + },
421 + },
422 + },
423 + run: func(t *testing.T, coll *Collector, runner *fakeRunner, now *time.Time) {
424 + t.Helper()
425 + runCollectCycle(t, coll)
426 + assert.Equal(t, 1, runner.calls)
427 +
428 + flat := coll.MetricStore().Read(metrix.ReadFlatten())
429 + assertMetricValue(t, flat, "nagios.job.state", metrix.Labels{"nagios_job": "period_job", "nagios.job.state": "ok"}, 1)
430 + assertMetricValue(t, flat, "nagios.true.bytes_used_value", metrix.Labels{"nagios_job": "period_job", metrix.MeasureSetFieldLabel: "value"}, 30000)
431 +
432 + raw := coll.MetricStore().Read()
433 + thresholdMetric := "nagios.true.bytes_used_threshold_state"
434 + thresholdLabels := metrix.Labels{"nagios_job": "period_job"}
435 + point, ok := raw.StateSet(thresholdMetric, thresholdLabels)
436 + require.True(t, ok)
437 + assert.True(t, point.States[perfThresholdStateWarning])
438 +
439 + *now = time.Date(2026, 3, 23, 20, 0, 0, 0, time.UTC)
440 + runCollectCycle(t, coll)
441 + assert.Equal(t, 1, runner.calls)
442 +
443 + flat = coll.MetricStore().Read(metrix.ReadFlatten())
444 + assertMetricValue(t, flat, "nagios.job.state", metrix.Labels{"nagios_job": "period_job", "nagios.job.state": "paused"}, 1)
445 + assertMetricValue(t, flat, "nagios.true.bytes_used_value", metrix.Labels{"nagios_job": "period_job", metrix.MeasureSetFieldLabel: "value"}, 30000)
446 + assertMetricValue(t, flat, thresholdMetric, metrix.Labels{"nagios_job": "period_job", thresholdMetric: perfThresholdStateWarning}, 0)
447 + assertMetricValue(t, flat, thresholdMetric, metrix.Labels{"nagios_job": "period_job", thresholdMetric: perfThresholdStateOK}, 0)
448 + assertMetricValue(t, flat, thresholdMetric, metrix.Labels{"nagios_job": "period_job", thresholdMetric: perfThresholdStateCritical}, 0)
449 + assertMetricValue(t, flat, thresholdMetric, metrix.Labels{"nagios_job": "period_job", thresholdMetric: perfThresholdStateNone}, 0)
450 +
451 + raw = coll.MetricStore().Read()
452 + point, ok = raw.StateSet(thresholdMetric, thresholdLabels)
453 + require.True(t, ok)
454 + assert.False(t, point.States[perfThresholdStateNone])
455 + assert.False(t, point.States[perfThresholdStateOK])
456 + assert.False(t, point.States[perfThresholdStateWarning])
457 + assert.False(t, point.States[perfThresholdStateCritical])
458 +
459 + *now = time.Date(2026, 3, 24, 9, 0, 0, 0, time.UTC)
460 + runCollectCycle(t, coll)
461 + assert.Equal(t, 2, runner.calls)
462 +
463 + flat = coll.MetricStore().Read(metrix.ReadFlatten())
464 + assertMetricValue(t, flat, "nagios.job.state", metrix.Labels{"nagios_job": "period_job", "nagios.job.state": "ok"}, 1)
465 + assertMetricValue(t, flat, "nagios.true.bytes_used_value", metrix.Labels{"nagios_job": "period_job", metrix.MeasureSetFieldLabel: "value"}, 10000)
466 +
467 + raw = coll.MetricStore().Read()
468 + point, ok = raw.StateSet(thresholdMetric, thresholdLabels)
469 + require.True(t, ok)
470 + assert.False(t, point.States[perfThresholdStateNone])
471 + assert.True(t, point.States[perfThresholdStateOK])
472 + assert.False(t, point.States[perfThresholdStateWarning])
473 + assert.False(t, point.States[perfThresholdStateCritical])
474 + },
475 + },
476 + "uses retry interval for retry state": {
477 + results: []fakeRun{
478 + {
479 + result: checkRunResult{ServiceState: "WARNING", JobState: "WARNING", ExitCode: 1},
480 + err: errors.New("plugin returned warning"),
481 + },
482 + {
483 + result: checkRunResult{ServiceState: "OK", JobState: "OK"},
484 + },
485 + },
486 + config: Config{
487 + UpdateEvery: 1,
488 + JobConfig: JobConfig{
489 + Name: "retry_job",
490 + Plugin: "/bin/true",
491 + CheckInterval: confDuration(5 * time.Minute),
492 + RetryInterval: confDuration(10 * time.Second),
493 + MaxCheckAttempts: 3,
494 + },
495 + },
496 + run: func(t *testing.T, coll *Collector, runner *fakeRunner, now *time.Time) {
497 + t.Helper()
498 + runCollectCycle(t, coll)
499 + assert.Equal(t, 1, runner.calls)
500 + assert.Equal(t, 2, coll.state.currentAttempt())
501 +
502 + *now = now.Add(9 * time.Second)
503 + runCollectCycle(t, coll)
504 + assert.Equal(t, 1, runner.calls)
505 +
506 + *now = now.Add(2 * time.Second)
507 + runCollectCycle(t, coll)
508 + assert.Equal(t, 2, runner.calls)
509 + assert.Equal(t, 1, coll.state.currentAttempt())
510 + },
511 + },
512 + "timeout is exposed publicly but macros keep Nagios unknown": {
513 + results: []fakeRun{
514 + {
515 + result: checkRunResult{ServiceState: nagiosStateUnknown, JobState: jobStateTimeout, ExitCode: -1},
516 + err: errNagiosCheckTimeout,
517 + },
518 + {
519 + result: checkRunResult{ServiceState: "OK", JobState: "OK"},
520 + },
521 + },
522 + config: Config{
523 + UpdateEvery: 1,
524 + JobConfig: JobConfig{
525 + Name: "timeout_job",
526 + Plugin: "/bin/true",
527 + CheckInterval: confDuration(5 * time.Minute),
528 + RetryInterval: confDuration(10 * time.Second),
529 + MaxCheckAttempts: 3,
530 + },
531 + },
532 + run: func(t *testing.T, coll *Collector, runner *fakeRunner, now *time.Time) {
533 + t.Helper()
534 + runCollectCycle(t, coll)
535 + assert.Equal(t, 1, runner.calls)
536 + assert.Equal(t, nagiosStateUnknown, coll.state.currentServiceState())
537 + assert.Equal(t, jobStateTimeout, coll.state.currentJobState())
538 +
539 + flat := coll.MetricStore().Read(metrix.ReadFlatten())
540 + assertMetricValue(t, flat, "nagios.job.state", metrix.Labels{"nagios_job": "timeout_job", "nagios.job.state": "timeout"}, 1)
541 +
542 + *now = now.Add(11 * time.Second)
543 + runCollectCycle(t, coll)
544 + require.Len(t, runner.reqs, 2)
545 + assert.Equal(t, nagiosStateUnknown, runner.reqs[1].MacroState.ServiceState)
546 + assert.Equal(t, 2, runner.reqs[1].MacroState.ServiceAttempt)
547 + },
548 + },
549 + "infrastructure failures return error and keep state unchanged": {
550 + results: []fakeRun{
551 + {
552 + result: checkRunResult{ServiceState: "UNKNOWN", JobState: "UNKNOWN", ExitCode: -1},
553 + err: errors.New("spawn failed"),
554 + },
555 + },
556 + config: Config{
557 + UpdateEvery: 1,
558 + JobConfig: JobConfig{
559 + Name: "infra_fail",
560 + Plugin: "/bin/true",
561 + },
562 + },
563 + run: func(t *testing.T, coll *Collector, runner *fakeRunner, _ *time.Time) {
564 + t.Helper()
565 + cc := mustCycleController(t, coll.MetricStore())
566 + cc.BeginCycle()
567 + err := coll.Collect(context.Background())
568 + cc.AbortCycle()
569 + require.Error(t, err)
570 + assert.Equal(t, 1, runner.calls)
571 + assert.Equal(t, nagiosStateUnknown, coll.state.currentServiceState())
572 + assert.Equal(t, nagiosStateUnknown, coll.state.currentJobState())
573 + },
574 + },
575 + "passes virtual node to runner": {
576 + results: []fakeRun{
577 + {result: checkRunResult{ServiceState: "OK", JobState: "OK"}},
578 + },
579 + config: Config{
580 + UpdateEvery: 1,
581 + JobConfig: JobConfig{
582 + Name: "with_vnode",
583 + Plugin: "/bin/true",
584 + },
585 + },
586 + setup: func(coll *Collector, _ *fakeRunner, _ *time.Time) {
587 + coll.vnode = vnodes.VirtualNode{
588 + Hostname: "node-a",
589 + Labels: map[string]string{
590 + "_address": "203.0.113.10",
591 + "_alias": "node-a-alias",
592 + "_DC": "east",
593 + "region": "lab",
594 + },
595 + }
596 + },
597 + run: func(t *testing.T, coll *Collector, runner *fakeRunner, _ *time.Time) {
598 + t.Helper()
599 + runCollectCycle(t, coll)
600 + require.Len(t, runner.reqs, 1)
601 + req := runner.reqs[0]
602 + assert.Equal(t, "node-a", req.Vnode.Hostname)
603 + assert.Equal(t, "203.0.113.10", req.Vnode.Labels["_address"])
604 + assert.Equal(t, "lab", req.Vnode.Labels["region"])
605 + },
606 + },
607 + }
608 +
609 + for name, tc := range tests {
610 + t.Run(name, func(t *testing.T) {
611 + now := time.Date(2026, 3, 21, 12, 0, 0, 0, time.UTC)
612 + runner := &fakeRunner{results: tc.results}
613 + coll := New()
614 + coll.runner = runner
615 + coll.now = func() time.Time { return now }
616 + coll.Config = tc.config
617 + if tc.setup != nil {
618 + tc.setup(coll, runner, &now)
619 + }
620 + require.NoError(t, coll.Init(context.Background()))
621 + tc.run(t, coll, runner, &now)
622 + })
623 + }
624 +}
625 +
626 +func TestBuildMacroSet(t *testing.T) {
627 + now := time.Date(2026, 3, 21, 12, 0, 0, 0, time.UTC)
628 + tests := map[string]struct {
629 + job JobConfig
630 + vnode vnodeInfo
631 + state macroState
632 + assert func(*testing.T, macroSet)
633 + }{
634 + "includes vnode and service macros": {
635 + job: JobConfig{
636 + Name: "http_check",
637 + Plugin: "/usr/lib/nagios/plugins/check_http",
638 + Args: []string{"-H", "$HOSTADDRESS$", "-p", "$ARG1$", "-w", "$ARG2$"},
639 + ArgValues: []string{"8080", "5"},
640 + CustomVars: map[string]string{
641 + "ENDPOINT": "/health",
642 + },
643 + Vnode: "fallback-host",
644 + },
645 + vnode: vnodeInfo{
646 + Hostname: "web1",
647 + Labels: map[string]string{
648 + "_address": "192.0.2.10",
649 + "_alias": "web-node",
650 + "_DATACENTER": "us-east-1",
651 + "role": "frontend",
652 + },
653 + },
654 + state: macroState{
655 + ServiceState: "OK",
656 + ServiceAttempt: 2,
657 + ServiceMaxAttempts: 5,
658 + },
659 + assert: func(t *testing.T, s macroSet) {
660 + t.Helper()
661 + assert.Equal(t, "192.0.2.10", s.Env["NAGIOS_HOSTADDRESS"])
662 + assert.Equal(t, "web-node", s.Env["NAGIOS_HOSTALIAS"])
663 + assert.Equal(t, "/health", s.Env["NAGIOS__SERVICEENDPOINT"])
664 + assert.Equal(t, "us-east-1", s.Env["NAGIOS__HOSTDATACENTER"])
665 + assert.Equal(t, "frontend", s.Env["NAGIOS__HOSTLABEL_ROLE"])
666 + assert.Equal(t, "8080", s.Env["NAGIOS_ARG1"])
667 + assert.Equal(t, "2", s.Env["NAGIOS_SERVICEATTEMPT"])
668 + assert.Equal(t, nagiosHostStateUp, s.Env["NAGIOS_HOSTSTATE"])
669 + assert.Equal(t, nagiosHostStateUpID, s.Env["NAGIOS_HOSTSTATEID"])
670 + assert.Equal(t, "192.0.2.10", s.CommandArgs[1])
671 + assert.Equal(t, "8080", s.CommandArgs[3])
672 + },
673 + },
674 + "falls back to job vnode when runtime vnode is empty": {
675 + job: JobConfig{
676 + Name: "fallback",
677 + Plugin: "/bin/true",
678 + Args: []string{"$HOSTNAME$"},
679 + Vnode: "fallback-host",
680 + },
681 + vnode: vnodeInfo{
682 + Labels: map[string]string{},
683 + },
684 + state: macroState{ServiceState: "OK"},
685 + assert: func(t *testing.T, s macroSet) {
686 + t.Helper()
687 + assert.Equal(t, "fallback-host", s.Env["NAGIOS_HOSTNAME"])
688 + assert.Equal(t, "fallback-host", s.CommandArgs[0])
689 + },
690 + },
691 + }
692 +
693 + for name, tc := range tests {
694 + t.Run(name, func(t *testing.T) {
695 + got := buildMacroSet(tc.job, tc.vnode, tc.state, now)
696 + tc.assert(t, got)
697 + })
698 + }
699 +}
700 +
701 +func TestReplaceMacro(t *testing.T) {
702 + tests := map[string]struct {
703 + value string
704 + env map[string]string
705 + want string
706 + }{
707 + "expands nested macros deterministically": {
708 + value: "$ARG1$",
709 + env: map[string]string{
710 + "NAGIOS_ARG1": "$HOSTADDRESS$:$ARG2$",
711 + "NAGIOS_ARG2": "8080",
712 + "NAGIOS_HOSTADDRESS": "192.0.2.10",
713 + },
714 + want: "192.0.2.10:8080",
715 + },
716 + "keeps unknown macros unchanged": {
717 + value: "$UNKNOWN$:$ARG1$",
718 + env: map[string]string{
719 + "NAGIOS_ARG1": "value",
720 + },
721 + want: "$UNKNOWN$:value",
722 + },
723 + "stops recursive cycles deterministically": {
724 + value: "$ARG1$",
725 + env: map[string]string{
726 + "NAGIOS_ARG1": "$ARG2$",
727 + "NAGIOS_ARG2": "$ARG1$",
728 + },
729 + want: "$ARG1$",
730 + },
731 + }
732 +
733 + for name, tc := range tests {
734 + t.Run(name, func(t *testing.T) {
735 + assert.Equal(t, tc.want, replaceMacro(tc.value, tc.env))
736 + })
737 + }
738 +}
739 +
740 +func TestBuildRunEnv(t *testing.T) {
741 + t.Setenv("NAGIOS_TEST_LEAK", "secret")
742 + t.Setenv("PATH", "/usr/local/bin:/usr/bin")
743 + t.Setenv("TZ", "UTC")
744 +
745 + tests := map[string]struct {
746 + workingDir string
747 + jobEnv map[string]string
748 + macroEnv map[string]string
749 + assert func(*testing.T, map[string]string)
750 + }{
751 + "uses explicit baseline and does not leak ambient env": {
752 + jobEnv: map[string]string{},
753 + macroEnv: map[string]string{},
754 + assert: func(t *testing.T, env map[string]string) {
755 + t.Helper()
756 + assert.NotContains(t, env, "NAGIOS_TEST_LEAK")
757 + assert.Equal(t, "UTC", env["TZ"])
758 + assert.Equal(t, "/usr/local/bin:/usr/bin", env["PATH"])
759 + if runtime.GOOS != "windows" {
760 + assert.Equal(t, "C", env["LC_ALL"])
761 + assert.Equal(t, "/bin/sh", env["SHELL"])
762 + }
763 + },
764 + },
765 + "uses actual current directory instead of inherited parent PWD": {
766 + jobEnv: map[string]string{},
767 + macroEnv: map[string]string{},
768 + assert: func(t *testing.T, env map[string]string) {
769 + t.Helper()
770 + if runtime.GOOS == "windows" {
771 + return
772 + }
773 + cwd, err := os.Getwd()
774 + require.NoError(t, err)
775 + assert.Equal(t, cwd, env["PWD"])
776 + assert.NotEqual(t, "/parent/pwd", env["PWD"])
777 + },
778 + },
779 + "working directory overrides PWD": {
780 + workingDir: "/tmp/checks",
781 + jobEnv: map[string]string{},
782 + macroEnv: map[string]string{},
783 + assert: func(t *testing.T, env map[string]string) {
784 + t.Helper()
785 + if runtime.GOOS == "windows" {
786 + return
787 + }
788 + assert.Equal(t, "/tmp/checks", env["PWD"])
789 + },
790 + },
791 + "job environment overrides baseline and macros override job environment": {
792 + jobEnv: map[string]string{
793 + "PATH": "/custom/bin",
794 + "NAGIOS_ARG1": "user-value",
795 + "TARGET": "$ARG1$",
796 + },
797 + macroEnv: map[string]string{
798 + "NAGIOS_ARG1": "macro-value",
799 + },
800 + assert: func(t *testing.T, env map[string]string) {
801 + t.Helper()
802 + assert.Equal(t, "/custom/bin", env["PATH"])
803 + assert.Equal(t, "macro-value", env["NAGIOS_ARG1"])
804 + assert.Equal(t, "macro-value", env["TARGET"])
805 + },
806 + },
807 + }
808 +
809 + for name, tc := range tests {
810 + t.Run(name, func(t *testing.T) {
811 + if runtime.GOOS != "windows" {
812 + t.Setenv("PWD", "/parent/pwd")
813 + }
814 + env := envSliceToMap(buildRunEnv(tc.workingDir, tc.jobEnv, tc.macroEnv))
815 + tc.assert(t, env)
816 + })
817 + }
818 +}
819 +
820 +func TestSystemCheckRunner_EnvironmentContract(t *testing.T) {
821 + if runtime.GOOS == "windows" {
822 + t.Skip("uses sh scripts")
823 + }
824 +
825 + t.Setenv("NAGIOS_TEST_LEAK", "secret")
826 + t.Setenv("PATH", "/usr/local/bin:/usr/bin")
827 + t.Setenv("TZ", "UTC")
828 +
829 + dir := t.TempDir()
830 + scriptPath := filepath.Join(dir, "check_env.sh")
831 + writeExecutable(t, scriptPath, `#!/bin/sh
832 +set -eu
833 +printf '%s\n' 'OK - env contract | value=1;;;;'
834 +printf 'EXPLICIT=%s\n' "${EXPLICIT:-}"
835 +printf 'EXPANDED=%s\n' "${EXPANDED:-}"
836 +printf 'HOSTADDRESS=%s\n' "${NAGIOS_HOSTADDRESS:-}"
837 +printf 'ARG1=%s\n' "${NAGIOS_ARG1:-}"
838 +printf 'LEAK=%s\n' "${NAGIOS_TEST_LEAK:-}"
839 +printf 'LC_ALL=%s\n' "${LC_ALL:-}"
840 +`)
841 +
842 + job := JobConfig{
843 + Name: "env_contract",
844 + Plugin: scriptPath,
845 + ArgValues: []string{"8080"},
846 + Environment: map[string]string{"EXPLICIT": "from-job", "EXPANDED": "$ARG1$"},
847 + Timeout: confDuration(5 * time.Second),
848 + CheckInterval: confDuration(5 * time.Minute),
849 + RetryInterval: confDuration(1 * time.Minute),
850 + }
851 +
852 + result, err := systemCheckRunner{}.Run(context.Background(), checkRunRequest{
853 + Job: job,
854 + Vnode: vnodeInfo{
855 + Hostname: "node-a",
856 + Labels: map[string]string{
857 + "_address": "192.0.2.10",
858 + },
859 + },
860 + MacroState: macroState{ServiceState: nagiosStateOK},
861 + Now: time.Date(2026, 3, 22, 12, 0, 0, 0, time.UTC),
862 + })
863 + require.NoError(t, err)
864 + assert.Equal(t, nagiosStateOK, result.ServiceState)
865 + assert.Equal(t, nagiosStateOK, result.JobState)
866 + assert.Equal(t, "OK - env contract", result.Parsed.StatusLine())
867 + assert.Contains(t, result.Parsed.LongOutput(), "EXPLICIT=from-job")
868 + assert.Contains(t, result.Parsed.LongOutput(), "EXPANDED=8080")
869 + assert.Contains(t, result.Parsed.LongOutput(), "HOSTADDRESS=192.0.2.10")
870 + assert.Contains(t, result.Parsed.LongOutput(), "ARG1=8080")
871 + assert.Contains(t, result.Parsed.LongOutput(), "LEAK=")
872 + assert.NotContains(t, result.Parsed.LongOutput(), "LEAK=secret")
873 + assert.Contains(t, result.Parsed.LongOutput(), "LC_ALL=C")
874 +}
875 +
876 +func envSliceToMap(env []string) map[string]string {
877 + out := make(map[string]string, len(env))
878 + for _, kv := range env {
879 + key, value, ok := strings.Cut(kv, "=")
880 + if ok {
881 + out[key] = value
882 + }
883 + }
884 + return out
885 +}
886 +
887 +type fakeRun struct {
888 + result checkRunResult
889 + err error
890 +}
891 +
892 +type fakeRunner struct {
893 + results []fakeRun
894 + reqs []checkRunRequest
895 + calls int
896 +}
897 +
898 +func (f *fakeRunner) Run(_ context.Context, req checkRunRequest) (checkRunResult, error) {
899 + f.reqs = append(f.reqs, req)
900 + if f.calls >= len(f.results) {
901 + f.calls++
902 + return checkRunResult{}, nil
903 + }
904 + run := f.results[f.calls]
905 + f.calls++
906 + return run.result, run.err
907 +}
908 +
909 +func runCollectCycle(t *testing.T, coll *Collector) {
910 + t.Helper()
911 + cc := mustCycleController(t, coll.MetricStore())
912 + cc.BeginCycle()
913 + if err := coll.Collect(context.Background()); err != nil {
914 + cc.AbortCycle()
915 + require.NoError(t, err)
916 + }
917 + cc.CommitCycleSuccess()
918 +}
919 +
920 +func mustCycleController(t *testing.T, store metrix.CollectorStore) metrix.CycleController {
921 + t.Helper()
922 + managed, ok := metrix.AsCycleManagedStore(store)
923 + require.True(t, ok)
924 + return managed.CycleController()
925 +}
926 +
927 +func assertMetricValue(t *testing.T, r metrix.Reader, name string, labels metrix.Labels, want float64) {
928 + t.Helper()
929 + got, ok := r.Value(name, labels)
930 + require.True(t, ok, "missing metric %s labels=%v", name, labels)
931 + assert.InDelta(t, want, got, 1e-9, "metric mismatch %s labels=%v", name, labels)
932 +}
933 +
934 +func assertMetricMissing(t *testing.T, r metrix.Reader, name string, labels metrix.Labels) {
935 + t.Helper()
936 + _, ok := r.Value(name, labels)
937 + assert.False(t, ok, "unexpected metric %s labels=%v", name, labels)
938 +}
939 +
940 +func confDuration(d time.Duration) confopt.Duration { return confopt.Duration(d) }
941 +
942 +func findChartDimensionByContext(specYAML *charttpl.Spec, context string) (charttpl.Dimension, bool) {
943 + for _, group := range specYAML.Groups {
944 + if dim, ok := findChartDimensionInGroup(group, context); ok {
945 + return dim, true
946 + }
947 + }
948 + return charttpl.Dimension{}, false
949 +}
950 +
951 +func findChartDimensionInGroup(group charttpl.Group, context string) (charttpl.Dimension, bool) {
952 + for _, chart := range group.Charts {
953 + if chart.Context != context || len(chart.Dimensions) == 0 {
954 + continue
955 + }
956 + return chart.Dimensions[0], true
957 + }
958 + for _, child := range group.Groups {
959 + if dim, ok := findChartDimensionInGroup(child, context); ok {
960 + return dim, true
961 + }
962 + }
963 + return charttpl.Dimension{}, false
964 +}
src/go/plugin/scripts.d/collector/nagios/compiled_job.go new
+78
@@ -0,0 +1,78 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "fmt"
7 + "strings"
8 + "time"
9 +
10 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/timeperiod"
11 +)
12 +
13 +const defaultCollectorUpdateEvery = 10
14 +
15 +type compiledJob struct {
16 + config JobConfig
17 + period *timeperiod.Period
18 + cadenceWarning string
19 +}
20 +
21 +func (j compiledJob) configured() bool {
22 + return j.config.Plugin != ""
23 +}
24 +
25 +func compileCollectorConfig(cfg Config) (compiledJob, error) {
26 + job, err := cfg.JobConfig.normalized()
27 + if err != nil {
28 + return compiledJob{}, err
29 + }
30 +
31 + periodCfgs := timeperiod.EnsureDefault(append([]timeperiod.Config(nil), cfg.TimePeriods...))
32 + periodSet, err := timeperiod.Compile(periodCfgs)
33 + if err != nil {
34 + return compiledJob{}, err
35 + }
36 +
37 + period, err := periodSet.Resolve(job.CheckPeriod)
38 + if err != nil {
39 + return compiledJob{}, err
40 + }
41 +
42 + updateEvery := resolveUpdateEvery(cfg.UpdateEvery)
43 +
44 + return compiledJob{
45 + config: job,
46 + period: period,
47 + cadenceWarning: cadenceResolutionWarning(job.Name, updateEvery, job.CheckInterval.Duration(), job.RetryInterval.Duration()),
48 + }, nil
49 +}
50 +
51 +func resolveUpdateEvery(seconds int) time.Duration {
52 + if seconds <= 0 {
53 + seconds = defaultCollectorUpdateEvery
54 + }
55 + return time.Duration(seconds) * time.Second
56 +}
57 +
58 +func cadenceResolutionWarning(jobName string, updateEvery, checkInterval, retryInterval time.Duration) string {
59 + if updateEvery <= 0 {
60 + return ""
61 + }
62 + var requested []string
63 + if checkInterval > 0 && updateEvery > checkInterval {
64 + requested = append(requested, fmt.Sprintf("check_interval=%s", checkInterval))
65 + }
66 + if retryInterval > 0 && updateEvery > retryInterval {
67 + requested = append(requested, fmt.Sprintf("retry_interval=%s", retryInterval))
68 + }
69 + if len(requested) == 0 {
70 + return ""
71 + }
72 + return fmt.Sprintf(
73 + "job '%s': update_every (%s) is slower than requested cadence (%s); checks and retries execute on collector ticks, so the effective cadence is limited by update_every",
74 + jobName,
75 + updateEvery,
76 + strings.Join(requested, ", "),
77 + )
78 +}
src/go/plugin/scripts.d/collector/nagios/config_schema.json new
+319
@@ -0,0 +1,319 @@
1 +{
2 + "jsonSchema": {
3 + "$schema": "http://json-schema.org/draft-07/schema#",
4 + "title": "Nagios collector configuration.",
5 + "type": "object",
6 + "properties": {
7 + "update_every": {
8 + "title": "Update every",
9 + "description": "Data collection interval, measured in seconds. This is the effective execution resolution for checks and retries.",
10 + "type": "integer",
11 + "minimum": 1,
12 + "default": 10
13 + },
14 + "autodetection_retry": {
15 + "title": "Detection retry",
16 + "description": "How often Netdata retries failed automatic detection jobs, in seconds. Set to 0 to disable retries.",
17 + "type": "integer",
18 + "minimum": 0,
19 + "default": 0
20 + },
21 + "plugin": {
22 + "title": "Plugin path",
23 + "description": "Absolute path to the Nagios-compatible check executable to run. If you need a script interpreter, point this to the interpreter executable and pass the script path in `args`. The command should return exit code 0, 1, 2, or 3 and may print performance data after `|`.",
24 + "type": "string"
25 + },
26 + "args": {
27 + "title": "Arguments",
28 + "description": "Arguments passed to the check command after macro expansion.",
29 + "type": [
30 + "array",
31 + "null"
32 + ],
33 + "items": {
34 + "title": "Argument",
35 + "type": "string"
36 + }
37 + },
38 + "arg_values": {
39 + "title": "Argument macros",
40 + "description": "Values exposed as `NAGIOS_ARG1` .. `NAGIOS_ARG32` for macro substitution and environment export.",
41 + "type": [
42 + "array",
43 + "null"
44 + ],
45 + "items": {
46 + "title": "Argument macro value",
47 + "type": "string"
48 + },
49 + "maxItems": 32
50 + },
51 + "environment": {
52 + "title": "Environment",
53 + "description": "Extra environment variables added on top of the collector's limited execution baseline. The check does not inherit the full Netdata process environment.",
54 + "type": "object",
55 + "additionalProperties": {
56 + "type": "string"
57 + }
58 + },
59 + "timeout": {
60 + "title": "Timeout",
61 + "description": "Maximum time allowed for one check execution, in seconds. If the check exceeds this limit, the job state becomes `timeout`.",
62 + "type": "number",
63 + "minimum": 0.001,
64 + "default": 5
65 + },
66 + "check_interval": {
67 + "title": "Check interval",
68 + "description": "Requested interval between regular checks, in seconds.",
69 + "type": "number",
70 + "minimum": 0.001,
71 + "default": 300
72 + },
73 + "retry_interval": {
74 + "title": "Retry interval",
75 + "description": "Requested interval between retry attempts while the check is in a soft non-OK state, in seconds.",
76 + "type": "number",
77 + "minimum": 0.001,
78 + "default": 60
79 + },
80 + "max_check_attempts": {
81 + "title": "Max check attempts",
82 + "description": "Maximum number of attempts before a non-OK result is treated as a hard state.",
83 + "type": "integer",
84 + "minimum": 1,
85 + "default": 3
86 + },
87 + "check_period": {
88 + "title": "Check period",
89 + "description": "Named time period that controls when checks are allowed to run. Use `24x7` for always-on execution. Outside the allowed period, the check does not execute and the public job state becomes `paused`.",
90 + "type": "string",
91 + "default": "24x7"
92 + },
93 + "time_periods": {
94 + "title": "Time periods",
95 + "description": "Custom named schedules local to this job. `check_period` can reference any name defined here.",
96 + "type": [
97 + "array",
98 + "null"
99 + ],
100 + "items": {
101 + "title": "Time period",
102 + "type": "object",
103 + "properties": {
104 + "name": {
105 + "title": "Name",
106 + "description": "Unique time period name referenced from `check_period`.",
107 + "type": "string"
108 + },
109 + "alias": {
110 + "title": "Alias",
111 + "description": "Human-readable label for this time period.",
112 + "type": "string"
113 + },
114 + "exclude": {
115 + "title": "Exclude",
116 + "description": "Names of other periods to subtract from this one.",
117 + "type": [
118 + "array",
119 + "null"
120 + ],
121 + "items": {
122 + "title": "Excluded period",
123 + "type": "string"
124 + }
125 + },
126 + "rules": {
127 + "title": "Rules",
128 + "description": "Allow rules for this time period.",
129 + "type": "array",
130 + "items": {
131 + "title": "Rule",
132 + "type": "object",
133 + "properties": {
134 + "type": {
135 + "title": "Type",
136 + "description": "Rule type.",
137 + "type": "string",
138 + "enum": [
139 + "weekly",
140 + "nth_weekday",
141 + "date"
142 + ],
143 + "default": "weekly"
144 + },
145 + "days": {
146 + "title": "Days",
147 + "description": "Weekdays used by `weekly` rules. If omitted, all days are allowed.",
148 + "type": [
149 + "array",
150 + "null"
151 + ],
152 + "items": {
153 + "type": "string",
154 + "enum": [
155 + "sunday",
156 + "monday",
157 + "tuesday",
158 + "wednesday",
159 + "thursday",
160 + "friday",
161 + "saturday"
162 + ]
163 + }
164 + },
165 + "ranges": {
166 + "title": "Ranges",
167 + "description": "Allowed time ranges in `HH:MM-HH:MM` format.",
168 + "type": "array",
169 + "items": {
170 + "title": "Time range",
171 + "type": "string"
172 + },
173 + "minItems": 1
174 + },
175 + "weekday": {
176 + "title": "Weekday",
177 + "description": "Weekday used by `nth_weekday` rules.",
178 + "type": "string",
179 + "enum": [
180 + "sunday",
181 + "monday",
182 + "tuesday",
183 + "wednesday",
184 + "thursday",
185 + "friday",
186 + "saturday"
187 + ]
188 + },
189 + "nth": {
190 + "title": "Nth occurrence",
191 + "description": "Month occurrence used by `nth_weekday` rules.",
192 + "type": "integer",
193 + "minimum": 1,
194 + "maximum": 5
195 + },
196 + "dates": {
197 + "title": "Dates",
198 + "description": "Explicit dates used by `date` rules, in `YYYY-MM-DD` format.",
199 + "type": [
200 + "array",
201 + "null"
202 + ],
203 + "items": {
204 + "title": "Date",
205 + "type": "string"
206 + }
207 + }
208 + },
209 + "required": [
210 + "ranges"
211 + ]
212 + },
213 + "minItems": 1
214 + }
215 + },
216 + "required": [
217 + "name",
218 + "rules"
219 + ]
220 + }
221 + },
222 + "working_directory": {
223 + "title": "Working directory",
224 + "description": "Optional working directory used when running the check command.",
225 + "type": "string"
226 + },
227 + "custom_vars": {
228 + "title": "Custom variables",
229 + "description": "Custom service variables exported as `NAGIOS__SERVICE*` environment variables.",
230 + "type": "object",
231 + "additionalProperties": {
232 + "type": "string"
233 + }
234 + },
235 + "notes": {
236 + "title": "Notes",
237 + "description": "Optional free-form notes for this job.",
238 + "type": "string"
239 + },
240 + "vnode": {
241 + "title": "Vnode",
242 + "description": "Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes).",
243 + "type": "string"
244 + }
245 + },
246 + "required": [
247 + "plugin"
248 + ]
249 + },
250 + "uiSchema": {
251 + "uiOptions": {
252 + "fullPage": true
253 + },
254 + "plugin": {
255 + "ui:placeholder": "/usr/lib/nagios/plugins/check_ping"
256 + },
257 + "args": {
258 + "ui:listFlavour": "list"
259 + },
260 + "arg_values": {
261 + "ui:listFlavour": "list",
262 + "ui:help": "These values are exported as `NAGIOS_ARG1` .. `NAGIOS_ARG32`."
263 + },
264 + "timeout": {
265 + "ui:help": "Accepts decimals for precise control (for example 1.5 for 1.5 seconds). String durations are also accepted in raw config files."
266 + },
267 + "check_interval": {
268 + "ui:help": "Requested regular check interval. Actual execution cannot be faster than `update_every`."
269 + },
270 + "retry_interval": {
271 + "ui:help": "Requested retry interval after non-OK results. Actual execution cannot be faster than `update_every`."
272 + },
273 + "check_period": {
274 + "ui:placeholder": "24x7"
275 + },
276 + "time_periods": {
277 + "ui:listFlavour": "list"
278 + },
279 + "vnode": {
280 + "ui:placeholder": "To use this option, first create a Virtual Node and then reference its name here."
281 + },
282 + "ui:flavour": "tabs",
283 + "ui:options": {
284 + "tabs": [
285 + {
286 + "title": "Base",
287 + "fields": [
288 + "update_every",
289 + "plugin",
290 + "args",
291 + "arg_values",
292 + "timeout",
293 + "vnode",
294 + "autodetection_retry"
295 + ]
296 + },
297 + {
298 + "title": "Scheduling",
299 + "fields": [
300 + "check_interval",
301 + "retry_interval",
302 + "max_check_attempts",
303 + "check_period",
304 + "time_periods"
305 + ]
306 + },
307 + {
308 + "title": "Runtime",
309 + "fields": [
310 + "working_directory",
311 + "environment",
312 + "custom_vars",
313 + "notes"
314 + ]
315 + }
316 + ]
317 + }
318 + }
319 +}
src/go/plugin/scripts.d/collector/nagios/constants.go new
+20
@@ -0,0 +1,20 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +const (
6 + nagiosStateOK = "OK"
7 + nagiosStateWarning = "WARNING"
8 + nagiosStateCritical = "CRITICAL"
9 + nagiosStateUnknown = "UNKNOWN"
10 + jobStateTimeout = "TIMEOUT"
11 + jobStatePaused = "PAUSED"
12 +
13 + nagiosHostStateUp = "UP"
14 + nagiosHostStateUpID = "0"
15 +
16 + nagiosStateIDOK = "0"
17 + nagiosStateIDWarning = "1"
18 + nagiosStateIDCritical = "2"
19 + nagiosStateIDUnknown = "3"
20 +)
src/go/plugin/scripts.d/collector/nagios/exec_env.go new
+110
@@ -0,0 +1,110 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "fmt"
7 + "os"
8 + "os/user"
9 + "runtime"
10 + "sort"
11 +)
12 +
13 +func buildRunEnv(workingDir string, jobEnv map[string]string, macroEnv map[string]string) []string {
14 + merged := buildExecutionBaselineEnv(workingDir)
15 + for k, v := range jobEnv {
16 + merged[k] = replaceMacro(v, macroEnv)
17 + }
18 + for k, v := range macroEnv {
19 + merged[k] = v
20 + }
21 +
22 + keys := make([]string, 0, len(merged))
23 + for k := range merged {
24 + keys = append(keys, k)
25 + }
26 + sort.Strings(keys)
27 +
28 + out := make([]string, 0, len(keys))
29 + for _, k := range keys {
30 + out = append(out, fmt.Sprintf("%s=%s", k, merged[k]))
31 + }
32 + return out
33 +}
34 +
35 +func buildExecutionBaselineEnv(workingDir string) map[string]string {
36 + if runtime.GOOS == "windows" {
37 + return buildWindowsExecutionBaselineEnv()
38 + }
39 + return buildUnixExecutionBaselineEnv(workingDir)
40 +}
41 +
42 +func buildUnixExecutionBaselineEnv(workingDir string) map[string]string {
43 + env := make(map[string]string)
44 + setEnvFromProcess(env, "PATH", "PATH")
45 + setEnvFromProcess(env, "TZ", "TZ")
46 + setEnvFromProcess(env, "TZDIR", "TZDIR")
47 + setWorkingDirEnv(env, workingDir)
48 +
49 + tmpdir := os.Getenv("TMPDIR")
50 + if tmpdir == "" {
51 + tmpdir = os.TempDir()
52 + }
53 + if tmpdir != "" {
54 + env["TMPDIR"] = tmpdir
55 + }
56 +
57 + if u, err := user.Current(); err == nil {
58 + if u.Username != "" {
59 + env["USER"] = u.Username
60 + env["LOGNAME"] = u.Username
61 + }
62 + if u.HomeDir != "" {
63 + env["HOME"] = u.HomeDir
64 + }
65 + } else {
66 + setEnvFromProcess(env, "USER", "USER")
67 + setEnvFromProcess(env, "LOGNAME", "LOGNAME")
68 + setEnvFromProcess(env, "HOME", "HOME")
69 + }
70 +
71 + env["SHELL"] = "/bin/sh"
72 + env["LC_ALL"] = "C"
73 + return env
74 +}
75 +
76 +func buildWindowsExecutionBaselineEnv() map[string]string {
77 + env := make(map[string]string)
78 + for _, key := range []string{
79 + "PATH",
80 + "TMP",
81 + "TEMP",
82 + "USERPROFILE",
83 + "HOMEDRIVE",
84 + "HOMEPATH",
85 + "SystemRoot",
86 + "WINDIR",
87 + "ComSpec",
88 + "PATHEXT",
89 + "TZ",
90 + } {
91 + setEnvFromProcess(env, key, key)
92 + }
93 + return env
94 +}
95 +
96 +func setEnvFromProcess(dst map[string]string, dstKey, srcKey string) {
97 + if value := os.Getenv(srcKey); value != "" {
98 + dst[dstKey] = value
99 + }
100 +}
101 +
102 +func setWorkingDirEnv(dst map[string]string, workingDir string) {
103 + if workingDir != "" {
104 + dst["PWD"] = workingDir
105 + return
106 + }
107 + if cwd, err := os.Getwd(); err == nil && cwd != "" {
108 + dst["PWD"] = cwd
109 + }
110 +}
src/go/plugin/scripts.d/collector/nagios/execution.go new
+70
@@ -0,0 +1,70 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "context"
7 + "errors"
8 +)
9 +
10 +var errNagiosCheckTimeout = errors.New("nagios: check timed out")
11 +
12 +func exitCodeFromError(err error) int {
13 + if err == nil {
14 + return 0
15 + }
16 + var exitErr interface{ ExitCode() int }
17 + if errors.As(err, &exitErr) {
18 + return exitErr.ExitCode()
19 + }
20 + return -1
21 +}
22 +
23 +func serviceStateFromExecution(exitCode int, err error) string {
24 + if errors.Is(err, errNagiosCheckTimeout) || errors.Is(err, context.DeadlineExceeded) {
25 + return nagiosStateUnknown
26 + }
27 + switch exitCode {
28 + case 0:
29 + return nagiosStateOK
30 + case 1:
31 + return nagiosStateWarning
32 + case 2:
33 + return nagiosStateCritical
34 + case 3:
35 + return nagiosStateUnknown
36 + default:
37 + return nagiosStateUnknown
38 + }
39 +}
40 +
41 +func jobStateFromExecution(exitCode int, err error) string {
42 + if errors.Is(err, errNagiosCheckTimeout) || errors.Is(err, context.DeadlineExceeded) {
43 + return jobStateTimeout
44 + }
45 + return serviceStateFromExecution(exitCode, err)
46 +}
47 +
48 +func classifyRunError(ctx context.Context, exitCode int, err error) error {
49 + if err == nil {
50 + return nil
51 + }
52 + if errors.Is(err, context.Canceled) {
53 + if ctxErr := ctx.Err(); ctxErr != nil {
54 + return ctxErr
55 + }
56 + return err
57 + }
58 + if errors.Is(err, errNagiosCheckTimeout) {
59 + return nil
60 + }
61 + if exitCode >= 0 && exitCode <= 3 {
62 + return nil
63 + }
64 + if errors.Is(err, context.DeadlineExceeded) {
65 + if ctxErr := ctx.Err(); ctxErr != nil {
66 + return ctxErr
67 + }
68 + }
69 + return err
70 +}
src/go/plugin/scripts.d/collector/nagios/execution_metrics.go new
+17
@@ -0,0 +1,17 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +type executionMetrics struct {
6 + durationSeconds float64
7 + cpuTotalSeconds float64
8 + maxRSSBytes float64
9 +}
10 +
11 +func executionMetricsFromResult(result checkRunResult) executionMetrics {
12 + return executionMetrics{
13 + durationSeconds: result.Duration.Seconds(),
14 + cpuTotalSeconds: (result.Usage.User + result.Usage.System).Seconds(),
15 + maxRSSBytes: float64(result.Usage.MaxRSSBytes),
16 + }
17 +}
src/go/plugin/scripts.d/collector/nagios/execution_test.go new
+60
@@ -0,0 +1,60 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "context"
7 + "errors"
8 + "testing"
9 +
10 + "github.com/stretchr/testify/assert"
11 +)
12 +
13 +func TestServiceStateFromExecution(t *testing.T) {
14 + tests := map[string]struct {
15 + exitCode int
16 + err error
17 + want string
18 + }{
19 + "success maps to ok": {
20 + exitCode: 0,
21 + want: nagiosStateOK,
22 + },
23 + "warning exit maps to warning even when err is non nil": {
24 + exitCode: 1,
25 + err: errors.New("plugin returned warning"),
26 + want: nagiosStateWarning,
27 + },
28 + "critical exit maps to critical even when err is non nil": {
29 + exitCode: 2,
30 + err: errors.New("plugin returned critical"),
31 + want: nagiosStateCritical,
32 + },
33 + "unknown exit maps to unknown even when err is non nil": {
34 + exitCode: 3,
35 + err: errors.New("plugin returned unknown"),
36 + want: nagiosStateUnknown,
37 + },
38 + "timeout maps to unknown service state": {
39 + exitCode: -1,
40 + err: errNagiosCheckTimeout,
41 + want: nagiosStateUnknown,
42 + },
43 + "deadline exceeded maps to unknown service state": {
44 + exitCode: -1,
45 + err: context.DeadlineExceeded,
46 + want: nagiosStateUnknown,
47 + },
48 + "non nagios exit defaults to unknown": {
49 + exitCode: 7,
50 + err: errors.New("bad exit"),
51 + want: nagiosStateUnknown,
52 + },
53 + }
54 +
55 + for name, tc := range tests {
56 + t.Run(name, func(t *testing.T) {
57 + assert.Equal(t, tc.want, serviceStateFromExecution(tc.exitCode, tc.err))
58 + })
59 + }
60 +}
src/go/plugin/scripts.d/collector/nagios/init.go new
+39
@@ -0,0 +1,39 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +func (c *Collector) initCollector() error {
6 + job, err := c.compileConfiguredJob()
7 + if err != nil {
8 + return err
9 + }
10 + c.job = job
11 + c.state = newCollectState(c.now(), job.config)
12 + return nil
13 +}
14 +
15 +func (c *Collector) checkCollector() error {
16 + if c.job.configured() {
17 + return nil
18 + }
19 +
20 + _, err := c.compileConfiguredJob()
21 + return err
22 +}
23 +
24 +func (c *Collector) compileConfiguredJob() (compiledJob, error) {
25 + job, err := compileCollectorConfig(c.Config)
26 + if err != nil {
27 + return compiledJob{}, err
28 + }
29 + c.warnCadenceResolution(job)
30 + return job, nil
31 +}
32 +
33 +func (c *Collector) warnCadenceResolution(job compiledJob) {
34 + if job.cadenceWarning == "" || job.cadenceWarning == c.cadenceWarning {
35 + return
36 + }
37 + c.Warningf("%s", job.cadenceWarning)
38 + c.cadenceWarning = job.cadenceWarning
39 +}
src/go/plugin/scripts.d/collector/nagios/internal/output/parser.go renamed
+10 -28
@@ -11,8 +11,8 @@ import (
11
12 // ParsedOutput represents the structured form of a Nagios plugin output.
13 type ParsedOutput struct {
14 - StatusLine string
15 - LongOutput string
14 + statusLine string
15 + longOutput string
16 Perfdata []PerfDatum
17 }
18
@@ -23,23 +23,15 @@ type PerfDatum struct {
23 Value float64
24 Warn *ThresholdRange
25 Crit *ThresholdRange
26 - Min *float64
27 - Max *float64
26 }
27
28 // ThresholdRange captures the Nagios range grammar semantics.
29 type ThresholdRange struct {
32 - Raw string
30 Inclusive bool
31 Low *float64
32 High *float64
33 }
34
38 -// Defined reports whether the range field was present (non-empty and not U).
39 -func (r *ThresholdRange) Defined() bool {
40 - return r != nil
41 -}
42 -
35 // Parse converts raw plugin output into status, long output, and perfdata sections.
36 func Parse(raw []byte) ParsedOutput {
37 text := strings.ReplaceAll(string(raw), "\r\n", "\n")
@@ -84,12 +76,16 @@ func Parse(raw []byte) ParsedOutput {
76 longOutput = strings.TrimRightFunc(longOutput, unicode.IsSpace)
77
78 return ParsedOutput{
87 - StatusLine: status,
88 - LongOutput: longOutput,
79 + statusLine: status,
80 + longOutput: longOutput,
81 Perfdata: perfdata,
82 }
83 }
84
85 +func (p ParsedOutput) StatusLine() string { return p.statusLine }
86 +
87 +func (p ParsedOutput) LongOutput() string { return p.longOutput }
88 +
89 func tokenizePerfdata(s string) []string {
90 var tokens []string
91 var cur strings.Builder
@@ -136,7 +132,7 @@ func parsePerfToken(token string) (PerfDatum, bool) {
132
133 fields := strings.SplitN(parts[1], ";", 5)
134 valueStr := fields[0]
139 - warnStr, critStr, minStr, maxStr := getField(fields, 1), getField(fields, 2), getField(fields, 3), getField(fields, 4)
135 + warnStr, critStr := getField(fields, 1), getField(fields, 2)
136
137 val, unit, ok := parseValueUnit(valueStr)
138 if !ok {
@@ -149,8 +145,6 @@ func parsePerfToken(token string) (PerfDatum, bool) {
145 Value: val,
146 Warn: parseRange(warnStr),
147 Crit: parseRange(critStr),
152 - Min: parseFloatPtr(minStr),
153 - Max: parseFloatPtr(maxStr),
148 }
149
150 return datum, true
@@ -187,24 +181,12 @@ func parseValueUnit(value string) (float64, string, bool) {
181 return v, unit, true
182 }
183
190 -func parseFloatPtr(val string) *float64 {
191 - val = strings.TrimSpace(val)
192 - if val == "" || strings.EqualFold(val, "u") {
193 - return nil
194 - }
195 - v, err := strconv.ParseFloat(val, 64)
196 - if err != nil {
197 - return nil
198 - }
199 - return &v
200 -}
201 -
184 func parseRange(val string) *ThresholdRange {
185 s := strings.TrimSpace(val)
186 if s == "" || strings.EqualFold(s, "u") {
187 return nil
188 }
207 - rng := &ThresholdRange{Raw: s}
189 + rng := &ThresholdRange{}
190 if strings.HasPrefix(s, "@") {
191 rng.Inclusive = true
192 s = strings.TrimSpace(s[1:])
src/go/plugin/scripts.d/collector/nagios/internal/output/parser_test.go new
+79
@@ -0,0 +1,79 @@
1 +package output
2 +
3 +import (
4 + "testing"
5 +
6 + "github.com/stretchr/testify/assert"
7 + "github.com/stretchr/testify/require"
8 +)
9 +
10 +func TestParsePerfdata(t *testing.T) {
11 + raw := []byte("OK - all good | 'time'=123ms;200;500;0;1000 'load1'=0.12;1.0;2.0\nLong output line\nAnother | ignored")
12 + parsed := Parse(raw)
13 + assert.Equal(t, "OK - all good", parsed.StatusLine())
14 + assert.Equal(t, "Long output line\nAnother", parsed.LongOutput())
15 + require.Len(t, parsed.Perfdata, 2)
16 + assert.Equal(t, "time", parsed.Perfdata[0].Label)
17 + assert.Equal(t, "ms", parsed.Perfdata[0].Unit)
18 + assert.Equal(t, 123.0, parsed.Perfdata[0].Value)
19 + require.NotNil(t, parsed.Perfdata[0].Warn)
20 + require.NotNil(t, parsed.Perfdata[0].Warn.High)
21 + assert.Equal(t, 200.0, *parsed.Perfdata[0].Warn.High)
22 + require.NotNil(t, parsed.Perfdata[0].Crit)
23 + require.NotNil(t, parsed.Perfdata[0].Crit.High)
24 + assert.Equal(t, 500.0, *parsed.Perfdata[0].Crit.High)
25 + assert.Equal(t, "load1", parsed.Perfdata[1].Label)
26 + assert.Equal(t, 0.12, parsed.Perfdata[1].Value)
27 +}
28 +
29 +func TestParseRangeVariants(t *testing.T) {
30 + tests := map[string]struct {
31 + input string
32 + expectNil bool
33 + low *float64
34 + high *float64
35 + inclusive bool
36 + }{
37 + "simple": {input: "10", low: floatPtr(0), high: floatPtr(10)},
38 + "range": {input: "10:20", low: floatPtr(10), high: floatPtr(20)},
39 + "inclusive": {input: "@5:15", low: floatPtr(5), high: floatPtr(15), inclusive: true},
40 + "lower_unbounded": {input: "~:5", low: nil, high: floatPtr(5)},
41 + "upper_unbounded": {input: "10:", low: floatPtr(10), high: nil},
42 + "default_low": {input: ":30", low: floatPtr(0), high: floatPtr(30)},
43 + "unknown": {input: "U", expectNil: true},
44 + }
45 + for name, tc := range tests {
46 + t.Run(name, func(t *testing.T) {
47 + rng := parseRange(tc.input)
48 + if tc.expectNil {
49 + assert.Nil(t, rng)
50 + return
51 + }
52 + require.NotNil(t, rng)
53 + if tc.low == nil {
54 + assert.Nil(t, rng.Low)
55 + } else {
56 + require.NotNil(t, rng.Low)
57 + assert.Equal(t, *tc.low, *rng.Low)
58 + }
59 + if tc.high == nil {
60 + assert.Nil(t, rng.High)
61 + } else {
62 + require.NotNil(t, rng.High)
63 + assert.Equal(t, *tc.high, *rng.High)
64 + }
65 + assert.Equal(t, tc.inclusive, rng.Inclusive)
66 + })
67 + }
68 +}
69 +
70 +func floatPtr(v float64) *float64 {
71 + return &v
72 +}
73 +
74 +func TestParseLongOutputPreservesLeadingWhitespace(t *testing.T) {
75 + raw := []byte("WARNING something broke\n first line\n\tsecond line \n")
76 + parsed := Parse(raw)
77 + expected := " first line\n\tsecond line"
78 + assert.Equal(t, expected, parsed.LongOutput())
79 +}
src/go/plugin/scripts.d/collector/nagios/job_config.go new
+108
@@ -0,0 +1,108 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "fmt"
7 + "maps"
8 + "path/filepath"
9 + "time"
10 +
11 + "github.com/netdata/netdata/go/plugins/pkg/confopt"
12 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/timeperiod"
13 +)
14 +
15 +const (
16 + defaultCheckInterval = 5 * time.Minute
17 + defaultRetryInterval = 1 * time.Minute
18 + defaultTimeout = 5 * time.Second
19 + defaultMaxCheckAttempts = 3
20 + maxArgMacros = 32
21 +)
22 +
23 +// JobConfig is the user-facing Nagios job configuration surface.
24 +type JobConfig struct {
25 + Name string `yaml:"name" json:"name"`
26 + Vnode string `yaml:"vnode,omitempty" json:"vnode"`
27 + Plugin string `yaml:"plugin" json:"plugin"`
28 + Args []string `yaml:"args,omitempty" json:"args"`
29 + ArgValues []string `yaml:"arg_values,omitempty" json:"arg_values"`
30 + Environment map[string]string `yaml:"environment,omitempty" json:"environment"`
31 + Timeout confopt.Duration `yaml:"timeout,omitempty" json:"timeout"`
32 + CheckInterval confopt.Duration `yaml:"check_interval,omitempty" json:"check_interval"`
33 + RetryInterval confopt.Duration `yaml:"retry_interval,omitempty" json:"retry_interval"`
34 + MaxCheckAttempts int `yaml:"max_check_attempts,omitempty" json:"max_check_attempts"`
35 + WorkingDirectory string `yaml:"working_directory,omitempty" json:"working_directory"`
36 + CustomVars map[string]string `yaml:"custom_vars,omitempty" json:"custom_vars"`
37 + CheckPeriod string `yaml:"check_period,omitempty" json:"check_period"`
38 +}
39 +
40 +func defaultedJobConfig(cfg JobConfig) JobConfig {
41 + if cfg.Timeout == 0 {
42 + cfg.Timeout = confopt.Duration(defaultTimeout)
43 + }
44 + if cfg.CheckInterval == 0 {
45 + cfg.CheckInterval = confopt.Duration(defaultCheckInterval)
46 + }
47 + if cfg.RetryInterval == 0 {
48 + cfg.RetryInterval = confopt.Duration(defaultRetryInterval)
49 + }
50 + if cfg.MaxCheckAttempts == 0 {
51 + cfg.MaxCheckAttempts = defaultMaxCheckAttempts
52 + }
53 + if cfg.Environment == nil {
54 + cfg.Environment = make(map[string]string)
55 + }
56 + if cfg.CustomVars == nil {
57 + cfg.CustomVars = make(map[string]string)
58 + }
59 + if cfg.CheckPeriod == "" {
60 + cfg.CheckPeriod = timeperiod.DefaultPeriodName
61 + }
62 + return cfg
63 +}
64 +
65 +func (cfg *JobConfig) setDefaults() {
66 + *cfg = defaultedJobConfig(*cfg)
67 +}
68 +
69 +func (cfg JobConfig) validate() error {
70 + if cfg.Name == "" {
71 + return fmt.Errorf("job name is required")
72 + }
73 + if cfg.Plugin == "" {
74 + return fmt.Errorf("job '%s': plugin path is required", cfg.Name)
75 + }
76 + if !filepath.IsAbs(cfg.Plugin) {
77 + return fmt.Errorf("job '%s': plugin path must be absolute", cfg.Name)
78 + }
79 + if len(cfg.ArgValues) > maxArgMacros {
80 + return fmt.Errorf("job '%s': arg_values supports up to %d entries", cfg.Name, maxArgMacros)
81 + }
82 + if cfg.CheckInterval <= 0 {
83 + return fmt.Errorf("job '%s': check_interval must be > 0", cfg.Name)
84 + }
85 + if cfg.RetryInterval <= 0 {
86 + return fmt.Errorf("job '%s': retry_interval must be > 0", cfg.Name)
87 + }
88 + if cfg.Timeout <= 0 {
89 + return fmt.Errorf("job '%s': timeout must be > 0", cfg.Name)
90 + }
91 + if cfg.MaxCheckAttempts < 1 {
92 + return fmt.Errorf("job '%s': max_check_attempts must be >= 1", cfg.Name)
93 + }
94 + return nil
95 +}
96 +
97 +func (cfg JobConfig) normalized() (JobConfig, error) {
98 + cfg.setDefaults()
99 + if err := cfg.validate(); err != nil {
100 + return JobConfig{}, err
101 + }
102 +
103 + cfg.Args = append([]string{}, cfg.Args...)
104 + cfg.ArgValues = append([]string{}, cfg.ArgValues...)
105 + cfg.Environment = maps.Clone(cfg.Environment)
106 + cfg.CustomVars = maps.Clone(cfg.CustomVars)
107 + return cfg, nil
108 +}
src/go/plugin/scripts.d/collector/nagios/job_config_test.go new
+60
@@ -0,0 +1,60 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "testing"
7 + "time"
8 +
9 + "github.com/netdata/netdata/go/plugins/pkg/confopt"
10 + "github.com/stretchr/testify/assert"
11 +)
12 +
13 +func TestJobConfigSetDefaults(t *testing.T) {
14 + cfg := JobConfig{Name: "sample", Plugin: "/usr/lib/nagios/plugins/check_ping"}
15 + cfg.setDefaults()
16 +
17 + assert.Equal(t, confopt.Duration(5*time.Second), cfg.Timeout)
18 + assert.NotZero(t, cfg.CheckInterval)
19 + assert.NotZero(t, cfg.RetryInterval)
20 + assert.NotZero(t, cfg.MaxCheckAttempts)
21 + assert.NotEmpty(t, cfg.CheckPeriod)
22 +}
23 +
24 +func TestJobConfigValidate(t *testing.T) {
25 + tests := map[string]struct {
26 + cfg JobConfig
27 + wantErr bool
28 + }{
29 + "valid": {
30 + cfg: JobConfig{Name: "sample", Plugin: "/bin/true"},
31 + },
32 + "arg_values over limit": {
33 + cfg: func() JobConfig {
34 + cfg := JobConfig{Name: "sample", Plugin: "/bin/true"}
35 + for i := 0; i < maxArgMacros+1; i++ {
36 + cfg.ArgValues = append(cfg.ArgValues, "value")
37 + }
38 + return cfg
39 + }(),
40 + wantErr: true,
41 + },
42 + "relative plugin path": {
43 + cfg: JobConfig{Name: "sample", Plugin: "check_ping"},
44 + wantErr: true,
45 + },
46 + }
47 +
48 + for name, tc := range tests {
49 + t.Run(name, func(t *testing.T) {
50 + tc.cfg.setDefaults()
51 +
52 + if tc.wantErr {
53 + assert.Error(t, tc.cfg.validate())
54 + return
55 + }
56 +
57 + assert.NoError(t, tc.cfg.validate())
58 + })
59 + }
60 +}
src/go/plugin/scripts.d/collector/nagios/job_v2_integration_test.go new
+213
@@ -0,0 +1,213 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "bytes"
7 + "io"
8 + "os"
9 + "path/filepath"
10 + "sync"
11 + "testing"
12 + "time"
13 +
14 + "github.com/netdata/netdata/go/plugins/plugin/framework/jobruntime"
15 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/collector/nagios/internal/output"
16 + "github.com/stretchr/testify/assert"
17 + "github.com/stretchr/testify/require"
18 +)
19 +
20 +func TestNagiosCollectorJobV2(t *testing.T) {
21 + type jobCaseState struct {
22 + job *jobruntime.JobV2
23 + out *lockedBuffer
24 + startedFile string
25 + }
26 +
27 + tests := map[string]struct {
28 + setup func(*testing.T) jobCaseState
29 + run func(*testing.T, jobCaseState)
30 + }{
31 + "emits perfdata-derived metrics on tick": {
32 + setup: func(t *testing.T) jobCaseState {
33 + t.Helper()
34 + now := time.Date(2026, 3, 21, 12, 0, 0, 0, time.UTC)
35 + runner := &fakeRunner{
36 + results: []fakeRun{
37 + {
38 + result: checkRunResult{
39 + ServiceState: "OK",
40 + JobState: "OK",
41 + Parsed: output.ParsedOutput{
42 + Perfdata: []output.PerfDatum{
43 + {Label: "requests", Unit: "c", Value: 7},
44 + },
45 + },
46 + },
47 + },
48 + },
49 + }
50 + coll := New()
51 + coll.runner = runner
52 + coll.now = func() time.Time { return now }
53 + coll.Config.JobConfig = JobConfig{
54 + Name: "jobv2",
55 + Plugin: "/bin/true",
56 + CheckInterval: confDuration(5 * time.Minute),
57 + RetryInterval: confDuration(1 * time.Minute),
58 + }
59 + coll.Config.UpdateEvery = 1
60 +
61 + out := &lockedBuffer{}
62 + job := newTestJobV2(t, "jobv2", coll, out)
63 + return jobCaseState{job: job, out: out}
64 + },
65 + run: func(t *testing.T, state jobCaseState) {
66 + t.Helper()
67 + state.job.Tick(1)
68 + deadline := time.Now().Add(2 * time.Second)
69 + for time.Now().Before(deadline) {
70 + if state.out.Len() > 0 {
71 + break
72 + }
73 + time.Sleep(10 * time.Millisecond)
74 + }
75 +
76 + wire := state.out.String()
77 + assert.Contains(t, wire, "CHART '")
78 + assert.Contains(t, wire, "BEGIN '")
79 + assert.Contains(t, wire, "requests")
80 + },
81 + },
82 + "stop cancels in-flight script": {
83 + setup: func(t *testing.T) jobCaseState {
84 + t.Helper()
85 + dir := t.TempDir()
86 + startedFile := filepath.Join(dir, "started")
87 + scriptPath := filepath.Join(dir, "check_slow.sh")
88 + writeExecutable(t, scriptPath, "#!/bin/sh\nset -eu\nstarted_file=\"$1\"\necho started > \"$started_file\"\ntrap 'exit 0' TERM INT\nsleep 30\n")
89 +
90 + coll := New()
91 + coll.Config.JobConfig = JobConfig{
92 + Name: "cancel_job",
93 + Plugin: scriptPath,
94 + Args: []string{startedFile},
95 + Timeout: confDuration(30 * time.Second),
96 + CheckInterval: confDuration(5 * time.Minute),
97 + RetryInterval: confDuration(1 * time.Minute),
98 + }
99 + coll.Config.UpdateEvery = 1
100 +
101 + out := &lockedBuffer{}
102 + job := newTestJobV2(t, "cancel_job", coll, out)
103 + return jobCaseState{job: job, out: out, startedFile: startedFile}
104 + },
105 + run: func(t *testing.T, state jobCaseState) {
106 + t.Helper()
107 + state.job.Tick(1)
108 + deadline := time.Now().Add(2 * time.Second)
109 + for time.Now().Before(deadline) {
110 + if _, err := os.Stat(state.startedFile); err == nil {
111 + break
112 + }
113 + time.Sleep(10 * time.Millisecond)
114 + }
115 + _, err := os.Stat(state.startedFile)
116 + require.NoError(t, err, "timed out waiting for in-flight script start")
117 + stopStarted := time.Now()
118 + state.job.Stop()
119 + assert.LessOrEqual(t, time.Since(stopStarted), 3*time.Second)
120 + },
121 + },
122 + }
123 +
124 + for name, tc := range tests {
125 + t.Run(name, func(t *testing.T) {
126 + state := tc.setup(t)
127 +
128 + startDone := make(chan struct{})
129 + go func() {
130 + state.job.Start()
131 + close(startDone)
132 + }()
133 +
134 + waitForJobRunning(t, state.job)
135 +
136 + if name != "stop cancels in-flight script" {
137 + defer stopAndWaitForJob(t, state.job, startDone)
138 + }
139 +
140 + tc.run(t, state)
141 +
142 + if name == "stop cancels in-flight script" {
143 + select {
144 + case <-startDone:
145 + case <-time.After(2 * time.Second):
146 + require.FailNow(t, "timeout waiting for job start loop to exit")
147 + }
148 + }
149 + })
150 + }
151 +}
152 +
153 +func newTestJobV2(t *testing.T, name string, coll *Collector, out io.Writer) *jobruntime.JobV2 {
154 + t.Helper()
155 + job := jobruntime.NewJobV2(jobruntime.JobV2Config{
156 + PluginName: "scripts.d",
157 + Name: name,
158 + ModuleName: "nagios",
159 + FullName: "scripts_d_" + name,
160 + Module: coll,
161 + Out: out,
162 + UpdateEvery: 1,
163 + })
164 + require.NoError(t, job.AutoDetection())
165 + return job
166 +}
167 +
168 +func waitForJobRunning(t *testing.T, job *jobruntime.JobV2) {
169 + t.Helper()
170 + deadline := time.Now().Add(2 * time.Second)
171 + for time.Now().Before(deadline) && !job.IsRunning() {
172 + time.Sleep(10 * time.Millisecond)
173 + }
174 + require.True(t, job.IsRunning(), "job did not enter running state")
175 +}
176 +
177 +func stopAndWaitForJob(t *testing.T, job *jobruntime.JobV2, startDone <-chan struct{}) {
178 + t.Helper()
179 + job.Stop()
180 + select {
181 + case <-startDone:
182 + case <-time.After(2 * time.Second):
183 + require.FailNow(t, "timeout waiting for job start loop to exit")
184 + }
185 +}
186 +
187 +func writeExecutable(t *testing.T, path, content string) {
188 + t.Helper()
189 + require.NoError(t, os.WriteFile(path, []byte(content), 0o755), "write executable %s", path)
190 +}
191 +
192 +type lockedBuffer struct {
193 + mu sync.Mutex
194 + buf bytes.Buffer
195 +}
196 +
197 +func (b *lockedBuffer) Write(p []byte) (int, error) {
198 + b.mu.Lock()
199 + defer b.mu.Unlock()
200 + return b.buf.Write(p)
201 +}
202 +
203 +func (b *lockedBuffer) Len() int {
204 + b.mu.Lock()
205 + defer b.mu.Unlock()
206 + return b.buf.Len()
207 +}
208 +
209 +func (b *lockedBuffer) String() string {
210 + b.mu.Lock()
211 + defer b.mu.Unlock()
212 + return b.buf.String()
213 +}
src/go/plugin/scripts.d/collector/nagios/legacy_scale_test.go new
+180
@@ -0,0 +1,180 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "math"
7 + "strings"
8 + "testing"
9 +
10 + "github.com/stretchr/testify/assert"
11 +)
12 +
13 +const (
14 + legacyDefaultDivisor = 1000
15 + legacyTimeDivisor = 1_000_000_000
16 +)
17 +
18 +type legacyScale struct {
19 + canonicalUnit string
20 + divisor int
21 + multiplier float64
22 +}
23 +
24 +func legacyDisplayValue(unit string, raw float64) float64 {
25 + scale := legacyScaleFromUnit(unit)
26 + scaled := int64(math.Round(raw * scale.multiplier))
27 + return float64(scaled) / float64(scale.divisor)
28 +}
29 +
30 +func legacyScaleFromUnit(unit string) legacyScale {
31 + trimmed := strings.TrimSpace(unit)
32 + if trimmed == "" {
33 + return legacyScale{canonicalUnit: "", divisor: legacyDefaultDivisor, multiplier: legacyDefaultDivisor}
34 + }
35 + lower := strings.ToLower(trimmed)
36 + if scale, ok := legacyTimeScale(lower); ok {
37 + return scale
38 + }
39 + if scale, ok := legacyByteScale(trimmed); ok {
40 + return scale
41 + }
42 + if lower == "%" {
43 + return legacyScale{canonicalUnit: "%", divisor: legacyDefaultDivisor, multiplier: legacyDefaultDivisor}
44 + }
45 + if lower == "c" {
46 + return legacyScale{canonicalUnit: "c", divisor: 1, multiplier: 1}
47 + }
48 + return legacyScale{canonicalUnit: trimmed, divisor: legacyDefaultDivisor, multiplier: legacyDefaultDivisor}
49 +}
50 +
51 +func legacyTimeScale(unit string) (legacyScale, bool) {
52 + switch unit {
53 + case "s", "sec", "secs", "second", "seconds":
54 + return legacyScale{canonicalUnit: "seconds", divisor: legacyTimeDivisor, multiplier: legacyTimeDivisor}, true
55 + case "ms", "millisecond", "milliseconds":
56 + return legacyScale{canonicalUnit: "seconds", divisor: legacyTimeDivisor, multiplier: 1_000_000}, true
57 + case "us", "µs", "usec", "microsecond", "microseconds":
58 + return legacyScale{canonicalUnit: "seconds", divisor: legacyTimeDivisor, multiplier: 1_000}, true
59 + case "ns", "nanosecond", "nanoseconds":
60 + return legacyScale{canonicalUnit: "seconds", divisor: legacyTimeDivisor, multiplier: 1}, true
61 + default:
62 + return legacyScale{}, false
63 + }
64 +}
65 +
66 +func legacyByteScale(unit string) (legacyScale, bool) {
67 + base, perSecond := legacySplitPerSecond(unit)
68 + if base == "" {
69 + return legacyScale{}, false
70 + }
71 + mult, kind, ok := legacyByteMultiplier(base)
72 + if !ok {
73 + return legacyScale{}, false
74 + }
75 + canonical := kind
76 + if perSecond {
77 + canonical += "/s"
78 + }
79 + return legacyScale{canonicalUnit: canonical, divisor: 1, multiplier: mult}, true
80 +}
81 +
82 +func legacySplitPerSecond(unit string) (string, bool) {
83 + lower := strings.ToLower(unit)
84 + switch {
85 + case strings.HasSuffix(lower, "/s"):
86 + return strings.TrimSpace(unit[:len(unit)-2]), true
87 + case strings.HasSuffix(lower, "ps"):
88 + return strings.TrimSpace(unit[:len(unit)-2]), true
89 + default:
90 + return strings.TrimSpace(unit), false
91 + }
92 +}
93 +
94 +func legacyByteMultiplier(unit string) (float64, string, bool) {
95 + unit = strings.TrimSpace(unit)
96 + if unit == "" {
97 + return 0, "", false
98 + }
99 + kind, prefix, ok := legacySplitByteUnit(unit)
100 + if !ok {
101 + return 0, "", false
102 + }
103 + mult, ok := legacyByteMagnitude(prefix)
104 + if !ok {
105 + return 0, "", false
106 + }
107 + return mult, kind, true
108 +}
109 +
110 +func legacySplitByteUnit(unit string) (string, string, bool) {
111 + lower := strings.ToLower(unit)
112 + switch {
113 + case strings.HasSuffix(lower, "bytes"):
114 + return "bytes", unit[:len(unit)-5], true
115 + case strings.HasSuffix(lower, "byte"):
116 + return "bytes", unit[:len(unit)-4], true
117 + case strings.HasSuffix(lower, "bits"):
118 + return "bits", unit[:len(unit)-4], true
119 + case strings.HasSuffix(lower, "bit"):
120 + return "bits", unit[:len(unit)-3], true
121 + }
122 + if len(unit) == 0 {
123 + return "", "", false
124 + }
125 + last := unit[len(unit)-1]
126 + switch last {
127 + case 'B':
128 + return "bytes", unit[:len(unit)-1], true
129 + case 'b':
130 + return "bits", unit[:len(unit)-1], true
131 + }
132 + return "", "", false
133 +}
134 +
135 +func legacyByteMagnitude(prefix string) (float64, bool) {
136 + switch strings.ToLower(strings.TrimSpace(prefix)) {
137 + case "":
138 + return 1, true
139 + case "k":
140 + return 1_000, true
141 + case "m":
142 + return 1_000_000, true
143 + case "g":
144 + return 1_000_000_000, true
145 + case "t":
146 + return 1_000_000_000_000, true
147 + default:
148 + return 0, false
149 + }
150 +}
151 +
152 +func TestLegacyScaleDistinguishesBitsAndBytes(t *testing.T) {
153 + tests := map[string]struct {
154 + unit string
155 + value float64
156 + canonicalUnit string
157 + want float64
158 + }{
159 + "megabytes per second": {
160 + unit: "MBps", value: 1.5, canonicalUnit: "bytes/s", want: 1_500_000,
161 + },
162 + "megabits per second": {
163 + unit: "Mbps", value: 1.5, canonicalUnit: "bits/s", want: 1_500_000,
164 + },
165 + "megabytes": {
166 + unit: "MB", value: 2.5, canonicalUnit: "bytes", want: 2_500_000,
167 + },
168 + "megabits": {
169 + unit: "Mb", value: 2.5, canonicalUnit: "bits", want: 2_500_000,
170 + },
171 + }
172 +
173 + for name, tc := range tests {
174 + t.Run(name, func(t *testing.T) {
175 + scale := legacyScaleFromUnit(tc.unit)
176 + assert.Equal(t, tc.canonicalUnit, scale.canonicalUnit)
177 + assert.Equal(t, tc.want, legacyDisplayValue(tc.unit, tc.value))
178 + })
179 + }
180 +}
src/go/plugin/scripts.d/collector/nagios/macros.go new
+175
@@ -0,0 +1,175 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "fmt"
7 + "strings"
8 + "time"
9 +
10 + "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
11 +)
12 +
13 +type macroSet struct {
14 + CommandArgs []string
15 + Env map[string]string
16 +}
17 +
18 +func buildMacroSet(job JobConfig, vnode vnodeInfo, state macroState, now time.Time) macroSet {
19 + env := make(map[string]string)
20 + set := func(key, value string) {
21 + if value != "" {
22 + env[key] = value
23 + }
24 + }
25 +
26 + set("NAGIOS_PLUGIN", job.Plugin)
27 + set("NAGIOS_JOB", job.Name)
28 + set("NAGIOS_HOSTNAME", firstNonEmpty(job.Vnode, vnode.Hostname))
29 + set("NAGIOS_HOSTADDRESS", vnode.Labels["_address"])
30 + set("NAGIOS_HOSTALIAS", vnode.Labels["_alias"])
31 + set("NAGIOS_SERVICEDESC", job.Name)
32 + set("NAGIOS_SERVICESTATE", state.ServiceState)
33 + set("NAGIOS_SERVICESTATEID", stateID(state.ServiceState))
34 + if state.ServiceAttempt > 0 {
35 + set("NAGIOS_SERVICEATTEMPT", fmt.Sprintf("%d", state.ServiceAttempt))
36 + }
37 + if state.ServiceMaxAttempts > 0 {
38 + set("NAGIOS_MAXSERVICEATTEMPTS", fmt.Sprintf("%d", state.ServiceMaxAttempts))
39 + }
40 + set("NAGIOS_HOSTSTATE", nagiosHostStateUp)
41 + set("NAGIOS_HOSTSTATEID", nagiosHostStateUpID)
42 + set("NAGIOS_LONGDATETIME", now.Format(time.RFC1123))
43 + set("NAGIOS_SHORTDATETIME", now.Format("2006-01-02 15:04"))
44 + set("NAGIOS_DATE", now.Format("2006-01-02"))
45 + set("NAGIOS_TIME", now.Format("15:04:05"))
46 + set("NAGIOS_TIMET", fmt.Sprintf("%d", now.Unix()))
47 +
48 + for k, v := range vnode.Labels {
49 + if strings.HasPrefix(k, "_") && k != "_address" && k != "_alias" {
50 + env[fmt.Sprintf("NAGIOS__HOST%s", strings.ToUpper(k[1:]))] = v
51 + } else if !strings.HasPrefix(k, "_") {
52 + env[fmt.Sprintf("NAGIOS__HOSTLABEL_%s", strings.ToUpper(k))] = v
53 + }
54 + }
55 + for k, v := range job.CustomVars {
56 + key := fmt.Sprintf("NAGIOS__SERVICE%s", strings.ToUpper(k))
57 + env[key] = v
58 + }
59 + for idx := 0; idx < len(job.ArgValues) && idx < maxArgMacros; idx++ {
60 + env[fmt.Sprintf("NAGIOS_ARG%d", idx+1)] = job.ArgValues[idx]
61 + }
62 +
63 + cmdArgs := make([]string, len(job.Args))
64 + for i, arg := range job.Args {
65 + cmdArgs[i] = replaceMacro(arg, env)
66 + }
67 + return macroSet{CommandArgs: cmdArgs, Env: env}
68 +}
69 +
70 +func vnodeInfoFromVirtualNode(vn *vnodes.VirtualNode, fallbackHostname string) vnodeInfo {
71 + info := vnodeInfo{
72 + Hostname: fallbackHostname,
73 + Labels: make(map[string]string),
74 + }
75 + if vn == nil {
76 + return info
77 + }
78 + if vn.Hostname != "" {
79 + info.Hostname = vn.Hostname
80 + }
81 + for k, v := range vn.Labels {
82 + info.Labels[k] = v
83 + }
84 + return info
85 +}
86 +
87 +func replaceMacro(value string, env map[string]string) string {
88 + return replaceMacroWithStack(value, env, nil)
89 +}
90 +
91 +func replaceMacroWithStack(value string, env map[string]string, stack map[string]struct{}) string {
92 + if value == "" || len(env) == 0 {
93 + return value
94 + }
95 +
96 + var b strings.Builder
97 + b.Grow(len(value))
98 +
99 + for i := 0; i < len(value); {
100 + if value[i] != '$' {
101 + b.WriteByte(value[i])
102 + i++
103 + continue
104 + }
105 +
106 + end := strings.IndexByte(value[i+1:], '$')
107 + if end < 0 {
108 + b.WriteByte(value[i])
109 + i++
110 + continue
111 + }
112 +
113 + token := value[i+1 : i+1+end]
114 + if token == "" {
115 + b.WriteString("$$")
116 + i += 2
117 + continue
118 + }
119 +
120 + macroKey := "NAGIOS_" + token
121 + macroValue, ok := env[macroKey]
122 + if !ok {
123 + b.WriteString(value[i : i+end+2])
124 + i += end + 2
125 + continue
126 + }
127 +
128 + b.WriteString(resolveMacroValue(macroKey, macroValue, env, stack))
129 + i += end + 2
130 + }
131 +
132 + return b.String()
133 +}
134 +
135 +func resolveMacroValue(key, raw string, env map[string]string, stack map[string]struct{}) string {
136 + if stack == nil {
137 + stack = make(map[string]struct{})
138 + }
139 + if _, ok := stack[key]; ok {
140 + return macroTokenForKey(key)
141 + }
142 +
143 + stack[key] = struct{}{}
144 + resolved := replaceMacroWithStack(raw, env, stack)
145 + delete(stack, key)
146 + return resolved
147 +}
148 +
149 +func macroTokenForKey(key string) string {
150 + return "$" + strings.TrimPrefix(key, "NAGIOS_") + "$"
151 +}
152 +
153 +func firstNonEmpty(values ...string) string {
154 + for _, v := range values {
155 + if v != "" {
156 + return v
157 + }
158 + }
159 + return ""
160 +}
161 +
162 +func stateID(state string) string {
163 + switch strings.ToUpper(state) {
164 + case nagiosStateOK:
165 + return nagiosStateIDOK
166 + case nagiosStateWarning:
167 + return nagiosStateIDWarning
168 + case nagiosStateCritical:
169 + return nagiosStateIDCritical
170 + case nagiosStateUnknown:
171 + return nagiosStateIDUnknown
172 + default:
173 + return nagiosStateIDUnknown
174 + }
175 +}
src/go/plugin/scripts.d/collector/nagios/metadata.yaml new
+353
@@ -0,0 +1,353 @@
1 +plugin_name: scripts.d.plugin
2 +modules:
3 + - meta:
4 + id: collector-scripts.d.plugin-nagios
5 + plugin_name: scripts.d.plugin
6 + module_name: nagios
7 + monitored_instance:
8 + name: Nagios Plugins
9 + link: https://www.nagios-plugins.org/
10 + categories:
11 + - data-collection.synthetic-testing
12 + icon_filename: nagios.png
13 + related_resources:
14 + integrations:
15 + list: []
16 + info_provided_to_referring_integrations:
17 + description: ""
18 + keywords:
19 + - nagios
20 + - plugins
21 + - checks
22 + - scripts
23 + - monitoring
24 + overview:
25 + multi_instance: true
26 + data_collection:
27 + metrics_description: |
28 + This collector runs Nagios-compatible checks, tracks the state of each configured job, measures how long each check takes to run, and automatically charts any performance data the check prints. For non-counter perfdata, Netdata also derives a threshold-state chart and reports `no_threshold` when the check does not provide warning or critical ranges.
29 + method_description: |
30 + Netdata runs the configured Nagios-compatible command for each job, reads the process exit code to determine the check state, and parses the command output into a summary line, optional long output, and optional performance data. Any performance data found after the `|` separator is converted into charts automatically. The main perfdata value becomes a chart, and non-counter metrics also get a derived threshold-state chart. If the check does not provide warning or critical ranges, that derived state is `no_threshold`. You can use packaged Nagios plugins or your own scripts, and you can control how often checks run, how retries behave, and when checks are allowed to run by using the job configuration.
31 + default_behavior:
32 + auto_detection:
33 + description: |
34 + No automatic detection is performed. Add one or more jobs explicitly and point each job to the script or executable you want Netdata to run.
35 + limits:
36 + description: |
37 + Each job runs one configured command. Additional charts are created only when the check emits Nagios performance data.
38 + performance_impact:
39 + description: |
40 + Each job starts an external command. The impact depends mostly on how often the job runs and how expensive the check command itself is.
41 + additional_permissions:
42 + description: |
43 + No additional permissions are required by the collector itself. If a check needs access to protected files, sockets, or system commands, provide that access to the check command or helper it uses.
44 + supported_platforms:
45 + include: []
46 + exclude: []
47 + setup:
48 + prerequisites:
49 + list:
50 + - title: Install check commands
51 + description: |
52 + Install the Nagios plugins or other Nagios-compatible scripts that you want Netdata to run.
53 +
54 + Most Linux distributions provide Nagios plugin packages:
55 +
56 + ```bash
57 + # Debian/Ubuntu
58 + apt install nagios-plugins
59 +
60 + # RHEL/CentOS/Fedora
61 + dnf install nagios-plugins-all
62 + ```
63 +
64 + Make sure the configured command path exists and is executable by the `netdata` user.
65 + - title: Write Nagios-compatible checks
66 + description: |
67 + A compatible check uses two things:
68 +
69 + - the **exit code** to tell Netdata whether the result is OK, WARNING, CRITICAL, or UNKNOWN
70 + - the **command output** to show a human-readable message and optional performance data
71 +
72 + Use these exit codes:
73 +
74 + - `0` = OK
75 + - `1` = WARNING
76 + - `2` = CRITICAL
77 + - `3` = UNKNOWN
78 +
79 + The first output line should follow this pattern:
80 +
81 + ```text
82 + <summary text> | <perfdata>
83 + ```
84 +
85 + The `|` separator is optional:
86 +
87 + - everything before `|` is the human-readable summary
88 + - everything after `|` is performance data used for automatic charts
89 +
90 + The summary should be short and useful because it is the main status text shown for the job. If the script prints multiple lines, Netdata uses the first line as the summary and keeps the remaining lines as long output.
91 +
92 + Each performance-data item follows this format:
93 +
94 + ```text
95 + 'label'=value[UOM];warn;crit;min;max
96 + ```
97 +
98 + Only `label` and `value` are required. The threshold and range fields are optional. Separate multiple metrics with spaces.
99 +
100 + Common units include:
101 +
102 + - `%` for percentages
103 + - `s`, `ms`, `us` for durations
104 + - `B`, `KB`, `MB`, `GB` for sizes
105 + - `c` for counters
106 +
107 + Example output:
108 +
109 + ```text
110 + OK - 85.5% free memory | free_pct=85.5%;20;10;0;100 free_kb=13999088KB;;;0;16380000
111 + ```
112 +
113 + In that example:
114 +
115 + - the exit code decides the state
116 + - `OK - 85.5% free memory` is the summary line
117 + - `free_pct=85.5%;20;10;0;100` creates a percentage metric
118 + - `free_kb=13999088KB;;;0;16380000` creates a size metric
119 + - the warning and critical ranges on non-counter metrics are also used to derive a threshold-state chart
120 +
121 + Good rules to follow:
122 +
123 + - return the correct exit code
124 + - keep the first line short and readable
125 + - put performance data after `|`
126 + - separate multiple metrics with spaces
127 + - quote labels if they contain spaces
128 +
129 + Minimal example:
130 +
131 + ```bash
132 + #!/bin/sh
133 + echo "CPU OK - 20% used | cpu=20%;80;90"
134 + exit 0
135 + ```
136 + configuration:
137 + file:
138 + name: scripts.d/nagios.conf
139 + options:
140 + description: |
141 + Add jobs under `jobs:`. Each job runs one Nagios-compatible check command.
142 + folding:
143 + title: Config options
144 + enabled: true
145 + list:
146 + - name: update_every
147 + description: How often Netdata evaluates the job schedule, in seconds.
148 + default_value: 10
149 + required: false
150 + group: Collection
151 + - name: autodetection_retry
152 + description: How often Netdata retries failed auto-detection jobs, in seconds. Set `0` to keep auto-detection disabled.
153 + default_value: 0
154 + required: false
155 + group: Collection
156 +
157 + - name: plugin
158 + description: Absolute path to the Nagios-compatible executable to run. This can be a packaged Nagios plugin or your own executable. If you need a script interpreter, point `plugin` to that interpreter and pass the script path in `args`. The command should return exit code `0`, `1`, `2`, or `3` and may print performance data after `|`.
159 + default_value: ""
160 + required: true
161 + group: Target
162 + - name: args
163 + description: Arguments passed to the command.
164 + default_value: ""
165 + required: false
166 + group: Target
167 + - name: arg_values
168 + description: Values exposed to `$ARG1$` through `$ARG32$` for macro expansion.
169 + default_value: ""
170 + required: false
171 + group: Target
172 + - name: working_directory
173 + description: Working directory used when running the command.
174 + default_value: ""
175 + required: false
176 + group: Target
177 +
178 + - name: timeout
179 + description: Maximum time allowed for one command run. If the check exceeds this limit, the job state becomes `timeout`.
180 + default_value: 5s
181 + required: false
182 + group: Scheduling
183 + - name: check_interval
184 + description: Interval between regular checks.
185 + default_value: 5m
186 + required: false
187 + group: Scheduling
188 + - name: retry_interval
189 + description: Interval between retries while a check remains in a non-OK soft state.
190 + default_value: 1m
191 + required: false
192 + group: Scheduling
193 + - name: max_check_attempts
194 + description: Number of attempts before a non-OK result becomes a hard state.
195 + default_value: 3
196 + required: false
197 + group: Scheduling
198 + - name: check_period
199 + description: Name of the time period that controls when the job is allowed to run. Outside this period, the check does not execute and the public job state becomes `paused`.
200 + default_value: 24x7
201 + required: false
202 + group: Scheduling
203 + - name: time_periods
204 + description: Custom named time periods defined inside the same job.
205 + default_value: ""
206 + required: false
207 + group: Scheduling
208 +
209 + - name: environment
210 + description: Extra environment variables added on top of the collector's limited execution baseline. The check does not inherit the full Netdata process environment.
211 + default_value: ""
212 + required: false
213 + group: Environment
214 + - name: custom_vars
215 + description: Custom service variables exposed to the check as Nagios-style macros.
216 + default_value: ""
217 + required: false
218 + group: Environment
219 +
220 + - name: vnode
221 + description: Associate the job with a virtual node so the check can use host-specific labels and macros.
222 + default_value: ""
223 + required: false
224 + group: Virtual Node
225 +
226 + - name: notes
227 + description: Optional notes for the job definition.
228 + default_value: ""
229 + required: false
230 + group: Misc
231 + examples:
232 + folding:
233 + title: Config
234 + enabled: true
235 + list:
236 + - name: Basic check
237 + description: Run a Nagios check command on a fixed interval.
238 + config: |
239 + jobs:
240 + - name: ping_localhost
241 + plugin: /usr/lib/nagios/plugins/check_ping
242 + args: ["-H", "127.0.0.1", "-w", "100.0,20%", "-c", "200.0,40%"]
243 + timeout: 5s
244 + check_interval: 1m
245 + retry_interval: 30s
246 + max_check_attempts: 3
247 + - name: Custom script
248 + description: Run your own Nagios-compatible shell script.
249 + config: |
250 + jobs:
251 + - name: custom_memory_check
252 + plugin: /opt/netdata/check_memory.sh
253 + timeout: 5s
254 + check_interval: 1m
255 + - name: Check with a job-local schedule
256 + description: Run a check only during selected hours by defining time periods inside the job.
257 + config: |
258 + jobs:
259 + - name: business_hours_http
260 + plugin: /usr/lib/nagios/plugins/check_http
261 + args: ["-H", "example.com"]
262 + check_period: business_hours
263 + time_periods:
264 + - name: business_hours
265 + alias: Business hours
266 + rules:
267 + - type: weekly
268 + days: [monday, tuesday, wednesday, thursday, friday]
269 + ranges: ["09:00-18:00"]
270 + - name: Check with virtual node macros
271 + description: Run a check against a virtual node and fill command arguments from Nagios-style macros.
272 + config: |
273 + jobs:
274 + - name: check_ssh
275 + plugin: /usr/lib/nagios/plugins/check_ssh
276 + args: ["-H", "$HOSTADDRESS$", "-p", "$ARG1$"]
277 + arg_values: ["22"]
278 + vnode: remote-server
279 + check_interval: 5m
280 + troubleshooting:
281 + problems:
282 + list:
283 + - name: The command cannot be executed
284 + description: |
285 + Confirm that the path in `plugin` exists, is executable, and can be accessed by the `netdata` user. If the check depends on external files or helpers, verify those paths and permissions too.
286 + - name: No performance-data charts appear
287 + description: |
288 + Performance-data charts are created only when the check prints Nagios performance data after the `|` separator. If the command returns only a status line without performance data, Netdata will still show the job state but no extra charts.
289 + - name: Some performance-data values are ignored
290 + description: |
291 + Check that each metric uses the Nagios performance-data format `label=value[UOM];warn;crit;min;max` and that multiple metrics are separated by spaces. If a label contains spaces, quote it. Netdata charts the main value for every perfdata metric, and for non-counter metrics it derives threshold state from `warn` and `crit`; it does not create separate charts for raw `min`, `max`, or raw threshold bounds.
292 + - name: The job state does not match the output text
293 + description: |
294 + The visible text does not decide the state. Netdata uses the process exit code instead: `0` for OK, `1` for WARNING, `2` for CRITICAL, and `3` for UNKNOWN. If the check exceeds the configured `timeout`, Netdata reports `timeout` even if the script never had a chance to print its own final state. If the current time is outside `check_period`, Netdata reports `paused` until the check is allowed to run again.
295 + - name: Only the first output line appears as the main status
296 + description: |
297 + This is expected. Netdata uses the first line as the summary shown for the job. Additional lines are kept as long output, and any `|` sections found on later lines are also parsed for performance data.
298 + - name: Macros are not expanded as expected
299 + description: |
300 + Check that positional values are provided in `arg_values`, custom service variables are defined in `custom_vars`, and any virtual-node labels needed for host macros are present on the selected `vnode`.
301 + - name: The script works in a shell but fails under Netdata
302 + description: |
303 + Nagios checks run with a limited execution environment rather than inheriting the full Netdata process environment. If the script depends on extra variables, set them explicitly in `environment` instead of relying on ambient shell state.
304 + - name: No built-in alerts are shipped yet
305 + description: |
306 + This preview collector does not currently install stock Netdata health alerts. Use the exposed `nagios.job.state` chart and the derived perfdata threshold-state charts to build alert rules that match your own checks.
307 + - name: Windows checks need an executable entry point
308 + description: |
309 + The collector runs the command named in `plugin` directly. On Windows, point `plugin` to an executable or to an interpreter such as `powershell.exe` and pass the script path in `args`.
310 + alerts: []
311 + metrics:
312 + folding:
313 + title: Metrics
314 + enabled: false
315 + description: |
316 + Each configured job exposes state and execution charts. If a check prints Nagios performance data, Netdata also creates additional value charts automatically from the values emitted by that check. For non-counter perfdata, Netdata also creates a derived threshold-state chart and uses `no_threshold` when the check does not define warning or critical ranges. Counter perfdata currently exposes only the value chart.
317 + availability: []
318 + scopes:
319 + - name: job
320 + description: These metrics refer to each configured check job.
321 + labels:
322 + - name: nagios_job
323 + description: Job name as defined in the configuration.
324 + metrics:
325 + - name: nagios.job.state
326 + description: Current job state for the check. Normal plugin results use `ok`, `warning`, `critical`, or `unknown`; collector-detected check timeouts use `timeout`; jobs blocked by `check_period` use `paused`.
327 + unit: state
328 + chart_type: line
329 + dimensions:
330 + - name: ok
331 + - name: warning
332 + - name: critical
333 + - name: unknown
334 + - name: timeout
335 + - name: paused
336 + - name: nagios.job.execution_duration
337 + description: Wall-clock duration recorded when the check runs. Non-due cycles report zero.
338 + unit: seconds
339 + chart_type: line
340 + dimensions:
341 + - name: duration
342 + - name: nagios.job.execution_cpu_total
343 + description: CPU time used when the check runs. Non-due cycles report zero. This chart is available on non-Windows platforms.
344 + unit: seconds
345 + chart_type: line
346 + dimensions:
347 + - name: total
348 + - name: nagios.job.execution_max_rss
349 + description: Peak RSS memory used when the check runs. Non-due cycles report zero. This chart is available on non-Windows platforms.
350 + unit: bytes
351 + chart_type: line
352 + dimensions:
353 + - name: rss
src/go/plugin/scripts.d/collector/nagios/perf_measureset.go new
+73
@@ -0,0 +1,73 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import "github.com/netdata/netdata/go/plugins/pkg/metrix"
6 +
7 +const (
8 + perfFieldValue = "value"
9 + perfThresholdStateNone = "no_threshold"
10 + perfThresholdStateOK = "ok"
11 + perfThresholdStateWarning = "warning"
12 + perfThresholdStateCritical = "critical"
13 +)
14 +
15 +var (
16 + perfMeasureSetFieldOrder = []string{
17 + perfFieldValue,
18 + }
19 + perfThresholdStateNames = []string{
20 + perfThresholdStateNone,
21 + perfThresholdStateOK,
22 + perfThresholdStateWarning,
23 + perfThresholdStateCritical,
24 + }
25 +)
26 +
27 +type perfValueMeasureSet struct {
28 + name string
29 + scriptName string
30 + unit string
31 + counter bool
32 + value metrix.SampleValue
33 +}
34 +
35 +type perfThresholdStateSet struct {
36 + name string
37 + scriptName string
38 + state string
39 +}
40 +
41 +type perfRouteResult struct {
42 + values []perfValueMeasureSet
43 + thresholdStates []perfThresholdStateSet
44 +}
45 +
46 +func perfMeasureSetFieldSpecs() []metrix.MeasureFieldSpec {
47 + return []metrix.MeasureFieldSpec{
48 + {Name: perfFieldValue, Float: true},
49 + }
50 +}
51 +
52 +func perfMeasureFieldFloat(field string) bool {
53 + return field == perfFieldValue
54 +}
55 +
56 +func defaultPerfMeasureSetValues() map[string]metrix.SampleValue {
57 + return map[string]metrix.SampleValue{perfFieldValue: 0}
58 +}
59 +
60 +func perfMeasureSetValues(value metrix.SampleValue) map[string]metrix.SampleValue {
61 + return map[string]metrix.SampleValue{perfFieldValue: value}
62 +}
63 +
64 +func perfThresholdStatePoint(active string) metrix.StateSetPoint {
65 + states := make(map[string]bool, len(perfThresholdStateNames))
66 + for _, state := range perfThresholdStateNames {
67 + states[state] = false
68 + }
69 + if active != "" {
70 + states[active] = true
71 + }
72 + return metrix.StateSetPoint{States: states}
73 +}
src/go/plugin/scripts.d/collector/nagios/perfdata_normalize.go new
+245
@@ -0,0 +1,245 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "crypto/sha1"
7 + "fmt"
8 + "math"
9 + "path/filepath"
10 + "strings"
11 +
12 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/collector/nagios/internal/output"
13 +)
14 +
15 +type perfUnitClass string
16 +
17 +const (
18 + perfClassTime perfUnitClass = "time"
19 + perfClassBytes perfUnitClass = "bytes"
20 + perfClassBits perfUnitClass = "bits"
21 + perfClassPercent perfUnitClass = "percent"
22 + perfClassCounter perfUnitClass = "counter"
23 + perfClassGeneric perfUnitClass = "generic"
24 +)
25 +
26 +type perfPreparedDatum struct {
27 + rawLabel string
28 + metricKey string
29 + class perfUnitClass
30 + value float64
31 + warn *output.ThresholdRange
32 + crit *output.ThresholdRange
33 +}
34 +
35 +func preparePerfDatum(datum output.PerfDatum) (perfPreparedDatum, bool) {
36 + rawLabel := strings.TrimSpace(datum.Label)
37 + if rawLabel == "" || !isFinite(datum.Value) {
38 + return perfPreparedDatum{}, false
39 + }
40 +
41 + metricKey := sanitizeMetricKey(rawLabel)
42 + if metricKey == "" {
43 + metricKey = "metric"
44 + }
45 +
46 + class, normalized := normalizePerfValue(datum.Unit, datum.Value)
47 + item := perfPreparedDatum{
48 + rawLabel: rawLabel,
49 + metricKey: metricKey,
50 + class: class,
51 + value: normalized,
52 + warn: normalizeThresholdRange(datum.Unit, datum.Warn),
53 + crit: normalizeThresholdRange(datum.Unit, datum.Crit),
54 + }
55 + return item, true
56 +}
57 +
58 +func normalizeOptionalFinite(unit string, v *float64) *float64 {
59 + if v == nil || !isFinite(*v) {
60 + return nil
61 + }
62 + _, normalized := normalizePerfValue(unit, *v)
63 + out := normalized
64 + return &out
65 +}
66 +
67 +func normalizeThresholdRange(unit string, rng *output.ThresholdRange) *output.ThresholdRange {
68 + if rng == nil {
69 + return nil
70 + }
71 + return &output.ThresholdRange{
72 + Inclusive: rng.Inclusive,
73 + Low: normalizeOptionalFinite(unit, rng.Low),
74 + High: normalizeOptionalFinite(unit, rng.High),
75 + }
76 +}
77 +
78 +func normalizePerfValue(unit string, value float64) (perfUnitClass, float64) {
79 + lower := strings.ToLower(strings.TrimSpace(unit))
80 + switch lower {
81 + case "s", "sec", "secs", "second", "seconds":
82 + return perfClassTime, value
83 + case "ms", "millisecond", "milliseconds":
84 + return perfClassTime, value / 1_000
85 + case "us", "µs", "usec", "microsecond", "microseconds":
86 + return perfClassTime, value / 1_000_000
87 + case "ns", "nanosecond", "nanoseconds":
88 + return perfClassTime, value / 1_000_000_000
89 + case "%":
90 + return perfClassPercent, value
91 + case "c":
92 + return perfClassCounter, value
93 + }
94 +
95 + if class, multiplier, ok := byteOrBitMultiplier(unit); ok {
96 + return class, value * multiplier
97 + }
98 + return perfClassGeneric, value
99 +}
100 +
101 +func byteOrBitMultiplier(unit string) (perfUnitClass, float64, bool) {
102 + base, ok := trimPerSecondSuffix(unit)
103 + if !ok || base == "" {
104 + return "", 0, false
105 + }
106 + class, prefix, ok := splitByteOrBitUnit(base)
107 + if !ok {
108 + return "", 0, false
109 + }
110 + multiplier, ok := byteMagnitude(prefix)
111 + if !ok {
112 + return "", 0, false
113 + }
114 + return class, multiplier, true
115 +}
116 +
117 +func trimPerSecondSuffix(unit string) (string, bool) {
118 + trimmed := strings.TrimSpace(unit)
119 + lower := strings.ToLower(trimmed)
120 + switch {
121 + case strings.HasSuffix(lower, "/s"):
122 + return strings.TrimSpace(trimmed[:len(trimmed)-2]), true
123 + case strings.HasSuffix(lower, "ps"):
124 + return strings.TrimSpace(trimmed[:len(trimmed)-2]), true
125 + default:
126 + return trimmed, true
127 + }
128 +}
129 +
130 +func splitByteOrBitUnit(unit string) (perfUnitClass, string, bool) {
131 + trimmed := strings.TrimSpace(unit)
132 + lower := strings.ToLower(trimmed)
133 + switch {
134 + case strings.HasSuffix(lower, "bytes"):
135 + return perfClassBytes, trimmed[:len(trimmed)-5], true
136 + case strings.HasSuffix(lower, "byte"):
137 + return perfClassBytes, trimmed[:len(trimmed)-4], true
138 + case strings.HasSuffix(lower, "bits"):
139 + return perfClassBits, trimmed[:len(trimmed)-4], true
140 + case strings.HasSuffix(lower, "bit"):
141 + return perfClassBits, trimmed[:len(trimmed)-3], true
142 + }
143 + if trimmed == "" {
144 + return "", "", false
145 + }
146 + switch last := trimmed[len(trimmed)-1]; last {
147 + case 'B':
148 + return perfClassBytes, trimmed[:len(trimmed)-1], true
149 + case 'b':
150 + return perfClassBits, trimmed[:len(trimmed)-1], true
151 + default:
152 + return "", "", false
153 + }
154 +}
155 +
156 +func byteMagnitude(prefix string) (float64, bool) {
157 + switch strings.ToLower(strings.TrimSpace(prefix)) {
158 + case "":
159 + return 1, true
160 + case "k":
161 + return 1_000, true
162 + case "m":
163 + return 1_000_000, true
164 + case "g":
165 + return 1_000_000_000, true
166 + case "t":
167 + return 1_000_000_000_000, true
168 + default:
169 + return 0, false
170 + }
171 +}
172 +
173 +func isFinite(v float64) bool {
174 + return !math.IsNaN(v) && !math.IsInf(v, 0)
175 +}
176 +
177 +func unitForClass(class perfUnitClass) string {
178 + switch class {
179 + case perfClassTime:
180 + return "seconds"
181 + case perfClassBytes:
182 + return "bytes"
183 + case perfClassBits:
184 + return "bits"
185 + case perfClassPercent:
186 + return "%"
187 + case perfClassCounter:
188 + return "c"
189 + default:
190 + return "generic"
191 + }
192 +}
193 +
194 +func sanitizeMetricKey(name string) string {
195 + lower := strings.ToLower(name)
196 + var b strings.Builder
197 + b.Grow(len(lower))
198 + lastUnderscore := false
199 + hasAlnum := false
200 + for _, r := range lower {
201 + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
202 + b.WriteRune(r)
203 + lastUnderscore = false
204 + hasAlnum = true
205 + continue
206 + }
207 + if r == '_' || r == '-' || isWhitespace(r) {
208 + if !lastUnderscore {
209 + b.WriteRune('_')
210 + lastUnderscore = true
211 + }
212 + continue
213 + }
214 + if !lastUnderscore {
215 + b.WriteRune('_')
216 + lastUnderscore = true
217 + }
218 + }
219 + result := b.String()
220 + if hasAlnum && result != "" {
221 + return result
222 + }
223 + sum := sha1.Sum([]byte(name))
224 + return fmt.Sprintf("id_%x", sum[:6])
225 +}
226 +
227 +func isWhitespace(r rune) bool {
228 + switch r {
229 + case ' ', '\t', '\n', '\r':
230 + return true
231 + }
232 + return false
233 +}
234 +
235 +func perfSourceFromPlugin(pluginPath string) string {
236 + base := filepath.Base(strings.TrimSpace(pluginPath))
237 + if base == "" || base == "." || base == string(filepath.Separator) {
238 + base = "script"
239 + }
240 + ext := filepath.Ext(base)
241 + if ext != "" {
242 + base = strings.TrimSuffix(base, ext)
243 + }
244 + return sanitizeMetricKey(base)
245 +}
src/go/plugin/scripts.d/collector/nagios/perfdata_router.go new
+149
@@ -0,0 +1,149 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "fmt"
7 + "math"
8 + "sort"
9 +
10 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/collector/nagios/internal/output"
11 +)
12 +
13 +const defaultPerfdataMetricKeyBudget = 64
14 +
15 +type perfdataRouter struct {
16 + maxPerJob int
17 +}
18 +
19 +func newPerfdataRouter(maxPerJob int) *perfdataRouter {
20 + if maxPerJob <= 0 {
21 + maxPerJob = defaultPerfdataMetricKeyBudget
22 + }
23 + return &perfdataRouter{
24 + maxPerJob: maxPerJob,
25 + }
26 +}
27 +
28 +func (r *perfdataRouter) route(pluginPath string, perf []output.PerfDatum) perfRouteResult {
29 + if len(perf) == 0 {
30 + return perfRouteResult{}
31 + }
32 + source := perfSourceFromPlugin(pluginPath)
33 +
34 + items := make([]perfPreparedDatum, 0, len(perf))
35 + for _, datum := range perf {
36 + item, ok := preparePerfDatum(datum)
37 + if !ok {
38 + continue
39 + }
40 + items = append(items, item)
41 + }
42 + if len(items) == 0 {
43 + return perfRouteResult{}
44 + }
45 +
46 + sort.SliceStable(items, func(i, j int) bool {
47 + if items[i].rawLabel == items[j].rawLabel {
48 + return items[i].metricKey < items[j].metricKey
49 + }
50 + return items[i].rawLabel < items[j].rawLabel
51 + })
52 +
53 + // Collision policy: keep the first final metric identity after lexical raw-label sort.
54 + deduped := make([]perfPreparedDatum, 0, len(items))
55 + seen := make(map[string]struct{}, len(items))
56 + for _, item := range items {
57 + identity := perfMetricIdentity(source, item)
58 + if _, ok := seen[identity]; ok {
59 + continue
60 + }
61 + seen[identity] = struct{}{}
62 + deduped = append(deduped, item)
63 + }
64 + if len(deduped) == 0 {
65 + return perfRouteResult{}
66 + }
67 +
68 + // Budget policy: deterministic cap by metric-key lexical order.
69 + sort.SliceStable(deduped, func(i, j int) bool {
70 + return deduped[i].metricKey < deduped[j].metricKey
71 + })
72 + if len(deduped) > r.maxPerJob {
73 + deduped = deduped[:r.maxPerJob]
74 + }
75 +
76 + result := perfRouteResult{
77 + values: make([]perfValueMeasureSet, 0, len(deduped)),
78 + thresholdStates: make([]perfThresholdStateSet, 0, len(deduped)),
79 + }
80 + for _, item := range deduped {
81 + base := perfMetricIdentity(source, item)
82 + result.values = append(result.values, perfValueMeasureSet{
83 + name: base,
84 + scriptName: source,
85 + unit: unitForClass(item.class),
86 + counter: item.class == perfClassCounter,
87 + value: item.value,
88 + })
89 +
90 + if item.class == perfClassCounter {
91 + // TODO: Add threshold-state handling for counter perfdata in a follow-up branch.
92 + // Nagios counter thresholds are authored against raw totals, while the value family
93 + // is emitted with counter semantics and flattened as deltas.
94 + continue
95 + }
96 +
97 + result.thresholdStates = append(result.thresholdStates, perfThresholdStateSet{
98 + name: perfThresholdStateMetricName(base),
99 + scriptName: source,
100 + state: thresholdStateForPerfDatum(item),
101 + })
102 + }
103 +
104 + return result
105 +}
106 +
107 +func perfMetricIdentity(source string, item perfPreparedDatum) string {
108 + return fmt.Sprintf("%s.%s_%s", source, item.class, item.metricKey)
109 +}
110 +
111 +func perfThresholdStateMetricName(base string) string {
112 + return base + "_threshold_state"
113 +}
114 +
115 +func thresholdStateForPerfDatum(item perfPreparedDatum) string {
116 + warnDefined := item.warn != nil
117 + critDefined := item.crit != nil
118 + switch {
119 + case !warnDefined && !critDefined:
120 + return perfThresholdStateNone
121 + case thresholdAlertable(item.value, item.crit):
122 + return perfThresholdStateCritical
123 + case thresholdAlertable(item.value, item.warn):
124 + return perfThresholdStateWarning
125 + default:
126 + return perfThresholdStateOK
127 + }
128 +}
129 +
130 +func thresholdAlertable(value float64, rng *output.ThresholdRange) bool {
131 + if rng == nil {
132 + return false
133 + }
134 +
135 + low := math.Inf(-1)
136 + if rng.Low != nil {
137 + low = *rng.Low
138 + }
139 + high := math.Inf(1)
140 + if rng.High != nil {
141 + high = *rng.High
142 + }
143 +
144 + inside := value >= low && value <= high
145 + if rng.Inclusive {
146 + return inside
147 + }
148 + return !inside
149 +}
src/go/plugin/scripts.d/collector/nagios/perfdata_router_test.go new
+225
@@ -0,0 +1,225 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "math"
7 + "strings"
8 + "testing"
9 +
10 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/collector/nagios/internal/output"
11 + "github.com/stretchr/testify/assert"
12 + "github.com/stretchr/testify/require"
13 +)
14 +
15 +const testPluginPath = "/opt/nagios-scripts/check_memory.pl"
16 +
17 +func TestPerfdataRouterRoutesAndCanonicalizesUnits(t *testing.T) {
18 + router := newPerfdataRouter(64)
19 +
20 + warnLow := 100.0
21 + warnHigh := 500.0
22 + got := router.route(testPluginPath, []output.PerfDatum{
23 + {
24 + Label: "latency",
25 + Unit: "ms",
26 + Value: 120,
27 + Warn: &output.ThresholdRange{
28 + Inclusive: true,
29 + Low: &warnLow,
30 + High: &warnHigh,
31 + },
32 + },
33 + {Label: "throughput", Unit: "KB", Value: 30},
34 + {Label: "traffic", Unit: "Mb", Value: 1.5},
35 + {Label: "free_pct", Unit: "%", Value: 40},
36 + {Label: "checks", Unit: "c", Value: 3},
37 + {Label: "custom", Unit: "widgets", Value: 7.25},
38 + })
39 +
40 + values := valueSampleMap(got.values)
41 + units := valueSampleUnits(got.values)
42 + thresholds := thresholdStateMap(got.thresholdStates)
43 +
44 + assertNear(t, values["check_memory.time_latency_value"], 0.12)
45 + assertNear(t, values["check_memory.bytes_throughput_value"], 30_000)
46 + assertNear(t, values["check_memory.bits_traffic_value"], 1_500_000)
47 + assertNear(t, values["check_memory.percent_free_pct_value"], 40)
48 + assertNear(t, values["check_memory.counter_checks_value"], 3)
49 + assertNear(t, values["check_memory.generic_custom_value"], 7.25)
50 +
51 + assertString(t, units["check_memory.time_latency_value"], "seconds")
52 + assertString(t, units["check_memory.bytes_throughput_value"], "bytes")
53 + assertString(t, units["check_memory.bits_traffic_value"], "bits")
54 + assertString(t, units["check_memory.percent_free_pct_value"], "%")
55 + assertString(t, units["check_memory.counter_checks_value"], "c")
56 + assertString(t, units["check_memory.generic_custom_value"], "generic")
57 +
58 + assertString(t, thresholds["check_memory.time_latency_threshold_state"], perfThresholdStateWarning)
59 + assertString(t, thresholds["check_memory.bytes_throughput_threshold_state"], perfThresholdStateNone)
60 + assertString(t, thresholds["check_memory.bits_traffic_threshold_state"], perfThresholdStateNone)
61 + assertString(t, thresholds["check_memory.percent_free_pct_threshold_state"], perfThresholdStateNone)
62 + assertString(t, thresholds["check_memory.generic_custom_threshold_state"], perfThresholdStateNone)
63 + _, hasCounterThreshold := thresholds["check_memory.counter_checks_threshold_state"]
64 + assert.False(t, hasCounterThreshold)
65 +}
66 +
67 +func TestPerfdataRouterPolicies(t *testing.T) {
68 + tests := map[string]struct {
69 + budget int
70 + prime []output.PerfDatum
71 + input []output.PerfDatum
72 + assert func(*testing.T, perfRouteResult)
73 + }{
74 + "collision keeps first lexical metric key": {
75 + budget: 64,
76 + input: []output.PerfDatum{
77 + {Label: "used-kb", Unit: "KB", Value: 2},
78 + {Label: "used kb", Unit: "KB", Value: 1},
79 + },
80 + assert: func(t *testing.T, got perfRouteResult) {
81 + t.Helper()
82 + samples := valueSampleMap(got.values)
83 + assertNear(t, samples["check_memory.bytes_used_kb_value"], 1_000)
84 + },
85 + },
86 + "budget drops metrics beyond cap": {
87 + budget: 2,
88 + input: []output.PerfDatum{
89 + {Label: "a", Unit: "c", Value: 1},
90 + {Label: "b", Unit: "c", Value: 2},
91 + {Label: "c", Unit: "c", Value: 3},
92 + },
93 + assert: func(t *testing.T, got perfRouteResult) {
94 + t.Helper()
95 + samples := valueSampleMap(got.values)
96 + _, okA := samples["check_memory.counter_a_value"]
97 + _, okB := samples["check_memory.counter_b_value"]
98 + _, okC := samples["check_memory.counter_c_value"]
99 + assert.True(t, okA)
100 + assert.True(t, okB)
101 + assert.False(t, okC)
102 + },
103 + },
104 + "budget keeps stable order for equal metric keys": {
105 + budget: 1,
106 + input: []output.PerfDatum{
107 + {Label: "latency", Unit: "KB", Value: 1},
108 + {Label: "latency", Unit: "ms", Value: 1},
109 + },
110 + assert: func(t *testing.T, got perfRouteResult) {
111 + t.Helper()
112 + samples := valueSampleMap(got.values)
113 + assertNear(t, samples["check_memory.bytes_latency_value"], 1_000)
114 + _, hasTime := samples["check_memory.time_latency_value"]
115 + assert.False(t, hasTime)
116 + },
117 + },
118 + "class changes create a new metric identity": {
119 + budget: 64,
120 + prime: []output.PerfDatum{
121 + {Label: "latency", Unit: "ms", Value: 10},
122 + },
123 + input: []output.PerfDatum{
124 + {Label: "latency", Unit: "KB", Value: 10},
125 + },
126 + assert: func(t *testing.T, got perfRouteResult) {
127 + t.Helper()
128 + samples := valueSampleMap(got.values)
129 + assertNear(t, samples["check_memory.bytes_latency_value"], 10_000)
130 + },
131 + },
132 + "invalid samples are ignored": {
133 + budget: 64,
134 + input: []output.PerfDatum{
135 + {Label: "", Unit: "ms", Value: 1},
136 + {Label: "bad", Unit: "ms", Value: math.NaN()},
137 + },
138 + assert: func(t *testing.T, got perfRouteResult) {
139 + t.Helper()
140 + assert.Empty(t, got.values)
141 + assert.Empty(t, got.thresholdStates)
142 + },
143 + },
144 + }
145 +
146 + for name, tc := range tests {
147 + t.Run(name, func(t *testing.T) {
148 + router := newPerfdataRouter(tc.budget)
149 + if len(tc.prime) > 0 {
150 + primed := router.route(testPluginPath, tc.prime)
151 + require.NotEmpty(t, primed.values)
152 + }
153 +
154 + got := router.route(testPluginPath, tc.input)
155 + tc.assert(t, got)
156 + })
157 + }
158 +}
159 +
160 +func TestSanitizeMetricKey(t *testing.T) {
161 + tests := map[string]struct {
162 + input string
163 + expect string
164 + expectPrefix string
165 + }{
166 + "plain text preserves words": {
167 + input: "Disk usage",
168 + expect: "disk_usage",
169 + },
170 + "trailing punctuation keeps boundary underscore": {
171 + input: "Disk usage /",
172 + expect: "disk_usage_",
173 + },
174 + "non alnum falls back to synthetic id": {
175 + input: "///",
176 + expectPrefix: "id_",
177 + },
178 + }
179 +
180 + for name, tc := range tests {
181 + t.Run(name, func(t *testing.T) {
182 + got := sanitizeMetricKey(tc.input)
183 + if tc.expect != "" {
184 + assert.Equal(t, tc.expect, got)
185 + }
186 + if tc.expectPrefix != "" {
187 + assert.True(t, strings.HasPrefix(got, tc.expectPrefix))
188 + }
189 + })
190 + }
191 +}
192 +
193 +func valueSampleMap(sets []perfValueMeasureSet) map[string]float64 {
194 + out := make(map[string]float64, len(sets))
195 + for _, set := range sets {
196 + out[set.name+"_"+perfFieldValue] = float64(set.value)
197 + }
198 + return out
199 +}
200 +
201 +func valueSampleUnits(sets []perfValueMeasureSet) map[string]string {
202 + out := make(map[string]string, len(sets))
203 + for _, set := range sets {
204 + out[set.name+"_"+perfFieldValue] = set.unit
205 + }
206 + return out
207 +}
208 +
209 +func thresholdStateMap(sets []perfThresholdStateSet) map[string]string {
210 + out := make(map[string]string, len(sets))
211 + for _, set := range sets {
212 + out[set.name] = set.state
213 + }
214 + return out
215 +}
216 +
217 +func assertNear(t *testing.T, got, want float64) {
218 + t.Helper()
219 + assert.InDelta(t, want, got, 1e-9)
220 +}
221 +
222 +func assertString(t *testing.T, got, want string) {
223 + t.Helper()
224 + assert.Equal(t, want, got)
225 +}
src/go/plugin/scripts.d/collector/nagios/runner.go new
+93
@@ -0,0 +1,93 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "context"
7 + "time"
8 +
9 + "github.com/netdata/netdata/go/plugins/logger"
10 + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/ndexec"
11 + outputpkg "github.com/netdata/netdata/go/plugins/plugin/scripts.d/collector/nagios/internal/output"
12 +)
13 +
14 +type checkRunner interface {
15 + Run(ctx context.Context, req checkRunRequest) (checkRunResult, error)
16 +}
17 +
18 +type checkRunRequest struct {
19 + Job JobConfig
20 + Vnode vnodeInfo
21 + MacroState macroState
22 + Now time.Time
23 + Log *logger.Logger
24 +}
25 +
26 +type checkRunResult struct {
27 + ExitCode int
28 + ServiceState string
29 + JobState string
30 + Parsed outputpkg.ParsedOutput
31 + Duration time.Duration
32 + Usage ndexec.ResourceUsage
33 +}
34 +
35 +type systemCheckRunner struct{}
36 +
37 +func (systemCheckRunner) Run(ctx context.Context, req checkRunRequest) (checkRunResult, error) {
38 + macros := buildMacroSet(req.Job, req.Vnode, req.MacroState, req.Now)
39 + args := macros.CommandArgs
40 + if len(args) == 0 {
41 + args = req.Job.Args
42 + }
43 +
44 + opts := ndexec.RunOptions{
45 + Env: buildRunEnv(req.Job.WorkingDirectory, req.Job.Environment, macros.Env),
46 + Dir: req.Job.WorkingDirectory,
47 + }
48 + timeout := req.Job.Timeout.Duration()
49 +
50 + execCtx := ctx
51 + cancel := func() {}
52 + if timeout > 0 {
53 + execCtx, cancel = context.WithTimeoutCause(ctx, timeout, errNagiosCheckTimeout)
54 + }
55 + defer cancel()
56 +
57 + startedAt := time.Now()
58 + // Temporary branch-specific direct execution path:
59 + // Nagios checks need job environment values and NAGIOS_* macros to reach the
60 + // real child process, but that does not currently survive the nd-run helper
61 + // boundary. The intended follow-up fix is to restore the helper path once
62 + // nd-run can preserve explicitly forwarded vars while still scrubbing the
63 + // ambient parent environment.
64 + output, _, usage, err := ndexec.RunDirectWithOptionsUsageContext(
65 + execCtx,
66 + req.Log,
67 + 0,
68 + opts,
69 + req.Job.Plugin,
70 + args...,
71 + )
72 +
73 + result := checkRunResult{
74 + ExitCode: exitCodeFromError(err),
75 + Duration: time.Since(startedAt),
76 + Usage: usage,
77 + }
78 + result.ServiceState = serviceStateFromExecution(result.ExitCode, err)
79 + result.JobState = jobStateFromExecution(result.ExitCode, err)
80 + result.Parsed = outputpkg.Parse(output)
81 + return result, err
82 +}
83 +
84 +type macroState struct {
85 + ServiceState string
86 + ServiceAttempt int
87 + ServiceMaxAttempts int
88 +}
89 +
90 +type vnodeInfo struct {
91 + Hostname string
92 + Labels map[string]string
93 +}
src/go/plugin/scripts.d/collector/nagios/schedule.go new
+52
@@ -0,0 +1,52 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "time"
7 +
8 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/timeperiod"
9 +)
10 +
11 +func (s *collectState) scheduleRegular(now, intervalBase time.Time, interval time.Duration) {
12 + s.nextAnniversary = advanceAnniversary(intervalBase, interval, now)
13 + s.nextDue = s.nextAnniversary
14 +}
15 +
16 +func (s *collectState) scheduleRetry(now time.Time, interval time.Duration) {
17 + s.nextAnniversary = advanceAnniversary(now, interval, now)
18 + s.nextDue = s.nextAnniversary
19 +}
20 +
21 +func (s *collectState) scheduleNextAllowed(now time.Time, interval time.Duration, period *timeperiod.Period) {
22 + next := time.Time{}
23 + if period != nil {
24 + next = period.NextAllowed(now)
25 + }
26 + if next.IsZero() {
27 + next = now.Add(intervalOrDefault(interval))
28 + }
29 + s.nextAnniversary = next
30 + s.nextDue = next
31 +}
32 +
33 +func advanceAnniversary(current time.Time, interval time.Duration, now time.Time) time.Time {
34 + interval = intervalOrDefault(interval)
35 + if current.IsZero() {
36 + current = now
37 + }
38 + next := current.Add(interval)
39 + if !next.After(now) {
40 + diff := now.Sub(next)
41 + steps := diff/interval + 1
42 + next = next.Add(time.Duration(steps) * interval)
43 + }
44 + return next
45 +}
46 +
47 +func intervalOrDefault(d time.Duration) time.Duration {
48 + if d <= 0 {
49 + return time.Minute
50 + }
51 + return d
52 +}
src/go/plugin/scripts.d/collector/nagios/state.go new
+150
@@ -0,0 +1,150 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "strings"
7 + "time"
8 +)
9 +
10 +type collectState struct {
11 + nextAnniversary time.Time
12 + nextDue time.Time
13 +
14 + serviceState string
15 + jobState string
16 + softAttempts int
17 + retrying bool
18 + maxAttempts int
19 +
20 + lastPerfValues []perfValueMeasureSet
21 + lastPerfThresholdStates []perfThresholdStateSet
22 +}
23 +
24 +func newCollectState(now time.Time, job JobConfig) collectState {
25 + return collectState{
26 + nextAnniversary: now,
27 + nextDue: now,
28 + serviceState: nagiosStateUnknown,
29 + jobState: nagiosStateUnknown,
30 + maxAttempts: max(job.MaxCheckAttempts, 1),
31 + }
32 +}
33 +
34 +func (s *collectState) due(now time.Time) bool {
35 + return s.nextDue.IsZero() || !s.nextDue.After(now)
36 +}
37 +
38 +func (s *collectState) currentServiceState() string {
39 + if s == nil || s.serviceState == "" {
40 + return nagiosStateUnknown
41 + }
42 + return s.serviceState
43 +}
44 +
45 +func (s *collectState) currentJobState() string {
46 + if s == nil || s.jobState == "" {
47 + return nagiosStateUnknown
48 + }
49 + return s.jobState
50 +}
51 +
52 +func (s *collectState) currentAttempt() int {
53 + if s == nil {
54 + return 1
55 + }
56 + if strings.EqualFold(s.serviceState, nagiosStateOK) || s.serviceState == "" {
57 + return 1
58 + }
59 + attempt := s.softAttempts
60 + if attempt <= 0 {
61 + attempt = 1
62 + }
63 + if s.retrying {
64 + attempt++
65 + }
66 + if attempt > s.maxAttempts {
67 + return s.maxAttempts
68 + }
69 + return attempt
70 +}
71 +
72 +func (s *collectState) macroState() macroState {
73 + return macroState{
74 + ServiceState: s.currentServiceState(),
75 + ServiceAttempt: s.currentAttempt(),
76 + ServiceMaxAttempts: s.maxAttempts,
77 + }
78 +}
79 +
80 +func (s *collectState) recordResult(serviceState, jobState string) {
81 + serviceState = normalizeState(serviceState)
82 + jobState = normalizeJobState(jobState)
83 + if serviceState == nagiosStateOK {
84 + s.softAttempts = 0
85 + s.retrying = false
86 + } else {
87 + s.softAttempts++
88 + if s.softAttempts >= s.maxAttempts {
89 + s.retrying = false
90 + } else {
91 + s.retrying = true
92 + }
93 + }
94 + s.serviceState = serviceState
95 + s.jobState = jobState
96 +}
97 +
98 +func (s *collectState) recordPeriodBlocked() {
99 + s.jobState = jobStatePaused
100 + for i := range s.lastPerfThresholdStates {
101 + s.lastPerfThresholdStates[i].state = ""
102 + }
103 +}
104 +
105 +func (s *collectState) rememberPerf(result perfRouteResult) {
106 + s.lastPerfValues = append(s.lastPerfValues[:0], result.values...)
107 + s.lastPerfThresholdStates = append(s.lastPerfThresholdStates[:0], result.thresholdStates...)
108 +}
109 +
110 +func (s *collectState) perfValueSets() []perfValueMeasureSet {
111 + return s.lastPerfValues
112 +}
113 +
114 +func (s *collectState) perfThresholdStates() []perfThresholdStateSet {
115 + return s.lastPerfThresholdStates
116 +}
117 +
118 +func (s *collectState) completeRun(now time.Time, serviceState, jobState string, perf perfRouteResult, job JobConfig) {
119 + s.recordResult(serviceState, jobState)
120 + s.rememberPerf(perf)
121 + if s.retrying {
122 + s.scheduleRetry(now, job.RetryInterval.Duration())
123 + return
124 + }
125 + s.scheduleRegular(now, s.nextAnniversary, job.CheckInterval.Duration())
126 +}
127 +
128 +func normalizeState(state string) string {
129 + switch strings.ToUpper(strings.TrimSpace(state)) {
130 + case nagiosStateOK:
131 + return nagiosStateOK
132 + case nagiosStateWarning:
133 + return nagiosStateWarning
134 + case nagiosStateCritical:
135 + return nagiosStateCritical
136 + default:
137 + return nagiosStateUnknown
138 + }
139 +}
140 +
141 +func normalizeJobState(state string) string {
142 + switch strings.ToUpper(strings.TrimSpace(state)) {
143 + case jobStateTimeout:
144 + return jobStateTimeout
145 + case jobStatePaused:
146 + return jobStatePaused
147 + default:
148 + return normalizeState(state)
149 + }
150 +}
src/go/plugin/scripts.d/collector/nagios/tests/plugins/check_mock_crit.sh renamed
src/go/plugin/scripts.d/collector/nagios/tests/plugins/check_mock_long.sh renamed
src/go/plugin/scripts.d/collector/nagios/tests/plugins/check_mock_macro.sh renamed
src/go/plugin/scripts.d/collector/nagios/tests/plugins/check_mock_ok.sh renamed
src/go/plugin/scripts.d/collector/nagios/tests/plugins/check_mock_slow.sh renamed
src/go/plugin/scripts.d/collector/nagios/tests/plugins/check_mock_warn.sh renamed
src/go/plugin/scripts.d/collector/nagios/v2_gate_test.go new
+451
@@ -0,0 +1,451 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package nagios
4 +
5 +import (
6 + "context"
7 + "math"
8 + "strings"
9 + "testing"
10 +
11 + "github.com/netdata/netdata/go/plugins/pkg/metrix"
12 + "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
13 + "github.com/netdata/netdata/go/plugins/plugin/framework/charttpl"
14 + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/collecttest"
15 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/collector/nagios/internal/output"
16 + "github.com/stretchr/testify/assert"
17 + "github.com/stretchr/testify/require"
18 +)
19 +
20 +const gatePluginPath = "/opt/nagios-scripts/check_gate.pl"
21 +
22 +func TestV2Gate_G1_TemplateCompileProof(t *testing.T) {
23 + templateYAML := New().ChartTemplateYAML()
24 + collecttest.AssertChartTemplateSchema(t, templateYAML)
25 +
26 + specYAML, err := charttpl.DecodeYAML([]byte(templateYAML))
27 + require.NoError(t, err)
28 + require.NoError(t, specYAML.Validate())
29 + _, err = chartengine.Compile(specYAML, 1)
30 + require.NoError(t, err)
31 +}
32 +
33 +func TestV2Gate_G2_PerfdataRouting(t *testing.T) {
34 + router := newPerfdataRouter(64)
35 + warnLow := 100.0
36 + warnHigh := 500.0
37 + critLow := 200.0
38 + critHigh := 900.0
39 + samples := router.route(gatePluginPath, []output.PerfDatum{
40 + {
41 + Label: "latency", Unit: "ms", Value: 120,
42 + Warn: &output.ThresholdRange{Inclusive: true, Low: &warnLow, High: &warnHigh},
43 + Crit: &output.ThresholdRange{Inclusive: true, Low: &critLow, High: &critHigh},
44 + },
45 + {Label: "throughput", Unit: "KB", Value: 30},
46 + {Label: "wire_rate", Unit: "kb", Value: 80},
47 + {Label: "free_pct", Unit: "%", Value: 40},
48 + {Label: "requests", Unit: "c", Value: 42},
49 + {Label: "custom", Unit: "widgets", Value: 3.14},
50 + {Label: "dup-one", Unit: "widgets", Value: 11}, // collides with dup_one
51 + {Label: "dup_one", Unit: "widgets", Value: 22},
52 + })
53 +
54 + byName := valueSampleMap(samples.values)
55 + byUnit := valueSampleUnits(samples.values)
56 + byThreshold := thresholdStateMap(samples.thresholdStates)
57 + assertNear(t, byName["check_gate.time_latency_value"], 0.12)
58 + assertNear(t, byName["check_gate.bytes_throughput_value"], 30000)
59 + assertNear(t, byName["check_gate.bits_wire_rate_value"], 80000)
60 + assertNear(t, byName["check_gate.percent_free_pct_value"], 40)
61 + assertNear(t, byName["check_gate.counter_requests_value"], 42)
62 + assertNear(t, byName["check_gate.generic_custom_value"], 3.14)
63 + assertNear(t, byName["check_gate.generic_dup_one_value"], 11)
64 + assertString(t, byThreshold["check_gate.time_latency_threshold_state"], perfThresholdStateWarning)
65 + assertString(t, byThreshold["check_gate.bytes_throughput_threshold_state"], perfThresholdStateNone)
66 + assertString(t, byThreshold["check_gate.bits_wire_rate_threshold_state"], perfThresholdStateNone)
67 + assertString(t, byThreshold["check_gate.percent_free_pct_threshold_state"], perfThresholdStateNone)
68 + assertString(t, byThreshold["check_gate.generic_custom_threshold_state"], perfThresholdStateNone)
69 + _, hasCounterThreshold := byThreshold["check_gate.counter_requests_threshold_state"]
70 + assert.False(t, hasCounterThreshold)
71 +
72 + assertString(t, byUnit["check_gate.time_latency_value"], "seconds")
73 + assertString(t, byUnit["check_gate.bytes_throughput_value"], "bytes")
74 + assertString(t, byUnit["check_gate.bits_wire_rate_value"], "bits")
75 + assertString(t, byUnit["check_gate.percent_free_pct_value"], "%")
76 + assertString(t, byUnit["check_gate.counter_requests_value"], "c")
77 + assertString(t, byUnit["check_gate.generic_custom_value"], "generic")
78 +
79 + store := metrix.NewCollectorStore()
80 + cc := gateCycleController(t, store)
81 + cc.BeginCycle()
82 + sm := store.Write().SnapshotMeter("nagios")
83 + labels := sm.LabelSet(
84 + metrix.Label{Key: "nagios_job", Value: "gate_job"},
85 + )
86 + for _, measureSet := range samples.values {
87 + fields := perfMeasureSetValues(measureSet.value)
88 + if measureSet.counter {
89 + sm.MeasureSetCounter(
90 + measureSet.name,
91 + metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
92 + metrix.WithChartFamily(measureSet.scriptName),
93 + metrix.WithUnit(measureSet.unit),
94 + ).ObserveTotalFields(fields, labels)
95 + continue
96 + }
97 + sm.MeasureSetGauge(
98 + measureSet.name,
99 + metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
100 + metrix.WithChartFamily(measureSet.scriptName),
101 + metrix.WithUnit(measureSet.unit),
102 + ).ObserveFields(fields, labels)
103 + }
104 + for _, thresholdState := range samples.thresholdStates {
105 + sm.WithLabelSet(labels).StateSet(
106 + thresholdState.name,
107 + metrix.WithStateSetMode(metrix.ModeBitSet),
108 + metrix.WithStateSetStates(perfThresholdStateNames...),
109 + metrix.WithChartFamily(thresholdState.scriptName),
110 + metrix.WithUnit("state"),
111 + ).Enable(thresholdState.state)
112 + }
113 + cc.CommitCycleSuccess()
114 +
115 + reader := store.Read(metrix.ReadFlatten())
116 + assertMetricMeta(t, reader, "nagios.check_gate.time_latency_value", "seconds", true)
117 + assertMetricMeta(t, reader, "nagios.check_gate.bytes_throughput_value", "bytes", true)
118 + assertMetricMeta(t, reader, "nagios.check_gate.bits_wire_rate_value", "bits", true)
119 + assertMetricMeta(t, reader, "nagios.check_gate.percent_free_pct_value", "%", true)
120 + assertMetricMeta(t, reader, "nagios.check_gate.counter_requests_value", "c", true)
121 + assertMetricMeta(t, reader, "nagios.check_gate.generic_custom_value", "generic", true)
122 + assertMetricMeta(t, reader, "nagios.check_gate.time_latency_threshold_state", "state", false)
123 + assertMetricChartFamily(t, reader, "nagios.check_gate.time_latency_value", "check_gate")
124 + assertMetricChartFamily(t, reader, "nagios.check_gate.time_latency_threshold_state", "check_gate")
125 + assertMetricValue(t, reader, "nagios.check_gate.time_latency_threshold_state", metrix.Labels{
126 + "nagios_job": "gate_job",
127 + "nagios.check_gate.time_latency_threshold_state": perfThresholdStateWarning,
128 + }, 1)
129 + assertSeriesKind(t, reader, "nagios.check_gate.time_latency_value", metrix.Labels{
130 + "nagios_job": "gate_job",
131 + metrix.MeasureSetFieldLabel: perfFieldValue,
132 + }, metrix.MetricKindGauge)
133 + assertSeriesKind(t, reader, "nagios.check_gate.counter_requests_value", metrix.Labels{
134 + "nagios_job": "gate_job",
135 + metrix.MeasureSetFieldLabel: perfFieldValue,
136 + }, metrix.MetricKindCounter)
137 +
138 + changedClass := router.route(gatePluginPath, []output.PerfDatum{
139 + {Label: "latency", Unit: "%", Value: 1}, // same label, different class => new identity
140 + })
141 + changedSamples := valueSampleMap(changedClass.values)
142 + assertNear(t, changedSamples["check_gate.percent_latency_value"], 1)
143 +}
144 +
145 +func TestV2Gate_G3_ChartLifecycleChurn(t *testing.T) {
146 + newHarness := func(t *testing.T) (*chartengine.Engine, metrix.CollectorStore, func(includeB bool) chartengine.Plan) {
147 + t.Helper()
148 + engine, err := chartengine.New()
149 + require.NoError(t, err)
150 + require.NoError(t, engine.LoadYAML([]byte(New().ChartTemplateYAML()), 1))
151 +
152 + store := metrix.NewCollectorStore()
153 + emit := func(includeB bool) chartengine.Plan {
154 + cc := gateCycleController(t, store)
155 + cc.BeginCycle()
156 + sm := store.Write().SnapshotMeter("nagios")
157 + ls := sm.LabelSet(
158 + metrix.Label{Key: "nagios_job", Value: "gate_job"},
159 + )
160 + aFields := defaultPerfMeasureSetValues()
161 + aFields[perfFieldValue] = 1
162 + sm.MeasureSetGauge(
163 + "check_gate.bytes_a",
164 + metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
165 + metrix.WithChartFamily("check_gate"),
166 + metrix.WithUnit("bytes"),
167 + ).ObserveFields(aFields, ls)
168 + if includeB {
169 + bFields := defaultPerfMeasureSetValues()
170 + bFields[perfFieldValue] = 2
171 + sm.MeasureSetGauge(
172 + "check_gate.bytes_b",
173 + metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
174 + metrix.WithChartFamily("check_gate"),
175 + metrix.WithUnit("bytes"),
176 + ).ObserveFields(bFields, ls)
177 + }
178 + cc.CommitCycleSuccess()
179 +
180 + plan, err := prepareCommittedPlan(engine, store.Read(metrix.ReadFlatten()))
181 + require.NoError(t, err)
182 + return plan
183 + }
184 + return engine, store, emit
185 + }
186 +
187 + t.Run("abort-cycle does not remove", func(t *testing.T) {
188 + engine, store, emit := newHarness(t)
189 +
190 + plan1 := emit(true)
191 + assert.NotZero(t, countActions[chartengine.CreateChartAction](plan1.Actions))
192 +
193 + cc := gateCycleController(t, store)
194 + cc.BeginCycle()
195 + sm := store.Write().SnapshotMeter("nagios")
196 + ls := sm.LabelSet(
197 + metrix.Label{Key: "nagios_job", Value: "gate_job"},
198 + )
199 + aFields := defaultPerfMeasureSetValues()
200 + aFields[perfFieldValue] = 1
201 + sm.MeasureSetGauge(
202 + "check_gate.bytes_a",
203 + metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
204 + metrix.WithChartFamily("check_gate"),
205 + metrix.WithUnit("bytes"),
206 + ).ObserveFields(aFields, ls)
207 + cc.AbortCycle()
208 + assert.Equal(t, metrix.CollectStatusFailed, store.Read(metrix.ReadRaw()).CollectMeta().LastAttemptStatus)
209 +
210 + planAbort, err := prepareCommittedPlan(engine, store.Read(metrix.ReadFlatten()))
211 + require.NoError(t, err)
212 + assert.Zero(t, removeActionsCount(planAbort.Actions))
213 + })
214 +
215 + t.Run("failed-attempt gap contributes to expiry aging", func(t *testing.T) {
216 + _, store, emit := newHarness(t)
217 +
218 + plan1 := emit(true)
219 + assert.NotZero(t, countActions[chartengine.CreateChartAction](plan1.Actions))
220 + plan2 := emit(false)
221 + assert.Zero(t, removeActionsCount(plan2.Actions))
222 + assertPlanHasUpdateForTarget(t, plan2, "nagios.check_gate.bytes_a")
223 + assertPlanHasNoRemoveForTarget(t, plan2, "nagios.check_gate.bytes_b")
224 +
225 + cc := gateCycleController(t, store)
226 + cc.BeginCycle()
227 + sm := store.Write().SnapshotMeter("nagios")
228 + ls := sm.LabelSet(
229 + metrix.Label{Key: "nagios_job", Value: "gate_job"},
230 + )
231 + aFields := defaultPerfMeasureSetValues()
232 + aFields[perfFieldValue] = 1
233 + sm.MeasureSetGauge(
234 + "check_gate.bytes_a",
235 + metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
236 + metrix.WithChartFamily("check_gate"),
237 + metrix.WithUnit("bytes"),
238 + ).ObserveFields(aFields, ls)
239 + cc.AbortCycle()
240 + assert.Equal(t, metrix.CollectStatusFailed, store.Read(metrix.ReadRaw()).CollectMeta().LastAttemptStatus)
241 +
242 + plan3 := emit(false)
243 + assert.NotZero(t, removeActionsCount(plan3.Actions))
244 + assertPlanHasUpdateAndRemoveForTargets(t, plan3,
245 + "nagios.check_gate.bytes_a",
246 + "nagios.check_gate.bytes_b",
247 + )
248 + plan4 := emit(false)
249 + assert.Zero(t, removeActionsCount(plan4.Actions))
250 + })
251 +}
252 +
253 +func TestV2Gate_G5_ScalingPrecisionEquivalence(t *testing.T) {
254 + tests := map[string]struct {
255 + unit string
256 + raw float64
257 + expectedUnit string
258 + }{
259 + "time": {unit: "ms", raw: 5.2, expectedUnit: "seconds"},
260 + "bytes": {unit: "KB", raw: 1024, expectedUnit: "bytes"},
261 + "bits": {unit: "kb", raw: 8, expectedUnit: "bits"},
262 + "percent": {unit: "%", raw: 99.5, expectedUnit: "%"},
263 + "counter": {unit: "c", raw: 42, expectedUnit: "c"},
264 + "generic": {unit: "widgets", raw: 3.14, expectedUnit: "generic"},
265 + }
266 +
267 + for name, tc := range tests {
268 + t.Run(name, func(t *testing.T) {
269 + router := newPerfdataRouter(64)
270 + displayV1 := legacyDisplayValue(tc.unit, tc.raw)
271 +
272 + samples := router.route(gatePluginPath, []output.PerfDatum{
273 + {Label: "sample", Unit: tc.unit, Value: tc.raw},
274 + })
275 + var (
276 + candidate float64
277 + candidateKey string
278 + candidateUnit string
279 + )
280 + found := false
281 + for key, value := range valueSampleMap(samples.values) {
282 + if len(key) >= 6 && key[len(key)-6:] == "_value" {
283 + candidate = value
284 + candidateKey = key
285 + candidateUnit = valueSampleUnits(samples.values)[key]
286 + found = true
287 + break
288 + }
289 + }
290 + require.True(t, found, "missing routed value sample")
291 +
292 + if displayV1 == 0 {
293 + assert.InDelta(t, displayV1, candidate, 1e-9)
294 + return
295 + }
296 + rel := math.Abs(candidate-displayV1) / math.Abs(displayV1)
297 + assert.LessOrEqual(t, rel, 0.001)
298 + assert.Equal(t, tc.expectedUnit, candidateUnit)
299 + assert.True(t, perfMeasureFieldFloat(perfFieldValue))
300 +
301 + store := metrix.NewCollectorStore()
302 + cc := gateCycleController(t, store)
303 + cc.BeginCycle()
304 + sm := store.Write().SnapshotMeter("nagios")
305 + for _, measureSet := range samples.values {
306 + fields := perfMeasureSetValues(measureSet.value)
307 + if measureSet.counter {
308 + sm.MeasureSetCounter(
309 + measureSet.name,
310 + metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
311 + metrix.WithChartFamily(measureSet.scriptName),
312 + metrix.WithUnit(measureSet.unit),
313 + ).ObserveTotalFields(fields, sm.LabelSet())
314 + continue
315 + }
316 + sm.MeasureSetGauge(
317 + measureSet.name,
318 + metrix.WithMeasureSetFields(perfMeasureSetFieldSpecs()...),
319 + metrix.WithChartFamily(measureSet.scriptName),
320 + metrix.WithUnit(measureSet.unit),
321 + ).ObserveFields(fields, sm.LabelSet())
322 + }
323 + cc.CommitCycleSuccess()
324 + flat := store.Read(metrix.ReadFlatten())
325 + assertMetricMeta(t, flat, "nagios."+candidateKey, tc.expectedUnit, true)
326 + assertMetricChartFamily(t, flat, "nagios."+candidateKey, "check_gate")
327 + })
328 + }
329 +}
330 +
331 +func countActions[T any](actions []chartengine.EngineAction) int {
332 + n := 0
333 + for _, action := range actions {
334 + if _, ok := action.(T); ok {
335 + n++
336 + }
337 + }
338 + return n
339 +}
340 +
341 +func removeActionsCount(actions []chartengine.EngineAction) int {
342 + return countActions[chartengine.RemoveChartAction](actions) + countActions[chartengine.RemoveDimensionAction](actions)
343 +}
344 +
345 +func gateCycleController(t *testing.T, store metrix.CollectorStore) metrix.CycleController {
346 + t.Helper()
347 + managed, ok := metrix.AsCycleManagedStore(store)
348 + require.True(t, ok)
349 + return managed.CycleController()
350 +}
351 +
352 +func prepareCommittedPlan(engine *chartengine.Engine, reader metrix.Reader) (chartengine.Plan, error) {
353 + attempt, err := engine.PreparePlan(reader)
354 + if err != nil {
355 + return chartengine.Plan{}, err
356 + }
357 + defer attempt.Abort()
358 +
359 + plan := attempt.Plan()
360 + if err := attempt.Commit(); err != nil {
361 + return chartengine.Plan{}, err
362 + }
363 + return plan, nil
364 +}
365 +
366 +func assertMetricMeta(t *testing.T, reader metrix.Reader, metricName, unit string, isFloat bool) {
367 + t.Helper()
368 + meta, ok := reader.MetricMeta(metricName)
369 + require.True(t, ok, "missing metric metadata for %q", metricName)
370 + assert.Equal(t, unit, meta.Unit)
371 + assert.Equal(t, isFloat, meta.Float)
372 +}
373 +
374 +func assertMetricChartFamily(t *testing.T, reader metrix.Reader, metricName, chartFamily string) {
375 + t.Helper()
376 + meta, ok := reader.MetricMeta(metricName)
377 + require.True(t, ok, "missing metric metadata for %q", metricName)
378 + assert.Equal(t, chartFamily, meta.ChartFamily)
379 +}
380 +
381 +func assertSeriesKind(t *testing.T, reader metrix.Reader, metricName string, labels metrix.Labels, want metrix.MetricKind) {
382 + t.Helper()
383 + meta, ok := reader.SeriesMeta(metricName, labels)
384 + require.True(t, ok, "missing series metadata for %q with labels %v", metricName, labels)
385 + assert.Equal(t, want, meta.Kind)
386 +}
387 +
388 +func assertPlanHasUpdateAndRemoveForTargets(t *testing.T, plan chartengine.Plan, updateMetricPrefix, removeMetricPrefix string) {
389 + t.Helper()
390 +
391 + hasUpdate := false
392 + hasRemove := false
393 +
394 + for _, action := range plan.Actions {
395 + switch a := action.(type) {
396 + case chartengine.UpdateChartAction:
397 + if strings.HasPrefix(a.ChartID, updateMetricPrefix) {
398 + hasUpdate = true
399 + }
400 + case chartengine.RemoveDimensionAction:
401 + if strings.HasPrefix(a.ChartID, removeMetricPrefix) {
402 + hasRemove = true
403 + }
404 + case chartengine.RemoveChartAction:
405 + if strings.HasPrefix(a.ChartID, removeMetricPrefix) {
406 + hasRemove = true
407 + }
408 + }
409 + }
410 +
411 + assert.True(t, hasUpdate, "expected update action for %q", updateMetricPrefix)
412 + assert.True(t, hasRemove, "expected remove action for %q", removeMetricPrefix)
413 +}
414 +
415 +func assertPlanHasUpdateForTarget(t *testing.T, plan chartengine.Plan, updateMetricPrefix string) {
416 + t.Helper()
417 + for _, action := range plan.Actions {
418 + update, ok := action.(chartengine.UpdateChartAction)
419 + if !ok {
420 + continue
421 + }
422 + if strings.HasPrefix(update.ChartID, updateMetricPrefix) {
423 + return
424 + }
425 + }
426 + assert.FailNow(t, "expected update action", "%q", updateMetricPrefix)
427 +}
428 +
429 +func assertPlanHasNoRemoveForTarget(t *testing.T, plan chartengine.Plan, removeMetricPrefix string) {
430 + t.Helper()
431 + for _, action := range plan.Actions {
432 + switch a := action.(type) {
433 + case chartengine.RemoveDimensionAction:
434 + if strings.HasPrefix(a.ChartID, removeMetricPrefix) {
435 + assert.FailNow(t, "unexpected remove dimension action", "%q", removeMetricPrefix)
436 + }
437 + case chartengine.RemoveChartAction:
438 + if strings.HasPrefix(a.ChartID, removeMetricPrefix) {
439 + assert.FailNow(t, "unexpected remove chart action", "%q", removeMetricPrefix)
440 + }
441 + }
442 + }
443 +}
444 +
445 +func TestV2Gate_SmokeCollect(t *testing.T) {
446 + coll := New()
447 + coll.runner = &fakeRunner{}
448 + coll.Config.JobConfig.Plugin = "/bin/true"
449 + coll.Config.JobConfig.Name = "smoke"
450 + require.NoError(t, coll.Check(context.Background()))
451 +}
src/go/plugin/scripts.d/config/scripts.d.conf
-2
@@ -7,5 +7,3 @@ default_run: yes
7
8 modules:
9 nagios: yes
10 - zabbix: yes
11 - scheduler: yes
src/go/plugin/scripts.d/config/scripts.d/nagios.conf new
+19
@@ -0,0 +1,19 @@
1 +## All available configuration options, their descriptions and default values:
2 +## https://github.com/netdata/netdata/tree/master/src/go/plugin/scripts.d/README.md
3 +
4 +#jobs:
5 +# - name: example_check
6 +# plugin: /usr/lib/nagios/plugins/check_ping
7 +# args: ["-H", "127.0.0.1", "-w", "100.0,20%", "-c", "200.0,40%"]
8 +# timeout: 60s
9 +# check_interval: 1m
10 +# retry_interval: 30s
11 +# max_check_attempts: 3
12 +# check_period: 24x7
13 +# time_periods:
14 +# - name: 24x7
15 +# alias: Always on
16 +# rules:
17 +# - type: weekly
18 +# days: [sunday, monday, tuesday, wednesday, thursday, friday, saturday]
19 +# ranges: ["00:00-24:00"]
src/go/plugin/scripts.d/config/scripts.d/scheduler.conf deleted
-11
@@ -1,11 +0,0 @@
1 -# Default scheduler definition so scripts.d exposes the built-in worker pool in dyncfg.
2 -jobs:
3 - - name: default
4 - workers: 50
5 - queue_size: 128
6 - logging:
7 - enabled: true
8 - otlp:
9 - endpoint: 127.0.0.1:4317
10 - tls: false
11 - timeout: 5s
src/go/plugin/scripts.d/modules/nagios/README.md deleted
-1
@@ -1 +0,0 @@
1 -integrations/nagios_plugins.md
\ No newline at end of file
src/go/plugin/scripts.d/modules/nagios/config_schema.json deleted
-224
@@ -1,224 +0,0 @@
1 -{
2 - "jsonSchema": {
3 - "$schema": "http://json-schema.org/draft-07/schema#",
4 - "title": "scripts.d Nagios job configuration",
5 - "type": "object",
6 - "additionalProperties": false,
7 - "properties": {
8 - "plugin": {
9 - "title": "Plugin path",
10 - "description": "Absolute path to the Nagios plugin executable.",
11 - "type": "string"
12 - },
13 - "vnode": {
14 - "title": "Virtual node",
15 - "description": "Virtual node GUID/name to associate with this job.",
16 - "type": "string"
17 - },
18 - "scheduler": {
19 - "title": "Scheduler name",
20 - "description": "Name of the scheduler that should execute this job (defaults to \"default\").",
21 - "type": "string"
22 - },
23 - "update_every": {
24 - "title": "Update every",
25 - "description": "Execution interval in seconds. Leave empty to use the module default.",
26 - "type": "integer",
27 - "minimum": 1
28 - },
29 - "autodetection_retry": {
30 - "title": "Detection retry",
31 - "description": "Retry interval in seconds when auto-detection fails (0 disables retries).",
32 - "type": "integer",
33 - "minimum": 0
34 - },
35 - "args": {
36 - "title": "Arguments",
37 - "description": "Command line arguments passed to the plugin (macros allowed).",
38 - "type": "array",
39 - "items": {
40 - "type": "string"
41 - }
42 - },
43 - "arg_values": {
44 - "title": "ARGn values",
45 - "description": "Values bound to $ARGn$ macros (max 32).",
46 - "type": "array",
47 - "items": {
48 - "type": "string"
49 - },
50 - "maxItems": 32
51 - },
52 - "environment": {
53 - "title": "Environment variables",
54 - "description": "Additional environment variables for the plugin.",
55 - "type": "object",
56 - "additionalProperties": {
57 - "type": "string"
58 - }
59 - },
60 - "user_macros": {
61 - "title": "User macros",
62 - "description": "Key/value pairs exposed as $USERn$ macros (e.g. USER1 becomes $USER1$).",
63 - "type": "object",
64 - "additionalProperties": {
65 - "type": "string"
66 - }
67 - },
68 - "custom_vars": {
69 - "title": "Custom service vars",
70 - "description": "Values exported as $_SERVICE* macros.",
71 - "type": "object",
72 - "additionalProperties": {
73 - "type": "string"
74 - }
75 - },
76 - "timeout": {
77 - "title": "Timeout",
78 - "description": "Execution timeout (duration, e.g. 60s).",
79 - "type": "string",
80 - "pattern": "^([0-9]+(\\\\.[0-9]+)?(ns|us|ms|s|m|h|d))+$"
81 - },
82 - "timeout_state": {
83 - "title": "Timeout state",
84 - "description": "State reported when a timeout occurs.",
85 - "type": "string",
86 - "enum": [
87 - "critical",
88 - "warning",
89 - "unknown"
90 - ]
91 - },
92 - "check_interval": {
93 - "title": "Check interval",
94 - "description": "Base scheduling interval (duration).",
95 - "type": "string",
96 - "pattern": "^([0-9]+(\\\\.[0-9]+)?(ns|us|ms|s|m|h|d))+$"
97 - },
98 - "retry_interval": {
99 - "title": "Retry interval",
100 - "description": "Interval between soft retries (duration).",
101 - "type": "string",
102 - "pattern": "^([0-9]+(\\\\.[0-9]+)?(ns|us|ms|s|m|h|d))+$"
103 - },
104 - "max_check_attempts": {
105 - "title": "Max check attempts",
106 - "description": "Number of soft attempts before a hard state is emitted.",
107 - "type": "integer",
108 - "minimum": 1
109 - },
110 - "inter_check_jitter": {
111 - "title": "Inter-check jitter",
112 - "description": "Random jitter applied to the interval (duration).",
113 - "type": "string",
114 - "pattern": "^([0-9]+(\\\\.[0-9]+)?(ns|us|ms|s|m|h|d))+$"
115 - },
116 - "working_directory": {
117 - "title": "Working directory",
118 - "description": "Directory from which the plugin will be executed.",
119 - "type": "string"
120 - },
121 - "notes": {
122 - "title": "Notes",
123 - "description": "Free-form notes attached to this job.",
124 - "type": "string"
125 - }
126 - },
127 - "required": [
128 - "plugin"
129 - ]
130 - },
131 - "uiSchema": {
132 - "uiOptions": {
133 - "fullPage": true
134 - },
135 - "ui:flavour": "tabs",
136 - "ui:options": {
137 - "tabs": [
138 - {
139 - "title": "General",
140 - "fields": [
141 - "plugin",
142 - "scheduler",
143 - "vnode",
144 - "working_directory",
145 - "notes"
146 - ]
147 - },
148 - {
149 - "title": "Arguments",
150 - "fields": [
151 - "args",
152 - "arg_values",
153 - "custom_vars"
154 - ]
155 - },
156 - {
157 - "title": "Timing & Retries",
158 - "fields": [
159 - "update_every",
160 - "autodetection_retry",
161 - "timeout",
162 - "timeout_state",
163 - "check_interval",
164 - "retry_interval",
165 - "max_check_attempts",
166 - "inter_check_jitter",
167 - "check_period"
168 - ]
169 - },
170 - {
171 - "title": "Environment",
172 - "fields": [
173 - "environment"
174 - ]
175 - }
176 - ]
177 - },
178 - "plugin": {
179 - "ui:placeholder": "/usr/lib/nagios/plugins/check_ssh"
180 - },
181 - "scheduler": {
182 - "ui:placeholder": "default"
183 - },
184 - "vnode": {
185 - "ui:placeholder": "optional-vnode"
186 - },
187 - "args": {
188 - "ui:listFlavour": "list",
189 - "ui:help": "Command-line arguments passed to the plugin. Macros such as {HOST.NAME}, {IP}, or $ARGn are supported."
190 - },
191 - "arg_values": {
192 - "ui:listFlavour": "list",
193 - "ui:help": "Values injected into sequential $ARGn placeholders."
194 - },
195 - "environment": {
196 - "ui:help": "Environment variables exported before executing the plugin (key \u2192 value)."
197 - },
198 - "custom_vars": {
199 - "ui:help": "Defines $_SERVICE* macros exposed to legacy scripts."
200 - },
201 - "timeout_state": {
202 - "ui:widget": "radio",
203 - "ui:options": {
204 - "inline": true
205 - }
206 - },
207 - "timeout": {
208 - "ui:placeholder": "30s",
209 - "ui:help": "Duration string (e.g. 10s, 1m30s)."
210 - },
211 - "check_interval": {
212 - "ui:placeholder": "1m"
213 - },
214 - "retry_interval": {
215 - "ui:placeholder": "30s"
216 - },
217 - "inter_check_jitter": {
218 - "ui:placeholder": "5s"
219 - },
220 - "check_period": {
221 - "ui:help": "Matches a named time period defined in the module configuration (defaults to 24x7)."
222 - }
223 - }
224 -}
src/go/plugin/scripts.d/modules/nagios/integrations/nagios_plugins.md deleted
-313
@@ -1,313 +0,0 @@
1 -<!--startmeta
2 -custom_edit_url: "https://github.com/netdata/netdata/edit/master/src/go/plugin/scripts.d/modules/nagios/README.md"
3 -meta_yaml: "https://github.com/netdata/netdata/edit/master/src/go/plugin/scripts.d/modules/nagios/metadata.yaml"
4 -sidebar_label: "Nagios Plugins"
5 -learn_status: "Published"
6 -learn_rel_path: "Collecting Metrics/Synthetic Testing"
7 -keywords: ['nagios', 'plugins', 'checks', 'scripts', 'monitoring']
8 -message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE COLLECTOR'S metadata.yaml FILE"
9 -endmeta-->
10 -
11 -# Nagios Plugins
12 -
13 -
14 -<img src="https://netdata.cloud/img/nagios.png" width="150"/>
15 -
16 -
17 -Plugin: scripts.d.plugin
18 -Module: nagios
19 -
20 -<img src="https://img.shields.io/badge/maintained%20by-Netdata-%2300ab44" />
21 -
22 -## Overview
23 -
24 -This module runs unmodified [Nagios plugins](https://www.nagios-plugins.org/) inside Netdata without any changes to the plugins themselves.
25 -
26 -For each configured job it collects:
27 -
28 -- **Check state**: OK / WARNING / CRITICAL / UNKNOWN (with soft/hard state tracking).
29 -- **Performance data**: Every `label=value;warn;crit;min;max` metric emitted by the plugin is parsed and charted automatically, with unit normalization where possible.
30 -- **Execution telemetry**: Runtime duration, scheduling latency, CPU time, peak RSS memory, and disk I/O per job.
31 -- **Scheduler health**: Running / queued / scheduled job counts and throughput rates.
32 -
33 -
34 -Jobs are executed via `nd-run` (the Netdata unprivileged helper) at the configured `check_interval`.
35 -Standard Nagios macros (`$HOSTADDRESS$`, `$ARG1$`, `$USERn$`, etc.) are expanded before execution.
36 -Plugin output is parsed according to the [Nagios Plugin API](https://nagios-plugins.org/doc/guidelines.html):
37 -the first line provides the status and optional performance data after the `|` separator.
38 -
39 -
40 -This collector is supported on all platforms.
41 -
42 -This collector supports collecting metrics from multiple instances of this integration, including remote instances.
43 -
44 -Plugins run as the `netdata` user via `nd-run`. If a plugin requires elevated privileges, configure it through `ndsudo` or adjust filesystem permissions accordingly.
45 -
46 -
47 -### Default Behavior
48 -
49 -#### Auto-Detection
50 -
51 -No auto-detection. Each job must be explicitly configured with a `plugin` path pointing to the Nagios plugin executable.
52 -
53 -
54 -#### Limits
55 -
56 -The default configuration for this integration does not impose any limits on data collection.
57 -
58 -#### Performance Impact
59 -
60 -Each job spawns a subprocess via `nd-run`. Resource usage (CPU, memory, disk I/O) is tracked per execution and exposed as telemetry charts.
61 -
62 -
63 -## Metrics
64 -
65 -Metrics grouped by *scope*.
66 -
67 -The scope defines the instance that the metric belongs to. An instance is uniquely identified by a set of labels.
68 -
69 -### Virtual Node Label Conventions
70 -
71 -When a job references a `vnode`, the module reads Nagios macros from the virtual node's **labels** using prefix conventions:
72 -
73 -| Label key | Nagios macro | Environment variable | Description |
74 -|-----------|-------------|---------------------|-------------|
75 -| `_address` | `$HOSTADDRESS$` | `NAGIOS_HOSTADDRESS` | IP address or DNS name of the host |
76 -| `_alias` | `$HOSTALIAS$` | `NAGIOS_HOSTALIAS` | Human-readable host alias |
77 -| `_VARNAME` | `$_HOSTVARNAME$` | `NAGIOS__HOSTVARNAME` | Custom host variable (any `_` prefixed key except `_address` and `_alias`) |
78 -| `key` | `$_HOSTLABEL_KEY$` | `NAGIOS__HOSTLABEL_KEY` | Regular label (no `_` prefix) |
79 -
80 -Example vnode configuration (`/etc/netdata/vnodes/hosts.yaml`):
81 -
82 -```yaml
83 -- hostname: web-server-1
84 - guid: 12345678-1234-1234-1234-123456789abc
85 - labels:
86 - _address: "192.168.1.10"
87 - _alias: "Web Server 1"
88 - _DATACENTER: "us-east-1"
89 - role: "frontend"
90 - environment: "production"
91 -```
92 -
93 -This produces:
94 -
95 -| Macro | Value |
96 -|-------|-------|
97 -| `$HOSTADDRESS$` | `192.168.1.10` |
98 -| `$HOSTALIAS$` | `Web Server 1` |
99 -| `$_HOSTDATACENTER$` | `us-east-1` |
100 -| `$_HOSTLABEL_ROLE$` | `frontend` |
101 -| `$_HOSTLABEL_ENVIRONMENT$` | `production` |
102 -
103 -
104 -### Per job
105 -
106 -Metrics for each configured Nagios plugin job.
107 -
108 -Labels:
109 -
110 -| Label | Description |
111 -|:-----------|:----------------|
112 -| nagios_job | Job name as defined in the configuration. |
113 -| nagios_plugin | Basename of the plugin executable. |
114 -| nagios_vnode | Virtual node associated with the job (if any). |
115 -| nagios_scheduler | Scheduler executing the job. |
116 -
117 -Metrics:
118 -
119 -| Metric | Dimensions | Unit |
120 -|:------|:----------|:----|
121 -| nagios.jobs.state | ok, warning, critical, unknown | state |
122 -| nagios.jobs.runtime | running, retrying, skipped | boolean |
123 -| nagios.jobs.latency | duration | seconds |
124 -| nagios.jobs.cpu | cpu | seconds |
125 -| nagios.jobs.mem | rss | bytes |
126 -| nagios.jobs.disk | read, write | bytes |
127 -
128 -### Per perfdata
129 -
130 -Metrics extracted from the plugin's performance data output.
131 -Each `label=value;warn;crit;min;max` entry produces a separate chart.
132 -
133 -
134 -Labels:
135 -
136 -| Label | Description |
137 -|:-----------|:----------------|
138 -| nagios_job | Job name. |
139 -| nagios_plugin | Plugin executable. |
140 -| perf_label | Performance data label as emitted by the plugin. |
141 -
142 -Metrics:
143 -
144 -| Metric | Dimensions | Unit |
145 -|:------|:----------|:----|
146 -| nagios.{script}.{label} | value | varies |
147 -
148 -### Per scheduler
149 -
150 -Scheduler-level metrics.
151 -
152 -Labels:
153 -
154 -| Label | Description |
155 -|:-----------|:----------------|
156 -| nagios_scheduler | Scheduler name. |
157 -
158 -Metrics:
159 -
160 -| Metric | Dimensions | Unit |
161 -|:------|:----------|:----|
162 -| nagios.scheduler.jobs | running, queued, scheduled | jobs |
163 -| nagios.scheduler.rate | started, finished, skipped | jobs |
164 -| nagios.scheduler.next | next | seconds |
165 -
166 -
167 -
168 -## Alerts
169 -
170 -There are no alerts configured by default for this integration.
171 -
172 -
173 -## Setup
174 -
175 -
176 -### Prerequisites
177 -
178 -#### Install Nagios plugins
179 -
180 -Install the plugins you want to run. Most distributions provide packages:
181 -
182 -```bash
183 -# Debian/Ubuntu
184 -apt install nagios-plugins
185 -
186 -# RHEL/CentOS/Fedora
187 -dnf install nagios-plugins-all
188 -```
189 -
190 -You can also use any script or binary that follows the [Nagios Plugin API](https://nagios-plugins.org/doc/guidelines.html).
191 -
192 -
193 -
194 -### Configuration
195 -
196 -#### Options
197 -
198 -Each job defines a single Nagios plugin execution. Jobs are listed under the `jobs` key.
199 -
200 -
201 -<details open><summary>Config options</summary>
202 -
203 -
204 -
205 -| Group | Option | Description | Default | Required |
206 -|:------|:-----|:------------|:--------|:---------:|
207 -| **General** | plugin | Absolute path to the Nagios plugin executable. | | yes |
208 -| **Arguments** | args | Command-line arguments passed to the plugin. Nagios macros are expanded before execution. | [] | no |
209 -| | arg_values | Values bound to positional `$ARGn$` macros (max 32). | [] | no |
210 -| **General** | vnode | Virtual node name to associate with this job. The vnode must be defined in the vnodes configuration directory. | | no |
211 -| | scheduler | Name of the scheduler that executes this job. | default | no |
212 -| **Timing** | timeout | Maximum execution time before the plugin is killed. Duration string (e.g. `30s`, `1m`). | 30s | no |
213 -| | timeout_state | State reported when a timeout occurs. | critical | no |
214 -| | check_interval | Base scheduling interval between executions. Duration string. | 1m | no |
215 -| | retry_interval | Interval between soft retries after a non-OK result. Duration string. | 30s | no |
216 -| | max_check_attempts | Number of soft-state attempts before transitioning to a hard state. | 3 | no |
217 -| **Environment** | user_macros | Key/value pairs exposed as `$USERn$` macros. For example, `USER1` becomes `$USER1$`. | {} | no |
218 -| | custom_vars | Key/value pairs exported as `$_SERVICEvar$` environment variables (`NAGIOS__SERVICEvar`). | {} | no |
219 -| | environment | Additional environment variables set before executing the plugin. | {} | no |
220 -| **General** | working_directory | Working directory for plugin execution. | | no |
221 -
222 -
223 -</details>
224 -
225 -
226 -
227 -#### via File
228 -
229 -The configuration file name for this integration is `scripts.d/nagios.conf`.
230 -
231 -
232 -You can edit the configuration file using the [`edit-config`](https://github.com/netdata/netdata/blob/master/docs/netdata-agent/configuration/README.md#edit-configuration-files) script from the
233 -Netdata [config directory](https://github.com/netdata/netdata/blob/master/docs/netdata-agent/configuration/README.md#locate-your-config-directory).
234 -
235 -```bash
236 -cd /etc/netdata 2>/dev/null || cd /opt/netdata/etc/netdata
237 -sudo ./edit-config scripts.d/nagios.conf
238 -```
239 -
240 -##### Examples
241 -
242 -###### SSL certificate check
243 -
244 -Check SSL certificate expiry for a host.
245 -
246 -<details open><summary>Config</summary>
247 -
248 -```yaml
249 -jobs:
250 - - name: ssl_github
251 - plugin: /usr/lib/nagios/plugins/check_http
252 - args: ["-H", "github.com", "--ssl", "-C", "30,15"]
253 - timeout: 30s
254 - check_interval: 1h
255 - retry_interval: 5m
256 - max_check_attempts: 3
257 -
258 -```
259 -</details>
260 -
261 -###### Check with macros and vnode
262 -
263 -Run a plugin against a virtual node using Nagios macros.
264 -The `$HOSTADDRESS$` and `$ARG1$` macros are expanded from the vnode labels and `arg_values`.
265 -
266 -
267 -<details open><summary>Config</summary>
268 -
269 -```yaml
270 -jobs:
271 - - name: check_ssh
272 - plugin: /usr/lib/nagios/plugins/check_ssh
273 - args: ["-H", "$HOSTADDRESS$", "-p", "$ARG1$"]
274 - arg_values: ["22"]
275 - vnode: my-server
276 - check_interval: 5m
277 -
278 -```
279 -</details>
280 -
281 -###### Check with custom vars
282 -
283 -Pass service-level custom variables to a plugin as `$_SERVICEvar$` macros.
284 -
285 -
286 -<details open><summary>Config</summary>
287 -
288 -```yaml
289 -jobs:
290 - - name: check_api
291 - plugin: /usr/local/bin/check_api
292 - args: ["-u", "$_SERVICEENDPOINT$"]
293 - custom_vars:
294 - ENDPOINT: "/health"
295 -
296 -```
297 -</details>
298 -
299 -
300 -
301 -## Troubleshooting
302 -
303 -###
304 -
305 -Plugin exits with "permission denied".
306 -
307 -
308 -###
309 -
310 -Macros like `$HOSTADDRESS$` are not expanded.
311 -
312 -
313 -
src/go/plugin/scripts.d/modules/nagios/metadata.yaml deleted
-347
@@ -1,347 +0,0 @@
1 -plugin_name: scripts.d.plugin
2 -modules:
3 - - meta:
4 - id: collector-scripts.d.plugin-nagios
5 - plugin_name: scripts.d.plugin
6 - module_name: nagios
7 - monitored_instance:
8 - name: Nagios Plugins
9 - link: https://www.nagios-plugins.org/
10 - icon_filename: nagios.png
11 - categories:
12 - - data-collection.synthetic-testing
13 - related_resources:
14 - integrations:
15 - list: []
16 - info_provided_to_referring_integrations:
17 - description: ""
18 - keywords:
19 - - nagios
20 - - plugins
21 - - checks
22 - - scripts
23 - - monitoring
24 - overview:
25 - data_collection:
26 - metrics_description: |
27 - This module runs unmodified [Nagios plugins](https://www.nagios-plugins.org/) inside Netdata without any changes to the plugins themselves.
28 -
29 - For each configured job it collects:
30 -
31 - - **Check state**: OK / WARNING / CRITICAL / UNKNOWN (with soft/hard state tracking).
32 - - **Performance data**: Every `label=value;warn;crit;min;max` metric emitted by the plugin is parsed and charted automatically, with unit normalization where possible.
33 - - **Execution telemetry**: Runtime duration, scheduling latency, CPU time, peak RSS memory, and disk I/O per job.
34 - - **Scheduler health**: Running / queued / scheduled job counts and throughput rates.
35 - method_description: |
36 - Jobs are executed via `nd-run` (the Netdata unprivileged helper) at the configured `check_interval`.
37 - Standard Nagios macros (`$HOSTADDRESS$`, `$ARG1$`, `$USERn$`, etc.) are expanded before execution.
38 - Plugin output is parsed according to the [Nagios Plugin API](https://nagios-plugins.org/doc/guidelines.html):
39 - the first line provides the status and optional performance data after the `|` separator.
40 - default_behavior:
41 - auto_detection:
42 - description: |
43 - No auto-detection. Each job must be explicitly configured with a `plugin` path pointing to the Nagios plugin executable.
44 - limits:
45 - description: ""
46 - performance_impact:
47 - description: |
48 - Each job spawns a subprocess via `nd-run`. Resource usage (CPU, memory, disk I/O) is tracked per execution and exposed as telemetry charts.
49 - additional_permissions:
50 - description: |
51 - Plugins run as the `netdata` user via `nd-run`. If a plugin requires elevated privileges, configure it through `ndsudo` or adjust filesystem permissions accordingly.
52 - multi_instance: true
53 - supported_platforms:
54 - include: []
55 - exclude: []
56 - setup:
57 - prerequisites:
58 - list:
59 - - title: Install Nagios plugins
60 - description: |
61 - Install the plugins you want to run. Most distributions provide packages:
62 -
63 - ```bash
64 - # Debian/Ubuntu
65 - apt install nagios-plugins
66 -
67 - # RHEL/CentOS/Fedora
68 - dnf install nagios-plugins-all
69 - ```
70 -
71 - You can also use any script or binary that follows the [Nagios Plugin API](https://nagios-plugins.org/doc/guidelines.html).
72 - configuration:
73 - file:
74 - name: scripts.d/nagios.conf
75 - options:
76 - description: |
77 - Each job defines a single Nagios plugin execution. Jobs are listed under the `jobs` key.
78 - folding:
79 - title: Config options
80 - enabled: true
81 - list:
82 - - name: plugin
83 - description: Absolute path to the Nagios plugin executable.
84 - default_value: ""
85 - required: true
86 - group: General
87 - - name: args
88 - description: Command-line arguments passed to the plugin. Nagios macros are expanded before execution.
89 - default_value: "[]"
90 - required: false
91 - group: Arguments
92 - - name: arg_values
93 - description: Values bound to positional `$ARGn$` macros (max 32).
94 - default_value: "[]"
95 - required: false
96 - group: Arguments
97 - - name: vnode
98 - description: Virtual node name to associate with this job. The vnode must be defined in the vnodes configuration directory.
99 - default_value: ""
100 - required: false
101 - group: General
102 - - name: scheduler
103 - description: Name of the scheduler that executes this job.
104 - default_value: default
105 - required: false
106 - group: General
107 - - name: timeout
108 - description: Maximum execution time before the plugin is killed. Duration string (e.g. `30s`, `1m`).
109 - default_value: 30s
110 - required: false
111 - group: Timing
112 - - name: timeout_state
113 - description: State reported when a timeout occurs.
114 - default_value: critical
115 - required: false
116 - group: Timing
117 - - name: check_interval
118 - description: Base scheduling interval between executions. Duration string.
119 - default_value: 1m
120 - required: false
121 - group: Timing
122 - - name: retry_interval
123 - description: Interval between soft retries after a non-OK result. Duration string.
124 - default_value: 30s
125 - required: false
126 - group: Timing
127 - - name: max_check_attempts
128 - description: Number of soft-state attempts before transitioning to a hard state.
129 - default_value: 3
130 - required: false
131 - group: Timing
132 - - name: user_macros
133 - description: Key/value pairs exposed as `$USERn$` macros. For example, `USER1` becomes `$USER1$`.
134 - default_value: "{}"
135 - required: false
136 - group: Environment
137 - - name: custom_vars
138 - description: Key/value pairs exported as `$_SERVICEvar$` environment variables (`NAGIOS__SERVICEvar`).
139 - default_value: "{}"
140 - required: false
141 - group: Environment
142 - - name: environment
143 - description: Additional environment variables set before executing the plugin.
144 - default_value: "{}"
145 - required: false
146 - group: Environment
147 - - name: working_directory
148 - description: Working directory for plugin execution.
149 - default_value: ""
150 - required: false
151 - group: General
152 - examples:
153 - folding:
154 - title: Config
155 - enabled: true
156 - list:
157 - - name: SSL certificate check
158 - description: Check SSL certificate expiry for a host.
159 - config: |
160 - jobs:
161 - - name: ssl_github
162 - plugin: /usr/lib/nagios/plugins/check_http
163 - args: ["-H", "github.com", "--ssl", "-C", "30,15"]
164 - timeout: 30s
165 - check_interval: 1h
166 - retry_interval: 5m
167 - max_check_attempts: 3
168 - - name: Check with macros and vnode
169 - description: |
170 - Run a plugin against a virtual node using Nagios macros.
171 - The `$HOSTADDRESS$` and `$ARG1$` macros are expanded from the vnode labels and `arg_values`.
172 - config: |
173 - jobs:
174 - - name: check_ssh
175 - plugin: /usr/lib/nagios/plugins/check_ssh
176 - args: ["-H", "$HOSTADDRESS$", "-p", "$ARG1$"]
177 - arg_values: ["22"]
178 - vnode: my-server
179 - check_interval: 5m
180 - - name: Check with custom vars
181 - description: |
182 - Pass service-level custom variables to a plugin as `$_SERVICEvar$` macros.
183 - config: |
184 - jobs:
185 - - name: check_api
186 - plugin: /usr/local/bin/check_api
187 - args: ["-u", "$_SERVICEENDPOINT$"]
188 - custom_vars:
189 - ENDPOINT: "/health"
190 - troubleshooting:
191 - problems:
192 - list:
193 - - description: |
194 - Plugin exits with "permission denied".
195 - solutions:
196 - - description: |
197 - Ensure the plugin file has execute permission for the `netdata` user:
198 - ```bash
199 - chmod +x /usr/lib/nagios/plugins/check_http
200 - ```
201 - - description: |
202 - Macros like `$HOSTADDRESS$` are not expanded.
203 - solutions:
204 - - description: |
205 - The job must reference a `vnode` that is configured in the vnodes directory (`/etc/netdata/vnodes/`).
206 - The vnode must have the corresponding label set. See the **Virtual Node Label Conventions** section below.
207 - alerts: []
208 - metrics:
209 - folding:
210 - title: Metrics
211 - enabled: false
212 - description: |
213 - ### Virtual Node Label Conventions
214 -
215 - When a job references a `vnode`, the module reads Nagios macros from the virtual node's **labels** using prefix conventions:
216 -
217 - | Label key | Nagios macro | Environment variable | Description |
218 - |-----------|-------------|---------------------|-------------|
219 - | `_address` | `$HOSTADDRESS$` | `NAGIOS_HOSTADDRESS` | IP address or DNS name of the host |
220 - | `_alias` | `$HOSTALIAS$` | `NAGIOS_HOSTALIAS` | Human-readable host alias |
221 - | `_VARNAME` | `$_HOSTVARNAME$` | `NAGIOS__HOSTVARNAME` | Custom host variable (any `_` prefixed key except `_address` and `_alias`) |
222 - | `key` | `$_HOSTLABEL_KEY$` | `NAGIOS__HOSTLABEL_KEY` | Regular label (no `_` prefix) |
223 -
224 - Example vnode configuration (`/etc/netdata/vnodes/hosts.yaml`):
225 -
226 - ```yaml
227 - - hostname: web-server-1
228 - guid: 12345678-1234-1234-1234-123456789abc
229 - labels:
230 - _address: "192.168.1.10"
231 - _alias: "Web Server 1"
232 - _DATACENTER: "us-east-1"
233 - role: "frontend"
234 - environment: "production"
235 - ```
236 -
237 - This produces:
238 -
239 - | Macro | Value |
240 - |-------|-------|
241 - | `$HOSTADDRESS$` | `192.168.1.10` |
242 - | `$HOSTALIAS$` | `Web Server 1` |
243 - | `$_HOSTDATACENTER$` | `us-east-1` |
244 - | `$_HOSTLABEL_ROLE$` | `frontend` |
245 - | `$_HOSTLABEL_ENVIRONMENT$` | `production` |
246 - availability: []
247 - scopes:
248 - - name: job
249 - description: Metrics for each configured Nagios plugin job.
250 - labels:
251 - - name: nagios_job
252 - description: Job name as defined in the configuration.
253 - - name: nagios_plugin
254 - description: Basename of the plugin executable.
255 - - name: nagios_vnode
256 - description: Virtual node associated with the job (if any).
257 - - name: nagios_scheduler
258 - description: Scheduler executing the job.
259 - metrics:
260 - - name: nagios.jobs.state
261 - description: Nagios plugin check state
262 - unit: state
263 - chart_type: line
264 - dimensions:
265 - - name: ok
266 - - name: warning
267 - - name: critical
268 - - name: unknown
269 - - name: nagios.jobs.runtime
270 - description: Nagios plugin runtime state
271 - unit: boolean
272 - chart_type: line
273 - dimensions:
274 - - name: running
275 - - name: retrying
276 - - name: skipped
277 - - name: nagios.jobs.latency
278 - description: Nagios plugin execution latency
279 - unit: seconds
280 - chart_type: line
281 - dimensions:
282 - - name: duration
283 - - name: nagios.jobs.cpu
284 - description: Nagios plugin CPU time
285 - unit: seconds
286 - chart_type: line
287 - dimensions:
288 - - name: cpu
289 - - name: nagios.jobs.mem
290 - description: Nagios plugin peak memory (RSS)
291 - unit: bytes
292 - chart_type: line
293 - dimensions:
294 - - name: rss
295 - - name: nagios.jobs.disk
296 - description: Nagios plugin disk I/O
297 - unit: bytes
298 - chart_type: line
299 - dimensions:
300 - - name: read
301 - - name: write
302 - - name: perfdata
303 - description: |
304 - Metrics extracted from the plugin's performance data output.
305 - Each `label=value;warn;crit;min;max` entry produces a separate chart.
306 - labels:
307 - - name: nagios_job
308 - description: Job name.
309 - - name: nagios_plugin
310 - description: Plugin executable.
311 - - name: perf_label
312 - description: Performance data label as emitted by the plugin.
313 - metrics:
314 - - name: nagios.{script}.{label}
315 - description: Performance data metric (context is dynamic per plugin and label)
316 - unit: varies
317 - chart_type: line
318 - dimensions:
319 - - name: value
320 - - name: scheduler
321 - description: Scheduler-level metrics.
322 - labels:
323 - - name: nagios_scheduler
324 - description: Scheduler name.
325 - metrics:
326 - - name: nagios.scheduler.jobs
327 - description: Scheduler job status
328 - unit: jobs
329 - chart_type: line
330 - dimensions:
331 - - name: running
332 - - name: queued
333 - - name: scheduled
334 - - name: nagios.scheduler.rate
335 - description: Scheduler workload throughput
336 - unit: jobs
337 - chart_type: line
338 - dimensions:
339 - - name: started
340 - - name: finished
341 - - name: skipped
342 - - name: nagios.scheduler.next
343 - description: Scheduler next run time
344 - unit: seconds
345 - chart_type: line
346 - dimensions:
347 - - name: next
src/go/plugin/scripts.d/modules/nagios/module.go deleted
-461
@@ -1,461 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package nagios
4 -
5 -import (
6 - "context"
7 - _ "embed"
8 - "fmt"
9 - "maps"
10 - "strings"
11 - "sync"
12 - "time"
13 -
14 - "github.com/netdata/netdata/go/plugins/pkg/confopt"
15 - "github.com/netdata/netdata/go/plugins/pkg/multipath"
16 - "github.com/netdata/netdata/go/plugins/pkg/pluginconfig"
17 - "github.com/netdata/netdata/go/plugins/pkg/tlscfg"
18 - "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
19 - "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
20 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/charts"
21 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/ids"
22 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/output"
23 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/runtime"
24 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/schedulers"
25 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
26 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/timeperiod"
27 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/units"
28 -)
29 -
30 -//go:embed config_schema.json
31 -var configSchema string
32 -
33 -func init() {
34 - collectorapi.Register("nagios", collectorapi.Creator{
35 - JobConfigSchema: configSchema,
36 - Defaults: collectorapi.Defaults{
37 - AutoDetectionRetry: 60,
38 - },
39 - Create: func() collectorapi.CollectorV1 { return New() },
40 - Config: func() any { return &Config{} },
41 - })
42 -}
43 -
44 -// Config represents a Nagios module configuration loaded by go.d's file discovery.
45 -// Each file may define multiple explicit jobs plus shared defaults/macros.
46 -type Config struct {
47 - spec.JobConfig `yaml:",inline" json:",inline"`
48 - UserMacros map[string]string `yaml:"user_macros,omitempty" json:"user_macros"`
49 - Logging LoggingConfig `yaml:"logging,omitempty" json:"logging"`
50 -}
51 -
52 -type LoggingConfig struct {
53 - Enabled bool `yaml:"enabled,omitempty" json:"enabled"`
54 - OTLP OTLPLoggingConfig `yaml:"otlp,omitempty" json:"otlp"`
55 -}
56 -
57 -type OTLPLoggingConfig struct {
58 - Endpoint string `yaml:"endpoint,omitempty" json:"endpoint"`
59 - Timeout confopt.Duration `yaml:"timeout,omitempty" json:"timeout"`
60 - TLS *bool `yaml:"tls,omitempty" json:"tls"`
61 - Headers map[string]string `yaml:"headers,omitempty" json:"headers"`
62 - TLSServerName string `yaml:"tls_server_name,omitempty" json:"tls_server_name,omitempty"`
63 - tlscfg.TLSConfig `yaml:",inline" json:",inline"`
64 -}
65 -
66 -func (l *LoggingConfig) setDefaults() {
67 - if l == nil {
68 - return
69 - }
70 - if l.OTLP.Endpoint == "" {
71 - l.OTLP.Endpoint = runtime.DefaultOTLPEndpoint
72 - }
73 - if l.OTLP.Timeout == 0 {
74 - l.OTLP.Timeout = confopt.Duration(runtime.DefaultOTLPTimeout)
75 - }
76 - if l.OTLP.Headers == nil {
77 - l.OTLP.Headers = make(map[string]string)
78 - }
79 - if l.OTLP.TLS == nil {
80 - v := true
81 - l.OTLP.TLS = &v
82 - }
83 - if !l.Enabled {
84 - l.Enabled = true
85 - }
86 -}
87 -
88 -func (l LoggingConfig) emitterConfig() runtime.OTLPEmitterConfig {
89 - return runtime.OTLPEmitterConfig{
90 - Endpoint: l.OTLP.Endpoint,
91 - Timeout: time.Duration(l.OTLP.Timeout),
92 - UseTLS: l.OTLP.tlsEnabled(),
93 - Headers: l.OTLP.Headers,
94 - TLSConfig: l.OTLP.TLSConfig,
95 - ServerName: l.OTLP.TLSServerName,
96 - }
97 -}
98 -
99 -func (c OTLPLoggingConfig) tlsEnabled() bool {
100 - if c.TLS == nil {
101 - return true
102 - }
103 - return *c.TLS
104 -}
105 -
106 -func boolPtr(v bool) *bool {
107 - b := v
108 - return &b
109 -}
110 -
111 -// Collector is a placeholder module that keeps the plugin wiring compiling while the real
112 -// Nagios execution engine is being implemented.
113 -type Collector struct {
114 - collectorapi.Base
115 - Config `yaml:",inline" json:""`
116 -
117 - charts *collectorapi.Charts
118 - chartMu sync.RWMutex
119 - perfCharts map[string]perfChartMeta
120 - periods *timeperiod.Set
121 - jobSpec spec.JobSpec
122 - identity charts.JobIdentity
123 - jobHandle *schedulers.JobHandle
124 - vnodeInfo map[string]runtime.VnodeInfo
125 - missingVnode map[string]struct{}
126 -
127 - currentVnode *vnodes.VirtualNode
128 - vnodeMu sync.RWMutex
129 -}
130 -
131 -type perfChartMeta struct {
132 - Scale units.Scale
133 -}
134 -
135 -// New returns a collector with sensible defaults so the module registry can instantiate jobs.
136 -func New() *Collector {
137 - return &Collector{
138 - Config: Config{
139 - UserMacros: make(map[string]string),
140 - Logging: LoggingConfig{
141 - Enabled: true,
142 - OTLP: OTLPLoggingConfig{
143 - Endpoint: runtime.DefaultOTLPEndpoint,
144 - Timeout: confopt.Duration(runtime.DefaultOTLPTimeout),
145 - TLS: boolPtr(false),
146 - Headers: make(map[string]string),
147 - },
148 - },
149 - },
150 - charts: &collectorapi.Charts{},
151 - perfCharts: make(map[string]perfChartMeta),
152 - }
153 -}
154 -
155 -func (c *Collector) Configuration() any {
156 - return c.Config
157 -}
158 -
159 -func (c *Collector) Init(ctx context.Context) error {
160 - if c.charts == nil {
161 - c.charts = &collectorapi.Charts{}
162 - }
163 - if c.perfCharts == nil {
164 - c.perfCharts = make(map[string]perfChartMeta)
165 - }
166 - c.Logging.setDefaults()
167 - if err := c.compileTimePeriods(); err != nil {
168 - return err
169 - }
170 - sp, err := c.buildJobSpec()
171 - if err != nil {
172 - return err
173 - }
174 - c.jobSpec = sp
175 - c.identity = charts.NewJobIdentity(sp.Scheduler, sp)
176 - c.refreshVnodeInfo()
177 - if err := c.validateVnode(sp.Vnode); err != nil {
178 - return err
179 - }
180 - if err := c.initCharts(); err != nil {
181 - return err
182 - }
183 - emitter := c.buildEmitter(c.Logging.Enabled, c.Logging.emitterConfig())
184 - reg := runtime.JobRegistration{
185 - Spec: sp,
186 - Emitter: emitter,
187 - RegisterPerfdata: c.registerPerfdataChart,
188 - Periods: c.periods,
189 - UserMacros: c.copyUserMacros(),
190 - Vnode: c.vnodeInfoFor(sp),
191 - }
192 - handle, err := schedulers.AttachJob(sp.Scheduler, reg, c.Logger)
193 - if err != nil {
194 - return err
195 - }
196 - c.jobHandle = handle
197 - return nil
198 -}
199 -
200 -func (c *Collector) Check(context.Context) error {
201 - // Placeholder check so autodetection succeeds during early development.
202 - return nil
203 -}
204 -
205 -func (c *Collector) Charts() *collectorapi.Charts {
206 - c.chartMu.RLock()
207 - defer c.chartMu.RUnlock()
208 - return c.charts
209 -}
210 -
211 -func (c *Collector) Collect(context.Context) map[string]int64 {
212 - all := schedulers.CollectMetrics(c.jobSpec.Scheduler)
213 - if len(all) == 0 {
214 - return nil
215 - }
216 - prefix := c.identity.MetricPrefix()
217 - metrics := make(map[string]int64)
218 - for k, v := range all {
219 - if strings.HasPrefix(k, prefix) {
220 - metrics[k] = v
221 - }
222 - }
223 - if len(metrics) == 0 {
224 - return nil
225 - }
226 - return metrics
227 -}
228 -
229 -func (c *Collector) Cleanup(context.Context) {
230 - if c.jobHandle != nil {
231 - schedulers.DetachJob(c.jobHandle)
232 - c.jobHandle = nil
233 - }
234 -}
235 -
236 -func (c *Collector) vnodeInfoFor(job spec.JobSpec) runtime.VnodeInfo {
237 - info, ok := c.lookupVnode(job.Vnode)
238 - if ok {
239 - return cloneVnodeInfo(info)
240 - }
241 - if strings.TrimSpace(job.Vnode) != "" {
242 - c.warnMissingVnode(job.Vnode)
243 - }
244 - return runtime.VnodeInfo{Hostname: job.Vnode}
245 -}
246 -
247 -func (c *Collector) lookupVnode(name string) (runtime.VnodeInfo, bool) {
248 - if c.vnodeInfo == nil {
249 - return runtime.VnodeInfo{}, false
250 - }
251 - key := strings.ToLower(strings.TrimSpace(name))
252 - if key == "" {
253 - return runtime.VnodeInfo{}, false
254 - }
255 - info, ok := c.vnodeInfo[key]
256 - return cloneVnodeInfo(info), ok
257 -}
258 -
259 -func (c *Collector) warnMissingVnode(name string) {
260 - name = strings.TrimSpace(name)
261 - if name == "" {
262 - return
263 - }
264 - if c.missingVnode == nil {
265 - c.missingVnode = make(map[string]struct{})
266 - }
267 - key := strings.ToLower(name)
268 - if _, seen := c.missingVnode[key]; seen {
269 - return
270 - }
271 - c.missingVnode[key] = struct{}{}
272 - c.Warningf("nagios: vnode '%s' not found; macros fallback to literal hostname", name)
273 -}
274 -
275 -func (c *Collector) initCharts() error {
276 - meta := charts.NewJobIdentity(c.jobSpec.Scheduler, c.jobSpec)
277 - jobCharts := charts.BuildJobCharts(meta, 100)
278 - newCharts := collectorapi.Charts{}
279 - if err := newCharts.Add(jobCharts...); err != nil {
280 - return err
281 - }
282 - c.chartMu.Lock()
283 - c.charts = &newCharts
284 - c.chartMu.Unlock()
285 - return nil
286 -}
287 -
288 -func (c *Collector) registerPerfdataChart(job spec.JobSpec, datum output.PerfDatum) {
289 - label := strings.TrimSpace(datum.Label)
290 - if label == "" {
291 - return
292 - }
293 - meta := c.identity
294 - labelID := ids.Sanitize(label)
295 - scale := units.NewScale(datum.Unit)
296 - key := fmt.Sprintf("%s|%s", meta.JobKey, labelID)
297 - c.chartMu.Lock()
298 - defer c.chartMu.Unlock()
299 - if existing, ok := c.perfCharts[key]; ok {
300 - if sameScale(existing.Scale, scale) {
301 - return
302 - }
303 - chartID := meta.PerfdataChartID(labelID)
304 - if chart := c.charts.Get(chartID); chart != nil {
305 - chart.MarkRemove()
306 - }
307 - }
308 - c.Infof("nagios: registering perfdata chart scheduler=%s job=%s label=%s unit=%s", meta.Scheduler, job.Name, label, datum.Unit)
309 - chart := charts.PerfdataChart(meta, label, scale, 200)
310 - if err := c.charts.Add(chart); err != nil {
311 - c.Errorf("failed to add perfdata chart for job %s label %s: %v", job.Name, label, err)
312 - return
313 - }
314 - c.perfCharts[key] = perfChartMeta{Scale: scale}
315 -}
316 -
317 -func sameScale(a, b units.Scale) bool {
318 - return a.Divisor == b.Divisor && a.CanonicalUnit == b.CanonicalUnit
319 -}
320 -
321 -func (c *Collector) copyUserMacros() map[string]string {
322 - userMacros := make(map[string]string, len(c.UserMacros))
323 - for k, v := range c.UserMacros {
324 - userMacros[k] = v
325 - }
326 - return userMacros
327 -}
328 -
329 -func (c *Collector) refreshVnodeInfo() {
330 - configDirs := pluginconfig.ConfigDir()
331 - if len(configDirs) == 0 {
332 - c.vnodeInfo = nil
333 - c.missingVnode = nil
334 - return
335 - }
336 - path, err := configDirs.Find("vnodes")
337 - if err != nil {
338 - if !multipath.IsNotFound(err) {
339 - c.Warningf("nagios: failed to locate vnodes directory: %v", err)
340 - }
341 - c.vnodeInfo = nil
342 - c.missingVnode = nil
343 - return
344 - }
345 - registry := vnodes.Load(path)
346 - if len(registry) == 0 {
347 - c.vnodeInfo = nil
348 - c.missingVnode = nil
349 - return
350 - }
351 - info := make(map[string]runtime.VnodeInfo, len(registry)*3)
352 - for key, vnode := range registry {
353 - if vnode == nil {
354 - continue
355 - }
356 - converted := runtime.VnodeInfo{
357 - Hostname: firstNonEmpty(vnode.Hostname, vnode.Name, key),
358 - Labels: maps.Clone(vnode.Labels),
359 - }
360 - if converted.Labels == nil {
361 - converted.Labels = make(map[string]string)
362 - }
363 - if _, ok := converted.Labels["_alias"]; !ok {
364 - converted.Labels["_alias"] = firstNonEmpty(vnode.Name, key)
365 - }
366 - if _, ok := converted.Labels["_address"]; !ok {
367 - if v := vnode.Labels["_net_default_iface_ip"]; v != "" {
368 - converted.Labels["_address"] = v
369 - }
370 - }
371 - for _, alias := range []string{key, vnode.Hostname, vnode.Name, vnode.GUID} {
372 - if alias == "" {
373 - continue
374 - }
375 - info[strings.ToLower(alias)] = cloneVnodeInfo(converted)
376 - }
377 - }
378 - c.vnodeInfo = info
379 - c.missingVnode = make(map[string]struct{})
380 -}
381 -
382 -func (c *Collector) VirtualNode() *vnodes.VirtualNode {
383 - c.vnodeMu.RLock()
384 - defer c.vnodeMu.RUnlock()
385 - if c.currentVnode == nil {
386 - return nil
387 - }
388 - return c.currentVnode.Copy()
389 -}
390 -
391 -func cloneVnodeInfo(src runtime.VnodeInfo) runtime.VnodeInfo {
392 - clone := src
393 - if len(src.Labels) > 0 {
394 - clone.Labels = maps.Clone(src.Labels)
395 - } else {
396 - clone.Labels = nil
397 - }
398 - return clone
399 -}
400 -
401 -func firstNonEmpty(values ...string) string {
402 - for _, v := range values {
403 - if strings.TrimSpace(v) != "" {
404 - return v
405 - }
406 - }
407 - return ""
408 -}
409 -
410 -func (c *Collector) compileTimePeriods() error {
411 - configs := []timeperiod.Config{timeperiod.DefaultPeriodConfig()}
412 - set, err := timeperiod.Compile(configs)
413 - if err != nil {
414 - return err
415 - }
416 - c.periods = set
417 - return nil
418 -}
419 -
420 -func (c *Collector) buildJobSpec() (spec.JobSpec, error) {
421 - cfg := c.JobConfig
422 - sp, err := cfg.ToSpec()
423 - if err != nil {
424 - return spec.JobSpec{}, err
425 - }
426 - return sp, nil
427 -}
428 -
429 -func (c *Collector) validateVnode(name string) error {
430 - if strings.TrimSpace(name) == "" {
431 - return nil
432 - }
433 - info, ok := c.lookupVnode(name)
434 - if !ok {
435 - return fmt.Errorf("job '%s': vnode '%s' not found", c.jobSpec.Name, name)
436 - }
437 - c.setCurrentVnode(info)
438 - return nil
439 -}
440 -
441 -func (c *Collector) setCurrentVnode(info runtime.VnodeInfo) {
442 - converted := &vnodes.VirtualNode{
443 - Name: info.Hostname,
444 - Hostname: info.Hostname,
445 - Labels: maps.Clone(info.Labels),
446 - }
447 - c.vnodeMu.Lock()
448 - c.currentVnode = converted
449 - c.vnodeMu.Unlock()
450 -}
451 -
452 -func (c *Collector) buildEmitter(enabled bool, cfg runtime.OTLPEmitterConfig) runtime.ResultEmitter {
453 - if enabled {
454 - emitter, err := runtime.NewOTLPEmitter(cfg, c.Logger)
455 - if err == nil {
456 - return emitter
457 - }
458 - c.Errorf("failed to initialize OTLP emitter: %v", err)
459 - }
460 - return runtime.NewLogEmitter(c.Logger)
461 -}
src/go/plugin/scripts.d/modules/scheduler/README.md deleted
-1
@@ -1 +0,0 @@
1 -integrations/scripts.d_scheduler.md
\ No newline at end of file
src/go/plugin/scripts.d/modules/scheduler/config_schema.json deleted
-132
@@ -1,132 +0,0 @@
1 -{
2 - "jsonSchema": {
3 - "$schema": "http://json-schema.org/draft-07/schema#",
4 - "title": "scripts.d Scheduler configuration",
5 - "type": "object",
6 - "additionalProperties": false,
7 - "properties": {
8 - "workers": {
9 - "title": "Worker count",
10 - "description": "Number of concurrent workers executing jobs (defaults to 50).",
11 - "type": "integer",
12 - "minimum": 1
13 - },
14 - "queue_size": {
15 - "title": "Queue size",
16 - "description": "Capacity of the internal work queue (defaults to 128).",
17 - "type": "integer",
18 - "minimum": 1
19 - },
20 - "labels": {
21 - "title": "Labels",
22 - "description": "Additional labels propagated to scheduler metrics.",
23 - "type": "object",
24 - "additionalProperties": {
25 - "type": "string"
26 - }
27 - },
28 - "logging": {
29 - "title": "Logging configuration",
30 - "description": "OTLP logging settings for jobs executed by this scheduler.",
31 - "type": "object",
32 - "additionalProperties": false,
33 - "properties": {
34 - "enabled": {
35 - "title": "Enable logging",
36 - "type": "boolean",
37 - "default": true
38 - },
39 - "otlp": {
40 - "title": "OTLP endpoint",
41 - "type": "object",
42 - "additionalProperties": false,
43 - "properties": {
44 - "endpoint": {
45 - "title": "Endpoint",
46 - "type": "string",
47 - "default": "127.0.0.1:4317"
48 - },
49 - "timeout": {
50 - "title": "Timeout",
51 - "type": "string",
52 - "pattern": "^([0-9]+(\\.[0-9]+)?(ns|us|ms|s|m|h|d))+$",
53 - "default": "5s"
54 - },
55 - "tls": {
56 - "title": "Use TLS",
57 - "type": "boolean",
58 - "default": true
59 - },
60 - "headers": {
61 - "title": "Headers",
62 - "type": "object",
63 - "additionalProperties": {
64 - "type": "string"
65 - }
66 - },
67 - "tls_server_name": {
68 - "title": "TLS server name",
69 - "type": "string"
70 - },
71 - "tls_ca": {
72 - "title": "TLS CA",
73 - "type": "string"
74 - },
75 - "tls_cert": {
76 - "title": "TLS certificate",
77 - "type": "string"
78 - },
79 - "tls_key": {
80 - "title": "TLS key",
81 - "type": "string"
82 - }
83 - }
84 - }
85 - }
86 - }
87 - },
88 - "required": [],
89 - "definitions": {},
90 - "additionalProperties": false
91 - },
92 - "uiSchema": {
93 - "uiOptions": {
94 - "fullPage": true
95 - },
96 - "workers": {
97 - "ui:placeholder": "50"
98 - },
99 - "queue_size": {
100 - "ui:placeholder": "128"
101 - },
102 - "labels": {
103 - "ui:help": "Key/value pairs attached to scheduler metrics (e.g. region:us-east)",
104 - "ui:options": {
105 - "addable": true,
106 - "removable": true
107 - }
108 - },
109 - "logging": {
110 - "enabled": {
111 - "ui:widget": "radio",
112 - "ui:options": {
113 - "inline": true
114 - }
115 - },
116 - "otlp": {
117 - "endpoint": {
118 - "ui:placeholder": "127.0.0.1:4317"
119 - },
120 - "timeout": {
121 - "ui:placeholder": "5s"
122 - },
123 - "tls": {
124 - "ui:widget": "radio",
125 - "ui:options": {
126 - "inline": true
127 - }
128 - }
129 - }
130 - }
131 - }
132 -}
src/go/plugin/scripts.d/modules/scheduler/integrations/scripts.d_scheduler.md deleted
-133
@@ -1,133 +0,0 @@
1 -<!--startmeta
2 -custom_edit_url: "https://github.com/netdata/netdata/edit/master/src/go/plugin/scripts.d/modules/scheduler/README.md"
3 -meta_yaml: "https://github.com/netdata/netdata/edit/master/src/go/plugin/scripts.d/modules/scheduler/metadata.yaml"
4 -sidebar_label: "scripts.d Scheduler"
5 -learn_status: "Published"
6 -learn_rel_path: "Collecting Metrics/Applications"
7 -keywords: ['scheduler', 'scripts']
8 -message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE COLLECTOR'S metadata.yaml FILE"
9 -endmeta-->
10 -
11 -# scripts.d Scheduler
12 -
13 -
14 -<img src="https://netdata.cloud/img/netdata-logomark.svg" width="150"/>
15 -
16 -
17 -Plugin: scripts.d.plugin
18 -Module: scheduler
19 -
20 -<img src="https://img.shields.io/badge/maintained%20by-Netdata-%2300ab44" />
21 -
22 -## Overview
23 -
24 -The scheduler module manages the execution of jobs defined by the nagios and zabbix modules.
25 -
26 -It provides:
27 -
28 -- **Worker pool**: Concurrent execution with configurable worker count and queue depth.
29 -- **OTLP logging**: Optional structured log export via gRPC for job execution results.
30 -
31 -Scheduler-level metrics (jobs status, throughput, next run time) are exposed through the nagios and zabbix modules that use it.
32 -
33 -
34 -The scheduler manages per-job timers and dispatches jobs to a worker pool.
35 -Each worker executes a job via the configured runner (nagios or zabbix) and reports results back.
36 -
37 -
38 -This collector is supported on all platforms.
39 -
40 -This collector supports collecting metrics from multiple instances of this integration, including remote instances.
41 -
42 -
43 -scripts.d Scheduler can be monitored further using the following other integrations:
44 -
45 -- [Nagios Plugins](/src/go/plugin/scripts.d/modules/nagios/integrations/nagios_plugins.md)
46 -
47 -### Default Behavior
48 -
49 -#### Auto-Detection
50 -
51 -A `default` scheduler is created automatically. Additional named schedulers can be defined in the configuration.
52 -
53 -
54 -#### Limits
55 -
56 -The default configuration for this integration does not impose any limits on data collection.
57 -
58 -#### Performance Impact
59 -
60 -The default configuration for this integration is not expected to impose a significant performance impact on the system.
61 -
62 -## Metrics
63 -
64 -Scheduler metrics are exposed through the nagios and zabbix modules under the `nagios.scheduler.*` context.
65 -
66 -
67 -
68 -## Alerts
69 -
70 -There are no alerts configured by default for this integration.
71 -
72 -
73 -## Setup
74 -
75 -
76 -### Prerequisites
77 -
78 -No action required.
79 -
80 -### Configuration
81 -
82 -#### Options
83 -
84 -Scheduler configuration controls the worker pool and optional OTLP logging.
85 -
86 -
87 -<details open><summary>Config options</summary>
88 -
89 -
90 -
91 -| Group | Option | Description | Default | Required |
92 -|:------|:-----|:------------|:--------|:---------:|
93 -| **General** | workers | Number of concurrent workers executing jobs. | 50 | no |
94 -| | queue_size | Capacity of the internal work queue. | 128 | no |
95 -| **Logging** | logging.enabled | Enable structured OTLP log export for job results. | true | no |
96 -| | logging.otlp.endpoint | gRPC endpoint for OTLP log export. | 127.0.0.1:4317 | no |
97 -
98 -
99 -</details>
100 -
101 -
102 -
103 -#### via File
104 -
105 -The configuration file name for this integration is `scripts.d/scheduler.conf`.
106 -
107 -
108 -You can edit the configuration file using the [`edit-config`](https://github.com/netdata/netdata/blob/master/docs/netdata-agent/configuration/README.md#edit-configuration-files) script from the
109 -Netdata [config directory](https://github.com/netdata/netdata/blob/master/docs/netdata-agent/configuration/README.md#locate-your-config-directory).
110 -
111 -```bash
112 -cd /etc/netdata 2>/dev/null || cd /opt/netdata/etc/netdata
113 -sudo ./edit-config scripts.d/scheduler.conf
114 -```
115 -
116 -##### Examples
117 -
118 -###### Custom scheduler
119 -
120 -Define a scheduler with a larger worker pool.
121 -
122 -<details open><summary>Config</summary>
123 -
124 -```yaml
125 -jobs:
126 - - name: heavy
127 - workers: 100
128 - queue_size: 256
129 -
130 -```
131 -</details>
132 -
133 -
src/go/plugin/scripts.d/modules/scheduler/metadata.yaml deleted
-107
@@ -1,107 +0,0 @@
1 -plugin_name: scripts.d.plugin
2 -modules:
3 - - meta:
4 - id: collector-scripts.d.plugin-scheduler
5 - plugin_name: scripts.d.plugin
6 - module_name: scheduler
7 - monitored_instance:
8 - name: scripts.d Scheduler
9 - link: ""
10 - icon_filename: netdata-logomark.svg
11 - categories:
12 - - data-collection.applications
13 - related_resources:
14 - integrations:
15 - list:
16 - - plugin_name: scripts.d.plugin
17 - module_name: nagios
18 - info_provided_to_referring_integrations:
19 - description: ""
20 - keywords:
21 - - scheduler
22 - - scripts
23 - overview:
24 - data_collection:
25 - metrics_description: |
26 - The scheduler module manages the execution of jobs defined by the nagios and zabbix modules.
27 -
28 - It provides:
29 -
30 - - **Worker pool**: Concurrent execution with configurable worker count and queue depth.
31 - - **OTLP logging**: Optional structured log export via gRPC for job execution results.
32 -
33 - Scheduler-level metrics (jobs status, throughput, next run time) are exposed through the nagios and zabbix modules that use it.
34 - method_description: |
35 - The scheduler manages per-job timers and dispatches jobs to a worker pool.
36 - Each worker executes a job via the configured runner (nagios or zabbix) and reports results back.
37 - default_behavior:
38 - auto_detection:
39 - description: |
40 - A `default` scheduler is created automatically. Additional named schedulers can be defined in the configuration.
41 - limits:
42 - description: ""
43 - performance_impact:
44 - description: ""
45 - additional_permissions:
46 - description: ""
47 - multi_instance: true
48 - supported_platforms:
49 - include: []
50 - exclude: []
51 - setup:
52 - prerequisites:
53 - list: []
54 - configuration:
55 - file:
56 - name: scripts.d/scheduler.conf
57 - options:
58 - description: |
59 - Scheduler configuration controls the worker pool and optional OTLP logging.
60 - folding:
61 - title: Config options
62 - enabled: true
63 - list:
64 - - name: workers
65 - description: Number of concurrent workers executing jobs.
66 - default_value: 50
67 - required: false
68 - group: General
69 - - name: queue_size
70 - description: Capacity of the internal work queue.
71 - default_value: 128
72 - required: false
73 - group: General
74 - - name: logging.enabled
75 - description: Enable structured OTLP log export for job results.
76 - default_value: "true"
77 - required: false
78 - group: Logging
79 - - name: logging.otlp.endpoint
80 - description: gRPC endpoint for OTLP log export.
81 - default_value: "127.0.0.1:4317"
82 - required: false
83 - group: Logging
84 - examples:
85 - folding:
86 - title: Config
87 - enabled: true
88 - list:
89 - - name: Custom scheduler
90 - description: Define a scheduler with a larger worker pool.
91 - config: |
92 - jobs:
93 - - name: heavy
94 - workers: 100
95 - queue_size: 256
96 - troubleshooting:
97 - problems:
98 - list: []
99 - alerts: []
100 - metrics:
101 - folding:
102 - title: Metrics
103 - enabled: false
104 - description: |
105 - Scheduler metrics are exposed through the nagios and zabbix modules under the `nagios.scheduler.*` context.
106 - availability: []
107 - scopes: []
src/go/plugin/scripts.d/modules/scheduler/module.go deleted
-200
@@ -1,200 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package scheduler
4 -
5 -import (
6 - "context"
7 - _ "embed"
8 - "fmt"
9 - "strings"
10 - "time"
11 -
12 - "github.com/netdata/netdata/go/plugins/pkg/confopt"
13 - "github.com/netdata/netdata/go/plugins/pkg/tlscfg"
14 - "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
15 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/charts"
16 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/runtime"
17 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/schedulers"
18 -)
19 -
20 -//go:embed config_schema.json
21 -var configSchema string
22 -
23 -var collectSchedulerMetrics = schedulers.CollectMetrics
24 -
25 -func init() {
26 - collectorapi.Register("scheduler", collectorapi.Creator{
27 - JobConfigSchema: configSchema,
28 - Defaults: collectorapi.Defaults{AutoDetectionRetry: 60},
29 - Create: func() collectorapi.CollectorV1 { return New() },
30 - Config: func() any { return &Config{} },
31 - })
32 -}
33 -
34 -// Config defines a scheduler job configuration.
35 -type Config struct {
36 - Name string `yaml:"name" json:"name"`
37 - Workers int `yaml:"workers,omitempty" json:"workers,omitempty"`
38 - QueueSize int `yaml:"queue_size,omitempty" json:"queue_size,omitempty"`
39 - Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"`
40 - Logging LoggingConfig `yaml:"logging,omitempty" json:"logging,omitempty"`
41 -}
42 -
43 -// LoggingConfig mirrors the Nagios logging block so schedulers can emit OTLP logs.
44 -type LoggingConfig struct {
45 - Enabled bool `yaml:"enabled,omitempty" json:"enabled"`
46 - OTLP OTLPLoggingConfig `yaml:"otlp,omitempty" json:"otlp"`
47 -}
48 -
49 -type OTLPLoggingConfig struct {
50 - Endpoint string `yaml:"endpoint,omitempty" json:"endpoint"`
51 - Timeout confopt.Duration `yaml:"timeout,omitempty" json:"timeout"`
52 - TLS *bool `yaml:"tls,omitempty" json:"tls"`
53 - Headers map[string]string `yaml:"headers,omitempty" json:"headers"`
54 - TLSServerName string `yaml:"tls_server_name,omitempty" json:"tls_server_name,omitempty"`
55 - tlscfg.TLSConfig `yaml:",inline" json:",inline"`
56 -}
57 -
58 -func (l *LoggingConfig) setDefaults() {
59 - if l == nil {
60 - return
61 - }
62 - if l.OTLP.Endpoint == "" {
63 - l.OTLP.Endpoint = runtime.DefaultOTLPEndpoint
64 - }
65 - if l.OTLP.Timeout == 0 {
66 - l.OTLP.Timeout = confopt.Duration(runtime.DefaultOTLPTimeout)
67 - }
68 - if l.OTLP.Headers == nil {
69 - l.OTLP.Headers = make(map[string]string)
70 - }
71 - if l.OTLP.TLS == nil {
72 - v := true
73 - l.OTLP.TLS = &v
74 - }
75 - if !l.Enabled {
76 - l.Enabled = true
77 - }
78 -}
79 -
80 -func (l LoggingConfig) emitterConfig() runtime.OTLPEmitterConfig {
81 - return runtime.OTLPEmitterConfig{
82 - Endpoint: l.OTLP.Endpoint,
83 - Timeout: time.Duration(l.OTLP.Timeout),
84 - UseTLS: l.OTLP.tlsEnabled(),
85 - Headers: l.OTLP.Headers,
86 - TLSConfig: l.OTLP.TLSConfig,
87 - ServerName: l.OTLP.TLSServerName,
88 - }
89 -}
90 -
91 -func (c OTLPLoggingConfig) tlsEnabled() bool {
92 - if c.TLS == nil {
93 - return true
94 - }
95 - return *c.TLS
96 -}
97 -
98 -// Collector implements the virtual scheduler job.
99 -type Collector struct {
100 - collectorapi.Base
101 - Config `yaml:",inline" json:",inline"`
102 -
103 - applied bool
104 - charts *collectorapi.Charts
105 -}
106 -
107 -// New returns a Collector with defaults applied.
108 -func New() *Collector {
109 - cfg := Config{
110 - Workers: 50,
111 - QueueSize: 128,
112 - Labels: make(map[string]string),
113 - Logging: LoggingConfig{
114 - Enabled: true,
115 - OTLP: OTLPLoggingConfig{
116 - Endpoint: runtime.DefaultOTLPEndpoint,
117 - Timeout: confopt.Duration(runtime.DefaultOTLPTimeout),
118 - TLS: boolPtr(false),
119 - Headers: make(map[string]string),
120 - },
121 - },
122 - }
123 - return &Collector{Config: cfg, charts: &collectorapi.Charts{}}
124 -}
125 -
126 -func boolPtr(v bool) *bool {
127 - b := v
128 - return &b
129 -}
130 -
131 -// Configuration satisfies module.Module.
132 -func (c *Collector) Configuration() any { return &c.Config }
133 -
134 -func (c *Collector) Init(context.Context) error {
135 - if c.Name == "" {
136 - return fmt.Errorf("scheduler name is required")
137 - }
138 - if c.Workers <= 0 {
139 - c.Workers = 50
140 - }
141 - if c.QueueSize <= 0 {
142 - c.QueueSize = 128
143 - }
144 - if c.Labels == nil {
145 - c.Labels = make(map[string]string)
146 - }
147 - c.Logging.setDefaults()
148 - if c.charts == nil {
149 - c.charts = &collectorapi.Charts{}
150 - }
151 - charts := charts.BuildSchedulerCharts(c.Name, 100)
152 - if err := c.charts.Add(charts...); err != nil {
153 - return err
154 - }
155 -
156 - def := schedulers.Definition{
157 - Name: c.Name,
158 - Workers: c.Workers,
159 - QueueSize: c.QueueSize,
160 - Labels: c.Labels,
161 - LoggingEnabled: c.Logging.Enabled,
162 - Logging: c.Logging.emitterConfig(),
163 - }
164 - if err := schedulers.ApplyDefinition(def, c.Logger); err != nil {
165 - return err
166 - }
167 - c.applied = true
168 - return nil
169 -}
170 -
171 -func (c *Collector) Check(context.Context) error { return nil }
172 -
173 -func (c *Collector) Charts() *collectorapi.Charts { return c.charts }
174 -
175 -func (c *Collector) Collect(context.Context) map[string]int64 {
176 - all := collectSchedulerMetrics(c.Name)
177 - if len(all) == 0 {
178 - return nil
179 - }
180 - prefix := fmt.Sprintf("%s.scheduler.", c.Name)
181 - filtered := make(map[string]int64)
182 - for k, v := range all {
183 - if strings.HasPrefix(k, prefix) {
184 - filtered[k] = v
185 - }
186 - }
187 - if len(filtered) == 0 {
188 - return nil
189 - }
190 - return filtered
191 -}
192 -
193 -func (c *Collector) Cleanup(context.Context) {
194 - if c.applied {
195 - _ = schedulers.RemoveDefinition(c.Name)
196 - }
197 - if c.charts != nil {
198 - c.charts = &collectorapi.Charts{}
199 - }
200 -}
src/go/plugin/scripts.d/modules/scheduler/module_test.go deleted
-43
@@ -1,43 +0,0 @@
1 -package scheduler
2 -
3 -import (
4 - "testing"
5 -
6 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/schedulers"
7 -)
8 -
9 -func TestCollectorInitRegistersDefinition(t *testing.T) {
10 - c := New()
11 - c.Name = "test-scheduler"
12 - if err := c.Init(nil); err != nil {
13 - t.Fatalf("Init failed: %v", err)
14 - }
15 - defer c.Cleanup(nil)
16 - if def, ok := schedulers.Get("test-scheduler"); !ok || def.Workers != c.Workers {
17 - t.Fatalf("scheduler definition not registered: %+v ok=%v", def, ok)
18 - }
19 -}
20 -
21 -func TestCollectorCollectFiltersMetrics(t *testing.T) {
22 - c := New()
23 - c.Name = "collect-test"
24 - if err := c.Init(nil); err != nil {
25 - t.Fatalf("init failed: %v", err)
26 - }
27 - defer c.Cleanup(nil)
28 - orig := collectSchedulerMetrics
29 - collectSchedulerMetrics = func(string) map[string]int64 {
30 - return map[string]int64{
31 - "collect-test.scheduler.jobs.active": 1,
32 - "other.scheduler.jobs.active": 2,
33 - }
34 - }
35 - defer func() { collectSchedulerMetrics = orig }()
36 - metrics := c.Collect(nil)
37 - if metrics == nil || metrics["collect-test.scheduler.jobs.active"] != 1 {
38 - t.Fatalf("expected filtered scheduler metrics, got %v", metrics)
39 - }
40 - if len(metrics) != 1 {
41 - t.Fatalf("expected only matching prefix, got %v", metrics)
42 - }
43 -}
src/go/plugin/scripts.d/pkg/config/defaults.go deleted
-80
@@ -1,80 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package config
4 -
5 -import (
6 - "github.com/netdata/netdata/go/plugins/pkg/confopt"
7 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
8 -)
9 -
10 -// Defaults describe reusable Nagios job attributes that can be applied to
11 -// multiple JobConfig entries.
12 -type Defaults struct {
13 - Timeout confopt.Duration `yaml:"timeout,omitempty" json:"timeout"`
14 - TimeoutState string `yaml:"timeout_state,omitempty" json:"timeout_state"`
15 - CheckInterval confopt.Duration `yaml:"check_interval,omitempty" json:"check_interval"`
16 - RetryInterval confopt.Duration `yaml:"retry_interval,omitempty" json:"retry_interval"`
17 - MaxCheckAttempts int `yaml:"max_check_attempts,omitempty" json:"max_check_attempts"`
18 - InterCheckJitter confopt.Duration `yaml:"inter_check_jitter,omitempty" json:"inter_check_jitter"`
19 - WorkingDirectory string `yaml:"working_directory,omitempty" json:"working_directory"`
20 - CheckPeriod string `yaml:"check_period,omitempty" json:"check_period"`
21 -}
22 -
23 -// Apply copies default values into the provided job when the job left the field unset.
24 -func (d Defaults) Apply(job *spec.JobConfig) {
25 - if job.Timeout == 0 && d.Timeout > 0 {
26 - job.Timeout = d.Timeout
27 - }
28 - if job.TimeoutState == "" && d.TimeoutState != "" {
29 - job.TimeoutState = d.TimeoutState
30 - }
31 - if job.CheckInterval == 0 && d.CheckInterval > 0 {
32 - job.CheckInterval = d.CheckInterval
33 - }
34 - if job.RetryInterval == 0 && d.RetryInterval > 0 {
35 - job.RetryInterval = d.RetryInterval
36 - }
37 - if job.MaxCheckAttempts == 0 && d.MaxCheckAttempts > 0 {
38 - job.MaxCheckAttempts = d.MaxCheckAttempts
39 - }
40 - if job.InterCheckJitter == 0 && d.InterCheckJitter > 0 {
41 - job.InterCheckJitter = d.InterCheckJitter
42 - }
43 - if job.WorkingDirectory == "" && d.WorkingDirectory != "" {
44 - job.WorkingDirectory = d.WorkingDirectory
45 - }
46 - if job.CheckPeriod == "" && d.CheckPeriod != "" {
47 - job.CheckPeriod = d.CheckPeriod
48 - }
49 -}
50 -
51 -// Merge returns a Defaults struct where non-zero fields from the override replace
52 -// the base values.
53 -func (d Defaults) Merge(override Defaults) Defaults {
54 - res := d
55 - if override.Timeout > 0 {
56 - res.Timeout = override.Timeout
57 - }
58 - if override.TimeoutState != "" {
59 - res.TimeoutState = override.TimeoutState
60 - }
61 - if override.CheckInterval > 0 {
62 - res.CheckInterval = override.CheckInterval
63 - }
64 - if override.RetryInterval > 0 {
65 - res.RetryInterval = override.RetryInterval
66 - }
67 - if override.MaxCheckAttempts > 0 {
68 - res.MaxCheckAttempts = override.MaxCheckAttempts
69 - }
70 - if override.InterCheckJitter > 0 {
71 - res.InterCheckJitter = override.InterCheckJitter
72 - }
73 - if override.WorkingDirectory != "" {
74 - res.WorkingDirectory = override.WorkingDirectory
75 - }
76 - if override.CheckPeriod != "" {
77 - res.CheckPeriod = override.CheckPeriod
78 - }
79 - return res
80 -}
src/go/plugin/scripts.d/pkg/ids/ids.go deleted
-51
@@ -1,51 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package ids
4 -
5 -import (
6 - "crypto/sha1"
7 - "fmt"
8 - "strings"
9 -)
10 -
11 -// Sanitize converts a job name into a lowercase alphanumeric identifier with underscores.
12 -func Sanitize(name string) string {
13 - lower := strings.ToLower(name)
14 - var b strings.Builder
15 - b.Grow(len(lower))
16 - lastUnderscore := false
17 - hasAlnum := false
18 - for _, r := range lower {
19 - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
20 - b.WriteRune(r)
21 - lastUnderscore = false
22 - hasAlnum = true
23 - continue
24 - }
25 - if r == '_' || r == '-' || isWhitespace(r) {
26 - if !lastUnderscore {
27 - b.WriteRune('_')
28 - lastUnderscore = true
29 - }
30 - continue
31 - }
32 - if !lastUnderscore {
33 - b.WriteRune('_')
34 - lastUnderscore = true
35 - }
36 - }
37 - result := b.String()
38 - if hasAlnum && result != "" {
39 - return result
40 - }
41 - sum := sha1.Sum([]byte(name))
42 - return fmt.Sprintf("id_%x", sum[:6])
43 -}
44 -
45 -func isWhitespace(r rune) bool {
46 - switch r {
47 - case ' ', '\t', '\n', '\r':
48 - return true
49 - }
50 - return false
51 -}
src/go/plugin/scripts.d/pkg/ids/ids_test.go deleted
-30
@@ -1,30 +0,0 @@
1 -package ids
2 -
3 -import (
4 - "strings"
5 - "testing"
6 -)
7 -
8 -func TestSanitizePreservesBoundaryUnderscores(t *testing.T) {
9 - cases := []struct {
10 - name string
11 - input string
12 - expect string
13 - }{
14 - {name: "plain", input: "Disk usage", expect: "disk_usage"},
15 - {name: "trailing punctuation", input: "Disk usage /", expect: "disk_usage_"},
16 - }
17 -
18 - for _, tc := range cases {
19 - if got := Sanitize(tc.input); got != tc.expect {
20 - t.Fatalf("%s: expected %q, got %q", tc.name, tc.expect, got)
21 - }
22 - }
23 -}
24 -
25 -func TestSanitizeFallsBackForNonAlnum(t *testing.T) {
26 - got := Sanitize("///")
27 - if !strings.HasPrefix(got, "id_") {
28 - t.Fatalf("expected fallback id_*, got %q", got)
29 - }
30 -}
src/go/plugin/scripts.d/pkg/output/parser_test.go deleted
-92
@@ -1,92 +0,0 @@
1 -package output
2 -
3 -import "testing"
4 -
5 -func TestParsePerfdata(t *testing.T) {
6 - raw := []byte("OK - all good | 'time'=123ms;200;500;0;1000 'load1'=0.12;1.0;2.0\nLong output line\nAnother | ignored")
7 - parsed := Parse(raw)
8 - if parsed.StatusLine != "OK - all good" {
9 - t.Fatalf("unexpected status line: %s", parsed.StatusLine)
10 - }
11 - if parsed.LongOutput != "Long output line\nAnother" {
12 - t.Fatalf("unexpected long output: %s", parsed.LongOutput)
13 - }
14 - if len(parsed.Perfdata) != 2 {
15 - t.Fatalf("expected 2 perfdata entries, got %d", len(parsed.Perfdata))
16 - }
17 - if parsed.Perfdata[0].Label != "time" || parsed.Perfdata[0].Unit != "ms" || parsed.Perfdata[0].Value != 123 {
18 - t.Fatalf("unexpected first perfdatum: %+v", parsed.Perfdata[0])
19 - }
20 - if parsed.Perfdata[0].Warn == nil || parsed.Perfdata[0].Warn.High == nil || *parsed.Perfdata[0].Warn.High != 200 {
21 - t.Fatalf("expected warn high=200: %+v", parsed.Perfdata[0].Warn)
22 - }
23 - if parsed.Perfdata[0].Crit == nil || parsed.Perfdata[0].Crit.High == nil || *parsed.Perfdata[0].Crit.High != 500 {
24 - t.Fatalf("expected crit high=500: %+v", parsed.Perfdata[0].Crit)
25 - }
26 - if parsed.Perfdata[1].Label != "load1" || parsed.Perfdata[1].Value != 0.12 {
27 - t.Fatalf("unexpected second perfdatum: %+v", parsed.Perfdata[1])
28 - }
29 -}
30 -
31 -func TestParseRangeVariants(t *testing.T) {
32 - testCases := []struct {
33 - name string
34 - input string
35 - expectNil bool
36 - low *float64
37 - high *float64
38 - inclusive bool
39 - }{
40 - {name: "simple", input: "10", low: floatPtr(0), high: floatPtr(10)},
41 - {name: "range", input: "10:20", low: floatPtr(10), high: floatPtr(20)},
42 - {name: "inclusive", input: "@5:15", low: floatPtr(5), high: floatPtr(15), inclusive: true},
43 - {name: "lower_unbounded", input: "~:5", low: nil, high: floatPtr(5)},
44 - {name: "upper_unbounded", input: "10:", low: floatPtr(10), high: nil},
45 - {name: "default_low", input: ":30", low: floatPtr(0), high: floatPtr(30)},
46 - {name: "unknown", input: "U", expectNil: true},
47 - }
48 - for _, tc := range testCases {
49 - t.Run(tc.name, func(t *testing.T) {
50 - rng := parseRange(tc.input)
51 - if tc.expectNil {
52 - if rng != nil {
53 - t.Fatalf("expected nil range, got %+v", rng)
54 - }
55 - return
56 - }
57 - if rng == nil {
58 - t.Fatalf("expected non-nil range for %q", tc.input)
59 - }
60 - if tc.low == nil {
61 - if rng.Low != nil {
62 - t.Fatalf("expected nil low, got %+v", *rng.Low)
63 - }
64 - } else if rng.Low == nil || *rng.Low != *tc.low {
65 - t.Fatalf("unexpected low: got %v want %v", rng.Low, *tc.low)
66 - }
67 - if tc.high == nil {
68 - if rng.High != nil {
69 - t.Fatalf("expected nil high, got %+v", *rng.High)
70 - }
71 - } else if rng.High == nil || *rng.High != *tc.high {
72 - t.Fatalf("unexpected high: got %v want %v", rng.High, *tc.high)
73 - }
74 - if rng.Inclusive != tc.inclusive {
75 - t.Fatalf("unexpected inclusive flag: got %v want %v", rng.Inclusive, tc.inclusive)
76 - }
77 - })
78 - }
79 -}
80 -
81 -func floatPtr(v float64) *float64 {
82 - return &v
83 -}
84 -
85 -func TestParseLongOutputPreservesLeadingWhitespace(t *testing.T) {
86 - raw := []byte("WARNING something broke\n first line\n\tsecond line \n")
87 - parsed := Parse(raw)
88 - expected := " first line\n\tsecond line"
89 - if parsed.LongOutput != expected {
90 - t.Fatalf("expected long output %q, got %q", expected, parsed.LongOutput)
91 - }
92 -}
src/go/plugin/scripts.d/pkg/runtime/emitter.go deleted
-81
@@ -1,81 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package runtime
4 -
5 -import (
6 - "strings"
7 - "time"
8 -
9 - "github.com/netdata/netdata/go/plugins/logger"
10 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/output"
11 -)
12 -
13 -// ResultEmitter receives execution snapshots for downstream logging/OTEL pipelines.
14 -type ResultEmitter interface {
15 - Emit(JobRuntime, ExecutionResult, JobSnapshot)
16 - Close() error
17 -}
18 -
19 -// JobSnapshot captures the scheduler's view of job state when emitting results.
20 -type JobSnapshot struct {
21 - HardState string
22 - SoftState string
23 - PrevHardState string
24 - Attempts int
25 - Duration time.Duration
26 - Timestamp time.Time
27 - Output output.ParsedOutput
28 -}
29 -
30 -type noopEmitter struct{}
31 -
32 -func (noopEmitter) Emit(JobRuntime, ExecutionResult, JobSnapshot) {}
33 -func (noopEmitter) Close() error { return nil }
34 -
35 -// NewNoopEmitter returns a ResultEmitter that discards all events.
36 -func NewNoopEmitter() ResultEmitter { return noopEmitter{} }
37 -
38 -// LogEmitter emits execution summaries to the plugin logger (placeholder for OTEL/log export).
39 -type LogEmitter struct {
40 - log *logger.Logger
41 -}
42 -
43 -// NewLogEmitter builds a logger-backed emitter (falls back to noop when log is nil).
44 -func NewLogEmitter(log *logger.Logger) ResultEmitter {
45 - if log == nil {
46 - return NewNoopEmitter()
47 - }
48 - return &LogEmitter{log: log}
49 -}
50 -
51 -func (e *LogEmitter) Emit(job JobRuntime, res ExecutionResult, snap JobSnapshot) {
52 - if e.log == nil {
53 - return
54 - }
55 -
56 - status := snap.Output.StatusLine
57 - longOut := snap.Output.LongOutput
58 - if len(longOut) > 512 {
59 - longOut = longOut[:512] + "…"
60 - }
61 -
62 - e.log.Infof(
63 - "nagios job result: job=%s plugin=%s state=%s exit=%d duration=%s cmd=%q perf=%d",
64 - job.Spec.Name,
65 - job.Spec.Plugin,
66 - snap.HardState,
67 - res.ExitCode,
68 - snap.Duration,
69 - res.Command,
70 - len(snap.Output.Perfdata),
71 - )
72 -
73 - if status != "" {
74 - e.log.Debugf("nagios job status: job=%s status=%q", job.Spec.Name, status)
75 - }
76 - if longOut != "" {
77 - e.log.Debugf("nagios job long_output: job=%s output=%q", job.Spec.Name, strings.ReplaceAll(longOut, "\n", " | "))
78 - }
79 -}
80 -
81 -func (e *LogEmitter) Close() error { return nil }
src/go/plugin/scripts.d/pkg/runtime/emitter_otel.go deleted
-251
@@ -1,251 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package runtime
4 -
5 -import (
6 - "context"
7 - "crypto/tls"
8 - "fmt"
9 - "net"
10 - "strings"
11 - "sync"
12 - "time"
13 -
14 - "github.com/netdata/netdata/go/plugins/logger"
15 - "github.com/netdata/netdata/go/plugins/pkg/tlscfg"
16 - collectorlogs "go.opentelemetry.io/proto/otlp/collector/logs/v1"
17 - commonpb "go.opentelemetry.io/proto/otlp/common/v1"
18 - logspb "go.opentelemetry.io/proto/otlp/logs/v1"
19 - resourcepb "go.opentelemetry.io/proto/otlp/resource/v1"
20 - "google.golang.org/grpc"
21 - "google.golang.org/grpc/credentials"
22 - "google.golang.org/grpc/credentials/insecure"
23 - "google.golang.org/grpc/metadata"
24 -)
25 -
26 -// OTLPEmitterConfig configures the OTLP emitter.
27 -type OTLPEmitterConfig struct {
28 - Endpoint string
29 - Timeout time.Duration
30 - UseTLS bool
31 - Headers map[string]string
32 - TLSConfig tlscfg.TLSConfig
33 - ServerName string
34 -}
35 -
36 -const (
37 - DefaultOTLPEndpoint = "127.0.0.1:4317"
38 - DefaultOTLPTimeout = 5 * time.Second
39 -)
40 -
41 -type otlpEmitter struct {
42 - client collectorlogs.LogsServiceClient
43 - conn *grpc.ClientConn
44 - log *logger.Logger
45 -
46 - timeout time.Duration
47 - headers metadata.MD
48 -
49 - resource *resourcepb.Resource
50 - scope *commonpb.InstrumentationScope
51 -
52 - mu sync.Mutex
53 -}
54 -
55 -func NewOTLPEmitter(cfg OTLPEmitterConfig, log *logger.Logger) (ResultEmitter, error) {
56 - endpoint := cfg.Endpoint
57 - if endpoint == "" {
58 - endpoint = DefaultOTLPEndpoint
59 - }
60 - timeout := cfg.Timeout
61 - if timeout <= 0 {
62 - timeout = DefaultOTLPTimeout
63 - }
64 -
65 - dialOpts := []grpc.DialOption{grpc.WithBlock()}
66 - if cfg.UseTLS {
67 - tlsConf, err := tlscfg.NewTLSConfig(cfg.TLSConfig)
68 - if err != nil {
69 - return nil, err
70 - }
71 - if tlsConf == nil {
72 - tlsConf = &tls.Config{}
73 - }
74 - if tlsConf.MinVersion == 0 {
75 - tlsConf.MinVersion = tls.VersionTLS12
76 - }
77 - if tlsConf.ServerName == "" {
78 - if cfg.ServerName != "" {
79 - tlsConf.ServerName = cfg.ServerName
80 - } else if host, _, err := net.SplitHostPort(endpoint); err == nil {
81 - tlsConf.ServerName = host
82 - } else {
83 - tlsConf.ServerName = endpoint
84 - }
85 - }
86 - dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConf)))
87 - } else {
88 - dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
89 - }
90 -
91 - ctx, cancel := context.WithTimeout(context.Background(), timeout)
92 - defer cancel()
93 -
94 - conn, err := grpc.DialContext(ctx, endpoint, dialOpts...)
95 - if err != nil {
96 - return nil, err
97 - }
98 -
99 - md := metadata.New(nil)
100 - for k, v := range cfg.Headers {
101 - md.Set(k, v)
102 - }
103 -
104 - scope := &commonpb.InstrumentationScope{Name: "netdata/scripts", Version: "v0"}
105 - resource := &resourcepb.Resource{
106 - Attributes: []*commonpb.KeyValue{
107 - stringKV("service.name", "netdata-scripts-plugin"),
108 - stringKV("netdata.agent", "true"),
109 - },
110 - }
111 -
112 - return &otlpEmitter{
113 - client: collectorlogs.NewLogsServiceClient(conn),
114 - conn: conn,
115 - log: log,
116 - timeout: timeout,
117 - headers: md,
118 - resource: resource,
119 - scope: scope,
120 - }, nil
121 -}
122 -
123 -func (e *otlpEmitter) Emit(job JobRuntime, res ExecutionResult, snap JobSnapshot) {
124 - records := buildOTLPRecords(job, res, snap)
125 - if len(records) == 0 {
126 - return
127 - }
128 -
129 - scopeLogs := &logspb.ScopeLogs{
130 - Scope: e.scope,
131 - LogRecords: records,
132 - }
133 - resourceLogs := &logspb.ResourceLogs{
134 - Resource: e.resource,
135 - ScopeLogs: []*logspb.ScopeLogs{scopeLogs},
136 - }
137 - req := &collectorlogs.ExportLogsServiceRequest{
138 - ResourceLogs: []*logspb.ResourceLogs{resourceLogs},
139 - }
140 -
141 - ctx, cancel := context.WithTimeout(context.Background(), e.timeout)
142 - defer cancel()
143 - if len(e.headers) > 0 {
144 - ctx = metadata.NewOutgoingContext(ctx, e.headers)
145 - }
146 -
147 - e.mu.Lock()
148 - defer e.mu.Unlock()
149 - if _, err := e.client.Export(ctx, req); err != nil && e.log != nil {
150 - e.log.Errorf("otel log export failed: %v", err)
151 - }
152 -}
153 -
154 -func (e *otlpEmitter) Close() error {
155 - if e.conn != nil {
156 - return e.conn.Close()
157 - }
158 - return nil
159 -}
160 -
161 -func stringKV(key, value string) *commonpb.KeyValue {
162 - return &commonpb.KeyValue{
163 - Key: key,
164 - Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: value}},
165 - }
166 -}
167 -
168 -func int64KV(key string, value int64) *commonpb.KeyValue {
169 - return &commonpb.KeyValue{
170 - Key: key,
171 - Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_IntValue{IntValue: value}},
172 - }
173 -}
174 -
175 -func buildOTLPRecords(job JobRuntime, res ExecutionResult, snap JobSnapshot) []*logspb.LogRecord {
176 - timestamp := snap.Timestamp
177 - if timestamp.IsZero() {
178 - timestamp = time.Now()
179 - }
180 - baseAttrs := []*commonpb.KeyValue{
181 - stringKV("NAGIOS_PLUGIN", job.Spec.Plugin),
182 - stringKV("NAGIOS_JOB", job.Spec.Name),
183 - stringKV("NAGIOS_STATE", snap.HardState),
184 - int64KV("NAGIOS_EXIT_CODE", int64(res.ExitCode)),
185 - int64KV("NAGIOS_DURATION_MS", int64(snap.Duration/time.Millisecond)),
186 - }
187 - if job.Spec.Vnode != "" {
188 - baseAttrs = append(baseAttrs, stringKV("NAGIOS_VNODE", job.Spec.Vnode))
189 - }
190 - if res.Command != "" {
191 - baseAttrs = append(baseAttrs, stringKV("NAGIOS_COMMAND", res.Command))
192 - }
193 -
194 - var records []*logspb.LogRecord
195 -
196 - execBody := fmt.Sprintf("plugin %s finished with state %s", job.Spec.Name, snap.HardState)
197 - records = append(records, buildLogRecord(timestamp, execBody, messageIDExecution, logspb.SeverityNumber_SEVERITY_NUMBER_INFO, baseAttrs))
198 -
199 - if snap.Output.StatusLine != "" {
200 - records = append(records, buildLogRecord(timestamp, snap.Output.StatusLine, messageIDStdout, logspb.SeverityNumber_SEVERITY_NUMBER_INFO, baseAttrs))
201 - }
202 -
203 - if snap.Output.LongOutput != "" {
204 - records = append(records, buildLogRecord(timestamp, snap.Output.LongOutput, messageIDLongOutput, logspb.SeverityNumber_SEVERITY_NUMBER_INFO, baseAttrs))
205 - }
206 -
207 - if res.Err != nil {
208 - records = append(records, buildLogRecord(timestamp, res.Err.Error(), messageIDStderr, logspb.SeverityNumber_SEVERITY_NUMBER_ERROR, baseAttrs))
209 - }
210 -
211 - if snap.PrevHardState != "" && snap.PrevHardState != snap.HardState {
212 - attrs := append(baseAttrs, stringKV("NAGIOS_OLD_STATE", snap.PrevHardState))
213 - attrs = append(attrs, stringKV("NAGIOS_NEW_STATE", snap.HardState))
214 - attrs = append(attrs, int64KV("NAGIOS_ATTEMPT", int64(snap.Attempts)))
215 - severity := severityForState(snap.HardState)
216 - body := fmt.Sprintf("state transition %s -> %s", snap.PrevHardState, snap.HardState)
217 - records = append(records, buildLogRecord(timestamp, body, messageIDStateTransition, severity, attrs))
218 - }
219 -
220 - return records
221 -}
222 -
223 -func buildLogRecord(ts time.Time, body string, messageID string, severity logspb.SeverityNumber, attrs []*commonpb.KeyValue) *logspb.LogRecord {
224 - lr := &logspb.LogRecord{
225 - TimeUnixNano: uint64(ts.UnixNano()),
226 - ObservedTimeUnixNano: uint64(time.Now().UnixNano()),
227 - SeverityNumber: severity,
228 - Body: &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: body}},
229 - Attributes: append([]*commonpb.KeyValue{stringKV("MESSAGE_ID", messageID)}, attrs...),
230 - }
231 - return lr
232 -}
233 -
234 -func severityForState(state string) logspb.SeverityNumber {
235 - switch strings.ToUpper(state) {
236 - case "CRITICAL":
237 - return logspb.SeverityNumber_SEVERITY_NUMBER_ERROR
238 - case "WARNING":
239 - return logspb.SeverityNumber_SEVERITY_NUMBER_WARN
240 - default:
241 - return logspb.SeverityNumber_SEVERITY_NUMBER_INFO
242 - }
243 -}
244 -
245 -const (
246 - messageIDExecution = "4fdf40816c124623a032b7fe73beacb8"
247 - messageIDStdout = "ec87a56120d5431bace51e2fb8bba243"
248 - messageIDStderr = "23e93dfccbf64e11aac858b9410d8a82"
249 - messageIDLongOutput = "d1f59606dd4d41e3b217a0cfcae8e632"
250 - messageIDStateTransition = "9ce0cb58ab8b44df82c4bf1ad9ee22de"
251 -)
src/go/plugin/scripts.d/pkg/runtime/executor.go deleted
-215
@@ -1,215 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package runtime
4 -
5 -import (
6 - "context"
7 - "errors"
8 - "fmt"
9 - "sync"
10 - "sync/atomic"
11 - "time"
12 -
13 - "github.com/netdata/netdata/go/plugins/logger"
14 - "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/ndexec"
15 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/output"
16 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
17 -)
18 -
19 -// ErrExecutorStopped indicates enqueueing was attempted after the executor stopped.
20 -var ErrExecutorStopped = errors.New("nagios executor stopped")
21 -
22 -// ErrNoWorkFunc is returned when no worker function was provided.
23 -var ErrNoWorkFunc = errors.New("nagios executor work function not configured")
24 -
25 -// JobRuntime wraps the static job spec plus runtime metadata (IDs, vnode, etc.).
26 -type JobRuntime struct {
27 - ID string
28 - Spec spec.JobSpec
29 - Vnode VnodeInfo
30 -}
31 -
32 -// ExecutionResult contains the outcome of a plugin invocation.
33 -type ExecutionResult struct {
34 - Job JobRuntime
35 - Output []byte
36 - Err error
37 - State string
38 - ExitCode int
39 - Command string
40 - Parsed output.ParsedOutput
41 - Start time.Time
42 - End time.Time
43 - Attempt int
44 - Duration time.Duration
45 - WorkerIdx int
46 - Usage ndexec.ResourceUsage
47 -}
48 -
49 -// WorkFunc executes a Nagios job and returns the result.
50 -type WorkFunc func(context.Context, JobRuntime) ExecutionResult
51 -
52 -// ExecutorConfig encapsulates runtime parameters for the job executor.
53 -type ExecutorConfig struct {
54 - Logger *logger.Logger
55 - Workers int
56 - QueueCapacity int
57 - Work WorkFunc
58 -}
59 -
60 -// Executor orchestrates asynchronous job execution using a bounded queue and worker pool.
61 -type Executor struct {
62 - cfg ExecutorConfig
63 -
64 - ctx context.Context
65 - cancel context.CancelFunc
66 -
67 - queue chan JobRuntime
68 - results chan ExecutionResult
69 - resetNeeded bool
70 -
71 - mu sync.Mutex
72 - inflight map[string]struct{}
73 - executing map[string]struct{}
74 -
75 - wg sync.WaitGroup
76 -
77 - skipped atomic.Uint64
78 -}
79 -
80 -// ExecutorStats captures instantaneous telemetry for the executor internals.
81 -type ExecutorStats struct {
82 - QueueDepth int
83 - Executing int
84 - SkippedEnqueue uint64
85 -}
86 -
87 -// NewExecutor returns an executor configured with the provided parameters.
88 -func NewExecutor(cfg ExecutorConfig) (*Executor, error) {
89 - if cfg.Workers <= 0 {
90 - return nil, fmt.Errorf("executor workers must be > 0")
91 - }
92 - if cfg.QueueCapacity <= 0 {
93 - return nil, fmt.Errorf("executor queue capacity must be > 0")
94 - }
95 - if cfg.Work == nil {
96 - return nil, ErrNoWorkFunc
97 - }
98 -
99 - exec := &Executor{
100 - cfg: cfg,
101 - inflight: make(map[string]struct{}),
102 - executing: make(map[string]struct{}),
103 - }
104 - exec.resetChannels()
105 -
106 - return exec, nil
107 -}
108 -
109 -func (e *Executor) resetChannels() {
110 - e.queue = make(chan JobRuntime, e.cfg.QueueCapacity)
111 - e.results = make(chan ExecutionResult, e.cfg.QueueCapacity)
112 -}
113 -
114 -// Start initializes the worker pool and begins draining the waiting queue.
115 -func (e *Executor) Start(ctx context.Context) {
116 - if e.ctx != nil {
117 - return
118 - }
119 - if e.resetNeeded {
120 - e.resetChannels()
121 - e.resetNeeded = false
122 - }
123 - e.ctx, e.cancel = context.WithCancel(ctx)
124 - for i := 0; i < e.cfg.Workers; i++ {
125 - e.wg.Add(1)
126 - go e.workerLoop(i)
127 - }
128 -}
129 -
130 -// Stop cancels workers and waits for them to exit.
131 -func (e *Executor) Stop() {
132 - if e.cancel == nil {
133 - return
134 - }
135 - e.cancel()
136 - e.wg.Wait()
137 - close(e.results)
138 - e.cancel = nil
139 - e.ctx = nil
140 - e.resetNeeded = true
141 -}
142 -
143 -// Enqueue schedules a job for execution respecting single-flight semantics.
144 -// Returns true when the job was queued, false if it was already running.
145 -func (e *Executor) Enqueue(job JobRuntime) (bool, error) {
146 - if e.ctx == nil {
147 - return false, ErrExecutorStopped
148 - }
149 - e.mu.Lock()
150 - if _, exists := e.inflight[job.ID]; exists {
151 - e.mu.Unlock()
152 - e.skipped.Add(1)
153 - return false, nil
154 - }
155 - e.inflight[job.ID] = struct{}{}
156 - e.mu.Unlock()
157 -
158 - select {
159 - case e.queue <- job:
160 - return true, nil
161 - case <-e.ctx.Done():
162 - e.mu.Lock()
163 - delete(e.inflight, job.ID)
164 - e.mu.Unlock()
165 - return false, ErrExecutorStopped
166 - }
167 -}
168 -
169 -// Results exposes the channel of execution results for the scheduler/state machine.
170 -func (e *Executor) Results() <-chan ExecutionResult {
171 - return e.results
172 -}
173 -
174 -// Stats returns a snapshot of executor internals for telemetry.
175 -func (e *Executor) Stats() ExecutorStats {
176 - e.mu.Lock()
177 - executing := len(e.executing)
178 - e.mu.Unlock()
179 -
180 - return ExecutorStats{
181 - QueueDepth: len(e.queue),
182 - Executing: executing,
183 - SkippedEnqueue: e.skipped.Load(),
184 - }
185 -}
186 -
187 -func (e *Executor) workerLoop(idx int) {
188 - defer e.wg.Done()
189 -
190 - for {
191 - select {
192 - case <-e.ctx.Done():
193 - return
194 - case job := <-e.queue:
195 - // Mark the job as executing.
196 - e.mu.Lock()
197 - e.executing[job.ID] = struct{}{}
198 - e.mu.Unlock()
199 -
200 - res := e.cfg.Work(e.ctx, job)
201 - res.WorkerIdx = idx
202 -
203 - e.mu.Lock()
204 - delete(e.executing, job.ID)
205 - delete(e.inflight, job.ID)
206 - e.mu.Unlock()
207 -
208 - select {
209 - case e.results <- res:
210 - case <-e.ctx.Done():
211 - return
212 - }
213 - }
214 - }
215 -}
src/go/plugin/scripts.d/pkg/runtime/executor_test.go deleted
-54
@@ -1,54 +0,0 @@
1 -package runtime
2 -
3 -import (
4 - "context"
5 - "testing"
6 - "time"
7 -
8 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
9 -)
10 -
11 -func TestExecutorSingleFlight(t *testing.T) {
12 - work := func(ctx context.Context, job JobRuntime) ExecutionResult {
13 - start := time.Now()
14 - time.Sleep(5 * time.Millisecond)
15 - return ExecutionResult{Job: job, Start: start, End: time.Now(), Duration: time.Since(start)}
16 - }
17 -
18 - exec, err := NewExecutor(ExecutorConfig{
19 - Workers: 1,
20 - QueueCapacity: 2,
21 - Work: work,
22 - })
23 - if err != nil {
24 - t.Fatalf("unexpected error: %v", err)
25 - }
26 -
27 - ctx, cancel := context.WithCancel(context.Background())
28 - defer cancel()
29 -
30 - exec.Start(ctx)
31 - defer exec.Stop()
32 -
33 - job := JobRuntime{ID: "job-1", Spec: spec.JobSpec{Name: "job-1"}}
34 -
35 - if ok, err := exec.Enqueue(job); err != nil || !ok {
36 - t.Fatalf("expected first enqueue to succeed, got ok=%v err=%v", ok, err)
37 - }
38 -
39 - if ok, err := exec.Enqueue(job); err != nil {
40 - t.Fatalf("unexpected error on duplicate enqueue: %v", err)
41 - } else if ok {
42 - t.Fatalf("expected duplicate enqueue to be skipped")
43 - }
44 -
45 - select {
46 - case <-exec.Results():
47 - case <-time.After(time.Second):
48 - t.Fatal("timed out waiting for execution result")
49 - }
50 -
51 - if ok, err := exec.Enqueue(job); err != nil || !ok {
52 - t.Fatalf("expected enqueue after completion to succeed, got ok=%v err=%v", ok, err)
53 - }
54 -}
src/go/plugin/scripts.d/pkg/runtime/macro_source.go deleted
-12
@@ -1,12 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package runtime
4 -
5 -import "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
6 -
7 -// MacroSource exposes the data required by the macro builder.
8 -type MacroSource interface {
9 - JobSpecs() []spec.JobSpec
10 - UserMacros() map[string]string
11 - VnodeInfo(job spec.JobSpec) VnodeInfo
12 -}
src/go/plugin/scripts.d/pkg/runtime/macros.go deleted
-153
@@ -1,153 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package runtime
4 -
5 -import (
6 - "fmt"
7 - "strings"
8 - "time"
9 -
10 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
11 -)
12 -
13 -// MacroContext collects all sources needed to substitute Nagios macros and
14 -// build the environment map for plugin execution.
15 -type MacroContext struct {
16 - Job spec.JobSpec
17 - UserMacros map[string]string
18 - Vnode VnodeInfo
19 - State StateInfo
20 -}
21 -
22 -// VnodeInfo mirrors the subset of data needed from Netdata virtual nodes.
23 -// Nagios-specific fields are derived from labels using prefix conventions:
24 -// - "_address" label → $HOSTADDRESS$
25 -// - "_alias" label → $HOSTALIAS$
26 -// - "_" prefixed keys → $_HOST*$ custom variables (prefix stripped)
27 -// - other keys → $_HOSTLABEL_*$ label macros
28 -type VnodeInfo struct {
29 - Hostname string
30 - Labels map[string]string
31 -}
32 -
33 -// StateInfo contains runtime state variables for macros (SERVICESTATE, etc.).
34 -type StateInfo struct {
35 - ServiceState string
36 - ServiceAttempt int
37 - ServiceMaxAttempts int
38 - HostState string
39 - HostStateID string
40 -}
41 -
42 -// MacroSet holds the resolved string replacements consumed by argument and
43 -// environment builders.
44 -type MacroSet struct {
45 - CommandArgs []string
46 - Env map[string]string
47 -}
48 -
49 -// BuildMacroSet resolves macros and environment variables for a job.
50 -func BuildMacroSet(ctx MacroContext) MacroSet {
51 - env := make(map[string]string)
52 -
53 - now := time.Now()
54 - set := func(key, value string) {
55 - if value != "" {
56 - env[key] = value
57 - }
58 - }
59 -
60 - set("NAGIOS_PLUGIN", ctx.Job.Plugin)
61 - set("NAGIOS_JOB", ctx.Job.Name)
62 - set("NAGIOS_HOSTNAME", firstNonEmpty(ctx.Job.Vnode, ctx.Vnode.Hostname))
63 - set("NAGIOS_HOSTADDRESS", ctx.Vnode.Labels["_address"])
64 - set("NAGIOS_HOSTALIAS", ctx.Vnode.Labels["_alias"])
65 - set("NAGIOS_SERVICEDESC", ctx.Job.Name)
66 - set("NAGIOS_SERVICESTATE", ctx.State.ServiceState)
67 - set("NAGIOS_SERVICESTATEID", stateID(ctx.State.ServiceState))
68 - if ctx.State.ServiceAttempt > 0 {
69 - set("NAGIOS_SERVICEATTEMPT", fmt.Sprintf("%d", ctx.State.ServiceAttempt))
70 - }
71 - if ctx.State.ServiceMaxAttempts > 0 {
72 - set("NAGIOS_MAXSERVICEATTEMPTS", fmt.Sprintf("%d", ctx.State.ServiceMaxAttempts))
73 - }
74 - set("NAGIOS_HOSTSTATE", ctx.State.HostState)
75 - set("NAGIOS_HOSTSTATEID", ctx.State.HostStateID)
76 - set("NAGIOS_LONGDATETIME", now.Format(time.RFC1123))
77 - set("NAGIOS_SHORTDATETIME", now.Format("2006-01-02 15:04"))
78 - set("NAGIOS_DATE", now.Format("2006-01-02"))
79 - set("NAGIOS_TIME", now.Format("15:04:05"))
80 - set("NAGIOS_TIMET", fmt.Sprintf("%d", now.Unix()))
81 -
82 - for k, v := range ctx.UserMacros {
83 - macro := strings.ToUpper(k)
84 - if strings.HasPrefix(macro, "USER") {
85 - env[fmt.Sprintf("NAGIOS_%s", macro)] = v
86 - }
87 - }
88 -
89 - for k, v := range ctx.Vnode.Labels {
90 - if strings.HasPrefix(k, "_") && k != "_address" && k != "_alias" {
91 - // "_" prefixed labels → $_HOST*$ custom variables (prefix stripped).
92 - env[fmt.Sprintf("NAGIOS__HOST%s", strings.ToUpper(k[1:]))] = v
93 - } else if !strings.HasPrefix(k, "_") {
94 - env[fmt.Sprintf("NAGIOS__HOSTLABEL_%s", strings.ToUpper(k))] = v
95 - }
96 - }
97 - for k, v := range ctx.Job.CustomVars {
98 - key := fmt.Sprintf("NAGIOS__SERVICE%s", strings.ToUpper(k))
99 - env[key] = v
100 - }
101 - for idx := 0; idx < len(ctx.Job.ArgValues) && idx < spec.MaxArgMacros; idx++ {
102 - macro := fmt.Sprintf("NAGIOS_ARG%d", idx+1)
103 - env[macro] = ctx.Job.ArgValues[idx]
104 - }
105 -
106 - cmdArgs := append([]string{}, ctx.Job.Args...)
107 - cmdArgs = substituteArgs(cmdArgs, env)
108 -
109 - return MacroSet{CommandArgs: cmdArgs, Env: env}
110 -}
111 -
112 -func substituteArgs(args []string, env map[string]string) []string {
113 - resolved := make([]string, len(args))
114 - for i, arg := range args {
115 - resolved[i] = replaceMacro(arg, env)
116 - }
117 - return resolved
118 -}
119 -
120 -func replaceMacro(value string, env map[string]string) string {
121 - replaced := value
122 - for key, val := range env {
123 - macro := fmt.Sprintf("$%s$", strings.TrimPrefix(key, "NAGIOS_"))
124 - replaced = strings.ReplaceAll(replaced, macro, val)
125 - }
126 - return replaced
127 -}
128 -
129 -func firstNonEmpty(values ...string) string {
130 - for _, v := range values {
131 - if v != "" {
132 - return v
133 - }
134 - }
135 - return ""
136 -}
137 -
138 -func stateID(state string) string {
139 - switch strings.ToUpper(state) {
140 - case "OK":
141 - return "0"
142 - case "WARNING":
143 - return "1"
144 - case "CRITICAL":
145 - return "2"
146 - case "UNKNOWN":
147 - return "3"
148 - default:
149 - return "3"
150 - }
151 -}
152 -
153 -// Placeholder for future macro expansion functions.
src/go/plugin/scripts.d/pkg/runtime/macros_test.go deleted
-66
@@ -1,66 +0,0 @@
1 -package runtime
2 -
3 -import (
4 - "testing"
5 -
6 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
7 -)
8 -
9 -func TestBuildMacroSet(t *testing.T) {
10 - ctx := MacroContext{
11 - Job: spec.JobSpec{
12 - Name: "http_check",
13 - Plugin: "/usr/lib/nagios/plugins/check_http",
14 - Args: []string{"-H", "$HOSTADDRESS$", "-p", "$ARG1$", "-w", "$ARG2$"},
15 - ArgValues: []string{"8080", "5"},
16 - CustomVars: map[string]string{
17 - "ENDPOINT": "/health",
18 - },
19 - },
20 - UserMacros: map[string]string{"USER1": "/usr/lib/nagios/plugins"},
21 - Vnode: VnodeInfo{
22 - Hostname: "web1",
23 - Labels: map[string]string{
24 - "_address": "192.0.2.10",
25 - "_alias": "web-node",
26 - "_DATACENTER": "us-east-1",
27 - "role": "frontend",
28 - },
29 - },
30 - State: StateInfo{ServiceState: "OK", ServiceAttempt: 2, ServiceMaxAttempts: 5, HostState: "UP", HostStateID: "0"},
31 - }
32 -
33 - s := BuildMacroSet(ctx)
34 -
35 - if got := s.Env["NAGIOS_HOSTADDRESS"]; got != "192.0.2.10" {
36 - t.Fatalf("host address macro mismatch: %s", got)
37 - }
38 - if got := s.Env["NAGIOS__SERVICEENDPOINT"]; got != "/health" {
39 - t.Fatalf("service custom var missing: %s", got)
40 - }
41 - if got := s.Env["NAGIOS__HOSTDATACENTER"]; got != "us-east-1" {
42 - t.Fatalf("host custom var missing: %s", got)
43 - }
44 -
45 - if len(s.CommandArgs) != len(ctx.Job.Args) {
46 - t.Fatalf("arg length mismatch")
47 - }
48 - if s.CommandArgs[1] != "192.0.2.10" || s.CommandArgs[3] != "8080" {
49 - t.Fatalf("macro substitution failed: %v", s.CommandArgs)
50 - }
51 - if got := s.Env["NAGIOS_ARG1"]; got != "8080" {
52 - t.Fatalf("arg macro missing: %s", got)
53 - }
54 - if got := s.Env["NAGIOS_SERVICEATTEMPT"]; got != "2" {
55 - t.Fatalf("service attempt macro missing: %s", got)
56 - }
57 - if got := s.Env["NAGIOS_MAXSERVICEATTEMPTS"]; got != "5" {
58 - t.Fatalf("max attempts macro missing: %s", got)
59 - }
60 - if got := s.Env["NAGIOS_HOSTSTATE"]; got != "UP" {
61 - t.Fatalf("host state macro missing: %s", got)
62 - }
63 - if got := s.Env["NAGIOS__HOSTLABEL_ROLE"]; got != "frontend" {
64 - t.Fatalf("host label macro missing: %s", got)
65 - }
66 -}
src/go/plugin/scripts.d/pkg/runtime/scheduler.go deleted
-866
@@ -1,866 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package runtime
4 -
5 -import (
6 - "context"
7 - "errors"
8 - "fmt"
9 - "math"
10 - "math/rand"
11 - "os"
12 - "sort"
13 - "strings"
14 - "sync"
15 - "sync/atomic"
16 - "time"
17 -
18 - "github.com/netdata/netdata/go/plugins/logger"
19 - "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/ndexec"
20 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/charts"
21 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/ids"
22 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/output"
23 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
24 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/timeperiod"
25 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/units"
26 -)
27 -
28 -// SchedulerConfig drives the construction of the async scheduler/executor pair.
29 -type SchedulerConfig struct {
30 - Logger *logger.Logger
31 - Workers int
32 - QueueCapacity int
33 - SchedulerName string
34 - UserMacros map[string]string
35 - VnodeLookup func(spec.JobSpec) VnodeInfo
36 -}
37 -
38 -// JobRegistration describes a runnable job managed by the scheduler.
39 -type JobRegistration struct {
40 - Spec spec.JobSpec
41 - Runner JobRunner
42 - Emitter ResultEmitter
43 - RegisterPerfdata func(spec.JobSpec, output.PerfDatum)
44 - Periods *timeperiod.Set
45 - UserMacros map[string]string
46 - Vnode VnodeInfo
47 - ID string
48 -}
49 -
50 -// JobRunner allows modules to override the command execution stage.
51 -// The function should honor the provided timeout when respecting ctx deadlines.
52 -type JobRunner func(ctx context.Context, job JobRuntime, timeout time.Duration) ([]byte, string, ndexec.ResourceUsage, error)
53 -
54 -// Scheduler wires the per-job timers, executor, and result collection loops.
55 -type Scheduler struct {
56 - log *logger.Logger
57 -
58 - executor *Executor
59 -
60 - jobs map[string]*jobState
61 - jobMu sync.RWMutex
62 - jobSeq atomic.Uint64
63 -
64 - timerCh chan string
65 -
66 - ctx context.Context
67 - cancel context.CancelFunc
68 -
69 - wg sync.WaitGroup
70 -
71 - userMacros map[string]string
72 - vnodeLookup func(spec.JobSpec) VnodeInfo
73 - schedulerName string
74 - rand *rand.Rand
75 - randMu sync.Mutex
76 -
77 - counters struct {
78 - started atomic.Uint64
79 - finished atomic.Uint64
80 - skipped atomic.Uint64
81 - }
82 -}
83 -
84 -type jobState struct {
85 - runtime JobRuntime
86 - period *timeperiod.Period
87 - nextAnniversary time.Time
88 - nextRun time.Time
89 - timer *time.Timer
90 - attempt int
91 - running bool
92 - lastDuration time.Duration
93 - lastCPU time.Duration
94 - lastRSS int64
95 - lastDiskRead int64
96 - lastDiskWrite int64
97 - cpuMeasured bool
98 - skipped uint64
99 - state string
100 - retrying bool
101 - periodSkipped bool
102 - identity charts.JobIdentity
103 - softAttempts int
104 - hardState string
105 - softState string
106 - checkInterval time.Duration
107 - retryInterval time.Duration
108 - maxAttempts int
109 - timeoutState string
110 - perfdata map[string]output.PerfDatum
111 - statusLine string
112 - longOutput string
113 - jitterRange time.Duration
114 - emitter ResultEmitter
115 - registerPerf func(spec.JobSpec, output.PerfDatum)
116 - runner JobRunner
117 - userMacros map[string]string
118 -}
119 -
120 -// NewScheduler initialises the scheduler structures but does not register jobs.
121 -func NewScheduler(cfg SchedulerConfig) (*Scheduler, error) {
122 - if cfg.Workers <= 0 {
123 - return nil, fmt.Errorf("scheduler workers must be > 0")
124 - }
125 - if cfg.SchedulerName == "" {
126 - cfg.SchedulerName = "default"
127 - }
128 - userMacros := make(map[string]string, len(cfg.UserMacros))
129 - for k, v := range cfg.UserMacros {
130 - userMacros[strings.ToUpper(k)] = v
131 - }
132 - lookup := cfg.VnodeLookup
133 - if lookup == nil {
134 - lookup = func(sp spec.JobSpec) VnodeInfo {
135 - return VnodeInfo{Hostname: sp.Vnode}
136 - }
137 - }
138 - queueCap := cfg.QueueCapacity
139 - if queueCap <= 0 {
140 - queueCap = 32
141 - }
142 - s := &Scheduler{
143 - log: cfg.Logger,
144 - timerCh: make(chan string, queueCap),
145 - jobs: make(map[string]*jobState),
146 - userMacros: userMacros,
147 - vnodeLookup: lookup,
148 - schedulerName: cfg.SchedulerName,
149 - rand: rand.New(rand.NewSource(time.Now().UnixNano())),
150 - }
151 - workFn := func(ctx context.Context, job JobRuntime) ExecutionResult {
152 - return s.runJob(ctx, job)
153 - }
154 - exec, err := NewExecutor(ExecutorConfig{
155 - Logger: cfg.Logger,
156 - Workers: cfg.Workers,
157 - QueueCapacity: queueCap,
158 - Work: workFn,
159 - })
160 - if err != nil {
161 - return nil, err
162 - }
163 - s.executor = exec
164 - return s, nil
165 -}
166 -
167 -// RegisterJob adds a job to the scheduler and arms its timer.
168 -func (s *Scheduler) RegisterJob(reg JobRegistration) (string, error) {
169 - if strings.TrimSpace(reg.Spec.Name) == "" {
170 - return "", fmt.Errorf("job name is required")
171 - }
172 - if reg.Emitter == nil {
173 - reg.Emitter = NewNoopEmitter()
174 - }
175 - jobID := strings.TrimSpace(reg.ID)
176 - if jobID == "" {
177 - jobIdx := int(s.jobSeq.Add(1))
178 - jobID = buildJobID(reg.Spec, jobIdx)
179 - }
180 - if _, exists := s.jobs[jobID]; exists {
181 - return "", fmt.Errorf("job id '%s' already registered", jobID)
182 - }
183 - vnode := reg.Vnode
184 - if VnodeInfoIsEmpty(vnode) && s.vnodeLookup != nil {
185 - vnode = s.vnodeLookup(reg.Spec)
186 - }
187 - jr := JobRuntime{
188 - ID: jobID,
189 - Spec: reg.Spec,
190 - Vnode: CloneVnodeInfo(vnode),
191 - }
192 - var period *timeperiod.Period
193 - if reg.Periods != nil {
194 - var err error
195 - period, err = reg.Periods.Resolve(reg.Spec.CheckPeriod)
196 - if err != nil {
197 - return "", err
198 - }
199 - }
200 - now := time.Now()
201 - identity := charts.NewJobIdentity(s.schedulerName, reg.Spec)
202 - userMacros := make(map[string]string, len(reg.UserMacros))
203 - for k, v := range reg.UserMacros {
204 - userMacros[strings.ToUpper(k)] = v
205 - }
206 - js := &jobState{
207 - runtime: jr,
208 - period: period,
209 - nextAnniversary: now,
210 - nextRun: now,
211 - state: "UNKNOWN",
212 - softState: "UNKNOWN",
213 - hardState: "UNKNOWN",
214 - identity: identity,
215 - checkInterval: intervalOrDefault(reg.Spec.CheckInterval),
216 - retryInterval: intervalOrDefault(reg.Spec.RetryInterval),
217 - maxAttempts: maxInt(reg.Spec.MaxCheckAttempts, 1),
218 - timeoutState: normalizeState(reg.Spec.TimeoutState),
219 - jitterRange: reg.Spec.InterCheckJitter,
220 - emitter: reg.Emitter,
221 - registerPerf: reg.RegisterPerfdata,
222 - runner: reg.Runner,
223 - userMacros: userMacros,
224 - }
225 - if js.retryInterval <= 0 {
226 - js.retryInterval = js.checkInterval
227 - }
228 - js.nextRun = s.applyJitter(js.nextAnniversary, js.jitterRange)
229 -
230 - s.jobMu.Lock()
231 - s.jobs[jr.ID] = js
232 - s.jobMu.Unlock()
233 -
234 - s.armTimer(js)
235 - return jr.ID, nil
236 -}
237 -
238 -// UnregisterJob removes a job from the scheduler and stops its timer.
239 -func (s *Scheduler) UnregisterJob(jobID string) {
240 - s.jobMu.Lock()
241 - defer s.jobMu.Unlock()
242 - js, ok := s.jobs[jobID]
243 - if !ok {
244 - return
245 - }
246 - if js.timer != nil {
247 - js.timer.Stop()
248 - }
249 - if js.emitter != nil {
250 - _ = js.emitter.Close()
251 - }
252 - delete(s.jobs, jobID)
253 -}
254 -
255 -// Start launches timers, workers, and the scheduler loop.
256 -func (s *Scheduler) Start(ctx context.Context) error {
257 - if s.ctx != nil {
258 - return fmt.Errorf("scheduler already started")
259 - }
260 - s.ctx, s.cancel = context.WithCancel(ctx)
261 -
262 - for _, js := range s.jobs {
263 - s.armTimer(js)
264 - }
265 -
266 - s.executor.Start(s.ctx)
267 -
268 - s.wg.Add(1)
269 - go s.run()
270 -
271 - return nil
272 -}
273 -
274 -// Stop cancels timers and waits for background goroutines to exit.
275 -func (s *Scheduler) Stop() {
276 - if s.cancel != nil {
277 - s.cancel()
278 - }
279 - s.jobMu.Lock()
280 - for _, js := range s.jobs {
281 - if js.timer != nil {
282 - js.timer.Stop()
283 - js.timer = nil
284 - }
285 - }
286 - s.jobMu.Unlock()
287 - s.executor.Stop()
288 - s.wg.Wait()
289 - s.jobMu.Lock()
290 - s.ctx = nil
291 - s.cancel = nil
292 - s.jobMu.Unlock()
293 -}
294 -
295 -func (s *Scheduler) run() {
296 - defer s.wg.Done()
297 -
298 - for {
299 - select {
300 - case <-s.ctx.Done():
301 - return
302 - case jobID := <-s.timerCh:
303 - s.handleTimer(jobID)
304 - case res, ok := <-s.executor.Results():
305 - if !ok {
306 - return
307 - }
308 - s.handleResult(res)
309 - }
310 - }
311 -}
312 -
313 -func (s *Scheduler) handleTimer(jobID string) {
314 - s.jobMu.Lock()
315 - js, ok := s.jobs[jobID]
316 - if !ok {
317 - s.jobMu.Unlock()
318 - return
319 - }
320 - now := time.Now()
321 - if js.period != nil && !js.period.Allows(now) {
322 - js.periodSkipped = true
323 - nextAllowed := js.period.NextAllowed(now)
324 - if nextAllowed.IsZero() {
325 - nextAllowed = now.Add(js.checkInterval)
326 - }
327 - js.nextAnniversary = nextAllowed
328 - js.nextRun = s.applyJitter(nextAllowed, js.jitterRange)
329 - s.jobMu.Unlock()
330 - s.armTimer(js)
331 - return
332 - }
333 - js.periodSkipped = false
334 - js.nextAnniversary = s.advanceAnniversary(js.nextAnniversary, js.checkInterval, now)
335 - js.nextRun = s.applyJitter(js.nextAnniversary, js.jitterRange)
336 - s.jobMu.Unlock()
337 -
338 - s.armTimer(js)
339 -
340 - if queued, err := s.executor.Enqueue(js.runtime); err != nil {
341 - if s.log != nil {
342 - s.log.Errorf("nagios executor enqueue failed: %v", err)
343 - }
344 - } else if queued {
345 - s.counters.started.Add(1)
346 - } else {
347 - s.jobMu.Lock()
348 - js.skipped++
349 - s.jobMu.Unlock()
350 - s.counters.skipped.Add(1)
351 - }
352 -}
353 -
354 -func (s *Scheduler) handleResult(res ExecutionResult) {
355 - parsed := output.Parse(res.Output)
356 - if s.log != nil {
357 - s.log.Debugf("nagios: parsed perfdata entries=%d for job=%s", len(parsed.Perfdata), res.Job.Spec.Name)
358 - }
359 - res.Parsed = parsed
360 -
361 - s.jobMu.Lock()
362 - js, ok := s.jobs[res.Job.ID]
363 - var jobSpec spec.JobSpec
364 - var snapshot JobSnapshot
365 - var scheduleRetry bool
366 - var measured bool
367 - if ok {
368 - jobSpec = js.runtime.Spec
369 - js.running = false
370 - js.lastDuration = res.Duration
371 - js.lastCPU = res.Usage.User + res.Usage.System
372 - measured = res.Usage.User != 0 || res.Usage.System != 0 || res.Duration == 0
373 - js.cpuMeasured = measured
374 - js.lastRSS = res.Usage.MaxRSSBytes
375 - js.lastDiskRead = res.Usage.ReadBytes
376 - js.lastDiskWrite = res.Usage.WriteBytes
377 - js.statusLine = parsed.StatusLine
378 - js.longOutput = parsed.LongOutput
379 - js.updatePerfdata(parsed.Perfdata)
380 - prevHard := js.hardState
381 - js.recordResult(res.State)
382 - js.periodSkipped = false
383 - if js.retrying {
384 - base := time.Now()
385 - js.nextAnniversary = s.advanceAnniversary(base, js.retryInterval, base)
386 - js.nextRun = s.applyJitter(js.nextAnniversary, js.jitterRange)
387 - scheduleRetry = true
388 - }
389 - snapshot = JobSnapshot{
390 - HardState: js.hardState,
391 - SoftState: js.softState,
392 - PrevHardState: prevHard,
393 - Attempts: js.softAttempts,
394 - Duration: res.Duration,
395 - Timestamp: res.End,
396 - Output: parsed,
397 - }
398 - }
399 - s.jobMu.Unlock()
400 -
401 - if ok {
402 - s.counters.finished.Add(1)
403 - if scheduleRetry {
404 - s.armTimer(js)
405 - }
406 - if !measured && res.Duration > 0 && s.log != nil {
407 - s.log.Debugf("nagios job %s completed without CPU usage data; emitting zero", res.Job.Spec.Name)
408 - }
409 - s.registerPerfdataCharts(jobSpec, parsed.Perfdata, js.registerPerf)
410 - if js.emitter != nil {
411 - js.emitter.Emit(res.Job, res, snapshot)
412 - }
413 - }
414 -}
415 -
416 -func (s *Scheduler) armTimer(js *jobState) {
417 - s.jobMu.Lock()
418 - ctx := s.ctx
419 - if ctx == nil {
420 - s.jobMu.Unlock()
421 - return
422 - }
423 - delay := time.Until(js.nextRun)
424 - if delay < 0 {
425 - delay = 0
426 - }
427 - jobID := js.runtime.ID
428 - if js.timer == nil {
429 - js.timer = time.AfterFunc(delay, func() {
430 - select {
431 - case s.timerCh <- jobID:
432 - case <-ctx.Done():
433 - }
434 - })
435 - } else {
436 - js.timer.Reset(delay)
437 - }
438 - s.jobMu.Unlock()
439 -}
440 -
441 -func (s *Scheduler) runJob(ctx context.Context, job JobRuntime) ExecutionResult {
442 - res := ExecutionResult{Job: job, Start: time.Now()}
443 - s.markRunning(job.ID, true)
444 - defer s.markRunning(job.ID, false)
445 -
446 - timeout := job.Spec.Timeout
447 - if timeout <= 0 {
448 - timeout = time.Minute
449 - }
450 -
451 - var output []byte
452 - var cmdStr string
453 - var usage ndexec.ResourceUsage
454 - var err error
455 -
456 - js, _ := s.getJobState(job.ID)
457 - runner := JobRunner(nil)
458 - if js != nil {
459 - runner = js.runner
460 - }
461 - if runner != nil {
462 - runCtx, cancel := context.WithTimeout(ctx, timeout)
463 - output, cmdStr, usage, err = runner(runCtx, job, timeout)
464 - cancel()
465 - } else {
466 - macroCtx := s.buildMacroContext(job, js)
467 - macroSet := BuildMacroSet(macroCtx)
468 - args := macroSet.CommandArgs
469 - if len(args) == 0 {
470 - args = job.Spec.Args
471 - }
472 - env := s.buildEnv(job.Spec.Environment, macroSet.Env)
473 - opts := ndexec.RunOptions{Env: env}
474 - if dir := job.Spec.WorkingDirectory; dir != "" {
475 - opts.Dir = dir
476 - }
477 - output, cmdStr, usage, err = ndexec.RunUnprivilegedWithOptionsUsage(s.log, timeout, opts, job.Spec.Plugin, args...)
478 - }
479 -
480 - if s.log != nil {
481 - s.log.Debugf("nagios: raw plugin output job=%s output=%q", job.Spec.Name, string(output))
482 - }
483 - res.Output = output
484 - res.Err = err
485 - res.Command = cmdStr
486 - res.ExitCode = exitCodeFromError(err)
487 - res.End = time.Now()
488 - res.Duration = res.End.Sub(res.Start)
489 - res.State = s.stateFromResult(job, res.ExitCode, err)
490 - res.Usage = usage
491 -
492 - return res
493 -}
494 -
495 -func (s *Scheduler) buildMacroContext(job JobRuntime, js *jobState) MacroContext {
496 - state := StateInfo{
497 - ServiceState: s.currentState(job.ID),
498 - ServiceAttempt: s.currentAttempt(job.ID),
499 - ServiceMaxAttempts: maxInt(job.Spec.MaxCheckAttempts, 1),
500 - HostState: "UP",
501 - HostStateID: "0",
502 - }
503 - macros := make(map[string]string, len(s.userMacros))
504 - for k, v := range s.userMacros {
505 - macros[k] = v
506 - }
507 - if js != nil && len(js.userMacros) > 0 {
508 - for k, v := range js.userMacros {
509 - macros[k] = v
510 - }
511 - }
512 - return MacroContext{
513 - Job: job.Spec,
514 - UserMacros: macros,
515 - Vnode: job.Vnode,
516 - State: state,
517 - }
518 -}
519 -
520 -func (s *Scheduler) markRunning(jobID string, running bool) {
521 - s.jobMu.Lock()
522 - if js, ok := s.jobs[jobID]; ok {
523 - js.running = running
524 - }
525 - s.jobMu.Unlock()
526 -}
527 -
528 -func (s *Scheduler) getJobState(jobID string) (*jobState, bool) {
529 - s.jobMu.RLock()
530 - defer s.jobMu.RUnlock()
531 - js, ok := s.jobs[jobID]
532 - return js, ok
533 -}
534 -
535 -func buildJobID(sp spec.JobSpec, idx int) string {
536 - vnode := sp.Vnode
537 - if vnode == "" {
538 - vnode = "local"
539 - }
540 - return fmt.Sprintf("%s@%s#%d", sp.Name, vnode, idx)
541 -}
542 -
543 -func (s *Scheduler) CollectMetrics() map[string]int64 {
544 - metrics := make(map[string]int64)
545 - stats := s.executor.Stats()
546 - metrics[charts.SchedulerMetricKey(s.schedulerName, charts.ChartSchedulerJobs, "running")] = int64(stats.Executing)
547 - metrics[charts.SchedulerMetricKey(s.schedulerName, charts.ChartSchedulerJobs, "queued")] = int64(stats.QueueDepth)
548 - metrics[charts.SchedulerMetricKey(s.schedulerName, charts.ChartSchedulerJobs, "scheduled")] = int64(s.scheduledCount())
549 - metrics[charts.SchedulerMetricKey(s.schedulerName, charts.ChartSchedulerRate, "started")] = int64(s.counters.started.Load())
550 - metrics[charts.SchedulerMetricKey(s.schedulerName, charts.ChartSchedulerRate, "finished")] = int64(s.counters.finished.Load())
551 - metrics[charts.SchedulerMetricKey(s.schedulerName, charts.ChartSchedulerRate, "skipped")] = int64(s.counters.skipped.Load())
552 - metrics[charts.SchedulerMetricKey(s.schedulerName, charts.ChartSchedulerNext, "next")] = s.nextRunDelay().Nanoseconds()
553 -
554 - s.jobMu.RLock()
555 - defer s.jobMu.RUnlock()
556 - for _, js := range s.jobs {
557 - id := js.identity
558 - metrics[id.TelemetryMetricID(charts.TelemetryStateMetric, "ok")] = boolToInt(strings.EqualFold(js.state, "OK"))
559 - metrics[id.TelemetryMetricID(charts.TelemetryStateMetric, "warning")] = boolToInt(strings.EqualFold(js.state, "WARNING"))
560 - metrics[id.TelemetryMetricID(charts.TelemetryStateMetric, "critical")] = boolToInt(strings.EqualFold(js.state, "CRITICAL"))
561 - metrics[id.TelemetryMetricID(charts.TelemetryStateMetric, "unknown")] = boolToInt(strings.EqualFold(js.state, "UNKNOWN"))
562 - metrics[id.TelemetryMetricID(charts.TelemetryStateMetric, "attempt")] = int64(js.currentAttempt())
563 - metrics[id.TelemetryMetricID(charts.TelemetryStateMetric, "max_attempts")] = int64(js.maxAttempts)
564 - metrics[id.TelemetryMetricID(charts.TelemetryRuntimeMetric, "running")] = boolToInt(js.running)
565 - metrics[id.TelemetryMetricID(charts.TelemetryRuntimeMetric, "retrying")] = boolToInt(js.retrying)
566 - missingCPU := !js.cpuMeasured && js.lastDuration > 0
567 - metrics[id.TelemetryMetricID(charts.TelemetryRuntimeMetric, "skipped")] = boolToInt(js.periodSkipped)
568 - metrics[id.TelemetryMetricID(charts.TelemetryRuntimeMetric, "cpu_missing")] = boolToInt(missingCPU)
569 - metrics[id.TelemetryMetricID(charts.TelemetryLatencyMetric, "duration")] = js.lastDuration.Nanoseconds()
570 - cpuNs := js.lastCPU.Nanoseconds()
571 - metrics[id.TelemetryMetricID(charts.TelemetryCPUMetric, "cpu_time")] = cpuNs
572 - metrics[id.TelemetryMetricID(charts.TelemetryMemoryMetric, "rss")] = js.lastRSS
573 - metrics[id.TelemetryMetricID(charts.TelemetryDiskMetric, "read")] = js.lastDiskRead
574 - metrics[id.TelemetryMetricID(charts.TelemetryDiskMetric, "write")] = js.lastDiskWrite
575 -
576 - if len(js.perfdata) > 0 {
577 - for labelID, datum := range js.perfdata {
578 - scale := units.NewScale(datum.Unit)
579 - metrics[id.PerfdataMetricID(labelID, "value")] = scale.Apply(datum.Value)
580 - if datum.Min != nil {
581 - metrics[id.PerfdataMetricID(labelID, "min")] = scale.Apply(*datum.Min)
582 - }
583 - if datum.Max != nil {
584 - metrics[id.PerfdataMetricID(labelID, "max")] = scale.Apply(*datum.Max)
585 - }
586 - s.setRangeMetrics(metrics, id, labelID, "warn", datum.Warn, scale)
587 - s.setRangeMetrics(metrics, id, labelID, "crit", datum.Crit, scale)
588 - }
589 - }
590 - }
591 -
592 - return metrics
593 -}
594 -
595 -func (s *Scheduler) nextRunDelay() time.Duration {
596 - min := 24 * time.Hour
597 - if len(s.jobs) == 0 {
598 - return 0
599 - }
600 - s.jobMu.RLock()
601 - defer s.jobMu.RUnlock()
602 - for _, js := range s.jobs {
603 - delta := time.Until(js.nextRun)
604 - if delta < 0 {
605 - delta = 0
606 - }
607 - if delta < min {
608 - min = delta
609 - }
610 - }
611 - return min
612 -}
613 -
614 -func boolToInt(v bool) int64 {
615 - if v {
616 - return 1
617 - }
618 - return 0
619 -}
620 -
621 -func (s *Scheduler) scheduledCount() int {
622 - s.jobMu.RLock()
623 - defer s.jobMu.RUnlock()
624 - return len(s.jobs)
625 -}
626 -
627 -func (s *Scheduler) advanceAnniversary(current time.Time, interval time.Duration, now time.Time) time.Time {
628 - interval = intervalOrDefault(interval)
629 - if current.IsZero() {
630 - current = now
631 - }
632 - next := current.Add(interval)
633 - if !next.After(now) {
634 - diff := now.Sub(next)
635 - steps := diff/interval + 1
636 - next = next.Add(time.Duration(steps) * interval)
637 - }
638 - return next
639 -}
640 -
641 -func (s *Scheduler) applyJitter(base time.Time, jitter time.Duration) time.Time {
642 - if jitter <= 0 {
643 - return base
644 - }
645 - if s.rand == nil {
646 - return base
647 - }
648 - s.randMu.Lock()
649 - val := s.rand.Float64()
650 - s.randMu.Unlock()
651 - if val <= 0 {
652 - return base
653 - }
654 - return base.Add(time.Duration(val * float64(jitter)))
655 -}
656 -
657 -func (s *Scheduler) setRangeMetrics(metrics map[string]int64, id charts.JobIdentity, labelID, kind string, rng *output.ThresholdRange, scale units.Scale) {
658 - definedKey := id.PerfdataMetricID(labelID, kind+"_defined")
659 - inclusiveKey := id.PerfdataMetricID(labelID, kind+"_inclusive")
660 - lowKey := id.PerfdataMetricID(labelID, kind+"_low")
661 - highKey := id.PerfdataMetricID(labelID, kind+"_high")
662 - lowDefinedKey := id.PerfdataMetricID(labelID, kind+"_low_defined")
663 - highDefinedKey := id.PerfdataMetricID(labelID, kind+"_high_defined")
664 - if rng == nil {
665 - metrics[definedKey] = 0
666 - metrics[inclusiveKey] = 0
667 - metrics[lowKey] = 0
668 - metrics[highKey] = 0
669 - metrics[lowDefinedKey] = 0
670 - metrics[highDefinedKey] = 0
671 - return
672 - }
673 - metrics[definedKey] = 1
674 - metrics[inclusiveKey] = boolToInt(rng.Inclusive)
675 - if v, ok := rangeBoundMetric(scale, rng.Low); ok {
676 - metrics[lowKey] = v
677 - metrics[lowDefinedKey] = 1
678 - } else {
679 - metrics[lowKey] = 0
680 - metrics[lowDefinedKey] = 0
681 - }
682 - if v, ok := rangeBoundMetric(scale, rng.High); ok {
683 - metrics[highKey] = v
684 - metrics[highDefinedKey] = 1
685 - } else {
686 - metrics[highKey] = 0
687 - metrics[highDefinedKey] = 0
688 - }
689 -}
690 -
691 -func rangeBoundMetric(scale units.Scale, val *float64) (int64, bool) {
692 - if val == nil {
693 - return 0, false
694 - }
695 - if math.IsNaN(*val) || math.IsInf(*val, 0) {
696 - return 0, false
697 - }
698 - return scale.Apply(*val), true
699 -}
700 -
701 -func (s *Scheduler) registerPerfdataCharts(job spec.JobSpec, perf []output.PerfDatum, register func(spec.JobSpec, output.PerfDatum)) {
702 - if register == nil {
703 - return
704 - }
705 - for _, datum := range perf {
706 - label := strings.TrimSpace(datum.Label)
707 - if label == "" {
708 - continue
709 - }
710 - register(job, datum)
711 - }
712 -}
713 -
714 -func exitCodeFromError(err error) int {
715 - if err == nil {
716 - return 0
717 - }
718 - var exitErr interface{ ExitCode() int }
719 - if errors.As(err, &exitErr) {
720 - return exitErr.ExitCode()
721 - }
722 - return -1
723 -}
724 -
725 -func (s *Scheduler) stateFromResult(job JobRuntime, exitCode int, err error) string {
726 - if err == nil {
727 - return "OK"
728 - }
729 - if errors.Is(err, context.DeadlineExceeded) {
730 - return normalizeState(job.Spec.TimeoutState)
731 - }
732 - switch exitCode {
733 - case 0:
734 - return "OK"
735 - case 1:
736 - return "WARNING"
737 - case 2:
738 - return "CRITICAL"
739 - case 3:
740 - return "UNKNOWN"
741 - default:
742 - return "UNKNOWN"
743 - }
744 -}
745 -
746 -func intervalOrDefault(d time.Duration) time.Duration {
747 - if d <= 0 {
748 - return time.Minute
749 - }
750 - return d
751 -}
752 -
753 -func maxInt(a, b int) int {
754 - if a > b {
755 - return a
756 - }
757 - return b
758 -}
759 -
760 -func normalizeState(state string) string {
761 - s := strings.ToUpper(state)
762 - switch s {
763 - case "OK", "WARNING", "CRITICAL", "UNKNOWN":
764 - return s
765 - default:
766 - return "UNKNOWN"
767 - }
768 -}
769 -
770 -func (s *Scheduler) currentState(jobID string) string {
771 - s.jobMu.RLock()
772 - defer s.jobMu.RUnlock()
773 - if js, ok := s.jobs[jobID]; ok && js.state != "" {
774 - return js.state
775 - }
776 - return "UNKNOWN"
777 -}
778 -
779 -func (s *Scheduler) currentAttempt(jobID string) int {
780 - s.jobMu.RLock()
781 - defer s.jobMu.RUnlock()
782 - if js, ok := s.jobs[jobID]; ok {
783 - return js.currentAttempt()
784 - }
785 - return 1
786 -}
787 -
788 -func (s *Scheduler) buildEnv(jobEnv map[string]string, macroEnv map[string]string) []string {
789 - merged := make(map[string]string)
790 - for _, kv := range os.Environ() {
791 - if eq := strings.Index(kv, "="); eq > 0 {
792 - merged[kv[:eq]] = kv[eq+1:]
793 - }
794 - }
795 - for k, v := range jobEnv {
796 - merged[k] = replaceMacro(v, macroEnv)
797 - }
798 - for k, v := range macroEnv {
799 - merged[k] = v
800 - }
801 - keys := make([]string, 0, len(merged))
802 - for k := range merged {
803 - keys = append(keys, k)
804 - }
805 - sort.Strings(keys)
806 - result := make([]string, 0, len(keys))
807 - for _, k := range keys {
808 - result = append(result, fmt.Sprintf("%s=%s", k, merged[k]))
809 - }
810 - return result
811 -}
812 -
813 -func (js *jobState) recordResult(state string) {
814 - state = normalizeState(state)
815 - js.softState = state
816 - if state == "OK" {
817 - js.softAttempts = 0
818 - js.hardState = "OK"
819 - js.retrying = false
820 - } else {
821 - js.softAttempts++
822 - if js.softAttempts >= js.maxAttempts {
823 - js.hardState = state
824 - js.retrying = false
825 - } else {
826 - js.retrying = true
827 - }
828 - }
829 - js.state = state
830 -}
831 -
832 -func (js *jobState) updatePerfdata(perf []output.PerfDatum) {
833 - if len(perf) == 0 {
834 - js.perfdata = nil
835 - return
836 - }
837 - mp := make(map[string]output.PerfDatum, len(perf))
838 - for _, datum := range perf {
839 - labelID := ids.Sanitize(datum.Label)
840 - if labelID == "" {
841 - continue
842 - }
843 - mp[labelID] = datum
844 - }
845 - js.perfdata = mp
846 -}
847 -
848 -func (js *jobState) currentAttempt() int {
849 - if js == nil {
850 - return 1
851 - }
852 - if strings.EqualFold(js.state, "OK") || js.state == "" {
853 - return 1
854 - }
855 - attempt := js.softAttempts
856 - if attempt <= 0 {
857 - attempt = 1
858 - }
859 - if js.retrying {
860 - attempt++
861 - }
862 - if attempt > js.maxAttempts {
863 - return js.maxAttempts
864 - }
865 - return attempt
866 -}
src/go/plugin/scripts.d/pkg/runtime/scheduler_state_test.go deleted
-39
@@ -1,39 +0,0 @@
1 -package runtime
2 -
3 -import "testing"
4 -
5 -func TestJobStateRecordResultKeepsSoftState(t *testing.T) {
6 - js := &jobState{maxAttempts: 3, hardState: "OK"}
7 -
8 - js.recordResult("warning")
9 -
10 - if js.state != "WARNING" {
11 - t.Fatalf("expected state=WARNING, got %s", js.state)
12 - }
13 - if js.hardState != "OK" {
14 - t.Fatalf("hard state should remain OK, got %s", js.hardState)
15 - }
16 - if js.softAttempts != 1 {
17 - t.Fatalf("expected softAttempts=1, got %d", js.softAttempts)
18 - }
19 - if !js.retrying {
20 - t.Fatalf("expected retrying=true on soft state")
21 - }
22 -}
23 -
24 -func TestJobStateRecordResultHardensAfterMaxAttempts(t *testing.T) {
25 - js := &jobState{maxAttempts: 2, hardState: "OK"}
26 -
27 - js.recordResult("critical")
28 - js.recordResult("critical")
29 -
30 - if js.hardState != "CRITICAL" {
31 - t.Fatalf("expected hard state CRITICAL, got %s", js.hardState)
32 - }
33 - if js.state != "CRITICAL" {
34 - t.Fatalf("expected state=CRITICAL, got %s", js.state)
35 - }
36 - if js.retrying {
37 - t.Fatalf("expected retrying=false after hard failure")
38 - }
39 -}
src/go/plugin/scripts.d/pkg/runtime/vnode.go deleted
-27
@@ -1,27 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package runtime
4 -
5 -import "maps"
6 -
7 -// CloneVnodeInfo deep-copies the label map to avoid shared references.
8 -func CloneVnodeInfo(src VnodeInfo) VnodeInfo {
9 - clone := src
10 - if len(src.Labels) > 0 {
11 - clone.Labels = maps.Clone(src.Labels)
12 - } else {
13 - clone.Labels = nil
14 - }
15 - return clone
16 -}
17 -
18 -// VnodeInfoIsEmpty reports whether the struct carries any useful data.
19 -func VnodeInfoIsEmpty(info VnodeInfo) bool {
20 - if info.Hostname != "" {
21 - return false
22 - }
23 - if len(info.Labels) > 0 {
24 - return false
25 - }
26 - return true
27 -}
src/go/plugin/scripts.d/pkg/schedulers/host.go deleted
-105
@@ -1,105 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package schedulers
4 -
5 -import (
6 - "context"
7 - "sync"
8 -
9 - "github.com/netdata/netdata/go/plugins/logger"
10 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/runtime"
11 -)
12 -
13 -type runtimeHost struct {
14 - def Definition
15 - log *logger.Logger
16 - sched *runtime.Scheduler
17 - ctx context.Context
18 - cancel context.CancelFunc
19 - mu sync.Mutex
20 - jobs map[string]runtime.JobRegistration
21 -}
22 -
23 -func newRuntimeHost(def Definition, log *logger.Logger) (*runtimeHost, error) {
24 - if log == nil {
25 - log = logger.New().With("scheduler", def.Name)
26 - }
27 - cfg := runtime.SchedulerConfig{
28 - Logger: log,
29 - Workers: def.Workers,
30 - QueueCapacity: def.QueueSize,
31 - SchedulerName: def.Name,
32 - }
33 - sched, err := runtime.NewScheduler(cfg)
34 - if err != nil {
35 - return nil, err
36 - }
37 - ctx, cancel := context.WithCancel(context.Background())
38 - if err := sched.Start(ctx); err != nil {
39 - cancel()
40 - sched.Stop()
41 - return nil, err
42 - }
43 - return &runtimeHost{def: def, log: log, sched: sched, ctx: ctx, cancel: cancel, jobs: make(map[string]runtime.JobRegistration)}, nil
44 -}
45 -
46 -func (h *runtimeHost) stop() {
47 - h.cancel()
48 - h.sched.Stop()
49 -}
50 -
51 -func (h *runtimeHost) attach(reg runtime.JobRegistration) (string, error) {
52 - jobID, err := h.sched.RegisterJob(reg)
53 - if err != nil {
54 - return "", err
55 - }
56 - h.mu.Lock()
57 - stored := reg
58 - stored.ID = jobID
59 - h.jobs[jobID] = stored
60 - h.mu.Unlock()
61 - return jobID, nil
62 -}
63 -
64 -func (h *runtimeHost) detach(jobID string) {
65 - h.sched.UnregisterJob(jobID)
66 - h.mu.Lock()
67 - if h.jobs != nil {
68 - delete(h.jobs, jobID)
69 - }
70 - h.mu.Unlock()
71 -}
72 -
73 -func (h *runtimeHost) collectMetrics() map[string]int64 {
74 - return h.sched.CollectMetrics()
75 -}
76 -
77 -func (h *runtimeHost) jobCount() int {
78 - h.mu.Lock()
79 - defer h.mu.Unlock()
80 - return len(h.jobs)
81 -}
82 -
83 -func (h *runtimeHost) snapshotJobs() map[string]runtime.JobRegistration {
84 - h.mu.Lock()
85 - defer h.mu.Unlock()
86 - copy := make(map[string]runtime.JobRegistration, len(h.jobs))
87 - for id, reg := range h.jobs {
88 - copy[id] = reg
89 - }
90 - return copy
91 -}
92 -
93 -func (h *runtimeHost) restoreJobs(jobs map[string]runtime.JobRegistration) error {
94 - for id, reg := range jobs {
95 - stored := reg
96 - stored.ID = id
97 - if _, err := h.sched.RegisterJob(stored); err != nil {
98 - return err
99 - }
100 - h.mu.Lock()
101 - h.jobs[id] = stored
102 - h.mu.Unlock()
103 - }
104 - return nil
105 -}
src/go/plugin/scripts.d/pkg/schedulers/manager.go deleted
-244
@@ -1,244 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package schedulers
4 -
5 -import (
6 - "fmt"
7 - "maps"
8 - "sync"
9 -
10 - "github.com/netdata/netdata/go/plugins/logger"
11 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/runtime"
12 -)
13 -
14 -// Definition describes a configured scheduler instance.
15 -type Definition struct {
16 - Name string
17 - Workers int
18 - QueueSize int
19 - Labels map[string]string
20 - LoggingEnabled bool
21 - Logging runtime.OTLPEmitterConfig
22 - Builtin bool
23 -}
24 -
25 -type manager struct {
26 - mu sync.RWMutex
27 - defs map[string]Definition
28 - hosts map[string]*runtimeHost
29 -}
30 -
31 -var defaultManager = newManager()
32 -
33 -func defaultDefinition() Definition {
34 - return Definition{
35 - Name: "default",
36 - Workers: 50,
37 - QueueSize: 128,
38 - Labels: nil,
39 - LoggingEnabled: true,
40 - Logging: runtime.OTLPEmitterConfig{
41 - Endpoint: runtime.DefaultOTLPEndpoint,
42 - Timeout: runtime.DefaultOTLPTimeout,
43 - UseTLS: false,
44 - Headers: map[string]string{},
45 - },
46 - Builtin: true,
47 - }
48 -}
49 -
50 -func newManager() *manager {
51 - m := &manager{
52 - defs: make(map[string]Definition),
53 - hosts: make(map[string]*runtimeHost),
54 - }
55 - // Seed with default scheduler definition.
56 - m.defs["default"] = defaultDefinition()
57 - return m
58 -}
59 -
60 -// ApplyDefinition registers or updates a scheduler definition and ensures its runtime host exists.
61 -func ApplyDefinition(def Definition, log *logger.Logger) error {
62 - return defaultManager.applyDefinition(def, log)
63 -}
64 -
65 -// RemoveDefinition deletes a scheduler definition (and stops its runtime) if no jobs remain.
66 -func RemoveDefinition(name string) error {
67 - return defaultManager.removeDefinition(name)
68 -}
69 -
70 -// Get returns a scheduler definition by name.
71 -func Get(name string) (Definition, bool) {
72 - return defaultManager.get(name)
73 -}
74 -
75 -// All returns every registered definition.
76 -func All() []Definition {
77 - return defaultManager.all()
78 -}
79 -
80 -// JobHandle represents a job registered with a scheduler.
81 -type JobHandle struct {
82 - scheduler string
83 - jobID string
84 -}
85 -
86 -// AttachJob registers a job to a scheduler.
87 -func AttachJob(name string, reg runtime.JobRegistration, log *logger.Logger) (*JobHandle, error) {
88 - return defaultManager.attachJob(name, reg, log)
89 -}
90 -
91 -// DetachJob removes a job from its scheduler.
92 -func DetachJob(handle *JobHandle) {
93 - defaultManager.detachJob(handle)
94 -}
95 -
96 -// CollectMetrics returns runtime metrics for the given scheduler.
97 -func CollectMetrics(name string) map[string]int64 {
98 - return defaultManager.collectMetrics(name)
99 -}
100 -
101 -func (m *manager) applyDefinition(def Definition, log *logger.Logger) error {
102 - if def.Name == "" {
103 - return fmt.Errorf("scheduler name is required")
104 - }
105 - norm := normalizeDefinition(def)
106 - m.mu.Lock()
107 - old := m.hosts[norm.Name]
108 - m.mu.Unlock()
109 - if old == nil {
110 - host, err := newRuntimeHost(norm, log)
111 - if err != nil {
112 - return err
113 - }
114 - m.mu.Lock()
115 - m.defs[norm.Name] = norm
116 - m.hosts[norm.Name] = host
117 - m.mu.Unlock()
118 - return nil
119 - }
120 - jobs := old.snapshotJobs()
121 - newHost, err := newRuntimeHost(norm, log)
122 - if err != nil {
123 - return err
124 - }
125 - if err := newHost.restoreJobs(jobs); err != nil {
126 - newHost.stop()
127 - return err
128 - }
129 - old.stop()
130 - m.mu.Lock()
131 - m.defs[norm.Name] = norm
132 - m.hosts[norm.Name] = newHost
133 - m.mu.Unlock()
134 - return nil
135 -}
136 -
137 -func (m *manager) removeDefinition(name string) error {
138 - if name == "" {
139 - return fmt.Errorf("scheduler name is required")
140 - }
141 - m.mu.Lock()
142 - defer m.mu.Unlock()
143 - host := m.hosts[name]
144 - if host != nil && host.jobCount() > 0 {
145 - return fmt.Errorf("scheduler '%s' still has %d jobs", name, host.jobCount())
146 - }
147 - if host != nil {
148 - host.stop()
149 - delete(m.hosts, name)
150 - }
151 - if name == "default" {
152 - m.defs[name] = defaultDefinition()
153 - return nil
154 - }
155 - delete(m.defs, name)
156 - return nil
157 -}
158 -
159 -func (m *manager) get(name string) (Definition, bool) {
160 - m.mu.RLock()
161 - def, ok := m.defs[name]
162 - m.mu.RUnlock()
163 - return def, ok
164 -}
165 -
166 -func (m *manager) all() []Definition {
167 - m.mu.RLock()
168 - defer m.mu.RUnlock()
169 - out := make([]Definition, 0, len(m.defs))
170 - for _, def := range m.defs {
171 - out = append(out, def)
172 - }
173 - return out
174 -}
175 -
176 -func (m *manager) attachJob(name string, reg runtime.JobRegistration, log *logger.Logger) (*JobHandle, error) {
177 - m.mu.RLock()
178 - host := m.hosts[name]
179 - m.mu.RUnlock()
180 - if host == nil {
181 - m.mu.Lock()
182 - defer m.mu.Unlock()
183 - var ok bool
184 - host, ok = m.hosts[name]
185 - if !ok {
186 - def, exists := m.defs[name]
187 - if !exists {
188 - return nil, fmt.Errorf("scheduler '%s' not defined", name)
189 - }
190 - newHost, err := newRuntimeHost(def, log)
191 - if err != nil {
192 - return nil, err
193 - }
194 - host = newHost
195 - m.hosts[name] = host
196 - }
197 - }
198 - jobID, err := host.attach(reg)
199 - if err != nil {
200 - return nil, err
201 - }
202 - return &JobHandle{scheduler: name, jobID: jobID}, nil
203 -}
204 -
205 -func (m *manager) detachJob(handle *JobHandle) {
206 - if handle == nil {
207 - return
208 - }
209 - m.mu.RLock()
210 - host := m.hosts[handle.scheduler]
211 - m.mu.RUnlock()
212 - if host == nil {
213 - return
214 - }
215 - host.detach(handle.jobID)
216 -}
217 -
218 -func (m *manager) collectMetrics(name string) map[string]int64 {
219 - m.mu.RLock()
220 - host := m.hosts[name]
221 - m.mu.RUnlock()
222 - if host == nil {
223 - return nil
224 - }
225 - return host.collectMetrics()
226 -}
227 -
228 -func normalizeDefinition(def Definition) Definition {
229 - if def.Workers <= 0 {
230 - def.Workers = 50
231 - }
232 - if def.QueueSize <= 0 {
233 - def.QueueSize = 128
234 - }
235 - if def.Logging.Headers == nil {
236 - def.Logging.Headers = make(map[string]string)
237 - } else {
238 - def.Logging.Headers = maps.Clone(def.Logging.Headers)
239 - }
240 - if def.Labels != nil {
241 - def.Labels = maps.Clone(def.Labels)
242 - }
243 - return def
244 -}
src/go/plugin/scripts.d/pkg/schedulers/manager_test.go deleted
-69
@@ -1,69 +0,0 @@
1 -package schedulers
2 -
3 -import (
4 - "testing"
5 - "time"
6 -
7 - "github.com/netdata/netdata/go/plugins/logger"
8 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/runtime"
9 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
10 -)
11 -
12 -func TestApplyGetRemove(t *testing.T) {
13 - def := Definition{Name: "custom", Workers: 25, QueueSize: 64}
14 - if err := ApplyDefinition(def, logger.New()); err != nil {
15 - t.Fatalf("apply failed: %v", err)
16 - }
17 - if got, ok := Get("custom"); !ok || got.Workers != 25 || got.QueueSize != 64 {
18 - t.Fatalf("unexpected definition: %+v ok=%v", got, ok)
19 - }
20 - if err := RemoveDefinition("custom"); err != nil {
21 - t.Fatalf("remove failed: %v", err)
22 - }
23 - if _, ok := Get("custom"); ok {
24 - t.Fatalf("expected custom to be removed")
25 - }
26 -}
27 -
28 -func TestDefaultAlwaysPresent(t *testing.T) {
29 - ApplyDefinition(Definition{Name: "default", Workers: 10, QueueSize: 10}, logger.New())
30 - if def, ok := Get("default"); !ok || def.Workers != 10 || def.Builtin {
31 - t.Fatalf("expected customized default, got %+v ok=%v", def, ok)
32 - }
33 - if err := RemoveDefinition("default"); err != nil {
34 - t.Fatalf("remove default failed: %v", err)
35 - }
36 - if def, ok := Get("default"); !ok || def.Workers != 50 || def.QueueSize != 128 || !def.Builtin {
37 - t.Fatalf("default definition should reset, got %+v ok=%v", def, ok)
38 - }
39 -}
40 -
41 -func TestAttachDetachJob(t *testing.T) {
42 - def := Definition{Name: "attach", Workers: 5, QueueSize: 16}
43 - if err := ApplyDefinition(def, logger.New()); err != nil {
44 - t.Fatalf("apply failed: %v", err)
45 - }
46 - reg := runtime.JobRegistration{Spec: testJobSpec("job1")}
47 - handle, err := AttachJob("attach", reg, logger.New())
48 - if err != nil {
49 - t.Fatalf("attach failed: %v", err)
50 - }
51 - if handle == nil {
52 - t.Fatalf("handle nil")
53 - }
54 - DetachJob(handle)
55 - if err := RemoveDefinition("attach"); err != nil {
56 - t.Fatalf("remove failed: %v", err)
57 - }
58 -}
59 -
60 -func testJobSpec(name string) spec.JobSpec {
61 - return spec.JobSpec{
62 - Name: name,
63 - Plugin: "/bin/true",
64 - CheckInterval: time.Second,
65 - RetryInterval: time.Second,
66 - Timeout: time.Second,
67 - MaxCheckAttempts: 1,
68 - }
69 -}
src/go/plugin/scripts.d/pkg/spec/job.go deleted
-181
@@ -1,181 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package spec
4 -
5 -import (
6 - "fmt"
7 - "strings"
8 - "time"
9 -
10 - "github.com/netdata/netdata/go/plugins/pkg/confopt"
11 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/timeperiod"
12 -)
13 -
14 -const (
15 - defaultCheckInterval = 5 * time.Minute
16 - defaultRetryInterval = 1 * time.Minute
17 - defaultTimeout = 60 * time.Second
18 - defaultMaxCheckAttempts = 3
19 -)
20 -
21 -const MaxArgMacros = 32
22 -
23 -var allowedTimeoutStates = map[string]struct{}{
24 - "critical": {},
25 - "warning": {},
26 - "unknown": {},
27 -}
28 -
29 -// JobConfig matches the YAML schema exposed to users.
30 -type JobConfig struct {
31 - Name string `yaml:"name" json:"name"`
32 - Scheduler string `yaml:"scheduler,omitempty" json:"scheduler,omitempty"`
33 - Vnode string `yaml:"vnode,omitempty" json:"vnode"`
34 - Plugin string `yaml:"plugin" json:"plugin"`
35 - Args []string `yaml:"args,omitempty" json:"args"`
36 - ArgValues []string `yaml:"arg_values,omitempty" json:"arg_values"`
37 - Environment map[string]string `yaml:"environment,omitempty" json:"environment"`
38 - Timeout confopt.Duration `yaml:"timeout,omitempty" json:"timeout"`
39 - TimeoutState string `yaml:"timeout_state,omitempty" json:"timeout_state"`
40 - CheckInterval confopt.Duration `yaml:"check_interval,omitempty" json:"check_interval"`
41 - RetryInterval confopt.Duration `yaml:"retry_interval,omitempty" json:"retry_interval"`
42 - MaxCheckAttempts int `yaml:"max_check_attempts,omitempty" json:"max_check_attempts"`
43 - UpdateEvery int `yaml:"update_every,omitempty" json:"update_every"`
44 - AutoDetectEvery int `yaml:"autodetection_retry,omitempty" json:"autodetection_retry"`
45 - InterCheckJitter confopt.Duration `yaml:"inter_check_jitter,omitempty" json:"inter_check_jitter"`
46 - WorkingDirectory string `yaml:"working_directory,omitempty" json:"working_directory"`
47 - Notes string `yaml:"notes,omitempty" json:"notes"`
48 - CustomVars map[string]string `yaml:"custom_vars,omitempty" json:"custom_vars"`
49 - CheckPeriod string `yaml:"check_period,omitempty" json:"check_period"`
50 - DirectorySource string `yaml:"__directory_source__,omitempty" json:"-"`
51 -}
52 -
53 -// JobSpec is the normalized, runtime-friendly representation of a job definition.
54 -type JobSpec struct {
55 - Name string
56 - Scheduler string
57 - Vnode string
58 - Plugin string
59 - Args []string
60 - ArgValues []string
61 - Environment map[string]string
62 - Timeout time.Duration
63 - TimeoutState string
64 - CheckInterval time.Duration
65 - RetryInterval time.Duration
66 - MaxCheckAttempts int
67 - InterCheckJitter time.Duration
68 - WorkingDirectory string
69 - CustomVars map[string]string
70 - CheckPeriod string
71 -}
72 -
73 -// SetDefaults normalizes empty user input before validation.
74 -func (cfg *JobConfig) SetDefaults() {
75 - if cfg.Timeout == 0 {
76 - cfg.Timeout = confopt.Duration(defaultTimeout)
77 - }
78 - if cfg.CheckInterval == 0 {
79 - cfg.CheckInterval = confopt.Duration(defaultCheckInterval)
80 - }
81 - if cfg.RetryInterval == 0 {
82 - cfg.RetryInterval = confopt.Duration(defaultRetryInterval)
83 - }
84 - if cfg.MaxCheckAttempts == 0 {
85 - cfg.MaxCheckAttempts = defaultMaxCheckAttempts
86 - }
87 - if cfg.TimeoutState == "" {
88 - cfg.TimeoutState = "critical"
89 - }
90 - if cfg.Environment == nil {
91 - cfg.Environment = make(map[string]string)
92 - }
93 - if cfg.CustomVars == nil {
94 - cfg.CustomVars = make(map[string]string)
95 - }
96 - if cfg.CheckPeriod == "" {
97 - cfg.CheckPeriod = timeperiod.DefaultPeriodName
98 - }
99 - if strings.TrimSpace(cfg.Scheduler) == "" {
100 - cfg.Scheduler = "default"
101 - }
102 -}
103 -
104 -// Validate ensures the job definition is self-consistent.
105 -func (cfg JobConfig) Validate() error {
106 - if cfg.Name == "" {
107 - return fmt.Errorf("job name is required")
108 - }
109 - if cfg.Plugin == "" {
110 - return fmt.Errorf("job '%s': plugin path is required", cfg.Name)
111 - }
112 - if len(cfg.ArgValues) > MaxArgMacros {
113 - return fmt.Errorf("job '%s': arg_values supports up to %d entries", cfg.Name, MaxArgMacros)
114 - }
115 - if cfg.CheckInterval <= 0 {
116 - return fmt.Errorf("job '%s': check_interval must be > 0", cfg.Name)
117 - }
118 - if cfg.RetryInterval <= 0 {
119 - return fmt.Errorf("job '%s': retry_interval must be > 0", cfg.Name)
120 - }
121 - if cfg.Timeout <= 0 {
122 - return fmt.Errorf("job '%s': timeout must be > 0", cfg.Name)
123 - }
124 - if cfg.TimeoutState != "" {
125 - if _, ok := allowedTimeoutStates[strings.ToLower(cfg.TimeoutState)]; !ok {
126 - return fmt.Errorf("job '%s': timeout_state must be one of %v", cfg.Name, keys(allowedTimeoutStates))
127 - }
128 - }
129 - if cfg.MaxCheckAttempts < 1 {
130 - return fmt.Errorf("job '%s': max_check_attempts must be >= 1", cfg.Name)
131 - }
132 - return nil
133 -}
134 -
135 -// ToSpec converts JobConfig into a runtime JobSpec.
136 -func (cfg JobConfig) ToSpec() (JobSpec, error) {
137 - cfg.SetDefaults()
138 - if err := cfg.Validate(); err != nil {
139 - return JobSpec{}, err
140 - }
141 -
142 - sp := JobSpec{
143 - Name: cfg.Name,
144 - Scheduler: strings.TrimSpace(cfg.Scheduler),
145 - Vnode: cfg.Vnode,
146 - Plugin: cfg.Plugin,
147 - Args: append([]string{}, cfg.Args...),
148 - ArgValues: append([]string{}, cfg.ArgValues...),
149 - Environment: cloneMap(cfg.Environment),
150 - Timeout: time.Duration(cfg.Timeout),
151 - TimeoutState: strings.ToLower(cfg.TimeoutState),
152 - CheckInterval: time.Duration(cfg.CheckInterval),
153 - RetryInterval: time.Duration(cfg.RetryInterval),
154 - MaxCheckAttempts: cfg.MaxCheckAttempts,
155 - InterCheckJitter: time.Duration(cfg.InterCheckJitter),
156 - WorkingDirectory: cfg.WorkingDirectory,
157 - CustomVars: cloneMap(cfg.CustomVars),
158 - CheckPeriod: cfg.CheckPeriod,
159 - }
160 -
161 - return sp, nil
162 -}
163 -
164 -func cloneMap(in map[string]string) map[string]string {
165 - if len(in) == 0 {
166 - return map[string]string{}
167 - }
168 - out := make(map[string]string, len(in))
169 - for k, v := range in {
170 - out[k] = v
171 - }
172 - return out
173 -}
174 -
175 -func keys(m map[string]struct{}) []string {
176 - out := make([]string, 0, len(m))
177 - for k := range m {
178 - out = append(out, k)
179 - }
180 - return out
181 -}
src/go/plugin/scripts.d/pkg/spec/job_test.go deleted
-48
@@ -1,48 +0,0 @@
1 -package spec
2 -
3 -import "testing"
4 -
5 -func TestJobConfigDefaults(t *testing.T) {
6 - cfg := JobConfig{Name: "sample", Plugin: "/usr/lib/nagios/plugins/check_ping"}
7 - cfg.SetDefaults()
8 -
9 - if cfg.Timeout == 0 {
10 - t.Fatalf("expected timeout to be set")
11 - }
12 - if cfg.CheckInterval == 0 || cfg.RetryInterval == 0 {
13 - t.Fatalf("expected intervals to be set")
14 - }
15 - if cfg.MaxCheckAttempts == 0 {
16 - t.Fatalf("expected max attempts to be non-zero")
17 - }
18 - if cfg.CheckPeriod == "" {
19 - t.Fatalf("expected check period to default")
20 - }
21 -}
22 -
23 -func TestJobConfigValidate(t *testing.T) {
24 - cfg := JobConfig{Name: "sample", Plugin: "/bin/true"}
25 - cfg.SetDefaults()
26 - if err := cfg.Validate(); err != nil {
27 - t.Fatalf("unexpected validation error: %v", err)
28 - }
29 -}
30 -
31 -func TestJobConfigInvalidTimeoutState(t *testing.T) {
32 - cfg := JobConfig{Name: "sample", Plugin: "/bin/true", TimeoutState: "bogus"}
33 - cfg.SetDefaults()
34 - if err := cfg.Validate(); err == nil {
35 - t.Fatalf("expected error")
36 - }
37 -}
38 -
39 -func TestJobConfigArgValuesLimit(t *testing.T) {
40 - cfg := JobConfig{Name: "sample", Plugin: "/bin/true"}
41 - for i := 0; i < MaxArgMacros+1; i++ {
42 - cfg.ArgValues = append(cfg.ArgValues, "value")
43 - }
44 - cfg.SetDefaults()
45 - if err := cfg.Validate(); err == nil {
46 - t.Fatalf("expected error when arg_values exceed limit")
47 - }
48 -}
src/go/plugin/scripts.d/pkg/timeperiod/README.md new
+83
@@ -0,0 +1,83 @@
1 +# timeperiod
2 +
3 +`timeperiod` compiles scripts.d schedule definitions into runtime period predicates used by the Nagios collector.
4 +
5 +## What are "Nagios-style scheduling periods"?
6 +
7 +In this plugin, a time period is a named allow-window that says **when a check may run**.
8 +
9 +- A period has one or more allow rules (`weekly`, `nth_weekday`, `date`).
10 +- A period can exclude other named periods (`exclude`).
11 +- `Allows(t)` answers: "is this timestamp allowed?"
12 +- `NextAllowed(t)` finds the next allowed timestamp.
13 +
14 +This is "Nagios-style" because checks are gated by named time periods and exclusions, instead of only a single fixed interval.
15 +
16 +## What this package does
17 +
18 +- Defines schedule config types (`Config`, `RuleConfig`) for YAML/JSON.
19 +- Compiles raw config into a resolved set of named periods (`Compile`, `Set.Resolve`).
20 +- Evaluates whether a timestamp is allowed (`Period.Allows`).
21 +- Finds the next allowed execution slot (`Period.NextAllowed`).
22 +- Ensures the builtin always-on period exists (`EnsureDefault`).
23 +
24 +## Supported rule types
25 +
26 +- `weekly`: weekdays + time ranges (`HH:MM-HH:MM`)
27 +- `nth_weekday`: Nth weekday in month (`weekday` + `nth`) + time ranges
28 +- `date`: explicit calendar dates (`YYYY-MM-DD`) + time ranges
29 +
30 +## Config examples
31 +
32 +### 1) Business-hours checks (Mon-Fri, 09:00-18:00)
33 +
34 +```yaml
35 +- name: business_hours
36 + alias: Business Hours
37 + rules:
38 + - type: weekly
39 + days: [monday, tuesday, wednesday, thursday, friday]
40 + ranges: ["09:00-18:00"]
41 +```
42 +
43 +### 2) First Monday maintenance window each month
44 +
45 +```yaml
46 +- name: first_monday_maintenance
47 + alias: First Monday Maint
48 + rules:
49 + - type: nth_weekday
50 + weekday: monday
51 + nth: 1
52 + ranges: ["02:00-04:00"]
53 +```
54 +
55 +### 3) Holiday blackout by specific dates
56 +
57 +```yaml
58 +- name: holidays
59 + alias: Holiday Blackout
60 + rules:
61 + - type: date
62 + dates: ["2026-12-25", "2026-12-31"]
63 + ranges: ["00:00-24:00"]
64 +```
65 +
66 +### 4) Allow always, except maintenance/holidays
67 +
68 +```yaml
69 +- name: run_checks
70 + alias: Run Checks
71 + rules:
72 + - type: weekly
73 + days: [sunday, monday, tuesday, wednesday, thursday, friday, saturday]
74 + ranges: ["00:00-24:00"]
75 + exclude: [first_monday_maintenance, holidays]
76 +```
77 +
78 +## Notes
79 +
80 +- Date format is strict `YYYY-MM-DD`.
81 +- Range format is strict `HH:MM-HH:MM` (`24:00` is valid only as an end boundary).
82 +- The package is scheduler-facing infrastructure and should remain generic (no parser/output domain logic).
83 +- `DefaultPeriodName` / `DefaultPeriodConfig` define the implicit `24x7` fallback period.
src/go/plugin/scripts.d/pkg/units/scale.go deleted
-150
@@ -1,150 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package units
4 -
5 -import (
6 - "math"
7 - "strings"
8 -)
9 -
10 -const (
11 - defaultDivisor = 1000
12 - timeDivisor = 1_000_000_000
13 -)
14 -
15 -// Scale describes how to convert a floating-point measurement into the integer
16 -// representation Netdata expects.
17 -type Scale struct {
18 - CanonicalUnit string
19 - Divisor int
20 - multiplier float64
21 -}
22 -
23 -// NewScale determines the appropriate scaling strategy for a Nagios perfdata unit.
24 -func NewScale(unit string) Scale {
25 - trimmed := strings.TrimSpace(unit)
26 - if trimmed == "" {
27 - return Scale{CanonicalUnit: "", Divisor: defaultDivisor, multiplier: defaultDivisor}
28 - }
29 - lower := strings.ToLower(trimmed)
30 - if scale, ok := timeScale(lower); ok {
31 - return scale
32 - }
33 - if scale, ok := byteScale(trimmed); ok {
34 - return scale
35 - }
36 - if lower == "%" {
37 - return Scale{CanonicalUnit: "%", Divisor: defaultDivisor, multiplier: defaultDivisor}
38 - }
39 - if lower == "c" {
40 - return Scale{CanonicalUnit: "c", Divisor: 1, multiplier: 1}
41 - }
42 - return Scale{CanonicalUnit: trimmed, Divisor: defaultDivisor, multiplier: defaultDivisor}
43 -}
44 -
45 -// Apply converts the provided value into its integer representation using the
46 -// scale's multiplier.
47 -func (s Scale) Apply(value float64) int64 {
48 - return int64(math.Round(value * s.multiplier))
49 -}
50 -
51 -func timeScale(unit string) (Scale, bool) {
52 - switch unit {
53 - case "s", "sec", "secs", "second", "seconds":
54 - return Scale{CanonicalUnit: "seconds", Divisor: timeDivisor, multiplier: timeDivisor}, true
55 - case "ms", "millisecond", "milliseconds":
56 - return Scale{CanonicalUnit: "seconds", Divisor: timeDivisor, multiplier: 1_000_000}, true
57 - case "us", "µs", "usec", "microsecond", "microseconds":
58 - return Scale{CanonicalUnit: "seconds", Divisor: timeDivisor, multiplier: 1_000}, true
59 - case "ns", "nanosecond", "nanoseconds":
60 - return Scale{CanonicalUnit: "seconds", Divisor: timeDivisor, multiplier: 1}, true
61 - default:
62 - return Scale{}, false
63 - }
64 -}
65 -
66 -func byteScale(unit string) (Scale, bool) {
67 - base, perSecond := splitPerSecond(unit)
68 - if base == "" {
69 - return Scale{}, false
70 - }
71 - mult, kind, ok := byteMultiplier(base)
72 - if !ok {
73 - return Scale{}, false
74 - }
75 - canonical := kind
76 - if perSecond {
77 - canonical += "/s"
78 - }
79 - return Scale{CanonicalUnit: canonical, Divisor: 1, multiplier: mult}, true
80 -}
81 -
82 -func splitPerSecond(unit string) (string, bool) {
83 - lower := strings.ToLower(unit)
84 - switch {
85 - case strings.HasSuffix(lower, "/s"):
86 - return strings.TrimSpace(unit[:len(unit)-2]), true
87 - case strings.HasSuffix(lower, "ps"):
88 - return strings.TrimSpace(unit[:len(unit)-2]), true
89 - default:
90 - return strings.TrimSpace(unit), false
91 - }
92 -}
93 -
94 -func byteMultiplier(unit string) (float64, string, bool) {
95 - unit = strings.TrimSpace(unit)
96 - if unit == "" {
97 - return 0, "", false
98 - }
99 - kind, prefix, ok := splitByteUnit(unit)
100 - if !ok {
101 - return 0, "", false
102 - }
103 - mult, ok := byteMagnitude(prefix)
104 - if !ok {
105 - return 0, "", false
106 - }
107 - return mult, kind, true
108 -}
109 -
110 -func splitByteUnit(unit string) (string, string, bool) {
111 - lower := strings.ToLower(unit)
112 - switch {
113 - case strings.HasSuffix(lower, "bytes"):
114 - return "bytes", unit[:len(unit)-5], true
115 - case strings.HasSuffix(lower, "byte"):
116 - return "bytes", unit[:len(unit)-4], true
117 - case strings.HasSuffix(lower, "bits"):
118 - return "bits", unit[:len(unit)-4], true
119 - case strings.HasSuffix(lower, "bit"):
120 - return "bits", unit[:len(unit)-3], true
121 - }
122 - if len(unit) == 0 {
123 - return "", "", false
124 - }
125 - last := unit[len(unit)-1]
126 - switch last {
127 - case 'B':
128 - return "bytes", unit[:len(unit)-1], true
129 - case 'b':
130 - return "bits", unit[:len(unit)-1], true
131 - }
132 - return "", "", false
133 -}
134 -
135 -func byteMagnitude(prefix string) (float64, bool) {
136 - switch strings.ToLower(strings.TrimSpace(prefix)) {
137 - case "":
138 - return 1, true
139 - case "k":
140 - return 1_000, true
141 - case "m":
142 - return 1_000_000, true
143 - case "g":
144 - return 1_000_000_000, true
145 - case "t":
146 - return 1_000_000_000_000, true
147 - default:
148 - return 0, false
149 - }
150 -}
src/go/plugin/scripts.d/pkg/units/scale_test.go deleted
-30
@@ -1,30 +0,0 @@
1 -package units
2 -
3 -import "testing"
4 -
5 -func TestNewScaleDistinguishesBitsAndBytes(t *testing.T) {
6 - cases := []struct {
7 - name string
8 - unit string
9 - value float64
10 - canonicalUnit string
11 - want int64
12 - }{
13 - {name: "megabytes per second", unit: "MBps", value: 1.5, canonicalUnit: "bytes/s", want: 1_500_000},
14 - {name: "megabits per second", unit: "Mbps", value: 1.5, canonicalUnit: "bits/s", want: 1_500_000},
15 - {name: "megabytes", unit: "MB", value: 2.5, canonicalUnit: "bytes", want: 2_500_000},
16 - {name: "megabits", unit: "Mb", value: 2.5, canonicalUnit: "bits", want: 2_500_000},
17 - }
18 -
19 - for _, tc := range cases {
20 - t.Run(tc.name, func(t *testing.T) {
21 - scale := NewScale(tc.unit)
22 - if scale.CanonicalUnit != tc.canonicalUnit {
23 - t.Fatalf("expected canonical unit %q, got %q", tc.canonicalUnit, scale.CanonicalUnit)
24 - }
25 - if got := scale.Apply(tc.value); got != tc.want {
26 - t.Fatalf("expected %d, got %d", tc.want, got)
27 - }
28 - })
29 - }
30 -}
src/go/plugin/scripts.d/tests/mock_integration_test.go deleted
-296
@@ -1,296 +0,0 @@
1 -//go:build linux
2 -
3 -package tests
4 -
5 -import (
6 - "context"
7 - "os"
8 - "path/filepath"
9 - "sync"
10 - "testing"
11 - "time"
12 -
13 - "github.com/netdata/netdata/go/plugins/pkg/buildinfo"
14 - ndexec "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/ndexec"
15 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/charts"
16 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/ids"
17 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/output"
18 - runtimepkg "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/runtime"
19 - specpkg "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
20 - "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/timeperiod"
21 - "github.com/stretchr/testify/require"
22 -)
23 -
24 -const (
25 - testScheduler = "mock-scheduler"
26 - mockPluginDir = "plugins"
27 - schedulerStart = 100 * time.Millisecond
28 -)
29 -
30 -func TestMockPluginsProduceExpectedStatesAndPerfdata(t *testing.T) {
31 - sched, emitter, metas := startTestScheduler(t, []specpkg.JobSpec{
32 - newJobSpec(t, "mock_ok", "check_mock_ok.sh", nil),
33 - newJobSpec(t, "mock_warn", "check_mock_warn.sh", func(sp *specpkg.JobSpec) { sp.MaxCheckAttempts = 1 }),
34 - newJobSpec(t, "mock_crit", "check_mock_crit.sh", func(sp *specpkg.JobSpec) { sp.MaxCheckAttempts = 1 }),
35 - })
36 - waitForJobs(t, emitter, []string{"mock_ok", "mock_warn", "mock_crit"})
37 -
38 - metrics := sched.CollectMetrics()
39 - assertMetricEquals(t, metrics, metas["mock_ok"], "state", "ok", 1)
40 - assertMetricEquals(t, metrics, metas["mock_warn"], "state", "warning", 1)
41 - assertMetricEquals(t, metrics, metas["mock_crit"], "state", "critical", 1)
42 -
43 - // perfdata hidden dims exist
44 - labelID := ids.Sanitize("value")
45 - ck := metas["mock_ok"].PerfdataMetricID(labelID, "warn_defined")
46 - require.Equal(t, int64(1), metrics[ck], "warn metadata missing")
47 -}
48 -
49 -func TestMockPluginMacrosExposeExpectedValues(t *testing.T) {
50 - spec := newJobSpec(t, "mock_macro", "check_mock_macro.sh", func(sp *specpkg.JobSpec) {
51 - sp.Vnode = "mock-host"
52 - sp.ArgValues = []string{"8080"}
53 - sp.MaxCheckAttempts = 3
54 - })
55 - if spec.Vnode == "" {
56 - t.Fatalf("vnode not set on job spec")
57 - }
58 - _, emitter, _ := startTestScheduler(t, []specpkg.JobSpec{spec})
59 - results := waitForResults(t, emitter, 1)
60 - require.NoError(t, results[0].Err)
61 - output := string(results[0].Output)
62 - require.Contains(t, output, "HOSTNAME=mock-host")
63 - require.Contains(t, output, "HOSTADDRESS=203.0.113.10")
64 - require.Contains(t, output, "HOSTALIAS=mock-host-alias")
65 - require.Contains(t, output, "SERVICEATTEMPT=1")
66 - require.Contains(t, output, "MAXSERVICEATTEMPTS=3")
67 - require.Contains(t, output, "HOSTLABEL_REGION=testlab")
68 - require.Contains(t, output, "HOST_CUSTOM_DC=east")
69 - require.Contains(t, output, "ARG1=8080")
70 -}
71 -
72 -func TestMockPluginHandlesLongOutputAndLogs(t *testing.T) {
73 - _, emitter, _ := startTestScheduler(t, []specpkg.JobSpec{newJobSpec(t, "mock_long", "check_mock_long.sh", nil)})
74 - results := waitForResults(t, emitter, 1)
75 - longOut := results[0].Parsed.LongOutput
76 - require.Contains(t, longOut, "line-one details")
77 - require.Contains(t, longOut, "line-two")
78 -}
79 -
80 -func TestMockSlowPluginRaisesSkipMetric(t *testing.T) {
81 - spec := newJobSpec(t, "mock_slow", "check_mock_slow.sh", func(sp *specpkg.JobSpec) {
82 - sp.Args = []string{"2"}
83 - sp.CheckInterval = 200 * time.Millisecond
84 - sp.RetryInterval = 200 * time.Millisecond
85 - })
86 - sched, _, _ := startTestScheduler(t, []specpkg.JobSpec{spec})
87 - time.Sleep(2500 * time.Millisecond)
88 - metrics := sched.CollectMetrics()
89 - key := charts.SchedulerMetricKey(testScheduler, charts.ChartSchedulerRate, "skipped")
90 - if metrics[key] == 0 {
91 - t.Fatalf("scheduler skip counter not incremented: %d", metrics[key])
92 - }
93 -}
94 -
95 -// --- helpers ---
96 -
97 -type recordingEmitter struct {
98 - mu sync.Mutex
99 - results []runtimepkg.ExecutionResult
100 - snapshots []runtimepkg.JobSnapshot
101 -}
102 -
103 -func newRecordingEmitter() *recordingEmitter {
104 - return &recordingEmitter{}
105 -}
106 -
107 -func (r *recordingEmitter) Emit(job runtimepkg.JobRuntime, res runtimepkg.ExecutionResult, snap runtimepkg.JobSnapshot) {
108 - r.mu.Lock()
109 - defer r.mu.Unlock()
110 - resCopy := res
111 - resCopy.Job = job
112 - r.results = append(r.results, resCopy)
113 - r.snapshots = append(r.snapshots, snap)
114 -}
115 -
116 -func (r *recordingEmitter) Close() error { return nil }
117 -
118 -func waitForResults(t *testing.T, emitter *recordingEmitter, want int) []runtimepkg.ExecutionResult {
119 - deadline := time.Now().Add(5 * time.Second)
120 - for {
121 - emitter.mu.Lock()
122 - if len(emitter.results) >= want {
123 - out := append([]runtimepkg.ExecutionResult(nil), emitter.results...)
124 - emitter.mu.Unlock()
125 - return out
126 - }
127 - emitter.mu.Unlock()
128 - if time.Now().After(deadline) {
129 - t.Fatalf("timeout waiting for %d results (have %d)", want, len(emitter.results))
130 - }
131 - time.Sleep(20 * time.Millisecond)
132 - }
133 -}
134 -
135 -func waitForJobs(t *testing.T, emitter *recordingEmitter, jobs []string) []runtimepkg.ExecutionResult {
136 - t.Helper()
137 - deadline := time.Now().Add(8 * time.Second)
138 - target := make(map[string]struct{}, len(jobs))
139 - for _, j := range jobs {
140 - target[j] = struct{}{}
141 - }
142 - for {
143 - emitter.mu.Lock()
144 - copyResults := append([]runtimepkg.ExecutionResult(nil), emitter.results...)
145 - emitter.mu.Unlock()
146 - seen := make(map[string]bool, len(copyResults))
147 - for _, res := range copyResults {
148 - seen[res.Job.Spec.Name] = true
149 - }
150 - missing := false
151 - for name := range target {
152 - if !seen[name] {
153 - missing = true
154 - break
155 - }
156 - }
157 - if !missing {
158 - return copyResults
159 - }
160 - if time.Now().After(deadline) {
161 - t.Fatalf("timeout waiting for jobs %v (seen %v)", jobs, seen)
162 - }
163 - time.Sleep(20 * time.Millisecond)
164 - }
165 -}
166 -
167 -func findResult(t *testing.T, results []runtimepkg.ExecutionResult, jobName string) runtimepkg.ExecutionResult {
168 - for _, res := range results {
169 - if res.Job.Spec.Name == jobName {
170 - return res
171 - }
172 - }
173 - names := make([]string, 0, len(results))
174 - for _, res := range results {
175 - names = append(names, res.Job.Spec.Name)
176 - }
177 - t.Fatalf("no result captured for %s (have %v)", jobName, names)
178 - return runtimepkg.ExecutionResult{}
179 -}
180 -
181 -func startTestScheduler(t *testing.T, jobs []specpkg.JobSpec) (*runtimepkg.Scheduler, *recordingEmitter, map[string]charts.JobIdentity) {
182 - t.Helper()
183 - ensureNdRun(t)
184 - emitter := newRecordingEmitter()
185 - periods := compileDefaultPeriods(t)
186 - workers := len(jobs)
187 - if workers == 0 {
188 - workers = 1
189 - }
190 - identities := make(map[string]charts.JobIdentity, len(jobs))
191 - for _, job := range jobs {
192 - identities[job.Name] = charts.NewJobIdentity(testScheduler, job)
193 - }
194 - sched, err := runtimepkg.NewScheduler(runtimepkg.SchedulerConfig{
195 - Workers: workers,
196 - SchedulerName: testScheduler,
197 - UserMacros: map[string]string{"USER1": "/usr/lib/nagios/plugins"},
198 - VnodeLookup: vnodeLookup,
199 - })
200 - require.NoError(t, err)
201 - for _, job := range jobs {
202 - _, err := sched.RegisterJob(runtimepkg.JobRegistration{
203 - Spec: job,
204 - Emitter: emitter,
205 - RegisterPerfdata: func(specpkg.JobSpec, output.PerfDatum) {},
206 - Periods: periods,
207 - })
208 - require.NoError(t, err)
209 - }
210 - ctx, cancel := context.WithCancel(context.Background())
211 - require.NoError(t, sched.Start(ctx))
212 - time.Sleep(schedulerStart)
213 - t.Cleanup(func() {
214 - cancel()
215 - sched.Stop()
216 - })
217 - return sched, emitter, identities
218 -}
219 -
220 -func vnodeLookup(spec specpkg.JobSpec) runtimepkg.VnodeInfo {
221 - if spec.Vnode == "" {
222 - return runtimepkg.VnodeInfo{}
223 - }
224 - return runtimepkg.VnodeInfo{
225 - Hostname: spec.Vnode,
226 - Labels: map[string]string{
227 - "_address": "203.0.113.10",
228 - "_alias": spec.Vnode + "-alias",
229 - "_DC": "east",
230 - "region": "testlab",
231 - },
232 - }
233 -}
234 -
235 -func compileDefaultPeriods(t *testing.T) *timeperiod.Set {
236 - configs := timeperiod.EnsureDefault(nil)
237 - set, err := timeperiod.Compile(configs)
238 - require.NoError(t, err)
239 - return set
240 -}
241 -
242 -func newJobSpec(t *testing.T, name, script string, fn func(*specpkg.JobSpec)) specpkg.JobSpec {
243 - t.Helper()
244 - path := scriptPath(t, script)
245 - spec := specpkg.JobSpec{
246 - Name: name,
247 - Plugin: path,
248 - Timeout: 5 * time.Second,
249 - CheckInterval: 1 * time.Second,
250 - RetryInterval: 500 * time.Millisecond,
251 - MaxCheckAttempts: 3,
252 - Args: []string{},
253 - ArgValues: []string{},
254 - Environment: map[string]string{},
255 - CustomVars: map[string]string{},
256 - }
257 - if fn != nil {
258 - fn(&spec)
259 - }
260 - return spec
261 -}
262 -
263 -func scriptPath(t *testing.T, name string) string {
264 - t.Helper()
265 - rel := filepath.Join(mockPluginDir, name)
266 - abs, err := filepath.Abs(rel)
267 - require.NoError(t, err)
268 - if _, err := os.Stat(abs); err != nil {
269 - t.Fatalf("mock plugin %s missing: %v", abs, err)
270 - }
271 - return abs
272 -}
273 -
274 -func ensureNdRun(t *testing.T) {
275 - t.Helper()
276 - dir := t.TempDir()
277 - stubRun := filepath.Join(dir, "nd-run")
278 - stubSudo := filepath.Join(dir, "ndsudo")
279 - content := "#!/usr/bin/env bash\ncmd=\"$1\"\nshift\nexec \"$cmd\" \"$@\"\n"
280 - require.NoError(t, os.WriteFile(stubRun, []byte(content), 0o755))
281 - require.NoError(t, os.WriteFile(stubSudo, []byte(content), 0o755))
282 - oldDir := buildinfo.NetdataBinDir
283 - buildinfo.NetdataBinDir = dir
284 - ndexec.SetRunnerPathsForTests(stubRun, stubSudo)
285 - t.Cleanup(func() {
286 - buildinfo.NetdataBinDir = oldDir
287 - })
288 -}
289 -
290 -func assertMetricEquals(t *testing.T, metrics map[string]int64, meta charts.JobIdentity, chart, dim string, want int64) {
291 - key := meta.TelemetryMetricID(chart, dim)
292 - got := metrics[key]
293 - if got != want {
294 - t.Fatalf("metric %s = %d, want %d (metrics=%v)", key, got, want, metrics)
295 - }
296 -}
src/health/health.d/nagios.conf deleted
-16
@@ -1,16 +0,0 @@
1 -# Default Nagios plugin alarms. Disable or clone this template if you need custom rules.
2 -
3 - template: nagios_perfdata_thresholds
4 - on: nagios.perfdata
5 - class: Services
6 - type: Nagios
7 -component: Nagios plugin
8 - lookup: average -1m unaligned of value
9 - units: value
10 - every: 15s
11 - warn: ($warn_defined == 1) AND ( (($warn_inclusive == 1) AND ($warn_low_defined == 1 OR $warn_high_defined == 1) AND (($warn_low_defined == 0 OR $value >= $warn_low) AND ($warn_high_defined == 0 OR $value <= $warn_high))) OR (($warn_inclusive == 0) AND (($warn_low_defined == 1 AND $value < $warn_low) OR ($warn_high_defined == 1 AND $value > $warn_high))) )
12 - crit: ($crit_defined == 1) AND ( (($crit_inclusive == 1) AND ($crit_low_defined == 1 OR $crit_high_defined == 1) AND (($crit_low_defined == 0 OR $value >= $crit_low) AND ($crit_high_defined == 0 OR $value <= $crit_high))) OR (($crit_inclusive == 0) AND (($crit_low_defined == 1 AND $value < $crit_low) OR ($crit_high_defined == 1 AND $value > $crit_high))) )
13 - delay: down 1m multiplier 1.5 max 10m
14 - summary: Nagios ${label:nagios_job}/${label:perf_label} threshold breach on scheduler ${label:nagios_scheduler}
15 - info: Value=${value} warn_inclusive=${warn_inclusive} crit_inclusive=${crit_inclusive}
16 - to: sysadmin
src/health/health.d/nagios_skipped.conf deleted
-16
@@ -1,16 +0,0 @@
1 -# Alerts when Nagios plugin checks are skipped because a previous run was still
2 -# executing at the scheduled interval.
3 -
4 - template: nagios_plugin_skipped
5 - on: nagios.runtime
6 - class: Services
7 - type: Nagios
8 -component: Nagios plugin
9 - lookup: max -1m unaligned of skipped
10 - every: 15s
11 - warn: $skipped > 0
12 - crit: $skipped > 2
13 - delay: up 10s down 30s multiplier 1.5 max 5m
14 - summary: Nagios ${label:nagios_job} skipped checks on scheduler ${label:nagios_scheduler}
15 - info: Scheduler skipped at least one execution because a prior run was still active.
16 - to: sysadmin
src/health/health.d/nagios_state.conf deleted
-17
@@ -1,17 +0,0 @@
1 -# Default Nagios plugin state alerts. Disable or override this template if you
2 -# maintain custom alerting on top of nagios.state charts.
3 -
4 - template: nagios_plugin_state
5 - on: nagios.state
6 - class: Services
7 - type: Nagios
8 -component: Nagios plugin
9 - calc: $warning + $critical + $unknown
10 - units: state
11 - every: 15s
12 - warn: ($warning > 0 OR $unknown > 0) AND ($attempt >= $max_attempts)
13 - crit: ($critical > 0) AND ($attempt >= $max_attempts)
14 - delay: down 30s multiplier 1.5 max 5m
15 - summary: Nagios ${label:nagios_job} state degraded on scheduler ${label:nagios_scheduler}
16 - info: Attempts=$attempt/$max_attempts warning=$warning critical=$critical unknown=$unknown
17 - to: sysadmin