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:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user