Files
nexarch/archive/cmd/backup-cli/main.go
T
sysops 823a14ae12 feat(archive): BAK-07 gestaffelte Aufbewahrungsfrist fuer Backup-Snapshots
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.
2026-08-30 00:49:23 +02:00

135 lines
4.4 KiB
Go

// 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 envInt(name string, def int) int {
v := os.Getenv(name)
if v == "" {
return def
}
n := def
_, _ = fmt.Sscanf(v, "%d", &n)
return n
}
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 <full|incremental|verify|rotate> [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":
// BAK-07: gestaffelte Aufbewahrungsfrist (KeepDaily/Weekly/Monthly),
// falls konfiguriert - ersetzt die flache "letzte N"-Regel
// (NEXARCH_BACKUP_KEEP_GENERATIONS), bleibt aber abwaertskompatibel,
// wenn keine Staffelung gesetzt ist.
policy := backup.RetentionPolicy{
KeepDaily: envInt("NEXARCH_BACKUP_KEEP_DAILY", 0),
KeepWeekly: envInt("NEXARCH_BACKUP_KEEP_WEEKLY", 0),
KeepMonthly: envInt("NEXARCH_BACKUP_KEEP_MONTHLY", 0),
}
if policy.KeepDaily > 0 || policy.KeepWeekly > 0 || policy.KeepMonthly > 0 {
removed, err := backup.PruneRotate(cfg.BackupDir, policy)
if err != nil {
log.Fatalf("gestaffelte rotation fehlgeschlagen: %v", err)
}
fmt.Printf("gestaffelte rotation abgeschlossen, %d generation(en) entfernt: %v\n", len(removed), removed)
return
}
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])
}
}