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