Skip to content

Commit e015523

Browse files
committed
Add snapshot save command
1 parent 5e3be15 commit e015523

10 files changed

Lines changed: 875 additions & 0 deletions

File tree

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
7272
newUpdateCmd(cfg, tel),
7373
newDocsCmd(),
7474
newAWSCmd(cfg, tel),
75+
newSnapshotCmd(cfg, tel),
7576
)
7677

7778
return root

cmd/snapshot.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/localstack/lstk/internal/config"
8+
"github.com/localstack/lstk/internal/endpoint"
9+
"github.com/localstack/lstk/internal/env"
10+
"github.com/localstack/lstk/internal/output"
11+
"github.com/localstack/lstk/internal/runtime"
12+
"github.com/localstack/lstk/internal/snapshot"
13+
"github.com/localstack/lstk/internal/telemetry"
14+
"github.com/localstack/lstk/internal/ui"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
func newSnapshotCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
19+
cmd := &cobra.Command{
20+
Use: "snapshot",
21+
Short: "Manage emulator snapshots",
22+
}
23+
cmd.AddCommand(newSnapshotSaveCmd(cfg, tel))
24+
return cmd
25+
}
26+
27+
func newSnapshotSaveCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
28+
return &cobra.Command{
29+
Use: "save [destination]",
30+
Short: "Save a snapshot of the emulator state",
31+
Long: `Save a snapshot of the running emulator's state to a local file.
32+
33+
The destination must be a file path. Use a path prefix to save locally:
34+
35+
lstk snapshot save # saves to ./ls-state-export
36+
lstk snapshot save ./my-snapshot # saves to ./my-snapshot
37+
lstk snapshot save /tmp/my-state # saves to /tmp/my-state
38+
39+
Cloud destinations are not yet supported.`,
40+
Args: cobra.MaximumNArgs(1),
41+
PreRunE: initConfig,
42+
RunE: commandWithTelemetry("snapshot save", tel, func(cmd *cobra.Command, args []string) error {
43+
var destArg string
44+
if len(args) > 0 {
45+
destArg = args[0]
46+
}
47+
48+
dest, err := snapshot.ParseDestination(destArg)
49+
if err != nil {
50+
return err
51+
}
52+
53+
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
54+
if err != nil {
55+
return err
56+
}
57+
58+
appConfig, err := config.Get()
59+
if err != nil {
60+
return fmt.Errorf("failed to get config: %w", err)
61+
}
62+
if len(appConfig.Containers) == 0 {
63+
return fmt.Errorf("no emulator configured")
64+
}
65+
66+
c := appConfig.Containers[0]
67+
host, _ := endpoint.ResolveHost(c.Port, cfg.LocalStackHost)
68+
exporter := snapshot.NewStateClient("http://" + host)
69+
70+
opts := snapshot.SaveOptions{}
71+
72+
if isInteractiveMode(cfg) {
73+
return ui.RunSnapshotSave(cmd.Context(), rt, appConfig.Containers, exporter, dest, opts)
74+
}
75+
return snapshot.Save(cmd.Context(), rt, appConfig.Containers, exporter, dest, output.NewPlainSink(os.Stdout), opts)
76+
}),
77+
}
78+
}

internal/snapshot/client.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package snapshot
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
)
9+
10+
// StateExporter retrieves state from the running LocalStack instance.
11+
type StateExporter interface {
12+
ExportState(ctx context.Context) (io.ReadCloser, error)
13+
}
14+
15+
// StateClient calls the LocalStack state API.
16+
type StateClient struct {
17+
baseURL string
18+
httpClient *http.Client
19+
}
20+
21+
func NewStateClient(baseURL string) *StateClient {
22+
return &StateClient{
23+
baseURL: baseURL,
24+
httpClient: &http.Client{},
25+
}
26+
}
27+
28+
// ExportState calls GET /_localstack/pods/state and returns the response body.
29+
// The caller is responsible for closing the returned ReadCloser.
30+
func (c *StateClient) ExportState(ctx context.Context) (io.ReadCloser, error) {
31+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/_localstack/pods/state", nil)
32+
if err != nil {
33+
return nil, fmt.Errorf("create request: %w", err)
34+
}
35+
36+
resp, err := c.httpClient.Do(req)
37+
if err != nil {
38+
return nil, fmt.Errorf("connect to LocalStack: %w", err)
39+
}
40+
if resp.StatusCode != http.StatusOK {
41+
_ = resp.Body.Close()
42+
return nil, fmt.Errorf("LocalStack returned status %d", resp.StatusCode)
43+
}
44+
return resp.Body, nil
45+
}

internal/snapshot/client_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package snapshot_test
2+
3+
import (
4+
"context"
5+
"io"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
11+
"github.com/localstack/lstk/internal/snapshot"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestStateClient_ExportState_OK(t *testing.T) {
17+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18+
assert.Equal(t, "/_localstack/pods/state", r.URL.Path)
19+
assert.Equal(t, http.MethodGet, r.Method)
20+
w.WriteHeader(http.StatusOK)
21+
_, _ = w.Write([]byte("ZIP_DATA"))
22+
}))
23+
defer srv.Close()
24+
25+
client := snapshot.NewStateClient(srv.URL)
26+
body, err := client.ExportState(context.Background())
27+
require.NoError(t, err)
28+
defer func() { _ = body.Close() }()
29+
30+
data, err := io.ReadAll(body)
31+
require.NoError(t, err)
32+
assert.Equal(t, "ZIP_DATA", string(data))
33+
}
34+
35+
func TestStateClient_ExportState_ServerError(t *testing.T) {
36+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37+
w.WriteHeader(http.StatusInternalServerError)
38+
}))
39+
defer srv.Close()
40+
41+
client := snapshot.NewStateClient(srv.URL)
42+
_, err := client.ExportState(context.Background())
43+
require.Error(t, err)
44+
assert.Contains(t, err.Error(), "500")
45+
}
46+
47+
func TestStateClient_ExportState_NotFound(t *testing.T) {
48+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49+
w.WriteHeader(http.StatusNotFound)
50+
}))
51+
defer srv.Close()
52+
53+
client := snapshot.NewStateClient(srv.URL)
54+
_, err := client.ExportState(context.Background())
55+
require.Error(t, err)
56+
assert.Contains(t, err.Error(), "404")
57+
}
58+
59+
func TestStateClient_ExportState_ConnectionRefused(t *testing.T) {
60+
// Bind then immediately close to get a port that refuses connections.
61+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
62+
addr := srv.URL
63+
srv.Close()
64+
65+
client := snapshot.NewStateClient(addr)
66+
_, err := client.ExportState(context.Background())
67+
require.Error(t, err)
68+
assert.Contains(t, err.Error(), "connect to LocalStack")
69+
}
70+
71+
func TestStateClient_ExportState_ContextCancelled(t *testing.T) {
72+
started := make(chan struct{})
73+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74+
close(started)
75+
// block until the client cancels
76+
<-r.Context().Done()
77+
}))
78+
defer srv.Close()
79+
80+
ctx, cancel := context.WithCancel(context.Background())
81+
client := snapshot.NewStateClient(srv.URL)
82+
83+
errCh := make(chan error, 1)
84+
go func() {
85+
_, err := client.ExportState(ctx)
86+
errCh <- err
87+
}()
88+
89+
<-started
90+
cancel()
91+
92+
err := <-errCh
93+
require.Error(t, err)
94+
}
95+
96+
func TestStateClient_ExportState_LargeBody(t *testing.T) {
97+
const size = 1 << 20 // 1 MB
98+
payload := strings.Repeat("X", size)
99+
100+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101+
w.WriteHeader(http.StatusOK)
102+
_, _ = w.Write([]byte(payload))
103+
}))
104+
defer srv.Close()
105+
106+
client := snapshot.NewStateClient(srv.URL)
107+
body, err := client.ExportState(context.Background())
108+
require.NoError(t, err)
109+
defer func() { _ = body.Close() }()
110+
111+
data, err := io.ReadAll(body)
112+
require.NoError(t, err)
113+
assert.Equal(t, size, len(data))
114+
}

internal/snapshot/destination.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package snapshot
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"strings"
8+
)
9+
10+
// Destination is where snapshot state is written.
11+
// LocalDestination writes to a local file; future implementations handle cloud storage.
12+
type Destination interface {
13+
Writer() (io.WriteCloser, error)
14+
String() string
15+
}
16+
17+
// ParseDestination parses the user-supplied destination string.
18+
//
19+
// Disambiguation rules:
20+
// - empty string → LocalDestination at default path "ls-state-export"
21+
// - contains "://" → unsupported cloud URI error
22+
// - starts with ".", "/", or "~", or contains "/" → LocalDestination
23+
// - bare name (no path chars, no scheme) → reserved for future cloud pod names; returns error
24+
func ParseDestination(dest string) (Destination, error) {
25+
if dest == "" {
26+
return LocalDestination{Path: "ls-state-export"}, nil
27+
}
28+
if strings.Contains(dest, "://") {
29+
return nil, fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot")
30+
}
31+
if strings.HasPrefix(dest, ".") || strings.HasPrefix(dest, "/") || strings.HasPrefix(dest, "~") || strings.Contains(dest, "/") {
32+
return LocalDestination{Path: dest}, nil
33+
}
34+
// bare name with no path separators: reserved for future cloud pod names
35+
return nil, fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot")
36+
}
37+
38+
// LocalDestination writes snapshot state to a local file.
39+
type LocalDestination struct {
40+
Path string
41+
}
42+
43+
func (d LocalDestination) Writer() (io.WriteCloser, error) {
44+
return os.Create(d.Path)
45+
}
46+
47+
func (d LocalDestination) String() string {
48+
return d.Path
49+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package snapshot_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/localstack/lstk/internal/snapshot"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestParseDestination(t *testing.T) {
12+
tests := []struct {
13+
input string
14+
want snapshot.Destination
15+
wantErr string
16+
}{
17+
{
18+
input: "",
19+
want: snapshot.LocalDestination{Path: "ls-state-export"},
20+
},
21+
{
22+
input: "./my-state",
23+
want: snapshot.LocalDestination{Path: "./my-state"},
24+
},
25+
{
26+
input: "/tmp/state",
27+
want: snapshot.LocalDestination{Path: "/tmp/state"},
28+
},
29+
{
30+
input: "~/snapshots/s",
31+
want: snapshot.LocalDestination{Path: "~/snapshots/s"},
32+
},
33+
{
34+
input: "subdir/state",
35+
want: snapshot.LocalDestination{Path: "subdir/state"},
36+
},
37+
{
38+
input: "my-pod",
39+
wantErr: "cloud destinations are not yet supported",
40+
},
41+
{
42+
input: "cloud://my-pod",
43+
wantErr: "cloud destinations are not yet supported",
44+
},
45+
{
46+
input: "s3://bucket/key",
47+
wantErr: "cloud destinations are not yet supported",
48+
},
49+
}
50+
51+
for _, tc := range tests {
52+
t.Run(tc.input, func(t *testing.T) {
53+
got, err := snapshot.ParseDestination(tc.input)
54+
if tc.wantErr != "" {
55+
require.Error(t, err)
56+
assert.Contains(t, err.Error(), tc.wantErr)
57+
assert.Contains(t, err.Error(), "./my-snapshot")
58+
return
59+
}
60+
require.NoError(t, err)
61+
assert.Equal(t, tc.want, got)
62+
})
63+
}
64+
}

0 commit comments

Comments
 (0)