Skip to content
Open
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
17 changes: 13 additions & 4 deletions cmd/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ Pass [destination] as an absolute or relative path for the exported file:
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.`,
To save to a remote pod on the LocalStack platform, use the pod: prefix:

lstk snapshot save pod:my-baseline # saves as a named pod on the platform`,
Args: cobra.MaximumNArgs(1),
PreRunE: initConfig(nil),
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -74,12 +76,19 @@ Cloud destinations are not yet supported.`,
return err
}
host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)
exporter := aws.NewClient()
client := aws.NewClient()
containers := []config.ContainerConfig{awsContainer}

if isInteractiveMode(cfg) {
return ui.RunSnapshotSave(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, host, dest)
return ui.RunSnapshotSave(cmd.Context(), rt, containers, client, host, dest, cfg.AuthToken)
}
sink := output.NewPlainSink(os.Stdout)
switch dest.Kind {
case snapshot.KindPod:
return snapshot.SavePod(cmd.Context(), rt, containers, client, host, dest.Value, cfg.AuthToken, sink)
default:
return snapshot.SaveLocal(cmd.Context(), rt, containers, client, host, dest.Value, sink)
}
return snapshot.Save(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, host, dest, output.NewPlainSink(os.Stdout))
},
}
}
63 changes: 63 additions & 0 deletions internal/emulator/aws/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ package aws

import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"

"github.com/localstack/lstk/internal/snapshot"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

"github.com/localstack/lstk/internal/emulator"
Expand Down Expand Up @@ -155,3 +158,63 @@ func (c *Client) ExportState(ctx context.Context, host string, dst io.Writer) er
}
return 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("{}")))
if err != nil {
return snapshot.PodSaveResult{}, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(":"+authToken)))

resp, err := c.http.Do(req)
if err != nil {
return snapshot.PodSaveResult{}, fmt.Errorf("connect to LocalStack: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return snapshot.PodSaveResult{}, fmt.Errorf("pod save failed (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
}

// The response is a newline-delimited JSON stream. We scan until we find a
// completion event and surface any server-side error as a Go error.
scanner := bufio.NewScanner(resp.Body)
buf := make([]byte, 1024*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var event struct {
Event string `json:"event"`
Status string `json:"status"`
Message string `json:"message"`
Info struct {
Version int `json:"version"`
Services []string `json:"services"`
Size int64 `json:"size"`
} `json:"info"`
}
if err := json.Unmarshal([]byte(line), &event); err != nil {
continue
}
if event.Event == "completion" {
if event.Status != "ok" {
return snapshot.PodSaveResult{}, fmt.Errorf("pod save failed: %s", event.Message)
}
return snapshot.PodSaveResult{
Version: event.Info.Version,
Services: event.Info.Services,
Size: event.Info.Size,
}, nil
}
}
if err := scanner.Err(); err != nil {
return snapshot.PodSaveResult{}, fmt.Errorf("reading response: %w", err)
}
return snapshot.PodSaveResult{}, fmt.Errorf("pod save: server closed stream without a completion event")
}
8 changes: 8 additions & 0 deletions internal/output/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ type ResourceSummaryEvent struct {
Services int
}

type PodSnapshotSavedEvent struct {
PodName string
Version int
Services []string
Size int64
}

type AuthCompleteEvent struct{}

// Event is a sealed marker — only event types in this package implement it,
Expand All @@ -91,6 +98,7 @@ func (AuthCompleteEvent) sealedEvent() {}
func (InstanceInfoEvent) sealedEvent() {}
func (TableEvent) sealedEvent() {}
func (ResourceSummaryEvent) sealedEvent() {}
func (PodSnapshotSavedEvent) sealedEvent() {}
func (ContainerStatusEvent) sealedEvent() {}
func (ProgressEvent) sealedEvent() {}
func (UserInputRequestEvent) sealedEvent() {}
Expand Down
36 changes: 36 additions & 0 deletions internal/output/plain_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import (
"time"
)

const (
byteKB int64 = 1024
byteMB = 1024 * byteKB
byteGB = 1024 * byteMB
)

// FormatEventLine converts an output event into a single display line.
func FormatEventLine(event Event) (string, bool) {
switch e := event.(type) {
Expand Down Expand Up @@ -34,6 +40,8 @@ func FormatEventLine(event Event) (string, bool) {
return formatTable(e)
case ResourceSummaryEvent:
return formatResourceSummary(e), true
case PodSnapshotSavedEvent:
return formatPodSnapshotSaved(e), true
case AuthCompleteEvent:
return "", false
default:
Expand Down Expand Up @@ -190,6 +198,34 @@ func formatResourceSummary(e ResourceSummaryEvent) string {
return fmt.Sprintf("~ %d resources · %d services", e.Resources, e.Services)
}

func formatPodSnapshotSaved(e PodSnapshotSavedEvent) string {
var sb strings.Builder
sb.WriteString(SuccessMarker() + fmt.Sprintf(" Snapshot saved to pod:%s", e.PodName))
if e.Version > 0 {
sb.WriteString(fmt.Sprintf("\n• Version: %d", e.Version))
}
if len(e.Services) > 0 {
sb.WriteString("\n• Services: " + strings.Join(e.Services, ", "))
}
if e.Size > 0 {
sb.WriteString("\n• Size: " + formatBytes(e.Size))
}
return sb.String()
}

func formatBytes(b int64) string {
switch {
case b >= byteGB:
return fmt.Sprintf("%.1f GB", float64(b)/float64(byteGB))
case b >= byteMB:
return fmt.Sprintf("%.1f MB", float64(b)/float64(byteMB))
case b >= byteKB:
return fmt.Sprintf("%.1f KB", float64(b)/float64(byteKB))
default:
return fmt.Sprintf("%d B", b)
}
}

func formatTable(e TableEvent) (string, bool) {
if len(e.Rows) == 0 {
return "", false
Expand Down
57 changes: 57 additions & 0 deletions internal/output/plain_format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,36 @@ func TestFormatEventLine(t *testing.T) {
want: "",
wantOK: false,
},
{
name: "pod snapshot saved full",
event: PodSnapshotSavedEvent{
PodName: "my-baseline",
Version: 3,
Services: []string{"dynamodb", "s3", "sqs"},
Size: 2621440,
},
want: SuccessMarker() + " Snapshot saved to pod:my-baseline\n• Version: 3\n• Services: dynamodb, s3, sqs\n• Size: 2.5 MB",
wantOK: true,
},
{
name: "pod snapshot saved many services",
event: PodSnapshotSavedEvent{
PodName: "big-pod",
Version: 1,
Services: []string{"s3", "sqs", "sns", "dynamodb", "lambda", "apigateway", "iam", "sts", "ec2", "rds", "kinesis", "firehose", "cloudwatch", "cloudformation", "route53"},
Size: 10485760,
},
want: SuccessMarker() + " Snapshot saved to pod:big-pod\n• Version: 1\n• Services: s3, sqs, sns, dynamodb, lambda, apigateway, iam, sts, ec2, rds, kinesis, firehose, cloudwatch, cloudformation, route53\n• Size: 10.0 MB",
wantOK: true,
},
{
name: "pod snapshot saved omits zero fields",
event: PodSnapshotSavedEvent{
PodName: "minimal-pod",
},
want: SuccessMarker() + " Snapshot saved to pod:minimal-pod",
wantOK: true,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -249,3 +279,30 @@ func TestFormatTableWidth(t *testing.T) {
}
})
}

func TestFormatBytes(t *testing.T) {
t.Parallel()
tests := []struct {
input int64
want string
}{
{0, "0 B"},
{512, "512 B"},
{1023, "1023 B"},
{1024, "1.0 KB"},
{1536, "1.5 KB"},
{1048576, "1.0 MB"},
{2621440, "2.5 MB"},
{1073741824, "1.0 GB"},
{2684354560, "2.5 GB"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
t.Parallel()
got := formatBytes(tt.input)
if got != tt.want {
t.Fatalf("formatBytes(%d) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
58 changes: 41 additions & 17 deletions internal/snapshot/destination.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,36 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)

var (
// ErrRemoteNotSupported is returned for known remote schemes (s3://, oras://, cloud:).
// ErrRemoteNotSupported is returned for known remote schemes (s3://, oras://).
ErrRemoteNotSupported = errors.New("remote destinations are not yet supported — coming soon")
// ErrUnknownScheme is returned for unrecognized URL schemes.
ErrUnknownScheme = errors.New("unrecognized destination scheme")

validPodName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]*$`)
)

// DestinationKind distinguishes local file paths from remote pod destinations.
type DestinationKind int

const (
KindLocal DestinationKind = iota
KindPod
)

// Destination is the parsed result of a user-supplied snapshot destination.
// For KindLocal, Value is an absolute local file path with a .zip extension.
// For KindPod, Value is the validated pod name (without the "pod:" prefix).
type Destination struct {
Kind DestinationKind
Value string
}

// displayPath shortens abs for human-readable output:
// under cwd → ./rel, under home → ~/rel, otherwise unchanged.
func displayPath(abs, cwd, home string) string {
Expand All @@ -33,60 +52,65 @@ func displayPath(abs, cwd, home string) string {
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) {
// ParseDestination resolves a user-supplied destination to a local path (KindLocal) or validated pod name (KindPod).
func ParseDestination(dest string, now time.Time) (Destination, 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, "pod://"):
podName := dest[len("pod://"):]
return Destination{}, fmt.Errorf("'%s' is not a valid reference. Aliases use a single colon. Did you mean:\npod:%s", dest, podName)
case strings.HasPrefix(lower, "pod:"):
podName := dest[len("pod:"):]
if !validPodName.MatchString(podName) {
return Destination{}, fmt.Errorf("invalid pod name %q: use letters, digits, and hyphens only, starting with a letter or digit", podName)
}
return Destination{Kind: KindPod, Value: podName}, nil
case strings.HasPrefix(lower, "s3://"),
strings.HasPrefix(lower, "oras://"),
strings.HasPrefix(lower, "cloud:"):
return "", ErrRemoteNotSupported
strings.HasPrefix(lower, "oras://"):
return Destination{}, ErrRemoteNotSupported
case strings.Contains(lower, "://"):
scheme, _, _ := strings.Cut(dest, "://")
return "", fmt.Errorf("%w: %q", ErrUnknownScheme, scheme+"://")
return Destination{}, 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)
return Destination{}, 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)
return Destination{}, 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 Destination{}, fmt.Errorf("parent directory %q does not exist — create it first", parent)
}
return "", fmt.Errorf("check parent directory: %w", err)
return Destination{}, fmt.Errorf("check parent directory: %w", err)
}
if !parentInfo.IsDir() {
return "", fmt.Errorf("parent path %q is not a directory", parent)
return Destination{}, 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)
return Destination{}, 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
return Destination{Kind: KindLocal, Value: abs}, nil
}
Loading
Loading