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>
714 lines
26 KiB
Go
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)
|
|
}
|