software steht so weit.
jetzt build + docker build und testen
This commit is contained in:
1 parent
42f0c0accb
commit
ca6cdcca63
90 files changed
+10298
-480
No files matched your search
@@ -0,0 +1,200 @@
|
||||
// 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: 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()
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package checkmk
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func lines(out string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
start := strings.Index(l, `"`)
|
||||
end := strings.Index(l[start+1:], `"`)
|
||||
m[l[start+1:start+1+end]] = l
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestTenants(t *testing.T) {
|
||||
out := Render(State{Tenants: []Tenant{
|
||||
{ID: "kunde_schmidt", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 74)},
|
||||
{ID: "meier_gmbh", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 25)},
|
||||
{ID: "bald", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 12)},
|
||||
{ID: "alt_ag", Commercial: true, Status: "expired", ValidUntil: now.AddDate(0, 0, -1)},
|
||||
{ID: "homelab", Commercial: false, Status: "active", ValidUntil: now.AddDate(0, 0, -100)},
|
||||
}}, now)
|
||||
l := lines(out)
|
||||
want := map[string]string{
|
||||
"Sultan_Tenant_kunde_schmidt": `0 "Sultan_Tenant_kunde_schmidt" days_left=74;30;14 OK - Abo aktiv bis 2026-12-15 (74 Tage verbleibend)`,
|
||||
"Sultan_Tenant_meier_gmbh": `1 "Sultan_Tenant_meier_gmbh" days_left=25;30;14 WARN - Abo laeuft in 25 Tagen ab! Verlaengerung erforderlich.`,
|
||||
"Sultan_Tenant_bald": `2 "Sultan_Tenant_bald" days_left=12;30;14 CRIT - Abo laeuft in 12 Tagen ab! Verlaengerung erforderlich.`,
|
||||
"Sultan_Tenant_alt_ag": `2 "Sultan_Tenant_alt_ag" days_left=0;30;14 CRIT - Abo seit 1 Tagen abgelaufen! Status: expired`,
|
||||
}
|
||||
for svc, w := range want {
|
||||
if l[svc] != w {
|
||||
t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], w)
|
||||
}
|
||||
}
|
||||
if _, ok := l["Sultan_Tenant_homelab"]; ok {
|
||||
t.Error("Community-Mandant mit Vertragsfrist (5c: keine)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayment(t *testing.T) {
|
||||
out := Render(State{Tenants: []Tenant{
|
||||
{ID: "a", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 0},
|
||||
{ID: "b", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 1, OverdueSince: now.AddDate(0, 0, -9)},
|
||||
{ID: "c", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 2, OverdueSince: now.AddDate(0, 0, -26)},
|
||||
{ID: "d", Commercial: true, Status: "suspended_payment", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 3, OverdueSince: now.AddDate(0, 0, -40)},
|
||||
{ID: "h", Commercial: false, Status: "active", ValidUntil: now},
|
||||
}}, now)
|
||||
l := lines(out)
|
||||
for svc, prefix := range map[string]string{
|
||||
"Sultan_Payment_a": `0 "Sultan_Payment_a" dunning_level=0;1;2 OK - keine offene Zahlung`,
|
||||
"Sultan_Payment_b": `1 "Sultan_Payment_b" dunning_level=1;1;2 WARN - Mahnstufe 1 (Zahlung seit 9 Tagen offen)`,
|
||||
"Sultan_Payment_c": `2 "Sultan_Payment_c" dunning_level=2;1;2 CRIT - Mahnstufe 2 (Zahlung seit 26 Tagen offen)`,
|
||||
"Sultan_Payment_d": `2 "Sultan_Payment_d" dunning_level=3;1;2 CRIT - Mahnstufe 3 (Zahlung seit 40 Tagen offen) - Kunde offline`,
|
||||
} {
|
||||
if l[svc] != prefix {
|
||||
t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], prefix)
|
||||
}
|
||||
}
|
||||
if _, ok := l["Sultan_Payment_h"]; ok {
|
||||
t.Error("Community-Mandant mit Mahnwesen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodes(t *testing.T) {
|
||||
out := Render(State{Nodes: []Node{
|
||||
{ID: "online", Status: "online", LeaseUntil: now.Add(48 * time.Hour)},
|
||||
{ID: "wartung", Status: "online", MaintenanceActive: true, MaintenanceUntil: now.Add(90 * time.Minute), MaintenanceReason: "Kernel 6.12", LeaseUntil: now.Add(time.Hour)},
|
||||
{ID: "kurz-weg", Status: "offline", OfflineSince: now.Add(-5 * time.Minute), LeaseUntil: now.Add(time.Hour)},
|
||||
{ID: "lange-weg", Status: "offline", OfflineSince: now.Add(-20 * time.Minute)},
|
||||
{ID: "abgezogen", Status: "drained"},
|
||||
}}, now)
|
||||
l := lines(out)
|
||||
want := map[string]string{
|
||||
"Sultan_Node_online": `0 "Sultan_Node_online" lease_hours_left=48 OK - online`,
|
||||
"Sultan_Node_wartung": `1 "Sultan_Node_wartung" lease_hours_left=1 WARN - Wartung bis 2026-10-02 13:30 UTC (Kernel 6.12)`,
|
||||
"Sultan_Node_kurz-weg": `1 "Sultan_Node_kurz-weg" lease_hours_left=1 WARN - offline seit 5 min`,
|
||||
"Sultan_Node_lange-weg": `2 "Sultan_Node_lange-weg" lease_hours_left=0 CRIT - offline seit 20 min`,
|
||||
"Sultan_Node_abgezogen": `0 "Sultan_Node_abgezogen" lease_hours_left=0 OK - drained`,
|
||||
}
|
||||
for svc, w := range want {
|
||||
if l[svc] != w {
|
||||
t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Angemeldete Wartung: auch offline nur WARN (geplant), nicht CRIT.
|
||||
func TestNodeOfflineDuringMaintenance(t *testing.T) {
|
||||
out := Render(State{Nodes: []Node{{ID: "pi", Status: "offline", OfflineSince: now.Add(-time.Hour), MaintenanceActive: true}}}, now)
|
||||
if !strings.HasPrefix(strings.TrimSpace(out), `1 "Sultan_Node_pi"`) || !strings.Contains(out, "Wartung unbefristet") {
|
||||
t.Errorf("out = %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViolations(t *testing.T) {
|
||||
for n, want := range map[int]string{
|
||||
0: `0 "Sultan_Policy_Violations" violations_24h=0;1 OK - keine Policy-Verstoesse in 24 h`,
|
||||
3: `1 "Sultan_Policy_Violations" violations_24h=3;1 WARN - 3 Policy-Verstoesse in 24 h (siehe Server-Log)`,
|
||||
} {
|
||||
if got := strings.TrimSpace(Render(State{Violations24h: n}, now)); got != want {
|
||||
t.Errorf("%d:\n got: %s\nwant: %s", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputIsSortedAndASCII(t *testing.T) {
|
||||
out := Render(State{
|
||||
Tenants: []Tenant{{ID: "b", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0)}, {ID: "a", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0)}},
|
||||
Nodes: []Node{{ID: "y", Status: "online"}, {ID: "x", Status: "online", MaintenanceActive: true, MaintenanceReason: "Grün & Ölwechsel\nzweite Zeile"}},
|
||||
}, now)
|
||||
if strings.Index(out, "Sultan_Tenant_a") > strings.Index(out, "Sultan_Tenant_b") || strings.Index(out, "Sultan_Node_x") > strings.Index(out, "Sultan_Node_y") {
|
||||
t.Errorf("nicht sortiert:\n%s", out)
|
||||
}
|
||||
for _, r := range out {
|
||||
if r > 127 {
|
||||
t.Fatalf("Nicht-ASCII-Zeichen %q in:\n%s", r, out)
|
||||
}
|
||||
}
|
||||
if strings.Count(out, "\n") != len(lines(out)) {
|
||||
t.Errorf("Zeilenumbruch aus dem Grund in der Ausgabe:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Gruen & Oelwechsel zweite Zeile") {
|
||||
t.Errorf("Umlaute nicht umschrieben:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortOfflineInSeconds(t *testing.T) {
|
||||
out := Render(State{Nodes: []Node{{ID: "pi", Status: "offline", OfflineSince: now.Add(-45 * time.Second)}}}, now)
|
||||
if !strings.Contains(out, "offline seit 45 s") {
|
||||
t.Errorf("out = %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Wartungsende in der Zeitzone des Servers (wie auf der Lander-Seite).
|
||||
func TestMaintenanceUntilInLocation(t *testing.T) {
|
||||
berlin, err := time.LoadLocation("Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Skip("Zeitzonendaten fehlen")
|
||||
}
|
||||
out := Render(State{
|
||||
Location: berlin,
|
||||
Nodes: []Node{{ID: "pi", Status: "online", MaintenanceActive: true, MaintenanceUntil: now.Add(90 * time.Minute)}},
|
||||
}, now)
|
||||
if !strings.Contains(out, "Wartung bis 2026-10-02 15:30 CEST") {
|
||||
t.Errorf("out = %s", out)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user