205 lines
6.2 KiB
Go
205 lines
6.2 KiB
Go
// Package checkmk erzeugt die Ausgabe für Checkmk im Local-Check-Format
|
||
// (Spec 6.1): `<status> "<service>" <metriken> <text>` mit 0 OK, 1 WARN,
|
||
// 2 CRIT. Der Text ist ASCII (Umlaute umschrieben), einzeilig.
|
||
package checkmk
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Schwellen (Spec 6.2 / Entscheidungen 2026-10-02).
|
||
const (
|
||
ContractWarnDays = 30
|
||
ContractCritDays = 14
|
||
OfflineCritAfter = 15 * time.Minute
|
||
)
|
||
|
||
const (
|
||
statusOK = 0
|
||
statusW = 1
|
||
statusCrit = 2
|
||
)
|
||
|
||
// Tenant ist ein Mandant aus Sicht des Monitorings.
|
||
type Tenant struct {
|
||
ID string
|
||
Commercial bool // Community: keine Vertragsfristen, kein Mahnwesen
|
||
Status string
|
||
ValidUntil time.Time
|
||
DunningLevel int
|
||
OverdueSince time.Time
|
||
}
|
||
|
||
// Node ist ein Knoten aus Sicht des Monitorings.
|
||
type Node struct {
|
||
ID string
|
||
Status string // online, offline, drained
|
||
OfflineSince time.Time
|
||
MaintenanceActive bool
|
||
MaintenanceUntil time.Time // Nullwert = unbefristet
|
||
MaintenanceReason string // intern – Monitoring ist intern
|
||
LeaseUntil time.Time // Nullwert = keine Lease
|
||
}
|
||
|
||
// State ist alles, was ausgegeben wird.
|
||
type State struct {
|
||
Tenants []Tenant
|
||
Nodes []Node
|
||
Violations24h int
|
||
// OfflineCrit: ab wann ein getrennter Knoten CRIT ist (Default 15 min).
|
||
OfflineCrit time.Duration
|
||
// Location: Zeitzone für angezeigte Zeitpunkte (Default UTC).
|
||
Location *time.Location
|
||
}
|
||
|
||
var label = map[int]string{statusOK: "OK", statusW: "WARN", statusCrit: "CRIT"}
|
||
|
||
const day = 24 * time.Hour
|
||
|
||
// Render liefert die Ausgabe, sortiert nach Mandant bzw. Knoten.
|
||
func Render(s State, now time.Time) string {
|
||
var b strings.Builder
|
||
line := func(status int, service, metrics, text string) {
|
||
fmt.Fprintf(&b, "%d %q %s %s - %s\n", status, service, metrics, label[status], ascii(text))
|
||
}
|
||
|
||
tenants := slices.Clone(s.Tenants)
|
||
slices.SortFunc(tenants, func(a, b Tenant) int { return strings.Compare(a.ID, b.ID) })
|
||
for _, t := range tenants {
|
||
if !t.Commercial {
|
||
continue
|
||
}
|
||
renderContract(line, t, now)
|
||
renderPayment(line, t, now)
|
||
}
|
||
|
||
nodes := slices.Clone(s.Nodes)
|
||
slices.SortFunc(nodes, func(a, b Node) int { return strings.Compare(a.ID, b.ID) })
|
||
crit := s.OfflineCrit
|
||
if crit <= 0 {
|
||
crit = OfflineCritAfter
|
||
}
|
||
loc := s.Location
|
||
if loc == nil {
|
||
loc = time.UTC
|
||
}
|
||
for _, n := range nodes {
|
||
renderNode(line, n, now, crit, loc)
|
||
}
|
||
|
||
if s.Violations24h == 0 {
|
||
line(statusOK, "Sultan_Policy_Violations", "violations_24h=0;1", "keine Policy-Verstoesse in 24 h")
|
||
} else {
|
||
line(statusW, "Sultan_Policy_Violations", fmt.Sprintf("violations_24h=%d;1", s.Violations24h),
|
||
fmt.Sprintf("%d Policy-Verstoesse in 24 h (siehe Server-Log)", s.Violations24h))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func renderContract(line func(int, string, string, string), t Tenant, now time.Time) {
|
||
svc := "Sultan_Tenant_" + t.ID
|
||
days := int(math.Floor(t.ValidUntil.Sub(now).Hours() / 24))
|
||
metrics := func(d int) string {
|
||
return fmt.Sprintf("days_left=%d;%d;%d", max(d, 0), ContractWarnDays, ContractCritDays)
|
||
}
|
||
switch {
|
||
case t.Status == "expired" || !now.Before(t.ValidUntil):
|
||
since := int(math.Ceil(now.Sub(t.ValidUntil).Hours() / 24))
|
||
line(statusCrit, svc, metrics(0), fmt.Sprintf("Abo seit %d Tagen abgelaufen! Status: %s", since, t.Status))
|
||
case t.Status == "quarantined":
|
||
line(statusCrit, svc, metrics(days), "Mandant in Quarantaene")
|
||
case days <= ContractCritDays:
|
||
line(statusCrit, svc, metrics(days), fmt.Sprintf("Abo laeuft in %d Tagen ab! Verlaengerung erforderlich.", days))
|
||
case days <= ContractWarnDays:
|
||
line(statusW, svc, metrics(days), fmt.Sprintf("Abo laeuft in %d Tagen ab! Verlaengerung erforderlich.", days))
|
||
default:
|
||
line(statusOK, svc, metrics(days), fmt.Sprintf("Abo aktiv bis %s (%d Tage verbleibend)", t.ValidUntil.UTC().Format(time.DateOnly), days))
|
||
}
|
||
}
|
||
|
||
func renderPayment(line func(int, string, string, string), t Tenant, now time.Time) {
|
||
svc := "Sultan_Payment_" + t.ID
|
||
metrics := fmt.Sprintf("dunning_level=%d;1;2", t.DunningLevel)
|
||
if t.DunningLevel == 0 {
|
||
line(statusOK, svc, metrics, "keine offene Zahlung")
|
||
return
|
||
}
|
||
text := fmt.Sprintf("Mahnstufe %d (Zahlung seit %d Tagen offen)", t.DunningLevel, int(now.Sub(t.OverdueSince)/day))
|
||
if t.DunningLevel >= 3 {
|
||
text += " - Kunde offline"
|
||
}
|
||
status := statusW
|
||
if t.DunningLevel >= 2 {
|
||
status = statusCrit
|
||
}
|
||
line(status, svc, metrics, text)
|
||
}
|
||
|
||
func renderNode(line func(int, string, string, string), n Node, now time.Time, offlineCrit time.Duration, loc *time.Location) {
|
||
svc := "Sultan_Node_" + n.ID
|
||
leaseHours := 0
|
||
if n.LeaseUntil.After(now) {
|
||
leaseHours = int(n.LeaseUntil.Sub(now) / time.Hour)
|
||
}
|
||
metrics := fmt.Sprintf("lease_hours_left=%d", leaseHours)
|
||
switch {
|
||
case n.Status == "drained":
|
||
line(statusOK, svc, metrics, "drained")
|
||
case n.MaintenanceActive:
|
||
text := "Wartung unbefristet"
|
||
if !n.MaintenanceUntil.IsZero() {
|
||
text = "Wartung bis " + n.MaintenanceUntil.In(loc).Format("2006-01-02 15:04 MST")
|
||
}
|
||
if n.MaintenanceReason != "" {
|
||
text += " (" + n.MaintenanceReason + ")"
|
||
}
|
||
line(statusW, svc, metrics, text)
|
||
case n.Status == "offline":
|
||
since := now.Sub(n.OfflineSince)
|
||
status := statusW
|
||
if since >= offlineCrit {
|
||
status = statusCrit
|
||
}
|
||
line(status, svc, metrics, "offline seit "+shortDuration(since))
|
||
default:
|
||
line(statusOK, svc, metrics, "online")
|
||
}
|
||
}
|
||
|
||
// shortDuration: unter einer Minute in Sekunden, sonst in Minuten.
|
||
func shortDuration(d time.Duration) string {
|
||
if d < time.Minute {
|
||
return fmt.Sprintf("%d s", int(d/time.Second))
|
||
}
|
||
return fmt.Sprintf("%d min", int(d/time.Minute))
|
||
}
|
||
|
||
var umlauts = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "Ä", "Ae", "Ö", "Oe", "Ü", "Ue", "ß", "ss")
|
||
|
||
// ASCII macht Text Checkmk-tauglich (siehe ascii) – auch für andere
|
||
// Checkmk-Ausgaben, z. B. die des Accountants.
|
||
func ASCII(s string) string { return ascii(s) }
|
||
|
||
// ascii macht Text Checkmk-tauglich: einzeilig, Umlaute umschrieben,
|
||
// übrige Nicht-ASCII-Zeichen ersetzt.
|
||
func ascii(s string) string {
|
||
s = umlauts.Replace(s)
|
||
var b strings.Builder
|
||
for _, r := range s {
|
||
switch {
|
||
case r == '\n' || r == '\r' || r == '\t':
|
||
b.WriteByte(' ')
|
||
case r < 32 || r == 127:
|
||
case r > 127:
|
||
b.WriteByte('?')
|
||
default:
|
||
b.WriteRune(r)
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|