diff --git a/archive/cmd/backup-cli/main.go b/archive/cmd/backup-cli/main.go index a68fc60..989af30 100644 --- a/archive/cmd/backup-cli/main.go +++ b/archive/cmd/backup-cli/main.go @@ -36,6 +36,16 @@ func loadConfig() backup.Config { return cfg } +func envInt(name string, def int) int { + v := os.Getenv(name) + if v == "" { + return def + } + n := def + _, _ = fmt.Sscanf(v, "%d", &n) + return n +} + func latestManifest(backupDir string) (string, error) { generations, err := backup.ListGenerations(backupDir) if err != nil { @@ -91,6 +101,23 @@ func main() { fmt.Printf("inkrementelle sicherung %q erstellt und verifiziert: %s\n", incID, newManifest) case "rotate": + // BAK-07: gestaffelte Aufbewahrungsfrist (KeepDaily/Weekly/Monthly), + // falls konfiguriert - ersetzt die flache "letzte N"-Regel + // (NEXARCH_BACKUP_KEEP_GENERATIONS), bleibt aber abwaertskompatibel, + // wenn keine Staffelung gesetzt ist. + policy := backup.RetentionPolicy{ + KeepDaily: envInt("NEXARCH_BACKUP_KEEP_DAILY", 0), + KeepWeekly: envInt("NEXARCH_BACKUP_KEEP_WEEKLY", 0), + KeepMonthly: envInt("NEXARCH_BACKUP_KEEP_MONTHLY", 0), + } + if policy.KeepDaily > 0 || policy.KeepWeekly > 0 || policy.KeepMonthly > 0 { + removed, err := backup.PruneRotate(cfg.BackupDir, policy) + if err != nil { + log.Fatalf("gestaffelte rotation fehlgeschlagen: %v", err) + } + fmt.Printf("gestaffelte rotation abgeschlossen, %d generation(en) entfernt: %v\n", len(removed), removed) + return + } keep := 7 if v := os.Getenv("NEXARCH_BACKUP_KEEP_GENERATIONS"); v != "" { _, _ = fmt.Sscanf(v, "%d", &keep) diff --git a/archive/cmd/objectbackup-cli/main.go b/archive/cmd/objectbackup-cli/main.go index 2430379..c20763d 100644 --- a/archive/cmd/objectbackup-cli/main.go +++ b/archive/cmd/objectbackup-cli/main.go @@ -25,6 +25,17 @@ func loadConfig() objectbackup.Config { return cfg } +func envInt(name string, def int) int { + v := os.Getenv(name) + if v == "" { + return def + } + if n, err := strconv.Atoi(v); err == nil { + return n + } + return def +} + func main() { if len(os.Args) < 2 { log.Fatal("aufruf: objectbackup-cli |check|rotate>") @@ -58,6 +69,20 @@ func main() { fmt.Println("verifikation (mit vollstaendigem lesen) erfolgreich") case "rotate": + // BAK-07: gestaffelte Aufbewahrungsfrist, falls konfiguriert - + // ersetzt die flache "letzte N"-Regel, bleibt abwaertskompatibel. + policy := objectbackup.RetentionPolicy{ + KeepDaily: envInt("NEXARCH_OBJECTBACKUP_KEEP_DAILY", 0), + KeepWeekly: envInt("NEXARCH_OBJECTBACKUP_KEEP_WEEKLY", 0), + KeepMonthly: envInt("NEXARCH_OBJECTBACKUP_KEEP_MONTHLY", 0), + } + if policy.KeepDaily > 0 || policy.KeepWeekly > 0 || policy.KeepMonthly > 0 { + if err := objectbackup.PruneTiered(ctx, cfg, policy); err != nil { + log.Fatalf("gestaffelte rotation fehlgeschlagen: %v", err) + } + fmt.Println("gestaffelte rotation abgeschlossen") + return + } keep := 7 if v := os.Getenv("NEXARCH_OBJECTBACKUP_KEEP_SNAPSHOTS"); v != "" { if n, err := strconv.Atoi(v); err == nil { diff --git a/archive/docs/BAK-07-PRUEFPROTOKOLL.md b/archive/docs/BAK-07-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..2856d7c --- /dev/null +++ b/archive/docs/BAK-07-PRUEFPROTOKOLL.md @@ -0,0 +1,94 @@ +# BAK-07 – Prüfprotokoll: Aufbewahrungsfrist für Backup-Snapshots + +Voraussetzung BAK-01, BAK-02 – erledigt, siehe eigene Protokolle. + +## Grundsatzentscheidung: restic-native Staffelung für Objekte, eigene GFS-Funktion für DB-Generationen + +Objekt-Storage (BAK-02): restic beherrscht gestaffelte Aufbewahrung +bereits nativ (`forget --keep-daily/--keep-weekly/--keep-monthly`) — +kein Eigenbau, `internal/objectbackup.PruneTiered` ist ein dünner +Wrapper (dieselbe Disziplin wie beim ursprünglichen +restic-statt-Eigenbau-Entscheid in BAK-02). + +Datenbank-Generationen (BAK-01): `pg_basebackup` kennt keine +Staffelung, daher `internal/backup.PruneTiered` als reine Funktion +(GFS-Prinzip: je Granularität Tag/Woche/Monat wird pro Zeitfenster die +NEUESTE Generation behalten, bis die konfigurierte Fensteranzahl +erreicht ist) — ohne Dateisystemzugriff testbar, `PruneRotate` führt die +tatsächliche Löschung anhand des Ergebnisses aus (analog zu BAK-01s +bestehendem `Rotate`). + +Beide Policies sind über Umgebungsvariablen konfigurierbar +(`NEXARCH_BACKUP_KEEP_DAILY/WEEKLY/MONTHLY`, +`NEXARCH_OBJECTBACKUP_KEEP_DAILY/WEEKLY/MONTHLY`), nicht hart codiert +(Ticket-Vorgabe). Bestehende flache "letzte N"-Rotation +(`NEXARCH_*_KEEP_GENERATIONS`/`KEEP_SNAPSHOTS`) bleibt als Fallback +erhalten, falls keine Staffelung gesetzt ist — kein Bruch für bestehende +Deployments. + +## Sicherheitsnetz für Akzeptanzkriterium 3 (Legal-Hold-Kollision) + +`internal/backup.PruneTiered` behält STRUKTURELL immer die neueste +Generation, unabhängig von der konfigurierten Staffelung — ein +Dokument, das in der neuesten (immer behaltenen) Sicherung enthalten +ist, bleibt also immer wiederherstellbar, unabhängig von einer +laufenden RET-03/CMP-06-Aufbewahrungssperre (die dieses Ticket, Archive +BAK-Modul, ohnehin nicht direkt anfasst — Sperren sind DMS/RET-Eigentum, +kein Import-Ziel für Archive per "kein Umbau angrenzender Bereiche"). +Für Objekt-Storage ist die äquivalente Eigenschaft restics eigene +Garantie: `forget --prune` entfernt niemals Datenblöcke, die ein +BEHALTENER Snapshot noch referenziert (Content-defined Chunking + +Referenzzählung) — real bewiesen, nicht nur behauptet (siehe Prüfung 3). + +## Umsetzung + +- `internal/backup.RetentionPolicy`/`PruneTiered` (`retention.go`) — + reine GFS-Funktion. +- `internal/backup.PruneRotate` (`rotate.go`) — führt `PruneTiered` + tatsächlich gegen das Sicherungsverzeichnis aus. +- `internal/objectbackup.RetentionPolicy`/`PruneTiered` (`restic.go`) — + Wrapper um restics native Staffelung. +- `cmd/backup-cli rotate` / `cmd/objectbackup-cli rotate` — nutzen die + gestaffelte Policy, wenn konfiguriert, sonst weiterhin die flache + Regel (Abwärtskompatibilität). + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Prune-Test mit simulierten Snapshots über mehrere Altersstufen ergibt korrekte Staffelung | **bestanden** — `TestPruneTiered_KeepsCorrectStaggering` (Datenbank, reine Funktion, simulierte Generationen über Tage/Wochen/Monate) UND `TestPruneTiered_KeepsCorrectStaggering_RealSnapshots` (Objekt-Storage, ECHTE restic-Snapshots mit erzwungenem Zeitstempel `restic backup --time`, `KeepDaily=3` reduziert 10 reale Snapshots korrekt auf 3) | +| 2 | Automatisierte Löschung abgelaufener Snapshots im Test nachgewiesen | **bestanden** — `TestPruneRotate_DeletesOnlyExpiredGenerations`: reales Verzeichnis, abgelaufene Generation tatsächlich vom Dateisystem entfernt, aktuelle unangetastet; real auf 131: `backup-cli rotate` und `objectbackup-cli rotate` über systemd ausgelöst, Journal zeigt „gestaffelte rotation abgeschlossen“ | +| 3 | Legal-Hold-Test bestätigt, dass Pruning nicht mit Dokumenten-Retention kollidiert | **bestanden** — `TestPruneTiered_AlwaysKeepsNewest` (Datenbank-Sicherheitsnetz: neueste Generation immer behalten, selbst bei Policy 0/0/0) UND `TestPruneTiered_KeptSnapshotStillFullyRestorable` (Objekt-Storage, real: 5 ältere Snapshots eines Dokuments weggeprunt, verbleibender Snapshot danach vollständig und inhaltlich korrekt wiederhergestellt, zusätzlich `restic check --read-data` bestätigt Datenintegrität nach dem Pruning) | + +Zusätzlich: `TestPruneTiered_DeterministicForIdenticalInput`. + +## Echte Verdrahtung auf 192.168.1.131 + +- `backup-cli`, `objectbackup-cli` neu gebaut mit der gestaffelten + Rotations-Logik +- `/etc/nexarch/archive-backup.env` und + `/etc/nexarch/archive-objectbackup.env` um + `KEEP_DAILY=14`/`KEEP_WEEKLY=12`/`KEEP_MONTHLY=12` ergänzt +- Beide `*-rotate.service` real über `systemctl start` ausgelöst — + Journal bestätigt den neuen Pfad ("gestaffelte rotation + abgeschlossen"/"... entfernt: [...]"), nicht mehr die alte flache + Regel + +## Build/Test-Ergebnis (192.168.1.131, `make check`) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -count=1 -> 7/7 Pakete mit Tests ok, 0 Fehlschläge +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei +Pflichtprüfungen real erfüllt — sowohl mit simulierten (Datenbank) als +auch mit tatsächlich zeitversetzt erzeugten, realen restic-Snapshots +(Objekt-Storage). Ein realer Formatfehler beim Testaufbau gefunden und +behoben (restics `--time`-Flag erwartet sein eigenes Format, nicht +RFC3339). Beide Rotations-Wege real über systemd auf 192.168.1.131 +ausgelöst, nicht nur isolierter Testcode. diff --git a/archive/internal/backup/retention.go b/archive/internal/backup/retention.go new file mode 100644 index 0000000..e495472 --- /dev/null +++ b/archive/internal/backup/retention.go @@ -0,0 +1,89 @@ +package backup + +import ( + "fmt" + "sort" + "time" +) + +// RetentionPolicy ist BAK-07s gestaffelte Aufbewahrungsfrist für +// Datenbank-Sicherungsgenerationen — unabhängig von RET-02s +// Dokumenten-Retention (Akzeptanzkriterium 1). Ein Feldwert 0 bedeutet +// "diese Stufe nicht anwenden", dieselbe Konvention wie +// objectbackup.RetentionPolicy (BAK-02), damit beide Backup-Arten +// gleich konfiguriert werden. +type RetentionPolicy struct { + KeepDaily int + KeepWeekly int + KeepMonthly int +} + +// PruneTiered wählt aus generations (IDs im NewGenerationID-Format) die +// zu BEHALTENDEN nach gestaffelter GFS-Regel: je Granularität +// (Tag/Woche/Monat) wird pro Zeitfenster die NEUESTE Generation +// behalten, bis die konfigurierte Anzahl Fenster erreicht ist — reine +// Funktion, keine Dateisystem-/Netzwerkzugriffe, dadurch ohne echte +// Infrastruktur testbar (Rotate in rotate.go führt die tatsächliche +// Löschung anhand des Ergebnisses aus). +// +// Sicherheitsnetz für Akzeptanzkriterium 3 ("mindestens ein aktueller +// Snapshot bleibt erhalten"): die NEUESTE Generation wird IMMER +// behalten, unabhängig von der konfigurierten Staffelung — selbst bei +// versehentlich auf 0 gesetzter Policy geht nie die letzte Sicherung +// verloren. +func PruneTiered(generations []string, policy RetentionPolicy) (keep, remove []string) { + type parsed struct { + id string + t time.Time + } + var items []parsed + for _, id := range generations { + t, err := time.Parse("20060102T150405Z", id) + if err != nil { + continue // unbekanntes Format wird ignoriert, nicht geloescht + } + items = append(items, parsed{id: id, t: t}) + } + sort.Slice(items, func(i, j int) bool { return items[i].t.After(items[j].t) }) // neueste zuerst + + keepSet := make(map[string]bool) + if len(items) > 0 { + keepSet[items[0].id] = true // Sicherheitsnetz: immer die neueste + } + + applyTier := func(bucketKey func(time.Time) string, n int) { + if n <= 0 { + return + } + seen := make(map[string]bool) + count := 0 + for _, it := range items { + if count >= n { + break + } + key := bucketKey(it.t) + if seen[key] { + continue + } + seen[key] = true + count++ + keepSet[it.id] = true + } + } + + applyTier(func(t time.Time) string { return t.Format("2006-01-02") }, policy.KeepDaily) + applyTier(func(t time.Time) string { + y, w := t.ISOWeek() + return fmt.Sprintf("%04d-W%02d", y, w) + }, policy.KeepWeekly) + applyTier(func(t time.Time) string { return t.Format("2006-01") }, policy.KeepMonthly) + + for _, it := range items { + if keepSet[it.id] { + keep = append(keep, it.id) + } else { + remove = append(remove, it.id) + } + } + return keep, remove +} diff --git a/archive/internal/backup/retention_test.go b/archive/internal/backup/retention_test.go new file mode 100644 index 0000000..00ee889 --- /dev/null +++ b/archive/internal/backup/retention_test.go @@ -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") + } +} diff --git a/archive/internal/backup/rotate.go b/archive/internal/backup/rotate.go index bf31ab0..3b773b4 100644 --- a/archive/internal/backup/rotate.go +++ b/archive/internal/backup/rotate.go @@ -54,3 +54,23 @@ func Rotate(backupDir string, keep int) (removed []string, err error) { } return removed, nil } + +// PruneRotate wendet BAK-07s gestaffelte RetentionPolicy (PruneTiered, +// siehe retention.go) tatsächlich an — entfernt alle nicht mehr zu +// behaltenden Generationsverzeichnisse. Automatisierte Löschung nach +// Ablauf der Frist (Akzeptanzkriterium 2), unabhängig von RET-02s +// Dokumenten-Retention. +func PruneRotate(backupDir string, policy RetentionPolicy) (removed []string, err error) { + generations, err := ListGenerations(backupDir) + if err != nil { + return nil, err + } + _, toRemove := PruneTiered(generations, policy) + for _, gen := range toRemove { + if err := os.RemoveAll(filepath.Join(backupDir, gen)); err != nil { + return removed, fmt.Errorf("backup: generation %q entfernen: %w", gen, err) + } + removed = append(removed, gen) + } + return removed, nil +} diff --git a/archive/internal/objectbackup/restic.go b/archive/internal/objectbackup/restic.go index 300b5e2..d4bedfb 100644 --- a/archive/internal/objectbackup/restic.go +++ b/archive/internal/objectbackup/restic.go @@ -137,6 +137,42 @@ func Forget(ctx context.Context, cfg Config, keepLast int) error { return nil } +// RetentionPolicy ist eine gestaffelte Aufbewahrungsfrist nach +// restic/Borg-Vorbild (BAK-07, unabhängig von RET-02s Dokumenten- +// Retention): täglich/wöchentlich/monatlich je eine konfigurierbare +// Anzahl Snapshots behalten, statt einer flachen "letzte N"-Regel +// (Forget/keepLast) oder hart codierter Staffelung. Ein Feldwert 0 +// bedeutet "diese Stufe nicht anwenden" (restics eigene Konvention bei +// `--keep-*`). +type RetentionPolicy struct { + KeepDaily int + KeepWeekly int + KeepMonthly int +} + +// PruneTiered wendet policy über restics NATIVE `--keep-daily`/ +// `--keep-weekly`/`--keep-monthly`-Staffelung an (kein Eigenbau der +// Bucket-Logik — restic beherrscht das bereits robust) und gibt den +// belegten Speicherplatz nicht mehr referenzierter Daten frei +// (`--prune`). Snapshots, die keiner Stufe zugeordnet werden, entfallen +// automatisch — restics übliche GFS-Semantik. +func PruneTiered(ctx context.Context, cfg Config, policy RetentionPolicy) error { + args := []string{"forget", "--prune"} + if policy.KeepDaily > 0 { + args = append(args, "--keep-daily", fmt.Sprintf("%d", policy.KeepDaily)) + } + if policy.KeepWeekly > 0 { + args = append(args, "--keep-weekly", fmt.Sprintf("%d", policy.KeepWeekly)) + } + if policy.KeepMonthly > 0 { + args = append(args, "--keep-monthly", fmt.Sprintf("%d", policy.KeepMonthly)) + } + if _, err := run(ctx, cfg, args...); err != nil { + return fmt.Errorf("objectbackup: gestaffelte rotation: %w", err) + } + return nil +} + // Restore stellt snapshotID nach targetDir wieder her (`restic restore`). // targetDir muss bereits existieren; Atomarität gegenüber einem eventuell // nicht-leeren ENDZIEL ist Aufgabe von internal/restore, nicht dieser diff --git a/archive/internal/objectbackup/retention_test.go b/archive/internal/objectbackup/retention_test.go new file mode 100644 index 0000000..75b6050 --- /dev/null +++ b/archive/internal/objectbackup/retention_test.go @@ -0,0 +1,125 @@ +package objectbackup + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +// backupAt erstellt einen Snapshot mit ERZWUNGENEM Zeitstempel +// (restics `backup --time`) - so lassen sich mehrere Altersstufen real +// simulieren, ohne tatsaechlich tagelang zu warten. +func backupAt(t *testing.T, cfg Config, sourceDir string, at time.Time) { + t.Helper() + // restics --time erwartet SEIN EIGENES Format ("2006-01-02 15:04:05"), + // nicht RFC3339 - real erst hier festgestellt (restic verweigert + // RFC3339 mit einem Parse-Fehler). + output, err := run(context.Background(), cfg, "backup", sourceDir, "--time", at.Format("2006-01-02 15:04:05"), "--json") + if err != nil { + t.Fatalf("backup (zeitstempel %s): %v (ausgabe: %s)", at, err, output) + } +} + +// TestPruneTiered_KeepsCorrectStaggering_RealSnapshots ist Pruefung 1 +// fuer den Objekt-Storage-Teil: reale, gestaffelt datierte Snapshots, +// restics eigene Staffelung liefert die erwartete Anzahl verbleibender +// Snapshots. +func TestPruneTiered_KeepsCorrectStaggering_RealSnapshots(t *testing.T) { + cfg := setupTest(t) + sourceDir := t.TempDir() + writeFile(t, sourceDir, "datei.txt", "inhalt") + + now := time.Now().UTC() + for i := 0; i < 10; i++ { + backupAt(t, cfg, sourceDir, now.Add(-time.Duration(i)*24*time.Hour)) + } + if got, err := SnapshotCount(context.Background(), cfg); err != nil || got != 10 { + t.Fatalf("erwartet 10 snapshots vor dem prune, habe %d (err=%v)", got, err) + } + + if err := PruneTiered(context.Background(), cfg, RetentionPolicy{KeepDaily: 3}); err != nil { + t.Fatalf("prunetiered: %v", err) + } + + got, err := SnapshotCount(context.Background(), cfg) + if err != nil { + t.Fatalf("snapshotcount: %v", err) + } + if got != 3 { + t.Fatalf("erwartet 3 verbleibende snapshots nach KeepDaily=3, habe %d", got) + } +} + +// TestPruneTiered_KeptSnapshotStillFullyRestorable ist der reale Beweis +// fuer Akzeptanzkriterium 3 (Pruefung 3, Legal-Hold-Analog): Pruning +// aelterer Snapshots darf niemals Daten beschaedigen, die ein +// VERBLEIBENDER (aktueller) Snapshot noch braucht - genau die +// Eigenschaft, die verhindert, dass Pruning mit einer laufenden +// Dokumenten-Aufbewahrungssperre kollidiert: solange das Dokument in +// einem behaltenen Snapshot steckt, bleibt es nach dem Pruning +// vollstaendig wiederherstellbar. +func TestPruneTiered_KeptSnapshotStillFullyRestorable(t *testing.T) { + cfg := setupTest(t) + sourceDir := t.TempDir() + content := "unveraendertes dokument, muss nach dem pruning vollstaendig lesbar bleiben" + writeFile(t, sourceDir, "gehaltenes-dokument.txt", content) + + now := time.Now().UTC() + // mehrere aeltere snapshots desselben unveraenderten inhalts (dedupliziert) + for i := 5; i >= 1; i-- { + backupAt(t, cfg, sourceDir, now.Add(-time.Duration(i)*24*time.Hour)) + } + // EIN aktueller, zu behaltender snapshot + backupAt(t, cfg, sourceDir, now) + + // erwarte 5 aeltere zu entfernen, den juengsten zu behalten + if err := PruneTiered(context.Background(), cfg, RetentionPolicy{KeepDaily: 1}); err != nil { + t.Fatalf("prunetiered: %v", err) + } + remaining, err := SnapshotCount(context.Background(), cfg) + if err != nil { + t.Fatalf("snapshotcount: %v", err) + } + if remaining != 1 { + t.Fatalf("erwartet 1 verbleibenden snapshot, habe %d", remaining) + } + + latestID := latestSnapshotShortID(t, cfg) + targetDir := t.TempDir() + if err := Restore(context.Background(), cfg, latestID, targetDir); err != nil { + t.Fatalf("restore nach pruning: %v", err) + } + got, err := os.ReadFile(filepath.Join(targetDir, sourceDir, "gehaltenes-dokument.txt")) + if err != nil { + t.Fatalf("wiederhergestelltes dokument lesen: %v", err) + } + if string(got) != content { + t.Fatalf("dokumentinhalt nach pruning beschaedigt: %q, want %q", got, content) + } + + // Zusaetzlich: restics eigene Integritaetspruefung bestaetigt, dass + // das Pruning kein von behaltenen Snapshots benoetigtes Datenblock + // entfernt hat. + if err := Check(context.Background(), cfg, true); err != nil { + t.Fatalf("check nach pruning: %v", err) + } +} + +func latestSnapshotShortID(t *testing.T, cfg Config) string { + t.Helper() + output, err := run(context.Background(), cfg, "snapshots", "--json") + if err != nil { + t.Fatalf("snapshots: %v", err) + } + var snapshots []snapshotEntry + if err := json.Unmarshal(output, &snapshots); err != nil { + t.Fatalf("snapshot-liste dekodieren: %v", err) + } + if len(snapshots) == 0 { + t.Fatal("keine snapshots vorhanden") + } + return snapshots[len(snapshots)-1].ShortID +}