From c46c9d76d9dd80e31a25974d0b2aa6a7a5a7e6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20BENO=C3=8ET?= Date: Sat, 20 Jul 2024 22:57:10 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20the=C2=A0`check=5Foutput=5Fmatche?= =?UTF-8?q?s`=20plugin=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds the `check_output_matches` plugin, which can be used to count regexp or substring matches from either text files or command outputs and determine the final status based on the amount of matches that were found. Reviewed-on: https://git.nocternity.net/projects/gomonop/pulls/5 Co-authored-by: Emmanuel BENOÎT Co-committed-by: Emmanuel BENOÎT --- .golangci.yml | 3 + README.md | 48 +++++- cmd/matches/cmdline.go | 95 ++++++++++++ cmd/matches/extprogram.go | 118 +++++++++++++++ cmd/matches/plugin.go | 76 ++++++++++ cmd/matches/reader.go | 95 ++++++++++++ cmd/matches/results.go | 125 ++++++++++++++++ cmd/sslcert/main.go | 29 ++-- cmd/zoneserial/main.go | 17 ++- go.mod | 4 +- go.sum | 5 +- main.go | 8 +- pkg/perfdata/perfdata.go | 36 ++++- pkg/perfdata/perfdata_test.go | 62 ++++++++ pkg/perfdata/range.go | 31 ++++ pkg/perfdata/range_test.go | 59 ++++++++ pkg/perfdata/rangeparser.go | 196 +++++++++++++++++++++++++ pkg/perfdata/rangeparser_test.go | 66 +++++++++ pkg/results/results.go | 7 +- pkg/results/results_test.go | 32 ++-- pkg/{results => status}/status.go | 4 +- pkg/{results => status}/status_test.go | 2 +- 22 files changed, 1063 insertions(+), 55 deletions(-) create mode 100644 cmd/matches/cmdline.go create mode 100644 cmd/matches/extprogram.go create mode 100644 cmd/matches/plugin.go create mode 100644 cmd/matches/reader.go create mode 100644 cmd/matches/results.go create mode 100644 pkg/perfdata/rangeparser.go create mode 100644 pkg/perfdata/rangeparser_test.go rename pkg/{results => status}/status.go (74%) rename pkg/{results => status}/status_test.go (90%) 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 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 -------------------- 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/extprogram.go b/cmd/matches/extprogram.go new file mode 100644 index 0000000..aa45dee --- /dev/null +++ b/cmd/matches/extprogram.go @@ -0,0 +1,118 @@ +package matches // import nocternity.net/gomonop/pkg/matches + +import ( + "bufio" + "context" + "io" + "os/exec" + "strings" +) + +// 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() { + 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() + 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 new file mode 100644 index 0000000..8f56a30 --- /dev/null +++ b/cmd/matches/plugin.go @@ -0,0 +1,76 @@ +// 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 ( + "regexp" + + "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 +} + +// Run the check. +func (pluginInst *matchesPlugin) RunCheck() { + pluginInst.counters = make([]int, len(pluginInst.matches)) + err := pluginInst.processData() + pluginInst.checkResults(err) +} 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()) +} 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/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= diff --git a/main.go b/main.go index d535144..cf84934 100644 --- a/main.go +++ b/main.go @@ -5,15 +5,17 @@ import ( "os" "path/filepath" + "nocternity.net/gomonop/cmd/matches" "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" ) 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, } @@ -28,7 +30,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) } @@ -61,7 +63,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/perfdata/perfdata.go b/pkg/perfdata/perfdata.go index fc1914e..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 @@ -35,14 +38,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. @@ -63,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 894077c..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) { @@ -50,6 +51,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 +71,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 +92,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 +112,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" @@ -135,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 diff --git a/pkg/perfdata/range.go b/pkg/perfdata/range.go index e4ad2c7..7defac8 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" + "strconv" +) + // Performance data range. type Range struct { start string @@ -29,6 +34,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 @@ -60,3 +68,26 @@ 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 +} diff --git a/pkg/perfdata/range_test.go b/pkg/perfdata/range_test.go index 26d1f21..9873395 100644 --- a/pkg/perfdata/range_test.go +++ b/pkg/perfdata/range_test.go @@ -1,6 +1,7 @@ package perfdata // import nocternity.net/gomonop/pkg/perfdata import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -40,6 +41,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() @@ -65,3 +74,53 @@ func TestRangeString(t *testing.T) { assert.Equal(t, test.out, result, "Expected '%s', got '%s'", test.out, result) } } + +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(), + ) + }) + } +} 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, + ) + } +} 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"