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>
202 lines
7.5 KiB
Go
202 lines
7.5 KiB
Go
package jobs
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/syncova/syncova/packages/scheduler"
|
|
)
|
|
|
|
// buildValidJob liefert einen brauchbaren Auftrag als Ausgangspunkt.
|
|
func buildValidJob() Job {
|
|
return Job{
|
|
Name: "Nächtliche Sicherung",
|
|
Status: JobStatusActive,
|
|
Priority: scheduler.PriorityNormal,
|
|
Schedule: scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeDaily, Hour: 2},
|
|
RepositoryID: uuid.New(),
|
|
Sources: []JobSource{
|
|
{SourceType: SourceTypeFilesystem, SourceID: "/daten", SourceName: "Dateiserver"},
|
|
},
|
|
MaximumConcurrency: 1,
|
|
RetryPolicy: scheduler.DefaultRetryPolicy(),
|
|
}
|
|
}
|
|
|
|
// TestValidJobIsAccepted prüft den Normalfall.
|
|
func TestValidJobIsAccepted(testInstance *testing.T) {
|
|
validJob := buildValidJob()
|
|
|
|
if validationError := validJob.Validate(); validationError != nil {
|
|
testInstance.Fatalf("ein gültiger Auftrag wurde abgelehnt: %v", validationError)
|
|
}
|
|
}
|
|
|
|
// TestJobWithoutSourceIsRejected ist der wichtigste Prüftest.
|
|
//
|
|
// Ein Auftrag ohne Quelle liefe erfolgreich durch, ohne etwas zu sichern — die
|
|
// gefährlichste Fehlkonfiguration, weil sie wie ein Erfolg aussieht.
|
|
func TestJobWithoutSourceIsRejected(testInstance *testing.T) {
|
|
jobWithoutSource := buildValidJob()
|
|
jobWithoutSource.Sources = nil
|
|
|
|
validationError := jobWithoutSource.Validate()
|
|
if !errors.Is(validationError, ErrInvalidJob) {
|
|
testInstance.Fatalf("ein Auftrag ohne Quelle wurde angenommen: %v", validationError)
|
|
}
|
|
|
|
if !strings.Contains(validationError.Error(), "quelle") {
|
|
testInstance.Errorf("die Fehlermeldung nennt die Ursache nicht: %v", validationError)
|
|
}
|
|
}
|
|
|
|
// TestDuplicateSourceIsRejected prüft die doppelte Quelle.
|
|
//
|
|
// Sie verdoppelte die Datenmenge, ohne mehr zu sichern.
|
|
func TestDuplicateSourceIsRejected(testInstance *testing.T) {
|
|
jobWithDuplicate := buildValidJob()
|
|
jobWithDuplicate.Sources = append(jobWithDuplicate.Sources, JobSource{
|
|
SourceType: SourceTypeFilesystem,
|
|
SourceID: "/daten",
|
|
})
|
|
|
|
if validationError := jobWithDuplicate.Validate(); validationError == nil {
|
|
testInstance.Fatal("eine doppelt aufgeführte Quelle wurde angenommen")
|
|
}
|
|
|
|
// Dieselbe Kennung bei anderer Quellart ist dagegen zulässig.
|
|
jobWithDistinctTypes := buildValidJob()
|
|
jobWithDistinctTypes.Sources = append(jobWithDistinctTypes.Sources, JobSource{
|
|
SourceType: SourceTypeLinuxSystem,
|
|
SourceID: "/daten",
|
|
})
|
|
|
|
if validationError := jobWithDistinctTypes.Validate(); validationError != nil {
|
|
testInstance.Errorf("verschiedene Quellarten mit gleicher Kennung wurden abgelehnt: %v", validationError)
|
|
}
|
|
}
|
|
|
|
// TestRecoveryPointObjectiveMustMatchSchedule ist der Test, der eine stille
|
|
// Fehlannahme verhindert.
|
|
//
|
|
// Ein RPO, den der Zeitplan nicht einhalten kann, ist keine Kleinigkeit: Der
|
|
// Betreiber glaubt, höchstens vier Stunden zu verlieren, während der Auftrag
|
|
// nur täglich läuft. Das gehört beim Anlegen gesagt, nicht nach dem Ausfall.
|
|
func TestRecoveryPointObjectiveMustMatchSchedule(testInstance *testing.T) {
|
|
impossibleJob := buildValidJob()
|
|
impossibleJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeDaily, Hour: 2}
|
|
impossibleJob.RecoveryPointObjective = 4 * time.Hour
|
|
|
|
validationError := impossibleJob.Validate()
|
|
if validationError == nil {
|
|
testInstance.Fatal("ein täglicher Zeitplan mit 4-Stunden-RPO wurde angenommen")
|
|
}
|
|
|
|
if !strings.Contains(validationError.Error(), "wiederherstellungspunkt") {
|
|
testInstance.Errorf("die Fehlermeldung erklärt den Widerspruch nicht: %v", validationError)
|
|
}
|
|
|
|
// Ein stündlicher Plan hält vier Stunden dagegen mühelos ein.
|
|
feasibleJob := buildValidJob()
|
|
feasibleJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeHourly}
|
|
feasibleJob.RecoveryPointObjective = 4 * time.Hour
|
|
|
|
if validationError := feasibleJob.Validate(); validationError != nil {
|
|
testInstance.Errorf("ein einhaltbarer Wiederherstellungspunkt wurde abgelehnt: %v", validationError)
|
|
}
|
|
}
|
|
|
|
// TestManualScheduleCannotPromiseRecoveryPoint prüft den Sonderfall.
|
|
func TestManualScheduleCannotPromiseRecoveryPoint(testInstance *testing.T) {
|
|
manualJob := buildValidJob()
|
|
manualJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeManual}
|
|
manualJob.RecoveryPointObjective = 24 * time.Hour
|
|
|
|
if validationError := manualJob.Validate(); validationError == nil {
|
|
testInstance.Fatal("ein Zeitplan auf Anforderung versprach einen Wiederherstellungspunkt")
|
|
}
|
|
}
|
|
|
|
// TestInvalidJobsAreRejected prüft die übrigen Prüfungen.
|
|
func TestInvalidJobsAreRejected(testInstance *testing.T) {
|
|
invalidJobs := map[string]func(*Job){
|
|
"ohne Namen": func(job *Job) { job.Name = " " },
|
|
"Name zu lang": func(job *Job) { job.Name = strings.Repeat("x", 201) },
|
|
"ohne Repository": func(job *Job) { job.RepositoryID = uuid.Nil },
|
|
"unbekannte Quellart": func(job *Job) { job.Sources[0].SourceType = "vmware_vm" },
|
|
"Quelle ohne Kennung": func(job *Job) { job.Sources[0].SourceID = "" },
|
|
"unbekannte Dringlichkeit": func(job *Job) {
|
|
job.Priority = "sehr-dringend"
|
|
},
|
|
"kaputter Zeitplan": func(job *Job) {
|
|
job.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeDaily, Hour: 99}
|
|
},
|
|
"keine Nebenläufigkeit": func(job *Job) { job.MaximumConcurrency = 0 },
|
|
"negative Bandbreite": func(job *Job) { job.BandwidthLimitBytesPerSecond = -1 },
|
|
"kaputte Wiederholung": func(job *Job) {
|
|
job.RetryPolicy = scheduler.RetryPolicy{MaximumAttempts: 0}
|
|
},
|
|
}
|
|
|
|
for caseName, applyDefect := range invalidJobs {
|
|
defectiveJob := buildValidJob()
|
|
applyDefect(&defectiveJob)
|
|
|
|
if validationError := defectiveJob.Validate(); validationError == nil {
|
|
testInstance.Errorf("%s: der Auftrag wurde angenommen", caseName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSelfDependencyIsRejected prüft die Selbstabhängigkeit.
|
|
func TestSelfDependencyIsRejected(testInstance *testing.T) {
|
|
selfDependentJob := buildValidJob()
|
|
selfDependentJob.ID = uuid.New()
|
|
selfDependentJob.DependsOnJobIDs = []uuid.UUID{selfDependentJob.ID}
|
|
|
|
if validationError := selfDependentJob.Validate(); validationError == nil {
|
|
testInstance.Fatal("ein Auftrag, der von sich selbst abhängt, wurde angenommen")
|
|
}
|
|
}
|
|
|
|
// TestSourceReferenceIsUniquePerSource prüft die Kettenkennung.
|
|
//
|
|
// Ein Auftrag mit mehreren Quellen erzeugt je Quelle eine eigene Kette: Wären
|
|
// sie in einer, ließe sich eine einzelne Quelle nicht mehr eigenständig
|
|
// wiederherstellen.
|
|
func TestSourceReferenceIsUniquePerSource(testInstance *testing.T) {
|
|
firstSource := JobSource{SourceType: SourceTypeProxmoxVM, SourceID: "qemu/100"}
|
|
secondSource := JobSource{SourceType: SourceTypeProxmoxVM, SourceID: "qemu/101"}
|
|
sameIDOtherType := JobSource{SourceType: SourceTypeFilesystem, SourceID: "qemu/100"}
|
|
|
|
if firstSource.SourceReference() == secondSource.SourceReference() {
|
|
testInstance.Error("zwei verschiedene Gäste ergaben dieselbe Kettenkennung")
|
|
}
|
|
|
|
if firstSource.SourceReference() == sameIDOtherType.SourceReference() {
|
|
testInstance.Error("gleiche Kennung bei anderer Quellart ergab dieselbe Kettenkennung")
|
|
}
|
|
}
|
|
|
|
// TestIsRunnableOnlyWhenActive prüft den Ausführungszustand.
|
|
func TestIsRunnableOnlyWhenActive(testInstance *testing.T) {
|
|
notRunnableStatuses := []JobStatus{JobStatusPaused, JobStatusDisabled, JobStatusError}
|
|
|
|
for _, jobStatus := range notRunnableStatuses {
|
|
testJob := buildValidJob()
|
|
testJob.Status = jobStatus
|
|
|
|
if testJob.IsRunnable() {
|
|
testInstance.Errorf("ein Auftrag im Zustand %q wurde als ausführbar gemeldet", jobStatus)
|
|
}
|
|
}
|
|
|
|
activeJob := buildValidJob()
|
|
if !activeJob.IsRunnable() {
|
|
testInstance.Error("ein aktiver Auftrag wurde als nicht ausführbar gemeldet")
|
|
}
|
|
}
|