package proxmox import ( "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "strconv" "strings" "sync" "testing" "time" ) // fakeProxmoxServer bildet die Proxmox-API für Tests nach. // // Der Nachbau ist kein Ersatz für eine echte Umgebung — die Grenzen stehen in // docs/proxmox.md. Er prüft aber genau das, was ohne Cluster prüfbar ist: dass // wir die Antwortformate richtig deuten, den Anmeldekopf richtig setzen, den // asynchronen Ablauf über UPIDs korrekt verfolgen und Fehlerfälle nicht // beschönigen. // // Die Antworten sind den echten nachgebildet: die "data"-Hülle, das // UPID-Format, die Konfigurationswerte als Zeichenkette-mit-Kommas. type fakeProxmoxServer struct { // httpServer ist der laufende Testserver. httpServer *httptest.Server // mutex schützt den veränderlichen Zustand. mutex sync.Mutex // tasksByIdentifier hält die angelegten Aufgaben. tasksByIdentifier map[string]*fakeTask // guestConfigurations sind die Konfigurationen je Gast. guestConfigurations map[int]map[string]any // guestStatuses sind die Betriebszustände je Gast. guestStatuses map[int]string // snapshotsByGuest hält die angelegten Abbilder. snapshotsByGuest map[int][]string // requestLog hält die eingegangenen Aufrufe für Zusicherungen. requestLog []string // receivedAuthorizationHeaders hält die gesehenen Anmeldeköpfe. receivedAuthorizationHeaders []string // taskCounter vergibt fortlaufende Aufgabennummern. taskCounter int // createdGuests hält die durch Wiederherstellung angelegten Gäste. createdGuests map[int]url.Values // appliedConfigurations hält die gesetzten Konfigurationen. appliedConfigurations map[int]url.Values // failNextTask lässt die nächste Aufgabe scheitern. failNextTask string // transientFailuresRemaining lässt Aufrufe vorübergehend scheitern. transientFailuresRemaining int // archiveVolumes sind die vorhandenen Sicherungsarchive je Gast. archiveVolumes map[int][]fakeArchive // deletedArchives sammelt die entfernten Sicherungsarchive. deletedArchives []string } // fakeArchive ist ein nachgebildetes Sicherungsarchiv. type fakeArchive struct { // Volume ist die Proxmox-Bezeichnung. Volume string // CreatedAt ist der Erstellungszeitpunkt als Unix-Zeit. CreatedAt int64 // Storage ist der Speicher, auf dem das Archiv liegt. Storage string } // fakeTask ist eine nachgebildete Proxmox-Aufgabe. type fakeTask struct { // Identifier ist die UPID. Identifier string // TaskType benennt die Art. TaskType string // PollsRemaining zählt, wie oft die Aufgabe noch als laufend gilt. // // Damit prüft der Test den asynchronen Ablauf wirklich: Eine Aufgabe, die // sofort fertig ist, würde eine fehlende Verfolgungsschleife nicht auffallen // lassen. PollsRemaining int // ExitStatus ist das Ergebnis nach Ablauf. ExitStatus string // NodeName ist der ausführende Knoten. NodeName string } // newFakeProxmoxServer erzeugt einen vorbereiteten Nachbau. func newFakeProxmoxServer(testInstance *testing.T) *fakeProxmoxServer { testInstance.Helper() fakeServer := &fakeProxmoxServer{ tasksByIdentifier: make(map[string]*fakeTask), guestConfigurations: map[int]map[string]any{ 100: { "name": "web-01", "cores": 4, "memory": 8192, "bios": "ovmf", "agent": "1,fstrim_cloned_disks=1", "boot": "order=scsi0;net0", "scsi0": "local-lvm:vm-100-disk-0,size=32G,ssd=1", "scsi1": "local-lvm:vm-100-disk-1,size=500G,backup=0", "efidisk0": "local-lvm:vm-100-disk-2,efitype=4m,size=1M", "ide2": "local:iso/debian-12.iso,media=cdrom", "net0": "virtio=AA:BB:CC:DD:EE:01,bridge=vmbr0,tag=42", "net1": "e1000=AA:BB:CC:DD:EE:02,bridge=vmbr1", "unused0": "local-lvm:vm-100-disk-9,size=10G", "digest": "abc123", }, 101: { "name": "db-01", "cores": 8, "memory": 32768, "scsi0": "ceph-pool:vm-101-disk-0,size=1.5T", "net0": "virtio=AA:BB:CC:DD:EE:03,bridge=vmbr0", "boot": "cdn", }, 200: { "hostname": "lxc-web", "rootfs": "local-lvm:vm-200-disk-0,size=8G", "mp0": "local-lvm:vm-200-disk-1,mp=/daten,size=100G", }, }, guestStatuses: map[int]string{ 100: "running", 101: "stopped", 200: "running", }, snapshotsByGuest: make(map[int][]string), createdGuests: make(map[int]url.Values), appliedConfigurations: make(map[int]url.Values), archiveVolumes: make(map[int][]fakeArchive), requestLog: make([]string, 0, 32), } fakeServer.httpServer = httptest.NewTLSServer(http.HandlerFunc(fakeServer.handleRequest)) testInstance.Cleanup(fakeServer.httpServer.Close) return fakeServer } // newConnectedProvider baut einen Provider gegen den Nachbau. func (fakeServer *fakeProxmoxServer) newConnectedProvider(testInstance *testing.T) *Provider { testInstance.Helper() testLogger := slog.New(slog.NewJSONHandler(io.Discard, nil)) builtProvider, providerError := NewProvider(ProviderOptions{ ClientOptions: ClientOptions{ BaseURL: fakeServer.httpServer.URL, APITokenID: "syncova@pve!backup", APITokenSecret: "geheim-nicht-in-logs", // secretscan:erlaubt: erfundener Testwert // Der Testserver trägt ein selbstsigniertes Zertifikat. Der Weg // über den Fingerabdruck wird gesondert geprüft. InsecureSkipTLSVerify: true, RequestTimeout: 5 * time.Second, MaximumRetries: 2, }, BackupStorageID: "local", // Der Nachbau antwortet sofort; ein Sekundentakt liesse die Tests nur // warten, ohne mehr zu prüfen. TaskPollInterval: 5 * time.Millisecond, }, testLogger) if providerError != nil { testInstance.Fatalf("der Provider ließ sich nicht bauen: %v", providerError) } return builtProvider } // handleRequest verteilt die Aufrufe. func (fakeServer *fakeProxmoxServer) handleRequest(responseWriter http.ResponseWriter, request *http.Request) { fakeServer.mutex.Lock() fakeServer.requestLog = append(fakeServer.requestLog, request.Method+" "+request.URL.Path) fakeServer.receivedAuthorizationHeaders = append(fakeServer.receivedAuthorizationHeaders, request.Header.Get("Authorization")) if fakeServer.transientFailuresRemaining > 0 { fakeServer.transientFailuresRemaining-- fakeServer.mutex.Unlock() // 503 ist ein vorübergehender Fehler; der Client muss ihn wiederholen. http.Error(responseWriter, `{"message":"service unavailable"}`, http.StatusServiceUnavailable) return } fakeServer.mutex.Unlock() // Der Anmeldekopf muss auf jedem Aufruf stehen, nicht nur auf dem ersten. if !strings.HasPrefix(request.Header.Get("Authorization"), "PVEAPIToken=") { http.Error(responseWriter, `{"message":"authentication failure"}`, http.StatusUnauthorized) return } apiPath := strings.TrimPrefix(request.URL.Path, apiBasePath) _ = request.ParseForm() switch { case apiPath == "/version": writeData(responseWriter, map[string]string{"version": "8.2.4", "release": "8.2"}) case apiPath == "/cluster/status": fakeServer.handleClusterStatus(responseWriter) case apiPath == "/nodes": writeData(responseWriter, []map[string]any{ {"node": "pve-01", "status": "online", "maxcpu": 32, "maxmem": int64(137438953472)}, {"node": "pve-02", "status": "online", "maxcpu": 32, "maxmem": int64(137438953472)}, {"node": "pve-03", "status": "offline", "maxcpu": 16, "maxmem": int64(68719476736)}, }) case apiPath == "/cluster/resources": fakeServer.handleClusterResources(responseWriter) case strings.HasSuffix(apiPath, "/config"): fakeServer.handleConfiguration(responseWriter, request, apiPath) case strings.HasSuffix(apiPath, "/status/current"): fakeServer.handleGuestStatus(responseWriter, apiPath) case strings.HasSuffix(apiPath, "/status/start"): fakeServer.handleStart(responseWriter, apiPath) case strings.Contains(apiPath, "/tasks/") && strings.HasSuffix(apiPath, "/status"): fakeServer.handleTaskStatus(responseWriter, apiPath) case strings.Contains(apiPath, "/tasks/") && strings.HasSuffix(apiPath, "/log"): writeData(responseWriter, []map[string]any{ {"n": 1, "t": "starting task"}, {"n": 2, "t": "ERROR: no space left on device"}, }) case strings.Contains(apiPath, "/snapshot"): fakeServer.handleSnapshot(responseWriter, request, apiPath) case strings.HasSuffix(apiPath, "/vzdump"): fakeServer.handleVzdump(responseWriter, request, apiPath) case strings.Contains(apiPath, "/storage/") && strings.HasSuffix(apiPath, "/content"): fakeServer.handleStorageContent(responseWriter, request, apiPath) case strings.Contains(apiPath, "/storage/") && strings.HasSuffix(apiPath, "/status"): fakeServer.handleStorageStatus(responseWriter, apiPath) case request.Method == http.MethodDelete && strings.Contains(apiPath, "/storage/") && strings.Contains(apiPath, "/content/"): fakeServer.handleArchiveDeletion(responseWriter, apiPath) case request.Method == http.MethodPost && (strings.HasSuffix(apiPath, "/qemu") || strings.HasSuffix(apiPath, "/lxc")): fakeServer.handleRestore(responseWriter, request, apiPath) default: http.Error(responseWriter, `{"message":"not found"}`, http.StatusNotFound) } } // handleClusterStatus liefert den Verbundstatus. func (fakeServer *fakeProxmoxServer) handleClusterStatus(responseWriter http.ResponseWriter) { writeData(responseWriter, []map[string]any{ {"type": "cluster", "id": "cluster", "name": "syncova-lab", "nodes": 3, "quorate": 1, "version": 5}, {"type": "node", "id": "node/pve-01", "name": "pve-01", "online": 1}, {"type": "node", "id": "node/pve-02", "name": "pve-02", "online": 1}, {"type": "node", "id": "node/pve-03", "name": "pve-03", "online": 0}, }) } // handleClusterResources liefert die Gäste des Verbunds. func (fakeServer *fakeProxmoxServer) handleClusterResources(responseWriter http.ResponseWriter) { fakeServer.mutex.Lock() defer fakeServer.mutex.Unlock() resourceEntries := []map[string]any{ {"type": "qemu", "id": "qemu/100", "node": "pve-01", "vmid": 100, "name": "web-01", "status": fakeServer.guestStatuses[100], "maxcpu": 4.0, "maxmem": int64(8589934592), "tags": "produktiv;web"}, {"type": "qemu", "id": "qemu/101", "node": "pve-02", "vmid": 101, "name": "db-01", "status": fakeServer.guestStatuses[101], "maxcpu": 8.0, "maxmem": int64(34359738368), "tags": "produktiv;datenbank"}, {"type": "lxc", "id": "lxc/200", "node": "pve-01", "vmid": 200, "name": "lxc-web", "status": fakeServer.guestStatuses[200], "maxcpu": 2.0, "maxmem": int64(2147483648)}, // Eine Vorlage: sie darf in der Erfassung nicht erscheinen. {"type": "qemu", "id": "qemu/900", "node": "pve-01", "vmid": 900, "name": "vorlage-debian", "status": "stopped", "template": 1}, } for guestID := range fakeServer.createdGuests { resourceEntries = append(resourceEntries, map[string]any{ "type": "qemu", "id": fmt.Sprintf("qemu/%d", guestID), "node": "pve-01", "vmid": guestID, "name": fmt.Sprintf("restore-%d", guestID), "status": "stopped", }) } writeData(responseWriter, resourceEntries) } // handleConfiguration liefert oder setzt eine Gastkonfiguration. func (fakeServer *fakeProxmoxServer) handleConfiguration(responseWriter http.ResponseWriter, request *http.Request, apiPath string) { guestID := extractGuestIDFromPath(apiPath) fakeServer.mutex.Lock() defer fakeServer.mutex.Unlock() if request.Method == http.MethodPut { fakeServer.appliedConfigurations[guestID] = request.PostForm writeData(responseWriter, nil) return } guestConfiguration, isKnown := fakeServer.guestConfigurations[guestID] if !isKnown { http.Error(responseWriter, `{"message":"Configuration file does not exist"}`, http.StatusNotFound) return } writeData(responseWriter, guestConfiguration) } // handleGuestStatus liefert den Betriebszustand. func (fakeServer *fakeProxmoxServer) handleGuestStatus(responseWriter http.ResponseWriter, apiPath string) { guestID := extractGuestIDFromPath(apiPath) fakeServer.mutex.Lock() defer fakeServer.mutex.Unlock() currentStatus, isKnown := fakeServer.guestStatuses[guestID] if !isKnown { currentStatus = "stopped" } writeData(responseWriter, map[string]any{"status": currentStatus, "qmpstatus": currentStatus}) } // handleStart startet einen Gast. func (fakeServer *fakeProxmoxServer) handleStart(responseWriter http.ResponseWriter, apiPath string) { guestID := extractGuestIDFromPath(apiPath) fakeServer.mutex.Lock() fakeServer.guestStatuses[guestID] = "running" fakeServer.mutex.Unlock() writeData(responseWriter, fakeServer.createTask("qmstart", "pve-01", 1)) } // handleSnapshot legt Abbilder an oder entfernt sie. func (fakeServer *fakeProxmoxServer) handleSnapshot(responseWriter http.ResponseWriter, request *http.Request, apiPath string) { guestID := extractGuestIDFromPath(apiPath) switch request.Method { case http.MethodPost: snapshotName := request.PostForm.Get("snapname") fakeServer.mutex.Lock() fakeServer.snapshotsByGuest[guestID] = append(fakeServer.snapshotsByGuest[guestID], snapshotName) fakeServer.mutex.Unlock() // Zwei Abfragen als laufend: der Test soll die Verfolgungsschleife // wirklich durchlaufen. writeData(responseWriter, fakeServer.createTask("qmsnapshot", "pve-01", 2)) case http.MethodDelete: snapshotName := apiPath[strings.LastIndex(apiPath, "/")+1:] fakeServer.mutex.Lock() remainingSnapshots := make([]string, 0) for _, existingSnapshot := range fakeServer.snapshotsByGuest[guestID] { if existingSnapshot != snapshotName { remainingSnapshots = append(remainingSnapshots, existingSnapshot) } } fakeServer.snapshotsByGuest[guestID] = remainingSnapshots fakeServer.mutex.Unlock() writeData(responseWriter, fakeServer.createTask("qmdelsnapshot", "pve-01", 1)) default: http.Error(responseWriter, `{"message":"method not allowed"}`, http.StatusMethodNotAllowed) } } // knownBackupStorages sind die Speicher, die im Nachbau Sicherungen aufnehmen. // // Ein unbekannter Speicher nimmt nichts auf. Damit lässt sich der Fall prüfen, // dass eine Aufgabe Erfolg meldet und trotzdem kein Archiv entsteht. var knownBackupStorages = map[string]bool{"local": true, "nfs-backup": true} // handleVzdump stößt eine Proxmox-Sicherung an. func (fakeServer *fakeProxmoxServer) handleVzdump(responseWriter http.ResponseWriter, request *http.Request, _ string) { guestID, _ := strconv.Atoi(request.PostForm.Get("vmid")) storageName := request.PostForm.Get("storage") if knownBackupStorages[storageName] { fakeServer.mutex.Lock() fakeServer.archiveVolumes[guestID] = append(fakeServer.archiveVolumes[guestID], fakeArchive{ Volume: fmt.Sprintf("%s:backup/vzdump-qemu-%d-2026_08_11-02_00_00.vma", storageName, guestID), CreatedAt: time.Now().Unix(), Storage: storageName, }) fakeServer.mutex.Unlock() } writeData(responseWriter, fakeServer.createTask("vzdump", "pve-01", 2)) } // handleStorageContent liefert die Archivliste eines Speichers. func (fakeServer *fakeProxmoxServer) handleStorageContent(responseWriter http.ResponseWriter, request *http.Request, apiPath string) { guestID, _ := strconv.Atoi(request.URL.Query().Get("vmid")) storageName := extractStorageFromPath(apiPath) fakeServer.mutex.Lock() defer fakeServer.mutex.Unlock() contentEntries := make([]map[string]any, 0) for _, archiveEntry := range fakeServer.archiveVolumes[guestID] { // Ein Archiv liegt auf genau einem Speicher; ein anderer zeigt es nicht. if archiveEntry.Storage != storageName { continue } contentEntries = append(contentEntries, map[string]any{ "volid": archiveEntry.Volume, "ctime": archiveEntry.CreatedAt, "size": int64(1024), }) } writeData(responseWriter, contentEntries) } // extractStorageFromPath liest den Speichernamen aus einem API-Pfad. func extractStorageFromPath(apiPath string) string { pathSegments := strings.Split(apiPath, "/") for segmentIndex, pathSegment := range pathSegments { if pathSegment == "storage" && segmentIndex+1 < len(pathSegments) { return pathSegments[segmentIndex+1] } } return "" } // handleRestore legt einen Gast aus einem Archiv an. func (fakeServer *fakeProxmoxServer) handleRestore(responseWriter http.ResponseWriter, request *http.Request, _ string) { guestID, _ := strconv.Atoi(request.PostForm.Get("vmid")) fakeServer.mutex.Lock() fakeServer.createdGuests[guestID] = request.PostForm fakeServer.guestStatuses[guestID] = "stopped" fakeServer.mutex.Unlock() writeData(responseWriter, fakeServer.createTask("qmrestore", "pve-01", 2)) } // handleTaskStatus liefert den Stand einer Aufgabe. func (fakeServer *fakeProxmoxServer) handleTaskStatus(responseWriter http.ResponseWriter, apiPath string) { pathSegments := strings.Split(strings.TrimPrefix(apiPath, "/"), "/") // Aufbau: nodes/{knoten}/tasks/{upid}/status const taskIdentifierSegment = 3 if len(pathSegments) <= taskIdentifierSegment { http.Error(responseWriter, `{"message":"not found"}`, http.StatusNotFound) return } taskIdentifier := pathSegments[taskIdentifierSegment] fakeServer.mutex.Lock() defer fakeServer.mutex.Unlock() currentTask, isKnown := fakeServer.tasksByIdentifier[taskIdentifier] if !isKnown { http.Error(responseWriter, `{"message":"no such task"}`, http.StatusNotFound) return } if currentTask.PollsRemaining > 0 { currentTask.PollsRemaining-- writeData(responseWriter, map[string]any{ "status": "running", "type": currentTask.TaskType, "node": currentTask.NodeName, "starttime": time.Now().Unix(), }) return } writeData(responseWriter, map[string]any{ "status": "stopped", "exitstatus": currentTask.ExitStatus, "type": currentTask.TaskType, "node": currentTask.NodeName, "starttime": time.Now().Unix(), }) } // createTask legt eine nachgebildete Aufgabe an und liefert ihre UPID. func (fakeServer *fakeProxmoxServer) createTask(taskType string, nodeName string, pollsRemaining int) string { fakeServer.mutex.Lock() defer fakeServer.mutex.Unlock() fakeServer.taskCounter++ // Echtes UPID-Format: UPID:node:pid:pstart:starttime:type:id:user: taskIdentifier := fmt.Sprintf("UPID:%s:%08X:%08X:%08X:%s:100:syncova@pve:", nodeName, fakeServer.taskCounter, fakeServer.taskCounter, time.Now().Unix(), taskType) exitStatus := "OK" if fakeServer.failNextTask != "" { exitStatus = fakeServer.failNextTask fakeServer.failNextTask = "" } fakeServer.tasksByIdentifier[taskIdentifier] = &fakeTask{ Identifier: taskIdentifier, TaskType: taskType, PollsRemaining: pollsRemaining, ExitStatus: exitStatus, NodeName: nodeName, } return taskIdentifier } // extractGuestIDFromPath liest die Gastkennung aus einem API-Pfad. func extractGuestIDFromPath(apiPath string) int { pathSegments := strings.Split(apiPath, "/") for segmentIndex, pathSegment := range pathSegments { if pathSegment != "qemu" && pathSegment != "lxc" { continue } if segmentIndex+1 >= len(pathSegments) { return 0 } guestID, conversionError := strconv.Atoi(pathSegments[segmentIndex+1]) if conversionError != nil { return 0 } return guestID } return 0 } // writeData schreibt eine Antwort in der Proxmox-Hülle. func writeData(responseWriter http.ResponseWriter, payload any) { responseWriter.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(responseWriter).Encode(map[string]any{"data": payload}) } // handleStorageStatus liefert die Angaben eines Speichers. // // Ein verzeichnisbasierter Speicher traegt einen Pfad; ZFS, LVM und Ceph nicht. // Der Unterschied entscheidet, ob sich ein Archiv als Datei lesen laesst. func (fakeServer *fakeProxmoxServer) handleStorageStatus(responseWriter http.ResponseWriter, apiPath string) { if strings.Contains(apiPath, "/zfs-pool/") { writeData(responseWriter, map[string]any{"type": "zfspool", "path": ""}) return } writeData(responseWriter, map[string]any{ "type": "dir", "path": "/var/lib/vz", "total": 500 * 1024 * 1024 * 1024, "avail": 200 * 1024 * 1024 * 1024, }) } // handleArchiveDeletion nimmt das Entfernen eines Archivs entgegen. func (fakeServer *fakeProxmoxServer) handleArchiveDeletion(responseWriter http.ResponseWriter, apiPath string) { fakeServer.mutex.Lock() fakeServer.deletedArchives = append(fakeServer.deletedArchives, apiPath) fakeServer.mutex.Unlock() writeData(responseWriter, "UPID:pve-01:00001234:00ABCDEF:66B00000:imgdel:local:root@pam:") } // deletedArchiveList liefert die entfernten Archive. // // Ueber die Sperre, weil das Aufraeumen aus dem Close() eines Datenstroms // heraus geschieht — also nicht zwingend aus dem Ablauf des Tests. func (fakeServer *fakeProxmoxServer) deletedArchiveList() []string { fakeServer.mutex.Lock() defer fakeServer.mutex.Unlock() return append([]string(nil), fakeServer.deletedArchives...) }