feat(archive): BAK-03 Restore-Verfahren fuer Datenbank und Objekt-Storage
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.
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -21,12 +22,13 @@ import (
|
||||
// über Umgebungsvariablen befüllt, nie im Code (siehe Ticket-Abschluss-
|
||||
// Regel).
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
BackupDir string
|
||||
PgBaseBackupPath string // Default "pg_basebackup", überschreibbar für Tests
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
BackupDir string
|
||||
PgBaseBackupPath string // Default "pg_basebackup", überschreibbar für Tests
|
||||
PgCombineBackupPath string // Default "pg_combinebackup", überschreibbar für Tests
|
||||
}
|
||||
|
||||
func (c Config) binary() string {
|
||||
@@ -43,6 +45,13 @@ const (
|
||||
IncrementalSubdir = "incremental"
|
||||
BackupManifestFile = "backup_manifest"
|
||||
BaseTarGzFile = "base.tar.gz"
|
||||
// WalTarGzFile: pg_basebackups Standard-WAL-Methode ist "stream" (WAL
|
||||
// wird waehrend der Sicherung parallel mitgestreamt) - bei -Ft/-z
|
||||
// landet dieser Strom in einer EIGENEN Archivdatei neben base.tar.gz.
|
||||
// Ohne dieses WAL ist die Sicherung NICHT crash-konsistent
|
||||
// wiederherstellbar (Postgres kann sonst keinen gueltigen Checkpoint
|
||||
// erreichen) - siehe Restore.
|
||||
WalTarGzFile = "pg_wal.tar.gz"
|
||||
)
|
||||
|
||||
// NewGenerationID liefert eine sortierbare, eindeutige Generation-Kennung
|
||||
@@ -91,6 +100,94 @@ func IncrementalBackup(ctx context.Context, cfg Config, generationID, incrementI
|
||||
return filepath.Join(dir, BackupManifestFile), nil
|
||||
}
|
||||
|
||||
// Restore kombiniert die Vollsicherung einer Generation mit allen ihren
|
||||
// Inkrementen (PostgreSQL 17s `pg_combinebackup`, das native Gegenstück zu
|
||||
// `pg_basebackup --incremental`) und schreibt das Ergebnis nach outputDir —
|
||||
// ein vollständiges, direkt startbares PGDATA-Verzeichnis. outputDir muss
|
||||
// bereits existieren und leer sein (pg_combinebackup-Vorgabe); Atomarität
|
||||
// gegenüber einem eventuell nicht-leeren ENDZIEL ist Aufgabe von
|
||||
// internal/restore, nicht dieser Funktion.
|
||||
//
|
||||
// pg_combinebackup erwartet PLAIN-Format-Eingabeverzeichnisse (Dateibaum),
|
||||
// FullBackup/IncrementalBackup speichern aber TAR+GZIP (`-Ft -z`, siehe
|
||||
// dort) — kleinere, einfacher zu prüfende Sicherungsdateien
|
||||
// (BAK-01-Entscheidung, hier NICHT verändert). Restore extrahiert daher
|
||||
// jede Stufe zunächst in ein eigenes temporäres Plain-Verzeichnis, bevor
|
||||
// pg_combinebackup darauf zugreift.
|
||||
func Restore(ctx context.Context, cfg Config, generationID, outputDir string) error {
|
||||
genDir := filepath.Join(cfg.BackupDir, generationID)
|
||||
tarDirs := []string{filepath.Join(genDir, FullBackupDirName)}
|
||||
|
||||
incrDir := filepath.Join(genDir, IncrementalSubdir)
|
||||
entries, err := os.ReadDir(incrDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup: inkrement-verzeichnis lesen: %w", err)
|
||||
}
|
||||
incrementIDs := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
incrementIDs = append(incrementIDs, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(incrementIDs) // Inkrement-IDs sind wie Generation-IDs chronologisch sortierbar
|
||||
for _, id := range incrementIDs {
|
||||
tarDirs = append(tarDirs, filepath.Join(incrDir, id))
|
||||
}
|
||||
|
||||
extractRoot, err := os.MkdirTemp("", "backup-restore-extract-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: extraktions-verzeichnis anlegen: %w", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(extractRoot) }()
|
||||
|
||||
inputs := make([]string, 0, len(tarDirs))
|
||||
for i, tarDir := range tarDirs {
|
||||
plainDir := filepath.Join(extractRoot, fmt.Sprintf("%02d", i))
|
||||
if err := os.MkdirAll(plainDir, 0o700); err != nil {
|
||||
return fmt.Errorf("backup: plain-verzeichnis anlegen: %w", err)
|
||||
}
|
||||
if err := extractTarGz(filepath.Join(tarDir, BaseTarGzFile), plainDir); err != nil {
|
||||
return fmt.Errorf("backup: %q extrahieren: %w", tarDir, err)
|
||||
}
|
||||
// backup_manifest liegt NEBEN base.tar.gz (von pg_basebackup so
|
||||
// geschrieben), nicht im Archiv selbst - pg_combinebackup braucht
|
||||
// es zusätzlich im Plain-Verzeichnis.
|
||||
manifestData, err := os.ReadFile(filepath.Join(tarDir, BackupManifestFile))
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: %q lesen: %w", filepath.Join(tarDir, BackupManifestFile), err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(plainDir, BackupManifestFile), manifestData, 0o600); err != nil {
|
||||
return fmt.Errorf("backup: manifest nach %q kopieren: %w", plainDir, err)
|
||||
}
|
||||
inputs = append(inputs, plainDir)
|
||||
}
|
||||
|
||||
binary := "pg_combinebackup"
|
||||
if cfg.PgCombineBackupPath != "" {
|
||||
binary = cfg.PgCombineBackupPath
|
||||
}
|
||||
args := append(append([]string{}, inputs...), "-o", outputDir)
|
||||
cmd := exec.CommandContext(ctx, binary, args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: %s fehlgeschlagen: %w (ausgabe: %s)", binary, err, string(output))
|
||||
}
|
||||
|
||||
// pg_combinebackup rekonstruiert nur die Datendateien - das fuer einen
|
||||
// konsistenten Start noetige WAL kommt aus der ZULETZT gezogenen Stufe
|
||||
// (letztes Inkrement, sonst die Vollsicherung), nicht aus allen Stufen
|
||||
// zusammen (siehe WalTarGzFile-Dokumentation).
|
||||
lastTarDir := tarDirs[len(tarDirs)-1]
|
||||
walDir := filepath.Join(outputDir, "pg_wal")
|
||||
if err := os.MkdirAll(walDir, 0o700); err != nil {
|
||||
return fmt.Errorf("backup: pg_wal-verzeichnis anlegen: %w", err)
|
||||
}
|
||||
if err := extractTarGz(filepath.Join(lastTarDir, WalTarGzFile), walDir); err != nil {
|
||||
return fmt.Errorf("backup: WAL aus %q wiederherstellen: %w", lastTarDir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPgBaseBackup(ctx context.Context, cfg Config, args []string) error {
|
||||
cmd := exec.CommandContext(ctx, cfg.binary(), args...)
|
||||
cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestRestore_CombinesIntoStartablePostgresInstance ist Pruefung 1 fuer
|
||||
// BAK-03 (Datenbank-Teil): Restore auf leerem System vollstaendig und
|
||||
// erfolgreich durchgefuehrt — real bewiesen, indem das wiederhergestellte
|
||||
// Verzeichnis tatsaechlich als eigenstaendige Postgres-Instanz gestartet
|
||||
// und per echter Verbindung abgefragt wird, nicht nur auf Dateiexistenz
|
||||
// geprueft.
|
||||
func TestRestore_CombinesIntoStartablePostgresInstance(t *testing.T) {
|
||||
cfg := requireTestConfig(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := exec.LookPath("pg_combinebackup"); err != nil {
|
||||
t.Skip("pg_combinebackup nicht installiert, restore-integrationstest uebersprungen")
|
||||
}
|
||||
pgCtl, err := exec.LookPath("pg_ctl")
|
||||
if err != nil {
|
||||
t.Skip("pg_ctl nicht installiert, restore-integrationstest uebersprungen")
|
||||
}
|
||||
|
||||
genID := NewGenerationID(time.Now())
|
||||
if _, err := FullBackup(ctx, cfg, genID); err != nil {
|
||||
t.Fatalf("fullbackup: %v", err)
|
||||
}
|
||||
|
||||
restoreOut := filepath.Join(t.TempDir(), "restored-pgdata")
|
||||
if err := os.MkdirAll(restoreOut, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Restore(ctx, cfg, genID, restoreOut); err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(restoreOut, "PG_VERSION")); err != nil {
|
||||
t.Fatalf("wiederhergestelltes verzeichnis ist kein gueltiges PGDATA (PG_VERSION fehlt): %v", err)
|
||||
}
|
||||
|
||||
// Auf diesem Debian-Postgres liegt postgresql.conf NICHT in PGDATA
|
||||
// (sondern in /etc/postgresql/17/main/) - pg_basebackup sichert daher
|
||||
// nur PGDATA-Inhalte, die Konfigurationsdatei fehlt im Restore
|
||||
// GENAUSO wie im echten Betriebs-Restore-Verfahren. Fuer den
|
||||
// End-zu-End-Nachweis (echte Daten wiederherstellbar) hier eine
|
||||
// minimale, ausschliesslich fuer den Testlauf gueltige Konfiguration
|
||||
// nachgereicht - dokumentiert als operativer Hinweis fuer ein echtes
|
||||
// Restore-Runbook, nicht Teil des Produktcodes.
|
||||
minimalConf := "listen_addresses = ''\n"
|
||||
if err := os.WriteFile(filepath.Join(restoreOut, "postgresql.conf"), []byte(minimalConf), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// dito pg_hba.conf - liegt auf Debian ebenfalls in /etc/postgresql,
|
||||
// nicht in PGDATA. Nur Unix-Socket-Verbindungen des lokalen Testlaufs
|
||||
// erlaubt (kein TCP, dieselbe Isolation wie listen_addresses='').
|
||||
hba := "local all all trust\n"
|
||||
if err := os.WriteFile(filepath.Join(restoreOut, "pg_hba.conf"), []byte(hba), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Konfigurationsdateien aus der Quellinstanz brachte pg_basebackup mit
|
||||
// (sie enthalten ggf. den alten Port/Unix-Socket) - fuer diesen Test
|
||||
// bewusst neuer Port und eigenes Socket-Verzeichnis, damit die
|
||||
// wiederhergestellte Instanz nicht mit der laufenden Test-Instanz
|
||||
// kollidiert.
|
||||
socketDir := t.TempDir()
|
||||
testPort := "55432"
|
||||
// WICHTIG: pg_ctl start foerdert postgres als Hintergrundprozess, der
|
||||
// die geerbten stdout/stderr-Pipes NICHT schliesst - CombinedOutput()
|
||||
// (das auf ein Pipe-EOF wartet) haengt sich daher auf, obwohl pg_ctl
|
||||
// selbst laengst zurueckgekehrt ist. Deshalb echte Logdatei statt Pipe
|
||||
// (Standard-pg_ctl-Muster), kein exec.Cmd.Stdout/-Stderr-Pipe-Zugriff.
|
||||
logFile := filepath.Join(t.TempDir(), "postgres.log")
|
||||
startCmd := exec.CommandContext(ctx, pgCtl, "start", "-D", restoreOut, "-w", "-t", "30", "-l", logFile,
|
||||
"-o", fmt.Sprintf("-p %s -k %s -c listen_addresses=''", testPort, socketDir))
|
||||
if err := startCmd.Run(); err != nil {
|
||||
logContent, _ := os.ReadFile(logFile)
|
||||
t.Fatalf("pg_ctl start (wiederhergestellte instanz): %v (log: %s)", err, string(logContent))
|
||||
}
|
||||
defer func() {
|
||||
stopCmd := exec.Command(pgCtl, "stop", "-D", restoreOut, "-m", "fast")
|
||||
_ = stopCmd.Run()
|
||||
}()
|
||||
|
||||
psqlOut, err := exec.CommandContext(ctx, "psql",
|
||||
"-h", socketDir, "-p", testPort, "-U", cfg.User, "-d", "postgres",
|
||||
"-tAc", "SELECT 1").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("echte verbindung zur wiederhergestellten instanz fehlgeschlagen: %v (ausgabe: %s)", err, string(psqlOut))
|
||||
}
|
||||
if strings.TrimSpace(string(psqlOut)) != "1" {
|
||||
t.Fatalf("unerwartete antwort von der wiederhergestellten instanz: %q", string(psqlOut))
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,23 @@ import (
|
||||
// (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 im gegebenen Sicherungsverzeichnis
|
||||
// vollständig lesbar ist — ö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.
|
||||
// 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 {
|
||||
path := filepath.Join(backupDir, BaseTarGzFile)
|
||||
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)
|
||||
@@ -28,7 +38,7 @@ func Verify(backupDir string) error {
|
||||
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: gzip-header ungueltig: %v", ErrCorrupted, err)
|
||||
return fmt.Errorf("%w: gzip-header von %s ungueltig: %v", ErrCorrupted, path, err)
|
||||
}
|
||||
defer func() { _ = gz.Close() }()
|
||||
|
||||
@@ -40,15 +50,69 @@ func Verify(backupDir string) error {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: tar-eintrag ungueltig: %v", ErrCorrupted, err)
|
||||
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 nicht vollstaendig lesbar: %v", ErrCorrupted, hdr.Name, err)
|
||||
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: archiv enthaelt keine eintraege", ErrCorrupted)
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user