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.
135 lines
4.4 KiB
Go
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])
|
|
}
|
|
}
|