Skip to content
Open
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
13 changes: 13 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,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
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
222 changes: 134 additions & 88 deletions cmd/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<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`

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",
Expand All @@ -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) {
Expand Down Expand Up @@ -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-<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
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)
}
}
}
27 changes: 27 additions & 0 deletions test/integration/snapshot_load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
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