Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<product>:<tag>` is used when `image` is unset.

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

`lstk start --type <aws|snowflake|azure>` (shorthand `-t`; also on the bare root) is the non-interactive answer to the first-run emulator picker. It is a flag only — a positional (`lstk start azure`) is rejected with a hint pointing at `--type`, to avoid implying the root-level `lstk aws`/`lstk az` proxy names mean "start that emulator". It is defined as "rewrite the `type` line in config", not an ephemeral per-run override — downstream commands (`stop`, `status`, `logs`, `volume`, snapshot auto-load) all resolve from the configured type, so persisting keeps config and reality in sync. First run creates the config with the selected type (same `EnsureCreated`/`SetEmulatorType` path the picker uses); a matching config is a no-op; a differing config is switched in place via the surgical type-line rewrite (comments/formatting preserved) with a note naming the file. On switch: a custom `image` is a hard error (it pins a product that can't be reinterpreted under a new type — use `--config` for a separate profile), a non-`latest` `tag` and any `volumes`/`volume` are kept with a warning, and `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:
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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]
Expand Down Expand Up @@ -318,6 +326,9 @@ lstk
# Start non-interactively (e.g. in CI)
LOCALSTACK_AUTH_TOKEN=<token> lstk --non-interactive

# Select the emulator non-interactively (records it in config)
LOCALSTACK_AUTH_TOKEN=<token> lstk start --type snowflake --non-interactive

# Stop the running emulator
lstk stop

Expand Down
4 changes: 4 additions & 0 deletions cmd/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions cmd/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
54 changes: 47 additions & 7 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
}

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 16 additions & 1 deletion cmd/start.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)
Expand All @@ -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
}
61 changes: 55 additions & 6 deletions internal/config/emulator_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
85 changes: 85 additions & 0 deletions internal/config/emulator_type_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
}
Loading