diff --git a/cmd/root.go b/cmd/root.go index 650352f9..9fd27bea 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..6638083d --- /dev/null +++ b/cmd/snapshot.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "fmt" + "os" + "slices" + + "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/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 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(nil), + RunE: 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 + } + + appConfig, err := config.Get() + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + 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 + } + + 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, containers, exporter, dest) + } + return snapshot.Save(cmd.Context(), rt, 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..c46abab1 --- /dev/null +++ b/internal/snapshot/client_test.go @@ -0,0 +1,120 @@ +package snapshot_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/localstack/lstk/internal/snapshot" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStateClient_ExportState_OK(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/_localstack/pods/state", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ZIP_DATA")) + })) + defer srv.Close() + + client := snapshot.NewStateClient(srv.URL) + body, err := client.ExportState(context.Background()) + require.NoError(t, err) + defer func() { _ = body.Close() }() + + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, "ZIP_DATA", string(data)) +} + +func TestStateClient_ExportState_ServerError(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + client := snapshot.NewStateClient(srv.URL) + _, err := client.ExportState(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestStateClient_ExportState_NotFound(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := snapshot.NewStateClient(srv.URL) + _, err := client.ExportState(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "404") +} + +func TestStateClient_ExportState_ConnectionRefused(t *testing.T) { + t.Parallel() + // Bind then immediately close to get a port that refuses connections. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + addr := srv.URL + srv.Close() + + client := snapshot.NewStateClient(addr) + _, err := client.ExportState(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "connect to LocalStack") +} + +func TestStateClient_ExportState_ContextCancelled(t *testing.T) { + t.Parallel() + started := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + // block until the client cancels + <-r.Context().Done() + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + client := snapshot.NewStateClient(srv.URL) + + errCh := make(chan error, 1) + go func() { + _, err := client.ExportState(ctx) + errCh <- err + }() + + <-started + cancel() + + err := <-errCh + require.Error(t, err) +} + +func TestStateClient_ExportState_LargeBody(t *testing.T) { + t.Parallel() + const size = 1 << 20 // 1 MB + payload := strings.Repeat("X", size) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(payload)) + })) + defer srv.Close() + + client := snapshot.NewStateClient(srv.URL) + body, err := client.ExportState(context.Background()) + require.NoError(t, err) + defer func() { _ = body.Close() }() + + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, size, len(data)) +} diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go new file mode 100644 index 00000000..60565d62 --- /dev/null +++ b/internal/snapshot/destination.go @@ -0,0 +1,33 @@ +package snapshot + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// 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 "", 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 "", 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 "", fmt.Errorf("resolve home directory: %w", err) + } + dest = home + dest[1:] + } + abs, err := filepath.Abs(dest) + if err != nil { + return "", fmt.Errorf("resolve path: %w", err) + } + return abs, nil +} diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go new file mode 100644 index 00000000..44ed7e44 --- /dev/null +++ b/internal/snapshot/destination_test.go @@ -0,0 +1,73 @@ +package snapshot_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/localstack/lstk/internal/snapshot" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDestination(t *testing.T) { + t.Parallel() + wd, err := os.Getwd() + require.NoError(t, err) + home, err := os.UserHomeDir() + require.NoError(t, err) + + tests := []struct { + input string + wantPath string + wantErr string + }{ + { + input: "", + wantPath: filepath.Join(wd, "ls-state-export"), + }, + { + input: "./my-state", + wantPath: filepath.Join(wd, "my-state"), + }, + { + input: filepath.Join(os.TempDir(), "state"), + wantPath: filepath.Join(os.TempDir(), "state"), + }, + { + input: "~/snapshots/s", + wantPath: filepath.Join(home, "snapshots", "s"), + }, + { + input: "subdir/state", + wantPath: filepath.Join(wd, "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) { + t.Parallel() + 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.wantPath, got) + }) + } +} diff --git a/internal/snapshot/save.go b/internal/snapshot/save.go new file mode 100644 index 00000000..6b87d2a1 --- /dev/null +++ b/internal/snapshot/save.go @@ -0,0 +1,66 @@ +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, 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)) + } + + 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...")) + + body, err := exporter.ExportState(ctx) + if err != nil { + sink.Emit(output.SpinnerStop()) + return fmt.Errorf("export state from LocalStack: %w", err) + } + defer func() { _ = body.Close() }() + + w, err := os.Create(dest) + if err != nil { + sink.Emit(output.SpinnerStop()) + return fmt.Errorf("save to %s: %w", dest, err) + } + + if _, err := io.Copy(w, body); err != nil { + _ = w.Close() + sink.Emit(output.SpinnerStop()) + return fmt.Errorf("write snapshot: %w", err) + } + + if err := w.Close(); err != nil { + sink.Emit(output.SpinnerStop()) + return fmt.Errorf("close snapshot: %w", err) + } + + sink.Emit(output.SpinnerStop()) + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Snapshot saved to %s", dest)}) + return nil +} diff --git a/internal/snapshot/save_test.go b/internal/snapshot/save_test.go new file mode 100644 index 00000000..4cdc6cdb --- /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) (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..f1264a08 --- /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 string) 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..684e6cb6 --- /dev/null +++ b/test/integration/snapshot_save_test.go @@ -0,0 +1,248 @@ +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) { + 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(), 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") +}