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>
457 lines
16 KiB
Go
457 lines
16 KiB
Go
package agent
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
)
|
|
|
|
// buildTestTree legt einen Verzeichnisbaum für Tests an.
|
|
func buildTestTree(testInstance *testing.T, fileContents map[string]string) string {
|
|
testInstance.Helper()
|
|
|
|
rootPath := testInstance.TempDir()
|
|
|
|
for relativePath, fileContent := range fileContents {
|
|
fullPath := filepath.Join(rootPath, relativePath)
|
|
|
|
if directoryError := os.MkdirAll(filepath.Dir(fullPath), 0o755); directoryError != nil {
|
|
testInstance.Fatalf("das Verzeichnis konnte nicht angelegt werden: %v", directoryError)
|
|
}
|
|
|
|
if writeError := os.WriteFile(fullPath, []byte(fileContent), 0o644); writeError != nil {
|
|
testInstance.Fatalf("die Datei konnte nicht angelegt werden: %v", writeError)
|
|
}
|
|
}
|
|
|
|
return rootPath
|
|
}
|
|
|
|
// findEntry sucht einen Eintrag anhand seines relativen Pfads.
|
|
func findEntry(discoveryResult *DiscoveryResult, relativePath string) (DiscoveredEntry, bool) {
|
|
for _, discoveredEntry := range discoveryResult.Entries {
|
|
if discoveredEntry.RelativePath == relativePath {
|
|
return discoveredEntry, true
|
|
}
|
|
}
|
|
|
|
return DiscoveredEntry{}, false
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Erfassung
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestDiscoverFindsAllFiles(testInstance *testing.T) {
|
|
rootPath := buildTestTree(testInstance, map[string]string{
|
|
"datei-a.txt": "Inhalt A",
|
|
"unterordner/datei-b.txt": "Inhalt B",
|
|
"unterordner/tief/c.txt": "Inhalt C",
|
|
})
|
|
|
|
discoveryResult, discoveryError := Discover(rootPath, DiscoveryOptions{})
|
|
if discoveryError != nil {
|
|
testInstance.Fatalf("die Erfassung schlug fehl: %v", discoveryError)
|
|
}
|
|
|
|
if discoveryResult.FileCount() != 3 {
|
|
testInstance.Fatalf("es sollen 3 Dateien gefunden werden, waren %d", discoveryResult.FileCount())
|
|
}
|
|
|
|
// Die Pfade werden mit Schrägstrich abgelegt, damit ein unter Windows
|
|
// erstelltes Backup auch unter Linux nutzbar bleibt.
|
|
if _, wasFound := findEntry(discoveryResult, "unterordner/tief/c.txt"); !wasFound {
|
|
testInstance.Errorf("der erwartete Pfad fehlt: %+v", discoveryResult.Entries)
|
|
}
|
|
|
|
expectedBytes := int64(len("Inhalt A") + len("Inhalt B") + len("Inhalt C"))
|
|
if discoveryResult.TotalBytes != expectedBytes {
|
|
testInstance.Errorf("die Gesamtgröße soll %d sein, war %d", expectedBytes, discoveryResult.TotalBytes)
|
|
}
|
|
}
|
|
|
|
func TestDiscoverSortsEntriesForReproducibility(testInstance *testing.T) {
|
|
rootPath := buildTestTree(testInstance, map[string]string{
|
|
"zebra.txt": "z", "alpha.txt": "a", "mitte.txt": "m",
|
|
})
|
|
|
|
// Zwei Läufe über denselben Bestand müssen dieselbe Reihenfolge ergeben,
|
|
// sonst wären die entstehenden Backups nicht vergleichbar.
|
|
firstResult, _ := Discover(rootPath, DiscoveryOptions{})
|
|
secondResult, _ := Discover(rootPath, DiscoveryOptions{})
|
|
|
|
if fmt.Sprint(firstResult.Entries) != fmt.Sprint(secondResult.Entries) {
|
|
testInstance.Fatal("zwei Läufe liefern unterschiedliche Reihenfolgen")
|
|
}
|
|
|
|
if firstResult.Entries[0].RelativePath != "alpha.txt" {
|
|
testInstance.Errorf("die Einträge sind nicht sortiert: %s", firstResult.Entries[0].RelativePath)
|
|
}
|
|
}
|
|
|
|
func TestDiscoverIncludesDirectoriesOnRequest(testInstance *testing.T) {
|
|
rootPath := buildTestTree(testInstance, map[string]string{
|
|
"ordner/datei.txt": "Inhalt",
|
|
})
|
|
|
|
withoutDirectories, _ := Discover(rootPath, DiscoveryOptions{})
|
|
for _, discoveredEntry := range withoutDirectories.Entries {
|
|
if discoveredEntry.EntryType == EntryTypeDirectory {
|
|
testInstance.Error("ohne Anforderung sollen keine Verzeichnisse erfasst werden")
|
|
}
|
|
}
|
|
|
|
// Verzeichnisse tragen keine Daten, aber ihre Rechte - nötig für eine
|
|
// vollständige Wiederherstellung.
|
|
withDirectories, _ := Discover(rootPath, DiscoveryOptions{IncludeDirectories: true})
|
|
|
|
directoryEntry, wasFound := findEntry(withDirectories, "ordner")
|
|
if !wasFound {
|
|
testInstance.Fatal("das Verzeichnis wurde nicht erfasst")
|
|
}
|
|
|
|
if directoryEntry.Mode == "" {
|
|
testInstance.Error("dem Verzeichnis fehlen die Rechte")
|
|
}
|
|
}
|
|
|
|
func TestDiscoverHandlesSingleFileAsRoot(testInstance *testing.T) {
|
|
rootPath := buildTestTree(testInstance, map[string]string{"einzeln.txt": "Inhalt"})
|
|
filePath := filepath.Join(rootPath, "einzeln.txt")
|
|
|
|
discoveryResult, discoveryError := Discover(filePath, DiscoveryOptions{})
|
|
if discoveryError != nil {
|
|
testInstance.Fatalf("die Erfassung einer einzelnen Datei schlug fehl: %v", discoveryError)
|
|
}
|
|
|
|
if discoveryResult.FileCount() != 1 {
|
|
testInstance.Fatalf("es soll 1 Datei erfasst werden, waren %d", discoveryResult.FileCount())
|
|
}
|
|
}
|
|
|
|
func TestDiscoverReportsMissingRoot(testInstance *testing.T) {
|
|
_, discoveryError := Discover(filepath.Join(testInstance.TempDir(), "gibt-es-nicht"), DiscoveryOptions{})
|
|
|
|
if !errors.Is(discoveryError, ErrRootNotFound) {
|
|
testInstance.Fatalf("ein fehlendes Verzeichnis muss gemeldet werden, war: %v", discoveryError)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ein- und Ausschlussregeln
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestExcludePatternsSkipMatchingFiles(testInstance *testing.T) {
|
|
rootPath := buildTestTree(testInstance, map[string]string{
|
|
"wichtig.txt": "behalten",
|
|
"zwischen.tmp": "verwerfen",
|
|
"logs/heute.log": "verwerfen",
|
|
"logs/gestern.log": "verwerfen",
|
|
})
|
|
|
|
discoveryResult, _ := Discover(rootPath, DiscoveryOptions{
|
|
ExcludePatterns: []string{"*.tmp", "logs"},
|
|
})
|
|
|
|
if discoveryResult.FileCount() != 1 {
|
|
testInstance.Fatalf("es soll 1 Datei übrig bleiben, waren %d: %+v",
|
|
discoveryResult.FileCount(), discoveryResult.Entries)
|
|
}
|
|
|
|
// Ein Muster ohne Platzhalter wirkt auf den gesamten Teilbaum.
|
|
if _, wasFound := findEntry(discoveryResult, "logs/heute.log"); wasFound {
|
|
testInstance.Error("das ausgeschlossene Verzeichnis wurde dennoch erfasst")
|
|
}
|
|
|
|
if discoveryResult.SkippedByPattern == 0 {
|
|
testInstance.Error("die Zahl der übergangenen Objekte wurde nicht vermerkt")
|
|
}
|
|
}
|
|
|
|
func TestIncludePatternsRestrictSelection(testInstance *testing.T) {
|
|
rootPath := buildTestTree(testInstance, map[string]string{
|
|
"bericht.pdf": "pdf",
|
|
"tabelle.xlsx": "xlsx",
|
|
"unterordner/x.pdf": "pdf",
|
|
"notiz.txt": "txt",
|
|
})
|
|
|
|
discoveryResult, _ := Discover(rootPath, DiscoveryOptions{
|
|
IncludePatterns: []string{"*.pdf"},
|
|
})
|
|
|
|
if discoveryResult.FileCount() != 2 {
|
|
testInstance.Fatalf("es sollen 2 PDF-Dateien erfasst werden, waren %d: %+v",
|
|
discoveryResult.FileCount(), discoveryResult.Entries)
|
|
}
|
|
|
|
// Ein Verzeichnis darf nicht wegen der Einschlussregeln übergangen werden -
|
|
// darunter liegen passende Dateien.
|
|
if _, wasFound := findEntry(discoveryResult, "unterordner/x.pdf"); !wasFound {
|
|
testInstance.Error("die passende Datei im Unterordner wurde nicht erfasst")
|
|
}
|
|
}
|
|
|
|
func TestExcludeTakesPrecedenceOverInclude(testInstance *testing.T) {
|
|
rootPath := buildTestTree(testInstance, map[string]string{
|
|
"bericht.pdf": "behalten",
|
|
"entwurf.pdf": "verwerfen",
|
|
})
|
|
|
|
discoveryResult, _ := Discover(rootPath, DiscoveryOptions{
|
|
IncludePatterns: []string{"*.pdf"},
|
|
ExcludePatterns: []string{"entwurf.pdf"},
|
|
})
|
|
|
|
// Wer etwas ausdrücklich ausnimmt, meint es.
|
|
if discoveryResult.FileCount() != 1 {
|
|
testInstance.Fatalf("der Ausschluss soll vorgehen, es blieben %d Dateien", discoveryResult.FileCount())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rechtefehler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestPermissionErrorsAreCollectedNotFatal(testInstance *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
testInstance.Skip("Dateirechte verhalten sich unter Windows anders")
|
|
}
|
|
|
|
if os.Geteuid() == 0 {
|
|
// Als Administrator greifen die Rechte nicht; der Test wäre wertlos.
|
|
testInstance.Skip("der Test setzt ein unprivilegiertes Konto voraus")
|
|
}
|
|
|
|
rootPath := buildTestTree(testInstance, map[string]string{
|
|
"lesbar.txt": "kann gelesen werden",
|
|
"gesperrt/geheim.txt": "kein Zugriff",
|
|
})
|
|
|
|
gesperrtesVerzeichnis := filepath.Join(rootPath, "gesperrt")
|
|
if chmodError := os.Chmod(gesperrtesVerzeichnis, 0o000); chmodError != nil {
|
|
testInstance.Fatalf("die Rechte konnten nicht gesetzt werden: %v", chmodError)
|
|
}
|
|
|
|
// Die Rechte werden zurückgesetzt, damit das Testverzeichnis aufräumbar bleibt.
|
|
testInstance.Cleanup(func() { _ = os.Chmod(gesperrtesVerzeichnis, 0o755) })
|
|
|
|
discoveryResult, discoveryError := Discover(rootPath, DiscoveryOptions{})
|
|
|
|
// Eine gesperrte Datei darf nicht den ganzen Lauf verhindern.
|
|
if discoveryError != nil {
|
|
testInstance.Fatalf("ein Rechtefehler darf die Erfassung nicht abbrechen: %v", discoveryError)
|
|
}
|
|
|
|
if discoveryResult.FileCount() < 1 {
|
|
testInstance.Error("die lesbare Datei wurde nicht erfasst")
|
|
}
|
|
|
|
// Verschwiegen werden darf das Problem aber niemals.
|
|
if !discoveryResult.HasProblems() {
|
|
testInstance.Fatal("der Rechtefehler wurde nicht vermerkt")
|
|
}
|
|
|
|
if discoveryResult.PermissionProblemCount() == 0 {
|
|
testInstance.Error("der Fehler wurde nicht als Rechtefehler erkannt")
|
|
}
|
|
|
|
// Die Meldung soll verständlich sein, keine Systemmeldung.
|
|
firstProblem := discoveryResult.Problems[0]
|
|
if firstProblem.Reason == "" {
|
|
testInstance.Error("dem Problem fehlt eine Erklärung")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sonderfälle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSymlinksAreRecordedNotFollowed(testInstance *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
testInstance.Skip("symbolische Verweise verlangen unter Windows besondere Rechte")
|
|
}
|
|
|
|
rootPath := buildTestTree(testInstance, map[string]string{"ziel.txt": "Inhalt"})
|
|
|
|
verweisPfad := filepath.Join(rootPath, "verweis.txt")
|
|
if linkError := os.Symlink(filepath.Join(rootPath, "ziel.txt"), verweisPfad); linkError != nil {
|
|
testInstance.Fatalf("der Verweis konnte nicht angelegt werden: %v", linkError)
|
|
}
|
|
|
|
discoveryResult, _ := Discover(rootPath, DiscoveryOptions{})
|
|
|
|
verweisEintrag, wasFound := findEntry(discoveryResult, "verweis.txt")
|
|
if !wasFound {
|
|
testInstance.Fatal("der symbolische Verweis wurde nicht erfasst")
|
|
}
|
|
|
|
if verweisEintrag.EntryType != EntryTypeSymlink {
|
|
testInstance.Errorf("der Verweis soll als solcher erfasst sein, war %q", verweisEintrag.EntryType)
|
|
}
|
|
|
|
// Das Ziel muss festgehalten werden, sonst liesse sich der Verweis nicht
|
|
// wiederherstellen.
|
|
if verweisEintrag.LinkTarget == "" {
|
|
testInstance.Error("dem Verweis fehlt sein Ziel")
|
|
}
|
|
}
|
|
|
|
func TestSymlinkLoopDoesNotHang(testInstance *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
testInstance.Skip("symbolische Verweise verlangen unter Windows besondere Rechte")
|
|
}
|
|
|
|
rootPath := testInstance.TempDir()
|
|
|
|
unterordner := filepath.Join(rootPath, "unterordner")
|
|
if directoryError := os.MkdirAll(unterordner, 0o755); directoryError != nil {
|
|
testInstance.Fatalf("das Verzeichnis konnte nicht angelegt werden: %v", directoryError)
|
|
}
|
|
|
|
// Ein Verweis auf das übergeordnete Verzeichnis erzeugte bei Verfolgung
|
|
// eine endlose Schleife.
|
|
if linkError := os.Symlink(rootPath, filepath.Join(unterordner, "zurueck")); linkError != nil {
|
|
testInstance.Fatalf("der Verweis konnte nicht angelegt werden: %v", linkError)
|
|
}
|
|
|
|
// Der Lauf muss beenden; ohne Zeitbegrenzung liefe der Test sonst ewig.
|
|
discoveryDone := make(chan struct{})
|
|
go func() {
|
|
defer close(discoveryDone)
|
|
_, _ = Discover(rootPath, DiscoveryOptions{})
|
|
}()
|
|
|
|
select {
|
|
case <-discoveryDone:
|
|
case <-timeoutAfterSeconds(10):
|
|
testInstance.Fatal("die Erfassung ist in einer Verweisschleife hängen geblieben")
|
|
}
|
|
}
|
|
|
|
func TestLargeFileIsRecordedWithCorrectSize(testInstance *testing.T) {
|
|
rootPath := testInstance.TempDir()
|
|
|
|
// Eine Datei jenseits gängiger Puffergrößen.
|
|
const largeFileSize = 40 * 1024 * 1024
|
|
largeFilePath := filepath.Join(rootPath, "gross.bin")
|
|
|
|
largeFile, createError := os.Create(largeFilePath)
|
|
if createError != nil {
|
|
testInstance.Fatalf("die Datei konnte nicht angelegt werden: %v", createError)
|
|
}
|
|
|
|
if truncateError := largeFile.Truncate(largeFileSize); truncateError != nil {
|
|
testInstance.Fatalf("die Datei konnte nicht vergrößert werden: %v", truncateError)
|
|
}
|
|
_ = largeFile.Close()
|
|
|
|
discoveryResult, discoveryError := Discover(rootPath, DiscoveryOptions{})
|
|
if discoveryError != nil {
|
|
testInstance.Fatalf("die Erfassung schlug fehl: %v", discoveryError)
|
|
}
|
|
|
|
largeEntry, wasFound := findEntry(discoveryResult, "gross.bin")
|
|
if !wasFound {
|
|
testInstance.Fatal("die große Datei wurde nicht erfasst")
|
|
}
|
|
|
|
if largeEntry.SizeBytes != largeFileSize {
|
|
testInstance.Errorf("die Größe soll %d sein, war %d", largeFileSize, largeEntry.SizeBytes)
|
|
}
|
|
}
|
|
|
|
func TestFileExceedingMaximumSizeIsReportedNotSilentlySkipped(testInstance *testing.T) {
|
|
rootPath := buildTestTree(testInstance, map[string]string{
|
|
"klein.txt": "ok",
|
|
"gross.txt": "diese Zeile ist deutlich länger als die Grenze",
|
|
})
|
|
|
|
discoveryResult, _ := Discover(rootPath, DiscoveryOptions{MaximumFileSize: 10})
|
|
|
|
if discoveryResult.FileCount() != 1 {
|
|
testInstance.Errorf("es soll 1 Datei übrig bleiben, waren %d", discoveryResult.FileCount())
|
|
}
|
|
|
|
// Eine übergangene Datei muss vermerkt werden, sonst hielte der Anwender
|
|
// das Backup für vollständig.
|
|
if !discoveryResult.HasProblems() {
|
|
testInstance.Fatal("die übergroße Datei wurde stillschweigend übergangen")
|
|
}
|
|
}
|
|
|
|
func TestManySmallFiles(testInstance *testing.T) {
|
|
rootPath := testInstance.TempDir()
|
|
|
|
// Viele kleine Dateien sind ein eigener Belastungsfall: der Aufwand liegt
|
|
// im Verzeichniszugriff, nicht in der Datenmenge.
|
|
const fileCount = 5000
|
|
|
|
for fileIndex := 0; fileIndex < fileCount; fileIndex++ {
|
|
// Eine Aufteilung auf Unterverzeichnisse entspricht der Praxis.
|
|
subDirectory := filepath.Join(rootPath, fmt.Sprintf("ordner-%02d", fileIndex%50))
|
|
if directoryError := os.MkdirAll(subDirectory, 0o755); directoryError != nil {
|
|
testInstance.Fatalf("das Verzeichnis konnte nicht angelegt werden: %v", directoryError)
|
|
}
|
|
|
|
filePath := filepath.Join(subDirectory, fmt.Sprintf("datei-%05d.txt", fileIndex))
|
|
if writeError := os.WriteFile(filePath, []byte("x"), 0o644); writeError != nil {
|
|
testInstance.Fatalf("die Datei konnte nicht angelegt werden: %v", writeError)
|
|
}
|
|
}
|
|
|
|
discoveryResult, discoveryError := Discover(rootPath, DiscoveryOptions{})
|
|
if discoveryError != nil {
|
|
testInstance.Fatalf("die Erfassung schlug fehl: %v", discoveryError)
|
|
}
|
|
|
|
if discoveryResult.FileCount() != fileCount {
|
|
testInstance.Fatalf("es sollen %d Dateien erfasst werden, waren %d", fileCount, discoveryResult.FileCount())
|
|
}
|
|
|
|
if discoveryResult.HasProblems() {
|
|
testInstance.Errorf("es traten unerwartete Probleme auf: %+v", discoveryResult.Problems[0])
|
|
}
|
|
}
|
|
|
|
func TestEmptyDirectoryYieldsNoEntries(testInstance *testing.T) {
|
|
discoveryResult, discoveryError := Discover(testInstance.TempDir(), DiscoveryOptions{})
|
|
if discoveryError != nil {
|
|
testInstance.Fatalf("die Erfassung schlug fehl: %v", discoveryError)
|
|
}
|
|
|
|
// Ein leeres Verzeichnis ist kein Fehler, liefert aber auch nichts.
|
|
if len(discoveryResult.Entries) != 0 {
|
|
testInstance.Errorf("es sollen keine Einträge entstehen, waren %d", len(discoveryResult.Entries))
|
|
}
|
|
}
|
|
|
|
func TestMatchesPatternBehaviour(testInstance *testing.T) {
|
|
patternCases := []struct {
|
|
pathValue string
|
|
patternValue string
|
|
shouldMatch bool
|
|
caseDescription string
|
|
}{
|
|
{"datei.tmp", "*.tmp", true, "Endung"},
|
|
{"ordner/datei.tmp", "*.tmp", true, "Endung im Unterordner"},
|
|
{"logs/app.log", "logs", true, "Verzeichnis als Präfix"},
|
|
{"logs", "logs", true, "Verzeichnis selbst"},
|
|
{"logsammlung/x.txt", "logs", false, "ähnlicher Name ohne Trennzeichen"},
|
|
{"datei.txt", "*.tmp", false, "andere Endung"},
|
|
}
|
|
|
|
for _, patternCase := range patternCases {
|
|
actualMatch := matchesPattern(patternCase.pathValue, patternCase.patternValue)
|
|
|
|
if actualMatch != patternCase.shouldMatch {
|
|
testInstance.Errorf("%s: %q gegen %q soll %v ergeben, war %v",
|
|
patternCase.caseDescription, patternCase.pathValue, patternCase.patternValue,
|
|
patternCase.shouldMatch, actualMatch)
|
|
}
|
|
}
|
|
}
|