diff --git a/docs/resources/virtual_environment_vm.md b/docs/resources/virtual_environment_vm.md index 26f76ba9..44b0fb4c 100644 --- a/docs/resources/virtual_environment_vm.md +++ b/docs/resources/virtual_environment_vm.md @@ -240,6 +240,10 @@ output "ubuntu_vm_public_key" { - `unsafe` - Write directly to the disk bypassing the host cache. - `datastore_id` - (Optional) The identifier for the datastore to create the disk in (defaults to `local-lvm`). + - `path_in_datastore` - (Optional) The in-datastore path to the disk image. + ***Experimental.***Use to attach another VM's disks, + or (as root only) host's filesystem paths (`datastore_id` empty string). + See "*Example: Attached disks*". - `discard` - (Optional) Whether to pass discard/trim requests to the underlying storage. Supported values are `on`/`ignore` (defaults to `ignore`). @@ -514,6 +518,74 @@ target node. If you need certain disks to be on specific datastores, set the `datastore_id` argument of the disks in the `disks` block to move the disks to the correct datastore after the cloning and migrating succeeded. +## Example: Attached disks + +In this example VM `data_vm` holds two data disks, and is not used as an actual VM, +but only as a container for the disks. +It does not have any OS installation, it is never started. + +VM `data_user_vm` attaches those disks as `scsi1` and `scsi2`. +**VM `data_user_vm` can be *re-created/replaced* without losing data stored on disks +owned by `data_vm`.** + +This functionality is **experimental**. + +Do *not* simultaneously run more than one VM using same disk. For most filesystems, +attaching one disk to multiple VM will cause errors or even data corruption. + +Do *not* move or resize `data_vm` disks. +(Resource `data_user_vm` should reject attempts to move or resize non-owned disks.) + + +```terraform +resource "proxmox_virtual_environment_vm" "data_vm" { + node_name = "first-node" + started = false + on_boot = false + + disk { + datastore_id = "local-zfs" + file_format = "raw" + interface = "scsi0" + size = 1 + } + + disk { + datastore_id = "local-zfs" + file_format = "raw" + interface = "scsi1" + size = 4 + } +} + +resource "proxmox_virtual_environment_vm" "data_user_vm" { + # boot disk + disk { + datastore_id = "local-zfs" + file_format = "raw" + interface = "scsi0" + size = 8 + } + + # attached disks from data_vm + dynamic "disk" { + 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"] + path_in_datastore = data_disk.value["path_in_datastore"] + file_format = data_disk.value["file_format"] + size = data_disk.value["size"] + # assign from scsi1 and up + interface = "scsi${data_disk.key + 1}" + } + } + + # remainder of VM configuration + ... +} +```` + ## Import Instances can be imported using the `node_name` and the `vm_id`, e.g., diff --git a/example/resource_virtual_environment_vm.tf b/example/resource_virtual_environment_vm.tf index 1d1d50ee..41e9054d 100644 --- a/example/resource_virtual_environment_vm.tf +++ b/example/resource_virtual_environment_vm.tf @@ -164,6 +164,40 @@ resource "proxmox_virtual_environment_vm" "example" { # mapping = "gpu" # pcie = true #} + + # attached disks from data_vm + dynamic "disk" { + 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"] + path_in_datastore = data_disk.value["path_in_datastore"] + file_format = data_disk.value["file_format"] + size = data_disk.value["size"] + # assign from scsi1 and up + interface = "scsi${data_disk.key + 1}" + } + } +} + +resource "proxmox_virtual_environment_vm" "data_vm" { + name = "terraform-provider-proxmox-data-vm" + node_name = data.proxmox_virtual_environment_nodes.example.names[0] + started = false + on_boot = false + + disk { + datastore_id = local.datastore_id + file_format = "raw" + interface = "scsi0" + size = 1 + } + disk { + datastore_id = local.datastore_id + file_format = "raw" + interface = "scsi1" + size = 4 + } } output "resource_proxmox_virtual_environment_vm_example_id" { diff --git a/proxmox/nodes/vms/vms_types.go b/proxmox/nodes/vms/vms_types.go index 69cdce43..96e65abd 100644 --- a/proxmox/nodes/vms/vms_types.go +++ b/proxmox/nodes/vms/vms_types.go @@ -187,6 +187,46 @@ type CustomStorageDevice struct { SizeInt *int } +// PathInDatastore returns path part of FileVolume or nil if it is not yet allocated. +func (r CustomStorageDevice) PathInDatastore() *string { + probablyDatastoreID, pathInDatastore, hasDatastoreID := strings.Cut(r.FileVolume, ":") + if !hasDatastoreID { + // when no ':' separator is found, 'Cut' places the whole string to 'probablyDatastoreID', + // we want it in 'pathInDatastore' (as it is absolute filesystem path) + pathInDatastore = probablyDatastoreID + + return &pathInDatastore + } + + pathInDatastoreWithoutDigits := strings.Map( + func(c rune) rune { + if c < '0' || c > '9' { + return -1 + } + return c + }, + pathInDatastore) + + if pathInDatastoreWithoutDigits == "" { + // FileVolume is not yet allocated, it is in the "STORAGE_ID:SIZE_IN_GiB" format + return nil + } + + return &pathInDatastore +} + +// IsOwnedBy returns true, if CustomStorageDevice is owned by given VM. Not yet allocated volumes are not owned by any VM. +func (r CustomStorageDevice) IsOwnedBy(vmID int) bool { + pathInDatastore := r.PathInDatastore() + if pathInDatastore == nil { + // not yet allocated volume, consider disk not owned by any VM + // NOTE: if needed, create IsOwnedByOtherThan(vmId) instead of changing this return value. + return false + } + + return strings.HasPrefix(*pathInDatastore, fmt.Sprintf("vm-%d-", vmID)) +} + // CustomStorageDevices handles QEMU SATA device parameters. type CustomStorageDevices map[string]CustomStorageDevice diff --git a/proxmoxtf/resource/vm.go b/proxmoxtf/resource/vm.go index 94476f38..802a76ba 100644 --- a/proxmoxtf/resource/vm.go +++ b/proxmoxtf/resource/vm.go @@ -177,6 +177,7 @@ const ( mkResourceVirtualEnvironmentVMDisk = "disk" mkResourceVirtualEnvironmentVMDiskInterface = "interface" mkResourceVirtualEnvironmentVMDiskDatastoreID = "datastore_id" + mkResourceVirtualEnvironmentVMDiskPathInDatastore = "path_in_datastore" mkResourceVirtualEnvironmentVMDiskFileFormat = "file_format" mkResourceVirtualEnvironmentVMDiskFileID = "file_id" mkResourceVirtualEnvironmentVMDiskSize = "size" @@ -599,14 +600,15 @@ func VM() *schema.Resource { DefaultFunc: func() (interface{}, error) { return []interface{}{ map[string]interface{}{ - mkResourceVirtualEnvironmentVMDiskDatastoreID: dvResourceVirtualEnvironmentVMDiskDatastoreID, - mkResourceVirtualEnvironmentVMDiskFileID: dvResourceVirtualEnvironmentVMDiskFileID, - mkResourceVirtualEnvironmentVMDiskInterface: dvResourceVirtualEnvironmentVMDiskInterface, - mkResourceVirtualEnvironmentVMDiskSize: dvResourceVirtualEnvironmentVMDiskSize, - mkResourceVirtualEnvironmentVMDiskIOThread: dvResourceVirtualEnvironmentVMDiskIOThread, - mkResourceVirtualEnvironmentVMDiskSSD: dvResourceVirtualEnvironmentVMDiskSSD, - mkResourceVirtualEnvironmentVMDiskDiscard: dvResourceVirtualEnvironmentVMDiskDiscard, - mkResourceVirtualEnvironmentVMDiskCache: dvResourceVirtualEnvironmentVMDiskCache, + mkResourceVirtualEnvironmentVMDiskDatastoreID: dvResourceVirtualEnvironmentVMDiskDatastoreID, + mkResourceVirtualEnvironmentVMDiskPathInDatastore: nil, + mkResourceVirtualEnvironmentVMDiskFileID: dvResourceVirtualEnvironmentVMDiskFileID, + mkResourceVirtualEnvironmentVMDiskInterface: dvResourceVirtualEnvironmentVMDiskInterface, + mkResourceVirtualEnvironmentVMDiskSize: dvResourceVirtualEnvironmentVMDiskSize, + mkResourceVirtualEnvironmentVMDiskIOThread: dvResourceVirtualEnvironmentVMDiskIOThread, + mkResourceVirtualEnvironmentVMDiskSSD: dvResourceVirtualEnvironmentVMDiskSSD, + mkResourceVirtualEnvironmentVMDiskDiscard: dvResourceVirtualEnvironmentVMDiskDiscard, + mkResourceVirtualEnvironmentVMDiskCache: dvResourceVirtualEnvironmentVMDiskCache, }, }, nil }, @@ -623,6 +625,13 @@ func VM() *schema.Resource { Optional: true, Default: dvResourceVirtualEnvironmentVMDiskDatastoreID, }, + mkResourceVirtualEnvironmentVMDiskPathInDatastore: { + Type: schema.TypeString, + Description: "The in-datastore path to disk image", + Computed: true, + Optional: true, + Default: nil, + }, mkResourceVirtualEnvironmentVMDiskFileFormat: { Type: schema.TypeString, Description: "The file format", @@ -3013,6 +3022,12 @@ func vmGetDiskDeviceObjects( block := diskEntry.(map[string]interface{}) datastoreID, _ := block[mkResourceVirtualEnvironmentVMDiskDatastoreID].(string) + pathInDatastore := "" + + if untyped, hasPathInDatastore := block[mkResourceVirtualEnvironmentVMDiskPathInDatastore]; hasPathInDatastore { + pathInDatastore = untyped.(string) + } + fileFormat, _ := block[mkResourceVirtualEnvironmentVMDiskFileFormat].(string) fileID, _ := block[mkResourceVirtualEnvironmentVMDiskFileID].(string) size, _ := block[mkResourceVirtualEnvironmentVMDiskSize].(int) @@ -3039,7 +3054,16 @@ func vmGetDiskDeviceObjects( if fileID != "" { diskDevice.Enabled = false } else { - diskDevice.FileVolume = fmt.Sprintf("%s:%d", datastoreID, size) + if pathInDatastore != "" { + if datastoreID != "" { + diskDevice.FileVolume = fmt.Sprintf("%s:%s", datastoreID, pathInDatastore) + } else { + // FileVolume is absolute path in the host filesystem + diskDevice.FileVolume = pathInDatastore + } + } else { + diskDevice.FileVolume = fmt.Sprintf("%s:%d", datastoreID, size) + } } diskDevice.ID = &datastoreID @@ -3813,24 +3837,34 @@ func vmReadCustom( disk := map[string]interface{}{} - fileIDParts := strings.Split(dd.FileVolume, ":") + datastoreID, pathInDatastore, hasDatastoreID := strings.Cut(dd.FileVolume, ":") + if !hasDatastoreID { + // when no ':' separator is found, 'Cut' places the whole string to 'datastoreID', + // we want it in 'pathInDatastore' (it is absolute filesystem path) + pathInDatastore = datastoreID + datastoreID = "" + } - disk[mkResourceVirtualEnvironmentVMDiskDatastoreID] = fileIDParts[0] + disk[mkResourceVirtualEnvironmentVMDiskDatastoreID] = datastoreID + disk[mkResourceVirtualEnvironmentVMDiskPathInDatastore] = pathInDatastore if dd.Format == nil { disk[mkResourceVirtualEnvironmentVMDiskFileFormat] = dvResourceVirtualEnvironmentVMDiskFileFormat - // disk format may not be returned by config API if it is default for the storage, and that may be different - // from the default qcow2, so we need to read it from the storage API to make sure we have the correct value - files, err := api.Node(nodeName).ListDatastoreFiles(ctx, fileIDParts[0]) - if err != nil { - diags = append(diags, diag.FromErr(err)...) - continue - } - for _, v := range files { - if v.VolumeID == dd.FileVolume { - disk[mkResourceVirtualEnvironmentVMDiskFileFormat] = v.FileFormat - break + if datastoreID != "" { + // disk format may not be returned by config API if it is default for the storage, and that may be different + // from the default qcow2, so we need to read it from the storage API to make sure we have the correct value + files, err := api.Node(nodeName).ListDatastoreFiles(ctx, datastoreID) + if err != nil { + diags = append(diags, diag.FromErr(err)...) + continue + } + + for _, v := range files { + if v.VolumeID == dd.FileVolume { + disk[mkResourceVirtualEnvironmentVMDiskFileFormat] = v.FileFormat + break + } } } } else { @@ -5602,29 +5636,48 @@ func vmUpdateDiskLocationAndSize( } if *oldDisk.ID != *diskNewEntries[prefix][oldKey].ID { - deleteOriginalDisk := types.CustomBool(true) + if oldDisk.IsOwnedBy(vmID) { + deleteOriginalDisk := types.CustomBool(true) - diskMoveBodies = append( - diskMoveBodies, - &vms.MoveDiskRequestBody{ - DeleteOriginalDisk: &deleteOriginalDisk, - Disk: *oldDisk.Interface, - TargetStorage: *diskNewEntries[prefix][oldKey].ID, - }, - ) + diskMoveBodies = append( + diskMoveBodies, + &vms.MoveDiskRequestBody{ + DeleteOriginalDisk: &deleteOriginalDisk, + Disk: *oldDisk.Interface, + TargetStorage: *diskNewEntries[prefix][oldKey].ID, + }, + ) - // Cannot be done while VM is running. - shutdownForDisksRequired = true + // Cannot be done while VM is running. + shutdownForDisksRequired = true + } else { + return diag.Errorf( + "Cannot move %s:%s to datastore %s in VM %d configuration, it is not owned by this VM!", + *oldDisk.ID, + *oldDisk.PathInDatastore(), + *diskNewEntries[prefix][oldKey].ID, + vmID, + ) + } } if *oldDisk.SizeInt < *diskNewEntries[prefix][oldKey].SizeInt { - diskResizeBodies = append( - diskResizeBodies, - &vms.ResizeDiskRequestBody{ - Disk: *oldDisk.Interface, - Size: *diskNewEntries[prefix][oldKey].Size, - }, - ) + if oldDisk.IsOwnedBy(vmID) { + diskResizeBodies = append( + diskResizeBodies, + &vms.ResizeDiskRequestBody{ + Disk: *oldDisk.Interface, + Size: *diskNewEntries[prefix][oldKey].Size, + }, + ) + } else { + return diag.Errorf( + "Cannot resize %s:%s in VM %d configuration, it is not owned by this VM!", + *oldDisk.ID, + *oldDisk.PathInDatastore(), + vmID, + ) + } } } } diff --git a/proxmoxtf/resource/vm_test.go b/proxmoxtf/resource/vm_test.go index 26b18dcb..a15e13b9 100644 --- a/proxmoxtf/resource/vm_test.go +++ b/proxmoxtf/resource/vm_test.go @@ -189,16 +189,18 @@ func TestVMSchema(t *testing.T) { test.AssertOptionalArguments(t, diskSchema, []string{ mkResourceVirtualEnvironmentVMDiskDatastoreID, + mkResourceVirtualEnvironmentVMDiskPathInDatastore, mkResourceVirtualEnvironmentVMDiskFileFormat, mkResourceVirtualEnvironmentVMDiskFileID, mkResourceVirtualEnvironmentVMDiskSize, }) test.AssertValueTypes(t, diskSchema, map[string]schema.ValueType{ - mkResourceVirtualEnvironmentVMDiskDatastoreID: schema.TypeString, - mkResourceVirtualEnvironmentVMDiskFileFormat: schema.TypeString, - mkResourceVirtualEnvironmentVMDiskFileID: schema.TypeString, - mkResourceVirtualEnvironmentVMDiskSize: schema.TypeInt, + mkResourceVirtualEnvironmentVMDiskDatastoreID: schema.TypeString, + mkResourceVirtualEnvironmentVMDiskPathInDatastore: schema.TypeString, + mkResourceVirtualEnvironmentVMDiskFileFormat: schema.TypeString, + mkResourceVirtualEnvironmentVMDiskFileID: schema.TypeString, + mkResourceVirtualEnvironmentVMDiskSize: schema.TypeInt, }) diskSpeedSchema := test.AssertNestedSchemaExistence(