Skip to content

Commit abc28ea

Browse files
Support starting the Azure emulator (#262)
1 parent 3ad1fbf commit abc28ea

11 files changed

Lines changed: 380 additions & 14 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ When no config file exists, lstk creates one at `$HOME/.config/lstk/config.toml`
6363
Use `lstk config path` to print the resolved config file path currently in use.
6464
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.
6565

66-
Created automatically on first run with defaults. Supports emulator types: `aws` and `snowflake`.
66+
Created automatically on first run with defaults. Supports emulator types: `aws`, `snowflake`, and `azure`.
6767

6868
# Emulator Setup Commands
6969

cmd/status.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/localstack/lstk/internal/container"
99
"github.com/localstack/lstk/internal/emulator"
1010
"github.com/localstack/lstk/internal/emulator/aws"
11+
"github.com/localstack/lstk/internal/emulator/azure"
1112
"github.com/localstack/lstk/internal/emulator/snowflake"
1213
"github.com/localstack/lstk/internal/env"
1314
"github.com/localstack/lstk/internal/output"
@@ -35,6 +36,7 @@ func newStatusCmd(cfg *env.Env) *cobra.Command {
3536
clients := map[config.EmulatorType]emulator.Client{
3637
config.EmulatorAWS: aws.NewClient(),
3738
config.EmulatorSnowflake: snowflake.NewClient(),
39+
config.EmulatorAzure: azure.NewClient(),
3840
}
3941

4042
if isInteractiveMode(cfg) {

internal/config/containers.go

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,21 @@ var emulatorDisplayNames = map[EmulatorType]string{
2929
}
3030

3131
// SelectableEmulatorTypes lists the emulator types available for interactive selection,
32-
// in the order they should be presented. The selection key for each type is its first character.
33-
var SelectableEmulatorTypes = []EmulatorType{EmulatorAWS, EmulatorSnowflake}
32+
// in the order they should be presented.
33+
var SelectableEmulatorTypes = []EmulatorType{EmulatorAWS, EmulatorSnowflake, EmulatorAzure}
34+
35+
// emulatorSelectionKeys assigns each selectable type a unique single-character key.
36+
// "aws" and "azure" both start with 'a', so keys can't simply be the first character.
37+
var emulatorSelectionKeys = map[EmulatorType]string{
38+
EmulatorAWS: "a",
39+
EmulatorSnowflake: "s",
40+
EmulatorAzure: "z",
41+
}
3442

3543
func (e EmulatorType) SelectionKey() string {
44+
if key, ok := emulatorSelectionKeys[e]; ok {
45+
return key
46+
}
3647
return string(e)[0:1]
3748
}
3849

@@ -47,9 +58,18 @@ func (e EmulatorType) DisplayName() string {
4758
return fmt.Sprintf("LocalStack %s Emulator", e.ShortName())
4859
}
4960

61+
// SelfValidatesLicense reports whether the emulator container performs its own
62+
// license activation on startup. For these emulators lstk skips its pre-flight
63+
// platform license check (the LocalStack platform API has no catalog entry for
64+
// them), and lets the container validate the token against the licensing server.
65+
func (e EmulatorType) SelfValidatesLicense() bool {
66+
return e == EmulatorSnowflake || e == EmulatorAzure
67+
}
68+
5069
var emulatorHealthPaths = map[EmulatorType]string{
5170
EmulatorAWS: "/_localstack/health",
5271
EmulatorSnowflake: "/_localstack/health",
72+
EmulatorAzure: "/_localstack/health",
5373
}
5474

5575
var knownImages = []struct {
@@ -60,6 +80,7 @@ var knownImages = []struct {
6080
{EmulatorAWS, "localstack-pro", true},
6181
{EmulatorAWS, "localstack", false},
6282
{EmulatorSnowflake, "snowflake", true},
83+
{EmulatorAzure, "localstack-azure", true},
6384
}
6485

6586
func EmulatorTypeForImage(image string) EmulatorType {
@@ -211,7 +232,7 @@ func (c *ContainerConfig) HealthPath() (string, error) {
211232

212233
func (c *ContainerConfig) ContainerPort() (string, error) {
213234
switch c.Type {
214-
case EmulatorAWS, EmulatorSnowflake:
235+
case EmulatorAWS, EmulatorSnowflake, EmulatorAzure:
215236
return DefaultAWSPort + "/tcp", nil
216237
default:
217238
return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type)

internal/config/containers_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,38 @@ func TestValidate_ValidPort(t *testing.T) {
116116
assert.NoError(t, c.Validate())
117117
}
118118

119+
func TestAzureEmulatorResolvesStartMetadata(t *testing.T) {
120+
c := &ContainerConfig{Type: EmulatorAzure, Port: "4566"}
121+
122+
image, err := c.Image()
123+
require.NoError(t, err)
124+
assert.Equal(t, "localstack/localstack-azure:latest", image)
125+
126+
productName, err := c.ProductName()
127+
require.NoError(t, err)
128+
assert.Equal(t, "localstack-azure", productName)
129+
130+
healthPath, err := c.HealthPath()
131+
require.NoError(t, err)
132+
assert.Equal(t, "/_localstack/health", healthPath)
133+
134+
containerPort, err := c.ContainerPort()
135+
require.NoError(t, err)
136+
assert.Equal(t, "4566/tcp", containerPort)
137+
}
138+
139+
func TestEmulatorTypeForImage_Azure(t *testing.T) {
140+
assert.Equal(t, EmulatorAzure, EmulatorTypeForImage("localstack/localstack-azure:latest"))
141+
}
142+
143+
func TestSelfValidatesLicense(t *testing.T) {
144+
// Snowflake and Azure containers activate their own license against the
145+
// licensing server, so lstk skips its pre-flight platform license check.
146+
assert.True(t, EmulatorSnowflake.SelfValidatesLicense())
147+
assert.True(t, EmulatorAzure.SelfValidatesLicense())
148+
assert.False(t, EmulatorAWS.SelfValidatesLicense())
149+
}
150+
119151
func TestValidate_MinMaxPorts(t *testing.T) {
120152
c := &ContainerConfig{Type: EmulatorAWS, Port: "1"}
121153
assert.NoError(t, c.Validate())

internal/container/label.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func ResolveEmulatorLabel(ctx context.Context, client api.PlatformAPI, container
4343

4444
tag := c.Tag
4545
if tag == "" || tag == "latest" {
46-
if c.Type == config.EmulatorSnowflake {
46+
if c.Type.SelfValidatesLicense() {
4747
return "LocalStack", false
4848
}
4949
if resolvedVersion == "" {

internal/container/start.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,11 @@ func tipsForType(t config.EmulatorType) []string {
278278
"> Tip: View emulator logs: lstk logs --follow",
279279
"> Tip: Check emulator status: lstk status",
280280
}
281+
case config.EmulatorAzure:
282+
return []string{
283+
"> Tip: View emulator logs: lstk logs --follow",
284+
"> Tip: Check emulator status: lstk status",
285+
}
281286
}
282287
return nil
283288
}
@@ -325,7 +330,7 @@ func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *
325330
func tryPrePullLicenseValidation(ctx context.Context, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string) ([]runtime.ContainerConfig, error) {
326331
var needsPostPull []runtime.ContainerConfig
327332
for _, c := range containers {
328-
if c.EmulatorType == config.EmulatorSnowflake {
333+
if c.EmulatorType.SelfValidatesLicense() {
329334
continue
330335
}
331336

@@ -346,7 +351,7 @@ func tryPrePullLicenseValidation(ctx context.Context, sink output.Sink, opts Sta
346351
func validateLicensesFromImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string) (string, error) {
347352
var firstVersion string
348353
for _, c := range containers {
349-
if c.EmulatorType == config.EmulatorSnowflake {
354+
if c.EmulatorType.SelfValidatesLicense() {
350355
continue
351356
}
352357

@@ -387,10 +392,10 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink,
387392
sink.Emit(output.SpinnerStop())
388393
errCode := telemetry.ErrCodeStartFailed
389394
var licErr *licenseNotCoveredError
390-
if errors.As(err, &licErr) && c.EmulatorType == config.EmulatorSnowflake {
395+
if errors.As(err, &licErr) && c.EmulatorType.SelfValidatesLicense() {
391396
errCode = telemetry.ErrCodeLicenseInvalid
392397
sink.Emit(output.ErrorEvent{
393-
Title: "Your license does not include the Snowflake emulator.",
398+
Title: fmt.Sprintf("Your license does not include the %s emulator.", c.EmulatorType.ShortName()),
394399
Actions: []output.ErrorAction{
395400
{Label: "Sign up for a free trial:", Value: "https://app.localstack.cloud/sign-up"},
396401
{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
612617
}
613618

614619
// licenseNotCoveredError is returned by awaitStartup when the container exits
615-
// because it does not include (snowflake) emulator.
620+
// because the license does not include the emulator (Snowflake or Azure).
616621
type licenseNotCoveredError struct{}
617622

618623
func (e *licenseNotCoveredError) Error() string {

internal/container/start_test.go

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ func TestSelectContainersToStart_AttachesWhenExternalContainerOnConfiguredPort(t
157157
}
158158

159159
mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil)
160-
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake"}, "4566/tcp").
160+
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake", "localstack/localstack-azure"}, "4566/tcp").
161161
Return(&runtime.RunningContainer{Name: "external-container", Image: "localstack/localstack-pro:3.5.0", BoundPort: "4566"}, nil)
162162
mockRT.EXPECT().ContainerEnv(gomock.Any(), "external-container").Return(nil, nil)
163163

@@ -186,7 +186,7 @@ func TestSelectContainersToStart_AttachesWhenExternalContainerVersionDiffers(t *
186186
}
187187

188188
mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil)
189-
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake"}, "4566/tcp").
189+
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake", "localstack/localstack-azure"}, "4566/tcp").
190190
Return(&runtime.RunningContainer{Name: "external-container", Image: "localstack/localstack-pro:3.5.0", BoundPort: "4566"}, nil)
191191
mockRT.EXPECT().ContainerEnv(gomock.Any(), "external-container").Return(nil, nil)
192192

@@ -220,7 +220,7 @@ func TestSelectContainersToStart_QueuesContainerWhenNoneRunningOnPort(t *testing
220220
}
221221

222222
mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil)
223-
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake"}, "4566/tcp").
223+
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake", "localstack/localstack-azure"}, "4566/tcp").
224224
Return(nil, nil)
225225

226226
sink := output.NewPlainSink(io.Discard)
@@ -246,7 +246,7 @@ func TestSelectContainersToStart_ErrorsOnEmulatorTypeMismatch(t *testing.T) {
246246
}
247247

248248
mockRT.EXPECT().IsRunning(gomock.Any(), c.Name).Return(false, nil)
249-
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake"}, "4566/tcp").
249+
mockRT.EXPECT().FindRunningByImage(gomock.Any(), []string{"localstack/localstack-pro", "localstack/localstack", "localstack/snowflake", "localstack/localstack-azure"}, "4566/tcp").
250250
Return(&runtime.RunningContainer{Name: "localstack-aws", Image: "localstack/localstack-pro:latest", BoundPort: "4566"}, nil)
251251

252252
var out bytes.Buffer
@@ -263,6 +263,20 @@ func TestSelectContainersToStart_ErrorsOnEmulatorTypeMismatch(t *testing.T) {
263263
assert.Contains(t, got, "docker stop localstack-aws")
264264
}
265265

266+
func TestEmitPostStartPointers_Azure(t *testing.T) {
267+
var out bytes.Buffer
268+
sink := output.NewPlainSink(&out)
269+
270+
emitPostStartPointers(sink, config.EmulatorAzure, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", false)
271+
272+
got := out.String()
273+
assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n")
274+
assert.Contains(t, got, "• Web app: https://app.localstack.cloud\n")
275+
assert.Contains(t, got, "> Tip:")
276+
assert.NotContains(t, got, "• Snowflake endpoint:",
277+
"Azure must not show the snowflake-prefixed endpoint")
278+
}
279+
266280
func TestEmitPostStartPointers_UnknownEmulator_NoTip(t *testing.T) {
267281
var out bytes.Buffer
268282
sink := output.NewPlainSink(&out)
@@ -357,3 +371,49 @@ func TestStartContainers_SnowflakeLicenseError(t *testing.T) {
357371
t.Fatal("no telemetry event received")
358372
}
359373
}
374+
375+
func TestStartContainers_AzureLicenseError(t *testing.T) {
376+
ctrl := gomock.NewController(t)
377+
mockRT := runtime.NewMockRuntime(ctrl)
378+
379+
c := runtime.ContainerConfig{
380+
Image: "localstack/localstack-azure:latest",
381+
Name: "localstack-azure",
382+
EmulatorType: config.EmulatorAzure,
383+
Tag: "latest",
384+
Port: "4566",
385+
ContainerPort: "4566/tcp",
386+
HealthPath: "/_localstack/health",
387+
}
388+
const containerID = "abc123"
389+
licenseLog := "The Azure emulator is currently not covered by your license."
390+
mockRT.EXPECT().Start(gomock.Any(), c).Return(containerID, nil)
391+
mockRT.EXPECT().IsRunning(gomock.Any(), containerID).Return(false, nil)
392+
mockRT.EXPECT().Logs(gomock.Any(), containerID, 20).Return(licenseLog, nil)
393+
394+
tel, capturedEvents := newCapturingTelClient(t)
395+
396+
var out bytes.Buffer
397+
sink := output.NewPlainSink(&out)
398+
399+
err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{})
400+
tel.Close()
401+
402+
require.Error(t, err)
403+
assert.True(t, output.IsSilent(err), "error should be silent since ErrorEvent was already emitted")
404+
got := out.String()
405+
assert.Contains(t, got, "Your license does not include the Azure emulator.")
406+
assert.Contains(t, got, "https://app.localstack.cloud/sign-up")
407+
assert.Contains(t, got, "https://www.localstack.cloud/demo")
408+
409+
select {
410+
case ev := <-capturedEvents:
411+
payload, ok := ev["payload"].(map[string]any)
412+
require.True(t, ok, "telemetry event should have a payload map")
413+
assert.Equal(t, telemetry.LifecycleStartError, payload["event_type"])
414+
assert.Equal(t, telemetry.ErrCodeLicenseInvalid, payload["error_code"])
415+
assert.Equal(t, "azure", payload["emulator"])
416+
default:
417+
t.Fatal("no telemetry event received")
418+
}
419+
}

internal/emulator/azure/client.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package azure
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
9+
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
10+
11+
"github.com/localstack/lstk/internal/emulator"
12+
)
13+
14+
type Client struct {
15+
http *http.Client
16+
}
17+
18+
func NewClient() *Client {
19+
return &Client{
20+
http: &http.Client{
21+
Transport: otelhttp.NewTransport(
22+
http.DefaultTransport,
23+
otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string {
24+
return "azure " + r.Method + " " + r.URL.Path
25+
}),
26+
),
27+
},
28+
}
29+
}
30+
31+
type infoResponse struct {
32+
Version string `json:"version"`
33+
}
34+
35+
// FetchVersion reads the version from /_localstack/info. The Azure image's
36+
// /_localstack/health response does not carry a "version" field, so /info is
37+
// the only endpoint that surfaces it.
38+
func (c *Client) FetchVersion(ctx context.Context, host string) (string, error) {
39+
url := fmt.Sprintf("http://%s/_localstack/info", host)
40+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
41+
if err != nil {
42+
return "", fmt.Errorf("failed to create info request: %w", err)
43+
}
44+
45+
resp, err := c.http.Do(req)
46+
if err != nil {
47+
return "", fmt.Errorf("failed to fetch info: %w", err)
48+
}
49+
defer func() { _ = resp.Body.Close() }()
50+
51+
if resp.StatusCode != http.StatusOK {
52+
return "", fmt.Errorf("info endpoint returned status %d", resp.StatusCode)
53+
}
54+
55+
var i infoResponse
56+
if err := json.NewDecoder(resp.Body).Decode(&i); err != nil {
57+
return "", fmt.Errorf("failed to decode info response: %w", err)
58+
}
59+
return i.Version, nil
60+
}
61+
62+
// FetchResources is a no-op for Azure — the emulator does not expose
63+
// /_localstack/resources (returns 404).
64+
func (c *Client) FetchResources(_ context.Context, _ string) ([]emulator.Resource, error) {
65+
return nil, nil
66+
}

0 commit comments

Comments
 (0)