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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user