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
12 changes: 12 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ import (
"github.com/spf13/pflag"
)

// canonicalCommandAnnotation, when set on a cobra.Command, overrides the
// command path reported to telemetry and tracing. Used so root-level aliases
// emit the same name as their canonical subcommand.
const canonicalCommandAnnotation = "lstk.canonical"

func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
var firstRun bool
root := &cobra.Command{
Expand Down Expand Up @@ -79,6 +84,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
newDocsCmd(),
newAWSCmd(cfg),
newSnapshotCmd(cfg),
newSaveCmd(cfg),
)

return root
Expand Down Expand Up @@ -224,6 +230,9 @@ func instrumentCommands(cmd *cobra.Command, tel *telemetry.Client) {
if c == c.Root() {
commandName = "start"
}
if canonical, ok := c.Annotations[canonicalCommandAnnotation]; ok {
commandName = canonical
}
tel.EmitCommand(c.Context(), commandName, flags, time.Since(startTime).Milliseconds(), exitCode, errorMsg)

return runErr
Expand All @@ -241,6 +250,9 @@ func wrapCommandsWithTracing(cmd *cobra.Command) {
if cmd.RunE != nil {
original := cmd.RunE
spanName := strings.ReplaceAll(cmd.CommandPath(), " ", ".")
if canonical, ok := cmd.Annotations[canonicalCommandAnnotation]; ok {
spanName = strings.ReplaceAll(cmd.Root().Name()+" "+canonical, " ", ".")
}
cmd.RunE = func(c *cobra.Command, args []string) error {
ctx, span := otel.Tracer("github.com/localstack/lstk").Start(c.Context(), spanName)
defer span.End()
Expand Down
112 changes: 66 additions & 46 deletions cmd/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ import (
"github.com/spf13/cobra"
)

const snapshotSaveCanonical = "snapshot save"

const snapshotSaveLong = `Save a snapshot of the running emulator's state.

Pass [destination] as an absolute or relative path for the exported file:

lstk snapshot save # saves to ./snapshot-<YYYY-MM-DDTHH-mm-ss>-<hex>.zip
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.`

func newSnapshotCmd(cfg *env.Env) *cobra.Command {
cmd := &cobra.Command{
Use: "snapshot",
Expand All @@ -27,59 +39,67 @@ func newSnapshotCmd(cfg *env.Env) *cobra.Command {

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.

Pass [destination] as an absolute or relative path for the exported file:

lstk snapshot save # saves to ./snapshot-<YYYY-MM-DDTHH-mm-ss>-<hex>.zip
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.`,
Use: "save [destination]",
Short: "Save a snapshot of the emulator state",
Long: snapshotSaveLong,
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]
}
RunE: runSnapshotSave(cfg),
}
}

dest, err := snapshot.ParseDestination(destArg, time.Now())
if err != nil {
return err
}
func newSaveCmd(cfg *env.Env) *cobra.Command {
return &cobra.Command{
Use: "save [destination]",
Short: "Save a snapshot of the emulator state",
Long: snapshotSaveLong,
Args: cobra.MaximumNArgs(1),
PreRunE: initConfig(nil),
RunE: runSnapshotSave(cfg),
Annotations: map[string]string{canonicalCommandAnnotation: snapshotSaveCanonical},
}
}

appConfig, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}
func runSnapshotSave(cfg *env.Env) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
var destArg string
if len(args) > 0 {
destArg = args[0]
}

var awsContainer config.ContainerConfig
var found bool
for _, c := range appConfig.Containers {
if c.Type == config.EmulatorAWS {
awsContainer = c
found = true
break
}
}
if !found {
return fmt.Errorf("snapshot is only supported for the AWS emulator")
}
dest, err := snapshot.ParseDestination(destArg, time.Now())
if err != nil {
return err
}

rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
}
host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)
exporter := aws.NewClient()
appConfig, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}

if isInteractiveMode(cfg) {
return ui.RunSnapshotSave(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, host, dest)
var awsContainer config.ContainerConfig
var found bool
for _, c := range appConfig.Containers {
if c.Type == config.EmulatorAWS {
awsContainer = c
found = true
break
}
return snapshot.Save(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, host, dest, output.NewPlainSink(os.Stdout))
},
}
if !found {
return fmt.Errorf("snapshot is only supported for the AWS emulator")
}

rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
}
host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost)
exporter := aws.NewClient()

if isInteractiveMode(cfg) {
return ui.RunSnapshotSave(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, host, dest)
}
return snapshot.Save(cmd.Context(), rt, []config.ContainerConfig{awsContainer}, exporter, host, dest, output.NewPlainSink(os.Stdout))
}
}
32 changes: 32 additions & 0 deletions test/integration/snapshot_save_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,38 @@ func TestSnapshotSaveTelemetryOnFailure(t *testing.T) {
assertCommandTelemetry(t, events, "snapshot save", 1)
}

func TestSaveAliasMatchesSnapshotSave(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)

ctx := testContext(t)
startTestContainer(t, ctx)
srv := mockStateServer(t)
dir := t.TempDir()
outPath := filepath.Join(dir, "alias.zip")

analyticsSrv, events := mockAnalyticsServer(t)
stdout, stderr, err := runLstk(t, ctx, dir,
env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)).With(env.AnalyticsEndpoint, analyticsSrv.URL),
"--non-interactive", "save", outPath,
)
require.NoError(t, err, "lstk save failed: %s", stderr)
assert.Contains(t, stdout, "Snapshot saved")

data, err := os.ReadFile(outPath)
require.NoError(t, err, "output file should exist")
assert.True(t, len(data) > 0, "output file should be non-empty")

r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
require.NoError(t, err, "output file should be a valid ZIP")
assert.NotEmpty(t, r.File)

// Alias must emit telemetry under the canonical name so usage isn't
// split across "save" and "snapshot save" labels.
assertCommandTelemetry(t, events, "snapshot save", 0)
}

func TestSnapshotSaveInteractive(t *testing.T) {
requireDocker(t)
cleanup()
Expand Down
Loading