From a8208b5849bc171e44b12c390d93fba8ab2ed8c4 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 28 Apr 2026 13:21:23 +0200 Subject: [PATCH 01/26] Add snapshot save command --- cmd/root.go | 3 +- cmd/snapshot.go | 76 ++++++++ internal/output/plain_sink.go | 24 ++- internal/snapshot/client.go | 44 +++++ internal/snapshot/client_test.go | 114 ++++++++++++ internal/snapshot/destination.go | 42 +++++ internal/snapshot/destination_test.go | 64 +++++++ internal/snapshot/save.go | 65 +++++++ internal/snapshot/save_test.go | 193 +++++++++++++++++++ internal/ui/run_snapshot_save.go | 16 ++ test/integration/snapshot_save_test.go | 246 +++++++++++++++++++++++++ 11 files changed, 882 insertions(+), 5 deletions(-) create mode 100644 cmd/snapshot.go create mode 100644 internal/snapshot/client.go create mode 100644 internal/snapshot/client_test.go create mode 100644 internal/snapshot/destination.go create mode 100644 internal/snapshot/destination_test.go create mode 100644 internal/snapshot/save.go create mode 100644 internal/snapshot/save_test.go create mode 100644 internal/ui/run_snapshot_save.go create mode 100644 test/integration/snapshot_save_test.go diff --git a/cmd/root.go b/cmd/root.go index 7d25e893..d4d96235 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -77,7 +77,8 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newVolumeCmd(cfg), newUpdateCmd(cfg), newDocsCmd(), - newAWSCmd(cfg), + newAWSCmd(cfg, tel), + newSnapshotCmd(cfg, tel), ) return root diff --git a/cmd/snapshot.go b/cmd/snapshot.go new file mode 100644 index 00000000..9d25f1a8 --- /dev/null +++ b/cmd/snapshot.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/localstack/lstk/internal/config" + "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/telemetry" + "github.com/localstack/lstk/internal/ui" + "github.com/spf13/cobra" +) + +func newSnapshotCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "snapshot", + Short: "Manage emulator snapshots", + } + cmd.AddCommand(newSnapshotSaveCmd(cfg, tel)) + return cmd +} + +func newSnapshotSaveCmd(cfg *env.Env, tel *telemetry.Client) *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 to a local file. + +The destination must be a file path. Use a path prefix to save locally: + + lstk snapshot save # saves to ./ls-state-export + lstk snapshot save ./my-snapshot # saves to ./my-snapshot + lstk snapshot save /tmp/my-state # saves to /tmp/my-state + +Cloud destinations are not yet supported.`, + Args: cobra.MaximumNArgs(1), + PreRunE: initConfig, + RunE: commandWithTelemetry("snapshot save", tel, func(cmd *cobra.Command, args []string) error { + var destArg string + if len(args) > 0 { + destArg = args[0] + } + + dest, err := snapshot.ParseDestination(destArg) + if err != nil { + return err + } + + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) + if err != nil { + return err + } + + appConfig, err := config.Get() + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + if len(appConfig.Containers) == 0 { + return fmt.Errorf("no emulator configured") + } + + c := appConfig.Containers[0] + host, _ := endpoint.ResolveHost(c.Port, cfg.LocalStackHost) + exporter := snapshot.NewStateClient("http://" + host) + + if isInteractiveMode(cfg) { + return ui.RunSnapshotSave(cmd.Context(), rt, appConfig.Containers, exporter, dest) + } + return snapshot.Save(cmd.Context(), rt, appConfig.Containers, exporter, dest, output.NewPlainSinkSplit(os.Stdout, os.Stderr)) + }), + } +} diff --git a/internal/output/plain_sink.go b/internal/output/plain_sink.go index fa6fed52..0f925b77 100644 --- a/internal/output/plain_sink.go +++ b/internal/output/plain_sink.go @@ -7,15 +7,27 @@ import ( ) type PlainSink struct { - out io.Writer - err error + out io.Writer + errOut io.Writer + err error } func NewPlainSink(out io.Writer) *PlainSink { if out == nil { out = os.Stdout } - return &PlainSink{out: out} + return &PlainSink{out: out, errOut: out} +} + +// NewPlainSinkSplit creates a PlainSink that routes ErrorEvents to errOut and all others to out. +func NewPlainSinkSplit(out, errOut io.Writer) *PlainSink { + if out == nil { + out = os.Stdout + } + if errOut == nil { + errOut = os.Stderr + } + return &PlainSink{out: out, errOut: errOut} } // Err returns the first write error encountered, if any. @@ -34,6 +46,10 @@ func (s *PlainSink) Emit(event Event) { if !ok { return } - _, err := fmt.Fprintln(s.out, line) + w := s.out + if _, isErr := event.(ErrorEvent); isErr { + w = s.errOut + } + _, err := fmt.Fprintln(w, line) s.setErr(err) } diff --git a/internal/snapshot/client.go b/internal/snapshot/client.go new file mode 100644 index 00000000..a778f147 --- /dev/null +++ b/internal/snapshot/client.go @@ -0,0 +1,44 @@ +package snapshot + +import ( + "context" + "fmt" + "io" + "net/http" +) + +// StateExporter retrieves state from the running LocalStack instance. +type StateExporter interface { + ExportState(ctx context.Context) (io.ReadCloser, error) +} + +// StateClient calls the LocalStack state API. +type StateClient struct { + baseURL string + httpClient *http.Client +} + +func NewStateClient(baseURL string) *StateClient { + return &StateClient{ + baseURL: baseURL, + httpClient: &http.Client{}, + } +} + +// ExportState calls GET /_localstack/pods/state; caller must close the returned body. +func (c *StateClient) ExportState(ctx context.Context) (io.ReadCloser, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/_localstack/pods/state", nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("connect to LocalStack: %w", err) + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, fmt.Errorf("LocalStack returned status %d", resp.StatusCode) + } + return resp.Body, nil +} diff --git a/internal/snapshot/client_test.go b/internal/snapshot/client_test.go new file mode 100644 index 00000000..f62bdae8 --- /dev/null +++ b/internal/snapshot/client_test.go @@ -0,0 +1,114 @@ +package snapshot_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/localstack/lstk/internal/snapshot" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStateClient_ExportState_OK(t *testing.T) { + 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() + + client := snapshot.NewStateClient(srv.URL) + body, err := client.ExportState(context.Background()) + require.NoError(t, err) + defer func() { _ = body.Close() }() + + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, "ZIP_DATA", string(data)) +} + +func TestStateClient_ExportState_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + client := snapshot.NewStateClient(srv.URL) + _, err := client.ExportState(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestStateClient_ExportState_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := snapshot.NewStateClient(srv.URL) + _, err := client.ExportState(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "404") +} + +func TestStateClient_ExportState_ConnectionRefused(t *testing.T) { + // Bind then immediately close to get a port that refuses connections. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + addr := srv.URL + srv.Close() + + client := snapshot.NewStateClient(addr) + _, err := client.ExportState(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "connect to LocalStack") +} + +func TestStateClient_ExportState_ContextCancelled(t *testing.T) { + started := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + // block until the client cancels + <-r.Context().Done() + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + client := snapshot.NewStateClient(srv.URL) + + errCh := make(chan error, 1) + go func() { + _, err := client.ExportState(ctx) + errCh <- err + }() + + <-started + cancel() + + err := <-errCh + require.Error(t, err) +} + +func TestStateClient_ExportState_LargeBody(t *testing.T) { + const size = 1 << 20 // 1 MB + payload := strings.Repeat("X", size) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(payload)) + })) + defer srv.Close() + + client := snapshot.NewStateClient(srv.URL) + body, err := client.ExportState(context.Background()) + require.NoError(t, err) + defer func() { _ = body.Close() }() + + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, size, len(data)) +} diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go new file mode 100644 index 00000000..5c760a7b --- /dev/null +++ b/internal/snapshot/destination.go @@ -0,0 +1,42 @@ +package snapshot + +import ( + "fmt" + "io" + "os" + "strings" +) + +// Destination is where snapshot state is written. +type Destination interface { + Writer() (io.WriteCloser, error) + String() string +} + +// ParseDestination returns a Destination for the user-supplied path, or an error for cloud/bare names. +func ParseDestination(dest string) (Destination, error) { + if dest == "" { + return LocalDestination{Path: "ls-state-export"}, nil + } + if strings.Contains(dest, "://") { + return nil, fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") + } + if strings.HasPrefix(dest, ".") || strings.HasPrefix(dest, "/") || strings.HasPrefix(dest, "~") || strings.Contains(dest, "/") { + return LocalDestination{Path: dest}, nil + } + // bare name with no path separators: reserved for future cloud pod names + return nil, fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") +} + +// LocalDestination writes snapshot state to a local file. +type LocalDestination struct { + Path string +} + +func (d LocalDestination) Writer() (io.WriteCloser, error) { + return os.Create(d.Path) +} + +func (d LocalDestination) String() string { + return d.Path +} diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go new file mode 100644 index 00000000..1595efc6 --- /dev/null +++ b/internal/snapshot/destination_test.go @@ -0,0 +1,64 @@ +package snapshot_test + +import ( + "testing" + + "github.com/localstack/lstk/internal/snapshot" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDestination(t *testing.T) { + tests := []struct { + input string + want snapshot.Destination + wantErr string + }{ + { + input: "", + want: snapshot.LocalDestination{Path: "ls-state-export"}, + }, + { + input: "./my-state", + want: snapshot.LocalDestination{Path: "./my-state"}, + }, + { + input: "/tmp/state", + want: snapshot.LocalDestination{Path: "/tmp/state"}, + }, + { + input: "~/snapshots/s", + want: snapshot.LocalDestination{Path: "~/snapshots/s"}, + }, + { + input: "subdir/state", + want: snapshot.LocalDestination{Path: "subdir/state"}, + }, + { + input: "my-pod", + wantErr: "cloud destinations are not yet supported", + }, + { + input: "cloud://my-pod", + wantErr: "cloud destinations are not yet supported", + }, + { + input: "s3://bucket/key", + wantErr: "cloud destinations are not yet supported", + }, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got, err := snapshot.ParseDestination(tc.input) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + assert.Contains(t, err.Error(), "./my-snapshot") + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go new file mode 100644 index 00000000..141e1bb3 --- /dev/null +++ b/internal/snapshot/save.go @@ -0,0 +1,65 @@ +package snapshot + +import ( + "context" + "fmt" + "io" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/container" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" +) + +// Save exports the emulator's state via exporter and writes it to dest. +func Save(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter StateExporter, dest Destination, sink output.Sink) error { + if err := rt.IsHealthy(ctx); err != nil { + rt.EmitUnhealthyError(sink, err) + return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) + } + + running, err := container.AnyRunning(ctx, rt, containers) + if err != nil { + return fmt.Errorf("checking emulator status: %w", err) + } + if !running { + output.EmitError(sink, output.ErrorEvent{ + Title: "LocalStack is not running", + Actions: []output.ErrorAction{ + {Label: "Start LocalStack:", Value: "lstk"}, + {Label: "See help:", Value: "lstk -h"}, + }, + }) + return output.NewSilentError(fmt.Errorf("LocalStack is not running")) + } + + output.EmitSpinnerStart(sink, "Saving snapshot...") + + body, err := exporter.ExportState(ctx) + if err != nil { + output.EmitSpinnerStop(sink) + return fmt.Errorf("export state from LocalStack: %w", err) + } + defer func() { _ = body.Close() }() + + w, err := dest.Writer() + if err != nil { + output.EmitSpinnerStop(sink) + return fmt.Errorf("open destination %s: %w", dest, err) + } + + if _, err := io.Copy(w, body); err != nil { + _ = w.Close() + output.EmitSpinnerStop(sink) + return fmt.Errorf("write snapshot: %w", err) + } + + if err := w.Close(); err != nil { + output.EmitSpinnerStop(sink) + return fmt.Errorf("close snapshot: %w", err) + } + + output.EmitSpinnerStop(sink) + output.EmitSuccess(sink, fmt.Sprintf("Snapshot saved to %s", dest)) + return nil +} diff --git a/internal/snapshot/save_test.go b/internal/snapshot/save_test.go new file mode 100644 index 00000000..2cb3ffd0 --- /dev/null +++ b/internal/snapshot/save_test.go @@ -0,0 +1,193 @@ +package snapshot_test + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "testing" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" + "github.com/localstack/lstk/internal/snapshot" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// fakeExporter implements StateExporter for tests. +type fakeExporter struct { + body []byte + err error +} + +func (f *fakeExporter) ExportState(_ context.Context) (io.ReadCloser, error) { + if f.err != nil { + return nil, f.err + } + return io.NopCloser(bytes.NewReader(f.body)), nil +} + +func captureEvents(t *testing.T) (output.Sink, func() []any) { + t.Helper() + var events []any + sink := output.SinkFunc(func(event any) { + events = append(events, event) + }) + return sink, func() []any { return events } +} + +func healthyRunningMock(t *testing.T) *runtime.MockRuntime { + t.Helper() + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsHealthy(gomock.Any()).Return(nil) + mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(true, nil) + return mockRT +} + +var awsContainers = []config.ContainerConfig{{Type: config.EmulatorAWS}} + +func TestSave_Success(t *testing.T) { + dir := t.TempDir() + dest := snapshot.LocalDestination{Path: filepath.Join(dir, "snap")} + exporter := &fakeExporter{body: []byte("ZIP_DATA")} + sink, getEvents := captureEvents(t) + + err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, dest, sink) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "snap")) + require.NoError(t, err) + assert.Equal(t, "ZIP_DATA", string(data)) + + events := getEvents() + require.NotEmpty(t, events) + + var spinnerStarted, spinnerStopped, succeeded bool + for _, e := range events { + switch ev := e.(type) { + case output.SpinnerEvent: + if ev.Active { + spinnerStarted = true + } else { + spinnerStopped = true + } + case output.MessageEvent: + if ev.Severity == output.SeveritySuccess { + succeeded = true + assert.Contains(t, ev.Text, dest.Path) + } + } + } + assert.True(t, spinnerStarted, "spinner should have started") + assert.True(t, spinnerStopped, "spinner should have stopped") + assert.True(t, succeeded, "success event should have been emitted") +} + +func TestSave_EmulatorNotRunning(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsHealthy(gomock.Any()).Return(nil) + mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(false, nil) + mockRT.EXPECT().FindRunningByImage(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil) + + dir := t.TempDir() + dest := snapshot.LocalDestination{Path: filepath.Join(dir, "snap")} + sink, getEvents := captureEvents(t) + + err := snapshot.Save(context.Background(), mockRT, awsContainers, &fakeExporter{body: []byte("x")}, dest, sink) + require.Error(t, err) + assert.True(t, output.IsSilent(err)) + + var gotErrorEvent bool + for _, e := range getEvents() { + if ev, ok := e.(output.ErrorEvent); ok { + gotErrorEvent = true + assert.Contains(t, ev.Title, "not running") + assert.NotEmpty(t, ev.Actions) + } + } + assert.True(t, gotErrorEvent, "ErrorEvent should have been emitted") + + _, statErr := os.Stat(filepath.Join(dir, "snap")) + assert.True(t, os.IsNotExist(statErr), "no file should be created when emulator is not running") +} + +func TestSave_UnhealthyRuntime(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsHealthy(gomock.Any()).Return(fmt.Errorf("docker unavailable")) + mockRT.EXPECT().EmitUnhealthyError(gomock.Any(), gomock.Any()) + + dir := t.TempDir() + dest := snapshot.LocalDestination{Path: filepath.Join(dir, "snap")} + sink := output.NewPlainSink(io.Discard) + + err := snapshot.Save(context.Background(), mockRT, awsContainers, &fakeExporter{}, dest, sink) + require.Error(t, err) + assert.True(t, output.IsSilent(err)) +} + +func TestSave_ExporterError(t *testing.T) { + dir := t.TempDir() + dest := snapshot.LocalDestination{Path: filepath.Join(dir, "snap")} + exporter := &fakeExporter{err: fmt.Errorf("connection refused")} + sink := output.NewPlainSink(io.Discard) + + err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, dest, sink) + require.Error(t, err) + assert.Contains(t, err.Error(), "connection refused") + + _, statErr := os.Stat(filepath.Join(dir, "snap")) + assert.True(t, os.IsNotExist(statErr), "no file should be created on exporter error") +} + +func TestSave_DestinationDirNotExist(t *testing.T) { + dest := snapshot.LocalDestination{Path: "/no/such/dir/snap"} + exporter := &fakeExporter{body: []byte("ZIP_DATA")} + sink := output.NewPlainSink(io.Discard) + + err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, dest, sink) + require.Error(t, err) + assert.Contains(t, err.Error(), "open destination") +} + +func TestSave_OverwritesExistingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "snap") + require.NoError(t, os.WriteFile(path, []byte("OLD"), 0600)) + + dest := snapshot.LocalDestination{Path: path} + exporter := &fakeExporter{body: []byte("NEW")} + sink := output.NewPlainSink(io.Discard) + + err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, dest, sink) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "NEW", string(data)) +} + +func TestSave_ContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + dir := t.TempDir() + dest := snapshot.LocalDestination{Path: filepath.Join(dir, "snap")} + exporter := &fakeExporter{err: ctx.Err()} + + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsHealthy(gomock.Any()).Return(nil) + mockRT.EXPECT().IsRunning(gomock.Any(), gomock.Any()).Return(true, nil) + + sink := output.NewPlainSink(io.Discard) + + err := snapshot.Save(ctx, mockRT, awsContainers, exporter, dest, sink) + require.Error(t, err) +} diff --git a/internal/ui/run_snapshot_save.go b/internal/ui/run_snapshot_save.go new file mode 100644 index 00000000..d5500de6 --- /dev/null +++ b/internal/ui/run_snapshot_save.go @@ -0,0 +1,16 @@ +package ui + +import ( + "context" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" + "github.com/localstack/lstk/internal/snapshot" +) + +func RunSnapshotSave(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter snapshot.StateExporter, dest snapshot.Destination) error { + return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { + return snapshot.Save(ctx, rt, containers, exporter, dest, sink) + }) +} diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go new file mode 100644 index 00000000..d434154d --- /dev/null +++ b/test/integration/snapshot_save_test.go @@ -0,0 +1,246 @@ +package integration_test + +import ( + "archive/zip" + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockStateServer returns a test server that serves a minimal ZIP at /_localstack/pods/state. +func mockStateServer(t *testing.T) *httptest.Server { + t.Helper() + var zipBuf bytes.Buffer + zw := zip.NewWriter(&zipBuf) + f, err := zw.Create("state.json") + require.NoError(t, err) + _, err = f.Write([]byte(`{"services":{}}`)) + require.NoError(t, err) + require.NoError(t, zw.Close()) + zipData := zipBuf.Bytes() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/_localstack/pods/state" { + w.Header().Set("Content-Type", "application/zip") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(zipData) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + return srv +} + +func lsHost(srv *httptest.Server) string { + return strings.TrimPrefix(srv.URL, "http://") +} + +func TestSnapshotSaveDefaultDestination(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv := mockStateServer(t) + dir := t.TempDir() + + stdout, stderr, err := runLstk(t, ctx, dir, + env.With(env.LocalStackHost, lsHost(srv)), + "--non-interactive", "snapshot", "save", + ) + require.NoError(t, err, "lstk snapshot save failed: %s", stderr) + assert.Contains(t, stdout, "Snapshot saved") + + _, statErr := os.Stat(filepath.Join(dir, "ls-state-export")) + assert.NoError(t, statErr, "default output file should exist") +} + +func TestSnapshotSaveCustomPath(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv := mockStateServer(t) + dir := t.TempDir() + outPath := filepath.Join(dir, "my-snap") + + stdout, stderr, err := runLstk(t, ctx, dir, + env.With(env.LocalStackHost, lsHost(srv)), + "--non-interactive", "snapshot", "save", outPath, + ) + require.NoError(t, err, "lstk snapshot save failed: %s", stderr) + assert.Contains(t, stdout, "Snapshot saved") + assert.Contains(t, stdout, outPath) + + data, err := os.ReadFile(outPath) + require.NoError(t, err, "output file should exist") + assert.True(t, len(data) > 0, "output file should be non-empty") + + r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + require.NoError(t, err, "output file should be a valid ZIP") + assert.NotEmpty(t, r.File) +} + +func TestSnapshotSaveRelativePath(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv := mockStateServer(t) + dir := t.TempDir() + + stdout, stderr, err := runLstk(t, ctx, dir, + env.With(env.LocalStackHost, lsHost(srv)), + "--non-interactive", "snapshot", "save", "./my-state", + ) + require.NoError(t, err, "lstk snapshot save failed: %s", stderr) + assert.Contains(t, stdout, "Snapshot saved") + + _, statErr := os.Stat(filepath.Join(dir, "my-state")) + assert.NoError(t, statErr, "relative output file should exist") +} + +func TestSnapshotSaveOverwritesExistingFile(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv := mockStateServer(t) + dir := t.TempDir() + outPath := filepath.Join(dir, "snap") + require.NoError(t, os.WriteFile(outPath, []byte("OLD"), 0600)) + + _, stderr, err := runLstk(t, ctx, dir, + env.With(env.LocalStackHost, lsHost(srv)), + "--non-interactive", "snapshot", "save", outPath, + ) + require.NoError(t, err, "lstk snapshot save should overwrite: %s", stderr) + + data, err := os.ReadFile(outPath) + require.NoError(t, err) + assert.NotEqual(t, "OLD", string(data), "file should have been overwritten") +} + +// TestSnapshotSaveBareNameRejected does not require Docker: destination +// parsing fails before the runtime is ever touched. +func TestSnapshotSaveBareNameRejected(t *testing.T) { + ctx := testContext(t) + dir := t.TempDir() + + _, stderr, err := runLstk(t, ctx, dir, nil, "--non-interactive", "snapshot", "save", "my-pod") + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "not yet supported") + assert.Contains(t, stderr, "./my-snapshot") +} + +// TestSnapshotSaveCloudURIRejected does not require Docker: destination +// parsing fails before the runtime is ever touched. +func TestSnapshotSaveCloudURIRejected(t *testing.T) { + ctx := testContext(t) + dir := t.TempDir() + + _, stderr, err := runLstk(t, ctx, dir, nil, "--non-interactive", "snapshot", "save", "cloud://my-pod") + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "not yet supported") +} + +func TestSnapshotSaveLocalStackNotRunning(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + // Intentionally no startTestContainer: the emulator is not running. + + _, stderr, err := runLstk(t, ctx, t.TempDir(), nil, + "--non-interactive", "snapshot", "save", + ) + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "not running") +} + +func TestSnapshotSaveInvalidParentDir(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv := mockStateServer(t) + + _, stderr, err := runLstk(t, ctx, t.TempDir(), + env.With(env.LocalStackHost, lsHost(srv)), + "--non-interactive", "snapshot", "save", "/no/such/dir/state", + ) + requireExitCode(t, 1, err) + assert.NotEmpty(t, stderr) +} + +func TestSnapshotSaveTelemetryEmitted(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv := mockStateServer(t) + + analyticsSrv, events := mockAnalyticsServer(t) + _, stderr, err := runLstk(t, ctx, t.TempDir(), + env.With(env.LocalStackHost, lsHost(srv)).With(env.AnalyticsEndpoint, analyticsSrv.URL), + "--non-interactive", "snapshot", "save", + ) + require.NoError(t, err, "lstk snapshot save failed: %s", stderr) + assertCommandTelemetry(t, events, "snapshot save", 0) +} + +func TestSnapshotSaveTelemetryOnFailure(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + // No container running → "LocalStack is not running" failure. + + analyticsSrv, events := mockAnalyticsServer(t) + _, _, err := runLstk(t, ctx, t.TempDir(), + env.With(env.AnalyticsEndpoint, analyticsSrv.URL), + "--non-interactive", "snapshot", "save", + ) + requireExitCode(t, 1, err) + assertCommandTelemetry(t, events, "snapshot save", 1) +} + +func TestSnapshotSaveInteractive(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv := mockStateServer(t) + dir := t.TempDir() + + out, err := runLstkInPTY(t, ctx, + env.With(env.LocalStackHost, lsHost(srv)), + "snapshot", "save", filepath.Join(dir, "snap"), + ) + require.NoError(t, err, "interactive lstk snapshot save failed") + assert.Contains(t, out, "Snapshot saved") +} From cff0533c5f34922e7a160c84515c14426a1009fd Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 28 Apr 2026 18:09:00 +0200 Subject: [PATCH 02/26] Absolute path and different error --- internal/snapshot/destination.go | 24 ++++++++++++------ internal/snapshot/destination_test.go | 35 ++++++++++++++++----------- internal/snapshot/save.go | 2 +- internal/snapshot/save_test.go | 2 +- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go index 5c760a7b..ee0a475c 100644 --- a/internal/snapshot/destination.go +++ b/internal/snapshot/destination.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" ) @@ -16,16 +17,25 @@ type Destination interface { // ParseDestination returns a Destination for the user-supplied path, or an error for cloud/bare names. func ParseDestination(dest string) (Destination, error) { if dest == "" { - return LocalDestination{Path: "ls-state-export"}, nil - } - if strings.Contains(dest, "://") { + dest = "ls-state-export" + } else if strings.Contains(dest, "://") { + return nil, fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") + } else if !strings.HasPrefix(dest, ".") && !strings.HasPrefix(dest, "/") && !strings.HasPrefix(dest, "~") && !strings.Contains(dest, "/") { + // bare name with no path separators: reserved for future cloud pod names return nil, fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") } - if strings.HasPrefix(dest, ".") || strings.HasPrefix(dest, "/") || strings.HasPrefix(dest, "~") || strings.Contains(dest, "/") { - return LocalDestination{Path: dest}, nil + if strings.HasPrefix(dest, "~") { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("resolve home directory: %w", err) + } + dest = home + dest[1:] + } + abs, err := filepath.Abs(dest) + if err != nil { + return nil, fmt.Errorf("resolve path: %w", err) } - // bare name with no path separators: reserved for future cloud pod names - return nil, fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") + return LocalDestination{Path: abs}, nil } // LocalDestination writes snapshot state to a local file. diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go index 1595efc6..10d80386 100644 --- a/internal/snapshot/destination_test.go +++ b/internal/snapshot/destination_test.go @@ -1,6 +1,8 @@ package snapshot_test import ( + "os" + "path/filepath" "testing" "github.com/localstack/lstk/internal/snapshot" @@ -9,30 +11,35 @@ import ( ) func TestParseDestination(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + home, err := os.UserHomeDir() + require.NoError(t, err) + tests := []struct { - input string - want snapshot.Destination - wantErr string + input string + wantPath string + wantErr string }{ { - input: "", - want: snapshot.LocalDestination{Path: "ls-state-export"}, + input: "", + wantPath: filepath.Join(wd, "ls-state-export"), }, { - input: "./my-state", - want: snapshot.LocalDestination{Path: "./my-state"}, + input: "./my-state", + wantPath: filepath.Join(wd, "my-state"), }, { - input: "/tmp/state", - want: snapshot.LocalDestination{Path: "/tmp/state"}, + input: "/tmp/state", + wantPath: "/tmp/state", }, { - input: "~/snapshots/s", - want: snapshot.LocalDestination{Path: "~/snapshots/s"}, + input: "~/snapshots/s", + wantPath: filepath.Join(home, "snapshots/s"), }, { - input: "subdir/state", - want: snapshot.LocalDestination{Path: "subdir/state"}, + input: "subdir/state", + wantPath: filepath.Join(wd, "subdir/state"), }, { input: "my-pod", @@ -58,7 +65,7 @@ func TestParseDestination(t *testing.T) { return } require.NoError(t, err) - assert.Equal(t, tc.want, got) + assert.Equal(t, snapshot.LocalDestination{Path: tc.wantPath}, got) }) } } diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go index 141e1bb3..8e2b8e45 100644 --- a/internal/snapshot/save.go +++ b/internal/snapshot/save.go @@ -45,7 +45,7 @@ func Save(ctx context.Context, rt runtime.Runtime, containers []config.Container w, err := dest.Writer() if err != nil { output.EmitSpinnerStop(sink) - return fmt.Errorf("open destination %s: %w", dest, err) + return fmt.Errorf("save to %s: %w", dest, err) } if _, err := io.Copy(w, body); err != nil { diff --git a/internal/snapshot/save_test.go b/internal/snapshot/save_test.go index 2cb3ffd0..3cddf990 100644 --- a/internal/snapshot/save_test.go +++ b/internal/snapshot/save_test.go @@ -153,7 +153,7 @@ func TestSave_DestinationDirNotExist(t *testing.T) { err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, dest, sink) require.Error(t, err) - assert.Contains(t, err.Error(), "open destination") + assert.Contains(t, err.Error(), "save to") } func TestSave_OverwritesExistingFile(t *testing.T) { From 21682bbc58690c5c0ac2c8418367c751044e969f Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 29 Apr 2026 17:21:09 +0200 Subject: [PATCH 03/26] snapshot only works for aws --- cmd/snapshot.go | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 9d25f1a8..62c44d41 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "slices" "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/endpoint" @@ -50,27 +51,35 @@ Cloud destinations are not yet supported.`, return err } - rt, err := runtime.NewDockerRuntime(cfg.DockerHost) - if err != nil { - return err - } - appConfig, err := config.Get() if err != nil { return fmt.Errorf("failed to get config: %w", err) } - if len(appConfig.Containers) == 0 { - return fmt.Errorf("no emulator configured") + + hasAWS := slices.ContainsFunc(appConfig.Containers, func(c config.ContainerConfig) bool { + return c.Type == config.EmulatorAWS + }) + hasOther := slices.ContainsFunc(appConfig.Containers, func(c config.ContainerConfig) bool { + return c.Type != config.EmulatorAWS + }) + if !hasAWS && hasOther { + return fmt.Errorf("snapshot is only supported for the AWS emulator") + } + + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) + if err != nil { + return err } - c := appConfig.Containers[0] - host, _ := endpoint.ResolveHost(c.Port, cfg.LocalStackHost) + awsContainer := config.ContainerConfig{Type: config.EmulatorAWS, Port: config.DefaultAWSPort} + host, _ := endpoint.ResolveHost(awsContainer.Port, cfg.LocalStackHost) exporter := snapshot.NewStateClient("http://" + host) + containers := []config.ContainerConfig{awsContainer} if isInteractiveMode(cfg) { - return ui.RunSnapshotSave(cmd.Context(), rt, appConfig.Containers, exporter, dest) + return ui.RunSnapshotSave(cmd.Context(), rt, containers, exporter, dest) } - return snapshot.Save(cmd.Context(), rt, appConfig.Containers, exporter, dest, output.NewPlainSinkSplit(os.Stdout, os.Stderr)) + return snapshot.Save(cmd.Context(), rt, containers, exporter, dest, output.NewPlainSinkSplit(os.Stdout, os.Stderr)) }), } } From ea992e4cd44fe5c4ccfed9523327c3ee75c12b69 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 29 Apr 2026 17:28:35 +0200 Subject: [PATCH 04/26] OS-agnostic destination check --- internal/snapshot/destination.go | 2 +- internal/snapshot/destination_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go index ee0a475c..be776a64 100644 --- a/internal/snapshot/destination.go +++ b/internal/snapshot/destination.go @@ -20,7 +20,7 @@ func ParseDestination(dest string) (Destination, error) { dest = "ls-state-export" } else if strings.Contains(dest, "://") { return nil, fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") - } else if !strings.HasPrefix(dest, ".") && !strings.HasPrefix(dest, "/") && !strings.HasPrefix(dest, "~") && !strings.Contains(dest, "/") { + } else if !strings.HasPrefix(dest, ".") && !strings.HasPrefix(dest, "~") && !filepath.IsAbs(dest) && filepath.Base(dest) == dest { // bare name with no path separators: reserved for future cloud pod names return nil, fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") } diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go index 10d80386..d90ef047 100644 --- a/internal/snapshot/destination_test.go +++ b/internal/snapshot/destination_test.go @@ -30,16 +30,16 @@ func TestParseDestination(t *testing.T) { wantPath: filepath.Join(wd, "my-state"), }, { - input: "/tmp/state", - wantPath: "/tmp/state", + input: filepath.Join(os.TempDir(), "state"), + wantPath: filepath.Join(os.TempDir(), "state"), }, { input: "~/snapshots/s", - wantPath: filepath.Join(home, "snapshots/s"), + wantPath: filepath.Join(home, "snapshots", "s"), }, { input: "subdir/state", - wantPath: filepath.Join(wd, "subdir/state"), + wantPath: filepath.Join(wd, "subdir", "state"), }, { input: "my-pod", From 208e1821f1123282f61280c0a251975c7753d74b Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 29 Apr 2026 18:39:13 +0200 Subject: [PATCH 05/26] Drop needless interface --- internal/snapshot/destination.go | 35 ++++++--------------------- internal/snapshot/destination_test.go | 2 +- internal/snapshot/save.go | 5 ++-- internal/snapshot/save_test.go | 16 ++++++------ internal/ui/run_snapshot_save.go | 2 +- 5 files changed, 21 insertions(+), 39 deletions(-) diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go index be776a64..60565d62 100644 --- a/internal/snapshot/destination.go +++ b/internal/snapshot/destination.go @@ -2,51 +2,32 @@ package snapshot import ( "fmt" - "io" "os" "path/filepath" "strings" ) -// Destination is where snapshot state is written. -type Destination interface { - Writer() (io.WriteCloser, error) - String() string -} - -// ParseDestination returns a Destination for the user-supplied path, or an error for cloud/bare names. -func ParseDestination(dest string) (Destination, error) { +// ParseDestination resolves the user-supplied path to an absolute local path, +// or returns an error for cloud/bare names. +func ParseDestination(dest string) (string, error) { if dest == "" { dest = "ls-state-export" } else if strings.Contains(dest, "://") { - return nil, fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") + return "", fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") } else if !strings.HasPrefix(dest, ".") && !strings.HasPrefix(dest, "~") && !filepath.IsAbs(dest) && filepath.Base(dest) == dest { // bare name with no path separators: reserved for future cloud pod names - return nil, fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") + return "", fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") } if strings.HasPrefix(dest, "~") { home, err := os.UserHomeDir() if err != nil { - return nil, fmt.Errorf("resolve home directory: %w", err) + return "", fmt.Errorf("resolve home directory: %w", err) } dest = home + dest[1:] } abs, err := filepath.Abs(dest) if err != nil { - return nil, fmt.Errorf("resolve path: %w", err) + return "", fmt.Errorf("resolve path: %w", err) } - return LocalDestination{Path: abs}, nil -} - -// LocalDestination writes snapshot state to a local file. -type LocalDestination struct { - Path string -} - -func (d LocalDestination) Writer() (io.WriteCloser, error) { - return os.Create(d.Path) -} - -func (d LocalDestination) String() string { - return d.Path + return abs, nil } diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go index d90ef047..ba1f6a82 100644 --- a/internal/snapshot/destination_test.go +++ b/internal/snapshot/destination_test.go @@ -65,7 +65,7 @@ func TestParseDestination(t *testing.T) { return } require.NoError(t, err) - assert.Equal(t, snapshot.LocalDestination{Path: tc.wantPath}, got) + assert.Equal(t, tc.wantPath, got) }) } } diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go index 8e2b8e45..60fa42cd 100644 --- a/internal/snapshot/save.go +++ b/internal/snapshot/save.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "os" "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/container" @@ -12,7 +13,7 @@ import ( ) // Save exports the emulator's state via exporter and writes it to dest. -func Save(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter StateExporter, dest Destination, sink output.Sink) error { +func Save(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter StateExporter, dest string, sink output.Sink) error { if err := rt.IsHealthy(ctx); err != nil { rt.EmitUnhealthyError(sink, err) return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) @@ -42,7 +43,7 @@ func Save(ctx context.Context, rt runtime.Runtime, containers []config.Container } defer func() { _ = body.Close() }() - w, err := dest.Writer() + w, err := os.Create(dest) if err != nil { output.EmitSpinnerStop(sink) return fmt.Errorf("save to %s: %w", dest, err) diff --git a/internal/snapshot/save_test.go b/internal/snapshot/save_test.go index 3cddf990..5fffad94 100644 --- a/internal/snapshot/save_test.go +++ b/internal/snapshot/save_test.go @@ -53,7 +53,7 @@ var awsContainers = []config.ContainerConfig{{Type: config.EmulatorAWS}} func TestSave_Success(t *testing.T) { dir := t.TempDir() - dest := snapshot.LocalDestination{Path: filepath.Join(dir, "snap")} + dest := filepath.Join(dir, "snap") exporter := &fakeExporter{body: []byte("ZIP_DATA")} sink, getEvents := captureEvents(t) @@ -79,7 +79,7 @@ func TestSave_Success(t *testing.T) { case output.MessageEvent: if ev.Severity == output.SeveritySuccess { succeeded = true - assert.Contains(t, ev.Text, dest.Path) + assert.Contains(t, ev.Text, dest) } } } @@ -96,7 +96,7 @@ func TestSave_EmulatorNotRunning(t *testing.T) { mockRT.EXPECT().FindRunningByImage(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil) dir := t.TempDir() - dest := snapshot.LocalDestination{Path: filepath.Join(dir, "snap")} + dest := filepath.Join(dir, "snap") sink, getEvents := captureEvents(t) err := snapshot.Save(context.Background(), mockRT, awsContainers, &fakeExporter{body: []byte("x")}, dest, sink) @@ -124,7 +124,7 @@ func TestSave_UnhealthyRuntime(t *testing.T) { mockRT.EXPECT().EmitUnhealthyError(gomock.Any(), gomock.Any()) dir := t.TempDir() - dest := snapshot.LocalDestination{Path: filepath.Join(dir, "snap")} + dest := filepath.Join(dir, "snap") sink := output.NewPlainSink(io.Discard) err := snapshot.Save(context.Background(), mockRT, awsContainers, &fakeExporter{}, dest, sink) @@ -134,7 +134,7 @@ func TestSave_UnhealthyRuntime(t *testing.T) { func TestSave_ExporterError(t *testing.T) { dir := t.TempDir() - dest := snapshot.LocalDestination{Path: filepath.Join(dir, "snap")} + dest := filepath.Join(dir, "snap") exporter := &fakeExporter{err: fmt.Errorf("connection refused")} sink := output.NewPlainSink(io.Discard) @@ -147,7 +147,7 @@ func TestSave_ExporterError(t *testing.T) { } func TestSave_DestinationDirNotExist(t *testing.T) { - dest := snapshot.LocalDestination{Path: "/no/such/dir/snap"} + dest := "/no/such/dir/snap" exporter := &fakeExporter{body: []byte("ZIP_DATA")} sink := output.NewPlainSink(io.Discard) @@ -161,7 +161,7 @@ func TestSave_OverwritesExistingFile(t *testing.T) { path := filepath.Join(dir, "snap") require.NoError(t, os.WriteFile(path, []byte("OLD"), 0600)) - dest := snapshot.LocalDestination{Path: path} + dest := path exporter := &fakeExporter{body: []byte("NEW")} sink := output.NewPlainSink(io.Discard) @@ -178,7 +178,7 @@ func TestSave_ContextCancelled(t *testing.T) { cancel() dir := t.TempDir() - dest := snapshot.LocalDestination{Path: filepath.Join(dir, "snap")} + dest := filepath.Join(dir, "snap") exporter := &fakeExporter{err: ctx.Err()} ctrl := gomock.NewController(t) diff --git a/internal/ui/run_snapshot_save.go b/internal/ui/run_snapshot_save.go index d5500de6..f1264a08 100644 --- a/internal/ui/run_snapshot_save.go +++ b/internal/ui/run_snapshot_save.go @@ -9,7 +9,7 @@ import ( "github.com/localstack/lstk/internal/snapshot" ) -func RunSnapshotSave(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter snapshot.StateExporter, dest snapshot.Destination) error { +func RunSnapshotSave(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter snapshot.StateExporter, dest string) error { return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { return snapshot.Save(ctx, rt, containers, exporter, dest, sink) }) From 9d04c57525de10e84ea86da1b0b7f93717577c16 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 29 Apr 2026 18:45:08 +0200 Subject: [PATCH 06/26] Handle conflicts --- cmd/root.go | 4 ++-- cmd/snapshot.go | 11 +++++------ internal/snapshot/save.go | 16 ++++++++-------- internal/snapshot/save_test.go | 8 ++++---- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index d4d96235..909203e4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -77,8 +77,8 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newVolumeCmd(cfg), newUpdateCmd(cfg), newDocsCmd(), - newAWSCmd(cfg, tel), - newSnapshotCmd(cfg, tel), + newAWSCmd(cfg), + newSnapshotCmd(cfg), ) return root diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 62c44d41..ffc91d71 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -11,21 +11,20 @@ import ( "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/runtime" "github.com/localstack/lstk/internal/snapshot" - "github.com/localstack/lstk/internal/telemetry" "github.com/localstack/lstk/internal/ui" "github.com/spf13/cobra" ) -func newSnapshotCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { +func newSnapshotCmd(cfg *env.Env) *cobra.Command { cmd := &cobra.Command{ Use: "snapshot", Short: "Manage emulator snapshots", } - cmd.AddCommand(newSnapshotSaveCmd(cfg, tel)) + cmd.AddCommand(newSnapshotSaveCmd(cfg)) return cmd } -func newSnapshotSaveCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { +func newSnapshotSaveCmd(cfg *env.Env) *cobra.Command { return &cobra.Command{ Use: "save [destination]", Short: "Save a snapshot of the emulator state", @@ -40,7 +39,7 @@ The destination must be a file path. Use a path prefix to save locally: Cloud destinations are not yet supported.`, Args: cobra.MaximumNArgs(1), PreRunE: initConfig, - RunE: commandWithTelemetry("snapshot save", tel, func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { var destArg string if len(args) > 0 { destArg = args[0] @@ -80,6 +79,6 @@ Cloud destinations are not yet supported.`, return ui.RunSnapshotSave(cmd.Context(), rt, containers, exporter, dest) } return snapshot.Save(cmd.Context(), rt, containers, exporter, dest, output.NewPlainSinkSplit(os.Stdout, os.Stderr)) - }), + }, } } diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go index 60fa42cd..0dd70d29 100644 --- a/internal/snapshot/save.go +++ b/internal/snapshot/save.go @@ -24,7 +24,7 @@ func Save(ctx context.Context, rt runtime.Runtime, containers []config.Container return fmt.Errorf("checking emulator status: %w", err) } if !running { - output.EmitError(sink, output.ErrorEvent{ + sink.Emit(output.ErrorEvent{ Title: "LocalStack is not running", Actions: []output.ErrorAction{ {Label: "Start LocalStack:", Value: "lstk"}, @@ -34,33 +34,33 @@ func Save(ctx context.Context, rt runtime.Runtime, containers []config.Container return output.NewSilentError(fmt.Errorf("LocalStack is not running")) } - output.EmitSpinnerStart(sink, "Saving snapshot...") + sink.Emit(output.SpinnerStart("Saving snapshot...")) body, err := exporter.ExportState(ctx) if err != nil { - output.EmitSpinnerStop(sink) + sink.Emit(output.SpinnerStop()) return fmt.Errorf("export state from LocalStack: %w", err) } defer func() { _ = body.Close() }() w, err := os.Create(dest) if err != nil { - output.EmitSpinnerStop(sink) + sink.Emit(output.SpinnerStop()) return fmt.Errorf("save to %s: %w", dest, err) } if _, err := io.Copy(w, body); err != nil { _ = w.Close() - output.EmitSpinnerStop(sink) + sink.Emit(output.SpinnerStop()) return fmt.Errorf("write snapshot: %w", err) } if err := w.Close(); err != nil { - output.EmitSpinnerStop(sink) + sink.Emit(output.SpinnerStop()) return fmt.Errorf("close snapshot: %w", err) } - output.EmitSpinnerStop(sink) - output.EmitSuccess(sink, fmt.Sprintf("Snapshot saved to %s", dest)) + sink.Emit(output.SpinnerStop()) + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Snapshot saved to %s", dest)}) return nil } diff --git a/internal/snapshot/save_test.go b/internal/snapshot/save_test.go index 5fffad94..f636cacf 100644 --- a/internal/snapshot/save_test.go +++ b/internal/snapshot/save_test.go @@ -31,13 +31,13 @@ func (f *fakeExporter) ExportState(_ context.Context) (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(f.body)), nil } -func captureEvents(t *testing.T) (output.Sink, func() []any) { +func captureEvents(t *testing.T) (output.Sink, func() []output.Event) { t.Helper() - var events []any - sink := output.SinkFunc(func(event any) { + var events []output.Event + sink := output.SinkFunc(func(event output.Event) { events = append(events, event) }) - return sink, func() []any { return events } + return sink, func() []output.Event { return events } } func healthyRunningMock(t *testing.T) *runtime.MockRuntime { From 3d91a28d087e8453cfa083addae77b50cf168d4f Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 29 Apr 2026 18:58:03 +0200 Subject: [PATCH 07/26] Drop 'snapshot' subcommand in favor of simplicity --- cmd/root.go | 2 +- cmd/snapshot.go | 21 ++++---------- test/integration/snapshot_save_test.go | 38 +++++++++++++------------- 3 files changed, 26 insertions(+), 35 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 909203e4..82e49df3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -78,7 +78,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newUpdateCmd(cfg), newDocsCmd(), newAWSCmd(cfg), - newSnapshotCmd(cfg), + newSnapshotSaveCmd(cfg), ) return root diff --git a/cmd/snapshot.go b/cmd/snapshot.go index ffc91d71..9bcb878f 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -15,26 +15,17 @@ import ( "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 to a local file. + Short: "Save emulator state to a file", + Long: `Save the running emulator's state to a local file. The destination must be a file path. Use a path prefix to save locally: - lstk snapshot save # saves to ./ls-state-export - lstk snapshot save ./my-snapshot # saves to ./my-snapshot - lstk snapshot save /tmp/my-state # saves to /tmp/my-state + lstk save # saves to ./ls-state-export + lstk save ./my-snapshot # saves to ./my-snapshot + lstk save /tmp/my-state # saves to /tmp/my-state Cloud destinations are not yet supported.`, Args: cobra.MaximumNArgs(1), @@ -62,7 +53,7 @@ Cloud destinations are not yet supported.`, return c.Type != config.EmulatorAWS }) if !hasAWS && hasOther { - return fmt.Errorf("snapshot is only supported for the AWS emulator") + return fmt.Errorf("save is only supported for the AWS emulator") } rt, err := runtime.NewDockerRuntime(cfg.DockerHost) diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index d434154d..b91525dd 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -56,9 +56,9 @@ func TestSnapshotSaveDefaultDestination(t *testing.T) { stdout, stderr, err := runLstk(t, ctx, dir, env.With(env.LocalStackHost, lsHost(srv)), - "--non-interactive", "snapshot", "save", + "--non-interactive", "save", ) - require.NoError(t, err, "lstk snapshot save failed: %s", stderr) + require.NoError(t, err, "lstk save failed: %s", stderr) assert.Contains(t, stdout, "Snapshot saved") _, statErr := os.Stat(filepath.Join(dir, "ls-state-export")) @@ -78,9 +78,9 @@ func TestSnapshotSaveCustomPath(t *testing.T) { stdout, stderr, err := runLstk(t, ctx, dir, env.With(env.LocalStackHost, lsHost(srv)), - "--non-interactive", "snapshot", "save", outPath, + "--non-interactive", "save", outPath, ) - require.NoError(t, err, "lstk snapshot save failed: %s", stderr) + require.NoError(t, err, "lstk save failed: %s", stderr) assert.Contains(t, stdout, "Snapshot saved") assert.Contains(t, stdout, outPath) @@ -105,9 +105,9 @@ func TestSnapshotSaveRelativePath(t *testing.T) { stdout, stderr, err := runLstk(t, ctx, dir, env.With(env.LocalStackHost, lsHost(srv)), - "--non-interactive", "snapshot", "save", "./my-state", + "--non-interactive", "save", "./my-state", ) - require.NoError(t, err, "lstk snapshot save failed: %s", stderr) + require.NoError(t, err, "lstk save failed: %s", stderr) assert.Contains(t, stdout, "Snapshot saved") _, statErr := os.Stat(filepath.Join(dir, "my-state")) @@ -128,9 +128,9 @@ func TestSnapshotSaveOverwritesExistingFile(t *testing.T) { _, stderr, err := runLstk(t, ctx, dir, env.With(env.LocalStackHost, lsHost(srv)), - "--non-interactive", "snapshot", "save", outPath, + "--non-interactive", "save", outPath, ) - require.NoError(t, err, "lstk snapshot save should overwrite: %s", stderr) + require.NoError(t, err, "lstk save should overwrite: %s", stderr) data, err := os.ReadFile(outPath) require.NoError(t, err) @@ -143,7 +143,7 @@ func TestSnapshotSaveBareNameRejected(t *testing.T) { ctx := testContext(t) dir := t.TempDir() - _, stderr, err := runLstk(t, ctx, dir, nil, "--non-interactive", "snapshot", "save", "my-pod") + _, stderr, err := runLstk(t, ctx, dir, nil, "--non-interactive", "save", "my-pod") requireExitCode(t, 1, err) assert.Contains(t, stderr, "not yet supported") assert.Contains(t, stderr, "./my-snapshot") @@ -155,7 +155,7 @@ func TestSnapshotSaveCloudURIRejected(t *testing.T) { ctx := testContext(t) dir := t.TempDir() - _, stderr, err := runLstk(t, ctx, dir, nil, "--non-interactive", "snapshot", "save", "cloud://my-pod") + _, stderr, err := runLstk(t, ctx, dir, nil, "--non-interactive", "save", "cloud://my-pod") requireExitCode(t, 1, err) assert.Contains(t, stderr, "not yet supported") } @@ -169,7 +169,7 @@ func TestSnapshotSaveLocalStackNotRunning(t *testing.T) { // Intentionally no startTestContainer: the emulator is not running. _, stderr, err := runLstk(t, ctx, t.TempDir(), nil, - "--non-interactive", "snapshot", "save", + "--non-interactive", "save", ) requireExitCode(t, 1, err) assert.Contains(t, stderr, "not running") @@ -186,7 +186,7 @@ func TestSnapshotSaveInvalidParentDir(t *testing.T) { _, stderr, err := runLstk(t, ctx, t.TempDir(), env.With(env.LocalStackHost, lsHost(srv)), - "--non-interactive", "snapshot", "save", "/no/such/dir/state", + "--non-interactive", "save", "/no/such/dir/state", ) requireExitCode(t, 1, err) assert.NotEmpty(t, stderr) @@ -204,10 +204,10 @@ func TestSnapshotSaveTelemetryEmitted(t *testing.T) { analyticsSrv, events := mockAnalyticsServer(t) _, stderr, err := runLstk(t, ctx, t.TempDir(), env.With(env.LocalStackHost, lsHost(srv)).With(env.AnalyticsEndpoint, analyticsSrv.URL), - "--non-interactive", "snapshot", "save", + "--non-interactive", "save", ) - require.NoError(t, err, "lstk snapshot save failed: %s", stderr) - assertCommandTelemetry(t, events, "snapshot save", 0) + require.NoError(t, err, "lstk save failed: %s", stderr) + assertCommandTelemetry(t, events, "save", 0) } func TestSnapshotSaveTelemetryOnFailure(t *testing.T) { @@ -221,10 +221,10 @@ func TestSnapshotSaveTelemetryOnFailure(t *testing.T) { analyticsSrv, events := mockAnalyticsServer(t) _, _, err := runLstk(t, ctx, t.TempDir(), env.With(env.AnalyticsEndpoint, analyticsSrv.URL), - "--non-interactive", "snapshot", "save", + "--non-interactive", "save", ) requireExitCode(t, 1, err) - assertCommandTelemetry(t, events, "snapshot save", 1) + assertCommandTelemetry(t, events, "save", 1) } func TestSnapshotSaveInteractive(t *testing.T) { @@ -239,8 +239,8 @@ func TestSnapshotSaveInteractive(t *testing.T) { out, err := runLstkInPTY(t, ctx, env.With(env.LocalStackHost, lsHost(srv)), - "snapshot", "save", filepath.Join(dir, "snap"), + "save", filepath.Join(dir, "snap"), ) - require.NoError(t, err, "interactive lstk snapshot save failed") + require.NoError(t, err, "interactive lstk save failed") assert.Contains(t, out, "Snapshot saved") } From 84b52ba9ecac1dae2b397fab50e7bc17d40a2e0a Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Fri, 8 May 2026 17:51:19 +0200 Subject: [PATCH 08/26] Restore 'snapshot' command in favor of clarity --- cmd/root.go | 2 +- cmd/snapshot.go | 21 ++++++++++---- test/integration/snapshot_save_test.go | 38 +++++++++++++------------- 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 82e49df3..909203e4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -78,7 +78,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newUpdateCmd(cfg), newDocsCmd(), newAWSCmd(cfg), - newSnapshotSaveCmd(cfg), + newSnapshotCmd(cfg), ) return root diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 9bcb878f..ffc91d71 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -15,17 +15,26 @@ import ( "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 emulator state to a file", - Long: `Save the running emulator's state to a local file. + Short: "Save a snapshot of the emulator state", + Long: `Save a snapshot of the running emulator's state to a local file. The destination must be a file path. Use a path prefix to save locally: - lstk save # saves to ./ls-state-export - lstk save ./my-snapshot # saves to ./my-snapshot - lstk save /tmp/my-state # saves to /tmp/my-state + lstk snapshot save # saves to ./ls-state-export + lstk snapshot save ./my-snapshot # saves to ./my-snapshot + lstk snapshot save /tmp/my-state # saves to /tmp/my-state Cloud destinations are not yet supported.`, Args: cobra.MaximumNArgs(1), @@ -53,7 +62,7 @@ Cloud destinations are not yet supported.`, return c.Type != config.EmulatorAWS }) if !hasAWS && hasOther { - return fmt.Errorf("save is only supported for the AWS emulator") + return fmt.Errorf("snapshot is only supported for the AWS emulator") } rt, err := runtime.NewDockerRuntime(cfg.DockerHost) diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index b91525dd..d434154d 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -56,9 +56,9 @@ func TestSnapshotSaveDefaultDestination(t *testing.T) { stdout, stderr, err := runLstk(t, ctx, dir, env.With(env.LocalStackHost, lsHost(srv)), - "--non-interactive", "save", + "--non-interactive", "snapshot", "save", ) - require.NoError(t, err, "lstk save failed: %s", stderr) + require.NoError(t, err, "lstk snapshot save failed: %s", stderr) assert.Contains(t, stdout, "Snapshot saved") _, statErr := os.Stat(filepath.Join(dir, "ls-state-export")) @@ -78,9 +78,9 @@ func TestSnapshotSaveCustomPath(t *testing.T) { stdout, stderr, err := runLstk(t, ctx, dir, env.With(env.LocalStackHost, lsHost(srv)), - "--non-interactive", "save", outPath, + "--non-interactive", "snapshot", "save", outPath, ) - require.NoError(t, err, "lstk save failed: %s", stderr) + require.NoError(t, err, "lstk snapshot save failed: %s", stderr) assert.Contains(t, stdout, "Snapshot saved") assert.Contains(t, stdout, outPath) @@ -105,9 +105,9 @@ func TestSnapshotSaveRelativePath(t *testing.T) { stdout, stderr, err := runLstk(t, ctx, dir, env.With(env.LocalStackHost, lsHost(srv)), - "--non-interactive", "save", "./my-state", + "--non-interactive", "snapshot", "save", "./my-state", ) - require.NoError(t, err, "lstk save failed: %s", stderr) + require.NoError(t, err, "lstk snapshot save failed: %s", stderr) assert.Contains(t, stdout, "Snapshot saved") _, statErr := os.Stat(filepath.Join(dir, "my-state")) @@ -128,9 +128,9 @@ func TestSnapshotSaveOverwritesExistingFile(t *testing.T) { _, stderr, err := runLstk(t, ctx, dir, env.With(env.LocalStackHost, lsHost(srv)), - "--non-interactive", "save", outPath, + "--non-interactive", "snapshot", "save", outPath, ) - require.NoError(t, err, "lstk save should overwrite: %s", stderr) + require.NoError(t, err, "lstk snapshot save should overwrite: %s", stderr) data, err := os.ReadFile(outPath) require.NoError(t, err) @@ -143,7 +143,7 @@ func TestSnapshotSaveBareNameRejected(t *testing.T) { ctx := testContext(t) dir := t.TempDir() - _, stderr, err := runLstk(t, ctx, dir, nil, "--non-interactive", "save", "my-pod") + _, stderr, err := runLstk(t, ctx, dir, nil, "--non-interactive", "snapshot", "save", "my-pod") requireExitCode(t, 1, err) assert.Contains(t, stderr, "not yet supported") assert.Contains(t, stderr, "./my-snapshot") @@ -155,7 +155,7 @@ func TestSnapshotSaveCloudURIRejected(t *testing.T) { ctx := testContext(t) dir := t.TempDir() - _, stderr, err := runLstk(t, ctx, dir, nil, "--non-interactive", "save", "cloud://my-pod") + _, stderr, err := runLstk(t, ctx, dir, nil, "--non-interactive", "snapshot", "save", "cloud://my-pod") requireExitCode(t, 1, err) assert.Contains(t, stderr, "not yet supported") } @@ -169,7 +169,7 @@ func TestSnapshotSaveLocalStackNotRunning(t *testing.T) { // Intentionally no startTestContainer: the emulator is not running. _, stderr, err := runLstk(t, ctx, t.TempDir(), nil, - "--non-interactive", "save", + "--non-interactive", "snapshot", "save", ) requireExitCode(t, 1, err) assert.Contains(t, stderr, "not running") @@ -186,7 +186,7 @@ func TestSnapshotSaveInvalidParentDir(t *testing.T) { _, stderr, err := runLstk(t, ctx, t.TempDir(), env.With(env.LocalStackHost, lsHost(srv)), - "--non-interactive", "save", "/no/such/dir/state", + "--non-interactive", "snapshot", "save", "/no/such/dir/state", ) requireExitCode(t, 1, err) assert.NotEmpty(t, stderr) @@ -204,10 +204,10 @@ func TestSnapshotSaveTelemetryEmitted(t *testing.T) { analyticsSrv, events := mockAnalyticsServer(t) _, stderr, err := runLstk(t, ctx, t.TempDir(), env.With(env.LocalStackHost, lsHost(srv)).With(env.AnalyticsEndpoint, analyticsSrv.URL), - "--non-interactive", "save", + "--non-interactive", "snapshot", "save", ) - require.NoError(t, err, "lstk save failed: %s", stderr) - assertCommandTelemetry(t, events, "save", 0) + require.NoError(t, err, "lstk snapshot save failed: %s", stderr) + assertCommandTelemetry(t, events, "snapshot save", 0) } func TestSnapshotSaveTelemetryOnFailure(t *testing.T) { @@ -221,10 +221,10 @@ func TestSnapshotSaveTelemetryOnFailure(t *testing.T) { analyticsSrv, events := mockAnalyticsServer(t) _, _, err := runLstk(t, ctx, t.TempDir(), env.With(env.AnalyticsEndpoint, analyticsSrv.URL), - "--non-interactive", "save", + "--non-interactive", "snapshot", "save", ) requireExitCode(t, 1, err) - assertCommandTelemetry(t, events, "save", 1) + assertCommandTelemetry(t, events, "snapshot save", 1) } func TestSnapshotSaveInteractive(t *testing.T) { @@ -239,8 +239,8 @@ func TestSnapshotSaveInteractive(t *testing.T) { out, err := runLstkInPTY(t, ctx, env.With(env.LocalStackHost, lsHost(srv)), - "save", filepath.Join(dir, "snap"), + "snapshot", "save", filepath.Join(dir, "snap"), ) - require.NoError(t, err, "interactive lstk save failed") + require.NoError(t, err, "interactive lstk snapshot save failed") assert.Contains(t, out, "Snapshot saved") } From 4e05dcf5bec94a18b5503814df2bb4e5ad87920c Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Fri, 8 May 2026 18:01:36 +0200 Subject: [PATCH 09/26] Fix linting & paralellize tests --- cmd/snapshot.go | 2 +- internal/snapshot/client_test.go | 6 ++++++ internal/snapshot/destination_test.go | 2 ++ internal/snapshot/save.go | 4 ++-- internal/snapshot/save_test.go | 7 +++++++ test/integration/snapshot_save_test.go | 6 ++++-- 6 files changed, 22 insertions(+), 5 deletions(-) diff --git a/cmd/snapshot.go b/cmd/snapshot.go index ffc91d71..6638083d 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -38,7 +38,7 @@ The destination must be a file path. Use a path prefix to save locally: Cloud destinations are not yet supported.`, Args: cobra.MaximumNArgs(1), - PreRunE: initConfig, + PreRunE: initConfig(nil), RunE: func(cmd *cobra.Command, args []string) error { var destArg string if len(args) > 0 { diff --git a/internal/snapshot/client_test.go b/internal/snapshot/client_test.go index f62bdae8..c46abab1 100644 --- a/internal/snapshot/client_test.go +++ b/internal/snapshot/client_test.go @@ -14,6 +14,7 @@ import ( ) func TestStateClient_ExportState_OK(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) @@ -33,6 +34,7 @@ func TestStateClient_ExportState_OK(t *testing.T) { } func TestStateClient_ExportState_ServerError(t *testing.T) { + t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) @@ -45,6 +47,7 @@ func TestStateClient_ExportState_ServerError(t *testing.T) { } func TestStateClient_ExportState_NotFound(t *testing.T) { + t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) })) @@ -57,6 +60,7 @@ func TestStateClient_ExportState_NotFound(t *testing.T) { } func TestStateClient_ExportState_ConnectionRefused(t *testing.T) { + t.Parallel() // Bind then immediately close to get a port that refuses connections. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) addr := srv.URL @@ -69,6 +73,7 @@ func TestStateClient_ExportState_ConnectionRefused(t *testing.T) { } func TestStateClient_ExportState_ContextCancelled(t *testing.T) { + t.Parallel() started := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(started) @@ -94,6 +99,7 @@ func TestStateClient_ExportState_ContextCancelled(t *testing.T) { } func TestStateClient_ExportState_LargeBody(t *testing.T) { + t.Parallel() const size = 1 << 20 // 1 MB payload := strings.Repeat("X", size) diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go index ba1f6a82..44ed7e44 100644 --- a/internal/snapshot/destination_test.go +++ b/internal/snapshot/destination_test.go @@ -11,6 +11,7 @@ import ( ) func TestParseDestination(t *testing.T) { + t.Parallel() wd, err := os.Getwd() require.NoError(t, err) home, err := os.UserHomeDir() @@ -57,6 +58,7 @@ func TestParseDestination(t *testing.T) { for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { + t.Parallel() got, err := snapshot.ParseDestination(tc.input) if tc.wantErr != "" { require.Error(t, err) diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go index 0dd70d29..6b87d2a1 100644 --- a/internal/snapshot/save.go +++ b/internal/snapshot/save.go @@ -19,11 +19,11 @@ func Save(ctx context.Context, rt runtime.Runtime, containers []config.Container return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) } - running, err := container.AnyRunning(ctx, rt, containers) + runningContainers, err := container.RunningEmulators(ctx, rt, containers) if err != nil { return fmt.Errorf("checking emulator status: %w", err) } - if !running { + if len(runningContainers) == 0 { sink.Emit(output.ErrorEvent{ Title: "LocalStack is not running", Actions: []output.ErrorAction{ diff --git a/internal/snapshot/save_test.go b/internal/snapshot/save_test.go index f636cacf..4cdc6cdb 100644 --- a/internal/snapshot/save_test.go +++ b/internal/snapshot/save_test.go @@ -52,6 +52,7 @@ func healthyRunningMock(t *testing.T) *runtime.MockRuntime { var awsContainers = []config.ContainerConfig{{Type: config.EmulatorAWS}} func TestSave_Success(t *testing.T) { + t.Parallel() dir := t.TempDir() dest := filepath.Join(dir, "snap") exporter := &fakeExporter{body: []byte("ZIP_DATA")} @@ -89,6 +90,7 @@ func TestSave_Success(t *testing.T) { } func TestSave_EmulatorNotRunning(t *testing.T) { + t.Parallel() ctrl := gomock.NewController(t) mockRT := runtime.NewMockRuntime(ctrl) mockRT.EXPECT().IsHealthy(gomock.Any()).Return(nil) @@ -118,6 +120,7 @@ func TestSave_EmulatorNotRunning(t *testing.T) { } func TestSave_UnhealthyRuntime(t *testing.T) { + t.Parallel() ctrl := gomock.NewController(t) mockRT := runtime.NewMockRuntime(ctrl) mockRT.EXPECT().IsHealthy(gomock.Any()).Return(fmt.Errorf("docker unavailable")) @@ -133,6 +136,7 @@ func TestSave_UnhealthyRuntime(t *testing.T) { } func TestSave_ExporterError(t *testing.T) { + t.Parallel() dir := t.TempDir() dest := filepath.Join(dir, "snap") exporter := &fakeExporter{err: fmt.Errorf("connection refused")} @@ -147,6 +151,7 @@ func TestSave_ExporterError(t *testing.T) { } func TestSave_DestinationDirNotExist(t *testing.T) { + t.Parallel() dest := "/no/such/dir/snap" exporter := &fakeExporter{body: []byte("ZIP_DATA")} sink := output.NewPlainSink(io.Discard) @@ -157,6 +162,7 @@ func TestSave_DestinationDirNotExist(t *testing.T) { } func TestSave_OverwritesExistingFile(t *testing.T) { + t.Parallel() dir := t.TempDir() path := filepath.Join(dir, "snap") require.NoError(t, os.WriteFile(path, []byte("OLD"), 0600)) @@ -174,6 +180,7 @@ func TestSave_OverwritesExistingFile(t *testing.T) { } func TestSave_ContextCancelled(t *testing.T) { + t.Parallel() ctx, cancel := context.WithCancel(context.Background()) cancel() diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index d434154d..684e6cb6 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -140,10 +140,11 @@ func TestSnapshotSaveOverwritesExistingFile(t *testing.T) { // TestSnapshotSaveBareNameRejected does not require Docker: destination // parsing fails before the runtime is ever touched. func TestSnapshotSaveBareNameRejected(t *testing.T) { + t.Parallel() ctx := testContext(t) dir := t.TempDir() - _, stderr, err := runLstk(t, ctx, dir, nil, "--non-interactive", "snapshot", "save", "my-pod") + _, stderr, err := runLstk(t, ctx, dir, testEnvWithHome(t.TempDir(), ""), "--non-interactive", "snapshot", "save", "my-pod") requireExitCode(t, 1, err) assert.Contains(t, stderr, "not yet supported") assert.Contains(t, stderr, "./my-snapshot") @@ -152,10 +153,11 @@ func TestSnapshotSaveBareNameRejected(t *testing.T) { // TestSnapshotSaveCloudURIRejected does not require Docker: destination // parsing fails before the runtime is ever touched. func TestSnapshotSaveCloudURIRejected(t *testing.T) { + t.Parallel() ctx := testContext(t) dir := t.TempDir() - _, stderr, err := runLstk(t, ctx, dir, nil, "--non-interactive", "snapshot", "save", "cloud://my-pod") + _, stderr, err := runLstk(t, ctx, dir, testEnvWithHome(t.TempDir(), ""), "--non-interactive", "snapshot", "save", "cloud://my-pod") requireExitCode(t, 1, err) assert.Contains(t, stderr, "not yet supported") } From 8171252ee537a9e33a0f172baef661b547474662 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Mon, 11 May 2026 14:52:36 +0200 Subject: [PATCH 10/26] Improvements --- cmd/snapshot.go | 17 ++++++++--------- internal/snapshot/destination.go | 4 ++-- internal/snapshot/destination_test.go | 19 +++++++++++++++++-- internal/snapshot/save.go | 20 ++++++++------------ 4 files changed, 35 insertions(+), 25 deletions(-) diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 6638083d..5a0f0fd0 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -55,13 +55,10 @@ Cloud destinations are not yet supported.`, return fmt.Errorf("failed to get config: %w", err) } - hasAWS := slices.ContainsFunc(appConfig.Containers, func(c config.ContainerConfig) bool { + awsIdx := slices.IndexFunc(appConfig.Containers, func(c config.ContainerConfig) bool { return c.Type == config.EmulatorAWS }) - hasOther := slices.ContainsFunc(appConfig.Containers, func(c config.ContainerConfig) bool { - return c.Type != config.EmulatorAWS - }) - if !hasAWS && hasOther { + if awsIdx < 0 && len(appConfig.Containers) > 0 { return fmt.Errorf("snapshot is only supported for the AWS emulator") } @@ -71,14 +68,16 @@ Cloud destinations are not yet supported.`, } awsContainer := config.ContainerConfig{Type: config.EmulatorAWS, Port: config.DefaultAWSPort} - host, _ := endpoint.ResolveHost(awsContainer.Port, cfg.LocalStackHost) + if awsIdx >= 0 { + awsContainer = appConfig.Containers[awsIdx] + } + host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost) exporter := snapshot.NewStateClient("http://" + host) - containers := []config.ContainerConfig{awsContainer} if isInteractiveMode(cfg) { - return ui.RunSnapshotSave(cmd.Context(), rt, containers, exporter, dest) + return ui.RunSnapshotSave(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, dest) } - return snapshot.Save(cmd.Context(), rt, containers, exporter, dest, output.NewPlainSinkSplit(os.Stdout, os.Stderr)) + return snapshot.Save(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, dest, output.NewPlainSinkSplit(os.Stdout, os.Stderr)) }, } } diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go index 60565d62..e537f60d 100644 --- a/internal/snapshot/destination.go +++ b/internal/snapshot/destination.go @@ -18,12 +18,12 @@ func ParseDestination(dest string) (string, error) { // bare name with no path separators: reserved for future cloud pod names return "", fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") } - if strings.HasPrefix(dest, "~") { + if dest == "~" || strings.HasPrefix(dest, "~/") || strings.HasPrefix(dest, `~\`) { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolve home directory: %w", err) } - dest = home + dest[1:] + dest = filepath.Join(home, strings.TrimLeft(dest[1:], `/\`)) } abs, err := filepath.Abs(dest) if err != nil { diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go index 44ed7e44..c148a12a 100644 --- a/internal/snapshot/destination_test.go +++ b/internal/snapshot/destination_test.go @@ -3,6 +3,7 @@ package snapshot_test import ( "os" "path/filepath" + "runtime" "testing" "github.com/localstack/lstk/internal/snapshot" @@ -17,11 +18,13 @@ func TestParseDestination(t *testing.T) { home, err := os.UserHomeDir() require.NoError(t, err) - tests := []struct { + type testCase struct { input string wantPath string wantErr string - }{ + } + + tests := []testCase{ { input: "", wantPath: filepath.Join(wd, "ls-state-export"), @@ -34,6 +37,10 @@ func TestParseDestination(t *testing.T) { input: filepath.Join(os.TempDir(), "state"), wantPath: filepath.Join(os.TempDir(), "state"), }, + { + input: "~", + wantPath: home, + }, { input: "~/snapshots/s", wantPath: filepath.Join(home, "snapshots", "s"), @@ -56,6 +63,14 @@ func TestParseDestination(t *testing.T) { }, } + if runtime.GOOS == "windows" { + tests = append(tests, + testCase{input: `~\snapshots\s`, wantPath: filepath.Join(home, "snapshots", "s")}, + testCase{input: `C:\Users\user\snap`, wantPath: `C:\Users\user\snap`}, + testCase{input: `C:/Users/user/snap`, wantPath: `C:\Users\user\snap`}, + ) + } + for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { t.Parallel() diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go index 6b87d2a1..cc674736 100644 --- a/internal/snapshot/save.go +++ b/internal/snapshot/save.go @@ -13,7 +13,7 @@ import ( ) // Save exports the emulator's state via exporter and writes it to dest. -func Save(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter StateExporter, dest string, sink output.Sink) error { +func Save(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter StateExporter, dest string, sink output.Sink) (retErr error) { if err := rt.IsHealthy(ctx); err != nil { rt.EmitUnhealthyError(sink, err) return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) @@ -35,32 +35,28 @@ func Save(ctx context.Context, rt runtime.Runtime, containers []config.Container } sink.Emit(output.SpinnerStart("Saving snapshot...")) + defer func() { + sink.Emit(output.SpinnerStop()) + if retErr == nil { + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Snapshot saved to %s", dest)}) + } + }() body, err := exporter.ExportState(ctx) if err != nil { - sink.Emit(output.SpinnerStop()) return fmt.Errorf("export state from LocalStack: %w", err) } defer func() { _ = body.Close() }() w, err := os.Create(dest) if err != nil { - sink.Emit(output.SpinnerStop()) return fmt.Errorf("save to %s: %w", dest, err) } if _, err := io.Copy(w, body); err != nil { _ = w.Close() - sink.Emit(output.SpinnerStop()) return fmt.Errorf("write snapshot: %w", err) } - if err := w.Close(); err != nil { - sink.Emit(output.SpinnerStop()) - return fmt.Errorf("close snapshot: %w", err) - } - - sink.Emit(output.SpinnerStop()) - sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Snapshot saved to %s", dest)}) - return nil + return w.Close() } From 475e44c0b0152e87ddd65a3e17ba28766bba3421 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Mon, 11 May 2026 15:37:55 +0200 Subject: [PATCH 11/26] Move ExportState into aws.Client --- cmd/snapshot.go | 7 +- internal/emulator/aws/client.go | 20 +++++ internal/emulator/aws/client_test.go | 110 ++++++++++++++++++++++++ internal/snapshot/client.go | 35 +------- internal/snapshot/client_test.go | 120 --------------------------- internal/snapshot/save.go | 4 +- internal/snapshot/save_test.go | 16 ++-- internal/ui/run_snapshot_save.go | 4 +- 8 files changed, 147 insertions(+), 169 deletions(-) delete mode 100644 internal/snapshot/client_test.go diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 5a0f0fd0..7ec78817 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -6,6 +6,7 @@ import ( "slices" "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" @@ -72,12 +73,12 @@ Cloud destinations are not yet supported.`, awsContainer = appConfig.Containers[awsIdx] } host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost) - exporter := snapshot.NewStateClient("http://" + host) + exporter := aws.NewClient() if isInteractiveMode(cfg) { - return ui.RunSnapshotSave(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, dest) + return ui.RunSnapshotSave(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, host, dest) } - return snapshot.Save(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, dest, output.NewPlainSinkSplit(os.Stdout, os.Stderr)) + return snapshot.Save(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, host, dest, output.NewPlainSinkSplit(os.Stdout, os.Stderr)) }, } } diff --git a/internal/emulator/aws/client.go b/internal/emulator/aws/client.go index e0122213..d683436a 100644 --- a/internal/emulator/aws/client.go +++ b/internal/emulator/aws/client.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "sort" "strings" @@ -131,3 +132,22 @@ func (c *Client) FetchResources(ctx context.Context, host string) ([]emulator.Re return rows, nil } + +// ExportState calls GET /_localstack/pods/state; caller must close the returned body. +func (c *Client) ExportState(ctx context.Context, host string) (io.ReadCloser, error) { + url := fmt.Sprintf("http://%s/_localstack/pods/state", host) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("connect to LocalStack: %w", err) + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, fmt.Errorf("LocalStack returned status %d", resp.StatusCode) + } + return resp.Body, nil +} diff --git a/internal/emulator/aws/client_test.go b/internal/emulator/aws/client_test.go index cbb915fc..66140d09 100644 --- a/internal/emulator/aws/client_test.go +++ b/internal/emulator/aws/client_test.go @@ -3,8 +3,10 @@ package aws import ( "context" "fmt" + "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -107,3 +109,111 @@ 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() + + c := NewClient() + body, err := c.ExportState(context.Background(), srv.Listener.Addr().String()) + require.NoError(t, err) + defer func() { _ = body.Close() }() + + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, "ZIP_DATA", string(data)) + }) + + 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()) + 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()) + 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) + 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() { + _, err := c.ExportState(ctx, srv.Listener.Addr().String()) + errCh <- err + }() + + <-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() + + c := NewClient() + body, err := c.ExportState(context.Background(), srv.Listener.Addr().String()) + require.NoError(t, err) + defer func() { _ = body.Close() }() + + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, size, len(data)) + }) +} + diff --git a/internal/snapshot/client.go b/internal/snapshot/client.go index a778f147..42a4477e 100644 --- a/internal/snapshot/client.go +++ b/internal/snapshot/client.go @@ -2,43 +2,10 @@ package snapshot import ( "context" - "fmt" "io" - "net/http" ) // StateExporter retrieves state from the running LocalStack instance. type StateExporter interface { - ExportState(ctx context.Context) (io.ReadCloser, error) -} - -// StateClient calls the LocalStack state API. -type StateClient struct { - baseURL string - httpClient *http.Client -} - -func NewStateClient(baseURL string) *StateClient { - return &StateClient{ - baseURL: baseURL, - httpClient: &http.Client{}, - } -} - -// ExportState calls GET /_localstack/pods/state; caller must close the returned body. -func (c *StateClient) ExportState(ctx context.Context) (io.ReadCloser, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/_localstack/pods/state", nil) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("connect to LocalStack: %w", err) - } - if resp.StatusCode != http.StatusOK { - _ = resp.Body.Close() - return nil, fmt.Errorf("LocalStack returned status %d", resp.StatusCode) - } - return resp.Body, nil + ExportState(ctx context.Context, host string) (io.ReadCloser, error) } diff --git a/internal/snapshot/client_test.go b/internal/snapshot/client_test.go deleted file mode 100644 index c46abab1..00000000 --- a/internal/snapshot/client_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package snapshot_test - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/localstack/lstk/internal/snapshot" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestStateClient_ExportState_OK(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() - - client := snapshot.NewStateClient(srv.URL) - body, err := client.ExportState(context.Background()) - require.NoError(t, err) - defer func() { _ = body.Close() }() - - data, err := io.ReadAll(body) - require.NoError(t, err) - assert.Equal(t, "ZIP_DATA", string(data)) -} - -func TestStateClient_ExportState_ServerError(t *testing.T) { - t.Parallel() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer srv.Close() - - client := snapshot.NewStateClient(srv.URL) - _, err := client.ExportState(context.Background()) - require.Error(t, err) - assert.Contains(t, err.Error(), "500") -} - -func TestStateClient_ExportState_NotFound(t *testing.T) { - t.Parallel() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - client := snapshot.NewStateClient(srv.URL) - _, err := client.ExportState(context.Background()) - require.Error(t, err) - assert.Contains(t, err.Error(), "404") -} - -func TestStateClient_ExportState_ConnectionRefused(t *testing.T) { - t.Parallel() - // Bind then immediately close to get a port that refuses connections. - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) - addr := srv.URL - srv.Close() - - client := snapshot.NewStateClient(addr) - _, err := client.ExportState(context.Background()) - require.Error(t, err) - assert.Contains(t, err.Error(), "connect to LocalStack") -} - -func TestStateClient_ExportState_ContextCancelled(t *testing.T) { - t.Parallel() - started := make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - close(started) - // block until the client cancels - <-r.Context().Done() - })) - defer srv.Close() - - ctx, cancel := context.WithCancel(context.Background()) - client := snapshot.NewStateClient(srv.URL) - - errCh := make(chan error, 1) - go func() { - _, err := client.ExportState(ctx) - errCh <- err - }() - - <-started - cancel() - - err := <-errCh - require.Error(t, err) -} - -func TestStateClient_ExportState_LargeBody(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, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(payload)) - })) - defer srv.Close() - - client := snapshot.NewStateClient(srv.URL) - body, err := client.ExportState(context.Background()) - require.NoError(t, err) - defer func() { _ = body.Close() }() - - data, err := io.ReadAll(body) - require.NoError(t, err) - assert.Equal(t, size, len(data)) -} diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go index cc674736..16db2701 100644 --- a/internal/snapshot/save.go +++ b/internal/snapshot/save.go @@ -13,7 +13,7 @@ import ( ) // Save exports the emulator's state via exporter and writes it to dest. -func Save(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter StateExporter, dest string, sink output.Sink) (retErr error) { +func Save(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter StateExporter, host, dest string, sink output.Sink) (retErr error) { if err := rt.IsHealthy(ctx); err != nil { rt.EmitUnhealthyError(sink, err) return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) @@ -42,7 +42,7 @@ func Save(ctx context.Context, rt runtime.Runtime, containers []config.Container } }() - body, err := exporter.ExportState(ctx) + body, err := exporter.ExportState(ctx, host) if err != nil { return fmt.Errorf("export state from LocalStack: %w", err) } diff --git a/internal/snapshot/save_test.go b/internal/snapshot/save_test.go index 4cdc6cdb..050b90f1 100644 --- a/internal/snapshot/save_test.go +++ b/internal/snapshot/save_test.go @@ -24,7 +24,7 @@ type fakeExporter struct { err error } -func (f *fakeExporter) ExportState(_ context.Context) (io.ReadCloser, error) { +func (f *fakeExporter) ExportState(_ context.Context, _ string) (io.ReadCloser, error) { if f.err != nil { return nil, f.err } @@ -58,7 +58,7 @@ func TestSave_Success(t *testing.T) { exporter := &fakeExporter{body: []byte("ZIP_DATA")} sink, getEvents := captureEvents(t) - err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, dest, sink) + err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, "", dest, sink) require.NoError(t, err) data, err := os.ReadFile(filepath.Join(dir, "snap")) @@ -101,7 +101,7 @@ func TestSave_EmulatorNotRunning(t *testing.T) { dest := filepath.Join(dir, "snap") sink, getEvents := captureEvents(t) - err := snapshot.Save(context.Background(), mockRT, awsContainers, &fakeExporter{body: []byte("x")}, dest, sink) + err := snapshot.Save(context.Background(), mockRT, awsContainers, &fakeExporter{body: []byte("x")}, "", dest, sink) require.Error(t, err) assert.True(t, output.IsSilent(err)) @@ -130,7 +130,7 @@ func TestSave_UnhealthyRuntime(t *testing.T) { dest := filepath.Join(dir, "snap") sink := output.NewPlainSink(io.Discard) - err := snapshot.Save(context.Background(), mockRT, awsContainers, &fakeExporter{}, dest, sink) + err := snapshot.Save(context.Background(), mockRT, awsContainers, &fakeExporter{}, "", dest, sink) require.Error(t, err) assert.True(t, output.IsSilent(err)) } @@ -142,7 +142,7 @@ func TestSave_ExporterError(t *testing.T) { exporter := &fakeExporter{err: fmt.Errorf("connection refused")} sink := output.NewPlainSink(io.Discard) - err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, dest, sink) + err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, "", dest, sink) require.Error(t, err) assert.Contains(t, err.Error(), "connection refused") @@ -156,7 +156,7 @@ func TestSave_DestinationDirNotExist(t *testing.T) { exporter := &fakeExporter{body: []byte("ZIP_DATA")} sink := output.NewPlainSink(io.Discard) - err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, dest, sink) + err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, "", dest, sink) require.Error(t, err) assert.Contains(t, err.Error(), "save to") } @@ -171,7 +171,7 @@ func TestSave_OverwritesExistingFile(t *testing.T) { exporter := &fakeExporter{body: []byte("NEW")} sink := output.NewPlainSink(io.Discard) - err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, dest, sink) + err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, "", dest, sink) require.NoError(t, err) data, err := os.ReadFile(path) @@ -195,6 +195,6 @@ func TestSave_ContextCancelled(t *testing.T) { sink := output.NewPlainSink(io.Discard) - err := snapshot.Save(ctx, mockRT, awsContainers, exporter, dest, sink) + err := snapshot.Save(ctx, mockRT, awsContainers, exporter, "", dest, sink) require.Error(t, err) } diff --git a/internal/ui/run_snapshot_save.go b/internal/ui/run_snapshot_save.go index f1264a08..6f1cc368 100644 --- a/internal/ui/run_snapshot_save.go +++ b/internal/ui/run_snapshot_save.go @@ -9,8 +9,8 @@ import ( "github.com/localstack/lstk/internal/snapshot" ) -func RunSnapshotSave(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter snapshot.StateExporter, dest string) error { +func RunSnapshotSave(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter snapshot.StateExporter, host, dest string) error { return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { - return snapshot.Save(ctx, rt, containers, exporter, dest, sink) + return snapshot.Save(ctx, rt, containers, exporter, host, dest, sink) }) } From e915375689f2c2cb67246821637b1f97ac9116a5 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Mon, 11 May 2026 18:41:11 +0200 Subject: [PATCH 12/26] change default snapshot name --- cmd/snapshot.go | 5 +++-- internal/snapshot/destination.go | 8 +++++--- internal/snapshot/destination_test.go | 20 +++++++++++++++----- test/integration/snapshot_save_test.go | 12 ++++++++++-- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 7ec78817..35fea081 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "slices" + "time" "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/emulator/aws" @@ -33,7 +34,7 @@ func newSnapshotSaveCmd(cfg *env.Env) *cobra.Command { The destination must be a file path. Use a path prefix to save locally: - lstk snapshot save # saves to ./ls-state-export + lstk snapshot save # saves to ./snapshot-2026-05-11T21-04-32 lstk snapshot save ./my-snapshot # saves to ./my-snapshot lstk snapshot save /tmp/my-state # saves to /tmp/my-state @@ -46,7 +47,7 @@ Cloud destinations are not yet supported.`, destArg = args[0] } - dest, err := snapshot.ParseDestination(destArg) + dest, err := snapshot.ParseDestination(destArg, time.Now()) if err != nil { return err } diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go index e537f60d..91267a6a 100644 --- a/internal/snapshot/destination.go +++ b/internal/snapshot/destination.go @@ -5,13 +5,15 @@ import ( "os" "path/filepath" "strings" + "time" ) // ParseDestination resolves the user-supplied path to an absolute local path, -// or returns an error for cloud/bare names. -func ParseDestination(dest string) (string, error) { +// or returns an error for cloud/bare names. When dest is empty, a default name +// based on now (UTC) is used, e.g. "snapshot-2026-05-11T21-04-32". +func ParseDestination(dest string, now time.Time) (string, error) { if dest == "" { - dest = "ls-state-export" + dest = "./" + now.UTC().Format("snapshot-2006-01-02T15-04-05") } else if strings.Contains(dest, "://") { return "", fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") } else if !strings.HasPrefix(dest, ".") && !strings.HasPrefix(dest, "~") && !filepath.IsAbs(dest) && filepath.Base(dest) == dest { diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go index c148a12a..8888f596 100644 --- a/internal/snapshot/destination_test.go +++ b/internal/snapshot/destination_test.go @@ -5,12 +5,24 @@ import ( "path/filepath" "runtime" "testing" + "time" "github.com/localstack/lstk/internal/snapshot" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestParseDestinationDefault(t *testing.T) { + t.Parallel() + wd, err := os.Getwd() + require.NoError(t, err) + + now := time.Date(2026, 5, 11, 21, 4, 32, 0, time.UTC) + got, err := snapshot.ParseDestination("", now) + require.NoError(t, err) + assert.Equal(t, filepath.Join(wd, "snapshot-2026-05-11T21-04-32"), got) +} + func TestParseDestination(t *testing.T) { t.Parallel() wd, err := os.Getwd() @@ -18,6 +30,8 @@ func TestParseDestination(t *testing.T) { home, err := os.UserHomeDir() require.NoError(t, err) + now := time.Date(2026, 5, 11, 21, 4, 32, 0, time.UTC) + type testCase struct { input string wantPath string @@ -25,10 +39,6 @@ func TestParseDestination(t *testing.T) { } tests := []testCase{ - { - input: "", - wantPath: filepath.Join(wd, "ls-state-export"), - }, { input: "./my-state", wantPath: filepath.Join(wd, "my-state"), @@ -74,7 +84,7 @@ func TestParseDestination(t *testing.T) { for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { t.Parallel() - got, err := snapshot.ParseDestination(tc.input) + got, err := snapshot.ParseDestination(tc.input, now) if tc.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErr) diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index 684e6cb6..12b6b7ee 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -61,8 +61,16 @@ func TestSnapshotSaveDefaultDestination(t *testing.T) { require.NoError(t, err, "lstk snapshot save failed: %s", stderr) assert.Contains(t, stdout, "Snapshot saved") - _, statErr := os.Stat(filepath.Join(dir, "ls-state-export")) - assert.NoError(t, statErr, "default output file should exist") + entries, readErr := os.ReadDir(dir) + require.NoError(t, readErr) + var found bool + for _, e := range entries { + if strings.HasPrefix(e.Name(), "snapshot-") { + found = true + break + } + } + assert.True(t, found, "default snapshot file (snapshot-*) should exist in %s", dir) } func TestSnapshotSaveCustomPath(t *testing.T) { From 420dfdbedda53c1f4dbbfadf3db733396dd97e9f Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Mon, 11 May 2026 18:47:06 +0200 Subject: [PATCH 13/26] local snapshot save always has .zip extension --- internal/snapshot/destination.go | 13 ++++-- internal/snapshot/destination_test.go | 61 ++++++++++++++++++-------- test/integration/snapshot_save_test.go | 6 +-- 3 files changed, 55 insertions(+), 25 deletions(-) diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go index 91267a6a..b2bfa2bd 100644 --- a/internal/snapshot/destination.go +++ b/internal/snapshot/destination.go @@ -10,15 +10,16 @@ import ( // ParseDestination resolves the user-supplied path to an absolute local path, // or returns an error for cloud/bare names. When dest is empty, a default name -// based on now (UTC) is used, e.g. "snapshot-2026-05-11T21-04-32". +// based on now (UTC) is used, e.g. "snapshot-2026-05-11T21-04-32.zip". +// The returned path always has a .zip extension. func ParseDestination(dest string, now time.Time) (string, error) { if dest == "" { dest = "./" + now.UTC().Format("snapshot-2006-01-02T15-04-05") } else if strings.Contains(dest, "://") { - return "", fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") + return "", fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot.zip") } else if !strings.HasPrefix(dest, ".") && !strings.HasPrefix(dest, "~") && !filepath.IsAbs(dest) && filepath.Base(dest) == dest { // bare name with no path separators: reserved for future cloud pod names - return "", fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot") + return "", fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot.zip") } if dest == "~" || strings.HasPrefix(dest, "~/") || strings.HasPrefix(dest, `~\`) { home, err := os.UserHomeDir() @@ -31,5 +32,11 @@ func ParseDestination(dest string, now time.Time) (string, error) { if err != nil { return "", fmt.Errorf("resolve path: %w", err) } + if info, err := os.Stat(abs); err == nil && info.IsDir() { + return "", fmt.Errorf("%q is a directory — specify a file path like ./my-snapshot.zip", abs) + } + if !strings.EqualFold(filepath.Ext(abs), ".zip") { + abs += ".zip" + } return abs, nil } diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go index 8888f596..c8da79de 100644 --- a/internal/snapshot/destination_test.go +++ b/internal/snapshot/destination_test.go @@ -12,6 +12,15 @@ import ( "github.com/stretchr/testify/require" ) +func TestParseDestinationRejectsDirectory(t *testing.T) { + t.Parallel() + dir := t.TempDir() + now := time.Date(2026, 5, 11, 21, 4, 32, 0, time.UTC) + _, err := snapshot.ParseDestination(dir, now) + require.Error(t, err) + assert.Contains(t, err.Error(), "is a directory") +} + func TestParseDestinationDefault(t *testing.T) { t.Parallel() wd, err := os.Getwd() @@ -20,7 +29,7 @@ func TestParseDestinationDefault(t *testing.T) { now := time.Date(2026, 5, 11, 21, 4, 32, 0, time.UTC) got, err := snapshot.ParseDestination("", now) require.NoError(t, err) - assert.Equal(t, filepath.Join(wd, "snapshot-2026-05-11T21-04-32"), got) + assert.Equal(t, filepath.Join(wd, "snapshot-2026-05-11T21-04-32.zip"), got) } func TestParseDestination(t *testing.T) { @@ -33,51 +42,63 @@ func TestParseDestination(t *testing.T) { now := time.Date(2026, 5, 11, 21, 4, 32, 0, time.UTC) type testCase struct { - input string - wantPath string - wantErr string + input string + wantPath string + wantErr string + wantCloudErr bool } tests := []testCase{ { input: "./my-state", - wantPath: filepath.Join(wd, "my-state"), + wantPath: filepath.Join(wd, "my-state.zip"), }, { input: filepath.Join(os.TempDir(), "state"), - wantPath: filepath.Join(os.TempDir(), "state"), + wantPath: filepath.Join(os.TempDir(), "state.zip"), }, { input: "~", - wantPath: home, + wantErr: "is a directory", }, { input: "~/snapshots/s", - wantPath: filepath.Join(home, "snapshots", "s"), + wantPath: filepath.Join(home, "snapshots", "s.zip"), }, { input: "subdir/state", - wantPath: filepath.Join(wd, "subdir", "state"), + wantPath: filepath.Join(wd, "subdir", "state.zip"), + }, + { + input: "./checkpoint.zip", + wantPath: filepath.Join(wd, "checkpoint.zip"), + }, + { + input: "./already.ZIP", + wantPath: filepath.Join(wd, "already.ZIP"), }, { - input: "my-pod", - wantErr: "cloud destinations are not yet supported", + input: "my-pod", + wantErr: "cloud destinations are not yet supported", + wantCloudErr: true, }, { - input: "cloud://my-pod", - wantErr: "cloud destinations are not yet supported", + input: "cloud://my-pod", + wantErr: "cloud destinations are not yet supported", + wantCloudErr: true, }, { - input: "s3://bucket/key", - wantErr: "cloud destinations are not yet supported", + input: "s3://bucket/key", + wantErr: "cloud destinations are not yet supported", + wantCloudErr: true, }, } if runtime.GOOS == "windows" { tests = append(tests, - testCase{input: `~\snapshots\s`, wantPath: filepath.Join(home, "snapshots", "s")}, - testCase{input: `C:\Users\user\snap`, wantPath: `C:\Users\user\snap`}, - testCase{input: `C:/Users/user/snap`, wantPath: `C:\Users\user\snap`}, + testCase{input: `~\snapshots\s`, wantPath: filepath.Join(home, "snapshots", "s.zip")}, + testCase{input: `C:\Users\user\snap`, wantPath: `C:\Users\user\snap.zip`}, + testCase{input: `C:/Users/user/snap`, wantPath: `C:\Users\user\snap.zip`}, ) } @@ -88,7 +109,9 @@ func TestParseDestination(t *testing.T) { if tc.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErr) - assert.Contains(t, err.Error(), "./my-snapshot") + if tc.wantCloudErr { + assert.Contains(t, err.Error(), "./my-snapshot.zip") + } return } require.NoError(t, err) diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index 12b6b7ee..eebf7cd4 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -82,7 +82,7 @@ func TestSnapshotSaveCustomPath(t *testing.T) { startTestContainer(t, ctx) srv := mockStateServer(t) dir := t.TempDir() - outPath := filepath.Join(dir, "my-snap") + outPath := filepath.Join(dir, "my-snap.zip") stdout, stderr, err := runLstk(t, ctx, dir, env.With(env.LocalStackHost, lsHost(srv)), @@ -118,7 +118,7 @@ func TestSnapshotSaveRelativePath(t *testing.T) { require.NoError(t, err, "lstk snapshot save failed: %s", stderr) assert.Contains(t, stdout, "Snapshot saved") - _, statErr := os.Stat(filepath.Join(dir, "my-state")) + _, statErr := os.Stat(filepath.Join(dir, "my-state.zip")) assert.NoError(t, statErr, "relative output file should exist") } @@ -131,7 +131,7 @@ func TestSnapshotSaveOverwritesExistingFile(t *testing.T) { startTestContainer(t, ctx) srv := mockStateServer(t) dir := t.TempDir() - outPath := filepath.Join(dir, "snap") + outPath := filepath.Join(dir, "snap.zip") require.NoError(t, os.WriteFile(outPath, []byte("OLD"), 0600)) _, stderr, err := runLstk(t, ctx, dir, From aac87051087bef77b46197de24fa714400ee56c5 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Mon, 11 May 2026 19:09:07 +0200 Subject: [PATCH 14/26] Remove partial snapshot file when write fails mid-stream Without this, a failed io.Copy left a corrupt/partial ZIP on disk. The user had no indication the file was incomplete. Co-Authored-By: Claude Sonnet 4.6 --- internal/snapshot/save.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go index 16db2701..65fe5068 100644 --- a/internal/snapshot/save.go +++ b/internal/snapshot/save.go @@ -55,6 +55,7 @@ func Save(ctx context.Context, rt runtime.Runtime, containers []config.Container if _, err := io.Copy(w, body); err != nil { _ = w.Close() + _ = os.Remove(dest) return fmt.Errorf("write snapshot: %w", err) } From 19762fc408bd64fb4de2d0eaaf518eb932d12df8 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Mon, 11 May 2026 19:09:40 +0200 Subject: [PATCH 15/26] Use isolated HOME in TestSnapshotSaveLocalStackNotRunning Passing nil to runLstk inherited the developer's real $HOME, which could write to ~/.config/lstk/lstk.log and the file-keyring fallback. Co-Authored-By: Claude Sonnet 4.6 --- test/integration/snapshot_save_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index eebf7cd4..56933c72 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -178,7 +178,7 @@ func TestSnapshotSaveLocalStackNotRunning(t *testing.T) { ctx := testContext(t) // Intentionally no startTestContainer: the emulator is not running. - _, stderr, err := runLstk(t, ctx, t.TempDir(), nil, + _, stderr, err := runLstk(t, ctx, t.TempDir(), testEnvWithHome(t.TempDir(), ""), "--non-interactive", "snapshot", "save", ) requireExitCode(t, 1, err) From f66ba568e462c6918e6175d8be013ee341f1ce7c Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Mon, 11 May 2026 19:10:05 +0200 Subject: [PATCH 16/26] Test NewPlainSinkSplit event routing behavior Verify that ErrorEvent is routed to errOut and all other events go to out, and that nil writer arguments fall back safely without panicking. Co-Authored-By: Claude Sonnet 4.6 --- internal/output/plain_sink_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/internal/output/plain_sink_test.go b/internal/output/plain_sink_test.go index b686a081..fe8d803f 100644 --- a/internal/output/plain_sink_test.go +++ b/internal/output/plain_sink_test.go @@ -286,6 +286,32 @@ func TestPlainSink_TableWidth(t *testing.T) { }) } +func TestPlainSinkSplit_RoutesErrorEventToErrOut(t *testing.T) { + var out, errOut bytes.Buffer + sink := NewPlainSinkSplit(&out, &errOut) + + sink.Emit(ErrorEvent{Title: "Something failed"}) + + assert.Empty(t, out.String(), "ErrorEvent should not go to out") + assert.Contains(t, errOut.String(), "Something failed") +} + +func TestPlainSinkSplit_RoutesOtherEventsToOut(t *testing.T) { + var out, errOut bytes.Buffer + sink := NewPlainSinkSplit(&out, &errOut) + + sink.Emit(MessageEvent{Severity: SeverityInfo, Text: "hello"}) + + assert.Contains(t, out.String(), "hello") + assert.Empty(t, errOut.String(), "MessageEvent should not go to errOut") +} + +func TestPlainSinkSplit_NilWritersFallback(t *testing.T) { + // nil writers should not panic (fallback to os.Stdout/os.Stderr) + sink := NewPlainSinkSplit(nil, nil) + assert.NotNil(t, sink) +} + func TestPlainSink_ErrReturnsNilOnSuccess(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) From ec75a46868621005e98194ec6b2da0e1ab20bb13 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Mon, 11 May 2026 19:20:25 +0200 Subject: [PATCH 17/26] Nits --- cmd/snapshot.go | 10 +++++----- internal/snapshot/destination.go | 7 +++++-- internal/snapshot/destination_test.go | 10 ++++------ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 35fea081..7c7680b9 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -30,13 +30,13 @@ 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 to a local file. + Long: `Save a snapshot of the running emulator's state. -The destination must be a file path. Use a path prefix to save locally: +Pass [destination] as an absolute or relative path for the exported file: - lstk snapshot save # saves to ./snapshot-2026-05-11T21-04-32 - lstk snapshot save ./my-snapshot # saves to ./my-snapshot - lstk snapshot save /tmp/my-state # saves to /tmp/my-state + lstk snapshot save # saves to ./snapshot-.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), diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go index b2bfa2bd..7231c962 100644 --- a/internal/snapshot/destination.go +++ b/internal/snapshot/destination.go @@ -1,6 +1,7 @@ package snapshot import ( + "errors" "fmt" "os" "path/filepath" @@ -8,6 +9,8 @@ import ( "time" ) +var ErrCloudNotSupported = errors.New("cloud destinations are not yet supported — use a file path like ./my-snapshot.zip") + // ParseDestination resolves the user-supplied path to an absolute local path, // or returns an error for cloud/bare names. When dest is empty, a default name // based on now (UTC) is used, e.g. "snapshot-2026-05-11T21-04-32.zip". @@ -16,10 +19,10 @@ func ParseDestination(dest string, now time.Time) (string, error) { if dest == "" { dest = "./" + now.UTC().Format("snapshot-2006-01-02T15-04-05") } else if strings.Contains(dest, "://") { - return "", fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot.zip") + return "", ErrCloudNotSupported } else if !strings.HasPrefix(dest, ".") && !strings.HasPrefix(dest, "~") && !filepath.IsAbs(dest) && filepath.Base(dest) == dest { // bare name with no path separators: reserved for future cloud pod names - return "", fmt.Errorf("cloud destinations are not yet supported — use a file path like ./my-snapshot.zip") + return "", ErrCloudNotSupported } if dest == "~" || strings.HasPrefix(dest, "~/") || strings.HasPrefix(dest, `~\`) { home, err := os.UserHomeDir() diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go index c8da79de..c49b4fa1 100644 --- a/internal/snapshot/destination_test.go +++ b/internal/snapshot/destination_test.go @@ -79,17 +79,14 @@ func TestParseDestination(t *testing.T) { }, { input: "my-pod", - wantErr: "cloud destinations are not yet supported", wantCloudErr: true, }, { input: "cloud://my-pod", - wantErr: "cloud destinations are not yet supported", wantCloudErr: true, }, { input: "s3://bucket/key", - wantErr: "cloud destinations are not yet supported", wantCloudErr: true, }, } @@ -109,9 +106,10 @@ func TestParseDestination(t *testing.T) { if tc.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErr) - if tc.wantCloudErr { - assert.Contains(t, err.Error(), "./my-snapshot.zip") - } + return + } + if tc.wantCloudErr { + require.ErrorIs(t, err, snapshot.ErrCloudNotSupported) return } require.NoError(t, err) From 9aba0ba1cf6a3b07ee5cd1c8320db9ff40149d64 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 12 May 2026 15:29:32 +0200 Subject: [PATCH 18/26] Improve parsing logic --- internal/snapshot/destination.go | 48 +++++++--- internal/snapshot/destination_test.go | 120 ++++++++++++++++++++----- test/integration/snapshot_save_test.go | 37 ++++---- 3 files changed, 151 insertions(+), 54 deletions(-) diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go index 7231c962..60a73aae 100644 --- a/internal/snapshot/destination.go +++ b/internal/snapshot/destination.go @@ -9,21 +9,33 @@ import ( "time" ) -var ErrCloudNotSupported = errors.New("cloud destinations are not yet supported — use a file path like ./my-snapshot.zip") +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") +) -// ParseDestination resolves the user-supplied path to an absolute local path, -// or returns an error for cloud/bare names. When dest is empty, a default name -// based on now (UTC) is used, e.g. "snapshot-2026-05-11T21-04-32.zip". +// 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.zip", saved in the current working directory. // The returned path always has a .zip extension. func ParseDestination(dest string, now time.Time) (string, error) { if dest == "" { dest = "./" + now.UTC().Format("snapshot-2006-01-02T15-04-05") - } else if strings.Contains(dest, "://") { - return "", ErrCloudNotSupported - } else if !strings.HasPrefix(dest, ".") && !strings.HasPrefix(dest, "~") && !filepath.IsAbs(dest) && filepath.Base(dest) == dest { - // bare name with no path separators: reserved for future cloud pod names - return "", ErrCloudNotSupported + } 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 := dest[:strings.Index(lower, "://")+3] + return "", fmt.Errorf("%w: %q", ErrUnknownScheme, scheme) + } } + if dest == "~" || strings.HasPrefix(dest, "~/") || strings.HasPrefix(dest, `~\`) { home, err := os.UserHomeDir() if err != nil { @@ -31,15 +43,31 @@ func ParseDestination(dest string, now time.Time) (string, error) { } 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.zip", abs) + 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 } diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go index c49b4fa1..186935b3 100644 --- a/internal/snapshot/destination_test.go +++ b/internal/snapshot/destination_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" @@ -41,14 +42,22 @@ func TestParseDestination(t *testing.T) { now := time.Date(2026, 5, 11, 21, 4, 32, 0, time.UTC) + // Set up dirs used in path-based cases below. + existingDir := t.TempDir() + subDir := filepath.Join(existingDir, "subdir") + require.NoError(t, os.Mkdir(subDir, 0o755)) + type testCase struct { - input string - wantPath string - wantErr string - wantCloudErr bool + name string // optional; uses input when empty + input string + wantPath string + wantErr string + wantRemoteErr bool + wantSchemeErr bool } tests := []testCase{ + // --- local paths --- { input: "./my-state", wantPath: filepath.Join(wd, "my-state.zip"), @@ -58,16 +67,23 @@ func TestParseDestination(t *testing.T) { wantPath: filepath.Join(os.TempDir(), "state.zip"), }, { - input: "~", - wantErr: "is a directory", + input: "~", + wantErr: "is a directory", + }, + { + // parent (~/) always exists + input: "~/my-state", + wantPath: filepath.Join(home, "my-state.zip"), }, { - input: "~/snapshots/s", - wantPath: filepath.Join(home, "snapshots", "s.zip"), + name: "relative path with existing subdir", + input: filepath.Join(subDir, "state"), + wantPath: filepath.Join(subDir, "state.zip"), }, { - input: "subdir/state", - wantPath: filepath.Join(wd, "subdir", "state.zip"), + // bare name: treated as relative to CWD + input: "my-pod", + wantPath: filepath.Join(wd, "my-pod.zip"), }, { input: "./checkpoint.zip", @@ -77,30 +93,86 @@ func TestParseDestination(t *testing.T) { input: "./already.ZIP", wantPath: filepath.Join(wd, "already.ZIP"), }, + + // --- parent directory does not exist --- + { + name: "parent dir missing", + input: filepath.Join(existingDir, "nonexistent", "state"), + wantErr: "parent directory", + }, + + // --- remote: s3 --- + { + input: "s3://bucket/key", + wantRemoteErr: true, + }, + { + input: "S3://bucket/key", + wantRemoteErr: true, + }, + + // --- remote: oras --- + { + input: "oras://registry/image", + wantRemoteErr: true, + }, + { + input: "ORAS://registry/image", + wantRemoteErr: true, + }, + + // --- remote: cloud --- + { + input: "cloud:my-pod", + wantRemoteErr: true, + }, + { + input: "Cloud:my-pod", + wantRemoteErr: true, + }, { - input: "my-pod", - wantCloudErr: true, + // cloud: prefix also catches cloud:// + input: "cloud://my-pod", + wantRemoteErr: true, }, + + // --- unknown schemes --- { - input: "cloud://my-pod", - wantCloudErr: true, + input: "https://example.com/snap", + wantSchemeErr: true, }, { - input: "s3://bucket/key", - wantCloudErr: true, + input: "gcs://bucket/key", + wantSchemeErr: true, }, } if runtime.GOOS == "windows" { + tmpParent := filepath.Clean(os.TempDir()) tests = append(tests, - testCase{input: `~\snapshots\s`, wantPath: filepath.Join(home, "snapshots", "s.zip")}, - testCase{input: `C:\Users\user\snap`, wantPath: `C:\Users\user\snap.zip`}, - testCase{input: `C:/Users/user/snap`, wantPath: `C:\Users\user\snap.zip`}, + testCase{ + input: `~\my-state`, + wantPath: filepath.Join(home, "my-state.zip"), + }, + testCase{ + name: "windows abs backslash", + input: filepath.Join(tmpParent, "snap"), + wantPath: filepath.Join(tmpParent, "snap.zip"), + }, + testCase{ + name: "windows abs forward-slash", + input: strings.ReplaceAll(filepath.Join(tmpParent, "snap"), `\`, `/`), + wantPath: filepath.Join(tmpParent, "snap.zip"), + }, ) } for _, tc := range tests { - t.Run(tc.input, func(t *testing.T) { + name := tc.input + if tc.name != "" { + name = tc.name + } + t.Run(name, func(t *testing.T) { t.Parallel() got, err := snapshot.ParseDestination(tc.input, now) if tc.wantErr != "" { @@ -108,8 +180,12 @@ func TestParseDestination(t *testing.T) { assert.Contains(t, err.Error(), tc.wantErr) return } - if tc.wantCloudErr { - require.ErrorIs(t, err, snapshot.ErrCloudNotSupported) + if tc.wantRemoteErr { + require.ErrorIs(t, err, snapshot.ErrRemoteNotSupported) + return + } + if tc.wantSchemeErr { + require.ErrorIs(t, err, snapshot.ErrUnknownScheme) return } require.NoError(t, err) diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index 56933c72..f50cafd7 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -145,29 +145,22 @@ func TestSnapshotSaveOverwritesExistingFile(t *testing.T) { assert.NotEqual(t, "OLD", string(data), "file should have been overwritten") } -// TestSnapshotSaveBareNameRejected does not require Docker: destination -// parsing fails before the runtime is ever touched. -func TestSnapshotSaveBareNameRejected(t *testing.T) { +func TestSnapshotSaveRemoteRejected(t *testing.T) { t.Parallel() - ctx := testContext(t) - dir := t.TempDir() - - _, stderr, err := runLstk(t, ctx, dir, testEnvWithHome(t.TempDir(), ""), "--non-interactive", "snapshot", "save", "my-pod") - requireExitCode(t, 1, err) - assert.Contains(t, stderr, "not yet supported") - assert.Contains(t, stderr, "./my-snapshot") -} - -// TestSnapshotSaveCloudURIRejected does not require Docker: destination -// parsing fails before the runtime is ever touched. -func TestSnapshotSaveCloudURIRejected(t *testing.T) { - t.Parallel() - ctx := testContext(t) - dir := t.TempDir() - - _, stderr, err := runLstk(t, ctx, dir, testEnvWithHome(t.TempDir(), ""), "--non-interactive", "snapshot", "save", "cloud://my-pod") - requireExitCode(t, 1, err) - assert.Contains(t, stderr, "not yet supported") + for _, dest := range []string{ + "s3://my-bucket/my-snap", + "oras://registry/my-snap", + "cloud://my-pod", + } { + t.Run(dest, func(t *testing.T) { + t.Parallel() + ctx := testContext(t) + + _, stderr, err := runLstk(t, ctx, t.TempDir(), testEnvWithHome(t.TempDir(), ""), "--non-interactive", "snapshot", "save", dest) + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "not yet supported") + }) + } } func TestSnapshotSaveLocalStackNotRunning(t *testing.T) { From 23459028fe17af7b4f9f9307df37528e455329a6 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 12 May 2026 16:30:35 +0200 Subject: [PATCH 19/26] Clean up snapshot save command and plain sink Fix AWS-only guard to correctly reject non-AWS emulator configs, align container lookup with the for-loop pattern used in aws.go, route ErrorEvents to stderr via NewPlainSinkSplit, and minor code clarity improvements. Co-Authored-By: Claude Sonnet 4.6 --- cmd/snapshot.go | 22 +++++++++++----------- internal/output/plain_sink.go | 24 ++++-------------------- internal/output/plain_sink_test.go | 26 -------------------------- internal/snapshot/destination.go | 4 ++-- internal/snapshot/save.go | 1 - test/integration/snapshot_save_test.go | 4 ++-- 6 files changed, 19 insertions(+), 62 deletions(-) diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 7c7680b9..59fc18bb 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "os" - "slices" "time" "github.com/localstack/lstk/internal/config" @@ -57,10 +56,16 @@ Cloud destinations are not yet supported.`, return fmt.Errorf("failed to get config: %w", err) } - awsIdx := slices.IndexFunc(appConfig.Containers, func(c config.ContainerConfig) bool { - return c.Type == config.EmulatorAWS - }) - if awsIdx < 0 && len(appConfig.Containers) > 0 { + awsContainer := config.ContainerConfig{Type: config.EmulatorAWS, Port: config.DefaultAWSPort} + 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") } @@ -68,18 +73,13 @@ Cloud destinations are not yet supported.`, if err != nil { return err } - - awsContainer := config.ContainerConfig{Type: config.EmulatorAWS, Port: config.DefaultAWSPort} - if awsIdx >= 0 { - awsContainer = appConfig.Containers[awsIdx] - } 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.NewPlainSinkSplit(os.Stdout, os.Stderr)) + return snapshot.Save(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, host, dest, output.NewPlainSink(os.Stdout)) }, } } diff --git a/internal/output/plain_sink.go b/internal/output/plain_sink.go index 0f925b77..fa6fed52 100644 --- a/internal/output/plain_sink.go +++ b/internal/output/plain_sink.go @@ -7,27 +7,15 @@ import ( ) type PlainSink struct { - out io.Writer - errOut io.Writer - err error + out io.Writer + err error } func NewPlainSink(out io.Writer) *PlainSink { if out == nil { out = os.Stdout } - return &PlainSink{out: out, errOut: out} -} - -// NewPlainSinkSplit creates a PlainSink that routes ErrorEvents to errOut and all others to out. -func NewPlainSinkSplit(out, errOut io.Writer) *PlainSink { - if out == nil { - out = os.Stdout - } - if errOut == nil { - errOut = os.Stderr - } - return &PlainSink{out: out, errOut: errOut} + return &PlainSink{out: out} } // Err returns the first write error encountered, if any. @@ -46,10 +34,6 @@ func (s *PlainSink) Emit(event Event) { if !ok { return } - w := s.out - if _, isErr := event.(ErrorEvent); isErr { - w = s.errOut - } - _, err := fmt.Fprintln(w, line) + _, err := fmt.Fprintln(s.out, line) s.setErr(err) } diff --git a/internal/output/plain_sink_test.go b/internal/output/plain_sink_test.go index fe8d803f..b686a081 100644 --- a/internal/output/plain_sink_test.go +++ b/internal/output/plain_sink_test.go @@ -286,32 +286,6 @@ func TestPlainSink_TableWidth(t *testing.T) { }) } -func TestPlainSinkSplit_RoutesErrorEventToErrOut(t *testing.T) { - var out, errOut bytes.Buffer - sink := NewPlainSinkSplit(&out, &errOut) - - sink.Emit(ErrorEvent{Title: "Something failed"}) - - assert.Empty(t, out.String(), "ErrorEvent should not go to out") - assert.Contains(t, errOut.String(), "Something failed") -} - -func TestPlainSinkSplit_RoutesOtherEventsToOut(t *testing.T) { - var out, errOut bytes.Buffer - sink := NewPlainSinkSplit(&out, &errOut) - - sink.Emit(MessageEvent{Severity: SeverityInfo, Text: "hello"}) - - assert.Contains(t, out.String(), "hello") - assert.Empty(t, errOut.String(), "MessageEvent should not go to errOut") -} - -func TestPlainSinkSplit_NilWritersFallback(t *testing.T) { - // nil writers should not panic (fallback to os.Stdout/os.Stderr) - sink := NewPlainSinkSplit(nil, nil) - assert.NotNil(t, sink) -} - func TestPlainSink_ErrReturnsNilOnSuccess(t *testing.T) { var out bytes.Buffer sink := NewPlainSink(&out) diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go index 60a73aae..4f0e4b8e 100644 --- a/internal/snapshot/destination.go +++ b/internal/snapshot/destination.go @@ -31,8 +31,8 @@ func ParseDestination(dest string, now time.Time) (string, error) { strings.HasPrefix(lower, "cloud:"): return "", ErrRemoteNotSupported case strings.Contains(lower, "://"): - scheme := dest[:strings.Index(lower, "://")+3] - return "", fmt.Errorf("%w: %q", ErrUnknownScheme, scheme) + scheme, _, _ := strings.Cut(dest, "://") + return "", fmt.Errorf("%w: %q", ErrUnknownScheme, scheme+"://") } } diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go index 65fe5068..f8a3d206 100644 --- a/internal/snapshot/save.go +++ b/internal/snapshot/save.go @@ -12,7 +12,6 @@ import ( "github.com/localstack/lstk/internal/runtime" ) -// Save exports the emulator's state via exporter and writes it to dest. func Save(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter StateExporter, host, dest string, sink output.Sink) (retErr error) { if err := rt.IsHealthy(ctx); err != nil { rt.EmitUnhealthyError(sink, err) diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index f50cafd7..92d278ae 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -171,11 +171,11 @@ func TestSnapshotSaveLocalStackNotRunning(t *testing.T) { ctx := testContext(t) // Intentionally no startTestContainer: the emulator is not running. - _, stderr, err := runLstk(t, ctx, t.TempDir(), testEnvWithHome(t.TempDir(), ""), + stdout, _, err := runLstk(t, ctx, t.TempDir(), testEnvWithHome(t.TempDir(), ""), "--non-interactive", "snapshot", "save", ) requireExitCode(t, 1, err) - assert.Contains(t, stderr, "not running") + assert.Contains(t, stdout, "not running") } func TestSnapshotSaveInvalidParentDir(t *testing.T) { From 04e7ec36280aee917d924f66a4434de7430b71e2 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 12 May 2026 18:13:31 +0200 Subject: [PATCH 20/26] Use gomock instead --- internal/snapshot/client.go | 2 + internal/snapshot/mock_state_exporter_test.go | 57 +++++++++++++++++++ internal/snapshot/save_test.go | 47 ++++++++------- 3 files changed, 86 insertions(+), 20 deletions(-) create mode 100644 internal/snapshot/mock_state_exporter_test.go diff --git a/internal/snapshot/client.go b/internal/snapshot/client.go index 42a4477e..25e35133 100644 --- a/internal/snapshot/client.go +++ b/internal/snapshot/client.go @@ -1,5 +1,7 @@ package snapshot +//go:generate mockgen -source=client.go -destination=mock_state_exporter_test.go -package=snapshot_test + import ( "context" "io" diff --git a/internal/snapshot/mock_state_exporter_test.go b/internal/snapshot/mock_state_exporter_test.go new file mode 100644 index 00000000..9e51e1a8 --- /dev/null +++ b/internal/snapshot/mock_state_exporter_test.go @@ -0,0 +1,57 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client.go +// +// Generated by this command: +// +// mockgen -source=client.go -destination=mock_state_exporter_test.go -package=snapshot_test +// + +// Package snapshot_test is a generated GoMock package. +package snapshot_test + +import ( + context "context" + io "io" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockStateExporter is a mock of StateExporter interface. +type MockStateExporter struct { + ctrl *gomock.Controller + recorder *MockStateExporterMockRecorder + isgomock struct{} +} + +// MockStateExporterMockRecorder is the mock recorder for MockStateExporter. +type MockStateExporterMockRecorder struct { + mock *MockStateExporter +} + +// NewMockStateExporter creates a new mock instance. +func NewMockStateExporter(ctrl *gomock.Controller) *MockStateExporter { + mock := &MockStateExporter{ctrl: ctrl} + mock.recorder = &MockStateExporterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStateExporter) EXPECT() *MockStateExporterMockRecorder { + return m.recorder +} + +// ExportState mocks base method. +func (m *MockStateExporter) ExportState(ctx context.Context, host string) (io.ReadCloser, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExportState", ctx, host) + ret0, _ := ret[0].(io.ReadCloser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExportState indicates an expected call of ExportState. +func (mr *MockStateExporterMockRecorder) ExportState(ctx, host any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExportState", reflect.TypeOf((*MockStateExporter)(nil).ExportState), ctx, host) +} diff --git a/internal/snapshot/save_test.go b/internal/snapshot/save_test.go index 050b90f1..4364961d 100644 --- a/internal/snapshot/save_test.go +++ b/internal/snapshot/save_test.go @@ -18,19 +18,6 @@ import ( "go.uber.org/mock/gomock" ) -// fakeExporter implements StateExporter for tests. -type fakeExporter struct { - body []byte - err error -} - -func (f *fakeExporter) ExportState(_ context.Context, _ string) (io.ReadCloser, error) { - if f.err != nil { - return nil, f.err - } - return io.NopCloser(bytes.NewReader(f.body)), nil -} - func captureEvents(t *testing.T) (output.Sink, func() []output.Event) { t.Helper() var events []output.Event @@ -49,13 +36,29 @@ func healthyRunningMock(t *testing.T) *runtime.MockRuntime { return mockRT } +func mockExporterReturning(t *testing.T, body []byte) *MockStateExporter { + t.Helper() + ctrl := gomock.NewController(t) + m := NewMockStateExporter(ctrl) + m.EXPECT().ExportState(gomock.Any(), gomock.Any()).Return(io.NopCloser(bytes.NewReader(body)), nil) + return m +} + +func mockExporterReturningError(t *testing.T, err error) *MockStateExporter { + t.Helper() + ctrl := gomock.NewController(t) + m := NewMockStateExporter(ctrl) + m.EXPECT().ExportState(gomock.Any(), gomock.Any()).Return(nil, err) + return m +} + var awsContainers = []config.ContainerConfig{{Type: config.EmulatorAWS}} func TestSave_Success(t *testing.T) { t.Parallel() dir := t.TempDir() dest := filepath.Join(dir, "snap") - exporter := &fakeExporter{body: []byte("ZIP_DATA")} + exporter := mockExporterReturning(t, []byte("ZIP_DATA")) sink, getEvents := captureEvents(t) err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, "", dest, sink) @@ -97,11 +100,13 @@ func TestSave_EmulatorNotRunning(t *testing.T) { mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(false, nil) mockRT.EXPECT().FindRunningByImage(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil) + exporter := NewMockStateExporter(ctrl) + dir := t.TempDir() dest := filepath.Join(dir, "snap") sink, getEvents := captureEvents(t) - err := snapshot.Save(context.Background(), mockRT, awsContainers, &fakeExporter{body: []byte("x")}, "", dest, sink) + err := snapshot.Save(context.Background(), mockRT, awsContainers, exporter, "", dest, sink) require.Error(t, err) assert.True(t, output.IsSilent(err)) @@ -126,11 +131,13 @@ func TestSave_UnhealthyRuntime(t *testing.T) { mockRT.EXPECT().IsHealthy(gomock.Any()).Return(fmt.Errorf("docker unavailable")) mockRT.EXPECT().EmitUnhealthyError(gomock.Any(), gomock.Any()) + exporter := NewMockStateExporter(ctrl) + dir := t.TempDir() dest := filepath.Join(dir, "snap") sink := output.NewPlainSink(io.Discard) - err := snapshot.Save(context.Background(), mockRT, awsContainers, &fakeExporter{}, "", dest, sink) + err := snapshot.Save(context.Background(), mockRT, awsContainers, exporter, "", dest, sink) require.Error(t, err) assert.True(t, output.IsSilent(err)) } @@ -139,7 +146,7 @@ func TestSave_ExporterError(t *testing.T) { t.Parallel() dir := t.TempDir() dest := filepath.Join(dir, "snap") - exporter := &fakeExporter{err: fmt.Errorf("connection refused")} + exporter := mockExporterReturningError(t, fmt.Errorf("connection refused")) sink := output.NewPlainSink(io.Discard) err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, "", dest, sink) @@ -153,7 +160,7 @@ func TestSave_ExporterError(t *testing.T) { func TestSave_DestinationDirNotExist(t *testing.T) { t.Parallel() dest := "/no/such/dir/snap" - exporter := &fakeExporter{body: []byte("ZIP_DATA")} + exporter := mockExporterReturning(t, []byte("ZIP_DATA")) sink := output.NewPlainSink(io.Discard) err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, "", dest, sink) @@ -168,7 +175,7 @@ func TestSave_OverwritesExistingFile(t *testing.T) { require.NoError(t, os.WriteFile(path, []byte("OLD"), 0600)) dest := path - exporter := &fakeExporter{body: []byte("NEW")} + exporter := mockExporterReturning(t, []byte("NEW")) sink := output.NewPlainSink(io.Discard) err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, "", dest, sink) @@ -186,7 +193,7 @@ func TestSave_ContextCancelled(t *testing.T) { dir := t.TempDir() dest := filepath.Join(dir, "snap") - exporter := &fakeExporter{err: ctx.Err()} + exporter := mockExporterReturningError(t, ctx.Err()) ctrl := gomock.NewController(t) mockRT := runtime.NewMockRuntime(ctrl) From 16e82a730385586cbaef8ae813c0fbc80ff71b42 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 12 May 2026 18:18:03 +0200 Subject: [PATCH 21/26] Move StateExporter interface where it's used --- internal/snapshot/client.go | 13 ------------- internal/snapshot/save.go | 7 +++++++ 2 files changed, 7 insertions(+), 13 deletions(-) delete mode 100644 internal/snapshot/client.go diff --git a/internal/snapshot/client.go b/internal/snapshot/client.go deleted file mode 100644 index 25e35133..00000000 --- a/internal/snapshot/client.go +++ /dev/null @@ -1,13 +0,0 @@ -package snapshot - -//go:generate mockgen -source=client.go -destination=mock_state_exporter_test.go -package=snapshot_test - -import ( - "context" - "io" -) - -// StateExporter retrieves state from the running LocalStack instance. -type StateExporter interface { - ExportState(ctx context.Context, host string) (io.ReadCloser, error) -} diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go index f8a3d206..8e8e75cb 100644 --- a/internal/snapshot/save.go +++ b/internal/snapshot/save.go @@ -1,5 +1,7 @@ package snapshot +//go:generate mockgen -source=save.go -destination=mock_state_exporter_test.go -package=snapshot_test + import ( "context" "fmt" @@ -12,6 +14,11 @@ import ( "github.com/localstack/lstk/internal/runtime" ) +// StateExporter retrieves state from the running LocalStack instance. +type StateExporter interface { + ExportState(ctx context.Context, host string) (io.ReadCloser, error) +} + func Save(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter StateExporter, host, dest string, sink output.Sink) (retErr error) { if err := rt.IsHealthy(ctx); err != nil { rt.EmitUnhealthyError(sink, err) From a4f776d52af09208191cf8cbd705e96291315345 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 12 May 2026 18:20:49 +0200 Subject: [PATCH 22/26] Remove redundant tests; handle as test cases --- internal/snapshot/destination_test.go | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go index 186935b3..6303d07d 100644 --- a/internal/snapshot/destination_test.go +++ b/internal/snapshot/destination_test.go @@ -13,25 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestParseDestinationRejectsDirectory(t *testing.T) { - t.Parallel() - dir := t.TempDir() - now := time.Date(2026, 5, 11, 21, 4, 32, 0, time.UTC) - _, err := snapshot.ParseDestination(dir, now) - require.Error(t, err) - assert.Contains(t, err.Error(), "is a directory") -} -func TestParseDestinationDefault(t *testing.T) { - t.Parallel() - wd, err := os.Getwd() - require.NoError(t, err) - - now := time.Date(2026, 5, 11, 21, 4, 32, 0, time.UTC) - got, err := snapshot.ParseDestination("", now) - require.NoError(t, err) - assert.Equal(t, filepath.Join(wd, "snapshot-2026-05-11T21-04-32.zip"), got) -} func TestParseDestination(t *testing.T) { t.Parallel() @@ -57,6 +39,13 @@ func TestParseDestination(t *testing.T) { } tests := []testCase{ + // --- default (empty input) --- + { + name: "default", + input: "", + wantPath: filepath.Join(wd, "snapshot-2026-05-11T21-04-32.zip"), + }, + // --- local paths --- { input: "./my-state", From 112f7367e5c726af695a1e2398653ccef99ede3a Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 12 May 2026 18:45:31 +0200 Subject: [PATCH 23/26] Change ExportState so HTTP response body is always closed inside the implementation --- internal/emulator/aws/client.go | 18 +++++++----- internal/emulator/aws/client_test.go | 28 ++++++++----------- internal/snapshot/mock_state_exporter_test.go | 17 ++++++----- internal/snapshot/save.go | 12 ++------ internal/snapshot/save_test.go | 15 ++++++---- 5 files changed, 43 insertions(+), 47 deletions(-) diff --git a/internal/emulator/aws/client.go b/internal/emulator/aws/client.go index d683436a..c5a23ca2 100644 --- a/internal/emulator/aws/client.go +++ b/internal/emulator/aws/client.go @@ -133,21 +133,25 @@ func (c *Client) FetchResources(ctx context.Context, host string) ([]emulator.Re return rows, nil } -// ExportState calls GET /_localstack/pods/state; caller must close the returned body. -func (c *Client) ExportState(ctx context.Context, host string) (io.ReadCloser, error) { +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 nil, fmt.Errorf("create request: %w", err) + return fmt.Errorf("create request: %w", err) } resp, err := c.http.Do(req) if err != nil { - return nil, fmt.Errorf("connect to LocalStack: %w", err) + return fmt.Errorf("connect to LocalStack: %w", err) } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { - _ = resp.Body.Close() - return nil, fmt.Errorf("LocalStack returned status %d", resp.StatusCode) + 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 resp.Body, nil + return nil } diff --git a/internal/emulator/aws/client_test.go b/internal/emulator/aws/client_test.go index 66140d09..8e46b2a0 100644 --- a/internal/emulator/aws/client_test.go +++ b/internal/emulator/aws/client_test.go @@ -1,6 +1,7 @@ package aws import ( + "bytes" "context" "fmt" "io" @@ -122,14 +123,11 @@ func TestExportState(t *testing.T) { })) defer srv.Close() + var buf bytes.Buffer c := NewClient() - body, err := c.ExportState(context.Background(), srv.Listener.Addr().String()) + err := c.ExportState(context.Background(), srv.Listener.Addr().String(), &buf) require.NoError(t, err) - defer func() { _ = body.Close() }() - - data, err := io.ReadAll(body) - require.NoError(t, err) - assert.Equal(t, "ZIP_DATA", string(data)) + assert.Equal(t, "ZIP_DATA", buf.String()) }) t.Run("returns error on 500", func(t *testing.T) { @@ -140,7 +138,7 @@ func TestExportState(t *testing.T) { defer srv.Close() c := NewClient() - _, err := c.ExportState(context.Background(), srv.Listener.Addr().String()) + err := c.ExportState(context.Background(), srv.Listener.Addr().String(), io.Discard) require.Error(t, err) assert.Contains(t, err.Error(), "500") }) @@ -153,7 +151,7 @@ func TestExportState(t *testing.T) { defer srv.Close() c := NewClient() - _, err := c.ExportState(context.Background(), srv.Listener.Addr().String()) + err := c.ExportState(context.Background(), srv.Listener.Addr().String(), io.Discard) require.Error(t, err) assert.Contains(t, err.Error(), "404") }) @@ -165,7 +163,7 @@ func TestExportState(t *testing.T) { srv.Close() c := NewClient() - _, err := c.ExportState(context.Background(), addr) + err := c.ExportState(context.Background(), addr, io.Discard) require.Error(t, err) assert.Contains(t, err.Error(), "connect to LocalStack") }) @@ -184,8 +182,7 @@ func TestExportState(t *testing.T) { errCh := make(chan error, 1) go func() { - _, err := c.ExportState(ctx, srv.Listener.Addr().String()) - errCh <- err + errCh <- c.ExportState(ctx, srv.Listener.Addr().String(), io.Discard) }() <-started @@ -206,14 +203,11 @@ func TestExportState(t *testing.T) { })) defer srv.Close() + var buf bytes.Buffer c := NewClient() - body, err := c.ExportState(context.Background(), srv.Listener.Addr().String()) - require.NoError(t, err) - defer func() { _ = body.Close() }() - - data, err := io.ReadAll(body) + err := c.ExportState(context.Background(), srv.Listener.Addr().String(), &buf) require.NoError(t, err) - assert.Equal(t, size, len(data)) + assert.Equal(t, size, buf.Len()) }) } diff --git a/internal/snapshot/mock_state_exporter_test.go b/internal/snapshot/mock_state_exporter_test.go index 9e51e1a8..8405cd5b 100644 --- a/internal/snapshot/mock_state_exporter_test.go +++ b/internal/snapshot/mock_state_exporter_test.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: client.go +// Source: save.go // // Generated by this command: // -// mockgen -source=client.go -destination=mock_state_exporter_test.go -package=snapshot_test +// mockgen -source=save.go -destination=mock_state_exporter_test.go -package=snapshot_test // // Package snapshot_test is a generated GoMock package. @@ -42,16 +42,15 @@ func (m *MockStateExporter) EXPECT() *MockStateExporterMockRecorder { } // ExportState mocks base method. -func (m *MockStateExporter) ExportState(ctx context.Context, host string) (io.ReadCloser, error) { +func (m *MockStateExporter) ExportState(ctx context.Context, host string, dst io.Writer) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExportState", ctx, host) - ret0, _ := ret[0].(io.ReadCloser) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret := m.ctrl.Call(m, "ExportState", ctx, host, dst) + ret0, _ := ret[0].(error) + return ret0 } // ExportState indicates an expected call of ExportState. -func (mr *MockStateExporterMockRecorder) ExportState(ctx, host any) *gomock.Call { +func (mr *MockStateExporterMockRecorder) ExportState(ctx, host, dst any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExportState", reflect.TypeOf((*MockStateExporter)(nil).ExportState), ctx, host) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExportState", reflect.TypeOf((*MockStateExporter)(nil).ExportState), ctx, host, dst) } diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go index 8e8e75cb..225bdf54 100644 --- a/internal/snapshot/save.go +++ b/internal/snapshot/save.go @@ -16,7 +16,7 @@ import ( // StateExporter retrieves state from the running LocalStack instance. type StateExporter interface { - ExportState(ctx context.Context, host string) (io.ReadCloser, error) + ExportState(ctx context.Context, host string, dst io.Writer) error } func Save(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter StateExporter, host, dest string, sink output.Sink) (retErr error) { @@ -48,21 +48,15 @@ func Save(ctx context.Context, rt runtime.Runtime, containers []config.Container } }() - body, err := exporter.ExportState(ctx, host) - if err != nil { - return fmt.Errorf("export state from LocalStack: %w", err) - } - defer func() { _ = body.Close() }() - w, err := os.Create(dest) if err != nil { return fmt.Errorf("save to %s: %w", dest, err) } - if _, err := io.Copy(w, body); err != nil { + if err := exporter.ExportState(ctx, host, w); err != nil { _ = w.Close() _ = os.Remove(dest) - return fmt.Errorf("write snapshot: %w", err) + return fmt.Errorf("export state from LocalStack: %w", err) } return w.Close() diff --git a/internal/snapshot/save_test.go b/internal/snapshot/save_test.go index 4364961d..ad0b8a13 100644 --- a/internal/snapshot/save_test.go +++ b/internal/snapshot/save_test.go @@ -1,7 +1,6 @@ package snapshot_test import ( - "bytes" "context" "fmt" "io" @@ -40,15 +39,20 @@ func mockExporterReturning(t *testing.T, body []byte) *MockStateExporter { t.Helper() ctrl := gomock.NewController(t) m := NewMockStateExporter(ctrl) - m.EXPECT().ExportState(gomock.Any(), gomock.Any()).Return(io.NopCloser(bytes.NewReader(body)), nil) + m.EXPECT().ExportState(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, dst io.Writer) error { + _, err := dst.Write(body) + return err + }, + ) return m } -func mockExporterReturningError(t *testing.T, err error) *MockStateExporter { +func mockExporterReturningError(t *testing.T, exportErr error) *MockStateExporter { t.Helper() ctrl := gomock.NewController(t) m := NewMockStateExporter(ctrl) - m.EXPECT().ExportState(gomock.Any(), gomock.Any()).Return(nil, err) + m.EXPECT().ExportState(gomock.Any(), gomock.Any(), gomock.Any()).Return(exportErr) return m } @@ -160,7 +164,8 @@ func TestSave_ExporterError(t *testing.T) { func TestSave_DestinationDirNotExist(t *testing.T) { t.Parallel() dest := "/no/such/dir/snap" - exporter := mockExporterReturning(t, []byte("ZIP_DATA")) + ctrl := gomock.NewController(t) + exporter := NewMockStateExporter(ctrl) sink := output.NewPlainSink(io.Discard) err := snapshot.Save(context.Background(), healthyRunningMock(t), awsContainers, exporter, "", dest, sink) From 4015b7659fe84dfc65bd4d051a42919fdabcf7ec Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 13 May 2026 12:41:12 +0200 Subject: [PATCH 24/26] Adjust snapshot name when destination not provided: append 3 char hex --- cmd/snapshot.go | 2 +- internal/snapshot/destination.go | 7 +++++-- internal/snapshot/destination_test.go | 26 ++++++++++++++++---------- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 59fc18bb..0d1c44ff 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -33,7 +33,7 @@ func newSnapshotSaveCmd(cfg *env.Env) *cobra.Command { Pass [destination] as an absolute or relative path for the exported file: - lstk snapshot save # saves to ./snapshot-.zip + lstk snapshot save # saves to ./snapshot--.zip lstk snapshot save ./my-snapshot.zip # saves to ./my-snapshot.zip lstk snapshot save /tmp/my-state # saves to /tmp/my-state.zip diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go index 4f0e4b8e..5d0ccea8 100644 --- a/internal/snapshot/destination.go +++ b/internal/snapshot/destination.go @@ -1,6 +1,7 @@ package snapshot import ( + "crypto/rand" "errors" "fmt" "os" @@ -18,11 +19,13 @@ var ( // 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.zip", saved in the current working directory. +// "snapshot-2026-05-11T21-04-32-a3f.zip", saved in the current working directory. // The returned path always has a .zip extension. func ParseDestination(dest string, now time.Time) (string, error) { if dest == "" { - dest = "./" + now.UTC().Format("snapshot-2006-01-02T15-04-05") + 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 { diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go index 6303d07d..6edd1ff6 100644 --- a/internal/snapshot/destination_test.go +++ b/internal/snapshot/destination_test.go @@ -3,6 +3,7 @@ package snapshot_test import ( "os" "path/filepath" + "regexp" "runtime" "strings" "testing" @@ -30,20 +31,21 @@ func TestParseDestination(t *testing.T) { require.NoError(t, os.Mkdir(subDir, 0o755)) type testCase struct { - name string // optional; uses input when empty - input string - wantPath string - wantErr string - wantRemoteErr bool - wantSchemeErr bool + name string // optional; uses input when empty + input string + wantPath string + wantPathRegexp string // used instead of wantPath when the result contains a random component + wantErr string + wantRemoteErr bool + wantSchemeErr bool } tests := []testCase{ // --- default (empty input) --- { - name: "default", - input: "", - wantPath: filepath.Join(wd, "snapshot-2026-05-11T21-04-32.zip"), + name: "default", + input: "", + wantPathRegexp: regexp.QuoteMeta(filepath.Join(wd, "snapshot-2026-05-11T21-04-32-")) + `[0-9a-f]{3}\.zip`, }, // --- local paths --- @@ -178,7 +180,11 @@ func TestParseDestination(t *testing.T) { return } require.NoError(t, err) - assert.Equal(t, tc.wantPath, got) + if tc.wantPathRegexp != "" { + assert.Regexp(t, tc.wantPathRegexp, got) + } else { + assert.Equal(t, tc.wantPath, got) + } }) } } From b89d36965a2ed9d24a56d06aba17e81dcd9c74fe Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 13 May 2026 13:11:42 +0200 Subject: [PATCH 25/26] Improve rendering of the path in the success message --- internal/snapshot/destination.go | 16 +++++++ internal/snapshot/destination_test.go | 66 ++++++++++++++++++++++++++ internal/snapshot/export_test.go | 3 ++ internal/snapshot/save.go | 4 +- test/integration/snapshot_save_test.go | 2 +- 5 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 internal/snapshot/export_test.go diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go index 5d0ccea8..46715b19 100644 --- a/internal/snapshot/destination.go +++ b/internal/snapshot/destination.go @@ -17,6 +17,22 @@ var ( 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. diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go index 6edd1ff6..b838d794 100644 --- a/internal/snapshot/destination_test.go +++ b/internal/snapshot/destination_test.go @@ -16,6 +16,72 @@ import ( +func TestDisplayPath(t *testing.T) { + t.Parallel() + + base := t.TempDir() + cwd := filepath.Join(base, "projects", "lstk") + home := filepath.Join(base, "home") + + tests := []struct { + name string + abs string + cwd string + home string + want string + }{ + { + name: "under cwd", + abs: filepath.Join(cwd, "snap.zip"), + cwd: cwd, home: home, + want: "./snap.zip", + }, + { + name: "under cwd subdir", + abs: filepath.Join(cwd, "exports", "snap.zip"), + cwd: cwd, home: home, + want: "./exports/snap.zip", + }, + { + name: "under home but not cwd", + abs: filepath.Join(home, "snap.zip"), + cwd: cwd, home: home, + want: "~/snap.zip", + }, + { + name: "under home subdir", + abs: filepath.Join(home, "downloads", "snap.zip"), + cwd: cwd, home: home, + want: "~/downloads/snap.zip", + }, + { + name: "unrelated to both", + abs: filepath.Join(base, "other", "snap.zip"), + cwd: cwd, home: home, + want: filepath.Join(base, "other", "snap.zip"), + }, + { + name: "empty cwd falls back to home", + abs: filepath.Join(home, "snap.zip"), + cwd: "", home: home, + want: "~/snap.zip", + }, + { + name: "empty cwd and home returns absolute", + abs: filepath.Join(base, "snap.zip"), + cwd: "", home: "", + want: filepath.Join(base, "snap.zip"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, snapshot.DisplayPath(tc.abs, tc.cwd, tc.home)) + }) + } +} + func TestParseDestination(t *testing.T) { t.Parallel() wd, err := os.Getwd() diff --git a/internal/snapshot/export_test.go b/internal/snapshot/export_test.go new file mode 100644 index 00000000..5959cd60 --- /dev/null +++ b/internal/snapshot/export_test.go @@ -0,0 +1,3 @@ +package snapshot + +var DisplayPath = displayPath diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go index 225bdf54..ce9842d7 100644 --- a/internal/snapshot/save.go +++ b/internal/snapshot/save.go @@ -44,7 +44,9 @@ func Save(ctx context.Context, rt runtime.Runtime, containers []config.Container defer func() { sink.Emit(output.SpinnerStop()) if retErr == nil { - sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Snapshot saved to %s", dest)}) + cwd, _ := os.Getwd() + home, _ := os.UserHomeDir() + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Snapshot saved to %s", displayPath(dest, cwd, home))}) } }() diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index 92d278ae..681e889b 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -90,7 +90,7 @@ func TestSnapshotSaveCustomPath(t *testing.T) { ) require.NoError(t, err, "lstk snapshot save failed: %s", stderr) assert.Contains(t, stdout, "Snapshot saved") - assert.Contains(t, stdout, outPath) + assert.Contains(t, stdout, "./my-snap.zip") data, err := os.ReadFile(outPath) require.NoError(t, err, "output file should exist") From 60503f4f85c3f92254edd8c0397a0d3327e75b96 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 13 May 2026 16:02:05 +0200 Subject: [PATCH 26/26] Address suggestions --- cmd/snapshot.go | 2 +- test/integration/snapshot_save_test.go | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 0d1c44ff..705f3179 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -56,7 +56,7 @@ Cloud destinations are not yet supported.`, return fmt.Errorf("failed to get config: %w", err) } - awsContainer := config.ContainerConfig{Type: config.EmulatorAWS, Port: config.DefaultAWSPort} + var awsContainer config.ContainerConfig var found bool for _, c := range appConfig.Containers { if c.Type == config.EmulatorAWS { diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index 681e889b..1c95c11a 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -55,7 +55,7 @@ func TestSnapshotSaveDefaultDestination(t *testing.T) { dir := t.TempDir() stdout, stderr, err := runLstk(t, ctx, dir, - env.With(env.LocalStackHost, lsHost(srv)), + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), "--non-interactive", "snapshot", "save", ) require.NoError(t, err, "lstk snapshot save failed: %s", stderr) @@ -85,7 +85,7 @@ func TestSnapshotSaveCustomPath(t *testing.T) { outPath := filepath.Join(dir, "my-snap.zip") stdout, stderr, err := runLstk(t, ctx, dir, - env.With(env.LocalStackHost, lsHost(srv)), + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), "--non-interactive", "snapshot", "save", outPath, ) require.NoError(t, err, "lstk snapshot save failed: %s", stderr) @@ -112,7 +112,7 @@ func TestSnapshotSaveRelativePath(t *testing.T) { dir := t.TempDir() stdout, stderr, err := runLstk(t, ctx, dir, - env.With(env.LocalStackHost, lsHost(srv)), + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), "--non-interactive", "snapshot", "save", "./my-state", ) require.NoError(t, err, "lstk snapshot save failed: %s", stderr) @@ -135,7 +135,7 @@ func TestSnapshotSaveOverwritesExistingFile(t *testing.T) { require.NoError(t, os.WriteFile(outPath, []byte("OLD"), 0600)) _, stderr, err := runLstk(t, ctx, dir, - env.With(env.LocalStackHost, lsHost(srv)), + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), "--non-interactive", "snapshot", "save", outPath, ) require.NoError(t, err, "lstk snapshot save should overwrite: %s", stderr) @@ -188,7 +188,7 @@ func TestSnapshotSaveInvalidParentDir(t *testing.T) { srv := mockStateServer(t) _, stderr, err := runLstk(t, ctx, t.TempDir(), - env.With(env.LocalStackHost, lsHost(srv)), + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), "--non-interactive", "snapshot", "save", "/no/such/dir/state", ) requireExitCode(t, 1, err) @@ -206,7 +206,7 @@ func TestSnapshotSaveTelemetryEmitted(t *testing.T) { analyticsSrv, events := mockAnalyticsServer(t) _, stderr, err := runLstk(t, ctx, t.TempDir(), - env.With(env.LocalStackHost, lsHost(srv)).With(env.AnalyticsEndpoint, analyticsSrv.URL), + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)).With(env.AnalyticsEndpoint, analyticsSrv.URL), "--non-interactive", "snapshot", "save", ) require.NoError(t, err, "lstk snapshot save failed: %s", stderr) @@ -223,7 +223,7 @@ func TestSnapshotSaveTelemetryOnFailure(t *testing.T) { analyticsSrv, events := mockAnalyticsServer(t) _, _, err := runLstk(t, ctx, t.TempDir(), - env.With(env.AnalyticsEndpoint, analyticsSrv.URL), + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.AnalyticsEndpoint, analyticsSrv.URL), "--non-interactive", "snapshot", "save", ) requireExitCode(t, 1, err) @@ -241,7 +241,7 @@ func TestSnapshotSaveInteractive(t *testing.T) { dir := t.TempDir() out, err := runLstkInPTY(t, ctx, - env.With(env.LocalStackHost, lsHost(srv)), + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), "snapshot", "save", filepath.Join(dir, "snap"), ) require.NoError(t, err, "interactive lstk snapshot save failed")