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>
274 lines
9.8 KiB
Go
274 lines
9.8 KiB
Go
package retention
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// referenceTimeForTests ist der feste Bezugszeitpunkt aller Tests.
|
|
//
|
|
// Ein fester Zeitpunkt statt time.Now: Sonst laege das Ergebnis mancher Tests
|
|
// am Wochentag der Ausfuehrung, und ein Fehler faende sich nur montags.
|
|
var referenceTimeForTests = time.Date(2026, time.August, 11, 12, 0, 0, 0, time.UTC)
|
|
|
|
// dailyBackups erzeugt taegliche Backups rueckwaerts ab dem Bezugszeitpunkt.
|
|
func dailyBackups(dayCount int) []BackupCandidate {
|
|
candidates := make([]BackupCandidate, 0, dayCount)
|
|
|
|
for dayOffset := 0; dayOffset < dayCount; dayOffset++ {
|
|
completionTime := referenceTimeForTests.AddDate(0, 0, -dayOffset)
|
|
|
|
candidates = append(candidates, BackupCandidate{
|
|
BackupID: completionTime.Format("2006-01-02"),
|
|
CompletedAt: completionTime,
|
|
SizeBytes: 1024,
|
|
})
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
// TestEmptyPolicyIsRejected prueft die Ablehnung einer Regel ohne Haltevorgabe.
|
|
//
|
|
// Eine leere Regel als „behalte nichts" auszufuehren waere die teuerste
|
|
// moegliche Auslegung eines Tippfehlers.
|
|
func TestEmptyPolicyIsRejected(testInstance *testing.T) {
|
|
emptyPolicy := Policy{Name: "leer"}
|
|
|
|
if validationError := emptyPolicy.Validate(); !errors.Is(validationError, ErrPolicyEmpty) {
|
|
testInstance.Fatalf("die leere Regel wurde angenommen, Fehler war: %v", validationError)
|
|
}
|
|
|
|
if _, applyError := Apply(emptyPolicy, dailyBackups(5), referenceTimeForTests); applyError == nil {
|
|
testInstance.Fatal("die leere Regel wurde angewandt")
|
|
}
|
|
}
|
|
|
|
// TestKeepWithinKeepsRecentBackups prueft die einfache Frist.
|
|
func TestKeepWithinKeepsRecentBackups(testInstance *testing.T) {
|
|
plan, applyError := Apply(PolicySevenDays, dailyBackups(20), referenceTimeForTests)
|
|
if applyError != nil {
|
|
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", applyError)
|
|
}
|
|
|
|
// Sieben Tage Frist: heute plus die sechs Vortage liegen innerhalb.
|
|
if plan.KeptCount != 7 {
|
|
testInstance.Errorf("es blieben %d Backups, erwartet wurden 7", plan.KeptCount)
|
|
}
|
|
|
|
if plan.DeletableCount != 13 {
|
|
testInstance.Errorf("es waeren %d loeschbar, erwartet wurden 13", plan.DeletableCount)
|
|
}
|
|
|
|
for _, decision := range plan.Decisions {
|
|
if decision.Reason == "" {
|
|
testInstance.Errorf("die Entscheidung fuer %s wurde nicht begruendet", decision.BackupID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestKeepLastProtectsTheOnlyRemainingBackup prueft die wichtigste Sicherung.
|
|
//
|
|
// Der Fall ist die haeufigste Katastrophe einer Aufbewahrungsregel: Ein System
|
|
// sichert monatelang nicht, alle Backups fallen aus der Frist — und die Regel
|
|
// loescht das letzte vorhandene. Danach gibt es keines mehr.
|
|
func TestKeepLastProtectsTheOnlyRemainingBackup(testInstance *testing.T) {
|
|
oldBackup := []BackupCandidate{{
|
|
BackupID: "das-einzige",
|
|
CompletedAt: referenceTimeForTests.AddDate(0, -6, 0),
|
|
SizeBytes: 4096,
|
|
}}
|
|
|
|
plan, applyError := Apply(PolicySevenDays, oldBackup, referenceTimeForTests)
|
|
if applyError != nil {
|
|
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", applyError)
|
|
}
|
|
|
|
if plan.DeletableCount != 0 {
|
|
testInstance.Fatal("das einzige vorhandene Backup wurde zum Loeschen vorgesehen")
|
|
}
|
|
|
|
if plan.Decisions[0].Reason == "" {
|
|
testInstance.Error("das Behalten wurde nicht begruendet")
|
|
}
|
|
}
|
|
|
|
// TestGrandfatherFatherSonKeepsOnePerInterval prueft die Staffelung.
|
|
func TestGrandfatherFatherSonKeepsOnePerInterval(testInstance *testing.T) {
|
|
// Zwei Jahre taeglicher Backups.
|
|
plan, applyError := Apply(PolicyGrandfatherFatherSon, dailyBackups(730), referenceTimeForTests)
|
|
if applyError != nil {
|
|
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", applyError)
|
|
}
|
|
|
|
// 30 Tages-, 8 Wochen-, 12 Monats- und 7 Jahresstaende. Die Mengen
|
|
// ueberschneiden sich, deshalb wird nur die Groessenordnung geprueft — und
|
|
// vor allem, dass nicht alles behalten wird.
|
|
if plan.KeptCount < 30 {
|
|
testInstance.Errorf("es blieben nur %d Backups, das ist weniger als die 30 Tagesstaende", plan.KeptCount)
|
|
}
|
|
|
|
if plan.KeptCount > 60 {
|
|
testInstance.Errorf("es blieben %d Backups; die Staffelung greift offenbar nicht", plan.KeptCount)
|
|
}
|
|
|
|
if plan.DeletableCount == 0 {
|
|
testInstance.Error("die Staffelung entfernte kein einziges Backup")
|
|
}
|
|
|
|
// Das juengste bleibt in jedem Fall.
|
|
if !plan.Decisions[0].Keep {
|
|
testInstance.Error("das juengste Backup wurde zum Loeschen vorgesehen")
|
|
}
|
|
}
|
|
|
|
// TestRetentionNeverDeletesProtectedBackups prueft den Vorrang des Schutzes.
|
|
func TestRetentionNeverDeletesProtectedBackups(testInstance *testing.T) {
|
|
protectedUntil := referenceTimeForTests.AddDate(0, 1, 0)
|
|
|
|
candidates := []BackupCandidate{
|
|
{BackupID: "juengstes", CompletedAt: referenceTimeForTests},
|
|
{
|
|
BackupID: "alt-aber-geschuetzt",
|
|
CompletedAt: referenceTimeForTests.AddDate(0, 0, -100),
|
|
ImmutableUntil: &protectedUntil,
|
|
},
|
|
{
|
|
BackupID: "alt-mit-legal-hold",
|
|
CompletedAt: referenceTimeForTests.AddDate(0, 0, -200),
|
|
LegalHold: true,
|
|
},
|
|
{BackupID: "alt-und-frei", CompletedAt: referenceTimeForTests.AddDate(0, 0, -300)},
|
|
}
|
|
|
|
plan, applyError := Apply(PolicySevenDays, candidates, referenceTimeForTests)
|
|
if applyError != nil {
|
|
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", applyError)
|
|
}
|
|
|
|
deletableIdentifiers := plan.DeletableBackupIDs()
|
|
|
|
if len(deletableIdentifiers) != 1 || deletableIdentifiers[0] != "alt-und-frei" {
|
|
testInstance.Fatalf("loeschbar waren %v, erwartet wurde nur alt-und-frei", deletableIdentifiers)
|
|
}
|
|
|
|
if plan.ProtectedCount != 2 {
|
|
testInstance.Errorf("es wurden %d geschuetzte Backups gemeldet, erwartet wurden 2", plan.ProtectedCount)
|
|
}
|
|
|
|
// Der Bericht muss den Unterschied benennen: „waere faellig, ist aber
|
|
// geschuetzt" erklaert, warum der Speicher nicht kleiner wird.
|
|
for _, decision := range plan.Decisions {
|
|
if decision.BackupID == "alt-aber-geschuetzt" && !decision.Protected {
|
|
testInstance.Error("das geschuetzte Backup wurde nicht als geschuetzt ausgewiesen")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRetentionKeepsIncrementalParents prueft den Schutz der Elternbackups.
|
|
func TestRetentionKeepsIncrementalParents(testInstance *testing.T) {
|
|
candidates := []BackupCandidate{
|
|
{BackupID: "juengstes", CompletedAt: referenceTimeForTests},
|
|
{
|
|
BackupID: "eltern",
|
|
CompletedAt: referenceTimeForTests.AddDate(0, 0, -90),
|
|
IsIncrementalParent: true,
|
|
},
|
|
}
|
|
|
|
plan, applyError := Apply(PolicySevenDays, candidates, referenceTimeForTests)
|
|
if applyError != nil {
|
|
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", applyError)
|
|
}
|
|
|
|
if plan.DeletableCount != 0 {
|
|
testInstance.Fatalf("es wurde ein Elternbackup zum Loeschen vorgesehen: %v", plan.DeletableBackupIDs())
|
|
}
|
|
}
|
|
|
|
// TestTimeZoneChangesDailyBuckets prueft die Zeitzone der Tagesgrenzen.
|
|
//
|
|
// Wer taeglich um 00:30 Uhr deutscher Zeit sichert, hat in UTC Backups vom
|
|
// Vortag um 22:30. Ohne Zeitzone fielen zwei Backups in denselben UTC-Tag und
|
|
// eines verschwaende — systematisch, und immer dasselbe.
|
|
func TestTimeZoneChangesDailyBuckets(testInstance *testing.T) {
|
|
berlinLocation, locationError := time.LoadLocation("Europe/Berlin")
|
|
if locationError != nil {
|
|
testInstance.Skipf("die Zeitzonendatenbank fehlt: %v", locationError)
|
|
}
|
|
|
|
// Zwei Backups am selben Berliner Tag, aber an zwei UTC-Tagen.
|
|
firstBackupTime := time.Date(2026, time.August, 10, 0, 30, 0, 0, berlinLocation)
|
|
secondBackupTime := time.Date(2026, time.August, 10, 23, 30, 0, 0, berlinLocation)
|
|
|
|
candidates := []BackupCandidate{
|
|
{BackupID: "spaet", CompletedAt: secondBackupTime},
|
|
{BackupID: "frueh", CompletedAt: firstBackupTime},
|
|
}
|
|
|
|
// Zwei Tagesstaende: In Berliner Zeit gibt es nur einen Tag zu halten, in
|
|
// UTC zwei. Mit nur einem Tagesstand waere der Unterschied nicht sichtbar —
|
|
// beide Auslegungen behielten dann genau ein Backup.
|
|
berlinPolicy := Policy{Name: "taeglich-berlin", KeepDaily: 2, TimeZone: "Europe/Berlin"}
|
|
|
|
berlinPlan, applyError := Apply(berlinPolicy, candidates, referenceTimeForTests)
|
|
if applyError != nil {
|
|
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", applyError)
|
|
}
|
|
|
|
// In Berliner Zeit ist das derselbe Tag: nur eines bleibt.
|
|
if berlinPlan.KeptCount != 1 {
|
|
testInstance.Errorf("in Europe/Berlin blieben %d Backups, erwartet wurde 1", berlinPlan.KeptCount)
|
|
}
|
|
|
|
utcPolicy := Policy{Name: "taeglich-utc", KeepDaily: 2}
|
|
|
|
utcPlan, utcError := Apply(utcPolicy, candidates, referenceTimeForTests)
|
|
if utcError != nil {
|
|
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", utcError)
|
|
}
|
|
|
|
// In UTC sind es zwei Tage: beide bleiben. Genau dieser Unterschied ist der
|
|
// Grund, warum die Zeitzone Teil der Regel ist.
|
|
if utcPlan.KeptCount != 2 {
|
|
testInstance.Errorf("in UTC blieben %d Backups, erwartet wurden 2", utcPlan.KeptCount)
|
|
}
|
|
}
|
|
|
|
// TestApplyOnEmptyRepositoryDeletesNothing prueft den leeren Fall.
|
|
func TestApplyOnEmptyRepositoryDeletesNothing(testInstance *testing.T) {
|
|
plan, applyError := Apply(PolicyThirtyDays, nil, referenceTimeForTests)
|
|
if applyError != nil {
|
|
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", applyError)
|
|
}
|
|
|
|
if plan.DeletableCount != 0 || plan.KeptCount != 0 {
|
|
testInstance.Error("auf einem leeren Repository wurde eine Entscheidung getroffen")
|
|
}
|
|
|
|
if plan.Summary() == "" {
|
|
testInstance.Error("der leere Plan wurde nicht zusammengefasst")
|
|
}
|
|
}
|
|
|
|
// TestPredefinedPoliciesAreValid prueft die mitgelieferten Regeln.
|
|
//
|
|
// Eine mitgelieferte Regel, die die eigene Pruefung nicht besteht, waere eine
|
|
// Falle mit Werkseinstellung.
|
|
func TestPredefinedPoliciesAreValid(testInstance *testing.T) {
|
|
for _, predefinedPolicy := range PredefinedPolicies() {
|
|
if validationError := predefinedPolicy.Validate(); validationError != nil {
|
|
testInstance.Errorf("die Regel %q ist unbrauchbar: %v", predefinedPolicy.Name, validationError)
|
|
}
|
|
|
|
if predefinedPolicy.KeepLast < 1 {
|
|
testInstance.Errorf("die Regel %q schuetzt das letzte Backup nicht", predefinedPolicy.Name)
|
|
}
|
|
|
|
if predefinedPolicy.Describe() == "" {
|
|
testInstance.Errorf("die Regel %q erklaert sich nicht", predefinedPolicy.Name)
|
|
}
|
|
}
|
|
}
|