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>
592 lines
20 KiB
Go
592 lines
20 KiB
Go
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...)
|
|
}
|