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>
436 lines
17 KiB
Go
436 lines
17 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/syncova/syncova/packages/repository"
|
|
)
|
|
|
|
// TestDetectChangesFindsAddedModifiedAndDeleted prüft die drei Grundfälle.
|
|
func TestDetectChangesFindsAddedModifiedAndDeleted(testInstance *testing.T) {
|
|
// Das Elternbackup liegt eine Stunde zurück; damit greift die
|
|
// Zeitstempel-Schutzregel nicht und die eigentlichen Fälle bleiben sichtbar.
|
|
parentStart := time.Now().UTC().Add(-time.Hour)
|
|
fileTime := parentStart.Add(-time.Minute)
|
|
|
|
parentManifest := &repository.Manifest{
|
|
BackupID: "eltern",
|
|
StartedAt: parentStart,
|
|
Entries: []repository.ManifestEntry{
|
|
{Path: "gleich.txt", EntryType: "file", SizeBytes: 100, ModifiedAt: fileTime, Mode: "0644",
|
|
Chunks: []repository.ChunkReference{{Identifier: "aaa", LogicalLength: 100}}},
|
|
{Path: "geaendert.txt", EntryType: "file", SizeBytes: 100, ModifiedAt: fileTime, Mode: "0644",
|
|
Chunks: []repository.ChunkReference{{Identifier: "bbb", LogicalLength: 100}}},
|
|
{Path: "weg.txt", EntryType: "file", SizeBytes: 50, ModifiedAt: fileTime, Mode: "0644",
|
|
Chunks: []repository.ChunkReference{{Identifier: "ccc", LogicalLength: 50}}},
|
|
},
|
|
}
|
|
|
|
discoveredEntries := []DiscoveredEntry{
|
|
{RelativePath: "gleich.txt", EntryType: EntryTypeFile, SizeBytes: 100, ModifiedAt: fileTime, Mode: "0644"},
|
|
{RelativePath: "geaendert.txt", EntryType: EntryTypeFile, SizeBytes: 250, ModifiedAt: fileTime, Mode: "0644"},
|
|
{RelativePath: "neu.txt", EntryType: EntryTypeFile, SizeBytes: 10, ModifiedAt: fileTime, Mode: "0644"},
|
|
}
|
|
|
|
changeSet := DetectChanges(discoveredEntries, parentManifest)
|
|
|
|
if changeSet.CountOf(ChangeKindUnchanged) != 1 {
|
|
testInstance.Errorf("es wurde genau ein unverändertes Objekt erwartet, gezählt wurden %d",
|
|
changeSet.CountOf(ChangeKindUnchanged))
|
|
}
|
|
|
|
if changeSet.CountOf(ChangeKindModified) != 1 {
|
|
testInstance.Errorf("es wurde genau ein geändertes Objekt erwartet, gezählt wurden %d",
|
|
changeSet.CountOf(ChangeKindModified))
|
|
}
|
|
|
|
if changeSet.CountOf(ChangeKindAdded) != 1 {
|
|
testInstance.Errorf("es wurde genau ein neues Objekt erwartet, gezählt wurden %d",
|
|
changeSet.CountOf(ChangeKindAdded))
|
|
}
|
|
|
|
if len(changeSet.DeletedPaths) != 1 || changeSet.DeletedPaths[0] != "weg.txt" {
|
|
testInstance.Errorf("die gelöschte Datei wurde nicht erkannt: %v", changeSet.DeletedPaths)
|
|
}
|
|
|
|
// Nur das Unveränderte darf Blockverweise übernehmen.
|
|
if changeSet.ReusedFileCount() != 1 {
|
|
testInstance.Errorf("es sollte genau eine Datei übernommen werden, es waren %d", changeSet.ReusedFileCount())
|
|
}
|
|
|
|
// Gelesen werden muss nur das Geänderte und das Neue.
|
|
expectedBytesToRead := int64(250 + 10)
|
|
if changeSet.BytesToRead() != expectedBytesToRead {
|
|
testInstance.Errorf("es sollten %d Byte gelesen werden, berechnet wurden %d",
|
|
expectedBytesToRead, changeSet.BytesToRead())
|
|
}
|
|
}
|
|
|
|
// TestDetectChangesTreatsSameSecondWriteAsModified prüft die Zeitstempel-Falle.
|
|
//
|
|
// Eine Datei, die während des Elternbackups geschrieben wurde, kann bei grober
|
|
// Zeitauflösung unverändert aussehen. Würde sie übernommen, ginge ihr neuer
|
|
// Inhalt still verloren — der schlimmste denkbare Fehler eines Backups.
|
|
func TestDetectChangesTreatsSameSecondWriteAsModified(testInstance *testing.T) {
|
|
parentStart := time.Now().UTC().Add(-time.Hour)
|
|
|
|
parentManifest := &repository.Manifest{
|
|
BackupID: "eltern",
|
|
StartedAt: parentStart,
|
|
Entries: []repository.ManifestEntry{
|
|
{Path: "waehrenddessen.txt", EntryType: "file", SizeBytes: 100, ModifiedAt: parentStart, Mode: "0644",
|
|
Chunks: []repository.ChunkReference{{Identifier: "aaa", LogicalLength: 100}}},
|
|
},
|
|
}
|
|
|
|
// Größe und Zeitstempel stimmen exakt überein — das Verfahren müsste die
|
|
// Datei für unverändert halten, wenn es die Falle nicht kennte.
|
|
discoveredEntries := []DiscoveredEntry{
|
|
{RelativePath: "waehrenddessen.txt", EntryType: EntryTypeFile, SizeBytes: 100, ModifiedAt: parentStart, Mode: "0644"},
|
|
}
|
|
|
|
changeSet := DetectChanges(discoveredEntries, parentManifest)
|
|
|
|
if changeSet.Decisions[0].Kind != ChangeKindModified {
|
|
testInstance.Fatalf("eine während des Elternbackups geschriebene Datei muss als geändert gelten, erkannt wurde %q",
|
|
changeSet.Decisions[0].Kind)
|
|
}
|
|
|
|
if changeSet.Decisions[0].ParentEntry != nil {
|
|
testInstance.Error("eine als geändert erkannte Datei darf keine Blockverweise übernehmen")
|
|
}
|
|
}
|
|
|
|
// TestDetectChangesReusesContentOnPermissionChange prüft die reine Rechteänderung.
|
|
func TestDetectChangesReusesContentOnPermissionChange(testInstance *testing.T) {
|
|
parentStart := time.Now().UTC().Add(-time.Hour)
|
|
fileTime := parentStart.Add(-time.Minute)
|
|
|
|
parentManifest := &repository.Manifest{
|
|
BackupID: "eltern",
|
|
StartedAt: parentStart,
|
|
Entries: []repository.ManifestEntry{
|
|
{Path: "rechte.txt", EntryType: "file", SizeBytes: 100, ModifiedAt: fileTime, Mode: "0644",
|
|
Chunks: []repository.ChunkReference{{Identifier: "aaa", LogicalLength: 100}}},
|
|
},
|
|
}
|
|
|
|
discoveredEntries := []DiscoveredEntry{
|
|
{RelativePath: "rechte.txt", EntryType: EntryTypeFile, SizeBytes: 100, ModifiedAt: fileTime, Mode: "0600"},
|
|
}
|
|
|
|
changeSet := DetectChanges(discoveredEntries, parentManifest)
|
|
|
|
if changeSet.Decisions[0].Kind != ChangeKindMetadataOnly {
|
|
testInstance.Fatalf("eine reine Rechteänderung wurde als %q eingestuft", changeSet.Decisions[0].Kind)
|
|
}
|
|
|
|
// Der Inhalt wird übernommen, die neuen Rechte werden dennoch vermerkt.
|
|
if changeSet.Decisions[0].ParentEntry == nil {
|
|
testInstance.Error("bei unverändertem Inhalt müssen die Blockverweise übernommen werden")
|
|
}
|
|
}
|
|
|
|
// TestDetectChangesRefusesEntryWithoutChunks prüft den Schutz gegen leere Elterneinträge.
|
|
func TestDetectChangesRefusesEntryWithoutChunks(testInstance *testing.T) {
|
|
parentStart := time.Now().UTC().Add(-time.Hour)
|
|
fileTime := parentStart.Add(-time.Minute)
|
|
|
|
parentManifest := &repository.Manifest{
|
|
BackupID: "eltern",
|
|
StartedAt: parentStart,
|
|
Entries: []repository.ManifestEntry{
|
|
// Ein Eintrag mit Größe, aber ohne Blockverweise darf nicht
|
|
// vorkommen. Käme er doch vor, wäre die Übernahme ein stiller
|
|
// Datenverlust.
|
|
{Path: "kaputt.txt", EntryType: "file", SizeBytes: 100, ModifiedAt: fileTime, Mode: "0644"},
|
|
},
|
|
}
|
|
|
|
discoveredEntries := []DiscoveredEntry{
|
|
{RelativePath: "kaputt.txt", EntryType: EntryTypeFile, SizeBytes: 100, ModifiedAt: fileTime, Mode: "0644"},
|
|
}
|
|
|
|
changeSet := DetectChanges(discoveredEntries, parentManifest)
|
|
|
|
if changeSet.Decisions[0].Kind != ChangeKindModified {
|
|
testInstance.Fatalf("ein Elterneintrag ohne Blockverweise muss zum erneuten Lesen führen, erkannt wurde %q",
|
|
changeSet.Decisions[0].Kind)
|
|
}
|
|
}
|
|
|
|
// TestDetectChangesHandlesTypeSwitch prüft den Wechsel von Datei zu Verzeichnis.
|
|
func TestDetectChangesHandlesTypeSwitch(testInstance *testing.T) {
|
|
parentStart := time.Now().UTC().Add(-time.Hour)
|
|
|
|
parentManifest := &repository.Manifest{
|
|
BackupID: "eltern",
|
|
StartedAt: parentStart,
|
|
Entries: []repository.ManifestEntry{
|
|
{Path: "wechsel", EntryType: "file", SizeBytes: 100, ModifiedAt: parentStart.Add(-time.Minute), Mode: "0644",
|
|
Chunks: []repository.ChunkReference{{Identifier: "aaa", LogicalLength: 100}}},
|
|
},
|
|
}
|
|
|
|
discoveredEntries := []DiscoveredEntry{
|
|
{RelativePath: "wechsel", EntryType: EntryTypeDirectory, ModifiedAt: parentStart.Add(-time.Minute), Mode: "0755"},
|
|
}
|
|
|
|
changeSet := DetectChanges(discoveredEntries, parentManifest)
|
|
|
|
if changeSet.Decisions[0].Kind != ChangeKindModified {
|
|
testInstance.Fatalf("ein Typwechsel muss als Änderung gelten, erkannt wurde %q", changeSet.Decisions[0].Kind)
|
|
}
|
|
}
|
|
|
|
// TestDetectChangesWithoutParentTreatsEverythingAsNew prüft den Fall ohne Elternbackup.
|
|
func TestDetectChangesWithoutParentTreatsEverythingAsNew(testInstance *testing.T) {
|
|
discoveredEntries := []DiscoveredEntry{
|
|
{RelativePath: "a.txt", EntryType: EntryTypeFile, SizeBytes: 10},
|
|
{RelativePath: "b.txt", EntryType: EntryTypeFile, SizeBytes: 20},
|
|
}
|
|
|
|
changeSet := DetectChanges(discoveredEntries, nil)
|
|
|
|
if changeSet.CountOf(ChangeKindAdded) != 2 {
|
|
testInstance.Fatalf("ohne Elternbackup muss alles neu sein, gezählt wurden %d",
|
|
changeSet.CountOf(ChangeKindAdded))
|
|
}
|
|
|
|
if changeSet.BytesToRead() != 30 {
|
|
testInstance.Errorf("es müssen alle 30 Byte gelesen werden, berechnet wurden %d", changeSet.BytesToRead())
|
|
}
|
|
}
|
|
|
|
// TestIncrementalBackupReadsOnlyChangedData ist der eigentliche Nachweis:
|
|
// Eine Zusatzsicherung liest die unveränderten Dateien nicht mehr.
|
|
func TestIncrementalBackupReadsOnlyChangedData(testInstance *testing.T) {
|
|
backupRunner, restoreRunner := newRoundTripEnvironment(testInstance)
|
|
sourcePath := buildRealisticTree(testInstance)
|
|
|
|
backupContext := context.Background()
|
|
|
|
fullResult, fullError := backupRunner.RunBackup(backupContext, BackupRunOptions{
|
|
BackupID: "voll-1",
|
|
SourcePath: sourcePath,
|
|
SourceName: "Testquelle",
|
|
})
|
|
if fullError != nil {
|
|
testInstance.Fatalf("die Vollsicherung schlug fehl: %v", fullError)
|
|
}
|
|
|
|
if fullResult.BackupType != repository.BackupTypeFull {
|
|
testInstance.Errorf("die erste Sicherung muss eine Vollsicherung sein, sie war %q", fullResult.BackupType)
|
|
}
|
|
|
|
// Eine einzige Datei ändert sich; ihr Zeitstempel wird ausdrücklich in die
|
|
// Zukunft gesetzt, damit die Änderung unabhängig von der Zeitauflösung des
|
|
// Dateisystems erkennbar ist.
|
|
changedFilePath := filepath.Join(sourcePath, "dokumente", "notiz.txt")
|
|
if writeError := os.WriteFile(changedFilePath, []byte("Eine geänderte Notiz mit anderem Inhalt."), 0o600); writeError != nil {
|
|
testInstance.Fatalf("die Datei konnte nicht geändert werden: %v", writeError)
|
|
}
|
|
|
|
newFileTime := time.Now().Add(time.Second)
|
|
if timeError := os.Chtimes(changedFilePath, newFileTime, newFileTime); timeError != nil {
|
|
testInstance.Fatalf("der Zeitstempel konnte nicht gesetzt werden: %v", timeError)
|
|
}
|
|
|
|
incrementalResult, incrementalError := backupRunner.RunBackup(backupContext, BackupRunOptions{
|
|
BackupID: "zusatz-1",
|
|
SourcePath: sourcePath,
|
|
SourceName: "Testquelle",
|
|
Incremental: true,
|
|
})
|
|
if incrementalError != nil {
|
|
testInstance.Fatalf("die Zusatzsicherung schlug fehl: %v", incrementalError)
|
|
}
|
|
|
|
if incrementalResult.BackupType != repository.BackupTypeIncremental {
|
|
testInstance.Errorf("die zweite Sicherung muss eine Zusatzsicherung sein, sie war %q", incrementalResult.BackupType)
|
|
}
|
|
|
|
if incrementalResult.Changes == nil {
|
|
testInstance.Fatal("eine Zusatzsicherung muss das Ergebnis der Änderungserkennung ausweisen")
|
|
}
|
|
|
|
if incrementalResult.Changes.ParentBackupID != "voll-1" {
|
|
testInstance.Errorf("als Elternbackup wurde %q gewählt statt voll-1", incrementalResult.Changes.ParentBackupID)
|
|
}
|
|
|
|
// Der Kern des Nachweises: es wurde deutlich weniger gelesen als beim
|
|
// Vollbackup. Ohne diesen Vergleich bliebe offen, ob die Zusatzsicherung
|
|
// überhaupt etwas einspart.
|
|
if incrementalResult.Progress.BytesProcessed >= fullResult.Progress.BytesProcessed {
|
|
testInstance.Errorf("die Zusatzsicherung las %d Byte, die Vollsicherung nur %d — es wurde nichts eingespart",
|
|
incrementalResult.Progress.BytesProcessed, fullResult.Progress.BytesProcessed)
|
|
}
|
|
|
|
if incrementalResult.Progress.ChunksReused == 0 {
|
|
testInstance.Error("es wurde kein einziger Block aus dem Elternbackup übernommen")
|
|
}
|
|
|
|
if incrementalResult.Changes.ChangedFileCount() != 1 {
|
|
testInstance.Errorf("es sollte genau eine Datei neu gelesen werden, es waren %d",
|
|
incrementalResult.Changes.ChangedFileCount())
|
|
}
|
|
|
|
// Trotz Zusatzsicherung muss die Wiederherstellung den vollständigen
|
|
// Bestand ergeben — das Manifest beschreibt alles, nicht nur die Änderung.
|
|
sourceFingerprint := treeFingerprint(testInstance, sourcePath, true)
|
|
|
|
restoreTarget := filepath.Join(testInstance.TempDir(), "wiederhergestellt")
|
|
restoreResult, restoreError := restoreRunner.RunRestore(backupContext, RestoreRunOptions{
|
|
BackupID: "zusatz-1",
|
|
TargetPath: restoreTarget,
|
|
RestorePermissions: true,
|
|
})
|
|
if restoreError != nil {
|
|
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
|
|
}
|
|
|
|
if restoreResult.FilesRestored != fullResult.FilesBackedUp {
|
|
testInstance.Errorf("aus der Zusatzsicherung kamen %d Dateien zurück, gesichert waren %d",
|
|
restoreResult.FilesRestored, fullResult.FilesBackedUp)
|
|
}
|
|
|
|
if restoredFingerprint := treeFingerprint(testInstance, restoreTarget, true); restoredFingerprint != sourceFingerprint {
|
|
testInstance.Fatal("die aus der Zusatzsicherung wiederhergestellten Daten weichen von der Quelle ab")
|
|
}
|
|
}
|
|
|
|
// TestIncrementalBackupDropsDeletedFiles prüft, dass Gelöschtes verschwindet
|
|
// und dabei benannt wird.
|
|
func TestIncrementalBackupDropsDeletedFiles(testInstance *testing.T) {
|
|
backupRunner, restoreRunner := newRoundTripEnvironment(testInstance)
|
|
sourcePath := buildRealisticTree(testInstance)
|
|
|
|
backupContext := context.Background()
|
|
|
|
if _, fullError := backupRunner.RunBackup(backupContext, BackupRunOptions{
|
|
BackupID: "voll-1",
|
|
SourcePath: sourcePath,
|
|
SourceName: "Testquelle",
|
|
}); fullError != nil {
|
|
testInstance.Fatalf("die Vollsicherung schlug fehl: %v", fullError)
|
|
}
|
|
|
|
if removeError := os.Remove(filepath.Join(sourcePath, "dokumente", "bericht.txt")); removeError != nil {
|
|
testInstance.Fatalf("die Datei konnte nicht gelöscht werden: %v", removeError)
|
|
}
|
|
|
|
incrementalResult, incrementalError := backupRunner.RunBackup(backupContext, BackupRunOptions{
|
|
BackupID: "zusatz-1",
|
|
SourcePath: sourcePath,
|
|
SourceName: "Testquelle",
|
|
Incremental: true,
|
|
})
|
|
if incrementalError != nil {
|
|
testInstance.Fatalf("die Zusatzsicherung schlug fehl: %v", incrementalError)
|
|
}
|
|
|
|
deletedPaths := incrementalResult.Changes.DeletedPaths
|
|
if len(deletedPaths) != 1 || deletedPaths[0] != "dokumente/bericht.txt" {
|
|
testInstance.Fatalf("die gelöschte Datei wurde nicht ausgewiesen: %v", deletedPaths)
|
|
}
|
|
|
|
restoreTarget := filepath.Join(testInstance.TempDir(), "wiederhergestellt")
|
|
if _, restoreError := restoreRunner.RunRestore(backupContext, RestoreRunOptions{
|
|
BackupID: "zusatz-1",
|
|
TargetPath: restoreTarget,
|
|
}); restoreError != nil {
|
|
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
|
|
}
|
|
|
|
if _, statError := os.Stat(filepath.Join(restoreTarget, "dokumente", "bericht.txt")); statError == nil {
|
|
testInstance.Fatal("die gelöschte Datei kam aus der Zusatzsicherung zurück")
|
|
}
|
|
|
|
// Das Elternbackup bleibt davon unberührt: aus ihm muss die Datei weiterhin
|
|
// zurückkommen. Genau dafür gibt es Aufbewahrung.
|
|
parentTarget := filepath.Join(testInstance.TempDir(), "eltern")
|
|
if _, restoreError := restoreRunner.RunRestore(backupContext, RestoreRunOptions{
|
|
BackupID: "voll-1",
|
|
TargetPath: parentTarget,
|
|
}); restoreError != nil {
|
|
testInstance.Fatalf("die Wiederherstellung des Elternbackups schlug fehl: %v", restoreError)
|
|
}
|
|
|
|
if _, statError := os.Stat(filepath.Join(parentTarget, "dokumente", "bericht.txt")); statError != nil {
|
|
testInstance.Fatalf("die gelöschte Datei fehlt im Elternbackup: %v", statError)
|
|
}
|
|
}
|
|
|
|
// TestIncrementalBackupWithoutParentFails prüft, dass eine Zusatzsicherung ohne
|
|
// Elternbackup nicht heimlich zur Vollsicherung wird.
|
|
//
|
|
// Ein stiller Wechsel wäre bequem und falsch: der Aufrufer glaubte, eine Kette
|
|
// fortzuschreiben, und hätte in Wahrheit eine neue begonnen.
|
|
func TestIncrementalBackupWithoutParentFails(testInstance *testing.T) {
|
|
backupRunner, _ := newRoundTripEnvironment(testInstance)
|
|
sourcePath := buildRealisticTree(testInstance)
|
|
|
|
_, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{
|
|
BackupID: "zusatz-ohne-eltern",
|
|
SourcePath: sourcePath,
|
|
Incremental: true,
|
|
})
|
|
|
|
if backupError == nil {
|
|
testInstance.Fatal("eine Zusatzsicherung ohne Elternbackup muss fehlschlagen")
|
|
}
|
|
|
|
if !strings.Contains(backupError.Error(), "elternbackup") {
|
|
testInstance.Errorf("die Fehlermeldung nennt die Ursache nicht: %v", backupError)
|
|
}
|
|
}
|
|
|
|
// TestIncrementalChainKeepsChainIdentifier prüft, dass eine Kette
|
|
// zusammenbleibt.
|
|
func TestIncrementalChainKeepsChainIdentifier(testInstance *testing.T) {
|
|
backupRunner, _ := newRoundTripEnvironment(testInstance)
|
|
sourcePath := buildRealisticTree(testInstance)
|
|
|
|
backupContext := context.Background()
|
|
|
|
if _, fullError := backupRunner.RunBackup(backupContext, BackupRunOptions{
|
|
BackupID: "voll-1",
|
|
SourcePath: sourcePath,
|
|
ChainID: "kette-a",
|
|
}); fullError != nil {
|
|
testInstance.Fatalf("die Vollsicherung schlug fehl: %v", fullError)
|
|
}
|
|
|
|
// Zwei aufeinanderfolgende Zusatzsicherungen: die zweite muss die erste als
|
|
// Elternbackup wählen, nicht die Vollsicherung.
|
|
for _, backupIdentifier := range []string{"zusatz-1", "zusatz-2"} {
|
|
if _, incrementalError := backupRunner.RunBackup(backupContext, BackupRunOptions{
|
|
BackupID: backupIdentifier,
|
|
SourcePath: sourcePath,
|
|
Incremental: true,
|
|
}); incrementalError != nil {
|
|
testInstance.Fatalf("die Zusatzsicherung %s schlug fehl: %v", backupIdentifier, incrementalError)
|
|
}
|
|
}
|
|
|
|
sourceRepository := backupRunner.engine.Repository()
|
|
|
|
lastManifest, readError := sourceRepository.ReadManifest(backupContext, "zusatz-2")
|
|
if readError != nil {
|
|
testInstance.Fatalf("das Manifest konnte nicht gelesen werden: %v", readError)
|
|
}
|
|
|
|
if lastManifest.ChainID != "kette-a" {
|
|
testInstance.Errorf("die Kette wurde nicht fortgeschrieben: %q statt kette-a", lastManifest.ChainID)
|
|
}
|
|
|
|
if lastManifest.ParentBackupID != "zusatz-1" {
|
|
testInstance.Errorf("als Elternbackup wurde %q gewählt statt zusatz-1", lastManifest.ParentBackupID)
|
|
}
|
|
}
|