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>
468 lines
16 KiB
Go
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
|
|
}
|