// 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 }