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..705f3179 --- /dev/null +++ b/cmd/snapshot.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "fmt" + "os" + "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) + } + + var awsContainer config.ContainerConfig + var found bool + for _, c := range appConfig.Containers { + if c.Type == config.EmulatorAWS { + awsContainer = c + found = true + break + } + } + if !found { + return fmt.Errorf("snapshot is only supported for the AWS emulator") + } + + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) + if err != nil { + return err + } + 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.NewPlainSink(os.Stdout)) + }, + } +} diff --git a/internal/emulator/aws/client.go b/internal/emulator/aws/client.go index e0122213..c5a23ca2 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,26 @@ func (c *Client) FetchResources(ctx context.Context, host string) ([]emulator.Re return rows, nil } + +func (c *Client) ExportState(ctx context.Context, host string, dst io.Writer) error { + url := fmt.Sprintf("http://%s/_localstack/pods/state", host) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("connect to LocalStack: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("LocalStack returned status %d", resp.StatusCode) + } + + if _, err := io.Copy(dst, resp.Body); err != nil { + return fmt.Errorf("stream state: %w", err) + } + return nil +} diff --git a/internal/emulator/aws/client_test.go b/internal/emulator/aws/client_test.go index cbb915fc..8e46b2a0 100644 --- a/internal/emulator/aws/client_test.go +++ b/internal/emulator/aws/client_test.go @@ -1,10 +1,13 @@ package aws import ( + "bytes" "context" "fmt" + "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -107,3 +110,104 @@ 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() + + var buf bytes.Buffer + c := NewClient() + err := c.ExportState(context.Background(), srv.Listener.Addr().String(), &buf) + require.NoError(t, err) + assert.Equal(t, "ZIP_DATA", buf.String()) + }) + + 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(), io.Discard) + 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(), io.Discard) + 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, io.Discard) + 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() { + errCh <- c.ExportState(ctx, srv.Listener.Addr().String(), io.Discard) + }() + + <-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() + + var buf bytes.Buffer + c := NewClient() + err := c.ExportState(context.Background(), srv.Listener.Addr().String(), &buf) + require.NoError(t, err) + assert.Equal(t, size, buf.Len()) + }) +} + diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go new file mode 100644 index 00000000..46715b19 --- /dev/null +++ b/internal/snapshot/destination.go @@ -0,0 +1,92 @@ +package snapshot + +import ( + "crypto/rand" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +var ( + // ErrRemoteNotSupported is returned for known remote schemes (s3://, oras://, cloud:). + ErrRemoteNotSupported = errors.New("remote destinations are not yet supported — coming soon") + // ErrUnknownScheme is returned for unrecognized URL schemes. + ErrUnknownScheme = errors.New("unrecognized destination scheme") +) + +// displayPath shortens abs for human-readable output: +// under cwd → ./rel, under home → ~/rel, otherwise unchanged. +func displayPath(abs, cwd, home string) string { + if cwd != "" { + if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") { + return "./" + filepath.ToSlash(rel) + } + } + if home != "" { + if rel, err := filepath.Rel(home, abs); err == nil && !strings.HasPrefix(rel, "..") { + return "~/" + filepath.ToSlash(rel) + } + } + return abs +} + +// ParseDestination resolves the user-supplied path to an absolute local path. +// When dest is empty, a default name based on now (UTC) is used, e.g. +// "snapshot-2026-05-11T21-04-32-a3f.zip", saved in the current working directory. +// The returned path always has a .zip extension. +func ParseDestination(dest string, now time.Time) (string, error) { + if dest == "" { + b := make([]byte, 2) + _, _ = rand.Read(b) + dest = "./" + now.UTC().Format("snapshot-2006-01-02T15-04-05") + "-" + fmt.Sprintf("%x", b)[:3] + } else { + lower := strings.ToLower(dest) + switch { + case strings.HasPrefix(lower, "s3://"), + strings.HasPrefix(lower, "oras://"), + strings.HasPrefix(lower, "cloud:"): + return "", ErrRemoteNotSupported + case strings.Contains(lower, "://"): + scheme, _, _ := strings.Cut(dest, "://") + return "", fmt.Errorf("%w: %q", ErrUnknownScheme, scheme+"://") + } + } + + 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) + } + + parent := filepath.Dir(abs) + parentInfo, err := os.Stat(parent) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("parent directory %q does not exist — create it first", parent) + } + return "", fmt.Errorf("check parent directory: %w", err) + } + if !parentInfo.IsDir() { + return "", fmt.Errorf("parent path %q is not a directory", parent) + } + + if info, err := os.Stat(abs); err == nil && info.IsDir() { + return "", fmt.Errorf("%q is a directory — specify a file path like ./my-snapshot", 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..b838d794 --- /dev/null +++ b/internal/snapshot/destination_test.go @@ -0,0 +1,256 @@ +package snapshot_test + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + "time" + + "github.com/localstack/lstk/internal/snapshot" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + + + +func TestDisplayPath(t *testing.T) { + t.Parallel() + + base := t.TempDir() + cwd := filepath.Join(base, "projects", "lstk") + home := filepath.Join(base, "home") + + tests := []struct { + name string + abs string + cwd string + home string + want string + }{ + { + name: "under cwd", + abs: filepath.Join(cwd, "snap.zip"), + cwd: cwd, home: home, + want: "./snap.zip", + }, + { + name: "under cwd subdir", + abs: filepath.Join(cwd, "exports", "snap.zip"), + cwd: cwd, home: home, + want: "./exports/snap.zip", + }, + { + name: "under home but not cwd", + abs: filepath.Join(home, "snap.zip"), + cwd: cwd, home: home, + want: "~/snap.zip", + }, + { + name: "under home subdir", + abs: filepath.Join(home, "downloads", "snap.zip"), + cwd: cwd, home: home, + want: "~/downloads/snap.zip", + }, + { + name: "unrelated to both", + abs: filepath.Join(base, "other", "snap.zip"), + cwd: cwd, home: home, + want: filepath.Join(base, "other", "snap.zip"), + }, + { + name: "empty cwd falls back to home", + abs: filepath.Join(home, "snap.zip"), + cwd: "", home: home, + want: "~/snap.zip", + }, + { + name: "empty cwd and home returns absolute", + abs: filepath.Join(base, "snap.zip"), + cwd: "", home: "", + want: filepath.Join(base, "snap.zip"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, snapshot.DisplayPath(tc.abs, tc.cwd, tc.home)) + }) + } +} + +func TestParseDestination(t *testing.T) { + t.Parallel() + wd, err := os.Getwd() + require.NoError(t, err) + home, err := os.UserHomeDir() + require.NoError(t, err) + + now := time.Date(2026, 5, 11, 21, 4, 32, 0, time.UTC) + + // Set up dirs used in path-based cases below. + existingDir := t.TempDir() + subDir := filepath.Join(existingDir, "subdir") + require.NoError(t, os.Mkdir(subDir, 0o755)) + + type testCase struct { + name string // optional; uses input when empty + input string + wantPath string + wantPathRegexp string // used instead of wantPath when the result contains a random component + wantErr string + wantRemoteErr bool + wantSchemeErr bool + } + + tests := []testCase{ + // --- default (empty input) --- + { + name: "default", + input: "", + wantPathRegexp: regexp.QuoteMeta(filepath.Join(wd, "snapshot-2026-05-11T21-04-32-")) + `[0-9a-f]{3}\.zip`, + }, + + // --- local paths --- + { + 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", + }, + { + // parent (~/) always exists + input: "~/my-state", + wantPath: filepath.Join(home, "my-state.zip"), + }, + { + name: "relative path with existing subdir", + input: filepath.Join(subDir, "state"), + wantPath: filepath.Join(subDir, "state.zip"), + }, + { + // bare name: treated as relative to CWD + input: "my-pod", + wantPath: filepath.Join(wd, "my-pod.zip"), + }, + { + input: "./checkpoint.zip", + wantPath: filepath.Join(wd, "checkpoint.zip"), + }, + { + input: "./already.ZIP", + wantPath: filepath.Join(wd, "already.ZIP"), + }, + + // --- parent directory does not exist --- + { + name: "parent dir missing", + input: filepath.Join(existingDir, "nonexistent", "state"), + wantErr: "parent directory", + }, + + // --- remote: s3 --- + { + input: "s3://bucket/key", + wantRemoteErr: true, + }, + { + input: "S3://bucket/key", + wantRemoteErr: true, + }, + + // --- remote: oras --- + { + input: "oras://registry/image", + wantRemoteErr: true, + }, + { + input: "ORAS://registry/image", + wantRemoteErr: true, + }, + + // --- remote: cloud --- + { + input: "cloud:my-pod", + wantRemoteErr: true, + }, + { + input: "Cloud:my-pod", + wantRemoteErr: true, + }, + { + // cloud: prefix also catches cloud:// + input: "cloud://my-pod", + wantRemoteErr: true, + }, + + // --- unknown schemes --- + { + input: "https://example.com/snap", + wantSchemeErr: true, + }, + { + input: "gcs://bucket/key", + wantSchemeErr: true, + }, + } + + if runtime.GOOS == "windows" { + tmpParent := filepath.Clean(os.TempDir()) + tests = append(tests, + testCase{ + input: `~\my-state`, + wantPath: filepath.Join(home, "my-state.zip"), + }, + testCase{ + name: "windows abs backslash", + input: filepath.Join(tmpParent, "snap"), + wantPath: filepath.Join(tmpParent, "snap.zip"), + }, + testCase{ + name: "windows abs forward-slash", + input: strings.ReplaceAll(filepath.Join(tmpParent, "snap"), `\`, `/`), + wantPath: filepath.Join(tmpParent, "snap.zip"), + }, + ) + } + + for _, tc := range tests { + name := tc.input + if tc.name != "" { + name = tc.name + } + t.Run(name, func(t *testing.T) { + t.Parallel() + got, err := snapshot.ParseDestination(tc.input, now) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + if tc.wantRemoteErr { + require.ErrorIs(t, err, snapshot.ErrRemoteNotSupported) + return + } + if tc.wantSchemeErr { + require.ErrorIs(t, err, snapshot.ErrUnknownScheme) + return + } + require.NoError(t, err) + if tc.wantPathRegexp != "" { + assert.Regexp(t, tc.wantPathRegexp, got) + } else { + assert.Equal(t, tc.wantPath, got) + } + }) + } +} diff --git a/internal/snapshot/export_test.go b/internal/snapshot/export_test.go new file mode 100644 index 00000000..5959cd60 --- /dev/null +++ b/internal/snapshot/export_test.go @@ -0,0 +1,3 @@ +package snapshot + +var DisplayPath = displayPath diff --git a/internal/snapshot/mock_state_exporter_test.go b/internal/snapshot/mock_state_exporter_test.go new file mode 100644 index 00000000..8405cd5b --- /dev/null +++ b/internal/snapshot/mock_state_exporter_test.go @@ -0,0 +1,56 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: save.go +// +// Generated by this command: +// +// mockgen -source=save.go -destination=mock_state_exporter_test.go -package=snapshot_test +// + +// Package snapshot_test is a generated GoMock package. +package snapshot_test + +import ( + context "context" + io "io" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockStateExporter is a mock of StateExporter interface. +type MockStateExporter struct { + ctrl *gomock.Controller + recorder *MockStateExporterMockRecorder + isgomock struct{} +} + +// MockStateExporterMockRecorder is the mock recorder for MockStateExporter. +type MockStateExporterMockRecorder struct { + mock *MockStateExporter +} + +// NewMockStateExporter creates a new mock instance. +func NewMockStateExporter(ctrl *gomock.Controller) *MockStateExporter { + mock := &MockStateExporter{ctrl: ctrl} + mock.recorder = &MockStateExporterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStateExporter) EXPECT() *MockStateExporterMockRecorder { + return m.recorder +} + +// ExportState mocks base method. +func (m *MockStateExporter) ExportState(ctx context.Context, host string, dst io.Writer) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExportState", ctx, host, dst) + ret0, _ := ret[0].(error) + return ret0 +} + +// ExportState indicates an expected call of ExportState. +func (mr *MockStateExporterMockRecorder) ExportState(ctx, host, dst any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExportState", reflect.TypeOf((*MockStateExporter)(nil).ExportState), ctx, host, dst) +} diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go new file mode 100644 index 00000000..ce9842d7 --- /dev/null +++ b/internal/snapshot/save.go @@ -0,0 +1,65 @@ +package snapshot + +//go:generate mockgen -source=save.go -destination=mock_state_exporter_test.go -package=snapshot_test + +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" +) + +// StateExporter retrieves state from the running LocalStack instance. +type StateExporter interface { + ExportState(ctx context.Context, host string, dst io.Writer) error +} + +func Save(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, exporter StateExporter, host, dest string, sink output.Sink) (retErr error) { + 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 { + cwd, _ := os.Getwd() + home, _ := os.UserHomeDir() + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Snapshot saved to %s", displayPath(dest, cwd, home))}) + } + }() + + w, err := os.Create(dest) + if err != nil { + return fmt.Errorf("save to %s: %w", dest, err) + } + + if err := exporter.ExportState(ctx, host, w); err != nil { + _ = w.Close() + _ = os.Remove(dest) + return fmt.Errorf("export state from LocalStack: %w", err) + } + + return w.Close() +} diff --git a/internal/snapshot/save_test.go b/internal/snapshot/save_test.go new file mode 100644 index 00000000..ad0b8a13 --- /dev/null +++ b/internal/snapshot/save_test.go @@ -0,0 +1,212 @@ +package snapshot_test + +import ( + "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" +) + +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 +} + +func mockExporterReturning(t *testing.T, body []byte) *MockStateExporter { + t.Helper() + ctrl := gomock.NewController(t) + m := NewMockStateExporter(ctrl) + m.EXPECT().ExportState(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, dst io.Writer) error { + _, err := dst.Write(body) + return err + }, + ) + return m +} + +func mockExporterReturningError(t *testing.T, exportErr error) *MockStateExporter { + t.Helper() + ctrl := gomock.NewController(t) + m := NewMockStateExporter(ctrl) + m.EXPECT().ExportState(gomock.Any(), gomock.Any(), gomock.Any()).Return(exportErr) + return m +} + +var awsContainers = []config.ContainerConfig{{Type: config.EmulatorAWS}} + +func TestSave_Success(t *testing.T) { + t.Parallel() + dir := t.TempDir() + dest := filepath.Join(dir, "snap") + exporter := mockExporterReturning(t, []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) + + exporter := NewMockStateExporter(ctrl) + + dir := t.TempDir() + dest := filepath.Join(dir, "snap") + sink, getEvents := captureEvents(t) + + err := snapshot.Save(context.Background(), mockRT, awsContainers, exporter, "", 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()) + + exporter := NewMockStateExporter(ctrl) + + dir := t.TempDir() + dest := filepath.Join(dir, "snap") + sink := output.NewPlainSink(io.Discard) + + err := snapshot.Save(context.Background(), mockRT, awsContainers, exporter, "", 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 := mockExporterReturningError(t, 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" + ctrl := gomock.NewController(t) + exporter := NewMockStateExporter(ctrl) + 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 := mockExporterReturning(t, []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 := mockExporterReturningError(t, 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..1c95c11a --- /dev/null +++ b/test/integration/snapshot_save_test.go @@ -0,0 +1,249 @@ +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.Environ(testEnvWithHome(t.TempDir(), "")).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.Environ(testEnvWithHome(t.TempDir(), "")).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, "./my-snap.zip") + + 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.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), + "--non-interactive", "snapshot", "save", "./my-state", + ) + require.NoError(t, err, "lstk snapshot save failed: %s", stderr) + 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.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), + "--non-interactive", "snapshot", "save", outPath, + ) + require.NoError(t, err, "lstk snapshot save should overwrite: %s", stderr) + + data, err := os.ReadFile(outPath) + require.NoError(t, err) + assert.NotEqual(t, "OLD", string(data), "file should have been overwritten") +} + +func TestSnapshotSaveRemoteRejected(t *testing.T) { + t.Parallel() + for _, dest := range []string{ + "s3://my-bucket/my-snap", + "oras://registry/my-snap", + "cloud://my-pod", + } { + t.Run(dest, func(t *testing.T) { + t.Parallel() + ctx := testContext(t) + + _, stderr, err := runLstk(t, ctx, t.TempDir(), testEnvWithHome(t.TempDir(), ""), "--non-interactive", "snapshot", "save", dest) + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "not yet supported") + }) + } +} + +func TestSnapshotSaveLocalStackNotRunning(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + // Intentionally no startTestContainer: the emulator is not running. + + stdout, _, err := runLstk(t, ctx, t.TempDir(), testEnvWithHome(t.TempDir(), ""), + "--non-interactive", "snapshot", "save", + ) + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "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.Environ(testEnvWithHome(t.TempDir(), "")).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.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)).With(env.AnalyticsEndpoint, analyticsSrv.URL), + "--non-interactive", "snapshot", "save", + ) + require.NoError(t, err, "lstk snapshot save failed: %s", stderr) + 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.Environ(testEnvWithHome(t.TempDir(), "")).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.Environ(testEnvWithHome(t.TempDir(), "")).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") +}