syncova-backup/packages/retention/enforcer.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

254 lines
9.6 KiB
Go

package retention
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/syncova/syncova/packages/platform/logging"
"github.com/syncova/syncova/packages/repository"
)
// Enforcer wendet eine Aufbewahrungsregel auf ein Repository an.
//
// Die Trennung von `Apply` ist Absicht: Die Rechnung, **welche** Backups gehen,
// ist reine Logik und ohne Repository pruefbar. Erst der Enforcer fasst Daten
// an — und er tut es nur, wenn man ihn ausdruecklich darum bittet.
type Enforcer struct {
// targetRepository ist das betroffene Repository.
targetRepository *repository.LocalRepository
// logger protokolliert den Verlauf.
logger *slog.Logger
// timeSource liefert die Gegenwart; im Test austauschbar.
timeSource func() time.Time
}
// NewEnforcer erzeugt die Anwendung einer Aufbewahrungsregel.
func NewEnforcer(targetRepository *repository.LocalRepository, baseLogger *slog.Logger) (*Enforcer, error) {
if targetRepository == nil {
return nil, errors.New("die aufbewahrung braucht ein repository")
}
return &Enforcer{
targetRepository: targetRepository,
logger: logging.WithComponent(baseLogger, "retention"),
timeSource: time.Now,
}, nil
}
// Preview berechnet den Plan, ohne etwas zu loeschen.
//
// Der Weg ueber die Vorschau ist der Regelfall. Eine Aufbewahrungsregel loescht
// Daten dauerhaft; wer sie das erste Mal anwendet, soll vorher sehen, was
// verschwindet — und zwar mit Begruendung je Backup.
func (enforcer *Enforcer) Preview(previewContext context.Context, policy Policy) (*Plan, error) {
candidates, collectError := enforcer.collectCandidates(previewContext)
if collectError != nil {
return nil, collectError
}
return Apply(policy, candidates, enforcer.timeSource())
}
// ExecutionResult ist das Ergebnis einer angewandten Regel.
type ExecutionResult struct {
// Plan ist der zugrunde liegende Plan.
Plan *Plan `json:"plan"`
// DeletedBackupIDs sind die tatsaechlich geloeschten Backups.
DeletedBackupIDs []string `json:"deleted_backup_ids"`
// FailedBackupIDs sind die Backups, deren Loeschung scheiterte.
FailedBackupIDs []string `json:"failed_backup_ids,omitempty"`
// Failures erklaeren die gescheiterten Loeschungen.
Failures []string `json:"failures,omitempty"`
// ChunksRemoved ist die Zahl bereinigter Datenbloecke.
ChunksRemoved int64 `json:"chunks_removed"`
// BytesFreed ist der freigegebene Speicher.
//
// Er stammt aus der Bereinigung, nicht aus der Summe der Backupgroessen:
// Wegen der Deduplizierung gibt ein geloeschtes Backup nur den Speicher
// frei, den kein anderes mehr braucht. Die Backupgroessen zu addieren
// ergaebe eine Zahl, die nie eintritt.
BytesFreed int64 `json:"bytes_freed"`
}
// IsPartialFailure meldet einen Lauf mit uebergangenen Backups.
func (result *ExecutionResult) IsPartialFailure() bool {
return len(result.FailedBackupIDs) > 0
}
// Summary fasst das Ergebnis in einem Satz zusammen.
func (result *ExecutionResult) Summary() string {
if result.IsPartialFailure() {
return fmt.Sprintf("TEILWEISE FEHLGESCHLAGEN: %d von %d Backups geloescht, %d Fehler. "+
"%d Bloecke bereinigt, %d Byte frei.",
len(result.DeletedBackupIDs), len(result.DeletedBackupIDs)+len(result.FailedBackupIDs),
len(result.FailedBackupIDs), result.ChunksRemoved, result.BytesFreed)
}
summary := fmt.Sprintf("%d Backups geloescht, %d Bloecke bereinigt, %d Byte frei.",
len(result.DeletedBackupIDs), result.ChunksRemoved, result.BytesFreed)
// Geloescht und trotzdem nichts frei? Das sieht nach einem Fehler aus und ist
// keiner: Wegen der Deduplizierung gibt ein Backup nur den Speicher frei, den
// kein anderes mehr braucht. Bei wachsenden Daten verweist das juengste
// Backup auf saemtliche Bloecke der aelteren. Ohne diesen Satz erzeugt jede
// solche Ausgabe eine Rueckfrage.
if len(result.DeletedBackupIDs) > 0 && result.BytesFreed == 0 {
summary += " Es wurde kein Speicher frei: Die Bloecke der geloeschten Backups werden von " +
"verbliebenen weiterhin gebraucht (Deduplizierung)."
}
return summary
}
// Execute wendet die Regel an und loescht die faelligen Backups.
//
// Der Plan wird **neu berechnet**, nicht uebergeben. Ein von aussen gereichter
// Plan koennte Stunden alt sein; in der Zwischenzeit kann ein Legal Hold gesetzt
// worden sein. Die Entscheidung, Daten zu vernichten, faellt hier und jetzt.
func (enforcer *Enforcer) Execute(executeContext context.Context, policy Policy) (*ExecutionResult, error) {
executionPlan, planError := enforcer.Preview(executeContext, policy)
if planError != nil {
return nil, planError
}
result := &ExecutionResult{
Plan: executionPlan,
DeletedBackupIDs: make([]string, 0, executionPlan.DeletableCount),
}
if executionPlan.DeletableCount == 0 {
enforcer.logger.Info("die aufbewahrungsregel loescht nichts",
slog.String("regel", policy.Name),
slog.Int("geschuetzt", executionPlan.ProtectedCount))
return result, nil
}
enforcer.logger.Warn("die aufbewahrungsregel loescht backups",
slog.String("regel", policy.Name),
slog.Int("anzahl", executionPlan.DeletableCount),
slog.Int("bleiben", executionPlan.KeptCount))
for _, backupIdentifier := range executionPlan.DeletableBackupIDs() {
if contextError := executeContext.Err(); contextError != nil {
return result, contextError
}
// Das Repository prueft den Schutz **erneut**. Diese Doppelung ist kein
// Versehen: Der Plan beruht auf einer Momentaufnahme, das Repository auf
// dem Zustand der Datei. Bei Widerspruch gewinnt der Schutz.
if deleteError := enforcer.targetRepository.DeleteBackup(executeContext, backupIdentifier); deleteError != nil {
enforcer.logger.Error("ein backup liess sich nicht loeschen",
slog.String("backup_id", backupIdentifier),
slog.String("grund", deleteError.Error()))
result.FailedBackupIDs = append(result.FailedBackupIDs, backupIdentifier)
result.Failures = append(result.Failures,
fmt.Sprintf("%s: %s", backupIdentifier, deleteError.Error()))
continue
}
result.DeletedBackupIDs = append(result.DeletedBackupIDs, backupIdentifier)
}
// Die Bereinigung laeuft **nach** allen Loeschungen, nicht dazwischen: Ein
// Block, den das eine Backup nicht mehr braucht, kann zum naechsten
// gehoeren. Wer nach jedem Backup bereinigt, liest die Manifeste
// unnoetig oft — und im schlimmsten Fall auf halbem Stand.
removedChunks, freedBytes, pruneError := enforcer.targetRepository.PruneOrphanedChunks(executeContext, false)
if pruneError != nil {
// Die Bereinigung scheitert lieber, als auf unvollstaendiger Grundlage
// zu loeschen. Die Backups sind bereits weg; das ist kein Grund, den
// Lauf als gescheitert zu melden — der Speicher wird eben spaeter frei.
enforcer.logger.Error("die bereinigung nach der aufbewahrung schlug fehl",
slog.String("grund", pruneError.Error()))
return result, nil
}
result.ChunksRemoved = removedChunks
result.BytesFreed = freedBytes
enforcer.logger.Info("die aufbewahrungsregel wurde angewandt",
slog.String("regel", policy.Name),
slog.Int("geloescht", len(result.DeletedBackupIDs)),
slog.Int64("bloecke_bereinigt", removedChunks),
slog.Int64("bytes_frei", freedBytes))
return result, nil
}
// collectCandidates liest die Backups des Repositorys samt Schutzlage.
func (enforcer *Enforcer) collectCandidates(collectContext context.Context) ([]BackupCandidate, error) {
catalogEntries, listError := enforcer.targetRepository.ListBackups(collectContext)
if listError != nil {
return nil, fmt.Errorf("die backups konnten nicht ermittelt werden: %w", listError)
}
// Welche Backups darf die Regel nicht loeschen, weil ein juengeres sie
// braucht?
//
// Nur solche, deren **Kind nicht eigenstaendig** ist. Bei Syncova traegt jedes
// Manifest die Blockverweise aller Objekte, auch die einer Zusatzsicherung;
// ein Elternbackup zu loeschen beschaedigt das Kind nicht.
//
// Diese Unterscheidung entscheidet, ob die Aufbewahrung ueberhaupt etwas
// tut: Ohne sie waere in einer fortlaufenden Kette **jedes** Backup ausser
// dem juengsten ein Elternteil — und die Regel raeumte nie auf, waehrend der
// Betreiber glaubt, eine Aufbewahrung zu haben.
parentIdentifiers := make(map[string]struct{}, len(catalogEntries))
for _, catalogEntry := range catalogEntries {
if catalogEntry.ParentBackupID == "" {
continue
}
if catalogEntry.SelfContainedRestore {
continue
}
parentIdentifiers[catalogEntry.ParentBackupID] = struct{}{}
}
candidates := make([]BackupCandidate, 0, len(catalogEntries))
for _, catalogEntry := range catalogEntries {
protectionStatus, statusError := enforcer.targetRepository.ProtectionStatusOf(
collectContext, catalogEntry.BackupID)
if statusError != nil {
// Ein Backup, dessen Schutzlage sich nicht feststellen laesst, gilt
// als geschuetzt. Der umgekehrte Standard machte aus einer
// beschaedigten Datei einen Datenverlust.
enforcer.logger.Warn("die schutzlage eines backups liess sich nicht lesen; es bleibt erhalten",
slog.String("backup_id", catalogEntry.BackupID),
slog.String("grund", statusError.Error()))
candidates = append(candidates, BackupCandidate{
BackupID: catalogEntry.BackupID,
CompletedAt: catalogEntry.CompletedAt,
SizeBytes: catalogEntry.StoredBytes,
LegalHold: true,
})
continue
}
_, isParent := parentIdentifiers[catalogEntry.BackupID]
candidates = append(candidates, BackupCandidate{
BackupID: catalogEntry.BackupID,
CompletedAt: catalogEntry.CompletedAt,
SizeBytes: catalogEntry.StoredBytes,
ImmutableUntil: protectionStatus.ImmutableUntil,
LegalHold: protectionStatus.LegalHold,
IsIncrementalParent: isParent,
})
}
return candidates, nil
}