From 7dc93bcf0c44c49e4c484f18da1d841dd3acbde8 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 4 Jun 2026 17:15:20 +0300 Subject: [PATCH 1/2] Show license plan in header for self-validating emulators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure and Snowflake starts left the header plan label blank: lstk deliberately skips the license API for self-validating emulators (no catalog entry for their products), and run.go conflated "no resolved version" with "already running", sending the (empty) cached label. Resolve the label from the license the container activates into its volume (cache/license.json) instead — no extra license API activation — falling back to "LocalStack" when the file is unavailable. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/container/label.go | 40 ++++++++++++++++-- internal/container/label_test.go | 69 ++++++++++++++++++++++++++++++++ internal/ui/run.go | 8 +++- test/integration/start_test.go | 49 +++++++++++++++++++++++ 4 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 internal/container/label_test.go diff --git a/internal/container/label.go b/internal/container/label.go index 034b4e16..78310524 100644 --- a/internal/container/label.go +++ b/internal/container/label.go @@ -2,7 +2,9 @@ package container import ( "context" + "encoding/json" "os" + "path/filepath" stdruntime "runtime" "time" @@ -36,6 +38,16 @@ func ResolveEmulatorLabel(ctx context.Context, client api.PlatformAPI, container c := containers[0] + // Self-validating emulators never hit the license API (it has no catalog + // entry for their products); their plan comes from the license the + // container activated itself. + if c.Type.SelfValidatesLicense() { + if label, ok := activatedLicenseLabel(c, logger); ok { + return label, true + } + return "LocalStack", false + } + productName, err := c.ProductName() if err != nil { return NoLicenseLabel, false @@ -43,9 +55,6 @@ func ResolveEmulatorLabel(ctx context.Context, client api.PlatformAPI, container tag := c.Tag if tag == "" || tag == "latest" { - if c.Type.SelfValidatesLicense() { - return "LocalStack", false - } if resolvedVersion == "" { return NoLicenseLabel, false } @@ -72,3 +81,28 @@ func ResolveEmulatorLabel(ctx context.Context, client api.PlatformAPI, container } return NoLicenseLabel, false } + +// activatedLicenseLabel reads the license a self-validating emulator activated +// during startup and cached at /var/lib/localstack/cache/license.json — inside +// the volume lstk mounts, so it is readable from the host. +func activatedLicenseLabel(c config.ContainerConfig, logger log.Logger) (string, bool) { + volumeDir, err := c.VolumeDir() + if err != nil { + return "", false + } + licensePath := filepath.Join(volumeDir, "cache", "license.json") + data, err := os.ReadFile(licensePath) + if err != nil { + logger.Info("could not read activated license for header: %v", err) + return "", false + } + var lic api.LicenseResponse + if err := json.Unmarshal(data, &lic); err != nil { + logger.Info("could not parse activated license %s: %v", licensePath, err) + return "", false + } + if plan := lic.PlanDisplayName(); plan != "" { + return "LocalStack " + plan, true + } + return "", false +} diff --git a/internal/container/label_test.go b/internal/container/label_test.go new file mode 100644 index 00000000..ccf053d5 --- /dev/null +++ b/internal/container/label_test.go @@ -0,0 +1,69 @@ +package container + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeActivatedLicense(t *testing.T, volumeDir, content string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(volumeDir, "cache"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(volumeDir, "cache", "license.json"), []byte(content), 0600)) +} + +// The nil PlatformAPI client in these tests doubles as an assertion: the +// self-validating path must never call the license API (it would panic). +func TestResolveEmulatorLabelReadsActivatedLicenseForSelfValidating(t *testing.T) { + volumeDir := t.TempDir() + writeActivatedLicense(t, volumeDir, `{"license_type":"enterprise","license_status":"ACTIVE"}`) + + containers := []config.ContainerConfig{{Type: config.EmulatorAzure, Tag: "latest", Volume: volumeDir}} + label, ok := ResolveEmulatorLabel(context.Background(), nil, containers, "token", "", log.Nop()) + + assert.Equal(t, "LocalStack Enterprise", label) + assert.True(t, ok, "label resolved from an activated license should be cached") +} + +func TestResolveEmulatorLabelReadsActivatedLicenseForPinnedTag(t *testing.T) { + volumeDir := t.TempDir() + writeActivatedLicense(t, volumeDir, `{"license_type":"ultimate"}`) + + containers := []config.ContainerConfig{{Type: config.EmulatorSnowflake, Tag: "1.2.3", Volume: volumeDir}} + label, ok := ResolveEmulatorLabel(context.Background(), nil, containers, "token", "", log.Nop()) + + assert.Equal(t, "LocalStack Ultimate", label) + assert.True(t, ok) +} + +func TestResolveEmulatorLabelFallsBackWhenActivatedLicenseMissing(t *testing.T) { + containers := []config.ContainerConfig{{Type: config.EmulatorAzure, Tag: "latest", Volume: t.TempDir()}} + label, ok := ResolveEmulatorLabel(context.Background(), nil, containers, "token", "", log.Nop()) + + assert.Equal(t, "LocalStack", label) + assert.False(t, ok, "fallback label must not be cached") +} + +func TestResolveEmulatorLabelFallsBackWhenActivatedLicenseInvalid(t *testing.T) { + for name, content := range map[string]string{ + "corrupt JSON": `{not json`, + "no license_type": `{"license_status":"ACTIVE"}`, + } { + t.Run(name, func(t *testing.T) { + volumeDir := t.TempDir() + writeActivatedLicense(t, volumeDir, content) + + containers := []config.ContainerConfig{{Type: config.EmulatorAzure, Tag: "latest", Volume: volumeDir}} + label, ok := ResolveEmulatorLabel(context.Background(), nil, containers, "token", "", log.Nop()) + + assert.Equal(t, "LocalStack", label) + assert.False(t, ok) + }) + } +} diff --git a/internal/ui/run.go b/internal/ui/run.go index 0273f45e..7474caab 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -129,8 +129,12 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { } } // Empty resolvedVersion means the container was already running and Start - // returned early — use the cached label rather than re-resolving. - if resolvedVersion == "" { + // returned early — use the cached label rather than re-resolving. Self- + // validating emulators never resolve a version; their label comes from the + // license file in the container volume, so re-resolve it on every start. + containers := runOpts.StartOptions.Containers + selfValidating := len(containers) > 0 && containers[0].Type.SelfValidatesLicense() + if resolvedVersion == "" && !selfValidating { go func() { labelCh <- config.CachedPlanLabel() }() } else { go container.ResolveAndCacheLabel(ctx, runOpts.StartOptions, resolvedVersion, labelCh) diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 74ec85be..9c0b4c59 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -1566,6 +1566,55 @@ func TestStartCommandSucceedsForAzure(t *testing.T) { "azure start should print a tip line like AWS does") } +func TestStartCommandForAzureShowsPlanFromActivatedLicense(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + requireDocker(t) + cleanup() + cleanupAzure() + t.Cleanup(cleanup) + t.Cleanup(cleanupAzure) + + ctx := testContext(t) + + // Azure self-validates its license, so the header plan label must come from + // the license the container activated into its volume — not the license API. + const fakeImage = "localstack/localstack-azure:test-fake" + _, err := dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: testImage, Target: fakeImage}) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = dockerClient.ImageRemove(context.Background(), fakeImage, client.ImageRemoveOptions{}) + }) + startExternalContainer(t, ctx, fakeImage, "localstack-external-azure", "4566") + + home := t.TempDir() + volumeDir := filepath.Join(home, "volume") + require.NoError(t, os.MkdirAll(filepath.Join(volumeDir, "cache"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(volumeDir, "cache", "license.json"), + []byte(`{"license_type":"enterprise"}`), 0600)) + + configContent := fmt.Sprintf(` +[[containers]] +type = "azure" +tag = "latest" +port = "4566" +volume = %q +`, volumeDir) + configFile := filepath.Join(home, "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + // Fresh HOME = no cached plan_label, so the label must come from the volume license. + stdout, err := runLstkInPTY(t, ctx, + env.Environ(testEnvWithHome(home, "")).Without(env.AuthToken).With(env.AuthToken, "fake-token"), + "start", "--config", configFile, + ) + require.NoError(t, err, "lstk start failed: %s", stdout) + assert.Contains(t, stdout, "already running") + assert.Contains(t, stdout, "LocalStack Enterprise", + "azure header should show the plan from the license the emulator activated into its volume") +} + func TestStartCommandForAzureSkipsLicenseValidation(t *testing.T) { requireDocker(t) _ = env.Require(t, env.AuthToken) From 5b605af1a85616d9a7281ad9d6874c3b1f351d55 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Tue, 4 Aug 2026 13:43:42 +0300 Subject: [PATCH 2/2] Keep self-validating version handling consistent Co-Authored-By: Claude --- internal/container/label.go | 5 +++-- internal/container/label_test.go | 16 ++++++++++------ internal/container/start.go | 19 ++++++++++++------- internal/container/start_test.go | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 15 deletions(-) diff --git a/internal/container/label.go b/internal/container/label.go index 78310524..b73b4532 100644 --- a/internal/container/label.go +++ b/internal/container/label.go @@ -83,11 +83,12 @@ func ResolveEmulatorLabel(ctx context.Context, client api.PlatformAPI, container } // activatedLicenseLabel reads the license a self-validating emulator activated -// during startup and cached at /var/lib/localstack/cache/license.json — inside -// the volume lstk mounts, so it is readable from the host. +// under /var/lib/localstack/cache/license.json, which lstk bind-mounts from the +// configured host volume. func activatedLicenseLabel(c config.ContainerConfig, logger log.Logger) (string, bool) { volumeDir, err := c.VolumeDir() if err != nil { + logger.Info("could not resolve emulator volume for header: %v", err) return "", false } licensePath := filepath.Join(volumeDir, "cache", "license.json") diff --git a/internal/container/label_test.go b/internal/container/label_test.go index ccf053d5..0f38004d 100644 --- a/internal/container/label_test.go +++ b/internal/container/label_test.go @@ -32,14 +32,18 @@ func TestResolveEmulatorLabelReadsActivatedLicenseForSelfValidating(t *testing.T } func TestResolveEmulatorLabelReadsActivatedLicenseForPinnedTag(t *testing.T) { - volumeDir := t.TempDir() - writeActivatedLicense(t, volumeDir, `{"license_type":"ultimate"}`) + for _, emulatorType := range []config.EmulatorType{config.EmulatorSnowflake, config.EmulatorAzure} { + t.Run(string(emulatorType), func(t *testing.T) { + volumeDir := t.TempDir() + writeActivatedLicense(t, volumeDir, `{"license_type":"ultimate"}`) - containers := []config.ContainerConfig{{Type: config.EmulatorSnowflake, Tag: "1.2.3", Volume: volumeDir}} - label, ok := ResolveEmulatorLabel(context.Background(), nil, containers, "token", "", log.Nop()) + containers := []config.ContainerConfig{{Type: emulatorType, Tag: "1.2.3", Volume: volumeDir}} + label, ok := ResolveEmulatorLabel(context.Background(), nil, containers, "token", "", log.Nop()) - assert.Equal(t, "LocalStack Ultimate", label) - assert.True(t, ok) + assert.Equal(t, "LocalStack Ultimate", label) + assert.True(t, ok) + }) + } } func TestResolveEmulatorLabelFallsBackWhenActivatedLicenseMissing(t *testing.T) { diff --git a/internal/container/start.go b/internal/container/start.go index 2bc43e09..262669a8 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -309,14 +309,10 @@ func startOnce(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts S } licenseRefreshed := prePullRefreshed || postPullRefreshed - // For pinned containers (postPullContainers was empty), use the tag directly. + // For pinned containers that use lstk's platform license flow, use the tag + // directly. Self-validating emulators do not resolve a version through that flow. if resolvedVersion == "" { - for _, c := range containers { - if c.EmulatorType != config.EmulatorSnowflake && c.Tag != "" && c.Tag != "latest" { - resolvedVersion = c.Tag - break - } - } + resolvedVersion = resolvedPinnedVersion(containers) } if err := startWithLicenseRetry(ctx, rt, sink, opts, interactive, containers, pulled, token, licenseFilePath, licenseRefreshed); err != nil { @@ -331,6 +327,15 @@ func startOnce(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts S return resolvedVersion, runPostStartSetups(ctx, rt, sink, opts.Containers, interactive, opts.LocalStackHost, opts.WebAppURL, setups) } +func resolvedPinnedVersion(containers []runtime.ContainerConfig) string { + for _, c := range containers { + if !c.EmulatorType.SelfValidatesLicense() && c.Tag != "" && c.Tag != "latest" { + return c.Tag + } + } + return "" +} + func runPostStartSetups(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []config.ContainerConfig, interactive bool, localStackHost, webAppURL string, setups map[config.EmulatorType]postStartSetupFunc) error { // build ordered list of unique types, keeping the first container config for each firstByType := map[config.EmulatorType]config.ContainerConfig{} diff --git a/internal/container/start_test.go b/internal/container/start_test.go index fd27f66b..92d242ff 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -65,6 +65,38 @@ func TestCheckSingleContainer(t *testing.T) { assert.Contains(t, err.Error(), "only one is supported at a time") } +func TestResolvedPinnedVersion(t *testing.T) { + tests := []struct { + name string + containers []runtime.ContainerConfig + want string + }{ + { + name: "platform-validated AWS tag", + containers: []runtime.ContainerConfig{{EmulatorType: config.EmulatorAWS, Tag: "2026.7"}}, + want: "2026.7", + }, + { + name: "latest AWS tag", + containers: []runtime.ContainerConfig{{EmulatorType: config.EmulatorAWS, Tag: "latest"}}, + }, + { + name: "self-validating Snowflake tag", + containers: []runtime.ContainerConfig{{EmulatorType: config.EmulatorSnowflake, Tag: "2026.7"}}, + }, + { + name: "self-validating Azure tag", + containers: []runtime.ContainerConfig{{EmulatorType: config.EmulatorAzure, Tag: "2026.7"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, resolvedPinnedVersion(tt.containers)) + }) + } +} + func TestStart_ReturnsEarlyIfRuntimeUnhealthy(t *testing.T) { ctrl := gomock.NewController(t) mockRT := runtime.NewMockRuntime(ctrl)