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..41fa8b1c 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 in your config, but only one is supported at a time", 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..5e267a62 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 is supported at a time") + 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 is supported at a time") +} + 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..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" ) @@ -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 is supported at a time") +} + func TestLegacyYAMLConfigGivesHelpfulError(t *testing.T) { t.Parallel() tmpHome := t.TempDir()