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
+27
View File
@@ -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)
+25
View File
@@ -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 <backup <quellverzeichnis>|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 {
+94
View File
@@ -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.
+89
View File
@@ -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
}
+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")
}
}
+20
View File
@@ -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
}
+36
View File
@@ -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
@@ -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
}