From cec4e6586834feb876321520b93caf7ce4cb68d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20M=C3=BChlbachler-Pietrzykowski?= Date: Tue, 31 Oct 2023 02:41:44 +0100 Subject: [PATCH] feat(vm): add support for USB devices passthrough (#666) * feat: support usb devices for vm; fixes #665 Signed-off-by: Daniel Muehlbachler-Pietrzykowski * chore: fix linter errors Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com> --------- Signed-off-by: Daniel Muehlbachler-Pietrzykowski Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com> Co-authored-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com> --- docs/resources/virtual_environment_vm.md | 6 + example/resource_virtual_environment_vm.tf | 16 ++- proxmox/nodes/vms/vms_types.go | 50 ++++++- proxmox/nodes/vms/vms_types_test.go | 47 +++++++ proxmoxtf/resource/vm.go | 143 ++++++++++++++++++++- proxmoxtf/resource/vm_test.go | 13 ++ 6 files changed, 263 insertions(+), 12 deletions(-) diff --git a/docs/resources/virtual_environment_vm.md b/docs/resources/virtual_environment_vm.md index 33d56cf9..9fd785f9 100644 --- a/docs/resources/virtual_environment_vm.md +++ b/docs/resources/virtual_environment_vm.md @@ -301,6 +301,12 @@ output "ubuntu_vm_public_key" { is a relative path under `/usr/share/kvm/`. - `xvga` - (Optional) Marks the PCI(e) device as the primary GPU of the VM. With this enabled the `vga` configuration argument will be ignored. +- `usb` - (Optional) A host USB device mapping (multiple blocks supported). + - `host` - (Optional) The USB device ID. Use either this or `mapping`. + - `mapping` - (Optional) The resource mapping name of the device, for + example usbdevice. Use either this or `id`. + - `usb3` - (Optional) Makes the USB device a USB3 device for the VM (defaults + to `false`). - `initialization` - (Optional) The cloud-init configuration. - `datastore_id` - (Optional) The identifier for the datastore to create the cloud-init disk in (defaults to `local-lvm`). diff --git a/example/resource_virtual_environment_vm.tf b/example/resource_virtual_environment_vm.tf index 41e9054d..b5357219 100644 --- a/example/resource_virtual_environment_vm.tf +++ b/example/resource_virtual_environment_vm.tf @@ -165,9 +165,21 @@ resource "proxmox_virtual_environment_vm" "example" { # pcie = true #} + #usb { + # host = "0000:1234" + # mapping = "usbdevice1" + # usb3 = false + #} + + #usb { + # host = "0000:5678" + # mapping = "usbdevice2" + # usb3 = false + #} + # attached disks from data_vm dynamic "disk" { - for_each = {for idx, val in proxmox_virtual_environment_vm.data_vm.disk : idx => val} + for_each = { for idx, val in proxmox_virtual_environment_vm.data_vm.disk : idx => val } iterator = data_disk content { datastore_id = data_disk.value["datastore_id"] @@ -175,7 +187,7 @@ resource "proxmox_virtual_environment_vm" "example" { file_format = data_disk.value["file_format"] size = data_disk.value["size"] # assign from scsi1 and up - interface = "scsi${data_disk.key + 1}" + interface = "scsi${data_disk.key + 1}" } } } diff --git a/proxmox/nodes/vms/vms_types.go b/proxmox/nodes/vms/vms_types.go index f6a725c8..66e5be49 100644 --- a/proxmox/nodes/vms/vms_types.go +++ b/proxmox/nodes/vms/vms_types.go @@ -242,8 +242,9 @@ type CustomStorageDevices map[string]CustomStorageDevice // CustomUSBDevice handles QEMU USB device parameters. type CustomUSBDevice struct { - HostDevice string `json:"host" url:"host"` - USB3 *types.CustomBool `json:"usb3,omitempty" url:"usb3,omitempty,int"` + HostDevice *string `json:"host" url:"host"` + Mapping *string `json:"mapping,omitempty" url:"mapping,omitempty"` + USB3 *types.CustomBool `json:"usb3,omitempty" url:"usb3,omitempty,int"` } // CustomUSBDevices handles QEMU USB device parameters. @@ -517,7 +518,10 @@ type GetResponseData struct { Tags *string `json:"tags,omitempty"` Template *types.CustomBool `json:"template,omitempty"` TimeDriftFixEnabled *types.CustomBool `json:"tdf,omitempty"` - USBDevices *CustomUSBDevices `json:"usb,omitempty"` + USBDevice0 *CustomUSBDevice `json:"usb0,omitempty"` + USBDevice1 *CustomUSBDevice `json:"usb1,omitempty"` + USBDevice2 *CustomUSBDevice `json:"usb2,omitempty"` + USBDevice3 *CustomUSBDevice `json:"usb3,omitempty"` VGADevice *CustomVGADevice `json:"vga,omitempty"` VirtualCPUCount *int `json:"vcpus,omitempty"` VirtualIODevice0 *CustomStorageDevice `json:"virtio0,omitempty"` @@ -1232,8 +1236,16 @@ func (r CustomStorageDevices) EncodeValues(_ string, v *url.Values) error { // EncodeValues converts a CustomUSBDevice struct to a URL vlaue. func (r CustomUSBDevice) EncodeValues(key string, v *url.Values) error { + if r.HostDevice == nil && r.Mapping == nil { + return fmt.Errorf("either device ID or resource mapping must be set") + } + values := []string{ - fmt.Sprintf("host=%s", r.HostDevice), + fmt.Sprintf("host=%s", *(r.HostDevice)), + } + + if r.Mapping != nil { + values = append(values, fmt.Sprintf("mapping=%s", *r.Mapping)) } if r.USB3 != nil { @@ -1696,6 +1708,36 @@ func (r *CustomPCIDevice) UnmarshalJSON(b []byte) error { return nil } +// UnmarshalJSON converts a CustomUSBDevice string to an object. +func (r *CustomUSBDevice) UnmarshalJSON(b []byte) error { + var s string + + if err := json.Unmarshal(b, &s); err != nil { + return fmt.Errorf("failed to unmarshal CustomUSBDevice: %w", err) + } + + pairs := strings.Split(s, ",") + + for _, p := range pairs { + v := strings.Split(strings.TrimSpace(p), "=") + if len(v) == 1 { + r.HostDevice = &v[1] + } else if len(v) == 2 { + switch v[0] { + case "host": + r.HostDevice = &v[1] + case "mapping": + r.Mapping = &v[1] + case "usb3": + bv := types.CustomBool(v[1] == "1") + r.USB3 = &bv + } + } + } + + return nil +} + // UnmarshalJSON converts a CustomSharedMemory string to an object. func (r *CustomSharedMemory) 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 68b32e21..ee995d3e 100644 --- a/proxmox/nodes/vms/vms_types_test.go +++ b/proxmox/nodes/vms/vms_types_test.go @@ -124,3 +124,50 @@ func TestCustomPCIDevice_UnmarshalJSON(t *testing.T) { }) } } + +func TestCustomUSBDevice_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + line string + want *CustomUSBDevice + wantErr bool + }{ + { + name: "id only usb device", + line: `"host=0000:81"`, + want: &CustomUSBDevice{ + HostDevice: types.StrPtr("0000:81"), + }, + }, + { + name: "usb device with more details", + line: `"host=81:00,usb3=0"`, + want: &CustomUSBDevice{ + HostDevice: types.StrPtr("81:00"), + USB3: types.BoolPtr(false), + }, + }, + { + name: "usb device with mapping", + line: `"mapping=mappeddevice,usb=0"`, + want: &CustomUSBDevice{ + HostDevice: nil, + Mapping: types.StrPtr("mappeddevice"), + USB3: types.BoolPtr(false), + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &CustomUSBDevice{} + if err := r.UnmarshalJSON([]byte(tt.line)); (err != nil) != tt.wantErr { + t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/proxmoxtf/resource/vm.go b/proxmoxtf/resource/vm.go index 4caf6d9b..910fc276 100644 --- a/proxmoxtf/resource/vm.go +++ b/proxmoxtf/resource/vm.go @@ -140,6 +140,7 @@ const ( maxResourceVirtualEnvironmentVMNetworkDevices = 8 maxResourceVirtualEnvironmentVMSerialDevices = 4 maxResourceVirtualEnvironmentVMHostPCIDevices = 8 + maxResourceVirtualEnvironmentVMHostUSBDevices = 4 mkResourceVirtualEnvironmentVMRebootAfterCreation = "reboot" mkResourceVirtualEnvironmentVMOnBoot = "on_boot" @@ -280,6 +281,10 @@ const ( mkResourceVirtualEnvironmentVMTimeoutShutdownVM = "timeout_shutdown_vm" mkResourceVirtualEnvironmentVMTimeoutStartVM = "timeout_start_vm" mkResourceVirtualEnvironmentVMTimeoutStopVM = "timeout_stop_vm" + mkResourceVirtualEnvironmentVMHostUSB = "usb" + mkResourceVirtualEnvironmentVMHostUSBDevice = "host" + mkResourceVirtualEnvironmentVMHostUSBDeviceMapping = "mapping" + mkResourceVirtualEnvironmentVMHostUSBDeviceUSB3 = "usb3" mkResourceVirtualEnvironmentVMVGA = "vga" mkResourceVirtualEnvironmentVMVGAEnabled = "enabled" mkResourceVirtualEnvironmentVMVGAMemory = "memory" @@ -1061,6 +1066,34 @@ func VM() *schema.Resource { }, }, }, + mkResourceVirtualEnvironmentVMHostUSB: { + Type: schema.TypeList, + Description: "The Host USB devices mapped to the VM", + Optional: true, + ForceNew: false, + DefaultFunc: func() (interface{}, error) { + return []interface{}{}, nil + }, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + mkResourceVirtualEnvironmentVMHostUSBDevice: { + Type: schema.TypeString, + Description: "The USB device ID for Proxmox, in form of ':'", + Required: true, + }, + mkResourceVirtualEnvironmentVMHostUSBDeviceMapping: { + Type: schema.TypeString, + Description: "The resource mapping name of the device, for example usbdisk. Use either this or id.", + Optional: true, + }, + mkResourceVirtualEnvironmentVMHostUSBDeviceUSB3: { + Type: schema.TypeBool, + Description: "Makes the USB device a USB3 device for the machine. Default is false", + Optional: true, + }, + }, + }, + }, mkResourceVirtualEnvironmentVMKeyboardLayout: { Type: schema.TypeString, Description: "The keyboard layout", @@ -1831,6 +1864,7 @@ func vmCreateClone(ctx context.Context, d *schema.ResourceData, m interface{}) d cpu := d.Get(mkResourceVirtualEnvironmentVMCPU).([]interface{}) initialization := d.Get(mkResourceVirtualEnvironmentVMInitialization).([]interface{}) hostPCI := d.Get(mkResourceVirtualEnvironmentVMHostPCI).([]interface{}) + hostUSB := d.Get(mkResourceVirtualEnvironmentVMHostUSB).([]interface{}) keyboardLayout := d.Get(mkResourceVirtualEnvironmentVMKeyboardLayout).(string) memory := d.Get(mkResourceVirtualEnvironmentVMMemory).([]interface{}) networkDevice := d.Get(mkResourceVirtualEnvironmentVMNetworkDevice).([]interface{}) @@ -1997,6 +2031,10 @@ func vmCreateClone(ctx context.Context, d *schema.ResourceData, m interface{}) d updateBody.PCIDevices = vmGetHostPCIDeviceObjects(d) } + if len(hostUSB) > 0 { + updateBody.USBDevices = vmGetHostUSBDeviceObjects(d) + } + if len(cdrom) > 0 || len(initialization) > 0 { updateBody.IDEDevices = ideDevices } @@ -2393,6 +2431,8 @@ func vmCreateCustom(ctx context.Context, d *schema.ResourceData, m interface{}) pciDeviceObjects := vmGetHostPCIDeviceObjects(d) + usbDeviceObjects := vmGetHostUSBDeviceObjects(d) + keyboardLayout := d.Get(mkResourceVirtualEnvironmentVMKeyboardLayout).(string) memoryBlock, err := structure.GetSchemaBlock( resource, @@ -2562,6 +2602,7 @@ func vmCreateCustom(ctx context.Context, d *schema.ResourceData, m interface{}) StartupOrder: startupOrder, TabletDeviceEnabled: &tabletDevice, Template: &template, + USBDevices: usbDeviceObjects, VGADevice: vgaDevice, VMID: &vmID, } @@ -3241,6 +3282,31 @@ func vmGetHostPCIDeviceObjects(d *schema.ResourceData) vms.CustomPCIDevices { return pciDeviceObjects } +func vmGetHostUSBDeviceObjects(d *schema.ResourceData) vms.CustomUSBDevices { + usbDevice := d.Get(mkResourceVirtualEnvironmentVMHostUSB).([]interface{}) + usbDeviceObjects := make(vms.CustomUSBDevices, len(usbDevice)) + + for i, usbDeviceEntry := range usbDevice { + block := usbDeviceEntry.(map[string]interface{}) + + host, _ := block[mkResourceVirtualEnvironmentVMHostUSBDevice].(string) + usb3 := types.CustomBool(block[mkResourceVirtualEnvironmentVMHostUSBDeviceUSB3].(bool)) + mapping, _ := block[mkResourceVirtualEnvironmentVMHostUSBDeviceMapping].(string) + + device := vms.CustomUSBDevice{ + HostDevice: &host, + USB3: &usb3, + } + if mapping != "" { + device.Mapping = &mapping + } + + usbDeviceObjects[i] = device + } + + return usbDeviceObjects +} + func vmGetNetworkDeviceObjects(d *schema.ResourceData) vms.CustomNetworkDevices { networkDevice := d.Get(mkResourceVirtualEnvironmentVMNetworkDevice).([]interface{}) networkDeviceObjects := make(vms.CustomNetworkDevices, len(networkDevice)) @@ -3417,33 +3483,38 @@ func vmGetStartupOrder(d *schema.ResourceData) *vms.CustomStartupOrder { } func vmGetTagsString(d *schema.ResourceData) string { - tags := d.Get(mkResourceVirtualEnvironmentVMTags).([]interface{}) var sanitizedTags []string + + tags := d.Get(mkResourceVirtualEnvironmentVMTags).([]interface{}) for i := 0; i < len(tags); i++ { tag := strings.TrimSpace(tags[i].(string)) if len(tag) > 0 { sanitizedTags = append(sanitizedTags, tag) } } + sort.Strings(sanitizedTags) + return strings.Join(sanitizedTags, ";") } func vmGetSerialDeviceValidator() schema.SchemaValidateDiagFunc { - return validation.ToDiagFunc(func(i interface{}, k string) (s []string, es []error) { + return validation.ToDiagFunc(func(i interface{}, k string) ([]string, []error) { v, ok := i.(string) + var es []error + if !ok { es = append(es, fmt.Errorf("expected type of %s to be string", k)) - return + return nil, es } if !strings.HasPrefix(v, "/dev/") && v != "socket" { es = append(es, fmt.Errorf("expected %s to be '/dev/*' or 'socket'", k)) - return + return nil, es } - return + return nil, es }) } @@ -4068,12 +4139,50 @@ func vmReadCustom( } if len(currentPCIList) > 0 { - // todo: reordering of devices by PVE may cause an issue here orderedPCIList := orderedListFromMap(pciMap) err := d.Set(mkResourceVirtualEnvironmentVMHostPCI, orderedPCIList) diags = append(diags, diag.FromErr(err)...) } + currentUSBList := d.Get(mkResourceVirtualEnvironmentVMHostUSB).([]interface{}) + usbMap := map[string]interface{}{} + + usbDevices := getUSBInfo(vmConfig, d) + for pi, pp := range usbDevices { + if (pp == nil) || (pp.HostDevice == nil && pp.Mapping == nil) { + continue + } + + usb := map[string]interface{}{} + + if pp.HostDevice != nil { + usb[mkResourceVirtualEnvironmentVMHostUSBDevice] = *pp.HostDevice + } else { + usb[mkResourceVirtualEnvironmentVMHostUSBDevice] = "" + } + + if pp.USB3 != nil { + usb[mkResourceVirtualEnvironmentVMHostUSBDeviceUSB3] = *pp.USB3 + } else { + usb[mkResourceVirtualEnvironmentVMHostUSBDeviceUSB3] = false + } + + if pp.Mapping != nil { + usb[mkResourceVirtualEnvironmentVMHostUSBDeviceMapping] = *pp.Mapping + } else { + usb[mkResourceVirtualEnvironmentVMHostUSBDeviceMapping] = "" + } + + usbMap[pi] = usb + } + + if len(currentUSBList) > 0 { + // todo: reordering of devices by PVE may cause an issue here + orderedUSBList := orderedListFromMap(usbMap) + err := d.Set(mkResourceVirtualEnvironmentVMHostUSB, orderedUSBList) + diags = append(diags, diag.FromErr(err)...) + } + // Compare the initialization configuration to the one stored in the state. initialization := map[string]interface{}{} @@ -5410,6 +5519,17 @@ func vmUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.D rebootRequired = true } + // Prepare the new usb devices configuration. + if d.HasChange(mkResourceVirtualEnvironmentVMHostUSB) { + updateBody.USBDevices = vmGetHostUSBDeviceObjects(d) + + for i := len(updateBody.USBDevices); i < maxResourceVirtualEnvironmentVMHostUSBDevices; i++ { + del = append(del, fmt.Sprintf("usb%d", i)) + } + + rebootRequired = true + } + // Prepare the new memory configuration. if d.HasChange(mkResourceVirtualEnvironmentVMMemory) { memoryBlock, err := structure.GetSchemaBlock( @@ -5922,6 +6042,17 @@ func getPCIInfo(resp *vms.GetResponseData, _ *schema.ResourceData) map[string]*v return pciDevices } +func getUSBInfo(resp *vms.GetResponseData, _ *schema.ResourceData) map[string]*vms.CustomUSBDevice { + usbDevices := map[string]*vms.CustomUSBDevice{} + + usbDevices["usb0"] = resp.USBDevice0 + usbDevices["usb1"] = resp.USBDevice1 + usbDevices["usb2"] = resp.USBDevice2 + usbDevices["usb3"] = resp.USBDevice3 + + return usbDevices +} + func parseImportIDWithNodeName(id string) (string, string, error) { nodeName, id, found := strings.Cut(id, "/") diff --git a/proxmoxtf/resource/vm_test.go b/proxmoxtf/resource/vm_test.go index a15e13b9..1fc7b4b5 100644 --- a/proxmoxtf/resource/vm_test.go +++ b/proxmoxtf/resource/vm_test.go @@ -49,6 +49,7 @@ func TestVMSchema(t *testing.T) { mkResourceVirtualEnvironmentVMEFIDisk, mkResourceVirtualEnvironmentVMInitialization, mkResourceVirtualEnvironmentVMHostPCI, + mkResourceVirtualEnvironmentVMHostUSB, mkResourceVirtualEnvironmentVMKeyboardLayout, mkResourceVirtualEnvironmentVMKVMArguments, mkResourceVirtualEnvironmentVMMachine, @@ -84,6 +85,7 @@ func TestVMSchema(t *testing.T) { mkResourceVirtualEnvironmentVMDisk: schema.TypeList, mkResourceVirtualEnvironmentVMEFIDisk: schema.TypeList, mkResourceVirtualEnvironmentVMHostPCI: schema.TypeList, + mkResourceVirtualEnvironmentVMHostUSB: schema.TypeList, mkResourceVirtualEnvironmentVMInitialization: schema.TypeList, mkResourceVirtualEnvironmentVMIPv4Addresses: schema.TypeList, mkResourceVirtualEnvironmentVMIPv6Addresses: schema.TypeList, @@ -278,6 +280,17 @@ func TestVMSchema(t *testing.T) { mkResourceVirtualEnvironmentVMHostPCIDeviceXVGA: schema.TypeBool, }) + hostUSBSchema := test.AssertNestedSchemaExistence(t, s, mkResourceVirtualEnvironmentVMHostUSB) + + test.AssertOptionalArguments(t, hostUSBSchema, []string{ + mkResourceVirtualEnvironmentVMHostUSBDeviceMapping, + }) + + test.AssertValueTypes(t, hostUSBSchema, map[string]schema.ValueType{ + mkResourceVirtualEnvironmentVMHostUSBDevice: schema.TypeString, + mkResourceVirtualEnvironmentVMHostUSBDeviceUSB3: schema.TypeBool, + }) + initializationDNSSchema := test.AssertNestedSchemaExistence( t, initializationSchema,