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>
388 lines
15 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|