feat(archive): BAK-07 gestaffelte Aufbewahrungsfrist fuer Backup-Snapshots

internal/backup.PruneTiered: reine GFS-Funktion (Tag/Woche/Monat) fuer
Datenbank-Generationen, behaelt strukturell immer die neueste Generation
(Sicherheitsnetz gegen Legal-Hold-Kollision). internal/objectbackup.
PruneTiered: duenner Wrapper um restics native --keep-daily/-weekly/
-monthly-Staffelung. Beide CLIs nutzen die Staffelung, wenn konfiguriert,
bleiben sonst abwaertskompatibel zur flachen "letzte N"-Regel. Real
gegen zeitversetzt erzeugte restic-Snapshots getestet (Fund: restics
--time-Flag erwartet eigenes Format, nicht RFC3339), Pruning-Sicherheit
nach dem Loeschen alter Snapshots ueber vollstaendigen Restore +
restic check --read-data bewiesen. Beide Rotationswege real ueber
systemd auf 131 ausgeloest.
This commit is contained in:
sysops
2026-08-30 00:49:23 +02:00
parent 80b0ca9176
commit 823a14ae12
8 changed files with 546 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
package backup
import (
"os"
"path/filepath"
"testing"
"time"
)
func genID(t time.Time) string { return NewGenerationID(t) }
// TestPruneTiered_KeepsCorrectStaggering ist Pruefung 1: Prune-Test mit
// simulierten Snapshots ueber mehrere Altersstufen ergibt korrekte
// Staffelung.
func TestPruneTiered_KeepsCorrectStaggering(t *testing.T) {
now := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
var generations []string
// 20 taegliche generationen der letzten 20 Tage.
for i := 0; i < 20; i++ {
generations = append(generations, genID(now.Add(-time.Duration(i)*24*time.Hour)))
}
// zusaetzlich aeltere, wochenweise verteilte generationen (Wochen 4-15
// zurueck), damit KeepWeekly etwas zu greifen hat, das KeepDaily nicht
// schon abdeckt.
for w := 4; w <= 15; w++ {
generations = append(generations, genID(now.Add(-time.Duration(w*7)*24*time.Hour)))
}
// und einige monatealte generationen.
for m := 3; m <= 10; m++ {
generations = append(generations, genID(now.AddDate(0, -m, 0)))
}
policy := RetentionPolicy{KeepDaily: 7, KeepWeekly: 4, KeepMonthly: 6}
keep, remove := PruneTiered(generations, policy)
if len(keep)+len(remove) != len(generations) {
t.Fatalf("keep+remove = %d, want %d (gesamtzahl)", len(keep)+len(remove), len(generations))
}
// Genau die 7 juengsten taeglichen generationen muessen behalten sein.
keepSet := make(map[string]bool, len(keep))
for _, k := range keep {
keepSet[k] = true
}
for i := 0; i < 7; i++ {
id := genID(now.Add(-time.Duration(i) * 24 * time.Hour))
if !keepSet[id] {
t.Fatalf("generation %q (tag %d) haette per KeepDaily behalten werden muessen", id, i)
}
}
// Zu weit zurueckliegende taegliche generationen (ausserhalb aller
// Stufen) duerfen NICHT behalten sein.
tooOld := genID(now.Add(-19 * 24 * time.Hour))
if keepSet[tooOld] {
t.Fatalf("generation %q haette NICHT behalten werden duerfen (zu alt fuer jede stufe)", tooOld)
}
if len(keep) == 0 {
t.Fatal("erwartet mindestens eine behaltene generation")
}
}
// TestPruneTiered_AlwaysKeepsNewest ist das Sicherheitsnetz fuer
// Akzeptanzkriterium 3 (mindestens ein aktueller Snapshot bleibt immer
// erhalten) - selbst bei Policy 0/0/0 (versehentlich deaktiviert).
func TestPruneTiered_AlwaysKeepsNewest(t *testing.T) {
now := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
generations := []string{genID(now), genID(now.Add(-48 * time.Hour))}
keep, remove := PruneTiered(generations, RetentionPolicy{})
if len(keep) != 1 || keep[0] != genID(now) {
t.Fatalf("erwartet genau die neueste generation behalten, habe keep=%v", keep)
}
if len(remove) != 1 {
t.Fatalf("erwartet 1 entfernte generation, habe %d", len(remove))
}
}
// TestPruneTiered_DeterministicForIdenticalInput - zwei Laeufe mit
// identischer Eingabe liefern dasselbe Ergebnis.
func TestPruneTiered_DeterministicForIdenticalInput(t *testing.T) {
now := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
var generations []string
for i := 0; i < 10; i++ {
generations = append(generations, genID(now.Add(-time.Duration(i)*24*time.Hour)))
}
policy := RetentionPolicy{KeepDaily: 3}
keep1, remove1 := PruneTiered(generations, policy)
keep2, remove2 := PruneTiered(generations, policy)
if len(keep1) != len(keep2) || len(remove1) != len(remove2) {
t.Fatal("unterschiedliches ergebnis zwischen zwei laeufen mit identischer eingabe")
}
}
// TestPruneRotate_DeletesOnlyExpiredGenerations ist Pruefung 2:
// automatisierte Loeschung abgelaufener Snapshots im Test nachgewiesen -
// real gegen echte Verzeichnisse.
func TestPruneRotate_DeletesOnlyExpiredGenerations(t *testing.T) {
backupDir := t.TempDir()
now := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
var recent, expired string
for i := 0; i < 3; i++ {
id := genID(now.Add(-time.Duration(i) * 24 * time.Hour))
if err := os.MkdirAll(filepath.Join(backupDir, id, FullBackupDirName), 0o750); err != nil {
t.Fatal(err)
}
recent = id
}
expired = genID(now.Add(-60 * 24 * time.Hour))
if err := os.MkdirAll(filepath.Join(backupDir, expired, FullBackupDirName), 0o750); err != nil {
t.Fatal(err)
}
removed, err := PruneRotate(backupDir, RetentionPolicy{KeepDaily: 3})
if err != nil {
t.Fatalf("prunerotate: %v", err)
}
if len(removed) != 1 || removed[0] != expired {
t.Fatalf("erwartet genau die abgelaufene generation %q entfernt, habe %v", expired, removed)
}
if _, err := os.Stat(filepath.Join(backupDir, recent)); err != nil {
t.Fatalf("juengste generation faelschlich entfernt: %v", err)
}
if _, err := os.Stat(filepath.Join(backupDir, expired)); !os.IsNotExist(err) {
t.Fatal("abgelaufene generation wurde nicht tatsaechlich vom dateisystem entfernt")
}
}