syncova-backup/packages/providers/proxmox/lifecycle_test.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

388 lines
15 KiB
Go

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)
}
}
}