diff --git a/.vscode/settings.json b/.vscode/settings.json index 6dbae1f2..53d7cf40 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,6 +22,7 @@ "cmode", "cpulimit", "CPUNUMA", + "cpus", "cputype", "cpuunits", "customdiff", @@ -33,8 +34,10 @@ "gocritic", "gosimple", "hookscript", + "hostnodes", "hostpci", "Hotplugged", + "Hugepages", "iface", "importdisk", "iothread", diff --git a/docs/resources/virtual_environment_vm.md b/docs/resources/virtual_environment_vm.md index a3538ee8..9d0a51a4 100644 --- a/docs/resources/virtual_environment_vm.md +++ b/docs/resources/virtual_environment_vm.md @@ -417,6 +417,17 @@ output "ubuntu_vm_public_key" { Settings `hugepages` and `keep_hugepages` are only allowed for `root@pam` authenticated user. 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 it (defaults to `false`). - `name` - (Optional) The virtual machine name. diff --git a/example/resource_virtual_environment_vm.tf b/example/resource_virtual_environment_vm.tf index e58911bb..d5d23d7e 100644 --- a/example/resource_virtual_environment_vm.tf +++ b/example/resource_virtual_environment_vm.tf @@ -131,6 +131,12 @@ resource "proxmox_virtual_environment_vm" "example" { # hugepages = "2" } + # numa { + # device = "numa0" + # cpus = "0-1" + # memory = 768 + # } + connection { type = "ssh" agent = false diff --git a/proxmox/nodes/vms/vms_types.go b/proxmox/nodes/vms/vms_types.go index 89e8a507..8ef7a854 100644 --- a/proxmox/nodes/vms/vms_types.go +++ b/proxmox/nodes/vms/vms_types.go @@ -109,7 +109,7 @@ type CustomNetworkDevices []CustomNetworkDevice type CustomNUMADevice struct { CPUIDs []string `json:"cpus" url:"cpus,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"` } @@ -455,8 +455,15 @@ type GetResponseData struct { NetworkDevice29 *CustomNetworkDevice `json:"net29,omitempty"` NetworkDevice30 *CustomNetworkDevice `json:"net30,omitempty"` NetworkDevice31 *CustomNetworkDevice `json:"net31,omitempty"` - NUMADevices *CustomNUMADevices `json:"numa_devices,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"` Overwrite *types.CustomBool `json:"force,omitempty"` PCIDevice0 *CustomPCIDevice `json:"hostpci0,omitempty"` @@ -939,7 +946,7 @@ func (r CustomNUMADevice) EncodeValues(key string, v *url.Values) error { } 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 { @@ -1590,6 +1597,41 @@ func (r *CustomNetworkDevice) UnmarshalJSON(b []byte) error { 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. func (r *CustomPCIDevice) UnmarshalJSON(b []byte) error { var s string diff --git a/proxmox/nodes/vms/vms_types_test.go b/proxmox/nodes/vms/vms_types_test.go index 21ff3b21..548df1a6 100644 --- a/proxmox/nodes/vms/vms_types_test.go +++ b/proxmox/nodes/vms/vms_types_test.go @@ -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) { t.Parallel() diff --git a/proxmoxtf/resource/vm/validators.go b/proxmoxtf/resource/vm/validators.go index a4ea0ee6..f7a2192a 100644 --- a/proxmoxtf/resource/vm/validators.go +++ b/proxmoxtf/resource/vm/validators.go @@ -147,7 +147,7 @@ func CPUTypeValidator() schema.SchemaValidateDiagFunc { // CPUAffinityValidator returns a schema validation function for a CPU affinity. func CPUAffinityValidator() schema.SchemaValidateDiagFunc { 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 }) } + +// 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 ';'", + ), + ) +} diff --git a/proxmoxtf/resource/vm/vm.go b/proxmoxtf/resource/vm/vm.go index 14c56607..2c7c9824 100644 --- a/proxmoxtf/resource/vm/vm.go +++ b/proxmoxtf/resource/vm/vm.go @@ -11,6 +11,7 @@ import ( "encoding/base64" "errors" "fmt" + "regexp" "sort" "strconv" "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/network" "github.com/bpg/terraform-provider-proxmox/utils" + "golang.org/x/exp/maps" "github.com/google/go-cmp/cmp" "github.com/google/uuid" @@ -133,6 +135,8 @@ const ( maxResourceVirtualEnvironmentVMSerialDevices = 4 maxResourceVirtualEnvironmentVMHostPCIDevices = 8 maxResourceVirtualEnvironmentVMHostUSBDevices = 4 + // hardcoded /usr/share/perl5/PVE/QemuServer/Memory.pm: "our $MAX_NUMA = 8". + maxResourceVirtualEnvironmentVMNUMADevices = 8 mkRebootAfterCreation = "reboot" mkOnBoot = "on_boot" @@ -171,6 +175,13 @@ const ( mkCPUAffinity = "affinity" mkDescription = "description" + mkNUMA = "numa" + mkNUMADevice = "device" + mkNUMACPUIDs = "cpus" + mkNUMAHostNodeNames = "hostnodes" + mkNUMAMemory = "memory" + mkNUMAPolicy = "policy" + mkEFIDisk = "efi_disk" mkEFIDiskDatastoreID = "datastore_id" 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", Optional: true, 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", 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: { Type: schema.TypeBool, 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{}) keyboardLayout := d.Get(mkKeyboardLayout).(string) memory := d.Get(mkMemory).([]interface{}) + numa := d.Get(mkNUMA).([]interface{}) operatingSystem := d.Get(mkOperatingSystem).([]interface{}) serialDevice := d.Get(mkSerialDevice).([]interface{}) 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) } + if len(numa) > 0 { + updateBody.NUMADevices = vmGetNumaDeviceObjects(d) + } + if len(hostUSB) > 0 { updateBody.USBDevices = vmGetHostUSBDeviceObjects(d) } @@ -2389,6 +2467,8 @@ func vmCreateCustom(ctx context.Context, d *schema.ResourceData, m interface{}) pciDeviceObjects := vmGetHostPCIDeviceObjects(d) + numaDeviceObjects := vmGetNumaDeviceObjects(d) + usbDeviceObjects := vmGetHostUSBDeviceObjects(d) keyboardLayout := d.Get(mkKeyboardLayout).(string) @@ -2559,6 +2639,7 @@ func vmCreateCustom(ctx context.Context, d *schema.ResourceData, m interface{}) KeyboardLayout: &keyboardLayout, NetworkDevices: networkDeviceObjects, NUMAEnabled: &cpuNUMA, + NUMADevices: numaDeviceObjects, OSType: &operatingSystemType, PCIDevices: pciDeviceObjects, SCSIHardware: &scsiHardware, @@ -3029,6 +3110,49 @@ func vmGetHostPCIDeviceObjects(d *schema.ResourceData) vms.CustomPCIDevices { 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 { usbDevice := d.Get(mkHostUSB).([]interface{}) usbDeviceObjects := make(vms.CustomUSBDevices, len(usbDevice)) @@ -3492,6 +3616,44 @@ func vmReadCustom( 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 { cpu[mkCPUSockets] = *vmConfig.CPUSockets } else { @@ -4971,6 +5133,17 @@ func vmUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.D 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. if d.HasChange(mkHostUSB) { updateBody.USBDevices = vmGetHostUSBDeviceObjects(d) @@ -5477,6 +5650,21 @@ func getDiskDatastores(vm *vms.GetResponseData, d *schema.ResourceData) []string 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 { pciDevices := map[string]*vms.CustomPCIDevice{} diff --git a/proxmoxtf/resource/vm/vm_test.go b/proxmoxtf/resource/vm/vm_test.go index ac401e89..43348acb 100644 --- a/proxmoxtf/resource/vm/vm_test.go +++ b/proxmoxtf/resource/vm/vm_test.go @@ -341,6 +341,21 @@ func TestVMSchema(t *testing.T) { 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( t, s, diff --git a/proxmoxtf/structure/schema.go b/proxmoxtf/structure/schema.go index c0d8b837..16f5b4e3 100644 --- a/proxmoxtf/structure/schema.go +++ b/proxmoxtf/structure/schema.go @@ -12,6 +12,7 @@ import ( "sort" "strings" + "github.com/bpg/terraform-provider-proxmox/utils" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -86,7 +87,7 @@ func GetSchemaBlock( // recommended to use it only for small lists. // Ref: https://github.com/hashicorp/terraform-plugin-sdk/issues/477 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, ".") if lastDotIndex != -1 { key = key[:lastDotIndex] @@ -120,3 +121,52 @@ func SuppressIfListsAreEqualIgnoringOrder(key, _, _ string, d *schema.ResourceDa 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 + } +} diff --git a/testacc b/testacc index 428bb128..ca8f0de5 100755 --- a/testacc +++ b/testacc @@ -1,3 +1,3 @@ #!/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 diff --git a/utils/maps.go b/utils/maps.go index 877f0dd9..22212823 100644 --- a/utils/maps.go +++ b/utils/maps.go @@ -22,3 +22,35 @@ func OrderedListFromMap(inputMap map[string]interface{}) []interface{} { 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 +} diff --git a/utils/maps_test.go b/utils/maps_test.go new file mode 100644 index 00000000..c402a28e --- /dev/null +++ b/utils/maps_test.go @@ -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) + } +}