refactor: make internals easier to test and add unit tests (#2)
All checks were successful
Run tests and linters / test (push) Successful in 44s
Run tests and linters / build (push) Successful in 47s
Run tests and linters / lint (push) Successful in 1m20s

This PR refactors most of the internals to make them easier to test (and also because the names didn't make sense). It adds unit tests for all internal components.

Reviewed-on: #2
Co-authored-by: Emmanuel BENOÎT <tseeker@nocternity.net>
Co-committed-by: Emmanuel BENOÎT <tseeker@nocternity.net>
This commit is contained in:
Emmanuel BENOîT 2024-07-20 10:01:05 +02:00 committed by Emmanuel BENOîT
parent dcd732cc34
commit 78af496fe9
20 changed files with 939 additions and 369 deletions

View file

@ -16,7 +16,7 @@ import (
"nocternity.net/gomonop/pkg/perfdata"
"nocternity.net/gomonop/pkg/plugin"
"nocternity.net/gomonop/pkg/program"
"nocternity.net/gomonop/pkg/results"
)
//--------------------------------------------------------------------------------------------------------
@ -167,7 +167,7 @@ type programFlags struct {
// Program data including configuration and runtime data.
type checkProgram struct {
programFlags // Flags from the command line
plugin *plugin.Plugin // Plugin output state
plugin *results.Results // Plugin output state
certificate *x509.Certificate // X.509 certificate from the server
}
@ -207,37 +207,37 @@ func (flags *programFlags) parseArguments() {
}
// Initialise the monitoring check program.
func NewProgram() program.Program {
func NewProgram() plugin.Plugin {
program := &checkProgram{
plugin: plugin.New("Certificate check"),
plugin: results.New("Certificate check"),
}
program.parseArguments()
return program
}
// Terminate the monitoring check program.
func (program *checkProgram) Done() {
program.plugin.Done()
// Return the program's output value.
func (program *checkProgram) Results() *results.Results {
return program.plugin
}
// 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(plugin.UNKNOWN, "no hostname specified")
program.plugin.SetState(results.StatusUnknown, "no hostname specified")
return false
}
if program.port < 1 || program.port > 65535 {
program.plugin.SetState(plugin.UNKNOWN, "invalid or missing port number")
program.plugin.SetState(results.StatusUnknown, "invalid or missing port number")
return false
}
if program.warn != -1 && program.crit != -1 && program.warn <= program.crit {
program.plugin.SetState(plugin.UNKNOWN, "nonsensical thresholds")
program.plugin.SetState(results.StatusUnknown, "nonsensical thresholds")
return false
}
if _, ok := certGetters[program.startTLS]; !ok {
errstr := "unsupported StartTLS protocol " + program.startTLS
program.plugin.SetState(plugin.UNKNOWN, errstr)
program.plugin.SetState(results.StatusUnknown, errstr)
return false
}
program.hostname = strings.ToLower(program.hostname)
@ -262,13 +262,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(plugin.WARNING,
program.plugin.SetState(results.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(plugin.CRITICAL, "incorrect certificate CN")
program.plugin.SetState(results.StatusCritical, "incorrect certificate CN")
return false
}
return true
@ -298,7 +298,7 @@ func (program *checkProgram) checkNames() bool {
certificateIsOk = program.checkHostName(name) && certificateIsOk
}
if !certificateIsOk {
program.plugin.SetState(plugin.CRITICAL, "names missing from SAN domain names")
program.plugin.SetState(results.StatusCritical, "names missing from SAN domain names")
}
return certificateIsOk
}
@ -306,26 +306,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) (plugin.Status, string) {
func (program *checkProgram) checkCertificateExpiry(tlDays int) (results.Status, string) {
if tlDays <= 0 {
return plugin.CRITICAL, "certificate expired"
return results.StatusCritical, "certificate expired"
}
var limitStr string
var state plugin.Status
var state results.Status
switch {
case program.crit > 0 && tlDays <= program.crit:
limitStr = fmt.Sprintf(" (<= %d)", program.crit)
state = plugin.CRITICAL
state = results.StatusCritical
case program.warn > 0 && tlDays <= program.warn:
limitStr = fmt.Sprintf(" (<= %d)", program.warn)
state = plugin.WARNING
state = results.StatusWarning
default:
limitStr = ""
state = plugin.OK
state = results.StatusOK
}
statusString := fmt.Sprintf("certificate will expire in %d days%s",
@ -338,10 +338,10 @@ func (program *checkProgram) checkCertificateExpiry(tlDays int) (plugin.Status,
func (program *checkProgram) setPerfData(tlDays int) {
pdat := perfdata.New("validity", perfdata.UomNone, strconv.Itoa(tlDays))
if program.crit > 0 {
pdat.SetCrit(perfdata.PDRMax(strconv.Itoa(program.crit)))
pdat.SetCrit(perfdata.RangeMax(strconv.Itoa(program.crit)))
}
if program.warn > 0 {
pdat.SetWarn(perfdata.PDRMax(strconv.Itoa(program.warn)))
pdat.SetWarn(perfdata.RangeMax(strconv.Itoa(program.warn)))
}
program.plugin.AddPerfData(pdat)
}
@ -351,7 +351,7 @@ func (program *checkProgram) setPerfData(tlDays int) {
func (program *checkProgram) RunCheck() {
err := program.getCertificate()
if err != nil {
program.plugin.SetState(plugin.UNKNOWN, err.Error())
program.plugin.SetState(results.StatusUnknown, err.Error())
} else if program.checkNames() {
timeLeft := time.Until(program.certificate.NotAfter)
tlDays := int((timeLeft + 86399*time.Second) / (24 * time.Hour))

View file

@ -14,7 +14,7 @@ import (
"nocternity.net/gomonop/pkg/perfdata"
"nocternity.net/gomonop/pkg/plugin"
"nocternity.net/gomonop/pkg/program"
"nocternity.net/gomonop/pkg/results"
)
//-------------------------------------------------------------------------------------------------------
@ -56,7 +56,7 @@ type programFlags struct {
// Program data including configuration and runtime data.
type checkProgram struct {
programFlags // Flags from the command line
plugin *plugin.Plugin // Plugin output state
plugin *results.Results // Plugin output state
}
// Parse command line arguments and store their values. If the -h flag is present,
@ -77,43 +77,39 @@ func (flags *programFlags) parseArguments() {
}
// Initialise the monitoring check program.
func NewProgram() program.Program {
func NewProgram() plugin.Plugin {
program := &checkProgram{
plugin: plugin.New("DNS zone serial match check"),
plugin: results.New("DNS zone serial match check"),
}
program.parseArguments()
return program
}
// Terminate the monitoring check program.
func (program *checkProgram) Done() {
if r := recover(); r != nil {
program.plugin.SetState(plugin.UNKNOWN, "Internal error")
program.plugin.AddLinef("Error info: %v", r)
}
program.plugin.Done()
// Return the program's output value.
func (program *checkProgram) Results() *results.Results {
return program.plugin
}
// 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(plugin.UNKNOWN, "no DNS hostname specified")
program.plugin.SetState(results.StatusUnknown, "no DNS hostname specified")
return false
}
if program.port < 1 || program.port > 65535 {
program.plugin.SetState(plugin.UNKNOWN, "invalid DNS port number")
program.plugin.SetState(results.StatusUnknown, "invalid DNS port number")
return false
}
if program.zone == "" {
program.plugin.SetState(plugin.UNKNOWN, "no DNS zone specified")
program.plugin.SetState(results.StatusUnknown, "no DNS zone specified")
return false
}
if program.rsHostname == "" {
program.plugin.SetState(plugin.UNKNOWN, "no reference DNS hostname specified")
program.plugin.SetState(results.StatusUnknown, "no reference DNS hostname specified")
return false
}
if program.rsPort < 1 || program.rsPort > 65535 {
program.plugin.SetState(plugin.UNKNOWN, "invalid reference DNS port number")
program.plugin.SetState(results.StatusUnknown, "invalid reference DNS port number")
return false
}
program.hostname = strings.ToLower(program.hostname)
@ -180,12 +176,12 @@ func (program *checkProgram) RunCheck() {
cOk, cSerial := program.getSerial("checked", checkResponse)
rOk, rSerial := program.getSerial("reference", refResponse)
if !(cOk && rOk) {
program.plugin.SetState(plugin.UNKNOWN, "could not read serials")
program.plugin.SetState(results.StatusUnknown, "could not read serials")
return
}
if cSerial == rSerial {
program.plugin.SetState(plugin.OK, "serials match")
program.plugin.SetState(results.StatusOK, "serials match")
} else {
program.plugin.SetState(plugin.CRITICAL, "serials mismatch")
program.plugin.SetState(results.StatusCritical, "serials mismatch")
}
}

4
go.mod
View file

@ -8,7 +8,11 @@ require (
)
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
)

9
go.sum
View file

@ -1,7 +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=
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=
@ -19,3 +25,6 @@ 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/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=

39
main.go
View file

@ -7,33 +7,34 @@ import (
"nocternity.net/gomonop/cmd/sslcert"
"nocternity.net/gomonop/cmd/zoneserial"
"nocternity.net/gomonop/pkg/program"
"nocternity.net/gomonop/pkg/plugin"
"nocternity.net/gomonop/pkg/results"
"nocternity.net/gomonop/pkg/version"
)
var (
programs map[string]program.Builder = map[string]program.Builder{
plugins map[string]plugin.Builder = map[string]plugin.Builder{
"check_ssl_certificate": sslcert.NewProgram,
"check_zone_serial": zoneserial.NewProgram,
}
)
func getProgram() program.Program {
func getPlugin() plugin.Plugin {
ownName := filepath.Base(os.Args[0])
if builder, ok := programs[ownName]; ok {
if builder, ok := plugins[ownName]; ok {
return builder()
}
if len(os.Args) < 2 {
fmt.Printf("Syntax: %s <program> [arguments]\n", ownName)
fmt.Printf(" %s --programs|-p\n", ownName)
fmt.Printf("Syntax: %s <plugin> [arguments]\n", ownName)
fmt.Printf(" %s --plugin|-p\n", ownName)
fmt.Printf(" %s --version|-v", ownName)
}
switch os.Args[1] {
case "--programs", "-p":
for name := range programs {
case "--plugins", "-p":
for name := range plugins {
fmt.Println(name)
}
os.Exit(0)
@ -43,20 +44,30 @@ func getProgram() program.Program {
os.Exit(0)
}
if builder, ok := programs[os.Args[1]]; ok {
if builder, ok := plugins[os.Args[1]]; ok {
os.Args = os.Args[1:]
return builder()
}
fmt.Printf("Unknown program: %s\n", os.Args[1])
fmt.Printf("Unknown plugin: %s\n", os.Args[1])
os.Exit(1)
return nil
}
func main() {
program := getProgram()
defer program.Done()
if program.CheckArguments() {
program.RunCheck()
runPlugin := getPlugin()
output := runPlugin.Results()
defer func() {
if r := recover(); r != nil {
output.SetState(results.StatusUnknown, "Internal error")
output.AddLinef("Error info: %v", r)
}
fmt.Println(output.String())
os.Exit(output.ExitCode())
}()
if runPlugin.CheckArguments() {
runPlugin.RunCheck()
}
}

25
pkg/perfdata/internals.go Normal file
View file

@ -0,0 +1,25 @@
package perfdata // import nocternity.net/gomonop/pkg/perfdata
import (
"regexp"
)
// Flags indicating which elements of performance data have been set.
type perfDataBits int
const (
PDatWarn perfDataBits = 1 << iota
PDatCrit
PDatMin
PDatMax
)
// Regexps used to check values and ranges in performance data records.
var (
// Common value check regexp.
vcRegexp = `^-?(0(\.\d*)?|[1-9]\d*(\.\d*)?|\.\d+)$`
// Compiled value check regexp.
valueCheck = regexp.MustCompile(vcRegexp)
// Compiled range min value check.
rangeMinCheck = regexp.MustCompile(vcRegexp + `|^~$`)
)

View file

@ -0,0 +1,50 @@
package perfdata // import nocternity.net/gomonop/pkg/perfdata
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestValueCheckValid(t *testing.T) {
validValues := []string{
"0", "0.", "0.952", "1", "123", "123.", "123.45", ".1",
"-0", "-0.", "-0.952", "-1", "-123", "-123.", "-123.45", "-.1",
}
for _, value := range validValues {
assert.True(t, valueCheck.MatchString(value), "'%s' is a valid value string", value)
}
}
func TestValueCheckInvalid(t *testing.T) {
invalidValues := []string{".", "-.", "a", " ", "", "~"}
for _, value := range invalidValues {
assert.False(t, valueCheck.MatchString(value), "'%s' is an invalid value string", value)
}
}
func TestMinCheckValid(t *testing.T) {
validValues := []string{
"0", "0.", "0.952", "1", "123", "123.", "123.45", ".1",
"-0", "-0.", "-0.952", "-1", "-123", "-123.", "-123.45", "-.1",
"~",
}
for _, value := range validValues {
if !rangeMinCheck.MatchString(value) {
t.Errorf("'%s' is a valid value string", value)
}
}
}
func TestMinCheckInvalid(t *testing.T) {
invalidValues := []string{".", "-.", "a", " ", ""}
for _, value := range invalidValues {
if rangeMinCheck.MatchString(value) {
t.Errorf("'%s' is an invalid value string", value)
}
}
}

View file

@ -3,111 +3,9 @@
package perfdata // import nocternity.net/gomonop/pkg/perfdata
import (
"fmt"
"regexp"
"strings"
)
// Units of measurement, which may be used to qualify the performance data.
type UnitOfMeasurement int
const (
UomNone UnitOfMeasurement = iota
UomSeconds
UomPercent
UomBytes
UomKilobytes
UomMegabytes
UomGigabytes
UomTerabytes
UomCounter
)
func (u UnitOfMeasurement) String() string {
return [...]string{"", "s", "%", "B", "KB", "MB", "GB", "TB", "c"}[u]
}
// Flags indicating which elements of performance data have been set.
type perfDataBits int
const (
PDatWarn perfDataBits = 1 << iota
PDatCrit
PDatMin
PDatMax
)
// Regexps used to check values and ranges in performance data records.
var (
// Common value check regexp.
vcRegexp = `^-?(0(\.\d*)?|[1-9]\d*(\.\d*)?|\.\d+)$`
// Compiled value check regexp.
valueCheck = regexp.MustCompile(vcRegexp)
// Compiled range min value check.
rangeMinCheck = regexp.MustCompile(vcRegexp + `|^~$`)
)
// Performance data range.
type PDRange struct {
start string
end string
inside bool
}
// Creates a performance data range from -inf to 0 and from the specified
// value to +inf.
func PDRMax(max string) *PDRange {
if !valueCheck.MatchString(max) {
panic("invalid performance data range maximum value")
}
pdRange := &PDRange{}
pdRange.start = "0"
pdRange.end = max
return pdRange
}
// Creates a performance data range from -inf to the specified minimal value
// and from the specified maximal value to +inf.
func PDRMinMax(min, max string) *PDRange {
if !valueCheck.MatchString(max) {
panic("invalid performance data range maximum value")
}
if !rangeMinCheck.MatchString(min) {
panic("invalid performance data range minimum value")
}
pdRange := &PDRange{}
pdRange.start = min
pdRange.end = max
return pdRange
}
// Inverts the range.
func (r *PDRange) Inside() *PDRange {
r.inside = true
return r
}
// Generates the range's string representation so it can be sent to the
// monitoring system.
func (r *PDRange) String() string {
var start, inside string
switch r.start {
case "":
start = "~"
case "0":
start = ""
default:
start = r.start
}
if r.inside {
inside = "@"
}
return inside + start + ":" + r.end
}
// Performance data, including a label, units, a value, warning/critical
// ranges and min/max boundaries.
type PerfData struct {
@ -115,7 +13,7 @@ type PerfData struct {
units UnitOfMeasurement
bits perfDataBits
value string
warn, crit PDRange
warn, crit Range
min, max string
}
@ -136,13 +34,13 @@ func New(label string, units UnitOfMeasurement, value string) *PerfData {
}
// Set the warning range for the performance data record.
func (d *PerfData) SetWarn(r *PDRange) {
func (d *PerfData) SetWarn(r *Range) {
d.warn = *r
d.bits |= PDatWarn
}
// Set the critical range for the performance data record.
func (d *PerfData) SetCrit(r *PDRange) {
func (d *PerfData) SetCrit(r *Range) {
d.crit = *r
d.bits |= PDatCrit
}
@ -171,26 +69,28 @@ func (d *PerfData) String() string {
var strBuilder strings.Builder
needsQuotes := strings.ContainsAny(d.Label, " '=\"")
if needsQuotes {
strBuilder.WriteString("'")
strBuilder.WriteRune('\'')
}
strBuilder.WriteString(strings.ReplaceAll(d.Label, "'", "''"))
if needsQuotes {
strBuilder.WriteString("'")
strBuilder.WriteRune('\'')
}
strBuilder.WriteString("=")
strBuilder.WriteString(fmt.Sprintf("%s%s;", d.value, d.units.String()))
strBuilder.WriteRune('=')
strBuilder.WriteString(d.value)
strBuilder.WriteString(d.units.String())
strBuilder.WriteRune(';')
if d.bits&PDatWarn != 0 {
strBuilder.WriteString(d.warn.String())
}
strBuilder.WriteString(";")
strBuilder.WriteRune(';')
if d.bits&PDatCrit != 0 {
strBuilder.WriteString(d.crit.String())
}
strBuilder.WriteString(";")
strBuilder.WriteRune(';')
if d.bits&PDatMin != 0 {
strBuilder.WriteString(d.min)
}
strBuilder.WriteString(";")
strBuilder.WriteRune(';')
if d.bits&PDatMax != 0 {
strBuilder.WriteString(d.max)
}

View file

@ -1,108 +1,261 @@
package perfdata // import nocternity.net/gomonop/pkg/perfdata
package perfdata
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func assert(t *testing.T, check bool, msg string) {
if !check {
t.Errorf(msg)
}
func TestNewNoValue(t *testing.T) {
const label = "label"
const units = UomNone
out := New(label, units, "")
assert.Equal(t, label, out.Label)
assert.Equal(t, units, out.units)
assert.Equal(t, perfDataBits(0), out.bits)
assert.Equal(t, "U", out.value)
// min, max, warn and crit are meaningless here
}
func assertPanic(t *testing.T, f func(), msg string) {
defer func() {
if r := recover(); r == nil {
t.Errorf(msg)
}
}()
f()
func TestNewValidValue(t *testing.T) {
const label = "label"
const units = UomNone
const value = "1234"
out := New(label, units, value)
assert.Equal(t, label, out.Label)
assert.Equal(t, units, out.units)
assert.Equal(t, perfDataBits(0), out.bits)
assert.Equal(t, value, out.value)
// min, max, warn and crit are meaningless here
}
func TestValueCheckValid(t *testing.T) {
validValues := []string{
"0", "0.", "0.952", "1", "123", "123.", "123.45", ".1",
"-0", "-0.", "-0.952", "-1", "-123", "-123.", "-123.45", "-.1",
}
for _, value := range validValues {
if !valueCheck.MatchString(value) {
t.Errorf("'%s' is a valid value string", value)
}
}
func TestNewInvalidValue(t *testing.T) {
assert.Panics(t, func() { New("label", UomNone, "nope") })
}
func TestValueCheckInvalid(t *testing.T) {
invalidValues := []string{".", "-.", "a", " ", ""}
func TestSetWarn(t *testing.T) {
rangeValue := Range{start: "A", end: "B"}
rangeStr := rangeValue.String()
for _, value := range invalidValues {
if valueCheck.MatchString(value) {
t.Errorf("'%s' is an invalid value string", value)
}
}
data := PerfData{}
data.SetWarn(&rangeValue)
assert.True(t, data.bits&PDatWarn != 0)
assert.Equal(t, rangeStr, data.warn.String())
}
func TestPdrMaxInvalid(t *testing.T) {
assertPanic(
t, func() { PDRMax("") },
"Created PerfDataRange with invalid max value",
)
func TestSetWarnTwice(t *testing.T) {
range1Value := Range{start: "A", end: "B"}
range2Value := Range{start: "C", end: "D"}
range2Str := range2Value.String()
require.NotEqual(t, range2Str, range1Value.String())
data := PerfData{}
data.SetWarn(&range1Value)
data.SetWarn(&range2Value)
assert.True(t, data.bits&PDatWarn != 0)
assert.Equal(t, range2Str, data.warn.String())
}
func TestPdrMax(t *testing.T) {
value := "123"
pdr := PDRMax(value)
assert(t, pdr.start == "0", "Min value should be '0'")
assert(t, pdr.end == value, "Max value not copied to PerfDataRange")
assert(t, !pdr.inside, "Inside flag should not be set")
func TestSetCrit(t *testing.T) {
rangeValue := Range{start: "A", end: "B"}
rangeStr := rangeValue.String()
data := PerfData{}
data.SetCrit(&rangeValue)
assert.True(t, data.bits&PDatCrit != 0)
assert.Equal(t, rangeStr, data.crit.String())
}
func TestPdrMinMaxInvalid(t *testing.T) {
assertPanic(
t, func() { PDRMinMax("", "123") },
"Created PerfDataRange with invalid min value",
)
assertPanic(
t, func() { PDRMinMax("123", "") },
"Created PerfDataRange with invalid max value",
)
func TestSetCritTwice(t *testing.T) {
range1Value := Range{start: "A", end: "B"}
range2Value := Range{start: "C", end: "D"}
range2Str := range2Value.String()
require.NotEqual(t, range2Str, range1Value.String())
data := PerfData{}
data.SetCrit(&range1Value)
data.SetCrit(&range2Value)
assert.True(t, data.bits&PDatCrit != 0)
assert.Equal(t, range2Str, data.crit.String())
}
func TestPdrMinMax(t *testing.T) {
min, max := "123", "456"
pdr := PDRMinMax(min, max)
assert(t, pdr.start == min, "Min value not copied to PerfDataRange")
assert(t, pdr.end == max, "Max value not copied to PerfDataRange")
assert(t, !pdr.inside, "Inside flag should not be set")
func TestSetMin(t *testing.T) {
const min = "100"
data := PerfData{}
data.SetMin(min)
assert.True(t, data.bits&PDatMin != 0)
assert.Equal(t, min, data.min)
}
func TestPdrInside(t *testing.T) {
pdr := &PDRange{}
pdr = pdr.Inside()
assert(t, pdr.inside, "Inside flag should be set")
pdr = pdr.Inside()
assert(t, pdr.inside, "Inside flag should still be set")
func TestSetMinInvalid(t *testing.T) {
data := PerfData{}
assert.Panics(t, func() { data.SetMin("nope") })
}
func TestPdrString(t *testing.T) {
func TestSetMinTwice(t *testing.T) {
data := PerfData{}
data.SetMin("100")
data.SetMin("200")
assert.Equal(t, "200", data.min)
assert.True(t, data.bits&PDatMin != 0)
}
func TestSetMax(t *testing.T) {
const max = "100"
data := PerfData{}
data.SetMax(max)
assert.True(t, data.bits&PDatMax != 0)
assert.Equal(t, max, data.max)
}
func TestSetMaxInvalid(t *testing.T) {
data := PerfData{}
assert.Panics(t, func() { data.SetMax("nope") })
}
func TestSetMaxTwice(t *testing.T) {
data := PerfData{}
data.SetMax("100")
data.SetMax("200")
assert.Equal(t, "200", data.max)
assert.True(t, data.bits&PDatMax != 0)
}
func TestString(t *testing.T) {
type Test struct {
pdr PDRange
out string
PerfData
expected string
}
range1 := Range{start: "A", end: "B"}
range2 := Range{start: "C", end: "D"}
tests := []Test{
{pdr: PDRange{start: "Y", end: "X"}, out: "Y:X"},
{pdr: PDRange{end: "X"}, out: "~:X"},
{pdr: PDRange{start: "0", end: "X"}, out: ":X"},
{pdr: PDRange{inside: true, start: "Y", end: "X"}, out: "@Y:X"},
{
PerfData{
Label: "label",
units: UomNone,
bits: perfDataBits(0),
value: "1234",
},
"label=1234;;;;",
},
{
PerfData{
Label: "la=bel",
units: UomNone,
bits: perfDataBits(0),
value: "1234",
},
"'la=bel'=1234;;;;",
},
{
PerfData{
Label: "la bel",
units: UomNone,
bits: perfDataBits(0),
value: "1234",
},
"'la bel'=1234;;;;",
},
{
PerfData{
Label: "la\"bel",
units: UomNone,
bits: perfDataBits(0),
value: "1234",
},
"'la\"bel'=1234;;;;",
},
{
PerfData{
Label: "la'bel",
units: UomNone,
bits: perfDataBits(0),
value: "1234",
},
"'la''bel'=1234;;;;",
},
{
PerfData{
Label: "label",
units: UomNone,
bits: PDatWarn,
value: "1234",
warn: range1,
},
"label=1234;" + range1.String() + ";;;",
},
{
PerfData{
Label: "label",
units: UomNone,
bits: PDatCrit,
value: "1234",
crit: range1,
},
"label=1234;;" + range1.String() + ";;",
},
{
PerfData{
Label: "label",
units: UomNone,
bits: PDatWarn | PDatCrit,
value: "1234",
warn: range1,
crit: range2,
},
"label=1234;" + range1.String() + ";" + range2.String() + ";;",
},
{
PerfData{
Label: "label",
units: UomNone,
bits: PDatMin,
value: "1234",
min: "X",
},
"label=1234;;;X;",
},
{
PerfData{
Label: "label",
units: UomNone,
bits: PDatMax,
value: "1234",
max: "Y",
},
"label=1234;;;;Y",
},
}
for _, units := range []UnitOfMeasurement{UomSeconds, UomPercent, UomBytes, UomKilobytes, UomMegabytes, UomGigabytes, UomTerabytes, UomCounter} {
tests = append(tests, Test{
PerfData{
Label: "label",
units: units,
bits: perfDataBits(0),
value: "1234",
},
fmt.Sprintf("label=1234%s;;;;", units),
})
}
for _, test := range tests {
result := test.pdr.String()
assert(
t,
result == test.out,
fmt.Sprintf("Expected '%s', got '%s'", test.out, result),
)
assert.Equal(t, test.expected, test.PerfData.String())
}
}

62
pkg/perfdata/range.go Normal file
View file

@ -0,0 +1,62 @@
package perfdata // import nocternity.net/gomonop/pkg/perfdata
// Performance data range.
type Range struct {
start string
end string
inside bool
}
// Creates a performance data range from -inf to 0 and from the specified
// value to +inf.
func RangeMax(max string) *Range {
if !valueCheck.MatchString(max) {
panic("invalid performance data range maximum value")
}
pdRange := &Range{}
pdRange.start = "0"
pdRange.end = max
return pdRange
}
// Creates a performance data range from -inf to the specified minimal value
// and from the specified maximal value to +inf.
func RangeMinMax(min, max string) *Range {
if !valueCheck.MatchString(max) {
panic("invalid performance data range maximum value")
}
if !rangeMinCheck.MatchString(min) {
panic("invalid performance data range minimum value")
}
pdRange := &Range{}
pdRange.start = min
pdRange.end = max
return pdRange
}
// Inverts the range.
func (r *Range) Inside() *Range {
r.inside = true
return r
}
// Generates the range's string representation so it can be sent to the
// monitoring system.
func (r *Range) String() string {
var start, inside string
switch r.start {
case "":
start = "~"
case "0":
start = ""
default:
start = r.start
}
if r.inside {
inside = "@"
}
return inside + start + ":" + r.end
}

View file

@ -0,0 +1,67 @@
package perfdata // import nocternity.net/gomonop/pkg/perfdata
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRangeMaxInvalid(t *testing.T) {
assert.Panics(
t, func() { RangeMax("") },
"Created PerfDataRange with invalid max value",
)
}
func TestRangeMax(t *testing.T) {
value := "123"
pdr := RangeMax(value)
assert.Equal(t, "0", pdr.start, "Min value should be '0'")
assert.Equal(t, value, pdr.end, "Max value not copied to PerfDataRange")
assert.False(t, pdr.inside, "Inside flag should not be set")
}
func TestRangeMinMaxInvalid(t *testing.T) {
assert.Panics(
t, func() { RangeMinMax("", "123") },
"Created PerfDataRange with invalid min value",
)
assert.Panics(
t, func() { RangeMinMax("123", "") },
"Created PerfDataRange with invalid max value",
)
}
func TestRangeMinMax(t *testing.T) {
min, max := "123", "456"
pdr := RangeMinMax(min, max)
assert.Equal(t, min, pdr.start, "Min value not copied to 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()
assert.True(t, pdr.inside, "Inside flag should be set")
pdr = pdr.Inside()
assert.True(t, pdr.inside, "Inside flag should still be set")
}
func TestRangeString(t *testing.T) {
type Test struct {
pdr Range
out string
}
tests := []Test{
{pdr: Range{start: "Y", end: "X"}, out: "Y:X"},
{pdr: Range{end: "X"}, out: "~:X"},
{pdr: Range{start: "0", end: "X"}, out: ":X"},
{pdr: Range{inside: true, start: "Y", end: "X"}, out: "@Y:X"},
}
for _, test := range tests {
result := test.pdr.String()
assert.Equal(t, test.out, result, "Expected '%s', got '%s'", test.out, result)
}
}

20
pkg/perfdata/units.go Normal file
View file

@ -0,0 +1,20 @@
package perfdata // import nocternity.net/gomonop/pkg/perfdata
// Units of measurement, which may be used to qualify the performance data.
type UnitOfMeasurement int
const (
UomNone UnitOfMeasurement = iota
UomSeconds
UomPercent
UomBytes
UomKilobytes
UomMegabytes
UomGigabytes
UomTerabytes
UomCounter
)
func (u UnitOfMeasurement) String() string {
return [...]string{"", "s", "%", "B", "KB", "MB", "GB", "TB", "c"}[u]
}

View file

@ -0,0 +1,26 @@
package perfdata // import nocternity.net/gomonop/pkg/perfdata
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestUnitsToString(t *testing.T) {
checks := map[UnitOfMeasurement]string{
UomNone: "",
UomSeconds: "s",
UomPercent: "%",
UomBytes: "B",
UomKilobytes: "KB",
UomMegabytes: "MB",
UomGigabytes: "GB",
UomTerabytes: "TB",
UomCounter: "c",
}
for u, s := range checks {
result := u.String()
assert.Equal(t, s, result, "Expected '%s', got '%s'", s, result)
}
}

View file

@ -1,122 +1,20 @@
// Package plugin implements a helper that can be used to implement a Nagios,
// Centreon, Icinga... service monitoring plugin.
package plugin // import nocternity.net/gomonop/pkg/perfdata
package plugin // import nocternity.net/gomonop/pkg/plugin
import (
"container/list"
"fmt"
"os"
"strings"
import "nocternity.net/gomonop/pkg/results"
"nocternity.net/gomonop/pkg/perfdata"
)
// Plugin represents the interface to a monitoring plugin.
type Plugin interface {
// Results accesses the results of the monitoring plugin.
Results() *results.Results
// Status represents the return status of the monitoring plugin. The
// corresponding integer value will be used as the program's exit code,
// to be interpreted by the monitoring system.
type Status int
// CheckArguments ensures that the arguments that were passed to the plugin
// actually make sense. Errors should be stored in the plugin's results.
CheckArguments() bool
// Plugin exit statuses.
const (
OK Status = iota
WARNING
CRITICAL
UNKNOWN
)
// String representations of the plugin statuses.
func (s Status) String() string {
return [...]string{"OK", "WARNING", "ERROR", "UNKNOWN"}[s]
// RunCheck actually runs whatever checks are implemented by the plugin and
// updates the results accordingly.
RunCheck()
}
// Plugin represents the monitoring plugin's state, including its name,
// return status and message, additional lines of text, and performance
// data to be encoded in the output.
type Plugin struct {
name string
status Status
message string
extraText *list.List
perfData map[string]*perfdata.PerfData
}
// New creates the plugin with `name` as its name and an unknown status.
func New(name string) *Plugin {
p := new(Plugin)
p.name = name
p.status = UNKNOWN
p.message = "no status set"
p.perfData = make(map[string]*perfdata.PerfData)
return p
}
// SetState sets the plugin's output code to `status` and its message to
// the specified `message`.
func (p *Plugin) SetState(status Status, message string) {
p.status = status
p.message = message
}
// AddLine adds the specified string to the extra output text buffer.
func (p *Plugin) AddLine(line string) {
if p.extraText == nil {
p.extraText = list.New()
}
p.extraText.PushBack(line)
}
// AddLinef formats the input and adds it to the text buffer.
func (p *Plugin) AddLinef(format string, data ...interface{}) {
p.AddLine(fmt.Sprintf(format, data...))
}
// AddLines add the specified `lines` to the output text.
func (p *Plugin) AddLines(lines []string) {
for _, line := range lines {
p.AddLine(line)
}
}
// AddPerfData adds performance data described by the "pd" argument to the
// output's performance data. If two performance data records are added for
// the same label, the program panics.
func (p *Plugin) AddPerfData(pd *perfdata.PerfData) {
_, exists := p.perfData[pd.Label]
if exists {
panic("duplicate performance data " + pd.Label)
}
p.perfData[pd.Label] = pd
}
// Done generates the plugin's text output from its name, status, text data
// and performance data, before exiting with the code corresponding to the
// status.
func (p *Plugin) Done() {
var strBuilder strings.Builder
strBuilder.WriteString(p.name)
strBuilder.WriteString(" ")
strBuilder.WriteString(p.status.String())
strBuilder.WriteString(": ")
strBuilder.WriteString(p.message)
if len(p.perfData) > 0 {
strBuilder.WriteString(" | ")
needSep := false
for _, data := range p.perfData {
if needSep {
strBuilder.WriteString(", ")
} else {
needSep = true
}
strBuilder.WriteString(data.String())
}
}
if p.extraText != nil {
for em := p.extraText.Front(); em != nil; em = em.Next() {
strBuilder.WriteString("\n")
//nolint:forcetypeassert // we want to panic if this isn't a string
strBuilder.WriteString(em.Value.(string))
}
}
fmt.Println(strBuilder.String())
os.Exit(int(p.status))
}
// Builder is a function that can be called in order to instantiate a plugin.
type Builder func() Plugin

View file

@ -1,9 +0,0 @@
package program // import nocternity.net/gomonop/pkg/program
type Program interface {
CheckArguments() bool
RunCheck()
Done()
}
type Builder func() Program

107
pkg/results/results.go Normal file
View file

@ -0,0 +1,107 @@
// Package results implements a helper that can be used to store the results of
// a Nagios, Centreon, Icinga... service monitoring plugin, and convert them to
// text which can be sent to the monitoring server.
package results // import nocternity.net/gomonop/pkg/results
import (
"container/list"
"fmt"
"strings"
"nocternity.net/gomonop/pkg/perfdata"
)
// Results represents the monitoring plugin's results, including its name,
// return status and message, additional lines of text, and performance
// data to be encoded in the output.
type Results struct {
name string
status Status
message string
extraText *list.List
perfData map[string]*perfdata.PerfData
}
// New creates the plugin with `name` as its name and an unknown status.
func New(name string) *Results {
p := new(Results)
p.name = name
p.status = StatusUnknown
p.message = "no status set"
p.perfData = make(map[string]*perfdata.PerfData)
return p
}
// SetState sets the plugin's output code to `status` and its message to
// the specified `message`.
func (p *Results) SetState(status Status, message string) {
p.status = status
p.message = message
}
// AddLine adds the specified string to the extra output text buffer.
func (p *Results) AddLine(line string) {
if p.extraText == nil {
p.extraText = list.New()
}
p.extraText.PushBack(line)
}
// AddLinef formats the input and adds it to the text buffer.
func (p *Results) AddLinef(format string, data ...interface{}) {
p.AddLine(fmt.Sprintf(format, data...))
}
// AddLines add the specified `lines` to the output text.
func (p *Results) AddLines(lines []string) {
for _, line := range lines {
p.AddLine(line)
}
}
// AddPerfData adds performance data described by the "pd" argument to the
// output's performance data. If two performance data records are added for
// the same label, the program panics.
func (p *Results) AddPerfData(pd *perfdata.PerfData) {
_, exists := p.perfData[pd.Label]
if exists {
panic("duplicate performance data " + pd.Label)
}
p.perfData[pd.Label] = pd
}
// String generates the plugin's text output from its name, status, text data
// and performance data.
func (p *Results) String() string {
var strBuilder strings.Builder
strBuilder.WriteString(p.name)
strBuilder.WriteString(" ")
strBuilder.WriteString(p.status.String())
strBuilder.WriteString(": ")
strBuilder.WriteString(p.message)
if len(p.perfData) > 0 {
strBuilder.WriteString(" | ")
needSep := false
for _, data := range p.perfData {
if needSep {
strBuilder.WriteString(", ")
} else {
needSep = true
}
strBuilder.WriteString(data.String())
}
}
if p.extraText != nil {
for em := p.extraText.Front(); em != nil; em = em.Next() {
strBuilder.WriteString("\n")
//nolint:forcetypeassert // we want to panic if this isn't a string
strBuilder.WriteString(em.Value.(string))
}
}
return strBuilder.String()
}
// ExitCode returns the plugin's exit code.
func (p *Results) ExitCode() int {
return int(p.status)
}

175
pkg/results/results_test.go Normal file
View file

@ -0,0 +1,175 @@
package results // import nocternity.net/gomonop/pkg/results
import (
"container/list"
"testing"
"github.com/stretchr/testify/assert"
"nocternity.net/gomonop/pkg/perfdata"
)
func TestNew(t *testing.T) {
p := New("test")
assert.Equal(t, p.name, "test")
assert.Equal(t, p.status, StatusUnknown)
assert.Equal(t, p.message, "no status set")
assert.Nil(t, p.extraText)
assert.NotNil(t, p.perfData)
}
func TestSetState(t *testing.T) {
p := Results{}
p.SetState(StatusWarning, "test")
assert.Equal(t, p.status, StatusWarning)
assert.Equal(t, p.message, "test")
}
func TestAddLineFirst(t *testing.T) {
p := Results{}
p.AddLine("test")
assert.NotNil(t, p.extraText)
assert.Equal(t, p.extraText.Len(), 1)
assert.Equal(t, p.extraText.Front().Value, "test")
}
func TestAddLineNext(t *testing.T) {
p := Results{}
p.extraText = list.New()
p.AddLine("test")
assert.Equal(t, p.extraText.Len(), 1)
assert.Equal(t, p.extraText.Front().Value, "test")
}
func TestAddLinef(t *testing.T) {
p := Results{}
p.AddLinef("test %d", 123)
assert.Equal(t, p.extraText.Len(), 1)
assert.Equal(t, p.extraText.Front().Value, "test 123")
}
func TestAddLines(t *testing.T) {
p := Results{}
p.AddLines([]string{"test", "test2"})
assert.Equal(t, p.extraText.Len(), 2)
assert.Equal(t, p.extraText.Front().Value, "test")
assert.Equal(t, p.extraText.Front().Next().Value, "test2")
}
func TestAddPerfData(t *testing.T) {
p := Results{}
p.perfData = make(map[string]*perfdata.PerfData)
p.AddPerfData(&perfdata.PerfData{Label: "test"})
value, ok := p.perfData["test"]
assert.True(t, ok)
assert.Equal(t, value.Label, "test")
}
func TestAddPerfDataDuplicate(t *testing.T) {
p := Results{}
p.perfData = make(map[string]*perfdata.PerfData)
p.perfData["test"] = &perfdata.PerfData{Label: "test"}
assert.Panics(t, func() { p.AddPerfData(&perfdata.PerfData{Label: "test"}) })
}
func TestString(t *testing.T) {
type Test struct {
Results
expected string
}
pdat := perfdata.PerfData{Label: "test"}
tests := []Test{
{
Results{
name: "test",
status: StatusWarning,
message: "test",
perfData: make(map[string]*perfdata.PerfData),
},
"test WARNING: test",
},
{
func() Results {
p := Results{
name: "test",
status: StatusWarning,
message: "test",
perfData: make(map[string]*perfdata.PerfData),
extraText: list.New(),
}
p.extraText.PushBack("test 1")
p.extraText.PushBack("test 2")
return p
}(),
"test WARNING: test\ntest 1\ntest 2",
},
{
func() Results {
p := Results{
name: "test",
status: StatusWarning,
message: "test",
perfData: make(map[string]*perfdata.PerfData),
}
p.perfData["test 1"] = &pdat
p.perfData["test 2"] = &pdat
return p
}(),
"test WARNING: test | " + pdat.String() + ", " +
pdat.String(),
},
{
func() Results {
p := Results{
name: "test",
status: StatusWarning,
message: "test",
perfData: make(map[string]*perfdata.PerfData),
extraText: list.New(),
}
p.perfData["test 1"] = &pdat
p.perfData["test 2"] = &pdat
p.extraText.PushBack("test 1")
p.extraText.PushBack("test 2")
return p
}(),
"test WARNING: test | " + pdat.String() + ", " +
pdat.String() + "\ntest 1\ntest 2",
},
}
for _, test := range tests {
result := test.Results.String()
assert.Equal(t, test.expected, result, "Expected '%s', got '%s'", test.expected, result)
}
}
func TestExitCode(t *testing.T) {
p := Results{}
p.status = StatusOK
assert.Equal(t, int(StatusOK), p.ExitCode())
p.status = StatusWarning
assert.Equal(t, int(StatusWarning), p.ExitCode())
p.status = StatusCritical
assert.Equal(t, int(StatusCritical), p.ExitCode())
p.status = StatusUnknown
assert.Equal(t, int(StatusUnknown), p.ExitCode())
}

19
pkg/results/status.go Normal file
View file

@ -0,0 +1,19 @@
package results // import nocternity.net/gomonop/pkg/results
// Status represents the return status of the monitoring plugin. The
// corresponding integer value will be used as the program's exit code,
// to be interpreted by the monitoring system.
type Status int
// Plugin exit statuses.
const (
StatusOK Status = iota
StatusWarning
StatusCritical
StatusUnknown
)
// String representations of the plugin statuses.
func (s Status) String() string {
return [...]string{"OK", "WARNING", "ERROR", "UNKNOWN"}[s]
}

View file

@ -0,0 +1,26 @@
package results // import nocternity.net/gomonop/pkg/results
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestStatusDefaultOK(t *testing.T) {
var s Status
assert.Equal(t, StatusOK, s)
}
func TestStatusToString(t *testing.T) {
assert.Equal(t, "OK", StatusOK.String())
assert.Equal(t, "WARNING", StatusWarning.String())
assert.Equal(t, "ERROR", StatusCritical.String())
assert.Equal(t, "UNKNOWN", StatusUnknown.String())
}
func TestStatusToInt(t *testing.T) {
assert.Equal(t, 0, int(StatusOK))
assert.Equal(t, 1, int(StatusWarning))
assert.Equal(t, 2, int(StatusCritical))
assert.Equal(t, 3, int(StatusUnknown))
}

View file

@ -0,0 +1,31 @@
package version // import nocternity.net/gomonop/pkg/version
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestVersion(t *testing.T) {
type Test struct {
version, commit, status, target, expected string
}
tests := []Test{
{"", "COMMIT", "clean", "TARGET", "development version (COMMIT) TARGET"},
{"VERSION", "COMMIT", "clean", "TARGET", "VERSION (COMMIT) TARGET"},
{"", "COMMIT", "dirty", "TARGET", "development version (COMMIT*) TARGET"},
{"VERSION", "COMMIT", "dirty", "TARGET", "VERSION (COMMIT*) TARGET"},
}
for _, test := range tests {
version = test.version
commit = test.commit
status = test.status
target = test.target
result := Version()
assert.Equal(t, test.expected, result, "Expected '%s', got '%s'", test.expected, result)
}
}