Accountant: Inventar, where, Checkmk, Abrechnung

This commit is contained in:
br committed 2026-10-18 17:08:58 +02:00
1 parent 9f16148cde
commit dd93277750
59 files changed
+7522 -113

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
package accountant
// Abfragen auf dem Accountant (Spec 5.3, Roadmap 6d Schritt 1): where – wo
// läuft eine URL, seit wann, wo vorher? – und routes – alle aktuellen
// Routen. Aktuell = letzter Bericht je Payee; seit wann/vorher = Historie.
import (
"cmp"
"context"
"encoding/json"
"slices"
"strings"
"time"
"sultan/pkg/ledger"
)
// RouteRecords übersetzt die Routen eines Berichts für die Historie.
func RouteRecords(rep Report) []ledger.RouteRecord {
out := make([]ledger.RouteRecord, 0, len(rep.Routes))
for _, r := range rep.Routes {
out = append(out, ledger.RouteRecord{
Tenant: r.Tenant, Node: r.Node, Protocol: r.Protocol, Router: r.Router, Hosts: r.Hosts,
Backend: r.Backend, BackendFQDN: r.BackendFQDN, Workload: r.Workload, ComposeDir: r.ComposeDir, Product: r.Product,
})
}
return out
}
// CurrentRoute ist eine Route aus dem letzten Bericht eines Payees.
type CurrentRoute struct {
Payee string
ReportAt time.Time // Eingang des Berichts
Route ReportRoute
Node ReportNode // Knoten der Route (leer, falls nicht im Bericht)
TenantName string
// Customer: zugeordneter Kunde (nil = keinem Kunden zugeordnet).
Customer *ledger.Customer
Since time.Time // Beginn des Zeitraums an diesem Ort (Historie)
// Previous: frühere, beendete Orte derselben Route (Mandant, Protokoll,
// Router), neueste zuerst.
Previous []ledger.RouteSpan
}
// WhereResult ist das Ergebnis von Where.
type WhereResult struct {
Current []CurrentRoute
// Gone: passende Routen, die es nirgends mehr gibt (neueste zuerst).
Gone []ledger.RouteSpan
}
// RoutesFilter schränkt Routes ein; leere Felder filtern nicht.
type RoutesFilter struct {
Tenant string
Payee string
}
func spanKey(tenant, node, protocol, router string) string {
return tenant + "\x00" + node + "\x00" + protocol + "\x00" + router
}
func routeKey(tenant, protocol, router string) string {
return tenant + "\x00" + protocol + "\x00" + router
}
// currentRoutes liest die letzten Berichte aller Payees und ergänzt den
// Beginn je Ort aus der Historie.
func currentRoutes(ctx context.Context, l *ledger.Ledger, keep func(ReportRoute) bool) ([]CurrentRoute, error) {
payees, err := l.ListPayees(ctx)
if err != nil {
return nil, err
}
open, err := l.RouteHistory(ctx, ledger.HistoryFilter{OpenOnly: true})
if err != nil {
return nil, err
}
resolver, err := l.CustomerResolver(ctx)
if err != nil {
return nil, err
}
customers := map[string]*ledger.Customer{}
cs, err := l.ListCustomers(ctx)
if err != nil {
return nil, err
}
for i := range cs {
customers[cs[i].ID] = &cs[i]
}
since := map[string]time.Time{}
for _, s := range open {
since[s.Payee+"\x00"+spanKey(s.Tenant, s.Node, s.Protocol, s.Router)] = s.FirstSeen
}
var out []CurrentRoute
for _, p := range payees {
body, at, err := l.LatestReport(ctx, p.ID)
if err != nil {
continue // noch kein Bericht
}
var rep Report
if err := json.Unmarshal(body, &rep); err != nil {
return nil, err
}
nodes := map[string]ReportNode{}
for _, n := range rep.Nodes {
nodes[n.ID] = n
}
names := map[string]string{}
for _, t := range rep.Tenants {
names[t.ID] = t.Name
}
for _, r := range rep.Routes {
if !keep(r) {
continue
}
out = append(out, CurrentRoute{
Payee: p.ID, ReportAt: at, Route: r, Node: nodes[r.Node], TenantName: names[r.Tenant],
Customer: customers[resolver.Resolve(p.ID, r.Tenant)],
Since: since[p.ID+"\x00"+spanKey(r.Tenant, r.Node, r.Protocol, r.Router)],
})
}
}
slices.SortFunc(out, func(a, b CurrentRoute) int {
return cmp.Or(strings.Compare(firstHost(a.Route.Hosts), firstHost(b.Route.Hosts)),
strings.Compare(a.Payee, b.Payee), strings.Compare(a.Route.Node, b.Route.Node), strings.Compare(a.Route.Router, b.Route.Router))
})
return out, nil
}
func firstHost(hosts []string) string {
if len(hosts) == 0 {
return ""
}
return hosts[0]
}
// matches: ein Host enthält query, oder query ist die Mandanten-ID.
func matches(query, tenant string, hosts []string) bool {
if strings.EqualFold(tenant, query) {
return true
}
for _, h := range hosts {
if strings.Contains(strings.ToLower(h), query) {
return true
}
}
return false
}
// Where sucht Routen, deren Host query enthält (ohne Groß-/Kleinschreibung)
// oder deren Mandant query heißt – aktuell mit früheren Orten, dazu
// passende Routen, die es nicht mehr gibt.
func Where(ctx context.Context, l *ledger.Ledger, query string) (WhereResult, error) {
query = strings.ToLower(strings.TrimSpace(query))
var res WhereResult
if query == "" {
return res, nil
}
cur, err := currentRoutes(ctx, l, func(r ReportRoute) bool { return matches(query, r.Tenant, r.Hosts) })
if err != nil {
return res, err
}
all, err := l.RouteHistory(ctx, ledger.HistoryFilter{})
if err != nil {
return res, err
}
ended := map[string][]ledger.RouteSpan{}
for _, s := range all {
if !s.Ended.IsZero() {
k := routeKey(s.Tenant, s.Protocol, s.Router)
ended[k] = append(ended[k], s)
}
}
newestFirst := func(a, b ledger.RouteSpan) int { return b.Ended.Compare(a.Ended) }
alive := map[string]bool{}
for i := range cur {
r := cur[i].Route
k := routeKey(r.Tenant, r.Protocol, r.Router)
alive[k] = true
for _, s := range ended[k] {
if s.Payee == cur[i].Payee && s.Node == r.Node {
continue // derselbe Ort früher einmal – kein Umzug
}
cur[i].Previous = append(cur[i].Previous, s)
}
slices.SortFunc(cur[i].Previous, newestFirst)
}
res.Current = cur
for k, spans := range ended {
if alive[k] {
continue
}
for _, s := range spans {
if matches(query, s.Tenant, s.Hosts) {
res.Gone = append(res.Gone, s)
}
}
}
slices.SortFunc(res.Gone, newestFirst)
return res, nil
}
// Routes liefert alle aktuellen Routen, sortiert nach Host.
func Routes(ctx context.Context, l *ledger.Ledger, f RoutesFilter) ([]CurrentRoute, error) {
cur, err := currentRoutes(ctx, l, func(r ReportRoute) bool { return f.Tenant == "" || r.Tenant == f.Tenant })
if err != nil || f.Payee == "" {
return cur, err
}
return slices.DeleteFunc(cur, func(c CurrentRoute) bool { return c.Payee != f.Payee }), nil
}
// ItemKey bezeichnet einen Posten: Mandant, Protokoll, Router (über alle
// Payees und Knoten – ein Umzug bleibt derselbe Posten).
type ItemKey struct {
Tenant, Protocol, Router string
}
// ItemsForHost liefert die Posten, zu denen host gehört (exakter Host, aus
// der Historie; ohne Groß-/Kleinschreibung), sortiert.
func ItemsForHost(ctx context.Context, l *ledger.Ledger, host string) ([]ItemKey, error) {
host = strings.ToLower(strings.TrimSpace(host))
spans, err := l.RouteHistory(ctx, ledger.HistoryFilter{Host: host})
if err != nil {
return nil, err
}
var out []ItemKey
for _, s := range spans {
k := ItemKey{s.Tenant, s.Protocol, s.Router}
if slices.Contains(s.Hosts, host) && !slices.Contains(out, k) {
out = append(out, k)
}
}
slices.SortFunc(out, func(a, b ItemKey) int {
return cmp.Or(strings.Compare(a.Tenant, b.Tenant), strings.Compare(a.Router, b.Router), strings.Compare(a.Protocol, b.Protocol))
})
return out, nil
}