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.
134 lines
4.9 KiB
Go
134 lines
4.9 KiB
Go
// 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
|
||
}
|