Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions cmd/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -91,20 +92,30 @@ 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
}

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")
if err != nil {
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 {
Expand All @@ -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
Expand All @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions internal/emulator/aws/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("{}")))
Expand Down
16 changes: 14 additions & 2 deletions internal/output/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand All @@ -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() {}
Expand Down
74 changes: 74 additions & 0 deletions internal/output/plain_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package output

import (
"fmt"
"sort"
"strings"
"time"
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions internal/output/plain_format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions internal/output/symbols.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ package output
func SuccessMarker() string {
return "✔︎"
}

func WarningMarker() string {
return "⚠"
}
77 changes: 77 additions & 0 deletions internal/snapshot/diff.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading