Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a8208b5
Add snapshot save command
anisaoshafi Apr 28, 2026
cff0533
Absolute path and different error
anisaoshafi Apr 28, 2026
21682bb
snapshot only works for aws
anisaoshafi Apr 29, 2026
ea992e4
OS-agnostic destination check
anisaoshafi Apr 29, 2026
208e182
Drop needless interface
anisaoshafi Apr 29, 2026
9d04c57
Handle conflicts
anisaoshafi Apr 29, 2026
3d91a28
Drop 'snapshot' subcommand in favor of simplicity
anisaoshafi Apr 29, 2026
84b52ba
Restore 'snapshot' command in favor of clarity
anisaoshafi May 8, 2026
4e05dcf
Fix linting & paralellize tests
anisaoshafi May 8, 2026
8171252
Improvements
anisaoshafi May 11, 2026
475e44c
Move ExportState into aws.Client
anisaoshafi May 11, 2026
e915375
change default snapshot name
anisaoshafi May 11, 2026
420dfdb
local snapshot save always has .zip extension
anisaoshafi May 11, 2026
aac8705
Remove partial snapshot file when write fails mid-stream
anisaoshafi May 11, 2026
19762fc
Use isolated HOME in TestSnapshotSaveLocalStackNotRunning
anisaoshafi May 11, 2026
f66ba56
Test NewPlainSinkSplit event routing behavior
anisaoshafi May 11, 2026
ec75a46
Nits
anisaoshafi May 11, 2026
9aba0ba
Improve parsing logic
anisaoshafi May 12, 2026
2345902
Clean up snapshot save command and plain sink
anisaoshafi May 12, 2026
04e7ec3
Use gomock instead
anisaoshafi May 12, 2026
16e82a7
Move StateExporter interface where it's used
anisaoshafi May 12, 2026
a4f776d
Remove redundant tests; handle as test cases
anisaoshafi May 12, 2026
112f736
Change ExportState so HTTP response body is always closed inside the …
anisaoshafi May 12, 2026
4015b76
Adjust snapshot name when destination not provided: append 3 char hex
anisaoshafi May 13, 2026
b89d369
Improve rendering of the path in the success message
anisaoshafi May 13, 2026
60503f4
Address suggestions
anisaoshafi May 13, 2026
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
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
newUpdateCmd(cfg),
newDocsCmd(),
newAWSCmd(cfg),
newSnapshotCmd(cfg),
)

return root
Expand Down
85 changes: 85 additions & 0 deletions cmd/snapshot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package cmd

import (
"fmt"
"os"
"time"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/emulator/aws"
"github.com/localstack/lstk/internal/endpoint"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/snapshot"
"github.com/localstack/lstk/internal/ui"
"github.com/spf13/cobra"
)

func newSnapshotCmd(cfg *env.Env) *cobra.Command {
cmd := &cobra.Command{
Use: "snapshot",
Short: "Manage emulator snapshots",
}
cmd.AddCommand(newSnapshotSaveCmd(cfg))
return cmd
}

func newSnapshotSaveCmd(cfg *env.Env) *cobra.Command {
return &cobra.Command{
Use: "save [destination]",
Short: "Save a snapshot of the emulator state",
Long: `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

Cloud destinations are not yet supported.`,
Args: cobra.MaximumNArgs(1),
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
var destArg string
if len(args) > 0 {
destArg = args[0]
}

dest, err := snapshot.ParseDestination(destArg, time.Now())
if err != nil {
return err
}

appConfig, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}

var awsContainer config.ContainerConfig
var found bool
for _, c := range appConfig.Containers {
if c.Type == config.EmulatorAWS {
awsContainer = c
found = true
break
}
}
if !found {
return fmt.Errorf("snapshot is only supported for the AWS emulator")
}

rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
}
host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)
exporter := aws.NewClient()

if isInteractiveMode(cfg) {
return ui.RunSnapshotSave(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, host, dest)
}
return snapshot.Save(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, host, dest, output.NewPlainSink(os.Stdout))
},
}
}
24 changes: 24 additions & 0 deletions internal/emulator/aws/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
Expand Down Expand Up @@ -131,3 +132,26 @@ func (c *Client) FetchResources(ctx context.Context, host string) ([]emulator.Re

return rows, nil
}

func (c *Client) ExportState(ctx context.Context, host string, dst io.Writer) error {
url := fmt.Sprintf("http://%s/_localstack/pods/state", host)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}

resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("connect to LocalStack: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("LocalStack returned status %d", resp.StatusCode)
}

if _, err := io.Copy(dst, resp.Body); err != nil {
return fmt.Errorf("stream state: %w", err)
}
return nil
}
104 changes: 104 additions & 0 deletions internal/emulator/aws/client_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package aws

import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -107,3 +110,104 @@ func TestFetchResources(t *testing.T) {
})
}

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

t.Run("streams body on 200", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/_localstack/pods/state", r.URL.Path)
assert.Equal(t, http.MethodGet, r.Method)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ZIP_DATA"))
}))
defer srv.Close()

var buf bytes.Buffer
c := NewClient()
err := c.ExportState(context.Background(), srv.Listener.Addr().String(), &buf)
require.NoError(t, err)
assert.Equal(t, "ZIP_DATA", buf.String())
})

t.Run("returns error on 500", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()

c := NewClient()
err := c.ExportState(context.Background(), srv.Listener.Addr().String(), io.Discard)
require.Error(t, err)
assert.Contains(t, err.Error(), "500")
})

t.Run("returns error on 404", 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.Listener.Addr().String(), io.Discard)
require.Error(t, err)
assert.Contains(t, err.Error(), "404")
})

t.Run("returns error on connection refused", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
addr := srv.Listener.Addr().String()
srv.Close()

c := NewClient()
err := c.ExportState(context.Background(), addr, io.Discard)
require.Error(t, err)
assert.Contains(t, err.Error(), "connect to LocalStack")
})

t.Run("returns error on context cancellation", func(t *testing.T) {
t.Parallel()
started := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-r.Context().Done()
}))
defer srv.Close()

ctx, cancel := context.WithCancel(context.Background())
c := NewClient()

errCh := make(chan error, 1)
go func() {
errCh <- c.ExportState(ctx, srv.Listener.Addr().String(), io.Discard)
}()

<-started
cancel()

err := <-errCh
require.Error(t, err)
})

t.Run("handles large body", func(t *testing.T) {
t.Parallel()
const size = 1 << 20 // 1 MB
payload := strings.Repeat("X", size)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(payload))
}))
defer srv.Close()

var buf bytes.Buffer
c := NewClient()
err := c.ExportState(context.Background(), srv.Listener.Addr().String(), &buf)
require.NoError(t, err)
assert.Equal(t, size, buf.Len())
})
}

92 changes: 92 additions & 0 deletions internal/snapshot/destination.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package snapshot

import (
"crypto/rand"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)

var (
// ErrRemoteNotSupported is returned for known remote schemes (s3://, oras://, cloud:).
ErrRemoteNotSupported = errors.New("remote destinations are not yet supported — coming soon")
// ErrUnknownScheme is returned for unrecognized URL schemes.
ErrUnknownScheme = errors.New("unrecognized destination scheme")
)

// displayPath shortens abs for human-readable output:
// under cwd → ./rel, under home → ~/rel, otherwise unchanged.
func displayPath(abs, cwd, home string) string {
if cwd != "" {
if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
return "./" + filepath.ToSlash(rel)
}
}
if home != "" {
if rel, err := filepath.Rel(home, abs); err == nil && !strings.HasPrefix(rel, "..") {
return "~/" + filepath.ToSlash(rel)
}
}
return abs
}

// ParseDestination resolves the user-supplied path to an absolute local path.
// When dest is empty, a default name based on now (UTC) is used, e.g.
// "snapshot-2026-05-11T21-04-32-a3f.zip", saved in the current working directory.
// The returned path always has a .zip extension.
Comment thread
anisaoshafi marked this conversation as resolved.
func ParseDestination(dest string, now time.Time) (string, error) {
if dest == "" {
b := make([]byte, 2)
_, _ = rand.Read(b)
dest = "./" + now.UTC().Format("snapshot-2006-01-02T15-04-05") + "-" + fmt.Sprintf("%x", b)[:3]
} else {
lower := strings.ToLower(dest)
switch {
case strings.HasPrefix(lower, "s3://"),
strings.HasPrefix(lower, "oras://"),
strings.HasPrefix(lower, "cloud:"):
return "", ErrRemoteNotSupported
case strings.Contains(lower, "://"):
scheme, _, _ := strings.Cut(dest, "://")
return "", fmt.Errorf("%w: %q", ErrUnknownScheme, scheme+"://")
}
}

if dest == "~" || strings.HasPrefix(dest, "~/") || strings.HasPrefix(dest, `~\`) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
dest = filepath.Join(home, strings.TrimLeft(dest[1:], `/\`))
}

abs, err := filepath.Abs(dest)
if err != nil {
return "", fmt.Errorf("resolve path: %w", err)
}

parent := filepath.Dir(abs)
parentInfo, err := os.Stat(parent)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("parent directory %q does not exist — create it first", parent)
}
return "", fmt.Errorf("check parent directory: %w", err)
}
if !parentInfo.IsDir() {
return "", fmt.Errorf("parent path %q is not a directory", parent)
}

if info, err := os.Stat(abs); err == nil && info.IsDir() {
return "", fmt.Errorf("%q is a directory — specify a file path like ./my-snapshot", abs)
}

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

return abs, nil
}
Loading
Loading