From 0f41c0e1117a1880ab50f2ef783ef3ae028448db Mon Sep 17 00:00:00 2001 From: George Tsiolis <120486+gtsiolis@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:27:42 +0000 Subject: [PATCH 1/2] Add config support for snapshot auto-load on start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `snapshot` field to `[[containers]]` (AWS only) that auto-loads the referenced snapshot once the emulator has started. Loading runs only when the emulator is freshly started this run (skipped when already running), mirroring v1's AUTO_LOAD_POD. `lstk start --snapshot REF` overrides the configured REF for one run and `lstk start --no-snapshot` skips it. `snapshot save` does not write back into the config — the field is manual. The auto-loader is resolved at the command boundary and threaded into the non-interactive start (cmd/root.go) and the TUI via ui.RunOptions.PostStart. Generated with [Linear](https://linear.app/localstack/issue/DEVX-853/add-config-support-for-snapshot-auto-load#agent-session-555ec215) Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com> --- CLAUDE.md | 2 + cmd/root.go | 31 +++++- cmd/snapshot.go | 81 ++++++++++++++++ cmd/snapshot_start_test.go | 72 ++++++++++++++ cmd/start.go | 11 ++- internal/config/containers.go | 3 + internal/config/default_config.toml | 1 + internal/ui/run.go | 16 ++++ test/integration/start_snapshot_test.go | 119 ++++++++++++++++++++++++ 9 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 cmd/snapshot_start_test.go create mode 100644 test/integration/start_snapshot_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 506569ad..a3b828aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,8 @@ A REF is parsed by helpers in `internal/snapshot/destination.go`: `ParseDestination` (save), `ParseSource` (load), `ParseRemovable` (remove), and `ParseShowable` (show) share pod-name validation; `ParseRemovable` and `ParseShowable` reject local paths (via the shared `parseCloudOnly` helper) so those cloud-only commands never touch local files. +**Auto-load on start.** A `[[containers]]` block (AWS only) can set `snapshot = "pod:my-baseline"` (any load REF) to auto-load that snapshot after the emulator starts. The loader runs only when the emulator is freshly started this run (skipped when already running), mirroring v1's `AUTO_LOAD_POD`. `lstk start --snapshot REF` overrides the configured REF for one run; `lstk start --no-snapshot` skips it. Resolution lives in `resolveStartSnapshotRef`/`newSnapshotAutoLoader` in `cmd/snapshot.go`; the loader is threaded into the non-interactive start in `cmd/root.go` and into the TUI via `ui.RunOptions.PostStart`. `snapshot save` never writes back into config — the `snapshot` field is manual. + # Code Style - Don't add comments for self-explanatory code. Only comment when the "why" isn't obvious from the code itself. diff --git a/cmd/root.go b/cmd/root.go index 8054882c..2ec10282 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -57,7 +57,11 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C if err != nil { return err } - return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun) + snapshotFlag, noSnapshot, err := snapshotFlags(cmd) + if err != nil { + return err + } + return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot) }, } @@ -68,6 +72,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C root.PersistentFlags().String("config", "", "Path to config file") root.PersistentFlags().BoolVar(&cfg.NonInteractive, "non-interactive", false, "Disable interactive mode") root.Flags().Bool("persist", false, "Persist emulator state across restarts") + addSnapshotStartFlags(root) configureHelp(root) @@ -201,12 +206,22 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger } } -func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool, firstRun bool) error { +func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool, firstRun bool, snapshotFlag string, noSnapshot bool) error { appConfig, err := config.Get() if err != nil { return fmt.Errorf("failed to get config: %w", err) } + ref, err := resolveStartSnapshotRef(appConfig, snapshotFlag, noSnapshot) + if err != nil { + return err + } + // Parse the REF eagerly so an invalid snapshot fails before the emulator starts. + autoLoad, err := newSnapshotAutoLoader(cfg, rt, appConfig, ref) + if err != nil { + return err + } + opts := buildStartOptions(cfg, appConfig, logger, tel, persist) notifyOpts := update.NotifyOptions{ @@ -230,6 +245,7 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t ConfigPath: configPath, EmulatorLabel: config.CachedPlanLabel(), NeedsEmulatorSelection: firstRun, + PostStart: autoLoad, }) } @@ -242,9 +258,18 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t }) } update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken}) - if _, err = container.Start(ctx, rt, sink, opts, false); err != nil { + resolvedVersion, err := container.Start(ctx, rt, sink, opts, false) + if err != nil { return err } + // Auto-load the configured snapshot only when the emulator was freshly started + // this run (resolvedVersion is empty when it was already running). This mirrors + // v1's AUTO_LOAD_POD: state is loaded as the emulator comes up, not on every invocation. + if autoLoad != nil && resolvedVersion != "" { + if err := autoLoad(ctx, sink); err != nil { + return err + } + } if firstRun { return config.EnsureCreated() } diff --git a/cmd/snapshot.go b/cmd/snapshot.go index a91386a9..e9e6b10d 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "os" "time" @@ -82,6 +83,86 @@ func newSnapshotCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cob return cmd } +// addSnapshotStartFlags registers the snapshot-related flags shared by the root +// and `start` commands. --snapshot overrides the configured REF for one run; +// --no-snapshot skips auto-loading for one run. +func addSnapshotStartFlags(cmd *cobra.Command) { + cmd.Flags().String("snapshot", "", "Snapshot REF to load after start (overrides config for this run)") + cmd.Flags().Bool("no-snapshot", false, "Skip auto-loading the configured snapshot for this run") +} + +func snapshotFlags(cmd *cobra.Command) (snapshotFlag string, noSnapshot bool, err error) { + if snapshotFlag, err = cmd.Flags().GetString("snapshot"); err != nil { + return "", false, err + } + if noSnapshot, err = cmd.Flags().GetBool("no-snapshot"); err != nil { + return "", false, err + } + return snapshotFlag, noSnapshot, nil +} + +// resolveStartSnapshotRef resolves the snapshot REF to auto-load on start. +// Precedence: --no-snapshot disables it; otherwise --snapshot wins over the +// AWS container's configured snapshot. Returns "" when nothing should be loaded. +func resolveStartSnapshotRef(appConfig *config.Config, snapshotFlag string, noSnapshot bool) (string, error) { + if noSnapshot && snapshotFlag != "" { + return "", errors.New("--snapshot and --no-snapshot cannot be used together") + } + if noSnapshot { + return "", nil + } + if snapshotFlag != "" { + return snapshotFlag, nil + } + for _, c := range appConfig.Containers { + if c.Type == config.EmulatorAWS && c.Snapshot != "" { + return c.Snapshot, nil + } + } + return "", nil +} + +// newSnapshotAutoLoader returns a loader that imports the given REF into the +// running AWS emulator, or nil when ref is empty. The REF is parsed eagerly so an +// invalid value fails before the emulator starts. The loader passes a nil Starter: +// it is only invoked once the emulator is already up. +func newSnapshotAutoLoader(cfg *env.Env, rt runtime.Runtime, appConfig *config.Config, ref string) (func(context.Context, output.Sink) error, error) { + if ref == "" { + return nil, nil + } + + var awsContainer config.ContainerConfig + found := false + for _, c := range appConfig.Containers { + if c.Type == config.EmulatorAWS { + awsContainer = c + found = true + break + } + } + if !found { + return nil, fmt.Errorf("snapshot auto-load is only supported for the AWS emulator") + } + + home, _ := os.UserHomeDir() + src, err := snapshot.ParseSource(ref, home) + if err != nil { + return nil, err + } + + client := aws.NewClient() + containers := []config.ContainerConfig{awsContainer} + return func(ctx context.Context, sink output.Sink) error { + host, _ := endpoint.ResolveHost(ctx, awsContainer.Port, cfg.LocalStackHost) + switch src.Kind { + case snapshot.KindPod: + return snapshot.LoadPod(ctx, rt, containers, client, host, src.Value, cfg.AuthToken, "", nil, sink) + default: + return snapshot.LoadLocal(ctx, rt, containers, client, host, src.Value, "", nil, sink) + } + }, nil +} + func buildStarter(cfg *env.Env, rt runtime.Runtime, appConfig *config.Config, logger log.Logger, tel *telemetry.Client) snapshot.Starter { return func(ctx context.Context, sink output.Sink) error { opts := buildStartOptions(cfg, appConfig, logger, tel, false) diff --git a/cmd/snapshot_start_test.go b/cmd/snapshot_start_test.go new file mode 100644 index 00000000..a5e9c9bf --- /dev/null +++ b/cmd/snapshot_start_test.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "testing" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/log" + "github.com/localstack/lstk/internal/telemetry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func awsConfig(snapshot string) *config.Config { + return &config.Config{ + Containers: []config.ContainerConfig{ + {Type: config.EmulatorAWS, Tag: "latest", Port: "4566", Snapshot: snapshot}, + }, + } +} + +func TestResolveStartSnapshotRef(t *testing.T) { + t.Run("from config", func(t *testing.T) { + ref, err := resolveStartSnapshotRef(awsConfig("pod:my-baseline"), "", false) + require.NoError(t, err) + assert.Equal(t, "pod:my-baseline", ref) + }) + + t.Run("flag overrides config", func(t *testing.T) { + ref, err := resolveStartSnapshotRef(awsConfig("pod:my-baseline"), "pod:override", false) + require.NoError(t, err) + assert.Equal(t, "pod:override", ref) + }) + + t.Run("no-snapshot skips config", func(t *testing.T) { + ref, err := resolveStartSnapshotRef(awsConfig("pod:my-baseline"), "", true) + require.NoError(t, err) + assert.Equal(t, "", ref) + }) + + t.Run("no snapshot configured", func(t *testing.T) { + ref, err := resolveStartSnapshotRef(awsConfig(""), "", false) + require.NoError(t, err) + assert.Equal(t, "", ref) + }) + + t.Run("snapshot only read from AWS container", func(t *testing.T) { + cfg := &config.Config{Containers: []config.ContainerConfig{ + {Type: config.EmulatorSnowflake, Tag: "latest", Port: "4566", Snapshot: "pod:ignored"}, + }} + ref, err := resolveStartSnapshotRef(cfg, "", false) + require.NoError(t, err) + assert.Equal(t, "", ref) + }) + + t.Run("conflicting flags error", func(t *testing.T) { + _, err := resolveStartSnapshotRef(awsConfig(""), "pod:x", true) + assert.ErrorContains(t, err, "cannot be used together") + }) +} + +func TestSnapshotStartFlagsRegistered(t *testing.T) { + root := NewRootCmd(&env.Env{}, telemetry.New("", true), log.Nop()) + + assert.NotNil(t, root.Flags().Lookup("snapshot"), "root should register --snapshot") + assert.NotNil(t, root.Flags().Lookup("no-snapshot"), "root should register --no-snapshot") + + start, _, err := root.Find([]string{"start"}) + require.NoError(t, err) + assert.NotNil(t, start.Flags().Lookup("snapshot"), "start should register --snapshot") + assert.NotNil(t, start.Flags().Lookup("no-snapshot"), "start should register --no-snapshot") +} diff --git a/cmd/start.go b/cmd/start.go index 470a9eca..aa60bb4a 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -15,7 +15,9 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra. Short: "Start emulator", Long: `Start emulator and services. -Host environment variables prefixed with LOCALSTACK_ are forwarded to the emulator.`, +Host environment variables prefixed with LOCALSTACK_ are forwarded to the emulator. + +If a snapshot is configured for the AWS emulator (the snapshot field in [[containers]]), it is auto-loaded once the emulator starts. Use --snapshot REF to override it for one run, or --no-snapshot to skip it.`, PreRunE: initConfigDeferCreate(&firstRun), RunE: func(c *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) @@ -26,9 +28,14 @@ Host environment variables prefixed with LOCALSTACK_ are forwarded to the emulat if err != nil { return err } - return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun) + snapshotFlag, noSnapshot, err := snapshotFlags(c) + if err != nil { + return err + } + return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot) }, } cmd.Flags().Bool("persist", false, "Persist emulator state across restarts") + addSnapshotStartFlags(cmd) return cmd } diff --git a/internal/config/containers.go b/internal/config/containers.go index 89a05069..45ab23d6 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -118,6 +118,9 @@ type ContainerConfig struct { Volume string `mapstructure:"volume"` // Env is a list of named environment references defined in the top-level [env.*] config sections. Env []string `mapstructure:"env"` + // Snapshot is an optional snapshot REF (e.g. "pod:my-baseline" or a local path) + // auto-loaded after the emulator starts. AWS emulator only. + Snapshot string `mapstructure:"snapshot"` } // VolumeDir returns the host directory to mount into the container for persistence/caching. diff --git a/internal/config/default_config.toml b/internal/config/default_config.toml index ccdcb36d..02b9ef20 100644 --- a/internal/config/default_config.toml +++ b/internal/config/default_config.toml @@ -11,6 +11,7 @@ tag = "latest" # Docker image tag, e.g. "latest", "2026.4" port = "4566" # Host port the emulator will be accessible on # volume = "" # Host directory for persistent state (default: OS cache dir) # env = [] # Named environment profiles to apply (see [env.*] sections below) +# snapshot = "pod:my-baseline" # Snapshot REF auto-loaded on start (AWS only); skip once with 'lstk start --no-snapshot' # Environment profiles let you group environment variables and reference # them by name in one or more containers via the 'env' field above. diff --git a/internal/ui/run.go b/internal/ui/run.go index 69e8076c..0273f45e 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -37,6 +37,10 @@ type RunOptions struct { ConfigPath string EmulatorLabel string NeedsEmulatorSelection bool + // PostStart, when set, runs after the emulator is freshly started (e.g. to + // auto-load a configured snapshot). It is skipped when the emulator was + // already running. + PostStart func(ctx context.Context, sink output.Sink) error } func Run(parentCtx context.Context, runOpts RunOptions) error { @@ -112,6 +116,18 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { p.Send(runErrMsg{err: err}) return } + // Auto-load the configured snapshot only when the emulator was freshly + // started this run (resolvedVersion is empty when it was already running). + if resolvedVersion != "" && runOpts.PostStart != nil { + if postErr := runOpts.PostStart(ctx, sink); postErr != nil { + if errors.Is(postErr, context.Canceled) { + return + } + err = postErr + p.Send(runErrMsg{err: postErr}) + return + } + } // Empty resolvedVersion means the container was already running and Start // returned early — use the cached label rather than re-resolving. if resolvedVersion == "" { diff --git a/test/integration/start_snapshot_test.go b/test/integration/start_snapshot_test.go new file mode 100644 index 00000000..f35773ba --- /dev/null +++ b/test/integration/start_snapshot_test.go @@ -0,0 +1,119 @@ +package integration_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- no Docker required (parallel) --- + +func TestStartSnapshotConflictingFlags(t *testing.T) { + t.Parallel() + ctx := testContext(t) + + _, stderr, err := runLstk(t, ctx, t.TempDir(), + testEnvWithHome(t.TempDir(), ""), + "--non-interactive", "start", "--snapshot", "pod:my-baseline", "--no-snapshot", + ) + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "cannot be used together") +} + +func TestStartSnapshotInvalidPodName(t *testing.T) { + t.Parallel() + ctx := testContext(t) + + _, stderr, err := runLstk(t, ctx, t.TempDir(), + testEnvWithHome(t.TempDir(), ""), + "--non-interactive", "start", "--snapshot", "pod:_bad", + ) + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "invalid pod name") +} + +func TestStartSnapshotLocalFileNotFound(t *testing.T) { + t.Parallel() + ctx := testContext(t) + + _, stderr, err := runLstk(t, ctx, t.TempDir(), + testEnvWithHome(t.TempDir(), ""), + "--non-interactive", "start", "--snapshot", "/no/such/snapshot.snapshot", + ) + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "snapshot file not found") +} + +// --- Docker required --- + +// TestStartAutoLoadsConfiguredSnapshot verifies that a snapshot configured in +// [[containers]].snapshot is auto-loaded once the emulator has started. The +// emulator start is real; the snapshot import is directed at a mock platform via +// LOCALSTACK_HOST so the test does not depend on a real cloud pod. +func TestStartAutoLoadsConfiguredSnapshot(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + + licenseServer := createMockLicenseServer(true) + defer licenseServer.Close() + podServer := mockPodLoadServer(t, true) + + configContent := ` +[[containers]] +type = "aws" +tag = "latest" +port = "4566" +snapshot = "pod:my-baseline" +` + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + ctx := testContext(t) + stdout, stderr, err := runLstk(t, ctx, "", + env.With(env.APIEndpoint, licenseServer.URL).With(env.LocalStackHost, lsHost(podServer)), + "--config", configFile, "start", + ) + require.NoError(t, err, "lstk start failed: %s", stderr) + requireExitCode(t, 0, err) + assert.Contains(t, stdout, "Snapshot loaded", "configured snapshot should auto-load after start") + assert.Contains(t, stdout, "my-baseline") +} + +// TestStartNoSnapshotSkipsAutoLoad verifies that --no-snapshot skips auto-loading +// the configured snapshot for that run while still starting the emulator. +func TestStartNoSnapshotSkipsAutoLoad(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + + licenseServer := createMockLicenseServer(true) + defer licenseServer.Close() + + configContent := ` +[[containers]] +type = "aws" +tag = "latest" +port = "4566" +snapshot = "pod:my-baseline" +` + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + ctx := testContext(t) + stdout, stderr, err := runLstk(t, ctx, "", + env.With(env.APIEndpoint, licenseServer.URL), + "--config", configFile, "start", "--no-snapshot", + ) + require.NoError(t, err, "lstk start --no-snapshot failed: %s", stderr) + requireExitCode(t, 0, err) + assert.NotContains(t, stdout, "Snapshot loaded", "--no-snapshot should skip auto-loading") +} From 330c8ef1c054a8d61dce96e0398df5e0027d7c43 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 25 Jun 2026 14:43:29 +0300 Subject: [PATCH 2/2] Document snapshot auto-load on start in README --- README.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 745af766..870d290f 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Running `lstk` will automatically handle configuration setup and start LocalStac - **Interactive TUI** — a Bubble Tea-powered terminal UI shown in an interactive terminal for commands like `start`, `login`, `status`, etc. - **Plain output** for CI/CD and scripting (auto-detected in non-interactive environments or forced with `--non-interactive`) - **Log streaming** — tail emulator logs in real-time with `--follow`; use `--verbose` to show all logs without filtering -- **Snapshots** — save, load, and remove emulator state as local files or named cloud snapshots (`pod:` prefix) +- **Snapshots** — save, load, and remove emulator state as local files or named cloud snapshots (`pod:` prefix), and auto-load one on start - **Browser-based login** — authenticate via browser and store credentials securely in the system keyring - **AWS CLI profile** — optionally configure a `localstack` profile in `~/.aws/` after start - **Terraform integration** — proxy Terraform commands to LocalStack with automatic AWS provider endpoint configuration @@ -134,6 +134,7 @@ tag = "latest" # Docker image tag, e.g. "latest", "2026.03" port = "4566" # Host port the emulator will be accessible on # volume = "" # Host directory for persistent state (default: OS cache dir) # env = [] # Named environment profiles to apply (see [env.*] sections below) +# snapshot = "pod:my-baseline" # Snapshot REF auto-loaded on start (AWS only); see Snapshots below ``` **Fields:** @@ -142,6 +143,7 @@ port = "4566" # Host port the emulator will be accessible on - `port`: port LocalStack listens on (default `4566`) - `volume`: (optional) host directory for persistent emulator state (default: OS cache dir) - `env`: (optional) list of named environment variable groups to inject into the container (see below) +- `snapshot`: (optional) snapshot REF auto-loaded after the emulator starts on a fresh run — a local file path or a `pod:` cloud snapshot (see [Snapshots](#snapshots)) ### Passing environment variables to the container @@ -336,6 +338,24 @@ lstk snapshot remove pod:my-baseline --force # skip the prompt (required in non `lstk snapshot load` supports merge strategies via `--merge` (`account-region-merge` (default), `overwrite`, `service-merge`) to control how snapshot state combines with running state. +### Auto-load on start + +The AWS emulator can automatically load a snapshot whenever it starts. Set the `snapshot` field on its `[[containers]]` block to any snapshot reference — a local file or a `pod:` cloud snapshot: + +```toml +[[containers]] +type = "aws" +port = "4566" +snapshot = "pod:my-baseline" +``` + +Override or disable it for a single run without editing the config: + +```bash +lstk start --snapshot pod:other-baseline # load a different snapshot this run +lstk start --no-snapshot # skip auto-loading this run +``` + ## Reporting bugs Feedback is welcome! Use the repository issue tracker for bug reports or feature requests.