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>
624 lines
26 KiB
Go
624 lines
26 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integritätslauf
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestScanReportsHealthyRepository(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins"), []byte("Zwei")})
|
|
writeTestBackup(testInstance, testRepository, "backup-2", [][]byte{[]byte("Drei")})
|
|
|
|
scanReport, scanError := testRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true})
|
|
if scanError != nil {
|
|
testInstance.Fatalf("der Integritätslauf schlug fehl: %v", scanError)
|
|
}
|
|
|
|
if !scanReport.IsHealthy() {
|
|
testInstance.Fatalf("das Repository soll einwandfrei sein: %s", scanReport.Summary())
|
|
}
|
|
|
|
if scanReport.BackupsChecked != 2 || scanReport.BackupsHealthy != 2 {
|
|
testInstance.Errorf("es sollen 2 Backups geprüft und einwandfrei sein, waren %d/%d",
|
|
scanReport.BackupsHealthy, scanReport.BackupsChecked)
|
|
}
|
|
}
|
|
|
|
func TestScanDetectsCorruptedChunk(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
manifest := writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Wird beschädigt")})
|
|
|
|
// Ein Chunk wird auf dem Datenträger verfälscht.
|
|
chunkIdentifier := manifest.Entries[0].Chunks[0].Identifier
|
|
chunkFilePath, _ := testRepository.chunkPath(chunkIdentifier)
|
|
_ = os.Chmod(chunkFilePath, 0o600)
|
|
_ = os.WriteFile(chunkFilePath, []byte("voellig andere daten"), 0o600)
|
|
|
|
scanReport, _ := testRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true})
|
|
|
|
if scanReport.IsHealthy() {
|
|
testInstance.Fatal("ein beschädigtes Repository darf nicht als einwandfrei gelten")
|
|
}
|
|
|
|
if scanReport.CorruptedChunks != 1 {
|
|
testInstance.Errorf("es soll 1 beschädigter Chunk gemeldet werden, waren %d", scanReport.CorruptedChunks)
|
|
}
|
|
|
|
// Das betroffene Backup muss namentlich benannt werden.
|
|
if len(scanReport.AffectedBackupIDs) != 1 || scanReport.AffectedBackupIDs[0] != "backup-1" {
|
|
testInstance.Errorf("das betroffene Backup fehlt im Bericht: %v", scanReport.AffectedBackupIDs)
|
|
}
|
|
|
|
// Die Zusammenfassung darf das Problem nicht beschönigen.
|
|
if !strings.Contains(scanReport.Summary(), "NICHT vollständig") {
|
|
testInstance.Errorf("die Zusammenfassung ist zu harmlos: %s", scanReport.Summary())
|
|
}
|
|
}
|
|
|
|
func TestScanDetectsMissingChunk(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
manifest := writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Wird geloescht")})
|
|
|
|
chunkIdentifier := manifest.Entries[0].Chunks[0].Identifier
|
|
chunkFilePath, _ := testRepository.chunkPath(chunkIdentifier)
|
|
if removeError := os.Remove(chunkFilePath); removeError != nil {
|
|
testInstance.Fatalf("der Chunk konnte nicht entfernt werden: %v", removeError)
|
|
}
|
|
|
|
scanReport, _ := testRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true})
|
|
|
|
if scanReport.MissingChunks != 1 {
|
|
testInstance.Errorf("es soll 1 fehlender Chunk gemeldet werden, waren %d", scanReport.MissingChunks)
|
|
}
|
|
|
|
if scanReport.IsHealthy() {
|
|
testInstance.Fatal("ein Backup mit fehlendem Chunk darf nicht als einwandfrei gelten")
|
|
}
|
|
}
|
|
|
|
func TestScanWithoutContentVerificationOnlyChecksPresence(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
manifest := writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Inhalt")})
|
|
|
|
// Der Inhalt wird verfälscht, die Datei bleibt aber vorhanden.
|
|
chunkIdentifier := manifest.Entries[0].Chunks[0].Identifier
|
|
chunkFilePath, _ := testRepository.chunkPath(chunkIdentifier)
|
|
_ = os.Chmod(chunkFilePath, 0o600)
|
|
_ = os.WriteFile(chunkFilePath, []byte("andere daten"), 0o600)
|
|
|
|
// Der schnelle Lauf prüft nur die Anwesenheit und findet die Beschädigung
|
|
// erwartungsgemäß nicht.
|
|
quickReport, _ := testRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: false})
|
|
if quickReport.CorruptedChunks != 0 {
|
|
testInstance.Error("der schnelle Lauf soll keine Inhaltsprüfung vornehmen")
|
|
}
|
|
|
|
// Der vollständige Lauf findet sie.
|
|
fullReport, _ := testRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true})
|
|
if fullReport.CorruptedChunks != 1 {
|
|
testInstance.Errorf("der vollständige Lauf soll die Beschädigung finden, waren %d", fullReport.CorruptedChunks)
|
|
}
|
|
}
|
|
|
|
func TestScanFindsOrphanedChunks(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Benutzt")})
|
|
|
|
// Ein Chunk ohne zugehöriges Backup, wie er nach einem Abbruch zurückbleibt.
|
|
_, _, _ = testRepository.writeChunk(context.Background(), []byte("Von niemandem benutzt"))
|
|
|
|
scanReport, _ := testRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true})
|
|
|
|
if scanReport.OrphanedChunks != 1 {
|
|
testInstance.Errorf("es soll 1 verwaister Chunk gemeldet werden, waren %d", scanReport.OrphanedChunks)
|
|
}
|
|
|
|
// Ein verwaister Chunk ist kein Datenverlust, das Repository bleibt gesund.
|
|
if !scanReport.IsHealthy() {
|
|
testInstance.Error("verwaiste Chunks dürfen das Repository nicht als beschädigt gelten lassen")
|
|
}
|
|
}
|
|
|
|
func TestScanReportIsPersistedInRepository(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")})
|
|
|
|
if _, scanError := testRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true}); scanError != nil {
|
|
testInstance.Fatalf("der Integritätslauf schlug fehl: %v", scanError)
|
|
}
|
|
|
|
// Der Bericht gehört ins Repository, damit nach einem Wiederaufbau
|
|
// erkennbar bleibt, wann zuletzt geprüft wurde.
|
|
verificationDirectory := filepath.Join(testRepository.RootPath(), directoryVerification)
|
|
directoryEntries, readError := os.ReadDir(verificationDirectory)
|
|
if readError != nil {
|
|
testInstance.Fatalf("das Prüfverzeichnis konnte nicht gelesen werden: %v", readError)
|
|
}
|
|
|
|
if len(directoryEntries) == 0 {
|
|
testInstance.Fatal("es wurde kein Prüfbericht abgelegt")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bereinigung
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestPruneRemovesOnlyOrphanedChunks(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
manifest := writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Benutzt")})
|
|
usedIdentifier := manifest.Entries[0].Chunks[0].Identifier
|
|
|
|
orphanedIdentifier, _, _ := testRepository.writeChunk(context.Background(), []byte("Verwaist"))
|
|
|
|
removedCount, _, pruneError := testRepository.PruneOrphanedChunks(context.Background(), false)
|
|
if pruneError != nil {
|
|
testInstance.Fatalf("die Bereinigung schlug fehl: %v", pruneError)
|
|
}
|
|
|
|
if removedCount != 1 {
|
|
testInstance.Errorf("es soll 1 Chunk entfernt werden, waren %d", removedCount)
|
|
}
|
|
|
|
// Der benutzte Chunk muss erhalten bleiben.
|
|
if _, readError := testRepository.ReadChunk(context.Background(), usedIdentifier); readError != nil {
|
|
testInstance.Fatalf("ein benutzter Chunk wurde entfernt: %v", readError)
|
|
}
|
|
|
|
// Der verwaiste ist weg.
|
|
if _, readError := testRepository.ReadChunk(context.Background(), orphanedIdentifier); !errors.Is(readError, ErrChunkNotFound) {
|
|
testInstance.Error("der verwaiste Chunk wurde nicht entfernt")
|
|
}
|
|
}
|
|
|
|
func TestPruneDryRunChangesNothing(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
orphanedIdentifier, _, _ := testRepository.writeChunk(context.Background(), []byte("Verwaist"))
|
|
|
|
removedCount, _, _ := testRepository.PruneOrphanedChunks(context.Background(), true)
|
|
if removedCount != 1 {
|
|
testInstance.Errorf("die Simulation soll 1 Chunk melden, waren %d", removedCount)
|
|
}
|
|
|
|
// In der Simulation darf nichts verschwinden.
|
|
if _, readError := testRepository.ReadChunk(context.Background(), orphanedIdentifier); readError != nil {
|
|
testInstance.Fatalf("die Simulation hat Daten entfernt: %v", readError)
|
|
}
|
|
}
|
|
|
|
func TestPruneRefusesWhenManifestIsUnreadable(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Daten")})
|
|
|
|
// Ein unlesbares Manifest bedeutet: die Menge der benötigten Chunks ist unbekannt.
|
|
manifestFilePath := testRepository.manifestPath("backup-1")
|
|
_ = os.Chmod(manifestFilePath, 0o600)
|
|
_ = os.WriteFile(manifestFilePath, []byte("kaputt"), 0o600)
|
|
|
|
// Eine Bereinigung auf unvollständiger Grundlage könnte Daten vernichten.
|
|
if _, _, pruneError := testRepository.PruneOrphanedChunks(context.Background(), false); pruneError == nil {
|
|
testInstance.Fatal("die Bereinigung muss bei einem unlesbaren Manifest abbrechen")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sperre und Neustart
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSecondWriterIsRejectedWhileRepositoryIsLocked(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
// Ein zweiter Schreiber würde Katalog und Manifeste durcheinanderbringen.
|
|
_, openError := Open(context.Background(), testRepository.RootPath(), OpenOptions{}, newTestLogger())
|
|
if !errors.Is(openError, ErrRepositoryLocked) {
|
|
testInstance.Fatalf("ein zweiter Schreiber muss abgelehnt werden, war: %v", openError)
|
|
}
|
|
}
|
|
|
|
func TestReadOnlyAccessIsPossibleWhileLocked(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Daten")})
|
|
|
|
// Eine Wiederherstellung muss auch während eines laufenden Backups möglich sein.
|
|
readerRepository, openError := Open(context.Background(), testRepository.RootPath(),
|
|
OpenOptions{ReadOnly: true}, newTestLogger())
|
|
if openError != nil {
|
|
testInstance.Fatalf("der lesende Zugriff wurde abgelehnt: %v", openError)
|
|
}
|
|
defer func() { _ = readerRepository.Close() }()
|
|
|
|
if _, readError := readerRepository.ReadManifest(context.Background(), "backup-1"); readError != nil {
|
|
testInstance.Fatalf("das Manifest konnte lesend nicht geöffnet werden: %v", readError)
|
|
}
|
|
}
|
|
|
|
func TestReadOnlyRepositoryRefusesToWrite(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
repositoryPath := testRepository.RootPath()
|
|
_ = testRepository.Close()
|
|
|
|
readerRepository, _ := Open(context.Background(), repositoryPath, OpenOptions{ReadOnly: true}, newTestLogger())
|
|
defer func() { _ = readerRepository.Close() }()
|
|
|
|
// Ohne Sperre darf nicht geschrieben werden.
|
|
if _, beginError := readerRepository.BeginBackup(context.Background(), "neu"); beginError == nil {
|
|
testInstance.Fatal("ein nur lesend geöffnetes Repository darf nicht beschrieben werden")
|
|
}
|
|
}
|
|
|
|
func TestRepositorySurvivesReopen(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
repositoryPath := testRepository.RootPath()
|
|
|
|
writeTestBackup(testInstance, testRepository, "vor-neustart", [][]byte{[]byte("Bestandsdaten")})
|
|
|
|
// Ein Neustart des Dienstes: schließen und erneut öffnen.
|
|
if closeError := testRepository.Close(); closeError != nil {
|
|
testInstance.Fatalf("das Repository konnte nicht geschlossen werden: %v", closeError)
|
|
}
|
|
|
|
reopenedRepository, openError := Open(context.Background(), repositoryPath, OpenOptions{}, newTestLogger())
|
|
if openError != nil {
|
|
testInstance.Fatalf("das Repository konnte nach dem Neustart nicht geöffnet werden: %v", openError)
|
|
}
|
|
defer func() { _ = reopenedRepository.Close() }()
|
|
|
|
if _, readError := reopenedRepository.ReadManifest(context.Background(), "vor-neustart"); readError != nil {
|
|
testInstance.Fatalf("das Backup überstand den Neustart nicht: %v", readError)
|
|
}
|
|
|
|
// Nach dem Neustart muss weitergeschrieben werden können.
|
|
writeTestBackup(testInstance, reopenedRepository, "nach-neustart", [][]byte{[]byte("Neue Daten")})
|
|
|
|
catalogEntries, _ := reopenedRepository.ListBackups(context.Background())
|
|
if len(catalogEntries) != 2 {
|
|
testInstance.Fatalf("es sollen 2 Backups vorliegen, waren %d", len(catalogEntries))
|
|
}
|
|
}
|
|
|
|
func TestBreakLockAllowsRecoveryAfterCrash(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
repositoryPath := testRepository.RootPath()
|
|
|
|
// Ein Absturz hinterlässt die Sperrdatei, ohne dass ein Prozess läuft.
|
|
// Der Prozess wird hier nicht sauber geschlossen, die Sperre bleibt liegen.
|
|
testRepository.lockHandle = nil
|
|
|
|
if _, openError := Open(context.Background(), repositoryPath, OpenOptions{}, newTestLogger()); !errors.Is(openError, ErrRepositoryLocked) {
|
|
testInstance.Fatalf("die zurückgebliebene Sperre soll den Zugriff verhindern, war: %v", openError)
|
|
}
|
|
|
|
// Erst der ausdrückliche Eingriff gibt das Repository frei.
|
|
if breakError := BreakLock(repositoryPath); breakError != nil {
|
|
testInstance.Fatalf("die Sperre konnte nicht gelöst werden: %v", breakError)
|
|
}
|
|
|
|
recoveredRepository, openError := Open(context.Background(), repositoryPath, OpenOptions{}, newTestLogger())
|
|
if openError != nil {
|
|
testInstance.Fatalf("das Repository konnte nach dem Lösen der Sperre nicht geöffnet werden: %v", openError)
|
|
}
|
|
|
|
_ = recoveredRepository.Close()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unterbrochener Schreibvorgang
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestInterruptedSessionLeavesNoVisibleBackup(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
repositoryPath := testRepository.RootPath()
|
|
|
|
// Eine Session wird geöffnet und Daten geschrieben, aber nie abgeschlossen.
|
|
backupWriter, _ := testRepository.BeginBackup(context.Background(), "abgestuerzt")
|
|
_, _, _ = backupWriter.WriteChunk(context.Background(), []byte("Daten vor dem Absturz"))
|
|
|
|
// Der Absturz: kein Commit, kein Abort, das Journal bleibt liegen.
|
|
testRepository.lockHandle = nil
|
|
_ = BreakLock(repositoryPath)
|
|
|
|
recoveredRepository, openError := Open(context.Background(), repositoryPath, OpenOptions{}, newTestLogger())
|
|
if openError != nil {
|
|
testInstance.Fatalf("das Repository konnte nicht geöffnet werden: %v", openError)
|
|
}
|
|
defer func() { _ = recoveredRepository.Close() }()
|
|
|
|
// Ein unvollständiges Backup darf nicht sichtbar sein.
|
|
if _, readError := recoveredRepository.ReadManifest(context.Background(), "abgestuerzt"); !errors.Is(readError, ErrBackupNotFound) {
|
|
testInstance.Fatalf("ein abgestürzter Lauf darf kein Backup hinterlassen, war: %v", readError)
|
|
}
|
|
|
|
// Der Abbruch muss aber auffindbar sein und darf nicht still verschwinden.
|
|
staleSessions, staleError := recoveredRepository.FindStaleSessions(context.Background())
|
|
if staleError != nil {
|
|
testInstance.Fatalf("die offenen Sessions konnten nicht ermittelt werden: %v", staleError)
|
|
}
|
|
|
|
if len(staleSessions) != 1 {
|
|
testInstance.Fatalf("es soll 1 zurückgebliebene Session gefunden werden, waren %d", len(staleSessions))
|
|
}
|
|
|
|
if staleSessions[0].BackupID != "abgestuerzt" || staleSessions[0].HasManifest {
|
|
testInstance.Errorf("die zurückgebliebene Session wurde falsch beschrieben: %+v", staleSessions[0])
|
|
}
|
|
|
|
// Nach dem Aufräumen ist sie verschwunden.
|
|
if cleanupError := recoveredRepository.CleanupStaleSession(context.Background(), staleSessions[0].SessionID); cleanupError != nil {
|
|
testInstance.Fatalf("die Session konnte nicht aufgeräumt werden: %v", cleanupError)
|
|
}
|
|
|
|
remainingSessions, _ := recoveredRepository.FindStaleSessions(context.Background())
|
|
if len(remainingSessions) != 0 {
|
|
testInstance.Errorf("nach dem Aufräumen soll keine Session zurückbleiben, waren %d", len(remainingSessions))
|
|
}
|
|
}
|
|
|
|
func TestChunksOfInterruptedSessionAreReused(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
sharedContent := []byte("Dieser Block wurde vor dem Absturz geschrieben.")
|
|
|
|
// Ein abgebrochener Lauf hinterlässt seine Chunks.
|
|
firstWriter, _ := testRepository.BeginBackup(context.Background(), "erster-versuch")
|
|
_, _, _ = firstWriter.WriteChunk(context.Background(), sharedContent)
|
|
_ = firstWriter.Abort(context.Background())
|
|
|
|
// Der zweite Anlauf findet sie vor und muss sie nicht erneut übertragen.
|
|
secondWriter, _ := testRepository.BeginBackup(context.Background(), "zweiter-versuch")
|
|
_, wasNew, _ := secondWriter.WriteChunk(context.Background(), sharedContent)
|
|
|
|
if wasNew {
|
|
testInstance.Error("bereits übertragene Blöcke sollen nach einem Abbruch wiederverwendet werden")
|
|
}
|
|
|
|
_ = secondWriter.Abort(context.Background())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Aufbewahrungsschutz
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestHardenedRepositoryProtectsBackupFromDeletion(testInstance *testing.T) {
|
|
hardenedRepository, createError := Create(context.Background(), testInstance.TempDir(), CreateOptions{
|
|
Name: "Gehärtetes Repository",
|
|
Kind: KindHardenedLinux,
|
|
CreatedByVersion: "test",
|
|
}, newTestLogger())
|
|
if createError != nil {
|
|
testInstance.Fatalf("das Repository konnte nicht angelegt werden: %v", createError)
|
|
}
|
|
defer func() { _ = hardenedRepository.Close() }()
|
|
|
|
// Der Löschschutz greift bis in das Aufräumen des Tests hinein: Ohne diese
|
|
// Freigabe scheitert t.TempDir am geschützten Manifest.
|
|
testInstance.Cleanup(func() { releaseAllImmutableFlagsForTest(testInstance, hardenedRepository.RootPath()) })
|
|
|
|
// Ein gehärtetes Repository ist immer unveränderlich.
|
|
if !hardenedRepository.Descriptor().Immutable {
|
|
testInstance.Fatal("ein gehärtetes Repository muss unveränderlich sein")
|
|
}
|
|
|
|
writeTestBackup(testInstance, hardenedRepository, "geschuetzt", [][]byte{[]byte("Wichtige Daten")})
|
|
|
|
manifest, _ := hardenedRepository.ReadManifest(context.Background(), "geschuetzt")
|
|
if manifest.ImmutableUntil == nil {
|
|
testInstance.Fatal("dem Backup fehlt die Aufbewahrungsfrist")
|
|
}
|
|
|
|
// Das Löschen muss scheitern, solange die Frist läuft.
|
|
deleteError := hardenedRepository.DeleteBackup(context.Background(), "geschuetzt")
|
|
if !errors.Is(deleteError, ErrRetentionLocked) {
|
|
testInstance.Fatalf("ein geschütztes Backup darf nicht gelöscht werden, war: %v", deleteError)
|
|
}
|
|
|
|
// Das Backup ist danach unverändert vorhanden.
|
|
if _, readError := hardenedRepository.ReadManifest(context.Background(), "geschuetzt"); readError != nil {
|
|
testInstance.Fatalf("das geschützte Backup wurde beschädigt: %v", readError)
|
|
}
|
|
}
|
|
|
|
func TestDeleteRemovesBackupAfterRetentionExpired(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, testRepository, "abgelaufen", [][]byte{[]byte("Alte Daten")})
|
|
|
|
// Ein gewöhnliches Repository setzt keine Frist; das Löschen ist erlaubt.
|
|
if deleteError := testRepository.DeleteBackup(context.Background(), "abgelaufen"); deleteError != nil {
|
|
testInstance.Fatalf("das Backup konnte nicht gelöscht werden: %v", deleteError)
|
|
}
|
|
|
|
if _, readError := testRepository.ReadManifest(context.Background(), "abgelaufen"); !errors.Is(readError, ErrBackupNotFound) {
|
|
testInstance.Fatalf("das Backup ist noch vorhanden, war: %v", readError)
|
|
}
|
|
|
|
// Der Katalog folgt der Löschung.
|
|
catalogEntries, _ := testRepository.ListBackups(context.Background())
|
|
if len(catalogEntries) != 0 {
|
|
testInstance.Errorf("der Katalog soll leer sein, enthielt %d Einträge", len(catalogEntries))
|
|
}
|
|
}
|
|
|
|
func TestRetentionLockExpires(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
backupWriter, _ := testRepository.BeginBackup(context.Background(), "kurze-frist")
|
|
_, _, _ = backupWriter.WriteChunk(context.Background(), []byte("Daten"))
|
|
|
|
// Eine bereits abgelaufene Frist.
|
|
expiredRetention := time.Now().Add(-time.Hour)
|
|
_ = backupWriter.Commit(context.Background(), &Manifest{
|
|
Source: SourceInformation{SourceType: "filesystem"},
|
|
ImmutableUntil: &expiredRetention,
|
|
})
|
|
|
|
// Nach Ablauf der Frist darf gelöscht werden.
|
|
if deleteError := testRepository.DeleteBackup(context.Background(), "kurze-frist"); deleteError != nil {
|
|
testInstance.Fatalf("nach Ablauf der Frist muss das Löschen möglich sein: %v", deleteError)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Zustand
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestHealthReportsUsableRepository(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Daten")})
|
|
|
|
healthReport, healthError := testRepository.Health(context.Background())
|
|
if healthError != nil {
|
|
testInstance.Fatalf("der Zustand konnte nicht ermittelt werden: %v", healthError)
|
|
}
|
|
|
|
// Der Status haengt vom tatsaechlichen Fuellstand des Testrechners ab.
|
|
// Entscheidend ist hier, dass das Repository erreichbar und beschreibbar
|
|
// ist; die Schwellenbewertung wird gesondert geprueft.
|
|
if healthReport.Status == HealthStatusCritical && healthReport.CapacityBytes == 0 {
|
|
testInstance.Errorf("das Repository soll erreichbar sein, war %q: %s",
|
|
healthReport.Status, healthReport.Message)
|
|
}
|
|
|
|
if healthReport.BackupCount != 1 {
|
|
testInstance.Errorf("es soll 1 Backup gezählt werden, waren %d", healthReport.BackupCount)
|
|
}
|
|
|
|
// Ohne Kapazitätsangabe wäre die Kapazitätswarnung wirkungslos.
|
|
if healthReport.CapacityBytes <= 0 {
|
|
testInstance.Error("die Kapazität konnte nicht ermittelt werden")
|
|
}
|
|
}
|
|
|
|
func TestHealthDetectsUnreachableRepository(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
// Der Descriptor verschwindet, etwa weil der Datenträger nicht mehr eingebunden ist.
|
|
if removeError := os.Remove(testRepository.descriptorPath()); removeError != nil {
|
|
testInstance.Fatalf("der Descriptor konnte nicht entfernt werden: %v", removeError)
|
|
}
|
|
|
|
healthReport, _ := testRepository.Health(context.Background())
|
|
|
|
if healthReport.Status != HealthStatusCritical {
|
|
testInstance.Fatalf("ein unerreichbares Repository muss als kritisch gelten, war %q", healthReport.Status)
|
|
}
|
|
|
|
// Der Anwender braucht eine Handlungsempfehlung, keinen Fehlercode.
|
|
if healthReport.RecommendedAction == "" {
|
|
testInstance.Error("es fehlt eine Handlungsempfehlung")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Backup-Ketten
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestValidateChainDetectsBrokenChain(testInstance *testing.T) {
|
|
testRepository := newTestRepository(testInstance)
|
|
|
|
// Eine Kette aus Vollsicherung und zwei Zusatzsicherungen.
|
|
writeChainBackup(testInstance, testRepository, "voll", "kette-1", "", BackupTypeFull)
|
|
writeChainBackup(testInstance, testRepository, "inkrement-1", "kette-1", "voll", BackupTypeIncremental)
|
|
writeChainBackup(testInstance, testRepository, "inkrement-2", "kette-1", "inkrement-1", BackupTypeIncremental)
|
|
|
|
catalog, _ := testRepository.Catalog(context.Background())
|
|
|
|
validationResult := catalog.ValidateChain("kette-1")
|
|
if !validationResult.IsValid {
|
|
testInstance.Fatalf("die vollständige Kette soll gültig sein: %s", validationResult.Message)
|
|
}
|
|
|
|
// Das mittlere Glied fällt aus.
|
|
if deleteError := testRepository.DeleteBackup(context.Background(), "inkrement-1"); deleteError != nil {
|
|
testInstance.Fatalf("das Backup konnte nicht gelöscht werden: %v", deleteError)
|
|
}
|
|
|
|
brokenCatalog, _ := testRepository.Catalog(context.Background())
|
|
brokenResult := brokenCatalog.ValidateChain("kette-1")
|
|
|
|
// Eine unterbrochene Kette darf niemals als in Ordnung erscheinen.
|
|
if brokenResult.IsValid {
|
|
testInstance.Fatal("die unterbrochene Kette wurde als gültig gemeldet")
|
|
}
|
|
|
|
if len(brokenResult.AffectedBackupIDs) == 0 {
|
|
testInstance.Error("die betroffenen Wiederherstellungspunkte wurden nicht benannt")
|
|
}
|
|
}
|
|
|
|
// writeChainBackup schreibt ein Backup als Teil einer Kette.
|
|
func writeChainBackup(testInstance *testing.T, testRepository *LocalRepository, backupID string, chainID string, parentID string, backupType BackupType) {
|
|
testInstance.Helper()
|
|
|
|
backupWriter, beginError := testRepository.BeginBackup(context.Background(), backupID)
|
|
if beginError != nil {
|
|
testInstance.Fatalf("die Schreibsession konnte nicht geöffnet werden: %v", beginError)
|
|
}
|
|
|
|
chunkReference, _, _ := backupWriter.WriteChunk(context.Background(), []byte("Daten von "+backupID))
|
|
|
|
commitError := backupWriter.Commit(context.Background(), &Manifest{
|
|
ChainID: chainID,
|
|
ParentBackupID: parentID,
|
|
BackupType: backupType,
|
|
Source: SourceInformation{SourceType: "filesystem", SourceID: "quelle-1"},
|
|
Entries: []ManifestEntry{{
|
|
Path: "/daten.bin",
|
|
EntryType: "file",
|
|
Chunks: []ChunkReference{chunkReference},
|
|
}},
|
|
})
|
|
|
|
if commitError != nil {
|
|
testInstance.Fatalf("das Backup konnte nicht abgeschlossen werden: %v", commitError)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateCapacityThresholds(testInstance *testing.T) {
|
|
capacityCases := []struct {
|
|
usedPercentage float64
|
|
expectedStatus HealthStatus
|
|
}{
|
|
{0, HealthStatusHealthy},
|
|
{50, HealthStatusHealthy},
|
|
{79.9, HealthStatusHealthy},
|
|
{80, HealthStatusWarning},
|
|
{85, HealthStatusWarning},
|
|
{89.9, HealthStatusWarning},
|
|
{90, HealthStatusCritical},
|
|
{99.9, HealthStatusCritical},
|
|
}
|
|
|
|
for _, capacityCase := range capacityCases {
|
|
actualStatus, statusMessage, recommendedAction := evaluateCapacity(capacityCase.usedPercentage)
|
|
|
|
if actualStatus != capacityCase.expectedStatus {
|
|
testInstance.Errorf("bei %.1f %% Belegung soll der Status %q sein, war %q",
|
|
capacityCase.usedPercentage, capacityCase.expectedStatus, actualStatus)
|
|
}
|
|
|
|
// Ein Problem ohne Handlungsempfehlung laesst den Anwender ratlos zurueck.
|
|
if actualStatus != HealthStatusHealthy && recommendedAction == "" {
|
|
testInstance.Errorf("bei %.1f %% Belegung fehlt die Handlungsempfehlung", capacityCase.usedPercentage)
|
|
}
|
|
|
|
if statusMessage == "" {
|
|
testInstance.Errorf("bei %.1f %% Belegung fehlt die Meldung", capacityCase.usedPercentage)
|
|
}
|
|
}
|
|
}
|