330 lines
11 KiB
Go
330 lines
11 KiB
Go
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)))
|
||
}
|