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
14 changes: 14 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ type CloudPod struct {
// requested pod does not exist (HTTP 404).
var ErrCloudPodNotFound = errors.New("cloud pod not found")

// ErrCloudPodsForbidden is returned when the platform refuses a cloud pod
// request with HTTP 403 ("generic.forbidden"), which is how it reports that the
// caller's plan does not include Cloud Pods. Only 403 maps here: the token
// authenticated fine and was then denied. A bad or expired token comes back as
// 401 and stays a generic error, so re-login problems aren't misreported as a
// billing problem.
var ErrCloudPodsForbidden = errors.New("cloud pods not available on this plan")

// CloudPodResourceCount is a count of a single resource kind within a service,
// e.g. {Noun: "buckets", Count: 3}.
type CloudPodResourceCount struct {
Expand Down Expand Up @@ -405,6 +413,9 @@ func (c *PlatformClient) ListCloudPods(ctx context.Context, authToken, creator s
}
}()

if resp.StatusCode == http.StatusForbidden {
return nil, ErrCloudPodsForbidden
}
if resp.StatusCode != http.StatusOK {
detail, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("failed to list cloud pods: status %d: %s", resp.StatusCode, strings.TrimSpace(string(detail)))
Expand Down Expand Up @@ -485,6 +496,9 @@ func (c *PlatformClient) GetCloudPod(ctx context.Context, authToken, podName str
if resp.StatusCode == http.StatusNotFound {
return nil, ErrCloudPodNotFound
}
if resp.StatusCode == http.StatusForbidden {
return nil, ErrCloudPodsForbidden
}
if resp.StatusCode != http.StatusOK {
detail, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("failed to get cloud pod: status %d: %s", resp.StatusCode, strings.TrimSpace(string(detail)))
Expand Down
38 changes: 38 additions & 0 deletions internal/api/cloudpod_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,41 @@ func TestPluralize(t *testing.T) {
assert.Equal(t, want, pluralize(in), "pluralize(%q)", in)
}
}

// The platform reports "your plan doesn't include Cloud Pods" as a 403, which
// callers translate into a friendly upgrade message (DEVX-1009).
func TestCloudPods_ForbiddenMapsToPlanError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error": true, "message": "generic.forbidden"}`))
}))
defer srv.Close()

client := NewPlatformClient(srv.URL, log.Nop())

_, listErr := client.ListCloudPods(context.Background(), "tok", "me")
assert.ErrorIs(t, listErr, ErrCloudPodsForbidden)

_, showErr := client.GetCloudPod(context.Background(), "tok", "any")
assert.ErrorIs(t, showErr, ErrCloudPodsForbidden)
}

// A rejected token is a 401, not a 403 — it must stay a generic error so a
// re-login problem is never reported as a billing problem.
func TestCloudPods_UnauthorizedIsNotAPlanError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error": true, "message": "unauthorized"}`))
}))
defer srv.Close()

client := NewPlatformClient(srv.URL, log.Nop())

_, listErr := client.ListCloudPods(context.Background(), "tok", "me")
require.Error(t, listErr)
assert.NotErrorIs(t, listErr, ErrCloudPodsForbidden)

_, showErr := client.GetCloudPod(context.Background(), "tok", "any")
require.Error(t, showErr)
assert.NotErrorIs(t, showErr, ErrCloudPodsForbidden)
}
53 changes: 48 additions & 5 deletions internal/emulator/aws/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -25,6 +26,31 @@ type Client struct {
s3BucketURLTemplate string
}

// isFeatureUnavailableResponse reports whether a non-2xx response from a
// /_localstack/pods* endpoint means the snapshot feature itself isn't available
// (the license doesn't cover it), rather than the operation having failed. When
// unentitled, the emulator never registers these routes at all, so every method
// falls through to the generic unmatched-path handler, which replies with a bare
// 404 and no body — a shape every real pods error can be told apart from, since
// those always carry a message (or arrive as an NDJSON error event).
//
// The paths these calls target are hardcoded, never user-supplied, so a URL typo
// cannot reach this and be misreported as an entitlement problem.
func isFeatureUnavailableResponse(statusCode int, body []byte) bool {
return statusCode == http.StatusNotFound && len(bytes.TrimSpace(body)) == 0
}

// emulatorStatusError formats a non-2xx emulator response. header is the whole
// message up to (but excluding) the body, e.g. "LocalStack returned status 404".
// The body is appended only when non-empty, so a response with no body never
// renders a dangling ": ".
func emulatorStatusError(header string, body []byte) error {
if trimmed := strings.TrimSpace(string(body)); trimmed != "" {
return fmt.Errorf("%s: %s", header, trimmed)
}
return errors.New(header)
}

func NewClient() *Client {
return &Client{
http: &http.Client{
Expand Down Expand Up @@ -150,7 +176,11 @@ func (c *Client) ResetState(ctx context.Context, baseURL string) error {
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("LocalStack returned status %d", resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
if isFeatureUnavailableResponse(resp.StatusCode, body) {
return snapshot.ErrSnapshotFeatureUnavailable
}
return emulatorStatusError(fmt.Sprintf("LocalStack returned status %d", resp.StatusCode), body)
}
return nil
}
Expand All @@ -177,7 +207,11 @@ func (c *Client) ExportState(ctx context.Context, baseURL string, services []str
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("LocalStack returned status %d", resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
if isFeatureUnavailableResponse(resp.StatusCode, body) {
return nil, snapshot.ErrSnapshotFeatureUnavailable
}
return nil, emulatorStatusError(fmt.Sprintf("LocalStack returned status %d", resp.StatusCode), body)
}

if _, err := io.Copy(dst, resp.Body); err != nil {
Expand Down Expand Up @@ -214,7 +248,10 @@ func (c *Client) ImportState(ctx context.Context, baseURL string, src io.Reader,
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("LocalStack returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
if isFeatureUnavailableResponse(resp.StatusCode, body) {
return snapshot.ErrSnapshotFeatureUnavailable
}
return emulatorStatusError(fmt.Sprintf("LocalStack returned status %d", resp.StatusCode), body)
}

nd := newNDJSONReader(resp.Body)
Expand Down Expand Up @@ -315,7 +352,10 @@ func (c *Client) DiffPodSnapshot(ctx context.Context, baseURL, podName, authToke
if isPodNotFoundMsg(bodyStr) {
return nil, fmt.Errorf("%w: %s", snapshot.ErrPodNotFound, bodyStr)
}
return nil, fmt.Errorf("diff failed (HTTP %d): %s", resp.StatusCode, bodyStr)
if isFeatureUnavailableResponse(resp.StatusCode, body) {
return nil, snapshot.ErrSnapshotFeatureUnavailable
}
return nil, emulatorStatusError(fmt.Sprintf("diff failed (HTTP %d)", resp.StatusCode), body)
}

var raw map[string][]struct {
Expand Down Expand Up @@ -373,7 +413,10 @@ func (c *Client) RemovePodSnapshot(ctx context.Context, baseURL, podName, authTo
if strings.Contains(strings.ToLower(bodyStr), "not found") {
return fmt.Errorf("%w: %s", snapshot.ErrPodNotFound, bodyStr)
}
return fmt.Errorf("pod remove failed (HTTP %d): %s", resp.StatusCode, bodyStr)
if isFeatureUnavailableResponse(resp.StatusCode, body) {
return snapshot.ErrSnapshotFeatureUnavailable
}
return emulatorStatusError(fmt.Sprintf("pod remove failed (HTTP %d)", resp.StatusCode), body)
}
return nil
}
143 changes: 141 additions & 2 deletions internal/emulator/aws/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/localstack/lstk/internal/snapshot"
)

func TestFetchVersion(t *testing.T) {
Expand Down Expand Up @@ -143,17 +145,32 @@ func TestExportState(t *testing.T) {
assert.Contains(t, err.Error(), "500")
})

t.Run("returns error on 404", func(t *testing.T) {
t.Run("translates an empty-body 404 into the feature-unavailable sentinel", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()

c := NewClient()
_, err := c.ExportState(context.Background(), srv.URL, nil, io.Discard)
require.ErrorIs(t, err, snapshot.ErrSnapshotFeatureUnavailable)
})

t.Run("keeps a 404 that carries a body as a generic error", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("no such route"))
}))
defer srv.Close()

c := NewClient()
_, err := c.ExportState(context.Background(), srv.URL, nil, io.Discard)
require.Error(t, err)
assert.NotErrorIs(t, err, snapshot.ErrSnapshotFeatureUnavailable)
assert.Contains(t, err.Error(), "404")
assert.Contains(t, err.Error(), "no such route")
})

t.Run("returns error on connection refused", func(t *testing.T) {
Expand Down Expand Up @@ -282,16 +299,30 @@ func TestResetState(t *testing.T) {
assert.Contains(t, err.Error(), "500")
})

t.Run("returns error on 404", func(t *testing.T) {
t.Run("translates an empty-body 404 into the feature-unavailable sentinel", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()

c := NewClient()
err := c.ResetState(context.Background(), srv.URL)
require.ErrorIs(t, err, snapshot.ErrSnapshotFeatureUnavailable)
})

t.Run("keeps a 404 that carries a body as a generic error", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("no such route"))
}))
defer srv.Close()

c := NewClient()
err := c.ResetState(context.Background(), srv.URL)
require.Error(t, err)
assert.NotErrorIs(t, err, snapshot.ErrSnapshotFeatureUnavailable)
assert.Contains(t, err.Error(), "404")
})

Expand Down Expand Up @@ -332,3 +363,111 @@ func TestResetState(t *testing.T) {
})
}


// snapshotOps invokes every snapshot-related endpoint the emulator gates behind
// the Cloud Pods license, so the empty-body-404 translation is asserted for all
// of them at once. A method missing from this table is a method that would still
// surface the raw "status 404" error (DEVX-1009).
func snapshotOps() map[string]func(context.Context, *Client, string) error {
return map[string]func(context.Context, *Client, string) error{
"ExportState": func(ctx context.Context, c *Client, host string) error {
_, err := c.ExportState(ctx, host, nil, io.Discard)
return err
},
"ImportState": func(ctx context.Context, c *Client, host string) error {
return c.ImportState(ctx, host, strings.NewReader("zip"), "")
},
"ResetState": func(ctx context.Context, c *Client, host string) error {
return c.ResetState(ctx, host)
},
"DiffPodSnapshot": func(ctx context.Context, c *Client, host string) error {
_, err := c.DiffPodSnapshot(ctx, host, "pod", "tok")
return err
},
"RemovePodSnapshot": func(ctx context.Context, c *Client, host string) error {
return c.RemovePodSnapshot(ctx, host, "pod", "tok")
},
"SavePodSnapshot": func(ctx context.Context, c *Client, host string) error {
_, err := c.SavePodSnapshot(ctx, host, "pod", "tok", nil)
return err
},
"LoadPodSnapshot": func(ctx context.Context, c *Client, host string) error {
_, err := c.LoadPodSnapshot(ctx, host, "pod", "tok", "")
return err
},
"RegisterRemote": func(ctx context.Context, c *Client, host string) error {
return c.RegisterRemote(ctx, host, "remote", "s3://bucket/prefix")
},
"ListPodsRemote": func(ctx context.Context, c *Client, host string) error {
_, err := c.ListPodsRemote(ctx, host, "remote", nil, "tok", "")
return err
},
"SavePodRemote": func(ctx context.Context, c *Client, host string) error {
_, err := c.SavePodRemote(ctx, host, "pod", "remote", nil, "tok", nil)
return err
},
"LoadPodRemote": func(ctx context.Context, c *Client, host string) error {
_, err := c.LoadPodRemote(ctx, host, "pod", "remote", nil, "tok", "")
return err
},
}
}

// An unentitled emulator never registers the pods routes, so every one of them
// falls through to the router's bare 404 with no body.
func TestSnapshotEndpointsTranslateEmptyBody404(t *testing.T) {
t.Parallel()

for name, invoke := range snapshotOps() {
t.Run(name, func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()

err := invoke(context.Background(), NewClient(), srv.URL)
require.ErrorIs(t, err, snapshot.ErrSnapshotFeatureUnavailable)
})
}
}

// The discriminator must stay narrow: a 404 carrying a message is a real error
// from a route that does exist, not a licensing verdict.
func TestSnapshotEndpointsKeep404WithBodyGeneric(t *testing.T) {
t.Parallel()

for name, invoke := range snapshotOps() {
t.Run(name, func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("something specific went wrong"))
}))
defer srv.Close()

err := invoke(context.Background(), NewClient(), srv.URL)
require.Error(t, err)
assert.NotErrorIs(t, err, snapshot.ErrSnapshotFeatureUnavailable)
})
}
}

func TestEmulatorStatusError(t *testing.T) {
t.Parallel()

t.Run("omits the body segment when the body is empty", func(t *testing.T) {
t.Parallel()
err := emulatorStatusError("LocalStack returned status 404", nil)
require.Error(t, err)
assert.Equal(t, "LocalStack returned status 404", err.Error())
assert.NotContains(t, err.Error(), ": ")
})

t.Run("appends a non-empty body", func(t *testing.T) {
t.Parallel()
err := emulatorStatusError("pod save failed (HTTP 500)", []byte(" boom "))
require.Error(t, err)
assert.Equal(t, "pod save failed (HTTP 500): boom", err.Error())
})
}
Loading
Loading