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.
200 lines
8.0 KiB
Go
200 lines
8.0 KiB
Go
// Package backup implementiert BAK-01: automatisierte, inkrementelle
|
|
// Sicherung der PostgreSQL-Datenbank per pg_basebackup (PostgreSQL 17s
|
|
// natives inkrementelles Backup über WAL-Summarization, siehe
|
|
// `summarize_wal`), mit Verifikation jeder Sicherung und
|
|
// generationsbasierter Rotation. Kein pg_dump-basierter Ansatz, weil
|
|
// pg_dump ausschließlich logische Vollsicherungen kennt — "inkrementell"
|
|
// im Sinne des Tickets erfordert das physische, WAL-summary-gestützte
|
|
// Verfahren aus PostgreSQL 17.
|
|
package backup
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// Config enthält die Verbindungsdaten für pg_basebackup — ausschließlich
|
|
// ü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
|
|
PgCombineBackupPath string // Default "pg_combinebackup", überschreibbar für Tests
|
|
}
|
|
|
|
func (c Config) binary() string {
|
|
if c.PgBaseBackupPath != "" {
|
|
return c.PgBaseBackupPath
|
|
}
|
|
return "pg_basebackup"
|
|
}
|
|
|
|
// FullBackupDirName/IncrementalDirName sind die festen Unterverzeichnis-
|
|
// namen je Generation.
|
|
const (
|
|
FullBackupDirName = "full"
|
|
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
|
|
// (RFC3339-artig, dateisystemtauglich) — Generationen werden anhand dieser
|
|
// Kennung chronologisch sortiert (Rotate, ListGenerations).
|
|
func NewGenerationID(t time.Time) string {
|
|
return t.UTC().Format("20060102T150405Z")
|
|
}
|
|
|
|
// FullBackup erstellt eine neue Vollsicherung (Akzeptanzkriterium 1) als
|
|
// eigene Generation. Liefert den Pfad zum backup_manifest, das spätere
|
|
// IncrementalBackup-Aufrufe als Referenz brauchen.
|
|
func FullBackup(ctx context.Context, cfg Config, generationID string) (manifestPath string, err error) {
|
|
dir := filepath.Join(cfg.BackupDir, generationID, FullBackupDirName)
|
|
if err := os.MkdirAll(filepath.Dir(dir), 0o750); err != nil {
|
|
return "", fmt.Errorf("backup: generationsverzeichnis anlegen: %w", err)
|
|
}
|
|
|
|
args := []string{
|
|
"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User,
|
|
"-D", dir, "-Ft", "-z", "--checkpoint=fast", "--no-password",
|
|
}
|
|
if err := runPgBaseBackup(ctx, cfg, args); err != nil {
|
|
return "", fmt.Errorf("backup: vollsicherung: %w", err)
|
|
}
|
|
return filepath.Join(dir, BackupManifestFile), nil
|
|
}
|
|
|
|
// IncrementalBackup erstellt eine inkrementelle Sicherung gegen die zuletzt
|
|
// bekannte Vollsicherung ODER die letzte Inkrement-Sicherung (priorManifestPath
|
|
// zeigt jeweils auf das backup_manifest der Referenz).
|
|
func IncrementalBackup(ctx context.Context, cfg Config, generationID, incrementID, priorManifestPath string) (manifestPath string, err error) {
|
|
dir := filepath.Join(cfg.BackupDir, generationID, IncrementalSubdir, incrementID)
|
|
if err := os.MkdirAll(filepath.Dir(dir), 0o750); err != nil {
|
|
return "", fmt.Errorf("backup: inkrement-verzeichnis anlegen: %w", err)
|
|
}
|
|
|
|
args := []string{
|
|
"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User,
|
|
"-D", dir, "-Ft", "-z", "--checkpoint=fast", "--no-password",
|
|
"--incremental=" + priorManifestPath,
|
|
}
|
|
if err := runPgBaseBackup(ctx, cfg, args); err != nil {
|
|
return "", fmt.Errorf("backup: inkrementelle sicherung: %w", err)
|
|
}
|
|
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)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("%s fehlgeschlagen: %w (ausgabe: %s)", cfg.binary(), err, string(output))
|
|
}
|
|
return nil
|
|
}
|