From ee64c0d41a90823e5673a4f89567f95cab318bbd Mon Sep 17 00:00:00 2001 From: Philipp Dorschner Date: Thu, 10 Mar 2022 13:30:04 +0100 Subject: [PATCH] Add metrics handling --- .golangci.yml | 3 + metric/metric.go | 224 ++++++++++++++++++++++++++++++++++++++++++ metric/metric_test.go | 170 ++++++++++++++++++++++++++++++++ 3 files changed, 397 insertions(+) create mode 100644 metric/metric.go create mode 100644 metric/metric_test.go diff --git a/.golangci.yml b/.golangci.yml index 38c58e7..e0e4da0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,6 +10,9 @@ linters: - lll - whitespace - wsl + - exportloopref + disable: + - scopelint presets: - bugs - unused diff --git a/metric/metric.go b/metric/metric.go new file mode 100644 index 0000000..e3e9dbc --- /dev/null +++ b/metric/metric.go @@ -0,0 +1,224 @@ +package metric + +import ( + "errors" + "fmt" + "github.com/NETWAYS/go-check" + "github.com/NETWAYS/go-check/perfdata" + "regexp" + "strconv" + "strings" +) + +var ( + reThreshold = regexp.MustCompile(`(?i)^(\d+)\s*(%|[TGMK]i?B)?$`) + ErrThresholdInvalid = errors.New("threshold invalid") +) + +const ( + KibiByte uint64 = 1024 + MebiByte = 1024 * KibiByte + GibiByte = 1024 * MebiByte + TebiByte = 1024 * GibiByte +) + +// Metric allows to check a metric for levels specified by its thresholds. +// +// Is currently implemented for byte values in IEC - which is what Nagios plugins use. +type Metric struct { + Value uint64 + Warning uint64 + Critical uint64 + Total uint64 + Type string +} + +// NewMetric returns a new Metric. +// TODO Value can not be greater than Total +func NewMetric(value, total uint64) *Metric { + return &Metric{Value: value, Total: total} +} + +// SetWarning parses warning level for free OR used and remembers the absolute value. +// +// Used: +// Total 100 MB; Threshold 80% => If 80 MB used. Returns warning +// +// Free: +// Total 100MB; Threshold 80% => If 20 MB free. Returns warning +func (m *Metric) SetWarning(threshold string) (err error) { + // TODO Refactor + var thresh uint64 + + switch m.Type { + case "used": + thresh, err = ThresholdUsed(threshold, m.Total) + if err != nil { + return fmt.Errorf("warning: %w", err) + } + case "free": + thresh, err = ThresholdFree(threshold, m.Total) + if err != nil { + return fmt.Errorf("warning: %w", err) + } + default: + return fmt.Errorf("wrong type, please specify 'used' OR 'free'") + } + + m.Warning = thresh + + return nil +} + +// SetCritical parses critical level for free OR used and remembers the absolute value. +// +// Used: +// Total 100 MB; Threshold 90% => If 90 MB used. Returns critical +// +// Free: +// Total 100MB; Threshold 90% => If 10 MB free. Returns critical +func (m *Metric) SetCritical(threshold string) (err error) { + // TODO Refactor + var thresh uint64 + + switch m.Type { + case "used": + thresh, err = ThresholdUsed(threshold, m.Total) + if err != nil { + return fmt.Errorf("critical: %w", err) + } + case "free": + thresh, err = ThresholdFree(threshold, m.Total) + if err != nil { + return fmt.Errorf("critical: %w", err) + } + default: + return fmt.Errorf("wrong type, please specify 'used' OR 'free'") + } + + m.Critical = thresh + + return nil +} + +// Status returns the Icinga status in perspective to the current value and thresholds. +// +// Free: +// Value <= Warning will result in warning state +// Value <= Critical will result in critical state +// +// Used: +// Value >= Warning will result in warning state +// Value >= Critical will result in critical state +func (m *Metric) Status() int { + switch m.Type { + case "used": + if m.Critical > 0 && m.Value >= m.Critical { + return check.Critical + } else if m.Warning > 0 && m.Value >= m.Warning { + return check.Warning + } else { + return check.OK + } + case "free": + if m.Critical > 0 && m.Value <= m.Critical { + return check.Critical + } else if m.Warning > 0 && m.Value <= m.Warning { + return check.Warning + } else { + return check.OK + } + default: + return check.Unknown + } +} + +// Perfdata returns a perfdata.Perfdata object for output with a plugin. +// +// Values are scaled down to MB, so they are more readable. And we won't need that much precision. +func (m *Metric) Perfdata(label string) perfdata.Perfdata { + // TODO If the values are to small, the value will be evaluated as 0 + return perfdata.Perfdata{ + Label: label, + Value: m.Value / MebiByte, + //Value: float64(m.Value) / float64(MebiByte), + Uom: "MB", // Warning: This should be IEC, but Nagios plugins won't know that. + Warn: &check.Threshold{Upper: float64(m.Warning / MebiByte)}, + //Warn: &check.Threshold{Upper: float64(m.Warning) / float64(MebiByte)}, + Crit: &check.Threshold{Upper: float64(m.Critical / MebiByte)}, + //Crit: &check.Threshold{Upper: float64(m.Critical) / float64(MebiByte)}, + Min: 0, + Max: m.Total / MebiByte, + //Max: float64(m.Total) / float64(MebiByte), + } +} + +// ThresholdFree returns the threshold level relative to the total. +// +// 10% free from 100MB = 90MB used +// 15MB free from 100MB = 85MB used +func ThresholdFree(threshold string, total uint64) (uint64, error) { + level, err := ParseThreshold(threshold, total) + if err != nil { + return 0, err + } + + return total - level, nil +} + +// ThresholdUsed returns the threshold level relative to the total. +// +// 90% used from 100MB = 10MB free (available) +// 85MB used from 100MB = 15MB free (available) +func ThresholdUsed(threshold string, total uint64) (uint64, error) { + level, err := ParseThreshold(threshold, total) + if err != nil { + return 0, err + } + + return level, nil +} + +// ParseThreshold returns the parsed unit(UOM) from threshold +// +// Total = 100MB; Threshold = 10% => 10MB +// Total = 100MB; Threshold = 30MB => 30MB +// Viable units are: kb, kib, mb, mib, gb, gib, tb, tib, % +func ParseThreshold(threshold string, total uint64) (uint64, error) { + match := reThreshold.FindStringSubmatch(threshold) + if match == nil { + return 0, ErrThresholdInvalid + } + + value, err := strconv.ParseUint(match[1], 10, 64) + if err != nil { + return 0, err + } + + if match[2] == "%" { + if value > 100 { + return 0, fmt.Errorf("percentage can't be larger than 100") + } + + level := (float64(value) / 100) * float64(total) + + return uint64(level), nil + } + + var level uint64 + + switch u := strings.ToLower(match[2]); u { + case "kb", "kib": + level = value * KibiByte + case "", "mb", "mib": + level = value * MebiByte + case "gb", "gib": + level = value * GibiByte + case "tb", "tib": + level = value * TebiByte + default: + return 0, fmt.Errorf("invalid unit") + } + + return level, nil +} diff --git a/metric/metric_test.go b/metric/metric_test.go new file mode 100644 index 0000000..315d835 --- /dev/null +++ b/metric/metric_test.go @@ -0,0 +1,170 @@ +package metric + +import ( + "fmt" + "github.com/NETWAYS/go-check" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestNewMetric(t *testing.T) { + m := NewMetric(10*MebiByte, 100*MebiByte) + + assert.Equal(t, m.Value, 10*MebiByte) + assert.Equal(t, m.Total, 100*MebiByte) +} + +func TestSimpleMetric_SetWarning(t *testing.T) { + m := &Metric{Value: 10 * MebiByte, Total: 100 * MebiByte, Type: "free"} + + err := m.SetWarning("10%") + assert.NoError(t, err) + assert.Equal(t, 90*MebiByte, m.Warning) + + m = &Metric{Value: 90 * MebiByte, Total: 100 * MebiByte, Type: "used"} + + err = m.SetWarning("90%") + assert.NoError(t, err) + assert.Equal(t, 90*MebiByte, m.Warning) +} + +func TestSimpleMetric_SetCritical(t *testing.T) { + m := &Metric{Value: 10 * MebiByte, Total: 100 * MebiByte, Type: "free"} + + err := m.SetCritical("20%") + assert.NoError(t, err) + assert.Equal(t, 80*MebiByte, m.Critical) + + m = &Metric{Value: 80 * MebiByte, Total: 100 * MebiByte, Type: "used"} + + err = m.SetCritical("80%") + assert.NoError(t, err) + assert.Equal(t, 80*MebiByte, m.Critical) +} + +// nolint: dupl +func TestThresholdFree(t *testing.T) { + threshold, err := ThresholdFree("10%", 100*MebiByte) + assert.NoError(t, err) + assert.Equal(t, 90*MebiByte, threshold) + + threshold, err = ThresholdFree("20MB", 100*MebiByte) + assert.NoError(t, err) + assert.Equal(t, 80*MebiByte, threshold) + + threshold, err = ThresholdFree("10", 100*MebiByte) + assert.NoError(t, err) + assert.Equal(t, 90*MebiByte, threshold) + + threshold, err = ThresholdFree("25MiB", 100*MebiByte) + assert.NoError(t, err) + assert.Equal(t, 75*MebiByte, threshold) + + threshold, err = ThresholdFree("25GiB", 100*GibiByte) + assert.NoError(t, err) + assert.Equal(t, 75*GibiByte, threshold) + + threshold, err = ThresholdFree("25TiB", 100*TebiByte) + assert.NoError(t, err) + assert.Equal(t, 75*TebiByte, threshold) + + _, err = ThresholdFree("101%", 100*MebiByte) + if assert.Error(t, err) { + assert.Equal(t, fmt.Errorf("percentage can't be larger than 100"), err) + } + + _, err = ThresholdFree("50Exmaple", 100*MebiByte) + if assert.Error(t, err) { + assert.Equal(t, fmt.Errorf("threshold invalid"), err) + } +} + +// nolint: dupl +func TestThresholdUsed(t *testing.T) { + threshold, err := ThresholdUsed("90%", 100*MebiByte) + assert.NoError(t, err) + assert.Equal(t, 90*MebiByte, threshold) + + threshold, err = ThresholdUsed("80MB", 100*MebiByte) + assert.NoError(t, err) + assert.Equal(t, 80*MebiByte, threshold) + + threshold, err = ThresholdUsed("90", 100*MebiByte) + assert.NoError(t, err) + assert.Equal(t, 90*MebiByte, threshold) + + threshold, err = ThresholdUsed("75MiB", 100*MebiByte) + assert.NoError(t, err) + assert.Equal(t, 75*MebiByte, threshold) + + threshold, err = ThresholdUsed("75GiB", 100*GibiByte) + assert.NoError(t, err) + assert.Equal(t, 75*GibiByte, threshold) + + threshold, err = ThresholdUsed("75TiB", 100*TebiByte) + assert.NoError(t, err) + assert.Equal(t, 75*TebiByte, threshold) + + _, err = ThresholdUsed("101%", 100*MebiByte) + if assert.Error(t, err) { + assert.Equal(t, fmt.Errorf("percentage can't be larger than 100"), err) + } + + _, err = ThresholdUsed("50Exmaple", 100*MebiByte) + if assert.Error(t, err) { + assert.Equal(t, fmt.Errorf("threshold invalid"), err) + } +} + +func TestSimpleMetric_StatusFree(t *testing.T) { + m := &Metric{Value: 30 * MebiByte, Total: 100 * MebiByte, Type: "free"} + err := m.SetCritical("90%") // 10 MB available space + assert.NoError(t, err) + + err = m.SetWarning("80%") // 20 MB available space + assert.NoError(t, err) + assert.Equal(t, check.OK, m.Status()) + + m.Value = 20 * MebiByte + assert.Equal(t, check.Warning, m.Status()) + + m.Value = 10 * MebiByte + assert.Equal(t, check.Critical, m.Status()) +} + +func TestSimpleMetric_StatusUsed(t *testing.T) { + m := &Metric{Value: 79 * MebiByte, Total: 100 * MebiByte, Type: "used"} + err := m.SetCritical("90%") // 10 MB available space + assert.NoError(t, err) + + err = m.SetWarning("80%") // 20 MB available space + assert.NoError(t, err) + + assert.Equal(t, check.OK, m.Status()) + + m.Value = 80 * MebiByte + assert.Equal(t, check.Warning, m.Status()) + + m.Value = 90 * MebiByte + assert.Equal(t, check.Critical, m.Status()) +} + +func TestSimpleMetric_Perfdata(t *testing.T) { + m := &Metric{Value: 10 * MebiByte, Total: 100 * MebiByte, Type: "free"} + err := m.SetCritical("10%") // 10 MB + assert.NoError(t, err) + + err = m.SetWarning("20%") // 20 MB + assert.NoError(t, err) + + assert.Equal(t, "/=10MB;80;90;0;100", m.Perfdata("/").String()) + + m = &Metric{Value: 90 * MebiByte, Total: 100 * MebiByte, Type: "used"} + err = m.SetCritical("90%") + assert.NoError(t, err) + + err = m.SetWarning("80%") + assert.NoError(t, err) + + assert.Equal(t, "/=90MB;80;90;0;100", m.Perfdata("/").String()) +}