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>
719 lines
27 KiB
Go
719 lines
27 KiB
Go
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))
|
|
}
|