mirror of
https://github.com/GoodOlClint/PSProxmoxVE.git
synced 2026-09-07 12:35:41 +00:00
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:
committed by
GitHub
parent
9a92577794
commit
0026ef2b57
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user