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
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
2 changes: 2 additions & 0 deletions cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
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", 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: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())
Expand Down
2 changes: 1 addition & 1 deletion internal/container/label.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
15 changes: 10 additions & 5 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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 {
Expand Down
68 changes: 64 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"}, "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"}, "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"}, "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"}, "4566/tcp").
Return(&runtime.RunningContainer{Name: "localstack-aws", Image: "localstack/localstack-pro:latest", BoundPort: "4566"}, nil)

var out bytes.Buffer
Expand All @@ -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)
Expand Down Expand Up @@ -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")
}
}
66 changes: 66 additions & 0 deletions internal/emulator/azure/client.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading