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

+137
View File
@@ -0,0 +1,137 @@
package accountant
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"sultan/pkg/auth"
"sultan/pkg/ledger"
"sultan/pkg/store"
)
// PathReport ist der Endpunkt für Berichte der Payees.
const PathReport = "/api/v1/report"
// maxReportSize begrenzt einen Bericht (signierter Body). Ein Eintrag je
// Route ist einige hundert Bytes groß; 8 MiB reichen für zehntausende Routen.
const maxReportSize = 8 << 20
// Report ist der vollständige Schnappschuss eines Payees (Spec 5.3) – kein
// Delta: Was fehlt, gibt es beim Payee nicht mehr.
type Report struct {
Version string `json:"version"` // sultan-Version des Payees
GeneratedAt time.Time `json:"generated_at"`
Tenants []ReportTenant `json:"tenants"`
Nodes []ReportNode `json:"nodes"`
Routes []ReportRoute `json:"routes"`
}
// ReportTenant ist ein Mandant des Payees.
type ReportTenant struct {
ID string `json:"id"`
Name string `json:"name"`
Tier string `json:"tier"`
Status string `json:"status"`
}
// ReportNode ist ein Knoten (Backend-Host) des Payees.
type ReportNode struct {
ID string `json:"id"`
Tenant string `json:"tenant"`
Hostname string `json:"hostname"`
FQDN string `json:"fqdn,omitempty"`
IP string `json:"ip"` // Absender-IP beim Server
Status string `json:"status"`
Connected bool `json:"connected"`
Maintenance bool `json:"maintenance,omitempty"`
LastSeen time.Time `json:"last_seen,omitzero"`
OS string `json:"os,omitempty"`
DockerVersion string `json:"docker_version,omitempty"`
NetBirdIP string `json:"netbird_ip,omitempty"`
}
// ReportRoute ist ein gemeldeter Router mit seinem Ziel.
type ReportRoute struct {
Tenant string `json:"tenant"`
Node string `json:"node"`
Workload string `json:"workload"` // Container bzw. sultan.d/<name>
Router string `json:"router"` // Name aus den Labels
Protocol string `json:"protocol"` // http oder tcp
Hosts []string `json:"hosts"`
// Backend wie Traefik es erreicht: http://<ip>:<port> bzw. <ip>:<port> (tcp).
Backend string `json:"backend"`
// BackendFQDN: <fqdn>:<port> – zum Springen auf den Host im Störfall.
BackendFQDN string `json:"backend_fqdn,omitempty"`
// Active: Router steht in der Traefik-Konfiguration (nicht von der Policy
// verworfen, z. B. fremde Domain oder Routenlimit).
Active bool `json:"active"`
// Down: Service gestoppt bzw. Healthcheck fehlgeschlagen (Wartungsseite).
Down bool `json:"down,omitempty"`
Product string `json:"product,omitempty"` // Label sultan.accountant.billing
ComposeProject string `json:"compose_project,omitempty"`
ComposeService string `json:"compose_service,omitempty"`
ComposeDir string `json:"compose_dir,omitempty"`
State string `json:"state,omitempty"`
StartedAt time.Time `json:"started_at,omitzero"`
Health string `json:"health,omitempty"`
}
// LabelBilling ordnet einer Route ein Produkt zu (Spec 5.3).
const LabelBilling = "sultan.accountant.billing"
func (a *Accountant) handleReport(w http.ResponseWriter, r *http.Request) {
pub, err := a.reportVerifier.VerifyUnregistered(r)
if err != nil {
code := auth.StatusCode(err)
http.Error(w, http.StatusText(code), code)
return
}
p, err := a.ledger.GetPayeeByPublicKey(r.Context(), auth.EncodePublicKey(pub))
if errors.Is(err, store.ErrNotFound) {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
if err != nil {
a.log.Error("Payee nachschlagen", "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
var rep Report
if err := json.NewDecoder(r.Body).Decode(&rep); err != nil {
http.Error(w, "ungültiger Bericht", http.StatusBadRequest)
return
}
if rep.GeneratedAt.IsZero() {
http.Error(w, "generated_at fehlt", http.StatusBadRequest)
return
}
if err := StoreReport(r.Context(), a.ledger, p.ID, rep); err != nil {
a.log.Error("Bericht speichern", "payee", p.ID, "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
a.log.Debug("Bericht empfangen", "payee", p.ID, "routes", len(rep.Routes), "nodes", len(rep.Nodes))
w.WriteHeader(http.StatusNoContent)
}
// StoreReport legt einen Bericht ab: letzter Stand (normalisiert: nur
// bekannte Felder), Historie und automatisch angelegte Kunden für neue
// Mandanten.
func StoreReport(ctx context.Context, l *ledger.Ledger, payeeID string, rep Report) error {
body, err := json.Marshal(rep)
if err != nil {
return err
}
if err := l.SaveReport(ctx, payeeID, body, RouteRecords(rep)); err != nil {
return err
}
tenants := make([]ledger.TenantRef, 0, len(rep.Tenants))
for _, t := range rep.Tenants {
tenants = append(tenants, ledger.TenantRef{ID: t.ID, Name: t.Name})
}
return l.EnsureCustomers(ctx, payeeID, tenants)
}