mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 19:41:17 +00:00
- Added isOCIContainerByConfig() to detect OCI containers by: - Presence of 'entrypoint' field (only OCI containers have this) - Combination of ostype=unmanaged, cmode=console, and lxc.signal.halt - This is needed because Proxmox doesn't persist ostemplate after creation - Now supports detection of already-created OCI containers (like the test alpine container)
596 lines
14 KiB
Go
596 lines
14 KiB
Go
package monitoring
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
|
|
)
|
|
|
|
// containerNetworkDetails holds parsed network interface information from container config.
|
|
type containerNetworkDetails struct {
|
|
Name string
|
|
MAC string
|
|
Addresses []string
|
|
}
|
|
|
|
// containerMountMetadata holds parsed mount point information from container config.
|
|
type containerMountMetadata struct {
|
|
Key string
|
|
Mountpoint string
|
|
Source string
|
|
}
|
|
|
|
// ensureContainerRootDiskEntry adds a root disk entry to a container if none exists.
|
|
func ensureContainerRootDiskEntry(container *models.Container) {
|
|
if container == nil || len(container.Disks) > 0 {
|
|
return
|
|
}
|
|
|
|
total := container.Disk.Total
|
|
used := container.Disk.Used
|
|
if total > 0 && used > total {
|
|
used = total
|
|
}
|
|
|
|
free := total - used
|
|
if free < 0 {
|
|
free = 0
|
|
}
|
|
|
|
usage := container.Disk.Usage
|
|
if total > 0 && usage <= 0 {
|
|
usage = safePercentage(float64(used), float64(total))
|
|
}
|
|
|
|
container.Disks = []models.Disk{
|
|
{
|
|
Total: total,
|
|
Used: used,
|
|
Free: free,
|
|
Usage: usage,
|
|
Mountpoint: "/",
|
|
Type: "rootfs",
|
|
},
|
|
}
|
|
}
|
|
|
|
// convertContainerDiskInfo converts Proxmox container disk info to the models format.
|
|
func convertContainerDiskInfo(status *proxmox.Container, metadata map[string]containerMountMetadata) []models.Disk {
|
|
if status == nil || len(status.DiskInfo) == 0 {
|
|
return nil
|
|
}
|
|
|
|
disks := make([]models.Disk, 0, len(status.DiskInfo))
|
|
for name, info := range status.DiskInfo {
|
|
total := clampToInt64(info.Total)
|
|
used := clampToInt64(info.Used)
|
|
if total > 0 && used > total {
|
|
used = total
|
|
}
|
|
free := total - used
|
|
if free < 0 {
|
|
free = 0
|
|
}
|
|
|
|
disk := models.Disk{
|
|
Total: total,
|
|
Used: used,
|
|
Free: free,
|
|
}
|
|
|
|
if total > 0 {
|
|
disk.Usage = safePercentage(float64(used), float64(total))
|
|
}
|
|
|
|
label := strings.TrimSpace(name)
|
|
lowerLabel := strings.ToLower(label)
|
|
mountpoint := ""
|
|
device := ""
|
|
|
|
if metadata != nil {
|
|
if meta, ok := metadata[lowerLabel]; ok {
|
|
mountpoint = strings.TrimSpace(meta.Mountpoint)
|
|
device = strings.TrimSpace(meta.Source)
|
|
}
|
|
}
|
|
|
|
if strings.EqualFold(label, "rootfs") || label == "" {
|
|
if mountpoint == "" {
|
|
mountpoint = "/"
|
|
}
|
|
disk.Type = "rootfs"
|
|
if device == "" {
|
|
device = sanitizeRootFSDevice(status.RootFS)
|
|
}
|
|
} else {
|
|
if mountpoint == "" {
|
|
mountpoint = label
|
|
}
|
|
disk.Type = lowerLabel
|
|
}
|
|
|
|
disk.Mountpoint = mountpoint
|
|
if disk.Device == "" && device != "" {
|
|
disk.Device = device
|
|
}
|
|
|
|
disks = append(disks, disk)
|
|
}
|
|
|
|
if len(disks) > 1 {
|
|
sort.SliceStable(disks, func(i, j int) bool {
|
|
return disks[i].Mountpoint < disks[j].Mountpoint
|
|
})
|
|
}
|
|
|
|
return disks
|
|
}
|
|
|
|
// sanitizeRootFSDevice extracts the device path from a rootfs config string.
|
|
func sanitizeRootFSDevice(root string) string {
|
|
root = strings.TrimSpace(root)
|
|
if root == "" {
|
|
return ""
|
|
}
|
|
if idx := strings.Index(root, ","); idx != -1 {
|
|
root = root[:idx]
|
|
}
|
|
return root
|
|
}
|
|
|
|
// parseContainerRawIPs extracts IP addresses from raw JSON data.
|
|
func parseContainerRawIPs(raw json.RawMessage) []string {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var data interface{}
|
|
if err := json.Unmarshal(raw, &data); err != nil {
|
|
return nil
|
|
}
|
|
return collectIPsFromInterface(data)
|
|
}
|
|
|
|
// collectIPsFromInterface recursively extracts IP addresses from various data types.
|
|
func collectIPsFromInterface(value interface{}) []string {
|
|
switch v := value.(type) {
|
|
case nil:
|
|
return nil
|
|
case string:
|
|
return sanitizeGuestAddressStrings(v)
|
|
case []interface{}:
|
|
results := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
results = append(results, collectIPsFromInterface(item)...)
|
|
}
|
|
return results
|
|
case []string:
|
|
results := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
results = append(results, sanitizeGuestAddressStrings(item)...)
|
|
}
|
|
return results
|
|
case map[string]interface{}:
|
|
results := make([]string, 0)
|
|
for _, key := range []string{"ip", "ip6", "ipv4", "ipv6", "address", "value"} {
|
|
if val, ok := v[key]; ok {
|
|
results = append(results, collectIPsFromInterface(val)...)
|
|
}
|
|
}
|
|
return results
|
|
case json.Number:
|
|
return sanitizeGuestAddressStrings(v.String())
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// sanitizeGuestAddressStrings cleans and validates IP address strings.
|
|
func sanitizeGuestAddressStrings(value string) []string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
|
|
lower := strings.ToLower(value)
|
|
switch lower {
|
|
case "dhcp", "manual", "static", "auto", "none", "n/a", "unknown", "0.0.0.0", "::", "::1":
|
|
return nil
|
|
}
|
|
|
|
parts := strings.FieldsFunc(value, func(r rune) bool {
|
|
return unicode.IsSpace(r) || r == ',' || r == ';'
|
|
})
|
|
|
|
if len(parts) > 1 {
|
|
results := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
results = append(results, sanitizeGuestAddressStrings(part)...)
|
|
}
|
|
return results
|
|
}
|
|
|
|
if idx := strings.Index(value, "/"); idx > 0 {
|
|
value = strings.TrimSpace(value[:idx])
|
|
}
|
|
|
|
lower = strings.ToLower(value)
|
|
|
|
if idx := strings.Index(value, "%"); idx > 0 {
|
|
value = strings.TrimSpace(value[:idx])
|
|
lower = strings.ToLower(value)
|
|
}
|
|
|
|
if strings.HasPrefix(value, "127.") || strings.HasPrefix(lower, "0.0.0.0") {
|
|
return nil
|
|
}
|
|
|
|
if strings.HasPrefix(lower, "fe80") {
|
|
return nil
|
|
}
|
|
|
|
if strings.HasPrefix(lower, "::1") {
|
|
return nil
|
|
}
|
|
|
|
return []string{value}
|
|
}
|
|
|
|
// dedupeStringsPreserveOrder removes duplicates from a string slice while preserving order.
|
|
func dedupeStringsPreserveOrder(values []string) []string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{}, len(values))
|
|
result := make([]string, 0, len(values))
|
|
for _, v := range values {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[v]; ok {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
result = append(result, v)
|
|
}
|
|
if len(result) == 0 {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
// parseContainerConfigNetworks extracts network interface details from container config.
|
|
func parseContainerConfigNetworks(config map[string]interface{}) []containerNetworkDetails {
|
|
if len(config) == 0 {
|
|
return nil
|
|
}
|
|
|
|
keys := make([]string, 0, len(config))
|
|
for key := range config {
|
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(key)), "net") {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
if len(keys) == 0 {
|
|
return nil
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
results := make([]containerNetworkDetails, 0, len(keys))
|
|
for _, key := range keys {
|
|
raw := fmt.Sprint(config[key])
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
|
|
detail := containerNetworkDetails{}
|
|
parts := strings.Split(raw, ",")
|
|
for _, part := range parts {
|
|
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
|
if len(kv) != 2 {
|
|
continue
|
|
}
|
|
k := strings.ToLower(strings.TrimSpace(kv[0]))
|
|
value := strings.TrimSpace(kv[1])
|
|
switch k {
|
|
case "name":
|
|
detail.Name = value
|
|
case "hwaddr", "mac", "macaddr":
|
|
detail.MAC = strings.ToUpper(value)
|
|
case "ip", "ip6", "ips", "ip6addr", "ip6prefix":
|
|
detail.Addresses = append(detail.Addresses, sanitizeGuestAddressStrings(value)...)
|
|
}
|
|
}
|
|
|
|
if detail.Name == "" {
|
|
detail.Name = strings.TrimSpace(key)
|
|
}
|
|
if len(detail.Addresses) > 0 {
|
|
detail.Addresses = dedupeStringsPreserveOrder(detail.Addresses)
|
|
}
|
|
|
|
if detail.Name != "" || detail.MAC != "" || len(detail.Addresses) > 0 {
|
|
results = append(results, detail)
|
|
}
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// parseContainerMountMetadata extracts mount point metadata from container config.
|
|
func parseContainerMountMetadata(config map[string]interface{}) map[string]containerMountMetadata {
|
|
if len(config) == 0 {
|
|
return nil
|
|
}
|
|
|
|
results := make(map[string]containerMountMetadata)
|
|
for rawKey, rawValue := range config {
|
|
key := strings.ToLower(strings.TrimSpace(rawKey))
|
|
if key != "rootfs" && !strings.HasPrefix(key, "mp") {
|
|
continue
|
|
}
|
|
|
|
value := strings.TrimSpace(fmt.Sprint(rawValue))
|
|
if value == "" {
|
|
continue
|
|
}
|
|
|
|
meta := containerMountMetadata{
|
|
Key: key,
|
|
}
|
|
|
|
parts := strings.Split(value, ",")
|
|
if len(parts) > 0 {
|
|
meta.Source = strings.TrimSpace(parts[0])
|
|
}
|
|
|
|
for _, part := range parts[1:] {
|
|
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
|
if len(kv) != 2 {
|
|
continue
|
|
}
|
|
k := strings.ToLower(strings.TrimSpace(kv[0]))
|
|
v := strings.TrimSpace(kv[1])
|
|
switch k {
|
|
case "mp", "mountpoint":
|
|
meta.Mountpoint = v
|
|
}
|
|
}
|
|
|
|
if meta.Mountpoint == "" && key == "rootfs" {
|
|
meta.Mountpoint = "/"
|
|
}
|
|
|
|
results[key] = meta
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// mergeContainerNetworkInterface merges network interface details into the target slice.
|
|
func mergeContainerNetworkInterface(target *[]models.GuestNetworkInterface, detail containerNetworkDetails) {
|
|
if target == nil {
|
|
return
|
|
}
|
|
if len(detail.Addresses) > 0 {
|
|
detail.Addresses = dedupeStringsPreserveOrder(detail.Addresses)
|
|
}
|
|
|
|
findMatch := func() int {
|
|
for i := range *target {
|
|
if detail.Name != "" && (*target)[i].Name != "" && strings.EqualFold((*target)[i].Name, detail.Name) {
|
|
return i
|
|
}
|
|
if detail.MAC != "" && (*target)[i].MAC != "" && strings.EqualFold((*target)[i].MAC, detail.MAC) {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
if idx := findMatch(); idx >= 0 {
|
|
if detail.Name != "" && (*target)[idx].Name == "" {
|
|
(*target)[idx].Name = detail.Name
|
|
}
|
|
if detail.MAC != "" && (*target)[idx].MAC == "" {
|
|
(*target)[idx].MAC = detail.MAC
|
|
}
|
|
if len(detail.Addresses) > 0 {
|
|
combined := append((*target)[idx].Addresses, detail.Addresses...)
|
|
(*target)[idx].Addresses = dedupeStringsPreserveOrder(combined)
|
|
}
|
|
return
|
|
}
|
|
|
|
newIface := models.GuestNetworkInterface{
|
|
Name: detail.Name,
|
|
MAC: detail.MAC,
|
|
}
|
|
if len(detail.Addresses) > 0 {
|
|
newIface.Addresses = dedupeStringsPreserveOrder(detail.Addresses)
|
|
}
|
|
*target = append(*target, newIface)
|
|
}
|
|
|
|
// extractContainerRootDeviceFromConfig extracts the root device path from container config.
|
|
func extractContainerRootDeviceFromConfig(config map[string]interface{}) string {
|
|
if len(config) == 0 {
|
|
return ""
|
|
}
|
|
raw, ok := config["rootfs"]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
value := strings.TrimSpace(fmt.Sprint(raw))
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
|
|
parts := strings.Split(value, ",")
|
|
device := strings.TrimSpace(parts[0])
|
|
return device
|
|
}
|
|
|
|
// lxcOSTypeDisplayNames maps Proxmox LXC ostype values to human-readable OS names.
|
|
// See: https://pve.proxmox.com/wiki/Manual:_pct.conf
|
|
var lxcOSTypeDisplayNames = map[string]string{
|
|
"alpine": "Alpine Linux",
|
|
"archlinux": "Arch Linux",
|
|
"centos": "CentOS",
|
|
"debian": "Debian",
|
|
"devuan": "Devuan",
|
|
"fedora": "Fedora",
|
|
"gentoo": "Gentoo",
|
|
"nixos": "NixOS",
|
|
"opensuse": "openSUSE",
|
|
"ubuntu": "Ubuntu",
|
|
"unmanaged": "Unmanaged",
|
|
}
|
|
|
|
// extractContainerOSType extracts and normalizes the ostype from container config.
|
|
// Returns a human-readable OS name (e.g., "Ubuntu", "Debian", "Alpine Linux").
|
|
func extractContainerOSType(config map[string]interface{}) string {
|
|
if len(config) == 0 {
|
|
return ""
|
|
}
|
|
|
|
raw, ok := config["ostype"]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
ostype := strings.TrimSpace(strings.ToLower(fmt.Sprint(raw)))
|
|
if ostype == "" {
|
|
return ""
|
|
}
|
|
|
|
// Return display name if known, otherwise capitalize the ostype
|
|
if displayName, found := lxcOSTypeDisplayNames[ostype]; found {
|
|
return displayName
|
|
}
|
|
|
|
// Fallback: capitalize first letter
|
|
if len(ostype) > 0 {
|
|
return strings.ToUpper(ostype[:1]) + ostype[1:]
|
|
}
|
|
return ostype
|
|
}
|
|
|
|
// extractContainerOSTemplate extracts the ostemplate value from container config.
|
|
// This is the template used to create the container, which may be an LXC template
|
|
// or an OCI image reference (Proxmox VE 9.1+).
|
|
func extractContainerOSTemplate(config map[string]interface{}) string {
|
|
if len(config) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Try common field names for the template
|
|
for _, key := range []string{"ostemplate", "template"} {
|
|
if raw, ok := config[key]; ok {
|
|
if value := strings.TrimSpace(fmt.Sprint(raw)); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// isOCITemplate returns true if the ostemplate string indicates an OCI container image.
|
|
// Proxmox VE 9.1+ supports pulling OCI images from registries like Docker Hub.
|
|
// OCI templates typically have formats like:
|
|
// - "oci:docker.io/library/alpine:latest"
|
|
// - "docker:alpine:latest"
|
|
// - "local:vztmpl/oci-alpine-latest.tar.gz" (pulled OCI image stored locally)
|
|
func isOCITemplate(template string) bool {
|
|
if template == "" {
|
|
return false
|
|
}
|
|
|
|
template = strings.ToLower(strings.TrimSpace(template))
|
|
|
|
// Explicit OCI prefix
|
|
if strings.HasPrefix(template, "oci:") {
|
|
return true
|
|
}
|
|
|
|
// Docker Hub shorthand (docker:image:tag)
|
|
if strings.HasPrefix(template, "docker:") {
|
|
return true
|
|
}
|
|
|
|
// Check for common OCI registry URLs
|
|
ociRegistries := []string{
|
|
"docker.io/",
|
|
"ghcr.io/",
|
|
"gcr.io/",
|
|
"quay.io/",
|
|
"registry.hub.docker.com/",
|
|
"mcr.microsoft.com/",
|
|
"public.ecr.aws/",
|
|
}
|
|
for _, registry := range ociRegistries {
|
|
if strings.Contains(template, registry) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check for locally stored OCI images (typically have "oci" in the filename)
|
|
// e.g., "local:vztmpl/oci-alpine-3.18.tar.xz"
|
|
if strings.Contains(template, "/oci-") || strings.Contains(template, "/oci_") {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isOCIContainerByConfig detects OCI containers by examining their configuration.
|
|
// Proxmox VE 9.1+ OCI containers have specific config markers:
|
|
// - "entrypoint" field is set (only OCI containers have this)
|
|
// - "ostype" is often "unmanaged" for OCI containers
|
|
// - "cmode" is often "console" for OCI containers
|
|
//
|
|
// This is useful because Proxmox doesn't persist the ostemplate after container creation.
|
|
func isOCIContainerByConfig(config map[string]interface{}) bool {
|
|
if len(config) == 0 {
|
|
return false
|
|
}
|
|
|
|
// Primary indicator: OCI containers have an "entrypoint" field
|
|
// Traditional LXC containers don't have this field
|
|
if _, hasEntrypoint := config["entrypoint"]; hasEntrypoint {
|
|
return true
|
|
}
|
|
|
|
// Secondary check: "unmanaged" ostype with console cmode is a strong hint
|
|
// (though not definitive as users could manually configure this)
|
|
ostype, _ := config["ostype"].(string)
|
|
cmode, _ := config["cmode"].(string)
|
|
if ostype == "unmanaged" && cmode == "console" {
|
|
// Check for other OCI indicators like lxc.signal.halt: SIGTERM
|
|
// (which is set by Proxmox for OCI containers)
|
|
if lxc, ok := config["lxc"]; ok {
|
|
lxcStr := fmt.Sprint(lxc)
|
|
if strings.Contains(lxcStr, "lxc.signal.halt") {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|