diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs index 0f630f4..9df9dce 100644 --- a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs @@ -3,6 +3,7 @@ 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; @@ -57,6 +58,9 @@ namespace PSProxmoxVE.Cmdlets.Cluster [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")) @@ -81,22 +85,7 @@ namespace PSProxmoxVE.Cmdlets.Cluster if (Wait.IsPresent && !string.IsNullOrEmpty(upid)) { - var nodeName = upid.Split(':').Length > 1 ? upid.Split(':')[1] : session.Hostname; - var taskService = new TaskService(); - try - { - task = taskService.WaitForTask(session, nodeName, upid); - } - catch (PveApiException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized) - { - // Cluster join restarts auth services on this node, invalidating - // the ticket mid-poll. Re-authenticate and retry. - WriteVerbose("Session expired during join — re-authenticating to poll task status..."); - var newSession = PveAuthenticator.AuthenticateWithCredentials( - session.Hostname, session.Port, session.SkipCertificateCheck, - "root@pam", plainPassword); - task = taskService.WaitForTask(newSession, nodeName, upid); - } + task = WaitForJoinTask(session, upid, plainPassword); } WriteObject(task); @@ -107,5 +96,55 @@ namespace PSProxmoxVE.Cmdlets.Cluster Marshal.ZeroFreeGlobalAllocUnicode(ptr); } } + + /// + /// Waits for the join task to complete. If the ticket is invalidated mid-poll + /// (cluster join restarts auth services), re-authenticates with retry and + /// continues waiting. + /// + private PveTask WaitForJoinTask(PveSession session, string upid, string plainPassword) + { + var nodeName = upid.Split(':').Length > 1 ? upid.Split(':')[1] : session.Hostname; + var taskService = new TaskService(); + + try + { + return taskService.WaitForTask(session, nodeName, upid); + } + catch (PveApiException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized) + { + WriteVerbose("Session expired during join — waiting for auth services to restart..."); + var newSession = ReauthenticateWithRetry(session, plainPassword); + return taskService.WaitForTask(newSession, nodeName, upid); + } + } + + /// + /// Retries authentication against this node until auth services come back online + /// after the cluster join restarts them. + /// + private PveSession ReauthenticateWithRetry(PveSession session, string plainPassword) + { + for (int attempt = 0; attempt < ReauthMaxAttempts; attempt++) + { + Thread.Sleep(ReauthDelayMs); + try + { + return PveAuthenticator.AuthenticateWithCredentials( + session.Hostname, session.Port, session.SkipCertificateCheck, + "root@pam", plainPassword); + } + catch (PveApiException retryEx) when ( + retryEx.StatusCode == HttpStatusCode.Unauthorized || + retryEx.StatusCode == HttpStatusCode.ServiceUnavailable) + { + WriteVerbose($"Re-auth attempt {attempt + 1}/{ReauthMaxAttempts} failed, retrying..."); + } + } + + throw new InvalidOperationException( + "Unable to re-authenticate after cluster join. " + + "Auth services on this node may still be restarting."); + } } } diff --git a/tests/PSProxmoxVE.Tests/Integration/ClusterConfig.Integration.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/ClusterConfig.Integration.Tests.ps1 index fa34596..e086043 100644 --- a/tests/PSProxmoxVE.Tests/Integration/ClusterConfig.Integration.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/ClusterConfig.Integration.Tests.ps1 @@ -261,27 +261,31 @@ Describe 'Cluster Config & HA Lifecycle — Integration' -Tag 'Integration' { $fingerprint = $script:Fingerprint - $result = Add-PveClusterMember ` - -Hostname $script:Host_ ` - -Fingerprint $fingerprint ` - -Password $secPw ` - -Wait ` - -Confirm:$false ` - -ErrorAction Stop + try { + $result = Add-PveClusterMember ` + -Hostname $script:Host_ ` + -Fingerprint $fingerprint ` + -Password $secPw ` + -Wait ` + -Confirm:$false ` + -ErrorAction Stop - $result | Should -Not -BeNullOrEmpty + $result | Should -Not -BeNullOrEmpty - # Brief pause for pmxcfs to sync after task completion - Start-Sleep -Seconds 5 - - # Reconnect to node A with root@pam for subsequent cluster operations - $secPwA = ConvertTo-SecureString $script:Password -AsPlainText -Force - $credA = New-Object System.Management.Automation.PSCredential('root@pam', $secPwA) - Connect-PveServer ` - -Server $script:Host_ ` - -Port $script:Port ` - -Credential $credA ` - -SkipCertificateCheck + # Brief pause for pmxcfs to sync after task completion + Start-Sleep -Seconds 5 + } + finally { + # Always reconnect to node A — whether join succeeded or failed, + # the current session points at node B which may be invalid. + $secPwA = ConvertTo-SecureString $script:Password -AsPlainText -Force + $credA = New-Object System.Management.Automation.PSCredential('root@pam', $secPwA) + Connect-PveServer ` + -Server $script:Host_ ` + -Port $script:Port ` + -Credential $credA ` + -SkipCertificateCheck + } } It 'Get-PveClusterConfigNode shows both nodes' {