diff --git a/CLAUDE.md b/CLAUDE.md index 52454f6c..84f65723 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ When no config file exists, lstk creates one at `$HOME/.config/lstk/config.toml` Use `lstk config path` to print the resolved config file path currently in use. When adding a new command that depends on configuration, wire config initialization explicitly in that command (`PreRunE: initConfig`). Keep side-effect-free commands (e.g., `version`, `config path`) without config initialization. -Created automatically on first run with defaults. Supports emulator types: `aws` and `snowflake`. +Created automatically on first run with defaults. Supports emulator types: `aws`, `snowflake`, and `azure`. # Emulator Setup Commands diff --git a/internal/config/containers.go b/internal/config/containers.go index c168004a..9f746b63 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -29,10 +29,21 @@ var emulatorDisplayNames = map[EmulatorType]string{ } // SelectableEmulatorTypes lists the emulator types available for interactive selection, -// in the order they should be presented. The selection key for each type is its first character. -var SelectableEmulatorTypes = []EmulatorType{EmulatorAWS, EmulatorSnowflake} +// in the order they should be presented. +var SelectableEmulatorTypes = []EmulatorType{EmulatorAWS, EmulatorSnowflake, EmulatorAzure} + +// emulatorSelectionKeys assigns each selectable type a unique single-character key. +// "aws" and "azure" both start with 'a', so keys can't simply be the first character. +var emulatorSelectionKeys = map[EmulatorType]string{ + EmulatorAWS: "a", + EmulatorSnowflake: "s", + EmulatorAzure: "z", +} func (e EmulatorType) SelectionKey() string { + if key, ok := emulatorSelectionKeys[e]; ok { + return key + } return string(e)[0:1] } @@ -47,9 +58,18 @@ func (e EmulatorType) DisplayName() string { return fmt.Sprintf("LocalStack %s Emulator", e.ShortName()) } +// SelfValidatesLicense reports whether the emulator container performs its own +// license activation on startup. For these emulators lstk skips its pre-flight +// platform license check (the LocalStack platform API has no catalog entry for +// them), and lets the container validate the token against the licensing server. +func (e EmulatorType) SelfValidatesLicense() bool { + return e == EmulatorSnowflake || e == EmulatorAzure +} + var emulatorHealthPaths = map[EmulatorType]string{ EmulatorAWS: "/_localstack/health", EmulatorSnowflake: "/_localstack/health", + EmulatorAzure: "/_localstack/health", } var knownImages = []struct { @@ -60,6 +80,7 @@ var knownImages = []struct { {EmulatorAWS, "localstack-pro", true}, {EmulatorAWS, "localstack", false}, {EmulatorSnowflake, "snowflake", true}, + {EmulatorAzure, "localstack-azure-alpha", true}, } func EmulatorTypeForImage(image string) EmulatorType { @@ -211,7 +232,7 @@ func (c *ContainerConfig) HealthPath() (string, error) { func (c *ContainerConfig) ContainerPort() (string, error) { switch c.Type { - case EmulatorAWS, EmulatorSnowflake: + case EmulatorAWS, EmulatorSnowflake, EmulatorAzure: return DefaultAWSPort + "/tcp", nil default: return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type) diff --git a/internal/config/containers_test.go b/internal/config/containers_test.go index 27ae249d..153be903 100644 --- a/internal/config/containers_test.go +++ b/internal/config/containers_test.go @@ -116,6 +116,38 @@ func TestValidate_ValidPort(t *testing.T) { assert.NoError(t, c.Validate()) } +func TestAzureEmulatorResolvesStartMetadata(t *testing.T) { + c := &ContainerConfig{Type: EmulatorAzure, Port: "4566"} + + image, err := c.Image() + require.NoError(t, err) + assert.Equal(t, "localstack/localstack-azure-alpha:latest", image) + + productName, err := c.ProductName() + require.NoError(t, err) + assert.Equal(t, "localstack-azure-alpha", productName) + + healthPath, err := c.HealthPath() + require.NoError(t, err) + assert.Equal(t, "/_localstack/health", healthPath) + + containerPort, err := c.ContainerPort() + require.NoError(t, err) + assert.Equal(t, "4566/tcp", containerPort) +} + +func TestEmulatorTypeForImage_Azure(t *testing.T) { + assert.Equal(t, EmulatorAzure, EmulatorTypeForImage("localstack/localstack-azure-alpha:latest")) +} + +func TestSelfValidatesLicense(t *testing.T) { + // Snowflake and Azure containers activate their own license against the + // licensing server, so lstk skips its pre-flight platform license check. + assert.True(t, EmulatorSnowflake.SelfValidatesLicense()) + assert.True(t, EmulatorAzure.SelfValidatesLicense()) + assert.False(t, EmulatorAWS.SelfValidatesLicense()) +} + func TestValidate_MinMaxPorts(t *testing.T) { c := &ContainerConfig{Type: EmulatorAWS, Port: "1"} assert.NoError(t, c.Validate()) diff --git a/internal/container/label.go b/internal/container/label.go index 8be1622b..d0f29c17 100644 --- a/internal/container/label.go +++ b/internal/container/label.go @@ -39,7 +39,7 @@ func ResolveEmulatorLabel(ctx context.Context, client api.PlatformAPI, container tag := c.Tag if tag == "" || tag == "latest" { - if c.Type == config.EmulatorSnowflake { + if c.Type.SelfValidatesLicense() { return "LocalStack", false } apiCtx, cancel := context.WithTimeout(ctx, 2*time.Second) diff --git a/internal/container/start.go b/internal/container/start.go index 83b93388..a6403572 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -315,7 +315,7 @@ func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel * func tryPrePullLicenseValidation(ctx context.Context, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string) ([]runtime.ContainerConfig, error) { var needsPostPull []runtime.ContainerConfig for _, c := range containers { - if c.EmulatorType == config.EmulatorSnowflake { + if c.EmulatorType.SelfValidatesLicense() { continue } @@ -347,7 +347,7 @@ func tryPrePullLicenseValidation(ctx context.Context, sink output.Sink, opts Sta // Fallback path: inspects each pulled image for its version, then validates the license. func validateLicensesFromImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string) error { for _, c := range containers { - if c.EmulatorType == config.EmulatorSnowflake { + if c.EmulatorType.SelfValidatesLicense() { continue } @@ -385,10 +385,10 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, sink.Emit(output.SpinnerStop()) errCode := telemetry.ErrCodeStartFailed var licErr *licenseNotCoveredError - if errors.As(err, &licErr) && c.EmulatorType == config.EmulatorSnowflake { + if errors.As(err, &licErr) && c.EmulatorType.SelfValidatesLicense() { errCode = telemetry.ErrCodeLicenseInvalid sink.Emit(output.ErrorEvent{ - Title: "Your license does not include the Snowflake emulator.", + Title: fmt.Sprintf("Your license does not include the %s emulator.", c.EmulatorType.ShortName()), 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"}, diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 9d77ce54..cc9b1022 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -157,7 +157,7 @@ func TestSelectContainersToStart_AttachesWhenExternalContainerOnConfiguredPort(t } mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil) - mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake"}, "4566/tcp"). + mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake", "localstack/localstack-azure-alpha"}, "4566/tcp"). Return(&runtime.RunningContainer{Name: "external-container", Image: "localstack/localstack-pro:3.5.0", BoundPort: "4566"}, nil) mockRT.EXPECT().ContainerEnv(gomock.Any(), "external-container").Return(nil, nil) @@ -186,7 +186,7 @@ func TestSelectContainersToStart_AttachesWhenExternalContainerVersionDiffers(t * } mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil) - mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake"}, "4566/tcp"). + mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake", "localstack/localstack-azure-alpha"}, "4566/tcp"). Return(&runtime.RunningContainer{Name: "external-container", Image: "localstack/localstack-pro:3.5.0", BoundPort: "4566"}, nil) mockRT.EXPECT().ContainerEnv(gomock.Any(), "external-container").Return(nil, nil) @@ -220,7 +220,7 @@ func TestSelectContainersToStart_QueuesContainerWhenNoneRunningOnPort(t *testing } mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil) - mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake"}, "4566/tcp"). + mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake", "localstack/localstack-azure-alpha"}, "4566/tcp"). Return(nil, nil) sink := output.NewPlainSink(io.Discard) @@ -246,7 +246,7 @@ func TestSelectContainersToStart_ErrorsOnEmulatorTypeMismatch(t *testing.T) { } mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil) - mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake"}, "4566/tcp"). + mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake", "localstack/localstack-azure-alpha"}, "4566/tcp"). Return(&runtime.RunningContainer{Name: "localstack-aws", Image: "localstack/localstack-pro:latest", BoundPort: "4566"}, nil) var out bytes.Buffer @@ -357,3 +357,49 @@ func TestStartContainers_SnowflakeLicenseError(t *testing.T) { t.Fatal("no telemetry event received") } } + +func TestStartContainers_AzureLicenseError(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + + c := runtime.ContainerConfig{ + Image: "localstack/localstack-azure-alpha:latest", + Name: "localstack-azure", + EmulatorType: config.EmulatorAzure, + Tag: "latest", + Port: "4566", + ContainerPort: "4566/tcp", + HealthPath: "/_localstack/health", + } + const containerID = "abc123" + licenseLog := "The Azure 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 Azure 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, "azure", payload["emulator"]) + default: + t.Fatal("no telemetry event received") + } +} diff --git a/test/integration/emulator_select_test.go b/test/integration/emulator_select_test.go index 62c80931..b291b1f1 100644 --- a/test/integration/emulator_select_test.go +++ b/test/integration/emulator_select_test.go @@ -114,6 +114,60 @@ func TestFirstRunShowsEmulatorSelectionPrompt(t *testing.T) { <-outputCh } +func TestFirstRunCanSelectAzureEmulator(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tmpHome := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpHome, ".config"), 0755)) + e := env.Environ(testEnvWithHome(tmpHome, tmpHome)). + With(env.DisableEvents, "1") + + configPath, _, err := runLstk(t, testContext(t), "", e, "config", "path") + require.NoError(t, err) + require.NoFileExists(t, configPath) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binaryPath(), "start") + cmd.Env = e + + ptmx, err := pty.Start(cmd) + require.NoError(t, err, "failed to start lstk in PTY") + defer func() { _ = ptmx.Close() }() + + out := &syncBuffer{} + outputCh := make(chan struct{}) + go func() { + _, _ = io.Copy(out, ptmx) + close(outputCh) + }() + + require.Eventually(t, func() bool { + return bytes.Contains(out.Bytes(), []byte("Which emulator would you like to use?")) + }, 10*time.Second, 100*time.Millisecond, "emulator selection prompt should appear on first run") + + assert.Contains(t, out.String(), "Azure", "Azure should be offered as a selectable emulator") + + // Press the Azure selection key ('z') instead of the default-highlighted AWS. + _, err = ptmx.Write([]byte("z")) + require.NoError(t, err) + + require.Eventually(t, func() bool { + return bytes.Contains(out.Bytes(), []byte("Azure emulator selected.")) + }, 10*time.Second, 100*time.Millisecond, "Azure selection confirmation should appear") + + configData, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(configData), `type = "azure"`) + + cancel() + <-outputCh +} + func TestFirstRunNonInteractiveEmitsDefaultEmulatorNote(t *testing.T) { t.Parallel() tmpHome := t.TempDir() diff --git a/test/integration/start_test.go b/test/integration/start_test.go index f384c710..adef5c3a 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -669,3 +669,55 @@ func TestStartCommandSucceedsForSnowflake(t *testing.T) { assert.Contains(t, stdout, "> Tip:", "snowflake start should print a tip line like AWS does") } + +const azureContainerName = "localstack-azure" + +func cleanupAzure() { + ctx := context.Background() + _, _ = dockerClient.ContainerRemove(ctx, azureContainerName, client.ContainerRemoveOptions{Force: true}) +} + +func writeAzureConfig(t *testing.T, hostPort string) string { + t.Helper() + content := fmt.Sprintf(` +[[containers]] +type = "azure" +tag = "latest" +port = %q +`, hostPort) + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(content), 0644)) + return configFile +} + +func TestStartCommandSucceedsForAzure(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + cleanupAzure() + t.Cleanup(cleanup) + t.Cleanup(cleanupAzure) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + const hostPort = "4566" + configFile := writeAzureConfig(t, hostPort) + + ctx := testContext(t) + _, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "--config", configFile, "start") + require.NoError(t, err, "lstk start failed: %s", stderr) + requireExitCode(t, 0, err) + + inspect, err := dockerClient.ContainerInspect(ctx, azureContainerName, client.ContainerInspectOptions{}) + require.NoError(t, err, "failed to inspect azure container") + require.True(t, inspect.Container.State.Running, "azure container should be running") + assert.Contains(t, inspect.Container.Config.Image, "localstack/localstack-azure-alpha", + "expected localstack/localstack-azure-alpha image, got %s", inspect.Container.Config.Image) + + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/_localstack/health", hostPort)) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + assert.Equal(t, http.StatusOK, resp.StatusCode) +}