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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,13 @@ Environment variables:

`lstk snapshot` captures and restores the running emulator's state (AWS emulator only). Domain logic lives in `internal/snapshot/`; `cmd/snapshot.go` is wiring + output-mode selection.

- `lstk snapshot save [destination]` — export state to a local `.zip` or a named cloud snapshot.
- `lstk snapshot save [destination]` — export state to a local `.snapshot` file or a named cloud snapshot.
- `lstk snapshot load REF` — restore state, starting the emulator first if needed; `--merge` controls how snapshot state combines with running state (`account-region-merge` (default), `overwrite`, `service-merge`).
- `lstk snapshot list` — list cloud snapshots on the LocalStack platform. Lists only snapshots you created by default; pass `--all` to include every snapshot in your organization. Cloud-only; requires auth.
- `lstk snapshot remove REF` — delete a cloud snapshot. Cloud-only; local files are never deleted by the CLI. Prompts for confirmation in interactive mode; `--force` is required to skip the prompt in non-interactive mode.

A REF is parsed by helpers in `internal/snapshot/destination.go`:
- **local file** — absolute/relative path; `.zip` is appended if omitted.
- **local file** — absolute/relative path; the `.snapshot` extension is forced (any other extension is replaced). On load, `.zip` files saved by older lstk versions are still accepted.
- **cloud snapshot** — `pod:` prefix (e.g. `pod:my-baseline`), stored on the LocalStack platform. Requires auth (`LOCALSTACK_AUTH_TOKEN` or `lstk login`).

`ParseDestination` (save), `ParseSource` (load), and `ParseRemovable` (remove) share pod-name validation; `ParseRemovable` rejects local paths so the CLI cannot delete local files.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ lstk setup azure
lstk az group list

# Save emulator state to a local file
lstk snapshot save ./my-snapshot.zip
lstk snapshot save ./my-snapshot.snapshot

# Save emulator state as a named cloud snapshot on the LocalStack platform
lstk snapshot save pod:my-baseline
Expand Down Expand Up @@ -269,12 +269,12 @@ Snapshots capture the running emulator's state so you can restore it later.

A snapshot reference is either a **local file** or a **cloud snapshot**:

- **Local file** — an absolute or relative path. A `.zip` extension is added if omitted.
- **Local file** — an absolute or relative path. A `.snapshot` extension is added if omitted (snapshots saved as `.zip` by older lstk versions still load).
- **Cloud snapshot** — a name with the `pod:` prefix (e.g. `pod:my-baseline`), stored on the LocalStack platform. Requires authentication (`LOCALSTACK_AUTH_TOKEN` or `lstk login`).

```bash
# Save (local or cloud)
lstk snapshot save ./my-snapshot.zip
lstk snapshot save ./my-snapshot.snapshot
lstk snapshot save pod:my-baseline

# Load (starts the emulator first if needed)
Expand Down
4 changes: 2 additions & 2 deletions cmd/help_wrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ func TestWrapLine(t *testing.T) {
},
{
name: "indented example is left untouched even when over width",
line: " lstk snapshot save # saves to ./snapshot.zip",
line: " lstk snapshot save # saves to ./snapshot.snapshot",
width: 20,
want: " lstk snapshot save # saves to ./snapshot.zip",
want: " lstk snapshot save # saves to ./snapshot.snapshot",
},
{
name: "tab-indented line is left untouched even when over width",
Expand Down
12 changes: 6 additions & 6 deletions cmd/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ const snapshotSaveLong = `Save a snapshot of the running emulator's state.

Pass [destination] as an absolute or relative path for the exported file:

lstk snapshot save # saves to ./snapshot-<YYYY-MM-DDTHH-mm-ss>-<hex>.zip
lstk snapshot save ./my-snapshot.zip # saves to ./my-snapshot.zip
lstk snapshot save /tmp/my-state # saves to /tmp/my-state.zip
lstk snapshot save # saves to ./snapshot-<YYYY-MM-DDTHH-mm-ss>-<hex>.snapshot
lstk snapshot save ./my-snapshot.snapshot # saves to ./my-snapshot.snapshot
lstk snapshot save /tmp/my-state # saves to /tmp/my-state.snapshot

To save to a remote pod on the LocalStack platform, use the pod: prefix:

Expand All @@ -55,9 +55,9 @@ const snapshotLoadLong = `Load a snapshot into the running emulator, starting it

REF identifies the snapshot to load:

lstk snapshot load my-baseline # loads ./my-baseline or ./my-baseline.zip
lstk snapshot load ./checkpoint.zip # loads from explicit path
lstk snapshot load pod:my-baseline # loads from LocalStack Cloud
lstk snapshot load my-baseline # loads ./my-baseline or ./my-baseline.snapshot

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

question: Couldn't find a better line to add this comment, but loading an invalid file returns an error. Could we update that and possibly remove the file extension reference?

Image

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch, will change 🙏🏼

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

fyi, this error was returned from emulator in this line, we only prefix it with load failed for service:.
I modified it to look like this. let me know if it needs more tweaking. thank you!
image

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Perfect, this matches the error pattern and style we introduced for other cases, too. 💯

Comment thread
gtsiolis marked this conversation as resolved.
lstk snapshot load ./checkpoint.snapshot # loads from explicit path
lstk snapshot load pod:my-baseline # loads from LocalStack Cloud

Merge strategies control how snapshot state is combined with running state:

Expand Down
18 changes: 18 additions & 0 deletions internal/emulator/aws/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,12 +221,24 @@ func (c *Client) ImportState(ctx context.Context, host string, src io.Reader, st
continue
}
if event.Status == "error" && event.Message != "" {
if isInvalidSnapshotFileMsg(event.Message) {
return snapshot.ErrInvalidSnapshotFile
}
return fmt.Errorf("load failed for service %s: %s", event.Service, event.Message)
}
}
return scanner.Err()
}

// isInvalidSnapshotFileMsg reports whether an emulator error message indicates
// the source could not be read as a snapshot archive. We translate these into
// snapshot.ErrInvalidSnapshotFile so the user-facing message never leaks the
// underlying archive format.
func isInvalidSnapshotFileMsg(msg string) bool {
m := strings.ToLower(msg)
return strings.Contains(m, "not a valid zip archive") || strings.Contains(m, "invalid pod file")
}

func (c *Client) LoadPodSnapshot(ctx context.Context, host, podName, authToken, strategy string) ([]string, error) {
url := fmt.Sprintf("http://%s/_localstack/pods/%s", host, podName)
if strategy != "" {
Expand Down Expand Up @@ -278,10 +290,16 @@ func (c *Client) LoadPodSnapshot(ctx context.Context, host, podName, authToken,
case "ok":
services = append(services, event.Service)
case "error":
if isInvalidSnapshotFileMsg(event.Message) {
return nil, snapshot.ErrInvalidSnapshotFile
}
return nil, fmt.Errorf("load failed for service %s: %s", event.Service, event.Message)
}
case "completion":
if event.Status != "ok" {
if isInvalidSnapshotFileMsg(event.Message) {
return nil, snapshot.ErrInvalidSnapshotFile
}
return nil, fmt.Errorf("pod load failed: %s", event.Message)
}
return services, nil
Expand Down
2 changes: 1 addition & 1 deletion internal/output/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ type DeferredEvent struct {
}

type SnapshotLoadedEvent struct {
Source string // display source shown to the user (e.g. "./snap.zip" or "pod:my-baseline")
Source string // display source shown to the user (e.g. "./snap.snapshot" or "pod:my-baseline")
Services []string // services restored
}

Expand Down
8 changes: 4 additions & 4 deletions internal/output/plain_format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,14 +214,14 @@ func TestFormatEventLine(t *testing.T) {
// snapshot load events
{
name: "snapshot loaded with services",
event: SnapshotLoadedEvent{Source: "./my-baseline.zip", Services: []string{"s3", "dynamodb"}},
want: SuccessMarker() + " Snapshot loaded from ./my-baseline.zip\n• Services: s3, dynamodb",
event: SnapshotLoadedEvent{Source: "./my-baseline.snapshot", Services: []string{"s3", "dynamodb"}},
want: SuccessMarker() + " Snapshot loaded from ./my-baseline.snapshot\n• Services: s3, dynamodb",
wantOK: true,
},
{
name: "snapshot loaded no services",
event: SnapshotLoadedEvent{Source: "./snap.zip"},
want: SuccessMarker() + " Snapshot loaded from ./snap.zip",
event: SnapshotLoadedEvent{Source: "./snap.snapshot"},
want: SuccessMarker() + " Snapshot loaded from ./snap.snapshot",
wantOK: true,
},
{
Expand Down
49 changes: 31 additions & 18 deletions internal/snapshot/destination.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ var (
validPodName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]*$`)
)

const (
snapshotExt = ".snapshot" // user-facing extension for local snapshots
legacySnapshotExt = ".zip" // accepted on load for backward compatibility

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

praise: Thanks for adding this! 🙏

)

// withSnapshotExt forces the .snapshot extension, replacing any other the user gave.
func withSnapshotExt(path string) string {
ext := filepath.Ext(path)
if strings.EqualFold(ext, snapshotExt) {
return path
}
return strings.TrimSuffix(path, ext) + snapshotExt
}

// DestinationKind distinguishes local file paths from remote pod destinations.
type DestinationKind int

Expand All @@ -32,7 +46,7 @@ const (
)

// Destination is the parsed result of a user-supplied snapshot destination.
// For KindLocal, Value is an absolute local file path with a .zip extension.
// For KindLocal, Value is an absolute local file path with a .snapshot extension.
// For KindPod, Value is the validated pod name (without the "pod:" prefix).
type Destination struct {
Kind DestinationKind
Expand All @@ -46,9 +60,7 @@ func ParseRemovable(ref, cwd, home string) (Destination, error) {
lower := strings.ToLower(ref)
if !strings.HasPrefix(lower, "pod:") && !strings.Contains(lower, "://") {
abs, _ := filepath.Abs(ref)
if !strings.EqualFold(filepath.Ext(abs), ".zip") {
abs += ".zip"
}
abs = withSnapshotExt(abs)
return Destination{}, fmt.Errorf("'%s' resolves to a local file (%s); CLI cannot delete local files", ref, displayPath(abs, cwd, home))
}
return ParseSource(ref, home)
Expand All @@ -72,7 +84,8 @@ func displayPath(abs, cwd, home string) string {

// ParseSource resolves a user-supplied source REF for loading a snapshot.
// Unlike ParseDestination it never auto-generates a name: REF is required.
// For local paths, the file must exist; if no extension is given, .zip is tried as a fallback.
// For local paths, the file must exist; if no matching file is found, .snapshot and
// then .zip (legacy) are tried as fallbacks.
// home is used to expand a leading "~" or "~/"; pass "" to disable tilde expansion.
func ParseSource(ref, home string) (Destination, error) {
if ref == "" {
Expand Down Expand Up @@ -110,25 +123,27 @@ func ParseSource(ref, home string) (Destination, error) {
return Destination{}, fmt.Errorf("resolve path: %w", err)
}

// Try the path as-is first, then with .zip appended as a fallback for bare
// names (e.g. "my-snapshot" → "my-snapshot.zip" since that is what save produces).
// Try the path as-is first, then with .snapshot appended as a fallback for bare
// names (e.g. "my-snapshot" → "my-snapshot.snapshot" since that is what save
// produces), and finally .zip for snapshots saved by older lstk versions.
resolved, err := resolveSourcePath(abs)
if err != nil {
return Destination{}, err
}
return Destination{Kind: KindLocal, Value: resolved}, nil
}

// resolveSourcePath returns the first existing path among: abs as-is, then abs+".zip".
// resolveSourcePath returns the first existing path among: abs as-is, then
// abs+".snapshot", then abs+".zip" (legacy).
func resolveSourcePath(abs string) (string, error) {
if _, err := os.Stat(abs); err == nil {
return abs, nil
}
withZip := abs + ".zip"
if _, err := os.Stat(withZip); err == nil {
return withZip, nil
withSnapshot := abs + snapshotExt
withZip := abs + legacySnapshotExt
for _, candidate := range []string{abs, withSnapshot, withZip} {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
return "", fmt.Errorf("snapshot file not found: %q (also tried %q)", abs, withZip)
return "", fmt.Errorf("snapshot file not found: %q (also tried %q and %q)", abs, withSnapshot, withZip)
}

// ParseDestination resolves a user-supplied destination to a local path (KindLocal) or validated pod name (KindPod).
Expand Down Expand Up @@ -187,9 +202,7 @@ func ParseDestination(dest, home string, now time.Time) (Destination, error) {
return Destination{}, fmt.Errorf("%q is a directory — specify a file path like ./my-snapshot", abs)
}

if !strings.EqualFold(filepath.Ext(abs), ".zip") {
abs += ".zip"
}
abs = withSnapshotExt(abs)

return Destination{Kind: KindLocal, Value: abs}, nil
}
Loading