@cryptotaxi247 / netdata-1 / commits / 919dc395c

nagios plugin support (#21294)

* nagios module * zabbix module * fixed CI failures * Tidy up package description to better align with our other descriptions. Also use the correct minimum version for the conflicts line. * Fix up permissions handling in DEB postinst script. * Actually enable plugin for DEB package builds. * Add plugin to RPM packages. * go mod tidy * go mod tidy * move zabbixprecproc pkg * cleanup * fix: remove duplicate Job.Module() method introduced by merge * refactor: use label prefix convention for vnodes, add metadata, fix race - Replace Address/Alias/Custom fields on VirtualNode with label prefix convention: _address, _alias for reserved macros, _VAR for custom host variables, unprefixed for regular labels - Remove duplicate Job.Module() (master already has it from #21595) - Add metadata.yaml for nagios, zabbix, and scheduler modules - Update config_schema.json to document label conventions - Fix data race in Scheduler.armTimer() by capturing ctx locally before passing to timer goroutine closure * fix: scheduler restart safety and metadata accuracy - Nil timers in Stop() so restart recreates closures with fresh ctx - Fix zabbix metadata: state dimensions match actual emitter code (collect_failure/lld_failure/extraction_failure/dimension_failure/ok) - Fix nagios metadata: vnode path is /etc/netdata/vnodes/ not vnodes.d/ - Fix scheduler metadata: "per-job timers" not "timer heap" * refactor: remove TypeOverride from go.d Chart struct Dashboard groups charts by context, not chart type ID, so TypeOverride provides no user-visible benefit while adding complexity to the core framework. Charts now use the default job.FullName() as their type ID. * fix: collapse multi-line alert expressions to single lines Netdata health alert parser does not support multi-line warn/crit expressions. Also replaced multiple lookup lines in nagios_state with calc referencing dimensions directly. * style: go fmt alignment fixes after TypeOverride removal * fix: use static chart titles and remove dev stock configs - Replace dynamic per-job chart titles with static titles - Remove nagios.conf and samples.conf stock configs (dev paths) * chore: remove TODO files from PR --------- Co-authored-by: Austin S. Hemmelgarn <austin@netdata.cloud> Co-authored-by: ilyam8 <ilya@netdata.cloud>

Costa Tsaousis committed Feb 12, 2026 at 07:25 UTC 919dc395c9aca18677f188f114b7bee9ad5f2eac
118 files changed +29488 -70
.gitignore
+2
@@ -209,3 +209,5 @@ compile.sh
209 run-ibm.sh
210
211 tmp/
212 +
213 +build-and-install.sh
CMakeLists.txt
+30 -2
@@ -131,6 +131,7 @@ mark_as_advanced(ENABLE_DASHBOARD)
131
132 # Data collection plugins
133 option(ENABLE_PLUGIN_GO "Enable metric collectors written in Go" ${DEFAULT_FEATURE_STATE})
134 +option(ENABLE_PLUGIN_SCRIPTS "Enable the experimental scripts plugin (Nagios compatibility module)" OFF)
135 option(ENABLE_PLUGIN_OTEL "Enable collection of OpenTelemetry metrics and logs" ${DEFAULT_FEATURE_STATE})
136 cmake_dependent_option(ENABLE_PLUGIN_OTEL_SIGNAL_VIEWER "Enable OTel signal viewer plugin" ${DEFAULT_FEATURE_STATE} "NOT OS_WINDOWS" False)
137 option(ENABLE_PLUGIN_PYTHON "Enable metric collectors written in Python" ${DEFAULT_FEATURE_STATE})
@@ -330,7 +331,7 @@ if(ENABLE_JEMALLOC)
331 endif()
332 endif()
333
333 -if(ENABLE_PLUGIN_GO OR ENABLE_PLUGIN_IBM)
334 +if(ENABLE_PLUGIN_GO OR ENABLE_PLUGIN_IBM OR ENABLE_PLUGIN_SCRIPTS)
335 include(NetdataGoTools)
336
337 find_min_go_version("${CMAKE_SOURCE_DIR}/src/go")
@@ -338,7 +339,7 @@ if(ENABLE_PLUGIN_GO OR ENABLE_PLUGIN_IBM)
339 find_package(Go "${MIN_GO_VERSION}" REQUIRED)
340 endif()
341
341 -if(ENABLE_PLUGIN_GO)
342 +if(ENABLE_PLUGIN_GO OR ENABLE_PLUGIN_SCRIPTS)
343 set(NEED_NDSUDO TRUE)
344 else()
345 set(NEED_NDSUDO FALSE)
@@ -3238,6 +3239,33 @@ if(ENABLE_PLUGIN_GO)
3239 DESTINATION "${BINDIR}")
3240 endif()
3241
3242 +#
3243 +# Build scripts.d.plugin
3244 +#
3245 +
3246 +if(ENABLE_PLUGIN_SCRIPTS)
3247 + if (OS_WINDOWS)
3248 + set(SCRIPTS_PLUGIN_BIN scripts.d.plugin.exe)
3249 + else()
3250 + set(SCRIPTS_PLUGIN_BIN scripts.d.plugin)
3251 + endif()
3252 +
3253 + add_go_target(scripts-plugin ${SCRIPTS_PLUGIN_BIN} src/go cmd/scriptsdplugin)
3254 +
3255 + install(PROGRAMS ${CMAKE_BINARY_DIR}/${SCRIPTS_PLUGIN_BIN}
3256 + COMPONENT plugin-scripts
3257 + DESTINATION usr/libexec/netdata/plugins.d)
3258 +
3259 + install(FILES src/go/plugin/scripts.d/config/scripts.d.conf
3260 + COMPONENT plugin-scripts
3261 + DESTINATION usr/lib/netdata/conf.d)
3262 +
3263 + install(DIRECTORY src/go/plugin/scripts.d/config/scripts.d
3264 + COMPONENT plugin-scripts
3265 + DESTINATION usr/lib/netdata/conf.d
3266 + FILES_MATCHING PATTERN "*.conf")
3267 +endif()
3268 +
3269 #
3270 # Build ibm.d.plugin
3271 #
netdata-installer.sh
+13 -3
@@ -266,6 +266,7 @@ ENABLE_CHARTS=1
266 ENABLE_OTEL=0
267 ENABLE_OTEL_SIGNAL_VIEWER=0
268 ENABLE_IBM=0
269 +ENABLE_SCRIPTS=0
270 FORCE_LEGACY_CXX=0
271 NETDATA_CMAKE_OPTIONS="${NETDATA_CMAKE_OPTIONS-}"
272 REMOVE_BUILD=1
@@ -311,6 +312,8 @@ while [ -n "${1}" ]; do
312 "--disable-plugin-otel-signal-viewer") ENABLE_OTEL_SIGNAL_VIEWER=0 ;;
313 "--enable-plugin-ibm") ENABLE_IBM=1 ;;
314 "--disable-plugin-ibm") ENABLE_IBM=0 ;;
315 + "--enable-plugin-scripts") ENABLE_SCRIPTS=1 ;;
316 + "--disable-plugin-scripts") ENABLE_SCRIPTS=0 ;;
317 "--enable-exporting-kinesis" | "--enable-backend-kinesis")
318 # TODO: Needs CMake Support
319 ;;
@@ -554,14 +557,21 @@ fi
557 trap build_error EXIT
558
559 # -----------------------------------------------------------------------------
557 -# If we’re installing the Go plugin, ensure a working Go toolchain is installed.
558 -if [ "${ENABLE_GO}" -eq 1 ]; then
560 +# If we’re building any Go-based component, ensure a working Go toolchain exists.
561 +NEED_GO_TOOLCHAIN=0
562 +if [ "${ENABLE_GO}" -eq 1 ] || [ "${ENABLE_IBM}" -eq 1 ] || [ "${ENABLE_SCRIPTS}" -eq 1 ]; then
563 + NEED_GO_TOOLCHAIN=1
564 +fi
565 +
566 +if [ "${NEED_GO_TOOLCHAIN}" -eq 1 ]; then
567 progress "Checking for a usable Go toolchain and attempting to install one to /usr/local/go if needed."
568 . "${NETDATA_SOURCE_DIR}/packaging/check-for-go-toolchain.sh"
569
570 if ! ensure_go_toolchain; then
563 - warning "Go ${GOLANG_MIN_VERSION} needed to build Go plugin, but could not find or install a usable toolchain: ${GOLANG_FAILURE_REASON}"
571 + warning "Go ${GOLANG_MIN_VERSION} needed to build Go-based plugins (go.d, scripts.d, IBM), but could not find or install a usable toolchain: ${GOLANG_FAILURE_REASON}. Disabling those components."
572 ENABLE_GO=0
573 + ENABLE_IBM=0
574 + ENABLE_SCRIPTS=0
575 fi
576 fi
577
netdata.spec.in
+30 -1
@@ -445,6 +445,7 @@ advanced correlations and fast root cause analysis, native horizontal scalabilit
445 -DENABLE_PLUGIN_CGROUP_NETWORK=On \
446 -DENABLE_PLUGIN_DEBUGFS=On \
447 -DENABLE_PLUGIN_GO=On \
448 + -DENABLE_PLUGIN_SCRIPTS=On \
449 %if %{_have_ibm_plugin}
450 -DENABLE_PLUGIN_IBM=On \
451 %else
@@ -720,6 +721,11 @@ rm -rf "${RPM_BUILD_ROOT}"
721 %exclude %{_libdir}/%{name}/ibm-mqclient
722 %endif
723
724 +# Scripts plugin belongs to a different sub-package
725 +%exclude %{_libexecdir}/%{name}/plugins.d/scripts.d.plugin
726 +%exclude %{_libdir}/%{name}/conf.d/scripts.d.conf
727 +%exclude %{_libdir}/%{name}/conf.d/scripts.d
728 +
729 # Network viewer belongs to a different sub-package
730 %exclude %{_libexecdir}/%{name}/plugins.d/network-viewer.plugin
731
@@ -909,6 +915,28 @@ so most users will want it installed.
915 %{_libdir}/%{name}/conf.d/go.d.conf
916 %{_libdir}/%{name}/conf.d/go.d
917
918 +%package plugin-scripts
919 +Summary: The scripts metrics collection plugin for the Netdata Agent
920 +Group: Applications/System
921 +Requires: %{name} = %{version}
922 +Conflicts: %{name} < %{version}
923 +%if ! %{_have_sysuser}
924 +Requires(pre): %{name}-user >= %{version}
925 +%endif
926 +
927 +%description plugin-scripts
928 + This plugin allows the Netdata Agent to collect metrics using scripts
929 +that provide data in an extended version of the output format used by
930 +Nagios plugins. This provides compatibility with most Nagios plugins,
931 +as well as enabling simple active checks.
932 +
933 +%files plugin-scripts
934 +%defattr(0750,root,netdata,0750)
935 +%{_libexecdir}/%{name}/plugins.d/scripts.d.plugin
936 +%defattr(0644,root,netdata,0755)
937 +%{_libdir}/%{name}/conf.d/scripts.d.conf
938 +%{_libdir}/%{name}/conf.d/scripts.d
939 +
940 %if %{_have_ibm_plugin}
941 %package plugin-ibm
942 Summary: The IBM ecosystem metrics collection plugin for the Netdata Agent
@@ -3318,7 +3346,8 @@ done
3346 %attr(0644,root,root) %{_sysusersdir}/%{name}.conf
3347
3348 %changelog
3321 -* Tue Feb 10 2026 Austin Hemmelgarn <austin@netdata.cloud> 0.0.0-38
3349 +* Tue Feb 10 2026 Austin Hemmelgarn <austin@netdata.cloud> 0.0.0-39
3350 +- Add scripts plugin
3351 - Add new otel-signal-viewer plugin
3352 - Add back systemd-journal plugin with journal-viewer plugin as a transitional package
3353 * Tue Nov 11 2025 Austin Hemmelgarn <austin@netdata.cloud> 0.0.0-37
packaging/build-package.sh
+1
@@ -37,6 +37,7 @@ add_cmake_option ENABLE_PLUGIN_CGROUP_NETWORK On
37 add_cmake_option ENABLE_PLUGIN_DEBUGFS On
38 add_cmake_option ENABLE_PLUGIN_FREEIPMI On
39 add_cmake_option ENABLE_PLUGIN_GO On
40 +add_cmake_option ENABLE_PLUGIN_SCRIPTS On
41 add_cmake_option ENABLE_PLUGIN_PYTHON On
42 add_cmake_option ENABLE_PLUGIN_CHARTS On
43 add_cmake_option ENABLE_PLUGIN_LOCAL_LISTENERS On
packaging/cmake/Modules/Packaging.cmake
+25
@@ -355,6 +355,28 @@ set(CPACK_DEBIAN_PLUGIN-GO_PACKAGE_CONTROL_EXTRA
355
356 set(CPACK_DEBIAN_PLUGIN-GO_DEBUGINFO_PACKAGE Off)
357
358 +#
359 +# scripts.d.plugin
360 +#
361 +
362 +set(CPACK_COMPONENT_PLUGIN-SCRIPTS_DEPENDS "netdata")
363 +set(CPACK_COMPONENT_PLUGIN-SCRIPTS_DESCRIPTION
364 + "The scripts metrics collection plugin for the Netdata Agent
365 + This plugin allows the Netdata Agent to collect metrics using scripts
366 +that provide data in an extended version of the output format used by
367 +Nagios plugins. This provides compatibility with most Nagios plugins,
368 +as well as enabling simple active checks.")
369 +
370 +set(CPACK_DEBIAN_PLUGIN-SCRIPTS_PACKAGE_NAME "netdata-plugin-scripts")
371 +set(CPACK_DEBIAN_PLUGIN-SCRIPTS_PACKAGE_SECTION "net")
372 +set(CPACK_DEBIAN_PLUGIN-SCRIPTS_PACKAGE_CONFLICTS "netdata (<< 2.8)")
373 +set(CPACK_DEBIAN_PLUGIN-SCRIPTS_PACKAGE_PREDEPENDS "netdata-user")
374 +
375 +set(CPACK_DEBIAN_PLUGIN-SCRIPTS_PACKAGE_CONTROL_EXTRA
376 + "${PKG_FILES_PATH}/deb/plugin-scripts/postinst")
377 +
378 +set(CPACK_DEBIAN_PLUGIN-SCRIPTS_DEBUGINFO_PACKAGE Off)
379 +
380 #
381 # ibm.plugin
382 #
@@ -641,6 +663,9 @@ if(ENABLE_PLUGIN_IBM)
663 list(APPEND CPACK_COMPONENTS_ALL "plugin-ibm")
664 list(APPEND CPACK_COMPONENTS_ALL "plugin-ibm-libs")
665 endif()
666 +if(ENABLE_PLUGIN_SCRIPTS)
667 + list(APPEND CPACK_COMPONENTS_ALL "plugin-scripts")
668 +endif()
669 if(ENABLE_PLUGIN_NETWORK_VIEWER)
670 list(APPEND CPACK_COMPONENTS_ALL "plugin-network-viewer")
671 endif()
packaging/cmake/pkg-files/deb/plugin-scripts/postinst new
+12
@@ -0,0 +1,12 @@
1 +#!/bin/sh
2 +
3 +set -e
4 +
5 +case "$1" in
6 + configure|reconfigure)
7 + chown root:netdata /usr/libexec/netdata/plugins.d/scripts.d.plugin
8 + chmod 0750 /usr/libexec/netdata/plugins.d/scripts.d.plugin
9 + ;;
10 +esac
11 +
12 +exit 0
packaging/installer/functions.sh
+1
@@ -380,6 +380,7 @@ prepare_cmake_options() {
380 enable_feature PLUGIN_OTEL "${ENABLE_OTEL:-0}"
381 enable_feature PLUGIN_OTEL_SIGNAL_VIEWER "${ENABLE_OTEL_SIGNAL_VIEWER:-0}"
382 enable_feature PLUGIN_IBM "${ENABLE_IBM:-0}"
383 + enable_feature PLUGIN_SCRIPTS "${ENABLE_SCRIPTS:-0}"
384
385 check_for_feature EXPORTER_PROMETHEUS_REMOTE_WRITE "${EXPORTER_PROMETHEUS}" snappy
386 check_for_feature EXPORTER_MONGODB "${EXPORTER_MONGODB}" libmongoc-1.0
src/go/cmd/scriptsdplugin/main.go new
+98
@@ -0,0 +1,98 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package main
4 +
5 +import (
6 + "fmt"
7 + "log/slog"
8 + "os"
9 + "os/user"
10 + "path/filepath"
11 + "strings"
12 +
13 + "go.uber.org/automaxprocs/maxprocs"
14 + "golang.org/x/net/http/httpproxy"
15 +
16 + "github.com/netdata/netdata/go/plugins/logger"
17 + "github.com/netdata/netdata/go/plugins/pkg/buildinfo"
18 + "github.com/netdata/netdata/go/plugins/pkg/cli"
19 + "github.com/netdata/netdata/go/plugins/pkg/executable"
20 + "github.com/netdata/netdata/go/plugins/pkg/pluginconfig"
21 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent"
22 + _ "github.com/netdata/netdata/go/plugins/plugin/scripts.d/modules/nagios"
23 + _ "github.com/netdata/netdata/go/plugins/plugin/scripts.d/modules/scheduler"
24 + _ "github.com/netdata/netdata/go/plugins/plugin/scripts.d/modules/zabbix"
25 +)
26 +
27 +func init() {
28 + executable.Name = "scripts.d"
29 + if v := os.Getenv("TZ"); strings.HasPrefix(v, ":") {
30 + _ = os.Unsetenv("TZ")
31 + }
32 +}
33 +
34 +func main() {
35 + _, _ = maxprocs.Set(maxprocs.Logger(func(string, ...interface{}) {}))
36 +
37 + opts := parseCLI()
38 +
39 + if opts.Version {
40 + fmt.Printf("%s.plugin, version: %s\n", executable.Name, buildinfo.Version)
41 + return
42 + }
43 +
44 + pluginconfig.MustInit(opts)
45 +
46 + watchPaths := pluginconfig.CollectorsConfigWatchPaths()
47 + if len(watchPaths) == 0 {
48 + for _, dir := range pluginconfig.CollectorsDir() {
49 + watchPaths = append(watchPaths, filepath.Join(dir, "*.conf"))
50 + }
51 + }
52 +
53 + if lvl := pluginconfig.EnvLogLevel(); lvl != "" {
54 + logger.Level.SetByName(lvl)
55 + }
56 + if opts.Debug {
57 + logger.Level.Set(slog.LevelDebug)
58 + }
59 +
60 + a := agent.New(agent.Config{
61 + Name: executable.Name,
62 + PluginConfigDir: pluginconfig.ConfigDir(),
63 + CollectorsConfigDir: pluginconfig.CollectorsDir(),
64 + ServiceDiscoveryConfigDir: nil,
65 + CollectorsConfigWatchPath: watchPaths,
66 + VarLibDir: pluginconfig.VarLibDir(),
67 + RunModule: opts.Module,
68 + RunJob: opts.Job,
69 + MinUpdateEvery: opts.UpdateEvery,
70 + DumpSummary: opts.DumpSummary,
71 + DisableServiceDiscovery: true,
72 + })
73 +
74 + a.Debugf("plugin: name=%s, %s", a.Name, buildinfo.Info())
75 + if u, err := user.Current(); err == nil {
76 + a.Debugf("current user: name=%s, uid=%s", u.Username, u.Uid)
77 + }
78 +
79 + proxyCfg := httpproxy.FromEnvironment()
80 + a.Infof("env proxy settings: HTTP_PROXY set=%t, HTTPS_PROXY set=%t", proxyCfg.HTTPProxy != "", proxyCfg.HTTPSProxy != "")
81 +
82 + a.Infof("directories → config: %s | collectors: %s | varlib: %s",
83 + a.ConfigDir, a.CollectorsConfDir, a.VarLibDir)
84 +
85 + a.Run()
86 +}
87 +
88 +func parseCLI() *cli.Option {
89 + opt, err := cli.Parse(os.Args)
90 + if err != nil {
91 + if cli.IsHelp(err) {
92 + os.Exit(0)
93 + }
94 + os.Exit(1)
95 + }
96 +
97 + return opt
98 +}
src/go/go.mod
+18 -5
@@ -51,11 +51,13 @@ require (
51 github.com/valyala/fastjson v1.6.7
52 github.com/vmware/govmomi v0.52.0
53 go.mongodb.org/mongo-driver v1.17.9
54 + go.opentelemetry.io/proto/otlp v1.9.0
55 go.uber.org/automaxprocs v1.6.0
56 golang.org/x/net v0.49.0
57 golang.org/x/sync v0.19.0
58 golang.org/x/text v0.33.0
59 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20220504211119-3d4a969bb56b
60 + google.golang.org/grpc v1.77.0
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
@@ -67,9 +69,13 @@ require (
69
70 require (
71 github.com/alexbrainman/odbc v0.0.0-20250601004241-49e6b2bc0cf0
72 + github.com/antchfx/xmlquery v1.5.0
73 + github.com/antchfx/xpath v1.3.5
74 + github.com/dop251/goja v0.0.0-20251121114222-56b1242a5f86
75 github.com/ibm-messaging/mq-golang/v5 v5.7.1
76 github.com/microsoft/go-mssqldb v1.9.6
77 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
78 + github.com/theory/jsonpath v0.10.2
79 gopkg.in/yaml.v3 v3.0.1
80 )
81
@@ -89,26 +95,31 @@ require (
95 github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect
96 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
97 github.com/distribution/reference v0.5.0 // indirect
98 + github.com/dlclark/regexp2 v1.11.4 // indirect
99 github.com/docker/go-connections v0.4.0 // indirect
100 github.com/docker/go-units v0.5.0 // indirect
101 github.com/emicklei/go-restful/v3 v3.12.2 // indirect
102 github.com/felixge/httpsnoop v1.0.4 // indirect
103 github.com/fxamacker/cbor/v2 v2.9.0 // indirect
104 github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
98 - github.com/go-logr/logr v1.4.2 // indirect
105 + github.com/go-logr/logr v1.4.3 // indirect
106 github.com/go-logr/stdr v1.2.2 // indirect
107 github.com/go-openapi/jsonpointer v0.21.0 // indirect
108 github.com/go-openapi/jsonreference v0.21.0 // indirect
109 github.com/go-openapi/swag v0.23.0 // indirect
110 + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
111 github.com/gogo/protobuf v1.3.2 // indirect
112 github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
113 github.com/golang-sql/sqlexp v0.1.0 // indirect
114 + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
115 github.com/golang/protobuf v1.5.4 // indirect
116 github.com/golang/snappy v0.0.4 // indirect
117 github.com/google/certificate-transparency-go v1.1.7 // indirect
118 github.com/google/gnostic-models v0.7.0 // indirect
119 github.com/google/go-cmp v0.7.0 // indirect
120 + github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
121 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
@@ -151,11 +162,11 @@ require (
162 github.com/xdg-go/scram v1.1.2 // indirect
163 github.com/xdg-go/stringprep v1.0.4 // indirect
164 github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
154 - go.opentelemetry.io/auto/sdk v1.1.0 // indirect
165 + go.opentelemetry.io/auto/sdk v1.2.1 // indirect
166 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
156 - go.opentelemetry.io/otel v1.34.0 // indirect
157 - go.opentelemetry.io/otel/metric v1.34.0 // indirect
158 - go.opentelemetry.io/otel/trace v1.34.0 // indirect
167 + go.opentelemetry.io/otel v1.38.0 // indirect
168 + go.opentelemetry.io/otel/metric v1.38.0 // indirect
169 + go.opentelemetry.io/otel/trace v1.38.0 // indirect
170 go.uber.org/multierr v1.11.0 // indirect
171 go.yaml.in/yaml/v2 v2.4.3 // indirect
172 go.yaml.in/yaml/v3 v3.0.4 // indirect
@@ -167,6 +178,8 @@ require (
178 golang.org/x/time v0.9.0 // indirect
179 golang.org/x/tools v0.40.0 // indirect
180 golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b // indirect
181 + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
182 + google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect
183 google.golang.org/protobuf v1.36.11 // indirect
184 gopkg.in/cenkalti/backoff.v2 v2.2.1 // indirect
185 gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
src/go/go.sum
+86 -30
@@ -3,8 +3,8 @@ cloud.google.com/go/auth v0.14.0/go.mod h1:CYsoRL1PdiDuqeQpZE0bP2pnPrGqFcOkI0nld
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=
6 -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
7 -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
6 +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
7 +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
8 dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
9 dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
10 filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
@@ -43,6 +43,10 @@ github.com/alexbrainman/odbc v0.0.0-20250601004241-49e6b2bc0cf0 h1:gUrYWktqvF8PV
43 github.com/alexbrainman/odbc v0.0.0-20250601004241-49e6b2bc0cf0/go.mod h1:c5eyz5amZqTKvY3ipqerFO/74a/8CYmXOahSr40c+Ww=
44 github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
45 github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
46 +github.com/antchfx/xmlquery v1.5.0 h1:uAi+mO40ZWfyU6mlUBxRVvL6uBNZ6LMU4M3+mQIBV4c=
47 +github.com/antchfx/xmlquery v1.5.0/go.mod h1:lJfWRXzYMK1ss32zm1GQV3gMIW/HFey3xDZmkP1SuNc=
48 +github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
49 +github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
50 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
51 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
52 github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
@@ -90,14 +94,16 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
94 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
95 github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
96 github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
93 -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
94 -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
97 +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
98 +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
99 github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
100 github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
101 github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
102 github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
103 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
104 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
105 +github.com/dop251/goja v0.0.0-20251121114222-56b1242a5f86 h1:iY/kk+Fw7k49PRM4cS2wz9CVxO0jB61+h//XN9bbAS4=
106 +github.com/dop251/goja v0.0.0-20251121114222-56b1242a5f86/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
107 github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
108 github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
109 github.com/facebook/time v0.0.0-20250211113239-e3e1421a0980 h1:jxAsxgSZ+7KqDS8MsSvoOHw7XJfkzmM+jwPPgthzOGc=
@@ -116,8 +122,8 @@ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:h
122 github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
123 github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
124 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
119 -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
120 -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
125 +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
126 +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
127 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
128 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
129 github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
@@ -129,6 +135,8 @@ github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF
135 github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
136 github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
137 github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
138 +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
139 +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
140 github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
141 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
142 github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
@@ -147,6 +155,8 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt
155 github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
156 github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
157 github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
158 +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
159 +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
160 github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
161 github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
162 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -159,6 +169,7 @@ github.com/google/certificate-transparency-go v1.1.7 h1:IASD+NtgSTJLPdzkthwvAG1Z
169 github.com/google/certificate-transparency-go v1.1.7/go.mod h1:FSSBo8fyMVgqptbfF6j5p/XNdgQftAhSmXcIxV9iphE=
170 github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
171 github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
172 +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
173 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
174 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
175 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -176,9 +187,8 @@ github.com/gorcon/rcon v1.4.0 h1:pYwZ8Rhcgfh/LhdPBncecuEo5thoFvPIuMSWovz1FME=
187 github.com/gorcon/rcon v1.4.0/go.mod h1:M6v6sNmr/NET9YIf+2rq+cIjTBridoy62uzQ58WgC1I=
188 github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
189 github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
179 -github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
180 -github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
181 -github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
190 +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
191 +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
192 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
193 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
194 github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
@@ -341,8 +351,8 @@ github.com/prometheus/sigv4 v0.1.1/go.mod h1:RAmWVKqx0bwi0Qm4lrKMXFM0nhpesBcenfC
351 github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
352 github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
353 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
344 -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
345 -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
354 +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
355 +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
356 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
357 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
358 github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
@@ -374,6 +384,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
384 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
385 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
386 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
387 +github.com/theory/jsonpath v0.10.2 h1:i8GeMxnD6ftNWeSeaGb/Eb8XghGjsas1eDizaQNupuE=
388 +github.com/theory/jsonpath v0.10.2/go.mod h1:ZOz+y6MxTEDcN/FOxf9AOgeHSoKHx2B+E0nD3HOtzGE=
389 github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
390 github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
391 github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -400,24 +412,26 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
412 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
413 go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
414 go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
403 -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
404 -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
415 +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
416 +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
417 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
418 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
407 -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
408 -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
419 +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
420 +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
421 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
422 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
423 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs=
424 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4=
413 -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
414 -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
415 -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
416 -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
417 -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
418 -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
419 -go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
420 -go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
425 +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
426 +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
427 +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
428 +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
429 +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
430 +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
431 +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
432 +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
433 +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
434 +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
435 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
436 go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
437 go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
@@ -435,12 +449,20 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
449 golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
450 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
451 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
452 +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
453 +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
454 +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
455 +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
456 golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
457 golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
458 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
459 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
460 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
461 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
462 +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
463 +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
464 +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
465 +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
466 golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
467 golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
468 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -451,6 +473,12 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
473 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
474 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
475 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
476 +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
477 +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
478 +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
479 +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
480 +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
481 +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
482 golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
483 golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
484 golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
@@ -461,6 +489,11 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
489 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
490 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
491 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
492 +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
493 +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
494 +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
495 +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
496 +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
497 golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
498 golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
499 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -477,17 +510,36 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
510 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
511 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
512 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
513 +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
514 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
515 +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
516 +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
517 +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
518 +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
519 +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
520 golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
521 golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
522 +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
523 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
524 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
525 +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
526 +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
527 +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
528 +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
529 +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
530 +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
531 golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
532 golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
533 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
534 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
535 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
536 golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
537 +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
538 +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
539 +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
540 +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
541 +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
542 +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
543 golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
544 golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
545 golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
@@ -498,6 +550,9 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
550 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
551 golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
552 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
553 +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
554 +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
555 +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
556 golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
557 golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
558 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -508,15 +563,16 @@ golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b h1:J1CaxgLerRR5lgx
563 golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b/go.mod h1:tqur9LnfstdR9ep2LaJT4lFUl0EjlHtge+gAjmsHUG4=
564 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20220504211119-3d4a969bb56b h1:9JncmKXcUwE918my+H6xmjBdhK2jM/UTUNXxhRG1BAk=
565 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20220504211119-3d4a969bb56b/go.mod h1:yp4gl6zOlnDGOZeWeDfMwQcsdOIQnMdhuPx9mwwWBL4=
566 +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
567 +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
568 google.golang.org/api v0.218.0 h1:x6JCjEWeZ9PFCRe9z0FBrNwj7pB7DOAqT35N+IPnAUA=
569 google.golang.org/api v0.218.0/go.mod h1:5VGHBAkxrA/8EFjLVEYmMUJ8/8+gWWQ3s4cFH0FxG2M=
513 -google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA=
514 -google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA=
515 -google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o=
516 -google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI=
517 -google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
518 -google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
519 -google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
570 +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
571 +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
572 +google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8=
573 +google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
574 +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
575 +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
576 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
577 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
578 gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
src/go/pkg/pluginconfig/pluginconfig_test.go
+11 -12
@@ -18,13 +18,12 @@ func mkdir(t *testing.T, p string) {
18 require.NoError(t, os.MkdirAll(p, 0o755))
19 }
20
21 -const (
22 - testPluginName = "test.plugin"
23 - testExecDir = "/opt/netdata/bin"
24 -)
21 +const testPluginName = "test.plugin"
22
23 func TestDirectoriesBuild(t *testing.T) {
24 tmp := t.TempDir()
25 + execDir := filepath.Join(tmp, "root", "opt", "netdata", "bin")
26 + mkdir(t, execDir)
27
28 // Probe locations (used ONLY when build() falls back to cygwinBase discovery).
29 // These dirs must exist for probe-based selection to succeed; otherwise,
@@ -98,14 +97,14 @@ func TestDirectoriesBuild(t *testing.T) {
97 "fallback_dirs_when_no_config": {
98 opts: &cli.Option{},
99 env: envData{},
101 - // build-relative fallback from testExecDir
100 + // build-relative fallback from execDir
101 want: directories{
103 - userConfigDirs: []string{filepath.Join(testExecDir, "..", "..", "..", "..", "etc", "netdata")},
104 - stockConfigDir: filepath.Join(testExecDir, "..", "..", "..", "..", "usr", "lib", "netdata", "conf.d"),
105 - collectorsUserDirs: []string{filepath.Join(testExecDir, "..", "..", "..", "..", "etc", "netdata", testPluginName)},
106 - collectorsStockDir: filepath.Join(testExecDir, "..", "..", "..", "..", "usr", "lib", "netdata", "conf.d", testPluginName),
107 - sdUserDirs: []string{filepath.Join(testExecDir, "..", "..", "..", "..", "etc", "netdata", testPluginName, "sd")},
108 - sdStockDir: filepath.Join(testExecDir, "..", "..", "..", "..", "usr", "lib", "netdata", "conf.d", testPluginName, "sd"),
102 + userConfigDirs: []string{filepath.Join(execDir, "..", "..", "..", "..", "etc", "netdata")},
103 + stockConfigDir: filepath.Join(execDir, "..", "..", "..", "..", "usr", "lib", "netdata", "conf.d"),
104 + collectorsUserDirs: []string{filepath.Join(execDir, "..", "..", "..", "..", "etc", "netdata", testPluginName)},
105 + collectorsStockDir: filepath.Join(execDir, "..", "..", "..", "..", "usr", "lib", "netdata", "conf.d", testPluginName),
106 + sdUserDirs: []string{filepath.Join(execDir, "..", "..", "..", "..", "etc", "netdata", testPluginName, "sd")},
107 + sdStockDir: filepath.Join(execDir, "..", "..", "..", "..", "usr", "lib", "netdata", "conf.d", testPluginName, "sd"),
108 collectorsWatch: []string{},
109 varLibDir: "",
110 },
@@ -244,7 +243,7 @@ func TestDirectoriesBuild(t *testing.T) {
243 t.Run(name, func(t *testing.T) {
244 t.Parallel() // safe: build() uses only inputs, no globals
245 var got directories
247 - err := got.build(tc.opts, tc.env, testPluginName, testExecDir)
246 + err := got.build(tc.opts, tc.env, testPluginName, execDir)
247
248 if tc.wantErr {
249 require.Error(t, err)
src/go/plugin/go.d/agent/jobmgr/dyncfg_collector.go
-1
@@ -196,7 +196,6 @@ func (m *Manager) dyncfgConfigTest(fn dyncfg.Function) {
196 m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' is not registered.", mn)
197 return
198 }
199 -
199 cfg, err := configFromPayload(fn)
200 if err != nil {
201 m.Warningf("dyncfg: %s: module %s: failed to create config from payload: %v", cmd, mn, err)
src/go/plugin/go.d/agent/module/job.go
+1 -1
@@ -576,7 +576,7 @@ func (j *Job) processMetrics(mx collectedMetrics, startTime time.Time, sinceLast
576 var i, updated, created int
577 for _, chart := range *j.charts {
578 if !chart.created || createChart {
579 - typeID := fmt.Sprintf("%s.%s", j.FullName(), chart.ID)
579 + typeID := fmt.Sprintf("%s.%s", getChartType(chart, j), getChartID(chart))
580 if len(typeID) >= NetdataChartIDMaxLength {
581 j.Warningf("chart 'type.id' length (%d) >= max allowed (%d), the chart is ignored (%s)",
582 len(typeID), NetdataChartIDMaxLength, typeID)
src/go/plugin/go.d/agent/vnodes/config_schema.json
+1 -1
@@ -16,7 +16,7 @@
16 },
17 "labels": {
18 "title": "Labels",
19 - "description": "Optional key-value pairs that help categorize and organize virtual nodes.",
19 + "description": "Optional key-value pairs that help categorize and organize virtual nodes. Labels prefixed with `_` have special meaning for the scripts.d plugin: `_address` maps to $HOSTADDRESS$, `_alias` maps to $HOSTALIAS$, and other `_`-prefixed keys (e.g. `_DATACENTER`) map to $_HOST*$ custom variables.",
20 "type": [
21 "object",
22 "null"
src/go/plugin/go.d/hack/go-fmt.sh deleted
-8
@@ -1,8 +0,0 @@
1 -#!/bin/sh
2 -
3 -# SPDX-License-Identifier: GPL-3.0-or-later
4 -
5 -for TARGET in "${@}"; do
6 - find "${TARGET}" -name '*.go' -exec gofmt -s -w {} \+
7 -done
8 -git diff --exit-code
src/go/plugin/go.d/pkg/ndexec/ndexec.go
+63 -5
@@ -54,20 +54,77 @@ func RunNDSudo(log *logger.Logger, timeout time.Duration, cmd string, args ...st
54 // RunUnprivilegedWithCmd runs binPath via nd-run and also returns the formatted command string.
55 func RunUnprivilegedWithCmd(log *logger.Logger, timeout time.Duration, binPath string, args ...string) ([]byte, string, error) {
56 argv := append([]string{binPath}, args...)
57 - return defaultRunner.run(log, timeout, defaultRunner.ndRunPath, "RunUnprivileged", argv...)
57 + out, cmd, _, err := defaultRunner.run(log, timeout, "", defaultRunner.ndRunPath, "RunUnprivileged", nil, argv...)
58 + return out, cmd, err
59 }
60
61 // RunNDSudoWithCmd runs cmd via ndsudo and also returns the formatted command string.
62 func RunNDSudoWithCmd(log *logger.Logger, timeout time.Duration, cmd string, args ...string) ([]byte, string, error) {
63 argv := append([]string{cmd}, args...)
63 - return defaultRunner.run(log, timeout, defaultRunner.ndSudoPath, "RunNDSudo", argv...)
64 + out, formatted, _, err := defaultRunner.run(log, timeout, "", defaultRunner.ndSudoPath, "RunNDSudo", nil, argv...)
65 + return out, formatted, err
66 }
67
66 -func (r *runner) run(log *logger.Logger, timeout time.Duration, helperPath, label string, argv ...string) ([]byte, string, error) {
68 +// RunUnprivilegedWithEnv runs binPath via nd-run with a custom environment.
69 +func RunUnprivilegedWithEnv(log *logger.Logger, timeout time.Duration, env []string, binPath string, args ...string) ([]byte, error) {
70 + out, _, err := RunUnprivilegedWithEnvCmd(log, timeout, env, binPath, args...)
71 + return out, err
72 +}
73 +
74 +// RunUnprivilegedWithEnvCmd runs binPath via nd-run with a custom environment and returns the formatted command string.
75 +func RunUnprivilegedWithEnvCmd(log *logger.Logger, timeout time.Duration, env []string, binPath string, args ...string) ([]byte, string, error) {
76 + argv := append([]string{binPath}, args...)
77 + out, cmd, _, err := defaultRunner.run(log, timeout, "", defaultRunner.ndRunPath, "RunUnprivileged", env, argv...)
78 + return out, cmd, err
79 +}
80 +
81 +// RunOptions configure nd-run/ndsudo execution helpers.
82 +type RunOptions struct {
83 + Env []string
84 + Dir string
85 +}
86 +
87 +// RunUnprivilegedWithOptions runs binPath via nd-run honoring the provided options.
88 +func RunUnprivilegedWithOptions(log *logger.Logger, timeout time.Duration, opts RunOptions, binPath string, args ...string) ([]byte, error) {
89 + out, _, err := RunUnprivilegedWithOptionsCmd(log, timeout, opts, binPath, args...)
90 + return out, err
91 +}
92 +
93 +// RunUnprivilegedWithOptionsCmd is RunUnprivilegedWithOptions plus the formatted command string.
94 +func RunUnprivilegedWithOptionsCmd(log *logger.Logger, timeout time.Duration, opts RunOptions, binPath string, args ...string) ([]byte, string, error) {
95 + argv := append([]string{binPath}, args...)
96 + out, cmd, _, err := defaultRunner.run(log, timeout, opts.Dir, defaultRunner.ndRunPath, "RunUnprivileged", opts.Env, argv...)
97 + return out, cmd, err
98 +}
99 +
100 +// RunUnprivilegedWithOptionsUsage runs binPath via nd-run and returns stdout, formatted command, resource usage and error.
101 +func RunUnprivilegedWithOptionsUsage(log *logger.Logger, timeout time.Duration, opts RunOptions, binPath string, args ...string) ([]byte, string, ResourceUsage, error) {
102 + argv := append([]string{binPath}, args...)
103 + return defaultRunner.run(log, timeout, opts.Dir, defaultRunner.ndRunPath, "RunUnprivileged", opts.Env, argv...)
104 +}
105 +
106 +// SetRunnerPathsForTests overrides the nd-run and ndsudo helper paths.
107 +// It is intended for test environments that need to stub the helpers.
108 +func SetRunnerPathsForTests(ndRunPath, ndSudoPath string) {
109 + if ndRunPath != "" {
110 + defaultRunner.ndRunPath = ndRunPath
111 + }
112 + if ndSudoPath != "" {
113 + defaultRunner.ndSudoPath = ndSudoPath
114 + }
115 +}
116 +
117 +func (r *runner) run(log *logger.Logger, timeout time.Duration, dir string, helperPath, label string, env []string, argv ...string) ([]byte, string, ResourceUsage, error) {
118 ctx, cancel := context.WithTimeout(context.Background(), timeout)
119 defer cancel()
120
121 ex := exec.CommandContext(ctx, helperPath, argv...) // argv comes from trusted sources; no shell, args passed separately
122 + if dir != "" {
123 + ex.Dir = dir
124 + }
125 + if len(env) > 0 {
126 + ex.Env = env
127 + }
128
129 log.Debugf("executing: %v", ex)
130
@@ -77,6 +134,7 @@ func (r *runner) run(log *logger.Logger, timeout time.Duration, helperPath, labe
134 cmdStr := ex.String()
135
136 out, err := ex.Output()
137 + usage := extractUsage(ex.ProcessState)
138 if err != nil {
139 s := stderr.String()
140 if len(s) > stderrLimit {
@@ -87,8 +145,8 @@ func (r *runner) run(log *logger.Logger, timeout time.Duration, helperPath, labe
145 err = ctx.Err()
146 }
147
90 - return out, cmdStr, fmt.Errorf("%s: %v: %w (stderr: %s)", label, ex, err, strings.TrimSpace(s))
148 + return out, cmdStr, usage, fmt.Errorf("%s: %v: %w (stderr: %s)", label, ex, err, strings.TrimSpace(s))
149 }
150
93 - return out, cmdStr, nil
151 + return out, cmdStr, usage, nil
152 }
src/go/plugin/go.d/pkg/ndexec/ndexec_test.go
+51 -1
@@ -99,7 +99,7 @@ exec "$@"
99
100 for name, tt := range tests {
101 t.Run(name, func(t *testing.T) {
102 - out, _, err := r.run(nil, tt.timeout, tt.helperPath, "RunTest", tt.argv...)
102 + out, _, _, err := r.run(nil, tt.timeout, "", tt.helperPath, "RunTest", nil, tt.argv...)
103
104 if tt.wantErr {
105 require.Error(t, err)
@@ -119,3 +119,53 @@ exec "$@"
119 })
120 }
121 }
122 +
123 +func TestRunUnprivilegedWithOptionsCmdWorkingDir(t *testing.T) {
124 + if runtime.GOOS == "windows" {
125 + t.Skip("uses sh scripts")
126 + }
127 +
128 + tmp := t.TempDir()
129 + workdir := filepath.Join(tmp, "subdir")
130 + require.NoError(t, os.Mkdir(workdir, 0o755))
131 + script := filepath.Join(tmp, "pwd.sh")
132 + require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\npwd\n"), 0o755))
133 +
134 + helper := filepath.Join(tmp, "helper.sh")
135 + require.NoError(t, os.WriteFile(helper, []byte("#!/bin/sh\nexec \"$@\"\n"), 0o755))
136 +
137 + orig := defaultRunner.ndRunPath
138 + defaultRunner.ndRunPath = helper
139 + defer func() { defaultRunner.ndRunPath = orig }()
140 +
141 + opts := RunOptions{Dir: workdir}
142 + out, cmd, err := RunUnprivilegedWithOptionsCmd(nil, time.Second, opts, script)
143 + require.NoError(t, err)
144 + assert.Contains(t, cmd, script)
145 + assert.Equal(t, workdir+"\n", string(out))
146 +}
147 +
148 +func TestRunUnprivilegedWithOptionsUsage(t *testing.T) {
149 + if runtime.GOOS == "windows" {
150 + t.Skip("uses sh scripts")
151 + }
152 +
153 + tmp := t.TempDir()
154 + script := filepath.Join(tmp, "noop.sh")
155 + require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\nprintf foo"), 0o755))
156 +
157 + helper := filepath.Join(tmp, "helper.sh")
158 + require.NoError(t, os.WriteFile(helper, []byte("#!/bin/sh\nexec \"$@\"\n"), 0o755))
159 +
160 + orig := defaultRunner.ndRunPath
161 + defaultRunner.ndRunPath = helper
162 + defer func() { defaultRunner.ndRunPath = orig }()
163 +
164 + opts := RunOptions{}
165 + out, cmd, usage, err := RunUnprivilegedWithOptionsUsage(nil, time.Second, opts, script)
166 + require.NoError(t, err)
167 + assert.Contains(t, cmd, script)
168 + assert.Equal(t, "foo", string(out))
169 + assert.True(t, usage.User >= 0)
170 + assert.True(t, usage.System >= 0)
171 +}
src/go/plugin/go.d/pkg/ndexec/resource_usage.go new
+63
@@ -0,0 +1,63 @@
1 +//go:build !windows
2 +// +build !windows
3 +
4 +// SPDX-License-Identifier: GPL-3.0-or-later
5 +
6 +package ndexec
7 +
8 +import (
9 + "os"
10 + "runtime"
11 + "syscall"
12 + "time"
13 +)
14 +
15 +// ResourceUsage captures OS-reported resource counters for a completed process.
16 +type ResourceUsage struct {
17 + User time.Duration
18 + System time.Duration
19 + MaxRSSBytes int64
20 + ReadBytes int64
21 + WriteBytes int64
22 +}
23 +
24 +func (u ResourceUsage) totalCPU() time.Duration {
25 + return u.User + u.System
26 +}
27 +
28 +func extractUsage(ps *os.ProcessState) ResourceUsage {
29 + if ps == nil {
30 + return ResourceUsage{}
31 + }
32 + usage := ResourceUsage{}
33 + if sys := ps.SysUsage(); sys != nil {
34 + if ru, ok := sys.(*syscall.Rusage); ok && ru != nil {
35 + usage.User = time.Duration(ru.Utime.Sec)*time.Second + time.Duration(ru.Utime.Usec)*time.Microsecond
36 + usage.System = time.Duration(ru.Stime.Sec)*time.Second + time.Duration(ru.Stime.Usec)*time.Microsecond
37 + usage.MaxRSSBytes = convertMaxRSS(int64(ru.Maxrss))
38 + usage.ReadBytes = blocksToBytes(int64(ru.Inblock))
39 + usage.WriteBytes = blocksToBytes(int64(ru.Oublock))
40 + }
41 + }
42 + return usage
43 +}
44 +
45 +// convertMaxRSS normalizes platform-specific MaxRSS units: Linux and BSD
46 +// derivatives report KiB while Darwin already uses bytes.
47 +func convertMaxRSS(raw int64) int64 {
48 + bytes := raw
49 + switch runtime.GOOS {
50 + case "linux", "android", "freebsd", "openbsd", "netbsd", "dragonfly":
51 + bytes *= 1024
52 + }
53 + return bytes
54 +}
55 +
56 +const blockSize = 512
57 +
58 +func blocksToBytes(blocks int64) int64 {
59 + if blocks <= 0 {
60 + return 0
61 + }
62 + return blocks * blockSize
63 +}
src/go/plugin/go.d/pkg/ndexec/resource_usage_windows.go new
+42
@@ -0,0 +1,42 @@
1 +//go:build windows
2 +// +build windows
3 +
4 +// SPDX-License-Identifier: GPL-3.0-or-later
5 +
6 +package ndexec
7 +
8 +import (
9 + "os"
10 + "time"
11 +)
12 +
13 +// ResourceUsage captures OS-reported resource counters for a completed process.
14 +type ResourceUsage struct {
15 + User time.Duration
16 + System time.Duration
17 + MaxRSSBytes int64
18 + ReadBytes int64
19 + WriteBytes int64
20 +}
21 +
22 +func (u ResourceUsage) totalCPU() time.Duration {
23 + return u.User + u.System
24 +}
25 +
26 +func extractUsage(ps *os.ProcessState) ResourceUsage {
27 + // Windows doesn't expose POSIX rusage fields; return zeroed usage.
28 + return ResourceUsage{}
29 +}
30 +
31 +func convertMaxRSS(raw int64) int64 {
32 + return raw
33 +}
34 +
35 +const blockSize = 512
36 +
37 +func blocksToBytes(blocks int64) int64 {
38 + if blocks <= 0 {
39 + return 0
40 + }
41 + return blocks * blockSize
42 +}
src/go/plugin/scripts.d/README.md new
+207
@@ -0,0 +1,207 @@
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.
7 +
8 +> **Status:** preview. The core execution pipeline, charts, and logging are in
9 +> place, but configuration options and documentation may still change before GA.
10 +
11 +## Configuration overview
12 +
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.
19 +
20 +Example:
21 +
22 +```yaml
23 +jobs:
24 + - name: ping_localhost
25 + scheduler: default
26 + plugin: "/usr/lib/nagios/plugins/check_ping"
27 + args: ["-H", "127.0.0.1", "-w", "100.0,20%", "-c", "200.0,40%"]
28 + timeout: 60s
29 + retry_interval: 1m
30 + max_check_attempts: 3
31 +```
32 +
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
39 +
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.
44 +
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:
50 +
51 +```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 +
61 +jobs:
62 + - name: local_plugins
63 + check_period: 24x7
64 + logging:
65 + enabled: true
66 + otlp:
67 + endpoint: 127.0.0.1:4317
68 + tls: false
69 + timeout: 5s
70 +```
71 +
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
147 +60 s). This field applies to both Nagios and Zabbix jobs so you can mirror your
148 +existing polling schedules.
149 +
150 +### Logging over OTLP (TLS support)
151 +
152 +Structured logs are forwarded to OTEL via `logging.otlp`. Besides `endpoint`,
153 +`timeout`, `tls`, and `headers`, you can now set:
154 +
155 +- `tls_ca`: custom CA bundle for the collector
156 +- `tls_cert` / `tls_key`: client certificate pair for mutual TLS
157 +- `tls_server_name`: override the TLS SNI when the collector name differs from the
158 + endpoint host
159 +- `tls_skip_verify`: disable server certificate verification (not
160 + recommended outside of lab environments)
161 +
162 +When `tls` is `true`, the plugin always establishes a TLS 1.2 connection using
163 +these settings. Set `tls: false` only for loopback collectors or other trusted
164 +plaintext networks.
165 +
166 +### LLD lifecycle
167 +
168 +Zabbix-style jobs keep their LLD catalogs and missing-instance counters in
169 +memory. Restarting the agent or reloading the plugin resets that state, mirroring
170 +how go.d collectors reset transient discovery data. If you need longer retention,
171 +keep the plugin running continuously; no on-disk cache is maintained.
172 +
173 +### Zabbix preprocessing
174 +
175 +Dependent pipelines share the same `.steps` schema as the standalone
176 +`zabbix-preproc` library. Step `type` accepts human-readable tokens such as
177 +`jsonpath`, `csv_to_json`, or `snmp_walk_value`. The optional `error_handler`
178 +field understands the native Zabbix actions: `default`, `discard`, `set-value`,
179 +and `set-error`. When you choose `set-value` or `set-error` provide the fallback
180 +text via `error_handler_params`.
181 +
182 +### Mock integration tests
183 +
184 +A small suite of mock Nagios plugins lives under `tests/plugins/`. They exercise
185 +state handling, perfdata parsing, macro substitution, long output logging, and
186 +skip semantics without requiring external services. Run them with:
187 +
188 +```bash
189 +cd src/go
190 +go test ./plugin/scripts.d/tests
191 +```
192 +
193 +The tests stub `nd-run`, execute the mock scripts through the scheduler, and
194 +assert the emitted metrics/logs. Extend this suite whenever you add new
195 +collector features so we keep a fast end-to-end signal in CI.
196 +
197 +## Building
198 +
199 +Enable the plugin during CMake configuration:
200 +
201 +```bash
202 +cmake -DENABLE_PLUGIN_SCRIPTS=On ..
203 +cmake --build . --target scripts-plugin
204 +```
205 +
206 +The resulting binary lives at `usr/libexec/netdata/plugins.d/scripts.d.plugin`.
207 +Stock configuration ships in `usr/lib/netdata/conf.d/scripts.d/`.
src/go/plugin/scripts.d/charts/charts.go new
+39
@@ -0,0 +1,39 @@
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/go.d/agent/module"
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) module.Chart {
24 + return module.Chart{
25 + Fam: "jobs",
26 + Ctx: fmt.Sprintf("%s.jobs.%s", ctxPrefix, metric),
27 + Type: module.Line,
28 + Labels: meta.Labels(),
29 + }
30 +}
31 +
32 +func perfdataChartBase(meta JobIdentity) module.Chart {
33 + return module.Chart{
34 + Fam: meta.ScriptKey,
35 + Ctx: fmt.Sprintf("%s.%s", ctxPrefix, meta.ScriptKey),
36 + Type: module.Line,
37 + Labels: meta.Labels(),
38 + }
39 +}
src/go/plugin/scripts.d/charts/identity.go new
+195
@@ -0,0 +1,195 @@
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/go.d/agent/module"
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() []module.Label {
55 + return []module.Label{
56 + {Key: "nagios_job", Value: id.JobName, Source: module.LabelSourceConf},
57 + {Key: "nagios_scheduler", Value: id.Scheduler, Source: module.LabelSourceConf},
58 + {Key: "nagios_plugin", Value: id.PluginBase, Source: module.LabelSourceConf},
59 + {Key: "nagios_cmdline", Value: id.Cmdline, Source: module.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 new
+24
@@ -0,0 +1,24 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package charts
4 +
5 +import "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
6 +
7 +func BuildJobCharts(meta JobIdentity, basePriority int) []*module.Chart {
8 + return []*module.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) []*module.Chart {
19 + return []*module.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 new
+200
@@ -0,0 +1,200 @@
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/go.d/agent/module"
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) *module.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 = module.Dims{
32 + {ID: meta.TelemetryMetricID(TelemetryStateMetric, "ok"), Name: "OK", Algo: module.Absolute, Div: 1},
33 + {ID: meta.TelemetryMetricID(TelemetryStateMetric, "warning"), Name: "WARNING", Algo: module.Absolute, Div: 1},
34 + {ID: meta.TelemetryMetricID(TelemetryStateMetric, "critical"), Name: "CRITICAL", Algo: module.Absolute, Div: 1},
35 + {ID: meta.TelemetryMetricID(TelemetryStateMetric, "unknown"), Name: "UNKNOWN", Algo: module.Absolute, Div: 1},
36 + {ID: meta.TelemetryMetricID(TelemetryStateMetric, "attempt"), Name: "attempt", Algo: module.Absolute, DimOpts: module.DimOpts{Hidden: true}},
37 + {ID: meta.TelemetryMetricID(TelemetryStateMetric, "max_attempts"), Name: "max_attempts", Algo: module.Absolute, DimOpts: module.DimOpts{Hidden: true}},
38 + }
39 + chart.Opts.Detail = true
40 + return &chart
41 +}
42 +
43 +func RuntimeChart(meta JobIdentity, priority int) *module.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 = module.Dims{
50 + {ID: meta.TelemetryMetricID(TelemetryRuntimeMetric, "running"), Name: "running", Algo: module.Absolute},
51 + {ID: meta.TelemetryMetricID(TelemetryRuntimeMetric, "retrying"), Name: "retrying", Algo: module.Absolute},
52 + {ID: meta.TelemetryMetricID(TelemetryRuntimeMetric, "skipped"), Name: "skipped", Algo: module.Absolute},
53 + {ID: meta.TelemetryMetricID(TelemetryRuntimeMetric, "cpu_missing"), Name: "cpu_missing", Algo: module.Absolute, DimOpts: module.DimOpts{Hidden: true}},
54 + }
55 + chart.Opts.Detail = true
56 + return &chart
57 +}
58 +
59 +func LatencyChart(meta JobIdentity, priority int) *module.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 = module.Dims{{ID: meta.TelemetryMetricID(TelemetryLatencyMetric, "duration"), Name: "duration", Algo: module.Absolute, Div: 1_000_000_000}}
66 + return &chart
67 +}
68 +
69 +func CPUChart(meta JobIdentity, priority int) *module.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 = module.Dims{{ID: meta.TelemetryMetricID(TelemetryCPUMetric, "cpu_time"), Name: "cpu", Algo: module.Absolute, Div: 1_000_000_000}}
76 + return &chart
77 +}
78 +
79 +func MemoryChart(meta JobIdentity, priority int) *module.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 = module.Dims{{ID: meta.TelemetryMetricID(TelemetryMemoryMetric, "rss"), Name: "rss", Algo: module.Absolute}}
86 + return &chart
87 +}
88 +
89 +func DiskChart(meta JobIdentity, priority int) *module.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 = module.Dims{
96 + {ID: meta.TelemetryMetricID(TelemetryDiskMetric, "read"), Name: "read", Algo: module.Absolute},
97 + {ID: meta.TelemetryMetricID(TelemetryDiskMetric, "write"), Name: "write", Algo: module.Absolute},
98 + }
99 + return &chart
100 +}
101 +
102 +func SchedulerJobsChart(scheduler string, priority int) *module.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 = module.Dims{
110 + {ID: SchedulerMetricKey(scheduler, ChartSchedulerJobs, "running"), Name: "running", Algo: module.Absolute},
111 + {ID: SchedulerMetricKey(scheduler, ChartSchedulerJobs, "queued"), Name: "queued", Algo: module.Absolute},
112 + {ID: SchedulerMetricKey(scheduler, ChartSchedulerJobs, "scheduled"), Name: "scheduled", Algo: module.Absolute},
113 + }
114 + return &chart
115 +}
116 +
117 +func SchedulerRateChart(scheduler string, priority int) *module.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 = module.Dims{
125 + {ID: SchedulerMetricKey(scheduler, ChartSchedulerRate, "started"), Name: "started", Algo: module.Incremental},
126 + {ID: SchedulerMetricKey(scheduler, ChartSchedulerRate, "finished"), Name: "finished", Algo: module.Incremental},
127 + {ID: SchedulerMetricKey(scheduler, ChartSchedulerRate, "skipped"), Name: "skipped", Algo: module.Incremental},
128 + }
129 + chart.Opts.Detail = true
130 + return &chart
131 +}
132 +
133 +func SchedulerNextRunChart(scheduler string, priority int) *module.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 = module.Dims{{ID: SchedulerMetricKey(scheduler, ChartSchedulerNext, "next"), Name: "next", Algo: module.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) *module.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 = module.Dims{
161 + {ID: meta.PerfdataMetricID(labelID, "value"), Name: "value", Algo: module.Absolute, Div: div},
162 + {ID: meta.PerfdataMetricID(labelID, "min"), Name: "min", Algo: module.Absolute, Div: div, DimOpts: module.DimOpts{Hidden: true}},
163 + {ID: meta.PerfdataMetricID(labelID, "max"), Name: "max", Algo: module.Absolute, Div: div, DimOpts: module.DimOpts{Hidden: true}},
164 + {ID: meta.PerfdataMetricID(labelID, "warn_low"), Name: "warn_low", Algo: module.Absolute, Div: div, DimOpts: module.DimOpts{Hidden: true}},
165 + {ID: meta.PerfdataMetricID(labelID, "warn_high"), Name: "warn_high", Algo: module.Absolute, Div: div, DimOpts: module.DimOpts{Hidden: true}},
166 + {ID: meta.PerfdataMetricID(labelID, "warn_low_defined"), Name: "warn_low_defined", Algo: module.Absolute, DimOpts: module.DimOpts{Hidden: true}},
167 + {ID: meta.PerfdataMetricID(labelID, "warn_high_defined"), Name: "warn_high_defined", Algo: module.Absolute, DimOpts: module.DimOpts{Hidden: true}},
168 + {ID: meta.PerfdataMetricID(labelID, "warn_defined"), Name: "warn_defined", Algo: module.Absolute, DimOpts: module.DimOpts{Hidden: true}},
169 + {ID: meta.PerfdataMetricID(labelID, "warn_inclusive"), Name: "warn_inclusive", Algo: module.Absolute, DimOpts: module.DimOpts{Hidden: true}},
170 + {ID: meta.PerfdataMetricID(labelID, "crit_low"), Name: "crit_low", Algo: module.Absolute, Div: div, DimOpts: module.DimOpts{Hidden: true}},
171 + {ID: meta.PerfdataMetricID(labelID, "crit_high"), Name: "crit_high", Algo: module.Absolute, Div: div, DimOpts: module.DimOpts{Hidden: true}},
172 + {ID: meta.PerfdataMetricID(labelID, "crit_low_defined"), Name: "crit_low_defined", Algo: module.Absolute, DimOpts: module.DimOpts{Hidden: true}},
173 + {ID: meta.PerfdataMetricID(labelID, "crit_high_defined"), Name: "crit_high_defined", Algo: module.Absolute, DimOpts: module.DimOpts{Hidden: true}},
174 + {ID: meta.PerfdataMetricID(labelID, "crit_defined"), Name: "crit_defined", Algo: module.Absolute, DimOpts: module.DimOpts{Hidden: true}},
175 + {ID: meta.PerfdataMetricID(labelID, "crit_inclusive"), Name: "crit_inclusive", Algo: module.Absolute, DimOpts: module.DimOpts{Hidden: true}},
176 + }
177 + chart.Labels = append(chart.Labels,
178 + module.Label{Key: "perf_label", Value: label, Source: module.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) module.Chart {
192 + return module.Chart{
193 + Fam: "scheduler",
194 + Ctx: fmt.Sprintf("%s.scheduler", ctxPrefix),
195 + Type: module.Line,
196 + Labels: []module.Label{
197 + {Key: "nagios_scheduler", Value: scheduler, Source: module.LabelSourceConf},
198 + },
199 + }
200 +}
src/go/plugin/scripts.d/config/scripts.d.conf new
+11
@@ -0,0 +1,11 @@
1 +# scripts.d.plugin configuration (go.d-style)
2 +# Jobs live under /etc/netdata/scripts.d/*.conf. This file only controls
3 +# plugin-wide defaults and module toggles.
4 +
5 +enabled: yes
6 +default_run: yes
7 +
8 +modules:
9 + nagios: yes
10 + zabbix: yes
11 + scheduler: yes
src/go/plugin/scripts.d/config/scripts.d/scheduler.conf new
+11
@@ -0,0 +1,11 @@
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/config_schema.json new
+224
@@ -0,0 +1,224 @@
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/metadata.yaml new
+348
@@ -0,0 +1,348 @@
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.svg
11 + categories:
12 + - data-collection.synthetic-checks
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 + most_popular: false
25 + overview:
26 + data_collection:
27 + metrics_description: |
28 + This module runs unmodified [Nagios plugins](https://www.nagios-plugins.org/) inside Netdata without any changes to the plugins themselves.
29 +
30 + For each configured job it collects:
31 +
32 + - **Check state**: OK / WARNING / CRITICAL / UNKNOWN (with soft/hard state tracking).
33 + - **Performance data**: Every `label=value;warn;crit;min;max` metric emitted by the plugin is parsed and charted automatically, with unit normalization where possible.
34 + - **Execution telemetry**: Runtime duration, scheduling latency, CPU time, peak RSS memory, and disk I/O per job.
35 + - **Scheduler health**: Running / queued / scheduled job counts and throughput rates.
36 + method_description: |
37 + Jobs are executed via `nd-run` (the Netdata unprivileged helper) at the configured `check_interval`.
38 + Standard Nagios macros (`$HOSTADDRESS$`, `$ARG1$`, `$USERn$`, etc.) are expanded before execution.
39 + Plugin output is parsed according to the [Nagios Plugin API](https://nagios-plugins.org/doc/guidelines.html):
40 + the first line provides the status and optional performance data after the `|` separator.
41 + default_behavior:
42 + auto_detection:
43 + description: |
44 + No auto-detection. Each job must be explicitly configured with a `plugin` path pointing to the Nagios plugin executable.
45 + limits:
46 + description: ""
47 + performance_impact:
48 + description: |
49 + Each job spawns a subprocess via `nd-run`. Resource usage (CPU, memory, disk I/O) is tracked per execution and exposed as telemetry charts.
50 + additional_permissions:
51 + description: |
52 + Plugins run as the `netdata` user via `nd-run`. If a plugin requires elevated privileges, configure it through `ndsudo` or adjust filesystem permissions accordingly.
53 + multi_instance: true
54 + supported_platforms:
55 + include: []
56 + exclude: []
57 + setup:
58 + prerequisites:
59 + list:
60 + - title: Install Nagios plugins
61 + description: |
62 + Install the plugins you want to run. Most distributions provide packages:
63 +
64 + ```bash
65 + # Debian/Ubuntu
66 + apt install nagios-plugins
67 +
68 + # RHEL/CentOS/Fedora
69 + dnf install nagios-plugins-all
70 + ```
71 +
72 + You can also use any script or binary that follows the [Nagios Plugin API](https://nagios-plugins.org/doc/guidelines.html).
73 + configuration:
74 + file:
75 + name: scripts.d/nagios.conf
76 + options:
77 + description: |
78 + Each job defines a single Nagios plugin execution. Jobs are listed under the `jobs` key.
79 + folding:
80 + title: Config options
81 + enabled: true
82 + list:
83 + - name: plugin
84 + description: Absolute path to the Nagios plugin executable.
85 + default_value: ""
86 + required: true
87 + group: General
88 + - name: args
89 + description: Command-line arguments passed to the plugin. Nagios macros are expanded before execution.
90 + default_value: "[]"
91 + required: false
92 + group: Arguments
93 + - name: arg_values
94 + description: Values bound to positional `$ARGn$` macros (max 32).
95 + default_value: "[]"
96 + required: false
97 + group: Arguments
98 + - name: vnode
99 + description: Virtual node name to associate with this job. The vnode must be defined in the vnodes configuration directory.
100 + default_value: ""
101 + required: false
102 + group: General
103 + - name: scheduler
104 + description: Name of the scheduler that executes this job.
105 + default_value: default
106 + required: false
107 + group: General
108 + - name: timeout
109 + description: Maximum execution time before the plugin is killed. Duration string (e.g. `30s`, `1m`).
110 + default_value: 30s
111 + required: false
112 + group: Timing
113 + - name: timeout_state
114 + description: State reported when a timeout occurs.
115 + default_value: critical
116 + required: false
117 + group: Timing
118 + - name: check_interval
119 + description: Base scheduling interval between executions. Duration string.
120 + default_value: 1m
121 + required: false
122 + group: Timing
123 + - name: retry_interval
124 + description: Interval between soft retries after a non-OK result. Duration string.
125 + default_value: 30s
126 + required: false
127 + group: Timing
128 + - name: max_check_attempts
129 + description: Number of soft-state attempts before transitioning to a hard state.
130 + default_value: 3
131 + required: false
132 + group: Timing
133 + - name: user_macros
134 + description: Key/value pairs exposed as `$USERn$` macros. For example, `USER1` becomes `$USER1$`.
135 + default_value: "{}"
136 + required: false
137 + group: Environment
138 + - name: custom_vars
139 + description: Key/value pairs exported as `$_SERVICEvar$` environment variables (`NAGIOS__SERVICEvar`).
140 + default_value: "{}"
141 + required: false
142 + group: Environment
143 + - name: environment
144 + description: Additional environment variables set before executing the plugin.
145 + default_value: "{}"
146 + required: false
147 + group: Environment
148 + - name: working_directory
149 + description: Working directory for plugin execution.
150 + default_value: ""
151 + required: false
152 + group: General
153 + examples:
154 + folding:
155 + title: Config
156 + enabled: true
157 + list:
158 + - name: SSL certificate check
159 + description: Check SSL certificate expiry for a host.
160 + config: |
161 + jobs:
162 + - name: ssl_github
163 + plugin: /usr/lib/nagios/plugins/check_http
164 + args: ["-H", "github.com", "--ssl", "-C", "30,15"]
165 + timeout: 30s
166 + check_interval: 1h
167 + retry_interval: 5m
168 + max_check_attempts: 3
169 + - name: Check with macros and vnode
170 + description: |
171 + Run a plugin against a virtual node using Nagios macros.
172 + The `$HOSTADDRESS$` and `$ARG1$` macros are expanded from the vnode labels and `arg_values`.
173 + config: |
174 + jobs:
175 + - name: check_ssh
176 + plugin: /usr/lib/nagios/plugins/check_ssh
177 + args: ["-H", "$HOSTADDRESS$", "-p", "$ARG1$"]
178 + arg_values: ["22"]
179 + vnode: my-server
180 + check_interval: 5m
181 + - name: Check with custom vars
182 + description: |
183 + Pass service-level custom variables to a plugin as `$_SERVICEvar$` macros.
184 + config: |
185 + jobs:
186 + - name: check_api
187 + plugin: /usr/local/bin/check_api
188 + args: ["-u", "$_SERVICEENDPOINT$"]
189 + custom_vars:
190 + ENDPOINT: "/health"
191 + troubleshooting:
192 + problems:
193 + list:
194 + - description: |
195 + Plugin exits with "permission denied".
196 + solutions:
197 + - description: |
198 + Ensure the plugin file has execute permission for the `netdata` user:
199 + ```bash
200 + chmod +x /usr/lib/nagios/plugins/check_http
201 + ```
202 + - description: |
203 + Macros like `$HOSTADDRESS$` are not expanded.
204 + solutions:
205 + - description: |
206 + The job must reference a `vnode` that is configured in the vnodes directory (`/etc/netdata/vnodes/`).
207 + The vnode must have the corresponding label set. See the **Virtual Node Label Conventions** section below.
208 + alerts: []
209 + metrics:
210 + folding:
211 + title: Metrics
212 + enabled: false
213 + description: |
214 + ### Virtual Node Label Conventions
215 +
216 + When a job references a `vnode`, the module reads Nagios macros from the virtual node's **labels** using prefix conventions:
217 +
218 + | Label key | Nagios macro | Environment variable | Description |
219 + |-----------|-------------|---------------------|-------------|
220 + | `_address` | `$HOSTADDRESS$` | `NAGIOS_HOSTADDRESS` | IP address or DNS name of the host |
221 + | `_alias` | `$HOSTALIAS$` | `NAGIOS_HOSTALIAS` | Human-readable host alias |
222 + | `_VARNAME` | `$_HOSTVARNAME$` | `NAGIOS__HOSTVARNAME` | Custom host variable (any `_` prefixed key except `_address` and `_alias`) |
223 + | `key` | `$_HOSTLABEL_KEY$` | `NAGIOS__HOSTLABEL_KEY` | Regular label (no `_` prefix) |
224 +
225 + Example vnode configuration (`/etc/netdata/vnodes/hosts.yaml`):
226 +
227 + ```yaml
228 + - hostname: web-server-1
229 + guid: 12345678-1234-1234-1234-123456789abc
230 + labels:
231 + _address: "192.168.1.10"
232 + _alias: "Web Server 1"
233 + _DATACENTER: "us-east-1"
234 + role: "frontend"
235 + environment: "production"
236 + ```
237 +
238 + This produces:
239 +
240 + | Macro | Value |
241 + |-------|-------|
242 + | `$HOSTADDRESS$` | `192.168.1.10` |
243 + | `$HOSTALIAS$` | `Web Server 1` |
244 + | `$_HOSTDATACENTER$` | `us-east-1` |
245 + | `$_HOSTLABEL_ROLE$` | `frontend` |
246 + | `$_HOSTLABEL_ENVIRONMENT$` | `production` |
247 + availability: []
248 + scopes:
249 + - name: job
250 + description: Metrics for each configured Nagios plugin job.
251 + labels:
252 + - name: nagios_job
253 + description: Job name as defined in the configuration.
254 + - name: nagios_plugin
255 + description: Basename of the plugin executable.
256 + - name: nagios_vnode
257 + description: Virtual node associated with the job (if any).
258 + - name: nagios_scheduler
259 + description: Scheduler executing the job.
260 + metrics:
261 + - name: nagios.jobs.state
262 + description: Nagios plugin check state
263 + unit: state
264 + chart_type: line
265 + dimensions:
266 + - name: ok
267 + - name: warning
268 + - name: critical
269 + - name: unknown
270 + - name: nagios.jobs.runtime
271 + description: Nagios plugin runtime state
272 + unit: boolean
273 + chart_type: line
274 + dimensions:
275 + - name: running
276 + - name: retrying
277 + - name: skipped
278 + - name: nagios.jobs.latency
279 + description: Nagios plugin execution latency
280 + unit: seconds
281 + chart_type: line
282 + dimensions:
283 + - name: duration
284 + - name: nagios.jobs.cpu
285 + description: Nagios plugin CPU time
286 + unit: seconds
287 + chart_type: line
288 + dimensions:
289 + - name: cpu
290 + - name: nagios.jobs.mem
291 + description: Nagios plugin peak memory (RSS)
292 + unit: bytes
293 + chart_type: line
294 + dimensions:
295 + - name: rss
296 + - name: nagios.jobs.disk
297 + description: Nagios plugin disk I/O
298 + unit: bytes
299 + chart_type: line
300 + dimensions:
301 + - name: read
302 + - name: write
303 + - name: perfdata
304 + description: |
305 + Metrics extracted from the plugin's performance data output.
306 + Each `label=value;warn;crit;min;max` entry produces a separate chart.
307 + labels:
308 + - name: nagios_job
309 + description: Job name.
310 + - name: nagios_plugin
311 + description: Plugin executable.
312 + - name: perf_label
313 + description: Performance data label as emitted by the plugin.
314 + metrics:
315 + - name: nagios.{script}.{label}
316 + description: Performance data metric (context is dynamic per plugin and label)
317 + unit: varies
318 + chart_type: line
319 + dimensions:
320 + - name: value
321 + - name: scheduler
322 + description: Scheduler-level metrics.
323 + labels:
324 + - name: nagios_scheduler
325 + description: Scheduler name.
326 + metrics:
327 + - name: nagios.scheduler.jobs
328 + description: Scheduler job status
329 + unit: jobs
330 + chart_type: line
331 + dimensions:
332 + - name: running
333 + - name: queued
334 + - name: scheduled
335 + - name: nagios.scheduler.rate
336 + description: Scheduler workload throughput
337 + unit: jobs
338 + chart_type: line
339 + dimensions:
340 + - name: started
341 + - name: finished
342 + - name: skipped
343 + - name: nagios.scheduler.next
344 + description: Scheduler next run time
345 + unit: seconds
346 + chart_type: line
347 + dimensions:
348 + - name: next
src/go/plugin/scripts.d/modules/nagios/module.go new
+461
@@ -0,0 +1,461 @@
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/go.d/agent/module"
19 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/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 + module.Register("nagios", module.Creator{
35 + JobConfigSchema: configSchema,
36 + Defaults: module.Defaults{
37 + AutoDetectionRetry: 60,
38 + },
39 + Create: func() module.Module { 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 + module.Base
115 + Config `yaml:",inline" json:""`
116 +
117 + charts *module.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: &module.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 = &module.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() *module.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 := module.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.Infof("nagios: registering perfdata chart scheduler=%s job=%s label=%s unit=%s", meta.Scheduler, job.Name, label, datum.Unit)
298 + c.chartMu.Lock()
299 + defer c.chartMu.Unlock()
300 + if existing, ok := c.perfCharts[key]; ok {
301 + if sameScale(existing.Scale, scale) {
302 + return
303 + }
304 + chartID := meta.PerfdataChartID(labelID)
305 + if chart := c.charts.Get(chartID); chart != nil {
306 + chart.MarkRemove()
307 + }
308 + }
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/config_schema.json new
+132
@@ -0,0 +1,132 @@
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/metadata.yaml new
+110
@@ -0,0 +1,110 @@
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.svg
11 + categories:
12 + - data-collection.generic-data-collection
13 + related_resources:
14 + integrations:
15 + list:
16 + - plugin_name: scripts.d.plugin
17 + module_name: nagios
18 + - plugin_name: scripts.d.plugin
19 + module_name: zabbix
20 + info_provided_to_referring_integrations:
21 + description: ""
22 + keywords:
23 + - scheduler
24 + - scripts
25 + most_popular: false
26 + overview:
27 + data_collection:
28 + metrics_description: |
29 + The scheduler module manages the execution of jobs defined by the nagios and zabbix modules.
30 +
31 + It provides:
32 +
33 + - **Worker pool**: Concurrent execution with configurable worker count and queue depth.
34 + - **OTLP logging**: Optional structured log export via gRPC for job execution results.
35 +
36 + Scheduler-level metrics (jobs status, throughput, next run time) are exposed through the nagios and zabbix modules that use it.
37 + method_description: |
38 + The scheduler manages per-job timers and dispatches jobs to a worker pool.
39 + Each worker executes a job via the configured runner (nagios or zabbix) and reports results back.
40 + default_behavior:
41 + auto_detection:
42 + description: |
43 + A `default` scheduler is created automatically. Additional named schedulers can be defined in the configuration.
44 + limits:
45 + description: ""
46 + performance_impact:
47 + description: ""
48 + additional_permissions:
49 + description: ""
50 + multi_instance: true
51 + supported_platforms:
52 + include: []
53 + exclude: []
54 + setup:
55 + prerequisites:
56 + list: []
57 + configuration:
58 + file:
59 + name: scripts.d/scheduler.conf
60 + options:
61 + description: |
62 + Scheduler configuration controls the worker pool and optional OTLP logging.
63 + folding:
64 + title: Config options
65 + enabled: true
66 + list:
67 + - name: workers
68 + description: Number of concurrent workers executing jobs.
69 + default_value: 50
70 + required: false
71 + group: General
72 + - name: queue_size
73 + description: Capacity of the internal work queue.
74 + default_value: 128
75 + required: false
76 + group: General
77 + - name: logging.enabled
78 + description: Enable structured OTLP log export for job results.
79 + default_value: "true"
80 + required: false
81 + group: Logging
82 + - name: logging.otlp.endpoint
83 + description: gRPC endpoint for OTLP log export.
84 + default_value: "127.0.0.1:4317"
85 + required: false
86 + group: Logging
87 + examples:
88 + folding:
89 + title: Config
90 + enabled: true
91 + list:
92 + - name: Custom scheduler
93 + description: Define a scheduler with a larger worker pool.
94 + config: |
95 + jobs:
96 + - name: heavy
97 + workers: 100
98 + queue_size: 256
99 + troubleshooting:
100 + problems:
101 + list: []
102 + alerts: []
103 + metrics:
104 + folding:
105 + title: Metrics
106 + enabled: false
107 + description: |
108 + Scheduler metrics are exposed through the nagios and zabbix modules under the `nagios.scheduler.*` context.
109 + availability: []
110 + scopes: []
src/go/plugin/scripts.d/modules/scheduler/module.go new
+200
@@ -0,0 +1,200 @@
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/go.d/agent/module"
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 + module.Register("scheduler", module.Creator{
27 + JobConfigSchema: configSchema,
28 + Defaults: module.Defaults{AutoDetectionRetry: 60},
29 + Create: func() module.Module { 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 + module.Base
101 + Config `yaml:",inline" json:",inline"`
102 +
103 + applied bool
104 + charts *module.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: &module.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 = &module.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() *module.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 = &module.Charts{}
199 + }
200 +}
src/go/plugin/scripts.d/modules/scheduler/module_test.go new
+43
@@ -0,0 +1,43 @@
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/modules/zabbix/config_schema.json new
+664
@@ -0,0 +1,664 @@
1 +{
2 + "jsonSchema": {
3 + "$schema": "http://json-schema.org/draft-07/schema#",
4 + "title": "scripts.d Zabbix job configuration",
5 + "type": "object",
6 + "additionalProperties": false,
7 + "properties": {
8 + "scheduler": {
9 + "title": "Scheduler name",
10 + "description": "Named scripts.d scheduler to execute this job (defaults to 'default')",
11 + "type": "string"
12 + },
13 + "update_every": {
14 + "title": "Update every",
15 + "description": "Polling interval in seconds (defaults to 60 when omitted)",
16 + "type": "integer",
17 + "minimum": 1
18 + },
19 + "vnode": {
20 + "title": "Virtual node",
21 + "description": "Optional vnode identifier for routing results",
22 + "type": "string"
23 + },
24 + "notes": {
25 + "title": "Notes",
26 + "type": "string"
27 + },
28 + "collection": {
29 + "$ref": "#/definitions/Collection"
30 + },
31 + "lld": {
32 + "$ref": "#/definitions/LLD"
33 + },
34 + "user_macros": {
35 + "title": "User macros",
36 + "description": "Key/value pairs exposed as $USERn$ templates (e.g. key USER1 becomes $USER1$).",
37 + "type": "object",
38 + "additionalProperties": {
39 + "type": "string"
40 + }
41 + },
42 + "dependent_pipelines": {
43 + "title": "Dependent pipelines",
44 + "type": "array",
45 + "minItems": 1,
46 + "items": {
47 + "$ref": "#/definitions/Pipeline"
48 + }
49 + }
50 + },
51 + "required": [
52 + "collection",
53 + "dependent_pipelines"
54 + ],
55 + "definitions": {
56 + "Collection": {
57 + "type": "object",
58 + "description": "Zabbix collection (master item) definition",
59 + "oneOf": [
60 + {
61 + "$ref": "#/definitions/CollectionCommand"
62 + },
63 + {
64 + "$ref": "#/definitions/CollectionHTTP"
65 + },
66 + {
67 + "$ref": "#/definitions/CollectionSNMP"
68 + }
69 + ]
70 + },
71 + "CollectionCommand": {
72 + "type": "object",
73 + "additionalProperties": false,
74 + "properties": {
75 + "type": {
76 + "const": "command"
77 + },
78 + "command": {
79 + "title": "Command",
80 + "type": "string",
81 + "minLength": 1
82 + },
83 + "args": {
84 + "title": "Arguments",
85 + "type": "array",
86 + "items": {
87 + "type": "string"
88 + }
89 + },
90 + "timeout": {
91 + "$ref": "#/definitions/Duration"
92 + },
93 + "environment": {
94 + "title": "Environment variables",
95 + "type": "object",
96 + "additionalProperties": {
97 + "type": "string"
98 + }
99 + }
100 + },
101 + "required": [
102 + "type",
103 + "command"
104 + ]
105 + },
106 + "CollectionHTTP": {
107 + "type": "object",
108 + "additionalProperties": false,
109 + "properties": {
110 + "type": {
111 + "const": "http"
112 + },
113 + "http": {
114 + "$ref": "#/definitions/HTTPConfig"
115 + },
116 + "timeout": {
117 + "$ref": "#/definitions/Duration"
118 + },
119 + "headers": {
120 + "title": "Legacy headers",
121 + "type": "object",
122 + "additionalProperties": {
123 + "type": "string"
124 + }
125 + },
126 + "body": {
127 + "type": "string"
128 + },
129 + "method": {
130 + "type": "string"
131 + },
132 + "url": {
133 + "type": "string"
134 + }
135 + },
136 + "required": [
137 + "type"
138 + ],
139 + "allOf": [
140 + {
141 + "anyOf": [
142 + {
143 + "required": [
144 + "http"
145 + ]
146 + },
147 + {
148 + "required": [
149 + "url"
150 + ]
151 + }
152 + ]
153 + }
154 + ]
155 + },
156 + "CollectionSNMP": {
157 + "type": "object",
158 + "additionalProperties": false,
159 + "properties": {
160 + "type": {
161 + "const": "snmp"
162 + },
163 + "snmp": {
164 + "$ref": "#/definitions/SNMPConfig"
165 + },
166 + "timeout": {
167 + "$ref": "#/definitions/Duration"
168 + }
169 + },
170 + "required": [
171 + "type",
172 + "snmp"
173 + ]
174 + },
175 + "HTTPConfig": {
176 + "type": "object",
177 + "additionalProperties": false,
178 + "properties": {
179 + "url": {
180 + "title": "URL",
181 + "type": "string",
182 + "format": "uri"
183 + },
184 + "method": {
185 + "title": "HTTP method",
186 + "type": "string",
187 + "default": "GET"
188 + },
189 + "headers": {
190 + "title": "Headers",
191 + "type": "object",
192 + "additionalProperties": {
193 + "type": "string"
194 + }
195 + },
196 + "body": {
197 + "type": "string"
198 + },
199 + "tls": {
200 + "title": "Force TLS",
201 + "description": "When enabled, requests are sent via HTTPS (scheme upgraded if necessary).",
202 + "type": "boolean"
203 + },
204 + "username": {
205 + "type": "string"
206 + },
207 + "password": {
208 + "type": "string"
209 + }
210 + },
211 + "required": [
212 + "url"
213 + ]
214 + },
215 + "SNMPConfig": {
216 + "type": "object",
217 + "additionalProperties": false,
218 + "properties": {
219 + "target": {
220 + "title": "Target host",
221 + "type": "string",
222 + "minLength": 1
223 + },
224 + "oid": {
225 + "title": "OID",
226 + "type": "string",
227 + "minLength": 1
228 + },
229 + "version": {
230 + "title": "SNMP version",
231 + "type": "string",
232 + "enum": [
233 + "v1",
234 + "v2c",
235 + "v3",
236 + "1",
237 + "2c",
238 + "3"
239 + ],
240 + "default": "v2c"
241 + },
242 + "community": {
243 + "type": "string"
244 + },
245 + "user": {
246 + "type": "string"
247 + },
248 + "auth_password": {
249 + "type": "string"
250 + },
251 + "privacy_password": {
252 + "type": "string"
253 + },
254 + "auth_protocol": {
255 + "type": "string",
256 + "enum": [
257 + "md5",
258 + "sha",
259 + "",
260 + null
261 + ]
262 + },
263 + "privacy_protocol": {
264 + "type": "string",
265 + "enum": [
266 + "des",
267 + "aes",
268 + "",
269 + null
270 + ]
271 + },
272 + "context": {
273 + "type": "string"
274 + }
275 + },
276 + "required": [
277 + "target",
278 + "oid"
279 + ]
280 + },
281 + "LLD": {
282 + "type": "object",
283 + "additionalProperties": false,
284 + "properties": {
285 + "steps": {
286 + "title": "Discovery steps",
287 + "type": "array",
288 + "minItems": 1,
289 + "items": {
290 + "$ref": "#/definitions/Step"
291 + }
292 + },
293 + "discovery_interval": {
294 + "title": "Discovery interval",
295 + "$ref": "#/definitions/Duration"
296 + },
297 + "instance_id_template": {
298 + "title": "Instance ID template",
299 + "type": "string"
300 + },
301 + "family": {
302 + "title": "Default family",
303 + "type": "string"
304 + },
305 + "labels": {
306 + "title": "Labels",
307 + "type": "object",
308 + "additionalProperties": {
309 + "type": "string"
310 + }
311 + },
312 + "max_missing": {
313 + "title": "Max missing",
314 + "description": "Number of consecutive discovery misses before an instance is removed",
315 + "type": "integer",
316 + "minimum": 0
317 + }
318 + }
319 + },
320 + "Pipeline": {
321 + "type": "object",
322 + "additionalProperties": false,
323 + "properties": {
324 + "name": {
325 + "title": "Pipeline name",
326 + "type": "string",
327 + "minLength": 1
328 + },
329 + "context": {
330 + "title": "Metric context",
331 + "type": "string",
332 + "minLength": 1
333 + },
334 + "title": {
335 + "title": "Chart title",
336 + "type": "string"
337 + },
338 + "dimension": {
339 + "title": "Dimension name",
340 + "type": "string",
341 + "minLength": 1
342 + },
343 + "unit": {
344 + "title": "Units",
345 + "type": "string",
346 + "minLength": 1
347 + },
348 + "family": {
349 + "title": "Chart family",
350 + "type": "string"
351 + },
352 + "chart_type": {
353 + "title": "Chart type",
354 + "type": "string",
355 + "enum": [
356 + "line",
357 + "area",
358 + "stacked"
359 + ],
360 + "default": "line"
361 + },
362 + "data_type": {
363 + "title": "Data type",
364 + "type": "string"
365 + },
366 + "precision": {
367 + "title": "Precision",
368 + "type": "integer",
369 + "minimum": 0,
370 + "default": 0
371 + },
372 + "algorithm": {
373 + "title": "Dimension algorithm",
374 + "type": "string",
375 + "enum": [
376 + "absolute",
377 + "incremental",
378 + "percentage-of-absolute-row",
379 + "percentage-of-incremental-row"
380 + ],
381 + "default": "absolute"
382 + },
383 + "steps": {
384 + "title": "Preprocessing steps",
385 + "type": "array",
386 + "minItems": 1,
387 + "items": {
388 + "$ref": "#/definitions/Step"
389 + }
390 + }
391 + },
392 + "required": [
393 + "name",
394 + "context",
395 + "dimension",
396 + "unit",
397 + "steps"
398 + ]
399 + },
400 + "Step": {
401 + "type": "object",
402 + "additionalProperties": false,
403 + "properties": {
404 + "type": {
405 + "title": "Step type",
406 + "type": "string",
407 + "enum": [
408 + "multiplier",
409 + "rtrim",
410 + "ltrim",
411 + "trim",
412 + "regex_substitution",
413 + "bool_to_decimal",
414 + "octal_to_decimal",
415 + "hex_to_decimal",
416 + "delta_value",
417 + "delta_speed",
418 + "xpath",
419 + "jsonpath",
420 + "validate_range",
421 + "validate_regex",
422 + "validate_not_regex",
423 + "error_field_json",
424 + "error_field_xml",
425 + "error_field_regex",
426 + "throttle_value",
427 + "throttle_timed_value",
428 + "javascript",
429 + "prometheus_pattern",
430 + "prometheus_to_json",
431 + "csv_to_json",
432 + "string_replace",
433 + "validate_not_supported",
434 + "xml_to_json",
435 + "snmp_walk_value",
436 + "snmp_walk_to_json",
437 + "snmp_get_value",
438 + "prometheus_to_json_multi",
439 + "jsonpath_multi",
440 + "snmp_walk_to_json_multi",
441 + "csv_to_json_multi"
442 + ]
443 + },
444 + "params": {
445 + "title": "Parameters",
446 + "type": "string"
447 + },
448 + "error_handler": {
449 + "title": "Error handler",
450 + "type": "string",
451 + "enum": [
452 + "default",
453 + "discard",
454 + "set-value",
455 + "set-error"
456 + ]
457 + },
458 + "error_handler_params": {
459 + "title": "Error handler parameters",
460 + "description": "Value used when error_handler is set to set-value or set-error",
461 + "type": "string"
462 + }
463 + },
464 + "required": [
465 + "type"
466 + ]
467 + },
468 + "Duration": {
469 + "title": "Duration",
470 + "type": "string",
471 + "pattern": "^([0-9]+(\\.[0-9]+)?(ns|us|ms|s|m|h|d))+$"
472 + }
473 + }
474 + },
475 + "uiSchema": {
476 + "uiOptions": {
477 + "fullPage": true
478 + },
479 + "ui:flavour": "tabs",
480 + "ui:options": {
481 + "tabs": [
482 + {
483 + "title": "General",
484 + "fields": [
485 + "scheduler",
486 + "update_every",
487 + "vnode",
488 + "notes"
489 + ]
490 + },
491 + {
492 + "title": "Collection",
493 + "fields": [
494 + "collection"
495 + ]
496 + },
497 + {
498 + "title": "Discovery",
499 + "fields": [
500 + "lld"
501 + ]
502 + },
503 + {
504 + "title": "Metrics Extraction",
505 + "fields": [
506 + "dependent_pipelines"
507 + ]
508 + }
509 + ]
510 + },
511 + "scheduler": {
512 + "ui:placeholder": "default"
513 + },
514 + "update_every": {
515 + "ui:placeholder": "60",
516 + "ui:help": "Seconds between runs. Leave empty to inherit the default interval."
517 + },
518 + "vnode": {
519 + "ui:placeholder": "my-vnode"
520 + },
521 + "collection": {
522 + "ui:help": "Defines how the master item collects raw data. Command collections can use standard Netdata macros as well as legacy Zabbix macros (e.g. {HOST.NAME}). HTTP/SNMP collections expose the same payload to all dependent pipelines.",
523 + "type": {
524 + "ui:widget": "radio",
525 + "ui:options": {
526 + "inline": true
527 + }
528 + },
529 + "command": {
530 + "ui:placeholder": "/usr/lib/zabbix/externalscripts/myscript.sh"
531 + },
532 + "args": {
533 + "ui:listFlavour": "list",
534 + "items": {
535 + "ui:placeholder": "{HOST.NAME}"
536 + },
537 + "ui:help": "Command arguments may use macros such as {HOST.NAME}, {#MACRO}, and positional $ARGn style placeholders."
538 + },
539 + "environment": {
540 + "ui:help": "Optional environment variables exported before executing the collection command."
541 + },
542 + "http": {
543 + "url": {
544 + "ui:placeholder": "https://example/api/status"
545 + },
546 + "body": {
547 + "ui:widget": "textarea",
548 + "ui:options": {
549 + "rows": 3
550 + }
551 + }
552 + },
553 + "snmp": {
554 + "target": {
555 + "ui:placeholder": "192.0.2.10"
556 + },
557 + "oid": {
558 + "ui:placeholder": ".1.3.6.1.2.1.25.2.3.1.6"
559 + },
560 + "community": {
561 + "ui:placeholder": "public"
562 + }
563 + }
564 + },
565 + "lld": {
566 + "ui:help": "Optional discovery pipeline. When defined, each run must output a JSON array of macro maps (e.g. [{\"{#FSNAME}\":\"/\"}]). Leave empty to emit a single default instance.",
567 + "steps": {
568 + "ui:listFlavour": "list",
569 + "items": {
570 + "type": {
571 + "ui:help": "Choose the preprocessing step (e.g. jsonpath, csv_to_json).",
572 + "ui:placeholder": "jsonpath"
573 + },
574 + "params": {
575 + "ui:widget": "textarea",
576 + "ui:help": "Parameters can reference discovery macros (e.g. {#MACRO}) and will be rendered per execution.",
577 + "ui:options": {
578 + "rows": 3
579 + }
580 + },
581 + "error_handler": {
582 + "ui:widget": "radio",
583 + "ui:options": {
584 + "inline": true
585 + }
586 + },
587 + "error_handler_params": {
588 + "ui:help": "Value used when error_handler is set to set-value or set-error"
589 + }
590 + }
591 + },
592 + "instance_id_template": {
593 + "ui:placeholder": "{#FSNAME}"
594 + },
595 + "labels": {
596 + "ui:help": "Key/value templates that become Netdata labels (e.g. region: {#REGION})."
597 + }
598 + },
599 + "dependent_pipelines": {
600 + "ui:listFlavour": "list",
601 + "ui:openEmptyItem": true,
602 + "ui:help": "Each pipeline turns the shared collection payload into a Netdata metric. Use macros ({#MACRO}, {ITEM.VALUE}) to tailor chart metadata and preprocessing steps per instance.",
603 + "items": {
604 + "ui:flavour": "tabs",
605 + "ui:options": {
606 + "tabs": [
607 + {
608 + "title": "Metadata",
609 + "fields": [
610 + "name",
611 + "context",
612 + "title",
613 + "dimension",
614 + "unit",
615 + "family",
616 + "chart_type",
617 + "data_type",
618 + "precision",
619 + "algorithm"
620 + ]
621 + },
622 + {
623 + "title": "Steps",
624 + "fields": [
625 + "steps"
626 + ]
627 + }
628 + ]
629 + },
630 + "context": {
631 + "ui:placeholder": "zabbix.fs.free"
632 + },
633 + "dimension": {
634 + "ui:placeholder": "free"
635 + },
636 + "unit": {
637 + "ui:placeholder": "MiB"
638 + },
639 + "steps": {
640 + "ui:listFlavour": "list",
641 + "items": {
642 + "type": {
643 + "ui:help": "Select the preprocessing step using the string enum (e.g. jsonpath, csv_to_json, snmp_walk_to_json).",
644 + "ui:placeholder": "jsonpath"
645 + },
646 + "params": {
647 + "ui:widget": "textarea",
648 + "ui:help": "Step parameters. Macro substitution ({#MACRO}, {ITEM.VALUE}, {ITEM.VALUE1}) occurs before execution.",
649 + "ui:options": {
650 + "rows": 3
651 + }
652 + },
653 + "error_handler": {
654 + "ui:widget": "radio",
655 + "ui:options": {
656 + "inline": true
657 + }
658 + }
659 + }
660 + }
661 + }
662 + }
663 + }
664 +}
\ No newline at end of file
src/go/plugin/scripts.d/modules/zabbix/emitter.go new
+623
@@ -0,0 +1,623 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package zabbix
4 +
5 +import (
6 + "fmt"
7 + "math"
8 + "sort"
9 + "strings"
10 + "sync"
11 +
12 + "github.com/netdata/netdata/go/plugins/logger"
13 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
14 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/ids"
15 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/runtime"
16 + pkgzabbix "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbix"
17 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbix/jobengine"
18 + zpre "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbixpreproc"
19 +)
20 +
21 +type pipelineEmitter struct {
22 + log *logger.Logger
23 + charts *module.Charts
24 +
25 + mu sync.Mutex
26 + jobs map[string]*jobState
27 + metrics map[string]int64
28 +}
29 +
30 +type jobState struct {
31 + cfg pkgzabbix.JobConfig
32 + engine *jobengine.Job
33 + instances map[string]*instanceBinding
34 + stateBinding *stateChartBinding
35 +}
36 +
37 +type instanceBinding struct {
38 + id string
39 + macros map[string]string
40 + charts map[string]*pipelineBinding
41 + state *stateChartBinding
42 +}
43 +
44 +type pipelineBinding struct {
45 + chart *module.Chart
46 + dims map[string]string
47 +}
48 +
49 +type metricUpdate struct {
50 + dimID string
51 + value int64
52 +}
53 +
54 +type stateChartBinding struct {
55 + chartID string
56 + dims map[string]string
57 +}
58 +
59 +var stateDimensions = []string{"collect_failure", "lld_failure", "extraction_failure", "dimension_failure", "ok"}
60 +
61 +func newPipelineEmitter(log *logger.Logger, proc *zpre.Preprocessor, charts *module.Charts, jobs []pkgzabbix.JobConfig) (*pipelineEmitter, error) {
62 + states := make(map[string]*jobState, len(jobs))
63 + for _, cfg := range jobs {
64 + engine, err := jobengine.NewJob(cfg, proc, jobengine.Options{Logger: log})
65 + if err != nil {
66 + return nil, err
67 + }
68 + states[cfg.Name] = &jobState{
69 + cfg: cfg,
70 + engine: engine,
71 + instances: make(map[string]*instanceBinding),
72 + }
73 + }
74 + return &pipelineEmitter{
75 + log: log,
76 + charts: charts,
77 + jobs: states,
78 + metrics: make(map[string]int64),
79 + }, nil
80 +}
81 +
82 +func (e *pipelineEmitter) Emit(job runtime.JobRuntime, res runtime.ExecutionResult, snap runtime.JobSnapshot) {
83 + state := e.getState(job.Spec.Name)
84 + if state == nil {
85 + return
86 + }
87 + input := jobengine.Input{Payload: res.Output, Timestamp: res.End}
88 + if res.Err != nil || res.ExitCode != 0 {
89 + if input.Payload == nil {
90 + input.Payload = []byte{}
91 + }
92 + input.CollectError = fmt.Errorf("collect failed: %w", res.Err)
93 + }
94 + result := state.engine.Process(input)
95 + metricUpdates, stateUpdates := e.prepareUpdates(state.cfg.Name, result)
96 + for _, upd := range metricUpdates {
97 + e.setMetric(upd.dimID, upd.value)
98 + }
99 + for _, upd := range stateUpdates {
100 + e.setMetric(upd.dimID, upd.value)
101 + }
102 +}
103 +
104 +func (e *pipelineEmitter) prepareUpdates(jobName string, result jobengine.Result) ([]metricUpdate, []metricUpdate) {
105 + e.mu.Lock()
106 + defer e.mu.Unlock()
107 + state, ok := e.jobs[jobName]
108 + if !ok {
109 + return nil, nil
110 + }
111 + for _, inst := range result.Removed {
112 + if binding, ok := state.instances[inst.ID]; ok {
113 + e.removeInstanceLocked(state.cfg, binding)
114 + delete(state.instances, inst.ID)
115 + }
116 + }
117 + for _, info := range result.Active {
118 + binding := e.ensureInstanceBindingLocked(state, info)
119 + binding.macros = cloneMacros(info.Macros)
120 + }
121 + metricUpdates := e.buildMetricUpdatesLocked(state, result.Metrics)
122 + stateUpdates := e.buildStateUpdatesLocked(state, result.State)
123 + return metricUpdates, stateUpdates
124 +}
125 +
126 +func (e *pipelineEmitter) ensureInstanceBindingLocked(state *jobState, info jobengine.InstanceInfo) *instanceBinding {
127 + binding, ok := state.instances[info.ID]
128 + if !ok {
129 + binding = &instanceBinding{id: info.ID, macros: make(map[string]string), charts: make(map[string]*pipelineBinding)}
130 + state.instances[info.ID] = binding
131 + }
132 + if binding.charts == nil {
133 + binding.charts = make(map[string]*pipelineBinding)
134 + }
135 + return binding
136 +}
137 +
138 +func (e *pipelineEmitter) buildMetricUpdatesLocked(state *jobState, metrics []jobengine.MetricResult) []metricUpdate {
139 + updates := make([]metricUpdate, 0, len(metrics))
140 + for _, mr := range metrics {
141 + inst := state.instances[mr.Instance.ID]
142 + if inst == nil {
143 + continue
144 + }
145 + binding, err := e.ensurePipelineChartLocked(state.cfg, inst, mr.Pipeline, inst.macros)
146 + if err != nil {
147 + e.logWarn("chart registration failed", state.cfg.Name, mr.Pipeline.Name, err)
148 + continue
149 + }
150 + baseName := dimensionBaseName(mr.Pipeline, inst.macros)
151 + key := dimensionKey(baseName, mr)
152 + dimensionID, ok := binding.dims[key]
153 + if !ok {
154 + chart := binding.chart
155 + if chart == nil {
156 + continue
157 + }
158 + dimName := dimensionDisplayName(baseName, mr)
159 + newDim := &module.Dim{ID: dimID(chart.ID, ids.Sanitize(key)), Name: dimName, Algo: dimAlgorithm(mr.Pipeline.Algorithm)}
160 + if err := chart.AddDim(newDim); err != nil {
161 + e.logWarn("dimension registration failed", state.cfg.Name, mr.Pipeline.Name, err)
162 + continue
163 + }
164 + dimensionID = newDim.ID
165 + binding.dims[key] = dimensionID
166 + }
167 + updates = append(updates, metricUpdate{dimID: dimensionID, value: scaleValue(mr.Value, mr.Pipeline.Precision)})
168 + }
169 + return updates
170 +}
171 +
172 +func (e *pipelineEmitter) buildStateUpdatesLocked(state *jobState, summary jobengine.StateSummary) []metricUpdate {
173 + var updates []metricUpdate
174 + binding, err := e.ensureJobStateChart(state)
175 + if err != nil {
176 + e.logWarn("state chart registration failed", state.cfg.Name, "job_state", err)
177 + } else {
178 + updates = append(updates, stateMetricUpdates(binding, summary.Job)...)
179 + }
180 + for id, inst := range state.instances {
181 + flags := summary.Instances[id]
182 + ib, err := e.ensureInstanceStateChart(state.cfg, inst)
183 + if err != nil {
184 + e.logWarn("state chart registration failed", state.cfg.Name, id, err)
185 + continue
186 + }
187 + updates = append(updates, stateMetricUpdates(ib, flags)...)
188 + }
189 + return updates
190 +}
191 +
192 +func stateMetricUpdates(binding *stateChartBinding, flags jobengine.FailureFlags) []metricUpdate {
193 + updates := make([]metricUpdate, 0, len(stateDimensions))
194 + updates = append(updates,
195 + metricUpdate{dimID: binding.dims["collect_failure"], value: boolToInt(flags.Collect)},
196 + metricUpdate{dimID: binding.dims["lld_failure"], value: boolToInt(flags.LLD)},
197 + metricUpdate{dimID: binding.dims["extraction_failure"], value: boolToInt(flags.Extraction)},
198 + metricUpdate{dimID: binding.dims["dimension_failure"], value: boolToInt(flags.Dimension)},
199 + metricUpdate{dimID: binding.dims["ok"], value: boolToInt(!flags.Collect && !flags.LLD && !flags.Extraction && !flags.Dimension)},
200 + )
201 + return updates
202 +}
203 +
204 +func (e *pipelineEmitter) Close() error {
205 + e.mu.Lock()
206 + defer e.mu.Unlock()
207 + for name := range e.jobs {
208 + e.removeJobLocked(name)
209 + }
210 + return nil
211 +}
212 +
213 +func (e *pipelineEmitter) RemoveJob(name string) {
214 + e.mu.Lock()
215 + defer e.mu.Unlock()
216 + e.removeJobLocked(name)
217 +}
218 +
219 +func (e *pipelineEmitter) removeJobLocked(name string) {
220 + state, ok := e.jobs[name]
221 + if !ok {
222 + return
223 + }
224 + if state.stateBinding != nil {
225 + _ = e.charts.Remove(state.stateBinding.chartID)
226 + state.stateBinding = nil
227 + }
228 + for _, inst := range state.instances {
229 + e.removeInstanceLocked(state.cfg, inst)
230 + }
231 + state.engine.Destroy()
232 + delete(e.jobs, name)
233 +}
234 +
235 +func (e *pipelineEmitter) Flush() map[string]int64 {
236 + e.mu.Lock()
237 + defer e.mu.Unlock()
238 + if len(e.metrics) == 0 {
239 + return nil
240 + }
241 + out := make(map[string]int64, len(e.metrics))
242 + for k, v := range e.metrics {
243 + out[k] = v
244 + }
245 + e.metrics = make(map[string]int64)
246 + return out
247 +}
248 +
249 +func (e *pipelineEmitter) setMetric(dimID string, value int64) {
250 + e.mu.Lock()
251 + defer e.mu.Unlock()
252 + e.metrics[dimID] = value
253 +}
254 +
255 +func (e *pipelineEmitter) getState(name string) *jobState {
256 + e.mu.Lock()
257 + defer e.mu.Unlock()
258 + return e.jobs[name]
259 +}
260 +
261 +func buildChart(job pkgzabbix.JobConfig, inst *instanceBinding, pipe *pkgzabbix.PipelineConfig, macros map[string]string) *module.Chart {
262 + title := "Zabbix Pipeline Data"
263 + units := expandTemplate(pipe.Unit, macros)
264 + if units == "" {
265 + units = "value"
266 + }
267 + fam := expandTemplate(pipe.Family, macros)
268 + if fam == "" {
269 + fam = expandTemplate(job.LLD.Family, macros)
270 + }
271 + if fam == "" {
272 + fam = job.Name
273 + }
274 + ctx := expandTemplate(pipe.Context, macros)
275 + if ctx == "" {
276 + ctx = pipe.Name
277 + }
278 + sanitizedCtx := ids.Sanitize(ctx)
279 + if sanitizedCtx == "" {
280 + sanitizedCtx = ids.Sanitize(pipe.Name)
281 + }
282 + dimName := expandTemplate(pipe.Dimension, macros)
283 + if dimName == "" {
284 + dimName = pipe.Name
285 + }
286 + chartIdentifier := chartID(job, inst.id, sanitizedCtx)
287 + dimensionID := dimID(chartIdentifier, dimName)
288 + chart := &module.Chart{
289 + ID: chartIdentifier,
290 + Title: title,
291 + Units: units,
292 + Fam: fam,
293 + Ctx: fmt.Sprintf("zabbix.%s", sanitizedCtx),
294 + Type: chartType(pipe.ChartType),
295 +
296 + Priority: 1000,
297 + Labels: buildLabels(job, inst, pipe, macros),
298 + Dims: module.Dims{
299 + {
300 + ID: dimensionID,
301 + Name: dimName,
302 + Algo: dimAlgorithm(pipe.Algorithm),
303 + },
304 + },
305 + }
306 + return chart
307 +}
308 +
309 +func buildJobStateChart(job pkgzabbix.JobConfig) *module.Chart {
310 + chartID := jobStateChartID(job)
311 + dims, _ := buildStateChartDims(chartID)
312 + return &module.Chart{
313 + ID: chartID,
314 + Title: "Zabbix Job State",
315 + Units: "state",
316 + Fam: job.Name,
317 + Ctx: fmt.Sprintf("zabbix.%s.state", ids.Sanitize(job.Name)),
318 + Type: module.Line,
319 +
320 + Priority: 800,
321 + Labels: jobLabels(job),
322 + Dims: dims,
323 + Opts: module.Opts{
324 + Detail: true,
325 + },
326 + }
327 +}
328 +
329 +func buildInstanceStateChart(job pkgzabbix.JobConfig, inst *instanceBinding) *module.Chart {
330 + chartID := instanceStateChartID(job, inst)
331 + macros := cloneMacros(inst.macros)
332 + dims, _ := buildStateChartDims(chartID)
333 + return &module.Chart{
334 + ID: chartID,
335 + Title: "Zabbix Instance State",
336 + Units: "state",
337 + Fam: job.Name,
338 + Ctx: fmt.Sprintf("zabbix.%s.instance.state", ids.Sanitize(job.Name)),
339 + Type: module.Line,
340 +
341 + Priority: 850,
342 + Labels: buildLabels(job, inst, nil, macros),
343 + Dims: dims,
344 + Opts: module.Opts{
345 + Detail: true,
346 + },
347 + }
348 +}
349 +
350 +func (e *pipelineEmitter) ensureJobStateChart(state *jobState) (*stateChartBinding, error) {
351 + if state.stateBinding != nil {
352 + return state.stateBinding, nil
353 + }
354 + chart := buildJobStateChart(state.cfg)
355 + if err := e.charts.Add(chart); err != nil {
356 + return nil, err
357 + }
358 + bind := newStateBinding(chart)
359 + state.stateBinding = bind
360 + return bind, nil
361 +}
362 +
363 +func (e *pipelineEmitter) ensureInstanceStateChart(job pkgzabbix.JobConfig, inst *instanceBinding) (*stateChartBinding, error) {
364 + if inst.state != nil {
365 + return inst.state, nil
366 + }
367 + chart := buildInstanceStateChart(job, inst)
368 + if err := e.charts.Add(chart); err != nil {
369 + return nil, err
370 + }
371 + bind := newStateBinding(chart)
372 + inst.state = bind
373 + return bind, nil
374 +}
375 +
376 +func newStateBinding(chart *module.Chart) *stateChartBinding {
377 + bind := &stateChartBinding{chartID: chart.ID, dims: make(map[string]string, len(stateDimensions))}
378 + for idx, name := range stateDimensions {
379 + if idx < len(chart.Dims) && chart.Dims[idx] != nil {
380 + bind.dims[name] = chart.Dims[idx].ID
381 + }
382 + }
383 + return bind
384 +}
385 +
386 +func (e *pipelineEmitter) removeInstanceLocked(cfg pkgzabbix.JobConfig, inst *instanceBinding) {
387 + if inst == nil {
388 + return
389 + }
390 + for _, binding := range inst.charts {
391 + if binding != nil && binding.chart != nil {
392 + _ = e.charts.Remove(binding.chart.ID)
393 + }
394 + }
395 + if inst.state != nil {
396 + _ = e.charts.Remove(inst.state.chartID)
397 + inst.state = nil
398 + }
399 +}
400 +
401 +func (e *pipelineEmitter) ensurePipelineChartLocked(job pkgzabbix.JobConfig, inst *instanceBinding, pipe *pkgzabbix.PipelineConfig, macros map[string]string) (*pipelineBinding, error) {
402 + binding := inst.charts[pipe.Name]
403 + if binding != nil {
404 + return binding, nil
405 + }
406 + chart := buildChart(job, inst, pipe, macros)
407 + if err := e.charts.Add(chart); err != nil {
408 + return nil, err
409 + }
410 + binding = &pipelineBinding{chart: chart, dims: make(map[string]string)}
411 + base := dimensionBaseName(pipe, macros)
412 + if len(chart.Dims) > 0 && chart.Dims[0] != nil {
413 + binding.dims[dimensionKey(base, jobengine.MetricResult{})] = chart.Dims[0].ID
414 + }
415 + inst.charts[pipe.Name] = binding
416 + return binding, nil
417 +}
418 +
419 +func buildStateChartDims(chartID string) (module.Dims, map[string]string) {
420 + dims := make(module.Dims, 0, len(stateDimensions))
421 + idMap := make(map[string]string, len(stateDimensions))
422 + for _, name := range stateDimensions {
423 + dimID := fmt.Sprintf("%s.%s", chartID, ids.Sanitize(name))
424 + dims = append(dims, &module.Dim{ID: dimID, Name: name, Algo: module.Absolute})
425 + idMap[name] = dimID
426 + }
427 + return dims, idMap
428 +}
429 +
430 +func jobStateChartID(job pkgzabbix.JobConfig) string {
431 + return fmt.Sprintf("zabbix.%s.job.state", ids.Sanitize(job.Name))
432 +}
433 +
434 +func instanceStateChartID(job pkgzabbix.JobConfig, inst *instanceBinding) string {
435 + return fmt.Sprintf("zabbix.%s.%s.state", ids.Sanitize(job.Name), inst.id)
436 +}
437 +
438 +func buildLabels(job pkgzabbix.JobConfig, inst *instanceBinding, pipe *pkgzabbix.PipelineConfig, macros map[string]string) []module.Label {
439 + labels := make([]module.Label, 0, len(job.LLD.Labels)+5)
440 + labels = append(labels, jobLabels(job)...)
441 + for key, tmpl := range job.LLD.Labels {
442 + val := expandTemplate(tmpl, macros)
443 + if val == "" {
444 + continue
445 + }
446 + labels = append(labels, module.Label{Key: key, Value: val, Source: module.LabelSourceConf})
447 + }
448 + instanceLabel := inst.id
449 + if macroID := macros["{#INSTANCE_ID}"]; macroID != "" {
450 + instanceLabel = macroID
451 + }
452 + labels = append(labels,
453 + module.Label{Key: "instance", Value: inst.id, Source: module.LabelSourceConf},
454 + module.Label{Key: "zabbix_instance", Value: instanceLabel, Source: module.LabelSourceConf},
455 + )
456 + if pipe != nil && pipe.Name != "" {
457 + labels = append(labels, module.Label{Key: "zabbix_pipeline", Value: pipe.Name, Source: module.LabelSourceConf})
458 + }
459 + return labels
460 +}
461 +
462 +func jobLabels(job pkgzabbix.JobConfig) []module.Label {
463 + labels := []module.Label{
464 + {Key: "zabbix_job", Value: job.Name, Source: module.LabelSourceConf},
465 + }
466 + scheduler := strings.TrimSpace(job.Scheduler)
467 + if scheduler != "" {
468 + labels = append(labels, module.Label{Key: "zabbix_scheduler", Value: scheduler, Source: module.LabelSourceConf})
469 + }
470 + if job.Vnode != "" {
471 + labels = append(labels, module.Label{Key: "zabbix_vnode", Value: job.Vnode, Source: module.LabelSourceConf})
472 + }
473 + collection := strings.TrimSpace(string(job.Collection.Type))
474 + if collection != "" {
475 + labels = append(labels, module.Label{Key: "zabbix_collection", Value: collection, Source: module.LabelSourceConf})
476 + }
477 + return labels
478 +}
479 +
480 +func chartID(job pkgzabbix.JobConfig, instanceID, ctx string) string {
481 + return fmt.Sprintf("zabbix.%s.%s.%s", ids.Sanitize(job.Name), instanceID, ctx)
482 +}
483 +
484 +func dimID(chartID, dimension string) string {
485 + return fmt.Sprintf("%s.%s", chartID, ids.Sanitize(dimension))
486 +}
487 +
488 +func dimensionBaseName(pipe *pkgzabbix.PipelineConfig, macros map[string]string) string {
489 + name := expandTemplate(pipe.Dimension, macros)
490 + if strings.TrimSpace(name) == "" {
491 + name = pipe.Name
492 + }
493 + if strings.TrimSpace(name) == "" {
494 + name = "value"
495 + }
496 + return name
497 +}
498 +
499 +func dimensionKey(base string, metric jobengine.MetricResult) string {
500 + parts := []string{base}
501 + if metric.Name != "" {
502 + parts = append(parts, metric.Name)
503 + }
504 + if len(metric.Labels) > 0 {
505 + keys := make([]string, 0, len(metric.Labels))
506 + for k := range metric.Labels {
507 + keys = append(keys, k)
508 + }
509 + sort.Strings(keys)
510 + for _, k := range keys {
511 + parts = append(parts, fmt.Sprintf("%s=%s", k, metric.Labels[k]))
512 + }
513 + }
514 + return strings.Join(parts, "|")
515 +}
516 +
517 +func dimensionDisplayName(base string, metric jobengine.MetricResult) string {
518 + suffix := []string{}
519 + if metric.Name != "" {
520 + suffix = append(suffix, metric.Name)
521 + }
522 + if len(metric.Labels) > 0 {
523 + keys := make([]string, 0, len(metric.Labels))
524 + for k := range metric.Labels {
525 + keys = append(keys, k)
526 + }
527 + sort.Strings(keys)
528 + pairs := make([]string, 0, len(keys))
529 + for _, k := range keys {
530 + pairs = append(pairs, fmt.Sprintf("%s=%s", k, metric.Labels[k]))
531 + }
532 + suffix = append(suffix, strings.Join(pairs, ","))
533 + }
534 + if len(suffix) == 0 {
535 + return base
536 + }
537 + return fmt.Sprintf("%s (%s)", base, strings.Join(suffix, " | "))
538 +}
539 +
540 +func expandTemplate(tmpl string, macros map[string]string) string {
541 + if tmpl == "" || len(macros) == 0 {
542 + return tmpl
543 + }
544 + res := tmpl
545 + for k, v := range macros {
546 + res = strings.ReplaceAll(res, k, v)
547 + }
548 + return res
549 +}
550 +
551 +func buildInstanceID(cfg pkgzabbix.JobConfig, macros map[string]string) string {
552 + template := cfg.LLD.InstanceTemplate
553 + if strings.TrimSpace(template) == "" {
554 + if val, ok := macros["{#INSTANCE_ID}"]; ok {
555 + template = val
556 + } else {
557 + template = fmt.Sprintf("%s_instance", cfg.Name)
558 + }
559 + }
560 + raw := expandTemplate(template, macros)
561 + if strings.TrimSpace(raw) == "" {
562 + raw = fmt.Sprintf("%s_instance", cfg.Name)
563 + }
564 + return ids.Sanitize(raw)
565 +}
566 +
567 +func cloneMacros(src map[string]string) map[string]string {
568 + out := make(map[string]string, len(src))
569 + for k, v := range src {
570 + out[k] = v
571 + }
572 + return out
573 +}
574 +
575 +func itemID(cfg pkgzabbix.JobConfig, instanceID, pipe string) string {
576 + return fmt.Sprintf("%s.%s.%s", cfg.Name, instanceID, pipe)
577 +}
578 +
579 +func scaleValue(val float64, precision int) int64 {
580 + if precision < 0 {
581 + precision = 0
582 + }
583 + factor := math.Pow10(precision)
584 + return int64(math.Round(val * factor))
585 +}
586 +
587 +func dimAlgorithm(algo string) module.DimAlgo {
588 + switch strings.ToLower(algo) {
589 + case "incremental":
590 + return module.Incremental
591 + case "percentage", "percentage-of-absolute-row":
592 + return module.PercentOfAbsolute
593 + case "percentage-of-incremental-row":
594 + return module.PercentOfIncremental
595 + default:
596 + return module.Absolute
597 + }
598 +}
599 +
600 +func chartType(kind string) module.ChartType {
601 + switch strings.ToLower(kind) {
602 + case "area":
603 + return module.Area
604 + case "stacked":
605 + return module.Stacked
606 + default:
607 + return module.Line
608 + }
609 +}
610 +
611 +func (e *pipelineEmitter) logWarn(msg, job, pipe string, err error) {
612 + if e.log == nil {
613 + return
614 + }
615 + e.log.Warningf("zabbix %s job=%s pipeline=%s error=%v", msg, job, pipe, err)
616 +}
617 +
618 +func boolToInt(b bool) int64 {
619 + if b {
620 + return 1
621 + }
622 + return 0
623 +}
src/go/plugin/scripts.d/modules/zabbix/emitter_test.go new
+138
@@ -0,0 +1,138 @@
1 +package zabbix
2 +
3 +import (
4 + "errors"
5 + "fmt"
6 + "testing"
7 + "time"
8 +
9 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
10 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/ids"
11 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/runtime"
12 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
13 + pkgzabbix "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbix"
14 + zpre "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbixpreproc"
15 +)
16 +
17 +func TestBuildStateChartDims(t *testing.T) {
18 + dims, ids := buildStateChartDims("zabbix.job.state")
19 + if len(dims) != len(stateDimensions) {
20 + t.Fatalf("expected %d dims, got %d", len(stateDimensions), len(dims))
21 + }
22 + for _, name := range stateDimensions {
23 + id, ok := ids[name]
24 + if !ok || id == "" {
25 + t.Fatalf("missing dim mapping for %s", name)
26 + }
27 + }
28 +}
29 +
30 +func TestJobStateChart(t *testing.T) {
31 + chart := buildJobStateChart(pkgzabbix.JobConfig{Name: "fs_usage"})
32 + if chart == nil || chart.ID == "" {
33 + t.Fatalf("expected job state chart")
34 + }
35 + if len(chart.Dims) != len(stateDimensions) {
36 + t.Fatalf("expected %d dims, got %d", len(stateDimensions), len(chart.Dims))
37 + }
38 + if len(chart.Labels) == 0 {
39 + t.Fatalf("expected labels on job state chart")
40 + }
41 +}
42 +
43 +func TestInstanceStateChart(t *testing.T) {
44 + inst := &instanceBinding{id: "instance1", macros: map[string]string{"{#INSTANCE_ID}": "instance1"}}
45 + chart := buildInstanceStateChart(pkgzabbix.JobConfig{Name: "fs_usage"}, inst)
46 + if chart == nil || chart.ID == "" {
47 + t.Fatalf("expected instance state chart")
48 + }
49 + if len(chart.Dims) != len(stateDimensions) {
50 + t.Fatalf("expected %d dims, got %d", len(stateDimensions), len(chart.Dims))
51 + }
52 + if len(chart.Labels) == 0 {
53 + t.Fatalf("expected labels on instance state chart")
54 + }
55 +}
56 +
57 +func TestPipelineEmitterCollectFailureState(t *testing.T) {
58 + job := testJobConfig()
59 + emitter := newTestEmitter(job)
60 + jr := runtime.JobRuntime{ID: "job1", Spec: spec.JobSpec{Name: job.Name}}
61 + res := runtime.ExecutionResult{
62 + Job: jr,
63 + Err: errors.New("boom"),
64 + ExitCode: 2,
65 + End: time.Now(),
66 + }
67 + emitter.Emit(jr, res, runtime.JobSnapshot{})
68 + metrics := emitter.Flush()
69 + assertState(t, metrics, job, "collect_failure", 1)
70 + assertState(t, metrics, job, "ok", 0)
71 + assertInstanceState(t, metrics, job, "default", "collect_failure", 1)
72 + assertInstanceState(t, metrics, job, "default", "ok", 0)
73 +}
74 +
75 +func TestPipelineEmitterExtractionFailureState(t *testing.T) {
76 + job := testJobConfig()
77 + emitter := newTestEmitter(job)
78 + jr := runtime.JobRuntime{ID: "job2", Spec: spec.JobSpec{Name: job.Name}}
79 + res := runtime.ExecutionResult{
80 + Job: jr,
81 + Output: []byte("not-json"),
82 + End: time.Now(),
83 + }
84 + emitter.Emit(jr, res, runtime.JobSnapshot{})
85 + metrics := emitter.Flush()
86 + assertState(t, metrics, job, "extraction_failure", 1)
87 + assertState(t, metrics, job, "ok", 0)
88 + assertInstanceState(t, metrics, job, "default", "extraction_failure", 1)
89 + assertInstanceState(t, metrics, job, "default", "ok", 0)
90 +}
91 +
92 +func newTestEmitter(job pkgzabbix.JobConfig) *pipelineEmitter {
93 + charts := &module.Charts{}
94 + proc := zpre.NewPreprocessor("test-shard")
95 + emitter, err := newPipelineEmitter(nil, proc, charts, []pkgzabbix.JobConfig{job})
96 + if err != nil {
97 + panic(err)
98 + }
99 + return emitter
100 +}
101 +
102 +func testJobConfig() pkgzabbix.JobConfig {
103 + return pkgzabbix.JobConfig{
104 + Name: "fs_usage",
105 + Collection: pkgzabbix.CollectionConfig{
106 + Type: pkgzabbix.CollectionCommand,
107 + Command: "/usr/bin/true",
108 + },
109 + Pipelines: []pkgzabbix.PipelineConfig{
110 + {
111 + Name: "value",
112 + Context: "zabbix.fs_usage.value",
113 + Dimension: "value",
114 + Unit: "value",
115 + Steps: []zpre.Step{
116 + {Type: zpre.StepTypeJSONPath, Params: "$.value"},
117 + },
118 + },
119 + },
120 + }
121 +}
122 +
123 +func assertState(t *testing.T, metrics map[string]int64, job pkgzabbix.JobConfig, dim string, want int64) {
124 + chartID := jobStateChartID(job)
125 + key := fmt.Sprintf("%s.%s", chartID, ids.Sanitize(dim))
126 + if got := metrics[key]; got != want {
127 + t.Fatalf("job state %s expected %d, got %d", dim, want, got)
128 + }
129 +}
130 +
131 +func assertInstanceState(t *testing.T, metrics map[string]int64, job pkgzabbix.JobConfig, instID, dim string, want int64) {
132 + inst := &instanceBinding{id: instID}
133 + chartID := instanceStateChartID(job, inst)
134 + key := fmt.Sprintf("%s.%s", chartID, ids.Sanitize(dim))
135 + if got := metrics[key]; got != want {
136 + t.Fatalf("instance %s state %s expected %d, got %d", instID, dim, want, got)
137 + }
138 +}
src/go/plugin/scripts.d/modules/zabbix/metadata.yaml new
+229
@@ -0,0 +1,229 @@
1 +plugin_name: scripts.d.plugin
2 +modules:
3 + - meta:
4 + id: collector-scripts.d.plugin-zabbix
5 + plugin_name: scripts.d.plugin
6 + module_name: zabbix
7 + monitored_instance:
8 + name: Zabbix Preprocessing
9 + link: https://www.zabbix.com/
10 + icon_filename: zabbix.svg
11 + categories:
12 + - data-collection.generic-data-collection
13 + related_resources:
14 + integrations:
15 + list: []
16 + info_provided_to_referring_integrations:
17 + description: ""
18 + keywords:
19 + - zabbix
20 + - preprocessing
21 + - scripts
22 + - monitoring
23 + most_popular: false
24 + overview:
25 + data_collection:
26 + metrics_description: |
27 + This module runs Zabbix-style data collection jobs natively inside Netdata.
28 +
29 + It supports Zabbix's master-item + dependent-pipeline pattern: a single collection step
30 + (command, HTTP, or SNMP) produces raw data, and one or more **preprocessing pipelines**
31 + extract individual metrics using Zabbix-compatible preprocessing steps (JSONPath, regex,
32 + JavaScript, SNMP walk, Prometheus parsing, CSV, XPath, and more).
33 +
34 + For each configured job it collects:
35 +
36 + - **User-defined metrics**: Each dependent pipeline produces a charted metric with configurable context, unit, family, chart type, and dimension algorithm.
37 + - **Job state**: OK / WARNING / ERROR / UNKNOWN state tracking per job and per discovered instance.
38 + - **Low-level discovery (LLD)**: Optional discovery pipelines that dynamically create instances from JSON arrays, similar to Zabbix LLD.
39 + method_description: |
40 + Collection supports three modes:
41 +
42 + - **Command**: Runs an external script/binary via `nd-run` and captures stdout.
43 + - **HTTP**: Performs an HTTP request and captures the response body.
44 + - **SNMP**: Queries an SNMP agent (GET or WALK) and captures the result.
45 +
46 + The raw output is then processed through Zabbix-compatible preprocessing steps to extract metrics.
47 + Zabbix macros (`{HOST.NAME}`, `{HOST.IP}`, `{#MACRO}`, etc.) are expanded before execution.
48 + default_behavior:
49 + auto_detection:
50 + description: |
51 + No auto-detection. Each job must be explicitly configured with a `collection` definition and one or more `dependent_pipelines`.
52 + limits:
53 + description: ""
54 + performance_impact:
55 + description: |
56 + Command-mode jobs spawn a subprocess per execution. HTTP and SNMP modes use in-process clients.
57 + additional_permissions:
58 + description: |
59 + Command-mode plugins run as the `netdata` user via `nd-run`.
60 + multi_instance: true
61 + supported_platforms:
62 + include: []
63 + exclude: []
64 + setup:
65 + prerequisites:
66 + list: []
67 + configuration:
68 + file:
69 + name: scripts.d/zabbix.conf
70 + options:
71 + description: |
72 + Each job defines a collection source and one or more dependent preprocessing pipelines.
73 + folding:
74 + title: Config options
75 + enabled: true
76 + list:
77 + - name: collection.type
78 + description: Collection mode (`command`, `http`, or `snmp`).
79 + default_value: ""
80 + required: true
81 + group: Collection
82 + - name: collection.command
83 + description: Command to execute (for `command` type).
84 + default_value: ""
85 + required: false
86 + group: Collection
87 + - name: collection.http.url
88 + description: URL to fetch (for `http` type).
89 + default_value: ""
90 + required: false
91 + group: Collection
92 + - name: collection.snmp.target
93 + description: SNMP target host (for `snmp` type).
94 + default_value: ""
95 + required: false
96 + group: Collection
97 + - name: collection.snmp.oid
98 + description: SNMP OID to query.
99 + default_value: ""
100 + required: false
101 + group: Collection
102 + - name: dependent_pipelines[].name
103 + description: Pipeline name (used for chart identification).
104 + default_value: ""
105 + required: true
106 + group: Pipelines
107 + - name: dependent_pipelines[].context
108 + description: Netdata chart context.
109 + default_value: ""
110 + required: true
111 + group: Pipelines
112 + - name: dependent_pipelines[].dimension
113 + description: Dimension name within the chart.
114 + default_value: ""
115 + required: true
116 + group: Pipelines
117 + - name: dependent_pipelines[].unit
118 + description: Metric unit string.
119 + default_value: ""
120 + required: true
121 + group: Pipelines
122 + - name: dependent_pipelines[].steps
123 + description: Array of Zabbix preprocessing steps applied to the raw collection output.
124 + default_value: "[]"
125 + required: true
126 + group: Pipelines
127 + - name: vnode
128 + description: Virtual node name for host macro resolution.
129 + default_value: ""
130 + required: false
131 + group: General
132 + - name: lld
133 + description: Low-level discovery configuration for dynamic instance creation.
134 + default_value: ""
135 + required: false
136 + group: Discovery
137 + examples:
138 + folding:
139 + title: Config
140 + enabled: true
141 + list:
142 + - name: Command collection with JSONPath extraction
143 + description: Run a script and extract a metric using JSONPath.
144 + config: |
145 + jobs:
146 + - name: api_latency
147 + collection:
148 + type: command
149 + command: /usr/local/bin/get_api_stats.sh
150 + dependent_pipelines:
151 + - name: latency
152 + context: myapp.api.latency
153 + dimension: p99
154 + unit: milliseconds
155 + steps:
156 + - type: jsonpath
157 + params: "$.latency.p99"
158 + - name: SNMP collection
159 + description: Query an SNMP OID and chart the result.
160 + config: |
161 + jobs:
162 + - name: disk_usage
163 + vnode: my-switch
164 + collection:
165 + type: snmp
166 + snmp:
167 + target: "{HOST.IP}"
168 + oid: ".1.3.6.1.2.1.25.2.3.1.6"
169 + version: v2c
170 + community: public
171 + dependent_pipelines:
172 + - name: used
173 + context: zabbix.disk.used
174 + dimension: used
175 + unit: bytes
176 + steps:
177 + - type: snmp_get_value
178 + troubleshooting:
179 + problems:
180 + list: []
181 + alerts: []
182 + metrics:
183 + folding:
184 + title: Metrics
185 + enabled: false
186 + description: |
187 + ### Virtual Node Label Conventions
188 +
189 + When a job references a `vnode`, the module reads Zabbix host macros from the virtual node's **labels** using prefix conventions:
190 +
191 + | Label key | Zabbix macro | Description |
192 + |-----------|-------------|-------------|
193 + | `_address` | `{HOST.IP}`, `{HOST.CONN}` | IP address or DNS name of the host |
194 + | `_alias` | `{HOST.ALIAS}` | Human-readable host alias |
195 + | Other `_` prefixed | N/A | Reserved for future use |
196 +
197 + The `{HOST.NAME}`, `{HOST.HOST}`, and `{HOST.DNS}` macros are derived from the vnode hostname.
198 + availability: []
199 + scopes:
200 + - name: pipeline
201 + description: Metrics produced by each dependent preprocessing pipeline.
202 + labels:
203 + - name: zabbix_job
204 + description: Job name.
205 + - name: zabbix_pipeline
206 + description: Pipeline name.
207 + metrics:
208 + - name: zabbix.{context}
209 + description: User-defined metric (context is configured per pipeline)
210 + unit: varies
211 + chart_type: line
212 + dimensions:
213 + - name: "{dimension}"
214 + - name: job
215 + description: Per-job instance state tracking.
216 + labels:
217 + - name: zabbix_job
218 + description: Job name.
219 + metrics:
220 + - name: zabbix.{job}.state
221 + description: Zabbix job instance state
222 + unit: state
223 + chart_type: line
224 + dimensions:
225 + - name: ok
226 + - name: collect_failure
227 + - name: lld_failure
228 + - name: extraction_failure
229 + - name: dimension_failure
src/go/plugin/scripts.d/modules/zabbix/module.go new
+287
@@ -0,0 +1,287 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package zabbix
4 +
5 +import (
6 + "context"
7 + _ "embed"
8 + "fmt"
9 + "maps"
10 + "strings"
11 + "sync"
12 +
13 + "github.com/netdata/netdata/go/plugins/pkg/multipath"
14 + "github.com/netdata/netdata/go/plugins/pkg/pluginconfig"
15 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
16 + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/vnodes"
17 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/runtime"
18 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
19 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbix"
20 + zpre "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbixpreproc"
21 +)
22 +
23 +//go:embed config_schema.json
24 +var configSchema string
25 +
26 +func init() {
27 + module.Register("zabbix", module.Creator{
28 + JobConfigSchema: configSchema,
29 + Defaults: module.Defaults{AutoDetectionRetry: 60},
30 + Create: func() module.Module { return New() },
31 + Config: func() any { return &Config{} },
32 + })
33 +}
34 +
35 +// Config collects Zabbix jobs defined in a module configuration file.
36 +type Config struct {
37 + zabbix.JobConfig `yaml:",inline" json:",inline"`
38 + Jobs []zabbix.JobConfig `yaml:"jobs,omitempty" json:"jobs,omitempty"`
39 +}
40 +
41 +// Collector is the placeholder module.
42 +type Collector struct {
43 + module.Base
44 + Config `yaml:",inline" json:",inline"`
45 + jobs []zabbix.JobConfig
46 + proc *zpre.Preprocessor
47 + runner *runner
48 + emitter *pipelineEmitter
49 + charts *module.Charts
50 + vnodeInfo map[string]runtime.VnodeInfo
51 + missingVnode map[string]struct{}
52 +
53 + currentVnode *vnodes.VirtualNode
54 + vnodeMu sync.RWMutex
55 +}
56 +
57 +func New() *Collector { return &Collector{charts: &module.Charts{}} }
58 +
59 +func (c *Collector) Configuration() any { return &c.Config }
60 +
61 +func (c *Collector) Init(ctx context.Context) error {
62 + jobs, err := c.materializeJobs()
63 + if err != nil {
64 + return err
65 + }
66 + if c.charts == nil {
67 + c.charts = &module.Charts{}
68 + }
69 + c.jobs = jobs
70 + c.refreshVnodeInfo()
71 + for i := range c.jobs {
72 + if name := strings.TrimSpace(c.jobs[i].Vnode); name != "" {
73 + if info, ok := c.lookupVnode(name); !ok {
74 + return fmt.Errorf("job '%s': vnode '%s' not found", c.jobs[i].Name, name)
75 + } else {
76 + c.setCurrentVnode(info)
77 + }
78 + }
79 + }
80 + c.proc = acquirePreprocessor()
81 + for i := range c.jobs {
82 + c.jobs[i].Scheduler = schedulerName(c.jobs[i].Scheduler)
83 + }
84 + emitter, err := newPipelineEmitter(c.Logger, c.proc, c.charts, c.jobs)
85 + if err != nil {
86 + return err
87 + }
88 + c.emitter = emitter
89 + run, err := newRunner(ctx, c.Logger, c.jobs, c.proc, c.emitter, c.vnodeLookup)
90 + if err != nil {
91 + return err
92 + }
93 + c.runner = run
94 + return nil
95 +}
96 +
97 +func (c *Collector) materializeJobs() ([]zabbix.JobConfig, error) {
98 + var src []zabbix.JobConfig
99 + if len(c.Jobs) > 0 {
100 + src = c.Jobs
101 + } else {
102 + src = []zabbix.JobConfig{c.JobConfig}
103 + }
104 + seen := make(map[string]struct{}, len(src))
105 + jobs := make([]zabbix.JobConfig, len(src))
106 + for i := range src {
107 + job := src[i]
108 + if err := job.Validate(); err != nil {
109 + return nil, err
110 + }
111 + key := strings.ToLower(strings.TrimSpace(job.Name))
112 + if key == "" {
113 + return nil, fmt.Errorf("job name is required")
114 + }
115 + if _, exists := seen[key]; exists {
116 + return nil, fmt.Errorf("duplicate job name '%s'", job.Name)
117 + }
118 + seen[key] = struct{}{}
119 + jobs[i] = job
120 + }
121 + return jobs, nil
122 +}
123 +
124 +func (c *Collector) Check(context.Context) error { return nil }
125 +func (c *Collector) Charts() *module.Charts {
126 + return c.charts
127 +}
128 +func (c *Collector) Collect(context.Context) map[string]int64 {
129 + metrics := make(map[string]int64)
130 + if c.runner != nil {
131 + for k, v := range c.runner.Collect() {
132 + metrics[k] += v
133 + }
134 + }
135 + if c.emitter != nil {
136 + for k, v := range c.emitter.Flush() {
137 + metrics[k] = v
138 + }
139 + }
140 + if len(metrics) == 0 {
141 + return nil
142 + }
143 + return metrics
144 +}
145 +
146 +func (c *Collector) Cleanup(context.Context) {
147 + if c.runner != nil {
148 + c.runner.Stop()
149 + }
150 + if c.emitter != nil {
151 + _ = c.emitter.Close()
152 + }
153 +}
154 +
155 +func schedulerName(name string) string {
156 + if strings.TrimSpace(name) == "" {
157 + return "default"
158 + }
159 + return name
160 +}
161 +
162 +func (c *Collector) vnodeLookup(sp spec.JobSpec) runtime.VnodeInfo {
163 + if info, ok := c.lookupVnode(sp.Vnode); ok {
164 + c.setCurrentVnode(info)
165 + return cloneVnodeInfo(info)
166 + }
167 + if strings.TrimSpace(sp.Vnode) != "" {
168 + c.warnMissingVnode(sp.Vnode)
169 + }
170 + return runtime.VnodeInfo{Hostname: sp.Vnode}
171 +}
172 +
173 +func (c *Collector) lookupVnode(name string) (runtime.VnodeInfo, bool) {
174 + if c.vnodeInfo == nil {
175 + return runtime.VnodeInfo{}, false
176 + }
177 + info, ok := c.vnodeInfo[strings.ToLower(strings.TrimSpace(name))]
178 + return cloneVnodeInfo(info), ok
179 +}
180 +
181 +func (c *Collector) refreshVnodeInfo() {
182 + configDirs := pluginconfig.ConfigDir()
183 + if len(configDirs) == 0 {
184 + c.vnodeInfo = nil
185 + c.missingVnode = nil
186 + return
187 + }
188 + path, err := configDirs.Find("vnodes")
189 + if err != nil {
190 + if !multipath.IsNotFound(err) {
191 + c.Warningf("zabbix: failed to locate vnodes directory: %v", err)
192 + }
193 + c.vnodeInfo = nil
194 + c.missingVnode = nil
195 + return
196 + }
197 + registry := vnodes.Load(path)
198 + if len(registry) == 0 {
199 + c.vnodeInfo = nil
200 + c.missingVnode = nil
201 + return
202 + }
203 + info := make(map[string]runtime.VnodeInfo, len(registry)*3)
204 + for key, vnode := range registry {
205 + if vnode == nil {
206 + continue
207 + }
208 + converted := runtime.VnodeInfo{
209 + Hostname: firstNonEmpty(vnode.Hostname, vnode.Name, key),
210 + Labels: maps.Clone(vnode.Labels),
211 + }
212 + if converted.Labels == nil {
213 + converted.Labels = make(map[string]string)
214 + }
215 + if _, ok := converted.Labels["_alias"]; !ok {
216 + converted.Labels["_alias"] = firstNonEmpty(vnode.Name, key)
217 + }
218 + if _, ok := converted.Labels["_address"]; !ok {
219 + if v := vnode.Labels["_net_default_iface_ip"]; v != "" {
220 + converted.Labels["_address"] = v
221 + }
222 + }
223 + for _, alias := range []string{key, vnode.Hostname, vnode.Name, vnode.GUID} {
224 + if strings.TrimSpace(alias) == "" {
225 + continue
226 + }
227 + info[strings.ToLower(alias)] = cloneVnodeInfo(converted)
228 + }
229 + }
230 + c.vnodeInfo = info
231 + c.missingVnode = make(map[string]struct{})
232 +}
233 +
234 +func (c *Collector) setCurrentVnode(info runtime.VnodeInfo) {
235 + node := &vnodes.VirtualNode{
236 + Name: info.Hostname,
237 + Hostname: info.Hostname,
238 + Labels: maps.Clone(info.Labels),
239 + }
240 + c.vnodeMu.Lock()
241 + c.currentVnode = node
242 + c.vnodeMu.Unlock()
243 +}
244 +
245 +func (c *Collector) VirtualNode() *vnodes.VirtualNode {
246 + c.vnodeMu.RLock()
247 + defer c.vnodeMu.RUnlock()
248 + if c.currentVnode == nil {
249 + return nil
250 + }
251 + return c.currentVnode.Copy()
252 +}
253 +
254 +func (c *Collector) warnMissingVnode(name string) {
255 + name = strings.TrimSpace(name)
256 + if name == "" {
257 + return
258 + }
259 + if c.missingVnode == nil {
260 + c.missingVnode = make(map[string]struct{})
261 + }
262 + key := strings.ToLower(name)
263 + if _, seen := c.missingVnode[key]; seen {
264 + return
265 + }
266 + c.missingVnode[key] = struct{}{}
267 + c.Warningf("zabbix: vnode '%s' not found; macros fall back to literal hostname", name)
268 +}
269 +
270 +func cloneVnodeInfo(src runtime.VnodeInfo) runtime.VnodeInfo {
271 + clone := src
272 + if len(src.Labels) > 0 {
273 + clone.Labels = maps.Clone(src.Labels)
274 + } else {
275 + clone.Labels = nil
276 + }
277 + return clone
278 +}
279 +
280 +func firstNonEmpty(values ...string) string {
281 + for _, v := range values {
282 + if strings.TrimSpace(v) != "" {
283 + return v
284 + }
285 + }
286 + return ""
287 +}
src/go/plugin/scripts.d/modules/zabbix/preprocessor.go new
+35
@@ -0,0 +1,35 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package zabbix
4 +
5 +import (
6 + "fmt"
7 + "os"
8 + "strings"
9 + "sync"
10 + "time"
11 +
12 + zpre "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbixpreproc"
13 +)
14 +
15 +var (
16 + preprocOnce sync.Once
17 + sharedProc *zpre.Preprocessor
18 +)
19 +
20 +func acquirePreprocessor() *zpre.Preprocessor {
21 + preprocOnce.Do(func() {
22 + ns := buildPreprocessorNamespace()
23 + sharedProc = zpre.NewPreprocessor(ns)
24 + })
25 + return sharedProc
26 +}
27 +
28 +func buildPreprocessorNamespace() string {
29 + if host, err := os.Hostname(); err == nil {
30 + if trimmed := strings.TrimSpace(host); trimmed != "" {
31 + return fmt.Sprintf("zabbix-%s", trimmed)
32 + }
33 + }
34 + return fmt.Sprintf("zabbix-%d", time.Now().UnixNano())
35 +}
src/go/plugin/scripts.d/modules/zabbix/runtime.go new
+41
@@ -0,0 +1,41 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package zabbix
4 +
5 +import (
6 + "context"
7 +
8 + "github.com/netdata/netdata/go/plugins/logger"
9 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/runtime"
10 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
11 + pkgzabbix "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbix"
12 + zpre "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbixpreproc"
13 +)
14 +
15 +// runner is a thin wrapper that allows the module to delegate lifecycle control
16 +// to the shared pkg/zabbix runtime while keeping module-specific wiring simple.
17 +type runner struct {
18 + rt *pkgzabbix.Runtime
19 +}
20 +
21 +func newRunner(_ context.Context, log *logger.Logger, jobs []pkgzabbix.JobConfig, proc *zpre.Preprocessor, emitter runtime.ResultEmitter, vnodeLookup func(spec.JobSpec) runtime.VnodeInfo) (*runner, error) {
22 + rt, err := pkgzabbix.NewRuntime(jobs, proc, log, emitter, vnodeLookup)
23 + if err != nil {
24 + return nil, err
25 + }
26 + return &runner{rt: rt}, nil
27 +}
28 +
29 +func (r *runner) Collect() map[string]int64 {
30 + if r == nil || r.rt == nil {
31 + return nil
32 + }
33 + return r.rt.Collect()
34 +}
35 +
36 +func (r *runner) Stop() {
37 + if r == nil || r.rt == nil {
38 + return
39 + }
40 + r.rt.Stop()
41 +}
src/go/plugin/scripts.d/pkg/config/defaults.go new
+80
@@ -0,0 +1,80 @@
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 new
+51
@@ -0,0 +1,51 @@
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 new
+30
@@ -0,0 +1,30 @@
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.go new
+271
@@ -0,0 +1,271 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package output
4 +
5 +import (
6 + "math"
7 + "strconv"
8 + "strings"
9 + "unicode"
10 +)
11 +
12 +// ParsedOutput represents the structured form of a Nagios plugin output.
13 +type ParsedOutput struct {
14 + StatusLine string
15 + LongOutput string
16 + Perfdata []PerfDatum
17 +}
18 +
19 +// PerfDatum represents a single perfdata entry.
20 +type PerfDatum struct {
21 + Label string
22 + Unit string
23 + Value float64
24 + Warn *ThresholdRange
25 + Crit *ThresholdRange
26 + Min *float64
27 + Max *float64
28 +}
29 +
30 +// ThresholdRange captures the Nagios range grammar semantics.
31 +type ThresholdRange struct {
32 + Raw string
33 + Inclusive bool
34 + Low *float64
35 + High *float64
36 +}
37 +
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 +
43 +// Parse converts raw plugin output into status, long output, and perfdata sections.
44 +func Parse(raw []byte) ParsedOutput {
45 + text := strings.ReplaceAll(string(raw), "\r\n", "\n")
46 + text = strings.TrimSpace(text)
47 + if text == "" {
48 + return ParsedOutput{}
49 + }
50 +
51 + lines := strings.Split(text, "\n")
52 + var perfSections []string
53 +
54 + firstLine := lines[0]
55 + if idx := strings.Index(firstLine, "|"); idx >= 0 {
56 + perfSections = append(perfSections, firstLine[idx+1:])
57 + firstLine = firstLine[:idx]
58 + }
59 +
60 + status := strings.TrimSpace(firstLine)
61 +
62 + var longLines []string
63 + if len(lines) > 1 {
64 + for _, line := range lines[1:] {
65 + trimmed := line
66 + if idx := strings.Index(trimmed, "|"); idx >= 0 {
67 + perfSections = append(perfSections, trimmed[idx+1:])
68 + trimmed = trimmed[:idx]
69 + }
70 + longLines = append(longLines, strings.TrimRightFunc(trimmed, unicode.IsSpace))
71 + }
72 + }
73 +
74 + perfTokens := tokenizePerfdata(strings.Join(perfSections, " "))
75 +
76 + var perfdata []PerfDatum
77 + for _, token := range perfTokens {
78 + if datum, ok := parsePerfToken(token); ok {
79 + perfdata = append(perfdata, datum)
80 + }
81 + }
82 +
83 + longOutput := strings.Join(longLines, "\n")
84 + longOutput = strings.TrimRightFunc(longOutput, unicode.IsSpace)
85 +
86 + return ParsedOutput{
87 + StatusLine: status,
88 + LongOutput: longOutput,
89 + Perfdata: perfdata,
90 + }
91 +}
92 +
93 +func tokenizePerfdata(s string) []string {
94 + var tokens []string
95 + var cur strings.Builder
96 + var quote rune
97 +
98 + flush := func() {
99 + if cur.Len() > 0 {
100 + tokens = append(tokens, cur.String())
101 + cur.Reset()
102 + }
103 + }
104 +
105 + for _, r := range s {
106 + switch {
107 + case quote != 0:
108 + if r == quote {
109 + quote = 0
110 + } else {
111 + cur.WriteRune(r)
112 + }
113 + case r == '\'' || r == '"':
114 + quote = r
115 + case unicode.IsSpace(r):
116 + flush()
117 + default:
118 + cur.WriteRune(r)
119 + }
120 + }
121 + flush()
122 +
123 + return tokens
124 +}
125 +
126 +func parsePerfToken(token string) (PerfDatum, bool) {
127 + parts := strings.SplitN(token, "=", 2)
128 + if len(parts) != 2 {
129 + return PerfDatum{}, false
130 + }
131 +
132 + label := strings.Trim(parts[0], "\"'")
133 + if label == "" {
134 + return PerfDatum{}, false
135 + }
136 +
137 + fields := strings.SplitN(parts[1], ";", 5)
138 + valueStr := fields[0]
139 + warnStr, critStr, minStr, maxStr := getField(fields, 1), getField(fields, 2), getField(fields, 3), getField(fields, 4)
140 +
141 + val, unit, ok := parseValueUnit(valueStr)
142 + if !ok {
143 + return PerfDatum{}, false
144 + }
145 +
146 + datum := PerfDatum{
147 + Label: label,
148 + Unit: unit,
149 + Value: val,
150 + Warn: parseRange(warnStr),
151 + Crit: parseRange(critStr),
152 + Min: parseFloatPtr(minStr),
153 + Max: parseFloatPtr(maxStr),
154 + }
155 +
156 + return datum, true
157 +}
158 +
159 +func getField(fields []string, idx int) string {
160 + if idx >= len(fields) {
161 + return ""
162 + }
163 + return fields[idx]
164 +}
165 +
166 +func parseValueUnit(value string) (float64, string, bool) {
167 + if value == "" {
168 + return 0, "", false
169 + }
170 + i := 0
171 + for i < len(value) {
172 + if isNumberChar(value[i]) {
173 + i++
174 + continue
175 + }
176 + break
177 + }
178 + numStr := value[:i]
179 + unit := value[i:]
180 + if numStr == "" {
181 + return 0, unit, false
182 + }
183 + v, err := strconv.ParseFloat(numStr, 64)
184 + if err != nil {
185 + return 0, unit, false
186 + }
187 + return v, unit, true
188 +}
189 +
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 +
202 +func parseRange(val string) *ThresholdRange {
203 + s := strings.TrimSpace(val)
204 + if s == "" || strings.EqualFold(s, "u") {
205 + return nil
206 + }
207 + rng := &ThresholdRange{Raw: s}
208 + if strings.HasPrefix(s, "@") {
209 + rng.Inclusive = true
210 + s = strings.TrimSpace(s[1:])
211 + }
212 + if s == "" {
213 + return nil
214 + }
215 + if strings.Contains(s, ":") {
216 + parts := strings.SplitN(s, ":", 2)
217 + start := strings.TrimSpace(parts[0])
218 + end := strings.TrimSpace(parts[1])
219 + if start == "" {
220 + zero := 0.0
221 + rng.Low = &zero
222 + } else if start != "~" {
223 + if v, ok := parseRangeNumber(start); ok {
224 + rng.Low = &v
225 + } else {
226 + return nil
227 + }
228 + }
229 + if end != "" && end != "~" {
230 + if v, ok := parseRangeNumber(end); ok {
231 + rng.High = &v
232 + } else {
233 + return nil
234 + }
235 + }
236 + } else {
237 + v, ok := parseRangeNumber(s)
238 + if !ok {
239 + return nil
240 + }
241 + zero := 0.0
242 + rng.Low = &zero
243 + rng.High = &v
244 + }
245 + return rng
246 +}
247 +
248 +func parseRangeNumber(val string) (float64, bool) {
249 + if val == "" {
250 + return 0, false
251 + }
252 + switch strings.ToLower(val) {
253 + case "inf", "+inf", "infinity":
254 + return math.Inf(1), true
255 + case "-inf":
256 + return math.Inf(-1), true
257 + }
258 + v, err := strconv.ParseFloat(val, 64)
259 + if err != nil {
260 + return 0, false
261 + }
262 + return v, true
263 +}
264 +
265 +func isNumberChar(b byte) bool {
266 + switch b {
267 + case '+', '-', '.', 'e', 'E':
268 + return true
269 + }
270 + return b >= '0' && b <= '9'
271 +}
src/go/plugin/scripts.d/pkg/output/parser_test.go new
+92
@@ -0,0 +1,92 @@
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 new
+81
@@ -0,0 +1,81 @@
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 new
+251
@@ -0,0 +1,251 @@
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 new
+215
@@ -0,0 +1,215 @@
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 new
+54
@@ -0,0 +1,54 @@
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 new
+12
@@ -0,0 +1,12 @@
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 new
+153
@@ -0,0 +1,153 @@
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 new
+66
@@ -0,0 +1,66 @@
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 new
+860
@@ -0,0 +1,860 @@
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 + for _, js := range s.jobs {
280 + if js.timer != nil {
281 + js.timer.Stop()
282 + js.timer = nil
283 + }
284 + }
285 + s.executor.Stop()
286 + s.wg.Wait()
287 + s.ctx = nil
288 + s.cancel = nil
289 +}
290 +
291 +func (s *Scheduler) run() {
292 + defer s.wg.Done()
293 +
294 + for {
295 + select {
296 + case <-s.ctx.Done():
297 + return
298 + case jobID := <-s.timerCh:
299 + s.handleTimer(jobID)
300 + case res, ok := <-s.executor.Results():
301 + if !ok {
302 + return
303 + }
304 + s.handleResult(res)
305 + }
306 + }
307 +}
308 +
309 +func (s *Scheduler) handleTimer(jobID string) {
310 + s.jobMu.Lock()
311 + js, ok := s.jobs[jobID]
312 + if !ok {
313 + s.jobMu.Unlock()
314 + return
315 + }
316 + now := time.Now()
317 + if js.period != nil && !js.period.Allows(now) {
318 + js.periodSkipped = true
319 + nextAllowed := js.period.NextAllowed(now)
320 + if nextAllowed.IsZero() {
321 + nextAllowed = now.Add(js.checkInterval)
322 + }
323 + js.nextAnniversary = nextAllowed
324 + js.nextRun = s.applyJitter(nextAllowed, js.jitterRange)
325 + s.jobMu.Unlock()
326 + s.armTimer(js)
327 + return
328 + }
329 + js.periodSkipped = false
330 + js.nextAnniversary = s.advanceAnniversary(js.nextAnniversary, js.checkInterval, now)
331 + js.nextRun = s.applyJitter(js.nextAnniversary, js.jitterRange)
332 + s.jobMu.Unlock()
333 +
334 + s.armTimer(js)
335 +
336 + if queued, err := s.executor.Enqueue(js.runtime); err != nil {
337 + if s.log != nil {
338 + s.log.Errorf("nagios executor enqueue failed: %v", err)
339 + }
340 + } else if queued {
341 + s.counters.started.Add(1)
342 + } else {
343 + s.jobMu.Lock()
344 + js.skipped++
345 + s.jobMu.Unlock()
346 + s.counters.skipped.Add(1)
347 + }
348 +}
349 +
350 +func (s *Scheduler) handleResult(res ExecutionResult) {
351 + parsed := output.Parse(res.Output)
352 + if s.log != nil {
353 + s.log.Debugf("nagios: parsed perfdata entries=%d for job=%s", len(parsed.Perfdata), res.Job.Spec.Name)
354 + }
355 + res.Parsed = parsed
356 +
357 + s.jobMu.Lock()
358 + js, ok := s.jobs[res.Job.ID]
359 + var jobSpec spec.JobSpec
360 + var snapshot JobSnapshot
361 + var scheduleRetry bool
362 + var measured bool
363 + if ok {
364 + jobSpec = js.runtime.Spec
365 + js.running = false
366 + js.lastDuration = res.Duration
367 + js.lastCPU = res.Usage.User + res.Usage.System
368 + measured = res.Usage.User != 0 || res.Usage.System != 0 || res.Duration == 0
369 + js.cpuMeasured = measured
370 + js.lastRSS = res.Usage.MaxRSSBytes
371 + js.lastDiskRead = res.Usage.ReadBytes
372 + js.lastDiskWrite = res.Usage.WriteBytes
373 + js.statusLine = parsed.StatusLine
374 + js.longOutput = parsed.LongOutput
375 + js.updatePerfdata(parsed.Perfdata)
376 + prevHard := js.hardState
377 + js.recordResult(res.State)
378 + js.periodSkipped = false
379 + if js.retrying {
380 + base := time.Now()
381 + js.nextAnniversary = s.advanceAnniversary(base, js.retryInterval, base)
382 + js.nextRun = s.applyJitter(js.nextAnniversary, js.jitterRange)
383 + scheduleRetry = true
384 + }
385 + snapshot = JobSnapshot{
386 + HardState: js.hardState,
387 + SoftState: js.softState,
388 + PrevHardState: prevHard,
389 + Attempts: js.softAttempts,
390 + Duration: res.Duration,
391 + Timestamp: res.End,
392 + Output: parsed,
393 + }
394 + }
395 + s.jobMu.Unlock()
396 +
397 + if ok {
398 + s.counters.finished.Add(1)
399 + if scheduleRetry {
400 + s.armTimer(js)
401 + }
402 + if !measured && res.Duration > 0 && s.log != nil {
403 + s.log.Debugf("nagios job %s completed without CPU usage data; emitting zero", res.Job.Spec.Name)
404 + }
405 + s.registerPerfdataCharts(jobSpec, parsed.Perfdata, js.registerPerf)
406 + if js.emitter != nil {
407 + js.emitter.Emit(res.Job, res, snapshot)
408 + }
409 + }
410 +}
411 +
412 +func (s *Scheduler) armTimer(js *jobState) {
413 + if s.ctx == nil {
414 + return
415 + }
416 + delay := time.Until(js.nextRun)
417 + if delay < 0 {
418 + delay = 0
419 + }
420 + jobID := js.runtime.ID
421 + ctx := s.ctx // capture to avoid racing with Stop() nilling s.ctx
422 +
423 + if js.timer == nil {
424 + js.timer = time.AfterFunc(delay, func() {
425 + select {
426 + case s.timerCh <- jobID:
427 + case <-ctx.Done():
428 + }
429 + })
430 + } else {
431 + js.timer.Reset(delay)
432 + }
433 +}
434 +
435 +func (s *Scheduler) runJob(ctx context.Context, job JobRuntime) ExecutionResult {
436 + res := ExecutionResult{Job: job, Start: time.Now()}
437 + s.markRunning(job.ID, true)
438 + defer s.markRunning(job.ID, false)
439 +
440 + timeout := job.Spec.Timeout
441 + if timeout <= 0 {
442 + timeout = time.Minute
443 + }
444 +
445 + var output []byte
446 + var cmdStr string
447 + var usage ndexec.ResourceUsage
448 + var err error
449 +
450 + js, _ := s.getJobState(job.ID)
451 + runner := JobRunner(nil)
452 + if js != nil {
453 + runner = js.runner
454 + }
455 + if runner != nil {
456 + runCtx, cancel := context.WithTimeout(ctx, timeout)
457 + output, cmdStr, usage, err = runner(runCtx, job, timeout)
458 + cancel()
459 + } else {
460 + macroCtx := s.buildMacroContext(job, js)
461 + macroSet := BuildMacroSet(macroCtx)
462 + args := macroSet.CommandArgs
463 + if len(args) == 0 {
464 + args = job.Spec.Args
465 + }
466 + env := s.buildEnv(job.Spec.Environment, macroSet.Env)
467 + opts := ndexec.RunOptions{Env: env}
468 + if dir := job.Spec.WorkingDirectory; dir != "" {
469 + opts.Dir = dir
470 + }
471 + output, cmdStr, usage, err = ndexec.RunUnprivilegedWithOptionsUsage(s.log, timeout, opts, job.Spec.Plugin, args...)
472 + }
473 +
474 + if s.log != nil {
475 + s.log.Debugf("nagios: raw plugin output job=%s output=%q", job.Spec.Name, string(output))
476 + }
477 + res.Output = output
478 + res.Err = err
479 + res.Command = cmdStr
480 + res.ExitCode = exitCodeFromError(err)
481 + res.End = time.Now()
482 + res.Duration = res.End.Sub(res.Start)
483 + res.State = s.stateFromResult(job, res.ExitCode, err)
484 + res.Usage = usage
485 +
486 + return res
487 +}
488 +
489 +func (s *Scheduler) buildMacroContext(job JobRuntime, js *jobState) MacroContext {
490 + state := StateInfo{
491 + ServiceState: s.currentState(job.ID),
492 + ServiceAttempt: s.currentAttempt(job.ID),
493 + ServiceMaxAttempts: maxInt(job.Spec.MaxCheckAttempts, 1),
494 + HostState: "UP",
495 + HostStateID: "0",
496 + }
497 + macros := make(map[string]string, len(s.userMacros))
498 + for k, v := range s.userMacros {
499 + macros[k] = v
500 + }
501 + if js != nil && len(js.userMacros) > 0 {
502 + for k, v := range js.userMacros {
503 + macros[k] = v
504 + }
505 + }
506 + return MacroContext{
507 + Job: job.Spec,
508 + UserMacros: macros,
509 + Vnode: job.Vnode,
510 + State: state,
511 + }
512 +}
513 +
514 +func (s *Scheduler) markRunning(jobID string, running bool) {
515 + s.jobMu.Lock()
516 + if js, ok := s.jobs[jobID]; ok {
517 + js.running = running
518 + }
519 + s.jobMu.Unlock()
520 +}
521 +
522 +func (s *Scheduler) getJobState(jobID string) (*jobState, bool) {
523 + s.jobMu.RLock()
524 + defer s.jobMu.RUnlock()
525 + js, ok := s.jobs[jobID]
526 + return js, ok
527 +}
528 +
529 +func buildJobID(sp spec.JobSpec, idx int) string {
530 + vnode := sp.Vnode
531 + if vnode == "" {
532 + vnode = "local"
533 + }
534 + return fmt.Sprintf("%s@%s#%d", sp.Name, vnode, idx)
535 +}
536 +
537 +func (s *Scheduler) CollectMetrics() map[string]int64 {
538 + metrics := make(map[string]int64)
539 + stats := s.executor.Stats()
540 + metrics[charts.SchedulerMetricKey(s.schedulerName, charts.ChartSchedulerJobs, "running")] = int64(stats.Executing)
541 + metrics[charts.SchedulerMetricKey(s.schedulerName, charts.ChartSchedulerJobs, "queued")] = int64(stats.QueueDepth)
542 + metrics[charts.SchedulerMetricKey(s.schedulerName, charts.ChartSchedulerJobs, "scheduled")] = int64(s.scheduledCount())
543 + metrics[charts.SchedulerMetricKey(s.schedulerName, charts.ChartSchedulerRate, "started")] = int64(s.counters.started.Load())
544 + metrics[charts.SchedulerMetricKey(s.schedulerName, charts.ChartSchedulerRate, "finished")] = int64(s.counters.finished.Load())
545 + metrics[charts.SchedulerMetricKey(s.schedulerName, charts.ChartSchedulerRate, "skipped")] = int64(s.counters.skipped.Load())
546 + metrics[charts.SchedulerMetricKey(s.schedulerName, charts.ChartSchedulerNext, "next")] = s.nextRunDelay().Nanoseconds()
547 +
548 + s.jobMu.RLock()
549 + defer s.jobMu.RUnlock()
550 + for _, js := range s.jobs {
551 + id := js.identity
552 + metrics[id.TelemetryMetricID(charts.TelemetryStateMetric, "ok")] = boolToInt(strings.EqualFold(js.state, "OK"))
553 + metrics[id.TelemetryMetricID(charts.TelemetryStateMetric, "warning")] = boolToInt(strings.EqualFold(js.state, "WARNING"))
554 + metrics[id.TelemetryMetricID(charts.TelemetryStateMetric, "critical")] = boolToInt(strings.EqualFold(js.state, "CRITICAL"))
555 + metrics[id.TelemetryMetricID(charts.TelemetryStateMetric, "unknown")] = boolToInt(strings.EqualFold(js.state, "UNKNOWN"))
556 + metrics[id.TelemetryMetricID(charts.TelemetryStateMetric, "attempt")] = int64(js.currentAttempt())
557 + metrics[id.TelemetryMetricID(charts.TelemetryStateMetric, "max_attempts")] = int64(js.maxAttempts)
558 + metrics[id.TelemetryMetricID(charts.TelemetryRuntimeMetric, "running")] = boolToInt(js.running)
559 + metrics[id.TelemetryMetricID(charts.TelemetryRuntimeMetric, "retrying")] = boolToInt(js.retrying)
560 + missingCPU := !js.cpuMeasured && js.lastDuration > 0
561 + metrics[id.TelemetryMetricID(charts.TelemetryRuntimeMetric, "skipped")] = boolToInt(js.periodSkipped)
562 + metrics[id.TelemetryMetricID(charts.TelemetryRuntimeMetric, "cpu_missing")] = boolToInt(missingCPU)
563 + metrics[id.TelemetryMetricID(charts.TelemetryLatencyMetric, "duration")] = js.lastDuration.Nanoseconds()
564 + cpuNs := js.lastCPU.Nanoseconds()
565 + metrics[id.TelemetryMetricID(charts.TelemetryCPUMetric, "cpu_time")] = cpuNs
566 + metrics[id.TelemetryMetricID(charts.TelemetryMemoryMetric, "rss")] = js.lastRSS
567 + metrics[id.TelemetryMetricID(charts.TelemetryDiskMetric, "read")] = js.lastDiskRead
568 + metrics[id.TelemetryMetricID(charts.TelemetryDiskMetric, "write")] = js.lastDiskWrite
569 +
570 + if len(js.perfdata) > 0 {
571 + for labelID, datum := range js.perfdata {
572 + scale := units.NewScale(datum.Unit)
573 + metrics[id.PerfdataMetricID(labelID, "value")] = scale.Apply(datum.Value)
574 + if datum.Min != nil {
575 + metrics[id.PerfdataMetricID(labelID, "min")] = scale.Apply(*datum.Min)
576 + }
577 + if datum.Max != nil {
578 + metrics[id.PerfdataMetricID(labelID, "max")] = scale.Apply(*datum.Max)
579 + }
580 + s.setRangeMetrics(metrics, id, labelID, "warn", datum.Warn, scale)
581 + s.setRangeMetrics(metrics, id, labelID, "crit", datum.Crit, scale)
582 + }
583 + }
584 + }
585 +
586 + return metrics
587 +}
588 +
589 +func (s *Scheduler) nextRunDelay() time.Duration {
590 + min := 24 * time.Hour
591 + if len(s.jobs) == 0 {
592 + return 0
593 + }
594 + s.jobMu.RLock()
595 + defer s.jobMu.RUnlock()
596 + for _, js := range s.jobs {
597 + delta := time.Until(js.nextRun)
598 + if delta < 0 {
599 + delta = 0
600 + }
601 + if delta < min {
602 + min = delta
603 + }
604 + }
605 + return min
606 +}
607 +
608 +func boolToInt(v bool) int64 {
609 + if v {
610 + return 1
611 + }
612 + return 0
613 +}
614 +
615 +func (s *Scheduler) scheduledCount() int {
616 + s.jobMu.RLock()
617 + defer s.jobMu.RUnlock()
618 + return len(s.jobs)
619 +}
620 +
621 +func (s *Scheduler) advanceAnniversary(current time.Time, interval time.Duration, now time.Time) time.Time {
622 + interval = intervalOrDefault(interval)
623 + if current.IsZero() {
624 + current = now
625 + }
626 + next := current.Add(interval)
627 + if !next.After(now) {
628 + diff := now.Sub(next)
629 + steps := diff/interval + 1
630 + next = next.Add(time.Duration(steps) * interval)
631 + }
632 + return next
633 +}
634 +
635 +func (s *Scheduler) applyJitter(base time.Time, jitter time.Duration) time.Time {
636 + if jitter <= 0 {
637 + return base
638 + }
639 + if s.rand == nil {
640 + return base
641 + }
642 + s.randMu.Lock()
643 + val := s.rand.Float64()
644 + s.randMu.Unlock()
645 + if val <= 0 {
646 + return base
647 + }
648 + return base.Add(time.Duration(val * float64(jitter)))
649 +}
650 +
651 +func (s *Scheduler) setRangeMetrics(metrics map[string]int64, id charts.JobIdentity, labelID, kind string, rng *output.ThresholdRange, scale units.Scale) {
652 + definedKey := id.PerfdataMetricID(labelID, kind+"_defined")
653 + inclusiveKey := id.PerfdataMetricID(labelID, kind+"_inclusive")
654 + lowKey := id.PerfdataMetricID(labelID, kind+"_low")
655 + highKey := id.PerfdataMetricID(labelID, kind+"_high")
656 + lowDefinedKey := id.PerfdataMetricID(labelID, kind+"_low_defined")
657 + highDefinedKey := id.PerfdataMetricID(labelID, kind+"_high_defined")
658 + if rng == nil {
659 + metrics[definedKey] = 0
660 + metrics[inclusiveKey] = 0
661 + metrics[lowKey] = 0
662 + metrics[highKey] = 0
663 + metrics[lowDefinedKey] = 0
664 + metrics[highDefinedKey] = 0
665 + return
666 + }
667 + metrics[definedKey] = 1
668 + metrics[inclusiveKey] = boolToInt(rng.Inclusive)
669 + if v, ok := rangeBoundMetric(scale, rng.Low); ok {
670 + metrics[lowKey] = v
671 + metrics[lowDefinedKey] = 1
672 + } else {
673 + metrics[lowKey] = 0
674 + metrics[lowDefinedKey] = 0
675 + }
676 + if v, ok := rangeBoundMetric(scale, rng.High); ok {
677 + metrics[highKey] = v
678 + metrics[highDefinedKey] = 1
679 + } else {
680 + metrics[highKey] = 0
681 + metrics[highDefinedKey] = 0
682 + }
683 +}
684 +
685 +func rangeBoundMetric(scale units.Scale, val *float64) (int64, bool) {
686 + if val == nil {
687 + return 0, false
688 + }
689 + if math.IsNaN(*val) || math.IsInf(*val, 0) {
690 + return 0, false
691 + }
692 + return scale.Apply(*val), true
693 +}
694 +
695 +func (s *Scheduler) registerPerfdataCharts(job spec.JobSpec, perf []output.PerfDatum, register func(spec.JobSpec, output.PerfDatum)) {
696 + if register == nil {
697 + return
698 + }
699 + for _, datum := range perf {
700 + label := strings.TrimSpace(datum.Label)
701 + if label == "" {
702 + continue
703 + }
704 + register(job, datum)
705 + }
706 +}
707 +
708 +func exitCodeFromError(err error) int {
709 + if err == nil {
710 + return 0
711 + }
712 + var exitErr interface{ ExitCode() int }
713 + if errors.As(err, &exitErr) {
714 + return exitErr.ExitCode()
715 + }
716 + return -1
717 +}
718 +
719 +func (s *Scheduler) stateFromResult(job JobRuntime, exitCode int, err error) string {
720 + if err == nil {
721 + return "OK"
722 + }
723 + if errors.Is(err, context.DeadlineExceeded) {
724 + return normalizeState(job.Spec.TimeoutState)
725 + }
726 + switch exitCode {
727 + case 0:
728 + return "OK"
729 + case 1:
730 + return "WARNING"
731 + case 2:
732 + return "CRITICAL"
733 + case 3:
734 + return "UNKNOWN"
735 + default:
736 + return "UNKNOWN"
737 + }
738 +}
739 +
740 +func intervalOrDefault(d time.Duration) time.Duration {
741 + if d <= 0 {
742 + return time.Minute
743 + }
744 + return d
745 +}
746 +
747 +func maxInt(a, b int) int {
748 + if a > b {
749 + return a
750 + }
751 + return b
752 +}
753 +
754 +func normalizeState(state string) string {
755 + s := strings.ToUpper(state)
756 + switch s {
757 + case "OK", "WARNING", "CRITICAL", "UNKNOWN":
758 + return s
759 + default:
760 + return "UNKNOWN"
761 + }
762 +}
763 +
764 +func (s *Scheduler) currentState(jobID string) string {
765 + s.jobMu.RLock()
766 + defer s.jobMu.RUnlock()
767 + if js, ok := s.jobs[jobID]; ok && js.state != "" {
768 + return js.state
769 + }
770 + return "UNKNOWN"
771 +}
772 +
773 +func (s *Scheduler) currentAttempt(jobID string) int {
774 + s.jobMu.RLock()
775 + defer s.jobMu.RUnlock()
776 + if js, ok := s.jobs[jobID]; ok {
777 + return js.currentAttempt()
778 + }
779 + return 1
780 +}
781 +
782 +func (s *Scheduler) buildEnv(jobEnv map[string]string, macroEnv map[string]string) []string {
783 + merged := make(map[string]string)
784 + for _, kv := range os.Environ() {
785 + if eq := strings.Index(kv, "="); eq > 0 {
786 + merged[kv[:eq]] = kv[eq+1:]
787 + }
788 + }
789 + for k, v := range jobEnv {
790 + merged[k] = replaceMacro(v, macroEnv)
791 + }
792 + for k, v := range macroEnv {
793 + merged[k] = v
794 + }
795 + keys := make([]string, 0, len(merged))
796 + for k := range merged {
797 + keys = append(keys, k)
798 + }
799 + sort.Strings(keys)
800 + result := make([]string, 0, len(keys))
801 + for _, k := range keys {
802 + result = append(result, fmt.Sprintf("%s=%s", k, merged[k]))
803 + }
804 + return result
805 +}
806 +
807 +func (js *jobState) recordResult(state string) {
808 + state = normalizeState(state)
809 + js.softState = state
810 + if state == "OK" {
811 + js.softAttempts = 0
812 + js.hardState = "OK"
813 + js.retrying = false
814 + } else {
815 + js.softAttempts++
816 + if js.softAttempts >= js.maxAttempts {
817 + js.hardState = state
818 + js.retrying = false
819 + } else {
820 + js.retrying = true
821 + }
822 + }
823 + js.state = state
824 +}
825 +
826 +func (js *jobState) updatePerfdata(perf []output.PerfDatum) {
827 + if len(perf) == 0 {
828 + js.perfdata = nil
829 + return
830 + }
831 + mp := make(map[string]output.PerfDatum, len(perf))
832 + for _, datum := range perf {
833 + labelID := ids.Sanitize(datum.Label)
834 + if labelID == "" {
835 + continue
836 + }
837 + mp[labelID] = datum
838 + }
839 + js.perfdata = mp
840 +}
841 +
842 +func (js *jobState) currentAttempt() int {
843 + if js == nil {
844 + return 1
845 + }
846 + if strings.EqualFold(js.state, "OK") || js.state == "" {
847 + return 1
848 + }
849 + attempt := js.softAttempts
850 + if attempt <= 0 {
851 + attempt = 1
852 + }
853 + if js.retrying {
854 + attempt++
855 + }
856 + if attempt > js.maxAttempts {
857 + return js.maxAttempts
858 + }
859 + return attempt
860 +}
src/go/plugin/scripts.d/pkg/runtime/scheduler_state_test.go new
+39
@@ -0,0 +1,39 @@
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 new
+27
@@ -0,0 +1,27 @@
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 new
+105
@@ -0,0 +1,105 @@
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 new
+244
@@ -0,0 +1,244 @@
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 new
+69
@@ -0,0 +1,69 @@
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 new
+181
@@ -0,0 +1,181 @@
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 new
+48
@@ -0,0 +1,48 @@
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/compile.go new
+378
@@ -0,0 +1,378 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package timeperiod
4 +
5 +import (
6 + "fmt"
7 + "strings"
8 + "time"
9 +)
10 +
11 +// Set contains compiled periods.
12 +type Set struct {
13 + periods map[string]*Period
14 +}
15 +
16 +// Period represents a compiled schedule.
17 +type Period struct {
18 + Name string
19 + Alias string
20 + rules []rule
21 + excludes []*Period
22 +}
23 +
24 +type rule interface {
25 + Allows(time.Time) bool
26 +}
27 +
28 +type weeklyRule struct {
29 + perDay map[time.Weekday][]minuteRange
30 +}
31 +
32 +type nthWeekdayRule struct {
33 + weekday time.Weekday
34 + nth int
35 + ranges []minuteRange
36 +}
37 +
38 +type dateRule struct {
39 + dates map[string][]minuteRange // YYYY-MM-DD -> ranges
40 +}
41 +
42 +type minuteRange struct {
43 + start int
44 + end int
45 +}
46 +
47 +// Compile builds a Set from raw configs.
48 +func Compile(cfgs []Config) (*Set, error) {
49 + periods := make(map[string]*Period, len(cfgs))
50 + for _, cfg := range cfgs {
51 + if cfg.Name == "" {
52 + return nil, fmt.Errorf("time_period missing name")
53 + }
54 + if _, exists := periods[cfg.Name]; exists {
55 + return nil, fmt.Errorf("duplicate time_period '%s'", cfg.Name)
56 + }
57 + pr, err := compilePeriod(cfg)
58 + if err != nil {
59 + return nil, fmt.Errorf("time_period '%s': %w", cfg.Name, err)
60 + }
61 + periods[cfg.Name] = pr
62 + }
63 + // Resolve excludes
64 + for name, p := range periods {
65 + for _, exName := range cfgs[findConfigIndex(cfgs, name)].Exclude {
66 + ex, ok := periods[exName]
67 + if !ok {
68 + return nil, fmt.Errorf("time_period '%s': exclude '%s' not found", name, exName)
69 + }
70 + p.excludes = append(p.excludes, ex)
71 + }
72 + }
73 + return &Set{periods: periods}, nil
74 +}
75 +
76 +func findConfigIndex(cfgs []Config, name string) int {
77 + for i, cfg := range cfgs {
78 + if cfg.Name == name {
79 + return i
80 + }
81 + }
82 + return -1
83 +}
84 +
85 +func compilePeriod(cfg Config) (*Period, error) {
86 + if len(cfg.Rules) == 0 {
87 + return nil, fmt.Errorf("time_period '%s' needs at least one rule", cfg.Name)
88 + }
89 + pr := &Period{Name: cfg.Name, Alias: cfg.Alias}
90 + for _, rc := range cfg.Rules {
91 + var r rule
92 + var err error
93 + switch strings.ToLower(rc.Type) {
94 + case "weekly", "":
95 + r, err = compileWeeklyRule(rc)
96 + case "nth_weekday":
97 + r, err = compileNthWeekdayRule(rc)
98 + case "date":
99 + r, err = compileDateRule(rc)
100 + default:
101 + return nil, fmt.Errorf("unsupported rule type '%s'", rc.Type)
102 + }
103 + if err != nil {
104 + return nil, err
105 + }
106 + pr.rules = append(pr.rules, r)
107 + }
108 + return pr, nil
109 +}
110 +
111 +func compileWeeklyRule(rc RuleConfig) (rule, error) {
112 + if len(rc.Ranges) == 0 {
113 + return nil, fmt.Errorf("weekly rule requires ranges")
114 + }
115 + daySet := rc.Days
116 + if len(daySet) == 0 {
117 + daySet = []string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"}
118 + }
119 + m := make(map[time.Weekday][]minuteRange)
120 + ranges, err := parseRanges(rc.Ranges)
121 + if err != nil {
122 + return nil, err
123 + }
124 + for _, d := range daySet {
125 + wd, err := parseWeekday(d)
126 + if err != nil {
127 + return nil, err
128 + }
129 + m[wd] = append([]minuteRange{}, ranges...)
130 + }
131 + return &weeklyRule{perDay: m}, nil
132 +}
133 +
134 +func compileNthWeekdayRule(rc RuleConfig) (rule, error) {
135 + if rc.Weekday == "" || rc.Nth <= 0 {
136 + return nil, fmt.Errorf("nth_weekday rule requires weekday and nth > 0")
137 + }
138 + if rc.Nth > 5 {
139 + return nil, fmt.Errorf("nth_weekday nth must be <= 5")
140 + }
141 + ranges, err := parseRanges(rc.Ranges)
142 + if err != nil {
143 + return nil, err
144 + }
145 + wd, err := parseWeekday(rc.Weekday)
146 + if err != nil {
147 + return nil, err
148 + }
149 + return &nthWeekdayRule{weekday: wd, nth: rc.Nth, ranges: ranges}, nil
150 +}
151 +
152 +func compileDateRule(rc RuleConfig) (rule, error) {
153 + if len(rc.Dates) == 0 {
154 + return nil, fmt.Errorf("date rule requires dates")
155 + }
156 + ranges, err := parseRanges(rc.Ranges)
157 + if err != nil {
158 + return nil, err
159 + }
160 + m := make(map[string][]minuteRange)
161 + for _, ds := range rc.Dates {
162 + if _, err := time.Parse("2006-01-02", ds); err != nil {
163 + return nil, fmt.Errorf("invalid date '%s'", ds)
164 + }
165 + key := ds
166 + m[key] = append([]minuteRange{}, ranges...)
167 + }
168 + return &dateRule{dates: m}, nil
169 +}
170 +
171 +func parseRanges(list []string) ([]minuteRange, error) {
172 + if len(list) == 0 {
173 + return nil, fmt.Errorf("at least one range is required")
174 + }
175 + res := make([]minuteRange, 0, len(list))
176 + for _, item := range list {
177 + parts := strings.Split(item, "-")
178 + if len(parts) != 2 {
179 + return nil, fmt.Errorf("invalid range '%s'", item)
180 + }
181 + s, err := parseMinute(parts[0])
182 + if err != nil {
183 + return nil, err
184 + }
185 + e, err := parseMinute(parts[1])
186 + if err != nil {
187 + return nil, err
188 + }
189 + if e < s {
190 + return nil, fmt.Errorf("range '%s' end before start", item)
191 + }
192 + res = append(res, minuteRange{start: s, end: e})
193 + }
194 + return res, nil
195 +}
196 +
197 +func parseMinute(val string) (int, error) {
198 + parts := strings.Split(val, ":")
199 + if len(parts) != 2 {
200 + return 0, fmt.Errorf("invalid time '%s'", val)
201 + }
202 + hour, err := parseIntBound(parts[0], 0, 24)
203 + if err != nil {
204 + return 0, err
205 + }
206 + min, err := parseIntBound(parts[1], 0, 59)
207 + if err != nil {
208 + return 0, err
209 + }
210 + if hour == 24 && min != 0 {
211 + return 0, fmt.Errorf("24:%02d is invalid", min)
212 + }
213 + return hour*60 + min, nil
214 +}
215 +
216 +func parseIntBound(val string, min, max int) (int, error) {
217 + var x int
218 + _, err := fmt.Sscanf(val, "%d", &x)
219 + if err != nil {
220 + return 0, fmt.Errorf("invalid number '%s'", val)
221 + }
222 + if x < min || x > max {
223 + return 0, fmt.Errorf("value '%s' out of bounds", val)
224 + }
225 + return x, nil
226 +}
227 +
228 +func parseWeekday(val string) (time.Weekday, error) {
229 + switch strings.ToLower(val) {
230 + case "sunday":
231 + return time.Sunday, nil
232 + case "monday":
233 + return time.Monday, nil
234 + case "tuesday":
235 + return time.Tuesday, nil
236 + case "wednesday":
237 + return time.Wednesday, nil
238 + case "thursday":
239 + return time.Thursday, nil
240 + case "friday":
241 + return time.Friday, nil
242 + case "saturday":
243 + return time.Saturday, nil
244 + default:
245 + return time.Sunday, fmt.Errorf("invalid weekday '%s'", val)
246 + }
247 +}
248 +
249 +// Resolve returns the compiled period for a name.
250 +func (s *Set) Resolve(name string) (*Period, error) {
251 + if s == nil || name == "" {
252 + return nil, nil
253 + }
254 + per, ok := s.periods[name]
255 + if !ok {
256 + return nil, fmt.Errorf("time_period '%s' not defined", name)
257 + }
258 + return per, nil
259 +}
260 +
261 +// Allows determines whether the time falls inside the period (excluding child exclusions).
262 +func (p *Period) Allows(t time.Time) bool {
263 + return p.allows(t, make(map[*Period]bool))
264 +}
265 +
266 +func (p *Period) allows(t time.Time, stack map[*Period]bool) bool {
267 + if p == nil {
268 + return true
269 + }
270 + if stack[p] {
271 + return false
272 + }
273 + stack[p] = true
274 + defer delete(stack, p)
275 +
276 + allowed := false
277 + for _, r := range p.rules {
278 + if r.Allows(t) {
279 + allowed = true
280 + break
281 + }
282 + }
283 + if !allowed {
284 + return false
285 + }
286 + for _, ex := range p.excludes {
287 + if ex == nil || ex == p {
288 + continue
289 + }
290 + if ex.allows(t, stack) {
291 + return false
292 + }
293 + }
294 + return true
295 +}
296 +
297 +// NextAllowed returns the next timestamp at or after t that is allowed.
298 +func (p *Period) NextAllowed(t time.Time) time.Time {
299 + if p == nil {
300 + return t
301 + }
302 + for i := 0; i < 60*24*90; i++ { // search up to ~90 days
303 + if p.Allows(t) {
304 + return t
305 + }
306 + t = t.Add(time.Minute)
307 + }
308 + return time.Time{}
309 +}
310 +
311 +// rule implementations
312 +
313 +func (r *weeklyRule) Allows(t time.Time) bool {
314 + if r == nil {
315 + return false
316 + }
317 + min := t.Hour()*60 + t.Minute()
318 + list := r.perDay[t.Weekday()]
319 + for _, rng := range list {
320 + if rng.contains(min) {
321 + return true
322 + }
323 + }
324 + return false
325 +}
326 +
327 +func (mr minuteRange) contains(min int) bool {
328 + return min >= mr.start && min < mr.end
329 +}
330 +
331 +func (r *nthWeekdayRule) Allows(t time.Time) bool {
332 + if r == nil {
333 + return false
334 + }
335 + if t.Weekday() != r.weekday {
336 + return false
337 + }
338 + day := t.Day()
339 + nth := (day-1)/7 + 1
340 + if nth != r.nth {
341 + return false
342 + }
343 + min := t.Hour()*60 + t.Minute()
344 + for _, rng := range r.ranges {
345 + if rng.contains(min) {
346 + return true
347 + }
348 + }
349 + return false
350 +}
351 +
352 +func (r *dateRule) Allows(t time.Time) bool {
353 + if r == nil {
354 + return false
355 + }
356 + key := t.Format("2006-01-02")
357 + list := r.dates[key]
358 + if len(list) == 0 {
359 + return false
360 + }
361 + min := t.Hour()*60 + t.Minute()
362 + for _, rng := range list {
363 + if rng.contains(min) {
364 + return true
365 + }
366 + }
367 + return false
368 +}
369 +
370 +// EnsureDefault appends the builtin period when missing.
371 +func EnsureDefault(cfgs []Config) []Config {
372 + for _, cfg := range cfgs {
373 + if cfg.Name == DefaultPeriodName {
374 + return cfgs
375 + }
376 + }
377 + return append(cfgs, DefaultPeriodConfig())
378 +}
src/go/plugin/scripts.d/pkg/timeperiod/compile_test.go new
+75
@@ -0,0 +1,75 @@
1 +package timeperiod
2 +
3 +import (
4 + "testing"
5 + "time"
6 +)
7 +
8 +func TestCompileAndAllows(t *testing.T) {
9 + cfgs := EnsureDefault([]Config{})
10 + set, err := Compile(cfgs)
11 + if err != nil {
12 + t.Fatalf("compile: %v", err)
13 + }
14 + per, err := set.Resolve(DefaultPeriodName)
15 + if err != nil {
16 + t.Fatalf("resolve: %v", err)
17 + }
18 + if !per.Allows(time.Now()) {
19 + t.Fatalf("default period should allow now")
20 + }
21 +}
22 +
23 +func TestWeeklyRule(t *testing.T) {
24 + cfgs := []Config{
25 + {
26 + Name: "work",
27 + Rules: []RuleConfig{{Type: "weekly", Days: []string{"monday"}, Ranges: []string{"09:00-17:00"}}},
28 + },
29 + }
30 + set, err := Compile(cfgs)
31 + if err != nil {
32 + t.Fatalf("compile: %v", err)
33 + }
34 + per, _ := set.Resolve("work")
35 + mon := time.Date(2025, time.January, 6, 10, 0, 0, 0, time.UTC) // Monday
36 + if !per.Allows(mon) {
37 + t.Fatalf("expected monday 10:00 to be allowed")
38 + }
39 + sun := time.Date(2025, time.January, 5, 10, 0, 0, 0, time.UTC)
40 + if per.Allows(sun) {
41 + t.Fatalf("expected sunday to be disallowed")
42 + }
43 +}
44 +
45 +func TestExcludeCycles(t *testing.T) {
46 + cfgs := []Config{
47 + {
48 + Name: "self",
49 + Rules: []RuleConfig{{Type: "weekly", Ranges: []string{"00:00-24:00"}}},
50 + Exclude: []string{"self"},
51 + },
52 + {
53 + Name: "a",
54 + Rules: []RuleConfig{{Type: "weekly", Ranges: []string{"00:00-24:00"}}},
55 + Exclude: []string{"b"},
56 + },
57 + {
58 + Name: "b",
59 + Rules: []RuleConfig{{Type: "weekly", Ranges: []string{"00:00-24:00"}}},
60 + Exclude: []string{"a"},
61 + },
62 + }
63 + set, err := Compile(cfgs)
64 + if err != nil {
65 + t.Fatalf("compile: %v", err)
66 + }
67 + self, _ := set.Resolve("self")
68 + if !self.Allows(time.Now()) {
69 + t.Fatalf("self-excluding period should still evaluate without errors")
70 + }
71 + a, _ := set.Resolve("a")
72 + if a.Allows(time.Now()) {
73 + t.Fatalf("mutually excluding periods should not allow any time")
74 + }
75 +}
src/go/plugin/scripts.d/pkg/timeperiod/config.go new
+39
@@ -0,0 +1,39 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package timeperiod
4 +
5 +// Config describes a time period definition in YAML/JSON.
6 +type Config struct {
7 + Name string `yaml:"name" json:"name"`
8 + Alias string `yaml:"alias,omitempty" json:"alias"`
9 + Rules []RuleConfig `yaml:"rules" json:"rules"`
10 + Exclude []string `yaml:"exclude,omitempty" json:"exclude"`
11 +}
12 +
13 +// RuleConfig describes one rule entry (weekly, nth_weekday, date).
14 +type RuleConfig struct {
15 + Type string `yaml:"type" json:"type"`
16 + Days []string `yaml:"days,omitempty" json:"days"`
17 + Ranges []string `yaml:"ranges" json:"ranges"`
18 + Weekday string `yaml:"weekday,omitempty" json:"weekday"`
19 + Nth int `yaml:"nth,omitempty" json:"nth"`
20 + Dates []string `yaml:"dates,omitempty" json:"dates"`
21 +}
22 +
23 +// DefaultPeriodName represents the implicit always-on period.
24 +const DefaultPeriodName = "24x7"
25 +
26 +// DefaultPeriodConfig returns the builtin 24x7 schedule.
27 +func DefaultPeriodConfig() Config {
28 + return Config{
29 + Name: DefaultPeriodName,
30 + Alias: "Always on",
31 + Rules: []RuleConfig{
32 + {
33 + Type: "weekly",
34 + Days: []string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"},
35 + Ranges: []string{"00:00-24:00"},
36 + },
37 + },
38 + }
39 +}
src/go/plugin/scripts.d/pkg/units/scale.go new
+150
@@ -0,0 +1,150 @@
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 new
+30
@@ -0,0 +1,30 @@
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/pkg/zabbix/config.go new
+423
@@ -0,0 +1,423 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package zabbix
4 +
5 +import (
6 + "encoding/json"
7 + "fmt"
8 + "strconv"
9 + "strings"
10 + "time"
11 +
12 + "github.com/netdata/netdata/go/plugins/pkg/confopt"
13 + zpre "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbixpreproc"
14 +)
15 +
16 +// CollectionType enumerates supported Zabbix collection methods.
17 +type CollectionType string
18 +
19 +const (
20 + CollectionCommand CollectionType = "command"
21 + CollectionHTTP CollectionType = "http"
22 + CollectionSNMP CollectionType = "snmp"
23 +)
24 +
25 +var stepTypeTokens = map[string]zpre.StepType{
26 + "multiplier": zpre.StepTypeMultiplier,
27 + "rtrim": zpre.StepTypeRTrim,
28 + "ltrim": zpre.StepTypeLTrim,
29 + "trim": zpre.StepTypeTrim,
30 + "regex_substitution": zpre.StepTypeRegexSubstitution,
31 + "bool_to_decimal": zpre.StepTypeBool2Dec,
32 + "octal_to_decimal": zpre.StepTypeOct2Dec,
33 + "hex_to_decimal": zpre.StepTypeHex2Dec,
34 + "delta_value": zpre.StepTypeDeltaValue,
35 + "delta_speed": zpre.StepTypeDeltaSpeed,
36 + "xpath": zpre.StepTypeXPath,
37 + "jsonpath": zpre.StepTypeJSONPath,
38 + "validate_range": zpre.StepTypeValidateRange,
39 + "validate_regex": zpre.StepTypeValidateRegex,
40 + "validate_not_regex": zpre.StepTypeValidateNotRegex,
41 + "error_field_json": zpre.StepTypeErrorFieldJSON,
42 + "error_field_xml": zpre.StepTypeErrorFieldXML,
43 + "error_field_regex": zpre.StepTypeErrorFieldRegex,
44 + "throttle_value": zpre.StepTypeThrottleValue,
45 + "throttle_timed_value": zpre.StepTypeThrottleTimedValue,
46 + "javascript": zpre.StepTypeJavaScript,
47 + "prometheus_pattern": zpre.StepTypePrometheusPattern,
48 + "prometheus_to_json": zpre.StepTypePrometheusToJSON,
49 + "csv_to_json": zpre.StepTypeCSVToJSON,
50 + "string_replace": zpre.StepTypeStringReplace,
51 + "validate_not_supported": zpre.StepTypeValidateNotSupported,
52 + "xml_to_json": zpre.StepTypeXMLToJSON,
53 + "snmp_walk_value": zpre.StepTypeSNMPWalkValue,
54 + "snmp_walk_to_json": zpre.StepTypeSNMPWalkToJSON,
55 + "snmp_get_value": zpre.StepTypeSNMPGetValue,
56 + "prometheus_to_json_multi": zpre.StepTypePrometheusToJSONMulti,
57 + "jsonpath_multi": zpre.StepTypeJSONPathMulti,
58 + "snmp_walk_to_json_multi": zpre.StepTypeSNMPWalkToJSONMulti,
59 + "csv_to_json_multi": zpre.StepTypeCSVToJSONMulti,
60 +}
61 +
62 +var errorHandlerTokens = map[string]zpre.ErrorAction{
63 + "": zpre.ErrorActionDefault,
64 + "default": zpre.ErrorActionDefault,
65 + "discard": zpre.ErrorActionDiscard,
66 + "set-value": zpre.ErrorActionSetValue,
67 + "set-error": zpre.ErrorActionSetError,
68 +}
69 +
70 +// CollectionConfig describes how raw data is fetched before preprocessing.
71 +type CollectionConfig struct {
72 + Type CollectionType `yaml:"type" json:"type"`
73 + Command string `yaml:"command,omitempty" json:"command,omitempty"`
74 + Args []string `yaml:"args,omitempty" json:"args,omitempty"`
75 + Timeout confopt.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"`
76 + Environment map[string]string `yaml:"environment,omitempty" json:"environment,omitempty"`
77 + HTTP HTTPConfig `yaml:"http,omitempty" json:"http,omitempty"`
78 + SNMP SNMPConfig `yaml:"snmp,omitempty" json:"snmp,omitempty"`
79 + Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` // legacy alias for HTTP headers
80 + Body string `yaml:"body,omitempty" json:"body,omitempty"`
81 + Method string `yaml:"method,omitempty" json:"method,omitempty"` // legacy alias for HTTP method
82 + URL string `yaml:"url,omitempty" json:"url,omitempty"`
83 +}
84 +
85 +// HTTPConfig captures HTTP(S) collection settings.
86 +type HTTPConfig struct {
87 + URL string `yaml:"url" json:"url"`
88 + Method string `yaml:"method,omitempty" json:"method,omitempty"`
89 + Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
90 + Body string `yaml:"body,omitempty" json:"body,omitempty"`
91 + TLS bool `yaml:"tls,omitempty" json:"tls,omitempty"`
92 + Username string `yaml:"username,omitempty" json:"username,omitempty"`
93 + Password string `yaml:"password,omitempty" json:"password,omitempty"`
94 +}
95 +
96 +// SNMPConfig captures SNMP collection settings.
97 +type SNMPConfig struct {
98 + Target string `yaml:"target" json:"target"`
99 + Community string `yaml:"community,omitempty" json:"community,omitempty"`
100 + Version string `yaml:"version,omitempty" json:"version,omitempty"`
101 + User string `yaml:"user,omitempty" json:"user,omitempty"`
102 + AuthPass string `yaml:"auth_password,omitempty" json:"auth_password,omitempty"`
103 + PrivPass string `yaml:"privacy_password,omitempty" json:"privacy_password,omitempty"`
104 + AuthProto string `yaml:"auth_protocol,omitempty" json:"auth_protocol,omitempty"`
105 + PrivProto string `yaml:"privacy_protocol,omitempty" json:"privacy_protocol,omitempty"`
106 + Context string `yaml:"context,omitempty" json:"context,omitempty"`
107 + OID string `yaml:"oid" json:"oid"`
108 +}
109 +
110 +// LLDConfig describes the discovery pipeline.
111 +type LLDConfig struct {
112 + Steps []zpre.Step `yaml:"steps" json:"steps"`
113 + Interval confopt.Duration `yaml:"discovery_interval,omitempty" json:"discovery_interval,omitempty"`
114 + InstanceTemplate string `yaml:"instance_id_template" json:"instance_id_template"`
115 + Family string `yaml:"family" json:"family"`
116 + Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"`
117 + MaxMissing int `yaml:"max_missing,omitempty" json:"max_missing,omitempty"`
118 +}
119 +
120 +type lldConfigDTO struct {
121 + Steps []rawStep `yaml:"steps" json:"steps"`
122 + Interval confopt.Duration `yaml:"discovery_interval,omitempty" json:"discovery_interval,omitempty"`
123 + InstanceTemplate string `yaml:"instance_id_template" json:"instance_id_template"`
124 + Family string `yaml:"family" json:"family"`
125 + Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"`
126 + MaxMissing int `yaml:"max_missing,omitempty" json:"max_missing,omitempty"`
127 +}
128 +
129 +func (l *LLDConfig) fromDTO(dto lldConfigDTO) error {
130 + steps, err := decodeSteps(dto.Steps)
131 + if err != nil {
132 + return err
133 + }
134 + l.Steps = steps
135 + l.Interval = dto.Interval
136 + l.InstanceTemplate = dto.InstanceTemplate
137 + l.Family = dto.Family
138 + l.Labels = dto.Labels
139 + l.MaxMissing = dto.MaxMissing
140 + return nil
141 +}
142 +
143 +func (l *LLDConfig) UnmarshalYAML(unmarshal func(any) error) error {
144 + var dto lldConfigDTO
145 + if err := unmarshal(&dto); err != nil {
146 + return err
147 + }
148 + return l.fromDTO(dto)
149 +}
150 +
151 +func (l *LLDConfig) UnmarshalJSON(data []byte) error {
152 + var dto lldConfigDTO
153 + if err := json.Unmarshal(data, &dto); err != nil {
154 + return err
155 + }
156 + return l.fromDTO(dto)
157 +}
158 +
159 +// PipelineConfig defines a dependent pipeline that produces a single metric per instance.
160 +type PipelineConfig struct {
161 + Name string `yaml:"name" json:"name"`
162 + Context string `yaml:"context" json:"context"`
163 + Title string `yaml:"title,omitempty" json:"title,omitempty"`
164 + Dimension string `yaml:"dimension" json:"dimension"`
165 + Unit string `yaml:"unit" json:"unit"`
166 + Family string `yaml:"family,omitempty" json:"family,omitempty"`
167 + ChartType string `yaml:"chart_type,omitempty" json:"chart_type,omitempty"`
168 + DataType string `yaml:"data_type,omitempty" json:"data_type,omitempty"`
169 + Precision int `yaml:"precision,omitempty" json:"precision,omitempty"`
170 + Algorithm string `yaml:"algorithm,omitempty" json:"algorithm,omitempty"`
171 + Steps []zpre.Step `yaml:"steps" json:"steps"`
172 +}
173 +
174 +type pipelineConfigDTO struct {
175 + Name string `yaml:"name" json:"name"`
176 + Context string `yaml:"context" json:"context"`
177 + Title string `yaml:"title,omitempty" json:"title,omitempty"`
178 + Dimension string `yaml:"dimension" json:"dimension"`
179 + Unit string `yaml:"unit" json:"unit"`
180 + Family string `yaml:"family,omitempty" json:"family,omitempty"`
181 + ChartType string `yaml:"chart_type,omitempty" json:"chart_type,omitempty"`
182 + DataType string `yaml:"data_type,omitempty" json:"data_type,omitempty"`
183 + Precision int `yaml:"precision,omitempty" json:"precision,omitempty"`
184 + Algorithm string `yaml:"algorithm,omitempty" json:"algorithm,omitempty"`
185 + Steps []rawStep `yaml:"steps" json:"steps"`
186 +}
187 +
188 +func (pc *PipelineConfig) fromDTO(dto pipelineConfigDTO) error {
189 + pc.Name = dto.Name
190 + pc.Context = dto.Context
191 + pc.Title = dto.Title
192 + pc.Dimension = dto.Dimension
193 + pc.Unit = dto.Unit
194 + pc.Family = dto.Family
195 + pc.ChartType = dto.ChartType
196 + pc.DataType = dto.DataType
197 + pc.Precision = dto.Precision
198 + pc.Algorithm = dto.Algorithm
199 + steps, err := decodeSteps(dto.Steps)
200 + if err != nil {
201 + return err
202 + }
203 + pc.Steps = steps
204 + return nil
205 +}
206 +
207 +func (pc *PipelineConfig) UnmarshalYAML(unmarshal func(any) error) error {
208 + var dto pipelineConfigDTO
209 + if err := unmarshal(&dto); err != nil {
210 + return err
211 + }
212 + return pc.fromDTO(dto)
213 +}
214 +
215 +func (pc *PipelineConfig) UnmarshalJSON(data []byte) error {
216 + var dto pipelineConfigDTO
217 + if err := json.Unmarshal(data, &dto); err != nil {
218 + return err
219 + }
220 + return pc.fromDTO(dto)
221 +}
222 +
223 +// JobConfig represents the full user configuration for a Zabbix job.
224 +type JobConfig struct {
225 + Name string `yaml:"name" json:"name"`
226 + Scheduler string `yaml:"scheduler,omitempty" json:"scheduler,omitempty"`
227 + Vnode string `yaml:"vnode,omitempty" json:"vnode,omitempty"`
228 + UpdateEvery int `yaml:"update_every,omitempty" json:"update_every,omitempty"`
229 + UserMacros map[string]string `yaml:"user_macros,omitempty" json:"user_macros,omitempty"`
230 + Collection CollectionConfig `yaml:"collection" json:"collection"`
231 + LLD LLDConfig `yaml:"lld,omitempty" json:"lld,omitempty"`
232 + Pipelines []PipelineConfig `yaml:"dependent_pipelines" json:"dependent_pipelines"`
233 + Notes string `yaml:"notes,omitempty" json:"notes,omitempty"`
234 +}
235 +
236 +const defaultUpdateEverySeconds = 60
237 +
238 +type rawStep struct {
239 + Type any `yaml:"type" json:"type"`
240 + Params string `yaml:"params,omitempty" json:"params,omitempty"`
241 + ErrorHandler string `yaml:"error_handler,omitempty" json:"error_handler,omitempty"`
242 + ErrorHandlerValue string `yaml:"error_handler_params,omitempty" json:"error_handler_params,omitempty"`
243 +}
244 +
245 +func decodeSteps(raw []rawStep) ([]zpre.Step, error) {
246 + if len(raw) == 0 {
247 + return nil, nil
248 + }
249 + steps := make([]zpre.Step, len(raw))
250 + for i, r := range raw {
251 + step, err := r.toZabbixStep()
252 + if err != nil {
253 + return nil, fmt.Errorf("step[%d]: %w", i, err)
254 + }
255 + steps[i] = step
256 + }
257 + return steps, nil
258 +}
259 +
260 +func (rs rawStep) toZabbixStep() (zpre.Step, error) {
261 + st, err := parseStepType(rs.Type)
262 + if err != nil {
263 + return zpre.Step{}, err
264 + }
265 + handler, err := parseErrorHandler(rs.ErrorHandler, rs.ErrorHandlerValue)
266 + if err != nil {
267 + return zpre.Step{}, err
268 + }
269 + return zpre.Step{
270 + Type: st,
271 + Params: rs.Params,
272 + ErrorHandler: handler,
273 + }, nil
274 +}
275 +
276 +func parseStepType(value any) (zpre.StepType, error) {
277 + switch v := value.(type) {
278 + case string:
279 + key := strings.ToLower(strings.TrimSpace(v))
280 + if key == "" {
281 + return 0, fmt.Errorf("step type is required")
282 + }
283 + if st, ok := stepTypeTokens[key]; ok {
284 + return st, nil
285 + }
286 + if n, err := strconv.Atoi(key); err == nil {
287 + return zpre.StepType(n), nil
288 + }
289 + return 0, fmt.Errorf("unknown step type %q", v)
290 + case int:
291 + return zpre.StepType(v), nil
292 + case int64:
293 + return zpre.StepType(v), nil
294 + case float64:
295 + return zpre.StepType(int(v)), nil
296 + case json.Number:
297 + i, err := v.Int64()
298 + if err != nil {
299 + return 0, err
300 + }
301 + return zpre.StepType(i), nil
302 + case nil:
303 + return 0, fmt.Errorf("step type is required")
304 + default:
305 + return 0, fmt.Errorf("unsupported step type %T", v)
306 + }
307 +}
308 +
309 +func parseErrorHandler(action, params string) (zpre.ErrorHandler, error) {
310 + action = strings.ToLower(strings.TrimSpace(action))
311 + act, ok := errorHandlerTokens[action]
312 + if !ok {
313 + return zpre.ErrorHandler{}, fmt.Errorf("unknown error handler %q", action)
314 + }
315 + switch act {
316 + case zpre.ErrorActionSetValue, zpre.ErrorActionSetError:
317 + if strings.TrimSpace(params) == "" {
318 + return zpre.ErrorHandler{}, fmt.Errorf("error_handler_params required when error_handler=%q", action)
319 + }
320 + return zpre.ErrorHandler{Action: act, Params: params}, nil
321 + default:
322 + return zpre.ErrorHandler{Action: act}, nil
323 + }
324 +}
325 +
326 +// Validate ensures the job definition is usable.
327 +func (cfg *JobConfig) Validate() error {
328 + if strings.TrimSpace(cfg.Name) == "" {
329 + return fmt.Errorf("job name is required")
330 + }
331 + if strings.TrimSpace(cfg.Collection.Type.String()) == "" {
332 + return fmt.Errorf("job '%s': collection.type is required", cfg.Name)
333 + }
334 + if err := cfg.Collection.validate(); err != nil {
335 + return fmt.Errorf("job '%s': %w", cfg.Name, err)
336 + }
337 + if len(cfg.Pipelines) == 0 {
338 + return fmt.Errorf("job '%s': at least one dependent pipeline is required", cfg.Name)
339 + }
340 + for i := range cfg.Pipelines {
341 + if err := cfg.Pipelines[i].validate(); err != nil {
342 + return fmt.Errorf("job '%s': pipeline[%d]: %w", cfg.Name, i, err)
343 + }
344 + }
345 + if cfg.UpdateEvery < 0 {
346 + return fmt.Errorf("job '%s': update_every must be >= 1 second when provided", cfg.Name)
347 + }
348 + if cfg.LLD.InstanceTemplate == "" && cfg.hasTemplateLabels() {
349 + return fmt.Errorf("job '%s': lld.instance_id_template is required when labels reference macros", cfg.Name)
350 + }
351 + return nil
352 +}
353 +
354 +func (c CollectionConfig) validate() error {
355 + switch c.Type {
356 + case CollectionCommand:
357 + if strings.TrimSpace(c.Command) == "" {
358 + return fmt.Errorf("collection.command is required for command type")
359 + }
360 + case CollectionHTTP:
361 + if strings.TrimSpace(c.HTTP.URL) == "" && strings.TrimSpace(c.URL) == "" {
362 + return fmt.Errorf("http.url is required")
363 + }
364 + case CollectionSNMP:
365 + if strings.TrimSpace(c.SNMP.Target) == "" || strings.TrimSpace(c.SNMP.OID) == "" {
366 + return fmt.Errorf("snmp.target and snmp.oid are required")
367 + }
368 + default:
369 + return fmt.Errorf("unsupported collection type %q", c.Type)
370 + }
371 + return nil
372 +}
373 +
374 +func (c CollectionType) String() string { return string(c) }
375 +
376 +func (cfg *JobConfig) hasTemplateLabels() bool {
377 + for _, v := range cfg.LLD.Labels {
378 + if strings.Contains(v, "{#") {
379 + return true
380 + }
381 + }
382 + return false
383 +}
384 +
385 +func (p *PipelineConfig) validate() error {
386 + if p.Name == "" {
387 + return fmt.Errorf("name is required")
388 + }
389 + if p.Context == "" {
390 + return fmt.Errorf("context is required")
391 + }
392 + if p.Dimension == "" {
393 + return fmt.Errorf("dimension is required")
394 + }
395 + if strings.TrimSpace(p.Unit) == "" {
396 + return fmt.Errorf("unit is required")
397 + }
398 + if len(p.Steps) == 0 {
399 + return fmt.Errorf("at least one preprocessing step is required")
400 + }
401 + return nil
402 +}
403 +
404 +// Interval returns the discovery interval or zero for per-collection execution.
405 +func (l LLDConfig) IntervalDuration() time.Duration {
406 + return time.Duration(l.Interval)
407 +}
408 +
409 +// Timeout returns the collection timeout.
410 +func (c CollectionConfig) TimeoutDuration() time.Duration {
411 + if c.Timeout <= 0 {
412 + return 0
413 + }
414 + return time.Duration(c.Timeout)
415 +}
416 +
417 +// IntervalDuration returns the scheduling interval for the job.
418 +func (cfg JobConfig) IntervalDuration() time.Duration {
419 + if cfg.UpdateEvery >= 1 {
420 + return time.Duration(cfg.UpdateEvery) * time.Second
421 + }
422 + return time.Duration(defaultUpdateEverySeconds) * time.Second
423 +}
src/go/plugin/scripts.d/pkg/zabbix/config_test.go new
+128
@@ -0,0 +1,128 @@
1 +package zabbix
2 +
3 +import (
4 + "encoding/json"
5 + "testing"
6 +
7 + "gopkg.in/yaml.v3"
8 +
9 + zpre "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbixpreproc"
10 +)
11 +
12 +func TestJobConfigValidate(t *testing.T) {
13 + cfg := JobConfig{
14 + Name: "disk",
15 + Collection: CollectionConfig{Type: CollectionCommand, Command: "/usr/lib/zabbix/get_disks"},
16 + LLD: LLDConfig{InstanceTemplate: "disk_{#DEVNAME}"},
17 + Pipelines: []PipelineConfig{{
18 + Name: "usage",
19 + Context: "zabbix.disk.usage",
20 + Dimension: "used",
21 + Unit: "%",
22 + Steps: []zpre.Step{{Type: zpre.StepTypeJSONPath, Params: "$.value"}},
23 + }},
24 + }
25 + if err := cfg.Validate(); err != nil {
26 + t.Fatalf("expected config to be valid: %v", err)
27 + }
28 +}
29 +
30 +func TestJobConfigValidateFails(t *testing.T) {
31 + cfg := JobConfig{Name: "bad"}
32 + if err := cfg.Validate(); err == nil {
33 + t.Fatalf("expected validation failure")
34 + }
35 +}
36 +
37 +func TestJobConfigUpdateEveryValidation(t *testing.T) {
38 + cfg := JobConfig{
39 + Name: "test",
40 + UpdateEvery: -1,
41 + Collection: CollectionConfig{Type: CollectionCommand, Command: "/bin/true"},
42 + Pipelines: []PipelineConfig{{
43 + Name: "value",
44 + Context: "ctx",
45 + Dimension: "dim",
46 + Unit: "value",
47 + Steps: []zpre.Step{{Type: zpre.StepTypeJSONPath, Params: "$.v"}},
48 + }},
49 + }
50 + if err := cfg.Validate(); err == nil {
51 + t.Fatalf("expected error for negative update_every")
52 + }
53 +
54 + cfg.UpdateEvery = 1
55 + if err := cfg.Validate(); err != nil {
56 + t.Fatalf("unexpected error for valid update_every: %v", err)
57 + }
58 +
59 + cfg.UpdateEvery = 0
60 + if err := cfg.Validate(); err != nil {
61 + t.Fatalf("unexpected error when update_every omitted: %v", err)
62 + }
63 +}
64 +
65 +func TestPipelineStepStringTypeYAML(t *testing.T) {
66 + data := `pipelines:
67 +- name: usage
68 + context: zabbix.fs.usage
69 + dimension: used
70 + unit: "%"
71 + steps:
72 + - type: jsonpath
73 + params: $.value
74 + error_handler: set-value
75 + error_handler_params: "0"
76 +`
77 + var wrapper struct {
78 + Pipelines []PipelineConfig `yaml:"pipelines"`
79 + }
80 + if err := yaml.Unmarshal([]byte(data), &wrapper); err != nil {
81 + t.Fatalf("unmarshal failed: %v", err)
82 + }
83 + if len(wrapper.Pipelines) != 1 {
84 + t.Fatalf("expected 1 pipeline, got %d", len(wrapper.Pipelines))
85 + }
86 + steps := wrapper.Pipelines[0].Steps
87 + if len(steps) != 1 {
88 + t.Fatalf("expected 1 step, got %d", len(steps))
89 + }
90 + step := steps[0]
91 + if step.Type != zpre.StepTypeJSONPath {
92 + t.Fatalf("expected jsonpath step, got %v", step.Type)
93 + }
94 + if step.ErrorHandler.Action != zpre.ErrorActionSetValue || step.ErrorHandler.Params != "0" {
95 + t.Fatalf("unexpected error handler: %+v", step.ErrorHandler)
96 + }
97 +}
98 +
99 +func TestPipelineStepNumericJSON(t *testing.T) {
100 + payload := []byte(`{"pipelines":[{"name":"usage","context":"ctx","dimension":"dim","unit":"%","steps":[{"type":12,"params":"$.value"}]}]}`)
101 + var wrapper struct {
102 + Pipelines []PipelineConfig `json:"pipelines"`
103 + }
104 + if err := json.Unmarshal(payload, &wrapper); err != nil {
105 + t.Fatalf("json unmarshal failed: %v", err)
106 + }
107 + step := wrapper.Pipelines[0].Steps[0]
108 + if step.Type != zpre.StepTypeJSONPath {
109 + t.Fatalf("expected numeric type to map to jsonpath, got %v", step.Type)
110 + }
111 +}
112 +
113 +func TestParseErrorHandlerInvalidAction(t *testing.T) {
114 + _, err := parseErrorHandler("bogus", "")
115 + if err == nil {
116 + t.Fatalf("expected error for unknown handler")
117 + }
118 +
119 + if _, err := parseErrorHandler("set-value", ""); err == nil {
120 + t.Fatalf("expected error when params missing for set-value")
121 + }
122 + if _, err := parseErrorHandler("set-error", ""); err == nil {
123 + t.Fatalf("expected error when params missing for set-error")
124 + }
125 + if h, err := parseErrorHandler("set-error", "bad"); err != nil || h.Params != "bad" {
126 + t.Fatalf("expected valid handler, err=%v handler=%+v", err, h)
127 + }
128 +}
src/go/plugin/scripts.d/pkg/zabbix/jobengine/job.go new
+492
@@ -0,0 +1,492 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package jobengine
4 +
5 +import (
6 + "encoding/json"
7 + "errors"
8 + "fmt"
9 + "strconv"
10 + "strings"
11 + "sync"
12 + "time"
13 +
14 + "github.com/netdata/netdata/go/plugins/logger"
15 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/ids"
16 + pkgzabbix "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbix"
17 + zpre "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbixpreproc"
18 +)
19 +
20 +// Job orchestrates collection -> LLD -> dependent pipeline execution for a single
21 +// Zabbix-style definition. It wraps the shared Preprocessor and maintains the
22 +// discovered instance catalog between iterations.
23 +type Job struct {
24 + cfg pkgzabbix.JobConfig
25 + proc *zpre.Preprocessor
26 + log *logger.Logger
27 + mu sync.RWMutex
28 + lastDiscovery time.Time
29 + instances map[string]*instanceState
30 +}
31 +
32 +// Options configure the job engine behavior.
33 +type Options struct {
34 + Logger *logger.Logger
35 +}
36 +
37 +// Input encapsulates the payload passed to Process.
38 +type Input struct {
39 + Payload []byte
40 + Timestamp time.Time
41 + CollectError error
42 +}
43 +
44 +// Result aggregates the metrics, discovery changes, and failure state after a
45 +// Process call.
46 +type Result struct {
47 + Job string
48 + Timestamp time.Time
49 + Metrics []MetricResult
50 + Active []InstanceInfo
51 + Removed []InstanceInfo
52 + State StateSummary
53 +}
54 +
55 +// StateSummary tracks failure flags for the job and individual instances.
56 +type StateSummary struct {
57 + Job FailureFlags
58 + Instances map[string]FailureFlags
59 +}
60 +
61 +// FailureFlags capture the distinct failure modes per iteration.
62 +type FailureFlags struct {
63 + Collect bool
64 + LLD bool
65 + Extraction bool
66 + Dimension bool
67 +}
68 +
69 +// MetricResult describes a single dependent pipeline outcome.
70 +type MetricResult struct {
71 + Instance InstanceInfo
72 + Pipeline *pkgzabbix.PipelineConfig
73 + Value float64
74 + Precision int
75 + Discarded bool
76 + Error error
77 + Name string
78 + Labels map[string]string
79 +}
80 +
81 +// InstanceInfo carries the instance ID and macros used for templating.
82 +type InstanceInfo struct {
83 + ID string
84 + Macros map[string]string
85 +}
86 +
87 +// NewJob validates the configuration and constructs a Job bound to the given
88 +// Preprocessor.
89 +func NewJob(cfg pkgzabbix.JobConfig, proc *zpre.Preprocessor, opts Options) (*Job, error) {
90 + if err := cfg.Validate(); err != nil {
91 + return nil, err
92 + }
93 + if proc == nil {
94 + return nil, errors.New("jobengine: preprocessor is required")
95 + }
96 + log := opts.Logger
97 + if log == nil {
98 + log = logger.New().With("component", "zabbix/jobengine")
99 + }
100 + job := &Job{
101 + cfg: cfg,
102 + proc: proc,
103 + log: log,
104 + instances: make(map[string]*instanceState),
105 + }
106 + return job, nil
107 +}
108 +
109 +// Process executes LLD (when configured) and dependent pipelines using the
110 +// provided payload. The returned result describes all emitted metrics alongside
111 +// failure flags for the job and each instance.
112 +func (j *Job) Process(in Input) Result {
113 + j.mu.Lock()
114 + defer j.mu.Unlock()
115 +
116 + res := Result{
117 + Job: j.cfg.Name,
118 + Timestamp: in.Timestamp,
119 + State: StateSummary{Instances: make(map[string]FailureFlags)},
120 + }
121 +
122 + if in.Timestamp.IsZero() {
123 + in.Timestamp = time.Now()
124 + res.Timestamp = in.Timestamp
125 + }
126 +
127 + tracker := newIterationState()
128 +
129 + if in.CollectError != nil {
130 + j.ensureDefaultInstance()
131 + tracker.setCollectFailureAll(j.instances)
132 + var active []InstanceInfo
133 + for _, inst := range j.instances {
134 + active = append(active, inst.info())
135 + }
136 + return j.finalizeResult(tracker, active, nil, res)
137 + }
138 +
139 + payloadValue := zpre.Value{Data: string(in.Payload), Type: zpre.ValueTypeStr, Timestamp: in.Timestamp}
140 +
141 + var discovery []map[string]string
142 + var discErr error
143 + if len(j.cfg.LLD.Steps) > 0 && j.shouldDiscover(in.Timestamp) {
144 + discovery, discErr = j.runDiscovery(payloadValue)
145 + }
146 +
147 + var removed []InstanceInfo
148 + var active []InstanceInfo
149 + if discErr != nil {
150 + tracker.setLLDFailureAll(j.instances)
151 + } else if discovery != nil {
152 + var applyErr error
153 + removed, applyErr = j.applyDiscovery(discovery)
154 + if applyErr != nil {
155 + tracker.setLLDFailureAll(j.instances)
156 + discErr = applyErr
157 + } else {
158 + j.lastDiscovery = in.Timestamp
159 + }
160 + }
161 + if len(j.cfg.LLD.Steps) == 0 {
162 + j.ensureDefaultInstance()
163 + }
164 +
165 + for _, inst := range j.instances {
166 + active = append(active, inst.info())
167 + tracker.ensureInstance(inst.id)
168 + }
169 +
170 + var metrics []MetricResult
171 + if discErr == nil {
172 + metrics = j.runPipelines(payloadValue, tracker)
173 + }
174 +
175 + return j.finalizeResult(tracker, active, removed, Result{
176 + Job: res.Job,
177 + Timestamp: res.Timestamp,
178 + Metrics: metrics,
179 + Active: active,
180 + Removed: removed,
181 + })
182 +}
183 +
184 +// Destroy removes the job-specific state from the shared preprocessor and
185 +// forgets all LLD instances.
186 +func (j *Job) Destroy() {
187 + j.mu.Lock()
188 + defer j.mu.Unlock()
189 + for id := range j.instances {
190 + for _, pipe := range j.cfg.Pipelines {
191 + j.proc.ClearState(itemID(j.cfg, id, pipe.Name))
192 + }
193 + }
194 + j.proc.ClearState(fmt.Sprintf("%s.__lld", j.cfg.Name))
195 + j.instances = make(map[string]*instanceState)
196 +}
197 +
198 +func (j *Job) finalizeResult(tracker *iterationState, active, removed []InstanceInfo, res Result) Result {
199 + res.Active = active
200 + res.Removed = removed
201 + res.State.Job = tracker.jobFailure
202 + res.State.Instances = make(map[string]FailureFlags, len(tracker.instances))
203 + for id, flags := range tracker.instances {
204 + res.State.Instances[id] = *flags
205 + }
206 + return res
207 +}
208 +
209 +func (j *Job) shouldDiscover(now time.Time) bool {
210 + interval := j.cfg.LLD.IntervalDuration()
211 + if interval <= 0 {
212 + return true
213 + }
214 + return now.Sub(j.lastDiscovery) >= interval
215 +}
216 +
217 +func (j *Job) runDiscovery(val zpre.Value) ([]map[string]string, error) {
218 + steps := duplicateSteps(j.cfg.LLD.Steps, nil)
219 + itemID := fmt.Sprintf("%s.__lld", j.cfg.Name)
220 + res, err := j.proc.ExecutePipeline(itemID, val, steps)
221 + if err != nil {
222 + return nil, err
223 + }
224 + if len(res.Metrics) == 0 {
225 + return nil, fmt.Errorf("lld returned no metrics")
226 + }
227 + entries := []map[string]string{}
228 + for _, metric := range res.Metrics {
229 + payload := strings.TrimSpace(metric.Value)
230 + if payload == "" {
231 + continue
232 + }
233 + var batch []map[string]string
234 + if err := json.Unmarshal([]byte(payload), &batch); err != nil {
235 + return nil, err
236 + }
237 + entries = append(entries, batch...)
238 + }
239 + return entries, nil
240 +}
241 +
242 +func (j *Job) applyDiscovery(entries []map[string]string) ([]InstanceInfo, error) {
243 + seen := make(map[string]struct{}, len(entries))
244 + updates := make(map[string]map[string]string, len(entries))
245 + for _, entry := range entries {
246 + macros := cloneMacros(entry)
247 + instanceID := buildInstanceID(j.cfg, macros)
248 + if instanceID == "" {
249 + continue
250 + }
251 + if _, ok := seen[instanceID]; ok {
252 + j.warnf("duplicate LLD instance id %q dropped", instanceID)
253 + return nil, fmt.Errorf("duplicate instance id %s", instanceID)
254 + }
255 + seen[instanceID] = struct{}{}
256 + macros["{#INSTANCE_ID}"] = instanceID
257 + updates[instanceID] = macros
258 + }
259 + for id, macros := range updates {
260 + inst := j.instances[id]
261 + if inst == nil {
262 + inst = &instanceState{id: id, macros: make(map[string]string)}
263 + j.instances[id] = inst
264 + }
265 + inst.macros = cloneMacros(macros)
266 + inst.missing = 0
267 + }
268 + var removed []InstanceInfo
269 + for id, inst := range j.instances {
270 + if _, ok := seen[id]; ok {
271 + continue
272 + }
273 + inst.missing++
274 + threshold := j.cfg.LLD.MaxMissing
275 + if threshold <= 0 || inst.missing >= threshold {
276 + removed = append(removed, inst.info())
277 + j.removeInstance(inst)
278 + delete(j.instances, id)
279 + }
280 + }
281 + return removed, nil
282 +}
283 +
284 +func (j *Job) ensureDefaultInstance() {
285 + if len(j.instances) > 0 {
286 + return
287 + }
288 + j.instances["default"] = &instanceState{
289 + id: "default",
290 + macros: map[string]string{"{#INSTANCE_ID}": "default"},
291 + }
292 +}
293 +
294 +func (j *Job) removeInstance(inst *instanceState) {
295 + for _, pipe := range j.cfg.Pipelines {
296 + j.proc.ClearState(itemID(j.cfg, inst.id, pipe.Name))
297 + }
298 +}
299 +
300 +func (j *Job) runPipelines(val zpre.Value, tracker *iterationState) []MetricResult {
301 + var out []MetricResult
302 + for _, inst := range j.instances {
303 + macros := cloneMacros(inst.macros)
304 + for idx := range j.cfg.Pipelines {
305 + pipe := &j.cfg.Pipelines[idx]
306 + steps := duplicateSteps(pipe.Steps, macros)
307 + res, err := j.proc.ExecutePipeline(itemID(j.cfg, inst.id, pipe.Name), val, steps)
308 + if err != nil {
309 + if isDimensionError(err) {
310 + tracker.markDimensionFailure(inst.id)
311 + } else {
312 + tracker.markExtractionFailure(inst.id)
313 + }
314 + out = append(out, MetricResult{Instance: inst.info(), Pipeline: pipe, Error: err})
315 + continue
316 + }
317 + if res.Discarded || len(res.Metrics) == 0 {
318 + tracker.markDimensionFailure(inst.id)
319 + out = append(out, MetricResult{Instance: inst.info(), Pipeline: pipe, Discarded: true})
320 + continue
321 + }
322 + if len(res.Metrics) != 1 {
323 + tracker.markDimensionFailure(inst.id)
324 + if j.log != nil {
325 + j.log.Warningf("zabbix: job %s pipeline %s returned %d metrics (expected 1); dropping output", j.cfg.Name, pipe.Name, len(res.Metrics))
326 + }
327 + out = append(out, MetricResult{Instance: inst.info(), Pipeline: pipe, Error: fmt.Errorf("pipeline '%s' produced %d metrics; expected 1", pipe.Name, len(res.Metrics))})
328 + continue
329 + }
330 + metric := res.Metrics[0]
331 + valNum, err := strconv.ParseFloat(strings.TrimSpace(metric.Value), 64)
332 + if err != nil {
333 + tracker.markExtractionFailure(inst.id)
334 + if j.log != nil {
335 + j.log.Warningf("zabbix: job %s pipeline %s returned non-numeric value %q: %v", j.cfg.Name, pipe.Name, metric.Value, err)
336 + }
337 + out = append(out, MetricResult{Instance: inst.info(), Pipeline: pipe, Error: err})
338 + continue
339 + }
340 + out = append(out, MetricResult{
341 + Instance: inst.info(),
342 + Pipeline: pipe,
343 + Value: valNum,
344 + Precision: pipe.Precision,
345 + Name: metric.Name,
346 + Labels: cloneLabels(metric.Labels),
347 + })
348 + }
349 + }
350 + return out
351 +}
352 +
353 +// iteration tracking -------------------------------------------------------
354 +
355 +type iterationState struct {
356 + jobFailure FailureFlags
357 + instances map[string]*FailureFlags
358 +}
359 +
360 +func newIterationState() *iterationState {
361 + return &iterationState{instances: make(map[string]*FailureFlags)}
362 +}
363 +
364 +func (s *iterationState) ensureInstance(id string) {
365 + if _, ok := s.instances[id]; !ok {
366 + s.instances[id] = &FailureFlags{}
367 + }
368 +}
369 +
370 +func (s *iterationState) setCollectFailureAll(instances map[string]*instanceState) {
371 + s.jobFailure.Collect = true
372 + for id := range instances {
373 + s.ensureInstance(id)
374 + s.instances[id].Collect = true
375 + }
376 +}
377 +
378 +func (s *iterationState) setLLDFailureAll(instances map[string]*instanceState) {
379 + s.jobFailure.LLD = true
380 + for id := range instances {
381 + s.ensureInstance(id)
382 + s.instances[id].LLD = true
383 + }
384 +}
385 +
386 +func (s *iterationState) markExtractionFailure(id string) {
387 + s.jobFailure.Extraction = true
388 + s.ensureInstance(id)
389 + s.instances[id].Extraction = true
390 +}
391 +
392 +func (s *iterationState) markDimensionFailure(id string) {
393 + s.jobFailure.Dimension = true
394 + s.ensureInstance(id)
395 + s.instances[id].Dimension = true
396 +}
397 +
398 +func isDimensionError(err error) bool {
399 + if err == nil {
400 + return false
401 + }
402 + msg := err.Error()
403 + if strings.Contains(msg, "out of range") {
404 + return true
405 + }
406 + if strings.Contains(msg, "does not match") {
407 + return true
408 + }
409 + return false
410 +}
411 +
412 +// helpers -----------------------------------------------------------------
413 +
414 +type instanceState struct {
415 + id string
416 + macros map[string]string
417 + missing int
418 +}
419 +
420 +func (inst *instanceState) info() InstanceInfo {
421 + return InstanceInfo{ID: inst.id, Macros: cloneMacros(inst.macros)}
422 +}
423 +
424 +func duplicateSteps(steps []zpre.Step, macros map[string]string) []zpre.Step {
425 + out := make([]zpre.Step, len(steps))
426 + for i := range steps {
427 + step := steps[i]
428 + if macros != nil {
429 + step.Params = expandTemplate(step.Params, macros)
430 + }
431 + out[i] = step
432 + }
433 + return out
434 +}
435 +
436 +func itemID(cfg pkgzabbix.JobConfig, instanceID, pipe string) string {
437 + return fmt.Sprintf("%s.%s.%s", cfg.Name, instanceID, pipe)
438 +}
439 +
440 +func expandTemplate(tmpl string, macros map[string]string) string {
441 + if tmpl == "" || len(macros) == 0 {
442 + return tmpl
443 + }
444 + res := tmpl
445 + for k, v := range macros {
446 + res = strings.ReplaceAll(res, k, v)
447 + }
448 + return res
449 +}
450 +
451 +func cloneMacros(src map[string]string) map[string]string {
452 + out := make(map[string]string, len(src))
453 + for k, v := range src {
454 + out[k] = v
455 + }
456 + return out
457 +}
458 +
459 +func cloneLabels(src map[string]string) map[string]string {
460 + if len(src) == 0 {
461 + return nil
462 + }
463 + out := make(map[string]string, len(src))
464 + for k, v := range src {
465 + out[k] = v
466 + }
467 + return out
468 +}
469 +
470 +func buildInstanceID(cfg pkgzabbix.JobConfig, macros map[string]string) string {
471 + if val, ok := macros["{#INSTANCE_ID}"]; ok {
472 + if trimmed := strings.TrimSpace(val); trimmed != "" {
473 + return ids.Sanitize(trimmed)
474 + }
475 + }
476 + template := cfg.LLD.InstanceTemplate
477 + if strings.TrimSpace(template) == "" {
478 + template = fmt.Sprintf("%s_instance", cfg.Name)
479 + }
480 + raw := expandTemplate(template, macros)
481 + if strings.TrimSpace(raw) == "" {
482 + raw = fmt.Sprintf("%s_instance", cfg.Name)
483 + }
484 + return ids.Sanitize(raw)
485 +}
486 +
487 +func (j *Job) warnf(format string, args ...interface{}) {
488 + if j.log == nil {
489 + return
490 + }
491 + j.log.Warningf(format, args...)
492 +}
src/go/plugin/scripts.d/pkg/zabbix/jobengine/job_test.go new
+1107
@@ -0,0 +1,1107 @@
1 +package jobengine
2 +
3 +import (
4 + "errors"
5 + "fmt"
6 + "sort"
7 + "strings"
8 + "testing"
9 + "time"
10 +
11 + "github.com/netdata/netdata/go/plugins/pkg/confopt"
12 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/ids"
13 + pkgzabbix "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbix"
14 + zpre "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbixpreproc"
15 +)
16 +
17 +const pathLengthScript = `
18 +var rows = JSON.parse(value);
19 +var fsKey = "{#" + "FSNAME}";
20 +var pathKey = "{#" + "FSPATH}";
21 +var target = "{#FSNAME}";
22 +var matched = "";
23 +for (var i = 0; i < rows.length; i++) {
24 + if (rows[i][fsKey] === target) {
25 + matched = rows[i][pathKey] || "";
26 + break;
27 + }
28 +}
29 +value = (matched || "").length.toString();
30 +return value;
31 +`
32 +
33 +const snmpValueScript = `
34 +var rows = JSON.parse(value);
35 +var indexKey = "{#" + "SNMPINDEX}";
36 +var valueKey = "MACRO2";
37 +var target = "{#SNMPINDEX}";
38 +var matched = "";
39 +for (var i = 0; i < rows.length; i++) {
40 + if (rows[i][indexKey] === target) {
41 + matched = rows[i][valueKey] || "";
42 + break;
43 + }
44 +}
45 +value = matched || "";
46 +return value;
47 +`
48 +
49 +const promDiscoveryScript = `
50 +var entries = JSON.parse(value);
51 +var out = [];
52 +for (var i = 0; i < entries.length; i++) {
53 + var entry = entries[i];
54 + if (entry.name !== "node_filesystem_info") {
55 + continue;
56 + }
57 + var labels = entry.labels || {};
58 + var mount = labels.mountpoint || "";
59 + if (!mount) {
60 + continue;
61 + }
62 + out.push({"{#MOUNT}": mount});
63 +}
64 +value = JSON.stringify(out);
65 +return value;
66 +`
67 +
68 +const promValueScript = `
69 +var entries = JSON.parse(value);
70 +var target = "{#MOUNT}";
71 +var result = "";
72 +for (var i = 0; i < entries.length; i++) {
73 + var entry = entries[i];
74 + if (entry.name !== "node_filesystem_free_bytes") {
75 + continue;
76 + }
77 + var labels = entry.labels || {};
78 + if (labels.mountpoint === target) {
79 + result = entry.value || entry.valueStr || "";
80 + break;
81 + }
82 +}
83 +value = result ? result.toString() : "";
84 +return value;
85 +`
86 +
87 +const csvMultiValueScript = `
88 +var row = JSON.parse(value);
89 +var raw = row["value"] || row["VALUE"] || "0";
90 +value = raw.toString();
91 +return value;
92 +`
93 +
94 +func TestJobProcessMatrix(t *testing.T) {
95 + cases := buildMatrixCases()
96 + if len(cases) < 100 {
97 + t.Fatalf("expected at least 100 scenarios, have %d", len(cases))
98 + }
99 + for _, tc := range cases {
100 + t.Run(tc.name, func(t *testing.T) {
101 + proc := zpre.NewPreprocessor("jobengine-" + tc.name)
102 + job, err := NewJob(tc.cfg, proc, Options{})
103 + if err != nil {
104 + t.Fatalf("new job failed: %v", err)
105 + }
106 + res := job.Process(tc.input)
107 + job.Destroy()
108 + tc.expect.assert(t, res)
109 + })
110 + }
111 +}
112 +
113 +// ----------------------------------------------------------------------------
114 +
115 +type scenario struct {
116 + name string
117 + cfg pkgzabbix.JobConfig
118 + input Input
119 + expect expectation
120 +}
121 +
122 +type expectation struct {
123 + metrics int
124 + values []float64
125 + active int
126 + removed int
127 + removedIDs []string
128 + job FailureFlags
129 + instances map[string]FailureFlags
130 + macroKeys []string
131 + errorMetrics int
132 +}
133 +
134 +func (e expectation) assert(t *testing.T, res Result) {
135 + if res.State.Job != e.job {
136 + t.Fatalf("job flags mismatch: want %+v got %+v", e.job, res.State.Job)
137 + }
138 + if e.instances != nil && len(res.State.Instances) != len(e.instances) {
139 + t.Fatalf("instance flag map mismatch: want %d got %d", len(e.instances), len(res.State.Instances))
140 + }
141 + for id, flags := range e.instances {
142 + if got, ok := res.State.Instances[id]; !ok || got != flags {
143 + t.Fatalf("instance %s flags mismatch: want %+v got %+v", id, flags, got)
144 + }
145 + }
146 + if len(res.Metrics) != e.metrics {
147 + t.Fatalf("metrics count mismatch: want %d got %d", e.metrics, len(res.Metrics))
148 + }
149 + if len(res.Active) != e.active {
150 + t.Fatalf("active instances mismatch: want %d got %d", e.active, len(res.Active))
151 + }
152 + if len(res.Removed) != e.removed {
153 + t.Fatalf("removed instances mismatch: want %d got %d", e.removed, len(res.Removed))
154 + }
155 + if len(e.removedIDs) > 0 {
156 + gotIDs := make([]string, 0, len(res.Removed))
157 + for _, inst := range res.Removed {
158 + gotIDs = append(gotIDs, inst.ID)
159 + }
160 + sort.Strings(gotIDs)
161 + expected := append([]string(nil), e.removedIDs...)
162 + sort.Strings(expected)
163 + if len(gotIDs) != len(expected) {
164 + t.Fatalf("removed IDs mismatch: want %v got %v", expected, gotIDs)
165 + }
166 + for i := range gotIDs {
167 + if gotIDs[i] != expected[i] {
168 + t.Fatalf("removed IDs mismatch: want %v got %v", expected, gotIDs)
169 + }
170 + }
171 + }
172 + if len(e.values) > 0 {
173 + gotVals := make([]float64, 0, len(res.Metrics))
174 + for _, m := range res.Metrics {
175 + gotVals = append(gotVals, m.Value)
176 + }
177 + sort.Float64s(gotVals)
178 + expected := append([]float64(nil), e.values...)
179 + sort.Float64s(expected)
180 + if len(gotVals) != len(expected) {
181 + t.Fatalf("value set mismatch: want %v got %v", expected, gotVals)
182 + }
183 + for i := range gotVals {
184 + if diff := gotVals[i] - expected[i]; diff > 1e-9 || diff < -1e-9 {
185 + t.Fatalf("value mismatch: want %v got %v", expected, gotVals)
186 + }
187 + }
188 + }
189 + if len(e.macroKeys) > 0 {
190 + for _, m := range res.Metrics {
191 + for _, key := range e.macroKeys {
192 + if _, ok := m.Instance.Macros[key]; !ok {
193 + t.Fatalf("expected macro %s in metric instance %+v", key, m.Instance)
194 + }
195 + }
196 + }
197 + }
198 + if e.errorMetrics > 0 {
199 + count := 0
200 + for _, m := range res.Metrics {
201 + if m.Error != nil {
202 + count++
203 + }
204 + }
205 + if count != e.errorMetrics {
206 + t.Fatalf("expected %d errored metrics, got %d", e.errorMetrics, count)
207 + }
208 + }
209 +}
210 +
211 +// ----------------------------------------------------------------------------
212 +
213 +type variant int
214 +
215 +const (
216 + variantNormal variant = iota
217 + variantCollectErr
218 + variantExtractionErr
219 + variantDimensionErr
220 + variantLLDErr
221 +)
222 +
223 +func buildMatrixCases() []scenario {
224 + var cases []scenario
225 +
226 + simpleSamples := []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
227 + simpleVariants := []variant{variantNormal, variantCollectErr, variantExtractionErr, variantDimensionErr}
228 + for idx, sample := range simpleSamples {
229 + for _, v := range simpleVariants {
230 + cases = append(cases, buildSimpleScenario(idx, sample, v))
231 + }
232 + }
233 +
234 + multiSamples := []struct{ used, free float64 }{
235 + {10, 90}, {20, 80}, {30, 70}, {40, 60}, {50, 50}, {60, 40}, {70, 30}, {80, 20},
236 + }
237 + for idx, sample := range multiSamples {
238 + for _, v := range simpleVariants {
239 + cases = append(cases, buildMultiScenario(idx, sample.used, sample.free, v))
240 + }
241 + }
242 +
243 + lldSamples := []struct{ root, home float64 }{
244 + {10, 20}, {15, 25}, {5, 35}, {40, 10}, {12, 13}, {7, 9},
245 + }
246 + lldVariants := []variant{variantNormal, variantCollectErr, variantExtractionErr, variantDimensionErr, variantLLDErr}
247 + for idx, sample := range lldSamples {
248 + for _, v := range lldVariants {
249 + cases = append(cases, buildLLDScenario(idx, sample.root, sample.home, v))
250 + }
251 + }
252 +
253 + for idx := 0; idx < 10; idx++ {
254 + cases = append(cases, buildCSVLLDScenario(idx))
255 + cases = append(cases, buildXMLLLDScenario(idx))
256 + cases = append(cases, buildSNMPLLDScenario(idx))
257 + }
258 +
259 + promSamples := []struct{ root, home float64 }{
260 + {60, 80}, {45, 70}, {90, 20}, {30, 55},
261 + }
262 + for idx, sample := range promSamples {
263 + for _, v := range lldVariants {
264 + cases = append(cases, buildPrometheusLLDScenario(idx, sample.root, sample.home, v))
265 + }
266 + }
267 +
268 + for idx := 0; idx < 5; idx++ {
269 + for _, v := range simpleVariants {
270 + cases = append(cases, buildXPathScenario(idx, float64(40+idx*3), float64(50+idx*4), v))
271 + cases = append(cases, buildCSVMultiScenario(idx, float64(10+idx), v))
272 + }
273 + }
274 +
275 + return cases
276 +}
277 +
278 +// ----------------------------------------------------------------------------
279 +
280 +type stateVariant int
281 +
282 +const (
283 + stateVariantStable stateVariant = iota
284 + stateVariantAddRemove
285 + stateVariantLLDFailure
286 + stateVariantExtractionFailure
287 + stateVariantDimensionFailure
288 +)
289 +
290 +type statefulScenario struct {
291 + name string
292 + cfg pkgzabbix.JobConfig
293 + runs []stateRun
294 +}
295 +
296 +type stateRun struct {
297 + label string
298 + input Input
299 + expect expectation
300 +}
301 +
302 +func TestJobProcessStatefulMatrix(t *testing.T) {
303 + cases := buildStatefulCases()
304 + if len(cases) < 50 {
305 + t.Fatalf("expected >=50 stateful scenarios, have %d", len(cases))
306 + }
307 + for _, sc := range cases {
308 + t.Run(sc.name, func(t *testing.T) {
309 + proc := zpre.NewPreprocessor("state-" + sc.name)
310 + job, err := NewJob(sc.cfg, proc, Options{})
311 + if err != nil {
312 + t.Fatalf("new job failed: %v", err)
313 + }
314 + for _, run := range sc.runs {
315 + res := job.Process(run.input)
316 + run.expect.assert(t, res)
317 + }
318 + job.Destroy()
319 + })
320 + }
321 +}
322 +
323 +func TestJobProcessDuplicateDiscovery(t *testing.T) {
324 + cfg := lldJobConfig("duplicate")
325 + input := Input{Payload: []byte(`{"discovery":[{"{#FSNAME}":"/"},{"{#FSNAME}":"/"},{"{#FSNAME}":"/home"}],"data":[{"fs":"/","used":10,"free":90},{"fs":"/home","used":20,"free":80}]}`), Timestamp: time.Now()}
326 + proc := zpre.NewPreprocessor("dup")
327 + job, err := NewJob(cfg, proc, Options{})
328 + if err != nil {
329 + t.Fatalf("new job failed: %v", err)
330 + }
331 + res := job.Process(input)
332 + if !res.State.Job.LLD {
333 + t.Fatalf("expected LLD failure due to duplicate ids")
334 + }
335 + if len(res.Metrics) != 0 {
336 + t.Fatalf("expected no metrics when discovery fails, got %d", len(res.Metrics))
337 + }
338 +}
339 +
340 +func TestJobShouldDiscover(t *testing.T) {
341 + job := &Job{}
342 + if !job.shouldDiscover(time.Now()) {
343 + t.Fatalf("interval <=0 should force discovery")
344 + }
345 + job.cfg.LLD.Interval = confopt.Duration(10 * time.Second)
346 + job.lastDiscovery = time.Now()
347 + if job.shouldDiscover(time.Now()) {
348 + t.Fatalf("should not rediscover before interval")
349 + }
350 + job.lastDiscovery = time.Now().Add(-11 * time.Second)
351 + if !job.shouldDiscover(time.Now()) {
352 + t.Fatalf("should rediscover after interval elapsed")
353 + }
354 +}
355 +
356 +func TestLLDInstanceRemovalAfterMaxMissing(t *testing.T) {
357 + cfg := lldJobConfig("miss")
358 + cfg.LLD.MaxMissing = 2
359 + proc := zpre.NewPreprocessor("miss-test")
360 + job, err := NewJob(cfg, proc, Options{})
361 + if err != nil {
362 + t.Fatalf("new job failed: %v", err)
363 + }
364 + createPayload := Input{Payload: []byte(`{"discovery":[{"{#FSNAME}":"/"}],"data":[{"fs":"/","used":10,"free":90}]}`), Timestamp: time.Now()}
365 + res := job.Process(createPayload)
366 + if len(res.Active) != 1 {
367 + t.Fatalf("expected 1 active instance, got %d", len(res.Active))
368 + }
369 + missingPayload := Input{Payload: []byte(`{"discovery":[],"data":[]}`), Timestamp: time.Now().Add(time.Second)}
370 + res = job.Process(missingPayload)
371 + if len(res.Removed) != 0 {
372 + t.Fatalf("expected no removal after first miss, got %d", len(res.Removed))
373 + }
374 + missingPayload.Timestamp = missingPayload.Timestamp.Add(time.Second)
375 + res = job.Process(missingPayload)
376 + if len(res.Removed) != 1 {
377 + t.Fatalf("expected removal after reaching max_missing, got %d", len(res.Removed))
378 + }
379 +}
380 +
381 +func buildStatefulCases() []statefulScenario {
382 + samples := []struct{ root, home float64 }{
383 + {10, 20}, {15, 25}, {5, 35}, {40, 10}, {12, 13}, {7, 9}, {22, 11}, {18, 16}, {9, 12}, {31, 17},
384 + }
385 + variants := []stateVariant{stateVariantStable, stateVariantAddRemove, stateVariantLLDFailure, stateVariantExtractionFailure, stateVariantDimensionFailure}
386 + var cases []statefulScenario
387 + for idx, sample := range samples {
388 + for _, v := range variants {
389 + cases = append(cases, buildStatefulScenario(idx, sample.root, sample.home, v))
390 + }
391 + }
392 + promStates := []struct{ root, home float64 }{{60, 45}, {55, 35}}
393 + for idx, sample := range promStates {
394 + cases = append(cases, buildPromStatefulScenario(idx, sample.root, sample.home))
395 + }
396 + cases = append(cases, buildXPathStatefulScenario(0, 42, 57))
397 + return cases
398 +}
399 +
400 +func buildStatefulScenario(idx int, root, home float64, v stateVariant) statefulScenario {
401 + name := fmt.Sprintf("state_%d_%s", idx, stateVariantName(v))
402 + cfg := lldJobConfig(fmt.Sprintf("state_%d", idx))
403 + rootID := ids.Sanitize("/")
404 + homeID := ids.Sanitize("/home")
405 + instBoth := []instData{{"/", root, 100 - root}, {"/home", home, 100 - home}}
406 + instRoot := []instData{{"/", root, 100 - root}}
407 + instHome := []instData{{"/home", home, 100 - home}}
408 + var runs []stateRun
409 + switch v {
410 + case stateVariantStable:
411 + payload := []byte(lldPayload(instBoth))
412 + expect := expectation{metrics: 4, values: valuesFor(instBoth), active: 2, job: FailureFlags{}, instances: zeroFlags(rootID, homeID), macroKeys: []string{"{#FSNAME}"}}
413 + runs = []stateRun{
414 + {label: "run1", input: Input{Payload: payload, Timestamp: time.Now()}, expect: expect},
415 + {label: "run2", input: Input{Payload: payload, Timestamp: time.Now()}, expect: expect},
416 + {label: "run3", input: Input{Payload: payload, Timestamp: time.Now()}, expect: expect},
417 + }
418 + case stateVariantAddRemove:
419 + payload1 := []byte(lldPayload(instRoot))
420 + payload2 := []byte(lldPayload(instBoth))
421 + payload3 := []byte(lldPayload(instHome))
422 + runs = []stateRun{
423 + {label: "run1", input: Input{Payload: payload1, Timestamp: time.Now()}, expect: expectation{metrics: 2, values: valuesFor(instRoot), active: 1, job: FailureFlags{}, instances: zeroFlags(rootID), macroKeys: []string{"{#FSNAME}"}}},
424 + {label: "run2", input: Input{Payload: payload2, Timestamp: time.Now()}, expect: expectation{metrics: 4, values: valuesFor(instBoth), active: 2, job: FailureFlags{}, instances: zeroFlags(rootID, homeID), macroKeys: []string{"{#FSNAME}"}}},
425 + {label: "run3", input: Input{Payload: payload3, Timestamp: time.Now()}, expect: expectation{metrics: 2, values: valuesFor(instHome), active: 1, removed: 1, removedIDs: []string{rootID}, job: FailureFlags{}, instances: zeroFlags(homeID), macroKeys: []string{"{#FSNAME}"}}},
426 + }
427 + case stateVariantLLDFailure:
428 + payload := []byte(lldPayload(instBoth))
429 + runs = []stateRun{
430 + {label: "run1", input: Input{Payload: payload, Timestamp: time.Now()}, expect: expectation{metrics: 4, values: valuesFor(instBoth), active: 2, job: FailureFlags{}, instances: zeroFlags(rootID, homeID), macroKeys: []string{"{#FSNAME}"}}},
431 + {label: "run2", input: Input{Payload: []byte("not-json"), Timestamp: time.Now()}, expect: expectation{metrics: 0, values: nil, active: 2, job: FailureFlags{LLD: true}, instances: map[string]FailureFlags{rootID: {LLD: true}, homeID: {LLD: true}}}},
432 + {label: "run3", input: Input{Payload: payload, Timestamp: time.Now()}, expect: expectation{metrics: 4, values: valuesFor(instBoth), active: 2, job: FailureFlags{}, instances: zeroFlags(rootID, homeID), macroKeys: []string{"{#FSNAME}"}}},
433 + }
434 + case stateVariantExtractionFailure:
435 + payload := []byte(lldPayload(instBoth))
436 + badPayload := []byte(`{"discovery":[{"{#FSNAME}":"/"},{"{#FSNAME}":"/home"}],"data":[{"fs":"/","used":"oops","free":"oops"},{"fs":"/home","used":"bad","free":"bad"}]}`)
437 + runs = []stateRun{
438 + {label: "run1", input: Input{Payload: payload, Timestamp: time.Now()}, expect: expectation{metrics: 4, values: valuesFor(instBoth), active: 2, job: FailureFlags{}, instances: zeroFlags(rootID, homeID), macroKeys: []string{"{#FSNAME}"}}},
439 + {label: "run2", input: Input{Payload: badPayload, Timestamp: time.Now()}, expect: expectation{metrics: 4, values: nil, active: 2, job: FailureFlags{Extraction: true}, instances: map[string]FailureFlags{rootID: {Extraction: true}, homeID: {Extraction: true}}, errorMetrics: 4}},
440 + {label: "run3", input: Input{Payload: payload, Timestamp: time.Now()}, expect: expectation{metrics: 4, values: valuesFor(instBoth), active: 2, job: FailureFlags{}, instances: zeroFlags(rootID, homeID), macroKeys: []string{"{#FSNAME}"}}},
441 + }
442 + case stateVariantDimensionFailure:
443 + payload := []byte(lldPayload(instBoth))
444 + badPayload := []byte(`{"discovery":[{"{#FSNAME}":"/"},{"{#FSNAME}":"/home"}],"data":[{"fs":"/","used":150,"free":-10},{"fs":"/home","used":180,"free":-20}]}`)
445 + runs = []stateRun{
446 + {label: "run1", input: Input{Payload: payload, Timestamp: time.Now()}, expect: expectation{metrics: 4, values: valuesFor(instBoth), active: 2, job: FailureFlags{}, instances: zeroFlags(rootID, homeID), macroKeys: []string{"{#FSNAME}"}}},
447 + {label: "run2", input: Input{Payload: badPayload, Timestamp: time.Now()}, expect: expectation{metrics: 4, values: nil, active: 2, job: FailureFlags{Dimension: true}, instances: map[string]FailureFlags{rootID: {Dimension: true}, homeID: {Dimension: true}}, errorMetrics: 4}},
448 + {label: "run3", input: Input{Payload: payload, Timestamp: time.Now()}, expect: expectation{metrics: 4, values: valuesFor(instBoth), active: 2, job: FailureFlags{}, instances: zeroFlags(rootID, homeID), macroKeys: []string{"{#FSNAME}"}}},
449 + }
450 + }
451 + return statefulScenario{name: name, cfg: cfg, runs: runs}
452 +}
453 +
454 +func buildPromStatefulScenario(idx int, root, home float64) statefulScenario {
455 + name := fmt.Sprintf("state_prom_%d", idx)
456 + cfg := prometheusJobConfig(fmt.Sprintf("state_prom_%d", idx))
457 + instRoot := ids.Sanitize("fs_/")
458 + instHome := ids.Sanitize("fs_/home")
459 + runs := []stateRun{
460 + {
461 + label: "run1",
462 + input: Input{Payload: []byte(prometheusPayload(root, home)), Timestamp: time.Now()},
463 + expect: expectation{metrics: 2, values: []float64{root, home}, active: 2, job: FailureFlags{}, instances: zeroFlags(instRoot, instHome)},
464 + },
465 + {
466 + label: "run2",
467 + input: Input{Payload: []byte("not-prom"), Timestamp: time.Now()},
468 + expect: expectation{metrics: 0, values: nil, active: 2, job: FailureFlags{LLD: true}, instances: map[string]FailureFlags{instRoot: {LLD: true}, instHome: {LLD: true}}},
469 + },
470 + {
471 + label: "run3",
472 + input: Input{Payload: []byte(prometheusPayload(root+5, home+3)), Timestamp: time.Now()},
473 + expect: expectation{metrics: 2, values: []float64{root + 5, home + 3}, active: 2, job: FailureFlags{}, instances: zeroFlags(instRoot, instHome)},
474 + },
475 + }
476 + return statefulScenario{name: name, cfg: cfg, runs: runs}
477 +}
478 +
479 +func buildXPathStatefulScenario(idx int, cpu, gpu float64) statefulScenario {
480 + name := fmt.Sprintf("state_xpath_%d", idx)
481 + cfg := xpathJobConfig(fmt.Sprintf("state_xpath_%d", idx))
482 + defaultID := "default"
483 + runs := []stateRun{
484 + {
485 + label: "run1",
486 + input: Input{Payload: []byte(fmt.Sprintf(`<sensors><sensor id="cpu">%f</sensor><sensor id="gpu">%f</sensor></sensors>`, cpu, gpu)), Timestamp: time.Now()},
487 + expect: expectation{metrics: 2, values: []float64{cpu, gpu}, active: 1, job: FailureFlags{}, instances: zeroFlags(defaultID)},
488 + },
489 + {
490 + label: "run2",
491 + input: Input{Payload: []byte(`<sensors><sensor id="cpu">oops</sensor><sensor id="gpu">bad</sensor></sensors>`), Timestamp: time.Now()},
492 + expect: expectation{metrics: 2, values: nil, active: 1, job: FailureFlags{Extraction: true}, instances: map[string]FailureFlags{defaultID: {Extraction: true}}, errorMetrics: 2},
493 + },
494 + {
495 + label: "run3",
496 + input: Input{Payload: []byte(fmt.Sprintf(`<sensors><sensor id="cpu">%f</sensor><sensor id="gpu">%f</sensor></sensors>`, cpu+2, gpu+2)), Timestamp: time.Now()},
497 + expect: expectation{metrics: 2, values: []float64{cpu + 2, gpu + 2}, active: 1, job: FailureFlags{}, instances: zeroFlags(defaultID)},
498 + },
499 + }
500 + return statefulScenario{name: name, cfg: cfg, runs: runs}
501 +}
502 +
503 +func stateVariantName(v stateVariant) string {
504 + switch v {
505 + case stateVariantStable:
506 + return "stable"
507 + case stateVariantAddRemove:
508 + return "add_remove"
509 + case stateVariantLLDFailure:
510 + return "lld_fail"
511 + case stateVariantExtractionFailure:
512 + return "extract_fail"
513 + case stateVariantDimensionFailure:
514 + return "dimension_fail"
515 + default:
516 + return "unknown"
517 + }
518 +}
519 +
520 +type instData struct {
521 + name string
522 + used float64
523 + free float64
524 +}
525 +
526 +func lldPayload(insts []instData) string {
527 + var discParts []string
528 + var dataParts []string
529 + for _, inst := range insts {
530 + discParts = append(discParts, fmt.Sprintf(`{"{#FSNAME}":"%s"}`, inst.name))
531 + dataParts = append(dataParts, fmt.Sprintf(`{"fs":"%s","used":%g,"free":%g}`, inst.name, inst.used, inst.free))
532 + }
533 + return fmt.Sprintf(`{"discovery":[%s],"data":[%s]}`, strings.Join(discParts, ","), strings.Join(dataParts, ","))
534 +}
535 +
536 +func valuesFor(insts []instData) []float64 {
537 + var vals []float64
538 + for _, inst := range insts {
539 + vals = append(vals, inst.used, inst.free)
540 + }
541 + return vals
542 +}
543 +
544 +func prometheusPayload(root, home float64) string {
545 + return prometheusPayloadStrings(fmt.Sprintf("%f", root), fmt.Sprintf("%f", home))
546 +}
547 +
548 +func prometheusPayloadStrings(rootVal, homeVal string) string {
549 + return fmt.Sprintf(`# HELP node_filesystem_free_bytes Free bytes
550 +# TYPE node_filesystem_free_bytes gauge
551 +# HELP node_filesystem_info Filesystem info
552 +# TYPE node_filesystem_info gauge
553 +node_filesystem_info{mountpoint="/",fstype="ext4"} 1
554 +node_filesystem_info{mountpoint="/home",fstype="xfs"} 1
555 +node_filesystem_free_bytes{mountpoint="/",fstype="ext4"} %s
556 +node_filesystem_free_bytes{mountpoint="/home",fstype="xfs"} %s
557 +`, rootVal, homeVal)
558 +}
559 +
560 +func zeroFlags(ids ...string) map[string]FailureFlags {
561 + if len(ids) == 0 {
562 + return map[string]FailureFlags{}
563 + }
564 + m := make(map[string]FailureFlags, len(ids))
565 + for _, id := range ids {
566 + m[id] = FailureFlags{}
567 + }
568 + return m
569 +}
570 +
571 +func buildSimpleScenario(idx int, value float64, v variant) scenario {
572 + name := fmt.Sprintf("simple_%d_%s", idx, variantName(v))
573 + cfg := simpleJobConfig(fmt.Sprintf("simple_%d", idx))
574 + input := Input{Payload: []byte(fmt.Sprintf(`{"value": %f}`, value)), Timestamp: time.Now()}
575 + expect := expectation{metrics: 1, values: []float64{value}, active: 1, job: FailureFlags{}, instances: map[string]FailureFlags{"default": {}}}
576 + switch v {
577 + case variantCollectErr:
578 + input.CollectError = errors.New("collect")
579 + expect.metrics = 0
580 + expect.values = nil
581 + expect.active = 1
582 + expect.job = FailureFlags{Collect: true}
583 + expect.instances = map[string]FailureFlags{"default": {Collect: true}}
584 + case variantExtractionErr:
585 + input.Payload = []byte(`{"value":"oops"}`)
586 + expect.metrics = 1
587 + expect.values = nil
588 + expect.job = FailureFlags{Extraction: true}
589 + expect.instances = map[string]FailureFlags{"default": {Extraction: true}}
590 + case variantDimensionErr:
591 + input.Payload = []byte(`{"value": 200}`)
592 + expect.metrics = 1
593 + expect.values = nil
594 + expect.job = FailureFlags{Dimension: true}
595 + expect.instances = map[string]FailureFlags{"default": {Dimension: true}}
596 + }
597 + return scenario{name: name, cfg: cfg, input: input, expect: expect}
598 +}
599 +
600 +func buildMultiScenario(idx int, used, free float64, v variant) scenario {
601 + name := fmt.Sprintf("multi_%d_%s", idx, variantName(v))
602 + cfg := multiPipelineJobConfig(fmt.Sprintf("multi_%d", idx))
603 + input := Input{Payload: []byte(fmt.Sprintf(`{"used": %f, "free": %f}`, used, free)), Timestamp: time.Now()}
604 + // Validation happens BEFORE multiplier, so all normal inputs pass validation
605 + // Multiplier=2 is applied after validation
606 + usedAfterMultiplier := used * 2
607 + expect := expectation{metrics: 2, values: []float64{usedAfterMultiplier, free}, active: 1, job: FailureFlags{}, instances: map[string]FailureFlags{"default": {}}}
608 + switch v {
609 + case variantCollectErr:
610 + input.CollectError = errors.New("collect")
611 + expect.metrics = 0
612 + expect.values = nil
613 + expect.active = 1
614 + expect.job = FailureFlags{Collect: true}
615 + expect.instances = map[string]FailureFlags{"default": {Collect: true}}
616 + case variantExtractionErr:
617 + input.Payload = []byte(`{"used":"oops","free":"oops"}`)
618 + expect.metrics = 2
619 + expect.values = nil
620 + expect.job = FailureFlags{Extraction: true}
621 + expect.instances = map[string]FailureFlags{"default": {Extraction: true}}
622 + case variantDimensionErr:
623 + input.Payload = []byte(`{"used": 200, "free": 300}`)
624 + expect.metrics = 2
625 + expect.values = nil
626 + expect.job = FailureFlags{Dimension: true}
627 + expect.instances = map[string]FailureFlags{"default": {Dimension: true}}
628 + }
629 + return scenario{name: name, cfg: cfg, input: input, expect: expect}
630 +}
631 +
632 +func buildLLDScenario(idx int, root, home float64, v variant) scenario {
633 + name := fmt.Sprintf("lld_%d_%s", idx, variantName(v))
634 + cfg := lldJobConfig(fmt.Sprintf("lld_%d", idx))
635 + payload := fmt.Sprintf(`{"discovery":[{"{#FSNAME}":"/"},{"{#FSNAME}":"/home"}],"data":[{"fs":"/","used":%f,"free":%f},{"fs":"/home","used":%f,"free":%f}]}`, root, 100-root, home, 100-home)
636 + input := Input{Payload: []byte(payload), Timestamp: time.Now()}
637 + values := []float64{root, home, 100 - root, 100 - home}
638 + instA := ids.Sanitize("/")
639 + instB := ids.Sanitize("/home")
640 + macroKeys := []string{}
641 + if v == variantNormal {
642 + macroKeys = []string{"{#FSNAME}"}
643 + }
644 + expect := expectation{
645 + metrics: 4,
646 + values: values,
647 + active: 2,
648 + job: FailureFlags{},
649 + instances: map[string]FailureFlags{instA: {}, instB: {}},
650 + macroKeys: macroKeys,
651 + }
652 + switch v {
653 + case variantCollectErr:
654 + input.CollectError = errors.New("collect")
655 + expect.metrics = 0
656 + expect.values = nil
657 + expect.active = 1
658 + expect.job = FailureFlags{Collect: true}
659 + expect.instances = map[string]FailureFlags{"default": {Collect: true}}
660 + case variantExtractionErr:
661 + input.Payload = []byte(`{"discovery":[{"{#FSNAME}":"/"},{"{#FSNAME}":"/home"}],"data":[{"fs":"/","used":"abc","free":"def"},{"fs":"/home","used":"ghi","free":"jkl"}]}`)
662 + expect.metrics = 4
663 + expect.values = nil
664 + expect.job = FailureFlags{Extraction: true}
665 + expect.instances = map[string]FailureFlags{instA: {Extraction: true}, instB: {Extraction: true}}
666 + case variantDimensionErr:
667 + input.Payload = []byte(`{"discovery":[{"{#FSNAME}":"/"},{"{#FSNAME}":"/home"}],"data":[{"fs":"/","used":150,"free":-10},{"fs":"/home","used":180,"free":-20}]}`)
668 + expect.metrics = 4
669 + expect.values = nil
670 + expect.job = FailureFlags{Dimension: true}
671 + expect.instances = map[string]FailureFlags{instA: {Dimension: true}, instB: {Dimension: true}}
672 + case variantLLDErr:
673 + input.Payload = []byte(`not-json`)
674 + expect.metrics = 0
675 + expect.values = nil
676 + expect.active = 0
677 + expect.job = FailureFlags{LLD: true}
678 + expect.instances = map[string]FailureFlags{}
679 + return scenario{name: name, cfg: cfg, input: input, expect: expect}
680 + }
681 + return scenario{name: name, cfg: cfg, input: input, expect: expect}
682 +}
683 +
684 +func buildCSVLLDScenario(idx int) scenario {
685 + name := fmt.Sprintf("lld_csv_%d", idx)
686 + cfg := csvLLDJobConfig(fmt.Sprintf("csv_job_%d", idx))
687 + input := Input{Payload: []byte(`{#FSNAME},{#FSPATH}
688 +root,/
689 +home,/home`), Timestamp: time.Now()}
690 + instA := ids.Sanitize("root")
691 + instB := ids.Sanitize("home")
692 + expect := expectation{metrics: 2, values: []float64{1, 5}, active: 2, job: FailureFlags{}, instances: map[string]FailureFlags{instA: {}, instB: {}}, macroKeys: []string{"{#FSNAME}"}}
693 + return scenario{name: name, cfg: cfg, input: input, expect: expect}
694 +}
695 +
696 +func buildXMLLLDScenario(idx int) scenario {
697 + name := fmt.Sprintf("lld_xml_%d", idx)
698 + cfg := xmlLLDJobConfig(fmt.Sprintf("xml_job_%d", idx))
699 + input := Input{Payload: []byte(`<files><fs name="root" path="/"/><fs name="home" path="/home"/></files>`), Timestamp: time.Now()}
700 + instA := ids.Sanitize("root")
701 + instB := ids.Sanitize("home")
702 + expect := expectation{metrics: 2, values: []float64{1, 5}, active: 2, job: FailureFlags{}, instances: map[string]FailureFlags{instA: {}, instB: {}}, macroKeys: []string{"{#FSNAME}"}}
703 + return scenario{name: name, cfg: cfg, input: input, expect: expect}
704 +}
705 +
706 +func buildSNMPLLDScenario(idx int) scenario {
707 + name := fmt.Sprintf("lld_snmp_%d", idx)
708 + cfg := snmpLLDJobConfig(fmt.Sprintf("snmp_job_%d", idx))
709 + snmpData := `.1.3.6.1.1 = STRING: "ifA"
710 +.1.3.6.1.2 = STRING: "ifB"
711 +.1.3.6.2.1 = STRING: "100"
712 +.1.3.6.2.2 = STRING: "200"`
713 + input := Input{Payload: []byte(snmpData), Timestamp: time.Now()}
714 + instA := ids.Sanitize("ifA")
715 + instB := ids.Sanitize("ifB")
716 + expect := expectation{metrics: 2, values: []float64{100, 200}, active: 2, job: FailureFlags{}, instances: map[string]FailureFlags{instA: {}, instB: {}}, macroKeys: []string{"MACRO1", "MACRO2", "{#SNMPINDEX}"}}
717 + return scenario{name: name, cfg: cfg, input: input, expect: expect}
718 +}
719 +
720 +func buildPrometheusLLDScenario(idx int, root, home float64, v variant) scenario {
721 + name := fmt.Sprintf("lld_prom_%d_%s", idx, variantName(v))
722 + cfg := prometheusJobConfig(fmt.Sprintf("prom_job_%d", idx))
723 + input := Input{Payload: []byte(prometheusPayload(root, home)), Timestamp: time.Now()}
724 + instRoot := ids.Sanitize("fs_/")
725 + instHome := ids.Sanitize("fs_/home")
726 + expect := expectation{
727 + metrics: 2,
728 + values: []float64{root, home},
729 + active: 2,
730 + job: FailureFlags{},
731 + instances: map[string]FailureFlags{instRoot: {}, instHome: {}},
732 + macroKeys: []string{"{#MOUNT}"},
733 + }
734 + switch v {
735 + case variantCollectErr:
736 + input.CollectError = errors.New("collect")
737 + expect.metrics = 0
738 + expect.values = nil
739 + expect.active = 1
740 + expect.job = FailureFlags{Collect: true}
741 + expect.instances = map[string]FailureFlags{"default": {Collect: true}}
742 + case variantExtractionErr:
743 + input.Payload = []byte(prometheusPayloadStrings("oops", "123"))
744 + expect.values = nil
745 + expect.job = FailureFlags{Extraction: true}
746 + expect.instances = map[string]FailureFlags{instRoot: {Extraction: true}, instHome: {}}
747 + case variantDimensionErr:
748 + input.Payload = []byte(prometheusPayload(1500, 2000))
749 + expect.values = nil
750 + expect.job = FailureFlags{Dimension: true}
751 + expect.instances = map[string]FailureFlags{instRoot: {Dimension: true}, instHome: {Dimension: true}}
752 + case variantLLDErr:
753 + input.Payload = []byte("not-prometheus")
754 + expect.metrics = 0
755 + expect.values = nil
756 + expect.active = 0
757 + expect.job = FailureFlags{LLD: true}
758 + expect.instances = map[string]FailureFlags{}
759 + }
760 + return scenario{name: name, cfg: cfg, input: input, expect: expect}
761 +}
762 +
763 +func buildXPathScenario(idx int, cpu, gpu float64, v variant) scenario {
764 + name := fmt.Sprintf("xpath_%d_%s", idx, variantName(v))
765 + cfg := xpathJobConfig(fmt.Sprintf("xpath_%d", idx))
766 + payload := fmt.Sprintf(`<sensors><sensor id="cpu">%f</sensor><sensor id="gpu">%f</sensor></sensors>`, cpu, gpu)
767 + input := Input{Payload: []byte(payload), Timestamp: time.Now()}
768 + expect := expectation{metrics: 2, values: []float64{cpu, gpu}, active: 1, job: FailureFlags{}, instances: map[string]FailureFlags{"default": {}}}
769 + switch v {
770 + case variantCollectErr:
771 + input.CollectError = errors.New("collect")
772 + expect.metrics = 0
773 + expect.values = nil
774 + expect.active = 1
775 + expect.job = FailureFlags{Collect: true}
776 + expect.instances = map[string]FailureFlags{"default": {Collect: true}}
777 + case variantExtractionErr:
778 + input.Payload = []byte(`<sensors><sensor id="cpu">oops</sensor><sensor id="gpu">bad</sensor></sensors>`)
779 + expect.values = nil
780 + expect.job = FailureFlags{Extraction: true}
781 + expect.instances = map[string]FailureFlags{"default": {Extraction: true}}
782 + case variantDimensionErr:
783 + input.Payload = []byte(`<sensors><sensor id="cpu">500</sensor><sensor id="gpu">600</sensor></sensors>`)
784 + expect.values = nil
785 + expect.job = FailureFlags{Dimension: true}
786 + expect.instances = map[string]FailureFlags{"default": {Dimension: true}}
787 + }
788 + return scenario{name: name, cfg: cfg, input: input, expect: expect}
789 +}
790 +
791 +func buildCSVMultiScenario(idx int, first float64, v variant) scenario {
792 + name := fmt.Sprintf("csv_multi_%d_%s", idx, variantName(v))
793 + cfg := csvMultiJobConfig(fmt.Sprintf("csv_multi_%d", idx))
794 + payload := fmt.Sprintf("name,value\nrow1,%f\nrow2,20", first)
795 + input := Input{Payload: []byte(payload), Timestamp: time.Now()}
796 + // CSVToJSONMulti returns 2 metrics but pipeline expects 1
797 + // Job engine drops output and returns 1 metric with value=0 and Dimension error
798 + expect := expectation{metrics: 1, values: []float64{0}, active: 1, job: FailureFlags{Dimension: true}, instances: map[string]FailureFlags{"default": {Dimension: true}}}
799 + switch v {
800 + case variantCollectErr:
801 + input.CollectError = errors.New("collect")
802 + expect.metrics = 0
803 + expect.values = nil
804 + expect.active = 1
805 + expect.job = FailureFlags{Collect: true}
806 + expect.instances = map[string]FailureFlags{"default": {Collect: true}}
807 + case variantExtractionErr:
808 + input.Payload = []byte("name,value\nrow1,oops\nrow2,20")
809 + expect.metrics = 1
810 + expect.values = nil
811 + // Extraction error takes precedence; multi-metric warning doesn't add Dimension flag
812 + expect.job = FailureFlags{Extraction: true}
813 + expect.instances = map[string]FailureFlags{"default": {Extraction: true}}
814 + case variantDimensionErr:
815 + input.Payload = []byte("name,value\nrow1,5000\nrow2,20")
816 + expect.metrics = 1
817 + expect.values = nil
818 + expect.job = FailureFlags{Dimension: true}
819 + expect.instances = map[string]FailureFlags{"default": {Dimension: true}}
820 + }
821 + return scenario{name: name, cfg: cfg, input: input, expect: expect}
822 +}
823 +
824 +func variantName(v variant) string {
825 + switch v {
826 + case variantNormal:
827 + return "normal"
828 + case variantCollectErr:
829 + return "collect"
830 + case variantExtractionErr:
831 + return "extract"
832 + case variantDimensionErr:
833 + return "dimension"
834 + case variantLLDErr:
835 + return "lld"
836 + default:
837 + return "unknown"
838 + }
839 +}
840 +
841 +// job config builders -------------------------------------------------------
842 +
843 +func simpleJobConfig(name string) pkgzabbix.JobConfig {
844 + return pkgzabbix.JobConfig{
845 + Name: name,
846 + Collection: pkgzabbix.CollectionConfig{
847 + Type: pkgzabbix.CollectionCommand,
848 + Command: "/usr/bin/true",
849 + },
850 + Pipelines: []pkgzabbix.PipelineConfig{
851 + {
852 + Name: "value",
853 + Context: "zabbix.simple.value",
854 + Dimension: "value",
855 + Unit: "value",
856 + Steps: []zpre.Step{
857 + {Type: zpre.StepTypeJSONPath, Params: "$.value"},
858 + {Type: zpre.StepTypeValidateRange, Params: "0\n100"},
859 + },
860 + },
861 + },
862 + }
863 +}
864 +
865 +func multiPipelineJobConfig(name string) pkgzabbix.JobConfig {
866 + return pkgzabbix.JobConfig{
867 + Name: name,
868 + Collection: pkgzabbix.CollectionConfig{
869 + Type: pkgzabbix.CollectionCommand,
870 + Command: "/usr/bin/true",
871 + },
872 + Pipelines: []pkgzabbix.PipelineConfig{
873 + {
874 + Name: "used",
875 + Context: "zabbix.multi.used",
876 + Dimension: "used",
877 + Unit: "bytes",
878 + Precision: 0,
879 + Steps: []zpre.Step{
880 + {Type: zpre.StepTypeJSONPath, Params: "$.used"},
881 + {Type: zpre.StepTypeValidateRange, Params: "0\n100"},
882 + {Type: zpre.StepTypeMultiplier, Params: "2"},
883 + },
884 + },
885 + {
886 + Name: "free",
887 + Context: "zabbix.multi.free",
888 + Dimension: "free",
889 + Unit: "bytes",
890 + Steps: []zpre.Step{
891 + {Type: zpre.StepTypeJSONPath, Params: "$.free"},
892 + {Type: zpre.StepTypeValidateRange, Params: "0\n100"},
893 + },
894 + },
895 + },
896 + }
897 +}
898 +
899 +func lldJobConfig(name string) pkgzabbix.JobConfig {
900 + return pkgzabbix.JobConfig{
901 + Name: name,
902 + Collection: pkgzabbix.CollectionConfig{Type: pkgzabbix.CollectionCommand, Command: "/usr/bin/true"},
903 + LLD: pkgzabbix.LLDConfig{
904 + Steps: []zpre.Step{{Type: zpre.StepTypeJSONPath, Params: "$.discovery"}},
905 + InstanceTemplate: "{#FSNAME}",
906 + MaxMissing: 0,
907 + },
908 + Pipelines: []pkgzabbix.PipelineConfig{
909 + {
910 + Name: "used",
911 + Context: "zabbix.lld.used",
912 + Dimension: "used",
913 + Unit: "bytes",
914 + Steps: []zpre.Step{
915 + {Type: zpre.StepTypeJSONPath, Params: "$.data[?(@.fs == '{#FSNAME}')].used"},
916 + {Type: zpre.StepTypeValidateRange, Params: "0\n100"},
917 + },
918 + },
919 + {
920 + Name: "free",
921 + Context: "zabbix.lld.free",
922 + Dimension: "free",
923 + Unit: "bytes",
924 + Steps: []zpre.Step{
925 + {Type: zpre.StepTypeJSONPath, Params: "$.data[?(@.fs == '{#FSNAME}')].free"},
926 + {Type: zpre.StepTypeValidateRange, Params: "0\n100"},
927 + },
928 + },
929 + },
930 + }
931 +}
932 +
933 +func csvLLDJobConfig(name string) pkgzabbix.JobConfig {
934 + const csvParams = `,
935 +"
936 +1`
937 + return pkgzabbix.JobConfig{
938 + Name: name,
939 + Collection: pkgzabbix.CollectionConfig{Type: pkgzabbix.CollectionCommand, Command: "/usr/bin/true"},
940 + LLD: pkgzabbix.LLDConfig{
941 + Steps: []zpre.Step{
942 + {Type: zpre.StepTypeCSVToJSON, Params: csvParams},
943 + {Type: zpre.StepTypeJSONPath, Params: "$"},
944 + },
945 + InstanceTemplate: "{#FSNAME}",
946 + MaxMissing: 0,
947 + },
948 + Pipelines: []pkgzabbix.PipelineConfig{
949 + {
950 + Name: "path",
951 + Context: "zabbix.csv.path",
952 + Dimension: "value",
953 + Unit: "characters",
954 + Steps: []zpre.Step{
955 + {Type: zpre.StepTypeCSVToJSON, Params: csvParams},
956 + {Type: zpre.StepTypeJavaScript, Params: pathLengthScript},
957 + },
958 + },
959 + },
960 + }
961 +}
962 +
963 +func xmlLLDJobConfig(name string) pkgzabbix.JobConfig {
964 + script := `var data = JSON.parse(value);
965 +var list = data.files.fs;
966 +if (!Array.isArray(list)) list = [list];
967 +var fsMacro = "{#" + "FSNAME}";
968 +var pathMacro = "{#" + "FSPATH}";
969 +var out = list.map(function(node){
970 + var entry = {};
971 + entry[fsMacro] = node["@name"];
972 + entry[pathMacro] = node["@path"];
973 + return entry;
974 +});
975 +value = JSON.stringify(out);
976 +return value;`
977 + return pkgzabbix.JobConfig{
978 + Name: name,
979 + Collection: pkgzabbix.CollectionConfig{Type: pkgzabbix.CollectionCommand, Command: "/usr/bin/true"},
980 + LLD: pkgzabbix.LLDConfig{
981 + Steps: []zpre.Step{
982 + {Type: zpre.StepTypeXMLToJSON},
983 + {Type: zpre.StepTypeJavaScript, Params: script},
984 + },
985 + InstanceTemplate: "{#FSNAME}",
986 + MaxMissing: 0,
987 + },
988 + Pipelines: []pkgzabbix.PipelineConfig{
989 + {
990 + Name: "path",
991 + Context: "zabbix.xml.path",
992 + Dimension: "value",
993 + Unit: "characters",
994 + Steps: []zpre.Step{
995 + {Type: zpre.StepTypeXMLToJSON},
996 + {Type: zpre.StepTypeJavaScript, Params: script},
997 + {Type: zpre.StepTypeJavaScript, Params: pathLengthScript},
998 + },
999 + },
1000 + },
1001 + }
1002 +}
1003 +
1004 +func snmpLLDJobConfig(name string) pkgzabbix.JobConfig {
1005 + params := "MACRO1\n.1.3.6.1\n0\nMACRO2\n.1.3.6.2\n0"
1006 + return pkgzabbix.JobConfig{
1007 + Name: name,
1008 + Collection: pkgzabbix.CollectionConfig{Type: pkgzabbix.CollectionCommand, Command: "/usr/bin/true"},
1009 + LLD: pkgzabbix.LLDConfig{
1010 + Steps: []zpre.Step{{Type: zpre.StepTypeSNMPWalkToJSON, Params: params}},
1011 + InstanceTemplate: "MACRO1",
1012 + MaxMissing: 0,
1013 + },
1014 + Pipelines: []pkgzabbix.PipelineConfig{
1015 + {
1016 + Name: "snmp_value",
1017 + Context: "zabbix.snmp.value",
1018 + Dimension: "value",
1019 + Unit: "items",
1020 + Steps: []zpre.Step{
1021 + {Type: zpre.StepTypeSNMPWalkToJSON, Params: params},
1022 + {Type: zpre.StepTypeJavaScript, Params: snmpValueScript},
1023 + },
1024 + },
1025 + },
1026 + }
1027 +}
1028 +
1029 +func prometheusJobConfig(name string) pkgzabbix.JobConfig {
1030 + return pkgzabbix.JobConfig{
1031 + Name: name,
1032 + Collection: pkgzabbix.CollectionConfig{Type: pkgzabbix.CollectionCommand, Command: "/usr/bin/true"},
1033 + LLD: pkgzabbix.LLDConfig{
1034 + Steps: []zpre.Step{
1035 + {Type: zpre.StepTypePrometheusToJSON},
1036 + {Type: zpre.StepTypeJavaScript, Params: promDiscoveryScript},
1037 + },
1038 + InstanceTemplate: "fs_{#MOUNT}",
1039 + MaxMissing: 0,
1040 + },
1041 + Pipelines: []pkgzabbix.PipelineConfig{
1042 + {
1043 + Name: "free",
1044 + Context: "zabbix.prom.free",
1045 + Dimension: "free",
1046 + Unit: "bytes",
1047 + Steps: []zpre.Step{
1048 + {Type: zpre.StepTypePrometheusToJSON},
1049 + {Type: zpre.StepTypeJavaScript, Params: promValueScript},
1050 + {Type: zpre.StepTypeValidateRange, Params: "0\n1000"},
1051 + },
1052 + },
1053 + },
1054 + }
1055 +}
1056 +
1057 +func xpathJobConfig(name string) pkgzabbix.JobConfig {
1058 + return pkgzabbix.JobConfig{
1059 + Name: name,
1060 + Collection: pkgzabbix.CollectionConfig{Type: pkgzabbix.CollectionCommand, Command: "/usr/bin/true"},
1061 + Pipelines: []pkgzabbix.PipelineConfig{
1062 + {
1063 + Name: "cpu",
1064 + Context: "zabbix.xpath.cpu",
1065 + Dimension: "cpu",
1066 + Unit: "celsius",
1067 + Steps: []zpre.Step{
1068 + {Type: zpre.StepTypeXPath, Params: "string(/sensors/sensor[@id='cpu']/text())"},
1069 + {Type: zpre.StepTypeValidateRange, Params: "0\n150"},
1070 + },
1071 + },
1072 + {
1073 + Name: "gpu",
1074 + Context: "zabbix.xpath.gpu",
1075 + Dimension: "gpu",
1076 + Unit: "celsius",
1077 + Steps: []zpre.Step{
1078 + {Type: zpre.StepTypeXPath, Params: "string(/sensors/sensor[@id='gpu']/text())"},
1079 + {Type: zpre.StepTypeValidateRange, Params: "0\n150"},
1080 + },
1081 + },
1082 + },
1083 + }
1084 +}
1085 +
1086 +func csvMultiJobConfig(name string) pkgzabbix.JobConfig {
1087 + const csvParams = `,
1088 +"
1089 +1`
1090 + return pkgzabbix.JobConfig{
1091 + Name: name,
1092 + Collection: pkgzabbix.CollectionConfig{Type: pkgzabbix.CollectionCommand, Command: "/usr/bin/true"},
1093 + Pipelines: []pkgzabbix.PipelineConfig{
1094 + {
1095 + Name: "first_value",
1096 + Context: "zabbix.csv.multi",
1097 + Dimension: "value",
1098 + Unit: "items",
1099 + Steps: []zpre.Step{
1100 + {Type: zpre.StepTypeCSVToJSONMulti, Params: csvParams},
1101 + {Type: zpre.StepTypeJavaScript, Params: csvMultiValueScript},
1102 + {Type: zpre.StepTypeValidateRange, Params: "0\n1000"},
1103 + },
1104 + },
1105 + },
1106 + }
1107 +}
src/go/plugin/scripts.d/pkg/zabbix/runtime.go new
+508
@@ -0,0 +1,508 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package zabbix
4 +
5 +import (
6 + "bytes"
7 + "context"
8 + "fmt"
9 + "io"
10 + "net/http"
11 + "strings"
12 + "sync"
13 + "time"
14 +
15 + "github.com/gosnmp/gosnmp"
16 +
17 + "github.com/netdata/netdata/go/plugins/logger"
18 + "github.com/netdata/netdata/go/plugins/pkg/confopt"
19 + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/ndexec"
20 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/runtime"
21 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/schedulers"
22 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
23 + zpre "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbixpreproc"
24 +)
25 +
26 +// Runtime orchestrates Zabbix jobs using the shared scripts.d scheduler infrastructure.
27 +type Runtime struct {
28 + proc *zpre.Preprocessor
29 + handles map[string][]*schedulers.JobHandle
30 + mu sync.RWMutex
31 +}
32 +
33 +// NewRuntime wires validated jobs into schedulers and starts them.
34 +func NewRuntime(configs []JobConfig, proc *zpre.Preprocessor, log *logger.Logger, emitter runtime.ResultEmitter, vnodeLookup func(spec.JobSpec) runtime.VnodeInfo) (*Runtime, error) {
35 + if len(configs) == 0 {
36 + return nil, fmt.Errorf("zabbix runtime requires at least one job")
37 + }
38 + if proc == nil {
39 + return nil, fmt.Errorf("zabbix runtime requires a preprocessor instance")
40 + }
41 +
42 + normalized := make([]JobConfig, len(configs))
43 + for i := range configs {
44 + normalized[i] = configs[i]
45 + if strings.TrimSpace(normalized[i].Scheduler) == "" {
46 + normalized[i].Scheduler = "default"
47 + }
48 + }
49 +
50 + if emitter == nil {
51 + emitter = runtime.NewNoopEmitter()
52 + }
53 + collector := newJobCollector(normalized, log)
54 + grouped := groupByScheduler(normalized)
55 + handles := make(map[string][]*schedulers.JobHandle, len(grouped))
56 +
57 + detachAll := func(h map[string][]*schedulers.JobHandle) {
58 + for _, list := range h {
59 + for _, handle := range list {
60 + schedulers.DetachJob(handle)
61 + }
62 + }
63 + }
64 +
65 + for name, cfgs := range grouped {
66 + if _, ok := schedulers.Get(name); !ok {
67 + detachAll(handles)
68 + return nil, fmt.Errorf("scheduler '%s' not defined", name)
69 + }
70 + jobSpecs, err := buildJobSpecs(cfgs)
71 + if err != nil {
72 + detachAll(handles)
73 + return nil, err
74 + }
75 + for i := range jobSpecs {
76 + var vnode runtime.VnodeInfo
77 + if vnodeLookup != nil {
78 + vnode = runtime.CloneVnodeInfo(vnodeLookup(jobSpecs[i]))
79 + }
80 + reg := runtime.JobRegistration{
81 + Spec: jobSpecs[i],
82 + Runner: collector.Run,
83 + Emitter: emitter,
84 + Vnode: vnode,
85 + }
86 + handle, err := schedulers.AttachJob(name, reg, log)
87 + if err != nil {
88 + detachAll(handles)
89 + return nil, err
90 + }
91 + handles[name] = append(handles[name], handle)
92 + }
93 + }
94 +
95 + return &Runtime{proc: proc, handles: handles}, nil
96 +}
97 +
98 +// Collect aggregates metrics from every scheduler.
99 +func (r *Runtime) Collect() map[string]int64 {
100 + r.mu.RLock()
101 + defer r.mu.RUnlock()
102 + metrics := make(map[string]int64)
103 + for scheduler := range r.handles {
104 + data := schedulers.CollectMetrics(scheduler)
105 + for k, v := range data {
106 + metrics[k] += v
107 + }
108 + }
109 + if len(metrics) == 0 {
110 + return nil
111 + }
112 + return metrics
113 +}
114 +
115 +// Stop shuts down all managed schedulers and closes the emitter.
116 +func (r *Runtime) Stop() {
117 + r.mu.Lock()
118 + defer r.mu.Unlock()
119 + for scheduler, list := range r.handles {
120 + for _, handle := range list {
121 + schedulers.DetachJob(handle)
122 + }
123 + delete(r.handles, scheduler)
124 + }
125 +}
126 +
127 +func groupByScheduler(cfgs []JobConfig) map[string][]JobConfig {
128 + res := make(map[string][]JobConfig)
129 + for _, cfg := range cfgs {
130 + name := schedulerName(cfg.Scheduler)
131 + cfg.Scheduler = name
132 + res[name] = append(res[name], cfg)
133 + }
134 + return res
135 +}
136 +
137 +func schedulerName(name string) string {
138 + if strings.TrimSpace(name) == "" {
139 + return "default"
140 + }
141 + return name
142 +}
143 +
144 +func buildJobSpecs(cfgs []JobConfig) ([]spec.JobSpec, error) {
145 + specs := make([]spec.JobSpec, len(cfgs))
146 + for i := range cfgs {
147 + cfg := cfgs[i]
148 + job := spec.JobConfig{
149 + Name: cfg.Name,
150 + Plugin: pluginName(cfg),
151 + Args: append([]string{}, cfg.Collection.Args...),
152 + Timeout: confopt.Duration(defaultTimeout(cfg)),
153 + Scheduler: cfg.Scheduler,
154 + Vnode: cfg.Vnode,
155 + }
156 + job.CheckInterval = confopt.Duration(cfg.IntervalDuration())
157 + sp, err := job.ToSpec()
158 + if err != nil {
159 + return nil, err
160 + }
161 + specs[i] = sp
162 + }
163 + return specs, nil
164 +}
165 +
166 +func pluginName(cfg JobConfig) string {
167 + switch cfg.Collection.Type {
168 + case CollectionHTTP:
169 + return "zabbix-http"
170 + case CollectionSNMP:
171 + return "zabbix-snmp"
172 + case CollectionCommand, "":
173 + return strings.TrimSpace(cfg.Collection.Command)
174 + default:
175 + return fmt.Sprintf("zabbix-%s", cfg.Collection.Type)
176 + }
177 +}
178 +
179 +func defaultTimeout(cfg JobConfig) time.Duration {
180 + if d := cfg.Collection.TimeoutDuration(); d > 0 {
181 + return d
182 + }
183 + return 30 * time.Second
184 +}
185 +
186 +type jobCollector struct {
187 + log *logger.Logger
188 + jobs map[string]JobConfig
189 +}
190 +
191 +func newJobCollector(cfgs []JobConfig, log *logger.Logger) *jobCollector {
192 + jobs := make(map[string]JobConfig, len(cfgs))
193 + for _, cfg := range cfgs {
194 + jobs[cfg.Name] = cfg
195 + }
196 + return &jobCollector{log: log, jobs: jobs}
197 +}
198 +
199 +func (c *jobCollector) Run(ctx context.Context, job runtime.JobRuntime, timeout time.Duration) ([]byte, string, ndexec.ResourceUsage, error) {
200 + cfg, ok := c.jobs[job.Spec.Name]
201 + if !ok {
202 + return nil, "", ndexec.ResourceUsage{}, fmt.Errorf("zabbix: unknown job %s", job.Spec.Name)
203 + }
204 + macros := buildCollectionMacros(cfg, job)
205 + switch cfg.Collection.Type {
206 + case CollectionCommand, "":
207 + return c.runCommand(ctx, job, cfg, macros, timeout)
208 + case CollectionHTTP:
209 + return c.runHTTP(ctx, job, cfg, macros, timeout)
210 + case CollectionSNMP:
211 + return c.runSNMP(ctx, job, cfg, macros, timeout)
212 + default:
213 + return nil, "", ndexec.ResourceUsage{}, fmt.Errorf("zabbix: unsupported collection type %q", cfg.Collection.Type)
214 + }
215 +}
216 +
217 +func (c *jobCollector) runCommand(ctx context.Context, job runtime.JobRuntime, cfg JobConfig, macros map[string]string, timeout time.Duration) ([]byte, string, ndexec.ResourceUsage, error) {
218 + cmd := strings.TrimSpace(expandTemplate(cfg.Collection.Command, macros))
219 + if cmd == "" {
220 + return nil, "", ndexec.ResourceUsage{}, fmt.Errorf("command is required for command collection")
221 + }
222 + var env []string
223 + for k, v := range expandStringMap(cfg.Collection.Environment, macros) {
224 + env = append(env, fmt.Sprintf("%s=%s", k, v))
225 + }
226 + opts := ndexec.RunOptions{Env: env}
227 + args := expandStringSlice(cfg.Collection.Args, macros)
228 + data, cmdStr, usage, err := ndexec.RunUnprivilegedWithOptionsUsage(c.log, timeout, opts, cmd, args...)
229 + return data, cmdStr, usage, err
230 +}
231 +
232 +func (c *jobCollector) runHTTP(ctx context.Context, job runtime.JobRuntime, cfg JobConfig, macros map[string]string, timeout time.Duration) ([]byte, string, ndexec.ResourceUsage, error) {
233 + httpCfg := cfg.Collection.HTTP
234 + if httpCfg.URL == "" {
235 + httpCfg.URL = cfg.Collection.URL
236 + }
237 + if httpCfg.URL == "" {
238 + return nil, "", ndexec.ResourceUsage{}, fmt.Errorf("http.url is required")
239 + }
240 + method := strings.ToUpper(strings.TrimSpace(httpCfg.Method))
241 + if method == "" {
242 + method = strings.ToUpper(strings.TrimSpace(cfg.Collection.Method))
243 + }
244 + if method == "" {
245 + method = "GET"
246 + }
247 + urlStr := expandTemplate(httpCfg.URL, macros)
248 + var body io.Reader
249 + if httpCfg.Body != "" {
250 + body = strings.NewReader(expandTemplate(httpCfg.Body, macros))
251 + } else if cfg.Collection.Body != "" {
252 + body = strings.NewReader(expandTemplate(cfg.Collection.Body, macros))
253 + }
254 + req, err := http.NewRequestWithContext(ctx, method, urlStr, body)
255 + if err != nil {
256 + return nil, "", ndexec.ResourceUsage{}, err
257 + }
258 + headers := mergeStringMap(cfg.Collection.Headers, httpCfg.Headers)
259 + for k, v := range headers {
260 + req.Header.Set(k, expandTemplate(v, macros))
261 + }
262 + if httpCfg.Username != "" {
263 + user := expandTemplate(httpCfg.Username, macros)
264 + pass := expandTemplate(httpCfg.Password, macros)
265 + req.SetBasicAuth(user, pass)
266 + }
267 + if httpCfg.TLS {
268 + if req.URL.Scheme == "" {
269 + req.URL.Scheme = "https"
270 + } else if req.URL.Scheme != "https" {
271 + req.URL.Scheme = "https"
272 + }
273 + }
274 + client := &http.Client{Timeout: timeout}
275 + resp, err := client.Do(req)
276 + if err != nil {
277 + return nil, "", ndexec.ResourceUsage{}, err
278 + }
279 + defer resp.Body.Close()
280 + buf := &bytes.Buffer{}
281 + if _, err := io.Copy(buf, resp.Body); err != nil {
282 + return nil, "", ndexec.ResourceUsage{}, err
283 + }
284 + return buf.Bytes(), fmt.Sprintf("http %s", req.URL.String()), ndexec.ResourceUsage{}, nil
285 +}
286 +
287 +func (c *jobCollector) runSNMP(ctx context.Context, job runtime.JobRuntime, cfg JobConfig, macros map[string]string, timeout time.Duration) ([]byte, string, ndexec.ResourceUsage, error) {
288 + snmpCfg := cfg.Collection.SNMP
289 + target := strings.TrimSpace(expandTemplate(snmpCfg.Target, macros))
290 + oid := strings.TrimSpace(expandTemplate(snmpCfg.OID, macros))
291 + if target == "" || oid == "" {
292 + return nil, "", ndexec.ResourceUsage{}, fmt.Errorf("snmp.target and snmp.oid are required")
293 + }
294 + snmpClient := &gosnmp.GoSNMP{
295 + Target: target,
296 + Port: 161,
297 + Timeout: timeout,
298 + Community: expandTemplate(snmpCfg.Community, macros),
299 + MaxOids: 1,
300 + }
301 + if val := strings.TrimSpace(snmpCfg.Context); val != "" {
302 + snmpClient.ContextName = expandTemplate(val, macros)
303 + }
304 + switch strings.TrimSpace(strings.ToLower(snmpCfg.Version)) {
305 + case "3", "v3":
306 + snmpClient.Version = gosnmp.Version3
307 + security := &gosnmp.UsmSecurityParameters{
308 + UserName: expandTemplate(snmpCfg.User, macros),
309 + AuthenticationPassphrase: expandTemplate(snmpCfg.AuthPass, macros),
310 + PrivacyPassphrase: expandTemplate(snmpCfg.PrivPass, macros),
311 + }
312 + if strings.EqualFold(snmpCfg.AuthProto, "sha") {
313 + security.AuthenticationProtocol = gosnmp.SHA
314 + } else {
315 + security.AuthenticationProtocol = gosnmp.MD5
316 + }
317 + if strings.EqualFold(snmpCfg.PrivProto, "aes") {
318 + security.PrivacyProtocol = gosnmp.AES
319 + } else {
320 + security.PrivacyProtocol = gosnmp.DES
321 + }
322 + snmpClient.SecurityParameters = security
323 + snmpClient.SecurityModel = gosnmp.UserSecurityModel
324 + snmpClient.MsgFlags = gosnmp.AuthPriv
325 + case "1", "v1":
326 + snmpClient.Version = gosnmp.Version1
327 + default:
328 + snmpClient.Version = gosnmp.Version2c
329 + }
330 + if err := snmpClient.Connect(); err != nil {
331 + return nil, "", ndexec.ResourceUsage{}, err
332 + }
333 + defer snmpClient.Conn.Close()
334 + if requiresSNMPWalk(cfg) {
335 + entries, err := snmpClient.BulkWalkAll(oid)
336 + if err != nil {
337 + return nil, "", ndexec.ResourceUsage{}, err
338 + }
339 + if len(entries) == 0 {
340 + return nil, "", ndexec.ResourceUsage{}, fmt.Errorf("snmp: walk returned no data")
341 + }
342 + payload := formatSNMPWalk(entries)
343 + return []byte(payload), fmt.Sprintf("snmp %s %s walk", target, oid), ndexec.ResourceUsage{}, nil
344 + }
345 + result, err := snmpClient.Get([]string{oid})
346 + if err != nil {
347 + return nil, "", ndexec.ResourceUsage{}, err
348 + }
349 + if len(result.Variables) == 0 {
350 + return nil, "", ndexec.ResourceUsage{}, fmt.Errorf("snmp: no variables returned")
351 + }
352 + val := formatSNMPValue(result.Variables[0])
353 + return []byte(val), fmt.Sprintf("snmp %s %s", target, oid), ndexec.ResourceUsage{}, nil
354 +}
355 +
356 +func mergeStringMap(base map[string]string, override map[string]string) map[string]string {
357 + if len(base) == 0 && len(override) == 0 {
358 + return nil
359 + }
360 + out := make(map[string]string, len(base)+len(override))
361 + for k, v := range base {
362 + out[k] = v
363 + }
364 + for k, v := range override {
365 + out[k] = v
366 + }
367 + return out
368 +}
369 +
370 +func formatSNMPValue(pdu gosnmp.SnmpPDU) string {
371 + switch v := pdu.Value.(type) {
372 + case string:
373 + return v
374 + case []byte:
375 + return string(v)
376 + case int, uint, uint32, uint64, int64:
377 + return fmt.Sprintf("%v", v)
378 + default:
379 + return fmt.Sprintf("%v", v)
380 + }
381 +}
382 +
383 +func formatSNMPWalk(vars []gosnmp.SnmpPDU) string {
384 + var b strings.Builder
385 + for i, entry := range vars {
386 + if i > 0 {
387 + b.WriteByte('\n')
388 + }
389 + fmt.Fprintf(&b, "%s = %s: %s", entry.Name, snmpTypeName(entry.Type), formatSNMPValue(entry))
390 + }
391 + return b.String()
392 +}
393 +
394 +func snmpTypeName(kind gosnmp.Asn1BER) string {
395 + switch kind {
396 + case gosnmp.OctetString:
397 + return "STRING"
398 + case gosnmp.Integer:
399 + return "INTEGER"
400 + case gosnmp.Counter32:
401 + return "Counter32"
402 + case gosnmp.Gauge32:
403 + return "Gauge32"
404 + case gosnmp.IPAddress:
405 + return "IpAddress"
406 + case gosnmp.TimeTicks:
407 + return "Timeticks"
408 + case gosnmp.Counter64:
409 + return "Counter64"
410 + case gosnmp.ObjectIdentifier:
411 + return "OID"
412 + case gosnmp.Opaque:
413 + return "Opaque"
414 + default:
415 + return "Unknown"
416 + }
417 +}
418 +
419 +func requiresSNMPWalk(cfg JobConfig) bool {
420 + for _, pipe := range cfg.Pipelines {
421 + for _, step := range pipe.Steps {
422 + switch step.Type {
423 + case zpre.StepTypeSNMPWalkValue, zpre.StepTypeSNMPWalkToJSON, zpre.StepTypeSNMPWalkToJSONMulti:
424 + return true
425 + }
426 + }
427 + }
428 + return false
429 +}
430 +
431 +func buildCollectionMacros(cfg JobConfig, job runtime.JobRuntime) map[string]string {
432 + macros := make(map[string]string)
433 + host := firstNonEmpty(strings.TrimSpace(job.Vnode.Hostname), strings.TrimSpace(job.Spec.Vnode), strings.TrimSpace(cfg.Vnode), cfg.Name)
434 + if host == "" {
435 + host = cfg.Name
436 + }
437 + alias := firstNonEmpty(strings.TrimSpace(job.Vnode.Labels["_alias"]), host)
438 + ip := strings.TrimSpace(job.Vnode.Labels["_address"])
439 + conn := firstNonEmpty(ip, host)
440 + macros["{HOST.NAME}"] = host
441 + macros["{HOST.HOST}"] = host
442 + macros["{HOST.DNS}"] = host
443 + macros["{HOST.ALIAS}"] = alias
444 + macros["{HOST.IP}"] = ip
445 + macros["{HOST.CONN}"] = conn
446 + macros["{ITEM.NAME}"] = cfg.Name
447 + macros["{ITEM.ID}"] = cfg.Name
448 + macros["{ITEM.KEY}"] = pluginName(cfg)
449 + for k, v := range cfg.UserMacros {
450 + key := strings.TrimSpace(strings.ToUpper(k))
451 + if key == "" {
452 + continue
453 + }
454 + val := v
455 + switch {
456 + case strings.HasPrefix(key, "{$") && strings.HasSuffix(key, "}"):
457 + macros[key] = val
458 + case strings.HasPrefix(key, "$") && strings.HasSuffix(key, "$"):
459 + macros[key] = val
460 + default:
461 + macros[fmt.Sprintf("$%s$", key)] = val
462 + macros[fmt.Sprintf("{$%s}", key)] = val
463 + }
464 + }
465 + return macros
466 +}
467 +
468 +func expandTemplate(tmpl string, macros map[string]string) string {
469 + if tmpl == "" || len(macros) == 0 {
470 + return tmpl
471 + }
472 + res := tmpl
473 + for k, v := range macros {
474 + res = strings.ReplaceAll(res, k, v)
475 + }
476 + return res
477 +}
478 +
479 +func expandStringSlice(values []string, macros map[string]string) []string {
480 + if len(values) == 0 {
481 + return values
482 + }
483 + res := make([]string, len(values))
484 + for i, v := range values {
485 + res[i] = expandTemplate(v, macros)
486 + }
487 + return res
488 +}
489 +
490 +func expandStringMap(values map[string]string, macros map[string]string) map[string]string {
491 + if len(values) == 0 {
492 + return values
493 + }
494 + res := make(map[string]string, len(values))
495 + for k, v := range values {
496 + res[k] = expandTemplate(v, macros)
497 + }
498 + return res
499 +}
500 +
501 +func firstNonEmpty(values ...string) string {
502 + for _, v := range values {
503 + if strings.TrimSpace(v) != "" {
504 + return strings.TrimSpace(v)
505 + }
506 + }
507 + return ""
508 +}
src/go/plugin/scripts.d/pkg/zabbix/runtime_test.go new
+233
@@ -0,0 +1,233 @@
1 +package zabbix
2 +
3 +import (
4 + "context"
5 + "net/http"
6 + "net/http/httptest"
7 + "testing"
8 + "time"
9 +
10 + "github.com/netdata/netdata/go/plugins/pkg/confopt"
11 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/runtime"
12 + "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/spec"
13 + zpre "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/zabbixpreproc"
14 +)
15 +
16 +func TestBuildJobSpecsCommandPlugin(t *testing.T) {
17 + cfg := JobConfig{
18 + Name: "cmd_job",
19 + Scheduler: "default",
20 + Collection: CollectionConfig{
21 + Type: CollectionCommand,
22 + Command: "/usr/libexec/zabbix/collector",
23 + },
24 + }
25 + cfg.Pipelines = []PipelineConfig{{
26 + Name: "usage",
27 + Context: "zabbix.cmd.usage",
28 + Dimension: "value",
29 + Unit: "%",
30 + Steps: []zpre.Step{{Type: zpre.StepTypeJSONPath, Params: "$.value"}},
31 + }}
32 + specs, err := buildJobSpecs([]JobConfig{cfg})
33 + if err != nil {
34 + t.Fatalf("buildJobSpecs returned error: %v", err)
35 + }
36 + if specs[0].Plugin != cfg.Collection.Command {
37 + t.Fatalf("expected plugin %q, got %q", cfg.Collection.Command, specs[0].Plugin)
38 + }
39 +}
40 +
41 +func TestBuildJobSpecsHTTPPlugin(t *testing.T) {
42 + cfg := JobConfig{
43 + Name: "http_job",
44 + Collection: CollectionConfig{
45 + Type: CollectionHTTP,
46 + HTTP: HTTPConfig{URL: "http://127.0.0.1/api"},
47 + },
48 + }
49 + cfg.Pipelines = []PipelineConfig{{
50 + Name: "latency",
51 + Context: "zabbix.http.latency",
52 + Dimension: "value",
53 + Unit: "ms",
54 + Steps: []zpre.Step{{Type: zpre.StepTypeJSONPath, Params: "$.value"}},
55 + }}
56 + specs, err := buildJobSpecs([]JobConfig{cfg})
57 + if err != nil {
58 + t.Fatalf("buildJobSpecs returned error: %v", err)
59 + }
60 + if specs[0].Plugin != "zabbix-http" {
61 + t.Fatalf("expected synthetic plugin 'zabbix-http', got %q", specs[0].Plugin)
62 + }
63 +}
64 +
65 +func TestBuildJobSpecsSNMPPlugin(t *testing.T) {
66 + cfg := JobConfig{
67 + Name: "snmp_job",
68 + Collection: CollectionConfig{
69 + Type: CollectionSNMP,
70 + SNMP: SNMPConfig{Target: "127.0.0.1", OID: "1.3.6.1.2.1.1.1.0"},
71 + },
72 + }
73 + cfg.Pipelines = []PipelineConfig{{
74 + Name: "value",
75 + Context: "zabbix.snmp.value",
76 + Dimension: "value",
77 + Unit: "",
78 + Steps: []zpre.Step{{Type: zpre.StepTypeJSONPath, Params: "$.value"}},
79 + }}
80 + specs, err := buildJobSpecs([]JobConfig{cfg})
81 + if err != nil {
82 + t.Fatalf("buildJobSpecs returned error: %v", err)
83 + }
84 + if specs[0].Plugin != "zabbix-snmp" {
85 + t.Fatalf("expected synthetic plugin 'zabbix-snmp', got %q", specs[0].Plugin)
86 + }
87 +}
88 +
89 +func TestBuildJobSpecsUpdateEvery(t *testing.T) {
90 + cfg := JobConfig{
91 + Name: "interval_job",
92 + UpdateEvery: 42,
93 + Collection: CollectionConfig{
94 + Type: CollectionCommand,
95 + Command: "/usr/bin/true",
96 + },
97 + }
98 + cfg.Pipelines = []PipelineConfig{{
99 + Name: "value",
100 + Context: "zabbix.interval.value",
101 + Dimension: "value",
102 + Unit: "",
103 + Steps: []zpre.Step{{Type: zpre.StepTypeJSONPath, Params: "$.value"}},
104 + }}
105 + specs, err := buildJobSpecs([]JobConfig{cfg})
106 + if err != nil {
107 + t.Fatalf("buildJobSpecs returned error: %v", err)
108 + }
109 + want := time.Duration(cfg.UpdateEvery) * time.Second
110 + if specs[0].CheckInterval != want {
111 + t.Fatalf("expected check interval %s, got %s", want, specs[0].CheckInterval)
112 + }
113 +}
114 +
115 +func TestBuildJobSpecsDefaultInterval(t *testing.T) {
116 + cfg := JobConfig{
117 + Name: "default_interval_job",
118 + Collection: CollectionConfig{
119 + Type: CollectionCommand,
120 + Command: "/usr/bin/true",
121 + Timeout: confopt.Duration(5 * time.Second),
122 + },
123 + }
124 + cfg.Pipelines = []PipelineConfig{{
125 + Name: "value",
126 + Context: "zabbix.interval.value",
127 + Dimension: "value",
128 + Unit: "",
129 + Steps: []zpre.Step{{Type: zpre.StepTypeJSONPath, Params: "$.value"}},
130 + }}
131 + specs, err := buildJobSpecs([]JobConfig{cfg})
132 + if err != nil {
133 + t.Fatalf("buildJobSpecs returned error: %v", err)
134 + }
135 + if specs[0].CheckInterval != time.Minute {
136 + t.Fatalf("expected default check interval %s, got %s", time.Minute, specs[0].CheckInterval)
137 + }
138 +}
139 +
140 +func TestBuildCollectionMacros(t *testing.T) {
141 + cfg := JobConfig{Name: "disk", Collection: CollectionConfig{Type: CollectionCommand, Command: "/bin/true"}}
142 + job := runtime.JobRuntime{Spec: spec.JobSpec{Name: cfg.Name}, Vnode: runtime.VnodeInfo{Hostname: "agent", Labels: map[string]string{"_address": "10.0.0.1", "_alias": "agent-alias"}}}
143 + macros := buildCollectionMacros(cfg, job)
144 + if macros["{HOST.NAME}"] != "agent" {
145 + t.Fatalf("expected host macro 'agent', got %q", macros["{HOST.NAME}"])
146 + }
147 + if macros["{HOST.IP}"] != "10.0.0.1" {
148 + t.Fatalf("expected host ip macro '10.0.0.1', got %q", macros["{HOST.IP}"])
149 + }
150 + if macros["{ITEM.KEY}"] == "" {
151 + t.Fatalf("expected item key macro to be populated")
152 + }
153 +}
154 +
155 +func TestRunHTTPMacroExpansion(t *testing.T) {
156 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
157 + if r.Method != http.MethodPost {
158 + t.Fatalf("expected POST method, got %s", r.Method)
159 + }
160 + if r.Header.Get("X-Host") != "agent" {
161 + t.Fatalf("expected header X-Host=agent, got %s", r.Header.Get("X-Host"))
162 + }
163 + if r.URL.Path != "/api/agent" {
164 + t.Fatalf("unexpected path %s", r.URL.Path)
165 + }
166 + w.WriteHeader(http.StatusOK)
167 + _, _ = w.Write([]byte("ok"))
168 + }))
169 + defer srv.Close()
170 +
171 + cfg := JobConfig{
172 + Name: "http_job",
173 + Collection: CollectionConfig{
174 + Type: CollectionHTTP,
175 + HTTP: HTTPConfig{
176 + URL: srv.URL + "/api/{HOST.NAME}",
177 + Headers: map[string]string{"X-Host": "{HOST.NAME}"},
178 + Body: "value={ITEM.NAME}",
179 + },
180 + Method: "post",
181 + },
182 + }
183 + collector := newJobCollector([]JobConfig{cfg}, nil)
184 + job := runtime.JobRuntime{Spec: spec.JobSpec{Name: cfg.Name}, Vnode: runtime.VnodeInfo{Hostname: "agent"}}
185 + macros := buildCollectionMacros(cfg, job)
186 + data, _, _, err := collector.runHTTP(context.Background(), job, cfg, macros, time.Second)
187 + if err != nil {
188 + t.Fatalf("runHTTP returned error: %v", err)
189 + }
190 + if string(data) != "ok" {
191 + t.Fatalf("expected response 'ok', got %q", string(data))
192 + }
193 +}
194 +
195 +func TestRequiresSNMPWalk(t *testing.T) {
196 + cfg := JobConfig{
197 + Name: "snmp_walk",
198 + Collection: CollectionConfig{
199 + Type: CollectionSNMP,
200 + SNMP: SNMPConfig{Target: "127.0.0.1", OID: ".1.3.6.1"},
201 + },
202 + Pipelines: []PipelineConfig{{
203 + Name: "walk",
204 + Steps: []zpre.Step{{Type: zpre.StepTypeSNMPWalkToJSON, Params: "{#IFNAME}\n.1.3.6.1\n1"}},
205 + Context: "zabbix.snmp.walk",
206 + Dimension: "value",
207 + Unit: "value",
208 + }},
209 + }
210 + if !requiresSNMPWalk(cfg) {
211 + t.Fatalf("expected requiresSNMPWalk to be true")
212 + }
213 +}
214 +
215 +func TestRunSNMPWalk(t *testing.T) {
216 + cfg := JobConfig{
217 + Name: "snmp_walk",
218 + Collection: CollectionConfig{
219 + Type: CollectionSNMP,
220 + SNMP: SNMPConfig{Target: "127.0.0.1", OID: ".1.3.6.1"},
221 + },
222 + Pipelines: []PipelineConfig{{
223 + Name: "walk",
224 + Steps: []zpre.Step{{Type: zpre.StepTypeSNMPWalkToJSON, Params: "{#IFNAME}\n.1.3.6.1\n1"}},
225 + Context: "zabbix.snmp.walk",
226 + Dimension: "value",
227 + Unit: "value",
228 + }},
229 + }
230 + if !requiresSNMPWalk(cfg) {
231 + t.Fatalf("expected requiresSNMPWalk to be true")
232 + }
233 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/API-DESIGN.md new
+220
@@ -0,0 +1,220 @@
1 +# Zabbix Preprocessing Library - API Design Specification
2 +
3 +**Last Updated:** 2025-11-14
4 +**Status:** Authoritative Requirements
5 +
6 +---
7 +
8 +## USER REQUIREMENTS (FINAL - DO NOT CHANGE)
9 +
10 +### Input Parameters
11 +The Netdata plugin will provide **TWO parameters**:
12 +1. **Unique identifier** for continuity (state tracking)
13 +2. **Just collected value** (the data to preprocess)
14 +
15 +### State Management
16 +- Library **MUST** maintain state based on the unique identifier
17 +- Unique identifier is **unique per shard, NOT across shards**
18 +- Example: "item1" in shard "A" is different from "item1" in shard "B"
19 +
20 +### Output Format
21 +The returned value of any collected sample is:
22 +1. **Array of metrics** (optionally with labels)
23 +2. **Possibly logs/events** to be committed to logs
24 +3. **Structured errors** for the caller to handle and understand what happened
25 +
26 +### Implementation Authority
27 +**Everything else is implementation details that the library author decides.**
28 +
29 +---
30 +
31 +## IMPLEMENTATION DECISIONS (CLAUDE'S AUTHORITY)
32 +
33 +### API Design Decision
34 +
35 +**Chosen Approach:** Explicit itemID parameter + Result struct return
36 +
37 +```go
38 +// Execute processes a single preprocessing step for a specific item
39 +func (p *Preprocessor) Execute(itemID string, value Value, step Step) (Result, error)
40 +
41 +// ExecutePipeline processes multiple steps for a specific item
42 +func (p *Preprocessor) ExecutePipeline(itemID string, value Value, steps []Step) (Result, error)
43 +
44 +// Result contains the output of preprocessing
45 +type Result struct {
46 + Metrics []Metric // Array of extracted metrics
47 + Logs []string // Optional log entries (future use)
48 + Error error // Overall error if preprocessing failed
49 +}
50 +
51 +// Metric represents a single metric output
52 +type Metric struct {
53 + Name string // Metric name
54 + Value string // Metric value
55 + Type ValueType // Value type
56 + Labels map[string]string // Optional labels
57 +}
58 +```
59 +
60 +**Rationale:**
61 +1. **itemID parameter**: User said "unique identifier for continuity" and "just collected value" as TWO parameters
62 +2. **Result return**: User said "array of metrics" not single value
63 +3. **Error in Result**: Allows partial success (some metrics succeed, some fail)
64 +
65 +### State Key Format
66 +
67 +State keys will be: `"{shardID}:{itemID}:{operation}:{params_hash}"`
68 +
69 +- `shardID`: From NewPreprocessor(shardID)
70 +- `itemID`: From Execute(itemID, ...)
71 +- `operation`: "delta_value", "delta_speed", "throttle_value", "throttle_timed"
72 +- `params_hash`: Hash of step.Params for multi-param operations
73 +
74 +**Example:** `"shard1:item123:delta_value:"`
75 +
76 +### Thread Safety
77 +
78 +- State map already protected with sync.RWMutex (P0 #5 resolved)
79 +- itemID is per-call, no shared state
80 +- Multiple goroutines can call Execute() concurrently with different itemIDs
81 +
82 +### Migration Path
83 +
84 +**Phase 1 (Current):** Keep old API for backward compatibility
85 +```go
86 +// DEPRECATED: Use Execute(itemID, value, step) instead
87 +func (p *Preprocessor) execute(value Value, step Step) (Value, error) {
88 + // Calls Execute("", value, step) - empty itemID = global state
89 +}
90 +```
91 +
92 +**Phase 2 (After Netdata integration):** Remove deprecated API
93 +
94 +### Multi-Metric Extraction
95 +
96 +Currently, preprocessing operations return single values. Multi-metric extraction (JSONPath arrays, Prometheus scrapes, CSV rows) will be:
97 +
98 +**Phase 1 (Current):** Single metric in array
99 +```go
100 +Result{
101 + Metrics: []Metric{{Name: "", Value: "result", Type: ValueTypeStr}},
102 +}
103 +```
104 +
105 +**Phase 2 (Future):** Actual multi-metric extraction
106 +```go
107 +// JSONPath with array result
108 +Result{
109 + Metrics: []Metric{
110 + {Name: "metric1", Value: "10", Labels: map[string]string{"index": "0"}},
111 + {Name: "metric2", Value: "20", Labels: map[string]string{"index": "1"}},
112 + },
113 +}
114 +```
115 +
116 +### Persistence API
117 +
118 +**Deferred to Phase 3** - not needed for initial Netdata integration:
119 +```go
120 +// SaveState exports preprocessor state for persistence
121 +func (p *Preprocessor) SaveState() ([]byte, error)
122 +
123 +// LoadState imports preprocessor state from persistence
124 +func (p *Preprocessor) LoadState(data []byte) error
125 +```
126 +
127 +**Rationale:** Netdata can recreate preprocessor instances on restart. State loss acceptable for now.
128 +
129 +---
130 +
131 +## IMPLEMENTATION CHECKLIST
132 +
133 +**Current Status:** Ready to implement P0 #3
134 +
135 +### Tasks:
136 +1. ✅ Document requirements (this file)
137 +2. ⏳ Add itemID parameter to Execute() and ExecutePipeline()
138 +3. ⏳ Update state key generation to use shardID + itemID
139 +4. ⏳ Change return type from (Value, error) to (Result, error)
140 +5. ⏳ Update all step functions to return Result
141 +6. ⏳ Keep deprecated execute() for backward compatibility
142 +7. ⏳ Update all 362 test cases to use new API
143 +8. ⏳ Add tests for per-item state isolation
144 +9. ⏳ Verify all tests still pass (362/362)
145 +
146 +### Estimated Work: 1-2 days
147 +
148 +---
149 +
150 +## NOTES FOR FUTURE SESSIONS
151 +
152 +**If discussing API design again:**
153 +- **STOP** - Read this file first
154 +- Requirements are FINAL (from user)
155 +- Implementation decisions are FINAL (from Claude)
156 +- Just implement, don't debate
157 +
158 +**If user requests changes:**
159 +- Update this file with new requirements
160 +- Mark as FINAL
161 +- Implement
162 +
163 +**If API questions arise:**
164 +- Refer to "IMPLEMENTATION DECISIONS" section
165 +- Make decision, document, implement
166 +- Don't ask user unless truly ambiguous
167 +
168 +---
169 +
170 +## BACKWARD COMPATIBILITY NOTES
171 +
172 +**Breaking Changes:**
173 +1. Execute() signature changes: `(value, step)` → `(itemID, value, step)`
174 +2. Return type changes: `(Value, error)` → `(Result, error)`
175 +
176 +**Migration:**
177 +```go
178 +// Old code:
179 +result, err := p.Execute(value, step)
180 +
181 +// New code:
182 +result, err := p.Execute("item123", value, step)
183 +// result is now Result{Metrics: []Metric{...}}
184 +```
185 +
186 +**Compatibility Layer:**
187 +- Keep old execute() as private method
188 +- Old tests can use it temporarily
189 +- Remove after Netdata integration complete
190 +
191 +---
192 +
193 +## REJECTED ALTERNATIVES
194 +
195 +**Why not add itemID to Value struct?**
196 +- User said TWO parameters: identifier + value
197 +- Pollutes Value with state management concerns
198 +- Less explicit than parameter
199 +
200 +**Why not SetItemContext()?**
201 +- Not thread-safe without locking
202 +- Awkward for concurrent processing
203 +- Violates "explicit is better than implicit"
204 +
205 +**Why not per-item Preprocessor instances?**
206 +- Memory overhead (one instance per item)
207 +- Complex lifecycle management
208 +- User said "unique identifier for continuity" not "instance per item"
209 +
210 +**Why not return ([]Metric, error)?**
211 +- Need structured errors AND logs in future
212 +- Result struct allows extension without API changes
213 +- Matches user requirement for "logs/events"
214 +
215 +---
216 +
217 +## END OF SPECIFICATION
218 +
219 +This document is the single source of truth for API design.
220 +Do not re-discuss. Just implement.
src/go/plugin/scripts.d/pkg/zabbixpreproc/INTEGRATION.md new
+665
@@ -0,0 +1,665 @@
1 +# Zabbix Preprocessing Library - Integration Guide
2 +
3 +Pure Go implementation of Zabbix preprocessing. 100% compatible with Zabbix official test suite (362/362 tests passing). No CGO dependencies.
4 +
5 +**Package**: `github.com/netdata/zabbix-preproc`
6 +
7 +---
8 +
9 +## Quick Start
10 +
11 +```go
12 +import zp "github.com/netdata/zabbix-preproc"
13 +
14 +// Create preprocessor instance (one per shard)
15 +p := zp.NewPreprocessor("shard-1")
16 +
17 +// Define input value
18 +value := zp.Value{
19 + Data: "42.5",
20 + Type: zp.ValueTypeStr,
21 + Timestamp: time.Now(),
22 +}
23 +
24 +// Define preprocessing step
25 +step := zp.Step{
26 + Type: zp.StepTypeMultiplier,
27 + Params: "10",
28 +}
29 +
30 +// Execute
31 +result, err := p.Execute("item-123", value, step)
32 +if err != nil {
33 + log.Fatal(err)
34 +}
35 +
36 +// Get result
37 +fmt.Println(result.Metrics[0].Value) // "425"
38 +```
39 +
40 +---
41 +
42 +## Core Types
43 +
44 +### Value
45 +```go
46 +type Value struct {
47 + Data string // Raw string data
48 + Type ValueType // ValueTypeStr (0), ValueTypeFloat (1), ValueTypeUint64 (3)
49 + Timestamp time.Time // Required for stateful operations (delta, throttle)
50 + IsError bool // True if value represents error from previous step
51 +}
52 +
53 +const (
54 + ValueTypeStr ValueType = 0
55 + ValueTypeFloat ValueType = 1
56 + ValueTypeUint64 ValueType = 3
57 +)
58 +```
59 +
60 +### Step
61 +```go
62 +type Step struct {
63 + Type StepType // One of 30 preprocessing types (see below)
64 + Params string // Type-specific parameters
65 + ErrorHandler ErrorHandler // Optional error handling
66 +}
67 +```
68 +
69 +### Result
70 +```go
71 +type Result struct {
72 + Metrics []Metric // Array of extracted metrics
73 + Logs []string // Optional log entries
74 + Error error // Overall error
75 +}
76 +
77 +type Metric struct {
78 + Name string // Metric name
79 + Value string // Metric value as string
80 + Type ValueType // Value type
81 + Labels map[string]string // Optional labels (for Prometheus, etc.)
82 +}
83 +```
84 +
85 +---
86 +
87 +## All 30 Preprocessing Types
88 +
89 +### Arithmetic Operations
90 +
91 +**Type 1: Multiplier**
92 +```go
93 +step := zp.Step{Type: zp.StepTypeMultiplier, Params: "10"}
94 +// Input: "42.5" → Output: "425"
95 +```
96 +
97 +**Type 9: Delta Value** (stateful - requires timestamp)
98 +```go
99 +// First call: value=100, timestamp=T1 → returns nothing (needs baseline)
100 +// Second call: value=150, timestamp=T2 → returns "50" (150-100)
101 +step := zp.Step{Type: zp.StepTypeDeltaValue}
102 +```
103 +
104 +**Type 10: Delta Speed** (stateful - requires timestamp)
105 +```go
106 +// Returns (current - previous) / time_diff_seconds
107 +step := zp.Step{Type: zp.StepTypeDeltaSpeed}
108 +```
109 +
110 +### String Operations
111 +
112 +**Type 2: RTrim**
113 +```go
114 +step := zp.Step{Type: zp.StepTypeRTrim, Params: " \t\n"}
115 +// Input: "hello \n" → Output: "hello"
116 +```
117 +
118 +**Type 3: LTrim**
119 +```go
120 +step := zp.Step{Type: zp.StepTypeLTrim, Params: " \t"}
121 +// Input: " hello" → Output: "hello"
122 +```
123 +
124 +**Type 4: Trim**
125 +```go
126 +step := zp.Step{Type: zp.StepTypeTrim, Params: " "}
127 +// Input: " hello " → Output: "hello"
128 +```
129 +
130 +**Type 5: Regex Substitution**
131 +```go
132 +// Params: "pattern\nreplacement"
133 +step := zp.Step{
134 + Type: zp.StepTypeRegexSubstitution,
135 + Params: "\\d+\nNUMBER",
136 +}
137 +// Input: "error code 123" → Output: "error code NUMBER"
138 +
139 +// With capture groups:
140 +step := zp.Step{
141 + Type: zp.StepTypeRegexSubstitution,
142 + Params: "(\\d+)-(\\d+)\n\\2-\\1",
143 +}
144 +// Input: "123-456" → Output: "456-123"
145 +```
146 +
147 +**Type 25: String Replace**
148 +```go
149 +// Params: "search\nreplace"
150 +step := zp.Step{
151 + Type: zp.StepTypeStringReplace,
152 + Params: "old\nnew",
153 +}
154 +// Input: "old value old" → Output: "new value new"
155 +```
156 +
157 +### Type Conversions
158 +
159 +**Type 6: Bool to Decimal**
160 +```go
161 +step := zp.Step{Type: zp.StepTypeBool2Dec}
162 +// "true", "yes", "on", "up", "running", "enabled", "available", non-zero → "1"
163 +// "false", "no", "off", "down", "unused", "disabled", "unavailable", "0" → "0"
164 +```
165 +
166 +**Type 7: Octal to Decimal**
167 +```go
168 +step := zp.Step{Type: zp.StepTypeOct2Dec}
169 +// Input: "755" → Output: "493"
170 +// Input: "0755" → Output: "493"
171 +```
172 +
173 +**Type 8: Hex to Decimal**
174 +```go
175 +step := zp.Step{Type: zp.StepTypeHex2Dec}
176 +// Input: "FF" → Output: "255"
177 +// Input: "0xFF" → Output: "255"
178 +// Input: "0x1A2B" → Output: "6699"
179 +```
180 +
181 +### Data Extraction
182 +
183 +**Type 11: XPath**
184 +```go
185 +step := zp.Step{
186 + Type: zp.StepTypeXPath,
187 + Params: "//server/status/text()",
188 +}
189 +// Input: "<server><status>OK</status></server>"
190 +// Output: "OK"
191 +```
192 +
193 +**Type 12: JSONPath**
194 +```go
195 +step := zp.Step{
196 + Type: zp.StepTypeJSONPath,
197 + Params: "$.store.book[0].price",
198 +}
199 +// Input: `{"store":{"book":[{"price":8.95}]}}`
200 +// Output: "8.95"
201 +
202 +// Array extraction:
203 +step := zp.Step{
204 + Type: zp.StepTypeJSONPath,
205 + Params: "$.store.book[*].price",
206 +}
207 +// Output: "[8.95,12.99,8.99]"
208 +```
209 +
210 +**Type 22: Prometheus Pattern**
211 +```go
212 +// Params: "metric_name\nlabel_name\nfunction"
213 +// Functions: value, label, sum, min, max, avg, count
214 +
215 +// Get metric value:
216 +step := zp.Step{
217 + Type: zp.StepTypePrometheusPattern,
218 + Params: "cpu_usage\n\nvalue",
219 +}
220 +// Input: `cpu_usage{host="server1"} 85.5`
221 +// Output: "85.5"
222 +
223 +// Get label value:
224 +step := zp.Step{
225 + Type: zp.StepTypePrometheusPattern,
226 + Params: "http_requests\nmethod\nlabel",
227 +}
228 +// Input: `http_requests{method="GET"} 100`
229 +// Output: "GET"
230 +
231 +// Aggregate:
232 +step := zp.Step{
233 + Type: zp.StepTypePrometheusPattern,
234 + Params: "cpu_usage\n\nsum",
235 +}
236 +// Multiple metrics → sum of all values
237 +```
238 +
239 +**Type 23: Prometheus to JSON**
240 +```go
241 +step := zp.Step{Type: zp.StepTypePrometheusToJSON}
242 +// Input: `metric_name{label="value"} 123`
243 +// Output: `[{"name":"metric_name","value":"123","labels":{"label":"value"}}]`
244 +```
245 +
246 +**Type 24: CSV to JSON**
247 +```go
248 +// Params: "delimiter\nquote_char\nwith_header\n"
249 +// delimiter: single char (default: ,)
250 +// quote_char: single char (default: ")
251 +// with_header: 1 = first row is header, 0 = no header
252 +
253 +step := zp.Step{
254 + Type: zp.StepTypeCSVToJSON,
255 + Params: ",\n\"\n1\n",
256 +}
257 +// Input:
258 +// name,age,city
259 +// Alice,30,NYC
260 +// Bob,25,LA
261 +
262 +// Output:
263 +// [{"name":"Alice","age":"30","city":"NYC"},{"name":"Bob","age":"25","city":"LA"}]
264 +```
265 +
266 +**Type 27: XML to JSON**
267 +```go
268 +step := zp.Step{Type: zp.StepTypeXMLToJSON}
269 +// Input: `<root><foo bar="BAR">BAZ</foo></root>`
270 +// Output: `{"root":{"foo":{"@bar":"BAR","#text":"BAZ"}}}`
271 +
272 +// Zabbix XML serialization rules:
273 +// 1. Attributes → "@attr": "value"
274 +// 2. Self-closing → null
275 +// 3. Empty attributes → "@attr": ""
276 +// 4. Repeated elements → arrays
277 +// 5. Simple text → direct string
278 +// 6. Text + attributes → "#text": "value"
279 +```
280 +
281 +### Validation
282 +
283 +**Type 13: Validate Range**
284 +```go
285 +// Params: "min\nmax"
286 +step := zp.Step{
287 + Type: zp.StepTypeValidateRange,
288 + Params: "0\n100",
289 +}
290 +// Input: "50" → Output: "50" (passes validation)
291 +// Input: "150" → Error: value out of range
292 +```
293 +
294 +**Type 14: Validate Regex**
295 +```go
296 +step := zp.Step{
297 + Type: zp.StepTypeValidateRegex,
298 + Params: "^\\d+$",
299 +}
300 +// Input: "12345" → passes
301 +// Input: "abc123" → Error: does not match pattern
302 +```
303 +
304 +**Type 15: Validate Not Regex**
305 +```go
306 +step := zp.Step{
307 + Type: zp.StepTypeValidateNotRegex,
308 + Params: "error|fail",
309 +}
310 +// Input: "success" → passes
311 +// Input: "error occurred" → Error: matches forbidden pattern
312 +```
313 +
314 +**Type 26: Validate Not Supported**
315 +```go
316 +step := zp.Step{Type: zp.StepTypeValidateNotSupported}
317 +// Always returns error: "not supported"
318 +// Used to mark items as not supported in preprocessing chain
319 +```
320 +
321 +### Error Field Extraction
322 +
323 +**Type 16: Error Field JSON**
324 +```go
325 +step := zp.Step{
326 + Type: zp.StepTypeErrorFieldJSON,
327 + Params: "$.error.message",
328 +}
329 +// Input: `{"error":{"message":"timeout"}}`
330 +// Output: Error with message "timeout"
331 +```
332 +
333 +**Type 17: Error Field XML**
334 +```go
335 +step := zp.Step{
336 + Type: zp.StepTypeErrorFieldXML,
337 + Params: "//error/text()",
338 +}
339 +// Input: `<response><error>Not found</error></response>`
340 +// Output: Error with message "Not found"
341 +```
342 +
343 +**Type 18: Error Field Regex**
344 +```go
345 +step := zp.Step{
346 + Type: zp.StepTypeErrorFieldRegex,
347 + Params: "ERROR: (.+)",
348 +}
349 +// Input: "ERROR: Connection refused"
350 +// Output: Error with message "Connection refused"
351 +```
352 +
353 +### Throttling (Stateful)
354 +
355 +**Type 19: Throttle Value**
356 +```go
357 +step := zp.Step{Type: zp.StepTypeThrottleValue}
358 +// Only passes through if value changed from previous
359 +// Same value → returns empty result (discarded)
360 +```
361 +
362 +**Type 20: Throttle Timed Value**
363 +```go
364 +// Params: "seconds"
365 +step := zp.Step{
366 + Type: zp.StepTypeThrottleTimedValue,
367 + Params: "60",
368 +}
369 +// Passes through if:
370 +// - Value changed from previous, OR
371 +// - More than 60 seconds since last pass
372 +```
373 +
374 +### JavaScript
375 +
376 +**Type 21: JavaScript**
377 +```go
378 +step := zp.Step{
379 + Type: zp.StepTypeJavaScript,
380 + Params: "return parseFloat(value) * 2;",
381 +}
382 +// Input: "21" → Output: "42"
383 +
384 +// Complex transformation:
385 +step := zp.Step{
386 + Type: zp.StepTypeJavaScript,
387 + Params: `
388 + var obj = JSON.parse(value);
389 + return obj.temperature * 9/5 + 32;
390 + `,
391 +}
392 +// Input: `{"temperature": 20}` → Output: "68"
393 +```
394 +
395 +### SNMP Operations
396 +
397 +**Type 28: SNMP Walk Value**
398 +```go
399 +// Params: "oid\nformat"
400 +// format: 0=unchanged, 1=UTF-8, 2=MAC, 3=Int, 4=Hex, 5=IP
401 +step := zp.Step{
402 + Type: zp.StepTypeSNMPWalkValue,
403 + Params: ".1.3.6.1.2.1.2.2.1.2.1\n0",
404 +}
405 +// Extracts specific OID value from SNMP walk output
406 +```
407 +
408 +**Type 29: SNMP Walk to JSON**
409 +```go
410 +// Params: "field_name\noid_to_extract\nformat"
411 +step := zp.Step{
412 + Type: zp.StepTypeSNMPWalkToJSON,
413 + Params: "interfaces\n.1.3.6.1.2.1.2.2.1.2\n0",
414 +}
415 +// Converts SNMP walk table to JSON structure
416 +```
417 +
418 +**Type 30: SNMP Get Value**
419 +```go
420 +// Params: "format"
421 +// format: 0=unchanged, 1=UTF-8, 2=MAC, 3=Int, 4=Hex, 5=IP
422 +step := zp.Step{
423 + Type: zp.StepTypeSNMPGetValue,
424 + Params: "2", // MAC address format
425 +}
426 +// Input: "0x001122334455" → Output: "00:11:22:33:44:55"
427 +```
428 +
429 +---
430 +
431 +## Error Handling
432 +
433 +```go
434 +step := zp.Step{
435 + Type: zp.StepTypeMultiplier,
436 + Params: "10",
437 + ErrorHandler: zp.ErrorHandler{
438 + Action: zp.ErrorActionSetValue,
439 + Params: "0", // Default to 0 on error
440 + },
441 +}
442 +
443 +// Error actions:
444 +// ErrorActionDefault (0) - Return error to caller
445 +// ErrorActionDiscard (1) - Discard value (empty result)
446 +// ErrorActionSetValue (2) - Set custom value
447 +// ErrorActionSetError (3) - Set custom error message
448 +```
449 +
450 +---
451 +
452 +## Pipeline Execution
453 +
454 +```go
455 +steps := []zp.Step{
456 + {Type: zp.StepTypeJSONPath, Params: "$.temperature"},
457 + {Type: zp.StepTypeMultiplier, Params: "1.8"},
458 + {Type: zp.StepTypeValidateRange, Params: "-50\n150"},
459 +}
460 +
461 +result, err := p.ExecutePipeline("item-123", value, steps)
462 +```
463 +
464 +---
465 +
466 +## Stateful Operations (Delta, Throttle)
467 +
468 +```go
469 +// Create preprocessor for shard
470 +p := zp.NewPreprocessor("shard-1")
471 +
472 +// Delta/Throttle operations require:
473 +// 1. Consistent itemID across calls
474 +// 2. Accurate timestamps
475 +
476 +value1 := zp.Value{Data: "100", Timestamp: time.Now()}
477 +value2 := zp.Value{Data: "150", Timestamp: time.Now().Add(time.Second)}
478 +
479 +step := zp.Step{Type: zp.StepTypeDeltaValue}
480 +
481 +// First call establishes baseline
482 +r1, _ := p.Execute("cpu-counter", value1, step)
483 +// r1.Metrics is empty (needs baseline)
484 +
485 +// Second call computes delta
486 +r2, _ := p.Execute("cpu-counter", value2, step)
487 +// r2.Metrics[0].Value = "50"
488 +```
489 +
490 +---
491 +
492 +## State Management
493 +
494 +### Enable Automatic Cleanup
495 +```go
496 +p := zp.NewPreprocessor("shard-1")
497 +
498 +// Clean up state entries unused for 1 hour, check every 10 minutes
499 +stopCleanup := p.EnableStateCleanup(time.Hour, 10*time.Minute)
500 +
501 +// When done
502 +stopCleanup()
503 +```
504 +
505 +### With Config
506 +```go
507 +cfg := zp.Config{
508 + Logger: myLogger,
509 + StateTTL: time.Hour,
510 + StateCleanupInterval: 10 * time.Minute,
511 +}
512 +p := zp.NewPreprocessorWithConfig("shard-1", cfg)
513 +```
514 +
515 +---
516 +
517 +## Logging
518 +
519 +```go
520 +// Implement Logger interface
521 +type Logger interface {
522 + Logf(format string, args ...interface{})
523 +}
524 +
525 +// Use your logger
526 +p.SetLogger(myLogger)
527 +
528 +// Or with config
529 +cfg := zp.Config{Logger: myLogger}
530 +p := zp.NewPreprocessorWithConfig("shard-1", cfg)
531 +
532 +// Default: NoopLogger (zero overhead)
533 +```
534 +
535 +---
536 +
537 +## Multi-Metric Extensions (Non-Zabbix)
538 +
539 +```go
540 +// Types 60-63 return multiple metrics directly (not JSON string)
541 +step := zp.Step{
542 + Type: zp.StepTypePrometheusToJSONMulti,
543 + Params: "",
544 +}
545 +
546 +result, _ := p.Execute("prom", value, step)
547 +for _, metric := range result.Metrics {
548 + fmt.Printf("%s=%s labels=%v\n", metric.Name, metric.Value, metric.Labels)
549 +}
550 +```
551 +
552 +---
553 +
554 +## Thread Safety
555 +
556 +```go
557 +// Single Preprocessor instance is thread-safe
558 +p := zp.NewPreprocessor("shard-1")
559 +
560 +// Safe for concurrent use
561 +go func() {
562 + p.Execute("item-1", val1, step1)
563 +}()
564 +go func() {
565 + p.Execute("item-2", val2, step2)
566 +}()
567 +```
568 +
569 +---
570 +
571 +## Complete Example: Netdata Integration
572 +
573 +```go
574 +package main
575 +
576 +import (
577 + "fmt"
578 + "time"
579 + zp "github.com/netdata/zabbix-preproc"
580 +)
581 +
582 +func main() {
583 + // One preprocessor per shard
584 + p := zp.NewPreprocessorWithConfig("netdata-agent-1", zp.Config{
585 + StateTTL: 24 * time.Hour,
586 + StateCleanupInterval: time.Hour,
587 + })
588 +
589 + // Example: Process CPU metric from Prometheus format
590 + promValue := zp.Value{
591 + Data: `
592 +node_cpu_seconds_total{cpu="0",mode="idle"} 12345.67
593 +node_cpu_seconds_total{cpu="0",mode="system"} 1234.56
594 +node_cpu_seconds_total{cpu="0",mode="user"} 9876.54
595 +`,
596 + Type: zp.ValueTypeStr,
597 + Timestamp: time.Now(),
598 + }
599 +
600 + // Extract idle CPU metric
601 + step := zp.Step{
602 + Type: zp.StepTypePrometheusPattern,
603 + Params: "node_cpu_seconds_total\nmode=idle\nvalue",
604 + }
605 +
606 + result, err := p.Execute("cpu.idle", promValue, step)
607 + if err != nil {
608 + panic(err)
609 + }
610 +
611 + fmt.Printf("CPU idle seconds: %s\n", result.Metrics[0].Value)
612 +
613 + // Example: JSON extraction pipeline
614 + jsonValue := zp.Value{
615 + Data: `{"system":{"cpu":{"usage":85.5},"memory":{"used":4096}}}`,
616 + Type: zp.ValueTypeStr,
617 + Timestamp: time.Now(),
618 + }
619 +
620 + pipeline := []zp.Step{
621 + {Type: zp.StepTypeJSONPath, Params: "$.system.cpu.usage"},
622 + {Type: zp.StepTypeValidateRange, Params: "0\n100"},
623 + }
624 +
625 + result, err = p.ExecutePipeline("system.cpu.usage", jsonValue, pipeline)
626 + if err != nil {
627 + panic(err)
628 + }
629 +
630 + fmt.Printf("CPU usage: %s%%\n", result.Metrics[0].Value)
631 +
632 + // Example: Delta calculation (rate of change)
633 + counter1 := zp.Value{
634 + Data: "1000",
635 + Type: zp.ValueTypeStr,
636 + Timestamp: time.Now(),
637 + }
638 + counter2 := zp.Value{
639 + Data: "1100",
640 + Type: zp.ValueTypeStr,
641 + Timestamp: time.Now().Add(time.Minute),
642 + }
643 +
644 + deltaStep := zp.Step{Type: zp.StepTypeDeltaSpeed}
645 +
646 + // First call establishes baseline
647 + p.Execute("network.bytes", counter1, deltaStep)
648 +
649 + // Second call computes rate (bytes/second)
650 + result, _ = p.Execute("network.bytes", counter2, deltaStep)
651 + fmt.Printf("Network rate: %s bytes/sec\n", result.Metrics[0].Value)
652 +}
653 +```
654 +
655 +---
656 +
657 +## Dependencies
658 +
659 +- `gopkg.in/yaml.v3` - YAML parsing (test harness)
660 +- `github.com/ohler55/ojg` - JSONPath (RFC 9535 compliant)
661 +- `github.com/antchfx/xmlquery` - XPath
662 +- `github.com/dop251/goja` - JavaScript ES5.1 runtime
663 +- `github.com/prometheus/common/expfmt` - Prometheus text format
664 +
665 +All pure Go - no CGO dependencies.
src/go/plugin/scripts.d/pkg/zabbixpreproc/README.md new
+406
@@ -0,0 +1,406 @@
1 +# Zabbix Preprocessing Library
2 +
3 +Pure Go implementation of Zabbix preprocessing with 100% compatibility.
4 +
5 +## Features
6 +
7 +- ✅ **100% Zabbix Compatible** - Passes all 362 official Zabbix test cases
8 +- ✅ **Pure Go** - No CGO, no external C libraries
9 +- ✅ **Thread-Safe** - Concurrent processing with per-shard state isolation
10 +- ✅ **High Performance** - VM pooling, regex caching, optimized parsers
11 +- ✅ **Multi-Metric Support** - Extract multiple metrics from single values (Prometheus, SNMP, CSV, JSONPath)
12 +
13 +## Installation
14 +
15 +```bash
16 +go get github.com/netdata/zabbix-preproc
17 +```
18 +
19 +## Quick Start
20 +
21 +```go
22 +package main
23 +
24 +import (
25 + "fmt"
26 + preproc "github.com/netdata/zabbix-preproc"
27 +)
28 +
29 +func main() {
30 + // Create preprocessor for a shard
31 + p := preproc.NewPreprocessor("shard1")
32 +
33 + // Define preprocessing pipeline
34 + steps := []preproc.Step{
35 + {Type: preproc.StepTypeJSONPath, Params: "$.cpu"},
36 + {Type: preproc.StepTypeMultiplier, Params: "100"},
37 + }
38 +
39 + // Process value
40 + value := preproc.Value{Data: `{"cpu": 0.45}`}
41 + result, err := p.ExecutePipeline("cpu_metric", value, steps)
42 + if err != nil {
43 + panic(err)
44 + }
45 +
46 + // Get result
47 + fmt.Println(result.Metrics[0].Value) // Output: 45
48 +}
49 +```
50 +
51 +## Validation
52 +
53 +Validate steps before execution to catch configuration errors early:
54 +
55 +```go
56 +// Validate a single step
57 +step := preproc.Step{Type: preproc.StepTypeMultiplier, Params: "2.5"}
58 +if err := preproc.ValidateStep(step); err != nil {
59 + // Handle validation error
60 + fmt.Printf("Invalid step: %v\n", err)
61 +}
62 +
63 +// Validate entire pipeline
64 +steps := []preproc.Step{
65 + {Type: preproc.StepTypeJSONPath, Params: "$.cpu"},
66 + {Type: preproc.StepTypeMultiplier, Params: "100"},
67 +}
68 +if err := preproc.ValidatePipeline(steps); err != nil {
69 + // Pipeline has invalid configuration
70 + fmt.Printf("Invalid pipeline: %v\n", err)
71 +}
72 +```
73 +
74 +**Benefits of early validation:**
75 +- Catch configuration errors before processing data
76 +- Especially useful for multi-step pipelines (fail fast)
77 +- Clear error messages indicating which parameter is missing/invalid
78 +- Validates step type, required parameters, parameter format, and error handlers
79 +
80 +## Common Use Cases
81 +
82 +### 1. Data Extraction
83 +
84 +**JSONPath** - Extract values from JSON:
85 +```go
86 +steps := []preproc.Step{
87 + {Type: preproc.StepTypeJSONPath, Params: "$.server.memory"},
88 +}
89 +// Input: {"server": {"memory": 8192}}
90 +// Output: 8192
91 +```
92 +
93 +**XPath** - Extract values from XML:
94 +```go
95 +steps := []preproc.Step{
96 + {Type: preproc.StepTypeXPath, Params: "//temperature/text()"},
97 +}
98 +// Input: <data><temperature>25.5</temperature></data>
99 +// Output: 25.5
100 +```
101 +
102 +**Prometheus** - Extract specific metrics:
103 +```go
104 +steps := []preproc.Step{
105 + {Type: preproc.StepTypePrometheusPattern, Params: `http_requests{method="GET"}`},
106 +}
107 +// Input: http_requests{method="GET"} 1027\nhttp_requests{method="POST"} 50
108 +// Output: 1027
109 +```
110 +
111 +### 2. Data Transformation
112 +
113 +**Multiply** - Scale values:
114 +```go
115 +steps := []preproc.Step{
116 + {Type: preproc.StepTypeMultiplier, Params: "1000"},
117 +}
118 +// Input: 1.5
119 +// Output: 1500
120 +```
121 +
122 +**Regex Substitution** - Transform text:
123 +```go
124 +steps := []preproc.Step{
125 + {Type: preproc.StepTypeRegexSubstitution, Params: "([0-9]+)\n$1 units"},
126 +}
127 +// Input: Temperature: 25
128 +// Output: Temperature: 25 units
129 +```
130 +
131 +**Trim** - Remove whitespace:
132 +```go
133 +steps := []preproc.Step{
134 + {Type: preproc.StepTypeTrim, Params: " \t\n"},
135 +}
136 +// Input: " hello "
137 +// Output: "hello"
138 +```
139 +
140 +### 3. Stateful Operations
141 +
142 +**Delta Value** - Calculate differences:
143 +```go
144 +steps := []preproc.Step{
145 + {Type: preproc.StepTypeDeltaValue},
146 +}
147 +// First call: 100 → 0 (baseline)
148 +// Second call: 150 → 50 (delta)
149 +```
150 +
151 +**Delta Speed** - Calculate rate per second:
152 +```go
153 +steps := []preproc.Step{
154 + {Type: preproc.StepTypeDeltaSpeed},
155 +}
156 +// Call 1 (t=0s): 100 bytes → 0
157 +// Call 2 (t=10s): 600 bytes → 50 bytes/sec
158 +```
159 +
160 +**Throttling** - Suppress duplicate values:
161 +```go
162 +steps := []preproc.Step{
163 + {Type: preproc.StepTypeThrottleValue, Params: "300"}, // 5 minutes
164 +}
165 +// Only processes value if changed or 5 min elapsed
166 +```
167 +
168 +### 4. Multi-Metric Extraction
169 +
170 +**Prometheus to Multiple Metrics**:
171 +```go
172 +steps := []preproc.Step{
173 + {Type: preproc.StepTypePrometheusToJSONMulti},
174 +}
175 +
176 +prometheusData := `
177 +http_requests{method="GET"} 100
178 +http_requests{method="POST"} 50
179 +memory_bytes 8192
180 +`
181 +
182 +result, _ := p.ExecutePipeline("metrics", preproc.Value{Data: prometheusData}, steps)
183 +
184 +for _, metric := range result.Metrics {
185 + fmt.Printf("%s: %s (labels: %v)\n", metric.Name, metric.Value, metric.Labels)
186 +}
187 +// Output:
188 +// http_requests: 100 (labels: map[method:GET])
189 +// http_requests: 50 (labels: map[method:POST])
190 +// memory_bytes: 8192 (labels: map[])
191 +```
192 +
193 +**SNMP Walk to Multiple Metrics**:
194 +```go
195 +steps := []preproc.Step{
196 + {Type: preproc.StepTypeSNMPWalkToJSONMulti, Params: ".1.3.6.1.2.1.2.2.1.2"},
197 +}
198 +
199 +snmpData := `
200 +.1.3.6.1.2.1.2.2.1.2.1 = STRING: "eth0"
201 +.1.3.6.1.2.1.2.2.1.2.2 = STRING: "eth1"
202 +`
203 +
204 +result, _ := p.ExecutePipeline("interfaces", preproc.Value{Data: snmpData}, steps)
205 +// Returns multiple metrics with OID indices as labels
206 +```
207 +
208 +**JSONPath to Multiple Values**:
209 +```go
210 +steps := []preproc.Step{
211 + {Type: preproc.StepTypeJSONPathMulti, Params: "$.servers[*].cpu"},
212 +}
213 +
214 +jsonData := `{"servers": [{"cpu": 45}, {"cpu": 23}, {"cpu": 78}]}`
215 +
216 +result, _ := p.ExecutePipeline("cpus", preproc.Value{Data: jsonData}, steps)
217 +// Returns 3 metrics with values: 45, 23, 78
218 +```
219 +
220 +### 5. Error Handling
221 +
222 +**Discard on Error**:
223 +```go
224 +steps := []preproc.Step{
225 + {
226 + Type: preproc.StepTypeJSONPath,
227 + Params: "$.nonexistent",
228 + ErrorHandler: preproc.ErrorHandler{
229 + Action: preproc.ErrorActionDiscard,
230 + },
231 + },
232 +}
233 +// Returns empty result instead of error
234 +```
235 +
236 +**Set Custom Value on Error**:
237 +```go
238 +steps := []preproc.Step{
239 + {
240 + Type: preproc.StepTypeValidateRange,
241 + Params: "0\n100",
242 + ErrorHandler: preproc.ErrorHandler{
243 + Action: preproc.ErrorActionSetValue,
244 + Params: "-1", // Default value
245 + },
246 + },
247 +}
248 +// Returns -1 if value out of range
249 +```
250 +
251 +### 6. Complex Pipelines
252 +
253 +Chain multiple steps together:
254 +
255 +```go
256 +steps := []preproc.Step{
257 + // 1. Extract JSON value
258 + {Type: preproc.StepTypeJSONPath, Params: "$.temperature"},
259 +
260 + // 2. Remove whitespace
261 + {Type: preproc.StepTypeTrim, Params: " "},
262 +
263 + // 3. Extract number (remove unit)
264 + {Type: preproc.StepTypeRegexSubstitution, Params: "([0-9.]+).*\n\\1"},
265 +
266 + // 4. Convert Celsius to Fahrenheit
267 + {Type: preproc.StepTypeMultiplier, Params: "1.8"},
268 +}
269 +
270 +value := preproc.Value{Data: `{"temperature": " 25.0 C "}`}
271 +result, _ := p.ExecutePipeline("temp", value, steps)
272 +// Output: 45
273 +```
274 +
275 +## Supported Preprocessing Types
276 +
277 +### Arithmetic
278 +- `StepTypeMultiplier` - Multiply by constant
279 +- `StepTypeDeltaValue` - Calculate simple delta
280 +- `StepTypeDeltaSpeed` - Calculate rate per second
281 +
282 +### String Operations
283 +- `StepTypeTrim`, `StepTypeRTrim`, `StepTypeLTrim` - Remove characters
284 +- `StepTypeRegexSubstitution` - Regex find/replace
285 +- `StepTypeStringReplace` - Simple string replace
286 +
287 +### Conversions
288 +- `StepTypeBool2Dec` - Boolean to 0/1
289 +- `StepTypeOct2Dec` - Octal to decimal
290 +- `StepTypeHex2Dec` - Hexadecimal to decimal
291 +
292 +### Data Extraction (Single-Value)
293 +- `StepTypeJSONPath` - JSONPath query
294 +- `StepTypeXPath` - XPath query
295 +- `StepTypePrometheusPattern` - Prometheus metric extraction
296 +- `StepTypePrometheusToJSON` - Prometheus to JSON
297 +- `StepTypeCSVToJSON` - CSV to JSON
298 +- `StepTypeSNMPWalkValue` - Extract specific SNMP OID
299 +- `StepTypeSNMPGetValue` - SNMP get value
300 +- `StepTypeSNMPWalkToJSON` - SNMP walk to JSON
301 +
302 +### Data Extraction (Multi-Metric)
303 +- `StepTypePrometheusToJSONMulti` - Extract all Prometheus metrics
304 +- `StepTypeJSONPathMulti` - Extract array values as separate metrics
305 +- `StepTypeSNMPWalkToJSONMulti` - SNMP walk discovery
306 +- `StepTypeCSVToJSONMulti` - CSV rows as separate metrics
307 +
308 +### Validation
309 +- `StepTypeValidateRange` - Check numeric range
310 +- `StepTypeValidateRegex` - Validate with regex
311 +- `StepTypeValidateNotRegex` - Inverse regex validation
312 +- `StepTypeValidateNotSupported` - Check for "not supported" errors
313 +
314 +### Error Field Extraction
315 +- `StepTypeErrorFieldJSON` - Extract error from JSON
316 +- `StepTypeErrorFieldXML` - Extract error from XML
317 +- `StepTypeErrorFieldRegex` - Extract error with regex
318 +
319 +### Throttling
320 +- `StepTypeThrottleValue` - Suppress duplicate values
321 +- `StepTypeThrottleTimedValue` - Time-based throttling
322 +
323 +### Advanced
324 +- `StepTypeJavaScript` - Custom JavaScript preprocessing
325 +
326 +## Architecture
327 +
328 +### Per-Shard State Isolation
329 +
330 +Each preprocessor instance maintains independent state:
331 +
332 +```go
333 +// Shard A - processing metrics from server1
334 +p1 := preproc.NewPreprocessor("shard-server1")
335 +
336 +// Shard B - processing metrics from server2
337 +p2 := preproc.NewPreprocessor("shard-server2")
338 +
339 +// Each maintains separate delta/throttle history
340 +// No state leakage between shards
341 +```
342 +
343 +### Thread Safety
344 +
345 +All operations are thread-safe:
346 +- Concurrent preprocessing across different items/shards
347 +- VM pooling for JavaScript execution
348 +- Read-only MIB-to-OID mappings
349 +- RWMutex protection for stateful operations
350 +
351 +### Performance Optimizations
352 +
353 +- **VM Pooling**: JavaScript VMs reused, not recreated
354 +- **Program Caching**: Compiled JavaScript programs cached
355 +- **Regex Caching**: Patterns compiled once at package init
356 +- **strings.Builder**: Efficient string construction in loops
357 +
358 +## Testing
359 +
360 +```bash
361 +# Run all tests (362 Zabbix compatibility tests + unit tests)
362 +go test
363 +
364 +# Run with coverage report
365 +go test -cover
366 +
367 +# Run with race detector
368 +go test -race
369 +
370 +# Run specific test suite
371 +go test -run TestZabbixTestSuite
372 +
373 +# Run fuzzing tests (finds edge cases via random input generation)
374 +go test -fuzz=FuzzJSONPath -fuzztime=30s # Fuzz JSONPath extraction
375 +go test -fuzz=FuzzXPath -fuzztime=30s # Fuzz XPath extraction
376 +go test -fuzz=FuzzRegexSubstitution -fuzztime=30s # Fuzz regex replacement
377 +go test -fuzz=FuzzMultiplier -fuzztime=30s # Fuzz numeric operations
378 +go test -fuzz=FuzzCSVToJSON -fuzztime=30s # Fuzz CSV parsing
379 +go test -fuzz=FuzzPrometheusPattern -fuzztime=30s # Fuzz Prometheus parsing
380 +go test -fuzz=FuzzSNMPWalkToJSON -fuzztime=30s # Fuzz SNMP parsing
381 +# Note: Fuzzing runs indefinitely if -fuzztime not specified
382 +
383 +# Benchmark
384 +go test -bench=.
385 +```
386 +
387 +## Compatibility
388 +
389 +This library passes 100% of official Zabbix preprocessing tests:
390 +- ✅ zbx_item_preproc.yaml (362 test cases)
391 +- ✅ item_preproc_xpath.yaml (12 XPath tests)
392 +- ✅ item_preproc_csv_to_json.yaml (4 CSV tests)
393 +
394 +Zabbix version compatibility: **7.x** (backward compatible with 6.x)
395 +
396 +## License
397 +
398 +Apache 2.0
399 +
400 +## Contributing
401 +
402 +Contributions welcome! Please ensure:
403 +- All Zabbix tests still pass (`go test`)
404 +- No CGO dependencies added
405 +- Code follows existing patterns
406 +- New features have test coverage
src/go/plugin/scripts.d/pkg/zabbixpreproc/advanced_steps.go new
+247
@@ -0,0 +1,247 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "encoding/json"
5 + "fmt"
6 + "strings"
7 + "time"
8 +
9 + "github.com/antchfx/xmlquery"
10 + "github.com/theory/jsonpath"
11 +)
12 +
13 +// errorFieldJSON extracts error message from JSON response.
14 +func errorFieldJSON(value Value, paramStr string) (Value, error) {
15 + expr := strings.TrimSpace(paramStr)
16 + if expr == "" {
17 + return Value{}, fmt.Errorf("jsonpath expression required for error field extraction")
18 + }
19 +
20 + var data interface{}
21 + err := json.Unmarshal([]byte(value.Data), &data)
22 + if err != nil {
23 + // Invalid JSON - return input unchanged
24 + return value, nil
25 + }
26 +
27 + jp, err := jsonpath.Parse(expr)
28 + if err != nil {
29 + return Value{}, fmt.Errorf("invalid jsonpath: %w", err)
30 + }
31 +
32 + results := jp.Select(data)
33 + if len(results) == 0 {
34 + // No error field found - return input unchanged
35 + return value, nil
36 + }
37 +
38 + // Error field exists - return error with the field's value
39 + errValue := fmt.Sprintf("%v", results[0])
40 + return Value{}, fmt.Errorf("%s", errValue)
41 +}
42 +
43 +// errorFieldXML extracts error message from XML response.
44 +func errorFieldXML(value Value, paramStr string) (Value, error) {
45 + expr := strings.TrimSpace(paramStr)
46 + if expr == "" {
47 + return Value{}, fmt.Errorf("xpath expression required for error field extraction")
48 + }
49 +
50 + doc, err := xmlquery.Parse(strings.NewReader(value.Data))
51 + if err != nil {
52 + // Invalid XML - return input unchanged
53 + return value, nil
54 + }
55 +
56 + nodes, err := xmlquery.QueryAll(doc, expr)
57 + if err != nil {
58 + return Value{}, fmt.Errorf("invalid xpath: %w", err)
59 + }
60 +
61 + if len(nodes) == 0 {
62 + // No error field found - return input unchanged
63 + return value, nil
64 + }
65 +
66 + // Error field exists - return error with the field's text value
67 + errText := nodes[0].InnerText()
68 + return Value{}, fmt.Errorf("%s", errText)
69 +}
70 +
71 +// errorFieldRegex extracts error message from regex match.
72 +func errorFieldRegex(value Value, paramStr string) (Value, error) {
73 + // paramStr format: "pattern\noutput"
74 + parts := strings.SplitN(paramStr, "\n", 2)
75 + if len(parts) < 1 {
76 + return Value{}, fmt.Errorf("regex pattern required for error field extraction")
77 + }
78 +
79 + pattern := parts[0]
80 + output := ""
81 + if len(parts) > 1 {
82 + output = parts[1]
83 + }
84 +
85 + re, err := compileRegexSafe(pattern, 0)
86 + if err != nil {
87 + return Value{}, fmt.Errorf("invalid regex: %w", err)
88 + }
89 +
90 + matches, err := findStringSubmatchWithTimeout(re, value.Data, defaultRegexTimeout)
91 + if err != nil {
92 + return Value{}, fmt.Errorf("regex error field extraction failed: %w", err)
93 + }
94 + if len(matches) == 0 {
95 + // No match - no error
96 + return value, nil
97 + }
98 +
99 + // Match found - return error
100 + errMsg := output
101 + if errMsg == "" {
102 + errMsg = matches[0]
103 + }
104 +
105 + // Replace capture groups in output
106 + for i, match := range matches {
107 + placeholder := fmt.Sprintf("$%d", i)
108 + errMsg = strings.ReplaceAll(errMsg, placeholder, match)
109 + }
110 +
111 + return Value{}, fmt.Errorf("%s", errMsg)
112 +}
113 +
114 +// throttleValue returns the value only if it changed from the previous value.
115 +// Thread-safe for concurrent use. State isolated per shard and item.
116 +func (p *Preprocessor) throttleValue(itemID string, value Value, paramStr string) (Value, error) {
117 + // State key: shardID:itemID:operation
118 + stateKey := fmt.Sprintf("%s:%s:throttle_value", p.shardID, itemID)
119 +
120 + // Acquire exclusive lock for read-modify-write operation
121 + p.mu.Lock()
122 + defer p.mu.Unlock()
123 +
124 + state, hasState := p.state[stateKey]
125 +
126 + // Always update state before returning
127 + defer func() {
128 + p.state[stateKey] = &OperationState{
129 + LastValue: value.Data,
130 + LastTimestamp: value.Timestamp,
131 + LastAccess: time.Now(),
132 + }
133 + }()
134 +
135 + if !hasState {
136 + // First call
137 + return value, nil
138 + }
139 +
140 + // Update access time
141 + state.LastAccess = time.Now()
142 +
143 + // Check if value changed
144 + if value.Data != state.LastValue {
145 + // Value changed - return it
146 + return value, nil
147 + }
148 +
149 + // Value didn't change - discard it (return empty value without error)
150 + return Value{Data: "", Type: value.Type}, nil
151 +}
152 +
153 +// throttleTimedValue returns the value only if it changed or enough time has passed.
154 +// Thread-safe for concurrent use. State isolated per shard and item.
155 +func (p *Preprocessor) throttleTimedValue(itemID string, value Value, paramStr string) (Value, error) {
156 + // State key: shardID:itemID:operation
157 + stateKey := fmt.Sprintf("%s:%s:throttle_timed", p.shardID, itemID)
158 +
159 + // Parse timeout parameter before acquiring lock
160 + timeoutSeconds := parseDuration(paramStr)
161 +
162 + // Acquire exclusive lock for read-modify-write operation
163 + p.mu.Lock()
164 + defer p.mu.Unlock()
165 +
166 + state, hasState := p.state[stateKey]
167 +
168 + if !hasState {
169 + // First call - record state and return value
170 + p.state[stateKey] = &OperationState{
171 + LastValue: value.Data,
172 + LastValueTime: value.Timestamp,
173 + LastAccess: time.Now(),
174 + }
175 + return value, nil
176 + }
177 +
178 + // Update access time
179 + state.LastAccess = time.Now()
180 +
181 + // Check if value changed
182 + if value.Data != state.LastValue {
183 + // Value changed - return immediately and update state
184 + p.state[stateKey] = &OperationState{
185 + LastValue: value.Data,
186 + LastValueTime: value.Timestamp,
187 + LastAccess: time.Now(),
188 + }
189 + return value, nil
190 + }
191 +
192 + // Value unchanged - check if enough time has passed
193 + timeDiff := value.Timestamp.Sub(state.LastValueTime).Seconds()
194 +
195 + if timeDiff >= timeoutSeconds {
196 + // Enough time passed - return value and update timestamp
197 + p.state[stateKey] = &OperationState{
198 + LastValue: value.Data,
199 + LastValueTime: value.Timestamp,
200 + LastAccess: time.Now(),
201 + }
202 + return value, nil
203 + }
204 +
205 + // Value unchanged and not enough time passed - discard
206 + return Value{Data: "", Type: value.Type}, nil
207 +}
208 +
209 +// parseDuration parses Zabbix duration format (60, 1m, 1h, 1d, 1w) to seconds
210 +func parseDuration(s string) float64 {
211 + s = strings.TrimSpace(s)
212 + if s == "" {
213 + return 60 // Default 1 minute
214 + }
215 +
216 + // Check for suffix
217 + var multiplier float64 = 1
218 + if len(s) > 0 {
219 + lastChar := s[len(s)-1]
220 + switch lastChar {
221 + case 's':
222 + multiplier = 1
223 + s = s[:len(s)-1]
224 + case 'm':
225 + multiplier = 60
226 + s = s[:len(s)-1]
227 + case 'h':
228 + multiplier = 3600
229 + s = s[:len(s)-1]
230 + case 'd':
231 + multiplier = 86400
232 + s = s[:len(s)-1]
233 + case 'w':
234 + multiplier = 604800
235 + s = s[:len(s)-1]
236 + }
237 + }
238 +
239 + // Parse numeric value
240 + var value float64
241 + fmt.Sscanf(s, "%f", &value)
242 + if value == 0 {
243 + return 60 // Default if parsing failed
244 + }
245 +
246 + return value * multiplier
247 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/config.go new
+70
@@ -0,0 +1,70 @@
1 +package zabbixpreproc
2 +
3 +import "time"
4 +
5 +// Limits defines configurable constraints for preprocessing operations.
6 +// Default values match Zabbix 7.x behavior for maximum compatibility.
7 +type Limits struct {
8 + // JavaScript preprocessing limits
9 + JavaScript JSLimits
10 +
11 + // Regular expression preprocessing limits
12 + Regex RegexLimits
13 +}
14 +
15 +// JSLimits configures JavaScript preprocessing constraints.
16 +type JSLimits struct {
17 + // Timeout is the maximum execution time for a single JavaScript step.
18 + // Zabbix default: 10 seconds. Scripts exceeding this are terminated.
19 + Timeout time.Duration
20 +
21 + // MaxCallStackSize limits recursion depth to prevent stack exhaustion.
22 + // Zabbix default: 1000. Matches ZBX_ES_STACK_LIMIT.
23 + MaxCallStackSize int
24 +
25 + // MaxHttpRequests limits HttpRequest objects per script execution.
26 + // Zabbix default: 10. Prevents resource exhaustion.
27 + MaxHttpRequests int
28 +}
29 +
30 +// RegexLimits configures regular expression preprocessing constraints.
31 +type RegexLimits struct {
32 + // MaxCaptureGroups is the maximum number of capture groups (\1-\N).
33 + // Zabbix default: 10 (groups \0-\9). Matches ZBX_REGEXP_GROUPS_MAX.
34 + MaxCaptureGroups int
35 +
36 + // MatchTimeout is the maximum time for a single regex match operation.
37 + // Prevents ReDoS attacks. Recommended: 1s for safety.
38 + MatchTimeout time.Duration
39 +
40 + // ReplaceTimeout is the maximum time for regex replacement operations.
41 + // Zabbix default: 3 seconds. Matches ZBX_REGEX_REPL_TIMEOUT.
42 + ReplaceTimeout time.Duration
43 +
44 + // MaxPatternLength limits the size of regex patterns.
45 + // Zabbix default: unlimited. Set to 0 for no limit.
46 + MaxPatternLength int
47 +
48 + // UsePCREFeatures enables lookahead/lookbehind via regexp2.
49 + // If false, uses stdlib regexp (RE2) which lacks these features.
50 + // Default: true for Zabbix compatibility.
51 + UsePCREFeatures bool
52 +}
53 +
54 +// DefaultLimits returns limits matching Zabbix 7.x defaults.
55 +func DefaultLimits() Limits {
56 + return Limits{
57 + JavaScript: JSLimits{
58 + Timeout: 10 * time.Second,
59 + MaxCallStackSize: 1000,
60 + MaxHttpRequests: 10,
61 + },
62 + Regex: RegexLimits{
63 + MaxCaptureGroups: 10,
64 + MatchTimeout: time.Second,
65 + ReplaceTimeout: 3 * time.Second,
66 + MaxPatternLength: 0, // No limit (matches Zabbix)
67 + UsePCREFeatures: true,
68 + },
69 + }
70 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/csv_step.go new
+727
@@ -0,0 +1,727 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "encoding/json"
5 + "fmt"
6 + "strconv"
7 + "strings"
8 + "unicode/utf8"
9 +)
10 +
11 +// csvToJSON converts CSV data to JSON.
12 +func csvToJSON(value Value, paramStr string) (Value, error) {
13 + // paramStr format: "delimiter\nquote_character\nheader_row"
14 + // header_row: 1 = first row is header, 0 = no header (use column indices)
15 + params := strings.Split(paramStr, "\n")
16 +
17 + // Validate parameter count: must be exactly 3
18 + if len(params) < 2 {
19 + return Value{}, fmt.Errorf("csv to json requires delimiter, quote, and header parameters")
20 + }
21 + if len(params) < 3 {
22 + return Value{}, fmt.Errorf("csv to json requires header row parameter")
23 + }
24 +
25 + // Extract delimiter (UTF-8 multi-byte support)
26 + delim := ","
27 + delimExplicit := false // Track if delimiter was explicitly set
28 + if params[0] != "" {
29 + r, size := utf8.DecodeRuneInString(params[0])
30 + if size != len(params[0]) {
31 + // Multi-character delimiter not allowed
32 + return Value{}, fmt.Errorf("invalid delimiter: must be single character")
33 + }
34 + delim = string(r)
35 + delimExplicit = true
36 + }
37 +
38 + // Extract quote character (UTF-8 multi-byte support)
39 + quote := "\""
40 + if params[1] != "" {
41 + r, size := utf8.DecodeRuneInString(params[1])
42 + if size != len(params[1]) {
43 + // Multi-character quote not allowed
44 + return Value{}, fmt.Errorf("invalid quote character: must be single character")
45 + }
46 + quote = string(r)
47 + }
48 +
49 + // Extract header_row flag
50 + headerRow, err := strconv.Atoi(params[2])
51 + if err != nil {
52 + return Value{}, fmt.Errorf("invalid header row parameter")
53 + }
54 + hasHeader := (headerRow == 1)
55 +
56 + // Check for Sep=/SEP=/sEp= declaration (case-INSENSITIVE)
57 + // Sep= overrides the delimiter param for ALL rows
58 + data := value.Data
59 + lines := strings.Split(data, "\n")
60 + startLine := 0
61 +
62 + if len(lines) > 0 {
63 + firstLine := strings.TrimRight(lines[0], "\r") // Handle \r\n line breaks
64 + // Case-insensitive check for "sep="
65 + if len(firstLine) >= 4 && strings.ToLower(firstLine[:4]) == "sep=" {
66 + if len(firstLine) == 4 {
67 + // "sep=" with no character - invalid, treat as data
68 + } else {
69 + // Check if exactly one character after "sep="
70 + r, size := utf8.DecodeRuneInString(firstLine[4:])
71 + if size == len(firstLine[4:]) {
72 + // Valid single-char sep= line
73 + // Only override delimiter if it wasn't explicitly set in params
74 + if !delimExplicit {
75 + delim = string(r)
76 + }
77 + startLine = 1
78 + // Reconstruct data without sep= line
79 + data = strings.Join(lines[startLine:], "\n")
80 + }
81 + // If multi-char after "sep=", treat as data (don't skip)
82 + }
83 + }
84 + }
85 +
86 + // Check if input ends with newline (for empty row logic)
87 + endsWithNewline := strings.HasSuffix(value.Data, "\n") || strings.HasSuffix(value.Data, "\r\n")
88 +
89 + // Parse CSV with strict validation
90 + records, err := parseCSVStrict(data, delim, quote)
91 + if err != nil {
92 + return Value{}, err
93 + }
94 +
95 + // Empty input returns []
96 + if len(records) == 0 {
97 + return Value{Data: "[]", Type: ValueTypeStr}, nil
98 + }
99 +
100 + var jsonData interface{}
101 +
102 + if hasHeader {
103 + // First row is header
104 + if len(records) < 1 {
105 + // Only header line, no data
106 + return Value{Data: "[]", Type: ValueTypeStr}, nil
107 + }
108 +
109 + header := records[0]
110 +
111 + // Validate no duplicate column names
112 + seen := make(map[string]bool)
113 + for _, col := range header {
114 + if seen[col] {
115 + return Value{}, fmt.Errorf("duplicate column name: %s", col)
116 + }
117 + seen[col] = true
118 + }
119 +
120 + result := make([]map[string]string, 0)
121 +
122 + // Add data rows
123 + for i := 1; i < len(records); i++ {
124 + row := records[i]
125 +
126 + // Validate field count: data row can't have more fields than header
127 + if len(row) > len(header) {
128 + return Value{}, fmt.Errorf("data row has more fields than header")
129 + }
130 +
131 + obj := make(map[string]string)
132 + for j, val := range row {
133 + if j < len(header) {
134 + obj[header[j]] = val
135 + }
136 + }
137 + // Fill missing fields with empty strings
138 + for j := len(row); j < len(header); j++ {
139 + obj[header[j]] = ""
140 + }
141 + result = append(result, obj)
142 + }
143 +
144 + // Add empty trailing row if input ended with newline
145 + if endsWithNewline {
146 + emptyObj := make(map[string]string)
147 + for _, h := range header {
148 + emptyObj[h] = ""
149 + }
150 + result = append(result, emptyObj)
151 + }
152 +
153 + // Marshal with ordered keys to preserve header column order
154 + jsonData, err = marshalOrderedJSON(result, header)
155 + if err != nil {
156 + return Value{}, fmt.Errorf("failed to marshal csv to json: %w", err)
157 + }
158 + } else {
159 + // No header - use 1-based column indices as keys
160 + result := make([]map[string]string, 0)
161 + for _, row := range records {
162 + // Special case: single empty field becomes empty object
163 + if len(row) == 1 && row[0] == "" {
164 + result = append(result, make(map[string]string))
165 + } else {
166 + obj := make(map[string]string)
167 + for i, val := range row {
168 + // Use 1-based indexing
169 + obj[strconv.Itoa(i+1)] = val
170 + }
171 + result = append(result, obj)
172 + }
173 + }
174 +
175 + // Add empty trailing row if input ended with newline
176 + if endsWithNewline && len(result) > 0 {
177 + result = append(result, make(map[string]string))
178 + }
179 +
180 + jsonData = result
181 + }
182 +
183 + var jsonBytes []byte
184 + if str, ok := jsonData.(string); ok {
185 + jsonBytes = []byte(str)
186 + } else {
187 + jsonBytes, err = json.Marshal(jsonData)
188 + if err != nil {
189 + return Value{}, fmt.Errorf("failed to marshal csv to json: %w", err)
190 + }
191 + }
192 +
193 + return Value{Data: string(jsonBytes), Type: ValueTypeStr}, nil
194 +}
195 +
196 +// marshalOrderedJSON marshals array of maps with keys in header order.
197 +func marshalOrderedJSON(data []map[string]string, header []string) (string, error) {
198 + var builder strings.Builder
199 + builder.WriteString("[")
200 +
201 + for i, obj := range data {
202 + if i > 0 {
203 + builder.WriteString(",")
204 + }
205 + // Delegate single object marshaling to avoid code duplication
206 + jsonObj, err := marshalOrderedJSONSingle(obj, header)
207 + if err != nil {
208 + return "", err
209 + }
210 + builder.WriteString(jsonObj)
211 + }
212 +
213 + builder.WriteString("]")
214 + return builder.String(), nil
215 +}
216 +
217 +// parseCSVStrict parses CSV data with strict Zabbix-compatible validation.
218 +//
219 +// This custom parser implements Zabbix's specific CSV parsing rules, which differ
220 +// from encoding/csv in several ways:
221 +// - Supports multi-byte UTF-8 delimiters and quote characters (not just single runes)
222 +// - Special mode when delimiter == quote (disables quoting behavior entirely)
223 +// - Strict validation: rejects \n\r line breaks, unclosed quotes, invalid characters after closing quotes
224 +// - UTF-8 validation: returns error on invalid UTF-8 sequences
225 +//
226 +// Parameters:
227 +// - data: Raw CSV string data to parse
228 +// - delimiter: Field delimiter (can be multi-byte UTF-8 string like "€")
229 +// - quote: Quote character (can be multi-byte UTF-8 string like "§")
230 +//
231 +// Returns:
232 +// - [][]string: Parsed CSV records (array of rows, each row is array of fields)
233 +// - error: Parsing error if data is invalid
234 +//
235 +// This function cannot be replaced with encoding/csv.Reader due to the multi-byte
236 +// delimiter/quote support and special delimiter==quote handling required by Zabbix.
237 +
238 +// csvParseState tracks the state during CSV parsing
239 +type csvParseState struct {
240 + records [][]string
241 + currentRecord []string
242 + currentField strings.Builder
243 + inQuotes bool
244 + fieldStart bool
245 + data string
246 + delimiter string
247 + quote string
248 +}
249 +
250 +// finishField completes the current field and adds it to the current record
251 +func (s *csvParseState) finishField() {
252 + s.currentRecord = append(s.currentRecord, s.currentField.String())
253 + s.currentField.Reset()
254 + s.fieldStart = true
255 +}
256 +
257 +// finishRecord completes the current record and adds it to results
258 +func (s *csvParseState) finishRecord() {
259 + if len(s.currentRecord) > 0 {
260 + s.records = append(s.records, s.currentRecord)
261 + }
262 + s.currentRecord = nil
263 + s.fieldStart = true
264 +}
265 +
266 +// handleQuoteChar processes a quote character at position i
267 +// Returns new position and whether to continue main loop
268 +func (s *csvParseState) handleQuoteChar(i int) (int, bool, error) {
269 + if s.inQuotes {
270 + // Check if it's an escaped quote (doubled)
271 + if strings.HasPrefix(s.data[i+len(s.quote):], s.quote) {
272 + // Escaped quote - add one quote to field
273 + s.currentField.WriteString(s.quote)
274 + s.fieldStart = false
275 + return i + len(s.quote)*2, true, nil
276 + }
277 +
278 + // End of quoted field
279 + s.inQuotes = false
280 + i += len(s.quote)
281 +
282 + // After closing quote, MUST be delimiter or newline
283 + if i < len(s.data) {
284 + nextChar := s.data[i]
285 + if nextChar != '\n' && nextChar != '\r' && !strings.HasPrefix(s.data[i:], s.delimiter) {
286 + return 0, false, fmt.Errorf("character after closing quote")
287 + }
288 + }
289 + s.fieldStart = false
290 + return i, true, nil
291 + }
292 +
293 + if s.fieldStart {
294 + // Start of quoted field (only at field start)
295 + s.inQuotes = true
296 + s.fieldStart = false
297 + return i + len(s.quote), true, nil
298 + }
299 +
300 + // Quote in middle of unquoted field - treat as literal
301 + s.currentField.WriteString(s.quote)
302 + s.fieldStart = false
303 + return i + len(s.quote), true, nil
304 +}
305 +
306 +// handleNewlineChar processes a newline character at position i (only outside quotes)
307 +// Returns new position and whether to continue main loop
308 +func (s *csvParseState) handleNewlineChar(i int) (int, bool, error) {
309 + if s.data[i] == '\n' {
310 + // Check for unsupported \n\r line break
311 + if i+1 < len(s.data) && s.data[i+1] == '\r' {
312 + return 0, false, fmt.Errorf("unsupported line break")
313 + }
314 +
315 + // End of record
316 + s.currentRecord = append(s.currentRecord, s.currentField.String())
317 + s.currentField.Reset()
318 + s.finishRecord()
319 + return i + 1, true, nil
320 + }
321 +
322 + if s.data[i] == '\r' {
323 + // Handle \r\n or just \r
324 + if i+1 < len(s.data) && s.data[i+1] == '\n' {
325 + // \r\n - end of record
326 + s.currentRecord = append(s.currentRecord, s.currentField.String())
327 + s.currentField.Reset()
328 + s.finishRecord()
329 + return i + 2, true, nil
330 + }
331 +
332 + // Just \r - treat as newline
333 + s.currentRecord = append(s.currentRecord, s.currentField.String())
334 + s.currentField.Reset()
335 + s.finishRecord()
336 + return i + 1, true, nil
337 + }
338 +
339 + return i, false, nil
340 +}
341 +
342 +func parseCSVStrict(data string, delimiter string, quote string) ([][]string, error) {
343 + // Special case: when delimiter == quote, disable quoting behavior
344 + if delimiter == quote {
345 + return parseCSVNoQuotes(data, delimiter)
346 + }
347 +
348 + // Initialize parsing state
349 + state := &csvParseState{
350 + data: data,
351 + delimiter: delimiter,
352 + quote: quote,
353 + fieldStart: true,
354 + }
355 +
356 + i := 0
357 + for i < len(data) {
358 + // Check for quote character at current position
359 + if strings.HasPrefix(data[i:], quote) {
360 + newPos, shouldContinue, err := state.handleQuoteChar(i)
361 + if err != nil {
362 + return nil, err
363 + }
364 + if shouldContinue {
365 + i = newPos
366 + continue
367 + }
368 + }
369 +
370 + // Check for delimiter (only outside quotes)
371 + if !state.inQuotes && strings.HasPrefix(data[i:], delimiter) {
372 + state.finishField()
373 + i += len(delimiter)
374 + continue
375 + }
376 +
377 + // Check for newline (only outside quotes)
378 + if !state.inQuotes {
379 + newPos, shouldContinue, err := state.handleNewlineChar(i)
380 + if err != nil {
381 + return nil, err
382 + }
383 + if shouldContinue {
384 + i = newPos
385 + continue
386 + }
387 + }
388 +
389 + // Regular character - add to current field
390 + r, size := utf8.DecodeRuneInString(data[i:])
391 + if r == utf8.RuneError && size == 1 {
392 + return nil, fmt.Errorf("invalid UTF-8 character at position %d", i)
393 + }
394 + state.currentField.WriteRune(r)
395 + i += size
396 + state.fieldStart = false
397 + }
398 +
399 + // Handle last field and record
400 + if state.inQuotes {
401 + return nil, fmt.Errorf("unclosed quoted field")
402 + }
403 +
404 + // Add last field to current record
405 + if state.currentField.Len() > 0 || len(state.currentRecord) > 0 {
406 + state.currentRecord = append(state.currentRecord, state.currentField.String())
407 + }
408 +
409 + // Add last record if not empty
410 + if len(state.currentRecord) > 0 {
411 + state.records = append(state.records, state.currentRecord)
412 + }
413 +
414 + return state.records, nil
415 +}
416 +
417 +// parseCSVNoQuotes parses CSV when delimiter == quote (no quoting behavior).
418 +func parseCSVNoQuotes(data string, delimiter string) ([][]string, error) {
419 + var records [][]string
420 + var currentRecord []string
421 + var currentField strings.Builder
422 +
423 + i := 0
424 +
425 + for i < len(data) {
426 + // Check for delimiter
427 + if strings.HasPrefix(data[i:], delimiter) {
428 + currentRecord = append(currentRecord, currentField.String())
429 + currentField.Reset()
430 + i += len(delimiter)
431 + continue
432 + }
433 +
434 + // Check for newline
435 + if data[i] == '\n' {
436 + // Check for \n\r
437 + if i+1 < len(data) && data[i+1] == '\r' {
438 + return nil, fmt.Errorf("unsupported line break")
439 + }
440 +
441 + // End of record
442 + currentRecord = append(currentRecord, currentField.String())
443 + currentField.Reset()
444 + if len(currentRecord) > 0 {
445 + records = append(records, currentRecord)
446 + }
447 + currentRecord = nil
448 + i++
449 + continue
450 + }
451 + if data[i] == '\r' {
452 + // Handle \r\n or just \r
453 + if i+1 < len(data) && data[i+1] == '\n' {
454 + currentRecord = append(currentRecord, currentField.String())
455 + currentField.Reset()
456 + if len(currentRecord) > 0 {
457 + records = append(records, currentRecord)
458 + }
459 + currentRecord = nil
460 + i += 2
461 + continue
462 + } else {
463 + currentRecord = append(currentRecord, currentField.String())
464 + currentField.Reset()
465 + if len(currentRecord) > 0 {
466 + records = append(records, currentRecord)
467 + }
468 + currentRecord = nil
469 + i++
470 + continue
471 + }
472 + }
473 +
474 + // Regular character
475 + r, size := utf8.DecodeRuneInString(data[i:])
476 + if r == utf8.RuneError && size == 1 {
477 + return nil, fmt.Errorf("invalid UTF-8 character at position %d", i)
478 + }
479 + currentField.WriteRune(r)
480 + i += size
481 + }
482 +
483 + // Handle last field and record
484 + if currentField.Len() > 0 || len(currentRecord) > 0 {
485 + currentRecord = append(currentRecord, currentField.String())
486 + }
487 + if len(currentRecord) > 0 {
488 + records = append(records, currentRecord)
489 + }
490 +
491 + return records, nil
492 +}
493 +
494 +// csvToJSONMulti converts CSV data to multiple Result metrics
495 +// Each CSV row becomes a separate Result.Metric with row index label
496 +func csvToJSONMulti(value Value, paramStr string) (Result, error) {
497 + // paramStr format: "delimiter\nquote_character\nheader_row"
498 + // header_row: 1 = first row is header, 0 = no header (use column indices)
499 + params := strings.Split(paramStr, "\n")
500 +
501 + // Validate parameter count: must be exactly 3
502 + if len(params) < 2 {
503 + err := fmt.Errorf("csv to json requires delimiter, quote, and header parameters")
504 + return Result{Error: err}, err
505 + }
506 + if len(params) < 3 {
507 + err := fmt.Errorf("csv to json requires header row parameter")
508 + return Result{Error: err}, err
509 + }
510 +
511 + // Extract delimiter (UTF-8 multi-byte support)
512 + delim := ","
513 + delimExplicit := false // Track if delimiter was explicitly set
514 + if params[0] != "" {
515 + r, size := utf8.DecodeRuneInString(params[0])
516 + if size != len(params[0]) {
517 + // Multi-character delimiter not allowed
518 + err := fmt.Errorf("invalid delimiter: must be single character")
519 + return Result{Error: err}, err
520 + }
521 + delim = string(r)
522 + delimExplicit = true
523 + }
524 +
525 + // Extract quote character (UTF-8 multi-byte support)
526 + quote := "\""
527 + if params[1] != "" {
528 + r, size := utf8.DecodeRuneInString(params[1])
529 + if size != len(params[1]) {
530 + // Multi-character quote not allowed
531 + err := fmt.Errorf("invalid quote character: must be single character")
532 + return Result{Error: err}, err
533 + }
534 + quote = string(r)
535 + }
536 +
537 + // Extract header_row flag
538 + headerRow, err := strconv.Atoi(params[2])
539 + if err != nil {
540 + err = fmt.Errorf("invalid header row parameter")
541 + return Result{Error: err}, err
542 + }
543 + hasHeader := (headerRow == 1)
544 +
545 + // Check for Sep=/SEP=/sEp= declaration (case-INSENSITIVE)
546 + // Sep= overrides the delimiter param for ALL rows
547 + data := value.Data
548 + lines := strings.Split(data, "\n")
549 + startLine := 0
550 +
551 + if len(lines) > 0 {
552 + firstLine := strings.TrimRight(lines[0], "\r") // Handle \r\n line breaks
553 + // Case-insensitive check for "sep="
554 + if len(firstLine) >= 4 && strings.ToLower(firstLine[:4]) == "sep=" {
555 + if len(firstLine) == 4 {
556 + // "sep=" with no character - invalid, treat as data
557 + } else {
558 + // Check if exactly one character after "sep="
559 + r, size := utf8.DecodeRuneInString(firstLine[4:])
560 + if size == len(firstLine[4:]) {
561 + // Valid single-char sep= line
562 + // Only override delimiter if it wasn't explicitly set in params
563 + if !delimExplicit {
564 + delim = string(r)
565 + }
566 + startLine = 1
567 + // Reconstruct data without sep= line
568 + data = strings.Join(lines[startLine:], "\n")
569 + }
570 + // If multi-char after "sep=", treat as data (don't skip)
571 + }
572 + }
573 + }
574 +
575 + // Check if input ends with newline (for empty row logic)
576 + endsWithNewline := strings.HasSuffix(value.Data, "\n") || strings.HasSuffix(value.Data, "\r\n")
577 +
578 + // Parse CSV with strict validation
579 + records, err := parseCSVStrict(data, delim, quote)
580 + if err != nil {
581 + return Result{Error: err}, err
582 + }
583 +
584 + // Empty input returns empty metrics array
585 + if len(records) == 0 {
586 + return Result{Metrics: []Metric{}}, nil
587 + }
588 +
589 + var metrics []Metric
590 +
591 + if hasHeader {
592 + // First row is header
593 + if len(records) < 1 {
594 + // Only header line, no data
595 + return Result{Metrics: []Metric{}}, nil
596 + }
597 +
598 + header := records[0]
599 +
600 + // Validate no duplicate column names
601 + seen := make(map[string]bool)
602 + for _, col := range header {
603 + if seen[col] {
604 + err := fmt.Errorf("duplicate column name: %s", col)
605 + return Result{Error: err}, err
606 + }
607 + seen[col] = true
608 + }
609 +
610 + // Convert each data row to a metric
611 + for i := 1; i < len(records); i++ {
612 + row := records[i]
613 +
614 + // Validate field count: data row can't have more fields than header
615 + if len(row) > len(header) {
616 + err := fmt.Errorf("data row has more fields than header")
617 + return Result{Error: err}, err
618 + }
619 +
620 + obj := make(map[string]string)
621 + for j, val := range row {
622 + if j < len(header) {
623 + obj[header[j]] = val
624 + }
625 + }
626 + // Fill missing fields with empty strings
627 + for j := len(row); j < len(header); j++ {
628 + obj[header[j]] = ""
629 + }
630 +
631 + // Marshal row to JSON object string
632 + jsonBytes, err := marshalOrderedJSONSingle(obj, header)
633 + if err != nil {
634 + return Result{Error: err}, err
635 + }
636 +
637 + metrics = append(metrics, Metric{
638 + Name: "csv_row",
639 + Value: jsonBytes,
640 + Type: ValueTypeStr,
641 + Labels: map[string]string{"row": strconv.Itoa(i)}, // 1-based row number (excluding header)
642 + })
643 + }
644 +
645 + // Add empty trailing row if input ended with newline
646 + if endsWithNewline {
647 + emptyObj := make(map[string]string)
648 + for _, h := range header {
649 + emptyObj[h] = ""
650 + }
651 + jsonBytes, err := marshalOrderedJSONSingle(emptyObj, header)
652 + if err != nil {
653 + return Result{Error: err}, err
654 + }
655 + metrics = append(metrics, Metric{
656 + Name: "csv_row",
657 + Value: jsonBytes,
658 + Type: ValueTypeStr,
659 + Labels: map[string]string{"row": strconv.Itoa(len(metrics) + 1)},
660 + })
661 + }
662 + } else {
663 + // No header - use 1-based column indices as keys
664 + for i, row := range records {
665 + var obj map[string]string
666 +
667 + // Special case: single empty field becomes empty object
668 + if len(row) == 1 && row[0] == "" {
669 + obj = make(map[string]string)
670 + } else {
671 + obj = make(map[string]string)
672 + for j, val := range row {
673 + // Use 1-based indexing
674 + obj[strconv.Itoa(j+1)] = val
675 + }
676 + }
677 +
678 + // Marshal row to JSON object string
679 + jsonBytes, err := json.Marshal(obj)
680 + if err != nil {
681 + return Result{Error: err}, err
682 + }
683 +
684 + metrics = append(metrics, Metric{
685 + Name: "csv_row",
686 + Value: string(jsonBytes),
687 + Type: ValueTypeStr,
688 + Labels: map[string]string{"row": strconv.Itoa(i + 1)}, // 1-based row number
689 + })
690 + }
691 +
692 + // Add empty trailing row if input ended with newline
693 + if endsWithNewline && len(metrics) > 0 {
694 + emptyJSON, _ := json.Marshal(make(map[string]string))
695 + metrics = append(metrics, Metric{
696 + Name: "csv_row",
697 + Value: string(emptyJSON),
698 + Type: ValueTypeStr,
699 + Labels: map[string]string{"row": strconv.Itoa(len(metrics) + 1)},
700 + })
701 + }
702 + }
703 +
704 + return Result{Metrics: metrics}, nil
705 +}
706 +
707 +// marshalOrderedJSONSingle marshals a single map with keys in header order.
708 +func marshalOrderedJSONSingle(obj map[string]string, header []string) (string, error) {
709 + var builder strings.Builder
710 + builder.WriteString("{")
711 + first := true
712 + for _, key := range header {
713 + val := obj[key]
714 + if !first {
715 + builder.WriteString(",")
716 + }
717 + first = false
718 + // Properly escape JSON key and value
719 + keyJSON, _ := json.Marshal(key)
720 + valJSON, _ := json.Marshal(val)
721 + builder.Write(keyJSON)
722 + builder.WriteString(":")
723 + builder.Write(valJSON)
724 + }
725 + builder.WriteString("}")
726 + return builder.String(), nil
727 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/fuzz_test.go new
+193
@@ -0,0 +1,193 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "strings"
5 + "testing"
6 + "time"
7 +)
8 +
9 +// Fuzzing tests to find edge cases and crashes
10 +// Run with: go test -fuzz=Fuzz
11 +
12 +// FuzzJSONPath fuzzes JSONPath extraction
13 +func FuzzJSONPath(f *testing.F) {
14 + // Seed corpus with known good inputs
15 + f.Add(`{"key": "value"}`, "$.key")
16 + f.Add(`{"a": {"b": 123}}`, "$.a.b")
17 + f.Add(`[1,2,3]`, "$[0]")
18 +
19 + f.Fuzz(func(t *testing.T, jsonData, path string) {
20 + // Skip empty path (required parameter)
21 + if path == "" {
22 + return
23 + }
24 +
25 + value := Value{Data: jsonData, Type: ValueTypeStr}
26 + _, _ = jsonpathExtract(value, path)
27 + // Don't check error - we're looking for crashes, not correctness
28 + })
29 +}
30 +
31 +// FuzzXPath fuzzes XPath extraction
32 +func FuzzXPath(f *testing.F) {
33 + // Seed corpus
34 + f.Add(`<root><item>value</item></root>`, "//item/text()")
35 + f.Add(`<data><a>1</a><b>2</b></data>`, "//a")
36 +
37 + f.Fuzz(func(t *testing.T, xmlData, path string) {
38 + // Skip empty path (required parameter)
39 + if path == "" {
40 + return
41 + }
42 +
43 + value := Value{Data: xmlData, Type: ValueTypeStr}
44 + _, _ = xpathExtract(value, path)
45 + // Don't check error - we're looking for crashes
46 + })
47 +}
48 +
49 +// FuzzRegexSubstitution fuzzes regex pattern replacement
50 +func FuzzRegexSubstitution(f *testing.F) {
51 + // Seed corpus
52 + f.Add("hello world", "world\nuniverse")
53 + f.Add("test123", "[0-9]+\nNUM")
54 +
55 + f.Fuzz(func(t *testing.T, input, params string) {
56 + // Skip invalid params format (need newline separator)
57 + if !strings.Contains(params, "\n") {
58 + return
59 + }
60 +
61 + value := Value{Data: input, Type: ValueTypeStr}
62 + _, _ = regexSubstitute(value, params)
63 + // Don't check error - we're looking for crashes
64 + })
65 +}
66 +
67 +// FuzzMultiplier fuzzes numeric multiplication
68 +func FuzzMultiplier(f *testing.F) {
69 + // Seed corpus
70 + f.Add("100", "2.5")
71 + f.Add("3.14", "10")
72 +
73 + f.Fuzz(func(t *testing.T, input, multiplier string) {
74 + value := Value{Data: input, Type: ValueTypeFloat}
75 + _, _ = multiplyValue(value, multiplier)
76 + // Don't check error - we're looking for crashes
77 + })
78 +}
79 +
80 +// FuzzCSVToJSON fuzzes CSV parsing
81 +func FuzzCSVToJSON(f *testing.F) {
82 + // Seed corpus
83 + f.Add("a,b,c\n1,2,3", ",\n\"\n1")
84 + f.Add("x;y\n10;20", ";\n\"\n1")
85 +
86 + f.Fuzz(func(t *testing.T, csvData, params string) {
87 + // Skip invalid params (need at least 3 newlines)
88 + if strings.Count(params, "\n") < 2 {
89 + return
90 + }
91 +
92 + value := Value{Data: csvData, Type: ValueTypeStr}
93 + _, _ = csvToJSON(value, params)
94 + // Don't check error - we're looking for crashes
95 + })
96 +}
97 +
98 +// FuzzPrometheusPattern fuzzes Prometheus metric extraction
99 +func FuzzPrometheusPattern(f *testing.F) {
100 + // Seed corpus
101 + f.Add(`http_requests{method="GET"} 100`, `http_requests{method="GET"}`)
102 + f.Add(`cpu_usage 45.2`, `cpu_usage`)
103 +
104 + f.Fuzz(func(t *testing.T, promData, pattern string) {
105 + // Skip empty pattern
106 + if pattern == "" {
107 + return
108 + }
109 +
110 + value := Value{Data: promData, Type: ValueTypeStr}
111 + _, _ = prometheusPattern(value, pattern)
112 + // Don't check error - we're looking for crashes
113 + })
114 +}
115 +
116 +// FuzzSNMPWalkToJSON fuzzes SNMP walk parsing
117 +func FuzzSNMPWalkToJSON(f *testing.F) {
118 + // Seed corpus
119 + f.Add(`.1.3.6.1.2.1.1.1.0 = STRING: "Linux"`, "{#NAME}\n.1.3.6.1.2.1.1\n1")
120 + f.Add(`.1.3.6.1.4.1.2021.10.1.3.1 = INTEGER: 50`, "{#CPU}\n.1.3.6.1.4.1.2021.10.1.3\n1")
121 +
122 + f.Fuzz(func(t *testing.T, snmpData, params string) {
123 + // Skip invalid params (need multiple of 3 newlines)
124 + if strings.Count(params, "\n")%3 != 2 {
125 + return
126 + }
127 +
128 + value := Value{Data: snmpData, Type: ValueTypeStr}
129 + logger := NoopLogger{}
130 + _, _ = snmpWalkToJSON(value, params, logger)
131 + // Don't check error - we're looking for crashes
132 + })
133 +}
134 +
135 +// FuzzTrim fuzzes string trimming
136 +func FuzzTrim(f *testing.F) {
137 + // Seed corpus
138 + f.Add(" hello ", " ")
139 + f.Add("\t\ntest\r\n", "\t\n\r")
140 +
141 + f.Fuzz(func(t *testing.T, input, chars string) {
142 + value := Value{Data: input, Type: ValueTypeStr}
143 + _, _ = trimValue(value, chars, "both")
144 + _, _ = trimValue(value, chars, "left")
145 + _, _ = trimValue(value, chars, "right")
146 + // Don't check error - we're looking for crashes
147 + })
148 +}
149 +
150 +// FuzzDelta fuzzes delta value calculation
151 +func FuzzDelta(f *testing.F) {
152 + // Seed corpus
153 + f.Add("100", "")
154 + f.Add("50", "")
155 +
156 + p := NewPreprocessor("fuzz-shard")
157 +
158 + f.Fuzz(func(t *testing.T, input, params string) {
159 + value := Value{
160 + Data: input,
161 + Type: ValueTypeUint64,
162 + Timestamp: time.Now(),
163 + }
164 + _, _ = p.deltaValue("fuzz-item", value, params)
165 + // Don't check error - we're looking for crashes
166 + })
167 +}
168 +
169 +// FuzzValidateRange fuzzes range validation
170 +func FuzzValidateRange(f *testing.F) {
171 + // Seed corpus
172 + f.Add("50", "0\n100")
173 + f.Add("3.14", "-10\n10")
174 +
175 + f.Fuzz(func(t *testing.T, input, params string) {
176 + value := Value{Data: input, Type: ValueTypeFloat}
177 + _, _ = validateRange(value, params)
178 + // Don't check error - we're looking for crashes
179 + })
180 +}
181 +
182 +// FuzzInterpretEscapeSequences fuzzes escape sequence interpretation
183 +func FuzzInterpretEscapeSequences(f *testing.F) {
184 + // Seed corpus
185 + f.Add("hello\\nworld")
186 + f.Add("tab\\there")
187 + f.Add("back\\\\slash")
188 +
189 + f.Fuzz(func(t *testing.T, input string) {
190 + _ = interpretEscapeSequences(input)
191 + // Don't check result - we're looking for crashes
192 + })
193 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/helpers_test.go new
+321
@@ -0,0 +1,321 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "testing"
5 +)
6 +
7 +// Unit tests for helper functions (separate from integration tests)
8 +
9 +func TestFormatFloat(t *testing.T) {
10 + tests := []struct {
11 + name string
12 + input float64
13 + want string
14 + }{
15 + {"whole number", 42.0, "42"},
16 + {"simple decimal", 3.14, "3.14"},
17 + {"small decimal", 0.001, "0.001"},
18 + {"large number", 1234567.89, "1.23456789e+06"},
19 + {"negative whole", -100.0, "-100"},
20 + {"negative decimal", -0.5, "-0.5"},
21 + {"zero", 0.0, "0"},
22 + {"very small", 0.0000001, "1e-07"},
23 + }
24 +
25 + for _, tt := range tests {
26 + t.Run(tt.name, func(t *testing.T) {
27 + got := formatFloat(tt.input)
28 + if got != tt.want {
29 + t.Errorf("formatFloat(%v) = %q, want %q", tt.input, got, tt.want)
30 + }
31 + })
32 + }
33 +}
34 +
35 +func TestInterpretEscapeSequences(t *testing.T) {
36 + tests := []struct {
37 + name string
38 + input string
39 + want string
40 + }{
41 + {"no escapes", "hello", "hello"},
42 + {"newline", "line1\\nline2", "line1\nline2"},
43 + {"tab", "col1\\tcol2", "col1\tcol2"},
44 + {"carriage return", "text\\rmore", "text\rmore"},
45 + {"backslash", "path\\\\file", "path\\file"},
46 + {"space", "a\\sb", "a b"},
47 + {"multiple escapes", "a\\nb\\tc", "a\nb\tc"},
48 + {"double backslash s", "trim\\\\sthis", "trim \t\r\n\\this"}, // \\s expands to full whitespace class
49 + {"unknown escape", "\\x41BC", "\\x41BC"}, // Unknown escapes preserved
50 + }
51 +
52 + for _, tt := range tests {
53 + t.Run(tt.name, func(t *testing.T) {
54 + got := interpretEscapeSequences(tt.input)
55 + if got != tt.want {
56 + t.Errorf("interpretEscapeSequences(%q) = %q, want %q", tt.input, got, tt.want)
57 + }
58 + })
59 + }
60 +}
61 +
62 +func TestConvertBackreferences(t *testing.T) {
63 + tests := []struct {
64 + name string
65 + replacement string
66 + want string
67 + }{
68 + {"no backrefs", "literal", "literal"},
69 + {"\\1 to ${1}", "prefix\\1suffix", "prefix${1}suffix"},
70 + {"\\2 to ${2}", "value\\2", "value${2}"},
71 + {"multiple backrefs", "\\1 and \\2", "${1} and ${2}"},
72 + {"\\\\1 literal backslash", "\\\\1", "\\1"},
73 + {"mixed", "a\\1b\\\\2c\\3", "a${1}b\\2c${3}"},
74 + {"\\0 full match", "result\\0", "result${0}"},
75 + {"multi-digit", "\\10", "${10}"},
76 + }
77 +
78 + for _, tt := range tests {
79 + t.Run(tt.name, func(t *testing.T) {
80 + got := convertBackreferences(tt.replacement)
81 + if got != tt.want {
82 + t.Errorf("convertBackreferences(%q) = %q, want %q", tt.replacement, got, tt.want)
83 + }
84 + })
85 + }
86 +}
87 +
88 +func TestParseHexBytes(t *testing.T) {
89 + tests := []struct {
90 + name string
91 + input string
92 + want []byte
93 + wantErr bool
94 + }{
95 + {"simple hex", "48656C6C6F", []byte("Hello"), false},
96 + {"with spaces", "48 65 6C 6C 6F", []byte("Hello"), false},
97 + {"lowercase", "68656c6c6f", []byte("hello"), false},
98 + {"mixed case", "48656C6c6F", []byte("Hello"), false},
99 + {"empty", "", []byte{}, false},
100 + {"invalid hex", "ZZZZ", nil, true},
101 + {"odd length", "48656C6C6", nil, true},
102 + {"null bytes", "00410042", []byte("\x00A\x00B"), false},
103 + }
104 +
105 + for _, tt := range tests {
106 + t.Run(tt.name, func(t *testing.T) {
107 + got, err := parseHexBytes(tt.input)
108 + if tt.wantErr {
109 + if err == nil {
110 + t.Errorf("parseHexBytes(%q) expected error, got nil", tt.input)
111 + }
112 + } else {
113 + if err != nil {
114 + t.Errorf("parseHexBytes(%q) unexpected error: %v", tt.input, err)
115 + }
116 + if string(got) != string(tt.want) {
117 + t.Errorf("parseHexBytes(%q) = %q, want %q", tt.input, got, tt.want)
118 + }
119 + }
120 + })
121 + }
122 +}
123 +
124 +func TestConvertBITSToInteger(t *testing.T) {
125 + tests := []struct {
126 + name string
127 + input string
128 + want string
129 + wantErr bool
130 + }{
131 + {"single byte", "05", "5", false},
132 + {"two bytes hex", "01 02", "513", false}, // Little-endian: reversed to 02 01 = 0x0201 = 513
133 + {"four bytes hex", "01 02 03 04", "67305985", false}, // Little-endian: reversed to 04 03 02 01 = 0x04030201 = 67305985
134 + {"all zeros", "00 00", "0", false},
135 + {"all ones", "FF FF", "65535", false},
136 + {"invalid hex", "ZZ", "", true},
137 + }
138 +
139 + for _, tt := range tests {
140 + t.Run(tt.name, func(t *testing.T) {
141 + got, err := convertBITSToInteger(tt.input)
142 + if tt.wantErr {
143 + if err == nil {
144 + t.Errorf("convertBITSToInteger(%q) expected error, got nil", tt.input)
145 + }
146 + } else {
147 + if err != nil {
148 + t.Errorf("convertBITSToInteger(%q) unexpected error: %v", tt.input, err)
149 + }
150 + if got != tt.want {
151 + t.Errorf("convertBITSToInteger(%q) = %q, want %q", tt.input, got, tt.want)
152 + }
153 + }
154 + })
155 + }
156 +}
157 +
158 +func TestFormatSTRING(t *testing.T) {
159 + tests := []struct {
160 + name string
161 + input string
162 + formatMode int
163 + want string
164 + wantErr bool
165 + }{
166 + {"plain text - value only", "hello", 1, "hello", false},
167 + {"with quotes - value only", `"quoted"`, 1, `quoted`, false}, // formatSTRING strips outer quotes
168 + {"empty - value only", "", 1, "", false},
169 + {"plain text - oid and value", "hello", 0, "hello", false},
170 + {"with quotes - oid and value", `"quoted"`, 0, `quoted`, false}, // formatSTRING strips outer quotes
171 + }
172 +
173 + for _, tt := range tests {
174 + t.Run(tt.name, func(t *testing.T) {
175 + got, err := formatSTRING(tt.input, tt.formatMode)
176 + if tt.wantErr {
177 + if err == nil {
178 + t.Errorf("formatSTRING(%q, %d) expected error, got nil", tt.input, tt.formatMode)
179 + }
180 + } else {
181 + if err != nil {
182 + t.Errorf("formatSTRING(%q, %d) unexpected error: %v", tt.input, tt.formatMode, err)
183 + }
184 + if got != tt.want {
185 + t.Errorf("formatSTRING(%q, %d) = %q, want %q", tt.input, tt.formatMode, got, tt.want)
186 + }
187 + }
188 + })
189 + }
190 +}
191 +
192 +func TestValueTypeToString(t *testing.T) {
193 + tests := []struct {
194 + name string
195 + input ValueType
196 + want string
197 + }{
198 + {"string type", ValueTypeStr, "ITEM_VALUE_TYPE_STR"},
199 + {"uint64 type", ValueTypeUint64, "ITEM_VALUE_TYPE_UINT64"},
200 + {"float type", ValueTypeFloat, "ITEM_VALUE_TYPE_FLOAT"},
201 + {"invalid type", ValueType(99), "UNKNOWN"},
202 + }
203 +
204 + for _, tt := range tests {
205 + t.Run(tt.name, func(t *testing.T) {
206 + got := valueTypeToString(tt.input)
207 + if got != tt.want {
208 + t.Errorf("valueTypeToString(%v) = %q, want %q", tt.input, got, tt.want)
209 + }
210 + })
211 + }
212 +}
213 +
214 +func TestParseValueType(t *testing.T) {
215 + tests := []struct {
216 + name string
217 + input string
218 + want ValueType
219 + wantErr bool
220 + }{
221 + {"string type", "ITEM_VALUE_TYPE_STR", ValueTypeStr, false},
222 + {"uint64 type", "ITEM_VALUE_TYPE_UINT64", ValueTypeUint64, false},
223 + {"float type", "ITEM_VALUE_TYPE_FLOAT", ValueTypeFloat, false},
224 + {"invalid type", "INVALID", ValueTypeStr, true},
225 + {"empty", "", ValueTypeStr, true},
226 + }
227 +
228 + for _, tt := range tests {
229 + t.Run(tt.name, func(t *testing.T) {
230 + got, err := parseValueType(tt.input)
231 + if tt.wantErr {
232 + if err == nil {
233 + t.Errorf("parseValueType(%q) expected error, got nil", tt.input)
234 + }
235 + } else {
236 + if err != nil {
237 + t.Errorf("parseValueType(%q) unexpected error: %v", tt.input, err)
238 + }
239 + if got != tt.want {
240 + t.Errorf("parseValueType(%q) = %v, want %v", tt.input, got, tt.want)
241 + }
242 + }
243 + })
244 + }
245 +}
246 +
247 +func TestNoopLogger(t *testing.T) {
248 + // Ensure NoopLogger doesn't panic and accepts all methods
249 + logger := NoopLogger{}
250 + logger.Debug("test", "key", "value")
251 + logger.Info("test", "key", "value")
252 + logger.Warn("test", "key", "value")
253 + logger.Error("test", "key", "value")
254 +
255 + // If we get here without panic, test passes
256 +}
257 +
258 +func TestErrorHandlerValidation(t *testing.T) {
259 + p := NewPreprocessor("test")
260 +
261 + tests := []struct {
262 + name string
263 + handler ErrorHandler
264 + origErr error
265 + wantErr bool
266 + wantVal string
267 + }{
268 + {
269 + name: "default - returns original error",
270 + handler: ErrorHandler{Action: ErrorActionDefault},
271 + origErr: errTestError,
272 + wantErr: true,
273 + },
274 + {
275 + name: "discard - returns empty value",
276 + handler: ErrorHandler{Action: ErrorActionDiscard},
277 + origErr: errTestError,
278 + wantErr: false,
279 + wantVal: "",
280 + },
281 + {
282 + name: "set value",
283 + handler: ErrorHandler{Action: ErrorActionSetValue, Params: "fallback"},
284 + origErr: errTestError,
285 + wantErr: false,
286 + wantVal: "fallback",
287 + },
288 + {
289 + name: "set error - custom message",
290 + handler: ErrorHandler{Action: ErrorActionSetError, Params: "Custom error"},
291 + origErr: errTestError,
292 + wantErr: true,
293 + },
294 + }
295 +
296 + for _, tt := range tests {
297 + t.Run(tt.name, func(t *testing.T) {
298 + val, err := p.handleError(tt.origErr, tt.handler)
299 + if tt.wantErr && err == nil {
300 + t.Error("expected error, got nil")
301 + }
302 + if !tt.wantErr && err != nil {
303 + t.Errorf("unexpected error: %v", err)
304 + }
305 + if !tt.wantErr && val.Data != tt.wantVal {
306 + t.Errorf("got value %q, want %q", val.Data, tt.wantVal)
307 + }
308 + })
309 + }
310 +}
311 +
312 +// Helper error for tests
313 +var errTestError = &testError{msg: "test error"}
314 +
315 +type testError struct {
316 + msg string
317 +}
318 +
319 +func (e *testError) Error() string {
320 + return e.msg
321 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/javascript_sandbox_test.go new
+289
@@ -0,0 +1,289 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "strings"
5 + "testing"
6 + "time"
7 +)
8 +
9 +// TestJavaScriptExecutionTimeout tests that JavaScript execution times out after 5 seconds
10 +func TestJavaScriptExecutionTimeout(t *testing.T) {
11 + value := Value{Data: "test", Type: ValueTypeStr}
12 +
13 + // Infinite loop should timeout
14 + script := `
15 + while(true) {
16 + // Infinite loop
17 + }
18 + return value;
19 + `
20 +
21 + start := time.Now()
22 + _, err := javascriptExecute(value, script, DefaultLimits().JavaScript)
23 + duration := time.Since(start)
24 +
25 + if err == nil {
26 + t.Fatal("Expected timeout error, got nil")
27 + }
28 +
29 + if !strings.Contains(err.Error(), "timeout") && !strings.Contains(err.Error(), "Interrupt") {
30 + t.Errorf("Expected timeout error, got: %v", err)
31 + }
32 +
33 + // Should timeout around 10 seconds (Zabbix default), allow some overhead
34 + if duration < 9*time.Second || duration > 12*time.Second {
35 + t.Errorf("Expected timeout around 10s, got %v", duration)
36 + }
37 +}
38 +
39 +// TestJavaScriptCallStackLimit tests that deep recursion is limited
40 +func TestJavaScriptCallStackLimit(t *testing.T) {
41 + value := Value{Data: "test", Type: ValueTypeStr}
42 +
43 + // Deep recursion should hit stack limit
44 + script := `
45 + function recurse(n) {
46 + if (n > 0) {
47 + return recurse(n - 1);
48 + }
49 + return n;
50 + }
51 + return recurse(10000);
52 + `
53 +
54 + _, err := javascriptExecute(value, script, DefaultLimits().JavaScript)
55 +
56 + if err == nil {
57 + t.Fatal("Expected stack overflow error, got nil")
58 + }
59 +
60 + // Goja may report stack issues in various ways - just verify it errors on deep recursion
61 + if err == nil {
62 + t.Fatalf("Expected deep recursion to fail, got nil error")
63 + }
64 + // The error should mention the recursive function or contain depth info
65 + errStr := err.Error()
66 + if !strings.Contains(errStr, "recurse") && !strings.Contains(errStr, "stack") && !strings.Contains(errStr, "RangeError") {
67 + t.Logf("Deep recursion error: %v", err)
68 + }
69 +}
70 +
71 +// TestJavaScriptRSAKeyValidation tests that RSA key validation is in place
72 +// Note: Minimum key size is 1024 bits to match Zabbix behavior (though 2048+ is recommended)
73 +func TestJavaScriptRSAKeyValidation(t *testing.T) {
74 + // The official Zabbix tests verify that 1024-bit keys work correctly.
75 + // Keys smaller than 1024 bits would be rejected by the validation in javascriptExecute.
76 + // This test verifies the validation code exists and works with valid keys.
77 +
78 + // This is already tested by the official Zabbix test suite with 1024-bit keys,
79 + // so we just verify that the validation constant is set correctly.
80 + if jsMinRSAKeyBits != 1024 {
81 + t.Errorf("Expected minimum RSA key size to be 1024 bits (Zabbix compatibility), got %d", jsMinRSAKeyBits)
82 + }
83 +
84 + t.Log("RSA key validation is in place with minimum", jsMinRSAKeyBits, "bits")
85 +}
86 +
87 +// TestJavaScriptNormalExecution tests that valid JavaScript still executes correctly
88 +func TestJavaScriptNormalExecution(t *testing.T) {
89 + value := Value{Data: "hello", Type: ValueTypeStr}
90 +
91 + tests := []struct {
92 + name string
93 + script string
94 + expected string
95 + }{
96 + {
97 + name: "simple return",
98 + script: `return value;`,
99 + expected: "hello",
100 + },
101 + {
102 + name: "string concatenation",
103 + script: `return value + " world";`,
104 + expected: "hello world",
105 + },
106 + {
107 + name: "btoa encoding",
108 + script: `return btoa(value);`,
109 + expected: "aGVsbG8=",
110 + },
111 + {
112 + name: "atob decoding",
113 + script: `return atob("aGVsbG8=");`,
114 + expected: "hello",
115 + },
116 + {
117 + name: "hmac",
118 + script: `return hmac("sha256", "secret", value);`,
119 + expected: "88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b",
120 + },
121 + }
122 +
123 + for _, tt := range tests {
124 + t.Run(tt.name, func(t *testing.T) {
125 + result, err := javascriptExecute(value, tt.script, DefaultLimits().JavaScript)
126 + if err != nil {
127 + t.Fatalf("Unexpected error: %v", err)
128 + }
129 +
130 + if result.Data != tt.expected {
131 + t.Errorf("Expected '%s', got '%s'", tt.expected, result.Data)
132 + }
133 + })
134 + }
135 +}
136 +
137 +// TestJavaScriptMemoryIntensiveOperation tests that memory-intensive operations complete within limits
138 +func TestJavaScriptMemoryIntensiveOperation(t *testing.T) {
139 + value := Value{Data: "test", Type: ValueTypeStr}
140 +
141 + // Create a large array (but not infinite)
142 + script := `
143 + var arr = [];
144 + for (var i = 0; i < 10000; i++) {
145 + arr.push(i);
146 + }
147 + return arr.length.toString();
148 + `
149 +
150 + result, err := javascriptExecute(value, script, DefaultLimits().JavaScript)
151 + if err != nil {
152 + t.Fatalf("Unexpected error: %v", err)
153 + }
154 +
155 + if result.Data != "10000" {
156 + t.Errorf("Expected '10000', got '%s'", result.Data)
157 + }
158 +}
159 +
160 +// TestJavaScriptCPUIntensiveOperation tests that CPU-intensive operations complete within timeout
161 +func TestJavaScriptCPUIntensiveOperation(t *testing.T) {
162 + value := Value{Data: "test", Type: ValueTypeStr}
163 +
164 + // Fibonacci calculation (should complete quickly)
165 + script := `
166 + function fib(n) {
167 + if (n <= 1) return n;
168 + return fib(n - 1) + fib(n - 2);
169 + }
170 + return fib(20).toString();
171 + `
172 +
173 + result, err := javascriptExecute(value, script, DefaultLimits().JavaScript)
174 + if err != nil {
175 + t.Fatalf("Unexpected error: %v", err)
176 + }
177 +
178 + if result.Data != "6765" {
179 + t.Errorf("Expected '6765', got '%s'", result.Data)
180 + }
181 +}
182 +
183 +// TestJavaScriptPanicRecovery tests that panics are caught and converted to errors
184 +func TestJavaScriptPanicRecovery(t *testing.T) {
185 + value := Value{Data: "test", Type: ValueTypeStr}
186 +
187 + // Trigger a JavaScript error
188 + script := `
189 + throw new Error("test error");
190 + return value;
191 + `
192 +
193 + _, err := javascriptExecute(value, script, DefaultLimits().JavaScript)
194 +
195 + if err == nil {
196 + t.Fatal("Expected error from thrown exception, got nil")
197 + }
198 +
199 + if !strings.Contains(err.Error(), "test error") && !strings.Contains(err.Error(), "Error") {
200 + t.Errorf("Expected error message, got: %v", err)
201 + }
202 +}
203 +
204 +// TestJavaScriptHMACWithInvalidAlgorithm tests HMAC error handling
205 +func TestJavaScriptHMACWithInvalidAlgorithm(t *testing.T) {
206 + value := Value{Data: "test", Type: ValueTypeStr}
207 +
208 + script := `
209 + return hmac("invalid", "key", value);
210 + `
211 +
212 + _, err := javascriptExecute(value, script, DefaultLimits().JavaScript)
213 +
214 + if err == nil {
215 + t.Fatal("Expected error for invalid HMAC algorithm, got nil")
216 + }
217 +
218 + if !strings.Contains(err.Error(), "unsupported") && !strings.Contains(err.Error(), "algorithm") {
219 + t.Errorf("Expected algorithm error, got: %v", err)
220 + }
221 +}
222 +
223 +// TestJavaScriptSandboxingDoesNotAffectValidCode tests that sandboxing doesn't break valid operations
224 +func TestJavaScriptSandboxingDoesNotAffectValidCode(t *testing.T) {
225 + value := Value{Data: "100", Type: ValueTypeStr}
226 +
227 + // Test various operations that should work fine
228 + tests := []struct {
229 + name string
230 + script string
231 + }{
232 + {
233 + name: "arithmetic",
234 + script: `return (parseInt(value) * 2).toString();`,
235 + },
236 + {
237 + name: "string operations",
238 + script: `return value.toUpperCase();`,
239 + },
240 + {
241 + name: "array operations",
242 + script: `return [1, 2, 3].map(x => x * 2).join(",");`,
243 + },
244 + {
245 + name: "object operations",
246 + script: `var obj = {a: 1}; return obj.a.toString();`,
247 + },
248 + {
249 + name: "json operations",
250 + script: `return JSON.stringify({value: value});`,
251 + },
252 + }
253 +
254 + for _, tt := range tests {
255 + t.Run(tt.name, func(t *testing.T) {
256 + _, err := javascriptExecute(value, tt.script, DefaultLimits().JavaScript)
257 + if err != nil {
258 + t.Errorf("Valid code failed after sandboxing: %v", err)
259 + }
260 + })
261 + }
262 +}
263 +
264 +// TestJavaScriptTimeoutPreventsHang tests that timeout prevents indefinite hangs
265 +func TestJavaScriptTimeoutPreventsHang(t *testing.T) {
266 + value := Value{Data: "test", Type: ValueTypeStr}
267 +
268 + // Slow operation that would hang without timeout
269 + script := `
270 + var sum = 0;
271 + for (var i = 0; i < 999999999999; i++) {
272 + sum += i;
273 + }
274 + return sum.toString();
275 + `
276 +
277 + start := time.Now()
278 + _, err := javascriptExecute(value, script, DefaultLimits().JavaScript)
279 + duration := time.Since(start)
280 +
281 + if err == nil {
282 + t.Fatal("Expected timeout error, got nil")
283 + }
284 +
285 + // Should timeout around 10s (Zabbix default), not run for minutes
286 + if duration > 12*time.Second {
287 + t.Errorf("Execution took too long (%v), timeout not working", duration)
288 + }
289 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/javascript_step.go new
+467
@@ -0,0 +1,467 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "crypto"
5 + "crypto/hmac"
6 + "crypto/md5"
7 + "crypto/rand"
8 + "crypto/rsa"
9 + "crypto/sha256"
10 + "crypto/x509"
11 + "encoding/base64"
12 + "encoding/hex"
13 + "encoding/pem"
14 + "fmt"
15 + "hash"
16 + "strings"
17 + "sync"
18 + "time"
19 +
20 + "github.com/dop251/goja"
21 +)
22 +
23 +// JavaScript execution safety constants
24 +const (
25 + jsExecutionTimeout = 5 * time.Second // Maximum execution time for JavaScript
26 + jsMaxCallStackSize = 100 // Maximum call stack depth
27 + jsMinRSAKeyBits = 1024 // Minimum RSA key size (bits) - matches Zabbix behavior
28 + jsProgramCacheSize = 1000 // Maximum number of cached compiled programs
29 + // NOTE: 1024-bit RSA keys are deprecated and insecure (NIST recommends 2048+ bits).
30 + // This limit matches Zabbix's behavior for compatibility. Production deployments
31 + // should enforce 2048+ bit keys at the configuration/policy level.
32 +)
33 +
34 +// PEM key normalization constants
35 +const (
36 + pemBeginMarker = "-----BEGIN"
37 + pemEndMarker = "-----END "
38 + pemDelimiter = "-----"
39 + pemBeginMarkerLen = 10 // len("-----BEGIN")
40 + pemEndMarkerLen = 9 // len("-----END ")
41 + pemDelimiterLen = 5 // len("-----")
42 + pemLineWidth = 64 // Standard PEM line width (RFC 7468)
43 + pemMinHeaderLen = 30 // Minimum length for PEM header check
44 + pemNewlineCheckPos = 27 // Position to check for newline after BEGIN marker
45 +)
46 +
47 +// boundedProgramCache implements a size-limited LRU cache for compiled JavaScript programs.
48 +type boundedProgramCache struct {
49 + mu sync.RWMutex
50 + cache map[string]*goja.Program
51 + order []string // LRU tracking (oldest first)
52 +}
53 +
54 +// newBoundedProgramCache creates a bounded program cache.
55 +func newBoundedProgramCache(maxSize int) *boundedProgramCache {
56 + return &boundedProgramCache{
57 + cache: make(map[string]*goja.Program),
58 + order: make([]string, 0, maxSize),
59 + }
60 +}
61 +
62 +// Get retrieves a compiled program from the cache.
63 +func (c *boundedProgramCache) Get(key string) (*goja.Program, bool) {
64 + c.mu.RLock()
65 + prog, found := c.cache[key]
66 + c.mu.RUnlock()
67 +
68 + if found {
69 + // Move to end of LRU order (most recently used)
70 + c.mu.Lock()
71 + for i, k := range c.order {
72 + if k == key {
73 + c.order = append(c.order[:i], c.order[i+1:]...)
74 + c.order = append(c.order, key)
75 + break
76 + }
77 + }
78 + c.mu.Unlock()
79 + }
80 +
81 + return prog, found
82 +}
83 +
84 +// Put stores a compiled program in the cache, evicting oldest if at capacity.
85 +func (c *boundedProgramCache) Put(key string, prog *goja.Program) {
86 + c.mu.Lock()
87 + defer c.mu.Unlock()
88 +
89 + // Check if already cached
90 + if _, exists := c.cache[key]; exists {
91 + // Update LRU order
92 + for i, k := range c.order {
93 + if k == key {
94 + c.order = append(c.order[:i], c.order[i+1:]...)
95 + c.order = append(c.order, key)
96 + break
97 + }
98 + }
99 + return
100 + }
101 +
102 + // Evict oldest if at capacity
103 + if len(c.cache) >= jsProgramCacheSize {
104 + oldest := c.order[0]
105 + delete(c.cache, oldest)
106 + c.order = c.order[1:]
107 + }
108 +
109 + // Add new entry
110 + c.cache[key] = prog
111 + c.order = append(c.order, key)
112 +}
113 +
114 +// JavaScript program caching for performance optimization
115 +var (
116 + // programCache caches compiled JavaScript programs (keyed by script code)
117 + // Bounded to jsProgramCacheSize to prevent unbounded memory growth
118 + programCache = newBoundedProgramCache(jsProgramCacheSize)
119 +)
120 +
121 +// createConfiguredVM creates a new goja VM with all built-in functions configured.
122 +// This function is called by vmPool when creating new VM instances.
123 +func createConfiguredVM() *goja.Runtime {
124 + vm := goja.New()
125 + vm.SetMaxCallStackSize(jsMaxCallStackSize)
126 +
127 + // Configure all built-in functions
128 + setupBuiltinFunctions(vm)
129 +
130 + return vm
131 +}
132 +
133 +// setupBuiltinFunctions configures all Zabbix built-in JavaScript functions on a VM.
134 +// This is called once per VM instance when it's created.
135 +func setupBuiltinFunctions(vm *goja.Runtime) {
136 + // btoa - Base64 encode (supports string and Uint8Array)
137 + vm.Set("btoa", func(call goja.FunctionCall) goja.Value {
138 + if len(call.Arguments) == 0 {
139 + return goja.Null()
140 + }
141 +
142 + var data []byte
143 + arg := call.Arguments[0]
144 +
145 + // Check if it's a Uint8Array
146 + if obj := arg.ToObject(vm); obj != nil {
147 + if arr := obj.Get("constructor"); arr != nil && arr.String() == "function Uint8Array() { [native code] }" {
148 + // It's a typed array, extract bytes
149 + if length := obj.Get("length"); length != nil {
150 + n := int(length.ToInteger())
151 + data = make([]byte, n)
152 + for i := 0; i < n; i++ {
153 + data[i] = byte(obj.Get(fmt.Sprintf("%d", i)).ToInteger())
154 + }
155 + }
156 + }
157 + }
158 +
159 + // If not Uint8Array, treat as string
160 + if data == nil {
161 + data = []byte(arg.String())
162 + }
163 +
164 + encoded := base64.StdEncoding.EncodeToString(data)
165 + return vm.ToValue(encoded)
166 + })
167 +
168 + // atob - Base64 decode
169 + vm.Set("atob", func(call goja.FunctionCall) goja.Value {
170 + if len(call.Arguments) == 0 {
171 + return goja.Null()
172 + }
173 +
174 + encoded := call.Arguments[0].String()
175 + decoded, err := base64.StdEncoding.DecodeString(encoded)
176 + if err != nil {
177 + // Invalid base64 - return empty string
178 + return vm.ToValue("")
179 + }
180 +
181 + return vm.ToValue(string(decoded))
182 + })
183 +
184 + // hmac - HMAC hash function
185 + vm.Set("hmac", func(call goja.FunctionCall) goja.Value {
186 + if len(call.Arguments) < 3 {
187 + panic(vm.NewGoError(fmt.Errorf("hmac requires 3 arguments: algorithm, key, data")))
188 + }
189 +
190 + // Check for null/undefined arguments
191 + if goja.IsNull(call.Arguments[1]) || goja.IsUndefined(call.Arguments[1]) {
192 + panic(vm.NewGoError(fmt.Errorf("invalid key parameter")))
193 + }
194 + if goja.IsNull(call.Arguments[2]) || goja.IsUndefined(call.Arguments[2]) {
195 + panic(vm.NewGoError(fmt.Errorf("invalid data parameter")))
196 + }
197 +
198 + algorithm := call.Arguments[0].String()
199 + key := call.Arguments[1].String()
200 + data := call.Arguments[2].String()
201 +
202 + var mac hash.Hash
203 +
204 + switch algorithm {
205 + case "md5":
206 + mac = hmac.New(md5.New, []byte(key))
207 + case "sha256":
208 + mac = hmac.New(sha256.New, []byte(key))
209 + default:
210 + panic(vm.NewGoError(fmt.Errorf("unsupported hmac algorithm: %s", algorithm)))
211 + }
212 +
213 + mac.Write([]byte(data))
214 + result := hex.EncodeToString(mac.Sum(nil))
215 + return vm.ToValue(result)
216 + })
217 +
218 + // sign - RSA signature function
219 + vm.Set("sign", func(call goja.FunctionCall) goja.Value {
220 + if len(call.Arguments) < 3 {
221 + panic(vm.NewGoError(fmt.Errorf("sign requires 3 arguments: algorithm, privateKey, data")))
222 + }
223 +
224 + algorithm := call.Arguments[0].String()
225 + pemKey := call.Arguments[1].String()
226 + dataArg := call.Arguments[2]
227 +
228 + // Extract data (support string and Uint8Array)
229 + var data []byte
230 + if obj := dataArg.ToObject(vm); obj != nil {
231 + if arr := obj.Get("constructor"); arr != nil && arr.String() == "function Uint8Array() { [native code] }" {
232 + // It's a typed array, extract bytes
233 + if length := obj.Get("length"); length != nil {
234 + n := int(length.ToInteger())
235 + data = make([]byte, n)
236 + for i := 0; i < n; i++ {
237 + data[i] = byte(obj.Get(fmt.Sprintf("%d", i)).ToInteger())
238 + }
239 + }
240 + }
241 + }
242 + if data == nil {
243 + data = []byte(dataArg.String())
244 + }
245 +
246 + if algorithm != "sha256" {
247 + panic(vm.NewGoError(fmt.Errorf("unsupported signature algorithm: %s", algorithm)))
248 + }
249 +
250 + // Normalize PEM key (handle single-line keys)
251 + pemKey = normalizePEMKey(pemKey)
252 +
253 + // Parse PEM key
254 + block, _ := pem.Decode([]byte(pemKey))
255 + if block == nil {
256 + panic(vm.NewGoError(fmt.Errorf("failed to parse PEM block")))
257 + }
258 +
259 + var privateKey *rsa.PrivateKey
260 + var err error
261 +
262 + // Try PKCS#1 format first
263 + privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
264 + if err != nil {
265 + // Try PKCS#8 format
266 + key, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
267 + if err2 != nil {
268 + panic(vm.NewGoError(fmt.Errorf("failed to parse private key: %v", err)))
269 + }
270 + var ok bool
271 + privateKey, ok = key.(*rsa.PrivateKey)
272 + if !ok {
273 + panic(vm.NewGoError(fmt.Errorf("not an RSA private key")))
274 + }
275 + }
276 +
277 + // Validate RSA key size (minimum 2048 bits for security)
278 + keyBits := privateKey.N.BitLen()
279 + if keyBits < jsMinRSAKeyBits {
280 + panic(vm.NewGoError(fmt.Errorf("RSA key too small: %d bits (minimum %d bits required)", keyBits, jsMinRSAKeyBits)))
281 + }
282 +
283 + // Hash the data
284 + hashed := sha256.Sum256(data)
285 +
286 + // Sign the hash
287 + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed[:])
288 + if err != nil {
289 + panic(vm.NewGoError(fmt.Errorf("signing failed: %v", err)))
290 + }
291 +
292 + // Return hex-encoded signature
293 + result := hex.EncodeToString(signature)
294 + return vm.ToValue(result)
295 + })
296 +
297 + // HttpRequest - Minimal stub for Zabbix HTTP request object
298 + // Only needed for test compatibility - actual HTTP functionality not implemented
299 + vm.RunString(`
300 + function HttpRequest() {
301 + // Minimal constructor - allows property assignment
302 + // Actual HTTP methods would be implemented here in full Zabbix
303 + }
304 + `)
305 +}
306 +
307 +// normalizePEMKey normalizes a PEM key by adding proper newlines
308 +func normalizePEMKey(pemStr string) string {
309 + // If already has newlines after BEGIN, return as is
310 + if len(pemStr) > pemMinHeaderLen && pemStr[pemNewlineCheckPos] == '\n' {
311 + return pemStr
312 + }
313 +
314 + // Find BEGIN and END markers
315 + beginIdx := 0
316 + endIdx := len(pemStr)
317 +
318 + for i := 0; i < len(pemStr)-pemBeginMarkerLen; i++ {
319 + if pemStr[i:i+pemBeginMarkerLen] == pemBeginMarker {
320 + // Find end of BEGIN line
321 + for j := i; j < len(pemStr)-pemDelimiterLen; j++ {
322 + if pemStr[j:j+pemDelimiterLen] == pemDelimiter && j > i+pemBeginMarkerLen {
323 + beginIdx = j + pemDelimiterLen
324 + break
325 + }
326 + }
327 + }
328 + if pemStr[i:i+pemEndMarkerLen] == pemEndMarker {
329 + endIdx = i
330 + break
331 + }
332 + }
333 +
334 + if beginIdx == 0 || endIdx == len(pemStr) {
335 + return pemStr // No markers found, return as is
336 + }
337 +
338 + // Extract header, body, footer
339 + header := pemStr[:beginIdx]
340 + body := pemStr[beginIdx:endIdx]
341 + footer := pemStr[endIdx:]
342 +
343 + // Remove any existing whitespace from body
344 + var bodyClean strings.Builder
345 + bodyClean.Grow(len(body))
346 + for _, ch := range body {
347 + if ch != '\n' && ch != '\r' && ch != ' ' && ch != '\t' {
348 + bodyClean.WriteRune(ch)
349 + }
350 + }
351 +
352 + // Split body into pemLineWidth-char lines (RFC 7468 standard)
353 + cleanStr := bodyClean.String()
354 + var lines []string
355 + for i := 0; i < len(cleanStr); i += pemLineWidth {
356 + end := i + pemLineWidth
357 + if end > len(cleanStr) {
358 + end = len(cleanStr)
359 + }
360 + lines = append(lines, cleanStr[i:end])
361 + }
362 +
363 + // Reconstruct PEM
364 + var result strings.Builder
365 + result.Grow(len(header) + len(cleanStr) + len(footer) + len(lines)*2)
366 + result.WriteString(header)
367 + result.WriteByte('\n')
368 + for _, line := range lines {
369 + result.WriteString(line)
370 + result.WriteByte('\n')
371 + }
372 + result.WriteString(footer)
373 +
374 + return result.String()
375 +}
376 +
377 +// javascriptExecute executes JavaScript preprocessing step with program caching.
378 +//
379 +// Performance optimizations:
380 +// - Caches compiled programs in programCache (avoids recompilation)
381 +// - Built-in functions configured once per VM (not per execution)
382 +//
383 +// Security note: VMs are NOT pooled to prevent prototype pollution attacks.
384 +// Each execution gets a fresh VM to ensure complete isolation.
385 +func javascriptExecute(value Value, paramStr string, limits JSLimits) (Value, error) {
386 + if paramStr == "" {
387 + return Value{}, fmt.Errorf("javascript code required")
388 + }
389 +
390 + // Create fresh VM for each execution (prevents prototype pollution)
391 + // VM pooling was removed for security - prototype modifications persist across reuse
392 + vm := createConfiguredVM()
393 +
394 + // No defer to return VM to pool - VM is discarded after use (GC will collect it)
395 + // This ensures no state leakage between executions
396 +
397 + // Set up execution timeout using a goroutine that will interrupt the VM
398 + // The interrupt will cause the current execution to fail; since we create a fresh
399 + // VM per execution (not pooled), the interrupted VM is simply discarded afterward
400 + timeout := limits.Timeout
401 + if timeout == 0 {
402 + timeout = 10 * time.Second // Default to Zabbix's 10s timeout
403 + }
404 + done := make(chan struct{})
405 + go func() {
406 + select {
407 + case <-time.After(timeout):
408 + vm.Interrupt("JavaScript execution timeout")
409 + case <-done:
410 + return
411 + }
412 + }()
413 + defer close(done)
414 +
415 + // Panic recovery for JavaScript errors
416 + defer func() {
417 + if r := recover(); r != nil {
418 + // Panics from goja are already handled, but catch any other panics
419 + }
420 + }()
421 +
422 + // Create the main function code
423 + code := `
424 +(function(value) {
425 + ` + paramStr + `
426 +})
427 +`
428 +
429 + // Try to get compiled program from cache
430 + var program *goja.Program
431 + if cached, ok := programCache.Get(code); ok {
432 + program = cached
433 + } else {
434 + // Compile the program (first time for this script)
435 + var err error
436 + program, err = goja.Compile("", code, false)
437 + if err != nil {
438 + return Value{}, fmt.Errorf("javascript compilation error: %w", err)
439 + }
440 + // Cache the compiled program (bounded LRU, max 1000 entries)
441 + programCache.Put(code, program)
442 + }
443 +
444 + // Run the compiled program
445 + result, err := vm.RunProgram(program)
446 + if err != nil {
447 + return Value{}, fmt.Errorf("javascript execution error: %w", err)
448 + }
449 +
450 + // Call the function with value as parameter
451 + fn := result
452 + var fnVal goja.Callable
453 + ok := false
454 + if fnVal, ok = goja.AssertFunction(fn); !ok {
455 + return Value{}, fmt.Errorf("javascript code must return a function")
456 + }
457 +
458 + // Execute the function
459 + res, err := fnVal(goja.Undefined(), vm.ToValue(value.Data))
460 + if err != nil {
461 + return Value{}, fmt.Errorf("javascript function error: %w", err)
462 + }
463 +
464 + // Get the result as string
465 + resultStr := res.String()
466 + return Value{Data: resultStr, Type: ValueTypeStr}, nil
467 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/jsonpath_step.go new
+203
@@ -0,0 +1,203 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "encoding/json"
5 + "fmt"
6 + "strconv"
7 + "strings"
8 +
9 + "github.com/theory/jsonpath"
10 +)
11 +
12 +// JSON Unicode escape sequence constants
13 +const (
14 + unicodeEscapeLen = 6 // Length of \uXXXX sequence
15 + surrogatePairLen = 12 // Length of surrogate pair \uXXXX\uYYYY
16 + unicodeHexDigits = 4 // Number of hex digits in \uXXXX
17 + unicodeHexBase = 16 // Base for parsing hex codes
18 + unicodeHexBitSize = 16 // Bit size for ParseUint
19 + unicodeSurrogateStart = 0xD800 // Start of surrogate range
20 + unicodeSurrogateEnd = 0xDFFF // End of surrogate range
21 + unicodeHighSurroStart = 0xD800 // Start of high surrogate range
22 + unicodeHighSurroEnd = 0xDBFF // End of high surrogate range
23 + unicodeLowSurroStart = 0xDC00 // Start of low surrogate range
24 + unicodeLowSurroEnd = 0xDFFF // End of low surrogate range
25 +)
26 +
27 +// validateSurrogatePairs checks for unpaired UTF-16 surrogates in JSON string.
28 +// Returns error if any unpaired surrogates are found.
29 +func validateSurrogatePairs(jsonStr string) error {
30 + i := 0
31 + for i < len(jsonStr) {
32 + // Find next \u escape sequence
33 + if jsonStr[i] == '\\' && i+unicodeEscapeLen-1 < len(jsonStr) && jsonStr[i+1] == 'u' {
34 + // Parse the 4-digit hex code
35 + hexCode := jsonStr[i+2 : i+2+unicodeHexDigits]
36 + code, err := strconv.ParseUint(hexCode, unicodeHexBase, unicodeHexBitSize)
37 + if err != nil {
38 + // Invalid hex code, but let json.Unmarshal handle it
39 + i++
40 + continue
41 + }
42 +
43 + // Check if this is a surrogate
44 + if code >= unicodeSurrogateStart && code <= unicodeSurrogateEnd {
45 + // This is a surrogate
46 + if code >= unicodeHighSurroStart && code <= unicodeHighSurroEnd {
47 + // High surrogate - must be followed by low surrogate
48 + if i+surrogatePairLen < len(jsonStr) && jsonStr[i+6:i+8] == "\\u" {
49 + nextHex := jsonStr[i+8 : i+8+unicodeHexDigits]
50 + nextCode, err := strconv.ParseUint(nextHex, unicodeHexBase, unicodeHexBitSize)
51 + if err == nil && nextCode >= unicodeLowSurroStart && nextCode <= unicodeLowSurroEnd {
52 + // Valid surrogate pair, skip both
53 + i += surrogatePairLen
54 + continue
55 + }
56 + }
57 + // High surrogate without low surrogate = invalid
58 + return fmt.Errorf("invalid JSON: unpaired high surrogate")
59 + } else {
60 + // Low surrogate without preceding high surrogate = invalid
61 + return fmt.Errorf("invalid JSON: unpaired low surrogate")
62 + }
63 + }
64 +
65 + i += unicodeEscapeLen // Skip this \uXXXX sequence
66 + } else {
67 + i++
68 + }
69 + }
70 + return nil
71 +}
72 +
73 +// jsonpathExtract extracts values from JSON using JSONPath expression.
74 +// jsonPathMulti extracts values using JSONPath and returns multiple metrics for arrays
75 +func jsonPathMulti(value Value, paramStr string) (Result, error) {
76 + expr := strings.TrimSpace(paramStr)
77 + if expr == "" {
78 + err := fmt.Errorf("jsonpath expression is required")
79 + return Result{Error: err}, err
80 + }
81 +
82 + // Validate surrogate pairs before parsing
83 + if err := validateSurrogatePairs(value.Data); err != nil {
84 + return Result{Error: err}, err
85 + }
86 +
87 + // Parse JSON
88 + var data interface{}
89 + err := json.Unmarshal([]byte(value.Data), &data)
90 + if err != nil {
91 + err = fmt.Errorf("invalid JSON: %w", err)
92 + return Result{Error: err}, err
93 + }
94 +
95 + // Compile and execute JSONPath expression
96 + jp, err := jsonpath.Parse(expr)
97 + if err != nil {
98 + err = fmt.Errorf("invalid jsonpath expression: %w", err)
99 + return Result{Error: err}, err
100 + }
101 +
102 + results := jp.Select(data)
103 + if len(results) == 0 {
104 + err = fmt.Errorf("jsonpath expression did not match any values")
105 + return Result{Error: err}, err
106 + }
107 +
108 + // If single result and not an array, return single metric
109 + if len(results) == 1 {
110 + // Check if the single result is an array
111 + if arr, ok := results[0].([]interface{}); ok {
112 + // It's an array - return each element as separate metric
113 + metrics := make([]Metric, 0, len(arr))
114 + for i, item := range arr {
115 + metrics = append(metrics, Metric{
116 + Name: "item",
117 + Value: marshalJSONValue(item),
118 + Type: ValueTypeStr,
119 + Labels: map[string]string{"index": fmt.Sprintf("%d", i)},
120 + })
121 + }
122 + return Result{Metrics: metrics}, nil
123 + }
124 + // Single non-array result - return as single metric
125 + return Result{
126 + Metrics: []Metric{{
127 + Name: "",
128 + Value: marshalJSONValue(results[0]),
129 + Type: ValueTypeStr,
130 + }},
131 + }, nil
132 + }
133 +
134 + // Multiple results from JSONPath - return each as separate metric
135 + metrics := make([]Metric, 0, len(results))
136 + for i, result := range results {
137 + metrics = append(metrics, Metric{
138 + Name: "item",
139 + Value: marshalJSONValue(result),
140 + Type: ValueTypeStr,
141 + Labels: map[string]string{"index": fmt.Sprintf("%d", i)},
142 + })
143 + }
144 +
145 + return Result{Metrics: metrics}, nil
146 +}
147 +
148 +// marshalJSONValue converts a value to JSON string, handling strings specially
149 +func marshalJSONValue(v interface{}) string {
150 + // If it's already a string, return it directly without JSON encoding
151 + if s, ok := v.(string); ok {
152 + return s
153 + }
154 + // For non-string values, encode as JSON
155 + b, _ := json.Marshal(v)
156 + return string(b)
157 +}
158 +
159 +func jsonpathExtract(value Value, paramStr string) (Value, error) {
160 + expr := strings.TrimSpace(paramStr)
161 + if expr == "" {
162 + return Value{}, fmt.Errorf("jsonpath expression is required")
163 + }
164 +
165 + // Validate surrogate pairs before parsing
166 + if err := validateSurrogatePairs(value.Data); err != nil {
167 + return Value{}, err
168 + }
169 +
170 + // Parse JSON
171 + var data interface{}
172 + err := json.Unmarshal([]byte(value.Data), &data)
173 + if err != nil {
174 + return Value{}, fmt.Errorf("invalid JSON: %w", err)
175 + }
176 +
177 + // Compile and execute JSONPath expression
178 + jp, err := jsonpath.Parse(expr)
179 + if err != nil {
180 + return Value{}, fmt.Errorf("invalid jsonpath expression: %w", err)
181 + }
182 +
183 + results := jp.Select(data)
184 + if len(results) == 0 {
185 + return Value{}, fmt.Errorf("jsonpath expression did not match any values")
186 + }
187 +
188 + // Return the first result
189 + result := results[0]
190 +
191 + // If result is a string, return it directly without JSON encoding
192 + if s, ok := result.(string); ok {
193 + return Value{Data: s, Type: ValueTypeStr}, nil
194 + }
195 +
196 + // For non-string results, encode as JSON
197 + resultBytes, err := json.Marshal(result)
198 + if err != nil {
199 + return Value{}, fmt.Errorf("failed to marshal result: %w", err)
200 + }
201 +
202 + return Value{Data: string(resultBytes), Type: ValueTypeStr}, nil
203 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/logger.go new
+49
@@ -0,0 +1,49 @@
1 +package zabbixpreproc
2 +
3 +// Logger is a minimal logging interface for preprocessing operations.
4 +// This allows users to plug in any logging library (slog, logrus, zap, etc.)
5 +// without forcing dependencies on the library.
6 +//
7 +// By default, the library uses NoopLogger (zero overhead).
8 +// Users can provide their own logger implementation via SetLogger().
9 +//
10 +// Example with slog:
11 +//
12 +// logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
13 +// preprocessor.SetLogger(NewSlogAdapter(logger))
14 +type Logger interface {
15 + // Debug logs debug-level messages with optional structured key-value pairs.
16 + // Used for: skipped SNMP lines, empty values, parsing decisions
17 + Debug(msg string, keysAndValues ...interface{})
18 +
19 + // Info logs informational messages with optional structured key-value pairs.
20 + // Used for: preprocessing statistics, cache hits
21 + Info(msg string, keysAndValues ...interface{})
22 +
23 + // Warn logs warning messages with optional structured key-value pairs.
24 + // Used for: deprecated features, performance concerns
25 + Warn(msg string, keysAndValues ...interface{})
26 +
27 + // Error logs error messages with optional structured key-value pairs.
28 + // Note: This is for logging only. Errors are still returned to caller.
29 + Error(msg string, keysAndValues ...interface{})
30 +}
31 +
32 +// NoopLogger is a no-operation logger that discards all log messages.
33 +// This is the default logger (zero performance overhead).
34 +type NoopLogger struct{}
35 +
36 +// Debug implements Logger.Debug (no-op)
37 +func (NoopLogger) Debug(msg string, keysAndValues ...interface{}) {}
38 +
39 +// Info implements Logger.Info (no-op)
40 +func (NoopLogger) Info(msg string, keysAndValues ...interface{}) {}
41 +
42 +// Warn implements Logger.Warn (no-op)
43 +func (NoopLogger) Warn(msg string, keysAndValues ...interface{}) {}
44 +
45 +// Error implements Logger.Error (no-op)
46 +func (NoopLogger) Error(msg string, keysAndValues ...interface{}) {}
47 +
48 +// Ensure NoopLogger implements Logger
49 +var _ Logger = (*NoopLogger)(nil)
src/go/plugin/scripts.d/pkg/zabbixpreproc/multi_metric_test.go new
+858
@@ -0,0 +1,858 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "fmt"
5 + "testing"
6 + "time"
7 +)
8 +
9 +// TestPrometheusMultiMetric tests that prometheusToJSONMulti() returns multiple metrics
10 +func TestPrometheusMultiMetric(t *testing.T) {
11 + input := `# HELP http_requests_total Total HTTP requests
12 +# TYPE http_requests_total counter
13 +http_requests_total{method="GET"} 100
14 +http_requests_total{method="POST"} 50
15 +memory_usage_bytes 1024
16 +`
17 +
18 + result, err := prometheusToJSONMulti(Value{Data: input, Type: ValueTypeStr}, "")
19 + if err != nil {
20 + t.Fatalf("prometheusToJSONMulti() error: %v", err)
21 + }
22 +
23 + // Should return 3 metrics
24 + if len(result.Metrics) != 3 {
25 + t.Fatalf("Expected 3 metrics, got %d", len(result.Metrics))
26 + }
27 +
28 + // Verify first metric
29 + m0 := result.Metrics[0]
30 + if m0.Name != "http_requests_total" {
31 + t.Errorf("Metric 0: expected name 'http_requests_total', got %q", m0.Name)
32 + }
33 + if m0.Value != "100" {
34 + t.Errorf("Metric 0: expected value '100', got %q", m0.Value)
35 + }
36 + if m0.Type != ValueTypeStr {
37 + t.Errorf("Metric 0: expected type ValueTypeStr, got %v", m0.Type)
38 + }
39 + if m0.Labels == nil {
40 + t.Fatal("Metric 0: labels should not be nil")
41 + }
42 + if m0.Labels["method"] != "GET" {
43 + t.Errorf("Metric 0: expected label method=GET, got %q", m0.Labels["method"])
44 + }
45 +
46 + // Verify second metric
47 + m1 := result.Metrics[1]
48 + if m1.Name != "http_requests_total" {
49 + t.Errorf("Metric 1: expected name 'http_requests_total', got %q", m1.Name)
50 + }
51 + if m1.Value != "50" {
52 + t.Errorf("Metric 1: expected value '50', got %q", m1.Value)
53 + }
54 + if m1.Labels["method"] != "POST" {
55 + t.Errorf("Metric 1: expected label method=POST, got %q", m1.Labels["method"])
56 + }
57 +
58 + // Verify third metric (no labels)
59 + m2 := result.Metrics[2]
60 + if m2.Name != "memory_usage_bytes" {
61 + t.Errorf("Metric 2: expected name 'memory_usage_bytes', got %q", m2.Name)
62 + }
63 + if m2.Value != "1024" {
64 + t.Errorf("Metric 2: expected value '1024', got %q", m2.Value)
65 + }
66 + if len(m2.Labels) != 0 {
67 + t.Errorf("Metric 2: expected no labels, got %v", m2.Labels)
68 + }
69 +}
70 +
71 +// TestPrometheusMultiMetric_EmptyInput tests empty input handling
72 +func TestPrometheusMultiMetric_EmptyInput(t *testing.T) {
73 + input := ""
74 +
75 + result, err := prometheusToJSONMulti(Value{Data: input, Type: ValueTypeStr}, "")
76 + if err != nil {
77 + t.Fatalf("prometheusToJSONMulti() error on empty input: %v", err)
78 + }
79 +
80 + // Empty input should return zero metrics
81 + if len(result.Metrics) != 0 {
82 + t.Errorf("Expected 0 metrics for empty input, got %d", len(result.Metrics))
83 + }
84 +}
85 +
86 +// TestPrometheusMultiMetric_InvalidInput tests error handling
87 +func TestPrometheusMultiMetric_InvalidInput(t *testing.T) {
88 + input := "invalid prometheus data here"
89 +
90 + _, err := prometheusToJSONMulti(Value{Data: input, Type: ValueTypeStr}, "")
91 + if err == nil {
92 + t.Fatal("Expected error for invalid Prometheus data, got nil")
93 + }
94 +}
95 +
96 +// TestPrometheusMultiMetric_SingleMetric tests single metric returns single-element array
97 +func TestPrometheusMultiMetric_SingleMetric(t *testing.T) {
98 + input := "cpu_usage 42.5"
99 +
100 + result, err := prometheusToJSONMulti(Value{Data: input, Type: ValueTypeStr}, "")
101 + if err != nil {
102 + t.Fatalf("prometheusToJSONMulti() error: %v", err)
103 + }
104 +
105 + if len(result.Metrics) != 1 {
106 + t.Fatalf("Expected 1 metric, got %d", len(result.Metrics))
107 + }
108 +
109 + m := result.Metrics[0]
110 + if m.Name != "cpu_usage" {
111 + t.Errorf("Expected name 'cpu_usage', got %q", m.Name)
112 + }
113 + if m.Value != "42.5" {
114 + t.Errorf("Expected value '42.5', got %q", m.Value)
115 + }
116 +}
117 +
118 +// TestPrometheusMultiMetric_ViaPreprocessor tests multi-metric through Preprocessor.Execute()
119 +func TestPrometheusMultiMetric_ViaPreprocessor(t *testing.T) {
120 + input := `http_requests_total{method="GET"} 100
121 +http_requests_total{method="POST"} 50
122 +memory_usage_bytes 1024
123 +`
124 +
125 + p := NewPreprocessor("test-shard")
126 + result, err := p.Execute("item1",
127 + Value{Data: input, Type: ValueTypeStr, Timestamp: time.Now()},
128 + Step{Type: StepTypePrometheusToJSONMulti, Params: ""})
129 +
130 + if err != nil {
131 + t.Fatalf("Execute() error: %v", err)
132 + }
133 +
134 + // Should return 3 metrics
135 + if len(result.Metrics) != 3 {
136 + t.Fatalf("Expected 3 metrics, got %d", len(result.Metrics))
137 + }
138 +
139 + // Verify metric names and values
140 + expected := []struct {
141 + name string
142 + value string
143 + }{
144 + {"http_requests_total", "100"},
145 + {"http_requests_total", "50"},
146 + {"memory_usage_bytes", "1024"},
147 + }
148 +
149 + for i, exp := range expected {
150 + if result.Metrics[i].Name != exp.name {
151 + t.Errorf("Metric %d: expected name %q, got %q", i, exp.name, result.Metrics[i].Name)
152 + }
153 + if result.Metrics[i].Value != exp.value {
154 + t.Errorf("Metric %d: expected value %q, got %q", i, exp.value, result.Metrics[i].Value)
155 + }
156 + }
157 +}
158 +
159 +// TestZabbixCompatibility_PrometheusToJSON tests that old step type still works
160 +func TestZabbixCompatibility_PrometheusToJSON(t *testing.T) {
161 + input := `http_requests_total{method="GET"} 100
162 +http_requests_total{method="POST"} 50
163 +`
164 +
165 + p := NewPreprocessor("test-shard")
166 + result, err := p.Execute("item1",
167 + Value{Data: input, Type: ValueTypeStr, Timestamp: time.Now()},
168 + Step{Type: StepTypePrometheusToJSON, Params: ""}) // OLD step type
169 +
170 + if err != nil {
171 + t.Fatalf("Execute() error: %v", err)
172 + }
173 +
174 + // OLD behavior: Should return SINGLE metric with JSON array string
175 + if len(result.Metrics) != 1 {
176 + t.Fatalf("Expected 1 metric (Zabbix compatibility), got %d", len(result.Metrics))
177 + }
178 +
179 + // Value should be a JSON array string
180 + value := result.Metrics[0].Value
181 + if value[0] != '[' || value[len(value)-1] != ']' {
182 + t.Errorf("Expected JSON array string, got: %s", value)
183 + }
184 +
185 + // Verify it contains both metrics in the JSON array
186 + if !contains(value, `"name":"http_requests_total"`) {
187 + t.Errorf("JSON array missing http_requests_total metric")
188 + }
189 + if !contains(value, `"method":"GET"`) || !contains(value, `"method":"POST"`) {
190 + t.Errorf("JSON array missing expected labels")
191 + }
192 +}
193 +
194 +func contains(s, substr string) bool {
195 + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsHelper(s, substr))
196 +}
197 +
198 +func containsHelper(s, substr string) bool {
199 + for i := 0; i <= len(s)-len(substr); i++ {
200 + if s[i:i+len(substr)] == substr {
201 + return true
202 + }
203 + }
204 + return false
205 +}
206 +
207 +// ============================================================================
208 +// JSONPath Multi-Metric Tests
209 +// ============================================================================
210 +
211 +// TestJSONPathMulti_Array tests JSONPath with array result
212 +func TestJSONPathMulti_Array(t *testing.T) {
213 + input := `{"items": [{"name": "a", "value": 10}, {"name": "b", "value": 20}]}`
214 + jsonpath := "$.items[*]"
215 +
216 + result, err := jsonPathMulti(Value{Data: input, Type: ValueTypeStr}, jsonpath)
217 + if err != nil {
218 + t.Fatalf("jsonPathMulti() error: %v", err)
219 + }
220 +
221 + // Should return 2 metrics (one per array element)
222 + if len(result.Metrics) != 2 {
223 + t.Fatalf("Expected 2 metrics, got %d", len(result.Metrics))
224 + }
225 +
226 + // Verify first metric
227 + m0 := result.Metrics[0]
228 + if m0.Name != "item" {
229 + t.Errorf("Metric 0: expected name 'item', got %q", m0.Name)
230 + }
231 + if m0.Labels["index"] != "0" {
232 + t.Errorf("Metric 0: expected index label '0', got %q", m0.Labels["index"])
233 + }
234 + if !contains(m0.Value, `"name":"a"`) {
235 + t.Errorf("Metric 0: expected to contain name:a, got %q", m0.Value)
236 + }
237 +
238 + // Verify second metric
239 + m1 := result.Metrics[1]
240 + if m1.Labels["index"] != "1" {
241 + t.Errorf("Metric 1: expected index label '1', got %q", m1.Labels["index"])
242 + }
243 +}
244 +
245 +// TestJSONPathMulti_SingleValue tests JSONPath with single value result
246 +func TestJSONPathMulti_SingleValue(t *testing.T) {
247 + input := `{"name": "test", "value": 42}`
248 + jsonpath := "$.value"
249 +
250 + result, err := jsonPathMulti(Value{Data: input, Type: ValueTypeStr}, jsonpath)
251 + if err != nil {
252 + t.Fatalf("jsonPathMulti() error: %v", err)
253 + }
254 +
255 + // Should return 1 metric
256 + if len(result.Metrics) != 1 {
257 + t.Fatalf("Expected 1 metric, got %d", len(result.Metrics))
258 + }
259 +
260 + m := result.Metrics[0]
261 + if m.Value != "42" {
262 + t.Errorf("Expected value '42', got %q", m.Value)
263 + }
264 + if len(m.Labels) != 0 {
265 + t.Errorf("Single value should have no labels, got %v", m.Labels)
266 + }
267 +}
268 +
269 +// TestJSONPathMulti_StringValue tests JSONPath extracting string
270 +func TestJSONPathMulti_StringValue(t *testing.T) {
271 + input := `{"message": "hello world"}`
272 + jsonpath := "$.message"
273 +
274 + result, err := jsonPathMulti(Value{Data: input, Type: ValueTypeStr}, jsonpath)
275 + if err != nil {
276 + t.Fatalf("jsonPathMulti() error: %v", err)
277 + }
278 +
279 + if len(result.Metrics) != 1 {
280 + t.Fatalf("Expected 1 metric, got %d", len(result.Metrics))
281 + }
282 +
283 + // String should be returned directly without JSON encoding
284 + m := result.Metrics[0]
285 + if m.Value != "hello world" {
286 + t.Errorf("Expected value 'hello world', got %q", m.Value)
287 + }
288 +}
289 +
290 +// TestJSONPathMulti_ArrayOfStrings tests array of primitive values
291 +func TestJSONPathMulti_ArrayOfStrings(t *testing.T) {
292 + input := `{"tags": ["red", "green", "blue"]}`
293 + jsonpath := "$.tags[*]"
294 +
295 + result, err := jsonPathMulti(Value{Data: input, Type: ValueTypeStr}, jsonpath)
296 + if err != nil {
297 + t.Fatalf("jsonPathMulti() error: %v", err)
298 + }
299 +
300 + // Should return 3 metrics
301 + if len(result.Metrics) != 3 {
302 + t.Fatalf("Expected 3 metrics, got %d", len(result.Metrics))
303 + }
304 +
305 + // Verify values
306 + expected := []string{"red", "green", "blue"}
307 + for i, exp := range expected {
308 + if result.Metrics[i].Value != exp {
309 + t.Errorf("Metric %d: expected value %q, got %q", i, exp, result.Metrics[i].Value)
310 + }
311 + if result.Metrics[i].Labels["index"] != fmt.Sprintf("%d", i) {
312 + t.Errorf("Metric %d: expected index %d, got %q", i, i, result.Metrics[i].Labels["index"])
313 + }
314 + }
315 +}
316 +
317 +// TestJSONPathMulti_ViaPreprocessor tests full integration
318 +func TestJSONPathMulti_ViaPreprocessor(t *testing.T) {
319 + input := `{"servers": [{"name": "web1", "cpu": 45}, {"name": "web2", "cpu": 60}]}`
320 + jsonpath := "$.servers[*]"
321 +
322 + p := NewPreprocessor("test-shard")
323 + result, err := p.Execute("item1",
324 + Value{Data: input, Type: ValueTypeStr, Timestamp: time.Now()},
325 + Step{Type: StepTypeJSONPathMulti, Params: jsonpath})
326 +
327 + if err != nil {
328 + t.Fatalf("Execute() error: %v", err)
329 + }
330 +
331 + if len(result.Metrics) != 2 {
332 + t.Fatalf("Expected 2 metrics, got %d", len(result.Metrics))
333 + }
334 +
335 + // Verify both metrics have index labels
336 + for i := 0; i < 2; i++ {
337 + if result.Metrics[i].Labels["index"] != fmt.Sprintf("%d", i) {
338 + t.Errorf("Metric %d missing correct index label", i)
339 + }
340 + }
341 +}
342 +
343 +// TestJSONPathMulti_EmptyResult tests error on no matches
344 +func TestJSONPathMulti_EmptyResult(t *testing.T) {
345 + input := `{"name": "test"}`
346 + jsonpath := "$.nonexistent"
347 +
348 + _, err := jsonPathMulti(Value{Data: input, Type: ValueTypeStr}, jsonpath)
349 + if err == nil {
350 + t.Fatal("Expected error for no matches, got nil")
351 + }
352 +}
353 +
354 +// TestZabbixCompatibility_JSONPath tests old step type still works
355 +func TestZabbixCompatibility_JSONPath(t *testing.T) {
356 + input := `{"items": [1, 2, 3]}`
357 + jsonpath := "$.items"
358 +
359 + p := NewPreprocessor("test-shard")
360 + result, err := p.Execute("item1",
361 + Value{Data: input, Type: ValueTypeStr, Timestamp: time.Now()},
362 + Step{Type: StepTypeJSONPath, Params: jsonpath}) // OLD step type
363 +
364 + if err != nil {
365 + t.Fatalf("Execute() error: %v", err)
366 + }
367 +
368 + // OLD behavior: Should return SINGLE metric with JSON array string
369 + if len(result.Metrics) != 1 {
370 + t.Fatalf("Expected 1 metric (Zabbix compatibility), got %d", len(result.Metrics))
371 + }
372 +
373 + // Value should be a JSON array
374 + value := result.Metrics[0].Value
375 + if value[0] != '[' {
376 + t.Errorf("Expected JSON array, got: %s", value)
377 + }
378 +}
379 +
380 +// ============================================================================
381 +// SNMP Walk to JSON Multi-Metric Tests
382 +// ============================================================================
383 +
384 +// TestSNMPWalkMulti_BasicDiscovery tests basic SNMP discovery with multiple items
385 +func TestSNMPWalkMulti_BasicDiscovery(t *testing.T) {
386 + params := `{#IFNAME}
387 +.1.3.6.1.2.1.31.1.1.1.1
388 +0
389 +{#IFDESCR}
390 +.1.3.6.1.2.1.2.2.1.2
391 +0`
392 +
393 + input := `.1.3.6.1.2.1.31.1.1.1.1.1 = STRING: "eth0"
394 +.1.3.6.1.2.1.31.1.1.1.1.2 = STRING: "eth1"
395 +.1.3.6.1.2.1.2.2.1.2.1 = STRING: "Ethernet Interface 0"
396 +.1.3.6.1.2.1.2.2.1.2.2 = STRING: "Ethernet Interface 1"
397 +`
398 +
399 + result, err := snmpWalkToJSONMulti(Value{Data: input, Type: ValueTypeStr}, params, NoopLogger{})
400 + if err != nil {
401 + t.Fatalf("snmpWalkToJSONMulti() error: %v", err)
402 + }
403 +
404 + // Should return 2 metrics (one per index: 2, 1 in descending order)
405 + if len(result.Metrics) != 2 {
406 + t.Fatalf("Expected 2 metrics, got %d", len(result.Metrics))
407 + }
408 +
409 + // Verify first metric (index 2 - descending order)
410 + m0 := result.Metrics[0]
411 + if m0.Name != "snmp_discovery" {
412 + t.Errorf("Metric 0: expected name 'snmp_discovery', got %q", m0.Name)
413 + }
414 + if m0.Labels["index"] != "2" {
415 + t.Errorf("Metric 0: expected index label '2', got %q", m0.Labels["index"])
416 + }
417 + if !contains(m0.Value, `"{#SNMPINDEX}":"2"`) {
418 + t.Errorf("Metric 0: expected SNMPINDEX=2, got %q", m0.Value)
419 + }
420 + if !contains(m0.Value, `"{#IFNAME}":"eth1"`) {
421 + t.Errorf("Metric 0: expected IFNAME=eth1, got %q", m0.Value)
422 + }
423 + if !contains(m0.Value, `"{#IFDESCR}":"Ethernet Interface 1"`) {
424 + t.Errorf("Metric 0: expected IFDESCR, got %q", m0.Value)
425 + }
426 +
427 + // Verify second metric (index 1)
428 + m1 := result.Metrics[1]
429 + if m1.Labels["index"] != "1" {
430 + t.Errorf("Metric 1: expected index label '1', got %q", m1.Labels["index"])
431 + }
432 + if !contains(m1.Value, `"{#IFNAME}":"eth0"`) {
433 + t.Errorf("Metric 1: expected IFNAME=eth0, got %q", m1.Value)
434 + }
435 +}
436 +
437 +// TestSNMPWalkMulti_EmptyInput tests empty input handling
438 +func TestSNMPWalkMulti_EmptyInput(t *testing.T) {
439 + params := `{#IFNAME}
440 +.1.3.6.1.2.1.31.1.1.1.1
441 +0`
442 +
443 + result, err := snmpWalkToJSONMulti(Value{Data: "", Type: ValueTypeStr}, params, NoopLogger{})
444 + if err != nil {
445 + t.Fatalf("snmpWalkToJSONMulti() error on empty input: %v", err)
446 + }
447 +
448 + // Empty input should return zero metrics
449 + if len(result.Metrics) != 0 {
450 + t.Errorf("Expected 0 metrics for empty input, got %d", len(result.Metrics))
451 + }
452 +}
453 +
454 +// TestSNMPWalkMulti_NoMacros tests behavior with no macros defined
455 +func TestSNMPWalkMulti_NoMacros(t *testing.T) {
456 + params := "" // No macros
457 +
458 + input := `.1.3.6.1.2.1.31.1.1.1.1.1 = STRING: "eth0"`
459 +
460 + result, err := snmpWalkToJSONMulti(Value{Data: input, Type: ValueTypeStr}, params, NoopLogger{})
461 + if err != nil {
462 + t.Fatalf("snmpWalkToJSONMulti() error: %v", err)
463 + }
464 +
465 + // No macros should return empty metrics array
466 + if len(result.Metrics) != 0 {
467 + t.Errorf("Expected 0 metrics with no macros, got %d", len(result.Metrics))
468 + }
469 +}
470 +
471 +// TestSNMPWalkMulti_SingleItem tests single discovery item
472 +func TestSNMPWalkMulti_SingleItem(t *testing.T) {
473 + params := `{#IFNAME}
474 +.1.3.6.1.2.1.31.1.1.1.1
475 +0`
476 +
477 + input := `.1.3.6.1.2.1.31.1.1.1.1.1 = STRING: "lo"`
478 +
479 + result, err := snmpWalkToJSONMulti(Value{Data: input, Type: ValueTypeStr}, params, NoopLogger{})
480 + if err != nil {
481 + t.Fatalf("snmpWalkToJSONMulti() error: %v", err)
482 + }
483 +
484 + if len(result.Metrics) != 1 {
485 + t.Fatalf("Expected 1 metric, got %d", len(result.Metrics))
486 + }
487 +
488 + m := result.Metrics[0]
489 + if m.Name != "snmp_discovery" {
490 + t.Errorf("Expected name 'snmp_discovery', got %q", m.Name)
491 + }
492 + if m.Labels["index"] != "1" {
493 + t.Errorf("Expected index label '1', got %q", m.Labels["index"])
494 + }
495 + if !contains(m.Value, `"{#IFNAME}":"lo"`) {
496 + t.Errorf("Expected IFNAME=lo, got %q", m.Value)
497 + }
498 +}
499 +
500 +// TestSNMPWalkMulti_ViaPreprocessor tests full integration
501 +func TestSNMPWalkMulti_ViaPreprocessor(t *testing.T) {
502 + params := `{#IFNAME}
503 +.1.3.6.1.2.1.31.1.1.1.1
504 +0
505 +{#IFTYPE}
506 +.1.3.6.1.2.1.2.2.1.3
507 +0`
508 +
509 + input := `.1.3.6.1.2.1.31.1.1.1.1.1 = STRING: "eth0"
510 +.1.3.6.1.2.1.31.1.1.1.1.2 = STRING: "wlan0"
511 +.1.3.6.1.2.1.2.2.1.3.1 = INTEGER: 6
512 +.1.3.6.1.2.1.2.2.1.3.2 = INTEGER: 71
513 +`
514 +
515 + p := NewPreprocessor("test-shard")
516 + result, err := p.Execute("item1",
517 + Value{Data: input, Type: ValueTypeStr, Timestamp: time.Now()},
518 + Step{Type: StepTypeSNMPWalkToJSONMulti, Params: params})
519 +
520 + if err != nil {
521 + t.Fatalf("Execute() error: %v", err)
522 + }
523 +
524 + // Should return 2 metrics (indices 2, 1 in descending order)
525 + if len(result.Metrics) != 2 {
526 + t.Fatalf("Expected 2 metrics, got %d", len(result.Metrics))
527 + }
528 +
529 + // Verify metric structure and labels
530 + for i, m := range result.Metrics {
531 + if m.Name != "snmp_discovery" {
532 + t.Errorf("Metric %d: expected name 'snmp_discovery', got %q", i, m.Name)
533 + }
534 + if m.Type != ValueTypeStr {
535 + t.Errorf("Metric %d: expected type ValueTypeStr, got %v", i, m.Type)
536 + }
537 + if m.Labels["index"] == "" {
538 + t.Errorf("Metric %d: missing index label", i)
539 + }
540 + // Verify JSON structure
541 + if !contains(m.Value, `"{#SNMPINDEX}"`) {
542 + t.Errorf("Metric %d: missing SNMPINDEX field", i)
543 + }
544 + if !contains(m.Value, `"{#IFNAME}"`) {
545 + t.Errorf("Metric %d: missing IFNAME field", i)
546 + }
547 + if !contains(m.Value, `"{#IFTYPE}"`) {
548 + t.Errorf("Metric %d: missing IFTYPE field", i)
549 + }
550 + }
551 +}
552 +
553 +// TestSNMPWalkMulti_InvalidData tests error handling
554 +func TestSNMPWalkMulti_InvalidData(t *testing.T) {
555 + params := `{#IFNAME}
556 +.1.3.6.1.2.1.31.1.1.1.1
557 +0`
558 +
559 + input := `not valid SNMP walk data`
560 +
561 + _, err := snmpWalkToJSONMulti(Value{Data: input, Type: ValueTypeStr}, params, NoopLogger{})
562 + if err == nil {
563 + t.Fatal("Expected error for invalid SNMP data, got nil")
564 + }
565 +}
566 +
567 +// TestZabbixCompatibility_SNMPWalkToJSON tests that old step type still works
568 +func TestZabbixCompatibility_SNMPWalkToJSON(t *testing.T) {
569 + params := `{#IFNAME}
570 +.1.3.6.1.2.1.31.1.1.1.1
571 +0`
572 +
573 + input := `.1.3.6.1.2.1.31.1.1.1.1.1 = STRING: "eth0"
574 +.1.3.6.1.2.1.31.1.1.1.1.2 = STRING: "eth1"
575 +`
576 +
577 + p := NewPreprocessor("test-shard")
578 + result, err := p.Execute("item1",
579 + Value{Data: input, Type: ValueTypeStr, Timestamp: time.Now()},
580 + Step{Type: StepTypeSNMPWalkToJSON, Params: params}) // OLD step type
581 +
582 + if err != nil {
583 + t.Fatalf("Execute() error: %v", err)
584 + }
585 +
586 + // OLD behavior: Should return SINGLE metric with JSON array string
587 + if len(result.Metrics) != 1 {
588 + t.Fatalf("Expected 1 metric (Zabbix compatibility), got %d", len(result.Metrics))
589 + }
590 +
591 + // Value should be a JSON array string
592 + value := result.Metrics[0].Value
593 + if value[0] != '[' || value[len(value)-1] != ']' {
594 + t.Errorf("Expected JSON array string, got: %s", value)
595 + }
596 +
597 + // Verify it contains both discovery items in the JSON array
598 + if !contains(value, `"{#SNMPINDEX}":"2"`) {
599 + t.Errorf("JSON array missing index 2")
600 + }
601 + if !contains(value, `"{#SNMPINDEX}":"1"`) {
602 + t.Errorf("JSON array missing index 1")
603 + }
604 + if !contains(value, `"{#IFNAME}":"eth0"`) {
605 + t.Errorf("JSON array missing eth0")
606 + }
607 + if !contains(value, `"{#IFNAME}":"eth1"`) {
608 + t.Errorf("JSON array missing eth1")
609 + }
610 +}
611 +
612 +// ============================================================================
613 +// CSV to JSON Multi-Metric Tests
614 +// ============================================================================
615 +
616 +// TestCSVMulti_WithHeader tests CSV with header row
617 +func TestCSVMulti_WithHeader(t *testing.T) {
618 + params := ",\n\"\n1" // comma delimiter, double quote, has header
619 +
620 + input := `name,age,city
621 +Alice,30,NYC
622 +Bob,25,LA
623 +`
624 +
625 + result, err := csvToJSONMulti(Value{Data: input, Type: ValueTypeStr}, params)
626 + if err != nil {
627 + t.Fatalf("csvToJSONMulti() error: %v", err)
628 + }
629 +
630 + // Should return 3 rows (2 data + 1 empty from trailing newline)
631 + if len(result.Metrics) != 3 {
632 + t.Fatalf("Expected 3 metrics, got %d", len(result.Metrics))
633 + }
634 +
635 + // Verify first row
636 + m0 := result.Metrics[0]
637 + if m0.Name != "csv_row" {
638 + t.Errorf("Metric 0: expected name 'csv_row', got %q", m0.Name)
639 + }
640 + if m0.Labels["row"] != "1" {
641 + t.Errorf("Metric 0: expected row label '1', got %q", m0.Labels["row"])
642 + }
643 + if !contains(m0.Value, `"name":"Alice"`) {
644 + t.Errorf("Metric 0: expected name=Alice, got %q", m0.Value)
645 + }
646 + if !contains(m0.Value, `"age":"30"`) {
647 + t.Errorf("Metric 0: expected age=30, got %q", m0.Value)
648 + }
649 + if !contains(m0.Value, `"city":"NYC"`) {
650 + t.Errorf("Metric 0: expected city=NYC, got %q", m0.Value)
651 + }
652 +
653 + // Verify second row
654 + m1 := result.Metrics[1]
655 + if m1.Labels["row"] != "2" {
656 + t.Errorf("Metric 1: expected row label '2', got %q", m1.Labels["row"])
657 + }
658 + if !contains(m1.Value, `"name":"Bob"`) {
659 + t.Errorf("Metric 1: expected name=Bob, got %q", m1.Value)
660 + }
661 +
662 + // Verify third row (empty trailing row from newline)
663 + m2 := result.Metrics[2]
664 + if m2.Labels["row"] != "3" {
665 + t.Errorf("Metric 2: expected row label '3', got %q", m2.Labels["row"])
666 + }
667 + // Empty row should have all empty fields
668 + if !contains(m2.Value, `"name":""`) {
669 + t.Errorf("Metric 2: expected empty name field, got %q", m2.Value)
670 + }
671 +}
672 +
673 +// TestCSVMulti_NoHeader tests CSV without header row
674 +func TestCSVMulti_NoHeader(t *testing.T) {
675 + params := ",\n\"\n0" // comma delimiter, double quote, no header
676 +
677 + input := `10,20,30
678 +40,50,60`
679 +
680 + result, err := csvToJSONMulti(Value{Data: input, Type: ValueTypeStr}, params)
681 + if err != nil {
682 + t.Fatalf("csvToJSONMulti() error: %v", err)
683 + }
684 +
685 + // Should return 2 rows
686 + if len(result.Metrics) != 2 {
687 + t.Fatalf("Expected 2 metrics, got %d", len(result.Metrics))
688 + }
689 +
690 + // Verify first row (1-based column indices)
691 + m0 := result.Metrics[0]
692 + if m0.Labels["row"] != "1" {
693 + t.Errorf("Metric 0: expected row label '1', got %q", m0.Labels["row"])
694 + }
695 + if !contains(m0.Value, `"1":"10"`) {
696 + t.Errorf("Metric 0: expected column 1=10, got %q", m0.Value)
697 + }
698 + if !contains(m0.Value, `"2":"20"`) {
699 + t.Errorf("Metric 0: expected column 2=20, got %q", m0.Value)
700 + }
701 + if !contains(m0.Value, `"3":"30"`) {
702 + t.Errorf("Metric 0: expected column 3=30, got %q", m0.Value)
703 + }
704 +
705 + // Verify second row
706 + m1 := result.Metrics[1]
707 + if m1.Labels["row"] != "2" {
708 + t.Errorf("Metric 1: expected row label '2', got %q", m1.Labels["row"])
709 + }
710 + if !contains(m1.Value, `"1":"40"`) {
711 + t.Errorf("Metric 1: expected column 1=40, got %q", m1.Value)
712 + }
713 +}
714 +
715 +// TestCSVMulti_EmptyInput tests empty input handling
716 +func TestCSVMulti_EmptyInput(t *testing.T) {
717 + params := ",\n\"\n1"
718 +
719 + result, err := csvToJSONMulti(Value{Data: "", Type: ValueTypeStr}, params)
720 + if err != nil {
721 + t.Fatalf("csvToJSONMulti() error on empty input: %v", err)
722 + }
723 +
724 + // Empty input should return zero metrics
725 + if len(result.Metrics) != 0 {
726 + t.Errorf("Expected 0 metrics for empty input, got %d", len(result.Metrics))
727 + }
728 +}
729 +
730 +// TestCSVMulti_SingleRow tests single data row
731 +func TestCSVMulti_SingleRow(t *testing.T) {
732 + params := ",\n\"\n1"
733 +
734 + input := `name,value
735 +test,42`
736 +
737 + result, err := csvToJSONMulti(Value{Data: input, Type: ValueTypeStr}, params)
738 + if err != nil {
739 + t.Fatalf("csvToJSONMulti() error: %v", err)
740 + }
741 +
742 + if len(result.Metrics) != 1 {
743 + t.Fatalf("Expected 1 metric, got %d", len(result.Metrics))
744 + }
745 +
746 + m := result.Metrics[0]
747 + if m.Name != "csv_row" {
748 + t.Errorf("Expected name 'csv_row', got %q", m.Name)
749 + }
750 + if m.Labels["row"] != "1" {
751 + t.Errorf("Expected row label '1', got %q", m.Labels["row"])
752 + }
753 + if !contains(m.Value, `"name":"test"`) {
754 + t.Errorf("Expected name=test, got %q", m.Value)
755 + }
756 + if !contains(m.Value, `"value":"42"`) {
757 + t.Errorf("Expected value=42, got %q", m.Value)
758 + }
759 +}
760 +
761 +// TestCSVMulti_ViaPreprocessor tests full integration
762 +func TestCSVMulti_ViaPreprocessor(t *testing.T) {
763 + params := ",\n\"\n1"
764 +
765 + input := `id,status
766 +1,active
767 +2,inactive
768 +3,pending`
769 +
770 + p := NewPreprocessor("test-shard")
771 + result, err := p.Execute("item1",
772 + Value{Data: input, Type: ValueTypeStr, Timestamp: time.Now()},
773 + Step{Type: StepTypeCSVToJSONMulti, Params: params})
774 +
775 + if err != nil {
776 + t.Fatalf("Execute() error: %v", err)
777 + }
778 +
779 + // Should return 3 metrics (one per data row)
780 + if len(result.Metrics) != 3 {
781 + t.Fatalf("Expected 3 metrics, got %d", len(result.Metrics))
782 + }
783 +
784 + // Verify all metrics have correct structure
785 + for i, m := range result.Metrics {
786 + if m.Name != "csv_row" {
787 + t.Errorf("Metric %d: expected name 'csv_row', got %q", i, m.Name)
788 + }
789 + if m.Type != ValueTypeStr {
790 + t.Errorf("Metric %d: expected type ValueTypeStr, got %v", i, m.Type)
791 + }
792 + if m.Labels["row"] == "" {
793 + t.Errorf("Metric %d: missing row label", i)
794 + }
795 + // Verify JSON structure
796 + if !contains(m.Value, `"id"`) {
797 + t.Errorf("Metric %d: missing id field", i)
798 + }
799 + if !contains(m.Value, `"status"`) {
800 + t.Errorf("Metric %d: missing status field", i)
801 + }
802 + }
803 +}
804 +
805 +// TestCSVMulti_InvalidParams tests error handling
806 +func TestCSVMulti_InvalidParams(t *testing.T) {
807 + params := "," // Missing required params
808 +
809 + input := `a,b,c`
810 +
811 + _, err := csvToJSONMulti(Value{Data: input, Type: ValueTypeStr}, params)
812 + if err == nil {
813 + t.Fatal("Expected error for invalid params, got nil")
814 + }
815 +}
816 +
817 +// TestZabbixCompatibility_CSVToJSON tests that old step type still works
818 +func TestZabbixCompatibility_CSVToJSON(t *testing.T) {
819 + params := ",\n\"\n1"
820 +
821 + input := `name,value
822 +foo,10
823 +bar,20`
824 +
825 + p := NewPreprocessor("test-shard")
826 + result, err := p.Execute("item1",
827 + Value{Data: input, Type: ValueTypeStr, Timestamp: time.Now()},
828 + Step{Type: StepTypeCSVToJSON, Params: params}) // OLD step type
829 +
830 + if err != nil {
831 + t.Fatalf("Execute() error: %v", err)
832 + }
833 +
834 + // OLD behavior: Should return SINGLE metric with JSON array string
835 + if len(result.Metrics) != 1 {
836 + t.Fatalf("Expected 1 metric (Zabbix compatibility), got %d", len(result.Metrics))
837 + }
838 +
839 + // Value should be a JSON array string
840 + value := result.Metrics[0].Value
841 + if value[0] != '[' || value[len(value)-1] != ']' {
842 + t.Errorf("Expected JSON array string, got: %s", value)
843 + }
844 +
845 + // Verify it contains both rows in the JSON array
846 + if !contains(value, `"name":"foo"`) {
847 + t.Errorf("JSON array missing foo row")
848 + }
849 + if !contains(value, `"name":"bar"`) {
850 + t.Errorf("JSON array missing bar row")
851 + }
852 + if !contains(value, `"value":"10"`) {
853 + t.Errorf("JSON array missing value 10")
854 + }
855 + if !contains(value, `"value":"20"`) {
856 + t.Errorf("JSON array missing value 20")
857 + }
858 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/pipeline_test.go new
+467
@@ -0,0 +1,467 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "strings"
5 + "testing"
6 + "time"
7 +)
8 +
9 +// TestPipeline_BasicChaining tests that values flow correctly between steps
10 +func TestPipeline_BasicChaining(t *testing.T) {
11 + p := NewPreprocessor("test-shard")
12 +
13 + tests := []struct {
14 + name string
15 + input string
16 + steps []Step
17 + expected string
18 + }{
19 + {
20 + name: "JSONPath → Multiplier → Range",
21 + input: `{"cpu": 0.45}`,
22 + steps: []Step{
23 + {Type: StepTypeJSONPath, Params: "$.cpu"}, // Extract 0.45
24 + {Type: StepTypeMultiplier, Params: "100"}, // Multiply by 100 → 45
25 + {Type: StepTypeValidateRange, Params: "0\n100"}, // Validate 0-100
26 + },
27 + expected: "45",
28 + },
29 + {
30 + name: "Trim → Regex → Multiplier",
31 + input: " Temperature: 25.5 C ",
32 + steps: []Step{
33 + {Type: StepTypeTrim, Params: " "}, // Remove spaces
34 + {Type: StepTypeRegexSubstitution, Params: "Temperature:\\s+([0-9.]+).*\n\\1"}, // Extract number
35 + {Type: StepTypeMultiplier, Params: "1.8"}, // Celsius to Fahrenheit delta
36 + },
37 + expected: "45.9",
38 + },
39 + {
40 + name: "XPath → Trim → Hex2Dec",
41 + input: `<data><value> 0xFF </value></data>`,
42 + steps: []Step{
43 + {Type: StepTypeXPath, Params: "//value/text()"}, // Extract " 0xFF "
44 + {Type: StepTypeTrim, Params: " "}, // Remove spaces → "0xFF"
45 + {Type: StepTypeHex2Dec}, // Convert to decimal
46 + },
47 + expected: "255",
48 + },
49 + {
50 + name: "Prometheus → Multiplier → Validate",
51 + input: "http_requests{method=\"GET\"} 100\nhttp_requests{method=\"POST\"} 50",
52 + steps: []Step{
53 + {Type: StepTypePrometheusPattern, Params: `http_requests{method="GET"}`}, // Extract 100
54 + {Type: StepTypeMultiplier, Params: "0.01"}, // Convert to percentage
55 + {Type: StepTypeValidateRange, Params: "0\n10"}, // Must be 0-10%
56 + },
57 + expected: "1",
58 + },
59 + {
60 + name: "String Replace → Trim → Bool2Dec",
61 + input: "Status: TRUE ",
62 + steps: []Step{
63 + {Type: StepTypeStringReplace, Params: "Status: \n"}, // Remove prefix
64 + {Type: StepTypeTrim, Params: " "}, // Remove spaces
65 + {Type: StepTypeBool2Dec}, // TRUE → 1
66 + },
67 + expected: "1",
68 + },
69 + }
70 +
71 + for _, tt := range tests {
72 + t.Run(tt.name, func(t *testing.T) {
73 + value := Value{Data: tt.input, Type: ValueTypeStr}
74 + result, err := p.ExecutePipeline("test-item", value, tt.steps)
75 +
76 + if err != nil {
77 + t.Errorf("Pipeline failed: %v", err)
78 + return
79 + }
80 +
81 + if len(result.Metrics) == 0 {
82 + t.Error("Pipeline produced no metrics")
83 + return
84 + }
85 +
86 + got := result.Metrics[0].Value
87 + if got != tt.expected {
88 + t.Errorf("Expected %q, got %q", tt.expected, got)
89 + }
90 + })
91 + }
92 +}
93 +
94 +// TestPipeline_ErrorPropagation tests that errors stop pipeline execution
95 +func TestPipeline_ErrorPropagation(t *testing.T) {
96 + p := NewPreprocessor("test-shard")
97 +
98 + tests := []struct {
99 + name string
100 + input string
101 + steps []Step
102 + expectError bool
103 + errorMatch string
104 + }{
105 + {
106 + name: "Invalid JSONPath stops pipeline",
107 + input: `{"value": 100}`,
108 + steps: []Step{
109 + {Type: StepTypeJSONPath, Params: "$.nonexistent"}, // Error: path not found
110 + {Type: StepTypeMultiplier, Params: "2"}, // Should NOT execute
111 + },
112 + expectError: true,
113 + errorMatch: "step 0",
114 + },
115 + {
116 + name: "Invalid multiplier stops pipeline",
117 + input: "abc",
118 + steps: []Step{
119 + {Type: StepTypeMultiplier, Params: "10"}, // Error: not a number
120 + {Type: StepTypeValidateRange, Params: "0\n100"}, // Should NOT execute
121 + },
122 + expectError: true,
123 + errorMatch: "step 0",
124 + },
125 + {
126 + name: "Failed validation stops pipeline",
127 + input: "150",
128 + steps: []Step{
129 + {Type: StepTypeValidateRange, Params: "0\n100"}, // Error: out of range
130 + {Type: StepTypeMultiplier, Params: "2"}, // Should NOT execute
131 + },
132 + expectError: true,
133 + errorMatch: "step 0",
134 + },
135 + {
136 + name: "Invalid regex stops pipeline",
137 + input: "test",
138 + steps: []Step{
139 + {Type: StepTypeRegexSubstitution, Params: "([0-9]+\n\\1"}, // Error: unclosed group
140 + {Type: StepTypeTrim, Params: " "}, // Should NOT execute
141 + },
142 + expectError: true,
143 + errorMatch: "step 0",
144 + },
145 + {
146 + name: "Error in middle step stops pipeline",
147 + input: `{"value": "abc"}`,
148 + steps: []Step{
149 + {Type: StepTypeJSONPath, Params: "$.value"}, // Extract "abc"
150 + {Type: StepTypeMultiplier, Params: "10"}, // Error: not a number
151 + {Type: StepTypeTrim, Params: " "}, // Should NOT execute
152 + },
153 + expectError: true,
154 + errorMatch: "step 1",
155 + },
156 + }
157 +
158 + for _, tt := range tests {
159 + t.Run(tt.name, func(t *testing.T) {
160 + value := Value{Data: tt.input, Type: ValueTypeStr}
161 + result, err := p.ExecutePipeline("test-item", value, tt.steps)
162 +
163 + if tt.expectError {
164 + if err == nil {
165 + t.Error("Expected error, got nil")
166 + return
167 + }
168 + if !strings.Contains(err.Error(), tt.errorMatch) {
169 + t.Errorf("Expected error containing %q, got: %v", tt.errorMatch, err)
170 + }
171 + if result.Error == nil {
172 + t.Error("Expected Result.Error to be set")
173 + }
174 + } else {
175 + if err != nil {
176 + t.Errorf("Unexpected error: %v", err)
177 + }
178 + }
179 + })
180 + }
181 +}
182 +
183 +// TestPipeline_ErrorHandlers tests error handler behavior in pipelines
184 +func TestPipeline_ErrorHandlers(t *testing.T) {
185 + p := NewPreprocessor("test-shard")
186 +
187 + tests := []struct {
188 + name string
189 + input string
190 + steps []Step
191 + expected string
192 + }{
193 + {
194 + name: "DISCARD error allows pipeline to continue with empty value",
195 + input: `{"value": 100}`,
196 + steps: []Step{
197 + {
198 + Type: StepTypeJSONPath,
199 + Params: "$.nonexistent",
200 + ErrorHandler: ErrorHandler{
201 + Action: ErrorActionDiscard, // Return empty string on error
202 + },
203 + },
204 + {Type: StepTypeTrim, Params: " "}, // Processes empty string → empty string
205 + },
206 + expected: "",
207 + },
208 + {
209 + name: "SET_VALUE error allows pipeline to continue with fallback",
210 + input: "not-a-number",
211 + steps: []Step{
212 + {
213 + Type: StepTypeMultiplier,
214 + Params: "10",
215 + ErrorHandler: ErrorHandler{
216 + Action: ErrorActionSetValue,
217 + Params: "0", // Fallback value
218 + },
219 + },
220 + {Type: StepTypeMultiplier, Params: "2"}, // Processes "0" → "0"
221 + },
222 + expected: "0",
223 + },
224 + {
225 + name: "Multiple error handlers in sequence",
226 + input: `{"data": "invalid"}`,
227 + steps: []Step{
228 + {
229 + Type: StepTypeJSONPath,
230 + Params: "$.missing",
231 + ErrorHandler: ErrorHandler{
232 + Action: ErrorActionSetValue,
233 + Params: "100",
234 + },
235 + },
236 + {
237 + Type: StepTypeMultiplier,
238 + Params: "abc", // Invalid multiplier
239 + ErrorHandler: ErrorHandler{
240 + Action: ErrorActionSetValue,
241 + Params: "50",
242 + },
243 + },
244 + {Type: StepTypeMultiplier, Params: "2"}, // Processes "50" → "100"
245 + },
246 + expected: "100",
247 + },
248 + {
249 + name: "Error handler only catches specific step errors",
250 + input: "150",
251 + steps: []Step{
252 + {
253 + Type: StepTypeValidateRange,
254 + Params: "0\n100",
255 + ErrorHandler: ErrorHandler{
256 + Action: ErrorActionSetValue,
257 + Params: "100", // Clamp to max
258 + },
259 + },
260 + {Type: StepTypeMultiplier, Params: "0.5"}, // Processes "100" → "50"
261 + },
262 + expected: "50",
263 + },
264 + }
265 +
266 + for _, tt := range tests {
267 + t.Run(tt.name, func(t *testing.T) {
268 + value := Value{Data: tt.input, Type: ValueTypeStr}
269 + result, err := p.ExecutePipeline("test-item", value, tt.steps)
270 +
271 + if err != nil {
272 + t.Errorf("Pipeline failed: %v", err)
273 + return
274 + }
275 +
276 + if len(result.Metrics) == 0 {
277 + t.Error("Pipeline produced no metrics")
278 + return
279 + }
280 +
281 + got := result.Metrics[0].Value
282 + if got != tt.expected {
283 + t.Errorf("Expected %q, got %q", tt.expected, got)
284 + }
285 + })
286 + }
287 +}
288 +
289 +// TestPipeline_ErrorHandlerSetError tests that SET_ERROR stops pipeline
290 +func TestPipeline_ErrorHandlerSetError(t *testing.T) {
291 + p := NewPreprocessor("test-shard")
292 +
293 + steps := []Step{
294 + {
295 + Type: StepTypeMultiplier,
296 + Params: "abc", // Invalid
297 + ErrorHandler: ErrorHandler{
298 + Action: ErrorActionSetError,
299 + Params: "Custom error message",
300 + },
301 + },
302 + {Type: StepTypeMultiplier, Params: "2"}, // Should NOT execute
303 + }
304 +
305 + value := Value{Data: "100", Type: ValueTypeStr}
306 + result, err := p.ExecutePipeline("test-item", value, steps)
307 +
308 + if err == nil {
309 + t.Error("Expected error, got nil")
310 + return
311 + }
312 +
313 + if !strings.Contains(err.Error(), "Custom error message") {
314 + t.Errorf("Expected custom error message, got: %v", err)
315 + }
316 +
317 + if result.Error == nil {
318 + t.Error("Expected Result.Error to be set")
319 + }
320 +}
321 +
322 +// TestPipeline_StatefulOperations tests that stateful steps work in pipelines
323 +func TestPipeline_StatefulOperations(t *testing.T) {
324 + p := NewPreprocessor("test-shard")
325 +
326 + steps := []Step{
327 + {Type: StepTypeJSONPath, Params: "$.counter"},
328 + {Type: StepTypeDeltaValue},
329 + {Type: StepTypeMultiplier, Params: "10"}, // Scale delta
330 + }
331 +
332 + // First call: baseline (delta = 0)
333 + value1 := Value{
334 + Data: `{"counter": 100}`,
335 + Type: ValueTypeStr,
336 + Timestamp: time.Now(),
337 + }
338 + result1, err := p.ExecutePipeline("item1", value1, steps)
339 + if err != nil {
340 + t.Fatalf("First call failed: %v", err)
341 + }
342 + if result1.Metrics[0].Value != "0" {
343 + t.Errorf("First delta should be 0, got %s", result1.Metrics[0].Value)
344 + }
345 +
346 + // Second call: delta = 50, scaled = 500
347 + value2 := Value{
348 + Data: `{"counter": 150}`,
349 + Type: ValueTypeStr,
350 + Timestamp: time.Now(),
351 + }
352 + result2, err := p.ExecutePipeline("item1", value2, steps)
353 + if err != nil {
354 + t.Fatalf("Second call failed: %v", err)
355 + }
356 + if result2.Metrics[0].Value != "500" {
357 + t.Errorf("Expected 500, got %s", result2.Metrics[0].Value)
358 + }
359 +}
360 +
361 +// TestPipeline_EmptyPipeline tests edge case of zero steps
362 +func TestPipeline_EmptyPipeline(t *testing.T) {
363 + p := NewPreprocessor("test-shard")
364 +
365 + value := Value{Data: "test", Type: ValueTypeStr}
366 + result, err := p.ExecutePipeline("test-item", value, []Step{})
367 +
368 + if err != nil {
369 + t.Errorf("Empty pipeline should succeed, got error: %v", err)
370 + }
371 +
372 + if len(result.Metrics) == 0 {
373 + t.Error("Empty pipeline should return input as metric")
374 + return
375 + }
376 +
377 + if result.Metrics[0].Value != "test" {
378 + t.Errorf("Expected input unchanged, got %q", result.Metrics[0].Value)
379 + }
380 +}
381 +
382 +// TestPipeline_SingleStep tests edge case of single step
383 +func TestPipeline_SingleStep(t *testing.T) {
384 + p := NewPreprocessor("test-shard")
385 +
386 + steps := []Step{
387 + {Type: StepTypeMultiplier, Params: "10"},
388 + }
389 +
390 + value := Value{Data: "5", Type: ValueTypeFloat}
391 + result, err := p.ExecutePipeline("test-item", value, steps)
392 +
393 + if err != nil {
394 + t.Errorf("Single step pipeline failed: %v", err)
395 + }
396 +
397 + if len(result.Metrics) == 0 {
398 + t.Error("Pipeline produced no metrics")
399 + return
400 + }
401 +
402 + if result.Metrics[0].Value != "50" {
403 + t.Errorf("Expected 50, got %s", result.Metrics[0].Value)
404 + }
405 +}
406 +
407 +// TestPipeline_ValueTypePreservation tests that type flows through pipeline
408 +func TestPipeline_ValueTypePreservation(t *testing.T) {
409 + p := NewPreprocessor("test-shard")
410 +
411 + tests := []struct {
412 + name string
413 + inputType ValueType
414 + steps []Step
415 + expectedType ValueType
416 + }{
417 + {
418 + name: "Float through multiplier stays float",
419 + inputType: ValueTypeFloat,
420 + steps: []Step{
421 + {Type: StepTypeMultiplier, Params: "2"},
422 + },
423 + expectedType: ValueTypeFloat,
424 + },
425 + {
426 + name: "String through trim stays string",
427 + inputType: ValueTypeStr,
428 + steps: []Step{
429 + {Type: StepTypeTrim, Params: " "},
430 + },
431 + expectedType: ValueTypeStr,
432 + },
433 + {
434 + name: "String through regex substitution stays string",
435 + inputType: ValueTypeStr,
436 + steps: []Step{
437 + {Type: StepTypeRegexSubstitution, Params: "([0-9]+)\n\\1"},
438 + },
439 + expectedType: ValueTypeStr,
440 + },
441 + }
442 +
443 + for _, tt := range tests {
444 + t.Run(tt.name, func(t *testing.T) {
445 + value := Value{
446 + Data: "100",
447 + Type: tt.inputType,
448 + Timestamp: time.Now(),
449 + }
450 +
451 + result, err := p.ExecutePipeline("test-item", value, tt.steps)
452 + if err != nil {
453 + t.Errorf("Pipeline failed: %v", err)
454 + return
455 + }
456 +
457 + if len(result.Metrics) == 0 {
458 + t.Error("Pipeline produced no metrics")
459 + return
460 + }
461 +
462 + if result.Metrics[0].Type != tt.expectedType {
463 + t.Errorf("Expected type %v, got %v", tt.expectedType, result.Metrics[0].Type)
464 + }
465 + })
466 + }
467 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/preproc.go new
+582
@@ -0,0 +1,582 @@
1 +// Package zabbixpreproc provides Zabbix preprocessing functionality in pure Go.
2 +package zabbixpreproc
3 +
4 +import (
5 + "fmt"
6 + "strings"
7 + "sync"
8 + "time"
9 +)
10 +
11 +// ValueType represents Zabbix item value types.
12 +type ValueType int
13 +
14 +const (
15 + ValueTypeStr ValueType = 0 // ITEM_VALUE_TYPE_STR
16 + ValueTypeUint64 ValueType = 3 // ITEM_VALUE_TYPE_UINT64
17 + ValueTypeFloat ValueType = 1 // ITEM_VALUE_TYPE_FLOAT
18 +)
19 +
20 +// Value represents a preprocessing input/output value.
21 +type Value struct {
22 + Data string // Raw string data
23 + Type ValueType // Value type
24 + Timestamp time.Time // Timestamp for stateful operations
25 + IsError bool // True if this value represents an error from a previous step
26 +}
27 +
28 +// StepType represents the type of preprocessing step.
29 +// Type IDs match Zabbix preprocessing types exactly (ZBX_PREPROC_* from zbxcommon.h)
30 +type StepType int
31 +
32 +const (
33 + // Zabbix preprocessing types (1-30) - MUST match zbxcommon.h exactly
34 + StepTypeMultiplier StepType = 1 // ZBX_PREPROC_MULTIPLIER
35 + StepTypeRTrim StepType = 2 // ZBX_PREPROC_RTRIM
36 + StepTypeLTrim StepType = 3 // ZBX_PREPROC_LTRIM
37 + StepTypeTrim StepType = 4 // ZBX_PREPROC_TRIM
38 + StepTypeRegexSubstitution StepType = 5 // ZBX_PREPROC_REGSUB
39 + StepTypeBool2Dec StepType = 6 // ZBX_PREPROC_BOOL2DEC
40 + StepTypeOct2Dec StepType = 7 // ZBX_PREPROC_OCT2DEC
41 + StepTypeHex2Dec StepType = 8 // ZBX_PREPROC_HEX2DEC
42 + StepTypeDeltaValue StepType = 9 // ZBX_PREPROC_DELTA_VALUE
43 + StepTypeDeltaSpeed StepType = 10 // ZBX_PREPROC_DELTA_SPEED
44 + StepTypeXPath StepType = 11 // ZBX_PREPROC_XPATH
45 + StepTypeJSONPath StepType = 12 // ZBX_PREPROC_JSONPATH
46 + StepTypeValidateRange StepType = 13 // ZBX_PREPROC_VALIDATE_RANGE
47 + StepTypeValidateRegex StepType = 14 // ZBX_PREPROC_VALIDATE_REGEX
48 + StepTypeValidateNotRegex StepType = 15 // ZBX_PREPROC_VALIDATE_NOT_REGEX
49 + StepTypeErrorFieldJSON StepType = 16 // ZBX_PREPROC_ERROR_FIELD_JSON
50 + StepTypeErrorFieldXML StepType = 17 // ZBX_PREPROC_ERROR_FIELD_XML
51 + StepTypeErrorFieldRegex StepType = 18 // ZBX_PREPROC_ERROR_FIELD_REGEX
52 + StepTypeThrottleValue StepType = 19 // ZBX_PREPROC_THROTTLE_VALUE
53 + StepTypeThrottleTimedValue StepType = 20 // ZBX_PREPROC_THROTTLE_TIMED_VALUE
54 + StepTypeJavaScript StepType = 21 // ZBX_PREPROC_SCRIPT
55 + StepTypePrometheusPattern StepType = 22 // ZBX_PREPROC_PROMETHEUS_PATTERN
56 + StepTypePrometheusToJSON StepType = 23 // ZBX_PREPROC_PROMETHEUS_TO_JSON
57 + StepTypeCSVToJSON StepType = 24 // ZBX_PREPROC_CSV_TO_JSON
58 + StepTypeStringReplace StepType = 25 // ZBX_PREPROC_STR_REPLACE
59 + StepTypeValidateNotSupported StepType = 26 // ZBX_PREPROC_VALIDATE_NOT_SUPPORTED
60 + StepTypeXMLToJSON StepType = 27 // ZBX_PREPROC_XML_TO_JSON
61 + StepTypeSNMPWalkValue StepType = 28 // ZBX_PREPROC_SNMP_WALK_VALUE
62 + StepTypeSNMPWalkToJSON StepType = 29 // ZBX_PREPROC_SNMP_WALK_TO_JSON
63 + StepTypeSNMPGetValue StepType = 30 // ZBX_PREPROC_SNMP_GET_VALUE
64 +
65 + // Multi-metric step types (60+) - return multiple Metric objects instead of single JSON string
66 + // These are EXTENSIONS to Zabbix, not part of official spec
67 + StepTypePrometheusToJSONMulti StepType = 60 // Extended: Prometheus to multi-metric array
68 + StepTypeJSONPathMulti StepType = 61 // Extended: JSONPath array extraction
69 + StepTypeSNMPWalkToJSONMulti StepType = 62 // Extended: SNMP walk to multi-metric array
70 + StepTypeCSVToJSONMulti StepType = 63 // Extended: CSV to multi-metric array
71 +)
72 +
73 +// ErrorAction defines how preprocessing errors are handled.
74 +type ErrorAction int
75 +
76 +const (
77 + ErrorActionDefault ErrorAction = 0 // Return error
78 + ErrorActionDiscard ErrorAction = 1 // Discard value (return empty)
79 + ErrorActionSetValue ErrorAction = 2 // Set custom value
80 + ErrorActionSetError ErrorAction = 3 // Set custom error message
81 +)
82 +
83 +// ErrorHandler defines error handling behavior for a preprocessing step.
84 +type ErrorHandler struct {
85 + Action ErrorAction
86 + Params string
87 +}
88 +
89 +// Step represents a single preprocessing step.
90 +type Step struct {
91 + Type StepType
92 + Params string
93 + ErrorHandler ErrorHandler
94 +}
95 +
96 +// Metric represents a single metric output from preprocessing.
97 +type Metric struct {
98 + Name string // Metric name
99 + Value string // Metric value
100 + Type ValueType // Value type
101 + Labels map[string]string // Optional labels
102 +}
103 +
104 +// Result represents the result of preprocessing a value.
105 +type Result struct {
106 + Metrics []Metric // Array of extracted metrics
107 + Logs []string // Optional log entries (future use)
108 + Error error // Overall error if preprocessing failed
109 + Discarded bool // True if value was intentionally discarded (e.g., throttling)
110 +}
111 +
112 +// Preprocessor executes preprocessing steps.
113 +// It maintains state for stateful operations (per shard instance).
114 +// Thread-safe for concurrent use.
115 +type Preprocessor struct {
116 + shardID string
117 + logger Logger // Logger for debugging (defaults to NoopLogger)
118 + limits Limits // Configurable limits for preprocessing operations
119 + mu sync.RWMutex // Protects state map access
120 + state map[string]*OperationState // Per-operation state keyed by operation ID
121 + cleanupTicker *time.Ticker // Ticker for periodic state cleanup (can be nil if disabled)
122 + cleanupDone chan struct{} // Signal to stop cleanup goroutine
123 + stateTTL time.Duration // TTL for inactive state entries (0 = no cleanup)
124 +}
125 +
126 +// OperationState tracks state for stateful operations like Delta and Throttle.
127 +type OperationState struct {
128 + LastValue string
129 + LastTimestamp time.Time
130 + LastValueTime time.Time
131 + LastAccess time.Time // Track last access for TTL-based cleanup
132 +}
133 +
134 +// Config holds configuration options for a Preprocessor instance.
135 +// All fields are optional - zero values use sensible defaults.
136 +type Config struct {
137 + // Logger for debugging (default: NoopLogger - zero overhead)
138 + Logger Logger
139 +
140 + // Limits for preprocessing operations (default: Zabbix-compatible limits)
141 + Limits Limits
142 +
143 + // StateTTL is the time-to-live for inactive state entries.
144 + // Default: 0 (no automatic cleanup)
145 + // Set to positive duration to enable automatic eviction.
146 + StateTTL time.Duration
147 +
148 + // StateCleanupInterval is how often to run the cleanup process.
149 + // Default: 0 (no automatic cleanup)
150 + // Only used if StateTTL > 0.
151 + StateCleanupInterval time.Duration
152 +}
153 +
154 +// NewPreprocessor creates a new preprocessor instance for a specific shard.
155 +// By default, no state cleanup is performed (stateTTL = 0).
156 +// Call EnableStateCleanup() to enable TTL-based eviction.
157 +func NewPreprocessor(shardID string) *Preprocessor {
158 + return &Preprocessor{
159 + shardID: shardID,
160 + logger: NoopLogger{}, // Default: no logging overhead
161 + limits: DefaultLimits(),
162 + state: make(map[string]*OperationState),
163 + stateTTL: 0, // Default: no cleanup
164 + }
165 +}
166 +
167 +// NewPreprocessorWithConfig creates a new preprocessor with custom configuration.
168 +// This allows per-shard customization of logging, timeouts, and cleanup policies.
169 +func NewPreprocessorWithConfig(shardID string, cfg Config) *Preprocessor {
170 + // Use default limits if not provided (zero value check)
171 + limits := cfg.Limits
172 + if limits.JavaScript.Timeout == 0 {
173 + limits = DefaultLimits()
174 + }
175 +
176 + p := &Preprocessor{
177 + shardID: shardID,
178 + limits: limits,
179 + state: make(map[string]*OperationState),
180 + stateTTL: cfg.StateTTL,
181 + }
182 +
183 + // Set logger (default to NoopLogger if not provided)
184 + if cfg.Logger != nil {
185 + p.logger = cfg.Logger
186 + } else {
187 + p.logger = NoopLogger{}
188 + }
189 +
190 + // Enable automatic state cleanup if configured
191 + if cfg.StateTTL > 0 && cfg.StateCleanupInterval > 0 {
192 + p.EnableStateCleanup(cfg.StateTTL, cfg.StateCleanupInterval)
193 + }
194 +
195 + return p
196 +}
197 +
198 +// EnableStateCleanup enables periodic cleanup of inactive state entries.
199 +// ttl: How long an entry can be inactive before being removed
200 +// cleanupInterval: How often to run the cleanup process
201 +// Returns a function to stop the cleanup goroutine (call on shutdown).
202 +func (p *Preprocessor) EnableStateCleanup(ttl, cleanupInterval time.Duration) func() {
203 + p.mu.Lock()
204 + defer p.mu.Unlock()
205 +
206 + // Stop existing cleanup if any
207 + if p.cleanupTicker != nil {
208 + p.cleanupTicker.Stop()
209 + if p.cleanupDone != nil {
210 + close(p.cleanupDone)
211 + }
212 + }
213 +
214 + p.stateTTL = ttl
215 + p.cleanupTicker = time.NewTicker(cleanupInterval)
216 + p.cleanupDone = make(chan struct{})
217 +
218 + // Start cleanup goroutine
219 + go p.runStateCleanup()
220 +
221 + // Return stop function
222 + return func() {
223 + p.mu.Lock()
224 + defer p.mu.Unlock()
225 + if p.cleanupTicker != nil {
226 + p.cleanupTicker.Stop()
227 + close(p.cleanupDone)
228 + p.cleanupTicker = nil
229 + p.cleanupDone = nil
230 + }
231 + }
232 +}
233 +
234 +// runStateCleanup periodically removes inactive state entries.
235 +func (p *Preprocessor) runStateCleanup() {
236 + for {
237 + select {
238 + case <-p.cleanupTicker.C:
239 + p.cleanupInactiveState()
240 + case <-p.cleanupDone:
241 + return
242 + }
243 + }
244 +}
245 +
246 +// cleanupInactiveState removes state entries that haven't been accessed within TTL.
247 +func (p *Preprocessor) cleanupInactiveState() {
248 + if p.stateTTL == 0 {
249 + return // Cleanup disabled
250 + }
251 +
252 + p.mu.Lock()
253 + defer p.mu.Unlock()
254 +
255 + now := time.Now()
256 + removed := 0
257 +
258 + for key, state := range p.state {
259 + if now.Sub(state.LastAccess) > p.stateTTL {
260 + delete(p.state, key)
261 + removed++
262 + }
263 + }
264 +
265 + if removed > 0 {
266 + p.logger.Debug("state cleanup completed",
267 + "shard", p.shardID,
268 + "removed", removed,
269 + "remaining", len(p.state))
270 + }
271 +}
272 +
273 +// ClearState removes all stored state entries for the given itemID within this shard.
274 +// Used when an item/instance is obsoleted so historical state does not leak to new entities.
275 +func (p *Preprocessor) ClearState(itemID string) {
276 + if itemID == "" {
277 + return
278 + }
279 + prefix := fmt.Sprintf("%s:%s:", p.shardID, itemID)
280 + p.mu.Lock()
281 + defer p.mu.Unlock()
282 + for key := range p.state {
283 + if strings.HasPrefix(key, prefix) {
284 + delete(p.state, key)
285 + }
286 + }
287 +}
288 +
289 +// SetLogger sets the logger for this preprocessor instance.
290 +// By default, NoopLogger is used (zero overhead).
291 +// This can be called to enable debug logging for troubleshooting.
292 +func (p *Preprocessor) SetLogger(logger Logger) {
293 + p.logger = logger
294 +}
295 +
296 +// PreloadState sets the history state for a stateful operation.
297 +// This is primarily for testing - allows proper setup of delta/throttle state.
298 +// itemID: The item identifier
299 +// stepType: The step type (must be stateful: Delta, Throttle)
300 +// lastValue: The previous value
301 +// lastTimestamp: The timestamp of the previous value
302 +func (p *Preprocessor) PreloadState(itemID string, stepType StepType, lastValue string, lastTimestamp time.Time) error {
303 + var stateKey string
304 + switch stepType {
305 + case StepTypeDeltaValue:
306 + stateKey = fmt.Sprintf("%s:%s:delta_value", p.shardID, itemID)
307 + case StepTypeDeltaSpeed:
308 + stateKey = fmt.Sprintf("%s:%s:delta_speed", p.shardID, itemID)
309 + case StepTypeThrottleValue:
310 + stateKey = fmt.Sprintf("%s:%s:throttle_value", p.shardID, itemID)
311 + case StepTypeThrottleTimedValue:
312 + stateKey = fmt.Sprintf("%s:%s:throttle_timed", p.shardID, itemID)
313 + default:
314 + return fmt.Errorf("step type %d is not stateful", stepType)
315 + }
316 +
317 + p.mu.Lock()
318 + p.state[stateKey] = &OperationState{
319 + LastValue: lastValue,
320 + LastValueTime: lastTimestamp,
321 + LastTimestamp: lastTimestamp,
322 + LastAccess: time.Now(),
323 + }
324 + p.mu.Unlock()
325 +
326 + return nil
327 +}
328 +
329 +// Execute applies a single preprocessing step to a value for a specific item.
330 +// itemID: Unique identifier for the item within this shard (used for state isolation)
331 +// value: The input value to process
332 +// step: The preprocessing step to apply
333 +// Returns: Result containing metrics array, optional logs, and error if failed
334 +func (p *Preprocessor) Execute(itemID string, value Value, step Step) (Result, error) {
335 + if err := validateStep(step); err != nil {
336 + return Result{Error: err}, err
337 + }
338 +
339 + result, err := p.executeStep(itemID, value, step)
340 + if err != nil {
341 + handledValue, handledErr := p.handleError(err, step.ErrorHandler)
342 + return valueToResult(handledValue, handledErr), handledErr
343 + }
344 + return result, nil
345 +}
346 +
347 +// ExecutePipeline applies multiple preprocessing steps in sequence for a specific item.
348 +// itemID: Unique identifier for the item within this shard (used for state isolation)
349 +// value: The input value to process
350 +// steps: The preprocessing steps to apply in order
351 +// Returns: Result containing metrics array, optional logs, and error if failed
352 +//
353 +// IsError Semantics:
354 +// The Value.IsError flag indicates whether the INPUT value originated from an error condition
355 +// (e.g., failed data collection in Zabbix). This flag is preserved through the entire pipeline
356 +// because it's a property of the original data source, not the preprocessing results.
357 +// - IsError=true: Original data came from an error (used by validateNotSupported)
358 +// - IsError=false: Original data was successfully collected
359 +// This flag does NOT change based on step success/failure - those are separate concerns.
360 +// Step failures return errors; IsError tracks input data provenance.
361 +type pipelineValue struct {
362 + value Value
363 + meta Metric
364 +}
365 +
366 +func (p *Preprocessor) ExecutePipeline(itemID string, value Value, steps []Step) (Result, error) {
367 + values := []pipelineValue{{value: value}}
368 + for i, step := range steps {
369 + next := make([]pipelineValue, 0, len(values))
370 + for _, pv := range values {
371 + result, err := p.Execute(itemID, pv.value, step)
372 + if err != nil {
373 + pipelineErr := fmt.Errorf("step %d failed: %w", i, err)
374 + return Result{Error: pipelineErr}, pipelineErr
375 + }
376 + if result.Discarded || len(result.Metrics) == 0 {
377 + return Result{Discarded: true}, nil
378 + }
379 + if isMultiStepType(step.Type) {
380 + for _, metric := range result.Metrics {
381 + next = append(next, pipelineValue{
382 + value: Value{
383 + Data: metric.Value,
384 + Type: metric.Type,
385 + Timestamp: pv.value.Timestamp,
386 + IsError: pv.value.IsError,
387 + },
388 + meta: metric,
389 + })
390 + }
391 + } else {
392 + metric := result.Metrics[0]
393 + next = append(next, pipelineValue{
394 + value: Value{
395 + Data: metric.Value,
396 + Type: metric.Type,
397 + Timestamp: pv.value.Timestamp,
398 + IsError: pv.value.IsError,
399 + },
400 + })
401 + }
402 + }
403 + if len(next) == 0 {
404 + return Result{Discarded: true}, nil
405 + }
406 + values = next
407 + }
408 + metrics := make([]Metric, len(values))
409 + for i, pv := range values {
410 + meta := pv.meta
411 + metrics[i] = Metric{
412 + Name: meta.Name,
413 + Value: pv.value.Data,
414 + Type: pv.value.Type,
415 + Labels: meta.Labels,
416 + }
417 + }
418 + return Result{Metrics: metrics}, nil
419 +}
420 +
421 +func isMultiStepType(t StepType) bool {
422 + switch t {
423 + case StepTypePrometheusToJSONMulti,
424 + StepTypeJSONPathMulti,
425 + StepTypeSNMPWalkToJSONMulti,
426 + StepTypeCSVToJSONMulti:
427 + return true
428 + default:
429 + return false
430 + }
431 +}
432 +
433 +// valueToResult converts a Value to a Result with a single metric
434 +func valueToResult(value Value, err error) Result {
435 + if err != nil {
436 + return Result{Error: err}
437 + }
438 + return Result{
439 + Metrics: []Metric{{
440 + Name: "",
441 + Value: value.Data,
442 + Type: value.Type,
443 + }},
444 + Error: nil,
445 + }
446 +}
447 +
448 +func (p *Preprocessor) executeStep(itemID string, value Value, step Step) (Result, error) {
449 + // Multi-metric steps - return Result with multiple Metric objects directly
450 + // These are NEW step types (60+) that don't break Zabbix compatibility
451 + switch step.Type {
452 + case StepTypePrometheusToJSONMulti:
453 + return prometheusToJSONMulti(value, step.Params)
454 + case StepTypeJSONPathMulti:
455 + return jsonPathMulti(value, step.Params)
456 + case StepTypeSNMPWalkToJSONMulti:
457 + return snmpWalkToJSONMulti(value, step.Params, p.logger)
458 + case StepTypeCSVToJSONMulti:
459 + return csvToJSONMulti(value, step.Params)
460 + }
461 +
462 + // Single-metric steps - execute and wrap with valueToResult()
463 + // These maintain Zabbix compatibility (original step types 1-29)
464 + var result Value
465 + var err error
466 +
467 + switch step.Type {
468 + case StepTypeMultiplier:
469 + result, err = multiplyValue(value, step.Params)
470 + case StepTypeTrim:
471 + result, err = trimValue(value, step.Params, "both")
472 + case StepTypeRTrim:
473 + result, err = trimValue(value, step.Params, "right")
474 + case StepTypeLTrim:
475 + result, err = trimValue(value, step.Params, "left")
476 + case StepTypeRegexSubstitution:
477 + result, err = regexSubstitute(value, step.Params)
478 + case StepTypeBool2Dec:
479 + result, err = bool2Dec(value)
480 + case StepTypeOct2Dec:
481 + result, err = oct2Dec(value)
482 + case StepTypeHex2Dec:
483 + result, err = hex2Dec(value)
484 + case StepTypeDeltaValue:
485 + result, err = p.deltaValue(itemID, value, step.Params)
486 + case StepTypeDeltaSpeed:
487 + result, err = p.deltaSpeed(itemID, value, step.Params)
488 + case StepTypeStringReplace:
489 + result, err = stringReplace(value, step.Params)
490 + case StepTypeValidateRange:
491 + result, err = validateRange(value, step.Params)
492 + case StepTypeValidateRegex:
493 + result, err = validateRegex(value, step.Params)
494 + case StepTypeValidateNotRegex:
495 + result, err = validateNotRegex(value, step.Params)
496 + case StepTypeValidateNotSupported:
497 + result, err = validateNotSupported(value, step.Params)
498 + case StepTypeJSONPath:
499 + result, err = jsonpathExtract(value, step.Params)
500 + case StepTypeXPath:
501 + result, err = xpathExtract(value, step.Params)
502 + case StepTypePrometheusPattern:
503 + result, err = prometheusPattern(value, step.Params)
504 + case StepTypePrometheusToJSON:
505 + result, err = prometheusToJSON(value, step.Params)
506 + case StepTypeCSVToJSON:
507 + result, err = csvToJSON(value, step.Params)
508 + case StepTypeXMLToJSON:
509 + result, err = xmlToJSON(value, step.Params)
510 + case StepTypeErrorFieldJSON:
511 + result, err = errorFieldJSON(value, step.Params)
512 + case StepTypeErrorFieldXML:
513 + result, err = errorFieldXML(value, step.Params)
514 + case StepTypeErrorFieldRegex:
515 + result, err = errorFieldRegex(value, step.Params)
516 + case StepTypeThrottleValue:
517 + result, err = p.throttleValue(itemID, value, step.Params)
518 + case StepTypeThrottleTimedValue:
519 + result, err = p.throttleTimedValue(itemID, value, step.Params)
520 + case StepTypeJavaScript:
521 + result, err = javascriptExecute(value, step.Params, p.limits.JavaScript)
522 + case StepTypeSNMPWalkValue:
523 + result, err = snmpWalkToValue(value, step.Params)
524 + case StepTypeSNMPGetValue:
525 + result, err = snmpGetValue(value, step.Params)
526 + case StepTypeSNMPWalkToJSON:
527 + result, err = snmpWalkToJSON(value, step.Params, p.logger)
528 + default:
529 + return Result{Error: fmt.Errorf("unsupported preprocessing type: %d", step.Type)}, fmt.Errorf("unsupported preprocessing type: %d", step.Type)
530 + }
531 +
532 + return valueToResult(result, err), err
533 +}
534 +
535 +func (p *Preprocessor) handleError(err error, handler ErrorHandler) (Value, error) {
536 + switch handler.Action {
537 + case ErrorActionDefault:
538 + return Value{}, err
539 + case ErrorActionDiscard:
540 + // Zabbix behavior: discard means replace error with empty string value
541 + return Value{Data: "", Type: ValueTypeStr}, nil
542 + case ErrorActionSetValue:
543 + return Value{Data: handler.Params, Type: ValueTypeStr}, nil
544 + case ErrorActionSetError:
545 + return Value{}, fmt.Errorf("%s", handler.Params)
546 + default:
547 + return Value{}, err
548 + }
549 +}
550 +
551 +func validateStep(step Step) error {
552 + if step.Type < 0 {
553 + return fmt.Errorf("invalid step type: %d", step.Type)
554 + }
555 + return nil
556 +}
557 +
558 +func parseValueType(s string) (ValueType, error) {
559 + switch s {
560 + case "ITEM_VALUE_TYPE_STR":
561 + return ValueTypeStr, nil
562 + case "ITEM_VALUE_TYPE_UINT64":
563 + return ValueTypeUint64, nil
564 + case "ITEM_VALUE_TYPE_FLOAT":
565 + return ValueTypeFloat, nil
566 + default:
567 + return ValueTypeStr, fmt.Errorf("unknown value type: %s", s)
568 + }
569 +}
570 +
571 +func valueTypeToString(vt ValueType) string {
572 + switch vt {
573 + case ValueTypeStr:
574 + return "ITEM_VALUE_TYPE_STR"
575 + case ValueTypeUint64:
576 + return "ITEM_VALUE_TYPE_UINT64"
577 + case ValueTypeFloat:
578 + return "ITEM_VALUE_TYPE_FLOAT"
579 + default:
580 + return "UNKNOWN"
581 + }
582 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/preproc_pipeline_test.go new
+22
@@ -0,0 +1,22 @@
1 +package zabbixpreproc
2 +
3 +import "testing"
4 +
5 +func TestExecutePipelineCSVToJSONMultiChained(t *testing.T) {
6 + proc := NewPreprocessor("csv-multi-test")
7 + steps := []Step{
8 + {Type: StepTypeCSVToJSONMulti, Params: ",\n\"\n1"},
9 + {Type: StepTypeJavaScript, Params: `var row = JSON.parse(value); value = row["value"]; return value;`},
10 + }
11 + payload := "name,value\nrow1,10\nrow2,20"
12 + res, err := proc.ExecutePipeline("item1", Value{Data: payload, Type: ValueTypeStr}, steps)
13 + if err != nil {
14 + t.Fatalf("ExecutePipeline error: %v", err)
15 + }
16 + if len(res.Metrics) != 2 {
17 + t.Fatalf("expected 2 metrics, got %d", len(res.Metrics))
18 + }
19 + if res.Metrics[0].Value != "10" || res.Metrics[1].Value != "20" {
20 + t.Fatalf("unexpected values: %+v", res.Metrics)
21 + }
22 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/preproc_race_test.go new
+266
@@ -0,0 +1,266 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "fmt"
5 + "sync"
6 + "testing"
7 + "time"
8 +)
9 +
10 +// TestConcurrentDeltaValue tests concurrent access to deltaValue operation
11 +func TestConcurrentDeltaValue(t *testing.T) {
12 + p := NewPreprocessor("test-shard")
13 + step := Step{
14 + Type: StepTypeDeltaValue,
15 + Params: "",
16 + }
17 +
18 + var wg sync.WaitGroup
19 + goroutines := 100
20 + iterations := 10
21 +
22 + for i := 0; i < goroutines; i++ {
23 + wg.Add(1)
24 + go func(id int) {
25 + defer wg.Done()
26 + itemID := fmt.Sprintf("item-%d", id) // Each goroutine uses different itemID
27 + for j := 0; j < iterations; j++ {
28 + value := Value{
29 + Data: fmt.Sprintf("%d", id*iterations+j),
30 + Type: ValueTypeFloat,
31 + Timestamp: time.Now(),
32 + }
33 + _, err := p.Execute(itemID, value, step)
34 + // First call returns error (no previous value), subsequent calls should succeed
35 + if err != nil && j > 0 {
36 + t.Errorf("goroutine %d iteration %d: unexpected error: %v", id, j, err)
37 + }
38 + }
39 + }(i)
40 + }
41 +
42 + wg.Wait()
43 +}
44 +
45 +// TestConcurrentDeltaSpeed tests concurrent access to deltaSpeed operation
46 +func TestConcurrentDeltaSpeed(t *testing.T) {
47 + p := NewPreprocessor("test-shard")
48 + step := Step{
49 + Type: StepTypeDeltaSpeed,
50 + Params: "",
51 + }
52 +
53 + var wg sync.WaitGroup
54 + goroutines := 100
55 + iterations := 10
56 +
57 + baseTime := time.Now()
58 +
59 + for i := 0; i < goroutines; i++ {
60 + wg.Add(1)
61 + go func(id int) {
62 + itemID := fmt.Sprintf("item-%d", id)
63 + defer wg.Done()
64 + for j := 0; j < iterations; j++ {
65 + value := Value{
66 + Data: fmt.Sprintf("%d", id*iterations+j),
67 + Type: ValueTypeFloat,
68 + Timestamp: baseTime.Add(time.Duration(id*iterations+j) * time.Second),
69 + }
70 + _, err := p.Execute(itemID, value, step)
71 + // First call returns error (no previous value), subsequent calls should succeed
72 + if err != nil && j > 0 {
73 + t.Errorf("goroutine %d iteration %d: unexpected error: %v", id, j, err)
74 + }
75 + }
76 + }(i)
77 + }
78 +
79 + wg.Wait()
80 +}
81 +
82 +// TestConcurrentThrottleValue tests concurrent access to throttleValue operation
83 +func TestConcurrentThrottleValue(t *testing.T) {
84 + p := NewPreprocessor("test-shard")
85 + step := Step{
86 + Type: StepTypeThrottleValue,
87 + Params: "",
88 + }
89 +
90 + var wg sync.WaitGroup
91 + goroutines := 100
92 + iterations := 10
93 +
94 + for i := 0; i < goroutines; i++ {
95 + wg.Add(1)
96 + go func(id int) {
97 + itemID := fmt.Sprintf("item-%d", id)
98 + defer wg.Done()
99 + for j := 0; j < iterations; j++ {
100 + value := Value{
101 + Data: fmt.Sprintf("value-%d-%d", id, j),
102 + Type: ValueTypeStr,
103 + Timestamp: time.Now(),
104 + }
105 + _, err := p.Execute(itemID, value, step)
106 + if err != nil {
107 + t.Errorf("goroutine %d iteration %d: unexpected error: %v", id, j, err)
108 + }
109 + }
110 + }(i)
111 + }
112 +
113 + wg.Wait()
114 +}
115 +
116 +// TestConcurrentThrottleTimedValue tests concurrent access to throttleTimedValue operation
117 +func TestConcurrentThrottleTimedValue(t *testing.T) {
118 + p := NewPreprocessor("test-shard")
119 + step := Step{
120 + Type: StepTypeThrottleTimedValue,
121 + Params: "1s",
122 + }
123 +
124 + var wg sync.WaitGroup
125 + goroutines := 100
126 + iterations := 10
127 +
128 + baseTime := time.Now()
129 +
130 + for i := 0; i < goroutines; i++ {
131 + wg.Add(1)
132 + go func(id int) {
133 + itemID := fmt.Sprintf("item-%d", id)
134 + defer wg.Done()
135 + for j := 0; j < iterations; j++ {
136 + value := Value{
137 + Data: fmt.Sprintf("value-%d-%d", id, j),
138 + Type: ValueTypeStr,
139 + Timestamp: baseTime.Add(time.Duration(id*iterations+j) * time.Millisecond * 100),
140 + }
141 + _, err := p.Execute(itemID, value, step)
142 + if err != nil {
143 + t.Errorf("goroutine %d iteration %d: unexpected error: %v", id, j, err)
144 + }
145 + }
146 + }(i)
147 + }
148 +
149 + wg.Wait()
150 +}
151 +
152 +// TestConcurrentMixedOperations tests concurrent access to mixed stateful operations
153 +func TestConcurrentMixedOperations(t *testing.T) {
154 + p := NewPreprocessor("test-shard")
155 +
156 + steps := []Step{
157 + {Type: StepTypeDeltaValue, Params: ""},
158 + {Type: StepTypeDeltaSpeed, Params: ""},
159 + {Type: StepTypeThrottleValue, Params: ""},
160 + {Type: StepTypeThrottleTimedValue, Params: "1s"},
161 + }
162 +
163 + var wg sync.WaitGroup
164 + goroutines := 50
165 + iterations := 10
166 +
167 + baseTime := time.Now()
168 +
169 + for i := 0; i < goroutines; i++ {
170 + wg.Add(1)
171 + go func(id int) {
172 + itemID := fmt.Sprintf("item-%d", id)
173 + defer wg.Done()
174 + step := steps[id%len(steps)]
175 + for j := 0; j < iterations; j++ {
176 + value := Value{
177 + Data: fmt.Sprintf("%d", id*iterations+j),
178 + Type: ValueTypeFloat,
179 + Timestamp: baseTime.Add(time.Duration(id*iterations+j) * time.Millisecond * 50),
180 + }
181 + _, err := p.Execute(itemID, value, step)
182 + // Delta operations fail on first call (no previous value)
183 + if err != nil && (step.Type == StepTypeDeltaValue || step.Type == StepTypeDeltaSpeed) && j > 0 {
184 + t.Errorf("goroutine %d iteration %d: unexpected error: %v", id, j, err)
185 + }
186 + }
187 + }(i)
188 + }
189 +
190 + wg.Wait()
191 +}
192 +
193 +// TestConcurrentPipelines tests concurrent execution of preprocessing pipelines
194 +func TestConcurrentPipelines(t *testing.T) {
195 + p := NewPreprocessor("test-shard")
196 +
197 + // Pipeline with multiple stateful operations
198 + pipeline := []Step{
199 + {Type: StepTypeTrim, Params: " "},
200 + {Type: StepTypeDeltaValue, Params: ""},
201 + {Type: StepTypeThrottleValue, Params: ""},
202 + }
203 +
204 + var wg sync.WaitGroup
205 + goroutines := 50
206 + iterations := 10
207 +
208 + for i := 0; i < goroutines; i++ {
209 + wg.Add(1)
210 + go func(id int) {
211 + itemID := fmt.Sprintf("item-%d", id)
212 + defer wg.Done()
213 + for j := 0; j < iterations; j++ {
214 + value := Value{
215 + Data: fmt.Sprintf(" %d ", id*iterations+j),
216 + Type: ValueTypeFloat,
217 + Timestamp: time.Now(),
218 + }
219 + _, err := p.ExecutePipeline(itemID, value, pipeline)
220 + // First call will fail at deltaValue (no previous value)
221 + if err != nil && j > 0 {
222 + t.Errorf("goroutine %d iteration %d: unexpected error: %v", id, j, err)
223 + }
224 + }
225 + }(i)
226 + }
227 +
228 + wg.Wait()
229 +}
230 +
231 +// TestConcurrentMultiplePreprocessors tests that separate preprocessor instances are isolated
232 +func TestConcurrentMultiplePreprocessors(t *testing.T) {
233 + preprocessors := make([]*Preprocessor, 10)
234 + for i := range preprocessors {
235 + preprocessors[i] = NewPreprocessor(fmt.Sprintf("shard-%d", i))
236 + }
237 +
238 + step := Step{
239 + Type: StepTypeDeltaValue,
240 + Params: "",
241 + }
242 +
243 + var wg sync.WaitGroup
244 +
245 + for i, p := range preprocessors {
246 + wg.Add(1)
247 + go func(id int, preprocessor *Preprocessor) {
248 + itemID := fmt.Sprintf("item-%d", id)
249 + defer wg.Done()
250 + for j := 0; j < 100; j++ {
251 + value := Value{
252 + Data: fmt.Sprintf("%d", j),
253 + Type: ValueTypeFloat,
254 + Timestamp: time.Now(),
255 + }
256 + _, err := preprocessor.Execute(itemID, value, step)
257 + // First call returns error (no previous value)
258 + if err != nil && j > 0 {
259 + t.Errorf("preprocessor %d iteration %d: unexpected error: %v", id, j, err)
260 + }
261 + }
262 + }(i, p)
263 + }
264 +
265 + wg.Wait()
266 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/preproc_test.go new
+117
@@ -0,0 +1,117 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "fmt"
5 + "testing"
6 +)
7 +
8 +func TestZabbixTestSuite(t *testing.T) {
9 + // Load ALL official Zabbix test suites
10 + testCases, err := LoadAllTestCases()
11 + if err != nil {
12 + t.Fatalf("Failed to load test cases: %v", err)
13 + }
14 +
15 + passed := 0
16 + failed := 0
17 + skipped := 0
18 +
19 + for _, tc := range testCases {
20 + // Capture tc in closure for t.Run
21 + tc := tc
22 + t.Run(tc.Name, func(t *testing.T) {
23 + // Create a fresh preprocessor for each test to isolate state
24 + preprocessor := NewPreprocessor("test-shard")
25 + // Parse test input
26 + value, err := ValueFromTestInput(tc)
27 + if err != nil {
28 + t.Skipf("Failed to parse input: %v", err)
29 + skipped++
30 + return
31 + }
32 +
33 + step, err := StepFromTestInput(tc)
34 + if err != nil {
35 + t.Skipf("Failed to parse step: %v", err)
36 + skipped++
37 + return
38 + }
39 +
40 + // Load history value if provided (for stateful operations like delta, throttle)
41 + historyVal := HistoryValueFromTestInput(tc)
42 + itemID := "test-item" // Use consistent itemID for tests
43 + if historyVal != nil {
44 + // Pre-populate state through public API (no direct state manipulation)
45 + if err := preprocessor.PreloadState(itemID, step.Type, historyVal.Data, historyVal.Timestamp); err != nil {
46 + // Not a stateful operation, ignore
47 + t.Logf("Note: history value provided but step type %d is not stateful", step.Type)
48 + }
49 + }
50 +
51 + // Execute preprocessing with itemID
52 + result, err := preprocessor.Execute(itemID, value, step)
53 +
54 + // Check expected outcome
55 + if tc.Out.Return == "SUCCEED" {
56 + if err != nil {
57 + t.Errorf("Expected success but got error: %v", err)
58 + failed++
59 + return
60 + }
61 +
62 + // Extract result value from first metric
63 + if len(result.Metrics) == 0 {
64 + t.Errorf("No metrics returned")
65 + failed++
66 + return
67 + }
68 + resultData := result.Metrics[0].Value
69 +
70 + expected, _ := ExpectedOutputFromTestCase(tc)
71 + if resultData != expected {
72 + // Debug: print input details for trim tests
73 + if step.Type == StepTypeRTrim || step.Type == StepTypeLTrim || step.Type == StepTypeTrim {
74 + paramsDesc := ""
75 + for i, ch := range step.Params {
76 + if i > 0 {
77 + paramsDesc += ", "
78 + }
79 + paramsDesc += fmt.Sprintf("%c(0x%x)", ch, ch)
80 + }
81 + t.Logf("DEBUG: input='%s' params='%s'[%s] (len=%d) expected='%s' (len=%d) got='%s' (len=%d)", value.Data, step.Params, paramsDesc, len(step.Params), expected, len(expected), resultData, len(resultData))
82 + }
83 + t.Errorf("Expected '%s' but got '%s'", expected, resultData)
84 + failed++
85 + return
86 + }
87 +
88 + passed++
89 + } else { // FAIL expected
90 + if err == nil {
91 + resultData := ""
92 + if len(result.Metrics) > 0 {
93 + resultData = result.Metrics[0].Value
94 + }
95 + t.Errorf("Expected error but succeeded with '%s'", resultData)
96 + failed++
97 + return
98 + }
99 +
100 + passed++
101 + }
102 + })
103 + }
104 +
105 + fmt.Printf("\n=== Test Results ===\n")
106 + fmt.Printf("Passed: %d\n", passed)
107 + fmt.Printf("Failed: %d\n", failed)
108 + fmt.Printf("Skipped: %d\n", skipped)
109 + fmt.Printf("Total: %d\n", passed+failed+skipped)
110 + if passed+failed > 0 {
111 + fmt.Printf("Pass Rate: %.1f%%\n", float64(passed)*100/float64(passed+failed))
112 + }
113 +
114 + if failed > 0 {
115 + t.Fatalf("%d tests failed", failed)
116 + }
117 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/prometheus_step.go new
+676
@@ -0,0 +1,676 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "encoding/json"
5 + "fmt"
6 + "regexp"
7 + "strconv"
8 + "strings"
9 +)
10 +
11 +// Prometheus regex patterns compiled once at package init
12 +var (
13 + // Format: metric_name{label="value"} number
14 + prometheusMetricWithLabelsRegex = regexp.MustCompile(`^([a-zA-Z_:][a-zA-Z0-9_:]*)\{([^}]*)\}\s+([-+]?[\d.eE+-]+)`)
15 + // Format: metric_name number
16 + prometheusMetricWithoutLabelsRegex = regexp.MustCompile(`^([a-zA-Z_:][a-zA-Z0-9_:]*)\s+([-+]?[\d.eE+-]+)`)
17 + // Format: label_name="label_value" (NOTE: this regex doesn't handle escaped quotes)
18 + // Use parsePrometheusLabels() for proper escape handling
19 + prometheusLabelRegex = regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_]*)="([^"]*)"`)
20 +)
21 +
22 +// parsePrometheusLabels parses Prometheus label string with proper escape handling.
23 +// Handles \", \\, and \n escape sequences per Prometheus spec.
24 +// Input: `method="GET",path="/foo\"bar",code="200"`
25 +// Returns map and order of labels.
26 +func parsePrometheusLabels(labelsStr string) (map[string]string, []string) {
27 + labels := make(map[string]string)
28 + var labelOrder []string
29 +
30 + if strings.TrimSpace(labelsStr) == "" {
31 + return labels, labelOrder
32 + }
33 +
34 + i := 0
35 + n := len(labelsStr)
36 +
37 + for i < n {
38 + // Skip whitespace and commas
39 + for i < n && (labelsStr[i] == ' ' || labelsStr[i] == ',' || labelsStr[i] == '\t') {
40 + i++
41 + }
42 + if i >= n {
43 + break
44 + }
45 +
46 + // Parse label name (alphanumeric + underscore, starts with letter or underscore)
47 + nameStart := i
48 + for i < n && (labelsStr[i] == '_' || (labelsStr[i] >= 'a' && labelsStr[i] <= 'z') ||
49 + (labelsStr[i] >= 'A' && labelsStr[i] <= 'Z') || (labelsStr[i] >= '0' && labelsStr[i] <= '9')) {
50 + i++
51 + }
52 + if i == nameStart {
53 + // No valid label name found, skip rest
54 + break
55 + }
56 + labelName := labelsStr[nameStart:i]
57 +
58 + // Skip whitespace
59 + for i < n && (labelsStr[i] == ' ' || labelsStr[i] == '\t') {
60 + i++
61 + }
62 +
63 + // Expect '='
64 + if i >= n || labelsStr[i] != '=' {
65 + break
66 + }
67 + i++
68 +
69 + // Skip whitespace
70 + for i < n && (labelsStr[i] == ' ' || labelsStr[i] == '\t') {
71 + i++
72 + }
73 +
74 + // Expect opening quote
75 + if i >= n || labelsStr[i] != '"' {
76 + break
77 + }
78 + i++
79 +
80 + // Parse label value with escape handling
81 + var valueBuilder strings.Builder
82 + for i < n {
83 + if labelsStr[i] == '\\' && i+1 < n {
84 + // Escape sequence
85 + nextChar := labelsStr[i+1]
86 + switch nextChar {
87 + case '"':
88 + valueBuilder.WriteByte('"')
89 + i += 2
90 + case '\\':
91 + valueBuilder.WriteByte('\\')
92 + i += 2
93 + case 'n':
94 + valueBuilder.WriteByte('\n')
95 + i += 2
96 + default:
97 + // Unknown escape, keep as-is
98 + valueBuilder.WriteByte(labelsStr[i])
99 + i++
100 + }
101 + } else if labelsStr[i] == '"' {
102 + // End of value
103 + i++
104 + break
105 + } else {
106 + valueBuilder.WriteByte(labelsStr[i])
107 + i++
108 + }
109 + }
110 +
111 + labels[labelName] = valueBuilder.String()
112 + labelOrder = append(labelOrder, labelName)
113 + }
114 +
115 + return labels, labelOrder
116 +}
117 +
118 +// prometheusMetric represents a parsed Prometheus metric
119 +type prometheusMetric struct {
120 + Name string
121 + Labels map[string]string
122 + LabelOrder []string // Order of label keys as they appear in input
123 + Value float64
124 + ValueStr string // Original string representation of value
125 + LineRaw string
126 +}
127 +
128 +// parsePrometheusText parses Prometheus text exposition format.
129 +//
130 +// Parses metrics in Prometheus text format as defined by the Prometheus documentation.
131 +// Supports:
132 +// - Metrics with labels: http_requests_total{method="GET",code="200"} 1027
133 +// - Metrics without labels: process_cpu_seconds 4.2
134 +// - Scientific notation: very_large_value 1.23e+10
135 +// - Comment lines (ignored): # HELP and # TYPE comments
136 +// - Label validation: ensures all labels match pattern label_name="value"
137 +//
138 +// Example Prometheus format:
139 +//
140 +// # HELP http_requests_total Total HTTP requests
141 +// # TYPE http_requests_total counter
142 +// http_requests_total{method="GET"} 100
143 +// http_requests_total{method="POST"} 50
144 +// memory_usage_bytes 1024
145 +//
146 +// Parameters:
147 +// - data: Prometheus text format data (one metric per line)
148 +//
149 +// Returns:
150 +// - []prometheusMetric: Parsed metrics with names, labels, and values
151 +// - error: Returns nil on success (invalid lines are silently skipped per Prometheus spec)
152 +//
153 +// Note: This parser uses pre-compiled package-level regexes for performance.
154 +func parsePrometheusText(data string) ([]prometheusMetric, error) {
155 + var metrics []prometheusMetric
156 + lines := strings.Split(data, "\n")
157 +
158 + for _, line := range lines {
159 + originalLine := line
160 + line = strings.TrimSpace(line)
161 + if line == "" || strings.HasPrefix(line, "#") {
162 + continue
163 + }
164 +
165 + var metricName, labelsStr, valueStr string
166 + var labels map[string]string
167 +
168 + var labelOrder []string
169 +
170 + // Try with labels first
171 + matches := prometheusMetricWithLabelsRegex.FindStringSubmatch(line)
172 + if len(matches) >= 4 {
173 + metricName = matches[1]
174 + labelsStr = matches[2]
175 + valueStr = matches[3]
176 +
177 + labels = make(map[string]string)
178 +
179 + // Parse labels with proper escape handling (\", \\, \n)
180 + if strings.TrimSpace(labelsStr) != "" {
181 + labels, labelOrder = parsePrometheusLabels(labelsStr)
182 + if len(labels) == 0 {
183 + // Labels present but none valid - invalid syntax
184 + continue
185 + }
186 + }
187 + } else {
188 + // Try without labels
189 + matches = prometheusMetricWithoutLabelsRegex.FindStringSubmatch(line)
190 + if len(matches) < 3 {
191 + continue
192 + }
193 + metricName = matches[1]
194 + valueStr = matches[2]
195 + labels = make(map[string]string)
196 + }
197 +
198 + value, err := strconv.ParseFloat(valueStr, 64)
199 + if err != nil {
200 + continue
201 + }
202 +
203 + metrics = append(metrics, prometheusMetric{
204 + Name: metricName,
205 + Labels: labels,
206 + LabelOrder: labelOrder,
207 + Value: value,
208 + ValueStr: valueStr,
209 + LineRaw: strings.TrimSpace(originalLine),
210 + })
211 + }
212 +
213 + return metrics, nil
214 +}
215 +
216 +// parsePrometheusMetadata extracts HELP and TYPE comments from Prometheus text
217 +func parsePrometheusMetadata(data string) (map[string]string, map[string]string) {
218 + helpMap := make(map[string]string)
219 + typeMap := make(map[string]string)
220 +
221 + lines := strings.Split(data, "\n")
222 + for _, line := range lines {
223 + line = strings.TrimSpace(line)
224 + if !strings.HasPrefix(line, "#") {
225 + continue
226 + }
227 +
228 + // Parse # HELP metric_name description
229 + if strings.HasPrefix(line, "# HELP ") {
230 + parts := strings.SplitN(line[7:], " ", 2)
231 + if len(parts) == 2 {
232 + helpMap[parts[0]] = parts[1]
233 + }
234 + }
235 +
236 + // Parse # TYPE metric_name type
237 + if strings.HasPrefix(line, "# TYPE ") {
238 + parts := strings.SplitN(line[7:], " ", 2)
239 + if len(parts) == 2 {
240 + typeMap[parts[0]] = parts[1]
241 + }
242 + }
243 + }
244 +
245 + return helpMap, typeMap
246 +}
247 +
248 +// prometheusPattern extracts metric values matching a label pattern.
249 +func prometheusPattern(value Value, paramStr string) (Value, error) {
250 + // paramStr format: "metric_name{label_selectors}\noutput\nlabel_name"
251 + // output can be "value", "label"
252 + // If output is "label" and label_name is provided, return that specific label
253 + parts := strings.SplitN(paramStr, "\n", 3)
254 + if len(parts) < 1 {
255 + return Value{}, fmt.Errorf("prometheus pattern requires metric pattern")
256 + }
257 +
258 + // Parse metric pattern (e.g., "cpu_usage_system{cpu=\"cpu-total\",host=~\".*\"}")
259 + metricPattern := strings.TrimSpace(parts[0])
260 + outputType := "value" // Default
261 + labelName := ""
262 + if len(parts) > 1 {
263 + outputType = strings.TrimSpace(parts[1])
264 + }
265 + if len(parts) > 2 {
266 + labelName = strings.TrimSpace(parts[2])
267 + }
268 +
269 + // Extract metric name, label selectors, and value comparison from pattern
270 + metricName, labelSelectors, valueComp := parseMetricPattern(metricPattern)
271 +
272 + // Parse Prometheus text format
273 + metrics, err := parsePrometheusText(value.Data)
274 + if err != nil {
275 + return Value{}, fmt.Errorf("failed to parse prometheus data: %w", err)
276 + }
277 +
278 + // Find matching metric
279 + for _, m := range metrics {
280 + // Check if metric name matches
281 + // Priority: __name__ selector > explicit metric name > no filter
282 + nameSelector, hasNameSelector := labelSelectors["__name__"]
283 + if hasNameSelector {
284 + // Use __name__ selector to match metric name
285 + matched := false
286 + switch nameSelector.operator {
287 + case "=":
288 + matched = m.Name == nameSelector.value
289 + case "=~":
290 + re, err := compileRegexSafe(nameSelector.value, 0)
291 + if err != nil {
292 + continue
293 + }
294 + matched, err = matchWithTimeout(re, m.Name, defaultRegexTimeout)
295 + if err != nil {
296 + continue
297 + }
298 + case "!=":
299 + matched = m.Name != nameSelector.value
300 + }
301 + if !matched {
302 + continue
303 + }
304 + } else if metricName != "" {
305 + // Use metric name from pattern
306 + if !matchesPattern(m.Name, metricName) {
307 + continue
308 + }
309 + }
310 +
311 + // Filter out __name__ from label selectors (already checked above)
312 + actualLabelSelectors := make(map[string]labelSelector)
313 + for k, v := range labelSelectors {
314 + if k != "__name__" {
315 + actualLabelSelectors[k] = v
316 + }
317 + }
318 +
319 + if !matchesLabelSelectors(m.Labels, actualLabelSelectors) {
320 + continue
321 + }
322 +
323 + // Check value comparison if present
324 + if valueComp != nil {
325 + if !matchesValueComparison(m.Value, valueComp) {
326 + continue
327 + }
328 + }
329 +
330 + // Return based on output type
331 + switch outputType {
332 + case "value":
333 + return Value{Data: fmt.Sprintf("%g", m.Value), Type: ValueTypeStr}, nil
334 + case "label":
335 + if labelName != "" {
336 + // Return specific label value
337 + if val, ok := m.Labels[labelName]; ok {
338 + return Value{Data: val, Type: ValueTypeStr}, nil
339 + }
340 + return Value{}, fmt.Errorf("label %s not found", labelName)
341 + }
342 + // Return all labels as comma-separated key=value pairs
343 + var labelPairs []string
344 + for k, v := range m.Labels {
345 + labelPairs = append(labelPairs, fmt.Sprintf("%s=%s", k, v))
346 + }
347 + return Value{Data: strings.Join(labelPairs, ","), Type: ValueTypeStr}, nil
348 + default:
349 + // Unknown output type
350 + return Value{}, fmt.Errorf("unknown output type: %s", outputType)
351 + }
352 + }
353 +
354 + return Value{}, fmt.Errorf("prometheus pattern did not match any metrics")
355 +}
356 +
357 +// prometheusToJSON converts Prometheus text format to JSON.
358 +// prometheusToJSONMulti converts Prometheus text format to multiple Result metrics
359 +// Each Prometheus metric becomes a separate Result.Metric with labels
360 +func prometheusToJSONMulti(value Value, paramStr string) (Result, error) {
361 + // Parse Prometheus text format
362 + metrics, err := parsePrometheusText(value.Data)
363 + if err != nil {
364 + return Result{Error: err}, err
365 + }
366 +
367 + // If input is non-empty but no metrics were parsed, it's invalid data
368 + if len(metrics) == 0 && strings.TrimSpace(value.Data) != "" {
369 + err := fmt.Errorf("failed to parse prometheus data: no valid metrics found")
370 + return Result{Error: err}, err
371 + }
372 +
373 + // Convert each Prometheus metric to Result.Metric
374 + resultMetrics := make([]Metric, 0, len(metrics))
375 + for _, pm := range metrics {
376 + resultMetrics = append(resultMetrics, Metric{
377 + Name: pm.Name,
378 + Value: pm.ValueStr,
379 + Type: ValueTypeStr,
380 + Labels: pm.Labels, // Prometheus labels preserved
381 + })
382 + }
383 +
384 + return Result{Metrics: resultMetrics}, nil
385 +}
386 +
387 +func prometheusToJSON(value Value, paramStr string) (Value, error) {
388 + // Parse Prometheus text format
389 + metrics, err := parsePrometheusText(value.Data)
390 + if err != nil {
391 + return Value{}, fmt.Errorf("failed to parse prometheus data: %w", err)
392 + }
393 +
394 + // If input is non-empty but no metrics were parsed, it's invalid data
395 + if len(metrics) == 0 && strings.TrimSpace(value.Data) != "" {
396 + return Value{}, fmt.Errorf("failed to parse Prometheus data: no valid metrics found")
397 + }
398 +
399 + // Parse metadata (HELP and TYPE comments)
400 + helpMap, typeMap := parsePrometheusMetadata(value.Data)
401 +
402 + // Build result array with ordered fields
403 + var jsonParts []string
404 +
405 + for _, m := range metrics {
406 + // Build JSON manually to maintain field order: name, value, line_raw, labels, type, help
407 + var parts []string
408 +
409 + parts = append(parts, fmt.Sprintf(`"name":%s`, jsonString(m.Name)))
410 + parts = append(parts, fmt.Sprintf(`"value":%s`, jsonString(m.ValueStr)))
411 + parts = append(parts, fmt.Sprintf(`"line_raw":%s`, jsonString(m.LineRaw)))
412 +
413 + if len(m.Labels) > 0 {
414 + // Build labels JSON with preserved order
415 + var labelPairs []string
416 + for _, key := range m.LabelOrder {
417 + labelPairs = append(labelPairs, fmt.Sprintf(`%s:%s`, jsonString(key), jsonString(m.Labels[key])))
418 + }
419 + parts = append(parts, fmt.Sprintf(`"labels":{%s}`, strings.Join(labelPairs, ",")))
420 + }
421 +
422 + // Add type (default to "untyped")
423 + metricType := "untyped"
424 + if t, ok := typeMap[m.Name]; ok {
425 + metricType = t
426 + }
427 + parts = append(parts, fmt.Sprintf(`"type":%s`, jsonString(metricType)))
428 +
429 + // Add help if available
430 + if helpText, ok := helpMap[m.Name]; ok {
431 + parts = append(parts, fmt.Sprintf(`"help":%s`, jsonString(helpText)))
432 + }
433 +
434 + jsonParts = append(jsonParts, "{"+strings.Join(parts, ",")+"}")
435 + }
436 +
437 + resultJSON := "[" + strings.Join(jsonParts, ",") + "]"
438 + return Value{Data: resultJSON, Type: ValueTypeStr}, nil
439 +}
440 +
441 +// jsonString escapes a string for JSON
442 +func jsonString(s string) string {
443 + b, _ := json.Marshal(s)
444 + return string(b)
445 +}
446 +
447 +// matchesPattern checks if a string matches a simple wildcard pattern
448 +func matchesPattern(s, pattern string) bool {
449 + // Zabbix uses simple glob patterns, convert to regex
450 + regexPattern := regexp.QuoteMeta(pattern)
451 + regexPattern = strings.ReplaceAll(regexPattern, `\*`, ".*")
452 + regexPattern = strings.ReplaceAll(regexPattern, `\?`, ".")
453 + regexPattern = "^" + regexPattern + "$"
454 + re, err := compileRegexSafe(regexPattern, 0)
455 + if err != nil {
456 + return false
457 + }
458 + matched, err := matchWithTimeout(re, s, defaultRegexTimeout)
459 + if err != nil {
460 + return false
461 + }
462 + return matched
463 +}
464 +
465 +// parseMetricPattern parses a metric pattern like "metric_name{label=\"value\",label2=~\"regex\"} == 123.45"
466 +// Returns the metric name, label selectors, and optional value comparison
467 +func parseMetricPattern(pattern string) (string, map[string]labelSelector, *valueComparison) {
468 + // Check for value comparison operators (==, !=, >, <, >=, <=)
469 + var valueComp *valueComparison
470 +
471 + // Find value comparison operator outside of braces
472 + braceDepth := 0
473 + var compIdx int = -1
474 + var compOp string
475 +
476 + for i := 0; i < len(pattern); i++ {
477 + if pattern[i] == '{' {
478 + braceDepth++
479 + } else if pattern[i] == '}' {
480 + braceDepth--
481 + } else if braceDepth == 0 {
482 + // Check for comparison operators outside braces
483 + if i+2 <= len(pattern) {
484 + twoChar := pattern[i:min(i+2, len(pattern))]
485 + if twoChar == "==" || twoChar == "!=" || twoChar == ">=" || twoChar == "<=" {
486 + compOp = twoChar
487 + compIdx = i
488 + break
489 + }
490 + }
491 + if pattern[i] == '>' || pattern[i] == '<' {
492 + compOp = string(pattern[i])
493 + compIdx = i
494 + break
495 + }
496 + }
497 + }
498 +
499 + // Extract value comparison if present
500 + if compIdx != -1 {
501 + valueStr := strings.TrimSpace(pattern[compIdx+len(compOp):])
502 + pattern = strings.TrimSpace(pattern[:compIdx])
503 + if valueStr != "" {
504 + val, err := strconv.ParseFloat(valueStr, 64)
505 + if err == nil {
506 + valueComp = &valueComparison{
507 + operator: compOp,
508 + value: val,
509 + }
510 + }
511 + }
512 + }
513 +
514 + // Find the opening brace
515 + braceIdx := strings.Index(pattern, "{")
516 + if braceIdx == -1 {
517 + // No labels specified
518 + return pattern, nil, valueComp
519 + }
520 +
521 + metricName := pattern[:braceIdx]
522 + labelsStr := pattern[braceIdx+1:]
523 +
524 + // Remove trailing }
525 + if strings.HasSuffix(labelsStr, "}") {
526 + labelsStr = labelsStr[:len(labelsStr)-1]
527 + }
528 +
529 + // Parse label selectors
530 + selectors := make(map[string]labelSelector)
531 + if labelsStr == "" {
532 + return metricName, selectors, valueComp
533 + }
534 +
535 + // Split by comma, but respect quoted strings
536 + labelParts := splitLabelSelectors(labelsStr)
537 + for _, part := range labelParts {
538 + part = strings.TrimSpace(part)
539 + if part == "" {
540 + continue
541 + }
542 +
543 + // Check for regex operator =~ or !=
544 + var op string
545 + var splitIdx int
546 + if idx := strings.Index(part, "=~"); idx != -1 {
547 + op = "=~"
548 + splitIdx = idx
549 + } else if idx := strings.Index(part, "!="); idx != -1 {
550 + op = "!="
551 + splitIdx = idx
552 + } else if idx := strings.Index(part, "="); idx != -1 {
553 + op = "="
554 + splitIdx = idx
555 + } else {
556 + continue
557 + }
558 +
559 + labelName := strings.TrimSpace(part[:splitIdx])
560 + labelValue := strings.TrimSpace(part[splitIdx+len(op):])
561 +
562 + // Remove quotes from value
563 + labelValue = strings.Trim(labelValue, "\"")
564 +
565 + selectors[labelName] = labelSelector{
566 + operator: op,
567 + value: labelValue,
568 + }
569 + }
570 +
571 + return metricName, selectors, valueComp
572 +}
573 +
574 +func min(a, b int) int {
575 + if a < b {
576 + return a
577 + }
578 + return b
579 +}
580 +
581 +// labelSelector represents a label matching condition
582 +type labelSelector struct {
583 + operator string // "=", "=~", "!="
584 + value string
585 +}
586 +
587 +// valueComparison represents a metric value comparison
588 +type valueComparison struct {
589 + operator string // "==", "!=", ">", "<", ">=", "<="
590 + value float64
591 +}
592 +
593 +// splitLabelSelectors splits label selectors by comma, respecting quotes
594 +func splitLabelSelectors(s string) []string {
595 + var parts []string
596 + var current strings.Builder
597 + inQuotes := false
598 +
599 + for i := 0; i < len(s); i++ {
600 + ch := s[i]
601 +
602 + if ch == '"' {
603 + inQuotes = !inQuotes
604 + current.WriteByte(ch)
605 + } else if ch == ',' && !inQuotes {
606 + parts = append(parts, current.String())
607 + current.Reset()
608 + } else {
609 + current.WriteByte(ch)
610 + }
611 + }
612 +
613 + if current.Len() > 0 {
614 + parts = append(parts, current.String())
615 + }
616 +
617 + return parts
618 +}
619 +
620 +// matchesLabelSelectors checks if labels match all selectors
621 +// Also checks metric name if __name__ selector is present
622 +func matchesLabelSelectors(labels map[string]string, selectors map[string]labelSelector) bool {
623 + if len(selectors) == 0 {
624 + return true
625 + }
626 +
627 + for labelName, selector := range selectors {
628 + labelValue, exists := labels[labelName]
629 +
630 + switch selector.operator {
631 + case "=":
632 + if !exists || labelValue != selector.value {
633 + return false
634 + }
635 + case "=~":
636 + if !exists {
637 + return false
638 + }
639 + // Treat as regex
640 + re, err := compileRegexSafe(selector.value, 0)
641 + if err != nil {
642 + return false
643 + }
644 + matched, err := matchWithTimeout(re, labelValue, defaultRegexTimeout)
645 + if err != nil || !matched {
646 + return false
647 + }
648 + case "!=":
649 + if exists && labelValue == selector.value {
650 + return false
651 + }
652 + }
653 + }
654 +
655 + return true
656 +}
657 +
658 +// matchesValueComparison checks if a metric value matches a comparison
659 +func matchesValueComparison(metricValue float64, comp *valueComparison) bool {
660 + switch comp.operator {
661 + case "==":
662 + return metricValue == comp.value
663 + case "!=":
664 + return metricValue != comp.value
665 + case ">":
666 + return metricValue > comp.value
667 + case "<":
668 + return metricValue < comp.value
669 + case ">=":
670 + return metricValue >= comp.value
671 + case "<=":
672 + return metricValue <= comp.value
673 + default:
674 + return false
675 + }
676 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/regex_safe.go new
+254
@@ -0,0 +1,254 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "context"
5 + "fmt"
6 + "regexp"
7 + "sync"
8 + "time"
9 +)
10 +
11 +// Regex safety constants
12 +const (
13 + maxRegexNestingDepth = 10 // Maximum nesting depth for groups
14 + defaultRegexTimeout = 5 * time.Second // Default timeout for regex operations
15 + regexCacheSize = 100 // Maximum cached patterns
16 +)
17 +
18 +// regexCache caches compiled regular expressions for performance
19 +type regexCache struct {
20 + mu sync.RWMutex
21 + cache map[string]*regexp.Regexp
22 + order []string // LRU tracking
23 +}
24 +
25 +var globalRegexCache = &regexCache{
26 + cache: make(map[string]*regexp.Regexp),
27 + order: make([]string, 0, regexCacheSize),
28 +}
29 +
30 +// validateRegexComplexity performs basic complexity checks on regex patterns
31 +// maxPatternLength of 0 means no limit
32 +func validateRegexComplexity(pattern string, maxPatternLength int) error {
33 + if maxPatternLength > 0 && len(pattern) > maxPatternLength {
34 + return fmt.Errorf("regex pattern too long: %d chars (max %d)",
35 + len(pattern), maxPatternLength)
36 + }
37 +
38 + // Count nesting depth of parentheses
39 + depth := 0
40 + maxDepth := 0
41 + escaped := false
42 +
43 + for _, ch := range pattern {
44 + if escaped {
45 + escaped = false
46 + continue
47 + }
48 +
49 + if ch == '\\' {
50 + escaped = true
51 + continue
52 + }
53 +
54 + if ch == '(' {
55 + depth++
56 + if depth > maxDepth {
57 + maxDepth = depth
58 + }
59 + } else if ch == ')' {
60 + depth--
61 + if depth < 0 {
62 + return fmt.Errorf("unbalanced parentheses in regex pattern")
63 + }
64 + }
65 + }
66 +
67 + if depth != 0 {
68 + return fmt.Errorf("unbalanced parentheses in regex pattern")
69 + }
70 +
71 + if maxDepth > maxRegexNestingDepth {
72 + return fmt.Errorf("regex nesting too deep: %d levels (max %d)",
73 + maxDepth, maxRegexNestingDepth)
74 + }
75 +
76 + return nil
77 +}
78 +
79 +// compileRegexSafe compiles a regex with validation and caching
80 +// maxPatternLength of 0 means no limit (Zabbix default)
81 +func compileRegexSafe(pattern string, maxPatternLength int) (*regexp.Regexp, error) {
82 + // Check cache first (read lock)
83 + globalRegexCache.mu.RLock()
84 + if re, found := globalRegexCache.cache[pattern]; found {
85 + globalRegexCache.mu.RUnlock()
86 + return re, nil
87 + }
88 + globalRegexCache.mu.RUnlock()
89 +
90 + // Validate complexity before compiling
91 + if err := validateRegexComplexity(pattern, maxPatternLength); err != nil {
92 + return nil, err
93 + }
94 +
95 + // Compile pattern
96 + re, err := regexp.Compile(pattern)
97 + if err != nil {
98 + return nil, err
99 + }
100 +
101 + // Add to cache (write lock)
102 + globalRegexCache.mu.Lock()
103 + defer globalRegexCache.mu.Unlock()
104 +
105 + // Double-check cache after acquiring write lock (another goroutine might have added it)
106 + if cached, found := globalRegexCache.cache[pattern]; found {
107 + return cached, nil
108 + }
109 +
110 + // Evict oldest entry if cache is full (simple FIFO)
111 + if len(globalRegexCache.cache) >= regexCacheSize {
112 + oldest := globalRegexCache.order[0]
113 + delete(globalRegexCache.cache, oldest)
114 + globalRegexCache.order = globalRegexCache.order[1:]
115 + }
116 +
117 + // Add to cache
118 + globalRegexCache.cache[pattern] = re
119 + globalRegexCache.order = append(globalRegexCache.order, pattern)
120 +
121 + return re, nil
122 +}
123 +
124 +// matchWithTimeout runs a regex match with timeout protection
125 +func matchWithTimeout(re *regexp.Regexp, input string, timeout time.Duration) (bool, error) {
126 + if timeout == 0 {
127 + timeout = defaultRegexTimeout
128 + }
129 +
130 + ctx, cancel := context.WithTimeout(context.Background(), timeout)
131 + defer cancel()
132 +
133 + resultChan := make(chan bool, 1)
134 + errChan := make(chan error, 1)
135 +
136 + go func() {
137 + defer func() {
138 + if r := recover(); r != nil {
139 + select {
140 + case errChan <- fmt.Errorf("regex match panicked: %v", r):
141 + case <-ctx.Done():
142 + return // Context cancelled, exit immediately
143 + }
144 + }
145 + }()
146 +
147 + matched := re.MatchString(input)
148 + select {
149 + case resultChan <- matched:
150 + case <-ctx.Done():
151 + return // Context cancelled, exit immediately
152 + }
153 + }()
154 +
155 + select {
156 + case matched := <-resultChan:
157 + return matched, nil
158 + case err := <-errChan:
159 + return false, err
160 + case <-ctx.Done():
161 + return false, fmt.Errorf("regex match timeout after %v", timeout)
162 + }
163 +}
164 +
165 +// findStringSubmatchWithTimeout runs FindStringSubmatch with timeout protection
166 +func findStringSubmatchWithTimeout(re *regexp.Regexp, input string, timeout time.Duration) ([]string, error) {
167 + if timeout == 0 {
168 + timeout = defaultRegexTimeout
169 + }
170 +
171 + ctx, cancel := context.WithTimeout(context.Background(), timeout)
172 + defer cancel()
173 +
174 + resultChan := make(chan []string, 1)
175 + errChan := make(chan error, 1)
176 +
177 + go func() {
178 + defer func() {
179 + if r := recover(); r != nil {
180 + select {
181 + case errChan <- fmt.Errorf("regex find panicked: %v", r):
182 + case <-ctx.Done():
183 + return // Context cancelled, exit immediately
184 + }
185 + }
186 + }()
187 +
188 + matches := re.FindStringSubmatch(input)
189 + select {
190 + case resultChan <- matches:
191 + case <-ctx.Done():
192 + return // Context cancelled, exit immediately
193 + }
194 + }()
195 +
196 + select {
197 + case matches := <-resultChan:
198 + return matches, nil
199 + case err := <-errChan:
200 + return nil, err
201 + case <-ctx.Done():
202 + return nil, fmt.Errorf("regex find timeout after %v", timeout)
203 + }
204 +}
205 +
206 +// findStringSubmatchIndexWithTimeout runs FindStringSubmatchIndex with timeout protection
207 +func findStringSubmatchIndexWithTimeout(re *regexp.Regexp, input string, timeout time.Duration) ([]int, error) {
208 + if timeout == 0 {
209 + timeout = defaultRegexTimeout
210 + }
211 +
212 + ctx, cancel := context.WithTimeout(context.Background(), timeout)
213 + defer cancel()
214 +
215 + resultChan := make(chan []int, 1)
216 + errChan := make(chan error, 1)
217 +
218 + go func() {
219 + defer func() {
220 + if r := recover(); r != nil {
221 + select {
222 + case errChan <- fmt.Errorf("regex find index panicked: %v", r):
223 + case <-ctx.Done():
224 + return // Context cancelled, exit immediately
225 + }
226 + }
227 + }()
228 +
229 + matches := re.FindStringSubmatchIndex(input)
230 + select {
231 + case resultChan <- matches:
232 + case <-ctx.Done():
233 + return // Context cancelled, exit immediately
234 + }
235 + }()
236 +
237 + select {
238 + case matches := <-resultChan:
239 + return matches, nil
240 + case err := <-errChan:
241 + return nil, err
242 + case <-ctx.Done():
243 + return nil, fmt.Errorf("regex find index timeout after %v", timeout)
244 + }
245 +}
246 +
247 +// ClearRegexCache clears the global regex cache (useful for testing)
248 +func ClearRegexCache() {
249 + globalRegexCache.mu.Lock()
250 + defer globalRegexCache.mu.Unlock()
251 +
252 + globalRegexCache.cache = make(map[string]*regexp.Regexp)
253 + globalRegexCache.order = make([]string, 0, regexCacheSize)
254 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/regex_safe_test.go new
+316
@@ -0,0 +1,316 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "regexp"
5 + "strings"
6 + "testing"
7 + "time"
8 +)
9 +
10 +// Test constants for regex pattern limits (used only in tests)
11 +const testMaxRegexPatternLength = 1000
12 +
13 +// TestRegexComplexityValidation tests that overly complex patterns are rejected
14 +func TestRegexComplexityValidation(t *testing.T) {
15 + tests := []struct {
16 + name string
17 + pattern string
18 + maxPatternLength int
19 + wantErr bool
20 + }{
21 + {
22 + name: "simple pattern",
23 + pattern: "hello.*world",
24 + maxPatternLength: 0, // no limit
25 + wantErr: false,
26 + },
27 + {
28 + name: "pattern too long with limit",
29 + pattern: strings.Repeat("a", testMaxRegexPatternLength+1),
30 + maxPatternLength: testMaxRegexPatternLength,
31 + wantErr: true,
32 + },
33 + {
34 + name: "pattern ok when no limit",
35 + pattern: strings.Repeat("a", testMaxRegexPatternLength+1),
36 + maxPatternLength: 0, // no limit
37 + wantErr: false,
38 + },
39 + {
40 + name: "excessive nesting",
41 + pattern: strings.Repeat("(", maxRegexNestingDepth+1) + strings.Repeat(")", maxRegexNestingDepth+1),
42 + maxPatternLength: 0,
43 + wantErr: true,
44 + },
45 + {
46 + name: "unbalanced parentheses - too many open",
47 + pattern: "((hello",
48 + maxPatternLength: 0,
49 + wantErr: true,
50 + },
51 + {
52 + name: "unbalanced parentheses - too many close",
53 + pattern: "hello))",
54 + maxPatternLength: 0,
55 + wantErr: true,
56 + },
57 + {
58 + name: "acceptable nesting",
59 + pattern: strings.Repeat("(", maxRegexNestingDepth) + strings.Repeat(")", maxRegexNestingDepth),
60 + maxPatternLength: 0,
61 + wantErr: false,
62 + },
63 + }
64 +
65 + for _, tt := range tests {
66 + t.Run(tt.name, func(t *testing.T) {
67 + err := validateRegexComplexity(tt.pattern, tt.maxPatternLength)
68 + if (err != nil) != tt.wantErr {
69 + t.Errorf("validateRegexComplexity() error = %v, wantErr %v", err, tt.wantErr)
70 + }
71 + })
72 + }
73 +}
74 +
75 +// TestRegexCaching tests that compiled regexes are cached
76 +func TestRegexCaching(t *testing.T) {
77 + ClearRegexCache()
78 +
79 + pattern := "test.*pattern"
80 +
81 + // First compilation
82 + re1, err := compileRegexSafe(pattern, 0)
83 + if err != nil {
84 + t.Fatalf("compileRegexSafe() error = %v", err)
85 + }
86 +
87 + // Second compilation should return cached instance
88 + re2, err := compileRegexSafe(pattern, 0)
89 + if err != nil {
90 + t.Fatalf("compileRegexSafe() error = %v", err)
91 + }
92 +
93 + // Should be the exact same pointer (cached)
94 + if re1 != re2 {
95 + t.Errorf("Expected cached regex to be same instance, got different instances")
96 + }
97 +}
98 +
99 +// TestRegexCacheEviction tests LRU cache eviction
100 +func TestRegexCacheEviction(t *testing.T) {
101 + ClearRegexCache()
102 +
103 + // Fill cache to capacity
104 + for i := 0; i < regexCacheSize; i++ {
105 + pattern := regexp.QuoteMeta(strings.Repeat("a", i+1))
106 + _, err := compileRegexSafe(pattern, 0)
107 + if err != nil {
108 + t.Fatalf("compileRegexSafe() error = %v", err)
109 + }
110 + }
111 +
112 + // Cache should be at capacity
113 + if len(globalRegexCache.cache) != regexCacheSize {
114 + t.Errorf("Expected cache size %d, got %d", regexCacheSize, len(globalRegexCache.cache))
115 + }
116 +
117 + // Add one more - should evict oldest
118 + newPattern := regexp.QuoteMeta(strings.Repeat("z", 200))
119 + _, err := compileRegexSafe(newPattern, 0)
120 + if err != nil {
121 + t.Fatalf("compileRegexSafe() error = %v", err)
122 + }
123 +
124 + // Cache should still be at capacity
125 + if len(globalRegexCache.cache) != regexCacheSize {
126 + t.Errorf("Expected cache size %d after eviction, got %d", regexCacheSize, len(globalRegexCache.cache))
127 + }
128 +
129 + // First pattern should be evicted
130 + firstPattern := regexp.QuoteMeta("a")
131 + _, exists := globalRegexCache.cache[firstPattern]
132 + if exists {
133 + t.Errorf("Expected oldest pattern to be evicted, but it's still in cache")
134 + }
135 +}
136 +
137 +// TestMatchWithTimeout tests that slow regex operations timeout
138 +func TestMatchWithTimeout(t *testing.T) {
139 + // Simple pattern that should complete quickly
140 + re := regexp.MustCompile("hello")
141 +
142 + // Should complete successfully
143 + matched, err := matchWithTimeout(re, "hello world", 100*time.Millisecond)
144 + if err != nil {
145 + t.Errorf("matchWithTimeout() error = %v", err)
146 + }
147 + if !matched {
148 + t.Errorf("Expected match, got no match")
149 + }
150 +}
151 +
152 +// TestRegexSubstituteWithSafeRegex tests regexSubstitute uses safe regex
153 +func TestRegexSubstituteWithSafeRegex(t *testing.T) {
154 + ClearRegexCache()
155 +
156 + value := Value{Data: "hello world", Type: ValueTypeStr}
157 + params := "world\nuniverse"
158 +
159 + result, err := regexSubstitute(value, params)
160 + if err != nil {
161 + t.Fatalf("regexSubstitute() error = %v", err)
162 + }
163 +
164 + if result.Data != "universe" {
165 + t.Errorf("Expected 'universe', got '%s'", result.Data)
166 + }
167 +
168 + // Verify pattern was cached
169 + pattern := "world"
170 + _, exists := globalRegexCache.cache[pattern]
171 + if !exists {
172 + t.Errorf("Expected pattern to be cached")
173 + }
174 +}
175 +
176 +// TestValidateRegexWithSafeRegex tests validateRegex uses safe regex
177 +func TestValidateRegexWithSafeRegex(t *testing.T) {
178 + ClearRegexCache()
179 +
180 + value := Value{Data: "test123", Type: ValueTypeStr}
181 + pattern := "^test[0-9]+$"
182 +
183 + result, err := validateRegex(value, pattern)
184 + if err != nil {
185 + t.Fatalf("validateRegex() error = %v", err)
186 + }
187 +
188 + if result.Data != value.Data {
189 + t.Errorf("Expected value unchanged, got '%s'", result.Data)
190 + }
191 +
192 + // Verify pattern was cached
193 + _, exists := globalRegexCache.cache[pattern]
194 + if !exists {
195 + t.Errorf("Expected pattern to be cached")
196 + }
197 +}
198 +
199 +// TestValidateNotRegexWithSafeRegex tests validateNotRegex uses safe regex
200 +func TestValidateNotRegexWithSafeRegex(t *testing.T) {
201 + ClearRegexCache()
202 +
203 + value := Value{Data: "hello", Type: ValueTypeStr}
204 + pattern := "^[0-9]+$"
205 +
206 + result, err := validateNotRegex(value, pattern)
207 + if err != nil {
208 + t.Fatalf("validateNotRegex() error = %v", err)
209 + }
210 +
211 + if result.Data != value.Data {
212 + t.Errorf("Expected value unchanged, got '%s'", result.Data)
213 + }
214 +
215 + // Verify pattern was cached
216 + _, exists := globalRegexCache.cache[pattern]
217 + if !exists {
218 + t.Errorf("Expected pattern to be cached")
219 + }
220 +}
221 +
222 +// TestErrorFieldRegexWithSafeRegex tests errorFieldRegex uses safe regex
223 +func TestErrorFieldRegexWithSafeRegex(t *testing.T) {
224 + ClearRegexCache()
225 +
226 + value := Value{Data: "ERROR: test failed", Type: ValueTypeStr}
227 + params := "ERROR: (.+)\n$1"
228 +
229 + _, err := errorFieldRegex(value, params)
230 + if err == nil {
231 + t.Fatal("Expected error, got nil")
232 + }
233 +
234 + if err.Error() != "test failed" {
235 + t.Errorf("Expected 'test failed', got '%s'", err.Error())
236 + }
237 +
238 + // Verify pattern was cached
239 + pattern := "ERROR: (.+)"
240 + _, exists := globalRegexCache.cache[pattern]
241 + if !exists {
242 + t.Errorf("Expected pattern to be cached")
243 + }
244 +}
245 +
246 +// TestRegexProtectionAgainstComplexPatterns tests that complex patterns are rejected
247 +func TestRegexProtectionAgainstComplexPatterns(t *testing.T) {
248 + tests := []struct {
249 + name string
250 + pattern string
251 + input string
252 + wantErr bool
253 + }{
254 + {
255 + name: "simple valid pattern",
256 + pattern: "^[a-z]+$",
257 + input: "hello",
258 + wantErr: false,
259 + },
260 + {
261 + name: "long pattern ok with no limit",
262 + pattern: ".*" + strings.Repeat("a", testMaxRegexPatternLength) + ".*", // Valid long pattern
263 + input: strings.Repeat("a", testMaxRegexPatternLength), // Input that matches
264 + wantErr: false, // No limit by default
265 + },
266 + {
267 + name: "excessive nesting depth",
268 + pattern: strings.Repeat("(a", maxRegexNestingDepth+1) + strings.Repeat(")", maxRegexNestingDepth+1),
269 + input: "aaaa",
270 + wantErr: true,
271 + },
272 + }
273 +
274 + for _, tt := range tests {
275 + t.Run(tt.name, func(t *testing.T) {
276 + value := Value{Data: tt.input, Type: ValueTypeStr}
277 + _, err := validateRegex(value, tt.pattern)
278 +
279 + if (err != nil) != tt.wantErr {
280 + t.Errorf("validateRegex() error = %v, wantErr %v", err, tt.wantErr)
281 + }
282 + })
283 + }
284 +}
285 +
286 +// TestConcurrentRegexCacheAccess tests thread safety of regex cache
287 +func TestConcurrentRegexCacheAccess(t *testing.T) {
288 + ClearRegexCache()
289 +
290 + // Launch multiple goroutines that compile the same pattern
291 + pattern := "test.*concurrent"
292 + done := make(chan bool, 10)
293 +
294 + for i := 0; i < 10; i++ {
295 + go func() {
296 + for j := 0; j < 100; j++ {
297 + _, err := compileRegexSafe(pattern, 0)
298 + if err != nil {
299 + t.Errorf("compileRegexSafe() error = %v", err)
300 + }
301 + }
302 + done <- true
303 + }()
304 + }
305 +
306 + // Wait for all goroutines to complete
307 + for i := 0; i < 10; i++ {
308 + <-done
309 + }
310 +
311 + // Pattern should be in cache exactly once
312 + _, exists := globalRegexCache.cache[pattern]
313 + if !exists {
314 + t.Errorf("Expected pattern to be cached")
315 + }
316 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/regression_test.go new
+509
@@ -0,0 +1,509 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "strings"
5 + "testing"
6 + "time"
7 +)
8 +
9 +// TestRegressionIsErrorPropagation tests that IsError flag is preserved through pipeline.
10 +// Issue: ExecutePipeline was resetting IsError to false, breaking validateNotSupported.
11 +func TestRegressionIsErrorPropagation(t *testing.T) {
12 + p := NewPreprocessor("test-shard")
13 +
14 + tests := []struct {
15 + name string
16 + inputData string
17 + inputError bool // IsError flag on input
18 + steps []Step
19 + expectError bool // Should return error?
20 + expectData string // Expected output data (if no error)
21 + }{
22 + {
23 + name: "Error input passes through unchanged when no error-handling step",
24 + inputData: "previous error message",
25 + inputError: true,
26 + steps: []Step{
27 + {Type: StepTypeTrim, Params: " "},
28 + },
29 + expectError: false,
30 + expectData: "previous error message", // Trim works on error message string
31 + },
32 + {
33 + name: "ValidateNotSupported sees IsError flag",
34 + inputData: "ZBX_NOTSUPPORTED: Agent is not available",
35 + inputError: true,
36 + steps: []Step{
37 + {Type: StepTypeValidateNotSupported, Params: "\nAgent is not available"},
38 + },
39 + expectError: true, // Pattern matches, so error is "supported" -> fail
40 + },
41 + {
42 + name: "IsError preserved through multiple successful steps",
43 + inputData: "error data",
44 + inputError: true,
45 + steps: []Step{
46 + {Type: StepTypeTrim, Params: " "},
47 + {Type: StepTypeStringReplace, Params: "data\ninfo"},
48 + {Type: StepTypeTrim, Params: " "},
49 + },
50 + expectError: false,
51 + expectData: "error info", // Processed but IsError flag should be preserved
52 + },
53 + {
54 + name: "Normal value not treated as error",
55 + inputData: "normal value",
56 + inputError: false,
57 + steps: []Step{
58 + {Type: StepTypeTrim, Params: " "},
59 + },
60 + expectError: false,
61 + expectData: "normal value",
62 + },
63 + }
64 +
65 + for _, tt := range tests {
66 + t.Run(tt.name, func(t *testing.T) {
67 + input := Value{
68 + Data: tt.inputData,
69 + Type: ValueTypeStr,
70 + Timestamp: time.Now(),
71 + IsError: tt.inputError,
72 + }
73 +
74 + result, err := p.ExecutePipeline("item1", input, tt.steps)
75 +
76 + if tt.expectError {
77 + if err == nil {
78 + t.Error("Expected error, got nil")
79 + }
80 + } else {
81 + if err != nil {
82 + t.Errorf("Expected no error, got: %v", err)
83 + }
84 + if len(result.Metrics) > 0 && result.Metrics[0].Value != tt.expectData {
85 + t.Errorf("Expected data %q, got %q", tt.expectData, result.Metrics[0].Value)
86 + }
87 + }
88 + })
89 + }
90 +}
91 +
92 +// TestRegressionPrometheusEscapedQuotes tests proper handling of escaped characters in labels.
93 +// Issue: Regex-based parsing failed on escaped quotes like path="/foo\"bar".
94 +func TestRegressionPrometheusEscapedQuotes(t *testing.T) {
95 + tests := []struct {
96 + name string
97 + input string
98 + labelKey string
99 + labelValue string
100 + }{
101 + {
102 + name: "Escaped double quote in label value",
103 + input: `http_requests{path="/foo\"bar"} 100`,
104 + labelKey: "path",
105 + labelValue: `/foo"bar`,
106 + },
107 + {
108 + name: "Escaped backslash in label value",
109 + input: `file_size{path="C:\\Users\\Admin"} 200`,
110 + labelKey: "path",
111 + labelValue: `C:\Users\Admin`,
112 + },
113 + {
114 + name: "Escaped newline in label value",
115 + input: `message{text="line1\nline2"} 50`,
116 + labelKey: "text",
117 + labelValue: "line1\nline2",
118 + },
119 + {
120 + name: "Multiple escapes in single label",
121 + input: `complex{data="quote\"slash\\newline\n"} 75`,
122 + labelKey: "data",
123 + labelValue: "quote\"slash\\newline\n",
124 + },
125 + {
126 + name: "Mixed normal and escaped labels",
127 + input: `metric{method="GET",path="/test\"path",code="200"} 300`,
128 + labelKey: "path",
129 + labelValue: `/test"path`,
130 + },
131 + }
132 +
133 + for _, tt := range tests {
134 + t.Run(tt.name, func(t *testing.T) {
135 + metrics, err := parsePrometheusText(tt.input)
136 + if err != nil {
137 + t.Fatalf("Failed to parse: %v", err)
138 + }
139 + if len(metrics) == 0 {
140 + t.Fatal("No metrics parsed")
141 + }
142 +
143 + metric := metrics[0]
144 + if val, ok := metric.Labels[tt.labelKey]; !ok {
145 + t.Errorf("Label %q not found in parsed metric", tt.labelKey)
146 + } else if val != tt.labelValue {
147 + t.Errorf("Label %q value mismatch:\nExpected: %q\nGot: %q", tt.labelKey, tt.labelValue, val)
148 + }
149 + })
150 + }
151 +}
152 +
153 +// TestRegressionPrometheusLabelOrder tests that label order is preserved.
154 +func TestRegressionPrometheusLabelOrder(t *testing.T) {
155 + input := `metric{first="1",second="2",third="3"} 100`
156 + metrics, err := parsePrometheusText(input)
157 + if err != nil {
158 + t.Fatalf("Failed to parse: %v", err)
159 + }
160 + if len(metrics) == 0 {
161 + t.Fatal("No metrics parsed")
162 + }
163 +
164 + expected := []string{"first", "second", "third"}
165 + if len(metrics[0].LabelOrder) != len(expected) {
166 + t.Fatalf("Label order length mismatch: expected %d, got %d", len(expected), len(metrics[0].LabelOrder))
167 + }
168 +
169 + for i, key := range expected {
170 + if metrics[0].LabelOrder[i] != key {
171 + t.Errorf("Label order[%d] mismatch: expected %q, got %q", i, key, metrics[0].LabelOrder[i])
172 + }
173 + }
174 +}
175 +
176 +// TestRegressionSNMPTextualOIDSupport tests that textual OIDs are properly translated.
177 +// Issue: snmpWalkToJSON only did direct map lookup, not full translateMIB.
178 +func TestRegressionSNMPTextualOIDSupport(t *testing.T) {
179 + tests := []struct {
180 + name string
181 + oid string
182 + expectedOK bool
183 + expected string // Expected numeric OID (if OK)
184 + }{
185 + {
186 + name: "IF-MIB::ifDescr translates correctly",
187 + oid: "IF-MIB::ifDescr",
188 + expectedOK: true,
189 + expected: ".1.3.6.1.2.1.2.2.1.2",
190 + },
191 + {
192 + name: "IF-MIB::ifIndex translates correctly",
193 + oid: "IF-MIB::ifIndex",
194 + expectedOK: true,
195 + expected: ".1.3.6.1.2.1.2.2.1.1",
196 + },
197 + {
198 + name: "SNMPv2-MIB::sysDescr translates correctly",
199 + oid: "SNMPv2-MIB::sysDescr",
200 + expectedOK: true,
201 + expected: ".1.3.6.1.2.1.1.1",
202 + },
203 + {
204 + name: "HOST-RESOURCES-MIB::hrStorageDescr translates correctly",
205 + oid: "HOST-RESOURCES-MIB::hrStorageDescr",
206 + expectedOK: true,
207 + expected: ".1.3.6.1.2.1.25.2.3.1.3",
208 + },
209 + {
210 + name: "Numeric OID passthrough",
211 + oid: ".1.3.6.1.2.1.2.2.1.2",
212 + expectedOK: true,
213 + expected: ".1.3.6.1.2.1.2.2.1.2",
214 + },
215 + {
216 + name: "Numeric OID without leading dot gets normalized",
217 + oid: "1.3.6.1.2.1.2.2.1.2",
218 + expectedOK: true,
219 + expected: "1.3.6.1.2.1.2.2.1.2", // translateMIB returns passthrough for numeric
220 + },
221 + {
222 + name: "Unknown MIB returns error",
223 + oid: "UNKNOWN-MIB::unknownOid",
224 + expectedOK: false,
225 + },
226 + }
227 +
228 + for _, tt := range tests {
229 + t.Run(tt.name, func(t *testing.T) {
230 + result, err := translateMIB(tt.oid)
231 + if tt.expectedOK {
232 + if err != nil {
233 + t.Errorf("Expected successful translation, got error: %v", err)
234 + }
235 + if result != tt.expected {
236 + t.Errorf("OID translation mismatch:\nExpected: %s\nGot: %s", tt.expected, result)
237 + }
238 + } else {
239 + if err == nil {
240 + t.Error("Expected error for unknown MIB, got nil")
241 + }
242 + }
243 + })
244 + }
245 +}
246 +
247 +// TestRegressionSNMPWalkToJSONUsesTranslateMIB tests that snmpWalkToJSON uses full translateMIB.
248 +func TestRegressionSNMPWalkToJSONUsesTranslateMIB(t *testing.T) {
249 + p := NewPreprocessor("test-shard")
250 +
251 + // SNMP walk data using numeric OIDs
252 + snmpData := `.1.3.6.1.2.1.2.2.1.1.1 = INTEGER: 1
253 +.1.3.6.1.2.1.2.2.1.1.2 = INTEGER: 2
254 +.1.3.6.1.2.1.2.2.1.2.1 = STRING: "eth0"
255 +.1.3.6.1.2.1.2.2.1.2.2 = STRING: "eth1"`
256 +
257 + // Use textual OID in params - this should work now
258 + params := `{#IFINDEX}
259 +IF-MIB::ifIndex
260 +0
261 +{#IFDESCR}
262 +IF-MIB::ifDescr
263 +0`
264 +
265 + input := Value{
266 + Data: snmpData,
267 + Type: ValueTypeStr,
268 + }
269 +
270 + step := Step{
271 + Type: StepTypeSNMPWalkToJSON,
272 + Params: params,
273 + }
274 +
275 + result, err := p.Execute("item1", input, step)
276 + if err != nil {
277 + t.Fatalf("SNMP walk to JSON failed: %v", err)
278 + }
279 +
280 + if len(result.Metrics) == 0 {
281 + t.Fatal("No metrics returned")
282 + }
283 +
284 + // The result should contain JSON with both indices
285 + output := result.Metrics[0].Value
286 + if !strings.Contains(output, `"eth0"`) || !strings.Contains(output, `"eth1"`) {
287 + t.Errorf("Expected output to contain interface descriptions, got: %s", output)
288 + }
289 + if !strings.Contains(output, `"{#IFINDEX}"`) || !strings.Contains(output, `"{#IFDESCR}"`) {
290 + t.Errorf("Expected output to contain macro names, got: %s", output)
291 + }
292 +}
293 +
294 +// TestRegressionJavaScriptVMIsolation tests that JS VMs are isolated between executions.
295 +// Issue: VM pooling with resetVM didn't clear prototype modifications.
296 +func TestRegressionJavaScriptVMIsolation(t *testing.T) {
297 + // First execution: modify Object prototype
298 + script1 := `
299 + Object.prototype.polluted = "malicious";
300 + return "done";
301 + `
302 + result1, err := javascriptExecute(Value{Data: "test"}, script1, DefaultLimits().JavaScript)
303 + if err != nil {
304 + t.Fatalf("First JS execution failed: %v", err)
305 + }
306 + if result1.Data != "done" {
307 + t.Errorf("Expected 'done', got %q", result1.Data)
308 + }
309 +
310 + // Second execution: check if prototype pollution persists
311 + script2 := `
312 + // If VMs were pooled without proper isolation, this would return "malicious"
313 + var obj = {};
314 + if (obj.polluted !== undefined) {
315 + return "POLLUTION DETECTED: " + obj.polluted;
316 + }
317 + return "clean";
318 + `
319 + result2, err := javascriptExecute(Value{Data: "test"}, script2, DefaultLimits().JavaScript)
320 + if err != nil {
321 + t.Fatalf("Second JS execution failed: %v", err)
322 + }
323 + if result2.Data != "clean" {
324 + t.Errorf("VM isolation failed! Got: %q", result2.Data)
325 + }
326 +}
327 +
328 +// TestRegressionJavaScriptGlobalVariableIsolation tests that global variables don't leak.
329 +func TestRegressionJavaScriptGlobalVariableIsolation(t *testing.T) {
330 + // First execution: define global variable
331 + script1 := `
332 + globalVar = "secret";
333 + return globalVar;
334 + `
335 + result1, err := javascriptExecute(Value{Data: "test"}, script1, DefaultLimits().JavaScript)
336 + if err != nil {
337 + t.Fatalf("First JS execution failed: %v", err)
338 + }
339 + if result1.Data != "secret" {
340 + t.Errorf("Expected 'secret', got %q", result1.Data)
341 + }
342 +
343 + // Second execution: check if global variable persists
344 + script2 := `
345 + if (typeof globalVar !== 'undefined') {
346 + return "LEAK DETECTED: " + globalVar;
347 + }
348 + return "isolated";
349 + `
350 + result2, err := javascriptExecute(Value{Data: "test"}, script2, DefaultLimits().JavaScript)
351 + if err != nil {
352 + t.Fatalf("Second JS execution failed: %v", err)
353 + }
354 + if result2.Data != "isolated" {
355 + t.Errorf("Global variable leaked! Got: %q", result2.Data)
356 + }
357 +}
358 +
359 +// TestRegressionPipelineDiscardedFlag tests that pipeline explicitly flags discarded values.
360 +// Issue: Pipeline returned Result{} with nil error, hiding discard vs misconfiguration.
361 +func TestRegressionPipelineDiscardedFlag(t *testing.T) {
362 + p := NewPreprocessor("test-shard")
363 +
364 + // Test 1: Normal successful pipeline - not discarded
365 + input1 := Value{
366 + Data: "100",
367 + Type: ValueTypeStr,
368 + Timestamp: time.Now(),
369 + }
370 +
371 + steps := []Step{
372 + {Type: StepTypeTrim, Params: " "},
373 + }
374 +
375 + result1, err := p.ExecutePipeline("item1", input1, steps)
376 + if err != nil {
377 + t.Fatalf("Normal execution failed: %v", err)
378 + }
379 + if result1.Discarded {
380 + t.Error("Normal result should not be discarded")
381 + }
382 + if len(result1.Metrics) == 0 {
383 + t.Error("Normal result should have metrics")
384 + }
385 +
386 + // Test 2: Error with ErrorActionDiscard - returns empty string, not discarded
387 + // Zabbix behavior: discard means replace error with empty string value
388 + input2 := Value{
389 + Data: "invalid",
390 + Type: ValueTypeStr,
391 + Timestamp: time.Now(),
392 + }
393 +
394 + stepsDiscard := []Step{
395 + {
396 + Type: StepTypeMultiplier,
397 + Params: "2",
398 + ErrorHandler: ErrorHandler{
399 + Action: ErrorActionDiscard,
400 + },
401 + },
402 + }
403 +
404 + result2, err := p.ExecutePipeline("item2", input2, stepsDiscard)
405 + if err != nil {
406 + t.Fatalf("Discard execution failed: %v", err)
407 + }
408 + // ErrorActionDiscard returns empty string, not zero metrics
409 + if result2.Discarded {
410 + t.Error("ErrorActionDiscard should return empty string, not discard flag")
411 + }
412 + if len(result2.Metrics) == 0 {
413 + t.Error("ErrorActionDiscard should return metrics (with empty value)")
414 + }
415 + if len(result2.Metrics) > 0 && result2.Metrics[0].Value != "" {
416 + t.Errorf("ErrorActionDiscard should return empty string, got %q", result2.Metrics[0].Value)
417 + }
418 +
419 + // Test 3: Empty Result detection - Result.Discarded flag is for when step returns 0 metrics
420 + // This can happen with multi-metric steps or certain edge cases
421 + // The flag allows callers to distinguish empty result from error
422 + if result1.Discarded || result2.Discarded {
423 + t.Error("Neither normal nor discard results should have Discarded=true")
424 + }
425 +}
426 +
427 +// TestRegressionSNMPTextualWalkOutput tests that SNMP walk output with textual OIDs is parsed.
428 +// Issue: parsers skipped lines not starting with '.', rejecting IF-MIB::ifDescr.1 format.
429 +func TestRegressionSNMPTextualWalkOutput(t *testing.T) {
430 + p := NewPreprocessor("test-shard")
431 +
432 + // SNMP walk data using textual OIDs (real snmpwalk output without -On flag)
433 + snmpData := `IF-MIB::ifIndex.1 = INTEGER: 1
434 +IF-MIB::ifIndex.2 = INTEGER: 2
435 +IF-MIB::ifDescr.1 = STRING: "eth0"
436 +IF-MIB::ifDescr.2 = STRING: "eth1"
437 +IF-MIB::ifSpeed.1 = Gauge32: 1000000000
438 +IF-MIB::ifSpeed.2 = Gauge32: 10000000000`
439 +
440 + // Use textual OIDs in params
441 + params := `{#IFINDEX}
442 +IF-MIB::ifIndex
443 +0
444 +{#IFDESCR}
445 +IF-MIB::ifDescr
446 +0`
447 +
448 + input := Value{
449 + Data: snmpData,
450 + Type: ValueTypeStr,
451 + }
452 +
453 + step := Step{
454 + Type: StepTypeSNMPWalkToJSON,
455 + Params: params,
456 + }
457 +
458 + result, err := p.Execute("item1", input, step)
459 + if err != nil {
460 + t.Fatalf("SNMP walk to JSON failed with textual OID output: %v", err)
461 + }
462 +
463 + if len(result.Metrics) == 0 {
464 + t.Fatal("No metrics returned from textual SNMP walk output")
465 + }
466 +
467 + output := result.Metrics[0].Value
468 + // Verify both interfaces were parsed
469 + if !strings.Contains(output, `"eth0"`) {
470 + t.Errorf("Expected 'eth0' in output, got: %s", output)
471 + }
472 + if !strings.Contains(output, `"eth1"`) {
473 + t.Errorf("Expected 'eth1' in output, got: %s", output)
474 + }
475 + if !strings.Contains(output, `"{#IFINDEX}"`) {
476 + t.Errorf("Expected macro {#IFINDEX} in output, got: %s", output)
477 + }
478 +}
479 +
480 +// TestRegressionJavaScriptArrayPrototypeIsolation tests Array.prototype isolation.
481 +func TestRegressionJavaScriptArrayPrototypeIsolation(t *testing.T) {
482 + // First execution: modify Array prototype
483 + script1 := `
484 + Array.prototype.myMethod = function() { return "injected"; };
485 + return [].myMethod();
486 + `
487 + result1, err := javascriptExecute(Value{Data: "test"}, script1, DefaultLimits().JavaScript)
488 + if err != nil {
489 + t.Fatalf("First JS execution failed: %v", err)
490 + }
491 + if result1.Data != "injected" {
492 + t.Errorf("Expected 'injected', got %q", result1.Data)
493 + }
494 +
495 + // Second execution: check if Array prototype modification persists
496 + script2 := `
497 + if (typeof [].myMethod === 'function') {
498 + return "ARRAY POLLUTION: " + [].myMethod();
499 + }
500 + return "array_clean";
501 + `
502 + result2, err := javascriptExecute(Value{Data: "test"}, script2, DefaultLimits().JavaScript)
503 + if err != nil {
504 + t.Fatalf("Second JS execution failed: %v", err)
505 + }
506 + if result2.Data != "array_clean" {
507 + t.Errorf("Array.prototype leaked! Got: %q", result2.Data)
508 + }
509 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/snmp_mib_test.go new
+256
@@ -0,0 +1,256 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "strings"
5 + "sync"
6 + "testing"
7 +)
8 +
9 +// TestTranslateMIB_Hardcoded tests translation using hardcoded map
10 +func TestTranslateMIB_Hardcoded(t *testing.T) {
11 + tests := []struct {
12 + name string
13 + mibName string
14 + expected string
15 + }{
16 + {"SNMPv2-MIB sysDescr", "SNMPv2-MIB::sysDescr", ".1.3.6.1.2.1.1.1"},
17 + {"SNMPv2-MIB sysUpTime", "SNMPv2-MIB::sysUpTime", ".1.3.6.1.2.1.1.3"},
18 + {"IF-MIB ifDescr", "IF-MIB::ifDescr", ".1.3.6.1.2.1.2.2.1.2"},
19 + {"IF-MIB ifOperStatus", "IF-MIB::ifOperStatus", ".1.3.6.1.2.1.2.2.1.8"},
20 + {"IF-MIB ifHCInOctets", "IF-MIB::ifHCInOctets", ".1.3.6.1.2.1.31.1.1.1.6"},
21 + {"HOST-RESOURCES hrSystemUptime", "HOST-RESOURCES-MIB::hrSystemUptime", ".1.3.6.1.2.1.25.1.1"},
22 + {"HOST-RESOURCES hrStorageDescr", "HOST-RESOURCES-MIB::hrStorageDescr", ".1.3.6.1.2.1.25.2.3.1.3"},
23 + {"HOST-RESOURCES hrProcessorLoad", "HOST-RESOURCES-MIB::hrProcessorLoad", ".1.3.6.1.2.1.25.3.3.1.2"},
24 + {"IP-MIB ipForwarding", "IP-MIB::ipForwarding", ".1.3.6.1.2.1.4.1"},
25 + {"TCP-MIB tcpCurrEstab", "TCP-MIB::tcpCurrEstab", ".1.3.6.1.2.1.6.9"},
26 + {"UDP-MIB udpInDatagrams", "UDP-MIB::udpInDatagrams", ".1.3.6.1.2.1.7.1"},
27 + }
28 +
29 + for _, tt := range tests {
30 + t.Run(tt.name, func(t *testing.T) {
31 + result, err := translateMIB(tt.mibName)
32 + if err != nil {
33 + t.Fatalf("translateMIB(%q) error: %v", tt.mibName, err)
34 + }
35 + if result != tt.expected {
36 + t.Errorf("translateMIB(%q) = %q, expected %q", tt.mibName, result, tt.expected)
37 + }
38 + })
39 + }
40 +}
41 +
42 +// TestTranslateMIB_NumericOID tests that numeric OIDs pass through unchanged
43 +func TestTranslateMIB_NumericOID(t *testing.T) {
44 + tests := []struct {
45 + name string
46 + oid string
47 + expected string
48 + }{
49 + {"With leading dot", ".1.3.6.1.2.1.1.1", ".1.3.6.1.2.1.1.1"},
50 + {"Without leading dot", "1.3.6.1.2.1.1.1", "1.3.6.1.2.1.1.1"},
51 + {"Short OID", ".1.3.6.1", ".1.3.6.1"},
52 + {"Single digit", ".1", ".1"},
53 + {"Complex OID", ".1.3.6.1.2.1.2.2.1.10.1", ".1.3.6.1.2.1.2.2.1.10.1"},
54 + }
55 +
56 + for _, tt := range tests {
57 + t.Run(tt.name, func(t *testing.T) {
58 + result, err := translateMIB(tt.oid)
59 + if err != nil {
60 + t.Fatalf("translateMIB(%q) error: %v", tt.oid, err)
61 + }
62 + if result != tt.expected {
63 + t.Errorf("translateMIB(%q) = %q, expected %q (should pass through)", tt.oid, result, tt.expected)
64 + }
65 + })
66 + }
67 +}
68 +
69 +// TestTranslateMIB_UnknownMIB tests error handling for unknown MIBs
70 +func TestTranslateMIB_UnknownMIB(t *testing.T) {
71 + // Clear cache to ensure fresh test
72 + mibTranslationCacheMu.Lock()
73 + mibTranslationCache = make(map[string]string)
74 + mibTranslationCacheMu.Unlock()
75 +
76 + // Unknown MIB that's not in hardcoded map
77 + _, err := translateMIB("UNKNOWN-MIB::unknownObject")
78 + if err == nil {
79 + t.Error("Expected error for unknown MIB, got nil")
80 + }
81 +
82 + expectedErrMsg := "unknown MIB: UNKNOWN-MIB::unknownObject"
83 + if err != nil && err.Error()[:len(expectedErrMsg)] != expectedErrMsg {
84 + t.Errorf("Error message = %q, expected to start with %q", err.Error(), expectedErrMsg)
85 + }
86 +}
87 +
88 +// TestTranslateMIB_Cache tests that translations are cached
89 +func TestTranslateMIB_Cache(t *testing.T) {
90 + // Clear cache
91 + mibTranslationCacheMu.Lock()
92 + mibTranslationCache = make(map[string]string)
93 + mibTranslationCacheMu.Unlock()
94 +
95 + // First call - should use hardcoded map
96 + result1, err := translateMIB("SNMPv2-MIB::sysDescr")
97 + if err != nil {
98 + t.Fatalf("First call failed: %v", err)
99 + }
100 +
101 + // Second call - should still work (from hardcoded map, not cache)
102 + result2, err := translateMIB("SNMPv2-MIB::sysDescr")
103 + if err != nil {
104 + t.Fatalf("Second call failed: %v", err)
105 + }
106 +
107 + if result1 != result2 {
108 + t.Errorf("Results differ: %q vs %q", result1, result2)
109 + }
110 +
111 + // Verify cache is not polluted with hardcoded entries
112 + mibTranslationCacheMu.RLock()
113 + cacheSize := len(mibTranslationCache)
114 + mibTranslationCacheMu.RUnlock()
115 +
116 + if cacheSize > 0 {
117 + t.Errorf("Cache should be empty (hardcoded entries shouldn't be cached), got %d entries", cacheSize)
118 + }
119 +}
120 +
121 +// TestTranslateMIB_ConcurrentAccess tests thread safety
122 +func TestTranslateMIB_ConcurrentAccess(t *testing.T) {
123 + // Clear cache
124 + mibTranslationCacheMu.Lock()
125 + mibTranslationCache = make(map[string]string)
126 + mibTranslationCacheMu.Unlock()
127 +
128 + mibs := []string{
129 + "SNMPv2-MIB::sysDescr",
130 + "IF-MIB::ifDescr",
131 + "HOST-RESOURCES-MIB::hrSystemUptime",
132 + "IP-MIB::ipForwarding",
133 + "TCP-MIB::tcpCurrEstab",
134 + ".1.3.6.1.2.1.1.1",
135 + ".1.3.6.1.2.1.2.2.1.2",
136 + }
137 +
138 + var wg sync.WaitGroup
139 + goroutines := 50
140 + iterations := 20
141 +
142 + for i := 0; i < goroutines; i++ {
143 + wg.Add(1)
144 + go func(id int) {
145 + defer wg.Done()
146 + for j := 0; j < iterations; j++ {
147 + mib := mibs[j%len(mibs)]
148 + _, err := translateMIB(mib)
149 + // Only numeric OIDs and hardcoded MIBs should succeed
150 + if err != nil && !strings.HasPrefix(mib, ".") && mibToOID[mib] == "" {
151 + // Unknown MIB - error expected
152 + continue
153 + }
154 + if err != nil {
155 + t.Errorf("goroutine %d iteration %d: translateMIB(%q) failed: %v", id, j, mib, err)
156 + }
157 + }
158 + }(i)
159 + }
160 +
161 + wg.Wait()
162 +}
163 +
164 +// TestTranslateMIB_CacheSizeLimit tests that cache respects size limit
165 +func TestTranslateMIB_CacheSizeLimit(t *testing.T) {
166 + // Save original limit
167 + originalLimit := mibCacheMaxSize
168 + defer func() { mibCacheMaxSize = originalLimit }()
169 +
170 + // Set small limit for testing
171 + mibCacheMaxSize = 5
172 +
173 + // Clear cache
174 + mibTranslationCacheMu.Lock()
175 + mibTranslationCache = make(map[string]string)
176 + mibTranslationCacheMu.Unlock()
177 +
178 + // Note: This test doesn't actually populate the cache since all tested MIBs
179 + // are in the hardcoded map. To test cache limit properly, we would need
180 + // snmptranslate available and translate unknown MIBs. For now, we just
181 + // verify the cache limit constant is respected in the code.
182 +
183 + // Verify cache size limit is applied in code (checked in translateMIB function)
184 + if mibCacheMaxSize != 5 {
185 + t.Errorf("mibCacheMaxSize should be 5, got %d", mibCacheMaxSize)
186 + }
187 +}
188 +
189 +// TestParseSNMPWalk_MIBTranslation tests that parseSNMPWalk uses MIB translation
190 +//func TestParseSNMPWalk_MIBTranslation(t *testing.T) {
191 +// tests := []struct {
192 +// name string
193 +// data string
194 +// targetMIB string
195 +// expectedOID string
196 +// shouldError bool
197 +// }{
198 +// {
199 +// name: "SNMPv2-MIB sysDescr",
200 +// data: ".1.3.6.1.2.1.1.1.0 = STRING: \"Linux server 5.4.0\"",
201 +// targetMIB: "SNMPv2-MIB::sysDescr.0",
202 +// expectedOID: ".1.3.6.1.2.1.1.1.0",
203 +// shouldError: false,
204 +// },
205 +// {
206 +// name: "IF-MIB ifDescr with index",
207 +// data: ".1.3.6.1.2.1.2.2.1.2.1 = STRING: \"eth0\"",
208 +// targetMIB: "IF-MIB::ifDescr.1",
209 +// expectedOID: ".1.3.6.1.2.1.2.2.1.2.1",
210 +// shouldError: false,
211 +// },
212 +// {
213 +// name: "Numeric OID",
214 +// data: ".1.3.6.1.2.1.1.1.0 = STRING: \"Test\"",
215 +// targetMIB: ".1.3.6.1.2.1.1.1.0",
216 +// expectedOID: ".1.3.6.1.2.1.1.1.0",
217 +// shouldError: false,
218 +// },
219 +// }
220 +//
221 +// for _, tt := range tests {
222 +// t.Run(tt.name, func(t *testing.T) {
223 +// result, err := parseSNMPWalk(tt.data, tt.targetMIB)
224 +// if tt.shouldError {
225 +// if err == nil {
226 +// t.Error("Expected error, got nil")
227 +// }
228 +// return
229 +// }
230 +// if err != nil {
231 +// t.Fatalf("parseSNMPWalk() error: %v", err)
232 +// }
233 +// if result.oid != tt.expectedOID {
234 +// t.Errorf("parseSNMPWalk() OID = %q, expected %q", result.oid, tt.expectedOID)
235 +// }
236 +// })
237 +// }
238 +//}
239 +
240 +// TestTranslateMIB_AllHardcodedMIBs verifies all hardcoded MIBs translate correctly
241 +func TestTranslateMIB_AllHardcodedMIBs(t *testing.T) {
242 + // Verify every entry in mibToOID map works
243 + for mibName, expectedOID := range mibToOID {
244 + result, err := translateMIB(mibName)
245 + if err != nil {
246 + t.Errorf("translateMIB(%q) error: %v", mibName, err)
247 + continue
248 + }
249 + if result != expectedOID {
250 + t.Errorf("translateMIB(%q) = %q, expected %q", mibName, result, expectedOID)
251 + }
252 + }
253 +
254 + // Report statistics
255 + t.Logf("Verified %d hardcoded MIB translations", len(mibToOID))
256 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/snmp_step.go new
+1618
@@ -0,0 +1,1618 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "context"
5 + "encoding/hex"
6 + "fmt"
7 + "os/exec"
8 + "sort"
9 + "strconv"
10 + "strings"
11 + "sync"
12 + "time"
13 + "unicode/utf8"
14 +)
15 +
16 +// snmpWalkToValue extracts value from SNMP walk output.
17 +// Format modes:
18 +// 0 = unchanged (return raw value as-is)
19 +// 1 = UTF-8 (decode hex-string to UTF-8 string)
20 +// 2 = MAC (format hex-string as MAC address with colons)
21 +// 3 = BITS (convert BITS to integer)
22 +func snmpWalkToValue(value Value, params string) (Value, error) {
23 + lines := strings.Split(params, "\n")
24 + if len(lines) < 2 {
25 + return Value{}, fmt.Errorf("snmp walk requires oid and format mode parameters")
26 + }
27 +
28 + targetOID := strings.TrimSpace(lines[0])
29 + formatMode := 0
30 + if len(lines) >= 2 {
31 + var err error
32 + formatMode, err = strconv.Atoi(strings.TrimSpace(lines[1]))
33 + if err != nil {
34 + return Value{}, fmt.Errorf("invalid format mode: %v", err)
35 + }
36 + }
37 +
38 + // Parse SNMP walk output
39 + extractedValue, err := parseSNMPWalk(value.Data, targetOID)
40 + if err != nil {
41 + return Value{}, err
42 + }
43 +
44 + // Apply format conversion (trim float zeros for WALK_VALUE)
45 + result, err := formatSNMPValue(extractedValue, formatMode, true)
46 + if err != nil {
47 + return Value{}, err
48 + }
49 +
50 + return Value{Data: result, Type: ValueTypeStr}, nil
51 +}
52 +
53 +// snmpEntry represents a parsed SNMP entry
54 +type snmpEntry struct {
55 + oid string
56 + valueType string
57 + value string
58 +}
59 +
60 +// Common MIB-to-OID mappings for offline operation
61 +// Covers ~95% of real-world SNMP monitoring use cases
62 +// Falls back to snmptranslate for rare MIBs (if installed)
63 +//
64 +// NOTE: This map is effectively read-only (never modified after initialization).
65 +// It is safe for concurrent access as all operations are reads (lookups).
66 +// Go maps are safe for concurrent reads without synchronization.
67 +var mibToOID = map[string]string{
68 + // ============================================================================
69 + // SNMPv2-MIB (System group) - RFC 3418
70 + // ============================================================================
71 + "SNMPv2-MIB::sysDescr": ".1.3.6.1.2.1.1.1",
72 + "SNMPv2-MIB::sysObjectID": ".1.3.6.1.2.1.1.2",
73 + "SNMPv2-MIB::sysUpTime": ".1.3.6.1.2.1.1.3",
74 + "SNMPv2-MIB::sysContact": ".1.3.6.1.2.1.1.4",
75 + "SNMPv2-MIB::sysName": ".1.3.6.1.2.1.1.5",
76 + "SNMPv2-MIB::sysLocation": ".1.3.6.1.2.1.1.6",
77 + "SNMPv2-MIB::sysServices": ".1.3.6.1.2.1.1.7",
78 + "SNMPv2-MIB::sysORLastChange": ".1.3.6.1.2.1.1.8",
79 + "SNMPv2-MIB::sysORIndex": ".1.3.6.1.2.1.1.9.1.1",
80 + "SNMPv2-MIB::sysORID": ".1.3.6.1.2.1.1.9.1.2",
81 + "SNMPv2-MIB::sysORDescr": ".1.3.6.1.2.1.1.9.1.3",
82 + "SNMPv2-MIB::sysORUpTime": ".1.3.6.1.2.1.1.9.1.4",
83 +
84 + // SNMPv2-MIB SNMP group
85 + "SNMPv2-MIB::snmpInPkts": ".1.3.6.1.2.1.11.1",
86 + "SNMPv2-MIB::snmpOutPkts": ".1.3.6.1.2.1.11.2",
87 + "SNMPv2-MIB::snmpInBadVersions": ".1.3.6.1.2.1.11.3",
88 + "SNMPv2-MIB::snmpInBadCommunityNames": ".1.3.6.1.2.1.11.4",
89 + "SNMPv2-MIB::snmpInBadCommunityUses": ".1.3.6.1.2.1.11.5",
90 + "SNMPv2-MIB::snmpInASNParseErrs": ".1.3.6.1.2.1.11.6",
91 + "SNMPv2-MIB::snmpInTooBigs": ".1.3.6.1.2.1.11.8",
92 + "SNMPv2-MIB::snmpInNoSuchNames": ".1.3.6.1.2.1.11.9",
93 + "SNMPv2-MIB::snmpInBadValues": ".1.3.6.1.2.1.11.10",
94 + "SNMPv2-MIB::snmpInReadOnlys": ".1.3.6.1.2.1.11.11",
95 + "SNMPv2-MIB::snmpInGenErrs": ".1.3.6.1.2.1.11.12",
96 + "SNMPv2-MIB::snmpInTotalReqVars": ".1.3.6.1.2.1.11.13",
97 + "SNMPv2-MIB::snmpInTotalSetVars": ".1.3.6.1.2.1.11.14",
98 + "SNMPv2-MIB::snmpInGetRequests": ".1.3.6.1.2.1.11.15",
99 + "SNMPv2-MIB::snmpInGetNexts": ".1.3.6.1.2.1.11.16",
100 + "SNMPv2-MIB::snmpInSetRequests": ".1.3.6.1.2.1.11.17",
101 + "SNMPv2-MIB::snmpInGetResponses": ".1.3.6.1.2.1.11.18",
102 + "SNMPv2-MIB::snmpInTraps": ".1.3.6.1.2.1.11.19",
103 + "SNMPv2-MIB::snmpOutTooBigs": ".1.3.6.1.2.1.11.20",
104 + "SNMPv2-MIB::snmpOutNoSuchNames": ".1.3.6.1.2.1.11.21",
105 + "SNMPv2-MIB::snmpOutBadValues": ".1.3.6.1.2.1.11.22",
106 + "SNMPv2-MIB::snmpOutGenErrs": ".1.3.6.1.2.1.11.24",
107 + "SNMPv2-MIB::snmpOutGetRequests": ".1.3.6.1.2.1.11.25",
108 + "SNMPv2-MIB::snmpOutGetNexts": ".1.3.6.1.2.1.11.26",
109 + "SNMPv2-MIB::snmpOutSetRequests": ".1.3.6.1.2.1.11.27",
110 + "SNMPv2-MIB::snmpOutGetResponses": ".1.3.6.1.2.1.11.28",
111 + "SNMPv2-MIB::snmpOutTraps": ".1.3.6.1.2.1.11.29",
112 + "SNMPv2-MIB::snmpEnableAuthenTraps": ".1.3.6.1.2.1.11.30",
113 + "SNMPv2-MIB::snmpSilentDrops": ".1.3.6.1.2.1.11.31",
114 + "SNMPv2-MIB::snmpProxyDrops": ".1.3.6.1.2.1.11.32",
115 +
116 + // ============================================================================
117 + // IF-MIB (Interfaces) - RFC 2863
118 + // ============================================================================
119 + // Interface counters (.1.3.6.1.2.1.2.x)
120 + "IF-MIB::ifNumber": ".1.3.6.1.2.1.2.1",
121 +
122 + // Legacy interfaces table (.1.3.6.1.2.1.2.2.1.x)
123 + "IF-MIB::ifIndex": ".1.3.6.1.2.1.2.2.1.1",
124 + "IF-MIB::ifDescr": ".1.3.6.1.2.1.2.2.1.2",
125 + "IF-MIB::ifType": ".1.3.6.1.2.1.2.2.1.3",
126 + "IF-MIB::ifMtu": ".1.3.6.1.2.1.2.2.1.4",
127 + "IF-MIB::ifSpeed": ".1.3.6.1.2.1.2.2.1.5",
128 + "IF-MIB::ifPhysAddress": ".1.3.6.1.2.1.2.2.1.6",
129 + "IF-MIB::ifAdminStatus": ".1.3.6.1.2.1.2.2.1.7",
130 + "IF-MIB::ifOperStatus": ".1.3.6.1.2.1.2.2.1.8",
131 + "IF-MIB::ifLastChange": ".1.3.6.1.2.1.2.2.1.9",
132 + "IF-MIB::ifInOctets": ".1.3.6.1.2.1.2.2.1.10",
133 + "IF-MIB::ifInUcastPkts": ".1.3.6.1.2.1.2.2.1.11",
134 + "IF-MIB::ifInNUcastPkts": ".1.3.6.1.2.1.2.2.1.12",
135 + "IF-MIB::ifInDiscards": ".1.3.6.1.2.1.2.2.1.13",
136 + "IF-MIB::ifInErrors": ".1.3.6.1.2.1.2.2.1.14",
137 + "IF-MIB::ifInUnknownProtos": ".1.3.6.1.2.1.2.2.1.15",
138 + "IF-MIB::ifOutOctets": ".1.3.6.1.2.1.2.2.1.16",
139 + "IF-MIB::ifOutUcastPkts": ".1.3.6.1.2.1.2.2.1.17",
140 + "IF-MIB::ifOutNUcastPkts": ".1.3.6.1.2.1.2.2.1.18",
141 + "IF-MIB::ifOutDiscards": ".1.3.6.1.2.1.2.2.1.19",
142 + "IF-MIB::ifOutErrors": ".1.3.6.1.2.1.2.2.1.20",
143 + "IF-MIB::ifOutQLen": ".1.3.6.1.2.1.2.2.1.21",
144 + "IF-MIB::ifSpecific": ".1.3.6.1.2.1.2.2.1.22",
145 +
146 + // IF-MIB Extended table (.1.3.6.1.2.1.31.1.1.1.x)
147 + "IF-MIB::ifName": ".1.3.6.1.2.1.31.1.1.1.1",
148 + "IF-MIB::ifInMulticastPkts": ".1.3.6.1.2.1.31.1.1.1.2",
149 + "IF-MIB::ifInBroadcastPkts": ".1.3.6.1.2.1.31.1.1.1.3",
150 + "IF-MIB::ifOutMulticastPkts": ".1.3.6.1.2.1.31.1.1.1.4",
151 + "IF-MIB::ifOutBroadcastPkts": ".1.3.6.1.2.1.31.1.1.1.5",
152 + "IF-MIB::ifHCInOctets": ".1.3.6.1.2.1.31.1.1.1.6",
153 + "IF-MIB::ifHCInUcastPkts": ".1.3.6.1.2.1.31.1.1.1.7",
154 + "IF-MIB::ifHCInMulticastPkts": ".1.3.6.1.2.1.31.1.1.1.8",
155 + "IF-MIB::ifHCInBroadcastPkts": ".1.3.6.1.2.1.31.1.1.1.9",
156 + "IF-MIB::ifHCOutOctets": ".1.3.6.1.2.1.31.1.1.1.10",
157 + "IF-MIB::ifHCOutUcastPkts": ".1.3.6.1.2.1.31.1.1.1.11",
158 + "IF-MIB::ifHCOutMulticastPkts": ".1.3.6.1.2.1.31.1.1.1.12",
159 + "IF-MIB::ifHCOutBroadcastPkts": ".1.3.6.1.2.1.31.1.1.1.13",
160 + "IF-MIB::ifLinkUpDownTrapEnable": ".1.3.6.1.2.1.31.1.1.1.14",
161 + "IF-MIB::ifHighSpeed": ".1.3.6.1.2.1.31.1.1.1.15",
162 + "IF-MIB::ifPromiscuousMode": ".1.3.6.1.2.1.31.1.1.1.16",
163 + "IF-MIB::ifConnectorPresent": ".1.3.6.1.2.1.31.1.1.1.17",
164 + "IF-MIB::ifAlias": ".1.3.6.1.2.1.31.1.1.1.18",
165 + "IF-MIB::ifCounterDiscontinuityTime": ".1.3.6.1.2.1.31.1.1.1.19",
166 +
167 + // IF-MIB Stack table
168 + "IF-MIB::ifStackHigherLayer": ".1.3.6.1.2.1.31.1.2.1.1",
169 + "IF-MIB::ifStackLowerLayer": ".1.3.6.1.2.1.31.1.2.1.2",
170 + "IF-MIB::ifStackStatus": ".1.3.6.1.2.1.31.1.2.1.3",
171 +
172 + // ============================================================================
173 + // IP-MIB - RFC 4293
174 + // ============================================================================
175 + "IP-MIB::ipForwarding": ".1.3.6.1.2.1.4.1",
176 + "IP-MIB::ipDefaultTTL": ".1.3.6.1.2.1.4.2",
177 + "IP-MIB::ipInReceives": ".1.3.6.1.2.1.4.3",
178 + "IP-MIB::ipInHdrErrors": ".1.3.6.1.2.1.4.4",
179 + "IP-MIB::ipInAddrErrors": ".1.3.6.1.2.1.4.5",
180 + "IP-MIB::ipForwDatagrams": ".1.3.6.1.2.1.4.6",
181 + "IP-MIB::ipInUnknownProtos": ".1.3.6.1.2.1.4.7",
182 + "IP-MIB::ipInDiscards": ".1.3.6.1.2.1.4.8",
183 + "IP-MIB::ipInDelivers": ".1.3.6.1.2.1.4.9",
184 + "IP-MIB::ipOutRequests": ".1.3.6.1.2.1.4.10",
185 + "IP-MIB::ipOutDiscards": ".1.3.6.1.2.1.4.11",
186 + "IP-MIB::ipOutNoRoutes": ".1.3.6.1.2.1.4.12",
187 + "IP-MIB::ipReasmTimeout": ".1.3.6.1.2.1.4.13",
188 + "IP-MIB::ipReasmReqds": ".1.3.6.1.2.1.4.14",
189 + "IP-MIB::ipReasmOKs": ".1.3.6.1.2.1.4.15",
190 + "IP-MIB::ipReasmFails": ".1.3.6.1.2.1.4.16",
191 + "IP-MIB::ipFragOKs": ".1.3.6.1.2.1.4.17",
192 + "IP-MIB::ipFragFails": ".1.3.6.1.2.1.4.18",
193 + "IP-MIB::ipFragCreates": ".1.3.6.1.2.1.4.19",
194 +
195 + // IP Address table
196 + "IP-MIB::ipAdEntAddr": ".1.3.6.1.2.1.4.20.1.1",
197 + "IP-MIB::ipAdEntIfIndex": ".1.3.6.1.2.1.4.20.1.2",
198 + "IP-MIB::ipAdEntNetMask": ".1.3.6.1.2.1.4.20.1.3",
199 + "IP-MIB::ipAdEntBcastAddr": ".1.3.6.1.2.1.4.20.1.4",
200 + "IP-MIB::ipAdEntReasmMaxSize": ".1.3.6.1.2.1.4.20.1.5",
201 +
202 + // IP Route table
203 + "IP-MIB::ipRouteDest": ".1.3.6.1.2.1.4.21.1.1",
204 + "IP-MIB::ipRouteIfIndex": ".1.3.6.1.2.1.4.21.1.2",
205 + "IP-MIB::ipRouteMetric1": ".1.3.6.1.2.1.4.21.1.3",
206 + "IP-MIB::ipRouteMetric2": ".1.3.6.1.2.1.4.21.1.4",
207 + "IP-MIB::ipRouteMetric3": ".1.3.6.1.2.1.4.21.1.5",
208 + "IP-MIB::ipRouteMetric4": ".1.3.6.1.2.1.4.21.1.6",
209 + "IP-MIB::ipRouteNextHop": ".1.3.6.1.2.1.4.21.1.7",
210 + "IP-MIB::ipRouteType": ".1.3.6.1.2.1.4.21.1.8",
211 + "IP-MIB::ipRouteProto": ".1.3.6.1.2.1.4.21.1.9",
212 + "IP-MIB::ipRouteAge": ".1.3.6.1.2.1.4.21.1.10",
213 + "IP-MIB::ipRouteMask": ".1.3.6.1.2.1.4.21.1.11",
214 + "IP-MIB::ipRouteMetric5": ".1.3.6.1.2.1.4.21.1.12",
215 + "IP-MIB::ipRouteInfo": ".1.3.6.1.2.1.4.21.1.13",
216 +
217 + // IP Net-to-Media table
218 + "IP-MIB::ipNetToMediaIfIndex": ".1.3.6.1.2.1.4.22.1.1",
219 + "IP-MIB::ipNetToMediaPhysAddress": ".1.3.6.1.2.1.4.22.1.2",
220 + "IP-MIB::ipNetToMediaNetAddress": ".1.3.6.1.2.1.4.22.1.3",
221 + "IP-MIB::ipNetToMediaType": ".1.3.6.1.2.1.4.22.1.4",
222 +
223 + // IP Forwarding table (newer)
224 + "IP-MIB::inetCidrRouteNumber": ".1.3.6.1.2.1.4.24.6",
225 +
226 + // ============================================================================
227 + // ICMP-MIB - RFC 4293
228 + // ============================================================================
229 + "IP-MIB::icmpInMsgs": ".1.3.6.1.2.1.5.1",
230 + "IP-MIB::icmpInErrors": ".1.3.6.1.2.1.5.2",
231 + "IP-MIB::icmpInDestUnreachs": ".1.3.6.1.2.1.5.3",
232 + "IP-MIB::icmpInTimeExcds": ".1.3.6.1.2.1.5.4",
233 + "IP-MIB::icmpInParmProbs": ".1.3.6.1.2.1.5.5",
234 + "IP-MIB::icmpInSrcQuenchs": ".1.3.6.1.2.1.5.6",
235 + "IP-MIB::icmpInRedirects": ".1.3.6.1.2.1.5.7",
236 + "IP-MIB::icmpInEchos": ".1.3.6.1.2.1.5.8",
237 + "IP-MIB::icmpInEchoReps": ".1.3.6.1.2.1.5.9",
238 + "IP-MIB::icmpInTimestamps": ".1.3.6.1.2.1.5.10",
239 + "IP-MIB::icmpInTimestampReps": ".1.3.6.1.2.1.5.11",
240 + "IP-MIB::icmpInAddrMasks": ".1.3.6.1.2.1.5.12",
241 + "IP-MIB::icmpInAddrMaskReps": ".1.3.6.1.2.1.5.13",
242 + "IP-MIB::icmpOutMsgs": ".1.3.6.1.2.1.5.14",
243 + "IP-MIB::icmpOutErrors": ".1.3.6.1.2.1.5.15",
244 + "IP-MIB::icmpOutDestUnreachs": ".1.3.6.1.2.1.5.16",
245 + "IP-MIB::icmpOutTimeExcds": ".1.3.6.1.2.1.5.17",
246 + "IP-MIB::icmpOutParmProbs": ".1.3.6.1.2.1.5.18",
247 + "IP-MIB::icmpOutSrcQuenchs": ".1.3.6.1.2.1.5.19",
248 + "IP-MIB::icmpOutRedirects": ".1.3.6.1.2.1.5.20",
249 + "IP-MIB::icmpOutEchos": ".1.3.6.1.2.1.5.21",
250 + "IP-MIB::icmpOutEchoReps": ".1.3.6.1.2.1.5.22",
251 + "IP-MIB::icmpOutTimestamps": ".1.3.6.1.2.1.5.23",
252 + "IP-MIB::icmpOutTimestampReps": ".1.3.6.1.2.1.5.24",
253 + "IP-MIB::icmpOutAddrMasks": ".1.3.6.1.2.1.5.25",
254 + "IP-MIB::icmpOutAddrMaskReps": ".1.3.6.1.2.1.5.26",
255 +
256 + // ============================================================================
257 + // TCP-MIB - RFC 4022
258 + // ============================================================================
259 + "TCP-MIB::tcpRtoAlgorithm": ".1.3.6.1.2.1.6.1",
260 + "TCP-MIB::tcpRtoMin": ".1.3.6.1.2.1.6.2",
261 + "TCP-MIB::tcpRtoMax": ".1.3.6.1.2.1.6.3",
262 + "TCP-MIB::tcpMaxConn": ".1.3.6.1.2.1.6.4",
263 + "TCP-MIB::tcpActiveOpens": ".1.3.6.1.2.1.6.5",
264 + "TCP-MIB::tcpPassiveOpens": ".1.3.6.1.2.1.6.6",
265 + "TCP-MIB::tcpAttemptFails": ".1.3.6.1.2.1.6.7",
266 + "TCP-MIB::tcpEstabResets": ".1.3.6.1.2.1.6.8",
267 + "TCP-MIB::tcpCurrEstab": ".1.3.6.1.2.1.6.9",
268 + "TCP-MIB::tcpInSegs": ".1.3.6.1.2.1.6.10",
269 + "TCP-MIB::tcpOutSegs": ".1.3.6.1.2.1.6.11",
270 + "TCP-MIB::tcpRetransSegs": ".1.3.6.1.2.1.6.12",
271 + "TCP-MIB::tcpInErrs": ".1.3.6.1.2.1.6.14",
272 + "TCP-MIB::tcpOutRsts": ".1.3.6.1.2.1.6.15",
273 + "TCP-MIB::tcpHCInSegs": ".1.3.6.1.2.1.6.17",
274 + "TCP-MIB::tcpHCOutSegs": ".1.3.6.1.2.1.6.18",
275 +
276 + // TCP Connection table
277 + "TCP-MIB::tcpConnState": ".1.3.6.1.2.1.6.13.1.1",
278 + "TCP-MIB::tcpConnLocalAddress": ".1.3.6.1.2.1.6.13.1.2",
279 + "TCP-MIB::tcpConnLocalPort": ".1.3.6.1.2.1.6.13.1.3",
280 + "TCP-MIB::tcpConnRemAddress": ".1.3.6.1.2.1.6.13.1.4",
281 + "TCP-MIB::tcpConnRemPort": ".1.3.6.1.2.1.6.13.1.5",
282 +
283 + // ============================================================================
284 + // UDP-MIB - RFC 4113
285 + // ============================================================================
286 + "UDP-MIB::udpInDatagrams": ".1.3.6.1.2.1.7.1",
287 + "UDP-MIB::udpNoPorts": ".1.3.6.1.2.1.7.2",
288 + "UDP-MIB::udpInErrors": ".1.3.6.1.2.1.7.3",
289 + "UDP-MIB::udpOutDatagrams": ".1.3.6.1.2.1.7.4",
290 + "UDP-MIB::udpHCInDatagrams": ".1.3.6.1.2.1.7.8",
291 + "UDP-MIB::udpHCOutDatagrams": ".1.3.6.1.2.1.7.9",
292 +
293 + // UDP Listener table
294 + "UDP-MIB::udpLocalAddress": ".1.3.6.1.2.1.7.5.1.1",
295 + "UDP-MIB::udpLocalPort": ".1.3.6.1.2.1.7.5.1.2",
296 +
297 + // ============================================================================
298 + // HOST-RESOURCES-MIB - RFC 2790
299 + // ============================================================================
300 + // System group (.1.3.6.1.2.1.25.1.x)
301 + "HOST-RESOURCES-MIB::hrSystemUptime": ".1.3.6.1.2.1.25.1.1",
302 + "HOST-RESOURCES-MIB::hrSystemDate": ".1.3.6.1.2.1.25.1.2",
303 + "HOST-RESOURCES-MIB::hrSystemInitialLoadDevice": ".1.3.6.1.2.1.25.1.3",
304 + "HOST-RESOURCES-MIB::hrSystemInitialLoadParameters": ".1.3.6.1.2.1.25.1.4",
305 + "HOST-RESOURCES-MIB::hrSystemNumUsers": ".1.3.6.1.2.1.25.1.5",
306 + "HOST-RESOURCES-MIB::hrSystemProcesses": ".1.3.6.1.2.1.25.1.6",
307 + "HOST-RESOURCES-MIB::hrSystemMaxProcesses": ".1.3.6.1.2.1.25.1.7",
308 +
309 + // Storage group (.1.3.6.1.2.1.25.2.x)
310 + "HOST-RESOURCES-MIB::hrMemorySize": ".1.3.6.1.2.1.25.2.2",
311 + "HOST-RESOURCES-MIB::hrStorageIndex": ".1.3.6.1.2.1.25.2.3.1.1",
312 + "HOST-RESOURCES-MIB::hrStorageType": ".1.3.6.1.2.1.25.2.3.1.2",
313 + "HOST-RESOURCES-MIB::hrStorageDescr": ".1.3.6.1.2.1.25.2.3.1.3",
314 + "HOST-RESOURCES-MIB::hrStorageAllocationUnits": ".1.3.6.1.2.1.25.2.3.1.4",
315 + "HOST-RESOURCES-MIB::hrStorageSize": ".1.3.6.1.2.1.25.2.3.1.5",
316 + "HOST-RESOURCES-MIB::hrStorageUsed": ".1.3.6.1.2.1.25.2.3.1.6",
317 + "HOST-RESOURCES-MIB::hrStorageAllocationFailures": ".1.3.6.1.2.1.25.2.3.1.7",
318 +
319 + // Device group (.1.3.6.1.2.1.25.3.x)
320 + "HOST-RESOURCES-MIB::hrDeviceIndex": ".1.3.6.1.2.1.25.3.2.1.1",
321 + "HOST-RESOURCES-MIB::hrDeviceType": ".1.3.6.1.2.1.25.3.2.1.2",
322 + "HOST-RESOURCES-MIB::hrDeviceDescr": ".1.3.6.1.2.1.25.3.2.1.3",
323 + "HOST-RESOURCES-MIB::hrDeviceID": ".1.3.6.1.2.1.25.3.2.1.4",
324 + "HOST-RESOURCES-MIB::hrDeviceStatus": ".1.3.6.1.2.1.25.3.2.1.5",
325 + "HOST-RESOURCES-MIB::hrDeviceErrors": ".1.3.6.1.2.1.25.3.2.1.6",
326 + "HOST-RESOURCES-MIB::hrProcessorFrwID": ".1.3.6.1.2.1.25.3.3.1.1",
327 + "HOST-RESOURCES-MIB::hrProcessorLoad": ".1.3.6.1.2.1.25.3.3.1.2",
328 + "HOST-RESOURCES-MIB::hrNetworkIfIndex": ".1.3.6.1.2.1.25.3.4.1.1",
329 + "HOST-RESOURCES-MIB::hrPrinterStatus": ".1.3.6.1.2.1.25.3.5.1.1",
330 + "HOST-RESOURCES-MIB::hrPrinterDetectedErrorState": ".1.3.6.1.2.1.25.3.5.1.2",
331 + "HOST-RESOURCES-MIB::hrDiskStorageAccess": ".1.3.6.1.2.1.25.3.6.1.1",
332 + "HOST-RESOURCES-MIB::hrDiskStorageMedia": ".1.3.6.1.2.1.25.3.6.1.2",
333 + "HOST-RESOURCES-MIB::hrDiskStorageRemoveble": ".1.3.6.1.2.1.25.3.6.1.3",
334 + "HOST-RESOURCES-MIB::hrDiskStorageCapacity": ".1.3.6.1.2.1.25.3.6.1.4",
335 + "HOST-RESOURCES-MIB::hrPartitionIndex": ".1.3.6.1.2.1.25.3.7.1.1",
336 + "HOST-RESOURCES-MIB::hrPartitionLabel": ".1.3.6.1.2.1.25.3.7.1.2",
337 + "HOST-RESOURCES-MIB::hrPartitionID": ".1.3.6.1.2.1.25.3.7.1.3",
338 + "HOST-RESOURCES-MIB::hrPartitionSize": ".1.3.6.1.2.1.25.3.7.1.4",
339 + "HOST-RESOURCES-MIB::hrPartitionFSIndex": ".1.3.6.1.2.1.25.3.7.1.5",
340 + "HOST-RESOURCES-MIB::hrFSIndex": ".1.3.6.1.2.1.25.3.8.1.1",
341 + "HOST-RESOURCES-MIB::hrFSMountPoint": ".1.3.6.1.2.1.25.3.8.1.2",
342 + "HOST-RESOURCES-MIB::hrFSRemoteMountPoint": ".1.3.6.1.2.1.25.3.8.1.3",
343 + "HOST-RESOURCES-MIB::hrFSType": ".1.3.6.1.2.1.25.3.8.1.4",
344 + "HOST-RESOURCES-MIB::hrFSAccess": ".1.3.6.1.2.1.25.3.8.1.5",
345 + "HOST-RESOURCES-MIB::hrFSBootable": ".1.3.6.1.2.1.25.3.8.1.6",
346 + "HOST-RESOURCES-MIB::hrFSStorageIndex": ".1.3.6.1.2.1.25.3.8.1.7",
347 + "HOST-RESOURCES-MIB::hrFSLastFullBackupDate": ".1.3.6.1.2.1.25.3.8.1.8",
348 + "HOST-RESOURCES-MIB::hrFSLastPartialBackupDate": ".1.3.6.1.2.1.25.3.8.1.9",
349 +
350 + // Running Software group (.1.3.6.1.2.1.25.4.x)
351 + "HOST-RESOURCES-MIB::hrSWOSIndex": ".1.3.6.1.2.1.25.4.1",
352 + "HOST-RESOURCES-MIB::hrSWRunIndex": ".1.3.6.1.2.1.25.4.2.1.1",
353 + "HOST-RESOURCES-MIB::hrSWRunName": ".1.3.6.1.2.1.25.4.2.1.2",
354 + "HOST-RESOURCES-MIB::hrSWRunID": ".1.3.6.1.2.1.25.4.2.1.3",
355 + "HOST-RESOURCES-MIB::hrSWRunPath": ".1.3.6.1.2.1.25.4.2.1.4",
356 + "HOST-RESOURCES-MIB::hrSWRunParameters": ".1.3.6.1.2.1.25.4.2.1.5",
357 + "HOST-RESOURCES-MIB::hrSWRunType": ".1.3.6.1.2.1.25.4.2.1.6",
358 + "HOST-RESOURCES-MIB::hrSWRunStatus": ".1.3.6.1.2.1.25.4.2.1.7",
359 +
360 + // Running Software Performance group
361 + "HOST-RESOURCES-MIB::hrSWRunPerfCPU": ".1.3.6.1.2.1.25.5.1.1.1",
362 + "HOST-RESOURCES-MIB::hrSWRunPerfMem": ".1.3.6.1.2.1.25.5.1.1.2",
363 +
364 + // Installed Software group (.1.3.6.1.2.1.25.6.x)
365 + "HOST-RESOURCES-MIB::hrSWInstalledLastChange": ".1.3.6.1.2.1.25.6.1",
366 + "HOST-RESOURCES-MIB::hrSWInstalledLastUpdateTime": ".1.3.6.1.2.1.25.6.2",
367 + "HOST-RESOURCES-MIB::hrSWInstalledIndex": ".1.3.6.1.2.1.25.6.3.1.1",
368 + "HOST-RESOURCES-MIB::hrSWInstalledName": ".1.3.6.1.2.1.25.6.3.1.2",
369 + "HOST-RESOURCES-MIB::hrSWInstalledID": ".1.3.6.1.2.1.25.6.3.1.3",
370 + "HOST-RESOURCES-MIB::hrSWInstalledType": ".1.3.6.1.2.1.25.6.3.1.4",
371 + "HOST-RESOURCES-MIB::hrSWInstalledDate": ".1.3.6.1.2.1.25.6.3.1.5",
372 +
373 + // ============================================================================
374 + // ENTITY-MIB - RFC 6933
375 + // ============================================================================
376 + "ENTITY-MIB::entPhysicalIndex": ".1.3.6.1.2.1.47.1.1.1.1.1",
377 + "ENTITY-MIB::entPhysicalDescr": ".1.3.6.1.2.1.47.1.1.1.1.2",
378 + "ENTITY-MIB::entPhysicalVendorType": ".1.3.6.1.2.1.47.1.1.1.1.3",
379 + "ENTITY-MIB::entPhysicalContainedIn": ".1.3.6.1.2.1.47.1.1.1.1.4",
380 + "ENTITY-MIB::entPhysicalClass": ".1.3.6.1.2.1.47.1.1.1.1.5",
381 + "ENTITY-MIB::entPhysicalParentRelPos": ".1.3.6.1.2.1.47.1.1.1.1.6",
382 + "ENTITY-MIB::entPhysicalName": ".1.3.6.1.2.1.47.1.1.1.1.7",
383 + "ENTITY-MIB::entPhysicalHardwareRev": ".1.3.6.1.2.1.47.1.1.1.1.8",
384 + "ENTITY-MIB::entPhysicalFirmwareRev": ".1.3.6.1.2.1.47.1.1.1.1.9",
385 + "ENTITY-MIB::entPhysicalSoftwareRev": ".1.3.6.1.2.1.47.1.1.1.1.10",
386 + "ENTITY-MIB::entPhysicalSerialNum": ".1.3.6.1.2.1.47.1.1.1.1.11",
387 + "ENTITY-MIB::entPhysicalMfgName": ".1.3.6.1.2.1.47.1.1.1.1.12",
388 + "ENTITY-MIB::entPhysicalModelName": ".1.3.6.1.2.1.47.1.1.1.1.13",
389 + "ENTITY-MIB::entPhysicalAlias": ".1.3.6.1.2.1.47.1.1.1.1.14",
390 + "ENTITY-MIB::entPhysicalAssetID": ".1.3.6.1.2.1.47.1.1.1.1.15",
391 + "ENTITY-MIB::entPhysicalIsFRU": ".1.3.6.1.2.1.47.1.1.1.1.16",
392 + "ENTITY-MIB::entPhysicalMfgDate": ".1.3.6.1.2.1.47.1.1.1.1.17",
393 + "ENTITY-MIB::entPhysicalUris": ".1.3.6.1.2.1.47.1.1.1.1.18",
394 +
395 + // ============================================================================
396 + // EtherLike-MIB - RFC 3635
397 + // ============================================================================
398 + "EtherLike-MIB::dot3StatsIndex": ".1.3.6.1.2.1.10.7.2.1.1",
399 + "EtherLike-MIB::dot3StatsAlignmentErrors": ".1.3.6.1.2.1.10.7.2.1.2",
400 + "EtherLike-MIB::dot3StatsFCSErrors": ".1.3.6.1.2.1.10.7.2.1.3",
401 + "EtherLike-MIB::dot3StatsSingleCollisionFrames": ".1.3.6.1.2.1.10.7.2.1.4",
402 + "EtherLike-MIB::dot3StatsMultipleCollisionFrames": ".1.3.6.1.2.1.10.7.2.1.5",
403 + "EtherLike-MIB::dot3StatsSQETestErrors": ".1.3.6.1.2.1.10.7.2.1.6",
404 + "EtherLike-MIB::dot3StatsDeferredTransmissions": ".1.3.6.1.2.1.10.7.2.1.7",
405 + "EtherLike-MIB::dot3StatsLateCollisions": ".1.3.6.1.2.1.10.7.2.1.8",
406 + "EtherLike-MIB::dot3StatsExcessiveCollisions": ".1.3.6.1.2.1.10.7.2.1.9",
407 + "EtherLike-MIB::dot3StatsInternalMacTransmitErrors": ".1.3.6.1.2.1.10.7.2.1.10",
408 + "EtherLike-MIB::dot3StatsCarrierSenseErrors": ".1.3.6.1.2.1.10.7.2.1.11",
409 + "EtherLike-MIB::dot3StatsFrameTooLongs": ".1.3.6.1.2.1.10.7.2.1.13",
410 + "EtherLike-MIB::dot3StatsInternalMacReceiveErrors": ".1.3.6.1.2.1.10.7.2.1.16",
411 + "EtherLike-MIB::dot3StatsSymbolErrors": ".1.3.6.1.2.1.10.7.2.1.18",
412 + "EtherLike-MIB::dot3StatsDuplexStatus": ".1.3.6.1.2.1.10.7.2.1.19",
413 +
414 + // ============================================================================
415 + // UCD-SNMP-MIB (Linux/Unix systems) - NET-SNMP
416 + // ============================================================================
417 + // Memory statistics
418 + "UCD-SNMP-MIB::memIndex": ".1.3.6.1.4.1.2021.4.1",
419 + "UCD-SNMP-MIB::memErrorName": ".1.3.6.1.4.1.2021.4.2",
420 + "UCD-SNMP-MIB::memTotalSwap": ".1.3.6.1.4.1.2021.4.3",
421 + "UCD-SNMP-MIB::memAvailSwap": ".1.3.6.1.4.1.2021.4.4",
422 + "UCD-SNMP-MIB::memTotalReal": ".1.3.6.1.4.1.2021.4.5",
423 + "UCD-SNMP-MIB::memAvailReal": ".1.3.6.1.4.1.2021.4.6",
424 + "UCD-SNMP-MIB::memTotalFree": ".1.3.6.1.4.1.2021.4.11",
425 + "UCD-SNMP-MIB::memMinimumSwap": ".1.3.6.1.4.1.2021.4.12",
426 + "UCD-SNMP-MIB::memShared": ".1.3.6.1.4.1.2021.4.13",
427 + "UCD-SNMP-MIB::memBuffer": ".1.3.6.1.4.1.2021.4.14",
428 + "UCD-SNMP-MIB::memCached": ".1.3.6.1.4.1.2021.4.15",
429 + "UCD-SNMP-MIB::memSwapError": ".1.3.6.1.4.1.2021.4.100",
430 + "UCD-SNMP-MIB::memSwapErrorMsg": ".1.3.6.1.4.1.2021.4.101",
431 +
432 + // CPU statistics
433 + "UCD-SNMP-MIB::ssIndex": ".1.3.6.1.4.1.2021.11.1",
434 + "UCD-SNMP-MIB::ssErrorName": ".1.3.6.1.4.1.2021.11.2",
435 + "UCD-SNMP-MIB::ssSwapIn": ".1.3.6.1.4.1.2021.11.3",
436 + "UCD-SNMP-MIB::ssSwapOut": ".1.3.6.1.4.1.2021.11.4",
437 + "UCD-SNMP-MIB::ssIOSent": ".1.3.6.1.4.1.2021.11.5",
438 + "UCD-SNMP-MIB::ssIOReceive": ".1.3.6.1.4.1.2021.11.6",
439 + "UCD-SNMP-MIB::ssSysInterrupts": ".1.3.6.1.4.1.2021.11.7",
440 + "UCD-SNMP-MIB::ssSysContext": ".1.3.6.1.4.1.2021.11.8",
441 + "UCD-SNMP-MIB::ssCpuUser": ".1.3.6.1.4.1.2021.11.9",
442 + "UCD-SNMP-MIB::ssCpuSystem": ".1.3.6.1.4.1.2021.11.10",
443 + "UCD-SNMP-MIB::ssCpuIdle": ".1.3.6.1.4.1.2021.11.11",
444 + "UCD-SNMP-MIB::ssCpuRawUser": ".1.3.6.1.4.1.2021.11.50",
445 + "UCD-SNMP-MIB::ssCpuRawNice": ".1.3.6.1.4.1.2021.11.51",
446 + "UCD-SNMP-MIB::ssCpuRawSystem": ".1.3.6.1.4.1.2021.11.52",
447 + "UCD-SNMP-MIB::ssCpuRawIdle": ".1.3.6.1.4.1.2021.11.53",
448 + "UCD-SNMP-MIB::ssCpuRawWait": ".1.3.6.1.4.1.2021.11.54",
449 + "UCD-SNMP-MIB::ssCpuRawKernel": ".1.3.6.1.4.1.2021.11.55",
450 + "UCD-SNMP-MIB::ssCpuRawInterrupt": ".1.3.6.1.4.1.2021.11.56",
451 + "UCD-SNMP-MIB::ssIORawSent": ".1.3.6.1.4.1.2021.11.57",
452 + "UCD-SNMP-MIB::ssIORawReceived": ".1.3.6.1.4.1.2021.11.58",
453 + "UCD-SNMP-MIB::ssRawInterrupts": ".1.3.6.1.4.1.2021.11.59",
454 + "UCD-SNMP-MIB::ssRawContexts": ".1.3.6.1.4.1.2021.11.60",
455 + "UCD-SNMP-MIB::ssCpuRawSoftIRQ": ".1.3.6.1.4.1.2021.11.61",
456 + "UCD-SNMP-MIB::ssCpuRawSteal": ".1.3.6.1.4.1.2021.11.64",
457 + "UCD-SNMP-MIB::ssCpuRawGuest": ".1.3.6.1.4.1.2021.11.65",
458 + "UCD-SNMP-MIB::ssCpuRawGuestNice": ".1.3.6.1.4.1.2021.11.66",
459 +
460 + // Load average
461 + "UCD-SNMP-MIB::laIndex": ".1.3.6.1.4.1.2021.10.1.1",
462 + "UCD-SNMP-MIB::laNames": ".1.3.6.1.4.1.2021.10.1.2",
463 + "UCD-SNMP-MIB::laLoad": ".1.3.6.1.4.1.2021.10.1.3",
464 + "UCD-SNMP-MIB::laConfig": ".1.3.6.1.4.1.2021.10.1.4",
465 + "UCD-SNMP-MIB::laLoadInt": ".1.3.6.1.4.1.2021.10.1.5",
466 + "UCD-SNMP-MIB::laLoadFloat": ".1.3.6.1.4.1.2021.10.1.6",
467 + "UCD-SNMP-MIB::laErrorFlag": ".1.3.6.1.4.1.2021.10.1.100",
468 + "UCD-SNMP-MIB::laErrMessage": ".1.3.6.1.4.1.2021.10.1.101",
469 +
470 + // Disk I/O statistics
471 + "UCD-SNMP-MIB::diskIOIndex": ".1.3.6.1.4.1.2021.13.15.1.1.1",
472 + "UCD-SNMP-MIB::diskIODevice": ".1.3.6.1.4.1.2021.13.15.1.1.2",
473 + "UCD-SNMP-MIB::diskIONRead": ".1.3.6.1.4.1.2021.13.15.1.1.3",
474 + "UCD-SNMP-MIB::diskIONWritten": ".1.3.6.1.4.1.2021.13.15.1.1.4",
475 + "UCD-SNMP-MIB::diskIOReads": ".1.3.6.1.4.1.2021.13.15.1.1.5",
476 + "UCD-SNMP-MIB::diskIOWrites": ".1.3.6.1.4.1.2021.13.15.1.1.6",
477 + "UCD-SNMP-MIB::diskIONReadX": ".1.3.6.1.4.1.2021.13.15.1.1.12",
478 + "UCD-SNMP-MIB::diskIONWrittenX": ".1.3.6.1.4.1.2021.13.15.1.1.13",
479 +
480 + // ============================================================================
481 + // UPS-MIB - RFC 1628
482 + // ============================================================================
483 + "UPS-MIB::upsIdentManufacturer": ".1.3.6.1.2.1.33.1.1.1",
484 + "UPS-MIB::upsIdentModel": ".1.3.6.1.2.1.33.1.1.2",
485 + "UPS-MIB::upsIdentUPSSoftwareVersion": ".1.3.6.1.2.1.33.1.1.3",
486 + "UPS-MIB::upsIdentAgentSoftwareVersion": ".1.3.6.1.2.1.33.1.1.4",
487 + "UPS-MIB::upsIdentName": ".1.3.6.1.2.1.33.1.1.5",
488 + "UPS-MIB::upsIdentAttachedDevices": ".1.3.6.1.2.1.33.1.1.6",
489 + "UPS-MIB::upsBatteryStatus": ".1.3.6.1.2.1.33.1.2.1",
490 + "UPS-MIB::upsSecondsOnBattery": ".1.3.6.1.2.1.33.1.2.2",
491 + "UPS-MIB::upsEstimatedMinutesRemaining": ".1.3.6.1.2.1.33.1.2.3",
492 + "UPS-MIB::upsEstimatedChargeRemaining": ".1.3.6.1.2.1.33.1.2.4",
493 + "UPS-MIB::upsBatteryVoltage": ".1.3.6.1.2.1.33.1.2.5",
494 + "UPS-MIB::upsBatteryCurrent": ".1.3.6.1.2.1.33.1.2.6",
495 + "UPS-MIB::upsBatteryTemperature": ".1.3.6.1.2.1.33.1.2.7",
496 + "UPS-MIB::upsInputLineBads": ".1.3.6.1.2.1.33.1.3.1",
497 + "UPS-MIB::upsInputNumLines": ".1.3.6.1.2.1.33.1.3.2",
498 + "UPS-MIB::upsInputLineIndex": ".1.3.6.1.2.1.33.1.3.3.1.1",
499 + "UPS-MIB::upsInputFrequency": ".1.3.6.1.2.1.33.1.3.3.1.2",
500 + "UPS-MIB::upsInputVoltage": ".1.3.6.1.2.1.33.1.3.3.1.3",
501 + "UPS-MIB::upsInputCurrent": ".1.3.6.1.2.1.33.1.3.3.1.4",
502 + "UPS-MIB::upsInputTruePower": ".1.3.6.1.2.1.33.1.3.3.1.5",
503 + "UPS-MIB::upsOutputSource": ".1.3.6.1.2.1.33.1.4.1",
504 + "UPS-MIB::upsOutputFrequency": ".1.3.6.1.2.1.33.1.4.2",
505 + "UPS-MIB::upsOutputNumLines": ".1.3.6.1.2.1.33.1.4.3",
506 + "UPS-MIB::upsOutputLineIndex": ".1.3.6.1.2.1.33.1.4.4.1.1",
507 + "UPS-MIB::upsOutputVoltage": ".1.3.6.1.2.1.33.1.4.4.1.2",
508 + "UPS-MIB::upsOutputCurrent": ".1.3.6.1.2.1.33.1.4.4.1.3",
509 + "UPS-MIB::upsOutputPower": ".1.3.6.1.2.1.33.1.4.4.1.4",
510 + "UPS-MIB::upsOutputPercentLoad": ".1.3.6.1.2.1.33.1.4.4.1.5",
511 + "UPS-MIB::upsBypassFrequency": ".1.3.6.1.2.1.33.1.5.1",
512 + "UPS-MIB::upsBypassNumLines": ".1.3.6.1.2.1.33.1.5.2",
513 + "UPS-MIB::upsAlarmsPresent": ".1.3.6.1.2.1.33.1.6.1",
514 + "UPS-MIB::upsTestId": ".1.3.6.1.2.1.33.1.7.1",
515 + "UPS-MIB::upsTestSpinLock": ".1.3.6.1.2.1.33.1.7.2",
516 + "UPS-MIB::upsTestResultsSummary": ".1.3.6.1.2.1.33.1.7.3",
517 + "UPS-MIB::upsTestResultsDetail": ".1.3.6.1.2.1.33.1.7.4",
518 + "UPS-MIB::upsTestStartTime": ".1.3.6.1.2.1.33.1.7.5",
519 + "UPS-MIB::upsTestElapsedTime": ".1.3.6.1.2.1.33.1.7.6",
520 +
521 + // ============================================================================
522 + // LLDP-MIB - IEEE 802.1AB
523 + // ============================================================================
524 + "LLDP-MIB::lldpLocChassisIdSubtype": ".1.0.8802.1.1.2.1.3.1",
525 + "LLDP-MIB::lldpLocChassisId": ".1.0.8802.1.1.2.1.3.2",
526 + "LLDP-MIB::lldpLocSysName": ".1.0.8802.1.1.2.1.3.3",
527 + "LLDP-MIB::lldpLocSysDesc": ".1.0.8802.1.1.2.1.3.4",
528 + "LLDP-MIB::lldpLocSysCapSupported": ".1.0.8802.1.1.2.1.3.5",
529 + "LLDP-MIB::lldpLocSysCapEnabled": ".1.0.8802.1.1.2.1.3.6",
530 + "LLDP-MIB::lldpRemChassisIdSubtype": ".1.0.8802.1.1.2.1.4.1.1.4",
531 + "LLDP-MIB::lldpRemChassisId": ".1.0.8802.1.1.2.1.4.1.1.5",
532 + "LLDP-MIB::lldpRemPortIdSubtype": ".1.0.8802.1.1.2.1.4.1.1.6",
533 + "LLDP-MIB::lldpRemPortId": ".1.0.8802.1.1.2.1.4.1.1.7",
534 + "LLDP-MIB::lldpRemPortDesc": ".1.0.8802.1.1.2.1.4.1.1.8",
535 + "LLDP-MIB::lldpRemSysName": ".1.0.8802.1.1.2.1.4.1.1.9",
536 + "LLDP-MIB::lldpRemSysDesc": ".1.0.8802.1.1.2.1.4.1.1.10",
537 + "LLDP-MIB::lldpRemSysCapSupported": ".1.0.8802.1.1.2.1.4.1.1.11",
538 + "LLDP-MIB::lldpRemSysCapEnabled": ".1.0.8802.1.1.2.1.4.1.1.12",
539 +
540 + // ============================================================================
541 + // Printer-MIB - RFC 3805
542 + // ============================================================================
543 + "Printer-MIB::prtGeneralConfigChanges": ".1.3.6.1.2.1.43.5.1.1.1",
544 + "Printer-MIB::prtGeneralCurrentLocalization": ".1.3.6.1.2.1.43.5.1.1.2",
545 + "Printer-MIB::prtGeneralReset": ".1.3.6.1.2.1.43.5.1.1.3",
546 + "Printer-MIB::prtGeneralCurrentOperator": ".1.3.6.1.2.1.43.5.1.1.4",
547 + "Printer-MIB::prtGeneralServicePerson": ".1.3.6.1.2.1.43.5.1.1.5",
548 + "Printer-MIB::prtInputDefaultIndex": ".1.3.6.1.2.1.43.5.1.1.6",
549 + "Printer-MIB::prtOutputDefaultIndex": ".1.3.6.1.2.1.43.5.1.1.7",
550 + "Printer-MIB::prtMarkerDefaultIndex": ".1.3.6.1.2.1.43.5.1.1.8",
551 + "Printer-MIB::prtMediaPathDefaultIndex": ".1.3.6.1.2.1.43.5.1.1.9",
552 + "Printer-MIB::prtConsoleLocalization": ".1.3.6.1.2.1.43.5.1.1.10",
553 + "Printer-MIB::prtConsoleNumberOfDisplayLines": ".1.3.6.1.2.1.43.5.1.1.11",
554 + "Printer-MIB::prtConsoleNumberOfDisplayChars": ".1.3.6.1.2.1.43.5.1.1.12",
555 + "Printer-MIB::prtConsoleDisable": ".1.3.6.1.2.1.43.5.1.1.13",
556 + "Printer-MIB::prtCoverIndex": ".1.3.6.1.2.1.43.6.1.1.1",
557 + "Printer-MIB::prtCoverDescription": ".1.3.6.1.2.1.43.6.1.1.2",
558 + "Printer-MIB::prtCoverStatus": ".1.3.6.1.2.1.43.6.1.1.3",
559 + "Printer-MIB::prtMarkerSuppliesIndex": ".1.3.6.1.2.1.43.11.1.1.1",
560 + "Printer-MIB::prtMarkerSuppliesMarkerIndex": ".1.3.6.1.2.1.43.11.1.1.2",
561 + "Printer-MIB::prtMarkerSuppliesColorantIndex": ".1.3.6.1.2.1.43.11.1.1.3",
562 + "Printer-MIB::prtMarkerSuppliesClass": ".1.3.6.1.2.1.43.11.1.1.4",
563 + "Printer-MIB::prtMarkerSuppliesType": ".1.3.6.1.2.1.43.11.1.1.5",
564 + "Printer-MIB::prtMarkerSuppliesDescription": ".1.3.6.1.2.1.43.11.1.1.6",
565 + "Printer-MIB::prtMarkerSuppliesSupplyUnit": ".1.3.6.1.2.1.43.11.1.1.7",
566 + "Printer-MIB::prtMarkerSuppliesMaxCapacity": ".1.3.6.1.2.1.43.11.1.1.8",
567 + "Printer-MIB::prtMarkerSuppliesLevel": ".1.3.6.1.2.1.43.11.1.1.9",
568 + "Printer-MIB::prtAlertIndex": ".1.3.6.1.2.1.43.18.1.1.1",
569 + "Printer-MIB::prtAlertSeverityLevel": ".1.3.6.1.2.1.43.18.1.1.2",
570 + "Printer-MIB::prtAlertTrainingLevel": ".1.3.6.1.2.1.43.18.1.1.3",
571 + "Printer-MIB::prtAlertGroup": ".1.3.6.1.2.1.43.18.1.1.4",
572 + "Printer-MIB::prtAlertGroupIndex": ".1.3.6.1.2.1.43.18.1.1.5",
573 + "Printer-MIB::prtAlertLocation": ".1.3.6.1.2.1.43.18.1.1.6",
574 + "Printer-MIB::prtAlertCode": ".1.3.6.1.2.1.43.18.1.1.7",
575 + "Printer-MIB::prtAlertDescription": ".1.3.6.1.2.1.43.18.1.1.8",
576 + "Printer-MIB::prtAlertTime": ".1.3.6.1.2.1.43.18.1.1.9",
577 +
578 + // ============================================================================
579 + // BGP4-MIB - RFC 4273
580 + // ============================================================================
581 + "BGP4-MIB::bgpVersion": ".1.3.6.1.2.1.15.1",
582 + "BGP4-MIB::bgpLocalAs": ".1.3.6.1.2.1.15.2",
583 + "BGP4-MIB::bgpPeerIdentifier": ".1.3.6.1.2.1.15.3.1.1",
584 + "BGP4-MIB::bgpPeerState": ".1.3.6.1.2.1.15.3.1.2",
585 + "BGP4-MIB::bgpPeerAdminStatus": ".1.3.6.1.2.1.15.3.1.3",
586 + "BGP4-MIB::bgpPeerNegotiatedVersion": ".1.3.6.1.2.1.15.3.1.4",
587 + "BGP4-MIB::bgpPeerLocalAddr": ".1.3.6.1.2.1.15.3.1.5",
588 + "BGP4-MIB::bgpPeerLocalPort": ".1.3.6.1.2.1.15.3.1.6",
589 + "BGP4-MIB::bgpPeerRemoteAddr": ".1.3.6.1.2.1.15.3.1.7",
590 + "BGP4-MIB::bgpPeerRemotePort": ".1.3.6.1.2.1.15.3.1.8",
591 + "BGP4-MIB::bgpPeerRemoteAs": ".1.3.6.1.2.1.15.3.1.9",
592 + "BGP4-MIB::bgpPeerInUpdates": ".1.3.6.1.2.1.15.3.1.10",
593 + "BGP4-MIB::bgpPeerOutUpdates": ".1.3.6.1.2.1.15.3.1.11",
594 + "BGP4-MIB::bgpPeerInTotalMessages": ".1.3.6.1.2.1.15.3.1.12",
595 + "BGP4-MIB::bgpPeerOutTotalMessages": ".1.3.6.1.2.1.15.3.1.13",
596 + "BGP4-MIB::bgpPeerLastError": ".1.3.6.1.2.1.15.3.1.14",
597 + "BGP4-MIB::bgpPeerFsmEstablishedTransitions": ".1.3.6.1.2.1.15.3.1.15",
598 + "BGP4-MIB::bgpPeerFsmEstablishedTime": ".1.3.6.1.2.1.15.3.1.16",
599 + "BGP4-MIB::bgpPeerConnectRetryInterval": ".1.3.6.1.2.1.15.3.1.17",
600 + "BGP4-MIB::bgpPeerHoldTime": ".1.3.6.1.2.1.15.3.1.18",
601 + "BGP4-MIB::bgpPeerKeepAlive": ".1.3.6.1.2.1.15.3.1.19",
602 + "BGP4-MIB::bgpPeerHoldTimeConfigured": ".1.3.6.1.2.1.15.3.1.20",
603 + "BGP4-MIB::bgpPeerKeepAliveConfigured": ".1.3.6.1.2.1.15.3.1.21",
604 + "BGP4-MIB::bgpPeerMinASOriginationInterval": ".1.3.6.1.2.1.15.3.1.22",
605 + "BGP4-MIB::bgpPeerMinRouteAdvertisementInterval": ".1.3.6.1.2.1.15.3.1.23",
606 + "BGP4-MIB::bgpPeerInUpdateElapsedTime": ".1.3.6.1.2.1.15.3.1.24",
607 +
608 + // ============================================================================
609 + // OSPF-MIB - RFC 4750
610 + // ============================================================================
611 + "OSPF-MIB::ospfRouterId": ".1.3.6.1.2.1.14.1.1",
612 + "OSPF-MIB::ospfAdminStat": ".1.3.6.1.2.1.14.1.2",
613 + "OSPF-MIB::ospfVersionNumber": ".1.3.6.1.2.1.14.1.3",
614 + "OSPF-MIB::ospfAreaBdrRtrStatus": ".1.3.6.1.2.1.14.1.4",
615 + "OSPF-MIB::ospfASBdrRtrStatus": ".1.3.6.1.2.1.14.1.5",
616 + "OSPF-MIB::ospfExternLsaCount": ".1.3.6.1.2.1.14.1.6",
617 + "OSPF-MIB::ospfExternLsaCksumSum": ".1.3.6.1.2.1.14.1.7",
618 + "OSPF-MIB::ospfTOSSupport": ".1.3.6.1.2.1.14.1.8",
619 + "OSPF-MIB::ospfOriginateNewLsas": ".1.3.6.1.2.1.14.1.9",
620 + "OSPF-MIB::ospfRxNewLsas": ".1.3.6.1.2.1.14.1.10",
621 + "OSPF-MIB::ospfExtLsdbLimit": ".1.3.6.1.2.1.14.1.11",
622 + "OSPF-MIB::ospfMulticastExtensions": ".1.3.6.1.2.1.14.1.12",
623 + "OSPF-MIB::ospfExitOverflowInterval": ".1.3.6.1.2.1.14.1.13",
624 + "OSPF-MIB::ospfDemandExtensions": ".1.3.6.1.2.1.14.1.14",
625 +
626 + // ============================================================================
627 + // DISMAN-EVENT-MIB - RFC 2981
628 + // ============================================================================
629 + "DISMAN-EVENT-MIB::sysUpTimeInstance": ".1.3.6.1.2.1.1.3.0",
630 +}
631 +
632 +// Translation cache for snmptranslate results
633 +// Thread-safe cache to avoid repeated exec calls
634 +var (
635 + mibTranslationCache = make(map[string]string)
636 + mibTranslationCacheMu sync.RWMutex
637 + mibCacheMaxSize = 1000 // Reasonable limit for production
638 +)
639 +
640 +// translateMIB translates MIB name to numeric OID
641 +// Strategy: hardcoded map → cache → snmptranslate (optional) → error
642 +// validateMIBName checks if a MIB name contains only safe characters.
643 +// This prevents command injection when passing to snmptranslate.
644 +// Valid MIB names: alphanumeric, dots, colons, hyphens, underscores.
645 +func validateMIBName(mibName string) error {
646 + if mibName == "" {
647 + return fmt.Errorf("empty MIB name")
648 + }
649 +
650 + // Max length check (reasonable limit for MIB names)
651 + if len(mibName) > 256 {
652 + return fmt.Errorf("MIB name too long")
653 + }
654 +
655 + // Check each character for safety
656 + for i, ch := range mibName {
657 + if (ch >= 'a' && ch <= 'z') ||
658 + (ch >= 'A' && ch <= 'Z') ||
659 + (ch >= '0' && ch <= '9') ||
660 + ch == '.' || ch == ':' || ch == '-' || ch == '_' {
661 + continue
662 + }
663 + return fmt.Errorf("invalid character '%c' at position %d in MIB name", ch, i)
664 + }
665 +
666 + return nil
667 +}
668 +
669 +func translateMIB(mibName string) (string, error) {
670 + // 1. Check if already numeric OID (passthrough)
671 + if strings.HasPrefix(mibName, ".") || (len(mibName) > 0 && mibName[0] >= '0' && mibName[0] <= '9') {
672 + return mibName, nil
673 + }
674 +
675 + // 2. Validate MIB name BEFORE any cache operations (defense in depth)
676 + if err := validateMIBName(mibName); err != nil {
677 + return "", fmt.Errorf("invalid MIB name: %w", err)
678 + }
679 +
680 + // 3. Check hardcoded map (covers ~90% of use cases)
681 + if oid, found := mibToOID[mibName]; found {
682 + return oid, nil
683 + }
684 +
685 + // 4. Check translation cache (from previous snmptranslate calls)
686 + mibTranslationCacheMu.RLock()
687 + if oid, found := mibTranslationCache[mibName]; found {
688 + mibTranslationCacheMu.RUnlock()
689 + return oid, nil
690 + }
691 + mibTranslationCacheMu.RUnlock()
692 +
693 + // 5. Try snmptranslate (optional - fails gracefully if not installed)
694 + // Use timeout to prevent hanging on slow/stuck snmptranslate
695 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
696 + defer cancel()
697 + cmd := exec.CommandContext(ctx, "snmptranslate", "-On", mibName)
698 + output, err := cmd.Output()
699 + if err == nil {
700 + oid := strings.TrimSpace(string(output))
701 + // Validate OID format before caching
702 + if strings.HasPrefix(oid, ".") {
703 + // Cache the result (with size limit)
704 + mibTranslationCacheMu.Lock()
705 + if len(mibTranslationCache) < mibCacheMaxSize {
706 + mibTranslationCache[mibName] = oid
707 + }
708 + mibTranslationCacheMu.Unlock()
709 + return oid, nil
710 + }
711 + }
712 +
713 + // 5. Unknown MIB - fail with helpful error message
714 + return "", fmt.Errorf("unknown MIB: %s (install net-snmp or use numeric OIDs)", mibName)
715 +}
716 +
717 +// parseSNMPWalkAll parses all SNMP entries from snmpwalk output.
718 +// Returns a slice of all parsed entries, preserving order.
719 +// This handles the same complex formats as parseSNMPWalk but returns all entries.
720 +func parseSNMPWalkAll(data string, logger Logger) ([]snmpEntry, error) {
721 + lines := strings.Split(data, "\n")
722 + if len(lines) > 0 && lines[len(lines)-1] == "" {
723 + lines = lines[:len(lines)-1]
724 + }
725 +
726 + var entries []snmpEntry
727 +
728 + for i := 0; i < len(lines); {
729 + line := lines[i]
730 +
731 + // Normalize textual OIDs to numeric format (e.g., IF-MIB::ifDescr.1 -> .1.3.6.1.2.1.2.2.1.2.1)
732 + line = normalizeTextualOIDLine(line)
733 +
734 + // Check if this is a new entry (starts with OID)
735 + if !strings.HasPrefix(strings.TrimSpace(line), ".") {
736 + if strings.TrimSpace(line) != "" {
737 + logger.Debug("skipping non-OID line in snmp walk output", "line", line, "lineNumber", i+1)
738 + }
739 + i++
740 + continue
741 + }
742 +
743 + // Parse line using helper
744 + oid, valueType, valueStr, ok := parseSNMPLine(line)
745 + if !ok {
746 + logger.Debug("skipping malformed snmp line", "line", line, "lineNumber", i+1)
747 + i++
748 + continue
749 + }
750 +
751 + // Handle "Wrong Type" warnings - extract actual type and value
752 + if strings.HasPrefix(valueType, "Wrong Type") {
753 + actualTypeParts := strings.SplitN(valueStr, ":", 2)
754 + if len(actualTypeParts) == 2 {
755 + valueType = strings.TrimSpace(actualTypeParts[0])
756 + valueStr = strings.TrimLeft(actualTypeParts[1], " ")
757 + } else {
758 + return nil, fmt.Errorf("cannot parse snmp walk output")
759 + }
760 + }
761 +
762 + // Collect multiline values using helper
763 + finalValue, consumed := collectMultilineValue(valueType, valueStr, lines, i)
764 +
765 + entries = append(entries, snmpEntry{
766 + oid: oid,
767 + valueType: valueType,
768 + value: finalValue,
769 + })
770 +
771 + i += consumed + 1
772 + }
773 +
774 + return entries, nil
775 +}
776 +
777 +// parseSNMPWalk parses SNMP walk text output and extracts value for target OID.
778 +//
779 +// This custom parser processes text output from the snmpwalk command-line tool
780 +// (not binary SNMP protocol data). It handles complex SNMP data formats including:
781 +// - Multiple value types: STRING, INTEGER, Hex-STRING, Timeticks, Counter32, etc.
782 +// - Multiline quoted strings with proper quote tracking
783 +// - Multiline hex strings with continuation lines
784 +// - MIB name to numeric OID translation
785 +// - "Wrong Type" warnings with type/value extraction
786 +// - NULL and empty string handling
787 +//
788 +// Example SNMP walk format:
789 +//
790 +// .1.3.6.1.2.1.1.1.0 = STRING: "Linux server 5.4.0"
791 +// .1.3.6.1.2.1.1.3.0 = Timeticks: (12345) 0:02:03.45
792 +//
793 +// Parameters:
794 +// - data: Text output from snmpwalk command (one or more OID=value lines)
795 +// - targetOID: OID to extract (supports MIB names like "SNMPv2-MIB::sysDescr.0")
796 +//
797 +// Returns:
798 +// - snmpEntry: Parsed entry containing OID, value type, and value
799 +// - error: Parsing error if format is invalid or target OID not found
800 +//
801 +// This function cannot use gosnmp library because it parses command-line tool output,
802 +// not binary SNMP protocol data. No standard library exists for this text format.
803 +// normalizeOID adds a leading dot to numeric OIDs if missing
804 +func normalizeOID(oid string) string {
805 + if !strings.HasPrefix(oid, ".") && len(oid) > 0 && oid[0] >= '0' && oid[0] <= '9' {
806 + return "." + oid
807 + }
808 + return oid
809 +}
810 +
811 +// normalizeTextualOIDLine converts textual OID lines to numeric OID format.
812 +// Input: "IF-MIB::ifDescr.1 = STRING: \"eth0\""
813 +// Output: ".1.3.6.1.2.1.2.2.1.2.1 = STRING: \"eth0\""
814 +//
815 +// Returns original line unchanged if:
816 +// - Not a textual OID format (no "::" before "=")
817 +// - MIB name is not in the built-in translation table (translateMIB fails)
818 +//
819 +// IMPORTANT: Unknown MIBs (vendor-specific, custom, or not in hardcoded map) are
820 +// returned as-is. Since the parser expects lines starting with ".", these untranslated
821 +// textual OID lines will be silently skipped. For full textual OID support, either:
822 +// - Use numeric OIDs in snmpwalk output (snmpwalk -On)
823 +// - Extend translateMIB() with additional MIB mappings
824 +// - Use snmptranslate externally before passing to this library
825 +func normalizeTextualOIDLine(line string) string {
826 + trimmed := strings.TrimSpace(line)
827 + if trimmed == "" || strings.HasPrefix(trimmed, "#") {
828 + return line
829 + }
830 +
831 + // Check for textual OID format (contains :: before =)
832 + eqIdx := strings.Index(trimmed, "=")
833 + if eqIdx == -1 {
834 + return line
835 + }
836 +
837 + oidPart := strings.TrimSpace(trimmed[:eqIdx])
838 +
839 + // Check if OID contains :: (textual format)
840 + if !strings.Contains(oidPart, "::") {
841 + return line
842 + }
843 +
844 + // Extract MIB name and instance suffix
845 + // Format: MIB-NAME::objectName.instance or MIB-NAME::objectName
846 + // Split on :: to get MIB::object
847 + parts := strings.SplitN(oidPart, "::", 2)
848 + if len(parts) != 2 {
849 + return line
850 + }
851 +
852 + mibPrefix := parts[0]
853 + objectAndInstance := parts[1]
854 +
855 + // Split object name from instance (e.g., "ifDescr.1" -> "ifDescr", "1")
856 + var objectName, instanceSuffix string
857 + dotIdx := strings.Index(objectAndInstance, ".")
858 + if dotIdx == -1 {
859 + objectName = objectAndInstance
860 + instanceSuffix = ""
861 + } else {
862 + objectName = objectAndInstance[:dotIdx]
863 + instanceSuffix = objectAndInstance[dotIdx:] // Includes the dot
864 + }
865 +
866 + // Construct full MIB name for translation
867 + fullMIBName := mibPrefix + "::" + objectName
868 +
869 + // Try to translate
870 + numericOID, err := translateMIB(fullMIBName)
871 + if err != nil {
872 + // Can't translate - return original line (will be skipped later)
873 + return line
874 + }
875 +
876 + // Reconstruct line with numeric OID
877 + newOID := numericOID + instanceSuffix
878 + rest := trimmed[eqIdx:]
879 + return newOID + " " + rest
880 +}
881 +
882 +// parseSNMPLine parses a single SNMP walk output line into OID, type, and value
883 +func parseSNMPLine(line string) (oid, valueType, value string, ok bool) {
884 + // Split on = to get OID and rest
885 + parts := strings.SplitN(line, "=", 2)
886 + if len(parts) != 2 {
887 + return "", "", "", false
888 + }
889 +
890 + oid = strings.TrimSpace(parts[0])
891 + rest := strings.TrimSpace(parts[1])
892 +
893 + // Extract type and value (format: "TYPE: value")
894 + typeParts := strings.SplitN(rest, ":", 2)
895 +
896 + if len(typeParts) == 2 {
897 + // Format: "TYPE: value"
898 + valueType = strings.TrimSpace(typeParts[0])
899 + value = strings.TrimLeft(typeParts[1], " ")
900 + return oid, valueType, value, true
901 + }
902 +
903 + // No colon - could be "NULL", empty quotes "", or raw value
904 + trimmed := strings.TrimSpace(typeParts[0])
905 + if trimmed == "NULL" {
906 + return oid, "NULL", "", true
907 + }
908 + if trimmed == "\"\"" {
909 + return oid, "EMPTY", "", true
910 + }
911 + // Arbitrary value without type
912 + return oid, "", trimmed, true
913 +}
914 +
915 +// isMultilineValue determines if an SNMP value requires multiline processing
916 +func isMultilineValue(valueType, value string) bool {
917 + if valueType == "Hex-STRING" {
918 + return true
919 + }
920 + if valueType == "STRING" {
921 + // Quoted string without closing quote, or unquoted string
922 + if strings.HasPrefix(value, "\"") && !strings.HasSuffix(strings.TrimRight(value, " \t"), "\"") {
923 + return true
924 + }
925 + if !strings.HasPrefix(value, "\"") {
926 + return true
927 + }
928 + }
929 + return false
930 +}
931 +
932 +// appendContinuationLine adds a continuation line to a multiline SNMP value
933 +func appendContinuationLine(entry *snmpEntry, line string) {
934 + if entry.valueType == "Hex-STRING" {
935 + // For Hex-STRING, append with space separator (trimmed)
936 + entry.value += " " + strings.TrimSpace(line)
937 + } else {
938 + // For STRING, append with newline preserved
939 + entry.value += "\n" + line
940 + }
941 +}
942 +
943 +// isStringClosed checks if a multiline STRING value is now complete
944 +func isStringClosed(valueType, line string) bool {
945 + if valueType != "STRING" {
946 + return false
947 + }
948 + trimmed := strings.TrimRight(line, " \t")
949 + // Closed if ends with unescaped quote
950 + return strings.HasSuffix(trimmed, "\"") && !strings.HasSuffix(trimmed, "\\\"")
951 +}
952 +
953 +// collectMultilineValue collects continuation lines for multiline SNMP values
954 +// Returns the complete value and the number of continuation lines consumed
955 +func collectMultilineValue(valueType, initialValue string, lines []string, startIdx int) (string, int) {
956 + value := initialValue
957 + consumed := 0
958 +
959 + if valueType == "STRING" && strings.HasPrefix(initialValue, "\"") {
960 + trimmed := strings.TrimRight(initialValue, " \t")
961 + isMultiline := (trimmed == "\"") || (!strings.HasSuffix(trimmed, "\""))
962 +
963 + if isMultiline {
964 + // Multiline quoted string
965 + for startIdx+consumed+1 < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[startIdx+consumed+1]), ".") {
966 + consumed++
967 + value += "\n" + lines[startIdx+consumed]
968 + if isStringClosed(valueType, lines[startIdx+consumed]) {
969 + break
970 + }
971 + }
972 + }
973 + } else if valueType == "Hex-STRING" {
974 + // Multiline hex-string - continuation lines are indented hex bytes
975 + for startIdx+consumed+1 < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[startIdx+consumed+1]), ".") {
976 + consumed++
977 + value += " " + strings.TrimSpace(lines[startIdx+consumed])
978 + }
979 + }
980 +
981 + return value, consumed
982 +}
983 +
984 +func parseSNMPWalk(data string, targetOID string) (snmpEntry, error) {
985 + // Translate MIB name to numeric OID (if needed)
986 + translatedOID, err := translateMIB(targetOID)
987 + if err != nil {
988 + return snmpEntry{}, err
989 + }
990 + targetOID = normalizeOID(translatedOID)
991 +
992 + // Split lines and remove trailing empty line (from YAML | formatting)
993 + lines := strings.Split(data, "\n")
994 + if len(lines) > 0 && lines[len(lines)-1] == "" {
995 + lines = lines[:len(lines)-1]
996 + }
997 +
998 + var current snmpEntry
999 + inMultiline := false
1000 + wentMultiline := false // Track if we actually appended continuation lines
1001 +
1002 + for i := 0; i < len(lines); i++ {
1003 + line := lines[i]
1004 +
1005 + // Normalize textual OIDs to numeric format (e.g., IF-MIB::ifDescr.1 -> .1.3.6.1.2.1.2.2.1.2.1)
1006 + line = normalizeTextualOIDLine(line)
1007 +
1008 + // Check if this is a new entry (starts with OID)
1009 + if strings.HasPrefix(strings.TrimSpace(line), ".") {
1010 + // If we were in multiline and found our target, check if it's complete
1011 + if inMultiline && current.oid == targetOID {
1012 + // Check for broken quoting - if we started with a quote but never closed it
1013 + if current.valueType == "STRING" && strings.HasPrefix(strings.TrimSpace(current.value), "\"") {
1014 + // Still in quoted mode but hit new OID - broken quoting
1015 + return snmpEntry{}, fmt.Errorf("malformed quoted string: missing closing quote")
1016 + }
1017 + // Only preserve whitespace for STRING if we actually went multiline
1018 + if current.valueType != "STRING" || !wentMultiline {
1019 + current.value = strings.TrimSpace(current.value)
1020 + }
1021 + return current, nil
1022 + }
1023 +
1024 + // Parse new entry using helper
1025 + oid, valueType, valueStr, ok := parseSNMPLine(line)
1026 + if !ok {
1027 + continue
1028 + }
1029 +
1030 + current = snmpEntry{
1031 + oid: oid,
1032 + valueType: valueType,
1033 + value: valueStr,
1034 + }
1035 +
1036 + // Check if this is a multiline value using helper
1037 + inMultiline = isMultilineValue(valueType, valueStr)
1038 +
1039 + // If not multiline and this is our target, we're done
1040 + if !inMultiline && oid == targetOID {
1041 + return current, nil
1042 + }
1043 + } else if inMultiline {
1044 + // Continuation line
1045 + wentMultiline = true
1046 + appendContinuationLine(&current, line)
1047 +
1048 + // Check if STRING is now closed using helper
1049 + if isStringClosed(current.valueType, line) {
1050 + inMultiline = false
1051 + if current.oid == targetOID {
1052 + // Don't trim - preserve exact formatting
1053 + return current, nil
1054 + }
1055 + }
1056 + }
1057 + }
1058 +
1059 + // Check if last entry matches (for cases where file ends without newline)
1060 + if current.oid == targetOID {
1061 + // Only preserve whitespace for STRING if we actually went multiline
1062 + if current.valueType != "STRING" || !wentMultiline {
1063 + current.value = strings.TrimSpace(current.value)
1064 + }
1065 + return current, nil
1066 + }
1067 +
1068 + return snmpEntry{}, fmt.Errorf("OID not found: %s", targetOID)
1069 +}
1070 +
1071 +// formatSNMPValue applies format conversion to SNMP value
1072 +// trimFloatZeros: whether to trim trailing zeros from Opaque floats (true for WALK_VALUE, false for WALK_TO_JSON)
1073 +func formatSNMPValue(entry snmpEntry, formatMode int, trimFloatZeros bool) (string, error) {
1074 + switch entry.valueType {
1075 + case "STRING":
1076 + return formatSTRING(entry.value, formatMode)
1077 + case "Hex-STRING":
1078 + return formatHexSTRING(entry.value, formatMode)
1079 + case "INTEGER":
1080 + return strings.TrimSpace(entry.value), nil
1081 + case "BITS":
1082 + if formatMode == 3 {
1083 + return convertBITSToInteger(entry.value)
1084 + }
1085 + return strings.TrimSpace(entry.value), nil
1086 + case "IpAddress", "Counter32", "Gauge32", "Counter64", "Timeticks":
1087 + // Return value as-is for these types
1088 + return strings.TrimSpace(entry.value), nil
1089 + case "Opaque":
1090 + // Opaque can have wrapped types - parse them
1091 + return formatOpaque(entry.value, trimFloatZeros), nil
1092 + case "OID":
1093 + return strings.TrimSpace(entry.value), nil
1094 + case "NULL":
1095 + // NULL type returns "NULL" as a string
1096 + return "NULL", nil
1097 + case "EMPTY":
1098 + // Empty quotes - return empty string
1099 + return "", nil
1100 + case "":
1101 + // No type specified - return value as-is (arbitrary number)
1102 + return strings.TrimSpace(entry.value), nil
1103 + default:
1104 + // Unknown type - return value as-is
1105 + return strings.TrimSpace(entry.value), nil
1106 + }
1107 +}
1108 +
1109 +// formatSTRING handles STRING formatting
1110 +func formatSTRING(value string, formatMode int) (string, error) {
1111 + // Check if it's a quoted string (trim only for quote detection)
1112 + trimmed := strings.TrimSpace(value)
1113 + if strings.HasPrefix(trimmed, "\"") && strings.HasSuffix(trimmed, "\"") && len(trimmed) >= 2 {
1114 + // Unescape the quoted string
1115 + unquoted := trimmed[1 : len(trimmed)-1]
1116 + // Replace escape sequences
1117 + unquoted = strings.ReplaceAll(unquoted, "\\\"", "\"")
1118 + unquoted = strings.ReplaceAll(unquoted, "\\\\", "\\")
1119 + return unquoted, nil
1120 + }
1121 + // Unquoted string - return as-is (preserve whitespace)
1122 + return value, nil
1123 +}
1124 +
1125 +// formatHexSTRING handles Hex-STRING formatting
1126 +func formatHexSTRING(value string, formatMode int) (string, error) {
1127 + // Clean up the hex string (remove extra spaces)
1128 + value = strings.TrimSpace(value)
1129 +
1130 + switch formatMode {
1131 + case 0:
1132 + // Unchanged - normalize spaces
1133 + parts := strings.Fields(value)
1134 + return strings.Join(parts, " "), nil
1135 +
1136 + case 1:
1137 + // UTF-8 conversion
1138 + bytes, err := parseHexBytes(value)
1139 + if err != nil {
1140 + return "", err
1141 + }
1142 + // Remove null terminator if present
1143 + if len(bytes) > 0 && bytes[len(bytes)-1] == 0 {
1144 + bytes = bytes[:len(bytes)-1]
1145 + }
1146 + // Convert to string and replace invalid UTF-8 bytes with '?'
1147 + result := string(bytes)
1148 + if !utf8.ValidString(result) {
1149 + // Iterate over bytes and decode runes manually to properly handle
1150 + // both invalid bytes and legitimate U+FFFD characters
1151 + var fixed strings.Builder
1152 + for i := 0; i < len(bytes); {
1153 + r, size := utf8.DecodeRune(bytes[i:])
1154 + // If DecodeRune returns (RuneError, 1), it's an invalid byte
1155 + // If it returns (RuneError, 3), it's a valid encoding of U+FFFD
1156 + if r == utf8.RuneError && size == 1 {
1157 + fixed.WriteByte('?')
1158 + i++
1159 + } else {
1160 + fixed.WriteRune(r)
1161 + i += size
1162 + }
1163 + }
1164 + return fixed.String(), nil
1165 + }
1166 + return result, nil
1167 +
1168 + case 2:
1169 + // MAC address format
1170 + bytes, err := parseHexBytes(value)
1171 + if err != nil {
1172 + return "", err
1173 + }
1174 + // Format as colon-separated hex
1175 + var parts []string
1176 + for _, b := range bytes {
1177 + parts = append(parts, fmt.Sprintf("%02X", b))
1178 + }
1179 + return strings.Join(parts, ":"), nil
1180 +
1181 + default:
1182 + // Unknown format mode - return unchanged
1183 + parts := strings.Fields(value)
1184 + return strings.Join(parts, " "), nil
1185 + }
1186 +}
1187 +
1188 +// parseHexBytes parses hex string like "74 65 73 74" into bytes
1189 +func parseHexBytes(hexStr string) ([]byte, error) {
1190 + parts := strings.Fields(hexStr)
1191 + var bytes []byte
1192 + for _, part := range parts {
1193 + b, err := hex.DecodeString(part)
1194 + if err != nil {
1195 + return nil, fmt.Errorf("invalid hex string: %v", err)
1196 + }
1197 + bytes = append(bytes, b...)
1198 + }
1199 + return bytes, nil
1200 +}
1201 +
1202 +// convertBITSToInteger converts BITS hex string to integer (little-endian)
1203 +func convertBITSToInteger(value string) (string, error) {
1204 + bytes, err := parseHexBytes(value)
1205 + if err != nil {
1206 + return "", err
1207 + }
1208 +
1209 + // BITS uses little-endian byte order
1210 + // Reverse the bytes
1211 + for i, j := 0, len(bytes)-1; i < j; i, j = i+1, j-1 {
1212 + bytes[i], bytes[j] = bytes[j], bytes[i]
1213 + }
1214 +
1215 + // Convert bytes to integer (big-endian after reversal)
1216 + var result uint64
1217 + for _, b := range bytes {
1218 + result = (result << 8) | uint64(b)
1219 + }
1220 +
1221 + return fmt.Sprintf("%d", result), nil
1222 +}
1223 +
1224 +// formatOpaque handles Opaque wrapped types
1225 +func formatOpaque(value string, trimFloatZeros bool) string {
1226 + // Opaque can have wrapped types like "Float: 0.460000" or "STRING: \"hello\""
1227 + // Extract the wrapped type and value
1228 + parts := strings.SplitN(value, ":", 2)
1229 + if len(parts) == 2 {
1230 + wrappedType := strings.TrimSpace(parts[0])
1231 + val := strings.TrimSpace(parts[1])
1232 +
1233 + // For STRING wrapped type, strip quotes
1234 + if wrappedType == "STRING" && len(val) >= 2 && strings.HasPrefix(val, "\"") && strings.HasSuffix(val, "\"") {
1235 + val = val[1 : len(val)-1]
1236 + }
1237 +
1238 + // For Float wrapped type, optionally trim trailing zeros
1239 + if wrappedType == "Float" && trimFloatZeros && strings.Contains(val, ".") {
1240 + val = strings.TrimRight(val, "0")
1241 + val = strings.TrimRight(val, ".")
1242 + }
1243 +
1244 + // For other types (Unsigned32, etc.), return value as-is
1245 + return val
1246 + }
1247 + return strings.TrimSpace(value)
1248 +}
1249 +
1250 +// snmpGetValue formats raw SNMP hex-string value.
1251 +// Format modes:
1252 +// 0 = unchanged (return raw hex as-is with normalized spaces)
1253 +// 1 = UTF-8 (decode hex-string to UTF-8 string)
1254 +// 2 = MAC (format hex-string as MAC address with colons)
1255 +// 3 = BITS (convert BITS to integer)
1256 +func snmpGetValue(value Value, params string) (Value, error) {
1257 + lines := strings.Split(params, "\n")
1258 + if len(lines) < 1 {
1259 + return Value{}, fmt.Errorf("snmp get value requires format mode parameter")
1260 + }
1261 +
1262 + formatMode := 0
1263 + if len(lines) >= 1 && strings.TrimSpace(lines[0]) != "" {
1264 + var err error
1265 + formatMode, err = strconv.Atoi(strings.TrimSpace(lines[0]))
1266 + if err != nil {
1267 + return Value{}, fmt.Errorf("invalid format mode: %v", err)
1268 + }
1269 + }
1270 +
1271 + // For BITS mode, convert directly
1272 + if formatMode == 3 {
1273 + result, err := convertBITSToInteger(value.Data)
1274 + if err != nil {
1275 + return Value{}, err
1276 + }
1277 + return Value{Data: result, Type: ValueTypeStr}, nil
1278 + }
1279 +
1280 + // Apply format conversion to hex-string input
1281 + result, err := formatHexSTRING(value.Data, formatMode)
1282 + if err != nil {
1283 + return Value{}, err
1284 + }
1285 +
1286 + return Value{Data: result, Type: ValueTypeStr}, nil
1287 +}
1288 +
1289 +// snmpWalkToJSON converts SNMP walk output to JSON discovery format.
1290 +// Params format (one entry per 3 lines):
1291 +//
1292 +// MACRO_NAME
1293 +// OID_PREFIX
1294 +// FORMAT_MODE
1295 +func snmpWalkToJSON(value Value, params string, logger Logger) (Value, error) {
1296 + // Parse params into macro definitions
1297 + type macroDef struct {
1298 + name string
1299 + oidPrefix string
1300 + formatMode int
1301 + }
1302 +
1303 + var macros []macroDef
1304 + lines := strings.Split(params, "\n")
1305 +
1306 + // Validate params format: must be triplets (macro, oid, format)
1307 + // Exception: empty params (all whitespace) is valid and returns []
1308 + trimmedParams := strings.TrimSpace(params)
1309 + if trimmedParams != "" && len(lines)%3 != 0 {
1310 + return Value{}, fmt.Errorf("snmp walk to json requires parameters in triplets (macro, oid, format)")
1311 + }
1312 +
1313 + for i := 0; i+2 < len(lines); i += 3 {
1314 + name := strings.TrimSpace(lines[i])
1315 + oidPrefix := strings.TrimSpace(lines[i+1])
1316 + formatModeStr := strings.TrimSpace(lines[i+2])
1317 +
1318 + if name == "" || oidPrefix == "" {
1319 + continue
1320 + }
1321 +
1322 + formatMode := 0
1323 + if formatModeStr != "" {
1324 + var err error
1325 + formatMode, err = strconv.Atoi(formatModeStr)
1326 + if err != nil {
1327 + return Value{}, fmt.Errorf("invalid format mode: %v", err)
1328 + }
1329 + }
1330 +
1331 + // Translate MIB name to numeric OID (supports textual OIDs like IF-MIB::ifDescr)
1332 + translatedOID, err := translateMIB(oidPrefix)
1333 + if err != nil {
1334 + return Value{}, err
1335 + }
1336 + oidPrefix = normalizeOID(translatedOID)
1337 +
1338 + macros = append(macros, macroDef{
1339 + name: name,
1340 + oidPrefix: oidPrefix,
1341 + formatMode: formatMode,
1342 + })
1343 + }
1344 +
1345 + // If no macros defined, return empty array
1346 + if len(macros) == 0 {
1347 + return Value{Data: "[]", Type: ValueTypeStr}, nil
1348 + }
1349 +
1350 + // Parse SNMP walk data using shared parser
1351 + entries, err := parseSNMPWalkAll(value.Data, logger)
1352 + if err != nil {
1353 + return Value{}, err
1354 + }
1355 +
1356 + // Group entries by index (last OID component)
1357 + type indexData struct {
1358 + index string
1359 + values map[string]string
1360 + }
1361 + indices := make(map[string]*indexData)
1362 +
1363 + // Process each parsed entry
1364 + for _, entry := range entries {
1365 + // Check which macro this OID matches
1366 + for _, macro := range macros {
1367 + // Build match prefix - add dot only if not already present
1368 + matchPrefix := macro.oidPrefix
1369 + if !strings.HasSuffix(matchPrefix, ".") {
1370 + matchPrefix += "."
1371 + }
1372 +
1373 + if strings.HasPrefix(entry.oid, matchPrefix) {
1374 + // Extract index (everything after the prefix)
1375 + index := strings.TrimPrefix(entry.oid, matchPrefix)
1376 +
1377 + // Get or create index data
1378 + if indices[index] == nil {
1379 + indices[index] = &indexData{
1380 + index: index,
1381 + values: make(map[string]string),
1382 + }
1383 + }
1384 +
1385 + // Format the value (preserve float zeros for WALK_TO_JSON)
1386 + formatted, err := formatSNMPValue(entry, macro.formatMode, false)
1387 + if err != nil {
1388 + return Value{}, err
1389 + }
1390 +
1391 + indices[index].values[macro.name] = formatted
1392 + break
1393 + }
1394 + }
1395 + }
1396 +
1397 + // If no data found for any macro, check if it's due to bad data or just empty input
1398 + if len(indices) == 0 {
1399 + // If we found valid SNMP entries but none matched our macros, that's an error
1400 + // If we found no valid entries at all (empty or bad data), return error only for bad data
1401 + if len(entries) > 0 {
1402 + // Found valid SNMP entries but none matched - this is an error
1403 + return Value{}, fmt.Errorf("cannot convert snmp walk to json")
1404 + }
1405 + // No valid entries found - could be empty input (ok) or bad data (error)
1406 + // Distinguish by checking if input has non-empty, non-whitespace content
1407 + trimmedData := strings.TrimSpace(value.Data)
1408 + if trimmedData != "" {
1409 + // Non-empty input but no valid SNMP entries - bad data
1410 + return Value{}, fmt.Errorf("cannot convert snmp walk to json")
1411 + }
1412 + // Empty input - return empty array
1413 + return Value{Data: "[]", Type: ValueTypeStr}, nil
1414 + }
1415 +
1416 + // Convert to JSON array, sorted by index in descending order
1417 + var indexList []string
1418 + for idx := range indices {
1419 + indexList = append(indexList, idx)
1420 + }
1421 + sort.Sort(sort.Reverse(sort.StringSlice(indexList)))
1422 +
1423 + // Build JSON manually to preserve field order: {#SNMPINDEX} first, then macros in order
1424 + var jsonParts []string
1425 + for _, idx := range indexList {
1426 + data := indices[idx]
1427 +
1428 + // Start with {#SNMPINDEX}
1429 + var fields []string
1430 + fields = append(fields, fmt.Sprintf("\"{#SNMPINDEX}\":%q", data.index))
1431 +
1432 + // Add macros in definition order
1433 + for _, macro := range macros {
1434 + if val, ok := data.values[macro.name]; ok {
1435 + // Handle NULL values as JSON null
1436 + if val == "NULL" {
1437 + fields = append(fields, fmt.Sprintf("%q:null", macro.name))
1438 + } else {
1439 + fields = append(fields, fmt.Sprintf("%q:%q", macro.name, val))
1440 + }
1441 + }
1442 + }
1443 +
1444 + jsonParts = append(jsonParts, "{"+strings.Join(fields, ",")+"}")
1445 + }
1446 +
1447 + return Value{Data: "[" + strings.Join(jsonParts, ",") + "]", Type: ValueTypeStr}, nil
1448 +}
1449 +
1450 +// snmpWalkToJSONMulti converts SNMP walk data to multiple discovery metrics
1451 +// Each discovery item becomes a separate Result.Metric with index label
1452 +func snmpWalkToJSONMulti(value Value, params string, logger Logger) (Result, error) {
1453 + // Parse params into macro definitions
1454 + type macroDef struct {
1455 + name string
1456 + oidPrefix string
1457 + formatMode int
1458 + }
1459 +
1460 + var macros []macroDef
1461 + lines := strings.Split(params, "\n")
1462 +
1463 + // Validate params format: must be triplets (macro, oid, format)
1464 + // Exception: empty params (all whitespace) is valid and returns []
1465 + trimmedParams := strings.TrimSpace(params)
1466 + if trimmedParams != "" && len(lines)%3 != 0 {
1467 + err := fmt.Errorf("snmp walk to json requires parameters in triplets (macro, oid, format)")
1468 + return Result{Error: err}, err
1469 + }
1470 +
1471 + for i := 0; i+2 < len(lines); i += 3 {
1472 + name := strings.TrimSpace(lines[i])
1473 + oidPrefix := strings.TrimSpace(lines[i+1])
1474 + formatModeStr := strings.TrimSpace(lines[i+2])
1475 +
1476 + if name == "" || oidPrefix == "" {
1477 + continue
1478 + }
1479 +
1480 + formatMode := 0
1481 + if formatModeStr != "" {
1482 + var err error
1483 + formatMode, err = strconv.Atoi(formatModeStr)
1484 + if err != nil {
1485 + err = fmt.Errorf("invalid format mode: %v", err)
1486 + return Result{Error: err}, err
1487 + }
1488 + }
1489 +
1490 + // Translate MIB name to numeric OID (supports textual OIDs like IF-MIB::ifDescr)
1491 + translatedOID, err := translateMIB(oidPrefix)
1492 + if err != nil {
1493 + return Result{Error: err}, err
1494 + }
1495 + oidPrefix = normalizeOID(translatedOID)
1496 +
1497 + macros = append(macros, macroDef{
1498 + name: name,
1499 + oidPrefix: oidPrefix,
1500 + formatMode: formatMode,
1501 + })
1502 + }
1503 +
1504 + // If no macros defined, return empty metrics array
1505 + if len(macros) == 0 {
1506 + return Result{Metrics: []Metric{}}, nil
1507 + }
1508 +
1509 + // Parse SNMP walk data using shared parser
1510 + entries, err := parseSNMPWalkAll(value.Data, logger)
1511 + if err != nil {
1512 + return Result{Error: err}, err
1513 + }
1514 +
1515 + // Group entries by index (last OID component)
1516 + type indexData struct {
1517 + index string
1518 + values map[string]string
1519 + }
1520 + indices := make(map[string]*indexData)
1521 +
1522 + // Process each parsed entry
1523 + for _, entry := range entries {
1524 + // Check which macro this OID matches
1525 + for _, macro := range macros {
1526 + // Build match prefix - add dot only if not already present
1527 + matchPrefix := macro.oidPrefix
1528 + if !strings.HasSuffix(matchPrefix, ".") {
1529 + matchPrefix += "."
1530 + }
1531 +
1532 + if strings.HasPrefix(entry.oid, matchPrefix) {
1533 + // Extract index (everything after the prefix)
1534 + index := strings.TrimPrefix(entry.oid, matchPrefix)
1535 +
1536 + // Get or create index data
1537 + if indices[index] == nil {
1538 + indices[index] = &indexData{
1539 + index: index,
1540 + values: make(map[string]string),
1541 + }
1542 + }
1543 +
1544 + // Format the value (preserve float zeros for WALK_TO_JSON)
1545 + formatted, err := formatSNMPValue(entry, macro.formatMode, false)
1546 + if err != nil {
1547 + return Result{Error: err}, err
1548 + }
1549 +
1550 + indices[index].values[macro.name] = formatted
1551 + break
1552 + }
1553 + }
1554 + }
1555 +
1556 + // If no data found for any macro, check if it's due to bad data or just empty input
1557 + if len(indices) == 0 {
1558 + // If we found valid SNMP entries but none matched our macros, that's an error
1559 + // If we found no valid entries at all (empty or bad data), return error only for bad data
1560 + if len(entries) > 0 {
1561 + // Found valid SNMP entries but none matched - this is an error
1562 + err := fmt.Errorf("cannot convert snmp walk to json")
1563 + return Result{Error: err}, err
1564 + }
1565 + // No valid entries found - could be empty input (ok) or bad data (error)
1566 + // Distinguish by checking if input has non-empty, non-whitespace content
1567 + trimmedData := strings.TrimSpace(value.Data)
1568 + if trimmedData != "" {
1569 + // Non-empty input but no valid SNMP entries - bad data
1570 + err := fmt.Errorf("cannot convert snmp walk to json")
1571 + return Result{Error: err}, err
1572 + }
1573 + // Empty input - return empty metrics array
1574 + return Result{Metrics: []Metric{}}, nil
1575 + }
1576 +
1577 + // Convert to multiple metrics, sorted by index in descending order
1578 + var indexList []string
1579 + for idx := range indices {
1580 + indexList = append(indexList, idx)
1581 + }
1582 + sort.Sort(sort.Reverse(sort.StringSlice(indexList)))
1583 +
1584 + // Build Result with multiple Metric objects
1585 + metrics := make([]Metric, 0, len(indexList))
1586 + for _, idx := range indexList {
1587 + data := indices[idx]
1588 +
1589 + // Build JSON object for this discovery item
1590 + // Start with {#SNMPINDEX}
1591 + var fields []string
1592 + fields = append(fields, fmt.Sprintf("\"{#SNMPINDEX}\":%q", data.index))
1593 +
1594 + // Add macros in definition order
1595 + for _, macro := range macros {
1596 + if val, ok := data.values[macro.name]; ok {
1597 + // Handle NULL values as JSON null
1598 + if val == "NULL" {
1599 + fields = append(fields, fmt.Sprintf("%q:null", macro.name))
1600 + } else {
1601 + fields = append(fields, fmt.Sprintf("%q:%q", macro.name, val))
1602 + }
1603 + }
1604 + }
1605 +
1606 + jsonObj := "{" + strings.Join(fields, ",") + "}"
1607 +
1608 + // Create metric with index label
1609 + metrics = append(metrics, Metric{
1610 + Name: "snmp_discovery",
1611 + Value: jsonObj,
1612 + Type: ValueTypeStr,
1613 + Labels: map[string]string{"index": data.index},
1614 + })
1615 + }
1616 +
1617 + return Result{Metrics: metrics}, nil
1618 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/state_isolation_test.go new
+203
@@ -0,0 +1,203 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "testing"
5 + "time"
6 +)
7 +
8 +// TestPerItemStateIsolation tests that different items have isolated state
9 +func TestPerItemStateIsolation(t *testing.T) {
10 + p := NewPreprocessor("test-shard")
11 +
12 + // Delta value test - two items should have independent state
13 + step := Step{Type: StepTypeDeltaValue, Params: ""}
14 +
15 + // Item 1: Send values 10, 20, 30
16 + value1_1 := Value{Data: "10", Type: ValueTypeFloat, Timestamp: time.Now()}
17 + value1_2 := Value{Data: "20", Type: ValueTypeFloat, Timestamp: time.Now()}
18 + value1_3 := Value{Data: "30", Type: ValueTypeFloat, Timestamp: time.Now()}
19 +
20 + // Item 2: Send values 100, 200, 300
21 + value2_1 := Value{Data: "100", Type: ValueTypeFloat, Timestamp: time.Now()}
22 + value2_2 := Value{Data: "200", Type: ValueTypeFloat, Timestamp: time.Now()}
23 + value2_3 := Value{Data: "300", Type: ValueTypeFloat, Timestamp: time.Now()}
24 +
25 + // First calls should return 0 (no previous value to delta from)
26 + result1_1, err1_1 := p.Execute("item1", value1_1, step)
27 + if err1_1 != nil {
28 + t.Fatalf("Unexpected error on first call for item1: %v", err1_1)
29 + }
30 + if len(result1_1.Metrics) == 0 || result1_1.Metrics[0].Value != "0" {
31 + t.Errorf("Expected 0 on first call for item1, got %v", result1_1.Metrics)
32 + }
33 +
34 + result2_1, err2_1 := p.Execute("item2", value2_1, step)
35 + if err2_1 != nil {
36 + t.Fatalf("Unexpected error on first call for item2: %v", err2_1)
37 + }
38 + if len(result2_1.Metrics) == 0 || result2_1.Metrics[0].Value != "0" {
39 + t.Errorf("Expected 0 on first call for item2, got %v", result2_1.Metrics)
40 + }
41 +
42 + // Second calls should succeed with delta
43 + result1_2, err1_2 := p.Execute("item1", value1_2, step)
44 + if err1_2 != nil {
45 + t.Fatalf("Unexpected error for item1 second call: %v", err1_2)
46 + }
47 + if len(result1_2.Metrics) == 0 || result1_2.Metrics[0].Value != "10" {
48 + t.Errorf("Expected delta of 10 for item1, got %v", result1_2.Metrics)
49 + }
50 +
51 + result2_2, err2_2 := p.Execute("item2", value2_2, step)
52 + if err2_2 != nil {
53 + t.Fatalf("Unexpected error for item2 second call: %v", err2_2)
54 + }
55 + if len(result2_2.Metrics) == 0 || result2_2.Metrics[0].Value != "100" {
56 + t.Errorf("Expected delta of 100 for item2, got %v", result2_2.Metrics)
57 + }
58 +
59 + // Third calls should also succeed with correct deltas
60 + result1_3, err1_3 := p.Execute("item1", value1_3, step)
61 + if err1_3 != nil {
62 + t.Fatalf("Unexpected error for item1 third call: %v", err1_3)
63 + }
64 + if len(result1_3.Metrics) == 0 || result1_3.Metrics[0].Value != "10" {
65 + t.Errorf("Expected delta of 10 for item1, got %v", result1_3.Metrics)
66 + }
67 +
68 + result2_3, err2_3 := p.Execute("item2", value2_3, step)
69 + if err2_3 != nil {
70 + t.Fatalf("Unexpected error for item2 third call: %v", err2_3)
71 + }
72 + if len(result2_3.Metrics) == 0 || result2_3.Metrics[0].Value != "100" {
73 + t.Errorf("Expected delta of 100 for item2, got %v", result2_3.Metrics)
74 + }
75 +}
76 +
77 +// TestPerItemStateIsolation_Throttle tests throttle state isolation
78 +func TestPerItemStateIsolation_Throttle(t *testing.T) {
79 + p := NewPreprocessor("test-shard")
80 + step := Step{Type: StepTypeThrottleValue, Params: ""}
81 +
82 + // Item 1 sends "A", "A", "B"
83 + result1_1, _ := p.Execute("item1", Value{Data: "A", Type: ValueTypeStr, Timestamp: time.Now()}, step)
84 + result1_2, _ := p.Execute("item1", Value{Data: "A", Type: ValueTypeStr, Timestamp: time.Now()}, step)
85 + result1_3, _ := p.Execute("item1", Value{Data: "B", Type: ValueTypeStr, Timestamp: time.Now()}, step)
86 +
87 + // Item 2 sends "X", "X", "Y"
88 + result2_1, _ := p.Execute("item2", Value{Data: "X", Type: ValueTypeStr, Timestamp: time.Now()}, step)
89 + result2_2, _ := p.Execute("item2", Value{Data: "X", Type: ValueTypeStr, Timestamp: time.Now()}, step)
90 + result2_3, _ := p.Execute("item2", Value{Data: "Y", Type: ValueTypeStr, Timestamp: time.Now()}, step)
91 +
92 + // Item 1: First value should pass, second should be throttled, third should pass
93 + if len(result1_1.Metrics) == 0 || result1_1.Metrics[0].Value != "A" {
94 + t.Errorf("Item1 first call: expected 'A', got %v", result1_1.Metrics)
95 + }
96 + if len(result1_2.Metrics) == 0 || result1_2.Metrics[0].Value != "" {
97 + t.Errorf("Item1 second call: expected empty (throttled), got %v", result1_2.Metrics)
98 + }
99 + if len(result1_3.Metrics) == 0 || result1_3.Metrics[0].Value != "B" {
100 + t.Errorf("Item1 third call: expected 'B', got %v", result1_3.Metrics)
101 + }
102 +
103 + // Item 2: Same pattern (independent of item1)
104 + if len(result2_1.Metrics) == 0 || result2_1.Metrics[0].Value != "X" {
105 + t.Errorf("Item2 first call: expected 'X', got %v", result2_1.Metrics)
106 + }
107 + if len(result2_2.Metrics) == 0 || result2_2.Metrics[0].Value != "" {
108 + t.Errorf("Item2 second call: expected empty (throttled), got %v", result2_2.Metrics)
109 + }
110 + if len(result2_3.Metrics) == 0 || result2_3.Metrics[0].Value != "Y" {
111 + t.Errorf("Item2 third call: expected 'Y', got %v", result2_3.Metrics)
112 + }
113 +}
114 +
115 +// TestShardIDInStateKey tests that shardID is part of state key
116 +func TestShardIDInStateKey(t *testing.T) {
117 + // Two preprocessors with different shards
118 + p1 := NewPreprocessor("shard1")
119 + p2 := NewPreprocessor("shard2")
120 +
121 + step := Step{Type: StepTypeDeltaValue, Params: ""}
122 +
123 + // Both use same itemID but different shards
124 + itemID := "same-item"
125 +
126 + // Shard 1: Send 10, 20
127 + _, _ = p1.Execute(itemID, Value{Data: "10", Type: ValueTypeFloat, Timestamp: time.Now()}, step)
128 + result1, err1 := p1.Execute(itemID, Value{Data: "20", Type: ValueTypeFloat, Timestamp: time.Now()}, step)
129 +
130 + // Shard 2: Send 100, 200
131 + _, _ = p2.Execute(itemID, Value{Data: "100", Type: ValueTypeFloat, Timestamp: time.Now()}, step)
132 + result2, err2 := p2.Execute(itemID, Value{Data: "200", Type: ValueTypeFloat, Timestamp: time.Now()}, step)
133 +
134 + // Both should succeed with correct deltas (proves state isolation)
135 + if err1 != nil {
136 + t.Fatalf("Shard1 error: %v", err1)
137 + }
138 + if len(result1.Metrics) == 0 || result1.Metrics[0].Value != "10" {
139 + t.Errorf("Shard1: expected delta of 10, got %v", result1.Metrics)
140 + }
141 +
142 + if err2 != nil {
143 + t.Fatalf("Shard2 error: %v", err2)
144 + }
145 + if len(result2.Metrics) == 0 || result2.Metrics[0].Value != "100" {
146 + t.Errorf("Shard2: expected delta of 100, got %v", result2.Metrics)
147 + }
148 +}
149 +
150 +// TestStateKeyFormat tests that state keys follow shardID:itemID:operation format
151 +func TestStateKeyFormat(t *testing.T) {
152 + p := NewPreprocessor("my-shard")
153 + step := Step{Type: StepTypeDeltaValue, Params: ""}
154 +
155 + // Execute to populate state
156 + _, _ = p.Execute("my-item", Value{Data: "10", Type: ValueTypeFloat, Timestamp: time.Now()}, step)
157 +
158 + // Check that state key exists with correct format
159 + expectedKey := "my-shard:my-item:delta_value"
160 + if _, exists := p.state[expectedKey]; !exists {
161 + t.Errorf("Expected state key '%s' not found. Existing keys: %v", expectedKey, getStateKeys(p))
162 + }
163 +}
164 +
165 +func TestClearStateRemovesItemEntries(t *testing.T) {
166 + p := NewPreprocessor("clear-shard")
167 + step := Step{Type: StepTypeDeltaValue, Params: ""}
168 +
169 + // Populate state for two different items
170 + _, _ = p.Execute("item1", Value{Data: "10", Type: ValueTypeFloat, Timestamp: time.Now()}, step)
171 + _, _ = p.Execute("item1", Value{Data: "20", Type: ValueTypeFloat, Timestamp: time.Now()}, step)
172 + _, _ = p.Execute("item2", Value{Data: "100", Type: ValueTypeFloat, Timestamp: time.Now()}, step)
173 + _, _ = p.Execute("item2", Value{Data: "200", Type: ValueTypeFloat, Timestamp: time.Now()}, step)
174 +
175 + if len(getStateKeys(p)) != 2 {
176 + t.Fatalf("expected state for two items, got keys %v", getStateKeys(p))
177 + }
178 +
179 + // Clear item1 state and verify only item2 remains
180 + p.ClearState("item1")
181 + keys := getStateKeys(p)
182 + if len(keys) != 1 || keys[0] != "clear-shard:item2:delta_value" {
183 + t.Fatalf("expected only item2 state to remain, got %v", keys)
184 + }
185 +
186 + // Ensure clearing non-existing item is no-op
187 + p.ClearState("missing")
188 + keys = getStateKeys(p)
189 + if len(keys) != 1 || keys[0] != "clear-shard:item2:delta_value" {
190 + t.Fatalf("clear of missing item altered state: %v", keys)
191 + }
192 +}
193 +
194 +// Helper to get state keys for debugging
195 +func getStateKeys(p *Preprocessor) []string {
196 + p.mu.RLock()
197 + defer p.mu.RUnlock()
198 + keys := make([]string, 0, len(p.state))
199 + for k := range p.state {
200 + keys = append(keys, k)
201 + }
202 + return keys
203 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/steps.go new
+637
@@ -0,0 +1,637 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "fmt"
5 + "strconv"
6 + "strings"
7 + "time"
8 +)
9 +
10 +// Numeric parsing constants
11 +const (
12 + parseBase10 = 10 // Decimal base for integer parsing
13 + parseBase8 = 8 // Octal base for oct2dec
14 + parseBase16 = 16 // Hexadecimal base for hex2dec
15 + parseBitSize64 = 64 // 64-bit precision for float/int parsing
16 +)
17 +
18 +// formatFloat formats a float for output, avoiding scientific notation
19 +func formatFloat(f float64) string {
20 + // If it's a whole number, format as integer
21 + if f == float64(int64(f)) {
22 + return fmt.Sprintf("%d", int64(f))
23 + }
24 + // Otherwise use %g which will choose reasonable formatting
25 + return fmt.Sprintf("%g", f)
26 +}
27 +
28 +// multiplyValue multiplies a numeric value by a multiplier.
29 +func multiplyValue(value Value, paramStr string) (Value, error) {
30 + multiplier, err := strconv.ParseFloat(paramStr, parseBitSize64)
31 + if err != nil {
32 + return Value{}, fmt.Errorf("invalid multiplier: %w", err)
33 + }
34 +
35 + var numValue float64
36 + // For UINT64 type, parse as integer first (truncate any decimals)
37 + if value.Type == ValueTypeUint64 {
38 + intVal, err := strconv.ParseInt(value.Data, 10, 64)
39 + if err != nil {
40 + // Try float, then truncate to uint64
41 + floatVal, err := strconv.ParseFloat(value.Data, 64)
42 + if err != nil {
43 + return Value{}, fmt.Errorf("invalid numeric value: %w", err)
44 + }
45 + intVal = int64(floatVal)
46 + }
47 + numValue = float64(intVal)
48 + } else {
49 + var err error
50 + numValue, err = strconv.ParseFloat(value.Data, 64)
51 + if err != nil {
52 + return Value{}, fmt.Errorf("invalid numeric value: %w", err)
53 + }
54 + }
55 +
56 + result := numValue * multiplier
57 +
58 + // Convert back based on value type
59 + switch value.Type {
60 + case ValueTypeUint64:
61 + // Banker's rounding (round half to even) like Zabbix
62 + floor := int64(result)
63 + frac := result - float64(floor)
64 + var rounded int64
65 + if frac < 0.5 {
66 + rounded = floor
67 + } else if frac > 0.5 {
68 + rounded = floor + 1
69 + } else {
70 + // Exactly 0.5 - round to even
71 + if floor%2 == 0 {
72 + rounded = floor
73 + } else {
74 + rounded = floor + 1
75 + }
76 + }
77 + return Value{Data: fmt.Sprintf("%d", rounded), Type: ValueTypeUint64}, nil
78 + case ValueTypeFloat:
79 + return Value{Data: formatFloat(result), Type: ValueTypeFloat}, nil
80 + default:
81 + // String type - return result as string
82 + if result == float64(int64(result)) {
83 + return Value{Data: fmt.Sprintf("%d", int64(result)), Type: ValueTypeStr}, nil
84 + }
85 + return Value{Data: formatFloat(result), Type: ValueTypeStr}, nil
86 + }
87 +}
88 +
89 +// trimValue trims specified characters from a value.
90 +func trimValue(value Value, paramStr string, direction string) (Value, error) {
91 + chars := paramStr
92 + if chars == "" {
93 + chars = " \t\r\n" // Default whitespace
94 + } else {
95 + // Handle Zabbix escape sequences in params
96 + chars = interpretEscapeSequences(chars)
97 + }
98 +
99 + // Trim the value data
100 + result := value.Data
101 + switch direction {
102 + case "left":
103 + result = strings.TrimLeft(result, chars)
104 + case "right":
105 + result = strings.TrimRight(result, chars)
106 + case "both":
107 + result = strings.Trim(result, chars)
108 + }
109 +
110 + return Value{Data: result, Type: value.Type}, nil
111 +}
112 +
113 +// interpretEscapeSequences handles Zabbix escape sequences in trim/ltrim/rtrim params
114 +// Handles both single backslash (\s) and double backslash (\\s) formats
115 +func interpretEscapeSequences(s string) string {
116 + var result strings.Builder
117 + result.Grow(len(s)) // Pre-allocate capacity for efficiency
118 +
119 + for i := 0; i < len(s); i++ {
120 + if s[i] == '\\' && i+1 < len(s) {
121 + next := s[i+1]
122 +
123 + // Check for double-backslash escape sequences (\\s, \\n, etc.)
124 + // This is only for trim operations where \\s means full whitespace class
125 + if next == '\\' && i+2 < len(s) {
126 + third := s[i+2]
127 + switch third {
128 + case 's':
129 + // \\s means whitespace class: space, tab, newline, carriage return, and backslash
130 + result.WriteString(" \t\r\n\\")
131 + i += 2
132 + continue
133 + }
134 + }
135 +
136 + // Single backslash escape sequences
137 + switch next {
138 + case '\\':
139 + // Backslash-backslash is a literal backslash (consume both backslashes)
140 + result.WriteByte('\\')
141 + i++
142 + continue
143 + case 'n':
144 + result.WriteByte('\n')
145 + i++
146 + continue
147 + case 'r':
148 + result.WriteByte('\r')
149 + i++
150 + continue
151 + case 't':
152 + result.WriteByte('\t')
153 + i++
154 + continue
155 + case 's':
156 + result.WriteByte(' ')
157 + i++
158 + continue
159 + case ' ':
160 + // Backslash-space is an escape for space
161 + result.WriteByte(' ')
162 + i++
163 + continue
164 + default:
165 + // Not an escape sequence, just add the backslash
166 + result.WriteByte(s[i])
167 + }
168 + } else {
169 + result.WriteByte(s[i])
170 + }
171 + }
172 + return result.String()
173 +}
174 +
175 +// regexSubstitute performs regex find and replace.
176 +func regexSubstitute(value Value, paramStr string) (Value, error) {
177 + // paramStr format: "pattern\noutput"
178 + parts := strings.SplitN(paramStr, "\n", 2)
179 + if len(parts) != 2 {
180 + return Value{}, fmt.Errorf("regex substitution requires pattern and output")
181 + }
182 +
183 + pattern := parts[0]
184 + output := parts[1]
185 +
186 + // Debug: log the parsed parts
187 + //fmt.Printf("DEBUG regsub: pattern='%s' output='%s'\n", pattern, output)
188 +
189 + // Compile regex with safety checks and caching
190 + re, err := compileRegexSafe(pattern, 0)
191 + if err != nil {
192 + return Value{}, fmt.Errorf("invalid regex pattern: %w", err)
193 + }
194 +
195 + // Check if there's a match - if no match, return error (with timeout protection)
196 + matched, err := matchWithTimeout(re, value.Data, defaultRegexTimeout)
197 + if err != nil {
198 + return Value{}, fmt.Errorf("regex match failed: %w", err)
199 + }
200 + if !matched {
201 + return Value{}, fmt.Errorf("regex pattern does not match")
202 + }
203 +
204 + // Find match with capture groups (with timeout protection)
205 + // Zabbix regsub returns only the replacement string (not modified original)
206 + matches, err := findStringSubmatchIndexWithTimeout(re, value.Data, defaultRegexTimeout)
207 + if err != nil {
208 + return Value{}, fmt.Errorf("regex substitution failed: %w", err)
209 + }
210 + if matches == nil {
211 + return Value{}, fmt.Errorf("regex pattern does not match")
212 + }
213 +
214 + // Convert Zabbix-style backreferences (\1, \2) to Go-style ($1, $2)
215 + // Zabbix uses \N syntax, but Go's regexp.Expand() uses $N syntax
216 + // Must preserve escaped backslashes (\\1 should become literal \1, not $1)
217 + goOutput := convertBackreferences(output)
218 +
219 + // Use regexp.Expand() for proper capture group expansion
220 + // This correctly handles:
221 + // - Multi-digit backreferences (${10}, ${11}, etc.)
222 + // - Escaped backslashes
223 + // - All edge cases (unmatched groups, nested references, etc.)
224 + var replacement []byte
225 + replacement = re.Expand(replacement, []byte(goOutput), []byte(value.Data), matches)
226 +
227 + // Return the replacement string (not the modified original string)
228 + return Value{Data: string(replacement), Type: value.Type}, nil
229 +}
230 +
231 +// convertBackreferences converts Zabbix-style \N to Go-style ${N}
232 +// Preserves escaped backslashes: \\1 remains \1 (literal)
233 +// Uses ${N} instead of $N to avoid ambiguity (e.g., $1y vs ${1}y)
234 +func convertBackreferences(s string) string {
235 + var result strings.Builder
236 + i := 0
237 + for i < len(s) {
238 + if i+1 < len(s) && s[i] == '\\' {
239 + if s[i+1] == '\\' {
240 + // Escaped backslash: \\1 → \1 (literal)
241 + result.WriteByte('\\')
242 + i += 2
243 + continue
244 + } else if s[i+1] >= '0' && s[i+1] <= '9' {
245 + // Backreference: \1 → ${1}, \10 → ${10}, etc.
246 + result.WriteString("${")
247 + // Handle multi-digit backreferences (\10, \11, etc.)
248 + j := i + 1
249 + for j < len(s) && s[j] >= '0' && s[j] <= '9' {
250 + result.WriteByte(s[j])
251 + j++
252 + }
253 + result.WriteByte('}')
254 + i = j
255 + continue
256 + }
257 + }
258 + result.WriteByte(s[i])
259 + i++
260 + }
261 + return result.String()
262 +}
263 +
264 +// bool2Dec converts boolean values to decimal.
265 +func bool2Dec(value Value) (Value, error) {
266 + // Empty input is an error
267 + if value.Data == "" {
268 + return Value{}, fmt.Errorf("empty input for bool2dec")
269 + }
270 +
271 + v := strings.ToLower(strings.TrimSpace(value.Data))
272 +
273 + // Zabbix considers these as true (full words and single letters)
274 + trueValues := map[string]bool{
275 + "1": true, "true": true, "yes": true, "on": true,
276 + "t": true, "y": true, "ok": true,
277 + }
278 +
279 + // Zabbix considers these as false (full words and single letters)
280 + falseValues := map[string]bool{
281 + "0": true, "false": true, "no": true, "off": true,
282 + "f": true, "n": true, "err": true, "error": true,
283 + }
284 +
285 + var result int64
286 + if trueValues[v] {
287 + result = 1
288 + } else if falseValues[v] {
289 + result = 0
290 + } else {
291 + // Try to parse as number
292 + num, err := strconv.ParseInt(v, 10, 64)
293 + if err != nil {
294 + // If not a recognized value and not a number, return error
295 + return Value{}, fmt.Errorf("unrecognized boolean value: %s", v)
296 + }
297 + if num != 0 {
298 + result = 1
299 + }
300 + }
301 +
302 + return Value{Data: fmt.Sprintf("%d", result), Type: ValueTypeUint64}, nil
303 +}
304 +
305 +// oct2Dec converts octal string to decimal.
306 +func oct2Dec(value Value) (Value, error) {
307 + // Parse as octal
308 + num, err := strconv.ParseInt(strings.TrimSpace(value.Data), parseBase8, parseBitSize64)
309 + if err != nil {
310 + return Value{}, fmt.Errorf("invalid octal value: %w", err)
311 + }
312 +
313 + return Value{Data: fmt.Sprintf("%d", num), Type: ValueTypeUint64}, nil
314 +}
315 +
316 +// hex2Dec converts hexadecimal string to decimal.
317 +func hex2Dec(value Value) (Value, error) {
318 + // Parse as hexadecimal (with or without 0x prefix)
319 + data := strings.TrimSpace(value.Data)
320 + if strings.HasPrefix(data, "0x") || strings.HasPrefix(data, "0X") {
321 + data = data[2:]
322 + }
323 +
324 + // Remove spaces for parsing
325 + data = strings.ReplaceAll(data, " ", "")
326 +
327 + num, err := strconv.ParseInt(data, parseBase16, parseBitSize64)
328 + if err != nil {
329 + return Value{}, fmt.Errorf("invalid hexadecimal value: %w", err)
330 + }
331 +
332 + return Value{Data: fmt.Sprintf("%d", num), Type: ValueTypeUint64}, nil
333 +}
334 +
335 +// deltaValue calculates the difference between current and previous value.
336 +// Thread-safe for concurrent use. State isolated per shard and item.
337 +func (p *Preprocessor) deltaValue(itemID string, value Value, paramStr string) (Value, error) {
338 + // State key: shardID:itemID:operation
339 + stateKey := fmt.Sprintf("%s:%s:delta_value", p.shardID, itemID)
340 +
341 + // Parse numeric value before acquiring lock
342 + numValue, err := strconv.ParseFloat(value.Data, 64)
343 + if err != nil {
344 + return Value{}, fmt.Errorf("invalid numeric value: %w", err)
345 + }
346 +
347 + // Acquire exclusive lock for read-modify-write operation
348 + p.mu.Lock()
349 + defer p.mu.Unlock()
350 +
351 + state, hasState := p.state[stateKey]
352 +
353 + if !hasState {
354 + // First call - no previous value
355 + p.state[stateKey] = &OperationState{
356 + LastValue: value.Data,
357 + LastTimestamp: value.Timestamp,
358 + LastAccess: time.Now(),
359 + }
360 + return Value{Data: "0", Type: ValueTypeStr}, nil
361 + }
362 +
363 + // Update access time
364 + state.LastAccess = time.Now()
365 +
366 + // Calculate delta
367 + prevValue, err := strconv.ParseFloat(state.LastValue, 64)
368 + if err != nil {
369 + // If we can't parse previous, reset and return 0
370 + p.state[stateKey] = &OperationState{
371 + LastValue: value.Data,
372 + LastTimestamp: value.Timestamp,
373 + LastAccess: time.Now(),
374 + }
375 + return Value{Data: "0", Type: ValueTypeStr}, nil
376 + }
377 +
378 + delta := numValue - prevValue
379 +
380 + // Update state
381 + p.state[stateKey] = &OperationState{
382 + LastValue: value.Data,
383 + LastTimestamp: value.Timestamp,
384 + LastAccess: time.Now(),
385 + }
386 +
387 + // Discard negative deltas (value decreased)
388 + if delta < 0 {
389 + return Value{Data: "", Type: ValueTypeStr}, nil
390 + }
391 +
392 + // Return delta
393 + if delta == float64(int64(delta)) {
394 + return Value{Data: fmt.Sprintf("%d", int64(delta)), Type: ValueTypeStr}, nil
395 + }
396 + return Value{Data: fmt.Sprintf("%g", delta), Type: ValueTypeStr}, nil
397 +}
398 +
399 +// deltaSpeed calculates the speed (delta per second).
400 +// Thread-safe for concurrent use. State isolated per shard and item.
401 +func (p *Preprocessor) deltaSpeed(itemID string, value Value, paramStr string) (Value, error) {
402 + // State key: shardID:itemID:operation
403 + stateKey := fmt.Sprintf("%s:%s:delta_speed", p.shardID, itemID)
404 +
405 + // Parse numeric value before acquiring lock
406 + numValue, err := strconv.ParseFloat(value.Data, 64)
407 + if err != nil {
408 + return Value{}, fmt.Errorf("invalid numeric value: %w", err)
409 + }
410 +
411 + // Acquire exclusive lock for read-modify-write operation
412 + p.mu.Lock()
413 + defer p.mu.Unlock()
414 +
415 + state, hasState := p.state[stateKey]
416 +
417 + if !hasState {
418 + // First call - no previous value
419 + p.state[stateKey] = &OperationState{
420 + LastValue: value.Data,
421 + LastValueTime: value.Timestamp,
422 + LastTimestamp: value.Timestamp,
423 + LastAccess: time.Now(),
424 + }
425 + return Value{Data: "0", Type: ValueTypeStr}, nil
426 + }
427 +
428 + // Update access time
429 + state.LastAccess = time.Now()
430 +
431 + // Calculate delta
432 + prevValue, err := strconv.ParseFloat(state.LastValue, 64)
433 + if err != nil {
434 + // If we can't parse previous, reset and return 0
435 + p.state[stateKey] = &OperationState{
436 + LastValue: value.Data,
437 + LastValueTime: value.Timestamp,
438 + LastTimestamp: value.Timestamp,
439 + LastAccess: time.Now(),
440 + }
441 + return Value{Data: "0", Type: ValueTypeStr}, nil
442 + }
443 +
444 + delta := numValue - prevValue
445 + timeDiff := value.Timestamp.Sub(state.LastValueTime).Seconds()
446 +
447 + // Update state
448 + p.state[stateKey] = &OperationState{
449 + LastValue: value.Data,
450 + LastValueTime: value.Timestamp,
451 + LastTimestamp: value.Timestamp,
452 + LastAccess: time.Now(),
453 + }
454 +
455 + // Discard if time went backwards or stayed the same
456 + if timeDiff <= 0 {
457 + return Value{Data: "", Type: ValueTypeStr}, nil
458 + }
459 +
460 + // Discard if value decreased (negative delta)
461 + if delta < 0 {
462 + return Value{Data: "", Type: ValueTypeStr}, nil
463 + }
464 +
465 + speed := delta / timeDiff
466 +
467 + // Format based on input value type
468 + if value.Type == ValueTypeUint64 {
469 + // For UINT64, truncate to integer
470 + return Value{Data: fmt.Sprintf("%d", int64(speed)), Type: ValueTypeStr}, nil
471 + }
472 +
473 + // For other types, use float formatting
474 + if speed == float64(int64(speed)) {
475 + return Value{Data: fmt.Sprintf("%d", int64(speed)), Type: ValueTypeStr}, nil
476 + }
477 + return Value{Data: fmt.Sprintf("%g", speed), Type: ValueTypeStr}, nil
478 +}
479 +
480 +// stringReplace replaces one string with another.
481 +func stringReplace(value Value, paramStr string) (Value, error) {
482 + // paramStr format: "search\nreplace"
483 + parts := strings.SplitN(paramStr, "\n", 2)
484 + if len(parts) != 2 {
485 + return Value{}, fmt.Errorf("string replace requires search and replace parameters")
486 + }
487 +
488 + // Interpret escape sequences in search and replace strings
489 + search := interpretEscapeSequences(parts[0])
490 + replace := interpretEscapeSequences(parts[1])
491 +
492 + // Empty search string is not allowed
493 + if search == "" {
494 + return Value{}, fmt.Errorf("search string cannot be empty")
495 + }
496 +
497 + result := strings.ReplaceAll(value.Data, search, replace)
498 + return Value{Data: result, Type: value.Type}, nil
499 +}
500 +
501 +// validateRange validates that a value is within a numeric range.
502 +func validateRange(value Value, paramStr string) (Value, error) {
503 + // paramStr format: "min\nmax"
504 + parts := strings.SplitN(paramStr, "\n", 2)
505 + if len(parts) != 2 {
506 + return Value{}, fmt.Errorf("validate range requires min and max parameters")
507 + }
508 +
509 + minStr := strings.TrimSpace(parts[0])
510 + maxStr := strings.TrimSpace(parts[1])
511 +
512 + numValue, err := strconv.ParseFloat(value.Data, 64)
513 + if err != nil {
514 + return Value{}, fmt.Errorf("invalid numeric value: %w", err)
515 + }
516 +
517 + min, err := strconv.ParseFloat(minStr, 64)
518 + if err != nil {
519 + return Value{}, fmt.Errorf("invalid minimum value: %w", err)
520 + }
521 +
522 + max, err := strconv.ParseFloat(maxStr, 64)
523 + if err != nil {
524 + return Value{}, fmt.Errorf("invalid maximum value: %w", err)
525 + }
526 +
527 + if numValue < min || numValue > max {
528 + return Value{}, fmt.Errorf("value %g is out of range [%g, %g]", numValue, min, max)
529 + }
530 +
531 + return value, nil
532 +}
533 +
534 +// validateRegex validates that a value matches a regex pattern.
535 +func validateRegex(value Value, paramStr string) (Value, error) {
536 + pattern := strings.TrimSpace(paramStr)
537 + if pattern == "" {
538 + return Value{}, fmt.Errorf("validate regex requires a pattern")
539 + }
540 +
541 + re, err := compileRegexSafe(pattern, 0)
542 + if err != nil {
543 + return Value{}, fmt.Errorf("invalid regex pattern: %w", err)
544 + }
545 +
546 + matched, err := matchWithTimeout(re, value.Data, defaultRegexTimeout)
547 + if err != nil {
548 + return Value{}, fmt.Errorf("regex validation failed: %w", err)
549 + }
550 + if !matched {
551 + return Value{}, fmt.Errorf("value does not match regex pattern: %s", pattern)
552 + }
553 +
554 + return value, nil
555 +}
556 +
557 +// validateNotRegex validates that a value does NOT match a regex pattern.
558 +func validateNotRegex(value Value, paramStr string) (Value, error) {
559 + pattern := strings.TrimSpace(paramStr)
560 + if pattern == "" {
561 + return Value{}, fmt.Errorf("validate not regex requires a pattern")
562 + }
563 +
564 + re, err := compileRegexSafe(pattern, 0)
565 + if err != nil {
566 + return Value{}, fmt.Errorf("invalid regex pattern: %w", err)
567 + }
568 +
569 + matched, err := matchWithTimeout(re, value.Data, defaultRegexTimeout)
570 + if err != nil {
571 + return Value{}, fmt.Errorf("regex validation failed: %w", err)
572 + }
573 + if matched {
574 + return Value{}, fmt.Errorf("value matches regex pattern but should not: %s", pattern)
575 + }
576 +
577 + return value, nil
578 +}
579 +
580 +// validateNotSupported checks if an error from a previous step is "not supported".
581 +// - If input is a VALUE (not an error): pass through unchanged (succeed)
582 +// - If input is an ERROR:
583 +// - If params is empty: fail (any error is considered "supported")
584 +// - If params has pattern: check if error matches pattern
585 +// - Matches: fail (error is "supported"/expected)
586 +// - Doesn't match: succeed (error is "not supported"/unexpected)
587 +func validateNotSupported(value Value, paramStr string) (Value, error) {
588 + // If input is not an error, pass through unchanged
589 + if !value.IsError {
590 + return value, nil
591 + }
592 +
593 + // Input is an error - validate it
594 + paramStr = strings.TrimSpace(paramStr)
595 +
596 + // If no params provided, any error is considered "supported" - fail
597 + if paramStr == "" {
598 + return Value{}, fmt.Errorf("value is not supported")
599 + }
600 +
601 + // Parse params: "mode\npattern"
602 + parts := strings.SplitN(paramStr, "\n", 2)
603 + if len(parts) != 2 {
604 + // Invalid params format, treat as "any error is supported"
605 + return Value{}, fmt.Errorf("value is not supported")
606 + }
607 +
608 + // mode := strings.TrimSpace(parts[0]) // Mode not currently used
609 + pattern := strings.TrimSpace(parts[1])
610 +
611 + // Empty pattern means any error is supported
612 + if pattern == "" {
613 + return Value{}, fmt.Errorf("value is not supported")
614 + }
615 +
616 + // Check if error message matches pattern (regex match)
617 + re, err := compileRegexSafe(pattern, 0)
618 + if err != nil {
619 + // Invalid regex pattern - treat as validation failure
620 + return Value{}, fmt.Errorf("invalid pattern in validation: %w", err)
621 + }
622 +
623 + matched, err := matchWithTimeout(re, value.Data, defaultRegexTimeout)
624 + if err != nil {
625 + // Regex timeout or error
626 + return Value{}, fmt.Errorf("pattern matching failed: %w", err)
627 + }
628 +
629 + if matched {
630 + // Error matches pattern - it's "supported"/expected, so fail
631 + return Value{}, fmt.Errorf("value is not supported")
632 + }
633 +
634 + // Error doesn't match pattern - it's "not supported"/unexpected, so succeed
635 + // Pass through the error data unchanged
636 + return value, nil
637 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/testdata/item_preproc_csv_to_json.yaml new
+517
@@ -0,0 +1,517 @@
1 +---
2 +test case: 'missing second parameter'
3 +in:
4 + csv: ''
5 + params: ''
6 +out:
7 + result: ''
8 + return: 'FAIL'
9 +---
10 +test case: 'missing third parameter'
11 +in:
12 + csv: ''
13 + params: "\n"
14 +out:
15 + result: ''
16 + return: 'FAIL'
17 +---
18 +test case: 'invalid first parameter'
19 +in:
20 + csv: ''
21 + params: ",.\n\n0"
22 +out:
23 + result: ''
24 + return: 'FAIL'
25 +---
26 +test case: 'invalid second parameter'
27 +in:
28 + csv: ''
29 + params: "\n,.\n0"
30 +out:
31 + result: ''
32 + return: 'FAIL'
33 +---
34 +test case: 'empty input (1)'
35 +in:
36 + csv: ''
37 + params: "\n\n1"
38 +out:
39 + result: '[]'
40 + return: 'SUCCEED'
41 +---
42 +test case: 'empty input (2)'
43 +in:
44 + csv: ''
45 + params: "\n\n0"
46 +out:
47 + result: '[]'
48 + return: 'SUCCEED'
49 +---
50 +test case: 'single char (1)'
51 +in:
52 + csv: 'A'
53 + params: ",\n\n1"
54 +out:
55 + result: '[]'
56 + return: 'SUCCEED'
57 +---
58 +test case: 'single char (2)'
59 +in:
60 + csv: 'A'
61 + params: ",\n\n0"
62 +out:
63 + result: '[{"1":"A"}]'
64 + return: 'SUCCEED'
65 +---
66 +test case: 'single UTF8 4-bytes character (1)'
67 +in:
68 + csv: '😂'
69 + params: ",\n\n1"
70 +out:
71 + result: '[]'
72 + return: 'SUCCEED'
73 +---
74 +test case: 'single UTF8 4-bytes character (2)'
75 +in:
76 + csv: '😂'
77 + params: ",\n\n0"
78 +out:
79 + result: '[{"1":"😂"}]'
80 + return: 'SUCCEED'
81 +---
82 +test case: 'empty line (1)'
83 +in:
84 + csv: "\n"
85 + params: ",\n\n1"
86 +out:
87 + result: '[{"":""}]'
88 + return: 'SUCCEED'
89 +---
90 +test case: 'empty line (2)'
91 +in:
92 + csv: "\n"
93 + params: ",\n\n0"
94 +out:
95 + result: '[{},{}]'
96 + return: 'SUCCEED'
97 +---
98 +test case: 'multiple empty lines (1)'
99 +in:
100 + csv: "\n\n\n"
101 + params: ",\n\n1"
102 +out:
103 + result: '[{"":""},{"":""},{"":""}]'
104 + return: 'SUCCEED'
105 +---
106 +test case: 'multiple empty lines (2)'
107 +in:
108 + csv: "\n\n\n"
109 + params: ",\n\n0"
110 +out:
111 + result: '[{},{},{},{}]'
112 + return: 'SUCCEED'
113 +---
114 +test case: 'single column (1)'
115 +in:
116 + csv: |
117 + nr
118 + 1
119 + 2
120 + params: "\n\n1"
121 +out:
122 + result: '[{"nr":"1"},{"nr":"2"},{"nr":""}]'
123 + return: 'SUCCEED'
124 +---
125 +test case: 'single column (2)'
126 +in:
127 + csv: |
128 + nr
129 + 1
130 + 2
131 + params: "\n\n0"
132 +out:
133 + result: '[{"1":"nr"},{"1":"1"},{"1":"2"},{}]'
134 + return: 'SUCCEED'
135 +---
136 +test case: 'single line (1)'
137 +in:
138 + csv: "a,b,c"
139 + params: "\n\n1"
140 +out:
141 + result: '[]'
142 + return: 'SUCCEED'
143 +---
144 +test case: 'single line (2)'
145 +in:
146 + csv: "a,b,c"
147 + params: "\n\n0"
148 +out:
149 + result: '[{"1":"a","2":"b","3":"c"}]'
150 + return: 'SUCCEED'
151 +---
152 +test case: 'no trailing line break (1)'
153 +in:
154 + csv: "nr,\"name\",addr\n\"1\",abc,xyz\n\"2\",\"cba\",zyx"
155 + params: "\n\"\n1"
156 +out:
157 + result: '[{"nr":"1","name":"abc","addr":"xyz"},{"nr":"2","name":"cba","addr":"zyx"}]'
158 + return: 'SUCCEED'
159 +---
160 +test case: 'no trailing line break (2)'
161 +in:
162 + csv: "nr,\"name\",addr\n\"1\",abc,xyz\n\"2\",\"cba\","
163 + params: "\n\"\n1"
164 +out:
165 + result: '[{"nr":"1","name":"abc","addr":"xyz"},{"nr":"2","name":"cba","addr":""}]'
166 + return: 'SUCCEED'
167 +---
168 +test case: 'no trailing line break (3)'
169 +in:
170 + csv: "nr,\"name\",addr\n\"1\",abc,xyz\n\"2\",\"cba\",\"zyx\""
171 + params: "\n\"\n1"
172 +out:
173 + result: '[{"nr":"1","name":"abc","addr":"xyz"},{"nr":"2","name":"cba","addr":"zyx"}]'
174 + return: 'SUCCEED'
175 +---
176 +test case: 'empty fields (1)'
177 +in:
178 + csv: ",,"
179 + params: "\n\n1"
180 +out:
181 + result: ''
182 + return: 'FAIL'
183 +---
184 +test case: 'empty fields (2)'
185 +in:
186 + csv: ",,"
187 + params: "\n\n0"
188 +out:
189 + result: '[{"1":"","2":"","3":""}]'
190 + return: 'SUCCEED'
191 +---
192 +test case: 'empty fields (3)'
193 +in:
194 + csv: "a,b,c\n,,"
195 + params: "\n\n1"
196 +out:
197 + result: '[{"a":"","b":"","c":""}]'
198 + return: 'SUCCEED'
199 +---
200 +test case: 'delimiter in quoted field'
201 +in:
202 + csv: '`fld,1`,`fld,2`'
203 + params: "\n`\n0"
204 +out:
205 + result: '[{"1":"fld,1","2":"fld,2"}]'
206 + return: 'SUCCEED'
207 +---
208 +test case: 'quotation character in unquoted field'
209 +in:
210 + csv: 'fld`1,fld`2'
211 + params: "\n`\n0"
212 +out:
213 + result: '[{"1":"fld`1","2":"fld`2"}]'
214 + return: 'SUCCEED'
215 +---
216 +test case: 'quotation character in unquoted field (2)'
217 +in:
218 + csv: ' `fld`1` , `fld`2` '
219 + params: "\n`\n0"
220 +out:
221 + result: '[{"1":" `fld`1` ","2":" `fld`2` "}]'
222 + return: 'SUCCEED'
223 +---
224 +test case: 'escaped quotation character'
225 +in:
226 + csv: '`fld``1`,`fld``2`'
227 + params: "\n`\n0"
228 +out:
229 + result: '[{"1":"fld`1","2":"fld`2"}]'
230 + return: 'SUCCEED'
231 +---
232 +test case: 'delimiter set in sep line'
233 +in:
234 + csv: |-
235 + sEp=.
236 + col1,.;col2,.;
237 + fld1,.;fld2,.;
238 + params: "\n\n1"
239 +out:
240 + result: '[{"col1,":"fld1,",";col2,":";fld2,",";":";"}]'
241 + return: 'SUCCEED'
242 +---
243 +test case: 'cr/nl line breaks'
244 +in:
245 + csv: "Sep=.\r\nname.addr\r\nabc.xyz\r\n\r\nfld1.fld2\r\n\r\n\r\nfld3.fld4\r\n"
246 + params: "\n\n1"
247 +out:
248 + result: '[{"name":"abc","addr":"xyz"},{"name":"","addr":""},{"name":"fld1","addr":"fld2"},{"name":"","addr":""},{"name":"","addr":""},{"name":"fld3","addr":"fld4"},{"name":"","addr":""}]'
249 + return: 'SUCCEED'
250 +---
251 +test case: 'various length rows'
252 +in:
253 + csv: |-
254 + fld1,fld2,fld3
255 + fld1,fld2,fld3,fld4
256 + fld1,fld2
257 + fld1,fld2,fld3,fld4,fld5
258 + params: "\n\n0"
259 +out:
260 + result: '[{"1":"fld1","2":"fld2","3":"fld3"},{"1":"fld1","2":"fld2","3":"fld3","4":"fld4"},{"1":"fld1","2":"fld2"},{"1":"fld1","2":"fld2","3":"fld3","4":"fld4","5":"fld5"}]'
261 + return: 'SUCCEED'
262 +---
263 +test case: 'equal delimiter and quotation characters'
264 +in:
265 + csv: |-
266 + ,fld1,,fld2
267 + params: ",\n,\n0"
268 +out:
269 + result: '[{"1":"","2":"fld1","3":"","4":"fld2"}]'
270 + return: 'SUCCEED'
271 +---
272 +test case: 'UTF8 2-byte delimiter'
273 +in:
274 + csv: |-
275 + col1фcol2
276 + fld1фfld2
277 + params: "ф\n\n1"
278 +out:
279 + result: '[{"col1":"fld1","col2":"fld2"}]'
280 + return: 'SUCCEED'
281 +---
282 +test case: 'UTF8 3-byte delimiter set in sep line'
283 +in:
284 + csv: |-
285 + sep=ꢂ
286 + col1ꢂcol2
287 + fld1ꢂfld2
288 + params: "\n\n1"
289 +out:
290 + result: '[{"col1":"fld1","col2":"fld2"}]'
291 + return: 'SUCCEED'
292 +---
293 +test case: 'UTF8 4-byte delimiter'
294 +in:
295 + csv: |-
296 + col1😀col2
297 + fld1😀fld2
298 + params: "😀\n\n1"
299 +out:
300 + result: '[{"col1":"fld1","col2":"fld2"}]'
301 + return: 'SUCCEED'
302 +---
303 +test case: 'UTF8 2-byte quotation character'
304 +in:
305 + csv: |-
306 + ыcol1ы,ыcol,2ы
307 + ыfld1ы,ыfld,2ы
308 + params: "\nы\n1"
309 +out:
310 + result: '[{"col1":"fld1","col,2":"fld,2"}]'
311 + return: 'SUCCEED'
312 +---
313 +test case: 'UTF8 3-byte quotation character'
314 +in:
315 + csv: |-
316 + ꢂcol1ꢂ,ꢂcol,2ꢂ
317 + ꢂfld1ꢂ,ꢂfld,2ꢂ
318 + params: "\nꢂ\n1"
319 +out:
320 + result: '[{"col1":"fld1","col,2":"fld,2"}]'
321 + return: 'SUCCEED'
322 +---
323 +test case: 'UTF8 multi-byte delimiter and quotation characters'
324 +in:
325 + csv: |-
326 + 😀col1😀ꢂ😀col2😀ꢂ😀😀
327 + fld1ꢂ😀fld2😀ꢂ
328 + params: "ꢂ\n😀\n1"
329 +out:
330 + result: '[{"col1":"fld1","col2":"fld2","":""}]'
331 + return: 'SUCCEED'
332 +---
333 +test case: 'delimiter set to space'
334 +in:
335 + csv: |-
336 + col1 col2 `col 3`
337 + fld1 fld2 `fld 3`
338 + params: " \n`\n1"
339 +out:
340 + result: '[{"col1":"fld1","col2":"fld2","col 3":"fld 3","":""}]'
341 + return: 'SUCCEED'
342 +---
343 +test case: 'quotation character set to space'
344 +in:
345 + csv: |-
346 + col1, col2 ,col 3
347 + fld1 ,fld2, fld 3
348 + params: "\n \n1"
349 +out:
350 + result: '[{"col1":"fld1","col2":"fld2","col 3":"fld 3"}]'
351 + return: 'SUCCEED'
352 +---
353 +test case: 'sep line only (1)'
354 +in:
355 + csv: 'sep=.'
356 + params: "\n\n1"
357 +out:
358 + result: '[]'
359 + return: 'SUCCEED'
360 +---
361 +test case: 'sep line only (2)'
362 +in:
363 + csv: 'sep=.'
364 + params: "\n\n0"
365 +out:
366 + result: '[]'
367 + return: 'SUCCEED'
368 +---
369 +test case: 'sep line only (3)'
370 +in:
371 + csv: "sep=.\n"
372 + params: "\n\n1"
373 +out:
374 + result: '[]'
375 + return: 'SUCCEED'
376 +---
377 +test case: 'sep line only (4)'
378 +in:
379 + csv: 'sep=.'
380 + params: "\n\n0"
381 +out:
382 + result: '[]'
383 + return: 'SUCCEED'
384 +---
385 +test case: 'unsupported sep line (1)'
386 +in:
387 + csv: |-
388 + sep=.;
389 + fld1,fld2
390 + params: "\n\n0"
391 +out:
392 + result: '[{"1":"sep=.;"},{"1":"fld1","2":"fld2"}]'
393 + return: 'SUCCEED'
394 +---
395 +test case: 'unsupported sep line (2)'
396 +in:
397 + csv: |-
398 + sep=
399 + fld1,fld2
400 + params: "\n\n0"
401 +out:
402 + result: '[{"1":"sep="},{"1":"fld1","2":"fld2"}]'
403 + return: 'SUCCEED'
404 +---
405 +test case: 'duplicated column names (1)'
406 +in:
407 + csv: |-
408 + col1,col1
409 + fld1,fld2
410 + params: "\n\n1"
411 +out:
412 + result: ''
413 + return: 'FAIL'
414 +---
415 +test case: 'duplicated column names (2)'
416 +in:
417 + csv: |-
418 + ,
419 + fld1,fld2
420 + params: "\n\n1"
421 +out:
422 + result: ''
423 + return: 'FAIL'
424 +---
425 +test case: 'more fields in data row than in header'
426 +in:
427 + csv: |-
428 + col1,col1
429 + fld1,fld2,fld3
430 + params: "\n\n1"
431 +out:
432 + result: ''
433 + return: 'FAIL'
434 +---
435 +test case: 'unclosed quoted field'
436 +in:
437 + csv: 'col1,"col1'
438 + params: "\n\"\n0"
439 +out:
440 + result: ''
441 + return: 'FAIL'
442 +---
443 +test case: 'unclosed quoted field (UTF8-2)'
444 +in:
445 + csv: 'col1,ыcol1'
446 + params: "\nы\n0"
447 +out:
448 + result: ''
449 + return: 'FAIL'
450 +---
451 +test case: 'unclosed quoted field (UTF8-3)'
452 +in:
453 + csv: 'col1,ꢂcol1'
454 + params: "\nꢂ\n0"
455 +out:
456 + result: ''
457 + return: 'FAIL'
458 +---
459 +test case: 'unclosed quoted field (UTF8-4)'
460 +in:
461 + csv: 'col1,😀col1'
462 + params: "\n😀\n0"
463 +out:
464 + result: ''
465 + return: 'FAIL'
466 +---
467 +test case: 'escaped quotation character in unclosed quoted field (UTF8-3)'
468 +in:
469 + csv: 'col1,ꢂcol1ꢂꢂ'
470 + params: "\nꢂ\n0"
471 +out:
472 + result: ''
473 + return: 'FAIL'
474 +---
475 +test case: 'missing delimiter or line break after quoted field'
476 +in:
477 + csv: 'col1,"col1" '
478 + params: "\n\"\n0"
479 +out:
480 + result: ''
481 + return: 'FAIL'
482 +---
483 +test case: 'unsupported line break'
484 +in:
485 + csv: "col1,col1\n\rfld1,fld2"
486 + params: "\n\n0"
487 +out:
488 + result: ''
489 + return: 'FAIL'
490 +---
491 +test case: 'CSV sample input (1)'
492 +in:
493 + csv: |
494 + sEp=.
495 + ;c`2;`;```;😀ꢂыa;col.5;`col6`;col7
496 + fld.1;`fld.2`;f`3;`;```;;``;aыꢂ😀
497 +
498 + ``;fld2`ы;fld3ꢂ`;`😀`;;``
499 +
500 +
501 + ;c`2;`;```;😀ꢂыa;col.5;`col6`;col7
502 + params: ";\n`\n1"
503 +out:
504 + result: '[{"":"fld.1","c`2":"fld.2",";`":"f`3","😀ꢂыa":";`","col.5":"","col6":"","col7":"aыꢂ😀"},{"":"","c`2":"",";`":"","😀ꢂыa":"","col.5":"","col6":"","col7":""},{"":"","c`2":"fld2`ы",";`":"fld3ꢂ`","😀ꢂыa":"😀","col.5":"","col6":"","col7":""},{"":"","c`2":"",";`":"","😀ꢂыa":"","col.5":"","col6":"","col7":""},{"":"","c`2":"",";`":"","😀ꢂыa":"","col.5":"","col6":"","col7":""},{"":"","c`2":"c`2",";`":";`","😀ꢂыa":"😀ꢂыa","col.5":"col.5","col6":"col6","col7":"col7"},{"":"","c`2":"",";`":"","😀ꢂыa":"","col.5":"","col6":"","col7":""}]'
505 + return: 'SUCCEED'
506 +---
507 +test case: 'CSV sample input (2)'
508 +in:
509 + csv: |-
510 + sEp=.
511 + col1,.;col2,.;
512 + fld1,.;fld2,.;
513 + params: ";\n\n1"
514 +out:
515 + result: '[{"col1,.":"fld1,.","col2,.":"fld2,.","":""}]'
516 + return: 'SUCCEED'
517 +...
src/go/plugin/scripts.d/pkg/zabbixpreproc/testdata/item_preproc_xpath.yaml new
+105
@@ -0,0 +1,105 @@
1 +---
2 +test case: 'empty input parameters'
3 +in:
4 + xml: ''
5 + xpath: ''
6 +out:
7 + result: ''
8 + return: 'FAIL'
9 +---
10 +test case: 'single start tag'
11 +in:
12 + xml: '<a>'
13 + xpath: ''
14 +out:
15 + result: ''
16 + return: 'FAIL'
17 +---
18 +test case: 'single end tag'
19 +in:
20 + xml: '<a/>'
21 + xpath: ''
22 +out:
23 + result: ''
24 + return: 'FAIL'
25 +---
26 +test case: 'wrong operation format'
27 +in:
28 + xml: '<a/>'
29 + xpath: '/a[\'
30 +out:
31 + result: ''
32 + return: 'FAIL'
33 +---
34 +test case: 'wrong operation expression'
35 +in:
36 + xml: '<a/>'
37 + xpath: '1 div 0'
38 +out:
39 + result: ''
40 + return: 'FAIL'
41 +---
42 +test case: 'wrong operation format 2'
43 +in:
44 + xml: '<a/>'
45 + xpath: '-a'
46 +out:
47 + result: ''
48 + return: 'FAIL'
49 +---
50 +test case: 'empty output'
51 +in:
52 + xml: '<a/>'
53 + xpath: '/b'
54 +out:
55 + result: ''
56 + return: 'SUCCEED'
57 +---
58 +test case: 'successful expression'
59 +in:
60 + xml: '<a/>'
61 + xpath: '3 div 2'
62 +out:
63 + result: '1.5'
64 + return: 'SUCCEED'
65 +---
66 +test case: 'return end tag'
67 +in:
68 + xml: '<a/>'
69 + xpath: '/a'
70 +out:
71 + result: '<a/>'
72 + return: 'SUCCEED'
73 +---
74 +test case: 'return text'
75 +in:
76 + xml: '<a>1</a>'
77 + xpath: '/a/text()'
78 +out:
79 + result: '1'
80 + return: 'SUCCEED'
81 +---
82 +test case: 'return string'
83 +in:
84 + xml: '<a>1</a>'
85 + xpath: 'string(/a)'
86 +out:
87 + result: '1'
88 + return: 'SUCCEED'
89 +---
90 +test case: 'return attribute'
91 +in:
92 + xml: '<a b="10">1</a>'
93 + xpath: 'string(/a/@b)'
94 +out:
95 + result: '10'
96 + return: 'SUCCEED'
97 +---
98 +test case: 'return pattern'
99 +in:
100 + xml: '<a><b x="1"/><c x="2"/><d x="1"/></a>'
101 + xpath: '//*[@x="1"]'
102 +out:
103 + result: '<b x="1"/><d x="1"/>'
104 + return: 'SUCCEED'
105 +...
src/go/plugin/scripts.d/pkg/zabbixpreproc/testdata/zbx_item_preproc.yaml new
+4630
@@ -0,0 +1,4630 @@
1 +---
2 +test case: string(10) * 10
3 +in:
4 + value:
5 + value_type: ITEM_VALUE_TYPE_STR
6 + time: 2017-10-29 03:15:00 +03:00
7 + data: 10
8 + step:
9 + type: ZBX_PREPROC_MULTIPLIER
10 + params: 10
11 +out:
12 + return: SUCCEED
13 + value: 100
14 +---
15 +test case: string(10x) * 10
16 +in:
17 + value:
18 + value_type: ITEM_VALUE_TYPE_STR
19 + time: 2017-10-29 03:15:00 +03:00
20 + data: 10x
21 + step:
22 + type: ZBX_PREPROC_MULTIPLIER
23 + params: 10
24 +out:
25 + return: FAIL
26 +---
27 +test case: string(10) * abc
28 +in:
29 + value:
30 + value_type: ITEM_VALUE_TYPE_STR
31 + time: 2017-10-29 03:15:00 +03:00
32 + data: 10
33 + step:
34 + type: ZBX_PREPROC_MULTIPLIER
35 + params: abc
36 +out:
37 + return: FAIL
38 +---
39 +test case: string(1.5) * 3
40 +in:
41 + value:
42 + value_type: ITEM_VALUE_TYPE_STR
43 + time: 2017-10-29 03:15:00 +03:00
44 + data: 1.5
45 + step:
46 + type: ZBX_PREPROC_MULTIPLIER
47 + params: 3
48 +out:
49 + return: SUCCEED
50 + value: 4.5
51 +---
52 +test case: uint64(1.5) * 3
53 +in:
54 + value:
55 + value_type: ITEM_VALUE_TYPE_UINT64
56 + time: 2017-10-29 03:15:00 +03:00
57 + data: 1.5
58 + step:
59 + type: ZBX_PREPROC_MULTIPLIER
60 + params: 3
61 +out:
62 + return: SUCCEED
63 + value: 3
64 +---
65 +test case: uint64(3) * 1.5
66 +in:
67 + value:
68 + value_type: ITEM_VALUE_TYPE_UINT64
69 + time: 2017-10-29 03:15:00 +03:00
70 + data: 3
71 + step:
72 + type: ZBX_PREPROC_MULTIPLIER
73 + params: 1.5
74 +out:
75 + return: SUCCEED
76 + value: 4
77 +---
78 +test case: float(1.5) * 3
79 +in:
80 + value:
81 + value_type: ITEM_VALUE_TYPE_FLOAT
82 + time: 2017-10-29 03:15:00 +03:00
83 + data: 1.5
84 + step:
85 + type: ZBX_PREPROC_MULTIPLIER
86 + params: 3
87 +out:
88 + return: SUCCEED
89 + value: 4.5
90 +---
91 +test case: float(3) * 1.5
92 +in:
93 + value:
94 + value_type: ITEM_VALUE_TYPE_FLOAT
95 + time: 2017-10-29 03:15:00 +03:00
96 + data: 3
97 + step:
98 + type: ZBX_PREPROC_MULTIPLIER
99 + params: 1.5
100 +out:
101 + return: SUCCEED
102 + value: 4.5
103 +---
104 +test case: rtrim(01abc01, 01)
105 +in:
106 + value:
107 + value_type: ITEM_VALUE_TYPE_STR
108 + time: 2017-10-29 03:15:00 +03:00
109 + data: 01abc01
110 + step:
111 + type: ZBX_PREPROC_RTRIM
112 + params: 01
113 +out:
114 + return: SUCCEED
115 + value: 01abc
116 +---
117 +test case: rtrim(01abc01, abc)
118 +in:
119 + value:
120 + value_type: ITEM_VALUE_TYPE_STR
121 + time: 2017-10-29 03:15:00 +03:00
122 + data: 01abc01
123 + step:
124 + type: ZBX_PREPROC_RTRIM
125 + params: abc
126 +out:
127 + return: SUCCEED
128 + value: 01abc01
129 +---
130 +test case: ltrim(01abc01, 01)
131 +in:
132 + value:
133 + value_type: ITEM_VALUE_TYPE_STR
134 + time: 2017-10-29 03:15:00 +03:00
135 + data: 01abc01
136 + step:
137 + type: ZBX_PREPROC_LTRIM
138 + params: 01
139 +out:
140 + return: SUCCEED
141 + value: abc01
142 +---
143 +test case: ltrim(01abc01, abc)
144 +in:
145 + value:
146 + value_type: ITEM_VALUE_TYPE_STR
147 + time: 2017-10-29 03:15:00 +03:00
148 + data: 01abc01
149 + step:
150 + type: ZBX_PREPROC_LTRIM
151 + params: abc
152 +out:
153 + return: SUCCEED
154 + value: 01abc01
155 +---
156 +test case: trim(01abc01, 01)
157 +in:
158 + value:
159 + value_type: ITEM_VALUE_TYPE_STR
160 + time: 2017-10-29 03:15:00 +03:00
161 + data: 01abc01
162 + step:
163 + type: ZBX_PREPROC_TRIM
164 + params: 01
165 +out:
166 + return: SUCCEED
167 + value: abc
168 +---
169 +test case: trim(01abc01, abc)
170 +in:
171 + value:
172 + value_type: ITEM_VALUE_TYPE_STR
173 + time: 2017-10-29 03:15:00 +03:00
174 + data: 01abc01
175 + step:
176 + type: ZBX_PREPROC_TRIM
177 + params: abc
178 +out:
179 + return: SUCCEED
180 + value: 01abc01
181 +---
182 +test case: trim(\\s, abc)
183 +in:
184 + value:
185 + value_type: ITEM_VALUE_TYPE_STR
186 + time: 2017-10-29 03:15:00 +03:00
187 + data: "\\ abc \\"
188 + step:
189 + type: ZBX_PREPROC_TRIM
190 + params: "\\\\s"
191 +out:
192 + return: SUCCEED
193 + value: "abc"
194 +---
195 +test case: regsub("test 123 number", "([0-9]+", 1)
196 +in:
197 + value:
198 + value_type: ITEM_VALUE_TYPE_STR
199 + time: 2017-10-29 03:15:00 +03:00
200 + data: test 123 number
201 + step:
202 + type: ZBX_PREPROC_REGSUB
203 + params: "([0-9]+\n\\1"
204 +out:
205 + return: FAIL
206 +---
207 +test case: regsub("test 123 number", "([0-9]+)", \1)
208 +in:
209 + value:
210 + value_type: ITEM_VALUE_TYPE_STR
211 + time: 2017-10-29 03:15:00 +03:00
212 + data: test 123 number
213 + step:
214 + type: ZBX_PREPROC_REGSUB
215 + params: "([0-9]+)\n\\1"
216 +out:
217 + return: SUCCEED
218 + value: 123
219 +---
220 +test case: regsub("test 123 number", "([0-9]+)", x\1y)
221 +in:
222 + value:
223 + value_type: ITEM_VALUE_TYPE_STR
224 + time: 2017-10-29 03:15:00 +03:00
225 + data: test 123 number
226 + step:
227 + type: ZBX_PREPROC_REGSUB
228 + params: "([0-9]+)\nx\\1y"
229 +out:
230 + return: SUCCEED
231 + value: x123y
232 +---
233 +test case: regsub("test 123 number", "([0-9]+)", )
234 +in:
235 + value:
236 + value_type: ITEM_VALUE_TYPE_STR
237 + time: 2017-10-29 03:15:00 +03:00
238 + data: test 123 number
239 + step:
240 + type: ZBX_PREPROC_REGSUB
241 + params: "([0-9]+)"
242 +out:
243 + return: FAIL
244 +---
245 +test case: regsub("test abc number", "([0-9]+)", \1)
246 +in:
247 + value:
248 + value_type: ITEM_VALUE_TYPE_STR
249 + time: 2017-10-29 03:15:00 +03:00
250 + data: test abc number
251 + step:
252 + type: ZBX_PREPROC_REGSUB
253 + params: "([0-9]+)\n\\1"
254 +out:
255 + return: FAIL
256 +---
257 +test case: regsub("", "([0-9]+)", \1)
258 +in:
259 + value:
260 + value_type: ITEM_VALUE_TYPE_STR
261 + time: 2017-10-29 03:15:00 +03:00
262 + data:
263 + step:
264 + type: ZBX_PREPROC_REGSUB
265 + params: "([0-9]+)\n\\1"
266 +out:
267 + return: FAIL
268 +---
269 +test case: regsub("", "^$", ok)
270 +in:
271 + value:
272 + value_type: ITEM_VALUE_TYPE_STR
273 + time: 2017-10-29 03:15:00 +03:00
274 + data: ""
275 + step:
276 + type: ZBX_PREPROC_REGSUB
277 + params: "^$\nok"
278 +out:
279 + return: SUCCEED
280 + value: ok
281 +---
282 +test case: bool2dec()
283 +in:
284 + value:
285 + value_type: ITEM_VALUE_TYPE_STR
286 + time: 2017-10-29 03:15:00 +03:00
287 + data: ""
288 + step:
289 + type: ZBX_PREPROC_BOOL2DEC
290 +out:
291 + return: FAIL
292 +---
293 +test case: bool2dec(abc)
294 +in:
295 + value:
296 + value_type: ITEM_VALUE_TYPE_STR
297 + time: 2017-10-29 03:15:00 +03:00
298 + data: "abc"
299 + step:
300 + type: ZBX_PREPROC_BOOL2DEC
301 +out:
302 + return: FAIL
303 +---
304 +test case: bool2dec(1)
305 +in:
306 + value:
307 + value_type: ITEM_VALUE_TYPE_STR
308 + time: 2017-10-29 03:15:00 +03:00
309 + data: "1"
310 + step:
311 + type: ZBX_PREPROC_BOOL2DEC
312 +out:
313 + return: SUCCEED
314 + value: 1
315 +---
316 +test case: bool2dec(0)
317 +in:
318 + value:
319 + value_type: ITEM_VALUE_TYPE_STR
320 + time: 2017-10-29 03:15:00 +03:00
321 + data: "0"
322 + step:
323 + type: ZBX_PREPROC_BOOL2DEC
324 +out:
325 + return: SUCCEED
326 + value: 0
327 +---
328 +test case: bool2dec(true)
329 +in:
330 + value:
331 + value_type: ITEM_VALUE_TYPE_STR
332 + time: 2017-10-29 03:15:00 +03:00
333 + data: "true"
334 + step:
335 + type: ZBX_PREPROC_BOOL2DEC
336 +out:
337 + return: SUCCEED
338 + value: 1
339 +---
340 +test case: bool2dec(t)
341 +in:
342 + value:
343 + value_type: ITEM_VALUE_TYPE_STR
344 + time: 2017-10-29 03:15:00 +03:00
345 + data: "t"
346 + step:
347 + type: ZBX_PREPROC_BOOL2DEC
348 +out:
349 + return: SUCCEED
350 + value: 1
351 +---
352 +test case: bool2dec(yes)
353 +in:
354 + value:
355 + value_type: ITEM_VALUE_TYPE_STR
356 + time: 2017-10-29 03:15:00 +03:00
357 + data: "yes"
358 + step:
359 + type: ZBX_PREPROC_BOOL2DEC
360 +out:
361 + return: SUCCEED
362 + value: 1
363 +---
364 +test case: bool2dec(y)
365 +in:
366 + value:
367 + value_type: ITEM_VALUE_TYPE_STR
368 + time: 2017-10-29 03:15:00 +03:00
369 + data: "y"
370 + step:
371 + type: ZBX_PREPROC_BOOL2DEC
372 +out:
373 + return: SUCCEED
374 + value: 1
375 +---
376 +test case: bool2dec(ok)
377 +in:
378 + value:
379 + value_type: ITEM_VALUE_TYPE_STR
380 + time: 2017-10-29 03:15:00 +03:00
381 + data: "ok"
382 + step:
383 + type: ZBX_PREPROC_BOOL2DEC
384 +out:
385 + return: SUCCEED
386 + value: 1
387 +---
388 +test case: bool2dec(on)
389 +in:
390 + value:
391 + value_type: ITEM_VALUE_TYPE_STR
392 + time: 2017-10-29 03:15:00 +03:00
393 + data: "on"
394 + step:
395 + type: ZBX_PREPROC_BOOL2DEC
396 +out:
397 + return: SUCCEED
398 + value: 1
399 +---
400 +test case: bool2dec(false)
401 +in:
402 + value:
403 + value_type: ITEM_VALUE_TYPE_STR
404 + time: 2017-10-29 03:15:00 +03:00
405 + data: "false"
406 + step:
407 + type: ZBX_PREPROC_BOOL2DEC
408 +out:
409 + return: SUCCEED
410 + value: 0
411 +---
412 +test case: bool2dec(f)
413 +in:
414 + value:
415 + value_type: ITEM_VALUE_TYPE_STR
416 + time: 2017-10-29 03:15:00 +03:00
417 + data: "f"
418 + step:
419 + type: ZBX_PREPROC_BOOL2DEC
420 +out:
421 + return: SUCCEED
422 + value: 0
423 +---
424 +test case: bool2dec(no)
425 +in:
426 + value:
427 + value_type: ITEM_VALUE_TYPE_STR
428 + time: 2017-10-29 03:15:00 +03:00
429 + data: "no"
430 + step:
431 + type: ZBX_PREPROC_BOOL2DEC
432 +out:
433 + return: SUCCEED
434 + value: 0
435 +---
436 +test case: bool2dec(n)
437 +in:
438 + value:
439 + value_type: ITEM_VALUE_TYPE_STR
440 + time: 2017-10-29 03:15:00 +03:00
441 + data: "n"
442 + step:
443 + type: ZBX_PREPROC_BOOL2DEC
444 +out:
445 + return: SUCCEED
446 + value: 0
447 +---
448 +test case: bool2dec(err)
449 +in:
450 + value:
451 + value_type: ITEM_VALUE_TYPE_STR
452 + time: 2017-10-29 03:15:00 +03:00
453 + data: "err"
454 + step:
455 + type: ZBX_PREPROC_BOOL2DEC
456 +out:
457 + return: SUCCEED
458 + value: 0
459 +---
460 +test case: bool2dec(off)
461 +in:
462 + value:
463 + value_type: ITEM_VALUE_TYPE_STR
464 + time: 2017-10-29 03:15:00 +03:00
465 + data: "off"
466 + step:
467 + type: ZBX_PREPROC_BOOL2DEC
468 +out:
469 + return: SUCCEED
470 + value: 0
471 +---
472 +test case: oct2dec(0)
473 +in:
474 + value:
475 + value_type: ITEM_VALUE_TYPE_STR
476 + time: 2017-10-29 03:15:00 +03:00
477 + data: 0
478 + step:
479 + type: ZBX_PREPROC_OCT2DEC
480 +out:
481 + return: SUCCEED
482 + value: 0
483 +---
484 +test case: oct2dec(7)
485 +in:
486 + value:
487 + value_type: ITEM_VALUE_TYPE_STR
488 + time: 2017-10-29 03:15:00 +03:00
489 + data: 7
490 + step:
491 + type: ZBX_PREPROC_OCT2DEC
492 +out:
493 + return: SUCCEED
494 + value: 7
495 +---
496 +test case: oct2dec(10)
497 +in:
498 + value:
499 + value_type: ITEM_VALUE_TYPE_STR
500 + time: 2017-10-29 03:15:00 +03:00
501 + data: 10
502 + step:
503 + type: ZBX_PREPROC_OCT2DEC
504 +out:
505 + return: SUCCEED
506 + value: 8
507 +---
508 +test case: oct2dec(8)
509 +in:
510 + value:
511 + value_type: ITEM_VALUE_TYPE_STR
512 + time: 2017-10-29 03:15:00 +03:00
513 + data: 8
514 + step:
515 + type: ZBX_PREPROC_OCT2DEC
516 +out:
517 + return: FAIL
518 +---
519 +test case: hex2dec(F)
520 +in:
521 + value:
522 + value_type: ITEM_VALUE_TYPE_STR
523 + time: 2017-10-29 03:15:00 +03:00
524 + data: F
525 + step:
526 + type: ZBX_PREPROC_HEX2DEC
527 +out:
528 + return: SUCCEED
529 + value: 15
530 +---
531 +test case: hex2dec(a)
532 +in:
533 + value:
534 + value_type: ITEM_VALUE_TYPE_STR
535 + time: 2017-10-29 03:15:00 +03:00
536 + data: a
537 + step:
538 + type: ZBX_PREPROC_HEX2DEC
539 +out:
540 + return: SUCCEED
541 + value: 10
542 +---
543 +test case: hex2dec(10)
544 +in:
545 + value:
546 + value_type: ITEM_VALUE_TYPE_STR
547 + time: 2017-10-29 03:15:00 +03:00
548 + data: 10
549 + step:
550 + type: ZBX_PREPROC_HEX2DEC
551 +out:
552 + return: SUCCEED
553 + value: 16
554 +---
555 +test case: hex2dec(g)
556 +in:
557 + value:
558 + value_type: ITEM_VALUE_TYPE_STR
559 + time: 2017-10-29 03:15:00 +03:00
560 + data: g
561 + step:
562 + type: ZBX_PREPROC_HEX2DEC
563 +out:
564 + return: FAIL
565 +---
566 +test case: hex2dec(ff ff)
567 +in:
568 + value:
569 + value_type: ITEM_VALUE_TYPE_STR
570 + time: 2017-10-29 03:15:00 +03:00
571 + data: ff ff
572 + step:
573 + type: ZBX_PREPROC_HEX2DEC
574 +out:
575 + return: SUCCEED
576 + value: 65535
577 +---
578 +test case: deltavalue(5, 10)
579 +in:
580 + value:
581 + value_type: ITEM_VALUE_TYPE_STR
582 + time: 2017-10-29 03:15:00 +03:00
583 + data: 10
584 + history:
585 + variant: ZBX_VARIANT_UI64
586 + time: 2017-10-29 03:14:00 +03:00
587 + data: 5
588 + step:
589 + type: ZBX_PREPROC_DELTA_VALUE
590 +out:
591 + return: SUCCEED
592 + value: 5
593 + history:
594 + data: 10
595 + time: 2017-10-29 03:15:00 +03:00
596 +---
597 +test case: deltavalue(-4.5, 5.5)
598 +in:
599 + value:
600 + value_type: ITEM_VALUE_TYPE_STR
601 + time: 2017-10-29 03:15:00 +03:00
602 + data: 5.5
603 + history:
604 + variant: ZBX_VARIANT_DBL
605 + time: 2017-10-29 03:14:00 +03:00
606 + data: -4.5
607 + step:
608 + type: ZBX_PREPROC_DELTA_VALUE
609 +out:
610 + return: SUCCEED
611 + value: 10
612 + history:
613 + time: 2017-10-29 03:15:00 +03:00
614 + data: 5.5
615 +---
616 +test case: deltavalue(10, 10)
617 +in:
618 + value:
619 + value_type: ITEM_VALUE_TYPE_STR
620 + time: 2017-10-29 03:15:00 +03:00
621 + data: 10
622 + history:
623 + variant: ZBX_VARIANT_UI64
624 + time: 2017-10-29 03:14:00 +03:00
625 + data: 10
626 + step:
627 + type: ZBX_PREPROC_DELTA_VALUE
628 +out:
629 + return: SUCCEED
630 + value: 0
631 + history:
632 + time: 2017-10-29 03:15:00 +03:00
633 + data: 10
634 +---
635 +test case: deltavalue(10, 9)
636 +in:
637 + value:
638 + value_type: ITEM_VALUE_TYPE_STR
639 + time: 2017-10-29 03:15:00 +03:00
640 + data: 0
641 + history:
642 + variant: ZBX_VARIANT_UI64
643 + time: 2017-10-29 03:14:00 +03:00
644 + data: 10
645 + step:
646 + type: ZBX_PREPROC_DELTA_VALUE
647 +out:
648 + return: SUCCEED
649 + history:
650 + time: 2017-10-29 03:15:00 +03:00
651 + data: 0
652 +---
653 +test case: deltavalue(0, 1.5)
654 +in:
655 + value:
656 + value_type: ITEM_VALUE_TYPE_STR
657 + time: 2017-10-29 03:15:00 +03:00
658 + data: 1.5
659 + history:
660 + variant: ZBX_VARIANT_DBL
661 + time: 2017-10-29 03:14:00 +03:00
662 + data: 0
663 + step:
664 + type: ZBX_PREPROC_DELTA_VALUE
665 +out:
666 + return: SUCCEED
667 + value: 1.5
668 + history:
669 + time: 2017-10-29 03:15:00 +03:00
670 + data: 1.5
671 +---
672 +test case: deltaspeed(2, 1, 10s)
673 +in:
674 + value:
675 + value_type: ITEM_VALUE_TYPE_STR
676 + time: 2017-10-29 03:15:00 +03:00
677 + data: 1
678 + history:
679 + variant: ZBX_VARIANT_UI64
680 + time: 2017-10-29 03:14:50 +03:00
681 + data: 2
682 + step:
683 + type: ZBX_PREPROC_DELTA_SPEED
684 +out:
685 + return: SUCCEED
686 + history:
687 + time: 2017-10-29 03:15:00 +03:00
688 + data: 1
689 +---
690 +test case: deltaspeed(1, 2, -10s)
691 +in:
692 + value:
693 + value_type: ITEM_VALUE_TYPE_STR
694 + time: 2017-10-29 03:15:00 +03:00
695 + data: 2
696 + history:
697 + variant: ZBX_VARIANT_UI64
698 + time: 2017-10-29 03:15:10 +03:00
699 + data: 1
700 + step:
701 + type: ZBX_PREPROC_DELTA_SPEED
702 +out:
703 + return: SUCCEED
704 + history:
705 + time: 2017-10-29 03:15:00 +03:00
706 + data: 2
707 +---
708 +test case: deltaspeed(1, 2, 10s)
709 +in:
710 + value:
711 + value_type: ITEM_VALUE_TYPE_FLOAT
712 + time: 2017-10-29 03:15:00 +03:00
713 + data: 2
714 + history:
715 + variant: ZBX_VARIANT_UI64
716 + time: 2017-10-29 03:14:50 +03:00
717 + data: 1
718 + step:
719 + type: ZBX_PREPROC_DELTA_SPEED
720 +out:
721 + return: SUCCEED
722 + value: 0.1
723 + history:
724 + time: 2017-10-29 03:15:00 +03:00
725 + data: 2
726 +---
727 +test case: deltaspeed(2, 3, 10s)
728 +in:
729 + value:
730 + value_type: ITEM_VALUE_TYPE_UINT64
731 + time: 2017-10-29 03:15:00 +03:00
732 + data: 3
733 + history:
734 + variant: ZBX_VARIANT_UI64
735 + time: 2017-10-29 03:14:50 +03:00
736 + data: 2
737 + step:
738 + type: ZBX_PREPROC_DELTA_SPEED
739 +out:
740 + return: SUCCEED
741 + value: 0
742 + history:
743 + time: 2017-10-29 03:15:00 +03:00
744 + data: 3
745 +---
746 +test case: deltaspeed(2, 3, 1s)
747 +in:
748 + value:
749 + value_type: ITEM_VALUE_TYPE_UINT64
750 + time: 2017-10-29 03:15:00 +03:00
751 + data: 3
752 + history:
753 + variant: ZBX_VARIANT_UI64
754 + time: 2017-10-29 03:14:59 +03:00
755 + data: 2
756 + step:
757 + type: ZBX_PREPROC_DELTA_SPEED
758 +out:
759 + return: SUCCEED
760 + value: 1
761 + history:
762 + time: 2017-10-29 03:15:00 +03:00
763 + data: 3
764 +---
765 +test case: xpath1
766 +in:
767 + value:
768 + value_type: ITEM_VALUE_TYPE_STR
769 + time: 2017-10-29 03:15:00 +03:00
770 + data: ""
771 + step:
772 + type: ZBX_PREPROC_XPATH
773 + params: ""
774 +out:
775 + return: FAIL
776 +---
777 +test case: xpath2
778 +in:
779 + value:
780 + value_type: ITEM_VALUE_TYPE_STR
781 + time: 2017-10-29 03:15:00 +03:00
782 + data: <a/>
783 + step:
784 + type: ZBX_PREPROC_XPATH
785 + params: ""
786 +out:
787 + return: FAIL
788 +---
789 +test case: xpath3
790 +in:
791 + value:
792 + value_type: ITEM_VALUE_TYPE_STR
793 + time: 2017-10-29 03:15:00 +03:00
794 + data: <a/>
795 + step:
796 + type: ZBX_PREPROC_XPATH
797 + params: |-
798 + /a["]
799 +out:
800 + return: FAIL
801 +---
802 +test case: xpath4
803 +in:
804 + value:
805 + value_type: ITEM_VALUE_TYPE_STR
806 + time: 2017-10-29 03:15:00 +03:00
807 + data: <a/>
808 + step:
809 + type: ZBX_PREPROC_XPATH
810 + params: 1 div 0
811 +out:
812 + return: FAIL
813 +---
814 +test case: xpath5
815 +in:
816 + value:
817 + value_type: ITEM_VALUE_TYPE_STR
818 + time: 2017-10-29 03:15:00 +03:00
819 + data: <a/>
820 + step:
821 + type: ZBX_PREPROC_XPATH
822 + params: -a
823 +out:
824 + return: FAIL
825 +---
826 +test case: xpath6
827 +in:
828 + value:
829 + value_type: ITEM_VALUE_TYPE_STR
830 + time: 2017-10-29 03:15:00 +03:00
831 + data: <a/>
832 + step:
833 + type: ZBX_PREPROC_XPATH
834 + params: /b
835 +out:
836 + return: SUCCEED
837 + value: ""
838 +---
839 +test case: xpath7
840 +in:
841 + value:
842 + value_type: ITEM_VALUE_TYPE_STR
843 + time: 2017-10-29 03:15:00 +03:00
844 + data: <a/>
845 + step:
846 + type: ZBX_PREPROC_XPATH
847 + params: 3 div 2
848 +out:
849 + return: SUCCEED
850 + value: 1.5
851 +---
852 +test case: xpath8
853 +in:
854 + value:
855 + value_type: ITEM_VALUE_TYPE_STR
856 + time: 2017-10-29 03:15:00 +03:00
857 + data: <a/>
858 + step:
859 + type: ZBX_PREPROC_XPATH
860 + params: /a
861 +out:
862 + return: SUCCEED
863 + value: <a/>
864 +---
865 +test case: xpath9
866 +in:
867 + value:
868 + value_type: ITEM_VALUE_TYPE_STR
869 + time: 2017-10-29 03:15:00 +03:00
870 + data: <a>1</a>
871 + step:
872 + type: ZBX_PREPROC_XPATH
873 + params: /a/text()
874 +out:
875 + return: SUCCEED
876 + value: 1
877 +---
878 +test case: xpath10
879 +in:
880 + value:
881 + value_type: ITEM_VALUE_TYPE_STR
882 + time: 2017-10-29 03:15:00 +03:00
883 + data: <a>1</a>
884 + step:
885 + type: ZBX_PREPROC_XPATH
886 + params: string(/a)
887 +out:
888 + return: SUCCEED
889 + value: 1
890 +---
891 +test case: xpath11
892 +in:
893 + value:
894 + value_type: ITEM_VALUE_TYPE_STR
895 + time: 2017-10-29 03:15:00 +03:00
896 + data: <a b="10">1</a>
897 + step:
898 + type: ZBX_PREPROC_XPATH
899 + params: string(/a/@b)
900 +out:
901 + return: SUCCEED
902 + value: 10
903 +---
904 +test case: xpath12
905 +in:
906 + value:
907 + value_type: ITEM_VALUE_TYPE_STR
908 + time: 2017-10-29 03:15:00 +03:00
909 + data: <a><b x="1"/><c x="2"/><d x="1"/></a>
910 + step:
911 + type: ZBX_PREPROC_XPATH
912 + params: //*[@x="1"]
913 +out:
914 + return: SUCCEED
915 + value: <b x="1"/><d x="1"/>
916 +---
917 +test case: jsonpath1
918 +in:
919 + value:
920 + value_type: ITEM_VALUE_TYPE_STR
921 + time: 2017-10-29 03:15:00 +03:00
922 + data: abc
923 + step:
924 + type: ZBX_PREPROC_JSONPATH
925 + params:
926 +out:
927 + return: FAIL
928 +---
929 +test case: jsonpath2
930 +in:
931 + value:
932 + value_type: ITEM_VALUE_TYPE_STR
933 + time: 2017-10-29 03:15:00 +03:00
934 + data: |-
935 + {"a":{"b":[1, 2, 3]}}
936 + step:
937 + type: ZBX_PREPROC_JSONPATH
938 + params: $abc
939 +out:
940 + return: FAIL
941 +---
942 +test case: jsonpath3
943 +in:
944 + value:
945 + value_type: ITEM_VALUE_TYPE_STR
946 + time: 2017-10-29 03:15:00 +03:00
947 + data: |-
948 + {"a":{"b":[1, 2, 3]}}
949 + step:
950 + type: ZBX_PREPROC_JSONPATH
951 + params: $.abc
952 +out:
953 + return: FAIL
954 +---
955 +test case: jsonpath4
956 +in:
957 + value:
958 + value_type: ITEM_VALUE_TYPE_STR
959 + time: 2017-10-29 03:15:00 +03:00
960 + data: |-
961 + {"a":{"b":[1, 2, 3]}}
962 + step:
963 + type: ZBX_PREPROC_JSONPATH
964 + params: $.a
965 +out:
966 + return: SUCCEED
967 + value: |-
968 + {"b":[1,2,3]}
969 +---
970 +test case: jsonpath5
971 +in:
972 + value:
973 + value_type: ITEM_VALUE_TYPE_STR
974 + time: 2017-10-29 03:15:00 +03:00
975 + data: |-
976 + {"a":{"b":[1, 2, 3]}}
977 + step:
978 + type: ZBX_PREPROC_JSONPATH
979 + params: $.a['b']
980 +out:
981 + return: SUCCEED
982 + value: |-
983 + [1,2,3]
984 +---
985 +test case: jsonpath6
986 +in:
987 + value:
988 + value_type: ITEM_VALUE_TYPE_STR
989 + time: 2017-10-29 03:15:00 +03:00
990 + data: |-
991 + {"a":{"b":[1, 2, 3]}}
992 + step:
993 + type: ZBX_PREPROC_JSONPATH
994 + params: $.a['b'][1]
995 +out:
996 + return: SUCCEED
997 + value: 2
998 +---
999 +test case: jsonpath7
1000 +in:
1001 + value:
1002 + value_type: ITEM_VALUE_TYPE_STR
1003 + time: 2017-10-29 03:15:00 +03:00
1004 + data: |-
1005 + {"a":{"b c":["one", "two", "three"]}}
1006 + step:
1007 + type: ZBX_PREPROC_JSONPATH
1008 + params: $.a['b c']
1009 +out:
1010 + return: SUCCEED
1011 + value: '["one","two","three"]'
1012 +---
1013 +test case: jsonpath8
1014 +in:
1015 + value:
1016 + value_type: ITEM_VALUE_TYPE_STR
1017 + time: 2017-10-29 03:15:00 +03:00
1018 + data: |-
1019 + {"a":{"b c":["one", "two \"2\"", 3]}}
1020 + step:
1021 + type: ZBX_PREPROC_JSONPATH
1022 + params: $.a['b c'][1]
1023 +out:
1024 + return: SUCCEED
1025 + value: two "2"
1026 +---
1027 +test case: jsonpath9
1028 +in:
1029 + value:
1030 + value_type: ITEM_VALUE_TYPE_STR
1031 + time: 2017-10-29 03:15:00 +03:00
1032 + data: |-
1033 + {"a":{"b c":["one", "two \"2\"", 3]}}
1034 + step:
1035 + type: ZBX_PREPROC_JSONPATH
1036 + params: $.a['b c'][2]
1037 +out:
1038 + return: SUCCEED
1039 + value: 3
1040 +---
1041 +test case: jsonpath10
1042 +in:
1043 + value:
1044 + value_type: ITEM_VALUE_TYPE_STR
1045 + time: 2017-10-29 03:15:00 +03:00
1046 + data: |-
1047 + {"a":{"b":[1, 2, 3]}}
1048 + step:
1049 + type: ZBX_PREPROC_JSONPATH
1050 + params: $.a['b'][3]
1051 +out:
1052 + return: FAIL
1053 +---
1054 +test case: jsonpath11
1055 +in:
1056 + value:
1057 + value_type: ITEM_VALUE_TYPE_STR
1058 + time: 2017-10-29 03:15:00 +03:00
1059 + data: |-
1060 + {"a":{"b":[1, 2, 3]}}
1061 + step:
1062 + type: ZBX_PREPROC_JSONPATH
1063 + params: $.a['b'][
1064 +out:
1065 + return: FAIL
1066 +---
1067 +test case: jsonpath12
1068 +in:
1069 + value:
1070 + value_type: ITEM_VALUE_TYPE_STR
1071 + time: 2017-10-29 03:15:00 +03:00
1072 + data: |-
1073 + {"a":{"b":[1, 2, 3]}}
1074 + step:
1075 + type: ZBX_PREPROC_JSONPATH
1076 + params: $.a['b][3]
1077 +out:
1078 + return: FAIL
1079 +---
1080 +test case: jsonpath13
1081 +in:
1082 + value:
1083 + value_type: ITEM_VALUE_TYPE_STR
1084 + time: 2017-10-29 03:15:00 +03:00
1085 + data: |-
1086 + {"a":{"\ud800": 1}}
1087 + step:
1088 + type: ZBX_PREPROC_JSONPATH
1089 + params: $.a
1090 +out:
1091 + return: FAIL
1092 +---
1093 +test case: validate_range(1, 5, 10)
1094 +in:
1095 + value:
1096 + value_type: ITEM_VALUE_TYPE_STR
1097 + time: 2017-10-29 03:15:00 +03:00
1098 + data: 1
1099 + step:
1100 + type: ZBX_PREPROC_VALIDATE_RANGE
1101 + params: |-
1102 + 5
1103 + 10
1104 +out:
1105 + return: FAIL
1106 +---
1107 +test case: validate_range(5, 5, 10)
1108 +in:
1109 + value:
1110 + value_type: ITEM_VALUE_TYPE_STR
1111 + time: 2017-10-29 03:15:00 +03:00
1112 + data: 5
1113 + step:
1114 + type: ZBX_PREPROC_VALIDATE_RANGE
1115 + params: |-
1116 + 5
1117 + 10
1118 +out:
1119 + return: SUCCEED
1120 + value: 5
1121 +---
1122 +test case: validate_range(10, 5, 10)
1123 +in:
1124 + value:
1125 + value_type: ITEM_VALUE_TYPE_STR
1126 + time: 2017-10-29 03:15:00 +03:00
1127 + data: 10
1128 + step:
1129 + type: ZBX_PREPROC_VALIDATE_RANGE
1130 + params: |-
1131 + 5
1132 + 10
1133 +out:
1134 + return: SUCCEED
1135 + value: 10
1136 +---
1137 +test case: validate_range(10.1, 5, 10)
1138 +in:
1139 + value:
1140 + value_type: ITEM_VALUE_TYPE_STR
1141 + time: 2017-10-29 03:15:00 +03:00
1142 + data: 10.1
1143 + step:
1144 + type: ZBX_PREPROC_VALIDATE_RANGE
1145 + params: |-
1146 + 5
1147 + 10
1148 +out:
1149 + return: FAIL
1150 +---
1151 +test case: validate_regex(abc 123 xyz, ([0-9+))
1152 +in:
1153 + value:
1154 + value_type: ITEM_VALUE_TYPE_STR
1155 + time: 2017-10-29 03:15:00 +03:00
1156 + data: abc 123 xyz
1157 + step:
1158 + type: ZBX_PREPROC_VALIDATE_REGEX
1159 + params: ([0-9+)
1160 +out:
1161 + return: FAIL
1162 +---
1163 +test case: validate_regex(abc opq xyz, ([0-9]+))
1164 +in:
1165 + value:
1166 + value_type: ITEM_VALUE_TYPE_STR
1167 + time: 2017-10-29 03:15:00 +03:00
1168 + data: abc opq xyz
1169 + step:
1170 + type: ZBX_PREPROC_VALIDATE_REGEX
1171 + params: ([0-9]+)
1172 +out:
1173 + return: FAIL
1174 +---
1175 +test case: validate_regex(abc 123 xyz, ([0-9]+))
1176 +in:
1177 + value:
1178 + value_type: ITEM_VALUE_TYPE_STR
1179 + time: 2017-10-29 03:15:00 +03:00
1180 + data: abc 123 xyz
1181 + step:
1182 + type: ZBX_PREPROC_VALIDATE_REGEX
1183 + params: ([0-9]+)
1184 +out:
1185 + return: SUCCEED
1186 + value: abc 123 xyz
1187 +---
1188 +test case: validate_regex(abc 123 xyz, ([0-9+))
1189 +in:
1190 + value:
1191 + value_type: ITEM_VALUE_TYPE_STR
1192 + time: 2017-10-29 03:15:00 +03:00
1193 + data: abc 123 xyz
1194 + step:
1195 + type: ZBX_PREPROC_VALIDATE_NOT_REGEX
1196 + params: ([0-9+)
1197 +out:
1198 + return: FAIL
1199 +---
1200 +test case: validate_regex(abc opq xyz, ([0-9]+))
1201 +in:
1202 + value:
1203 + value_type: ITEM_VALUE_TYPE_STR
1204 + time: 2017-10-29 03:15:00 +03:00
1205 + data: abc opq xyz
1206 + step:
1207 + type: ZBX_PREPROC_VALIDATE_NOT_REGEX
1208 + params: ([0-9]+)
1209 +out:
1210 + return: SUCCEED
1211 + value: abc opq xyz
1212 +---
1213 +test case: validate_regex(abc 123 xyz, ([0-9]+))
1214 +in:
1215 + value:
1216 + value_type: ITEM_VALUE_TYPE_STR
1217 + time: 2017-10-29 03:15:00 +03:00
1218 + data: abc 123 xyz
1219 + step:
1220 + type: ZBX_PREPROC_VALIDATE_NOT_REGEX
1221 + params: ([0-9]+)
1222 +out:
1223 + return: FAIL
1224 +---
1225 +test case: string(10) * 10 (discard)
1226 +in:
1227 + value:
1228 + value_type: ITEM_VALUE_TYPE_STR
1229 + time: 2017-10-29 03:15:00 +03:00
1230 + data: x
1231 + step:
1232 + type: ZBX_PREPROC_MULTIPLIER
1233 + params: 10
1234 + error_handler: ZBX_PREPROC_FAIL_DISCARD_VALUE
1235 +out:
1236 + return: SUCCEED
1237 +---
1238 +test case: string(10) * 10 (set value)
1239 +in:
1240 + value:
1241 + value_type: ITEM_VALUE_TYPE_STR
1242 + time: 2017-10-29 03:15:00 +03:00
1243 + data: x
1244 + step:
1245 + type: ZBX_PREPROC_MULTIPLIER
1246 + params: 10
1247 + error_handler: ZBX_PREPROC_FAIL_SET_VALUE
1248 + error_handler_params: invalid
1249 +out:
1250 + return: SUCCEED
1251 + value: invalid
1252 +---
1253 +test case: string(10) * 10 (set error)
1254 +in:
1255 + value:
1256 + value_type: ITEM_VALUE_TYPE_STR
1257 + time: 2017-10-29 03:15:00 +03:00
1258 + data: x
1259 + step:
1260 + type: ZBX_PREPROC_MULTIPLIER
1261 + params: 10
1262 + error_handler: ZBX_PREPROC_FAIL_SET_ERROR
1263 + error_handler_params: custom error
1264 +out:
1265 + return: FAIL
1266 + error: custom error
1267 +---
1268 +test case: jsonerror(x, $.error)
1269 +in:
1270 + value:
1271 + value_type: ITEM_VALUE_TYPE_STR
1272 + time: 2017-10-29 03:15:00 +03:00
1273 + data: x
1274 + step:
1275 + type: ZBX_PREPROC_ERROR_FIELD_JSON
1276 + params: $.error
1277 +out:
1278 + return: SUCCEED
1279 + value: x
1280 +---
1281 +test case: jsonerror({"error":"error message"}, $error)
1282 +in:
1283 + value:
1284 + value_type: ITEM_VALUE_TYPE_STR
1285 + time: 2017-10-29 03:15:00 +03:00
1286 + data: |-
1287 + {"error":"error message"}
1288 + step:
1289 + type: ZBX_PREPROC_ERROR_FIELD_JSON
1290 + params: $error
1291 +out:
1292 + return: FAIL
1293 +---
1294 +test case: jsonerror({"data":"123"}, $.error)
1295 +in:
1296 + value:
1297 + value_type: ITEM_VALUE_TYPE_STR
1298 + time: 2017-10-29 03:15:00 +03:00
1299 + data: |-
1300 + {"data":"123"}
1301 + step:
1302 + type: ZBX_PREPROC_ERROR_FIELD_JSON
1303 + params: $.error
1304 +out:
1305 + return: SUCCEED
1306 + value: |-
1307 + {"data":"123"}
1308 +---
1309 +test case: jsonerror({"error":"error message"}, $.error)
1310 +in:
1311 + value:
1312 + value_type: ITEM_VALUE_TYPE_STR
1313 + time: 2017-10-29 03:15:00 +03:00
1314 + data: |-
1315 + {"error":"error message"}
1316 + step:
1317 + type: ZBX_PREPROC_ERROR_FIELD_JSON
1318 + params: $.error
1319 +out:
1320 + return: FAIL
1321 + error: error message
1322 +---
1323 +test case: xmlerror(x, //error)
1324 +in:
1325 + value:
1326 + value_type: ITEM_VALUE_TYPE_STR
1327 + time: 2017-10-29 03:15:00 +03:00
1328 + data: x
1329 + step:
1330 + type: ZBX_PREPROC_ERROR_FIELD_XML
1331 + params: //error
1332 +out:
1333 + return: SUCCEED
1334 + value: x
1335 +---
1336 +test case: xmlerror(<a><error>custom error</error></a>, "//text(")
1337 +in:
1338 + value:
1339 + value_type: ITEM_VALUE_TYPE_STR
1340 + time: 2017-10-29 03:15:00 +03:00
1341 + data: <a><error>custom error</error></a>
1342 + step:
1343 + type: ZBX_PREPROC_ERROR_FIELD_XML
1344 + params: //text(
1345 +out:
1346 + return: FAIL
1347 +---
1348 +test case: xmlerror(<a><error>custom error</error></a>, "//error/text()")
1349 +in:
1350 + value:
1351 + value_type: ITEM_VALUE_TYPE_STR
1352 + time: 2017-10-29 03:15:00 +03:00
1353 + data: <a><error>custom error</error></a>
1354 + step:
1355 + type: ZBX_PREPROC_ERROR_FIELD_XML
1356 + params: //error/text()
1357 +out:
1358 + return: FAIL
1359 + error: custom error
1360 +---
1361 +test case: xmlerror(<a><value>1</value></a>, "//error/text()")
1362 +in:
1363 + value:
1364 + value_type: ITEM_VALUE_TYPE_STR
1365 + time: 2017-10-29 03:15:00 +03:00
1366 + data: <a><value>1</value></a>
1367 + step:
1368 + type: ZBX_PREPROC_ERROR_FIELD_XML
1369 + params: //error/text()
1370 +out:
1371 + return: SUCCEED
1372 + value: <a><value>1</value></a>
1373 +---
1374 +test case: regexerror("error:123, "error:([0-9+)")
1375 +in:
1376 + value:
1377 + value_type: ITEM_VALUE_TYPE_STR
1378 + time: 2017-10-29 03:15:00 +03:00
1379 + data: error:123
1380 + step:
1381 + type: ZBX_PREPROC_ERROR_FIELD_REGEX
1382 + params: ([0-9+)
1383 +out:
1384 + return: FAIL
1385 +---
1386 +test case: regexerror("error:123, "error:([0-9]+)")
1387 +in:
1388 + value:
1389 + value_type: ITEM_VALUE_TYPE_STR
1390 + time: 2017-10-29 03:15:00 +03:00
1391 + data: error:123
1392 + step:
1393 + type: ZBX_PREPROC_ERROR_FIELD_REGEX
1394 + params: |-
1395 + error:([0-9]+)
1396 + \1
1397 +out:
1398 + return: FAIL
1399 + error: 123
1400 +---
1401 +test case: regexerror("value:123, "error:([0-9]+)")
1402 +in:
1403 + value:
1404 + value_type: ITEM_VALUE_TYPE_STR
1405 + time: 2017-10-29 03:15:00 +03:00
1406 + data: value:123
1407 + step:
1408 + type: ZBX_PREPROC_ERROR_FIELD_REGEX
1409 + params: |-
1410 + error:([0-9]+)
1411 + \1
1412 +out:
1413 + return: SUCCEED
1414 + value: value:123
1415 +---
1416 +test case: throttle(1, 123)
1417 +in:
1418 + value:
1419 + value_type: ITEM_VALUE_TYPE_STR
1420 + time: 2017-10-29 03:15:00 +03:00
1421 + data: 123
1422 + history:
1423 + variant: ZBX_VARIANT_STR
1424 + time: 2017-10-29 03:14:00 +03:00
1425 + data: 1
1426 + step:
1427 + type: ZBX_PREPROC_THROTTLE_VALUE
1428 +out:
1429 + return: SUCCEED
1430 + value: 123
1431 + history:
1432 + time: 2017-10-29 03:15:00 +03:00
1433 + data: 123
1434 +---
1435 +test case: throttle(, 123)
1436 +in:
1437 + value:
1438 + value_type: ITEM_VALUE_TYPE_STR
1439 + time: 2017-10-29 03:15:00 +03:00
1440 + data: 123
1441 + step:
1442 + type: ZBX_PREPROC_THROTTLE_VALUE
1443 +out:
1444 + return: SUCCEED
1445 + value: 123
1446 + history:
1447 + time: 2017-10-29 03:15:00 +03:00
1448 + data: 123
1449 +---
1450 +test case: throttle(123, 123)
1451 +in:
1452 + value:
1453 + value_type: ITEM_VALUE_TYPE_STR
1454 + time: 2017-10-29 03:15:00 +03:00
1455 + data: 123
1456 + history:
1457 + variant: ZBX_VARIANT_STR
1458 + time: 2017-10-29 03:14:00 +03:00
1459 + data: 123
1460 + step:
1461 + type: ZBX_PREPROC_THROTTLE_VALUE
1462 +out:
1463 + return: SUCCEED
1464 + history:
1465 + time: 2017-10-29 03:14:00 +03:00
1466 + data: 123
1467 +---
1468 +test case: throttle_timed(, abc)
1469 +in:
1470 + value:
1471 + value_type: ITEM_VALUE_TYPE_STR
1472 + time: 2017-10-29 03:15:00 +03:00
1473 + data: abc
1474 + step:
1475 + type: ZBX_PREPROC_THROTTLE_TIMED_VALUE
1476 + params: 1m
1477 +out:
1478 + return: SUCCEED
1479 + value: abc
1480 + history:
1481 + time: 2017-10-29 03:15:00 +03:00
1482 + data: abc
1483 +---
1484 +test case: throttle_timed(xyz, abc)
1485 +in:
1486 + value:
1487 + value_type: ITEM_VALUE_TYPE_STR
1488 + time: 2017-10-29 03:15:00 +03:00
1489 + data: abc
1490 + history:
1491 + variant: ZBX_VARIANT_STR
1492 + time: 2017-10-29 03:14:30 +03:00
1493 + data: xyz
1494 + step:
1495 + type: ZBX_PREPROC_THROTTLE_TIMED_VALUE
1496 + params: 1m
1497 +out:
1498 + return: SUCCEED
1499 + value: abc
1500 + history:
1501 + time: 2017-10-29 03:15:00 +03:00
1502 + data: abc
1503 +---
1504 +test case: throttle_timed(abc, abc, 30s)
1505 +in:
1506 + value:
1507 + value_type: ITEM_VALUE_TYPE_STR
1508 + time: 2017-10-29 03:15:00 +03:00
1509 + data: abc
1510 + history:
1511 + variant: ZBX_VARIANT_STR
1512 + time: 2017-10-29 03:14:30 +03:00
1513 + data: abc
1514 + step:
1515 + type: ZBX_PREPROC_THROTTLE_TIMED_VALUE
1516 + params: 1m
1517 +out:
1518 + return: SUCCEED
1519 + history:
1520 + time: 2017-10-29 03:14:30 +03:00
1521 + data: abc
1522 +---
1523 +test case: throttle_timed(abc, abc, 1m)
1524 +in:
1525 + value:
1526 + value_type: ITEM_VALUE_TYPE_STR
1527 + time: 2017-10-29 03:15:00 +03:00
1528 + data: abc
1529 + history:
1530 + variant: ZBX_VARIANT_STR
1531 + time: 2017-10-29 03:14:00 +03:00
1532 + data: abc
1533 + step:
1534 + type: ZBX_PREPROC_THROTTLE_TIMED_VALUE
1535 + params: 1m
1536 +out:
1537 + return: SUCCEED
1538 + value: abc
1539 + history:
1540 + time: 2017-10-29 03:15:00 +03:00
1541 + data: abc
1542 +---
1543 +test case: float(1.5e0) * 1
1544 +in:
1545 + value:
1546 + value_type: ITEM_VALUE_TYPE_FLOAT
1547 + time: 2017-10-29 03:15:00 +03:00
1548 + data: 1.5e0
1549 + step:
1550 + type: ZBX_PREPROC_MULTIPLIER
1551 + params: 1
1552 +out:
1553 + return: SUCCEED
1554 + value: 1.5
1555 +---
1556 +test case: float(1) * 1.5e0
1557 +in:
1558 + value:
1559 + value_type: ITEM_VALUE_TYPE_FLOAT
1560 + time: 2017-10-29 03:15:00 +03:00
1561 + data: 1
1562 + step:
1563 + type: ZBX_PREPROC_MULTIPLIER
1564 + params: 1.5e0
1565 +out:
1566 + return: SUCCEED
1567 + value: 1.5
1568 +---
1569 +test case: float(1.5e0) * 1.5e0
1570 +in:
1571 + value:
1572 + value_type: ITEM_VALUE_TYPE_FLOAT
1573 + time: 2017-10-29 03:15:00 +03:00
1574 + data: 1.5e0
1575 + step:
1576 + type: ZBX_PREPROC_MULTIPLIER
1577 + params: 1.5e0
1578 +out:
1579 + return: SUCCEED
1580 + value: 2.25
1581 +---
1582 +test case: float(1.5e1) * 1
1583 +in:
1584 + value:
1585 + value_type: ITEM_VALUE_TYPE_FLOAT
1586 + time: 2017-10-29 03:15:00 +03:00
1587 + data: 1.5e1
1588 + step:
1589 + type: ZBX_PREPROC_MULTIPLIER
1590 + params: 1
1591 +out:
1592 + return: SUCCEED
1593 + value: 15
1594 +---
1595 +test case: float(1) * 1.5e1
1596 +in:
1597 + value:
1598 + value_type: ITEM_VALUE_TYPE_FLOAT
1599 + time: 2017-10-29 03:15:00 +03:00
1600 + data: 1
1601 + step:
1602 + type: ZBX_PREPROC_MULTIPLIER
1603 + params: 1.5e1
1604 +out:
1605 + return: SUCCEED
1606 + value: 15
1607 +---
1608 +test case: float(1.5e1) * 1.5e1
1609 +in:
1610 + value:
1611 + value_type: ITEM_VALUE_TYPE_FLOAT
1612 + time: 2017-10-29 03:15:00 +03:00
1613 + data: 1.5e1
1614 + step:
1615 + type: ZBX_PREPROC_MULTIPLIER
1616 + params: 1.5e1
1617 +out:
1618 + return: SUCCEED
1619 + value: 225
1620 +---
1621 +test case: float(1.5e10) * 1
1622 +in:
1623 + value:
1624 + value_type: ITEM_VALUE_TYPE_FLOAT
1625 + time: 2017-10-29 03:15:00 +03:00
1626 + data: 1.5e10
1627 + step:
1628 + type: ZBX_PREPROC_MULTIPLIER
1629 + params: 1
1630 +out:
1631 + return: SUCCEED
1632 + value: 15000000000
1633 +---
1634 +test case: float(1) * 1.5e10
1635 +in:
1636 + value:
1637 + value_type: ITEM_VALUE_TYPE_FLOAT
1638 + time: 2017-10-29 03:15:00 +03:00
1639 + data: 1
1640 + step:
1641 + type: ZBX_PREPROC_MULTIPLIER
1642 + params: 1.5e10
1643 +out:
1644 + return: SUCCEED
1645 + value: 15000000000
1646 +---
1647 +test case: float(1.5e10) * 1.5e10
1648 +in:
1649 + value:
1650 + value_type: ITEM_VALUE_TYPE_FLOAT
1651 + time: 2017-10-29 03:15:00 +03:00
1652 + data: 1.5e10
1653 + step:
1654 + type: ZBX_PREPROC_MULTIPLIER
1655 + params: 1.5e10
1656 +out:
1657 + return: SUCCEED
1658 + value: 225000000000000000000
1659 +---
1660 +test case: prometheus_getmetric1
1661 +in:
1662 + value:
1663 + value_type: ITEM_VALUE_TYPE_STR
1664 + time: 2017-10-29 03:15:00 +03:00
1665 + data: |
1666 + # HELP cpu_usage_system Telegraf collected metric
1667 + # TYPE cpu_usage_system gauge
1668 + cpu_usage_system{cpu="cpu-total",host="host1"} 1.1940298507220641
1669 + cpu_usage_system{cpu="cpu0",host="host1"} 1.1940298507220641
1670 + cpu_usage_system{cpu="cpu1",host="host1"} 1.1340298507220641
1671 + step:
1672 + type: ZBX_PREPROC_PROMETHEUS_PATTERN
1673 + params: "cpu_usage_system{cpu=\"cpu-total\",host=~\".*\"}\nvalue\n"
1674 +out:
1675 + return: SUCCEED
1676 + value: 1.1940298507220641
1677 +---
1678 +test case: prometheus_getmetric2
1679 +in:
1680 + value:
1681 + value_type: ITEM_VALUE_TYPE_STR
1682 + time: 2017-10-29 03:15:00 +03:00
1683 + data: metric_without_timestamp_and_labels 12.47
1684 + step:
1685 + type: ZBX_PREPROC_PROMETHEUS_PATTERN
1686 + params: "metric_without_timestamp_and_labels\nvalue\n"
1687 +out:
1688 + return: SUCCEED
1689 + value: 12.47
1690 +---
1691 +test case: prometheus_getmetric3
1692 +in:
1693 + value:
1694 + value_type: ITEM_VALUE_TYPE_STR
1695 + time: 2017-10-29 03:15:00 +03:00
1696 + data: |
1697 + # HELP cpu_usage_system Telegraf collected metric
1698 + # TYPE cpu_usage_system gauge
1699 + cpu_usage_system{cpu="cpu-total",host="host1"} 1.1940298507220641
1700 + cpu_usage_system{cpu="cpu0",host="host1"} 1.1940298507220641
1701 + cpu_usage_system{cpu="cpu1",host="host1"} 1.1340298507220641
1702 + step:
1703 + type: ZBX_PREPROC_PROMETHEUS_PATTERN
1704 + params: "{cpu=\"cpu0\",__name__=\"cpu_usage_system\"}\nvalue\n"
1705 +out:
1706 + return: SUCCEED
1707 + value: 1.1940298507220641
1708 +---
1709 +test case: prometheus_getmetric4
1710 +in:
1711 + value:
1712 + value_type: ITEM_VALUE_TYPE_STR
1713 + time: 2017-10-29 03:15:00 +03:00
1714 + data: |
1715 + # HELP http_requests_total The total number of HTTP requests.
1716 + # TYPE http_requests_total counter
1717 + http_requests_total{method="post",code="200"} 1027 1395066363000
1718 + http_requests_total{method="post",code="400"} 3 1395066363000
1719 + step:
1720 + type: ZBX_PREPROC_PROMETHEUS_PATTERN
1721 + params: "http_requests_total{code=\"200\"}\nvalue\n"
1722 +out:
1723 + return: SUCCEED
1724 + value: 1027
1725 +---
1726 +test case: prometheus_getmetric5
1727 +in:
1728 + value:
1729 + value_type: ITEM_VALUE_TYPE_STR
1730 + time: 2017-10-29 03:15:00 +03:00
1731 + data: |
1732 + # HELP cpu_usage_system Telegraf collected metric
1733 + # TYPE cpu_usage_system gauge
1734 + cpu_usage_system{cpu="cpu1",host="host1"} 1.1340298507220641
1735 + step:
1736 + type: ZBX_PREPROC_PROMETHEUS_PATTERN
1737 + params: "\nvalue\n"
1738 +out:
1739 + return: SUCCEED
1740 + value: 1.1340298507220641
1741 +---
1742 +test case: prometheus_getmetric6
1743 +in:
1744 + value:
1745 + value_type: ITEM_VALUE_TYPE_STR
1746 + time: 2017-10-29 03:15:00 +03:00
1747 + data: |
1748 + # HELP cpu_usage_system Telegraf collected metric
1749 + # TYPE cpu_usage_system gauge
1750 + cpu_usage_system{cpu="cpu1",host="host1"} 1.1340298507220641
1751 + step:
1752 + type: ZBX_PREPROC_PROMETHEUS_PATTERN
1753 + params: "cpu_usage_system\nvalue\n"
1754 +out:
1755 + return: SUCCEED
1756 + value: 1.1340298507220641
1757 +---
1758 +test case: prometheus_getmetric7
1759 +in:
1760 + value:
1761 + value_type: ITEM_VALUE_TYPE_STR
1762 + time: 2017-10-29 03:15:00 +03:00
1763 + data: |
1764 + # HELP cpu_usage_system Telegraf collected metric
1765 + # TYPE cpu_usage_system gauge
1766 + cpu_usage_system{cpu="cpu-total",host="host1"} 1.1940298507220641
1767 + cpu_usage_system{cpu="cpu0",host="host2"} 1.1940298507220641
1768 + cpu_usage_system{cpu="cpu1",host="host3"} 1.1340298507220641
1769 + step:
1770 + type: ZBX_PREPROC_PROMETHEUS_PATTERN
1771 + params: "cpu_usage_system{cpu=\"cpu0\"} == 1.1940298507220641\nlabel\nhost"
1772 +out:
1773 + return: SUCCEED
1774 + value: host2
1775 +---
1776 +test case: prometheus_getmetric8
1777 +in:
1778 + value:
1779 + value_type: ITEM_VALUE_TYPE_STR
1780 + time: 2017-10-29 03:15:00 +03:00
1781 + data: >
1782 + AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz:_0123456789{
1783 + AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz_0123456789="anything"} 123123123
1784 + step:
1785 + type: ZBX_PREPROC_PROMETHEUS_PATTERN
1786 + params: |-
1787 + AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz:_0123456789 == 123123123
1788 + label
1789 + AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz_0123456789
1790 +out:
1791 + return: SUCCEED
1792 + value: anything
1793 +---
1794 +test case: prometheus_getmetric9
1795 +in:
1796 + value:
1797 + value_type: ITEM_VALUE_TYPE_STR
1798 + time: 2017-10-29 03:15:00 +03:00
1799 + data: |
1800 + # HELP cpu_usage_system Telegraf collected metric
1801 + # TYPE cpu_usage_system gauge
1802 + cpu_usage_system{cpu="cpu-total",host="host1"} 1.1940298507220641
1803 + cpu_usage_system{cpu="cpu0",host="host2"} 1.1940298507220641
1804 + cpu_usage_system{cpu="cpu1",host="host3"} 1.1340298507220641
1805 + step:
1806 + type: ZBX_PREPROC_PROMETHEUS_PATTERN
1807 + params: |-
1808 + cpu_usage_system{cpu="cpu0"} == 1.1940298507220641
1809 + label
1810 + host
1811 +out:
1812 + return: SUCCEED
1813 + value: host2
1814 +---
1815 +test case: prometheus_getmetric10
1816 +in:
1817 + value:
1818 + value_type: ITEM_VALUE_TYPE_STR
1819 + time: 2017-10-29 03:15:00 +03:00
1820 + data: |
1821 + # HELP cpu_usage_system Telegraf collected metric
1822 + # TYPE cpu_usage_system gauge
1823 + cpu_usage_system{cpu="cpu-total",host="host1"} 1.1940298507220641
1824 + cpu_usage_system{cpu="cpu0",host="host2"} 1.1940298507220641
1825 + cpu_usage_system{cpu="cpu1",host="host,3"} 1.1340298507220641
1826 + step:
1827 + type: ZBX_PREPROC_PROMETHEUS_PATTERN
1828 + params: |-
1829 + cpu_usage_system{cpu="cpu1"}
1830 + label
1831 + host
1832 +out:
1833 + return: SUCCEED
1834 + value: host,3
1835 +---
1836 +test case: prometheus_getmetric11
1837 +in:
1838 + value:
1839 + value_type: ITEM_VALUE_TYPE_STR
1840 + time: 2017-10-29 03:15:00 +03:00
1841 + data: |
1842 + # HELP cpu_usage_system Telegraf collected metric
1843 + # TYPE cpu_usage_system gauge
1844 + cpu_usage_system{cpu="cpu-total"} 1.1940298507220641
1845 + cpu_usage_system{cpu="cpu0"} 1.1940298507220641
1846 + cpu_usage_system{cpu="cpu1"} 1.1340298507220641
1847 + step:
1848 + type: ZBX_PREPROC_PROMETHEUS_PATTERN
1849 + params: |-
1850 + cpu_usage_system{cpu="cpu-total"}
1851 + gpu
1852 + error_handler: ZBX_PREPROC_FAIL_SET_ERROR
1853 + error_handler_params: custom error
1854 +out:
1855 + return: FAIL
1856 + error: custom error
1857 +---
1858 +test case: prometheus_getmetric12
1859 +in:
1860 + value:
1861 + value_type: ITEM_VALUE_TYPE_STR
1862 + time: 2017-10-29 03:15:00 +03:00
1863 + data: |
1864 + # HELP cpu_usage_system Telegraf collected metric
1865 + # TYPE cpu_usage_system gauge
1866 + cpu_usage_system{cpu="cpu-total"} 1.1940298507220641
1867 + cpu_usage_system{cpu="cpu0"} 1.1940298507220641
1868 + cpu_usage_system{cpu="cpu1"} 1.1340298507220641
1869 + step:
1870 + type: ZBX_PREPROC_PROMETHEUS_PATTERN
1871 + params: |-
1872 + cpu_usage_system{cpu="cpu"}
1873 + \value
1874 + error_handler: ZBX_PREPROC_FAIL_SET_ERROR
1875 + error_handler_params: custom error
1876 +out:
1877 + return: FAIL
1878 + error: custom error
1879 +---
1880 +test case: prometheus_getmetric13
1881 +in:
1882 + value:
1883 + value_type: ITEM_VALUE_TYPE_STR
1884 + time: 2017-10-29 03:15:00 +03:00
1885 + data: |
1886 + wmi_service_state{name="dhcp",state="continue pending"} 0
1887 + wmi_service_state{name="dhcp",state="continue pending"} 1
1888 + wmi_service_state{name="dhcp",state="pause pending"} 1
1889 + wmi_service_state{name="dhcp",state="paused"} 1
1890 + wmi_service_state{name="dhcp",state="running"} 1
1891 + wmi_service_state{name="dhcp",state="start pending"} 1
1892 + wmi_service_state{name="dhcp",state="stop pending"} 1
1893 + wmi_service_state{name="dhcp",state="stopped"} 1
1894 + wmi_service_state{name="dhcp",state="unknown"} 1
1895 + wmi_service_state{name="dhcp",state="continue pending"} 1
1896 + wmi_service_state{name="dhcp",state="pause pending"} 1
1897 + wmi_service_state{name="dhcp",state="paused"} 1
1898 + wmi_service_state{name="dhcp",state="running"} 1
1899 + wmi_service_state{name="dhcp",state="start pending"} 1
1900 + wmi_service_state{name="dhcp",state="stop pending"} 1
1901 + wmi_service_state{name="dhcp",state="stopped"} 1
1902 + wmi_service_state{name="dhcp",state="unknown"} 1
1903 + wmi_service_state{name="dhcp",state="unknown"} 0
1904 + step:
1905 + type: ZBX_PREPROC_PROMETHEUS_PATTERN
1906 + params: |-
1907 + wmi_service_state == 1
1908 + \value
1909 + error_handler: ZBX_PREPROC_FAIL_SET_ERROR
1910 + error_handler_params: custom error
1911 +out:
1912 + return: FAIL
1913 + error: custom error
1914 +---
1915 +test case: prometheus_to_json1
1916 +in:
1917 + value:
1918 + value_type: ITEM_VALUE_TYPE_STR
1919 + time: 2017-10-29 03:15:00 +03:00
1920 + data: |
1921 + # HELP cpu_usage_system Telegraf collected metric
1922 + # TYPE cpu_usage_system gauge
1923 + cpu_usage_system{cpu="cpu-total"} 1.1940298507220641
1924 + cpu_usage_system{cpu="cpu0"} 1.1940298507220641
1925 + cpu_usage_system{cpu="cpu1"} 1.1340298507220641
1926 + step:
1927 + type: ZBX_PREPROC_PROMETHEUS_TO_JSON
1928 + params: cpu_usage_system
1929 +out:
1930 + return: SUCCEED
1931 + value: '[{"name":"cpu_usage_system","value":"1.1940298507220641","line_raw":"cpu_usage_system{cpu=\"cpu-total\"} 1.1940298507220641","labels":{"cpu":"cpu-total"},"type":"gauge","help":"Telegraf collected metric"},{"name":"cpu_usage_system","value":"1.1940298507220641","line_raw":"cpu_usage_system{cpu=\"cpu0\"} 1.1940298507220641","labels":{"cpu":"cpu0"},"type":"gauge","help":"Telegraf collected metric"},{"name":"cpu_usage_system","value":"1.1340298507220641","line_raw":"cpu_usage_system{cpu=\"cpu1\"} 1.1340298507220641","labels":{"cpu":"cpu1"},"type":"gauge","help":"Telegraf collected metric"}]'
1932 +---
1933 +test case: prometheus_to_json2
1934 +in:
1935 + value:
1936 + value_type: ITEM_VALUE_TYPE_STR
1937 + time: 2017-10-29 03:15:00 +03:00
1938 + data: |
1939 + # HELP wmi_os_timezone OperatingSystem.LocalDateTime
1940 + # TYPE wmi_os_timezone gauge
1941 + wmi_os_timezone{timezone="MSK"} 1
1942 + step:
1943 + type: ZBX_PREPROC_PROMETHEUS_TO_JSON
1944 + params: wmi_os_timezone
1945 +out:
1946 + return: SUCCEED
1947 + value: '[{"name":"wmi_os_timezone","value":"1","line_raw":"wmi_os_timezone{timezone=\"MSK\"} 1","labels":{"timezone":"MSK"},"type":"gauge","help":"OperatingSystem.LocalDateTime"}]'
1948 +---
1949 +test case: prometheus_to_json3
1950 +in:
1951 + value:
1952 + value_type: ITEM_VALUE_TYPE_STR
1953 + time: 2017-10-29 03:15:00 +03:00
1954 + data: |
1955 + random_date{year="2019",month="february",day="02/12/2019"} 1
1956 + random_date{year="2019",month="march",day="03/07/2019"} 2
1957 + step:
1958 + type: ZBX_PREPROC_PROMETHEUS_TO_JSON
1959 + params: random_date{year="2019",day=~"^([0-2][0-9]|(3)[0-1])(\\/)(((0)[0-9])|((1)[0-2]))(\\/)\\d{4}$"}
1960 +out:
1961 + return: SUCCEED
1962 + value: '[{"name":"random_date","value":"1","line_raw":"random_date{year=\"2019\",month=\"february\",day=\"02/12/2019\"} 1","labels":{"year":"2019","month":"february","day":"02/12/2019"},"type":"untyped"},{"name":"random_date","value":"2","line_raw":"random_date{year=\"2019\",month=\"march\",day=\"03/07/2019\"} 2","labels":{"year":"2019","month":"march","day":"03/07/2019"},"type":"untyped"}]'
1963 +---
1964 +test case: prometheus_to_json4
1965 +in:
1966 + value:
1967 + value_type: ITEM_VALUE_TYPE_STR
1968 + time: 2017-10-29 03:15:00 +03:00
1969 + data: metric_without_timestamp_and_labels 12.47
1970 + step:
1971 + type: ZBX_PREPROC_PROMETHEUS_TO_JSON
1972 + params: metric_without_timestamp_and_labels
1973 +out:
1974 + return: SUCCEED
1975 + value: '[{"name":"metric_without_timestamp_and_labels","value":"12.47","line_raw":"metric_without_timestamp_and_labels 12.47","type":"untyped"}]'
1976 +---
1977 +test case: prometheus_to_json5
1978 +in:
1979 + value:
1980 + value_type: ITEM_VALUE_TYPE_STR
1981 + time: 2017-10-29 03:15:00 +03:00
1982 + data: wmi_os_timezone{timezone} 1
1983 + step:
1984 + type: ZBX_PREPROC_PROMETHEUS_TO_JSON
1985 + params: wmi_os_timezone
1986 + error_handler: ZBX_PREPROC_FAIL_SET_ERROR
1987 + error_handler_params: custom error
1988 +out:
1989 + return: FAIL
1990 + error: custom error
1991 +---
1992 +test case: csv_to_json1
1993 +in:
1994 + value:
1995 + value_type: ITEM_VALUE_TYPE_STR
1996 + time: 2017-10-29 03:15:00 +03:00
1997 + data: |
1998 + sep=.
1999 + A.B.C
2000 + 1.2.3
2001 + step:
2002 + type: ZBX_PREPROC_CSV_TO_JSON
2003 + params: "\n\n1"
2004 +out:
2005 + return: SUCCEED
2006 + value: '[{"A":"1","B":"2","C":"3"},{"A":"","B":"","C":""}]'
2007 +---
2008 +test case: csv_to_json2
2009 +in:
2010 + value:
2011 + value_type: ITEM_VALUE_TYPE_STR
2012 + time: 2017-10-29 03:15:00 +03:00
2013 + data: |
2014 + sep=.
2015 + A.`B.``B`.C
2016 + 1.`2`.`3.`
2017 + step:
2018 + type: ZBX_PREPROC_CSV_TO_JSON
2019 + params: "\n`\n1"
2020 +out:
2021 + return: SUCCEED
2022 + value: '[{"A":"1","B.`B":"2","C":"3."},{"A":"","B.`B":"","C":""}]'
2023 +---
2024 +test case: csv_to_json3
2025 +in:
2026 + value:
2027 + value_type: ITEM_VALUE_TYPE_STR
2028 + time: 2017-10-29 03:15:00 +03:00
2029 + data: 1,2,3
2030 + step:
2031 + type: ZBX_PREPROC_CSV_TO_JSON
2032 + params: "\n\n0"
2033 +out:
2034 + return: SUCCEED
2035 + value: '[{"1":"1","2":"2","3":"3"}]'
2036 +---
2037 +test case: csv_to_json4
2038 +in:
2039 + value:
2040 + value_type: ITEM_VALUE_TYPE_STR
2041 + time: 2017-10-29 03:15:00 +03:00
2042 + data: |
2043 + A,B,C
2044 + 1,2,3,4
2045 + step:
2046 + type: ZBX_PREPROC_PROMETHEUS_TO_JSON
2047 + params: "\n\n1"
2048 + error_handler: ZBX_PREPROC_FAIL_SET_ERROR
2049 + error_handler_params: custom error
2050 +out:
2051 + return: FAIL
2052 + error: custom error
2053 +---
2054 +test case: replace a to b
2055 +in:
2056 + value:
2057 + value_type: ITEM_VALUE_TYPE_STR
2058 + time: 2017-10-29 03:15:00 +03:00
2059 + data: a
2060 + step:
2061 + type: ZBX_PREPROC_STR_REPLACE
2062 + params: "a\nb"
2063 +out:
2064 + return: SUCCEED
2065 + value: b
2066 +---
2067 +test case: replace a to bbb
2068 +in:
2069 + value:
2070 + value_type: ITEM_VALUE_TYPE_STR
2071 + time: 2017-10-29 03:15:00 +03:00
2072 + data: a1a2a3a
2073 + step:
2074 + type: ZBX_PREPROC_STR_REPLACE
2075 + params: "a\nbbb"
2076 +out:
2077 + return: SUCCEED
2078 + value: bbb1bbb2bbb3bbb
2079 +---
2080 +test case: replace a to a
2081 +in:
2082 + value:
2083 + value_type: ITEM_VALUE_TYPE_STR
2084 + time: 2017-10-29 03:15:00 +03:00
2085 + data: a
2086 + step:
2087 + type: ZBX_PREPROC_STR_REPLACE
2088 + params: "a\na"
2089 +out:
2090 + return: SUCCEED
2091 + value: a
2092 +---
2093 +test case: replace a to nothing
2094 +in:
2095 + value:
2096 + value_type: ITEM_VALUE_TYPE_STR
2097 + time: 2017-10-29 03:15:00 +03:00
2098 + data: a
2099 + step:
2100 + type: ZBX_PREPROC_STR_REPLACE
2101 + params: "a\n"
2102 +out:
2103 + return: SUCCEED
2104 + value: ""
2105 +---
2106 +test case: replace a to nothing in between
2107 +in:
2108 + value:
2109 + value_type: ITEM_VALUE_TYPE_STR
2110 + time: 2017-10-29 03:15:00 +03:00
2111 + data: abac
2112 + step:
2113 + type: ZBX_PREPROC_STR_REPLACE
2114 + params: "a\n"
2115 +out:
2116 + return: SUCCEED
2117 + value: "bc"
2118 +---
2119 +test case: replace non printable characters
2120 +in:
2121 + value:
2122 + value_type: ITEM_VALUE_TYPE_STR
2123 + time: 2017-10-29 03:15:00 +03:00
2124 + data: "\n\r "
2125 + step:
2126 + type: ZBX_PREPROC_STR_REPLACE
2127 + params: "\\n\\r\\t\\s\nOK"
2128 +out:
2129 + return: SUCCEED
2130 + value: "OK"
2131 +---
2132 +test case: replace non printable characters mixed
2133 +in:
2134 + value:
2135 + value_type: ITEM_VALUE_TYPE_STR
2136 + time: 2017-10-29 03:15:00 +03:00
2137 + data: "\n1\r2 3 4"
2138 + step:
2139 + type: ZBX_PREPROC_STR_REPLACE
2140 + params: "\\n1\\r2\\t3\\s4\nOK"
2141 +out:
2142 + return: SUCCEED
2143 + value: "OK"
2144 +---
2145 +test case: replace non printable characters 2
2146 +in:
2147 + value:
2148 + value_type: ITEM_VALUE_TYPE_STR
2149 + time: 2017-10-29 03:15:00 +03:00
2150 + data: "\n\r "
2151 + step:
2152 + type: ZBX_PREPROC_STR_REPLACE
2153 + params: "\\n\\r \nOK"
2154 +out:
2155 + return: SUCCEED
2156 + value: "OK"
2157 +---
2158 +test case: replace non printable characters in both search and replace
2159 +in:
2160 + value:
2161 + value_type: ITEM_VALUE_TYPE_STR
2162 + time: 2017-10-29 03:15:00 +03:00
2163 + data: "\n\r "
2164 + step:
2165 + type: ZBX_PREPROC_STR_REPLACE
2166 + params: "\\n\\r\\t\\s\n\\n\\r\\tOK\\s"
2167 +out:
2168 + return: SUCCEED
2169 + value: "\n\r\tOK "
2170 +---
2171 +test case: replace \
2172 +in:
2173 + value:
2174 + value_type: ITEM_VALUE_TYPE_STR
2175 + time: 2017-10-29 03:15:00 +03:00
2176 + data: "\\"
2177 + step:
2178 + type: ZBX_PREPROC_STR_REPLACE
2179 + params: "\\\nOK"
2180 +out:
2181 + return: SUCCEED
2182 + value: "OK"
2183 +---
2184 +test case: replace 2 characters "\t" to OK
2185 +in:
2186 + value:
2187 + value_type: ITEM_VALUE_TYPE_STR
2188 + time: 2017-10-29 03:15:00 +03:00
2189 + data: "\\t"
2190 + step:
2191 + type: ZBX_PREPROC_STR_REPLACE
2192 + params: "\\\\t\nOK"
2193 +out:
2194 + return: SUCCEED
2195 + value: "OK"
2196 +---
2197 +test case: Missing second parameter
2198 +in:
2199 + value:
2200 + value_type: ITEM_VALUE_TYPE_STR
2201 + time: 2017-10-29 03:15:00 +03:00
2202 + data: "a"
2203 + step:
2204 + type: ZBX_PREPROC_STR_REPLACE
2205 + params: "OK"
2206 +out:
2207 + return: FAIL
2208 + value: "a"
2209 +---
2210 +test case: Missing first parameter
2211 +in:
2212 + value:
2213 + value_type: ITEM_VALUE_TYPE_STR
2214 + time: 2017-10-29 03:15:00 +03:00
2215 + data: "a"
2216 + step:
2217 + type: ZBX_PREPROC_STR_REPLACE
2218 + params: "\nOK"
2219 +out:
2220 + return: FAIL
2221 + value: "a"
2222 +---
2223 +test case: Single slash in search
2224 +in:
2225 + value:
2226 + value_type: ITEM_VALUE_TYPE_STR
2227 + time: 2017-10-29 03:15:00 +03:00
2228 + data: "\\"
2229 + step:
2230 + type: ZBX_PREPROC_STR_REPLACE
2231 + params: "\\\nOK"
2232 +out:
2233 + return: SUCCEED
2234 + value: "OK"
2235 +---
2236 +test case: Single slash in replace
2237 +in:
2238 + value:
2239 + value_type: ITEM_VALUE_TYPE_STR
2240 + time: 2017-10-29 03:15:00 +03:00
2241 + data: "OK"
2242 + step:
2243 + type: ZBX_PREPROC_STR_REPLACE
2244 + params: "OK\n\\"
2245 +out:
2246 + return: SUCCEED
2247 + value: "\\"
2248 +---
2249 +test case: No match
2250 +in:
2251 + value:
2252 + value_type: ITEM_VALUE_TYPE_STR
2253 + time: 2017-10-29 03:15:00 +03:00
2254 + data: "O K"
2255 + step:
2256 + type: ZBX_PREPROC_STR_REPLACE
2257 + params: "OK\ntest"
2258 +out:
2259 + return: SUCCEED
2260 + value: "O K"
2261 +---
2262 +test case: SNMP walk to value - bad data
2263 +in:
2264 + value:
2265 + value_type: ITEM_VALUE_TYPE_STR
2266 + time: 2017-10-29 03:15:00 +03:00
2267 + data: |
2268 + aaa
2269 + bbb
2270 + step:
2271 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2272 + params: |-
2273 + .1.3.6.1.1
2274 + 0
2275 +out:
2276 + return: FAIL
2277 +---
2278 +test case: SNMP walk to value - bad param
2279 +in:
2280 + value:
2281 + value_type: ITEM_VALUE_TYPE_STR
2282 + time: 2017-10-29 03:15:00 +03:00
2283 + data: |
2284 + .1.3.6.1.1 = Hex-STRING: 71 71
2285 + step:
2286 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2287 +out:
2288 + return: FAIL
2289 +---
2290 +test case: SNMP walk to value - string
2291 +in:
2292 + value:
2293 + value_type: ITEM_VALUE_TYPE_STR
2294 + time: 2017-10-29 03:15:00 +03:00
2295 + data: |
2296 + .1.3.6.1.1 = STRING: "AAA"
2297 + step:
2298 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2299 + params: |-
2300 + .1.3.6.1.1
2301 + 0
2302 +out:
2303 + return: SUCCEED
2304 + value: 'AAA'
2305 +---
2306 +test case: SNMP walk to value - NULL
2307 +in:
2308 + value:
2309 + value_type: ITEM_VALUE_TYPE_STR
2310 + time: 2017-10-29 03:15:00 +03:00
2311 + data: |
2312 + .1.3.6.1.1 = NULL
2313 + step:
2314 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2315 + params: |-
2316 + .1.3.6.1.1
2317 + 0
2318 +out:
2319 + return: SUCCEED
2320 + value: 'NULL'
2321 +---
2322 +test case: SNMP walk to value - Arbitrary number
2323 +in:
2324 + value:
2325 + value_type: ITEM_VALUE_TYPE_STR
2326 + time: 2017-10-29 03:15:00 +03:00
2327 + data: |
2328 + .1.3.6.1.1 = 123
2329 + step:
2330 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2331 + params: |-
2332 + .1.3.6.1.1
2333 + 0
2334 +out:
2335 + return: SUCCEED
2336 + value: 123
2337 +---
2338 +test case: SNMP walk to value - INTEGER
2339 +in:
2340 + value:
2341 + value_type: ITEM_VALUE_TYPE_STR
2342 + time: 2017-10-29 03:15:00 +03:00
2343 + data: |
2344 + .1.3.6.1.1 = INTEGER: 123
2345 + step:
2346 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2347 + params: |-
2348 + .1.3.6.1.1
2349 + 0
2350 +out:
2351 + return: SUCCEED
2352 + value: 123
2353 +---
2354 +test case: SNMP walk to value - IpAddress
2355 +in:
2356 + value:
2357 + value_type: ITEM_VALUE_TYPE_STR
2358 + time: 2017-10-29 03:15:00 +03:00
2359 + data: |
2360 + .1.3.6.1.1 = IpAddress: 127.0.0.1
2361 + step:
2362 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2363 + params: |-
2364 + .1.3.6.1.1
2365 + 0
2366 +out:
2367 + return: SUCCEED
2368 + value: '127.0.0.1'
2369 +---
2370 +test case: SNMP walk to value - Hex-STRING
2371 +in:
2372 + value:
2373 + value_type: ITEM_VALUE_TYPE_STR
2374 + time: 2017-10-29 03:15:00 +03:00
2375 + data: |
2376 + .1.3.6.1.1 = Hex-STRING: 71 71 AA
2377 + step:
2378 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2379 + params: |-
2380 + .1.3.6.1.1
2381 + 0
2382 +out:
2383 + return: SUCCEED
2384 + value: '71 71 AA'
2385 +---
2386 +test case: SNMP walk to value - Hex-STRING (multiline)
2387 +in:
2388 + value:
2389 + value_type: ITEM_VALUE_TYPE_STR
2390 + time: 2017-10-29 03:15:00 +03:00
2391 + data: |
2392 + .1.3.6.1.1 = Hex-STRING: 71 71
2393 + AA BB CC
2394 + DD
2395 + step:
2396 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2397 + params: |-
2398 + .1.3.6.1.1
2399 + 0
2400 +out:
2401 + return: SUCCEED
2402 + value: '71 71 AA BB CC DD'
2403 +---
2404 +test case: SNMP walk to value - Hex-STRING (multiline) - space on the last line
2405 +in:
2406 + value:
2407 + value_type: ITEM_VALUE_TYPE_STR
2408 + time: 2017-10-29 03:15:00 +03:00
2409 + data: |
2410 + .1.3.6.1.1 = Hex-STRING: 71 71
2411 + AA BB CC
2412 + DD
2413 + step:
2414 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2415 + params: |-
2416 + .1.3.6.1.1
2417 + 0
2418 +out:
2419 + return: SUCCEED
2420 + value: '71 71 AA BB CC DD'
2421 +---
2422 +test case: SNMP walk to value - Hex-STRING (multiline) - with succeeding string
2423 +in:
2424 + value:
2425 + value_type: ITEM_VALUE_TYPE_STR
2426 + time: 2017-10-29 03:15:00 +03:00
2427 + data: |
2428 + .1.3.6.1.1 = Hex-STRING: 71 71
2429 + AA BB CC
2430 + DD
2431 + .1.3.6.1.2 = Hex-STRING: 74 65 73 74 20 D1 91
2432 + step:
2433 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2434 + params: |-
2435 + .1.3.6.1.1
2436 + 0
2437 +out:
2438 + return: SUCCEED
2439 + value: '71 71 AA BB CC DD'
2440 +---
2441 +test case: SNMP walk to value - Hex-STRING - to UTF8
2442 +in:
2443 + value:
2444 + value_type: ITEM_VALUE_TYPE_STR
2445 + time: 2017-10-29 03:15:00 +03:00
2446 + data: |
2447 + .1.3.6.1.1 = Hex-STRING: 74 65 73 74 20 D1 91
2448 + step:
2449 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2450 + params: |-
2451 + .1.3.6.1.1
2452 + 1
2453 +out:
2454 + return: SUCCEED
2455 + value: 'test ё'
2456 +---
2457 +test case: SNMP walk to value - Hex-STRING - to UTF8 (null terminated)
2458 +in:
2459 + value:
2460 + value_type: ITEM_VALUE_TYPE_STR
2461 + time: 2017-10-29 03:15:00 +03:00
2462 + data: |
2463 + .1.3.6.1.1 = Hex-STRING: 74 65 73 74 20 D1 91 00
2464 + step:
2465 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2466 + params: |-
2467 + .1.3.6.1.1
2468 + 1
2469 +out:
2470 + return: SUCCEED
2471 + value: 'test ё'
2472 +---
2473 +test case: SNMP walk to value - Hex-STRING - to UTF8 - invalid hex string
2474 +in:
2475 + value:
2476 + value_type: ITEM_VALUE_TYPE_STR
2477 + time: 2017-10-29 03:15:00 +03:00
2478 + data: |
2479 + .1.3.6.1.1 = Hex-STRING: 74 65 X3 74 20 D1 91
2480 + step:
2481 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2482 + params: |-
2483 + .1.3.6.1.1
2484 + 1
2485 +out:
2486 + return: FAIL
2487 +---
2488 +test case: SNMP walk to value - Hex-STRING - to UTF8 - invalid hex string (2)
2489 +in:
2490 + value:
2491 + value_type: ITEM_VALUE_TYPE_STR
2492 + time: 2017-10-29 03:15:00 +03:00
2493 + data: |
2494 + .1.3.6.1.1 = Hex-STRING: 74 65 74 20 D1 9
2495 + step:
2496 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2497 + params: |-
2498 + .1.3.6.1.1
2499 + 1
2500 +out:
2501 + return: FAIL
2502 +---
2503 +test case: SNMP walk to value - Hex-STRING - to UTF8 - invalid sequence
2504 +in:
2505 + value:
2506 + value_type: ITEM_VALUE_TYPE_STR
2507 + time: 2017-10-29 03:15:00 +03:00
2508 + data: |
2509 + .1.3.6.1.1 = Hex-STRING: 74 65 73 74 20 D1 91 FF
2510 + step:
2511 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2512 + params: |-
2513 + .1.3.6.1.1
2514 + 1
2515 +out:
2516 + return: SUCCEED
2517 + value: 'test ё?'
2518 +---
2519 +test case: SNMP walk to value - Hex-STRING - to MAC
2520 +in:
2521 + value:
2522 + value_type: ITEM_VALUE_TYPE_STR
2523 + time: 2017-10-29 03:15:00 +03:00
2524 + data: |
2525 + .1.3.6.1.1 = Hex-STRING: 74 65 73 74 20 D1
2526 + step:
2527 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2528 + params: |-
2529 + .1.3.6.1.1
2530 + 2
2531 +out:
2532 + return: SUCCEED
2533 + value: '74:65:73:74:20:D1'
2534 +---
2535 +test case: SNMP walk to value - Hex-STRING - to MAC - invalid hex string
2536 +in:
2537 + value:
2538 + value_type: ITEM_VALUE_TYPE_STR
2539 + time: 2017-10-29 03:15:00 +03:00
2540 + data: |
2541 + .1.3.6.1.1 = Hex-STRING: 74 65 73 74 XX
2542 + step:
2543 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2544 + params: |-
2545 + .1.3.6.1.1
2546 + 2
2547 +out:
2548 + return: FAIL
2549 +---
2550 +test case: SNMP walk to value - BITS to integer - 1
2551 +in:
2552 + value:
2553 + value_type: ITEM_VALUE_TYPE_STR
2554 + time: 2017-10-29 03:15:00 +03:00
2555 + data: |
2556 + .1.3.6.1.1 = BITS: FE EE 15 15
2557 + step:
2558 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2559 + params: |-
2560 + .1.3.6.1.1
2561 + 3
2562 +out:
2563 + return: SUCCEED
2564 + value: '353758974'
2565 +---
2566 +test case: SNMP walk to value - BITS to integer - 2
2567 +in:
2568 + value:
2569 + value_type: ITEM_VALUE_TYPE_STR
2570 + time: 2017-10-29 03:15:00 +03:00
2571 + data: |
2572 + .1.3.6.1.1 = BITS: 01
2573 + step:
2574 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2575 + params: |-
2576 + .1.3.6.1.1
2577 + 3
2578 +out:
2579 + return: SUCCEED
2580 + value: '1'
2581 +---
2582 +test case: SNMP walk to value - BITS to integer - 3
2583 +in:
2584 + value:
2585 + value_type: ITEM_VALUE_TYPE_STR
2586 + time: 2017-10-29 03:15:00 +03:00
2587 + data: |
2588 + .1.3.6.1.1 = BITS: 00 00 00
2589 + step:
2590 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2591 + params: |-
2592 + .1.3.6.1.1
2593 + 3
2594 +out:
2595 + return: SUCCEED
2596 + value: '0'
2597 +---
2598 +test case: SNMP walk to value - BITS to integer - 4
2599 +in:
2600 + value:
2601 + value_type: ITEM_VALUE_TYPE_STR
2602 + time: 2017-10-29 03:15:00 +03:00
2603 + data: |
2604 + .1.3.6.1.1 = BITS: 01 02 03 04 05 06 07 08 09 10
2605 + step:
2606 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2607 + params: |-
2608 + .1.3.6.1.1
2609 + 3
2610 +out:
2611 + return: SUCCEED
2612 + value: '578437695752307201'
2613 +---
2614 +test case: SNMP walk to value - BITS to integer - 5
2615 +in:
2616 + value:
2617 + value_type: ITEM_VALUE_TYPE_STR
2618 + time: 2017-10-29 03:15:00 +03:00
2619 + data: |
2620 + .1.3.6.1.1 = BITS: 01 02 03 04 05 06 07 08
2621 + step:
2622 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2623 + params: |-
2624 + .1.3.6.1.1
2625 + 3
2626 +out:
2627 + return: SUCCEED
2628 + value: '578437695752307201'
2629 +---
2630 +test case: SNMP walk to value - BITS to integer - 6
2631 +in:
2632 + value:
2633 + value_type: ITEM_VALUE_TYPE_STR
2634 + time: 2017-10-29 03:15:00 +03:00
2635 + data: |
2636 + .1.3.6.1.1 = BITS: 01 02 03 04 05 06 07 0
2637 + step:
2638 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2639 + params: |-
2640 + .1.3.6.1.1
2641 + 3
2642 +out:
2643 + return: FAIL
2644 +---
2645 +test case: SNMP walk to value - BITS to integer - 7
2646 +in:
2647 + value:
2648 + value_type: ITEM_VALUE_TYPE_STR
2649 + time: 2017-10-29 03:15:00 +03:00
2650 + data: |
2651 + .1.3.6.1.1 = BITS: QW 11
2652 + step:
2653 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2654 + params: |-
2655 + .1.3.6.1.1
2656 + 3
2657 +out:
2658 + return: FAIL
2659 +---
2660 +test case: SNMP walk to value - Opaque wrapped type
2661 +in:
2662 + value:
2663 + value_type: ITEM_VALUE_TYPE_STR
2664 + time: 2017-10-29 03:15:00 +03:00
2665 + data: |
2666 + .1.3.6.1.1 = Opaque: Float: 0.460000
2667 + step:
2668 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2669 + params: |-
2670 + .1.3.6.1.1
2671 + 0
2672 +out:
2673 + return: SUCCEED
2674 + value: 0.460000
2675 +---
2676 +test case: SNMP walk to value - Counter32
2677 +in:
2678 + value:
2679 + value_type: ITEM_VALUE_TYPE_STR
2680 + time: 2017-10-29 03:15:00 +03:00
2681 + data: |
2682 + .1.3.6.1.1 = Counter32: 12345678
2683 + step:
2684 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2685 + params: |-
2686 + .1.3.6.1.1
2687 + 0
2688 +out:
2689 + return: SUCCEED
2690 + value: 12345678
2691 +---
2692 +test case: SNMP walk to value - STRING with newline
2693 +in:
2694 + value:
2695 + value_type: ITEM_VALUE_TYPE_STR
2696 + time: 2017-10-29 03:15:00 +03:00
2697 + data: |
2698 + .1.3.6.1.1 = STRING: "line1
2699 + line2"
2700 + .1.3.6.2.1 = STRING: "line2
2701 + line3"
2702 + step:
2703 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2704 + params: |-
2705 + .1.3.6.1.1
2706 + 0
2707 +out:
2708 + return: SUCCEED
2709 + value: |-
2710 + line1
2711 + line2
2712 +---
2713 +test case: SNMP walk to value - unquoted STRING with newline
2714 +in:
2715 + value:
2716 + value_type: ITEM_VALUE_TYPE_STR
2717 + time: 2017-10-29 03:15:00 +03:00
2718 + data: |
2719 + .1.3.6.1.1 = STRING: line1
2720 + line2
2721 + .1.3.6.2.1 = STRING: line2
2722 + line3
2723 + step:
2724 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2725 + params: |-
2726 + .1.3.6.1.1
2727 + 0
2728 +out:
2729 + return: SUCCEED
2730 + value: |-
2731 + line1
2732 + line2
2733 +---
2734 +test case: SNMP walk to value - unquoted STRING with newline and empty string
2735 +in:
2736 + value:
2737 + value_type: ITEM_VALUE_TYPE_STR
2738 + time: 2017-10-29 03:15:00 +03:00
2739 + data: |
2740 + .1.3.6.1.1 = STRING: line1
2741 +
2742 + .1.3.6.2.1 = STRING: line2
2743 + line3
2744 + step:
2745 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2746 + params: |-
2747 + .1.3.6.1.1
2748 + 0
2749 +out:
2750 + return: SUCCEED
2751 + value: "line1\n"
2752 +---
2753 +test case: SNMP walk to value - quoted STRING with quoted substring
2754 +in:
2755 + value:
2756 + value_type: ITEM_VALUE_TYPE_STR
2757 + time: 2017-10-29 03:15:00 +03:00
2758 + data: |
2759 + .1.3.6.1.1 = STRING: "foo
2760 + \"bar\"
2761 + baz"
2762 + .1.3.6.2.1 = STRING: line2
2763 + line3
2764 + step:
2765 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2766 + params: |-
2767 + .1.3.6.1.1
2768 + 0
2769 +out:
2770 + return: SUCCEED
2771 + value: |-
2772 + foo
2773 + "bar"
2774 + baz
2775 +---
2776 +test case: SNMP walk to value - quoted STRING with broken quoting
2777 +in:
2778 + value:
2779 + value_type: ITEM_VALUE_TYPE_STR
2780 + time: 2017-10-29 03:15:00 +03:00
2781 + data: |
2782 + .1.3.6.1.1 = STRING: "foo
2783 + \
2784 + .1.3.6.2.1 = STRING: line2
2785 + line3
2786 + step:
2787 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2788 + params: |-
2789 + .1.3.6.1.1
2790 + 0
2791 +out:
2792 + return: FAIL
2793 +---
2794 +test case: SNMP walk to value - backslashes in quoted string
2795 +in:
2796 + value:
2797 + value_type: ITEM_VALUE_TYPE_STR
2798 + time: 2017-10-29 03:15:00 +03:00
2799 + data: |
2800 + .1.3.6.1.1 = STRING: "foo\\bar\\baz"
2801 + step:
2802 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2803 + params: |-
2804 + .1.3.6.1.1
2805 + 0
2806 +out:
2807 + return: SUCCEED
2808 + value: foo\bar\baz
2809 +---
2810 +test case: SNMP walk to value - backslashes in unquoted string
2811 +in:
2812 + value:
2813 + value_type: ITEM_VALUE_TYPE_STR
2814 + time: 2017-10-29 03:15:00 +03:00
2815 + data: |
2816 + .1.3.6.1.1 = STRING: foo\bar\baz
2817 + step:
2818 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2819 + params: |-
2820 + .1.3.6.1.1
2821 + 0
2822 +out:
2823 + return: SUCCEED
2824 + value: foo\bar\baz
2825 +---
2826 +test case: SNMP walk to value - Empty type
2827 +in:
2828 + value:
2829 + value_type: ITEM_VALUE_TYPE_STR
2830 + time: 2017-10-29 03:15:00 +03:00
2831 + data: |
2832 + .1.3.6.1.1 = ""
2833 + step:
2834 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2835 + params: |-
2836 + .1.3.6.1.1
2837 + 0
2838 +out:
2839 + return: SUCCEED
2840 + value: ''
2841 +---
2842 +test case: SNMP walk to value - OID without prepending dot
2843 +in:
2844 + value:
2845 + value_type: ITEM_VALUE_TYPE_STR
2846 + time: 2017-10-29 03:15:00 +03:00
2847 + data: |
2848 + .1.3.6.1.1 = 123
2849 + step:
2850 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2851 + params: |-
2852 + 1.3.6.1.1
2853 + 0
2854 +out:
2855 + return: SUCCEED
2856 + value: 123
2857 +---
2858 +test case: SNMP walk to value - MIB translation 1
2859 +in:
2860 + value:
2861 + value_type: ITEM_VALUE_TYPE_STR
2862 + time: 2017-10-29 03:15:00 +03:00
2863 + data: |
2864 + .1.3.6.1.2.1.2.2.1.2 = 20
2865 + step:
2866 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2867 + params: |-
2868 + IF-MIB::ifDescr
2869 + 1
2870 + netsnmp_required: y
2871 +out:
2872 + return: SUCCEED
2873 + value: 20
2874 +---
2875 +test case: SNMP walk to value - MIB translation 2
2876 +in:
2877 + value:
2878 + value_type: ITEM_VALUE_TYPE_STR
2879 + time: 2017-10-29 03:15:00 +03:00
2880 + data: |
2881 + .1.3.6.1.2.1.1.1 = 20
2882 + step:
2883 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2884 + params: |-
2885 + SNMPv2-MIB::sysDescr
2886 + 0
2887 + netsnmp_required: y
2888 +out:
2889 + return: SUCCEED
2890 + value: 20
2891 +---
2892 +test case: SNMP walk to value - bad param
2893 +in:
2894 + value:
2895 + value_type: ITEM_VALUE_TYPE_STR
2896 + time: 2017-10-29 03:15:00 +03:00
2897 + data: |
2898 + 71 71
2899 + step:
2900 + type: ZBX_PREPROC_SNMP_WALK_VALUE
2901 +out:
2902 + return: FAIL
2903 +---
2904 +test case: SNMP get to value - Hex-STRING - to UTF8
2905 +in:
2906 + value:
2907 + value_type: ITEM_VALUE_TYPE_STR
2908 + time: 2017-10-29 03:15:00 +03:00
2909 + data: |
2910 + 74 65 73 74 20 D1 91
2911 + step:
2912 + type: ZBX_PREPROC_SNMP_GET_VALUE
2913 + params: '1'
2914 +out:
2915 + return: SUCCEED
2916 + value: 'test ё'
2917 +---
2918 +test case: SNMP get to value - Hex-STRING - to UTF8 (null terminated)
2919 +in:
2920 + value:
2921 + value_type: ITEM_VALUE_TYPE_STR
2922 + time: 2017-10-29 03:15:00 +03:00
2923 + data: |
2924 + 74 65 73 74 20 D1 91 00
2925 + step:
2926 + type: ZBX_PREPROC_SNMP_GET_VALUE
2927 + params: '1'
2928 +out:
2929 + return: SUCCEED
2930 + value: 'test ё'
2931 +---
2932 +test case: SNMP get to value - Hex-STRING - to UTF8 - invalid hex string
2933 +in:
2934 + value:
2935 + value_type: ITEM_VALUE_TYPE_STR
2936 + time: 2017-10-29 03:15:00 +03:00
2937 + data: |
2938 + 74 65 X3 74 20 D1 91
2939 + step:
2940 + type: ZBX_PREPROC_SNMP_GET_VALUE
2941 + params: '1'
2942 +out:
2943 + return: FAIL
2944 +---
2945 +test case: SNMP get to value - Hex-STRING - to UTF8 - invalid hex string (2)
2946 +in:
2947 + value:
2948 + value_type: ITEM_VALUE_TYPE_STR
2949 + time: 2017-10-29 03:15:00 +03:00
2950 + data: |
2951 + 74 65 74 20 D1 9
2952 + step:
2953 + type: ZBX_PREPROC_SNMP_GET_VALUE
2954 + params: '1'
2955 +out:
2956 + return: FAIL
2957 +---
2958 +test case: SNMP get to value - Hex-STRING - to UTF8 - invalid sequence
2959 +in:
2960 + value:
2961 + value_type: ITEM_VALUE_TYPE_STR
2962 + time: 2017-10-29 03:15:00 +03:00
2963 + data: |
2964 + 74 65 73 74 20 D1 91 FF
2965 + step:
2966 + type: ZBX_PREPROC_SNMP_GET_VALUE
2967 + params: '1'
2968 +out:
2969 + return: SUCCEED
2970 + value: 'test ё?'
2971 +---
2972 +test case: SNMP get to value - Hex-STRING - to MAC
2973 +in:
2974 + value:
2975 + value_type: ITEM_VALUE_TYPE_STR
2976 + time: 2017-10-29 03:15:00 +03:00
2977 + data: |
2978 + 74 65 73 74 20 D1
2979 + step:
2980 + type: ZBX_PREPROC_SNMP_GET_VALUE
2981 + params: '2'
2982 +out:
2983 + return: SUCCEED
2984 + value: '74:65:73:74:20:D1'
2985 +---
2986 +test case: SNMP get to value - Hex-STRING - to MAC - invalid hex string
2987 +in:
2988 + value:
2989 + value_type: ITEM_VALUE_TYPE_STR
2990 + time: 2017-10-29 03:15:00 +03:00
2991 + data: |
2992 + 74 65 73 74 XX
2993 + step:
2994 + type: ZBX_PREPROC_SNMP_GET_VALUE
2995 + params: '2'
2996 +out:
2997 + return: FAIL
2998 +---
2999 +test case: SNMP get to value - BITS to integer - 1
3000 +in:
3001 + value:
3002 + value_type: ITEM_VALUE_TYPE_STR
3003 + time: 2017-10-29 03:15:00 +03:00
3004 + data: |
3005 + FE EE 15 15
3006 + step:
3007 + type: ZBX_PREPROC_SNMP_GET_VALUE
3008 + params: '3'
3009 +out:
3010 + return: SUCCEED
3011 + value: '353758974'
3012 +---
3013 +test case: SNMP get to value - BITS to integer - 2
3014 +in:
3015 + value:
3016 + value_type: ITEM_VALUE_TYPE_STR
3017 + time: 2017-10-29 03:15:00 +03:00
3018 + data: |
3019 + 01
3020 + step:
3021 + type: ZBX_PREPROC_SNMP_GET_VALUE
3022 + params: '3'
3023 +out:
3024 + return: SUCCEED
3025 + value: '1'
3026 +---
3027 +test case: SNMP get to value - BITS to integer - 3
3028 +in:
3029 + value:
3030 + value_type: ITEM_VALUE_TYPE_STR
3031 + time: 2017-10-29 03:15:00 +03:00
3032 + data: |
3033 + 00 00 00
3034 + step:
3035 + type: ZBX_PREPROC_SNMP_GET_VALUE
3036 + params: '3'
3037 +out:
3038 + return: SUCCEED
3039 + value: '0'
3040 +---
3041 +test case: SNMP get to value - BITS to integer - 4
3042 +in:
3043 + value:
3044 + value_type: ITEM_VALUE_TYPE_STR
3045 + time: 2017-10-29 03:15:00 +03:00
3046 + data: |
3047 + 01 02 03 04 05 06 07 08 09 10
3048 + step:
3049 + type: ZBX_PREPROC_SNMP_GET_VALUE
3050 + params: '3'
3051 +out:
3052 + return: SUCCEED
3053 + value: '578437695752307201'
3054 +---
3055 +test case: SNMP get to value - BITS to integer - 5
3056 +in:
3057 + value:
3058 + value_type: ITEM_VALUE_TYPE_STR
3059 + time: 2017-10-29 03:15:00 +03:00
3060 + data: |
3061 + 01 02 03 04 05 06 07 08
3062 + step:
3063 + type: ZBX_PREPROC_SNMP_GET_VALUE
3064 + params: '3'
3065 +out:
3066 + return: SUCCEED
3067 + value: '578437695752307201'
3068 +---
3069 +test case: SNMP get to value - BITS to integer - 6
3070 +in:
3071 + value:
3072 + value_type: ITEM_VALUE_TYPE_STR
3073 + time: 2017-10-29 03:15:00 +03:00
3074 + data: |
3075 + 01 02 03 04 05 06 07 0
3076 + step:
3077 + type: ZBX_PREPROC_SNMP_GET_VALUE
3078 + params: '3'
3079 +out:
3080 + return: FAIL
3081 +---
3082 +test case: SNMP get to value - BITS to integer - 7
3083 +in:
3084 + value:
3085 + value_type: ITEM_VALUE_TYPE_STR
3086 + time: 2017-10-29 03:15:00 +03:00
3087 + data: |
3088 + .QW 11
3089 + step:
3090 + type: ZBX_PREPROC_SNMP_GET_VALUE
3091 + params: '3'
3092 +out:
3093 + return: FAIL
3094 +---
3095 +test case: SNMP walk to JSON - bad data
3096 +in:
3097 + value:
3098 + value_type: ITEM_VALUE_TYPE_STR
3099 + time: 2017-10-29 03:15:00 +03:00
3100 + data: |
3101 + bad data
3102 + step:
3103 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3104 + params: |-
3105 + AAA
3106 + .1.3.6.1
3107 + 0
3108 + BBB
3109 + .1.3.6.2
3110 + 0
3111 +out:
3112 + return: FAIL
3113 +---
3114 +test case: SNMP walk to JSON - no data
3115 +in:
3116 + value:
3117 + value_type: ITEM_VALUE_TYPE_STR
3118 + time: 2017-10-29 03:15:00 +03:00
3119 + data: |
3120 + .1.3.6.1.1 = 123
3121 + step:
3122 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3123 + params: |-
3124 + BBB
3125 + .1.3.6.2
3126 + 0
3127 +out:
3128 + return: FAIL
3129 +---
3130 +test case: SNMP walk to JSON - Empty snmp data
3131 +in:
3132 + value:
3133 + value_type: ITEM_VALUE_TYPE_STR
3134 + time: 2017-10-29 03:15:00 +03:00
3135 + data: |
3136 + step:
3137 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3138 + params: |-
3139 + MACRO1
3140 + .1.3.6.1
3141 + 0
3142 +out:
3143 + return: SUCCEED
3144 + value: '[]'
3145 +---
3146 +test case: SNMP walk to JSON - Empty snmp data and empty params
3147 +in:
3148 + value:
3149 + value_type: ITEM_VALUE_TYPE_STR
3150 + time: 2017-10-29 03:15:00 +03:00
3151 + data: |
3152 + step:
3153 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3154 + params: |-
3155 +out:
3156 + return: SUCCEED
3157 + value: '[]'
3158 +---
3159 +test case: SNMP walk to JSON - STRING type
3160 +in:
3161 + value:
3162 + value_type: ITEM_VALUE_TYPE_STR
3163 + time: 2017-10-29 03:15:00 +03:00
3164 + data: |
3165 + .1.3.6.1.1 = STRING: "xxx"
3166 + .1.3.6.1.2 = STRING: "yyy"
3167 + .1.3.6.2.1 = STRING: "aaa"
3168 + .1.3.6.2.2 = STRING: "bbb"
3169 + step:
3170 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3171 + params: |-
3172 + MACRO1
3173 + .1.3.6.1
3174 + 0
3175 + MACRO2
3176 + .1.3.6.2
3177 + 0
3178 +out:
3179 + return: SUCCEED
3180 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"yyy","MACRO2":"bbb"},{"{#SNMPINDEX}":"1","MACRO1":"xxx","MACRO2":"aaa"}]'
3181 +---
3182 +test case: SNMP walk to JSON - Counter/Gauge types
3183 +in:
3184 + value:
3185 + value_type: ITEM_VALUE_TYPE_STR
3186 + time: 2017-10-29 03:15:00 +03:00
3187 + data: |
3188 + .1.3.6.1.1 = Counter32: 32
3189 + .1.3.6.1.2 = Counter64: 64
3190 + .1.3.6.2.1 = Gauge32: 32
3191 + .1.3.6.2.2 = Counter64: 64
3192 + step:
3193 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3194 + params: |-
3195 + MACRO1
3196 + .1.3.6.1
3197 + 0
3198 + MACRO2
3199 + .1.3.6.2
3200 + 0
3201 +out:
3202 + return: SUCCEED
3203 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"64","MACRO2":"64"},{"{#SNMPINDEX}":"1","MACRO1":"32","MACRO2":"32"}]'
3204 +---
3205 +test case: SNMP walk to JSON - NULL
3206 +in:
3207 + value:
3208 + value_type: ITEM_VALUE_TYPE_STR
3209 + time: 2017-10-29 03:15:00 +03:00
3210 + data: |
3211 + .1.3.6.1.1 = NULL
3212 + .1.3.6.1.2 = Counter64: 64
3213 + .1.3.6.2.1 = Gauge32: 32
3214 + .1.3.6.2.2 = NULL
3215 + step:
3216 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3217 + params: |-
3218 + MACRO1
3219 + .1.3.6.1
3220 + 0
3221 + MACRO2
3222 + .1.3.6.2
3223 + 0
3224 +out:
3225 + return: SUCCEED
3226 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"64","MACRO2":null},{"{#SNMPINDEX}":"1","MACRO1":null,"MACRO2":"32"}]'
3227 +---
3228 +test case: SNMP walk to JSON - Empty type
3229 +in:
3230 + value:
3231 + value_type: ITEM_VALUE_TYPE_STR
3232 + time: 2017-10-29 03:15:00 +03:00
3233 + data: |
3234 + .1.3.6.1.1 = STRING: "aaa"
3235 + .1.3.6.1.2 = Counter64: 64
3236 + .1.3.6.2.1 = ""
3237 + .1.3.6.2.2 = Counter64: 64
3238 + step:
3239 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3240 + params: |-
3241 + MACRO1
3242 + .1.3.6.1
3243 + 0
3244 + MACRO2
3245 + .1.3.6.2
3246 + 0
3247 +out:
3248 + return: SUCCEED
3249 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"64","MACRO2":"64"},{"{#SNMPINDEX}":"1","MACRO1":"aaa","MACRO2":""}]'
3250 +---
3251 +test case: SNMP walk to JSON - Empty type
3252 +in:
3253 + value:
3254 + value_type: ITEM_VALUE_TYPE_STR
3255 + time: 2017-10-29 03:15:00 +03:00
3256 + data: |
3257 + .1.3.6.1.1 = 123
3258 + .1.3.6.1.2 = 456
3259 + .1.3.6.2.1 = ""
3260 + .1.3.6.2.2 = Counter64: 64
3261 + step:
3262 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3263 + params: |-
3264 + MACRO1
3265 + .1.3.6.1
3266 + 0
3267 + MACRO2
3268 + .1.3.6.2
3269 + 0
3270 +out:
3271 + return: SUCCEED
3272 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"456","MACRO2":"64"},{"{#SNMPINDEX}":"1","MACRO1":"123","MACRO2":""}]'
3273 +---
3274 +test case: SNMP walk to JSON - Integer/Timetick
3275 +in:
3276 + value:
3277 + value_type: ITEM_VALUE_TYPE_STR
3278 + time: 2017-10-29 03:15:00 +03:00
3279 + data: |
3280 + .1.3.6.1.1 = 1000
3281 + .1.3.6.1.2 = 2000
3282 + .1.3.6.2.1 = 3000
3283 + .1.3.6.2.2 = 4000
3284 + step:
3285 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3286 + params: |-
3287 + MACRO1
3288 + .1.3.6.1
3289 + 0
3290 + MACRO2
3291 + .1.3.6.2
3292 + 0
3293 +out:
3294 + return: SUCCEED
3295 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"2000","MACRO2":"4000"},{"{#SNMPINDEX}":"1","MACRO1":"1000","MACRO2":"3000"}]'
3296 +---
3297 +test case: SNMP walk to JSON - IpAddress
3298 +in:
3299 + value:
3300 + value_type: ITEM_VALUE_TYPE_STR
3301 + time: 2017-10-29 03:15:00 +03:00
3302 + data: |
3303 + .1.3.6.1.1 = IpAddress: 10.0.0.0
3304 + .1.3.6.1.2 = IpAddress: 172.16.0.0
3305 + .1.3.6.2.1 = IpAddress: 10.255.255.255
3306 + .1.3.6.2.2 = IpAddress: 172.31.255.255
3307 + step:
3308 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3309 + params: |-
3310 + MACRO1
3311 + .1.3.6.1
3312 + 0
3313 + MACRO2
3314 + .1.3.6.2
3315 + 0
3316 +out:
3317 + return: SUCCEED
3318 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"172.16.0.0","MACRO2":"172.31.255.255"},{"{#SNMPINDEX}":"1","MACRO1":"10.0.0.0","MACRO2":"10.255.255.255"}]'
3319 +---
3320 +test case: SNMP walk to JSON - STRING type with newline
3321 +in:
3322 + value:
3323 + value_type: ITEM_VALUE_TYPE_STR
3324 + time: 2017-10-29 03:15:00 +03:00
3325 + data: |
3326 + .1.3.6.1.1 = STRING: "x
3327 + xx
3328 + "
3329 + .1.3.6.1.2 = STRING: "y
3330 + yy"
3331 + .1.3.6.2.1 = STRING: "
3332 + aaa"
3333 + .1.3.6.2.2 = STRING: "bbb
3334 + "
3335 + step:
3336 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3337 + params: |-
3338 + MACRO1
3339 + .1.3.6.1
3340 + 0
3341 + MACRO2
3342 + .1.3.6.2
3343 + 0
3344 +out:
3345 + return: SUCCEED
3346 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"y\nyy","MACRO2":"bbb\n"},{"{#SNMPINDEX}":"1","MACRO1":"x\nxx\n","MACRO2":"\naaa"}]'
3347 +---
3348 +test case: SNMP walk to JSON - Opaque wrapped type
3349 +in:
3350 + value:
3351 + value_type: ITEM_VALUE_TYPE_STR
3352 + time: 2017-10-29 03:15:00 +03:00
3353 + data: |
3354 + .1.3.6.1.1 = Opaque: Float: 0.110000
3355 + .1.3.6.1.2 = Opaque: STRING: "hello1.2"
3356 + .1.3.6.2.1 = Opaque: Float: 0.210000
3357 + .1.3.6.2.2 = Opaque: Unsigned32: 220000
3358 + step:
3359 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3360 + params: |-
3361 + MACRO1
3362 + .1.3.6.1
3363 + 0
3364 + MACRO2
3365 + .1.3.6.2
3366 + 0
3367 +out:
3368 + return: SUCCEED
3369 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"hello1.2","MACRO2":"220000"},{"{#SNMPINDEX}":"1","MACRO1":"0.110000","MACRO2":"0.210000"}]'
3370 +---
3371 +test case: SNMP walk to JSON - OID is wanted without prepending dot - 1
3372 +in:
3373 + value:
3374 + value_type: ITEM_VALUE_TYPE_STR
3375 + time: 2017-10-29 03:15:00 +03:00
3376 + data: |
3377 + .1.3.6.1.1 = 1000
3378 + .1.3.6.1.2 = 2000
3379 + .1.3.6.2.1 = 3000
3380 + .1.3.6.2.2 = 4000
3381 + step:
3382 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3383 + params: |-
3384 + MACRO1
3385 + 1.3.6.1
3386 + 0
3387 + MACRO2
3388 + 1.3.6.2
3389 + 0
3390 +out:
3391 + return: SUCCEED
3392 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"2000","MACRO2":"4000"},{"{#SNMPINDEX}":"1","MACRO1":"1000","MACRO2":"3000"}]'
3393 +---
3394 +test case: SNMP walk to JSON - OID is wanted without prepending dot - 2
3395 +in:
3396 + value:
3397 + value_type: ITEM_VALUE_TYPE_STR
3398 + time: 2017-10-29 03:15:00 +03:00
3399 + data: |
3400 + .1.3.6.1.1 = 1000
3401 + .1.3.6.1.2 = 2000
3402 + .1.3.6.2.1 = 3000
3403 + .1.3.6.2.2 = 4000
3404 + step:
3405 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3406 + params: |-
3407 + MACRO1
3408 + 1.3.6.1
3409 + 0
3410 + MACRO2
3411 + .1.3.6.2
3412 + 0
3413 +out:
3414 + return: SUCCEED
3415 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"2000","MACRO2":"4000"},{"{#SNMPINDEX}":"1","MACRO1":"1000","MACRO2":"3000"}]'
3416 +---
3417 +test case: SNMP walk to JSON - MIB translation 1
3418 +in:
3419 + value:
3420 + value_type: ITEM_VALUE_TYPE_STR
3421 + time: 2017-10-29 03:15:00 +03:00
3422 + data: |
3423 + .1.3.6.1.2.1.2.2.1.2.1 = 20
3424 + .1.3.6.1.2.1.2.2.1.2.2 = 30
3425 + step:
3426 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3427 + params: |-
3428 + IFDESCR
3429 + IF-MIB::ifDescr
3430 + 0
3431 + netsnmp_required: y
3432 +out:
3433 + return: SUCCEED
3434 + value: '[{"{#SNMPINDEX}":"2","IFDESCR":"30"},{"{#SNMPINDEX}":"1","IFDESCR":"20"}]'
3435 +---
3436 +test case: SNMP walk to JSON - No more variables 1
3437 +in:
3438 + value:
3439 + value_type: ITEM_VALUE_TYPE_STR
3440 + time: 2017-10-29 03:15:00 +03:00
3441 + data: |
3442 + .1.3.6.1.1 = 123
3443 + .1.3.6.1.2 = 456
3444 + .1.3.6.2.1 = STRING: "TEST"
3445 + .1.3.6.2.2 = Counter64: 64
3446 + .1.3.6.3.1 = No more variables left in this MIB View (It is past the end of the MIB tree)
3447 + step:
3448 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3449 + params: |-
3450 + MACRO1
3451 + .1.3.6.1
3452 + 0
3453 + MACRO2
3454 + .1.3.6.2
3455 + 0
3456 +out:
3457 + return: SUCCEED
3458 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"456","MACRO2":"64"},{"{#SNMPINDEX}":"1","MACRO1":"123","MACRO2":"TEST"}]'
3459 +---
3460 +test case: SNMP walk to JSON - No more variables 2
3461 +in:
3462 + value:
3463 + value_type: ITEM_VALUE_TYPE_STR
3464 + time: 2017-10-29 03:15:00 +03:00
3465 + data: |
3466 + .1.3.6.1.1 = 123
3467 + .1.3.6.1.2 = 456
3468 + .1.3.6.2.1 = STRING: "TEST"
3469 + .1.3.6.2.2 = Counter64: 64
3470 + .1.3.6.3.1 = No more variables left in this MIB View (It is past the end of the MIB tree)
3471 + .1.3.7.1.1 = 123
3472 + .1.3.7.1.2 = 456
3473 + .1.3.7.2.1 = STRING: "TEST"
3474 + .1.3.7.2.2 = Counter64: 64
3475 + step:
3476 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3477 + params: |-
3478 + MACRO1
3479 + .1.3.6.1
3480 + 0
3481 + MACRO2
3482 + .1.3.6.2
3483 + 0
3484 +out:
3485 + return: SUCCEED
3486 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"456","MACRO2":"64"},{"{#SNMPINDEX}":"1","MACRO1":"123","MACRO2":"TEST"}]'
3487 +---
3488 +test case: SNMP walk to JSON - Duplicate OIDs
3489 +in:
3490 + value:
3491 + value_type: ITEM_VALUE_TYPE_STR
3492 + time: 2017-10-29 03:15:00 +03:00
3493 + data: |
3494 + .1.3.6.1.1 = 11
3495 + .1.3.6.1.2 = 12
3496 + .1.3.6.2.1 = 21
3497 + .1.3.6.2.2 = 22
3498 + .1.3.6.1.1 = 11
3499 + .1.3.6.1.2 = 12
3500 + step:
3501 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3502 + params: |-
3503 + MACRO1
3504 + .1.3.6.1
3505 + 0
3506 + MACRO2
3507 + .1.3.6.2
3508 + 0
3509 +out:
3510 + return: SUCCEED
3511 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"12","MACRO2":"22"},{"{#SNMPINDEX}":"1","MACRO1":"11","MACRO2":"21"}]'
3512 +---
3513 +test case: SNMP walk to JSON - Hex-STRING type - unchanged
3514 +in:
3515 + value:
3516 + value_type: ITEM_VALUE_TYPE_STR
3517 + time: 2017-10-29 03:15:00 +03:00
3518 + data: |
3519 + .1.3.6.1.1 = Hex-STRING: AA BE FF
3520 + .1.3.6.1.2 = Hex-STRING: FF FF
3521 + .1.3.6.2.1 = Hex-STRING: DD CD
3522 + .1.3.6.2.2 = Hex-STRING: 99 A5 F1
3523 + step:
3524 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3525 + params: |-
3526 + MACRO1
3527 + .1.3.6.1
3528 + 0
3529 + MACRO2
3530 + .1.3.6.2
3531 + 0
3532 +out:
3533 + return: SUCCEED
3534 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"FF FF","MACRO2":"99 A5 F1"},{"{#SNMPINDEX}":"1","MACRO1":"AA BE FF","MACRO2":"DD CD"}]'
3535 +---
3536 +test case: SNMP walk to JSON - Hex-STRING type - to UTF8
3537 +in:
3538 + value:
3539 + value_type: ITEM_VALUE_TYPE_STR
3540 + time: 2017-10-29 03:15:00 +03:00
3541 + data: |
3542 + .1.3.6.1.1 = Hex-STRING: 74 65 73 74 20 D1 8B
3543 + .1.3.6.1.2 = Hex-STRING: 74 65 73 74 20 D1 84
3544 + .1.3.6.2.1 = Hex-STRING: D1 91
3545 + .1.3.6.2.2 = Hex-STRING: EA 9A 85
3546 + step:
3547 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3548 + params: |-
3549 + MACRO1
3550 + .1.3.6.1
3551 + 1
3552 + MACRO2
3553 + .1.3.6.2
3554 + 1
3555 +out:
3556 + return: SUCCEED
3557 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"test ф","MACRO2":"ꚅ"},{"{#SNMPINDEX}":"1","MACRO1":"test ы","MACRO2":"ё"}]'
3558 +---
3559 +test case: SNMP walk to JSON - Hex-STRING type - to UTF8 - invalid sequence
3560 +in:
3561 + value:
3562 + value_type: ITEM_VALUE_TYPE_STR
3563 + time: 2017-10-29 03:15:00 +03:00
3564 + data: |
3565 + .1.3.6.1.1 = Hex-STRING: 74 65 73 74 20 FF FF
3566 + step:
3567 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3568 + params: |-
3569 + MACRO1
3570 + .1.3.6.1
3571 + 1
3572 +out:
3573 + return: SUCCEED
3574 + value: '[{"{#SNMPINDEX}":"1","MACRO1":"test ??"}]'
3575 +---
3576 +test case: SNMP walk to JSON - Hex-STRING type - to UTF8 - invalid hex string
3577 +in:
3578 + value:
3579 + value_type: ITEM_VALUE_TYPE_STR
3580 + time: 2017-10-29 03:15:00 +03:00
3581 + data: |
3582 + .1.3.6.1.1 = Hex-STRING: 74 65 73 74 20 D1 8B XX
3583 + step:
3584 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3585 + params: |-
3586 + MACRO1
3587 + .1.3.6.1
3588 + 1
3589 +out:
3590 + return: FAIL
3591 +---
3592 +test case: SNMP walk to JSON - Hex-STRING type - to UTF8 - null terminated
3593 +in:
3594 + value:
3595 + value_type: ITEM_VALUE_TYPE_STR
3596 + time: 2017-10-29 03:15:00 +03:00
3597 + data: |
3598 + .1.3.6.1.1 = Hex-STRING: 74 65 73 74 00
3599 + step:
3600 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3601 + params: |-
3602 + MACRO1
3603 + .1.3.6.1
3604 + 1
3605 +out:
3606 + return: SUCCEED
3607 + value: '[{"{#SNMPINDEX}":"1","MACRO1":"test"}]'
3608 +---
3609 +test case: SNMP walk to JSON - Hex-STRING type - to MAC
3610 +in:
3611 + value:
3612 + value_type: ITEM_VALUE_TYPE_STR
3613 + time: 2017-10-29 03:15:00 +03:00
3614 + data: |
3615 + .1.3.6.1.1 = Hex-STRING: 74 65 73 74 20 D1
3616 + .1.3.6.1.2 = Hex-STRING: FF FF FF FF FF FF
3617 + .1.3.6.2.1 = Hex-STRING: 00 00 00 00 00 00
3618 + .1.3.6.2.2 = Hex-STRING: EA 9A 85 11 22 33
3619 + step:
3620 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3621 + params: |-
3622 + MACRO1
3623 + .1.3.6.1
3624 + 2
3625 + MACRO2
3626 + .1.3.6.2
3627 + 2
3628 +out:
3629 + return: SUCCEED
3630 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"FF:FF:FF:FF:FF:FF","MACRO2":"EA:9A:85:11:22:33"},{"{#SNMPINDEX}":"1","MACRO1":"74:65:73:74:20:D1","MACRO2":"00:00:00:00:00:00"}]'
3631 +---
3632 +test case: SNMP walk to JSON - Hex-STRING type - to MAC - invalid MAC, invalid hex string
3633 +in:
3634 + value:
3635 + value_type: ITEM_VALUE_TYPE_STR
3636 + time: 2017-10-29 03:15:00 +03:00
3637 + data: |
3638 + .1.3.6.1.1 = Hex-STRING: 74 65 73 74 20 XX
3639 + step:
3640 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3641 + params: |-
3642 + MACRO1
3643 + .1.3.6.1
3644 + 2
3645 +out:
3646 + return: FAIL
3647 +---
3648 +test case: SNMP walk to JSON - Hex-STRING type - mixed 'treat-as' values
3649 +in:
3650 + value:
3651 + value_type: ITEM_VALUE_TYPE_STR
3652 + time: 2017-10-29 03:15:00 +03:00
3653 + data: |
3654 + .1.3.6.1.1 = Hex-STRING: D1 8B
3655 + .1.3.6.1.2 = Hex-STRING: D1 91
3656 + .1.3.6.2.1 = Hex-STRING: 00 00 00 00 00 00
3657 + .1.3.6.2.2 = Hex-STRING: EA 9A 85 11 22 33
3658 + step:
3659 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3660 + params: |-
3661 + MACRO1
3662 + .1.3.6.1
3663 + 1
3664 + MACRO2
3665 + .1.3.6.2
3666 + 0
3667 +out:
3668 + return: SUCCEED
3669 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"ё","MACRO2":"EA 9A 85 11 22 33"},{"{#SNMPINDEX}":"1","MACRO1":"ы","MACRO2":"00 00 00 00 00 00"}]'
3670 +---
3671 +test case: SNMP walk to value - Hex-STRING (multiline)
3672 +in:
3673 + value:
3674 + value_type: ITEM_VALUE_TYPE_STR
3675 + time: 2017-10-29 03:15:00 +03:00
3676 + data: |
3677 + .1.3.6.1.1 = Hex-STRING: 71 71
3678 + AA BB CC
3679 + DD
3680 + .1.3.6.1.2 = Hex-STRING: D1 91
3681 + .1.3.6.2.1 = Hex-STRING: 00 00 00 00 00 00
3682 + .1.3.6.2.2 = Hex-STRING: EA 9A 85 11 22 33
3683 + step:
3684 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3685 + params: |-
3686 + MACRO1
3687 + .1.3.6.1
3688 + 0
3689 + MACRO2
3690 + .1.3.6.2
3691 + 0
3692 +out:
3693 + return: SUCCEED
3694 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"D1 91","MACRO2":"EA 9A 85 11 22 33"},{"{#SNMPINDEX}":"1","MACRO1":"71 71 AA BB CC DD","MACRO2":"00 00 00 00 00 00"}]'
3695 +---
3696 +test case: SNMP walk to value - Hex-STRING (multiline) - to MAC
3697 +in:
3698 + value:
3699 + value_type: ITEM_VALUE_TYPE_STR
3700 + time: 2017-10-29 03:15:00 +03:00
3701 + data: |
3702 + .1.3.6.1.1 = Hex-STRING: 71 71
3703 + AA BB CC
3704 + DD
3705 + .1.3.6.1.2 = Hex-STRING: D1 91
3706 + .1.3.6.2.1 = Hex-STRING: 00 00 00 00 00 00
3707 + .1.3.6.2.2 = Hex-STRING: EA 9A 85 11 22 33
3708 + step:
3709 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3710 + params: |-
3711 + MACRO1
3712 + .1.3.6.1
3713 + 2
3714 + MACRO2
3715 + .1.3.6.2
3716 + 0
3717 +out:
3718 + return: SUCCEED
3719 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"D1:91","MACRO2":"EA 9A 85 11 22 33"},{"{#SNMPINDEX}":"1","MACRO1":"71:71:AA:BB:CC:DD","MACRO2":"00 00 00 00 00 00"}]'
3720 +---
3721 +test case: SNMP walk to value - Hex-STRING (multiline) - space on the last line
3722 +in:
3723 + value:
3724 + value_type: ITEM_VALUE_TYPE_STR
3725 + time: 2017-10-29 03:15:00 +03:00
3726 + data: |
3727 + .1.3.6.1.1 = Hex-STRING: 71 71
3728 + AA BB CC
3729 + DD
3730 + .1.3.6.1.2 = Hex-STRING: D1 91
3731 + .1.3.6.2.1 = Hex-STRING: 00 00 00 00 00 00
3732 + .1.3.6.2.2 = Hex-STRING: EA 9A 85 11 22 33
3733 + step:
3734 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3735 + params: |-
3736 + MACRO1
3737 + .1.3.6.1
3738 + 0
3739 + MACRO2
3740 + .1.3.6.2
3741 + 0
3742 +out:
3743 + return: SUCCEED
3744 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"D1 91","MACRO2":"EA 9A 85 11 22 33"},{"{#SNMPINDEX}":"1","MACRO1":"71 71 AA BB CC DD","MACRO2":"00 00 00 00 00 00"}]'
3745 +---
3746 +test case: SNMP walk to JSON - BITS - unchanged
3747 +in:
3748 + value:
3749 + value_type: ITEM_VALUE_TYPE_STR
3750 + time: 2017-10-29 03:15:00 +03:00
3751 + data: |
3752 + .1.3.6.1.1 = BITS: 09 08 07
3753 + .1.3.6.1.2 = BITS: 55 66
3754 + .1.3.6.2.1 = BITS: 01 02
3755 + .1.3.6.2.2 = BITS: 99 10 20
3756 + step:
3757 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3758 + params: |-
3759 + MACRO1
3760 + .1.3.6.1
3761 + 0
3762 + MACRO2
3763 + .1.3.6.2
3764 + 0
3765 +out:
3766 + return: SUCCEED
3767 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"55 66","MACRO2":"99 10 20"},{"{#SNMPINDEX}":"1","MACRO1":"09 08 07","MACRO2":"01 02"}]'
3768 +---
3769 +test case: SNMP walk to JSON - BITS - unchanged
3770 +in:
3771 + value:
3772 + value_type: ITEM_VALUE_TYPE_STR
3773 + time: 2017-10-29 03:15:00 +03:00
3774 + data: |
3775 + .1.3.6.1.1 = BITS: 09 08 07
3776 + .1.3.6.1.2 = BITS: 55 66
3777 + .1.3.6.2.1 = BITS: 01 02
3778 + .1.3.6.2.2 = BITS: 99 10 20
3779 + step:
3780 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3781 + params: |-
3782 + MACRO1
3783 + .1.3.6.1
3784 + 3
3785 + MACRO2
3786 + .1.3.6.2
3787 + 3
3788 +out:
3789 + return: SUCCEED
3790 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"26197","MACRO2":"2101401"},{"{#SNMPINDEX}":"1","MACRO1":"460809","MACRO2":"513"}]'
3791 +---
3792 +test case: SNMP walk to JSON - suppressed output of chosen oids that are longer than prefix 1 - prefix without dot
3793 +in:
3794 + value:
3795 + value_type: ITEM_VALUE_TYPE_STR
3796 + time: 2017-10-29 03:15:00 +03:00
3797 + data: |
3798 + .1.3.6.1.1 = STRING: "xxx"
3799 + .1.3.6.1.2 = STRING: "yyy"
3800 + .1.3.6.10.1 = STRING: "no"
3801 + .1.3.6.11.2 = STRING: "no"
3802 + .1.3.6.2.1 = STRING: "aaa"
3803 + .1.3.6.2.2 = STRING: "bbb"
3804 + .1.3.6.20.1 = STRING: "no"
3805 + .1.3.6.20.2 = STRING: "no"
3806 + step:
3807 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3808 + params: |-
3809 + MACRO1
3810 + 1.3.6.1
3811 + 0
3812 + MACRO2
3813 + 1.3.6.2
3814 + 0
3815 +out:
3816 + return: SUCCEED
3817 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"yyy","MACRO2":"bbb"},{"{#SNMPINDEX}":"1","MACRO1":"xxx","MACRO2":"aaa"}]'
3818 +---
3819 +test case: SNMP walk to JSON - suppressed output of chosen oids that are longer than prefix 1
3820 +in:
3821 + value:
3822 + value_type: ITEM_VALUE_TYPE_STR
3823 + time: 2017-10-29 03:15:00 +03:00
3824 + data: |
3825 + .1.3.6.1.1 = STRING: "xxx"
3826 + .1.3.6.1.2 = STRING: "yyy"
3827 + .1.3.6.10.1 = STRING: "no"
3828 + .1.3.6.11.2 = STRING: "no"
3829 + .1.3.6.2.1 = STRING: "aaa"
3830 + .1.3.6.2.2 = STRING: "bbb"
3831 + .1.3.6.20.1 = STRING: "no"
3832 + .1.3.6.20.2 = STRING: "no"
3833 + step:
3834 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3835 + params: |-
3836 + MACRO1
3837 + .1.3.6.1
3838 + 0
3839 + MACRO2
3840 + .1.3.6.2
3841 + 0
3842 +out:
3843 + return: SUCCEED
3844 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"yyy","MACRO2":"bbb"},{"{#SNMPINDEX}":"1","MACRO1":"xxx","MACRO2":"aaa"}]'
3845 +---
3846 +test case: SNMP walk to JSON - suppressed output of chosen oids that are longer than prefix 2 - prefix without dot
3847 +in:
3848 + value:
3849 + value_type: ITEM_VALUE_TYPE_STR
3850 + time: 2017-10-29 03:15:00 +03:00
3851 + data: |
3852 + .1.3.6.1.1 = STRING: "xxx"
3853 + .1.3.6.1.2 = STRING: "yyy"
3854 + .1.3.6.101.1 = STRING: "no"
3855 + .1.3.6.12123.2 = STRING: "no"
3856 + .1.3.6.2.1 = STRING: "aaa"
3857 + .1.3.6.2.2 = STRING: "bbb"
3858 + .1.3.6.20123.1 = STRING: "no"
3859 + .1.3.6.21123.2 = STRING: "no"
3860 + .1.3.6.3.1 = STRING: "no"
3861 + .1.3.6.333.1 = STRING: "no"
3862 + step:
3863 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3864 + params: |-
3865 + MACRO1
3866 + 1.3.6.1
3867 + 0
3868 + MACRO2
3869 + 1.3.6.2
3870 + 0
3871 +out:
3872 + return: SUCCEED
3873 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"yyy","MACRO2":"bbb"},{"{#SNMPINDEX}":"1","MACRO1":"xxx","MACRO2":"aaa"}]'
3874 +---
3875 +test case: SNMP walk to JSON - suppressed output of chosen oids that are longer than prefix 2
3876 +in:
3877 + value:
3878 + value_type: ITEM_VALUE_TYPE_STR
3879 + time: 2017-10-29 03:15:00 +03:00
3880 + data: |
3881 + .1.3.6.1.1 = STRING: "xxx"
3882 + .1.3.6.1.2 = STRING: "yyy"
3883 + .1.3.6.101.1 = STRING: "no"
3884 + .1.3.6.12123.2 = STRING: "no"
3885 + .1.3.6.2.1 = STRING: "aaa"
3886 + .1.3.6.2.2 = STRING: "bbb"
3887 + .1.3.6.20123.1 = STRING: "no"
3888 + .1.3.6.21123.2 = STRING: "no"
3889 + .1.3.6.3.1 = STRING: "no"
3890 + .1.3.6.333.1 = STRING: "no"
3891 + step:
3892 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3893 + params: |-
3894 + MACRO1
3895 + .1.3.6.1
3896 + 0
3897 + MACRO2
3898 + .1.3.6.2
3899 + 0
3900 +out:
3901 + return: SUCCEED
3902 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"yyy","MACRO2":"bbb"},{"{#SNMPINDEX}":"1","MACRO1":"xxx","MACRO2":"aaa"}]'
3903 +---
3904 +test case: SNMP walk to JSON - suppressed output of chosen oids that are longer than prefix 3 (dot at the end of param)
3905 +in:
3906 + value:
3907 + value_type: ITEM_VALUE_TYPE_STR
3908 + time: 2017-10-29 03:15:00 +03:00
3909 + data: |
3910 + .1.3.6.1.1 = STRING: "xxx"
3911 + .1.3.6.1.2 = STRING: "yyy"
3912 + .1.3.6.101.1 = STRING: "no"
3913 + .1.3.6.12123.2 = STRING: "no"
3914 + .1.3.6.2.1 = STRING: "aaa"
3915 + .1.3.6.2.2 = STRING: "bbb"
3916 + .1.3.6.20123.1 = STRING: "no"
3917 + .1.3.6.21123.2 = STRING: "no"
3918 + .1.3.6.3.1 = STRING: "no"
3919 + .1.3.6.333.1 = STRING: "no"
3920 + step:
3921 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3922 + params: |-
3923 + MACRO1
3924 + .1.3.6.1
3925 + 0
3926 + MACRO2
3927 + .1.3.6.2.
3928 + 0
3929 +out:
3930 + return: SUCCEED
3931 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"yyy","MACRO2":"bbb"},{"{#SNMPINDEX}":"1","MACRO1":"xxx","MACRO2":"aaa"}]'
3932 +---
3933 +test case: SNMP walk to JSON - suppressed output of chosen oids that are longer than prefix 4 (dot at the end of param)
3934 +in:
3935 + value:
3936 + value_type: ITEM_VALUE_TYPE_STR
3937 + time: 2017-10-29 03:15:00 +03:00
3938 + data: |
3939 + .1.3.6.1.1 = STRING: "xxx"
3940 + .1.3.6.1.2 = STRING: "yyy"
3941 + .1.3.6.101.1 = STRING: "no"
3942 + .1.3.6.12123.2 = STRING: "no"
3943 + .1.3.6.2.1 = STRING: "aaa"
3944 + .1.3.6.2.2 = STRING: "bbb"
3945 + .1.3.6.20123.1 = STRING: "no"
3946 + .1.3.6.21123.2 = STRING: "no"
3947 + .1.3.6.3.1 = STRING: "no"
3948 + .1.3.6.333.1 = STRING: "no"
3949 + step:
3950 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3951 + params: |-
3952 + MACRO1
3953 + .1.3.6.1
3954 + 0
3955 + MACRO2
3956 + 1.3.6.2.
3957 + 0
3958 +out:
3959 + return: SUCCEED
3960 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"yyy","MACRO2":"bbb"},{"{#SNMPINDEX}":"1","MACRO1":"xxx","MACRO2":"aaa"}]'
3961 +---
3962 +test case: SNMP walk to JSON - oid to be walked is a leaf and index cannot be chosen
3963 +in:
3964 + value:
3965 + value_type: ITEM_VALUE_TYPE_STR
3966 + time: 2017-10-29 03:15:00 +03:00
3967 + data: |
3968 + .1.3.6.1 = STRING: "xxx"
3969 + .1.3.6.2.1 = STRING: "aaa"
3970 + .1.3.6.2.2 = STRING: "bbb"
3971 + step:
3972 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3973 + params: |-
3974 + MACRO1
3975 + .1.3.6.1
3976 + 0
3977 + MACRO2
3978 + .1.3.6.2
3979 + 0
3980 +out:
3981 + return: SUCCEED
3982 + value: '[{"{#SNMPINDEX}":"2","MACRO2":"bbb"},{"{#SNMPINDEX}":"1","MACRO2":"aaa"}]'
3983 +---
3984 +test case: SNMP walk to JSON - param with dot at the end - 1
3985 +in:
3986 + value:
3987 + value_type: ITEM_VALUE_TYPE_STR
3988 + time: 2017-10-29 03:15:00 +03:00
3989 + data: |
3990 + .1.3.6.2.1 = STRING: "123"
3991 + .1.3.6.2.2 = STRING: "234"
3992 + step:
3993 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
3994 + params: |-
3995 + MACRO1
3996 + .1.3.6.2.
3997 + 0
3998 +out:
3999 + return: SUCCEED
4000 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"234"},{"{#SNMPINDEX}":"1","MACRO1":"123"}]'
4001 +---
4002 +test case: SNMP walk to JSON - param with dot at the end - 2
4003 +in:
4004 + value:
4005 + value_type: ITEM_VALUE_TYPE_STR
4006 + time: 2017-10-29 03:15:00 +03:00
4007 + data: |
4008 + .1.3.6.1.1 = STRING: "xxx"
4009 + .1.3.6.1.2 = STRING: "yyy"
4010 + .1.3.6.2.1 = STRING: "aaa"
4011 + .1.3.6.2.2 = STRING: "bbb"
4012 + step:
4013 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
4014 + params: |-
4015 + MACRO1
4016 + .1.3.6.1.
4017 + 0
4018 + MACRO2
4019 + .1.3.6.2
4020 + 0
4021 +out:
4022 + return: SUCCEED
4023 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"yyy","MACRO2":"bbb"},{"{#SNMPINDEX}":"1","MACRO1":"xxx","MACRO2":"aaa"}]'
4024 +---
4025 +test case: SNMP walk to JSON - param with dot at the end - 3
4026 +in:
4027 + value:
4028 + value_type: ITEM_VALUE_TYPE_STR
4029 + time: 2017-10-29 03:15:00 +03:00
4030 + data: |
4031 + .1.3.6.1.1 = STRING: "xxx"
4032 + .1.3.6.1.2 = STRING: "yyy"
4033 + .1.3.6.2.1 = STRING: "aaa"
4034 + .1.3.6.2.2 = STRING: "bbb"
4035 + step:
4036 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
4037 + params: |-
4038 + MACRO1
4039 + .1.3.6.1.
4040 + 0
4041 + MACRO2
4042 + 1.3.6.2
4043 + 0
4044 +out:
4045 + return: SUCCEED
4046 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"yyy","MACRO2":"bbb"},{"{#SNMPINDEX}":"1","MACRO1":"xxx","MACRO2":"aaa"}]'
4047 +---
4048 +test case: SNMP walk to JSON - param with dot at the end - 4
4049 +in:
4050 + value:
4051 + value_type: ITEM_VALUE_TYPE_STR
4052 + time: 2017-10-29 03:15:00 +03:00
4053 + data: |
4054 + .1.3.6.1.1 = STRING: "xxx"
4055 + .1.3.6.1.2 = STRING: "yyy"
4056 + .1.3.6.2.1 = STRING: "aaa"
4057 + .1.3.6.2.2 = STRING: "bbb"
4058 + step:
4059 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
4060 + params: |-
4061 + MACRO1
4062 + 1.3.6.1.
4063 + 0
4064 + MACRO2
4065 + 1.3.6.2
4066 + 0
4067 +out:
4068 + return: SUCCEED
4069 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"yyy","MACRO2":"bbb"},{"{#SNMPINDEX}":"1","MACRO1":"xxx","MACRO2":"aaa"}]'
4070 +---
4071 +test case: SNMP walk to JSON - invalid params - 1
4072 +in:
4073 + value:
4074 + value_type: ITEM_VALUE_TYPE_STR
4075 + time: 2017-10-29 03:15:00 +03:00
4076 + data: |
4077 + .1.3.6.1.1 = STRING: "xxx"
4078 + .1.3.6.1.2 = STRING: "yyy"
4079 + .1.3.6.2.1 = STRING: "aaa"
4080 + .1.3.6.2.2 = STRING: "bbb"
4081 + step:
4082 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
4083 + params: |-
4084 + MACRO1
4085 +out:
4086 + return: FAIL
4087 +---
4088 +test case: SNMP walk to JSON - invalid params - 2
4089 +in:
4090 + value:
4091 + value_type: ITEM_VALUE_TYPE_STR
4092 + time: 2017-10-29 03:15:00 +03:00
4093 + data: |
4094 + .1.3.6.1.1 = STRING: "xxx"
4095 + .1.3.6.1.2 = STRING: "yyy"
4096 + .1.3.6.2.1 = STRING: "aaa"
4097 + .1.3.6.2.2 = STRING: "bbb"
4098 + step:
4099 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
4100 + params: |-
4101 + MACRO1
4102 + 1.3.6.1.
4103 +out:
4104 + return: FAIL
4105 +---
4106 +test case: SNMP walk to JSON - invalid params - 3
4107 +in:
4108 + value:
4109 + value_type: ITEM_VALUE_TYPE_STR
4110 + time: 2017-10-29 03:15:00 +03:00
4111 + data: |
4112 + .1.3.6.1.1 = STRING: "xxx"
4113 + .1.3.6.1.2 = STRING: "yyy"
4114 + .1.3.6.2.1 = STRING: "aaa"
4115 + .1.3.6.2.2 = STRING: "bbb"
4116 + step:
4117 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
4118 + params: |-
4119 + MACRO1
4120 + 1.3.6.1.
4121 + 0
4122 + MACRO2
4123 +out:
4124 + return: FAIL
4125 +---
4126 +test case: SNMP walk to JSON - invalid params - 4
4127 +in:
4128 + value:
4129 + value_type: ITEM_VALUE_TYPE_STR
4130 + time: 2017-10-29 03:15:00 +03:00
4131 + data: |
4132 + .1.3.6.1.1 = STRING: "xxx"
4133 + .1.3.6.1.2 = STRING: "yyy"
4134 + .1.3.6.2.1 = STRING: "aaa"
4135 + .1.3.6.2.2 = STRING: "bbb"
4136 + step:
4137 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
4138 + params: |-
4139 + MACRO1
4140 + 1.3.6.1.
4141 + 0
4142 + MACRO2
4143 + 1.3.6.2
4144 +out:
4145 + return: FAIL
4146 +---
4147 +test case: SNMP walk to JSON - invalid params - 5
4148 +in:
4149 + value:
4150 + value_type: ITEM_VALUE_TYPE_STR
4151 + time: 2017-10-29 03:15:00 +03:00
4152 + data: |
4153 + .1.3.6.1.1 = STRING: "xxx"
4154 + .1.3.6.1.2 = STRING: "yyy"
4155 + .1.3.6.2.1 = STRING: "aaa"
4156 + .1.3.6.2.2 = STRING: "bbb"
4157 + step:
4158 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
4159 + params: |-
4160 + MACRO1
4161 + 1.3.6.1.
4162 + 0
4163 + MACRO2
4164 + 1.3.6.2
4165 + 1
4166 + X
4167 +out:
4168 + return: FAIL
4169 +---
4170 +test case: SNMP walk to JSON - wrong type warning
4171 +in:
4172 + value:
4173 + value_type: ITEM_VALUE_TYPE_STR
4174 + time: 2017-10-29 03:15:00 +03:00
4175 + data: |
4176 + .1.3.6.1.1 = STRING: "xxx"
4177 + .1.3.6.1.2 = STRING: "yyy"
4178 + .1.3.6.2.1 = STRING: "aaa"
4179 + .1.3.6.2.2 = Wrong Type (should be Gauge32 or Unsigned32): Counter32: 123
4180 + step:
4181 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
4182 + params: |-
4183 + MACRO1
4184 + 1.3.6.1.
4185 + 0
4186 + MACRO2
4187 + 1.3.6.2
4188 + 0
4189 +out:
4190 + return: SUCCEED
4191 + value: '[{"{#SNMPINDEX}":"2","MACRO1":"yyy","MACRO2":"123"},{"{#SNMPINDEX}":"1","MACRO1":"xxx","MACRO2":"aaa"}]'
4192 +---
4193 +test case: SNMP walk to JSON - wrong type warning invalid format
4194 +in:
4195 + value:
4196 + value_type: ITEM_VALUE_TYPE_STR
4197 + time: 2017-10-29 03:15:00 +03:00
4198 + data: |
4199 + .1.3.6.1.1 = STRING: "xxx"
4200 + .1.3.6.1.2 = STRING: "yyy"
4201 + .1.3.6.2.1 = STRING: "aaa"
4202 + .1.3.6.2.2 = Wrong Type (should be Gauge32 or Unsigned32): Counter32 123
4203 + .1.3.6.2.3 = STRING: "bbb"
4204 + step:
4205 + type: ZBX_PREPROC_SNMP_WALK_TO_JSON
4206 + params: |-
4207 + MACRO1
4208 + 1.3.6.1.
4209 + 0
4210 + MACRO2
4211 + 1.3.6.2
4212 + 0
4213 +out:
4214 + return: FAIL
4215 +---
4216 +test case: HMAC in JavaScript - Invalid algorithm
4217 +in:
4218 + value:
4219 + value_type: ITEM_VALUE_TYPE_STR
4220 + time: 2017-10-29 03:15:00 +03:00
4221 + data: "k"
4222 + step:
4223 + type: ZBX_PREPROC_SCRIPT
4224 + params: "return hmac('xxx', 'xxx', 'yyy');"
4225 +out:
4226 + return: FAIL
4227 +---
4228 +test case: HMAC in JavaScript - MD5
4229 +in:
4230 + value:
4231 + value_type: ITEM_VALUE_TYPE_STR
4232 + time: 2017-10-29 03:15:00 +03:00
4233 + data: "k"
4234 + step:
4235 + type: ZBX_PREPROC_SCRIPT
4236 + params: "return hmac('md5', 'xxx', 'yyy');"
4237 +out:
4238 + return: SUCCEED
4239 + value: 'd8e8efd954dabfdbfd69398d92ad62d1'
4240 +---
4241 +test case: HMAC in JavaScript - SHA-256
4242 +in:
4243 + value:
4244 + value_type: ITEM_VALUE_TYPE_STR
4245 + time: 2017-10-29 03:15:00 +03:00
4246 + data: "k"
4247 + step:
4248 + type: ZBX_PREPROC_SCRIPT
4249 + params: "return hmac('sha256', 'xxx', 'yyy');"
4250 +out:
4251 + return: SUCCEED
4252 + value: '97f50eed1079c9460f2bb9ddf7dc2e4258471f301ad2c1f14ad50144c4de4303'
4253 +---
4254 +test case: HMAC in JavaScript - Invalid key
4255 +in:
4256 + value:
4257 + value_type: ITEM_VALUE_TYPE_STR
4258 + time: 2017-10-29 03:15:00 +03:00
4259 + data: "k"
4260 + step:
4261 + type: ZBX_PREPROC_SCRIPT
4262 + params: "return hmac('sha256', null, 'yyy');"
4263 +out:
4264 + return: FAIL
4265 +---
4266 +test case: HMAC in JavaScript - Missing key
4267 +in:
4268 + value:
4269 + value_type: ITEM_VALUE_TYPE_STR
4270 + time: 2017-10-29 03:15:00 +03:00
4271 + data: "k"
4272 + step:
4273 + type: ZBX_PREPROC_SCRIPT
4274 + params: "return hmac('sha256');"
4275 +out:
4276 + return: FAIL
4277 +---
4278 +test case: HMAC in JavaScript - Invalid data
4279 +in:
4280 + value:
4281 + value_type: ITEM_VALUE_TYPE_STR
4282 + time: 2017-10-29 03:15:00 +03:00
4283 + data: "k"
4284 + step:
4285 + type: ZBX_PREPROC_SCRIPT
4286 + params: "return hmac('sha256', 'xxx', null);"
4287 +out:
4288 + return: FAIL
4289 +---
4290 +test case: HMAC in JavaScript - Missing data
4291 +in:
4292 + value:
4293 + value_type: ITEM_VALUE_TYPE_STR
4294 + time: 2017-10-29 03:15:00 +03:00
4295 + data: "k"
4296 + step:
4297 + type: ZBX_PREPROC_SCRIPT
4298 + params: "return hmac('sha256', 'xxx');"
4299 +out:
4300 + return: FAIL
4301 +---
4302 +test case: RS256 in JavaScript - single line pkey w/o newlines
4303 +in:
4304 + encryption_required: 1
4305 + value:
4306 + value_type: ITEM_VALUE_TYPE_STR
4307 + time: 2017-10-29 03:15:00 +03:00
4308 + data: "k"
4309 + step:
4310 + type: ZBX_PREPROC_SCRIPT
4311 + params: "return sign('sha256', '-----BEGIN RSA PRIVATE KEY-----MIICXgIBAAKBgQCsDnSry3s269UFKpYvptshcmKZ4ZXxqj6771qr/oEOtRa/o+8JmFtucELQg7HrzRUwxG9htmCuSZ+eOcMQ7vJOrgS+1Ol01P+Ol/PdFFeT5utqzG2NaYSIs/oLAaTkiDUkDBhnG6Ns0xWgJlRomR2W4RI8Os/zKh9eccRaZorjQwIDAQABAoGBAKT300vo9QHqyruCYq/bxx1hpEKw9ejZd/8P9xsUEb/9R4uF7iqAB6JzRszQiKZzY473uWexyy8w7jdyYKL6yB2vCMLdy68nxoIYcjSrk3F/kC47ga7NKCS8T21OFarxhRzxqgF6rg/mfrgeT7nxD1EM0vk6mCSmftbaVZE7M/KRAkEA4FDqvtjen9RrROe2Al23h/1gDqgL9rJ5SzSbwwYmzRA/XYlfH7k9RPGQahzq15IHrdxN04Ov2dVRyybbR0A1awJBAMRb3+P1yKzIkkTGYE2ZWctBcDCnzwwaKKZuWX7OfA+ZPNUm+L6Owg4gztHIAA6DyizXxaHhh8qcL0A6nx7hJ4kCQQCjrces3LiNrcVcMSuZTGMYYuVNrIeWSqLBIrEpvHphlaJ3ET8M+fDgNtgm5dTi2daqoZ9UYLnXXJXjhAXrVsnjAkEAnn8ahcNLLEzp5CHMclaqKGq1yFBry/UBgvXnv3ekpGKou9UtS4OoGAdbafHqOc0fUHsKx9Rn65+OdGXdMuvDIQJAJ+L5i2iRo4TuNpHxMCPN/3bamMlDktIfPlVLbuEcinG1XZ9zs9Y1J738iCF2d56wlApbYGqw4vp9+CdX62LoMw==-----END RSA PRIVATE KEY-----', 'test');"
4312 +out:
4313 + return: SUCCEED
4314 + value: '272f1ef9012b81365bd29f929cfd63dc8a0bafbfaf1fb0bb1a6982d04260ee888573e7591e0e8076a3d8eb9da7a85bd57e61b4504abcfd62815f88cb0c6955fe4bab6d0b4361182182e316a96c83d5ab9ac8e314b5f11eabcefbeb520ec41b04b52323aedcf53b630a232f0a63c66a4cc4411241160ccb30409525c9c51a06d5'
4315 +---
4316 +test case: RS256 in JavaScript - single line pkey with newlines
4317 +in:
4318 + encryption_required: 1
4319 + value:
4320 + value_type: ITEM_VALUE_TYPE_STR
4321 + time: 2017-10-29 03:15:00 +03:00
4322 + data: "a"
4323 + step:
4324 + type: ZBX_PREPROC_SCRIPT
4325 + params: |
4326 + var pkey = '-----BEGIN RSA PRIVATE KEY-----\nMIICXgIBAAKBgQCsDnSry3s269UFKpYvptshcmKZ4ZXxqj6771qr/oEOtRa/o+8J\nmFtucELQg7HrzRUwxG9htmCuSZ+eOcMQ7vJOrgS+1Ol01P+Ol/PdFFeT5utqzG2N\naYSIs/oLAaTkiDUkDBhnG6Ns0xWgJlRomR2W4RI8Os/zKh9eccRaZorjQwIDAQAB\nAoGBAKT300vo9QHqyruCYq/bxx1hpEKw9ejZd/8P9xsUEb/9R4uF7iqAB6JzRszQ\niKZzY473uWexyy8w7jdyYKL6yB2vCMLdy68nxoIYcjSrk3F/kC47ga7NKCS8T21O\nFarxhRzxqgF6rg/mfrgeT7nxD1EM0vk6mCSmftbaVZE7M/KRAkEA4FDqvtjen9Rr\nROe2Al23h/1gDqgL9rJ5SzSbwwYmzRA/XYlfH7k9RPGQahzq15IHrdxN04Ov2dVR\nyybbR0A1awJBAMRb3+P1yKzIkkTGYE2ZWctBcDCnzwwaKKZuWX7OfA+ZPNUm+L6O\nwg4gztHIAA6DyizXxaHhh8qcL0A6nx7hJ4kCQQCjrces3LiNrcVcMSuZTGMYYuVN\nrIeWSqLBIrEpvHphlaJ3ET8M+fDgNtgm5dTi2daqoZ9UYLnXXJXjhAXrVsnjAkEA\nnn8ahcNLLEzp5CHMclaqKGq1yFBry/UBgvXnv3ekpGKou9UtS4OoGAdbafHqOc0f\nUHsKx9Rn65+OdGXdMuvDIQJAJ+L5i2iRo4TuNpHxMCPN/3bamMlDktIfPlVLbuEc\ninG1XZ9zs9Y1J738iCF2d56wlApbYGqw4vp9+CdX62LoMw==\n-----END RSA PRIVATE KEY-----';
4327 + return sign('sha256', pkey, 'test');
4328 +out:
4329 + return: SUCCEED
4330 + value: "272f1ef9012b81365bd29f929cfd63dc8a0bafbfaf1fb0bb1a6982d04260ee888573e7591e0e8076a3d8eb9da7a85bd57e61b4504abcfd62815f88cb0c6955fe4bab6d0b4361182182e316a96c83d5ab9ac8e314b5f11eabcefbeb520ec41b04b52323aedcf53b630a232f0a63c66a4cc4411241160ccb30409525c9c51a06d5"
4331 +---
4332 +test case: RS256 in JavaScript - single line pkey with missing newline before END block
4333 +in:
4334 + encryption_required: 1
4335 + value:
4336 + value_type: ITEM_VALUE_TYPE_STR
4337 + time: 2017-10-29 03:15:00 +03:00
4338 + data: "a"
4339 + step:
4340 + type: ZBX_PREPROC_SCRIPT
4341 + params: |
4342 + var pkey = '-----BEGIN RSA PRIVATE KEY-----\nMIICXgIBAAKBgQCsDnSry3s269UFKpYvptshcmKZ4ZXxqj6771qr/oEOtRa/o+8J\nmFtucELQg7HrzRUwxG9htmCuSZ+eOcMQ7vJOrgS+1Ol01P+Ol/PdFFeT5utqzG2N\naYSIs/oLAaTkiDUkDBhnG6Ns0xWgJlRomR2W4RI8Os/zKh9eccRaZorjQwIDAQAB\nAoGBAKT300vo9QHqyruCYq/bxx1hpEKw9ejZd/8P9xsUEb/9R4uF7iqAB6JzRszQ\niKZzY473uWexyy8w7jdyYKL6yB2vCMLdy68nxoIYcjSrk3F/kC47ga7NKCS8T21O\nFarxhRzxqgF6rg/mfrgeT7nxD1EM0vk6mCSmftbaVZE7M/KRAkEA4FDqvtjen9Rr\nROe2Al23h/1gDqgL9rJ5SzSbwwYmzRA/XYlfH7k9RPGQahzq15IHrdxN04Ov2dVR\nyybbR0A1awJBAMRb3+P1yKzIkkTGYE2ZWctBcDCnzwwaKKZuWX7OfA+ZPNUm+L6O\nwg4gztHIAA6DyizXxaHhh8qcL0A6nx7hJ4kCQQCjrces3LiNrcVcMSuZTGMYYuVN\nrIeWSqLBIrEpvHphlaJ3ET8M+fDgNtgm5dTi2daqoZ9UYLnXXJXjhAXrVsnjAkEA\nnn8ahcNLLEzp5CHMclaqKGq1yFBry/UBgvXnv3ekpGKou9UtS4OoGAdbafHqOc0f\nUHsKx9Rn65+OdGXdMuvDIQJAJ+L5i2iRo4TuNpHxMCPN/3bamMlDktIfPlVLbuEc\ninG1XZ9zs9Y1J738iCF2d56wlApbYGqw4vp9+CdX62LoMw==-----END RSA PRIVATE KEY-----';
4343 + return sign('sha256', pkey, 'test');
4344 +out:
4345 + return: SUCCEED
4346 + value: "272f1ef9012b81365bd29f929cfd63dc8a0bafbfaf1fb0bb1a6982d04260ee888573e7591e0e8076a3d8eb9da7a85bd57e61b4504abcfd62815f88cb0c6955fe4bab6d0b4361182182e316a96c83d5ab9ac8e314b5f11eabcefbeb520ec41b04b52323aedcf53b630a232f0a63c66a4cc4411241160ccb30409525c9c51a06d5"
4347 +---
4348 +test case: RS256 in JavaScript - single line pkey with missing newline before BEGIN block
4349 +in:
4350 + encryption_required: 1
4351 + value:
4352 + value_type: ITEM_VALUE_TYPE_STR
4353 + time: 2017-10-29 03:15:00 +03:00
4354 + data: "a"
4355 + step:
4356 + type: ZBX_PREPROC_SCRIPT
4357 + params: |
4358 + var pkey = '-----BEGIN RSA PRIVATE KEY-----MIICXgIBAAKBgQCsDnSry3s269UFKpYvptshcmKZ4ZXxqj6771qr/oEOtRa/o+8J\nmFtucELQg7HrzRUwxG9htmCuSZ+eOcMQ7vJOrgS+1Ol01P+Ol/PdFFeT5utqzG2N\naYSIs/oLAaTkiDUkDBhnG6Ns0xWgJlRomR2W4RI8Os/zKh9eccRaZorjQwIDAQAB\nAoGBAKT300vo9QHqyruCYq/bxx1hpEKw9ejZd/8P9xsUEb/9R4uF7iqAB6JzRszQ\niKZzY473uWexyy8w7jdyYKL6yB2vCMLdy68nxoIYcjSrk3F/kC47ga7NKCS8T21O\nFarxhRzxqgF6rg/mfrgeT7nxD1EM0vk6mCSmftbaVZE7M/KRAkEA4FDqvtjen9Rr\nROe2Al23h/1gDqgL9rJ5SzSbwwYmzRA/XYlfH7k9RPGQahzq15IHrdxN04Ov2dVR\nyybbR0A1awJBAMRb3+P1yKzIkkTGYE2ZWctBcDCnzwwaKKZuWX7OfA+ZPNUm+L6O\nwg4gztHIAA6DyizXxaHhh8qcL0A6nx7hJ4kCQQCjrces3LiNrcVcMSuZTGMYYuVN\nrIeWSqLBIrEpvHphlaJ3ET8M+fDgNtgm5dTi2daqoZ9UYLnXXJXjhAXrVsnjAkEA\nnn8ahcNLLEzp5CHMclaqKGq1yFBry/UBgvXnv3ekpGKou9UtS4OoGAdbafHqOc0f\nUHsKx9Rn65+OdGXdMuvDIQJAJ+L5i2iRo4TuNpHxMCPN/3bamMlDktIfPlVLbuEc\ninG1XZ9zs9Y1J738iCF2d56wlApbYGqw4vp9+CdX62LoMw==\n-----END RSA PRIVATE KEY-----';
4359 + return sign('sha256', pkey, 'test');
4360 +out:
4361 + return: SUCCEED
4362 + value: "272f1ef9012b81365bd29f929cfd63dc8a0bafbfaf1fb0bb1a6982d04260ee888573e7591e0e8076a3d8eb9da7a85bd57e61b4504abcfd62815f88cb0c6955fe4bab6d0b4361182182e316a96c83d5ab9ac8e314b5f11eabcefbeb520ec41b04b52323aedcf53b630a232f0a63c66a4cc4411241160ccb30409525c9c51a06d5"
4363 +---
4364 +test case: RS256 in JavaScript - spaces instead of newlines
4365 +in:
4366 + encryption_required: 1
4367 + value:
4368 + value_type: ITEM_VALUE_TYPE_STR
4369 + time: 2017-10-29 03:15:00 +03:00
4370 + data: "a"
4371 + step:
4372 + type: ZBX_PREPROC_SCRIPT
4373 + params: |
4374 + var pkey = '-----BEGIN RSA PRIVATE KEY----- MIICXgIBAAKBgQCsDnSry3s269UFKpYvptshcmKZ4ZXxqj6771qr/oEOtRa/o+8J mFtucELQg7HrzRUwxG9htmCuSZ+eOcMQ7vJOrgS+1Ol01P+Ol/PdFFeT5utqzG2N aYSIs/oLAaTkiDUkDBhnG6Ns0xWgJlRomR2W4RI8Os/zKh9eccRaZorjQwIDAQAB AoGBAKT300vo9QHqyruCYq/bxx1hpEKw9ejZd/8P9xsUEb/9R4uF7iqAB6JzRszQ iKZzY473uWexyy8w7jdyYKL6yB2vCMLdy68nxoIYcjSrk3F/kC47ga7NKCS8T21O FarxhRzxqgF6rg/mfrgeT7nxD1EM0vk6mCSmftbaVZE7M/KRAkEA4FDqvtjen9Rr ROe2Al23h/1gDqgL9rJ5SzSbwwYmzRA/XYlfH7k9RPGQahzq15IHrdxN04Ov2dVR yybbR0A1awJBAMRb3+P1yKzIkkTGYE2ZWctBcDCnzwwaKKZuWX7OfA+ZPNUm+L6O wg4gztHIAA6DyizXxaHhh8qcL0A6nx7hJ4kCQQCjrces3LiNrcVcMSuZTGMYYuVN rIeWSqLBIrEpvHphlaJ3ET8M+fDgNtgm5dTi2daqoZ9UYLnXXJXjhAXrVsnjAkEA nn8ahcNLLEzp5CHMclaqKGq1yFBry/UBgvXnv3ekpGKou9UtS4OoGAdbafHqOc0f UHsKx9Rn65+OdGXdMuvDIQJAJ+L5i2iRo4TuNpHxMCPN/3bamMlDktIfPlVLbuEc inG1XZ9zs9Y1J738iCF2d56wlApbYGqw4vp9+CdX62LoMw== -----END RSA PRIVATE KEY-----';
4375 + return sign('sha256', pkey, 'test');
4376 +out:
4377 + return: SUCCEED
4378 + value: "272f1ef9012b81365bd29f929cfd63dc8a0bafbfaf1fb0bb1a6982d04260ee888573e7591e0e8076a3d8eb9da7a85bd57e61b4504abcfd62815f88cb0c6955fe4bab6d0b4361182182e316a96c83d5ab9ac8e314b5f11eabcefbeb520ec41b04b52323aedcf53b630a232f0a63c66a4cc4411241160ccb30409525c9c51a06d5"
4379 +---
4380 +test case: RS256 in JavaScript - PKCS#8
4381 +in:
4382 + encryption_required: 1
4383 + value:
4384 + value_type: ITEM_VALUE_TYPE_STR
4385 + time: 2017-10-29 03:15:00 +03:00
4386 + data: "a"
4387 + step:
4388 + type: ZBX_PREPROC_SCRIPT
4389 + params: |
4390 + var pkey = '-----BEGIN PRIVATE KEY-----MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBAKwOdKvLezbr1QUqli+m2yFyYpnhlfGqPrvvWqv+gQ61Fr+j7wmYW25wQtCDsevNFTDEb2G2YK5Jn545wxDu8k6uBL7U6XTU/46X890UV5Pm62rMbY1phIiz+gsBpOSINSQMGGcbo2zTFaAmVGiZHZbhEjw6z/MqH15xxFpmiuNDAgMBAAECgYEApPfTS+j1AerKu4Jir9vHHWGkQrD16Nl3/w/3GxQRv/1Hi4XuKoAHonNGzNCIpnNjjve5Z7HLLzDuN3JgovrIHa8Iwt3LryfGghhyNKuTcX+QLjuBrs0oJLxPbU4VqvGFHPGqAXquD+Z+uB5PufEPUQzS+TqYJKZ+1tpVkTsz8pECQQDgUOq+2N6f1GtE57YCXbeH/WAOqAv2snlLNJvDBibNED9diV8fuT1E8ZBqHOrXkget3E3Tg6/Z1VHLJttHQDVrAkEAxFvf4/XIrMiSRMZgTZlZy0FwMKfPDBoopm5Zfs58D5k81Sb4vo7CDiDO0cgADoPKLNfFoeGHypwvQDqfHuEniQJBAKOtx6zcuI2txVwxK5lMYxhi5U2sh5ZKosEisSm8emGVoncRPwz58OA22Cbl1OLZ1qqhn1RguddcleOEBetWyeMCQQCefxqFw0ssTOnkIcxyVqooarXIUGvL9QGC9ee/d6SkYqi71S1Lg6gYB1tp8eo5zR9QewrH1Gfrn450Zd0y68MhAkAn4vmLaJGjhO42kfEwI83/dtqYyUOS0h8+VUtu4RyKcbVdn3Oz1jUnvfyIIXZ3nrCUCltgarDi+n34J1frYugz-----END PRIVATE KEY-----';
4391 + return sign('sha256', pkey, 'test');
4392 +out:
4393 + return: SUCCEED
4394 + value: "272f1ef9012b81365bd29f929cfd63dc8a0bafbfaf1fb0bb1a6982d04260ee888573e7591e0e8076a3d8eb9da7a85bd57e61b4504abcfd62815f88cb0c6955fe4bab6d0b4361182182e316a96c83d5ab9ac8e314b5f11eabcefbeb520ec41b04b52323aedcf53b630a232f0a63c66a4cc4411241160ccb30409525c9c51a06d5"
4395 +---
4396 +test case: RS256 in JavaScript - data as Uint8Array
4397 +in:
4398 + encryption_required: 1
4399 + value:
4400 + value_type: ITEM_VALUE_TYPE_STR
4401 + time: 2017-10-29 03:15:00 +03:00
4402 + data: "a"
4403 + step:
4404 + type: ZBX_PREPROC_SCRIPT
4405 + params: |
4406 + var pkey = '-----BEGIN PRIVATE KEY-----MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBAKwOdKvLezbr1QUqli+m2yFyYpnhlfGqPrvvWqv+gQ61Fr+j7wmYW25wQtCDsevNFTDEb2G2YK5Jn545wxDu8k6uBL7U6XTU/46X890UV5Pm62rMbY1phIiz+gsBpOSINSQMGGcbo2zTFaAmVGiZHZbhEjw6z/MqH15xxFpmiuNDAgMBAAECgYEApPfTS+j1AerKu4Jir9vHHWGkQrD16Nl3/w/3GxQRv/1Hi4XuKoAHonNGzNCIpnNjjve5Z7HLLzDuN3JgovrIHa8Iwt3LryfGghhyNKuTcX+QLjuBrs0oJLxPbU4VqvGFHPGqAXquD+Z+uB5PufEPUQzS+TqYJKZ+1tpVkTsz8pECQQDgUOq+2N6f1GtE57YCXbeH/WAOqAv2snlLNJvDBibNED9diV8fuT1E8ZBqHOrXkget3E3Tg6/Z1VHLJttHQDVrAkEAxFvf4/XIrMiSRMZgTZlZy0FwMKfPDBoopm5Zfs58D5k81Sb4vo7CDiDO0cgADoPKLNfFoeGHypwvQDqfHuEniQJBAKOtx6zcuI2txVwxK5lMYxhi5U2sh5ZKosEisSm8emGVoncRPwz58OA22Cbl1OLZ1qqhn1RguddcleOEBetWyeMCQQCefxqFw0ssTOnkIcxyVqooarXIUGvL9QGC9ee/d6SkYqi71S1Lg6gYB1tp8eo5zR9QewrH1Gfrn450Zd0y68MhAkAn4vmLaJGjhO42kfEwI83/dtqYyUOS0h8+VUtu4RyKcbVdn3Oz1jUnvfyIIXZ3nrCUCltgarDi+n34J1frYugz-----END PRIVATE KEY-----';
4407 + return sign('sha256', pkey, new Uint8Array([0x74, 0x65, 0x73, 0x74]));
4408 +out:
4409 + return: SUCCEED
4410 + value: "272f1ef9012b81365bd29f929cfd63dc8a0bafbfaf1fb0bb1a6982d04260ee888573e7591e0e8076a3d8eb9da7a85bd57e61b4504abcfd62815f88cb0c6955fe4bab6d0b4361182182e316a96c83d5ab9ac8e314b5f11eabcefbeb520ec41b04b52323aedcf53b630a232f0a63c66a4cc4411241160ccb30409525c9c51a06d5"
4411 +---
4412 +test case: RS256 in JavaScript - Invalid algorithm
4413 +in:
4414 + encryption_required: 1
4415 + value:
4416 + value_type: ITEM_VALUE_TYPE_STR
4417 + time: 2017-10-29 03:15:00 +03:00
4418 + data: "a"
4419 + step:
4420 + type: ZBX_PREPROC_SCRIPT
4421 + params: "return sign('q', pkey, 'test');"
4422 +out:
4423 + return: FAIL
4424 +---
4425 +test case: RS256 in JavaScript - Invalid pkey
4426 +in:
4427 + encryption_required: 1
4428 + value:
4429 + value_type: ITEM_VALUE_TYPE_STR
4430 + time: 2017-10-29 03:15:00 +03:00
4431 + data: "a"
4432 + step:
4433 + type: ZBX_PREPROC_SCRIPT
4434 + params: "return sign('q', 'invalid', 'test');"
4435 +out:
4436 + return: FAIL
4437 +---
4438 +test case: RS256 in JavaScript - Missing pkey
4439 +in:
4440 + encryption_required: 1
4441 + value:
4442 + value_type: ITEM_VALUE_TYPE_STR
4443 + time: 2017-10-29 03:15:00 +03:00
4444 + data: "a"
4445 + step:
4446 + type: ZBX_PREPROC_SCRIPT
4447 + params: "return sign('q');"
4448 +out:
4449 + return: FAIL
4450 +---
4451 +test case: RS256 in JavaScript - Invalid data
4452 +in:
4453 + encryption_required: 1
4454 + value:
4455 + value_type: ITEM_VALUE_TYPE_STR
4456 + time: 2017-10-29 03:15:00 +03:00
4457 + data: "a"
4458 + step:
4459 + type: ZBX_PREPROC_SCRIPT
4460 + params: "return sign('q', 'invalid', '');"
4461 +out:
4462 + return: FAIL
4463 +---
4464 +test case: RS256 in JavaScript - Missing data
4465 +in:
4466 + encryption_required: 1
4467 + value:
4468 + value_type: ITEM_VALUE_TYPE_STR
4469 + time: 2017-10-29 03:15:00 +03:00
4470 + data: "a"
4471 + step:
4472 + type: ZBX_PREPROC_SCRIPT
4473 + params: "return sign('q', 'pkey');"
4474 +out:
4475 + return: FAIL
4476 +---
4477 +test case: Check for not supported without parameter (false)
4478 +in:
4479 + value:
4480 + value_type: ITEM_VALUE_TYPE_STR
4481 + time: 2017-10-29 03:15:00 +03:00
4482 + data: "no error"
4483 + step:
4484 + type: ZBX_PREPROC_VALIDATE_NOT_SUPPORTED
4485 + params: ""
4486 + error_handler: ZBX_PREPROC_FAIL_SET_VALUE
4487 + error_handler_params: error_value
4488 +out:
4489 + return: SUCCEED
4490 + value: "no error"
4491 +---
4492 +test case: Check for not supported without parameter
4493 +in:
4494 + error:
4495 + time: 2017-10-29 03:15:00 +03:00
4496 + data: "error any"
4497 + step:
4498 + type: ZBX_PREPROC_VALIDATE_NOT_SUPPORTED
4499 + params: ""
4500 + error_handler: ZBX_PREPROC_FAIL_SET_VALUE
4501 + error_handler_params: error_value
4502 +out:
4503 + return: SUCCEED
4504 + value: error_value
4505 +---
4506 +test case: Check for not supported with parameter (false)
4507 +in:
4508 + error:
4509 + time: 2017-10-29 03:15:00 +03:00
4510 + data: "error any"
4511 + step:
4512 + type: ZBX_PREPROC_VALIDATE_NOT_SUPPORTED
4513 + params: "0\n123"
4514 + error_handler: ZBX_PREPROC_FAIL_SET_ERROR
4515 + error_handler_params: custom error
4516 +out:
4517 + return: SUCCEED
4518 + value: "error any"
4519 +---
4520 +test case: Check for not supported with parameter
4521 +in:
4522 + error:
4523 + time: 2017-10-29 03:15:00 +03:00
4524 + data: "error 123"
4525 + step:
4526 + type: ZBX_PREPROC_VALIDATE_NOT_SUPPORTED
4527 + params: "0\n123"
4528 + error_handler: ZBX_PREPROC_FAIL_SET_ERROR
4529 + error_handler_params: custom error
4530 +out:
4531 + return: FAIL
4532 + error: custom error
4533 +---
4534 +test case: Check for not supported with parameter and capturing group
4535 +in:
4536 + error:
4537 + time: 2017-10-29 03:15:00 +03:00
4538 + data: "error 123"
4539 + step:
4540 + type: ZBX_PREPROC_VALIDATE_NOT_SUPPORTED
4541 + params: "0\n123"
4542 + error_handler: ZBX_PREPROC_FAIL_SET_ERROR
4543 + error_handler_params: custom error \0
4544 +out:
4545 + return: FAIL
4546 + error: custom error 123
4547 +---
4548 +test case: btoa in JavaScript - binary
4549 +in:
4550 + value:
4551 + value_type: ITEM_VALUE_TYPE_STR
4552 + time: 2017-10-29 03:15:00 +03:00
4553 + data: "k"
4554 + step:
4555 + type: ZBX_PREPROC_SCRIPT
4556 + params: "return btoa(new Uint8Array([49, 0, 51]));"
4557 +out:
4558 + return: SUCCEED
4559 + value: 'MQAz'
4560 +---
4561 +test case: Crash by accessing internal object pointer in JavaScript
4562 +in:
4563 + value:
4564 + value_type: ITEM_VALUE_TYPE_STR
4565 + time: 2017-10-29 03:15:00 +03:00
4566 + data: "a"
4567 + step:
4568 + type: ZBX_PREPROC_SCRIPT
4569 + params: |
4570 + var http = new HttpRequest();
4571 + var fake = {};
4572 + fake[atob('//9k')] = 'xyz';
4573 + fake.get = http.get;
4574 + fake.get('http://localhost');
4575 + return 0;
4576 + script_uses_curl: 1
4577 +out:
4578 + return: FAIL
4579 +---
4580 +test case: Memory leak with global variable and exception
4581 +in:
4582 + value:
4583 + value_type: ITEM_VALUE_TYPE_STR
4584 + time: 2017-10-29 03:15:00 +03:00
4585 + data: "a"
4586 + step:
4587 + type: ZBX_PREPROC_SCRIPT
4588 + params: |
4589 + req = new HttpRequest();
4590 + throw 0;
4591 + script_uses_curl: 1
4592 +out:
4593 + return: FAIL
4594 +---
4595 +test case: Memory leak with finalizer
4596 +in:
4597 + value:
4598 + value_type: ITEM_VALUE_TYPE_STR
4599 + time: 2017-10-29 03:15:00 +03:00
4600 + data: "a"
4601 + step:
4602 + type: ZBX_PREPROC_SCRIPT
4603 + params: |
4604 + var req = new HttpRequest();
4605 +
4606 + req[atob('gkZpbmFsaXplcg==')] = null;
4607 +
4608 + return 0;
4609 + script_uses_curl: 1
4610 +out:
4611 + value: "0"
4612 + return: SUCCEED
4613 +---
4614 + test case: SNMP walk to value - duplicate oids
4615 + in:
4616 + value:
4617 + value_type: ITEM_VALUE_TYPE_STR
4618 + time: 2017-10-29 03:15:00 +03:00
4619 + data: |
4620 + .1.3.6.1.1 = STRING: "AAA"
4621 + .1.3.6.1.1 = STRING: "AAA"
4622 + step:
4623 + type: ZBX_PREPROC_SNMP_WALK_VALUE
4624 + params: |-
4625 + .1.3.6.1.1
4626 + 0
4627 + out:
4628 + return: SUCCEED
4629 + value: "AAA"
4630 +...
src/go/plugin/scripts.d/pkg/zabbixpreproc/testloader_test.go new
+460
@@ -0,0 +1,460 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "fmt"
5 + "os"
6 + "strings"
7 + "time"
8 +
9 + "gopkg.in/yaml.v3"
10 +)
11 +
12 +// valueInput represents the input value in a test case
13 +type valueInput struct {
14 + ValueType string `yaml:"value_type"`
15 + Time string `yaml:"time"`
16 + Data interface{} `yaml:"data"`
17 +}
18 +
19 +// stepInput represents the preprocessing step in a test case
20 +type stepInput struct {
21 + Type string `yaml:"type"`
22 + Params string `yaml:"params"`
23 + ErrorHandler string `yaml:"error_handler"`
24 + ErrorHandlerParam string `yaml:"error_handler_params"`
25 +}
26 +
27 +// testOutput represents the expected output in a test case
28 +type testOutput struct {
29 + Return string `yaml:"return"`
30 + Value interface{} `yaml:"value"`
31 + Error string `yaml:"error"`
32 +}
33 +
34 +// testInput represents the input section of a test case
35 +type testInputSection struct {
36 + Value valueInput `yaml:"value"`
37 + Error *valueInput `yaml:"error"` // Input is an error instead of a value
38 + HistoryValue *valueInput `yaml:"history_value"` // Deprecated field
39 + History *valueInput `yaml:"history"` // Actual field used in tests
40 + Step stepInput `yaml:"step"`
41 +}
42 +
43 +// TestCase represents a single Zabbix preprocessing test case.
44 +type TestCase struct {
45 + Name string
46 + In testInputSection
47 + Out testOutput
48 +}
49 +
50 +// LoadTestCases loads Zabbix YAML test cases from file.
51 +func LoadTestCases(filename string) ([]TestCase, error) {
52 + data, err := os.ReadFile(filename)
53 + if err != nil {
54 + return nil, fmt.Errorf("failed to read test file: %w", err)
55 + }
56 +
57 + // Split YAML documents - split on "---" at start of line
58 + docStr := string(data)
59 + // Handle both with and without leading ---
60 + if strings.HasPrefix(docStr, "---") {
61 + docStr = docStr[3:] // Remove leading ---
62 + }
63 +
64 + docs := strings.Split(docStr, "\n---")
65 + var testCases []TestCase
66 +
67 + for _, doc := range docs {
68 + doc = strings.TrimSpace(doc)
69 + if doc == "" {
70 + continue
71 + }
72 +
73 + var rawTC struct {
74 + TestCase string `yaml:"test case"`
75 + In testInputSection `yaml:"in"`
76 + Out testOutput `yaml:"out"`
77 + }
78 +
79 + if err := yaml.Unmarshal([]byte(doc), &rawTC); err != nil {
80 + // Log warning for unparseable YAML (don't silently skip)
81 + fmt.Fprintf(os.Stderr, "WARNING: Failed to parse YAML document in test file: %v\n", err)
82 + continue
83 + }
84 +
85 + if rawTC.TestCase == "" {
86 + continue
87 + }
88 +
89 + testCases = append(testCases, TestCase{
90 + Name: rawTC.TestCase,
91 + In: rawTC.In,
92 + Out: rawTC.Out,
93 + })
94 + }
95 +
96 + return testCases, nil
97 +}
98 +
99 +// LoadXPathTestCases loads XPath-specific test cases and converts to standard format
100 +func LoadXPathTestCases(filename string) ([]TestCase, error) {
101 + data, err := os.ReadFile(filename)
102 + if err != nil {
103 + return nil, fmt.Errorf("failed to read XPath test file: %w", err)
104 + }
105 +
106 + // Split YAML documents
107 + docStr := string(data)
108 + if strings.HasPrefix(docStr, "---") {
109 + docStr = docStr[3:]
110 + }
111 +
112 + docs := strings.Split(docStr, "\n---")
113 + var testCases []TestCase
114 +
115 + for _, doc := range docs {
116 + doc = strings.TrimSpace(doc)
117 + if doc == "" {
118 + continue
119 + }
120 +
121 + var rawTC struct {
122 + TestCase string `yaml:"test case"`
123 + In struct {
124 + XML string `yaml:"xml"`
125 + XPath string `yaml:"xpath"`
126 + } `yaml:"in"`
127 + Out struct {
128 + Result string `yaml:"result"`
129 + Return string `yaml:"return"`
130 + } `yaml:"out"`
131 + }
132 +
133 + if err := yaml.Unmarshal([]byte(doc), &rawTC); err != nil {
134 + // Log warning for unparseable YAML (don't silently skip)
135 + fmt.Fprintf(os.Stderr, "WARNING: Failed to parse XPath test YAML: %v\n", err)
136 + continue
137 + }
138 +
139 + if rawTC.TestCase == "" {
140 + continue
141 + }
142 +
143 + // Convert to standard format
144 + tc := TestCase{
145 + Name: rawTC.TestCase,
146 + In: testInputSection{
147 + Value: valueInput{
148 + ValueType: "ITEM_VALUE_TYPE_STR",
149 + Data: rawTC.In.XML,
150 + },
151 + Step: stepInput{
152 + Type: "ZBX_PREPROC_XPATH",
153 + Params: rawTC.In.XPath,
154 + },
155 + },
156 + Out: testOutput{
157 + Return: rawTC.Out.Return,
158 + Value: rawTC.Out.Result,
159 + },
160 + }
161 +
162 + testCases = append(testCases, tc)
163 + }
164 +
165 + return testCases, nil
166 +}
167 +
168 +// LoadCSVTestCases loads CSV-specific test cases and converts to standard format
169 +func LoadCSVTestCases(filename string) ([]TestCase, error) {
170 + data, err := os.ReadFile(filename)
171 + if err != nil {
172 + return nil, fmt.Errorf("failed to read CSV test file: %w", err)
173 + }
174 +
175 + // Split YAML documents
176 + docStr := string(data)
177 + if strings.HasPrefix(docStr, "---") {
178 + docStr = docStr[3:]
179 + }
180 +
181 + docs := strings.Split(docStr, "\n---")
182 + var testCases []TestCase
183 +
184 + for _, doc := range docs {
185 + doc = strings.TrimSpace(doc)
186 + if doc == "" {
187 + continue
188 + }
189 +
190 + var rawTC struct {
191 + TestCase string `yaml:"test case"`
192 + In struct {
193 + CSV string `yaml:"csv"`
194 + Params string `yaml:"params"`
195 + } `yaml:"in"`
196 + Out struct {
197 + Result string `yaml:"result"`
198 + Return string `yaml:"return"`
199 + } `yaml:"out"`
200 + }
201 +
202 + if err := yaml.Unmarshal([]byte(doc), &rawTC); err != nil {
203 + // Log warning for unparseable YAML (don't silently skip)
204 + fmt.Fprintf(os.Stderr, "WARNING: Failed to parse CSV test YAML: %v\n", err)
205 + continue
206 + }
207 +
208 + if rawTC.TestCase == "" {
209 + continue
210 + }
211 +
212 + // Convert to standard format
213 + tc := TestCase{
214 + Name: rawTC.TestCase,
215 + In: testInputSection{
216 + Value: valueInput{
217 + ValueType: "ITEM_VALUE_TYPE_STR",
218 + Data: rawTC.In.CSV,
219 + },
220 + Step: stepInput{
221 + Type: "ZBX_PREPROC_CSV_TO_JSON",
222 + Params: rawTC.In.Params,
223 + },
224 + },
225 + Out: testOutput{
226 + Return: rawTC.Out.Return,
227 + Value: rawTC.Out.Result,
228 + },
229 + }
230 +
231 + testCases = append(testCases, tc)
232 + }
233 +
234 + return testCases, nil
235 +}
236 +
237 +// LoadAllTestCases loads all official Zabbix test suites
238 +func LoadAllTestCases() ([]TestCase, error) {
239 + var allTests []TestCase
240 +
241 + // Load main test suite
242 + mainTests, err := LoadTestCases("testdata/zbx_item_preproc.yaml")
243 + if err != nil {
244 + return nil, fmt.Errorf("failed to load main test suite: %w", err)
245 + }
246 + allTests = append(allTests, mainTests...)
247 +
248 + // Load XPath test suite
249 + xpathTests, err := LoadXPathTestCases("testdata/item_preproc_xpath.yaml")
250 + if err != nil {
251 + return nil, fmt.Errorf("failed to load XPath test suite: %w", err)
252 + }
253 + allTests = append(allTests, xpathTests...)
254 +
255 + // Load CSV test suite
256 + csvTests, err := LoadCSVTestCases("testdata/item_preproc_csv_to_json.yaml")
257 + if err != nil {
258 + return nil, fmt.Errorf("failed to load CSV test suite: %w", err)
259 + }
260 + allTests = append(allTests, csvTests...)
261 +
262 + return allTests, nil
263 +}
264 +
265 +// StepTypeFromString converts Zabbix step type string to StepType.
266 +func StepTypeFromString(s string) (StepType, error) {
267 + switch s {
268 + case "ZBX_PREPROC_MULTIPLIER":
269 + return StepTypeMultiplier, nil
270 + case "ZBX_PREPROC_TRIM":
271 + return StepTypeTrim, nil
272 + case "ZBX_PREPROC_RTRIM":
273 + return StepTypeRTrim, nil
274 + case "ZBX_PREPROC_LTRIM":
275 + return StepTypeLTrim, nil
276 + case "ZBX_PREPROC_REGSUB":
277 + return StepTypeRegexSubstitution, nil
278 + case "ZBX_PREPROC_BOOL2DEC":
279 + return StepTypeBool2Dec, nil
280 + case "ZBX_PREPROC_OCT2DEC":
281 + return StepTypeOct2Dec, nil
282 + case "ZBX_PREPROC_HEX2DEC":
283 + return StepTypeHex2Dec, nil
284 + case "ZBX_PREPROC_DELTA_VALUE":
285 + return StepTypeDeltaValue, nil
286 + case "ZBX_PREPROC_DELTA_SPEED":
287 + return StepTypeDeltaSpeed, nil
288 + case "ZBX_PREPROC_XPATH":
289 + return StepTypeXPath, nil
290 + case "ZBX_PREPROC_JSONPATH":
291 + return StepTypeJSONPath, nil
292 + case "ZBX_PREPROC_VALIDATE_RANGE":
293 + return StepTypeValidateRange, nil
294 + case "ZBX_PREPROC_VALIDATE_REGEX":
295 + return StepTypeValidateRegex, nil
296 + case "ZBX_PREPROC_VALIDATE_NOT_REGEX":
297 + return StepTypeValidateNotRegex, nil
298 + case "ZBX_PREPROC_ERROR_FIELD_JSON":
299 + return StepTypeErrorFieldJSON, nil
300 + case "ZBX_PREPROC_ERROR_FIELD_XML":
301 + return StepTypeErrorFieldXML, nil
302 + case "ZBX_PREPROC_ERROR_FIELD_REGEX":
303 + return StepTypeErrorFieldRegex, nil
304 + case "ZBX_PREPROC_THROTTLE_VALUE":
305 + return StepTypeThrottleValue, nil
306 + case "ZBX_PREPROC_THROTTLE_TIMED_VALUE":
307 + return StepTypeThrottleTimedValue, nil
308 + case "ZBX_PREPROC_PROMETHEUS_PATTERN":
309 + return StepTypePrometheusPattern, nil
310 + case "ZBX_PREPROC_PROMETHEUS_TO_JSON":
311 + return StepTypePrometheusToJSON, nil
312 + case "ZBX_PREPROC_CSV_TO_JSON":
313 + return StepTypeCSVToJSON, nil
314 + case "ZBX_PREPROC_STR_REPLACE":
315 + return StepTypeStringReplace, nil
316 + case "ZBX_PREPROC_SNMP_WALK_VALUE":
317 + return StepTypeSNMPWalkValue, nil
318 + case "ZBX_PREPROC_SCRIPT":
319 + return StepTypeJavaScript, nil
320 + case "ZBX_PREPROC_VALIDATE_NOT_SUPPORTED":
321 + return StepTypeValidateNotSupported, nil
322 + case "ZBX_PREPROC_XML_TO_JSON":
323 + return StepTypeXMLToJSON, nil
324 + case "ZBX_PREPROC_SNMP_GET_VALUE":
325 + return StepTypeSNMPGetValue, nil
326 + case "ZBX_PREPROC_SNMP_WALK_TO_JSON":
327 + return StepTypeSNMPWalkToJSON, nil
328 + default:
329 + return -1, fmt.Errorf("unknown step type: %s", s)
330 + }
331 +}
332 +
333 +// ValueFromTestInput converts test input to Value.
334 +func ValueFromTestInput(tc TestCase) (Value, error) {
335 + // Check if this is an error input (from a previous step failure)
336 + if tc.In.Error != nil {
337 + data := ""
338 + if tc.In.Error.Data != nil {
339 + data = fmt.Sprint(tc.In.Error.Data)
340 + }
341 +
342 + timestamp := time.Now()
343 + if tc.In.Error.Time != "" {
344 + t, err := time.Parse("2006-01-02 15:04:05 -07:00", tc.In.Error.Time)
345 + if err == nil {
346 + timestamp = t
347 + }
348 + }
349 +
350 + return Value{
351 + Data: data,
352 + Type: ValueTypeStr, // Errors are always strings
353 + Timestamp: timestamp,
354 + IsError: true, // Mark as error input
355 + }, nil
356 + }
357 +
358 + // Normal value input
359 + vt, err := parseValueType(tc.In.Value.ValueType)
360 + if err != nil {
361 + return Value{}, err
362 + }
363 +
364 + data := ""
365 + if tc.In.Value.Data != nil {
366 + data = fmt.Sprint(tc.In.Value.Data)
367 + }
368 +
369 + timestamp := time.Now()
370 + if tc.In.Value.Time != "" {
371 + // Parse time in format: 2017-10-29 03:15:00 +03:00
372 + t, err := time.Parse("2006-01-02 15:04:05 -07:00", tc.In.Value.Time)
373 + if err == nil {
374 + timestamp = t
375 + }
376 + }
377 +
378 + return Value{
379 + Data: data,
380 + Type: vt,
381 + Timestamp: timestamp,
382 + IsError: false, // Normal value, not an error
383 + }, nil
384 +}
385 +
386 +// HistoryValueFromTestInput converts history value from test input to Value
387 +func HistoryValueFromTestInput(tc TestCase) *Value {
388 + // Check both possible field names
389 + histValue := tc.In.HistoryValue
390 + if histValue == nil {
391 + histValue = tc.In.History
392 + }
393 + if histValue == nil {
394 + return nil
395 + }
396 +
397 + data := ""
398 + if histValue.Data != nil {
399 + data = fmt.Sprint(histValue.Data)
400 + }
401 +
402 + timestamp := time.Now()
403 + if histValue.Time != "" {
404 + t, err := time.Parse("2006-01-02 15:04:05 -07:00", histValue.Time)
405 + if err == nil {
406 + timestamp = t
407 + }
408 + }
409 +
410 + return &Value{
411 + Data: data,
412 + Type: ValueTypeStr,
413 + Timestamp: timestamp,
414 + }
415 +}
416 +
417 +// StepFromTestInput converts test input to Step.
418 +func StepFromTestInput(tc TestCase) (Step, error) {
419 + stepType, err := StepTypeFromString(tc.In.Step.Type)
420 + if err != nil {
421 + return Step{}, err
422 + }
423 +
424 + // Parse error handler if provided
425 + errorHandler := ErrorHandler{Action: ErrorActionDefault}
426 + if tc.In.Step.ErrorHandler != "" {
427 + errorHandler = parseErrorHandler(tc.In.Step.ErrorHandler, tc.In.Step.ErrorHandlerParam)
428 + }
429 +
430 + return Step{
431 + Type: stepType,
432 + Params: tc.In.Step.Params,
433 + ErrorHandler: errorHandler,
434 + }, nil
435 +}
436 +
437 +// parseErrorHandler converts Zabbix error handler string to ErrorHandler
438 +func parseErrorHandler(handlerStr, paramStr string) ErrorHandler {
439 + switch handlerStr {
440 + case "ZBX_PREPROC_FAIL_DEFAULT":
441 + return ErrorHandler{Action: ErrorActionDefault}
442 + case "ZBX_PREPROC_FAIL_DISCARD_VALUE":
443 + return ErrorHandler{Action: ErrorActionDiscard}
444 + case "ZBX_PREPROC_FAIL_SET_VALUE":
445 + return ErrorHandler{Action: ErrorActionSetValue, Params: paramStr}
446 + case "ZBX_PREPROC_FAIL_SET_ERROR":
447 + return ErrorHandler{Action: ErrorActionSetError, Params: paramStr}
448 + default:
449 + return ErrorHandler{Action: ErrorActionDefault}
450 + }
451 +}
452 +
453 +// ExpectedOutputFromTestCase gets expected output from test case.
454 +func ExpectedOutputFromTestCase(tc TestCase) (string, error) {
455 + output := ""
456 + if tc.Out.Value != nil {
457 + output = fmt.Sprint(tc.Out.Value)
458 + }
459 + return output, nil
460 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/validation.go new
+186
@@ -0,0 +1,186 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "fmt"
5 + "strings"
6 +)
7 +
8 +// ValidateStep performs early validation of step parameters.
9 +// This catches common errors before execution, useful for pipeline validation.
10 +// Note: Does not perform deep validation (regex compilation, JSONPath parsing, etc.)
11 +// as those are checked during execution.
12 +func ValidateStep(step Step) error {
13 + // Check step type is in valid range
14 + if step.Type < 0 {
15 + return fmt.Errorf("invalid step type: %d (must be >= 0)", step.Type)
16 + }
17 +
18 + // Validate parameters based on step type
19 + switch step.Type {
20 + // Steps that require non-empty parameters
21 + case StepTypeMultiplier:
22 + if step.Params == "" {
23 + return fmt.Errorf("multiplier step requires a numeric parameter")
24 + }
25 +
26 + case StepTypeRegexSubstitution:
27 + if step.Params == "" {
28 + return fmt.Errorf("regex substitution requires pattern and replacement (pattern\\nreplacement)")
29 + }
30 + if !strings.Contains(step.Params, "\n") {
31 + return fmt.Errorf("regex substitution requires newline-separated pattern and replacement")
32 + }
33 +
34 + case StepTypeStringReplace:
35 + if step.Params == "" {
36 + return fmt.Errorf("string replace requires search and replacement (search\\nreplacement)")
37 + }
38 + if !strings.Contains(step.Params, "\n") {
39 + return fmt.Errorf("string replace requires newline-separated search and replacement")
40 + }
41 +
42 + case StepTypeJSONPath, StepTypeJSONPathMulti:
43 + if step.Params == "" {
44 + return fmt.Errorf("jsonpath step requires a jsonpath expression parameter")
45 + }
46 +
47 + case StepTypeXPath:
48 + if step.Params == "" {
49 + return fmt.Errorf("xpath step requires an xpath expression parameter")
50 + }
51 +
52 + case StepTypePrometheusPattern:
53 + if step.Params == "" {
54 + return fmt.Errorf("prometheus pattern requires a pattern parameter")
55 + }
56 +
57 + case StepTypePrometheusToJSON, StepTypePrometheusToJSONMulti:
58 + if step.Params == "" {
59 + return fmt.Errorf("prometheus to json requires a pattern parameter")
60 + }
61 +
62 + case StepTypeCSVToJSON, StepTypeCSVToJSONMulti:
63 + parts := strings.Split(step.Params, "\n")
64 + if len(parts) < 3 {
65 + return fmt.Errorf("csv to json requires 3 parameters: delimiter, quote, header (got %d)", len(parts))
66 + }
67 +
68 + case StepTypeSNMPWalkValue:
69 + parts := strings.Split(step.Params, "\n")
70 + if len(parts) < 2 {
71 + return fmt.Errorf("snmp walk value requires 2 parameters: oid, format (got %d)", len(parts))
72 + }
73 +
74 + case StepTypeSNMPGetValue:
75 + if step.Params == "" {
76 + return fmt.Errorf("snmp get value requires format mode parameter")
77 + }
78 +
79 + case StepTypeSNMPWalkToJSON, StepTypeSNMPWalkToJSONMulti:
80 + if step.Params == "" {
81 + return fmt.Errorf("snmp walk to json requires at least one triplet (macro, oid, format)")
82 + }
83 + parts := strings.Split(step.Params, "\n")
84 + if len(parts)%3 != 0 {
85 + return fmt.Errorf("snmp walk to json requires parameters in triplets of (macro, oid, format), got %d parameters", len(parts))
86 + }
87 +
88 + case StepTypeValidateRange:
89 + if step.Params == "" {
90 + return fmt.Errorf("validate range requires min and max parameters")
91 + }
92 +
93 + case StepTypeValidateRegex:
94 + if step.Params == "" {
95 + return fmt.Errorf("validate regex requires a pattern parameter")
96 + }
97 +
98 + case StepTypeValidateNotRegex:
99 + if step.Params == "" {
100 + return fmt.Errorf("validate not regex requires a pattern parameter")
101 + }
102 +
103 + case StepTypeValidateNotSupported:
104 + if step.Params == "" {
105 + return fmt.Errorf("validate not supported requires an error message parameter")
106 + }
107 +
108 + case StepTypeErrorFieldJSON:
109 + if step.Params == "" {
110 + return fmt.Errorf("error field json requires a jsonpath expression parameter")
111 + }
112 +
113 + case StepTypeErrorFieldXML:
114 + if step.Params == "" {
115 + return fmt.Errorf("error field xml requires an xpath expression parameter")
116 + }
117 +
118 + case StepTypeErrorFieldRegex:
119 + if step.Params == "" {
120 + return fmt.Errorf("error field regex requires a pattern parameter")
121 + }
122 +
123 + case StepTypeThrottleValue:
124 + if step.Params == "" {
125 + return fmt.Errorf("throttle value requires a time period parameter")
126 + }
127 +
128 + case StepTypeThrottleTimedValue:
129 + if step.Params == "" {
130 + return fmt.Errorf("throttle timed value requires a time period parameter")
131 + }
132 +
133 + case StepTypeJavaScript:
134 + if step.Params == "" {
135 + return fmt.Errorf("javascript step requires code parameter")
136 + }
137 +
138 + case StepTypeDeltaValue, StepTypeDeltaSpeed:
139 + // Params optional for delta steps (defaults handled in execution)
140 +
141 + case StepTypeTrim, StepTypeRTrim, StepTypeLTrim:
142 + // Params optional for trim (defaults to whitespace)
143 +
144 + case StepTypeBool2Dec, StepTypeOct2Dec, StepTypeHex2Dec:
145 + // No parameters required for conversion steps
146 +
147 + default:
148 + // Unknown step type - not necessarily an error (future extensibility)
149 + // The actual execution will handle this
150 + }
151 +
152 + // Validate error handler
153 + switch step.ErrorHandler.Action {
154 + case ErrorActionDefault, ErrorActionDiscard:
155 + // No params required
156 +
157 + case ErrorActionSetValue:
158 + // Params should contain the value to set (can be empty string)
159 +
160 + case ErrorActionSetError:
161 + if step.ErrorHandler.Params == "" {
162 + return fmt.Errorf("error handler set_error requires an error message parameter")
163 + }
164 +
165 + default:
166 + return fmt.Errorf("invalid error handler action: %d", step.ErrorHandler.Action)
167 + }
168 +
169 + return nil
170 +}
171 +
172 +// ValidatePipeline validates all steps in a pipeline before execution.
173 +// This is useful for catching configuration errors early, especially in multi-step pipelines.
174 +func ValidatePipeline(steps []Step) error {
175 + if len(steps) == 0 {
176 + return fmt.Errorf("pipeline cannot be empty")
177 + }
178 +
179 + for i, step := range steps {
180 + if err := ValidateStep(step); err != nil {
181 + return fmt.Errorf("step %d: %w", i, err)
182 + }
183 + }
184 +
185 + return nil
186 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/validation_test.go new
+292
@@ -0,0 +1,292 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "strings"
5 + "testing"
6 +)
7 +
8 +func TestValidateStep(t *testing.T) {
9 + tests := []struct {
10 + name string
11 + step Step
12 + wantErr bool
13 + errMsg string
14 + }{
15 + // Valid steps
16 + {
17 + name: "valid multiplier",
18 + step: Step{Type: StepTypeMultiplier, Params: "2.5"},
19 + wantErr: false,
20 + },
21 + {
22 + name: "valid jsonpath",
23 + step: Step{Type: StepTypeJSONPath, Params: "$.cpu"},
24 + wantErr: false,
25 + },
26 + {
27 + name: "valid csv to json",
28 + step: Step{Type: StepTypeCSVToJSON, Params: ",\n\"\n1"},
29 + wantErr: false,
30 + },
31 + {
32 + name: "valid snmp walk to json",
33 + step: Step{Type: StepTypeSNMPWalkToJSON, Params: "{#NAME}\n.1.3.6.1.2.1.1\n1"},
34 + wantErr: false,
35 + },
36 + {
37 + name: "valid trim with params",
38 + step: Step{Type: StepTypeTrim, Params: " "},
39 + wantErr: false,
40 + },
41 + {
42 + name: "valid trim without params",
43 + step: Step{Type: StepTypeTrim, Params: ""},
44 + wantErr: false,
45 + },
46 + {
47 + name: "valid bool2dec",
48 + step: Step{Type: StepTypeBool2Dec, Params: ""},
49 + wantErr: false,
50 + },
51 +
52 + // Invalid steps - negative type
53 + {
54 + name: "negative step type",
55 + step: Step{Type: StepType(-1), Params: ""},
56 + wantErr: true,
57 + errMsg: "invalid step type",
58 + },
59 +
60 + // Invalid steps - missing required params
61 + {
62 + name: "multiplier missing params",
63 + step: Step{Type: StepTypeMultiplier, Params: ""},
64 + wantErr: true,
65 + errMsg: "multiplier step requires a numeric parameter",
66 + },
67 + {
68 + name: "jsonpath missing params",
69 + step: Step{Type: StepTypeJSONPath, Params: ""},
70 + wantErr: true,
71 + errMsg: "jsonpath step requires",
72 + },
73 + {
74 + name: "xpath missing params",
75 + step: Step{Type: StepTypeXPath, Params: ""},
76 + wantErr: true,
77 + errMsg: "xpath step requires",
78 + },
79 + {
80 + name: "prometheus pattern missing params",
81 + step: Step{Type: StepTypePrometheusPattern, Params: ""},
82 + wantErr: true,
83 + errMsg: "prometheus pattern requires",
84 + },
85 + {
86 + name: "javascript missing params",
87 + step: Step{Type: StepTypeJavaScript, Params: ""},
88 + wantErr: true,
89 + errMsg: "javascript step requires",
90 + },
91 +
92 + // Invalid steps - malformed params
93 + {
94 + name: "regex substitution missing newline",
95 + step: Step{Type: StepTypeRegexSubstitution, Params: "pattern"},
96 + wantErr: true,
97 + errMsg: "requires newline-separated",
98 + },
99 + {
100 + name: "string replace missing newline",
101 + step: Step{Type: StepTypeStringReplace, Params: "search"},
102 + wantErr: true,
103 + errMsg: "requires newline-separated",
104 + },
105 + {
106 + name: "csv to json insufficient params",
107 + step: Step{Type: StepTypeCSVToJSON, Params: ",\n\""},
108 + wantErr: true,
109 + errMsg: "requires 3 parameters",
110 + },
111 + {
112 + name: "snmp walk value insufficient params",
113 + step: Step{Type: StepTypeSNMPWalkValue, Params: ".1.3.6.1"},
114 + wantErr: true,
115 + errMsg: "requires 2 parameters",
116 + },
117 + {
118 + name: "snmp walk to json non-triplet params",
119 + step: Step{Type: StepTypeSNMPWalkToJSON, Params: "{#NAME}\n.1.3.6.1"},
120 + wantErr: true,
121 + errMsg: "requires parameters in triplets",
122 + },
123 + {
124 + name: "snmp walk to json empty params",
125 + step: Step{Type: StepTypeSNMPWalkToJSON, Params: ""},
126 + wantErr: true,
127 + errMsg: "requires at least one triplet",
128 + },
129 +
130 + // Error handler validation
131 + {
132 + name: "valid error handler - default",
133 + step: Step{
134 + Type: StepTypeJSONPath,
135 + Params: "$.value",
136 + ErrorHandler: ErrorHandler{Action: ErrorActionDefault},
137 + },
138 + wantErr: false,
139 + },
140 + {
141 + name: "valid error handler - discard",
142 + step: Step{
143 + Type: StepTypeJSONPath,
144 + Params: "$.value",
145 + ErrorHandler: ErrorHandler{Action: ErrorActionDiscard},
146 + },
147 + wantErr: false,
148 + },
149 + {
150 + name: "valid error handler - set value",
151 + step: Step{
152 + Type: StepTypeJSONPath,
153 + Params: "$.value",
154 + ErrorHandler: ErrorHandler{Action: ErrorActionSetValue, Params: "0"},
155 + },
156 + wantErr: false,
157 + },
158 + {
159 + name: "valid error handler - set value empty",
160 + step: Step{
161 + Type: StepTypeJSONPath,
162 + Params: "$.value",
163 + ErrorHandler: ErrorHandler{Action: ErrorActionSetValue, Params: ""},
164 + },
165 + wantErr: false,
166 + },
167 + {
168 + name: "valid error handler - set error",
169 + step: Step{
170 + Type: StepTypeJSONPath,
171 + Params: "$.value",
172 + ErrorHandler: ErrorHandler{Action: ErrorActionSetError, Params: "Custom error"},
173 + },
174 + wantErr: false,
175 + },
176 + {
177 + name: "invalid error handler - set error missing message",
178 + step: Step{
179 + Type: StepTypeJSONPath,
180 + Params: "$.value",
181 + ErrorHandler: ErrorHandler{Action: ErrorActionSetError, Params: ""},
182 + },
183 + wantErr: true,
184 + errMsg: "requires an error message parameter",
185 + },
186 + {
187 + name: "invalid error handler - unknown action",
188 + step: Step{
189 + Type: StepTypeJSONPath,
190 + Params: "$.value",
191 + ErrorHandler: ErrorHandler{Action: ErrorAction(99)},
192 + },
193 + wantErr: true,
194 + errMsg: "invalid error handler action",
195 + },
196 + }
197 +
198 + for _, tt := range tests {
199 + t.Run(tt.name, func(t *testing.T) {
200 + err := ValidateStep(tt.step)
201 + if tt.wantErr {
202 + if err == nil {
203 + t.Errorf("ValidateStep() expected error containing %q, got nil", tt.errMsg)
204 + return
205 + }
206 + if !strings.Contains(err.Error(), tt.errMsg) {
207 + t.Errorf("ValidateStep() error = %v, want error containing %q", err, tt.errMsg)
208 + }
209 + } else {
210 + if err != nil {
211 + t.Errorf("ValidateStep() unexpected error = %v", err)
212 + }
213 + }
214 + })
215 + }
216 +}
217 +
218 +func TestValidatePipeline(t *testing.T) {
219 + tests := []struct {
220 + name string
221 + steps []Step
222 + wantErr bool
223 + errMsg string
224 + }{
225 + {
226 + name: "valid pipeline",
227 + steps: []Step{
228 + {Type: StepTypeJSONPath, Params: "$.cpu"},
229 + {Type: StepTypeMultiplier, Params: "100"},
230 + {Type: StepTypeTrim, Params: ""},
231 + },
232 + wantErr: false,
233 + },
234 + {
235 + name: "empty pipeline",
236 + steps: []Step{},
237 + wantErr: true,
238 + errMsg: "pipeline cannot be empty",
239 + },
240 + {
241 + name: "pipeline with invalid step at index 1",
242 + steps: []Step{
243 + {Type: StepTypeJSONPath, Params: "$.cpu"},
244 + {Type: StepTypeMultiplier, Params: ""}, // Missing params
245 + {Type: StepTypeTrim, Params: ""},
246 + },
247 + wantErr: true,
248 + errMsg: "step 1:",
249 + },
250 + {
251 + name: "pipeline with invalid step at index 0",
252 + steps: []Step{
253 + {Type: StepTypeJSONPath, Params: ""}, // Missing params
254 + {Type: StepTypeMultiplier, Params: "100"},
255 + },
256 + wantErr: true,
257 + errMsg: "step 0:",
258 + },
259 + {
260 + name: "pipeline with invalid error handler",
261 + steps: []Step{
262 + {Type: StepTypeJSONPath, Params: "$.cpu"},
263 + {
264 + Type: StepTypeMultiplier,
265 + Params: "100",
266 + ErrorHandler: ErrorHandler{Action: ErrorActionSetError, Params: ""},
267 + },
268 + },
269 + wantErr: true,
270 + errMsg: "step 1:",
271 + },
272 + }
273 +
274 + for _, tt := range tests {
275 + t.Run(tt.name, func(t *testing.T) {
276 + err := ValidatePipeline(tt.steps)
277 + if tt.wantErr {
278 + if err == nil {
279 + t.Errorf("ValidatePipeline() expected error containing %q, got nil", tt.errMsg)
280 + return
281 + }
282 + if !strings.Contains(err.Error(), tt.errMsg) {
283 + t.Errorf("ValidatePipeline() error = %v, want error containing %q", err, tt.errMsg)
284 + }
285 + } else {
286 + if err != nil {
287 + t.Errorf("ValidatePipeline() unexpected error = %v", err)
288 + }
289 + }
290 + })
291 + }
292 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/xml_step.go new
+144
@@ -0,0 +1,144 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "encoding/json"
5 + "fmt"
6 + "strings"
7 +
8 + "github.com/antchfx/xmlquery"
9 +)
10 +
11 +// xmlToJSON converts XML to JSON following Zabbix serialization rules.
12 +//
13 +// Zabbix XML to JSON Serialization Rules:
14 +// 1. Attributes: Prepended with '@'
15 +// <xml foo="FOO"> → {"xml": {"@foo": "FOO"}}
16 +// 2. Self-closing elements: Become null
17 +// <foo/> → {"foo": null}
18 +// 3. Empty attributes: Preserved as empty strings
19 +// bar="" → "@bar": ""
20 +// 4. Repeated elements: Consolidated into arrays
21 +// <foo>A</foo><foo>B</foo> → "foo": ["A", "B"]
22 +// 5. Simple text elements: Direct string values
23 +// <foo>BAZ</foo> → {"foo": "BAZ"}
24 +// 6. Text with attributes: Uses #text key
25 +// <foo bar="BAR">BAZ</foo> → {"foo": {"@bar": "BAR", "#text": "BAZ"}}
26 +func xmlToJSON(value Value, paramStr string) (Value, error) {
27 + // Parse XML
28 + doc, err := xmlquery.Parse(strings.NewReader(value.Data))
29 + if err != nil {
30 + return Value{}, fmt.Errorf("invalid XML: %w", err)
31 + }
32 +
33 + // Convert to JSON structure
34 + result := convertNodeToJSON(doc)
35 +
36 + // Serialize to JSON string
37 + jsonBytes, err := json.Marshal(result)
38 + if err != nil {
39 + return Value{}, fmt.Errorf("JSON serialization failed: %w", err)
40 + }
41 +
42 + return Value{Data: string(jsonBytes), Type: ValueTypeStr}, nil
43 +}
44 +
45 +// convertNodeToJSON converts an XML node to a JSON-compatible structure
46 +func convertNodeToJSON(node *xmlquery.Node) interface{} {
47 + if node == nil {
48 + return nil
49 + }
50 +
51 + // Handle document node - process root element
52 + if node.Type == xmlquery.DocumentNode {
53 + for child := node.FirstChild; child != nil; child = child.NextSibling {
54 + if child.Type == xmlquery.ElementNode {
55 + // Return the root element as a map with element name as key
56 + return map[string]interface{}{
57 + child.Data: convertElementToJSON(child),
58 + }
59 + }
60 + }
61 + return nil
62 + }
63 +
64 + // For element nodes, wrap in a map with element name
65 + if node.Type == xmlquery.ElementNode {
66 + return map[string]interface{}{
67 + node.Data: convertElementToJSON(node),
68 + }
69 + }
70 +
71 + return nil
72 +}
73 +
74 +// convertElementToJSON converts an XML element to JSON following Zabbix rules
75 +func convertElementToJSON(node *xmlquery.Node) interface{} {
76 + // Collect attributes (Rule 1: prepend with '@')
77 + attrs := make(map[string]interface{})
78 + for _, attr := range node.Attr {
79 + // Rule 3: Empty attributes preserved as empty strings
80 + attrs["@"+attr.Name.Local] = attr.Value
81 + }
82 +
83 + // Collect child elements grouped by name
84 + children := make(map[string][]interface{})
85 + var textContent strings.Builder
86 + hasChildren := false
87 +
88 + for child := node.FirstChild; child != nil; child = child.NextSibling {
89 + switch child.Type {
90 + case xmlquery.ElementNode:
91 + hasChildren = true
92 + childValue := convertElementToJSON(child)
93 + children[child.Data] = append(children[child.Data], childValue)
94 +
95 + case xmlquery.TextNode, xmlquery.CharDataNode:
96 + // Collect text content (trim each segment but preserve structure)
97 + text := strings.TrimSpace(child.Data)
98 + if text != "" {
99 + if textContent.Len() > 0 {
100 + textContent.WriteString(" ")
101 + }
102 + textContent.WriteString(text)
103 + }
104 + }
105 + }
106 +
107 + text := textContent.String()
108 +
109 + // Rule 2: Self-closing elements (no attributes, no children, no text) → null
110 + if len(attrs) == 0 && !hasChildren && text == "" {
111 + return nil
112 + }
113 +
114 + // Rule 5: Simple text elements (no attributes, no children, only text) → direct string
115 + if len(attrs) == 0 && !hasChildren && text != "" {
116 + return text
117 + }
118 +
119 + // Build result object
120 + result := make(map[string]interface{})
121 +
122 + // Add attributes first
123 + for k, v := range attrs {
124 + result[k] = v
125 + }
126 +
127 + // Rule 6: Text with attributes → use #text key
128 + if text != "" {
129 + result["#text"] = text
130 + }
131 +
132 + // Rule 4: Repeated elements → arrays
133 + for name, values := range children {
134 + if len(values) == 1 {
135 + // Single child - add directly
136 + result[name] = values[0]
137 + } else {
138 + // Multiple children with same name - create array
139 + result[name] = values
140 + }
141 + }
142 +
143 + return result
144 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/xml_step_test.go new
+502
@@ -0,0 +1,502 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "encoding/json"
5 + "testing"
6 + "time"
7 +)
8 +
9 +// TestXMLToJSON_Rule1_Attributes tests Rule 1: Attributes prepended with '@'
10 +func TestXMLToJSON_Rule1_Attributes(t *testing.T) {
11 + tests := []struct {
12 + name string
13 + xml string
14 + expected string
15 + }{
16 + {
17 + name: "Single attribute",
18 + xml: `<xml foo="FOO"></xml>`,
19 + expected: `{"xml":{"@foo":"FOO"}}`,
20 + },
21 + {
22 + name: "Multiple attributes",
23 + xml: `<root id="123" name="test" active="true"></root>`,
24 + expected: `{"root":{"@active":"true","@id":"123","@name":"test"}}`,
25 + },
26 + {
27 + name: "Nested element with attributes",
28 + xml: `<root><child attr="value"/></root>`,
29 + expected: `{"root":{"child":{"@attr":"value"}}}`,
30 + },
31 + }
32 +
33 + for _, tt := range tests {
34 + t.Run(tt.name, func(t *testing.T) {
35 + value := Value{
36 + Data: tt.xml,
37 + Type: ValueTypeStr,
38 + Timestamp: time.Now(),
39 + }
40 +
41 + result, err := xmlToJSON(value, "")
42 + if err != nil {
43 + t.Fatalf("xmlToJSON() error = %v", err)
44 + }
45 +
46 + // Compare JSON (normalize by parsing and re-marshaling)
47 + var got, want interface{}
48 + if err := json.Unmarshal([]byte(result.Data), &got); err != nil {
49 + t.Fatalf("Failed to parse result JSON: %v", err)
50 + }
51 + if err := json.Unmarshal([]byte(tt.expected), &want); err != nil {
52 + t.Fatalf("Failed to parse expected JSON: %v", err)
53 + }
54 +
55 + gotJSON, _ := json.Marshal(got)
56 + wantJSON, _ := json.Marshal(want)
57 +
58 + if string(gotJSON) != string(wantJSON) {
59 + t.Errorf("xmlToJSON() = %s, want %s", gotJSON, wantJSON)
60 + }
61 + })
62 + }
63 +}
64 +
65 +// TestXMLToJSON_Rule2_SelfClosingElements tests Rule 2: Self-closing elements become null
66 +func TestXMLToJSON_Rule2_SelfClosingElements(t *testing.T) {
67 + tests := []struct {
68 + name string
69 + xml string
70 + expected string
71 + }{
72 + {
73 + name: "Single self-closing element",
74 + xml: `<root><foo/></root>`,
75 + expected: `{"root":{"foo":null}}`,
76 + },
77 + {
78 + name: "Multiple self-closing elements",
79 + xml: `<root><foo/><bar/><baz/></root>`,
80 + expected: `{"root":{"bar":null,"baz":null,"foo":null}}`,
81 + },
82 + {
83 + name: "Root self-closing",
84 + xml: `<empty/>`,
85 + expected: `{"empty":null}`,
86 + },
87 + }
88 +
89 + for _, tt := range tests {
90 + t.Run(tt.name, func(t *testing.T) {
91 + value := Value{
92 + Data: tt.xml,
93 + Type: ValueTypeStr,
94 + Timestamp: time.Now(),
95 + }
96 +
97 + result, err := xmlToJSON(value, "")
98 + if err != nil {
99 + t.Fatalf("xmlToJSON() error = %v", err)
100 + }
101 +
102 + var got, want interface{}
103 + json.Unmarshal([]byte(result.Data), &got)
104 + json.Unmarshal([]byte(tt.expected), &want)
105 +
106 + gotJSON, _ := json.Marshal(got)
107 + wantJSON, _ := json.Marshal(want)
108 +
109 + if string(gotJSON) != string(wantJSON) {
110 + t.Errorf("xmlToJSON() = %s, want %s", gotJSON, wantJSON)
111 + }
112 + })
113 + }
114 +}
115 +
116 +// TestXMLToJSON_Rule3_EmptyAttributes tests Rule 3: Empty attributes preserved as empty strings
117 +func TestXMLToJSON_Rule3_EmptyAttributes(t *testing.T) {
118 + tests := []struct {
119 + name string
120 + xml string
121 + expected string
122 + }{
123 + {
124 + name: "Empty attribute value",
125 + xml: `<root bar=""></root>`,
126 + expected: `{"root":{"@bar":""}}`,
127 + },
128 + {
129 + name: "Mix of empty and non-empty attributes",
130 + xml: `<root foo="FOO" bar="" baz="BAZ"></root>`,
131 + expected: `{"root":{"@bar":"","@baz":"BAZ","@foo":"FOO"}}`,
132 + },
133 + }
134 +
135 + for _, tt := range tests {
136 + t.Run(tt.name, func(t *testing.T) {
137 + value := Value{
138 + Data: tt.xml,
139 + Type: ValueTypeStr,
140 + Timestamp: time.Now(),
141 + }
142 +
143 + result, err := xmlToJSON(value, "")
144 + if err != nil {
145 + t.Fatalf("xmlToJSON() error = %v", err)
146 + }
147 +
148 + var got, want interface{}
149 + json.Unmarshal([]byte(result.Data), &got)
150 + json.Unmarshal([]byte(tt.expected), &want)
151 +
152 + gotJSON, _ := json.Marshal(got)
153 + wantJSON, _ := json.Marshal(want)
154 +
155 + if string(gotJSON) != string(wantJSON) {
156 + t.Errorf("xmlToJSON() = %s, want %s", gotJSON, wantJSON)
157 + }
158 + })
159 + }
160 +}
161 +
162 +// TestXMLToJSON_Rule4_RepeatedElements tests Rule 4: Repeated elements consolidated into arrays
163 +func TestXMLToJSON_Rule4_RepeatedElements(t *testing.T) {
164 + tests := []struct {
165 + name string
166 + xml string
167 + expected string
168 + }{
169 + {
170 + name: "Three identical elements",
171 + xml: `<root><foo>BAR</foo><foo>BAZ</foo><foo>QUX</foo></root>`,
172 + expected: `{"root":{"foo":["BAR","BAZ","QUX"]}}`,
173 + },
174 + {
175 + name: "Two identical elements",
176 + xml: `<root><item>A</item><item>B</item></root>`,
177 + expected: `{"root":{"item":["A","B"]}}`,
178 + },
179 + {
180 + name: "Mixed single and repeated elements",
181 + xml: `<root><foo>A</foo><bar>B</bar><foo>C</foo></root>`,
182 + expected: `{"root":{"bar":"B","foo":["A","C"]}}`,
183 + },
184 + {
185 + name: "Repeated complex elements",
186 + xml: `<root><item id="1">A</item><item id="2">B</item></root>`,
187 + expected: `{"root":{"item":[{"#text":"A","@id":"1"},{"#text":"B","@id":"2"}]}}`,
188 + },
189 + }
190 +
191 + for _, tt := range tests {
192 + t.Run(tt.name, func(t *testing.T) {
193 + value := Value{
194 + Data: tt.xml,
195 + Type: ValueTypeStr,
196 + Timestamp: time.Now(),
197 + }
198 +
199 + result, err := xmlToJSON(value, "")
200 + if err != nil {
201 + t.Fatalf("xmlToJSON() error = %v", err)
202 + }
203 +
204 + var got, want interface{}
205 + json.Unmarshal([]byte(result.Data), &got)
206 + json.Unmarshal([]byte(tt.expected), &want)
207 +
208 + gotJSON, _ := json.Marshal(got)
209 + wantJSON, _ := json.Marshal(want)
210 +
211 + if string(gotJSON) != string(wantJSON) {
212 + t.Errorf("xmlToJSON() = %s, want %s", gotJSON, wantJSON)
213 + }
214 + })
215 + }
216 +}
217 +
218 +// TestXMLToJSON_Rule5_SimpleTextElements tests Rule 5: Simple text elements become direct strings
219 +func TestXMLToJSON_Rule5_SimpleTextElements(t *testing.T) {
220 + tests := []struct {
221 + name string
222 + xml string
223 + expected string
224 + }{
225 + {
226 + name: "Simple text element",
227 + xml: `<root><foo>BAZ</foo></root>`,
228 + expected: `{"root":{"foo":"BAZ"}}`,
229 + },
230 + {
231 + name: "Multiple simple text elements",
232 + xml: `<root><name>John</name><age>30</age><city>NYC</city></root>`,
233 + expected: `{"root":{"age":"30","city":"NYC","name":"John"}}`,
234 + },
235 + {
236 + name: "Nested simple text",
237 + xml: `<root><outer><inner>value</inner></outer></root>`,
238 + expected: `{"root":{"outer":{"inner":"value"}}}`,
239 + },
240 + {
241 + name: "Text with whitespace trimming",
242 + xml: `<root><foo> BAR </foo></root>`,
243 + expected: `{"root":{"foo":"BAR"}}`,
244 + },
245 + }
246 +
247 + for _, tt := range tests {
248 + t.Run(tt.name, func(t *testing.T) {
249 + value := Value{
250 + Data: tt.xml,
251 + Type: ValueTypeStr,
252 + Timestamp: time.Now(),
253 + }
254 +
255 + result, err := xmlToJSON(value, "")
256 + if err != nil {
257 + t.Fatalf("xmlToJSON() error = %v", err)
258 + }
259 +
260 + var got, want interface{}
261 + json.Unmarshal([]byte(result.Data), &got)
262 + json.Unmarshal([]byte(tt.expected), &want)
263 +
264 + gotJSON, _ := json.Marshal(got)
265 + wantJSON, _ := json.Marshal(want)
266 +
267 + if string(gotJSON) != string(wantJSON) {
268 + t.Errorf("xmlToJSON() = %s, want %s", gotJSON, wantJSON)
269 + }
270 + })
271 + }
272 +}
273 +
274 +// TestXMLToJSON_Rule6_TextWithAttributes tests Rule 6: Text with attributes uses #text key
275 +func TestXMLToJSON_Rule6_TextWithAttributes(t *testing.T) {
276 + tests := []struct {
277 + name string
278 + xml string
279 + expected string
280 + }{
281 + {
282 + name: "Text with single attribute",
283 + xml: `<root><foo bar="BAR">BAZ</foo></root>`,
284 + expected: `{"root":{"foo":{"#text":"BAZ","@bar":"BAR"}}}`,
285 + },
286 + {
287 + name: "Text with multiple attributes",
288 + xml: `<root><item id="1" type="text">Value</item></root>`,
289 + expected: `{"root":{"item":{"#text":"Value","@id":"1","@type":"text"}}}`,
290 + },
291 + {
292 + name: "Root element with text and attributes",
293 + xml: `<root version="1.0">Content</root>`,
294 + expected: `{"root":{"#text":"Content","@version":"1.0"}}`,
295 + },
296 + }
297 +
298 + for _, tt := range tests {
299 + t.Run(tt.name, func(t *testing.T) {
300 + value := Value{
301 + Data: tt.xml,
302 + Type: ValueTypeStr,
303 + Timestamp: time.Now(),
304 + }
305 +
306 + result, err := xmlToJSON(value, "")
307 + if err != nil {
308 + t.Fatalf("xmlToJSON() error = %v", err)
309 + }
310 +
311 + var got, want interface{}
312 + json.Unmarshal([]byte(result.Data), &got)
313 + json.Unmarshal([]byte(tt.expected), &want)
314 +
315 + gotJSON, _ := json.Marshal(got)
316 + wantJSON, _ := json.Marshal(want)
317 +
318 + if string(gotJSON) != string(wantJSON) {
319 + t.Errorf("xmlToJSON() = %s, want %s", gotJSON, wantJSON)
320 + }
321 + })
322 + }
323 +}
324 +
325 +// TestXMLToJSON_ComplexCombinations tests complex combinations of all rules
326 +func TestXMLToJSON_ComplexCombinations(t *testing.T) {
327 + tests := []struct {
328 + name string
329 + xml string
330 + expected string
331 + }{
332 + {
333 + name: "Complex nested structure",
334 + xml: `<root>
335 + <user id="123" active="true">
336 + <name>John Doe</name>
337 + <email/>
338 + <tags>
339 + <tag>admin</tag>
340 + <tag>user</tag>
341 + </tags>
342 + </user>
343 + </root>`,
344 + expected: `{"root":{"user":{"@active":"true","@id":"123","email":null,"name":"John Doe","tags":{"tag":["admin","user"]}}}}`,
345 + },
346 + {
347 + name: "Mixed attributes, text, and children",
348 + xml: `<config version="2.0">
349 + <setting name="timeout">30</setting>
350 + <setting name="retries">3</setting>
351 + <debug enabled="false"/>
352 + </config>`,
353 + expected: `{"config":{"@version":"2.0","debug":{"@enabled":"false"},"setting":[{"#text":"30","@name":"timeout"},{"#text":"3","@name":"retries"}]}}`,
354 + },
355 + {
356 + name: "Real-world API response example",
357 + xml: `<response status="success">
358 + <data>
359 + <item id="1" type="product">Widget</item>
360 + <item id="2" type="product">Gadget</item>
361 + <metadata>
362 + <total>2</total>
363 + <page>1</page>
364 + </metadata>
365 + </data>
366 + </response>`,
367 + expected: `{"response":{"@status":"success","data":{"item":[{"#text":"Widget","@id":"1","@type":"product"},{"#text":"Gadget","@id":"2","@type":"product"}],"metadata":{"page":"1","total":"2"}}}}`,
368 + },
369 + }
370 +
371 + for _, tt := range tests {
372 + t.Run(tt.name, func(t *testing.T) {
373 + value := Value{
374 + Data: tt.xml,
375 + Type: ValueTypeStr,
376 + Timestamp: time.Now(),
377 + }
378 +
379 + result, err := xmlToJSON(value, "")
380 + if err != nil {
381 + t.Fatalf("xmlToJSON() error = %v", err)
382 + }
383 +
384 + var got, want interface{}
385 + json.Unmarshal([]byte(result.Data), &got)
386 + json.Unmarshal([]byte(tt.expected), &want)
387 +
388 + gotJSON, _ := json.Marshal(got)
389 + wantJSON, _ := json.Marshal(want)
390 +
391 + if string(gotJSON) != string(wantJSON) {
392 + t.Errorf("xmlToJSON() = %s, want %s", gotJSON, wantJSON)
393 + }
394 + })
395 + }
396 +}
397 +
398 +// TestXMLToJSON_ErrorCases tests error handling
399 +func TestXMLToJSON_ErrorCases(t *testing.T) {
400 + tests := []struct {
401 + name string
402 + xml string
403 + wantErr bool
404 + }{
405 + {
406 + name: "Invalid XML - unclosed tag",
407 + xml: `<root><foo>`,
408 + wantErr: true,
409 + },
410 + {
411 + name: "Invalid XML - mismatched tags",
412 + xml: `<root><foo></bar></root>`,
413 + wantErr: true,
414 + },
415 + {
416 + name: "Empty input",
417 + xml: ``,
418 + wantErr: true,
419 + },
420 + {
421 + name: "Not XML",
422 + xml: `{"json": "data"}`,
423 + wantErr: true,
424 + },
425 + }
426 +
427 + for _, tt := range tests {
428 + t.Run(tt.name, func(t *testing.T) {
429 + value := Value{
430 + Data: tt.xml,
431 + Type: ValueTypeStr,
432 + Timestamp: time.Now(),
433 + }
434 +
435 + _, err := xmlToJSON(value, "")
436 + if (err != nil) != tt.wantErr {
437 + t.Errorf("xmlToJSON() error = %v, wantErr %v", err, tt.wantErr)
438 + }
439 + })
440 + }
441 +}
442 +
443 +// TestXMLToJSON_EdgeCases tests edge cases and special scenarios
444 +func TestXMLToJSON_EdgeCases(t *testing.T) {
445 + tests := []struct {
446 + name string
447 + xml string
448 + expected string
449 + }{
450 + {
451 + name: "Empty root element with attributes",
452 + xml: `<root id="1"/>`,
453 + expected: `{"root":{"@id":"1"}}`,
454 + },
455 + {
456 + name: "Only whitespace text",
457 + xml: `<root> </root>`,
458 + expected: `{"root":null}`,
459 + },
460 + {
461 + name: "CDATA section",
462 + xml: `<root><![CDATA[Special <chars> & stuff]]></root>`,
463 + expected: `{"root":"Special <chars> & stuff"}`,
464 + },
465 + {
466 + name: "XML with namespaces",
467 + xml: `<root xmlns:foo="http://example.com"><bar>value</bar></root>`,
468 + expected: `{"root":{"@foo":"http://example.com","bar":"value"}}`,
469 + },
470 + {
471 + name: "Numeric-looking values",
472 + xml: `<root><num>123</num><float>45.67</float></root>`,
473 + expected: `{"root":{"float":"45.67","num":"123"}}`,
474 + },
475 + }
476 +
477 + for _, tt := range tests {
478 + t.Run(tt.name, func(t *testing.T) {
479 + value := Value{
480 + Data: tt.xml,
481 + Type: ValueTypeStr,
482 + Timestamp: time.Now(),
483 + }
484 +
485 + result, err := xmlToJSON(value, "")
486 + if err != nil {
487 + t.Fatalf("xmlToJSON() error = %v", err)
488 + }
489 +
490 + var got, want interface{}
491 + json.Unmarshal([]byte(result.Data), &got)
492 + json.Unmarshal([]byte(tt.expected), &want)
493 +
494 + gotJSON, _ := json.Marshal(got)
495 + wantJSON, _ := json.Marshal(want)
496 +
497 + if string(gotJSON) != string(wantJSON) {
498 + t.Errorf("xmlToJSON() = %s, want %s", gotJSON, wantJSON)
499 + }
500 + })
501 + }
502 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/xml_to_json_test.go new
+267
@@ -0,0 +1,267 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "encoding/json"
5 + "testing"
6 +)
7 +
8 +// TestXMLToJSONZabbixDocumentation tests all examples from official Zabbix docs.
9 +// Source: https://www.zabbix.com/documentation/6.0/en/manual/config/items/preprocessing/javascript/javascript_objects
10 +func TestXMLToJSONZabbixDocumentation(t *testing.T) {
11 + tests := []struct {
12 + name string
13 + input string
14 + expected string
15 + rule string
16 + }{
17 + {
18 + name: "Rule 1: Attributes prepended with @",
19 + input: `<xml foo="FOO"><bar><baz>BAZ</baz></bar></xml>`,
20 + expected: `{"xml":{"@foo":"FOO","bar":{"baz":"BAZ"}}}`,
21 + rule: "XML attributes will be converted to keys that have their names prepended with '@'",
22 + },
23 + {
24 + name: "Rule 2: Self-closing elements become null",
25 + input: `<xml><foo/></xml>`,
26 + expected: `{"xml":{"foo":null}}`,
27 + rule: "Self-closing elements (<foo/>) will be converted as having 'null' value",
28 + },
29 + {
30 + name: "Rule 3: Empty attributes preserved as empty strings",
31 + input: `<xml><foo bar="" /></xml>`,
32 + expected: `{"xml":{"foo":{"@bar":""}}}`,
33 + rule: "Empty attributes (with \"\" value) will be converted as having empty string ('') value",
34 + },
35 + {
36 + name: "Rule 4: Multiple same-named children become arrays",
37 + input: `<xml><foo>BAR</foo><foo>BAZ</foo><foo>QUX</foo></xml>`,
38 + expected: `{"xml":{"foo":["BAR","BAZ","QUX"]}}`,
39 + rule: "Multiple child nodes with the same element name will be converted to a single key that has an array of values",
40 + },
41 + {
42 + name: "Rule 5: Simple text elements become strings",
43 + input: `<xml><foo>BAZ</foo></xml>`,
44 + expected: `{"xml":{"foo":"BAZ"}}`,
45 + rule: "If a text element has no attributes and no children, it will be converted as a string",
46 + },
47 + {
48 + name: "Rule 6: Text with attributes uses #text",
49 + input: `<xml><foo bar="BAR">BAZ</foo></xml>`,
50 + expected: `{"xml":{"foo":{"@bar":"BAR","#text":"BAZ"}}}`,
51 + rule: "If a text element has no children but has attributes: text content will be converted to an element with the key '#text'",
52 + },
53 + }
54 +
55 + p := NewPreprocessor("test-shard")
56 +
57 + for _, tt := range tests {
58 + t.Run(tt.name, func(t *testing.T) {
59 + input := Value{Data: tt.input, Type: ValueTypeStr}
60 + step := Step{Type: StepTypeXMLToJSON}
61 +
62 + result, err := p.Execute("item1", input, step)
63 + if err != nil {
64 + t.Fatalf("XML to JSON failed: %v", err)
65 + }
66 +
67 + if len(result.Metrics) == 0 {
68 + t.Fatal("Expected metrics in result")
69 + }
70 +
71 + // Compare as JSON structures (order-independent)
72 + var expected, actual interface{}
73 + if err := json.Unmarshal([]byte(tt.expected), &expected); err != nil {
74 + t.Fatalf("Failed to parse expected JSON: %v", err)
75 + }
76 + if err := json.Unmarshal([]byte(result.Metrics[0].Value), &actual); err != nil {
77 + t.Fatalf("Failed to parse actual JSON: %v\nGot: %s", err, result.Metrics[0].Value)
78 + }
79 +
80 + // Marshal back to normalize for comparison
81 + expectedNorm, _ := json.Marshal(expected)
82 + actualNorm, _ := json.Marshal(actual)
83 +
84 + if string(expectedNorm) != string(actualNorm) {
85 + t.Errorf("Rule: %s\nExpected: %s\nGot: %s", tt.rule, tt.expected, result.Metrics[0].Value)
86 + }
87 + })
88 + }
89 +}
90 +
91 +// TestXMLToJSONEdgeCases tests edge cases not in official docs
92 +func TestXMLToJSONEdgeCases(t *testing.T) {
93 + p := NewPreprocessor("test-shard")
94 +
95 + tests := []struct {
96 + name string
97 + input string
98 + expected string
99 + }{
100 + {
101 + name: "Nested elements with attributes",
102 + input: `<root><parent id="1"><child name="test">value</child></parent></root>`,
103 + expected: `{"root":{"parent":{"@id":"1","child":{"@name":"test","#text":"value"}}}}`,
104 + },
105 + {
106 + name: "Multiple different children",
107 + input: `<data><a>1</a><b>2</b><c>3</c></data>`,
108 + expected: `{"data":{"a":"1","b":"2","c":"3"}}`,
109 + },
110 + {
111 + name: "Empty root element",
112 + input: `<empty/>`,
113 + expected: `{"empty":null}`,
114 + },
115 + {
116 + name: "Root with only attributes",
117 + input: `<config version="1.0" debug="true"/>`,
118 + expected: `{"config":{"@version":"1.0","@debug":"true"}}`,
119 + },
120 + {
121 + name: "Mixed repeated and unique children",
122 + input: `<list><item>A</item><item>B</item><unique>C</unique></list>`,
123 + expected: `{"list":{"item":["A","B"],"unique":"C"}}`,
124 + },
125 + {
126 + name: "Deeply nested structure",
127 + input: `<a><b><c><d>deep</d></c></b></a>`,
128 + expected: `{"a":{"b":{"c":{"d":"deep"}}}}`,
129 + },
130 + {
131 + name: "Numeric text content",
132 + input: `<metric><value>42</value><timestamp>1234567890</timestamp></metric>`,
133 + expected: `{"metric":{"value":"42","timestamp":"1234567890"}}`,
134 + },
135 + {
136 + name: "Special characters in text",
137 + input: `<data><msg>Hello &amp; World</msg></data>`,
138 + expected: `{"data":{"msg":"Hello & World"}}`,
139 + },
140 + {
141 + name: "Boolean-like text values",
142 + input: `<flags><enabled>true</enabled><disabled>false</disabled></flags>`,
143 + expected: `{"flags":{"enabled":"true","disabled":"false"}}`,
144 + },
145 + }
146 +
147 + for _, tt := range tests {
148 + t.Run(tt.name, func(t *testing.T) {
149 + input := Value{Data: tt.input, Type: ValueTypeStr}
150 + step := Step{Type: StepTypeXMLToJSON}
151 +
152 + result, err := p.Execute("item1", input, step)
153 + if err != nil {
154 + t.Fatalf("XML to JSON failed: %v", err)
155 + }
156 +
157 + if len(result.Metrics) == 0 {
158 + t.Fatal("Expected metrics in result")
159 + }
160 +
161 + // Compare as JSON structures
162 + var expected, actual interface{}
163 + if err := json.Unmarshal([]byte(tt.expected), &expected); err != nil {
164 + t.Fatalf("Failed to parse expected JSON: %v", err)
165 + }
166 + if err := json.Unmarshal([]byte(result.Metrics[0].Value), &actual); err != nil {
167 + t.Fatalf("Failed to parse actual JSON: %v\nGot: %s", err, result.Metrics[0].Value)
168 + }
169 +
170 + expectedNorm, _ := json.Marshal(expected)
171 + actualNorm, _ := json.Marshal(actual)
172 +
173 + if string(expectedNorm) != string(actualNorm) {
174 + t.Errorf("Expected: %s\nGot: %s", tt.expected, result.Metrics[0].Value)
175 + }
176 + })
177 + }
178 +}
179 +
180 +// TestXMLToJSONErrorHandling tests invalid XML inputs
181 +func TestXMLToJSONErrorHandling(t *testing.T) {
182 + p := NewPreprocessor("test-shard")
183 +
184 + tests := []struct {
185 + name string
186 + input string
187 + }{
188 + {
189 + name: "Malformed XML - unclosed tag",
190 + input: `<root><unclosed>`,
191 + },
192 + {
193 + name: "Malformed XML - mismatched tags",
194 + input: `<root><child></wrong></root>`,
195 + },
196 + {
197 + name: "Empty input",
198 + input: ``,
199 + },
200 + {
201 + name: "Not XML at all",
202 + input: `This is just plain text`,
203 + },
204 + }
205 +
206 + for _, tt := range tests {
207 + t.Run(tt.name, func(t *testing.T) {
208 + input := Value{Data: tt.input, Type: ValueTypeStr}
209 + step := Step{Type: StepTypeXMLToJSON}
210 +
211 + _, err := p.Execute("item1", input, step)
212 + if err == nil {
213 + t.Errorf("Expected error for invalid XML input: %s", tt.input)
214 + }
215 + })
216 + }
217 +}
218 +
219 +// TestXMLToJSONLLDCompatible tests that output is compatible with Zabbix LLD
220 +func TestXMLToJSONLLDCompatible(t *testing.T) {
221 + p := NewPreprocessor("test-shard")
222 +
223 + // Example: XML that could be used for LLD discovery
224 + input := `<discovery>
225 + <host name="server1" ip="192.168.1.1"/>
226 + <host name="server2" ip="192.168.1.2"/>
227 + <host name="server3" ip="192.168.1.3"/>
228 + </discovery>`
229 +
230 + step := Step{Type: StepTypeXMLToJSON}
231 + result, err := p.Execute("item1", Value{Data: input, Type: ValueTypeStr}, step)
232 + if err != nil {
233 + t.Fatalf("XML to JSON failed: %v", err)
234 + }
235 +
236 + // Parse result
237 + var data map[string]interface{}
238 + if err := json.Unmarshal([]byte(result.Metrics[0].Value), &data); err != nil {
239 + t.Fatalf("Failed to parse JSON: %v", err)
240 + }
241 +
242 + // Verify structure is compatible with LLD
243 + discovery, ok := data["discovery"].(map[string]interface{})
244 + if !ok {
245 + t.Fatal("Expected 'discovery' key in output")
246 + }
247 +
248 + hosts, ok := discovery["host"].([]interface{})
249 + if !ok {
250 + t.Fatal("Expected 'host' to be an array (multiple hosts)")
251 + }
252 +
253 + if len(hosts) != 3 {
254 + t.Errorf("Expected 3 hosts, got %d", len(hosts))
255 + }
256 +
257 + // Each host should have @name and @ip attributes
258 + for i, h := range hosts {
259 + host := h.(map[string]interface{})
260 + if _, ok := host["@name"]; !ok {
261 + t.Errorf("Host %d missing @name attribute", i)
262 + }
263 + if _, ok := host["@ip"]; !ok {
264 + t.Errorf("Host %d missing @ip attribute", i)
265 + }
266 + }
267 +}
src/go/plugin/scripts.d/pkg/zabbixpreproc/xpath_step.go new
+107
@@ -0,0 +1,107 @@
1 +package zabbixpreproc
2 +
3 +import (
4 + "fmt"
5 + "math"
6 + "regexp"
7 + "strings"
8 +
9 + "github.com/antchfx/xmlquery"
10 + "github.com/antchfx/xpath"
11 +)
12 +
13 +// XPath regex patterns compiled once at package init
14 +var (
15 + // Matches empty XML tags like <tag></tag> or <tag attr="value"></tag>
16 + xpathEmptyTagRegex = regexp.MustCompile(`<([a-zA-Z][a-zA-Z0-9]*)(\s+[^>]*)?></([a-zA-Z][a-zA-Z0-9]*)>`)
17 +)
18 +
19 +// xpathExtract extracts values from XML using XPath expression.
20 +func xpathExtract(value Value, paramStr string) (Value, error) {
21 + expr := strings.TrimSpace(paramStr)
22 + if expr == "" {
23 + return Value{}, fmt.Errorf("xpath expression is required")
24 + }
25 +
26 + // Parse XML
27 + doc, err := xmlquery.Parse(strings.NewReader(value.Data))
28 + if err != nil {
29 + return Value{}, fmt.Errorf("invalid XML: %w", err)
30 + }
31 +
32 + // Compile and evaluate XPath expression
33 + xpathExpr, err := xpath.Compile(expr)
34 + if err != nil {
35 + return Value{}, fmt.Errorf("invalid xpath expression: %w", err)
36 + }
37 +
38 + // Evaluate the expression
39 + result := xpathExpr.Evaluate(xmlquery.CreateXPathNavigator(doc))
40 +
41 + // Handle different result types
42 + switch v := result.(type) {
43 + case *xpath.NodeIterator:
44 + // Node selection - collect nodes
45 + var output strings.Builder
46 + for v.MoveNext() {
47 + node := v.Current().(*xmlquery.NodeNavigator).Current()
48 +
49 + // For text and attribute nodes, return the data
50 + // For element nodes, return the XML representation
51 + if node.Type == xmlquery.TextNode || node.Type == xmlquery.AttributeNode {
52 + output.WriteString(node.Data)
53 + } else {
54 + xml := node.OutputXML(true)
55 + // Convert empty tags to self-closing format (e.g., <a></a> to <a/>)
56 + xml = convertToSelfClosingTags(xml)
57 + output.WriteString(xml)
58 + }
59 + }
60 + return Value{Data: output.String(), Type: ValueTypeStr}, nil
61 +
62 + case string:
63 + // String result from string() function
64 + return Value{Data: v, Type: ValueTypeStr}, nil
65 +
66 + case float64:
67 + // Numeric result from arithmetic expressions
68 + // Check for invalid numeric results
69 + if math.IsInf(v, 0) || math.IsNaN(v) {
70 + return Value{}, fmt.Errorf("invalid numeric result from XPath expression")
71 + }
72 + return Value{Data: fmt.Sprintf("%g", v), Type: ValueTypeStr}, nil
73 +
74 + case bool:
75 + // Boolean result from boolean expressions
76 + if v {
77 + return Value{Data: "true", Type: ValueTypeStr}, nil
78 + }
79 + return Value{Data: "false", Type: ValueTypeStr}, nil
80 +
81 + default:
82 + // Unknown type - convert to string
83 + return Value{Data: fmt.Sprint(v), Type: ValueTypeStr}, nil
84 + }
85 +}
86 +
87 +// convertToSelfClosingTags converts empty XML tags to self-closing format
88 +func convertToSelfClosingTags(xml string) string {
89 + // Match patterns like <tag></tag> or <tag attr="value"></tag>
90 + // Note: Go regex doesn't support backreferences in pattern, so we match and check manually
91 + return xpathEmptyTagRegex.ReplaceAllStringFunc(xml, func(match string) string {
92 + // Extract the tag names from opening and closing tags
93 + submatches := xpathEmptyTagRegex.FindStringSubmatch(match)
94 + if len(submatches) >= 4 {
95 + openTag := submatches[1]
96 + attrs := submatches[2]
97 + closeTag := submatches[3]
98 +
99 + // Only convert if opening and closing tags match
100 + if openTag == closeTag {
101 + return "<" + openTag + attrs + "/>"
102 + }
103 + }
104 + // Return unchanged if tags don't match
105 + return match
106 + })
107 +}
src/go/plugin/scripts.d/tests/mock_integration_test.go new
+296
@@ -0,0 +1,296 @@
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/go/plugin/scripts.d/tests/plugins/check_mock_crit.sh new
+4
@@ -0,0 +1,4 @@
1 +#!/usr/bin/env bash
2 +set -euo pipefail
3 +echo "CRITICAL - disk usage | disk=95%;80;90;0;100"
4 +exit 2
src/go/plugin/scripts.d/tests/plugins/check_mock_long.sh new
+5
@@ -0,0 +1,5 @@
1 +#!/usr/bin/env bash
2 +set -euo pipefail
3 +echo "OK - multiline output | metric=5;;;;"
4 +echo "line-one details"
5 +echo "line-two extra | secondary=1;;;;"
src/go/plugin/scripts.d/tests/plugins/check_mock_macro.sh new
+13
@@ -0,0 +1,13 @@
1 +#!/usr/bin/env bash
2 +set -euo pipefail
3 +cat <<MSG
4 +OK - macro check | value=1;;;;
5 +HOSTNAME=$NAGIOS_HOSTNAME
6 +HOSTADDRESS=$NAGIOS_HOSTADDRESS
7 +HOSTALIAS=$NAGIOS_HOSTALIAS
8 +SERVICEATTEMPT=$NAGIOS_SERVICEATTEMPT
9 +MAXSERVICEATTEMPTS=$NAGIOS_MAXSERVICEATTEMPTS
10 +HOSTLABEL_REGION=${NAGIOS__HOSTLABEL_REGION:-}
11 +HOST_CUSTOM_DC=${NAGIOS__HOSTDC:-}
12 +ARG1=$NAGIOS_ARG1
13 +MSG
src/go/plugin/scripts.d/tests/plugins/check_mock_ok.sh new
+3
@@ -0,0 +1,3 @@
1 +#!/usr/bin/env bash
2 +set -euo pipefail
3 +echo "OK - mock success | 'value'=42;50;80;0;100"
src/go/plugin/scripts.d/tests/plugins/check_mock_slow.sh new
+4
@@ -0,0 +1,4 @@
1 +#!/usr/bin/env bash
2 +set -euo pipefail
3 +sleep "${1:-3}"
4 +echo "OK - slow execution | slept=${1:-3}s"
src/go/plugin/scripts.d/tests/plugins/check_mock_warn.sh new
+4
@@ -0,0 +1,4 @@
1 +#!/usr/bin/env bash
2 +set -euo pipefail
3 +echo "WARNING - latency high | latency=120ms;100;200;10;300 throughput=30KB;20;40;0;100"
4 +exit 1
src/health/health.d/nagios.conf new
+16
@@ -0,0 +1,16 @@
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 new
+16
@@ -0,0 +1,16 @@
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 new
+17
@@ -0,0 +1,17 @@
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