package backupexecutor import ( "bytes" "context" "errors" "io" "log/slog" "os" "path/filepath" "strings" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/syncova/syncova/packages/backupengine" "github.com/syncova/syncova/packages/jobs" "github.com/syncova/syncova/packages/platform/crypto" "github.com/syncova/syncova/packages/repository" "github.com/syncova/syncova/packages/scheduler" ) // connectTestDatabase öffnet die Testdatenbank. // // Ohne erreichbare Datenbank werden die Tests übersprungen: Ein rot leuchtender // Testlauf auf einem Rechner ohne Docker gewöhnt nur das Wegschauen an. func connectTestDatabase(testInstance *testing.T) *pgxpool.Pool { testInstance.Helper() connectionString := os.Getenv("SYNCOVA_TEST_DATABASE_URL") if connectionString == "" { testInstance.Skip("SYNCOVA_TEST_DATABASE_URL ist nicht gesetzt; die Datenbanktests werden übersprungen") } connectContext, cancelConnect := context.WithTimeout(context.Background(), 5*time.Second) defer cancelConnect() connectionPool, poolError := pgxpool.New(connectContext, connectionString) if poolError != nil { testInstance.Skipf("die Testdatenbank war nicht erreichbar: %v", poolError) } if pingError := connectionPool.Ping(connectContext); pingError != nil { connectionPool.Close() testInstance.Skipf("die Testdatenbank antwortete nicht: %v", pingError) } testInstance.Cleanup(connectionPool.Close) return connectionPool } // discardLogger unterdrückt die Protokollausgabe der Tests. func discardLogger() *slog.Logger { return slog.New(slog.NewJSONHandler(io.Discard, nil)) } // buildTestSecretStore erzeugt einen Schlüsselspeicher. func buildTestSecretStore(testInstance *testing.T) crypto.SecretStore { testInstance.Helper() builtStore, storeError := crypto.NewLocalSecretStore( map[string][]byte{"v1": bytes.Repeat([]byte{0x42}, 32)}, "v1") if storeError != nil { testInstance.Fatalf("der Schlüsselspeicher ließ sich nicht bauen: %v", storeError) } return builtStore } // buildSourceTree legt einen Verzeichnisbaum als Sicherungsquelle an. func buildSourceTree(testInstance *testing.T) string { testInstance.Helper() sourcePath := testInstance.TempDir() fileContents := map[string]string{ "dokumente/bericht.txt": strings.Repeat("Ein Bericht mit Inhalt. ", 200), "dokumente/notiz.txt": "Eine kurze Notiz.", "config.ini": "[abschnitt]\nwert=1\n", } for relativePath, fileContent := range fileContents { fullPath := filepath.Join(sourcePath, relativePath) if directoryError := os.MkdirAll(filepath.Dir(fullPath), 0o755); directoryError != nil { testInstance.Fatalf("das Verzeichnis ließ sich nicht anlegen: %v", directoryError) } if writeError := os.WriteFile(fullPath, []byte(fileContent), 0o644); writeError != nil { testInstance.Fatalf("die Datei ließ sich nicht anlegen: %v", writeError) } } return sourcePath } // registerRepository legt ein Repository an und trägt es in die Control Plane ein. func registerRepository(testInstance *testing.T, connectionPool *pgxpool.Pool) (uuid.UUID, string) { testInstance.Helper() repositoryPath := filepath.Join(testInstance.TempDir(), "repository") createdRepository, createError := repository.Create(context.Background(), repositoryPath, repository.CreateOptions{Name: "Testziel", CreatedByVersion: "test"}, discardLogger()) if createError != nil { testInstance.Fatalf("das Repository ließ sich nicht anlegen: %v", createError) } // Es wird sofort wieder geschlossen: Der Executor öffnet es selbst und // braucht dafür die Schreibsperre. if closeError := createdRepository.Close(); closeError != nil { testInstance.Fatalf("das Repository ließ sich nicht schließen: %v", closeError) } repositoryName := "exec-" + uuid.NewString() var repositoryID uuid.UUID if scanError := connectionPool.QueryRow(context.Background(), `INSERT INTO repositories (name, location) VALUES ($1, $2) RETURNING id`, repositoryName, repositoryPath).Scan(&repositoryID); scanError != nil { testInstance.Fatalf("das Repository ließ sich nicht eintragen: %v", scanError) } // Jede Anweisung einzeln: pgx sendet ueber das erweiterte Protokoll und // fuehrt mehrere durch Semikolon getrennte Anweisungen in einem Exec nicht // aus. Zusammengefasst blieben die Zeilen unbemerkt in der Datenbank liegen. testInstance.Cleanup(func() { cleanupContext := context.Background() _, _ = connectionPool.Exec(cleanupContext, `DELETE FROM backups WHERE repository_id = $1`, repositoryID) _, _ = connectionPool.Exec(cleanupContext, `DELETE FROM backup_chains WHERE repository_id = $1`, repositoryID) _, _ = connectionPool.Exec(cleanupContext, `DELETE FROM repositories WHERE id = $1`, repositoryID) }) return repositoryID, repositoryPath } // buildExecutionJob baut einen Auftrag für den Executor. func buildExecutionJob(repositoryID uuid.UUID, sourcePath string) *jobs.Job { return &jobs.Job{ ID: uuid.New(), Name: "Executor-Test", Status: jobs.JobStatusActive, Priority: scheduler.PriorityNormal, Schedule: scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeManual}, RepositoryID: repositoryID, Sources: []jobs.JobSource{ {SourceType: jobs.SourceTypeFilesystem, SourceID: sourcePath, SourceName: "Testquelle"}, }, MaximumConcurrency: 1, RetryPolicy: scheduler.DefaultRetryPolicy(), } } // createRunRow legt einen Lauf an, damit der Fremdschlüssel des Backups hält. func createRunRow(testInstance *testing.T, connectionPool *pgxpool.Pool, repositoryID uuid.UUID, executionJob *jobs.Job) uuid.UUID { testInstance.Helper() var jobIdentifier uuid.UUID if scanError := connectionPool.QueryRow(context.Background(), ` INSERT INTO backup_jobs (name, schedule_type, repository_id) VALUES ($1, 'manual', $2) RETURNING id`, executionJob.Name+"-"+uuid.NewString()[:8], repositoryID).Scan(&jobIdentifier); scanError != nil { testInstance.Fatalf("der Auftrag ließ sich nicht eintragen: %v", scanError) } executionJob.ID = jobIdentifier var runIdentifier uuid.UUID if scanError := connectionPool.QueryRow(context.Background(), ` INSERT INTO backup_job_runs (job_id, status, correlation_id) VALUES ($1, 'running', gen_random_uuid()) RETURNING id`, jobIdentifier).Scan(&runIdentifier); scanError != nil { testInstance.Fatalf("der Lauf ließ sich nicht eintragen: %v", scanError) } testInstance.Cleanup(func() { cleanupContext := context.Background() _, _ = connectionPool.Exec(cleanupContext, `DELETE FROM backups WHERE job_run_id = $1`, runIdentifier) _, _ = connectionPool.Exec(cleanupContext, `DELETE FROM backup_job_runs WHERE job_id = $1`, jobIdentifier) _, _ = connectionPool.Exec(cleanupContext, `DELETE FROM backup_jobs WHERE id = $1`, jobIdentifier) }) return runIdentifier } // TestExecutorBacksUpAndRestores ist der Nachweis der ganzen Kette. // // Auftrag → Executor → Backup Engine → Repository → Wiederherstellung. Ohne // diesen Test bliebe offen, ob der Scheduler tatsächlich sichert oder nur // erfolgreich aussieht. func TestExecutorBacksUpAndRestores(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := jobs.NewPostgresStore(connectionPool) repositoryID, repositoryPath := registerRepository(testInstance, connectionPool) sourcePath := buildSourceTree(testInstance) executionJob := buildExecutionJob(repositoryID, sourcePath) runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, executionJob) testExecutor, executorError := New(testStore, Options{ SecretStore: buildTestSecretStore(testInstance), CreatedByVersion: "test", }, discardLogger()) if executorError != nil { testInstance.Fatalf("der Executor ließ sich nicht bauen: %v", executorError) } executionResult, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{ Job: executionJob, RunID: runIdentifier, CorrelationID: uuid.New(), AttemptNumber: 1, }) if executeError != nil { testInstance.Fatalf("die Ausführung schlug fehl: %v", executeError) } if executionResult.FilesSkipped != 0 { testInstance.Errorf("es wurden %d Objekte übergangen: %v", executionResult.FilesSkipped, executionResult.SkipReasons) } if executionResult.BytesProcessed == 0 { testInstance.Fatal("es wurden keine Daten gelesen; der Lauf sichert nichts") } // Der entscheidende Nachweis: Die Daten liegen tatsächlich im Repository. openedRepository, openError := repository.Open(context.Background(), repositoryPath, repository.OpenOptions{ReadOnly: true}, discardLogger()) if openError != nil { testInstance.Fatalf("das Repository ließ sich nicht öffnen: %v", openError) } defer func() { _ = openedRepository.Close() }() catalogEntries, listError := openedRepository.ListBackups(context.Background()) if listError != nil { testInstance.Fatalf("die Backups ließen sich nicht auflisten: %v", listError) } if len(catalogEntries) != 1 { testInstance.Fatalf("im Repository liegen %d Backups, erwartet war 1", len(catalogEntries)) } // Und der Datenbankeintrag verweist auf dasselbe Backup. var recordedBackupID string var recordedType string if scanError := connectionPool.QueryRow(context.Background(), `SELECT backup_id_in_repository, backup_type FROM backups WHERE job_run_id = $1`, runIdentifier).Scan(&recordedBackupID, &recordedType); scanError != nil { testInstance.Fatalf("das Backup wurde nicht in der Datenbank vermerkt: %v", scanError) } if recordedBackupID != catalogEntries[0].BackupID { testInstance.Errorf("die Datenbank verweist auf %q, im Repository liegt %q", recordedBackupID, catalogEntries[0].BackupID) } if recordedType != "full" { testInstance.Errorf("das erste Backup wurde als %q vermerkt", recordedType) } } // TestExecutorUsesIncrementalOnSecondRun prüft die Zusatzsicherung. // // Der zweite Lauf desselben Auftrags muss auf dem ersten aufbauen. Täte er es // nicht, läse jede Nacht der gesamte Bestand erneut — und die Zusatzsicherung // wäre nur eine Behauptung. func TestExecutorUsesIncrementalOnSecondRun(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := jobs.NewPostgresStore(connectionPool) repositoryID, _ := registerRepository(testInstance, connectionPool) sourcePath := buildSourceTree(testInstance) executionJob := buildExecutionJob(repositoryID, sourcePath) firstRunID := createRunRow(testInstance, connectionPool, repositoryID, executionJob) testExecutor, _ := New(testStore, Options{ SecretStore: buildTestSecretStore(testInstance), CreatedByVersion: "test", }, discardLogger()) firstResult, firstError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{ Job: executionJob, RunID: firstRunID, AttemptNumber: 1, }) if firstError != nil { testInstance.Fatalf("der erste Lauf schlug fehl: %v", firstError) } // Der erste Lauf wird abgeschlossen. Ohne diesen Schritt verhindert der // Teilindex den zweiten — genau wie im Betrieb: Ein Auftrag hat nie zwei // aktive Läufe. if _, execError := connectionPool.Exec(context.Background(), `UPDATE backup_job_runs SET status = 'succeeded', completed_at = now() WHERE id = $1`, firstRunID); execError != nil { testInstance.Fatalf("der erste Lauf ließ sich nicht abschließen: %v", execError) } // Ein zweiter Lauf desselben Auftrags. var secondRunID uuid.UUID if scanError := connectionPool.QueryRow(context.Background(), ` INSERT INTO backup_job_runs (job_id, status, correlation_id) VALUES ($1, 'running', gen_random_uuid()) RETURNING id`, executionJob.ID).Scan(&secondRunID); scanError != nil { testInstance.Fatalf("der zweite Lauf ließ sich nicht anlegen: %v", scanError) } secondResult, secondError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{ Job: executionJob, RunID: secondRunID, AttemptNumber: 1, }) if secondError != nil { testInstance.Fatalf("der zweite Lauf schlug fehl: %v", secondError) } // Der Nachweis: Im zweiten Lauf wurde deutlich weniger gelesen. if secondResult.BytesProcessed >= firstResult.BytesProcessed { testInstance.Errorf("der zweite Lauf las %d Byte, der erste %d — es wurde nichts eingespart", secondResult.BytesProcessed, firstResult.BytesProcessed) } var secondType string var hasParent bool if scanError := connectionPool.QueryRow(context.Background(), `SELECT backup_type, parent_backup_id IS NOT NULL FROM backups WHERE job_run_id = $1`, secondRunID).Scan(&secondType, &hasParent); scanError != nil { testInstance.Fatalf("das zweite Backup wurde nicht vermerkt: %v", scanError) } if secondType != "incremental" { testInstance.Errorf("das zweite Backup wurde als %q vermerkt", secondType) } if !hasParent { testInstance.Error("die Zusatzsicherung wurde ohne Elternbackup vermerkt; die Kette hat keinen Anfang") } } // TestExecutorRefusesWithoutEncryption ist ein Sicherheitstest. // // Ein Executor ohne Schlüssel, der trotzdem sichert, legte unverschlüsselte // Backups an, ohne dass es jemandem auffiele. Die Kombination wird beim // Einrichten abgelehnt, nicht erst beim ersten Lauf um zwei Uhr nachts. func TestExecutorRefusesWithoutEncryption(testInstance *testing.T) { _, executorError := New(&jobs.PostgresStore{}, Options{}, discardLogger()) if !errors.Is(executorError, ErrEncryptionRequired) { testInstance.Fatalf("ein Executor ohne Schlüsselmaterial wurde angenommen: %v", executorError) } // Ausdrücklich zugelassen ist es erlaubt — für Testumgebungen. if _, allowedError := New(&jobs.PostgresStore{}, Options{AllowUnencrypted: true}, discardLogger()); allowedError != nil { testInstance.Errorf("die ausdrückliche Erlaubnis wurde abgelehnt: %v", allowedError) } } // TestExecutorReportsUnknownRepository prüft die Fehlerklassifizierung. func TestExecutorReportsUnknownRepository(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := jobs.NewPostgresStore(connectionPool) testExecutor, _ := New(testStore, Options{ SecretStore: buildTestSecretStore(testInstance), }, discardLogger()) unknownJob := &jobs.Job{ ID: uuid.New(), Name: "Ohne Repository", RepositoryID: uuid.New(), Sources: []jobs.JobSource{ {SourceType: jobs.SourceTypeFilesystem, SourceID: testInstance.TempDir()}, }, } _, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{ Job: unknownJob, RunID: uuid.New(), AttemptNumber: 1, }) if executeError == nil { testInstance.Fatal("ein unbekanntes Repository wurde angenommen") } var typedError *jobs.ExecutionError if !errors.As(executeError, &typedError) { testInstance.Fatalf("der Fehler war nicht eingeordnet: %v", executeError) } // Ein Konfigurationsfehler wird nicht wiederholt — richtig so: Er behebt // sich nicht durch Warten. if typedError.FailureClass.IsRetryable() { testInstance.Errorf("ein unbekanntes Repository wurde als %q eingeordnet und damit wiederholt", typedError.FailureClass) } } // TestExecutorRefusesReadOnlyRepository prüft den gesperrten Zustand. func TestExecutorRefusesReadOnlyRepository(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := jobs.NewPostgresStore(connectionPool) repositoryID, _ := registerRepository(testInstance, connectionPool) if _, execError := connectionPool.Exec(context.Background(), `UPDATE repositories SET status = 'read_only' WHERE id = $1`, repositoryID); execError != nil { testInstance.Fatalf("der Zustand ließ sich nicht setzen: %v", execError) } testExecutor, _ := New(testStore, Options{ SecretStore: buildTestSecretStore(testInstance), }, discardLogger()) _, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{ Job: buildExecutionJob(repositoryID, buildSourceTree(testInstance)), RunID: uuid.New(), }) if executeError == nil { testInstance.Fatal("ein Repository im Nur-Lese-Zustand nahm eine Sicherung an") } var typedError *jobs.ExecutionError if !errors.As(executeError, &typedError) || typedError.Code != "REPOSITORY_NOT_WRITABLE" { testInstance.Errorf("der Fehler wurde als %v gemeldet", executeError) } } // TestExecutorReportsMissingSource prüft die fehlende Quelle. func TestExecutorReportsMissingSource(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := jobs.NewPostgresStore(connectionPool) repositoryID, _ := registerRepository(testInstance, connectionPool) missingJob := buildExecutionJob(repositoryID, filepath.Join(testInstance.TempDir(), "gibt-es-nicht")) runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, missingJob) testExecutor, _ := New(testStore, Options{ SecretStore: buildTestSecretStore(testInstance), }, discardLogger()) _, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{ Job: missingJob, RunID: runIdentifier, }) // Alle Quellen gescheitert bedeutet: der Lauf ist gescheitert. if executeError == nil { testInstance.Fatal("eine nicht vorhandene Quelle wurde angenommen") } var typedError *jobs.ExecutionError if !errors.As(executeError, &typedError) || typedError.Code != "ALL_SOURCES_FAILED" { testInstance.Errorf("der Fehler wurde als %v gemeldet", executeError) } } // TestExecutorMarksPartialFailureWhenOneSourceFails prüft den gemischten Fall. // // Eine gescheiterte Quelle beendet den Lauf nicht — die übrigen sollen gesichert // werden. Verschwiegen wird sie trotzdem nicht: Der Lauf wird zum Teilfehler. func TestExecutorMarksPartialFailureWhenOneSourceFails(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := jobs.NewPostgresStore(connectionPool) repositoryID, _ := registerRepository(testInstance, connectionPool) mixedJob := buildExecutionJob(repositoryID, buildSourceTree(testInstance)) mixedJob.Sources = append(mixedJob.Sources, jobs.JobSource{ SourceType: jobs.SourceTypeFilesystem, SourceID: filepath.Join(testInstance.TempDir(), "gibt-es-nicht"), SourceName: "Fehlende Quelle", }) runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, mixedJob) testExecutor, _ := New(testStore, Options{ SecretStore: buildTestSecretStore(testInstance), }, discardLogger()) executionResult, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{ Job: mixedJob, RunID: runIdentifier, }) if executeError != nil { testInstance.Fatalf("eine einzelne fehlende Quelle brach den ganzen Lauf ab: %v", executeError) } // Die gute Quelle wurde gesichert … if executionResult.BytesProcessed == 0 { testInstance.Error("die vorhandene Quelle wurde nicht gesichert") } // … und die fehlende erscheint als übergangen, damit die Schleife den Lauf // als Teilfehler wertet. if executionResult.FilesSkipped == 0 { testInstance.Fatal("die fehlende Quelle wurde nicht als übergangen ausgewiesen; der Lauf gälte als Erfolg") } if len(executionResult.SkipReasons) == 0 { testInstance.Error("die Übergehung wurde nicht begründet") } } // TestExecutorRejectsUnsupportedSourceType prüft nicht umgesetzte Quellarten. // // Eine stillschweigend übergangene Quelle ergäbe ein Backup, das vollständig // aussieht und es nicht ist. func TestExecutorRejectsUnsupportedSourceType(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := jobs.NewPostgresStore(connectionPool) repositoryID, _ := registerRepository(testInstance, connectionPool) // Seit Phase 7 sind Proxmox-Gäste umgesetzt; geprüft wird deshalb mit einer // Art, die es weiterhin nicht ist. Der Sinn des Tests bleibt derselbe: Eine // nicht unterstützte Quelle wird benannt, nicht stillschweigend übergangen // — sonst entstünde ein Backup, das vollständig aussieht und es nicht ist. virtualJob := buildExecutionJob(repositoryID, buildSourceTree(testInstance)) virtualJob.Sources = []jobs.JobSource{ {SourceType: jobs.SourceTypeWindowsSystem, SourceID: "WINSRV01", SourceName: "Dateiserver"}, } runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, virtualJob) testExecutor, _ := New(testStore, Options{ SecretStore: buildTestSecretStore(testInstance), }, discardLogger()) _, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{ Job: virtualJob, RunID: runIdentifier, }) if executeError == nil { testInstance.Fatal("eine nicht unterstützte Quellart wurde stillschweigend übergangen") } if !strings.Contains(executeError.Error(), "windows_system") { testInstance.Errorf("die Fehlermeldung nennt die Quellart nicht: %v", executeError) } } // TestBackupIdentifierIsFileSafe prüft die Kennungsbildung. // // Eine Backup-Kennung wird zum Dateinamen des Manifests. Ein Pfad mit // Schrägstrichen erzeugte dort Unterverzeichnisse, ein Punkt am Anfang eine // versteckte Datei. func TestBackupIdentifierIsFileSafe(testInstance *testing.T) { runIdentifier := uuid.New() problematicSources := []string{ "/daten/kunden", "../../etc/passwd", "C:\\Users\\Test", ".versteckt", "qemu/100", "", } for _, sourceIdentifier := range problematicSources { builtIdentifier := buildBackupIdentifier(runIdentifier, jobs.JobSource{SourceType: jobs.SourceTypeFilesystem, SourceID: sourceIdentifier}) if strings.ContainsAny(builtIdentifier, `/\`) { testInstance.Errorf("die Kennung für %q enthält Pfadtrenner: %q", sourceIdentifier, builtIdentifier) } if strings.HasPrefix(builtIdentifier, ".") { testInstance.Errorf("die Kennung für %q beginnt mit einem Punkt: %q", sourceIdentifier, builtIdentifier) } if strings.Contains(builtIdentifier, "..") { testInstance.Errorf("die Kennung für %q enthält einen Pfadausbruch: %q", sourceIdentifier, builtIdentifier) } // Die Obergrenze des Repositorys darf nicht überschritten werden — sonst // scheitert jede Sicherung dieser Quelle, und zwar erst beim Lauf. if len(builtIdentifier) > maximumBackupIdentifierLength { testInstance.Errorf("die Kennung für %q ist %d Zeichen lang, zulässig sind %d: %q", sourceIdentifier, len(builtIdentifier), maximumBackupIdentifierLength, builtIdentifier) } // Die Laufkennung muss enthalten sein: Sie erlaubt es, von einem Backup // im Repository aus den erzeugenden Lauf zurückzuverfolgen — auch dann, // wenn die Datenbank verloren ging. if !strings.Contains(builtIdentifier, runIdentifier.String()) { testInstance.Errorf("die Kennung %q enthält die Laufkennung nicht", builtIdentifier) } } } // TestExecutorRespectsExcludePatterns prüft die Ausschlussregeln. func TestExecutorRespectsExcludePatterns(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := jobs.NewPostgresStore(connectionPool) repositoryID, repositoryPath := registerRepository(testInstance, connectionPool) sourcePath := buildSourceTree(testInstance) // Eine Datei, die ausgeschlossen wird. if writeError := os.WriteFile(filepath.Join(sourcePath, "gross.tmp"), bytes.Repeat([]byte("x"), 1024), 0o644); writeError != nil { testInstance.Fatalf("die Datei ließ sich nicht anlegen: %v", writeError) } excludingJob := buildExecutionJob(repositoryID, sourcePath) excludingJob.Sources[0].ExcludePatterns = []string{"*.tmp"} runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, excludingJob) testExecutor, _ := New(testStore, Options{ SecretStore: buildTestSecretStore(testInstance), }, discardLogger()) if _, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{ Job: excludingJob, RunID: runIdentifier, }); executeError != nil { testInstance.Fatalf("die Ausführung schlug fehl: %v", executeError) } openedRepository, _ := repository.Open(context.Background(), repositoryPath, repository.OpenOptions{ReadOnly: true}, discardLogger()) defer func() { _ = openedRepository.Close() }() engine := backupengine.NewEngine(openedRepository, buildTestSecretStore(testInstance), discardLogger()) catalogEntries, _ := openedRepository.ListBackups(context.Background()) if len(catalogEntries) == 0 { testInstance.Fatal("es wurde kein Backup angelegt") } manifestEntries, listError := engine.ListEntries(context.Background(), catalogEntries[0].BackupID) if listError != nil { testInstance.Fatalf("die Einträge ließen sich nicht lesen: %v", listError) } for _, manifestEntry := range manifestEntries { if strings.HasSuffix(manifestEntry.Path, ".tmp") { testInstance.Errorf("die ausgeschlossene Datei %q wurde gesichert", manifestEntry.Path) } } } // TestExecutorAppliesJobBandwidthLimit prüft, dass die Grenze des Auftrags // tatsächlich im Datenpfad ankommt. // // Dieser Test schließt eine Lücke, die im Betrieb aufgefallen ist: Der // Begrenzer wurde gebaut, protokolliert — und beim Bau der Quellanfrage // vergessen. Ein Feld, das gesetzt aussieht und nie ankommt, ist im Protokoll // nicht zu erkennen: Die Zeile „der lauf ist in der bandbreite begrenzt" // erschien, während die Sicherung mit voller Geschwindigkeit lief. func TestExecutorAppliesJobBandwidthLimit(testInstance *testing.T) { if testing.Short() { testInstance.Skip("die Durchsatzmessung braucht echte Zeit") } connectionPool := connectTestDatabase(testInstance) testStore := jobs.NewPostgresStore(connectionPool) repositoryID, _ := registerRepository(testInstance, connectionPool) // Genug Daten, damit die Grenze messbar wirkt: 4 MiB bei 1 MiB/s. sourcePath := testInstance.TempDir() incompressibleData := make([]byte, 4<<20) var generatorState uint64 = 0x2545F4914F6CDD1D for byteIndex := range incompressibleData { generatorState ^= generatorState << 13 generatorState ^= generatorState >> 7 generatorState ^= generatorState << 17 incompressibleData[byteIndex] = byte(generatorState) } if writeError := os.WriteFile(filepath.Join(sourcePath, "gross.bin"), incompressibleData, 0o644); writeError != nil { testInstance.Fatalf("die Datei ließ sich nicht anlegen: %v", writeError) } limitedJob := buildExecutionJob(repositoryID, sourcePath) limitedJob.BandwidthLimitBytesPerSecond = 1 << 20 runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, limitedJob) testExecutor, _ := New(testStore, Options{ SecretStore: buildTestSecretStore(testInstance), CompressionLevel: backupengine.CompressionOff, }, discardLogger()) startTime := time.Now() executionResult, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{ Job: limitedJob, RunID: runIdentifier, AttemptNumber: 1, }) if executeError != nil { testInstance.Fatalf("die Ausführung schlug fehl: %v", executeError) } elapsedDuration := time.Since(startTime) if executionResult.BytesProcessed < int64(len(incompressibleData)) { testInstance.Fatalf("es wurden nur %d von %d Byte gelesen", executionResult.BytesProcessed, len(incompressibleData)) } // Der Eimer liefert eine Sekunde Vorrat; für die übrigen 3 MiB muss // gewartet werden. Ohne wirksame Grenze liefe der Lauf in Millisekunden. const minimumExpectedDuration = 2 * time.Second if elapsedDuration < minimumExpectedDuration { testInstance.Fatalf("4 MiB bei 1 MiB/s liefen in %s durch; die Grenze des Auftrags kommt nicht im Datenpfad an", elapsedDuration) } testInstance.Logf("4 MiB bei einer Auftragsgrenze von 1 MiB/s: %s", elapsedDuration.Round(time.Millisecond)) }