-
Notifications
You must be signed in to change notification settings - Fork 5
Add snapshot save command #210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3bc6561
5e8bd61
e80ca53
cbcd58b
ee93506
714989f
ed1e8e1
5ab0285
d8df55c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)) | ||
| }, | ||
| } | ||
| } |
| 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 | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: we already have
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry I meant to say |
||
| 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)) | ||
| } |
| 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 | ||
| } |
There was a problem hiding this comment.
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.ReadClosercan we instead pass theio.Writerdestination as argument?This way we're sure the caller won't forget to close the returned body.