diff --git a/cmd/root.go b/cmd/root.go index 7d25e893..909203e4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -78,6 +78,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newUpdateCmd(cfg), newDocsCmd(), newAWSCmd(cfg), + newSnapshotCmd(cfg), ) return root diff --git a/cmd/snapshot.go b/cmd/snapshot.go new file mode 100644 index 00000000..7c7680b9 --- /dev/null +++ b/cmd/snapshot.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "fmt" + "os" + "slices" + "time" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/emulator/aws" + "github.com/localstack/lstk/internal/endpoint" + "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" + "github.com/localstack/lstk/internal/snapshot" + "github.com/localstack/lstk/internal/ui" + "github.com/spf13/cobra" +) + +func newSnapshotCmd(cfg *env.Env) *cobra.Command { + cmd := &cobra.Command{ + Use: "snapshot", + Short: "Manage emulator snapshots", + } + cmd.AddCommand(newSnapshotSaveCmd(cfg)) + return cmd +} + +func newSnapshotSaveCmd(cfg *env.Env) *cobra.Command { + return &cobra.Command{ + Use: "save [destination]", + Short: "Save a snapshot of the emulator state", + Long: `Save a snapshot of the running emulator's state. + +Pass [destination] as an absolute or relative path for the exported file: + + lstk snapshot save # saves to ./snapshot-.zip + lstk snapshot save ./my-snapshot.zip # saves to ./my-snapshot.zip + lstk snapshot save /tmp/my-state # saves to /tmp/my-state.zip + +Cloud destinations are not yet supported.`, + Args: cobra.MaximumNArgs(1), + PreRunE: initConfig(nil), + RunE: func(cmd *cobra.Command, args []string) error { + var destArg string + if len(args) > 0 { + destArg = args[0] + } + + dest, err := snapshot.ParseDestination(destArg, time.Now()) + if err != nil { + return err + } + + appConfig, err := config.Get() + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + awsIdx := slices.IndexFunc(appConfig.Containers, func(c config.ContainerConfig) bool { + return c.Type == config.EmulatorAWS + }) + if awsIdx < 0 && len(appConfig.Containers) > 0 { + return fmt.Errorf("snapshot is only supported for the AWS emulator") + } + + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) + 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)) + }, + } +} 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/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/output/plain_sink_test.go b/internal/output/plain_sink_test.go index f7abc94d..86b19f86 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) diff --git a/internal/snapshot/client.go b/internal/snapshot/client.go new file mode 100644 index 00000000..42a4477e --- /dev/null +++ b/internal/snapshot/client.go @@ -0,0 +1,11 @@ +package snapshot + +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/destination.go b/internal/snapshot/destination.go new file mode 100644 index 00000000..7231c962 --- /dev/null +++ b/internal/snapshot/destination.go @@ -0,0 +1,45 @@ +package snapshot + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "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". +// 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 + } + if dest == "~" || strings.HasPrefix(dest, "~/") || strings.HasPrefix(dest, `~\`) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + dest = filepath.Join(home, strings.TrimLeft(dest[1:], `/\`)) + } + abs, err := filepath.Abs(dest) + if err != nil { + return "", fmt.Errorf("resolve path: %w", err) + } + 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 new file mode 100644 index 00000000..c49b4fa1 --- /dev/null +++ b/internal/snapshot/destination_test.go @@ -0,0 +1,119 @@ +package snapshot_test + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/localstack/lstk/internal/snapshot" + "github.com/stretchr/testify/assert" + "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() + wd, err := os.Getwd() + require.NoError(t, err) + 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 + wantErr string + wantCloudErr bool + } + + tests := []testCase{ + { + input: "./my-state", + wantPath: filepath.Join(wd, "my-state.zip"), + }, + { + input: filepath.Join(os.TempDir(), "state"), + wantPath: filepath.Join(os.TempDir(), "state.zip"), + }, + { + input: "~", + wantErr: "is a directory", + }, + { + input: "~/snapshots/s", + wantPath: filepath.Join(home, "snapshots", "s.zip"), + }, + { + input: "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", + wantCloudErr: true, + }, + { + input: "cloud://my-pod", + wantCloudErr: true, + }, + { + input: "s3://bucket/key", + wantCloudErr: true, + }, + } + + if runtime.GOOS == "windows" { + 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`}, + ) + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + got, err := snapshot.ParseDestination(tc.input, now) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + if tc.wantCloudErr { + require.ErrorIs(t, err, snapshot.ErrCloudNotSupported) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantPath, got) + }) + } +} diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go new file mode 100644 index 00000000..65fe5068 --- /dev/null +++ b/internal/snapshot/save.go @@ -0,0 +1,63 @@ +package snapshot + +import ( + "context" + "fmt" + "io" + "os" + + "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, 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)) + } + + runningContainers, err := container.RunningEmulators(ctx, rt, containers) + if err != nil { + return fmt.Errorf("checking emulator status: %w", err) + } + if len(runningContainers) == 0 { + sink.Emit(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")) + } + + 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, 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 { + _ = w.Close() + _ = os.Remove(dest) + return fmt.Errorf("write snapshot: %w", err) + } + + return w.Close() +} diff --git a/internal/snapshot/save_test.go b/internal/snapshot/save_test.go new file mode 100644 index 00000000..050b90f1 --- /dev/null +++ b/internal/snapshot/save_test.go @@ -0,0 +1,200 @@ +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, _ 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 + sink := output.SinkFunc(func(event output.Event) { + events = append(events, event) + }) + return sink, func() []output.Event { 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) { + t.Parallel() + dir := t.TempDir() + dest := 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) + } + } + } + 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) { + t.Parallel() + 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 := 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) { + t.Parallel() + 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 := 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) { + t.Parallel() + dir := t.TempDir() + dest := 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) { + t.Parallel() + dest := "/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(), "save to") +} + +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)) + + dest := 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) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + dir := t.TempDir() + dest := 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..6f1cc368 --- /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, host, dest string) error { + return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { + return snapshot.Save(ctx, rt, containers, exporter, host, dest, sink) + }) +} diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go new file mode 100644 index 00000000..56933c72 --- /dev/null +++ b/test/integration/snapshot_save_test.go @@ -0,0 +1,256 @@ +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") + + 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) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv := mockStateServer(t) + dir := t.TempDir() + outPath := filepath.Join(dir, "my-snap.zip") + + 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.zip")) + 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.zip") + 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) { + 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") +} + +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(), testEnvWithHome(t.TempDir(), ""), + "--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") +}