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
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions cmd/snapshot.go
Original file line number Diff line number Diff line change
@@ -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))
},
}
}
24 changes: 20 additions & 4 deletions internal/output/plain_sink.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
44 changes: 44 additions & 0 deletions internal/snapshot/client.go
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: instead of returning io.ReadCloser can we instead pass the io.Writer destination as argument?
This way we're sure the caller won't forget to close the returned body.

}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: we already have internal/snapshot/client.go for the aws emulator http api, can we add the new method there instead? Keeping a dedicated interface StateExporter just for the endpoints related to snapshots make sense to me though

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I meant to say internal/emulator/aws/client.go

120 changes: 120 additions & 0 deletions internal/snapshot/client_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
33 changes: 33 additions & 0 deletions internal/snapshot/destination.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading