Skip to content

Commit 3c4f272

Browse files
Add config support for snapshot auto-load on start
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>
1 parent 039d0ce commit 3c4f272

9 files changed

Lines changed: 331 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ A REF is parsed by helpers in `internal/snapshot/destination.go`:
103103

104104
`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.
105105

106+
**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.
107+
106108
# Code Style
107109

108110
- Don't add comments for self-explanatory code. Only comment when the "why" isn't obvious from the code itself.

cmd/root.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
5050
if err != nil {
5151
return err
5252
}
53-
return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun)
53+
snapshotFlag, noSnapshot, err := snapshotFlags(cmd)
54+
if err != nil {
55+
return err
56+
}
57+
return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot)
5458
},
5559
}
5660

@@ -61,6 +65,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
6165
root.PersistentFlags().String("config", "", "Path to config file")
6266
root.PersistentFlags().BoolVar(&cfg.NonInteractive, "non-interactive", false, "Disable interactive mode")
6367
root.Flags().Bool("persist", false, "Persist emulator state across restarts")
68+
addSnapshotStartFlags(root)
6469

6570
configureHelp(root)
6671

@@ -172,12 +177,22 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger
172177
}
173178
}
174179

175-
func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool, firstRun bool) error {
180+
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 {
176181
appConfig, err := config.Get()
177182
if err != nil {
178183
return fmt.Errorf("failed to get config: %w", err)
179184
}
180185

186+
ref, err := resolveStartSnapshotRef(appConfig, snapshotFlag, noSnapshot)
187+
if err != nil {
188+
return err
189+
}
190+
// Parse the REF eagerly so an invalid snapshot fails before the emulator starts.
191+
autoLoad, err := newSnapshotAutoLoader(cfg, rt, appConfig, ref)
192+
if err != nil {
193+
return err
194+
}
195+
181196
opts := buildStartOptions(cfg, appConfig, logger, tel, persist)
182197

183198
notifyOpts := update.NotifyOptions{
@@ -201,6 +216,7 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
201216
ConfigPath: configPath,
202217
EmulatorLabel: config.CachedPlanLabel(),
203218
NeedsEmulatorSelection: firstRun,
219+
PostStart: autoLoad,
204220
})
205221
}
206222

@@ -213,9 +229,18 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
213229
})
214230
}
215231
update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken})
216-
if _, err = container.Start(ctx, rt, sink, opts, false); err != nil {
232+
resolvedVersion, err := container.Start(ctx, rt, sink, opts, false)
233+
if err != nil {
217234
return err
218235
}
236+
// Auto-load the configured snapshot only when the emulator was freshly started
237+
// this run (resolvedVersion is empty when it was already running). This mirrors
238+
// v1's AUTO_LOAD_POD: state is loaded as the emulator comes up, not on every invocation.
239+
if autoLoad != nil && resolvedVersion != "" {
240+
if err := autoLoad(ctx, sink); err != nil {
241+
return err
242+
}
243+
}
219244
if firstRun {
220245
return config.EnsureCreated()
221246
}

cmd/snapshot.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"os"
78
"time"
@@ -82,6 +83,86 @@ func newSnapshotCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cob
8283
return cmd
8384
}
8485

86+
// addSnapshotStartFlags registers the snapshot-related flags shared by the root
87+
// and `start` commands. --snapshot overrides the configured REF for one run;
88+
// --no-snapshot skips auto-loading for one run.
89+
func addSnapshotStartFlags(cmd *cobra.Command) {
90+
cmd.Flags().String("snapshot", "", "Snapshot REF to load after start (overrides config for this run)")
91+
cmd.Flags().Bool("no-snapshot", false, "Skip auto-loading the configured snapshot for this run")
92+
}
93+
94+
func snapshotFlags(cmd *cobra.Command) (snapshotFlag string, noSnapshot bool, err error) {
95+
if snapshotFlag, err = cmd.Flags().GetString("snapshot"); err != nil {
96+
return "", false, err
97+
}
98+
if noSnapshot, err = cmd.Flags().GetBool("no-snapshot"); err != nil {
99+
return "", false, err
100+
}
101+
return snapshotFlag, noSnapshot, nil
102+
}
103+
104+
// resolveStartSnapshotRef resolves the snapshot REF to auto-load on start.
105+
// Precedence: --no-snapshot disables it; otherwise --snapshot wins over the
106+
// AWS container's configured snapshot. Returns "" when nothing should be loaded.
107+
func resolveStartSnapshotRef(appConfig *config.Config, snapshotFlag string, noSnapshot bool) (string, error) {
108+
if noSnapshot && snapshotFlag != "" {
109+
return "", errors.New("--snapshot and --no-snapshot cannot be used together")
110+
}
111+
if noSnapshot {
112+
return "", nil
113+
}
114+
if snapshotFlag != "" {
115+
return snapshotFlag, nil
116+
}
117+
for _, c := range appConfig.Containers {
118+
if c.Type == config.EmulatorAWS && c.Snapshot != "" {
119+
return c.Snapshot, nil
120+
}
121+
}
122+
return "", nil
123+
}
124+
125+
// newSnapshotAutoLoader returns a loader that imports the given REF into the
126+
// running AWS emulator, or nil when ref is empty. The REF is parsed eagerly so an
127+
// invalid value fails before the emulator starts. The loader passes a nil Starter:
128+
// it is only invoked once the emulator is already up.
129+
func newSnapshotAutoLoader(cfg *env.Env, rt runtime.Runtime, appConfig *config.Config, ref string) (func(context.Context, output.Sink) error, error) {
130+
if ref == "" {
131+
return nil, nil
132+
}
133+
134+
var awsContainer config.ContainerConfig
135+
found := false
136+
for _, c := range appConfig.Containers {
137+
if c.Type == config.EmulatorAWS {
138+
awsContainer = c
139+
found = true
140+
break
141+
}
142+
}
143+
if !found {
144+
return nil, fmt.Errorf("snapshot auto-load is only supported for the AWS emulator")
145+
}
146+
147+
home, _ := os.UserHomeDir()
148+
src, err := snapshot.ParseSource(ref, home)
149+
if err != nil {
150+
return nil, err
151+
}
152+
153+
client := aws.NewClient()
154+
containers := []config.ContainerConfig{awsContainer}
155+
return func(ctx context.Context, sink output.Sink) error {
156+
host, _ := endpoint.ResolveHost(ctx, awsContainer.Port, cfg.LocalStackHost)
157+
switch src.Kind {
158+
case snapshot.KindPod:
159+
return snapshot.LoadPod(ctx, rt, containers, client, host, src.Value, cfg.AuthToken, "", nil, sink)
160+
default:
161+
return snapshot.LoadLocal(ctx, rt, containers, client, host, src.Value, "", nil, sink)
162+
}
163+
}, nil
164+
}
165+
85166
func buildStarter(cfg *env.Env, rt runtime.Runtime, appConfig *config.Config, logger log.Logger, tel *telemetry.Client) snapshot.Starter {
86167
return func(ctx context.Context, sink output.Sink) error {
87168
opts := buildStartOptions(cfg, appConfig, logger, tel, false)

cmd/snapshot_start_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package cmd
2+
3+
import (
4+
"testing"
5+
6+
"github.com/localstack/lstk/internal/config"
7+
"github.com/localstack/lstk/internal/env"
8+
"github.com/localstack/lstk/internal/log"
9+
"github.com/localstack/lstk/internal/telemetry"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func awsConfig(snapshot string) *config.Config {
15+
return &config.Config{
16+
Containers: []config.ContainerConfig{
17+
{Type: config.EmulatorAWS, Tag: "latest", Port: "4566", Snapshot: snapshot},
18+
},
19+
}
20+
}
21+
22+
func TestResolveStartSnapshotRef(t *testing.T) {
23+
t.Run("from config", func(t *testing.T) {
24+
ref, err := resolveStartSnapshotRef(awsConfig("pod:my-baseline"), "", false)
25+
require.NoError(t, err)
26+
assert.Equal(t, "pod:my-baseline", ref)
27+
})
28+
29+
t.Run("flag overrides config", func(t *testing.T) {
30+
ref, err := resolveStartSnapshotRef(awsConfig("pod:my-baseline"), "pod:override", false)
31+
require.NoError(t, err)
32+
assert.Equal(t, "pod:override", ref)
33+
})
34+
35+
t.Run("no-snapshot skips config", func(t *testing.T) {
36+
ref, err := resolveStartSnapshotRef(awsConfig("pod:my-baseline"), "", true)
37+
require.NoError(t, err)
38+
assert.Equal(t, "", ref)
39+
})
40+
41+
t.Run("no snapshot configured", func(t *testing.T) {
42+
ref, err := resolveStartSnapshotRef(awsConfig(""), "", false)
43+
require.NoError(t, err)
44+
assert.Equal(t, "", ref)
45+
})
46+
47+
t.Run("snapshot only read from AWS container", func(t *testing.T) {
48+
cfg := &config.Config{Containers: []config.ContainerConfig{
49+
{Type: config.EmulatorSnowflake, Tag: "latest", Port: "4566", Snapshot: "pod:ignored"},
50+
}}
51+
ref, err := resolveStartSnapshotRef(cfg, "", false)
52+
require.NoError(t, err)
53+
assert.Equal(t, "", ref)
54+
})
55+
56+
t.Run("conflicting flags error", func(t *testing.T) {
57+
_, err := resolveStartSnapshotRef(awsConfig(""), "pod:x", true)
58+
assert.ErrorContains(t, err, "cannot be used together")
59+
})
60+
}
61+
62+
func TestSnapshotStartFlagsRegistered(t *testing.T) {
63+
root := NewRootCmd(&env.Env{}, telemetry.New("", true), log.Nop())
64+
65+
assert.NotNil(t, root.Flags().Lookup("snapshot"), "root should register --snapshot")
66+
assert.NotNil(t, root.Flags().Lookup("no-snapshot"), "root should register --no-snapshot")
67+
68+
start, _, err := root.Find([]string{"start"})
69+
require.NoError(t, err)
70+
assert.NotNil(t, start.Flags().Lookup("snapshot"), "start should register --snapshot")
71+
assert.NotNil(t, start.Flags().Lookup("no-snapshot"), "start should register --no-snapshot")
72+
}

cmd/start.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.
1515
Short: "Start emulator",
1616
Long: `Start emulator and services.
1717
18-
Host environment variables prefixed with LOCALSTACK_ are forwarded to the emulator.`,
18+
Host environment variables prefixed with LOCALSTACK_ are forwarded to the emulator.
19+
20+
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.`,
1921
PreRunE: initConfigDeferCreate(&firstRun),
2022
RunE: func(c *cobra.Command, args []string) error {
2123
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
@@ -26,9 +28,14 @@ Host environment variables prefixed with LOCALSTACK_ are forwarded to the emulat
2628
if err != nil {
2729
return err
2830
}
29-
return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun)
31+
snapshotFlag, noSnapshot, err := snapshotFlags(c)
32+
if err != nil {
33+
return err
34+
}
35+
return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot)
3036
},
3137
}
3238
cmd.Flags().Bool("persist", false, "Persist emulator state across restarts")
39+
addSnapshotStartFlags(cmd)
3340
return cmd
3441
}

internal/config/containers.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ type ContainerConfig struct {
118118
Volume string `mapstructure:"volume"`
119119
// Env is a list of named environment references defined in the top-level [env.*] config sections.
120120
Env []string `mapstructure:"env"`
121+
// Snapshot is an optional snapshot REF (e.g. "pod:my-baseline" or a local path)
122+
// auto-loaded after the emulator starts. AWS emulator only.
123+
Snapshot string `mapstructure:"snapshot"`
121124
}
122125

123126
// VolumeDir returns the host directory to mount into the container for persistence/caching.

internal/config/default_config.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ tag = "latest" # Docker image tag, e.g. "latest", "2026.4"
1111
port = "4566" # Host port the emulator will be accessible on
1212
# volume = "" # Host directory for persistent state (default: OS cache dir)
1313
# env = [] # Named environment profiles to apply (see [env.*] sections below)
14+
# snapshot = "pod:my-baseline" # Snapshot REF auto-loaded on start (AWS only); skip once with 'lstk start --no-snapshot'
1415

1516
# Environment profiles let you group environment variables and reference
1617
# them by name in one or more containers via the 'env' field above.

internal/ui/run.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ type RunOptions struct {
3737
ConfigPath string
3838
EmulatorLabel string
3939
NeedsEmulatorSelection bool
40+
// PostStart, when set, runs after the emulator is freshly started (e.g. to
41+
// auto-load a configured snapshot). It is skipped when the emulator was
42+
// already running.
43+
PostStart func(ctx context.Context, sink output.Sink) error
4044
}
4145

4246
func Run(parentCtx context.Context, runOpts RunOptions) error {
@@ -112,6 +116,18 @@ func Run(parentCtx context.Context, runOpts RunOptions) error {
112116
p.Send(runErrMsg{err: err})
113117
return
114118
}
119+
// Auto-load the configured snapshot only when the emulator was freshly
120+
// started this run (resolvedVersion is empty when it was already running).
121+
if resolvedVersion != "" && runOpts.PostStart != nil {
122+
if postErr := runOpts.PostStart(ctx, sink); postErr != nil {
123+
if errors.Is(postErr, context.Canceled) {
124+
return
125+
}
126+
err = postErr
127+
p.Send(runErrMsg{err: postErr})
128+
return
129+
}
130+
}
115131
// Empty resolvedVersion means the container was already running and Start
116132
// returned early — use the cached label rather than re-resolving.
117133
if resolvedVersion == "" {

0 commit comments

Comments
 (0)