master
go 1,257 lines 43.1 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package nagios
4
5 import (
6 "context"
7 "encoding/json"
8 "errors"
9 "os"
10 "path/filepath"
11 "runtime"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/netdata/netdata/go/plugins/pkg/confopt"
17 "github.com/netdata/netdata/go/plugins/pkg/metrix"
18 "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
19 "github.com/netdata/netdata/go/plugins/plugin/framework/charttpl"
20 "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
21 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/collecttest"
22 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/ndexec"
23 "github.com/netdata/netdata/go/plugins/plugin/scripts.d/collector/nagios/internal/output"
24 "github.com/netdata/netdata/go/plugins/plugin/scripts.d/pkg/timeperiod"
25 "github.com/stretchr/testify/assert"
26 "github.com/stretchr/testify/require"
27 )
28
29 func TestCollector_ChartTemplateYAML(t *testing.T) {
30 templateYAML := New().ChartTemplateYAML()
31 collecttest.AssertChartTemplateSchema(t, templateYAML)
32
33 specYAML, err := charttpl.DecodeYAML([]byte(templateYAML))
34 require.NoError(t, err)
35 require.NoError(t, specYAML.Validate())
36 _, err = chartengine.Compile(specYAML, 1)
37 require.NoError(t, err)
38
39 tests := map[string]struct {
40 context string
41 selector string
42 wantFloat bool
43 }{
44 "execution duration dimension is float": {
45 context: "execution_duration",
46 selector: "job.execution_duration",
47 wantFloat: true,
48 },
49 "execution cpu dimension is float": {
50 context: "execution_cpu",
51 selector: "job.execution_cpu_total",
52 wantFloat: true,
53 },
54 "execution memory dimension is integer": {
55 context: "execution_memory",
56 selector: "job.execution_max_rss",
57 wantFloat: false,
58 },
59 }
60
61 for name, tc := range tests {
62 t.Run(name, func(t *testing.T) {
63 dim, ok := findChartDimensionByContext(specYAML, tc.context)
64 require.True(t, ok, "missing chart context %q", tc.context)
65 assert.Equal(t, tc.selector, dim.Selector)
66 if tc.wantFloat {
67 require.NotNil(t, dim.Options)
68 assert.True(t, dim.Options.Float)
69 return
70 }
71 if dim.Options != nil {
72 assert.False(t, dim.Options.Float)
73 }
74 })
75 }
76 }
77
78 func TestCollector_ConfigSchema(t *testing.T) {
79 tests := map[string]struct {
80 assert func(*testing.T, nagiosConfigSchemaDoc)
81 }{
82 "wrapped schema follows collector conventions": {
83 assert: func(t *testing.T, doc nagiosConfigSchemaDoc) {
84 t.Helper()
85 assert.NotEmpty(t, doc.JSONSchema.Schema)
86 _, hasPlugin := doc.JSONSchema.Properties["plugin"]
87 _, hasCheckName := doc.JSONSchema.Properties["check_name"]
88 _, hasName := doc.JSONSchema.Properties["name"]
89 _, hasTimeoutState := doc.JSONSchema.Properties["timeout_state"]
90 _, hasUIOptions := doc.UISchema["uiOptions"]
91 assert.True(t, hasPlugin)
92 assert.True(t, hasCheckName)
93 assert.False(t, hasName)
94 assert.False(t, hasTimeoutState)
95 assert.True(t, hasUIOptions)
96 },
97 },
98 }
99
100 for name, tc := range tests {
101 t.Run(name, func(t *testing.T) {
102 var doc nagiosConfigSchemaDoc
103 require.NoError(t, json.Unmarshal([]byte(configSchema), &doc))
104 tc.assert(t, doc)
105 })
106 }
107 }
108
109 func TestCollector_New(t *testing.T) {
110 tests := map[string]struct {
111 assert func(*testing.T, *Collector)
112 }{
113 "exposes runtime defaults on the live collector config": {
114 assert: func(t *testing.T, coll *Collector) {
115 t.Helper()
116 assert.Equal(t, defaultCollectorUpdateEvery, coll.Config.UpdateEvery)
117 assert.Equal(t, confDuration(5*time.Second), coll.Config.JobConfig.Timeout)
118 assert.Equal(t, confDuration(5*time.Minute), coll.Config.JobConfig.CheckInterval)
119 assert.Equal(t, confDuration(1*time.Minute), coll.Config.JobConfig.RetryInterval)
120 assert.Equal(t, 3, coll.Config.JobConfig.MaxCheckAttempts)
121 assert.Equal(t, "24x7", coll.Config.JobConfig.CheckPeriod)
122 require.NotNil(t, coll.Config.JobConfig.Environment)
123 require.NotNil(t, coll.Config.JobConfig.CustomVars)
124 assert.Empty(t, coll.Config.JobConfig.Environment)
125 assert.Empty(t, coll.Config.JobConfig.CustomVars)
126 },
127 },
128 }
129
130 for name, tc := range tests {
131 t.Run(name, func(t *testing.T) {
132 coll := New()
133 tc.assert(t, coll)
134 })
135 }
136 }
137
138 type nagiosConfigSchemaDoc struct {
139 JSONSchema struct {
140 Schema string `json:"$schema"`
141 Properties map[string]json.RawMessage `json:"properties"`
142 } `json:"jsonSchema"`
143 UISchema map[string]json.RawMessage `json:"uiSchema"`
144 }
145
146 func TestCollector_Check(t *testing.T) {
147 truePluginPath := writeTestPluginFile(t, "true")
148
149 tests := map[string]struct {
150 config Config
151 wantErr bool
152 errMatch string
153 }{
154 "missing plugin": {
155 config: Config{JobConfig: JobConfig{Name: "invalid-without-plugin"}},
156 wantErr: true,
157 errMatch: "plugin path is required",
158 },
159 "update_every exceeds cadence": {
160 config: Config{
161 UpdateEvery: 10,
162 JobConfig: JobConfig{
163 Name: "cadence",
164 Plugin: truePluginPath,
165 CheckInterval: confDuration(5 * time.Second),
166 RetryInterval: confDuration(5 * time.Second),
167 },
168 },
169 wantErr: false,
170 },
171 "valid config": {
172 config: Config{
173 UpdateEvery: 1,
174 JobConfig: JobConfig{
175 Name: "valid",
176 Plugin: truePluginPath,
177 CheckInterval: confDuration(5 * time.Second),
178 RetryInterval: confDuration(5 * time.Second),
179 },
180 },
181 },
182 }
183
184 for name, tc := range tests {
185 t.Run(name, func(t *testing.T) {
186 coll := newTestCollector()
187 coll.runner = &fakeRunner{}
188 coll.Config = tc.config
189
190 err := coll.Check(context.Background())
191 if tc.wantErr {
192 require.Error(t, err)
193 if tc.errMatch != "" {
194 assert.Contains(t, err.Error(), tc.errMatch)
195 }
196 return
197 }
198 require.NoError(t, err)
199 })
200 }
201 }
202
203 func TestIsKnownInterpreter(t *testing.T) {
204 tests := map[string]struct {
205 path string
206 want bool
207 }{
208 "bash": {path: "/bin/bash", want: true},
209 "sh": {path: "/bin/sh", want: true},
210 "python3": {path: "/usr/bin/python3", want: true},
211 "python3 versioned": {path: "/usr/bin/python3.11", want: true},
212 "powershell": {path: "/usr/bin/powershell", want: true},
213 "powershell.exe": {path: "/powershell.exe", want: true},
214 "pwsh": {path: "/usr/bin/pwsh", want: true},
215 "env": {path: "/usr/bin/env", want: true},
216 "node": {path: "/usr/bin/node", want: true},
217 "cmd.exe": {path: "/cmd.exe", want: true},
218 "php": {path: "/usr/bin/php", want: true},
219 "check_ping": {path: "/usr/lib/nagios/plugins/check_ping", want: false},
220 "check_http": {path: "/usr/lib/nagios/plugins/check_http", want: false},
221 "custom script": {path: "/opt/netdata/checks/check_api.sh", want: false},
222 "custom exe": {path: "/opt/checks/check_service.exe", want: false},
223 }
224
225 for name, tc := range tests {
226 t.Run(name, func(t *testing.T) {
227 assert.Equal(t, tc.want, isKnownInterpreter(tc.path))
228 })
229 }
230 }
231
232 func TestCompileCollectorConfig_CadenceWarning(t *testing.T) {
233 tests := map[string]struct {
234 config Config
235 wantErr bool
236 wantWarning bool
237 }{
238 "warning when update_every exceeds retry interval": {
239 config: Config{
240 UpdateEvery: 10,
241 JobConfig: JobConfig{
242 Name: "cadence-warning",
243 Plugin: "/bin/true",
244 CheckInterval: confDuration(10 * time.Second),
245 RetryInterval: confDuration(2 * time.Second),
246 },
247 },
248 wantWarning: true,
249 },
250 "no warning when cadence fits update_every": {
251 config: Config{
252 UpdateEvery: 1,
253 JobConfig: JobConfig{
254 Name: "cadence-ok",
255 Plugin: "/bin/true",
256 CheckInterval: confDuration(5 * time.Second),
257 RetryInterval: confDuration(5 * time.Second),
258 },
259 },
260 },
261 "invalid config still fails": {
262 config: Config{
263 JobConfig: JobConfig{
264 Name: "invalid",
265 },
266 },
267 wantErr: true,
268 },
269 }
270
271 for name, tc := range tests {
272 t.Run(name, func(t *testing.T) {
273 job, err := compileCollectorConfig(tc.config)
274 if tc.wantErr {
275 require.Error(t, err)
276 return
277 }
278 require.NoError(t, err)
279 assert.Equal(t, tc.wantWarning, job.cadenceWarning != "", "warning: %q", job.cadenceWarning)
280 })
281 }
282 }
283
284 func TestCollector_Init(t *testing.T) {
285 truePluginPath := writeTestPluginFile(t, "true")
286
287 tests := map[string]struct {
288 config Config
289 assert func(*testing.T, *Collector)
290 }{
291 "default timing comes from spec": {
292 config: Config{
293 JobConfig: JobConfig{
294 Name: "defaults",
295 Plugin: truePluginPath,
296 },
297 },
298 assert: func(t *testing.T, coll *Collector) {
299 t.Helper()
300 assert.Equal(t, confDuration(5*time.Minute), coll.job.config.CheckInterval)
301 assert.Equal(t, confDuration(1*time.Minute), coll.job.config.RetryInterval)
302 },
303 },
304 }
305
306 for name, tc := range tests {
307 t.Run(name, func(t *testing.T) {
308 coll := newTestCollector()
309 coll.runner = &fakeRunner{}
310 coll.Config = tc.config
311 require.NoError(t, coll.Init(context.Background()))
312 tc.assert(t, coll)
313 })
314 }
315 }
316
317 func TestCollector_Collect(t *testing.T) {
318 truePluginPath := writeTestPluginFile(t, "true")
319 pwshPluginPath := writeTestPluginFile(t, "pwsh")
320
321 tests := map[string]struct {
322 results []fakeRun
323 config Config
324 setup func(*Collector, *fakeRunner, *time.Time)
325 run func(*testing.T, *Collector, *fakeRunner, *time.Time)
326 }{
327 "replays cached metrics when not due": {
328 results: []fakeRun{
329 {
330 result: checkRunResult{
331 ServiceState: "OK",
332 JobState: "OK",
333 Duration: 2500 * time.Millisecond,
334 Usage: ndexec.ResourceUsage{
335 User: 300 * time.Millisecond,
336 System: 200 * time.Millisecond,
337 MaxRSSBytes: 12345,
338 },
339 Parsed: output.ParsedOutput{
340 Perfdata: []output.PerfDatum{
341 {Label: "used", Unit: "KB", Value: 30},
342 },
343 },
344 },
345 },
346 },
347 config: Config{
348 UpdateEvery: 1,
349 JobConfig: JobConfig{
350 Name: "check_disk",
351 Plugin: truePluginPath,
352 CheckInterval: confDuration(5 * time.Minute),
353 RetryInterval: confDuration(1 * time.Minute),
354 },
355 },
356 run: func(t *testing.T, coll *Collector, runner *fakeRunner, now *time.Time) {
357 t.Helper()
358 runCollectCycle(t, coll)
359 assert.Equal(t, 1, runner.calls)
360
361 read := coll.MetricStore().Read(metrix.ReadRaw())
362 flat := coll.MetricStore().Read(metrix.ReadFlatten())
363 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "check_disk", "job.execution_state": "ok"}, 1)
364 assertMetricValue(t, flat, "perfdata.true.job.execution_state", metrix.Labels{"nagios_job": "check_disk", "perfdata.true.job.execution_state": "ok"}, 1)
365 assertMetricChartFamily(t, flat, "perfdata.true.job.execution_state", "Perfdata/true")
366 assertMetricValue(t, flat, "job.execution_duration", metrix.Labels{"nagios_job": "check_disk"}, 2.5)
367 assertMetricMeta(t, flat, "job.execution_duration", "seconds", true)
368 if runtime.GOOS != "windows" {
369 assertMetricValue(t, flat, "job.execution_cpu_total", metrix.Labels{"nagios_job": "check_disk"}, 0.5)
370 assertMetricValue(t, flat, "job.execution_max_rss", metrix.Labels{"nagios_job": "check_disk"}, 12345)
371 assertMetricMeta(t, flat, "job.execution_cpu_total", "seconds", true)
372 assertMetricMeta(t, flat, "job.execution_max_rss", "bytes", false)
373 } else {
374 assertMetricMissing(t, flat, "job.execution_cpu_total", metrix.Labels{"nagios_job": "check_disk"})
375 assertMetricMissing(t, flat, "job.execution_max_rss", metrix.Labels{"nagios_job": "check_disk"})
376 }
377 assertMetricValue(t, flat, "perfdata.true.bytes_used_value", metrix.Labels{"nagios_job": "check_disk", metrix.MeasureSetFieldLabel: "value"}, 30000)
378 point, ok := read.MeasureSet("perfdata.true.bytes_used", metrix.Labels{"nagios_job": "check_disk"})
379 require.True(t, ok)
380 assert.Equal(t, 30000.0, point.Values[0])
381
382 *now = now.Add(1 * time.Second)
383 runCollectCycle(t, coll)
384 assert.Equal(t, 1, runner.calls)
385
386 flat = coll.MetricStore().Read(metrix.ReadFlatten())
387 assertMetricValue(t, flat, "job.execution_duration", metrix.Labels{"nagios_job": "check_disk"}, 0)
388 if runtime.GOOS != "windows" {
389 assertMetricValue(t, flat, "job.execution_cpu_total", metrix.Labels{"nagios_job": "check_disk"}, 0)
390 assertMetricValue(t, flat, "job.execution_max_rss", metrix.Labels{"nagios_job": "check_disk"}, 0)
391 } else {
392 assertMetricMissing(t, flat, "job.execution_cpu_total", metrix.Labels{"nagios_job": "check_disk"})
393 assertMetricMissing(t, flat, "job.execution_max_rss", metrix.Labels{"nagios_job": "check_disk"})
394 }
395 assertMetricValue(t, flat, "perfdata.true.bytes_used_value", metrix.Labels{"nagios_job": "check_disk", metrix.MeasureSetFieldLabel: "value"}, 30000)
396 },
397 },
398 "uses explicit check name for perfdata namespace": {
399 results: []fakeRun{
400 {
401 result: checkRunResult{
402 ServiceState: "OK",
403 JobState: "OK",
404 Parsed: output.ParsedOutput{
405 Perfdata: []output.PerfDatum{
406 {Label: "used", Unit: "KB", Value: 30},
407 },
408 },
409 },
410 },
411 },
412 config: Config{
413 UpdateEvery: 1,
414 JobConfig: JobConfig{
415 Name: "check_service_job",
416 CheckName: "check_service",
417 Plugin: pwshPluginPath,
418 Args: []string{"-NoProfile", "-File", "/opt/netdata/check_service.ps1"},
419 CheckInterval: confDuration(5 * time.Minute),
420 RetryInterval: confDuration(1 * time.Minute),
421 },
422 },
423 run: func(t *testing.T, coll *Collector, runner *fakeRunner, now *time.Time) {
424 t.Helper()
425 runCollectCycle(t, coll)
426 assert.Equal(t, 1, runner.calls)
427
428 flat := coll.MetricStore().Read(metrix.ReadFlatten())
429 assertMetricValue(t, flat, "perfdata.check_service.job.execution_state", metrix.Labels{"nagios_job": "check_service_job", "perfdata.check_service.job.execution_state": "ok"}, 1)
430 assertMetricChartFamily(t, flat, "perfdata.check_service.job.execution_state", "Perfdata/check_service")
431 assertMetricValue(t, flat, "perfdata.check_service.bytes_used_value", metrix.Labels{"nagios_job": "check_service_job", metrix.MeasureSetFieldLabel: "value"}, 30000)
432 assertMetricMissing(t, flat, "perfdata.pwsh.job.execution_state", metrix.Labels{"nagios_job": "check_service_job", "perfdata.pwsh.job.execution_state": "ok"})
433 assertMetricMissing(t, flat, "perfdata.pwsh.bytes_used_value", metrix.Labels{"nagios_job": "check_service_job", metrix.MeasureSetFieldLabel: "value"})
434
435 *now = now.Add(1 * time.Second)
436 },
437 },
438 "check period blocked cycles pause job state and zero threshold states": {
439 results: []fakeRun{
440 {
441 result: checkRunResult{
442 ServiceState: "OK",
443 JobState: "OK",
444 Parsed: output.ParsedOutput{
445 Perfdata: []output.PerfDatum{
446 func() output.PerfDatum {
447 low := 0.0
448 high := 20.0
449 return output.PerfDatum{
450 Label: "used",
451 Unit: "KB",
452 Value: 30,
453 Warn: &output.ThresholdRange{Low: &low, High: &high},
454 }
455 }(),
456 },
457 },
458 },
459 },
460 {
461 result: checkRunResult{
462 ServiceState: "OK",
463 JobState: "OK",
464 Parsed: output.ParsedOutput{
465 Perfdata: []output.PerfDatum{
466 func() output.PerfDatum {
467 low := 0.0
468 high := 20.0
469 return output.PerfDatum{
470 Label: "used",
471 Unit: "KB",
472 Value: 10,
473 Warn: &output.ThresholdRange{Low: &low, High: &high},
474 }
475 }(),
476 },
477 },
478 },
479 },
480 },
481 config: Config{
482 UpdateEvery: 1,
483 JobConfig: JobConfig{
484 Name: "period_job",
485 Plugin: truePluginPath,
486 CheckInterval: confDuration(1 * time.Hour),
487 RetryInterval: confDuration(1 * time.Minute),
488 CheckPeriod: "business",
489 },
490 TimePeriods: []timeperiod.Config{
491 {
492 Name: "business",
493 Rules: []timeperiod.RuleConfig{
494 {
495 Type: "weekly",
496 Days: []string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"},
497 Ranges: []string{"09:00-18:00"},
498 },
499 },
500 },
501 },
502 },
503 run: func(t *testing.T, coll *Collector, runner *fakeRunner, now *time.Time) {
504 t.Helper()
505 runCollectCycle(t, coll)
506 assert.Equal(t, 1, runner.calls)
507
508 flat := coll.MetricStore().Read(metrix.ReadFlatten())
509 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "period_job", "job.execution_state": "ok"}, 1)
510 assertMetricValue(t, flat, "perfdata.true.job.execution_state", metrix.Labels{"nagios_job": "period_job", "perfdata.true.job.execution_state": "ok"}, 1)
511 assertMetricValue(t, flat, "perfdata.true.bytes_used_value", metrix.Labels{"nagios_job": "period_job", metrix.MeasureSetFieldLabel: "value"}, 30000)
512 assertMetricValue(t, flat, "job.perfdata.threshold_state", metrix.Labels{
513 "nagios_job": "period_job",
514 perfdataValueLabelKey: "bytes_used",
515 "job.perfdata.threshold_state": perfThresholdStateWarning,
516 }, 1)
517
518 raw := coll.MetricStore().Read()
519 thresholdMetric := "perfdata.true.bytes_used_threshold_state"
520 thresholdLabels := metrix.Labels{"nagios_job": "period_job"}
521 point, ok := raw.StateSet(thresholdMetric, thresholdLabels)
522 require.True(t, ok)
523 assert.True(t, point.States[perfThresholdStateWarning])
524 alertThresholdMetric := "job.perfdata.threshold_state"
525 alertThresholdLabels := metrix.Labels{"nagios_job": "period_job", perfdataValueLabelKey: "bytes_used"}
526 alertPoint, ok := raw.StateSet(alertThresholdMetric, alertThresholdLabels)
527 require.True(t, ok)
528 assert.True(t, alertPoint.States[perfThresholdStateWarning])
529
530 *now = time.Date(2026, 3, 23, 20, 0, 0, 0, time.UTC)
531 runCollectCycle(t, coll)
532 assert.Equal(t, 1, runner.calls)
533
534 flat = coll.MetricStore().Read(metrix.ReadFlatten())
535 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "period_job", "job.execution_state": "paused"}, 1)
536 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "period_job", "job.execution_state": "retry"}, 0)
537 assertMetricValue(t, flat, "perfdata.true.job.execution_state", metrix.Labels{"nagios_job": "period_job", "perfdata.true.job.execution_state": "paused"}, 1)
538 assertMetricValue(t, flat, "perfdata.true.job.execution_state", metrix.Labels{"nagios_job": "period_job", "perfdata.true.job.execution_state": "retry"}, 0)
539 assertMetricValue(t, flat, "perfdata.true.bytes_used_value", metrix.Labels{"nagios_job": "period_job", metrix.MeasureSetFieldLabel: "value"}, 30000)
540 assertMetricValue(t, flat, thresholdMetric, metrix.Labels{"nagios_job": "period_job", thresholdMetric: perfThresholdStateWarning}, 0)
541 assertMetricValue(t, flat, thresholdMetric, metrix.Labels{"nagios_job": "period_job", thresholdMetric: perfThresholdStateOK}, 0)
542 assertMetricValue(t, flat, thresholdMetric, metrix.Labels{"nagios_job": "period_job", thresholdMetric: perfThresholdStateCritical}, 0)
543 assertMetricValue(t, flat, thresholdMetric, metrix.Labels{"nagios_job": "period_job", thresholdMetric: perfThresholdStateNone}, 0)
544 assertMetricValue(t, flat, alertThresholdMetric, metrix.Labels{"nagios_job": "period_job", perfdataValueLabelKey: "bytes_used", alertThresholdMetric: perfThresholdStateWarning}, 0)
545 assertMetricValue(t, flat, alertThresholdMetric, metrix.Labels{"nagios_job": "period_job", perfdataValueLabelKey: "bytes_used", alertThresholdMetric: perfThresholdStateOK}, 0)
546 assertMetricValue(t, flat, alertThresholdMetric, metrix.Labels{"nagios_job": "period_job", perfdataValueLabelKey: "bytes_used", alertThresholdMetric: perfThresholdStateCritical}, 0)
547 assertMetricValue(t, flat, alertThresholdMetric, metrix.Labels{"nagios_job": "period_job", perfdataValueLabelKey: "bytes_used", alertThresholdMetric: perfThresholdStateNone}, 0)
548 assertMetricValue(t, flat, alertThresholdMetric, metrix.Labels{"nagios_job": "period_job", perfdataValueLabelKey: "bytes_used", alertThresholdMetric: perfThresholdStateRetry}, 0)
549
550 raw = coll.MetricStore().Read()
551 point, ok = raw.StateSet(thresholdMetric, thresholdLabels)
552 require.True(t, ok)
553 assert.False(t, point.States[perfThresholdStateNone])
554 assert.False(t, point.States[perfThresholdStateOK])
555 assert.False(t, point.States[perfThresholdStateWarning])
556 assert.False(t, point.States[perfThresholdStateCritical])
557 alertPoint, ok = raw.StateSet(alertThresholdMetric, alertThresholdLabels)
558 require.True(t, ok)
559 assert.False(t, alertPoint.States[perfThresholdStateNone])
560 assert.False(t, alertPoint.States[perfThresholdStateOK])
561 assert.False(t, alertPoint.States[perfThresholdStateWarning])
562 assert.False(t, alertPoint.States[perfThresholdStateCritical])
563 assert.False(t, alertPoint.States[perfThresholdStateRetry])
564
565 *now = time.Date(2026, 3, 24, 9, 0, 0, 0, time.UTC)
566 runCollectCycle(t, coll)
567 assert.Equal(t, 2, runner.calls)
568
569 flat = coll.MetricStore().Read(metrix.ReadFlatten())
570 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "period_job", "job.execution_state": "ok"}, 1)
571 assertMetricValue(t, flat, "perfdata.true.job.execution_state", metrix.Labels{"nagios_job": "period_job", "perfdata.true.job.execution_state": "ok"}, 1)
572 assertMetricValue(t, flat, "perfdata.true.bytes_used_value", metrix.Labels{"nagios_job": "period_job", metrix.MeasureSetFieldLabel: "value"}, 10000)
573
574 raw = coll.MetricStore().Read()
575 point, ok = raw.StateSet(thresholdMetric, thresholdLabels)
576 require.True(t, ok)
577 assert.False(t, point.States[perfThresholdStateNone])
578 assert.True(t, point.States[perfThresholdStateOK])
579 assert.False(t, point.States[perfThresholdStateWarning])
580 assert.False(t, point.States[perfThresholdStateCritical])
581 alertPoint, ok = raw.StateSet(alertThresholdMetric, alertThresholdLabels)
582 require.True(t, ok)
583 assert.False(t, alertPoint.States[perfThresholdStateNone])
584 assert.True(t, alertPoint.States[perfThresholdStateOK])
585 assert.False(t, alertPoint.States[perfThresholdStateWarning])
586 assert.False(t, alertPoint.States[perfThresholdStateCritical])
587 },
588 },
589 "uses retry interval for retry state": {
590 results: []fakeRun{
591 {
592 result: checkRunResult{
593 ServiceState: "WARNING",
594 JobState: "WARNING",
595 ExitCode: 1,
596 Parsed: output.ParsedOutput{
597 Perfdata: []output.PerfDatum{
598 func() output.PerfDatum {
599 low := 0.0
600 high := 20.0
601 return output.PerfDatum{
602 Label: "used",
603 Unit: "KB",
604 Value: 30,
605 Warn: &output.ThresholdRange{Low: &low, High: &high},
606 }
607 }(),
608 },
609 },
610 },
611 err: errors.New("plugin returned warning"),
612 },
613 {
614 result: checkRunResult{
615 ServiceState: "OK",
616 JobState: "OK",
617 Parsed: output.ParsedOutput{
618 Perfdata: []output.PerfDatum{
619 func() output.PerfDatum {
620 low := 0.0
621 high := 20.0
622 return output.PerfDatum{
623 Label: "used",
624 Unit: "KB",
625 Value: 10,
626 Warn: &output.ThresholdRange{Low: &low, High: &high},
627 }
628 }(),
629 },
630 },
631 },
632 },
633 },
634 config: Config{
635 UpdateEvery: 1,
636 JobConfig: JobConfig{
637 Name: "retry_job",
638 Plugin: truePluginPath,
639 CheckInterval: confDuration(5 * time.Minute),
640 RetryInterval: confDuration(10 * time.Second),
641 MaxCheckAttempts: 3,
642 },
643 },
644 run: func(t *testing.T, coll *Collector, runner *fakeRunner, now *time.Time) {
645 t.Helper()
646 runCollectCycle(t, coll)
647 assert.Equal(t, 1, runner.calls)
648 assert.Equal(t, 2, coll.state.currentAttempt())
649 flat := coll.MetricStore().Read(metrix.ReadFlatten())
650 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "retry_job", "job.execution_state": "warning"}, 1)
651 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "retry_job", "job.execution_state": "retry"}, 1)
652 assertMetricValue(t, flat, "perfdata.true.job.execution_state", metrix.Labels{"nagios_job": "retry_job", "perfdata.true.job.execution_state": "warning"}, 1)
653 assertMetricValue(t, flat, "perfdata.true.job.execution_state", metrix.Labels{"nagios_job": "retry_job", "perfdata.true.job.execution_state": "retry"}, 1)
654 assertMetricValue(t, flat, "job.perfdata.threshold_state", metrix.Labels{
655 "nagios_job": "retry_job",
656 perfdataValueLabelKey: "bytes_used",
657 "job.perfdata.threshold_state": perfThresholdStateWarning,
658 }, 1)
659 assertMetricValue(t, flat, "job.perfdata.threshold_state", metrix.Labels{
660 "nagios_job": "retry_job",
661 perfdataValueLabelKey: "bytes_used",
662 "job.perfdata.threshold_state": perfThresholdStateRetry,
663 }, 1)
664
665 *now = now.Add(9 * time.Second)
666 runCollectCycle(t, coll)
667 assert.Equal(t, 1, runner.calls)
668
669 *now = now.Add(2 * time.Second)
670 runCollectCycle(t, coll)
671 assert.Equal(t, 2, runner.calls)
672 assert.Equal(t, 1, coll.state.currentAttempt())
673 flat = coll.MetricStore().Read(metrix.ReadFlatten())
674 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "retry_job", "job.execution_state": "ok"}, 1)
675 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "retry_job", "job.execution_state": "retry"}, 0)
676 assertMetricValue(t, flat, "job.perfdata.threshold_state", metrix.Labels{
677 "nagios_job": "retry_job",
678 perfdataValueLabelKey: "bytes_used",
679 "job.perfdata.threshold_state": perfThresholdStateOK,
680 }, 1)
681 assertMetricValue(t, flat, "job.perfdata.threshold_state", metrix.Labels{
682 "nagios_job": "retry_job",
683 perfdataValueLabelKey: "bytes_used",
684 "job.perfdata.threshold_state": perfThresholdStateRetry,
685 }, 0)
686 },
687 },
688 "period block suppresses public retry state": {
689 results: []fakeRun{
690 {
691 result: checkRunResult{
692 ServiceState: "WARNING",
693 JobState: "WARNING",
694 ExitCode: 1,
695 Parsed: output.ParsedOutput{
696 Perfdata: []output.PerfDatum{
697 func() output.PerfDatum {
698 low := 0.0
699 high := 20.0
700 return output.PerfDatum{
701 Label: "used",
702 Unit: "KB",
703 Value: 30,
704 Warn: &output.ThresholdRange{Low: &low, High: &high},
705 }
706 }(),
707 },
708 },
709 },
710 err: errors.New("plugin returned warning"),
711 },
712 },
713 config: Config{
714 UpdateEvery: 1,
715 JobConfig: JobConfig{
716 Name: "paused_retry_job",
717 Plugin: truePluginPath,
718 CheckInterval: confDuration(1 * time.Hour),
719 RetryInterval: confDuration(10 * time.Second),
720 MaxCheckAttempts: 3,
721 CheckPeriod: "business",
722 },
723 TimePeriods: []timeperiod.Config{
724 {
725 Name: "business",
726 Rules: []timeperiod.RuleConfig{
727 {
728 Type: "weekly",
729 Days: []string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"},
730 Ranges: []string{"09:00-18:00"},
731 },
732 },
733 },
734 },
735 },
736 run: func(t *testing.T, coll *Collector, runner *fakeRunner, now *time.Time) {
737 t.Helper()
738 runCollectCycle(t, coll)
739 assert.Equal(t, 1, runner.calls)
740
741 flat := coll.MetricStore().Read(metrix.ReadFlatten())
742 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "paused_retry_job", "job.execution_state": "warning"}, 1)
743 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "paused_retry_job", "job.execution_state": "retry"}, 1)
744 assertMetricValue(t, flat, "job.perfdata.threshold_state", metrix.Labels{
745 "nagios_job": "paused_retry_job",
746 perfdataValueLabelKey: "bytes_used",
747 "job.perfdata.threshold_state": perfThresholdStateWarning,
748 }, 1)
749 assertMetricValue(t, flat, "job.perfdata.threshold_state", metrix.Labels{
750 "nagios_job": "paused_retry_job",
751 perfdataValueLabelKey: "bytes_used",
752 "job.perfdata.threshold_state": perfThresholdStateRetry,
753 }, 1)
754
755 *now = time.Date(2026, 3, 23, 20, 0, 0, 0, time.UTC)
756 runCollectCycle(t, coll)
757 assert.Equal(t, 1, runner.calls)
758
759 flat = coll.MetricStore().Read(metrix.ReadFlatten())
760 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "paused_retry_job", "job.execution_state": "paused"}, 1)
761 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "paused_retry_job", "job.execution_state": "retry"}, 0)
762 assertMetricValue(t, flat, "perfdata.true.job.execution_state", metrix.Labels{"nagios_job": "paused_retry_job", "perfdata.true.job.execution_state": "paused"}, 1)
763 assertMetricValue(t, flat, "perfdata.true.job.execution_state", metrix.Labels{"nagios_job": "paused_retry_job", "perfdata.true.job.execution_state": "retry"}, 0)
764 for _, state := range perfThresholdAlertStateNames {
765 assertMetricValue(t, flat, "job.perfdata.threshold_state", metrix.Labels{
766 "nagios_job": "paused_retry_job",
767 perfdataValueLabelKey: "bytes_used",
768 "job.perfdata.threshold_state": state,
769 }, 0)
770 }
771
772 raw := coll.MetricStore().Read()
773 alertPoint, ok := raw.StateSet("job.perfdata.threshold_state", metrix.Labels{
774 "nagios_job": "paused_retry_job",
775 perfdataValueLabelKey: "bytes_used",
776 })
777 require.True(t, ok)
778 for _, state := range perfThresholdAlertStateNames {
779 assert.False(t, alertPoint.States[state])
780 }
781 },
782 },
783 "timeout is exposed publicly but macros keep Nagios unknown": {
784 results: []fakeRun{
785 {
786 result: checkRunResult{ServiceState: nagiosStateUnknown, JobState: jobStateTimeout, ExitCode: -1},
787 err: errNagiosCheckTimeout,
788 },
789 {
790 result: checkRunResult{ServiceState: "OK", JobState: "OK"},
791 },
792 },
793 config: Config{
794 UpdateEvery: 1,
795 JobConfig: JobConfig{
796 Name: "timeout_job",
797 Plugin: truePluginPath,
798 CheckInterval: confDuration(5 * time.Minute),
799 RetryInterval: confDuration(10 * time.Second),
800 MaxCheckAttempts: 3,
801 },
802 },
803 run: func(t *testing.T, coll *Collector, runner *fakeRunner, now *time.Time) {
804 t.Helper()
805 runCollectCycle(t, coll)
806 assert.Equal(t, 1, runner.calls)
807 assert.Equal(t, nagiosStateUnknown, coll.state.currentServiceState())
808 assert.Equal(t, jobStateTimeout, coll.state.currentJobState())
809
810 flat := coll.MetricStore().Read(metrix.ReadFlatten())
811 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "timeout_job", "job.execution_state": "timeout"}, 1)
812 assertMetricValue(t, flat, "job.execution_state", metrix.Labels{"nagios_job": "timeout_job", "job.execution_state": "retry"}, 1)
813 assertMetricValue(t, flat, "perfdata.true.job.execution_state", metrix.Labels{"nagios_job": "timeout_job", "perfdata.true.job.execution_state": "timeout"}, 1)
814 assertMetricValue(t, flat, "perfdata.true.job.execution_state", metrix.Labels{"nagios_job": "timeout_job", "perfdata.true.job.execution_state": "retry"}, 1)
815
816 *now = now.Add(11 * time.Second)
817 runCollectCycle(t, coll)
818 require.Len(t, runner.reqs, 2)
819 assert.Equal(t, nagiosStateUnknown, runner.reqs[1].MacroState.ServiceState)
820 assert.Equal(t, 2, runner.reqs[1].MacroState.ServiceAttempt)
821 },
822 },
823 "infrastructure failures return error and keep state unchanged": {
824 results: []fakeRun{
825 {
826 result: checkRunResult{ServiceState: "UNKNOWN", JobState: "UNKNOWN", ExitCode: -1},
827 err: errors.New("spawn failed"),
828 },
829 },
830 config: Config{
831 UpdateEvery: 1,
832 JobConfig: JobConfig{
833 Name: "infra_fail",
834 Plugin: truePluginPath,
835 },
836 },
837 run: func(t *testing.T, coll *Collector, runner *fakeRunner, _ *time.Time) {
838 t.Helper()
839 cc := mustCycleController(t, coll.MetricStore())
840 cc.BeginCycle()
841 err := coll.Collect(context.Background())
842 cc.AbortCycle()
843 require.Error(t, err)
844 assert.Equal(t, 1, runner.calls)
845 assert.Equal(t, nagiosStateUnknown, coll.state.currentServiceState())
846 assert.Equal(t, nagiosStateUnknown, coll.state.currentJobState())
847 },
848 },
849 "passes virtual node to runner": {
850 results: []fakeRun{
851 {result: checkRunResult{ServiceState: "OK", JobState: "OK"}},
852 },
853 config: Config{
854 UpdateEvery: 1,
855 JobConfig: JobConfig{
856 Name: "with_vnode",
857 Plugin: truePluginPath,
858 },
859 },
860 setup: func(coll *Collector, _ *fakeRunner, _ *time.Time) {
861 coll.vnode = vnodes.VirtualNode{
862 Hostname: "node-a",
863 Labels: map[string]string{
864 "_address": "203.0.113.10",
865 "_alias": "node-a-alias",
866 "_DC": "east",
867 "region": "lab",
868 },
869 }
870 },
871 run: func(t *testing.T, coll *Collector, runner *fakeRunner, _ *time.Time) {
872 t.Helper()
873 runCollectCycle(t, coll)
874 require.Len(t, runner.reqs, 1)
875 req := runner.reqs[0]
876 assert.Equal(t, "node-a", req.Vnode.Hostname)
877 assert.Equal(t, "203.0.113.10", req.Vnode.Labels["_address"])
878 assert.Equal(t, "lab", req.Vnode.Labels["region"])
879 },
880 },
881 }
882
883 for name, tc := range tests {
884 t.Run(name, func(t *testing.T) {
885 now := time.Date(2026, 3, 21, 12, 0, 0, 0, time.UTC)
886 runner := &fakeRunner{results: tc.results}
887 coll := newTestCollector()
888 coll.runner = runner
889 coll.now = func() time.Time { return now }
890 coll.Config = tc.config
891 if tc.setup != nil {
892 tc.setup(coll, runner, &now)
893 }
894 require.NoError(t, coll.Init(context.Background()))
895 tc.run(t, coll, runner, &now)
896 })
897 }
898 }
899
900 func TestBuildMacroSet(t *testing.T) {
901 now := time.Date(2026, 3, 21, 12, 0, 0, 0, time.UTC)
902 tests := map[string]struct {
903 job JobConfig
904 vnode vnodeInfo
905 state macroState
906 assert func(*testing.T, macroSet)
907 }{
908 "includes vnode and service macros": {
909 job: JobConfig{
910 Name: "http_check",
911 Plugin: "/usr/lib/nagios/plugins/check_http",
912 Args: []string{"-H", "$HOSTADDRESS$", "-p", "$ARG1$", "-w", "$ARG2$"},
913 ArgValues: []string{"8080", "5"},
914 CustomVars: map[string]string{
915 "ENDPOINT": "/health",
916 },
917 Vnode: "fallback-host",
918 },
919 vnode: vnodeInfo{
920 Hostname: "web1",
921 Labels: map[string]string{
922 "_address": "192.0.2.10",
923 "_alias": "web-node",
924 "_DATACENTER": "us-east-1",
925 "role": "frontend",
926 },
927 },
928 state: macroState{
929 ServiceState: "OK",
930 ServiceAttempt: 2,
931 ServiceMaxAttempts: 5,
932 },
933 assert: func(t *testing.T, s macroSet) {
934 t.Helper()
935 assert.Equal(t, "192.0.2.10", s.Env["NAGIOS_HOSTADDRESS"])
936 assert.Equal(t, "web-node", s.Env["NAGIOS_HOSTALIAS"])
937 assert.Equal(t, "/health", s.Env["NAGIOS__SERVICEENDPOINT"])
938 assert.Equal(t, "us-east-1", s.Env["NAGIOS__HOSTDATACENTER"])
939 assert.Equal(t, "frontend", s.Env["NAGIOS__HOSTLABEL_ROLE"])
940 assert.Equal(t, "8080", s.Env["NAGIOS_ARG1"])
941 assert.Equal(t, "2", s.Env["NAGIOS_SERVICEATTEMPT"])
942 assert.Equal(t, nagiosHostStateUp, s.Env["NAGIOS_HOSTSTATE"])
943 assert.Equal(t, nagiosHostStateUpID, s.Env["NAGIOS_HOSTSTATEID"])
944 assert.Equal(t, "192.0.2.10", s.CommandArgs[1])
945 assert.Equal(t, "8080", s.CommandArgs[3])
946 },
947 },
948 "falls back to job vnode when runtime vnode is empty": {
949 job: JobConfig{
950 Name: "fallback",
951 Plugin: "/bin/true",
952 Args: []string{"$HOSTNAME$"},
953 Vnode: "fallback-host",
954 },
955 vnode: vnodeInfo{
956 Labels: map[string]string{},
957 },
958 state: macroState{ServiceState: "OK"},
959 assert: func(t *testing.T, s macroSet) {
960 t.Helper()
961 assert.Equal(t, "fallback-host", s.Env["NAGIOS_HOSTNAME"])
962 assert.Equal(t, "fallback-host", s.CommandArgs[0])
963 },
964 },
965 }
966
967 for name, tc := range tests {
968 t.Run(name, func(t *testing.T) {
969 got := buildMacroSet(tc.job, tc.vnode, tc.state, now)
970 tc.assert(t, got)
971 })
972 }
973 }
974
975 func TestReplaceMacro(t *testing.T) {
976 tests := map[string]struct {
977 value string
978 env map[string]string
979 want string
980 }{
981 "expands nested macros deterministically": {
982 value: "$ARG1$",
983 env: map[string]string{
984 "NAGIOS_ARG1": "$HOSTADDRESS$:$ARG2$",
985 "NAGIOS_ARG2": "8080",
986 "NAGIOS_HOSTADDRESS": "192.0.2.10",
987 },
988 want: "192.0.2.10:8080",
989 },
990 "keeps unknown macros unchanged": {
991 value: "$UNKNOWN$:$ARG1$",
992 env: map[string]string{
993 "NAGIOS_ARG1": "value",
994 },
995 want: "$UNKNOWN$:value",
996 },
997 "stops recursive cycles deterministically": {
998 value: "$ARG1$",
999 env: map[string]string{
1000 "NAGIOS_ARG1": "$ARG2$",
1001 "NAGIOS_ARG2": "$ARG1$",
1002 },
1003 want: "$ARG1$",
1004 },
1005 }
1006
1007 for name, tc := range tests {
1008 t.Run(name, func(t *testing.T) {
1009 assert.Equal(t, tc.want, replaceMacro(tc.value, tc.env))
1010 })
1011 }
1012 }
1013
1014 func TestBuildRunEnv(t *testing.T) {
1015 t.Setenv("NAGIOS_TEST_LEAK", "secret")
1016 t.Setenv("PATH", "/usr/local/bin:/usr/bin")
1017 t.Setenv("TZ", "UTC")
1018
1019 tests := map[string]struct {
1020 workingDir string
1021 jobEnv map[string]string
1022 macroEnv map[string]string
1023 assert func(*testing.T, map[string]string)
1024 }{
1025 "uses explicit baseline and does not leak ambient env": {
1026 jobEnv: map[string]string{},
1027 macroEnv: map[string]string{},
1028 assert: func(t *testing.T, env map[string]string) {
1029 t.Helper()
1030 assert.NotContains(t, env, "NAGIOS_TEST_LEAK")
1031 assert.Equal(t, "UTC", env["TZ"])
1032 assert.Equal(t, "/usr/local/bin:/usr/bin", env["PATH"])
1033 if runtime.GOOS != "windows" {
1034 assert.Equal(t, "C", env["LC_ALL"])
1035 assert.Equal(t, "/bin/sh", env["SHELL"])
1036 }
1037 },
1038 },
1039 "uses actual current directory instead of inherited parent PWD": {
1040 jobEnv: map[string]string{},
1041 macroEnv: map[string]string{},
1042 assert: func(t *testing.T, env map[string]string) {
1043 t.Helper()
1044 if runtime.GOOS == "windows" {
1045 return
1046 }
1047 cwd, err := os.Getwd()
1048 require.NoError(t, err)
1049 assert.Equal(t, cwd, env["PWD"])
1050 assert.NotEqual(t, "/parent/pwd", env["PWD"])
1051 },
1052 },
1053 "working directory overrides PWD": {
1054 workingDir: "/tmp/checks",
1055 jobEnv: map[string]string{},
1056 macroEnv: map[string]string{},
1057 assert: func(t *testing.T, env map[string]string) {
1058 t.Helper()
1059 if runtime.GOOS == "windows" {
1060 return
1061 }
1062 assert.Equal(t, "/tmp/checks", env["PWD"])
1063 },
1064 },
1065 "job environment overrides baseline and macros override job environment": {
1066 jobEnv: map[string]string{
1067 "PATH": "/custom/bin",
1068 "NAGIOS_ARG1": "user-value",
1069 "TARGET": "$ARG1$",
1070 },
1071 macroEnv: map[string]string{
1072 "NAGIOS_ARG1": "macro-value",
1073 },
1074 assert: func(t *testing.T, env map[string]string) {
1075 t.Helper()
1076 assert.Equal(t, "/custom/bin", env["PATH"])
1077 assert.Equal(t, "macro-value", env["NAGIOS_ARG1"])
1078 assert.Equal(t, "macro-value", env["TARGET"])
1079 },
1080 },
1081 }
1082
1083 for name, tc := range tests {
1084 t.Run(name, func(t *testing.T) {
1085 if runtime.GOOS != "windows" {
1086 t.Setenv("PWD", "/parent/pwd")
1087 }
1088 env := envSliceToMap(buildRunEnv(tc.workingDir, tc.jobEnv, tc.macroEnv))
1089 tc.assert(t, env)
1090 })
1091 }
1092 }
1093
1094 func TestSystemCheckRunner_EnvironmentContract(t *testing.T) {
1095 if runtime.GOOS == "windows" {
1096 t.Skip("uses sh scripts")
1097 }
1098
1099 t.Setenv("NAGIOS_TEST_LEAK", "secret")
1100 t.Setenv("PATH", "/usr/local/bin:/usr/bin")
1101 t.Setenv("TZ", "UTC")
1102
1103 dir := t.TempDir()
1104 scriptPath := filepath.Join(dir, "check_env.sh")
1105 writeExecutable(t, scriptPath, `#!/bin/sh
1106 set -eu
1107 printf '%s\n' 'OK - env contract | value=1;;;;'
1108 printf 'EXPLICIT=%s\n' "${EXPLICIT:-}"
1109 printf 'EXPANDED=%s\n' "${EXPANDED:-}"
1110 printf 'HOSTADDRESS=%s\n' "${NAGIOS_HOSTADDRESS:-}"
1111 printf 'ARG1=%s\n' "${NAGIOS_ARG1:-}"
1112 printf 'LEAK=%s\n' "${NAGIOS_TEST_LEAK:-}"
1113 printf 'LC_ALL=%s\n' "${LC_ALL:-}"
1114 `)
1115
1116 job := JobConfig{
1117 Name: "env_contract",
1118 Plugin: scriptPath,
1119 ArgValues: []string{"8080"},
1120 Environment: map[string]string{"EXPLICIT": "from-job", "EXPANDED": "$ARG1$"},
1121 Timeout: confDuration(5 * time.Second),
1122 CheckInterval: confDuration(5 * time.Minute),
1123 RetryInterval: confDuration(1 * time.Minute),
1124 }
1125
1126 result, err := systemCheckRunner{}.Run(context.Background(), checkRunRequest{
1127 Job: job,
1128 Vnode: vnodeInfo{
1129 Hostname: "node-a",
1130 Labels: map[string]string{
1131 "_address": "192.0.2.10",
1132 },
1133 },
1134 MacroState: macroState{ServiceState: nagiosStateOK},
1135 Now: time.Date(2026, 3, 22, 12, 0, 0, 0, time.UTC),
1136 })
1137 require.NoError(t, err)
1138 assert.Equal(t, nagiosStateOK, result.ServiceState)
1139 assert.Equal(t, nagiosStateOK, result.JobState)
1140 assert.Equal(t, "OK - env contract", result.Parsed.StatusLine())
1141 assert.Contains(t, result.Parsed.LongOutput(), "EXPLICIT=from-job")
1142 assert.Contains(t, result.Parsed.LongOutput(), "EXPANDED=8080")
1143 assert.Contains(t, result.Parsed.LongOutput(), "HOSTADDRESS=192.0.2.10")
1144 assert.Contains(t, result.Parsed.LongOutput(), "ARG1=8080")
1145 assert.Contains(t, result.Parsed.LongOutput(), "LEAK=")
1146 assert.NotContains(t, result.Parsed.LongOutput(), "LEAK=secret")
1147 assert.Contains(t, result.Parsed.LongOutput(), "LC_ALL=C")
1148 }
1149
1150 func envSliceToMap(env []string) map[string]string {
1151 out := make(map[string]string, len(env))
1152 for _, kv := range env {
1153 key, value, ok := strings.Cut(kv, "=")
1154 if ok {
1155 out[key] = value
1156 }
1157 }
1158 return out
1159 }
1160
1161 type fakeRun struct {
1162 result checkRunResult
1163 err error
1164 }
1165
1166 type fakeRunner struct {
1167 results []fakeRun
1168 reqs []checkRunRequest
1169 calls int
1170 }
1171
1172 func (f *fakeRunner) Run(_ context.Context, req checkRunRequest) (checkRunResult, error) {
1173 f.reqs = append(f.reqs, req)
1174 if f.calls >= len(f.results) {
1175 f.calls++
1176 return checkRunResult{}, nil
1177 }
1178 run := f.results[f.calls]
1179 f.calls++
1180 return run.result, run.err
1181 }
1182
1183 func runCollectCycle(t *testing.T, coll *Collector) {
1184 t.Helper()
1185 cc := mustCycleController(t, coll.MetricStore())
1186 cc.BeginCycle()
1187 if err := coll.Collect(context.Background()); err != nil {
1188 cc.AbortCycle()
1189 require.NoError(t, err)
1190 }
1191 cc.CommitCycleSuccess()
1192 }
1193
1194 func mustCycleController(t *testing.T, store metrix.CollectorStore) metrix.CycleController {
1195 t.Helper()
1196 managed, ok := metrix.AsCycleManagedStore(store)
1197 require.True(t, ok)
1198 return managed.CycleController()
1199 }
1200
1201 func assertMetricValue(t *testing.T, r metrix.Reader, name string, labels metrix.Labels, want float64) {
1202 t.Helper()
1203 got, ok := r.Value(name, labels)
1204 require.True(t, ok, "missing metric %s labels=%v", name, labels)
1205 assert.InDelta(t, want, got, 1e-9, "metric mismatch %s labels=%v", name, labels)
1206 }
1207
1208 func assertMetricMissing(t *testing.T, r metrix.Reader, name string, labels metrix.Labels) {
1209 t.Helper()
1210 _, ok := r.Value(name, labels)
1211 assert.False(t, ok, "unexpected metric %s labels=%v", name, labels)
1212 }
1213
1214 func writeTestPluginFile(t *testing.T, name string) string {
1215 t.Helper()
1216 path := filepath.Join(t.TempDir(), name)
1217 mode := os.FileMode(0o644)
1218 content := "placeholder\n"
1219 if runtime.GOOS != "windows" {
1220 mode = 0o755
1221 content = "#!/bin/sh\nexit 0\n"
1222 }
1223 require.NoError(t, os.WriteFile(path, []byte(content), mode))
1224 return path
1225 }
1226
1227 func newTestCollector() *Collector {
1228 coll := New()
1229 coll.validatePlugin = func(path string) (string, error) { return path, nil }
1230 return coll
1231 }
1232
1233 func confDuration(d time.Duration) confopt.Duration { return confopt.Duration(d) }
1234
1235 func findChartDimensionByContext(specYAML *charttpl.Spec, context string) (charttpl.Dimension, bool) {
1236 for _, group := range specYAML.Groups {
1237 if dim, ok := findChartDimensionInGroup(group, context); ok {
1238 return dim, true
1239 }
1240 }
1241 return charttpl.Dimension{}, false
1242 }
1243
1244 func findChartDimensionInGroup(group charttpl.Group, context string) (charttpl.Dimension, bool) {
1245 for _, chart := range group.Charts {
1246 if chart.Context != context || len(chart.Dimensions) == 0 {
1247 continue
1248 }
1249 return chart.Dimensions[0], true
1250 }
1251 for _, child := range group.Groups {
1252 if dim, ok := findChartDimensionInGroup(child, context); ok {
1253 return dim, true
1254 }
1255 }
1256 return charttpl.Dimension{}, false
1257 }