From 8ea983c0fc8803e49210d1326caf8f17c4908fec Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 18 Mar 2026 12:57:21 -0500 Subject: [PATCH] fix: resolve all integration test failures against live PVE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- debug/.gitignore | 3 + debug/Capture-UploadDiff.ps1 | 190 +++++++++++++++ .../Authentication/PveAuthenticator.cs | 2 +- .../Authentication/PveSession.cs | 2 +- src/PSProxmoxVE.Core/Client/PveHttpClient.cs | 226 ++++++++---------- .../Models/Nodes/PveNodeStatus.cs | 4 +- .../CloudInit/GetPveCloudInitConfigCmdlet.cs | 2 +- .../Connection/DisconnectPveServerCmdlet.cs | 2 +- .../Cmdlets/Network/GetPveNetworkCmdlet.cs | 2 +- .../Cmdlets/Network/GetPveSdnVnetCmdlet.cs | 2 +- .../Cmdlets/Network/GetPveSdnZoneCmdlet.cs | 2 +- .../Network/InvokePveNetworkApplyCmdlet.cs | 4 +- .../Cmdlets/Network/NewPveNetworkCmdlet.cs | 2 +- .../Cmdlets/Network/NewPveSdnVnetCmdlet.cs | 2 +- .../Cmdlets/Network/NewPveSdnZoneCmdlet.cs | 2 +- .../Cmdlets/Network/RemovePveNetworkCmdlet.cs | 2 +- .../Cmdlets/Network/RemovePveSdnVnetCmdlet.cs | 2 +- .../Cmdlets/Network/RemovePveSdnZoneCmdlet.cs | 2 +- .../Cmdlets/Network/SetPveNetworkCmdlet.cs | 2 +- .../Cmdlets/Nodes/GetPveNodeCmdlet.cs | 2 +- .../Cmdlets/Nodes/GetPveNodeStatusCmdlet.cs | 6 +- .../Cmdlets/Snapshots/NewPveSnapshotCmdlet.cs | 4 +- .../Snapshots/RemovePveSnapshotCmdlet.cs | 4 +- .../Snapshots/RestorePveSnapshotCmdlet.cs | 4 +- .../Storage/InvokePveStorageDownloadCmdlet.cs | 4 +- .../Cmdlets/Storage/NewPveStorageCmdlet.cs | 2 +- .../Cmdlets/Storage/RemovePveStorageCmdlet.cs | 2 +- .../Cmdlets/Storage/SendPveIsoCmdlet.cs | 54 +++-- .../Cmdlets/Tasks/WaitPveTaskCmdlet.cs | 2 +- .../Cmdlets/Templates/GetPveTemplateCmdlet.cs | 4 +- .../Cmdlets/Users/GetPveRoleCmdlet.cs | 2 +- .../Cmdlets/Users/GetPveUserCmdlet.cs | 2 +- .../Cmdlets/Users/NewPveRoleCmdlet.cs | 2 +- .../Cmdlets/Users/SetPvePermissionCmdlet.cs | 2 +- src/PSProxmoxVE/Cmdlets/Vms/NewPveVmCmdlet.cs | 12 + .../Models/NodeModelTests.cs | 2 +- 36 files changed, 384 insertions(+), 181 deletions(-) create mode 100644 debug/.gitignore create mode 100644 debug/Capture-UploadDiff.ps1 diff --git a/debug/.gitignore b/debug/.gitignore new file mode 100644 index 0000000..d915a05 --- /dev/null +++ b/debug/.gitignore @@ -0,0 +1,3 @@ +*.flow +_analyze.py +run_analyze.py diff --git a/debug/Capture-UploadDiff.ps1 b/debug/Capture-UploadDiff.ps1 new file mode 100644 index 0000000..55115b6 --- /dev/null +++ b/debug/Capture-UploadDiff.ps1 @@ -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 diff --git a/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs b/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs index 47ee43a..5bcbfa7 100644 --- a/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs +++ b/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs @@ -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); diff --git a/src/PSProxmoxVE.Core/Authentication/PveSession.cs b/src/PSProxmoxVE.Core/Authentication/PveSession.cs index cdbb587..487d077 100644 --- a/src/PSProxmoxVE.Core/Authentication/PveSession.cs +++ b/src/PSProxmoxVE.Core/Authentication/PveSession.cs @@ -28,7 +28,7 @@ namespace PSProxmoxVE.Core.Authentication } /// Base URL for the Proxmox VE API - public string BaseUrl => $"https://{Hostname}:{Port}/api2/json"; + public string BaseUrl => $"https://{Hostname}:{Port}/api2/json/"; /// Creates a session using ticket-based authentication internal PveSession( diff --git a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs index 992ece9..c9cf102 100644 --- a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs +++ b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs @@ -174,26 +174,22 @@ namespace PSProxmoxVE.Core.Client // ------------------------------------------------------------------------- /// - /// 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). /// - /// Relative API resource path, e.g. "/nodes/pve/storage/local/upload" + /// Relative API resource path, e.g. "nodes/pve/storage/local/upload" /// Absolute local path to the file to upload /// Additional form fields (e.g. content type) /// Optional file checksum value @@ -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(); } - /// - /// 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). - /// - private static byte[] BuildMultipartPreamble( - string boundary, - string fileName, - Dictionary? 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 // ------------------------------------------------------------------------- /// - /// An 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. /// - private sealed class PushStreamContent : HttpContent + private sealed class ProgressStream : Stream { - private readonly Func _onStreamAvailable; - private readonly long _contentLength; + private readonly Stream _inner; + private readonly long _totalBytes; + private readonly Action _callback; + private long _bytesRead; - public PushStreamContent(Func onStreamAvailable, long contentLength) + public ProgressStream(Stream inner, long totalBytes, Action 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); } } } diff --git a/src/PSProxmoxVE.Core/Models/Nodes/PveNodeStatus.cs b/src/PSProxmoxVE.Core/Models/Nodes/PveNodeStatus.cs index 2d11423..4f67d9e 100644 --- a/src/PSProxmoxVE.Core/Models/Nodes/PveNodeStatus.cs +++ b/src/PSProxmoxVE.Core/Models/Nodes/PveNodeStatus.cs @@ -15,7 +15,7 @@ public class PveNodeStatus /// [JsonPropertyName("node")] [JsonProperty("node")] - public string Name { get; set; } = string.Empty; + public string Node { get; set; } = string.Empty; /// /// 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}"; } diff --git a/src/PSProxmoxVE/Cmdlets/CloudInit/GetPveCloudInitConfigCmdlet.cs b/src/PSProxmoxVE/Cmdlets/CloudInit/GetPveCloudInitConfigCmdlet.cs index 3146978..691a149 100644 --- a/src/PSProxmoxVE/Cmdlets/CloudInit/GetPveCloudInitConfigCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/CloudInit/GetPveCloudInitConfigCmdlet.cs @@ -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"]; diff --git a/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs index eb9a711..fb27c6e 100644 --- a/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs @@ -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) { diff --git a/src/PSProxmoxVE/Cmdlets/Network/GetPveNetworkCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/GetPveNetworkCmdlet.cs index 045c5f7..31c0bdd 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/GetPveNetworkCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/GetPveNetworkCmdlet.cs @@ -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)}"; diff --git a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnVnetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnVnetCmdlet.cs index bd77a9a..450c7c3 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnVnetCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnVnetCmdlet.cs @@ -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(); diff --git a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnZoneCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnZoneCmdlet.cs index 7d3a146..8772187 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnZoneCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnZoneCmdlet.cs @@ -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(); diff --git a/src/PSProxmoxVE/Cmdlets/Network/InvokePveNetworkApplyCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/InvokePveNetworkApplyCmdlet.cs index f5d6b56..d4c03f3 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/InvokePveNetworkApplyCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/InvokePveNetworkApplyCmdlet.cs @@ -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); diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveNetworkCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveNetworkCmdlet.cs index 966656b..aafc2cd 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/NewPveNetworkCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveNetworkCmdlet.cs @@ -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(); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnVnetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnVnetCmdlet.cs index 6654d44..5ad6a6c 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnVnetCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnVnetCmdlet.cs @@ -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(); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnZoneCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnZoneCmdlet.cs index a680f2f..91f7da7 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnZoneCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnZoneCmdlet.cs @@ -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(); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Network/RemovePveNetworkCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/RemovePveNetworkCmdlet.cs index 97d4f00..ef0416d 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/RemovePveNetworkCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/RemovePveNetworkCmdlet.cs @@ -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(); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnVnetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnVnetCmdlet.cs index 538bbd6..679f79a 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnVnetCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnVnetCmdlet.cs @@ -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(); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnZoneCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnZoneCmdlet.cs index 52a3be7..02d7c5c 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnZoneCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnZoneCmdlet.cs @@ -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(); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Network/SetPveNetworkCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/SetPveNetworkCmdlet.cs index b1351c4..83fb9f4 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/SetPveNetworkCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/SetPveNetworkCmdlet.cs @@ -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(); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeCmdlet.cs index f4aa232..a8c63f1 100644 --- a/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeCmdlet.cs @@ -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) { diff --git a/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeStatusCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeStatusCmdlet.cs index 6d2e839..d618fd2 100644 --- a/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeStatusCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeStatusCmdlet.cs @@ -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); } diff --git a/src/PSProxmoxVE/Cmdlets/Snapshots/NewPveSnapshotCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Snapshots/NewPveSnapshotCmdlet.cs index 1ecb71c..64cce5d 100644 --- a/src/PSProxmoxVE/Cmdlets/Snapshots/NewPveSnapshotCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Snapshots/NewPveSnapshotCmdlet.cs @@ -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); diff --git a/src/PSProxmoxVE/Cmdlets/Snapshots/RemovePveSnapshotCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Snapshots/RemovePveSnapshotCmdlet.cs index de0ff62..c961b36 100644 --- a/src/PSProxmoxVE/Cmdlets/Snapshots/RemovePveSnapshotCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Snapshots/RemovePveSnapshotCmdlet.cs @@ -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); diff --git a/src/PSProxmoxVE/Cmdlets/Snapshots/RestorePveSnapshotCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Snapshots/RestorePveSnapshotCmdlet.cs index ceda163..8146ffd 100644 --- a/src/PSProxmoxVE/Cmdlets/Snapshots/RestorePveSnapshotCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Snapshots/RestorePveSnapshotCmdlet.cs @@ -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); diff --git a/src/PSProxmoxVE/Cmdlets/Storage/InvokePveStorageDownloadCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Storage/InvokePveStorageDownloadCmdlet.cs index 099d3af..69414d3 100644 --- a/src/PSProxmoxVE/Cmdlets/Storage/InvokePveStorageDownloadCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Storage/InvokePveStorageDownloadCmdlet.cs @@ -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 { ["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) { diff --git a/src/PSProxmoxVE/Cmdlets/Storage/NewPveStorageCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Storage/NewPveStorageCmdlet.cs index 13d1ab3..e084b4d 100644 --- a/src/PSProxmoxVE/Cmdlets/Storage/NewPveStorageCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Storage/NewPveStorageCmdlet.cs @@ -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 diff --git a/src/PSProxmoxVE/Cmdlets/Storage/RemovePveStorageCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Storage/RemovePveStorageCmdlet.cs index f0ed2f1..4399113 100644 --- a/src/PSProxmoxVE/Cmdlets/Storage/RemovePveStorageCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Storage/RemovePveStorageCmdlet.cs @@ -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(); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Storage/SendPveIsoCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Storage/SendPveIsoCmdlet.cs index 2bbf3f0..f4a71ba 100644 --- a/src/PSProxmoxVE/Cmdlets/Storage/SendPveIsoCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Storage/SendPveIsoCmdlet.cs @@ -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 - { - ["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 { - 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) { diff --git a/src/PSProxmoxVE/Cmdlets/Tasks/WaitPveTaskCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Tasks/WaitPveTaskCmdlet.cs index 013477b..674f012 100644 --- a/src/PSProxmoxVE/Cmdlets/Tasks/WaitPveTaskCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Tasks/WaitPveTaskCmdlet.cs @@ -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; diff --git a/src/PSProxmoxVE/Cmdlets/Templates/GetPveTemplateCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Templates/GetPveTemplateCmdlet.cs index b9aab1f..5d17d9f 100644 --- a/src/PSProxmoxVE/Cmdlets/Templates/GetPveTemplateCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Templates/GetPveTemplateCmdlet.cs @@ -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(); diff --git a/src/PSProxmoxVE/Cmdlets/Users/GetPveRoleCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Users/GetPveRoleCmdlet.cs index 879740a..3369680 100644 --- a/src/PSProxmoxVE/Cmdlets/Users/GetPveRoleCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Users/GetPveRoleCmdlet.cs @@ -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(); diff --git a/src/PSProxmoxVE/Cmdlets/Users/GetPveUserCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Users/GetPveUserCmdlet.cs index cbe3f38..83254d6 100644 --- a/src/PSProxmoxVE/Cmdlets/Users/GetPveUserCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Users/GetPveUserCmdlet.cs @@ -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(); diff --git a/src/PSProxmoxVE/Cmdlets/Users/NewPveRoleCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Users/NewPveRoleCmdlet.cs index 631b40d..4a50db9 100644 --- a/src/PSProxmoxVE/Cmdlets/Users/NewPveRoleCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Users/NewPveRoleCmdlet.cs @@ -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 }); } diff --git a/src/PSProxmoxVE/Cmdlets/Users/SetPvePermissionCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Users/SetPvePermissionCmdlet.cs index 6973fc3..e3b79af 100644 --- a/src/PSProxmoxVE/Cmdlets/Users/SetPvePermissionCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Users/SetPvePermissionCmdlet.cs @@ -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(); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Vms/NewPveVmCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Vms/NewPveVmCmdlet.cs index eed3d2a..5f30ab9 100644 --- a/src/PSProxmoxVE/Cmdlets/Vms/NewPveVmCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Vms/NewPveVmCmdlet.cs @@ -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(); 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) diff --git a/tests/PSProxmoxVE.Core.Tests/Models/NodeModelTests.cs b/tests/PSProxmoxVE.Core.Tests/Models/NodeModelTests.cs index 8f5b53e..9880d7f 100644 --- a/tests/PSProxmoxVE.Core.Tests/Models/NodeModelTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Models/NodeModelTests.cs @@ -72,7 +72,7 @@ namespace PSProxmoxVE.Core.Tests.Models var data = JObject.Parse(json)["data"]; var status = data.ToObject(); 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);