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 }