Files
sultan/pkg/accountant/checkmk.go
T

330 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package accountant
// Checkmk-Ausgabe des Accountants (Spec 5.3, Roadmap 6e): ein Service je URL
// (Störfall: wohin springen?) und je Payee (Frische der Berichte), im
// Local-Check-Format. Der Text kommt aus einer text/template-Vorlage:
// eingebaut, überschreibbar durch <data-dir>/checkmk/checkmk.tmpl (wirkt
// sofort). Werte aus Berichten sind Fremdeingaben (Containername,
// Compose-Verzeichnis stammen vom Kunden-Host): Sie sind bereinigt
// (einzeilig, ASCII, ohne | und ") und HTML-escaped; nur bereinigt stehen
// sie unter .Unescaped – falls in Checkmk das HTML-Escaping abgeschaltet ist,
// öffnet deren Ausgabe Script-Injektion in die Oberfläche.
import (
"bytes"
"cmp"
"encoding/json"
"errors"
"fmt"
"html"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"text/template"
"time"
"sultan/pkg/checkmk"
"sultan/pkg/ledger"
)
// CheckmkTemplateName ist der Dateiname der eigenen Vorlage in <data-dir>/checkmk.
const CheckmkTemplateName = "checkmk.tmpl"
// Checkmk-Status.
const (
cmkOK = 0
cmkWarn = 1
cmkCrit = 2
)
// defaultCheckmkTemplate ist die eingebaute Vorlage (sultan accountant
// dump-checkmk exportiert sie zum Anpassen). Jede Zeile:
// <status> "<service>" - <OK|WARN|CRIT> - <text>
const defaultCheckmkTemplate = `{{- /*
Checkmk-Vorlage des Sultan-Accountants (text/template).
Eine Zeile je Service: <status> "<service>" - <OK|WARN|CRIT> - <text>
.Payees: .Service .State .Problem .ID .Hostname .Age .LastReport
.URLs: .Service .State .Problem .Host .URL .Tenant .Payee .Node .Router .Workload
.Backend .BackendFQDN .BackendURL .ComposeProject .ComposeDir .Health .Product
Werte sind bereinigt und HTML-escaped; .Unescaped.<Feld> nur bereinigt.
Funktionen: status (0/1/2 → OK/WARN/CRIT)
*/ -}}
{{- range .Payees -}}
{{.State}} "{{.Service}}" - {{status .State}} - {{with .Problem}}{{.}}; {{end}}letzter Bericht {{.Age}}{{with .Hostname}} von {{.}}{{end}}
{{end -}}
{{- range .URLs -}}
{{.State}} "{{.Service}}" - {{status .State}} - {{with .Problem}}{{.}}; {{end}}{{.URL}} -> {{or .BackendURL .Backend}} ({{.Tenant}} auf {{.Node}} via {{.Payee}}{{with .ComposeDir}}, compose {{.}}{{end}}{{with .Health}}, {{.}}{{end}}{{with .Product}}, Produkt {{.}}{{end}})
{{end -}}
`
// CheckmkData ist der Inhalt, den die Vorlage sieht.
type CheckmkData struct {
Now time.Time
Payees []CheckmkPayee
URLs []CheckmkURL
}
// PayeeFields sind die Textfelder eines Payees.
type PayeeFields struct {
ID string
Hostname string
Problem string
}
// CheckmkPayee ist der Frische-Service eines Payees.
type CheckmkPayee struct {
PayeeFields // bereinigt und HTML-escaped
Unescaped PayeeFields // nur bereinigt
Service string // Name des Checkmk-Services
State int // 0 OK, 1 WARN, 2 CRIT
Age string // z. B. "vor 5 min", "nie"
LastReport time.Time // Nullzeit = nie
}
// URLFields sind die Textfelder einer URL.
type URLFields struct {
Host, URL string
Tenant, Payee, Node string
Router, Workload string
Backend, BackendFQDN, BackendURL string
ComposeProject, ComposeDir string
Health, Product string
Problem string
}
// CheckmkURL ist der Service einer URL.
type CheckmkURL struct {
URLFields // bereinigt und HTML-escaped
Unescaped URLFields // nur bereinigt
Service string
State int
}
// clean macht Fremdtext einzeilig, ASCII und frei von Trennzeichen des
// Local-Check-Formats.
func clean(s string) string {
return strings.NewReplacer("|", "/", `"`, "'").Replace(checkmk.ASCII(s))
}
func escapeFields[T any](v T, f func(*T, func(string) string)) T {
f(&v, html.EscapeString)
return v
}
func (u *URLFields) apply(fn func(string) string) {
for _, p := range []*string{&u.Host, &u.URL, &u.Tenant, &u.Payee, &u.Node, &u.Router, &u.Workload,
&u.Backend, &u.BackendFQDN, &u.BackendURL, &u.ComposeProject, &u.ComposeDir, &u.Health, &u.Product, &u.Problem} {
*p = fn(*p)
}
}
func (p *PayeeFields) apply(fn func(string) string) {
p.ID, p.Hostname, p.Problem = fn(p.ID), fn(p.Hostname), fn(p.Problem)
}
func age(d time.Duration) string {
switch {
case d < time.Minute:
return "vor weniger als 1 min"
case d < time.Hour:
return fmt.Sprintf("vor %d min", int(d/time.Minute))
case d < 48*time.Hour:
return fmt.Sprintf("vor %d h", int(d/time.Hour))
default:
return fmt.Sprintf("vor %d Tagen", int(d/(24*time.Hour)))
}
}
// freshness bewertet das Alter eines Berichts.
func freshness(last, now time.Time, warn, crit time.Duration) (int, string) {
switch d := now.Sub(last); {
case last.IsZero():
return cmkWarn, "noch kein Bericht"
case d > crit:
return cmkCrit, "Bericht veraltet (" + age(d) + ")"
case d > warn:
return cmkWarn, "Bericht veraltet (" + age(d) + ")"
default:
return cmkOK, ""
}
}
// buildCheckmk bewertet Payees und URLs. reports enthält den letzten Bericht
// je Payee-ID (fehlt bei Payees ohne Bericht).
func buildCheckmk(payees []ledger.Payee, reports map[string]Report, now time.Time, staleWarn, staleCrit time.Duration) CheckmkData {
d := CheckmkData{Now: now}
for _, p := range payees {
state, problem := freshness(p.LastReport, now, staleWarn, staleCrit)
raw := PayeeFields{ID: clean(p.ID), Hostname: clean(p.Hostname), Problem: problem}
cp := CheckmkPayee{
Unescaped: raw, PayeeFields: escapeFields(raw, (*PayeeFields).apply),
Service: "Sultan Payee " + raw.ID, State: state, Age: "nie", LastReport: p.LastReport,
}
if !p.LastReport.IsZero() {
cp.Age = age(now.Sub(p.LastReport))
}
d.Payees = append(d.Payees, cp)
rep, ok := reports[p.ID]
if !ok {
continue
}
nodes := map[string]ReportNode{}
for _, n := range rep.Nodes {
nodes[n.ID] = n
}
for _, r := range rep.Routes {
for _, host := range r.Hosts {
d.URLs = append(d.URLs, urlService(p.ID, host, r, nodes[r.Node], state, problem))
}
}
}
slices.SortFunc(d.Payees, func(a, b CheckmkPayee) int { return strings.Compare(a.Service, b.Service) })
// Eindeutige Servicenamen: derselbe Host mehrfach → Zusatz (payee/knoten).
slices.SortFunc(d.URLs, func(a, b CheckmkURL) int {
return cmp.Or(strings.Compare(a.Unescaped.Host, b.Unescaped.Host), strings.Compare(a.Unescaped.Payee, b.Unescaped.Payee),
strings.Compare(a.Unescaped.Node, b.Unescaped.Node), strings.Compare(a.Unescaped.Router, b.Unescaped.Router))
})
seen := map[string]bool{}
for i := range d.URLs {
u := &d.URLs[i]
u.Service = "Sultan URL " + u.Unescaped.Host
if seen[u.Service] {
u.Service += " (" + u.Unescaped.Payee + "/" + u.Unescaped.Node + "/" + u.Unescaped.Router + ")"
}
seen[u.Service] = true
}
slices.SortFunc(d.URLs, func(a, b CheckmkURL) int { return strings.Compare(a.Service, b.Service) })
return d
}
func urlService(payeeID, host string, r ReportRoute, node ReportNode, payeeState int, payeeProblem string) CheckmkURL {
raw := URLFields{
Host: clean(host), Tenant: clean(r.Tenant), Payee: clean(payeeID), Node: clean(r.Node),
Router: clean(r.Router), Workload: clean(r.Workload), Backend: clean(r.Backend), BackendFQDN: clean(r.BackendFQDN),
ComposeProject: clean(r.ComposeProject), ComposeDir: clean(r.ComposeDir), Health: clean(r.Health), Product: clean(r.Product),
}
if r.Protocol == "tcp" {
raw.URL, raw.BackendURL = raw.Host, raw.BackendFQDN
} else {
raw.URL = "https://" + raw.Host
if raw.BackendFQDN != "" {
raw.BackendURL = "http://" + raw.BackendFQDN
}
}
state := cmkOK
var problems []string
if !r.Active {
problems = append(problems, "von Policy verworfen")
}
if r.Down {
problems = append(problems, "gestoppt/Wartungsseite")
}
if r.Health == "unhealthy" {
problems = append(problems, "unhealthy")
}
if !node.Connected && !node.Maintenance {
problems = append(problems, "Knoten getrennt")
}
switch {
case node.Maintenance:
// Angekündigte Wartung: kein Alarm, aber sichtbar.
problems = append([]string{"Wartung"}, problems...)
case len(problems) > 0:
state = cmkWarn
}
if payeeState > state {
state = payeeState
problems = append([]string{"Bericht von " + payeeID + ": " + payeeProblem}, problems...)
}
raw.Problem = clean(strings.Join(problems, ", "))
return CheckmkURL{Unescaped: raw, URLFields: escapeFields(raw, (*URLFields).apply), State: state}
}
var cmkFuncs = template.FuncMap{
"status": func(s int) string {
switch s {
case cmkOK:
return "OK"
case cmkWarn:
return "WARN"
default:
return "CRIT"
}
},
}
var builtinCheckmk = template.Must(template.New(CheckmkTemplateName).Funcs(cmkFuncs).Parse(defaultCheckmkTemplate))
// renderCheckmk rendert d mit der eigenen Vorlage aus dataDir (falls
// vorhanden), sonst mit der eingebauten. Eine fehlerhafte eigene Vorlage
// fällt auf die eingebaute zurück und erscheint als CRIT-Service.
func renderCheckmk(dataDir string, d CheckmkData) string {
path := filepath.Join(dataDir, "checkmk", CheckmkTemplateName)
var problem error
if raw, err := os.ReadFile(path); err == nil {
t, err := template.New(CheckmkTemplateName).Funcs(cmkFuncs).Parse(string(raw))
if err == nil {
var buf bytes.Buffer
if err = t.Execute(&buf, d); err == nil {
return buf.String()
}
}
problem = err
} else if !errors.Is(err, os.ErrNotExist) {
problem = err
}
var buf bytes.Buffer
if problem != nil {
fmt.Fprintf(&buf, "%d \"Sultan Accountant Vorlage\" - CRIT - %s fehlerhaft, eingebaute Vorlage aktiv: %s\n",
cmkCrit, CheckmkTemplateName, clean(problem.Error()))
}
if err := builtinCheckmk.Execute(&buf, d); err != nil {
fmt.Fprintf(&buf, "%d \"Sultan Accountant Vorlage\" - CRIT - eingebaute Vorlage: %s\n", cmkCrit, clean(err.Error()))
}
return buf.String()
}
// DumpCheckmk schreibt die eingebaute Vorlage nach <dataDir>/checkmk zum
// Anpassen; eine vorhandene Datei bleibt ohne force unangetastet.
func DumpCheckmk(dataDir string, force bool) (string, error) {
dir := filepath.Join(dataDir, "checkmk")
path := filepath.Join(dir, CheckmkTemplateName)
if _, err := os.Stat(path); err == nil && !force {
return "", fmt.Errorf("%s existiert bereits (überschreiben mit --force): %w", path, os.ErrExist)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return path, os.WriteFile(path, []byte(defaultCheckmkTemplate), 0o644)
}
func (a *Accountant) handleCheckmk(w http.ResponseWriter, r *http.Request) {
payees, err := a.ledger.ListPayees(r.Context())
if err != nil {
a.log.Error("Checkmk: Payees lesen", "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
reports := map[string]Report{}
for _, p := range payees {
body, _, err := a.ledger.LatestReport(r.Context(), p.ID)
if err != nil {
continue // noch kein Bericht
}
var rep Report
if err := json.Unmarshal(body, &rep); err != nil {
a.log.Error("Checkmk: Bericht unlesbar", "payee", p.ID, "err", err)
continue
}
reports[p.ID] = rep
}
d := buildCheckmk(payees, reports, time.Now(), a.cfg.StaleWarn, a.cfg.StaleCrit)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(renderCheckmk(a.cfg.DataDir, d)))
}