BAK-02: objekt-storage-backup-snapshots
restic statt Eigenbau (Content-defined Chunking fuer Dedup, verschluesseltes Repository ab Werk, geprueftes Tooling). internal/objectbackup wrapt restic init/backup/check/forget ueber os/exec, cmd/objectbackup-cli fuer systemd-Timer (stuendliche Sicherung, woechentliche Vollstaendigkeitspruefung, taegliche Rotation). Auf 192.168.1.131 real verifiziert (kein Mock): zweiter Lauf gegen unveraenderten Bestand ueberraegt nichts (files_new=0), zwei identische Dateien erzeugen nachweislich nur 1 data_blob (echter Dedup-Nachweis), absichtlich gekipptes Byte in einer Pack-Datei wird von check --read-data zuverlaessig erkannt. Echt verdrahtet: 3 systemd-Timer installiert+ aktiviert, jeder Dienst einmal end-to-end via systemctl start ausgeloest. Im selben Rutsch BAK-01-Fund korrigiert: NEXARCH_BACKUP_DIR zeigte auf /var/backups/nexarch (Root-Dateisystem, kein Dataset) statt auf das persistente ZFS-Dataset /var/nexarch-archiv - korrigiert, erneut ausgeloest, landet nachweislich am richtigen Ort. objectbackup-cli-Repository liegt von Anfang an korrekt dort. Siehe archive/docs/BAK-02-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
5e4b91c4e9
commit
10ba866f0e
+30
-1
@@ -12,7 +12,7 @@ Datenbank-Backup-Strategie) — weitere Bausteine folgen ticketweise.
|
||||
```bash
|
||||
export NEXARCH_BACKUP_PG_USER=nexarch_backup
|
||||
export NEXARCH_BACKUP_PG_PASSWORD=...
|
||||
export NEXARCH_BACKUP_DIR=/var/backups/nexarch
|
||||
export NEXARCH_BACKUP_DIR=/var/nexarch-archiv/backups/postgres # NICHT auf einem ephemeren Test-Dataset (siehe Betrieb)
|
||||
export NEXARCH_BACKUP_KEEP_GENERATIONS=7 # optional, Default 7
|
||||
|
||||
backup-cli full # neue Vollsicherung + Verifikation
|
||||
@@ -26,6 +26,35 @@ Replikationsverbindung), und `summarize_wal = on` muss serverseitig gesetzt
|
||||
sein (PostgreSQL 17s natives inkrementelles Backup, keine WAL-Archivierung
|
||||
nötig).
|
||||
|
||||
## BAK-02: Objekt-Storage-Backup
|
||||
|
||||
`cmd/objectbackup-cli` sichert einen lokalen Verzeichnisbaum (den
|
||||
FDN-03-`LocalDriver`-Basisordner direkt, oder — für S3-gestützte
|
||||
Deployments — einen vorgelagerten `rclone`-Spiegel) mit
|
||||
[restic](https://restic.net) (Content-defined Chunking, verschlüsseltes
|
||||
Repository, geprüftes Tooling statt Eigenbau):
|
||||
|
||||
```bash
|
||||
export NEXARCH_OBJECTBACKUP_REPO_DIR=/var/nexarch-archiv/backups/objects
|
||||
export NEXARCH_OBJECTBACKUP_PASSWORD=...
|
||||
export NEXARCH_OBJECTBACKUP_KEEP_SNAPSHOTS=30 # optional, Default 7
|
||||
|
||||
objectbackup-cli backup /var/nexarch-objects # Sicherung + Verifikation
|
||||
objectbackup-cli check # vollständiges Lesen aller Datenblöcke
|
||||
objectbackup-cli rotate # restic forget --keep-last N --prune
|
||||
```
|
||||
|
||||
## Betrieb: Backup-Zielverzeichnis
|
||||
|
||||
Backup-Ziele liegen unter `/var/nexarch-archiv/` (persistentes ZFS-Dataset,
|
||||
`zfs/data/subvol-1131-disk-0` auf 192.168.1.131), NIEMALS unter
|
||||
`/var/nexarch-test/` (ephemeres Dataset, wird von den `reset-test-env.sh`-
|
||||
Skripten der anderen Module geleert). ZFS-seitige Snapshots/Replikation
|
||||
dieses Datasets sind ein eigenständiges Infra-Runbook (siehe
|
||||
`../../STORAGE-KONZEPT.md` Abschnitt 7), kein Ticket-Code — `zfs
|
||||
dedup=on` bewusst NICHT setzen (hoher RAM-Bedarf), Deduplizierung läuft
|
||||
ausschließlich App-seitig über restic.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// objectbackup-cli ist der Aufrufpunkt für BAK-02, für systemd-Timer
|
||||
// gedacht (siehe deploy/systemd/) — "automatisiert nach Zeitplan" entsteht
|
||||
// durch die Timer-Definition, kein eigener Dauerprozess (dieselbe
|
||||
// Begründung wie BAK-01 / cmd/backup-cli).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/objectbackup"
|
||||
)
|
||||
|
||||
func loadConfig() objectbackup.Config {
|
||||
cfg := objectbackup.Config{
|
||||
RepoDir: os.Getenv("NEXARCH_OBJECTBACKUP_REPO_DIR"),
|
||||
Password: os.Getenv("NEXARCH_OBJECTBACKUP_PASSWORD"),
|
||||
}
|
||||
if cfg.RepoDir == "" || cfg.Password == "" {
|
||||
log.Fatal("NEXARCH_OBJECTBACKUP_REPO_DIR und NEXARCH_OBJECTBACKUP_PASSWORD muessen gesetzt sein")
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
log.Fatal("aufruf: objectbackup-cli <backup <quellverzeichnis>|check|rotate>")
|
||||
}
|
||||
cfg := loadConfig()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := objectbackup.InitRepo(ctx, cfg); err != nil {
|
||||
log.Fatalf("repository initialisieren: %v", err)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "backup":
|
||||
if len(os.Args) < 3 {
|
||||
log.Fatal("aufruf: objectbackup-cli backup <quellverzeichnis>")
|
||||
}
|
||||
summary, err := objectbackup.Backup(ctx, cfg, os.Args[2])
|
||||
if err != nil {
|
||||
log.Fatalf("sicherung fehlgeschlagen: %v", err)
|
||||
}
|
||||
if err := objectbackup.Check(ctx, cfg, false); err != nil {
|
||||
log.Fatalf("verifikation nach sicherung fehlgeschlagen: %v", err)
|
||||
}
|
||||
fmt.Printf("sicherung %q erstellt und verifiziert (neu=%d geaendert=%d unveraendert=%d)\n",
|
||||
summary.SnapshotID, summary.FilesNew, summary.FilesChanged, summary.FilesUnmodified)
|
||||
|
||||
case "check":
|
||||
if err := objectbackup.Check(ctx, cfg, true); err != nil {
|
||||
log.Fatalf("verifikation fehlgeschlagen: %v", err)
|
||||
}
|
||||
fmt.Println("verifikation (mit vollstaendigem lesen) erfolgreich")
|
||||
|
||||
case "rotate":
|
||||
keep := 7
|
||||
if v := os.Getenv("NEXARCH_OBJECTBACKUP_KEEP_SNAPSHOTS"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
keep = n
|
||||
}
|
||||
}
|
||||
if err := objectbackup.Forget(ctx, cfg, keep); err != nil {
|
||||
log.Fatalf("rotation fehlgeschlagen: %v", err)
|
||||
}
|
||||
fmt.Println("rotation abgeschlossen")
|
||||
|
||||
default:
|
||||
log.Fatalf("unbekannter befehl %q", os.Args[1])
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,15 @@ golangci-lint run ./... -> 0 issues
|
||||
go test ./... -p 1 -count=1 -> 4/4 Tests ok, 0 Fehlschläge (echter Postgres 17, kein Mock)
|
||||
```
|
||||
|
||||
## Nachtrag (BAK-02-Sitzung): Backup-Zielverzeichnis korrigiert
|
||||
|
||||
`NEXARCH_BACKUP_DIR` zeigte ursprünglich auf `/var/backups/nexarch`
|
||||
(Root-Dateisystem des Containers, kein dediziertes Dataset) — korrigiert auf
|
||||
`/var/nexarch-archiv/backups/postgres` (persistentes ZFS-Dataset), siehe
|
||||
`docs/BAK-02-PRUEFPROTOKOLL.md` Abschnitt „Korrektur an BAK-01" für Details.
|
||||
Vollsicherung nach der Korrektur erneut über systemd ausgelöst, landet
|
||||
nachweislich am neuen Ort.
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# BAK-02 – Prüfprotokoll: Objekt-Storage-Backup/Snapshots
|
||||
|
||||
Welle 1, keine Vorbedingungen.
|
||||
|
||||
## Grundsatzentscheidung: restic statt Eigenbau
|
||||
|
||||
Nutzerentscheidung: restic statt einer Neuimplementierung, weil restic alle
|
||||
vier Akzeptanzkriterien mit ausgereiftem, breit geprüftem Tooling erfüllt
|
||||
(Content-defined Chunking für Dedup, `check --read-data` für
|
||||
Vollständigkeit, `forget --keep-last` für Rotation, Repository-Verschlüsselung
|
||||
ab Werk). Installiert via `apt-get install restic` (Version 0.18.0).
|
||||
|
||||
Backup-Quelle ist ein lokaler Verzeichnisbaum — für den FDN-03-`LocalDriver`
|
||||
direkt dessen Basisverzeichnis. Für S3-gestützte Produktions-Deployments
|
||||
(Betriebsmodus 2/3 aus `STORAGE-KONZEPT.md` Abschnitt 6.2) wäre ein
|
||||
vorgelagerter Sync-Schritt (z. B. `rclone`) nötig, um Bucket-Inhalte lokal
|
||||
zu spiegeln, bevor restic sie sichert — restic sichert Dateibäume, keine
|
||||
S3-Buckets direkt. Das bleibt hier bewusst unimplementiert (kein konkreter
|
||||
S3-Produktionsbestand vorhanden, der das aktuell erfordert), aber
|
||||
architektonisch vorgesehen und dokumentiert (`README.md`).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `internal/objectbackup.InitRepo` — idempotent, erkennt "bereits
|
||||
initialisiert" am `restic init`-Fehlertext statt zu scheitern.
|
||||
- `internal/objectbackup.Backup` — `restic backup --json`, parst die
|
||||
`summary`-Zeile (mehrere JSON-Zeilen in der Ausgabe, gezielt die mit
|
||||
`message_type=="summary"` gesucht).
|
||||
- `internal/objectbackup.Check` — `restic check [--read-data]` (Akzeptanz-
|
||||
kriterium 3: Vollständigkeitsprüfung).
|
||||
- `internal/objectbackup.Forget` — `restic forget --keep-last N --prune`
|
||||
(Rotation).
|
||||
- `cmd/objectbackup-cli` — `backup <dir>`/`check`/`rotate`, aufgerufen von
|
||||
systemd-Timern (stündlich/wöchentlich/täglich).
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Zweiter Sicherungslauf nach unverändertem Bestand überträgt keine Daten erneut | **bestanden** — `TestBackup_UnchangedSecondRunTransmitsNothingNew`: zweiter Lauf gegen unveränderten Bestand liefert `files_new=0`, `files_changed=0`, `files_unmodified=1` |
|
||||
| 2 | Zwei identische Testdateien belegen nachweislich nur einmal Speicherplatz | **bestanden** — `TestBackup_DeduplicatesIdenticalContent`: zwei Dateien mit identischem Inhalt erzeugen `data_blobs=1`, nicht 2 — echter Dedup-Nachweis über restics Content-defined Chunking, nicht nur Namensvergleich |
|
||||
| 3 | Vollständigkeitsprüfung erkennt ein fehlendes Objekt in der Sicherung | **bestanden** — `TestCheck_DetectsCorruptedPack`: ein Byte in einer echten Repository-Pack-Datei gekippt, `Check(readData=true)` schlägt danach fehl (unbeschädigt zuvor erfolgreich) — dieselbe Vorgehensweise wie die manuelle Recherche vor der Implementierung |
|
||||
|
||||
Zusätzlich (nicht explizit als Pflichtprüfung gefordert, aber Teil von
|
||||
Akzeptanzkriterium 3 „lässt sich einzeln prüfen"): `TestForget_
|
||||
KeepsOnlyRequestedSnapshotCount` — 3 Sicherungsläufe, `Forget(keepLast=1)`
|
||||
reduziert auf genau 1 verbleibenden Snapshot.
|
||||
|
||||
## Korrektur an BAK-01 im selben Rutsch: Backup-Zielverzeichnis
|
||||
|
||||
Nutzerhinweis aufgegriffen: `NEXARCH_BACKUP_DIR` zeigte bei BAK-01
|
||||
ursprünglich auf `/var/backups/nexarch` (Root-Dateisystem des LXC-
|
||||
Containers, nicht auf einem der beiden dedizierten ZFS-Datasets). Korrigiert
|
||||
auf `/var/nexarch-archiv/backups/postgres` (persistentes Dataset
|
||||
`zfs/data/subvol-1131-disk-0`), NICHT `/var/nexarch-test/` (ephemeres
|
||||
Dataset `ssd-rpool-data/swap/subvol-1131-disk-0`, wird von
|
||||
`reset-test-env.sh`-Skripten anderer Module geleert). `objectbackup-cli`s
|
||||
Repository liegt von Anfang an korrekt unter
|
||||
`/var/nexarch-archiv/backups/objects`. Beide Pfade real auf
|
||||
192.168.1.131 verifiziert (`df`/`mount` bestätigt ZFS-Dataset-Zuordnung),
|
||||
BAK-01s Vollsicherung nach der Korrektur erneut über systemd ausgelöst und
|
||||
bestätigt am neuen Ort gelandet.
|
||||
|
||||
ZFS-seitige Snapshot-/Replikations-Strategie für `nexarch/archiv` bleibt
|
||||
bewusst außerhalb dieses Tickets (Infra-Runbook, siehe
|
||||
`STORAGE-KONZEPT.md` Abschnitt 7 „Backup vs. Storage-Redundanz" sowie den
|
||||
Hinweis, `zfs dedup=on` NICHT zu setzen — App-seitige Dedup über restic
|
||||
genügt, ZFS-Dedup wäre auf dem 4-GB-Testhost ein Speicherrisiko).
|
||||
|
||||
## Echte Verdrahtung auf 192.168.1.131
|
||||
|
||||
- `objectbackup-cli` gebaut nach `/opt/nexarch-archive/bin/`
|
||||
- `/etc/nexarch/archive-objectbackup.env` (0600)
|
||||
- 3 systemd-Timer installiert und aktiviert: Sicherung stündlich (`:30`),
|
||||
Vollständigkeitsprüfung wöchentlich (So. 04:00 UTC), Rotation täglich
|
||||
(03:30 UTC) — `systemctl list-timers` bestätigt alle scharf
|
||||
- Jeder der drei Dienste einmal über `systemctl start` ausgelöst, alle mit
|
||||
`status=0/SUCCESS`; Journal bestätigt inhaltlich korrekte Ausgabe
|
||||
|
||||
## 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 -> 2/2 Pakete mit Tests ok (internal/backup, internal/objectbackup), 0 Fehlschläge
|
||||
```
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
|
||||
real gegen echtes restic-Tooling erfüllt. BAK-01-Pfadfehler im selben
|
||||
Rutsch korrigiert und erneut end-to-end verifiziert.
|
||||
@@ -0,0 +1,156 @@
|
||||
// Package objectbackup implementiert BAK-02: automatisierte, inkrementelle,
|
||||
// deduplizierende Sicherung des Objekt-Storage-Bestands. Nutzt restic
|
||||
// (Content-defined Chunking, verschlüsseltes Repository ab Werk) statt
|
||||
// Eigenbau — restic erfüllt alle Akzeptanzkriterien mit ausgereiftem,
|
||||
// geprüftem Tooling statt einer weniger robusten Neuimplementierung.
|
||||
//
|
||||
// Backup-Quelle ist ein lokaler Verzeichnisbaum — für den LocalDriver aus
|
||||
// FDN-03 direkt dessen Basisverzeichnis, für S3-gestützte Produktions-
|
||||
// Deployments ein vorgelagerter Sync-Schritt (z.B. rclone) auf einen
|
||||
// lokalen Spiegel, bevor restic ihn sichert (nicht Bestandteil dieser
|
||||
// Kachel — restic selbst sichert Dateibäume, keine S3-Buckets direkt).
|
||||
package objectbackup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config enthält Repository-Ort und -Passwort — ausschließlich über
|
||||
// Umgebungsvariablen befüllt (siehe Ticket-Abschluss-Regel).
|
||||
type Config struct {
|
||||
RepoDir string
|
||||
Password string
|
||||
ResticPath string // Default "restic", überschreibbar für Tests
|
||||
}
|
||||
|
||||
func (c Config) binary() string {
|
||||
if c.ResticPath != "" {
|
||||
return c.ResticPath
|
||||
}
|
||||
return "restic"
|
||||
}
|
||||
|
||||
func (c Config) env() []string {
|
||||
return append(os.Environ(), "RESTIC_PASSWORD="+c.Password)
|
||||
}
|
||||
|
||||
func run(ctx context.Context, cfg Config, args ...string) ([]byte, error) {
|
||||
fullArgs := append([]string{"-r", cfg.RepoDir}, args...)
|
||||
cmd := exec.CommandContext(ctx, cfg.binary(), fullArgs...)
|
||||
cmd.Env = cfg.env()
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("%s %v fehlgeschlagen: %w (ausgabe: %s)", cfg.binary(), args, err, string(output))
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// InitRepo legt ein neues restic-Repository an, falls es noch nicht
|
||||
// existiert — idempotent, ein bereits initialisiertes Repository ist kein
|
||||
// Fehler (Wiederholte Aufrufe durch systemd-Timer nach einem Neustart
|
||||
// dürfen nicht fehlschlagen).
|
||||
func InitRepo(ctx context.Context, cfg Config) error {
|
||||
output, err := run(ctx, cfg, "init")
|
||||
if err != nil {
|
||||
if strings.Contains(string(output), "config file already exists") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("objectbackup: repository initialisieren: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackupSummary ist der geparste "summary"-Datensatz aus `restic backup --json`.
|
||||
type BackupSummary struct {
|
||||
SnapshotID string `json:"snapshot_id"`
|
||||
FilesNew int `json:"files_new"`
|
||||
FilesChanged int `json:"files_changed"`
|
||||
FilesUnmodified int `json:"files_unmodified"`
|
||||
DataBlobs int `json:"data_blobs"`
|
||||
TotalBytes int64 `json:"total_bytes_processed"`
|
||||
}
|
||||
|
||||
// Backup sichert sourceDir inkrementell (Akzeptanzkriterium 1: unveränderte
|
||||
// Objekte werden nicht erneut übertragen — restics Content-defined
|
||||
// Chunking erkennt das automatisch, kein manueller Änderungsabgleich
|
||||
// nötig).
|
||||
func Backup(ctx context.Context, cfg Config, sourceDir string) (BackupSummary, error) {
|
||||
output, err := run(ctx, cfg, "backup", sourceDir, "--json")
|
||||
if err != nil {
|
||||
return BackupSummary{}, fmt.Errorf("objectbackup: sicherung: %w", err)
|
||||
}
|
||||
return parseSummary(output)
|
||||
}
|
||||
|
||||
// parseSummary sucht in der zeilenweisen JSON-Ausgabe von `restic backup
|
||||
// --json` (mehrere Fortschritts-/Statuszeilen, GENAU EINE mit
|
||||
// message_type=="summary") die Zusammenfassung.
|
||||
func parseSummary(output []byte) (BackupSummary, error) {
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
var probe struct {
|
||||
MessageType string `json:"message_type"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lines[i]), &probe); err != nil {
|
||||
continue
|
||||
}
|
||||
if probe.MessageType == "summary" {
|
||||
var summary BackupSummary
|
||||
if err := json.Unmarshal([]byte(lines[i]), &summary); err != nil {
|
||||
return BackupSummary{}, fmt.Errorf("objectbackup: summary-zeile dekodieren: %w", err)
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
}
|
||||
return BackupSummary{}, fmt.Errorf("objectbackup: keine summary-zeile in der restic-ausgabe gefunden")
|
||||
}
|
||||
|
||||
// Check prüft die Vollständigkeit/Lesbarkeit des Repository
|
||||
// (Akzeptanzkriterium 3 / Pflichtprüfung: Vollständigkeitsprüfung erkennt
|
||||
// fehlendes/beschädigtes Objekt). readData=true liest jeden gespeicherten
|
||||
// Datenblock tatsächlich (teurer, aber die einzige Prüfung, die
|
||||
// Bit-Rot in bereits gespeicherten Paketen erkennt — ohne readData prüft
|
||||
// restic nur Struktur/Indizes, nicht den tatsächlichen Blockinhalt).
|
||||
func Check(ctx context.Context, cfg Config, readData bool) error {
|
||||
args := []string{"check"}
|
||||
if readData {
|
||||
args = append(args, "--read-data")
|
||||
}
|
||||
if _, err := run(ctx, cfg, args...); err != nil {
|
||||
return fmt.Errorf("objectbackup: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Forget entfernt alte Snapshots nach Rotationsregel und gibt den davon
|
||||
// belegten Speicherplatz frei (--prune) — restics Äquivalent zu
|
||||
// BAK-01s Rotate.
|
||||
func Forget(ctx context.Context, cfg Config, keepLast int) error {
|
||||
if _, err := run(ctx, cfg, "forget", "--keep-last", fmt.Sprintf("%d", keepLast), "--prune"); err != nil {
|
||||
return fmt.Errorf("objectbackup: rotation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type snapshotEntry struct {
|
||||
ShortID string `json:"short_id"`
|
||||
}
|
||||
|
||||
// SnapshotCount liefert die Anzahl vorhandener Snapshots — für Tests und
|
||||
// Statusabfragen.
|
||||
func SnapshotCount(ctx context.Context, cfg Config) (int, error) {
|
||||
output, err := run(ctx, cfg, "snapshots", "--json")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("objectbackup: snapshots auflisten: %w", err)
|
||||
}
|
||||
var snapshots []snapshotEntry
|
||||
if err := json.Unmarshal(output, &snapshots); err != nil {
|
||||
return 0, fmt.Errorf("objectbackup: snapshot-liste dekodieren: %w", err)
|
||||
}
|
||||
return len(snapshots), nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package objectbackup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func requireRestic(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("restic"); err != nil {
|
||||
t.Skip("restic nicht installiert, Integrationstest uebersprungen")
|
||||
}
|
||||
}
|
||||
|
||||
func setupTest(t *testing.T) Config {
|
||||
t.Helper()
|
||||
requireRestic(t)
|
||||
cfg := Config{RepoDir: filepath.Join(t.TempDir(), "repo"), Password: "test-passwort-fuer-objectbackup"}
|
||||
if err := InitRepo(context.Background(), cfg); err != nil {
|
||||
t.Fatalf("initrepo: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, dir, name, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("testdatei %q schreiben: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackup_UnchangedSecondRunTransmitsNothingNew ist Pruefung 1:
|
||||
// zweiter Sicherungslauf nach unveraendertem Bestand ueberraegt keine
|
||||
// Daten erneut.
|
||||
func TestBackup_UnchangedSecondRunTransmitsNothingNew(t *testing.T) {
|
||||
cfg := setupTest(t)
|
||||
ctx := context.Background()
|
||||
sourceDir := t.TempDir()
|
||||
writeFile(t, sourceDir, "dokument.pdf", "unveraenderter inhalt")
|
||||
|
||||
first, err := Backup(ctx, cfg, sourceDir)
|
||||
if err != nil {
|
||||
t.Fatalf("erste sicherung: %v", err)
|
||||
}
|
||||
if first.FilesNew != 1 {
|
||||
t.Fatalf("erste sicherung: files_new = %d, want 1", first.FilesNew)
|
||||
}
|
||||
|
||||
second, err := Backup(ctx, cfg, sourceDir)
|
||||
if err != nil {
|
||||
t.Fatalf("zweite sicherung: %v", err)
|
||||
}
|
||||
if second.FilesNew != 0 || second.FilesChanged != 0 {
|
||||
t.Fatalf("zweite sicherung (unveraendert): files_new=%d files_changed=%d, want beide 0", second.FilesNew, second.FilesChanged)
|
||||
}
|
||||
if second.FilesUnmodified != 1 {
|
||||
t.Fatalf("zweite sicherung: files_unmodified = %d, want 1", second.FilesUnmodified)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackup_DeduplicatesIdenticalContent ist Pruefung 2: zwei identische
|
||||
// Testdateien belegen nachweislich nur einmal Speicherplatz.
|
||||
func TestBackup_DeduplicatesIdenticalContent(t *testing.T) {
|
||||
cfg := setupTest(t)
|
||||
ctx := context.Background()
|
||||
sourceDir := t.TempDir()
|
||||
content := "exakt identischer inhalt in beiden dateien fuer den dedup-nachweis"
|
||||
writeFile(t, sourceDir, "original.pdf", content)
|
||||
writeFile(t, sourceDir, "kopie.pdf", content)
|
||||
|
||||
summary, err := Backup(ctx, cfg, sourceDir)
|
||||
if err != nil {
|
||||
t.Fatalf("sicherung: %v", err)
|
||||
}
|
||||
if summary.FilesNew != 2 {
|
||||
t.Fatalf("erwartet 2 neue dateien, habe %d", summary.FilesNew)
|
||||
}
|
||||
// Zwei Dateien mit IDENTISCHEM Inhalt duerfen nur EINEN data_blob
|
||||
// erzeugen - das ist der Dedup-Nachweis (Akzeptanzkriterium 2).
|
||||
if summary.DataBlobs != 1 {
|
||||
t.Fatalf("data_blobs = %d, want 1 (zwei identische dateien haetten nur einen blob erzeugen duerfen - keine dedup)", summary.DataBlobs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheck_DetectsCorruptedPack ist Pruefung 3: Vollstaendigkeitspruefung
|
||||
// erkennt ein beschaedigtes/fehlendes Objekt in der Sicherung.
|
||||
func TestCheck_DetectsCorruptedPack(t *testing.T) {
|
||||
cfg := setupTest(t)
|
||||
ctx := context.Background()
|
||||
sourceDir := t.TempDir()
|
||||
writeFile(t, sourceDir, "wichtig.pdf", "inhalt, der spaeter absichtlich beschaedigt wird")
|
||||
|
||||
if _, err := Backup(ctx, cfg, sourceDir); err != nil {
|
||||
t.Fatalf("sicherung: %v", err)
|
||||
}
|
||||
if err := Check(ctx, cfg, true); err != nil {
|
||||
t.Fatalf("check (unbeschaedigt) haette erfolgreich sein muessen: %v", err)
|
||||
}
|
||||
|
||||
// Absichtliche Beschaedigung: ein Byte in einer Pack-Datei im
|
||||
// Repository kippen (dieselbe Fundstelle wie beim manuellen
|
||||
// Nachweis waehrend der Recherche zu diesem Ticket).
|
||||
packDir := filepath.Join(cfg.RepoDir, "data")
|
||||
corrupted := false
|
||||
if err := filepath.Walk(packDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || corrupted {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) < 20 {
|
||||
return nil
|
||||
}
|
||||
data[10] ^= 0xFF
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
corrupted = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("pack-datei beschaedigen: %v", err)
|
||||
}
|
||||
if !corrupted {
|
||||
t.Fatal("keine pack-datei zum beschaedigen gefunden - testaufbau fehlerhaft")
|
||||
}
|
||||
|
||||
if err := Check(ctx, cfg, true); err == nil {
|
||||
t.Fatal("check haette die beschaedigte pack-datei erkennen muessen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestForget_KeepsOnlyRequestedSnapshotCount prueft die Rotation.
|
||||
func TestForget_KeepsOnlyRequestedSnapshotCount(t *testing.T) {
|
||||
cfg := setupTest(t)
|
||||
ctx := context.Background()
|
||||
sourceDir := t.TempDir()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
writeFile(t, sourceDir, "f.txt", "version "+string(rune('a'+i)))
|
||||
if _, err := Backup(ctx, cfg, sourceDir); err != nil {
|
||||
t.Fatalf("sicherung %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
before, err := SnapshotCount(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotcount (vorher): %v", err)
|
||||
}
|
||||
if before != 3 {
|
||||
t.Fatalf("erwartet 3 snapshots vor rotation, habe %d", before)
|
||||
}
|
||||
|
||||
if err := Forget(ctx, cfg, 1); err != nil {
|
||||
t.Fatalf("forget: %v", err)
|
||||
}
|
||||
|
||||
after, err := SnapshotCount(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotcount (nachher): %v", err)
|
||||
}
|
||||
if after != 1 {
|
||||
t.Fatalf("erwartet 1 snapshot nach rotation (keep-last 1), habe %d", after)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user