Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:**
Expand All @@ -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

Expand Down Expand Up @@ -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.
31 changes: 28 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
}

Expand All @@ -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)

Expand Down Expand Up @@ -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{
Expand All @@ -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,
})
}

Expand All @@ -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()
}
Expand Down
81 changes: 81 additions & 0 deletions cmd/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"context"
"errors"
"fmt"
"os"
"time"
Expand Down Expand Up @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions cmd/snapshot_start_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
11 changes: 9 additions & 2 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great summary 💯

PreRunE: initConfigDeferCreate(&firstRun),
RunE: func(c *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
Expand All @@ -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
}
3 changes: 3 additions & 0 deletions internal/config/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions internal/config/default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions internal/ui/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 == "" {
Expand Down
Loading
Loading