software steht so weit.

jetzt build + docker build und testen
This commit is contained in:
br committed 2026-10-02 13:25:34 +02:00
1 parent 42f0c0accb
commit ca6cdcca63
90 files changed
+10298 -480

No files matched your search

+200
View File
@@ -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()
}
+153
View File
@@ -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)
}
}