Merge pull request #36 from GoodOlClint/test/cluster-ha-tests

This commit is contained in:
GoodOlClint
2026-03-25 19:51:55 -05:00
committed by GitHub
11 changed files with 3006 additions and 9 deletions
+48 -2
View File
@@ -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
// -------------------------------------------------------------------------
/// <summary>
/// Builds form-encoded content using minimal encoding that matches curl behavior.
/// .NET's <see cref="FormUrlEncodedContent"/> over-encodes characters like
/// <c>:</c> and <c>!</c> which PVE's internal API consumers (e.g. cluster join)
/// do not properly URL-decode.
/// </summary>
private static StringContent BuildFormContent(Dictionary<string, string> 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");
}
/// <summary>
/// Encodes a form value with minimal percent-encoding — only characters that
/// would break form parsing are encoded. This matches curl's <c>-d</c> behavior
/// and avoids over-encoding that PVE does not handle correctly.
/// </summary>
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
// -------------------------------------------------------------------------
@@ -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
/// </summary>
[Cmdlet(VerbsCommon.Add, "PveClusterConfigNode", SupportsShouldProcess = true,
ConfirmImpact = ConfirmImpact.High)]
[OutputType(typeof(string))]
[OutputType(typeof(PveTask))]
public sealed class AddPveClusterConfigNodeCmdlet : PveCmdletBase
{
/// <summary>The name of the node to add.</summary>
@@ -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; }
/// <summary>Wait for the task to complete.</summary>
[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);
}
}
}
@@ -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
/// </summary>
[Cmdlet(VerbsCommon.Add, "PveClusterMember", SupportsShouldProcess = true,
ConfirmImpact = ConfirmImpact.High)]
[OutputType(typeof(string))]
[OutputType(typeof(PveTask))]
public sealed class AddPveClusterMemberCmdlet : PveCmdletBase
{
/// <summary>Hostname or IP of an existing cluster member.</summary>
@@ -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; }
/// <summary>Wait for the join task to complete.</summary>
[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);
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// Retries authentication against this node until auth services come back online
/// after the cluster join restarts them.
/// </summary>
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.");
}
}
}
@@ -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
/// </summary>
[Cmdlet(VerbsCommon.New, "PveCluster", SupportsShouldProcess = true,
ConfirmImpact = ConfirmImpact.High)]
[OutputType(typeof(string))]
[OutputType(typeof(PveTask))]
public sealed class NewPveClusterCmdlet : PveCmdletBase
{
/// <summary>The name for the new cluster.</summary>
@@ -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; }
/// <summary>Wait for the cluster creation task to complete.</summary>
[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);
}
}
}
@@ -19,6 +19,10 @@ namespace PSProxmoxVE.Cmdlets.HA
HelpMessage = "HA rule ID.")]
public string Rule { get; set; } = string.Empty;
/// <summary>Rule type (required by PVE API on update).</summary>
[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;
/// <summary>Rule state.</summary>
[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<string, string>();
var data = new Dictionary<string, string> { ["type"] = Type };
if (State != null) data["state"] = State;
if (Comment != null) data["comment"] = Comment;
if (Properties != null)
+15
View File
@@ -160,6 +160,21 @@ namespace PSProxmoxVE.Cmdlets
TimeSpan.FromSeconds(timeoutSeconds));
}
/// <summary>
/// Extracts the node name from a UPID string (format: UPID:node:...).
/// Falls back to <paramref name="fallback"/> if the UPID is empty or cannot be parsed.
/// </summary>
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;
}
/// <summary>
/// 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.
@@ -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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/config",
It.IsAny<Dictionary<string, string>>()))
.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<Dictionary<string, string>>(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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/config",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
var links = new Dictionary<string, string> { ["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<Dictionary<string, string>>(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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/config/nodes/pve2",
It.IsAny<Dictionary<string, string>>()))
.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<Dictionary<string, string>>(d =>
d["new_node_ip"] == "10.0.0.2")),
Times.Once);
}
[Fact]
public void RemoveConfigNode_CallsDeleteWithEncodedUrl()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/config/join",
It.IsAny<Dictionary<string, string>>()))
.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<Dictionary<string, string>>(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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PutAsync(
"cluster/options",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new ClusterConfigService(mockClient.Object);
var opts = new Dictionary<string, string>
{
["keyboard"] = "de",
["language"] = "de"
};
// Act
service.SetClusterOptions(CreateSession(), opts);
// Assert
mockClient.Verify(c => c.PutAsync(
"cluster/options",
It.Is<Dictionary<string, string>>(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<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/status")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var statuses = service.GetClusterStatus(CreateSession());
// Assert
Assert.Equal(2, statuses.Length);
Assert.Equal("cluster", statuses[0].Type);
Assert.Equal("pve-cluster", statuses[0].Name);
Assert.Equal(3, statuses[0].Nodes);
Assert.Equal(1, statuses[0].Quorate);
Assert.Equal("node", statuses[1].Type);
Assert.Equal("pve1", statuses[1].Name);
Assert.Equal(1, statuses[1].Online);
Assert.Equal("10.0.0.1", statuses[1].Ip);
mockClient.Verify(c => c.GetAsync("cluster/status"), Times.Once);
}
[Fact]
public void GetNextId_ReturnsInt()
{
// Arrange
var json = @"{""data"": ""100""}";
var mockClient = new Mock<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/nextid")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => 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<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/nextid")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => service.GetNextId(CreateSession()));
Assert.Contains("did not contain a 'data' field", ex.Message);
}
[Fact]
public void GetClusterConfig_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetClusterConfig(null!));
}
[Fact]
public void GetConfigNodes_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetConfigNodes(null!));
}
[Fact]
public void GetClusterOptions_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetClusterOptions(null!));
}
[Fact]
public void GetNextId_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetNextId(null!));
}
[Fact]
public void GetClusterStatus_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetClusterStatus(null!));
}
[Fact]
public void Constructor_NullClient_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new ClusterConfigService(null!));
}
}
}
@@ -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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/resources",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
var options = new Dictionary<string, string>
{
["state"] = "started",
["group"] = "grp1"
};
// Act
service.CreateResource(CreateSession(), "vm:100", options);
// Assert
mockClient.Verify(c => c.PostAsync(
"cluster/ha/resources",
It.Is<Dictionary<string, string>>(d =>
d["sid"] == "vm:100" &&
d["state"] == "started" &&
d["group"] == "grp1")),
Times.Once);
}
[Fact]
public void UpdateResource_PutsOptionsToEncodedUrl()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PutAsync(
"cluster/ha/resources/vm%3A100",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
var options = new Dictionary<string, string>
{
["state"] = "stopped"
};
// Act
service.UpdateResource(CreateSession(), "vm:100", options);
// Assert
mockClient.Verify(c => c.PutAsync(
"cluster/ha/resources/vm%3A100",
It.Is<Dictionary<string, string>>(d =>
d["state"] == "stopped")),
Times.Once);
}
[Fact]
public void DeleteResource_CallsDeleteWithEncodedUrl()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/resources/vm%3A100/migrate",
It.IsAny<Dictionary<string, string>>()))
.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<Dictionary<string, string>>(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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/resources/vm%3A100/relocate",
It.IsAny<Dictionary<string, string>>()))
.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<Dictionary<string, string>>(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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/groups",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
var options = new Dictionary<string, string>
{
["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<Dictionary<string, string>>(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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/rules",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
var options = new Dictionary<string, string>
{
["rule"] = "rule1",
["type"] = "location",
["state"] = "enabled"
};
// Act
service.CreateRule(CreateSession(), options);
// Assert
mockClient.Verify(c => c.PostAsync(
"cluster/ha/rules",
It.Is<Dictionary<string, string>>(d =>
d["rule"] == "rule1" &&
d["type"] == "location" &&
d["state"] == "enabled")),
Times.Once);
}
[Fact]
public void DeleteRule_CallsDeleteWithEncodedUrl()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
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<IPveHttpClient>();
var service = new HaService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetResources(null!));
}
[Fact]
public void GetGroups_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new HaService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetGroups(null!));
}
[Fact]
public void GetStatus_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new HaService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetStatus(null!));
}
[Fact]
public void GetRules_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new HaService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetRules(null!));
}
[Fact]
public void Constructor_NullClient_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new HaService(null!));
}
}
}
@@ -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*'
}
}
}
@@ -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*'
}
}
}
@@ -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<Dictionary<string, object?>> — 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
}
}
}