Accountant: Inventar, where, Checkmk, Abrechnung
This commit is contained in:
1 parent
9f16148cde
commit
dd93277750
59 files changed
+7522
-113
No files matched your search
@@ -0,0 +1,271 @@
|
||||
package accountant
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/ledger"
|
||||
)
|
||||
|
||||
var cmkNow = time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func cmkReport(routes ...ReportRoute) Report {
|
||||
return Report{
|
||||
GeneratedAt: cmkNow,
|
||||
Nodes: []ReportNode{
|
||||
{ID: "webdocker11", Tenant: "kunde", Connected: true},
|
||||
{ID: "alt", Tenant: "kunde", Connected: false},
|
||||
{ID: "wartung", Tenant: "kunde", Connected: false, Maintenance: true},
|
||||
},
|
||||
Routes: routes,
|
||||
}
|
||||
}
|
||||
|
||||
func okRoute(host string) ReportRoute {
|
||||
return ReportRoute{Tenant: "kunde", Node: "webdocker11", Workload: "wp", Router: "wp", Protocol: "http",
|
||||
Hosts: []string{host}, Backend: "http://10.0.0.11:8081", BackendFQDN: "webdocker11.lan:8081",
|
||||
Active: true, ComposeDir: "/srv/kunde-a", Health: "healthy", State: "running"}
|
||||
}
|
||||
|
||||
func findURL(t *testing.T, d CheckmkData, service string) CheckmkURL {
|
||||
t.Helper()
|
||||
for _, u := range d.URLs {
|
||||
if u.Service == service {
|
||||
return u
|
||||
}
|
||||
}
|
||||
t.Fatalf("Service %q fehlt in %+v", service, d.URLs)
|
||||
return CheckmkURL{}
|
||||
}
|
||||
|
||||
// Zustände: OK, WARN bei Problemen der Route, CRIT bei veraltetem Bericht;
|
||||
// Wartung dämpft auf OK.
|
||||
func TestBuildCheckmk_States(t *testing.T) {
|
||||
inactive := okRoute("fremd.de")
|
||||
inactive.Active = false
|
||||
down := okRoute("olat.kunde.de")
|
||||
down.Down = true
|
||||
sick := okRoute("krank.kunde.de")
|
||||
sick.Health = "unhealthy"
|
||||
gone := okRoute("weg.kunde.de")
|
||||
gone.Node = "alt"
|
||||
maint := okRoute("pause.kunde.de")
|
||||
maint.Node, maint.Down = "wartung", true
|
||||
multi := okRoute("kunde.de")
|
||||
multi.Hosts = []string{"kunde.de", "www.kunde.de"}
|
||||
|
||||
payees := []ledger.Payee{
|
||||
{ID: "frisch", Hostname: "webproxy11", LastReport: cmkNow.Add(-time.Minute)},
|
||||
{ID: "lahm", Hostname: "webproxy12", LastReport: cmkNow.Add(-3 * time.Hour)},
|
||||
{ID: "tot", Hostname: "webproxy13", LastReport: cmkNow.Add(-7 * time.Hour)},
|
||||
{ID: "neu", Hostname: "webproxy14"},
|
||||
}
|
||||
reports := map[string]Report{
|
||||
"frisch": cmkReport(okRoute("ok.kunde.de"), inactive, down, sick, gone, maint, multi),
|
||||
"lahm": cmkReport(okRoute("lahm.kunde.de")),
|
||||
"tot": cmkReport(okRoute("tot.kunde.de")),
|
||||
}
|
||||
d := buildCheckmk(payees, reports, cmkNow, 2*time.Hour, 6*time.Hour)
|
||||
|
||||
wantPayees := map[string]int{"frisch": 0, "lahm": 1, "tot": 2, "neu": 1}
|
||||
for _, p := range d.Payees {
|
||||
if p.State != wantPayees[p.ID] {
|
||||
t.Errorf("Payee %s: Status %d, erwartet %d (%+v)", p.ID, p.State, wantPayees[p.ID], p)
|
||||
}
|
||||
}
|
||||
if len(d.Payees) != 4 {
|
||||
t.Errorf("Payees: %+v", d.Payees)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
service, problem string
|
||||
state int
|
||||
}{
|
||||
{"Sultan URL ok.kunde.de", "", 0},
|
||||
{"Sultan URL fremd.de", "von Policy verworfen", 1},
|
||||
{"Sultan URL olat.kunde.de", "gestoppt", 1},
|
||||
{"Sultan URL krank.kunde.de", "unhealthy", 1},
|
||||
{"Sultan URL weg.kunde.de", "Knoten getrennt", 1},
|
||||
{"Sultan URL pause.kunde.de", "Wartung", 0},
|
||||
{"Sultan URL kunde.de", "", 0},
|
||||
{"Sultan URL www.kunde.de", "", 0},
|
||||
{"Sultan URL lahm.kunde.de", "Bericht", 1},
|
||||
{"Sultan URL tot.kunde.de", "Bericht", 2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
u := findURL(t, d, tt.service)
|
||||
if u.State != tt.state || (tt.problem == "") != (u.Problem == "") || !strings.Contains(u.Problem, tt.problem) {
|
||||
t.Errorf("%s: Status %d Problem %q, erwartet %d %q", tt.service, u.State, u.Problem, tt.state, tt.problem)
|
||||
}
|
||||
}
|
||||
u := findURL(t, d, "Sultan URL ok.kunde.de")
|
||||
if u.URL != "https://ok.kunde.de" || u.BackendURL != "http://webdocker11.lan:8081" || u.Payee != "frisch" ||
|
||||
u.Tenant != "kunde" || u.ComposeDir != "/srv/kunde-a" {
|
||||
t.Errorf("Felder: %+v", u)
|
||||
}
|
||||
for i := 1; i < len(d.URLs); i++ {
|
||||
if d.URLs[i-1].Service > d.URLs[i].Service {
|
||||
t.Errorf("nicht sortiert: %s vor %s", d.URLs[i-1].Service, d.URLs[i].Service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Derselbe Host zweimal (z. B. zwei Payees): eindeutige Servicenamen.
|
||||
func TestBuildCheckmk_DuplicateHosts(t *testing.T) {
|
||||
payees := []ledger.Payee{{ID: "a", LastReport: cmkNow}, {ID: "b", LastReport: cmkNow}}
|
||||
reports := map[string]Report{"a": cmkReport(okRoute("kunde.de")), "b": cmkReport(okRoute("kunde.de"))}
|
||||
d := buildCheckmk(payees, reports, cmkNow, 2*time.Hour, 6*time.Hour)
|
||||
if len(d.URLs) != 2 || d.URLs[0].Service == d.URLs[1].Service {
|
||||
t.Errorf("Servicenamen nicht eindeutig: %+v", d.URLs)
|
||||
}
|
||||
}
|
||||
|
||||
// Werte vom Kunden-Host sind Fremdeingaben: einzeilig, ASCII, ohne | und ",
|
||||
// HTML-escaped; Unescaped nur bereinigt.
|
||||
func TestBuildCheckmk_Sanitizes(t *testing.T) {
|
||||
evil := okRoute("kunde.de")
|
||||
evil.ComposeDir = "/srv/<script>alert(1)</script>\n2 \"Fake\" - OK |x"
|
||||
evil.Tenant = "küche"
|
||||
d := buildCheckmk([]ledger.Payee{{ID: "a", Hostname: "<b>h</b>", LastReport: cmkNow}},
|
||||
map[string]Report{"a": cmkReport(evil)}, cmkNow, 2*time.Hour, 6*time.Hour)
|
||||
u := d.URLs[0]
|
||||
for _, s := range []string{u.ComposeDir, u.Unescaped.ComposeDir, d.Payees[0].Hostname} {
|
||||
if strings.ContainsAny(s, "\n\r|\"") {
|
||||
t.Errorf("nicht bereinigt: %q", s)
|
||||
}
|
||||
}
|
||||
if strings.Contains(u.ComposeDir, "<script>") || !strings.Contains(u.ComposeDir, "<script>") {
|
||||
t.Errorf("nicht escaped: %q", u.ComposeDir)
|
||||
}
|
||||
if !strings.Contains(u.Unescaped.ComposeDir, "<script>") {
|
||||
t.Errorf("Unescaped verändert: %q", u.Unescaped.ComposeDir)
|
||||
}
|
||||
if u.Tenant != "kueche" {
|
||||
t.Errorf("Umlaut: %q", u.Tenant)
|
||||
}
|
||||
}
|
||||
|
||||
var cmkLine = regexp.MustCompile(`^[0-2] "[^"|]+" - (OK|WARN|CRIT) - [ -~]*$`)
|
||||
|
||||
// Die eingebaute Vorlage liefert gültige Local-Check-Zeilen mit Links.
|
||||
func TestRenderCheckmk_Default(t *testing.T) {
|
||||
down := okRoute("olat.kunde.de")
|
||||
down.Down = true
|
||||
d := buildCheckmk([]ledger.Payee{{ID: "webproxy11", Hostname: "webproxy11.lan", LastReport: cmkNow.Add(-5 * time.Minute)}},
|
||||
map[string]Report{"webproxy11": cmkReport(okRoute("kunde.de"), down)}, cmkNow, 2*time.Hour, 6*time.Hour)
|
||||
out := renderCheckmk(t.TempDir(), d)
|
||||
lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("%d Zeilen:\n%s", len(lines), out)
|
||||
}
|
||||
for _, l := range lines {
|
||||
if !cmkLine.MatchString(l) {
|
||||
t.Errorf("ungültige Zeile: %q", l)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
`0 "Sultan Payee webproxy11" - OK - `,
|
||||
`0 "Sultan URL kunde.de" - OK - `,
|
||||
"https://kunde.de", "http://webdocker11.lan:8081", "/srv/kunde-a", "kunde", "webdocker11",
|
||||
`1 "Sultan URL olat.kunde.de" - WARN - gestoppt`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("fehlt %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Eigene Vorlage in <data-dir>/checkmk hat Vorrang und wirkt sofort; eine
|
||||
// defekte fällt auf die eingebaute zurück und meldet sich als CRIT.
|
||||
func TestRenderCheckmk_CustomTemplate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
d := buildCheckmk([]ledger.Payee{{ID: "p", LastReport: cmkNow}}, map[string]Report{"p": cmkReport(okRoute("kunde.de"))},
|
||||
cmkNow, 2*time.Hour, 6*time.Hour)
|
||||
os.MkdirAll(filepath.Join(dir, "checkmk"), 0o755)
|
||||
tmpl := filepath.Join(dir, "checkmk", CheckmkTemplateName)
|
||||
|
||||
os.WriteFile(tmpl, []byte(`{{range .URLs}}{{.State}} "{{.Service}}" - {{status .State}} - eigen {{.Unescaped.Host}}{{"\n"}}{{end}}`), 0o644)
|
||||
if out := renderCheckmk(dir, d); out != "0 \"Sultan URL kunde.de\" - OK - eigen kunde.de\n" {
|
||||
t.Errorf("eigene Vorlage: %q", out)
|
||||
}
|
||||
|
||||
os.WriteFile(tmpl, []byte(`{{range .URLs}`), 0o644)
|
||||
out := renderCheckmk(dir, d)
|
||||
if !strings.Contains(out, `2 "Sultan Accountant Vorlage" - CRIT - `) || !strings.Contains(out, `"Sultan URL kunde.de"`) {
|
||||
t.Errorf("defekte Vorlage: %q", out)
|
||||
}
|
||||
os.WriteFile(tmpl, []byte(`{{.GibtsNicht}}`), 0o644)
|
||||
if out := renderCheckmk(dir, d); !strings.Contains(out, `2 "Sultan Accountant Vorlage"`) {
|
||||
t.Errorf("Ausführungsfehler: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDumpCheckmk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path, err := DumpCheckmk(dir, false)
|
||||
if err != nil || path != filepath.Join(dir, "checkmk", CheckmkTemplateName) {
|
||||
t.Fatalf("DumpCheckmk = %q, %v", path, err)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
if string(data) != defaultCheckmkTemplate {
|
||||
t.Error("Inhalt weicht von der eingebauten Vorlage ab")
|
||||
}
|
||||
if _, err := DumpCheckmk(dir, false); err == nil {
|
||||
t.Error("vorhandene Datei überschrieben")
|
||||
}
|
||||
if _, err := DumpCheckmk(dir, true); err != nil {
|
||||
t.Errorf("--force: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ende zu Ende: Bericht eines Payees erscheint auf dem Loopback-Port.
|
||||
func TestCheckmkEndpoint(t *testing.T) {
|
||||
a, err := New(ctx, Config{DataDir: filepath.Join(t.TempDir(), "accdata"), Addr: "127.0.0.1:0", MetricsAddr: "127.0.0.1:0",
|
||||
Logger: slog.New(slog.DiscardHandler)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { a.Shutdown(ctx) })
|
||||
key := enrolledPayee(t, a, "webproxy11")
|
||||
rep := cmkReport(okRoute("kunde.de"))
|
||||
rep.GeneratedAt = time.Now()
|
||||
body, _ := json.Marshal(rep)
|
||||
postReport(t, a, key, body)
|
||||
|
||||
resp, err := http.Get("http://" + a.MetricsAddr() + "/metrics/checkmk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") ||
|
||||
!strings.Contains(string(out), `0 "Sultan URL kunde.de"`) || !strings.Contains(string(out), `0 "Sultan Payee webproxy11"`) {
|
||||
t.Errorf("Status %d, %q:\n%s", resp.StatusCode, resp.Header.Get("Content-Type"), out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckmkEndpoint_Disabled(t *testing.T) {
|
||||
a, err := New(ctx, Config{DataDir: filepath.Join(t.TempDir(), "accdata"), Addr: "127.0.0.1:0", MetricsAddr: "none",
|
||||
Logger: slog.New(slog.DiscardHandler)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Shutdown(ctx)
|
||||
if a.MetricsAddr() != "" {
|
||||
t.Errorf("MetricsAddr = %q, erwartet leer", a.MetricsAddr())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user