Pulse/internal/monitoring/container_parsing.go
rcourtman 59796e1406 feat: Enhanced OCI detection via entrypoint field
- 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)
2025-12-12 18:13:17 +00:00

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
}