feat: add Import-PveOva cmdlet for OVA appliance import

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-20 13:08:31 -05:00
parent 088221a5f1
commit ca0cd945a0
6 changed files with 996 additions and 0 deletions
@@ -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" };
}
}
}