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
25 changes: 24 additions & 1 deletion internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,11 +369,23 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink,
sink.Emit(output.ContainerStatusEvent{Phase: "waiting", Container: c.Name})
healthURL := fmt.Sprintf("http://localhost:%s%s", c.Port, c.HealthPath)
if err := awaitStartup(ctx, rt, sink, containerID, "LocalStack", healthURL); err != nil {
errCode := telemetry.ErrCodeStartFailed
var licErr *licenseNotCoveredError
if errors.As(err, &licErr) && c.EmulatorType == config.EmulatorSnowflake {
errCode = telemetry.ErrCodeLicenseInvalid
sink.Emit(output.ErrorEvent{
Title: "Your license does not include the Snowflake emulator.",
Actions: []output.ErrorAction{
{Label: "Start a free Snowflake trial:", Value: "https://www.localstack.cloud/pricing?tab=snowflake"},
},
})
err = output.NewSilentError(err)
}
tel.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{
EventType: telemetry.LifecycleStartError,
Emulator: c.EmulatorType,
Image: c.Image,
ErrorCode: telemetry.ErrCodeStartFailed,
ErrorCode: errCode,
ErrorMsg: err.Error(),
})
return err
Expand Down Expand Up @@ -568,6 +580,14 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c
return nil
}

// licenseNotCoveredError is returned by awaitStartup when the container exits
// because it does not include (snowflake) emulator.
type licenseNotCoveredError struct{}

func (e *licenseNotCoveredError) Error() string {
return "license does not include this emulator"
}

// awaitStartup polls until one of two outcomes:
// - Success: health endpoint returns 200 (license is valid, LocalStack is ready)
// - Failure: container stops running (e.g., license activation failed), returns error with container logs
Expand All @@ -583,6 +603,9 @@ func awaitStartup(ctx context.Context, rt runtime.Runtime, sink output.Sink, con
}
if !running {
logs, logsErr := rt.Logs(ctx, containerID, 20)
if logsErr == nil && strings.Contains(logs, "not covered by your license") {
return &licenseNotCoveredError{}
}
if logsErr != nil || logs == "" {
return fmt.Errorf("%s exited unexpectedly", name)
}
Expand Down
45 changes: 45 additions & 0 deletions internal/container/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,48 @@ func TestFilterHostEnv(t *testing.T) {
assert.NotContains(t, got, "HOME=/home/user")
assert.NotContains(t, got, "CI_PIPELINE=foo", "only exact CI= must be forwarded, not CI_*")
}

func TestStartContainers_SnowflakeLicenseError(t *testing.T) {
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)

c := runtime.ContainerConfig{
Image: "localstack/snowflake:latest",
Name: "localstack-snowflake",
EmulatorType: config.EmulatorSnowflake,
Tag: "latest",
Port: "4566",
ContainerPort: "4566/tcp",
HealthPath: "/_localstack/health",
}
const containerID = "abc123"
licenseLog := "⚠️ The Snowflake emulator is currently not covered by your license. ❄️"
mockRT.EXPECT().Start(gomock.Any(), c).Return(containerID, nil)
mockRT.EXPECT().IsRunning(gomock.Any(), containerID).Return(false, nil)
mockRT.EXPECT().Logs(gomock.Any(), containerID, 20).Return(licenseLog, nil)

tel, capturedEvents := newCapturingTelClient(t)

var out bytes.Buffer
sink := output.NewPlainSink(&out)

err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{})
tel.Close()

require.Error(t, err)
assert.True(t, output.IsSilent(err), "error should be silent since ErrorEvent was already emitted")
got := out.String()
assert.Contains(t, got, "Your license does not include the Snowflake emulator.")
assert.Contains(t, got, "https://www.localstack.cloud/pricing?tab=snowflake")

select {
case ev := <-capturedEvents:
payload, ok := ev["payload"].(map[string]any)
require.True(t, ok, "telemetry event should have a payload map")
assert.Equal(t, telemetry.LifecycleStartError, payload["event_type"])
assert.Equal(t, telemetry.ErrCodeLicenseInvalid, payload["error_code"])
assert.Equal(t, "snowflake", payload["emulator"])
default:
t.Fatal("no telemetry event received")
}
}
10 changes: 5 additions & 5 deletions internal/telemetry/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ const (

// Error codes for start_error lifecycle events.
const (
ErrCodePortConflict = "port_conflict"
ErrCodeImagePullFailed = "image_pull_failed"
ErrCodeLicenseInvalid = "license_invalid"
ErrCodeStartFailed = "start_failed"
ErrCodeEmulatorMismatch = "emulator_mismatch"
ErrCodePortConflict = "port_conflict"
ErrCodeImagePullFailed = "image_pull_failed"
ErrCodeLicenseInvalid = "license_invalid"
ErrCodeStartFailed = "start_failed"
ErrCodeEmulatorMismatch = "emulator_mismatch"
)

// ToMap converts a telemetry event struct to a map[string]any for use with Emit.
Expand Down
Loading