Skip to content
Merged
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
26 changes: 25 additions & 1 deletion internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,11 +369,24 @@ 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.",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Short and sweet.

Actions: []output.ErrorAction{
{Label: "Sign up for a free trial:", Value: "https://app.localstack.cloud/sign-up"},
{Label: "Contact our team:", Value: "https://www.localstack.cloud/demo"},
},
})
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 +581,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 +604,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
46 changes: 46 additions & 0 deletions internal/container/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,49 @@ 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://app.localstack.cloud/sign-up")
assert.Contains(t, got, "https://www.localstack.cloud/demo")

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