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' {