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>
415 lines
13 KiB
Go
415 lines
13 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// baseTime ist der Bezugszeitpunkt der Warteschlangentests.
|
|
var baseTime = time.Date(2026, time.June, 1, 2, 0, 0, 0, time.UTC)
|
|
|
|
// TestDispatchRespectsPriority prüft die Reihenfolge.
|
|
func TestDispatchRespectsPriority(testInstance *testing.T) {
|
|
testQueue := NewQueue(ConcurrencyLimits{})
|
|
|
|
// Bewusst in umgekehrter Reihenfolge eingereiht: Die Warteschlange darf
|
|
// nicht der Einreihung, sondern muss der Dringlichkeit folgen.
|
|
for _, testCase := range []struct {
|
|
jobID string
|
|
priority Priority
|
|
}{
|
|
{"niedrig", PriorityLow},
|
|
{"normal", PriorityNormal},
|
|
{"kritisch", PriorityCritical},
|
|
{"hoch", PriorityHigh},
|
|
} {
|
|
if enqueueError := testQueue.Enqueue(QueuedJob{
|
|
JobID: testCase.jobID,
|
|
Priority: testCase.priority,
|
|
ScheduledFor: baseTime,
|
|
EnqueuedAt: baseTime,
|
|
}); enqueueError != nil {
|
|
testInstance.Fatalf("das Einreihen schlug fehl: %v", enqueueError)
|
|
}
|
|
}
|
|
|
|
decisions := testQueue.Dispatch(baseTime)
|
|
|
|
expectedOrder := []string{"kritisch", "hoch", "normal", "niedrig"}
|
|
if len(decisions) != len(expectedOrder) {
|
|
testInstance.Fatalf("es wurden %d Entscheidungen erwartet, geliefert wurden %d", len(expectedOrder), len(decisions))
|
|
}
|
|
|
|
for decisionIndex, expectedJobID := range expectedOrder {
|
|
if decisions[decisionIndex].Job.JobID != expectedJobID {
|
|
testInstance.Errorf("an Stelle %d stand %q, erwartet war %q",
|
|
decisionIndex, decisions[decisionIndex].Job.JobID, expectedJobID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAgingPreventsStarvation ist der wichtigste Warteschlangentest.
|
|
//
|
|
// Ohne Alterung verhungert ein Auftrag niedriger Priorität in einer Anlage mit
|
|
// genug dringenden Aufträgen für immer — und niemand bemerkt es, weil er
|
|
// technisch „wartet" statt zu scheitern.
|
|
func TestAgingPreventsStarvation(testInstance *testing.T) {
|
|
testQueue := NewQueue(ConcurrencyLimits{})
|
|
|
|
// Ein niedrig eingestufter Auftrag wartet seit drei Stunden.
|
|
if enqueueError := testQueue.Enqueue(QueuedJob{
|
|
JobID: "lange-wartend",
|
|
Priority: PriorityLow,
|
|
ScheduledFor: baseTime.Add(-3 * time.Hour),
|
|
EnqueuedAt: baseTime.Add(-3 * time.Hour),
|
|
}); enqueueError != nil {
|
|
testInstance.Fatalf("das Einreihen schlug fehl: %v", enqueueError)
|
|
}
|
|
|
|
// Ein normaler Auftrag kommt gerade erst.
|
|
if enqueueError := testQueue.Enqueue(QueuedJob{
|
|
JobID: "frisch",
|
|
Priority: PriorityNormal,
|
|
ScheduledFor: baseTime,
|
|
EnqueuedAt: baseTime,
|
|
}); enqueueError != nil {
|
|
testInstance.Fatalf("das Einreihen schlug fehl: %v", enqueueError)
|
|
}
|
|
|
|
decisions := testQueue.Dispatch(baseTime)
|
|
|
|
if decisions[0].Job.JobID != "lange-wartend" {
|
|
testInstance.Fatalf("der lange wartende Auftrag verhungert; zuerst kam %q", decisions[0].Job.JobID)
|
|
}
|
|
}
|
|
|
|
// TestAgingDoesNotOvertakeCriticalJobs prüft den Deckel.
|
|
//
|
|
// Ohne ihn überholte ein tagelang wartender Auftrag jede kritische Sicherung.
|
|
func TestAgingDoesNotOvertakeCriticalJobs(testInstance *testing.T) {
|
|
testQueue := NewQueue(ConcurrencyLimits{})
|
|
|
|
// Seit einer Woche wartend, aber niedrig eingestuft.
|
|
_ = testQueue.Enqueue(QueuedJob{
|
|
JobID: "uralt",
|
|
Priority: PriorityLow,
|
|
ScheduledFor: baseTime.Add(-7 * 24 * time.Hour),
|
|
EnqueuedAt: baseTime.Add(-7 * 24 * time.Hour),
|
|
})
|
|
|
|
_ = testQueue.Enqueue(QueuedJob{
|
|
JobID: "kritisch",
|
|
Priority: PriorityCritical,
|
|
ScheduledFor: baseTime,
|
|
EnqueuedAt: baseTime,
|
|
})
|
|
|
|
decisions := testQueue.Dispatch(baseTime)
|
|
|
|
if decisions[0].Job.JobID != "kritisch" {
|
|
testInstance.Fatalf("ein alter Auftrag überholte die kritische Sicherung; zuerst kam %q", decisions[0].Job.JobID)
|
|
}
|
|
}
|
|
|
|
// TestDispatchSkipsFutureJobs prüft, dass nichts zu früh läuft.
|
|
func TestDispatchSkipsFutureJobs(testInstance *testing.T) {
|
|
testQueue := NewQueue(ConcurrencyLimits{})
|
|
|
|
_ = testQueue.Enqueue(QueuedJob{
|
|
JobID: "spaeter",
|
|
Priority: PriorityCritical,
|
|
ScheduledFor: baseTime.Add(time.Hour),
|
|
EnqueuedAt: baseTime,
|
|
})
|
|
|
|
if decisions := testQueue.Dispatch(baseTime); len(decisions) != 0 {
|
|
testInstance.Fatalf("ein künftiger Auftrag wurde freigegeben: %+v", decisions)
|
|
}
|
|
}
|
|
|
|
// TestGlobalConcurrencyLimitIsEnforced prüft die Gesamtgrenze.
|
|
func TestGlobalConcurrencyLimitIsEnforced(testInstance *testing.T) {
|
|
testQueue := NewQueue(ConcurrencyLimits{GlobalLimit: 2})
|
|
|
|
for _, jobIdentifier := range []string{"eins", "zwei", "drei", "vier"} {
|
|
_ = testQueue.Enqueue(QueuedJob{
|
|
JobID: jobIdentifier,
|
|
Priority: PriorityNormal,
|
|
ScheduledFor: baseTime,
|
|
EnqueuedAt: baseTime,
|
|
})
|
|
}
|
|
|
|
decisions := testQueue.Dispatch(baseTime)
|
|
|
|
var runnableCount int
|
|
for _, decision := range decisions {
|
|
if decision.CanRun {
|
|
runnableCount++
|
|
}
|
|
}
|
|
|
|
if runnableCount != 2 {
|
|
testInstance.Fatalf("es wurden %d Aufträge freigegeben, zugelassen sind 2", runnableCount)
|
|
}
|
|
|
|
// Die abgelehnten müssen eine Begründung tragen. Ein wartender Auftrag ohne
|
|
// Begründung ist für den Betrieb wertlos.
|
|
for _, decision := range decisions {
|
|
if !decision.CanRun && decision.Reason == "" {
|
|
testInstance.Errorf("der Auftrag %q wartet ohne Begründung", decision.Job.JobID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPerRepositoryLimitIsEnforced prüft die Grenze je Repository.
|
|
func TestPerRepositoryLimitIsEnforced(testInstance *testing.T) {
|
|
testQueue := NewQueue(ConcurrencyLimits{PerRepositoryLimit: 1})
|
|
|
|
testQueue.RegisterJobMetadata("job-a", "repo-1", "quelle-a")
|
|
testQueue.RegisterJobMetadata("job-b", "repo-1", "quelle-b")
|
|
testQueue.RegisterJobMetadata("job-c", "repo-2", "quelle-c")
|
|
|
|
for _, jobIdentifier := range []string{"job-a", "job-b", "job-c"} {
|
|
_ = testQueue.Enqueue(QueuedJob{
|
|
JobID: jobIdentifier,
|
|
Priority: PriorityNormal,
|
|
ScheduledFor: baseTime,
|
|
EnqueuedAt: baseTime,
|
|
})
|
|
}
|
|
|
|
decisions := testQueue.Dispatch(baseTime)
|
|
|
|
runnableByJob := make(map[string]bool)
|
|
for _, decision := range decisions {
|
|
runnableByJob[decision.Job.JobID] = decision.CanRun
|
|
}
|
|
|
|
// Auf repo-1 darf nur einer laufen, auf repo-2 der dritte.
|
|
if runnableByJob["job-a"] == runnableByJob["job-b"] {
|
|
testInstance.Error("auf demselben Repository liefen zwei Aufträge gleichzeitig")
|
|
}
|
|
|
|
if !runnableByJob["job-c"] {
|
|
testInstance.Error("ein Auftrag auf einem anderen Repository wurde unnötig zurückgehalten")
|
|
}
|
|
}
|
|
|
|
// TestPerSourceLimitPreventsDoubleBackup prüft die Grenze je Quelle.
|
|
func TestPerSourceLimitPreventsDoubleBackup(testInstance *testing.T) {
|
|
testQueue := NewQueue(ConcurrencyLimits{PerSourceLimit: 1})
|
|
|
|
testQueue.RegisterJobMetadata("taeglich", "repo-1", "vm-100")
|
|
testQueue.RegisterJobMetadata("stuendlich", "repo-2", "vm-100")
|
|
|
|
for _, jobIdentifier := range []string{"taeglich", "stuendlich"} {
|
|
_ = testQueue.Enqueue(QueuedJob{
|
|
JobID: jobIdentifier,
|
|
Priority: PriorityNormal,
|
|
ScheduledFor: baseTime,
|
|
EnqueuedAt: baseTime,
|
|
})
|
|
}
|
|
|
|
decisions := testQueue.Dispatch(baseTime)
|
|
|
|
var runnableCount int
|
|
for _, decision := range decisions {
|
|
if decision.CanRun {
|
|
runnableCount++
|
|
}
|
|
}
|
|
|
|
if runnableCount != 1 {
|
|
testInstance.Fatalf("dieselbe Quelle wurde %d mal gleichzeitig gesichert", runnableCount)
|
|
}
|
|
}
|
|
|
|
// TestDependencyBlocksUntilPredecessorSucceeds prüft Abhängigkeiten.
|
|
func TestDependencyBlocksUntilPredecessorSucceeds(testInstance *testing.T) {
|
|
testQueue := NewQueue(ConcurrencyLimits{})
|
|
|
|
_ = testQueue.Enqueue(QueuedJob{
|
|
JobID: "anwendung",
|
|
Priority: PriorityNormal,
|
|
ScheduledFor: baseTime,
|
|
EnqueuedAt: baseTime,
|
|
DependsOnJobIDs: []string{"datenbank"},
|
|
})
|
|
|
|
// Ohne Vorgeschichte darf nichts laufen.
|
|
decisions := testQueue.Dispatch(baseTime)
|
|
if decisions[0].CanRun {
|
|
testInstance.Fatal("ein abhängiger Auftrag lief, obwohl der Vorgänger nie lief")
|
|
}
|
|
|
|
// Nach einem Erfolg des Vorgängers darf er laufen.
|
|
testQueue.MarkFinished("datenbank", OutcomeSucceeded)
|
|
|
|
decisions = testQueue.Dispatch(baseTime)
|
|
if !decisions[0].CanRun {
|
|
testInstance.Fatalf("der abhängige Auftrag blieb blockiert: %s", decisions[0].Reason)
|
|
}
|
|
}
|
|
|
|
// TestDependencyRejectsPartialFailure ist ein Ehrlichkeitstest.
|
|
//
|
|
// Ein Teilfehler ist kein Erfolg (PROMPT.md §138). Wer eine Datenbank sichert
|
|
// und danach das Anwendungsverzeichnis, will nicht das Verzeichnis zu einer
|
|
// halben Datenbank.
|
|
func TestDependencyRejectsPartialFailure(testInstance *testing.T) {
|
|
testQueue := NewQueue(ConcurrencyLimits{})
|
|
|
|
_ = testQueue.Enqueue(QueuedJob{
|
|
JobID: "anwendung",
|
|
Priority: PriorityNormal,
|
|
ScheduledFor: baseTime,
|
|
EnqueuedAt: baseTime,
|
|
DependsOnJobIDs: []string{"datenbank"},
|
|
})
|
|
|
|
testQueue.MarkFinished("datenbank", OutcomePartialFailure)
|
|
|
|
decisions := testQueue.Dispatch(baseTime)
|
|
if decisions[0].CanRun {
|
|
testInstance.Fatal("ein abhängiger Auftrag lief nach einem Teilfehler des Vorgängers")
|
|
}
|
|
|
|
if decisions[0].Reason == "" {
|
|
testInstance.Error("die Blockade wurde nicht begründet")
|
|
}
|
|
}
|
|
|
|
// TestDependencyWaitsWhilePredecessorRuns prüft den laufenden Vorgänger.
|
|
func TestDependencyWaitsWhilePredecessorRuns(testInstance *testing.T) {
|
|
testQueue := NewQueue(ConcurrencyLimits{})
|
|
|
|
testQueue.MarkFinished("datenbank", OutcomeSucceeded)
|
|
testQueue.MarkRunning("datenbank", baseTime)
|
|
|
|
_ = testQueue.Enqueue(QueuedJob{
|
|
JobID: "anwendung",
|
|
ScheduledFor: baseTime,
|
|
EnqueuedAt: baseTime,
|
|
DependsOnJobIDs: []string{"datenbank"},
|
|
})
|
|
|
|
decisions := testQueue.Dispatch(baseTime)
|
|
if decisions[0].CanRun {
|
|
testInstance.Fatal("ein abhängiger Auftrag lief, während der Vorgänger noch läuft")
|
|
}
|
|
}
|
|
|
|
// TestEnqueueRejectsDuplicates prüft den Schutz gegen Mehrfacheinreihung.
|
|
//
|
|
// Ohne ihn entstünden bei einem überlasteten System immer mehr Einträge
|
|
// desselben Auftrags, bis die Warteschlange den Speicher füllt.
|
|
func TestEnqueueRejectsDuplicates(testInstance *testing.T) {
|
|
testQueue := NewQueue(ConcurrencyLimits{})
|
|
|
|
firstJob := QueuedJob{JobID: "taeglich", ScheduledFor: baseTime, EnqueuedAt: baseTime}
|
|
|
|
if enqueueError := testQueue.Enqueue(firstJob); enqueueError != nil {
|
|
testInstance.Fatalf("das erste Einreihen schlug fehl: %v", enqueueError)
|
|
}
|
|
|
|
if enqueueError := testQueue.Enqueue(firstJob); !errors.Is(enqueueError, ErrJobAlreadyQueued) {
|
|
testInstance.Fatalf("ein doppeltes Einreihen wurde angenommen: %v", enqueueError)
|
|
}
|
|
|
|
testQueue.MarkRunning("taeglich", baseTime)
|
|
|
|
if enqueueError := testQueue.Enqueue(firstJob); !errors.Is(enqueueError, ErrJobAlreadyQueued) {
|
|
testInstance.Fatalf("ein laufender Auftrag wurde erneut eingereiht: %v", enqueueError)
|
|
}
|
|
}
|
|
|
|
// TestDependencyCycleIsDetected prüft die Ringerkennung.
|
|
//
|
|
// Ein Ring bliebe sonst unbemerkt: Jeder beteiligte Auftrag wartete auf einen
|
|
// anderen, keiner liefe je an, und die Oberfläche zeigte lauter „wartende"
|
|
// Aufträge ohne erkennbaren Grund.
|
|
func TestDependencyCycleIsDetected(testInstance *testing.T) {
|
|
cyclicDependencies := map[string][]string{
|
|
"a": {"b"},
|
|
"b": {"c"},
|
|
"c": {"a"},
|
|
}
|
|
|
|
cycleError := ValidateDependencyGraph(cyclicDependencies)
|
|
if !errors.Is(cycleError, ErrDependencyCycle) {
|
|
testInstance.Fatalf("der Ring wurde nicht erkannt: %v", cycleError)
|
|
}
|
|
|
|
// Die Fehlermeldung muss den Weg zeigen, sonst ist sie nutzlos.
|
|
if len(cycleError.Error()) < 20 {
|
|
testInstance.Errorf("die Fehlermeldung nennt den Ring nicht: %v", cycleError)
|
|
}
|
|
}
|
|
|
|
// TestSelfDependencyIsDetected prüft den Ring der Länge eins.
|
|
func TestSelfDependencyIsDetected(testInstance *testing.T) {
|
|
if cycleError := ValidateDependencyGraph(map[string][]string{
|
|
"a": {"a"},
|
|
}); !errors.Is(cycleError, ErrDependencyCycle) {
|
|
testInstance.Fatalf("eine Selbstabhängigkeit wurde angenommen: %v", cycleError)
|
|
}
|
|
}
|
|
|
|
// TestValidDependencyGraphIsAccepted prüft den Normalfall.
|
|
func TestValidDependencyGraphIsAccepted(testInstance *testing.T) {
|
|
// Ein Diamant: d hängt von b und c ab, beide von a. Kein Ring.
|
|
validDependencies := map[string][]string{
|
|
"a": {},
|
|
"b": {"a"},
|
|
"c": {"a"},
|
|
"d": {"b", "c"},
|
|
}
|
|
|
|
if cycleError := ValidateDependencyGraph(validDependencies); cycleError != nil {
|
|
testInstance.Fatalf("ein gültiger Abhängigkeitsgraph wurde abgelehnt: %v", cycleError)
|
|
}
|
|
}
|
|
|
|
// TestMarkRunningRemovesFromPending prüft den Zustandsübergang.
|
|
func TestMarkRunningRemovesFromPending(testInstance *testing.T) {
|
|
testQueue := NewQueue(ConcurrencyLimits{})
|
|
|
|
_ = testQueue.Enqueue(QueuedJob{JobID: "taeglich", ScheduledFor: baseTime, EnqueuedAt: baseTime})
|
|
|
|
if testQueue.PendingCount() != 1 {
|
|
testInstance.Fatalf("es wartet %d Auftrag, erwartet war 1", testQueue.PendingCount())
|
|
}
|
|
|
|
testQueue.MarkRunning("taeglich", baseTime)
|
|
|
|
if testQueue.PendingCount() != 0 || testQueue.RunningCount() != 1 {
|
|
testInstance.Errorf("nach dem Start warten %d und laufen %d Aufträge",
|
|
testQueue.PendingCount(), testQueue.RunningCount())
|
|
}
|
|
|
|
testQueue.MarkFinished("taeglich", OutcomeSucceeded)
|
|
|
|
if testQueue.RunningCount() != 0 {
|
|
testInstance.Errorf("nach dem Ende laufen noch %d Aufträge", testQueue.RunningCount())
|
|
}
|
|
}
|
|
|
|
// TestUnknownPriorityDefaultsToNormal prüft den Umgang mit Tippfehlern.
|
|
//
|
|
// Eine unbekannte Stufe darf einen Auftrag nicht an die Spitze befördern.
|
|
func TestUnknownPriorityDefaultsToNormal(testInstance *testing.T) {
|
|
unknownPriority := Priority("sehr-dringend")
|
|
|
|
if unknownPriority.IsValid() {
|
|
testInstance.Error("eine erfundene Stufe wurde als gültig gemeldet")
|
|
}
|
|
|
|
if unknownPriority.Rank() != PriorityNormal.Rank() {
|
|
testInstance.Errorf("eine unbekannte Stufe erhielt den Rang %d statt %d",
|
|
unknownPriority.Rank(), PriorityNormal.Rank())
|
|
}
|
|
}
|