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>
338 lines
12 KiB
Go
338 lines
12 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/syncova/syncova/packages/repository"
|
|
)
|
|
|
|
// ChangeKind benennt das Ergebnis der Änderungserkennung für ein Objekt.
|
|
type ChangeKind string
|
|
|
|
const (
|
|
// ChangeKindAdded ist ein Objekt, das im Elternbackup nicht vorkam.
|
|
ChangeKindAdded ChangeKind = "added"
|
|
// ChangeKindModified ist ein verändertes Objekt.
|
|
ChangeKindModified ChangeKind = "modified"
|
|
// ChangeKindUnchanged ist ein unverändertes Objekt.
|
|
//
|
|
// Sein Inhalt wird nicht erneut gelesen; die Blockverweise des
|
|
// Elternbackups werden übernommen.
|
|
ChangeKindUnchanged ChangeKind = "unchanged"
|
|
// ChangeKindMetadataOnly ist ein Objekt mit unverändertem Inhalt, aber
|
|
// geänderten Rechten.
|
|
//
|
|
// Es zählt zu den übernommenen Objekten — gelesen werden muss es nicht —,
|
|
// wird aber gesondert ausgewiesen, damit eine Rechteänderung nicht
|
|
// unbemerkt bleibt.
|
|
ChangeKindMetadataOnly ChangeKind = "metadata_only"
|
|
)
|
|
|
|
// ChangeDecision ist das Urteil über ein einzelnes erfasstes Objekt.
|
|
type ChangeDecision struct {
|
|
// Entry ist das erfasste Objekt.
|
|
Entry DiscoveredEntry
|
|
// Kind ist das Ergebnis des Vergleichs.
|
|
Kind ChangeKind
|
|
// Reason erklärt ein „geändert" verständlich.
|
|
//
|
|
// Ohne diese Begründung liesse sich später nicht mehr nachvollziehen, warum
|
|
// ein vermeintlich unverändertes Backup doch alles neu gelesen hat.
|
|
Reason string
|
|
// ParentEntry ist der zugehörige Eintrag des Elternbackups.
|
|
//
|
|
// Er ist nur bei übernommenen Objekten gesetzt und liefert die
|
|
// Blockverweise samt Inhaltsprüfsumme.
|
|
ParentEntry *repository.ManifestEntry
|
|
}
|
|
|
|
// ChangeSet ist das Ergebnis der Änderungserkennung für einen ganzen Lauf.
|
|
type ChangeSet struct {
|
|
// Decisions sind die Urteile in der Reihenfolge der Erfassung.
|
|
Decisions []ChangeDecision
|
|
// DeletedPaths sind Pfade, die im Elternbackup lagen und in der Quelle
|
|
// fehlen.
|
|
//
|
|
// Sie erscheinen nicht im neuen Manifest. Sichtbar gemacht werden sie
|
|
// trotzdem: eine unbemerkt verschwundene Datei ist genau der Fall, den ein
|
|
// Backup aufdecken soll.
|
|
DeletedPaths []string
|
|
// ParentBackupID ist die Kennung des verglichenen Elternbackups.
|
|
ParentBackupID string
|
|
// ParentStartedAt ist der Beginn des Elternbackups in UTC.
|
|
ParentStartedAt time.Time
|
|
}
|
|
|
|
// CountOf zählt die Objekte einer Art.
|
|
func (changeSet *ChangeSet) CountOf(changeKind ChangeKind) int {
|
|
var matchCount int
|
|
for _, changeDecision := range changeSet.Decisions {
|
|
if changeDecision.Kind == changeKind {
|
|
matchCount++
|
|
}
|
|
}
|
|
|
|
return matchCount
|
|
}
|
|
|
|
// ReusedFileCount zählt die Dateien, deren Inhalt nicht erneut gelesen wird.
|
|
//
|
|
// Gezählt werden ausschließlich Dateien. Verzeichnisse und Verweise tragen
|
|
// keine Daten; sie unter „übernommen" mitzuzählen liesse die Ersparnis größer
|
|
// erscheinen, als sie ist.
|
|
func (changeSet *ChangeSet) ReusedFileCount() int {
|
|
var reusedCount int
|
|
for _, changeDecision := range changeSet.Decisions {
|
|
if changeDecision.Entry.EntryType != EntryTypeFile {
|
|
continue
|
|
}
|
|
|
|
if changeDecision.ParentEntry != nil {
|
|
reusedCount++
|
|
}
|
|
}
|
|
|
|
return reusedCount
|
|
}
|
|
|
|
// ChangedFileCount zählt die Dateien, die gelesen werden müssen.
|
|
func (changeSet *ChangeSet) ChangedFileCount() int {
|
|
var changedCount int
|
|
for _, changeDecision := range changeSet.Decisions {
|
|
if changeDecision.Entry.EntryType != EntryTypeFile {
|
|
continue
|
|
}
|
|
|
|
if changeDecision.ParentEntry == nil {
|
|
changedCount++
|
|
}
|
|
}
|
|
|
|
return changedCount
|
|
}
|
|
|
|
// BytesToRead ist die Datenmenge, die tatsächlich gelesen werden muss.
|
|
func (changeSet *ChangeSet) BytesToRead() int64 {
|
|
var pendingBytes int64
|
|
for _, changeDecision := range changeSet.Decisions {
|
|
if changeDecision.Entry.EntryType == EntryTypeFile && changeDecision.ParentEntry == nil {
|
|
pendingBytes += changeDecision.Entry.SizeBytes
|
|
}
|
|
}
|
|
|
|
return pendingBytes
|
|
}
|
|
|
|
// ErrNoParentBackup meldet eine Zusatzsicherung ohne verwendbares Elternbackup.
|
|
var ErrNoParentBackup = errors.New("für diese quelle gibt es kein vollständiges elternbackup")
|
|
|
|
// FindParentBackup sucht das jüngste abgeschlossene Backup derselben Quelle.
|
|
//
|
|
// Maßgeblich ist die Quellkennung, nicht die Kette: wer denselben Pfad erneut
|
|
// sichert, meint dieselben Daten. Ein Backup einer anderen Quelle als
|
|
// Elternbackup heranzuziehen wäre der sicherste Weg, unveränderte Objekte
|
|
// falsch zuzuordnen.
|
|
func FindParentBackup(searchContext context.Context, sourceRepository *repository.LocalRepository, sourceIdentifier string) (*repository.Manifest, error) {
|
|
catalogEntries, listError := sourceRepository.ListBackups(searchContext)
|
|
if listError != nil {
|
|
return nil, listError
|
|
}
|
|
|
|
var newestEntry *repository.CatalogEntry
|
|
|
|
for entryIndex := range catalogEntries {
|
|
catalogEntry := &catalogEntries[entryIndex]
|
|
|
|
if catalogEntry.SourceID != sourceIdentifier {
|
|
continue
|
|
}
|
|
|
|
if newestEntry == nil || catalogEntry.CompletedAt.After(newestEntry.CompletedAt) {
|
|
newestEntry = catalogEntry
|
|
}
|
|
}
|
|
|
|
if newestEntry == nil {
|
|
return nil, fmt.Errorf("%w: %s", ErrNoParentBackup, sourceIdentifier)
|
|
}
|
|
|
|
// Gelesen wird das Manifest, nicht der Katalogeintrag: der Katalog ist nur
|
|
// ein Beschleuniger und könnte veraltet sein. Das Manifest ist die Quelle
|
|
// der Wahrheit — und nur es enthält die Blockverweise.
|
|
parentManifest, readError := sourceRepository.ReadManifest(searchContext, newestEntry.BackupID)
|
|
if readError != nil {
|
|
return nil, fmt.Errorf("das manifest des elternbackups %s konnte nicht gelesen werden: %w",
|
|
newestEntry.BackupID, readError)
|
|
}
|
|
|
|
if !parentManifest.Complete {
|
|
return nil, fmt.Errorf("%w: das jüngste backup %s ist unvollständig",
|
|
ErrNoParentBackup, newestEntry.BackupID)
|
|
}
|
|
|
|
return parentManifest, nil
|
|
}
|
|
|
|
// DetectChanges vergleicht die erfassten Objekte gegen ein Elternmanifest.
|
|
//
|
|
// Das Verfahren beruht auf Größe und Änderungszeitpunkt. Das ist eine bewusste
|
|
// Abwägung: Der Inhalt jeder Datei erneut zu lesen, nur um festzustellen, dass
|
|
// er gleich geblieben ist, hebt den gesamten Zweck einer Zusatzsicherung auf.
|
|
// Die Grenze des Verfahrens wird nicht verschwiegen — wer eine Datei verändert
|
|
// und ihren Zeitstempel anschließend zurücksetzt, täuscht es. Für diesen Fall
|
|
// gibt es die vollständige Sicherung.
|
|
//
|
|
// Eine Falle behandelt die Funktion ausdrücklich: Wird eine Datei in derselben
|
|
// Sekunde geschrieben, in der das Elternbackup sie gelesen hat, kann ihr
|
|
// Zeitstempel unverändert aussehen, obwohl der Inhalt ein anderer ist. Manche
|
|
// Dateisysteme lösen Zeitstempel nur sekundengenau auf. Deshalb gilt jedes
|
|
// Objekt als geändert, dessen Zeitstempel nicht *vor* dem Beginn des
|
|
// Elternbackups liegt.
|
|
func DetectChanges(discoveredEntries []DiscoveredEntry, parentManifest *repository.Manifest) *ChangeSet {
|
|
changeSet := &ChangeSet{
|
|
Decisions: make([]ChangeDecision, 0, len(discoveredEntries)),
|
|
DeletedPaths: make([]string, 0),
|
|
}
|
|
|
|
if parentManifest == nil {
|
|
// Ohne Elternbackup ist alles neu.
|
|
for _, discoveredEntry := range discoveredEntries {
|
|
changeSet.Decisions = append(changeSet.Decisions, ChangeDecision{
|
|
Entry: discoveredEntry,
|
|
Kind: ChangeKindAdded,
|
|
Reason: "Es gibt kein Elternbackup zum Vergleichen.",
|
|
})
|
|
}
|
|
|
|
return changeSet
|
|
}
|
|
|
|
changeSet.ParentBackupID = parentManifest.BackupID
|
|
changeSet.ParentStartedAt = parentManifest.StartedAt
|
|
|
|
// Der Index macht den Vergleich linear statt quadratisch. Bei einer Million
|
|
// Dateien wäre die verschachtelte Suche sonst nicht mehr vertretbar.
|
|
parentEntriesByPath := make(map[string]*repository.ManifestEntry, len(parentManifest.Entries))
|
|
for entryIndex := range parentManifest.Entries {
|
|
parentEntry := &parentManifest.Entries[entryIndex]
|
|
parentEntriesByPath[parentEntry.Path] = parentEntry
|
|
}
|
|
|
|
seenPaths := make(map[string]struct{}, len(discoveredEntries))
|
|
|
|
for _, discoveredEntry := range discoveredEntries {
|
|
seenPaths[discoveredEntry.RelativePath] = struct{}{}
|
|
|
|
parentEntry, existedBefore := parentEntriesByPath[discoveredEntry.RelativePath]
|
|
if !existedBefore {
|
|
changeSet.Decisions = append(changeSet.Decisions, ChangeDecision{
|
|
Entry: discoveredEntry,
|
|
Kind: ChangeKindAdded,
|
|
Reason: "Das Objekt kam im Elternbackup nicht vor.",
|
|
})
|
|
|
|
continue
|
|
}
|
|
|
|
changeSet.Decisions = append(changeSet.Decisions,
|
|
compareAgainstParent(discoveredEntry, parentEntry, parentManifest.StartedAt))
|
|
}
|
|
|
|
for _, parentEntry := range parentManifest.Entries {
|
|
if _, stillPresent := seenPaths[parentEntry.Path]; !stillPresent {
|
|
changeSet.DeletedPaths = append(changeSet.DeletedPaths, parentEntry.Path)
|
|
}
|
|
}
|
|
|
|
return changeSet
|
|
}
|
|
|
|
// compareAgainstParent beurteilt ein einzelnes Objekt gegen seinen Vorgänger.
|
|
func compareAgainstParent(discoveredEntry DiscoveredEntry, parentEntry *repository.ManifestEntry, parentStartedAt time.Time) ChangeDecision {
|
|
changeDecision := ChangeDecision{Entry: discoveredEntry}
|
|
|
|
// Ein Typwechsel — etwa Datei zu Verzeichnis — macht jeden weiteren
|
|
// Vergleich sinnlos.
|
|
if string(discoveredEntry.EntryType) != parentEntry.EntryType {
|
|
changeDecision.Kind = ChangeKindModified
|
|
changeDecision.Reason = fmt.Sprintf("Die Art des Objekts wechselte von %s zu %s.",
|
|
parentEntry.EntryType, discoveredEntry.EntryType)
|
|
|
|
return changeDecision
|
|
}
|
|
|
|
switch discoveredEntry.EntryType {
|
|
case EntryTypeDirectory:
|
|
// Ein Verzeichnis trägt keine Daten. Es wird immer neu vermerkt, das
|
|
// kostet nichts; nur seine Rechte können sich ändern.
|
|
changeDecision.Kind = ChangeKindUnchanged
|
|
if discoveredEntry.Mode != parentEntry.Mode {
|
|
changeDecision.Kind = ChangeKindMetadataOnly
|
|
changeDecision.Reason = fmt.Sprintf("Die Rechte wechselten von %s zu %s.", parentEntry.Mode, discoveredEntry.Mode)
|
|
}
|
|
|
|
return changeDecision
|
|
|
|
case EntryTypeSymlink:
|
|
changeDecision.Kind = ChangeKindUnchanged
|
|
if discoveredEntry.LinkTarget != parentEntry.LinkTarget {
|
|
changeDecision.Kind = ChangeKindModified
|
|
changeDecision.Reason = fmt.Sprintf("Das Verweisziel wechselte von %q zu %q.",
|
|
parentEntry.LinkTarget, discoveredEntry.LinkTarget)
|
|
}
|
|
|
|
return changeDecision
|
|
}
|
|
|
|
if discoveredEntry.SizeBytes != parentEntry.SizeBytes {
|
|
changeDecision.Kind = ChangeKindModified
|
|
changeDecision.Reason = fmt.Sprintf("Die Größe wechselte von %d auf %d Byte.",
|
|
parentEntry.SizeBytes, discoveredEntry.SizeBytes)
|
|
|
|
return changeDecision
|
|
}
|
|
|
|
if !discoveredEntry.ModifiedAt.Equal(parentEntry.ModifiedAt) {
|
|
changeDecision.Kind = ChangeKindModified
|
|
changeDecision.Reason = fmt.Sprintf("Der Änderungszeitpunkt wechselte von %s auf %s.",
|
|
parentEntry.ModifiedAt.Format(time.RFC3339), discoveredEntry.ModifiedAt.Format(time.RFC3339))
|
|
|
|
return changeDecision
|
|
}
|
|
|
|
// Die Zeitstempel-Falle: eine Datei, die während des Elternbackups oder
|
|
// danach geschrieben wurde, kann bei grober Zeitauflösung gleich aussehen.
|
|
// Im Zweifel wird gelesen — ein zu viel gelesenes Backup kostet Zeit, ein
|
|
// zu wenig gelesenes verliert Daten.
|
|
if !parentStartedAt.IsZero() && !discoveredEntry.ModifiedAt.Before(parentStartedAt) {
|
|
changeDecision.Kind = ChangeKindModified
|
|
changeDecision.Reason = "Der Änderungszeitpunkt liegt nicht vor dem Beginn des Elternbackups; der Inhalt könnte sich unbemerkt geändert haben."
|
|
|
|
return changeDecision
|
|
}
|
|
|
|
// Ein Elterneintrag ohne Blockverweise trägt keinen übernehmbaren Inhalt.
|
|
// Das darf nicht vorkommen, wäre aber ein stiller Datenverlust — deshalb
|
|
// wird im Zweifel gelesen.
|
|
if len(parentEntry.Chunks) == 0 && parentEntry.SizeBytes > 0 {
|
|
changeDecision.Kind = ChangeKindModified
|
|
changeDecision.Reason = "Das Elternbackup enthält für dieses Objekt keine Blockverweise."
|
|
|
|
return changeDecision
|
|
}
|
|
|
|
changeDecision.ParentEntry = parentEntry
|
|
changeDecision.Kind = ChangeKindUnchanged
|
|
|
|
if discoveredEntry.Mode != parentEntry.Mode {
|
|
changeDecision.Kind = ChangeKindMetadataOnly
|
|
changeDecision.Reason = fmt.Sprintf("Der Inhalt ist unverändert, die Rechte wechselten von %s zu %s.",
|
|
parentEntry.Mode, discoveredEntry.Mode)
|
|
}
|
|
|
|
return changeDecision
|
|
}
|