mirror of
https://github.com/GoodOlClint/PSProxmoxVE.git
synced 2026-09-03 18:55:33 +00:00
fix: surface swallowed errors in status polling and per-node listing (#181)
* fix: surface swallowed errors in status polling and per-node listing WaitForStatusTransition's poll loop caught every exception except OOM/ StackOverflow and discarded it silently. An expired ticket, a deleted VM, or a wrong node name under -Wait -Timeout spun for the full timeout and then raised PveTaskTimeoutException instead of the real 401/403/404. The loop now catches only PveApiException (excluding 401/403/404, which propagate) and HttpRequestException, and WriteVerbose's what it swallows. VmService.GetVms and ContainerService.GetContainers caught PveApiException of any status per node and continued, so a permission problem or an unreachable node looked identical to "no VMs". The per-node catch now narrows to a 5xx/408/connectivity failure (IsNodeUnreachable) and takes an optional onNodeSkipped callback; Get-PveVm and Get-PveContainer wire it to WriteWarning. Any other status (401/403/404 included) propagates. Adds xUnit coverage for the per-node aggregation path (403 propagates, 500/ 408/connectivity failures are skipped and reported, the other nodes' results still come back), reaching NodeService's internal client via reflection since it is not otherwise constructor-injectable from VmService/ ContainerService. Closes #142 * test: add per-node aggregation coverage and wire onNodeSkipped Adds the remaining changes: VmService/ContainerService per-node catch narrowing plus onNodeSkipped callback, the Get-PveVm/Get-PveContainer WriteWarning wiring, and the xUnit coverage for the aggregation loop. --------- Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
8a82146acc
commit
0ec75a02e7
@@ -1,7 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using Moq;
|
||||
using PSProxmoxVE.Core.Authentication;
|
||||
using PSProxmoxVE.Core.Client;
|
||||
using PSProxmoxVE.Core.Exceptions;
|
||||
using PSProxmoxVE.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
@@ -113,5 +118,126 @@ namespace PSProxmoxVE.Core.Tests.Services
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal("305", captured!["newid"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// GetContainers multi-node aggregation: issue #142
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Points the private NodeService field a ContainerService constructs at a mock
|
||||
/// client, so the "list all nodes" call the multi-node overload issues is reachable
|
||||
/// without a real HTTP connection. ContainerService(client) only injects the client
|
||||
/// used for the per-node lxc calls; NodeService is never constructor-injectable from
|
||||
/// ContainerService, so this is the only offline path to the aggregation loop.
|
||||
/// </summary>
|
||||
private static void InjectNodeServiceClient(ContainerService service, IPveHttpClient client)
|
||||
{
|
||||
var field = typeof(ContainerService).GetField("_nodeService", BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
?? throw new InvalidOperationException("ContainerService._nodeService field not found.");
|
||||
field.SetValue(service, new NodeService(client));
|
||||
}
|
||||
|
||||
private static Mock<IPveHttpClient> SetupTwoNodeCluster()
|
||||
{
|
||||
var mockClient = new Mock<IPveHttpClient>();
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes"))
|
||||
.ReturnsAsync("{\"data\":[{\"node\":\"pve1\"},{\"node\":\"pve2\"}]}");
|
||||
return mockClient;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetContainers_AllNodes_A500OnOneNodeIsSkippedAndReportedButOtherNodeResultsReturn()
|
||||
{
|
||||
var mockClient = SetupTwoNodeCluster();
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve1/lxc"))
|
||||
.ReturnsAsync("{\"data\":[{\"vmid\":100}]}");
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve2/lxc"))
|
||||
.ThrowsAsync(new PveApiException(HttpStatusCode.InternalServerError, "internal error", "nodes/pve2/lxc", "GET"));
|
||||
|
||||
var service = new ContainerService(mockClient.Object);
|
||||
InjectNodeServiceClient(service, mockClient.Object);
|
||||
|
||||
var skipped = new List<string>();
|
||||
var containers = service.GetContainers(CreateSession(), onNodeSkipped: (node, ex) => skipped.Add(node));
|
||||
|
||||
var ct = Assert.Single(containers);
|
||||
Assert.Equal(100, ct.VmId);
|
||||
Assert.Equal(new[] { "pve2" }, skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetContainers_AllNodes_A403OnOneNodePropagatesInsteadOfBeingSwallowed()
|
||||
{
|
||||
var mockClient = SetupTwoNodeCluster();
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve1/lxc"))
|
||||
.ReturnsAsync("{\"data\":[{\"vmid\":100}]}");
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve2/lxc"))
|
||||
.ThrowsAsync(new PveApiException(HttpStatusCode.Forbidden, "permission denied", "nodes/pve2/lxc", "GET"));
|
||||
|
||||
var service = new ContainerService(mockClient.Object);
|
||||
InjectNodeServiceClient(service, mockClient.Object);
|
||||
|
||||
var ex = Assert.Throws<PveApiException>(() => service.GetContainers(CreateSession()));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetContainers_AllNodes_ConnectivityFailureOnOneNodeIsSkippedAndReported()
|
||||
{
|
||||
// PveHttpClient.SendOnceAsync never lets a raw HttpRequestException escape — it
|
||||
// wraps one as PveApiException(ServiceUnavailable, ..., inner: HttpRequestException).
|
||||
// That is the shape a real connectivity failure takes by the time it reaches
|
||||
// ContainerService, so that is what this test throws.
|
||||
var mockClient = SetupTwoNodeCluster();
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve1/lxc"))
|
||||
.ReturnsAsync("{\"data\":[{\"vmid\":100}]}");
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve2/lxc"))
|
||||
.ThrowsAsync(new PveApiException(HttpStatusCode.ServiceUnavailable, "connection refused",
|
||||
"nodes/pve2/lxc", "GET", new HttpRequestException("connection refused")));
|
||||
|
||||
var service = new ContainerService(mockClient.Object);
|
||||
InjectNodeServiceClient(service, mockClient.Object);
|
||||
|
||||
var skipped = new List<string>();
|
||||
var containers = service.GetContainers(CreateSession(), onNodeSkipped: (node, ex) => skipped.Add(node));
|
||||
|
||||
var ct = Assert.Single(containers);
|
||||
Assert.Equal(100, ct.VmId);
|
||||
Assert.Equal(new[] { "pve2" }, skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetContainers_AllNodes_ClientTimeoutOnOneNodeIsSkippedAndReported()
|
||||
{
|
||||
// PveHttpClient.SendOnceAsync wraps an HttpClient timeout as
|
||||
// PveApiException(RequestTimeout) — the case of a powered-off or
|
||||
// firewall-blackholed node, which must be skipped like any other
|
||||
// unreachable node rather than aborting the whole listing.
|
||||
var mockClient = SetupTwoNodeCluster();
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve1/lxc"))
|
||||
.ReturnsAsync("{\"data\":[{\"vmid\":100}]}");
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve2/lxc"))
|
||||
.ThrowsAsync(new PveApiException(HttpStatusCode.RequestTimeout, "Request timed out after 100s.",
|
||||
"nodes/pve2/lxc", "GET"));
|
||||
|
||||
var service = new ContainerService(mockClient.Object);
|
||||
InjectNodeServiceClient(service, mockClient.Object);
|
||||
|
||||
var skipped = new List<string>();
|
||||
var containers = service.GetContainers(CreateSession(), onNodeSkipped: (node, ex) => skipped.Add(node));
|
||||
|
||||
var ct = Assert.Single(containers);
|
||||
Assert.Equal(100, ct.VmId);
|
||||
Assert.Equal(new[] { "pve2" }, skipped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using Moq;
|
||||
using PSProxmoxVE.Core.Authentication;
|
||||
using PSProxmoxVE.Core.Client;
|
||||
using PSProxmoxVE.Core.Exceptions;
|
||||
using PSProxmoxVE.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
@@ -268,5 +272,126 @@ namespace PSProxmoxVE.Core.Tests.Services
|
||||
Assert.Equal("305", captured!["newid"]);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// GetVms multi-node aggregation: issue #142
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Points the private NodeService field a VmService constructs at a mock client,
|
||||
/// so the "list all nodes" call the multi-node overload issues is reachable
|
||||
/// without a real HTTP connection. VmService(client) only injects the client used
|
||||
/// for the per-node qemu calls; NodeService is never constructor-injectable from
|
||||
/// VmService, so this is the only offline path to the aggregation loop.
|
||||
/// </summary>
|
||||
private static void InjectNodeServiceClient(VmService service, IPveHttpClient client)
|
||||
{
|
||||
var field = typeof(VmService).GetField("_nodeService", BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
?? throw new InvalidOperationException("VmService._nodeService field not found.");
|
||||
field.SetValue(service, new NodeService(client));
|
||||
}
|
||||
|
||||
private static Mock<IPveHttpClient> SetupTwoNodeCluster()
|
||||
{
|
||||
var mockClient = new Mock<IPveHttpClient>();
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes"))
|
||||
.ReturnsAsync("{\"data\":[{\"node\":\"pve1\"},{\"node\":\"pve2\"}]}");
|
||||
return mockClient;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVms_AllNodes_A500OnOneNodeIsSkippedAndReportedButOtherNodeResultsReturn()
|
||||
{
|
||||
var mockClient = SetupTwoNodeCluster();
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve1/qemu"))
|
||||
.ReturnsAsync("{\"data\":[{\"vmid\":100}]}");
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve2/qemu"))
|
||||
.ThrowsAsync(new PveApiException(HttpStatusCode.InternalServerError, "internal error", "nodes/pve2/qemu", "GET"));
|
||||
|
||||
var service = new VmService(mockClient.Object);
|
||||
InjectNodeServiceClient(service, mockClient.Object);
|
||||
|
||||
var skipped = new List<string>();
|
||||
var vms = service.GetVms(CreateSession(), onNodeSkipped: (node, ex) => skipped.Add(node));
|
||||
|
||||
var vm = Assert.Single(vms);
|
||||
Assert.Equal(100, vm.VmId);
|
||||
Assert.Equal(new[] { "pve2" }, skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVms_AllNodes_A403OnOneNodePropagatesInsteadOfBeingSwallowed()
|
||||
{
|
||||
var mockClient = SetupTwoNodeCluster();
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve1/qemu"))
|
||||
.ReturnsAsync("{\"data\":[{\"vmid\":100}]}");
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve2/qemu"))
|
||||
.ThrowsAsync(new PveApiException(HttpStatusCode.Forbidden, "permission denied", "nodes/pve2/qemu", "GET"));
|
||||
|
||||
var service = new VmService(mockClient.Object);
|
||||
InjectNodeServiceClient(service, mockClient.Object);
|
||||
|
||||
var ex = Assert.Throws<PveApiException>(() => service.GetVms(CreateSession()));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVms_AllNodes_ConnectivityFailureOnOneNodeIsSkippedAndReported()
|
||||
{
|
||||
// PveHttpClient.SendOnceAsync never lets a raw HttpRequestException escape — it
|
||||
// wraps one as PveApiException(ServiceUnavailable, ..., inner: HttpRequestException).
|
||||
// That is the shape a real connectivity failure takes by the time it reaches
|
||||
// VmService, so that is what this test throws.
|
||||
var mockClient = SetupTwoNodeCluster();
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve1/qemu"))
|
||||
.ReturnsAsync("{\"data\":[{\"vmid\":100}]}");
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve2/qemu"))
|
||||
.ThrowsAsync(new PveApiException(HttpStatusCode.ServiceUnavailable, "connection refused",
|
||||
"nodes/pve2/qemu", "GET", new HttpRequestException("connection refused")));
|
||||
|
||||
var service = new VmService(mockClient.Object);
|
||||
InjectNodeServiceClient(service, mockClient.Object);
|
||||
|
||||
var skipped = new List<string>();
|
||||
var vms = service.GetVms(CreateSession(), onNodeSkipped: (node, ex) => skipped.Add(node));
|
||||
|
||||
var vm = Assert.Single(vms);
|
||||
Assert.Equal(100, vm.VmId);
|
||||
Assert.Equal(new[] { "pve2" }, skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVms_AllNodes_ClientTimeoutOnOneNodeIsSkippedAndReported()
|
||||
{
|
||||
// PveHttpClient.SendOnceAsync wraps an HttpClient timeout as
|
||||
// PveApiException(RequestTimeout) — the case of a powered-off or
|
||||
// firewall-blackholed node, which must be skipped like any other
|
||||
// unreachable node rather than aborting the whole listing.
|
||||
var mockClient = SetupTwoNodeCluster();
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve1/qemu"))
|
||||
.ReturnsAsync("{\"data\":[{\"vmid\":100}]}");
|
||||
mockClient
|
||||
.Setup(c => c.GetAsync("nodes/pve2/qemu"))
|
||||
.ThrowsAsync(new PveApiException(HttpStatusCode.RequestTimeout, "Request timed out after 100s.",
|
||||
"nodes/pve2/qemu", "GET"));
|
||||
|
||||
var service = new VmService(mockClient.Object);
|
||||
InjectNodeServiceClient(service, mockClient.Object);
|
||||
|
||||
var skipped = new List<string>();
|
||||
var vms = service.GetVms(CreateSession(), onNodeSkipped: (node, ex) => skipped.Add(node));
|
||||
|
||||
var vm = Assert.Single(vms);
|
||||
Assert.Equal(100, vm.VmId);
|
||||
Assert.Equal(new[] { "pve2" }, skipped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user