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