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.
126 lines
4.4 KiB
Go
126 lines
4.4 KiB
Go
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
|
|
}
|