diff --git a/cmd/root.go b/cmd/root.go index 3e18a2fc..dde76d25 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{ @@ -80,6 +85,8 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newAWSCmd(cfg), newSnapshotCmd(cfg, tel, logger), newResetCmd(cfg), + newSaveCmd(cfg), + newLoadCmd(cfg, tel, logger), ) return root @@ -225,6 +232,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 @@ -242,6 +252,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 ef8dc13b..26f9826d 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -20,6 +20,36 @@ 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 + +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` + +const snapshotLoadCanonical = "snapshot load" + +const snapshotLoadLong = `Load a snapshot into the running emulator, starting it first if needed. + +REF identifies the snapshot to load: + + lstk snapshot load my-baseline # loads ./my-baseline or ./my-baseline.zip + lstk snapshot load ./checkpoint.zip # loads from explicit path + lstk snapshot load pod:my-baseline # loads from LocalStack Cloud + +Merge strategies control how snapshot state is combined with running state: + + --merge=account-region-merge (default) snapshot wins on (service, account, region) overlap + --merge=overwrite wipe running state, then load + --merge=service-merge snapshot wins per-resource; non-overlapping resources combined` + func newSnapshotCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command { cmd := &cobra.Command{ Use: "snapshot", @@ -39,60 +69,70 @@ func buildStarter(cfg *env.Env, rt runtime.Runtime, appConfig *config.Config, lo func newSnapshotLoadCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command { cmd := &cobra.Command{ - Use: "load REF", - Short: "Load a snapshot into the running emulator", - Long: `Load a snapshot into the running emulator, starting it first if needed. + Use: "load REF", + Short: "Load a snapshot into the running emulator", + Long: snapshotLoadLong, + Args: cobra.ExactArgs(1), + PreRunE: initConfig(nil), + RunE: runSnapshotLoad(cfg, tel, logger), + } + addMergeFlag(cmd) + return cmd +} -REF identifies the snapshot to load: +func newLoadCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command { + cmd := &cobra.Command{ + Use: "load REF", + Short: "Load a snapshot into the running emulator", + Long: snapshotLoadLong, + Args: cobra.ExactArgs(1), + PreRunE: initConfig(nil), + RunE: runSnapshotLoad(cfg, tel, logger), + Annotations: map[string]string{canonicalCommandAnnotation: snapshotLoadCanonical}, + } + addMergeFlag(cmd) + return cmd +} - lstk snapshot load my-baseline # loads ./my-baseline or ./my-baseline.zip - lstk snapshot load ./checkpoint.zip # loads from explicit path - lstk snapshot load pod:my-baseline # loads from LocalStack Cloud +func addMergeFlag(cmd *cobra.Command) { + cmd.Flags().String("merge", snapshot.MergeStrategyAccountRegion, "Merge strategy: overwrite, account-region-merge, service-merge") +} -Merge strategies control how snapshot state is combined with running state: +func runSnapshotLoad(cfg *env.Env, tel *telemetry.Client, logger log.Logger) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + strategy, err := cmd.Flags().GetString("merge") + if err != nil { + return err + } - --merge=account-region-merge (default) snapshot wins on (service, account, region) overlap - --merge=overwrite wipe running state, then load - --merge=service-merge snapshot wins per-resource; non-overlapping resources combined`, - Args: cobra.ExactArgs(1), - PreRunE: initConfig(nil), - RunE: func(cmd *cobra.Command, args []string) error { - strategy, err := cmd.Flags().GetString("merge") - if err != nil { - return err - } - - home, _ := os.UserHomeDir() - src, err := snapshot.ParseSource(args[0], home) - if err != nil { - return err - } - - if err := snapshot.ValidateMergeStrategy(strategy); err != nil { - return err - } - - rt, client, host, containers, appConfig, err := resolveSnapshotDeps(cmd.Context(), cfg) - if err != nil { - return err - } - - starter := buildStarter(cfg, rt, appConfig, logger, tel) - - if isInteractiveMode(cfg) { - return ui.RunSnapshotLoad(cmd.Context(), rt, containers, client, host, src, cfg.AuthToken, strategy, starter) - } - sink := output.NewPlainSink(os.Stdout) - switch src.Kind { - case snapshot.KindPod: - return snapshot.LoadPod(cmd.Context(), rt, containers, client, host, src.Value, cfg.AuthToken, strategy, starter, sink) - default: - return snapshot.LoadLocal(cmd.Context(), rt, containers, client, host, src.Value, strategy, starter, sink) - } - }, + home, _ := os.UserHomeDir() + src, err := snapshot.ParseSource(args[0], home) + if err != nil { + return err + } + + if err := snapshot.ValidateMergeStrategy(strategy); err != nil { + return err + } + + rt, client, host, containers, appConfig, err := resolveSnapshotDeps(cmd.Context(), cfg) + if err != nil { + return err + } + + starter := buildStarter(cfg, rt, appConfig, logger, tel) + + if isInteractiveMode(cfg) { + return ui.RunSnapshotLoad(cmd.Context(), rt, containers, client, host, src, cfg.AuthToken, strategy, starter) + } + sink := output.NewPlainSink(os.Stdout) + switch src.Kind { + case snapshot.KindPod: + return snapshot.LoadPod(cmd.Context(), rt, containers, client, host, src.Value, cfg.AuthToken, strategy, starter, sink) + default: + return snapshot.LoadLocal(cmd.Context(), rt, containers, client, host, src.Value, strategy, starter, sink) + } } - cmd.Flags().String("merge", snapshot.MergeStrategyAccountRegion, "Merge strategy: overwrite, account-region-merge, service-merge") - return cmd } func resolveSnapshotDeps(ctx context.Context, cfg *env.Env) (rt runtime.Runtime, client *aws.Client, host string, containers []config.ContainerConfig, appConfig *config.Config, err error) { @@ -124,48 +164,54 @@ func resolveSnapshotDeps(ctx context.Context, cfg *env.Env) (rt runtime.Runtime, 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. + Use: "save [destination]", + Short: "Save a snapshot of the emulator state", + Long: snapshotSaveLong, + Args: cobra.MaximumNArgs(1), + PreRunE: initConfig(nil), + RunE: runSnapshotSave(cfg), + } +} + +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}, + } +} -Pass [destination] as an absolute or relative path for the exported file: +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] + } - 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 + home, _ := os.UserHomeDir() + dest, err := snapshot.ParseDestination(destArg, home, time.Now()) + if err != nil { + return err + } -To save to a remote pod on the LocalStack platform, use the pod: prefix: + rt, client, host, containers, _, err := resolveSnapshotDeps(cmd.Context(), cfg) + if err != nil { + return err + } - lstk snapshot save pod:my-baseline # saves as a named pod on the platform`, - 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] - } - - home, _ := os.UserHomeDir() - dest, err := snapshot.ParseDestination(destArg, home, time.Now()) - if err != nil { - return err - } - - rt, client, host, containers, _, err := resolveSnapshotDeps(cmd.Context(), cfg) - if err != nil { - return 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) - } - }, + 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) + } } } diff --git a/test/integration/snapshot_load_test.go b/test/integration/snapshot_load_test.go index c09c6b43..60932652 100644 --- a/test/integration/snapshot_load_test.go +++ b/test/integration/snapshot_load_test.go @@ -273,3 +273,30 @@ func TestSnapshotLoadInteractive(t *testing.T) { require.NoError(t, err, "interactive lstk snapshot load failed") assert.Contains(t, out, "Snapshot loaded") } + +func TestLoadAliasMatchesSnapshotLoad(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv, _ := mockLocalLoadServer(t) + + dir := t.TempDir() + snapPath := writeTestSnapFile(t, dir, "snap.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", "load", snapPath, + ) + require.NoError(t, err, "lstk load failed: %s", stderr) + assert.Contains(t, stdout, "Snapshot loaded") + + // Alias must emit telemetry under the canonical name so usage isn't + // split across "load" and "snapshot load" labels. + assertCommandTelemetry(t, events, "snapshot load", 0) +} diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index ef81e975..aa5af30b 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -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()