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.
168 lines
6.1 KiB
Go
168 lines
6.1 KiB
Go
// Package objectbackup implementiert BAK-02: automatisierte, inkrementelle,
|
|
// deduplizierende Sicherung des Objekt-Storage-Bestands. Nutzt restic
|
|
// (Content-defined Chunking, verschlüsseltes Repository ab Werk) statt
|
|
// Eigenbau — restic erfüllt alle Akzeptanzkriterien mit ausgereiftem,
|
|
// geprüftem Tooling statt einer weniger robusten Neuimplementierung.
|
|
//
|
|
// Backup-Quelle ist ein lokaler Verzeichnisbaum — für den LocalDriver aus
|
|
// FDN-03 direkt dessen Basisverzeichnis, für S3-gestützte Produktions-
|
|
// Deployments ein vorgelagerter Sync-Schritt (z.B. rclone) auf einen
|
|
// lokalen Spiegel, bevor restic ihn sichert (nicht Bestandteil dieser
|
|
// Kachel — restic selbst sichert Dateibäume, keine S3-Buckets direkt).
|
|
package objectbackup
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// Config enthält Repository-Ort und -Passwort — ausschließlich über
|
|
// Umgebungsvariablen befüllt (siehe Ticket-Abschluss-Regel).
|
|
type Config struct {
|
|
RepoDir string
|
|
Password string
|
|
ResticPath string // Default "restic", überschreibbar für Tests
|
|
}
|
|
|
|
func (c Config) binary() string {
|
|
if c.ResticPath != "" {
|
|
return c.ResticPath
|
|
}
|
|
return "restic"
|
|
}
|
|
|
|
func (c Config) env() []string {
|
|
return append(os.Environ(), "RESTIC_PASSWORD="+c.Password)
|
|
}
|
|
|
|
func run(ctx context.Context, cfg Config, args ...string) ([]byte, error) {
|
|
fullArgs := append([]string{"-r", cfg.RepoDir}, args...)
|
|
cmd := exec.CommandContext(ctx, cfg.binary(), fullArgs...)
|
|
cmd.Env = cfg.env()
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return output, fmt.Errorf("%s %v fehlgeschlagen: %w (ausgabe: %s)", cfg.binary(), args, err, string(output))
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
// InitRepo legt ein neues restic-Repository an, falls es noch nicht
|
|
// existiert — idempotent, ein bereits initialisiertes Repository ist kein
|
|
// Fehler (Wiederholte Aufrufe durch systemd-Timer nach einem Neustart
|
|
// dürfen nicht fehlschlagen).
|
|
func InitRepo(ctx context.Context, cfg Config) error {
|
|
output, err := run(ctx, cfg, "init")
|
|
if err != nil {
|
|
if strings.Contains(string(output), "config file already exists") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("objectbackup: repository initialisieren: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BackupSummary ist der geparste "summary"-Datensatz aus `restic backup --json`.
|
|
type BackupSummary struct {
|
|
SnapshotID string `json:"snapshot_id"`
|
|
FilesNew int `json:"files_new"`
|
|
FilesChanged int `json:"files_changed"`
|
|
FilesUnmodified int `json:"files_unmodified"`
|
|
DataBlobs int `json:"data_blobs"`
|
|
TotalBytes int64 `json:"total_bytes_processed"`
|
|
}
|
|
|
|
// Backup sichert sourceDir inkrementell (Akzeptanzkriterium 1: unveränderte
|
|
// Objekte werden nicht erneut übertragen — restics Content-defined
|
|
// Chunking erkennt das automatisch, kein manueller Änderungsabgleich
|
|
// nötig).
|
|
func Backup(ctx context.Context, cfg Config, sourceDir string) (BackupSummary, error) {
|
|
output, err := run(ctx, cfg, "backup", sourceDir, "--json")
|
|
if err != nil {
|
|
return BackupSummary{}, fmt.Errorf("objectbackup: sicherung: %w", err)
|
|
}
|
|
return parseSummary(output)
|
|
}
|
|
|
|
// parseSummary sucht in der zeilenweisen JSON-Ausgabe von `restic backup
|
|
// --json` (mehrere Fortschritts-/Statuszeilen, GENAU EINE mit
|
|
// message_type=="summary") die Zusammenfassung.
|
|
func parseSummary(output []byte) (BackupSummary, error) {
|
|
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
var probe struct {
|
|
MessageType string `json:"message_type"`
|
|
}
|
|
if err := json.Unmarshal([]byte(lines[i]), &probe); err != nil {
|
|
continue
|
|
}
|
|
if probe.MessageType == "summary" {
|
|
var summary BackupSummary
|
|
if err := json.Unmarshal([]byte(lines[i]), &summary); err != nil {
|
|
return BackupSummary{}, fmt.Errorf("objectbackup: summary-zeile dekodieren: %w", err)
|
|
}
|
|
return summary, nil
|
|
}
|
|
}
|
|
return BackupSummary{}, fmt.Errorf("objectbackup: keine summary-zeile in der restic-ausgabe gefunden")
|
|
}
|
|
|
|
// Check prüft die Vollständigkeit/Lesbarkeit des Repository
|
|
// (Akzeptanzkriterium 3 / Pflichtprüfung: Vollständigkeitsprüfung erkennt
|
|
// fehlendes/beschädigtes Objekt). readData=true liest jeden gespeicherten
|
|
// Datenblock tatsächlich (teurer, aber die einzige Prüfung, die
|
|
// Bit-Rot in bereits gespeicherten Paketen erkennt — ohne readData prüft
|
|
// restic nur Struktur/Indizes, nicht den tatsächlichen Blockinhalt).
|
|
func Check(ctx context.Context, cfg Config, readData bool) error {
|
|
args := []string{"check"}
|
|
if readData {
|
|
args = append(args, "--read-data")
|
|
}
|
|
if _, err := run(ctx, cfg, args...); err != nil {
|
|
return fmt.Errorf("objectbackup: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Forget entfernt alte Snapshots nach Rotationsregel und gibt den davon
|
|
// belegten Speicherplatz frei (--prune) — restics Äquivalent zu
|
|
// BAK-01s Rotate.
|
|
func Forget(ctx context.Context, cfg Config, keepLast int) error {
|
|
if _, err := run(ctx, cfg, "forget", "--keep-last", fmt.Sprintf("%d", keepLast), "--prune"); err != nil {
|
|
return fmt.Errorf("objectbackup: rotation: %w", err)
|
|
}
|
|
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"`
|
|
}
|
|
|
|
// SnapshotCount liefert die Anzahl vorhandener Snapshots — für Tests und
|
|
// Statusabfragen.
|
|
func SnapshotCount(ctx context.Context, cfg Config) (int, error) {
|
|
output, err := run(ctx, cfg, "snapshots", "--json")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("objectbackup: snapshots auflisten: %w", err)
|
|
}
|
|
var snapshots []snapshotEntry
|
|
if err := json.Unmarshal(output, &snapshots); err != nil {
|
|
return 0, fmt.Errorf("objectbackup: snapshot-liste dekodieren: %w", err)
|
|
}
|
|
return len(snapshots), nil
|
|
}
|