package proxmox import ( "context" "errors" "io" "strings" "testing" "time" "github.com/syncova/syncova/packages/providers" ) // TestCreateSnapshotWaitsForTaskCompletion prüft die Aufgabenverfolgung. // // In Proxmox antwortet der Aufruf sofort mit einer Kennung, während die Arbeit // im Hintergrund läuft. Wer die Antwort für das Ergebnis hält, meldet ein // Abbild als angelegt, bevor es existiert — und sichert gegen ein Abbild, das // gerade erst entsteht. func TestCreateSnapshotWaitsForTaskCompletion(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) createdSnapshot, snapshotError := testProvider.CreateSnapshot(context.Background(), "qemu/100", providers.SnapshotOptions{ Name: "syncova-2026-08-11", Description: "Sicherungslauf", }) if snapshotError != nil { testInstance.Fatalf("das Abbild ließ sich nicht anlegen: %v", snapshotError) } if createdSnapshot.Identifier != "syncova-2026-08-11" { testInstance.Errorf("das Abbild heißt %q", createdSnapshot.Identifier) } // Der Nachweis der Verfolgung: der Nachbau meldet die Aufgabe zweimal als // laufend. Ohne Schleife wäre der Statusendpunkt höchstens einmal // aufgerufen worden. fakeServer.mutex.Lock() var statusCallCount int for _, loggedRequest := range fakeServer.requestLog { if strings.Contains(loggedRequest, "/tasks/") && strings.HasSuffix(loggedRequest, "/status") { statusCallCount++ } } fakeServer.mutex.Unlock() if statusCallCount < 3 { testInstance.Errorf("die Aufgabe wurde nur %d mal abgefragt; die Verfolgungsschleife greift nicht", statusCallCount) } } // TestCreateSnapshotReportsHonestConsistency ist der wichtigste Test dieser // Datei. // // Wer eine anwendungskonsistente Sicherung verlangt und einen Gast ohne // Gastdienst erwischt, muss „crash consistent" ins Manifest geschrieben // bekommen. Ein Backup, das mehr verspricht als es hält, ist gefährlicher als // eines, das seine Grenzen kennt. func TestCreateSnapshotReportsHonestConsistency(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) snapshotContext := context.Background() // Gast 100 läuft und hat einen Gastdienst: anwendungskonsistent ist // erreichbar. withAgent, agentError := testProvider.CreateSnapshot(snapshotContext, "qemu/100", providers.SnapshotOptions{ Name: "mit-agent", QuiesceGuest: true, }) if agentError != nil { testInstance.Fatalf("das Abbild ließ sich nicht anlegen: %v", agentError) } if withAgent.ConsistencyLevel != providers.ConsistencyApplicationConsistent { testInstance.Errorf("mit Gastdienst wurde %q erreicht", withAgent.ConsistencyLevel) } // Gast 200 läuft ohne Gastdienst. Die Anforderung bleibt dieselbe — das // Ergebnis darf sie nicht behaupten. withoutAgent, noAgentError := testProvider.CreateSnapshot(snapshotContext, "lxc/200", providers.SnapshotOptions{ Name: "ohne-agent", QuiesceGuest: true, }) if noAgentError != nil { testInstance.Fatalf("das Abbild ließ sich nicht anlegen: %v", noAgentError) } if withoutAgent.ConsistencyLevel != providers.ConsistencyCrashConsistent { testInstance.Fatalf("ohne Gastdienst wurde %q gemeldet; die Konsistenzstufe wurde beschönigt", withoutAgent.ConsistencyLevel) } // Ein angehaltener Gast schreibt nicht mehr: sein Abbild ist konsistent, // ganz ohne Gastdienst. stoppedGuest, stoppedError := testProvider.CreateSnapshot(snapshotContext, "qemu/101", providers.SnapshotOptions{ Name: "angehalten", }) if stoppedError != nil { testInstance.Fatalf("das Abbild ließ sich nicht anlegen: %v", stoppedError) } if stoppedGuest.ConsistencyLevel != providers.ConsistencyApplicationConsistent { testInstance.Errorf("bei angehaltenem Gast wurde %q gemeldet", stoppedGuest.ConsistencyLevel) } } // TestCreateSnapshotRequiresName prüft die Eingabeprüfung. func TestCreateSnapshotRequiresName(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) if _, snapshotError := testProvider.CreateSnapshot(context.Background(), "qemu/100", providers.SnapshotOptions{}); snapshotError == nil { testInstance.Fatal("ein Abbild ohne Namen muss abgelehnt werden") } } // TestFailedTaskCarriesLogTail prüft die Fehlermeldung gescheiterter Aufgaben. // // Proxmox meldet Fehler über den Exit-Status der Aufgabe, nicht über den // HTTP-Status. Der Exit-Status ist oft ein Satz; die Ursache steht im // Protokoll. Sie mitzugeben erspart den Gang in die Proxmox-Oberfläche. func TestFailedTaskCarriesLogTail(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) fakeServer.mutex.Lock() fakeServer.failNextTask = "unable to create snapshot: storage full" fakeServer.mutex.Unlock() _, snapshotError := testProvider.CreateSnapshot(context.Background(), "qemu/100", providers.SnapshotOptions{ Name: "wird-scheitern", }) if snapshotError == nil { testInstance.Fatal("eine gescheiterte Aufgabe muss einen Fehler ergeben") } var taskError *TaskFailedError if !errors.As(snapshotError, &taskError) { testInstance.Fatalf("der Fehler wurde nicht als Aufgabenfehler gemeldet: %v", snapshotError) } if !strings.Contains(taskError.ExitStatus, "storage full") { testInstance.Errorf("der Exit-Status ging verloren: %q", taskError.ExitStatus) } // Die eigentliche Ursache steht im Protokoll. if !strings.Contains(snapshotError.Error(), "no space left on device") { testInstance.Errorf("die Protokollzeilen fehlen in der Meldung: %v", snapshotError) } } // TestRemoveSnapshotWaitsForCompletion prüft das Entfernen. // // Auf das Ende zu warten ist keine Förmlichkeit: Ein zurückbleibendes Abbild // wächst mit jedem Schreibvorgang des Gasts weiter und ist die häufigste // Ursache voller Proxmox-Datenträger. func TestRemoveSnapshotWaitsForCompletion(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) snapshotContext := context.Background() if _, snapshotError := testProvider.CreateSnapshot(snapshotContext, "qemu/100", providers.SnapshotOptions{ Name: "temporaer", }); snapshotError != nil { testInstance.Fatalf("das Abbild ließ sich nicht anlegen: %v", snapshotError) } if removeError := testProvider.RemoveSnapshot(snapshotContext, "qemu/100", "temporaer"); removeError != nil { testInstance.Fatalf("das Abbild ließ sich nicht entfernen: %v", removeError) } fakeServer.mutex.Lock() remainingSnapshots := fakeServer.snapshotsByGuest[100] fakeServer.mutex.Unlock() if len(remainingSnapshots) != 0 { testInstance.Errorf("das Abbild blieb zurück: %v", remainingSnapshots) } } // TestReadChangedBlocksReportsUnsupported ist ein Ehrlichkeitstest. // // Proxmox VE gibt geänderte Blöcke nicht über die REST-API heraus. Eine leere // Bereichsliste zurückzugeben wäre der bequeme Weg und der schlimmste: die // Sicherung hielte jede Platte für unverändert und schriebe ein leeres Backup, // das aussähe wie ein gelungenes. func TestReadChangedBlocksReportsUnsupported(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) changedBlocks, blockError := testProvider.ReadChangedBlocks(context.Background(), "qemu/100", "scsi0", "") if !errors.Is(blockError, providers.ErrNotSupported) { testInstance.Fatalf("die fehlende Blocknachverfolgung muss als nicht unterstützt gemeldet werden: %v", blockError) } if changedBlocks != nil { testInstance.Fatal("bei nicht unterstützter Nachverfolgung darf kein Ergebnis geliefert werden") } } // TestOpenDiskWithoutTransportFails prüft den fehlenden Zugriffsweg. // // Ohne Transport gibt es keinen Weg an die Archivdatei. Ein leerer Datenstrom // erzeugte ein Backup ohne Inhalt, das sich als gelungen ausgäbe. func TestOpenDiskWithoutTransportFails(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) _, openError := testProvider.OpenDisk(context.Background(), providers.DiskReadRequest{ GuestID: "qemu/100", }) if !errors.Is(openError, ErrArchiveTransportMissing) { testInstance.Fatalf("ein fehlender Zugriffsweg muss als solcher gemeldet werden: %v", openError) } } // TestOpenDiskRejectsPartialRanges prüft die Bereichsangabe. func TestOpenDiskRejectsPartialRanges(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) testProvider.options.ArchiveTransport = &stubArchiveTransport{} _, openError := testProvider.OpenDisk(context.Background(), providers.DiskReadRequest{ GuestID: "qemu/100", Ranges: []providers.BlockRange{{OffsetBytes: 0, LengthBytes: 4096}}, }) if !errors.Is(openError, providers.ErrNotSupported) { testInstance.Fatalf("teilweises Lesen muss als nicht unterstützt gemeldet werden: %v", openError) } } // stubArchiveTransport ist ein Platzhalter für Tests. type stubArchiveTransport struct { // requestedVolume merkt sich das angeforderte Archiv. requestedVolume string // requestedNode merkt sich den Knoten. requestedNode string } // OpenArchive liefert einen Datenstrom mit festem Inhalt. func (transport *stubArchiveTransport) OpenArchive(_ context.Context, nodeName string, volumeIdentifier string) (io.ReadCloser, error) { transport.requestedNode = nodeName transport.requestedVolume = volumeIdentifier return readCloser{strings.NewReader("VMA-Archivinhalt")}, nil } // readCloser macht einen Reader schließbar. type readCloser struct { *strings.Reader } // Close erfüllt die Schnittstelle. func (readCloser) Close() error { return nil } // TestOpenDiskRunsVzdumpAndFindsArchive prüft den Sicherungspfad. func TestOpenDiskRunsVzdumpAndFindsArchive(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) archiveTransport := &stubArchiveTransport{} testProvider.options.ArchiveTransport = archiveTransport diskStream, openError := testProvider.OpenDisk(context.Background(), providers.DiskReadRequest{ GuestID: "qemu/100", }) if openError != nil { testInstance.Fatalf("die Sicherung ließ sich nicht öffnen: %v", openError) } defer func() { _ = diskStream.Close() }() if archiveTransport.requestedNode != "pve-01" { testInstance.Errorf("das Archiv wurde von Knoten %q angefordert", archiveTransport.requestedNode) } if !strings.Contains(archiveTransport.requestedVolume, "vzdump-qemu-100") { testInstance.Errorf("es wurde das falsche Archiv gewählt: %q", archiveTransport.requestedVolume) } } // TestOpenDiskFailsWhenArchiveMissing prüft den Fall einer erfolgreichen // Aufgabe ohne Ergebnis. // // Meldet die Aufgabe Erfolg und liegt trotzdem kein Archiv, wäre ein leeres // Backup die stille Folge. Hier wird abgebrochen. func TestOpenDiskFailsWhenArchiveMissing(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) testProvider.options.ArchiveTransport = &stubArchiveTransport{} // Der Speicher enthält keine Archive für diesen Gast: der Nachbau legt sie // nur für den tatsächlich gesicherten Gast an. Gast 101 wird gesichert, // aber die Archivliste wird für 999 abgefragt — hier genügt es, den // Speicher auf einen unbekannten zu setzen. testProvider.options.BackupStorageID = "leerer-speicher" _, openError := testProvider.OpenDisk(context.Background(), providers.DiskReadRequest{ GuestID: "qemu/101", }) if openError == nil { testInstance.Fatal("eine Sicherung ohne entstandenes Archiv muss fehlschlagen") } } // TestTransientErrorsAreRetried prüft die Wiederholung. // // Ein 503 kann beim nächsten Versuch verschwunden sein; ein 401 wiederholt sich // beliebig oft mit demselben Ergebnis. Ohne die Unterscheidung würde entweder // zu früh aufgegeben oder sinnlos gewartet. func TestTransientErrorsAreRetried(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) fakeServer.mutex.Lock() fakeServer.transientFailuresRemaining = 2 fakeServer.mutex.Unlock() if connectError := testProvider.Connect(context.Background()); connectError != nil { testInstance.Fatalf("ein vorübergehender Fehler muss durch Wiederholung überwunden werden: %v", connectError) } } // TestAuthenticationFailureIsNotRetried prüft, dass dauerhafte Fehler sofort // gemeldet werden. func TestAuthenticationFailureIsNotRetried(testInstance *testing.T) { fakeServer := newFakeProxmoxServer(testInstance) testProvider := fakeServer.newConnectedProvider(testInstance) // Ein leerer Anmeldekopf lässt den Nachbau mit 401 antworten. testProvider.client.authorizationHeader = "kaputt" startTime := time.Now() connectError := testProvider.Connect(context.Background()) elapsedDuration := time.Since(startTime) if connectError == nil { testInstance.Fatal("ein abgewiesenes Token muss einen Fehler ergeben") } // Ohne Wiederholungen ist der Aufruf sofort zurück. Mit Wiederholungen // vergingen mindestens drei Sekunden Wartezeit. if elapsedDuration > 2*time.Second { testInstance.Errorf("ein abgewiesenes Token wurde wiederholt; der Aufruf dauerte %v", elapsedDuration) } if !strings.Contains(connectError.Error(), "api-token") { testInstance.Errorf("die Fehlermeldung gibt keinen Hinweis auf die Ursache: %v", connectError) } } // TestTaskIdentifierParsing prüft die Zerlegung der UPID. func TestTaskIdentifierParsing(testInstance *testing.T) { validIdentifier := TaskIdentifier("UPID:pve-02:0000AB12:0012F3C4:66B8A1C0:qmsnapshot:100:syncova@pve:") descriptor, parseError := parseTaskIdentifier(validIdentifier) if parseError != nil { testInstance.Fatalf("eine gültige UPID wurde abgelehnt: %v", parseError) } // Der Knoten ist der entscheidende Teil: der Statusendpunkt ist // knotenbezogen, eine Aufgabe auf pve-02 lässt sich nicht über pve-01 // abfragen. if descriptor.NodeName != "pve-02" { testInstance.Errorf("der Knoten wurde als %q gelesen", descriptor.NodeName) } if descriptor.TaskType != "qmsnapshot" { testInstance.Errorf("die Aufgabenart wurde als %q gelesen", descriptor.TaskType) } invalidIdentifiers := []TaskIdentifier{"", "nicht-upid", "UPID:zu:kurz"} for _, invalidIdentifier := range invalidIdentifiers { if _, invalidError := parseTaskIdentifier(invalidIdentifier); !errors.Is(invalidError, ErrMalformedTaskIdentifier) { testInstance.Errorf("die ungültige UPID %q wurde angenommen", invalidIdentifier) } } }