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)