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