feat(cmd): add output matcher plugin
This commit is contained in:
parent
1f9be057b6
commit
09df5aa44e
3 changed files with 412 additions and 0 deletions
95
cmd/matches/cmdline.go
Normal file
95
cmd/matches/cmdline.go
Normal file
|
@ -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)
|
||||||
|
}
|
315
cmd/matches/plugin.go
Normal file
315
cmd/matches/plugin.go
Normal file
|
@ -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)
|
||||||
|
}
|
2
main.go
2
main.go
|
@ -5,6 +5,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"nocternity.net/gomonop/cmd/matches"
|
||||||
"nocternity.net/gomonop/cmd/sslcert"
|
"nocternity.net/gomonop/cmd/sslcert"
|
||||||
"nocternity.net/gomonop/cmd/zoneserial"
|
"nocternity.net/gomonop/cmd/zoneserial"
|
||||||
"nocternity.net/gomonop/pkg/plugin"
|
"nocternity.net/gomonop/pkg/plugin"
|
||||||
|
@ -14,6 +15,7 @@ import (
|
||||||
|
|
||||||
var (
|
var (
|
||||||
plugins map[string]plugin.Builder = map[string]plugin.Builder{
|
plugins map[string]plugin.Builder = map[string]plugin.Builder{
|
||||||
|
"check_output_matches": matches.NewPlugin,
|
||||||
"check_ssl_certificate": sslcert.NewProgram,
|
"check_ssl_certificate": sslcert.NewProgram,
|
||||||
"check_zone_serial": zoneserial.NewProgram,
|
"check_zone_serial": zoneserial.NewProgram,
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue