feat: add Import-PveOva cmdlet for OVA appliance import

Parses OVF metadata client-side from the OVA TAR archive to extract
VM name, CPU, memory, disks, and network adapters. Then uploads the
OVA with content=import, creates the VM, and imports each disk.

- OvfMetadata parser with multi-target TAR support (System.Formats.Tar
  on net9.0, manual 512-byte header parsing on netstandard2.0/net48)
- VmService.UploadOva() for content=import uploads
- Import-PveOva cmdlet with -Name/-Memory/-Cores overrides and -Wait
- Pester unit tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-20 13:08:31 -05:00
parent 088221a5f1
commit ca0cd945a0
6 changed files with 996 additions and 0 deletions
+1
View File
@@ -228,6 +228,7 @@ SDN management requires Proxmox VE 8.0 or later. Connected server is version 7.4
| `Set-PveVmConfig` | Modify VM configuration |
| `Resize-PveVmDisk` | Resize a VM disk |
| `Import-PveVmDisk` | Import a disk image (qcow2, raw, vmdk, OVA) into a VM |
| `Import-PveOva` | Import an OVA appliance as a new VM (parses OVF, uploads, creates VM, imports disks) |
### Containers
| Cmdlet | Description |
@@ -0,0 +1,496 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Xml;
namespace PSProxmoxVE.Core.Models.Vms
{
/// <summary>
/// Represents a disk reference extracted from an OVF descriptor.
/// </summary>
public class OvfDiskReference
{
/// <summary>The bus type hint (ide, scsi, sata).</summary>
public string BusType { get; set; } = "scsi";
/// <summary>The VMDK filename within the OVA archive.</summary>
public string FileName { get; set; } = string.Empty;
}
/// <summary>
/// Represents a network adapter extracted from an OVF descriptor.
/// </summary>
public class OvfNetworkAdapter
{
/// <summary>The adapter name (e.g. "Network adapter 1").</summary>
public string AdapterName { get; set; } = string.Empty;
/// <summary>The connection name (e.g. "VM Network", "bridged").</summary>
public string ConnectionName { get; set; } = string.Empty;
}
/// <summary>
/// Metadata extracted from an OVF descriptor inside an OVA archive.
/// </summary>
public class OvfMetadata
{
/// <summary>The VM name from the OVF.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Number of CPU cores.</summary>
public int CpuCount { get; set; } = 1;
/// <summary>Memory in MB.</summary>
public int MemoryMB { get; set; } = 1024;
/// <summary>Disk references found in the OVF.</summary>
public List<OvfDiskReference> Disks { get; set; } = new List<OvfDiskReference>();
/// <summary>Network adapters found in the OVF.</summary>
public List<OvfNetworkAdapter> NetworkAdapters { get; set; } = new List<OvfNetworkAdapter>();
/// <summary>OS type hint from the OVF OperatingSystemSection.</summary>
public string OsTypeHint { get; set; } = string.Empty;
// OVF namespace constants
private const string OvfNs = "http://schemas.dmtf.org/ovf/envelope/1";
private const string RasdNs = "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData";
private const string VssdNs = "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData";
/// <summary>
/// Parses an OVA file (TAR archive) and extracts OVF metadata.
/// </summary>
/// <param name="ovaPath">Path to the OVA file.</param>
/// <returns>Parsed OVF metadata.</returns>
public static OvfMetadata FromOva(string ovaPath)
{
if (string.IsNullOrWhiteSpace(ovaPath))
throw new ArgumentException("OVA path must not be null or empty.", nameof(ovaPath));
if (!File.Exists(ovaPath))
throw new FileNotFoundException("OVA file not found.", ovaPath);
var ovfXml = ExtractOvfFromTar(ovaPath);
if (ovfXml == null)
throw new InvalidOperationException("No .ovf file found inside the OVA archive.");
return ParseOvfXml(ovfXml);
}
/// <summary>
/// Extracts the .ovf XML content from a TAR archive.
/// Uses System.Formats.Tar on .NET 9+ and manual TAR parsing on older frameworks.
/// </summary>
private static string? ExtractOvfFromTar(string tarPath)
{
#if NET7_0_OR_GREATER
return ExtractOvfUsingSystemTar(tarPath);
#else
return ExtractOvfManualTar(tarPath);
#endif
}
#if NET7_0_OR_GREATER
private static string? ExtractOvfUsingSystemTar(string tarPath)
{
using var stream = File.OpenRead(tarPath);
using var reader = new System.Formats.Tar.TarReader(stream);
System.Formats.Tar.TarEntry? entry;
while ((entry = reader.GetNextEntry()) != null)
{
if (entry.Name.EndsWith(".ovf", StringComparison.OrdinalIgnoreCase) && entry.DataStream != null)
{
using var sr = new StreamReader(entry.DataStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
return sr.ReadToEnd();
}
}
return null;
}
#else
/// <summary>
/// Minimal TAR reader for netstandard2.0/net48.
/// TAR format: 512-byte header per file, name at offset 0 (100 bytes), size at offset 124 (12 bytes octal),
/// followed by file data padded to 512-byte boundary.
/// </summary>
private static string? ExtractOvfManualTar(string tarPath)
{
using var stream = File.OpenRead(tarPath);
var header = new byte[512];
while (true)
{
var bytesRead = ReadFull(stream, header, 0, 512);
if (bytesRead < 512)
break;
// Check for end-of-archive (two zero blocks)
if (IsZeroBlock(header))
break;
// Extract name (offset 0, 100 bytes)
var name = ReadNullTerminatedString(header, 0, 100);
// Extract size (offset 124, 12 bytes, octal)
var sizeStr = ReadNullTerminatedString(header, 124, 12).Trim();
long size = 0;
if (!string.IsNullOrEmpty(sizeStr))
{
try { size = Convert.ToInt64(sizeStr, 8); }
catch { size = 0; }
}
// Check for GNU/POSIX long name prefix (offset 345, 155 bytes)
var prefix = ReadNullTerminatedString(header, 345, 155);
if (!string.IsNullOrEmpty(prefix))
name = prefix + "/" + name;
if (name.EndsWith(".ovf", StringComparison.OrdinalIgnoreCase) && size > 0)
{
var data = new byte[size];
var dataRead = ReadFull(stream, data, 0, (int)size);
if (dataRead < size)
throw new InvalidOperationException("Unexpected end of OVA archive while reading .ovf entry.");
return Encoding.UTF8.GetString(data);
}
// Skip past the file data (padded to 512-byte boundary)
if (size > 0)
{
var paddedSize = ((size + 511) / 512) * 512;
SkipBytes(stream, paddedSize);
}
}
return null;
}
private static int ReadFull(Stream stream, byte[] buffer, int offset, int count)
{
int totalRead = 0;
while (totalRead < count)
{
int read = stream.Read(buffer, offset + totalRead, count - totalRead);
if (read == 0) break;
totalRead += read;
}
return totalRead;
}
private static void SkipBytes(Stream stream, long count)
{
if (stream.CanSeek)
{
stream.Seek(count, SeekOrigin.Current);
}
else
{
var buf = new byte[Math.Min(count, 8192)];
long remaining = count;
while (remaining > 0)
{
int toRead = (int)Math.Min(remaining, buf.Length);
int read = stream.Read(buf, 0, toRead);
if (read == 0) break;
remaining -= read;
}
}
}
private static bool IsZeroBlock(byte[] block)
{
for (int i = 0; i < block.Length; i++)
{
if (block[i] != 0) return false;
}
return true;
}
private static string ReadNullTerminatedString(byte[] buffer, int offset, int maxLength)
{
int end = offset;
int limit = offset + maxLength;
while (end < limit && buffer[end] != 0)
end++;
return Encoding.ASCII.GetString(buffer, offset, end - offset);
}
#endif
/// <summary>
/// Parses OVF XML and extracts VM metadata.
/// </summary>
private static OvfMetadata ParseOvfXml(string xml)
{
var doc = new XmlDocument();
doc.LoadXml(xml);
var nsm = new XmlNamespaceManager(doc.NameTable);
nsm.AddNamespace("ovf", OvfNs);
nsm.AddNamespace("rasd", RasdNs);
nsm.AddNamespace("vssd", VssdNs);
var metadata = new OvfMetadata();
// Extract VM name from VirtualSystem
var vsNode = doc.SelectSingleNode("//ovf:VirtualSystem", nsm);
if (vsNode != null)
{
// Try ovf:id attribute first, then Name element
var idAttr = vsNode.Attributes?["ovf:id"];
if (idAttr != null && !string.IsNullOrEmpty(idAttr.Value))
{
metadata.Name = idAttr.Value;
}
}
// Try VirtualSystemIdentifier from VirtualSystemSettingData
var vsId = doc.SelectSingleNode("//vssd:VirtualSystemIdentifier", nsm);
if (vsId != null && !string.IsNullOrEmpty(vsId.InnerText))
{
metadata.Name = vsId.InnerText;
}
// Extract OS type hint from OperatingSystemSection
var osSection = doc.SelectSingleNode("//ovf:OperatingSystemSection", nsm);
if (osSection != null)
{
var osTypeAttr = osSection.Attributes?["ovf:id"];
var description = osSection.SelectSingleNode("ovf:Description", nsm);
var osDesc = description?.InnerText ?? osTypeAttr?.Value ?? string.Empty;
metadata.OsTypeHint = MapOsType(osDesc);
}
// Build file reference map: fileRef -> fileName
var fileRefs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var fileNodes = doc.SelectNodes("//ovf:References/ovf:File", nsm);
if (fileNodes != null)
{
foreach (XmlNode fileNode in fileNodes)
{
var id = fileNode.Attributes?["ovf:id"]?.Value;
var href = fileNode.Attributes?["ovf:href"]?.Value;
if (id != null && href != null)
{
fileRefs[id] = href;
}
}
}
// Build disk reference map: diskId -> fileRef
var diskFileMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var diskNodes = doc.SelectNodes("//ovf:DiskSection/ovf:Disk", nsm);
if (diskNodes != null)
{
foreach (XmlNode diskNode in diskNodes)
{
var diskId = diskNode.Attributes?["ovf:diskId"]?.Value;
var fileRef = diskNode.Attributes?["ovf:fileRef"]?.Value;
if (diskId != null && fileRef != null)
{
diskFileMap[diskId] = fileRef;
}
}
}
// Parse hardware items
var items = doc.SelectNodes("//ovf:VirtualHardwareSection/ovf:Item", nsm);
if (items != null)
{
foreach (XmlNode item in items)
{
var resourceTypeNode = item.SelectSingleNode("rasd:ResourceType", nsm);
if (resourceTypeNode == null) continue;
if (!int.TryParse(resourceTypeNode.InnerText.Trim(), out int resourceType))
continue;
switch (resourceType)
{
case 3: // Processor
var vcpuNode = item.SelectSingleNode("rasd:VirtualQuantity", nsm);
if (vcpuNode != null && int.TryParse(vcpuNode.InnerText.Trim(), out int vcpus))
metadata.CpuCount = vcpus;
break;
case 4: // Memory
var memNode = item.SelectSingleNode("rasd:VirtualQuantity", nsm);
var unitsNode = item.SelectSingleNode("rasd:AllocationUnits", nsm);
if (memNode != null && long.TryParse(memNode.InnerText.Trim(), out long memVal))
{
var units = unitsNode?.InnerText?.Trim() ?? "byte * 2^20";
metadata.MemoryMB = ConvertToMB(memVal, units);
}
break;
case 6: // Parallel SCSI HBA (sometimes used as SATA controller)
case 5: // IDE Controller
case 20: // SCSI/SAS controller (storage)
// Controllers themselves don't produce disk entries; skip.
break;
case 17: // Disk Drive
var diskRef = ExtractDiskReference(item, nsm, diskFileMap, fileRefs);
if (diskRef != null)
{
// Determine bus type from parent controller
diskRef.BusType = DetermineBusType(item, items, nsm);
metadata.Disks.Add(diskRef);
}
break;
case 10: // Ethernet Adapter
var adapterName = item.SelectSingleNode("rasd:ElementName", nsm)?.InnerText
?? item.SelectSingleNode("rasd:Caption", nsm)?.InnerText
?? "Network adapter";
var connection = item.SelectSingleNode("rasd:Connection", nsm)?.InnerText ?? string.Empty;
metadata.NetworkAdapters.Add(new OvfNetworkAdapter
{
AdapterName = adapterName,
ConnectionName = connection
});
break;
}
}
}
return metadata;
}
private static OvfDiskReference? ExtractDiskReference(
XmlNode item,
XmlNamespaceManager nsm,
Dictionary<string, string> diskFileMap,
Dictionary<string, string> fileRefs)
{
// The HostResource element typically contains a reference like "ovf:/disk/vmdisk1"
var hostResource = item.SelectSingleNode("rasd:HostResource", nsm)?.InnerText ?? string.Empty;
string? diskId = null;
if (hostResource.Contains("/disk/"))
{
var idx = hostResource.LastIndexOf("/disk/", StringComparison.Ordinal);
diskId = hostResource.Substring(idx + 6);
}
else if (hostResource.Contains("disk/"))
{
var idx = hostResource.LastIndexOf("disk/", StringComparison.Ordinal);
diskId = hostResource.Substring(idx + 5);
}
if (diskId != null && diskFileMap.TryGetValue(diskId, out var fileRef) && fileRefs.TryGetValue(fileRef, out var fileName))
{
return new OvfDiskReference { FileName = fileName };
}
return null;
}
private static string DetermineBusType(XmlNode diskItem, XmlNodeList allItems, XmlNamespaceManager nsm)
{
// Look at the Parent element to find which controller this disk is attached to
var parentNode = diskItem.SelectSingleNode("rasd:Parent", nsm);
if (parentNode == null)
return "scsi"; // default
var parentId = parentNode.InnerText.Trim();
foreach (XmlNode item in allItems)
{
var instanceId = item.SelectSingleNode("rasd:InstanceID", nsm)?.InnerText?.Trim();
if (instanceId != parentId) continue;
var resourceTypeNode = item.SelectSingleNode("rasd:ResourceType", nsm);
if (resourceTypeNode == null) continue;
if (int.TryParse(resourceTypeNode.InnerText.Trim(), out int rt))
{
switch (rt)
{
case 5: return "ide";
case 6: return "sata";
case 20: return "scsi";
}
}
break;
}
return "scsi"; // default fallback
}
private static int ConvertToMB(long value, string allocationUnits)
{
// Common OVF allocation units:
// "byte * 2^20" = MiB
// "byte * 2^30" = GiB
// "byte * 2^10" = KiB
// "MegaBytes" or "MB"
var lower = allocationUnits.ToLowerInvariant();
if (lower.Contains("2^30") || lower.Contains("gib") || lower.Contains("gigabyte"))
return (int)(value * 1024);
if (lower.Contains("2^20") || lower.Contains("mib") || lower.Contains("megabyte") || lower.Contains("mb"))
return (int)value;
if (lower.Contains("2^10") || lower.Contains("kib") || lower.Contains("kilobyte") || lower.Contains("kb"))
return (int)(value / 1024);
if (lower.Contains("byte"))
return (int)(value / (1024 * 1024));
// Default: assume MiB
return (int)value;
}
private static string MapOsType(string osDescription)
{
if (string.IsNullOrEmpty(osDescription))
return "other";
var lower = osDescription.ToLowerInvariant();
// Windows variants
if (lower.Contains("windows 11") || lower.Contains("win11"))
return "win11";
if (lower.Contains("windows 10") || lower.Contains("win10"))
return "win10";
if (lower.Contains("windows server 2022") || lower.Contains("2022"))
return "win11";
if (lower.Contains("windows server 2019") || lower.Contains("2019"))
return "win10";
if (lower.Contains("windows server 2016") || lower.Contains("2016"))
return "win10";
if (lower.Contains("windows 8") || lower.Contains("win8"))
return "win8";
if (lower.Contains("windows 7") || lower.Contains("win7"))
return "win7";
if (lower.Contains("windows"))
return "win10";
// Linux variants
if (lower.Contains("linux") || lower.Contains("ubuntu") || lower.Contains("debian") ||
lower.Contains("centos") || lower.Contains("rhel") || lower.Contains("red hat") ||
lower.Contains("fedora") || lower.Contains("suse") || lower.Contains("alma") ||
lower.Contains("rocky"))
return "l26";
// FreeBSD
if (lower.Contains("freebsd"))
return "l26";
// Solaris
if (lower.Contains("solaris"))
return "solaris";
// Try numeric OVF OS ID
if (int.TryParse(osDescription, out int osId))
{
// Common CIM OS IDs
if (osId >= 56 && osId <= 70) return "win10"; // Various Windows
if (osId >= 93 && osId <= 113) return "l26"; // Various Linux
if (osId == 36) return "l26"; // Linux
if (osId == 101 || osId == 106) return "l26"; // Linux 64-bit
}
return "other";
}
}
}
@@ -492,5 +492,50 @@ namespace PSProxmoxVE.Core.Services
.GetAwaiter().GetResult();
return JObject.Parse(response)["data"] as JObject ?? new JObject();
}
// -------------------------------------------------------------------------
// OVA Upload
// -------------------------------------------------------------------------
/// <summary>
/// Uploads an OVA file to storage with content=import. Returns the task UPID.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="node">The cluster node name.</param>
/// <param name="storage">The target storage identifier.</param>
/// <param name="ovaPath">The local path to the OVA file.</param>
/// <param name="progressCallback">
/// Optional callback invoked periodically with (bytesSent, totalBytes).
/// May be called from a background thread.
/// </param>
public PveTask UploadOva(
PveSession session,
string node,
string storage,
string ovaPath,
Action<long, long>? progressCallback = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(storage)) throw new ArgumentNullException(nameof(storage));
if (string.IsNullOrWhiteSpace(ovaPath)) throw new ArgumentNullException(nameof(ovaPath));
var formFields = new Dictionary<string, string>
{
["content"] = "import"
};
using var client = new PveHttpClient(session);
var response = client.UploadFileAsync(
$"nodes/{node}/storage/{storage}/upload",
ovaPath,
formFields,
progressCallback: progressCallback)
.GetAwaiter().GetResult();
var root = JObject.Parse(response);
var upid = root["data"]?.ToString() ?? string.Empty;
return new PveTask { Upid = upid, Node = node, Status = "running" };
}
}
}
@@ -0,0 +1,308 @@
using System;
using System.Collections.Generic;
using System.IO;
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 an OVA file into a Proxmox VE virtual machine.</para>
/// <para type="description">
/// Imports an OVA (Open Virtual Appliance) archive into Proxmox VE by:
/// 1. Parsing the embedded OVF descriptor to extract VM configuration (CPU, memory, disks, networks)
/// 2. Uploading the OVA to PVE storage with content=import
/// 3. Creating a new VM with the extracted (or overridden) configuration
/// 4. Importing each VMDK disk from the OVA into the target storage
/// 5. Configuring network adapters
///
/// Use -Name, -Memory, or -Cores to override the values discovered in the OVF.
/// Use -Wait to block until all import tasks complete.
/// </para>
/// </summary>
[Cmdlet(VerbsData.Import, "PveOva", SupportsShouldProcess = true)]
[OutputType(typeof(PveVm))]
public sealed class ImportPveOvaCmdlet : PveCmdletBase
{
/// <summary>
/// <para type="description">The Proxmox VE node to import the OVA on.</para>
/// </summary>
[Parameter(Mandatory = true, Position = 0, HelpMessage = "The PVE node name.")]
public string Node { get; set; } = string.Empty;
/// <summary>
/// <para type="description">The storage to upload the OVA file to (must support 'import' content type).</para>
/// </summary>
[Parameter(Mandatory = true, Position = 1, HelpMessage = "The storage pool for OVA upload.")]
public string Storage { get; set; } = string.Empty;
/// <summary>
/// <para type="description">The local path to the OVA file to import.</para>
/// </summary>
[Parameter(Mandatory = true, Position = 2, HelpMessage = "Local path to the OVA file.")]
[ValidateNotNullOrEmpty]
public string Path { get; set; } = string.Empty;
/// <summary>
/// <para type="description">The target storage for imported VM disks (e.g. "local-lvm").</para>
/// </summary>
[Parameter(Mandatory = true, HelpMessage = "Target storage for imported VM disks.")]
public string TargetStorage { get; set; } = string.Empty;
/// <summary>
/// <para type="description">The VM ID to assign. When omitted, the next available ID is auto-assigned.</para>
/// </summary>
[Parameter(Mandatory = false, HelpMessage = "The VM identifier. Auto-assigned if omitted.")]
[ValidateRange(100, 999999999)]
public int? VmId { get; set; }
/// <summary>
/// <para type="description">Override the VM name from the OVF descriptor.</para>
/// </summary>
[Parameter(Mandatory = false, HelpMessage = "Override the VM name from the OVF.")]
public string? Name { get; set; }
/// <summary>
/// <para type="description">Override the memory size (in MiB) from the OVF descriptor.</para>
/// </summary>
[Parameter(Mandatory = false, HelpMessage = "Override memory size in MiB.")]
public int? Memory { get; set; }
/// <summary>
/// <para type="description">Override the CPU core count from the OVF descriptor.</para>
/// </summary>
[Parameter(Mandatory = false, HelpMessage = "Override CPU core count.")]
public int? Cores { get; set; }
/// <summary>
/// <para type="description">When specified, waits for all tasks to complete before returning.</para>
/// </summary>
[Parameter(Mandatory = false, HelpMessage = "Wait for all tasks to complete before returning.")]
public SwitchParameter Wait { get; set; }
protected override void ProcessRecord()
{
// Validate the OVA file exists
if (!File.Exists(Path))
{
ThrowTerminatingError(new ErrorRecord(
new FileNotFoundException($"OVA file not found: {Path}"),
"OvaFileNotFound",
ErrorCategory.ObjectNotFound,
Path));
return;
}
// Step 1: Parse OVA to extract OVF metadata
WriteVerbose("Parsing OVA file to extract OVF metadata...");
OvfMetadata metadata;
try
{
metadata = OvfMetadata.FromOva(Path);
}
catch (Exception ex)
{
ThrowTerminatingError(new ErrorRecord(
ex,
"OvfParseError",
ErrorCategory.ParserError,
Path));
return;
}
// Step 2: Log discovered configuration
var vmName = Name ?? metadata.Name;
var vmMemory = Memory ?? metadata.MemoryMB;
var vmCores = Cores ?? metadata.CpuCount;
WriteVerbose($"OVF VM Name: {metadata.Name}");
WriteVerbose($"OVF CPU Cores: {metadata.CpuCount}");
WriteVerbose($"OVF Memory: {metadata.MemoryMB} MB");
WriteVerbose($"OVF OS Type: {metadata.OsTypeHint}");
WriteVerbose($"OVF Disks: {metadata.Disks.Count}");
foreach (var disk in metadata.Disks)
{
WriteVerbose($" Disk: {disk.FileName} (bus: {disk.BusType})");
}
WriteVerbose($"OVF Network Adapters: {metadata.NetworkAdapters.Count}");
foreach (var nic in metadata.NetworkAdapters)
{
WriteVerbose($" NIC: {nic.AdapterName} -> {nic.ConnectionName}");
}
if (!ShouldProcess($"OVA '{System.IO.Path.GetFileName(Path)}' on node '{Node}'", "Import-PveOva"))
return;
var session = GetSession();
var vmService = new VmService();
var taskService = new TaskService();
// Step 3: Auto-assign VM ID if not specified
int vmId;
if (VmId.HasValue)
{
vmId = VmId.Value;
}
else
{
using var allocClient = new PveHttpClient(session);
var nextIdJson = allocClient.GetAsync("cluster/nextid").GetAwaiter().GetResult();
var nextIdData = JObject.Parse(nextIdJson)["data"];
vmId = int.Parse(nextIdData!.ToString());
WriteVerbose($"Auto-assigned VM ID: {vmId}");
}
// Step 4: Upload OVA to storage with content=import
var fileName = System.IO.Path.GetFileName(Path);
var totalBytes = new FileInfo(Path).Length;
WriteVerbose($"Uploading OVA to {Node}/{Storage} (content=import)...");
var activityId = 1;
var progressRecord = new ProgressRecord(activityId,
$"Importing OVA to {Node}",
$"Uploading {fileName}...");
long progressBytes = 0;
var uploadTask = System.Threading.Tasks.Task.Run(() =>
vmService.UploadOva(session, Node, Storage, Path,
(bytesSent, _) =>
System.Threading.Interlocked.Exchange(ref progressBytes, bytesSent)));
// Poll progress on the pipeline thread
while (!uploadTask.IsCompleted)
{
System.Threading.Thread.Sleep(500);
if (totalBytes > 0)
{
var sent = System.Threading.Interlocked.Read(ref progressBytes);
progressRecord.PercentComplete = (int)((sent * 100L) / totalBytes);
progressRecord.StatusDescription =
$"Uploading: {sent / 1024 / 1024} MB / {totalBytes / 1024 / 1024} MB";
WriteProgress(progressRecord);
}
}
var uploadResult = uploadTask.GetAwaiter().GetResult();
progressRecord.RecordType = ProgressRecordType.Completed;
WriteProgress(progressRecord);
// Wait for upload task to complete on PVE side
if (!string.IsNullOrEmpty(uploadResult.Upid))
{
WriteVerbose("Waiting for OVA upload task to complete on PVE...");
var completedUpload = taskService.WaitForTask(session, Node, uploadResult.Upid, null, null, null);
if (completedUpload.ExitStatus != null && completedUpload.ExitStatus != "OK")
{
ThrowTerminatingError(new ErrorRecord(
new InvalidOperationException($"OVA upload task failed with status: {completedUpload.ExitStatus}"),
"OvaUploadFailed",
ErrorCategory.InvalidResult,
uploadResult.Upid));
return;
}
}
// Step 5: Create the VM
WriteVerbose($"Creating VM {vmId} on node '{Node}'...");
var vmConfig = new Dictionary<string, object>
{
["vmid"] = vmId
};
if (!string.IsNullOrEmpty(vmName))
vmConfig["name"] = vmName;
vmConfig["memory"] = vmMemory;
vmConfig["cores"] = vmCores;
if (!string.IsNullOrEmpty(metadata.OsTypeHint) && metadata.OsTypeHint != "other")
vmConfig["ostype"] = metadata.OsTypeHint;
var createTask = vmService.CreateVm(session, Node, vmConfig);
if (!string.IsNullOrEmpty(createTask.Upid))
{
WriteVerbose("Waiting for VM creation task to complete...");
var completedCreate = taskService.WaitForTask(session, Node, createTask.Upid, null, null, null);
if (completedCreate.ExitStatus != null && completedCreate.ExitStatus != "OK")
{
ThrowTerminatingError(new ErrorRecord(
new InvalidOperationException($"VM creation task failed with status: {completedCreate.ExitStatus}"),
"VmCreationFailed",
ErrorCategory.InvalidResult,
createTask.Upid));
return;
}
}
// Step 6: Import each disk from the OVA
var importProgressRecord = new ProgressRecord(2,
$"Importing OVA disks to VM {vmId}",
"Importing disks...");
for (int i = 0; i < metadata.Disks.Count; i++)
{
var disk = metadata.Disks[i];
var diskSlot = $"{disk.BusType}{i}";
var importFrom = $"{Storage}:import/{fileName}/{disk.FileName}";
importProgressRecord.PercentComplete = (int)((i * 100L) / metadata.Disks.Count);
importProgressRecord.StatusDescription = $"Importing disk {i + 1}/{metadata.Disks.Count}: {disk.FileName} -> {diskSlot}";
WriteProgress(importProgressRecord);
WriteVerbose($"Importing disk: {disk.FileName} -> VM {vmId} slot {diskSlot} (from {importFrom})");
var diskTask = vmService.ImportDisk(session, Node, vmId, diskSlot, TargetStorage, importFrom);
if (Wait.IsPresent && !string.IsNullOrEmpty(diskTask.Upid))
{
WriteVerbose($"Waiting for disk import task to complete: {diskSlot}...");
var completedDisk = taskService.WaitForTask(session, Node, diskTask.Upid, null, null, null);
if (completedDisk.ExitStatus != null && completedDisk.ExitStatus != "OK")
{
WriteWarning($"Disk import for {diskSlot} completed with status: {completedDisk.ExitStatus}");
}
}
}
importProgressRecord.RecordType = ProgressRecordType.Completed;
WriteProgress(importProgressRecord);
// Step 7: Configure network adapters
if (metadata.NetworkAdapters.Count > 0)
{
var netConfig = new Dictionary<string, object>();
for (int i = 0; i < metadata.NetworkAdapters.Count; i++)
{
// Use virtio model with default bridge vmbr0
netConfig[$"net{i}"] = "virtio,bridge=vmbr0";
}
WriteVerbose($"Configuring {metadata.NetworkAdapters.Count} network adapter(s)...");
vmService.SetVmConfig(session, Node, vmId, netConfig);
}
// Step 8: Output the created VM
WriteVerbose("Retrieving created VM...");
try
{
var vm = vmService.GetVm(session, Node, vmId);
WriteObject(vm);
}
catch
{
// If the VM isn't queryable yet (e.g. disk import still running), return basic info
WriteObject(new PveVm
{
VmId = vmId,
Name = vmName,
Node = Node,
Status = "stopped"
});
}
}
}
}
+1
View File
@@ -78,6 +78,7 @@
'Set-PveVmConfig',
'Resize-PveVmDisk',
'Import-PveVmDisk',
'Import-PveOva',
# QEMU Guest Agent
'Test-PveVmGuestAgent',
@@ -0,0 +1,145 @@
#Requires -Module Pester
<#
.SYNOPSIS
Pester 5 tests for Import-PveOva.
All tests are fully offline — no live Proxmox VE target is required.
#>
BeforeAll {
. $PSScriptRoot/../_TestHelper.ps1
$script:Cmd = Get-Command 'Import-PveOva' -ErrorAction SilentlyContinue
$script:Available = $null -ne $script:Cmd
function Skip-IfMissing {
if (-not $script:Available) {
Set-ItResult -Skipped -Because "Import-PveOva is not yet implemented in this build"
}
}
}
Describe 'Import-PveOva — 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-PveOva'
}
}
Describe 'Import-PveOva' {
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
}
It 'Should support Confirm' {
Skip-IfMissing
$script:Cmd.Parameters.ContainsKey('Confirm') | 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 'Storage should be Mandatory' {
Skip-IfMissing
$isMandatory = $script:Cmd.Parameters['Storage'].ParameterSets.Values |
Where-Object { $_.IsMandatory }
$isMandatory | Should -Not -BeNullOrEmpty
}
It 'Path should be Mandatory' {
Skip-IfMissing
$isMandatory = $script:Cmd.Parameters['Path'].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
}
}
Context 'Optional parameters' {
It 'Should have VmId parameter' {
Skip-IfMissing
$script:Cmd.Parameters.ContainsKey('VmId') | Should -BeTrue
}
It 'VmId should not be Mandatory' {
Skip-IfMissing
$allMandatory = $script:Cmd.Parameters['VmId'].ParameterSets.Values |
Where-Object { $_.IsMandatory }
$allMandatory | Should -BeNullOrEmpty
}
It 'Should have Name parameter' {
Skip-IfMissing
$script:Cmd.Parameters.ContainsKey('Name') | Should -BeTrue
}
It 'Should have Memory parameter' {
Skip-IfMissing
$script:Cmd.Parameters.ContainsKey('Memory') | Should -BeTrue
}
It 'Should have Cores parameter' {
Skip-IfMissing
$script:Cmd.Parameters.ContainsKey('Cores') | 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 'Output type' {
It 'Should declare PveVm as output type' {
Skip-IfMissing
$outputTypes = $script:Cmd.OutputType | ForEach-Object { $_.Type.Name }
$outputTypes | Should -Contain 'PveVm'
}
}
Context 'Without active session' {
It 'Should throw when no session is active and file does not exist' {
Skip-IfMissing
{ Import-PveOva -Node 'pve1' -Storage 'local' -Path '/nonexistent/file.ova' `
-TargetStorage 'local-lvm' -Confirm:$false -ErrorAction Stop } |
Should -Throw
}
}
}