syncova-backup/packages/retention/policy.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

468 lines
16 KiB
Go

// Package retention entscheidet, welche Backups bleiben und welche gehen.
//
// Das ist die gefaehrlichste Rechnung der ganzen Anlage: Ein Fehler hier
// vernichtet Daten, und zwar unbemerkt und dauerhaft. Deshalb gilt hier eine
// Regel, die im uebrigen Code nicht noetig ist — **im Zweifel bleibt ein Backup
// erhalten**. Eine unklare Lage kostet Speicherplatz; die umgekehrte
// Entscheidung kostet das Backup.
package retention
import (
"errors"
"fmt"
"sort"
"time"
)
// Policy ist eine Aufbewahrungsregel.
//
// Die beiden Formen aus PROMPT.md §16 sind bewusst in einer Struktur vereint:
// Eine einfache Frist ist der Sonderfall „behalte alles juenger als X", und
// beide Formen duerfen kombiniert werden. Wer 30 Tage **und** 12 Monatsstaende
// haelt, verliert dazwischen nichts.
type Policy struct {
// Name ist die sprechende Bezeichnung.
Name string `json:"name"`
// KeepWithin haelt jedes Backup, das juenger ist als diese Spanne.
//
// Die einfachste und sicherste Regel. Null bedeutet: keine solche Regel.
KeepWithin time.Duration `json:"keep_within,omitempty"`
// KeepLast haelt die juengsten n Backups, unabhaengig vom Alter.
//
// Der Schutz gegen die haeufigste Katastrophe einer Aufbewahrungsregel: Ein
// System sichert monatelang nicht, alle Backups laufen aus der Frist, und
// die Regel loescht das letzte vorhandene. Danach gibt es keines mehr.
KeepLast int `json:"keep_last,omitempty"`
// KeepDaily haelt je Tag das juengste Backup.
KeepDaily int `json:"keep_daily,omitempty"`
// KeepWeekly haelt je Woche das juengste Backup.
KeepWeekly int `json:"keep_weekly,omitempty"`
// KeepMonthly haelt je Monat das juengste Backup.
KeepMonthly int `json:"keep_monthly,omitempty"`
// KeepYearly haelt je Jahr das juengste Backup.
KeepYearly int `json:"keep_yearly,omitempty"`
// TimeZone ist die Zeitzone der Tages- und Monatsgrenzen.
//
// Ohne sie liefe die Einteilung in UTC, und ein Backup von 01:00 Uhr
// deutscher Zeit fiele auf den Vortag. Wer taeglich um 00:30 sichert,
// verloere dadurch systematisch die falschen Backups.
TimeZone string `json:"time_zone,omitempty"`
}
// Fehler der Aufbewahrungsregeln.
var (
// ErrPolicyEmpty meldet eine Regel, die nichts behalten wuerde.
ErrPolicyEmpty = errors.New("eine aufbewahrungsregel ohne jede haltevorgabe wuerde alle backups loeschen")
// ErrPolicyInvalid meldet eine widerspruechliche Regel.
ErrPolicyInvalid = errors.New("die aufbewahrungsregel ist unbrauchbar")
)
// Validate prueft eine Regel auf Brauchbarkeit.
//
// Eine leere Regel wird abgelehnt statt als „behalte nichts" ausgefuehrt. Der
// Unterschied ist der zwischen einem Tippfehler und einem Datenverlust.
func (policy Policy) Validate() error {
if policy.Name == "" {
return fmt.Errorf("%w: sie hat keinen namen", ErrPolicyInvalid)
}
for fieldName, fieldValue := range map[string]int{
"keep_last": policy.KeepLast,
"keep_daily": policy.KeepDaily,
"keep_weekly": policy.KeepWeekly,
"keep_monthly": policy.KeepMonthly,
"keep_yearly": policy.KeepYearly,
} {
if fieldValue < 0 {
return fmt.Errorf("%w: %s darf nicht negativ sein", ErrPolicyInvalid, fieldName)
}
}
if policy.KeepWithin < 0 {
return fmt.Errorf("%w: keep_within darf nicht negativ sein", ErrPolicyInvalid)
}
if policy.TimeZone != "" {
if _, locationError := time.LoadLocation(policy.TimeZone); locationError != nil {
return fmt.Errorf("%w: die zeitzone %q ist unbekannt", ErrPolicyInvalid, policy.TimeZone)
}
}
if !policy.keepsAnything() {
return ErrPolicyEmpty
}
return nil
}
// keepsAnything meldet, ob die Regel ueberhaupt etwas behaelt.
func (policy Policy) keepsAnything() bool {
return policy.KeepWithin > 0 || policy.KeepLast > 0 || policy.KeepDaily > 0 ||
policy.KeepWeekly > 0 || policy.KeepMonthly > 0 || policy.KeepYearly > 0
}
// location liefert die Zeitzone der Regel.
func (policy Policy) location() *time.Location {
if policy.TimeZone == "" {
return time.UTC
}
loadedLocation, locationError := time.LoadLocation(policy.TimeZone)
if locationError != nil {
return time.UTC
}
return loadedLocation
}
// Describe erklaert die Regel in einem Satz.
func (policy Policy) Describe() string {
descriptionParts := make([]string, 0, 6)
if policy.KeepWithin > 0 {
descriptionParts = append(descriptionParts,
fmt.Sprintf("alles juenger als %s", formatDuration(policy.KeepWithin)))
}
for _, countedRule := range []struct {
count int
label string
}{
{policy.KeepLast, "die letzten %d Backups"},
{policy.KeepDaily, "%d Tagesstaende"},
{policy.KeepWeekly, "%d Wochenstaende"},
{policy.KeepMonthly, "%d Monatsstaende"},
{policy.KeepYearly, "%d Jahresstaende"},
} {
if countedRule.count > 0 {
descriptionParts = append(descriptionParts, fmt.Sprintf(countedRule.label, countedRule.count))
}
}
if len(descriptionParts) == 0 {
return "Diese Regel wuerde nichts behalten."
}
description := "Behalten werden " + descriptionParts[0]
for _, remainingPart := range descriptionParts[1:] {
description += ", " + remainingPart
}
return description + "."
}
// formatDuration schreibt eine Zeitspanne lesbar.
func formatDuration(duration time.Duration) string {
switch {
case duration >= 365*24*time.Hour:
return fmt.Sprintf("%d Jahre", int(duration.Hours()/24/365))
case duration >= 24*time.Hour:
return fmt.Sprintf("%d Tage", int(duration.Hours()/24))
default:
return duration.String()
}
}
// Vorgefertigte Regeln fuer die einfache Auswahl (PROMPT.md §16).
//
// Jede von ihnen enthaelt zusaetzlich KeepLast: 1. Ohne diese Ergaenzung
// loeschte „7 Tage" bei einem System, das drei Wochen nicht gesichert hat,
// **jedes** vorhandene Backup — die Regel traefe genau dann zu, wenn man die
// Daten am dringendsten braucht.
var (
// PolicySevenDays haelt eine Woche.
PolicySevenDays = Policy{Name: "7 Tage", KeepWithin: 7 * 24 * time.Hour, KeepLast: 1}
// PolicyFourteenDays haelt zwei Wochen.
PolicyFourteenDays = Policy{Name: "14 Tage", KeepWithin: 14 * 24 * time.Hour, KeepLast: 1}
// PolicyThirtyDays haelt einen Monat.
PolicyThirtyDays = Policy{Name: "30 Tage", KeepWithin: 30 * 24 * time.Hour, KeepLast: 1}
// PolicyNinetyDays haelt ein Quartal.
PolicyNinetyDays = Policy{Name: "90 Tage", KeepWithin: 90 * 24 * time.Hour, KeepLast: 1}
// PolicyOneYear haelt ein Jahr.
PolicyOneYear = Policy{Name: "1 Jahr", KeepWithin: 365 * 24 * time.Hour, KeepLast: 1}
// PolicyGrandfatherFatherSon ist die klassische Staffelung.
PolicyGrandfatherFatherSon = Policy{
Name: "GFS (30/8/12/7)",
KeepDaily: 30,
KeepWeekly: 8,
KeepMonthly: 12,
KeepYearly: 7,
KeepLast: 1,
}
)
// PredefinedPolicies liefert die vorgefertigten Regeln in Anzeigereihenfolge.
func PredefinedPolicies() []Policy {
return []Policy{
PolicySevenDays,
PolicyFourteenDays,
PolicyThirtyDays,
PolicyNinetyDays,
PolicyOneYear,
PolicyGrandfatherFatherSon,
}
}
// BackupCandidate ist ein Backup, ueber das entschieden wird.
type BackupCandidate struct {
// BackupID ist die Kennung im Repository.
BackupID string `json:"backup_id"`
// CompletedAt ist der Abschlusszeitpunkt in UTC.
CompletedAt time.Time `json:"completed_at"`
// SizeBytes ist die belegte Datenmenge.
SizeBytes int64 `json:"size_bytes,omitempty"`
// ImmutableUntil ist das Ende des Aufbewahrungsschutzes in UTC.
ImmutableUntil *time.Time `json:"immutable_until,omitempty"`
// LegalHold meldet einen unbefristeten Schutz.
LegalHold bool `json:"legal_hold"`
// IsIncrementalParent meldet ein Backup, auf dem ein anderes aufbaut.
IsIncrementalParent bool `json:"is_incremental_parent"`
}
// Decision ist die Entscheidung ueber ein einzelnes Backup.
type Decision struct {
// BackupID ist das betrachtete Backup.
BackupID string `json:"backup_id"`
// CompletedAt ist der Abschlusszeitpunkt in UTC.
CompletedAt time.Time `json:"completed_at"`
// Keep meldet, ob das Backup bleibt.
Keep bool `json:"keep"`
// Reason begruendet die Entscheidung.
//
// Jede Entscheidung wird begruendet, auch das Behalten. Eine Vorschau, die
// nur Kennungen auflistet, laesst den Betreiber raten, warum ausgerechnet
// dieses Backup verschwinden soll.
Reason string `json:"reason"`
// Protected meldet ein Backup, das trotz Regel nicht geloescht werden darf.
Protected bool `json:"protected"`
}
// Plan ist das Ergebnis einer Anwendung der Regel.
type Plan struct {
// PolicyName ist die angewandte Regel.
PolicyName string `json:"policy_name"`
// Decisions sind die Einzelentscheidungen, juengste zuerst.
Decisions []Decision `json:"decisions"`
// KeptCount ist die Zahl behaltener Backups.
KeptCount int `json:"kept_count"`
// DeletableCount ist die Zahl loeschbarer Backups.
DeletableCount int `json:"deletable_count"`
// ProtectedCount ist die Zahl geschuetzter Backups, die die Regel loeschen wollte.
//
// Die Zahl steht bewusst gesondert: Sie ist die Auskunft „die Regel greift
// hier noch nicht durch" und erklaert, warum der Speicher trotz Aufraeumen
// nicht kleiner wird.
ProtectedCount int `json:"protected_count"`
// ReclaimableBytes ist die Datenmenge der loeschbaren Backups.
ReclaimableBytes int64 `json:"reclaimable_bytes"`
// EvaluatedAt ist der Zeitpunkt der Bewertung in UTC.
EvaluatedAt time.Time `json:"evaluated_at"`
}
// DeletableBackupIDs liefert die Kennungen der loeschbaren Backups.
func (plan *Plan) DeletableBackupIDs() []string {
deletableIdentifiers := make([]string, 0, plan.DeletableCount)
for _, decision := range plan.Decisions {
if !decision.Keep {
deletableIdentifiers = append(deletableIdentifiers, decision.BackupID)
}
}
return deletableIdentifiers
}
// Summary fasst den Plan in einem Satz zusammen.
func (plan *Plan) Summary() string {
if plan.DeletableCount == 0 {
if plan.ProtectedCount > 0 {
return fmt.Sprintf("Kein Backup wird geloescht. %d stehen unter Aufbewahrungsschutz.",
plan.ProtectedCount)
}
return "Kein Backup wird geloescht."
}
summary := fmt.Sprintf("%d von %d Backups werden geloescht, %d bleiben.",
plan.DeletableCount, len(plan.Decisions), plan.KeptCount)
if plan.ProtectedCount > 0 {
summary += fmt.Sprintf(" Weitere %d waeren faellig, stehen aber unter Aufbewahrungsschutz.",
plan.ProtectedCount)
}
return summary
}
// Apply entscheidet fuer jedes Backup, ob es bleibt.
//
// Die Reihenfolge der Pruefungen ist die Sicherheitsarchitektur dieser Funktion:
// Erst alle Gruende zu behalten, dann erst die Loeschung. Ein Backup faellt nur
// heraus, wenn **keine** Regel es haelt.
func Apply(policy Policy, candidates []BackupCandidate, referenceTime time.Time) (*Plan, error) {
if validationError := policy.Validate(); validationError != nil {
return nil, validationError
}
plan := &Plan{
PolicyName: policy.Name,
Decisions: make([]Decision, 0, len(candidates)),
EvaluatedAt: referenceTime.UTC(),
}
if len(candidates) == 0 {
return plan, nil
}
// Juengste zuerst. Die gesamte Auswahl beruht auf dieser Reihenfolge: „das
// juengste Backup eines Tages" ist das erste, das in diesem Tag auftaucht.
sortedCandidates := make([]BackupCandidate, len(candidates))
copy(sortedCandidates, candidates)
sort.SliceStable(sortedCandidates, func(firstIndex int, secondIndex int) bool {
return sortedCandidates[firstIndex].CompletedAt.After(sortedCandidates[secondIndex].CompletedAt)
})
keepReasons := collectKeepReasons(policy, sortedCandidates, referenceTime)
for candidateIndex, candidate := range sortedCandidates {
decision := Decision{
BackupID: candidate.BackupID,
CompletedAt: candidate.CompletedAt,
}
if keepReason, isKept := keepReasons[candidateIndex]; isKept {
decision.Keep = true
decision.Reason = keepReason
plan.KeptCount++
plan.Decisions = append(plan.Decisions, decision)
continue
}
// Ein Backup, auf dem ein anderes aufbaut, bleibt — auch wenn die Regel
// es nicht mehr haelt. Es zu loeschen machte das jeuengere unbrauchbar.
//
// Bei Syncova sind Zusatzsicherungen zwar eigenstaendig wiederherstellbar
// (das Manifest ist vollstaendig), doch die Regel steht hier fuer den
// Fall, dass ein spaeteres Format das nicht mehr garantiert.
if candidate.IsIncrementalParent {
decision.Keep = true
decision.Reason = "Auf diesem Backup baut ein juengeres auf."
plan.KeptCount++
plan.Decisions = append(plan.Decisions, decision)
continue
}
// Der Aufbewahrungsschutz steht ueber jeder Regel. Er wird zuletzt
// geprueft, damit die Vorschau sagen kann: „waere faellig, ist aber
// geschuetzt".
if candidate.LegalHold {
decision.Keep = true
decision.Protected = true
decision.Reason = "Fuer Beweiszwecke gehalten."
plan.KeptCount++
plan.ProtectedCount++
plan.Decisions = append(plan.Decisions, decision)
continue
}
if candidate.ImmutableUntil != nil && referenceTime.Before(*candidate.ImmutableUntil) {
decision.Keep = true
decision.Protected = true
decision.Reason = fmt.Sprintf("Waere faellig, steht aber bis %s unter Aufbewahrungsschutz.",
candidate.ImmutableUntil.Format(time.RFC3339))
plan.KeptCount++
plan.ProtectedCount++
plan.Decisions = append(plan.Decisions, decision)
continue
}
decision.Keep = false
decision.Reason = "Keine Haltevorgabe der Regel trifft auf dieses Backup zu."
plan.DeletableCount++
plan.ReclaimableBytes += candidate.SizeBytes
plan.Decisions = append(plan.Decisions, decision)
}
return plan, nil
}
// collectKeepReasons sammelt je Backup den Grund, es zu behalten.
//
// Ein Backup kann mehrere Gruende haben; genannt wird der erste gefundene. Das
// genuegt: Fuer die Entscheidung zaehlt, **dass** es bleibt.
func collectKeepReasons(policy Policy, sortedCandidates []BackupCandidate, referenceTime time.Time) map[int]string {
keepReasons := make(map[int]string)
// Die letzten n — unabhaengig von allem anderen.
for candidateIndex := 0; candidateIndex < policy.KeepLast && candidateIndex < len(sortedCandidates); candidateIndex++ {
keepReasons[candidateIndex] = fmt.Sprintf("Gehoert zu den letzten %d Backups.", policy.KeepLast)
}
// Alles innerhalb der Frist.
if policy.KeepWithin > 0 {
earliestKeptTime := referenceTime.Add(-policy.KeepWithin)
for candidateIndex, candidate := range sortedCandidates {
if candidate.CompletedAt.After(earliestKeptTime) {
if _, alreadyKept := keepReasons[candidateIndex]; !alreadyKept {
keepReasons[candidateIndex] = fmt.Sprintf("Juenger als %s.", formatDuration(policy.KeepWithin))
}
}
}
}
policyLocation := policy.location()
for _, intervalRule := range []struct {
count int
bucketKey func(time.Time) string
label string
}{
{policy.KeepDaily, func(pointInTime time.Time) string {
return pointInTime.In(policyLocation).Format("2006-01-02")
}, "Juengstes Backup seines Tages (%d/%d)"},
{policy.KeepWeekly, func(pointInTime time.Time) string {
isoYear, isoWeek := pointInTime.In(policyLocation).ISOWeek()
return fmt.Sprintf("%04d-W%02d", isoYear, isoWeek)
}, "Juengstes Backup seiner Woche (%d/%d)"},
{policy.KeepMonthly, func(pointInTime time.Time) string {
return pointInTime.In(policyLocation).Format("2006-01")
}, "Juengstes Backup seines Monats (%d/%d)"},
{policy.KeepYearly, func(pointInTime time.Time) string {
return pointInTime.In(policyLocation).Format("2006")
}, "Juengstes Backup seines Jahres (%d/%d)"},
} {
if intervalRule.count <= 0 {
continue
}
seenBuckets := make(map[string]struct{}, intervalRule.count)
keptInThisRule := 0
for candidateIndex, candidate := range sortedCandidates {
if keptInThisRule >= intervalRule.count {
break
}
bucketIdentifier := intervalRule.bucketKey(candidate.CompletedAt)
if _, alreadySeen := seenBuckets[bucketIdentifier]; alreadySeen {
continue
}
seenBuckets[bucketIdentifier] = struct{}{}
keptInThisRule++
if _, alreadyKept := keepReasons[candidateIndex]; !alreadyKept {
keepReasons[candidateIndex] = fmt.Sprintf(intervalRule.label, keptInThisRule, intervalRule.count)
}
}
}
return keepReasons
}