syncova-backup/packages/backupexecutor/proxmox_backup_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

714 lines
26 KiB
Go

package backupexecutor
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/syncova/syncova/packages/hypervisor"
"github.com/syncova/syncova/packages/jobs"
"github.com/syncova/syncova/packages/repository"
"github.com/syncova/syncova/packages/scheduler"
)
// TestExecutorBacksUpProxmoxGuest ist der Nachweis der Proxmox-Kette.
//
// Auftrag mit einer Gastquelle → Executor → Provider → vzdump → Archiv als
// Datenstrom → Backup Engine → Repository → Manifest. Bis zu dieser Phase
// lehnte der Executor eine solche Quelle mit „kann noch nicht gesichert
// werden" ab; ein Auftrag liess sich anlegen und scheiterte beim Lauf.
//
// Der Test prüft nicht nur, dass es durchläuft, sondern **was im Repository
// liegt**: das Plattenabbild mit dem unveränderten Archivinhalt und die
// Gastkonfiguration daneben. Ohne die zweite ließe sich die Maschine zwar mit
// ihren Daten, aber nicht in ihrer Gestalt wiederherstellen.
func TestExecutorBacksUpProxmoxGuest(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
testStore := jobs.NewPostgresStore(connectionPool)
repositoryID, repositoryPath := registerRepository(testInstance, connectionPool)
// Der Nachbau legt sein Archiv in ein Verzeichnis, das zugleich als
// Proxmox-Speicher eingehängt gilt — genau der Fall „gemeinsame Freigabe".
storageRoot := testInstance.TempDir()
fakeProxmox := startFakeProxmoxNode(testInstance, storageRoot)
hypervisorStore := buildHypervisorStore(testInstance, connectionPool)
createdCluster, createError := hypervisorStore.CreateCluster(context.Background(), hypervisor.ClusterInput{
Name: "verbund-" + uuid.NewString(),
APIEndpoint: fakeProxmox.baseURL,
APITokenID: "syncova@pve!backup",
APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert
BackupStorageID: "local",
// Der Nachbau trägt ein selbstsigniertes Zertifikat; die Bindung über
// den Fingerabdruck wird im Provider-Paket gesondert geprüft.
TLSFingerprint: fakeProxmox.certificateFingerprint,
ArchiveTransport: hypervisor.TransportLocal,
ArchiveMountRoots: map[string]string{"local": storageRoot},
})
if createError != nil {
testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError)
}
testInstance.Cleanup(func() {
_ = hypervisorStore.DeleteCluster(context.Background(), createdCluster.ID)
})
testExecutor := buildProxmoxExecutor(testInstance, testStore, hypervisorStore)
executionJob := &jobs.Job{
Name: "Proxmox-Test",
Status: jobs.JobStatusActive,
Priority: scheduler.PriorityNormal,
Schedule: scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeManual},
RepositoryID: repositoryID,
Sources: []jobs.JobSource{
{
SourceType: jobs.SourceTypeProxmoxVM,
SourceID: "qemu/100",
SourceName: "web-01",
ClusterID: &createdCluster.ID,
},
},
MaximumConcurrency: 1,
RetryPolicy: scheduler.DefaultRetryPolicy(),
}
runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, executionJob)
executionResult, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{
Job: executionJob,
RunID: runIdentifier,
})
if executeError != nil {
testInstance.Fatalf("die Sicherung des Gasts schlug fehl: %v", executeError)
}
// Die ausgenommene Platte (backup=0) macht den Lauf zum Teilfehler. Das ist
// gewollt: Die Wiederherstellung liefert eine unvollständige Maschine, und
// wer es nicht erfährt, hält sie für vollständig.
if executionResult.FilesSkipped != 1 {
testInstance.Errorf("es wurden %d Objekte übergangen, erwartet war die ausgenommene Platte",
executionResult.FilesSkipped)
}
if len(executionResult.SkipReasons) != 1 || !strings.Contains(executionResult.SkipReasons[0], "backup=0") {
testInstance.Errorf("der Grund der Auslassung ist nicht benannt: %v", executionResult.SkipReasons)
}
if executionResult.BytesProcessed < int64(len(fakeProxmox.archiveContent)) {
testInstance.Errorf("es wurden nur %d Byte gelesen, das Archiv hat %d",
executionResult.BytesProcessed, len(fakeProxmox.archiveContent))
}
// Das vzdump-Archiv darf nach dem Lauf nicht mehr auf dem Knoten liegen:
// Sonst füllte jede Sicherung den Proxmox-Speicher mit einer zweiten,
// unverwalteten Kopie derselben Daten.
if len(fakeProxmox.deletedVolumes()) != 1 {
testInstance.Errorf("das Archiv wurde nicht vom Knoten entfernt: %v", fakeProxmox.deletedVolumes())
}
assertGuestBackupContents(testInstance, repositoryPath, fakeProxmox.archiveContent)
}
// assertGuestBackupContents prueft, was tatsaechlich im Repository liegt.
func assertGuestBackupContents(testInstance *testing.T, repositoryPath string, expectedArchive []byte) {
testInstance.Helper()
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
repository.OpenOptions{}, discardLogger())
if openError != nil {
testInstance.Fatalf("das Repository ließ sich nicht öffnen: %v", openError)
}
defer func() { _ = openedRepository.Close() }()
catalogEntries, listError := openedRepository.ListBackups(context.Background())
if listError != nil {
testInstance.Fatalf("die Backups ließen sich nicht lesen: %v", listError)
}
if len(catalogEntries) != 1 {
testInstance.Fatalf("es liegen %d Backups im Repository statt eines", len(catalogEntries))
}
storedManifest, readError := openedRepository.ReadManifest(context.Background(), catalogEntries[0].BackupID)
if readError != nil {
testInstance.Fatalf("das Manifest ließ sich nicht lesen: %v", readError)
}
// Die Kennzahlen dürfen nicht null sein — der Fund aus Phase 18: Die Engine
// schrieb einmal am Zählwerk der Schreibsession vorbei, und jedes Manifest
// trug logical_bytes 0, ohne dass ein Test es bemerkte.
if storedManifest.Statistics.LogicalBytes == 0 {
testInstance.Error("das Manifest weist keine gesicherte Datenmenge aus")
}
var sawArchive, sawConfiguration bool
for _, manifestEntry := range storedManifest.Entries {
switch manifestEntry.Path {
case guestArchiveEntryPath:
sawArchive = true
if manifestEntry.EntryType != "disk" {
testInstance.Errorf("das Plattenabbild ist als %q vermerkt", manifestEntry.EntryType)
}
if manifestEntry.SizeBytes != int64(len(expectedArchive)) {
testInstance.Errorf("das Abbild ist %d Byte groß, das Archiv hat %d",
manifestEntry.SizeBytes, len(expectedArchive))
}
case guestConfigurationEntryPath:
sawConfiguration = true
}
}
if !sawArchive {
testInstance.Error("im Manifest fehlt das Plattenabbild des Gasts")
}
if !sawConfiguration {
testInstance.Error("im Manifest fehlt die Gastkonfiguration")
}
// Die Quellangaben müssen den Verbund benennen: Ohne sie ließe sich nach
// einem Totalverlust nicht mehr feststellen, woher das Backup stammt.
if storedManifest.Source.SourceType != string(jobs.SourceTypeProxmoxVM) {
testInstance.Errorf("die Quellart ist %q", storedManifest.Source.SourceType)
}
if storedManifest.Source.Attributes["guest_id"] != "qemu/100" {
testInstance.Errorf("der Gast ist nicht vermerkt: %v", storedManifest.Source.Attributes)
}
}
// TestExecutorRejectsProxmoxSourceWithoutCluster prueft die Konfigurationsluecke.
//
// Ein Lauf, der ohne Verbund stillschweigend nichts täte, wäre der
// gefährlichste Fall: ein grüner Lauf ohne Daten.
func TestExecutorRejectsProxmoxSourceWithoutCluster(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
testStore := jobs.NewPostgresStore(connectionPool)
repositoryID, _ := registerRepository(testInstance, connectionPool)
hypervisorStore := buildHypervisorStore(testInstance, connectionPool)
testExecutor := buildProxmoxExecutor(testInstance, testStore, hypervisorStore)
executionJob := &jobs.Job{
Name: "Proxmox-ohne-Verbund",
Status: jobs.JobStatusActive,
Priority: scheduler.PriorityNormal,
Schedule: scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeManual},
RepositoryID: repositoryID,
Sources: []jobs.JobSource{
{SourceType: jobs.SourceTypeProxmoxVM, SourceID: "qemu/100", SourceName: "web-01"},
},
MaximumConcurrency: 1,
RetryPolicy: scheduler.DefaultRetryPolicy(),
}
runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, executionJob)
_, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{
Job: executionJob,
RunID: runIdentifier,
})
if executeError == nil {
testInstance.Fatal("eine Proxmox-Quelle ohne Verbund wurde gesichert")
}
if !strings.Contains(executeError.Error(), "Virtualisierungsverbund") {
testInstance.Errorf("die Meldung nennt den Grund nicht: %v", executeError)
}
}
// buildProxmoxExecutor baut einen Executor mit Virtualisierungsverwaltung.
func buildProxmoxExecutor(testInstance *testing.T, testStore *jobs.PostgresStore,
hypervisorStore *hypervisor.Store) *Executor {
testInstance.Helper()
builtExecutor, buildError := New(testStore, Options{
SecretStore: buildTestSecretStore(testInstance),
HypervisorStore: hypervisorStore,
CreatedByVersion: "test",
}, discardLogger())
if buildError != nil {
testInstance.Fatalf("der Executor ließ sich nicht bauen: %v", buildError)
}
return builtExecutor
}
// buildHypervisorStore baut die Virtualisierungsverwaltung für den Test.
func buildHypervisorStore(testInstance *testing.T, connectionPool *pgxpool.Pool) *hypervisor.Store {
testInstance.Helper()
builtStore, buildError := hypervisor.NewStore(connectionPool, buildTestSecretStore(testInstance))
if buildError != nil {
testInstance.Fatalf("die Virtualisierungsverwaltung ließ sich nicht bauen: %v", buildError)
}
return builtStore
}
// fakeProxmoxNode ist ein Nachbau der Proxmox-API für den Sicherungsweg.
//
// Bewusst eigenständig und nicht der Nachbau aus dem Provider-Paket: Der liegt
// in einer _test.go und lässt sich nicht importieren. Ihn dafür in den
// Produktivcode zu heben hieße, eine Attrappe auszuliefern.
type fakeProxmoxNode struct {
// baseURL ist die Adresse des Nachbaus.
baseURL string
// certificateFingerprint ist der SHA-256-Fingerabdruck seines Zertifikats.
certificateFingerprint string
// archiveContent ist der Inhalt des erzeugten Archivs.
archiveContent []byte
// storageRoot ist das Verzeichnis des Sicherungsspeichers.
storageRoot string
// mutex schützt die veränderlichen Felder.
mutex sync.Mutex
// removedVolumes sammelt die entfernten Archive.
removedVolumes []string
// archiveVolume ist die Kennung des erzeugten Archivs.
archiveVolume string
// restoreCalled meldet, ob Proxmox zur Wiederherstellung aufgefordert wurde.
restoreCalled bool
// appliedConfiguration ist die nachgezogene Gastkonfiguration.
appliedConfiguration string
}
// configurationApplied liefert die nachgezogene Konfiguration.
func (fakeNode *fakeProxmoxNode) configurationApplied() string {
fakeNode.mutex.Lock()
defer fakeNode.mutex.Unlock()
return fakeNode.appliedConfiguration
}
// sawRestoreCall meldet, ob eine Wiederherstellung angestoßen wurde.
func (fakeNode *fakeProxmoxNode) sawRestoreCall() bool {
fakeNode.mutex.Lock()
defer fakeNode.mutex.Unlock()
return fakeNode.restoreCalled
}
// deletedVolumes liefert die entfernten Archive.
func (fakeNode *fakeProxmoxNode) deletedVolumes() []string {
fakeNode.mutex.Lock()
defer fakeNode.mutex.Unlock()
return append([]string(nil), fakeNode.removedVolumes...)
}
// startFakeProxmoxNode startet den Nachbau.
func startFakeProxmoxNode(testInstance *testing.T, storageRoot string) *fakeProxmoxNode {
testInstance.Helper()
// Inkompressibler Inhalt: Ein periodisches Muster ergäbe eine
// Kompressionsrate, die nichts über die Anlage aussagt (der Fund aus
// Phase 4).
archiveContent := make([]byte, 3*1024*1024)
for byteIndex := range archiveContent {
archiveContent[byteIndex] = byte((byteIndex*2654435761 + byteIndex/7) % 251)
}
fakeNode := &fakeProxmoxNode{
archiveContent: archiveContent,
storageRoot: storageRoot,
}
testServer := httptest.NewTLSServer(http.HandlerFunc(fakeNode.handleRequest))
testInstance.Cleanup(testServer.Close)
fakeNode.baseURL = testServer.URL
certificateDigest := sha256.Sum256(testServer.Certificate().Raw)
fakeNode.certificateFingerprint = hex.EncodeToString(certificateDigest[:])
return fakeNode
}
// handleRequest bedient die vom Sicherungsweg benötigten Endpunkte.
func (fakeNode *fakeProxmoxNode) handleRequest(responseWriter http.ResponseWriter, request *http.Request) {
apiPath := strings.TrimPrefix(request.URL.Path, "/api2/json")
_ = request.ParseForm()
switch {
case apiPath == "/version":
writeProxmoxData(responseWriter, map[string]any{"version": "8.2.2", "release": "8.2"})
case apiPath == "/nodes":
writeProxmoxData(responseWriter, []map[string]any{
{"node": "pve-01", "status": "online", "maxcpu": 16, "maxmem": 68719476736},
})
case apiPath == "/nodes/pve-01/qemu/900/config":
// Die Konfiguration des wiederhergestellten Gasts wird nachgezogen —
// insbesondere die MAC-Adressen. Sie müssen erhalten bleiben:
// Lizenzbindungen und DHCP-Reservierungen hängen daran.
fakeNode.mutex.Lock()
fakeNode.appliedConfiguration = request.PostForm.Encode()
fakeNode.mutex.Unlock()
writeProxmoxData(responseWriter, nil)
case apiPath == "/nodes/pve-01/qemu/100/status/current":
writeProxmoxData(responseWriter, map[string]any{"status": "stopped", "vmid": 100})
case apiPath == "/cluster/resources":
writeProxmoxData(responseWriter, []map[string]any{
{"type": "qemu", "vmid": 100, "node": "pve-01", "name": "web-01", "status": "running"},
})
case apiPath == "/nodes/pve-01/qemu/100/config":
writeProxmoxData(responseWriter, map[string]any{
"name": "web-01",
"cores": 4,
"memory": 8192,
"bios": "ovmf",
"scsi0": "local-lvm:vm-100-disk-0,size=32G",
// Diese Platte ist von der Sicherung ausgenommen — der Lauf muss
// sie ausweisen, nicht verschweigen.
"scsi1": "local-lvm:vm-100-disk-1,size=500G,backup=0",
"net0": "virtio=AA:BB:CC:DD:EE:01,bridge=vmbr0",
})
case request.Method == http.MethodPost && apiPath == "/nodes/pve-01/qemu":
fakeNode.mutex.Lock()
fakeNode.restoreCalled = true
fakeNode.mutex.Unlock()
writeProxmoxData(responseWriter, "UPID:pve-01:00005678:00ABCDEF:66B00000:qmrestore::root@pam:")
case apiPath == "/nodes/pve-01/vzdump":
fakeNode.createArchive(responseWriter)
case strings.HasPrefix(apiPath, "/nodes/pve-01/tasks/"):
fakeNode.handleTask(responseWriter, apiPath)
case apiPath == "/nodes/pve-01/storage/local/content":
fakeNode.mutex.Lock()
volumeIdentifier := fakeNode.archiveVolume
fakeNode.mutex.Unlock()
if volumeIdentifier == "" {
writeProxmoxData(responseWriter, []map[string]any{})
return
}
writeProxmoxData(responseWriter, []map[string]any{
{"volid": volumeIdentifier, "ctime": 1723500000, "size": len(fakeNode.archiveContent)},
})
case request.Method == http.MethodDelete && strings.Contains(apiPath, "/storage/local/content/"):
fakeNode.mutex.Lock()
fakeNode.removedVolumes = append(fakeNode.removedVolumes, apiPath)
fakeNode.archiveVolume = ""
fakeNode.mutex.Unlock()
writeProxmoxData(responseWriter, "UPID:pve-01:00000001:00000001:66000000:imgdel::root@pam:")
default:
http.Error(responseWriter, `{"message":"not found"}`, http.StatusNotFound)
}
}
// createArchive legt das vzdump-Archiv als echte Datei an.
func (fakeNode *fakeProxmoxNode) createArchive(responseWriter http.ResponseWriter) {
dumpDirectory := filepath.Join(fakeNode.storageRoot, "dump")
if makeError := os.MkdirAll(dumpDirectory, 0o700); makeError != nil {
http.Error(responseWriter, makeError.Error(), http.StatusInternalServerError)
return
}
archiveName := "vzdump-qemu-100-2026_08_13-02_00_00.vma"
if writeError := os.WriteFile(filepath.Join(dumpDirectory, archiveName),
fakeNode.archiveContent, 0o600); writeError != nil {
http.Error(responseWriter, writeError.Error(), http.StatusInternalServerError)
return
}
fakeNode.mutex.Lock()
fakeNode.archiveVolume = "local:dump/" + archiveName
fakeNode.mutex.Unlock()
writeProxmoxData(responseWriter, "UPID:pve-01:00001234:00ABCDEF:66B00000:vzdump::root@pam:")
}
// handleTask beantwortet die Statusabfrage einer Aufgabe.
//
// Der Ablauf ist bei Proxmox asynchron: Der ändernde Aufruf antwortet mit einer
// Kennung, das Ergebnis steht im Exit-Status — nicht im HTTP-Status.
func (fakeNode *fakeProxmoxNode) handleTask(responseWriter http.ResponseWriter, apiPath string) {
if strings.HasSuffix(apiPath, "/log") {
writeProxmoxData(responseWriter, []map[string]any{{"n": 1, "t": "TASK OK"}})
return
}
writeProxmoxData(responseWriter, map[string]any{
"status": "stopped",
"exitstatus": "OK",
"type": "vzdump",
"node": "pve-01",
})
}
// writeProxmoxData schreibt eine Antwort in der Proxmox-Hülle.
func writeProxmoxData(responseWriter http.ResponseWriter, payload any) {
responseWriter.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(responseWriter).Encode(map[string]any{"data": payload})
}
// TestProxmoxGuestRoundTripIsByteExact ist der Nachweis der gesamten Kette.
//
// Sichern → Repository → Wiederherstellen → **bitgenau vergleichen**. Das ist
// der Punkt, an dem sich zeigt, ob die Anlage einen Gast tatsächlich
// zurückbringt: Ein Backup, das man nicht zurückspielen kann, ist keines
// (PROMPT.md §141).
//
// Was dieser Test **nicht** belegt: dass die wiederhergestellte Maschine
// startet. Das kann nur ein echter Proxmox-Knoten, und genau daran hängt der
// verpflichtende Meilenstein der Phase 7.
func TestProxmoxGuestRoundTripIsByteExact(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
testStore := jobs.NewPostgresStore(connectionPool)
repositoryID, repositoryPath := registerRepository(testInstance, connectionPool)
storageRoot := testInstance.TempDir()
fakeProxmox := startFakeProxmoxNode(testInstance, storageRoot)
hypervisorStore := buildHypervisorStore(testInstance, connectionPool)
createdCluster, createError := hypervisorStore.CreateCluster(context.Background(), hypervisor.ClusterInput{
Name: "verbund-" + uuid.NewString(),
APIEndpoint: fakeProxmox.baseURL,
APITokenID: "syncova@pve!backup",
APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert
BackupStorageID: "local",
TLSFingerprint: fakeProxmox.certificateFingerprint,
ArchiveTransport: hypervisor.TransportLocal,
ArchiveMountRoots: map[string]string{"local": storageRoot},
})
if createError != nil {
testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError)
}
testInstance.Cleanup(func() {
_ = hypervisorStore.DeleteCluster(context.Background(), createdCluster.ID)
})
testExecutor := buildProxmoxExecutor(testInstance, testStore, hypervisorStore)
executionJob := &jobs.Job{
Name: "Proxmox-Rundlauf",
Status: jobs.JobStatusActive,
Priority: scheduler.PriorityNormal,
Schedule: scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeManual},
RepositoryID: repositoryID,
Sources: []jobs.JobSource{
{
SourceType: jobs.SourceTypeProxmoxVM,
SourceID: "qemu/100",
SourceName: "web-01",
ClusterID: &createdCluster.ID,
},
},
MaximumConcurrency: 1,
RetryPolicy: scheduler.DefaultRetryPolicy(),
}
runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, executionJob)
if _, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{
Job: executionJob,
RunID: runIdentifier,
}); executeError != nil {
testInstance.Fatalf("die Sicherung schlug fehl: %v", executeError)
}
backupIdentifier := findSingleBackupIdentifier(testInstance, repositoryPath)
// Die Quelle wird beseitigt: Der Nachbau liefert das Archiv nicht mehr,
// und was jetzt zurückkommt, kommt ausschließlich aus dem Repository.
if removeError := os.RemoveAll(filepath.Join(storageRoot, "dump")); removeError != nil {
testInstance.Fatalf("das Quellarchiv ließ sich nicht entfernen: %v", removeError)
}
restoreResult, restoreError := hypervisorStore.RestoreGuest(context.Background(),
hypervisor.GuestRestoreRequest{
ClusterID: createdCluster.ID,
RepositoryPath: repositoryPath,
BackupID: backupIdentifier,
TargetNode: "pve-01",
// Neben das Original, nicht darüber: Der Ursprungsgast existiert
// noch, und eine Wiederherstellung, die ihn überschreibt, würde
// genau das vernichten, was man prüfen wollte. Das ist zugleich
// der Weg, den ein Wiederherstellungstest (Phase 10) auf echter
// Hardware nimmt.
TargetGuestID: "qemu/900",
// Das bereitgestellte Archiv bleibt liegen, damit der Test es
// vergleichen kann. Im Betrieb wird es abgeräumt.
KeepStagedArchive: true,
}, buildTestSecretStore(testInstance), discardLogger())
if restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
if restoreResult.BytesStaged != int64(len(fakeProxmox.archiveContent)) {
testInstance.Errorf("es wurden %d Byte bereitgestellt, das Archiv hat %d",
restoreResult.BytesStaged, len(fakeProxmox.archiveContent))
}
// Der eigentliche Nachweis: Byte für Byte identisch.
stagedArchivePath := filepath.Join(storageRoot, "dump",
"syncova-restore-"+sanitizeForFileName(backupIdentifier)+".vma")
stagedContent, readError := os.ReadFile(stagedArchivePath)
if readError != nil {
testInstance.Fatalf("das bereitgestellte Archiv liegt nicht an seinem Platz: %v", readError)
}
if !bytes.Equal(stagedContent, fakeProxmox.archiveContent) {
testInstance.Fatalf("das wiederhergestellte Archiv weicht ab (%d statt %d Byte)",
len(stagedContent), len(fakeProxmox.archiveContent))
}
if !fakeProxmox.sawRestoreCall() {
testInstance.Error("Proxmox wurde nicht zur Wiederherstellung aufgefordert")
}
// Der Gast wird niemals unaufgefordert gestartet: Eine wiederhergestellte
// Maschine mit derselben Adresse wie das noch laufende Original richtet
// mehr Schaden an als der Ausfall.
if restoreResult.Started {
testInstance.Error("der Gast wurde ohne Aufforderung gestartet")
}
// Die MAC-Adresse muss erhalten bleiben: Lizenzbindungen und
// DHCP-Reservierungen hängen daran. Eine wiederhergestellte VM mit neuer
// MAC ist für das Netz eine andere Maschine.
if !strings.Contains(fakeProxmox.configurationApplied(), "AA%3ABB%3ACC%3ADD%3AEE%3A01") {
testInstance.Errorf("die MAC-Adresse wurde nicht übernommen: %q", fakeProxmox.configurationApplied())
}
// Die Plattenzuordnung wird bewusst **nicht** aus der gesicherten
// Konfiguration gesetzt — sie verwiese auf den alten Ort, und die Maschine
// startete nicht. Das gehört als Warnung ausgewiesen, nicht verschwiegen.
if len(restoreResult.Warnings) == 0 {
testInstance.Error("die nicht übernommene Plattenzuordnung wurde nicht als Warnung ausgewiesen")
}
}
// TestProxmoxRestoreFailsWithoutRepositoryData prueft den Fehlerweg.
//
// Fehlt das Backup, darf kein halbes Archiv auf dem Knoten zurueckbleiben:
// Proxmox hielte es fuer ein gueltiges und ergaebe eine Maschine mit halben
// Daten.
func TestProxmoxRestoreFailsWithoutRepositoryData(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
_, repositoryPath := registerRepository(testInstance, connectionPool)
storageRoot := testInstance.TempDir()
fakeProxmox := startFakeProxmoxNode(testInstance, storageRoot)
hypervisorStore := buildHypervisorStore(testInstance, connectionPool)
createdCluster, createError := hypervisorStore.CreateCluster(context.Background(), hypervisor.ClusterInput{
Name: "verbund-" + uuid.NewString(),
APIEndpoint: fakeProxmox.baseURL,
APITokenID: "syncova@pve!backup",
APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert
BackupStorageID: "local",
TLSFingerprint: fakeProxmox.certificateFingerprint,
ArchiveTransport: hypervisor.TransportLocal,
ArchiveMountRoots: map[string]string{"local": storageRoot},
})
if createError != nil {
testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError)
}
testInstance.Cleanup(func() {
_ = hypervisorStore.DeleteCluster(context.Background(), createdCluster.ID)
})
_, restoreError := hypervisorStore.RestoreGuest(context.Background(), hypervisor.GuestRestoreRequest{
ClusterID: createdCluster.ID,
RepositoryPath: repositoryPath,
BackupID: "es-gibt-mich-nicht",
TargetNode: "pve-01",
}, buildTestSecretStore(testInstance), discardLogger())
if restoreError == nil {
testInstance.Fatal("eine Wiederherstellung ohne Backup wurde als erfolgreich gemeldet")
}
dumpEntries, _ := os.ReadDir(filepath.Join(storageRoot, "dump"))
if len(dumpEntries) != 0 {
testInstance.Errorf("nach dem Fehlschlag liegen %d Dateien auf dem Knoten", len(dumpEntries))
}
if fakeProxmox.sawRestoreCall() {
testInstance.Error("Proxmox wurde trotz fehlendem Archiv zur Wiederherstellung aufgefordert")
}
}
// findSingleBackupIdentifier liest die Kennung des einzigen Backups.
func findSingleBackupIdentifier(testInstance *testing.T, repositoryPath string) string {
testInstance.Helper()
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
repository.OpenOptions{ReadOnly: true}, discardLogger())
if openError != nil {
testInstance.Fatalf("das Repository ließ sich nicht öffnen: %v", openError)
}
defer func() { _ = openedRepository.Close() }()
catalogEntries, listError := openedRepository.ListBackups(context.Background())
if listError != nil || len(catalogEntries) != 1 {
testInstance.Fatalf("es ließ sich kein einzelnes Backup finden: %v", listError)
}
return catalogEntries[0].BackupID
}
// sanitizeForFileName bildet den Dateinamen wie der Executor.
func sanitizeForFileName(backupIdentifier string) string {
return strings.Map(func(currentRune rune) rune {
switch {
case currentRune >= 'a' && currentRune <= 'z',
currentRune >= 'A' && currentRune <= 'Z',
currentRune >= '0' && currentRune <= '9',
currentRune == '-', currentRune == '_':
return currentRune
default:
return '-'
}
}, backupIdentifier)
}