From f85daf01faa456eebfd9f0acc72106155462fd40 Mon Sep 17 00:00:00 2001 From: Claudiu Belu Date: Mon, 22 Jun 2026 15:47:52 +0000 Subject: [PATCH] internal/rest/resources/control: Use independent context for join failure cleanup The controlPost reverter used r.Context() for all cleanup operations (PreRemove hook, certificate fetch, client creation). When a join fails due to context deadline exceeded, r.Context() may already be expired, causing the entire cleanup to silently fail and leaving a ghost node in the dqlite cluster. In particular, shared.GetRemoteCertificate was silently returning due an error returned (due to r.Context() being expired), which means that internalClient.DeleteClusterMember was never called. Use an independent context with a 2-minute timeout for cleanup operations so they can complete regardless of the original request context state. Also add error logging to GetRemoteCertificate and client creation failures which previously returned silently. Signed-off-by: Claudiu Belu (cherry picked from commit 08e72c7b1ec6c6db66c4316691de983804855ce3) --- internal/rest/resources/control.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/rest/resources/control.go b/internal/rest/resources/control.go index 57082a99..699fcc49 100644 --- a/internal/rest/resources/control.go +++ b/internal/rest/resources/control.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "slices" + "time" "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" @@ -91,8 +92,14 @@ func controlPost(state types.State, r *http.Request) types.Response { return } + // Use an independent context for cleanup so that it is not tied + // to the request context, which may already be expired (e.g. the + // join timed out). + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cleanupCancel() + // Run the pre-remove hook like we do for cluster node removals. - err := intState.Hooks.PreRemove(r.Context(), state, true) + err := intState.Hooks.PreRemove(cleanupCtx, state, true) if err != nil { logger.Error("Failed to run pre-remove hook on initialization error", slog.String("error", err.Error())) } @@ -101,7 +108,7 @@ func controlPost(state types.State, r *http.Request) types.Response { // As part of this request the cluster member gets re-executed. // If we don't send the request, re-exec the member manually. if joinInfo == nil || req.JoinToken == "" { - reExec, err := resetClusterMember(r.Context(), state, true) + reExec, err := resetClusterMember(cleanupCtx, state, true) if err != nil { logger.Error("Failed to reset cluster member on bootstrap error", slog.String("error", err.Error())) return @@ -114,13 +121,15 @@ func controlPost(state types.State, r *http.Request) types.Response { } url := api.NewURL().Scheme("https").Host(joinInfo.TrustedMember.Address.String()) - cert, err := shared.GetRemoteCertificate(r.Context(), url.String(), "") + cert, err := shared.GetRemoteCertificate(cleanupCtx, url.String(), "") if err != nil { + logger.Error("Failed to get certificate of cluster member for cleanup", slog.String("address", url.String()), slog.String("error", err.Error())) return } client, err := state.Connect().Member(&url.URL, false, cert) if err != nil { + logger.Error("Failed to create client for cluster member cleanup", slog.String("address", url.String()), slog.String("error", err.Error())) return }