refactor: one task-response parser, and the dead code #154 names (part A) (#207)

* refactor: one task-response parser, and the dead code #154 names (part A)

Unifies the 8 byte-similar private ParseTask methods in BackupService,
ContainerService, NetworkService, NodeService, SnapshotService,
StorageService, TemplateService, and VmService into one shared
PveTaskResponse.Parse(json, node) utility, on the variant that stamps
Status = "running" for a bare-UPID response. BackupService, NodeService,
TemplateService, and VmService previously left Status null for that case;
their non--Wait task output now reports "running" like the other four
services already did.

Removes the duplicate ClusterConfigService.GetClusterStatus in favor of
ClusterService's; ClusterConfigService now holds a ClusterService built
from the same injected/default client, and WaitForQuorum and
Get-PveClusterStatus go through it.

Removes dead code named in issue #154 and its fold-in comments: the
never-called PveHttpClient/IPveHttpClient sync wrappers Put/Delete, the
never-thrown PveAuthenticationException, a #pragma around an
already-nullable field, the unreferenced TestHelper mock-handler helpers,
three hand-rolled version-warning blocks (now PveCmdletBase.WarnIfBelowVersion),
an unreachable catch(HttpRequestException) arm in WaitForStatusTransition,
dead ExitStatus-checking branches in ImportPveOvaCmdlet after WaitForTask
(which already throws on failure), and an unreachable int branch in
ApiValueHelper.IsExited.

Fixes the WaitForStatusTransition catch removal's premise: PveHttpClient
read the response body outside its HttpRequestException try block, so a
mid-body stream drop could still escape unwrapped. Moves the body read
inside the try so every HttpRequestException the client can throw becomes
a PveApiException, matching what the removed catch assumed.

Part of #154.

* Add service files: BackupService, ClusterConfigService, ContainerService, NetworkService

* Add service files: NodeService, SnapshotService, StorageService, TemplateService

* Add VmService and Utilities files

* Remove unused PveAuthenticationException (never thrown, caught, or tested)

* Add cmdlet files: GetPveClusterStatus, SDN subnets, PveCmdletBase, SendPveFile, ImportPveOva

* Add test files: BackupServiceTests, ClusterConfigServiceTests, NodeServiceTests

* Add remaining test files: TemplateServiceTests, VmServiceTests, TestHelper, ApiValueHelperTests, PveTaskResponseTests

* Add VmServiceTests

---------

Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
This commit is contained in:
goodolclint-claude[bot]
2026-09-03 00:59:51 +00:00
committed by GitHub
parent 109dca657a
commit 089bb7c81e
28 changed files with 188 additions and 333 deletions
@@ -35,12 +35,6 @@ namespace PSProxmoxVE.Core.Client
/// <summary>Synchronous wrapper for <see cref="PostAsync(string, Dictionary{string, string})"/>.</summary>
string Post(string resource, Dictionary<string, string>? data = null);
/// <summary>Synchronous wrapper for <see cref="PutAsync"/>.</summary>
string Put(string resource, Dictionary<string, string>? data = null);
/// <summary>Synchronous wrapper for <see cref="DeleteAsync"/>.</summary>
string Delete(string resource);
/// <summary>
/// Uploads a file to a Proxmox VE storage endpoint using MultipartFormDataContent.
/// </summary>
+4 -12
View File
@@ -20,9 +20,7 @@ namespace PSProxmoxVE.Core.Client
/// </summary>
public class PveHttpClient : IPveHttpClient
{
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type
private readonly PveSession? _session;
#pragma warning restore CS8625
private readonly string _baseUrl;
private readonly HttpClient _httpClient;
private readonly HttpMessageHandler _handler;
@@ -266,14 +264,6 @@ namespace PSProxmoxVE.Core.Client
public string Post(string resource, Dictionary<string, string>? data = null) =>
PostAsync(resource, data).GetAwaiter().GetResult();
/// <summary>Synchronous wrapper for <see cref="PutAsync"/>.</summary>
public string Put(string resource, Dictionary<string, string>? data = null) =>
PutAsync(resource, data).GetAwaiter().GetResult();
/// <summary>Synchronous wrapper for <see cref="DeleteAsync"/>.</summary>
public string Delete(string resource) =>
DeleteAsync(resource).GetAwaiter().GetResult();
// -------------------------------------------------------------------------
// ISO / file upload
// -------------------------------------------------------------------------
@@ -434,9 +424,11 @@ namespace PSProxmoxVE.Core.Client
private async Task<string> SendOnceAsync(HttpRequestMessage request, string resource, string httpMethod)
{
HttpResponseMessage response;
string body;
try
{
response = await _httpClient.SendAsync(request).ConfigureAwait(false);
body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
catch (TaskCanceledException ex)
{
@@ -452,12 +444,12 @@ namespace PSProxmoxVE.Core.Client
}
catch (HttpRequestException ex)
{
// Covers both a failed connection and a stream drop mid-body-read, so every
// HttpRequestException PveHttpClient can throw arrives as PveApiException.
throw new PveApiException(HttpStatusCode.ServiceUnavailable,
ex.Message, resource, httpMethod, ex);
}
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorMessage = ExtractErrorMessage(body, response.ReasonPhrase ?? response.StatusCode.ToString());
@@ -1,23 +0,0 @@
using System;
namespace PSProxmoxVE.Core.Exceptions
{
/// <summary>Exception thrown when authentication to a Proxmox VE server fails.</summary>
public class PveAuthenticationException : Exception
{
/// <summary>Initializes a new instance with the specified error message.</summary>
/// <param name="message">The error message describing the authentication failure.</param>
public PveAuthenticationException(string message)
: base(message)
{
}
/// <summary>Initializes a new instance with the specified error message and inner exception.</summary>
/// <param name="message">The error message describing the authentication failure.</param>
/// <param name="innerException">The exception that caused this failure.</param>
public PveAuthenticationException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}
+1 -16
View File
@@ -47,7 +47,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/vzdump", config)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -151,20 +151,5 @@ namespace PSProxmoxVE.Core.Services
return JsonHelper.ToListOfDictionaries(data as JArray);
});
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static PveTask ParseTask(string response, string node)
{
var data = JObject.Parse(response)["data"];
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
}
}
@@ -19,16 +19,24 @@ namespace PSProxmoxVE.Core.Services
private static readonly TimeSpan DefaultQuorumTimeout = TimeSpan.FromSeconds(60);
private static readonly TimeSpan QuorumPollInterval = TimeSpan.FromSeconds(2);
private readonly ClusterService _clusterService;
/// <summary>
/// Initializes a new instance of the <see cref="ClusterConfigService"/> class.
/// </summary>
public ClusterConfigService() { }
public ClusterConfigService()
{
_clusterService = new ClusterService();
}
/// <summary>
/// Initializes a new instance of the <see cref="ClusterConfigService"/> class with an injected HTTP client.
/// </summary>
/// <param name="client">The HTTP client to use for API calls. The caller owns its lifetime.</param>
public ClusterConfigService(IPveHttpClient client) : base(client) { }
public ClusterConfigService(IPveHttpClient client) : base(client)
{
_clusterService = new ClusterService(client);
}
/// <summary>
/// Returns the cluster configuration directory (GET /cluster/config).
@@ -297,22 +305,6 @@ namespace PSProxmoxVE.Core.Services
});
}
/// <summary>
/// Returns the current cluster status (GET /cluster/status).
/// Delegates to the same endpoint as <see cref="ClusterService.GetClusterStatus"/>.
/// </summary>
public PveClusterStatus[] GetClusterStatus(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
return Invoke(session, client =>
{
var response = client.GetAsync("cluster/status").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveClusterStatus[]>() ?? Array.Empty<PveClusterStatus>();
});
}
/// <summary>
/// Blocks until the cluster reports quorum (GET /cluster/status, quorate = 1).
/// </summary>
@@ -335,7 +327,7 @@ namespace PSProxmoxVE.Core.Services
{
try
{
foreach (var entry in GetClusterStatus(session))
foreach (var entry in _clusterService.GetClusterStatus(session))
{
if (string.Equals(entry.Type, "cluster", StringComparison.OrdinalIgnoreCase)
&& entry.Quorate == 1)
@@ -6,6 +6,7 @@ using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Containers;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Core.Services
{
@@ -201,7 +202,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/snapshot", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -222,7 +223,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.DeleteAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}")
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -243,7 +244,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}/rollback")
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -270,7 +271,7 @@ namespace PSProxmoxVE.Core.Services
kvp => kvp.Value?.ToString() ?? string.Empty);
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -300,7 +301,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/status/shutdown", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -325,7 +326,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.DeleteAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}{queryString}")
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -356,7 +357,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/clone", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -382,7 +383,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/migrate", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -422,7 +423,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PutAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/resize", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -447,7 +448,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/move_volume", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -467,7 +468,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/template")
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -505,19 +506,8 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/status/{action}")
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
private static PveTask ParseTask(string response, string node)
{
var data = JObject.Parse(response)["data"];
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node, Status = "running" };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
}
}
@@ -6,6 +6,7 @@ using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Network;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Core.Services
{
@@ -141,7 +142,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PutAsync($"nodes/{Uri.EscapeDataString(node)}/network")
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -588,20 +589,5 @@ namespace PSProxmoxVE.Core.Services
.GetAwaiter().GetResult();
});
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static PveTask ParseTask(string response, string node)
{
var data = JObject.Parse(response)["data"];
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node, Status = "running" };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
}
}
+2 -17
View File
@@ -153,7 +153,7 @@ namespace PSProxmoxVE.Core.Services
return Invoke(session, client =>
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/startall", formData).GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -172,7 +172,7 @@ namespace PSProxmoxVE.Core.Services
return Invoke(session, client =>
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/stopall", formData).GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -193,20 +193,5 @@ namespace PSProxmoxVE.Core.Services
return PveVersion.Parse(versionStr!);
});
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static PveTask ParseTask(string response, string node)
{
var data = JObject.Parse(response)["data"];
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
}
}
@@ -4,6 +4,7 @@ using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Core.Services
{
@@ -78,7 +79,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/snapshot", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -103,7 +104,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.DeleteAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}")
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -128,23 +129,8 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}/rollback")
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static PveTask ParseTask(string response, string node)
{
var data = JObject.Parse(response)["data"];
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node, Status = "running" };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
}
}
@@ -6,6 +6,7 @@ using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Storage;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Core.Services
{
@@ -137,7 +138,7 @@ namespace PSProxmoxVE.Core.Services
checksumAlgorithm,
progressCallback)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -182,7 +183,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/storage/{Uri.EscapeDataString(storage)}/download-url", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -337,23 +338,8 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/storage/{Uri.EscapeDataString(storage)}/content", config)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static PveTask ParseTask(string response, string node)
{
var data = JObject.Parse(response)["data"];
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node, Status = "running" };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
}
}
@@ -4,6 +4,7 @@ using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Core.Services
{
@@ -68,7 +69,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/template")
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -92,20 +93,5 @@ namespace PSProxmoxVE.Core.Services
return _vmService.RemoveVm(session, node, vmid, purge);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static PveTask ParseTask(string response, string node)
{
var data = JObject.Parse(response)["data"];
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
}
}
+10 -21
View File
@@ -249,7 +249,7 @@ namespace PSProxmoxVE.Core.Services
// POST (not PUT) because import-from triggers a background task
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/config", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -276,7 +276,7 @@ namespace PSProxmoxVE.Core.Services
kvp => kvp.Value?.ToString() ?? string.Empty);
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -312,7 +312,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/status/shutdown", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -343,7 +343,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/status/reboot", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -395,7 +395,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.DeleteAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}{queryString}")
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -435,7 +435,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/clone", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -468,7 +468,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/migrate", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -504,7 +504,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PutAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/resize", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -521,21 +521,10 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/status/{action}")
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
private static PveTask ParseTask(string response, string node)
{
var data = JObject.Parse(response)["data"];
// Many endpoints return the UPID string directly as the data value
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
// -------------------------------------------------------------------------
// QEMU Guest Agent
@@ -664,7 +653,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/move_disk", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
return PveTaskResponse.Parse(response, node);
});
}
@@ -10,8 +10,9 @@ namespace PSProxmoxVE.Core.Utilities
{
/// <summary>
/// Determines if a value represents a true/exited state.
/// Accepts boolean true, integer 1 (as Int64 or Int32), and string "1" as true.
/// All other values (false, 0, "0", null, etc.) are false.
/// Accepts boolean true, integer 1 (as Int64, which is how Newtonsoft deserializes a
/// JSON integer), and string "1" as true. All other values (false, 0, "0", null, etc.)
/// are false.
/// </summary>
/// <param name="value">The value to check, typically from API response data.</param>
/// <returns>True if the value represents an exited/true state, false otherwise.</returns>
@@ -26,9 +27,6 @@ namespace PSProxmoxVE.Core.Utilities
if (value is long l)
return l == 1L;
if (value is int i)
return i == 1;
if (value is string s)
return s == "1";
@@ -0,0 +1,36 @@
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Models.Vms;
namespace PSProxmoxVE.Core.Utilities
{
/// <summary>
/// Parses the task returned by a mutating Proxmox VE API call. Most such endpoints return
/// the UPID as a bare string in <c>data</c>; a few return a task object directly.
/// </summary>
public static class PveTaskResponse
{
/// <summary>
/// Parses <paramref name="json"/> as a Proxmox VE API envelope and extracts the task
/// its <c>data</c> field describes, stamping <see cref="PveTask.Node"/> with
/// <paramref name="node"/>.
/// </summary>
/// <param name="json">The raw JSON response body.</param>
/// <param name="node">The cluster node the request was made against.</param>
/// <returns>
/// A <see cref="PveTask"/> with <see cref="PveTask.Upid"/> and
/// <see cref="PveTask.Status"/> set to <c>"running"</c> when <c>data</c> is a UPID
/// string; the deserialized task when <c>data</c> is an object; or an empty task
/// when <c>data</c> is null or absent.
/// </returns>
public static PveTask Parse(string json, string node)
{
var data = JObject.Parse(json)["data"];
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node, Status = "running" };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
}
}
@@ -18,7 +18,7 @@ namespace PSProxmoxVE.Cmdlets.Cluster
protected override void ProcessRecord()
{
var session = GetSession();
var service = new ClusterConfigService();
var service = new ClusterService();
WriteVerbose("Getting cluster status...");
var statuses = service.GetClusterStatus(session);
@@ -48,12 +48,8 @@ namespace PSProxmoxVE.Cmdlets.Network
var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0);
if (!string.IsNullOrEmpty(DhcpRange)
&& session.ServerVersion != null && !session.ServerVersion.IsAtLeast(8, 1))
{
WriteWarning("The -DhcpRange parameter requires PVE 8.1 or later. "
+ $"Connected server is PVE {session.ServerVersion}. The parameter will be sent but may be ignored.");
}
WarnIfBelowVersion(session, !string.IsNullOrEmpty(DhcpRange), 8, 1,
"The -DhcpRange parameter requires", "The parameter will be sent but may be ignored.");
WriteVerbose($"Creating SDN subnet '{Subnet}' on VNet '{Vnet}'...");
var data = new Dictionary<string, object>
@@ -47,12 +47,8 @@ namespace PSProxmoxVE.Cmdlets.Network
var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0);
if (!string.IsNullOrEmpty(DhcpRange)
&& session.ServerVersion != null && !session.ServerVersion.IsAtLeast(8, 1))
{
WriteWarning("The -DhcpRange parameter requires PVE 8.1 or later. "
+ $"Connected server is PVE {session.ServerVersion}. The parameter will be sent but may be ignored.");
}
WarnIfBelowVersion(session, !string.IsNullOrEmpty(DhcpRange), 8, 1,
"The -DhcpRange parameter requires", "The parameter will be sent but may be ignored.");
var service = new NetworkService();
+34 -4
View File
@@ -92,6 +92,40 @@ namespace PSProxmoxVE.Cmdlets
}
}
/// <summary>
/// Emits a soft warning when <paramref name="condition"/> holds and the connected
/// server is below <paramref name="requiredMajor"/>.<paramref name="requiredMinor"/>.
/// Unlike <see cref="RequireVersion"/>, this never blocks the call: the parameter or
/// feature may simply be silently ignored by an older server.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="condition">True when the caller used the parameter/feature this warning covers.</param>
/// <param name="requiredMajor">Major version the parameter/feature requires.</param>
/// <param name="requiredMinor">Minor version the parameter/feature requires.</param>
/// <param name="requirementClause">
/// The warning's leading clause, ending in "requires"/"require" (e.g. "The -DhcpRange
/// parameter requires"), so the full sentence reads
/// "&lt;requirementClause&gt; PVE &lt;requiredMajor&gt;.&lt;requiredMinor&gt; or later.".
/// </param>
/// <param name="consequence">Trailing sentence describing what happens if the server is too old.</param>
protected void WarnIfBelowVersion(
PveSession session,
bool condition,
int requiredMajor,
int requiredMinor,
string requirementClause,
string consequence)
{
if (!condition) return;
var version = session.ServerVersion;
if (version == null || version.IsAtLeast(requiredMajor, requiredMinor)) return;
WriteWarning(
$"{requirementClause} PVE {requiredMajor}.{requiredMinor} or later. " +
$"Connected server is PVE {version}. {consequence}");
}
/// <summary>
/// Waits for a PVE task to complete, then optionally polls VM status until
/// it matches <paramref name="expectedStatus"/>. Used by lifecycle cmdlets
@@ -151,10 +185,6 @@ namespace PSProxmoxVE.Cmdlets
{
WriteVerbose($"Status poll failed, retrying: {ex.Message}");
}
catch (System.Net.Http.HttpRequestException ex)
{
WriteVerbose($"Status poll failed, retrying: {ex.Message}");
}
System.Threading.Thread.Sleep(2000);
}
@@ -75,12 +75,10 @@ namespace PSProxmoxVE.Cmdlets.Storage
var session = GetSession();
if ((!string.IsNullOrEmpty(Checksum) || !string.IsNullOrEmpty(ChecksumAlgorithm))
&& session.ServerVersion != null && !session.ServerVersion.IsAtLeast(7, 1))
{
WriteWarning("The -Checksum and -ChecksumAlgorithm parameters require PVE 7.1 or later. "
+ $"Connected server is PVE {session.ServerVersion}. The upload will proceed without checksum verification.");
}
WarnIfBelowVersion(session,
!string.IsNullOrEmpty(Checksum) || !string.IsNullOrEmpty(ChecksumAlgorithm), 7, 1,
"The -Checksum and -ChecksumAlgorithm parameters require",
"The upload will proceed without checksum verification.");
TimeSpan timeout;
if (TimeoutSeconds.HasValue)
@@ -213,16 +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);
if (completedUpload.ExitStatus != null && completedUpload.ExitStatus != "OK")
{
ThrowTerminatingError(new ErrorRecord(
new InvalidOperationException($"OVA upload task failed with status: {completedUpload.ExitStatus}"),
"OvaUploadFailed",
ErrorCategory.InvalidResult,
uploadResult.Upid));
return;
}
taskService.WaitForTask(session, Node, uploadResult.Upid);
}
// Step 5: Create VM with disks and network in a single API call.
@@ -298,16 +289,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);
if (completedCreate.ExitStatus != null && completedCreate.ExitStatus != "OK")
{
ThrowTerminatingError(new ErrorRecord(
new InvalidOperationException($"OVA import task failed with status: {completedCreate.ExitStatus}"),
"OvaImportFailed",
ErrorCategory.InvalidResult,
createTask.Upid));
return;
}
taskService.WaitForTask(session, Node, createTask.Upid);
}
// Step 8: Output the created VM
@@ -50,6 +50,7 @@ namespace PSProxmoxVE.Core.Tests.Services
// Assert
Assert.Equal(upid, task.Upid);
Assert.Equal(Node, task.Node);
Assert.Equal("running", task.Status);
mockClient.Verify(c => c.PostAsync(
$"nodes/{Node}/vzdump",
It.Is<Dictionary<string, string>>(d => d["vmid"] == "100" && d["storage"] == "local")),
@@ -318,34 +318,6 @@ namespace PSProxmoxVE.Core.Tests.Services
Times.Once);
}
[Fact]
public void GetClusterStatus_ReturnsStatusArray()
{
// Arrange
var json = @"{""data"": [
{""type"": ""cluster"", ""name"": ""pve-cluster"", ""nodes"": 3, ""quorate"": 1, ""version"": 5},
{""type"": ""node"", ""name"": ""pve1"", ""online"": 1, ""local"": 1, ""nodeid"": 1, ""ip"": ""10.0.0.1""}
]}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/status")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var statuses = service.GetClusterStatus(CreateSession());
// Assert
Assert.Equal(2, statuses.Length);
Assert.Equal("cluster", statuses[0].Type);
Assert.Equal("pve-cluster", statuses[0].Name);
Assert.Equal(3, statuses[0].Nodes);
Assert.Equal(1, statuses[0].Quorate);
Assert.Equal("node", statuses[1].Type);
Assert.Equal("pve1", statuses[1].Name);
Assert.Equal(1, statuses[1].Online);
Assert.Equal("10.0.0.1", statuses[1].Ip);
mockClient.Verify(c => c.GetAsync("cluster/status"), Times.Once);
}
[Fact]
public void GetNextId_ReturnsInt()
{
@@ -452,17 +424,6 @@ namespace PSProxmoxVE.Core.Tests.Services
Assert.Throws<ArgumentNullException>(() => service.GetNextId(null!));
}
[Fact]
public void GetClusterStatus_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetClusterStatus(null!));
}
[Fact]
public void Constructor_NullClient_ThrowsArgumentNullException()
{
@@ -250,6 +250,7 @@ namespace PSProxmoxVE.Core.Tests.Services
Assert.NotNull(task);
Assert.Contains("startall", task.Upid);
Assert.Equal("pve1", task.Node);
Assert.Equal("running", task.Status);
mockClient.Verify(c => c.PostAsync(
"nodes/pve1/startall",
It.IsAny<Dictionary<string, string>>()),
@@ -275,6 +276,7 @@ namespace PSProxmoxVE.Core.Tests.Services
Assert.NotNull(task);
Assert.Contains("stopall", task.Upid);
Assert.Equal("pve1", task.Node);
Assert.Equal("running", task.Status);
mockClient.Verify(c => c.PostAsync(
"nodes/pve1/stopall",
It.IsAny<Dictionary<string, string>>()),
@@ -40,6 +40,7 @@ namespace PSProxmoxVE.Core.Tests.Services
// Assert
Assert.Equal(upid, task.Upid);
Assert.Equal(Node, task.Node);
Assert.Equal("running", task.Status);
mockClient.Verify(c => c.PostAsync(
$"nodes/{Node}/qemu/{VmId}/template",
It.IsAny<Dictionary<string, string>>()),
@@ -130,6 +130,7 @@ namespace PSProxmoxVE.Core.Tests.Services
// config lock; the native endpoint keeps the whole restart server-side.
Assert.Equal($"nodes/{TestNode}/qemu/{TestVmId}/status/reboot", resource);
Assert.Contains("qmreboot", task.Upid);
Assert.Equal("running", task.Status);
}
[Fact]
@@ -1,10 +1,4 @@
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using Moq.Protected;
namespace PSProxmoxVE.Core.Tests
{
@@ -15,38 +9,5 @@ namespace PSProxmoxVE.Core.Tests
var path = Path.Combine("Fixtures", filename);
return File.ReadAllText(path);
}
public static Mock<HttpMessageHandler> CreateMockHandler(string responseBody, HttpStatusCode statusCode = HttpStatusCode.OK)
{
var mock = new Mock<HttpMessageHandler>();
mock.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = statusCode,
Content = new StringContent(responseBody)
});
return mock;
}
public static Mock<HttpMessageHandler> CreateMockHandlerSequence(params (string body, HttpStatusCode status)[] responses)
{
var mock = new Mock<HttpMessageHandler>();
var setup = mock.Protected()
.SetupSequence<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>());
foreach (var (body, status) in responses)
{
setup = setup.ReturnsAsync(new HttpResponseMessage
{
StatusCode = status,
Content = new StringContent(body)
});
}
return mock;
}
}
}
@@ -9,7 +9,6 @@ namespace PSProxmoxVE.Core.Tests.Utilities
[Theory]
[InlineData(true)]
[InlineData(1L)]
[InlineData(1)]
[InlineData("1")]
public void IsExited_TrueValues_ReturnsTrue(object value)
{
@@ -19,12 +18,12 @@ namespace PSProxmoxVE.Core.Tests.Utilities
[Theory]
[InlineData(false)]
[InlineData(0L)]
[InlineData(0)]
[InlineData("0")]
[InlineData(null)]
[InlineData("")]
[InlineData("true")]
[InlineData(2L)]
[InlineData(1)]
[InlineData(42)]
public void IsExited_FalseValues_ReturnsFalse(object? value)
{
@@ -0,0 +1,45 @@
using PSProxmoxVE.Core.Utilities;
using Xunit;
namespace PSProxmoxVE.Core.Tests.Utilities
{
public class PveTaskResponseTests
{
[Fact]
public void Parse_UpidString_ReturnsRunningTaskWithNode()
{
var task = PveTaskResponse.Parse(
"{\"data\":\"UPID:pve1:00001234:00005678:AABBCCDD:qmstart:100:root@pam:\"}",
"pve1");
Assert.Equal("UPID:pve1:00001234:00005678:AABBCCDD:qmstart:100:root@pam:", task.Upid);
Assert.Equal("pve1", task.Node);
Assert.Equal("running", task.Status);
}
[Fact]
public void Parse_TaskObject_DeserializesAndStampsNode()
{
var task = PveTaskResponse.Parse(
"{\"data\":{\"upid\":\"UPID:pve2:00000001:00000002:00000003:vzdump::root@pam:\",\"status\":\"stopped\",\"exitstatus\":\"OK\",\"node\":\"ignored\"}}",
"pve2");
Assert.Equal("UPID:pve2:00000001:00000002:00000003:vzdump::root@pam:", task.Upid);
Assert.Equal("stopped", task.Status);
Assert.Equal("OK", task.ExitStatus);
Assert.Equal("pve2", task.Node);
}
[Theory]
[InlineData("{\"data\":null}")]
[InlineData("{}")]
public void Parse_NullOrAbsentData_ReturnsEmptyTaskWithNode(string json)
{
var task = PveTaskResponse.Parse(json, "pve3");
Assert.Equal(string.Empty, task.Upid);
Assert.Null(task.Status);
Assert.Equal("pve3", task.Node);
}
}
}