From 6cd63f7a253e469575c5eaa320e3a36f09b34836 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 28 May 2026 18:50:54 +0200 Subject: [PATCH] Implement --dry-run flag for snapshot load --- cmd/snapshot.go | 32 +++++ internal/emulator/aws/client.go | 43 +++++++ internal/output/events.go | 16 ++- internal/output/plain_format.go | 74 +++++++++++ internal/output/plain_format_test.go | 37 ++++++ internal/output/symbols.go | 4 + internal/snapshot/diff.go | 77 ++++++++++++ internal/snapshot/diff_test.go | 140 +++++++++++++++++++++ internal/snapshot/mock_diff_client_test.go | 57 +++++++++ internal/ui/app.go | 6 + internal/ui/run_snapshot_diff.go | 16 +++ test/integration/aws_cmd_test.go | 5 +- test/integration/snapshot_load_test.go | 68 ++++++++++ 13 files changed, 571 insertions(+), 4 deletions(-) create mode 100644 internal/snapshot/diff.go create mode 100644 internal/snapshot/diff_test.go create mode 100644 internal/snapshot/mock_diff_client_test.go create mode 100644 internal/ui/run_snapshot_diff.go diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 26f9826d..a51eb7f5 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -77,6 +77,7 @@ func newSnapshotLoadCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) RunE: runSnapshotLoad(cfg, tel, logger), } addMergeFlag(cmd) + addDryRunFlag(cmd) return cmd } @@ -91,6 +92,7 @@ func newLoadCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C Annotations: map[string]string{canonicalCommandAnnotation: snapshotLoadCanonical}, } addMergeFlag(cmd) + addDryRunFlag(cmd) return cmd } @@ -98,6 +100,10 @@ func addMergeFlag(cmd *cobra.Command) { cmd.Flags().String("merge", snapshot.MergeStrategyAccountRegion, "Merge strategy: overwrite, account-region-merge, service-merge") } +func addDryRunFlag(cmd *cobra.Command) { + cmd.Flags().Bool("dry-run", false, "Preview what would change without modifying state (pod refs only)") +} + func runSnapshotLoad(cfg *env.Env, tel *telemetry.Client, logger log.Logger) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { strategy, err := cmd.Flags().GetString("merge") @@ -105,6 +111,11 @@ func runSnapshotLoad(cfg *env.Env, tel *telemetry.Client, logger log.Logger) fun return err } + dryRun, err := cmd.Flags().GetBool("dry-run") + if err != nil { + return err + } + home, _ := os.UserHomeDir() src, err := snapshot.ParseSource(args[0], home) if err != nil { @@ -115,6 +126,13 @@ func runSnapshotLoad(cfg *env.Env, tel *telemetry.Client, logger log.Logger) fun return err } + if dryRun { + if src.Kind != snapshot.KindPod { + return fmt.Errorf("--dry-run is only supported for pod refs — use the \"pod:\" prefix (e.g. pod:my-baseline --dry-run)") + } + return execDiff(cmd, cfg, src.Value, strategy) + } + rt, client, host, containers, appConfig, err := resolveSnapshotDeps(cmd.Context(), cfg) if err != nil { return err @@ -135,6 +153,20 @@ func runSnapshotLoad(cfg *env.Env, tel *telemetry.Client, logger log.Logger) fun } } + +func execDiff(cmd *cobra.Command, cfg *env.Env, podName, strategy string) error { + rt, client, host, containers, _, err := resolveSnapshotDeps(cmd.Context(), cfg) + if err != nil { + return err + } + + if isInteractiveMode(cfg) { + return ui.RunSnapshotDiff(cmd.Context(), rt, containers, client, host, podName, cfg.AuthToken, strategy) + } + sink := output.NewPlainSink(os.Stdout) + return snapshot.DiffPod(cmd.Context(), rt, containers, client, host, podName, cfg.AuthToken, strategy, sink) +} + func resolveSnapshotDeps(ctx context.Context, cfg *env.Env) (rt runtime.Runtime, client *aws.Client, host string, containers []config.ContainerConfig, appConfig *config.Config, err error) { appConfig, err = config.Get() if err != nil { diff --git a/internal/emulator/aws/client.go b/internal/emulator/aws/client.go index 838f32ce..e031943f 100644 --- a/internal/emulator/aws/client.go +++ b/internal/emulator/aws/client.go @@ -293,6 +293,49 @@ func (c *Client) LoadPodSnapshot(ctx context.Context, host, podName, authToken, return services, nil } +func (c *Client) DiffPodSnapshot(ctx context.Context, host, podName, authToken string) (snapshot.DiffResult, error) { + url := fmt.Sprintf("http://%s/_localstack/pods/%s/diff", host, podName) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(":"+authToken))) + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("connect to LocalStack: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("diff failed (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var raw map[string][]struct { + OperationType string `json:"operation_type"` + } + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("parse diff response: %w", err) + } + + result := make(snapshot.DiffResult, len(raw)) + for svc, ops := range raw { + var counts snapshot.ServiceDiffCounts + for _, op := range ops { + switch op.OperationType { + case "ADDITION": + counts.Additions++ + case "MODIFICATION": + counts.Modifications++ + // DELETION is intentionally omitted: the diff endpoint does not currently return deletions. + } + } + result[svc] = counts + } + return result, nil +} + func (c *Client) SavePodSnapshot(ctx context.Context, host, podName, authToken string) (snapshot.PodSaveResult, error) { url := fmt.Sprintf("http://%s/_localstack/pods/%s", host, podName) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader([]byte("{}"))) diff --git a/internal/output/events.go b/internal/output/events.go index 260ce825..bc515afc 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -91,6 +91,17 @@ type SnapshotLoadedEvent struct { type AuthCompleteEvent struct{} +type SnapshotDiffServiceResult struct { + Additions int + Modifications int +} + +type SnapshotDiffEvent struct { + PodName string + Strategy string + Services map[string]SnapshotDiffServiceResult +} + // Event is a sealed marker — only event types in this package implement it, // so Sink.Emit rejects unknown types at compile time. type Event interface{ sealedEvent() } @@ -103,8 +114,9 @@ func (AuthCompleteEvent) sealedEvent() {} func (InstanceInfoEvent) sealedEvent() {} func (TableEvent) sealedEvent() {} func (ResourceSummaryEvent) sealedEvent() {} -func (PodSnapshotSavedEvent) sealedEvent() {} -func (SnapshotLoadedEvent) sealedEvent() {} +func (PodSnapshotSavedEvent) sealedEvent() {} +func (SnapshotLoadedEvent) sealedEvent() {} +func (SnapshotDiffEvent) sealedEvent() {} func (ContainerStatusEvent) sealedEvent() {} func (ProgressEvent) sealedEvent() {} func (UserInputRequestEvent) sealedEvent() {} diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index c565c7b6..2c2baf61 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -2,6 +2,7 @@ package output import ( "fmt" + "sort" "strings" "time" ) @@ -44,6 +45,8 @@ func FormatEventLine(event Event) (string, bool) { return formatPodSnapshotSaved(e), true case SnapshotLoadedEvent: return formatSnapshotLoaded(e), true + case SnapshotDiffEvent: + return formatSnapshotDiff(e), true case AuthCompleteEvent: return "", false default: @@ -224,6 +227,77 @@ func formatPodSnapshotSaved(e PodSnapshotSavedEvent) string { return sb.String() } +func formatSnapshotDiff(e SnapshotDiffEvent) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Dry-run results for pod:%s", e.PodName)) + + services := make([]string, 0, len(e.Services)) + for svc := range e.Services { + services = append(services, svc) + } + sort.Strings(services) + + maxWidth := 0 + for _, svc := range services { + if len(svc) > maxWidth { + maxWidth = len(svc) + } + } + + var rows []string + hasModifications := false + totalMods := 0 + for _, svc := range services { + counts := e.Services[svc] + if counts.Additions == 0 && counts.Modifications == 0 { + continue + } + var row strings.Builder + row.WriteString(fmt.Sprintf(" %-*s", maxWidth+2, svc)) + if counts.Additions > 0 { + noun := "additions" + if counts.Additions == 1 { + noun = "addition" + } + row.WriteString(fmt.Sprintf("+ %d %s", counts.Additions, noun)) + } + if counts.Modifications > 0 { + if counts.Additions > 0 { + row.WriteString(" ") + } + hasModifications = true + totalMods += counts.Modifications + noun := "modifications" + if counts.Modifications == 1 { + noun = "modification" + } + row.WriteString(fmt.Sprintf("~ %d %s %s", counts.Modifications, noun, WarningMarker())) + } + rows = append(rows, row.String()) + } + + if len(rows) == 0 { + sb.WriteString("\n\n No changes — pod state matches running state.") + } else { + sb.WriteString("\n") + for _, r := range rows { + sb.WriteString("\n") + sb.WriteString(r) + } + } + + if hasModifications { + noun := "modifications" + if totalMods == 1 { + noun = "modification" + } + sb.WriteString(fmt.Sprintf("\n\n> Note: %d %s will be resolved using the %s strategy.", totalMods, noun, e.Strategy)) + } + + sb.WriteString("\n\n" + SuccessMarker() + " No state was modified.") + return sb.String() +} + func formatBytes(b int64) string { switch { case b >= byteGB: diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index 7e269e24..d975d36b 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -236,6 +236,43 @@ func TestFormatEventLine(t *testing.T) { want: SuccessMarker() + " Snapshot loaded from pod:empty-pod", wantOK: true, }, + + // snapshot diff events + { + name: "snapshot diff with additions and modifications", + event: SnapshotDiffEvent{ + PodName: "my-baseline", + Strategy: "account-region-merge", + Services: map[string]SnapshotDiffServiceResult{ + "s3": {Additions: 5}, + "sqs": {Additions: 3, Modifications: 1}, + }, + }, + want: "Dry-run results for pod:my-baseline\n\n s3 + 5 additions\n sqs + 3 additions ~ 1 modification ⚠\n\n> Note: 1 modification will be resolved using the account-region-merge strategy.\n\n" + SuccessMarker() + " No state was modified.", + wantOK: true, + }, + { + name: "snapshot diff additions only", + event: SnapshotDiffEvent{ + PodName: "my-baseline", + Strategy: "account-region-merge", + Services: map[string]SnapshotDiffServiceResult{ + "dynamodb": {Additions: 2}, + }, + }, + want: "Dry-run results for pod:my-baseline\n\n dynamodb + 2 additions\n\n" + SuccessMarker() + " No state was modified.", + wantOK: true, + }, + { + name: "snapshot diff no changes", + event: SnapshotDiffEvent{ + PodName: "empty-pod", + Strategy: "account-region-merge", + Services: map[string]SnapshotDiffServiceResult{}, + }, + want: "Dry-run results for pod:empty-pod\n\n No changes — pod state matches running state.\n\n" + SuccessMarker() + " No state was modified.", + wantOK: true, + }, } for _, tt := range tests { diff --git a/internal/output/symbols.go b/internal/output/symbols.go index 94834287..9954a7cc 100644 --- a/internal/output/symbols.go +++ b/internal/output/symbols.go @@ -3,3 +3,7 @@ package output func SuccessMarker() string { return "✔︎" } + +func WarningMarker() string { + return "⚠" +} diff --git a/internal/snapshot/diff.go b/internal/snapshot/diff.go new file mode 100644 index 00000000..0caf99fb --- /dev/null +++ b/internal/snapshot/diff.go @@ -0,0 +1,77 @@ +//go:generate mockgen -source=diff.go -destination=mock_diff_client_test.go -package=snapshot_test + +package snapshot + +import ( + "context" + "fmt" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/container" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" +) + +// ServiceDiffCounts holds the addition and modification counts for a single service. +type ServiceDiffCounts struct { + Additions int + Modifications int +} + +// DiffResult maps service name to addition/modification counts from a diff response. +type DiffResult map[string]ServiceDiffCounts + +// PodDiffer is satisfied by aws.Client. +type PodDiffer interface { + DiffPodSnapshot(ctx context.Context, host, podName, authToken string) (DiffResult, error) +} + +// DiffPod calls the diff endpoint for a named pod and emits a SnapshotDiffEvent. +// It requires the emulator to already be running (unlike LoadPod, there is no auto-start). +func DiffPod(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, differ PodDiffer, host, podName, authToken, strategy string, sink output.Sink) error { + if authToken == "" { + return fmt.Errorf("pod snapshots require authentication — set LOCALSTACK_AUTH_TOKEN or run %q", "lstk login") + } + + 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(fmt.Sprintf("Checking diff for pod %q...", podName))) + result, err := differ.DiffPodSnapshot(ctx, host, podName, authToken) + sink.Emit(output.SpinnerStop()) + if err != nil { + return err + } + + services := make(map[string]output.SnapshotDiffServiceResult, len(result)) + for svc, counts := range result { + services[svc] = output.SnapshotDiffServiceResult{ + Additions: counts.Additions, + Modifications: counts.Modifications, + } + } + sink.Emit(output.SnapshotDiffEvent{ + PodName: podName, + Strategy: strategy, + Services: services, + }) + return nil +} diff --git a/internal/snapshot/diff_test.go b/internal/snapshot/diff_test.go new file mode 100644 index 00000000..f2b0f172 --- /dev/null +++ b/internal/snapshot/diff_test.go @@ -0,0 +1,140 @@ +package snapshot_test + +import ( + "context" + "fmt" + "io" + "testing" + + "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 TestDiffPod_Success(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + differ := NewMockPodDiffer(ctrl) + differ.EXPECT().DiffPodSnapshot(gomock.Any(), gomock.Any(), "my-baseline", "test-token"). + Return(snapshot.DiffResult{ + "s3": {Additions: 5}, + "sqs": {Additions: 3, Modifications: 1}, + "dynamodb": {Additions: 1}, + }, nil) + + sink, getEvents := captureEvents(t) + err := snapshot.DiffPod(context.Background(), healthyRunningMock(t), awsContainers, differ, "", "my-baseline", "test-token", snapshot.MergeStrategyAccountRegion, sink) + require.NoError(t, err) + + events := getEvents() + var spinnerStarted, spinnerStopped bool + var diffEvent *output.SnapshotDiffEvent + for _, e := range events { + switch ev := e.(type) { + case output.SpinnerEvent: + if ev.Active { + spinnerStarted = true + } else { + spinnerStopped = true + } + case output.SnapshotDiffEvent: + diffEvent = &ev + } + } + assert.True(t, spinnerStarted, "spinner should have started") + assert.True(t, spinnerStopped, "spinner should have stopped") + require.NotNil(t, diffEvent, "SnapshotDiffEvent should have been emitted") + assert.Equal(t, "my-baseline", diffEvent.PodName) + assert.Equal(t, snapshot.MergeStrategyAccountRegion, diffEvent.Strategy) + assert.Equal(t, 5, diffEvent.Services["s3"].Additions) + assert.Equal(t, 3, diffEvent.Services["sqs"].Additions) + assert.Equal(t, 1, diffEvent.Services["sqs"].Modifications) +} + +func TestDiffPod_EmptyResult(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + differ := NewMockPodDiffer(ctrl) + differ.EXPECT().DiffPodSnapshot(gomock.Any(), gomock.Any(), "empty-pod", "test-token"). + Return(snapshot.DiffResult{}, nil) + + sink, getEvents := captureEvents(t) + err := snapshot.DiffPod(context.Background(), healthyRunningMock(t), awsContainers, differ, "", "empty-pod", "test-token", snapshot.MergeStrategyAccountRegion, sink) + require.NoError(t, err) + + var diffEvent *output.SnapshotDiffEvent + for _, e := range getEvents() { + if ev, ok := e.(output.SnapshotDiffEvent); ok { + diffEvent = &ev + } + } + require.NotNil(t, diffEvent, "SnapshotDiffEvent should have been emitted even for empty result") + assert.Empty(t, diffEvent.Services) +} + +func TestDiffPod_NoAuthToken(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + differ := NewMockPodDiffer(ctrl) + sink := output.NewPlainSink(io.Discard) + + err := snapshot.DiffPod(context.Background(), runtime.NewMockRuntime(ctrl), awsContainers, differ, "", "my-baseline", "", "", sink) + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication") +} + +func TestDiffPod_DifferError(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + differ := NewMockPodDiffer(ctrl) + differ.EXPECT().DiffPodSnapshot(gomock.Any(), gomock.Any(), "my-baseline", "test-token"). + Return(nil, fmt.Errorf("platform unreachable")) + + sink, _ := captureEvents(t) + err := snapshot.DiffPod(context.Background(), healthyRunningMock(t), awsContainers, differ, "", "my-baseline", "test-token", "", sink) + require.Error(t, err) + assert.Contains(t, err.Error(), "platform unreachable") +} + +func TestDiffPod_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) + + differ := NewMockPodDiffer(ctrl) + sink, getEvents := captureEvents(t) + + err := snapshot.DiffPod(context.Background(), mockRT, awsContainers, differ, "", "my-baseline", "test-token", "", 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.True(t, gotErrorEvent, "ErrorEvent should have been emitted") +} + +func TestDiffPod_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()) + + differ := NewMockPodDiffer(ctrl) + sink := output.NewPlainSink(io.Discard) + + err := snapshot.DiffPod(context.Background(), mockRT, awsContainers, differ, "", "my-baseline", "test-token", "", sink) + require.Error(t, err) + assert.True(t, output.IsSilent(err)) +} diff --git a/internal/snapshot/mock_diff_client_test.go b/internal/snapshot/mock_diff_client_test.go new file mode 100644 index 00000000..51d7ba3d --- /dev/null +++ b/internal/snapshot/mock_diff_client_test.go @@ -0,0 +1,57 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: diff.go +// +// Generated by this command: +// +// mockgen -source=diff.go -destination=mock_diff_client_test.go -package=snapshot_test +// + +// Package snapshot_test is a generated GoMock package. +package snapshot_test + +import ( + context "context" + reflect "reflect" + + snapshot "github.com/localstack/lstk/internal/snapshot" + gomock "go.uber.org/mock/gomock" +) + +// MockPodDiffer is a mock of PodDiffer interface. +type MockPodDiffer struct { + ctrl *gomock.Controller + recorder *MockPodDifferMockRecorder + isgomock struct{} +} + +// MockPodDifferMockRecorder is the mock recorder for MockPodDiffer. +type MockPodDifferMockRecorder struct { + mock *MockPodDiffer +} + +// NewMockPodDiffer creates a new mock instance. +func NewMockPodDiffer(ctrl *gomock.Controller) *MockPodDiffer { + mock := &MockPodDiffer{ctrl: ctrl} + mock.recorder = &MockPodDifferMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPodDiffer) EXPECT() *MockPodDifferMockRecorder { + return m.recorder +} + +// DiffPodSnapshot mocks base method. +func (m *MockPodDiffer) DiffPodSnapshot(ctx context.Context, host, podName, authToken string) (snapshot.DiffResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DiffPodSnapshot", ctx, host, podName, authToken) + ret0, _ := ret[0].(snapshot.DiffResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DiffPodSnapshot indicates an expected call of DiffPodSnapshot. +func (mr *MockPodDifferMockRecorder) DiffPodSnapshot(ctx, host, podName, authToken any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DiffPodSnapshot", reflect.TypeOf((*MockPodDiffer)(nil).DiffPodSnapshot), ctx, host, podName, authToken) +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 4d5baa96..21289d18 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -303,6 +303,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.addSuccessLines(line) } return a, nil + case output.SnapshotDiffEvent: + if line, ok := output.FormatEventLine(msg); ok { + a.addSuccessLines(line) + } + return a, nil default: if e, ok := msg.(output.Event); ok { if line, ok := output.FormatEventLine(e); ok { @@ -525,3 +530,4 @@ func (a App) View() string { func (a App) Err() error { return a.err } + diff --git a/internal/ui/run_snapshot_diff.go b/internal/ui/run_snapshot_diff.go new file mode 100644 index 00000000..e059f572 --- /dev/null +++ b/internal/ui/run_snapshot_diff.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 RunSnapshotDiff(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, client snapshot.PodDiffer, host, podName, authToken, strategy string) error { + return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { + return snapshot.DiffPod(ctx, rt, containers, client, host, podName, authToken, strategy, sink) + }) +} diff --git a/test/integration/aws_cmd_test.go b/test/integration/aws_cmd_test.go index fca8df64..0ad973e2 100644 --- a/test/integration/aws_cmd_test.go +++ b/test/integration/aws_cmd_test.go @@ -8,8 +8,8 @@ import ( "runtime" "testing" - "github.com/moby/moby/client" "github.com/localstack/lstk/test/integration/env" + "github.com/moby/moby/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -285,7 +285,8 @@ func TestAWSCommandWorksWithExternalContainer(t *testing.T) { ctx := testContext(t) const fakeImage = "localstack/localstack-pro:test-fake" - _, err := dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: testImage, Target: fakeImage}); require.NoError(t, err) + _, err := dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: testImage, Target: fakeImage}) + require.NoError(t, err) t.Cleanup(func() { _, _ = dockerClient.ImageRemove(context.Background(), fakeImage, client.ImageRemoveOptions{}) }) diff --git a/test/integration/snapshot_load_test.go b/test/integration/snapshot_load_test.go index 60932652..fce5ccdb 100644 --- a/test/integration/snapshot_load_test.go +++ b/test/integration/snapshot_load_test.go @@ -14,6 +14,25 @@ import ( "github.com/stretchr/testify/require" ) +// mockPodDiffServer returns a test server that handles GET /_localstack/pods/{name}/diff. +// It responds with a fixed diff payload: two S3 additions and one DynamoDB modification. +func mockPodDiffServer(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/_localstack/pods/") && + strings.HasSuffix(r.URL.Path, "/diff") && + r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"s3":[{"operation_type":"ADDITION"},{"operation_type":"ADDITION"}],"dynamodb":[{"operation_type":"MODIFICATION"}]}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + return srv +} + // mockLocalLoadServer returns a test server that handles local snapshot import: // - POST /_localstack/pods → import (always succeeds) // - POST /_localstack/state/reset → state reset (overwrite strategy) @@ -300,3 +319,52 @@ func TestLoadAliasMatchesSnapshotLoad(t *testing.T) { // split across "load" and "snapshot load" labels. assertCommandTelemetry(t, events, "snapshot load", 0) } + +// --- dry-run tests --- + +func TestSnapshotLoadDryRunOnLocalRef(t *testing.T) { + t.Parallel() + ctx := testContext(t) + dir := t.TempDir() + snapPath := writeTestSnapFile(t, dir, "snap.zip") + + _, stderr, err := runLstk(t, ctx, dir, + testEnvWithHome(t.TempDir(), ""), + "--non-interactive", "snapshot", "load", "--dry-run", snapPath, + ) + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "pod refs") +} + +func TestSnapshotLoadDryRunPodNoAuthToken(t *testing.T) { + t.Parallel() + ctx := testContext(t) + + _, stderr, err := runLstk(t, ctx, t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")).Without(env.AuthToken), + "--non-interactive", "snapshot", "load", "--dry-run", "pod:my-baseline", + ) + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "authentication") +} + +func TestSnapshotLoadDryRunPodSuccess(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv := mockPodDiffServer(t) + + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.LocalStackHost, lsHost(srv)). + With(env.AuthToken, "test-token"), + "--non-interactive", "snapshot", "load", "--dry-run", "pod:my-baseline", + ) + require.NoError(t, err, "lstk snapshot load --dry-run failed: %s", stderr) + assert.Contains(t, stdout, "Dry-run results") + assert.Contains(t, stdout, "my-baseline") + assert.Contains(t, stdout, "No state was modified.") +}