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>
256 lines
9.3 KiB
Go
256 lines
9.3 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/syncova/syncova/packages/repository"
|
|
)
|
|
|
|
// TestInterruptedBackupLeavesNoVisibleBackup prüft den Abbruch eines Laufs.
|
|
//
|
|
// Das ist der Kern des Commit-Protokolls: Ein unterbrochener Lauf darf keine
|
|
// Spur hinterlassen, die sich als gültiges Backup ausgibt. Übrig bleiben dürfen
|
|
// Chunks — sie kosten Platz, aber ein späterer Lauf verwendet sie wieder. Was
|
|
// niemals übrig bleiben darf, ist ein Manifest.
|
|
func TestInterruptedBackupLeavesNoVisibleBackup(testInstance *testing.T) {
|
|
backupRunner, _ := newRoundTripEnvironment(testInstance)
|
|
sourcePath := buildRealisticTree(testInstance)
|
|
|
|
// Der Abbruch erfolgt sofort. Damit trifft er den Lauf mit Sicherheit vor
|
|
// dem Setzen des Abschlussvermerks.
|
|
backupContext, cancelBackup := context.WithCancel(context.Background())
|
|
cancelBackup()
|
|
|
|
_, backupError := backupRunner.RunBackup(backupContext, BackupRunOptions{
|
|
BackupID: "abgebrochen",
|
|
SourcePath: sourcePath,
|
|
})
|
|
|
|
if backupError == nil {
|
|
testInstance.Fatal("ein abgebrochener Lauf muss einen Fehler melden")
|
|
}
|
|
|
|
if !errors.Is(backupError, context.Canceled) {
|
|
testInstance.Errorf("der Abbruch wurde nicht als solcher gemeldet: %v", backupError)
|
|
}
|
|
|
|
sourceRepository := backupRunner.engine.Repository()
|
|
|
|
// Das entscheidende Kriterium: kein lesbares Manifest.
|
|
if _, readError := sourceRepository.ReadManifest(context.Background(), "abgebrochen"); readError == nil {
|
|
testInstance.Fatal("der abgebrochene Lauf hinterliess ein sichtbares Manifest")
|
|
}
|
|
|
|
catalogEntries, listError := sourceRepository.ListBackups(context.Background())
|
|
if listError != nil {
|
|
testInstance.Fatalf("der Katalog konnte nicht gelesen werden: %v", listError)
|
|
}
|
|
|
|
for _, catalogEntry := range catalogEntries {
|
|
if catalogEntry.BackupID == "abgebrochen" {
|
|
testInstance.Fatal("der abgebrochene Lauf erscheint im Katalog")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBackupAfterInterruptionSucceeds prüft, dass ein Abbruch das Repository
|
|
// nicht dauerhaft blockiert.
|
|
//
|
|
// Die Schreibsperre eines abgebrochenen Laufs muss fallen. Bliebe sie liegen,
|
|
// wäre nach jedem Stromausfall ein Eingriff von Hand nötig — und das Backup
|
|
// fiele genau dann aus, wenn man es am nötigsten hätte.
|
|
func TestBackupAfterInterruptionSucceeds(testInstance *testing.T) {
|
|
backupRunner, _ := newRoundTripEnvironment(testInstance)
|
|
sourcePath := buildRealisticTree(testInstance)
|
|
|
|
backupContext, cancelBackup := context.WithCancel(context.Background())
|
|
cancelBackup()
|
|
|
|
if _, backupError := backupRunner.RunBackup(backupContext, BackupRunOptions{
|
|
BackupID: "abgebrochen",
|
|
SourcePath: sourcePath,
|
|
}); backupError == nil {
|
|
testInstance.Fatal("ein abgebrochener Lauf muss einen Fehler melden")
|
|
}
|
|
|
|
// Derselbe Bestand, neuer Lauf, unbeschädigtes Verhalten.
|
|
secondResult, secondError := backupRunner.RunBackup(context.Background(), BackupRunOptions{
|
|
BackupID: "nach-abbruch",
|
|
SourcePath: sourcePath,
|
|
})
|
|
if secondError != nil {
|
|
testInstance.Fatalf("nach einem Abbruch schlug der nächste Lauf fehl: %v", secondError)
|
|
}
|
|
|
|
if secondResult.IsPartialFailure() {
|
|
testInstance.Errorf("der Lauf nach dem Abbruch war ein Teilfehler: %v", secondResult.Problems)
|
|
}
|
|
}
|
|
|
|
// TestIncrementalBackupRefusesMissingParentChunk prüft die Existenzprüfung
|
|
// übernommener Blöcke.
|
|
//
|
|
// Ein Manifest, das auf fehlende Blöcke verweist, sähe wie ein vollständiges
|
|
// Backup aus. Der Fehler fiele erst bei der Wiederherstellung auf — also genau
|
|
// dann, wenn es zu spät ist.
|
|
func TestIncrementalBackupRefusesMissingParentChunk(testInstance *testing.T) {
|
|
backupRunner, _ := newRoundTripEnvironment(testInstance)
|
|
sourcePath := buildRealisticTree(testInstance)
|
|
|
|
backupContext := context.Background()
|
|
|
|
if _, fullError := backupRunner.RunBackup(backupContext, BackupRunOptions{
|
|
BackupID: "voll-1",
|
|
SourcePath: sourcePath,
|
|
}); fullError != nil {
|
|
testInstance.Fatalf("die Vollsicherung schlug fehl: %v", fullError)
|
|
}
|
|
|
|
sourceRepository := backupRunner.engine.Repository()
|
|
|
|
parentManifest, readError := sourceRepository.ReadManifest(backupContext, "voll-1")
|
|
if readError != nil {
|
|
testInstance.Fatalf("das Manifest konnte nicht gelesen werden: %v", readError)
|
|
}
|
|
|
|
// Ein Block wird aus der Ablage entfernt — der Fall eines beschädigten oder
|
|
// versehentlich aufgeräumten Repositorys.
|
|
removedIdentifier := findFirstChunkIdentifier(testInstance, parentManifest)
|
|
removeChunkFromDisk(testInstance, sourceRepository.RootPath(), removedIdentifier)
|
|
|
|
_, incrementalError := backupRunner.RunBackup(backupContext, BackupRunOptions{
|
|
BackupID: "zusatz-1",
|
|
SourcePath: sourcePath,
|
|
Incremental: true,
|
|
})
|
|
|
|
if incrementalError == nil {
|
|
testInstance.Fatal("eine Zusatzsicherung mit fehlendem Elternblock muss fehlschlagen")
|
|
}
|
|
|
|
if !strings.Contains(incrementalError.Error(), "block") {
|
|
testInstance.Errorf("die Fehlermeldung benennt die Ursache nicht: %v", incrementalError)
|
|
}
|
|
|
|
if _, manifestError := sourceRepository.ReadManifest(backupContext, "zusatz-1"); manifestError == nil {
|
|
testInstance.Fatal("der fehlgeschlagene Lauf hinterliess ein sichtbares Manifest")
|
|
}
|
|
}
|
|
|
|
// findFirstChunkIdentifier liefert die Kennung eines beliebigen Blocks.
|
|
func findFirstChunkIdentifier(testInstance *testing.T, sourceManifest *repository.Manifest) string {
|
|
testInstance.Helper()
|
|
|
|
for chunkIdentifier := range sourceManifest.UniqueChunkIdentifiers() {
|
|
return chunkIdentifier
|
|
}
|
|
|
|
testInstance.Fatal("das Manifest enthält keine Blöcke")
|
|
|
|
return ""
|
|
}
|
|
|
|
// removeChunkFromDisk löscht einen Block aus der Ablage.
|
|
func removeChunkFromDisk(testInstance *testing.T, repositoryRoot string, chunkIdentifier string) {
|
|
testInstance.Helper()
|
|
|
|
// Die Ablage staffelt nach den ersten Zeichen der Kennung, damit ein
|
|
// Verzeichnis nicht mit Millionen Einträgen überläuft.
|
|
chunkPath := filepath.Join(repositoryRoot, "chunks",
|
|
chunkIdentifier[0:2], chunkIdentifier[2:4], chunkIdentifier)
|
|
|
|
if removeError := os.Remove(chunkPath); removeError != nil {
|
|
testInstance.Fatalf("der Block %s konnte nicht entfernt werden: %v", chunkIdentifier, removeError)
|
|
}
|
|
}
|
|
|
|
// TestDiscoveryDoesNotBlockOnNamedPipe prüft den Umgang mit Sonderdateien.
|
|
//
|
|
// Eine benannte Pipe zu öffnen blockiert, bis jemand hineinschreibt. Ein Backup,
|
|
// das das täte, bliebe für immer stehen — ohne Fehlermeldung, ohne Fortschritt.
|
|
// Deshalb wird sie erfasst, gemeldet und nicht geöffnet.
|
|
func TestDiscoveryDoesNotBlockOnNamedPipe(testInstance *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
testInstance.Skip("benannte Pipes verhalten sich unter Windows grundlegend anders")
|
|
}
|
|
|
|
sourcePath := testInstance.TempDir()
|
|
|
|
if writeError := os.WriteFile(filepath.Join(sourcePath, "gewoehnlich.txt"),
|
|
[]byte("Eine ganz normale Datei."), 0o644); writeError != nil {
|
|
testInstance.Fatalf("die Datei konnte nicht angelegt werden: %v", writeError)
|
|
}
|
|
|
|
pipePath := filepath.Join(sourcePath, "warteschlange.fifo")
|
|
if pipeError := syscall.Mkfifo(pipePath, 0o644); pipeError != nil {
|
|
testInstance.Skipf("eine benannte Pipe liess sich nicht anlegen: %v", pipeError)
|
|
}
|
|
|
|
backupRunner, _ := newRoundTripEnvironment(testInstance)
|
|
|
|
// Die Zeitgrenze ist der eigentliche Nachweis: ohne sie liefe der Test bei
|
|
// einem Rückschritt endlos, statt zu scheitern.
|
|
backupContext, cancelBackup := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancelBackup()
|
|
|
|
backupResult, backupError := backupRunner.RunBackup(backupContext, BackupRunOptions{
|
|
BackupID: "mit-pipe",
|
|
SourcePath: sourcePath,
|
|
})
|
|
if backupError != nil {
|
|
testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError)
|
|
}
|
|
|
|
if backupResult.FilesBackedUp != 1 {
|
|
testInstance.Errorf("es sollte genau eine gewöhnliche Datei gesichert werden, es waren %d",
|
|
backupResult.FilesBackedUp)
|
|
}
|
|
|
|
// Die uebergangene Pipe muss **ausgewiesen** sein — aber sie macht den Lauf
|
|
// **nicht** zum Teilfehler.
|
|
//
|
|
// Diese Unterscheidung wurde in Phase 6 nachgezogen. Zuvor galt jedes
|
|
// Erfassungsproblem als uebergangenes Objekt, und damit war jede Sicherung
|
|
// eines Linux-Systems ein Teilfehler: In /var/run und /tmp liegen staendig
|
|
// Sockets. Nach einer Woche klickt niemand mehr einen Teilfehler an — und
|
|
// dann faellt auch der echte nicht mehr auf (dieselbe Ueberlegung wie beim
|
|
// Meldungswesen, Phase 14).
|
|
//
|
|
// Eine benannte Pipe *fehlt* nicht im Backup, sie *gehoert* nicht hinein:
|
|
// Sie hat keinen Inhalt, den man sichern und zurueckschreiben koennte.
|
|
if backupResult.IsPartialFailure() {
|
|
testInstance.Error("eine Sonderdatei ohne sicherbaren Inhalt macht den Lauf zum Teilfehler")
|
|
}
|
|
|
|
if backupResult.SkippedObjectCount() != 1 {
|
|
testInstance.Errorf("es wurden %d Objekte ohne sicherbaren Inhalt gezaehlt, erwartet wurde eines",
|
|
backupResult.SkippedObjectCount())
|
|
}
|
|
|
|
// Verschwiegen werden darf sie trotzdem nicht: Wer einen Socket im
|
|
// Quellverzeichnis hat, soll erfahren, dass er nicht im Backup ist.
|
|
if !strings.Contains(backupResult.Summary(), "ohne sicherbaren Inhalt") {
|
|
testInstance.Errorf("die Zusammenfassung verschweigt das uebergangene Objekt: %q",
|
|
backupResult.Summary())
|
|
}
|
|
|
|
var pipeReported bool
|
|
for _, discoveryProblem := range backupResult.Problems {
|
|
if strings.HasSuffix(discoveryProblem.Path, "warteschlange.fifo") {
|
|
pipeReported = true
|
|
}
|
|
}
|
|
|
|
if !pipeReported {
|
|
testInstance.Errorf("die übergangene Pipe wurde nicht benannt: %v", backupResult.Problems)
|
|
}
|
|
}
|