diff --git a/CLAUDE.md b/CLAUDE.md index 0d416630..918e74c1 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. + +`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/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..236194c6 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -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)}) @@ -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", 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/endpoint/target.go b/internal/endpoint/target.go index 39775784..3db43a07 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,21 @@ 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) 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/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/__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__/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/__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/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 new file mode 100644 index 00000000..ab430c03 --- /dev/null +++ b/test/integration/snowflake_next_test.go @@ -0,0 +1,165 @@ +package integration_test + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/localstack/lstk/test/integration/env" + "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 +// 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) + + 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") +} + +// 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") +} 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" {