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
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ When adding a new command that depends on configuration, wire config initializat

A parent command that only groups subcommands (e.g. `config`, `setup`, `volume`, `snapshot`) must call `requireSubcommand(cmd)` (in `cmd/root.go`). Cobra otherwise prints help and exits 0 for an unknown/missing subcommand of a non-runnable parent; `requireSubcommand` sets `cobra.NoArgs` plus a help-printing `RunE` so a bare invocation still shows help (exit 0) while an unknown subcommand exits non-zero. Cobra's autogenerated `completion` command is the same shape, but it is created lazily during `Execute`, so `NewRootCmd` calls `root.InitDefaultCompletionCmd()` to materialize it before applying `requireSubcommand` (the call is idempotent — Cobra skips re-adding it).

Created automatically on first run with defaults. Supports emulator types: `aws`, `snowflake`, and `azure`.
Created automatically on first run with defaults. Supports emulator types: `aws`, `snowflake`, `azure`, and the preview `snowflake-next`.

`initConfigDeferCreate` (wrapping `config.Load`) only ever *reads* config — it never writes the default config.toml to disk. That's deliberate: the emulator-selection prompt (`container.SelectEmulator`) is shown only when `firstRun` is still true, and only bare `lstk` and `lstk start` wire it in (`NeedsEmulatorSelection: firstRun` in `startEmulator`). If some other command eagerly persisted a default (`type = "aws"`) config on its own first run, the selector would never get a chance to show on a genuinely fresh install — every command must use `initConfigDeferCreate`, never a hypothetical eager-create variant, so that only a real emulator start (interactive selection, or the non-interactive default-emulator path) ever writes the file. `EnsureCreated()` therefore has exactly three legitimate callers: the non-interactive first-run path in `cmd/root.go` (after a successful default start), `container.SelectEmulator` (after the user picks one), and `container.ApplyEmulatorType` (the `--type` flag's first-run path).

Expand All @@ -126,7 +126,11 @@ Each `[[containers]]` block may set an optional `container_name` (override the d

## Selecting the emulator (`--type`)

`lstk start --type <aws|snowflake|azure>` (shorthand `-t`; also on the bare root) is the non-interactive answer to the first-run emulator picker. It is a flag only — a positional (`lstk start azure`) is rejected with a hint pointing at `--type`, to avoid implying the root-level `lstk aws`/`lstk az` proxy names mean "start that emulator". It is defined as "rewrite the `type` line in config", not an ephemeral per-run override — downstream commands (`stop`, `status`, `logs`, `volume`, snapshot auto-load) all resolve from the configured type, so persisting keeps config and reality in sync. First run creates the config with the selected type (same `EnsureCreated`/`SetEmulatorType` path the picker uses); a matching config is a no-op; a differing config is switched in place via the surgical type-line rewrite (comments/formatting preserved) with a note naming the file. On switch: a custom `image` is a hard error (it pins a product that can't be reinterpreted under a new type — use `--config` for a separate profile), a non-`latest` `tag` and any `volumes`/`volume` are kept with a warning, and `container_name`/`port`/`env`/`snapshot` are kept silently (they describe the user's topology rather than pinning a product). Domain logic is `container.ApplyEmulatorType` (parallel to `container.SelectEmulator`); it is applied at the top of `startEmulator` (`cmd/root.go`) before snapshot/start-options are resolved, so it runs before the TUI and its messages go through a plain sink.
`lstk start --type <aws|snowflake|azure|snowflake-next>` (shorthand `-t`; also on the bare root) is the non-interactive answer to the first-run emulator picker. It is a flag only — a positional (`lstk start azure`) is rejected with a hint pointing at `--type`, to avoid implying the root-level `lstk aws`/`lstk az` proxy names mean "start that emulator". It is defined as "rewrite the `type` line in config", not an ephemeral per-run override — downstream commands (`stop`, `status`, `logs`, `volume`, snapshot auto-load) all resolve from the configured type, so persisting keeps config and reality in sync. First run creates the config with the selected type (same `EnsureCreated`/`SetEmulatorType` path the picker uses); a matching config is a no-op; a differing config is switched in place via the surgical type-line rewrite (comments/formatting preserved) with a note naming the file. On switch: a custom `image` is a hard error (it pins a product that can't be reinterpreted under a new type — use `--config` for a separate profile), a non-`latest` `tag` and any `volumes`/`volume` are kept with a warning, and `container_name`/`port`/`env`/`snapshot` are kept silently (they describe the user's topology rather than pinning a product). Domain logic is `container.ApplyEmulatorType` (parallel to `container.SelectEmulator`); it is applied at the top of `startEmulator` (`cmd/root.go`) before snapshot/start-options are resolved, so it runs before the TUI and its messages go through a plain sink.

Emulator types split two ways, and the distinction is load-bearing: `config.SelectableEmulatorTypes` is what the interactive first-run picker offers, while `config.KnownEmulatorTypes()` (selectable plus `previewEmulatorTypes`) is what config and `--type` accept. A preview type is reachable only by asking for it explicitly, so a new install's first choice stays a GA product. `snowflake-next` is the one preview today — the rewritten Snowflake emulator, which at GA takes over the plain `snowflake` type and image and is then retired (LAV-595). Adding a type means touching `knownImages`, `emulatorHealthPaths`, `ContainerPort`, `SelfValidatesLicense`, `emulatorDisplayNames`, the `cmd/status.go` client map, and `tipsForType`; the compiler catches none of these, since they are all map/slice entries.

`snowflake-next` needs no per-emulator special-casing on the start path: the image is a drop-in for `localstack/snowflake` at the container level — it binds from `GATEWAY_LISTEN` (every entry in the list), declares `/var/lib/localstack` as its volume, chooses its data dir from `LOCALSTACK_PERSISTENCE`, and chowns a bind-mounted state dir before dropping privileges (localstack/snowflake-rs#2245). lstk's generic start path already covers all of that, so the type is nothing but the registry entries above. If a future preview image diverges again, fix the image rather than re-adding an adaptation branch here.

`GATEWAY_LISTEN` (host exposure and published ports) is read from the container's resolved env, not hardcoded; parsing and derivation live in `internal/container/gateway.go`.

Expand Down
4 changes: 3 additions & 1 deletion cmd/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ func emulatorCandidates() []config.ContainerConfig {
seen[c.Type] = struct{}{}
}
}
for _, t := range config.SelectableEmulatorTypes {
// Every known type, not just the selectable ones: this probes for running
// emulators to report to the extension, and a preview type runs the same way.
for _, t := range config.KnownEmulatorTypes() {
if _, ok := seen[t]; ok {
continue
}
Expand Down
6 changes: 5 additions & 1 deletion cmd/iac.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,13 @@ func requireRunningAWSEmulator(ctx context.Context, rt runtime.Runtime, sink out
// (e.g. Snowflake or Azure), or "" if none is running. The IaC proxy commands
// support only the AWS emulator, so this lets them give a specific error when a
// different emulator is running instead of a misleading "AWS not running".
//
// It enumerates every known type, not just the selectable ones: the question is
// what might be running, and a preview emulator the picker never offers can be
// running just as well.
func runningNonAWSEmulator(ctx context.Context, rt runtime.Runtime) string {
var others []config.ContainerConfig
for _, t := range config.SelectableEmulatorTypes {
for _, t := range config.KnownEmulatorTypes() {
if t == config.EmulatorAWS {
continue
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t

// addEmulatorTypeFlag registers the --type/-t flag on a start-capable command.
func addEmulatorTypeFlag(cmd *cobra.Command) {
cmd.Flags().StringP("type", "t", "", "Emulator type to start (aws, snowflake, azure)")
cmd.Flags().StringP("type", "t", "", "Emulator type to start (aws, snowflake, azure, snowflake-next)")
}

// resolveEmulatorTypeFlag resolves the requested emulator type from the --type
Expand Down
2 changes: 2 additions & 0 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Host environment variables prefixed with LOCALSTACK_ are forwarded to the emulat

Use --type (aws, snowflake, azure) to select the emulator non-interactively; it records the selection in config, switching the configured type in place when it differs.

snowflake-next is a preview of the next Snowflake emulator. It is not offered by the interactive picker, but --type snowflake-next selects it like any other type.

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.`,
Args: func(_ *cobra.Command, args []string) error {
if len(args) > 0 {
Expand Down
7 changes: 4 additions & 3 deletions cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ func newStatusCmd(cfg *env.Env) *cobra.Command {
}

clients := map[config.EmulatorType]emulator.Client{
config.EmulatorAWS: aws.NewClient(),
config.EmulatorSnowflake: snowflake.NewClient(),
config.EmulatorAzure: azure.NewClient(),
config.EmulatorAWS: aws.NewClient(),
config.EmulatorSnowflake: snowflake.NewClient(),
config.EmulatorAzure: azure.NewClient(),
config.EmulatorSnowflakeNext: snowflake.NewClient(),
}

if target != nil {
Expand Down
41 changes: 32 additions & 9 deletions internal/config/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,42 @@ const (
EmulatorAWS EmulatorType = "aws"
EmulatorSnowflake EmulatorType = "snowflake"
EmulatorAzure EmulatorType = "azure"
// EmulatorSnowflakeNext is the rewritten Snowflake emulator, published as
// localstack/snowflake-next while it is in preview. The name deliberately
// says nothing about the implementation: at GA it takes over the plain
// `snowflake` type and image, and the Python build stays reachable only
// through pinned legacy tags, at which point this type is retired (LAV-595).
EmulatorSnowflakeNext EmulatorType = "snowflake-next"

DefaultPort = "4566"
dockerRegistry = "localstack"
)

var emulatorDisplayNames = map[EmulatorType]string{
EmulatorAWS: "AWS",
EmulatorSnowflake: "Snowflake",
EmulatorAzure: "Azure",
EmulatorAWS: "AWS",
EmulatorSnowflake: "Snowflake",
EmulatorAzure: "Azure",
EmulatorSnowflakeNext: "Snowflake Preview",
}

// SelectableEmulatorTypes lists the emulator types available for interactive selection,
// in the order they should be presented.
// in the order they should be presented. Preview types are deliberately absent — see
// previewEmulatorTypes.
var SelectableEmulatorTypes = []EmulatorType{EmulatorAWS, EmulatorSnowflake, EmulatorAzure}

// previewEmulatorTypes lists types that are valid in config and accepted by --type,
// but are not offered by the interactive first-run picker: a new user's first choice
// should be a GA product, while an existing user can opt into a preview explicitly.
// They are still named in ParseEmulatorType's error, since an error that lists the
// valid values must list all of them.
var previewEmulatorTypes = []EmulatorType{EmulatorSnowflakeNext}

// KnownEmulatorTypes lists every type accepted in config or via --type: the
// selectable ones followed by the previews.
func KnownEmulatorTypes() []EmulatorType {
return append(append([]EmulatorType{}, SelectableEmulatorTypes...), previewEmulatorTypes...)
}

// emulatorSelectionKeys assigns each selectable type a unique single-character key.
// "aws" and "azure" both start with 'a', so keys can't simply be the first character.
var emulatorSelectionKeys = map[EmulatorType]string{
Expand Down Expand Up @@ -67,13 +88,14 @@ func (e EmulatorType) DisplayName() string {
// platform license check (the LocalStack platform API has no catalog entry for
// them), and lets the container validate the token against the licensing server.
func (e EmulatorType) SelfValidatesLicense() bool {
return e == EmulatorSnowflake || e == EmulatorAzure
return e == EmulatorSnowflake || e == EmulatorAzure || e == EmulatorSnowflakeNext
}

var emulatorHealthPaths = map[EmulatorType]string{
EmulatorAWS: "/_localstack/health",
EmulatorSnowflake: "/_localstack/health",
EmulatorAzure: "/_localstack/health",
EmulatorAWS: "/_localstack/health",
EmulatorSnowflake: "/_localstack/health",
EmulatorAzure: "/_localstack/health",
EmulatorSnowflakeNext: "/_localstack/health",
}

var knownImages = []struct {
Expand All @@ -85,6 +107,7 @@ var knownImages = []struct {
{EmulatorAWS, "localstack", false},
{EmulatorSnowflake, "snowflake", true},
{EmulatorAzure, "localstack-azure", true},
{EmulatorSnowflakeNext, "snowflake-next", true},
}

func EmulatorTypeForImage(image string) EmulatorType {
Expand Down Expand Up @@ -593,7 +616,7 @@ func (c *ContainerConfig) HealthPath() (string, error) {

func (c *ContainerConfig) ContainerPort() (string, error) {
switch c.Type {
case EmulatorAWS, EmulatorSnowflake, EmulatorAzure:
case EmulatorAWS, EmulatorSnowflake, EmulatorAzure, EmulatorSnowflakeNext:
return DefaultPort + "/tcp", nil
default:
return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type)
Expand Down
3 changes: 2 additions & 1 deletion internal/config/default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
# 'lstk start' refuses to start with more than one block.

[[containers]]
type = "aws" # Emulator type. Currently supported: "aws", "snowflake", "azure"
type = "aws" # Emulator type. Currently supported: "aws", "snowflake", "azure",
# # and "snowflake-next" (preview of the next Snowflake emulator).
tag = "latest" # Docker image tag, e.g. "latest", "2026.4"
port = "4566" # Host port the emulator will be accessible on
# container_name = "" # Container name (default: "localstack-<type>", plus "-<tag>"
Expand Down
13 changes: 8 additions & 5 deletions internal/config/emulator_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,19 @@ var (
tableHeaderRe = regexp.MustCompile(`(?m)^[ \t]*\[`)
)

// ParseEmulatorType validates a raw emulator type string against the selectable
// types and returns the corresponding EmulatorType.
// ParseEmulatorType validates a raw emulator type string against the known
// types and returns the corresponding EmulatorType. Preview types are accepted
// even though the interactive picker does not offer them, since --type is the
// only way to reach them.
func ParseEmulatorType(s string) (EmulatorType, error) {
for _, t := range SelectableEmulatorTypes {
known := KnownEmulatorTypes()
for _, t := range known {
if string(t) == s {
return t, nil
}
}
valid := make([]string, len(SelectableEmulatorTypes))
for i, t := range SelectableEmulatorTypes {
valid := make([]string, len(known))
for i, t := range known {
valid[i] = string(t)
}
return "", fmt.Errorf("invalid emulator type %q (must be one of: %s)", s, strings.Join(valid, ", "))
Expand Down
4 changes: 2 additions & 2 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ func isPersistenceEnabled(ctx context.Context, rt runtime.Runtime, containerName
}

func emitPostStartPointers(sink output.Sink, emulatorType config.EmulatorType, resolvedHost, webAppURL string, persist bool) {
if sfHost := snowflake.Hostname(resolvedHost); emulatorType == config.EmulatorSnowflake && sfHost != "" {
if sfHost := snowflake.Hostname(resolvedHost); (emulatorType == config.EmulatorSnowflake || emulatorType == config.EmulatorSnowflakeNext) && sfHost != "" {
sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Snowflake endpoint: http://%s", sfHost)})
} else {
sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Endpoint: %s", resolvedHost)})
Expand All @@ -417,7 +417,7 @@ func tipsForType(t config.EmulatorType) []string {
"> Tip: View emulator logs: lstk logs --follow",
"> Tip: View deployed resources: lstk status",
}
case config.EmulatorSnowflake:
case config.EmulatorSnowflake, config.EmulatorSnowflakeNext:
return []string{
"> Tip: View emulator logs: lstk logs --follow",
"> Tip: Check emulator status: lstk status",
Expand Down
8 changes: 4 additions & 4 deletions internal/container/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ func TestSelectContainersToStart_AttachesWhenExternalContainerOnConfiguredPort(t
}

mockRT.EXPECT().InspectBrief(gomock.Any(), c.Name).Return(runtime.ContainerBrief{}, nil)
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake", "localstack/localstack-azure"}, "4566/tcp").
mockRT.EXPECT().FindRunningByImage(gomock.Any(), config.KnownImageRepos(), "4566/tcp").
Return(&runtime.RunningContainer{Name: "external-container", Image: "localstack/localstack-pro:3.5.0", BoundPort: "4566"}, nil)
mockRT.EXPECT().ContainerEnv(gomock.Any(), "external-container").Return(nil, nil)

Expand Down Expand Up @@ -305,7 +305,7 @@ func TestSelectContainersToStart_AttachesWhenExternalContainerVersionDiffers(t *
}

mockRT.EXPECT().InspectBrief(gomock.Any(), c.Name).Return(runtime.ContainerBrief{}, nil)
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake", "localstack/localstack-azure"}, "4566/tcp").
mockRT.EXPECT().FindRunningByImage(gomock.Any(), config.KnownImageRepos(), "4566/tcp").
Return(&runtime.RunningContainer{Name: "external-container", Image: "localstack/localstack-pro:3.5.0", BoundPort: "4566"}, nil)
mockRT.EXPECT().ContainerEnv(gomock.Any(), "external-container").Return(nil, nil)

Expand Down Expand Up @@ -339,7 +339,7 @@ func TestSelectContainersToStart_QueuesContainerWhenNoneRunningOnPort(t *testing
}

mockRT.EXPECT().InspectBrief(gomock.Any(), c.Name).Return(runtime.ContainerBrief{}, nil)
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake", "localstack/localstack-azure"}, "4566/tcp").
mockRT.EXPECT().FindRunningByImage(gomock.Any(), config.KnownImageRepos(), "4566/tcp").
Return(nil, nil)
mockRT.EXPECT().Flavor().Return(runtime.FlavorDockerDesktop).AnyTimes()

Expand All @@ -366,7 +366,7 @@ func TestSelectContainersToStart_ErrorsOnEmulatorTypeMismatch(t *testing.T) {
}

mockRT.EXPECT().InspectBrief(gomock.Any(), c.Name).Return(runtime.ContainerBrief{}, nil)
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake", "localstack/localstack-azure"}, "4566/tcp").
mockRT.EXPECT().FindRunningByImage(gomock.Any(), config.KnownImageRepos(), "4566/tcp").
Return(&runtime.RunningContainer{Name: "localstack-aws", Image: "localstack/localstack-pro:latest", BoundPort: "4566"}, nil)

var out bytes.Buffer
Expand Down
2 changes: 1 addition & 1 deletion internal/container/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func Status(ctx context.Context, rt runtime.Runtime, containers []config.Contain
}
}
host, _ := endpoint.ResolveHost(ctx, port, localStackHost)
if c.Type == config.EmulatorSnowflake {
if c.Type == config.EmulatorSnowflake || c.Type == config.EmulatorSnowflakeNext {
if h := snowflake.Hostname(host); h != "" {
host = h
}
Expand Down
Loading
Loading