package backupexecutor import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/syncova/syncova/packages/hypervisor" "github.com/syncova/syncova/packages/jobs" "github.com/syncova/syncova/packages/repository" "github.com/syncova/syncova/packages/scheduler" ) // TestExecutorBacksUpProxmoxGuest ist der Nachweis der Proxmox-Kette. // // Auftrag mit einer Gastquelle → Executor → Provider → vzdump → Archiv als // Datenstrom → Backup Engine → Repository → Manifest. Bis zu dieser Phase // lehnte der Executor eine solche Quelle mit „kann noch nicht gesichert // werden" ab; ein Auftrag liess sich anlegen und scheiterte beim Lauf. // // Der Test prüft nicht nur, dass es durchläuft, sondern **was im Repository // liegt**: das Plattenabbild mit dem unveränderten Archivinhalt und die // Gastkonfiguration daneben. Ohne die zweite ließe sich die Maschine zwar mit // ihren Daten, aber nicht in ihrer Gestalt wiederherstellen. func TestExecutorBacksUpProxmoxGuest(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := jobs.NewPostgresStore(connectionPool) repositoryID, repositoryPath := registerRepository(testInstance, connectionPool) // Der Nachbau legt sein Archiv in ein Verzeichnis, das zugleich als // Proxmox-Speicher eingehängt gilt — genau der Fall „gemeinsame Freigabe". storageRoot := testInstance.TempDir() fakeProxmox := startFakeProxmoxNode(testInstance, storageRoot) hypervisorStore := buildHypervisorStore(testInstance, connectionPool) createdCluster, createError := hypervisorStore.CreateCluster(context.Background(), hypervisor.ClusterInput{ Name: "verbund-" + uuid.NewString(), APIEndpoint: fakeProxmox.baseURL, APITokenID: "syncova@pve!backup", APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert BackupStorageID: "local", // Der Nachbau trägt ein selbstsigniertes Zertifikat; die Bindung über // den Fingerabdruck wird im Provider-Paket gesondert geprüft. TLSFingerprint: fakeProxmox.certificateFingerprint, ArchiveTransport: hypervisor.TransportLocal, ArchiveMountRoots: map[string]string{"local": storageRoot}, }) if createError != nil { testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError) } testInstance.Cleanup(func() { _ = hypervisorStore.DeleteCluster(context.Background(), createdCluster.ID) }) testExecutor := buildProxmoxExecutor(testInstance, testStore, hypervisorStore) executionJob := &jobs.Job{ Name: "Proxmox-Test", Status: jobs.JobStatusActive, Priority: scheduler.PriorityNormal, Schedule: scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeManual}, RepositoryID: repositoryID, Sources: []jobs.JobSource{ { SourceType: jobs.SourceTypeProxmoxVM, SourceID: "qemu/100", SourceName: "web-01", ClusterID: &createdCluster.ID, }, }, MaximumConcurrency: 1, RetryPolicy: scheduler.DefaultRetryPolicy(), } runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, executionJob) executionResult, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{ Job: executionJob, RunID: runIdentifier, }) if executeError != nil { testInstance.Fatalf("die Sicherung des Gasts schlug fehl: %v", executeError) } // Die ausgenommene Platte (backup=0) macht den Lauf zum Teilfehler. Das ist // gewollt: Die Wiederherstellung liefert eine unvollständige Maschine, und // wer es nicht erfährt, hält sie für vollständig. if executionResult.FilesSkipped != 1 { testInstance.Errorf("es wurden %d Objekte übergangen, erwartet war die ausgenommene Platte", executionResult.FilesSkipped) } if len(executionResult.SkipReasons) != 1 || !strings.Contains(executionResult.SkipReasons[0], "backup=0") { testInstance.Errorf("der Grund der Auslassung ist nicht benannt: %v", executionResult.SkipReasons) } if executionResult.BytesProcessed < int64(len(fakeProxmox.archiveContent)) { testInstance.Errorf("es wurden nur %d Byte gelesen, das Archiv hat %d", executionResult.BytesProcessed, len(fakeProxmox.archiveContent)) } // Das vzdump-Archiv darf nach dem Lauf nicht mehr auf dem Knoten liegen: // Sonst füllte jede Sicherung den Proxmox-Speicher mit einer zweiten, // unverwalteten Kopie derselben Daten. if len(fakeProxmox.deletedVolumes()) != 1 { testInstance.Errorf("das Archiv wurde nicht vom Knoten entfernt: %v", fakeProxmox.deletedVolumes()) } assertGuestBackupContents(testInstance, repositoryPath, fakeProxmox.archiveContent) } // assertGuestBackupContents prueft, was tatsaechlich im Repository liegt. func assertGuestBackupContents(testInstance *testing.T, repositoryPath string, expectedArchive []byte) { testInstance.Helper() openedRepository, openError := repository.Open(context.Background(), repositoryPath, repository.OpenOptions{}, 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 lesen: %v", listError) } if len(catalogEntries) != 1 { testInstance.Fatalf("es liegen %d Backups im Repository statt eines", len(catalogEntries)) } storedManifest, readError := openedRepository.ReadManifest(context.Background(), catalogEntries[0].BackupID) if readError != nil { testInstance.Fatalf("das Manifest ließ sich nicht lesen: %v", readError) } // Die Kennzahlen dürfen nicht null sein — der Fund aus Phase 18: Die Engine // schrieb einmal am Zählwerk der Schreibsession vorbei, und jedes Manifest // trug logical_bytes 0, ohne dass ein Test es bemerkte. if storedManifest.Statistics.LogicalBytes == 0 { testInstance.Error("das Manifest weist keine gesicherte Datenmenge aus") } var sawArchive, sawConfiguration bool for _, manifestEntry := range storedManifest.Entries { switch manifestEntry.Path { case guestArchiveEntryPath: sawArchive = true if manifestEntry.EntryType != "disk" { testInstance.Errorf("das Plattenabbild ist als %q vermerkt", manifestEntry.EntryType) } if manifestEntry.SizeBytes != int64(len(expectedArchive)) { testInstance.Errorf("das Abbild ist %d Byte groß, das Archiv hat %d", manifestEntry.SizeBytes, len(expectedArchive)) } case guestConfigurationEntryPath: sawConfiguration = true } } if !sawArchive { testInstance.Error("im Manifest fehlt das Plattenabbild des Gasts") } if !sawConfiguration { testInstance.Error("im Manifest fehlt die Gastkonfiguration") } // Die Quellangaben müssen den Verbund benennen: Ohne sie ließe sich nach // einem Totalverlust nicht mehr feststellen, woher das Backup stammt. if storedManifest.Source.SourceType != string(jobs.SourceTypeProxmoxVM) { testInstance.Errorf("die Quellart ist %q", storedManifest.Source.SourceType) } if storedManifest.Source.Attributes["guest_id"] != "qemu/100" { testInstance.Errorf("der Gast ist nicht vermerkt: %v", storedManifest.Source.Attributes) } } // TestExecutorRejectsProxmoxSourceWithoutCluster prueft die Konfigurationsluecke. // // Ein Lauf, der ohne Verbund stillschweigend nichts täte, wäre der // gefährlichste Fall: ein grüner Lauf ohne Daten. func TestExecutorRejectsProxmoxSourceWithoutCluster(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := jobs.NewPostgresStore(connectionPool) repositoryID, _ := registerRepository(testInstance, connectionPool) hypervisorStore := buildHypervisorStore(testInstance, connectionPool) testExecutor := buildProxmoxExecutor(testInstance, testStore, hypervisorStore) executionJob := &jobs.Job{ Name: "Proxmox-ohne-Verbund", Status: jobs.JobStatusActive, Priority: scheduler.PriorityNormal, Schedule: scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeManual}, RepositoryID: repositoryID, Sources: []jobs.JobSource{ {SourceType: jobs.SourceTypeProxmoxVM, SourceID: "qemu/100", SourceName: "web-01"}, }, MaximumConcurrency: 1, RetryPolicy: scheduler.DefaultRetryPolicy(), } runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, executionJob) _, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{ Job: executionJob, RunID: runIdentifier, }) if executeError == nil { testInstance.Fatal("eine Proxmox-Quelle ohne Verbund wurde gesichert") } if !strings.Contains(executeError.Error(), "Virtualisierungsverbund") { testInstance.Errorf("die Meldung nennt den Grund nicht: %v", executeError) } } // buildProxmoxExecutor baut einen Executor mit Virtualisierungsverwaltung. func buildProxmoxExecutor(testInstance *testing.T, testStore *jobs.PostgresStore, hypervisorStore *hypervisor.Store) *Executor { testInstance.Helper() builtExecutor, buildError := New(testStore, Options{ SecretStore: buildTestSecretStore(testInstance), HypervisorStore: hypervisorStore, CreatedByVersion: "test", }, discardLogger()) if buildError != nil { testInstance.Fatalf("der Executor ließ sich nicht bauen: %v", buildError) } return builtExecutor } // buildHypervisorStore baut die Virtualisierungsverwaltung für den Test. func buildHypervisorStore(testInstance *testing.T, connectionPool *pgxpool.Pool) *hypervisor.Store { testInstance.Helper() builtStore, buildError := hypervisor.NewStore(connectionPool, buildTestSecretStore(testInstance)) if buildError != nil { testInstance.Fatalf("die Virtualisierungsverwaltung ließ sich nicht bauen: %v", buildError) } return builtStore } // fakeProxmoxNode ist ein Nachbau der Proxmox-API für den Sicherungsweg. // // Bewusst eigenständig und nicht der Nachbau aus dem Provider-Paket: Der liegt // in einer _test.go und lässt sich nicht importieren. Ihn dafür in den // Produktivcode zu heben hieße, eine Attrappe auszuliefern. type fakeProxmoxNode struct { // baseURL ist die Adresse des Nachbaus. baseURL string // certificateFingerprint ist der SHA-256-Fingerabdruck seines Zertifikats. certificateFingerprint string // archiveContent ist der Inhalt des erzeugten Archivs. archiveContent []byte // storageRoot ist das Verzeichnis des Sicherungsspeichers. storageRoot string // mutex schützt die veränderlichen Felder. mutex sync.Mutex // removedVolumes sammelt die entfernten Archive. removedVolumes []string // archiveVolume ist die Kennung des erzeugten Archivs. archiveVolume string // restoreCalled meldet, ob Proxmox zur Wiederherstellung aufgefordert wurde. restoreCalled bool // appliedConfiguration ist die nachgezogene Gastkonfiguration. appliedConfiguration string } // configurationApplied liefert die nachgezogene Konfiguration. func (fakeNode *fakeProxmoxNode) configurationApplied() string { fakeNode.mutex.Lock() defer fakeNode.mutex.Unlock() return fakeNode.appliedConfiguration } // sawRestoreCall meldet, ob eine Wiederherstellung angestoßen wurde. func (fakeNode *fakeProxmoxNode) sawRestoreCall() bool { fakeNode.mutex.Lock() defer fakeNode.mutex.Unlock() return fakeNode.restoreCalled } // deletedVolumes liefert die entfernten Archive. func (fakeNode *fakeProxmoxNode) deletedVolumes() []string { fakeNode.mutex.Lock() defer fakeNode.mutex.Unlock() return append([]string(nil), fakeNode.removedVolumes...) } // startFakeProxmoxNode startet den Nachbau. func startFakeProxmoxNode(testInstance *testing.T, storageRoot string) *fakeProxmoxNode { testInstance.Helper() // Inkompressibler Inhalt: Ein periodisches Muster ergäbe eine // Kompressionsrate, die nichts über die Anlage aussagt (der Fund aus // Phase 4). archiveContent := make([]byte, 3*1024*1024) for byteIndex := range archiveContent { archiveContent[byteIndex] = byte((byteIndex*2654435761 + byteIndex/7) % 251) } fakeNode := &fakeProxmoxNode{ archiveContent: archiveContent, storageRoot: storageRoot, } testServer := httptest.NewTLSServer(http.HandlerFunc(fakeNode.handleRequest)) testInstance.Cleanup(testServer.Close) fakeNode.baseURL = testServer.URL certificateDigest := sha256.Sum256(testServer.Certificate().Raw) fakeNode.certificateFingerprint = hex.EncodeToString(certificateDigest[:]) return fakeNode } // handleRequest bedient die vom Sicherungsweg benötigten Endpunkte. func (fakeNode *fakeProxmoxNode) handleRequest(responseWriter http.ResponseWriter, request *http.Request) { apiPath := strings.TrimPrefix(request.URL.Path, "/api2/json") _ = request.ParseForm() switch { case apiPath == "/version": writeProxmoxData(responseWriter, map[string]any{"version": "8.2.2", "release": "8.2"}) case apiPath == "/nodes": writeProxmoxData(responseWriter, []map[string]any{ {"node": "pve-01", "status": "online", "maxcpu": 16, "maxmem": 68719476736}, }) case apiPath == "/nodes/pve-01/qemu/900/config": // Die Konfiguration des wiederhergestellten Gasts wird nachgezogen — // insbesondere die MAC-Adressen. Sie müssen erhalten bleiben: // Lizenzbindungen und DHCP-Reservierungen hängen daran. fakeNode.mutex.Lock() fakeNode.appliedConfiguration = request.PostForm.Encode() fakeNode.mutex.Unlock() writeProxmoxData(responseWriter, nil) case apiPath == "/nodes/pve-01/qemu/100/status/current": writeProxmoxData(responseWriter, map[string]any{"status": "stopped", "vmid": 100}) case apiPath == "/cluster/resources": writeProxmoxData(responseWriter, []map[string]any{ {"type": "qemu", "vmid": 100, "node": "pve-01", "name": "web-01", "status": "running"}, }) case apiPath == "/nodes/pve-01/qemu/100/config": writeProxmoxData(responseWriter, map[string]any{ "name": "web-01", "cores": 4, "memory": 8192, "bios": "ovmf", "scsi0": "local-lvm:vm-100-disk-0,size=32G", // Diese Platte ist von der Sicherung ausgenommen — der Lauf muss // sie ausweisen, nicht verschweigen. "scsi1": "local-lvm:vm-100-disk-1,size=500G,backup=0", "net0": "virtio=AA:BB:CC:DD:EE:01,bridge=vmbr0", }) case request.Method == http.MethodPost && apiPath == "/nodes/pve-01/qemu": fakeNode.mutex.Lock() fakeNode.restoreCalled = true fakeNode.mutex.Unlock() writeProxmoxData(responseWriter, "UPID:pve-01:00005678:00ABCDEF:66B00000:qmrestore::root@pam:") case apiPath == "/nodes/pve-01/vzdump": fakeNode.createArchive(responseWriter) case strings.HasPrefix(apiPath, "/nodes/pve-01/tasks/"): fakeNode.handleTask(responseWriter, apiPath) case apiPath == "/nodes/pve-01/storage/local/content": fakeNode.mutex.Lock() volumeIdentifier := fakeNode.archiveVolume fakeNode.mutex.Unlock() if volumeIdentifier == "" { writeProxmoxData(responseWriter, []map[string]any{}) return } writeProxmoxData(responseWriter, []map[string]any{ {"volid": volumeIdentifier, "ctime": 1723500000, "size": len(fakeNode.archiveContent)}, }) case request.Method == http.MethodDelete && strings.Contains(apiPath, "/storage/local/content/"): fakeNode.mutex.Lock() fakeNode.removedVolumes = append(fakeNode.removedVolumes, apiPath) fakeNode.archiveVolume = "" fakeNode.mutex.Unlock() writeProxmoxData(responseWriter, "UPID:pve-01:00000001:00000001:66000000:imgdel::root@pam:") default: http.Error(responseWriter, `{"message":"not found"}`, http.StatusNotFound) } } // createArchive legt das vzdump-Archiv als echte Datei an. func (fakeNode *fakeProxmoxNode) createArchive(responseWriter http.ResponseWriter) { dumpDirectory := filepath.Join(fakeNode.storageRoot, "dump") if makeError := os.MkdirAll(dumpDirectory, 0o700); makeError != nil { http.Error(responseWriter, makeError.Error(), http.StatusInternalServerError) return } archiveName := "vzdump-qemu-100-2026_08_13-02_00_00.vma" if writeError := os.WriteFile(filepath.Join(dumpDirectory, archiveName), fakeNode.archiveContent, 0o600); writeError != nil { http.Error(responseWriter, writeError.Error(), http.StatusInternalServerError) return } fakeNode.mutex.Lock() fakeNode.archiveVolume = "local:dump/" + archiveName fakeNode.mutex.Unlock() writeProxmoxData(responseWriter, "UPID:pve-01:00001234:00ABCDEF:66B00000:vzdump::root@pam:") } // handleTask beantwortet die Statusabfrage einer Aufgabe. // // Der Ablauf ist bei Proxmox asynchron: Der ändernde Aufruf antwortet mit einer // Kennung, das Ergebnis steht im Exit-Status — nicht im HTTP-Status. func (fakeNode *fakeProxmoxNode) handleTask(responseWriter http.ResponseWriter, apiPath string) { if strings.HasSuffix(apiPath, "/log") { writeProxmoxData(responseWriter, []map[string]any{{"n": 1, "t": "TASK OK"}}) return } writeProxmoxData(responseWriter, map[string]any{ "status": "stopped", "exitstatus": "OK", "type": "vzdump", "node": "pve-01", }) } // writeProxmoxData schreibt eine Antwort in der Proxmox-Hülle. func writeProxmoxData(responseWriter http.ResponseWriter, payload any) { responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{"data": payload}) } // TestProxmoxGuestRoundTripIsByteExact ist der Nachweis der gesamten Kette. // // Sichern → Repository → Wiederherstellen → **bitgenau vergleichen**. Das ist // der Punkt, an dem sich zeigt, ob die Anlage einen Gast tatsächlich // zurückbringt: Ein Backup, das man nicht zurückspielen kann, ist keines // (PROMPT.md §141). // // Was dieser Test **nicht** belegt: dass die wiederhergestellte Maschine // startet. Das kann nur ein echter Proxmox-Knoten, und genau daran hängt der // verpflichtende Meilenstein der Phase 7. func TestProxmoxGuestRoundTripIsByteExact(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) testStore := jobs.NewPostgresStore(connectionPool) repositoryID, repositoryPath := registerRepository(testInstance, connectionPool) storageRoot := testInstance.TempDir() fakeProxmox := startFakeProxmoxNode(testInstance, storageRoot) hypervisorStore := buildHypervisorStore(testInstance, connectionPool) createdCluster, createError := hypervisorStore.CreateCluster(context.Background(), hypervisor.ClusterInput{ Name: "verbund-" + uuid.NewString(), APIEndpoint: fakeProxmox.baseURL, APITokenID: "syncova@pve!backup", APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert BackupStorageID: "local", TLSFingerprint: fakeProxmox.certificateFingerprint, ArchiveTransport: hypervisor.TransportLocal, ArchiveMountRoots: map[string]string{"local": storageRoot}, }) if createError != nil { testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError) } testInstance.Cleanup(func() { _ = hypervisorStore.DeleteCluster(context.Background(), createdCluster.ID) }) testExecutor := buildProxmoxExecutor(testInstance, testStore, hypervisorStore) executionJob := &jobs.Job{ Name: "Proxmox-Rundlauf", Status: jobs.JobStatusActive, Priority: scheduler.PriorityNormal, Schedule: scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeManual}, RepositoryID: repositoryID, Sources: []jobs.JobSource{ { SourceType: jobs.SourceTypeProxmoxVM, SourceID: "qemu/100", SourceName: "web-01", ClusterID: &createdCluster.ID, }, }, MaximumConcurrency: 1, RetryPolicy: scheduler.DefaultRetryPolicy(), } runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, executionJob) if _, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{ Job: executionJob, RunID: runIdentifier, }); executeError != nil { testInstance.Fatalf("die Sicherung schlug fehl: %v", executeError) } backupIdentifier := findSingleBackupIdentifier(testInstance, repositoryPath) // Die Quelle wird beseitigt: Der Nachbau liefert das Archiv nicht mehr, // und was jetzt zurückkommt, kommt ausschließlich aus dem Repository. if removeError := os.RemoveAll(filepath.Join(storageRoot, "dump")); removeError != nil { testInstance.Fatalf("das Quellarchiv ließ sich nicht entfernen: %v", removeError) } restoreResult, restoreError := hypervisorStore.RestoreGuest(context.Background(), hypervisor.GuestRestoreRequest{ ClusterID: createdCluster.ID, RepositoryPath: repositoryPath, BackupID: backupIdentifier, TargetNode: "pve-01", // Neben das Original, nicht darüber: Der Ursprungsgast existiert // noch, und eine Wiederherstellung, die ihn überschreibt, würde // genau das vernichten, was man prüfen wollte. Das ist zugleich // der Weg, den ein Wiederherstellungstest (Phase 10) auf echter // Hardware nimmt. TargetGuestID: "qemu/900", // Das bereitgestellte Archiv bleibt liegen, damit der Test es // vergleichen kann. Im Betrieb wird es abgeräumt. KeepStagedArchive: true, }, buildTestSecretStore(testInstance), discardLogger()) if restoreError != nil { testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError) } if restoreResult.BytesStaged != int64(len(fakeProxmox.archiveContent)) { testInstance.Errorf("es wurden %d Byte bereitgestellt, das Archiv hat %d", restoreResult.BytesStaged, len(fakeProxmox.archiveContent)) } // Der eigentliche Nachweis: Byte für Byte identisch. stagedArchivePath := filepath.Join(storageRoot, "dump", "syncova-restore-"+sanitizeForFileName(backupIdentifier)+".vma") stagedContent, readError := os.ReadFile(stagedArchivePath) if readError != nil { testInstance.Fatalf("das bereitgestellte Archiv liegt nicht an seinem Platz: %v", readError) } if !bytes.Equal(stagedContent, fakeProxmox.archiveContent) { testInstance.Fatalf("das wiederhergestellte Archiv weicht ab (%d statt %d Byte)", len(stagedContent), len(fakeProxmox.archiveContent)) } if !fakeProxmox.sawRestoreCall() { testInstance.Error("Proxmox wurde nicht zur Wiederherstellung aufgefordert") } // Der Gast wird niemals unaufgefordert gestartet: Eine wiederhergestellte // Maschine mit derselben Adresse wie das noch laufende Original richtet // mehr Schaden an als der Ausfall. if restoreResult.Started { testInstance.Error("der Gast wurde ohne Aufforderung gestartet") } // Die MAC-Adresse muss erhalten bleiben: Lizenzbindungen und // DHCP-Reservierungen hängen daran. Eine wiederhergestellte VM mit neuer // MAC ist für das Netz eine andere Maschine. if !strings.Contains(fakeProxmox.configurationApplied(), "AA%3ABB%3ACC%3ADD%3AEE%3A01") { testInstance.Errorf("die MAC-Adresse wurde nicht übernommen: %q", fakeProxmox.configurationApplied()) } // Die Plattenzuordnung wird bewusst **nicht** aus der gesicherten // Konfiguration gesetzt — sie verwiese auf den alten Ort, und die Maschine // startete nicht. Das gehört als Warnung ausgewiesen, nicht verschwiegen. if len(restoreResult.Warnings) == 0 { testInstance.Error("die nicht übernommene Plattenzuordnung wurde nicht als Warnung ausgewiesen") } } // TestProxmoxRestoreFailsWithoutRepositoryData prueft den Fehlerweg. // // Fehlt das Backup, darf kein halbes Archiv auf dem Knoten zurueckbleiben: // Proxmox hielte es fuer ein gueltiges und ergaebe eine Maschine mit halben // Daten. func TestProxmoxRestoreFailsWithoutRepositoryData(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) _, repositoryPath := registerRepository(testInstance, connectionPool) storageRoot := testInstance.TempDir() fakeProxmox := startFakeProxmoxNode(testInstance, storageRoot) hypervisorStore := buildHypervisorStore(testInstance, connectionPool) createdCluster, createError := hypervisorStore.CreateCluster(context.Background(), hypervisor.ClusterInput{ Name: "verbund-" + uuid.NewString(), APIEndpoint: fakeProxmox.baseURL, APITokenID: "syncova@pve!backup", APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert BackupStorageID: "local", TLSFingerprint: fakeProxmox.certificateFingerprint, ArchiveTransport: hypervisor.TransportLocal, ArchiveMountRoots: map[string]string{"local": storageRoot}, }) if createError != nil { testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError) } testInstance.Cleanup(func() { _ = hypervisorStore.DeleteCluster(context.Background(), createdCluster.ID) }) _, restoreError := hypervisorStore.RestoreGuest(context.Background(), hypervisor.GuestRestoreRequest{ ClusterID: createdCluster.ID, RepositoryPath: repositoryPath, BackupID: "es-gibt-mich-nicht", TargetNode: "pve-01", }, buildTestSecretStore(testInstance), discardLogger()) if restoreError == nil { testInstance.Fatal("eine Wiederherstellung ohne Backup wurde als erfolgreich gemeldet") } dumpEntries, _ := os.ReadDir(filepath.Join(storageRoot, "dump")) if len(dumpEntries) != 0 { testInstance.Errorf("nach dem Fehlschlag liegen %d Dateien auf dem Knoten", len(dumpEntries)) } if fakeProxmox.sawRestoreCall() { testInstance.Error("Proxmox wurde trotz fehlendem Archiv zur Wiederherstellung aufgefordert") } } // findSingleBackupIdentifier liest die Kennung des einzigen Backups. func findSingleBackupIdentifier(testInstance *testing.T, repositoryPath string) string { testInstance.Helper() 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 || len(catalogEntries) != 1 { testInstance.Fatalf("es ließ sich kein einzelnes Backup finden: %v", listError) } return catalogEntries[0].BackupID } // sanitizeForFileName bildet den Dateinamen wie der Executor. func sanitizeForFileName(backupIdentifier string) string { return strings.Map(func(currentRune rune) rune { switch { case currentRune >= 'a' && currentRune <= 'z', currentRune >= 'A' && currentRune <= 'Z', currentRune >= '0' && currentRune <= '9', currentRune == '-', currentRune == '_': return currentRune default: return '-' } }, backupIdentifier) }