package monitoring import ( "encoding/json" "testing" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" ) func TestSanitizeRootFSDevice(t *testing.T) { t.Parallel() tests := []struct { name string input string want string }{ { name: "empty string", input: "", want: "", }, { name: "no comma", input: "local:100/vm-100-disk-0.raw", want: "local:100/vm-100-disk-0.raw", }, { name: "with comma", input: "local:100/vm-100-disk-0.raw,size=8G", want: "local:100/vm-100-disk-0.raw", }, { name: "with whitespace", input: " local:100/vm-100-disk-0.raw ", want: "local:100/vm-100-disk-0.raw", }, { name: "with whitespace and comma", input: " local:100/vm-100-disk-0.raw ,size=8G", want: "local:100/vm-100-disk-0.raw ", // Trailing whitespace preserved after comma split }, { name: "only whitespace", input: " ", want: "", }, { name: "multiple commas", input: "local:100/vm-100-disk-0.raw,size=8G,format=raw", want: "local:100/vm-100-disk-0.raw", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := sanitizeRootFSDevice(tt.input) if got != tt.want { t.Errorf("sanitizeRootFSDevice(%q) = %q, want %q", tt.input, got, tt.want) } }) } } // Note: Tests for sanitizeGuestAddressStrings and dedupeStringsPreserveOrder // are already present in helpers_test.go func TestCollectIPsFromInterface(t *testing.T) { t.Parallel() tests := []struct { name string input interface{} want []string }{ { name: "nil", input: nil, want: nil, }, { name: "string valid IP", input: "192.168.1.100", want: []string{"192.168.1.100"}, }, { name: "string invalid IP", input: "dhcp", want: nil, }, { name: "[]interface{} with strings", input: []interface{}{"192.168.1.100", "192.168.1.101"}, want: []string{"192.168.1.100", "192.168.1.101"}, }, { name: "[]interface{} nested", input: []interface{}{"192.168.1.100", []interface{}{"192.168.1.101"}}, want: []string{"192.168.1.100", "192.168.1.101"}, }, { name: "[]string", input: []string{"192.168.1.100", "192.168.1.101"}, want: []string{"192.168.1.100", "192.168.1.101"}, }, { name: "map with ip key", input: map[string]interface{}{ "ip": "192.168.1.100", }, want: []string{"192.168.1.100"}, }, { name: "map with ip6 key", input: map[string]interface{}{ "ip6": "2001:db8::1", }, want: []string{"2001:db8::1"}, }, { name: "map with ipv4 key", input: map[string]interface{}{ "ipv4": "192.168.1.100", }, want: []string{"192.168.1.100"}, }, { name: "map with ipv6 key", input: map[string]interface{}{ "ipv6": "2001:db8::1", }, want: []string{"2001:db8::1"}, }, { name: "map with address key", input: map[string]interface{}{ "address": "192.168.1.100", }, want: []string{"192.168.1.100"}, }, { name: "map with value key", input: map[string]interface{}{ "value": "192.168.1.100", }, want: []string{"192.168.1.100"}, }, { name: "map with multiple keys", input: map[string]interface{}{ "ip": "192.168.1.100", "ip6": "2001:db8::1", }, want: []string{"192.168.1.100", "2001:db8::1"}, }, { name: "map with unrelated keys", input: map[string]interface{}{ "name": "eth0", "mac": "00:11:22:33:44:55", }, want: nil, }, { name: "json.Number", input: json.Number("192"), want: []string{"192"}, // json.Number is converted to string and passed through }, { name: "int", input: 12345, want: nil, }, { name: "float64", input: 123.45, want: nil, }, { name: "bool", input: true, want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := collectIPsFromInterface(tt.input) if !stringSlicesEqual(got, tt.want) { t.Errorf("collectIPsFromInterface(%v) = %v, want %v", tt.input, got, tt.want) } }) } } func TestParseContainerRawIPs(t *testing.T) { t.Parallel() tests := []struct { name string input json.RawMessage want []string }{ { name: "empty", input: json.RawMessage(``), want: nil, }, { name: "nil", input: nil, want: nil, }, { name: "invalid JSON", input: json.RawMessage(`{invalid`), want: nil, }, { name: "string IP", input: json.RawMessage(`"192.168.1.100"`), want: []string{"192.168.1.100"}, }, { name: "array of IPs", input: json.RawMessage(`["192.168.1.100", "192.168.1.101"]`), want: []string{"192.168.1.100", "192.168.1.101"}, }, { name: "object with ip key", input: json.RawMessage(`{"ip": "192.168.1.100"}`), want: []string{"192.168.1.100"}, }, { name: "complex nested structure", input: json.RawMessage(`{"interfaces": [{"ip": "192.168.1.100"}, {"ip": "192.168.1.101"}]}`), want: nil, // "interfaces" key is not checked }, { name: "object with multiple IP keys", input: json.RawMessage(`{"ip": "192.168.1.100", "ip6": "2001:db8::1"}`), want: []string{"192.168.1.100", "2001:db8::1"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := parseContainerRawIPs(tt.input) if !stringSlicesEqual(got, tt.want) { t.Errorf("parseContainerRawIPs(%s) = %v, want %v", string(tt.input), got, tt.want) } }) } } func TestParseContainerConfigNetworks(t *testing.T) { t.Parallel() tests := []struct { name string input map[string]interface{} want []containerNetworkDetails }{ { name: "empty config", input: map[string]interface{}{}, want: nil, }, { name: "nil config", input: nil, want: nil, }, { name: "no net keys", input: map[string]interface{}{ "cores": 2, "memory": 2048, }, want: nil, }, { name: "single interface", input: map[string]interface{}{ "net0": "name=eth0,hwaddr=AA:BB:CC:DD:EE:FF,ip=192.168.1.100", }, want: []containerNetworkDetails{ { Name: "eth0", MAC: "AA:BB:CC:DD:EE:FF", Addresses: []string{"192.168.1.100"}, }, }, }, { name: "MAC normalization to uppercase", input: map[string]interface{}{ "net0": "name=eth0,hwaddr=aa:bb:cc:dd:ee:ff", }, want: []containerNetworkDetails{ { Name: "eth0", MAC: "AA:BB:CC:DD:EE:FF", }, }, }, { name: "multiple interfaces sorted", input: map[string]interface{}{ "net1": "name=eth1,hwaddr=BB:BB:BB:BB:BB:BB", "net0": "name=eth0,hwaddr=AA:AA:AA:AA:AA:AA", }, want: []containerNetworkDetails{ { Name: "eth0", MAC: "AA:AA:AA:AA:AA:AA", }, { Name: "eth1", MAC: "BB:BB:BB:BB:BB:BB", }, }, }, { name: "interface with multiple IPs", input: map[string]interface{}{ "net0": "name=eth0,ip=192.168.1.100,ip6=2001:db8::1", }, want: []containerNetworkDetails{ { Name: "eth0", Addresses: []string{"192.168.1.100", "2001:db8::1"}, }, }, }, { name: "interface without name uses key", input: map[string]interface{}{ "net0": "hwaddr=AA:BB:CC:DD:EE:FF,ip=192.168.1.100", }, want: []containerNetworkDetails{ { Name: "net0", MAC: "AA:BB:CC:DD:EE:FF", Addresses: []string{"192.168.1.100"}, }, }, }, { name: "interface with CIDR notation", input: map[string]interface{}{ "net0": "name=eth0,ip=192.168.1.100/24", }, want: []containerNetworkDetails{ { Name: "eth0", Addresses: []string{"192.168.1.100"}, }, }, }, { name: "interface with dhcp ignored", input: map[string]interface{}{ "net0": "name=eth0,hwaddr=AA:BB:CC:DD:EE:FF,ip=dhcp", }, want: []containerNetworkDetails{ { Name: "eth0", MAC: "AA:BB:CC:DD:EE:FF", }, }, }, { name: "empty interface value", input: map[string]interface{}{ "net0": "", }, want: nil, }, { name: "interface with whitespace", input: map[string]interface{}{ "net0": " name=eth0 , hwaddr=AA:BB:CC:DD:EE:FF ", }, want: []containerNetworkDetails{ { Name: "eth0", MAC: "AA:BB:CC:DD:EE:FF", }, }, }, { name: "mixed case net keys", input: map[string]interface{}{ "NET0": "name=eth0,hwaddr=AA:BB:CC:DD:EE:FF", "Net1": "name=eth1,hwaddr=BB:BB:BB:BB:BB:BB", }, want: []containerNetworkDetails{ { Name: "eth0", MAC: "AA:BB:CC:DD:EE:FF", }, { Name: "eth1", MAC: "BB:BB:BB:BB:BB:BB", }, }, }, { name: "macaddr alternative key", input: map[string]interface{}{ "net0": "name=eth0,macaddr=AA:BB:CC:DD:EE:FF", }, want: []containerNetworkDetails{ { Name: "eth0", MAC: "AA:BB:CC:DD:EE:FF", }, }, }, { name: "duplicate IPs deduplicated", input: map[string]interface{}{ "net0": "name=eth0,ip=192.168.1.100,ip6=192.168.1.100", }, want: []containerNetworkDetails{ { Name: "eth0", Addresses: []string{"192.168.1.100"}, // Deduplicated }, }, }, { name: "parts without equals sign skipped", input: map[string]interface{}{ "net0": "name=eth0,invalidpart,hwaddr=AA:BB:CC:DD:EE:FF", }, want: []containerNetworkDetails{ { Name: "eth0", MAC: "AA:BB:CC:DD:EE:FF", }, }, }, { name: "mac key variant", input: map[string]interface{}{ "net0": "name=eth0,mac=AA:BB:CC:DD:EE:FF", }, want: []containerNetworkDetails{ { Name: "eth0", MAC: "AA:BB:CC:DD:EE:FF", }, }, }, { name: "ips key variant", input: map[string]interface{}{ "net0": "name=eth0,ips=192.168.1.100", }, want: []containerNetworkDetails{ { Name: "eth0", Addresses: []string{"192.168.1.100"}, }, }, }, { name: "ip6addr key variant", input: map[string]interface{}{ "net0": "name=eth0,ip6addr=2001:db8::1", }, want: []containerNetworkDetails{ { Name: "eth0", Addresses: []string{"2001:db8::1"}, }, }, }, { name: "ip6prefix key variant", input: map[string]interface{}{ "net0": "name=eth0,ip6prefix=2001:db8::", }, want: []containerNetworkDetails{ { Name: "eth0", Addresses: []string{"2001:db8::"}, }, }, }, { name: "whitespace only interface value", input: map[string]interface{}{ "net0": " ", }, want: nil, }, { name: "all empty net values returns nil", input: map[string]interface{}{ "net0": "", "net1": " ", }, want: nil, }, { name: "only unrecognized keys uses key as name", input: map[string]interface{}{ "net0": "unknown=value,other=data", }, want: []containerNetworkDetails{ { Name: "net0", }, }, }, { name: "value parts only without equals", input: map[string]interface{}{ "net0": "noequals,alsonoequals", }, want: []containerNetworkDetails{ { Name: "net0", }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := parseContainerConfigNetworks(tt.input) if !networkDetailsSlicesEqual(got, tt.want) { t.Errorf("parseContainerConfigNetworks() = %+v, want %+v", got, tt.want) } }) } } // Note: Basic tests for parseContainerMountMetadata are in monitor_container_test.go // These tests add additional edge case coverage func TestParseContainerMountMetadataEdgeCases(t *testing.T) { t.Parallel() tests := []struct { name string input map[string]interface{} want map[string]containerMountMetadata }{ { name: "empty config", input: map[string]interface{}{}, want: nil, }, { name: "nil config", input: nil, want: nil, }, { name: "no mount keys", input: map[string]interface{}{ "cores": 2, "memory": 2048, }, want: nil, }, { name: "rootfs with explicit mountpoint", input: map[string]interface{}{ "rootfs": "local:100/vm-100-disk-0.raw,mp=/,size=8G", }, want: map[string]containerMountMetadata{ "rootfs": { Key: "rootfs", Mountpoint: "/", Source: "local:100/vm-100-disk-0.raw", }, }, }, { name: "mount point with mountpoint key", input: map[string]interface{}{ "mp0": "local:volume,mountpoint=/mnt/data", }, want: map[string]containerMountMetadata{ "mp0": { Key: "mp0", Mountpoint: "/mnt/data", Source: "local:volume", }, }, }, { name: "empty value ignored", input: map[string]interface{}{ "mp0": "", }, want: nil, }, { name: "whitespace trimmed", input: map[string]interface{}{ "mp0": " local:volume , mp=/mnt/data ", }, want: map[string]containerMountMetadata{ "mp0": { Key: "mp0", Mountpoint: "/mnt/data", Source: "local:volume", }, }, }, { name: "part without equals sign skipped", input: map[string]interface{}{ "mp0": "local:volume,readonly,mp=/data,backup", }, want: map[string]containerMountMetadata{ "mp0": { Key: "mp0", Mountpoint: "/data", Source: "local:volume", }, }, }, { name: "rootfs without mountpoint defaults to slash", input: map[string]interface{}{ "rootfs": "local:100/vm-100-disk-0.raw,size=8G", }, want: map[string]containerMountMetadata{ "rootfs": { Key: "rootfs", Mountpoint: "/", Source: "local:100/vm-100-disk-0.raw", }, }, }, { name: "non-rootfs without mountpoint has empty mountpoint", input: map[string]interface{}{ "mp1": "local:volume,size=10G", }, want: map[string]containerMountMetadata{ "mp1": { Key: "mp1", Mountpoint: "", Source: "local:volume", }, }, }, { name: "key case insensitive", input: map[string]interface{}{ "ROOTFS": "local:disk,size=8G", "MP0": "local:vol,mp=/mnt", }, want: map[string]containerMountMetadata{ "rootfs": { Key: "rootfs", Mountpoint: "/", Source: "local:disk", }, "mp0": { Key: "mp0", Mountpoint: "/mnt", Source: "local:vol", }, }, }, { name: "whitespace-only value treated as empty", input: map[string]interface{}{ "mp0": " ", }, want: nil, }, { name: "single source value no comma parts", input: map[string]interface{}{ "rootfs": "local:disk", }, want: map[string]containerMountMetadata{ "rootfs": { Key: "rootfs", Mountpoint: "/", Source: "local:disk", }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := parseContainerMountMetadata(tt.input) if !mountMetadataMapsEqual(got, tt.want) { t.Errorf("parseContainerMountMetadata() = %+v, want %+v", got, tt.want) } }) } } func TestExtractContainerRootDeviceFromConfig(t *testing.T) { t.Parallel() tests := []struct { name string input map[string]interface{} want string }{ { name: "empty config", input: map[string]interface{}{}, want: "", }, { name: "nil config", input: nil, want: "", }, { name: "no rootfs key", input: map[string]interface{}{ "cores": 2, }, want: "", }, { name: "rootfs with device only", input: map[string]interface{}{ "rootfs": "local:100/vm-100-disk-0.raw", }, want: "local:100/vm-100-disk-0.raw", }, { name: "rootfs with device and options", input: map[string]interface{}{ "rootfs": "local:100/vm-100-disk-0.raw,size=8G", }, want: "local:100/vm-100-disk-0.raw", }, { name: "rootfs empty value", input: map[string]interface{}{ "rootfs": "", }, want: "", }, { name: "rootfs whitespace only", input: map[string]interface{}{ "rootfs": " ", }, want: "", }, { name: "rootfs with whitespace", input: map[string]interface{}{ "rootfs": " local:100/vm-100-disk-0.raw ,size=8G", }, want: "local:100/vm-100-disk-0.raw", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := extractContainerRootDeviceFromConfig(tt.input) if got != tt.want { t.Errorf("extractContainerRootDeviceFromConfig() = %q, want %q", got, tt.want) } }) } } func TestMergeContainerNetworkInterface(t *testing.T) { t.Parallel() tests := []struct { name string target []models.GuestNetworkInterface detail containerNetworkDetails want []models.GuestNetworkInterface }{ { name: "nil target", target: nil, detail: containerNetworkDetails{ Name: "eth0", }, want: nil, }, { name: "empty target append new", target: []models.GuestNetworkInterface{}, detail: containerNetworkDetails{ Name: "eth0", MAC: "AA:BB:CC:DD:EE:FF", Addresses: []string{"192.168.1.100"}, }, want: []models.GuestNetworkInterface{ { Name: "eth0", MAC: "AA:BB:CC:DD:EE:FF", Addresses: []string{"192.168.1.100"}, }, }, }, { name: "match by name merge MAC", target: []models.GuestNetworkInterface{ { Name: "eth0", }, }, detail: containerNetworkDetails{ Name: "eth0", MAC: "AA:BB:CC:DD:EE:FF", }, want: []models.GuestNetworkInterface{ { Name: "eth0", MAC: "AA:BB:CC:DD:EE:FF", }, }, }, { name: "match by MAC merge name", target: []models.GuestNetworkInterface{ { MAC: "AA:BB:CC:DD:EE:FF", }, }, detail: containerNetworkDetails{ Name: "eth0", MAC: "aa:bb:cc:dd:ee:ff", // case insensitive }, want: []models.GuestNetworkInterface{ { Name: "eth0", MAC: "AA:BB:CC:DD:EE:FF", }, }, }, { name: "match by name merge addresses", target: []models.GuestNetworkInterface{ { Name: "eth0", Addresses: []string{"192.168.1.100"}, }, }, detail: containerNetworkDetails{ Name: "eth0", Addresses: []string{"192.168.1.101"}, }, want: []models.GuestNetworkInterface{ { Name: "eth0", Addresses: []string{"192.168.1.100", "192.168.1.101"}, }, }, }, { name: "deduplicate addresses", target: []models.GuestNetworkInterface{ { Name: "eth0", Addresses: []string{"192.168.1.100"}, }, }, detail: containerNetworkDetails{ Name: "eth0", Addresses: []string{"192.168.1.100", "192.168.1.101"}, }, want: []models.GuestNetworkInterface{ { Name: "eth0", Addresses: []string{"192.168.1.100", "192.168.1.101"}, }, }, }, { name: "no match append", target: []models.GuestNetworkInterface{ { Name: "eth0", }, }, detail: containerNetworkDetails{ Name: "eth1", MAC: "BB:BB:BB:BB:BB:BB", }, want: []models.GuestNetworkInterface{ { Name: "eth0", }, { Name: "eth1", MAC: "BB:BB:BB:BB:BB:BB", }, }, }, { name: "match by name case insensitive", target: []models.GuestNetworkInterface{ { Name: "ETH0", }, }, detail: containerNetworkDetails{ Name: "eth0", MAC: "AA:BB:CC:DD:EE:FF", }, want: []models.GuestNetworkInterface{ { Name: "ETH0", MAC: "AA:BB:CC:DD:EE:FF", }, }, }, { name: "don't overwrite existing name", target: []models.GuestNetworkInterface{ { Name: "existing", MAC: "AA:BB:CC:DD:EE:FF", }, }, detail: containerNetworkDetails{ Name: "new", MAC: "aa:bb:cc:dd:ee:ff", }, want: []models.GuestNetworkInterface{ { Name: "existing", MAC: "AA:BB:CC:DD:EE:FF", }, }, }, { name: "don't overwrite existing MAC", target: []models.GuestNetworkInterface{ { Name: "eth0", MAC: "AA:AA:AA:AA:AA:AA", }, }, detail: containerNetworkDetails{ Name: "eth0", MAC: "BB:BB:BB:BB:BB:BB", }, want: []models.GuestNetworkInterface{ { Name: "eth0", MAC: "AA:AA:AA:AA:AA:AA", }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() var target *[]models.GuestNetworkInterface if tt.target != nil { // Make a copy to avoid test interference targetCopy := make([]models.GuestNetworkInterface, len(tt.target)) copy(targetCopy, tt.target) target = &targetCopy } mergeContainerNetworkInterface(target, tt.detail) var got []models.GuestNetworkInterface if target != nil { got = *target } if !guestNetworkInterfaceSlicesEqual(got, tt.want) { t.Errorf("mergeContainerNetworkInterface() = %+v, want %+v", got, tt.want) } }) } } func TestConvertContainerDiskInfo(t *testing.T) { t.Parallel() tests := []struct { name string status *proxmox.Container metadata map[string]containerMountMetadata want []models.Disk }{ { name: "nil status", status: nil, want: nil, }, { name: "empty DiskInfo", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{}, }, want: nil, }, { name: "nil DiskInfo", status: &proxmox.Container{ DiskInfo: nil, }, want: nil, }, { name: "single rootfs disk", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{ "rootfs": { Total: 8589934592, // 8GB Used: 4294967296, // 4GB }, }, RootFS: "local:100/vm-100-disk-0.raw", }, want: []models.Disk{ { Total: 8589934592, Used: 4294967296, Free: 4294967296, Usage: 50.0, Mountpoint: "/", Type: "rootfs", Device: "local:100/vm-100-disk-0.raw", }, }, }, { name: "rootfs with metadata", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{ "rootfs": { Total: 8589934592, Used: 4294967296, }, }, }, metadata: map[string]containerMountMetadata{ "rootfs": { Mountpoint: "/", Source: "local-lvm:vm-100-disk-0", }, }, want: []models.Disk{ { Total: 8589934592, Used: 4294967296, Free: 4294967296, Usage: 50.0, Mountpoint: "/", Type: "rootfs", Device: "local-lvm:vm-100-disk-0", }, }, }, { name: "multiple disks sorted by mountpoint", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{ "mp0": { Total: 10737418240, Used: 5368709120, }, "rootfs": { Total: 8589934592, Used: 4294967296, }, }, }, metadata: map[string]containerMountMetadata{ "mp0": { Mountpoint: "/mnt/data", Source: "local:volume1", }, "rootfs": { Mountpoint: "/", Source: "local:100/vm-100-disk-0.raw", }, }, want: []models.Disk{ { Total: 8589934592, Used: 4294967296, Free: 4294967296, Usage: 50.0, Mountpoint: "/", Type: "rootfs", Device: "local:100/vm-100-disk-0.raw", }, { Total: 10737418240, Used: 5368709120, Free: 5368709120, Usage: 50.0, Mountpoint: "/mnt/data", Type: "mp0", Device: "local:volume1", }, }, }, { name: "disk with used > total clamped", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{ "rootfs": { Total: 8589934592, Used: 10737418240, // More than total }, }, }, want: []models.Disk{ { Total: 8589934592, Used: 8589934592, // Clamped to total Free: 0, Usage: 100.0, Mountpoint: "/", Type: "rootfs", }, }, }, { name: "disk with zero total", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{ "rootfs": { Total: 0, Used: 0, }, }, }, want: []models.Disk{ { Total: 0, Used: 0, Free: 0, Usage: 0, Mountpoint: "/", Type: "rootfs", }, }, }, { name: "empty label defaults to rootfs", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{ "": { Total: 8589934592, Used: 4294967296, }, }, }, want: []models.Disk{ { Total: 8589934592, Used: 4294967296, Free: 4294967296, Usage: 50.0, Mountpoint: "/", Type: "rootfs", }, }, }, { name: "non-rootfs disk without metadata uses label as mountpoint", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{ "mp0": { Total: 10737418240, Used: 5368709120, }, }, }, want: []models.Disk{ { Total: 10737418240, Used: 5368709120, Free: 5368709120, Usage: 50.0, Mountpoint: "mp0", Type: "mp0", }, }, }, { name: "case insensitive rootfs", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{ "ROOTFS": { Total: 8589934592, Used: 4294967296, }, }, }, want: []models.Disk{ { Total: 8589934592, Used: 4294967296, Free: 4294967296, Usage: 50.0, Mountpoint: "/", Type: "rootfs", }, }, }, { name: "nil metadata does not panic", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{ "rootfs": { Total: 1000, Used: 500, }, }, RootFS: "local:disk", }, metadata: nil, want: []models.Disk{ { Total: 1000, Used: 500, Free: 500, Usage: 50.0, Mountpoint: "/", Type: "rootfs", Device: "local:disk", }, }, }, { name: "disk gets device from metadata when not set", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{ "mp0": { Total: 1000, Used: 500, }, }, }, metadata: map[string]containerMountMetadata{ "mp0": { Mountpoint: "/data", Source: "nfs:shared-volume", }, }, want: []models.Disk{ { Total: 1000, Used: 500, Free: 500, Usage: 50.0, Mountpoint: "/data", Type: "mp0", Device: "nfs:shared-volume", }, }, }, { name: "negative free clamped to zero", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{ "rootfs": { Total: 0, Used: 500, // used > total=0, free = -500 clamped to 0 }, }, }, want: []models.Disk{ { Total: 0, Used: 500, // Not clamped because total == 0 Free: 0, // Clamped from -500 to 0 Usage: 0, // No calculation when total == 0 Mountpoint: "/", Type: "rootfs", }, }, }, { name: "whitespace label trimmed and treated as rootfs", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{ " ": { // whitespace only, trims to empty Total: 1000, Used: 500, }, }, }, want: []models.Disk{ { Total: 1000, Used: 500, Free: 500, Usage: 50.0, Mountpoint: "/", Type: "rootfs", }, }, }, { name: "rootfs gets device from RootFS when metadata has empty source", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{ "rootfs": { Total: 1000, Used: 500, }, }, RootFS: "local:100/disk.raw,size=8G", }, metadata: map[string]containerMountMetadata{ "rootfs": { Mountpoint: "/", Source: "", // empty source }, }, want: []models.Disk{ { Total: 1000, Used: 500, Free: 500, Usage: 50.0, Mountpoint: "/", Type: "rootfs", Device: "local:100/disk.raw", // Falls back to RootFS, sanitized }, }, }, { name: "non-rootfs with whitespace label gets type disk", status: &proxmox.Container{ DiskInfo: map[string]proxmox.ContainerDiskUsage{ "mp0": { Total: 1000, Used: 500, }, }, }, metadata: map[string]containerMountMetadata{ "mp0": { Mountpoint: "/mnt/storage", Source: "", }, }, want: []models.Disk{ { Total: 1000, Used: 500, Free: 500, Usage: 50.0, Mountpoint: "/mnt/storage", Type: "mp0", }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := convertContainerDiskInfo(tt.status, tt.metadata) if !diskSlicesEqual(got, tt.want) { t.Errorf("convertContainerDiskInfo() =\n%+v\nwant:\n%+v", got, tt.want) } }) } } func TestEnsureContainerRootDiskEntry(t *testing.T) { t.Parallel() tests := []struct { name string container *models.Container want *models.Container }{ { name: "nil container", container: nil, want: nil, }, { name: "already has disks", container: &models.Container{ Disks: []models.Disk{ { Mountpoint: "/mnt/data", Total: 1000, Used: 500, }, }, }, want: &models.Container{ Disks: []models.Disk{ { Mountpoint: "/mnt/data", Total: 1000, Used: 500, }, }, }, }, { name: "no disks creates root entry", container: &models.Container{ Disk: models.Disk{ Total: 8589934592, Used: 4294967296, Usage: 50.0, }, }, want: &models.Container{ Disk: models.Disk{ Total: 8589934592, Used: 4294967296, Usage: 50.0, }, Disks: []models.Disk{ { Total: 8589934592, Used: 4294967296, Free: 4294967296, Usage: 50.0, Mountpoint: "/", Type: "rootfs", }, }, }, }, { name: "used > total clamped", container: &models.Container{ Disk: models.Disk{ Total: 8589934592, Used: 10737418240, // More than total }, }, want: &models.Container{ Disk: models.Disk{ Total: 8589934592, Used: 10737418240, }, Disks: []models.Disk{ { Total: 8589934592, Used: 8589934592, // Clamped Free: 0, Usage: 100.0, Mountpoint: "/", Type: "rootfs", }, }, }, }, { name: "zero usage calculated", container: &models.Container{ Disk: models.Disk{ Total: 8589934592, Used: 4294967296, Usage: 0, // Will be calculated }, }, want: &models.Container{ Disk: models.Disk{ Total: 8589934592, Used: 4294967296, Usage: 0, }, Disks: []models.Disk{ { Total: 8589934592, Used: 4294967296, Free: 4294967296, Usage: 50.0, // Calculated Mountpoint: "/", Type: "rootfs", }, }, }, }, { name: "zero total", container: &models.Container{ Disk: models.Disk{ Total: 0, Used: 0, }, }, want: &models.Container{ Disk: models.Disk{ Total: 0, Used: 0, }, Disks: []models.Disk{ { Total: 0, Used: 0, Free: 0, Usage: 0, Mountpoint: "/", Type: "rootfs", }, }, }, }, { name: "used greater than total gets clamped when total positive", container: &models.Container{ Disk: models.Disk{ Total: 1000, Used: 1500, // More than total, will be clamped to 1000 }, }, want: &models.Container{ Disk: models.Disk{ Total: 1000, Used: 1500, }, Disks: []models.Disk{ { Total: 1000, Used: 1000, // Clamped to total Free: 0, Usage: 100.0, Mountpoint: "/", Type: "rootfs", }, }, }, }, { name: "negative free clamped to zero when total is zero but used is positive", container: &models.Container{ Disk: models.Disk{ Total: 0, Used: 500, // Used > 0, total = 0, so free = 0 - 500 = -500, clamped to 0 }, }, want: &models.Container{ Disk: models.Disk{ Total: 0, Used: 500, }, Disks: []models.Disk{ { Total: 0, Used: 500, // Not clamped because total == 0 (clamping only when total > 0) Free: 0, // Clamped from -500 to 0 Usage: 0, // No calculation when total == 0 Mountpoint: "/", Type: "rootfs", }, }, }, }, { name: "usage already set not recalculated", container: &models.Container{ Disk: models.Disk{ Total: 1000, Used: 500, Usage: 75.0, // Already set (even if wrong), should not be recalculated }, }, want: &models.Container{ Disk: models.Disk{ Total: 1000, Used: 500, Usage: 75.0, }, Disks: []models.Disk{ { Total: 1000, Used: 500, Free: 500, Usage: 75.0, // Preserved from original Mountpoint: "/", Type: "rootfs", }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() // Make a copy to avoid test interference var container *models.Container if tt.container != nil { containerCopy := *tt.container if tt.container.Disks != nil { containerCopy.Disks = make([]models.Disk, len(tt.container.Disks)) copy(containerCopy.Disks, tt.container.Disks) } container = &containerCopy } ensureContainerRootDiskEntry(container) if !containersEqual(container, tt.want) { t.Errorf("ensureContainerRootDiskEntry() =\n%+v\nwant:\n%+v", container, tt.want) } }) } } // Helper functions for test comparisons // Note: stringSlicesEqual already exists in helpers_test.go func networkDetailsSlicesEqual(a, b []containerNetworkDetails) bool { if len(a) != len(b) { return false } if a == nil && b == nil { return true } if a == nil || b == nil { return false } for i := range a { if a[i].Name != b[i].Name || a[i].MAC != b[i].MAC { return false } if !stringSlicesEqual(a[i].Addresses, b[i].Addresses) { return false } } return true } func mountMetadataMapsEqual(a, b map[string]containerMountMetadata) bool { if len(a) != len(b) { return false } if a == nil && b == nil { return true } if a == nil || b == nil { return false } for key, valA := range a { valB, ok := b[key] if !ok { return false } if valA.Key != valB.Key || valA.Mountpoint != valB.Mountpoint || valA.Source != valB.Source { return false } } return true } func guestNetworkInterfaceSlicesEqual(a, b []models.GuestNetworkInterface) bool { if len(a) != len(b) { return false } if a == nil && b == nil { return true } if a == nil || b == nil { return false } for i := range a { if a[i].Name != b[i].Name || a[i].MAC != b[i].MAC { return false } if !stringSlicesEqual(a[i].Addresses, b[i].Addresses) { return false } } return true } func diskSlicesEqual(a, b []models.Disk) bool { if len(a) != len(b) { return false } if a == nil && b == nil { return true } if a == nil || b == nil { return false } for i := range a { if !disksEqual(&a[i], &b[i]) { return false } } return true } func disksEqual(a, b *models.Disk) bool { if a == nil && b == nil { return true } if a == nil || b == nil { return false } return a.Total == b.Total && a.Used == b.Used && a.Free == b.Free && floatsEqual(a.Usage, b.Usage) && a.Mountpoint == b.Mountpoint && a.Type == b.Type && a.Device == b.Device } func floatsEqual(a, b float64) bool { const epsilon = 0.0001 diff := a - b if diff < 0 { diff = -diff } return diff < epsilon } func containersEqual(a, b *models.Container) bool { if a == nil && b == nil { return true } if a == nil || b == nil { return false } if a.Disk.Total != b.Disk.Total || a.Disk.Used != b.Disk.Used || !floatsEqual(a.Disk.Usage, b.Disk.Usage) { return false } return diskSlicesEqual(a.Disks, b.Disks) } // OCI Container Detection Tests (Proxmox VE 9.1+) func TestExtractContainerOSTemplate(t *testing.T) { t.Parallel() tests := []struct { name string config map[string]interface{} expected string }{ { name: "empty config", config: map[string]interface{}{}, expected: "", }, { name: "standard LXC template", config: map[string]interface{}{ "ostemplate": "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst", }, expected: "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst", }, { name: "OCI image with oci prefix", config: map[string]interface{}{ "ostemplate": "oci:docker.io/library/alpine:latest", }, expected: "oci:docker.io/library/alpine:latest", }, { name: "Docker Hub shorthand", config: map[string]interface{}{ "ostemplate": "docker:nginx:latest", }, expected: "docker:nginx:latest", }, { name: "template field fallback", config: map[string]interface{}{ "template": "oci:ghcr.io/myorg/myimage:v1.0", }, expected: "oci:ghcr.io/myorg/myimage:v1.0", }, { name: "ostemplate takes precedence over template", config: map[string]interface{}{ "ostemplate": "oci:docker.io/library/alpine:latest", "template": "local:vztmpl/something-else.tar.gz", }, expected: "oci:docker.io/library/alpine:latest", }, { name: "whitespace trimmed", config: map[string]interface{}{ "ostemplate": " oci:docker.io/library/alpine:latest ", }, expected: "oci:docker.io/library/alpine:latest", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := extractContainerOSTemplate(tt.config) if result != tt.expected { t.Errorf("extractContainerOSTemplate() = %q, want %q", result, tt.expected) } }) } } func TestIsOCITemplate(t *testing.T) { t.Parallel() tests := []struct { name string template string expected bool }{ // Empty/nil cases { name: "empty string", template: "", expected: false, }, // Standard LXC templates (should NOT be detected as OCI) { name: "standard LXC template", template: "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst", expected: false, }, { name: "Proxmox template store", template: "local:vztmpl/debian-12-standard_12.0-1_amd64.tar.zst", expected: false, }, // Explicit OCI prefix { name: "oci prefix - Docker Hub", template: "oci:docker.io/library/alpine:latest", expected: true, }, { name: "oci prefix - GHCR", template: "oci:ghcr.io/myorg/myimage:v1.0", expected: true, }, { name: "oci prefix uppercase", template: "OCI:docker.io/library/nginx:latest", expected: true, }, // Docker Hub shorthand { name: "docker prefix simple", template: "docker:alpine:latest", expected: true, }, { name: "docker prefix with path", template: "docker:library/nginx:1.25", expected: true, }, { name: "docker prefix uppercase", template: "DOCKER:redis:7", expected: true, }, // Registry URLs embedded (with slashes as the detection logic expects) { name: "Docker Hub URL with slashes", template: "docker.io/library/alpine:latest", expected: true, }, { name: "GHCR URL with slashes", template: "ghcr.io/myorg/myapp:v2", expected: true, }, { name: "GCR URL", template: "gcr.io/myproject/myimage:latest", expected: true, }, { name: "Quay.io URL", template: "quay.io/coreos/etcd:v3.5", expected: true, }, { name: "Microsoft Container Registry", template: "mcr.microsoft.com/dotnet/runtime:7.0", expected: true, }, { name: "AWS ECR Public", template: "public.ecr.aws/amazonlinux/amazonlinux:latest", expected: true, }, // Locally stored OCI images { name: "local OCI image with oci- prefix", template: "local:vztmpl/oci-alpine-3.18.tar.xz", expected: true, }, { name: "local OCI image with oci_ prefix", template: "local:vztmpl/oci_nginx_latest.tar.gz", expected: true, }, // Edge cases { name: "case insensitive oci", template: "OcI:docker.io/library/alpine:latest", expected: true, }, { name: "whitespace handling", template: " oci:docker.io/library/alpine:latest ", expected: true, }, { name: "similar but not OCI - social.io", template: "local:vztmpl/social.io-app.tar.gz", expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := isOCITemplate(tt.template) if result != tt.expected { t.Errorf("isOCITemplate(%q) = %v, want %v", tt.template, result, tt.expected) } }) } }