mirror of
https://github.com/GoodOlClint/PSProxmoxVE.git
synced 2026-07-26 07:58:14 +00:00
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:
@@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user