From e02297336530d25e433b1a6029f82fcba34f95bd Mon Sep 17 00:00:00 2001 From: Przemek Denkiewicz Date: Tue, 18 Aug 2026 16:18:45 +0200 Subject: [PATCH 1/4] Add snowflake-next preview emulator type Co-Authored-By: Claude --- CLAUDE.md | 8 +- cmd/extension.go | 4 +- cmd/iac.go | 6 +- cmd/root.go | 2 +- cmd/start.go | 2 + cmd/status.go | 7 +- internal/config/containers.go | 41 ++- internal/config/default_config.toml | 3 +- internal/config/emulator_type.go | 13 +- internal/container/start.go | 31 ++- internal/container/start_test.go | 8 +- internal/container/status.go | 2 +- internal/emulator/snowflake/snowflake.go | 33 +++ internal/endpoint/target.go | 17 +- .../__snapshots__/emulator_type_test.snap | 2 +- .../__snapshots__/extension_test.snap | 6 +- test/integration/snowflake_next_test.go | 238 ++++++++++++++++++ 17 files changed, 383 insertions(+), 40 deletions(-) create mode 100644 test/integration/snowflake_next_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 0d416630..8d2be994 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). @@ -126,7 +126,11 @@ Each `[[containers]]` block may set an optional `container_name` (override the d ## Selecting the emulator (`--type`) -`lstk start --type ` (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 ` (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. + +Unlike the other emulators, `snowflake-next` does not read `GATEWAY_LISTEN` and ships its own declared VOLUME, so the start path adapts it: `SNOWFLAKE_LISTEN_ADDR` moves its listener onto the gateway port, and a bind mount covers its volume path (otherwise every start strands a PostgreSQL cluster in an anonymous volume, because lstk recreates the container and `docker rm` keeps anonymous volumes). Its cluster is always written to disk, so `--persist` is expressed as *where* `PGDATA` points rather than an on/off switch — details on `snowflake.NextStateDir` and `snowflake.NextEphemeralPGData`. `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`. diff --git a/cmd/extension.go b/cmd/extension.go index ebfdda4d..2cfa5b75 100644 --- a/cmd/extension.go +++ b/cmd/extension.go @@ -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 } diff --git a/cmd/iac.go b/cmd/iac.go index b3ac8386..841fa79c 100644 --- a/cmd/iac.go +++ b/cmd/iac.go @@ -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 } diff --git a/cmd/root.go b/cmd/root.go index 12dfbabd..32ff77fe 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 diff --git a/cmd/start.go b/cmd/start.go index bd898c75..9cdd4fb8 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -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 { diff --git a/cmd/status.go b/cmd/status.go index b0d688b9..341c2c96 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -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 { diff --git a/internal/config/containers.go b/internal/config/containers.go index 4fb3c964..36bd2dd9 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -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{ @@ -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 { @@ -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 { @@ -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) diff --git a/internal/config/default_config.toml b/internal/config/default_config.toml index 4c07d723..72d3fc64 100644 --- a/internal/config/default_config.toml +++ b/internal/config/default_config.toml @@ -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-", plus "-" diff --git a/internal/config/emulator_type.go b/internal/config/emulator_type.go index ff5af81c..8a7f48c9 100644 --- a/internal/config/emulator_type.go +++ b/internal/config/emulator_type.go @@ -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, ", ")) diff --git a/internal/container/start.go b/internal/container/start.go index 7fe13736..744c6f34 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -223,6 +223,21 @@ func startOnce(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts S env = append(env, "SF_S3_ENDPOINT="+snowflake.S3Endpoint(c.Port)) } + // The preview Snowflake emulator listens on 8080 by default and ignores + // GATEWAY_LISTEN, so its own listen variable is what has to move it onto the + // gateway port every other part of lstk assumes. Its PostgreSQL cluster is + // always written to disk (there is no in-memory mode), so persistence is a + // matter of where PGDATA points: inside the bound state dir, or in the + // container's writable layer when the user did not ask to persist. + if c.Type == config.EmulatorSnowflakeNext { + if !envHasKey(resolvedEnv, "SNOWFLAKE_LISTEN_ADDR") { + env = append(env, "SNOWFLAKE_LISTEN_ADDR="+snowflake.NextListenAddr(config.DefaultPort)) + } + if !opts.Persist && !envHasKey(resolvedEnv, "PGDATA") { + env = append(env, "PGDATA="+snowflake.NextEphemeralPGData) + } + } + env = append(env, hostEnv...) env = append(env, agentEnvVars...) @@ -245,6 +260,18 @@ func startOnce(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts S } binds = append(binds, runtime.BindMount{HostPath: volumeDir, ContainerPath: "/var/lib/localstack"}) + // Cover the preview Snowflake emulator's own declared VOLUME — see + // snowflake.NextStateDir for why leaving it uncovered leaks a PostgreSQL + // cluster per start. It lives under the managed volume dir so that + // `lstk volume path` points at it and `lstk volume clear` resets it. + if c.Type == config.EmulatorSnowflakeNext { + stateDir := filepath.Join(volumeDir, "snowflake-rs") + if err := os.MkdirAll(stateDir, 0755); err != nil { + return "", fmt.Errorf("failed to create state directory %s: %w", stateDir, err) + } + binds = append(binds, runtime.BindMount{HostPath: stateDir, ContainerPath: snowflake.NextStateDir}) + } + // Extra user-defined mounts (e.g. Snowflake init hooks). Unlike the persistence // directory, these are not created — init-hook entries are files, so the source // must already exist; creating it would produce a wrong empty directory. @@ -394,7 +421,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)}) @@ -417,7 +444,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", diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 9d91fd96..0e508bfb 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -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) @@ -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) @@ -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() @@ -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 diff --git a/internal/container/status.go b/internal/container/status.go index 7ba7f339..edc2793b 100644 --- a/internal/container/status.go +++ b/internal/container/status.go @@ -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 } diff --git a/internal/emulator/snowflake/snowflake.go b/internal/emulator/snowflake/snowflake.go index e729a810..c175cb08 100644 --- a/internal/emulator/snowflake/snowflake.go +++ b/internal/emulator/snowflake/snowflake.go @@ -15,6 +15,39 @@ func S3Endpoint(port string) string { return "s3." + endpoint.Hostname + ":" + port } +// NextListenAddr returns the value for the preview Snowflake emulator's +// SNOWFLAKE_LISTEN_ADDR variable, given the port it should serve inside the +// container. The image itself defaults to 8080, but lstk publishes, health-checks +// and advertises every emulator on the LocalStack gateway port, so the listener is +// moved there rather than teaching the rest of lstk a second container port. +func NextListenAddr(containerPort string) string { + return "0.0.0.0:" + containerPort +} + +// NextStateDir is the container path the preview Snowflake emulator declares as a +// VOLUME and puts its embedded PostgreSQL cluster in (the image's PGDATA default +// is the "data" subdirectory of it). +// +// lstk binds a directory over it on every start. That is not about persistence: +// lstk recreates the container each start and `docker rm` leaves an anonymous +// volume behind, so leaving the declaration uncovered would strand a whole +// PostgreSQL cluster's worth of data per start. Covering it also gives --persist +// a place to keep the cluster without overriding PGDATA. +// +// Caveat for --persist on Linux: the emulator runs as a non-root user (uid 1000), +// which has to create the PGDATA subdirectory inside this mount, so a host +// directory owned by a different uid makes PostgreSQL refuse to start. Docker +// Desktop maps ownership and is unaffected. The default (no --persist) path never +// writes here, so only --persist is exposed to it. +const NextStateDir = "/var/lib/snowflake-rs" + +// NextEphemeralPGData is where the preview Snowflake emulator's PostgreSQL cluster +// goes when persistence is off. It sits outside NextStateDir, in the container's +// writable layer, so the cluster is discarded with the container — matching what a +// user gets from the other emulators without --persist. The emulator already uses +// /tmp for stages and its TLS cache, so the path is writable by its non-root user. +const NextEphemeralPGData = "/tmp/snowflake-rs/data" + func Hostname(resolvedHost string) string { host, _, err := net.SplitHostPort(resolvedHost) if err != nil { diff --git a/internal/endpoint/target.go b/internal/endpoint/target.go index 39775784..cb6a7c28 100644 --- a/internal/endpoint/target.go +++ b/internal/endpoint/target.go @@ -203,12 +203,6 @@ var awsSignatureServices = []string{"s3", "sqs", "sts", "iam", "lambda", "dynamo // its health/info surface, and doubles as the reachability check: an // unreachable or non-LocalStack-shaped response fails closed rather than // silently proceeding. -// -// NOTE: the AWS-vs-Snowflake classification below (via "services" map -// contents) is a best-effort heuristic pending confirmation against a real -// LocalStack Snowflake health payload — the Snowflake product requires a -// licensed emulator to inspect, which wasn't available to verify this -// against. See design.md's Open Questions for add-endpoint-url-flag. func probeType(ctx context.Context, endpointURL string) (config.EmulatorType, error) { health, err := fetchJSON[healthResponse](ctx, endpointURL+"/_localstack/health") if err != nil { @@ -275,6 +269,17 @@ func swapScheme(endpointURL string) (string, bool) { // classifyByServices inspects a health response's "services" map for a // per-product signature, returning "" when neither is recognized. +// +// Snowflake is checked before AWS because the Snowflake image reports the whole +// AWS service catalog alongside its own "snowflake" key, so an AWS key proves +// nothing on its own. Verified against localstack/snowflake:latest and a +// community localstack image. +// +// The preview Snowflake emulator (config.EmulatorSnowflakeNext) is not +// classifiable here: its health payload carries a version and no services map at +// all, so it lands in IndeterminateTypeError. Fixing that means adding the key on +// its side (LAV-1678) rather than guessing from an absent map here, which would +// misread every future emulator with a minimal payload as the preview. func classifyByServices(services map[string]string) config.EmulatorType { if _, ok := services["snowflake"]; ok { return config.EmulatorSnowflake diff --git a/test/integration/__snapshots__/emulator_type_test.snap b/test/integration/__snapshots__/emulator_type_test.snap index 55c16e55..f7a5adfc 100644 --- a/test/integration/__snapshots__/emulator_type_test.snap +++ b/test/integration/__snapshots__/emulator_type_test.snap @@ -12,7 +12,7 @@ Error: failed to switch emulator type: no [[containers]] block found in config --- [TestStartTypeInvalidValue_1] -Error: invalid emulator type "bogus" (must be one of: aws, snowflake, azure) +Error: invalid emulator type "bogus" (must be one of: aws, snowflake, azure, snowflake-next) --- [TestStartTypePositionalRejected_1] diff --git a/test/integration/__snapshots__/extension_test.snap b/test/integration/__snapshots__/extension_test.snap index 80efdd3b..930e2afa 100644 --- a/test/integration/__snapshots__/extension_test.snap +++ b/test/integration/__snapshots__/extension_test.snap @@ -45,7 +45,7 @@ Options: --persist Persist emulator state across restarts --snapshot string Snapshot REF to load after start (overrides config for this run) --timeout duration Maximum time to wait for the emulator to become ready (overrides LSTK_STARTUP_TIMEOUT; 0 uses the default) - -t, --type string Emulator type to start (aws, snowflake, azure) + -t, --type string Emulator type to start (aws, snowflake, azure, snowflake-next) -v, --version Show version --- @@ -106,7 +106,7 @@ Options: --persist Persist emulator state across restarts --snapshot string Snapshot REF to load after start (overrides config for this run) --timeout duration Maximum time to wait for the emulator to become ready (overrides LSTK_STARTUP_TIMEOUT; 0 uses the default) - -t, --type string Emulator type to start (aws, snowflake, azure) + -t, --type string Emulator type to start (aws, snowflake, azure, snowflake-next) -v, --version Show version --- @@ -154,7 +154,7 @@ Options: --persist Persist emulator state across restarts --snapshot string Snapshot REF to load after start (overrides config for this run) --timeout duration Maximum time to wait for the emulator to become ready (overrides LSTK_STARTUP_TIMEOUT; 0 uses the default) - -t, --type string Emulator type to start (aws, snowflake, azure) + -t, --type string Emulator type to start (aws, snowflake, azure, snowflake-next) -v, --version Show version --- diff --git a/test/integration/snowflake_next_test.go b/test/integration/snowflake_next_test.go new file mode 100644 index 00000000..23cffec4 --- /dev/null +++ b/test/integration/snowflake_next_test.go @@ -0,0 +1,238 @@ +package integration_test + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/localstack/lstk/test/integration/env" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const snowflakeNextContainerName = "localstack-snowflake-next" + +func cleanupSnowflakeNext() { + ctx := context.Background() + _, _ = dockerClient.ContainerRemove(ctx, snowflakeNextContainerName, client.ContainerRemoveOptions{Force: true}) +} + +func writeSnowflakeNextConfig(t *testing.T, hostPort string) string { + t.Helper() + content := fmt.Sprintf(` +[[containers]] +type = "snowflake-next" +tag = "latest" +port = %q +`, hostPort) + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(content), 0644)) + return configFile +} + +func TestStartTypeFlagSelectsSnowflakeNextOnFirstRun(t *testing.T) { + t.Parallel() + e, _ := typeTestEnv(t) + configPath := resolvedConfigPath(t, e) + require.NoFileExists(t, configPath) + + stdout, _, _ := runLstk(t, testContext(t), t.TempDir(), e, "start", "--type", "snowflake-next", "--non-interactive") + + assert.Contains(t, stdout, "Snowflake Preview emulator selected.") + data, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(data), `type = "snowflake-next"`) +} + +func TestStartTypeFlagSwitchesFromSnowflakeToPreview(t *testing.T) { + t.Parallel() + e, _ := typeTestEnv(t) + configPath := resolvedConfigPath(t, e) + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) + require.NoError(t, os.WriteFile(configPath, []byte("[[containers]]\ntype = \"snowflake\" # keep me\ntag = \"latest\"\nport = \"4566\"\n"), 0644)) + + stdout, _, _ := runLstk(t, testContext(t), t.TempDir(), e, "start", "--type", "snowflake-next", "--non-interactive") + + assert.Contains(t, stdout, "Switched configured emulator to Snowflake Preview") + data, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(data), `type = "snowflake-next"`) + assert.Contains(t, string(data), "# keep me") +} + +// TestFirstRunPickerOmitsSnowflakeNext pins the decision that a preview emulator +// is reachable through --type but is never offered to a first-time user: the +// picker is a new install's first impression and should only present GA products. +func TestFirstRunPickerOmitsSnowflakeNext(t *testing.T) { + requireDocker(t) + t.Parallel() + + tmpHome := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) + e := env.Environ(testEnvWithHome(tmpHome, tmpHome)). + With(env.DisableEvents, "1") + + configPath, _, err := runLstk(t, testContext(t), "", e, "config", "path") + require.NoError(t, err) + require.NoFileExists(t, configPath) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + p := startLstkInPTY(t, ctx, e, "start") + p.waitForOutput("Which emulator would you like to use?", "emulator selection prompt should appear on first run") + + // Wait for the option list to render before asserting on absence, so this + // cannot pass merely by reading the screen too early. + p.waitForOutput("Snowflake", "the picker should offer the GA Snowflake emulator") + assert.NotContains(t, p.output(), "Snowflake Preview", + "the first-run picker must not offer the preview emulator") + + p.kill() +} + +// TestStartSnowflakeNextServesGatewayOnConfiguredPort is the end-to-end proof of +// the port remap: the image listens on 8080 and ignores GATEWAY_LISTEN, so +// without lstk rewriting its own listen variable nothing answers on the port +// lstk publishes, health-checks and advertises. +func TestStartSnowflakeNextServesGatewayOnConfiguredPort(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + cleanupSnowflakeNext() + t.Cleanup(cleanup) + t.Cleanup(cleanupSnowflakeNext) + + const hostPort = "4577" + configFile := writeSnowflakeNextConfig(t, hostPort) + + ctx := testContext(t) + stdout, stderr, err := runLstk(t, ctx, "", env.Environ(testEnvWithHome(t.TempDir(), "")), "--config", configFile, "start") + require.NoError(t, err, "lstk start failed: %s", stderr) + requireExitCode(t, 0, err) + + inspect, err := dockerClient.ContainerInspect(ctx, snowflakeNextContainerName, client.ContainerInspectOptions{}) + require.NoError(t, err, "failed to inspect snowflake-next container") + require.True(t, inspect.Container.State.Running, "snowflake-next container should be running") + assert.Contains(t, inspect.Container.Config.Image, "localstack/snowflake-next", + "expected localstack/snowflake-next image, got %s", inspect.Container.Config.Image) + + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/_localstack/health", hostPort)) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + assert.Equal(t, http.StatusOK, resp.StatusCode, + "the emulator must answer the health contract on the configured host port") + + assert.Contains(t, stdout, "• Snowflake endpoint: http://snowflake.", + "the preview emulator should print the snowflake-prefixed endpoint hint") +} + +// TestStartSnowflakeNextKeepsStateOutOfVolumeWithoutPersist and its --persist +// sibling pin where the emulator's PostgreSQL cluster is written. It always +// writes to disk (there is no in-memory mode), so persistence is decided purely +// by which path PGDATA names, and the mount over the image's own declared VOLUME +// must be present either way — an uncovered declaration strands a whole cluster +// in an anonymous volume on every start, since lstk recreates the container. +func TestStartSnowflakeNextKeepsStateOutOfVolumeWithoutPersist(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + cleanupSnowflakeNext() + t.Cleanup(cleanup) + t.Cleanup(cleanupSnowflakeNext) + + configFile := writeSnowflakeNextConfig(t, "4578") + + ctx := testContext(t) + _, stderr, err := runLstk(t, ctx, "", env.Environ(testEnvWithHome(t.TempDir(), "")), "--config", configFile, "start") + require.NoError(t, err, "lstk start failed: %s", stderr) + + inspect, err := dockerClient.ContainerInspect(ctx, snowflakeNextContainerName, client.ContainerInspectOptions{}) + require.NoError(t, err) + envVars := containerEnvToMap(inspect.Container.Config.Env) + assert.Equal(t, "0.0.0.0:4566", envVars["SNOWFLAKE_LISTEN_ADDR"], + "the listener must be moved onto the gateway port lstk publishes") + assert.Equal(t, "/tmp/snowflake-rs/data", envVars["PGDATA"], + "without --persist the cluster must live in the container, not the mounted volume") + assertStateDirMounted(t, inspect.Container.Mounts) +} + +func TestStartSnowflakeNextPersistsStateIntoVolumeWithPersist(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + cleanupSnowflakeNext() + t.Cleanup(cleanup) + t.Cleanup(cleanupSnowflakeNext) + + configFile := writeSnowflakeNextConfig(t, "4579") + + ctx := testContext(t) + _, stderr, err := runLstk(t, ctx, "", env.Environ(testEnvWithHome(t.TempDir(), "")), "--config", configFile, "start", "--persist") + require.NoError(t, err, "lstk start failed: %s", stderr) + + inspect, err := dockerClient.ContainerInspect(ctx, snowflakeNextContainerName, client.ContainerInspectOptions{}) + require.NoError(t, err) + envVars := containerEnvToMap(inspect.Container.Config.Env) + // The image sets PGDATA itself; --persist means lstk leaves that default + // alone, so the cluster is written into the mounted state dir. + assert.Equal(t, "/var/lib/snowflake-rs/data", envVars["PGDATA"], + "with --persist the cluster must land in the mounted state dir") + assertStateDirMounted(t, inspect.Container.Mounts) +} + +// assertStateDirMounted checks a host directory is bound over the image's declared +// VOLUME, which is what keeps Docker from creating an anonymous volume per start. +func assertStateDirMounted(t *testing.T, mounts []container.MountPoint) { + t.Helper() + for _, m := range mounts { + if m.Destination == "/var/lib/snowflake-rs" { + assert.Equal(t, "bind", string(m.Type), + "/var/lib/snowflake-rs must be a bind mount, not an anonymous volume") + assert.True(t, strings.HasSuffix(filepath.ToSlash(m.Source), "/snowflake-rs"), + "expected the managed volume subdirectory, got %s", m.Source) + return + } + } + t.Errorf("no mount covers /var/lib/snowflake-rs; got %+v", mounts) +} + +// TestTerraformRejectsRunningSnowflakeNext covers the discovery side of the +// preview type: the IaC proxies support only the AWS emulator, and they name the +// emulator that is actually running so the error is not a misleading "AWS not +// running". That naming enumerates the known types, so a type the interactive +// picker never offers has to be included. alpine retagged as the preview image is +// enough — discovery matches on image repo and port, so no product image or +// license is needed. +func TestTerraformRejectsRunningSnowflakeNext(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + + const fakeImage = "localstack/snowflake-next:test-fake" + _, err := dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: testImage, Target: fakeImage}) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = dockerClient.ImageRemove(context.Background(), fakeImage, client.ImageRemoveOptions{}) + }) + startExternalContainer(t, ctx, fakeImage, "localstack-external-snowflake-next", "4566") + + e, _ := typeTestEnvWithDocker(t) + stdout, _, err := runLstk(t, ctx, t.TempDir(), e, "terraform", "plan") + + require.Error(t, err) + assert.Contains(t, stdout, "LocalStack Snowflake Preview Emulator is running", + "the error must name the running preview emulator, not report AWS as missing") +} From 8cff579f95d0fc5b3abfcac2a69f8aa5b71997bf Mon Sep 17 00:00:00 2001 From: Przemek Denkiewicz Date: Tue, 18 Aug 2026 16:58:13 +0200 Subject: [PATCH 2/4] Make the snowflake-next state dir writable by the emulator's non-root user Co-Authored-By: Claude --- internal/container/start.go | 40 ++++++++++++++-- internal/container/start_test.go | 46 +++++++++++++++++++ internal/emulator/snowflake/snowflake.go | 12 +++-- internal/endpoint/target.go | 14 ++++-- .../__snapshots__/endpoint_url_test.snap | 7 +++ test/integration/endpoint_url_test.go | 32 +++++++++++++ test/integration/snowflake_next_test.go | 12 +++++ 7 files changed, 150 insertions(+), 13 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index 744c6f34..5317ae60 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -265,9 +265,9 @@ func startOnce(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts S // cluster per start. It lives under the managed volume dir so that // `lstk volume path` points at it and `lstk volume clear` resets it. if c.Type == config.EmulatorSnowflakeNext { - stateDir := filepath.Join(volumeDir, "snowflake-rs") - if err := os.MkdirAll(stateDir, 0755); err != nil { - return "", fmt.Errorf("failed to create state directory %s: %w", stateDir, err) + stateDir, err := prepareNextStateDir(volumeDir, sink) + if err != nil { + return "", err } binds = append(binds, runtime.BindMount{HostPath: stateDir, ContainerPath: snowflake.NextStateDir}) } @@ -412,6 +412,40 @@ func emitAlreadyRunning(ctx context.Context, sink output.Sink, c runtime.Contain emitPostStartPointers(sink, c.EmulatorType, resolvedHost, webAppURL, persist) } +// prepareNextStateDir creates the host directory bound over the preview Snowflake +// emulator's declared VOLUME (snowflake.NextStateDir) and makes it writable by +// whichever user the emulator runs as. +// +// The wide mode is what --persist needs on native Linux Docker, where host uids +// are not remapped: the emulator runs as uid 1000 and has to create PGDATA inside +// this mount, so a directory owned by any other uid — every CI runner, most Linux +// desktops — makes PostgreSQL fail to initialize and the container exit during +// startup. lstk cannot chown to a uid it does not own, so widening the mode is the +// only fix available to it. Docker Desktop and the other VM-backed runtimes map +// ownership and never needed this, which is why the failure only ever showed up on +// Linux. MkdirAll's mode is masked by the process umask, so the mode is set +// explicitly afterwards rather than trusted to the create call — that also widens a +// directory an older lstk already created. +// +// A chmod that does not stick is not fatal: it is a no-op on Windows and fails on a +// directory another user owns, neither of which necessarily breaks the start. But +// the failure it would cause surfaces only as an opaque health-check timeout, so +// say what the emulator needs instead of leaving the user to debug PostgreSQL. +func prepareNextStateDir(volumeDir string, sink output.Sink) (string, error) { + stateDir := filepath.Join(volumeDir, "snowflake-rs") + if err := os.MkdirAll(stateDir, 0777); err != nil { + return "", fmt.Errorf("failed to create state directory %s: %w", stateDir, err) + } + if err := os.Chmod(stateDir, 0777); err != nil { + sink.Emit(output.MessageEvent{ + Severity: output.SeverityWarning, + Text: fmt.Sprintf("Could not make %s writable for the emulator: %v. The emulator runs as uid 1000 and writes its state there, "+ + "so with --persist it may fail to start until that directory is writable by it.", stateDir, err), + }) + } + return stateDir, nil +} + func isPersistenceEnabled(ctx context.Context, rt runtime.Runtime, containerName string) bool { env, err := rt.ContainerEnv(ctx, containerName) if err != nil { diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 0e508bfb..28b30090 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "os" "path/filepath" + goruntime "runtime" "strconv" "strings" "sync" @@ -1743,3 +1744,48 @@ func TestPromptRelogin_OffersAnAdvertisedDeclineKey(t *testing.T) { }) } } + +// TestPrepareNextStateDir_IsWritableByTheEmulatorUser pins the fix for the +// --persist failure on native Linux Docker: the preview Snowflake emulator runs +// as uid 1000 and creates PGDATA inside this bind-mounted directory, so a +// directory only its host owner can write makes PostgreSQL fail to initialize +// and the container exit during startup. It can only be reproduced end to end on +// Linux with a host uid other than 1000 (CI), so the permission itself is +// pinned here. +func TestPrepareNextStateDir_IsWritableByTheEmulatorUser(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("POSIX permission bits do not port to Windows") + } + var out bytes.Buffer + volumeDir := t.TempDir() + + stateDir, err := prepareNextStateDir(volumeDir, output.NewPlainSink(&out)) + require.NoError(t, err) + + info, err := os.Stat(stateDir) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0777), info.Mode().Perm(), + "the emulator's non-root user must be able to create PGDATA inside the mount") + assert.Empty(t, out.String(), "a successful prepare must not warn") +} + +// TestPrepareNextStateDir_WidensAnExistingDirectory covers the upgrade path: a +// state dir left behind by an older lstk (or by a umask that masked the create +// mode) is widened in place, so an existing install is not stuck with a start +// that keeps failing. +func TestPrepareNextStateDir_WidensAnExistingDirectory(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("POSIX permission bits do not port to Windows") + } + volumeDir := t.TempDir() + stateDir := filepath.Join(volumeDir, "snowflake-rs") + require.NoError(t, os.MkdirAll(stateDir, 0700)) + + got, err := prepareNextStateDir(volumeDir, output.NewPlainSink(io.Discard)) + require.NoError(t, err) + require.Equal(t, stateDir, got) + + info, err := os.Stat(stateDir) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0777), info.Mode().Perm()) +} diff --git a/internal/emulator/snowflake/snowflake.go b/internal/emulator/snowflake/snowflake.go index c175cb08..ed469905 100644 --- a/internal/emulator/snowflake/snowflake.go +++ b/internal/emulator/snowflake/snowflake.go @@ -34,11 +34,13 @@ func NextListenAddr(containerPort string) string { // PostgreSQL cluster's worth of data per start. Covering it also gives --persist // a place to keep the cluster without overriding PGDATA. // -// Caveat for --persist on Linux: the emulator runs as a non-root user (uid 1000), -// which has to create the PGDATA subdirectory inside this mount, so a host -// directory owned by a different uid makes PostgreSQL refuse to start. Docker -// Desktop maps ownership and is unaffected. The default (no --persist) path never -// writes here, so only --persist is exposed to it. +// The emulator runs as a non-root user (uid 1000) and has to create the PGDATA +// subdirectory inside this mount, so on native Linux Docker — where that uid is the +// container's, not the host user's — the host directory has to be writable by it, +// or PostgreSQL fails to initialize and the container exits during startup. lstk +// cannot chown to a uid it does not own, so the start path widens the directory's +// mode instead; see prepareNextStateDir in internal/container/start.go. Docker +// Desktop maps ownership and never needed either. const NextStateDir = "/var/lib/snowflake-rs" // NextEphemeralPGData is where the preview Snowflake emulator's PostgreSQL cluster diff --git a/internal/endpoint/target.go b/internal/endpoint/target.go index cb6a7c28..3db43a07 100644 --- a/internal/endpoint/target.go +++ b/internal/endpoint/target.go @@ -275,11 +275,15 @@ func swapScheme(endpointURL string) (string, bool) { // nothing on its own. Verified against localstack/snowflake:latest and a // community localstack image. // -// The preview Snowflake emulator (config.EmulatorSnowflakeNext) is not -// classifiable here: its health payload carries a version and no services map at -// all, so it lands in IndeterminateTypeError. Fixing that means adding the key on -// its side (LAV-1678) rather than guessing from an absent map here, which would -// misread every future emulator with a minimal payload as the preview. +// The preview Snowflake emulator (config.EmulatorSnowflakeNext) reports the same +// "snowflake" key and nothing else (localstack/snowflake-rs#2116, LAV-1678), so a +// remote preview resolves to EmulatorSnowflake. That collapse is deliberate: the +// two are indistinguishable from the payload, and every path a resolved Target +// reaches treats them identically (same emulator client, same side of every +// AWS-only and Azure-only branch), so the only visible difference is the GA +// display name in `lstk status`. A type is never inferred from what a payload +// lacks — reading an absent or AWS-key-free map as "the preview" would misread +// every future emulator with a minimal payload. func classifyByServices(services map[string]string) config.EmulatorType { if _, ok := services["snowflake"]; ok { return config.EmulatorSnowflake diff --git a/test/integration/__snapshots__/endpoint_url_test.snap b/test/integration/__snapshots__/endpoint_url_test.snap index c1b7cdfb..7cf8984c 100644 --- a/test/integration/__snapshots__/endpoint_url_test.snap +++ b/test/integration/__snapshots__/endpoint_url_test.snap @@ -67,6 +67,13 @@ Fetching LocalStack status... S3 my-test-bucket us-east-1 000000000000 --- +[TestStatusEndpointURLSnowflakePreviewPayload_1] +Fetching LocalStack status... +✔︎ LocalStack Snowflake Emulator is running +• Endpoint: http://127.0.0.1: +• Version: +--- + [TestStatusUnreachableEndpointURLFailsClosed_1] Error: could not reach LocalStack emulator at http://127.0.0.1:: unexpected status 404 from http://127.0.0.1:/_localstack/health --- diff --git a/test/integration/endpoint_url_test.go b/test/integration/endpoint_url_test.go index 57e36987..8867b5ae 100644 --- a/test/integration/endpoint_url_test.go +++ b/test/integration/endpoint_url_test.go @@ -402,3 +402,35 @@ func TestCDKAWSEndpointURLWrongTypeFails(t *testing.T) { require.Error(t, err) snap.Match(t, sanitizeOutput(stdout)) } + +// TestStatusEndpointURLSnowflakePreviewPayload pins that `status` works against +// a remotely-hosted preview Snowflake emulator, whose health payload carries a +// version and a services map holding the single "snowflake" key and no AWS keys +// (localstack/snowflake-rs#2116). Before that key existed the payload could not +// be classified and every --endpoint-url command against the preview hard-failed +// as indeterminate. lstk collapses it onto the GA snowflake type deliberately — +// the two are indistinguishable from the payload and every remote path treats +// them identically — so the card reports the GA display name with the preview's +// own version. +func TestStatusEndpointURLSnowflakePreviewPayload(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/_localstack/health" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "version": "0.1.0+bfb557c", + "services": map[string]string{"snowflake": "available"}, + }) + })) + defer srv.Close() + + e := env.With(env.DisableEvents, "1").WithHome(t.TempDir()) + e = append(e, unreachableDockerHost) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "--endpoint-url", srv.URL, "status") + require.NoError(t, err, "stderr: %s", stderr) + snap.Match(t, sanitizeOutput(stdout)) +} diff --git a/test/integration/snowflake_next_test.go b/test/integration/snowflake_next_test.go index 23cffec4..a89fe184 100644 --- a/test/integration/snowflake_next_test.go +++ b/test/integration/snowflake_next_test.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path/filepath" + goruntime "runtime" "strings" "testing" "time" @@ -201,6 +202,17 @@ func assertStateDirMounted(t *testing.T, mounts []container.MountPoint) { "/var/lib/snowflake-rs must be a bind mount, not an anonymous volume") assert.True(t, strings.HasSuffix(filepath.ToSlash(m.Source), "/snowflake-rs"), "expected the managed volume subdirectory, got %s", m.Source) + if goruntime.GOOS != "windows" { + // The emulator runs as uid 1000 and creates PGDATA inside this + // mount. On native Linux Docker that uid is the container's, not + // the host user's, so a directory only its owner can write makes + // PostgreSQL fail to initialize and the container exit — the mode + // is the only part of that lstk can control. + info, err := os.Stat(m.Source) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0777), info.Mode().Perm(), + "the mounted state dir must be writable by the emulator's non-root user") + } return } } From 6308d284d038155944dcf84aed1b69182a2b2f3b Mon Sep 17 00:00:00 2001 From: Przemek Denkiewicz Date: Tue, 18 Aug 2026 18:21:57 +0200 Subject: [PATCH 3/4] Handle emulator-owned files in volume clear and snowflake-next test cleanup Co-Authored-By: Claude --- internal/volume/clear.go | 21 ++++++++++++- test/integration/snowflake_next_test.go | 22 +++++++++++++ test/integration/volume_test.go | 41 +++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/internal/volume/clear.go b/internal/volume/clear.go index 550fb2c9..8271f4d6 100644 --- a/internal/volume/clear.go +++ b/internal/volume/clear.go @@ -72,7 +72,7 @@ func clearDir(dir string) error { for _, entry := range entries { if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil { if os.IsPermission(err) { - return fmt.Errorf("%w — some files are owned by root (created by Docker); try: sudo lstk volume clear", err) + return fmt.Errorf("%w — some files were created by the emulator and belong to another user; try: sudo lstk volume clear", err) } return err } @@ -80,15 +80,34 @@ func clearDir(dir string) error { return nil } +// dirSize sums the volume's files for the "here is what will be deleted" listing. +// +// A subtree the caller cannot read is skipped rather than failing the walk: the +// emulators write into the volume as their own container user, and the preview +// Snowflake emulator's PostgreSQL cluster in particular is a 0700 directory owned +// by uid 1000, which the user running lstk cannot traverse. Failing here aborted +// `volume clear` before it printed anything or reached the removal that tells the +// user what to do about exactly those files. The reported size is therefore a +// lower bound whenever the volume holds such a directory — better than refusing to +// run over a number the command only uses to describe what it is about to remove. func dirSize(path string) (int64, error) { var size int64 err := filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error { if err != nil { + if os.IsPermission(err) { + if d != nil && d.IsDir() { + return fs.SkipDir + } + return nil + } return err } if !d.IsDir() { info, err := d.Info() if err != nil { + if os.IsPermission(err) { + return nil + } return err } size += info.Size() diff --git a/test/integration/snowflake_next_test.go b/test/integration/snowflake_next_test.go index a89fe184..57302594 100644 --- a/test/integration/snowflake_next_test.go +++ b/test/integration/snowflake_next_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "os" + "os/exec" "path/filepath" goruntime "runtime" "strings" @@ -181,6 +182,7 @@ func TestStartSnowflakeNextPersistsStateIntoVolumeWithPersist(t *testing.T) { ctx := testContext(t) _, stderr, err := runLstk(t, ctx, "", env.Environ(testEnvWithHome(t.TempDir(), "")), "--config", configFile, "start", "--persist") require.NoError(t, err, "lstk start failed: %s", stderr) + removePersistedNextState(t) inspect, err := dockerClient.ContainerInspect(ctx, snowflakeNextContainerName, client.ContainerInspectOptions{}) require.NoError(t, err) @@ -192,6 +194,26 @@ func TestStartSnowflakeNextPersistsStateIntoVolumeWithPersist(t *testing.T) { assertStateDirMounted(t, inspect.Container.Mounts) } +// removePersistedNextState deletes the emulator's PostgreSQL cluster from inside +// the container, as root, once the test is done with it. PostgreSQL creates PGDATA +// as the emulator's own uid 1000 with mode 0700, so on Linux — where that uid is +// not the test process's — nothing running on the host can descend into it, and +// t.TempDir's own cleanup of the temporary HOME fails with "permission denied". +// Cleanups run last-registered-first, so calling this after the start puts the +// removal ahead of both the container removal and the temporary HOME's. +func removePersistedNextState(t *testing.T) { + t.Helper() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "docker", "exec", "--user", "0", + snowflakeNextContainerName, "sh", "-c", "rm -rf /var/lib/snowflake-rs/*").CombinedOutput() + if err != nil { + t.Logf("could not remove the emulator's persisted state: %v: %s", err, out) + } + }) +} + // assertStateDirMounted checks a host directory is bound over the image's declared // VOLUME, which is what keeps Docker from creating an anonymous volume per start. func assertStateDirMounted(t *testing.T, mounts []container.MountPoint) { diff --git a/test/integration/volume_test.go b/test/integration/volume_test.go index 7b6abd4d..bac17991 100644 --- a/test/integration/volume_test.go +++ b/test/integration/volume_test.go @@ -226,6 +226,47 @@ volume = "` + escapeTomlPath(volumeDir) + `" assertCommandTelemetry(t, events, "volume clear", 0) }) + t.Run("suggests sudo when the emulator's own state directory is unreadable", func(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX permission bits do not port to Windows") + } + if os.Getuid() == 0 { + t.Skip("test requires non-root user") + } + + // What the preview Snowflake emulator leaves behind under --persist: its + // PostgreSQL cluster, created by the emulator's own uid with mode 0700, so + // the user running lstk can neither read nor traverse it. chmod 000 + // reproduces that without needing a second uid. Unlike root-owned *files*, + // an unreadable *directory* also blocks measuring the volume, which used to + // abort the command before it reported anything actionable. + volumeDir := t.TempDir() + stateDir := filepath.Join(volumeDir, "snowflake-rs", "data") + require.NoError(t, os.MkdirAll(stateDir, 0700)) + require.NoError(t, os.WriteFile(filepath.Join(stateDir, "PG_VERSION"), []byte("16\n"), 0600)) + require.NoError(t, os.Chmod(stateDir, 0)) + t.Cleanup(func() { _ = os.Chmod(stateDir, 0700) }) + + configContent := ` +[[containers]] +type = "snowflake-next" +tag = "latest" +port = "4566" +volume = "` + escapeTomlPath(volumeDir) + `" +` + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), testEnvWithHome(t.TempDir(), ""), "--config", configFile, "--non-interactive", "volume", "clear", "--force") + require.Error(t, err) + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "sudo", + "the failure must tell the user how to remove files the emulator owns") + assert.NotContains(t, stderr, "failed to read volume directory", + "an unreadable subdirectory must not abort the command before it tries to clear") + }) + t.Run("suggests sudo when volume contains root-owned files", func(t *testing.T) { t.Parallel() if runtime.GOOS != "linux" { From f8915994f6aa6cf2c2076d310ae4df9aca5620ba Mon Sep 17 00:00:00 2001 From: Przemek Denkiewicz Date: Tue, 25 Aug 2026 15:52:58 +0200 Subject: [PATCH 4/4] Drop snowflake-next adaptation code now that the image is a drop-in localstack/snowflake-rs#2245 makes localstack/snowflake-next a container-level drop-in for localstack/snowflake: it binds from GATEWAY_LISTEN, declares /var/lib/localstack as its volume, picks its data dir from LOCALSTACK_PERSISTENCE, and chowns a bind-mounted state dir before dropping privileges. lstk's generic start path already covers all of that, so the per-emulator branches, the Next* constants, and the tests pinning them are redundant. The type stays a plain registry entry. Verified against the published image (revision 994f10d): start answers health on the configured port, --persist survives a container recreation, and volume clear still works. Co-Authored-By: Claude --- CLAUDE.md | 2 +- internal/container/start.go | 61 ------------ internal/container/start_test.go | 46 --------- internal/emulator/snowflake/snowflake.go | 35 ------- test/integration/snowflake_next_test.go | 117 +---------------------- 5 files changed, 6 insertions(+), 255 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8d2be994..918e74c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,7 @@ Each `[[containers]]` block may set an optional `container_name` (override the d 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. -Unlike the other emulators, `snowflake-next` does not read `GATEWAY_LISTEN` and ships its own declared VOLUME, so the start path adapts it: `SNOWFLAKE_LISTEN_ADDR` moves its listener onto the gateway port, and a bind mount covers its volume path (otherwise every start strands a PostgreSQL cluster in an anonymous volume, because lstk recreates the container and `docker rm` keeps anonymous volumes). Its cluster is always written to disk, so `--persist` is expressed as *where* `PGDATA` points rather than an on/off switch — details on `snowflake.NextStateDir` and `snowflake.NextEphemeralPGData`. +`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`. diff --git a/internal/container/start.go b/internal/container/start.go index 5317ae60..236194c6 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -223,21 +223,6 @@ func startOnce(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts S env = append(env, "SF_S3_ENDPOINT="+snowflake.S3Endpoint(c.Port)) } - // The preview Snowflake emulator listens on 8080 by default and ignores - // GATEWAY_LISTEN, so its own listen variable is what has to move it onto the - // gateway port every other part of lstk assumes. Its PostgreSQL cluster is - // always written to disk (there is no in-memory mode), so persistence is a - // matter of where PGDATA points: inside the bound state dir, or in the - // container's writable layer when the user did not ask to persist. - if c.Type == config.EmulatorSnowflakeNext { - if !envHasKey(resolvedEnv, "SNOWFLAKE_LISTEN_ADDR") { - env = append(env, "SNOWFLAKE_LISTEN_ADDR="+snowflake.NextListenAddr(config.DefaultPort)) - } - if !opts.Persist && !envHasKey(resolvedEnv, "PGDATA") { - env = append(env, "PGDATA="+snowflake.NextEphemeralPGData) - } - } - env = append(env, hostEnv...) env = append(env, agentEnvVars...) @@ -260,18 +245,6 @@ func startOnce(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts S } binds = append(binds, runtime.BindMount{HostPath: volumeDir, ContainerPath: "/var/lib/localstack"}) - // Cover the preview Snowflake emulator's own declared VOLUME — see - // snowflake.NextStateDir for why leaving it uncovered leaks a PostgreSQL - // cluster per start. It lives under the managed volume dir so that - // `lstk volume path` points at it and `lstk volume clear` resets it. - if c.Type == config.EmulatorSnowflakeNext { - stateDir, err := prepareNextStateDir(volumeDir, sink) - if err != nil { - return "", err - } - binds = append(binds, runtime.BindMount{HostPath: stateDir, ContainerPath: snowflake.NextStateDir}) - } - // Extra user-defined mounts (e.g. Snowflake init hooks). Unlike the persistence // directory, these are not created — init-hook entries are files, so the source // must already exist; creating it would produce a wrong empty directory. @@ -412,40 +385,6 @@ func emitAlreadyRunning(ctx context.Context, sink output.Sink, c runtime.Contain emitPostStartPointers(sink, c.EmulatorType, resolvedHost, webAppURL, persist) } -// prepareNextStateDir creates the host directory bound over the preview Snowflake -// emulator's declared VOLUME (snowflake.NextStateDir) and makes it writable by -// whichever user the emulator runs as. -// -// The wide mode is what --persist needs on native Linux Docker, where host uids -// are not remapped: the emulator runs as uid 1000 and has to create PGDATA inside -// this mount, so a directory owned by any other uid — every CI runner, most Linux -// desktops — makes PostgreSQL fail to initialize and the container exit during -// startup. lstk cannot chown to a uid it does not own, so widening the mode is the -// only fix available to it. Docker Desktop and the other VM-backed runtimes map -// ownership and never needed this, which is why the failure only ever showed up on -// Linux. MkdirAll's mode is masked by the process umask, so the mode is set -// explicitly afterwards rather than trusted to the create call — that also widens a -// directory an older lstk already created. -// -// A chmod that does not stick is not fatal: it is a no-op on Windows and fails on a -// directory another user owns, neither of which necessarily breaks the start. But -// the failure it would cause surfaces only as an opaque health-check timeout, so -// say what the emulator needs instead of leaving the user to debug PostgreSQL. -func prepareNextStateDir(volumeDir string, sink output.Sink) (string, error) { - stateDir := filepath.Join(volumeDir, "snowflake-rs") - if err := os.MkdirAll(stateDir, 0777); err != nil { - return "", fmt.Errorf("failed to create state directory %s: %w", stateDir, err) - } - if err := os.Chmod(stateDir, 0777); err != nil { - sink.Emit(output.MessageEvent{ - Severity: output.SeverityWarning, - Text: fmt.Sprintf("Could not make %s writable for the emulator: %v. The emulator runs as uid 1000 and writes its state there, "+ - "so with --persist it may fail to start until that directory is writable by it.", stateDir, err), - }) - } - return stateDir, nil -} - func isPersistenceEnabled(ctx context.Context, rt runtime.Runtime, containerName string) bool { env, err := rt.ContainerEnv(ctx, containerName) if err != nil { diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 28b30090..0e508bfb 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -11,7 +11,6 @@ import ( "net/http/httptest" "os" "path/filepath" - goruntime "runtime" "strconv" "strings" "sync" @@ -1744,48 +1743,3 @@ func TestPromptRelogin_OffersAnAdvertisedDeclineKey(t *testing.T) { }) } } - -// TestPrepareNextStateDir_IsWritableByTheEmulatorUser pins the fix for the -// --persist failure on native Linux Docker: the preview Snowflake emulator runs -// as uid 1000 and creates PGDATA inside this bind-mounted directory, so a -// directory only its host owner can write makes PostgreSQL fail to initialize -// and the container exit during startup. It can only be reproduced end to end on -// Linux with a host uid other than 1000 (CI), so the permission itself is -// pinned here. -func TestPrepareNextStateDir_IsWritableByTheEmulatorUser(t *testing.T) { - if goruntime.GOOS == "windows" { - t.Skip("POSIX permission bits do not port to Windows") - } - var out bytes.Buffer - volumeDir := t.TempDir() - - stateDir, err := prepareNextStateDir(volumeDir, output.NewPlainSink(&out)) - require.NoError(t, err) - - info, err := os.Stat(stateDir) - require.NoError(t, err) - assert.Equal(t, os.FileMode(0777), info.Mode().Perm(), - "the emulator's non-root user must be able to create PGDATA inside the mount") - assert.Empty(t, out.String(), "a successful prepare must not warn") -} - -// TestPrepareNextStateDir_WidensAnExistingDirectory covers the upgrade path: a -// state dir left behind by an older lstk (or by a umask that masked the create -// mode) is widened in place, so an existing install is not stuck with a start -// that keeps failing. -func TestPrepareNextStateDir_WidensAnExistingDirectory(t *testing.T) { - if goruntime.GOOS == "windows" { - t.Skip("POSIX permission bits do not port to Windows") - } - volumeDir := t.TempDir() - stateDir := filepath.Join(volumeDir, "snowflake-rs") - require.NoError(t, os.MkdirAll(stateDir, 0700)) - - got, err := prepareNextStateDir(volumeDir, output.NewPlainSink(io.Discard)) - require.NoError(t, err) - require.Equal(t, stateDir, got) - - info, err := os.Stat(stateDir) - require.NoError(t, err) - assert.Equal(t, os.FileMode(0777), info.Mode().Perm()) -} diff --git a/internal/emulator/snowflake/snowflake.go b/internal/emulator/snowflake/snowflake.go index ed469905..e729a810 100644 --- a/internal/emulator/snowflake/snowflake.go +++ b/internal/emulator/snowflake/snowflake.go @@ -15,41 +15,6 @@ func S3Endpoint(port string) string { return "s3." + endpoint.Hostname + ":" + port } -// NextListenAddr returns the value for the preview Snowflake emulator's -// SNOWFLAKE_LISTEN_ADDR variable, given the port it should serve inside the -// container. The image itself defaults to 8080, but lstk publishes, health-checks -// and advertises every emulator on the LocalStack gateway port, so the listener is -// moved there rather than teaching the rest of lstk a second container port. -func NextListenAddr(containerPort string) string { - return "0.0.0.0:" + containerPort -} - -// NextStateDir is the container path the preview Snowflake emulator declares as a -// VOLUME and puts its embedded PostgreSQL cluster in (the image's PGDATA default -// is the "data" subdirectory of it). -// -// lstk binds a directory over it on every start. That is not about persistence: -// lstk recreates the container each start and `docker rm` leaves an anonymous -// volume behind, so leaving the declaration uncovered would strand a whole -// PostgreSQL cluster's worth of data per start. Covering it also gives --persist -// a place to keep the cluster without overriding PGDATA. -// -// The emulator runs as a non-root user (uid 1000) and has to create the PGDATA -// subdirectory inside this mount, so on native Linux Docker — where that uid is the -// container's, not the host user's — the host directory has to be writable by it, -// or PostgreSQL fails to initialize and the container exits during startup. lstk -// cannot chown to a uid it does not own, so the start path widens the directory's -// mode instead; see prepareNextStateDir in internal/container/start.go. Docker -// Desktop maps ownership and never needed either. -const NextStateDir = "/var/lib/snowflake-rs" - -// NextEphemeralPGData is where the preview Snowflake emulator's PostgreSQL cluster -// goes when persistence is off. It sits outside NextStateDir, in the container's -// writable layer, so the cluster is discarded with the container — matching what a -// user gets from the other emulators without --persist. The emulator already uses -// /tmp for stages and its TLS cache, so the path is writable by its non-root user. -const NextEphemeralPGData = "/tmp/snowflake-rs/data" - func Hostname(resolvedHost string) string { host, _, err := net.SplitHostPort(resolvedHost) if err != nil { diff --git a/test/integration/snowflake_next_test.go b/test/integration/snowflake_next_test.go index 57302594..ab430c03 100644 --- a/test/integration/snowflake_next_test.go +++ b/test/integration/snowflake_next_test.go @@ -5,15 +5,11 @@ import ( "fmt" "net/http" "os" - "os/exec" "path/filepath" - goruntime "runtime" - "strings" "testing" "time" "github.com/localstack/lstk/test/integration/env" - "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -100,10 +96,11 @@ func TestFirstRunPickerOmitsSnowflakeNext(t *testing.T) { p.kill() } -// TestStartSnowflakeNextServesGatewayOnConfiguredPort is the end-to-end proof of -// the port remap: the image listens on 8080 and ignores GATEWAY_LISTEN, so -// without lstk rewriting its own listen variable nothing answers on the port -// lstk publishes, health-checks and advertises. +// TestStartSnowflakeNextServesGatewayOnConfiguredPort is the end-to-end proof +// that the preview type needs no per-emulator adaptation: the image binds from +// GATEWAY_LISTEN like every other emulator, so the generic start path alone has +// to produce something answering the health contract on the configured host +// port. It is what would fail first if the image stopped being a drop-in. func TestStartSnowflakeNextServesGatewayOnConfiguredPort(t *testing.T) { requireDocker(t) _ = env.Require(t, env.AuthToken) @@ -137,110 +134,6 @@ func TestStartSnowflakeNextServesGatewayOnConfiguredPort(t *testing.T) { "the preview emulator should print the snowflake-prefixed endpoint hint") } -// TestStartSnowflakeNextKeepsStateOutOfVolumeWithoutPersist and its --persist -// sibling pin where the emulator's PostgreSQL cluster is written. It always -// writes to disk (there is no in-memory mode), so persistence is decided purely -// by which path PGDATA names, and the mount over the image's own declared VOLUME -// must be present either way — an uncovered declaration strands a whole cluster -// in an anonymous volume on every start, since lstk recreates the container. -func TestStartSnowflakeNextKeepsStateOutOfVolumeWithoutPersist(t *testing.T) { - requireDocker(t) - _ = env.Require(t, env.AuthToken) - - cleanup() - cleanupSnowflakeNext() - t.Cleanup(cleanup) - t.Cleanup(cleanupSnowflakeNext) - - configFile := writeSnowflakeNextConfig(t, "4578") - - ctx := testContext(t) - _, stderr, err := runLstk(t, ctx, "", env.Environ(testEnvWithHome(t.TempDir(), "")), "--config", configFile, "start") - require.NoError(t, err, "lstk start failed: %s", stderr) - - inspect, err := dockerClient.ContainerInspect(ctx, snowflakeNextContainerName, client.ContainerInspectOptions{}) - require.NoError(t, err) - envVars := containerEnvToMap(inspect.Container.Config.Env) - assert.Equal(t, "0.0.0.0:4566", envVars["SNOWFLAKE_LISTEN_ADDR"], - "the listener must be moved onto the gateway port lstk publishes") - assert.Equal(t, "/tmp/snowflake-rs/data", envVars["PGDATA"], - "without --persist the cluster must live in the container, not the mounted volume") - assertStateDirMounted(t, inspect.Container.Mounts) -} - -func TestStartSnowflakeNextPersistsStateIntoVolumeWithPersist(t *testing.T) { - requireDocker(t) - _ = env.Require(t, env.AuthToken) - - cleanup() - cleanupSnowflakeNext() - t.Cleanup(cleanup) - t.Cleanup(cleanupSnowflakeNext) - - configFile := writeSnowflakeNextConfig(t, "4579") - - ctx := testContext(t) - _, stderr, err := runLstk(t, ctx, "", env.Environ(testEnvWithHome(t.TempDir(), "")), "--config", configFile, "start", "--persist") - require.NoError(t, err, "lstk start failed: %s", stderr) - removePersistedNextState(t) - - inspect, err := dockerClient.ContainerInspect(ctx, snowflakeNextContainerName, client.ContainerInspectOptions{}) - require.NoError(t, err) - envVars := containerEnvToMap(inspect.Container.Config.Env) - // The image sets PGDATA itself; --persist means lstk leaves that default - // alone, so the cluster is written into the mounted state dir. - assert.Equal(t, "/var/lib/snowflake-rs/data", envVars["PGDATA"], - "with --persist the cluster must land in the mounted state dir") - assertStateDirMounted(t, inspect.Container.Mounts) -} - -// removePersistedNextState deletes the emulator's PostgreSQL cluster from inside -// the container, as root, once the test is done with it. PostgreSQL creates PGDATA -// as the emulator's own uid 1000 with mode 0700, so on Linux — where that uid is -// not the test process's — nothing running on the host can descend into it, and -// t.TempDir's own cleanup of the temporary HOME fails with "permission denied". -// Cleanups run last-registered-first, so calling this after the start puts the -// removal ahead of both the container removal and the temporary HOME's. -func removePersistedNextState(t *testing.T) { - t.Helper() - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - out, err := exec.CommandContext(ctx, "docker", "exec", "--user", "0", - snowflakeNextContainerName, "sh", "-c", "rm -rf /var/lib/snowflake-rs/*").CombinedOutput() - if err != nil { - t.Logf("could not remove the emulator's persisted state: %v: %s", err, out) - } - }) -} - -// assertStateDirMounted checks a host directory is bound over the image's declared -// VOLUME, which is what keeps Docker from creating an anonymous volume per start. -func assertStateDirMounted(t *testing.T, mounts []container.MountPoint) { - t.Helper() - for _, m := range mounts { - if m.Destination == "/var/lib/snowflake-rs" { - assert.Equal(t, "bind", string(m.Type), - "/var/lib/snowflake-rs must be a bind mount, not an anonymous volume") - assert.True(t, strings.HasSuffix(filepath.ToSlash(m.Source), "/snowflake-rs"), - "expected the managed volume subdirectory, got %s", m.Source) - if goruntime.GOOS != "windows" { - // The emulator runs as uid 1000 and creates PGDATA inside this - // mount. On native Linux Docker that uid is the container's, not - // the host user's, so a directory only its owner can write makes - // PostgreSQL fail to initialize and the container exit — the mode - // is the only part of that lstk can control. - info, err := os.Stat(m.Source) - require.NoError(t, err) - assert.Equal(t, os.FileMode(0777), info.Mode().Perm(), - "the mounted state dir must be writable by the emulator's non-root user") - } - return - } - } - t.Errorf("no mount covers /var/lib/snowflake-rs; got %+v", mounts) -} - // TestTerraformRejectsRunningSnowflakeNext covers the discovery side of the // preview type: the IaC proxies support only the AWS emulator, and they name the // emulator that is actually running so the error is not a misleading "AWS not