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/cmd/status.go b/cmd/status.go index d7af77d3..62d1c1c9 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -8,6 +8,7 @@ import ( "github.com/localstack/lstk/internal/container" "github.com/localstack/lstk/internal/emulator" "github.com/localstack/lstk/internal/emulator/aws" + "github.com/localstack/lstk/internal/emulator/azure" "github.com/localstack/lstk/internal/emulator/snowflake" "github.com/localstack/lstk/internal/env" "github.com/localstack/lstk/internal/output" @@ -35,6 +36,7 @@ func newStatusCmd(cfg *env.Env) *cobra.Command { clients := map[config.EmulatorType]emulator.Client{ config.EmulatorAWS: aws.NewClient(), config.EmulatorSnowflake: snowflake.NewClient(), + config.EmulatorAzure: azure.NewClient(), } if isInteractiveMode(cfg) { diff --git a/internal/config/containers.go b/internal/config/containers.go index c168004a..a7fb1cd4 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", 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..ff1bccb0 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:latest", image) + + productName, err := c.ProductName() + require.NoError(t, err) + assert.Equal(t, "localstack-azure", 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: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 acde5357..034b4e16 100644 --- a/internal/container/label.go +++ b/internal/container/label.go @@ -43,7 +43,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 } if resolvedVersion == "" { diff --git a/internal/container/start.go b/internal/container/start.go index 5938ab19..2ddecf4b 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -278,6 +278,11 @@ func tipsForType(t config.EmulatorType) []string { "> Tip: View emulator logs: lstk logs --follow", "> Tip: Check emulator status: lstk status", } + case config.EmulatorAzure: + return []string{ + "> Tip: View emulator logs: lstk logs --follow", + "> Tip: Check emulator status: lstk status", + } } return nil } @@ -325,7 +330,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 } @@ -346,7 +351,7 @@ func tryPrePullLicenseValidation(ctx context.Context, sink output.Sink, opts Sta func validateLicensesFromImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string) (string, error) { var firstVersion string for _, c := range containers { - if c.EmulatorType == config.EmulatorSnowflake { + if c.EmulatorType.SelfValidatesLicense() { continue } @@ -387,10 +392,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"}, @@ -612,7 +617,7 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c } // licenseNotCoveredError is returned by awaitStartup when the container exits -// because it does not include (snowflake) emulator. +// because the license does not include the emulator (Snowflake or Azure). type licenseNotCoveredError struct{} func (e *licenseNotCoveredError) Error() string { diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 09755d99..8c329de4 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"}, "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"}, "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"}, "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"}, "4566/tcp"). Return(&runtime.RunningContainer{Name: "localstack-aws", Image: "localstack/localstack-pro:latest", BoundPort: "4566"}, nil) var out bytes.Buffer @@ -263,6 +263,20 @@ func TestSelectContainersToStart_ErrorsOnEmulatorTypeMismatch(t *testing.T) { assert.Contains(t, got, "docker stop localstack-aws") } +func TestEmitPostStartPointers_Azure(t *testing.T) { + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + emitPostStartPointers(sink, config.EmulatorAzure, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", false) + + got := out.String() + assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n") + assert.Contains(t, got, "• Web app: https://app.localstack.cloud\n") + assert.Contains(t, got, "> Tip:") + assert.NotContains(t, got, "• Snowflake endpoint:", + "Azure must not show the snowflake-prefixed endpoint") +} + func TestEmitPostStartPointers_UnknownEmulator_NoTip(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) @@ -357,3 +371,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: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/internal/emulator/azure/client.go b/internal/emulator/azure/client.go new file mode 100644 index 00000000..d6024d11 --- /dev/null +++ b/internal/emulator/azure/client.go @@ -0,0 +1,66 @@ +package azure + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + + "github.com/localstack/lstk/internal/emulator" +) + +type Client struct { + http *http.Client +} + +func NewClient() *Client { + return &Client{ + http: &http.Client{ + Transport: otelhttp.NewTransport( + http.DefaultTransport, + otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string { + return "azure " + r.Method + " " + r.URL.Path + }), + ), + }, + } +} + +type infoResponse struct { + Version string `json:"version"` +} + +// FetchVersion reads the version from /_localstack/info. The Azure image's +// /_localstack/health response does not carry a "version" field, so /info is +// the only endpoint that surfaces it. +func (c *Client) FetchVersion(ctx context.Context, host string) (string, error) { + url := fmt.Sprintf("http://%s/_localstack/info", host) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("failed to create info request: %w", err) + } + + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch info: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("info endpoint returned status %d", resp.StatusCode) + } + + var i infoResponse + if err := json.NewDecoder(resp.Body).Decode(&i); err != nil { + return "", fmt.Errorf("failed to decode info response: %w", err) + } + return i.Version, nil +} + +// FetchResources is a no-op for Azure — the emulator does not expose +// /_localstack/resources (returns 404). +func (c *Client) FetchResources(_ context.Context, _ string) ([]emulator.Resource, error) { + return nil, nil +} diff --git a/internal/emulator/azure/client_test.go b/internal/emulator/azure/client_test.go new file mode 100644 index 00000000..3f7bb521 --- /dev/null +++ b/internal/emulator/azure/client_test.go @@ -0,0 +1,51 @@ +package azure + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFetchVersion(t *testing.T) { + t.Parallel() + + t.Run("returns version from info endpoint", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/_localstack/info", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintln(w, `{"version": "2026.6.0.dev112:17a29a966", "edition": "azure-alpha"}`) + })) + defer server.Close() + + c := NewClient() + version, err := c.FetchVersion(context.Background(), server.Listener.Addr().String()) + require.NoError(t, err) + assert.Equal(t, "2026.6.0.dev112:17a29a966", version) + }) + + t.Run("returns error on non-200", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + c := NewClient() + _, err := c.FetchVersion(context.Background(), server.Listener.Addr().String()) + require.Error(t, err) + }) +} + +func TestFetchResources_AlwaysEmpty(t *testing.T) { + t.Parallel() + c := NewClient() + rows, err := c.FetchResources(context.Background(), "unused") + require.NoError(t, err) + assert.Empty(t, rows) +} diff --git a/test/integration/emulator_select_test.go b/test/integration/emulator_select_test.go index 4e2138a4..01532cdf 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 TestFirstRunPromptsForLoginBeforeEmulatorSelection(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 0d49cee8..a12918a5 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -702,3 +702,78 @@ 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) + stdout, 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", + "expected localstack/localstack-azure 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) + + assert.Contains(t, stdout, "> Tip:", + "azure start should print a tip line like AWS does") +} + +func TestStartCommandForAzureSkipsLicenseValidation(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + cleanupAzure() + t.Cleanup(cleanup) + t.Cleanup(cleanupAzure) + + // Mock server that rejects all license requests — this would cause lstk start to fail for AWS. + // Azure activates its own license against the licensing server, so lstk must skip the pre-flight check. + mockServer := createMockLicenseServer(false) + defer mockServer.Close() + + ctx := testContext(t) + _, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "--config", writeAzureConfig(t, "4566"), "start") + require.NoError(t, err, "lstk start should succeed for azure even when the license server rejects the request: %s", stderr) + requireExitCode(t, 0, err) +}