From e057c1bf4c7c1887a9dc1a6656aeb2c9c16e55c7 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. 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 9feb9d25..f2c23044 100644 --- a/internal/rest/resources/control.go +++ b/internal/rest/resources/control.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "slices" + "time" "github.com/canonical/lxd/lxd/response" "github.com/canonical/lxd/lxd/util" @@ -90,8 +91,14 @@ func controlPost(state state.State, r *http.Request) response.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", logger.Ctx{"error": err}) } @@ -100,7 +107,7 @@ func controlPost(state state.State, r *http.Request) response.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", logger.Ctx{"error": err}) return @@ -113,13 +120,15 @@ func controlPost(state state.State, r *http.Request) response.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", logger.Ctx{"address": url.String(), "error": err}) return } client, err := internalClient.New(*url, state.ServerCert(), cert, false) if err != nil { + logger.Error("Failed to create client for cluster member cleanup", logger.Ctx{"address": url.String(), "error": err}) return }