Neues Modul-Verzeichnis code/archive/ (Monorepo-Muster wie code/dms/). PostgreSQL-17-natives inkrementelles Backup (pg_basebackup --incremental, WAL-Summarization) statt WAL-Archiving, um den geteilten Testhost ohne Neustart umzustellen (summarize_wal=on per pg_reload_conf). Rolle nexarch_backup mit REPLICATION-Attribut angelegt. internal/backup: FullBackup/IncrementalBackup (pg_basebackup-Wrapper), Verify (vollstaendiges Lesen von base.tar.gz, gzip+tar, nicht nur Header), Rotate/ListGenerations (generationsbasiert, aeltere zuerst entfernt). cmd/backup-cli fuer systemd-Timer-Aufruf (deploy/systemd/ nexarch-archive-backup-*.timer, taeglich/stuendlich/taeglich). Auf 192.168.1.131 verifiziert: 4/4 Tests gegen echte Postgres-17-Instanz (kein Mock) - inkrementelle Sicherung real kleiner als Vollsicherung, Verifikation erkennt absichtlich beschaedigte Datei, Rotation entfernt nur die aeltesten Generationen. Zusaetzlich ECHT verdrahtet: backup-cli gebaut, 3 systemd-Timer installiert+aktiviert, jeder der drei Dienste einmal ueber systemctl start end-to-end ausgeloest (status=0/SUCCESS je Dienst) - nicht nur go test, sondern der reale Automatisierungspfad selbst geprueft. Siehe archive/docs/BAK-01-PRUEFPROTOKOLL.md fuer alle Pruefungsergebnisse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
188 lines
5.4 KiB
Go
188 lines
5.4 KiB
Go
package backup
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func requireTestConfig(t *testing.T) Config {
|
|
t.Helper()
|
|
user := os.Getenv("TEST_BACKUP_PG_USER")
|
|
if user == "" {
|
|
t.Skip("TEST_BACKUP_PG_USER nicht gesetzt, Integrationstest uebersprungen (braucht echten Postgres mit REPLICATION-Rolle)")
|
|
}
|
|
return Config{
|
|
Host: envOr("TEST_BACKUP_PG_HOST", "localhost"),
|
|
Port: envOr("TEST_BACKUP_PG_PORT", "5432"),
|
|
User: user,
|
|
Password: os.Getenv("TEST_BACKUP_PG_PASSWORD"),
|
|
BackupDir: t.TempDir(),
|
|
}
|
|
}
|
|
|
|
func envOr(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// TestFullBackup_CreatesVerifiedBackup ist Pruefung 1: Sicherung gegen
|
|
// Testdatenbank erfolgreich erstellt und verifiziert.
|
|
func TestFullBackup_CreatesVerifiedBackup(t *testing.T) {
|
|
cfg := requireTestConfig(t)
|
|
ctx := context.Background()
|
|
|
|
genID := NewGenerationID(time.Now())
|
|
manifest, err := FullBackup(ctx, cfg, genID)
|
|
if err != nil {
|
|
t.Fatalf("fullbackup: %v", err)
|
|
}
|
|
if _, err := os.Stat(manifest); err != nil {
|
|
t.Fatalf("backup_manifest fehlt: %v", err)
|
|
}
|
|
|
|
dir := filepath.Dir(manifest)
|
|
if _, err := os.Stat(filepath.Join(dir, BaseTarGzFile)); err != nil {
|
|
t.Fatalf("%s fehlt: %v", BaseTarGzFile, err)
|
|
}
|
|
|
|
if err := Verify(dir); err != nil {
|
|
t.Fatalf("verify: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestIncrementalBackup_IsSmallerThanFull ist der Nachweis fuer
|
|
// Akzeptanzkriterium 1 (inkrementell): eine echte inkrementelle Sicherung
|
|
// gegen unveraenderten Bestand ist deutlich kleiner als die Vollsicherung —
|
|
// beweist, dass tatsaechlich nur Aenderungen uebertragen wurden (PostgreSQL
|
|
// 17 WAL-Summarization), nicht nochmal alles.
|
|
func TestIncrementalBackup_IsSmallerThanFull(t *testing.T) {
|
|
cfg := requireTestConfig(t)
|
|
ctx := context.Background()
|
|
|
|
genID := NewGenerationID(time.Now())
|
|
fullManifest, err := FullBackup(ctx, cfg, genID)
|
|
if err != nil {
|
|
t.Fatalf("fullbackup: %v", err)
|
|
}
|
|
fullDir := filepath.Dir(fullManifest)
|
|
fullSize := fileSize(t, filepath.Join(fullDir, BaseTarGzFile))
|
|
|
|
incID := NewGenerationID(time.Now().Add(time.Second))
|
|
incManifest, err := IncrementalBackup(ctx, cfg, genID, incID, fullManifest)
|
|
if err != nil {
|
|
t.Fatalf("incrementalbackup: %v", err)
|
|
}
|
|
incDir := filepath.Dir(incManifest)
|
|
if err := Verify(incDir); err != nil {
|
|
t.Fatalf("verify (inkrementell): %v", err)
|
|
}
|
|
incSize := fileSize(t, filepath.Join(incDir, BaseTarGzFile))
|
|
|
|
if incSize >= fullSize {
|
|
t.Fatalf("inkrementelle sicherung (%d bytes) ist nicht kleiner als die vollsicherung (%d bytes) - keine echte inkrementelle Uebertragung", incSize, fullSize)
|
|
}
|
|
}
|
|
|
|
func fileSize(t *testing.T, path string) int64 {
|
|
t.Helper()
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("dateigroesse von %q ermitteln: %v", path, err)
|
|
}
|
|
return info.Size()
|
|
}
|
|
|
|
// TestVerify_DetectsCorruptedFile ist Pruefung 2: Verifikation erkennt eine
|
|
// absichtlich beschaedigte Sicherungsdatei.
|
|
func TestVerify_DetectsCorruptedFile(t *testing.T) {
|
|
cfg := requireTestConfig(t)
|
|
ctx := context.Background()
|
|
|
|
genID := NewGenerationID(time.Now())
|
|
manifest, err := FullBackup(ctx, cfg, genID)
|
|
if err != nil {
|
|
t.Fatalf("fullbackup: %v", err)
|
|
}
|
|
dir := filepath.Dir(manifest)
|
|
|
|
if err := Verify(dir); err != nil {
|
|
t.Fatalf("verify (unbeschaedigt) haette erfolgreich sein muessen: %v", err)
|
|
}
|
|
|
|
// Absichtliche Beschaedigung: mehrere Bytes in der Mitte der Datei kippen.
|
|
path := filepath.Join(dir, BaseTarGzFile)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("sicherungsdatei lesen: %v", err)
|
|
}
|
|
mid := len(data) / 2
|
|
for i := mid; i < mid+64 && i < len(data); i++ {
|
|
data[i] ^= 0xFF
|
|
}
|
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
|
t.Fatalf("beschaedigte sicherungsdatei schreiben: %v", err)
|
|
}
|
|
|
|
if err := Verify(dir); err == nil {
|
|
t.Fatal("verify haette die beschaedigte sicherungsdatei erkennen muessen")
|
|
}
|
|
}
|
|
|
|
// TestRotate_RemovesOnlyOldestGenerations ist Pruefung 3.
|
|
func TestRotate_RemovesOnlyOldestGenerations(t *testing.T) {
|
|
backupDir := t.TempDir()
|
|
generationIDs := []string{
|
|
"20260101T000000Z",
|
|
"20260102T000000Z",
|
|
"20260103T000000Z",
|
|
"20260104T000000Z",
|
|
"20260105T000000Z",
|
|
}
|
|
for _, id := range generationIDs {
|
|
if err := os.MkdirAll(filepath.Join(backupDir, id, FullBackupDirName), 0o750); err != nil {
|
|
t.Fatalf("generation %q anlegen: %v", id, err)
|
|
}
|
|
}
|
|
|
|
removed, err := Rotate(backupDir, 2)
|
|
if err != nil {
|
|
t.Fatalf("rotate: %v", err)
|
|
}
|
|
wantRemoved := []string{"20260101T000000Z", "20260102T000000Z", "20260103T000000Z"}
|
|
if len(removed) != len(wantRemoved) {
|
|
t.Fatalf("entfernte generationen = %v, want %v", removed, wantRemoved)
|
|
}
|
|
for i, w := range wantRemoved {
|
|
if removed[i] != w {
|
|
t.Fatalf("entfernte generationen = %v, want %v", removed, wantRemoved)
|
|
}
|
|
}
|
|
|
|
remaining, err := ListGenerations(backupDir)
|
|
if err != nil {
|
|
t.Fatalf("listgenerations: %v", err)
|
|
}
|
|
wantRemaining := []string{"20260104T000000Z", "20260105T000000Z"}
|
|
if len(remaining) != len(wantRemaining) {
|
|
t.Fatalf("verbleibende generationen = %v, want %v", remaining, wantRemaining)
|
|
}
|
|
for i, w := range wantRemaining {
|
|
if remaining[i] != w {
|
|
t.Fatalf("verbleibende generationen = %v, want %v", remaining, wantRemaining)
|
|
}
|
|
}
|
|
|
|
// Die NEUESTEN duerfen NICHT entfernt sein (Pruefung 3: nur die
|
|
// aeltesten Generationen).
|
|
for _, w := range wantRemaining {
|
|
if _, err := os.Stat(filepath.Join(backupDir, w)); err != nil {
|
|
t.Fatalf("neueste generation %q wurde faelschlich entfernt: %v", w, err)
|
|
}
|
|
}
|
|
}
|