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>
362 lines
13 KiB
Go
362 lines
13 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// frozenRepositoryPath ist das eingefrorene Beispiel-Repository.
|
|
//
|
|
// Es liegt als echtes Verzeichnis im Quellbestand und wurde mit
|
|
// Repository-Formatversion 1 angelegt. Der Unterschied zu einem gewöhnlichen
|
|
// Rundlauftest ist derselbe wie beim Containerformat: Der schreibt und liest
|
|
// mit demselben Code und bliebe grün, wenn sich beide Seiten gemeinsam ändern.
|
|
const frozenRepositoryPath = "testdata/frozen-repository"
|
|
|
|
// TestRepositoryLayoutIsFrozen haelt das Repository-Protokoll fest (Phase 22).
|
|
//
|
|
// Die Verzeichnis- und Dateinamen sind kein Umsetzungsdetail, sondern der
|
|
// Vertrag: Ein Repository muss ohne Control Server und ohne Datenbank deutbar
|
|
// bleiben (`Attach → Discover Format → Scan Manifests → Rebuild Catalog →
|
|
// Restore`). Wer hier umbenennt, macht bestehende Repositories unauffindbar —
|
|
// die Daten liegen noch da und niemand findet sie mehr.
|
|
func TestRepositoryLayoutIsFrozen(testInstance *testing.T) {
|
|
frozenNames := map[string]string{
|
|
"directoryFormat": directoryFormat,
|
|
"directoryManifests": directoryManifests,
|
|
"directoryChunks": directoryChunks,
|
|
"directoryIndexes": directoryIndexes,
|
|
"directoryJournals": directoryJournals,
|
|
"directoryVerification": directoryVerification,
|
|
"directoryMetadata": directoryMetadata,
|
|
"directoryStaging": directoryStaging,
|
|
"fileDescriptor": fileDescriptor,
|
|
"fileCatalog": fileCatalog,
|
|
"fileLock": fileLock,
|
|
"manifestExtension": manifestExtension,
|
|
"journalExtension": journalExtension,
|
|
"retentionHoldExtension": retentionHoldExtension,
|
|
}
|
|
|
|
expectedNames := map[string]string{
|
|
"directoryFormat": "format",
|
|
"directoryManifests": "manifests",
|
|
"directoryChunks": "chunks",
|
|
"directoryIndexes": "indexes",
|
|
"directoryJournals": "journals",
|
|
"directoryVerification": "verification",
|
|
"directoryMetadata": "metadata",
|
|
"directoryStaging": "staging",
|
|
"fileDescriptor": "repository.json",
|
|
"fileCatalog": "catalog.json",
|
|
"fileLock": "repository.lock",
|
|
"manifestExtension": ".manifest.json",
|
|
"journalExtension": ".journal.json",
|
|
"retentionHoldExtension": ".hold.json",
|
|
}
|
|
|
|
for constantName, expectedValue := range expectedNames {
|
|
if frozenNames[constantName] != expectedValue {
|
|
testInstance.Errorf("%s heißt %q statt %q.\n"+
|
|
" Bestehende Repositories werden damit unauffindbar — die Daten liegen noch da.",
|
|
constantName, frozenNames[constantName], expectedValue)
|
|
}
|
|
}
|
|
|
|
if ManifestVersion != 1 {
|
|
testInstance.Errorf("die Manifestversion ist %d statt 1", ManifestVersion)
|
|
}
|
|
}
|
|
|
|
// TestFrozenRepositoryStaysReadable oeffnet das eingefrorene Repository.
|
|
//
|
|
// Der Test geht den Weg, den ein Notfall geht: anhaengen, Format erkennen,
|
|
// Manifeste lesen, Katalog **ohne Datenbank** neu aufbauen, Blöcke pruefen. Ist
|
|
// hier etwas kaputt, ist ein bestehendes Backup verloren — auch wenn jeder
|
|
// andere Test gruen bleibt.
|
|
func TestFrozenRepositoryStaysReadable(testInstance *testing.T) {
|
|
repositoryCopy := copyFrozenRepository(testInstance)
|
|
|
|
openedRepository, openError := Open(context.Background(), repositoryCopy, OpenOptions{}, newTestLogger())
|
|
if openError != nil {
|
|
testInstance.Fatalf("das eingefrorene Repository ließ sich nicht öffnen: %v\n"+
|
|
" Erzeugen mit: SYNCOVA_WRITE_FIXTURE=ja go test ./packages/repository "+
|
|
"-run TestWriteFrozenRepositoryFixture", openError)
|
|
}
|
|
|
|
defer func() { _ = openedRepository.Close() }()
|
|
|
|
repositoryDescriptor := openedRepository.Descriptor()
|
|
|
|
if repositoryDescriptor.FormatVersion != 1 {
|
|
testInstance.Errorf("die Formatversion des Repositorys ist %d statt 1",
|
|
repositoryDescriptor.FormatVersion)
|
|
}
|
|
|
|
if repositoryDescriptor.HashAlgorithm != HashAlgorithmSHA256 {
|
|
testInstance.Errorf("das Hashverfahren ist %q — die Chunk-Kennungen wären damit andere",
|
|
repositoryDescriptor.HashAlgorithm)
|
|
}
|
|
|
|
catalogEntries, listError := openedRepository.ListBackups(context.Background())
|
|
if listError != nil {
|
|
testInstance.Fatalf("die Backups ließen sich nicht auflisten: %v", listError)
|
|
}
|
|
|
|
if len(catalogEntries) != 1 {
|
|
testInstance.Fatalf("es liegen %d Backups im eingefrorenen Repository statt eines", len(catalogEntries))
|
|
}
|
|
|
|
storedManifest, manifestError := openedRepository.ReadManifest(context.Background(),
|
|
catalogEntries[0].BackupID)
|
|
if manifestError != nil {
|
|
testInstance.Fatalf("das Manifest ließ sich nicht lesen: %v", manifestError)
|
|
}
|
|
|
|
if storedManifest.ManifestVersion != 1 {
|
|
testInstance.Errorf("die Manifestversion ist %d", storedManifest.ManifestVersion)
|
|
}
|
|
|
|
if len(storedManifest.Entries) == 0 {
|
|
testInstance.Fatal("das eingefrorene Manifest enthält keine Objekte")
|
|
}
|
|
|
|
// Jeder Block muss noch da sein und seiner Kennung entsprechen. Ein Manifest
|
|
// allein belegt nur, dass jemand einmal etwas gesichert hat.
|
|
for _, chunkReference := range storedManifest.UniqueChunkReferences() {
|
|
storedChunk, chunkError := openedRepository.ReadStoredChunk(context.Background(),
|
|
chunkReference.Identifier, chunkReference.StoredDigest)
|
|
if chunkError != nil {
|
|
testInstance.Errorf("der Block %s ließ sich nicht lesen: %v", chunkReference.Identifier, chunkError)
|
|
|
|
continue
|
|
}
|
|
|
|
if len(storedChunk) == 0 {
|
|
testInstance.Errorf("der Block %s ist leer", chunkReference.Identifier)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFrozenRepositoryRebuildsWithoutCatalog prueft die Kernzusage.
|
|
//
|
|
// „Bliebe das Repository nutzbar, wenn Control Server und Datenbank ersatzlos
|
|
// verschwinden?" — hier wird zusaetzlich der Katalog entfernt, also der letzte
|
|
// Beschleuniger. Was bleibt, sind die Manifeste, und aus ihnen allein muss sich
|
|
// alles wieder aufbauen lassen.
|
|
func TestFrozenRepositoryRebuildsWithoutCatalog(testInstance *testing.T) {
|
|
repositoryCopy := copyFrozenRepository(testInstance)
|
|
|
|
// Das ganze Verzeichnis entfernen, nicht nur die Datei: „Katalog verloren"
|
|
// heißt nicht immer „Datei gelöscht" (der Fund aus Phase 18).
|
|
if removeError := os.RemoveAll(filepath.Join(repositoryCopy, directoryIndexes)); removeError != nil {
|
|
testInstance.Fatalf("der Katalog ließ sich nicht entfernen: %v", removeError)
|
|
}
|
|
|
|
openedRepository, openError := Open(context.Background(), repositoryCopy, OpenOptions{}, newTestLogger())
|
|
if openError != nil {
|
|
testInstance.Fatalf("das Repository ließ sich ohne Katalog nicht öffnen: %v", openError)
|
|
}
|
|
|
|
defer func() { _ = openedRepository.Close() }()
|
|
|
|
rebuiltCatalog, rebuildError := openedRepository.RebuildCatalog(context.Background())
|
|
if rebuildError != nil {
|
|
testInstance.Fatalf("der Katalog ließ sich nicht neu aufbauen: %v", rebuildError)
|
|
}
|
|
|
|
if len(rebuiltCatalog.Entries) != 1 {
|
|
testInstance.Fatalf("der neu gebaute Katalog enthält %d Backups statt eines",
|
|
len(rebuiltCatalog.Entries))
|
|
}
|
|
}
|
|
|
|
// TestFrozenRepositoryPassesDeepScan prueft jeden Block gegen seine Pruefsumme.
|
|
func TestFrozenRepositoryPassesDeepScan(testInstance *testing.T) {
|
|
repositoryCopy := copyFrozenRepository(testInstance)
|
|
|
|
openedRepository, openError := Open(context.Background(), repositoryCopy, OpenOptions{}, newTestLogger())
|
|
if openError != nil {
|
|
testInstance.Fatalf("das eingefrorene Repository ließ sich nicht öffnen: %v", openError)
|
|
}
|
|
|
|
defer func() { _ = openedRepository.Close() }()
|
|
|
|
scanReport, scanError := openedRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true})
|
|
if scanError != nil {
|
|
testInstance.Fatalf("der Integritätslauf schlug fehl: %v", scanError)
|
|
}
|
|
|
|
if !scanReport.IsHealthy() {
|
|
testInstance.Errorf("der Integritätslauf meldet Befunde: %s", scanReport.Summary())
|
|
}
|
|
|
|
if scanReport.ChunksChecked != 2 {
|
|
testInstance.Errorf("es wurden %d Blöcke geprüft statt zwei", scanReport.ChunksChecked)
|
|
}
|
|
}
|
|
|
|
// copyFrozenRepository legt eine Arbeitskopie an.
|
|
//
|
|
// Das eingefrorene Repository selbst wird nie geöffnet: Ein Öffnen legt eine
|
|
// Sperrdatei an und schreibt womöglich den Katalog fort — der Vertrag änderte
|
|
// sich dann durch seine eigene Prüfung.
|
|
func copyFrozenRepository(testInstance *testing.T) string {
|
|
testInstance.Helper()
|
|
|
|
if _, statError := os.Stat(frozenRepositoryPath); statError != nil {
|
|
testInstance.Skipf("das eingefrorene Repository fehlt: %v", statError)
|
|
}
|
|
|
|
targetRoot := filepath.Join(testInstance.TempDir(), "frozen")
|
|
|
|
copyError := filepath.WalkDir(frozenRepositoryPath, func(currentPath string, directoryEntry os.DirEntry, walkError error) error {
|
|
if walkError != nil {
|
|
return walkError
|
|
}
|
|
|
|
relativePath, relativeError := filepath.Rel(frozenRepositoryPath, currentPath)
|
|
if relativeError != nil {
|
|
return relativeError
|
|
}
|
|
|
|
targetPath := filepath.Join(targetRoot, relativePath)
|
|
|
|
if directoryEntry.IsDir() {
|
|
return os.MkdirAll(targetPath, 0o700)
|
|
}
|
|
|
|
fileContent, readError := os.ReadFile(currentPath)
|
|
if readError != nil {
|
|
return readError
|
|
}
|
|
|
|
return os.WriteFile(targetPath, fileContent, 0o600)
|
|
})
|
|
if copyError != nil {
|
|
testInstance.Fatalf("die Arbeitskopie ließ sich nicht anlegen: %v", copyError)
|
|
}
|
|
|
|
// Leere Verzeichnisse überleben kein Git. Sie werden hier ergänzt, weil ein
|
|
// Repository sie erwartet — das ist kein Formatverstoß, sondern eine
|
|
// Eigenheit der Ablage im Quellbestand.
|
|
for _, requiredDirectory := range allRepositoryDirectories {
|
|
if makeError := os.MkdirAll(filepath.Join(targetRoot, requiredDirectory), 0o700); makeError != nil {
|
|
testInstance.Fatalf("das Verzeichnis %s ließ sich nicht anlegen: %v", requiredDirectory, makeError)
|
|
}
|
|
}
|
|
|
|
return targetRoot
|
|
}
|
|
|
|
// TestWriteFrozenRepositoryFixture erzeugt das eingefrorene Repository neu.
|
|
//
|
|
// Nur mit ausdruecklichem Schalter — es versehentlich mitlaufen zu lassen hiesse,
|
|
// den Vertrag jedes Mal auf den aktuellen Stand zu heben und damit genau die
|
|
// Pruefung abzuschalten, um die es geht.
|
|
func TestWriteFrozenRepositoryFixture(testInstance *testing.T) {
|
|
if os.Getenv("SYNCOVA_WRITE_FIXTURE") != "ja" {
|
|
testInstance.Skip("nur mit SYNCOVA_WRITE_FIXTURE=ja; sonst würde der eingefrorene Vertrag überschrieben")
|
|
}
|
|
|
|
if removeError := os.RemoveAll(frozenRepositoryPath); removeError != nil {
|
|
testInstance.Fatalf("das alte Fixture ließ sich nicht entfernen: %v", removeError)
|
|
}
|
|
|
|
createdRepository, createError := Create(context.Background(), frozenRepositoryPath, CreateOptions{
|
|
Name: "Eingefrorenes Beispiel (Phase 22)",
|
|
CreatedByVersion: "0.1.0-dev",
|
|
}, newTestLogger())
|
|
if createError != nil {
|
|
testInstance.Fatalf("das Repository ließ sich nicht anlegen: %v", createError)
|
|
}
|
|
|
|
writeSession, beginError := createdRepository.BeginBackup(context.Background(), "eingefroren-phase-22")
|
|
if beginError != nil {
|
|
testInstance.Fatalf("die Schreibsession ließ sich nicht öffnen: %v", beginError)
|
|
}
|
|
|
|
firstChunk := []byte("erster eingefrorener block der phase 22")
|
|
secondChunk := []byte("zweiter eingefrorener block der phase 22")
|
|
|
|
firstReference, _, firstError := writeSession.WriteChunk(context.Background(), firstChunk)
|
|
if firstError != nil {
|
|
testInstance.Fatalf("der erste Block ließ sich nicht schreiben: %v", firstError)
|
|
}
|
|
|
|
secondReference, _, secondError := writeSession.WriteChunk(context.Background(), secondChunk)
|
|
if secondError != nil {
|
|
testInstance.Fatalf("der zweite Block ließ sich nicht schreiben: %v", secondError)
|
|
}
|
|
|
|
backupManifest := Manifest{
|
|
BackupID: "eingefroren-phase-22",
|
|
ChainID: "kette-phase-22",
|
|
BackupType: BackupTypeFull,
|
|
ConsistencyLevel: ConsistencyCrash,
|
|
Source: SourceInformation{
|
|
SourceType: "filesystem",
|
|
SourceID: "/daten",
|
|
SourceName: "Eingefrorene Quelle",
|
|
},
|
|
Entries: []ManifestEntry{
|
|
{
|
|
Path: "daten/erste-datei.txt",
|
|
EntryType: "file",
|
|
SizeBytes: int64(len(firstChunk)),
|
|
Mode: "0644",
|
|
Chunks: []ChunkReference{firstReference},
|
|
},
|
|
{
|
|
Path: "daten/zweite-datei.txt",
|
|
EntryType: "file",
|
|
SizeBytes: int64(len(secondChunk)),
|
|
Mode: "0644",
|
|
Chunks: []ChunkReference{secondReference},
|
|
},
|
|
},
|
|
CreatedByVersion: "0.1.0-dev",
|
|
}
|
|
|
|
if commitError := writeSession.Commit(context.Background(), &backupManifest); commitError != nil {
|
|
testInstance.Fatalf("der Commit schlug fehl: %v", commitError)
|
|
}
|
|
|
|
if closeError := createdRepository.Close(); closeError != nil {
|
|
testInstance.Fatalf("das Repository ließ sich nicht schließen: %v", closeError)
|
|
}
|
|
|
|
// Die Sperrdatei gehört nicht in den Quellbestand: Sie beschreibt einen
|
|
// laufenden Zugriff, nicht das Format.
|
|
_ = os.Remove(filepath.Join(frozenRepositoryPath, fileLock))
|
|
|
|
writtenFiles := listFixtureFiles(testInstance, frozenRepositoryPath)
|
|
testInstance.Logf("eingefrorenes Repository geschrieben:\n %s", strings.Join(writtenFiles, "\n "))
|
|
}
|
|
|
|
// listFixtureFiles zaehlt die geschriebenen Dateien auf.
|
|
func listFixtureFiles(testInstance *testing.T, rootPath string) []string {
|
|
testInstance.Helper()
|
|
|
|
collectedFiles := make([]string, 0, 16)
|
|
|
|
_ = filepath.WalkDir(rootPath, func(currentPath string, directoryEntry os.DirEntry, walkError error) error {
|
|
if walkError != nil || directoryEntry.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
relativePath, _ := filepath.Rel(rootPath, currentPath)
|
|
collectedFiles = append(collectedFiles, relativePath)
|
|
|
|
return nil
|
|
})
|
|
|
|
sort.Strings(collectedFiles)
|
|
|
|
return collectedFiles
|
|
}
|