BAK-01: datenbank-backup-strategie
Neues Modul-Verzeichnis code/archive/ (Monorepo-Muster wie code/dms/). PostgreSQL-17-natives inkrementelles Backup (pg_basebackup --incremental, WAL-Summarization) statt WAL-Archiving, um den geteilten Testhost ohne Neustart umzustellen (summarize_wal=on per pg_reload_conf). Rolle nexarch_backup mit REPLICATION-Attribut angelegt. internal/backup: FullBackup/IncrementalBackup (pg_basebackup-Wrapper), Verify (vollstaendiges Lesen von base.tar.gz, gzip+tar, nicht nur Header), Rotate/ListGenerations (generationsbasiert, aeltere zuerst entfernt). cmd/backup-cli fuer systemd-Timer-Aufruf (deploy/systemd/ nexarch-archive-backup-*.timer, taeglich/stuendlich/taeglich). Auf 192.168.1.131 verifiziert: 4/4 Tests gegen echte Postgres-17-Instanz (kein Mock) - inkrementelle Sicherung real kleiner als Vollsicherung, Verifikation erkennt absichtlich beschaedigte Datei, Rotation entfernt nur die aeltesten Generationen. Zusaetzlich ECHT verdrahtet: backup-cli gebaut, 3 systemd-Timer installiert+aktiviert, jeder der drei Dienste einmal ueber systemctl start end-to-end ausgeloest (status=0/SUCCESS je Dienst) - nicht nur go test, sondern der reale Automatisierungspfad selbst geprueft. Siehe archive/docs/BAK-01-PRUEFPROTOKOLL.md fuer alle Pruefungsergebnisse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c895a67c4b
commit
5e4b91c4e9
@@ -0,0 +1,18 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 3m
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
enable:
|
||||||
|
- govet
|
||||||
|
- staticcheck
|
||||||
|
- errcheck
|
||||||
|
- unused
|
||||||
|
- ineffassign
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofmt
|
||||||
|
- goimports
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.PHONY: build lint fmt test check
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
gofmt -l .
|
||||||
|
@test -z "$$(gofmt -l .)" || (echo "gofmt-Verstoesse gefunden, siehe oben" && exit 1)
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./... -p 1 -count=1
|
||||||
|
|
||||||
|
check: build
|
||||||
|
go vet ./...
|
||||||
|
golangci-lint run ./...
|
||||||
|
go test ./... -p 1 -count=1
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# NEXARCH Archive
|
||||||
|
|
||||||
|
Zentrales, modulübergreifendes Modul für Aufbewahrung, WORM, Compliance und
|
||||||
|
Backup. Dieses Verzeichnis enthält bisher `internal/backup` (BAK-01,
|
||||||
|
Datenbank-Backup-Strategie) — weitere Bausteine folgen ticketweise.
|
||||||
|
|
||||||
|
## BAK-01: Datenbank-Backup
|
||||||
|
|
||||||
|
`cmd/backup-cli` — Aufrufpunkt für systemd-Timer (siehe
|
||||||
|
`../deploy/systemd/nexarch-archive-backup-*.timer`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NEXARCH_BACKUP_PG_USER=nexarch_backup
|
||||||
|
export NEXARCH_BACKUP_PG_PASSWORD=...
|
||||||
|
export NEXARCH_BACKUP_DIR=/var/backups/nexarch
|
||||||
|
export NEXARCH_BACKUP_KEEP_GENERATIONS=7 # optional, Default 7
|
||||||
|
|
||||||
|
backup-cli full # neue Vollsicherung + Verifikation
|
||||||
|
backup-cli incremental # inkrementelle Sicherung gegen die neueste Generation
|
||||||
|
backup-cli rotate # entfernt alle bis auf die neuesten N Generationen
|
||||||
|
```
|
||||||
|
|
||||||
|
Voraussetzung: die konfigurierte Postgres-Rolle braucht das
|
||||||
|
`REPLICATION`-Attribut (`pg_basebackup` nutzt eine
|
||||||
|
Replikationsverbindung), und `summarize_wal = on` muss serverseitig gesetzt
|
||||||
|
sein (PostgreSQL 17s natives inkrementelles Backup, keine WAL-Archivierung
|
||||||
|
nötig).
|
||||||
|
|
||||||
|
## Prüfungen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make check # build + vet + lint + test, analog Core/DMS
|
||||||
|
```
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// backup-cli ist der Aufrufpunkt für BAK-01, gedacht für systemd-Timer
|
||||||
|
// (siehe deploy/systemd/) — "automatisiert nach Zeitplan" (Akzeptanzkriterium
|
||||||
|
// 1) entsteht durch die Zeitplan-Definition im Timer-Unit, nicht durch
|
||||||
|
// einen eigenen In-Prozess-Scheduler (kein zusätzlicher Dauerprozess nötig,
|
||||||
|
// passt zur Produkt-DNA "kein Anwendungsserver mit unnötigem
|
||||||
|
// Ressourcenverbrauch").
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/archive/internal/backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadConfig() backup.Config {
|
||||||
|
cfg := 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"),
|
||||||
|
}
|
||||||
|
if cfg.Host == "" {
|
||||||
|
cfg.Host = "localhost"
|
||||||
|
}
|
||||||
|
if cfg.Port == "" {
|
||||||
|
cfg.Port = "5432"
|
||||||
|
}
|
||||||
|
if cfg.User == "" || cfg.Password == "" || cfg.BackupDir == "" {
|
||||||
|
log.Fatal("NEXARCH_BACKUP_PG_USER, NEXARCH_BACKUP_PG_PASSWORD und NEXARCH_BACKUP_DIR muessen gesetzt sein")
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestManifest(backupDir string) (string, error) {
|
||||||
|
generations, err := backup.ListGenerations(backupDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(generations) == 0 {
|
||||||
|
return "", fmt.Errorf("keine vorhandene generation fuer inkrementelle sicherung gefunden - zuerst 'full' ausfuehren")
|
||||||
|
}
|
||||||
|
latest := generations[len(generations)-1]
|
||||||
|
full := backupDir + "/" + latest + "/" + backup.FullBackupDirName + "/" + backup.BackupManifestFile
|
||||||
|
if _, err := os.Stat(full); err == nil {
|
||||||
|
return full, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("kein backup_manifest in der neuesten generation %q gefunden", latest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
log.Fatal("aufruf: backup-cli <full|incremental|verify|rotate> [args]")
|
||||||
|
}
|
||||||
|
cfg := loadConfig()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "full":
|
||||||
|
genID := backup.NewGenerationID(time.Now())
|
||||||
|
manifest, err := backup.FullBackup(ctx, cfg, genID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("vollsicherung fehlgeschlagen: %v", err)
|
||||||
|
}
|
||||||
|
dir := manifest[:len(manifest)-len("/"+backup.BackupManifestFile)]
|
||||||
|
if err := backup.Verify(dir); err != nil {
|
||||||
|
log.Fatalf("verifikation der vollsicherung fehlgeschlagen: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("vollsicherung %q erstellt und verifiziert: %s\n", genID, manifest)
|
||||||
|
|
||||||
|
case "incremental":
|
||||||
|
manifest, err := latestManifest(cfg.BackupDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
generations, _ := backup.ListGenerations(cfg.BackupDir)
|
||||||
|
genID := generations[len(generations)-1]
|
||||||
|
incID := backup.NewGenerationID(time.Now())
|
||||||
|
newManifest, err := backup.IncrementalBackup(ctx, cfg, genID, incID, manifest)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("inkrementelle sicherung fehlgeschlagen: %v", err)
|
||||||
|
}
|
||||||
|
dir := newManifest[:len(newManifest)-len("/"+backup.BackupManifestFile)]
|
||||||
|
if err := backup.Verify(dir); err != nil {
|
||||||
|
log.Fatalf("verifikation der inkrementellen sicherung fehlgeschlagen: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("inkrementelle sicherung %q erstellt und verifiziert: %s\n", incID, newManifest)
|
||||||
|
|
||||||
|
case "rotate":
|
||||||
|
keep := 7
|
||||||
|
if v := os.Getenv("NEXARCH_BACKUP_KEEP_GENERATIONS"); v != "" {
|
||||||
|
_, _ = fmt.Sscanf(v, "%d", &keep)
|
||||||
|
}
|
||||||
|
removed, err := backup.Rotate(cfg.BackupDir, keep)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("rotation fehlgeschlagen: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("rotation abgeschlossen, %d generation(en) entfernt: %v\n", len(removed), removed)
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.Fatalf("unbekannter befehl %q", os.Args[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# BAK-01 – Prüfprotokoll: Datenbank-Backup-Strategie
|
||||||
|
|
||||||
|
Welle 1, keine Vorbedingungen. Neues Modul-Verzeichnis `code/archive/`
|
||||||
|
(gleiches Monorepo-Muster wie `code/dms/`), eigenes Go-Modul
|
||||||
|
`gitea.perlbach24.de/scripte/nexarch/archive`.
|
||||||
|
|
||||||
|
## Grundsatzentscheidung: PostgreSQL-17-natives inkrementelles Backup
|
||||||
|
|
||||||
|
`pg_dump` kennt nur logische Vollsicherungen — "inkrementell" im Sinne des
|
||||||
|
Tickets erfordert das physische Backup-Verfahren. Gewählt: PostgreSQL 17s
|
||||||
|
natives `pg_basebackup --incremental` (WAL-Summarization), NICHT klassisches
|
||||||
|
WAL-Archiving (`archive_mode`), weil letzteres einen Neustart der
|
||||||
|
(geteilten, auch von Core/DMS-Tests genutzten) Postgres-Instanz auf
|
||||||
|
192.168.1.131 erfordert hätte. Stattdessen `summarize_wal = on` gesetzt —
|
||||||
|
nur ein `pg_reload_conf()`, kein Neustart, keine Unterbrechung laufender
|
||||||
|
Verbindungen (per Health-Check nach der Änderung bestätigt).
|
||||||
|
|
||||||
|
Voraussetzung geschaffen: Rolle `nexarch_backup` mit `REPLICATION`-Attribut
|
||||||
|
angelegt (Postgres verlangt eine Replikationsverbindung für
|
||||||
|
`pg_basebackup`), `pg_hba.conf` erlaubte lokale Replikationsverbindungen
|
||||||
|
bereits.
|
||||||
|
|
||||||
|
## Umsetzung
|
||||||
|
|
||||||
|
- `internal/backup.FullBackup`/`IncrementalBackup` — rufen `pg_basebackup`
|
||||||
|
über `os/exec` auf, Ergebnis landet in einer Generationsstruktur
|
||||||
|
(`<BackupDir>/<Generation>/full/` bzw. `.../incremental/<ID>/`).
|
||||||
|
- `internal/backup.Verify` — öffnet `base.tar.gz` vollständig (gzip- UND
|
||||||
|
tar-Stream, jeder Eintrag bis zum Ende gelesen, nicht nur Kopfdaten) —
|
||||||
|
Akzeptanzkriterium 2: Verifikation auf Lesbarkeit, nicht nur Erstellung.
|
||||||
|
- `internal/backup.Rotate`/`ListGenerations` — Generationen sind nach
|
||||||
|
Zeitstempel-ID sortierbar, `Rotate` entfernt die ältesten bis auf `keep`
|
||||||
|
komplett (inklusive aller abhängigen Inkremente).
|
||||||
|
- `cmd/backup-cli` — `full`/`incremental`/`rotate`, aufgerufen von
|
||||||
|
systemd-Timern (`deploy/systemd/nexarch-archive-backup-*.timer`) —
|
||||||
|
"automatisiert nach Zeitplan" (Akzeptanzkriterium 1) entsteht durch die
|
||||||
|
Timer-Definition, kein zusätzlicher Dauerprozess nötig.
|
||||||
|
|
||||||
|
## Prüfungen
|
||||||
|
|
||||||
|
| # | Prüfung | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Sicherung gegen Testdatenbank erfolgreich erstellt und verifiziert | **bestanden** — `TestFullBackup_CreatesVerifiedBackup` gegen die echte Postgres-17-Instanz auf 192.168.1.131 (kein Mock), zusätzlich `TestIncrementalBackup_IsSmallerThanFull`: inkrementelle Sicherung real deutlich kleiner als Vollsicherung (167 KB vs. 16 MB bei der ersten manuellen Probe) — beweist echte inkrementelle Übertragung, nicht nur eine zweite Vollsicherung |
|
||||||
|
| 2 | Verifikation erkennt eine absichtlich beschädigte Sicherungsdatei | **bestanden** — `TestVerify_DetectsCorruptedFile`: 64 Bytes in der Mitte von `base.tar.gz` gekippt, `Verify` schlägt danach fehl (unbeschädigt zuvor erfolgreich) |
|
||||||
|
| 3 | Rotationsregel entfernt nachweislich nur die ältesten Generationen | **bestanden** — `TestRotate_RemovesOnlyOldestGenerations`: 5 Generationen, `keep=2`, exakt die 3 ältesten entfernt, die 2 neuesten nachweislich unangetastet |
|
||||||
|
|
||||||
|
## Echte Verdrahtung auf 192.168.1.131 (nicht nur Testcode)
|
||||||
|
|
||||||
|
Anders als die zuletzt in DMS gefundenen "Baustein existiert, ist aber
|
||||||
|
nirgends verdrahtet"-Fälle (FDN-03/FDN-09 gegen Core) wurde hier die
|
||||||
|
komplette Kette tatsächlich installiert und ausgeführt:
|
||||||
|
|
||||||
|
- `backup-cli` gebaut nach `/opt/nexarch-archive/bin/`
|
||||||
|
- `/etc/nexarch/archive-backup.env` mit den Verbindungsdaten (0600)
|
||||||
|
- 3 systemd-Timer installiert und aktiviert (`enable --now`):
|
||||||
|
Vollsicherung täglich 02:00 UTC, Inkrement stündlich, Rotation täglich
|
||||||
|
03:00 UTC (`systemctl list-timers` bestätigt alle drei scharf)
|
||||||
|
- Jeder der drei Dienste (`full`/`incremental`/`rotate`) einmal manuell über
|
||||||
|
`systemctl start` ausgelöst (nicht nur `go test` direkt) — alle drei mit
|
||||||
|
`status=0/SUCCESS`, Journal bestätigt inhaltlich korrekte Ausgabe
|
||||||
|
(Vollsicherung erstellt+verifiziert, Inkrement erstellt+verifiziert
|
||||||
|
gegen die richtige Vorgänger-Generation, Rotation lief ohne Fehler)
|
||||||
|
|
||||||
|
## 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 -> 4/4 Tests ok, 0 Fehlschläge (echter Postgres 17, kein Mock)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gesamtergebnis
|
||||||
|
|
||||||
|
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
|
||||||
|
real erfüllt — inklusive tatsächlicher systemd-Timer-Installation und
|
||||||
|
manuell ausgelöstem End-to-End-Lauf aller drei Dienste auf dem Testhost,
|
||||||
|
nicht nur isolierter Testcode.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module gitea.perlbach24.de/scripte/nexarch/archive
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Package backup implementiert BAK-01: automatisierte, inkrementelle
|
||||||
|
// Sicherung der PostgreSQL-Datenbank per pg_basebackup (PostgreSQL 17s
|
||||||
|
// natives inkrementelles Backup über WAL-Summarization, siehe
|
||||||
|
// `summarize_wal`), mit Verifikation jeder Sicherung und
|
||||||
|
// generationsbasierter Rotation. Kein pg_dump-basierter Ansatz, weil
|
||||||
|
// pg_dump ausschließlich logische Vollsicherungen kennt — "inkrementell"
|
||||||
|
// im Sinne des Tickets erfordert das physische, WAL-summary-gestützte
|
||||||
|
// Verfahren aus PostgreSQL 17.
|
||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config enthält die Verbindungsdaten für pg_basebackup — ausschließlich
|
||||||
|
// über Umgebungsvariablen befüllt, nie im Code (siehe Ticket-Abschluss-
|
||||||
|
// Regel).
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
BackupDir string
|
||||||
|
PgBaseBackupPath string // Default "pg_basebackup", überschreibbar für Tests
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) binary() string {
|
||||||
|
if c.PgBaseBackupPath != "" {
|
||||||
|
return c.PgBaseBackupPath
|
||||||
|
}
|
||||||
|
return "pg_basebackup"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullBackupDirName/IncrementalDirName sind die festen Unterverzeichnis-
|
||||||
|
// namen je Generation.
|
||||||
|
const (
|
||||||
|
FullBackupDirName = "full"
|
||||||
|
IncrementalSubdir = "incremental"
|
||||||
|
BackupManifestFile = "backup_manifest"
|
||||||
|
BaseTarGzFile = "base.tar.gz"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewGenerationID liefert eine sortierbare, eindeutige Generation-Kennung
|
||||||
|
// (RFC3339-artig, dateisystemtauglich) — Generationen werden anhand dieser
|
||||||
|
// Kennung chronologisch sortiert (Rotate, ListGenerations).
|
||||||
|
func NewGenerationID(t time.Time) string {
|
||||||
|
return t.UTC().Format("20060102T150405Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullBackup erstellt eine neue Vollsicherung (Akzeptanzkriterium 1) als
|
||||||
|
// eigene Generation. Liefert den Pfad zum backup_manifest, das spätere
|
||||||
|
// IncrementalBackup-Aufrufe als Referenz brauchen.
|
||||||
|
func FullBackup(ctx context.Context, cfg Config, generationID string) (manifestPath string, err error) {
|
||||||
|
dir := filepath.Join(cfg.BackupDir, generationID, FullBackupDirName)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dir), 0o750); err != nil {
|
||||||
|
return "", fmt.Errorf("backup: generationsverzeichnis anlegen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User,
|
||||||
|
"-D", dir, "-Ft", "-z", "--checkpoint=fast", "--no-password",
|
||||||
|
}
|
||||||
|
if err := runPgBaseBackup(ctx, cfg, args); err != nil {
|
||||||
|
return "", fmt.Errorf("backup: vollsicherung: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, BackupManifestFile), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementalBackup erstellt eine inkrementelle Sicherung gegen die zuletzt
|
||||||
|
// bekannte Vollsicherung ODER die letzte Inkrement-Sicherung (priorManifestPath
|
||||||
|
// zeigt jeweils auf das backup_manifest der Referenz).
|
||||||
|
func IncrementalBackup(ctx context.Context, cfg Config, generationID, incrementID, priorManifestPath string) (manifestPath string, err error) {
|
||||||
|
dir := filepath.Join(cfg.BackupDir, generationID, IncrementalSubdir, incrementID)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dir), 0o750); err != nil {
|
||||||
|
return "", fmt.Errorf("backup: inkrement-verzeichnis anlegen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User,
|
||||||
|
"-D", dir, "-Ft", "-z", "--checkpoint=fast", "--no-password",
|
||||||
|
"--incremental=" + priorManifestPath,
|
||||||
|
}
|
||||||
|
if err := runPgBaseBackup(ctx, cfg, args); err != nil {
|
||||||
|
return "", fmt.Errorf("backup: inkrementelle sicherung: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, BackupManifestFile), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPgBaseBackup(ctx context.Context, cfg Config, args []string) error {
|
||||||
|
cmd := exec.CommandContext(ctx, cfg.binary(), args...)
|
||||||
|
cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s fehlgeschlagen: %w (ausgabe: %s)", cfg.binary(), err, string(output))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func requireTestConfig(t *testing.T) Config {
|
||||||
|
t.Helper()
|
||||||
|
user := os.Getenv("TEST_BACKUP_PG_USER")
|
||||||
|
if user == "" {
|
||||||
|
t.Skip("TEST_BACKUP_PG_USER nicht gesetzt, Integrationstest uebersprungen (braucht echten Postgres mit REPLICATION-Rolle)")
|
||||||
|
}
|
||||||
|
return Config{
|
||||||
|
Host: envOr("TEST_BACKUP_PG_HOST", "localhost"),
|
||||||
|
Port: envOr("TEST_BACKUP_PG_PORT", "5432"),
|
||||||
|
User: user,
|
||||||
|
Password: os.Getenv("TEST_BACKUP_PG_PASSWORD"),
|
||||||
|
BackupDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFullBackup_CreatesVerifiedBackup ist Pruefung 1: Sicherung gegen
|
||||||
|
// Testdatenbank erfolgreich erstellt und verifiziert.
|
||||||
|
func TestFullBackup_CreatesVerifiedBackup(t *testing.T) {
|
||||||
|
cfg := requireTestConfig(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
genID := NewGenerationID(time.Now())
|
||||||
|
manifest, err := FullBackup(ctx, cfg, genID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fullbackup: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(manifest); err != nil {
|
||||||
|
t.Fatalf("backup_manifest fehlt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(manifest)
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, BaseTarGzFile)); err != nil {
|
||||||
|
t.Fatalf("%s fehlt: %v", BaseTarGzFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Verify(dir); err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIncrementalBackup_IsSmallerThanFull ist der Nachweis fuer
|
||||||
|
// Akzeptanzkriterium 1 (inkrementell): eine echte inkrementelle Sicherung
|
||||||
|
// gegen unveraenderten Bestand ist deutlich kleiner als die Vollsicherung —
|
||||||
|
// beweist, dass tatsaechlich nur Aenderungen uebertragen wurden (PostgreSQL
|
||||||
|
// 17 WAL-Summarization), nicht nochmal alles.
|
||||||
|
func TestIncrementalBackup_IsSmallerThanFull(t *testing.T) {
|
||||||
|
cfg := requireTestConfig(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
genID := NewGenerationID(time.Now())
|
||||||
|
fullManifest, err := FullBackup(ctx, cfg, genID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fullbackup: %v", err)
|
||||||
|
}
|
||||||
|
fullDir := filepath.Dir(fullManifest)
|
||||||
|
fullSize := fileSize(t, filepath.Join(fullDir, BaseTarGzFile))
|
||||||
|
|
||||||
|
incID := NewGenerationID(time.Now().Add(time.Second))
|
||||||
|
incManifest, err := IncrementalBackup(ctx, cfg, genID, incID, fullManifest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("incrementalbackup: %v", err)
|
||||||
|
}
|
||||||
|
incDir := filepath.Dir(incManifest)
|
||||||
|
if err := Verify(incDir); err != nil {
|
||||||
|
t.Fatalf("verify (inkrementell): %v", err)
|
||||||
|
}
|
||||||
|
incSize := fileSize(t, filepath.Join(incDir, BaseTarGzFile))
|
||||||
|
|
||||||
|
if incSize >= fullSize {
|
||||||
|
t.Fatalf("inkrementelle sicherung (%d bytes) ist nicht kleiner als die vollsicherung (%d bytes) - keine echte inkrementelle Uebertragung", incSize, fullSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSize(t *testing.T, path string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dateigroesse von %q ermitteln: %v", path, err)
|
||||||
|
}
|
||||||
|
return info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerify_DetectsCorruptedFile ist Pruefung 2: Verifikation erkennt eine
|
||||||
|
// absichtlich beschaedigte Sicherungsdatei.
|
||||||
|
func TestVerify_DetectsCorruptedFile(t *testing.T) {
|
||||||
|
cfg := requireTestConfig(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
genID := NewGenerationID(time.Now())
|
||||||
|
manifest, err := FullBackup(ctx, cfg, genID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fullbackup: %v", err)
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(manifest)
|
||||||
|
|
||||||
|
if err := Verify(dir); err != nil {
|
||||||
|
t.Fatalf("verify (unbeschaedigt) haette erfolgreich sein muessen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absichtliche Beschaedigung: mehrere Bytes in der Mitte der Datei kippen.
|
||||||
|
path := filepath.Join(dir, BaseTarGzFile)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sicherungsdatei lesen: %v", err)
|
||||||
|
}
|
||||||
|
mid := len(data) / 2
|
||||||
|
for i := mid; i < mid+64 && i < len(data); i++ {
|
||||||
|
data[i] ^= 0xFF
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
t.Fatalf("beschaedigte sicherungsdatei schreiben: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Verify(dir); err == nil {
|
||||||
|
t.Fatal("verify haette die beschaedigte sicherungsdatei erkennen muessen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRotate_RemovesOnlyOldestGenerations ist Pruefung 3.
|
||||||
|
func TestRotate_RemovesOnlyOldestGenerations(t *testing.T) {
|
||||||
|
backupDir := t.TempDir()
|
||||||
|
generationIDs := []string{
|
||||||
|
"20260101T000000Z",
|
||||||
|
"20260102T000000Z",
|
||||||
|
"20260103T000000Z",
|
||||||
|
"20260104T000000Z",
|
||||||
|
"20260105T000000Z",
|
||||||
|
}
|
||||||
|
for _, id := range generationIDs {
|
||||||
|
if err := os.MkdirAll(filepath.Join(backupDir, id, FullBackupDirName), 0o750); err != nil {
|
||||||
|
t.Fatalf("generation %q anlegen: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removed, err := Rotate(backupDir, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rotate: %v", err)
|
||||||
|
}
|
||||||
|
wantRemoved := []string{"20260101T000000Z", "20260102T000000Z", "20260103T000000Z"}
|
||||||
|
if len(removed) != len(wantRemoved) {
|
||||||
|
t.Fatalf("entfernte generationen = %v, want %v", removed, wantRemoved)
|
||||||
|
}
|
||||||
|
for i, w := range wantRemoved {
|
||||||
|
if removed[i] != w {
|
||||||
|
t.Fatalf("entfernte generationen = %v, want %v", removed, wantRemoved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining, err := ListGenerations(backupDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listgenerations: %v", err)
|
||||||
|
}
|
||||||
|
wantRemaining := []string{"20260104T000000Z", "20260105T000000Z"}
|
||||||
|
if len(remaining) != len(wantRemaining) {
|
||||||
|
t.Fatalf("verbleibende generationen = %v, want %v", remaining, wantRemaining)
|
||||||
|
}
|
||||||
|
for i, w := range wantRemaining {
|
||||||
|
if remaining[i] != w {
|
||||||
|
t.Fatalf("verbleibende generationen = %v, want %v", remaining, wantRemaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die NEUESTEN duerfen NICHT entfernt sein (Pruefung 3: nur die
|
||||||
|
// aeltesten Generationen).
|
||||||
|
for _, w := range wantRemaining {
|
||||||
|
if _, err := os.Stat(filepath.Join(backupDir, w)); err != nil {
|
||||||
|
t.Fatalf("neueste generation %q wurde faelschlich entfernt: %v", w, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListGenerations liefert alle Generation-IDs in backupDir, aufsteigend
|
||||||
|
// sortiert (die GenerationID selbst ist chronologisch sortierbar, siehe
|
||||||
|
// NewGenerationID — kein Blick auf Dateisystem-Zeitstempel nötig, die bei
|
||||||
|
// einem Restore/Kopiervorgang verändert werden könnten).
|
||||||
|
func ListGenerations(backupDir string) ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(backupDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("backup: sicherungsverzeichnis lesen: %w", err)
|
||||||
|
}
|
||||||
|
var generations []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
generations = append(generations, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(generations)
|
||||||
|
return generations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate entfernt alle bis auf die `keep` NEUESTEN Generationen
|
||||||
|
// (Akzeptanzkriterium 3) — jede Generation umfasst ihre Vollsicherung UND
|
||||||
|
// alle davon abhängigen Inkremente, ein Löschen der gesamten
|
||||||
|
// Generationsverzeichnisses entfernt beides konsistent zusammen.
|
||||||
|
func Rotate(backupDir string, keep int) (removed []string, err error) {
|
||||||
|
if keep < 0 {
|
||||||
|
keep = 0
|
||||||
|
}
|
||||||
|
generations, err := ListGenerations(backupDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(generations) <= keep {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
toRemove := generations[:len(generations)-keep]
|
||||||
|
for _, gen := range toRemove {
|
||||||
|
if err := os.RemoveAll(filepath.Join(backupDir, gen)); err != nil {
|
||||||
|
return removed, fmt.Errorf("backup: generation %q entfernen: %w", gen, err)
|
||||||
|
}
|
||||||
|
removed = append(removed, gen)
|
||||||
|
}
|
||||||
|
return removed, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrCorrupted wird geliefert, wenn eine Sicherungsdatei nicht lesbar ist
|
||||||
|
// (Akzeptanzkriterium 2: Verifikation, nicht nur Erstellungs-Prüfung).
|
||||||
|
var ErrCorrupted = fmt.Errorf("backup: sicherungsdatei ist beschaedigt oder unvollstaendig")
|
||||||
|
|
||||||
|
// Verify prüft, dass base.tar.gz im gegebenen Sicherungsverzeichnis
|
||||||
|
// vollständig lesbar ist — öffnet gzip- UND tar-Stream und liest JEDEN
|
||||||
|
// Eintrag bis zum Ende durch (nicht nur die Kopfdaten), damit ein
|
||||||
|
// abgeschnittener oder mit kaputten Bytes überschriebener Inhalt
|
||||||
|
// zuverlässig auffällt, nicht nur ein defekter Tar-Header.
|
||||||
|
func Verify(backupDir string) error {
|
||||||
|
path := filepath.Join(backupDir, BaseTarGzFile)
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %s nicht lesbar: %v", ErrCorrupted, path, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
gz, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: gzip-header ungueltig: %v", ErrCorrupted, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = gz.Close() }()
|
||||||
|
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
entries := 0
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: tar-eintrag ungueltig: %v", ErrCorrupted, err)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(io.Discard, tr); err != nil {
|
||||||
|
return fmt.Errorf("%w: inhalt von %q nicht vollstaendig lesbar: %v", ErrCorrupted, hdr.Name, err)
|
||||||
|
}
|
||||||
|
entries++
|
||||||
|
}
|
||||||
|
if entries == 0 {
|
||||||
|
return fmt.Errorf("%w: archiv enthaelt keine eintraege", ErrCorrupted)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=NEXARCH Archive - Datenbank-Vollsicherung (BAK-01)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=nexarch
|
||||||
|
EnvironmentFile=/etc/nexarch/archive-backup.env
|
||||||
|
ExecStart=__INSTALL_DIR__/bin/backup-cli full
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Taeglicher Zeitplan fuer NEXARCH Archive Datenbank-Vollsicherung (BAK-01)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 02:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=NEXARCH Archive - Datenbank-Inkrementalsicherung (BAK-01)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=nexarch
|
||||||
|
EnvironmentFile=/etc/nexarch/archive-backup.env
|
||||||
|
ExecStart=__INSTALL_DIR__/bin/backup-cli incremental
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Stuendlicher Zeitplan fuer NEXARCH Archive Datenbank-Inkrementalsicherung (BAK-01)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* *:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=NEXARCH Archive - Sicherungsgenerationen-Rotation (BAK-01)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=nexarch
|
||||||
|
EnvironmentFile=/etc/nexarch/archive-backup.env
|
||||||
|
ExecStart=__INSTALL_DIR__/bin/backup-cli rotate
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Taeglicher Zeitplan fuer NEXARCH Archive Sicherungsgenerationen-Rotation (BAK-01)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Reference in New Issue
Block a user