syncova-backup/packages/agent/interruption_test.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

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)
}
}