From c761cf9b93d4c84bcd684c74b022033fbd8c3520 Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 30 Aug 2026 00:31:34 +0200 Subject: [PATCH] feat(archive): BAK-06 Restore-Testverfahren 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). --- archive/cmd/restoretest-cli/main.go | 71 ++++++ archive/cmd/restoretest-metrics/main.go | 70 +++++ archive/docs/BAK-06-PRUEFPROTOKOLL.md | 110 ++++++++ archive/internal/restoretest/history.go | 61 +++++ archive/internal/restoretest/history_test.go | 49 ++++ archive/internal/restoretest/restoretest.go | 239 ++++++++++++++++++ .../internal/restoretest/restoretest_test.go | 122 +++++++++ ...h-archive-restoretest-metrics.service.tmpl | 14 + .../nexarch-archive-restoretest.service.tmpl | 11 + .../nexarch-archive-restoretest.timer.tmpl | 9 + 10 files changed, 756 insertions(+) create mode 100644 archive/cmd/restoretest-cli/main.go create mode 100644 archive/cmd/restoretest-metrics/main.go create mode 100644 archive/docs/BAK-06-PRUEFPROTOKOLL.md create mode 100644 archive/internal/restoretest/history.go create mode 100644 archive/internal/restoretest/history_test.go create mode 100644 archive/internal/restoretest/restoretest.go create mode 100644 archive/internal/restoretest/restoretest_test.go create mode 100644 deploy/systemd/nexarch-archive-restoretest-metrics.service.tmpl create mode 100644 deploy/systemd/nexarch-archive-restoretest.service.tmpl create mode 100644 deploy/systemd/nexarch-archive-restoretest.timer.tmpl diff --git a/archive/cmd/restoretest-cli/main.go b/archive/cmd/restoretest-cli/main.go new file mode 100644 index 0000000..cb7f1c4 --- /dev/null +++ b/archive/cmd/restoretest-cli/main.go @@ -0,0 +1,71 @@ +// restoretest-cli ist der Aufrufpunkt fuer BAK-06 (systemd-Timer, +// regelmaessiger Zeitplan) - fuehrt einen vollstaendigen Restore-Test +// (Datenbank UND Objekt-Storage) gegen die jeweils neueste Sicherung in +// eine isolierte Testumgebung durch und protokolliert das Ergebnis. +package main + +import ( + "context" + "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/restoretest" +) + +func main() { + testRoot := os.Getenv("NEXARCH_RESTORETEST_DIR") + logPath := os.Getenv("NEXARCH_RESTORETEST_LOG") + if testRoot == "" || logPath == "" { + log.Fatal("NEXARCH_RESTORETEST_DIR und NEXARCH_RESTORETEST_LOG muessen gesetzt sein") + } + if err := os.MkdirAll(testRoot, 0o750); err != nil { + log.Fatalf("testverzeichnis anlegen: %v", err) + } + + ctx := context.Background() + failed := false + + dbCfg := 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"), + } + pgCfg := restoretest.PgConfig{TestPort: envOr("NEXARCH_RESTORETEST_PG_PORT", "55433")} + dbResult := restoretest.RunDatabaseTest(ctx, dbCfg, pgCfg, testRoot) + logResult(logPath, dbResult) + if !dbResult.Success { + failed = true + } + + objCfg := objectbackup.Config{ + RepoDir: os.Getenv("NEXARCH_OBJECTBACKUP_REPO_DIR"), + Password: os.Getenv("NEXARCH_OBJECTBACKUP_PASSWORD"), + } + objResult := restoretest.RunObjectTest(ctx, objCfg, testRoot) + logResult(logPath, objResult) + if !objResult.Success { + failed = true + } + + if failed { + os.Exit(1) + } +} + +func logResult(logPath string, r restoretest.Result) { + log.Printf("restoretest %s: quelle=%s erfolg=%t detail=%s", r.Kind, r.Source, r.Success, r.Detail) + if err := restoretest.AppendHistory(logPath, r); err != nil { + log.Fatalf("protokoll schreiben: %v", err) + } +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/archive/cmd/restoretest-metrics/main.go b/archive/cmd/restoretest-metrics/main.go new file mode 100644 index 0000000..8652610 --- /dev/null +++ b/archive/cmd/restoretest-metrics/main.go @@ -0,0 +1,70 @@ +// restoretest-metrics stellt BAK-06s Testlauf-Historie unter /metrics +// bereit - dauerhafter Prozess getrennt vom Oneshot-restoretest-cli +// (Pull-Modell, Core OPS-03 scrapt, kein Push - dieselbe Begruendung wie +// BAK-08s scrub-metrics). +package main + +import ( + "fmt" + "log" + "net/http" + "os" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/restoretest" +) + +func main() { + logPath := os.Getenv("NEXARCH_RESTORETEST_LOG") + if logPath == "" { + log.Fatal("NEXARCH_RESTORETEST_LOG muss gesetzt sein") + } + addr := os.Getenv("NEXARCH_RESTORETEST_METRICS_LISTEN_ADDR") + if addr == "" { + addr = ":8091" + } + + mux := http.NewServeMux() + mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) { + history, err := restoretest.ReadHistory(logPath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Historie ist append-only (siehe AppendHistory) - die Anzahl + // fehlgeschlagener Eintraege waechst daher von selbst monoton, + // kein separater Zaehlerstand noetig (gueltiger Prometheus-Counter). + var failuresTotal int + lastSuccess := map[restoretest.Kind]float64{restoretest.KindDatabase: -1, restoretest.KindObjects: -1} + for _, res := range history { + if !res.Success { + failuresTotal++ + } + if res.Success { + lastSuccess[res.Kind] = 1 + } else { + lastSuccess[res.Kind] = 0 + } + } + + w.Header().Set("Content-Type", "text/plain; version=0.0.4") + body := fmt.Sprintf( + "# HELP nexarch_archive_restore_test_failures_total Anzahl seit Einrichtung fehlgeschlagener Restore-Testlaeufe (BAK-06).\n"+ + "# TYPE nexarch_archive_restore_test_failures_total counter\n"+ + "nexarch_archive_restore_test_failures_total %d\n"+ + "# HELP nexarch_archive_restore_test_last_success Letzter Testlauf erfolgreich (1) oder fehlgeschlagen (0), -1 wenn noch kein Lauf, je Art.\n"+ + "# TYPE nexarch_archive_restore_test_last_success gauge\n"+ + "nexarch_archive_restore_test_last_success{kind=\"database\"} %g\n"+ + "nexarch_archive_restore_test_last_success{kind=\"objects\"} %g\n", + failuresTotal, lastSuccess[restoretest.KindDatabase], lastSuccess[restoretest.KindObjects]) + if _, err := w.Write([]byte(body)); err != nil { + log.Printf("restoretest-metrics: antwort schreiben: %v", err) + } + }) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + log.Printf("restoretest-metrics: listening on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("http server: %v", err) + } +} diff --git a/archive/docs/BAK-06-PRUEFPROTOKOLL.md b/archive/docs/BAK-06-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..5821ef5 --- /dev/null +++ b/archive/docs/BAK-06-PRUEFPROTOKOLL.md @@ -0,0 +1,110 @@ +# BAK-06 – Prüfprotokoll: Restore-Testverfahren + +Voraussetzung BAK-03 – erledigt, siehe eigenes Protokoll. + +## Grundsatzentscheidung: Produktcode statt Testcode für den echten Nachweis + +Produkt-DNA: "Wiederherstellung ist Routine, nicht Ausnahmefall – +regelmäßig getestet." Ein reiner Dateiexistenz-Check hätte diese +Forderung nicht erfüllt (dieselbe Disziplin wie überall sonst in diesem +Projekt: "jede Prüfung tatsächlich durchführen"). `internal/restoretest` +übernimmt daher exakt die Prüftiefe, die BAK-03s eigener Test bewiesen +hat — tatsächlicher Restore, tatsächlicher Kurzstart einer isolierten +Postgres-Instanz, tatsächliche `SELECT 1`-Abfrage — als WIEDERHOLBAREN +Produktcode statt einmaligen Testcode, damit es unbeaufsichtigt auf +Zeitplan laufen kann. + +Historie: append-only JSONL-Datei (Akzeptanzkriterium 2/Pflichtprüfung +3). Sichtbare Warnung (Akzeptanzkriterium 3): derselbe OPS-05-Pull-Weg +wie BAK-08 (`nexarch_archive_restore_test_failures_total`, Counter) — +bewusst als EIGENES `/metrics`-Modul (`archive-restoretest`) registriert, +nicht in BAK-08s `scrub-metrics` verbaut (kein Umbau angrenzender, +bereits fertiger Bereiche). + +## Drei reale Defekte während der Implementierung gefunden und behoben + +1. **Unix-Socket-Pfadlänge:** Postgres begrenzt Socket-Pfade auf 107 + Byte — ein unter `t.TempDir()` verschachtelter Pfad reißt dieses + Limit leicht. Fix: eigenes, kurzes Socket-Verzeichnis + (`os.MkdirTemp("", ...)`), nicht unter dem Testverzeichnis. +2. **`restic snapshots --json` unter dem systemd-Dienstnutzer + `nexarch`:** ohne beschreibbares `HOME` schreibt restic eine Warnung + ("unable to open cache: ...") nach STDERR — `CombinedOutput()` hätte + sie vor das JSON-Array gemischt und das Parsen gebrochen. Fix: nur + `cmd.Output()` (stdout), stderr separat für Fehlermeldungen. +3. Beide Defekte wurden NICHT beim ersten laufenden Testdurchlauf + sichtbar, sondern erst beim ECHTEN systemd-Lauf unter dem + `nexarch`-Dienstnutzer (Defekt 2) bzw. bei tief verschachtelten + Go-Testpfaden (Defekt 1) — Beleg dafür, warum sowohl automatisierte + Tests als auch ein realer Deploy-Lauf nötig sind, keines allein hätte + beide gefunden. + +## Umsetzung + +- `internal/restoretest.RunDatabaseTest` — neueste Generation ermitteln + (`backup.ListGenerations`), `backup.Restore` in isoliertes + Testverzeichnis, Kurzstart via `pg_ctl`, `SELECT 1` über echte + Verbindung, danach `pg_ctl stop`. +- `internal/restoretest.RunObjectTest` — neuesten Snapshot ermitteln, + `objectbackup.Restore` in isoliertes Testverzeichnis, Inhalt real + geprüft (nicht nur Exit-Code). +- `internal/restoretest.AppendHistory`/`ReadHistory` — JSONL, + append-only. +- `cmd/restoretest-cli` — Oneshot, beide Testarten, Exit-Code 1 bei + Fehlschlag. +- `cmd/restoretest-metrics` — dauerhafter `/metrics`-Endpunkt, Zähler + aus der Historie abgeleitet (append-only ⇒ Fehlschlagszahl von selbst + monoton, kein separater Zählerstand nötig). + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Geplanter Testlauf gegen aktuelle Sicherung erfolgreich durchgeführt und protokolliert | **bestanden** — `TestRunDatabaseTest_SucceedsAgainstRealBackup`, `TestRunObjectTest_SucceedsAgainstRealSnapshot`; real auf 131: `systemctl start nexarch-archive-restoretest.service` → beide Testarten `erfolg=true`, in `history.log` protokolliert | +| 2 | Absichtlich beschädigte Sicherung lässt den Testlauf sichtbar fehlschlagen | **bestanden** — `TestRunDatabaseTest_DetectsCorruptedBackup`: `base.tar.gz` durch Datenmüll ersetzt, `RunDatabaseTest` liefert `Success=false` mit aussagekräftigem Detail | +| 3 | Protokollhistorie zeigt mehrere zurückliegende Testläufe nachvollziehbar an | **bestanden** — `TestHistory_ShowsMultiplePastRunsInOrder`: drei Einträge angehängt, in exakt derselben Reihenfolge gelesen | + +## Echte Verdrahtung auf 192.168.1.131 + +- `restoretest-cli`, `restoretest-metrics` gebaut nach + `/opt/nexarch-archive/bin/` +- `/etc/nexarch/archive-restoretest.env` (0600) +- `nexarch-archive-restoretest.timer` installiert/aktiviert (wöchentlich + So. 07:00 UTC), `nexarch-archive-restoretest-metrics.service` + installiert/aktiviert (dauerhaft) — beide `systemctl status`: aktiv +- `Environment=PATH=...` im Service ergänzt um + `/usr/lib/postgresql/17/bin` (Debian verlinkt `pg_ctl`/ + `pg_combinebackup` nicht ins Standard-PATH) +- **Reales `INSERT` in `metrics_sources`** (Core-Registry-DB): + `('archive-restoretest', 'http://127.0.0.1:8091/metrics')` +- **End-to-End über OPS-03 bestätigt**: `curl http://127.0.0.1:8085/metrics` + zeigt `nexarch_module_archive_restoretest_nexarch_archive_restore_test_*` + mit den realen Werten (`failures_total=0`, + `last_success{kind="database"}=1`, `last_success{kind="objects"}=1`) +- Realer Lauf via `systemctl start nexarch-archive-restoretest.service`: + beide Testarten erfolgreich, Journal zeigt Details, `/metrics` und + OPS-03-Aggregation stimmen überein + +## Build/Test-Ergebnis (192.168.1.131, `make check`) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -count=1 -> 6/6 Pakete mit Tests ok (backup, objectbackup, reconcile, restore, restoretest, scrub), 0 Fehlschläge +``` + +Hinweis wie bei BAK-03: die Datenbank-Restore-Tests +(`TestRunDatabaseTest_*`) brauchen `pg_ctl`/`pg_combinebackup` im PATH +und laufen NICHT als root — separat als `postgres`-Systemnutzer +verifiziert (7/7 `internal/restoretest`-Tests bestanden), im normalen +root-`make check`-Lauf werden sie mit klarer Meldung übersprungen. + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei +Pflichtprüfungen real erfüllt — sowohl testgetrieben als auch über einen +echten, unbeaufsichtigten systemd-Lauf mit OPS-03/OPS-05-Sichtbarkeit +nachgewiesen. Zwei während der Implementierung gefundene reale Defekte +(Unix-Socket-Pfadlänge, restic-stderr-Vermischung unter dem +Dienstnutzer) behoben und dokumentiert. diff --git a/archive/internal/restoretest/history.go b/archive/internal/restoretest/history.go new file mode 100644 index 0000000..99a3084 --- /dev/null +++ b/archive/internal/restoretest/history.go @@ -0,0 +1,61 @@ +package restoretest + +import ( + "bufio" + "encoding/json" + "fmt" + "os" +) + +// AppendHistory hängt result an die JSONL-Protokolldatei an +// (Akzeptanzkriterium 2/Pflichtprüfung 3: Historie zeigt mehrere +// zurückliegende Testläufe nachvollziehbar an — append-only, nichts wird +// überschrieben). +func AppendHistory(logPath string, result Result) error { + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("restoretest: protokolldatei öffnen: %w", err) + } + defer func() { _ = f.Close() }() + + line, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("restoretest: ergebnis kodieren: %w", err) + } + if _, err := f.Write(append(line, '\n')); err != nil { + return fmt.Errorf("restoretest: ergebnis schreiben: %w", err) + } + return nil +} + +// ReadHistory liest alle bisherigen Testlauf-Ergebnisse — Nachweis der +// Nachvollziehbarkeit über mehrere Läufe hinweg. +func ReadHistory(logPath string) ([]Result, error) { + f, err := os.Open(logPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("restoretest: protokolldatei lesen: %w", err) + } + defer func() { _ = f.Close() }() + + var results []Result + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var r Result + if err := json.Unmarshal(line, &r); err != nil { + return nil, fmt.Errorf("restoretest: protokollzeile dekodieren: %w", err) + } + results = append(results, r) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("restoretest: protokolldatei durchlaufen: %w", err) + } + return results, nil +} diff --git a/archive/internal/restoretest/history_test.go b/archive/internal/restoretest/history_test.go new file mode 100644 index 0000000..ffd9714 --- /dev/null +++ b/archive/internal/restoretest/history_test.go @@ -0,0 +1,49 @@ +package restoretest + +import ( + "path/filepath" + "testing" + "time" +) + +// TestHistory_ShowsMultiplePastRunsInOrder ist Pruefung 3: Protokoll- +// historie zeigt mehrere zurueckliegende Testlaeufe nachvollziehbar an. +func TestHistory_ShowsMultiplePastRunsInOrder(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "restoretest.log") + + results := []Result{ + {Timestamp: time.Now().UTC(), Kind: KindDatabase, Source: "gen-1", Success: true, Detail: "ok"}, + {Timestamp: time.Now().UTC().Add(time.Hour), Kind: KindObjects, Source: "snap-1", Success: false, Detail: "kaputt"}, + {Timestamp: time.Now().UTC().Add(2 * time.Hour), Kind: KindDatabase, Source: "gen-2", Success: true, Detail: "ok"}, + } + for _, r := range results { + if err := AppendHistory(logPath, r); err != nil { + t.Fatalf("appendHistory: %v", err) + } + } + + got, err := ReadHistory(logPath) + if err != nil { + t.Fatalf("readHistory: %v", err) + } + if len(got) != len(results) { + t.Fatalf("erwartet %d eintraege, habe %d", len(results), len(got)) + } + for i, want := range results { + if got[i].Source != want.Source || got[i].Success != want.Success || got[i].Kind != want.Kind { + t.Fatalf("eintrag %d = %+v, want %+v", i, got[i], want) + } + } +} + +// TestReadHistory_MissingFileReturnsEmpty - noch kein Testlauf ist kein +// Fehlerzustand. +func TestReadHistory_MissingFileReturnsEmpty(t *testing.T) { + got, err := ReadHistory(filepath.Join(t.TempDir(), "nicht-vorhanden.log")) + if err != nil { + t.Fatalf("erwartet keinen fehler, habe: %v", err) + } + if len(got) != 0 { + t.Fatalf("erwartet leere historie, habe %d eintraege", len(got)) + } +} diff --git a/archive/internal/restoretest/restoretest.go b/archive/internal/restoretest/restoretest.go new file mode 100644 index 0000000..5afd5b9 --- /dev/null +++ b/archive/internal/restoretest/restoretest.go @@ -0,0 +1,239 @@ +// 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 +} diff --git a/archive/internal/restoretest/restoretest_test.go b/archive/internal/restoretest/restoretest_test.go new file mode 100644 index 0000000..8cb118c --- /dev/null +++ b/archive/internal/restoretest/restoretest_test.go @@ -0,0 +1,122 @@ +package restoretest + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/backup" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/objectbackup" +) + +func requireDBTestConfig(t *testing.T) backup.Config { + t.Helper() + user := os.Getenv("TEST_BACKUP_PG_USER") + if user == "" { + t.Skip("TEST_BACKUP_PG_USER nicht gesetzt, Integrationstest uebersprungen") + } + if _, err := exec.LookPath("pg_combinebackup"); err != nil { + t.Skip("pg_combinebackup nicht installiert, Integrationstest uebersprungen") + } + if _, err := exec.LookPath("pg_ctl"); err != nil { + t.Skip("pg_ctl nicht installiert, Integrationstest uebersprungen") + } + return backup.Config{ + Host: envOrT("TEST_BACKUP_PG_HOST", "localhost"), + Port: envOrT("TEST_BACKUP_PG_PORT", "5432"), + User: user, + Password: os.Getenv("TEST_BACKUP_PG_PASSWORD"), + BackupDir: t.TempDir(), + } +} + +func envOrT(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// TestRunDatabaseTest_SucceedsAgainstRealBackup ist Pruefung 1: geplanter +// Testlauf gegen aktuelle Sicherung erfolgreich durchgefuehrt. +func TestRunDatabaseTest_SucceedsAgainstRealBackup(t *testing.T) { + cfg := requireDBTestConfig(t) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + genID := backup.NewGenerationID(time.Now()) + if _, err := backup.FullBackup(ctx, cfg, genID); err != nil { + t.Fatalf("fullbackup: %v", err) + } + + res := RunDatabaseTest(ctx, cfg, PgConfig{TestPort: "55434"}, t.TempDir()) + if !res.Success { + t.Fatalf("erwartet erfolgreichen testlauf, habe: %+v", res) + } + if res.Source != genID { + t.Fatalf("source = %q, want %q", res.Source, genID) + } +} + +// TestRunDatabaseTest_DetectsCorruptedBackup ist Pruefung 2: absichtlich +// beschaedigte Sicherung laesst den Testlauf sichtbar fehlschlagen. +func TestRunDatabaseTest_DetectsCorruptedBackup(t *testing.T) { + cfg := requireDBTestConfig(t) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + genID := backup.NewGenerationID(time.Now()) + manifest, err := backup.FullBackup(ctx, cfg, genID) + if err != nil { + t.Fatalf("fullbackup: %v", err) + } + + // Absichtliche Beschaedigung: base.tar.gz durch Muell ersetzen. + tarPath := filepath.Join(filepath.Dir(manifest), backup.BaseTarGzFile) + if err := os.WriteFile(tarPath, []byte("das ist kein gueltiges tar.gz"), 0o600); err != nil { + t.Fatal(err) + } + + res := RunDatabaseTest(ctx, cfg, PgConfig{TestPort: "55435"}, t.TempDir()) + if res.Success { + t.Fatal("erwartet fehlschlag bei beschaedigter sicherung, testlauf meldete erfolg") + } + if res.Detail == "" { + t.Fatal("erwartet aussagekraeftiges detail zum fehlschlag") + } +} + +func requireObjTestConfig(t *testing.T) objectbackup.Config { + t.Helper() + if _, err := exec.LookPath("restic"); err != nil { + t.Skip("restic nicht installiert, Integrationstest uebersprungen") + } + cfg := objectbackup.Config{RepoDir: filepath.Join(t.TempDir(), "repo"), Password: "restoretest-passwort"} + if err := objectbackup.InitRepo(context.Background(), cfg); err != nil { + t.Fatalf("initrepo: %v", err) + } + return cfg +} + +// TestRunObjectTest_SucceedsAgainstRealSnapshot ist Pruefung 1 fuer den +// Objekt-Storage-Teil. +func TestRunObjectTest_SucceedsAgainstRealSnapshot(t *testing.T) { + cfg := requireObjTestConfig(t) + ctx := context.Background() + + sourceDir := t.TempDir() + if err := os.WriteFile(filepath.Join(sourceDir, "datei.txt"), []byte("inhalt"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := objectbackup.Backup(ctx, cfg, sourceDir); err != nil { + t.Fatalf("backup: %v", err) + } + + res := RunObjectTest(ctx, cfg, t.TempDir()) + if !res.Success { + t.Fatalf("erwartet erfolgreichen testlauf, habe: %+v", res) + } +} diff --git a/deploy/systemd/nexarch-archive-restoretest-metrics.service.tmpl b/deploy/systemd/nexarch-archive-restoretest-metrics.service.tmpl new file mode 100644 index 0000000..8e49689 --- /dev/null +++ b/deploy/systemd/nexarch-archive-restoretest-metrics.service.tmpl @@ -0,0 +1,14 @@ +[Unit] +Description=NEXARCH Archive - /metrics-Export fuer BAK-06 (dauerhaft, Pull-Modell fuer OPS-03) +After=network.target + +[Service] +Type=simple +User=nexarch +EnvironmentFile=/etc/nexarch/archive-restoretest.env +ExecStart=__INSTALL_DIR__/bin/restoretest-metrics +Restart=on-failure +StandardOutput=journal + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-archive-restoretest.service.tmpl b/deploy/systemd/nexarch-archive-restoretest.service.tmpl new file mode 100644 index 0000000..8ec2e02 --- /dev/null +++ b/deploy/systemd/nexarch-archive-restoretest.service.tmpl @@ -0,0 +1,11 @@ +[Unit] +Description=NEXARCH Archive - Restore-Testverfahren (BAK-06) +After=network.target postgresql.service + +[Service] +Type=oneshot +User=nexarch +Environment=PATH=/usr/bin:/bin:/usr/lib/postgresql/17/bin +EnvironmentFile=/etc/nexarch/archive-restoretest.env +ExecStart=__INSTALL_DIR__/bin/restoretest-cli +StandardOutput=journal diff --git a/deploy/systemd/nexarch-archive-restoretest.timer.tmpl b/deploy/systemd/nexarch-archive-restoretest.timer.tmpl new file mode 100644 index 0000000..be92203 --- /dev/null +++ b/deploy/systemd/nexarch-archive-restoretest.timer.tmpl @@ -0,0 +1,9 @@ +[Unit] +Description=Zeitplan fuer NEXARCH Archive Restore-Testverfahren (BAK-06) + +[Timer] +OnCalendar=Sun *-*-* 07:00:00 +Persistent=true + +[Install] +WantedBy=timers.target