// Package checkmk erzeugt die Ausgabe für Checkmk im Local-Check-Format // (Spec 6.1): ` "" ` 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() }