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>
335 lines
12 KiB
Go
335 lines
12 KiB
Go
package backupengine
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/syncova/syncova/packages/platform/ratelimit"
|
|
"github.com/syncova/syncova/packages/repository"
|
|
)
|
|
|
|
// newBandwidthTestEngine baut eine Engine auf einem frischen Repository.
|
|
func newBandwidthTestEngine(testInstance *testing.T) *Engine {
|
|
testInstance.Helper()
|
|
|
|
testLogger := slog.New(slog.NewJSONHandler(io.Discard, nil))
|
|
|
|
testRepository, createError := repository.Create(context.Background(), testInstance.TempDir(),
|
|
repository.CreateOptions{Name: "Bandbreite", CreatedByVersion: "test"}, testLogger)
|
|
if createError != nil {
|
|
testInstance.Fatalf("das Repository ließ sich nicht anlegen: %v", createError)
|
|
}
|
|
|
|
testInstance.Cleanup(func() { _ = testRepository.Close() })
|
|
|
|
return NewEngine(testRepository, nil, testLogger)
|
|
}
|
|
|
|
// buildIncompressiblePayload erzeugt kaum komprimierbare Testdaten.
|
|
//
|
|
// Komprimierbare Daten wären für eine Durchsatzmessung wertlos: Die Grenze
|
|
// wirkt auf das Lesen, die Kompression schrumpft danach — und das Ergebnis
|
|
// hinge an der Art der Testdaten statt an der Grenze.
|
|
func buildIncompressiblePayload(byteCount int) []byte {
|
|
generatedData := make([]byte, byteCount)
|
|
|
|
var generatorState uint64 = 0x9E3779B97F4A7C15
|
|
for byteIndex := range generatedData {
|
|
generatorState ^= generatorState << 13
|
|
generatorState ^= generatorState >> 7
|
|
generatorState ^= generatorState << 17
|
|
generatedData[byteIndex] = byte(generatorState)
|
|
}
|
|
|
|
return generatedData
|
|
}
|
|
|
|
// TestBandwidthLimitSlowsDownBackup ist der Wirksamkeitsnachweis.
|
|
//
|
|
// Ohne diesen Test bliebe offen, ob die Grenze überhaupt im Datenpfad liegt —
|
|
// eine Einstellung, die gespeichert und geprüft wird, aber nichts bewirkt, ist
|
|
// die unangenehmste Art von Fake-Feature: Sie sieht aus wie Schutz.
|
|
func TestBandwidthLimitSlowsDownBackup(testInstance *testing.T) {
|
|
if testing.Short() {
|
|
testInstance.Skip("die Durchsatzmessung braucht echte Zeit")
|
|
}
|
|
|
|
// 2 MiB bei 1 MiB/s müssen mindestens etwa zwei Sekunden dauern.
|
|
const payloadBytes = 2 << 20
|
|
const bytesPerSecond int64 = 1 << 20
|
|
|
|
sourceData := buildIncompressiblePayload(payloadBytes)
|
|
|
|
limitedEngine := newBandwidthTestEngine(testInstance)
|
|
|
|
bandwidthLimiter, limiterError := ratelimit.NewLimiter(bytesPerSecond)
|
|
if limiterError != nil {
|
|
testInstance.Fatalf("der Begrenzer ließ sich nicht bauen: %v", limiterError)
|
|
}
|
|
|
|
startTime := time.Now()
|
|
|
|
backupResult, backupError := limitedEngine.Backup(context.Background(), BackupOptions{
|
|
BackupID: "begrenzt",
|
|
BackupType: repository.BackupTypeFull,
|
|
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "test"},
|
|
CompressionLevel: CompressionOff,
|
|
BandwidthLimiter: bandwidthLimiter,
|
|
}, []BackupSource{
|
|
{Path: "daten.bin", EntryType: "file", Reader: bytes.NewReader(sourceData)},
|
|
})
|
|
if backupError != nil {
|
|
testInstance.Fatalf("die begrenzte Sicherung schlug fehl: %v", backupError)
|
|
}
|
|
|
|
limitedDuration := time.Since(startTime)
|
|
|
|
if backupResult.Progress.BytesProcessed != payloadBytes {
|
|
testInstance.Errorf("es wurden %d Byte verarbeitet, erwartet waren %d",
|
|
backupResult.Progress.BytesProcessed, payloadBytes)
|
|
}
|
|
|
|
// Der Eimer ist anfangs voll (eine Sekunde Vorrat), also kostet nur die
|
|
// zweite Hälfte Wartezeit. Erwartet wird deshalb mindestens eine Sekunde.
|
|
const minimumExpectedDuration = time.Second
|
|
|
|
if limitedDuration < minimumExpectedDuration {
|
|
testInstance.Fatalf("die Sicherung von %d Byte bei %d Byte/s dauerte nur %s; die Grenze liegt nicht im Datenpfad",
|
|
payloadBytes, bytesPerSecond, limitedDuration)
|
|
}
|
|
|
|
measuredThroughput := float64(payloadBytes) / limitedDuration.Seconds()
|
|
|
|
// Der gemessene Durchsatz darf die Grenze nicht deutlich überschreiten.
|
|
// Der Eimer erlaubt kurzfristig mehr; das Doppelte ist die Obergrenze
|
|
// dessen, was durch den anfänglichen Vorrat erklärbar ist.
|
|
if measuredThroughput > float64(bytesPerSecond)*2 {
|
|
testInstance.Errorf("gemessen wurden %.0f Byte/s bei einer Grenze von %d Byte/s",
|
|
measuredThroughput, bytesPerSecond)
|
|
}
|
|
|
|
testInstance.Logf("begrenzt: %d Byte in %s (%.2f MiB/s bei einer Grenze von %.0f MiB/s)",
|
|
payloadBytes, limitedDuration.Round(time.Millisecond),
|
|
measuredThroughput/(1<<20), float64(bytesPerSecond)/(1<<20))
|
|
}
|
|
|
|
// TestBackupWithoutLimitIsFast prüft den Vergleichsfall.
|
|
//
|
|
// Ohne ihn belegte der vorige Test nur, dass die Sicherung langsam ist — nicht,
|
|
// dass die Grenze der Grund dafür war.
|
|
func TestBackupWithoutLimitIsFast(testInstance *testing.T) {
|
|
if testing.Short() {
|
|
testInstance.Skip("die Durchsatzmessung braucht echte Zeit")
|
|
}
|
|
|
|
const payloadBytes = 2 << 20
|
|
|
|
sourceData := buildIncompressiblePayload(payloadBytes)
|
|
|
|
unlimitedEngine := newBandwidthTestEngine(testInstance)
|
|
|
|
startTime := time.Now()
|
|
|
|
if _, backupError := unlimitedEngine.Backup(context.Background(), BackupOptions{
|
|
BackupID: "unbegrenzt",
|
|
BackupType: repository.BackupTypeFull,
|
|
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "test"},
|
|
CompressionLevel: CompressionOff,
|
|
}, []BackupSource{
|
|
{Path: "daten.bin", EntryType: "file", Reader: bytes.NewReader(sourceData)},
|
|
}); backupError != nil {
|
|
testInstance.Fatalf("die unbegrenzte Sicherung schlug fehl: %v", backupError)
|
|
}
|
|
|
|
unlimitedDuration := time.Since(startTime)
|
|
|
|
// Dieselbe Menge muss ohne Grenze deutlich schneller durchlaufen.
|
|
if unlimitedDuration > 500*time.Millisecond {
|
|
testInstance.Errorf("die unbegrenzte Sicherung von %d Byte dauerte %s; der Vergleich trägt nicht",
|
|
payloadBytes, unlimitedDuration)
|
|
}
|
|
|
|
testInstance.Logf("unbegrenzt: %d Byte in %s", payloadBytes, unlimitedDuration.Round(time.Millisecond))
|
|
}
|
|
|
|
// TestNilLimiterMeansUnlimited prüft den fehlenden Begrenzer.
|
|
//
|
|
// Ein nil-Begrenzer darf die Sicherung weder anhalten noch abstürzen lassen.
|
|
func TestNilLimiterMeansUnlimited(testInstance *testing.T) {
|
|
nilLimiterEngine := newBandwidthTestEngine(testInstance)
|
|
|
|
sourceData := buildIncompressiblePayload(64 << 10)
|
|
|
|
backupResult, backupError := nilLimiterEngine.Backup(context.Background(), BackupOptions{
|
|
BackupID: "ohne-begrenzer",
|
|
BackupType: repository.BackupTypeFull,
|
|
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "test"},
|
|
CompressionLevel: CompressionOff,
|
|
BandwidthLimiter: nil,
|
|
}, []BackupSource{
|
|
{Path: "daten.bin", EntryType: "file", Reader: bytes.NewReader(sourceData)},
|
|
})
|
|
if backupError != nil {
|
|
testInstance.Fatalf("die Sicherung ohne Begrenzer schlug fehl: %v", backupError)
|
|
}
|
|
|
|
if backupResult.Progress.BytesProcessed != int64(len(sourceData)) {
|
|
testInstance.Errorf("es wurden %d Byte verarbeitet, erwartet waren %d",
|
|
backupResult.Progress.BytesProcessed, len(sourceData))
|
|
}
|
|
}
|
|
|
|
// TestBandwidthLimitPreservesData ist der wichtigste Test dieser Datei.
|
|
//
|
|
// Eine Bandbreitengrenze, die den Inhalt antastet, wäre die heimtückischste Art,
|
|
// ein Backup zu beschädigen: Es liefe erfolgreich durch, und der Schaden fiele
|
|
// erst bei der Wiederherstellung auf.
|
|
func TestBandwidthLimitPreservesData(testInstance *testing.T) {
|
|
limitedEngine := newBandwidthTestEngine(testInstance)
|
|
|
|
// Klein genug, damit der Test nicht wartet — es geht um die Daten, nicht
|
|
// um die Zeit.
|
|
sourceData := buildIncompressiblePayload(128 << 10)
|
|
|
|
bandwidthLimiter, _ := ratelimit.NewLimiter(8 << 20)
|
|
|
|
if _, backupError := limitedEngine.Backup(context.Background(), BackupOptions{
|
|
BackupID: "unversehrt",
|
|
BackupType: repository.BackupTypeFull,
|
|
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "test"},
|
|
CompressionLevel: CompressionOff,
|
|
BandwidthLimiter: bandwidthLimiter,
|
|
}, []BackupSource{
|
|
{Path: "daten.bin", EntryType: "file", Reader: bytes.NewReader(sourceData)},
|
|
}); backupError != nil {
|
|
testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError)
|
|
}
|
|
|
|
var restoredData bytes.Buffer
|
|
|
|
if _, restoreError := limitedEngine.Restore(context.Background(), RestoreOptions{
|
|
BackupID: "unversehrt",
|
|
Path: "daten.bin",
|
|
}, &restoredData); restoreError != nil {
|
|
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
|
|
}
|
|
|
|
if !bytes.Equal(restoredData.Bytes(), sourceData) {
|
|
testInstance.Fatal("die begrenzte Sicherung veränderte die Daten")
|
|
}
|
|
}
|
|
|
|
// TestBandwidthLimitRespectsCancellation prüft den Abbruch während der Wartezeit.
|
|
//
|
|
// Ohne ihn hinge ein abgebrochener Sicherungslauf noch minutenlang in einer
|
|
// Wartezeit, die niemand mehr braucht — und das Herunterfahren des Dienstes
|
|
// hinge daran.
|
|
func TestBandwidthLimitRespectsCancellation(testInstance *testing.T) {
|
|
limitedEngine := newBandwidthTestEngine(testInstance)
|
|
|
|
// Eine große Menge bei kleiner Rate: Der Lauf käme ohne Abbruch nicht
|
|
// innerhalb der Testlaufzeit durch.
|
|
sourceData := buildIncompressiblePayload(16 << 20)
|
|
|
|
bandwidthLimiter, _ := ratelimit.NewLimiter(1 << 20)
|
|
|
|
backupContext, cancelBackup := context.WithCancel(context.Background())
|
|
|
|
// Der Abbruch erfolgt, während der Begrenzer wartet.
|
|
go func() {
|
|
time.Sleep(300 * time.Millisecond)
|
|
cancelBackup()
|
|
}()
|
|
|
|
startTime := time.Now()
|
|
|
|
_, backupError := limitedEngine.Backup(backupContext, BackupOptions{
|
|
BackupID: "abgebrochen",
|
|
BackupType: repository.BackupTypeFull,
|
|
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "test"},
|
|
CompressionLevel: CompressionOff,
|
|
BandwidthLimiter: bandwidthLimiter,
|
|
}, []BackupSource{
|
|
{Path: "daten.bin", EntryType: "file", Reader: bytes.NewReader(sourceData)},
|
|
})
|
|
|
|
elapsedDuration := time.Since(startTime)
|
|
|
|
if backupError == nil {
|
|
testInstance.Fatal("der abgebrochene Lauf meldete Erfolg")
|
|
}
|
|
|
|
if !errors.Is(backupError, context.Canceled) {
|
|
testInstance.Errorf("der Abbruch wurde nicht als solcher gemeldet: %v", backupError)
|
|
}
|
|
|
|
// Ohne Beachtung des Abbruchs dauerte der Lauf 16 Sekunden.
|
|
if elapsedDuration > 5*time.Second {
|
|
testInstance.Fatalf("der abgebrochene Lauf dauerte %s; die Wartezeit beachtet den Abbruch nicht", elapsedDuration)
|
|
}
|
|
}
|
|
|
|
// TestOneLimiterServesAllSources prüft die gemeinsame Nutzung.
|
|
//
|
|
// Je Quelle einen eigenen Begrenzer zu führen ergäbe bei einem Auftrag mit drei
|
|
// Verzeichnissen das Dreifache der vereinbarten Rate — genau der Fehler, der
|
|
// eine Bandbreitengrenze wirkungslos macht.
|
|
func TestOneLimiterServesAllSources(testInstance *testing.T) {
|
|
if testing.Short() {
|
|
testInstance.Skip("die Durchsatzmessung braucht echte Zeit")
|
|
}
|
|
|
|
const bytesPerSource = 1 << 20
|
|
const sourceCount = 3
|
|
const bytesPerSecond int64 = 1 << 20
|
|
|
|
limitedEngine := newBandwidthTestEngine(testInstance)
|
|
|
|
bandwidthLimiter, _ := ratelimit.NewLimiter(bytesPerSecond)
|
|
|
|
backupSources := make([]BackupSource, 0, sourceCount)
|
|
for sourceIndex := 0; sourceIndex < sourceCount; sourceIndex++ {
|
|
// Je Quelle eigene Daten, damit die Deduplizierung sie nicht
|
|
// zusammenfallen lässt und die Messung verfälscht.
|
|
sourceData := buildIncompressiblePayload(bytesPerSource)
|
|
sourceData[0] = byte(sourceIndex)
|
|
|
|
backupSources = append(backupSources, BackupSource{
|
|
Path: "quelle-" + string(rune('a'+sourceIndex)) + ".bin",
|
|
EntryType: "file",
|
|
Reader: bytes.NewReader(sourceData),
|
|
})
|
|
}
|
|
|
|
startTime := time.Now()
|
|
|
|
if _, backupError := limitedEngine.Backup(context.Background(), BackupOptions{
|
|
BackupID: "drei-quellen",
|
|
BackupType: repository.BackupTypeFull,
|
|
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "test"},
|
|
CompressionLevel: CompressionOff,
|
|
BandwidthLimiter: bandwidthLimiter,
|
|
}, backupSources); backupError != nil {
|
|
testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError)
|
|
}
|
|
|
|
totalDuration := time.Since(startTime)
|
|
|
|
// Drei MiB bei einem MiB/s, abzüglich des anfänglichen Vorrats von einem
|
|
// MiB: mindestens etwa zwei Sekunden. Mit einem Begrenzer je Quelle liefe
|
|
// jede Quelle sofort durch und die Gesamtdauer läge nahe null.
|
|
const minimumExpectedDuration = 1500 * time.Millisecond
|
|
|
|
if totalDuration < minimumExpectedDuration {
|
|
testInstance.Fatalf("drei Quellen mit je %d Byte liefen in %s durch; jede Quelle hatte offenbar ihren eigenen Begrenzer",
|
|
bytesPerSource, totalDuration)
|
|
}
|
|
|
|
testInstance.Logf("drei Quellen mit je 1 MiB bei 1 MiB/s: %s", totalDuration.Round(time.Millisecond))
|
|
}
|