feat: add Import-PveVmDisk cmdlet for disk image import

Imports disk images (qcow2, raw, vmdk) and OVA archives into VMs
via the PVE API using POST config with import-from syntax.

- VmService.ImportDisk() method using POST /nodes/{node}/qemu/{vmid}/config
- Import-PveVmDisk cmdlet with -Source, -Disk, -TargetStorage, -Format, -Wait
- Pester unit tests
- prepare-test-vm.sh updated to use Import-PveVmDisk instead of SSH qm importdisk
- Set-PveNetwork: add required Type parameter (fixes PVE API 400 error)
- Integration test fix: pass -Type bridge to Set-PveNetwork

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-20 12:55:36 -05:00
parent ae55dbf0d7
commit 088221a5f1
9 changed files with 316 additions and 10 deletions
@@ -123,6 +123,61 @@ namespace PSProxmoxVE.Core.Services
.GetAwaiter().GetResult();
}
// -------------------------------------------------------------------------
// Disk import
// -------------------------------------------------------------------------
/// <summary>
/// Imports a disk image into a VM by setting a disk config key with the import-from syntax.
/// Uses POST (not PUT) because the import is an async background operation.
/// Returns the task UPID.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="node">The cluster node name.</param>
/// <param name="vmid">The VM ID.</param>
/// <param name="disk">The disk key (e.g. "scsi0", "sata0", "virtio0").</param>
/// <param name="targetStorage">The target storage for the imported disk (e.g. "local-lvm").</param>
/// <param name="importFrom">
/// The import source in PVE format. Examples:
/// <list type="bullet">
/// <item>"local:iso/image.img" — import from a file already on storage</item>
/// <item>"local:import/myvm.ova/disk.vmdk" — import a disk from within an OVA</item>
/// <item>"/var/lib/vz/images/disk.qcow2" — import from an absolute path on the node</item>
/// </list>
/// </param>
/// <param name="format">Optional target format (e.g. "qcow2", "raw"). Defaults to storage default.</param>
public PveTask ImportDisk(
PveSession session,
string node,
int vmid,
string disk,
string targetStorage,
string importFrom,
string? format = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(disk)) throw new ArgumentNullException(nameof(disk));
if (string.IsNullOrWhiteSpace(targetStorage)) throw new ArgumentNullException(nameof(targetStorage));
if (string.IsNullOrWhiteSpace(importFrom)) throw new ArgumentNullException(nameof(importFrom));
// Build the disk value: "storage:0,import-from=source[,format=fmt]"
var diskValue = $"{targetStorage}:0,import-from={importFrom}";
if (!string.IsNullOrEmpty(format))
diskValue += $",format={format}";
var formData = new Dictionary<string, string>
{
[disk] = diskValue
};
using var client = new PveHttpClient(session);
// POST (not PUT) because import-from triggers a background task
var response = client.PostAsync($"nodes/{node}/qemu/{vmid}/config", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
@@ -22,6 +22,10 @@ namespace PSProxmoxVE.Cmdlets.Network
[Parameter(Mandatory = true, Position = 1, HelpMessage = "The network interface name.")]
public string Iface { get; set; } = string.Empty;
/// <summary>Network interface type (e.g. bridge, bond, eth, vlan, OVSBridge).</summary>
[Parameter(Mandatory = true, Position = 2, HelpMessage = "Interface type (bridge, bond, eth, vlan, OVSBridge).")]
public string Type { get; set; } = string.Empty;
/// <summary>IPv4 address for the interface.</summary>
[Parameter(Mandatory = false, HelpMessage = "IPv4 address for the interface.")]
public string? Address { get; set; }
@@ -75,7 +79,10 @@ namespace PSProxmoxVE.Cmdlets.Network
using var client = new PveHttpClient(session);
WriteVerbose($"Updating network interface '{Iface}' on node '{Node}'...");
var data = new Dictionary<string, string>();
var data = new Dictionary<string, string>
{
["type"] = Type
};
if (!string.IsNullOrEmpty(Address)) data["address"] = Address!;
if (!string.IsNullOrEmpty(Netmask)) data["netmask"] = Netmask!;
@@ -0,0 +1,93 @@
using System;
using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Vms
{
/// <summary>
/// <para type="synopsis">Imports a disk image into a Proxmox VE virtual machine.</para>
/// <para type="description">
/// Imports a disk image file into the specified VM by setting a disk configuration key
/// with the PVE import-from syntax. The image can be:
///
/// - A file already on PVE storage (e.g. a cloud image downloaded via Invoke-PveStorageDownload)
/// - A VMDK inside an uploaded OVA archive
/// - An absolute path to an image file on the PVE node
///
/// The import runs as a background task on the PVE node. Use -Wait to block until complete.
///
/// Examples:
/// # Import a cloud image from local ISO storage
/// Import-PveVmDisk -Node pve1 -VmId 100 -Disk scsi0 -TargetStorage local-lvm `
/// -Source 'local:iso/noble-server-cloudimg-amd64.img' -Wait
///
/// # Import a VMDK from an OVA (uploaded with content=import)
/// Import-PveVmDisk -Node pve1 -VmId 100 -Disk sata0 -TargetStorage local-lvm `
/// -Source 'local:import/appliance.ova/appliance-disk1.vmdk' -Wait
/// </para>
/// </summary>
[Cmdlet(VerbsData.Import, "PveVmDisk", SupportsShouldProcess = true)]
[OutputType(typeof(PveTask))]
public sealed class ImportPveVmDiskCmdlet : PveCmdletBase
{
/// <summary>The Proxmox VE node name.</summary>
[Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, HelpMessage = "The PVE node name.")]
public string Node { get; set; } = string.Empty;
/// <summary>The VM identifier. Accepts pipeline input from Get-PveVm.</summary>
[Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, HelpMessage = "The VM identifier.")]
[ValidateRange(100, 999999999)]
public int VmId { get; set; }
/// <summary>
/// The disk bus and index to import to (e.g. "scsi0", "sata0", "virtio0", "ide0").
/// </summary>
[Parameter(Mandatory = true, Position = 0, HelpMessage = "Target disk slot (e.g. scsi0, sata0, virtio0).")]
[ValidatePattern(@"^(scsi|sata|virtio|ide)\d+$")]
public string Disk { get; set; } = string.Empty;
/// <summary>The target storage for the imported disk (e.g. "local-lvm", "ceph-pool").</summary>
[Parameter(Mandatory = true, HelpMessage = "Target storage for the imported disk.")]
public string TargetStorage { get; set; } = string.Empty;
/// <summary>
/// The import source. Accepted formats:
/// - Storage reference: "local:iso/image.img", "local:import/vm.ova/disk.vmdk"
/// - Absolute path on the node: "/var/lib/vz/images/disk.qcow2"
/// </summary>
[Parameter(Mandatory = true, HelpMessage = "Import source (e.g. local:iso/image.img or local:import/vm.ova/disk.vmdk).")]
public string Source { get; set; } = string.Empty;
/// <summary>Target disk format (e.g. "qcow2", "raw"). If omitted, uses the storage default.</summary>
[Parameter(Mandatory = false, HelpMessage = "Target disk format (qcow2, raw, vmdk).")]
[ValidateSet("qcow2", "raw", "vmdk")]
public string? Format { get; set; }
/// <summary>When specified, waits for the import task to complete before returning.</summary>
[Parameter(Mandatory = false, HelpMessage = "Wait for the import task to complete.")]
public SwitchParameter Wait { get; set; }
protected override void ProcessRecord()
{
if (!ShouldProcess($"VM {VmId} disk {Disk} on node '{Node}'", $"Import disk from '{Source}' to '{TargetStorage}'"))
return;
var session = GetSession();
var vmService = new VmService();
WriteVerbose($"Importing disk to VM {VmId} slot {Disk}: {Source} -> {TargetStorage}...");
var task = vmService.ImportDisk(session, Node, VmId, Disk, TargetStorage, Source, Format);
if (Wait.IsPresent && !string.IsNullOrEmpty(task.Upid))
{
var taskService = new TaskService();
task = taskService.WaitForTask(session, Node, task.Upid, null, null, null);
}
WriteObject(task);
}
}
}
+1
View File
@@ -77,6 +77,7 @@
'Get-PveVmConfig',
'Set-PveVmConfig',
'Resize-PveVmDisk',
'Import-PveVmDisk',
# QEMU Guest Agent
'Test-PveVmGuestAgent',