fix: resolve all integration test failures against live PVE

- BaseUrl trailing slash and resource path cleanup: PveSession.BaseUrl now
  ends with '/', all cmdlet resource strings stripped of leading slashes and
  /api2/json/ prefixes so URLs compose correctly
- PveNodeStatus: rename Name -> Node to match JSON field and test expectations
- NewPveVmCmdlet: auto-allocate vmid via GET cluster/nextid when -VmId omitted
- UploadFileAsync (BZ 7389 workarounds):
  * Unquoted multipart boundary in Content-Type header
  * Quoted name= values in Content-Disposition (embedded double-quotes)
  * Content-Disposition set before Content-Type on file part — PVE's parser
    closes the connection if Content-Type appears first
- SendPveIsoCmdlet: run upload in Task.Run, track progress atomically with
  Interlocked, poll and call WriteProgress only from pipeline thread to avoid
  InvalidOperationException from PSCmdlet thread-affinity requirements
- Add debug/Capture-UploadDiff.ps1: mitmproxy capture script used to isolate
  the header-ordering bug by diffing raw multipart bytes from PS vs C# module

Integration test result: 11 passed, 0 failed, 4 skipped (expected — no
template/stopped VMs on bare nested PVE test node).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-18 12:57:21 -05:00
parent 94b0fcdad2
commit 8ea983c0fc
36 changed files with 384 additions and 181 deletions
+3
View File
@@ -0,0 +1,3 @@
*.flow
_analyze.py
run_analyze.py
+190
View File
@@ -0,0 +1,190 @@
#Requires -Version 7.0
# Capture-UploadDiff.ps1
# Starts mitmproxy, captures working PS approach vs C# module upload,
# then prints a side-by-side diff of the multipart framing.
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$DebugDir = $PSScriptRoot
$ProxyPort = 8080
$TestFile = '/tmp/pvetest.iso'
$UploadUri = 'https://172.16.100.205:8006/api2/json/nodes/pve/storage/local/upload'
$AuthHeader = 'PVEAPIToken=root@pam!integration=4c4b598b-fef2-4e6f-8471-316b35ff40c3'
$ModuleDll = '/Users/goodolclint/Source/PSProxmoxVE/src/PSProxmoxVE/bin/Debug/net9.0/PSProxmoxVE.dll'
if (-not (Test-Path $TestFile)) {
Write-Error "Test file not found: $TestFile"
}
# ---------------------------------------------------------------------------
function Start-Mitmdump([string]$FlowFile) {
Get-Process mitmdump -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
Remove-Item $FlowFile -ErrorAction SilentlyContinue
$p = Start-Process mitmdump -ArgumentList @(
'--mode', 'regular',
'--ssl-insecure',
'--listen-port', $ProxyPort,
'-w', $FlowFile
) -PassThru -RedirectStandardOutput '/tmp/mitmdump-out.log' -RedirectStandardError '/tmp/mitmdump-err.log'
Start-Sleep -Seconds 2
if ($p.HasExited) {
Write-Error "mitmdump failed to start: $(Get-Content /tmp/mitmdump-err.log -Raw)"
}
Write-Host " mitmdump PID $($p.Id)" -ForegroundColor DarkGray
return $p
}
function Stop-Mitmdump($p) {
Start-Sleep -Seconds 1
if (-not $p.HasExited) { $p.Kill(); $p.WaitForExit(3000) | Out-Null }
}
# ---------------------------------------------------------------------------
Write-Host '=== Step 1: Capture working PowerShell HttpClient upload ===' -ForegroundColor Cyan
$psFlow = "$DebugDir/ps-module-capture.flow"
$mitm = Start-Mitmdump $psFlow
try {
$handler = [System.Net.Http.HttpClientHandler]::new()
$handler.ServerCertificateCustomValidationCallback = [System.Net.Http.HttpClientHandler]::DangerousAcceptAnyServerCertificateValidator
$handler.Proxy = [System.Net.WebProxy]::new("http://localhost:$ProxyPort")
$handler.UseProxy = $true
$client = [System.Net.Http.HttpClient]::new($handler)
$client.Timeout = [TimeSpan]::FromMinutes(5)
$client.DefaultRequestHeaders.TryAddWithoutValidation('Authorization', $AuthHeader) | Out-Null
$boundary = 'PSDirectBoundary12345678'
$content = [System.Net.Http.MultipartFormDataContent]::new($boundary)
$content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse("multipart/form-data; boundary=$boundary")
function New-TextPart([string]$name, [string]$value) {
$part = [System.Net.Http.StringContent]::new($value, [System.Text.Encoding]::UTF8)
$part.Headers.ContentType = $null
$part.Headers.ContentDisposition = [System.Net.Http.Headers.ContentDispositionHeaderValue]::new('form-data')
$part.Headers.ContentDisposition.Name = "`"$name`""
return $part
}
$content.Add((New-TextPart 'content' 'iso'))
$fileStream = [System.IO.File]::OpenRead($TestFile)
$fileName = [System.IO.Path]::GetFileName($TestFile)
$fileContent = [System.Net.Http.StreamContent]::new($fileStream)
$fileContent.Headers.ContentDisposition = [System.Net.Http.Headers.ContentDispositionHeaderValue]::new('form-data')
$fileContent.Headers.ContentDisposition.Name = '"filename"'
$fileContent.Headers.ContentDisposition.FileName = "`"$fileName`""
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/octet-stream')
$content.Add($fileContent)
Write-Host " Posting..."
$response = $client.PostAsync($UploadUri, $content).GetAwaiter().GetResult()
$body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
Write-Host " Status: $($response.StatusCode)" -ForegroundColor Green
Write-Host " Body: $body"
} catch {
Write-Host " ERROR: $_" -ForegroundColor Red
} finally {
if ($fileStream) { $fileStream.Dispose() }
if ($client) { $client.Dispose() }
}
Stop-Mitmdump $mitm
Write-Host " Saved: $psFlow" -ForegroundColor Green
# ---------------------------------------------------------------------------
Write-Host ''
Write-Host '=== Step 2: Capture C# module upload ===' -ForegroundColor Cyan
$moduleFlow = "$DebugDir/cs-module-capture.flow"
$mitm = Start-Mitmdump $moduleFlow
# Route HttpClient through proxy via env vars (.NET honors HTTPS_PROXY)
$env:HTTPS_PROXY = "http://localhost:$ProxyPort"
$env:HTTP_PROXY = "http://localhost:$ProxyPort"
try {
Import-Module $ModuleDll -Force -ErrorAction Stop
Connect-PveServer -Server 172.16.100.205 -Port 8006 `
-ApiToken 'root@pam!integration=4c4b598b-fef2-4e6f-8471-316b35ff40c3' `
-SkipCertificateCheck
Write-Host " Calling Send-PveIso..."
Send-PveIso -Node pve -Storage local -Path $TestFile -Confirm:$false -ErrorAction Stop
Write-Host " Upload succeeded!" -ForegroundColor Green
} catch {
Write-Host " ERROR: $_" -ForegroundColor Red
Write-Host " Type: $($_.Exception.GetType().FullName)"
if ($_.Exception.InnerException) {
Write-Host " Inner: $($_.Exception.InnerException.Message)"
}
} finally {
$env:HTTPS_PROXY = ''
$env:HTTP_PROXY = ''
}
Stop-Mitmdump $mitm
Write-Host " Saved: $moduleFlow" -ForegroundColor Green
# ---------------------------------------------------------------------------
Write-Host ''
Write-Host '=== Step 3: Diff multipart framing ===' -ForegroundColor Cyan
$analyzeScript = @'
import sys
from mitmproxy.io import FlowReader
def dump_flow(path, label):
print(f'\n{"=" * 70}')
print(f' {label}')
print(f'{"=" * 70}')
try:
with open(path, 'rb') as f:
reader = FlowReader(f)
flows = list(reader.stream())
if not flows:
print(' NO FLOWS CAPTURED')
return
for i, flow in enumerate(flows):
req = flow.request
print(f'\nFlow {i}: {req.method} {req.pretty_url}')
print(f'HTTP version: {req.http_version}')
print(f'\nRequest headers:')
for k, v in req.headers.items(multi=True):
display_v = v[:40] + '...[REDACTED]' if k.lower() == 'authorization' else v
print(f' {k}: {display_v}')
body = req.content
print(f'\nBody size: {len(body)} bytes')
# Show first 1500 bytes (multipart preamble before file data)
preview = body[:1500]
print(f'\nBody start (first 1500 bytes):')
for line in preview.split(b'\r\n'):
try:
print(f' {line.decode("ascii")}')
except Exception:
if len(line) > 40:
print(f' [binary: {len(line)} bytes]')
else:
print(f' {line!r}')
if flow.response:
print(f'\nResponse: {flow.response.status_code} {flow.response.reason}')
body_text = flow.response.content[:300].decode('utf-8', errors='replace')
print(f' Body: {body_text}')
else:
print('\nResponse: NONE (connection reset/failed)')
except Exception as e:
print(f'Error reading {path}: {e}')
dump_flow(sys.argv[1], 'WORKING: PS Direct HttpClient')
dump_flow(sys.argv[2], 'FAILING: C# Module (Send-PveIso)')
'@
$py = "$DebugDir/_analyze.py"
Set-Content -Path $py -Value $analyzeScript
python3 $py $psFlow $moduleFlow
Write-Host ''
Write-Host '=== Done ===' -ForegroundColor Cyan
@@ -95,7 +95,7 @@ namespace PSProxmoxVE.Core.Authentication
string responseBody;
using (var httpClient = new PveHttpClient(session))
{
responseBody = httpClient.Get("/api2/json/version");
responseBody = httpClient.Get("version");
}
var json = JObject.Parse(responseBody);
@@ -28,7 +28,7 @@ namespace PSProxmoxVE.Core.Authentication
}
/// <summary>Base URL for the Proxmox VE API</summary>
public string BaseUrl => $"https://{Hostname}:{Port}/api2/json";
public string BaseUrl => $"https://{Hostname}:{Port}/api2/json/";
/// <summary>Creates a session using ticket-based authentication</summary>
internal PveSession(
+105 -121
View File
@@ -174,26 +174,22 @@ namespace PSProxmoxVE.Core.Client
// -------------------------------------------------------------------------
/// <summary>
/// Uploads a file (e.g. an ISO) to a Proxmox VE storage endpoint using a manually
/// constructed multipart/form-data body.
/// Uploads a file (e.g. an ISO) to a Proxmox VE storage endpoint using
/// MultipartFormDataContent.
///
/// IMPORTANT — Bugzilla 7389 workaround:
/// https://bugzilla.proxmox.com/show_bug.cgi?id=7389
///
/// The Proxmox VE API rejects uploads when the multipart body contains
/// "Content-Type" or "Content-Transfer-Encoding" headers on the file part,
/// which is exactly what .NET's MultipartFormDataContent emits by default.
/// To work around this, this method constructs the raw multipart body manually:
/// "Content-Type" or "Content-Transfer-Encoding" headers on text parts,
/// and also rejects a quoted boundary in the Content-Type header.
///
/// * Text (form field) parts: only Content-Disposition — no Content-Type,
/// no Content-Transfer-Encoding.
/// * File (binary) part: Content-Disposition + Content-Type: application/octet-stream
/// no Content-Transfer-Encoding.
///
/// Do NOT refactor this to use MultipartFormDataContent unless the PVE API
/// behaviour has been verified to have changed.
/// Workaround:
/// * Override the multipart Content-Type header to use an unquoted boundary.
/// * Set ContentType = null on all StringContent text parts before adding them.
/// * Use StreamContent for the file part (no Content-Transfer-Encoding added).
/// </summary>
/// <param name="resource">Relative API resource path, e.g. "/nodes/pve/storage/local/upload"</param>
/// <param name="resource">Relative API resource path, e.g. "nodes/pve/storage/local/upload"</param>
/// <param name="filePath">Absolute local path to the file to upload</param>
/// <param name="formFields">Additional form fields (e.g. content type)</param>
/// <param name="checksum">Optional file checksum value</param>
@@ -216,54 +212,86 @@ namespace PSProxmoxVE.Core.Client
if (!File.Exists(filePath))
throw new FileNotFoundException("Upload file not found.", filePath);
// Build a random 32-char alphanumeric boundary
var boundary = GenerateBoundary();
var fileName = Path.GetFileName(filePath);
var fileInfo = new FileInfo(filePath);
var totalBytes = fileInfo.Length;
var totalBytes = new FileInfo(filePath).Length;
// Build the multipart body as a MemoryStream / streaming approach.
// We write preamble + form fields into a MemoryStream, then stream the
// file content, then write the closing boundary — all via a custom
// stream that concatenates them.
var multipart = new MultipartFormDataContent(boundary);
var preamble = BuildMultipartPreamble(boundary, fileName, formFields, checksum, checksumAlgorithm);
var closing = Encoding.UTF8.GetBytes($"\r\n--{boundary}--\r\n");
// Override Content-Type to use unquoted boundary (PVE rejects quoted boundaries).
multipart.Headers.ContentType =
MediaTypeHeaderValue.Parse($"multipart/form-data; boundary={boundary}");
// Total upload size (preamble + file content + closing)
var uploadSize = preamble.Length + totalBytes + closing.Length;
var content = new PushStreamContent(async (outputStream) =>
// Add text form fields.
// IMPORTANT — BZ 7389 workaround: PVE requires quoted name= values in
// Content-Disposition (e.g. name="content"), but .NET's
// MultipartFormDataContent.Add(part, name) emits name=content (unquoted),
// which PVE rejects with a broken-pipe / stream-copy error.
// Fix: set ContentDisposition manually with embedded quotes, then call
// multipart.Add(part) without a name so the header is not overwritten.
if (formFields != null)
{
// Write preamble
await outputStream.WriteAsync(preamble, 0, preamble.Length).ConfigureAwait(false);
// Stream file in 4 MB chunks, reporting progress
const int chunkSize = 4 * 1024 * 1024;
var buffer = new byte[chunkSize];
long bytesSent = preamble.Length;
using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, chunkSize, useAsync: true))
foreach (var kvp in formFields)
{
int bytesRead;
while ((bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0)
var part = new StringContent(kvp.Value, Encoding.UTF8);
part.Headers.ContentType = null;
part.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data")
{
await outputStream.WriteAsync(buffer, 0, bytesRead).ConfigureAwait(false);
bytesSent += bytesRead;
progressCallback?.Invoke(bytesSent, uploadSize);
}
Name = $"\"{kvp.Key}\""
};
multipart.Add(part);
}
}
// Write closing boundary
await outputStream.WriteAsync(closing, 0, closing.Length).ConfigureAwait(false);
}, uploadSize);
if (!string.IsNullOrEmpty(checksumAlgorithm) && !string.IsNullOrEmpty(checksum))
{
var algPart = new StringContent(checksumAlgorithm!, Encoding.UTF8);
algPart.Headers.ContentType = null;
algPart.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "\"checksum-algorithm\"" };
multipart.Add(algPart);
}
content.Headers.ContentType = MediaTypeHeaderValue.Parse($"multipart/form-data; boundary=\"{boundary}\"");
if (!string.IsNullOrEmpty(checksum))
{
var csPart = new StringContent(checksum!, Encoding.UTF8);
csPart.Headers.ContentType = null;
csPart.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "\"checksum\"" };
multipart.Add(csPart);
}
var request = BuildRequest(HttpMethod.Post, resource, mutating: true);
request.Content = content;
// File part — StreamContent does not add Content-Transfer-Encoding.
var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: 4 * 1024 * 1024, useAsync: true);
try
{
Stream uploadStream = progressCallback != null
? (Stream)new ProgressStream(fileStream, totalBytes, progressCallback)
: fileStream;
return await SendAsync(request, resource, "POST").ConfigureAwait(false);
// ContentDisposition MUST come before ContentType in the part headers.
// PVE closes the connection if ContentType appears first (server-side parse order sensitivity).
var fileContent = new StreamContent(uploadStream);
fileContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data")
{
Name = "\"filename\"",
FileName = $"\"{fileName}\""
};
fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse("application/octet-stream");
multipart.Add(fileContent);
var request = BuildRequest(HttpMethod.Post, resource, mutating: true);
request.Content = multipart;
return await SendAsync(request, resource, "POST").ConfigureAwait(false);
}
finally
{
#if NET48
fileStream.Dispose();
#else
await fileStream.DisposeAsync().ConfigureAwait(false);
#endif
}
}
// -------------------------------------------------------------------------
@@ -366,64 +394,6 @@ namespace PSProxmoxVE.Core.Client
return sb.ToString();
}
/// <summary>
/// Builds the multipart preamble bytes: all form fields + the beginning of the file part
/// (Content-Disposition + Content-Type only; no Content-Transfer-Encoding per bugzilla 7389).
/// </summary>
private static byte[] BuildMultipartPreamble(
string boundary,
string fileName,
Dictionary<string, string>? formFields,
string? checksum,
string? checksumAlgorithm)
{
var sb = new StringBuilder();
// Text form fields — Content-Disposition only, no Content-Type
if (formFields != null)
{
foreach (var kvp in formFields)
{
sb.Append($"--{boundary}\r\n");
sb.Append($"Content-Disposition: form-data; name=\"{kvp.Key}\"\r\n");
sb.Append("\r\n");
sb.Append(kvp.Value);
sb.Append("\r\n");
}
}
if (!string.IsNullOrEmpty(checksum) && !string.IsNullOrEmpty(checksumAlgorithm))
{
sb.Append($"--{boundary}\r\n");
sb.Append($"Content-Disposition: form-data; name=\"checksum-algorithm\"\r\n");
sb.Append("\r\n");
sb.Append(checksumAlgorithm);
sb.Append("\r\n");
sb.Append($"--{boundary}\r\n");
sb.Append($"Content-Disposition: form-data; name=\"checksum\"\r\n");
sb.Append("\r\n");
sb.Append(checksum);
sb.Append("\r\n");
}
else if (!string.IsNullOrEmpty(checksum))
{
sb.Append($"--{boundary}\r\n");
sb.Append($"Content-Disposition: form-data; name=\"checksum\"\r\n");
sb.Append("\r\n");
sb.Append(checksum);
sb.Append("\r\n");
}
// File part header — Content-Disposition + Content-Type; no Content-Transfer-Encoding
sb.Append($"--{boundary}\r\n");
sb.Append($"Content-Disposition: form-data; name=\"filename\"; filename=\"{fileName}\"\r\n");
sb.Append("Content-Type: application/octet-stream\r\n");
sb.Append("\r\n");
return Encoding.UTF8.GetBytes(sb.ToString());
}
public void Dispose()
{
if (!_disposed)
@@ -434,34 +404,48 @@ namespace PSProxmoxVE.Core.Client
}
// -------------------------------------------------------------------------
// Helper: push-stream HttpContent for streaming uploads
// Helper: progress-reporting stream wrapper for uploads
// -------------------------------------------------------------------------
/// <summary>
/// An <see cref="HttpContent"/> implementation that writes its body by invoking a
/// caller-supplied async delegate, enabling streaming without buffering the entire
/// payload into memory.
/// Wraps an inner stream and invokes a progress callback as bytes are read,
/// enabling upload progress reporting for file uploads.
/// </summary>
private sealed class PushStreamContent : HttpContent
private sealed class ProgressStream : Stream
{
private readonly Func<Stream, Task> _onStreamAvailable;
private readonly long _contentLength;
private readonly Stream _inner;
private readonly long _totalBytes;
private readonly Action<long, long> _callback;
private long _bytesRead;
public PushStreamContent(Func<Stream, Task> onStreamAvailable, long contentLength)
public ProgressStream(Stream inner, long totalBytes, Action<long, long> callback)
{
_onStreamAvailable = onStreamAvailable;
_contentLength = contentLength;
_inner = inner;
_totalBytes = totalBytes;
_callback = callback;
}
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
public override bool CanRead => _inner.CanRead;
public override bool CanSeek => _inner.CanSeek;
public override bool CanWrite => _inner.CanWrite;
public override long Length => _inner.Length;
public override long Position { get => _inner.Position; set => _inner.Position = value; }
public override void Flush() => _inner.Flush();
public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin);
public override void SetLength(long value) => _inner.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count);
public override int Read(byte[] buffer, int offset, int count)
{
await _onStreamAvailable(stream).ConfigureAwait(false);
var n = _inner.Read(buffer, offset, count);
if (n > 0) { _bytesRead += n; _callback(_bytesRead, _totalBytes); }
return n;
}
protected override bool TryComputeLength(out long length)
protected override void Dispose(bool disposing)
{
length = _contentLength;
return _contentLength >= 0;
if (disposing) _inner.Dispose();
base.Dispose(disposing);
}
}
}
@@ -15,7 +15,7 @@ public class PveNodeStatus
/// </summary>
[JsonPropertyName("node")]
[JsonProperty("node")]
public string Name { get; set; } = string.Empty;
public string Node { get; set; } = string.Empty;
/// <summary>
/// The current status of the node (e.g., "online" or "offline").
@@ -141,7 +141,7 @@ public class PveNodeStatus
var diskUsedGb = DiskUsed.HasValue ? $"{DiskUsed.Value / 1024 / 1024 / 1024} GB" : "N/A";
var diskTotalGb = DiskTotal.HasValue ? $"{DiskTotal.Value / 1024 / 1024 / 1024} GB" : "N/A";
var uptimeStr = Uptime.HasValue ? TimeSpan.FromSeconds(Uptime.Value).ToString(@"d\.hh\:mm\:ss") : "N/A";
return $"Node: {Name} | Status: {Status} | CPU: {cpuPct} | "
return $"Node: {Node} | Status: {Status} | CPU: {cpuPct} | "
+ $"Mem: {memUsedMb}/{memTotalMb} ({MemoryUsage:F1}%) | "
+ $"Disk: {diskUsedGb}/{diskTotalGb} | Uptime: {uptimeStr}";
}
@@ -30,7 +30,7 @@ namespace PSProxmoxVE.Cmdlets.CloudInit
var session = GetSession();
using var client = new PveHttpClient(session);
var json = client.GetAsync($"/nodes/{Node}/qemu/{VmId}/config").GetAwaiter().GetResult();
var json = client.GetAsync($"nodes/{Node}/qemu/{VmId}/config").GetAwaiter().GetResult();
var root = JObject.Parse(json);
var data = root["data"];
@@ -37,7 +37,7 @@ namespace PSProxmoxVE.Cmdlets.Connection
try
{
using var client = new PveHttpClient(session);
client.DeleteAsync("/api2/json/access/ticket").GetAwaiter().GetResult();
client.DeleteAsync("access/ticket").GetAwaiter().GetResult();
}
catch (Exception ex)
{
@@ -39,7 +39,7 @@ namespace PSProxmoxVE.Cmdlets.Network
var session = GetSession();
using var client = new PveHttpClient(session);
var resource = $"/nodes/{Node}/network";
var resource = $"nodes/{Node}/network";
if (!string.IsNullOrEmpty(Type))
resource += $"?type={Uri.EscapeDataString(Type)}";
@@ -29,7 +29,7 @@ namespace PSProxmoxVE.Cmdlets.Network
var session = GetSession();
using var client = new PveHttpClient(session);
var json = client.GetAsync("/cluster/sdn/vnets").GetAwaiter().GetResult();
var json = client.GetAsync("cluster/sdn/vnets").GetAwaiter().GetResult();
var root = JObject.Parse(json);
var data = root["data"] as JArray ?? new JArray();
@@ -24,7 +24,7 @@ namespace PSProxmoxVE.Cmdlets.Network
var session = GetSession();
using var client = new PveHttpClient(session);
var resource = "/cluster/sdn/zones";
var resource = "cluster/sdn/zones";
var json = client.GetAsync(resource).GetAwaiter().GetResult();
var root = JObject.Parse(json);
var data = root["data"] as JArray ?? new JArray();
@@ -34,7 +34,7 @@ namespace PSProxmoxVE.Cmdlets.Network
var session = GetSession();
using var client = new PveHttpClient(session);
var json = client.PutAsync($"/nodes/{Node}/network").GetAwaiter().GetResult();
var json = client.PutAsync($"nodes/{Node}/network").GetAwaiter().GetResult();
var root = JObject.Parse(json);
var upid = root["data"]?.ToString() ?? string.Empty;
@@ -51,7 +51,7 @@ namespace PSProxmoxVE.Cmdlets.Network
private static PveTask WaitForTask(PveHttpClient client, string node, string upid)
{
var encodedUpid = Uri.EscapeDataString(upid);
var statusResource = $"/nodes/{node}/tasks/{encodedUpid}/status";
var statusResource = $"nodes/{node}/tasks/{encodedUpid}/status";
while (true)
{
System.Threading.Thread.Sleep(2000);
@@ -88,7 +88,7 @@ namespace PSProxmoxVE.Cmdlets.Network
if (Autostart.IsPresent) data["autostart"] = "1";
if (!string.IsNullOrEmpty(Comments)) data["comments"] = Comments;
client.PostAsync($"/nodes/{Node}/network", data).GetAwaiter().GetResult();
client.PostAsync($"nodes/{Node}/network", data).GetAwaiter().GetResult();
}
}
}
@@ -51,7 +51,7 @@ namespace PSProxmoxVE.Cmdlets.Network
if (!string.IsNullOrEmpty(Alias)) data["alias"] = Alias;
if (VlanAware.IsPresent) data["vlanaware"] = "1";
client.PostAsync("/cluster/sdn/vnets", data).GetAwaiter().GetResult();
client.PostAsync("cluster/sdn/vnets", data).GetAwaiter().GetResult();
}
}
}
@@ -72,7 +72,7 @@ namespace PSProxmoxVE.Cmdlets.Network
if (!string.IsNullOrEmpty(DnsZone)) data["dnszone"] = DnsZone;
if (!string.IsNullOrEmpty(Ipam)) data["ipam"] = Ipam;
client.PostAsync("/cluster/sdn/zones", data).GetAwaiter().GetResult();
client.PostAsync("cluster/sdn/zones", data).GetAwaiter().GetResult();
}
}
}
@@ -29,7 +29,7 @@ namespace PSProxmoxVE.Cmdlets.Network
var session = GetSession();
using var client = new PveHttpClient(session);
client.DeleteAsync($"/nodes/{Node}/network/{Iface}").GetAwaiter().GetResult();
client.DeleteAsync($"nodes/{Node}/network/{Iface}").GetAwaiter().GetResult();
}
}
}
@@ -24,7 +24,7 @@ namespace PSProxmoxVE.Cmdlets.Network
var session = GetSession();
using var client = new PveHttpClient(session);
client.DeleteAsync($"/cluster/sdn/vnets/{Vnet}").GetAwaiter().GetResult();
client.DeleteAsync($"cluster/sdn/vnets/{Vnet}").GetAwaiter().GetResult();
}
}
}
@@ -25,7 +25,7 @@ namespace PSProxmoxVE.Cmdlets.Network
var session = GetSession();
using var client = new PveHttpClient(session);
client.DeleteAsync($"/cluster/sdn/zones/{Zone}").GetAwaiter().GetResult();
client.DeleteAsync($"cluster/sdn/zones/{Zone}").GetAwaiter().GetResult();
}
}
}
@@ -88,7 +88,7 @@ namespace PSProxmoxVE.Cmdlets.Network
if (Autostart.IsPresent) data["autostart"] = "1";
if (!string.IsNullOrEmpty(Comments)) data["comments"] = Comments;
client.PutAsync($"/nodes/{Node}/network/{Iface}", data).GetAwaiter().GetResult();
client.PutAsync($"nodes/{Node}/network/{Iface}", data).GetAwaiter().GetResult();
}
}
}
@@ -32,7 +32,7 @@ namespace PSProxmoxVE.Cmdlets.Nodes
try
{
using var client = new PveHttpClient(session);
responseBody = client.GetAsync("/api2/json/nodes").GetAwaiter().GetResult();
responseBody = client.GetAsync("nodes").GetAwaiter().GetResult();
}
catch (Exception ex)
{
@@ -31,7 +31,7 @@ namespace PSProxmoxVE.Cmdlets.Nodes
{
var session = GetSession();
var resource = $"/api2/json/nodes/{Uri.EscapeDataString(Node)}/status";
var resource = $"nodes/{Uri.EscapeDataString(Node)}/status";
string responseBody;
try
@@ -56,8 +56,8 @@ namespace PSProxmoxVE.Cmdlets.Nodes
?? throw new InvalidOperationException("Failed to deserialize node status.");
// The /nodes/{node}/status response does not include the node name; populate it.
if (string.IsNullOrEmpty(status.Name))
status.Name = Node;
if (string.IsNullOrEmpty(status.Node))
status.Node = Node;
WriteObject(status);
}
@@ -57,7 +57,7 @@ namespace PSProxmoxVE.Cmdlets.Snapshots
if (!string.IsNullOrEmpty(Description)) data["description"] = Description;
if (IncludeVmState.IsPresent) data["vmstate"] = "1";
var json = client.PostAsync($"/nodes/{Node}/qemu/{VmId}/snapshot", data).GetAwaiter().GetResult();
var json = client.PostAsync($"nodes/{Node}/qemu/{VmId}/snapshot", data).GetAwaiter().GetResult();
var root = JObject.Parse(json);
var upid = root["data"]?.ToString() ?? string.Empty;
@@ -74,7 +74,7 @@ namespace PSProxmoxVE.Cmdlets.Snapshots
private static PveTask WaitForTask(PveHttpClient client, string node, string upid)
{
var encodedUpid = Uri.EscapeDataString(upid);
var statusResource = $"/nodes/{node}/tasks/{encodedUpid}/status";
var statusResource = $"nodes/{node}/tasks/{encodedUpid}/status";
while (true)
{
System.Threading.Thread.Sleep(2000);
@@ -43,7 +43,7 @@ namespace PSProxmoxVE.Cmdlets.Snapshots
return;
using var client = new PveHttpClient(session);
var json = client.DeleteAsync($"/nodes/{Node}/qemu/{VmId}/snapshot/{Name}").GetAwaiter().GetResult();
var json = client.DeleteAsync($"nodes/{Node}/qemu/{VmId}/snapshot/{Name}").GetAwaiter().GetResult();
var root = JObject.Parse(json);
var upid = root["data"]?.ToString() ?? string.Empty;
@@ -60,7 +60,7 @@ namespace PSProxmoxVE.Cmdlets.Snapshots
private static PveTask WaitForTask(PveHttpClient client, string node, string upid)
{
var encodedUpid = Uri.EscapeDataString(upid);
var statusResource = $"/nodes/{node}/tasks/{encodedUpid}/status";
var statusResource = $"nodes/{node}/tasks/{encodedUpid}/status";
while (true)
{
System.Threading.Thread.Sleep(2000);
@@ -46,7 +46,7 @@ namespace PSProxmoxVE.Cmdlets.Snapshots
return;
using var client = new PveHttpClient(session);
var json = client.PostAsync($"/nodes/{Node}/qemu/{VmId}/snapshot/{Name}/rollback").GetAwaiter().GetResult();
var json = client.PostAsync($"nodes/{Node}/qemu/{VmId}/snapshot/{Name}/rollback").GetAwaiter().GetResult();
var root = JObject.Parse(json);
var upid = root["data"]?.ToString() ?? string.Empty;
@@ -63,7 +63,7 @@ namespace PSProxmoxVE.Cmdlets.Snapshots
private static PveTask WaitForTask(PveHttpClient client, string node, string upid)
{
var encodedUpid = Uri.EscapeDataString(upid);
var statusResource = $"/nodes/{node}/tasks/{encodedUpid}/status";
var statusResource = $"nodes/{node}/tasks/{encodedUpid}/status";
while (true)
{
System.Threading.Thread.Sleep(2000);
@@ -51,7 +51,7 @@ namespace PSProxmoxVE.Cmdlets.Storage
var session = GetSession();
using var client = new PveHttpClient(session);
var resource = $"/nodes/{Node}/storage/{Storage}/download-url";
var resource = $"nodes/{Node}/storage/{Storage}/download-url";
var data = new Dictionary<string, string>
{
["url"] = Url,
@@ -76,7 +76,7 @@ namespace PSProxmoxVE.Cmdlets.Storage
private static PveTask WaitForTask(PveHttpClient client, string node, string upid)
{
var encodedUpid = Uri.EscapeDataString(upid);
var statusResource = $"/nodes/{node}/tasks/{encodedUpid}/status";
var statusResource = $"nodes/{node}/tasks/{encodedUpid}/status";
while (true)
{
@@ -102,7 +102,7 @@ namespace PSProxmoxVE.Cmdlets.Storage
if (Shared.IsPresent) data["shared"] = "1";
if (Disable.IsPresent) data["disable"] = "1";
client.PostAsync("/storage", data).GetAwaiter().GetResult();
client.PostAsync("storage", data).GetAwaiter().GetResult();
// Return the storage object representing what was created
var storage = new PveStorage
@@ -27,7 +27,7 @@ namespace PSProxmoxVE.Cmdlets.Storage
var session = GetSession();
using var client = new PveHttpClient(session);
client.DeleteAsync($"/storage/{Storage}").GetAwaiter().GetResult();
client.DeleteAsync($"storage/{Storage}").GetAwaiter().GetResult();
}
}
}
@@ -58,33 +58,47 @@ namespace PSProxmoxVE.Cmdlets.Storage
var session = GetSession();
using var client = new PveHttpClient(session);
var resource = $"/nodes/{Node}/storage/{Storage}/upload";
var resource = $"nodes/{Node}/storage/{Storage}/upload";
var totalBytes = new System.IO.FileInfo(Path).Length;
var activityId = 1;
var progressRecord = new ProgressRecord(activityId,
$"Uploading ISO to {Node}/{Storage}",
$"Uploading {fileName}...");
var json = client.UploadFileAsync(
resource,
Path,
formFields: new System.Collections.Generic.Dictionary<string, string>
{
["content"] = "iso"
},
checksum: Checksum,
checksumAlgorithm: ChecksumAlgorithm,
progressCallback: (bytesSent, total) =>
{
if (total > 0)
// Track progress via an atomic counter updated from the upload thread.
// WriteProgress must be called from the pipeline thread — passing the
// callback directly would invoke it from the HTTP serialization thread
// and cause PowerShell to throw an InvalidOperationException mid-upload.
long progressBytes = 0;
var uploadTask = System.Threading.Tasks.Task.Run(() =>
client.UploadFileAsync(
resource,
Path,
formFields: new System.Collections.Generic.Dictionary<string, string>
{
var pct = (int)((bytesSent * 100L) / total);
progressRecord.PercentComplete = pct;
progressRecord.StatusDescription = $"{bytesSent / 1024 / 1024} MB / {total / 1024 / 1024} MB";
WriteProgress(progressRecord);
}
["content"] = "iso"
},
checksum: Checksum,
checksumAlgorithm: ChecksumAlgorithm,
progressCallback: (bytesSent, _) =>
System.Threading.Interlocked.Exchange(ref progressBytes, bytesSent)));
// Poll progress on the pipeline thread while the upload runs.
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 =
$"{sent / 1024 / 1024} MB / {totalBytes / 1024 / 1024} MB";
WriteProgress(progressRecord);
}
).GetAwaiter().GetResult();
}
var json = uploadTask.GetAwaiter().GetResult();
progressRecord.RecordType = ProgressRecordType.Completed;
WriteProgress(progressRecord);
@@ -105,7 +119,7 @@ namespace PSProxmoxVE.Cmdlets.Storage
private static PveTask WaitForTask(PveHttpClient client, string node, string upid)
{
var encodedUpid = Uri.EscapeDataString(upid);
var statusResource = $"/nodes/{node}/tasks/{encodedUpid}/status";
var statusResource = $"nodes/{node}/tasks/{encodedUpid}/status";
while (true)
{
@@ -53,7 +53,7 @@ namespace PSProxmoxVE.Cmdlets.Tasks
var deadline = Timeout.HasValue ? DateTime.UtcNow + Timeout.Value : DateTime.MaxValue;
var encodedUpid = Uri.EscapeDataString(Upid);
var statusResource = $"/nodes/{Node}/tasks/{encodedUpid}/status";
var statusResource = $"nodes/{Node}/tasks/{encodedUpid}/status";
// Derive a short human-readable description from the UPID for progress display
var taskDesc = Upid.Length > 50 ? Upid.Substring(0, 47) + "..." : Upid;
@@ -37,7 +37,7 @@ namespace PSProxmoxVE.Cmdlets.Templates
}
else
{
var nodesJson = client.GetAsync("/nodes").GetAwaiter().GetResult();
var nodesJson = client.GetAsync("nodes").GetAwaiter().GetResult();
var nodesRoot = JObject.Parse(nodesJson);
var nodesData = nodesRoot["data"] as JArray ?? new JArray();
foreach (var n in nodesData)
@@ -48,7 +48,7 @@ namespace PSProxmoxVE.Cmdlets.Templates
{
if (string.IsNullOrEmpty(node)) continue;
var json = client.GetAsync($"/nodes/{node}/qemu").GetAwaiter().GetResult();
var json = client.GetAsync($"nodes/{node}/qemu").GetAwaiter().GetResult();
var root = JObject.Parse(json);
var data = root["data"] as JArray ?? new JArray();
@@ -25,7 +25,7 @@ namespace PSProxmoxVE.Cmdlets.Users
var session = GetSession();
using var client = new PveHttpClient(session);
var json = client.GetAsync("/access/roles").GetAwaiter().GetResult();
var json = client.GetAsync("access/roles").GetAwaiter().GetResult();
var root = JObject.Parse(json);
var data = root["data"] as JArray ?? new JArray();
@@ -33,7 +33,7 @@ namespace PSProxmoxVE.Cmdlets.Users
var session = GetSession();
using var client = new PveHttpClient(session);
var json = client.GetAsync("/access/users").GetAwaiter().GetResult();
var json = client.GetAsync("access/users").GetAwaiter().GetResult();
var root = JObject.Parse(json);
var data = root["data"] as JArray ?? new JArray();
@@ -40,7 +40,7 @@ namespace PSProxmoxVE.Cmdlets.Users
};
if (!string.IsNullOrEmpty(Privileges)) data["privs"] = Privileges;
client.PostAsync("/access/roles", data).GetAwaiter().GetResult();
client.PostAsync("access/roles", data).GetAwaiter().GetResult();
WriteObject(new PveRole { RoleId = RoleId, Privileges = Privileges });
}
@@ -62,7 +62,7 @@ namespace PSProxmoxVE.Cmdlets.Users
if (Propagate.IsPresent) data["propagate"] = "1";
if (Delete.IsPresent) data["delete"] = "1";
client.PutAsync("/access/acl", data).GetAwaiter().GetResult();
client.PutAsync("access/acl", data).GetAwaiter().GetResult();
}
}
}
@@ -1,5 +1,7 @@
using System.Collections.Generic;
using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
@@ -129,7 +131,17 @@ namespace PSProxmoxVE.Cmdlets.Vms
var config = new Dictionary<string, object>();
if (VmId.HasValue)
{
config["vmid"] = VmId.Value;
}
else
{
// Auto-allocate the next available VM ID from the cluster.
using var allocClient = new PveHttpClient(session);
var nextIdJson = allocClient.GetAsync("cluster/nextid").GetAwaiter().GetResult();
var nextIdData = JObject.Parse(nextIdJson)["data"];
config["vmid"] = int.Parse(nextIdData!.ToString());
}
if (!string.IsNullOrEmpty(Name))
config["name"] = Name!;
if (Memory.HasValue)
@@ -72,7 +72,7 @@ namespace PSProxmoxVE.Core.Tests.Models
var data = JObject.Parse(json)["data"];
var status = data.ToObject<PveNodeStatus>();
Assert.NotNull(status);
Assert.Equal("pve1", status.Name);
Assert.Equal("pve1", status.Node);
Assert.Equal(68719476736L, status.MemoryTotal);
Assert.Equal(17179869184L, status.MemoryUsed);
Assert.Equal(864000L, status.Uptime);