Files
Pulse/internal/monitoring/container_parsing_test.go
rcourtman e55df08dab feat: Add Proxmox 9.1+ OCI container support
- Backend: Add IsOCI and OSTemplate fields to Container model
- Backend: Add extractContainerOSTemplate() and isOCITemplate() detection functions
- Backend: Detect OCI containers via ostemplate config and set type to 'oci'
- Frontend: Add isOci and osTemplate to Container interface
- Frontend: Add 'oci-container' to ResourceType with distinct purple badge
- Frontend: Update Dashboard filters to include OCI containers with LXC
- Tests: Add comprehensive unit tests for OCI detection logic

OCI containers are detected by checking the ostemplate for patterns like:
- oci: prefix (e.g., oci:docker.io/library/alpine:latest)
- docker: prefix (e.g., docker:nginx:latest)
- Known registry URLs (docker.io, ghcr.io, gcr.io, quay.io, etc.)
- Local templates with oci- or oci_ filename patterns
2025-12-12 17:51:43 +00:00

1956 lines
40 KiB
Go

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)
}
})
}
}