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 @@ -80,6 +85,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
newAWSCmd(cfg),
newSnapshotCmd(cfg),
newResetCmd(cfg),
newSaveCmd(cfg),
)

return root
Expand Down Expand Up @@ -225,6 +231,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 @@ -242,6 +251,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
130 changes: 75 additions & 55 deletions cmd/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ 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

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`

func newSnapshotCmd(cfg *env.Env) *cobra.Command {
cmd := &cobra.Command{
Use: "snapshot",
Expand All @@ -27,68 +41,74 @@ 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

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`,
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)
client := aws.NewClient()
containers := []config.ContainerConfig{awsContainer}
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, 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)
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")
}

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

if isInteractiveMode(cfg) {
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)
}
}
}
32 changes: 32 additions & 0 deletions test/integration/snapshot_save_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,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