From 0b2485b26b4284894a87885f5c7deaa4514c5efb Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 1 Dec 2025 10:40:48 +0000 Subject: [PATCH] refactor: Extract container parsing functions to separate file Move 13 related container parsing functions from monitor.go (9359 lines) to container_parsing.go (451 lines). Reduces monitor.go by 427 lines. Functions extracted: - ensureContainerRootDiskEntry - convertContainerDiskInfo - sanitizeRootFSDevice - parseContainerRawIPs - collectIPsFromInterface - sanitizeGuestAddressStrings - dedupeStringsPreserveOrder - containerNetworkDetails struct - containerMountMetadata struct - parseContainerConfigNetworks - parseContainerMountMetadata - mergeContainerNetworkInterface - extractContainerRootDeviceFromConfig --- internal/monitoring/container_parsing.go | 451 +++++++++++++++++++++++ internal/monitoring/monitor.go | 427 --------------------- 2 files changed, 451 insertions(+), 427 deletions(-) create mode 100644 internal/monitoring/container_parsing.go diff --git a/internal/monitoring/container_parsing.go b/internal/monitoring/container_parsing.go new file mode 100644 index 0000000..2e35b7b --- /dev/null +++ b/internal/monitoring/container_parsing.go @@ -0,0 +1,451 @@ +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 + } + if lowerLabel != "" { + disk.Type = lowerLabel + } else { + disk.Type = "disk" + } + } + + 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 +} diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 8ddd04c..b79f36b 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -19,7 +19,6 @@ import ( "sync" "sync/atomic" "time" - "unicode" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/config" @@ -2951,432 +2950,6 @@ func (m *Monitor) enrichContainerMetadata(ctx context.Context, client PVEClientI } } -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", - }, - } -} - -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 - } - if lowerLabel != "" { - disk.Type = lowerLabel - } else { - disk.Type = "disk" - } - } - - 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 -} - -func sanitizeRootFSDevice(root string) string { - root = strings.TrimSpace(root) - if root == "" { - return "" - } - if idx := strings.Index(root, ","); idx != -1 { - root = root[:idx] - } - return root -} - -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) -} - -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 - } -} - -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} -} - -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 -} - -type containerNetworkDetails struct { - Name string - MAC string - Addresses []string -} - -type containerMountMetadata struct { - Key string - Mountpoint string - Source string -} - -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 -} - -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 -} - -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) -} - -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 -} - // GetConnectionStatuses returns the current connection status for all nodes func (m *Monitor) GetConnectionStatuses() map[string]bool { if mock.IsMockEnabled() {