refactor: typed models for the remaining dictionary-returning services (#157) (#229)

NodeService.GetNodeConfig/GetNodeDns, ClusterConfigService.GetClusterConfig,
BackupService.GetNotBackedUp and VmService.GetGuestExecStatus returned raw
Dictionary/List<Dictionary> instead of a Pve* model, per issue #157. Each now
has a typed model under Models/{Nodes,Cluster,Backup,Vms}/ with [JsonProperty]
for documented fields and a [JsonExtensionData]-backed AdditionalProperties
catch-all, following the PveVmConfig pattern. The five consuming cmdlets and
their [OutputType] attributes are updated to match.

GetClusterConfig also fixes a latent bug: GET /cluster/config returns a JSON
array (a directory index), but the old code did `data is JObject obj ? ... :
empty dict`, which silently always returned an empty dictionary since data was
a JArray. PveClusterConfigEntry decodes the array correctly and exposes a
typed Name property (the array items' schema documents no named fields, but
the endpoint's "links" metadata gives the child-URL template as "{name}").

The guest-exec poll loop in InvokePveVmGuestExecCmdlet keeps its exact
Stopwatch + Thread.Sleep(1000) structure (ADR 0001 accepted exception); only
the type it reads from changed. A TolerantBooleanConverter was added so
PveGuestExecStatus.Exited keeps accepting PVE's boolean/integer/string forms,
matching what ApiValueHelper.IsExited already tolerated for the old
dictionary path.

Reviewed with codex-rescue, correctness-reviewer and api-compat-reviewer
before commit; both real findings above (the name/subdir key and the Exited
string-form regression) came from that pass and are mutation-tested.

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 19:16:29 +00:00
committed by GitHub
parent 9a92577794
commit 0026ef2b57
29 changed files with 652 additions and 78 deletions
+1 -1
View File
@@ -68,7 +68,7 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable
### None
## OUTPUTS
### System.Management.Automation.PSObject
### PSProxmoxVE.Core.Models.Backup.PveBackupInfo
## NOTES
## RELATED LINKS
+1 -1
View File
@@ -68,7 +68,7 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable
### None
## OUTPUTS
### System.Collections.Generic.Dictionary`2[[System.String, System.Private.CoreLib, Version=10.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e],[System.Object, System.Private.CoreLib, Version=10.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]]
### PSProxmoxVE.Core.Models.Cluster.PveClusterConfigEntry
## NOTES
## RELATED LINKS
+1 -1
View File
@@ -84,7 +84,7 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable
### System.String
## OUTPUTS
### System.Management.Automation.PSObject
### PSProxmoxVE.Core.Models.Nodes.PveNodeConfig
## NOTES
## RELATED LINKS
+1 -1
View File
@@ -84,7 +84,7 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable
### System.String
## OUTPUTS
### System.Management.Automation.PSObject
### PSProxmoxVE.Core.Models.Nodes.PveNodeDns
## NOTES
## RELATED LINKS
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Core.Models.Backup;
/// <summary>
/// Represents one guest not covered by any backup job, as returned by
/// the /cluster/backup-info/not-backed-up endpoint.
/// </summary>
public class PveBackupInfo
{
/// <summary>
/// Name of the guest.
/// </summary>
[JsonProperty("name")]
public string? Name { get; set; }
/// <summary>
/// Type of the guest ("qemu" or "lxc").
/// </summary>
[JsonProperty("type")]
public string? Type { get; set; }
/// <summary>
/// VMID of the guest.
/// </summary>
[JsonProperty("vmid")]
public int? VmId { get; set; }
/// <summary>
/// Raw landing spot for any key not mapped to a typed property above.
/// </summary>
[JsonExtensionData]
private IDictionary<string, JToken>? ExtensionData { get; set; }
private Dictionary<string, object?>? _additionalProperties;
/// <summary>
/// Any keys not surfaced as a typed property above. Keys map to native
/// .NET values so the dictionary works naturally in PowerShell pipelines.
/// </summary>
[JsonIgnore]
public Dictionary<string, object?> AdditionalProperties =>
_additionalProperties ??= ExtensionData == null
? new Dictionary<string, object?>()
: ExtensionData.ToDictionary(kvp => kvp.Key, kvp => JsonHelper.ToNative(kvp.Value));
/// <inheritdoc />
public override string ToString()
{
return $"{Type ?? "guest"} {VmId?.ToString() ?? "?"} ({Name ?? "N/A"}) not backed up";
}
}
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Core.Models.Cluster;
/// <summary>
/// Represents one entry of the directory index returned by GET /cluster/config.
/// The endpoint's item schema documents no named properties ("properties": {}),
/// but its "links" metadata gives the child-URL template as "{name}", so every
/// entry carries a "name" key (e.g. "nodes", "totem", "qdevice", "join",
/// "apiversion"). Any other key lands in <see cref="AdditionalProperties"/>.
/// </summary>
public class PveClusterConfigEntry
{
/// <summary>
/// The sub-resource name (e.g. "nodes", "totem", "qdevice", "join", "apiversion").
/// </summary>
[JsonProperty("name")]
public string? Name { get; set; }
/// <summary>
/// Raw landing spot for any key not mapped to a typed property above.
/// </summary>
[JsonExtensionData]
private IDictionary<string, JToken>? ExtensionData { get; set; }
private Dictionary<string, object?>? _additionalProperties;
/// <summary>
/// Any keys not surfaced as a typed property above. Keys map to native
/// .NET values so the dictionary works naturally in PowerShell pipelines.
/// </summary>
[JsonIgnore]
public Dictionary<string, object?> AdditionalProperties =>
_additionalProperties ??= ExtensionData == null
? new Dictionary<string, object?>()
: ExtensionData.ToDictionary(kvp => kvp.Key, kvp => JsonHelper.ToNative(kvp.Value));
/// <inheritdoc />
public override string ToString()
{
return $"Cluster Config Entry: {Name ?? "N/A"}";
}
}
@@ -0,0 +1,81 @@
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Core.Models.Nodes;
/// <summary>
/// Represents the configuration of a Proxmox VE node,
/// as returned by the /nodes/{node}/config endpoint.
/// </summary>
public class PveNodeConfig
{
/// <summary>
/// Node specific ACME settings.
/// </summary>
[JsonProperty("acme")]
public string? Acme { get; set; }
/// <summary>
/// RAM usage target for ballooning, in percent of total memory.
/// </summary>
[JsonProperty("ballooning-target")]
public int? BallooningTarget { get; set; }
/// <summary>
/// Description for the node, shown in the web interface node notes panel.
/// </summary>
[JsonProperty("description")]
public string? Description { get; set; }
/// <summary>
/// SHA1 digest of the current configuration file.
/// </summary>
[JsonProperty("digest")]
public string? Digest { get; set; }
/// <summary>
/// The location of the node, overriding the datacenter config default.
/// </summary>
[JsonProperty("location")]
public string? Location { get; set; }
/// <summary>
/// Initial delay in seconds before starting all on-boot Virtual Guests.
/// </summary>
[JsonProperty("startall-onboot-delay")]
public int? StartAllOnbootDelay { get; set; }
/// <summary>
/// Node specific wake-on-LAN settings.
/// </summary>
[JsonProperty("wakeonlan")]
public string? WakeOnLan { get; set; }
/// <summary>
/// Raw landing spot for any config key not mapped to a typed property above
/// (e.g. per-domain "acmedomain0", "acmedomain1", ... entries).
/// </summary>
[JsonExtensionData]
private IDictionary<string, JToken>? ExtensionData { get; set; }
private Dictionary<string, object?>? _additionalProperties;
/// <summary>
/// Any node config keys not surfaced as a typed property above. Keys map to
/// native .NET values so the dictionary works naturally in PowerShell pipelines.
/// </summary>
[JsonIgnore]
public Dictionary<string, object?> AdditionalProperties =>
_additionalProperties ??= ExtensionData == null
? new Dictionary<string, object?>()
: ExtensionData.ToDictionary(kvp => kvp.Key, kvp => JsonHelper.ToNative(kvp.Value));
/// <inheritdoc />
public override string ToString()
{
return $"Node Config | Description: {Description ?? "N/A"} | WakeOnLan: {WakeOnLan ?? "N/A"}";
}
}
@@ -0,0 +1,62 @@
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Core.Models.Nodes;
/// <summary>
/// Represents the DNS configuration of a Proxmox VE node,
/// as returned by the /nodes/{node}/dns endpoint.
/// </summary>
public class PveNodeDns
{
/// <summary>
/// First name server IP address.
/// </summary>
[JsonProperty("dns1")]
public string? Dns1 { get; set; }
/// <summary>
/// Second name server IP address.
/// </summary>
[JsonProperty("dns2")]
public string? Dns2 { get; set; }
/// <summary>
/// Third name server IP address.
/// </summary>
[JsonProperty("dns3")]
public string? Dns3 { get; set; }
/// <summary>
/// Search domain for host-name lookup.
/// </summary>
[JsonProperty("search")]
public string? Search { get; set; }
/// <summary>
/// Raw landing spot for any DNS config key not mapped to a typed property above.
/// </summary>
[JsonExtensionData]
private IDictionary<string, JToken>? ExtensionData { get; set; }
private Dictionary<string, object?>? _additionalProperties;
/// <summary>
/// Any DNS config keys not surfaced as a typed property above. Keys map to
/// native .NET values so the dictionary works naturally in PowerShell pipelines.
/// </summary>
[JsonIgnore]
public Dictionary<string, object?> AdditionalProperties =>
_additionalProperties ??= ExtensionData == null
? new Dictionary<string, object?>()
: ExtensionData.ToDictionary(kvp => kvp.Key, kvp => JsonHelper.ToNative(kvp.Value));
/// <inheritdoc />
public override string ToString()
{
return $"DNS | Search: {Search ?? "N/A"} | {Dns1 ?? "N/A"}, {Dns2 ?? "N/A"}, {Dns3 ?? "N/A"}";
}
}
@@ -0,0 +1,85 @@
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Core.Models.Vms;
/// <summary>
/// Represents the status of a guest-agent command started via exec, as returned
/// by the /nodes/{node}/qemu/{vmid}/agent/exec-status endpoint.
/// </summary>
public class PveGuestExecStatus
{
/// <summary>
/// Whether the command has exited yet. PVE has been observed sending this as
/// a boolean, an integer (1/0), or a string ("1"/"0"); <see cref="TolerantBooleanConverter"/>
/// normalizes all three.
/// </summary>
[JsonProperty("exited")]
[JsonConverter(typeof(TolerantBooleanConverter))]
public bool? Exited { get; set; }
/// <summary>
/// Process exit code, if it was normally terminated.
/// </summary>
[JsonProperty("exitcode")]
public int? ExitCode { get; set; }
/// <summary>
/// Signal number or exception code, if the process was abnormally terminated.
/// </summary>
[JsonProperty("signal")]
public int? Signal { get; set; }
/// <summary>
/// Base64-encoded stdout of the process.
/// </summary>
[JsonProperty("out-data")]
public string? OutData { get; set; }
/// <summary>
/// Base64-encoded stderr of the process.
/// </summary>
[JsonProperty("err-data")]
public string? ErrData { get; set; }
/// <summary>
/// True if stdout was not fully captured.
/// </summary>
[JsonProperty("out-truncated")]
public bool? OutTruncated { get; set; }
/// <summary>
/// True if stderr was not fully captured.
/// </summary>
[JsonProperty("err-truncated")]
public bool? ErrTruncated { get; set; }
/// <summary>
/// Raw landing spot for any key not mapped to a typed property above.
/// </summary>
[JsonExtensionData]
private IDictionary<string, JToken>? ExtensionData { get; set; }
private Dictionary<string, object?>? _additionalProperties;
/// <summary>
/// Any keys not surfaced as a typed property above. Keys map to native
/// .NET values so the dictionary works naturally in PowerShell pipelines.
/// </summary>
[JsonIgnore]
public Dictionary<string, object?> AdditionalProperties =>
_additionalProperties ??= ExtensionData == null
? new Dictionary<string, object?>()
: ExtensionData.ToDictionary(kvp => kvp.Key, kvp => JsonHelper.ToNative(kvp.Value));
/// <inheritdoc />
public override string ToString()
{
return Exited == true
? $"Exec Status | Exited, code {ExitCode?.ToString() ?? "N/A"}"
: "Exec Status | Running";
}
}
@@ -139,7 +139,7 @@ namespace PSProxmoxVE.Core.Services
/// <summary>
/// Returns the list of guests not covered by any backup job.
/// </summary>
public List<Dictionary<string, object?>> GetNotBackedUp(PveSession session)
public List<PveBackupInfo> GetNotBackedUp(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
@@ -148,7 +148,7 @@ namespace PSProxmoxVE.Core.Services
var response = client.GetAsync("cluster/backup-info/not-backed-up")
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return JsonHelper.ToListOfDictionaries(data as JArray);
return data?.ToObject<List<PveBackupInfo>>() ?? new List<PveBackupInfo>();
});
}
}
@@ -6,7 +6,6 @@ using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Exceptions;
using PSProxmoxVE.Core.Models.Cluster;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Core.Services
{
@@ -39,10 +38,11 @@ namespace PSProxmoxVE.Core.Services
}
/// <summary>
/// Returns the cluster configuration directory (GET /cluster/config).
/// The response is a mixed structure returned as a Dictionary.
/// Returns the cluster configuration directory (GET /cluster/config). The
/// endpoint is a directory index: an array of entries (name: "nodes",
/// "totem", "qdevice", "join", "apiversion"), not a single object.
/// </summary>
public Dictionary<string, object?> GetClusterConfig(PveSession session)
public List<PveClusterConfigEntry> GetClusterConfig(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
@@ -50,7 +50,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.GetAsync("cluster/config").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data is JObject obj ? JsonHelper.ToDictionary(obj) : new Dictionary<string, object?>();
return data?.ToObject<List<PveClusterConfigEntry>>() ?? new List<PveClusterConfigEntry>();
});
}
+4 -4
View File
@@ -71,7 +71,7 @@ namespace PSProxmoxVE.Core.Services
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="node">The cluster node name.</param>
public Dictionary<string, object?> GetNodeConfig(PveSession session, string node)
public PveNodeConfig GetNodeConfig(PveSession session, string node)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
@@ -80,7 +80,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.GetAsync($"nodes/{Uri.EscapeDataString(node)}/config").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return JsonHelper.ToDictionary(data as JObject);
return data?.ToObject<PveNodeConfig>() ?? new PveNodeConfig();
});
}
@@ -107,7 +107,7 @@ namespace PSProxmoxVE.Core.Services
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="node">The cluster node name.</param>
public Dictionary<string, object?> GetNodeDns(PveSession session, string node)
public PveNodeDns GetNodeDns(PveSession session, string node)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
@@ -116,7 +116,7 @@ namespace PSProxmoxVE.Core.Services
{
var response = client.GetAsync($"nodes/{Uri.EscapeDataString(node)}/dns").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return JsonHelper.ToDictionary(data as JObject);
return data?.ToObject<PveNodeDns>() ?? new PveNodeDns();
});
}
+2 -2
View File
@@ -605,7 +605,7 @@ namespace PSProxmoxVE.Core.Services
/// <summary>
/// Gets the status/result of a guest agent exec command by PID.
/// </summary>
public Dictionary<string, object?> GetGuestExecStatus(PveSession session, string node, int vmid, int pid)
public PveGuestExecStatus GetGuestExecStatus(PveSession session, string node, int vmid, int pid)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
@@ -615,7 +615,7 @@ namespace PSProxmoxVE.Core.Services
var response = client.GetAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/agent/exec-status?pid={pid}")
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return JsonHelper.ToDictionary(data as JObject);
return data?.ToObject<PveGuestExecStatus>() ?? new PveGuestExecStatus();
});
}
@@ -43,4 +43,25 @@ namespace PSProxmoxVE.Core.Utilities
serializer.Serialize(writer, value);
}
}
/// <summary>
/// JSON converter for a boolean field the PVE API sends as boolean, integer
/// (1/0), or string ("1"/"0") depending on endpoint and version. Delegates
/// the tolerant interpretation to <see cref="ApiValueHelper.IsExited"/>.
/// </summary>
public class TolerantBooleanConverter : JsonConverter<bool?>
{
/// <inheritdoc />
public override bool? ReadJson(JsonReader reader, Type objectType, bool? existingValue, bool hasExistingValue, JsonSerializer serializer)
{
var token = JToken.Load(reader);
return token.Type == JTokenType.Null ? (bool?)null : ApiValueHelper.IsExited(JsonHelper.ToNative(token));
}
/// <inheritdoc />
public override void WriteJson(JsonWriter writer, bool? value, JsonSerializer serializer)
{
serializer.Serialize(writer, value);
}
}
}
@@ -1,5 +1,5 @@
using System.Collections.Generic;
using System.Management.Automation;
using PSProxmoxVE.Core.Models.Backup;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Backup
@@ -12,7 +12,7 @@ namespace PSProxmoxVE.Cmdlets.Backup
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.Get, "PveBackupInfo")]
[OutputType(typeof(PSObject))]
[OutputType(typeof(PveBackupInfo))]
public sealed class GetPveBackupInfoCmdlet : PveCmdletBase
{
protected override void ProcessPveRecord()
@@ -26,12 +26,7 @@ namespace PSProxmoxVE.Cmdlets.Backup
foreach (var item in items)
{
var pso = new PSObject();
foreach (var kvp in item)
{
pso.Properties.Add(new PSNoteProperty(kvp.Key, kvp.Value));
}
WriteObject(pso);
WriteObject(item);
}
}
}
@@ -1,5 +1,5 @@
using System.Collections.Generic;
using System.Management.Automation;
using PSProxmoxVE.Core.Models.Cluster;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Cluster
@@ -7,12 +7,12 @@ namespace PSProxmoxVE.Cmdlets.Cluster
/// <summary>
/// <para type="synopsis">Gets the cluster configuration.</para>
/// <para type="description">
/// Returns the raw cluster configuration including nodes, totem settings,
/// and cluster version information.
/// Returns the cluster configuration directory (GET /cluster/config): one entry
/// per available sub-resource (nodes, totem, qdevice, join, apiversion).
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.Get, "PveClusterConfig")]
[OutputType(typeof(Dictionary<string, object>))]
[OutputType(typeof(PveClusterConfigEntry))]
public sealed class GetPveClusterConfigCmdlet : PveCmdletBase
{
protected override void ProcessPveRecord()
@@ -21,8 +21,11 @@ namespace PSProxmoxVE.Cmdlets.Cluster
var service = new ClusterConfigService();
WriteVerbose("Getting cluster configuration...");
var config = service.GetClusterConfig(session);
WriteObject(config);
var entries = service.GetClusterConfig(session);
foreach (var entry in entries)
{
WriteObject(entry);
}
}
}
}
@@ -1,4 +1,5 @@
using System.Management.Automation;
using PSProxmoxVE.Core.Models.Nodes;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Nodes
@@ -11,7 +12,7 @@ namespace PSProxmoxVE.Cmdlets.Nodes
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.Get, "PveNodeConfig")]
[OutputType(typeof(PSObject))]
[OutputType(typeof(PveNodeConfig))]
public sealed class GetPveNodeConfigCmdlet : PveCmdletBase
{
/// <summary>The Proxmox VE node name.</summary>
@@ -27,13 +28,7 @@ namespace PSProxmoxVE.Cmdlets.Nodes
WriteVerbose($"Getting configuration for node '{Node}'...");
var config = service.GetNodeConfig(session, Node);
var psObj = new PSObject();
foreach (var kvp in config)
{
psObj.Properties.Add(new PSNoteProperty(kvp.Key, kvp.Value));
}
WriteObject(psObj);
WriteObject(config);
}
}
}
@@ -1,4 +1,5 @@
using System.Management.Automation;
using PSProxmoxVE.Core.Models.Nodes;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Nodes
@@ -11,7 +12,7 @@ namespace PSProxmoxVE.Cmdlets.Nodes
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.Get, "PveNodeDns")]
[OutputType(typeof(PSObject))]
[OutputType(typeof(PveNodeDns))]
public sealed class GetPveNodeDnsCmdlet : PveCmdletBase
{
/// <summary>The Proxmox VE node name.</summary>
@@ -27,13 +28,7 @@ namespace PSProxmoxVE.Cmdlets.Nodes
WriteVerbose($"Getting DNS configuration for node '{Node}'...");
var dns = service.GetNodeDns(session, Node);
var psObj = new PSObject();
foreach (var kvp in dns)
{
psObj.Properties.Add(new PSNoteProperty(kvp.Key, kvp.Value));
}
WriteObject(psObj);
WriteObject(dns);
}
}
}
@@ -1,6 +1,7 @@
using System;
using System.Diagnostics;
using System.Management.Automation;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
using PSProxmoxVE.Core.Utilities;
@@ -54,19 +55,19 @@ namespace PSProxmoxVE.Cmdlets.Vms
// Poll for completion with timeout
var sw = Stopwatch.StartNew();
var deadline = TimeSpan.FromSeconds(Timeout);
System.Collections.Generic.Dictionary<string, object?> result;
PveGuestExecStatus result;
do
{
System.Threading.Thread.Sleep(1000);
if (sw.Elapsed >= deadline)
throw new TimeoutException($"Guest command did not complete within {Timeout} seconds.");
result = service.GetGuestExecStatus(session, Node, VmId, pid);
} while (!result.TryGetValue("exited", out var exited) || !ApiValueHelper.IsExited(exited));
} while (!ApiValueHelper.IsExited(result.Exited));
var output = new PSObject();
output.Properties.Add(new PSNoteProperty("ExitCode", result.TryGetValue("exitcode", out var ec) && ec is long ecl ? (int)ecl : -1));
output.Properties.Add(new PSNoteProperty("Stdout", DecodeBase64(result.TryGetValue("out-data", out var od) ? od?.ToString() : null)));
output.Properties.Add(new PSNoteProperty("Stderr", DecodeBase64(result.TryGetValue("err-data", out var ed) ? ed?.ToString() : null)));
output.Properties.Add(new PSNoteProperty("ExitCode", result.ExitCode ?? -1));
output.Properties.Add(new PSNoteProperty("Stdout", DecodeBase64(result.OutData)));
output.Properties.Add(new PSNoteProperty("Stderr", DecodeBase64(result.ErrData)));
output.Properties.Add(new PSNoteProperty("Pid", pid));
WriteObject(output);
+4 -4
View File
@@ -2088,7 +2088,7 @@
<command:returnValues>
<command:returnValue>
<dev:type>
<maml:name>System.Management.Automation.PSObject</maml:name>
<maml:name>PSProxmoxVE.Core.Models.Backup.PveBackupInfo</maml:name>
</dev:type>
<maml:description>
<maml:para></maml:para>
@@ -2476,7 +2476,7 @@
<command:returnValues>
<command:returnValue>
<dev:type>
<maml:name>System.Collections.Generic.Dictionary`2[[System.String, System.Private.CoreLib, Version=10.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e],[System.Object, System.Private.CoreLib, Version=10.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]]</maml:name>
<maml:name>PSProxmoxVE.Core.Models.Cluster.PveClusterConfigEntry</maml:name>
</dev:type>
<maml:description>
<maml:para></maml:para>
@@ -6478,7 +6478,7 @@
<command:returnValues>
<command:returnValue>
<dev:type>
<maml:name>System.Management.Automation.PSObject</maml:name>
<maml:name>PSProxmoxVE.Core.Models.Nodes.PveNodeConfig</maml:name>
</dev:type>
<maml:description>
<maml:para></maml:para>
@@ -6605,7 +6605,7 @@
<command:returnValues>
<command:returnValue>
<dev:type>
<maml:name>System.Management.Automation.PSObject</maml:name>
<maml:name>PSProxmoxVE.Core.Models.Nodes.PveNodeDns</maml:name>
</dev:type>
<maml:description>
<maml:para></maml:para>
+26
View File
@@ -1415,5 +1415,31 @@
</TableControl>
</View>
<!-- PSProxmoxVE.Core.Models.Cluster.PveClusterConfigEntry -->
<View>
<Name>PSProxmoxVE.Core.Models.Cluster.PveClusterConfigEntry</Name>
<ViewSelectedBy>
<TypeName>PSProxmoxVE.Core.Models.Cluster.PveClusterConfigEntry</TypeName>
</ViewSelectedBy>
<TableControl>
<TableHeaders>
<TableColumnHeader>
<Label>Name</Label>
<Width>20</Width>
<Alignment>Left</Alignment>
</TableColumnHeader>
</TableHeaders>
<TableRowEntries>
<TableRowEntry>
<TableColumnItems>
<TableColumnItem>
<PropertyName>Name</PropertyName>
</TableColumnItem>
</TableColumnItems>
</TableRowEntry>
</TableRowEntries>
</TableControl>
</View>
</ViewDefinitions>
</Configuration>
@@ -122,5 +122,33 @@ namespace PSProxmoxVE.Core.Tests.Models
Assert.Null(jobs[1].Node);
Assert.Null(jobs[1].Exclude);
}
[Fact]
public void PveBackupInfo_Deserialize_HasDocumentedFields()
{
var json = @"{""data"": [
{ ""vmid"": 100, ""name"": ""webserver"", ""type"": ""qemu"" }
]}";
var data = JObject.Parse(json)["data"];
Assert.NotNull(data);
var items = data.ToObject<PveBackupInfo[]>();
Assert.NotNull(items);
Assert.Equal(100, items[0].VmId);
Assert.Equal("webserver", items[0].Name);
Assert.Equal("qemu", items[0].Type);
}
[Fact]
public void PveBackupInfo_UnmappedKey_LandsInAdditionalProperties()
{
var json = @"{""data"": [
{ ""vmid"": 200, ""name"": ""db"", ""type"": ""lxc"", ""comment"": ""prod"" }
]}";
var data = JObject.Parse(json)["data"];
var items = data!.ToObject<PveBackupInfo[]>();
Assert.NotNull(items);
Assert.Equal("prod", items![0].AdditionalProperties["comment"]);
Assert.False(items[0].AdditionalProperties.ContainsKey("vmid"));
}
}
}
@@ -143,5 +143,32 @@ namespace PSProxmoxVE.Core.Tests.Models
Assert.Null(entries[1].Nodes);
Assert.Null(entries[1].Quorate);
}
[Fact]
public void PveClusterConfigEntry_Deserialize_HasNameFromLinksTemplate()
{
// GET /cluster/config is a directory index: the item schema documents
// no named fields, but the endpoint's "links" metadata gives the
// child-URL template as "{name}", so every entry carries a "name" key.
var json = @"{""data"": [{""name"": ""nodes""}, {""name"": ""totem""}]}";
var data = JObject.Parse(json)["data"];
Assert.NotNull(data);
var entries = data.ToObject<System.Collections.Generic.List<PveClusterConfigEntry>>();
Assert.NotNull(entries);
Assert.Equal(2, entries!.Count);
Assert.Equal("nodes", entries[0].Name);
Assert.Equal("totem", entries[1].Name);
}
[Fact]
public void PveClusterConfigEntry_UnmappedKey_LandsInAdditionalProperties()
{
var json = @"{""data"": [{""name"": ""nodes"", ""extra"": ""x""}]}";
var data = JObject.Parse(json)["data"];
var entries = data!.ToObject<System.Collections.Generic.List<PveClusterConfigEntry>>();
Assert.NotNull(entries);
Assert.Equal("x", entries![0].AdditionalProperties["extra"]);
Assert.False(entries[0].AdditionalProperties.ContainsKey("name"));
}
}
}
@@ -108,5 +108,62 @@ namespace PSProxmoxVE.Core.Tests.Models
Assert.NotNull(nodes[0].LoadAverage);
Assert.Equal(3, nodes[0].LoadAverage!.Length);
}
[Fact]
public void PveNodeConfig_Deserialize_HasDocumentedFields()
{
var json = @"{""data"": {
""description"": ""Primary node"",
""wakeonlan"": ""AA:BB:CC:DD:EE:FF"",
""ballooning-target"": 80,
""startall-onboot-delay"": 30,
""digest"": ""abc123""
}}";
var data = JObject.Parse(json)["data"];
Assert.NotNull(data);
var config = data.ToObject<PveNodeConfig>();
Assert.NotNull(config);
Assert.Equal("Primary node", config.Description);
Assert.Equal("AA:BB:CC:DD:EE:FF", config.WakeOnLan);
Assert.Equal(80, config.BallooningTarget);
Assert.Equal(30, config.StartAllOnbootDelay);
Assert.Equal("abc123", config.Digest);
}
[Fact]
public void PveNodeConfig_UnmappedKey_LandsInAdditionalProperties()
{
var json = @"{""data"": {""description"": ""n1"", ""acmedomain0"": ""example.com,plugin=dns""}}";
var data = JObject.Parse(json)["data"];
var config = data!.ToObject<PveNodeConfig>();
Assert.NotNull(config);
Assert.Equal("example.com,plugin=dns", config!.AdditionalProperties["acmedomain0"]);
Assert.False(config.AdditionalProperties.ContainsKey("description"));
}
[Fact]
public void PveNodeDns_Deserialize_HasDocumentedFields()
{
var json = @"{""data"": {""dns1"": ""8.8.8.8"", ""dns2"": ""8.8.4.4"", ""dns3"": ""1.1.1.1"", ""search"": ""example.com""}}";
var data = JObject.Parse(json)["data"];
Assert.NotNull(data);
var dns = data.ToObject<PveNodeDns>();
Assert.NotNull(dns);
Assert.Equal("8.8.8.8", dns.Dns1);
Assert.Equal("8.8.4.4", dns.Dns2);
Assert.Equal("1.1.1.1", dns.Dns3);
Assert.Equal("example.com", dns.Search);
}
[Fact]
public void PveNodeDns_UnmappedKey_LandsInAdditionalProperties()
{
var json = @"{""data"": {""dns1"": ""8.8.8.8"", ""dns4"": ""9.9.9.9""}}";
var data = JObject.Parse(json)["data"];
var dns = data!.ToObject<PveNodeDns>();
Assert.NotNull(dns);
Assert.Equal("9.9.9.9", dns!.AdditionalProperties["dns4"]);
Assert.False(dns.AdditionalProperties.ContainsKey("dns1"));
}
}
}
@@ -225,5 +225,53 @@ namespace PSProxmoxVE.Core.Tests.Models
Assert.False(config!.AdditionalProperties.ContainsKey("scsihw"));
Assert.False(config.AdditionalProperties.ContainsKey("cores"));
}
[Fact]
public void PveGuestExecStatus_Deserialize_HasDocumentedFields()
{
var json = @"{""data"": {
""exited"": true,
""exitcode"": 0,
""out-data"": ""aGVsbG8="",
""err-data"": """",
""out-truncated"": false
}}";
var data = JObject.Parse(json)["data"];
Assert.NotNull(data);
var status = data.ToObject<PveGuestExecStatus>();
Assert.NotNull(status);
Assert.True(status.Exited);
Assert.Equal(0, status.ExitCode);
Assert.Equal("aGVsbG8=", status.OutData);
Assert.Equal(string.Empty, status.ErrData);
Assert.False(status.OutTruncated);
}
[Theory]
[InlineData("true", true)]
[InlineData("false", false)]
[InlineData("1", true)]
[InlineData("0", false)]
[InlineData("\"1\"", true)]
[InlineData("\"0\"", false)]
public void PveGuestExecStatus_Exited_ToleratesBooleanIntegerAndStringForms(string exitedLiteral, bool expected)
{
var json = $@"{{""data"": {{""exited"": {exitedLiteral}}}}}";
var data = JObject.Parse(json)["data"];
var status = data!.ToObject<PveGuestExecStatus>();
Assert.NotNull(status);
Assert.Equal(expected, status!.Exited);
}
[Fact]
public void PveGuestExecStatus_UnmappedKey_LandsInAdditionalProperties()
{
var json = @"{""data"": {""exited"": false, ""newfield"": ""future""}}";
var data = JObject.Parse(json)["data"];
var status = data!.ToObject<PveGuestExecStatus>();
Assert.NotNull(status);
Assert.Equal("future", status!.AdditionalProperties["newfield"]);
Assert.False(status.AdditionalProperties.ContainsKey("exited"));
}
}
}
@@ -285,12 +285,12 @@ namespace PSProxmoxVE.Core.Tests.Services
// ---------------------------------------------------------------
[Fact]
public void GetNotBackedUp_ReturnsListOfDictionaries()
public void GetNotBackedUp_ReturnsTypedEntriesWithUnknownKeyInAdditionalProperties()
{
// Arrange
var json = @"{
""data"": [
{ ""vmid"": 100, ""name"": ""webserver"", ""type"": ""qemu"" },
{ ""vmid"": 100, ""name"": ""webserver"", ""type"": ""qemu"", ""comment"": ""prod"" },
{ ""vmid"": 200, ""name"": ""database"", ""type"": ""lxc"" }
]
}";
@@ -304,10 +304,11 @@ namespace PSProxmoxVE.Core.Tests.Services
var result = service.GetNotBackedUp(CreateSession());
// Assert
Assert.IsType<List<Dictionary<string, object?>>>(result);
Assert.Equal(2, result.Count);
Assert.Equal(100L, result[0]["vmid"]);
Assert.Equal("webserver", result[0]["name"]);
Assert.Equal(100, result[0].VmId);
Assert.Equal("webserver", result[0].Name);
Assert.Equal("qemu", result[0].Type);
Assert.Equal("prod", result[0].AdditionalProperties["comment"]);
}
[Fact]
@@ -21,10 +21,13 @@ namespace PSProxmoxVE.Core.Tests.Services
}
[Fact]
public void GetClusterConfig_ReturnsJObject()
public void GetClusterConfig_ReturnsTypedEntriesWithUnknownKeyInAdditionalProperties()
{
// Arrange
var json = @"{""data"": {""nodes"": {""pve1"": {}}, ""totem"": {""version"": ""2""}}}";
// Arrange — GET /cluster/config is a directory index: an array of
// entries, not a single object. The item schema documents no named
// fields, but the endpoint's "links" metadata gives the child-URL
// template as "{name}", so every entry carries a "name" key.
var json = @"{""data"": [{""name"": ""nodes""}, {""name"": ""totem"", ""extra"": ""x""}]}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/config")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
@@ -33,9 +36,10 @@ namespace PSProxmoxVE.Core.Tests.Services
var config = service.GetClusterConfig(CreateSession());
// Assert
Assert.NotNull(config);
Assert.NotNull(config["nodes"]);
Assert.NotNull(config["totem"]);
Assert.Equal(2, config.Count);
Assert.Equal("nodes", config[0].Name);
Assert.Equal("totem", config[1].Name);
Assert.Equal("x", config[1].AdditionalProperties["extra"]);
mockClient.Verify(c => c.GetAsync("cluster/config"), Times.Once);
}
@@ -138,10 +138,10 @@ namespace PSProxmoxVE.Core.Tests.Services
}
[Fact]
public void GetNodeConfig_ReturnsDictionary()
public void GetNodeConfig_ReturnsTypedModelWithUnknownKeyInAdditionalProperties()
{
// Arrange
var json = @"{""data"": {""description"": ""Primary node"", ""wakeonlan"": ""AA:BB:CC:DD:EE:FF""}}";
var json = @"{""data"": {""description"": ""Primary node"", ""wakeonlan"": ""AA:BB:CC:DD:EE:FF"", ""acmedomain0"": ""example.com,plugin=dns""}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("nodes/pve1/config")).ReturnsAsync(json);
var service = new NodeService(mockClient.Object);
@@ -151,9 +151,9 @@ namespace PSProxmoxVE.Core.Tests.Services
// Assert
Assert.NotNull(config);
Assert.IsType<Dictionary<string, object?>>(config);
Assert.Equal("Primary node", config["description"]?.ToString());
Assert.Equal("AA:BB:CC:DD:EE:FF", config["wakeonlan"]?.ToString());
Assert.Equal("Primary node", config.Description);
Assert.Equal("AA:BB:CC:DD:EE:FF", config.WakeOnLan);
Assert.Equal("example.com,plugin=dns", config.AdditionalProperties["acmedomain0"]);
mockClient.Verify(c => c.GetAsync("nodes/pve1/config"), Times.Once);
}
@@ -184,10 +184,10 @@ namespace PSProxmoxVE.Core.Tests.Services
}
[Fact]
public void GetNodeDns_ReturnsDictionary()
public void GetNodeDns_ReturnsTypedModelWithUnknownKeyInAdditionalProperties()
{
// Arrange
var json = @"{""data"": {""dns1"": ""8.8.8.8"", ""dns2"": ""8.8.4.4"", ""search"": ""example.com""}}";
var json = @"{""data"": {""dns1"": ""8.8.8.8"", ""dns2"": ""8.8.4.4"", ""search"": ""example.com"", ""dns4"": ""1.1.1.1""}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("nodes/pve1/dns")).ReturnsAsync(json);
var service = new NodeService(mockClient.Object);
@@ -197,10 +197,10 @@ namespace PSProxmoxVE.Core.Tests.Services
// Assert
Assert.NotNull(dns);
Assert.IsType<Dictionary<string, object?>>(dns);
Assert.Equal("8.8.8.8", dns["dns1"]?.ToString());
Assert.Equal("8.8.4.4", dns["dns2"]?.ToString());
Assert.Equal("example.com", dns["search"]?.ToString());
Assert.Equal("8.8.8.8", dns.Dns1);
Assert.Equal("8.8.4.4", dns.Dns2);
Assert.Equal("example.com", dns.Search);
Assert.Equal("1.1.1.1", dns.AdditionalProperties["dns4"]);
mockClient.Verify(c => c.GetAsync("nodes/pve1/dns"), Times.Once);
}
@@ -113,6 +113,48 @@ namespace PSProxmoxVE.Core.Tests.Services
Assert.Equal("args", ex.ParamName);
}
[Fact]
public void GetGuestExecStatus_ReturnsTypedModelWithUnknownKeyInAdditionalProperties()
{
var json = @"{""data"": {
""exited"": true,
""exitcode"": 0,
""out-data"": ""aGVsbG8="",
""err-data"": """",
""newfield"": ""future""
}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync($"nodes/{TestNode}/qemu/{TestVmId}/agent/exec-status?pid=4242"))
.ReturnsAsync(json);
var service = new VmService(mockClient.Object);
var status = service.GetGuestExecStatus(CreateSession(), TestNode, TestVmId, 4242);
Assert.True(status.Exited);
Assert.Equal(0, status.ExitCode);
Assert.Equal("aGVsbG8=", status.OutData);
Assert.Equal(string.Empty, status.ErrData);
Assert.Equal("future", status.AdditionalProperties["newfield"]);
}
[Fact]
public void GetGuestExecStatus_ExitedAsString_StillPollsToCompletion()
{
// PVE has been observed sending "exited" as the string "1"/"0" as well
// as a JSON boolean or integer; the poll loop in InvokePveVmGuestExecCmdlet
// must not throw on this shape.
var json = @"{""data"": {""exited"": ""1"", ""exitcode"": 0}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync($"nodes/{TestNode}/qemu/{TestVmId}/agent/exec-status?pid=99"))
.ReturnsAsync(json);
var service = new VmService(mockClient.Object);
var status = service.GetGuestExecStatus(CreateSession(), TestNode, TestVmId, 99);
Assert.True(status.Exited);
Assert.Equal(0, status.ExitCode);
}
[Fact]
public void RebootVm_PostsToTheNativeRebootEndpoint()
{