From 44f475812deffa70380aa37edd17d17a502934f1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 13 Oct 2026 16:19:31 +0000 Subject: [PATCH] refactor: share workspace provenance, readiness, delete, and 429 backoff steps Refs #153 --- .github/workflows/ci.yaml | 1 + internal/controller/coder_access.go | 37 +++ internal/controller/coder_workspace_steps.go | 304 ++++++++++++++++++ .../controller/codertemplatetest_confirm.go | 54 +--- .../codertemplatetest_controller.go | 45 +-- .../controller/codertemplatetest_create.go | 22 +- .../controller/codertemplatetest_delete.go | 118 +------ .../controller/codertemplatetest_readiness.go | 55 +--- 8 files changed, 361 insertions(+), 275 deletions(-) create mode 100644 internal/controller/coder_workspace_steps.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 32e934bef..db3c9f2cd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -88,6 +88,7 @@ jobs: # Shared Coder access that CoderTemplateTest and later pool controllers call. - 'internal/controller/controlplane_access.go' - 'internal/controller/coder_access.go' + - 'internal/controller/coder_workspace_steps.go' - 'internal/app/controllerapp/**' - 'config/e2e/**' - 'deploy/**' diff --git a/internal/controller/coder_access.go b/internal/controller/coder_access.go index 858d93470..84e507987 100644 --- a/internal/controller/coder_access.go +++ b/internal/controller/coder_access.go @@ -23,6 +23,43 @@ import ( // and version resolution. CoderTemplateTest uses them today, and // CoderWorkspacePool (#153) will too. +// coderAnswerFor is the rule for every Coder answer a controller trusts: +// the answer names the ID or name that the controller asked for. Anything else +// means Coder, a proxy, or the SDK is broken, so it is an assertion failure. +func coderAnswerFor(kind, asked, answered string) error { + if asked == answered { + return nil + } + return &coderAnswerError{msg: fmt.Sprintf("assertion failed: Coder answered %s %q when asked for %q", kind, answered, asked)} +} + +type coderAnswerError struct{ msg string } + +func (e *coderAnswerError) Error() string { return e.msg } + +func isCoderNotFound(err error) bool { + return coderStatus(err) == http.StatusNotFound +} + +// coderErrorSummary holds the status and Coder's short message only, never +// details, validation errors, or job logs. +func coderErrorSummary(err error) string { + var sdkErr *codersdk.Error + if errors.As(err, &sdkErr) { + return fmt.Sprintf("Coder answered %d: %s", sdkErr.StatusCode(), sdkErr.Message) + } + return err.Error() +} + +// coderStatus returns the HTTP status of a Coder answer, or 0 without one. +func coderStatus(err error) int { + var sdkErr *codersdk.Error + if errors.As(err, &sdkErr) { + return sdkErr.StatusCode() + } + return 0 +} + // coderStep is the outcome of a shared lookup that cannot go on: a wait, // when the input can still appear, or a failure, when it never will. Each // controller turns it into its own status. diff --git a/internal/controller/coder_workspace_steps.go b/internal/controller/coder_workspace_steps.go new file mode 100644 index 000000000..1d7d24a8c --- /dev/null +++ b/internal/controller/coder_workspace_steps.go @@ -0,0 +1,304 @@ +package controller + +import ( + "context" + "errors" + "math/rand/v2" + "net/http" + "sync" + "time" + + "github.com/coder/coder/v2/codersdk" + "github.com/google/uuid" +) + +// This file holds the workspace steps that every controller which builds +// workspaces shares: provenance after an uncertain create, the readiness rules +// for one build, the delete step, and the HTTP 429 backoff. CoderTemplateTest +// uses them today, and CoderWorkspacePool (#153) will too. + +const ( + // coderBuildPoll is how often a controller reads a workspace while a + // build or a delete runs. + coderBuildPoll = 5 * time.Second + + // The HTTP 429 backoff doubles from 2 s to its 2 m cap, with 20 % jitter, + // like the CoderProvisioner controller. + coderBackoffBase = 2 * time.Second + coderBackoffCap = 2 * time.Minute + + // The delete retry backoff doubles from 1 m to its 30 m cap. Both values + // are guesses, not measurements (#152 plan A3). + coderDeleteBackoffBase = time.Minute + coderDeleteBackoffCap = 30 * time.Minute +) + +// coderBackoff counts consecutive HTTP 429 answers per key, in memory, so a +// restart starts over. The zero value is ready to use. +type coderBackoff struct { + attempts sync.Map // Key -> consecutive HTTP 429 answers. +} + +// next counts one more 429 for key and returns the wait before the next try. +func (b *coderBackoff) next(key any) time.Duration { + attempts := 0 + if v, ok := b.attempts.Load(key); ok { + attempts = v.(int) + } + b.attempts.Store(key, attempts+1) + backoff := coderBackoffCap + if attempts < 6 { // 2 s * 2^6 passes the cap. + backoff = min(coderBackoffBase< consecutive HTTP 429 answers. + rateLimited coderBackoff // Keyed by types.NamespacedName. } // templateTestControlPlaneRefIndex indexes tests by the control plane they use. @@ -124,7 +122,7 @@ func (r *CoderTemplateTestReconciler) Reconcile(ctx context.Context, req ctrl.Re keepBackoff := false defer func() { if !keepBackoff { - r.rateLimited.Delete(req.NamespacedName) + r.rateLimited.forget(req.NamespacedName) } }() tt := &coderv1alpha1.CoderTemplateTest{} @@ -391,20 +389,6 @@ func templateTestWorkspaceName(uid types.UID) (string, error) { return templateTestWorkspacePrefix + hex[:templateTestUIDHexLength], nil } -// coderAnswerFor is the rule for every Coder answer the reconciler trusts: -// the answer names the ID or name that the controller asked for. Anything else -// means Coder, a proxy, or the SDK is broken, so it is an assertion failure. -func coderAnswerFor(kind, asked, answered string) error { - if asked == answered { - return nil - } - return &coderAnswerError{msg: fmt.Sprintf("assertion failed: Coder answered %s %q when asked for %q", kind, answered, asked)} -} - -type coderAnswerError struct{ msg string } - -func (e *coderAnswerError) Error() string { return e.msg } - func templateTestDeadlineExceeded(tt *coderv1alpha1.CoderTemplateTest, lastReason, lastMessage string) *templateTestStep { return templateTestFail("DeadlineExceeded", "The test did not finish within %ds. Last wait: %s: %s", templateTestTimeoutSeconds(tt), lastReason, lastMessage) @@ -421,10 +405,6 @@ func isTemplateTestFinal(phase string) bool { return phase == coderv1alpha1.CoderTemplateTestPhaseSucceeded || phase == coderv1alpha1.CoderTemplateTestPhaseFailed } -func isCoderNotFound(err error) bool { - return coderStatus(err) == http.StatusNotFound -} - // coderUnavailable keeps the test waiting after a failed Coder call. The // message holds the status and Coder's short message only, never details. func coderUnavailable(action string, err error) *templateTestStep { @@ -440,25 +420,6 @@ func templateTestStepFrom(step *coderStep) *templateTestStep { return &templateTestStep{failed: step.failed, reason: step.reason, message: step.message, rateLimited: step.rateLimited} } -// coderErrorSummary holds the status and Coder's short message only, never -// details, validation errors, or job logs. -func coderErrorSummary(err error) string { - var sdkErr *codersdk.Error - if errors.As(err, &sdkErr) { - return fmt.Sprintf("Coder answered %d: %s", sdkErr.StatusCode(), sdkErr.Message) - } - return err.Error() -} - -// coderStatus returns the HTTP status of a Coder answer, or 0 without one. -func coderStatus(err error) int { - var sdkErr *codersdk.Error - if errors.As(err, &sdkErr) { - return sdkErr.StatusCode() - } - return 0 -} - func truncateTemplateTestMessage(message string) string { if len(message) <= templateTestMaxMessageLength { return message diff --git a/internal/controller/codertemplatetest_create.go b/internal/controller/codertemplatetest_create.go index b6511d7d5..06e2ec4d9 100644 --- a/internal/controller/codertemplatetest_create.go +++ b/internal/controller/codertemplatetest_create.go @@ -5,7 +5,6 @@ import ( "crypto/tls" "errors" "fmt" - "math/rand/v2" "net" "net/http" "time" @@ -18,11 +17,6 @@ import ( coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1" ) -const ( - templateTestBackoffBase = 2 * time.Second - templateTestBackoffCap = 2 * time.Minute -) - // createWorkspace writes the create marker, then sends the only create // request of this attempt. A marker write conflict ends the reconcile before // any request, so a decision made on a stale object never reaches Coder. @@ -115,8 +109,8 @@ func templateTestFailNotCreated(reason, format string, args ...any) *templateTes } // retryAfter returns the requeue delay for a step that waits. After HTTP 429 -// it backs off from 2 s to 2 m with 20 % jitter, like the CoderProvisioner -// controller. The attempt count lives in memory, so a restart starts over. +// it backs off from 2 s to 2 m with 20 % jitter (coderBackoff). The attempt +// count lives in memory, so a restart starts over. // Reconcile forgets it after any outcome other than a wait after 429. func (r *CoderTemplateTestReconciler) retryAfter(key types.NamespacedName, tt *coderv1alpha1.CoderTemplateTest, step *templateTestStep) time.Duration { // A 429 backoff wins over a step's own poll (r4173060957). @@ -129,15 +123,5 @@ func (r *CoderTemplateTestReconciler) retryAfter(key types.NamespacedName, tt *c } return templateTestPendingPoll } - attempts := 0 - if v, ok := r.rateLimited.Load(key); ok { - attempts = v.(int) - } - r.rateLimited.Store(key, attempts+1) - backoff := templateTestBackoffCap - if attempts < 6 { // 2 s * 2^6 passes the cap. - backoff = min(templateTestBackoffBase<