diff --git a/archive/cmd/restore-cli/main.go b/archive/cmd/restore-cli/main.go new file mode 100644 index 0000000..0515211 --- /dev/null +++ b/archive/cmd/restore-cli/main.go @@ -0,0 +1,104 @@ +// restore-cli ist der Aufrufpunkt fuer BAK-03: dokumentiertes, +// wiederholbares Restore-Verfahren fuer Datenbank (BAK-01) und +// Objekt-Storage (BAK-02). Kein systemd-Timer (Notfall-/Einzelfall- +// Werkzeug, kein Zeitplan) - manueller Aufruf mit expliziter Bestaetigung +// bei nicht-leerem Ziel. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/backup" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/objectbackup" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/restore" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + + logPath := os.Getenv("NEXARCH_RESTORE_LOG") + if logPath == "" { + logPath = "/var/nexarch-archiv/restore.log" + } + + switch os.Args[1] { + case "database": + runDatabase(logPath, os.Args[2:]) + case "objects": + runObjects(logPath, os.Args[2:]) + default: + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: restore-cli database [-force] ") + fmt.Fprintln(os.Stderr, " restore-cli objects [-force] ") + fmt.Fprintln(os.Stderr, "(Flag -force MUSS vor den Positionsargumenten stehen, Standardverhalten des Go-flag-Pakets)") +} + +func runDatabase(logPath string, args []string) { + fs := flag.NewFlagSet("database", flag.ExitOnError) + force := fs.Bool("force", false, "bestehenden, nicht-leeren Inhalt im Zielverzeichnis ersetzen") + if err := fs.Parse(args); err != nil { + os.Exit(2) + } + if fs.NArg() != 2 { + usage() + os.Exit(2) + } + generationID, target := fs.Arg(0), fs.Arg(1) + + cfg := backup.Config{ + Host: os.Getenv("NEXARCH_BACKUP_PG_HOST"), + Port: os.Getenv("NEXARCH_BACKUP_PG_PORT"), + User: os.Getenv("NEXARCH_BACKUP_PG_USER"), + Password: os.Getenv("NEXARCH_BACKUP_PG_PASSWORD"), + BackupDir: os.Getenv("NEXARCH_BACKUP_DIR"), + } + ctx := context.Background() + + entry, err := restore.AtomicRestore(restore.KindDatabase, generationID, target, *force, logPath, func(tempDir string) error { + return backup.Restore(ctx, cfg, generationID, tempDir) + }) + report(entry, err) +} + +func runObjects(logPath string, args []string) { + fs := flag.NewFlagSet("objects", flag.ExitOnError) + force := fs.Bool("force", false, "bestehenden, nicht-leeren Inhalt im Zielverzeichnis ersetzen") + if err := fs.Parse(args); err != nil { + os.Exit(2) + } + if fs.NArg() != 2 { + usage() + os.Exit(2) + } + snapshotID, target := fs.Arg(0), fs.Arg(1) + + cfg := objectbackup.Config{ + RepoDir: os.Getenv("NEXARCH_OBJECTBACKUP_REPO_DIR"), + Password: os.Getenv("NEXARCH_OBJECTBACKUP_PASSWORD"), + } + ctx := context.Background() + + entry, err := restore.AtomicRestore(restore.KindObjects, snapshotID, target, *force, logPath, func(tempDir string) error { + return objectbackup.Restore(ctx, cfg, snapshotID, tempDir) + }) + report(entry, err) +} + +func report(entry restore.LogEntry, err error) { + fmt.Printf("restore %s: quelle=%s ziel=%s ergebnis=%s\n", entry.Kind, entry.Source, entry.Target, entry.Result) + if err != nil { + log.Fatalf("restore fehlgeschlagen: %v", err) + } +} diff --git a/archive/docs/BAK-01-PRUEFPROTOKOLL.md b/archive/docs/BAK-01-PRUEFPROTOKOLL.md index 6810ab9..163efcb 100644 --- a/archive/docs/BAK-01-PRUEFPROTOKOLL.md +++ b/archive/docs/BAK-01-PRUEFPROTOKOLL.md @@ -85,3 +85,13 @@ nachweislich am neuen Ort. real erfüllt — inklusive tatsächlicher systemd-Timer-Installation und manuell ausgelöstem End-to-End-Lauf aller drei Dienste auf dem Testhost, nicht nur isolierter Testcode. + +## Nachtrag (BAK-03): Verify prüft jetzt auch pg_wal.tar.gz + +Beim Bau von BAK-03s echtem Restore-Test fiel auf, dass `pg_basebackup` +(Standard-WAL-Methode `stream`) bei `-Ft -z` NEBEN `base.tar.gz` eine +zweite Archivdatei `pg_wal.tar.gz` erzeugt, die `Verify` bislang nie +geprüft hat — eine Sicherung mit beschädigtem WAL-Archiv wäre unbemerkt +nicht crash-konsistent wiederherstellbar gewesen. `Verify` prüft seither +beide Archive vollständig (siehe `BAK-03-PRUEFPROTOKOLL.md`). Das +Sicherungsverfahren selbst (Format, Ort, Rotation) bleibt unverändert. diff --git a/archive/docs/BAK-03-PRUEFPROTOKOLL.md b/archive/docs/BAK-03-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..6fccf26 --- /dev/null +++ b/archive/docs/BAK-03-PRUEFPROTOKOLL.md @@ -0,0 +1,122 @@ +# BAK-03 – Prüfprotokoll: Restore-Verfahren + +Voraussetzungen BAK-01, BAK-02 – erledigt, siehe eigene Protokolle. + +## Grundsatzentscheidung: Atomarität über Temp-Verzeichnis + Rename + +`internal/restore.AtomicRestore` kennt weder Postgres noch restic — +reine Ablaufsteuerung: Wiederherstellung IMMER in ein frisches +temporäres Verzeichnis (nie direkt in das Ziel), Übernahme erst bei +Erfolg per `os.Rename` (selbes Dateisystem wie das Ziel, daher atomar). +Ist das Ziel nicht leer und keine Bestätigung (`-force`) gegeben, wird +NICHT einmal die Wiederherstellungsfunktion aufgerufen — Abbruch vor +jeder Berührung des Ziels (Akzeptanzkriterium 2). Jeder Aufruf — Erfolg, +Abbruch oder Fehler — erzeugt genau einen JSONL-Protokolleintrag +(Akzeptanzkriterium 3). + +Die eigentliche Wiederherstellung bleibt in `internal/backup.Restore` +(Datenbank) und `internal/objectbackup.Restore` (Objekt-Storage) — +`internal/restore` orchestriert nur. + +## Drei reale Defekte während der Implementierung gefunden und behoben + +Alle drei erst durch den ECHTEN Restore-Test (Postgres-Instanz tatsächlich +gestartet, nicht nur Dateien verglichen) aufgedeckt: + +1. **`pg_combinebackup` braucht Plain-Format, BAK-01 liefert Tar+Gzip.** + `Restore` extrahiert jetzt jede Sicherungsstufe zunächst in ein + temporäres Plain-Verzeichnis (inkl. `backup_manifest`-Kopie) und + speist erst DIESE in `pg_combinebackup`. BAK-01s Speicherformat selbst + unverändert (kleinere, leichter prüfbare Dateien). +2. **`pg_wal.tar.gz` wurde nie verifiziert oder wiederhergestellt.** + `pg_basebackup`s Standard-WAL-Methode (`stream`) erzeugt bei `-Ft -z` + eine ZWEITE Archivdatei neben `base.tar.gz` — ohne sie ist keine + crash-konsistente Wiederherstellung möglich (Postgres findet sonst + keinen gültigen Checkpoint). `backup.Verify` prüft jetzt BEIDE + Archive vollständig; `backup.Restore` extrahiert das WAL der ZULETZT + gezogenen Stufe (nicht aller Stufen) nach `pg_wal/` im + Wiederherstellungsziel. Ergänzung zu BAK-01s Prüfung, keine + Umstellung des Sicherungsverfahrens. +3. **Go-`exec`-Hänger bei `pg_ctl start`:** `pg_ctl` startet Postgres als + Hintergrundprozess, der die geerbten stdout/stderr-Pipes NICHT + schließt — `CombinedOutput()` (wartet auf Pipe-EOF) hängt sich + dadurch auf, obwohl `pg_ctl` selbst längst zurückgekehrt ist. Fix nur + im Testcode: echte Logdatei (`-l`) statt Pipe, Standard-`pg_ctl`-Muster. + +Zusätzlich real (nicht Ticket-relevant, aber notiert): auf diesem +Debian-Postgres liegen `postgresql.conf`/`pg_hba.conf` NICHT in PGDATA +(sondern `/etc/postgresql/17/main/`) — ein echtes Restore-Runbook muss +diese Dateien separat mitführen/rekonstruieren, `pg_basebackup` sichert +sie nicht. Für den Testnachweis minimal nachgereicht, kein Produktcode. + +## Umsetzung + +- `internal/restore.AtomicRestore` — Ablaufsteuerung, JSONL-Protokoll + (`Timestamp`, `Kind`, `Source`, `Target`, `Result`). +- `internal/backup.Restore` — `pg_combinebackup` gegen extrahierte + Plain-Verzeichnisse + WAL-Wiederherstellung der letzten Stufe. +- `internal/backup.extractTarGz` (in `verify.go`, neben der bestehenden + Tar/Gzip-Leselogik) — vollständige Extraktion, kein Kopf-only-Read. +- `internal/backup.Verify` — prüft jetzt `base.tar.gz` UND + `pg_wal.tar.gz` vollständig. +- `internal/objectbackup.Restore` — `restic restore --target`. +- `cmd/restore-cli` — `database`/`objects`-Unterbefehle, `-force`-Flag + (vor Positionsargumenten, Go-`flag`-Konvention), kein systemd-Timer + (Notfall-/Einzelfall-Werkzeug, kein Zeitplan). + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Restore auf leerem System vollständig und erfolgreich durchgeführt | **bestanden** — real: `TestRestore_CombinesIntoStartablePostgresInstance` (Postgres tatsächlich aus wiederhergestelltem Verzeichnis gestartet, `SELECT 1` über echte Verbindung beantwortet); zusätzlich real per `restore-cli database` auf 131 ausgeführt (PG_VERSION/base/pg_wal vorhanden, Exit 0); `TestRestore_RecoversRealContentFromSnapshot` (Objekt-Storage, echter Dateiinhalt verglichen) UND real per `restore-cli objects` auf 131 (echter restic-Snapshot wiederhergestellt) | +| 2 | Restore auf nicht-leeres Zielverzeichnis lässt bei Abbruch den ursprünglichen Inhalt unverändert | **bestanden** — `TestAtomicRestore_NonEmptyTargetWithoutForce_LeavesOriginalUntouched` (restoreFn wird nachweislich NIE aufgerufen) und `TestAtomicRestore_FailedRestoreFn_LeavesTargetUntouched`; real auf 131: `restore-cli database` gegen nicht-leeres Ziel ohne `-force` → Abbruch, bestehende Testdatei unverändert vorhanden | +| 3 | Protokolleintrag zum Restore ist vollständig und nachvollziehbar | **bestanden** — `TestAtomicRestore_LogsCompleteEntry` (Quelle, Ziel, Zeitpunkt, Ergebnis für Erfolgs- UND Abbruchfall in derselben Datei); real auf 131: `/tmp/restore-cli-test.log` zeigt alle vier realen Läufe (Abbruch, Fehler mangels PATH, Erfolg, Force-Überschreiben) korrekt protokolliert | + +Zusätzlich: `TestAtomicRestore_EmptyTarget_Succeeds`, +`TestAtomicRestore_ForceOverwritesNonEmptyTarget` (bewusste Bestätigung +ersetzt bestehenden Inhalt real, alter Inhalt nachweislich weg, neuer da). + +## Echte Verdrahtung auf 192.168.1.131 + +- `restore-cli` gebaut nach `/opt/nexarch-archive/bin/` +- Kein systemd-Timer (bewusst — Notfall-/Einzelfall-Werkzeug) +- Vier reale CLI-Läufe durchgeführt und protokolliert: Abbruch bei + nicht-leerem Ziel ohne `-force`, Fehler mangels `pg_combinebackup` im + PATH (zeigt: Fehler wird korrekt erkannt UND protokolliert, kein + stiller Fehlschlag), erfolgreicher Restore einer echten Generation + (`20260829T222054Z`) in leeres Ziel, erfolgreiches Überschreiben mit + `-force`. Objekt-Storage-Restore real gegen echten restic-Snapshot + (`43da36bf`) — Inhalt (`.placeholder`-Datei) tatsächlich vorhanden. +- Alle Testartefakte (`/tmp/restore-*`, Testlog) nach Prüfung entfernt. + +## Build/Test-Ergebnis + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +``` + +`internal/restore`: 5/5 Tests (reine Funktionen, kein Setup nötig). +`internal/objectbackup`: `TestRestore_RecoversRealContentFromSnapshot` +bestanden (zusammen mit den bestehenden BAK-02-Tests). +`internal/backup`: `TestRestore_CombinesIntoStartablePostgresInstance` +bestanden — **Hinweis**: dieser eine Test läuft NICHT als root +(`pg_ctl: cannot be run as root`) und braucht `pg_combinebackup`/ +`pg_ctl` im PATH (`/usr/lib/postgresql/17/bin` auf Debian, dort nicht +standardmäßig verlinkt) — daher separat als `postgres`-Systemnutzer mit +entsprechendem PATH ausgeführt, nicht Teil des root-`make check`-Laufs; +dort wird er mit klarer Meldung übersprungen (`pg_combinebackup nicht +installiert`), kein stiller Fehlschlag. Alle übrigen BAK-01-Tests +(inkl. der um `pg_wal.tar.gz` erweiterten `Verify`) liefen unverändert +grün im normalen `make check`-Lauf. + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei +Pflichtprüfungen real erfüllt — beide Restore-Pfade (Datenbank, +Objekt-Storage) sowohl testgetrieben als auch manuell über `restore-cli` +auf echter Infrastruktur nachgewiesen. Drei während der Implementierung +gefundene reale Defekte (Tar-vs-Plain-Format, fehlende WAL-Sicherung/ +-Verifikation, Go-exec-Pipe-Hänger) behoben und dokumentiert, nicht +stillschweigend umgangen. diff --git a/archive/internal/backup/backup.go b/archive/internal/backup/backup.go index 8c3f372..2d05915 100644 --- a/archive/internal/backup/backup.go +++ b/archive/internal/backup/backup.go @@ -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) diff --git a/archive/internal/backup/restore_test.go b/archive/internal/backup/restore_test.go new file mode 100644 index 0000000..818d75d --- /dev/null +++ b/archive/internal/backup/restore_test.go @@ -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)) + } +} diff --git a/archive/internal/backup/verify.go b/archive/internal/backup/verify.go index 2d42e7d..d08a4cd 100644 --- a/archive/internal/backup/verify.go +++ b/archive/internal/backup/verify.go @@ -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 } diff --git a/archive/internal/objectbackup/restic.go b/archive/internal/objectbackup/restic.go index 90b1552..300b5e2 100644 --- a/archive/internal/objectbackup/restic.go +++ b/archive/internal/objectbackup/restic.go @@ -137,6 +137,17 @@ func Forget(ctx context.Context, cfg Config, keepLast int) error { return nil } +// Restore stellt snapshotID nach targetDir wieder her (`restic restore`). +// targetDir muss bereits existieren; Atomarität gegenüber einem eventuell +// nicht-leeren ENDZIEL ist Aufgabe von internal/restore, nicht dieser +// Funktion (dieselbe Aufgabenteilung wie backup.Restore). +func Restore(ctx context.Context, cfg Config, snapshotID, targetDir string) error { + if _, err := run(ctx, cfg, "restore", snapshotID, "--target", targetDir); err != nil { + return fmt.Errorf("objectbackup: wiederherstellung: %w", err) + } + return nil +} + type snapshotEntry struct { ShortID string `json:"short_id"` } diff --git a/archive/internal/objectbackup/restore_test.go b/archive/internal/objectbackup/restore_test.go new file mode 100644 index 0000000..b9f09ff --- /dev/null +++ b/archive/internal/objectbackup/restore_test.go @@ -0,0 +1,41 @@ +package objectbackup + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +// TestRestore_RecoversRealContentFromSnapshot ist Pruefung 1 fuer BAK-03 +// (Objekt-Storage-Teil): Restore auf leerem Zielverzeichnis vollstaendig +// erfolgreich, real gegen restic geprueft, Dateiinhalt tatsaechlich +// verglichen (kein Bloss-Existenz-Check). +func TestRestore_RecoversRealContentFromSnapshot(t *testing.T) { + cfg := setupTest(t) + ctx := context.Background() + + sourceDir := t.TempDir() + content := []byte("original objektinhalt fuer restore-test") + if err := os.WriteFile(filepath.Join(sourceDir, "objekt.txt"), content, 0o600); err != nil { + t.Fatal(err) + } + summary, err := Backup(ctx, cfg, sourceDir) + if err != nil { + t.Fatalf("backup: %v", err) + } + + targetDir := t.TempDir() + if err := Restore(ctx, cfg, summary.SnapshotID, targetDir); err != nil { + t.Fatalf("restore: %v", err) + } + + restoredPath := filepath.Join(targetDir, sourceDir, "objekt.txt") + got, err := os.ReadFile(restoredPath) + if err != nil { + t.Fatalf("wiederhergestellte datei lesen (%s): %v", restoredPath, err) + } + if string(got) != string(content) { + t.Fatalf("wiederhergestellter inhalt = %q, want %q", got, content) + } +} diff --git a/archive/internal/restore/restore.go b/archive/internal/restore/restore.go new file mode 100644 index 0000000..3db234d --- /dev/null +++ b/archive/internal/restore/restore.go @@ -0,0 +1,133 @@ +// Package restore implementiert BAK-03: dokumentiertes, wiederholbares +// Restore-Verfahren für Datenbank (BAK-01) und Objekt-Storage (BAK-02). +// Enthält NUR die gemeinsame Ablauflogik (Atomarität über Temp-Verzeichnis, +// Protokollierung) — die eigentliche Wiederherstellung bleibt in den +// jeweiligen Paketen (backup.Restore, objectbackup.Restore), damit +// internal/restore nicht weiß, WIE eine Sicherung gelesen wird, nur WANN +// und WIE SICHER sie an ihren Zielort darf. +package restore + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// Kind unterscheidet die beiden Restore-Arten im Protokoll. +type Kind string + +const ( + KindDatabase Kind = "database" + KindObjects Kind = "objects" +) + +// LogEntry ist EIN Protokolleintrag (Akzeptanzkriterium 3: Quelle, +// Zeitpunkt, Ergebnis). +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Kind Kind `json:"kind"` + Source string `json:"source"` // Generation-ID oder Snapshot-ID + Target string `json:"target"` + Result string `json:"result"` // "ok", "abgebrochen: ...", "fehlgeschlagen: ..." +} + +// ErrTargetNotEmpty wird zurückgegeben, wenn targetDir bereits Inhalt hat +// und force nicht gesetzt ist — Akzeptanzkriterium 2: bestehender Inhalt +// bleibt unangetastet, solange der Vorgang nicht bestätigt wird. +var ErrTargetNotEmpty = fmt.Errorf("restore: zielverzeichnis ist nicht leer, restore ohne bestätigung (force) abgebrochen") + +// AtomicRestore führt restoreFn gegen ein FRISCHES temporäres Verzeichnis +// aus (niemals direkt gegen target) und übernimmt es erst bei Erfolg — +// entweder wenn target leer ist, oder wenn force=true (bewusste +// Bestätigung, bestehenden Inhalt zu überschreiben). Bei jedem Fehler +// oder abgelehnter Bestätigung bleibt target garantiert unverändert +// (Akzeptanzkriterium 2), das temporäre Verzeichnis wird aufgeräumt. +// Jeder Aufruf – erfolgreich oder nicht – erzeugt genau einen +// Protokolleintrag in logPath (Akzeptanzkriterium 3). +func AtomicRestore(kind Kind, source, target string, force bool, logPath string, restoreFn func(tempDir string) error) (LogEntry, error) { + entry := LogEntry{Timestamp: time.Now().UTC(), Kind: kind, Source: source, Target: target} + + empty, err := dirIsEmptyOrMissing(target) + if err != nil { + entry.Result = "fehlgeschlagen: " + err.Error() + _ = appendLog(logPath, entry) + return entry, err + } + if !empty && !force { + entry.Result = "abgebrochen: " + ErrTargetNotEmpty.Error() + _ = appendLog(logPath, entry) + return entry, ErrTargetNotEmpty + } + + parent := filepath.Dir(filepath.Clean(target)) + tempDir, err := os.MkdirTemp(parent, ".restore-tmp-*") + if err != nil { + entry.Result = "fehlgeschlagen: temp-verzeichnis anlegen: " + err.Error() + _ = appendLog(logPath, entry) + return entry, fmt.Errorf("restore: temp-verzeichnis anlegen: %w", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() // no-op nach erfolgreichem Rename (Verzeichnis existiert dann nicht mehr) + + if err := restoreFn(tempDir); err != nil { + entry.Result = "fehlgeschlagen: " + err.Error() + _ = appendLog(logPath, entry) + return entry, fmt.Errorf("restore: wiederherstellung: %w", err) + } + + if !empty { + // force=true, bewusste Bestätigung: alter Inhalt wird ERST JETZT + // entfernt, nachdem restoreFn bereits erfolgreich in tempDir + // abgeschlossen hat — ein Fehlschlag oben hätte target nie berührt. + if err := os.RemoveAll(target); err != nil { + entry.Result = "fehlgeschlagen: altes ziel entfernen: " + err.Error() + _ = appendLog(logPath, entry) + return entry, fmt.Errorf("restore: altes ziel entfernen: %w", err) + } + } else if err := os.MkdirAll(parent, 0o750); err != nil { + entry.Result = "fehlgeschlagen: übergeordnetes verzeichnis anlegen: " + err.Error() + _ = appendLog(logPath, entry) + return entry, fmt.Errorf("restore: übergeordnetes verzeichnis anlegen: %w", err) + } + + if err := os.Rename(tempDir, target); err != nil { + entry.Result = "fehlgeschlagen: umbenennen: " + err.Error() + _ = appendLog(logPath, entry) + return entry, fmt.Errorf("restore: temp-verzeichnis nach ziel umbenennen: %w", err) + } + + entry.Result = "ok" + if err := appendLog(logPath, entry); err != nil { + return entry, fmt.Errorf("restore: protokoll schreiben: %w", err) + } + return entry, nil +} + +func dirIsEmptyOrMissing(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return true, nil + } + return false, fmt.Errorf("zielverzeichnis lesen: %w", err) + } + return len(entries) == 0, nil +} + +func appendLog(logPath string, entry LogEntry) error { + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("protokolldatei öffnen: %w", err) + } + defer func() { _ = f.Close() }() + + line, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("protokolleintrag kodieren: %w", err) + } + if _, err := f.Write(append(line, '\n')); err != nil { + return fmt.Errorf("protokolleintrag schreiben: %w", err) + } + return nil +} diff --git a/archive/internal/restore/restore_test.go b/archive/internal/restore/restore_test.go new file mode 100644 index 0000000..ee9fdc8 --- /dev/null +++ b/archive/internal/restore/restore_test.go @@ -0,0 +1,181 @@ +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]) + } +}