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);