From cf8d806dd225d9537f9ba578d744edbfeb508aef Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 6 May 2026 18:49:15 +0200 Subject: [PATCH] Improve error for users with no snowflake license --- internal/container/start.go | 25 +++++++++++++++++- internal/container/start_test.go | 45 ++++++++++++++++++++++++++++++++ internal/telemetry/events.go | 10 +++---- 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index 8ad0691d..bdab74c2 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -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 @@ -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 @@ -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) } diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 07373a28..92de03ee 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -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") + } +} diff --git a/internal/telemetry/events.go b/internal/telemetry/events.go index 81bd4309..76c23334 100644 --- a/internal/telemetry/events.go +++ b/internal/telemetry/events.go @@ -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.