// 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 }