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 + } + } +}