Skip to content

Commit 4a70a9c

Browse files
anisaoshaficlaude
andauthored
Add snapshot save command (local) (#210)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 679d747 commit 4a70a9c

12 files changed

Lines changed: 1163 additions & 0 deletions

File tree

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
7878
newUpdateCmd(cfg),
7979
newDocsCmd(),
8080
newAWSCmd(cfg),
81+
newSnapshotCmd(cfg),
8182
)
8283

8384
return root

cmd/snapshot.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"time"
7+
8+
"github.com/localstack/lstk/internal/config"
9+
"github.com/localstack/lstk/internal/emulator/aws"
10+
"github.com/localstack/lstk/internal/endpoint"
11+
"github.com/localstack/lstk/internal/env"
12+
"github.com/localstack/lstk/internal/output"
13+
"github.com/localstack/lstk/internal/runtime"
14+
"github.com/localstack/lstk/internal/snapshot"
15+
"github.com/localstack/lstk/internal/ui"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
func newSnapshotCmd(cfg *env.Env) *cobra.Command {
20+
cmd := &cobra.Command{
21+
Use: "snapshot",
22+
Short: "Manage emulator snapshots",
23+
}
24+
cmd.AddCommand(newSnapshotSaveCmd(cfg))
25+
return cmd
26+
}
27+
28+
func newSnapshotSaveCmd(cfg *env.Env) *cobra.Command {
29+
return &cobra.Command{
30+
Use: "save [destination]",
31+
Short: "Save a snapshot of the emulator state",
32+
Long: `Save a snapshot of the running emulator's state.
33+
34+
Pass [destination] as an absolute or relative path for the exported file:
35+
36+
lstk snapshot save # saves to ./snapshot-<YYYY-MM-DDTHH-mm-ss>-<hex>.zip
37+
lstk snapshot save ./my-snapshot.zip # saves to ./my-snapshot.zip
38+
lstk snapshot save /tmp/my-state # saves to /tmp/my-state.zip
39+
40+
Cloud destinations are not yet supported.`,
41+
Args: cobra.MaximumNArgs(1),
42+
PreRunE: initConfig(nil),
43+
RunE: func(cmd *cobra.Command, args []string) error {
44+
var destArg string
45+
if len(args) > 0 {
46+
destArg = args[0]
47+
}
48+
49+
dest, err := snapshot.ParseDestination(destArg, time.Now())
50+
if err != nil {
51+
return err
52+
}
53+
54+
appConfig, err := config.Get()
55+
if err != nil {
56+
return fmt.Errorf("failed to get config: %w", err)
57+
}
58+
59+
var awsContainer config.ContainerConfig
60+
var found bool
61+
for _, c := range appConfig.Containers {
62+
if c.Type == config.EmulatorAWS {
63+
awsContainer = c
64+
found = true
65+
break
66+
}
67+
}
68+
if !found {
69+
return fmt.Errorf("snapshot is only supported for the AWS emulator")
70+
}
71+
72+
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
73+
if err != nil {
74+
return err
75+
}
76+
host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)
77+
exporter := aws.NewClient()
78+
79+
if isInteractiveMode(cfg) {
80+
return ui.RunSnapshotSave(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, host, dest)
81+
}
82+
return snapshot.Save(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, host, dest, output.NewPlainSink(os.Stdout))
83+
},
84+
}
85+
}

internal/emulator/aws/client.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"encoding/json"
77
"fmt"
8+
"io"
89
"net/http"
910
"sort"
1011
"strings"
@@ -131,3 +132,26 @@ func (c *Client) FetchResources(ctx context.Context, host string) ([]emulator.Re
131132

132133
return rows, nil
133134
}
135+
136+
func (c *Client) ExportState(ctx context.Context, host string, dst io.Writer) error {
137+
url := fmt.Sprintf("http://%s/_localstack/pods/state", host)
138+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
139+
if err != nil {
140+
return fmt.Errorf("create request: %w", err)
141+
}
142+
143+
resp, err := c.http.Do(req)
144+
if err != nil {
145+
return fmt.Errorf("connect to LocalStack: %w", err)
146+
}
147+
defer func() { _ = resp.Body.Close() }()
148+
149+
if resp.StatusCode != http.StatusOK {
150+
return fmt.Errorf("LocalStack returned status %d", resp.StatusCode)
151+
}
152+
153+
if _, err := io.Copy(dst, resp.Body); err != nil {
154+
return fmt.Errorf("stream state: %w", err)
155+
}
156+
return nil
157+
}

internal/emulator/aws/client_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package aws
22

33
import (
4+
"bytes"
45
"context"
56
"fmt"
7+
"io"
68
"net/http"
79
"net/http/httptest"
10+
"strings"
811
"testing"
912

1013
"github.com/stretchr/testify/assert"
@@ -107,3 +110,104 @@ func TestFetchResources(t *testing.T) {
107110
})
108111
}
109112

113+
func TestExportState(t *testing.T) {
114+
t.Parallel()
115+
116+
t.Run("streams body on 200", func(t *testing.T) {
117+
t.Parallel()
118+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
119+
assert.Equal(t, "/_localstack/pods/state", r.URL.Path)
120+
assert.Equal(t, http.MethodGet, r.Method)
121+
w.WriteHeader(http.StatusOK)
122+
_, _ = w.Write([]byte("ZIP_DATA"))
123+
}))
124+
defer srv.Close()
125+
126+
var buf bytes.Buffer
127+
c := NewClient()
128+
err := c.ExportState(context.Background(), srv.Listener.Addr().String(), &buf)
129+
require.NoError(t, err)
130+
assert.Equal(t, "ZIP_DATA", buf.String())
131+
})
132+
133+
t.Run("returns error on 500", func(t *testing.T) {
134+
t.Parallel()
135+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
136+
w.WriteHeader(http.StatusInternalServerError)
137+
}))
138+
defer srv.Close()
139+
140+
c := NewClient()
141+
err := c.ExportState(context.Background(), srv.Listener.Addr().String(), io.Discard)
142+
require.Error(t, err)
143+
assert.Contains(t, err.Error(), "500")
144+
})
145+
146+
t.Run("returns error on 404", func(t *testing.T) {
147+
t.Parallel()
148+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
149+
w.WriteHeader(http.StatusNotFound)
150+
}))
151+
defer srv.Close()
152+
153+
c := NewClient()
154+
err := c.ExportState(context.Background(), srv.Listener.Addr().String(), io.Discard)
155+
require.Error(t, err)
156+
assert.Contains(t, err.Error(), "404")
157+
})
158+
159+
t.Run("returns error on connection refused", func(t *testing.T) {
160+
t.Parallel()
161+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
162+
addr := srv.Listener.Addr().String()
163+
srv.Close()
164+
165+
c := NewClient()
166+
err := c.ExportState(context.Background(), addr, io.Discard)
167+
require.Error(t, err)
168+
assert.Contains(t, err.Error(), "connect to LocalStack")
169+
})
170+
171+
t.Run("returns error on context cancellation", func(t *testing.T) {
172+
t.Parallel()
173+
started := make(chan struct{})
174+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
175+
close(started)
176+
<-r.Context().Done()
177+
}))
178+
defer srv.Close()
179+
180+
ctx, cancel := context.WithCancel(context.Background())
181+
c := NewClient()
182+
183+
errCh := make(chan error, 1)
184+
go func() {
185+
errCh <- c.ExportState(ctx, srv.Listener.Addr().String(), io.Discard)
186+
}()
187+
188+
<-started
189+
cancel()
190+
191+
err := <-errCh
192+
require.Error(t, err)
193+
})
194+
195+
t.Run("handles large body", func(t *testing.T) {
196+
t.Parallel()
197+
const size = 1 << 20 // 1 MB
198+
payload := strings.Repeat("X", size)
199+
200+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
201+
w.WriteHeader(http.StatusOK)
202+
_, _ = w.Write([]byte(payload))
203+
}))
204+
defer srv.Close()
205+
206+
var buf bytes.Buffer
207+
c := NewClient()
208+
err := c.ExportState(context.Background(), srv.Listener.Addr().String(), &buf)
209+
require.NoError(t, err)
210+
assert.Equal(t, size, buf.Len())
211+
})
212+
}
213+

internal/snapshot/destination.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package snapshot
2+
3+
import (
4+
"crypto/rand"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"time"
11+
)
12+
13+
var (
14+
// ErrRemoteNotSupported is returned for known remote schemes (s3://, oras://, cloud:).
15+
ErrRemoteNotSupported = errors.New("remote destinations are not yet supported — coming soon")
16+
// ErrUnknownScheme is returned for unrecognized URL schemes.
17+
ErrUnknownScheme = errors.New("unrecognized destination scheme")
18+
)
19+
20+
// displayPath shortens abs for human-readable output:
21+
// under cwd → ./rel, under home → ~/rel, otherwise unchanged.
22+
func displayPath(abs, cwd, home string) string {
23+
if cwd != "" {
24+
if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
25+
return "./" + filepath.ToSlash(rel)
26+
}
27+
}
28+
if home != "" {
29+
if rel, err := filepath.Rel(home, abs); err == nil && !strings.HasPrefix(rel, "..") {
30+
return "~/" + filepath.ToSlash(rel)
31+
}
32+
}
33+
return abs
34+
}
35+
36+
// ParseDestination resolves the user-supplied path to an absolute local path.
37+
// When dest is empty, a default name based on now (UTC) is used, e.g.
38+
// "snapshot-2026-05-11T21-04-32-a3f.zip", saved in the current working directory.
39+
// The returned path always has a .zip extension.
40+
func ParseDestination(dest string, now time.Time) (string, error) {
41+
if dest == "" {
42+
b := make([]byte, 2)
43+
_, _ = rand.Read(b)
44+
dest = "./" + now.UTC().Format("snapshot-2006-01-02T15-04-05") + "-" + fmt.Sprintf("%x", b)[:3]
45+
} else {
46+
lower := strings.ToLower(dest)
47+
switch {
48+
case strings.HasPrefix(lower, "s3://"),
49+
strings.HasPrefix(lower, "oras://"),
50+
strings.HasPrefix(lower, "cloud:"):
51+
return "", ErrRemoteNotSupported
52+
case strings.Contains(lower, "://"):
53+
scheme, _, _ := strings.Cut(dest, "://")
54+
return "", fmt.Errorf("%w: %q", ErrUnknownScheme, scheme+"://")
55+
}
56+
}
57+
58+
if dest == "~" || strings.HasPrefix(dest, "~/") || strings.HasPrefix(dest, `~\`) {
59+
home, err := os.UserHomeDir()
60+
if err != nil {
61+
return "", fmt.Errorf("resolve home directory: %w", err)
62+
}
63+
dest = filepath.Join(home, strings.TrimLeft(dest[1:], `/\`))
64+
}
65+
66+
abs, err := filepath.Abs(dest)
67+
if err != nil {
68+
return "", fmt.Errorf("resolve path: %w", err)
69+
}
70+
71+
parent := filepath.Dir(abs)
72+
parentInfo, err := os.Stat(parent)
73+
if err != nil {
74+
if os.IsNotExist(err) {
75+
return "", fmt.Errorf("parent directory %q does not exist — create it first", parent)
76+
}
77+
return "", fmt.Errorf("check parent directory: %w", err)
78+
}
79+
if !parentInfo.IsDir() {
80+
return "", fmt.Errorf("parent path %q is not a directory", parent)
81+
}
82+
83+
if info, err := os.Stat(abs); err == nil && info.IsDir() {
84+
return "", fmt.Errorf("%q is a directory — specify a file path like ./my-snapshot", abs)
85+
}
86+
87+
if !strings.EqualFold(filepath.Ext(abs), ".zip") {
88+
abs += ".zip"
89+
}
90+
91+
return abs, nil
92+
}

0 commit comments

Comments
 (0)