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