From 26174d897e35748fa70c6eaf78a7f6fafde6a44b Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Fri, 31 Jul 2026 18:36:45 +0200 Subject: [PATCH 1/3] Show a helpful error when the plan does not include snapshots Closes DEVX-1009 Co-Authored-By: Claude --- internal/emulator/aws/client.go | 53 ++++++- internal/emulator/aws/client_test.go | 143 ++++++++++++++++- internal/emulator/aws/remote.go | 20 ++- internal/snapshot/CLAUDE.md | 13 ++ internal/snapshot/diff.go | 3 + internal/snapshot/feature_unavailable_test.go | 145 ++++++++++++++++++ internal/snapshot/load.go | 29 ++++ internal/snapshot/mock_remove_client_test.go | 55 +++++++ internal/snapshot/remote.go | 7 + internal/snapshot/remove.go | 5 + internal/snapshot/save.go | 9 +- test/integration/snapshot_load_test.go | 62 ++++++++ 12 files changed, 532 insertions(+), 12 deletions(-) create mode 100644 internal/snapshot/feature_unavailable_test.go create mode 100644 internal/snapshot/mock_remove_client_test.go diff --git a/internal/emulator/aws/client.go b/internal/emulator/aws/client.go index 2bcb9411..ba194b47 100644 --- a/internal/emulator/aws/client.go +++ b/internal/emulator/aws/client.go @@ -6,6 +6,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -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{ @@ -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 } @@ -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 { @@ -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) @@ -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 { @@ -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 } diff --git a/internal/emulator/aws/client_test.go b/internal/emulator/aws/client_test.go index 3c1b9a0a..dc57dd93 100644 --- a/internal/emulator/aws/client_test.go +++ b/internal/emulator/aws/client_test.go @@ -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) { @@ -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) { @@ -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") }) @@ -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()) + }) +} diff --git a/internal/emulator/aws/remote.go b/internal/emulator/aws/remote.go index bb3aa66a..6c248180 100644 --- a/internal/emulator/aws/remote.go +++ b/internal/emulator/aws/remote.go @@ -104,7 +104,10 @@ func (c *Client) RegisterRemote(ctx context.Context, baseURL, name, remoteURL st if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("register remote failed (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + if isFeatureUnavailableResponse(resp.StatusCode, body) { + return snapshot.ErrSnapshotFeatureUnavailable + } + return emulatorStatusError(fmt.Sprintf("register remote failed (HTTP %d)", resp.StatusCode), body) } return nil } @@ -154,7 +157,10 @@ func (c *Client) ListPodsRemote(ctx context.Context, baseURL, remoteName string, if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("list pods failed (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + if isFeatureUnavailableResponse(resp.StatusCode, respBody) { + return nil, snapshot.ErrSnapshotFeatureUnavailable + } + return nil, emulatorStatusError(fmt.Sprintf("list pods failed (HTTP %d)", resp.StatusCode), respBody) } var parsed struct { @@ -192,7 +198,10 @@ func (c *Client) doPodSave(ctx context.Context, baseURL, podName, authToken stri if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) - return snapshot.PodSaveResult{}, fmt.Errorf("pod save failed (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + if isFeatureUnavailableResponse(resp.StatusCode, respBody) { + return snapshot.PodSaveResult{}, snapshot.ErrSnapshotFeatureUnavailable + } + return snapshot.PodSaveResult{}, emulatorStatusError(fmt.Sprintf("pod save failed (HTTP %d)", resp.StatusCode), respBody) } // The response is a newline-delimited JSON stream. We scan until we find a @@ -259,7 +268,10 @@ func (c *Client) doPodLoad(ctx context.Context, baseURL, podName, authToken, str } if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("pod load failed (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + if isFeatureUnavailableResponse(resp.StatusCode, respBody) { + return nil, snapshot.ErrSnapshotFeatureUnavailable + } + return nil, emulatorStatusError(fmt.Sprintf("pod load failed (HTTP %d)", resp.StatusCode), respBody) } var services []string diff --git a/internal/snapshot/CLAUDE.md b/internal/snapshot/CLAUDE.md index 3c71bbce..56056b9a 100644 --- a/internal/snapshot/CLAUDE.md +++ b/internal/snapshot/CLAUDE.md @@ -11,6 +11,19 @@ A REF is parsed by helpers in `internal/snapshot/destination.go`: `ParseDestination` (save), `ParseSource` (load), `ParseRemovable` (remove), and `ParseShowable` (show) share pod-name validation; `ParseRemovable` and `ParseShowable` reject local paths (via the shared `parseCloudOnly` helper) so those cloud-only commands never touch local files. +## Plans without Cloud Pods (empty-body 404) + +Snapshots are a paid feature. On the emulator side, Cloud Pods is a licensed `plux` plugin (`localstack.platform.plugin/pods`); when the license doesn't grant it, the plugin raises `PluginDisabled` at init and **its routes are never registered**. Requests then hit the emulator's generic "no route matched under `/_localstack`" handler, which replies `404` with a **completely empty body** — there is no 402/403 and no message, because nothing per-request ever checks the license. lstk used to interpolate that empty body straight into its error string, producing the useless `LocalStack returned status 404: ` (DEVX-1009). + +`isFeatureUnavailableResponse` (`internal/emulator/aws/client.go`) detects it — `404` **and** an empty body — and every gated method returns `ErrSnapshotFeatureUnavailable` instead. The five rendering sites (`load()`, `save()`, `DiffPod`, `remove()`, `ListRemoteS3`) funnel it through `emitFeatureUnavailableError`, which owns the wording and the pricing CTA. `TestSnapshotEndpointsTranslateEmptyBody404` asserts the translation for all 11 gated client methods at once, so a newly added endpoint that forgets it fails there. + +Two things to keep in mind when touching this: +- **The discriminator must stay narrow.** A `404` *with* a body is a real error from a route that does exist, and must keep falling through to the generic message (`TestSnapshotEndpointsKeep404WithBodyGeneric` guards this). Widening it would misreport unrelated failures as billing problems. Note the sibling `GET /_localstack/pods/{name}/versions` endpoint — not currently used by lstk — *does* return a bare message-less 404 for a genuinely missing pod, so wrapping it would break the heuristic. +- **Detection is reactive, deliberately.** lstk caches the license document (`config.LicenseFilePath()`), but its `products[]` list is coarse and the pods product string can't be verified from either repo, so a local pre-check risks blocking *paying* customers. Don't add one without first confirming the real product name. Consequences: `lstk load` still auto-starts the emulator before failing, and `--merge=overwrite` (which trips the separate `localstack.platform.plugin/state-reset` plugin via `POST /_localstack/state/reset`) reports the same generic message rather than suggesting another merge strategy. +- **`ResetState` is shared with `lstk reset`.** Every other gated client method is called only from `internal/snapshot`, but `aws.Client.ResetState` is also used by `internal/reset`, which wraps the error and prints its text verbatim. That's why `ErrSnapshotFeatureUnavailable`'s message says "feature not available on this plan" and not "snapshot ..." — keep it feature-neutral, or `lstk reset` starts blaming snapshots. Giving `lstk reset` its own paid-plan message (with the pricing CTA) is a reasonable follow-up. + +`snapshot list` and `snapshot show` are unaffected — they query the platform API (`/v1/cloudpods*`), not the emulator. + ## Limiting saved services (`--services`) `lstk snapshot save [destination] --services s3,lambda` (shorthand `-s`) limits a save to a subset of the emulator's services; omitted or empty means every service, same as today. Applies uniformly to local files, `pod:` platform saves, and S3-remote pod saves. `validate.ServiceList` (`internal/validate/validate.go`) parses and validates the comma-separated value — syntax only (a regex over `[\w-]+` tokens), never against a known-service allow-list: lstk has no canonical service registry, and the platform itself silently drops unrecognized names rather than rejecting them (mirrors the legacy CLI's `is_comma_delimited_list`). diff --git a/internal/snapshot/diff.go b/internal/snapshot/diff.go index 521e0056..2e504d95 100644 --- a/internal/snapshot/diff.go +++ b/internal/snapshot/diff.go @@ -58,6 +58,9 @@ func DiffPod(ctx context.Context, rt runtime.Runtime, containers []config.Contai sink.Emit(output.SpinnerStart(fmt.Sprintf("Checking diff for pod %q...", podName))) result, err := differ.DiffPodSnapshot(ctx, host, podName, authToken) sink.Emit(output.SpinnerStop()) + if errors.Is(err, ErrSnapshotFeatureUnavailable) { + return emitFeatureUnavailableError(sink) + } if errors.Is(err, ErrPodNotFound) { sink.Emit(output.ErrorEvent{ Title: "Could not check pod diff", diff --git a/internal/snapshot/feature_unavailable_test.go b/internal/snapshot/feature_unavailable_test.go new file mode 100644 index 00000000..f3ac0adc --- /dev/null +++ b/internal/snapshot/feature_unavailable_test.go @@ -0,0 +1,145 @@ +package snapshot_test + +import ( + "context" + "fmt" + "testing" + + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" + "github.com/localstack/lstk/internal/snapshot" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// assertFeatureUnavailable checks the shared "requires a paid plan" rendering: +// a silent error (so the top-level handler doesn't re-print it) plus an +// ErrorEvent carrying the pricing CTA. +func assertFeatureUnavailable(t *testing.T, err error, events []output.Event) { + t.Helper() + require.Error(t, err) + require.ErrorIs(t, err, snapshot.ErrSnapshotFeatureUnavailable) + assert.True(t, output.IsSilent(err), "the feature-unavailable error should be silent so it isn't double-rendered") + + var errEvent *output.ErrorEvent + for _, e := range events { + if ev, ok := e.(output.ErrorEvent); ok { + errEvent = &ev + } + } + require.NotNil(t, errEvent, "a structured ErrorEvent should have been emitted") + assert.Equal(t, "Snapshots require a paid LocalStack plan", errEvent.Title) + assert.NotContains(t, errEvent.Title, "404", "the raw HTTP status must never reach the user") + + var values []string + for _, a := range errEvent.Actions { + values = append(values, a.Value) + } + assert.Contains(t, values, "https://www.localstack.cloud/pricing") +} + +func TestLoadLocal_FeatureUnavailable(t *testing.T) { + t.Parallel() + src := writeSnapshotFile(t, "ZIP_DATA") + // Wrapped, mirroring how SaveLocal/LoadRemoteS3 wrap client errors with %w. + client := mockLocalClientReturning(t, fmt.Errorf("import: %w", snapshot.ErrSnapshotFeatureUnavailable)) + sink, getEvents := captureEvents(t) + + err := snapshot.LoadLocal(context.Background(), healthyRunningMock(t), awsContainers, client, "", src, "", nopStarter, sink) + assertFeatureUnavailable(t, err, getEvents()) +} + +func TestLoadPod_FeatureUnavailable(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + loader := NewMockPodLoader(ctrl) + loader.EXPECT().LoadPodSnapshot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, snapshot.ErrSnapshotFeatureUnavailable) + sink, getEvents := captureEvents(t) + + err := snapshot.LoadPod(context.Background(), healthyRunningMock(t), awsContainers, loader, "", "my-baseline", "test-token", "", nopStarter, sink) + assertFeatureUnavailable(t, err, getEvents()) +} + +// save() translated no sentinels before this change, so this covers a brand-new +// branch rather than an extra case on an existing one. +func TestSaveLocal_FeatureUnavailable(t *testing.T) { + t.Parallel() + exporter := mockExporterReturningError(t, snapshot.ErrSnapshotFeatureUnavailable) + sink, getEvents := captureEvents(t) + + dest := writeSnapshotFile(t, "") + err := snapshot.SaveLocal(context.Background(), healthyRunningMock(t), awsContainers, exporter, "", dest, nil, sink) + assertFeatureUnavailable(t, err, getEvents()) +} + +func TestSavePod_FeatureUnavailable(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + saver := NewMockPodSaver(ctrl) + saver.EXPECT().SavePodSnapshot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(snapshot.PodSaveResult{}, snapshot.ErrSnapshotFeatureUnavailable) + sink, getEvents := captureEvents(t) + + err := snapshot.SavePod(context.Background(), healthyRunningMock(t), awsContainers, saver, "", "my-baseline", "test-token", nil, sink) + assertFeatureUnavailable(t, err, getEvents()) +} + +func TestDiffPod_FeatureUnavailable(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + differ := NewMockPodDiffer(ctrl) + differ.EXPECT().DiffPodSnapshot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, snapshot.ErrSnapshotFeatureUnavailable) + sink, getEvents := captureEvents(t) + + err := snapshot.DiffPod(context.Background(), healthyRunningMock(t), awsContainers, differ, "", "my-baseline", "test-token", "", sink) + assertFeatureUnavailable(t, err, getEvents()) +} + +func TestRemove_FeatureUnavailable(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + remover := NewMockPodRemover(ctrl) + remover.EXPECT().RemovePodSnapshot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(snapshot.ErrSnapshotFeatureUnavailable) + sink, getEvents := captureEvents(t) + + err := snapshot.Remove(context.Background(), healthyRunningMock(t), awsContainers, "my-baseline", "test-token", remover, "", true, sink) + assertFeatureUnavailable(t, err, getEvents()) +} + +func TestListRemoteS3_FeatureUnavailable(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + client := NewMockRemoteClient(ctrl) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsHealthy(gomock.Any()).Return(nil) + client.EXPECT().S3BucketExists(gomock.Any(), "bucket").Return(true, nil) + client.EXPECT().RegisterRemote(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + client.EXPECT().ListPodsRemote(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), ""). + Return(nil, snapshot.ErrSnapshotFeatureUnavailable) + sink, getEvents := captureEvents(t) + + err := snapshot.ListRemoteS3(context.Background(), mockRT, awsContainers, client, "", "s3://bucket", + snapshot.S3Credentials{AccessKeyID: "a", SecretAccessKey: "b"}, "", sink) + assertFeatureUnavailable(t, err, getEvents()) +} + +// The S3 remote paths register the remote before the pod call, so an unentitled +// emulator fails at RegisterRemote — which is wrapped with %w and must still be +// recognised. +func TestSaveRemoteS3_FeatureUnavailableOnRegisterRemote(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + client := NewMockRemoteClient(ctrl) + client.EXPECT().S3BucketExists(gomock.Any(), "bucket").Return(true, nil) + client.EXPECT().RegisterRemote(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(snapshot.ErrSnapshotFeatureUnavailable) + sink, getEvents := captureEvents(t) + + err := snapshot.SaveRemoteS3(context.Background(), healthyRunningMock(t), awsContainers, client, "", "my-pod", "s3://bucket", + snapshot.S3Credentials{AccessKeyID: "a", SecretAccessKey: "b"}, "", nil, sink) + assertFeatureUnavailable(t, err, getEvents()) +} diff --git a/internal/snapshot/load.go b/internal/snapshot/load.go index 202e563f..c9181b8a 100644 --- a/internal/snapshot/load.go +++ b/internal/snapshot/load.go @@ -28,6 +28,32 @@ var ErrIncompatibleSnapshot = errors.New("snapshot is incompatible with the runn // archive format from the user-facing message. var ErrInvalidSnapshotFile = errors.New("not a valid snapshot file") +// ErrSnapshotFeatureUnavailable indicates the running emulator's license does +// not include Cloud Pods, so its /_localstack/pods* routes were never +// registered. The emulator reports that as a bare 404 with an empty body (its +// generic unmatched-route reply), which the aws client translates into this +// sentinel — see isFeatureUnavailableResponse. +// +// The message deliberately says nothing about snapshots: aws.Client.ResetState +// is shared with `lstk reset`, which surfaces this error text directly, so +// naming snapshots here would mislabel a non-snapshot command. Snapshot-specific +// wording belongs in emitFeatureUnavailableError. +var ErrSnapshotFeatureUnavailable = errors.New("feature not available on this plan") + +// emitFeatureUnavailableError renders the shared "requires a paid plan" message +// and returns the silent error the top-level handler expects. Every snapshot +// operation funnels through here so the wording and CTAs live in one place. +func emitFeatureUnavailableError(sink output.Sink) error { + sink.Emit(output.ErrorEvent{ + Title: "Snapshots require a paid LocalStack plan", + Summary: "Your plan does not include the snapshot feature.", + Actions: []output.ErrorAction{ + {Label: "Compare plans:", Value: "https://www.localstack.cloud/pricing"}, + }, + }) + return output.NewSilentError(ErrSnapshotFeatureUnavailable) +} + func ValidateMergeStrategy(strategy string) error { switch strategy { case MergeStrategyAccountRegion, MergeStrategyOverwrite, MergeStrategyService: @@ -97,6 +123,9 @@ func load(ctx context.Context, rt runtime.Runtime, containers []config.Container }() err = do() + if errors.Is(err, ErrSnapshotFeatureUnavailable) { + return emitFeatureUnavailableError(sink) + } if errors.Is(err, ErrIncompatibleSnapshot) { sink.Emit(output.ErrorEvent{ Title: "Could not load snapshot", diff --git a/internal/snapshot/mock_remove_client_test.go b/internal/snapshot/mock_remove_client_test.go new file mode 100644 index 00000000..a4c13c9a --- /dev/null +++ b/internal/snapshot/mock_remove_client_test.go @@ -0,0 +1,55 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: remove.go +// +// Generated by this command: +// +// mockgen -source=remove.go -destination=mock_remove_client_test.go -package=snapshot_test +// + +// Package snapshot_test is a generated GoMock package. +package snapshot_test + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockPodRemover is a mock of PodRemover interface. +type MockPodRemover struct { + ctrl *gomock.Controller + recorder *MockPodRemoverMockRecorder + isgomock struct{} +} + +// MockPodRemoverMockRecorder is the mock recorder for MockPodRemover. +type MockPodRemoverMockRecorder struct { + mock *MockPodRemover +} + +// NewMockPodRemover creates a new mock instance. +func NewMockPodRemover(ctrl *gomock.Controller) *MockPodRemover { + mock := &MockPodRemover{ctrl: ctrl} + mock.recorder = &MockPodRemoverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPodRemover) EXPECT() *MockPodRemoverMockRecorder { + return m.recorder +} + +// RemovePodSnapshot mocks base method. +func (m *MockPodRemover) RemovePodSnapshot(ctx context.Context, host, podName, authToken string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemovePodSnapshot", ctx, host, podName, authToken) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemovePodSnapshot indicates an expected call of RemovePodSnapshot. +func (mr *MockPodRemoverMockRecorder) RemovePodSnapshot(ctx, host, podName, authToken any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePodSnapshot", reflect.TypeOf((*MockPodRemover)(nil).RemovePodSnapshot), ctx, host, podName, authToken) +} diff --git a/internal/snapshot/remote.go b/internal/snapshot/remote.go index eb9ab88a..ffb67fa0 100644 --- a/internal/snapshot/remote.go +++ b/internal/snapshot/remote.go @@ -6,6 +6,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "net" "net/url" @@ -212,11 +213,17 @@ func ListRemoteS3(ctx context.Context, rt runtime.Runtime, containers []config.C sink.Emit(output.SpinnerStart("Fetching snapshots")) if err := client.RegisterRemote(ctx, host, name, remoteURL); err != nil { sink.Emit(output.SpinnerStop()) + if errors.Is(err, ErrSnapshotFeatureUnavailable) { + return emitFeatureUnavailableError(sink) + } return fmt.Errorf("register S3 remote: %w", err) } pods, err := client.ListPodsRemote(ctx, host, name, creds.params(), authToken, "") sink.Emit(output.SpinnerStop()) if err != nil { + if errors.Is(err, ErrSnapshotFeatureUnavailable) { + return emitFeatureUnavailableError(sink) + } return fmt.Errorf("list snapshots on %s: %w", s3URL, err) } diff --git a/internal/snapshot/remove.go b/internal/snapshot/remove.go index dd29cd36..5e994d3d 100644 --- a/internal/snapshot/remove.go +++ b/internal/snapshot/remove.go @@ -1,3 +1,5 @@ +//go:generate mockgen -source=remove.go -destination=mock_remove_client_test.go -package=snapshot_test + package snapshot import ( @@ -79,6 +81,9 @@ func remove(ctx context.Context, podName, authToken string, remover PodRemover, } }() err := remover.RemovePodSnapshot(ctx, host, podName, authToken) + if errors.Is(err, ErrSnapshotFeatureUnavailable) { + return emitFeatureUnavailableError(sink) + } if errors.Is(err, ErrPodNotFound) { return fmt.Errorf("cloud pod %q not found", podName) } diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go index 08dd8fa4..5252b5b2 100644 --- a/internal/snapshot/save.go +++ b/internal/snapshot/save.go @@ -4,6 +4,7 @@ package snapshot import ( "context" + "errors" "fmt" "io" "os" @@ -65,7 +66,13 @@ func save(ctx context.Context, rt runtime.Runtime, containers []config.Container } }() - return do() + if err := do(); err != nil { + if errors.Is(err, ErrSnapshotFeatureUnavailable) { + return emitFeatureUnavailableError(sink) + } + return err + } + return nil } // SaveLocal saves the running emulator's state to a local file. services, when diff --git a/test/integration/snapshot_load_test.go b/test/integration/snapshot_load_test.go index 4f36aa79..c954fb28 100644 --- a/test/integration/snapshot_load_test.go +++ b/test/integration/snapshot_load_test.go @@ -553,3 +553,65 @@ func TestSnapshotLoadDryRunPodNotFound(t *testing.T) { assert.Contains(t, stdout, "not found on the LocalStack platform") assert.NotContains(t, strings.ToLower(stdout+stderr), "version information") } + +// mockUnlicensedPodsServer mimics an emulator whose license does not include +// Cloud Pods: the plugin never loads, so its routes are never registered and +// every request falls through to the router's bare 404 with an empty body. +func mockUnlicensedPodsServer(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + return srv +} + +// TestSnapshotLoadFeatureUnavailable reproduces DEVX-1009: on a plan without +// Cloud Pods, `lstk snapshot load ` used to fail with the raw, meaningless +// "LocalStack returned status 404: ". It must instead explain that snapshots need +// a paid plan and point at pricing. +func TestSnapshotLoadFeatureUnavailable(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv := mockUnlicensedPodsServer(t) + + dir := t.TempDir() + snapPath := writeTestSnapFile(t, dir, "snap.snapshot") + + stdout, stderr, err := runLstk(t, ctx, dir, + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), + "--non-interactive", "snapshot", "load", snapPath, + ) + requireExitCode(t, 1, err) + // The user-facing error is emitted through the sink (stdout). + assert.Contains(t, stdout, "Snapshots require a paid LocalStack plan") + assert.Contains(t, stdout, "https://www.localstack.cloud/pricing") + assert.NotContains(t, stdout+stderr, "status 404", "the raw HTTP status must not leak to the user") +} + +// The save path funnels through a different shared helper than load, so it needs +// its own coverage. +func TestSnapshotSaveFeatureUnavailable(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv := mockUnlicensedPodsServer(t) + + dir := t.TempDir() + + stdout, stderr, err := runLstk(t, ctx, dir, + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), + "--non-interactive", "snapshot", "save", filepath.Join(dir, "out.snapshot"), + ) + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "Snapshots require a paid LocalStack plan") + assert.Contains(t, stdout, "https://www.localstack.cloud/pricing") + assert.NotContains(t, stdout+stderr, "status 404") +} From 554fef44a65497f8a9d4c1b4532f6c2dd130a7ab Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Fri, 31 Jul 2026 19:28:54 +0200 Subject: [PATCH 2/3] Show the same paid-plan error for snapshot list and show The platform reports an unentitled plan as a 403 rather than the emulator's empty 404, so these two commands still leaked a raw status and JSON body. Co-Authored-By: Claude --- internal/api/client.go | 14 +++++++ internal/api/cloudpod_test.go | 38 +++++++++++++++++++ internal/snapshot/CLAUDE.md | 2 +- internal/snapshot/feature_unavailable_test.go | 34 +++++++++++++++++ internal/snapshot/list.go | 4 ++ internal/snapshot/show.go | 3 ++ 6 files changed, 94 insertions(+), 1 deletion(-) diff --git a/internal/api/client.go b/internal/api/client.go index 09597189..6a3b0501 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -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 { @@ -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))) @@ -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))) diff --git a/internal/api/cloudpod_test.go b/internal/api/cloudpod_test.go index 9777f1c7..da0050be 100644 --- a/internal/api/cloudpod_test.go +++ b/internal/api/cloudpod_test.go @@ -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) +} diff --git a/internal/snapshot/CLAUDE.md b/internal/snapshot/CLAUDE.md index 56056b9a..ce097151 100644 --- a/internal/snapshot/CLAUDE.md +++ b/internal/snapshot/CLAUDE.md @@ -22,7 +22,7 @@ Two things to keep in mind when touching this: - **Detection is reactive, deliberately.** lstk caches the license document (`config.LicenseFilePath()`), but its `products[]` list is coarse and the pods product string can't be verified from either repo, so a local pre-check risks blocking *paying* customers. Don't add one without first confirming the real product name. Consequences: `lstk load` still auto-starts the emulator before failing, and `--merge=overwrite` (which trips the separate `localstack.platform.plugin/state-reset` plugin via `POST /_localstack/state/reset`) reports the same generic message rather than suggesting another merge strategy. - **`ResetState` is shared with `lstk reset`.** Every other gated client method is called only from `internal/snapshot`, but `aws.Client.ResetState` is also used by `internal/reset`, which wraps the error and prints its text verbatim. That's why `ErrSnapshotFeatureUnavailable`'s message says "feature not available on this plan" and not "snapshot ..." — keep it feature-neutral, or `lstk reset` starts blaming snapshots. Giving `lstk reset` its own paid-plan message (with the pricing CTA) is a reasonable follow-up. -`snapshot list` and `snapshot show` are unaffected — they query the platform API (`/v1/cloudpods*`), not the emulator. +`snapshot list` and `snapshot show` reach the same conclusion from a different signal: they query the platform API (`/v1/cloudpods*`), not the emulator, and it answers an unentitled plan with `403 {"error": true, "message": "generic.forbidden"}`. `ListCloudPods`/`GetCloudPod` map that to `api.ErrCloudPodsForbidden`, which both commands render through the same `emitFeatureUnavailableError`. Only `403` maps — a rejected token is a `401` and stays a generic error, so a re-login problem is never reported as a billing problem. ## Limiting saved services (`--services`) diff --git a/internal/snapshot/feature_unavailable_test.go b/internal/snapshot/feature_unavailable_test.go index f3ac0adc..856c3475 100644 --- a/internal/snapshot/feature_unavailable_test.go +++ b/internal/snapshot/feature_unavailable_test.go @@ -5,6 +5,8 @@ import ( "fmt" "testing" + "github.com/localstack/lstk/internal/api" + "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/runtime" "github.com/localstack/lstk/internal/snapshot" @@ -143,3 +145,35 @@ func TestSaveRemoteS3_FeatureUnavailableOnRegisterRemote(t *testing.T) { snapshot.S3Credentials{AccessKeyID: "a", SecretAccessKey: "b"}, "", nil, sink) assertFeatureUnavailable(t, err, getEvents()) } + +// stubLister/stubInspector stand in for the platform client on the list/show +// paths, which take a plain interface rather than a generated mock. +type stubLister struct{ err error } + +func (s stubLister) ListCloudPods(context.Context, string, string) ([]api.CloudPod, error) { + return nil, s.err +} + +type stubInspector struct{ err error } + +func (s stubInspector) GetCloudPod(context.Context, string, string) (*api.CloudPodDetails, error) { + return nil, s.err +} + +// list/show query the platform, which reports a plan without Cloud Pods as a +// 403 rather than the emulator's empty 404 — same message, different signal. +func TestList_FeatureUnavailable(t *testing.T) { + t.Parallel() + sink, getEvents := captureEvents(t) + + err := snapshot.List(context.Background(), stubLister{err: api.ErrCloudPodsForbidden}, "test-token", "me", sink) + assertFeatureUnavailable(t, err, getEvents()) +} + +func TestShow_FeatureUnavailable(t *testing.T) { + t.Parallel() + sink, getEvents := captureEvents(t) + + err := snapshot.Show(context.Background(), stubInspector{err: api.ErrCloudPodsForbidden}, "test-token", "my-baseline", sink) + assertFeatureUnavailable(t, err, getEvents()) +} diff --git a/internal/snapshot/list.go b/internal/snapshot/list.go index 8486b39b..f42fac5e 100644 --- a/internal/snapshot/list.go +++ b/internal/snapshot/list.go @@ -2,6 +2,7 @@ package snapshot import ( "context" + "errors" "fmt" "github.com/localstack/lstk/internal/api" @@ -27,6 +28,9 @@ func List(ctx context.Context, lister CloudPodLister, authToken, creator string, sink.Emit(output.SpinnerStart("Fetching snapshots")) pods, err := lister.ListCloudPods(ctx, authToken, creator) sink.Emit(output.SpinnerStop()) + if errors.Is(err, api.ErrCloudPodsForbidden) { + return emitFeatureUnavailableError(sink) + } if err != nil { return fmt.Errorf("list snapshots: %w", err) } diff --git a/internal/snapshot/show.go b/internal/snapshot/show.go index 6774eae2..9de2eca5 100644 --- a/internal/snapshot/show.go +++ b/internal/snapshot/show.go @@ -31,6 +31,9 @@ func Show(ctx context.Context, inspector CloudPodInspector, authToken, podName s details, err := inspector.GetCloudPod(ctx, authToken, podName) sink.Emit(output.SpinnerStop()) if err != nil { + if errors.Is(err, api.ErrCloudPodsForbidden) { + return emitFeatureUnavailableError(sink) + } if errors.Is(err, api.ErrCloudPodNotFound) { sink.Emit(output.ErrorEvent{ Title: fmt.Sprintf("Snapshot 'pod:%s' not found", podName), From 84d0476ef71ef0fa5fa7efc78e3dbed829f60e49 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Mon, 3 Aug 2026 16:24:11 +0200 Subject: [PATCH 3/3] Rephrase comment and correct terminology --- internal/snapshot/CLAUDE.md | 4 ++-- internal/snapshot/load.go | 17 ++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/internal/snapshot/CLAUDE.md b/internal/snapshot/CLAUDE.md index ce097151..f401a61e 100644 --- a/internal/snapshot/CLAUDE.md +++ b/internal/snapshot/CLAUDE.md @@ -11,9 +11,9 @@ A REF is parsed by helpers in `internal/snapshot/destination.go`: `ParseDestination` (save), `ParseSource` (load), `ParseRemovable` (remove), and `ParseShowable` (show) share pod-name validation; `ParseRemovable` and `ParseShowable` reject local paths (via the shared `parseCloudOnly` helper) so those cloud-only commands never touch local files. -## Plans without Cloud Pods (empty-body 404) +## Plans without the snapshot entitlement (empty-body 404) -Snapshots are a paid feature. On the emulator side, Cloud Pods is a licensed `plux` plugin (`localstack.platform.plugin/pods`); when the license doesn't grant it, the plugin raises `PluginDisabled` at init and **its routes are never registered**. Requests then hit the emulator's generic "no route matched under `/_localstack`" handler, which replies `404` with a **completely empty body** — there is no 402/403 and no message, because nothing per-request ever checks the license. lstk used to interpolate that empty body straight into its error string, producing the useless `LocalStack returned status 404: ` (DEVX-1009). +Snapshots are a paid feature — local file, S3 remote, and platform pod destinations alike, not just data actually stored on LocalStack's platform. On the emulator side, the entitlement is a licensed `plux` plugin (`localstack.platform.plugin/pods`, branded "Cloud Pods" on the license); when the license doesn't grant it, the plugin raises `PluginDisabled` at init and **its routes are never registered**. Requests then hit the emulator's generic "no route matched under `/_localstack`" handler, which replies `404` with a **completely empty body** — there is no 402/403 and no message, because nothing per-request ever checks the license. lstk used to interpolate that empty body straight into its error string, producing the useless `LocalStack returned status 404: ` (DEVX-1009). `isFeatureUnavailableResponse` (`internal/emulator/aws/client.go`) detects it — `404` **and** an empty body — and every gated method returns `ErrSnapshotFeatureUnavailable` instead. The five rendering sites (`load()`, `save()`, `DiffPod`, `remove()`, `ListRemoteS3`) funnel it through `emitFeatureUnavailableError`, which owns the wording and the pricing CTA. `TestSnapshotEndpointsTranslateEmptyBody404` asserts the translation for all 11 gated client methods at once, so a newly added endpoint that forgets it fails there. diff --git a/internal/snapshot/load.go b/internal/snapshot/load.go index c9181b8a..19118b30 100644 --- a/internal/snapshot/load.go +++ b/internal/snapshot/load.go @@ -28,16 +28,15 @@ var ErrIncompatibleSnapshot = errors.New("snapshot is incompatible with the runn // archive format from the user-facing message. var ErrInvalidSnapshotFile = errors.New("not a valid snapshot file") -// ErrSnapshotFeatureUnavailable indicates the running emulator's license does -// not include Cloud Pods, so its /_localstack/pods* routes were never -// registered. The emulator reports that as a bare 404 with an empty body (its -// generic unmatched-route reply), which the aws client translates into this -// sentinel — see isFeatureUnavailableResponse. +// ErrSnapshotFeatureUnavailable indicates the emulator's license lacks the +// paid entitlement for snapshots (branded "Cloud Pods", but required for +// local-file and S3-remote saves too, not just platform pods). Its +// /_localstack/pods* routes are then never registered, so the emulator +// replies with a bare, empty-body 404 — see isFeatureUnavailableResponse. // -// The message deliberately says nothing about snapshots: aws.Client.ResetState -// is shared with `lstk reset`, which surfaces this error text directly, so -// naming snapshots here would mislabel a non-snapshot command. Snapshot-specific -// wording belongs in emitFeatureUnavailableError. +// Kept feature-neutral since aws.Client.ResetState is shared with `lstk +// reset`, which surfaces this text directly; snapshot-specific wording lives +// in emitFeatureUnavailableError instead. var ErrSnapshotFeatureUnavailable = errors.New("feature not available on this plan") // emitFeatureUnavailableError renders the shared "requires a paid plan" message