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>
611 lines
19 KiB
Go
611 lines
19 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/syncova/syncova/packages/providers"
|
|
)
|
|
|
|
// clusterResource ist ein Eintrag aus /cluster/resources.
|
|
//
|
|
// Der Endpunkt liefert Knoten, Gäste und Speicher in einem Aufruf. Ihn zu
|
|
// nutzen statt jeden Knoten einzeln zu befragen ist bei größeren Verbünden der
|
|
// Unterschied zwischen einem Aufruf und hunderten.
|
|
type clusterResource struct {
|
|
// ResourceType ist "node", "qemu", "lxc", "storage" oder "pool".
|
|
ResourceType string `json:"type"`
|
|
// ID ist die zusammengesetzte Kennung, etwa "qemu/100".
|
|
ID string `json:"id"`
|
|
// Node ist der Knotenname.
|
|
Node string `json:"node"`
|
|
// VMID ist die Gastkennung.
|
|
VMID int `json:"vmid"`
|
|
// Name ist die Bezeichnung.
|
|
Name string `json:"name"`
|
|
// Status ist der Zustand.
|
|
Status string `json:"status"`
|
|
// MaxCPU ist die Zahl der Prozessoren.
|
|
MaxCPU float64 `json:"maxcpu"`
|
|
// MaxMemory ist der Arbeitsspeicher in Byte.
|
|
MaxMemory int64 `json:"maxmem"`
|
|
// Tags sind die Etiketten, durch Semikolon getrennt.
|
|
Tags string `json:"tags"`
|
|
// Template meldet eine Vorlage statt eines Gasts.
|
|
Template int `json:"template"`
|
|
// Uptime ist die Laufzeit in Sekunden.
|
|
Uptime int64 `json:"uptime"`
|
|
}
|
|
|
|
// ListClusters ermittelt den Verbund.
|
|
//
|
|
// Proxmox kennt genau einen Verbund je Endpunkt; ein Einzelknoten ohne Verbund
|
|
// wird als Verbund mit einem Wirt gemeldet. Die Schnittstelle gibt trotzdem
|
|
// eine Liste zurück, weil VMware mehrere kennt — eine Sonderform hier
|
|
// erzwänge später eine Änderung an der Schnittstelle.
|
|
func (provider *Provider) ListClusters(listContext context.Context) ([]providers.Cluster, error) {
|
|
if provider.client == nil {
|
|
return nil, providers.ErrNotConnected
|
|
}
|
|
|
|
var statusEntries []struct {
|
|
Type string `json:"type"`
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Nodes int `json:"nodes"`
|
|
Quorate int `json:"quorate"`
|
|
Version int `json:"version"`
|
|
Online int `json:"online"`
|
|
}
|
|
|
|
if statusError := provider.client.get(listContext, "/cluster/status", &statusEntries); statusError != nil {
|
|
return nil, fmt.Errorf("der verbundstatus war nicht abrufbar: %w", statusError)
|
|
}
|
|
|
|
discoveredClusters := make([]providers.Cluster, 0, 1)
|
|
var onlineNodeCount int
|
|
|
|
for _, statusEntry := range statusEntries {
|
|
if statusEntry.Type == "node" {
|
|
onlineNodeCount++
|
|
continue
|
|
}
|
|
|
|
if statusEntry.Type != "cluster" {
|
|
continue
|
|
}
|
|
|
|
discoveredClusters = append(discoveredClusters, providers.Cluster{
|
|
Identifier: statusEntry.Name,
|
|
Name: statusEntry.Name,
|
|
HostCount: statusEntry.Nodes,
|
|
Quorate: statusEntry.Quorate == 1,
|
|
})
|
|
}
|
|
|
|
// Ein Einzelknoten meldet keinen Verbundeintrag. Ihn zu übergehen liesse
|
|
// die Erfassung leer ausgehen — ausgerechnet im häufigsten kleinen Aufbau.
|
|
if len(discoveredClusters) == 0 {
|
|
discoveredClusters = append(discoveredClusters, providers.Cluster{
|
|
Identifier: "standalone",
|
|
Name: "Einzelknoten",
|
|
HostCount: onlineNodeCount,
|
|
// Ein Einzelknoten ist immer beschlussfähig: es gibt niemanden,
|
|
// mit dem er sich einigen müsste.
|
|
Quorate: true,
|
|
})
|
|
}
|
|
|
|
return discoveredClusters, nil
|
|
}
|
|
|
|
// ListHosts ermittelt die Knoten.
|
|
func (provider *Provider) ListHosts(listContext context.Context, clusterID string) ([]providers.Host, error) {
|
|
if provider.client == nil {
|
|
return nil, providers.ErrNotConnected
|
|
}
|
|
|
|
var nodeEntries []struct {
|
|
Node string `json:"node"`
|
|
Status string `json:"status"`
|
|
MaxCPU int `json:"maxcpu"`
|
|
MaxMemory int64 `json:"maxmem"`
|
|
Uptime int64 `json:"uptime"`
|
|
CPUUsage float64 `json:"cpu"`
|
|
}
|
|
|
|
if nodesError := provider.client.get(listContext, "/nodes", &nodeEntries); nodesError != nil {
|
|
return nil, fmt.Errorf("die knotenliste war nicht abrufbar: %w", nodesError)
|
|
}
|
|
|
|
discoveredHosts := make([]providers.Host, 0, len(nodeEntries))
|
|
|
|
for _, nodeEntry := range nodeEntries {
|
|
discoveredHosts = append(discoveredHosts, providers.Host{
|
|
Identifier: nodeEntry.Node,
|
|
Name: nodeEntry.Node,
|
|
ClusterID: clusterID,
|
|
Online: nodeEntry.Status == "online",
|
|
CPUCount: nodeEntry.MaxCPU,
|
|
MemoryBytes: nodeEntry.MaxMemory,
|
|
})
|
|
}
|
|
|
|
sort.Slice(discoveredHosts, func(firstIndex int, secondIndex int) bool {
|
|
return discoveredHosts[firstIndex].Name < discoveredHosts[secondIndex].Name
|
|
})
|
|
|
|
return discoveredHosts, nil
|
|
}
|
|
|
|
// ListVMs ermittelt die Gäste; ein leerer Wirt bedeutet alle Wirte.
|
|
func (provider *Provider) ListVMs(listContext context.Context, hostID string) ([]providers.Guest, error) {
|
|
if provider.client == nil {
|
|
return nil, providers.ErrNotConnected
|
|
}
|
|
|
|
var clusterResources []clusterResource
|
|
if resourceError := provider.client.get(listContext, "/cluster/resources?type=vm", &clusterResources); resourceError != nil {
|
|
return nil, fmt.Errorf("die gastliste war nicht abrufbar: %w", resourceError)
|
|
}
|
|
|
|
discoveredGuests := make([]providers.Guest, 0, len(clusterResources))
|
|
|
|
for _, resourceEntry := range clusterResources {
|
|
if hostID != "" && resourceEntry.Node != hostID {
|
|
continue
|
|
}
|
|
|
|
// Vorlagen sind keine lauffähigen Gäste. Sie mitzusichern erzeugte
|
|
// Backups, die niemand wiederherstellen will, und verfälschte jede
|
|
// Zählung.
|
|
if resourceEntry.Template == 1 {
|
|
continue
|
|
}
|
|
|
|
guestType := providers.GuestTypeVirtualMachine
|
|
if resourceEntry.ResourceType == "lxc" {
|
|
guestType = providers.GuestTypeContainer
|
|
}
|
|
|
|
discoveredGuests = append(discoveredGuests, providers.Guest{
|
|
Identifier: formatGuestIdentifier(resourceEntry.ResourceType, resourceEntry.VMID),
|
|
Name: resourceEntry.Name,
|
|
GuestType: guestType,
|
|
HostID: resourceEntry.Node,
|
|
PowerState: translatePowerState(resourceEntry.Status),
|
|
CPUCount: int(resourceEntry.MaxCPU),
|
|
MemoryBytes: resourceEntry.MaxMemory,
|
|
Tags: splitTags(resourceEntry.Tags),
|
|
})
|
|
}
|
|
|
|
sort.Slice(discoveredGuests, func(firstIndex int, secondIndex int) bool {
|
|
return discoveredGuests[firstIndex].Identifier < discoveredGuests[secondIndex].Identifier
|
|
})
|
|
|
|
return discoveredGuests, nil
|
|
}
|
|
|
|
// GetVMInfo liefert die Angaben zu einem Gast.
|
|
func (provider *Provider) GetVMInfo(infoContext context.Context, guestID string) (*providers.Guest, error) {
|
|
allGuests, listError := provider.ListVMs(infoContext, "")
|
|
if listError != nil {
|
|
return nil, listError
|
|
}
|
|
|
|
for guestIndex := range allGuests {
|
|
if allGuests[guestIndex].Identifier == guestID {
|
|
return &allGuests[guestIndex], nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("%w: %s", providers.ErrGuestNotFound, guestID)
|
|
}
|
|
|
|
// GetVMMetaData liefert die Konfiguration eines Gasts.
|
|
func (provider *Provider) GetVMMetaData(metadataContext context.Context, guestID string) (*providers.GuestMetadata, error) {
|
|
guestLocation, locateError := provider.locateGuest(metadataContext, guestID)
|
|
if locateError != nil {
|
|
return nil, locateError
|
|
}
|
|
|
|
rawConfiguration, configurationError := provider.fetchRawConfiguration(metadataContext, guestLocation)
|
|
if configurationError != nil {
|
|
return nil, configurationError
|
|
}
|
|
|
|
guestMetadata := &providers.GuestMetadata{
|
|
GuestID: guestID,
|
|
RawConfiguration: rawConfiguration,
|
|
// Der Gastdienst muss ausdrücklich eingeschaltet sein. Ihn anzunehmen
|
|
// hiesse, eine anwendungskonsistente Sicherung zu versprechen, die
|
|
// nicht möglich ist.
|
|
GuestAgentEnabled: strings.HasPrefix(rawConfiguration["agent"], "1"),
|
|
}
|
|
|
|
if biosValue, hasBIOS := rawConfiguration["bios"]; hasBIOS {
|
|
guestMetadata.FirmwareType = biosValue
|
|
} else if guestLocation.GuestType == providers.GuestTypeVirtualMachine {
|
|
// Proxmox lässt "bios" weg, wenn der Standard gilt. Das Feld leer zu
|
|
// lassen wäre missverständlich: eine wiederhergestellte Maschine mit
|
|
// UEFI statt SeaBIOS startet nicht.
|
|
guestMetadata.FirmwareType = "seabios"
|
|
}
|
|
|
|
guestMetadata.NetworkInterfaces = parseNetworkInterfaces(rawConfiguration)
|
|
guestMetadata.BootOrder = parseBootOrder(rawConfiguration)
|
|
|
|
return guestMetadata, nil
|
|
}
|
|
|
|
// diskPropertyPattern erkennt die Plattenschlüssel der Proxmox-Konfiguration.
|
|
//
|
|
// QEMU: scsi0, virtio3, ide2, sata1 — LXC: rootfs, mp0 … mp255.
|
|
var diskPropertyPattern = regexp.MustCompile(`^(scsi|virtio|ide|sata|efidisk|tpmstate|unused|mp)(\d+)$|^rootfs$`)
|
|
|
|
// nonDiskDrivePattern erkennt Einträge, die zwar an einem Plattenanschluss
|
|
// hängen, aber keine Platte sind.
|
|
//
|
|
// Ein eingelegtes ISO-Abbild steht als "ide2: local:iso/debian.iso,media=cdrom"
|
|
// in der Konfiguration. Es mitzusichern lüde ein Installationsmedium ins
|
|
// Backup; es beim Wiederherstellen zu erwarten liesse die Maschine scheitern,
|
|
// wenn das Abbild fehlt.
|
|
var nonDiskDrivePattern = regexp.MustCompile(`media=cdrom|^none,|^cdrom,`)
|
|
|
|
// GetVMDisks liefert die Platten eines Gasts.
|
|
func (provider *Provider) GetVMDisks(diskContext context.Context, guestID string) ([]providers.Disk, error) {
|
|
guestLocation, locateError := provider.locateGuest(diskContext, guestID)
|
|
if locateError != nil {
|
|
return nil, locateError
|
|
}
|
|
|
|
rawConfiguration, configurationError := provider.fetchRawConfiguration(diskContext, guestLocation)
|
|
if configurationError != nil {
|
|
return nil, configurationError
|
|
}
|
|
|
|
discoveredDisks := make([]providers.Disk, 0, 4)
|
|
|
|
for propertyName, propertyValue := range rawConfiguration {
|
|
if !diskPropertyPattern.MatchString(propertyName) {
|
|
continue
|
|
}
|
|
|
|
if nonDiskDrivePattern.MatchString(propertyValue) {
|
|
continue
|
|
}
|
|
|
|
discoveredDisks = append(discoveredDisks, parseDiskProperty(propertyName, propertyValue))
|
|
}
|
|
|
|
sort.Slice(discoveredDisks, func(firstIndex int, secondIndex int) bool {
|
|
return discoveredDisks[firstIndex].Identifier < discoveredDisks[secondIndex].Identifier
|
|
})
|
|
|
|
return discoveredDisks, nil
|
|
}
|
|
|
|
// parseDiskProperty deutet einen Plattenwert der Proxmox-Konfiguration.
|
|
//
|
|
// Aufbau: "local-lvm:vm-100-disk-0,size=32G,backup=0,ssd=1"
|
|
func parseDiskProperty(propertyName string, propertyValue string) providers.Disk {
|
|
valueParts := strings.Split(propertyValue, ",")
|
|
|
|
parsedDisk := providers.Disk{
|
|
Identifier: propertyName,
|
|
Volume: valueParts[0],
|
|
}
|
|
|
|
// Der Datenträger steht als "speicher:pfad" am Anfang.
|
|
if separatorIndex := strings.Index(valueParts[0], ":"); separatorIndex > 0 {
|
|
parsedDisk.StorageID = valueParts[0][:separatorIndex]
|
|
}
|
|
|
|
// Ein "unused"-Eintrag ist eine abgehängte Platte. Sie gehört ins Backup —
|
|
// sie enthält Daten —, aber Proxmox bindet sie nicht ein.
|
|
if strings.HasPrefix(propertyName, "unused") {
|
|
parsedDisk.ReadOnly = true
|
|
}
|
|
|
|
for _, valuePart := range valueParts[1:] {
|
|
optionName, optionValue, hasValue := strings.Cut(valuePart, "=")
|
|
if !hasValue {
|
|
continue
|
|
}
|
|
|
|
switch optionName {
|
|
case "size":
|
|
parsedDisk.SizeBytes = parseProxmoxSize(optionValue)
|
|
|
|
case "format":
|
|
parsedDisk.Format = optionValue
|
|
|
|
case "backup":
|
|
// "backup=0" nimmt die Platte ausdrücklich von der Sicherung aus.
|
|
// Diese Angabe ist der wichtigste Fund dieser Funktion: Wer sie
|
|
// übergeht, hält eine unvollständige Maschine für vollständig.
|
|
parsedDisk.ExcludedFromBackup = optionValue == "0"
|
|
|
|
case "ro":
|
|
parsedDisk.ReadOnly = optionValue == "1"
|
|
}
|
|
}
|
|
|
|
return parsedDisk
|
|
}
|
|
|
|
// sizeSuffixFactors sind die Vielfachen der Proxmox-Größenangaben.
|
|
var sizeSuffixFactors = map[string]int64{
|
|
"K": 1 << 10,
|
|
"M": 1 << 20,
|
|
"G": 1 << 30,
|
|
"T": 1 << 40,
|
|
}
|
|
|
|
// parseProxmoxSize deutet eine Größenangabe wie "32G".
|
|
func parseProxmoxSize(sizeText string) int64 {
|
|
trimmedText := strings.TrimSpace(sizeText)
|
|
if trimmedText == "" {
|
|
return 0
|
|
}
|
|
|
|
suffixCharacter := strings.ToUpper(trimmedText[len(trimmedText)-1:])
|
|
|
|
suffixFactor, hasSuffix := sizeSuffixFactors[suffixCharacter]
|
|
if !hasSuffix {
|
|
// Ohne Einheit meint Proxmox Byte.
|
|
return parseOptionalInteger(trimmedText)
|
|
}
|
|
|
|
numericPart := strings.TrimSpace(trimmedText[:len(trimmedText)-1])
|
|
|
|
// Proxmox erlaubt gebrochene Angaben wie "1.5T".
|
|
parsedValue, parseError := strconv.ParseFloat(numericPart, 64)
|
|
if parseError != nil {
|
|
return 0
|
|
}
|
|
|
|
return int64(parsedValue * float64(suffixFactor))
|
|
}
|
|
|
|
// parseNetworkInterfaces liest die Netzwerkkarten aus der Konfiguration.
|
|
//
|
|
// Aufbau: "net0: virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=42"
|
|
func parseNetworkInterfaces(rawConfiguration map[string]string) []providers.NetworkInterface {
|
|
networkPattern := regexp.MustCompile(`^net(\d+)$`)
|
|
|
|
parsedInterfaces := make([]providers.NetworkInterface, 0, 2)
|
|
|
|
for propertyName, propertyValue := range rawConfiguration {
|
|
if !networkPattern.MatchString(propertyName) {
|
|
continue
|
|
}
|
|
|
|
parsedInterface := providers.NetworkInterface{Identifier: propertyName}
|
|
|
|
for _, valuePart := range strings.Split(propertyValue, ",") {
|
|
optionName, optionValue, hasValue := strings.Cut(valuePart, "=")
|
|
if !hasValue {
|
|
continue
|
|
}
|
|
|
|
switch optionName {
|
|
case "bridge":
|
|
parsedInterface.Bridge = optionValue
|
|
|
|
case "tag":
|
|
parsedInterface.VLANTag = int(parseOptionalInteger(optionValue))
|
|
|
|
case "virtio", "e1000", "rtl8139", "vmxnet3", "e1000e":
|
|
// Das Kartenmodell steht als Schlüssel, die MAC-Adresse als
|
|
// Wert. Die Adresse muss erhalten bleiben: Lizenzbindungen und
|
|
// DHCP-Reservierungen hängen daran.
|
|
parsedInterface.Model = optionName
|
|
parsedInterface.MACAddress = optionValue
|
|
}
|
|
}
|
|
|
|
parsedInterfaces = append(parsedInterfaces, parsedInterface)
|
|
}
|
|
|
|
sort.Slice(parsedInterfaces, func(firstIndex int, secondIndex int) bool {
|
|
return parsedInterfaces[firstIndex].Identifier < parsedInterfaces[secondIndex].Identifier
|
|
})
|
|
|
|
return parsedInterfaces
|
|
}
|
|
|
|
// parseBootOrder liest die Startreihenfolge.
|
|
//
|
|
// Aufbau: "order=scsi0;net0" (neu) oder "cdn" (alt, zeichenweise).
|
|
func parseBootOrder(rawConfiguration map[string]string) []string {
|
|
bootValue, hasBootValue := rawConfiguration["boot"]
|
|
if !hasBootValue {
|
|
return nil
|
|
}
|
|
|
|
if orderPart, hasOrder := strings.CutPrefix(bootValue, "order="); hasOrder {
|
|
return strings.Split(orderPart, ";")
|
|
}
|
|
|
|
// Die alte Schreibweise nennt Geräteklassen: c=Platte, d=CD, n=Netz.
|
|
legacyDeviceNames := map[rune]string{'c': "disk", 'd': "cdrom", 'n': "network", 'a': "floppy"}
|
|
|
|
bootOrder := make([]string, 0, len(bootValue))
|
|
for _, deviceCharacter := range bootValue {
|
|
if deviceName, isKnown := legacyDeviceNames[deviceCharacter]; isKnown {
|
|
bootOrder = append(bootOrder, deviceName)
|
|
}
|
|
}
|
|
|
|
return bootOrder
|
|
}
|
|
|
|
// guestLocation ist ein aufgelöster Gast samt Knoten.
|
|
type guestLocation struct {
|
|
// NodeName ist der Knoten, auf dem der Gast liegt.
|
|
NodeName string
|
|
// VMID ist die numerische Kennung.
|
|
VMID int
|
|
// GuestType ist die Art des Gasts.
|
|
GuestType providers.GuestType
|
|
// APISegment ist "qemu" oder "lxc" für die Pfadbildung.
|
|
APISegment string
|
|
}
|
|
|
|
// locateGuest ermittelt Knoten und Art eines Gasts.
|
|
//
|
|
// Der Umweg über die Verbundressourcen ist nötig, weil die Proxmox-Pfade den
|
|
// Knoten enthalten: /nodes/{knoten}/qemu/{vmid}. Eine Gastkennung allein
|
|
// genügt nicht, und der Knoten kann sich durch Migration ändern.
|
|
func (provider *Provider) locateGuest(locateContext context.Context, guestID string) (guestLocation, error) {
|
|
if provider.client == nil {
|
|
return guestLocation{}, providers.ErrNotConnected
|
|
}
|
|
|
|
apiSegment, numericID, parseError := parseGuestIdentifier(guestID)
|
|
if parseError != nil {
|
|
return guestLocation{}, parseError
|
|
}
|
|
|
|
var clusterResources []clusterResource
|
|
if resourceError := provider.client.get(locateContext, "/cluster/resources?type=vm", &clusterResources); resourceError != nil {
|
|
return guestLocation{}, fmt.Errorf("der gast %s war nicht auffindbar: %w", guestID, resourceError)
|
|
}
|
|
|
|
for _, resourceEntry := range clusterResources {
|
|
if resourceEntry.VMID != numericID || resourceEntry.ResourceType != apiSegment {
|
|
continue
|
|
}
|
|
|
|
guestType := providers.GuestTypeVirtualMachine
|
|
if resourceEntry.ResourceType == "lxc" {
|
|
guestType = providers.GuestTypeContainer
|
|
}
|
|
|
|
return guestLocation{
|
|
NodeName: resourceEntry.Node,
|
|
VMID: numericID,
|
|
GuestType: guestType,
|
|
APISegment: apiSegment,
|
|
}, nil
|
|
}
|
|
|
|
return guestLocation{}, fmt.Errorf("%w: %s", providers.ErrGuestNotFound, guestID)
|
|
}
|
|
|
|
// fetchRawConfiguration holt die unveränderte Konfiguration eines Gasts.
|
|
func (provider *Provider) fetchRawConfiguration(configurationContext context.Context, location guestLocation) (map[string]string, error) {
|
|
configurationPath := fmt.Sprintf("/nodes/%s/%s/%d/config",
|
|
url.PathEscape(location.NodeName), location.APISegment, location.VMID)
|
|
|
|
// Die Werte kommen je nach Feld als Zeichenkette, Zahl oder Wahrheitswert.
|
|
// Sie werden einheitlich als Text abgelegt: die Konfiguration wird
|
|
// wortgetreu mitgesichert, nicht umgedeutet.
|
|
var rawValues map[string]any
|
|
if configurationError := provider.client.get(configurationContext, configurationPath, &rawValues); configurationError != nil {
|
|
return nil, fmt.Errorf("die konfiguration von %d war nicht abrufbar: %w", location.VMID, configurationError)
|
|
}
|
|
|
|
textualConfiguration := make(map[string]string, len(rawValues))
|
|
for propertyName, propertyValue := range rawValues {
|
|
textualConfiguration[propertyName] = formatConfigurationValue(propertyValue)
|
|
}
|
|
|
|
return textualConfiguration, nil
|
|
}
|
|
|
|
// formatConfigurationValue bringt einen Konfigurationswert in Textform.
|
|
func formatConfigurationValue(rawValue any) string {
|
|
switch typedValue := rawValue.(type) {
|
|
case string:
|
|
return typedValue
|
|
case bool:
|
|
if typedValue {
|
|
return "1"
|
|
}
|
|
return "0"
|
|
case float64:
|
|
// Ganzzahlen sollen nicht als "100.000000" erscheinen.
|
|
if typedValue == float64(int64(typedValue)) {
|
|
return strconv.FormatInt(int64(typedValue), 10)
|
|
}
|
|
return strconv.FormatFloat(typedValue, 'f', -1, 64)
|
|
case nil:
|
|
return ""
|
|
default:
|
|
return fmt.Sprintf("%v", typedValue)
|
|
}
|
|
}
|
|
|
|
// formatGuestIdentifier bildet die herstellerneutrale Gastkennung.
|
|
//
|
|
// Die Art gehört hinein, weil QEMU 100 und LXC 100 in Proxmox nebeneinander
|
|
// bestehen können. Eine reine Zahl wäre mehrdeutig.
|
|
func formatGuestIdentifier(resourceType string, vmID int) string {
|
|
return fmt.Sprintf("%s/%d", resourceType, vmID)
|
|
}
|
|
|
|
// parseGuestIdentifier zerlegt eine Gastkennung.
|
|
func parseGuestIdentifier(guestID string) (string, int, error) {
|
|
apiSegment, numericPart, hasSeparator := strings.Cut(guestID, "/")
|
|
if !hasSeparator {
|
|
return "", 0, fmt.Errorf("die gastkennung %q muss die form qemu/100 oder lxc/100 haben", guestID)
|
|
}
|
|
|
|
if apiSegment != "qemu" && apiSegment != "lxc" {
|
|
return "", 0, fmt.Errorf("die gastart %q ist unbekannt; erwartet werden qemu oder lxc", apiSegment)
|
|
}
|
|
|
|
numericID, parseError := strconv.Atoi(numericPart)
|
|
if parseError != nil || numericID <= 0 {
|
|
return "", 0, fmt.Errorf("die gastkennung %q enthält keine gültige nummer", guestID)
|
|
}
|
|
|
|
return apiSegment, numericID, nil
|
|
}
|
|
|
|
// translatePowerState bildet den Proxmox-Zustand ab.
|
|
func translatePowerState(proxmoxStatus string) providers.PowerState {
|
|
switch proxmoxStatus {
|
|
case "running":
|
|
return providers.PowerStateRunning
|
|
case "stopped":
|
|
return providers.PowerStateStopped
|
|
case "paused", "suspended":
|
|
return providers.PowerStatePaused
|
|
default:
|
|
// Ein unbekannter Zustand gilt nicht als angehalten. Sonst würde eine
|
|
// laufende Maschine ohne Rückfrage überschrieben.
|
|
return providers.PowerStateUnknown
|
|
}
|
|
}
|
|
|
|
// splitTags zerlegt die Etikettenliste.
|
|
func splitTags(tagText string) []string {
|
|
trimmedText := strings.TrimSpace(tagText)
|
|
if trimmedText == "" {
|
|
return nil
|
|
}
|
|
|
|
// Proxmox trennt Etiketten mit Semikolon, ältere Fassungen mit Komma.
|
|
separatedTags := strings.FieldsFunc(trimmedText, func(currentRune rune) bool {
|
|
return currentRune == ';' || currentRune == ','
|
|
})
|
|
|
|
collectedTags := make([]string, 0, len(separatedTags))
|
|
for _, singleTag := range separatedTags {
|
|
if trimmedTag := strings.TrimSpace(singleTag); trimmedTag != "" {
|
|
collectedTags = append(collectedTags, trimmedTag)
|
|
}
|
|
}
|
|
|
|
return collectedTags
|
|
}
|