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.
119 lines
3.6 KiB
Go
119 lines
3.6 KiB
Go
package backup
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// ErrCorrupted wird geliefert, wenn eine Sicherungsdatei nicht lesbar ist
|
|
// (Akzeptanzkriterium 2: Verifikation, nicht nur Erstellungs-Prüfung).
|
|
var ErrCorrupted = fmt.Errorf("backup: sicherungsdatei ist beschaedigt oder unvollstaendig")
|
|
|
|
// Verify prüft, dass base.tar.gz UND pg_wal.tar.gz im gegebenen
|
|
// Sicherungsverzeichnis vollständig lesbar sind — öffnet gzip- UND
|
|
// tar-Stream und liest JEDEN Eintrag bis zum Ende durch (nicht nur die
|
|
// Kopfdaten), damit ein abgeschnittener oder mit kaputten Bytes
|
|
// überschriebener Inhalt zuverlässig auffällt, nicht nur ein defekter
|
|
// Tar-Header. pg_wal.tar.gz wird geprüft, weil ohne intaktes WAL kein
|
|
// crash-konsistenter Restore möglich ist (siehe Restore/WalTarGzFile) —
|
|
// eine Sicherung mit beschädigtem WAL-Archiv wäre sonst unbemerkt
|
|
// unbrauchbar.
|
|
func Verify(backupDir string) error {
|
|
if err := verifyTarGz(filepath.Join(backupDir, BaseTarGzFile)); err != nil {
|
|
return err
|
|
}
|
|
return verifyTarGz(filepath.Join(backupDir, WalTarGzFile))
|
|
}
|
|
|
|
func verifyTarGz(path string) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %s nicht lesbar: %v", ErrCorrupted, path, err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
gz, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: gzip-header von %s ungueltig: %v", ErrCorrupted, path, err)
|
|
}
|
|
defer func() { _ = gz.Close() }()
|
|
|
|
tr := tar.NewReader(gz)
|
|
entries := 0
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("%w: tar-eintrag in %s ungueltig: %v", ErrCorrupted, path, err)
|
|
}
|
|
if _, err := io.Copy(io.Discard, tr); err != nil {
|
|
return fmt.Errorf("%w: inhalt von %q in %s nicht vollstaendig lesbar: %v", ErrCorrupted, hdr.Name, path, err)
|
|
}
|
|
entries++
|
|
}
|
|
if entries == 0 {
|
|
return fmt.Errorf("%w: %s enthaelt keine eintraege", ErrCorrupted, path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extractTarGz entpackt tarGzPath vollständig nach destDir — genutzt von
|
|
// Restore, um die TAR+GZIP-Sicherungsstufen (siehe Verify) in das
|
|
// PLAIN-Format zu überführen, das pg_combinebackup als Eingabe erwartet.
|
|
func extractTarGz(tarGzPath, destDir string) error {
|
|
f, err := os.Open(tarGzPath)
|
|
if err != nil {
|
|
return fmt.Errorf("%s öffnen: %w", tarGzPath, err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
gz, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
return fmt.Errorf("gzip-header ungueltig: %w", err)
|
|
}
|
|
defer func() { _ = gz.Close() }()
|
|
|
|
tr := tar.NewReader(gz)
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("tar-eintrag lesen: %w", err)
|
|
}
|
|
target := filepath.Join(destDir, filepath.Clean(hdr.Name))
|
|
switch hdr.Typeflag {
|
|
case tar.TypeDir:
|
|
if err := os.MkdirAll(target, 0o750); err != nil {
|
|
return fmt.Errorf("verzeichnis %q anlegen: %w", target, err)
|
|
}
|
|
case tar.TypeReg:
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
|
return fmt.Errorf("übergeordnetes verzeichnis von %q anlegen: %w", target, err)
|
|
}
|
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode))
|
|
if err != nil {
|
|
return fmt.Errorf("datei %q anlegen: %w", target, err)
|
|
}
|
|
if _, err := io.Copy(out, tr); err != nil {
|
|
_ = out.Close()
|
|
return fmt.Errorf("datei %q schreiben: %w", target, err)
|
|
}
|
|
if err := out.Close(); err != nil {
|
|
return fmt.Errorf("datei %q schliessen: %w", target, err)
|
|
}
|
|
default:
|
|
// Symlinks/Sonderdateien: pg_basebackup-Archive enthalten
|
|
// praktisch keine, übersprungen statt Restore abzubrechen.
|
|
}
|
|
}
|
|
return nil
|
|
}
|