diff --git a/README.md b/README.md
index a94a761..ef6c831 100644
--- a/README.md
+++ b/README.md
@@ -227,6 +227,7 @@ SDN management requires Proxmox VE 8.0 or later. Connected server is version 7.4
| `Get-PveVmConfig` | Get VM configuration |
| `Set-PveVmConfig` | Modify VM configuration |
| `Resize-PveVmDisk` | Resize a VM disk |
+| `Import-PveVmDisk` | Import a disk image (qcow2, raw, vmdk, OVA) into a VM |
### Containers
| Cmdlet | Description |
diff --git a/docs/PVE_API_COVERAGE.md b/docs/PVE_API_COVERAGE.md
index 9d020aa..4f4162c 100644
--- a/docs/PVE_API_COVERAGE.md
+++ b/docs/PVE_API_COVERAGE.md
@@ -4,7 +4,7 @@ This document tracks which PVE API areas are implemented in PSProxmoxVE and whic
**Last updated:** 2026-03-20
**Module version:** 0.1.0-preview
-**Total cmdlets:** 74
+**Total cmdlets:** 75
## Implemented
@@ -12,7 +12,7 @@ This document tracks which PVE API areas are implemented in PSProxmoxVE and whic
|------|---------|---------------|
| **Connection** | 3 | `POST /access/ticket`, `DELETE /access/ticket` |
| **Nodes** | 2 | `GET /nodes`, `GET /nodes/{node}/status` |
-| **VMs (QEMU)** | 18 | `/nodes/{node}/qemu/*` (CRUD, lifecycle, clone, migrate, resize, config, guest agent) |
+| **VMs (QEMU)** | 19 | `/nodes/{node}/qemu/*` (CRUD, lifecycle, clone, migrate, resize, disk import, config, guest agent) |
| **Containers (LXC)** | 14 | `/nodes/{node}/lxc/*` (CRUD, lifecycle, clone, migrate, config, snapshots) |
| **Storage** | 6 | `/storage`, `/nodes/{node}/storage/{storage}/*` (CRUD, content, upload, download) |
| **Snapshots** | 4 | `/nodes/{node}/qemu/{vmid}/snapshot/*` (CRUD, rollback) |
diff --git a/src/PSProxmoxVE.Core/Services/VmService.cs b/src/PSProxmoxVE.Core/Services/VmService.cs
index c041d7c..3d51eb2 100644
--- a/src/PSProxmoxVE.Core/Services/VmService.cs
+++ b/src/PSProxmoxVE.Core/Services/VmService.cs
@@ -123,6 +123,61 @@ namespace PSProxmoxVE.Core.Services
.GetAwaiter().GetResult();
}
+ // -------------------------------------------------------------------------
+ // Disk import
+ // -------------------------------------------------------------------------
+
+ ///
+ /// 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.
+ ///
+ /// The authenticated PVE session.
+ /// The cluster node name.
+ /// The VM ID.
+ /// The disk key (e.g. "scsi0", "sata0", "virtio0").
+ /// The target storage for the imported disk (e.g. "local-lvm").
+ ///
+ /// The import source in PVE format. Examples:
+ ///
+ /// - "local:iso/image.img" — import from a file already on storage
+ /// - "local:import/myvm.ova/disk.vmdk" — import a disk from within an OVA
+ /// - "/var/lib/vz/images/disk.qcow2" — import from an absolute path on the node
+ ///
+ ///
+ /// Optional target format (e.g. "qcow2", "raw"). Defaults to storage default.
+ 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
+ {
+ [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
// -------------------------------------------------------------------------
diff --git a/src/PSProxmoxVE/Cmdlets/Network/SetPveNetworkCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/SetPveNetworkCmdlet.cs
index 090f23f..e922372 100644
--- a/src/PSProxmoxVE/Cmdlets/Network/SetPveNetworkCmdlet.cs
+++ b/src/PSProxmoxVE/Cmdlets/Network/SetPveNetworkCmdlet.cs
@@ -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;
+ /// Network interface type (e.g. bridge, bond, eth, vlan, OVSBridge).
+ [Parameter(Mandatory = true, Position = 2, HelpMessage = "Interface type (bridge, bond, eth, vlan, OVSBridge).")]
+ public string Type { get; set; } = string.Empty;
+
/// IPv4 address for the interface.
[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();
+ var data = new Dictionary
+ {
+ ["type"] = Type
+ };
if (!string.IsNullOrEmpty(Address)) data["address"] = Address!;
if (!string.IsNullOrEmpty(Netmask)) data["netmask"] = Netmask!;
diff --git a/src/PSProxmoxVE/Cmdlets/Vms/ImportPveVmDiskCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Vms/ImportPveVmDiskCmdlet.cs
new file mode 100644
index 0000000..6d2e659
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Vms/ImportPveVmDiskCmdlet.cs
@@ -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
+{
+ ///
+ /// Imports a disk image into a Proxmox VE virtual machine.
+ ///
+ /// 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
+ ///
+ ///
+ [Cmdlet(VerbsData.Import, "PveVmDisk", SupportsShouldProcess = true)]
+ [OutputType(typeof(PveTask))]
+ public sealed class ImportPveVmDiskCmdlet : PveCmdletBase
+ {
+ /// The Proxmox VE node name.
+ [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, HelpMessage = "The PVE node name.")]
+ public string Node { get; set; } = string.Empty;
+
+ /// The VM identifier. Accepts pipeline input from Get-PveVm.
+ [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, HelpMessage = "The VM identifier.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ ///
+ /// The disk bus and index to import to (e.g. "scsi0", "sata0", "virtio0", "ide0").
+ ///
+ [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;
+
+ /// The target storage for the imported disk (e.g. "local-lvm", "ceph-pool").
+ [Parameter(Mandatory = true, HelpMessage = "Target storage for the imported disk.")]
+ public string TargetStorage { get; set; } = string.Empty;
+
+ ///
+ /// 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"
+ ///
+ [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;
+
+ /// Target disk format (e.g. "qcow2", "raw"). If omitted, uses the storage default.
+ [Parameter(Mandatory = false, HelpMessage = "Target disk format (qcow2, raw, vmdk).")]
+ [ValidateSet("qcow2", "raw", "vmdk")]
+ public string? Format { get; set; }
+
+ /// When specified, waits for the import task to complete before returning.
+ [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);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/PSProxmoxVE.psd1 b/src/PSProxmoxVE/PSProxmoxVE.psd1
index f136f9d..984d182 100644
--- a/src/PSProxmoxVE/PSProxmoxVE.psd1
+++ b/src/PSProxmoxVE/PSProxmoxVE.psd1
@@ -77,6 +77,7 @@
'Get-PveVmConfig',
'Set-PveVmConfig',
'Resize-PveVmDisk',
+ 'Import-PveVmDisk',
# QEMU Guest Agent
'Test-PveVmGuestAgent',
diff --git a/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1
index eff8847..a8fd631 100644
--- a/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1
+++ b/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1
@@ -575,6 +575,7 @@ Describe 'Integration Tests' -Tag 'Integration' {
{ Set-PveNetwork `
-Node $script:Node `
-Iface 'vmbr99' `
+ -Type 'bridge' `
-Comments 'Created by Pester integration test' `
-ErrorAction Stop } | Should -Not -Throw
}
diff --git a/tests/PSProxmoxVE.Tests/Vms/ImportPveVmDisk.Tests.ps1 b/tests/PSProxmoxVE.Tests/Vms/ImportPveVmDisk.Tests.ps1
new file mode 100644
index 0000000..d088c9a
--- /dev/null
+++ b/tests/PSProxmoxVE.Tests/Vms/ImportPveVmDisk.Tests.ps1
@@ -0,0 +1,144 @@
+#Requires -Module Pester
+<#
+.SYNOPSIS
+ Pester 5 tests for Import-PveVmDisk.
+
+ All tests are fully offline — no live Proxmox VE target is required.
+#>
+
+BeforeAll {
+ . $PSScriptRoot/../_TestHelper.ps1
+
+ $script:Cmd = Get-Command 'Import-PveVmDisk' -ErrorAction SilentlyContinue
+ $script:Available = $null -ne $script:Cmd
+
+ function Skip-IfMissing {
+ if (-not $script:Available) {
+ Set-ItResult -Skipped -Because "Import-PveVmDisk is not yet implemented in this build"
+ }
+ }
+}
+
+Describe 'Import-PveVmDisk — manifest' {
+ BeforeAll {
+ $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1'
+ $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null }
+ }
+
+ It 'Should be declared in CmdletsToExport' {
+ if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return }
+ $script:Manifest.CmdletsToExport | Should -Contain 'Import-PveVmDisk'
+ }
+}
+
+Describe 'Import-PveVmDisk' {
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Node should be Mandatory' {
+ Skip-IfMissing
+ $isMandatory = $script:Cmd.Parameters['Node'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+
+ It 'VmId should be Mandatory' {
+ Skip-IfMissing
+ $isMandatory = $script:Cmd.Parameters['VmId'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Disk should be Mandatory' {
+ Skip-IfMissing
+ $isMandatory = $script:Cmd.Parameters['Disk'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+
+ It 'TargetStorage should be Mandatory' {
+ Skip-IfMissing
+ $isMandatory = $script:Cmd.Parameters['TargetStorage'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Source should be Mandatory' {
+ Skip-IfMissing
+ $isMandatory = $script:Cmd.Parameters['Source'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Optional parameters' {
+ It 'Should have Format parameter' {
+ Skip-IfMissing
+ $script:Cmd.Parameters.ContainsKey('Format') | Should -BeTrue
+ }
+
+ It 'Should have Wait switch parameter' {
+ Skip-IfMissing
+ $script:Cmd.Parameters.ContainsKey('Wait') | Should -BeTrue
+ }
+
+ It 'Should have Session parameter' {
+ Skip-IfMissing
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Disk parameter validation' {
+ It 'Should accept valid disk names' {
+ Skip-IfMissing
+ # ValidatePattern should accept these — test by checking the attribute exists
+ $attrs = $script:Cmd.Parameters['Disk'].Attributes |
+ Where-Object { $_ -is [System.Management.Automation.ValidatePatternAttribute] }
+ $attrs | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Pipeline support' {
+ It 'VmId should accept pipeline input by property name' {
+ Skip-IfMissing
+ $attr = $script:Cmd.Parameters['VmId'].ParameterSets.Values |
+ Where-Object { $_.ValueFromPipelineByPropertyName }
+ $attr | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Node should accept pipeline input by property name' {
+ Skip-IfMissing
+ $attr = $script:Cmd.Parameters['Node'].ParameterSets.Values |
+ Where-Object { $_.ValueFromPipelineByPropertyName }
+ $attr | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing
+ { Import-PveVmDisk -Node 'pve1' -VmId 100 -Disk 'scsi0' `
+ -TargetStorage 'local-lvm' -Source 'local:iso/test.img' `
+ -Confirm:$false -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
diff --git a/tests/infrastructure/scripts/prepare-test-vm.sh b/tests/infrastructure/scripts/prepare-test-vm.sh
index eed54e9..23f9480 100755
--- a/tests/infrastructure/scripts/prepare-test-vm.sh
+++ b/tests/infrastructure/scripts/prepare-test-vm.sh
@@ -4,15 +4,15 @@
# Uses PSProxmoxVE cmdlets for all supported operations:
# - Invoke-PveStorageDownload (cloud image download)
# - New-PveVm (VM creation)
-# - Set-PveVmConfig -AdditionalConfig (disk/boot/agent/cloud-init config)
+# - Import-PveVmDisk (disk import from storage)
+# - Set-PveVmConfig -AdditionalConfig (boot/agent/cloud-init config)
# - Set-PveCloudInitConfig (user/password/IP)
# - Start-PveVm (boot)
# - Test-PveVmGuestAgent (agent ping)
#
# SSH/SCP only for operations without API support:
# - pvesm set (enable snippets content type)
-# - SCP snippet upload (no snippet API)
-# - qm importdisk (no disk import API)
+# - SCP snippet upload (no snippet upload API — PVE limitation, not even the web UI supports this)
#
# Usage: prepare-test-vm.sh
#
@@ -72,9 +72,14 @@ pwsh -NoProfile -Command "
-Memory 512 -Cores 1 -OsType 'l26' -Wait
"
-# ── Step 4: Import disk (SSH — no importdisk API) ────────────────────
-echo "Importing disk image..."
-${SSH_CMD} "qm importdisk ${VMID} /var/lib/vz/template/iso/${CLOUD_IMAGE_FILENAME} local-lvm 2>&1 | tail -1"
+# ── Step 4: Import disk (module cmdlet) ────────────────────────────
+echo "Importing disk image via Import-PveVmDisk..."
+pwsh -NoProfile -Command "
+ Import-Module PSProxmoxVE; ${CONNECT_CMD}
+ Import-PveVmDisk -Node '${NODE}' -VmId ${VMID} -Disk 'scsi0' \
+ -TargetStorage 'local-lvm' \
+ -Source 'local:iso/${CLOUD_IMAGE_FILENAME}' -Wait
+"
# ── Step 5: Configure VM (module cmdlet — AdditionalConfig) ──────────
echo "Configuring VM via Set-PveVmConfig -AdditionalConfig..."
@@ -82,7 +87,6 @@ pwsh -NoProfile -Command "
Import-Module PSProxmoxVE; ${CONNECT_CMD}
Set-PveVmConfig -Node '${NODE}' -VmId ${VMID} -AdditionalConfig @{
scsihw = 'virtio-scsi-single'
- scsi0 = 'local-lvm:vm-${VMID}-disk-0'
boot = 'order=scsi0'
serial0 = 'socket'
agent = '1'