internal/backup.PruneTiered: reine GFS-Funktion (Tag/Woche/Monat) fuer Datenbank-Generationen, behaelt strukturell immer die neueste Generation (Sicherheitsnetz gegen Legal-Hold-Kollision). internal/objectbackup. PruneTiered: duenner Wrapper um restics native --keep-daily/-weekly/ -monthly-Staffelung. Beide CLIs nutzen die Staffelung, wenn konfiguriert, bleiben sonst abwaertskompatibel zur flachen "letzte N"-Regel. Real gegen zeitversetzt erzeugte restic-Snapshots getestet (Fund: restics --time-Flag erwartet eigenes Format, nicht RFC3339), Pruning-Sicherheit nach dem Loeschen alter Snapshots ueber vollstaendigen Restore + restic check --read-data bewiesen. Beide Rotationswege real ueber systemd auf 131 ausgeloest.
204 lines
7.5 KiB
Go
204 lines
7.5 KiB
Go
// 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
|
|
}
|
|
|
|
// RetentionPolicy ist eine gestaffelte Aufbewahrungsfrist nach
|
|
// restic/Borg-Vorbild (BAK-07, unabhängig von RET-02s Dokumenten-
|
|
// Retention): täglich/wöchentlich/monatlich je eine konfigurierbare
|
|
// Anzahl Snapshots behalten, statt einer flachen "letzte N"-Regel
|
|
// (Forget/keepLast) oder hart codierter Staffelung. Ein Feldwert 0
|
|
// bedeutet "diese Stufe nicht anwenden" (restics eigene Konvention bei
|
|
// `--keep-*`).
|
|
type RetentionPolicy struct {
|
|
KeepDaily int
|
|
KeepWeekly int
|
|
KeepMonthly int
|
|
}
|
|
|
|
// PruneTiered wendet policy über restics NATIVE `--keep-daily`/
|
|
// `--keep-weekly`/`--keep-monthly`-Staffelung an (kein Eigenbau der
|
|
// Bucket-Logik — restic beherrscht das bereits robust) und gibt den
|
|
// belegten Speicherplatz nicht mehr referenzierter Daten frei
|
|
// (`--prune`). Snapshots, die keiner Stufe zugeordnet werden, entfallen
|
|
// automatisch — restics übliche GFS-Semantik.
|
|
func PruneTiered(ctx context.Context, cfg Config, policy RetentionPolicy) error {
|
|
args := []string{"forget", "--prune"}
|
|
if policy.KeepDaily > 0 {
|
|
args = append(args, "--keep-daily", fmt.Sprintf("%d", policy.KeepDaily))
|
|
}
|
|
if policy.KeepWeekly > 0 {
|
|
args = append(args, "--keep-weekly", fmt.Sprintf("%d", policy.KeepWeekly))
|
|
}
|
|
if policy.KeepMonthly > 0 {
|
|
args = append(args, "--keep-monthly", fmt.Sprintf("%d", policy.KeepMonthly))
|
|
}
|
|
if _, err := run(ctx, cfg, args...); err != nil {
|
|
return fmt.Errorf("objectbackup: gestaffelte rotation: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Restore stellt snapshotID nach targetDir wieder her (`restic restore`).
|
|
// targetDir muss bereits existieren; Atomarität gegenüber einem eventuell
|
|
// nicht-leeren ENDZIEL ist Aufgabe von internal/restore, nicht dieser
|
|
// Funktion (dieselbe Aufgabenteilung wie backup.Restore).
|
|
func Restore(ctx context.Context, cfg Config, snapshotID, targetDir string) error {
|
|
if _, err := run(ctx, cfg, "restore", snapshotID, "--target", targetDir); err != nil {
|
|
return fmt.Errorf("objectbackup: wiederherstellung: %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
|
|
}
|