From 09f448ed47c3a285e08879b0cc4d63a55186ad1e Mon Sep 17 00:00:00 2001 From: Anisa Oshafi <20666022+anisaoshafi@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:55:38 +0000 Subject: [PATCH 1/2] Fail fast on unsupported multi-container configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configs with more than one enabled [[containers]] block are unsupported (e.g. AWS + Snowflake), but previously passed validation and let start proceed, only to fail later during startup on container-name conflicts or shared port collisions — sometimes after image pulls had begun, leaving a partial startup and a confusing error. Guard against this at the top of container.Start, before any health/auth checks or image pulls, so startup stops early with a clear message. The check lives on the start path rather than config.Get() on purpose: recovery/reporting commands (stop, status, logout) must still enumerate multiple running emulators. This replaces the previous soft warning that only covered duplicate emulator types. Generated with [Linear](https://linear.app/localstack/issue/DEVX-952/fail-fast-on-unsupported-multi-container-configs#agent-session-1dac3d90) Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com> --- CLAUDE.md | 2 ++ internal/config/default_config.toml | 5 +++-- internal/container/start.go | 32 +++++++++++++++++----------- internal/container/start_test.go | 33 +++++++++++++++++++++++++++++ test/integration/config_test.go | 22 +++++++++++++++++++ 5 files changed, 80 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c18d98b2..f744ad99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,8 @@ A parent command that only groups subcommands (e.g. `config`, `setup`, `volume`, Created automatically on first run with defaults. Supports emulator types: `aws`, `snowflake`, and `azure`. +Only one `[[containers]]` block may be enabled at a time. `container.Start` rejects a config with more than one block up front (before health/auth checks and image pulls), since running multiple emulators together (e.g. AWS + Snowflake) is unsupported and would otherwise fail later during startup with container-name conflicts or port collisions. The guard lives on the start path (not `config.Get()`) on purpose: recovery/reporting commands like `stop`, `status`, and `logout` must still enumerate multiple running emulators. + 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. ## Volume Mounts diff --git a/internal/config/default_config.toml b/internal/config/default_config.toml index 28679a0e..cf4c6926 100644 --- a/internal/config/default_config.toml +++ b/internal/config/default_config.toml @@ -2,8 +2,9 @@ # Run 'lstk config path' to see where this file lives. # Each [[containers]] block defines an emulator instance. -# Currently, running AWS and Snowflake at the same time is not fully supported — -# enable only one [[containers]] block at a time. +# Only one [[containers]] block may be enabled at a time — running multiple +# emulators together (e.g. AWS and Snowflake) is not supported yet, so +# 'lstk start' refuses to start with more than one block. [[containers]] type = "aws" # Emulator type. Currently supported: "aws", "snowflake", "azure" diff --git a/internal/container/start.go b/internal/container/start.go index 05fce696..c3813d6c 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -49,6 +49,18 @@ type StartOptions struct { } func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, interactive bool) (string, error) { + // Fail fast on unsupported multi-container configs before any health/auth + // checks or image pulls, so we don't leave a partial startup that later dies + // on container-name conflicts or shared port collisions. + if err := checkSingleContainer(opts.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 "", output.NewSilentError(err) + } + if err := rt.IsHealthy(ctx); err != nil { rt.EmitUnhealthyError(sink, err) return "", output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) @@ -67,10 +79,6 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start opts.Telemetry.SetAuthToken(token) - if hasDuplicateContainerTypes(opts.Containers) { - sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: "Multiple emulators of the same type are defined in your config; this setup is not supported yet"}) - } - tel := opts.Telemetry hostEnv := filterHostEnv(os.Environ()) @@ -837,15 +845,15 @@ func agentEnv(cl caller.Classification) []string { return env } -func hasDuplicateContainerTypes(containers []config.ContainerConfig) bool { - seen := make(map[config.EmulatorType]bool) - for _, c := range containers { - if seen[c.Type] { - return true - } - seen[c.Type] = true +// checkSingleContainer rejects configs that enable more than one [[containers]] +// block. Only one emulator is supported at a time; running several together +// (e.g. AWS and Snowflake) is not supported yet and would collide on container +// names and shared ports during startup. +func checkSingleContainer(containers []config.ContainerConfig) error { + if len(containers) > 1 { + return fmt.Errorf("found %d [[containers]] blocks, but only one is supported at a time; enable a single [[containers]] block (running multiple emulators together, e.g. AWS and Snowflake, is not supported yet)", len(containers)) } - return false + return nil } func servicePortRange() []runtime.PortMapping { diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 0267b1bd..54421342 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -28,6 +28,39 @@ import ( "go.uber.org/mock/gomock" ) +func TestStart_RejectsMultipleContainersBeforeHealthCheck(t *testing.T) { + ctrl := gomock.NewController(t) + // No IsHealthy expectation: the guard must fire before the runtime is touched. + mockRT := runtime.NewMockRuntime(ctrl) + + sink := output.NewPlainSink(io.Discard) + opts := StartOptions{ + Logger: log.Nop(), + Containers: []config.ContainerConfig{ + {Type: config.EmulatorAWS, Port: "4566"}, + {Type: config.EmulatorSnowflake, Port: "4567"}, + }, + } + + _, err := Start(context.Background(), mockRT, sink, opts, false) + + require.Error(t, err) + assert.Contains(t, err.Error(), "only one") + assert.True(t, output.IsSilent(err), "error should be silent since it was already emitted") +} + +func TestCheckSingleContainer(t *testing.T) { + assert.NoError(t, checkSingleContainer(nil)) + assert.NoError(t, checkSingleContainer([]config.ContainerConfig{{Type: config.EmulatorAWS}})) + + err := checkSingleContainer([]config.ContainerConfig{ + {Type: config.EmulatorAWS}, + {Type: config.EmulatorSnowflake}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "only one") +} + func TestStart_ReturnsEarlyIfRuntimeUnhealthy(t *testing.T) { ctrl := gomock.NewController(t) mockRT := runtime.NewMockRuntime(ctrl) diff --git a/test/integration/config_test.go b/test/integration/config_test.go index df2ad8e6..136f0bbb 100644 --- a/test/integration/config_test.go +++ b/test/integration/config_test.go @@ -202,6 +202,28 @@ tag = "latest" assert.Contains(t, stderr, "port is required") } +func TestStartWithMultipleContainersFailsFast(t *testing.T) { + t.Parallel() + configContent := ` +[[containers]] +type = "aws" +port = "4566" + +[[containers]] +type = "snowflake" +port = "4567" +` + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + // The guard runs at the very top of container.Start, before any Docker health + // check, auth, or image pull — so start fails fast even without a daemon/token. + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), testEnvWithHome(t.TempDir(), ""), "--config", configFile, "start") + require.Error(t, err) + requireExitCode(t, 1, err) + assert.Contains(t, stdout+stderr, "only one") +} + func TestLegacyYAMLConfigGivesHelpfulError(t *testing.T) { t.Parallel() tmpHome := t.TempDir() From 1e8a474366d4df3d84d6c9cc44c085f4024afb01 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Fri, 3 Jul 2026 16:46:34 +0200 Subject: [PATCH 2/2] Simplify error message --- internal/container/start.go | 2 +- internal/container/start_test.go | 4 ++-- test/integration/config_test.go | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index c3813d6c..41fa8b1c 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -851,7 +851,7 @@ func agentEnv(cl caller.Classification) []string { // names and shared ports during startup. func checkSingleContainer(containers []config.ContainerConfig) error { if len(containers) > 1 { - return fmt.Errorf("found %d [[containers]] blocks, but only one is supported at a time; enable a single [[containers]] block (running multiple emulators together, e.g. AWS and Snowflake, is not supported yet)", len(containers)) + return fmt.Errorf("found %d [[containers]] blocks in your config, but only one is supported at a time", len(containers)) } return nil } diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 54421342..5e267a62 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -45,7 +45,7 @@ func TestStart_RejectsMultipleContainersBeforeHealthCheck(t *testing.T) { _, err := Start(context.Background(), mockRT, sink, opts, false) require.Error(t, err) - assert.Contains(t, err.Error(), "only one") + assert.Contains(t, err.Error(), "only one is supported at a time") assert.True(t, output.IsSilent(err), "error should be silent since it was already emitted") } @@ -58,7 +58,7 @@ func TestCheckSingleContainer(t *testing.T) { {Type: config.EmulatorSnowflake}, }) require.Error(t, err) - assert.Contains(t, err.Error(), "only one") + assert.Contains(t, err.Error(), "only one is supported at a time") } func TestStart_ReturnsEarlyIfRuntimeUnhealthy(t *testing.T) { diff --git a/test/integration/config_test.go b/test/integration/config_test.go index 136f0bbb..402a4ed0 100644 --- a/test/integration/config_test.go +++ b/test/integration/config_test.go @@ -7,8 +7,8 @@ import ( "runtime" "testing" - "github.com/moby/moby/client" "github.com/localstack/lstk/test/integration/env" + "github.com/moby/moby/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -221,7 +221,7 @@ port = "4567" stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), testEnvWithHome(t.TempDir(), ""), "--config", configFile, "start") require.Error(t, err) requireExitCode(t, 1, err) - assert.Contains(t, stdout+stderr, "only one") + assert.Contains(t, stdout+stderr, "only one is supported at a time") } func TestLegacyYAMLConfigGivesHelpfulError(t *testing.T) {