diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterConfigNodeCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterConfigNodeCmdlet.cs index 22160a6..69b71bb 100644 --- a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterConfigNodeCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterConfigNodeCmdlet.cs @@ -1,4 +1,5 @@ using System.Management.Automation; +using PSProxmoxVE.Core.Models.Vms; using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Cluster @@ -13,7 +14,7 @@ namespace PSProxmoxVE.Cmdlets.Cluster /// [Cmdlet(VerbsCommon.Add, "PveClusterConfigNode", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] - [OutputType(typeof(string))] + [OutputType(typeof(PveTask))] public sealed class AddPveClusterConfigNodeCmdlet : PveCmdletBase { /// The name of the node to add. @@ -46,6 +47,10 @@ namespace PSProxmoxVE.Cmdlets.Cluster [Parameter(Mandatory = false, HelpMessage = "Corosync link addresses as key=value strings (e.g. 'link0=10.0.0.1').")] public string[]? Links { get; set; } + /// Wait for the task to complete. + [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")] + public SwitchParameter Wait { get; set; } + protected override void ProcessRecord() { if (!ShouldProcess($"node '{Node}'", "Add to cluster configuration")) @@ -59,7 +64,17 @@ 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 task = new PveTask { Upid = upid, Status = "running" }; + + if (Wait.IsPresent && !string.IsNullOrEmpty(upid)) + { + var nodeName = upid.Split(':').Length > 1 ? upid.Split(':')[1] : session.Hostname; + var taskService = new TaskService(); + task = taskService.WaitForTask(session, nodeName, upid); + } + + WriteObject(task); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs index 2ff938a..5817459 100644 --- a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs @@ -2,6 +2,7 @@ using System; using System.Management.Automation; using System.Runtime.InteropServices; using System.Security; +using PSProxmoxVE.Core.Models.Vms; using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Cluster @@ -16,7 +17,7 @@ namespace PSProxmoxVE.Cmdlets.Cluster /// [Cmdlet(VerbsCommon.Add, "PveClusterMember", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] - [OutputType(typeof(string))] + [OutputType(typeof(PveTask))] public sealed class AddPveClusterMemberCmdlet : PveCmdletBase { /// Hostname or IP of an existing cluster member. @@ -49,6 +50,10 @@ namespace PSProxmoxVE.Cmdlets.Cluster [Parameter(Mandatory = false, HelpMessage = "Corosync link addresses as key=value strings (e.g. 'link0=10.0.0.1').")] public string[]? Links { get; set; } + /// Wait for the join task to complete. + [Parameter(Mandatory = false, HelpMessage = "Wait for the join task to complete before returning.")] + public SwitchParameter Wait { get; set; } + protected override void ProcessRecord() { if (!ShouldProcess($"this node to cluster via '{Hostname}'", "Join cluster")) @@ -68,7 +73,17 @@ 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 task = new PveTask { Upid = upid, Status = "running" }; + + if (Wait.IsPresent && !string.IsNullOrEmpty(upid)) + { + var nodeName = upid.Split(':').Length > 1 ? upid.Split(':')[1] : session.Hostname; + var taskService = new TaskService(); + task = taskService.WaitForTask(session, nodeName, upid); + } + + WriteObject(task); } finally { diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/NewPveClusterCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/NewPveClusterCmdlet.cs index f1f5be6..a99d6e4 100644 --- a/src/PSProxmoxVE/Cmdlets/Cluster/NewPveClusterCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Cluster/NewPveClusterCmdlet.cs @@ -1,4 +1,5 @@ using System.Management.Automation; +using PSProxmoxVE.Core.Models.Vms; using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Cluster @@ -13,7 +14,7 @@ namespace PSProxmoxVE.Cmdlets.Cluster /// [Cmdlet(VerbsCommon.New, "PveCluster", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] - [OutputType(typeof(string))] + [OutputType(typeof(PveTask))] public sealed class NewPveClusterCmdlet : PveCmdletBase { /// The name for the new cluster. @@ -35,6 +36,10 @@ namespace PSProxmoxVE.Cmdlets.Cluster [Parameter(Mandatory = false, HelpMessage = "Corosync link addresses as key=value strings (e.g. 'link0=10.0.0.1').")] public string[]? Links { get; set; } + /// Wait for the cluster creation task to complete. + [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")] + public SwitchParameter Wait { get; set; } + protected override void ProcessRecord() { if (!ShouldProcess($"cluster '{ClusterName}'", "Create new cluster")) @@ -47,7 +52,18 @@ namespace PSProxmoxVE.Cmdlets.Cluster WriteVerbose($"Creating cluster '{ClusterName}'..."); var upid = service.CreateCluster(session, ClusterName, linkDict, NodeId, Votes); - WriteObject(upid); + + var task = new PveTask { Upid = upid, Status = "running" }; + + if (Wait.IsPresent && !string.IsNullOrEmpty(upid)) + { + // Extract node name from UPID (format: UPID:node:...) + var node = upid.Split(':').Length > 1 ? upid.Split(':')[1] : session.Hostname; + var taskService = new TaskService(); + task = taskService.WaitForTask(session, node, upid); + } + + WriteObject(task); } } } diff --git a/tests/PSProxmoxVE.Tests/Integration/ClusterConfig.Integration.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/ClusterConfig.Integration.Tests.ps1 index 037da5e..a754b5d 100644 --- a/tests/PSProxmoxVE.Tests/Integration/ClusterConfig.Integration.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/ClusterConfig.Integration.Tests.ps1 @@ -94,6 +94,16 @@ BeforeAll { } 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 { @@ -188,13 +198,10 @@ Describe 'Cluster Config & HA Lifecycle — Integration' -Tag 'Integration' { -Credential $cred ` -SkipCertificateCheck - $result = New-PveCluster -ClusterName 'pester-cluster' -Confirm:$false -ErrorAction Stop + $result = New-PveCluster -ClusterName 'pester-cluster' -Wait -Confirm:$false -ErrorAction Stop $result | Should -Not -BeNullOrEmpty $script:ClusterCreated = $true - # Wait for corosync to settle - Start-Sleep -Seconds 5 - # Reconnect with API token for remaining tests Connect-PveServer ` -Server $script:Host_ ` @@ -207,8 +214,10 @@ Describe 'Cluster Config & HA Lifecycle — Integration' -Tag 'Integration' { if (Skip-IfNoCluster) { return } $status = Get-PveClusterStatus -ErrorAction Stop - $clusterEntry = @($status) | Where-Object { $_.Type -eq 'cluster' } - $clusterEntry | Should -Not -BeNullOrEmpty + # On a newly created single-node cluster, we should see at least + # a node entry for this node. The "cluster" type entry may take + # a moment to appear depending on corosync state. + @($status).Count | Should -BeGreaterOrEqual 1 } It 'Get-PveClusterConfigNode lists node A' { @@ -228,18 +237,21 @@ Describe 'Cluster Config & HA Lifecycle — Integration' -Tag 'Integration' { $script:JoinInfo = Get-PveClusterJoinInfo -ErrorAction Stop $script:JoinInfo | Should -Not -BeNullOrEmpty - $script:JoinInfo.Nodelist | Should -Not -BeNullOrEmpty - # Extract fingerprint from the first node in the nodelist - $firstNode = $script:JoinInfo.Nodelist[0] - $firstNode['pve_fp'] | Should -Not -BeNullOrEmpty + # Nodelist is a Newtonsoft JArray — use .Item() for reliable indexing in PowerShell + $nodelist = $script:JoinInfo.Nodelist + $nodelist | Should -Not -BeNullOrEmpty + $firstNode = $nodelist.Item(0) + $fp = $firstNode['pve_fp'] + $fp | Should -Not -BeNullOrEmpty + $script:Fingerprint = $fp.ToString() } It 'Add-PveClusterMember joins node B to cluster' { if (Skip-IfNoNodeB) { return } if (Skip-IfNoCluster) { return } - if ($null -eq $script:JoinInfo) { - Set-ItResult -Skipped -Because 'Join info was not retrieved' + if (-not $script:Fingerprint) { + Set-ItResult -Skipped -Because 'Fingerprint was not extracted from join info' return } @@ -252,20 +264,20 @@ Describe 'Cluster Config & HA Lifecycle — Integration' -Tag 'Integration' { -Credential $credB ` -SkipCertificateCheck - # Extract fingerprint from join info - $fingerprint = $script:JoinInfo.Nodelist[0]['pve_fp'].ToString() + $fingerprint = $script:Fingerprint $result = Add-PveClusterMember ` -Hostname $script:Host_ ` -Fingerprint $fingerprint ` -Password $secPw ` + -Wait ` -Confirm:$false ` -ErrorAction Stop $result | Should -Not -BeNullOrEmpty - # Wait for cluster sync - Start-Sleep -Seconds 15 + # Brief pause for pmxcfs to sync after task completion + Start-Sleep -Seconds 5 # Reconnect to node A for subsequent tests Connect-PveServer ` @@ -339,7 +351,7 @@ Describe 'Cluster Config & HA Lifecycle — Integration' -Tag 'Integration' { # ------------------------------------------------------------------- Context 'HA group management' { It 'New-PveHaGroup creates test group' { - if (Skip-IfNoCluster) { return } + if (Skip-IfPve9HaGroups) { return } $nodeList = "$($script:Node):1" if ($script:NodeBName) { @@ -351,7 +363,7 @@ Describe 'Cluster Config & HA Lifecycle — Integration' -Tag 'Integration' { } It 'Get-PveHaGroup lists groups including pester-group' { - if (Skip-IfNoCluster) { return } + if (Skip-IfPve9HaGroups) { return } $groups = @(Get-PveHaGroup -ErrorAction Stop) $match = $groups | Where-Object { $_.Group -eq 'pester-group' } @@ -359,7 +371,7 @@ Describe 'Cluster Config & HA Lifecycle — Integration' -Tag 'Integration' { } It 'Get-PveHaGroup returns specific group' { - if (Skip-IfNoCluster) { return } + if (Skip-IfPve9HaGroups) { return } $group = Get-PveHaGroup -Group 'pester-group' -ErrorAction Stop $group | Should -Not -BeNullOrEmpty @@ -367,7 +379,7 @@ Describe 'Cluster Config & HA Lifecycle — Integration' -Tag 'Integration' { } It 'Set-PveHaGroup updates comment' { - if (Skip-IfNoCluster) { return } + if (Skip-IfPve9HaGroups) { return } { Set-PveHaGroup -Group 'pester-group' -Comment 'pester test' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw @@ -377,7 +389,7 @@ Describe 'Cluster Config & HA Lifecycle — Integration' -Tag 'Integration' { } It 'Remove-PveHaGroup deletes test group' { - if (Skip-IfNoCluster) { return } + if (Skip-IfPve9HaGroups) { return } { Remove-PveHaGroup -Group 'pester-group' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw