diff --git a/CLAUDE.md b/CLAUDE.md index f5af977d..4db2cc82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,10 @@ Only one `[[containers]]` block may be enabled at a time. `container.Start` reje Each `[[containers]]` block may set an optional `image` to override the default Docker Hub image (e.g. an internal registry mirror or a locally loaded offline image). `ContainerConfig.Image()` returns `image` as-is when it already carries a tag (so the separately-configured `tag` is dropped in that case), otherwise it appends `tag` (or `latest`); the default `localstack/:` is used when `image` is unset. +## 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 `port`/`env`/`snapshot` are kept silently. 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. + ## GATEWAY_LISTEN and host exposure `GATEWAY_LISTEN` is not hardcoded — it is read from the container's resolved env (set it via an `[env.*]` profile referenced by the container's `env` field). When unset it defaults to `:4566,:443`. Parsing/derivation lives in `internal/container/gateway.go` (`parseGatewayListen`), mirroring the v1 CLI: diff --git a/README.md b/README.md index 83bbae00..556cca05 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,14 @@ type = "azure" # or "snowflake" port = "4566" ``` +For CI or other non-interactive use, pass `--type` (shorthand `-t`) to select the emulator without editing config. It records the selection in your config file — creating it on first run, or switching the `type` in place when it differs — so later commands (`stop`, `status`, `logs`, `volume`, …) resolve the same emulator: + +```bash +LOCALSTACK_AUTH_TOKEN= lstk start --type azure --non-interactive +``` + +Switching an existing config keeps the other block fields; a custom `image` blocks the switch (it pins a specific product — use a separate `--config` file instead). + The chosen emulator must be running before you set up or use its CLI integration below. > [!NOTE] @@ -318,6 +326,9 @@ lstk # Start non-interactively (e.g. in CI) LOCALSTACK_AUTH_TOKEN= lstk --non-interactive +# Select the emulator non-interactively (records it in config) +LOCALSTACK_AUTH_TOKEN= lstk start --type snowflake --non-interactive + # Stop the running emulator lstk stop diff --git a/cmd/proxy.go b/cmd/proxy.go index 085ab849..f63b96c5 100644 --- a/cmd/proxy.go +++ b/cmd/proxy.go @@ -18,6 +18,10 @@ type globalFlags struct { // unknown) and their effect silently lost. Both --flag value and --flag=value // forms are recognized, in any position. // +// --config is stripped only in its long form: it has no -c shorthand, and a +// short -c must pass through untouched because wrapped tools claim it (CDK's +// -c/--context, SAM's -c/--cached), so stripping it would break those commands. +// // --json is deliberately NOT recognized here: unlike --non-interactive/--config, // which configure lstk's own wrapping mechanics, --json is purely about lstk's // own output rendering, which proxy commands don't have — their output is the diff --git a/cmd/proxy_test.go b/cmd/proxy_test.go index 376eed24..0af854f0 100644 --- a/cmd/proxy_test.go +++ b/cmd/proxy_test.go @@ -94,6 +94,18 @@ func TestStripGlobalFlags(t *testing.T) { args: []string{"s3", "ls", "--non-interactive-mode", "--config-file", "x"}, wantArgs: []string{"s3", "ls", "--non-interactive-mode", "--config-file", "x"}, }, + { + // --config has no -c shorthand: -c must pass through so wrapped tools + // that claim it keep working (CDK's -c/--context, SAM's -c/--cached). + name: "-c passes through to the wrapped tool", + args: []string{"synth", "-c", "env=prod"}, + wantArgs: []string{"synth", "-c", "env=prod"}, + }, + { + name: "-c=value passes through to the wrapped tool", + args: []string{"synth", "-c=env=prod"}, + wantArgs: []string{"synth", "-c=env=prod"}, + }, } for _, tt := range tests { diff --git a/cmd/root.go b/cmd/root.go index 1ee95575..04824abc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -77,7 +77,13 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C if err != nil { return err } - return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot) + // A non-empty first positional was already routed to extension dispatch + // above, so the emulator is selected only via the --type flag here. + emulatorType, err := resolveEmulatorTypeFlag(cmd) + if err != nil { + return err + } + return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot, emulatorType) }, } @@ -89,6 +95,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C root.PersistentFlags().BoolVar(&cfg.NonInteractive, "non-interactive", false, "Disable interactive mode") root.PersistentFlags().BoolVar(&cfg.JSON, "json", false, "Output in JSON format (only supported by some commands)") root.Flags().Bool("persist", false, "Persist emulator state across restarts") + addEmulatorTypeFlag(root) addSnapshotStartFlags(root) // Parse lstk's global flags only when they precede the command name: with @@ -256,12 +263,32 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger } } -func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool, firstRun bool, snapshotFlag string, noSnapshot bool) error { +func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool, firstRun bool, snapshotFlag string, noSnapshot bool, emulatorType config.EmulatorType) error { appConfig, err := config.Get() if err != nil { return fmt.Errorf("failed to get config: %w", err) } + configPath, err := config.FriendlyConfigPath() + if err != nil { + logger.Info("could not resolve friendly config path: %v", err) + } + + // Apply the --type flag before resolving snapshot and start options so + // everything downstream reflects the selected emulator. Messages go to a plain + // sink even in interactive mode because the config mutation has to happen before + // the TUI starts (the auto-load loader and start options are built from it). + if emulatorType != "" { + newContainers, applyErr := container.ApplyEmulatorType(output.NewPlainSink(os.Stdout), emulatorType, appConfig.Containers, firstRun, configPath) + if applyErr != nil { + return applyErr + } + appConfig.Containers = newContainers + // The config now exists and records the selection, so it is no longer a + // first run: skip the interactive picker and the default-emulator notice. + firstRun = false + } + ref, err := resolveStartSnapshotRef(appConfig, snapshotFlag, noSnapshot) if err != nil { return err @@ -281,11 +308,6 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t PersistSkipVersion: config.SetUpdateSkippedVersion, } - configPath, err := config.FriendlyConfigPath() - if err != nil { - logger.Info("could not resolve friendly config path: %v", err) - } - if isInteractiveMode(cfg) { return ui.Run(ctx, ui.RunOptions{ Runtime: rt, @@ -326,6 +348,24 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t return nil } +// 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)") +} + +// resolveEmulatorTypeFlag resolves the requested emulator type from the --type +// flag. It returns "" when the flag is unset. +func resolveEmulatorTypeFlag(cmd *cobra.Command) (config.EmulatorType, error) { + flagVal, err := cmd.Flags().GetString("type") + if err != nil { + return "", err + } + if flagVal == "" { + return "", nil + } + return config.ParseEmulatorType(flagVal) +} + // walkCommandsWithRunE walks the Cobra command tree rooted at cmd, calling wrap // on every command that has a RunE so callers can layer cross-cutting behavior // (telemetry, JSON gating, tracing) onto it. diff --git a/cmd/start.go b/cmd/start.go index aa60bb4a..8271eeb1 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -1,6 +1,8 @@ package cmd import ( + "fmt" + "github.com/localstack/lstk/internal/env" "github.com/localstack/lstk/internal/log" "github.com/localstack/lstk/internal/runtime" @@ -17,7 +19,15 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra. Host environment variables prefixed with LOCALSTACK_ are forwarded to the emulator. +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. + 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 { + return fmt.Errorf("unexpected argument %q; select the emulator with --type (e.g. lstk start --type %s)", args[0], args[0]) + } + return nil + }, PreRunE: initConfigDeferCreate(&firstRun), RunE: func(c *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) @@ -32,10 +42,15 @@ If a snapshot is configured for the AWS emulator (the snapshot field in [[contai if err != nil { return err } - return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot) + emulatorType, err := resolveEmulatorTypeFlag(c) + if err != nil { + return err + } + return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot, emulatorType) }, } cmd.Flags().Bool("persist", false, "Persist emulator state across restarts") + addEmulatorTypeFlag(cmd) addSnapshotStartFlags(cmd) return cmd } diff --git a/internal/config/emulator_type.go b/internal/config/emulator_type.go index 8383aa68..ff5af81c 100644 --- a/internal/config/emulator_type.go +++ b/internal/config/emulator_type.go @@ -4,9 +4,39 @@ import ( "fmt" "os" "regexp" + "strings" ) -var typeLineRe = regexp.MustCompile(`type\s*=\s*["'](\w+)["']`) +// typeLineRe matches a `type = "value"` assignment anchored at the start of a +// line (leading indentation allowed). Anchoring means commented lines (which +// start with '#') and unrelated keys such as `content_type = "..."` never match, +// and only the value is captured (group 1) so a rewrite can splice it in place +// without disturbing quotes, spacing, or trailing comments. +var typeLineRe = regexp.MustCompile(`(?m)^[ \t]*type[ \t]*=[ \t]*["'](\w+)["']`) + +// containersHeaderRe matches the `[[containers]]` array-of-tables header, and +// tableHeaderRe matches any TOML table header. Anchoring at line start skips +// commented-out (`#`-prefixed) headers. Together they bound the type search to +// the active block so a `type` key in any other table is never rewritten. +var ( + containersHeaderRe = regexp.MustCompile(`(?m)^[ \t]*\[\[containers\]\]`) + tableHeaderRe = regexp.MustCompile(`(?m)^[ \t]*\[`) +) + +// ParseEmulatorType validates a raw emulator type string against the selectable +// types and returns the corresponding EmulatorType. +func ParseEmulatorType(s string) (EmulatorType, error) { + for _, t := range SelectableEmulatorTypes { + if string(t) == s { + return t, nil + } + } + valid := make([]string, len(SelectableEmulatorTypes)) + for i, t := range SelectableEmulatorTypes { + valid[i] = string(t) + } + return "", fmt.Errorf("invalid emulator type %q (must be one of: %s)", s, strings.Join(valid, ", ")) +} // SetEmulatorType rewrites the emulator type in the config file and reloads. // No-op if the requested type is already set. @@ -19,15 +49,34 @@ func SetEmulatorType(to EmulatorType) error { if err != nil { return fmt.Errorf("failed to read config file: %w", err) } - m := typeLineRe.FindStringSubmatch(string(data)) - if m == nil { + // The type key belongs to the active [[containers]] block, so scope the + // search to that block — from just after its header to the next table header + // (or EOF). This guarantees a `type` key in any other table (e.g. an [env.*] + // profile) is never mistaken for the emulator type. Only the captured value + // is replaced, leaving commented-out example blocks and the original + // formatting untouched. + header := containersHeaderRe.FindIndex(data) + if header == nil { + return fmt.Errorf("no [[containers]] block found in config") + } + blockStart := header[1] + block := data[blockStart:] + if next := tableHeaderRe.FindIndex(block); next != nil { + block = block[:next[0]] + } + loc := typeLineRe.FindSubmatchIndex(block) + if loc == nil { return fmt.Errorf("no emulator type field found in config") } - if EmulatorType(m[1]) == to { + valueStart, valueEnd := blockStart+loc[2], blockStart+loc[3] + if EmulatorType(data[valueStart:valueEnd]) == to { return nil } - updated := typeLineRe.ReplaceAllString(string(data), `type = "`+string(to)+`"`) - if err := os.WriteFile(path, []byte(updated), 0644); err != nil { + updated := make([]byte, 0, len(data)-(valueEnd-valueStart)+len(to)) + updated = append(updated, data[:valueStart]...) + updated = append(updated, string(to)...) + updated = append(updated, data[valueEnd:]...) + if err := os.WriteFile(path, updated, 0644); err != nil { return fmt.Errorf("failed to write config file: %w", err) } return loadConfig(path) diff --git a/internal/config/emulator_type_test.go b/internal/config/emulator_type_test.go index 483d2033..42a32a9a 100644 --- a/internal/config/emulator_type_test.go +++ b/internal/config/emulator_type_test.go @@ -10,6 +10,29 @@ import ( "github.com/stretchr/testify/require" ) +func TestParseEmulatorType(t *testing.T) { + for _, tc := range []struct { + in string + want EmulatorType + wantErr bool + }{ + {"aws", EmulatorAWS, false}, + {"snowflake", EmulatorSnowflake, false}, + {"azure", EmulatorAzure, false}, + {"AWS", "", true}, + {"", "", true}, + {"bogus", "", true}, + } { + got, err := ParseEmulatorType(tc.in) + if tc.wantErr { + assert.Error(t, err, "input %q", tc.in) + continue + } + require.NoError(t, err, "input %q", tc.in) + assert.Equal(t, tc.want, got) + } +} + func TestSetEmulatorType_WritesAndReloads(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") @@ -59,3 +82,65 @@ func TestSetEmulatorType_PreservesInlineComments(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(got), `type = "snowflake" # Emulator type`) } + +// TestSetEmulatorType_OnlyRewritesActiveBlock guards the surgical rewrite: only +// the active block's type line changes. A commented-out example block and an +// unrelated `content_type` key (which both contain the substring `type = "..."`) +// must be left untouched. +func TestSetEmulatorType_OnlyRewritesActiveBlock(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + content := "[[containers]]\ntype = \"aws\"\nport = \"4566\"\nenv = [\"p\"]\n\n# [[containers]]\n# type = \"snowflake\"\n\n[env.p]\ncontent_type = \"json\"\n" + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + require.NoError(t, loadConfig(path)) + t.Cleanup(func() { viper.Reset() }) + + require.NoError(t, SetEmulatorType(EmulatorAzure)) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(got), "type = \"azure\"\nport", "active block should switch") + assert.Contains(t, string(got), `# type = "snowflake"`, "commented block must be untouched") + assert.Contains(t, string(got), `content_type = "json"`, "unrelated key must be untouched") +} + +// TestSetEmulatorType_IgnoresTypeKeyInEarlierTable guards the block-scoped +// rewrite: a `type` key in another table that appears before [[containers]] +// (e.g. an [env.*] profile) must not be mistaken for the emulator type. TOML +// tables have no required order, so the search must be anchored to the +// [[containers]] header, not just the first `type =` line in the file. +func TestSetEmulatorType_IgnoresTypeKeyInEarlierTable(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + content := "[env.default]\ntype = \"custom\"\n\n[[containers]]\ntype = \"aws\"\nport = \"4566\"\n" + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + require.NoError(t, loadConfig(path)) + t.Cleanup(func() { viper.Reset() }) + + require.NoError(t, SetEmulatorType(EmulatorAzure)) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(got), `type = "custom"`, "the env table's type key must be untouched") + assert.Contains(t, string(got), `type = "azure"`, "the container block's type must switch") + assert.NotContains(t, string(got), `type = "aws"`) +} + +// TestSetEmulatorType_ErrorsWithoutContainersBlock ensures a config with no +// [[containers]] block reports a clear error rather than silently rewriting an +// unrelated key or reporting a generic "no type field" message. +func TestSetEmulatorType_ErrorsWithoutContainersBlock(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + require.NoError(t, os.WriteFile(path, []byte("[env.default]\ntype = \"custom\"\n"), 0644)) + require.NoError(t, loadConfig(path)) + t.Cleanup(func() { viper.Reset() }) + + err := SetEmulatorType(EmulatorAzure) + require.Error(t, err) + assert.Contains(t, err.Error(), "[[containers]] block") + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(got), `type = "custom"`, "the unrelated key must be left untouched on error") +} diff --git a/internal/container/emulator_type.go b/internal/container/emulator_type.go new file mode 100644 index 00000000..a94f8e70 --- /dev/null +++ b/internal/container/emulator_type.go @@ -0,0 +1,120 @@ +package container + +import ( + "fmt" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/output" +) + +// ApplyEmulatorType applies a non-interactive emulator selection (the --type +// flag) to the config before start, returning the resulting containers. +// +// It is the scripted counterpart to the interactive picker (SelectEmulator): +// - First run (no config file yet): create the config and record the requested +// type, reusing the same path the picker writes. +// - Config already matches: no-op. +// - Config differs: switch the type in place via the surgical type-line rewrite +// (comments/formatting preserved), keeping the other block fields. A custom +// image is a hard error — it pins a specific product that cannot be +// reinterpreted under a different emulator type. A non-default tag or any +// volume mounts are kept with a warning, since they are often product-specific. +// +// Messages are emitted through sink; configPath is the friendly config path used +// in those messages so a switch against a checked-in file is visible. +func ApplyEmulatorType(sink output.Sink, requested config.EmulatorType, containers []config.ContainerConfig, firstRun bool, configPath string) ([]config.ContainerConfig, error) { + if firstRun { + if err := config.EnsureCreated(); err != nil { + return nil, fmt.Errorf("failed to create config file: %w", err) + } + if err := config.SetEmulatorType(requested); err != nil { + return nil, fmt.Errorf("failed to set emulator type: %w", err) + } + newCfg, err := config.Get() + if err != nil { + return nil, err + } + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: requested.ShortName() + " emulator selected."}) + if configPath != "" { + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "Change configuration in " + configPath + "."}) + } + return newCfg.Containers, nil + } + + // A config that exists but has no [[containers]] block has no emulator to + // select (and would panic the containers[0] access below); surface a clear, + // actionable error instead of the raw rewrite failure. + if len(containers) == 0 { + err := fmt.Errorf("config has no [[containers]] block") + sink.Emit(output.ErrorEvent{ + Title: "Incomplete configuration", + Summary: "The config file has no [[containers]] block, so there is no emulator to select.", + Actions: []output.ErrorAction{{Label: "Add a [[containers]] block, or delete the file to regenerate it:", Value: "lstk config path"}}, + }) + return nil, output.NewSilentError(err) + } + + // Reject a multi-block config before touching the file: only one block can + // start (checkSingleContainer enforces this on the start path), and rewriting + // one block's type while the start is doomed to fail would leave a confusing + // half-changed config. + if err := checkSingleContainer(containers); err != nil { + sink.Emit(output.ErrorEvent{ + Title: "Unsupported configuration", + Summary: err.Error(), + Actions: []output.ErrorAction{{Label: "Edit your config file so only one [[containers]] block is enabled:", Value: "lstk config path"}}, + }) + return nil, output.NewSilentError(err) + } + + current := containers[0] + if current.Type == requested { + return containers, nil + } + + // configPath can be empty when the friendly path couldn't be resolved; fall + // back to a generic phrase so the messages below still read as sentences. + location := configPath + if location == "" { + location = "your config file" + } + + if current.CustomImage != "" { + sink.Emit(output.ErrorEvent{ + Title: fmt.Sprintf("Cannot switch emulator to %s while a custom image is set", requested.ShortName()), + Summary: "A custom image pins a specific product, so lstk would run the previous product's image under the new emulator type and health checks.", + Actions: []output.ErrorAction{ + {Label: "Remove or update 'image' in", Value: location}, + {Label: "Or keep a separate profile with", Value: "lstk start --type " + string(requested) + " --config "}, + }, + }) + return nil, output.NewSilentError(fmt.Errorf("cannot switch emulator type while a custom image is set")) + } + + if current.Tag != "" && current.Tag != "latest" { + sink.Emit(output.MessageEvent{ + Severity: output.SeverityWarning, + Text: fmt.Sprintf("Keeping tag %q, which may not exist for the %s emulator — update it in %s if the start fails.", current.Tag, requested.ShortName(), location), + }) + } + if current.Volume != "" || len(current.Volumes) > 0 { + sink.Emit(output.MessageEvent{ + Severity: output.SeverityWarning, + Text: fmt.Sprintf("Keeping volume mounts, which are now shared with the %s emulator and may be product-specific — review them in %s.", requested.ShortName(), location), + }) + } + + if err := config.SetEmulatorType(requested); err != nil { + return nil, fmt.Errorf("failed to switch emulator type: %w", err) + } + newCfg, err := config.Get() + if err != nil { + return nil, err + } + note := fmt.Sprintf("Switched configured emulator to %s", requested.ShortName()) + if configPath != "" { + note += fmt.Sprintf(" (%s)", configPath) + } + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: note + "."}) + return newCfg.Containers, nil +} diff --git a/internal/container/emulator_type_test.go b/internal/container/emulator_type_test.go new file mode 100644 index 00000000..3391e752 --- /dev/null +++ b/internal/container/emulator_type_test.go @@ -0,0 +1,106 @@ +package container + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/output" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// loadTempConfig writes content to a temp config.toml and loads it as the active +// config, returning its path. These tests mutate the process-global viper state, +// so they must not run in parallel. +func loadTempConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + require.NoError(t, config.InitFromPath(path)) + return path +} + +func TestApplyEmulatorType_SwitchesInPlace(t *testing.T) { + path := loadTempConfig(t, "[[containers]]\ntype = \"aws\" # keep me\ntag = \"latest\"\nport = \"4566\"\n") + cfg, err := config.Get() + require.NoError(t, err) + + var buf bytes.Buffer + containers, err := ApplyEmulatorType(output.NewPlainSink(&buf), config.EmulatorAzure, cfg.Containers, false, path) + require.NoError(t, err) + + require.Len(t, containers, 1) + assert.Equal(t, config.EmulatorAzure, containers[0].Type) + assert.Contains(t, buf.String(), "Switched configured emulator to Azure") + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(data), `type = "azure"`) + assert.Contains(t, string(data), "# keep me") +} + +func TestApplyEmulatorType_NoOpWhenMatching(t *testing.T) { + content := "[[containers]]\ntype = \"aws\"\ntag = \"latest\"\nport = \"4566\"\n" + path := loadTempConfig(t, content) + cfg, err := config.Get() + require.NoError(t, err) + + var buf bytes.Buffer + containers, err := ApplyEmulatorType(output.NewPlainSink(&buf), config.EmulatorAWS, cfg.Containers, false, path) + require.NoError(t, err) + + assert.Equal(t, config.EmulatorAWS, containers[0].Type) + assert.Empty(t, buf.String()) + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, content, string(data)) +} + +func TestApplyEmulatorType_ErrorsWhenImageSet(t *testing.T) { + content := "[[containers]]\ntype = \"aws\"\ntag = \"latest\"\nport = \"4566\"\nimage = \"my-registry.example.com/localstack-pro:3.0\"\n" + path := loadTempConfig(t, content) + cfg, err := config.Get() + require.NoError(t, err) + + var buf bytes.Buffer + _, err = ApplyEmulatorType(output.NewPlainSink(&buf), config.EmulatorSnowflake, cfg.Containers, false, path) + require.Error(t, err) + assert.True(t, output.IsSilent(err)) + assert.Contains(t, buf.String(), "custom image") + + data, readErr := os.ReadFile(path) + require.NoError(t, readErr) + assert.Equal(t, content, string(data)) +} + +// TestApplyEmulatorType_ErrorsWhenNoContainersBlock exercises the defensive +// guard against a container-less config. config.Get() normally injects a default +// container, so we pass an explicitly empty slice to reach the branch that would +// otherwise panic on containers[0]; it must surface a clear, silent error. +func TestApplyEmulatorType_ErrorsWhenNoContainersBlock(t *testing.T) { + path := loadTempConfig(t, "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n") + + var buf bytes.Buffer + _, err := ApplyEmulatorType(output.NewPlainSink(&buf), config.EmulatorAzure, nil, false, path) + require.Error(t, err) + assert.True(t, output.IsSilent(err)) + assert.Contains(t, buf.String(), "[[containers]] block") +} + +func TestApplyEmulatorType_WarnsOnTagAndVolumes(t *testing.T) { + path := loadTempConfig(t, "[[containers]]\ntype = \"aws\"\ntag = \"3.0\"\nport = \"4566\"\nvolumes = [\"./init.sql:/etc/localstack/init/ready.d/init.sql\"]\n") + cfg, err := config.Get() + require.NoError(t, err) + + var buf bytes.Buffer + _, err = ApplyEmulatorType(output.NewPlainSink(&buf), config.EmulatorSnowflake, cfg.Containers, false, path) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, `Keeping tag "3.0"`) + assert.Contains(t, out, "Keeping volume mounts") + assert.Contains(t, out, "Switched configured emulator to Snowflake") +} diff --git a/test/integration/emulator_type_test.go b/test/integration/emulator_type_test.go new file mode 100644 index 00000000..3f637fea --- /dev/null +++ b/test/integration/emulator_type_test.go @@ -0,0 +1,190 @@ +package integration_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// dockerHostKey points the binary at a non-existent Docker socket so that +// `start` fails fast right after applying the --type flag, letting these tests +// exercise the config mutation and messaging without needing a Docker daemon. +const dockerHostKey = env.Key("DOCKER_HOST") + +// typeTestEnv builds an isolated environment whose start path fails at the Docker +// ping, so the emulator-type handling runs but nothing is actually started. +func typeTestEnv(t *testing.T) (env.Environ, string) { + t.Helper() + tmpHome := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) + e := env.Environ(testEnvWithHome(tmpHome, tmpHome)). + With(env.DisableEvents, "1"). + With(env.AuthToken, "dummy-token"). + With(dockerHostKey, "unix:///nonexistent-lstk-test.sock") + return e, tmpHome +} + +func resolvedConfigPath(t *testing.T, e env.Environ) string { + t.Helper() + configPath, _, err := runLstk(t, testContext(t), t.TempDir(), e, "config", "path") + require.NoError(t, err) + return configPath +} + +func TestStartTypeFlagFirstRunCreatesConfig(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", "--non-interactive") + + assert.Contains(t, stdout, "Snowflake emulator selected.") + data, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(data), `type = "snowflake"`) +} + +// TestBareRootTypeFlagCreatesConfig covers the bare-root form (no "start" +// subcommand) documented in the README/CLAUDE.md; it exercises the +// non-interspersed flag parsing that routes a leading positional to extension +// dispatch, so only the flag form is valid on the root. +func TestBareRootTypeFlagCreatesConfig(t *testing.T) { + t.Parallel() + e, _ := typeTestEnv(t) + configPath := resolvedConfigPath(t, e) + require.NoFileExists(t, configPath) + + stdout, _, _ := runLstk(t, testContext(t), t.TempDir(), e, "--type", "azure", "--non-interactive") + + assert.Contains(t, stdout, "Azure emulator selected.") + data, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(data), `type = "azure"`) +} + +func TestStartTypeFlagSwitchesInPlace(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 = \"aws\" # keep me\ntag = \"latest\"\nport = \"4566\"\n"), 0644)) + + stdout, _, _ := runLstk(t, testContext(t), t.TempDir(), e, "start", "--type", "azure", "--non-interactive") + + assert.Contains(t, stdout, "Switched configured emulator to Azure") + data, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(data), `type = "azure"`) + // The surgical rewrite preserves the inline comment and other fields. + assert.Contains(t, string(data), "# keep me") + assert.Contains(t, string(data), `port = "4566"`) +} + +func TestStartTypeFlagNoOpWhenMatching(t *testing.T) { + t.Parallel() + e, _ := typeTestEnv(t) + configPath := resolvedConfigPath(t, e) + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) + content := "[[containers]]\ntype = \"aws\"\ntag = \"latest\"\nport = \"4566\"\n" + require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) + + stdout, _, _ := runLstk(t, testContext(t), t.TempDir(), e, "start", "--type", "aws", "--non-interactive") + + assert.NotContains(t, stdout, "Switched configured emulator") + data, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Equal(t, content, string(data)) +} + +func TestStartTypeFlagErrorsWhenImageSet(t *testing.T) { + t.Parallel() + e, _ := typeTestEnv(t) + configPath := resolvedConfigPath(t, e) + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) + content := "[[containers]]\ntype = \"aws\"\ntag = \"latest\"\nport = \"4566\"\nimage = \"my-registry.example.com/localstack-pro:3.0\"\n" + require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "start", "--type", "snowflake", "--non-interactive") + + require.Error(t, err) + assert.Contains(t, stdout, "Cannot switch emulator to Snowflake while a custom image is set") + // Config must be left untouched. + data, readErr := os.ReadFile(configPath) + require.NoError(t, readErr) + assert.Equal(t, content, string(data)) +} + +// TestStartTypeErrorsOnMultipleBlocks verifies the switch refuses a config with +// more than one [[containers]] block before mutating it, so neither block's type +// is rewritten by a start that cannot succeed anyway. +func TestStartTypeErrorsOnMultipleBlocks(t *testing.T) { + t.Parallel() + e, _ := typeTestEnv(t) + configPath := resolvedConfigPath(t, e) + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) + content := "[[containers]]\ntype = \"aws\"\nport = \"4566\"\n\n[[containers]]\ntype = \"snowflake\"\nport = \"4567\"\n" + require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, "start", "--type", "azure", "--non-interactive") + + require.Error(t, err) + assert.Contains(t, stdout, "Unsupported configuration") + // Config must be left untouched — neither block's type is rewritten. + data, readErr := os.ReadFile(configPath) + require.NoError(t, readErr) + assert.Equal(t, content, string(data)) +} + +// TestStartTypePositionalRejected pins that the emulator is a flag only: a +// positional (`lstk start azure`) is rejected with a hint pointing at --type, +// rather than silently starting AWS (the pre-fix behavior) or being accepted as +// an alias. This keeps one spelling and avoids colliding with the `aws`/`az` +// proxy subcommands that a positional mental model would imply on the root. +func TestStartTypePositionalRejected(t *testing.T) { + t.Parallel() + e, _ := typeTestEnv(t) + configPath := resolvedConfigPath(t, e) + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "start", "azure", "--non-interactive") + + require.Error(t, err) + assert.Contains(t, stderr, "select the emulator with --type") + require.NoFileExists(t, configPath) +} + +// TestStartTypeErrorsWhenNoContainersBlock is the end-to-end regression for the +// block-scoped rewrite: a config file with a `type` key in an [env.*] table but +// no [[containers]] block must fail with a clear error rather than silently +// rewriting the unrelated env key (the pre-fix behavior of an unscoped match). +func TestStartTypeErrorsWhenNoContainersBlock(t *testing.T) { + t.Parallel() + e, _ := typeTestEnv(t) + configPath := resolvedConfigPath(t, e) + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) + content := "[env.default]\ntype = \"custom\"\n" + require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "start", "--type", "azure", "--non-interactive") + + require.Error(t, err) + assert.Contains(t, stderr, "[[containers]] block") + // The env table's type key must be left untouched, not corrupted to "azure". + data, readErr := os.ReadFile(configPath) + require.NoError(t, readErr) + assert.Equal(t, content, string(data)) +} + +func TestStartTypeInvalidValue(t *testing.T) { + t.Parallel() + e, _ := typeTestEnv(t) + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "start", "--type", "bogus", "--non-interactive") + + require.Error(t, err) + assert.Contains(t, stderr, `invalid emulator type "bogus"`) +}