Skip to content
Open
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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,13 @@ When lstk's stdout and stderr are both terminals, `lstk aws` runs the child via

- `lstk snapshot save [destination]` (`-s`/`--services`: comma-separated list to limit a save to a subset of services; applies uniformly to local files, `pod:` cloud snapshots, and S3-remote saves) / `lstk snapshot load REF` (`--merge`: `account-region-merge` default, `overwrite`, `service-merge`) / `list` (cloud; `--all` for org-wide) / `remove REF` / `show REF` / `versions REF`.
- `show`, `versions`, and bare `list` (no `s3://` arg) only ever call the LocalStack platform API — never the emulator. `remove`, by contrast, proxies its delete through the running emulator despite being a "cloud" (`pod:`) concept, so it still requires one to be reachable; don't conflate "operates on cloud-hosted storage" with "never touches the emulator" — they're different axes.
- A REF is a local `.snapshot` file, a `pod:` cloud snapshot on the LocalStack platform (requires auth), or an `s3://bucket/prefix` remote in the user's own bucket (the emulator performs the transfer; S3 supports save/load/list only).
- A REF is a local `.snapshot` file, a `pod:` cloud snapshot on the LocalStack platform (requires an identity), or an `s3://bucket/prefix` remote in the user's own bucket (the emulator performs the transfer; S3 supports save/load/list only).
- Every save to an existing `pod:` snapshot creates a new **version**. `versions REF` lists them; `load` and `show` accept a `pod:<name>:<version>` REF (latest when omitted). `save`, `remove`, `versions`, and S3 remotes reject a version suffix rather than ignore it.
- A `[[containers]]` block (AWS only) can set `snapshot = "pod:..."` to auto-load after a fresh start; `lstk start --snapshot REF` overrides it for one run, `--no-snapshot` skips it.
- Emulator-backed pod operations against an lstk-managed local emulator do not pre-check for a token: with none supplied they send no auth header, so the running emulator reuses the identity it was started with. Externally-managed targets (`--endpoint-url` and its environment-variable equivalents) and platform-direct commands (`list`, `show`, `versions`) still need a token from the environment or keychain.
- `save`/`load`/`remove` and `list s3://...` support the global `--endpoint-url` targeting described under "Targeting an External Emulator"; `show`, `versions`, and bare `list` silently ignore it (they never touch the emulator regardless).

REF parsing helpers, S3 credential precedence and remote-upsert mechanics, and the auto-load wiring are documented in `internal/snapshot/CLAUDE.md`.
REF parsing helpers, the authentication rules, S3 credential precedence and remote-upsert mechanics, and the auto-load wiring are documented in `internal/snapshot/CLAUDE.md`.

# NPM Distribution

Expand Down
34 changes: 30 additions & 4 deletions cmd/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,11 @@ func runSnapshotLoad(cfg *env.Env, tel *telemetry.Client, logger log.Logger) fun
if err != nil {
return err
}
if src.Kind == snapshot.KindPod {
if err := requireExternalPodAuth(external, cfg.AuthToken); err != nil {
return err
}
}

var starter snapshot.Starter
if !external {
Expand Down Expand Up @@ -414,27 +419,36 @@ func runSnapshotRemove(cfg *env.Env) func(*cobra.Command, []string) error {
if !force {
return fmt.Errorf("snapshot remove requires confirmation; use --force to skip in non-interactive mode")
}
rt, client, host, containers, _, _, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg)
rt, client, host, containers, _, external, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg)
if err != nil {
return err
}
if err := requireExternalPodAuth(external, cfg.AuthToken); err != nil {
return err
}
sink := output.NewPlainSink(os.Stdout)
return snapshot.Remove(cmd.Context(), rt, containers, ref.Value, cfg.AuthToken, client, host, force, sink)
}

rt, client, host, containers, _, _, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg)
rt, client, host, containers, _, external, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg)
if err != nil {
return err
}
if err := requireExternalPodAuth(external, cfg.AuthToken); err != nil {
return err
}
return ui.RunSnapshotRemove(cmd.Context(), rt, containers, client, host, args[0], cwd, home, cfg.AuthToken, force)
}
}

func execDiff(cmd *cobra.Command, cfg *env.Env, podName string, version int, strategy string) error {
rt, client, host, containers, _, _, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg)
rt, client, host, containers, _, external, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg)
if err != nil {
return err
}
if err := requireExternalPodAuth(external, cfg.AuthToken); err != nil {
return err
}

if isInteractiveMode(cfg) {
return ui.RunSnapshotDiff(cmd.Context(), rt, containers, client, host, podName, version, cfg.AuthToken, strategy)
Expand All @@ -443,6 +457,13 @@ func execDiff(cmd *cobra.Command, cfg *env.Env, podName string, version int, str
return snapshot.DiffPod(cmd.Context(), rt, containers, client, host, podName, version, cfg.AuthToken, strategy, sink)
}

func requireExternalPodAuth(external bool, authToken string) error {
if external && authToken == "" {
return fmt.Errorf("authentication is required for cloud snapshot operations against an externally-managed emulator — set LOCALSTACK_AUTH_TOKEN or run %q", "lstk login")
}
return nil
}

// resolveSnapshotDeps resolves the runtime, host, and target container(s) for
// a snapshot subcommand that contacts the emulator (save/load/remove/list
// s3://...). When an endpoint URL is resolved (--endpoint-url/
Expand Down Expand Up @@ -786,10 +807,15 @@ func runSnapshotSave(cfg *env.Env) func(*cobra.Command, []string) error {
return err
}

rt, client, host, containers, _, _, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg)
rt, client, host, containers, _, external, err := resolveSnapshotDeps(cmd.Context(), cmd, cfg)
if err != nil {
return err
}
if dest.Kind == snapshot.KindPod {
if err := requireExternalPodAuth(external, cfg.AuthToken); err != nil {
return err
}
}

if isInteractiveMode(cfg) {
return ui.RunSnapshotSave(cmd.Context(), rt, containers, client, host, dest, cfg.AuthToken, services)
Expand Down
16 changes: 16 additions & 0 deletions cmd/snapshot_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package cmd

import (
"testing"

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

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

require.NoError(t, requireExternalPodAuth(false, ""), "a locally managed emulator may reuse its startup identity")
require.NoError(t, requireExternalPodAuth(true, "test-token"), "an external emulator accepts explicit caller authentication")
assert.ErrorContains(t, requireExternalPodAuth(true, ""), "authentication is required for cloud snapshot operations against an externally-managed emulator")
}
19 changes: 15 additions & 4 deletions internal/emulator/aws/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -351,7 +350,7 @@ func (c *Client) DiffPodSnapshot(ctx context.Context, baseURL, podName string, v
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(":"+authToken)))
setBasicAuth(req, authToken)

resp, err := c.http.Do(req)
if err != nil {
Expand All @@ -365,12 +364,18 @@ func (c *Client) DiffPodSnapshot(ctx context.Context, baseURL, podName string, v
if isPodVersionNotFoundMsg(bodyStr) {
return nil, fmt.Errorf("%w: %s", snapshot.ErrPodVersionNotFound, bodyStr)
}
// Not-found is classified from the body first: the platform answers 403
// for a pod the identity cannot see, which is a missing pod rather than a
// missing identity.
if isPodNotFoundMsg(bodyStr) {
return nil, fmt.Errorf("%w: %s", snapshot.ErrPodNotFound, bodyStr)
}
if isFeatureUnavailableResponse(resp.StatusCode, body) {
return nil, snapshot.ErrSnapshotFeatureUnavailable
}
if authRejected(resp.StatusCode) {
return nil, fmt.Errorf("%w: %s", snapshot.ErrAuthRequired, bodyStr)
}
return nil, emulatorStatusError(fmt.Sprintf("diff failed (HTTP %d)", resp.StatusCode), body)
}

Expand All @@ -390,7 +395,7 @@ func (c *Client) DiffPodSnapshot(ctx context.Context, baseURL, podName string, v
counts.Additions++
case "MODIFICATION":
counts.Modifications++
// DELETION is intentionally omitted: the diff endpoint does not currently return deletions.
// DELETION is intentionally omitted: the diff endpoint does not currently return deletions.
}
}
result[svc] = counts
Expand All @@ -415,7 +420,7 @@ func (c *Client) RemovePodSnapshot(ctx context.Context, baseURL, podName, authTo
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(":"+authToken)))
setBasicAuth(req, authToken)

resp, err := c.http.Do(req)
if err != nil {
Expand All @@ -426,12 +431,18 @@ func (c *Client) RemovePodSnapshot(ctx context.Context, baseURL, podName, authTo
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
bodyStr := strings.TrimSpace(string(body))
// Not-found is classified from the body first: the platform answers 403
// for a pod the identity cannot see, which is a missing pod rather than a
// missing identity.
if strings.Contains(strings.ToLower(bodyStr), "not found") {
return fmt.Errorf("%w: %s", snapshot.ErrPodNotFound, bodyStr)
}
if isFeatureUnavailableResponse(resp.StatusCode, body) {
return snapshot.ErrSnapshotFeatureUnavailable
}
if authRejected(resp.StatusCode) {
return fmt.Errorf("%w: %s", snapshot.ErrAuthRequired, bodyStr)
}
return emulatorStatusError(fmt.Sprintf("pod remove failed (HTTP %d)", resp.StatusCode), body)
}
return nil
Expand Down
91 changes: 91 additions & 0 deletions internal/emulator/aws/pod_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package aws

import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/localstack/lstk/internal/snapshot"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type podCall struct {
name string
call func(c *Client, host string) error
}

// podCalls returns the emulator-backed pod operations that authenticate with the
// caller's token when there is one and fall back to the emulator's own identity
// when there is not. Every operation below is called without a token.
func podCalls() []podCall {
return []podCall{
{"save", func(c *Client, host string) error {
_, err := c.SavePodSnapshot(context.Background(), host, "my-pod", "", nil)
return err
}},
{"load", func(c *Client, host string) error {
_, err := c.LoadPodSnapshot(context.Background(), host, "my-pod", 0, "", "")
return err
}},
{"diff", func(c *Client, host string) error {
_, err := c.DiffPodSnapshot(context.Background(), host, "my-pod", 0, "")
return err
}},
{"remove", func(c *Client, host string) error {
return c.RemovePodSnapshot(context.Background(), host, "my-pod", "")
}},
}
}

// An omitted token must leave the Authorization header off the request entirely,
// so the emulator falls back to the identity it was started with instead of
// receiving empty credentials.
func TestPodRequests_OmitAuthorizationHeaderWithoutToken(t *testing.T) {
t.Parallel()
for _, tc := range podCalls() {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var hasAuthHeader bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, hasAuthHeader = r.Header["Authorization"]
if strings.HasSuffix(r.URL.Path, "/diff") {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`))
return
}
w.Header().Set("Content-Type", "application/x-ndjson")
_, _ = w.Write([]byte(`{"event":"completion","status":"ok","info":{"version":1}}` + "\n"))
}))
defer server.Close()

require.NoError(t, tc.call(NewClient(), server.URL))
assert.False(t, hasAuthHeader, "Authorization header should be omitted when no token is supplied")
})
}
}

// A rejection for lack of a usable identity must surface as ErrAuthRequired so
// the snapshot layer can render an actionable message.
func TestPodRequests_MapUnauthorizedToErrAuthRequired(t *testing.T) {
t.Parallel()
for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} {
for _, tc := range podCalls() {
t.Run(http.StatusText(status)+"/"+tc.name, func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte("no credentials configured"))
}))
defer server.Close()

err := tc.call(NewClient(), server.URL)
require.Error(t, err)
assert.True(t, errors.Is(err, snapshot.ErrAuthRequired), "got %v", err)
})
}
}
}
29 changes: 26 additions & 3 deletions internal/emulator/aws/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,25 @@ func marshalPodBody(remoteName string, params map[string]string, services []stri
}

// setBasicAuth sets the LocalStack Basic auth header when a token is present.
// S3 remotes do not require a platform token, so it is optional.
// It is optional: S3 remotes do not require a platform token, and for
// platform-hosted pods on a locally managed emulator an omitted header makes
// the emulator fall back to the identity it was started with.
func setBasicAuth(req *http.Request, authToken string) {
if authToken == "" {
return
}
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(":"+authToken)))
}

// authRejected reports whether status is the emulator rejecting a pod operation
// for lack of a usable identity: 401 for no or invalid credentials, 403 for
// credentials that exist but don't grant access. Callers wrap the response body
// in snapshot.ErrAuthRequired so the domain layer can render an actionable
// message with the emulator's own explanation.
func authRejected(status int) bool {
return status == http.StatusUnauthorized || status == http.StatusForbidden
}

// S3BucketExists reports whether an S3 bucket exists, via an unsigned HEAD to the
// S3 endpoint: a 404 means the bucket does not exist; any other status (200, 403,
// or a redirect for a bucket in another region) means it does. This lets lstk
Expand Down Expand Up @@ -163,6 +174,10 @@ func (c *Client) ListPodsRemote(ctx context.Context, baseURL, remoteName string,
if isFeatureUnavailableResponse(resp.StatusCode, respBody) {
return nil, snapshot.ErrSnapshotFeatureUnavailable
}
msg := strings.TrimSpace(string(respBody))
if authRejected(resp.StatusCode) {
return nil, fmt.Errorf("%w: %s", snapshot.ErrAuthRequired, msg)
}
return nil, emulatorStatusError(fmt.Sprintf("list pods failed (HTTP %d)", resp.StatusCode), respBody)
}

Expand Down Expand Up @@ -204,6 +219,10 @@ func (c *Client) doPodSave(ctx context.Context, baseURL, podName, authToken stri
if isFeatureUnavailableResponse(resp.StatusCode, respBody) {
return snapshot.PodSaveResult{}, snapshot.ErrSnapshotFeatureUnavailable
}
msg := strings.TrimSpace(string(respBody))
if authRejected(resp.StatusCode) {
return snapshot.PodSaveResult{}, fmt.Errorf("%w: %s", snapshot.ErrAuthRequired, msg)
}
return snapshot.PodSaveResult{}, emulatorStatusError(fmt.Sprintf("pod save failed (HTTP %d)", resp.StatusCode), respBody)
}

Expand Down Expand Up @@ -283,8 +302,12 @@ func (c *Client) doPodLoad(ctx context.Context, baseURL, podName string, version
if isFeatureUnavailableResponse(resp.StatusCode, respBody) {
return nil, snapshot.ErrSnapshotFeatureUnavailable
}
if bodyStr := strings.TrimSpace(string(respBody)); isPodVersionNotFoundMsg(bodyStr) {
return nil, fmt.Errorf("%w: %s", snapshot.ErrPodVersionNotFound, bodyStr)
msg := strings.TrimSpace(string(respBody))
if isPodVersionNotFoundMsg(msg) {
return nil, fmt.Errorf("%w: %s", snapshot.ErrPodVersionNotFound, msg)
}
if authRejected(resp.StatusCode) {
return nil, fmt.Errorf("%w: %s", snapshot.ErrAuthRequired, msg)
}
return nil, emulatorStatusError(fmt.Sprintf("pod load failed (HTTP %d)", resp.StatusCode), respBody)
}
Expand Down
Loading
Loading