Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions cmd/wfctl/infra_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"

Expand Down Expand Up @@ -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")
Expand All @@ -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 {
Expand All @@ -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)
Comment on lines 141 to +143

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On create failures, io.ReadAll(createResp.Body) reads the full response body without bounds and ignores the read error. A large/streaming error response could cause excessive memory usage or hang. Consider reading with a size limit (e.g., io.LimitReader) and handling the read error so the returned message is reliable.

Copilot uses AI. Check for mistakes.
}
fmt.Printf(" state backend: created DO Spaces bucket %q in %s\n", bucket, region)
return nil
Expand Down
85 changes: 85 additions & 0 deletions cmd/wfctl/infra_bootstrap_bucket_test.go
Original file line number Diff line number Diff line change
@@ -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)
}))
Comment on lines +12 to +18

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These httptest handlers only branch on HTTP method and don’t assert the request path (e.g., /v2/spaces/buckets/{bucket}) or POST body (name/region). As a result, the tests could pass even if the production code regresses to the wrong endpoint again. Consider validating r.URL.Path (and decoding the JSON body for POST) so the tests actually cover the intended API contract.

Copilot uses AI. Check for mistakes.
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")
}
Comment on lines +28 to +48

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test has a data race: postCalled is written by the httptest server handler goroutine and read by the test goroutine. Under go test -race this will fail. Use an atomic, a channel, or synchronize via a mutex/WaitGroup (or assert the POST was called by validating handler behavior and failing the test if it wasn’t).

Copilot uses AI. Check for mistakes.
}

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)
}
}
Loading