From ccdc3533f69cef824ba62757369608044338cfe3 Mon Sep 17 00:00:00 2001 From: Drew Hudson-Viles Date: Thu, 16 Apr 2026 11:58:44 +0100 Subject: [PATCH 1/2] feat: adding support for multiqueue Right now, users cannot add queues to the network NIC. With this PR, users will now be able to optionally supply a value into the CAPI provider to configure queues. --- api/v1alpha2/proxmoxmachine_types.go | 7 ++++ api/v1alpha2/proxmoxmachine_types_test.go | 14 +++++++ api/v1alpha2/zz_generated.deepcopy.go | 5 +++ ...ture.cluster.x-k8s.io_proxmoxmachines.yaml | 8 ++++ ...ster.x-k8s.io_proxmoxmachinetemplates.yaml | 8 ++++ internal/service/vmservice/utils.go | 28 ++++++++++++- internal/service/vmservice/vm.go | 2 +- internal/service/vmservice/vm_test.go | 39 +++++++++++++++++-- 8 files changed, 104 insertions(+), 7 deletions(-) diff --git a/api/v1alpha2/proxmoxmachine_types.go b/api/v1alpha2/proxmoxmachine_types.go index 4a2745ea6..4af2def96 100644 --- a/api/v1alpha2/proxmoxmachine_types.go +++ b/api/v1alpha2/proxmoxmachine_types.go @@ -455,6 +455,13 @@ type NetworkDevice struct { // +kubebuilder:validation:Maximum=4094 VLAN *int32 `json:"vlan,omitempty"` + // Queues is the number of queues assigned to the device. + // This value is passed to the Multiqueue field in PROXMOX. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + Queues *int32 `json:"queues,omitempty"` + // name is the network device name. // +default="net0" // +optional diff --git a/api/v1alpha2/proxmoxmachine_types_test.go b/api/v1alpha2/proxmoxmachine_types_test.go index 597d33884..a59d2952f 100644 --- a/api/v1alpha2/proxmoxmachine_types_test.go +++ b/api/v1alpha2/proxmoxmachine_types_test.go @@ -367,6 +367,20 @@ var _ = Describe("ProxmoxMachine Test", func() { }) }) + Context("Queues", func() { + It("Should not allow machine with network device queue equal to 0", func() { + dm := defaultMachine() + dm.Spec.Network = &NetworkSpec{ + NetworkDevices: []NetworkDevice{{ + Bridge: ptr.To("vmbr0"), + Queues: ptr.To(int32(0)), + }}, + } + + Expect(k8sClient.Create(context.Background(), dm)).Should(MatchError(ContainSubstring("should be greater than or equal to 1"))) + }) + }) + Context("VMIDRange", func() { It("Should only allow spec.vmIDRange.start >= 100", func() { dm := defaultMachine() diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index 8606a5e41..284aed501 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -207,6 +207,11 @@ func (in *NetworkDevice) DeepCopyInto(out *NetworkDevice) { *out = new(int32) **out = **in } + if in.Queues != nil { + in, out := &in.Queues, &out.Queues + *out = new(int32) + **out = **in + } in.InterfaceConfig.DeepCopyInto(&out.InterfaceConfig) } diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_proxmoxmachines.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_proxmoxmachines.yaml index 6b391edc9..9cbe47fae 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_proxmoxmachines.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_proxmoxmachines.yaml @@ -1114,6 +1114,14 @@ spec: minLength: 4 pattern: ^net[0-9]+$ type: string + queues: + description: |- + Queues is the number of queues assigned to the device. + This value is passed to the Multiqueue field in PROXMOX. + format: int32 + maximum: 65535 + minimum: 1 + type: integer routes: description: routes are the routes associated with this interface. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_proxmoxmachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_proxmoxmachinetemplates.yaml index 220fca029..080f5dd1e 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_proxmoxmachinetemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_proxmoxmachinetemplates.yaml @@ -980,6 +980,14 @@ spec: minLength: 4 pattern: ^net[0-9]+$ type: string + queues: + description: |- + Queues is the number of queues assigned to the device. + This value is passed to the Multiqueue field in PROXMOX. + format: int32 + maximum: 65535 + minimum: 1 + type: integer routes: description: routes are the routes associated with this interface. diff --git a/internal/service/vmservice/utils.go b/internal/service/vmservice/utils.go index d2b09ec0c..d1872d5f3 100644 --- a/internal/service/vmservice/utils.go +++ b/internal/service/vmservice/utils.go @@ -137,6 +137,21 @@ func extractNetworkVLAN(input string) int32 { return 0 } +// extractNetworkQueue returns the queue out of net device input e.g. virtio=A6:23:64:4D:84:CB,bridge=vmbr1,mtu=1500,tag=100,queues=4. +func extractNetworkQueue(input string) int32 { + re := regexp.MustCompile(`queues=(\d+)`) + match := re.FindStringSubmatch(input) + if len(match) > 1 { + queue, err := strconv.ParseUint(match[1], 10, 16) + if err != nil { + return 0 + } + return int32(queue) + } + + return 0 +} + func shouldUpdateNetworkDevices(machineScope *scope.MachineScope) bool { if machineScope.ProxmoxMachine.Spec.Network == nil { // no network config needed @@ -180,14 +195,19 @@ func shouldUpdateNetworkDevices(machineScope *scope.MachineScope) bool { return true } } + queues := extractNetworkQueue(net) + if queues != ptr.Deref(v.Queues, 0) { + return true + } + } return false } // formatNetworkDevice formats a network device config -// example 'virtio,bridge=vmbr0,tag=100'. -func formatNetworkDevice(model, bridge string, mtu *int32, vlan *int32) string { +// example 'virtio,bridge=vmbr0,tag=100,queues=4'. +func formatNetworkDevice(model, bridge string, mtu *int32, vlan *int32, queues int32) string { var components = []string{model, fmt.Sprintf("bridge=%s", bridge)} if mtu != nil { @@ -198,6 +218,10 @@ func formatNetworkDevice(model, bridge string, mtu *int32, vlan *int32) string { components = append(components, fmt.Sprintf("tag=%d", *vlan)) } + if queues != 0 { + components = append(components, fmt.Sprintf("queues=%d", queues)) + } + return strings.Join(components, ",") } diff --git a/internal/service/vmservice/vm.go b/internal/service/vmservice/vm.go index f9fd3a920..63ebab0ee 100644 --- a/internal/service/vmservice/vm.go +++ b/internal/service/vmservice/vm.go @@ -327,7 +327,7 @@ func reconcileVirtualMachineConfig(ctx context.Context, machineScope *scope.Mach for _, v := range devices { vmOptions = append(vmOptions, proxmox.VirtualMachineOption{ Name: string(v.Name), - Value: formatNetworkDevice(ptr.Deref(v.Model, "virtio"), ptr.Deref(v.Bridge, ""), v.MTU, v.VLAN), + Value: formatNetworkDevice(ptr.Deref(v.Model, "virtio"), ptr.Deref(v.Bridge, ""), v.MTU, v.VLAN, ptr.Deref(v.Queues, 0)), }) } } diff --git a/internal/service/vmservice/vm_test.go b/internal/service/vmservice/vm_test.go index f7cfba286..85a2d37ec 100644 --- a/internal/service/vmservice/vm_test.go +++ b/internal/service/vmservice/vm_test.go @@ -449,8 +449,8 @@ func TestReconcileVirtualMachineConfig_ApplyConfig(t *testing.T) { proxmox.VirtualMachineOption{Name: optionCores, Value: *machineScope.ProxmoxMachine.Spec.NumCores}, proxmox.VirtualMachineOption{Name: optionMemory, Value: *machineScope.ProxmoxMachine.Spec.MemoryMiB}, proxmox.VirtualMachineOption{Name: optionDescription, Value: machineScope.ProxmoxMachine.Spec.Description}, - proxmox.VirtualMachineOption{Name: "net0", Value: formatNetworkDevice("virtio", "vmbr0", ptr.To[int32](1500), nil)}, - proxmox.VirtualMachineOption{Name: "net1", Value: formatNetworkDevice("virtio", "vmbr1", ptr.To[int32](1500), nil)}, + proxmox.VirtualMachineOption{Name: "net0", Value: formatNetworkDevice("virtio", "vmbr0", ptr.To[int32](1500), nil, 0)}, + proxmox.VirtualMachineOption{Name: "net1", Value: formatNetworkDevice("virtio", "vmbr1", ptr.To[int32](1500), nil, 0)}, } proxmoxClient.EXPECT().ConfigureVM(context.Background(), vm, expectedOptions...).Return(task, nil).Once() @@ -624,8 +624,39 @@ func TestReconcileVirtualMachineConfigVLAN(t *testing.T) { proxmox.VirtualMachineOption{Name: optionSockets, Value: *machineScope.ProxmoxMachine.Spec.NumSockets}, proxmox.VirtualMachineOption{Name: optionCores, Value: *machineScope.ProxmoxMachine.Spec.NumCores}, proxmox.VirtualMachineOption{Name: optionMemory, Value: *machineScope.ProxmoxMachine.Spec.MemoryMiB}, - proxmox.VirtualMachineOption{Name: "net0", Value: formatNetworkDevice("virtio", "vmbr0", nil, ptr.To(int32(100)))}, - proxmox.VirtualMachineOption{Name: "net1", Value: formatNetworkDevice("virtio", "vmbr1", nil, ptr.To(int32(100)))}, + proxmox.VirtualMachineOption{Name: "net0", Value: formatNetworkDevice("virtio", "vmbr0", nil, ptr.To(int32(100)), 0)}, + proxmox.VirtualMachineOption{Name: "net1", Value: formatNetworkDevice("virtio", "vmbr1", nil, ptr.To(int32(100)), 0)}, + } + + proxmoxClient.EXPECT().ConfigureVM(context.TODO(), vm, expectedOptions...).Return(task, nil).Once() + + requeue, err := reconcileVirtualMachineConfig(context.TODO(), machineScope) + require.NoError(t, err) + require.True(t, requeue) + require.EqualValues(t, task.UPID, *machineScope.ProxmoxMachine.Status.TaskRef) +} + +func TestReconcileVirtualMachineConfigQueue(t *testing.T) { + machineScope, proxmoxClient, _ := setupReconcilerTestWithCondition(t, infrav1.ProxmoxMachineVirtualMachineProvisionedCloningReason) + machineScope.ProxmoxMachine.Spec.NumSockets = ptr.To(int32(4)) + machineScope.ProxmoxMachine.Spec.NumCores = ptr.To(int32(4)) + machineScope.ProxmoxMachine.Spec.MemoryMiB = ptr.To(int32(16 * 1024)) + machineScope.ProxmoxMachine.Spec.Network = &infrav1.NetworkSpec{ + NetworkDevices: []infrav1.NetworkDevice{ + {Name: infrav1.NetName("net0"), Bridge: ptr.To("vmbr0"), Model: ptr.To("virtio"), VLAN: ptr.To(int32(100)), Queues: ptr.To(int32(4))}, + {Name: infrav1.NetName("net1"), Bridge: ptr.To("vmbr1"), Model: ptr.To("virtio"), VLAN: ptr.To(int32(100)), Queues: ptr.To(int32(4))}, + }, + } + + vm := newStoppedVM() + task := newTask() + machineScope.SetVirtualMachine(vm) + expectedOptions := []interface{}{ + proxmox.VirtualMachineOption{Name: optionSockets, Value: machineScope.ProxmoxMachine.Spec.NumSockets}, + proxmox.VirtualMachineOption{Name: optionCores, Value: machineScope.ProxmoxMachine.Spec.NumCores}, + proxmox.VirtualMachineOption{Name: optionMemory, Value: machineScope.ProxmoxMachine.Spec.MemoryMiB}, + proxmox.VirtualMachineOption{Name: "net0", Value: formatNetworkDevice("virtio", "vmbr0", nil, nil, 4)}, + proxmox.VirtualMachineOption{Name: "net1", Value: formatNetworkDevice("virtio", "vmbr1", nil, nil, 4)}, } proxmoxClient.EXPECT().ConfigureVM(context.TODO(), vm, expectedOptions...).Return(task, nil).Once() From 609d7e0d3cead78250ca1d0285d5b1f1fec26593 Mon Sep 17 00:00:00 2001 From: Drew Hudson-Viles Date: Fri, 17 Apr 2026 10:01:48 +0100 Subject: [PATCH 2/2] chore: updating to support v1alpha2 --- api/v1alpha1/proxmoxmachine_conversion.go | 1 + api/v1alpha1/zz_generated.conversion.go | 1 + internal/service/vmservice/vm_test.go | 10 +++++----- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/api/v1alpha1/proxmoxmachine_conversion.go b/api/v1alpha1/proxmoxmachine_conversion.go index e92042415..381568593 100644 --- a/api/v1alpha1/proxmoxmachine_conversion.go +++ b/api/v1alpha1/proxmoxmachine_conversion.go @@ -102,6 +102,7 @@ func restoreProxmoxMachineSpec(src *ProxmoxMachineSpec, dst *v1alpha2.ProxmoxMac if i < len(restored.Network.NetworkDevices) { dst.Network.NetworkDevices[i].DefaultIPv4 = restored.Network.NetworkDevices[i].DefaultIPv4 dst.Network.NetworkDevices[i].DefaultIPv6 = restored.Network.NetworkDevices[i].DefaultIPv6 + dst.Network.NetworkDevices[i].Queues = restored.Network.NetworkDevices[i].Queues } } } diff --git a/api/v1alpha1/zz_generated.conversion.go b/api/v1alpha1/zz_generated.conversion.go index f33158012..722e5c289 100644 --- a/api/v1alpha1/zz_generated.conversion.go +++ b/api/v1alpha1/zz_generated.conversion.go @@ -611,6 +611,7 @@ func autoConvert_v1alpha2_NetworkDevice_To_v1alpha1_NetworkDevice(in *v1alpha2.N } else { out.VLAN = nil } + // WARNING: in.Queues requires manual conversion: does not exist in peer-type // WARNING: in.Name requires manual conversion: does not exist in peer-type // WARNING: in.InterfaceConfig requires manual conversion: does not exist in peer-type return nil diff --git a/internal/service/vmservice/vm_test.go b/internal/service/vmservice/vm_test.go index 85a2d37ec..dbd3211d9 100644 --- a/internal/service/vmservice/vm_test.go +++ b/internal/service/vmservice/vm_test.go @@ -652,11 +652,11 @@ func TestReconcileVirtualMachineConfigQueue(t *testing.T) { task := newTask() machineScope.SetVirtualMachine(vm) expectedOptions := []interface{}{ - proxmox.VirtualMachineOption{Name: optionSockets, Value: machineScope.ProxmoxMachine.Spec.NumSockets}, - proxmox.VirtualMachineOption{Name: optionCores, Value: machineScope.ProxmoxMachine.Spec.NumCores}, - proxmox.VirtualMachineOption{Name: optionMemory, Value: machineScope.ProxmoxMachine.Spec.MemoryMiB}, - proxmox.VirtualMachineOption{Name: "net0", Value: formatNetworkDevice("virtio", "vmbr0", nil, nil, 4)}, - proxmox.VirtualMachineOption{Name: "net1", Value: formatNetworkDevice("virtio", "vmbr1", nil, nil, 4)}, + proxmox.VirtualMachineOption{Name: optionSockets, Value: *machineScope.ProxmoxMachine.Spec.NumSockets}, + proxmox.VirtualMachineOption{Name: optionCores, Value: *machineScope.ProxmoxMachine.Spec.NumCores}, + proxmox.VirtualMachineOption{Name: optionMemory, Value: *machineScope.ProxmoxMachine.Spec.MemoryMiB}, + proxmox.VirtualMachineOption{Name: "net0", Value: formatNetworkDevice("virtio", "vmbr0", nil, ptr.To(int32(100)), 4)}, + proxmox.VirtualMachineOption{Name: "net1", Value: formatNetworkDevice("virtio", "vmbr1", nil, ptr.To(int32(100)), 4)}, } proxmoxClient.EXPECT().ConfigureVM(context.TODO(), vm, expectedOptions...).Return(task, nil).Once()