From 3bc656109a40d0d110b5d3fb1ddc5f8d4caf2564 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 28 Apr 2026 13:21:23 +0200 Subject: [PATCH 1/9] 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 650352f9..437bf7d8 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 5e8bd61856548399d1be896f1346ffeac5759675 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 28 Apr 2026 18:09:00 +0200 Subject: [PATCH 2/9] 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 e80ca53111043cfcca67c5e397fac2e326f93417 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 29 Apr 2026 17:21:09 +0200 Subject: [PATCH 3/9] 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 cbcd58bde5cd5194fb44841fc1da19592ac4aabe Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 29 Apr 2026 17:28:35 +0200 Subject: [PATCH 4/9] 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 ee9350658f1762536ffc23f5c1b165a0505f4e83 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 29 Apr 2026 18:39:13 +0200 Subject: [PATCH 5/9] 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 714989fc5db39d0bbb25055bec22c9e63f6cc490 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 29 Apr 2026 18:45:08 +0200 Subject: [PATCH 6/9] 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 437bf7d8..9fd27bea 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 ed1e8e1cdbe2209ed8638283b371bbd2074963fb Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 29 Apr 2026 18:58:03 +0200 Subject: [PATCH 7/9] 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 9fd27bea..573d7304 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 5ab02859e100e8e32defba7c12f141f0d09e29db Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Fri, 8 May 2026 17:51:19 +0200 Subject: [PATCH 8/9] 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 573d7304..9fd27bea 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 d8df55cc93b90ec2576ff428a288882f7cef4a7f Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Fri, 8 May 2026 18:01:36 +0200 Subject: [PATCH 9/9] 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") }