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>
279 lines
11 KiB
Go
279 lines
11 KiB
Go
package repository
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/syncova/syncova/packages/backupformat"
|
|
)
|
|
|
|
func TestExportAndImportRoundTrip(testInstance *testing.T) {
|
|
sourceRepository := newTestRepository(testInstance)
|
|
|
|
originalManifest := writeTestBackup(testInstance, sourceRepository, "backup-1", [][]byte{
|
|
[]byte("Erster Block"),
|
|
[]byte("Zweiter Block"),
|
|
[]byte("Dritter Block"),
|
|
})
|
|
|
|
// Export in einen portablen Container.
|
|
var containerBuffer bytes.Buffer
|
|
exportedBytes, exportError := sourceRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
|
|
if exportError != nil {
|
|
testInstance.Fatalf("der Export schlug fehl: %v", exportError)
|
|
}
|
|
|
|
if exportedBytes == 0 {
|
|
testInstance.Fatal("der Container ist leer")
|
|
}
|
|
|
|
// Import in ein völlig anderes Repository - so wie eine Übertragung an
|
|
// einen zweiten Standort abliefe.
|
|
targetRepository := newTestRepository(testInstance)
|
|
|
|
importResult, importError := targetRepository.ImportBackup(context.Background(), bytes.NewReader(containerBuffer.Bytes()))
|
|
if importError != nil {
|
|
testInstance.Fatalf("der Import schlug fehl: %v", importError)
|
|
}
|
|
|
|
if importResult.BackupID != "backup-1" {
|
|
testInstance.Errorf("die Backup-Kennung weicht ab: %q", importResult.BackupID)
|
|
}
|
|
|
|
if importResult.ChunksImported != 3 {
|
|
testInstance.Errorf("es sollen 3 Chunks übernommen werden, waren %d", importResult.ChunksImported)
|
|
}
|
|
|
|
// Das eingelesene Manifest muss dem ursprünglichen entsprechen.
|
|
importedManifest, readError := targetRepository.ReadManifest(context.Background(), "backup-1")
|
|
if readError != nil {
|
|
testInstance.Fatalf("das eingelesene Manifest ist nicht lesbar: %v", readError)
|
|
}
|
|
|
|
if importedManifest.ContentHash != originalManifest.ContentHash {
|
|
testInstance.Error("die Prüfsumme des Manifests hat die Übertragung nicht überstanden")
|
|
}
|
|
|
|
// Die Daten selbst müssen bitgenau angekommen sein.
|
|
for _, chunkReference := range originalManifest.Entries[0].Chunks {
|
|
originalData, _ := sourceRepository.ReadChunk(context.Background(), chunkReference.Identifier)
|
|
importedData, chunkError := targetRepository.ReadChunk(context.Background(), chunkReference.Identifier)
|
|
|
|
if chunkError != nil {
|
|
testInstance.Fatalf("ein Chunk fehlt nach dem Import: %v", chunkError)
|
|
}
|
|
|
|
if !bytes.Equal(originalData, importedData) {
|
|
testInstance.Fatal("der Inhalt eines Chunks weicht nach der Übertragung ab")
|
|
}
|
|
}
|
|
|
|
// Das Backup muss im Zielkatalog auftauchen.
|
|
catalogEntries, _ := targetRepository.ListBackups(context.Background())
|
|
if len(catalogEntries) != 1 {
|
|
testInstance.Fatalf("das Ziel-Repository soll 1 Backup führen, waren %d", len(catalogEntries))
|
|
}
|
|
}
|
|
|
|
func TestImportDeduplicatesAgainstExistingChunks(testInstance *testing.T) {
|
|
sourceRepository := newTestRepository(testInstance)
|
|
sharedContent := []byte("Dieser Block existiert bereits im Ziel.")
|
|
|
|
writeTestBackup(testInstance, sourceRepository, "backup-1", [][]byte{sharedContent, []byte("Nur hier")})
|
|
|
|
var containerBuffer bytes.Buffer
|
|
_, _ = sourceRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
|
|
|
|
// Das Ziel kennt einen der Blöcke bereits.
|
|
targetRepository := newTestRepository(testInstance)
|
|
_, _, _ = targetRepository.writeChunk(context.Background(), sharedContent)
|
|
|
|
importResult, importError := targetRepository.ImportBackup(context.Background(), bytes.NewReader(containerBuffer.Bytes()))
|
|
if importError != nil {
|
|
testInstance.Fatalf("der Import schlug fehl: %v", importError)
|
|
}
|
|
|
|
// Ein bereits vorhandener Block wird nicht erneut geschrieben.
|
|
if importResult.ChunksAlreadyPresent != 1 {
|
|
testInstance.Errorf("es soll 1 Block bereits vorhanden sein, waren %d", importResult.ChunksAlreadyPresent)
|
|
}
|
|
|
|
if importResult.ChunksImported != 1 {
|
|
testInstance.Errorf("es soll 1 Block übernommen werden, waren %d", importResult.ChunksImported)
|
|
}
|
|
}
|
|
|
|
func TestImportRejectsTruncatedContainer(testInstance *testing.T) {
|
|
sourceRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, sourceRepository, "backup-1", [][]byte{[]byte("Daten")})
|
|
|
|
var containerBuffer bytes.Buffer
|
|
_, _ = sourceRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
|
|
|
|
// Eine abgebrochene Übertragung liefert einen unvollständigen Container.
|
|
containerBytes := containerBuffer.Bytes()
|
|
truncatedContainer := containerBytes[:len(containerBytes)-30]
|
|
|
|
targetRepository := newTestRepository(testInstance)
|
|
|
|
_, importError := targetRepository.ImportBackup(context.Background(), bytes.NewReader(truncatedContainer))
|
|
if importError == nil {
|
|
testInstance.Fatal("ein abgeschnittener Container darf nicht eingelesen werden")
|
|
}
|
|
|
|
// Es darf kein sichtbares Backup entstanden sein.
|
|
if _, readError := targetRepository.ReadManifest(context.Background(), "backup-1"); !errors.Is(readError, ErrBackupNotFound) {
|
|
testInstance.Fatalf("ein unvollständiger Import darf kein Backup hinterlassen, war: %v", readError)
|
|
}
|
|
}
|
|
|
|
func TestImportRejectsCorruptedContainer(testInstance *testing.T) {
|
|
sourceRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, sourceRepository, "backup-1", [][]byte{[]byte("Originaldaten")})
|
|
|
|
var containerBuffer bytes.Buffer
|
|
_, _ = sourceRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
|
|
|
|
// Ein Übertragungsfehler verfälscht ein Byte im Datenbereich.
|
|
tamperedContainer := bytes.Clone(containerBuffer.Bytes())
|
|
dataPosition := bytes.Index(tamperedContainer, []byte("Originaldaten"))
|
|
if dataPosition < 0 {
|
|
testInstance.Fatal("der Datenbereich wurde nicht gefunden")
|
|
}
|
|
|
|
tamperedContainer[dataPosition] = 'X'
|
|
|
|
targetRepository := newTestRepository(testInstance)
|
|
|
|
_, importError := targetRepository.ImportBackup(context.Background(), bytes.NewReader(tamperedContainer))
|
|
if importError == nil {
|
|
testInstance.Fatal("ein verfälschter Container darf nicht eingelesen werden")
|
|
}
|
|
|
|
if _, readError := targetRepository.ReadManifest(context.Background(), "backup-1"); !errors.Is(readError, ErrBackupNotFound) {
|
|
testInstance.Fatal("ein fehlgeschlagener Import darf kein Backup hinterlassen")
|
|
}
|
|
}
|
|
|
|
func TestExportRefusesWhenChunkIsMissing(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
manifest := writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Daten")})
|
|
|
|
// Ein Chunk verschwindet vom Datenträger.
|
|
chunkFilePath, _ := testRepository.chunkPath(manifest.Entries[0].Chunks[0].Identifier)
|
|
_ = os.Remove(chunkFilePath)
|
|
|
|
// Ein Container ohne alle Blöcke wäre unbrauchbar und darf nicht entstehen.
|
|
var containerBuffer bytes.Buffer
|
|
_, exportError := testRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
|
|
|
|
if !errors.Is(exportError, ErrChunkNotFound) {
|
|
testInstance.Fatalf("der Export muss bei fehlendem Chunk abbrechen, war: %v", exportError)
|
|
}
|
|
}
|
|
|
|
func TestExportRefusesWhenChunkIsCorrupted(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
manifest := writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Originaldaten")})
|
|
|
|
// Ein Block auf dem Datenträger ist beschädigt.
|
|
chunkFilePath, _ := testRepository.chunkPath(manifest.Entries[0].Chunks[0].Identifier)
|
|
_ = os.Chmod(chunkFilePath, 0o600)
|
|
_ = os.WriteFile(chunkFilePath, []byte("beschaedigt!!"), 0o600)
|
|
|
|
var containerBuffer bytes.Buffer
|
|
_, exportError := testRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
|
|
|
|
// Ein beschädigter Block darf nicht in einen Container gelangen: der
|
|
// Empfänger hielte ihn sonst für gültige Daten.
|
|
if !errors.Is(exportError, ErrChunkCorrupted) {
|
|
testInstance.Fatalf("der Export muss bei beschädigtem Chunk abbrechen, war: %v", exportError)
|
|
}
|
|
}
|
|
|
|
func TestImportRejectsDuplicateBackupIdentifier(testInstance *testing.T) {
|
|
sourceRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, sourceRepository, "backup-1", [][]byte{[]byte("Daten")})
|
|
|
|
var containerBuffer bytes.Buffer
|
|
_, _ = sourceRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
|
|
|
|
targetRepository := newTestRepository(testInstance)
|
|
|
|
// Der erste Import gelingt.
|
|
if _, importError := targetRepository.ImportBackup(context.Background(), bytes.NewReader(containerBuffer.Bytes())); importError != nil {
|
|
testInstance.Fatalf("der erste Import schlug fehl: %v", importError)
|
|
}
|
|
|
|
// Der zweite würde ein bestehendes Backup überschreiben.
|
|
if _, importError := targetRepository.ImportBackup(context.Background(), bytes.NewReader(containerBuffer.Bytes())); importError == nil {
|
|
testInstance.Fatal("ein bereits vorhandenes Backup darf nicht überschrieben werden")
|
|
}
|
|
}
|
|
|
|
func TestImportRefusesOnReadOnlyRepository(testInstance *testing.T) {
|
|
sourceRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, sourceRepository, "backup-1", [][]byte{[]byte("Daten")})
|
|
|
|
var containerBuffer bytes.Buffer
|
|
_, _ = sourceRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
|
|
|
|
targetRepository := newTestRepository(testInstance)
|
|
targetPath := targetRepository.RootPath()
|
|
_ = targetRepository.Close()
|
|
|
|
readOnlyRepository, _ := Open(context.Background(), targetPath, OpenOptions{ReadOnly: true}, newTestLogger())
|
|
defer func() { _ = readOnlyRepository.Close() }()
|
|
|
|
if _, importError := readOnlyRepository.ImportBackup(context.Background(), bytes.NewReader(containerBuffer.Bytes())); importError == nil {
|
|
testInstance.Fatal("ein nur lesend geöffnetes Repository darf nichts aufnehmen")
|
|
}
|
|
}
|
|
|
|
func TestExportedContainerCarriesChainInformation(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
writeChainBackup(testInstance, testRepository, "voll", "kette-1", "", BackupTypeFull)
|
|
writeChainBackup(testInstance, testRepository, "inkrement", "kette-1", "voll", BackupTypeIncremental)
|
|
|
|
var containerBuffer bytes.Buffer
|
|
_, _ = testRepository.ExportBackup(context.Background(), "inkrement", &containerBuffer, "test")
|
|
|
|
containerReader, readerError := backupformat.NewReader(bytes.NewReader(containerBuffer.Bytes()))
|
|
if readerError != nil {
|
|
testInstance.Fatalf("der Container konnte nicht gelesen werden: %v", readerError)
|
|
}
|
|
|
|
containerHeader := containerReader.Header()
|
|
|
|
// Ohne Kettenangaben liesse sich eine Kette aus einzelnen Containern nicht
|
|
// wieder zusammensetzen.
|
|
if containerHeader.ChainID != "kette-1" {
|
|
testInstance.Errorf("die Kettenkennung weicht ab: %q", containerHeader.ChainID)
|
|
}
|
|
|
|
if containerHeader.ParentBackupID != "voll" {
|
|
testInstance.Errorf("das Elternbackup weicht ab: %q", containerHeader.ParentBackupID)
|
|
}
|
|
}
|
|
|
|
func TestExportedContainerNamesSourceRepository(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Daten")})
|
|
|
|
var containerBuffer bytes.Buffer
|
|
_, _ = testRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
|
|
|
|
containerReader, _ := backupformat.NewReader(bytes.NewReader(containerBuffer.Bytes()))
|
|
|
|
// Die Herkunft muss nachvollziehbar bleiben.
|
|
if containerReader.Header().RepositoryID != testRepository.Descriptor().RepositoryID {
|
|
testInstance.Error("der Container benennt sein Ursprungs-Repository nicht")
|
|
}
|
|
}
|