From 02b5511b3deef1b459b2fa65f937700e65d6b58d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 20 Apr 2026 15:18:54 -0400 Subject: [PATCH] fix(wfctl): fix Spaces bucket bootstrap to use /v2/spaces/buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous code hit /v2/spaces/{bucket} (check) and /v2/spaces (create), which don't exist — causing HTTP 404 on every fresh-environment bootstrap. Changes: - Check: GET /v2/spaces/buckets/{bucket} (200 → exists, 404 → missing) - Create: POST /v2/spaces/buckets (201 → created) - Include server response body in error messages for 4xx/5xx create failures - Extract bootstrapDOSpacesBucketAt(ctx, bucket, region, apiBase) for testing Tests: 4 httptest-based cases cover exist-skip, create-new, 4xx-with-body, and missing-token. GOWORK=off go test ./cmd/wfctl/... -run Bootstrap passes. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/infra_bootstrap.go | 28 ++++++-- cmd/wfctl/infra_bootstrap_bucket_test.go | 85 ++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 cmd/wfctl/infra_bootstrap_bucket_test.go diff --git a/cmd/wfctl/infra_bootstrap.go b/cmd/wfctl/infra_bootstrap.go index 66d3a348..c015af5d 100644 --- a/cmd/wfctl/infra_bootstrap.go +++ b/cmd/wfctl/infra_bootstrap.go @@ -7,6 +7,7 @@ import ( "errors" "flag" "fmt" + "io" "net/http" "os" @@ -91,6 +92,12 @@ func bootstrapStateBackend(ctx context.Context, cfgFile string) error { // bootstrapDOSpacesBucket creates a DO Spaces bucket if it does not already exist. func bootstrapDOSpacesBucket(ctx context.Context, bucket, region string) error { + return bootstrapDOSpacesBucketAt(ctx, bucket, region, "https://api.digitalocean.com") +} + +// bootstrapDOSpacesBucketAt is the testable core of bootstrapDOSpacesBucket. +// apiBase is the DO API base URL (injectable for tests). +func bootstrapDOSpacesBucketAt(ctx context.Context, bucket, region, apiBase string) error { token := os.Getenv("DIGITALOCEAN_TOKEN") if token == "" { return fmt.Errorf("DIGITALOCEAN_TOKEN not set") @@ -99,9 +106,12 @@ func bootstrapDOSpacesBucket(ctx context.Context, bucket, region string) error { region = "nyc3" } - // Check if bucket exists. - checkURL := fmt.Sprintf("https://api.digitalocean.com/v2/spaces/%s", bucket) - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, nil) + // Check if bucket exists using the Spaces Buckets REST API. + checkURL := fmt.Sprintf("%s/v2/spaces/buckets/%s", apiBase, bucket) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, nil) + if err != nil { + return fmt.Errorf("check bucket %q: %w", bucket, err) + } req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { @@ -114,19 +124,23 @@ func bootstrapDOSpacesBucket(ctx context.Context, bucket, region string) error { return nil } - // Create bucket. + // Create bucket via POST /v2/spaces/buckets. payload := map[string]string{"name": bucket, "region": region} body, _ := json.Marshal(payload) - createReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.digitalocean.com/v2/spaces", bytes.NewReader(body)) + createReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+"/v2/spaces/buckets", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create bucket %q: %w", bucket, err) + } createReq.Header.Set("Authorization", "Bearer "+token) createReq.Header.Set("Content-Type", "application/json") createResp, err := http.DefaultClient.Do(createReq) if err != nil { return fmt.Errorf("create bucket %q: %w", bucket, err) } - createResp.Body.Close() + defer createResp.Body.Close() if createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK { - return fmt.Errorf("create bucket %q: HTTP %d", bucket, createResp.StatusCode) + respBody, _ := io.ReadAll(createResp.Body) + return fmt.Errorf("create bucket %q: HTTP %d: %s", bucket, createResp.StatusCode, respBody) } fmt.Printf(" state backend: created DO Spaces bucket %q in %s\n", bucket, region) return nil diff --git a/cmd/wfctl/infra_bootstrap_bucket_test.go b/cmd/wfctl/infra_bootstrap_bucket_test.go new file mode 100644 index 00000000..94e269af --- /dev/null +++ b/cmd/wfctl/infra_bootstrap_bucket_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestBootstrapDOSpacesBucket_AlreadyExists(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + return + } + http.Error(w, "unexpected method", http.StatusInternalServerError) + })) + defer srv.Close() + + t.Setenv("DIGITALOCEAN_TOKEN", "test-token") + if err := bootstrapDOSpacesBucketAt(context.Background(), "my-bucket", "nyc3", srv.URL); err != nil { + t.Fatalf("expected no error when bucket already exists, got: %v", err) + } +} + +func TestBootstrapDOSpacesBucket_CreatesNew(t *testing.T) { + var postCalled bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusNotFound) + case http.MethodPost: + postCalled = true + w.WriteHeader(http.StatusCreated) + default: + http.Error(w, "unexpected: "+r.Method, http.StatusInternalServerError) + } + })) + defer srv.Close() + + t.Setenv("DIGITALOCEAN_TOKEN", "test-token") + if err := bootstrapDOSpacesBucketAt(context.Background(), "my-bucket", "nyc3", srv.URL); err != nil { + t.Fatalf("expected no error creating new bucket, got: %v", err) + } + if !postCalled { + t.Error("expected POST to be called when bucket does not exist") + } +} + +func TestBootstrapDOSpacesBucket_CreateErrorIncludesBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusNotFound) + case http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + w.Write([]byte(`{"id":"unprocessable_entity","message":"region is invalid"}`)) //nolint:errcheck + default: + http.Error(w, "unexpected: "+r.Method, http.StatusInternalServerError) + } + })) + defer srv.Close() + + t.Setenv("DIGITALOCEAN_TOKEN", "test-token") + err := bootstrapDOSpacesBucketAt(context.Background(), "bad-bucket", "invalid-region", srv.URL) + if err == nil { + t.Fatal("expected error on 4xx create response") + } + if !strings.Contains(err.Error(), "region is invalid") { + t.Errorf("expected response body in error, got: %v", err) + } +} + +func TestBootstrapDOSpacesBucket_MissingToken(t *testing.T) { + t.Setenv("DIGITALOCEAN_TOKEN", "") + err := bootstrapDOSpacesBucketAt(context.Background(), "my-bucket", "nyc3", "http://unused") + if err == nil { + t.Fatal("expected error when DIGITALOCEAN_TOKEN unset") + } + if !strings.Contains(err.Error(), "DIGITALOCEAN_TOKEN") { + t.Errorf("expected DIGITALOCEAN_TOKEN in error, got: %v", err) + } +}