Skip to content
Draft
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
40 changes: 37 additions & 3 deletions internal/container/label.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package container

import (
"context"
"encoding/json"
"os"
"path/filepath"
stdruntime "runtime"
"time"

Expand Down Expand Up @@ -36,16 +38,23 @@ 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
}

tag := c.Tag
if tag == "" || tag == "latest" {
if c.Type.SelfValidatesLicense() {
return "LocalStack", false
}
if resolvedVersion == "" {
return NoLicenseLabel, false
}
Expand All @@ -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
}
69 changes: 69 additions & 0 deletions internal/container/label_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
8 changes: 6 additions & 2 deletions internal/ui/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions test/integration/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading