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>
607 lines
23 KiB
Go
607 lines
23 KiB
Go
package repository
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// newTestLogger liefert einen Logger, der ins Nichts schreibt.
|
|
func newTestLogger() *slog.Logger {
|
|
return slog.New(slog.NewJSONHandler(io.Discard, nil))
|
|
}
|
|
|
|
// newTestRepository legt ein Repository in einem temporären Verzeichnis an.
|
|
//
|
|
// t.TempDir räumt das Verzeichnis nach dem Test automatisch weg.
|
|
func newTestRepository(testInstance *testing.T) *LocalRepository {
|
|
testInstance.Helper()
|
|
|
|
testRepository, createError := Create(context.Background(), testInstance.TempDir(), CreateOptions{
|
|
Name: "Test-Repository",
|
|
Kind: KindLocal,
|
|
CreatedByVersion: "test",
|
|
}, newTestLogger())
|
|
|
|
if createError != nil {
|
|
testInstance.Fatalf("das Repository konnte nicht angelegt werden: %v", createError)
|
|
}
|
|
|
|
testInstance.Cleanup(func() { _ = testRepository.Close() })
|
|
|
|
return testRepository
|
|
}
|
|
|
|
// writeTestBackup schreibt ein Backup mit den übergebenen Datenblöcken.
|
|
func writeTestBackup(testInstance *testing.T, testRepository *LocalRepository, backupID string, chunkContents [][]byte) *Manifest {
|
|
testInstance.Helper()
|
|
|
|
backupWriter, beginError := testRepository.BeginBackup(context.Background(), backupID)
|
|
if beginError != nil {
|
|
testInstance.Fatalf("die Schreibsession konnte nicht geöffnet werden: %v", beginError)
|
|
}
|
|
|
|
chunkReferences := make([]ChunkReference, 0, len(chunkContents))
|
|
var logicalOffset int64
|
|
|
|
for _, chunkContent := range chunkContents {
|
|
chunkReference, _, writeError := backupWriter.WriteChunk(context.Background(), chunkContent)
|
|
if writeError != nil {
|
|
testInstance.Fatalf("der Chunk konnte nicht geschrieben werden: %v", writeError)
|
|
}
|
|
|
|
chunkReference.LogicalOffset = logicalOffset
|
|
logicalOffset += chunkReference.LogicalLength
|
|
chunkReferences = append(chunkReferences, chunkReference)
|
|
}
|
|
|
|
manifest := &Manifest{
|
|
Source: SourceInformation{
|
|
SourceType: "filesystem",
|
|
SourceID: "quelle-1",
|
|
SourceName: "Testquelle",
|
|
},
|
|
Entries: []ManifestEntry{{
|
|
Path: "/daten/test.bin",
|
|
EntryType: "file",
|
|
SizeBytes: logicalOffset,
|
|
Chunks: chunkReferences,
|
|
}},
|
|
CreatedByVersion: "test",
|
|
}
|
|
|
|
if commitError := backupWriter.Commit(context.Background(), manifest); commitError != nil {
|
|
testInstance.Fatalf("das Backup konnte nicht abgeschlossen werden: %v", commitError)
|
|
}
|
|
|
|
return manifest
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Anlegen und Öffnen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCreateProducesSelfDescribingRepository(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
// Alle Verzeichnisse des Formats müssen vorliegen.
|
|
for _, expectedDirectory := range allRepositoryDirectories {
|
|
directoryPath := filepath.Join(testRepository.RootPath(), expectedDirectory)
|
|
if _, statError := os.Stat(directoryPath); statError != nil {
|
|
testInstance.Errorf("das Verzeichnis %q fehlt: %v", expectedDirectory, statError)
|
|
}
|
|
}
|
|
|
|
descriptor := testRepository.Descriptor()
|
|
if descriptor.Identifier != FormatIdentifier {
|
|
testInstance.Errorf("die Kennung soll %q sein, war %q", FormatIdentifier, descriptor.Identifier)
|
|
}
|
|
|
|
// Ohne Repository-Kennung liesse sich ein Katalog nicht zuordnen.
|
|
if descriptor.RepositoryID == "" {
|
|
testInstance.Error("dem Repository fehlt seine Kennung")
|
|
}
|
|
}
|
|
|
|
func TestCreateRefusesToOverwriteExistingRepository(testInstance *testing.T) {
|
|
repositoryPath := testInstance.TempDir()
|
|
|
|
firstRepository, _ := Create(context.Background(), repositoryPath, CreateOptions{Name: "Erstes"}, newTestLogger())
|
|
_ = firstRepository.Close()
|
|
|
|
// Ein bestehendes Repository zu überschreiben wäre vollständiger Datenverlust.
|
|
_, secondError := Create(context.Background(), repositoryPath, CreateOptions{Name: "Zweites"}, newTestLogger())
|
|
if !errors.Is(secondError, ErrRepositoryExists) {
|
|
testInstance.Fatalf("ein bestehendes Repository darf nicht überschrieben werden, war: %v", secondError)
|
|
}
|
|
}
|
|
|
|
func TestOpenRejectsDirectoryWithoutRepository(testInstance *testing.T) {
|
|
_, openError := Open(context.Background(), testInstance.TempDir(), OpenOptions{}, newTestLogger())
|
|
|
|
// Ein beliebiges Verzeichnis darf nicht als Repository durchgehen.
|
|
if !errors.Is(openError, ErrNotARepository) {
|
|
testInstance.Fatalf("erwartet wurde ErrNotARepository, war: %v", openError)
|
|
}
|
|
}
|
|
|
|
func TestOpenRejectsNewerFormatVersion(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
repositoryPath := testRepository.RootPath()
|
|
_ = testRepository.Close()
|
|
|
|
// Der Descriptor wird auf eine künftige Formatversion gesetzt.
|
|
descriptor := testRepository.Descriptor()
|
|
descriptor.FormatVersion = FormatVersion + 1
|
|
|
|
encodedDescriptor, _ := encodeDescriptor(descriptor)
|
|
descriptorPath := filepath.Join(repositoryPath, directoryFormat, fileDescriptor)
|
|
if writeError := os.WriteFile(descriptorPath, encodedDescriptor, dataFilePermissions); writeError != nil {
|
|
testInstance.Fatalf("der Descriptor konnte nicht geändert werden: %v", writeError)
|
|
}
|
|
|
|
// Eine ältere Programmversion darf ein neueres Format nicht anfassen.
|
|
_, openError := Open(context.Background(), repositoryPath, OpenOptions{}, newTestLogger())
|
|
if !errors.Is(openError, ErrUnsupportedFormat) {
|
|
testInstance.Fatalf("ein neueres Format muss abgelehnt werden, war: %v", openError)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Chunks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestWriteAndReadChunk(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
originalData := []byte("Dies sind Testdaten für einen Chunk.")
|
|
|
|
chunkIdentifier, wasNew, writeError := testRepository.writeChunk(context.Background(), originalData)
|
|
if writeError != nil {
|
|
testInstance.Fatalf("der Chunk konnte nicht geschrieben werden: %v", writeError)
|
|
}
|
|
|
|
if !wasNew {
|
|
testInstance.Error("der erste Chunk soll als neu gelten")
|
|
}
|
|
|
|
readData, readError := testRepository.ReadChunk(context.Background(), chunkIdentifier)
|
|
if readError != nil {
|
|
testInstance.Fatalf("der Chunk konnte nicht gelesen werden: %v", readError)
|
|
}
|
|
|
|
if !bytes.Equal(readData, originalData) {
|
|
testInstance.Fatalf("der gelesene Inhalt weicht ab: %q", readData)
|
|
}
|
|
}
|
|
|
|
func TestIdenticalContentIsStoredOnlyOnce(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
sameData := []byte("Derselbe Inhalt zweimal geschrieben.")
|
|
|
|
firstIdentifier, firstWasNew, _ := testRepository.writeChunk(context.Background(), sameData)
|
|
secondIdentifier, secondWasNew, _ := testRepository.writeChunk(context.Background(), sameData)
|
|
|
|
// Inhaltsadressierung bedeutet: gleicher Inhalt, gleiche Kennung.
|
|
if firstIdentifier != secondIdentifier {
|
|
testInstance.Fatal("gleicher Inhalt muss dieselbe Kennung ergeben")
|
|
}
|
|
|
|
if !firstWasNew {
|
|
testInstance.Error("der erste Schreibvorgang soll als neu gelten")
|
|
}
|
|
|
|
// Der zweite Vorgang darf nichts erneut schreiben - das ist die Deduplizierung.
|
|
if secondWasNew {
|
|
testInstance.Error("der zweite Schreibvorgang darf nicht als neu gelten")
|
|
}
|
|
}
|
|
|
|
func TestReadChunkDetectsCorruption(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
originalData := []byte("Dieser Inhalt wird gleich manipuliert.")
|
|
|
|
chunkIdentifier, _, _ := testRepository.writeChunk(context.Background(), originalData)
|
|
|
|
// Ein einzelnes verändertes Byte simuliert einen Bitfehler auf dem Datenträger.
|
|
chunkFilePath, _ := testRepository.chunkPath(chunkIdentifier)
|
|
if chmodError := os.Chmod(chunkFilePath, 0o600); chmodError != nil {
|
|
testInstance.Fatalf("die Dateirechte konnten nicht geändert werden: %v", chmodError)
|
|
}
|
|
|
|
corruptedData := bytes.Clone(originalData)
|
|
corruptedData[0] ^= 0xff
|
|
|
|
if writeError := os.WriteFile(chunkFilePath, corruptedData, 0o600); writeError != nil {
|
|
testInstance.Fatalf("der Chunk konnte nicht manipuliert werden: %v", writeError)
|
|
}
|
|
|
|
// Ein beschädigter Chunk darf niemals unbemerkt ausgeliefert werden.
|
|
_, readError := testRepository.ReadChunk(context.Background(), chunkIdentifier)
|
|
if !errors.Is(readError, ErrChunkCorrupted) {
|
|
testInstance.Fatalf("die Beschädigung muss erkannt werden, war: %v", readError)
|
|
}
|
|
}
|
|
|
|
func TestReadChunkReportsMissingChunk(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
// Eine wohlgeformte, aber nicht vorhandene Kennung.
|
|
missingIdentifier := "0000000000000000000000000000000000000000000000000000000000000000"
|
|
|
|
_, readError := testRepository.ReadChunk(context.Background(), missingIdentifier)
|
|
if !errors.Is(readError, ErrChunkNotFound) {
|
|
testInstance.Fatalf("ein fehlender Chunk muss gemeldet werden, war: %v", readError)
|
|
}
|
|
}
|
|
|
|
func TestChunkIdentifierIsValidatedAgainstPathTraversal(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
maliciousIdentifiers := []string{
|
|
"../../../etc/passwd",
|
|
"..",
|
|
"/absolute/pfad",
|
|
"kurz",
|
|
"GROSSBUCHSTABEN000000000000000000000000000000000000000000000000",
|
|
"zzzz000000000000000000000000000000000000000000000000000000000000",
|
|
}
|
|
|
|
for _, maliciousIdentifier := range maliciousIdentifiers {
|
|
// Eine manipulierte Kennung darf niemals aus dem Repository herausführen.
|
|
if _, pathError := testRepository.chunkPath(maliciousIdentifier); pathError == nil {
|
|
testInstance.Errorf("die Kennung %q wurde akzeptiert", maliciousIdentifier)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenChunkVerifiesWhileStreaming(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
originalData := bytes.Repeat([]byte("Streaming-Daten. "), 1000)
|
|
|
|
chunkIdentifier, _, _ := testRepository.writeChunk(context.Background(), originalData)
|
|
|
|
chunkStream, openError := testRepository.OpenChunk(context.Background(), chunkIdentifier)
|
|
if openError != nil {
|
|
testInstance.Fatalf("der Chunk konnte nicht geöffnet werden: %v", openError)
|
|
}
|
|
defer func() { _ = chunkStream.Close() }()
|
|
|
|
streamedData, readError := io.ReadAll(chunkStream)
|
|
if readError != nil {
|
|
testInstance.Fatalf("der Datenstrom konnte nicht gelesen werden: %v", readError)
|
|
}
|
|
|
|
if !bytes.Equal(streamedData, originalData) {
|
|
testInstance.Fatal("der gestreamte Inhalt weicht ab")
|
|
}
|
|
}
|
|
|
|
func TestOpenChunkDetectsCorruptionAtEndOfStream(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
originalData := bytes.Repeat([]byte("A"), 4096)
|
|
|
|
chunkIdentifier, _, _ := testRepository.writeChunk(context.Background(), originalData)
|
|
|
|
chunkFilePath, _ := testRepository.chunkPath(chunkIdentifier)
|
|
_ = os.Chmod(chunkFilePath, 0o600)
|
|
|
|
corruptedData := bytes.Clone(originalData)
|
|
corruptedData[2048] = 'B'
|
|
_ = os.WriteFile(chunkFilePath, corruptedData, 0o600)
|
|
|
|
chunkStream, _ := testRepository.OpenChunk(context.Background(), chunkIdentifier)
|
|
defer func() { _ = chunkStream.Close() }()
|
|
|
|
// Beim Streamen fällt die Beschädigung spätestens am Ende auf.
|
|
_, readError := io.ReadAll(chunkStream)
|
|
if !errors.Is(readError, ErrChunkCorrupted) {
|
|
testInstance.Fatalf("die Beschädigung muss beim Streamen auffallen, war: %v", readError)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Commit-Protokoll
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCommitMakesBackupVisible(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{
|
|
[]byte("Erster Block"),
|
|
[]byte("Zweiter Block"),
|
|
})
|
|
|
|
loadedManifest, readError := testRepository.ReadManifest(context.Background(), "backup-1")
|
|
if readError != nil {
|
|
testInstance.Fatalf("das Manifest konnte nicht gelesen werden: %v", readError)
|
|
}
|
|
|
|
if !loadedManifest.Complete {
|
|
testInstance.Error("das Manifest muss als abgeschlossen gekennzeichnet sein")
|
|
}
|
|
|
|
if loadedManifest.Statistics.ChunksWritten != 2 {
|
|
testInstance.Errorf("es sollen 2 Chunks vermerkt sein, waren %d", loadedManifest.Statistics.ChunksWritten)
|
|
}
|
|
}
|
|
|
|
func TestAbortLeavesNoVisibleBackup(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
backupWriter, beginError := testRepository.BeginBackup(context.Background(), "abgebrochen")
|
|
if beginError != nil {
|
|
testInstance.Fatalf("die Schreibsession konnte nicht geöffnet werden: %v", beginError)
|
|
}
|
|
|
|
_, _, _ = backupWriter.WriteChunk(context.Background(), []byte("Daten eines abgebrochenen Laufs"))
|
|
|
|
if abortError := backupWriter.Abort(context.Background()); abortError != nil {
|
|
testInstance.Fatalf("der Abbruch schlug fehl: %v", abortError)
|
|
}
|
|
|
|
// Ohne Commit darf kein Backup sichtbar sein - ein halbes Backup wäre
|
|
// schlimmer als gar keines.
|
|
_, readError := testRepository.ReadManifest(context.Background(), "abgebrochen")
|
|
if !errors.Is(readError, ErrBackupNotFound) {
|
|
testInstance.Fatalf("ein abgebrochener Lauf darf kein Backup hinterlassen, war: %v", readError)
|
|
}
|
|
}
|
|
|
|
func TestManifestWithoutCompletionMarkerIsRejected(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, testRepository, "unvollstaendig", [][]byte{[]byte("Daten")})
|
|
|
|
// Der Abschlussvermerk wird entfernt.
|
|
manifestFilePath := testRepository.manifestPath("unvollstaendig")
|
|
rawManifest, _ := os.ReadFile(manifestFilePath)
|
|
|
|
manifest, _ := decodeManifest(rawManifest)
|
|
manifest.Complete = false
|
|
|
|
tamperedManifest, _ := encodeManifest(manifest)
|
|
_ = os.Chmod(manifestFilePath, 0o600)
|
|
_ = os.WriteFile(manifestFilePath, tamperedManifest, 0o600)
|
|
|
|
// Ohne Abschlussvermerk gilt das Backup als unvollständig.
|
|
_, readError := testRepository.ReadManifest(context.Background(), "unvollstaendig")
|
|
if !errors.Is(readError, ErrBackupIncomplete) {
|
|
testInstance.Fatalf("ein Backup ohne Abschlussvermerk muss abgelehnt werden, war: %v", readError)
|
|
}
|
|
}
|
|
|
|
func TestTamperedManifestIsDetected(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, testRepository, "manipuliert", [][]byte{[]byte("Daten")})
|
|
|
|
manifestFilePath := testRepository.manifestPath("manipuliert")
|
|
rawManifest, _ := os.ReadFile(manifestFilePath)
|
|
|
|
manifest, _ := decodeManifest(rawManifest)
|
|
// Die Kennzahl wird verfälscht, die Prüfsumme bleibt die alte.
|
|
manifest.Statistics.LogicalBytes = 999999
|
|
|
|
tamperedManifest, _ := encodeManifest(manifest)
|
|
_ = os.Chmod(manifestFilePath, 0o600)
|
|
_ = os.WriteFile(manifestFilePath, tamperedManifest, 0o600)
|
|
|
|
_, readError := testRepository.ReadManifest(context.Background(), "manipuliert")
|
|
if !errors.Is(readError, ErrManifestCorrupted) {
|
|
testInstance.Fatalf("ein verfälschtes Manifest muss erkannt werden, war: %v", readError)
|
|
}
|
|
}
|
|
|
|
func TestExistingBackupIsNotOverwritten(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Original")})
|
|
|
|
// Ein zweites Backup gleicher Kennung würde das erste vernichten.
|
|
_, beginError := testRepository.BeginBackup(context.Background(), "backup-1")
|
|
if beginError == nil {
|
|
testInstance.Fatal("eine bereits vergebene Backup-Kennung muss abgelehnt werden")
|
|
}
|
|
}
|
|
|
|
func TestWriteAfterCommitIsRejected(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
backupWriter, _ := testRepository.BeginBackup(context.Background(), "geschlossen")
|
|
_, _, _ = backupWriter.WriteChunk(context.Background(), []byte("Daten"))
|
|
_ = backupWriter.Commit(context.Background(), &Manifest{Source: SourceInformation{SourceType: "filesystem"}})
|
|
|
|
// Nach dem Abschluss darf nichts mehr hinzukommen: das Manifest ist versiegelt.
|
|
_, _, writeError := backupWriter.WriteChunk(context.Background(), []byte("Nachzügler"))
|
|
if !errors.Is(writeError, ErrSessionClosed) {
|
|
testInstance.Fatalf("ein Schreibvorgang nach dem Abschluss muss abgelehnt werden, war: %v", writeError)
|
|
}
|
|
}
|
|
|
|
func TestDeduplicationAcrossBackups(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
sharedContent := []byte("Dieser Block kommt in beiden Backups vor.")
|
|
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{sharedContent, []byte("Nur im ersten")})
|
|
|
|
backupWriter, _ := testRepository.BeginBackup(context.Background(), "backup-2")
|
|
_, firstWasNew, _ := backupWriter.WriteChunk(context.Background(), sharedContent)
|
|
_, secondWasNew, _ := backupWriter.WriteChunk(context.Background(), []byte("Nur im zweiten"))
|
|
|
|
// Der gemeinsame Block darf nicht erneut abgelegt werden.
|
|
if firstWasNew {
|
|
testInstance.Error("der bereits vorhandene Block darf nicht erneut geschrieben werden")
|
|
}
|
|
|
|
if !secondWasNew {
|
|
testInstance.Error("der neue Block muss geschrieben werden")
|
|
}
|
|
|
|
sessionStatistics := backupWriter.Statistics()
|
|
if sessionStatistics.ChunksDeduplicated != 1 {
|
|
testInstance.Errorf("es soll 1 Chunk dedupliziert sein, waren %d", sessionStatistics.ChunksDeduplicated)
|
|
}
|
|
|
|
_ = backupWriter.Abort(context.Background())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Katalog
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCatalogListsCommittedBackups(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")})
|
|
writeTestBackup(testInstance, testRepository, "backup-2", [][]byte{[]byte("Zwei")})
|
|
|
|
catalogEntries, listError := testRepository.ListBackups(context.Background())
|
|
if listError != nil {
|
|
testInstance.Fatalf("der Katalog konnte nicht gelesen werden: %v", listError)
|
|
}
|
|
|
|
if len(catalogEntries) != 2 {
|
|
testInstance.Fatalf("es sollen 2 Backups im Katalog stehen, waren %d", len(catalogEntries))
|
|
}
|
|
}
|
|
|
|
func TestRebuildCatalogWithoutAnyDatabase(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")})
|
|
writeTestBackup(testInstance, testRepository, "backup-2", [][]byte{[]byte("Zwei")})
|
|
writeTestBackup(testInstance, testRepository, "backup-3", [][]byte{[]byte("Drei")})
|
|
|
|
// Der Katalog wird vollständig gelöscht - wie nach dem Verlust des Control Servers.
|
|
if removeError := os.Remove(testRepository.catalogPath()); removeError != nil {
|
|
testInstance.Fatalf("der Katalog konnte nicht gelöscht werden: %v", removeError)
|
|
}
|
|
|
|
rebuiltCatalog, rebuildError := testRepository.RebuildCatalog(context.Background())
|
|
if rebuildError != nil {
|
|
testInstance.Fatalf("der Katalog konnte nicht wiederaufgebaut werden: %v", rebuildError)
|
|
}
|
|
|
|
if len(rebuiltCatalog.Entries) != 3 {
|
|
testInstance.Fatalf("der wiederaufgebaute Katalog soll 3 Backups enthalten, waren %d", len(rebuiltCatalog.Entries))
|
|
}
|
|
|
|
if !rebuiltCatalog.RebuiltFromManifests {
|
|
testInstance.Error("der Katalog soll als wiederaufgebaut gekennzeichnet sein")
|
|
}
|
|
}
|
|
|
|
func TestCorruptedCatalogIsRebuiltAutomatically(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")})
|
|
|
|
// Der Katalog wird durch Unsinn ersetzt.
|
|
if writeError := os.WriteFile(testRepository.catalogPath(), []byte("kein gültiges JSON"), 0o600); writeError != nil {
|
|
testInstance.Fatalf("der Katalog konnte nicht manipuliert werden: %v", writeError)
|
|
}
|
|
|
|
// Ein unlesbarer Katalog ist kein Datenverlust: er wird neu erzeugt.
|
|
catalogEntries, listError := testRepository.ListBackups(context.Background())
|
|
if listError != nil {
|
|
testInstance.Fatalf("der Katalog wurde nicht wiederaufgebaut: %v", listError)
|
|
}
|
|
|
|
if len(catalogEntries) != 1 {
|
|
testInstance.Fatalf("es soll 1 Backup gefunden werden, waren %d", len(catalogEntries))
|
|
}
|
|
}
|
|
|
|
func TestRebuildSkipsIncompleteBackups(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
writeTestBackup(testInstance, testRepository, "vollstaendig", [][]byte{[]byte("Gut")})
|
|
writeTestBackup(testInstance, testRepository, "unvollstaendig", [][]byte{[]byte("Schlecht")})
|
|
|
|
// Dem zweiten Backup wird der Abschlussvermerk entzogen.
|
|
manifestFilePath := testRepository.manifestPath("unvollstaendig")
|
|
rawManifest, _ := os.ReadFile(manifestFilePath)
|
|
manifest, _ := decodeManifest(rawManifest)
|
|
manifest.Complete = false
|
|
tamperedManifest, _ := encodeManifest(manifest)
|
|
_ = os.Chmod(manifestFilePath, 0o600)
|
|
_ = os.WriteFile(manifestFilePath, tamperedManifest, 0o600)
|
|
|
|
rebuiltCatalog, _ := testRepository.RebuildCatalog(context.Background())
|
|
|
|
// Ein unvollständiges Backup darf nicht als wiederherstellbar erscheinen.
|
|
if len(rebuiltCatalog.Entries) != 1 {
|
|
testInstance.Fatalf("nur das vollständige Backup gehört in den Katalog, es waren %d", len(rebuiltCatalog.Entries))
|
|
}
|
|
|
|
if rebuiltCatalog.Entries[0].BackupID != "vollstaendig" {
|
|
testInstance.Errorf("das falsche Backup wurde aufgenommen: %s", rebuiltCatalog.Entries[0].BackupID)
|
|
}
|
|
}
|
|
|
|
func TestCatalogOfForeignRepositoryIsRejected(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")})
|
|
|
|
// Ein Katalog mit fremder Repository-Kennung wird untergeschoben.
|
|
foreignCatalog := &Catalog{
|
|
CatalogVersion: CatalogVersion,
|
|
RepositoryID: "ein-fremdes-repository",
|
|
Entries: []CatalogEntry{
|
|
{BackupID: "erfundenes-backup"},
|
|
},
|
|
GeneratedAt: time.Now().UTC(),
|
|
}
|
|
|
|
encodedCatalog, _ := encodeCatalog(foreignCatalog)
|
|
_ = os.WriteFile(testRepository.catalogPath(), encodedCatalog, 0o600)
|
|
|
|
catalogEntries, _ := testRepository.ListBackups(context.Background())
|
|
|
|
// Der fremde Katalog darf nicht übernommen werden.
|
|
if len(catalogEntries) != 1 || catalogEntries[0].BackupID != "backup-1" {
|
|
testInstance.Fatalf("der fremde Katalog wurde übernommen: %+v", catalogEntries)
|
|
}
|
|
}
|
|
|
|
// TestRebuildAfterCatalogDirectoryIsGone prueft den Verlust des ganzen Verzeichnisses.
|
|
//
|
|
// „Katalog verloren" (Szenario C der Phase 18) heisst nicht immer „Datei
|
|
// geloescht": Bei einem Teilausfall des Dateisystems oder nach einem beherzten
|
|
// Aufraeumen fehlt das ganze Verzeichnis. Bis zum Nachweis der Phase 18
|
|
// scheiterte ausgerechnet der Neuaufbau, der den Verlust beheben soll — mit
|
|
// einer Meldung ueber eine temporaere Datei, die nichts ueber das eigentliche
|
|
// Problem sagte.
|
|
//
|
|
// Die bestehenden Tests loeschten stets nur die Katalogdatei und blieben
|
|
// deshalb gruen.
|
|
func TestRebuildAfterCatalogDirectoryIsGone(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")})
|
|
writeTestBackup(testInstance, testRepository, "backup-2", [][]byte{[]byte("Zwei")})
|
|
|
|
// Nicht nur die Datei — das ganze Verzeichnis.
|
|
if removeError := os.RemoveAll(filepath.Dir(testRepository.catalogPath())); removeError != nil {
|
|
testInstance.Fatalf("das Katalogverzeichnis konnte nicht gelöscht werden: %v", removeError)
|
|
}
|
|
|
|
catalogEntries, listError := testRepository.ListBackups(context.Background())
|
|
if listError != nil {
|
|
testInstance.Fatalf("der Katalog wurde nicht wiederaufgebaut: %v", listError)
|
|
}
|
|
|
|
if len(catalogEntries) != 2 {
|
|
testInstance.Fatalf("es sollen 2 Backups gefunden werden, waren %d", len(catalogEntries))
|
|
}
|
|
|
|
// Und er muss danach wieder auf der Platte liegen: Ein Katalog, der bei
|
|
// jedem Zugriff neu entsteht, macht aus einem Beschleuniger eine Bremse.
|
|
if _, statError := os.Stat(testRepository.catalogPath()); statError != nil {
|
|
testInstance.Errorf("der Katalog wurde nicht wieder abgelegt: %v", statError)
|
|
}
|
|
}
|