Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
262 lines
7.7 KiB
Go
262 lines
7.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// contractGoldenFile ist die eingefrorene Endpunktliste.
|
|
const contractGoldenFile = "contract_routes.txt"
|
|
|
|
// TestAPIContractIsFrozen haelt den API-Vertrag fest (Phase 22).
|
|
//
|
|
// Der Vertrag ist eingefroren: `/api/v1` ist ausgeliefert, und jede Aenderung
|
|
// daran bricht bestehende Aufrufer. Ein Endpunkt, der still verschwindet oder
|
|
// eine andere Berechtigung bekommt, faellt niemandem auf, bis ein Kunde anruft
|
|
// — genau deshalb steht er hier in einer Datei, die man **absichtlich** aendern
|
|
// muss.
|
|
//
|
|
// Geprueft wird der Quelltext des Routers, nicht der gebaute Multiplexer:
|
|
// http.ServeMux gibt seine Routen nicht heraus, und ein Test, der nur die
|
|
// bekannten Routen anfragt, bemerkt eine **hinzugefuegte** nicht. Eine neue
|
|
// Route ist aber genau die haeufigste Vertragsaenderung.
|
|
//
|
|
// Bricht dieser Test, ist das keine Panne, sondern die Frage: Ist die Aenderung
|
|
// abwaertskompatibel? Wenn nein, gehoert sie hinter `/api/v2`.
|
|
func TestAPIContractIsFrozen(testInstance *testing.T) {
|
|
registeredRoutes := extractRegisteredRoutes(testInstance, "router.go")
|
|
|
|
goldenContent, readError := os.ReadFile(contractGoldenFile)
|
|
if readError != nil {
|
|
testInstance.Fatalf("die eingefrorene Endpunktliste ließ sich nicht lesen: %v", readError)
|
|
}
|
|
|
|
expectedRoutes := parseGoldenRoutes(string(goldenContent))
|
|
|
|
reportRouteDifferences(testInstance, expectedRoutes, registeredRoutes)
|
|
}
|
|
|
|
// reportRouteDifferences nennt jede Abweichung einzeln.
|
|
//
|
|
// Eine reine "stimmt nicht ueberein"-Meldung zwaenge zum Vergleichen von Hand;
|
|
// bei knapp hundert Endpunkten sucht das niemand freiwillig.
|
|
func reportRouteDifferences(testInstance *testing.T, expectedRoutes []string, actualRoutes []string) {
|
|
testInstance.Helper()
|
|
|
|
expectedSet := make(map[string]bool, len(expectedRoutes))
|
|
for _, singleRoute := range expectedRoutes {
|
|
expectedSet[singleRoute] = true
|
|
}
|
|
|
|
actualSet := make(map[string]bool, len(actualRoutes))
|
|
for _, singleRoute := range actualRoutes {
|
|
actualSet[singleRoute] = true
|
|
}
|
|
|
|
for _, singleRoute := range actualRoutes {
|
|
if !expectedSet[singleRoute] {
|
|
testInstance.Errorf("neuer oder geänderter Endpunkt: %s\n"+
|
|
" Ist er abwärtskompatibel? Dann in %s eintragen. Wenn nicht, gehört er hinter /api/v2.",
|
|
singleRoute, contractGoldenFile)
|
|
}
|
|
}
|
|
|
|
for _, singleRoute := range expectedRoutes {
|
|
if !actualSet[singleRoute] {
|
|
testInstance.Errorf("entfallener Endpunkt: %s\n"+
|
|
" Ein ausgelieferter Endpunkt darf nicht verschwinden — bestehende Aufrufer brechen.",
|
|
singleRoute)
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractRegisteredRoutes liest die Routen aus dem Quelltext des Routers.
|
|
//
|
|
// Jede Zeile hat die Form "<METHODE> <pfad> <berechtigung>". Die Berechtigung
|
|
// gehoert dazu, weil eine stillschweigend gelockerte Pruefung die gefaehrlichste
|
|
// Vertragsaenderung ueberhaupt waere: Der Endpunkt funktioniert weiter, nur
|
|
// duerfen ihn ploetzlich mehr Leute aufrufen.
|
|
func extractRegisteredRoutes(testInstance *testing.T, sourceFileName string) []string {
|
|
testInstance.Helper()
|
|
|
|
fileSet := token.NewFileSet()
|
|
|
|
parsedFile, parseError := parser.ParseFile(fileSet, sourceFileName, nil, parser.SkipObjectResolution)
|
|
if parseError != nil {
|
|
testInstance.Fatalf("der Router ließ sich nicht auswerten: %v", parseError)
|
|
}
|
|
|
|
collectedRoutes := make([]string, 0, 128)
|
|
|
|
ast.Inspect(parsedFile, func(currentNode ast.Node) bool {
|
|
callExpression, isCall := currentNode.(*ast.CallExpr)
|
|
if !isCall {
|
|
return true
|
|
}
|
|
|
|
methodName, isMultiplexerCall := multiplexerMethodName(callExpression)
|
|
if !isMultiplexerCall || len(callExpression.Args) == 0 {
|
|
return true
|
|
}
|
|
|
|
routePattern, patternResolved := resolveRoutePattern(callExpression.Args[0])
|
|
if !patternResolved {
|
|
return true
|
|
}
|
|
|
|
// Die Auffangroute "/" ist kein fachlicher Endpunkt, sondern die
|
|
// Fehlerhülle für alles Unbekannte.
|
|
if routePattern == "/" {
|
|
return true
|
|
}
|
|
|
|
requiredPermission := "-"
|
|
|
|
if methodName == "Handle" && len(callExpression.Args) > 1 {
|
|
requiredPermission = resolveRequiredPermission(callExpression.Args[1])
|
|
}
|
|
|
|
collectedRoutes = append(collectedRoutes, routePattern+" "+requiredPermission)
|
|
|
|
return true
|
|
})
|
|
|
|
sort.Strings(collectedRoutes)
|
|
|
|
return collectedRoutes
|
|
}
|
|
|
|
// multiplexerMethodName erkennt einen Aufruf auf dem Router.
|
|
func multiplexerMethodName(callExpression *ast.CallExpr) (string, bool) {
|
|
selectorExpression, isSelector := callExpression.Fun.(*ast.SelectorExpr)
|
|
if !isSelector {
|
|
return "", false
|
|
}
|
|
|
|
receiverIdentifier, isIdentifier := selectorExpression.X.(*ast.Ident)
|
|
if !isIdentifier || receiverIdentifier.Name != "requestMultiplexer" {
|
|
return "", false
|
|
}
|
|
|
|
if selectorExpression.Sel.Name != "Handle" && selectorExpression.Sel.Name != "HandleFunc" {
|
|
return "", false
|
|
}
|
|
|
|
return selectorExpression.Sel.Name, true
|
|
}
|
|
|
|
// resolveRoutePattern setzt das Routenmuster aus dem Ausdruck zusammen.
|
|
//
|
|
// Die Routen stehen als `"GET " + apiBasePath + "/agents"` im Quelltext; der
|
|
// Basispfad wird dabei aufgeloest, damit die eingefrorene Liste die
|
|
// tatsaechlichen Adressen enthaelt und nicht eine Konstante.
|
|
func resolveRoutePattern(patternExpression ast.Expr) (string, bool) {
|
|
switch typedExpression := patternExpression.(type) {
|
|
case *ast.BasicLit:
|
|
if typedExpression.Kind != token.STRING {
|
|
return "", false
|
|
}
|
|
|
|
unquotedValue, unquoteError := strconv.Unquote(typedExpression.Value)
|
|
if unquoteError != nil {
|
|
return "", false
|
|
}
|
|
|
|
return unquotedValue, true
|
|
|
|
case *ast.Ident:
|
|
if typedExpression.Name == "apiBasePath" {
|
|
return apiBasePath, true
|
|
}
|
|
|
|
return "", false
|
|
|
|
case *ast.BinaryExpr:
|
|
if typedExpression.Op != token.ADD {
|
|
return "", false
|
|
}
|
|
|
|
leftValue, leftResolved := resolveRoutePattern(typedExpression.X)
|
|
rightValue, rightResolved := resolveRoutePattern(typedExpression.Y)
|
|
|
|
if !leftResolved || !rightResolved {
|
|
return "", false
|
|
}
|
|
|
|
return leftValue + rightValue, true
|
|
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// resolveRequiredPermission liest die geforderte Berechtigung aus dem Aufruf.
|
|
//
|
|
// Der übliche Fall ist `protected("agents.read", handler)`. Alles andere —
|
|
// etwa die Middleware der Agentenanmeldung — wird als solches benannt statt
|
|
// stillschweigend als "keine Berechtigung" geführt.
|
|
func resolveRequiredPermission(handlerExpression ast.Expr) string {
|
|
callExpression, isCall := handlerExpression.(*ast.CallExpr)
|
|
if !isCall || len(callExpression.Args) == 0 {
|
|
return "?"
|
|
}
|
|
|
|
functionIdentifier, isIdentifier := callExpression.Fun.(*ast.Ident)
|
|
if !isIdentifier {
|
|
return "?"
|
|
}
|
|
|
|
if functionIdentifier.Name == "agentAuthenticated" {
|
|
return "agent-token"
|
|
}
|
|
|
|
// Angemeldet, aber ohne besondere Berechtigung: die Endpunkte, die jeder
|
|
// über sich selbst aufruft. Sie als "?" zu führen wäre die schlechteste
|
|
// Auskunft — eine Unklarheit in einem eingefrorenen Vertrag verdeckt genau
|
|
// die Änderung, die er aufdecken soll.
|
|
if functionIdentifier.Name == "authenticated" {
|
|
return "sitzung"
|
|
}
|
|
|
|
if functionIdentifier.Name != "protected" {
|
|
return "?"
|
|
}
|
|
|
|
permissionLiteral, isLiteral := callExpression.Args[0].(*ast.BasicLit)
|
|
if !isLiteral || permissionLiteral.Kind != token.STRING {
|
|
return "?"
|
|
}
|
|
|
|
unquotedPermission, unquoteError := strconv.Unquote(permissionLiteral.Value)
|
|
if unquoteError != nil {
|
|
return "?"
|
|
}
|
|
|
|
return unquotedPermission
|
|
}
|
|
|
|
// parseGoldenRoutes liest die eingefrorene Liste.
|
|
func parseGoldenRoutes(fileContent string) []string {
|
|
parsedRoutes := make([]string, 0, 128)
|
|
|
|
for _, currentLine := range strings.Split(fileContent, "\n") {
|
|
trimmedLine := strings.TrimSpace(currentLine)
|
|
|
|
if trimmedLine == "" || strings.HasPrefix(trimmedLine, "#") {
|
|
continue
|
|
}
|
|
|
|
parsedRoutes = append(parsedRoutes, trimmedLine)
|
|
}
|
|
|
|
sort.Strings(parsedRoutes)
|
|
|
|
return parsedRoutes
|
|
}
|