Tests for LXC container config parsing functions: - sanitizeRootFSDevice: rootfs device extraction - collectIPsFromInterface: recursive IP extraction from various types - parseContainerRawIPs: JSON IP parsing - parseContainerConfigNetworks: network interface parsing with MAC normalization, CIDR stripping, sorting - parseContainerMountMetadata: mount point metadata parsing - extractContainerRootDeviceFromConfig: root device extraction - mergeContainerNetworkInterface: interface merging by name/MAC - convertContainerDiskInfo: Proxmox disk info conversion - ensureContainerRootDiskEntry: root disk entry creation 91+ test cases covering edge cases, invalid data, and complex scenarios. Coverage improved from 40.6% to 42.2%.
1373 lines
28 KiB
Go
1373 lines
28 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
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
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",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
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",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
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: "negative free clamped to zero",
|
|
container: &models.Container{
|
|
Disk: models.Disk{
|
|
Total: 1000,
|
|
Used: 1500, // More than total
|
|
},
|
|
},
|
|
want: &models.Container{
|
|
Disk: models.Disk{
|
|
Total: 1000,
|
|
Used: 1500,
|
|
},
|
|
Disks: []models.Disk{
|
|
{
|
|
Total: 1000,
|
|
Used: 1000,
|
|
Free: 0,
|
|
Usage: 100.0,
|
|
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)
|
|
}
|