From 0026ef2b57129dc1790de859bc04acc2de4e4ac1 Mon Sep 17 00:00:00 2001 From: "goodolclint-claude[bot]" <323206664+goodolclint-claude[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:16:29 +0000 Subject: [PATCH] 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 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> --- docs/cmdlets/Get-PveBackupInfo.md | 2 +- docs/cmdlets/Get-PveClusterConfig.md | 2 +- docs/cmdlets/Get-PveNodeConfig.md | 2 +- docs/cmdlets/Get-PveNodeDns.md | 2 +- .../Models/Backup/PveBackupInfo.cs | 56 ++++++++++++ .../Models/Cluster/PveClusterConfigEntry.cs | 47 ++++++++++ .../Models/Nodes/PveNodeConfig.cs | 81 ++++++++++++++++++ .../Models/Nodes/PveNodeDns.cs | 62 ++++++++++++++ .../Models/Vms/PveGuestExecStatus.cs | 85 +++++++++++++++++++ .../Services/BackupService.cs | 4 +- .../Services/ClusterConfigService.cs | 10 +-- src/PSProxmoxVE.Core/Services/NodeService.cs | 8 +- src/PSProxmoxVE.Core/Services/VmService.cs | 4 +- .../Utilities/NativeJsonConverters.cs | 21 +++++ .../Cmdlets/Backup/GetPveBackupInfoCmdlet.cs | 11 +-- .../Cluster/GetPveClusterConfigCmdlet.cs | 15 ++-- .../Cmdlets/Nodes/GetPveNodeConfigCmdlet.cs | 11 +-- .../Cmdlets/Nodes/GetPveNodeDnsCmdlet.cs | 11 +-- .../Cmdlets/Vms/InvokePveVmGuestExecCmdlet.cs | 11 +-- src/PSProxmoxVE/PSProxmoxVE.dll-Help.xml | 8 +- src/PSProxmoxVE/PSProxmoxVE.format.ps1xml | 26 ++++++ .../Models/BackupModelTests.cs | 28 ++++++ .../Models/ClusterModelTests.cs | 27 ++++++ .../Models/NodeModelTests.cs | 57 +++++++++++++ .../Models/VmModelTests.cs | 48 +++++++++++ .../Services/BackupServiceTests.cs | 11 +-- .../Services/ClusterConfigServiceTests.cs | 16 ++-- .../Services/NodeServiceTests.cs | 22 ++--- .../Services/VmServiceTests.cs | 42 +++++++++ 29 files changed, 652 insertions(+), 78 deletions(-) create mode 100644 src/PSProxmoxVE.Core/Models/Backup/PveBackupInfo.cs create mode 100644 src/PSProxmoxVE.Core/Models/Cluster/PveClusterConfigEntry.cs create mode 100644 src/PSProxmoxVE.Core/Models/Nodes/PveNodeConfig.cs create mode 100644 src/PSProxmoxVE.Core/Models/Nodes/PveNodeDns.cs create mode 100644 src/PSProxmoxVE.Core/Models/Vms/PveGuestExecStatus.cs diff --git a/docs/cmdlets/Get-PveBackupInfo.md b/docs/cmdlets/Get-PveBackupInfo.md index dacb4ad..436a3f2 100644 --- a/docs/cmdlets/Get-PveBackupInfo.md +++ b/docs/cmdlets/Get-PveBackupInfo.md @@ -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 diff --git a/docs/cmdlets/Get-PveClusterConfig.md b/docs/cmdlets/Get-PveClusterConfig.md index 0d3dee8..20ea02b 100644 --- a/docs/cmdlets/Get-PveClusterConfig.md +++ b/docs/cmdlets/Get-PveClusterConfig.md @@ -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 diff --git a/docs/cmdlets/Get-PveNodeConfig.md b/docs/cmdlets/Get-PveNodeConfig.md index f3ef745..d34d578 100644 --- a/docs/cmdlets/Get-PveNodeConfig.md +++ b/docs/cmdlets/Get-PveNodeConfig.md @@ -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 diff --git a/docs/cmdlets/Get-PveNodeDns.md b/docs/cmdlets/Get-PveNodeDns.md index 6a8d46d..c39e015 100644 --- a/docs/cmdlets/Get-PveNodeDns.md +++ b/docs/cmdlets/Get-PveNodeDns.md @@ -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 diff --git a/src/PSProxmoxVE.Core/Models/Backup/PveBackupInfo.cs b/src/PSProxmoxVE.Core/Models/Backup/PveBackupInfo.cs new file mode 100644 index 0000000..77866a7 --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Backup/PveBackupInfo.cs @@ -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; + +/// +/// Represents one guest not covered by any backup job, as returned by +/// the /cluster/backup-info/not-backed-up endpoint. +/// +public class PveBackupInfo +{ + /// + /// Name of the guest. + /// + [JsonProperty("name")] + public string? Name { get; set; } + + /// + /// Type of the guest ("qemu" or "lxc"). + /// + [JsonProperty("type")] + public string? Type { get; set; } + + /// + /// VMID of the guest. + /// + [JsonProperty("vmid")] + public int? VmId { get; set; } + + /// + /// Raw landing spot for any key not mapped to a typed property above. + /// + [JsonExtensionData] + private IDictionary? ExtensionData { get; set; } + + private Dictionary? _additionalProperties; + + /// + /// Any keys not surfaced as a typed property above. Keys map to native + /// .NET values so the dictionary works naturally in PowerShell pipelines. + /// + [JsonIgnore] + public Dictionary AdditionalProperties => + _additionalProperties ??= ExtensionData == null + ? new Dictionary() + : ExtensionData.ToDictionary(kvp => kvp.Key, kvp => JsonHelper.ToNative(kvp.Value)); + + /// + public override string ToString() + { + return $"{Type ?? "guest"} {VmId?.ToString() ?? "?"} ({Name ?? "N/A"}) not backed up"; + } +} diff --git a/src/PSProxmoxVE.Core/Models/Cluster/PveClusterConfigEntry.cs b/src/PSProxmoxVE.Core/Models/Cluster/PveClusterConfigEntry.cs new file mode 100644 index 0000000..48ef450 --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Cluster/PveClusterConfigEntry.cs @@ -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; + +/// +/// 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 . +/// +public class PveClusterConfigEntry +{ + /// + /// The sub-resource name (e.g. "nodes", "totem", "qdevice", "join", "apiversion"). + /// + [JsonProperty("name")] + public string? Name { get; set; } + + /// + /// Raw landing spot for any key not mapped to a typed property above. + /// + [JsonExtensionData] + private IDictionary? ExtensionData { get; set; } + + private Dictionary? _additionalProperties; + + /// + /// Any keys not surfaced as a typed property above. Keys map to native + /// .NET values so the dictionary works naturally in PowerShell pipelines. + /// + [JsonIgnore] + public Dictionary AdditionalProperties => + _additionalProperties ??= ExtensionData == null + ? new Dictionary() + : ExtensionData.ToDictionary(kvp => kvp.Key, kvp => JsonHelper.ToNative(kvp.Value)); + + /// + public override string ToString() + { + return $"Cluster Config Entry: {Name ?? "N/A"}"; + } +} diff --git a/src/PSProxmoxVE.Core/Models/Nodes/PveNodeConfig.cs b/src/PSProxmoxVE.Core/Models/Nodes/PveNodeConfig.cs new file mode 100644 index 0000000..e16543b --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Nodes/PveNodeConfig.cs @@ -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; + +/// +/// Represents the configuration of a Proxmox VE node, +/// as returned by the /nodes/{node}/config endpoint. +/// +public class PveNodeConfig +{ + /// + /// Node specific ACME settings. + /// + [JsonProperty("acme")] + public string? Acme { get; set; } + + /// + /// RAM usage target for ballooning, in percent of total memory. + /// + [JsonProperty("ballooning-target")] + public int? BallooningTarget { get; set; } + + /// + /// Description for the node, shown in the web interface node notes panel. + /// + [JsonProperty("description")] + public string? Description { get; set; } + + /// + /// SHA1 digest of the current configuration file. + /// + [JsonProperty("digest")] + public string? Digest { get; set; } + + /// + /// The location of the node, overriding the datacenter config default. + /// + [JsonProperty("location")] + public string? Location { get; set; } + + /// + /// Initial delay in seconds before starting all on-boot Virtual Guests. + /// + [JsonProperty("startall-onboot-delay")] + public int? StartAllOnbootDelay { get; set; } + + /// + /// Node specific wake-on-LAN settings. + /// + [JsonProperty("wakeonlan")] + public string? WakeOnLan { get; set; } + + /// + /// Raw landing spot for any config key not mapped to a typed property above + /// (e.g. per-domain "acmedomain0", "acmedomain1", ... entries). + /// + [JsonExtensionData] + private IDictionary? ExtensionData { get; set; } + + private Dictionary? _additionalProperties; + + /// + /// 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. + /// + [JsonIgnore] + public Dictionary AdditionalProperties => + _additionalProperties ??= ExtensionData == null + ? new Dictionary() + : ExtensionData.ToDictionary(kvp => kvp.Key, kvp => JsonHelper.ToNative(kvp.Value)); + + /// + public override string ToString() + { + return $"Node Config | Description: {Description ?? "N/A"} | WakeOnLan: {WakeOnLan ?? "N/A"}"; + } +} diff --git a/src/PSProxmoxVE.Core/Models/Nodes/PveNodeDns.cs b/src/PSProxmoxVE.Core/Models/Nodes/PveNodeDns.cs new file mode 100644 index 0000000..42eb6d9 --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Nodes/PveNodeDns.cs @@ -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; + +/// +/// Represents the DNS configuration of a Proxmox VE node, +/// as returned by the /nodes/{node}/dns endpoint. +/// +public class PveNodeDns +{ + /// + /// First name server IP address. + /// + [JsonProperty("dns1")] + public string? Dns1 { get; set; } + + /// + /// Second name server IP address. + /// + [JsonProperty("dns2")] + public string? Dns2 { get; set; } + + /// + /// Third name server IP address. + /// + [JsonProperty("dns3")] + public string? Dns3 { get; set; } + + /// + /// Search domain for host-name lookup. + /// + [JsonProperty("search")] + public string? Search { get; set; } + + /// + /// Raw landing spot for any DNS config key not mapped to a typed property above. + /// + [JsonExtensionData] + private IDictionary? ExtensionData { get; set; } + + private Dictionary? _additionalProperties; + + /// + /// 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. + /// + [JsonIgnore] + public Dictionary AdditionalProperties => + _additionalProperties ??= ExtensionData == null + ? new Dictionary() + : ExtensionData.ToDictionary(kvp => kvp.Key, kvp => JsonHelper.ToNative(kvp.Value)); + + /// + public override string ToString() + { + return $"DNS | Search: {Search ?? "N/A"} | {Dns1 ?? "N/A"}, {Dns2 ?? "N/A"}, {Dns3 ?? "N/A"}"; + } +} diff --git a/src/PSProxmoxVE.Core/Models/Vms/PveGuestExecStatus.cs b/src/PSProxmoxVE.Core/Models/Vms/PveGuestExecStatus.cs new file mode 100644 index 0000000..d1e7ba3 --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Vms/PveGuestExecStatus.cs @@ -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; + +/// +/// Represents the status of a guest-agent command started via exec, as returned +/// by the /nodes/{node}/qemu/{vmid}/agent/exec-status endpoint. +/// +public class PveGuestExecStatus +{ + /// + /// Whether the command has exited yet. PVE has been observed sending this as + /// a boolean, an integer (1/0), or a string ("1"/"0"); + /// normalizes all three. + /// + [JsonProperty("exited")] + [JsonConverter(typeof(TolerantBooleanConverter))] + public bool? Exited { get; set; } + + /// + /// Process exit code, if it was normally terminated. + /// + [JsonProperty("exitcode")] + public int? ExitCode { get; set; } + + /// + /// Signal number or exception code, if the process was abnormally terminated. + /// + [JsonProperty("signal")] + public int? Signal { get; set; } + + /// + /// Base64-encoded stdout of the process. + /// + [JsonProperty("out-data")] + public string? OutData { get; set; } + + /// + /// Base64-encoded stderr of the process. + /// + [JsonProperty("err-data")] + public string? ErrData { get; set; } + + /// + /// True if stdout was not fully captured. + /// + [JsonProperty("out-truncated")] + public bool? OutTruncated { get; set; } + + /// + /// True if stderr was not fully captured. + /// + [JsonProperty("err-truncated")] + public bool? ErrTruncated { get; set; } + + /// + /// Raw landing spot for any key not mapped to a typed property above. + /// + [JsonExtensionData] + private IDictionary? ExtensionData { get; set; } + + private Dictionary? _additionalProperties; + + /// + /// Any keys not surfaced as a typed property above. Keys map to native + /// .NET values so the dictionary works naturally in PowerShell pipelines. + /// + [JsonIgnore] + public Dictionary AdditionalProperties => + _additionalProperties ??= ExtensionData == null + ? new Dictionary() + : ExtensionData.ToDictionary(kvp => kvp.Key, kvp => JsonHelper.ToNative(kvp.Value)); + + /// + public override string ToString() + { + return Exited == true + ? $"Exec Status | Exited, code {ExitCode?.ToString() ?? "N/A"}" + : "Exec Status | Running"; + } +} diff --git a/src/PSProxmoxVE.Core/Services/BackupService.cs b/src/PSProxmoxVE.Core/Services/BackupService.cs index 53010d6..cf4f3ee 100644 --- a/src/PSProxmoxVE.Core/Services/BackupService.cs +++ b/src/PSProxmoxVE.Core/Services/BackupService.cs @@ -139,7 +139,7 @@ namespace PSProxmoxVE.Core.Services /// /// Returns the list of guests not covered by any backup job. /// - public List> GetNotBackedUp(PveSession session) + public List 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>() ?? new List(); }); } } diff --git a/src/PSProxmoxVE.Core/Services/ClusterConfigService.cs b/src/PSProxmoxVE.Core/Services/ClusterConfigService.cs index bb9c9fc..d151e5e 100644 --- a/src/PSProxmoxVE.Core/Services/ClusterConfigService.cs +++ b/src/PSProxmoxVE.Core/Services/ClusterConfigService.cs @@ -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 } /// - /// 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. /// - public Dictionary GetClusterConfig(PveSession session) + public List 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(); + return data?.ToObject>() ?? new List(); }); } diff --git a/src/PSProxmoxVE.Core/Services/NodeService.cs b/src/PSProxmoxVE.Core/Services/NodeService.cs index d6d3d0a..25593e0 100644 --- a/src/PSProxmoxVE.Core/Services/NodeService.cs +++ b/src/PSProxmoxVE.Core/Services/NodeService.cs @@ -71,7 +71,7 @@ namespace PSProxmoxVE.Core.Services /// /// The authenticated PVE session. /// The cluster node name. - public Dictionary 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() ?? new PveNodeConfig(); }); } @@ -107,7 +107,7 @@ namespace PSProxmoxVE.Core.Services /// /// The authenticated PVE session. /// The cluster node name. - public Dictionary 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() ?? new PveNodeDns(); }); } diff --git a/src/PSProxmoxVE.Core/Services/VmService.cs b/src/PSProxmoxVE.Core/Services/VmService.cs index 593555c..2ed9bca 100644 --- a/src/PSProxmoxVE.Core/Services/VmService.cs +++ b/src/PSProxmoxVE.Core/Services/VmService.cs @@ -605,7 +605,7 @@ namespace PSProxmoxVE.Core.Services /// /// Gets the status/result of a guest agent exec command by PID. /// - public Dictionary 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() ?? new PveGuestExecStatus(); }); } diff --git a/src/PSProxmoxVE.Core/Utilities/NativeJsonConverters.cs b/src/PSProxmoxVE.Core/Utilities/NativeJsonConverters.cs index 0c4b007..6102e5c 100644 --- a/src/PSProxmoxVE.Core/Utilities/NativeJsonConverters.cs +++ b/src/PSProxmoxVE.Core/Utilities/NativeJsonConverters.cs @@ -43,4 +43,25 @@ namespace PSProxmoxVE.Core.Utilities serializer.Serialize(writer, value); } } + + /// + /// 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 . + /// + public class TolerantBooleanConverter : JsonConverter + { + /// + 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)); + } + + /// + public override void WriteJson(JsonWriter writer, bool? value, JsonSerializer serializer) + { + serializer.Serialize(writer, value); + } + } } diff --git a/src/PSProxmoxVE/Cmdlets/Backup/GetPveBackupInfoCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Backup/GetPveBackupInfoCmdlet.cs index 99d68a9..b453810 100644 --- a/src/PSProxmoxVE/Cmdlets/Backup/GetPveBackupInfoCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Backup/GetPveBackupInfoCmdlet.cs @@ -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 /// /// [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); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/GetPveClusterConfigCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/GetPveClusterConfigCmdlet.cs index 21e0c6f..96eaedc 100644 --- a/src/PSProxmoxVE/Cmdlets/Cluster/GetPveClusterConfigCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Cluster/GetPveClusterConfigCmdlet.cs @@ -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 /// /// Gets the cluster configuration. /// - /// 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). /// /// [Cmdlet(VerbsCommon.Get, "PveClusterConfig")] - [OutputType(typeof(Dictionary))] + [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); + } } } } diff --git a/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeConfigCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeConfigCmdlet.cs index 89be9ef..eb38ee7 100644 --- a/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeConfigCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeConfigCmdlet.cs @@ -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 /// /// [Cmdlet(VerbsCommon.Get, "PveNodeConfig")] - [OutputType(typeof(PSObject))] + [OutputType(typeof(PveNodeConfig))] public sealed class GetPveNodeConfigCmdlet : PveCmdletBase { /// The Proxmox VE node name. @@ -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); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeDnsCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeDnsCmdlet.cs index a3d2ba3..5268f5f 100644 --- a/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeDnsCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeDnsCmdlet.cs @@ -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 /// /// [Cmdlet(VerbsCommon.Get, "PveNodeDns")] - [OutputType(typeof(PSObject))] + [OutputType(typeof(PveNodeDns))] public sealed class GetPveNodeDnsCmdlet : PveCmdletBase { /// The Proxmox VE node name. @@ -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); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Vms/InvokePveVmGuestExecCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Vms/InvokePveVmGuestExecCmdlet.cs index a0510f0..3ed2727 100644 --- a/src/PSProxmoxVE/Cmdlets/Vms/InvokePveVmGuestExecCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Vms/InvokePveVmGuestExecCmdlet.cs @@ -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 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); diff --git a/src/PSProxmoxVE/PSProxmoxVE.dll-Help.xml b/src/PSProxmoxVE/PSProxmoxVE.dll-Help.xml index f41d705..84d303d 100644 --- a/src/PSProxmoxVE/PSProxmoxVE.dll-Help.xml +++ b/src/PSProxmoxVE/PSProxmoxVE.dll-Help.xml @@ -2088,7 +2088,7 @@ - System.Management.Automation.PSObject + PSProxmoxVE.Core.Models.Backup.PveBackupInfo @@ -2476,7 +2476,7 @@ - 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 @@ -6478,7 +6478,7 @@ - System.Management.Automation.PSObject + PSProxmoxVE.Core.Models.Nodes.PveNodeConfig @@ -6605,7 +6605,7 @@ - System.Management.Automation.PSObject + PSProxmoxVE.Core.Models.Nodes.PveNodeDns diff --git a/src/PSProxmoxVE/PSProxmoxVE.format.ps1xml b/src/PSProxmoxVE/PSProxmoxVE.format.ps1xml index 9cbb8a5..a0a6044 100644 --- a/src/PSProxmoxVE/PSProxmoxVE.format.ps1xml +++ b/src/PSProxmoxVE/PSProxmoxVE.format.ps1xml @@ -1415,5 +1415,31 @@ + + + PSProxmoxVE.Core.Models.Cluster.PveClusterConfigEntry + + PSProxmoxVE.Core.Models.Cluster.PveClusterConfigEntry + + + + + + 20 + Left + + + + + + + Name + + + + + + + diff --git a/tests/PSProxmoxVE.Core.Tests/Models/BackupModelTests.cs b/tests/PSProxmoxVE.Core.Tests/Models/BackupModelTests.cs index d7671f5..33b1993 100644 --- a/tests/PSProxmoxVE.Core.Tests/Models/BackupModelTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Models/BackupModelTests.cs @@ -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(); + 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(); + Assert.NotNull(items); + Assert.Equal("prod", items![0].AdditionalProperties["comment"]); + Assert.False(items[0].AdditionalProperties.ContainsKey("vmid")); + } } } diff --git a/tests/PSProxmoxVE.Core.Tests/Models/ClusterModelTests.cs b/tests/PSProxmoxVE.Core.Tests/Models/ClusterModelTests.cs index 63856db..9fede8b 100644 --- a/tests/PSProxmoxVE.Core.Tests/Models/ClusterModelTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Models/ClusterModelTests.cs @@ -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>(); + 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>(); + Assert.NotNull(entries); + Assert.Equal("x", entries![0].AdditionalProperties["extra"]); + Assert.False(entries[0].AdditionalProperties.ContainsKey("name")); + } } } diff --git a/tests/PSProxmoxVE.Core.Tests/Models/NodeModelTests.cs b/tests/PSProxmoxVE.Core.Tests/Models/NodeModelTests.cs index df6137a..b3087cc 100644 --- a/tests/PSProxmoxVE.Core.Tests/Models/NodeModelTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Models/NodeModelTests.cs @@ -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(); + 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(); + 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(); + 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(); + Assert.NotNull(dns); + Assert.Equal("9.9.9.9", dns!.AdditionalProperties["dns4"]); + Assert.False(dns.AdditionalProperties.ContainsKey("dns1")); + } } } diff --git a/tests/PSProxmoxVE.Core.Tests/Models/VmModelTests.cs b/tests/PSProxmoxVE.Core.Tests/Models/VmModelTests.cs index dd36be9..2397ea7 100644 --- a/tests/PSProxmoxVE.Core.Tests/Models/VmModelTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Models/VmModelTests.cs @@ -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(); + 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(); + 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(); + Assert.NotNull(status); + Assert.Equal("future", status!.AdditionalProperties["newfield"]); + Assert.False(status.AdditionalProperties.ContainsKey("exited")); + } } } diff --git a/tests/PSProxmoxVE.Core.Tests/Services/BackupServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/BackupServiceTests.cs index 4feafaf..f2a1edd 100644 --- a/tests/PSProxmoxVE.Core.Tests/Services/BackupServiceTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Services/BackupServiceTests.cs @@ -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>>(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] diff --git a/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs index 228a6b2..4045988 100644 --- a/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs @@ -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(); 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); } diff --git a/tests/PSProxmoxVE.Core.Tests/Services/NodeServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/NodeServiceTests.cs index 959fd48..a76093d 100644 --- a/tests/PSProxmoxVE.Core.Tests/Services/NodeServiceTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Services/NodeServiceTests.cs @@ -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(); 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>(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(); 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>(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); } diff --git a/tests/PSProxmoxVE.Core.Tests/Services/VmServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/VmServiceTests.cs index 1664113..e8527bc 100644 --- a/tests/PSProxmoxVE.Core.Tests/Services/VmServiceTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Services/VmServiceTests.cs @@ -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(); + 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(); + 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() {