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>
190 lines
6.6 KiB
Go
190 lines
6.6 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestRetryOnlyForTransientFailures ist der wichtigste Wiederholungstest.
|
|
//
|
|
// Ein Anmeldefehler behebt sich nicht durch Warten; ein Integritätsfehler wird
|
|
// durch Wiederholen nicht besser, sondern nur später bemerkt.
|
|
func TestRetryOnlyForTransientFailures(testInstance *testing.T) {
|
|
retryPolicy := DefaultRetryPolicy()
|
|
|
|
retryableClasses := []FailureClass{FailureTransient, FailureNetwork, FailureRepository, FailureSource}
|
|
for _, failureClass := range retryableClasses {
|
|
decision := retryPolicy.Decide(1, failureClass)
|
|
|
|
if !decision.ShouldRetry {
|
|
testInstance.Errorf("ein Fehler der Art %q wurde nicht wiederholt: %s", failureClass, decision.Reason)
|
|
}
|
|
}
|
|
|
|
permanentClasses := []FailureClass{
|
|
FailureAuthentication, FailureIntegrity, FailureConfiguration, FailureSecurity, FailurePermanent,
|
|
}
|
|
for _, failureClass := range permanentClasses {
|
|
decision := retryPolicy.Decide(1, failureClass)
|
|
|
|
if decision.ShouldRetry {
|
|
testInstance.Errorf("ein Fehler der Art %q wurde wiederholt, obwohl er sich nicht behebt", failureClass)
|
|
}
|
|
|
|
// Auch ein „nein" braucht eine Begründung: Der Betreiber muss zwischen
|
|
// „aufgegeben" und „nicht wiederholbar" unterscheiden können.
|
|
if decision.Reason == "" {
|
|
testInstance.Errorf("die Ablehnung bei %q wurde nicht begründet", failureClass)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRetryStopsAfterMaximumAttempts prüft die Obergrenze.
|
|
func TestRetryStopsAfterMaximumAttempts(testInstance *testing.T) {
|
|
retryPolicy := RetryPolicy{MaximumAttempts: 3, InitialDelay: time.Second, MaximumDelay: time.Minute}
|
|
|
|
for attemptNumber := 1; attemptNumber < 3; attemptNumber++ {
|
|
if decision := retryPolicy.Decide(attemptNumber, FailureTransient); !decision.ShouldRetry {
|
|
testInstance.Errorf("nach Versuch %d wurde nicht wiederholt: %s", attemptNumber, decision.Reason)
|
|
}
|
|
}
|
|
|
|
finalDecision := retryPolicy.Decide(3, FailureTransient)
|
|
if finalDecision.ShouldRetry {
|
|
testInstance.Fatal("nach dem letzten zugelassenen Versuch wurde erneut wiederholt")
|
|
}
|
|
}
|
|
|
|
// TestBackoffGrowsExponentiallyAndIsCapped prüft die Wartezeiten.
|
|
//
|
|
// Ohne Deckel wüchse die Wartezeit weiter: Beim achten Versuch wären es aus
|
|
// 30 Sekunden über eine Stunde, beim zehnten mehr als vier — ein nächtliches
|
|
// Sicherungsfenster wäre längst vorbei.
|
|
func TestBackoffGrowsExponentiallyAndIsCapped(testInstance *testing.T) {
|
|
// Ohne Streuung, damit die Werte genau prüfbar sind.
|
|
retryPolicy := RetryPolicy{
|
|
MaximumAttempts: 10,
|
|
InitialDelay: 30 * time.Second,
|
|
MaximumDelay: 15 * time.Minute,
|
|
}
|
|
|
|
expectedDelays := map[int]time.Duration{
|
|
1: 30 * time.Second,
|
|
2: 60 * time.Second,
|
|
3: 120 * time.Second,
|
|
4: 240 * time.Second,
|
|
5: 480 * time.Second,
|
|
// Ab hier greift der Deckel: 960 s wären mehr als 15 Minuten.
|
|
6: 15 * time.Minute,
|
|
9: 15 * time.Minute,
|
|
}
|
|
|
|
for attemptNumber, expectedDelay := range expectedDelays {
|
|
decision := retryPolicy.Decide(attemptNumber, FailureTransient)
|
|
|
|
if decision.Delay != expectedDelay {
|
|
testInstance.Errorf("nach Versuch %d wurde %s gewartet, erwartet waren %s",
|
|
attemptNumber, decision.Delay, expectedDelay)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestJitterOnlyShortensDelay prüft die Streuung.
|
|
//
|
|
// Nach oben zu streuen verlängerte die Wartezeit über die vereinbarte Grenze
|
|
// hinaus — der Deckel wäre dann keiner.
|
|
func TestJitterOnlyShortensDelay(testInstance *testing.T) {
|
|
retryPolicy := RetryPolicy{
|
|
MaximumAttempts: 5,
|
|
InitialDelay: time.Minute,
|
|
MaximumDelay: time.Minute,
|
|
JitterFraction: 0.5,
|
|
}
|
|
|
|
// Mehrere Durchläufe, weil die Streuung zufällig ist.
|
|
for runIndex := 0; runIndex < 200; runIndex++ {
|
|
decision := retryPolicy.Decide(1, FailureTransient)
|
|
|
|
if decision.Delay > time.Minute {
|
|
testInstance.Fatalf("die Streuung verlängerte die Wartezeit auf %s über die Grenze von 1m", decision.Delay)
|
|
}
|
|
|
|
if decision.Delay < 30*time.Second {
|
|
testInstance.Fatalf("die Streuung verkürzte die Wartezeit auf %s, mehr als die zugelassenen 50 %%", decision.Delay)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBackoffSurvivesLargeAttemptNumbers prüft den Überlauf.
|
|
//
|
|
// Mit einer Schiebeoperation liefe die Wartezeit bei hohen Versuchsnummern
|
|
// über und würde negativ — aus einer langen Wartezeit würde gar keine.
|
|
func TestBackoffSurvivesLargeAttemptNumbers(testInstance *testing.T) {
|
|
retryPolicy := RetryPolicy{
|
|
MaximumAttempts: 10,
|
|
InitialDelay: time.Hour,
|
|
MaximumDelay: 2 * time.Hour,
|
|
}
|
|
|
|
decision := retryPolicy.Decide(9, FailureTransient)
|
|
|
|
if decision.Delay <= 0 {
|
|
testInstance.Fatalf("bei Versuch 9 ergab sich eine Wartezeit von %s", decision.Delay)
|
|
}
|
|
|
|
if decision.Delay > 2*time.Hour {
|
|
testInstance.Fatalf("bei Versuch 9 wurde der Deckel überschritten: %s", decision.Delay)
|
|
}
|
|
}
|
|
|
|
// TestRetryPolicyValidation prüft die Eingabeprüfung.
|
|
func TestRetryPolicyValidation(testInstance *testing.T) {
|
|
defaultPolicy := DefaultRetryPolicy()
|
|
if validationError := defaultPolicy.Validate(); validationError != nil {
|
|
testInstance.Fatalf("die Voreinstellung wurde abgelehnt: %v", validationError)
|
|
}
|
|
|
|
brokenPolicies := map[string]RetryPolicy{
|
|
"kein Versuch": {MaximumAttempts: 0},
|
|
"zu viele Versuche": {MaximumAttempts: 50},
|
|
"negative Wartezeit": {MaximumAttempts: 3, InitialDelay: -time.Second},
|
|
"Deckel unter Anfang": {
|
|
MaximumAttempts: 3, InitialDelay: time.Minute, MaximumDelay: time.Second,
|
|
},
|
|
"Streuung außerhalb": {MaximumAttempts: 3, JitterFraction: 1.5},
|
|
}
|
|
|
|
for caseName, brokenPolicy := range brokenPolicies {
|
|
if validationError := brokenPolicy.Validate(); validationError == nil {
|
|
testInstance.Errorf("%s: die Strategie wurde angenommen", caseName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestClassifyErrorDefaultsToPermanent prüft den sicheren Rückfall.
|
|
//
|
|
// Ein falsch als vorübergehend eingeordneter Fehler liesse einen aussichtslosen
|
|
// Auftrag immer wieder anlaufen und verdeckte dabei die eigentliche Ursache.
|
|
func TestClassifyErrorDefaultsToPermanent(testInstance *testing.T) {
|
|
knownNetworkError := errors.New("verbindung abgebrochen")
|
|
|
|
classifications := map[error]FailureClass{
|
|
knownNetworkError: FailureNetwork,
|
|
}
|
|
|
|
if classifiedClass := ClassifyError(knownNetworkError, classifications); classifiedClass != FailureNetwork {
|
|
testInstance.Errorf("ein bekannter Fehler wurde als %q eingeordnet", classifiedClass)
|
|
}
|
|
|
|
unknownError := errors.New("etwas ganz anderes")
|
|
|
|
if classifiedClass := ClassifyError(unknownError, classifications); classifiedClass != FailurePermanent {
|
|
testInstance.Fatalf("ein unbekannter Fehler wurde als %q eingeordnet statt als dauerhaft", classifiedClass)
|
|
}
|
|
|
|
if ClassifyError(nil, classifications) != "" {
|
|
testInstance.Error("ein fehlender Fehler wurde eingeordnet")
|
|
}
|
|
}
|