diff --git a/archive/.golangci.yml b/archive/.golangci.yml new file mode 100644 index 0000000..0270bbc --- /dev/null +++ b/archive/.golangci.yml @@ -0,0 +1,18 @@ +version: "2" + +run: + timeout: 3m + +linters: + default: none + enable: + - govet + - staticcheck + - errcheck + - unused + - ineffassign + +formatters: + enable: + - gofmt + - goimports diff --git a/archive/Makefile b/archive/Makefile new file mode 100644 index 0000000..1a25384 --- /dev/null +++ b/archive/Makefile @@ -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 diff --git a/archive/README.md b/archive/README.md new file mode 100644 index 0000000..b109250 --- /dev/null +++ b/archive/README.md @@ -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 +``` diff --git a/archive/cmd/backup-cli/main.go b/archive/cmd/backup-cli/main.go new file mode 100644 index 0000000..a68fc60 --- /dev/null +++ b/archive/cmd/backup-cli/main.go @@ -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 [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]) + } +} diff --git a/archive/docs/BAK-01-PRUEFPROTOKOLL.md b/archive/docs/BAK-01-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..de5e8a2 --- /dev/null +++ b/archive/docs/BAK-01-PRUEFPROTOKOLL.md @@ -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 + (`//full/` bzw. `.../incremental//`). +- `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. diff --git a/archive/go.mod b/archive/go.mod new file mode 100644 index 0000000..58807a5 --- /dev/null +++ b/archive/go.mod @@ -0,0 +1,3 @@ +module gitea.perlbach24.de/scripte/nexarch/archive + +go 1.22 diff --git a/archive/internal/backup/backup.go b/archive/internal/backup/backup.go new file mode 100644 index 0000000..8c3f372 --- /dev/null +++ b/archive/internal/backup/backup.go @@ -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 +} diff --git a/archive/internal/backup/backup_test.go b/archive/internal/backup/backup_test.go new file mode 100644 index 0000000..7fb411f --- /dev/null +++ b/archive/internal/backup/backup_test.go @@ -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) + } + } +} diff --git a/archive/internal/backup/rotate.go b/archive/internal/backup/rotate.go new file mode 100644 index 0000000..bf31ab0 --- /dev/null +++ b/archive/internal/backup/rotate.go @@ -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 +} diff --git a/archive/internal/backup/verify.go b/archive/internal/backup/verify.go new file mode 100644 index 0000000..2d42e7d --- /dev/null +++ b/archive/internal/backup/verify.go @@ -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 +} diff --git a/deploy/systemd/nexarch-archive-backup-full.service.tmpl b/deploy/systemd/nexarch-archive-backup-full.service.tmpl new file mode 100644 index 0000000..70f7b68 --- /dev/null +++ b/deploy/systemd/nexarch-archive-backup-full.service.tmpl @@ -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 diff --git a/deploy/systemd/nexarch-archive-backup-full.timer.tmpl b/deploy/systemd/nexarch-archive-backup-full.timer.tmpl new file mode 100644 index 0000000..0434aab --- /dev/null +++ b/deploy/systemd/nexarch-archive-backup-full.timer.tmpl @@ -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 diff --git a/deploy/systemd/nexarch-archive-backup-incremental.service.tmpl b/deploy/systemd/nexarch-archive-backup-incremental.service.tmpl new file mode 100644 index 0000000..0c167e1 --- /dev/null +++ b/deploy/systemd/nexarch-archive-backup-incremental.service.tmpl @@ -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 diff --git a/deploy/systemd/nexarch-archive-backup-incremental.timer.tmpl b/deploy/systemd/nexarch-archive-backup-incremental.timer.tmpl new file mode 100644 index 0000000..2830f70 --- /dev/null +++ b/deploy/systemd/nexarch-archive-backup-incremental.timer.tmpl @@ -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 diff --git a/deploy/systemd/nexarch-archive-backup-rotate.service.tmpl b/deploy/systemd/nexarch-archive-backup-rotate.service.tmpl new file mode 100644 index 0000000..db45a02 --- /dev/null +++ b/deploy/systemd/nexarch-archive-backup-rotate.service.tmpl @@ -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 diff --git a/deploy/systemd/nexarch-archive-backup-rotate.timer.tmpl b/deploy/systemd/nexarch-archive-backup-rotate.timer.tmpl new file mode 100644 index 0000000..4725510 --- /dev/null +++ b/deploy/systemd/nexarch-archive-backup-rotate.timer.tmpl @@ -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