diff --git a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs index 4982231..84d1209 100644 --- a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs +++ b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs @@ -99,7 +99,7 @@ namespace PSProxmoxVE.Core.Client { var request = BuildRequest(HttpMethod.Post, resource, mutating: true); if (data != null) - request.Content = new FormUrlEncodedContent(data); + request.Content = BuildFormContent(data); return await SendAsync(request, resource, "POST").ConfigureAwait(false); } @@ -111,7 +111,7 @@ namespace PSProxmoxVE.Core.Client { var request = BuildRequest(HttpMethod.Put, resource, mutating: true); if (data != null) - request.Content = new FormUrlEncodedContent(data); + request.Content = BuildFormContent(data); return await SendAsync(request, resource, "PUT").ConfigureAwait(false); } @@ -124,6 +124,52 @@ namespace PSProxmoxVE.Core.Client return await SendAsync(request, resource, "DELETE").ConfigureAwait(false); } + // ------------------------------------------------------------------------- + // Form body encoding + // ------------------------------------------------------------------------- + + /// + /// Builds form-encoded content using minimal encoding that matches curl behavior. + /// .NET's over-encodes characters like + /// : and ! which PVE's internal API consumers (e.g. cluster join) + /// do not properly URL-decode. + /// + private static StringContent BuildFormContent(Dictionary data) + { + var sb = new StringBuilder(); + foreach (var kvp in data) + { + if (sb.Length > 0) sb.Append('&'); + sb.Append(EncodeFormValue(kvp.Key)); + sb.Append('='); + sb.Append(EncodeFormValue(kvp.Value)); + } + return new StringContent(sb.ToString(), Encoding.UTF8, "application/x-www-form-urlencoded"); + } + + /// + /// Encodes a form value with minimal percent-encoding — only characters that + /// would break form parsing are encoded. This matches curl's -d behavior + /// and avoids over-encoding that PVE does not handle correctly. + /// + private static string EncodeFormValue(string value) + { + var sb = new StringBuilder(value.Length); + foreach (char c in value) + { + switch (c) + { + case '&': sb.Append("%26"); break; + case '=': sb.Append("%3D"); break; + case '+': sb.Append("%2B"); break; + case ' ': sb.Append('+'); break; + case '%': sb.Append("%25"); break; + default: sb.Append(c); break; + } + } + return sb.ToString(); + } + // ------------------------------------------------------------------------- // Synchronous wrappers // ------------------------------------------------------------------------- diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterConfigNodeCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterConfigNodeCmdlet.cs index 22160a6..d073237 100644 --- a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterConfigNodeCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterConfigNodeCmdlet.cs @@ -1,4 +1,5 @@ using System.Management.Automation; +using PSProxmoxVE.Core.Models.Vms; using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Cluster @@ -13,7 +14,7 @@ namespace PSProxmoxVE.Cmdlets.Cluster /// [Cmdlet(VerbsCommon.Add, "PveClusterConfigNode", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] - [OutputType(typeof(string))] + [OutputType(typeof(PveTask))] public sealed class AddPveClusterConfigNodeCmdlet : PveCmdletBase { /// The name of the node to add. @@ -46,6 +47,10 @@ namespace PSProxmoxVE.Cmdlets.Cluster [Parameter(Mandatory = false, HelpMessage = "Corosync link addresses as key=value strings (e.g. 'link0=10.0.0.1').")] public string[]? Links { get; set; } + /// Wait for the task to complete. + [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")] + public SwitchParameter Wait { get; set; } + protected override void ProcessRecord() { if (!ShouldProcess($"node '{Node}'", "Add to cluster configuration")) @@ -59,7 +64,18 @@ namespace PSProxmoxVE.Cmdlets.Cluster WriteVerbose($"Adding node '{Node}' to cluster configuration..."); var upid = service.AddConfigNode(session, Node, NewNodeIp, linkDict, NodeId, Votes, Force.IsPresent ? true : (bool?)null, ApiVersion); - WriteObject(upid); + + var nodeName = GetNodeFromUpid(upid, session.Hostname); + + var task = new PveTask { Upid = upid, Status = "running", Node = nodeName }; + + if (Wait.IsPresent && !string.IsNullOrEmpty(upid)) + { + var taskService = new TaskService(); + task = taskService.WaitForTask(session, nodeName, upid); + } + + WriteObject(task); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs index 2ff938a..a8735b6 100644 --- a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs @@ -1,7 +1,12 @@ using System; using System.Management.Automation; +using System.Net; using System.Runtime.InteropServices; using System.Security; +using System.Threading; +using PSProxmoxVE.Core.Authentication; +using PSProxmoxVE.Core.Exceptions; +using PSProxmoxVE.Core.Models.Vms; using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Cluster @@ -16,7 +21,7 @@ namespace PSProxmoxVE.Cmdlets.Cluster /// [Cmdlet(VerbsCommon.Add, "PveClusterMember", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] - [OutputType(typeof(string))] + [OutputType(typeof(PveTask))] public sealed class AddPveClusterMemberCmdlet : PveCmdletBase { /// Hostname or IP of an existing cluster member. @@ -49,6 +54,13 @@ namespace PSProxmoxVE.Cmdlets.Cluster [Parameter(Mandatory = false, HelpMessage = "Corosync link addresses as key=value strings (e.g. 'link0=10.0.0.1').")] public string[]? Links { get; set; } + /// Wait for the join task to complete. + [Parameter(Mandatory = false, HelpMessage = "Wait for the join task to complete before returning.")] + public SwitchParameter Wait { get; set; } + + private const int ReauthMaxAttempts = 10; + private const int ReauthDelayMs = 3000; + protected override void ProcessRecord() { if (!ShouldProcess($"this node to cluster via '{Hostname}'", "Join cluster")) @@ -68,7 +80,16 @@ namespace PSProxmoxVE.Cmdlets.Cluster WriteVerbose($"Joining cluster via '{Hostname}'..."); var upid = service.JoinCluster(session, Hostname, Fingerprint, plainPassword, linkDict, NodeId, Votes, Force.IsPresent ? true : (bool?)null); - WriteObject(upid); + + var node = GetNodeFromUpid(upid, session.Hostname); + var task = new PveTask { Upid = upid, Status = "running", Node = node }; + + if (Wait.IsPresent && !string.IsNullOrEmpty(upid)) + { + task = WaitForJoinTask(session, upid, plainPassword); + } + + WriteObject(task); } finally { @@ -76,5 +97,55 @@ namespace PSProxmoxVE.Cmdlets.Cluster Marshal.ZeroFreeGlobalAllocUnicode(ptr); } } + + /// + /// Waits for the join task to complete. If the ticket is invalidated mid-poll + /// (cluster join restarts auth services), re-authenticates with retry and + /// continues waiting. + /// + private PveTask WaitForJoinTask(PveSession session, string upid, string plainPassword) + { + var nodeName = upid.Split(':').Length > 1 ? upid.Split(':')[1] : session.Hostname; + var taskService = new TaskService(); + + try + { + return taskService.WaitForTask(session, nodeName, upid); + } + catch (PveApiException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized) + { + WriteVerbose("Session expired during join — waiting for auth services to restart..."); + var newSession = ReauthenticateWithRetry(session, plainPassword); + return taskService.WaitForTask(newSession, nodeName, upid); + } + } + + /// + /// Retries authentication against this node until auth services come back online + /// after the cluster join restarts them. + /// + private PveSession ReauthenticateWithRetry(PveSession session, string plainPassword) + { + for (int attempt = 0; attempt < ReauthMaxAttempts; attempt++) + { + Thread.Sleep(ReauthDelayMs); + try + { + return PveAuthenticator.AuthenticateWithCredentials( + session.Hostname, session.Port, session.SkipCertificateCheck, + "root@pam", plainPassword); + } + catch (PveApiException retryEx) when ( + retryEx.StatusCode == HttpStatusCode.Unauthorized || + retryEx.StatusCode == HttpStatusCode.ServiceUnavailable) + { + WriteVerbose($"Re-auth attempt {attempt + 1}/{ReauthMaxAttempts} failed, retrying..."); + } + } + + throw new InvalidOperationException( + "Unable to re-authenticate after cluster join. " + + "Auth services on this node may still be restarting."); + } } } diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/NewPveClusterCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/NewPveClusterCmdlet.cs index f1f5be6..13621c7 100644 --- a/src/PSProxmoxVE/Cmdlets/Cluster/NewPveClusterCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Cluster/NewPveClusterCmdlet.cs @@ -1,4 +1,5 @@ using System.Management.Automation; +using PSProxmoxVE.Core.Models.Vms; using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Cluster @@ -13,7 +14,7 @@ namespace PSProxmoxVE.Cmdlets.Cluster /// [Cmdlet(VerbsCommon.New, "PveCluster", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] - [OutputType(typeof(string))] + [OutputType(typeof(PveTask))] public sealed class NewPveClusterCmdlet : PveCmdletBase { /// The name for the new cluster. @@ -35,6 +36,10 @@ namespace PSProxmoxVE.Cmdlets.Cluster [Parameter(Mandatory = false, HelpMessage = "Corosync link addresses as key=value strings (e.g. 'link0=10.0.0.1').")] public string[]? Links { get; set; } + /// Wait for the cluster creation task to complete. + [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")] + public SwitchParameter Wait { get; set; } + protected override void ProcessRecord() { if (!ShouldProcess($"cluster '{ClusterName}'", "Create new cluster")) @@ -47,7 +52,18 @@ namespace PSProxmoxVE.Cmdlets.Cluster WriteVerbose($"Creating cluster '{ClusterName}'..."); var upid = service.CreateCluster(session, ClusterName, linkDict, NodeId, Votes); - WriteObject(upid); + + var node = GetNodeFromUpid(upid, session.Hostname); + + var task = new PveTask { Upid = upid, Status = "running", Node = node }; + + if (Wait.IsPresent && !string.IsNullOrEmpty(upid)) + { + var taskService = new TaskService(); + task = taskService.WaitForTask(session, node, upid); + } + + WriteObject(task); } } } diff --git a/src/PSProxmoxVE/Cmdlets/HA/SetPveHaRuleCmdlet.cs b/src/PSProxmoxVE/Cmdlets/HA/SetPveHaRuleCmdlet.cs index bb1d1d9..4f6f181 100644 --- a/src/PSProxmoxVE/Cmdlets/HA/SetPveHaRuleCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/HA/SetPveHaRuleCmdlet.cs @@ -19,6 +19,10 @@ namespace PSProxmoxVE.Cmdlets.HA HelpMessage = "HA rule ID.")] public string Rule { get; set; } = string.Empty; + /// Rule type (required by PVE API on update). + [Parameter(Mandatory = true, Position = 1, HelpMessage = "HA rule type (as defined in PVE, e.g. node-affinity, resource-affinity, location, colocation).")] + public string Type { get; set; } = string.Empty; + /// Rule state. [Parameter(Mandatory = false, HelpMessage = "Rule state: enabled or disabled.")] [ValidateSet("enabled", "disabled")] @@ -42,7 +46,7 @@ namespace PSProxmoxVE.Cmdlets.HA var service = new HaService(); - var data = new Dictionary(); + var data = new Dictionary { ["type"] = Type }; if (State != null) data["state"] = State; if (Comment != null) data["comment"] = Comment; if (Properties != null) diff --git a/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs b/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs index 2f6b775..467e57d 100644 --- a/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs +++ b/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs @@ -160,6 +160,21 @@ namespace PSProxmoxVE.Cmdlets TimeSpan.FromSeconds(timeoutSeconds)); } + /// + /// Extracts the node name from a UPID string (format: UPID:node:...). + /// Falls back to if the UPID is empty or cannot be parsed. + /// + protected static string GetNodeFromUpid(string? upid, string fallback) + { + if (upid != null && upid.Length > 0) + { + var parts = upid.Split(':'); + if (parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1])) + return parts[1]; + } + return fallback; + } + /// /// Parses an array of Corosync link strings (e.g. "link0=10.0.0.1") into a dictionary. /// Emits a warning for entries that do not match the expected "key=value" format. diff --git a/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs new file mode 100644 index 0000000..f5d65f8 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs @@ -0,0 +1,470 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Moq; +using Newtonsoft.Json.Linq; +using Xunit; +using PSProxmoxVE.Core.Authentication; +using PSProxmoxVE.Core.Client; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Core.Tests.Services +{ + public class ClusterConfigServiceTests + { + private static PveSession CreateSession() + { + return new PveSession("pve.example.com", 8006, false, + "root@pam!testtoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + } + + [Fact] + public void GetClusterConfig_ReturnsJObject() + { + // Arrange + var json = @"{""data"": {""nodes"": {""pve1"": {}}, ""totem"": {""version"": ""2""}}}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/config")).ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act + var config = service.GetClusterConfig(CreateSession()); + + // Assert + Assert.NotNull(config); + Assert.NotNull(config["nodes"]); + Assert.NotNull(config["totem"]); + mockClient.Verify(c => c.GetAsync("cluster/config"), Times.Once); + } + + [Fact] + public void CreateCluster_PostsClusterNameAndReturnsUpid() + { + // Arrange + var json = @"{""data"": ""UPID:pve1:000ABC:00000001:5F1234AB:cluster_create::root@pam:""}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.PostAsync( + "cluster/config", + It.IsAny>())) + .ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act + var upid = service.CreateCluster(CreateSession(), "test-cluster"); + + // Assert + Assert.Contains("cluster_create", upid); + mockClient.Verify(c => c.PostAsync( + "cluster/config", + It.Is>(d => + d["clustername"] == "test-cluster")), + Times.Once); + } + + [Fact] + public void CreateCluster_WithOptionalParams_IncludesThemInPost() + { + // Arrange + var json = @"{""data"": ""UPID:pve1:000ABC:00000001:5F1234AB:cluster_create::root@pam:""}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.PostAsync( + "cluster/config", + It.IsAny>())) + .ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + var links = new Dictionary { ["link0"] = "10.0.0.1" }; + + // Act + service.CreateCluster(CreateSession(), "test-cluster", links, nodeid: 1, votes: 2); + + // Assert + mockClient.Verify(c => c.PostAsync( + "cluster/config", + It.Is>(d => + d["clustername"] == "test-cluster" && + d["link0"] == "10.0.0.1" && + d["nodeid"] == "1" && + d["votes"] == "2")), + Times.Once); + } + + [Fact] + public void GetConfigNodes_ReturnsNodeArray() + { + // Arrange + var json = @"{""data"": [ + {""node"": ""pve1"", ""nodeid"": 1, ""ring0_addr"": ""10.0.0.1"", ""quorum_votes"": 1}, + {""node"": ""pve2"", ""nodeid"": 2, ""ring0_addr"": ""10.0.0.2"", ""quorum_votes"": 1} + ]}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/config/nodes")).ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act + var nodes = service.GetConfigNodes(CreateSession()); + + // Assert + Assert.Equal(2, nodes.Length); + Assert.Equal("pve1", nodes[0].Name); + Assert.Equal(1, nodes[0].NodeId); + Assert.Equal("10.0.0.1", nodes[0].Ring0Addr); + Assert.Equal(1, nodes[0].QuorumVotes); + Assert.Equal("pve2", nodes[1].Name); + Assert.Equal(2, nodes[1].NodeId); + mockClient.Verify(c => c.GetAsync("cluster/config/nodes"), Times.Once); + } + + [Fact] + public void AddConfigNode_PostsToEncodedUrlAndReturnsUpid() + { + // Arrange + var json = @"{""data"": ""UPID:pve1:000ABC:00000001:5F1234AB:addnode::root@pam:""}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.PostAsync( + "cluster/config/nodes/pve2", + It.IsAny>())) + .ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act + var upid = service.AddConfigNode(CreateSession(), "pve2", newNodeIp: "10.0.0.2"); + + // Assert + Assert.Contains("addnode", upid); + mockClient.Verify(c => c.PostAsync( + "cluster/config/nodes/pve2", + It.Is>(d => + d["new_node_ip"] == "10.0.0.2")), + Times.Once); + } + + [Fact] + public void RemoveConfigNode_CallsDeleteWithEncodedUrl() + { + // Arrange + var mockClient = new Mock(); + mockClient.Setup(c => c.DeleteAsync("cluster/config/nodes/pve2")) + .ReturnsAsync("{}"); + var service = new ClusterConfigService(mockClient.Object); + + // Act + service.RemoveConfigNode(CreateSession(), "pve2"); + + // Assert + mockClient.Verify(c => c.DeleteAsync("cluster/config/nodes/pve2"), Times.Once); + } + + [Fact] + public void GetJoinInfo_ReturnsClusterJoinInfo() + { + // Arrange + var json = @"{""data"": { + ""config_digest"": ""abc123"", + ""preferred_node"": ""pve1"", + ""nodelist"": [{""name"": ""pve1"", ""ring0_addr"": ""10.0.0.1""}], + ""totem"": {""version"": ""2""} + }}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/config/join")).ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act + var joinInfo = service.GetJoinInfo(CreateSession()); + + // Assert + Assert.Equal("abc123", joinInfo.ConfigDigest); + Assert.Equal("pve1", joinInfo.PreferredNode); + Assert.NotNull(joinInfo.Nodelist); + Assert.Single(joinInfo.Nodelist); + Assert.NotNull(joinInfo.Totem); + mockClient.Verify(c => c.GetAsync("cluster/config/join"), Times.Once); + } + + [Fact] + public void GetJoinInfo_WithNode_AppendsQueryString() + { + // Arrange + var json = @"{""data"": {""config_digest"": ""abc123"", ""preferred_node"": ""pve1""}}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/config/join?node=pve1")).ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act + var joinInfo = service.GetJoinInfo(CreateSession(), "pve1"); + + // Assert + Assert.Equal("pve1", joinInfo.PreferredNode); + mockClient.Verify(c => c.GetAsync("cluster/config/join?node=pve1"), Times.Once); + } + + [Fact] + public void JoinCluster_PostsRequiredParamsAndReturnsUpid() + { + // Arrange + var json = @"{""data"": ""UPID:pve2:000ABC:00000001:5F1234AB:join::root@pam:""}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.PostAsync( + "cluster/config/join", + It.IsAny>())) + .ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act + var upid = service.JoinCluster(CreateSession(), "10.0.0.1", "AA:BB:CC:DD", "secret"); + + // Assert + Assert.Contains("join", upid); + mockClient.Verify(c => c.PostAsync( + "cluster/config/join", + It.Is>(d => + d["hostname"] == "10.0.0.1" && + d["fingerprint"] == "AA:BB:CC:DD" && + d["password"] == "secret")), + Times.Once); + } + + [Fact] + public void GetTotem_ReturnsJObject() + { + // Arrange + var json = @"{""data"": {""version"": ""2"", ""secauth"": ""on"", ""cluster_name"": ""pve-cluster""}}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/config/totem")).ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act + var totem = service.GetTotem(CreateSession()); + + // Assert + Assert.NotNull(totem); + Assert.Equal("2", totem["version"]?.ToString()); + Assert.Equal("on", totem["secauth"]?.ToString()); + Assert.Equal("pve-cluster", totem["cluster_name"]?.ToString()); + mockClient.Verify(c => c.GetAsync("cluster/config/totem"), Times.Once); + } + + [Fact] + public void GetApiVersion_ReturnsInt() + { + // Arrange + var json = @"{""data"": 10}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/config/apiversion")).ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act + var version = service.GetApiVersion(CreateSession()); + + // Assert + Assert.Equal(10, version); + mockClient.Verify(c => c.GetAsync("cluster/config/apiversion"), Times.Once); + } + + [Fact] + public void GetClusterOptions_ReturnsPveClusterOptions() + { + // Arrange + var json = @"{""data"": { + ""keyboard"": ""en-us"", + ""language"": ""en"", + ""console"": ""html5"", + ""fencing"": ""watchdog"", + ""email_from"": ""admin@example.com"", + ""max_workers"": 4 + }}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/options")).ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act + var options = service.GetClusterOptions(CreateSession()); + + // Assert + Assert.Equal("en-us", options.Keyboard); + Assert.Equal("en", options.Language); + Assert.Equal("html5", options.Console); + Assert.Equal("watchdog", options.Fencing); + Assert.Equal("admin@example.com", options.EmailFrom); + Assert.Equal(4, options.MaxWorkers); + mockClient.Verify(c => c.GetAsync("cluster/options"), Times.Once); + } + + [Fact] + public void SetClusterOptions_CallsPutAsyncWithCorrectResource() + { + // Arrange + var mockClient = new Mock(); + mockClient.Setup(c => c.PutAsync( + "cluster/options", + It.IsAny>())) + .ReturnsAsync("{}"); + var service = new ClusterConfigService(mockClient.Object); + var opts = new Dictionary + { + ["keyboard"] = "de", + ["language"] = "de" + }; + + // Act + service.SetClusterOptions(CreateSession(), opts); + + // Assert + mockClient.Verify(c => c.PutAsync( + "cluster/options", + It.Is>(d => + d["keyboard"] == "de" && d["language"] == "de")), + Times.Once); + } + + [Fact] + public void GetClusterStatus_ReturnsStatusArray() + { + // Arrange + var json = @"{""data"": [ + {""type"": ""cluster"", ""name"": ""pve-cluster"", ""nodes"": 3, ""quorate"": 1, ""version"": 5}, + {""type"": ""node"", ""name"": ""pve1"", ""online"": 1, ""local"": 1, ""nodeid"": 1, ""ip"": ""10.0.0.1""} + ]}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/status")).ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act + var statuses = service.GetClusterStatus(CreateSession()); + + // Assert + Assert.Equal(2, statuses.Length); + Assert.Equal("cluster", statuses[0].Type); + Assert.Equal("pve-cluster", statuses[0].Name); + Assert.Equal(3, statuses[0].Nodes); + Assert.Equal(1, statuses[0].Quorate); + Assert.Equal("node", statuses[1].Type); + Assert.Equal("pve1", statuses[1].Name); + Assert.Equal(1, statuses[1].Online); + Assert.Equal("10.0.0.1", statuses[1].Ip); + mockClient.Verify(c => c.GetAsync("cluster/status"), Times.Once); + } + + [Fact] + public void GetNextId_ReturnsInt() + { + // Arrange + var json = @"{""data"": ""100""}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/nextid")).ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act + var nextId = service.GetNextId(CreateSession()); + + // Assert + Assert.Equal(100, nextId); + mockClient.Verify(c => c.GetAsync("cluster/nextid"), Times.Once); + } + + [Fact] + public void GetNextId_WithVmid_AppendsQueryString() + { + // Arrange + var json = @"{""data"": ""200""}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/nextid?vmid=200")).ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act + var nextId = service.GetNextId(CreateSession(), 200); + + // Assert + Assert.Equal(200, nextId); + mockClient.Verify(c => c.GetAsync("cluster/nextid?vmid=200"), Times.Once); + } + + [Fact] + public void GetNextId_InvalidResponse_ThrowsInvalidOperationException() + { + // Arrange + var json = @"{""data"": ""not-a-number""}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/nextid")).ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act & Assert + var ex = Assert.Throws(() => service.GetNextId(CreateSession())); + Assert.Contains("unexpected next VMID value", ex.Message); + } + + [Fact] + public void GetNextId_NullDataField_ThrowsInvalidOperationException() + { + // Arrange + var json = @"{""notdata"": ""100""}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/nextid")).ReturnsAsync(json); + var service = new ClusterConfigService(mockClient.Object); + + // Act & Assert + var ex = Assert.Throws(() => service.GetNextId(CreateSession())); + Assert.Contains("did not contain a 'data' field", ex.Message); + } + + [Fact] + public void GetClusterConfig_NullSession_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + var service = new ClusterConfigService(mockClient.Object); + + // Act & Assert + Assert.Throws(() => service.GetClusterConfig(null!)); + } + + [Fact] + public void GetConfigNodes_NullSession_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + var service = new ClusterConfigService(mockClient.Object); + + // Act & Assert + Assert.Throws(() => service.GetConfigNodes(null!)); + } + + [Fact] + public void GetClusterOptions_NullSession_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + var service = new ClusterConfigService(mockClient.Object); + + // Act & Assert + Assert.Throws(() => service.GetClusterOptions(null!)); + } + + [Fact] + public void GetNextId_NullSession_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + var service = new ClusterConfigService(mockClient.Object); + + // Act & Assert + Assert.Throws(() => service.GetNextId(null!)); + } + + [Fact] + public void GetClusterStatus_NullSession_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + var service = new ClusterConfigService(mockClient.Object); + + // Act & Assert + Assert.Throws(() => service.GetClusterStatus(null!)); + } + + [Fact] + public void Constructor_NullClient_ThrowsArgumentNullException() + { + Assert.Throws(() => new ClusterConfigService(null!)); + } + } +} diff --git a/tests/PSProxmoxVE.Core.Tests/Services/HaServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/HaServiceTests.cs new file mode 100644 index 0000000..3300e90 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Services/HaServiceTests.cs @@ -0,0 +1,509 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Moq; +using Newtonsoft.Json.Linq; +using Xunit; +using PSProxmoxVE.Core.Authentication; +using PSProxmoxVE.Core.Client; +using PSProxmoxVE.Core.Models.HA; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Core.Tests.Services +{ + public class HaServiceTests + { + private static PveSession CreateSession() + { + return new PveSession("pve.example.com", 8006, false, + "root@pam!testtoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + } + + // ----------------------------------------------------------------- + // Resources + // ----------------------------------------------------------------- + + [Fact] + public void GetResources_ReturnsResourceArray() + { + // Arrange + var json = @"{""data"": [ + {""sid"": ""vm:100"", ""state"": ""started"", ""group"": ""grp1"", ""type"": ""vm"", ""max_relocate"": 1, ""max_restart"": 1}, + {""sid"": ""ct:200"", ""state"": ""stopped"", ""type"": ""ct""} + ]}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/ha/resources")).ReturnsAsync(json); + var service = new HaService(mockClient.Object); + + // Act + var resources = service.GetResources(CreateSession()); + + // Assert + Assert.Equal(2, resources.Length); + Assert.Equal("vm:100", resources[0].Sid); + Assert.Equal("started", resources[0].State); + Assert.Equal("grp1", resources[0].Group); + Assert.Equal("vm", resources[0].Type); + Assert.Equal(1, resources[0].MaxRelocate); + Assert.Equal(1, resources[0].MaxRestart); + Assert.Equal("ct:200", resources[1].Sid); + Assert.Equal("stopped", resources[1].State); + mockClient.Verify(c => c.GetAsync("cluster/ha/resources"), Times.Once); + } + + [Fact] + public void GetResource_ReturnsSingleResource() + { + // Arrange + var json = @"{""data"": {""sid"": ""vm:100"", ""state"": ""started"", ""group"": ""grp1"", ""type"": ""vm""}}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/ha/resources/vm%3A100")).ReturnsAsync(json); + var service = new HaService(mockClient.Object); + + // Act + var resource = service.GetResource(CreateSession(), "vm:100"); + + // Assert + Assert.Equal("vm:100", resource.Sid); + Assert.Equal("started", resource.State); + Assert.Equal("grp1", resource.Group); + Assert.Equal("vm", resource.Type); + mockClient.Verify(c => c.GetAsync("cluster/ha/resources/vm%3A100"), Times.Once); + } + + [Fact] + public void GetResource_EncodesColonInSid() + { + // Arrange + var json = @"{""data"": {""sid"": ""ct:200"", ""state"": ""stopped""}}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/ha/resources/ct%3A200")).ReturnsAsync(json); + var service = new HaService(mockClient.Object); + + // Act + var resource = service.GetResource(CreateSession(), "ct:200"); + + // Assert + Assert.Equal("ct:200", resource.Sid); + mockClient.Verify(c => c.GetAsync("cluster/ha/resources/ct%3A200"), Times.Once); + } + + [Fact] + public void CreateResource_PostsSidAndOptions() + { + // Arrange + var mockClient = new Mock(); + mockClient.Setup(c => c.PostAsync( + "cluster/ha/resources", + It.IsAny>())) + .ReturnsAsync("{}"); + var service = new HaService(mockClient.Object); + var options = new Dictionary + { + ["state"] = "started", + ["group"] = "grp1" + }; + + // Act + service.CreateResource(CreateSession(), "vm:100", options); + + // Assert + mockClient.Verify(c => c.PostAsync( + "cluster/ha/resources", + It.Is>(d => + d["sid"] == "vm:100" && + d["state"] == "started" && + d["group"] == "grp1")), + Times.Once); + } + + [Fact] + public void UpdateResource_PutsOptionsToEncodedUrl() + { + // Arrange + var mockClient = new Mock(); + mockClient.Setup(c => c.PutAsync( + "cluster/ha/resources/vm%3A100", + It.IsAny>())) + .ReturnsAsync("{}"); + var service = new HaService(mockClient.Object); + var options = new Dictionary + { + ["state"] = "stopped" + }; + + // Act + service.UpdateResource(CreateSession(), "vm:100", options); + + // Assert + mockClient.Verify(c => c.PutAsync( + "cluster/ha/resources/vm%3A100", + It.Is>(d => + d["state"] == "stopped")), + Times.Once); + } + + [Fact] + public void DeleteResource_CallsDeleteWithEncodedUrl() + { + // Arrange + var mockClient = new Mock(); + mockClient.Setup(c => c.DeleteAsync("cluster/ha/resources/vm%3A100")) + .ReturnsAsync("{}"); + var service = new HaService(mockClient.Object); + + // Act + service.DeleteResource(CreateSession(), "vm:100"); + + // Assert + mockClient.Verify(c => c.DeleteAsync("cluster/ha/resources/vm%3A100"), Times.Once); + } + + [Fact] + public void MigrateResource_PostsNodeToEncodedUrl() + { + // Arrange + var json = @"{""data"": ""UPID:pve1:000ABC:00000001:5F1234AB:hamigrate::root@pam:""}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.PostAsync( + "cluster/ha/resources/vm%3A100/migrate", + It.IsAny>())) + .ReturnsAsync(json); + var service = new HaService(mockClient.Object); + + // Act + var upid = service.MigrateResource(CreateSession(), "vm:100", "pve2"); + + // Assert + Assert.Contains("hamigrate", upid); + mockClient.Verify(c => c.PostAsync( + "cluster/ha/resources/vm%3A100/migrate", + It.Is>(d => + d["node"] == "pve2")), + Times.Once); + } + + [Fact] + public void RelocateResource_PostsNodeToEncodedUrl() + { + // Arrange + var json = @"{""data"": ""UPID:pve1:000DEF:00000002:5F1234AC:harelocate::root@pam:""}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.PostAsync( + "cluster/ha/resources/vm%3A100/relocate", + It.IsAny>())) + .ReturnsAsync(json); + var service = new HaService(mockClient.Object); + + // Act + var upid = service.RelocateResource(CreateSession(), "vm:100", "pve3"); + + // Assert + Assert.Contains("harelocate", upid); + mockClient.Verify(c => c.PostAsync( + "cluster/ha/resources/vm%3A100/relocate", + It.Is>(d => + d["node"] == "pve3")), + Times.Once); + } + + // ----------------------------------------------------------------- + // Groups + // ----------------------------------------------------------------- + + [Fact] + public void GetGroups_ReturnsGroupArray() + { + // Arrange + var json = @"{""data"": [ + {""group"": ""grp1"", ""nodes"": ""pve1:2,pve2:1"", ""restricted"": 1, ""nofailback"": 0, ""comment"": ""Primary group""}, + {""group"": ""grp2"", ""nodes"": ""pve2,pve3""} + ]}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/ha/groups")).ReturnsAsync(json); + var service = new HaService(mockClient.Object); + + // Act + var groups = service.GetGroups(CreateSession()); + + // Assert + Assert.Equal(2, groups.Length); + Assert.Equal("grp1", groups[0].Group); + Assert.Equal("pve1:2,pve2:1", groups[0].Nodes); + Assert.Equal(1, groups[0].Restricted); + Assert.Equal(0, groups[0].NoFailback); + Assert.Equal("Primary group", groups[0].Comment); + Assert.Equal("grp2", groups[1].Group); + Assert.Equal("pve2,pve3", groups[1].Nodes); + mockClient.Verify(c => c.GetAsync("cluster/ha/groups"), Times.Once); + } + + [Fact] + public void GetGroup_ReturnsSingleGroup() + { + // Arrange + var json = @"{""data"": {""group"": ""grp1"", ""nodes"": ""pve1:2,pve2:1"", ""restricted"": 0}}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/ha/groups/grp1")).ReturnsAsync(json); + var service = new HaService(mockClient.Object); + + // Act + var group = service.GetGroup(CreateSession(), "grp1"); + + // Assert + Assert.Equal("grp1", group.Group); + Assert.Equal("pve1:2,pve2:1", group.Nodes); + Assert.Equal(0, group.Restricted); + mockClient.Verify(c => c.GetAsync("cluster/ha/groups/grp1"), Times.Once); + } + + [Fact] + public void CreateGroup_PostsGroupNodesAndOptions() + { + // Arrange + var mockClient = new Mock(); + mockClient.Setup(c => c.PostAsync( + "cluster/ha/groups", + It.IsAny>())) + .ReturnsAsync("{}"); + var service = new HaService(mockClient.Object); + var options = new Dictionary + { + ["restricted"] = "1", + ["comment"] = "Test group" + }; + + // Act + service.CreateGroup(CreateSession(), "grp1", "pve1:2,pve2:1", options); + + // Assert + mockClient.Verify(c => c.PostAsync( + "cluster/ha/groups", + It.Is>(d => + d["group"] == "grp1" && + d["nodes"] == "pve1:2,pve2:1" && + d["restricted"] == "1" && + d["comment"] == "Test group")), + Times.Once); + } + + [Fact] + public void DeleteGroup_CallsDeleteWithEncodedUrl() + { + // Arrange + var mockClient = new Mock(); + mockClient.Setup(c => c.DeleteAsync("cluster/ha/groups/grp1")) + .ReturnsAsync("{}"); + var service = new HaService(mockClient.Object); + + // Act + service.DeleteGroup(CreateSession(), "grp1"); + + // Assert + mockClient.Verify(c => c.DeleteAsync("cluster/ha/groups/grp1"), Times.Once); + } + + // ----------------------------------------------------------------- + // Status + // ----------------------------------------------------------------- + + [Fact] + public void GetStatus_ReturnsStatusArray() + { + // Arrange + var json = @"{""data"": [ + {""id"": ""quorum"", ""type"": ""quorum"", ""node"": ""pve1"", ""status"": ""OK"", ""timestamp"": 1700000000}, + {""id"": ""manager"", ""type"": ""manager"", ""node"": ""pve1"", ""status"": ""OK"", ""crm_state"": ""S_IDLE""}, + {""id"": ""service:vm:100"", ""type"": ""service"", ""node"": ""pve1"", ""status"": ""started"", ""request_state"": ""started""} + ]}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/ha/status/current")).ReturnsAsync(json); + var service = new HaService(mockClient.Object); + + // Act + var statuses = service.GetStatus(CreateSession()); + + // Assert + Assert.Equal(3, statuses.Length); + Assert.Equal("quorum", statuses[0].Id); + Assert.Equal("quorum", statuses[0].Type); + Assert.Equal("pve1", statuses[0].Node); + Assert.Equal("OK", statuses[0].Status); + Assert.Equal(1700000000L, statuses[0].Timestamp); + Assert.Equal("manager", statuses[1].Id); + Assert.Equal("S_IDLE", statuses[1].CrmState); + Assert.Equal("service:vm:100", statuses[2].Id); + Assert.Equal("started", statuses[2].RequestState); + mockClient.Verify(c => c.GetAsync("cluster/ha/status/current"), Times.Once); + } + + [Fact] + public void GetManagerStatus_ReturnsJObject() + { + // Arrange + var json = @"{""data"": {""manager_status"": {""master_node"": ""pve1""}, ""quorum"": {""node"": ""pve1""}}}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/ha/status/manager_status")).ReturnsAsync(json); + var service = new HaService(mockClient.Object); + + // Act + var status = service.GetManagerStatus(CreateSession()); + + // Assert + Assert.NotNull(status); + Assert.NotNull(status["manager_status"]); + Assert.NotNull(status["quorum"]); + mockClient.Verify(c => c.GetAsync("cluster/ha/status/manager_status"), Times.Once); + } + + // ----------------------------------------------------------------- + // Rules + // ----------------------------------------------------------------- + + [Fact] + public void GetRules_ReturnsRuleArray() + { + // Arrange + var json = @"{""data"": [ + {""rule"": ""rule1"", ""type"": ""location"", ""state"": ""enabled"", ""comment"": ""Location rule""}, + {""rule"": ""rule2"", ""type"": ""colocation"", ""state"": ""disabled""} + ]}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/ha/rules")).ReturnsAsync(json); + var service = new HaService(mockClient.Object); + + // Act + var rules = service.GetRules(CreateSession()); + + // Assert + Assert.Equal(2, rules.Length); + Assert.Equal("rule1", rules[0].Rule); + Assert.Equal("location", rules[0].Type); + Assert.Equal("enabled", rules[0].State); + Assert.Equal("Location rule", rules[0].Comment); + Assert.Equal("rule2", rules[1].Rule); + Assert.Equal("colocation", rules[1].Type); + Assert.Equal("disabled", rules[1].State); + mockClient.Verify(c => c.GetAsync("cluster/ha/rules"), Times.Once); + } + + [Fact] + public void GetRule_ReturnsSingleRule() + { + // Arrange + var json = @"{""data"": {""rule"": ""rule1"", ""type"": ""location"", ""state"": ""enabled""}}"; + var mockClient = new Mock(); + mockClient.Setup(c => c.GetAsync("cluster/ha/rules/rule1")).ReturnsAsync(json); + var service = new HaService(mockClient.Object); + + // Act + var rule = service.GetRule(CreateSession(), "rule1"); + + // Assert + Assert.Equal("rule1", rule.Rule); + Assert.Equal("location", rule.Type); + Assert.Equal("enabled", rule.State); + mockClient.Verify(c => c.GetAsync("cluster/ha/rules/rule1"), Times.Once); + } + + [Fact] + public void CreateRule_PostsOptions() + { + // Arrange + var mockClient = new Mock(); + mockClient.Setup(c => c.PostAsync( + "cluster/ha/rules", + It.IsAny>())) + .ReturnsAsync("{}"); + var service = new HaService(mockClient.Object); + var options = new Dictionary + { + ["rule"] = "rule1", + ["type"] = "location", + ["state"] = "enabled" + }; + + // Act + service.CreateRule(CreateSession(), options); + + // Assert + mockClient.Verify(c => c.PostAsync( + "cluster/ha/rules", + It.Is>(d => + d["rule"] == "rule1" && + d["type"] == "location" && + d["state"] == "enabled")), + Times.Once); + } + + [Fact] + public void DeleteRule_CallsDeleteWithEncodedUrl() + { + // Arrange + var mockClient = new Mock(); + mockClient.Setup(c => c.DeleteAsync("cluster/ha/rules/rule1")) + .ReturnsAsync("{}"); + var service = new HaService(mockClient.Object); + + // Act + service.DeleteRule(CreateSession(), "rule1"); + + // Assert + mockClient.Verify(c => c.DeleteAsync("cluster/ha/rules/rule1"), Times.Once); + } + + // ----------------------------------------------------------------- + // Null session tests + // ----------------------------------------------------------------- + + [Fact] + public void GetResources_NullSession_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + var service = new HaService(mockClient.Object); + + // Act & Assert + Assert.Throws(() => service.GetResources(null!)); + } + + [Fact] + public void GetGroups_NullSession_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + var service = new HaService(mockClient.Object); + + // Act & Assert + Assert.Throws(() => service.GetGroups(null!)); + } + + [Fact] + public void GetStatus_NullSession_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + var service = new HaService(mockClient.Object); + + // Act & Assert + Assert.Throws(() => service.GetStatus(null!)); + } + + [Fact] + public void GetRules_NullSession_ThrowsArgumentNullException() + { + // Arrange + var mockClient = new Mock(); + var service = new HaService(mockClient.Object); + + // Act & Assert + Assert.Throws(() => service.GetRules(null!)); + } + + [Fact] + public void Constructor_NullClient_ThrowsArgumentNullException() + { + Assert.Throws(() => new HaService(null!)); + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Cluster/ClusterConfigCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Cluster/ClusterConfigCmdlets.Tests.ps1 new file mode 100644 index 0000000..fa2bdb8 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Cluster/ClusterConfigCmdlets.Tests.ps1 @@ -0,0 +1,613 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 tests for cluster config and management cmdlets: + Get-PveClusterStatus, Get-PveClusterNextId, Get-PveClusterOption, + Set-PveClusterOption, Get-PveClusterConfig, Get-PveClusterConfigNode, + Add-PveClusterConfigNode, Remove-PveClusterConfigNode, + Get-PveClusterJoinInfo, Add-PveClusterMember, New-PveCluster. + + All tests are fully offline — no live Proxmox VE target is required. + If a cmdlet is not yet compiled the test is marked Skipped. +#> + +BeforeAll { + . $PSScriptRoot/../_TestHelper.ps1 + + $script:Availability = @{} + foreach ($name in @( + 'Get-PveClusterStatus', + 'Get-PveClusterNextId', + 'Get-PveClusterOption', + 'Set-PveClusterOption', + 'Get-PveClusterConfig', + 'Get-PveClusterConfigNode', + 'Add-PveClusterConfigNode', + 'Remove-PveClusterConfigNode', + 'Get-PveClusterJoinInfo', + 'Add-PveClusterMember', + 'New-PveCluster' + )) { + $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue) + } + + function Skip-IfMissing([string]$Name) { + if (-not $script:Availability[$Name]) { + Set-ItResult -Skipped -Because "$Name is not yet implemented in this build" + } + } +} + +# --------------------------------------------------------------------------- +# Get-PveClusterStatus +# --------------------------------------------------------------------------- +Describe 'Get-PveClusterStatus' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveClusterStatus' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveClusterStatus' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveClusterStatus' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveClusterStatus' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveClusterStatus' + { Get-PveClusterStatus -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Get-PveClusterNextId +# --------------------------------------------------------------------------- +Describe 'Get-PveClusterNextId' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveClusterNextId' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveClusterNextId' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveClusterNextId' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have optional VmId parameter' { + Skip-IfMissing 'Get-PveClusterNextId' + $script:Cmd.Parameters.ContainsKey('VmId') | Should -BeTrue + } + It 'VmId should not be Mandatory' { + Skip-IfMissing 'Get-PveClusterNextId' + $p = $script:Cmd.Parameters['VmId'] + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -BeNullOrEmpty + } + It 'VmId should have ValidateRange(100, 999999999)' { + Skip-IfMissing 'Get-PveClusterNextId' + $p = $script:Cmd.Parameters['VmId'] + $rangeAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + $rangeAttr | Should -Not -BeNullOrEmpty + $rangeAttr.MinRange | Should -Be 100 + $rangeAttr.MaxRange | Should -Be 999999999 + } + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveClusterNextId' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveClusterNextId' + { Get-PveClusterNextId -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Get-PveClusterOption +# --------------------------------------------------------------------------- +Describe 'Get-PveClusterOption' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveClusterOption' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveClusterOption' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveClusterOption' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveClusterOption' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveClusterOption' + { Get-PveClusterOption -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Set-PveClusterOption +# --------------------------------------------------------------------------- +Describe 'Set-PveClusterOption' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Set-PveClusterOption' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Set-PveClusterOption' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Set-PveClusterOption' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have Keyboard parameter' { + Skip-IfMissing 'Set-PveClusterOption' + $script:Cmd.Parameters.ContainsKey('Keyboard') | Should -BeTrue + } + It 'Keyboard should have ValidateSet' { + Skip-IfMissing 'Set-PveClusterOption' + $p = $script:Cmd.Parameters['Keyboard'] + $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $vsAttr | Should -Not -BeNullOrEmpty + } + It 'Should have Language parameter' { + Skip-IfMissing 'Set-PveClusterOption' + $script:Cmd.Parameters.ContainsKey('Language') | Should -BeTrue + } + It 'Should have Console parameter' { + Skip-IfMissing 'Set-PveClusterOption' + $script:Cmd.Parameters.ContainsKey('Console') | Should -BeTrue + } + It 'Console should have ValidateSet' { + Skip-IfMissing 'Set-PveClusterOption' + $p = $script:Cmd.Parameters['Console'] + $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $vsAttr | Should -Not -BeNullOrEmpty + } + It 'Should have Fencing parameter with ValidateSet' { + Skip-IfMissing 'Set-PveClusterOption' + $p = $script:Cmd.Parameters['Fencing'] + $p | Should -Not -BeNullOrEmpty + $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $vsAttr | Should -Not -BeNullOrEmpty + } + It 'Should have Session parameter' { + Skip-IfMissing 'Set-PveClusterOption' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'Set-PveClusterOption' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Set-PveClusterOption' + { Set-PveClusterOption -Keyboard 'en-us' -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Get-PveClusterConfig +# --------------------------------------------------------------------------- +Describe 'Get-PveClusterConfig' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveClusterConfig' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveClusterConfig' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveClusterConfig' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveClusterConfig' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveClusterConfig' + { Get-PveClusterConfig -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Get-PveClusterConfigNode +# --------------------------------------------------------------------------- +Describe 'Get-PveClusterConfigNode' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveClusterConfigNode' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveClusterConfigNode' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveClusterConfigNode' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveClusterConfigNode' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveClusterConfigNode' + { Get-PveClusterConfigNode -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Add-PveClusterConfigNode +# --------------------------------------------------------------------------- +Describe 'Add-PveClusterConfigNode' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Add-PveClusterConfigNode' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Add-PveClusterConfigNode' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Add-PveClusterConfigNode' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Node parameter' { + Skip-IfMissing 'Add-PveClusterConfigNode' + $p = $script:Cmd.Parameters['Node'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have optional NewNodeIp parameter' { + Skip-IfMissing 'Add-PveClusterConfigNode' + $script:Cmd.Parameters.ContainsKey('NewNodeIp') | Should -BeTrue + } + It 'Should have optional NodeId parameter' { + Skip-IfMissing 'Add-PveClusterConfigNode' + $script:Cmd.Parameters.ContainsKey('NodeId') | Should -BeTrue + } + It 'Should have optional Votes parameter' { + Skip-IfMissing 'Add-PveClusterConfigNode' + $script:Cmd.Parameters.ContainsKey('Votes') | Should -BeTrue + } + It 'Should have optional Force switch' { + Skip-IfMissing 'Add-PveClusterConfigNode' + $script:Cmd.Parameters.ContainsKey('Force') | Should -BeTrue + } + It 'Should have Session parameter' { + Skip-IfMissing 'Add-PveClusterConfigNode' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'Add-PveClusterConfigNode' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + It 'Should have ConfirmImpact High' { + Skip-IfMissing 'Add-PveClusterConfigNode' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.ConfirmImpact | Should -Be 'High' + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Add-PveClusterConfigNode' + { Add-PveClusterConfigNode -Node 'pve2' -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveClusterConfigNode +# --------------------------------------------------------------------------- +Describe 'Remove-PveClusterConfigNode' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveClusterConfigNode' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveClusterConfigNode' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Remove-PveClusterConfigNode' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Node parameter' { + Skip-IfMissing 'Remove-PveClusterConfigNode' + $p = $script:Cmd.Parameters['Node'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have Session parameter' { + Skip-IfMissing 'Remove-PveClusterConfigNode' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'Remove-PveClusterConfigNode' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + It 'Should have ConfirmImpact High' { + Skip-IfMissing 'Remove-PveClusterConfigNode' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.ConfirmImpact | Should -Be 'High' + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveClusterConfigNode' + { Remove-PveClusterConfigNode -Node 'pve2' -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Get-PveClusterJoinInfo +# --------------------------------------------------------------------------- +Describe 'Get-PveClusterJoinInfo' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveClusterJoinInfo' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveClusterJoinInfo' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveClusterJoinInfo' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have optional Node parameter' { + Skip-IfMissing 'Get-PveClusterJoinInfo' + $script:Cmd.Parameters.ContainsKey('Node') | Should -BeTrue + } + It 'Node should not be Mandatory' { + Skip-IfMissing 'Get-PveClusterJoinInfo' + $p = $script:Cmd.Parameters['Node'] + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -BeNullOrEmpty + } + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveClusterJoinInfo' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveClusterJoinInfo' + { Get-PveClusterJoinInfo -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Add-PveClusterMember +# --------------------------------------------------------------------------- +Describe 'Add-PveClusterMember' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Add-PveClusterMember' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Add-PveClusterMember' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Add-PveClusterMember' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Hostname parameter' { + Skip-IfMissing 'Add-PveClusterMember' + $p = $script:Cmd.Parameters['Hostname'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have mandatory Fingerprint parameter' { + Skip-IfMissing 'Add-PveClusterMember' + $p = $script:Cmd.Parameters['Fingerprint'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have mandatory Password parameter' { + Skip-IfMissing 'Add-PveClusterMember' + $p = $script:Cmd.Parameters['Password'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Password should be SecureString type' { + Skip-IfMissing 'Add-PveClusterMember' + $script:Cmd.Parameters['Password'].ParameterType | Should -Be ([System.Security.SecureString]) + } + It 'Should have optional Force switch' { + Skip-IfMissing 'Add-PveClusterMember' + $script:Cmd.Parameters.ContainsKey('Force') | Should -BeTrue + } + It 'Should have Session parameter' { + Skip-IfMissing 'Add-PveClusterMember' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'Add-PveClusterMember' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + It 'Should have ConfirmImpact High' { + Skip-IfMissing 'Add-PveClusterMember' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.ConfirmImpact | Should -Be 'High' + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Add-PveClusterMember' + $secPw = ConvertTo-SecureString 'test' -AsPlainText -Force + { Add-PveClusterMember -Hostname 'pve1' -Fingerprint 'AA:BB' -Password $secPw -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveCluster +# --------------------------------------------------------------------------- +Describe 'New-PveCluster' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveCluster' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveCluster' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'New-PveCluster' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory ClusterName parameter' { + Skip-IfMissing 'New-PveCluster' + $p = $script:Cmd.Parameters['ClusterName'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'ClusterName should have ValidateLength(1, 15)' { + Skip-IfMissing 'New-PveCluster' + $p = $script:Cmd.Parameters['ClusterName'] + $lenAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateLengthAttribute] } + $lenAttr | Should -Not -BeNullOrEmpty + $lenAttr.MinLength | Should -Be 1 + $lenAttr.MaxLength | Should -Be 15 + } + It 'Should have optional NodeId parameter' { + Skip-IfMissing 'New-PveCluster' + $script:Cmd.Parameters.ContainsKey('NodeId') | Should -BeTrue + } + It 'Should have optional Votes parameter' { + Skip-IfMissing 'New-PveCluster' + $script:Cmd.Parameters.ContainsKey('Votes') | Should -BeTrue + } + It 'Should have optional Links parameter' { + Skip-IfMissing 'New-PveCluster' + $script:Cmd.Parameters.ContainsKey('Links') | Should -BeTrue + } + It 'Should have Session parameter' { + Skip-IfMissing 'New-PveCluster' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'New-PveCluster' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + It 'Should have ConfirmImpact High' { + Skip-IfMissing 'New-PveCluster' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.ConfirmImpact | Should -Be 'High' + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveCluster' + { New-PveCluster -ClusterName 'testcluster' -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} diff --git a/tests/PSProxmoxVE.Tests/HA/HaCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/HA/HaCmdlets.Tests.ps1 new file mode 100644 index 0000000..501cbf9 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/HA/HaCmdlets.Tests.ps1 @@ -0,0 +1,831 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 tests for HA (High Availability) cmdlets: + Get-PveHaResource, New-PveHaResource, Set-PveHaResource, + Remove-PveHaResource, Move-PveHaResource, + Get-PveHaGroup, New-PveHaGroup, Set-PveHaGroup, Remove-PveHaGroup, + Get-PveHaStatus, + Get-PveHaRule, New-PveHaRule, Set-PveHaRule, Remove-PveHaRule. + + All tests are fully offline — no live Proxmox VE target is required. + If a cmdlet is not yet compiled the test is marked Skipped. +#> + +BeforeAll { + . $PSScriptRoot/../_TestHelper.ps1 + + $script:Availability = @{} + foreach ($name in @( + 'Get-PveHaResource', + 'New-PveHaResource', + 'Set-PveHaResource', + 'Remove-PveHaResource', + 'Move-PveHaResource', + 'Get-PveHaGroup', + 'New-PveHaGroup', + 'Set-PveHaGroup', + 'Remove-PveHaGroup', + 'Get-PveHaStatus', + 'Get-PveHaRule', + 'New-PveHaRule', + 'Set-PveHaRule', + 'Remove-PveHaRule' + )) { + $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue) + } + + function Skip-IfMissing([string]$Name) { + if (-not $script:Availability[$Name]) { + Set-ItResult -Skipped -Because "$Name is not yet implemented in this build" + } + } +} + +# --------------------------------------------------------------------------- +# Get-PveHaResource +# --------------------------------------------------------------------------- +Describe 'Get-PveHaResource' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveHaResource' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveHaResource' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveHaResource' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have optional Sid parameter' { + Skip-IfMissing 'Get-PveHaResource' + $script:Cmd.Parameters.ContainsKey('Sid') | Should -BeTrue + } + It 'Sid should not be Mandatory' { + Skip-IfMissing 'Get-PveHaResource' + $p = $script:Cmd.Parameters['Sid'] + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -BeNullOrEmpty + } + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveHaResource' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveHaResource' + { Get-PveHaResource -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveHaResource +# --------------------------------------------------------------------------- +Describe 'New-PveHaResource' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveHaResource' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveHaResource' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'New-PveHaResource' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Sid parameter' { + Skip-IfMissing 'New-PveHaResource' + $p = $script:Cmd.Parameters['Sid'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have optional State parameter with ValidateSet' { + Skip-IfMissing 'New-PveHaResource' + $p = $script:Cmd.Parameters['State'] + $p | Should -Not -BeNullOrEmpty + $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $vsAttr | Should -Not -BeNullOrEmpty + } + It 'Should have optional Group parameter' { + Skip-IfMissing 'New-PveHaResource' + $script:Cmd.Parameters.ContainsKey('Group') | Should -BeTrue + } + It 'Should have optional MaxRelocate parameter with ValidateRange' { + Skip-IfMissing 'New-PveHaResource' + $p = $script:Cmd.Parameters['MaxRelocate'] + $p | Should -Not -BeNullOrEmpty + $rangeAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + $rangeAttr | Should -Not -BeNullOrEmpty + $rangeAttr.MinRange | Should -Be 0 + $rangeAttr.MaxRange | Should -Be 10 + } + It 'Should have optional MaxRestart parameter with ValidateRange' { + Skip-IfMissing 'New-PveHaResource' + $p = $script:Cmd.Parameters['MaxRestart'] + $p | Should -Not -BeNullOrEmpty + $rangeAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + $rangeAttr | Should -Not -BeNullOrEmpty + $rangeAttr.MinRange | Should -Be 0 + $rangeAttr.MaxRange | Should -Be 10 + } + It 'Should have Session parameter' { + Skip-IfMissing 'New-PveHaResource' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'New-PveHaResource' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveHaResource' + { New-PveHaResource -Sid 'vm:100' -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Set-PveHaResource +# --------------------------------------------------------------------------- +Describe 'Set-PveHaResource' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Set-PveHaResource' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Set-PveHaResource' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Set-PveHaResource' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Sid parameter' { + Skip-IfMissing 'Set-PveHaResource' + $p = $script:Cmd.Parameters['Sid'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have optional State parameter with ValidateSet' { + Skip-IfMissing 'Set-PveHaResource' + $p = $script:Cmd.Parameters['State'] + $p | Should -Not -BeNullOrEmpty + $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $vsAttr | Should -Not -BeNullOrEmpty + } + It 'Should have Session parameter' { + Skip-IfMissing 'Set-PveHaResource' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'Set-PveHaResource' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Set-PveHaResource' + { Set-PveHaResource -Sid 'vm:100' -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveHaResource +# --------------------------------------------------------------------------- +Describe 'Remove-PveHaResource' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveHaResource' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveHaResource' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Remove-PveHaResource' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Sid parameter' { + Skip-IfMissing 'Remove-PveHaResource' + $p = $script:Cmd.Parameters['Sid'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have Session parameter' { + Skip-IfMissing 'Remove-PveHaResource' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'Remove-PveHaResource' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + It 'Should have ConfirmImpact High' { + Skip-IfMissing 'Remove-PveHaResource' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.ConfirmImpact | Should -Be 'High' + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveHaResource' + { Remove-PveHaResource -Sid 'vm:100' -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Move-PveHaResource +# --------------------------------------------------------------------------- +Describe 'Move-PveHaResource' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Move-PveHaResource' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Move-PveHaResource' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Move-PveHaResource' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Sid parameter' { + Skip-IfMissing 'Move-PveHaResource' + $p = $script:Cmd.Parameters['Sid'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have mandatory Node parameter' { + Skip-IfMissing 'Move-PveHaResource' + $p = $script:Cmd.Parameters['Node'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have Mode parameter with ValidateSet(Migrate, Relocate)' { + Skip-IfMissing 'Move-PveHaResource' + $p = $script:Cmd.Parameters['Mode'] + $p | Should -Not -BeNullOrEmpty + $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $vsAttr | Should -Not -BeNullOrEmpty + $vsAttr.ValidValues | Should -Contain 'Migrate' + $vsAttr.ValidValues | Should -Contain 'Relocate' + } + It 'Should have Session parameter' { + Skip-IfMissing 'Move-PveHaResource' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'Move-PveHaResource' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + It 'Should have ConfirmImpact High' { + Skip-IfMissing 'Move-PveHaResource' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.ConfirmImpact | Should -Be 'High' + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Move-PveHaResource' + { Move-PveHaResource -Sid 'vm:100' -Node 'pve2' -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Get-PveHaGroup +# --------------------------------------------------------------------------- +Describe 'Get-PveHaGroup' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveHaGroup' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveHaGroup' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveHaGroup' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have optional Group parameter' { + Skip-IfMissing 'Get-PveHaGroup' + $script:Cmd.Parameters.ContainsKey('Group') | Should -BeTrue + } + It 'Group should not be Mandatory' { + Skip-IfMissing 'Get-PveHaGroup' + $p = $script:Cmd.Parameters['Group'] + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -BeNullOrEmpty + } + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveHaGroup' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveHaGroup' + { Get-PveHaGroup -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveHaGroup +# --------------------------------------------------------------------------- +Describe 'New-PveHaGroup' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveHaGroup' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveHaGroup' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'New-PveHaGroup' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Group parameter' { + Skip-IfMissing 'New-PveHaGroup' + $p = $script:Cmd.Parameters['Group'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have mandatory Nodes parameter' { + Skip-IfMissing 'New-PveHaGroup' + $p = $script:Cmd.Parameters['Nodes'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have optional Restricted switch' { + Skip-IfMissing 'New-PveHaGroup' + $script:Cmd.Parameters.ContainsKey('Restricted') | Should -BeTrue + } + It 'Should have optional NoFailback switch' { + Skip-IfMissing 'New-PveHaGroup' + $script:Cmd.Parameters.ContainsKey('NoFailback') | Should -BeTrue + } + It 'Should have optional Comment parameter' { + Skip-IfMissing 'New-PveHaGroup' + $script:Cmd.Parameters.ContainsKey('Comment') | Should -BeTrue + } + It 'Should have Session parameter' { + Skip-IfMissing 'New-PveHaGroup' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'New-PveHaGroup' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveHaGroup' + { New-PveHaGroup -Group 'grp1' -Nodes 'pve1:1,pve2:2' -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Set-PveHaGroup +# --------------------------------------------------------------------------- +Describe 'Set-PveHaGroup' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Set-PveHaGroup' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Set-PveHaGroup' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Set-PveHaGroup' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Group parameter' { + Skip-IfMissing 'Set-PveHaGroup' + $p = $script:Cmd.Parameters['Group'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have optional Nodes parameter' { + Skip-IfMissing 'Set-PveHaGroup' + $script:Cmd.Parameters.ContainsKey('Nodes') | Should -BeTrue + } + It 'Should have optional Restricted parameter with ValidateRange(0, 1)' { + Skip-IfMissing 'Set-PveHaGroup' + $p = $script:Cmd.Parameters['Restricted'] + $p | Should -Not -BeNullOrEmpty + $rangeAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + $rangeAttr | Should -Not -BeNullOrEmpty + $rangeAttr.MinRange | Should -Be 0 + $rangeAttr.MaxRange | Should -Be 1 + } + It 'Should have optional NoFailback parameter with ValidateRange(0, 1)' { + Skip-IfMissing 'Set-PveHaGroup' + $p = $script:Cmd.Parameters['NoFailback'] + $p | Should -Not -BeNullOrEmpty + $rangeAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + $rangeAttr | Should -Not -BeNullOrEmpty + $rangeAttr.MinRange | Should -Be 0 + $rangeAttr.MaxRange | Should -Be 1 + } + It 'Should have Session parameter' { + Skip-IfMissing 'Set-PveHaGroup' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'Set-PveHaGroup' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Set-PveHaGroup' + { Set-PveHaGroup -Group 'grp1' -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveHaGroup +# --------------------------------------------------------------------------- +Describe 'Remove-PveHaGroup' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveHaGroup' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveHaGroup' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Remove-PveHaGroup' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Group parameter' { + Skip-IfMissing 'Remove-PveHaGroup' + $p = $script:Cmd.Parameters['Group'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have Session parameter' { + Skip-IfMissing 'Remove-PveHaGroup' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'Remove-PveHaGroup' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + It 'Should have ConfirmImpact High' { + Skip-IfMissing 'Remove-PveHaGroup' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.ConfirmImpact | Should -Be 'High' + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveHaGroup' + { Remove-PveHaGroup -Group 'grp1' -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Get-PveHaStatus +# --------------------------------------------------------------------------- +Describe 'Get-PveHaStatus' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveHaStatus' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveHaStatus' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveHaStatus' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveHaStatus' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveHaStatus' + { Get-PveHaStatus -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Get-PveHaRule +# --------------------------------------------------------------------------- +Describe 'Get-PveHaRule' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveHaRule' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveHaRule' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveHaRule' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have optional Rule parameter' { + Skip-IfMissing 'Get-PveHaRule' + $script:Cmd.Parameters.ContainsKey('Rule') | Should -BeTrue + } + It 'Rule should not be Mandatory' { + Skip-IfMissing 'Get-PveHaRule' + $p = $script:Cmd.Parameters['Rule'] + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -BeNullOrEmpty + } + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveHaRule' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveHaRule' + { Get-PveHaRule -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveHaRule +# --------------------------------------------------------------------------- +Describe 'New-PveHaRule' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveHaRule' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveHaRule' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'New-PveHaRule' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Type parameter' { + Skip-IfMissing 'New-PveHaRule' + $p = $script:Cmd.Parameters['Type'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have optional State parameter with ValidateSet' { + Skip-IfMissing 'New-PveHaRule' + $p = $script:Cmd.Parameters['State'] + $p | Should -Not -BeNullOrEmpty + $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $vsAttr | Should -Not -BeNullOrEmpty + } + It 'Should have optional Comment parameter' { + Skip-IfMissing 'New-PveHaRule' + $script:Cmd.Parameters.ContainsKey('Comment') | Should -BeTrue + } + It 'Should have optional Properties parameter' { + Skip-IfMissing 'New-PveHaRule' + $script:Cmd.Parameters.ContainsKey('Properties') | Should -BeTrue + } + It 'Should have Session parameter' { + Skip-IfMissing 'New-PveHaRule' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'New-PveHaRule' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveHaRule' + { New-PveHaRule -Type 'location' -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Set-PveHaRule +# --------------------------------------------------------------------------- +Describe 'Set-PveHaRule' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Set-PveHaRule' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Set-PveHaRule' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Set-PveHaRule' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Rule parameter' { + Skip-IfMissing 'Set-PveHaRule' + $p = $script:Cmd.Parameters['Rule'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have mandatory Type parameter with ValidateSet' { + Skip-IfMissing 'Set-PveHaRule' + $p = $script:Cmd.Parameters['Type'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $vsAttr | Should -Not -BeNullOrEmpty + } + It 'Should have optional State parameter with ValidateSet' { + Skip-IfMissing 'Set-PveHaRule' + $p = $script:Cmd.Parameters['State'] + $p | Should -Not -BeNullOrEmpty + $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $vsAttr | Should -Not -BeNullOrEmpty + } + It 'Should have optional Properties parameter' { + Skip-IfMissing 'Set-PveHaRule' + $script:Cmd.Parameters.ContainsKey('Properties') | Should -BeTrue + } + It 'Should have Session parameter' { + Skip-IfMissing 'Set-PveHaRule' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'Set-PveHaRule' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Set-PveHaRule' + { Set-PveHaRule -Rule 'rule1' -Type 'node-affinity' -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveHaRule +# --------------------------------------------------------------------------- +Describe 'Remove-PveHaRule' -Tag 'Unit' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveHaRule' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveHaRule' + $script:Cmd | Should -Not -BeNullOrEmpty + } + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Remove-PveHaRule' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have mandatory Rule parameter' { + Skip-IfMissing 'Remove-PveHaRule' + $p = $script:Cmd.Parameters['Rule'] + $p | Should -Not -BeNullOrEmpty + $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + It 'Should have Session parameter' { + Skip-IfMissing 'Remove-PveHaRule' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + It 'Should support ShouldProcess' { + Skip-IfMissing 'Remove-PveHaRule' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.SupportsShouldProcess | Should -BeTrue + } + It 'Should have ConfirmImpact High' { + Skip-IfMissing 'Remove-PveHaRule' + $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) | + Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] } + $cmdletAttr.ConfirmImpact | Should -Be 'High' + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveHaRule' + { Remove-PveHaRule -Rule 'rule1' -ErrorAction Stop -Confirm:$false } | + Should -Throw '*No active Proxmox VE session*' + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/16_Cluster.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/16_Cluster.Tests.ps1 new file mode 100644 index 0000000..405ccc7 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/16_Cluster.Tests.ps1 @@ -0,0 +1,406 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Integration tests for cluster configuration and HA lifecycle. + + Exercises the full cluster lifecycle: create cluster on node A, + join node B, manage HA groups/rules, and verify cluster state. + + Nodes must be provisioned STANDALONE (not clustered). These tests + create a cluster during the run. Cleanup is handled by reprovisioning + (2-node cluster cannot be torn down via API due to quorum loss). + + WARNING: These tests CREATE a cluster on the target nodes. + Never run against a production cluster. +#> + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve + + # --- State tracking --- + $script:ClusterCreated = $false + $script:JoinInfo = $null + $script:NodeBName = $null + $script:HaRuleCreated = $false + $script:HaTestVmId = $null + + # --- Cluster-specific skip helpers --- + + function script:Skip-IfNoCluster { + if (Skip-IfNoTarget) { return $true } + if (-not $script:ClusterCreated) { + Set-ItResult -Skipped -Because 'Cluster was not created (multi-node env vars may not be set)' + return $true + } + return $false + } + + function script:Skip-IfPve9HaGroups { + # PVE 9.0+ migrated HA groups to rules — groups API returns 500 + if (Skip-IfNoCluster) { return $true } + if ($script:PveVersion -and [int]$script:PveVersion -ge 9) { + Set-ItResult -Skipped -Because 'PVE 9.0+ migrated HA groups to rules — use Get/New/Set/Remove-PveHaRule instead' + return $true + } + return $false + } +} + +AfterAll { + # Best-effort cleanup of HA artifacts + if ($script:HaRuleCreated) { + try { Remove-PveHaRule -Rule 'pester-rule-1' -Confirm:$false -ErrorAction SilentlyContinue } catch { } + } + if ($script:HaTestVmId) { + try { Remove-PveHaResource -Sid "vm:$($script:HaTestVmId)" -Confirm:$false -ErrorAction SilentlyContinue } catch { } + try { Remove-PveVm -Node $script:Node -VmId $script:HaTestVmId -Confirm:$false -ErrorAction SilentlyContinue } catch { } + } + + # Node removal from a 2-node cluster is not possible via API (quorum loss). + # Test infrastructure handles cleanup via reprovisioning. + Disconnect-TestPve +} + +Describe 'Cluster Config & HA Lifecycle — Integration' -Tag 'Integration' { + + # ------------------------------------------------------------------- + Context 'Pre-cluster reads on standalone node' { + It 'Get-PveClusterStatus returns node info' { + if (Skip-IfNoTarget) { return } + + $status = Get-PveClusterStatus -ErrorAction Stop + $status | Should -Not -BeNullOrEmpty + @($status).Count | Should -BeGreaterOrEqual 1 + } + + It 'Get-PveClusterNextId returns valid VMID' { + if (Skip-IfNoTarget) { return } + + $nextId = Get-PveClusterNextId -ErrorAction Stop + $nextId | Should -Not -BeNullOrEmpty + [int]$nextId | Should -BeGreaterOrEqual 100 + } + + It 'Get-PveClusterOption returns options' { + if (Skip-IfNoTarget) { return } + + $options = Get-PveClusterOption -ErrorAction Stop + $options | Should -Not -BeNullOrEmpty + } + + It 'Get-PveClusterConfig returns config' { + if (Skip-IfNoTarget) { return } + + { Get-PveClusterConfig -ErrorAction Stop } | Should -Not -Throw + } + + It 'Get-PveHaStatus returns status without throwing' { + if (Skip-IfNoTarget) { return } + + { Get-PveHaStatus -ErrorAction Stop } | Should -Not -Throw + } + } + + # ------------------------------------------------------------------- + Context 'Create cluster on node A' { + It 'New-PveCluster creates a cluster named pester-cluster' { + if (Skip-IfNoNodeB) { return } + + # Already connected as root@pam via Connect-TestPve + $result = New-PveCluster -ClusterName 'pester-cluster' -Wait -Confirm:$false -ErrorAction Stop + $result | Should -Not -BeNullOrEmpty + $script:ClusterCreated = $true + + # Allow corosync to fully stabilize + Start-Sleep -Seconds 5 + } + + It 'Get-PveClusterStatus shows cluster formed' { + if (Skip-IfNoCluster) { return } + + $status = Get-PveClusterStatus -ErrorAction Stop + @($status).Count | Should -BeGreaterOrEqual 1 + } + + It 'Get-PveClusterConfigNode lists node A' { + if (Skip-IfNoCluster) { return } + + $nodes = Get-PveClusterConfigNode -ErrorAction Stop + $nodes | Should -Not -BeNullOrEmpty + @($nodes).Count | Should -BeGreaterOrEqual 1 + } + } + + # ------------------------------------------------------------------- + Context 'Join node B to cluster' { + It 'Get-PveClusterJoinInfo returns join token from node A' { + if (Skip-IfNoNodeB) { return } + if (Skip-IfNoCluster) { return } + + $script:JoinInfo = Get-PveClusterJoinInfo -ErrorAction Stop + $script:JoinInfo | Should -Not -BeNullOrEmpty + + # Nodelist is List> — access pve_fp from first entry + $script:JoinInfo.Nodelist | Should -Not -BeNullOrEmpty + $script:JoinInfo.Nodelist.Count | Should -BeGreaterOrEqual 1 + $script:Fingerprint = $script:JoinInfo.Nodelist[0]['pve_fp'] + $script:Fingerprint | Should -Not -BeNullOrEmpty + } + + It 'Add-PveClusterMember joins node B to cluster' { + if (Skip-IfNoNodeB) { return } + if (Skip-IfNoCluster) { return } + if (-not $script:Fingerprint) { + Set-ItResult -Skipped -Because 'Fingerprint was not extracted from join info' + return + } + + # Connect to node B with root@pam + $secPw = ConvertTo-SecureString $script:PasswordB -AsPlainText -Force + $credB = New-Object System.Management.Automation.PSCredential('root@pam', $secPw) + Connect-PveServer ` + -Server $script:HostB ` + -Port $script:Port ` + -Credential $credB ` + -SkipCertificateCheck + + $fingerprint = $script:Fingerprint + $joinPw = ConvertTo-SecureString $script:Password -AsPlainText -Force + + try { + $result = Add-PveClusterMember ` + -Hostname $script:Host_ ` + -Fingerprint $fingerprint ` + -Password $joinPw ` + -Wait ` + -Confirm:$false ` + -ErrorAction Stop + + $result | Should -Not -BeNullOrEmpty + + # Brief pause for pmxcfs to sync + Start-Sleep -Seconds 5 + } + finally { + # Always reconnect to node A + Connect-TestPve + } + } + + It 'Get-PveClusterConfigNode shows both nodes' { + if (Skip-IfNoNodeB) { return } + if (Skip-IfNoCluster) { return } + + $nodes = Get-PveClusterConfigNode -ErrorAction Stop + @($nodes).Count | Should -BeGreaterOrEqual 2 + + # Discover and store node B's name + $nodeNames = @($nodes) | ForEach-Object { $_.Name } + $script:NodeBName = $nodeNames | Where-Object { $_ -ne $script:Node } | Select-Object -First 1 + $script:NodeBName | Should -Not -BeNullOrEmpty + } + + It 'Get-PveClusterStatus shows 2 nodes online' { + if (Skip-IfNoNodeB) { return } + if (Skip-IfNoCluster) { return } + + $status = Get-PveClusterStatus -ErrorAction Stop + $nodeEntries = @($status) | Where-Object { $_.Type -eq 'node' } + @($nodeEntries).Count | Should -BeGreaterOrEqual 2 + + $onlineNodes = @($nodeEntries) | Where-Object { $_.Online -eq 1 } + @($onlineNodes).Count | Should -BeGreaterOrEqual 2 + } + } + + # ------------------------------------------------------------------- + Context 'Cluster options' { + It 'Set-PveClusterOption sets keyboard to en-us' { + if (Skip-IfNoCluster) { return } + + $options = Get-PveClusterOption -ErrorAction Stop + $script:OriginalKeyboard = $options.Keyboard + + Set-PveClusterOption -Keyboard 'en-us' -Confirm:$false -ErrorAction Stop + } + + It 'Get-PveClusterOption reflects the keyboard change' { + if (Skip-IfNoCluster) { return } + + $options = Get-PveClusterOption -ErrorAction Stop + $options.Keyboard | Should -Be 'en-us' + } + + It 'Set-PveClusterOption restores original keyboard' { + if (Skip-IfNoCluster) { return } + + if ($script:OriginalKeyboard) { + Set-PveClusterOption -Keyboard $script:OriginalKeyboard -Confirm:$false -ErrorAction Stop + } + else { + Set-PveClusterOption -Delete 'keyboard' -Confirm:$false -ErrorAction Stop + } + + $options = Get-PveClusterOption -ErrorAction Stop + $options.Keyboard | Should -Be $script:OriginalKeyboard + } + } + + # ------------------------------------------------------------------- + Context 'HA group management (PVE 8 only)' { + It 'New-PveHaGroup creates test group' { + if (Skip-IfPve9HaGroups) { return } + + $nodeList = "$($script:Node):1" + if ($script:NodeBName) { + $nodeList += ",$($script:NodeBName):2" + } + + { New-PveHaGroup -Group 'pester-group' -Nodes $nodeList -Confirm:$false -ErrorAction Stop } | + Should -Not -Throw + } + + It 'Get-PveHaGroup lists groups including pester-group' { + if (Skip-IfPve9HaGroups) { return } + + $groups = @(Get-PveHaGroup -ErrorAction Stop) + $match = $groups | Where-Object { $_.Group -eq 'pester-group' } + $match | Should -Not -BeNullOrEmpty + } + + It 'Get-PveHaGroup returns specific group' { + if (Skip-IfPve9HaGroups) { return } + + $group = Get-PveHaGroup -Group 'pester-group' -ErrorAction Stop + $group | Should -Not -BeNullOrEmpty + $group.Group | Should -Be 'pester-group' + } + + It 'Set-PveHaGroup updates comment' { + if (Skip-IfPve9HaGroups) { return } + + { Set-PveHaGroup -Group 'pester-group' -Comment 'pester test' -Confirm:$false -ErrorAction Stop } | + Should -Not -Throw + } + + It 'Remove-PveHaGroup deletes test group' { + if (Skip-IfPve9HaGroups) { return } + + { Remove-PveHaGroup -Group 'pester-group' -Confirm:$false -ErrorAction Stop } | + Should -Not -Throw + } + } + + # ------------------------------------------------------------------- + Context 'HA rules CRUD (PVE 9.0+)' { + It 'Get-PveHaRule returns rules list without throwing' { + if (Skip-IfNoPve9) { return } + + { Get-PveHaRule -ErrorAction Stop } | Should -Not -Throw + } + + It 'New-PveHaRule creates a node-affinity rule' { + if (Skip-IfNoPve9) { return } + if (Skip-IfNoCluster) { return } + + # HA rules require a managed resource — create a minimal VM first + $script:HaTestVmId = Get-PveClusterNextId -ErrorAction Stop + New-PveVm -Node $script:Node -VmId $script:HaTestVmId ` + -Name 'pester-ha-test' -Memory 128 -Cores 1 ` + -Confirm:$false -ErrorAction Stop | Out-Null + + New-PveHaResource -Sid "vm:$($script:HaTestVmId)" ` + -State 'disabled' ` + -Confirm:$false -ErrorAction Stop + + { New-PveHaRule -Type 'node-affinity' ` + -Properties @{ + rule = 'pester-rule-1' + resources = "vm:$($script:HaTestVmId)" + nodes = $script:Node + } ` + -Comment 'Pester test rule' ` + -Confirm:$false ` + -ErrorAction Stop } | Should -Not -Throw + + $script:HaRuleCreated = $true + } + + It 'Get-PveHaRule lists the created rule' { + if (Skip-IfNoPve9) { return } + if (-not $script:HaRuleCreated) { + Set-ItResult -Skipped -Because 'HA rule was not created' + return + } + + $rules = Get-PveHaRule -ErrorAction Stop + $rules | Should -Not -BeNullOrEmpty + $match = @($rules) | Where-Object { $_.Rule -eq 'pester-rule-1' } + $match | Should -Not -BeNullOrEmpty + } + + It 'Get-PveHaRule returns specific rule by ID' { + if (Skip-IfNoPve9) { return } + if (-not $script:HaRuleCreated) { + Set-ItResult -Skipped -Because 'HA rule was not created' + return + } + + $rule = Get-PveHaRule -Rule 'pester-rule-1' -ErrorAction Stop + $rule | Should -Not -BeNullOrEmpty + $rule.Rule | Should -Be 'pester-rule-1' + $rule.Comment | Should -Be 'Pester test rule' + } + + It 'Set-PveHaRule updates the comment' { + if (Skip-IfNoPve9) { return } + if (-not $script:HaRuleCreated) { + Set-ItResult -Skipped -Because 'HA rule was not created' + return + } + + { Set-PveHaRule -Rule 'pester-rule-1' -Type 'node-affinity' ` + -Comment 'Updated by Pester' ` + -Confirm:$false ` + -ErrorAction Stop } | Should -Not -Throw + + $rule = Get-PveHaRule -Rule 'pester-rule-1' -ErrorAction Stop + $rule.Comment | Should -Be 'Updated by Pester' + } + + It 'Remove-PveHaRule deletes the rule' { + if (Skip-IfNoPve9) { return } + if (-not $script:HaRuleCreated) { + Set-ItResult -Skipped -Because 'HA rule was not created' + return + } + + { Remove-PveHaRule -Rule 'pester-rule-1' -Confirm:$false -ErrorAction Stop } | + Should -Not -Throw + + $script:HaRuleCreated = $false + + # Clean up HA resource and test VM + try { Remove-PveHaResource -Sid "vm:$($script:HaTestVmId)" -Confirm:$false -ErrorAction SilentlyContinue } catch { } + try { Remove-PveVm -Node $script:Node -VmId $script:HaTestVmId -Confirm:$false -ErrorAction SilentlyContinue } catch { } + $script:HaTestVmId = $null + + # Verify rule is gone + $rules = Get-PveHaRule -ErrorAction Stop + $match = @($rules) | Where-Object { $_.Rule -eq 'pester-rule-1' } + $match | Should -BeNullOrEmpty + } + } + + # ------------------------------------------------------------------- + Context 'Verify cluster state at end of test' { + It 'Get-PveClusterConfigNode shows both nodes still present' { + if (Skip-IfNoNodeB) { return } + if (Skip-IfNoCluster) { return } + + $nodes = Get-PveClusterConfigNode -ErrorAction Stop + @($nodes).Count | Should -BeGreaterOrEqual 2 + } + } +}