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, }