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:
@@ -36,6 +36,16 @@ func loadConfig() backup.Config {
|
|||||||
return cfg
|
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) {
|
func latestManifest(backupDir string) (string, error) {
|
||||||
generations, err := backup.ListGenerations(backupDir)
|
generations, err := backup.ListGenerations(backupDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -91,6 +101,23 @@ func main() {
|
|||||||
fmt.Printf("inkrementelle sicherung %q erstellt und verifiziert: %s\n", incID, newManifest)
|
fmt.Printf("inkrementelle sicherung %q erstellt und verifiziert: %s\n", incID, newManifest)
|
||||||
|
|
||||||
case "rotate":
|
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
|
keep := 7
|
||||||
if v := os.Getenv("NEXARCH_BACKUP_KEEP_GENERATIONS"); v != "" {
|
if v := os.Getenv("NEXARCH_BACKUP_KEEP_GENERATIONS"); v != "" {
|
||||||
_, _ = fmt.Sscanf(v, "%d", &keep)
|
_, _ = fmt.Sscanf(v, "%d", &keep)
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ func loadConfig() objectbackup.Config {
|
|||||||
return cfg
|
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() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
log.Fatal("aufruf: objectbackup-cli <backup <quellverzeichnis>|check|rotate>")
|
log.Fatal("aufruf: objectbackup-cli <backup <quellverzeichnis>|check|rotate>")
|
||||||
@@ -58,6 +69,20 @@ func main() {
|
|||||||
fmt.Println("verifikation (mit vollstaendigem lesen) erfolgreich")
|
fmt.Println("verifikation (mit vollstaendigem lesen) erfolgreich")
|
||||||
|
|
||||||
case "rotate":
|
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
|
keep := 7
|
||||||
if v := os.Getenv("NEXARCH_OBJECTBACKUP_KEEP_SNAPSHOTS"); v != "" {
|
if v := os.Getenv("NEXARCH_OBJECTBACKUP_KEEP_SNAPSHOTS"); v != "" {
|
||||||
if n, err := strconv.Atoi(v); err == nil {
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
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
|
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`).
|
// Restore stellt snapshotID nach targetDir wieder her (`restic restore`).
|
||||||
// targetDir muss bereits existieren; Atomarität gegenüber einem eventuell
|
// targetDir muss bereits existieren; Atomarität gegenüber einem eventuell
|
||||||
// nicht-leeren ENDZIEL ist Aufgabe von internal/restore, nicht dieser
|
// 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