0
0
mirror of https://github.com/bpg/terraform-provider-proxmox.git synced 2025-08-22 19:38:35 +00:00

feat(vm): add support for numa architecture attribute (#1156) (#1175)

* feat(vm): add support for numa architecture attribute (#1156)

Signed-off-by: Serge Logvinov <serge.logvinov@sinextra.dev>

* fix: numa blocks reordering issue

Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>

---------

Signed-off-by: Serge Logvinov <serge.logvinov@sinextra.dev>
Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
Co-authored-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
This commit is contained in:
Serge 2024-04-06 23:30:13 +03:00 committed by GitHub
parent 31b6812ce2
commit dbbd966736
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 454 additions and 7 deletions

View File

@ -22,6 +22,7 @@
"cmode", "cmode",
"cpulimit", "cpulimit",
"CPUNUMA", "CPUNUMA",
"cpus",
"cputype", "cputype",
"cpuunits", "cpuunits",
"customdiff", "customdiff",
@ -33,8 +34,10 @@
"gocritic", "gocritic",
"gosimple", "gosimple",
"hookscript", "hookscript",
"hostnodes",
"hostpci", "hostpci",
"Hotplugged", "Hotplugged",
"Hugepages",
"iface", "iface",
"importdisk", "importdisk",
"iothread", "iothread",

View File

@ -417,6 +417,17 @@ output "ubuntu_vm_public_key" {
Settings `hugepages` and `keep_hugepages` are only allowed for `root@pam` authenticated user. Settings `hugepages` and `keep_hugepages` are only allowed for `root@pam` authenticated user.
And required `cpu.numa` to be enabled. And required `cpu.numa` to be enabled.
- `numa` - (Optional) The NUMA configuration.
- `device` - (Required) The NUMA device name for Proxmox, in form
of `numaX` where `X` is a sequential number from 0 to 7.
- `cpus` - (Required) The CPU cores to assign to the NUMA node (format is `0-7;16-31`).
- `memory` - (Required) The memory in megabytes to assign to the NUMA node.
- `hostnodes` - (Optional) The NUMA host nodes.
- `policy` - (Optional) The NUMA policy (defaults to `preferred`).
- `interleave` - Interleave memory across nodes.
- `preferred` - Prefer the specified node.
- `bind` - Only use the specified node.
- `migrate` - (Optional) Migrate the VM on node change instead of re-creating - `migrate` - (Optional) Migrate the VM on node change instead of re-creating
it (defaults to `false`). it (defaults to `false`).
- `name` - (Optional) The virtual machine name. - `name` - (Optional) The virtual machine name.

View File

@ -131,6 +131,12 @@ resource "proxmox_virtual_environment_vm" "example" {
# hugepages = "2" # hugepages = "2"
} }
# numa {
# device = "numa0"
# cpus = "0-1"
# memory = 768
# }
connection { connection {
type = "ssh" type = "ssh"
agent = false agent = false

View File

@ -109,7 +109,7 @@ type CustomNetworkDevices []CustomNetworkDevice
type CustomNUMADevice struct { type CustomNUMADevice struct {
CPUIDs []string `json:"cpus" url:"cpus,semicolon"` CPUIDs []string `json:"cpus" url:"cpus,semicolon"`
HostNodeNames *[]string `json:"hostnodes,omitempty" url:"hostnodes,omitempty,semicolon"` HostNodeNames *[]string `json:"hostnodes,omitempty" url:"hostnodes,omitempty,semicolon"`
Memory *float64 `json:"memory,omitempty" url:"memory,omitempty"` Memory *int `json:"memory,omitempty" url:"memory,omitempty"`
Policy *string `json:"policy,omitempty" url:"policy,omitempty"` Policy *string `json:"policy,omitempty" url:"policy,omitempty"`
} }
@ -455,8 +455,15 @@ type GetResponseData struct {
NetworkDevice29 *CustomNetworkDevice `json:"net29,omitempty"` NetworkDevice29 *CustomNetworkDevice `json:"net29,omitempty"`
NetworkDevice30 *CustomNetworkDevice `json:"net30,omitempty"` NetworkDevice30 *CustomNetworkDevice `json:"net30,omitempty"`
NetworkDevice31 *CustomNetworkDevice `json:"net31,omitempty"` NetworkDevice31 *CustomNetworkDevice `json:"net31,omitempty"`
NUMADevices *CustomNUMADevices `json:"numa_devices,omitempty"`
NUMAEnabled *types.CustomBool `json:"numa,omitempty"` NUMAEnabled *types.CustomBool `json:"numa,omitempty"`
NUMADevices0 *CustomNUMADevice `json:"numa0,omitempty"`
NUMADevices1 *CustomNUMADevice `json:"numa1,omitempty"`
NUMADevices2 *CustomNUMADevice `json:"numa2,omitempty"`
NUMADevices3 *CustomNUMADevice `json:"numa3,omitempty"`
NUMADevices4 *CustomNUMADevice `json:"numa4,omitempty"`
NUMADevices5 *CustomNUMADevice `json:"numa5,omitempty"`
NUMADevices6 *CustomNUMADevice `json:"numa6,omitempty"`
NUMADevices7 *CustomNUMADevice `json:"numa7,omitempty"`
OSType *string `json:"ostype,omitempty"` OSType *string `json:"ostype,omitempty"`
Overwrite *types.CustomBool `json:"force,omitempty"` Overwrite *types.CustomBool `json:"force,omitempty"`
PCIDevice0 *CustomPCIDevice `json:"hostpci0,omitempty"` PCIDevice0 *CustomPCIDevice `json:"hostpci0,omitempty"`
@ -939,7 +946,7 @@ func (r CustomNUMADevice) EncodeValues(key string, v *url.Values) error {
} }
if r.Memory != nil { if r.Memory != nil {
values = append(values, fmt.Sprintf("memory=%f", *r.Memory)) values = append(values, fmt.Sprintf("memory=%d", *r.Memory))
} }
if r.Policy != nil { if r.Policy != nil {
@ -1590,6 +1597,41 @@ func (r *CustomNetworkDevice) UnmarshalJSON(b []byte) error {
return nil return nil
} }
// UnmarshalJSON converts a CustomNUMADevice string to an object.
func (r *CustomNUMADevice) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return fmt.Errorf("failed to unmarshal CustomNUMADevice: %w", err)
}
pairs := strings.Split(s, ",")
for _, p := range pairs {
v := strings.Split(strings.TrimSpace(p), "=")
if len(v) == 2 {
switch v[0] {
case "cpus":
r.CPUIDs = strings.Split(v[1], ";")
case "hostnodes":
hostnodes := strings.Split(v[1], ";")
r.HostNodeNames = &hostnodes
case "memory":
memory, err := strconv.Atoi(v[1])
if err != nil {
return fmt.Errorf("failed to parse memory size: %w", err)
}
r.Memory = &memory
case "policy":
r.Policy = &v[1]
}
}
}
return nil
}
// UnmarshalJSON converts a CustomPCIDevice string to an object. // UnmarshalJSON converts a CustomPCIDevice string to an object.
func (r *CustomPCIDevice) UnmarshalJSON(b []byte) error { func (r *CustomPCIDevice) UnmarshalJSON(b []byte) error {
var s string var s string

View File

@ -274,6 +274,47 @@ func TestCustomPCIDevice_UnmarshalJSON(t *testing.T) {
} }
} }
func TestCustomNUMADevice_UnmarshalJSON(t *testing.T) {
t.Parallel()
tests := []struct {
name string
line string
want *CustomNUMADevice
wantErr bool
}{
{
name: "numa device all options",
line: `"cpus=1-2;3-4,hostnodes=1-2,memory=1024,policy=preferred"`,
want: &CustomNUMADevice{
CPUIDs: []string{"1-2", "3-4"},
HostNodeNames: &[]string{"1-2"},
Memory: types.IntPtr(1024),
Policy: types.StrPtr("preferred"),
},
},
{
name: "numa device cpus/memory only",
line: `"cpus=1-2,memory=1024"`,
want: &CustomNUMADevice{
CPUIDs: []string{"1-2"},
Memory: types.IntPtr(1024),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := &CustomNUMADevice{}
if err := r.UnmarshalJSON([]byte(tt.line)); (err != nil) != tt.wantErr {
t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestCustomUSBDevice_UnmarshalJSON(t *testing.T) { func TestCustomUSBDevice_UnmarshalJSON(t *testing.T) {
t.Parallel() t.Parallel()

View File

@ -147,7 +147,7 @@ func CPUTypeValidator() schema.SchemaValidateDiagFunc {
// CPUAffinityValidator returns a schema validation function for a CPU affinity. // CPUAffinityValidator returns a schema validation function for a CPU affinity.
func CPUAffinityValidator() schema.SchemaValidateDiagFunc { func CPUAffinityValidator() schema.SchemaValidateDiagFunc {
return validation.ToDiagFunc( return validation.ToDiagFunc(
validation.StringMatch(regexp.MustCompile(`^\d+[\d-,]*$`), "must contain numbers but also number ranges"), validation.StringMatch(regexp.MustCompile(`^\d+[\d-,]*$`), "must contain numbers or number ranges separated by ','"),
) )
} }
@ -338,3 +338,13 @@ func SerialDeviceValidator() schema.SchemaValidateDiagFunc {
return nil, es return nil, es
}) })
} }
// RangeSemicolonValidator is a proxmox list validation function for ranges with semicolon.
func RangeSemicolonValidator() schema.SchemaValidateDiagFunc {
return validation.ToDiagFunc(
validation.StringMatch(
regexp.MustCompile(`^\d+(?:-\d+)?(?:;\d+(?:-\d+)?)*$`),
"must contain numbers or number ranges separated by ';'",
),
)
}

View File

@ -11,6 +11,7 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -19,6 +20,7 @@ import (
"github.com/bpg/terraform-provider-proxmox/proxmoxtf/resource/vm/disk" "github.com/bpg/terraform-provider-proxmox/proxmoxtf/resource/vm/disk"
"github.com/bpg/terraform-provider-proxmox/proxmoxtf/resource/vm/network" "github.com/bpg/terraform-provider-proxmox/proxmoxtf/resource/vm/network"
"github.com/bpg/terraform-provider-proxmox/utils" "github.com/bpg/terraform-provider-proxmox/utils"
"golang.org/x/exp/maps"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/google/uuid" "github.com/google/uuid"
@ -133,6 +135,8 @@ const (
maxResourceVirtualEnvironmentVMSerialDevices = 4 maxResourceVirtualEnvironmentVMSerialDevices = 4
maxResourceVirtualEnvironmentVMHostPCIDevices = 8 maxResourceVirtualEnvironmentVMHostPCIDevices = 8
maxResourceVirtualEnvironmentVMHostUSBDevices = 4 maxResourceVirtualEnvironmentVMHostUSBDevices = 4
// hardcoded /usr/share/perl5/PVE/QemuServer/Memory.pm: "our $MAX_NUMA = 8".
maxResourceVirtualEnvironmentVMNUMADevices = 8
mkRebootAfterCreation = "reboot" mkRebootAfterCreation = "reboot"
mkOnBoot = "on_boot" mkOnBoot = "on_boot"
@ -171,6 +175,13 @@ const (
mkCPUAffinity = "affinity" mkCPUAffinity = "affinity"
mkDescription = "description" mkDescription = "description"
mkNUMA = "numa"
mkNUMADevice = "device"
mkNUMACPUIDs = "cpus"
mkNUMAHostNodeNames = "hostnodes"
mkNUMAMemory = "memory"
mkNUMAPolicy = "policy"
mkEFIDisk = "efi_disk" mkEFIDisk = "efi_disk"
mkEFIDiskDatastoreID = "datastore_id" mkEFIDiskDatastoreID = "datastore_id"
mkEFIDiskFileFormat = "file_format" mkEFIDiskFileFormat = "file_format"
@ -1049,7 +1060,7 @@ func VM() *schema.Resource {
Description: "Hugepages will not be deleted after VM shutdown and can be used for subsequent starts", Description: "Hugepages will not be deleted after VM shutdown and can be used for subsequent starts",
Optional: true, Optional: true,
Default: dvMemoryKeepHugepages, Default: dvMemoryKeepHugepages,
RequiredWith: []string{"cpu.0.numa", "memory.0.hugepages"}, RequiredWith: []string{"cpu.0.numa"},
}, },
}, },
}, },
@ -1067,6 +1078,68 @@ func VM() *schema.Resource {
Description: "The node name", Description: "The node name",
Required: true, Required: true,
}, },
mkNUMA: {
Type: schema.TypeList,
Description: "The NUMA topology",
Optional: true,
ForceNew: false,
DefaultFunc: func() (interface{}, error) {
return []interface{}{}, nil
},
DiffSuppressFunc: structure.SuppressIfListsOfMapsAreEqualIgnoringOrderByKey(mkNUMADevice),
DiffSuppressOnRefresh: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
mkNUMADevice: {
Type: schema.TypeString,
Description: "Numa node device ID",
Optional: false,
Required: true,
RequiredWith: []string{"cpu.0.numa"},
ValidateDiagFunc: validation.ToDiagFunc(validation.StringMatch(
regexp.MustCompile(`^numa\d+$`),
"numa node device ID must be in the format 'numaX' where X is a number",
)),
},
mkNUMACPUIDs: {
Type: schema.TypeString,
Description: "CPUs accessing this NUMA node",
Optional: false,
Required: true,
RequiredWith: []string{"cpu.0.numa"},
ValidateDiagFunc: RangeSemicolonValidator(),
},
mkNUMAMemory: {
Type: schema.TypeInt,
Description: "Amount of memory this NUMA node provides",
Optional: false,
Required: true,
RequiredWith: []string{"cpu.0.numa"},
ValidateDiagFunc: validation.ToDiagFunc(
validation.IntBetween(64, 268435456),
),
},
mkNUMAHostNodeNames: {
Type: schema.TypeString,
Description: "Host NUMA nodes to use",
Optional: true,
RequiredWith: []string{"cpu.0.numa"},
ValidateDiagFunc: RangeSemicolonValidator(),
},
mkNUMAPolicy: {
Type: schema.TypeString,
Description: "NUMA policy",
Optional: true,
Default: "preferred",
ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{
"bind",
"interleave",
"preferred",
}, true)),
},
},
},
},
mkMigrate: { mkMigrate: {
Type: schema.TypeBool, Type: schema.TypeBool,
Description: "Whether to migrate the VM on node change instead of re-creating it", Description: "Whether to migrate the VM on node change instead of re-creating it",
@ -1795,6 +1868,7 @@ func vmCreateClone(ctx context.Context, d *schema.ResourceData, m interface{}) d
hostUSB := d.Get(mkHostUSB).([]interface{}) hostUSB := d.Get(mkHostUSB).([]interface{})
keyboardLayout := d.Get(mkKeyboardLayout).(string) keyboardLayout := d.Get(mkKeyboardLayout).(string)
memory := d.Get(mkMemory).([]interface{}) memory := d.Get(mkMemory).([]interface{})
numa := d.Get(mkNUMA).([]interface{})
operatingSystem := d.Get(mkOperatingSystem).([]interface{}) operatingSystem := d.Get(mkOperatingSystem).([]interface{})
serialDevice := d.Get(mkSerialDevice).([]interface{}) serialDevice := d.Get(mkSerialDevice).([]interface{})
onBoot := types.CustomBool(d.Get(mkOnBoot).(bool)) onBoot := types.CustomBool(d.Get(mkOnBoot).(bool))
@ -1969,6 +2043,10 @@ func vmCreateClone(ctx context.Context, d *schema.ResourceData, m interface{}) d
updateBody.PCIDevices = vmGetHostPCIDeviceObjects(d) updateBody.PCIDevices = vmGetHostPCIDeviceObjects(d)
} }
if len(numa) > 0 {
updateBody.NUMADevices = vmGetNumaDeviceObjects(d)
}
if len(hostUSB) > 0 { if len(hostUSB) > 0 {
updateBody.USBDevices = vmGetHostUSBDeviceObjects(d) updateBody.USBDevices = vmGetHostUSBDeviceObjects(d)
} }
@ -2389,6 +2467,8 @@ func vmCreateCustom(ctx context.Context, d *schema.ResourceData, m interface{})
pciDeviceObjects := vmGetHostPCIDeviceObjects(d) pciDeviceObjects := vmGetHostPCIDeviceObjects(d)
numaDeviceObjects := vmGetNumaDeviceObjects(d)
usbDeviceObjects := vmGetHostUSBDeviceObjects(d) usbDeviceObjects := vmGetHostUSBDeviceObjects(d)
keyboardLayout := d.Get(mkKeyboardLayout).(string) keyboardLayout := d.Get(mkKeyboardLayout).(string)
@ -2559,6 +2639,7 @@ func vmCreateCustom(ctx context.Context, d *schema.ResourceData, m interface{})
KeyboardLayout: &keyboardLayout, KeyboardLayout: &keyboardLayout,
NetworkDevices: networkDeviceObjects, NetworkDevices: networkDeviceObjects,
NUMAEnabled: &cpuNUMA, NUMAEnabled: &cpuNUMA,
NUMADevices: numaDeviceObjects,
OSType: &operatingSystemType, OSType: &operatingSystemType,
PCIDevices: pciDeviceObjects, PCIDevices: pciDeviceObjects,
SCSIHardware: &scsiHardware, SCSIHardware: &scsiHardware,
@ -3029,6 +3110,49 @@ func vmGetHostPCIDeviceObjects(d *schema.ResourceData) vms.CustomPCIDevices {
return pciDeviceObjects return pciDeviceObjects
} }
func vmGetNumaDeviceObjects(d *schema.ResourceData) vms.CustomNUMADevices {
numaNode := d.Get(mkNUMA).([]interface{})
numaNodeObjects := make(vms.CustomNUMADevices, len(numaNode))
for i, numaNodeEntry := range numaNode {
block := numaNodeEntry.(map[string]interface{})
deviceName := block[mkNUMADevice].(string)
ids := block[mkNUMACPUIDs].(string)
hostNodes, _ := block[mkNUMAHostNodeNames].(string)
memory, _ := block[mkNUMAMemory].(int)
policy, _ := block[mkNUMAPolicy].(string)
device := vms.CustomNUMADevice{
Memory: &memory,
Policy: &policy,
}
if ids != "" {
dIDs := strings.Split(ids, ";")
device.CPUIDs = dIDs
}
if hostNodes != "" {
dHostNodes := strings.Split(hostNodes, ";")
device.HostNodeNames = &dHostNodes
}
if strings.HasPrefix(deviceName, "numa") {
deviceID, err := strconv.Atoi(deviceName[4:])
if err == nil {
numaNodeObjects[deviceID] = device
continue
}
}
numaNodeObjects[i] = device
}
return numaNodeObjects
}
func vmGetHostUSBDeviceObjects(d *schema.ResourceData) vms.CustomUSBDevices { func vmGetHostUSBDeviceObjects(d *schema.ResourceData) vms.CustomUSBDevices {
usbDevice := d.Get(mkHostUSB).([]interface{}) usbDevice := d.Get(mkHostUSB).([]interface{})
usbDeviceObjects := make(vms.CustomUSBDevices, len(usbDevice)) usbDeviceObjects := make(vms.CustomUSBDevices, len(usbDevice))
@ -3492,6 +3616,44 @@ func vmReadCustom(
cpu[mkCPUNUMA] = false cpu[mkCPUNUMA] = false
} }
currentNUMAList := d.Get(mkNUMA).([]interface{})
numaMap := map[string]interface{}{}
numaDevices := getNUMAInfo(vmConfig, d)
for ni, np := range numaDevices {
if np == nil || np.CPUIDs == nil || np.HostNodeNames == nil {
continue
}
numaNode := map[string]interface{}{}
numaNode[mkNUMADevice] = ni
if len(np.CPUIDs) > 0 {
numaNode[mkNUMACPUIDs] = strings.Join(np.CPUIDs, ";")
}
numaNode[mkNUMAHostNodeNames] = strings.Join(*np.HostNodeNames, ";")
numaNode[mkNUMAMemory] = np.Memory
numaNode[mkNUMAPolicy] = np.Policy
numaMap[ni] = numaNode
}
if len(clone) == 0 || len(currentNUMAList) > 0 {
var numaList []interface{}
if len(currentNUMAList) > 0 {
resMap := utils.MapResourceList(currentNUMAList, mkNUMADevice)
devices := maps.Keys[map[string]interface{}](resMap)
numaList = utils.OrderedListFromMapByKeyValues(numaMap, devices)
} else {
numaList = utils.OrderedListFromMap(numaMap)
}
err := d.Set(mkNUMA, numaList)
diags = append(diags, diag.FromErr(err)...)
}
if vmConfig.CPUSockets != nil { if vmConfig.CPUSockets != nil {
cpu[mkCPUSockets] = *vmConfig.CPUSockets cpu[mkCPUSockets] = *vmConfig.CPUSockets
} else { } else {
@ -4971,6 +5133,17 @@ func vmUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.D
rebootRequired = true rebootRequired = true
} }
// Prepare the new numa devices configuration.
if d.HasChange(mkNUMA) {
updateBody.NUMADevices = vmGetNumaDeviceObjects(d)
for i := len(updateBody.NUMADevices); i < maxResourceVirtualEnvironmentVMNUMADevices; i++ {
del = append(del, fmt.Sprintf("numa%d", i))
}
rebootRequired = true
}
// Prepare the new usb devices configuration. // Prepare the new usb devices configuration.
if d.HasChange(mkHostUSB) { if d.HasChange(mkHostUSB) {
updateBody.USBDevices = vmGetHostUSBDeviceObjects(d) updateBody.USBDevices = vmGetHostUSBDeviceObjects(d)
@ -5477,6 +5650,21 @@ func getDiskDatastores(vm *vms.GetResponseData, d *schema.ResourceData) []string
return datastores return datastores
} }
func getNUMAInfo(resp *vms.GetResponseData, _ *schema.ResourceData) map[string]*vms.CustomNUMADevice {
numaDevices := map[string]*vms.CustomNUMADevice{}
numaDevices["numa0"] = resp.NUMADevices0
numaDevices["numa1"] = resp.NUMADevices1
numaDevices["numa2"] = resp.NUMADevices2
numaDevices["numa3"] = resp.NUMADevices3
numaDevices["numa4"] = resp.NUMADevices4
numaDevices["numa5"] = resp.NUMADevices5
numaDevices["numa6"] = resp.NUMADevices6
numaDevices["numa7"] = resp.NUMADevices7
return numaDevices
}
func getPCIInfo(resp *vms.GetResponseData, _ *schema.ResourceData) map[string]*vms.CustomPCIDevice { func getPCIInfo(resp *vms.GetResponseData, _ *schema.ResourceData) map[string]*vms.CustomPCIDevice {
pciDevices := map[string]*vms.CustomPCIDevice{} pciDevices := map[string]*vms.CustomPCIDevice{}

View File

@ -341,6 +341,21 @@ func TestVMSchema(t *testing.T) {
mkMemoryShared: schema.TypeInt, mkMemoryShared: schema.TypeInt,
}) })
numaSchema := test.AssertNestedSchemaExistence(t, s, mkNUMA)
test.AssertOptionalArguments(t, numaSchema, []string{
mkNUMAHostNodeNames,
mkNUMAPolicy,
})
test.AssertValueTypes(t, numaSchema, map[string]schema.ValueType{
mkNUMADevice: schema.TypeString,
mkNUMACPUIDs: schema.TypeString,
mkNUMAMemory: schema.TypeInt,
mkNUMAHostNodeNames: schema.TypeString,
mkNUMAPolicy: schema.TypeString,
})
operatingSystemSchema := test.AssertNestedSchemaExistence( operatingSystemSchema := test.AssertNestedSchemaExistence(
t, t,
s, s,

View File

@ -12,6 +12,7 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/bpg/terraform-provider-proxmox/utils"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
) )
@ -86,7 +87,7 @@ func GetSchemaBlock(
// recommended to use it only for small lists. // recommended to use it only for small lists.
// Ref: https://github.com/hashicorp/terraform-plugin-sdk/issues/477 // Ref: https://github.com/hashicorp/terraform-plugin-sdk/issues/477
func SuppressIfListsAreEqualIgnoringOrder(key, _, _ string, d *schema.ResourceData) bool { func SuppressIfListsAreEqualIgnoringOrder(key, _, _ string, d *schema.ResourceData) bool {
// the key is a path to the list item, not the list itself, e.g. "tags.0" // the key is a path to the list item, not the list itself, e.g. "tags.#"
lastDotIndex := strings.LastIndex(key, ".") lastDotIndex := strings.LastIndex(key, ".")
if lastDotIndex != -1 { if lastDotIndex != -1 {
key = key[:lastDotIndex] key = key[:lastDotIndex]
@ -120,3 +121,52 @@ func SuppressIfListsAreEqualIgnoringOrder(key, _, _ string, d *schema.ResourceDa
return reflect.DeepEqual(oldEvents, newEvents) return reflect.DeepEqual(oldEvents, newEvents)
} }
// SuppressIfListsOfMapsAreEqualIgnoringOrderByKey is a customdiff.SuppressionFunc that suppresses
// changes to a list of resources if the old and new lists are equal, ignoring the order of the
// elements.
// It will be called for each resource attribute, so it is not super efficient. It is
// recommended to use it only for small lists / small resources.
// Note: The order of the attributes within the resource is still important.
// Ref: https://github.com/hashicorp/terraform-plugin-sdk/issues/477
func SuppressIfListsOfMapsAreEqualIgnoringOrderByKey(keyAttr string) schema.SchemaDiffSuppressFunc {
// the attr is a path to the item's attribute, not the list itself, e.g. "numa.0.device"
return func(attr, _, _ string, d *schema.ResourceData) bool {
lastDotIndex := strings.LastIndex(attr, ".")
if lastDotIndex != -1 {
attr = attr[:lastDotIndex]
}
lastDotIndex = strings.LastIndex(attr, ".")
if lastDotIndex != -1 {
attr = attr[:lastDotIndex]
}
oldData, newData := d.GetChange(attr)
if oldData == nil || newData == nil {
return false
}
oldArray := oldData.([]interface{})
newArray := newData.([]interface{})
if len(oldArray) != len(newArray) {
return false
}
oldKeys := utils.MapResourceList(oldArray, keyAttr)
newKeys := utils.MapResourceList(newArray, keyAttr)
for k, v := range oldKeys {
if _, ok := newKeys[k]; !ok {
return false
}
if !reflect.DeepEqual(v, newKeys[k]) {
return false
}
}
return true
}
}

View File

@ -1,3 +1,3 @@
#!/bin/sh #!/bin/sh
TF_ACC=1 env $(cat testacc.env | xargs) go test -v -timeout 120s -run "$1" github.com/bpg/terraform-provider-proxmox/fwprovider/tests $2 TF_ACC=1 env $(cat testacc.env | xargs) go test -v -timeout 360s -run "$1" github.com/bpg/terraform-provider-proxmox/fwprovider/tests $2

View File

@ -22,3 +22,35 @@ func OrderedListFromMap(inputMap map[string]interface{}) []interface{} {
return orderedList return orderedList
} }
// MapResourceList generates a list of strings from a Terraform resource list (list of maps).
// The list is generated from the value of the specified attribute.
//
// "Map" in this context is a functional programming term, not a Go map.
// "Resource" in this context is a Terraform resource, i.e. a map of attributes.
func MapResourceList(resourceList []interface{}, attrName string) map[string]interface{} {
m := make(map[string]interface{}, len(resourceList))
for _, resource := range resourceList {
r := resource.(map[string]interface{})
key := r[attrName].(string)
m[key] = r
}
return m
}
// OrderedListFromMapByKeyValues generates a list from a map's values.
// The values are sorted based on the provided key list. If a key is not found in the map, it is skipped.
func OrderedListFromMapByKeyValues(inputMap map[string]interface{}, keyList []string) []interface{} {
orderedList := make([]interface{}, len(keyList))
for i, k := range keyList {
val, ok := inputMap[k]
if ok {
orderedList[i] = val
}
}
return orderedList
}

49
utils/maps_test.go Normal file
View File

@ -0,0 +1,49 @@
package utils
import (
"reflect"
"testing"
)
func TestMapResourceList(t *testing.T) {
t.Parallel()
resourceList := []interface{}{
map[string]interface{}{"name": "resource1", "attr": "value1"},
map[string]interface{}{"name": "resource2", "attr": "value2"},
map[string]interface{}{"name": "resource3", "attr": "value3"},
}
expected := map[string]interface{}{
"value1": map[string]interface{}{"name": "resource1", "attr": "value1"},
"value2": map[string]interface{}{"name": "resource2", "attr": "value2"},
"value3": map[string]interface{}{"name": "resource3", "attr": "value3"},
}
result := MapResourceList(resourceList, "attr")
if !reflect.DeepEqual(result, expected) {
t.Errorf("MapResourceList() = %v, want %v", result, expected)
}
}
func TestOrderedListFromMapByKeyValues(t *testing.T) {
t.Parallel()
inputMap := map[string]interface{}{
"key1": "value1",
"key2": "value2",
"key3": "value3",
"key4": "value4",
}
keyList := []string{"key2", "key1", "key4"}
expected := []interface{}{"value2", "value1", "value4"}
result := OrderedListFromMapByKeyValues(inputMap, keyList)
if !reflect.DeepEqual(result, expected) {
t.Errorf("OrderedListFromMapByKeyValues() = %v, want %v", result, expected)
}
}