diff --git a/cmd/root.go b/cmd/root.go index 909203e4..8f07f350 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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{ @@ -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 @@ -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 @@ -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() diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 705f3179..8722c217 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -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--.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", @@ -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--.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)) } } diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index 1c95c11a..efd63dc5 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -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()