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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<product>:<tag>` is used when `image` is unset.

## Volume Mounts
Expand Down
5 changes: 3 additions & 2 deletions internal/config/default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
32 changes: 20 additions & 12 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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())
Expand Down Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions internal/container/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 23 additions & 1 deletion test/integration/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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()
Expand Down
Loading