From f01a30d5dfacd0392089550024b8c990f5c61ec5 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 4 Jun 2026 17:15:20 +0300 Subject: [PATCH] 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 9321e436..63fa9ba2 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -112,8 +112,12 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { return } // 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 a12918a5..cbeb3975 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -758,6 +758,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)