diff --git a/README.md b/README.md
index ef6c831..bf3c267 100644
--- a/README.md
+++ b/README.md
@@ -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 |
diff --git a/src/PSProxmoxVE.Core/Models/Vms/OvfMetadata.cs b/src/PSProxmoxVE.Core/Models/Vms/OvfMetadata.cs
new file mode 100644
index 0000000..ccc10d5
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Models/Vms/OvfMetadata.cs
@@ -0,0 +1,496 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Xml;
+
+namespace PSProxmoxVE.Core.Models.Vms
+{
+ ///
+ /// Represents a disk reference extracted from an OVF descriptor.
+ ///
+ public class OvfDiskReference
+ {
+ /// The bus type hint (ide, scsi, sata).
+ public string BusType { get; set; } = "scsi";
+
+ /// The VMDK filename within the OVA archive.
+ public string FileName { get; set; } = string.Empty;
+ }
+
+ ///
+ /// Represents a network adapter extracted from an OVF descriptor.
+ ///
+ public class OvfNetworkAdapter
+ {
+ /// The adapter name (e.g. "Network adapter 1").
+ public string AdapterName { get; set; } = string.Empty;
+
+ /// The connection name (e.g. "VM Network", "bridged").
+ public string ConnectionName { get; set; } = string.Empty;
+ }
+
+ ///
+ /// Metadata extracted from an OVF descriptor inside an OVA archive.
+ ///
+ public class OvfMetadata
+ {
+ /// The VM name from the OVF.
+ public string Name { get; set; } = string.Empty;
+
+ /// Number of CPU cores.
+ public int CpuCount { get; set; } = 1;
+
+ /// Memory in MB.
+ public int MemoryMB { get; set; } = 1024;
+
+ /// Disk references found in the OVF.
+ public List Disks { get; set; } = new List();
+
+ /// Network adapters found in the OVF.
+ public List NetworkAdapters { get; set; } = new List();
+
+ /// OS type hint from the OVF OperatingSystemSection.
+ 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";
+
+ ///
+ /// Parses an OVA file (TAR archive) and extracts OVF metadata.
+ ///
+ /// Path to the OVA file.
+ /// Parsed OVF metadata.
+ 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);
+ }
+
+ ///
+ /// Extracts the .ovf XML content from a TAR archive.
+ /// Uses System.Formats.Tar on .NET 9+ and manual TAR parsing on older frameworks.
+ ///
+ 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
+ ///
+ /// 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.
+ ///
+ 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
+
+ ///
+ /// Parses OVF XML and extracts VM metadata.
+ ///
+ 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(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(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 diskFileMap,
+ Dictionary 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";
+ }
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Services/VmService.cs b/src/PSProxmoxVE.Core/Services/VmService.cs
index 3d51eb2..66734e3 100644
--- a/src/PSProxmoxVE.Core/Services/VmService.cs
+++ b/src/PSProxmoxVE.Core/Services/VmService.cs
@@ -492,5 +492,50 @@ namespace PSProxmoxVE.Core.Services
.GetAwaiter().GetResult();
return JObject.Parse(response)["data"] as JObject ?? new JObject();
}
+
+ // -------------------------------------------------------------------------
+ // OVA Upload
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Uploads an OVA file to storage with content=import. Returns the task UPID.
+ ///
+ /// The authenticated PVE session.
+ /// The cluster node name.
+ /// The target storage identifier.
+ /// The local path to the OVA file.
+ ///
+ /// Optional callback invoked periodically with (bytesSent, totalBytes).
+ /// May be called from a background thread.
+ ///
+ public PveTask UploadOva(
+ PveSession session,
+ string node,
+ string storage,
+ string ovaPath,
+ Action? 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
+ {
+ ["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" };
+ }
}
}
diff --git a/src/PSProxmoxVE/Cmdlets/Vms/ImportPveOvaCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Vms/ImportPveOvaCmdlet.cs
new file mode 100644
index 0000000..d1947cd
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Vms/ImportPveOvaCmdlet.cs
@@ -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
+{
+ ///
+ /// Imports an OVA file into a Proxmox VE virtual machine.
+ ///
+ /// 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.
+ ///
+ ///
+ [Cmdlet(VerbsData.Import, "PveOva", SupportsShouldProcess = true)]
+ [OutputType(typeof(PveVm))]
+ public sealed class ImportPveOvaCmdlet : PveCmdletBase
+ {
+ ///
+ /// The Proxmox VE node to import the OVA on.
+ ///
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The PVE node name.")]
+ public string Node { get; set; } = string.Empty;
+
+ ///
+ /// The storage to upload the OVA file to (must support 'import' content type).
+ ///
+ [Parameter(Mandatory = true, Position = 1, HelpMessage = "The storage pool for OVA upload.")]
+ public string Storage { get; set; } = string.Empty;
+
+ ///
+ /// The local path to the OVA file to import.
+ ///
+ [Parameter(Mandatory = true, Position = 2, HelpMessage = "Local path to the OVA file.")]
+ [ValidateNotNullOrEmpty]
+ public string Path { get; set; } = string.Empty;
+
+ ///
+ /// The target storage for imported VM disks (e.g. "local-lvm").
+ ///
+ [Parameter(Mandatory = true, HelpMessage = "Target storage for imported VM disks.")]
+ public string TargetStorage { get; set; } = string.Empty;
+
+ ///
+ /// The VM ID to assign. When omitted, the next available ID is auto-assigned.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "The VM identifier. Auto-assigned if omitted.")]
+ [ValidateRange(100, 999999999)]
+ public int? VmId { get; set; }
+
+ ///
+ /// Override the VM name from the OVF descriptor.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Override the VM name from the OVF.")]
+ public string? Name { get; set; }
+
+ ///
+ /// Override the memory size (in MiB) from the OVF descriptor.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Override memory size in MiB.")]
+ public int? Memory { get; set; }
+
+ ///
+ /// Override the CPU core count from the OVF descriptor.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Override CPU core count.")]
+ public int? Cores { get; set; }
+
+ ///
+ /// When specified, waits for all tasks to complete before returning.
+ ///
+ [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
+ {
+ ["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();
+ 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"
+ });
+ }
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/PSProxmoxVE.psd1 b/src/PSProxmoxVE/PSProxmoxVE.psd1
index 984d182..0da34e8 100644
--- a/src/PSProxmoxVE/PSProxmoxVE.psd1
+++ b/src/PSProxmoxVE/PSProxmoxVE.psd1
@@ -78,6 +78,7 @@
'Set-PveVmConfig',
'Resize-PveVmDisk',
'Import-PveVmDisk',
+ 'Import-PveOva',
# QEMU Guest Agent
'Test-PveVmGuestAgent',
diff --git a/tests/PSProxmoxVE.Tests/Vms/ImportPveOva.Tests.ps1 b/tests/PSProxmoxVE.Tests/Vms/ImportPveOva.Tests.ps1
new file mode 100644
index 0000000..e7ceb49
--- /dev/null
+++ b/tests/PSProxmoxVE.Tests/Vms/ImportPveOva.Tests.ps1
@@ -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
+ }
+ }
+}