From 517bb2073ab13783352f7d7beedb9a4a2006a9aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 11:32:39 +0200 Subject: [PATCH 01/16] fix(main): fix syntax help text --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index d535144..1704ca3 100644 --- a/main.go +++ b/main.go @@ -28,7 +28,7 @@ func getPlugin() plugin.Plugin { if len(os.Args) < 2 { fmt.Printf("Syntax: %s [arguments]\n", ownName) - fmt.Printf(" %s --plugin|-p\n", ownName) + fmt.Printf(" %s --plugins|-p\n", ownName) fmt.Printf(" %s --version|-v\n", ownName) os.Exit(1) } -- 2.39.5 From c87f31d89d00611e830f5af0db9483ed3908ebc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 15:28:16 +0200 Subject: [PATCH 02/16] chore: replace `golf` with a fork --- go.mod | 4 +++- go.sum | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 111d2ca..6b14533 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,16 @@ go 1.22 require ( github.com/karrick/golf v1.4.0 github.com/miekg/dns v1.1.40 + github.com/stretchr/testify v1.9.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/testify v1.9.0 // indirect golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 // indirect golang.org/x/net v0.0.0-20190923162816-aa69164e4478 // indirect golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/karrick/golf v1.4.0 => github.com/tseeker/golf v0.0.0-20240720130627-ce082c3b50d5 diff --git a/go.sum b/go.sum index 37c20f7..d53fb3f 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,13 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/karrick/golf v1.4.0 h1:9i9HnUh7uCyUFJhIqg311HBibw4f2pbGldi0ZM2FhaQ= -github.com/karrick/golf v1.4.0/go.mod h1:qGN0IhcEL+IEgCXp00RvH32UP59vtwc8w5YcIdArNRk= github.com/miekg/dns v1.1.40 h1:pyyPFfGMnciYUk/mXpKkVmeMQjfXqt3FAJ2hy7tPiLA= github.com/miekg/dns v1.1.40/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tseeker/golf v0.0.0-20240720130627-ce082c3b50d5 h1:IreFtDkjJg81heDw+xIEkVZXySV4zQzwKbpenJBQHqk= +github.com/tseeker/golf v0.0.0-20240720130627-ce082c3b50d5/go.mod h1:qGN0IhcEL+IEgCXp00RvH32UP59vtwc8w5YcIdArNRk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -25,6 +25,7 @@ golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -- 2.39.5 From 14c7a0e5e891f5eab6af25c1551e82bb9ca430f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 16:22:22 +0200 Subject: [PATCH 03/16] feat(pkg): add parser for perf data ranges --- pkg/perfdata/range.go | 118 +++++++++++++++++++++++++++++++++++++ pkg/perfdata/range_test.go | 59 +++++++++++++++++++ 2 files changed, 177 insertions(+) diff --git a/pkg/perfdata/range.go b/pkg/perfdata/range.go index e4ad2c7..82c5df9 100644 --- a/pkg/perfdata/range.go +++ b/pkg/perfdata/range.go @@ -1,5 +1,10 @@ package perfdata // import nocternity.net/gomonop/pkg/perfdata +import ( + "fmt" + "strings" +) + // Performance data range. type Range struct { start string @@ -60,3 +65,116 @@ func (r *Range) String() string { return inside + start + ":" + r.end } + +// A state of the range parser. +type rangeParserState int + +const ( + rpsInit = iota // Initial state + rpsExpectStart // Expect the start of the range + rpsInStart // Reading the start of the range + rpsExpectColon // Expect the colon that separates the start and end + rpsExpectEnd // Expect the end of the range + rpsInEnd // Reading the end of the range +) + +// An error emitted by the range parser. +type rangeParserError struct { + input string + position int + message string +} + +// parseError creates a new range parser error. +func parseError(input string, position int, message string) *rangeParserError { + return &rangeParserError{ + input: input, + position: position, + message: message, + } +} + +// Error implements the error interface for the range parser error type. +func (rpe *rangeParserError) Error() string { + return fmt.Sprintf("in `%s' at position %d: %s", rpe.input, rpe.position, rpe.message) +} + +// Try to parse a string into a performance data range. +func ParseRange(input string) (*Range, error) { + runes := []rune(input) + index := 0 + state := rpsInit + parsed := Range{} + startOfStart := 0 + startOfEnd := 0 + strBuilder := strings.Builder{} + + for index < len(runes) { + curRune := runes[index] + + switch state { + case rpsInit: + if curRune == '@' { + parsed.inside = true + index += 1 + } + state = rpsExpectStart + + case rpsExpectStart: + switch curRune { + case ':': + parsed.start = "0" + state = rpsExpectEnd + case '~': + state = rpsExpectColon + default: + strBuilder.WriteRune(curRune) + startOfStart = index + state = rpsInStart + } + index += 1 + + case rpsInStart: + switch curRune { + case ':': + parsed.start = strBuilder.String() + if !valueCheck.MatchString(parsed.start) { + return nil, parseError(input, startOfStart, "invalid start value") + } + strBuilder.Reset() + state = rpsExpectEnd + default: + strBuilder.WriteRune(curRune) + } + index += 1 + + case rpsExpectColon: + switch curRune { + case ':': + state = rpsExpectEnd + index += 1 + default: + return nil, parseError(input, index, "expected ':'") + } + + case rpsExpectEnd: + startOfEnd = index + state = rpsInEnd + + case rpsInEnd: + strBuilder.WriteRune(curRune) + index += 1 + } + } + + if state != rpsInEnd { + return nil, parseError(input, index, "unexpected end of input") + } + + parsed.end = strBuilder.String() + if !valueCheck.MatchString(parsed.end) { + return nil, parseError(input, startOfEnd, "invalid end value") + } + + return &parsed, nil +} diff --git a/pkg/perfdata/range_test.go b/pkg/perfdata/range_test.go index 26d1f21..1313de3 100644 --- a/pkg/perfdata/range_test.go +++ b/pkg/perfdata/range_test.go @@ -1,9 +1,12 @@ package perfdata // import nocternity.net/gomonop/pkg/perfdata import ( + "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestRangeMaxInvalid(t *testing.T) { @@ -65,3 +68,59 @@ func TestRangeString(t *testing.T) { assert.Equal(t, test.out, result, "Expected '%s', got '%s'", test.out, result) } } + +func TestRangeParserOk(t *testing.T) { + type Test struct { + in string + out Range + } + tests := []Test{ + {in: ":0", out: Range{start: "0", end: "0"}}, + {in: "0:0", out: Range{start: "0", end: "0"}}, + {in: "~:0", out: Range{start: "", end: "0"}}, + {in: ":123", out: Range{start: "0", end: "123"}}, + {in: "0:123", out: Range{start: "0", end: "123"}}, + {in: "~:123", out: Range{start: "", end: "123"}}, + {in: "@:0", out: Range{start: "0", end: "0", inside: true}}, + {in: "@0:0", out: Range{start: "0", end: "0", inside: true}}, + {in: "@~:0", out: Range{start: "", end: "0", inside: true}}, + {in: "@:123", out: Range{start: "0", end: "123", inside: true}}, + {in: "@0:123", out: Range{start: "0", end: "123", inside: true}}, + {in: "@~:123", out: Range{start: "", end: "123", inside: true}}, + } + + for _, test := range tests { + result, err := ParseRange(test.in) + require.NoError(t, err, "Expected no error, got '%v'", err) + assert.Equal(t, test.out, *result, "Expected '%v', got '%v'", test.out, *result) + } +} + +func TestRangeParserError(t *testing.T) { + type Test struct { + in string + errPos int + } + tests := []Test{ + {in: "", errPos: 0}, + {in: ":", errPos: 1}, + {in: "x:1", errPos: 0}, + {in: ":~", errPos: 1}, + {in: "1", errPos: 1}, + {in: "@", errPos: 1}, + {in: "@:", errPos: 2}, + {in: "@x:1", errPos: 1}, + {in: "@:~", errPos: 2}, + {in: "@1", errPos: 2}, + } + + for _, test := range tests { + result, err := ParseRange(test.in) + require.Error(t, err, "Expected error, got '%v'", err) + assert.Nil(t, result, "Expected nil result, got '%v'", result) + assert.True( + t, strings.Contains(err.Error(), fmt.Sprintf("at position %d", test.errPos)), + "Expected error to contain '%s', got '%s'", fmt.Sprintf("at position %d", test.errPos), err, + ) + } +} -- 2.39.5 From 1a35167eab40a526c0b4f17378609623de535d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 18:11:06 +0200 Subject: [PATCH 04/16] feat(pkg): thresholds on perfdata can be cleared using `Set...(nil)` --- pkg/perfdata/perfdata.go | 16 ++++++++++++---- pkg/perfdata/perfdata_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/pkg/perfdata/perfdata.go b/pkg/perfdata/perfdata.go index fc1914e..a6f3536 100644 --- a/pkg/perfdata/perfdata.go +++ b/pkg/perfdata/perfdata.go @@ -35,14 +35,22 @@ func New(label string, units UnitOfMeasurement, value string) *PerfData { // Set the warning range for the performance data record. func (d *PerfData) SetWarn(r *Range) { - d.warn = *r - d.bits |= PDatWarn + if r == nil { + d.bits &^= PDatWarn + } else { + d.warn = *r + d.bits |= PDatWarn + } } // Set the critical range for the performance data record. func (d *PerfData) SetCrit(r *Range) { - d.crit = *r - d.bits |= PDatCrit + if r == nil { + d.bits &^= PDatCrit + } else { + d.crit = *r + d.bits |= PDatCrit + } } // Set the performance data's minimal value. diff --git a/pkg/perfdata/perfdata_test.go b/pkg/perfdata/perfdata_test.go index 894077c..c112e3a 100644 --- a/pkg/perfdata/perfdata_test.go +++ b/pkg/perfdata/perfdata_test.go @@ -50,6 +50,12 @@ func TestSetWarn(t *testing.T) { assert.Equal(t, rangeStr, data.warn.String()) } +func TestSetWarnNil(t *testing.T) { + data := PerfData{} + data.SetWarn(nil) + assert.Equal(t, perfDataBits(0), data.bits&PDatWarn) +} + func TestSetWarnTwice(t *testing.T) { range1Value := Range{start: "A", end: "B"} range2Value := Range{start: "C", end: "D"} @@ -64,6 +70,16 @@ func TestSetWarnTwice(t *testing.T) { assert.Equal(t, range2Str, data.warn.String()) } +func TestSetWarnClear(t *testing.T) { + range1Value := Range{start: "A", end: "B"} + + data := PerfData{} + data.SetWarn(&range1Value) + data.SetWarn(nil) + + assert.Equal(t, perfDataBits(0), data.bits&PDatWarn) +} + func TestSetCrit(t *testing.T) { rangeValue := Range{start: "A", end: "B"} rangeStr := rangeValue.String() @@ -75,6 +91,12 @@ func TestSetCrit(t *testing.T) { assert.Equal(t, rangeStr, data.crit.String()) } +func TestSetCritNil(t *testing.T) { + data := PerfData{} + data.SetCrit(nil) + assert.Equal(t, perfDataBits(0), data.bits&PDatCrit) +} + func TestSetCritTwice(t *testing.T) { range1Value := Range{start: "A", end: "B"} range2Value := Range{start: "C", end: "D"} @@ -89,6 +111,16 @@ func TestSetCritTwice(t *testing.T) { assert.Equal(t, range2Str, data.crit.String()) } +func TestSetCritClear(t *testing.T) { + range1Value := Range{start: "A", end: "B"} + + data := PerfData{} + data.SetCrit(&range1Value) + data.SetCrit(nil) + + assert.Equal(t, perfDataBits(0), data.bits&PDatCrit) +} + func TestSetMin(t *testing.T) { const min = "100" -- 2.39.5 From 7c8c624b17ca1b35a9802bfd94a305b8ae78d16a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 18:12:01 +0200 Subject: [PATCH 05/16] fix(pkg): fix type issue for `rps*` constants in perfdata --- pkg/perfdata/range.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/perfdata/range.go b/pkg/perfdata/range.go index 82c5df9..4e3c00d 100644 --- a/pkg/perfdata/range.go +++ b/pkg/perfdata/range.go @@ -70,12 +70,12 @@ func (r *Range) String() string { type rangeParserState int const ( - rpsInit = iota // Initial state - rpsExpectStart // Expect the start of the range - rpsInStart // Reading the start of the range - rpsExpectColon // Expect the colon that separates the start and end - rpsExpectEnd // Expect the end of the range - rpsInEnd // Reading the end of the range + rpsInit rangeParserState = iota // Initial state + rpsExpectStart // Expect the start of the range + rpsInStart // Reading the start of the range + rpsExpectColon // Expect the colon that separates the start and end + rpsExpectEnd // Expect the end of the range + rpsInEnd // Reading the end of the range ) // An error emitted by the range parser. -- 2.39.5 From 6d44df62166325eaccc9279de34fef47f72a417d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 18:17:53 +0200 Subject: [PATCH 06/16] fix(pkg): range parser supports single numbers These are `[0:X]` ranges, according to the Nagios doc. --- pkg/perfdata/range.go | 15 ++++++++++----- pkg/perfdata/range_test.go | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/pkg/perfdata/range.go b/pkg/perfdata/range.go index 4e3c00d..f74ca2a 100644 --- a/pkg/perfdata/range.go +++ b/pkg/perfdata/range.go @@ -116,7 +116,7 @@ func ParseRange(input string) (*Range, error) { case rpsInit: if curRune == '@' { parsed.inside = true - index += 1 + index++ } state = rpsExpectStart @@ -132,7 +132,7 @@ func ParseRange(input string) (*Range, error) { startOfStart = index state = rpsInStart } - index += 1 + index++ case rpsInStart: switch curRune { @@ -146,13 +146,13 @@ func ParseRange(input string) (*Range, error) { default: strBuilder.WriteRune(curRune) } - index += 1 + index++ case rpsExpectColon: switch curRune { case ':': state = rpsExpectEnd - index += 1 + index++ default: return nil, parseError(input, index, "expected ':'") } @@ -163,10 +163,15 @@ func ParseRange(input string) (*Range, error) { case rpsInEnd: strBuilder.WriteRune(curRune) - index += 1 + index++ } } + if state == rpsInStart { + // The range was a single value, so that's the upper bound. + parsed.start = "0" + state = rpsInEnd + } if state != rpsInEnd { return nil, parseError(input, index, "unexpected end of input") } diff --git a/pkg/perfdata/range_test.go b/pkg/perfdata/range_test.go index 1313de3..8238026 100644 --- a/pkg/perfdata/range_test.go +++ b/pkg/perfdata/range_test.go @@ -81,12 +81,14 @@ func TestRangeParserOk(t *testing.T) { {in: ":123", out: Range{start: "0", end: "123"}}, {in: "0:123", out: Range{start: "0", end: "123"}}, {in: "~:123", out: Range{start: "", end: "123"}}, + {in: "1", out: Range{start: "0", end: "1"}}, {in: "@:0", out: Range{start: "0", end: "0", inside: true}}, {in: "@0:0", out: Range{start: "0", end: "0", inside: true}}, {in: "@~:0", out: Range{start: "", end: "0", inside: true}}, {in: "@:123", out: Range{start: "0", end: "123", inside: true}}, {in: "@0:123", out: Range{start: "0", end: "123", inside: true}}, {in: "@~:123", out: Range{start: "", end: "123", inside: true}}, + {in: "@1", out: Range{start: "0", end: "1", inside: true}}, } for _, test := range tests { @@ -106,12 +108,10 @@ func TestRangeParserError(t *testing.T) { {in: ":", errPos: 1}, {in: "x:1", errPos: 0}, {in: ":~", errPos: 1}, - {in: "1", errPos: 1}, {in: "@", errPos: 1}, {in: "@:", errPos: 2}, {in: "@x:1", errPos: 1}, {in: "@:~", errPos: 2}, - {in: "@1", errPos: 2}, } for _, test := range tests { -- 2.39.5 From 7209591e08e14ddda4c3ff1996d3d885de296950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 18:42:18 +0200 Subject: [PATCH 07/16] feat(pkg): add `Range.Contains()` method --- pkg/perfdata/range.go | 24 ++++++++++++++++++ pkg/perfdata/range_test.go | 50 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/pkg/perfdata/range.go b/pkg/perfdata/range.go index f74ca2a..02face6 100644 --- a/pkg/perfdata/range.go +++ b/pkg/perfdata/range.go @@ -2,6 +2,7 @@ package perfdata // import nocternity.net/gomonop/pkg/perfdata import ( "fmt" + "strconv" "strings" ) @@ -66,6 +67,29 @@ func (r *Range) String() string { return inside + start + ":" + r.end } +// Contains checks whether a numeric value is within the range. +func (r *Range) Contains(value float64) bool { + var inStart, inEnd bool + + if r.start != "" { + startValue, err := strconv.ParseFloat(r.start, 64) + if err != nil { + panic(fmt.Sprintf("invalid performance data range start value: %v", err)) + } + inStart = value < startValue + } + + if r.end != "" { + endValue, err := strconv.ParseFloat(r.end, 64) + if err != nil { + panic(fmt.Sprintf("invalid performance data range end value: %v", err)) + } + inEnd = value > endValue + } + + return (inStart || inEnd) != r.inside +} + // A state of the range parser. type rangeParserState int diff --git a/pkg/perfdata/range_test.go b/pkg/perfdata/range_test.go index 8238026..2bcf56f 100644 --- a/pkg/perfdata/range_test.go +++ b/pkg/perfdata/range_test.go @@ -69,6 +69,56 @@ func TestRangeString(t *testing.T) { } } +func TestRangeContains(t *testing.T) { + type Test struct { + pdr Range + value float64 + result bool + } + + tests := []Test{ + {pdr: Range{start: "0", end: "10"}, value: 0, result: false}, + {pdr: Range{start: "0", end: "10"}, value: 10, result: false}, + {pdr: Range{start: "0", end: "10"}, value: -1, result: true}, + {pdr: Range{start: "0", end: "10"}, value: 11, result: true}, + {pdr: Range{start: "", end: "10"}, value: -1000, result: false}, + {pdr: Range{start: "", end: "10"}, value: 10, result: false}, + {pdr: Range{start: "", end: "10"}, value: 11, result: true}, + {pdr: Range{start: "10", end: ""}, value: -1000, result: true}, + {pdr: Range{start: "10", end: ""}, value: 9, result: true}, + {pdr: Range{start: "10", end: ""}, value: 10, result: false}, + {pdr: Range{start: "10", end: "20"}, value: 9, result: true}, + {pdr: Range{start: "10", end: "20"}, value: 10, result: false}, + {pdr: Range{start: "10", end: "20"}, value: 20, result: false}, + {pdr: Range{start: "10", end: "20"}, value: 21, result: true}, + } + + // Test cases with the inside flag set and the opposite result + n := len(tests) + for i := range n { + tests = append(tests, Test{ + pdr: Range{ + start: tests[i].pdr.start, + end: tests[i].pdr.end, + inside: !tests[i].pdr.inside, + }, + value: tests[i].value, + result: !tests[i].result, + }) + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%v", test), func(t *testing.T) { + result := test.pdr.Contains(test.value) + assert.Equal( + t, test.result, result, + "Expected '%v', got '%v' for value '%f' and range '%s'", + test.result, result, test.value, test.pdr.String(), + ) + }) + } +} + func TestRangeParserOk(t *testing.T) { type Test struct { in string -- 2.39.5 From 3263d8c583e0d3fac36f2397fb6e5189c879e3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 18:56:51 +0200 Subject: [PATCH 08/16] refactor(pkg): move `Status` to a separate module --- cmd/sslcert/main.go | 29 ++++++++++++----------- cmd/zoneserial/main.go | 17 +++++++------- main.go | 4 ++-- pkg/results/results.go | 7 +++--- pkg/results/results_test.go | 32 ++++++++++++++------------ pkg/{results => status}/status.go | 4 +++- pkg/{results => status}/status_test.go | 2 +- 7 files changed, 51 insertions(+), 44 deletions(-) rename pkg/{results => status}/status.go (74%) rename pkg/{results => status}/status_test.go (90%) diff --git a/cmd/sslcert/main.go b/cmd/sslcert/main.go index eb7b2f1..87f1a34 100644 --- a/cmd/sslcert/main.go +++ b/cmd/sslcert/main.go @@ -17,6 +17,7 @@ import ( "nocternity.net/gomonop/pkg/perfdata" "nocternity.net/gomonop/pkg/plugin" "nocternity.net/gomonop/pkg/results" + "nocternity.net/gomonop/pkg/status" ) //-------------------------------------------------------------------------------------------------------- @@ -224,20 +225,20 @@ func (program *checkProgram) Results() *results.Results { // if the arguments made sense. func (program *checkProgram) CheckArguments() bool { if program.hostname == "" { - program.plugin.SetState(results.StatusUnknown, "no hostname specified") + program.plugin.SetState(status.StatusUnknown, "no hostname specified") return false } if program.port < 1 || program.port > 65535 { - program.plugin.SetState(results.StatusUnknown, "invalid or missing port number") + program.plugin.SetState(status.StatusUnknown, "invalid or missing port number") return false } if program.warn != -1 && program.crit != -1 && program.warn <= program.crit { - program.plugin.SetState(results.StatusUnknown, "nonsensical thresholds") + program.plugin.SetState(status.StatusUnknown, "nonsensical thresholds") return false } if _, ok := certGetters[program.startTLS]; !ok { errstr := "unsupported StartTLS protocol " + program.startTLS - program.plugin.SetState(results.StatusUnknown, errstr) + program.plugin.SetState(status.StatusUnknown, errstr) return false } program.hostname = strings.ToLower(program.hostname) @@ -262,13 +263,13 @@ func (program *checkProgram) getCertificate() error { // matches the requested host name. func (program *checkProgram) checkSANlessCertificate() bool { if !program.ignoreCnOnly || len(program.extraNames) != 0 { - program.plugin.SetState(results.StatusWarning, + program.plugin.SetState(status.StatusWarning, "certificate doesn't have SAN domain names") return false } dn := strings.ToLower(program.certificate.Subject.String()) if !strings.HasPrefix(dn, fmt.Sprintf("cn=%s,", program.hostname)) { - program.plugin.SetState(results.StatusCritical, "incorrect certificate CN") + program.plugin.SetState(status.StatusCritical, "incorrect certificate CN") return false } return true @@ -298,7 +299,7 @@ func (program *checkProgram) checkNames() bool { certificateIsOk = program.checkHostName(name) && certificateIsOk } if !certificateIsOk { - program.plugin.SetState(results.StatusCritical, "names missing from SAN domain names") + program.plugin.SetState(status.StatusCritical, "names missing from SAN domain names") } return certificateIsOk } @@ -306,26 +307,26 @@ func (program *checkProgram) checkNames() bool { // Check a certificate's time to expiry against the warning and critical // thresholds, returning a status code and description based on these // values. -func (program *checkProgram) checkCertificateExpiry(tlDays int) (results.Status, string) { +func (program *checkProgram) checkCertificateExpiry(tlDays int) (status.Status, string) { if tlDays <= 0 { - return results.StatusCritical, "certificate expired" + return status.StatusCritical, "certificate expired" } var limitStr string - var state results.Status + var state status.Status switch { case program.crit > 0 && tlDays <= program.crit: limitStr = fmt.Sprintf(" (<= %d)", program.crit) - state = results.StatusCritical + state = status.StatusCritical case program.warn > 0 && tlDays <= program.warn: limitStr = fmt.Sprintf(" (<= %d)", program.warn) - state = results.StatusWarning + state = status.StatusWarning default: limitStr = "" - state = results.StatusOK + state = status.StatusOK } statusString := fmt.Sprintf("certificate will expire in %d days%s", @@ -351,7 +352,7 @@ func (program *checkProgram) setPerfData(tlDays int) { func (program *checkProgram) RunCheck() { err := program.getCertificate() if err != nil { - program.plugin.SetState(results.StatusUnknown, err.Error()) + program.plugin.SetState(status.StatusUnknown, err.Error()) } else if program.checkNames() { timeLeft := time.Until(program.certificate.NotAfter) tlDays := int((timeLeft + 86399*time.Second) / (24 * time.Hour)) diff --git a/cmd/zoneserial/main.go b/cmd/zoneserial/main.go index f5a1f25..b73965c 100644 --- a/cmd/zoneserial/main.go +++ b/cmd/zoneserial/main.go @@ -15,6 +15,7 @@ import ( "nocternity.net/gomonop/pkg/perfdata" "nocternity.net/gomonop/pkg/plugin" "nocternity.net/gomonop/pkg/results" + "nocternity.net/gomonop/pkg/status" ) //------------------------------------------------------------------------------------------------------- @@ -93,23 +94,23 @@ func (program *checkProgram) Results() *results.Results { // Check the values that were specified from the command line. Returns true if the arguments made sense. func (program *checkProgram) CheckArguments() bool { if program.hostname == "" { - program.plugin.SetState(results.StatusUnknown, "no DNS hostname specified") + program.plugin.SetState(status.StatusUnknown, "no DNS hostname specified") return false } if program.port < 1 || program.port > 65535 { - program.plugin.SetState(results.StatusUnknown, "invalid DNS port number") + program.plugin.SetState(status.StatusUnknown, "invalid DNS port number") return false } if program.zone == "" { - program.plugin.SetState(results.StatusUnknown, "no DNS zone specified") + program.plugin.SetState(status.StatusUnknown, "no DNS zone specified") return false } if program.rsHostname == "" { - program.plugin.SetState(results.StatusUnknown, "no reference DNS hostname specified") + program.plugin.SetState(status.StatusUnknown, "no reference DNS hostname specified") return false } if program.rsPort < 1 || program.rsPort > 65535 { - program.plugin.SetState(results.StatusUnknown, "invalid reference DNS port number") + program.plugin.SetState(status.StatusUnknown, "invalid reference DNS port number") return false } program.hostname = strings.ToLower(program.hostname) @@ -176,12 +177,12 @@ func (program *checkProgram) RunCheck() { cOk, cSerial := program.getSerial("checked", checkResponse) rOk, rSerial := program.getSerial("reference", refResponse) if !(cOk && rOk) { - program.plugin.SetState(results.StatusUnknown, "could not read serials") + program.plugin.SetState(status.StatusUnknown, "could not read serials") return } if cSerial == rSerial { - program.plugin.SetState(results.StatusOK, "serials match") + program.plugin.SetState(status.StatusOK, "serials match") } else { - program.plugin.SetState(results.StatusCritical, "serials mismatch") + program.plugin.SetState(status.StatusCritical, "serials mismatch") } } diff --git a/main.go b/main.go index 1704ca3..f9199f7 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,7 @@ import ( "nocternity.net/gomonop/cmd/sslcert" "nocternity.net/gomonop/cmd/zoneserial" "nocternity.net/gomonop/pkg/plugin" - "nocternity.net/gomonop/pkg/results" + "nocternity.net/gomonop/pkg/status" "nocternity.net/gomonop/pkg/version" ) @@ -61,7 +61,7 @@ func main() { output := runPlugin.Results() defer func() { if r := recover(); r != nil { - output.SetState(results.StatusUnknown, "Internal error") + output.SetState(status.StatusUnknown, "Internal error") output.AddLinef("Error info: %v", r) } fmt.Println(output.String()) diff --git a/pkg/results/results.go b/pkg/results/results.go index 3118071..bc34c13 100644 --- a/pkg/results/results.go +++ b/pkg/results/results.go @@ -9,6 +9,7 @@ import ( "strings" "nocternity.net/gomonop/pkg/perfdata" + "nocternity.net/gomonop/pkg/status" ) // Results represents the monitoring plugin's results, including its name, @@ -16,7 +17,7 @@ import ( // data to be encoded in the output. type Results struct { name string - status Status + status status.Status message string extraText *list.List perfData map[string]*perfdata.PerfData @@ -26,7 +27,7 @@ type Results struct { func New(name string) *Results { p := new(Results) p.name = name - p.status = StatusUnknown + p.status = status.StatusUnknown p.message = "no status set" p.perfData = make(map[string]*perfdata.PerfData) return p @@ -34,7 +35,7 @@ func New(name string) *Results { // SetState sets the plugin's output code to `status` and its message to // the specified `message`. -func (p *Results) SetState(status Status, message string) { +func (p *Results) SetState(status status.Status, message string) { p.status = status p.message = message } diff --git a/pkg/results/results_test.go b/pkg/results/results_test.go index 32ddacf..b6566fb 100644 --- a/pkg/results/results_test.go +++ b/pkg/results/results_test.go @@ -5,14 +5,16 @@ import ( "testing" "github.com/stretchr/testify/assert" + "nocternity.net/gomonop/pkg/perfdata" + "nocternity.net/gomonop/pkg/status" ) func TestNew(t *testing.T) { p := New("test") assert.Equal(t, p.name, "test") - assert.Equal(t, p.status, StatusUnknown) + assert.Equal(t, p.status, status.StatusUnknown) assert.Equal(t, p.message, "no status set") assert.Nil(t, p.extraText) assert.NotNil(t, p.perfData) @@ -21,9 +23,9 @@ func TestNew(t *testing.T) { func TestSetState(t *testing.T) { p := Results{} - p.SetState(StatusWarning, "test") + p.SetState(status.StatusWarning, "test") - assert.Equal(t, p.status, StatusWarning) + assert.Equal(t, p.status, status.StatusWarning) assert.Equal(t, p.message, "test") } @@ -96,7 +98,7 @@ func TestString(t *testing.T) { { Results{ name: "test", - status: StatusWarning, + status: status.StatusWarning, message: "test", perfData: make(map[string]*perfdata.PerfData), }, @@ -106,7 +108,7 @@ func TestString(t *testing.T) { func() Results { p := Results{ name: "test", - status: StatusWarning, + status: status.StatusWarning, message: "test", perfData: make(map[string]*perfdata.PerfData), extraText: list.New(), @@ -121,7 +123,7 @@ func TestString(t *testing.T) { func() Results { p := Results{ name: "test", - status: StatusWarning, + status: status.StatusWarning, message: "test", perfData: make(map[string]*perfdata.PerfData), } @@ -136,7 +138,7 @@ func TestString(t *testing.T) { func() Results { p := Results{ name: "test", - status: StatusWarning, + status: status.StatusWarning, message: "test", perfData: make(map[string]*perfdata.PerfData), extraText: list.New(), @@ -161,15 +163,15 @@ func TestString(t *testing.T) { func TestExitCode(t *testing.T) { p := Results{} - p.status = StatusOK - assert.Equal(t, int(StatusOK), p.ExitCode()) + p.status = status.StatusOK + assert.Equal(t, int(status.StatusOK), p.ExitCode()) - p.status = StatusWarning - assert.Equal(t, int(StatusWarning), p.ExitCode()) + p.status = status.StatusWarning + assert.Equal(t, int(status.StatusWarning), p.ExitCode()) - p.status = StatusCritical - assert.Equal(t, int(StatusCritical), p.ExitCode()) + p.status = status.StatusCritical + assert.Equal(t, int(status.StatusCritical), p.ExitCode()) - p.status = StatusUnknown - assert.Equal(t, int(StatusUnknown), p.ExitCode()) + p.status = status.StatusUnknown + assert.Equal(t, int(status.StatusUnknown), p.ExitCode()) } diff --git a/pkg/results/status.go b/pkg/status/status.go similarity index 74% rename from pkg/results/status.go rename to pkg/status/status.go index 6238a20..f34382e 100644 --- a/pkg/results/status.go +++ b/pkg/status/status.go @@ -1,4 +1,6 @@ -package results // import nocternity.net/gomonop/pkg/results +// The status package contains the datatype that corresponds to monitoring +// plugin status values. +package status // import nocternity.net/gomonop/pkg/status // Status represents the return status of the monitoring plugin. The // corresponding integer value will be used as the program's exit code, diff --git a/pkg/results/status_test.go b/pkg/status/status_test.go similarity index 90% rename from pkg/results/status_test.go rename to pkg/status/status_test.go index 8f81f43..0e3de96 100644 --- a/pkg/results/status_test.go +++ b/pkg/status/status_test.go @@ -1,4 +1,4 @@ -package results // import nocternity.net/gomonop/pkg/results +package status // import nocternity.net/gomonop/pkg/status import ( "testing" -- 2.39.5 From ee05d6f00409df0e1fc89c3afa5f578fc190f3b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 19:12:59 +0200 Subject: [PATCH 09/16] feat(pkg): add status check method to PerfData --- pkg/perfdata/perfdata.go | 20 ++++++++++++++++++++ pkg/perfdata/perfdata_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/pkg/perfdata/perfdata.go b/pkg/perfdata/perfdata.go index a6f3536..f551c78 100644 --- a/pkg/perfdata/perfdata.go +++ b/pkg/perfdata/perfdata.go @@ -3,7 +3,10 @@ package perfdata // import nocternity.net/gomonop/pkg/perfdata import ( + "strconv" "strings" + + "nocternity.net/gomonop/pkg/status" ) // Performance data, including a label, units, a value, warning/critical @@ -71,6 +74,23 @@ func (d *PerfData) SetMax(max string) { d.bits |= PDatMax } +// Check the performance data's value against its configured ranges. +func (d *PerfData) GetStatus() status.Status { + value, err := strconv.ParseFloat(d.value, 64) + if err != nil { + return status.StatusUnknown + } + + if d.bits&PDatCrit != 0 && d.crit.Contains(value) { + return status.StatusCritical + } + if d.bits&PDatWarn != 0 && d.warn.Contains(value) { + return status.StatusWarning + } + + return status.StatusOK +} + // Converts performance data to a string which may be read by the monitoring // system. func (d *PerfData) String() string { diff --git a/pkg/perfdata/perfdata_test.go b/pkg/perfdata/perfdata_test.go index c112e3a..ac3f3e9 100644 --- a/pkg/perfdata/perfdata_test.go +++ b/pkg/perfdata/perfdata_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "nocternity.net/gomonop/pkg/status" ) func TestNewNoValue(t *testing.T) { @@ -167,6 +168,35 @@ func TestSetMaxTwice(t *testing.T) { assert.True(t, data.bits&PDatMax != 0) } +func TestCheck(t *testing.T) { + warnRange := RangeMinMax("0", "10").Inside() + critRange := RangeMinMax("1", "9").Inside() + + type Test struct { + in string + out status.Status + } + + tests := []Test{ + {in: "moo", out: status.StatusUnknown}, + {in: "-1", out: status.StatusOK}, + {in: "0", out: status.StatusWarning}, + {in: "1", out: status.StatusCritical}, + {in: "9", out: status.StatusCritical}, + {in: "10", out: status.StatusWarning}, + {in: "11", out: status.StatusOK}, + } + for _, test := range tests { + pdat := PerfData{ + value: test.in, + bits: PDatCrit | PDatWarn, + warn: *warnRange, + crit: *critRange, + } + assert.Equal(t, test.out, pdat.GetStatus()) + } +} + func TestString(t *testing.T) { type Test struct { PerfData -- 2.39.5 From 1f9be057b686c491cd91b01bd335bf8fd670a4b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 20:33:15 +0200 Subject: [PATCH 10/16] fix(pkg): fix using `~` as the lower bound for `RangeMinMax` --- pkg/perfdata/range.go | 3 +++ pkg/perfdata/range_test.go | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/pkg/perfdata/range.go b/pkg/perfdata/range.go index 02face6..5c621d2 100644 --- a/pkg/perfdata/range.go +++ b/pkg/perfdata/range.go @@ -35,6 +35,9 @@ func RangeMinMax(min, max string) *Range { panic("invalid performance data range minimum value") } pdRange := &Range{} + if min == "~" { + min = "" + } pdRange.start = min pdRange.end = max return pdRange diff --git a/pkg/perfdata/range_test.go b/pkg/perfdata/range_test.go index 2bcf56f..a7d616a 100644 --- a/pkg/perfdata/range_test.go +++ b/pkg/perfdata/range_test.go @@ -43,6 +43,14 @@ func TestRangeMinMax(t *testing.T) { assert.False(t, pdr.inside, "Inside flag should not be set") } +func TestRangeMinMaxOpen(t *testing.T) { + min, max := "~", "456" + pdr := RangeMinMax(min, max) + assert.Equal(t, "", pdr.start, "Min value not clear in PerfDataRange") + assert.Equal(t, max, pdr.end, "Max value not copied to PerfDataRange") + assert.False(t, pdr.inside, "Inside flag should not be set") +} + func TestRangeInside(t *testing.T) { pdr := &Range{} pdr = pdr.Inside() -- 2.39.5 From 09df5aa44e42a838869f673be49daa2a5cd538b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 20:47:42 +0200 Subject: [PATCH 11/16] feat(cmd): add output matcher plugin --- cmd/matches/cmdline.go | 95 +++++++++++++ cmd/matches/plugin.go | 315 +++++++++++++++++++++++++++++++++++++++++ main.go | 2 + 3 files changed, 412 insertions(+) create mode 100644 cmd/matches/cmdline.go create mode 100644 cmd/matches/plugin.go diff --git a/cmd/matches/cmdline.go b/cmd/matches/cmdline.go new file mode 100644 index 0000000..fbf9e3b --- /dev/null +++ b/cmd/matches/cmdline.go @@ -0,0 +1,95 @@ +package matches // import nocternity.net/gomonop/cmd/matches + +import ( + "context" + "regexp" + "strings" + "time" + + "github.com/karrick/golf" + + "nocternity.net/gomonop/pkg/perfdata" +) + +// pluginFlags represent command line flags that have been parsed. +type pluginFlags struct { + isFile bool // Are we reading from a file? + dataSource string // The file or command to read from + timeout time.Duration // A timeout for the command, or 0 to disable + matches []matchConfig // Configuration for the matches to check + strict bool // Reject lines that don't match anything +} + +// matchConfig is the configuration for a single match to check. +type matchConfig struct { + isRegexp bool // Are we checking against a regular expression? + matchString string // The string or regexp to match + compiledRe *regexp.Regexp // The compiled regexp + warn *perfdata.Range // Warning range + crit *perfdata.Range // Critical range +} + +// parseArguments parses command line arguments for the plugin. +func (p *pluginFlags) parseArguments() { + golf.BoolVarP(&p.isFile, 'f', "is-file", false, "Are we reading from a file?") + golf.StringVarP(&p.dataSource, 's', "source", "", "The file or command to read from") + golf.DurationVarP(&p.timeout, 'T', "timeout", 0, "A timeout for the command, or 0 to disable") + golf.BoolVarP(&p.strict, 'S', "strict", false, "Reject lines that do not match anything") + + isRegexp := golf.BoolP('R', "no-regexp", true, "Following match argument will be a basic string") + golf.BoolVarP(isRegexp, 'r', "regexp", false, "Following match argument will be a regexp") + + var wRange *perfdata.Range + golf.StringFuncP('w', "warn", "", "Warning range, in Nagios-compatible format", func(s string) error { + locRange, err := perfdata.ParseRange(s) + if err == nil { + wRange = locRange + } + return err + }) + golf.BoolFuncP('W', "no-warn", false, "Clear warning range", func(bool) error { + wRange = nil + return nil + }) + + var cRange *perfdata.Range + golf.StringFuncP('c', "critical", "", "Critical range, in Nagios-compatible format", func(s string) error { + locRange, err := perfdata.ParseRange(s) + if err == nil { + cRange = locRange + } + return err + }) + golf.BoolFuncP('C', "no-critical", false, "Clear warning range", func(bool) error { + cRange = nil + return nil + }) + + golf.StringFuncP('m', "match", "", "Match string", func(s string) error { + p.matches = append(p.matches, matchConfig{ + isRegexp: *isRegexp, + matchString: s, + warn: wRange, + crit: cRange, + }) + return nil + }) + + golf.Parse() +} + +// makeContext generates a context based on the timeout, if one is set. +func (p *pluginFlags) makeContext() (context.Context, context.CancelFunc) { + if p.timeout == 0 { + return context.Background(), func() {} + } + return context.WithTimeout(context.Background(), p.timeout) +} + +// matches check if the specified string matches a configuration. +func (p *matchConfig) matches(s string) bool { + if p.isRegexp { + return p.compiledRe.MatchString(s) + } + return strings.Contains(s, p.matchString) +} diff --git a/cmd/matches/plugin.go b/cmd/matches/plugin.go new file mode 100644 index 0000000..761701c --- /dev/null +++ b/cmd/matches/plugin.go @@ -0,0 +1,315 @@ +// The matches package contains the implementation of the `check_output_matches` +// plugin, which can be used to run an arbitrary command and check its output +// against various patterns. It can also be configured to do the same on an +// arbitrary file. +package matches // import nocternity.net/gomonop/cmd/matches + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + + "nocternity.net/gomonop/pkg/perfdata" + "nocternity.net/gomonop/pkg/plugin" + "nocternity.net/gomonop/pkg/results" + "nocternity.net/gomonop/pkg/status" +) + +// Program data for the string matches plugin. +type matchesPlugin struct { + pluginFlags // Flags from the command line + results *results.Results // Plugin output state + counters []int // Counters for each match + unmatchedLines int // Number of lines that didn't match anything +} + +// Initialise the plugin. +func NewPlugin() plugin.Plugin { + pluginInst := &matchesPlugin{ + results: results.New("String matches counter"), + } + pluginInst.parseArguments() + return pluginInst +} + +// Return the program's output value. +func (pluginInst *matchesPlugin) Results() *results.Results { + return pluginInst.results +} + +// Check the values that were specified from the command line. Returns true +// if the arguments made sense. +func (pluginInst *matchesPlugin) CheckArguments() bool { + if pluginInst.dataSource == "" { + pluginInst.results.SetState(status.StatusUnknown, "no data source specified") + return false + } + + if !pluginInst.strict && len(pluginInst.matches) == 0 { + pluginInst.results.SetState(status.StatusUnknown, "would match anything") + return false + } + + for index := range pluginInst.matches { + if pluginInst.matches[index].matchString == "" { + pluginInst.results.SetState(status.StatusUnknown, "empty match string") + pluginInst.results.AddLinef("(At match %d)", index+1) + return false + } + if pluginInst.matches[index].isRegexp { + rexp, err := regexp.Compile(pluginInst.matches[index].matchString) + if err != nil { + pluginInst.results.SetState(status.StatusUnknown, "Invalid regular expression") + pluginInst.results.AddLine(err.Error()) + pluginInst.results.AddLinef("(At match %d)", index+1) + return false + } + pluginInst.matches[index].compiledRe = rexp + } + } + + return true +} + +// readFromFile starts a goroutine that reads from the file and sends each line +// to the dataPipe. +func (pluginInst *matchesPlugin) readFromFile(ctx context.Context, donePipe chan error, dataPipe chan string) { + go func() { + file, err := os.Open(pluginInst.dataSource) + if err != nil { + donePipe <- err + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + select { + case <-ctx.Done(): + donePipe <- ctx.Err() + return + case dataPipe <- scanner.Text(): + } + } + donePipe <- scanner.Err() + }() +} + +// startPipeCopy starts a goroutine that copies data from the reader to the +// dataPipe. +func startPipeCopy(reader io.Reader, dataPipe chan string) (aborter chan struct{}, readError chan error) { + aborter = make(chan struct{}) + readError = make(chan error) + + go func() { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + select { + case <-aborter: + return + case dataPipe <- scanner.Text(): + } + } + readError <- scanner.Err() + }() + + return +} + +// readFromProgram starts a goroutine that controls the program, sending lines +// from both stderr and stdout to the dataPipe. +func (pluginInst *matchesPlugin) readFromProgram(ctx context.Context, donePipe chan error, dataPipe chan string) { + go func() { + cmd := exec.Command(pluginInst.dataSource) //nolint:gosec // Command is in fact user-provided + stdout, err := cmd.StdoutPipe() + if err != nil { + donePipe <- err + return + } + stderr, err := cmd.StderrPipe() + if err != nil { + donePipe <- err + return + } + if err := cmd.Start(); err != nil { + donePipe <- err + return + } + + outAborter, outReadError := startPipeCopy(stdout, dataPipe) + errAborter, errReadError := startPipeCopy(stderr, dataPipe) + defer func() { + _ = stdout.Close() + _ = stderr.Close() + close(outAborter) + close(errAborter) + close(outReadError) + close(errReadError) + }() + abort := func(err error) { + outAborter <- struct{}{} + errAborter <- struct{}{} + _ = cmd.Process.Kill() + donePipe <- err + } + + errComplete := false + outComplete := false + for !(errComplete && outComplete) { + select { + case <-ctx.Done(): + abort(ctx.Err()) + return + case err := <-outReadError: + if err != nil { + abort(err) + return + } + outComplete = true + case err := <-errReadError: + if err != nil { + abort(err) + return + } + errComplete = true + } + } + + donePipe <- cmd.Wait() + }() +} + +// startReading starts reading data from either a file or a program. +func (pluginInst *matchesPlugin) startReading(ctx context.Context, donePipe chan error, dataPipe chan string) { + if pluginInst.isFile { + pluginInst.readFromFile(ctx, donePipe, dataPipe) + } else { + pluginInst.readFromProgram(ctx, donePipe, dataPipe) + } +} + +// processLine processes a single line obtained from the program or file. +func (pluginInst *matchesPlugin) processLine(line string) { + hadMatch := false + for index := range pluginInst.matches { + if pluginInst.matches[index].matches(line) { + pluginInst.counters[index]++ + hadMatch = true + } + } + if !hadMatch { + pluginInst.unmatchedLines++ + } +} + +// processData processes data from either the program or the file. +func (pluginInst *matchesPlugin) processData() error { + ctx, cancel := pluginInst.makeContext() + defer cancel() + + donePipe := make(chan error) + dataPipe := make(chan string) + pluginInst.startReading(ctx, donePipe, dataPipe) + + defer func() { + close(donePipe) + close(dataPipe) + }() + + for { + select { + case line := <-dataPipe: + pluginInst.processLine(line) + case err := <-donePipe: + return err + } + } +} + +// checkResults checks the various counters against their configured thresholds, +// and the strict mode failure count. +func (pluginInst *matchesPlugin) checkResults(readError error) { + nWarns, nCrits, nUnknowns := 0, 0, 0 + for index := range pluginInst.counters { + config := &pluginInst.matches[index] + + var nature string + if config.isRegexp { + nature = "regexp" + } else { + nature = "substring" + } + label := fmt.Sprintf("#%2d : %s %s", index+1, nature, config.matchString) + value := strconv.Itoa(pluginInst.counters[index]) + + pdat := perfdata.New(label, perfdata.UomNone, value) + pdat.SetWarn(config.warn) + pdat.SetCrit(config.crit) + pluginInst.results.AddPerfData(pdat) + + switch pdat.GetStatus() { + case status.StatusCritical: + nCrits++ + case status.StatusWarning: + nWarns++ + case status.StatusUnknown: + nUnknowns++ + } + } + + umlPdat := perfdata.New("unmatched", perfdata.UomNone, strconv.Itoa(pluginInst.unmatchedLines)) + if pluginInst.strict { + umlPdat.SetCrit(perfdata.RangeMinMax("~", "0")) + } + switch umlPdat.GetStatus() { + case status.StatusCritical: + nCrits++ + } + pluginInst.results.AddPerfData(umlPdat) + + problems := []string{} + if nCrits > 0 { + problems = append(problems, fmt.Sprintf("%d value(s) critical", nCrits)) + } + if nWarns > 0 { + problems = append(problems, fmt.Sprintf("%d value(s) warning", nWarns)) + } + if nUnknowns > 0 { + problems = append(problems, fmt.Sprintf("%d value(s) unknown", nUnknowns)) + } + if len(problems) == 0 { + problems = append(problems, "No match errors") + } + + if readError != nil { + pluginInst.results.AddLinef("Error while reading data: %s", readError.Error()) + nUnknowns++ + } + + var finalStatus status.Status + switch { + case nCrits > 0: + finalStatus = status.StatusCritical + case nWarns > 0: + finalStatus = status.StatusWarning + case nUnknowns > 0: + finalStatus = status.StatusUnknown + default: + finalStatus = status.StatusOK + } + + pluginInst.results.SetState(finalStatus, strings.Join(problems, ", ")) +} + +// Run the check. +func (pluginInst *matchesPlugin) RunCheck() { + pluginInst.counters = make([]int, len(pluginInst.matches)) + err := pluginInst.processData() + pluginInst.checkResults(err) +} diff --git a/main.go b/main.go index f9199f7..cf84934 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" + "nocternity.net/gomonop/cmd/matches" "nocternity.net/gomonop/cmd/sslcert" "nocternity.net/gomonop/cmd/zoneserial" "nocternity.net/gomonop/pkg/plugin" @@ -14,6 +15,7 @@ import ( var ( plugins map[string]plugin.Builder = map[string]plugin.Builder{ + "check_output_matches": matches.NewPlugin, "check_ssl_certificate": sslcert.NewProgram, "check_zone_serial": zoneserial.NewProgram, } -- 2.39.5 From 2b58a27cc0ea48a98feb052154915d6ea2e3fbc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 21:51:01 +0200 Subject: [PATCH 12/16] refactor(matches): split the external program handling code into more functions --- cmd/matches/extprogram.go | 116 ++++++++++++++++++ cmd/matches/plugin.go | 239 -------------------------------------- cmd/matches/reader.go | 95 +++++++++++++++ cmd/matches/results.go | 125 ++++++++++++++++++++ 4 files changed, 336 insertions(+), 239 deletions(-) create mode 100644 cmd/matches/extprogram.go create mode 100644 cmd/matches/reader.go create mode 100644 cmd/matches/results.go diff --git a/cmd/matches/extprogram.go b/cmd/matches/extprogram.go new file mode 100644 index 0000000..cc85121 --- /dev/null +++ b/cmd/matches/extprogram.go @@ -0,0 +1,116 @@ +package matches // import nocternity.net/gomonop/pkg/matches + +import ( + "bufio" + "context" + "io" + "os/exec" +) + +// streamObtainer refers to functions that may return either an input stream or +// an error. +type streamObtainer func() (io.ReadCloser, error) + +// pipeCopy encapsulates the structures that are used to transfer data from a +// single stream to the reader. +type pipeCopy struct { + reader io.ReadCloser // The input stream being read from + dataPipe chan string // Channel that receives the data we read + aborter chan struct{} // Channel that causes the copy to abort + readResult chan error // Channel that returns results from reading +} + +// startPipeCopy starts a goroutine that copies data from the reader to the +// dataPipe. +func startPipeCopy(obtainer streamObtainer, pipes *readerPipes) *pipeCopy { + stream, err := obtainer() + if err != nil { + pipes.done <- err + return nil + } + + pipeCopyData := &pipeCopy{ + reader: stream, + dataPipe: pipes.data, + aborter: make(chan struct{}), + readResult: make(chan error), + } + + go pipeCopyData.run() + + return pipeCopyData +} + +// run runs the copy operation from the input stream to the data pipe. It is +// meant to be executed as a goroutine. +func (pc *pipeCopy) run() { + scanner := bufio.NewScanner(pc.reader) + for scanner.Scan() { + select { + case <-pc.aborter: + return + case pc.dataPipe <- scanner.Text(): + } + } + pc.readResult <- scanner.Err() +} + +// close closes the stream and channels used by a copy pipe. +func (pc *pipeCopy) close() { + pc.abort() + _ = pc.reader.Close() + close(pc.aborter) + close(pc.readResult) +} + +// abort causes the pipe copy to be aborted. +func (pc *pipeCopy) abort() { + pc.aborter <- struct{}{} +} + +// readFromProgram starts a goroutine that controls the program, sending lines +// from both stderr and stdout to the dataPipe. +func (pluginInst *matchesPlugin) readFromProgram(ctx context.Context, pipes *readerPipes) { + go func() { + cmd := exec.Command(pluginInst.dataSource) //nolint:gosec // Command is in fact user-provided + + outs := startPipeCopy(func() (io.ReadCloser, error) { return cmd.StdoutPipe() }, pipes) + defer outs.close() + errs := startPipeCopy(func() (io.ReadCloser, error) { return cmd.StderrPipe() }, pipes) + defer errs.close() + + if err := cmd.Start(); err != nil { + pipes.done <- err + return + } + + abort := func(err error) { + _ = cmd.Process.Kill() + pipes.done <- err + } + + errComplete := false + outComplete := false + for !(errComplete && outComplete) { + select { + case <-ctx.Done(): + abort(ctx.Err()) + return + case err := <-outs.readResult: + if err != nil { + abort(err) + return + } + outComplete = true + case err := <-errs.readResult: + if err != nil { + abort(err) + return + } + errComplete = true + } + } + + pipes.done <- cmd.Wait() + }() +} diff --git a/cmd/matches/plugin.go b/cmd/matches/plugin.go index 761701c..8f56a30 100644 --- a/cmd/matches/plugin.go +++ b/cmd/matches/plugin.go @@ -5,17 +5,8 @@ package matches // import nocternity.net/gomonop/cmd/matches import ( - "bufio" - "context" - "fmt" - "io" - "os" - "os/exec" "regexp" - "strconv" - "strings" - "nocternity.net/gomonop/pkg/perfdata" "nocternity.net/gomonop/pkg/plugin" "nocternity.net/gomonop/pkg/results" "nocternity.net/gomonop/pkg/status" @@ -77,236 +68,6 @@ func (pluginInst *matchesPlugin) CheckArguments() bool { return true } -// readFromFile starts a goroutine that reads from the file and sends each line -// to the dataPipe. -func (pluginInst *matchesPlugin) readFromFile(ctx context.Context, donePipe chan error, dataPipe chan string) { - go func() { - file, err := os.Open(pluginInst.dataSource) - if err != nil { - donePipe <- err - return - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - select { - case <-ctx.Done(): - donePipe <- ctx.Err() - return - case dataPipe <- scanner.Text(): - } - } - donePipe <- scanner.Err() - }() -} - -// startPipeCopy starts a goroutine that copies data from the reader to the -// dataPipe. -func startPipeCopy(reader io.Reader, dataPipe chan string) (aborter chan struct{}, readError chan error) { - aborter = make(chan struct{}) - readError = make(chan error) - - go func() { - scanner := bufio.NewScanner(reader) - for scanner.Scan() { - select { - case <-aborter: - return - case dataPipe <- scanner.Text(): - } - } - readError <- scanner.Err() - }() - - return -} - -// readFromProgram starts a goroutine that controls the program, sending lines -// from both stderr and stdout to the dataPipe. -func (pluginInst *matchesPlugin) readFromProgram(ctx context.Context, donePipe chan error, dataPipe chan string) { - go func() { - cmd := exec.Command(pluginInst.dataSource) //nolint:gosec // Command is in fact user-provided - stdout, err := cmd.StdoutPipe() - if err != nil { - donePipe <- err - return - } - stderr, err := cmd.StderrPipe() - if err != nil { - donePipe <- err - return - } - if err := cmd.Start(); err != nil { - donePipe <- err - return - } - - outAborter, outReadError := startPipeCopy(stdout, dataPipe) - errAborter, errReadError := startPipeCopy(stderr, dataPipe) - defer func() { - _ = stdout.Close() - _ = stderr.Close() - close(outAborter) - close(errAborter) - close(outReadError) - close(errReadError) - }() - abort := func(err error) { - outAborter <- struct{}{} - errAborter <- struct{}{} - _ = cmd.Process.Kill() - donePipe <- err - } - - errComplete := false - outComplete := false - for !(errComplete && outComplete) { - select { - case <-ctx.Done(): - abort(ctx.Err()) - return - case err := <-outReadError: - if err != nil { - abort(err) - return - } - outComplete = true - case err := <-errReadError: - if err != nil { - abort(err) - return - } - errComplete = true - } - } - - donePipe <- cmd.Wait() - }() -} - -// startReading starts reading data from either a file or a program. -func (pluginInst *matchesPlugin) startReading(ctx context.Context, donePipe chan error, dataPipe chan string) { - if pluginInst.isFile { - pluginInst.readFromFile(ctx, donePipe, dataPipe) - } else { - pluginInst.readFromProgram(ctx, donePipe, dataPipe) - } -} - -// processLine processes a single line obtained from the program or file. -func (pluginInst *matchesPlugin) processLine(line string) { - hadMatch := false - for index := range pluginInst.matches { - if pluginInst.matches[index].matches(line) { - pluginInst.counters[index]++ - hadMatch = true - } - } - if !hadMatch { - pluginInst.unmatchedLines++ - } -} - -// processData processes data from either the program or the file. -func (pluginInst *matchesPlugin) processData() error { - ctx, cancel := pluginInst.makeContext() - defer cancel() - - donePipe := make(chan error) - dataPipe := make(chan string) - pluginInst.startReading(ctx, donePipe, dataPipe) - - defer func() { - close(donePipe) - close(dataPipe) - }() - - for { - select { - case line := <-dataPipe: - pluginInst.processLine(line) - case err := <-donePipe: - return err - } - } -} - -// checkResults checks the various counters against their configured thresholds, -// and the strict mode failure count. -func (pluginInst *matchesPlugin) checkResults(readError error) { - nWarns, nCrits, nUnknowns := 0, 0, 0 - for index := range pluginInst.counters { - config := &pluginInst.matches[index] - - var nature string - if config.isRegexp { - nature = "regexp" - } else { - nature = "substring" - } - label := fmt.Sprintf("#%2d : %s %s", index+1, nature, config.matchString) - value := strconv.Itoa(pluginInst.counters[index]) - - pdat := perfdata.New(label, perfdata.UomNone, value) - pdat.SetWarn(config.warn) - pdat.SetCrit(config.crit) - pluginInst.results.AddPerfData(pdat) - - switch pdat.GetStatus() { - case status.StatusCritical: - nCrits++ - case status.StatusWarning: - nWarns++ - case status.StatusUnknown: - nUnknowns++ - } - } - - umlPdat := perfdata.New("unmatched", perfdata.UomNone, strconv.Itoa(pluginInst.unmatchedLines)) - if pluginInst.strict { - umlPdat.SetCrit(perfdata.RangeMinMax("~", "0")) - } - switch umlPdat.GetStatus() { - case status.StatusCritical: - nCrits++ - } - pluginInst.results.AddPerfData(umlPdat) - - problems := []string{} - if nCrits > 0 { - problems = append(problems, fmt.Sprintf("%d value(s) critical", nCrits)) - } - if nWarns > 0 { - problems = append(problems, fmt.Sprintf("%d value(s) warning", nWarns)) - } - if nUnknowns > 0 { - problems = append(problems, fmt.Sprintf("%d value(s) unknown", nUnknowns)) - } - if len(problems) == 0 { - problems = append(problems, "No match errors") - } - - if readError != nil { - pluginInst.results.AddLinef("Error while reading data: %s", readError.Error()) - nUnknowns++ - } - - var finalStatus status.Status - switch { - case nCrits > 0: - finalStatus = status.StatusCritical - case nWarns > 0: - finalStatus = status.StatusWarning - case nUnknowns > 0: - finalStatus = status.StatusUnknown - default: - finalStatus = status.StatusOK - } - - pluginInst.results.SetState(finalStatus, strings.Join(problems, ", ")) -} - // Run the check. func (pluginInst *matchesPlugin) RunCheck() { pluginInst.counters = make([]int, len(pluginInst.matches)) diff --git a/cmd/matches/reader.go b/cmd/matches/reader.go new file mode 100644 index 0000000..87d15b1 --- /dev/null +++ b/cmd/matches/reader.go @@ -0,0 +1,95 @@ +package matches // import nocternity.net/gomonop/pkg/matches + +import ( + "bufio" + "context" + "os" +) + +// readerPipes encapsulates the two channels used to communicate data and state +// from the functions that acquire the data. +type readerPipes struct { + done chan error + data chan string +} + +// makeReaderPipes initializes the channels. +func makeReaderPipes() *readerPipes { + return &readerPipes{ + done: make(chan error), + data: make(chan string), + } +} + +// close closes the channels. +func (rdp *readerPipes) close() { + close(rdp.done) + close(rdp.data) +} + +// readFromFile starts a goroutine that reads from the file and sends each line +// to the dataPipe. +func (pluginInst *matchesPlugin) readFromFile(ctx context.Context, pipes *readerPipes) { + go func() { + file, err := os.Open(pluginInst.dataSource) + if err != nil { + pipes.done <- err + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + select { + case <-ctx.Done(): + pipes.done <- ctx.Err() + return + case pipes.data <- scanner.Text(): + } + } + pipes.done <- scanner.Err() + }() +} + +// startReading starts reading data from either a file or a program. +func (pluginInst *matchesPlugin) startReading(ctx context.Context, pipes *readerPipes) { + if pluginInst.isFile { + pluginInst.readFromFile(ctx, pipes) + } else { + pluginInst.readFromProgram(ctx, pipes) + } +} + +// processLine processes a single line obtained from the program or file. +func (pluginInst *matchesPlugin) processLine(line string) { + hadMatch := false + for index := range pluginInst.matches { + if pluginInst.matches[index].matches(line) { + pluginInst.counters[index]++ + hadMatch = true + } + } + if !hadMatch { + pluginInst.unmatchedLines++ + } +} + +// processData processes data from either the program or the file. +func (pluginInst *matchesPlugin) processData() error { + ctx, cancel := pluginInst.makeContext() + defer cancel() + + pipes := makeReaderPipes() + defer pipes.close() + + pluginInst.startReading(ctx, pipes) + + for { + select { + case line := <-pipes.data: + pluginInst.processLine(line) + case err := <-pipes.done: + return err + } + } +} diff --git a/cmd/matches/results.go b/cmd/matches/results.go new file mode 100644 index 0000000..57cf4d3 --- /dev/null +++ b/cmd/matches/results.go @@ -0,0 +1,125 @@ +package matches // import nocternity.net/gomonop/cmd/matches + +import ( + "fmt" + "strconv" + "strings" + + "nocternity.net/gomonop/pkg/perfdata" + "nocternity.net/gomonop/pkg/status" +) + +// resultsInfo contains information about results being processed. +type resultsInfo struct { + nWarns, nCrits, nUnknowns uint +} + +// updateResults updates the result counters based on the specified status. +func (info *resultsInfo) updateFrom(itemStatus status.Status) { + switch itemStatus { + case status.StatusCritical: + info.nCrits++ + case status.StatusWarning: + info.nWarns++ + case status.StatusUnknown: + info.nUnknowns++ + case status.StatusOK: + // do nothing + } +} + +// toProblems generate the list of problems found while running the plugin, +// as a string which can be returned to the monitoring server. +func (info *resultsInfo) toProblems() string { + problems := []string{} + if info.nCrits > 0 { + problems = append(problems, + fmt.Sprintf("%d value(s) critical", info.nCrits), + ) + } + if info.nWarns > 0 { + problems = append(problems, + fmt.Sprintf("%d value(s) warning", info.nWarns), + ) + } + if info.nUnknowns > 0 { + problems = append(problems, + fmt.Sprintf("%d value(s) unknown", info.nUnknowns), + ) + } + if len(problems) == 0 { + problems = append(problems, "No match errors") + } + return strings.Join(problems, ", ") +} + +// toStatus computes the final status of the plugin based on the status +// counters. +func (info *resultsInfo) toStatus() status.Status { + switch { + case info.nCrits > 0: + return status.StatusCritical + case info.nWarns > 0: + return status.StatusWarning + case info.nUnknowns > 0: + return status.StatusUnknown + default: + return status.StatusOK + } +} + +// checkCounters checks the match counters against their configured thresholds, +// if any, and adds the corresponding perf data to the result. It initializes a +// resultsInfo structure containing the counts of critical, warning and unknown +// statuses gathered so far. +func (pluginInst *matchesPlugin) checkCounters() resultsInfo { + info := resultsInfo{} + + for index := range pluginInst.counters { + config := &pluginInst.matches[index] + + var nature string + if config.isRegexp { + nature = "regexp" + } else { + nature = "substring" + } + label := fmt.Sprintf("#%2d : %s %s", index+1, nature, config.matchString) + value := strconv.Itoa(pluginInst.counters[index]) + + pdat := perfdata.New(label, perfdata.UomNone, value) + pdat.SetWarn(config.warn) + pdat.SetCrit(config.crit) + pluginInst.results.AddPerfData(pdat) + + info.updateFrom(pdat.GetStatus()) + } + + return info +} + +// checkUnmatched checks the unmatched lines counters, applying a limit if +// strict mode is enabled. The corresponding performance data is added to the +// result. +func (pluginInst *matchesPlugin) checkUnmatched(info *resultsInfo) { + umlPdat := perfdata.New("unmatched", perfdata.UomNone, strconv.Itoa(pluginInst.unmatchedLines)) + if pluginInst.strict { + umlPdat.SetCrit(perfdata.RangeMinMax("~", "0")) + } + info.updateFrom(umlPdat.GetStatus()) + pluginInst.results.AddPerfData(umlPdat) +} + +// checkResults checks the various counters against their configured thresholds, +// and the strict mode failure count. +func (pluginInst *matchesPlugin) checkResults(readError error) { + info := pluginInst.checkCounters() + pluginInst.checkUnmatched(&info) + + if readError != nil { + pluginInst.results.AddLinef("Error while reading data: %s", readError.Error()) + info.nUnknowns++ + } + + pluginInst.results.SetState(info.toStatus(), info.toProblems()) +} -- 2.39.5 From 402f7f3c11c15a2dc0c0f9a6ffbf5dc3c19a8458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 22:21:52 +0200 Subject: [PATCH 13/16] refactor(perfdata): split the range parser into many functions --- pkg/perfdata/range.go | 119 ------------------- pkg/perfdata/range_test.go | 58 --------- pkg/perfdata/rangeparser.go | 196 +++++++++++++++++++++++++++++++ pkg/perfdata/rangeparser_test.go | 66 +++++++++++ 4 files changed, 262 insertions(+), 177 deletions(-) create mode 100644 pkg/perfdata/rangeparser.go create mode 100644 pkg/perfdata/rangeparser_test.go diff --git a/pkg/perfdata/range.go b/pkg/perfdata/range.go index 5c621d2..7defac8 100644 --- a/pkg/perfdata/range.go +++ b/pkg/perfdata/range.go @@ -3,7 +3,6 @@ package perfdata // import nocternity.net/gomonop/pkg/perfdata import ( "fmt" "strconv" - "strings" ) // Performance data range. @@ -92,121 +91,3 @@ func (r *Range) Contains(value float64) bool { return (inStart || inEnd) != r.inside } - -// A state of the range parser. -type rangeParserState int - -const ( - rpsInit rangeParserState = iota // Initial state - rpsExpectStart // Expect the start of the range - rpsInStart // Reading the start of the range - rpsExpectColon // Expect the colon that separates the start and end - rpsExpectEnd // Expect the end of the range - rpsInEnd // Reading the end of the range -) - -// An error emitted by the range parser. -type rangeParserError struct { - input string - position int - message string -} - -// parseError creates a new range parser error. -func parseError(input string, position int, message string) *rangeParserError { - return &rangeParserError{ - input: input, - position: position, - message: message, - } -} - -// Error implements the error interface for the range parser error type. -func (rpe *rangeParserError) Error() string { - return fmt.Sprintf("in `%s' at position %d: %s", rpe.input, rpe.position, rpe.message) -} - -// Try to parse a string into a performance data range. -func ParseRange(input string) (*Range, error) { - runes := []rune(input) - index := 0 - state := rpsInit - parsed := Range{} - startOfStart := 0 - startOfEnd := 0 - strBuilder := strings.Builder{} - - for index < len(runes) { - curRune := runes[index] - - switch state { - case rpsInit: - if curRune == '@' { - parsed.inside = true - index++ - } - state = rpsExpectStart - - case rpsExpectStart: - switch curRune { - case ':': - parsed.start = "0" - state = rpsExpectEnd - case '~': - state = rpsExpectColon - default: - strBuilder.WriteRune(curRune) - startOfStart = index - state = rpsInStart - } - index++ - - case rpsInStart: - switch curRune { - case ':': - parsed.start = strBuilder.String() - if !valueCheck.MatchString(parsed.start) { - return nil, parseError(input, startOfStart, "invalid start value") - } - strBuilder.Reset() - state = rpsExpectEnd - default: - strBuilder.WriteRune(curRune) - } - index++ - - case rpsExpectColon: - switch curRune { - case ':': - state = rpsExpectEnd - index++ - default: - return nil, parseError(input, index, "expected ':'") - } - - case rpsExpectEnd: - startOfEnd = index - state = rpsInEnd - - case rpsInEnd: - strBuilder.WriteRune(curRune) - index++ - } - } - - if state == rpsInStart { - // The range was a single value, so that's the upper bound. - parsed.start = "0" - state = rpsInEnd - } - if state != rpsInEnd { - return nil, parseError(input, index, "unexpected end of input") - } - - parsed.end = strBuilder.String() - if !valueCheck.MatchString(parsed.end) { - return nil, parseError(input, startOfEnd, "invalid end value") - } - - return &parsed, nil -} diff --git a/pkg/perfdata/range_test.go b/pkg/perfdata/range_test.go index a7d616a..9873395 100644 --- a/pkg/perfdata/range_test.go +++ b/pkg/perfdata/range_test.go @@ -2,11 +2,9 @@ package perfdata // import nocternity.net/gomonop/pkg/perfdata import ( "fmt" - "strings" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestRangeMaxInvalid(t *testing.T) { @@ -126,59 +124,3 @@ func TestRangeContains(t *testing.T) { }) } } - -func TestRangeParserOk(t *testing.T) { - type Test struct { - in string - out Range - } - tests := []Test{ - {in: ":0", out: Range{start: "0", end: "0"}}, - {in: "0:0", out: Range{start: "0", end: "0"}}, - {in: "~:0", out: Range{start: "", end: "0"}}, - {in: ":123", out: Range{start: "0", end: "123"}}, - {in: "0:123", out: Range{start: "0", end: "123"}}, - {in: "~:123", out: Range{start: "", end: "123"}}, - {in: "1", out: Range{start: "0", end: "1"}}, - {in: "@:0", out: Range{start: "0", end: "0", inside: true}}, - {in: "@0:0", out: Range{start: "0", end: "0", inside: true}}, - {in: "@~:0", out: Range{start: "", end: "0", inside: true}}, - {in: "@:123", out: Range{start: "0", end: "123", inside: true}}, - {in: "@0:123", out: Range{start: "0", end: "123", inside: true}}, - {in: "@~:123", out: Range{start: "", end: "123", inside: true}}, - {in: "@1", out: Range{start: "0", end: "1", inside: true}}, - } - - for _, test := range tests { - result, err := ParseRange(test.in) - require.NoError(t, err, "Expected no error, got '%v'", err) - assert.Equal(t, test.out, *result, "Expected '%v', got '%v'", test.out, *result) - } -} - -func TestRangeParserError(t *testing.T) { - type Test struct { - in string - errPos int - } - tests := []Test{ - {in: "", errPos: 0}, - {in: ":", errPos: 1}, - {in: "x:1", errPos: 0}, - {in: ":~", errPos: 1}, - {in: "@", errPos: 1}, - {in: "@:", errPos: 2}, - {in: "@x:1", errPos: 1}, - {in: "@:~", errPos: 2}, - } - - for _, test := range tests { - result, err := ParseRange(test.in) - require.Error(t, err, "Expected error, got '%v'", err) - assert.Nil(t, result, "Expected nil result, got '%v'", result) - assert.True( - t, strings.Contains(err.Error(), fmt.Sprintf("at position %d", test.errPos)), - "Expected error to contain '%s', got '%s'", fmt.Sprintf("at position %d", test.errPos), err, - ) - } -} diff --git a/pkg/perfdata/rangeparser.go b/pkg/perfdata/rangeparser.go new file mode 100644 index 0000000..c9ea0c1 --- /dev/null +++ b/pkg/perfdata/rangeparser.go @@ -0,0 +1,196 @@ +package perfdata // import nocternity.net/gomonop/pkg/perfdata + +import ( + "fmt" + "strings" +) + +// A state of the range parser. +type rangeParserState int + +const ( + rpsInit rangeParserState = iota // Initial state + rpsExpectStart // Expect the start of the range + rpsInStart // Reading the start of the range + rpsExpectColon // Expect the colon that separates the start and end + rpsExpectEnd // Expect the end of the range + rpsInEnd // Reading the end of the range +) + +// An error emitted by the range parser. +type rangeParserError struct { + input string + position int + message string +} + +// parseError creates a new range parser error. +func parseError(input string, position int, message string) *rangeParserError { + return &rangeParserError{ + input: input, + position: position, + message: message, + } +} + +// Error implements the error interface for the range parser error type. +func (rpe *rangeParserError) Error() string { + return fmt.Sprintf("in `%s' at position %d: %s", rpe.input, rpe.position, rpe.message) +} + +// The full state of the range parser. +type rangeParser struct { + runes []rune // The runes being parsed + index int // The read position + state rangeParserState // The FSM state + startOfValue int // The position of the start of the value being read + strBuilder strings.Builder // An accumulator for values + output Range // The output being generated +} + +// initRangeParser initializes the range parser from the specified input. +func initRangeParser(input string) *rangeParser { + return &rangeParser{ + runes: []rune(input), + state: rpsInit, + } +} + +// handleInit handles the parser's rpsInit state. It can accept a '@' or skip +// to rpsExpectStart. +func (rp *rangeParser) handleInit(current rune) { + if current == '@' { + rp.output.inside = true + rp.index++ + } + rp.state = rpsExpectStart +} + +// handleExpectStart handles the parser's rpsExpectStart state. It may accept +// a colon, which will move the parser to the rpsExpectEnd state, a '~' which +// will move it to rpsExpectColon, or any other rune which will cause the +// accumulation of the "start" value to begin, and the state to be moved to +// handleInStart. +func (rp *rangeParser) handleExpectStart(current rune) { + switch current { + case ':': + rp.output.start = "0" + rp.state = rpsExpectEnd + rp.index++ + case '~': + rp.state = rpsExpectColon + rp.index++ + default: + rp.startOfValue = rp.index + rp.state = rpsInStart + } +} + +// handleInStart handles the parser's rpsInStart state, which corresponds to the +// reading of the "start" value. If a colon is found, the value will be written +// to the range structure and validated, before switching to the rpsExpectEnd +// state. Otherwise it will simply accumulate runes. +func (rp *rangeParser) handleInStart(current rune) error { + switch current { + case ':': + rp.output.start = rp.strBuilder.String() + if !valueCheck.MatchString(rp.output.start) { + return parseError(string(rp.runes), rp.startOfValue, "invalid start value") + } + rp.strBuilder.Reset() + rp.state = rpsExpectEnd + default: + rp.strBuilder.WriteRune(current) + } + + rp.index++ + return nil +} + +// handleExpectColon handles the parser's rpsExpectColon state. A colon MUST be +// read, otherwise an error will be returned. The parser will then switch to the +// rpsExpectEnd state. +func (rp *rangeParser) handleExpectColon(current rune) error { + switch current { + case ':': + rp.state = rpsExpectEnd + rp.index++ + default: + return parseError(string(rp.runes), rp.index, "expected ':'") + } + + return nil +} + +// handleExpectEnd handles the parser's rpsExpectEnd state, which sets the +// position of the beginning of the "end" value, and jumps to the rpsInEnd +// state. +func (rp *rangeParser) handleExpectEnd() { + rp.startOfValue = rp.index + rp.state = rpsInEnd +} + +// handleInEnd handles the parser's rpsInEnd state, which accumulates runes for +// the "end" value. +func (rp *rangeParser) handleInEnd(current rune) { + rp.strBuilder.WriteRune(current) + rp.index++ +} + +// consumeInput is the parser's state machine. +func (rp *rangeParser) consumeInput() error { + for rp.index < len(rp.runes) { + var err error + curRune := rp.runes[rp.index] + + switch rp.state { + case rpsInit: + rp.handleInit(curRune) + + case rpsExpectStart: + rp.handleExpectStart(curRune) + + case rpsInStart: + err = rp.handleInStart(curRune) + + case rpsExpectColon: + err = rp.handleExpectColon(curRune) + + case rpsExpectEnd: + rp.handleExpectEnd() + + case rpsInEnd: + rp.handleInEnd(curRune) + } + + if err != nil { + return err + } + } + + return nil +} + +// Try to parse a string into a performance data range. +func ParseRange(input string) (*Range, error) { + parser := initRangeParser(input) + if err := parser.consumeInput(); err != nil { + return nil, err + } + + if parser.state == rpsInStart { + // The range was a single value, so that's the upper bound. + parser.output.start = "0" + parser.state = rpsInEnd + } + if parser.state != rpsInEnd { + return nil, parseError(input, parser.index, "unexpected end of input") + } + + parser.output.end = parser.strBuilder.String() + if !valueCheck.MatchString(parser.output.end) { + return nil, parseError(input, parser.startOfValue, "invalid end value") + } + + return &parser.output, nil +} diff --git a/pkg/perfdata/rangeparser_test.go b/pkg/perfdata/rangeparser_test.go new file mode 100644 index 0000000..8ff5271 --- /dev/null +++ b/pkg/perfdata/rangeparser_test.go @@ -0,0 +1,66 @@ +package perfdata // import nocternity.net/gomonop/pkg/perfdata + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRangeParserOk(t *testing.T) { + type Test struct { + in string + out Range + } + tests := []Test{ + {in: ":0", out: Range{start: "0", end: "0"}}, + {in: "0:0", out: Range{start: "0", end: "0"}}, + {in: "~:0", out: Range{start: "", end: "0"}}, + {in: ":123", out: Range{start: "0", end: "123"}}, + {in: "0:123", out: Range{start: "0", end: "123"}}, + {in: "~:123", out: Range{start: "", end: "123"}}, + {in: "1", out: Range{start: "0", end: "1"}}, + {in: "@:0", out: Range{start: "0", end: "0", inside: true}}, + {in: "@0:0", out: Range{start: "0", end: "0", inside: true}}, + {in: "@~:0", out: Range{start: "", end: "0", inside: true}}, + {in: "@:123", out: Range{start: "0", end: "123", inside: true}}, + {in: "@0:123", out: Range{start: "0", end: "123", inside: true}}, + {in: "@~:123", out: Range{start: "", end: "123", inside: true}}, + {in: "@1", out: Range{start: "0", end: "1", inside: true}}, + } + + for _, test := range tests { + result, err := ParseRange(test.in) + require.NoError(t, err, "Expected no error, got '%v'", err) + assert.Equal(t, test.out, *result, "Expected '%v', got '%v'", test.out, *result) + } +} + +func TestRangeParserError(t *testing.T) { + type Test struct { + in string + errPos int + } + tests := []Test{ + {in: "", errPos: 0}, + {in: ":", errPos: 1}, + {in: "x:1", errPos: 0}, + {in: ":~", errPos: 1}, + {in: "@", errPos: 1}, + {in: "@:", errPos: 2}, + {in: "@x:1", errPos: 1}, + {in: "@:~", errPos: 2}, + } + + for _, test := range tests { + result, err := ParseRange(test.in) + require.Error(t, err, "Expected error, got '%v'", err) + assert.Nil(t, result, "Expected nil result, got '%v'", result) + assert.True( + t, strings.Contains(err.Error(), fmt.Sprintf("at position %d", test.errPos)), + "Expected error to contain '%s', got '%s'", fmt.Sprintf("at position %d", test.errPos), err, + ) + } +} -- 2.39.5 From 9333c6ff80781bbbe97c55d5e1cfc7f78321029f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 22:24:00 +0200 Subject: [PATCH 14/16] chore: allow karrick/golf to be replaced, pending PR result --- .golangci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index d0f65ba..f1de1e6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -34,3 +34,6 @@ linters-settings: - standard - default - localmodule + gomoddirectives: + replace-allow-list: + - github.com/karrick/golf -- 2.39.5 From b8506e8a3f7808ce72b8f050307157f9346e0a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 22:31:28 +0200 Subject: [PATCH 15/16] fix(matches): split command into arguments --- cmd/matches/extprogram.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/matches/extprogram.go b/cmd/matches/extprogram.go index cc85121..aa45dee 100644 --- a/cmd/matches/extprogram.go +++ b/cmd/matches/extprogram.go @@ -5,6 +5,7 @@ import ( "context" "io" "os/exec" + "strings" ) // streamObtainer refers to functions that may return either an input stream or @@ -72,7 +73,8 @@ func (pc *pipeCopy) abort() { // from both stderr and stdout to the dataPipe. func (pluginInst *matchesPlugin) readFromProgram(ctx context.Context, pipes *readerPipes) { go func() { - cmd := exec.Command(pluginInst.dataSource) //nolint:gosec // Command is in fact user-provided + args := strings.Split(pluginInst.dataSource, " ") + cmd := exec.Command(args[0], args[1:]...) //nolint:gosec // Command is in fact user-provided outs := startPipeCopy(func() (io.ReadCloser, error) { return cmd.StdoutPipe() }, pipes) defer outs.close() -- 2.39.5 From 8d0110bc4bb5c5f3723cd4b2b43d405ea2f72c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 22:51:53 +0200 Subject: [PATCH 16/16] doc: add documentation for `check_output_matches` --- README.md | 48 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4af0721..243f153 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,9 @@ supports the following command-line flags: ### DNS zone serials - The `check_zone_serial` plugin can be used to check that the version of a - zone served by a DNS is up-to-date compared to the same zone served by - another, "reference" DNS. It supports the following command-line flags: +The `check_zone_serial` plugin can be used to check that the version of a +zone served by a DNS is up-to-date compared to the same zone served by +another, "reference" DNS. It supports the following command-line flags: * `-H name`/`--hostname name`: the host name or address of the server to check. @@ -49,6 +49,48 @@ supports the following command-line flags: * `-p port`/`--rs-port port`: the port to use on the reference server (defaults to 53). +### Generic text match counter + +The `check_output_matches` plugin can be used to count occurrences of strings +in a program's output or in a text file, and compute its final status based on +that. + +It supports the following general command line flags: + +* `-f` / `--is-file` indicates that the plugin will be reading from a text file + instead of running another program; +* `-s` / `--source` is either the name of the file to read, or the command to + execute. The command may include multiple arguments separated by single + spaces; it does not support any form of quoting. +* `-T` / `--timeout` can set a timeout for the command. It is disabled by + default. +* `-S` / `--strict` determines how unmatched lines are handled. By default they + are ignored, but setting this flag will cause the plugin to enter `CRITICAL` + mode if unmatched lines are found. + +Other flags are available in order to configure the matches. The main flag is +`-m` / `--match`, which adds a new match string to the set of checks to run. +The checks are influenced by the following additional flags, which apply to all +subsequent matches. + +* `-r` / `--regexp` indicates that new matches will be based on regular + expressions instead of substrings. +* `-R` / `--no-regexp` switches back to substring-based matches. +* `-w` / `--warn` can be used to set a warning range. It must be followed by + a Nagios range specification. +* `-W` / `--no-warn` clears the warning range. +* `-c` / `--critical` can be used to set the critical range. It must be followed + by a Nagios range specification. +* `-C` / `--no-critical` clears the critical range. + +For example, the command below: + + gomonop check_output_matches -s 'find /some/place' \ + -w 4 -r -m '^.*\.warn$' \ + -W -c 0 -R -m fatal + +configures a warning if more than 4 files ending in `.warn` are found, and a +critical state if any file with `fatal` in its name is found. Building from source -------------------- -- 2.39.5