mirror of
https://github.com/GoodOlClint/PSProxmoxVE.git
synced 2026-09-03 18:55:33 +00:00
fix: Import-PveOva's dead not-found catch and missing upload timeout (#180)
* fix: retarget Import-PveOva's not-found catch and add upload timeouts VmService.GetVm throws InvalidOperationException when the VM is not yet listed on the node, never PveApiException(NotFound) — the API call itself returns 200. Import-PveOva's tail catch was for the exception GetVm never throws, so a successful import without -Wait raised the InvalidOperationException unhandled instead of falling back to a basic PveVm. Retarget the catch, and narrow its try region to the GetVm call only so the fallback can no longer fire for a WriteObject failure on an already-retrieved VM. VmService.UploadOva and StorageService.UploadIso built their PveHttpClient with no timeout override, so large OVA/ISO uploads inherited the session's 100s default and aborted mid-transfer. Both now take a TimeSpan? timeout (default 30 minutes), matching Send-PveFile. Import-PveOva gains -TimeoutSeconds mirroring Send-PveFileCmdlet's parameter. Also drops the trailing null, null, null on WaitForTask calls in ImportPveOvaCmdlet.cs and NewPveVmCmdlet.cs, left over from #140. Closes #139 * fix: give UploadOva/UploadIso a timeout override and add coverage Completes the #139 fix: VmService.UploadOva and StorageService.UploadIso now take a TimeSpan? timeout (default 30 minutes) instead of always using the session's 100s default. Adds a GetVm not-found regression test and a timeout-propagation test suite for both upload methods. * test: release the upload before deleting its temp file The two default-timeout tests left the upload in flight and then deleted the file it still had open. Windows refuses that, so both build-and-test legs failed on windows-latest. --------- Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
0ec75a02e7
commit
716ecd100d
@@ -113,6 +113,10 @@ namespace PSProxmoxVE.Core.Services
|
||||
/// <param name="checksum">Optional checksum value.</param>
|
||||
/// <param name="checksumAlgorithm">Optional checksum algorithm (e.g. "sha256").</param>
|
||||
/// <param name="progressCallback">Optional callback with (bytesSent, totalBytes).</param>
|
||||
/// <param name="timeout">
|
||||
/// HTTP timeout override for this upload. Defaults to 30 minutes, overriding the
|
||||
/// session's default 100-second timeout so that large files have time to transfer.
|
||||
/// </param>
|
||||
public PveTask UploadIso(
|
||||
PveSession session,
|
||||
string node,
|
||||
@@ -120,7 +124,8 @@ namespace PSProxmoxVE.Core.Services
|
||||
string filePath,
|
||||
string? checksum = null,
|
||||
string? checksumAlgorithm = null,
|
||||
Action<long, long>? progressCallback = null)
|
||||
Action<long, long>? progressCallback = null,
|
||||
TimeSpan? timeout = null)
|
||||
{
|
||||
if (session == null) throw new ArgumentNullException(nameof(session));
|
||||
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
|
||||
@@ -132,7 +137,7 @@ namespace PSProxmoxVE.Core.Services
|
||||
["content"] = "iso"
|
||||
};
|
||||
|
||||
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
|
||||
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session, timeout ?? TimeSpan.FromMinutes(30));
|
||||
try
|
||||
{
|
||||
var response = client.UploadFileAsync(
|
||||
|
||||
@@ -945,12 +945,17 @@ namespace PSProxmoxVE.Core.Services
|
||||
/// Optional callback invoked periodically with (bytesSent, totalBytes).
|
||||
/// May be called from a background thread.
|
||||
/// </param>
|
||||
/// <param name="timeout">
|
||||
/// HTTP timeout override for this upload. Defaults to 30 minutes, overriding the
|
||||
/// session's default 100-second timeout so that large OVA files have time to transfer.
|
||||
/// </param>
|
||||
public PveTask UploadOva(
|
||||
PveSession session,
|
||||
string node,
|
||||
string storage,
|
||||
string ovaPath,
|
||||
Action<long, long>? progressCallback = null)
|
||||
Action<long, long>? progressCallback = null,
|
||||
TimeSpan? timeout = null)
|
||||
{
|
||||
if (session == null) throw new ArgumentNullException(nameof(session));
|
||||
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
|
||||
@@ -962,7 +967,7 @@ namespace PSProxmoxVE.Core.Services
|
||||
["content"] = "import"
|
||||
};
|
||||
|
||||
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
|
||||
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session, timeout ?? TimeSpan.FromMinutes(30));
|
||||
try
|
||||
{
|
||||
var response = client.UploadFileAsync(
|
||||
|
||||
@@ -2,8 +2,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Management.Automation;
|
||||
using System.Net;
|
||||
using PSProxmoxVE.Core.Exceptions;
|
||||
using PSProxmoxVE.Core.Models.Vms;
|
||||
using PSProxmoxVE.Core.Services;
|
||||
|
||||
@@ -83,6 +81,15 @@ namespace PSProxmoxVE.Cmdlets.Vms
|
||||
[Parameter(Mandatory = false, HelpMessage = "Wait for all tasks to complete before returning.")]
|
||||
public SwitchParameter Wait { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP timeout for the OVA upload, in seconds. Pass 0 for infinite (no timeout).
|
||||
/// When omitted, defaults to 30 minutes — overriding the session timeout so that
|
||||
/// large OVA uploads do not trip the default 100-second HttpClient timeout.
|
||||
/// </summary>
|
||||
[Parameter(Mandatory = false, HelpMessage = "HTTP timeout in seconds (0 = infinite). Defaults to 1800 (30 min).")]
|
||||
[ValidateRange(0, int.MaxValue)]
|
||||
public int? TimeoutSeconds { get; set; }
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
// Validate the OVA file exists
|
||||
@@ -164,11 +171,24 @@ namespace PSProxmoxVE.Cmdlets.Vms
|
||||
$"Importing OVA to {Node}",
|
||||
$"Uploading {fileName}...");
|
||||
|
||||
TimeSpan uploadTimeout;
|
||||
if (TimeoutSeconds.HasValue)
|
||||
{
|
||||
uploadTimeout = TimeoutSeconds.Value == 0
|
||||
? System.Threading.Timeout.InfiniteTimeSpan
|
||||
: TimeSpan.FromSeconds(TimeoutSeconds.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
uploadTimeout = TimeSpan.FromMinutes(30);
|
||||
}
|
||||
|
||||
long progressBytes = 0;
|
||||
var uploadTask = System.Threading.Tasks.Task.Run(() =>
|
||||
vmService.UploadOva(session, Node, Storage, Path,
|
||||
(bytesSent, _) =>
|
||||
System.Threading.Interlocked.Exchange(ref progressBytes, bytesSent)));
|
||||
System.Threading.Interlocked.Exchange(ref progressBytes, bytesSent),
|
||||
uploadTimeout));
|
||||
|
||||
// Poll progress on the pipeline thread
|
||||
while (!uploadTask.IsCompleted)
|
||||
@@ -193,7 +213,7 @@ namespace PSProxmoxVE.Cmdlets.Vms
|
||||
if (!string.IsNullOrEmpty(uploadResult.Upid))
|
||||
{
|
||||
WriteVerbose("Waiting for OVA upload task to complete on PVE...");
|
||||
var completedUpload = taskService.WaitForTask(session, Node, uploadResult.Upid, null, null, null);
|
||||
var completedUpload = taskService.WaitForTask(session, Node, uploadResult.Upid);
|
||||
if (completedUpload.ExitStatus != null && completedUpload.ExitStatus != "OK")
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
@@ -278,7 +298,7 @@ namespace PSProxmoxVE.Cmdlets.Vms
|
||||
if (Wait.IsPresent && !string.IsNullOrEmpty(createTask.Upid))
|
||||
{
|
||||
WriteVerbose("Waiting for VM creation + disk import to complete...");
|
||||
var completedCreate = taskService.WaitForTask(session, Node, createTask.Upid, null, null, null);
|
||||
var completedCreate = taskService.WaitForTask(session, Node, createTask.Upid);
|
||||
if (completedCreate.ExitStatus != null && completedCreate.ExitStatus != "OK")
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
@@ -292,23 +312,23 @@ namespace PSProxmoxVE.Cmdlets.Vms
|
||||
|
||||
// Step 8: Output the created VM
|
||||
WriteVerbose("Retrieving created VM...");
|
||||
PveVm? vm = null;
|
||||
try
|
||||
{
|
||||
var vm = vmService.GetVm(session, Node, vmId);
|
||||
WriteObject(vm);
|
||||
vm = vmService.GetVm(session, Node, vmId);
|
||||
}
|
||||
catch (PveApiException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// VM not yet queryable (e.g. disk import still in progress); return basic info
|
||||
WriteVerbose($"VM retrieval failed, returning basic info: {ex.Message}");
|
||||
WriteObject(new PveVm
|
||||
{
|
||||
VmId = vmId,
|
||||
Name = vmName,
|
||||
Node = Node,
|
||||
Status = "stopped"
|
||||
});
|
||||
}
|
||||
WriteObject(vm ?? new PveVm
|
||||
{
|
||||
VmId = vmId,
|
||||
Name = vmName,
|
||||
Node = Node,
|
||||
Status = "stopped"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ namespace PSProxmoxVE.Cmdlets.Vms
|
||||
if (Wait.IsPresent)
|
||||
{
|
||||
var taskService = new TaskService();
|
||||
task = taskService.WaitForTask(session, Node, task.Upid, null, null, null);
|
||||
task = taskService.WaitForTask(session, Node, task.Upid);
|
||||
}
|
||||
|
||||
WriteObject(task);
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading.Tasks;
|
||||
using PSProxmoxVE.Core.Authentication;
|
||||
using PSProxmoxVE.Core.Exceptions;
|
||||
using PSProxmoxVE.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace PSProxmoxVE.Core.Tests.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Proves that <see cref="VmService.UploadOva"/> and <see cref="StorageService.UploadIso"/>
|
||||
/// apply their own <c>timeout</c> parameter to the <see cref="Client.PveHttpClient"/> they
|
||||
/// construct, instead of always inheriting <see cref="PveSession.Timeout"/>. Each test runs a
|
||||
/// real TLS server on loopback that completes the handshake and then never answers the HTTP
|
||||
/// request, so the request can only end via the client-side timeout.
|
||||
/// </summary>
|
||||
public class UploadTimeoutTests
|
||||
{
|
||||
private static X509Certificate2 CreateSelfSignedServerCert()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=127.0.0.1", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var cert = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1));
|
||||
// Re-import as PKCS12 — an ephemeral CertificateRequest key set is not usable by
|
||||
// SslStream's server-auth path on every platform without this round-trip.
|
||||
return new X509Certificate2(cert.Export(X509ContentType.Pkcs12));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a loopback TLS server that accepts one connection, completes the handshake,
|
||||
/// and then holds the connection open without ever writing an HTTP response.
|
||||
/// </summary>
|
||||
private static (TcpListener listener, int port) StartSilentTlsServer(X509Certificate2 cert, TimeSpan hold)
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var tcpClient = await listener.AcceptTcpClientAsync().ConfigureAwait(false);
|
||||
using var sslStream = new SslStream(tcpClient.GetStream(), leaveInnerStreamOpen: false);
|
||||
await sslStream.AuthenticateAsServerAsync(cert, clientCertificateRequired: false,
|
||||
checkCertificateRevocation: false).ConfigureAwait(false);
|
||||
// Never read the request or write a response — the client is left waiting
|
||||
// until its own HttpClient.Timeout fires, or until the test tears the
|
||||
// listener down.
|
||||
await Task.Delay(hold).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The test disposes the listener once it has its result; ignore the resulting
|
||||
// teardown exceptions on this background task.
|
||||
}
|
||||
});
|
||||
|
||||
return (listener, port);
|
||||
}
|
||||
|
||||
private static PveSession NewSessionWithTimeout(int port, TimeSpan sessionTimeout)
|
||||
{
|
||||
var session = new PveSession("127.0.0.1", port, skipCertificateCheck: true,
|
||||
apiToken: "root@pam!test=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
session.Timeout = sessionTimeout;
|
||||
return session;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadOva_ExplicitTimeout_FiresBeforeSessionDefault()
|
||||
{
|
||||
using var cert = CreateSelfSignedServerCert();
|
||||
var (listener, port) = StartSilentTlsServer(cert, TimeSpan.FromSeconds(60));
|
||||
var tempFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
// Session default is 100s (PveSession's own built-in default); the explicit
|
||||
// override below must be what actually governs this call.
|
||||
var session = NewSessionWithTimeout(port, TimeSpan.FromSeconds(100));
|
||||
var service = new VmService();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var ex = await Assert.ThrowsAsync<PveApiException>(() =>
|
||||
Task.Run(() => service.UploadOva(session, "pve1", "local", tempFile,
|
||||
timeout: TimeSpan.FromSeconds(1))));
|
||||
sw.Stop();
|
||||
|
||||
Assert.Equal(HttpStatusCode.RequestTimeout, ex.StatusCode);
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(30),
|
||||
$"expected the 1s override to fire, not the 100s session default; took {sw.Elapsed}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadIso_ExplicitTimeout_FiresBeforeSessionDefault()
|
||||
{
|
||||
using var cert = CreateSelfSignedServerCert();
|
||||
var (listener, port) = StartSilentTlsServer(cert, TimeSpan.FromSeconds(60));
|
||||
var tempFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
var session = NewSessionWithTimeout(port, TimeSpan.FromSeconds(100));
|
||||
var service = new StorageService();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var ex = await Assert.ThrowsAsync<PveApiException>(() =>
|
||||
Task.Run(() => service.UploadIso(session, "pve1", "local", tempFile,
|
||||
progressCallback: null, timeout: TimeSpan.FromSeconds(1))));
|
||||
sw.Stop();
|
||||
|
||||
Assert.Equal(HttpStatusCode.RequestTimeout, ex.StatusCode);
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(30),
|
||||
$"expected the 1s override to fire, not the 100s session default; took {sw.Elapsed}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadOva_NoTimeoutGiven_UsesThirtyMinuteDefaultNotSessionTimeout()
|
||||
{
|
||||
using var cert = CreateSelfSignedServerCert();
|
||||
var (listener, port) = StartSilentTlsServer(cert, TimeSpan.FromSeconds(4));
|
||||
var tempFile = Path.GetTempFileName();
|
||||
Task? uploadTask = null;
|
||||
try
|
||||
{
|
||||
// Session default is deliberately shorter than the wait window below. If
|
||||
// UploadOva let the session's own timeout govern the request (the #139 bug),
|
||||
// this would throw within that window instead of still being in flight.
|
||||
var session = NewSessionWithTimeout(port, TimeSpan.FromMilliseconds(300));
|
||||
var service = new VmService();
|
||||
|
||||
uploadTask = Task.Run(() => service.UploadOva(session, "pve1", "local", tempFile));
|
||||
var completedFirst = await Task.WhenAny(uploadTask, Task.Delay(TimeSpan.FromSeconds(3)))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.NotSame(uploadTask, completedFirst);
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
// The upload still holds tempFile open until the server drops the connection;
|
||||
// Windows refuses to delete an open file, so wait for the task to end first.
|
||||
if (uploadTask != null)
|
||||
{
|
||||
try { await uploadTask.ConfigureAwait(false); }
|
||||
catch (Exception) { }
|
||||
}
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadIso_NoTimeoutGiven_UsesThirtyMinuteDefaultNotSessionTimeout()
|
||||
{
|
||||
using var cert = CreateSelfSignedServerCert();
|
||||
var (listener, port) = StartSilentTlsServer(cert, TimeSpan.FromSeconds(4));
|
||||
var tempFile = Path.GetTempFileName();
|
||||
Task? uploadTask = null;
|
||||
try
|
||||
{
|
||||
var session = NewSessionWithTimeout(port, TimeSpan.FromMilliseconds(300));
|
||||
var service = new StorageService();
|
||||
|
||||
uploadTask = Task.Run(() => service.UploadIso(session, "pve1", "local", tempFile));
|
||||
var completedFirst = await Task.WhenAny(uploadTask, Task.Delay(TimeSpan.FromSeconds(3)))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.NotSame(uploadTask, completedFirst);
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
// The upload still holds tempFile open until the server drops the connection;
|
||||
// Windows refuses to delete an open file, so wait for the task to end first.
|
||||
if (uploadTask != null)
|
||||
{
|
||||
try { await uploadTask.ConfigureAwait(false); }
|
||||
catch (Exception) { }
|
||||
}
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,25 @@ namespace PSProxmoxVE.Core.Tests.Services
|
||||
Assert.Equal("305", captured!["newid"]);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// GetVm
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetVm_VmNotInNodeListing_ThrowsInvalidOperationException()
|
||||
{
|
||||
var mockClient = new Mock<IPveHttpClient>();
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync($"nodes/{TestNode}/qemu"))
|
||||
.ReturnsAsync("{\"data\":[]}");
|
||||
|
||||
var service = new VmService(mockClient.Object);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => service.GetVm(CreateSession(), TestNode, TestVmId));
|
||||
Assert.Contains(TestVmId.ToString(), ex.Message);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// GetVms multi-node aggregation: issue #142
|
||||
|
||||
Reference in New Issue
Block a user