mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
- 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
1956 lines
40 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|