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>
246 lines
8.8 KiB
Go
246 lines
8.8 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestBlackoutWindowBlocksAndDefersRun prüft das Sperrfenster.
|
|
//
|
|
// Ein durch ein Fenster verhinderter Lauf wird verschoben, nicht übergangen.
|
|
// Ihn ausfallen zu lassen erzeugte eine Lücke in der Sicherungskette, von der
|
|
// niemand erführe: Dass ein Lauf verspätet ist, sieht man; dass er fehlt, nicht.
|
|
func TestBlackoutWindowBlocksAndDefersRun(testInstance *testing.T) {
|
|
windowSet, setError := NewWindowSet([]MaintenanceWindow{
|
|
{
|
|
Identifier: "inventur",
|
|
Name: "Jahresinventur",
|
|
WindowKind: WindowKindBlackout,
|
|
StartsAt: time.Date(2026, time.December, 27, 0, 0, 0, 0, time.UTC),
|
|
EndsAt: time.Date(2026, time.December, 28, 6, 0, 0, 0, time.UTC),
|
|
},
|
|
})
|
|
if setError != nil {
|
|
testInstance.Fatalf("die Fenstermenge wurde abgelehnt: %v", setError)
|
|
}
|
|
|
|
insideWindow := time.Date(2026, time.December, 27, 2, 0, 0, 0, time.UTC)
|
|
|
|
isBlocked, blockReason := windowSet.IsBlocked(insideWindow, "taeglich")
|
|
if !isBlocked {
|
|
testInstance.Fatal("ein Zeitpunkt im Sperrfenster wurde freigegeben")
|
|
}
|
|
|
|
if blockReason.WindowName != "Jahresinventur" {
|
|
testInstance.Errorf("die Begründung nennt das Fenster nicht: %+v", blockReason)
|
|
}
|
|
|
|
// Der Lauf muss nachgeholt werden — unmittelbar nach dem Fenster.
|
|
deferredTime, deferError := windowSet.NextAllowedTime(insideWindow, "taeglich")
|
|
if deferError != nil {
|
|
testInstance.Fatalf("es wurde kein Ausweichzeitpunkt gefunden: %v", deferError)
|
|
}
|
|
|
|
if deferredTime.Before(time.Date(2026, time.December, 28, 6, 0, 0, 0, time.UTC)) {
|
|
testInstance.Errorf("der Ausweichzeitpunkt %s liegt noch im Fenster", deferredTime)
|
|
}
|
|
}
|
|
|
|
// TestAllowedWindowBlocksOutsideItself prüft die Umkehrung.
|
|
func TestAllowedWindowBlocksOutsideItself(testInstance *testing.T) {
|
|
windowSet, setError := NewWindowSet([]MaintenanceWindow{
|
|
{
|
|
Name: "Nachtfenster",
|
|
WindowKind: WindowKindAllowed,
|
|
Recurring: &RecurringWindow{
|
|
StartHour: 22,
|
|
Duration: 8 * time.Hour,
|
|
},
|
|
},
|
|
})
|
|
if setError != nil {
|
|
testInstance.Fatalf("die Fenstermenge wurde abgelehnt: %v", setError)
|
|
}
|
|
|
|
// Mitten am Tag: außerhalb des Erlaubnisfensters.
|
|
middayTime := time.Date(2026, time.June, 1, 14, 0, 0, 0, time.UTC)
|
|
|
|
if isBlocked, _ := windowSet.IsBlocked(middayTime, "taeglich"); !isBlocked {
|
|
testInstance.Fatal("ein Zeitpunkt außerhalb des Erlaubnisfensters wurde freigegeben")
|
|
}
|
|
|
|
// In der Nacht: zugelassen.
|
|
nightTime := time.Date(2026, time.June, 1, 23, 0, 0, 0, time.UTC)
|
|
|
|
if isBlocked, blockReason := windowSet.IsBlocked(nightTime, "taeglich"); isBlocked {
|
|
testInstance.Fatalf("ein Zeitpunkt im Erlaubnisfenster wurde gesperrt: %+v", blockReason)
|
|
}
|
|
}
|
|
|
|
// TestRecurringWindowSpansMidnight ist der Test für den häufigsten Denkfehler.
|
|
//
|
|
// Ein Fenster, das samstags um 22:00 beginnt und zwölf Stunden dauert, reicht
|
|
// bis Sonntag 10:00. Wer nur den Tag des Zeitpunkts prüft, hielte Sonntag 09:00
|
|
// für frei — und liesse den Auftrag mitten in die Wartung laufen.
|
|
func TestRecurringWindowSpansMidnight(testInstance *testing.T) {
|
|
windowSet, setError := NewWindowSet([]MaintenanceWindow{
|
|
{
|
|
Name: "Wochenendwartung",
|
|
WindowKind: WindowKindBlackout,
|
|
Recurring: &RecurringWindow{
|
|
Weekdays: []time.Weekday{time.Saturday},
|
|
StartHour: 22,
|
|
Duration: 12 * time.Hour,
|
|
},
|
|
},
|
|
})
|
|
if setError != nil {
|
|
testInstance.Fatalf("die Fenstermenge wurde abgelehnt: %v", setError)
|
|
}
|
|
|
|
// Samstag, 6. Juni 2026, 23:00 — im Fenster.
|
|
saturdayNight := time.Date(2026, time.June, 6, 23, 0, 0, 0, time.UTC)
|
|
if isBlocked, _ := windowSet.IsBlocked(saturdayNight, "taeglich"); !isBlocked {
|
|
testInstance.Error("Samstag 23:00 wurde freigegeben, obwohl das Fenster um 22:00 begann")
|
|
}
|
|
|
|
// Sonntag 09:00 — noch im Fenster, obwohl es ein anderer Tag ist.
|
|
sundayMorning := time.Date(2026, time.June, 7, 9, 0, 0, 0, time.UTC)
|
|
if isBlocked, _ := windowSet.IsBlocked(sundayMorning, "taeglich"); !isBlocked {
|
|
testInstance.Fatal("Sonntag 09:00 wurde freigegeben, obwohl das Samstagsfenster bis 10:00 reicht")
|
|
}
|
|
|
|
// Sonntag 11:00 — nach dem Fenster.
|
|
sundayLate := time.Date(2026, time.June, 7, 11, 0, 0, 0, time.UTC)
|
|
if isBlocked, _ := windowSet.IsBlocked(sundayLate, "taeglich"); isBlocked {
|
|
testInstance.Error("Sonntag 11:00 wurde gesperrt, obwohl das Fenster um 10:00 endete")
|
|
}
|
|
}
|
|
|
|
// TestWindowAppliesOnlyToNamedJobs prüft die Einschränkung.
|
|
func TestWindowAppliesOnlyToNamedJobs(testInstance *testing.T) {
|
|
windowSet, _ := NewWindowSet([]MaintenanceWindow{
|
|
{
|
|
Name: "Nur für die Datenbank",
|
|
WindowKind: WindowKindBlackout,
|
|
StartsAt: time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC),
|
|
EndsAt: time.Date(2026, time.June, 2, 0, 0, 0, 0, time.UTC),
|
|
AppliesToJobIDs: []string{"datenbank"},
|
|
},
|
|
})
|
|
|
|
insideWindow := time.Date(2026, time.June, 1, 12, 0, 0, 0, time.UTC)
|
|
|
|
if isBlocked, _ := windowSet.IsBlocked(insideWindow, "datenbank"); !isBlocked {
|
|
testInstance.Error("der benannte Auftrag wurde nicht gesperrt")
|
|
}
|
|
|
|
if isBlocked, _ := windowSet.IsBlocked(insideWindow, "dateiserver"); isBlocked {
|
|
testInstance.Error("ein nicht benannter Auftrag wurde mitgesperrt")
|
|
}
|
|
}
|
|
|
|
// TestAllowedWindowForOtherJobDoesNotBlockEveryone ist der Test gegen einen
|
|
// gefährlichen Kurzschluss.
|
|
//
|
|
// Gäbe es irgendwo ein Erlaubnisfenster und würde die Regel auf alle Aufträge
|
|
// angewandt, fielen sämtliche Sicherungen aus, die nicht gemeint waren.
|
|
func TestAllowedWindowForOtherJobDoesNotBlockEveryone(testInstance *testing.T) {
|
|
windowSet, _ := NewWindowSet([]MaintenanceWindow{
|
|
{
|
|
Name: "Nur nachts für die Datenbank",
|
|
WindowKind: WindowKindAllowed,
|
|
AppliesToJobIDs: []string{"datenbank"},
|
|
Recurring: &RecurringWindow{
|
|
StartHour: 2,
|
|
Duration: 2 * time.Hour,
|
|
},
|
|
},
|
|
})
|
|
|
|
middayTime := time.Date(2026, time.June, 1, 14, 0, 0, 0, time.UTC)
|
|
|
|
// Die Datenbank darf mittags nicht.
|
|
if isBlocked, _ := windowSet.IsBlocked(middayTime, "datenbank"); !isBlocked {
|
|
testInstance.Error("die Datenbank lief außerhalb ihres Erlaubnisfensters")
|
|
}
|
|
|
|
// Alle anderen sind davon unberührt.
|
|
if isBlocked, blockReason := windowSet.IsBlocked(middayTime, "dateiserver"); isBlocked {
|
|
testInstance.Fatalf("ein fremdes Erlaubnisfenster sperrte einen unbeteiligten Auftrag: %+v", blockReason)
|
|
}
|
|
}
|
|
|
|
// TestNextAllowedTimeFailsOnPermanentBlackout prüft die Fehlkonfiguration.
|
|
//
|
|
// Ein Sperrfenster ohne absehbares Ende ist fast immer ein Fehler. Er wird als
|
|
// solcher gemeldet statt als stiller Ausfall.
|
|
func TestNextAllowedTimeFailsOnPermanentBlackout(testInstance *testing.T) {
|
|
windowSet, _ := NewWindowSet([]MaintenanceWindow{
|
|
{
|
|
Name: "Dauersperre",
|
|
WindowKind: WindowKindBlackout,
|
|
Recurring: &RecurringWindow{
|
|
StartHour: 0,
|
|
// Ein Fenster über die volle Woche sperrt jeden Zeitpunkt.
|
|
Duration: 7 * 24 * time.Hour,
|
|
},
|
|
},
|
|
})
|
|
|
|
_, searchError := windowSet.NextAllowedTime(time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC), "taeglich")
|
|
|
|
if searchError == nil {
|
|
testInstance.Fatal("eine Dauersperre wurde nicht als Fehlkonfiguration gemeldet")
|
|
}
|
|
}
|
|
|
|
// TestWindowValidationRejectsBrokenDefinitions prüft die Eingabeprüfung.
|
|
func TestWindowValidationRejectsBrokenDefinitions(testInstance *testing.T) {
|
|
brokenWindows := map[string]MaintenanceWindow{
|
|
"unbekannte Wirkung": {WindowKind: "vielleicht", StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour)},
|
|
"Ende vor Anfang": {
|
|
WindowKind: WindowKindBlackout,
|
|
StartsAt: time.Date(2026, time.June, 2, 0, 0, 0, 0, time.UTC),
|
|
EndsAt: time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC),
|
|
},
|
|
"einmalig ohne Zeiten": {WindowKind: WindowKindBlackout},
|
|
"wiederkehrend ohne Länge": {
|
|
WindowKind: WindowKindBlackout,
|
|
Recurring: &RecurringWindow{StartHour: 2},
|
|
},
|
|
"wiederkehrend zu lang": {
|
|
WindowKind: WindowKindBlackout,
|
|
Recurring: &RecurringWindow{StartHour: 2, Duration: 8 * 24 * time.Hour},
|
|
},
|
|
"unbekannte Zeitzone": {
|
|
WindowKind: WindowKindBlackout,
|
|
Recurring: &RecurringWindow{StartHour: 2, Duration: time.Hour, TimeZone: "Mars/Olympus_Mons"},
|
|
},
|
|
}
|
|
|
|
for caseName, brokenWindow := range brokenWindows {
|
|
if validationError := brokenWindow.Validate(); validationError == nil {
|
|
testInstance.Errorf("%s: das Fenster wurde angenommen", caseName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestActiveWindowsExplainsWhyJobWaits prüft die Auskunft für die Oberfläche.
|
|
func TestActiveWindowsExplainsWhyJobWaits(testInstance *testing.T) {
|
|
windowSet, _ := NewWindowSet([]MaintenanceWindow{
|
|
{
|
|
Name: "Monatsabschluss",
|
|
WindowKind: WindowKindBlackout,
|
|
StartsAt: time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC),
|
|
EndsAt: time.Date(2026, time.June, 2, 0, 0, 0, 0, time.UTC),
|
|
},
|
|
})
|
|
|
|
activeWindows := windowSet.ActiveWindows(time.Date(2026, time.June, 1, 12, 0, 0, 0, time.UTC), "taeglich")
|
|
|
|
if len(activeWindows) != 1 || activeWindows[0].Name != "Monatsabschluss" {
|
|
testInstance.Fatalf("das wirksame Fenster wurde nicht ausgewiesen: %+v", activeWindows)
|
|
}
|
|
}
|