syncova-backup/packages/providers/proxmox/restore_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

420 lines
15 KiB
Go

package proxmox
import (
"context"
"errors"
"strings"
"testing"
"github.com/syncova/syncova/packages/providers"
)
// TestRestoreToOriginalHostRefusesRunningGuest ist der wichtigste Schutztest.
//
// Eine Wiederherstellung an den Ursprungsort überschreibt die vorhandene
// Maschine. Geschieht das im Lauf, verliert man beides: den alten Zustand und
// den laufenden Betrieb.
func TestRestoreToOriginalHostRefusesRunningGuest(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
// Gast 100 läuft im Nachbau.
_, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{
TargetKind: providers.RestoreToOriginalHost,
SourceGuestID: "qemu/100",
ArchiveReference: "local:backup/vzdump-qemu-100-2026_08_11-02_00_00.vma",
OverwriteExisting: true,
})
if !errors.Is(restoreError, providers.ErrGuestRunning) {
testInstance.Fatalf("eine laufende Maschine darf nicht überschrieben werden: %v", restoreError)
}
// Nichts darf angelegt worden sein.
fakeServer.mutex.Lock()
createdCount := len(fakeServer.createdGuests)
fakeServer.mutex.Unlock()
if createdCount != 0 {
testInstance.Error("trotz Ablehnung wurde eine Wiederherstellung angestoßen")
}
}
// TestRestoreToOriginalHostRequiresExplicitOverwrite prüft die Zustimmung.
//
// Ein vorhandener Gast wird nie stillschweigend überschrieben — auch nicht,
// wenn er steht (PROMPT.md §140: destruktive Aktionen niemals still).
func TestRestoreToOriginalHostRequiresExplicitOverwrite(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
// Gast 101 steht — technisch überschreibbar, aber ohne Zustimmung nicht.
_, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{
TargetKind: providers.RestoreToOriginalHost,
SourceGuestID: "qemu/101",
ArchiveReference: "local:backup/vzdump-qemu-101-2026_08_11-02_00_00.vma",
})
if !errors.Is(restoreError, ErrTargetGuestExists) {
testInstance.Fatalf("ohne ausdrückliche Zustimmung darf nicht überschrieben werden: %v", restoreError)
}
}
// TestRestoreToOriginalHostOverwritesStoppedGuest prüft den erlaubten Fall.
func TestRestoreToOriginalHostOverwritesStoppedGuest(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
restoreResult, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{
TargetKind: providers.RestoreToOriginalHost,
SourceGuestID: "qemu/101",
ArchiveReference: "local:backup/vzdump-qemu-101-2026_08_11-02_00_00.vma",
OverwriteExisting: true,
})
if restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
if restoreResult.GuestID != "qemu/101" {
testInstance.Errorf("die Maschine wurde als %q wiederhergestellt", restoreResult.GuestID)
}
// Der Ursprungsknoten muss getroffen werden.
if restoreResult.HostID != "pve-02" {
testInstance.Errorf("die Wiederherstellung landete auf Knoten %q statt auf dem Ursprungsknoten pve-02", restoreResult.HostID)
}
// Das Überschreiben muss ausgewiesen werden.
if len(restoreResult.Warnings) == 0 {
testInstance.Error("das Überschreiben eines bestehenden Gasts wurde nicht als Warnung ausgewiesen")
}
fakeServer.mutex.Lock()
submittedForm := fakeServer.createdGuests[101]
fakeServer.mutex.Unlock()
if submittedForm.Get("force") != "1" {
testInstance.Error("Proxmox wurde nicht zum Überschreiben angewiesen")
}
// Der Gast darf niemals unaufgefordert starten.
if submittedForm.Get("start") != "0" {
testInstance.Error("die Maschine wurde zum Starten angewiesen, obwohl das nicht verlangt war")
}
}
// TestRestoreAsNewGuestPicksFreeIdentifier prüft die Wiederherstellung als
// neue Maschine.
//
// Das ist der Weg für einen Wiederherstellungstest: Das Original bleibt
// unberührt, der Betrieb läuft weiter.
func TestRestoreAsNewGuestPicksFreeIdentifier(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
restoreResult, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{
TargetKind: providers.RestoreAsNewGuest,
SourceGuestID: "qemu/100",
ArchiveReference: "local:backup/vzdump-qemu-100-2026_08_11-02_00_00.vma",
})
if restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
// 101 ist belegt, 102 ist frei.
if restoreResult.GuestID != "qemu/102" {
testInstance.Errorf("es wurde die Kennung %q gewählt, erwartet war qemu/102", restoreResult.GuestID)
}
// Das Original muss unberührt bleiben.
fakeServer.mutex.Lock()
_, originalTouched := fakeServer.createdGuests[100]
originalStatus := fakeServer.guestStatuses[100]
fakeServer.mutex.Unlock()
if originalTouched {
testInstance.Fatal("die Ursprungsmaschine wurde angefasst, obwohl als neuer Gast wiederhergestellt wurde")
}
if originalStatus != "running" {
testInstance.Error("die Ursprungsmaschine wurde angehalten")
}
// Die MAC-Warnung ist Pflicht: zwei Maschinen mit derselben Adresse im
// selben Netz stören einander.
var mentionsNetwork bool
for _, warningText := range restoreResult.Warnings {
if strings.Contains(warningText, "MAC") {
mentionsNetwork = true
}
}
if !mentionsNetwork {
testInstance.Errorf("es fehlt die Warnung vor doppelten Netzwerkadressen: %v", restoreResult.Warnings)
}
}
// TestRestoreAsNewGuestRejectsOccupiedIdentifier prüft die belegte Zielkennung.
func TestRestoreAsNewGuestRejectsOccupiedIdentifier(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
_, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{
TargetKind: providers.RestoreAsNewGuest,
SourceGuestID: "qemu/100",
TargetGuestID: "qemu/101",
ArchiveReference: "local:backup/vzdump-qemu-100.vma",
})
if !errors.Is(restoreError, ErrTargetGuestExists) {
testInstance.Fatalf("eine belegte Zielkennung muss abgelehnt werden: %v", restoreError)
}
}
// TestRestoreToAlternateHostUsesRequestedNode prüft den anderen Wirt.
func TestRestoreToAlternateHostUsesRequestedNode(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
restoreResult, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{
TargetKind: providers.RestoreToAlternateHost,
SourceGuestID: "qemu/300",
TargetHostID: "pve-02",
ArchiveReference: "local:backup/vzdump-qemu-300.vma",
})
if restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
if restoreResult.HostID != "pve-02" {
testInstance.Errorf("die Wiederherstellung landete auf %q statt auf pve-02", restoreResult.HostID)
}
}
// TestRestoreRefusesOfflineNode prüft den nicht erreichbaren Zielknoten.
//
// Das jetzt zu erkennen erspart einen Abbruch nach Stunden.
func TestRestoreRefusesOfflineNode(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
_, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{
TargetKind: providers.RestoreToAlternateHost,
SourceGuestID: "qemu/300",
TargetHostID: "pve-03",
ArchiveReference: "local:backup/vzdump-qemu-300.vma",
})
if restoreError == nil {
testInstance.Fatal("ein nicht erreichbarer Zielknoten muss abgelehnt werden")
}
if !strings.Contains(restoreError.Error(), "erreichbar") {
testInstance.Errorf("die Fehlermeldung nennt die Ursache nicht: %v", restoreError)
}
}
// TestRestoreRefusesUnknownNode prüft einen fremden Knoten.
func TestRestoreRefusesUnknownNode(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
_, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{
TargetKind: providers.RestoreToAlternateHost,
SourceGuestID: "qemu/300",
TargetHostID: "fremder-knoten",
ArchiveReference: "local:backup/vzdump-qemu-300.vma",
})
if restoreError == nil {
testInstance.Fatal("ein unbekannter Zielknoten muss abgelehnt werden")
}
}
// TestRestoreRequiresArchive prüft die Pflichtangabe.
func TestRestoreRequiresArchive(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
_, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{
TargetKind: providers.RestoreAsNewGuest,
SourceGuestID: "qemu/100",
})
if restoreError == nil {
testInstance.Fatal("eine Wiederherstellung ohne Archiv muss abgelehnt werden")
}
}
// TestRestoreRejectsReservedIdentifier prüft den reservierten Bereich.
func TestRestoreRejectsReservedIdentifier(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
_, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{
TargetKind: providers.RestoreAsNewGuest,
SourceGuestID: "qemu/100",
TargetGuestID: "qemu/50",
ArchiveReference: "local:backup/vzdump-qemu-100.vma",
})
if restoreError == nil {
testInstance.Fatal("eine Kennung im reservierten Bereich muss abgelehnt werden")
}
}
// TestRestoreAppliesMetadataButNotDiskLayout ist der zweitwichtigste Test.
//
// Die Konfiguration muss zurückkommen — ohne sie hätte die Maschine ihre Daten,
// aber nicht ihre Gestalt. Die Plattenzuordnung darf dagegen NICHT gesetzt
// werden: sie verwiese auf Datenträger am alten Ort, und die Maschine startete
// nicht.
func TestRestoreAppliesMetadataButNotDiskLayout(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
restoreContext := context.Background()
sourceMetadata, metadataError := testProvider.GetVMMetaData(restoreContext, "qemu/100")
if metadataError != nil {
testInstance.Fatalf("die Konfiguration ließ sich nicht lesen: %v", metadataError)
}
restoreResult, restoreError := testProvider.RestoreVM(restoreContext, providers.RestoreRequest{
TargetKind: providers.RestoreAsNewGuest,
SourceGuestID: "qemu/100",
ArchiveReference: "local:backup/vzdump-qemu-100.vma",
Metadata: sourceMetadata,
})
if restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
fakeServer.mutex.Lock()
appliedConfiguration := fakeServer.appliedConfigurations[102]
fakeServer.mutex.Unlock()
if appliedConfiguration == nil {
testInstance.Fatal("die Konfiguration wurde gar nicht gesetzt")
}
// Die Gestalt der Maschine muss zurückkommen.
if appliedConfiguration.Get("bios") != "ovmf" {
testInstance.Errorf("die Firmware wurde nicht gesetzt: %q", appliedConfiguration.Get("bios"))
}
if appliedConfiguration.Get("net0") != "virtio=AA:BB:CC:DD:EE:01,bridge=vmbr0,tag=42" {
testInstance.Errorf("die Netzwerkkarte wurde nicht wiederhergestellt: %q", appliedConfiguration.Get("net0"))
}
if appliedConfiguration.Get("cores") != "4" {
testInstance.Errorf("die Prozessorzahl wurde nicht gesetzt: %q", appliedConfiguration.Get("cores"))
}
// Die Plattenzuordnung darf nicht überschrieben werden.
for _, diskProperty := range []string{"scsi0", "scsi1", "efidisk0", "unused0"} {
if appliedConfiguration.Get(diskProperty) != "" {
testInstance.Errorf("die Plattenzuordnung %s wurde gesetzt und würde auf den alten Ort verweisen", diskProperty)
}
}
// Interne Felder gehören nicht in eine Wiederherstellung.
if appliedConfiguration.Get("digest") != "" {
testInstance.Error("das interne Feld digest wurde mitgesetzt")
}
// Der Anwender muss erfahren, dass die Plattenzuordnung aus dem Archiv kommt.
var mentionsDiskLayout bool
for _, warningText := range restoreResult.Warnings {
if strings.Contains(warningText, "Plattenzuordnung") {
mentionsDiskLayout = true
}
}
if !mentionsDiskLayout {
testInstance.Errorf("es fehlt der Hinweis zur Plattenzuordnung: %v", restoreResult.Warnings)
}
}
// TestRestoreStartsGuestOnlyWhenRequested prüft den ausdrücklichen Start.
func TestRestoreStartsGuestOnlyWhenRequested(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
restoreResult, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{
TargetKind: providers.RestoreAsNewGuest,
SourceGuestID: "qemu/100",
ArchiveReference: "local:backup/vzdump-qemu-100.vma",
StartAfterRestore: true,
})
if restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
if !restoreResult.Started {
testInstance.Fatal("der ausdrücklich verlangte Start wurde nicht ausgeführt")
}
fakeServer.mutex.Lock()
startedStatus := fakeServer.guestStatuses[102]
fakeServer.mutex.Unlock()
if startedStatus != "running" {
testInstance.Errorf("die Maschine läuft nicht: %q", startedStatus)
}
}
// TestRestoreReportsProgress prüft die Fortschrittsmeldung.
func TestRestoreReportsProgress(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
observedStages := make([]string, 0, 8)
if _, restoreError := testProvider.RestoreVM(context.Background(), providers.RestoreRequest{
TargetKind: providers.RestoreAsNewGuest,
SourceGuestID: "qemu/100",
ArchiveReference: "local:backup/vzdump-qemu-100.vma",
ProgressCallback: func(currentProgress providers.RestoreProgress) {
observedStages = append(observedStages, currentProgress.Stage)
},
}); restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
if len(observedStages) < 3 {
testInstance.Fatalf("es wurden nur %d Schritte gemeldet: %v", len(observedStages), observedStages)
}
if observedStages[len(observedStages)-1] != "abgeschlossen" {
testInstance.Errorf("der letzte gemeldete Schritt war %q", observedStages[len(observedStages)-1])
}
}
// TestFindFreeGuestIdentifierSearchesAboveOriginal prüft die Kennungssuche.
//
// Gesucht wird oberhalb der Ursprungskennung: die wiederhergestellte Maschine
// steht damit in der Übersicht neben ihrem Original.
func TestFindFreeGuestIdentifierSearchesAboveOriginal(testInstance *testing.T) {
occupiedIdentifiers := map[int]struct{}{100: {}, 101: {}, 102: {}}
freeIdentifier, searchError := findFreeGuestIdentifier(occupiedIdentifiers, 100)
if searchError != nil {
testInstance.Fatalf("die Suche schlug fehl: %v", searchError)
}
if freeIdentifier != 103 {
testInstance.Errorf("es wurde %d gewählt, erwartet war 103", freeIdentifier)
}
// Auch unterhalb des zulässigen Bereichs muss die Suche richtig einsetzen.
lowStartIdentifier, lowError := findFreeGuestIdentifier(map[int]struct{}{}, 5)
if lowError != nil {
testInstance.Fatalf("die Suche schlug fehl: %v", lowError)
}
if lowStartIdentifier != minimumUserGuestID {
testInstance.Errorf("unterhalb des zulässigen Bereichs wurde %d gewählt", lowStartIdentifier)
}
}