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>
254 lines
9.6 KiB
Go
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
|
|
}
|