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>
353 lines
11 KiB
Go
353 lines
11 KiB
Go
package ratelimit
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeClock ersetzt die Zeit in Tests.
|
|
//
|
|
// Ein Test, der echte Sekunden verstreichen lässt, wird entweder langsam oder
|
|
// unzuverlässig. Mit einer gesteuerten Uhr lässt sich das Zeitverhalten genau
|
|
// prüfen, ohne zu warten.
|
|
type fakeClock struct {
|
|
// mutex schützt den Zustand.
|
|
mutex sync.Mutex
|
|
// currentTime ist die aktuelle Zeit.
|
|
currentTime time.Time
|
|
// totalSlept ist die insgesamt angeforderte Wartezeit.
|
|
totalSlept time.Duration
|
|
}
|
|
|
|
// now liefert die aktuelle Zeit der gesteuerten Uhr.
|
|
func (clock *fakeClock) now() time.Time {
|
|
clock.mutex.Lock()
|
|
defer clock.mutex.Unlock()
|
|
|
|
return clock.currentTime
|
|
}
|
|
|
|
// sleep zählt die Wartezeit und lässt die Uhr vorrücken.
|
|
func (clock *fakeClock) sleep(waitContext context.Context, waitDuration time.Duration) error {
|
|
if contextError := waitContext.Err(); contextError != nil {
|
|
return contextError
|
|
}
|
|
|
|
clock.mutex.Lock()
|
|
defer clock.mutex.Unlock()
|
|
|
|
clock.totalSlept += waitDuration
|
|
clock.currentTime = clock.currentTime.Add(waitDuration)
|
|
|
|
return nil
|
|
}
|
|
|
|
// newTestLimiter baut einen Begrenzer mit gesteuerter Uhr.
|
|
func newTestLimiter(testInstance *testing.T, bytesPerSecond int64) (*Limiter, *fakeClock) {
|
|
testInstance.Helper()
|
|
|
|
builtLimiter, limiterError := NewLimiter(bytesPerSecond)
|
|
if limiterError != nil {
|
|
testInstance.Fatalf("der Begrenzer ließ sich nicht bauen: %v", limiterError)
|
|
}
|
|
|
|
testClock := &fakeClock{currentTime: time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC)}
|
|
|
|
builtLimiter.nowFunction = testClock.now
|
|
builtLimiter.sleepFunction = testClock.sleep
|
|
builtLimiter.lastRefillTime = testClock.currentTime
|
|
|
|
return builtLimiter, testClock
|
|
}
|
|
|
|
// TestLimiterEnforcesRateOverTime prüft die Einhaltung der Rate.
|
|
func TestLimiterEnforcesRateOverTime(testInstance *testing.T) {
|
|
const bytesPerSecond int64 = 1 << 20 // 1 MiB/s
|
|
|
|
testLimiter, testClock := newTestLimiter(testInstance, bytesPerSecond)
|
|
|
|
// Der Eimer ist anfangs voll: 1 MiB geht ohne Warten durch.
|
|
if waitError := testLimiter.Wait(context.Background(), bytesPerSecond); waitError != nil {
|
|
testInstance.Fatalf("die erste Übertragung schlug fehl: %v", waitError)
|
|
}
|
|
|
|
if testClock.totalSlept != 0 {
|
|
testInstance.Errorf("die erste Übertragung wartete %s, obwohl der Eimer voll war", testClock.totalSlept)
|
|
}
|
|
|
|
// Weitere 4 MiB müssen ungefähr vier Sekunden kosten.
|
|
if waitError := testLimiter.Wait(context.Background(), 4*bytesPerSecond); waitError != nil {
|
|
testInstance.Fatalf("die zweite Übertragung schlug fehl: %v", waitError)
|
|
}
|
|
|
|
expectedWait := 4 * time.Second
|
|
tolerance := 200 * time.Millisecond
|
|
|
|
if testClock.totalSlept < expectedWait-tolerance || testClock.totalSlept > expectedWait+tolerance {
|
|
testInstance.Errorf("für 4 MiB bei 1 MiB/s wurde %s gewartet, erwartet waren etwa %s",
|
|
testClock.totalSlept, expectedWait)
|
|
}
|
|
}
|
|
|
|
// TestLimiterHandlesRequestLargerThanBurst prüft den Sonderfall.
|
|
//
|
|
// Eine Anforderung größer als der Eimer liesse sich nie erfüllen und blockierte
|
|
// für immer. Sie muss in Teilstücke zerlegt werden.
|
|
func TestLimiterHandlesRequestLargerThanBurst(testInstance *testing.T) {
|
|
const bytesPerSecond int64 = 1 << 20
|
|
|
|
testLimiter, testClock := newTestLimiter(testInstance, bytesPerSecond)
|
|
|
|
// Zehnmal die Eimergröße auf einmal.
|
|
requestedBytes := 10 * testLimiter.burstBytes
|
|
|
|
waitDone := make(chan error, 1)
|
|
go func() {
|
|
waitDone <- testLimiter.Wait(context.Background(), requestedBytes)
|
|
}()
|
|
|
|
select {
|
|
case waitError := <-waitDone:
|
|
if waitError != nil {
|
|
testInstance.Fatalf("die große Übertragung schlug fehl: %v", waitError)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
testInstance.Fatal("eine Anforderung größer als der Eimer blockierte")
|
|
}
|
|
|
|
// Neun Eimerfüllungen müssen bezahlt werden, eine war vorrätig.
|
|
expectedWait := 9 * time.Second
|
|
tolerance := time.Second
|
|
|
|
if testClock.totalSlept < expectedWait-tolerance {
|
|
testInstance.Errorf("für %d Byte bei 1 MiB/s wurde nur %s gewartet", requestedBytes, testClock.totalSlept)
|
|
}
|
|
}
|
|
|
|
// TestLimiterRespectsContextCancellation prüft den Abbruch.
|
|
//
|
|
// Ohne ihn hinge ein abgebrochener Sicherungslauf noch minutenlang in einer
|
|
// Wartezeit, die niemand mehr braucht.
|
|
func TestLimiterRespectsContextCancellation(testInstance *testing.T) {
|
|
testLimiter, limiterError := NewLimiter(minimumBurstBytes)
|
|
if limiterError != nil {
|
|
testInstance.Fatalf("der Begrenzer ließ sich nicht bauen: %v", limiterError)
|
|
}
|
|
|
|
// Der Eimer wird geleert, damit der nächste Aufruf tatsächlich wartet.
|
|
if waitError := testLimiter.Wait(context.Background(), testLimiter.burstBytes); waitError != nil {
|
|
testInstance.Fatalf("die Vorbereitung schlug fehl: %v", waitError)
|
|
}
|
|
|
|
cancelledContext, cancelWait := context.WithCancel(context.Background())
|
|
cancelWait()
|
|
|
|
startTime := time.Now()
|
|
waitError := testLimiter.Wait(cancelledContext, testLimiter.burstBytes*4)
|
|
elapsedDuration := time.Since(startTime)
|
|
|
|
if !errors.Is(waitError, context.Canceled) {
|
|
testInstance.Fatalf("der Abbruch wurde nicht gemeldet: %v", waitError)
|
|
}
|
|
|
|
if elapsedDuration > time.Second {
|
|
testInstance.Errorf("der abgebrochene Aufruf dauerte %s", elapsedDuration)
|
|
}
|
|
}
|
|
|
|
// TestUnlimitedLimiterDoesNotWrap prüft den abgeschalteten Begrenzer.
|
|
//
|
|
// Eine Hülle, die nichts tut, kostet bei jedem Block einen Aufruf mehr.
|
|
func TestUnlimitedLimiterDoesNotWrap(testInstance *testing.T) {
|
|
unlimitedLimiter, limiterError := NewLimiter(Unlimited)
|
|
if limiterError != nil {
|
|
testInstance.Fatalf("der Begrenzer ließ sich nicht bauen: %v", limiterError)
|
|
}
|
|
|
|
if !unlimitedLimiter.IsUnlimited() {
|
|
testInstance.Error("ein Begrenzer mit Rate 0 meldete sich als begrenzend")
|
|
}
|
|
|
|
sourceReader := strings.NewReader("Inhalt")
|
|
|
|
wrappedReader := NewLimitedReader(context.Background(), sourceReader, unlimitedLimiter)
|
|
if wrappedReader != io.Reader(sourceReader) {
|
|
testInstance.Error("ohne Begrenzung wurde der Datenstrom trotzdem umhüllt")
|
|
}
|
|
|
|
// Auch ein nicht vorhandener Begrenzer darf nicht stören.
|
|
var missingLimiter *Limiter
|
|
if !missingLimiter.IsUnlimited() {
|
|
testInstance.Error("ein fehlender Begrenzer meldete sich als begrenzend")
|
|
}
|
|
}
|
|
|
|
// TestLimitedReaderDeliversAllBytes prüft die Unversehrtheit der Daten.
|
|
//
|
|
// Eine Bandbreitengrenze darf den Inhalt nicht antasten — sonst wäre sie die
|
|
// heimtückischste Art, ein Backup zu beschädigen.
|
|
func TestLimitedReaderDeliversAllBytes(testInstance *testing.T) {
|
|
testLimiter, _ := newTestLimiter(testInstance, 1<<20)
|
|
|
|
sourceData := bytes.Repeat([]byte("Syncova"), 50_000)
|
|
|
|
limitedReader := NewLimitedReader(context.Background(), bytes.NewReader(sourceData), testLimiter)
|
|
|
|
readData, readError := io.ReadAll(limitedReader)
|
|
if readError != nil {
|
|
testInstance.Fatalf("das Lesen schlug fehl: %v", readError)
|
|
}
|
|
|
|
if !bytes.Equal(readData, sourceData) {
|
|
testInstance.Fatal("die begrenzte Übertragung veränderte die Daten")
|
|
}
|
|
}
|
|
|
|
// TestLimitedWriterDeliversAllBytes prüft das Schreibziel.
|
|
func TestLimitedWriterDeliversAllBytes(testInstance *testing.T) {
|
|
testLimiter, _ := newTestLimiter(testInstance, 1<<20)
|
|
|
|
sourceData := bytes.Repeat([]byte("Backup"), 50_000)
|
|
|
|
var targetBuffer bytes.Buffer
|
|
limitedWriter := NewLimitedWriter(context.Background(), &targetBuffer, testLimiter)
|
|
|
|
writtenCount, writeError := limitedWriter.Write(sourceData)
|
|
if writeError != nil {
|
|
testInstance.Fatalf("das Schreiben schlug fehl: %v", writeError)
|
|
}
|
|
|
|
if writtenCount != len(sourceData) {
|
|
testInstance.Errorf("es wurden %d von %d Byte geschrieben", writtenCount, len(sourceData))
|
|
}
|
|
|
|
if !bytes.Equal(targetBuffer.Bytes(), sourceData) {
|
|
testInstance.Fatal("die begrenzte Übertragung veränderte die Daten")
|
|
}
|
|
}
|
|
|
|
// TestSharedLimiterSplitsRateBetweenWorkers prüft die gemeinsame Nutzung.
|
|
//
|
|
// Ein Begrenzer je Arbeiter ergäbe ein Vielfaches der vereinbarten Rate — genau
|
|
// der Fehler, der eine Bandbreitengrenze wirkungslos macht.
|
|
func TestSharedLimiterSplitsRateBetweenWorkers(testInstance *testing.T) {
|
|
const bytesPerSecond int64 = 1 << 20
|
|
|
|
testLimiter, testClock := newTestLimiter(testInstance, bytesPerSecond)
|
|
|
|
const workerCount = 4
|
|
const bytesPerWorker = 1 << 20
|
|
|
|
var waitGroup sync.WaitGroup
|
|
waitGroup.Add(workerCount)
|
|
|
|
for workerIndex := 0; workerIndex < workerCount; workerIndex++ {
|
|
go func() {
|
|
defer waitGroup.Done()
|
|
|
|
_ = testLimiter.Wait(context.Background(), bytesPerWorker)
|
|
}()
|
|
}
|
|
|
|
waitGroup.Wait()
|
|
|
|
// Vier Arbeiter übertragen zusammen 4 MiB; einer war vorrätig, für drei
|
|
// muss gewartet werden.
|
|
expectedMinimumWait := 2 * time.Second
|
|
|
|
if testClock.totalSlept < expectedMinimumWait {
|
|
testInstance.Fatalf("vier Arbeiter warteten zusammen nur %s; die Rate wurde vervielfacht", testClock.totalSlept)
|
|
}
|
|
}
|
|
|
|
// TestParseBandwidthLimitDistinguishesBitsAndBytes ist der wichtigste
|
|
// Auswertungstest.
|
|
//
|
|
// Netzwerkleute rechnen in Bit je Sekunde, Speicherleute in Byte. Wer
|
|
// „100Mbit" meint und „100MB" einträgt, vergibt das Achtfache.
|
|
func TestParseBandwidthLimitDistinguishesBitsAndBytes(testInstance *testing.T) {
|
|
expectedValues := map[string]int64{
|
|
"": Unlimited,
|
|
"0": Unlimited,
|
|
"unbegrenzt": Unlimited,
|
|
"50MB": 50 << 20,
|
|
"50 MB": 50 << 20,
|
|
"50MB/s": 50 << 20,
|
|
"1GB": 1 << 30,
|
|
"512KB": 512 << 10,
|
|
"100Mbit": 100 * 1000 * 1000 / 8,
|
|
"1Gbit": 1000 * 1000 * 1000 / 8,
|
|
"1.5MB": 1_572_864,
|
|
"1,5MB": 1_572_864,
|
|
"1048576": 1 << 20,
|
|
}
|
|
|
|
for limitText, expectedValue := range expectedValues {
|
|
parsedValue, parseError := ParseBandwidthLimit(limitText)
|
|
if parseError != nil {
|
|
testInstance.Errorf("%q wurde abgelehnt: %v", limitText, parseError)
|
|
continue
|
|
}
|
|
|
|
if parsedValue != expectedValue {
|
|
testInstance.Errorf("%q ergab %d, erwartet waren %d", limitText, parsedValue, expectedValue)
|
|
}
|
|
}
|
|
|
|
// Der Kern: 100Mbit ist deutlich weniger als 100MB.
|
|
bitValue, _ := ParseBandwidthLimit("100Mbit")
|
|
byteValue, _ := ParseBandwidthLimit("100MB")
|
|
|
|
if bitValue >= byteValue {
|
|
testInstance.Fatalf("100Mbit (%d) wurde nicht kleiner als 100MB (%d) ausgewertet", bitValue, byteValue)
|
|
}
|
|
}
|
|
|
|
// TestParseBandwidthLimitRejectsNonsense prüft die Eingabeprüfung.
|
|
func TestParseBandwidthLimitRejectsNonsense(testInstance *testing.T) {
|
|
invalidInputs := []string{"schnell", "-5MB", "MB", "1,2,3MB"}
|
|
|
|
for _, invalidInput := range invalidInputs {
|
|
if _, parseError := ParseBandwidthLimit(invalidInput); parseError == nil {
|
|
testInstance.Errorf("die Eingabe %q wurde angenommen", invalidInput)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNewLimiterRejectsUnusablyLowRates prüft die Untergrenze.
|
|
//
|
|
// Kleiner als ein typischer Netzwerkblock zu begrenzen führt zu ständigem
|
|
// Warten in winzigen Schritten; der Durchsatz bräche stärker ein als vorgesehen.
|
|
func TestNewLimiterRejectsUnusablyLowRates(testInstance *testing.T) {
|
|
if _, limiterError := NewLimiter(100); !errors.Is(limiterError, ErrInvalidRate) {
|
|
testInstance.Errorf("eine zu kleine Rate wurde angenommen: %v", limiterError)
|
|
}
|
|
|
|
if _, limiterError := NewLimiter(-1); !errors.Is(limiterError, ErrInvalidRate) {
|
|
testInstance.Errorf("eine negative Rate wurde angenommen: %v", limiterError)
|
|
}
|
|
}
|
|
|
|
// TestFormatBandwidthLimit prüft die Ausgabe.
|
|
func TestFormatBandwidthLimit(testInstance *testing.T) {
|
|
expectedTexts := map[int64]string{
|
|
Unlimited: "unbegrenzt",
|
|
50 << 20: "50.0 MB/s",
|
|
1 << 30: "1.0 GB/s",
|
|
512 << 10: "512.0 KB/s",
|
|
500: "500 B/s",
|
|
}
|
|
|
|
for bytesPerSecond, expectedText := range expectedTexts {
|
|
if formattedText := FormatBandwidthLimit(bytesPerSecond); formattedText != expectedText {
|
|
testInstance.Errorf("%d ergab %q, erwartet war %q", bytesPerSecond, formattedText, expectedText)
|
|
}
|
|
}
|
|
}
|