internal/restoretest wiederholt BAK-03s eigene Pruefdiskt als Produktcode: echter Restore der neuesten Sicherung, echter Kurzstart einer isolierten Postgres-Instanz, echte SELECT-1-Abfrage; Objekt-Seite echter restic-Restore + Inhaltspruefung. JSONL-Historie (append-only), sichtbare Warnung ueber denselben OPS-05-Pull-Weg wie BAK-08, eigenes /metrics-Modul (archive-restoretest). Zwei reale Defekte gefunden und behoben: Unix-Socket-Pfadlaenge unter tief verschachtelten Testpfaden, restic-stderr-Vermischung beim JSON-Parsen unter dem Dienstnutzer. Real auf 131 verdrahtet und ausgeloest (beide Testarten erfolgreich, End-zu-Ende ueber OPS-03 bestaetigt).
240 lines
8.1 KiB
Go
240 lines
8.1 KiB
Go
// Package restoretest implementiert BAK-06: regelmäßiger, automatisierter
|
|
// Testlauf des BAK-03-Restore-Verfahrens gegen eine isolierte
|
|
// Testumgebung — "Wiederherstellung ist Routine, nicht Ausnahmefall"
|
|
// (Produkt-DNA) heißt: nicht nur Dateien vorhanden pruefen, sondern
|
|
// tatsächlich eine funktionsfähige Instanz aus der Sicherung starten und
|
|
// befragen, exakt wie BAK-03s eigener Prüfungsnachweis — hier als
|
|
// Produktcode statt Testcode, damit es regelmäßig UNBEAUFSICHTIGT laufen
|
|
// kann.
|
|
package restoretest
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/archive/internal/backup"
|
|
"gitea.perlbach24.de/scripte/nexarch/archive/internal/objectbackup"
|
|
)
|
|
|
|
// Kind unterscheidet Datenbank- und Objekt-Storage-Testlauf im Protokoll.
|
|
type Kind string
|
|
|
|
const (
|
|
KindDatabase Kind = "database"
|
|
KindObjects Kind = "objects"
|
|
)
|
|
|
|
// Result ist EIN Testlauf-Ergebnis (Akzeptanzkriterium 2: Ergebnis mit
|
|
// Zeitstempel protokolliert).
|
|
type Result struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Kind Kind `json:"kind"`
|
|
Source string `json:"source"` // Generation-ID oder Snapshot-ID
|
|
Success bool `json:"success"`
|
|
Detail string `json:"detail"`
|
|
}
|
|
|
|
// PgConfig bündelt die zusätzlich zu backup.Config nötigen Angaben für
|
|
// den Kurzstart der Test-Instanz (eigener Port/Socket, damit die
|
|
// Testinstanz die echte Test-Datenbank auf demselben Host nicht stört).
|
|
type PgConfig struct {
|
|
PgCtlPath string // Default "pg_ctl"
|
|
PsqlPath string // Default "psql"
|
|
TestPort string // Default "55433"
|
|
}
|
|
|
|
func (c PgConfig) ctlBinary() string {
|
|
if c.PgCtlPath != "" {
|
|
return c.PgCtlPath
|
|
}
|
|
return "pg_ctl"
|
|
}
|
|
|
|
func (c PgConfig) psqlBinary() string {
|
|
if c.PsqlPath != "" {
|
|
return c.PsqlPath
|
|
}
|
|
return "psql"
|
|
}
|
|
|
|
func (c PgConfig) port() string {
|
|
if c.TestPort != "" {
|
|
return c.TestPort
|
|
}
|
|
return "55433"
|
|
}
|
|
|
|
// RunDatabaseTest führt einen vollständigen Restore der NEUESTEN
|
|
// Generation in ein frisches, isoliertes Testverzeichnis durch und
|
|
// beweist Wiederherstellbarkeit, indem daraus tatsächlich eine
|
|
// eigenständige Postgres-Instanz gestartet und per echter Verbindung
|
|
// abgefragt wird (Akzeptanzkriterium 1) — dieselbe Prüftiefe wie BAK-03s
|
|
// eigener Nachweis, hier als wiederholbarer Produktcode.
|
|
func RunDatabaseTest(ctx context.Context, cfg backup.Config, pgCfg PgConfig, testRoot string) Result {
|
|
res := Result{Timestamp: time.Now().UTC(), Kind: KindDatabase}
|
|
|
|
generations, err := backup.ListGenerations(cfg.BackupDir)
|
|
if err != nil || len(generations) == 0 {
|
|
res.Detail = fmt.Sprintf("keine sicherungsgeneration gefunden: %v", err)
|
|
return res
|
|
}
|
|
genID := generations[len(generations)-1]
|
|
res.Source = genID
|
|
|
|
testDir, err := os.MkdirTemp(testRoot, "restoretest-db-*")
|
|
if err != nil {
|
|
res.Detail = fmt.Sprintf("testverzeichnis anlegen: %v", err)
|
|
return res
|
|
}
|
|
defer func() { _ = os.RemoveAll(testDir) }()
|
|
|
|
restoreOut := filepath.Join(testDir, "pgdata")
|
|
if err := os.MkdirAll(restoreOut, 0o700); err != nil {
|
|
res.Detail = fmt.Sprintf("pgdata-verzeichnis anlegen: %v", err)
|
|
return res
|
|
}
|
|
if err := backup.Restore(ctx, cfg, genID, restoreOut); err != nil {
|
|
res.Detail = fmt.Sprintf("restore fehlgeschlagen: %v", err)
|
|
return res
|
|
}
|
|
|
|
// Minimalkonfiguration NUR für den isolierten Kurzstart (auf diesem
|
|
// Debian-Postgres liegen postgresql.conf/pg_hba.conf ausserhalb von
|
|
// PGDATA, siehe BAK-03-PRUEFPROTOKOLL.md - der reale Sicherungsinhalt
|
|
// selbst enthält sie nicht).
|
|
if err := os.WriteFile(filepath.Join(restoreOut, "postgresql.conf"), []byte("listen_addresses = ''\n"), 0o600); err != nil {
|
|
res.Detail = fmt.Sprintf("testkonfiguration schreiben: %v", err)
|
|
return res
|
|
}
|
|
if err := os.WriteFile(filepath.Join(restoreOut, "pg_hba.conf"), []byte("local all all trust\n"), 0o600); err != nil {
|
|
res.Detail = fmt.Sprintf("testkonfiguration schreiben: %v", err)
|
|
return res
|
|
}
|
|
|
|
// EIGENES, kurzes Verzeichnis fuer den Unix-Socket - NICHT unter
|
|
// testDir (Postgres begrenzt Socket-Pfade auf 107 Byte, ein tief
|
|
// verschachtelter Testverzeichnis-Pfad reisst dieses Limit leicht).
|
|
socketDir, err := os.MkdirTemp("", "nexarch-rt-sock-*")
|
|
if err != nil {
|
|
res.Detail = fmt.Sprintf("socket-verzeichnis anlegen: %v", err)
|
|
return res
|
|
}
|
|
defer func() { _ = os.RemoveAll(socketDir) }()
|
|
logFile := filepath.Join(testDir, "postgres.log")
|
|
|
|
startCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
defer cancel()
|
|
startCmd := exec.CommandContext(startCtx, pgCfg.ctlBinary(), "start", "-D", restoreOut, "-w", "-t", "30", "-l", logFile,
|
|
"-o", fmt.Sprintf("-p %s -k %s -c listen_addresses=''", pgCfg.port(), socketDir))
|
|
if err := startCmd.Run(); err != nil {
|
|
logContent, _ := os.ReadFile(logFile)
|
|
res.Detail = fmt.Sprintf("testinstanz starten fehlgeschlagen: %v (log: %s)", err, string(logContent))
|
|
return res
|
|
}
|
|
defer func() {
|
|
stopCmd := exec.Command(pgCfg.ctlBinary(), "stop", "-D", restoreOut, "-m", "fast")
|
|
_ = stopCmd.Run()
|
|
}()
|
|
|
|
psqlCtx, cancelPsql := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancelPsql()
|
|
psqlOut, err := exec.CommandContext(psqlCtx, pgCfg.psqlBinary(),
|
|
"-h", socketDir, "-p", pgCfg.port(), "-U", cfg.User, "-d", "postgres",
|
|
"-tAc", "SELECT 1").CombinedOutput()
|
|
if err != nil || strings.TrimSpace(string(psqlOut)) != "1" {
|
|
res.Detail = fmt.Sprintf("verbindung zur testinstanz fehlgeschlagen: %v (ausgabe: %s)", err, string(psqlOut))
|
|
return res
|
|
}
|
|
|
|
res.Success = true
|
|
res.Detail = "restore und verbindungspruefung erfolgreich"
|
|
return res
|
|
}
|
|
|
|
// RunObjectTest führt einen vollständigen Restore des NEUESTEN Snapshots
|
|
// in ein frisches, isoliertes Testverzeichnis durch — restics eigene
|
|
// Vollständigkeitsgarantie beim Restore (bricht bei fehlenden/beschädigten
|
|
// Blöcken ab) ist der Wiederherstellbarkeitsnachweis, zusätzlich real
|
|
// geprüft, dass das Zielverzeichnis tatsächlich Inhalt enthält.
|
|
func RunObjectTest(ctx context.Context, cfg objectbackup.Config, testRoot string) Result {
|
|
res := Result{Timestamp: time.Now().UTC(), Kind: KindObjects}
|
|
|
|
snapshotID, err := latestSnapshotID(ctx, cfg)
|
|
if err != nil {
|
|
res.Detail = fmt.Sprintf("neuesten snapshot ermitteln: %v", err)
|
|
return res
|
|
}
|
|
res.Source = snapshotID
|
|
|
|
testDir, err := os.MkdirTemp(testRoot, "restoretest-objects-*")
|
|
if err != nil {
|
|
res.Detail = fmt.Sprintf("testverzeichnis anlegen: %v", err)
|
|
return res
|
|
}
|
|
defer func() { _ = os.RemoveAll(testDir) }()
|
|
|
|
if err := objectbackup.Restore(ctx, cfg, snapshotID, testDir); err != nil {
|
|
res.Detail = fmt.Sprintf("restore fehlgeschlagen: %v", err)
|
|
return res
|
|
}
|
|
|
|
empty, err := dirIsEmpty(testDir)
|
|
if err != nil {
|
|
res.Detail = fmt.Sprintf("wiederhergestelltes verzeichnis pruefen: %v", err)
|
|
return res
|
|
}
|
|
if empty {
|
|
res.Detail = "restore lief ohne fehler, aber zielverzeichnis ist leer"
|
|
return res
|
|
}
|
|
|
|
res.Success = true
|
|
res.Detail = "restore erfolgreich, inhalt vorhanden"
|
|
return res
|
|
}
|
|
|
|
func dirIsEmpty(dir string) (bool, error) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return len(entries) == 0, nil
|
|
}
|
|
|
|
func latestSnapshotID(ctx context.Context, cfg objectbackup.Config) (string, error) {
|
|
binary := cfg.ResticPath
|
|
if binary == "" {
|
|
binary = "restic"
|
|
}
|
|
cmd := exec.CommandContext(ctx, binary, "-r", cfg.RepoDir, "snapshots", "--json")
|
|
cmd.Env = append(os.Environ(), "RESTIC_PASSWORD="+cfg.Password)
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
// NUR stdout, nicht CombinedOutput: restic schreibt bei fehlendem/
|
|
// nicht beschreibbarem Cache-Verzeichnis eine Warnung nach stderr
|
|
// (z.B. "unable to open cache: ..." - real beobachtet unter dem
|
|
// systemd-Dienstnutzer "nexarch" ohne beschreibbares HOME), die vor
|
|
// das JSON-Array gemischt worden waere und das Parsen bricht.
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("restic snapshots: %w (stderr: %s)", err, stderr.String())
|
|
}
|
|
var snapshots []struct {
|
|
ShortID string `json:"short_id"`
|
|
}
|
|
if err := json.Unmarshal(output, &snapshots); err != nil {
|
|
return "", fmt.Errorf("snapshot-liste dekodieren: %w", err)
|
|
}
|
|
if len(snapshots) == 0 {
|
|
return "", fmt.Errorf("keine snapshots vorhanden")
|
|
}
|
|
return snapshots[len(snapshots)-1].ShortID, nil
|
|
}
|