syncova-backup/packages/agent/incremental.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

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
}