Skip to content
Draft
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: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
27 changes: 24 additions & 3 deletions internal/config/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}

Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions internal/config/containers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
2 changes: 1 addition & 1 deletion internal/container/label.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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"},
Expand Down
54 changes: 50 additions & 4 deletions internal/container/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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")
}
}
54 changes: 54 additions & 0 deletions test/integration/emulator_select_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
52 changes: 52 additions & 0 deletions test/integration/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading