internal/restore: Atomarer Restore ueber Temp-Verzeichnis + Rename, nicht-leeres Ziel ohne -force bricht VOR jeder Beruehrung ab, JSONL- Protokoll jedes Laufs. Drei reale Defekte beim Bau gefunden und behoben: pg_combinebackup braucht Plain- statt Tar-Format (Extraktionsschritt ergaenzt), pg_wal.tar.gz wurde nie verifiziert/wiederhergestellt (BAK-01s Verify jetzt erweitert), Go-exec haengt bei pg_ctl start wegen vererbter Pipes (Testfix: echte Logdatei statt CombinedOutput). Beide Restore-Pfade real auf 131 ueber restore-cli nachgewiesen, inkl. echtem Postgres-Start aus wiederhergestelltem Verzeichnis.
182 lines
6.1 KiB
Go
182 lines
6.1 KiB
Go
package restore
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func readLog(t *testing.T, path string) []LogEntry {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("protokolldatei lesen: %v", err)
|
|
}
|
|
var entries []LogEntry
|
|
for _, line := range splitLines(data) {
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var e LogEntry
|
|
if err := json.Unmarshal(line, &e); err != nil {
|
|
t.Fatalf("protokollzeile dekodieren: %v (%s)", err, line)
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func splitLines(data []byte) [][]byte {
|
|
var out [][]byte
|
|
start := 0
|
|
for i, b := range data {
|
|
if b == '\n' {
|
|
out = append(out, data[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestAtomicRestore_EmptyTarget_Succeeds ist Pruefung 1: Restore auf
|
|
// leerem (nicht vorhandenem) Ziel vollstaendig erfolgreich.
|
|
func TestAtomicRestore_EmptyTarget_Succeeds(t *testing.T) {
|
|
root := t.TempDir()
|
|
target := filepath.Join(root, "ziel")
|
|
logPath := filepath.Join(root, "restore.log")
|
|
|
|
entry, err := AtomicRestore(KindDatabase, "gen-1", target, false, logPath, func(tempDir string) error {
|
|
return os.WriteFile(filepath.Join(tempDir, "marker"), []byte("wiederhergestellt"), 0o600)
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("atomicRestore: %v", err)
|
|
}
|
|
if entry.Result != "ok" {
|
|
t.Fatalf("result = %q, want ok", entry.Result)
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(target, "marker"))
|
|
if err != nil || string(content) != "wiederhergestellt" {
|
|
t.Fatalf("ziel nicht korrekt befuellt: %v %q", err, content)
|
|
}
|
|
}
|
|
|
|
// TestAtomicRestore_NonEmptyTargetWithoutForce_LeavesOriginalUntouched ist
|
|
// Pruefung 2: Restore auf nicht-leeres Zielverzeichnis laesst bei Abbruch
|
|
// (keine Bestaetigung) den urspruenglichen Inhalt unveraendert.
|
|
func TestAtomicRestore_NonEmptyTargetWithoutForce_LeavesOriginalUntouched(t *testing.T) {
|
|
root := t.TempDir()
|
|
target := filepath.Join(root, "ziel")
|
|
logPath := filepath.Join(root, "restore.log")
|
|
if err := os.MkdirAll(target, 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(target, "bestehend"), []byte("original"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
restoreFnCalled := false
|
|
_, err := AtomicRestore(KindObjects, "snap-1", target, false, logPath, func(tempDir string) error {
|
|
restoreFnCalled = true
|
|
return nil
|
|
})
|
|
if err != ErrTargetNotEmpty {
|
|
t.Fatalf("erwartet ErrTargetNotEmpty, habe: %v", err)
|
|
}
|
|
if restoreFnCalled {
|
|
t.Fatal("restoreFn haette bei nicht-leerem ziel ohne force NIE aufgerufen werden duerfen")
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(target, "bestehend"))
|
|
if err != nil || string(content) != "original" {
|
|
t.Fatalf("urspruenglicher inhalt veraendert: %v %q", err, content)
|
|
}
|
|
}
|
|
|
|
// TestAtomicRestore_FailedRestoreFn_LeavesTargetUntouched ist Nachweis,
|
|
// dass ein Fehler WAEHREND der Wiederherstellung (in tempDir) das
|
|
// bestehende Ziel nicht beschaedigt, weil erst nach Erfolg umbenannt wird.
|
|
func TestAtomicRestore_FailedRestoreFn_LeavesTargetUntouched(t *testing.T) {
|
|
root := t.TempDir()
|
|
target := filepath.Join(root, "ziel")
|
|
logPath := filepath.Join(root, "restore.log")
|
|
if err := os.MkdirAll(target, 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(target, "bestehend"), []byte("original"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := AtomicRestore(KindDatabase, "gen-2", target, true, logPath, func(tempDir string) error {
|
|
return os.ErrInvalid // simuliert fehlgeschlagene wiederherstellung
|
|
})
|
|
if err == nil {
|
|
t.Fatal("erwartet fehler")
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(target, "bestehend"))
|
|
if err != nil || string(content) != "original" {
|
|
t.Fatalf("ziel bei fehlgeschlagenem restoreFn veraendert: %v %q", err, content)
|
|
}
|
|
}
|
|
|
|
// TestAtomicRestore_ForceOverwritesNonEmptyTarget ist Nachweis, dass eine
|
|
// BEWUSSTE Bestaetigung (force) bestehenden Inhalt ersetzen darf.
|
|
func TestAtomicRestore_ForceOverwritesNonEmptyTarget(t *testing.T) {
|
|
root := t.TempDir()
|
|
target := filepath.Join(root, "ziel")
|
|
logPath := filepath.Join(root, "restore.log")
|
|
if err := os.MkdirAll(target, 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(target, "alt"), []byte("alt"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := AtomicRestore(KindObjects, "snap-2", target, true, logPath, func(tempDir string) error {
|
|
return os.WriteFile(filepath.Join(tempDir, "neu"), []byte("neu"), 0o600)
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("atomicRestore mit force: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(target, "alt")); !os.IsNotExist(err) {
|
|
t.Fatal("alter inhalt haette nach force-restore ersetzt sein muessen")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(target, "neu")); err != nil {
|
|
t.Fatalf("neuer inhalt fehlt: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestAtomicRestore_LogsCompleteEntry ist Pruefung 3: Protokolleintrag
|
|
// ist vollstaendig und nachvollziehbar (Quelle, Zeitpunkt, Ergebnis) -
|
|
// sowohl fuer Erfolg als auch fuer Abbruch, in derselben Datei.
|
|
func TestAtomicRestore_LogsCompleteEntry(t *testing.T) {
|
|
root := t.TempDir()
|
|
logPath := filepath.Join(root, "restore.log")
|
|
|
|
okTarget := filepath.Join(root, "ok-ziel")
|
|
if _, err := AtomicRestore(KindDatabase, "gen-3", okTarget, false, logPath, func(tempDir string) error {
|
|
return os.WriteFile(filepath.Join(tempDir, "x"), []byte("x"), 0o600)
|
|
}); err != nil {
|
|
t.Fatalf("erfolgreicher restore: %v", err)
|
|
}
|
|
|
|
blockedTarget := filepath.Join(root, "blockiert-ziel")
|
|
if err := os.MkdirAll(blockedTarget, 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(blockedTarget, "bestehend"), []byte("y"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _ = AtomicRestore(KindObjects, "snap-3", blockedTarget, false, logPath, func(tempDir string) error { return nil })
|
|
|
|
entries := readLog(t, logPath)
|
|
if len(entries) != 2 {
|
|
t.Fatalf("erwartet 2 protokolleintraege, habe %d: %+v", len(entries), entries)
|
|
}
|
|
if entries[0].Source != "gen-3" || entries[0].Kind != KindDatabase || entries[0].Result != "ok" || entries[0].Timestamp.IsZero() {
|
|
t.Fatalf("erster eintrag unvollstaendig: %+v", entries[0])
|
|
}
|
|
if entries[1].Source != "snap-3" || entries[1].Kind != KindObjects || entries[1].Result == "" || entries[1].Timestamp.IsZero() {
|
|
t.Fatalf("zweiter eintrag unvollstaendig: %+v", entries[1])
|
|
}
|
|
}
|