From 088221a5f1a20457f77ebdda3397945636957ef3 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Fri, 20 Mar 2026 12:55:36 -0500 Subject: [PATCH] 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) --- README.md | 1 + docs/PVE_API_COVERAGE.md | 4 +- src/PSProxmoxVE.Core/Services/VmService.cs | 55 +++++++ .../Cmdlets/Network/SetPveNetworkCmdlet.cs | 9 +- .../Cmdlets/Vms/ImportPveVmDiskCmdlet.cs | 93 +++++++++++ src/PSProxmoxVE/PSProxmoxVE.psd1 | 1 + .../Integration/Integration.Tests.ps1 | 1 + .../Vms/ImportPveVmDisk.Tests.ps1 | 144 ++++++++++++++++++ .../infrastructure/scripts/prepare-test-vm.sh | 18 ++- 9 files changed, 316 insertions(+), 10 deletions(-) create mode 100644 src/PSProxmoxVE/Cmdlets/Vms/ImportPveVmDiskCmdlet.cs create mode 100644 tests/PSProxmoxVE.Tests/Vms/ImportPveVmDisk.Tests.ps1 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'