feat: add the check_output_matches plugin #5

Merged
Emmanuel BENOîT merged 16 commits from tseeker/gomonop:20240720-line-matches into master 2024-07-20 22:57:10 +02:00
22 changed files with 1063 additions and 55 deletions

View file

@ -34,3 +34,6 @@ linters-settings:
- standard
- default
- localmodule
gomoddirectives:
replace-allow-list:
- github.com/karrick/golf

View file

@ -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
--------------------

95
cmd/matches/cmdline.go Normal file
View 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)
}

118
cmd/matches/extprogram.go Normal file
View file

@ -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()
}()
}

76
cmd/matches/plugin.go Normal file
View file

@ -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)
}

95
cmd/matches/reader.go Normal file
View file

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

125
cmd/matches/results.go Normal file
View file

@ -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())
}

View file

@ -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))

View file

@ -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")
}
}

4
go.mod
View file

@ -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

5
go.sum
View file

@ -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=

View file

@ -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 <plugin> [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())

View file

@ -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) {
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) {
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 {

View file

@ -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

View file

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

View file

@ -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(),
)
})
}
}

196
pkg/perfdata/rangeparser.go Normal file
View file

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

View file

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

View file

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

View file

@ -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())
}

View file

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

View file

@ -1,4 +1,4 @@
package results // import nocternity.net/gomonop/pkg/results
package status // import nocternity.net/gomonop/pkg/status
import (
"testing"