Skip to content

Commit 6eba568

Browse files
committed
Implement --dry-run flag for snapshot load
1 parent ec14f45 commit 6eba568

13 files changed

Lines changed: 630 additions & 4 deletions

File tree

cmd/snapshot.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ func newSnapshotLoadCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger)
7777
RunE: runSnapshotLoad(cfg, tel, logger),
7878
}
7979
addMergeFlag(cmd)
80+
addDryRunFlag(cmd)
8081
return cmd
8182
}
8283

@@ -91,20 +92,30 @@ func newLoadCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
9192
Annotations: map[string]string{canonicalCommandAnnotation: snapshotLoadCanonical},
9293
}
9394
addMergeFlag(cmd)
95+
addDryRunFlag(cmd)
9496
return cmd
9597
}
9698

9799
func addMergeFlag(cmd *cobra.Command) {
98100
cmd.Flags().String("merge", snapshot.MergeStrategyAccountRegion, "Merge strategy: overwrite, account-region-merge, service-merge")
99101
}
100102

103+
func addDryRunFlag(cmd *cobra.Command) {
104+
cmd.Flags().Bool("dry-run", false, "Preview what would change without modifying state (pod refs only)")
105+
}
106+
101107
func runSnapshotLoad(cfg *env.Env, tel *telemetry.Client, logger log.Logger) func(*cobra.Command, []string) error {
102108
return func(cmd *cobra.Command, args []string) error {
103109
strategy, err := cmd.Flags().GetString("merge")
104110
if err != nil {
105111
return err
106112
}
107113

114+
dryRun, err := cmd.Flags().GetBool("dry-run")
115+
if err != nil {
116+
return err
117+
}
118+
108119
home, _ := os.UserHomeDir()
109120
src, err := snapshot.ParseSource(args[0], home)
110121
if err != nil {
@@ -115,6 +126,13 @@ func runSnapshotLoad(cfg *env.Env, tel *telemetry.Client, logger log.Logger) fun
115126
return err
116127
}
117128

129+
if dryRun {
130+
if src.Kind != snapshot.KindPod {
131+
return fmt.Errorf("--dry-run is only supported for pod refs — use the \"pod:\" prefix (e.g. pod:my-baseline --dry-run)")
132+
}
133+
return execDiff(cmd, cfg, src.Value, strategy)
134+
}
135+
118136
rt, client, host, containers, appConfig, err := resolveSnapshotDeps(cmd.Context(), cfg)
119137
if err != nil {
120138
return err
@@ -135,6 +153,20 @@ func runSnapshotLoad(cfg *env.Env, tel *telemetry.Client, logger log.Logger) fun
135153
}
136154
}
137155

156+
157+
func execDiff(cmd *cobra.Command, cfg *env.Env, podName, strategy string) error {
158+
rt, client, host, containers, _, err := resolveSnapshotDeps(cmd.Context(), cfg)
159+
if err != nil {
160+
return err
161+
}
162+
163+
if isInteractiveMode(cfg) {
164+
return ui.RunSnapshotDiff(cmd.Context(), rt, containers, client, host, podName, cfg.AuthToken, strategy)
165+
}
166+
sink := output.NewPlainSink(os.Stdout)
167+
return snapshot.DiffPod(cmd.Context(), rt, containers, client, host, podName, cfg.AuthToken, strategy, sink)
168+
}
169+
138170
func resolveSnapshotDeps(ctx context.Context, cfg *env.Env) (rt runtime.Runtime, client *aws.Client, host string, containers []config.ContainerConfig, appConfig *config.Config, err error) {
139171
appConfig, err = config.Get()
140172
if err != nil {

internal/emulator/aws/client.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,49 @@ func (c *Client) LoadPodSnapshot(ctx context.Context, host, podName, authToken,
293293
return services, nil
294294
}
295295

296+
func (c *Client) DiffPodSnapshot(ctx context.Context, host, podName, authToken string) (snapshot.DiffResult, error) {
297+
url := fmt.Sprintf("http://%s/_localstack/pods/%s/diff", host, podName)
298+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
299+
if err != nil {
300+
return nil, fmt.Errorf("create request: %w", err)
301+
}
302+
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(":"+authToken)))
303+
304+
resp, err := c.http.Do(req)
305+
if err != nil {
306+
return nil, fmt.Errorf("connect to LocalStack: %w", err)
307+
}
308+
defer func() { _ = resp.Body.Close() }()
309+
310+
if resp.StatusCode != http.StatusOK {
311+
body, _ := io.ReadAll(resp.Body)
312+
return nil, fmt.Errorf("diff failed (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
313+
}
314+
315+
var raw map[string][]struct {
316+
OperationType string `json:"operation_type"`
317+
}
318+
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
319+
return nil, fmt.Errorf("parse diff response: %w", err)
320+
}
321+
322+
result := make(snapshot.DiffResult, len(raw))
323+
for svc, ops := range raw {
324+
var counts snapshot.ServiceDiffCounts
325+
for _, op := range ops {
326+
switch op.OperationType {
327+
case "ADDITION":
328+
counts.Additions++
329+
case "MODIFICATION":
330+
counts.Modifications++
331+
// DELETION is intentionally omitted: the diff endpoint does not currently return deletions.
332+
}
333+
}
334+
result[svc] = counts
335+
}
336+
return result, nil
337+
}
338+
296339
func (c *Client) SavePodSnapshot(ctx context.Context, host, podName, authToken string) (snapshot.PodSaveResult, error) {
297340
url := fmt.Sprintf("http://%s/_localstack/pods/%s", host, podName)
298341
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader([]byte("{}")))

internal/output/events.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ type SnapshotLoadedEvent struct {
9191

9292
type AuthCompleteEvent struct{}
9393

94+
type SnapshotDiffServiceResult struct {
95+
Additions int
96+
Modifications int
97+
}
98+
99+
type SnapshotDiffEvent struct {
100+
PodName string
101+
Strategy string
102+
Services map[string]SnapshotDiffServiceResult
103+
}
104+
94105
// Event is a sealed marker — only event types in this package implement it,
95106
// so Sink.Emit rejects unknown types at compile time.
96107
type Event interface{ sealedEvent() }
@@ -103,8 +114,9 @@ func (AuthCompleteEvent) sealedEvent() {}
103114
func (InstanceInfoEvent) sealedEvent() {}
104115
func (TableEvent) sealedEvent() {}
105116
func (ResourceSummaryEvent) sealedEvent() {}
106-
func (PodSnapshotSavedEvent) sealedEvent() {}
107-
func (SnapshotLoadedEvent) sealedEvent() {}
117+
func (PodSnapshotSavedEvent) sealedEvent() {}
118+
func (SnapshotLoadedEvent) sealedEvent() {}
119+
func (SnapshotDiffEvent) sealedEvent() {}
108120
func (ContainerStatusEvent) sealedEvent() {}
109121
func (ProgressEvent) sealedEvent() {}
110122
func (UserInputRequestEvent) sealedEvent() {}

internal/output/plain_format.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package output
22

33
import (
44
"fmt"
5+
"sort"
56
"strings"
67
"time"
78
)
@@ -44,6 +45,8 @@ func FormatEventLine(event Event) (string, bool) {
4445
return formatPodSnapshotSaved(e), true
4546
case SnapshotLoadedEvent:
4647
return formatSnapshotLoaded(e), true
48+
case SnapshotDiffEvent:
49+
return formatSnapshotDiff(e), true
4750
case AuthCompleteEvent:
4851
return "", false
4952
default:
@@ -224,6 +227,77 @@ func formatPodSnapshotSaved(e PodSnapshotSavedEvent) string {
224227
return sb.String()
225228
}
226229

230+
func formatSnapshotDiff(e SnapshotDiffEvent) string {
231+
var sb strings.Builder
232+
sb.WriteString(fmt.Sprintf("Dry-run results for pod:%s", e.PodName))
233+
234+
services := make([]string, 0, len(e.Services))
235+
for svc := range e.Services {
236+
services = append(services, svc)
237+
}
238+
sort.Strings(services)
239+
240+
maxWidth := 0
241+
for _, svc := range services {
242+
if len(svc) > maxWidth {
243+
maxWidth = len(svc)
244+
}
245+
}
246+
247+
var rows []string
248+
hasModifications := false
249+
totalMods := 0
250+
for _, svc := range services {
251+
counts := e.Services[svc]
252+
if counts.Additions == 0 && counts.Modifications == 0 {
253+
continue
254+
}
255+
var row strings.Builder
256+
row.WriteString(fmt.Sprintf(" %-*s", maxWidth+2, svc))
257+
if counts.Additions > 0 {
258+
noun := "additions"
259+
if counts.Additions == 1 {
260+
noun = "addition"
261+
}
262+
row.WriteString(fmt.Sprintf("+ %d %s", counts.Additions, noun))
263+
}
264+
if counts.Modifications > 0 {
265+
if counts.Additions > 0 {
266+
row.WriteString(" ")
267+
}
268+
hasModifications = true
269+
totalMods += counts.Modifications
270+
noun := "modifications"
271+
if counts.Modifications == 1 {
272+
noun = "modification"
273+
}
274+
row.WriteString(fmt.Sprintf("~ %d %s %s", counts.Modifications, noun, WarningMarker()))
275+
}
276+
rows = append(rows, row.String())
277+
}
278+
279+
if len(rows) == 0 {
280+
sb.WriteString("\n\n No changes — pod state matches running state.")
281+
} else {
282+
sb.WriteString("\n")
283+
for _, r := range rows {
284+
sb.WriteString("\n")
285+
sb.WriteString(r)
286+
}
287+
}
288+
289+
if hasModifications {
290+
noun := "modifications"
291+
if totalMods == 1 {
292+
noun = "modification"
293+
}
294+
sb.WriteString(fmt.Sprintf("\n\n> Note: %d %s will be resolved using the %s strategy.", totalMods, noun, e.Strategy))
295+
}
296+
297+
sb.WriteString("\n\nNo state was modified.")
298+
return sb.String()
299+
}
300+
227301
func formatBytes(b int64) string {
228302
switch {
229303
case b >= byteGB:

internal/output/plain_format_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,43 @@ func TestFormatEventLine(t *testing.T) {
236236
want: SuccessMarker() + " Snapshot loaded from pod:empty-pod",
237237
wantOK: true,
238238
},
239+
240+
// snapshot diff events
241+
{
242+
name: "snapshot diff with additions and modifications",
243+
event: SnapshotDiffEvent{
244+
PodName: "my-baseline",
245+
Strategy: "account-region-merge",
246+
Services: map[string]SnapshotDiffServiceResult{
247+
"s3": {Additions: 5},
248+
"sqs": {Additions: 3, Modifications: 1},
249+
},
250+
},
251+
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\nNo state was modified.",
252+
wantOK: true,
253+
},
254+
{
255+
name: "snapshot diff additions only",
256+
event: SnapshotDiffEvent{
257+
PodName: "my-baseline",
258+
Strategy: "account-region-merge",
259+
Services: map[string]SnapshotDiffServiceResult{
260+
"dynamodb": {Additions: 2},
261+
},
262+
},
263+
want: "Dry-run results for pod:my-baseline\n\n dynamodb + 2 additions\n\nNo state was modified.",
264+
wantOK: true,
265+
},
266+
{
267+
name: "snapshot diff no changes",
268+
event: SnapshotDiffEvent{
269+
PodName: "empty-pod",
270+
Strategy: "account-region-merge",
271+
Services: map[string]SnapshotDiffServiceResult{},
272+
},
273+
want: "Dry-run results for pod:empty-pod\n\n No changes — pod state matches running state.\n\nNo state was modified.",
274+
wantOK: true,
275+
},
239276
}
240277

241278
for _, tt := range tests {

internal/output/symbols.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@ package output
33
func SuccessMarker() string {
44
return "✔︎"
55
}
6+
7+
func WarningMarker() string {
8+
return "⚠"
9+
}

internal/snapshot/diff.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//go:generate mockgen -source=diff.go -destination=mock_diff_client_test.go -package=snapshot_test
2+
3+
package snapshot
4+
5+
import (
6+
"context"
7+
"fmt"
8+
9+
"github.com/localstack/lstk/internal/config"
10+
"github.com/localstack/lstk/internal/container"
11+
"github.com/localstack/lstk/internal/output"
12+
"github.com/localstack/lstk/internal/runtime"
13+
)
14+
15+
// ServiceDiffCounts holds the addition and modification counts for a single service.
16+
type ServiceDiffCounts struct {
17+
Additions int
18+
Modifications int
19+
}
20+
21+
// DiffResult maps service name to addition/modification counts from a diff response.
22+
type DiffResult map[string]ServiceDiffCounts
23+
24+
// PodDiffer is satisfied by aws.Client.
25+
type PodDiffer interface {
26+
DiffPodSnapshot(ctx context.Context, host, podName, authToken string) (DiffResult, error)
27+
}
28+
29+
// DiffPod calls the diff endpoint for a named pod and emits a SnapshotDiffEvent.
30+
// It requires the emulator to already be running (unlike LoadPod, there is no auto-start).
31+
func DiffPod(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, differ PodDiffer, host, podName, authToken, strategy string, sink output.Sink) error {
32+
if authToken == "" {
33+
return fmt.Errorf("pod snapshots require authentication — set LOCALSTACK_AUTH_TOKEN or run %q", "lstk login")
34+
}
35+
36+
if err := rt.IsHealthy(ctx); err != nil {
37+
rt.EmitUnhealthyError(sink, err)
38+
return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err))
39+
}
40+
41+
runningContainers, err := container.RunningEmulators(ctx, rt, containers)
42+
if err != nil {
43+
return fmt.Errorf("checking emulator status: %w", err)
44+
}
45+
46+
if len(runningContainers) == 0 {
47+
sink.Emit(output.ErrorEvent{
48+
Title: "LocalStack is not running",
49+
Actions: []output.ErrorAction{
50+
{Label: "Start LocalStack:", Value: "lstk"},
51+
{Label: "See help:", Value: "lstk -h"},
52+
},
53+
})
54+
return output.NewSilentError(fmt.Errorf("LocalStack is not running"))
55+
}
56+
57+
sink.Emit(output.SpinnerStart(fmt.Sprintf("Checking diff for pod %q...", podName)))
58+
result, err := differ.DiffPodSnapshot(ctx, host, podName, authToken)
59+
sink.Emit(output.SpinnerStop())
60+
if err != nil {
61+
return err
62+
}
63+
64+
services := make(map[string]output.SnapshotDiffServiceResult, len(result))
65+
for svc, counts := range result {
66+
services[svc] = output.SnapshotDiffServiceResult{
67+
Additions: counts.Additions,
68+
Modifications: counts.Modifications,
69+
}
70+
}
71+
sink.Emit(output.SnapshotDiffEvent{
72+
PodName: podName,
73+
Strategy: strategy,
74+
Services: services,
75+
})
76+
return nil
77+
}

0 commit comments

Comments
 (0)