Repository: bpg/terraform-provider-proxmox

Issue:
Support custom cloud init drive file format
**Is your feature request related to a problem? Please describe.**
When adding a cloud-init drive in a directory storage, you can specify the file format (e.g., qcow2, raw). However, the initialization block does not include a file_format option.

**Describe the solution you'd like**
I want the `initilization` block to include a `file_format` option similar to `disk` block.

**Describe alternatives you've considered**

**Additional context**

<img width="480" height="270" alt="Image" src="https://github.com/user-attachments/assets/da12bec3-8098-41cc-b97a-da78125cf78d" />

Discussion:
@hsnprsd: Proxmox throws an error when I try to add a cloud-init drive with both media=cdrom and a format option (like format=raw).

```bash
root@pve:~# pvesh set nodes/pve/qemu/100/config -ide2 local:cloudinit,format=qcow2,media=cdrom
400 Parameter verification failed.
ide2: unable to parse drive options
pvesh set <api_path> [OPTIONS] [FORMAT_OPTIONS]
```

```bash
root@pve:~# pvesh set nodes/pve/qemu/100/config -ide2 local:cloudinit,media=cdrom
update VM 100: -ide2 local:cloudinit,media=cdrom
Formatting '/var/lib/vz/images/100/vm-100-cloudinit.qcow2', fmt=qcow2 cluster_size=65536 extended_l2=off preallocation=metadata compression_type=zlib size=4194304 lazy_refcounts=off refcount_bits=16
ide2: successfully created disk 'local:100/vm-100-cloudinit.qcow2,media=cdrom'
generating cloud-init ISO
```

Solution patch:
diff --git a/docs/resources/virtual_environment_vm.md b/docs/resources/virtual_environment_vm.md
index c1565e60..3ce4ee1c 100755
--- a/docs/resources/virtual_environment_vm.md
+++ b/docs/resources/virtual_environment_vm.md
@@ -377,6 +377,10 @@ output "ubuntu_vm_public_key" {
         image to. Must be one of `ide0..3`, `sata0..5`, `scsi0..30`. Will be
         detected if the setting is missing but a cloud-init image is present,
         otherwise defaults to `ide2`.
+    - `file_format` - (Optional) The file format.
+        - `qcow2` - QEMU Disk Image v2.
+        - `raw` - Raw Disk Image.
+        - `vmdk` - VMware Disk Image.
     - `dns` - (Optional) The DNS configuration.
         - `domain` - (Optional) The DNS search domain.
         - `server` - (Optional) The DNS server. The `server` attribute is
diff --git a/proxmox/nodes/vms/custom_storage_device.go b/proxmox/nodes/vms/custom_storage_device.go
index 3397377a..611f267a 100644
--- a/proxmox/nodes/vms/custom_storage_device.go
+++ b/proxmox/nodes/vms/custom_storage_device.go
@@ -110,8 +110,7 @@ func (d *CustomStorageDevice) IsOwnedBy(vmID int) bool {
 
 // IsCloudInitDrive returns true, if CustomStorageDevice is a cloud-init drive.
 func (d *CustomStorageDevice) IsCloudInitDrive(vmID int) bool {
-	return d.Media != nil && *d.Media == "cdrom" &&
-		strings.Contains(d.FileVolume, fmt.Sprintf("vm-%d-cloudinit", vmID))
+	return strings.Contains(d.FileVolume, fmt.Sprintf("vm-%d-cloudinit", vmID))
 }
 
 // EncodeOptions converts a CustomStorageDevice's common options a URL value.
diff --git a/proxmoxtf/resource/vm/vm.go b/proxmoxtf/resource/vm/vm.go
index 8aefbcc5..6008649f 100644
--- a/proxmoxtf/resource/vm/vm.go
+++ b/proxmoxtf/resource/vm/vm.go
@@ -11,6 +11,7 @@ import (
 	"encoding/base64"
 	"errors"
 	"fmt"
+	"path/filepath"
 	"regexp"
 	"sort"
 	"strconv"
@@ -225,6 +226,7 @@ const (
 	mkInitialization                    = "initialization"
 	mkInitializationDatastoreID         = "datastore_id"
 	mkInitializationInterface           = "interface"
+	mkInitializationFileFormat          = "file_format"
 	mkInitializationDNS                 = "dns"
 	mkInitializationDNSDomain           = "domain"
 	mkInitializationDNSServers          = "servers"
@@ -840,6 +842,13 @@ func VM() *schema.Resource {
 							return newValue == ""
 						},
 					},
+					mkInitializationFileFormat: {
+						Type:             schema.TypeString,
+						Description:      "The file format",
+						Optional:         true,
+						Computed:         true,
+						ValidateDiagFunc: validators.FileFormat(),
+					},
 					mkInitializationDNS: {
 						Type:        schema.TypeList,
 						Description: "The DNS configuration",
@@ -1769,16 +1778,16 @@ func vmCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnosti
 }
 
 // Check for an existing CloudInit IDE drive. If no such drive is found, return the specified `defaultValue`.
-func findExistingCloudInitDrive(vmConfig *vms.GetResponseData, vmID int, defaultValue string) string {
+func findExistingCloudInitDrive(vmConfig *vms.GetResponseData, vmID int, defaultValue string) (string, *vms.CustomStorageDevice) {
 	devs := vmConfig.StorageDevices.Filter(func(device *vms.CustomStorageDevice) bool {
 		return device.IsCloudInitDrive(vmID)
 	})
 
-	for iface := range devs {
-		return iface
+	for iface, device := range devs {
+		return iface, device
 	}
 
-	return defaultValue
+	return defaultValue, nil
 }
 
 // Return a pointer to the storage device configuration based on a name. The device name is assumed to be a
@@ -2203,21 +2212,27 @@ func vmCreateClone(ctx context.Context, d *schema.ResourceData, m any) diag.Diag
 		initializationBlock := initialization[0].(map[string]any)
 		initializationDatastoreID := initializationBlock[mkInitializationDatastoreID].(string)
 		initializationInterface := initializationBlock[mkInitializationInterface].(string)
+		initializationFileFormat := initializationBlock[mkInitializationFileFormat].(string)
 
-		existingInterface := findExistingCloudInitDrive(vmConfig, vmID, "ide2")
+		existingInterface, _ := findExistingCloudInitDrive(vmConfig, vmID, "ide2")
 		if initializationInterface == "" {
 			initializationInterface = existingInterface
 		}
 
 		tflog.Trace(ctx, fmt.Sprintf("CloudInit IDE interface is '%s'", initializationInterface))
 
-		cdromCloudInitFileID := fmt.Sprintf("%s:cloudinit", initializationDatastoreID)
-		cdromCloudInitMedia := "cdrom"
-		ideDevices[initializationInterface] = &vms.CustomStorageDevice{
-			FileVolume: cdromCloudInitFileID,
-			Media:      &cdromCloudInitMedia,
+		initializationFileVolume := fmt.Sprintf("%s:cloudinit", initializationDatastoreID)
+
+		device := &vms.CustomStorageDevice{
+			FileVolume: initializationFileVolume,
 		}
 
+		if initializationFileFormat != "" {
+			device.Format = &initializationFileFormat
+		}
+
+		ideDevices[initializationInterface] = device
+
 		if err := deleteIdeDrives(ctx, vmAPI, initializationInterface, existingInterface); err != nil {
 			return err
 		}
@@ -2594,8 +2609,9 @@ func vmCreateCustom(ctx context.Context, d *schema.ResourceData, m any) diag.Dia
 		}
 	}
 
-	cdromCloudInitFileID := ""
-	cdromCloudInitInterface := ""
+	initializationFileVolume := ""
+	initializationInterface := ""
+	initializationFileFormat := ""
 
 	cpuBlock, err := structure.GetSchemaBlock(
 		resource,
@@ -2659,12 +2675,13 @@ func vmCreateCustom(ctx context.Context, d *schema.ResourceData, m any) diag.Dia
 
 		initializationBlock := initialization[0].(map[string]any)
 		initializationDatastoreID := initializationBlock[mkInitializationDatastoreID].(string)
+		initializationFileFormat = initializationBlock[mkInitializationFileFormat].(string)
 
-		cdromCloudInitFileID = fmt.Sprintf("%s:cloudinit", initializationDatastoreID)
+		initializationFileVolume = fmt.Sprintf("%s:cloudinit", initializationDatastoreID)
 
-		cdromCloudInitInterface = initializationBlock[mkInitializationInterface].(string)
-		if cdromCloudInitInterface == "" {
-			cdromCloudInitInterface = "ide2"
+		initializationInterface = initializationBlock[mkInitializationInterface].(string)
+		if initializationInterface == "" {
+			initializationInterface = "ide2"
 		}
 	}
 
@@ -2794,19 +2811,24 @@ func vmCreateCustom(ctx context.Context, d *schema.ResourceData, m any) diag.Dia
 		cpuFlagsConverted[fi] = flag.(string)
 	}
 
-	ideDevice2Media := "cdrom"
+	cdromMedia := "cdrom"
 
-	if cdromCloudInitInterface != "" {
-		diskDeviceObjects[cdromCloudInitInterface] = &vms.CustomStorageDevice{
-			FileVolume: cdromCloudInitFileID,
-			Media:      &ideDevice2Media,
+	if initializationInterface != "" {
+		device := &vms.CustomStorageDevice{
+			FileVolume: initializationFileVolume,
 		}
+
+		if initializationFileFormat != "" {
+			device.Format = &initializationFileFormat
+		}
+
+		diskDeviceObjects[initializationInterface] = device
 	}
 
 	if cdromInterface != "" {
 		diskDeviceObjects[cdromInterface] = &vms.CustomStorageDevice{
 			FileVolume: cdromFileID,
-			Media:      &ideDevice2Media,
+			Media:      &cdromMedia,
 		}
 	}
 
@@ -4384,13 +4406,28 @@ func vmReadCustom(
 	// Compare the initialization configuration to the one stored in the state.
 	initialization := map[string]any{}
 
-	initializationInterface := findExistingCloudInitDrive(vmConfig, vmID, "")
+	initializationInterface, initializationDevice := findExistingCloudInitDrive(vmConfig, vmID, "")
 	if initializationInterface != "" {
-		initializationDevice := getStorageDevice(vmConfig, initializationInterface)
-		fileVolumeParts := strings.Split(initializationDevice.FileVolume, ":")
+		parts := strings.Split(initializationDevice.FileVolume, ",")
+
+		fileVolume := parts[0]
+		fileVolumeParts := strings.Split(fileVolume, ":")
+
+		datastoreId := fileVolumeParts[0]
+		pathInDatastore := fileVolumeParts[1]
 
 		initialization[mkInitializationInterface] = initializationInterface
-		initialization[mkInitializationDatastoreID] = fileVolumeParts[0]
+		initialization[mkInitializationDatastoreID] = datastoreId
+
+		if initializationDevice.Format != nil && *initializationDevice.Format != "" {
+			initialization[mkInitializationFileFormat] = *initializationDevice.Format
+		} else {
+			fileFormat := filepath.Ext(pathInDatastore)
+
+			if fileFormat != "" {
+				initialization[mkInitializationFileFormat] = fileFormat[1:]
+			}
+		}
 	}
 
 	if vmConfig.CloudInitDNSDomain != nil || vmConfig.CloudInitDNSServer != nil {
@@ -5631,35 +5668,43 @@ func vmUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnosti
 			initializationBlock := initialization[0].(map[string]any)
 			initializationDatastoreID := initializationBlock[mkInitializationDatastoreID].(string)
 			initializationInterface := initializationBlock[mkInitializationInterface].(string)
-			cdromMedia := "cdrom"
+			initializationFileFormat := initializationBlock[mkInitializationFileFormat].(string)
 
-			existingInterface := findExistingCloudInitDrive(vmConfig, vmID, "")
+			existingInterface, existingDevice := findExistingCloudInitDrive(vmConfig, vmID, "")
 			if initializationInterface == "" && existingInterface == "" {
 				initializationInterface = "ide2"
 			} else if initializationInterface == "" {
 				initializationInterface = existingInterface
 			}
 
-			mustMove := existingInterface != "" && initializationInterface != existingInterface
-			if mustMove {
+			mustChangeInterface := existingInterface != "" && initializationInterface != existingInterface
+			if mustChangeInterface {
 				tflog.Debug(ctx, fmt.Sprintf("CloudInit must be moved from %s to %s", existingInterface, initializationInterface))
 			}
 
 			mustChangeDatastore := false
+			mustChangeFileFormat := false
 
 			oldInit, _ := d.GetChange(mkInitialization)
 			if len(oldInit.([]any)) > 0 {
 				oldInitBlock := oldInit.([]any)[0].(map[string]any)
 				prevDatastoreID := oldInitBlock[mkInitializationDatastoreID].(string)
+				prevFileFormat := oldInitBlock[mkInitializationFileFormat].(string)
 
 				mustChangeDatastore = prevDatastoreID != initializationDatastoreID
 				if mustChangeDatastore {
 					tflog.Debug(ctx, fmt.Sprintf("CloudInit must be moved from datastore %s to datastore %s",
 						prevDatastoreID, initializationDatastoreID))
 				}
+
+				mustChangeFileFormat = prevFileFormat != initializationFileFormat
+				if mustChangeFileFormat {
+					tflog.Debug(ctx, fmt.Sprintf("CloudInit must be moved from file format %s to file format %s",
+						prevFileFormat, initializationFileFormat))
+				}
 			}
 
-			if mustMove || mustChangeDatastore || existingInterface == "" {
+			if mustChangeInterface || mustChangeDatastore || mustChangeFileFormat || existingInterface == "" {
 				// CloudInit must be moved, either from a device to another or from a datastore
 				// to another (or both). This requires the VM to be stopped.
 				if !stoppedBeforeUpdate {
@@ -5675,14 +5720,18 @@ func vmUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnosti
 				stoppedBeforeUpdate = true
 				fileVolume = fmt.Sprintf("%s:cloudinit", initializationDatastoreID)
 			} else {
-				ideDevice := getStorageDevice(vmConfig, existingInterface)
-				fileVolume = ideDevice.FileVolume
+				fileVolume = existingDevice.FileVolume
 			}
 
-			updateBody.AddCustomStorageDevice(initializationInterface, vms.CustomStorageDevice{
+			device := vms.CustomStorageDevice{
 				FileVolume: fileVolume,
-				Media:      &cdromMedia,
-			})
+			}
+
+			if initializationFileFormat != "" {
+				device.Format = &initializationFileFormat
+			}
+
+			updateBody.AddCustomStorageDevice(initializationInterface, device)
 		}
 
 		cloudInitRebuildRequired = true



Test patch:
diff --git a/fwprovider/test/resource_vm_test.go b/fwprovider/test/resource_vm_test.go
index 8accac52..448889b0 100644
--- a/fwprovider/test/resource_vm_test.go
+++ b/fwprovider/test/resource_vm_test.go
@@ -609,6 +609,49 @@ func TestAccResourceVMInitialization(t *testing.T) {
 		name string
 		step []resource.TestStep
 	}{
+		{"custom cloud-init drive file format", []resource.TestStep{{
+			Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_vm" "test_vm_cloudinit" {
+					node_name = "{{.NodeName}}"
+					started = false
+					cpu {
+						cores = 1
+					}
+					memory {
+						dedicated = 1024
+					}
+
+					initialization {
+						datastore_id = "local"
+						file_format = "raw"
+					}
+				}`),
+			Check: ResourceAttributes("proxmox_virtual_environment_vm.test_vm_cloudinit", map[string]string{
+				"initialization.0.datastore_id": "local",
+				"initialization.0.file_format":  "raw",
+			}),
+		}, {
+			Config: te.RenderConfig(`
+				resource "proxmox_virtual_environment_vm" "test_vm_cloudinit" {
+					node_name = "{{.NodeName}}"
+					started = false
+					cpu {
+						cores = 1
+					}
+					memory {
+						dedicated = 1024
+					}
+
+					initialization {
+						datastore_id = "local"
+						file_format  = "qcow2"
+					}
+				}`),
+			Check: ResourceAttributes("proxmox_virtual_environment_vm.test_vm_cloudinit", map[string]string{
+				"initialization.0.datastore_id": "local",
+				"initialization.0.file_format":  "qcow2",
+			}),
+		}}},
 		{"custom cloud-init: use SCSI interface", []resource.TestStep{{
 			Config: te.RenderConfig(`
 				resource "proxmox_virtual_environment_file" "cloud_config" {
diff --git a/proxmoxtf/resource/vm/vm_test.go b/proxmoxtf/resource/vm/vm_test.go
index 277f526a..9c4e1ca6 100644
--- a/proxmoxtf/resource/vm/vm_test.go
+++ b/proxmoxtf/resource/vm/vm_test.go
@@ -202,6 +202,7 @@ func TestVMSchema(t *testing.T) {
 	test.AssertOptionalArguments(t, initializationSchema, []string{
 		mkInitializationDatastoreID,
 		mkInitializationInterface,
+		mkInitializationFileFormat,
 		mkInitializationDNS,
 		mkInitializationIPConfig,
 		mkInitializationUserAccount,
@@ -210,6 +211,7 @@ func TestVMSchema(t *testing.T) {
 	test.AssertValueTypes(t, initializationSchema, map[string]schema.ValueType{
 		mkInitializationDatastoreID: schema.TypeString,
 		mkInitializationInterface:   schema.TypeString,
+		mkInitializationFileFormat:  schema.TypeString,
 		mkInitializationDNS:         schema.TypeList,
 		mkInitializationIPConfig:    schema.TypeList,
 		mkInitializationUserAccount: schema.TypeList,