From 071e26569de9e89402630adc6797ba8b82693734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Fri, 17 Jul 2026 14:27:20 +0200 Subject: [PATCH 1/9] fix(license): recover from stale tokens and cached licenses without manual logout A rejected token or a stale cached license.json previously required a manual `lstk logout` before the next start could succeed (DEVX-658). - Definitive license rejections (HTTP 400/401/403) now delete the cached license.json, and interactive starts offer an in-place re-login (auth.Relogin) followed by one retried start with the fresh token. Non-interactive starts emit an actionable ErrorEvent instead of a raw stderr error. - Non-definitive license server responses (5xx outages, 407 from corporate proxies) no longer block the start; the pre-flight degrades and the emulator validates the license at startup, like transport failures already did. - When the container exits with a license failure while a cached license.json that this run did not refresh was mounted (pre-flight skipped, e.g. image already local), the cache is dropped, re-validated against the license server, and the start is retried once. Fixes DEVX-658 Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- internal/auth/auth.go | 23 ++ internal/auth/auth_test.go | 41 +++ internal/container/CLAUDE.md | 10 +- internal/container/start.go | 281 ++++++++++++++++---- internal/container/start_test.go | 260 +++++++++++++++++- test/integration/license_test.go | 122 ++++++++- test/integration/start_test.go | 4 +- test/integration/version_resolution_test.go | 8 +- 9 files changed, 683 insertions(+), 68 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 626e09d4..0812e44e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,7 @@ Each `[[containers]]` block may set an optional `image` (override the default Do # Offline / Enterprise Environments -There is no `--offline` flag. Instead `container.Start` degrades gracefully when internet requests fail (Docker Hub unreachable, proxy/TLS interception, license server unreachable): local images are used when pulls fail, and the license pre-flight is skipped on transport-level failures or unsupported-tag rejections so the container validates its own bundled license. The exact fallback rules live in `internal/container/CLAUDE.md`; pair them with a custom `image` in the config to point at a locally loaded image or an internal-registry mirror. +There is no `--offline` flag. Instead `container.Start` degrades gracefully when internet requests fail (Docker Hub unreachable, proxy/TLS interception, license server unreachable): local images are used when pulls fail, and the license pre-flight is skipped on transport-level failures, non-definitive server responses (5xx/407), or unsupported-tag rejections so the container validates its own bundled license. Definitive license rejections (HTTP 400/401/403) drop the cached license and offer an in-place re-login instead of requiring a manual `lstk logout` (DEVX-658). The exact fallback and retry rules live in `internal/container/CLAUDE.md`; pair them with a custom `image` in the config to point at a locally loaded image or an internal-registry mirror. # Emulator Setup Commands diff --git a/internal/auth/auth.go b/internal/auth/auth.go index c1680e39..a5081d39 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -61,6 +61,29 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) { return "", fmt.Errorf("authentication required: set LOCALSTACK_AUTH_TOKEN or run in interactive mode") } + return a.loginAndStore(ctx) +} + +// Relogin discards the stored auth token and cached license file, then runs the +// login flow again. Used when the platform definitively rejects the current +// token (e.g. it predates a license purchase), so the user doesn't have to run +// `lstk logout` manually before retrying. +func (a *Auth) Relogin(ctx context.Context) (string, error) { + if !a.allowLogin { + return "", fmt.Errorf("authentication required: set LOCALSTACK_AUTH_TOKEN or run in interactive mode") + } + + if err := a.tokenStorage.DeleteAuthToken(); err != nil && !errors.Is(err, ErrTokenNotFound) { + a.sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not remove stored token: %v", err)}) + } + if a.licenseFilePath != "" { + _ = os.Remove(a.licenseFilePath) + } + + return a.loginAndStore(ctx) +} + +func (a *Auth) loginAndStore(ctx context.Context) (string, error) { token, err := a.login.Login(ctx) if err != nil { if errors.Is(err, context.Canceled) { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 64d2b424..7489c5cf 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "path/filepath" "strings" "testing" @@ -57,3 +58,43 @@ func TestGetToken_ReturnsTokenWhenKeyringStoreFails(t *testing.T) { return false }) } + +func TestRelogin_DiscardsTokenAndLicenseThenLogsIn(t *testing.T) { + ctrl := gomock.NewController(t) + mockStorage := NewMockAuthTokenStorage(ctrl) + mockLogin := NewMockLoginProvider(ctrl) + + licensePath := filepath.Join(t.TempDir(), "license.json") + if err := os.WriteFile(licensePath, []byte(`{"license":"stale"}`), 0600); err != nil { + t.Fatal(err) + } + + auth := &Auth{ + tokenStorage: mockStorage, + login: mockLogin, + sink: output.SinkFunc(func(output.Event) {}), + allowLogin: true, + licenseFilePath: licensePath, + } + + mockStorage.EXPECT().DeleteAuthToken().Return(nil) + mockLogin.EXPECT().Login(gomock.Any()).Return("fresh-token", nil) + mockStorage.EXPECT().SetAuthToken("fresh-token").Return(nil) + + token, err := auth.Relogin(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "fresh-token", token) + assert.NoFileExists(t, licensePath, "relogin must drop the cached license file") +} + +func TestRelogin_FailsWhenLoginNotAllowed(t *testing.T) { + auth := &Auth{ + sink: output.SinkFunc(func(output.Event) {}), + allowLogin: false, + } + + _, err := auth.Relogin(context.Background()) + + assert.ErrorContains(t, err, "authentication required") +} diff --git a/internal/container/CLAUDE.md b/internal/container/CLAUDE.md index d0e43646..c9c27b56 100644 --- a/internal/container/CLAUDE.md +++ b/internal/container/CLAUDE.md @@ -16,8 +16,16 @@ There is no `--offline` flag. Instead `container.Start` degrades gracefully when - **Image pull**: if `rt.PullImage` fails but `rt.ImageExists` reports the image is already present locally, lstk warns and uses the local image instead of failing. - **License pre-flight (image already local)**: when a pinned image is already present locally — so `pullImages` won't pull it — `tryPrePullLicenseValidation` skips the pre-flight check entirely (gated on `rt.ImageExists`), since the redundant network round-trip would otherwise block a fully-offline start; the container validates its own bundled license at startup. This is symmetric with the skip-pull behaviour above. -- **License pre-flight (server unreachable)**: when a check does run, `validateLicense` distinguishes a definitive server rejection (`*api.LicenseError`, e.g. HTTP 403/400 — still fatal) from a transport-level failure (any other error — offline/proxy/cert). On a transport failure it skips the pre-flight check and lets the container validate its own bundled license at startup. +- **License pre-flight (server unreachable or erroring)**: when a check does run, `validateLicense` distinguishes a definitive server rejection (`isDefinitiveLicenseRejection`: HTTP 400/401/403 — fatal) from everything else: a transport-level failure (offline/proxy/cert) *and* any non-definitive status (5xx outage, 407 from a corporate proxy, ...) both skip the pre-flight check and let the container validate its own bundled license at startup. - **License pre-flight (unsupported tag)**: when the server rejects the image tag *format* itself (`IsUnsupportedTag` — a 400 whose detail contains `licensing.license.format`, e.g. `dev` nightlies or custom enterprise-mirror tags), that is not a verdict on the license, so `validateLicense` skips the pre-flight with a warning and lets the container validate its own bundled license at startup — the same degradation as a transport failure. Genuine token/subscription rejections stay fatal. The invariant across all these paths: the pre-flight is a fail-fast optimization and must never block a start the container itself would accept. - **Telemetry/update checks** are already best-effort and fail silently when offline. `runtime.PullImage` always closes its `progress` channel (even when `ImagePull` fails early) so the local-image fallback path doesn't leak the progress goroutine. Pair this with a custom `image` in the config to point at a locally loaded image or an internal-registry mirror. + +## License errors: cache invalidation and retry (DEVX-658) + +A stale cached license (`license.json`) or a stale token (e.g. one that predates a license purchase) must never require a manual `lstk logout` to recover: + +- **Definitive rejection (HTTP 400/401/403) in the pre-flight**: `validateLicense` deletes the cached `license.json` (the verdict invalidates it — a later start whose pre-flight is skipped must not keep mounting the stale copy). In interactive mode, `container.Start` then prompts to log in again (`auth.Relogin`: drops the stored token + cached license, reruns the browser login) and retries the start once with the fresh token. In non-interactive mode it emits an `ErrorEvent` pointing at `lstk logout && lstk login` / `LOCALSTACK_AUTH_TOKEN` and returns a silent error. +- **Startup license failure with a stale mounted cache**: when the container exits with license-related logs while a cached `license.json` that this run did *not* refresh was mounted (pre-flight skipped, e.g. image already local), `startContainers` returns a `licenseStartupError` instead of rendering the failure through `startupMonitor.handleFailure`, and `startWithLicenseRetry` drops the cache, re-validates against the license server (forced, bypassing the image-local skip), and retries the start once. No retry when the license was freshly fetched this run or no cache was mounted; a repeat failure is rendered by `handleFailure` as usual. The self-validating "not covered by your license" case keeps its dedicated messaging and is never retried. +- `StartOptions.AuthOptions` threads `auth.Option`s (e.g. `WithBrowserOpener`) into the internally constructed `auth.Auth` so tests of the re-login path never open a real browser tab. diff --git a/internal/container/start.go b/internal/container/start.go index f82e8721..26fa1dc7 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -47,6 +47,9 @@ type StartOptions struct { StartupTimeout time.Duration // zero uses the per-mode default (resolveStartupTimeout) Logger log.Logger Telemetry *telemetry.Client + // AuthOptions is passed through to auth.New; tests use it to inject a fake + // browser opener so a re-login flow never opens a real tab. + AuthOptions []auth.Option } func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, interactive bool) (string, error) { @@ -67,11 +70,16 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start return "", output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) } + licenseFilePath, err := config.LicenseFilePath() + if err != nil { + return "", fmt.Errorf("failed to determine license file path: %w", err) + } + tokenStorage, err := auth.NewTokenStorage(opts.ForceFileKeyring, opts.Logger) if err != nil { return "", fmt.Errorf("failed to initialize token storage: %w", err) } - a := auth.New(sink, opts.PlatformClient, tokenStorage, opts.AuthToken, opts.WebAppURL, interactive, "") + a := auth.New(sink, opts.PlatformClient, tokenStorage, opts.AuthToken, opts.WebAppURL, interactive, licenseFilePath, opts.AuthOptions...) token, err := a.GetToken(ctx) if err != nil { @@ -80,6 +88,37 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start opts.Telemetry.SetAuthToken(token) + version, err := startOnce(ctx, rt, sink, opts, interactive, token, licenseFilePath, false) + var licErr *api.LicenseError + if err == nil || !errors.As(err, &licErr) { + return version, err + } + + // The platform definitively rejected the token/license (validateLicense has + // already dropped the cached license.json). The rejected token may simply + // predate a license purchase or plan change (DEVX-658), so offer a fresh + // login instead of requiring a manual `lstk logout` before the next run. + if interactive && promptRelogin(ctx, sink, licErr) { + newToken, loginErr := a.Relogin(ctx) + if loginErr != nil { + return "", loginErr + } + opts.Telemetry.SetAuthToken(newToken) + return startOnce(ctx, rt, sink, opts, interactive, newToken, licenseFilePath, true) + } + + sink.Emit(output.ErrorEvent{ + Title: "License validation failed", + Summary: licErr.Message, + Actions: []output.ErrorAction{ + {Label: "Log in again to refresh your credentials:", Value: "lstk logout && lstk login"}, + {Label: "Or provide a valid token via the environment variable:", Value: "LOCALSTACK_AUTH_TOKEN"}, + }, + }) + return "", output.NewSilentError(err) +} + +func startOnce(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, interactive bool, token, licenseFilePath string, forceLicenseValidation bool) (string, error) { tel := opts.Telemetry hostEnv, droppedEnv := filterHostEnv(os.Environ()) @@ -202,7 +241,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start } } - containers, err = selectContainersToStart(ctx, rt, sink, tel, containers, opts.LocalStackHost, opts.WebAppURL) + containers, err := selectContainersToStart(ctx, rt, sink, tel, containers, opts.LocalStackHost, opts.WebAppURL) if err != nil { return "", err } @@ -210,15 +249,10 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start return "", nil } - licenseFilePath, err := config.LicenseFilePath() - if err != nil { - return "", fmt.Errorf("failed to determine license file path: %w", err) - } - // Validate licenses before pulling. Pinned tags are validated immediately — // unless the image is already present locally, in which case both the pull and // the pre-flight check are skipped. "latest" tags defer to post-pull validation. - postPullContainers, err := tryPrePullLicenseValidation(ctx, rt, sink, opts, containers, token, licenseFilePath) + postPullContainers, prePullRefreshed, err := tryPrePullLicenseValidation(ctx, rt, sink, opts, containers, token, licenseFilePath, forceLicenseValidation) if err != nil { return "", err } @@ -229,10 +263,11 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start } // Validate "latest" containers by inspecting the pulled image for its version. - resolvedVersion, err := validateLicensesFromImages(ctx, rt, sink, opts, postPullContainers, token, licenseFilePath) + resolvedVersion, postPullRefreshed, err := validateLicensesFromImages(ctx, rt, sink, opts, postPullContainers, token, licenseFilePath) if err != nil { return "", err } + licenseRefreshed := prePullRefreshed || postPullRefreshed // For pinned containers (postPullContainers was empty), use the tag directly. if resolvedVersion == "" { @@ -244,18 +279,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start } } - // Mount the cached license file into each container if available. - if _, err := os.Stat(licenseFilePath); err == nil { - for i := range containers { - containers[i].Binds = append(containers[i].Binds, runtime.BindMount{ - HostPath: licenseFilePath, - ContainerPath: "/etc/localstack/conf.d/license.json", - ReadOnly: true, - }) - } - } - - if err := startContainers(ctx, rt, sink, tel, containers, pulled, opts.StartupTimeout, interactive); err != nil { + if err := startWithLicenseRetry(ctx, rt, sink, opts, interactive, containers, pulled, token, licenseFilePath, licenseRefreshed); err != nil { return "", err } @@ -468,10 +492,13 @@ func pullImage(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *t } // Validates licenses before pulling for containers with pinned tags, except those -// whose image is already present locally (not pulled, so the check is skipped too). +// whose image is already present locally (not pulled, so the check is skipped too — +// unless force is set, e.g. when retrying after a startup license failure). // "latest" and empty tags are deferred to post-pull validation via image inspection. -func tryPrePullLicenseValidation(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string) ([]runtime.ContainerConfig, error) { +// The bool reports whether any validation refreshed the cached license file. +func tryPrePullLicenseValidation(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string, force bool) ([]runtime.ContainerConfig, bool, error) { var needsPostPull []runtime.ContainerConfig + var refreshed bool for _, c := range containers { if c.EmulatorType.SelfValidatesLicense() { continue @@ -483,24 +510,30 @@ func tryPrePullLicenseValidation(ctx context.Context, rt runtime.Runtime, sink o // blocker in offline/enterprise environments — when no network round-trip // happens at all and the container validates the license at // startup. A probe error is non-fatal here; fall through to the check. - if exists, err := rt.ImageExists(ctx, c.Image); err == nil && exists { - continue + if !force { + if exists, err := rt.ImageExists(ctx, c.Image); err == nil && exists { + continue + } } - if err := validateLicense(ctx, sink, opts, c, token, licenseFilePath); err != nil { - return nil, err + wrote, err := validateLicense(ctx, sink, opts, c, token, licenseFilePath) + if err != nil { + return nil, false, err } + refreshed = refreshed || wrote continue } needsPostPull = append(needsPostPull, c) } - return needsPostPull, nil + return needsPostPull, refreshed, nil } // Inspects each pulled image for its version, then validates the license. -// Returns the resolved version of the first validated container, empty string if none. -func validateLicensesFromImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string) (string, error) { +// Returns the resolved version of the first validated container (empty string if +// none) and whether any validation refreshed the cached license file. +func validateLicensesFromImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string) (string, bool, error) { var firstVersion string + var refreshed bool for _, c := range containers { if c.EmulatorType.SelfValidatesLicense() { continue @@ -508,20 +541,60 @@ func validateLicensesFromImages(ctx context.Context, rt runtime.Runtime, sink ou v, err := rt.GetImageVersion(ctx, c.Image) if err != nil { - return "", fmt.Errorf("could not resolve version from image %s: %w", c.Image, err) + return "", false, fmt.Errorf("could not resolve version from image %s: %w", c.Image, err) } c.Tag = v if firstVersion == "" { firstVersion = v } - if err := validateLicense(ctx, sink, opts, c, token, licenseFilePath); err != nil { - return "", err + wrote, err := validateLicense(ctx, sink, opts, c, token, licenseFilePath) + if err != nil { + return "", false, err } + refreshed = refreshed || wrote + } + return firstVersion, refreshed, nil +} + +// startWithLicenseRetry mounts the cached license file and starts the +// containers. When a container exits with a license failure while a cached +// license.json that this run did not refresh was mounted (the pre-flight was +// skipped, e.g. because the image was already local), the cache may predate a +// license purchase or plan change (DEVX-658): it is dropped, re-validated +// against the license server, and the start is retried once. +func startWithLicenseRetry(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, interactive bool, containers []runtime.ContainerConfig, pulled map[string]bool, token, licenseFilePath string, licenseRefreshed bool) error { + // A retry only makes sense when a cached license this run did not refresh + // was mounted — a freshly fetched license failing at startup is a real + // verdict, and refetching it would loop for nothing. + licenseMounted := mountCachedLicense(containers, licenseFilePath) + retryCandidate := licenseMounted && !licenseRefreshed + + err := startContainers(ctx, rt, sink, opts.Telemetry, containers, pulled, opts.StartupTimeout, interactive, retryCandidate) + if err == nil { + return nil + } + var licStartErr *licenseStartupError + if !errors.As(err, &licStartErr) { + return err } - return firstVersion, nil + + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: "License rejected at startup — refreshing the cached license and retrying"}) + if rmErr := os.Remove(licenseFilePath); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) { + opts.Logger.Error("failed to remove cached license file: %v", rmErr) + } + retryPostPull, _, verr := tryPrePullLicenseValidation(ctx, rt, sink, opts, containers, token, licenseFilePath, true) + if verr != nil { + return verr + } + if _, _, verr := validateLicensesFromImages(ctx, rt, sink, opts, retryPostPull, token, licenseFilePath); verr != nil { + return verr + } + stripLicenseMount(containers) + mountCachedLicense(containers, licenseFilePath) + return startContainers(ctx, rt, sink, opts.Telemetry, containers, pulled, opts.StartupTimeout, interactive, false) } -func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig, pulled map[string]bool, startupTimeout time.Duration, interactive bool) error { +func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig, pulled map[string]bool, startupTimeout time.Duration, interactive bool, licenseRetryCandidate bool) error { monitor := newStartupMonitor(rt, sink, tel, startupTimeout, interactive) for _, c := range containers { startTime := time.Now() @@ -586,6 +659,23 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, logs = direct } } + // When the caller can retry with a refreshed license (a stale cached + // license.json was mounted, DEVX-658), return the classification + // instead of rendering the failure — startWithLicenseRetry retries + // once, and a repeat failure comes back through handleFailure. The + // self-validating "not covered" case keeps its dedicated messaging. + var exitErr *containerExitedError + notCovered := c.EmulatorType.SelfValidatesLicense() && strings.Contains(logs, "not covered by your license") + if licenseRetryCandidate && !notCovered && errors.As(err, &exitErr) && logsIndicateLicenseFailure(logs) { + tel.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{ + EventType: telemetry.LifecycleStartError, + Emulator: c.EmulatorType, + Image: c.Image, + ErrorCode: telemetry.ErrCodeLicenseInvalid, + ErrorMsg: err.Error(), + }) + return &licenseStartupError{name: "LocalStack", logs: logs} + } return monitor.handleFailure(ctx, c, err, logs) } sink.Emit(output.SpinnerStop()) @@ -809,7 +899,9 @@ func emitPortInUseError(sink output.Sink, port string) { }) } -func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, containerConfig runtime.ContainerConfig, token, licenseFilePath string) error { +// validateLicense runs the license pre-flight and caches the license file on +// success. The bool reports whether the cached license file was (re)written. +func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, containerConfig runtime.ContainerConfig, token, licenseFilePath string) (bool, error) { version := containerConfig.Tag sink.Emit(output.SpinnerStart("Checking license")) @@ -836,7 +928,7 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c // propagate it instead of degrading. The client's own request timeout is // distinct from ctx and still falls through to the offline fallback below. if ctx.Err() != nil { - return ctx.Err() + return false, ctx.Err() } var licErr *api.LicenseError if !errors.As(err, &licErr) { @@ -846,7 +938,7 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c // the license at startup instead of blocking the start. opts.Logger.Info("license server unreachable, deferring license validation to the emulator: %v", err) sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: "Could not reach the license server; the emulator will validate the license once it starts"}) - return nil + return false, nil } if licErr.IsUnsupportedTag { // The server rejecting the tag *format* (e.g. a "dev" nightly or a custom @@ -859,16 +951,27 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c "The license server does not support tag %q; the emulator will validate the license once it starts. If this is unintended, %s", version, config.TagSuggestion(), )}) - return nil + return false, nil + } + if !isDefinitiveLicenseRejection(licErr.Status) { + // A 5xx outage, a 407 from a corporate proxy, or any other unexpected + // status is not a verdict on the license. Degrade like the transport + // failure above and let the container validate the license at startup. + opts.Logger.Info("license server returned HTTP %d, deferring license validation to the emulator: %s", licErr.Status, licErr.Detail) + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf( + "The license server returned an unexpected response (HTTP %d); the emulator will validate the license once it starts", licErr.Status, + )}) + return false, nil } - // Known limitation: any other *api.LicenseError — i.e. any non-200 HTTP - // response, including a 5xx or a 407 from a corporate proxy — is treated as a - // definitive verdict and stays fatal here; only connection-level failures and - // unsupported tags (both handled above) degrade. Gating this on licErr.Status - // is tracked as follow-up. if licErr.Detail != "" { opts.Logger.Error("license server response (HTTP %d): %s", licErr.Status, licErr.Detail) } + // A definitive rejection also invalidates the cached license: drop it so a + // later start (whose pre-flight may be skipped, e.g. when the image is + // already local) cannot keep failing against the stale copy (DEVX-658). + if rmErr := os.Remove(licenseFilePath); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) { + opts.Logger.Error("failed to remove cached license file: %v", rmErr) + } opts.Telemetry.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{ EventType: telemetry.LifecycleStartError, Emulator: containerConfig.EmulatorType, @@ -876,7 +979,7 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c ErrorCode: telemetry.ErrCodeLicenseInvalid, ErrorMsg: err.Error(), }) - return fmt.Errorf("license validation failed for %s:%s: %w", containerConfig.ProductName, version, err) + return false, fmt.Errorf("license validation failed for %s:%s: %w", containerConfig.ProductName, version, err) } sink.Emit(output.SpinnerStop()) @@ -891,10 +994,65 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c opts.Logger.Error("failed to create license cache directory: %v", err) } else if err := os.WriteFile(licenseFilePath, licenseResp.RawBytes, 0600); err != nil { opts.Logger.Error("failed to cache license file: %v", err) + } else { + return true, nil } } - return nil + return false, nil +} + +// isDefinitiveLicenseRejection reports whether an HTTP status from the license +// server is a verdict on the token/license itself. Anything else (a 5xx outage, +// a 407 from a corporate proxy, ...) is not, and degrades to container-side +// validation instead of blocking the start. +func isDefinitiveLicenseRejection(status int) bool { + return status == http.StatusBadRequest || status == http.StatusUnauthorized || status == http.StatusForbidden +} + +// promptRelogin asks the user whether to run a fresh login after a definitive +// license rejection. Only call in interactive mode: the plain sink never +// answers input requests, so waiting on one would hang. +func promptRelogin(ctx context.Context, sink output.Sink, licErr *api.LicenseError) bool { + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("License validation failed: %s", licErr.Message)}) + responseCh := make(chan output.InputResponse, 1) + sink.Emit(output.UserInputRequestEvent{ + Prompt: "Log in again to refresh your credentials?", + Options: []output.InputOption{{Key: "enter", Label: "Press ENTER to log in again"}}, + ResponseCh: responseCh, + }) + select { + case resp := <-responseCh: + return !resp.Cancelled + case <-ctx.Done(): + return false + } +} + +const licenseMountPath = "/etc/localstack/conf.d/license.json" + +// mountCachedLicense mounts the cached license file read-only into each +// container when it exists on disk, and reports whether it did. +func mountCachedLicense(containers []runtime.ContainerConfig, licenseFilePath string) bool { + if _, err := os.Stat(licenseFilePath); err != nil { + return false + } + for i := range containers { + containers[i].Binds = append(containers[i].Binds, runtime.BindMount{ + HostPath: licenseFilePath, + ContainerPath: licenseMountPath, + ReadOnly: true, + }) + } + return true +} + +func stripLicenseMount(containers []runtime.ContainerConfig) { + for i := range containers { + containers[i].Binds = slices.DeleteFunc(containers[i].Binds, func(b runtime.BindMount) bool { + return b.ContainerPath == licenseMountPath + }) + } } // licenseNotCoveredError is returned by startupMonitor.handleFailure when the container exits @@ -905,6 +1063,37 @@ func (e *licenseNotCoveredError) Error() string { return "license does not include this emulator" } +// licenseStartupError is returned by startContainers instead of rendering the +// failure when the container exits with license-related output while a retry +// with a refreshed license is possible — e.g. after validating a stale cached +// license.json mounted from an earlier run (DEVX-658). startWithLicenseRetry +// retries the start once with a freshly fetched license. +type licenseStartupError struct { + name string + logs string +} + +func (e *licenseStartupError) Error() string { + return fmt.Sprintf("%s exited during license validation:\n%s", e.name, e.logs) +} + +// logsIndicateLicenseFailure reports whether a failed container's logs point at +// license validation rather than some other startup crash. Matching is loose on +// purpose: the emulator wording varies across products and versions, and a +// false positive only costs one extra license fetch and start attempt. +func logsIndicateLicenseFailure(logs string) bool { + l := strings.ToLower(logs) + if !strings.Contains(l, "license") { + return false + } + for _, marker := range []string{"fail", "invalid", "expired", "error", "could not", "unable"} { + if strings.Contains(l, marker) { + return true + } + } + return false +} + // containerExitedError is returned by startupMonitor.await when the container stops // running before becoming healthy (e.g. it crashed during startup). type containerExitedError struct { diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 660dc53f..9e616a76 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/http/httptest" + "os" "path/filepath" "strconv" "strings" @@ -484,7 +485,7 @@ func TestValidateLicense_ContinuesWhenServerUnreachable(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - err := validateLicense(context.Background(), sink, opts, c, "tok", filepath.Join(t.TempDir(), "license.json")) + _, err := validateLicense(context.Background(), sink, opts, c, "tok", filepath.Join(t.TempDir(), "license.json")) require.NoError(t, err, "an unreachable license server must not block the start") assert.Contains(t, out.String(), "Could not reach the license server") @@ -509,7 +510,7 @@ func TestValidateLicense_FailsOnServerRejection(t *testing.T) { Image: "localstack/localstack-pro:2026.4", } - err := validateLicense(context.Background(), output.NewPlainSink(io.Discard), opts, c, "tok", filepath.Join(t.TempDir(), "license.json")) + _, err := validateLicense(context.Background(), output.NewPlainSink(io.Discard), opts, c, "tok", filepath.Join(t.TempDir(), "license.json")) require.Error(t, err, "a server rejection must remain fatal") assert.Contains(t, err.Error(), "license validation failed") @@ -541,7 +542,7 @@ func TestValidateLicense_SkipsPreflightOnUnsupportedTag(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - err := validateLicense(context.Background(), sink, opts, c, "tok", filepath.Join(t.TempDir(), "license.json")) + _, err := validateLicense(context.Background(), sink, opts, c, "tok", filepath.Join(t.TempDir(), "license.json")) require.NoError(t, err, "a tag the license server cannot parse must not block the start") assert.Contains(t, out.String(), `does not support tag "dev"`) @@ -570,7 +571,7 @@ func TestValidateLicense_PropagatesContextCancellation(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - err := validateLicense(ctx, sink, opts, c, "tok", filepath.Join(t.TempDir(), "license.json")) + _, err := validateLicense(ctx, sink, opts, c, "tok", filepath.Join(t.TempDir(), "license.json")) require.ErrorIs(t, err, context.Canceled) assert.NotContains(t, out.String(), "Could not reach the license server", @@ -606,7 +607,7 @@ func TestTryPrePullLicenseValidation_SkipsCheckWhenImageIsLocal(t *testing.T) { Telemetry: telemetry.New("", true), } - postPull, err := tryPrePullLicenseValidation(context.Background(), mockRT, output.NewPlainSink(io.Discard), opts, []runtime.ContainerConfig{c}, "tok", filepath.Join(t.TempDir(), "license.json")) + postPull, _, err := tryPrePullLicenseValidation(context.Background(), mockRT, output.NewPlainSink(io.Discard), opts, []runtime.ContainerConfig{c}, "tok", filepath.Join(t.TempDir(), "license.json"), false) require.NoError(t, err) assert.Empty(t, postPull, "a pinned local image needs no post-pull validation") @@ -639,7 +640,7 @@ func TestTryPrePullLicenseValidation_ChecksWhenImageMissing(t *testing.T) { Telemetry: telemetry.New("", true), } - _, err := tryPrePullLicenseValidation(context.Background(), mockRT, output.NewPlainSink(io.Discard), opts, []runtime.ContainerConfig{c}, "tok", filepath.Join(t.TempDir(), "license.json")) + _, _, err := tryPrePullLicenseValidation(context.Background(), mockRT, output.NewPlainSink(io.Discard), opts, []runtime.ContainerConfig{c}, "tok", filepath.Join(t.TempDir(), "license.json"), false) require.Error(t, err, "a missing local image must still fail fast on a server rejection") } @@ -669,7 +670,7 @@ func TestStartContainers_SnowflakeLicenseError(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{}, 0, false) + err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{}, 0, false, false) tel.Close() require.Error(t, err) @@ -716,7 +717,7 @@ func TestStartContainers_AzureLicenseError(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{}, 0, false) + err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{}, 0, false, false) tel.Close() require.Error(t, err) @@ -891,7 +892,7 @@ func TestStartContainers_ExitedEmitsErrorAndTelemetry(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{}, time.Minute, false) + err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{}, time.Minute, false, false) tel.Close() require.Error(t, err) @@ -936,7 +937,7 @@ func TestStartContainers_TimeoutEmitsErrorAndTelemetry(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{}, 50*time.Millisecond, false) + err := startContainers(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c}, map[string]bool{}, 50*time.Millisecond, false, false) tel.Close() require.Error(t, err) @@ -1241,3 +1242,242 @@ func TestPullImages_ParentCancelPropagatesNotFallBack(t *testing.T) { assert.NotContains(t, strings.Join(sink.messageTexts(), "\n"), "using the local image", "cancellation must not emit the offline fall-back message") } + +func TestValidateLicense_DefersOnServerError(t *testing.T) { + // A 5xx (or 407, ...) from the license server is an outage, not a verdict on + // the license — the pre-flight must degrade to container-side validation. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + defer srv.Close() + + opts := StartOptions{ + PlatformClient: api.NewPlatformClient(srv.URL, log.Nop()), + Logger: log.Nop(), + Telemetry: telemetry.New("", true), + } + c := runtime.ContainerConfig{ + EmulatorType: config.EmulatorAWS, + ProductName: "localstack-pro", + Tag: "2026.4", + Image: "localstack/localstack-pro:2026.4", + } + + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + _, err := validateLicense(context.Background(), sink, opts, c, "tok", filepath.Join(t.TempDir(), "license.json")) + + require.NoError(t, err, "a license server outage must not block the start") + assert.Contains(t, out.String(), "unexpected response (HTTP 502)") +} + +func TestValidateLicense_RemovesCachedLicenseOnRejection(t *testing.T) { + // A definitive rejection invalidates the cached license: a later start whose + // pre-flight is skipped (e.g. image already local) must not keep mounting the + // stale copy (DEVX-658). + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + licenseFilePath := filepath.Join(t.TempDir(), "license.json") + require.NoError(t, os.WriteFile(licenseFilePath, []byte(`{"license":"stale"}`), 0600)) + + opts := StartOptions{ + PlatformClient: api.NewPlatformClient(srv.URL, log.Nop()), + Logger: log.Nop(), + Telemetry: telemetry.New("", true), + } + c := runtime.ContainerConfig{ + EmulatorType: config.EmulatorAWS, + ProductName: "localstack-pro", + Tag: "2026.4", + Image: "localstack/localstack-pro:2026.4", + } + + _, err := validateLicense(context.Background(), output.NewPlainSink(io.Discard), opts, c, "tok", licenseFilePath) + + require.Error(t, err, "a server rejection must remain fatal") + assert.NoFileExists(t, licenseFilePath, "the stale cached license must be removed on a definitive rejection") +} + +func TestLogsIndicateLicenseFailure(t *testing.T) { + cases := []struct { + name string + logs string + want bool + }{ + {"activation failure", "ERROR: License activation failed! Please check your auth token.", true}, + {"invalid license", "The License is invalid or has expired", true}, + {"license mentioned without failure", "Checking license... OK\nReady.", false}, + {"unrelated crash", "panic: something exploded", false}, + {"empty", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, logsIndicateLicenseFailure(tc.logs)) + }) + } +} + +func TestStartWithLicenseRetry_RefreshesStaleCachedLicense(t *testing.T) { + // The joel scenario from DEVX-658: the pre-flight was skipped (image already + // local), so a stale cached license.json was mounted and the emulator exited + // with a license failure. The start must drop the cache, fetch a fresh + // license, and retry once — without a manual `lstk logout`. + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + + healthSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer healthSrv.Close() + _, port, err := net.SplitHostPort(strings.TrimPrefix(healthSrv.URL, "http://")) + require.NoError(t, err) + + licenseFilePath := filepath.Join(t.TempDir(), "license.json") + require.NoError(t, os.WriteFile(licenseFilePath, []byte(`{"license":"stale"}`), 0600)) + + var licenseHits int32 + licSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&licenseHits, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"license_type":"enterprise","license":"fresh"}`)) + })) + defer licSrv.Close() + + c := runtime.ContainerConfig{ + Image: "localstack/localstack-pro:2026.4", + Name: "localstack-aws", + EmulatorType: config.EmulatorAWS, + ProductName: "localstack-pro", + Tag: "2026.4", + Port: port, + ContainerPort: "4566/tcp", + HealthPath: "/health", + } + + // First attempt: the container exits with a license failure. + mockRT.EXPECT().Start(gomock.Any(), gomock.Any()).Return("cid1cid1cid1", exitResultChan(runtime.ExitResult{ExitCode: 1}), nil) + mockRT.EXPECT().StreamLogs(gomock.Any(), "cid1cid1cid1", gomock.Any(), true, "all").Return(nil) + mockRT.EXPECT().IsRunning(gomock.Any(), "cid1cid1cid1").Return(false, nil) + mockRT.EXPECT().Logs(gomock.Any(), "cid1cid1cid1", 20).Return("License activation failed: the license is invalid or expired", nil) + + // Second attempt succeeds (the health endpoint responds 200). + var secondStart runtime.ContainerConfig + mockRT.EXPECT().Start(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, cfg runtime.ContainerConfig) (string, <-chan runtime.ExitResult, error) { + secondStart = cfg + return "cid2cid2cid2", make(chan runtime.ExitResult), nil + }) + mockRT.EXPECT().StreamLogs(gomock.Any(), "cid2cid2cid2", gomock.Any(), true, "all").Return(nil) + mockRT.EXPECT().IsRunning(gomock.Any(), "cid2cid2cid2").Return(true, nil).AnyTimes() + + opts := StartOptions{ + PlatformClient: api.NewPlatformClient(licSrv.URL, log.Nop()), + Logger: log.Nop(), + Telemetry: telemetry.New("", true), + } + + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + err = startWithLicenseRetry(context.Background(), mockRT, sink, opts, false, []runtime.ContainerConfig{c}, map[string]bool{}, "tok", licenseFilePath, false) + + require.NoError(t, err, "the retry with a refreshed license must succeed; output: %s", out.String()) + assert.Equal(t, int32(1), atomic.LoadInt32(&licenseHits), "the retry must fetch a fresh license exactly once") + data, readErr := os.ReadFile(licenseFilePath) + require.NoError(t, readErr) + assert.Contains(t, string(data), "fresh", "the cached license must be replaced by the freshly fetched one") + + var licenseBinds int + for _, b := range secondStart.Binds { + if b.ContainerPath == licenseMountPath { + licenseBinds++ + assert.Equal(t, licenseFilePath, b.HostPath) + } + } + assert.Equal(t, 1, licenseBinds, "the retry must mount exactly one refreshed license file") + assert.Contains(t, out.String(), "refreshing the cached license and retrying") +} + +func TestStartWithLicenseRetry_NoRetryWhenPreflightRefreshedLicense(t *testing.T) { + // When this run already fetched a fresh license, a startup license failure is + // a real verdict — refetching the same license again would loop for nothing. + // The failure renders through the regular startup error path instead. + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + + licenseFilePath := filepath.Join(t.TempDir(), "license.json") + require.NoError(t, os.WriteFile(licenseFilePath, []byte(`{"license":"fresh"}`), 0600)) + + c := runtime.ContainerConfig{ + Image: "localstack/localstack-pro:2026.4", + Name: "localstack-aws", + EmulatorType: config.EmulatorAWS, + ProductName: "localstack-pro", + Tag: "2026.4", + Port: "4566", + ContainerPort: "4566/tcp", + HealthPath: "/health", + } + + mockRT.EXPECT().Start(gomock.Any(), gomock.Any()).Return("cid1cid1cid1", exitResultChan(runtime.ExitResult{ExitCode: 1}), nil) + mockRT.EXPECT().StreamLogs(gomock.Any(), "cid1cid1cid1", gomock.Any(), true, "all").Return(nil) + mockRT.EXPECT().IsRunning(gomock.Any(), "cid1cid1cid1").Return(false, nil) + mockRT.EXPECT().Logs(gomock.Any(), "cid1cid1cid1", 20).Return("License activation failed", nil) + + opts := StartOptions{ + PlatformClient: api.NewPlatformClient("http://127.0.0.1:1", log.Nop()), + Logger: log.Nop(), + Telemetry: telemetry.New("", true), + } + + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + err := startWithLicenseRetry(context.Background(), mockRT, sink, opts, false, []runtime.ContainerConfig{c}, map[string]bool{}, "tok", licenseFilePath, true) + + require.Error(t, err) + assert.True(t, output.IsSilent(err), "the failure must be rendered by the regular startup error path") + assert.NotContains(t, out.String(), "refreshing the cached license", "no retry when the license was freshly fetched this run") + assert.FileExists(t, licenseFilePath, "a freshly validated license must not be dropped") +} + +func TestStartWithLicenseRetry_NoRetryWithoutCachedLicense(t *testing.T) { + // With no cached license mounted, a startup license failure cannot be a stale + // cache problem — the failure renders through the regular startup error path. + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + + c := runtime.ContainerConfig{ + Image: "localstack/localstack-pro:2026.4", + Name: "localstack-aws", + EmulatorType: config.EmulatorAWS, + ProductName: "localstack-pro", + Tag: "2026.4", + Port: "4566", + ContainerPort: "4566/tcp", + HealthPath: "/health", + } + + mockRT.EXPECT().Start(gomock.Any(), gomock.Any()).Return("cid1cid1cid1", exitResultChan(runtime.ExitResult{ExitCode: 1}), nil) + mockRT.EXPECT().StreamLogs(gomock.Any(), "cid1cid1cid1", gomock.Any(), true, "all").Return(nil) + mockRT.EXPECT().IsRunning(gomock.Any(), "cid1cid1cid1").Return(false, nil) + mockRT.EXPECT().Logs(gomock.Any(), "cid1cid1cid1", 20).Return("License activation failed", nil) + + opts := StartOptions{ + PlatformClient: api.NewPlatformClient("http://127.0.0.1:1", log.Nop()), + Logger: log.Nop(), + Telemetry: telemetry.New("", true), + } + + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + err := startWithLicenseRetry(context.Background(), mockRT, sink, opts, false, []runtime.ContainerConfig{c}, map[string]bool{}, "tok", filepath.Join(t.TempDir(), "license.json"), false) + + require.Error(t, err) + assert.True(t, output.IsSilent(err), "the failure must be rendered by the regular startup error path") + assert.NotContains(t, out.String(), "refreshing the cached license") +} diff --git a/test/integration/license_test.go b/test/integration/license_test.go index cdf06df5..8402b78e 100644 --- a/test/integration/license_test.go +++ b/test/integration/license_test.go @@ -1,17 +1,25 @@ package integration_test import ( + "bytes" "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" + "runtime" + "sync/atomic" "testing" + "time" + + "github.com/creack/pty" - "github.com/moby/moby/client" "github.com/localstack/lstk/test/integration/env" + "github.com/moby/moby/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -85,16 +93,122 @@ func TestLicenseValidationFailure(t *testing.T) { defer mockServer.Close() ctx := testContext(t) - _, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL).With(env.AuthToken, "test-token-for-license-validation"), "start") + stdout, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL).With(env.AuthToken, "test-token-for-license-validation"), "start") require.Error(t, err, "expected lstk start to fail with forbidden license") requireExitCode(t, 1, err) - assert.Contains(t, stderr, "license validation failed") - assert.Contains(t, stderr, "invalid, inactive, or expired") + assert.Contains(t, stdout, "License validation failed") + assert.Contains(t, stdout, "invalid, inactive, or expired") + assert.Contains(t, stdout, "lstk logout", "the error should point at re-authentication") + assert.NotContains(t, stderr, "license validation failed", "the error event replaces the raw stderr error") _, err = dockerClient.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{}) assert.Error(t, err, "container should not exist after license failure") } +// TestLicenseRejectionOffersReloginAndRetries covers DEVX-658: a definitively +// rejected token (e.g. one that predates a license purchase) must offer a fresh +// login in interactive mode and retry the start with the new token, instead of +// requiring a manual `lstk logout`. +func TestLicenseRejectionOffersReloginAndRetries(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + requireDocker(t) + realToken := env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + cleanupLicense() + t.Cleanup(cleanupLicense) + + staleToken := "stale-token-predating-license-purchase" + authReqID := "relogin-auth-req-id" + + var staleRejected atomic.Bool + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/request": + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]string{ + "id": authReqID, "code": "TEST123", "exchange_token": "relogin-exchange-token", + }) + case r.Method == http.MethodGet && r.URL.Path == "/v1/auth/request/"+authReqID: + _ = json.NewEncoder(w).Encode(map[string]bool{"confirmed": true}) + case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/request/"+authReqID+"/exchange": + _ = json.NewEncoder(w).Encode(map[string]string{"id": authReqID, "auth_token": "Bearer test-bearer"}) + case r.Method == http.MethodGet && r.URL.Path == "/v1/license/credentials": + _ = json.NewEncoder(w).Encode(map[string]string{"token": realToken}) + case r.Method == http.MethodPost && r.URL.Path == "/v1/license/request": + var req struct { + Credentials struct { + Token string `json:"token"` + } `json:"credentials"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if req.Credentials.Token != realToken { + staleRejected.Store(true) + w.WriteHeader(http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"license_type":"enterprise"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockServer.Close() + + ctx := testContext(t) + environ, _ := fakeBrowserOpener(t, env.With(env.AuthToken, staleToken).With(env.APIEndpoint, mockServer.URL).With(env.WebAppURL, mockServer.URL)) + + // An explicit config prevents firstRun=true, which would block the TUI on the + // emulator selection prompt before the license check runs. + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte("[[containers]]\ntype = \"aws\"\ntag = \"latest\"\nport = \"4566\"\n"), 0644)) + + cmd := exec.CommandContext(ctx, binaryPath(), "start", "--config", configFile) + cmd.Env = environ + ptmx, err := pty.Start(cmd) + require.NoError(t, err, "failed to start command in PTY") + defer func() { _ = ptmx.Close() }() + + out := &syncBuffer{} + outputCh := make(chan struct{}) + go func() { + _, _ = io.Copy(out, ptmx) + close(outputCh) + }() + + // The stale token is rejected; the re-login prompt appears. Press ENTER. + require.Eventually(t, func() bool { + return bytes.Contains(out.Bytes(), []byte("Log in again")) + }, 90*time.Second, 100*time.Millisecond, "the re-login prompt should appear after the license rejection") + _, err = ptmx.Write([]byte("\r")) + require.NoError(t, err) + + // The login flow runs; confirm it once the completion prompt appears. + require.Eventually(t, func() bool { + return bytes.Contains(out.Bytes(), []byte("key when complete")) + }, 30*time.Second, 100*time.Millisecond, "the login completion prompt should appear") + _, err = ptmx.Write([]byte("\r")) + require.NoError(t, err) + + err = cmd.Wait() + <-outputCh + require.NoError(t, err, "start should succeed after re-login: %s", out.String()) + assert.True(t, staleRejected.Load(), "the stale token must have been rejected by the license server first") + assert.Contains(t, out.String(), "Valid license") + + inspect, err := dockerClient.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{}) + require.NoError(t, err, "failed to inspect container") + assert.True(t, inspect.Container.State.Running, "container should be running after the retried start") + + storedToken, err := GetAuthTokenFromKeyring() + require.NoError(t, err) + assert.Equal(t, realToken, storedToken, "the fresh token must replace the rejected one") +} + func licenseFilePath(t *testing.T) string { t.Helper() cacheDir, err := os.UserCacheDir() diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 95987a67..7289f20b 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -307,10 +307,10 @@ func TestStartCommandFailsWithInvalidToken(t *testing.T) { mockServer := createMockLicenseServer(false) defer mockServer.Close() - _, stderr, err := runLstk(t, testContext(t), "", env.With(env.AuthToken, "invalid-token").With(env.APIEndpoint, mockServer.URL), "start") + stdout, _, err := runLstk(t, testContext(t), "", env.With(env.AuthToken, "invalid-token").With(env.APIEndpoint, mockServer.URL), "start") require.Error(t, err, "expected lstk start to fail with invalid token") requireExitCode(t, 1, err) - assert.Contains(t, stderr, "license validation failed") + assert.Contains(t, stdout, "License validation failed") } func TestStartCommandDoesNothingWhenAlreadyRunning(t *testing.T) { diff --git a/test/integration/version_resolution_test.go b/test/integration/version_resolution_test.go index 01e2cfb4..cdb60d41 100644 --- a/test/integration/version_resolution_test.go +++ b/test/integration/version_resolution_test.go @@ -82,8 +82,8 @@ func TestCommandFailsNicelyWhenLicenseCheckFails(t *testing.T) { stdout, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "start") require.Error(t, err, "expected lstk start to fail when license check fails") - assert.Contains(t, stderr, "license validation failed", - "stdout: %s", stdout) + assert.Contains(t, stdout, "License validation failed", + "stderr: %s", stderr) } // Verifies that a tag the license server cannot parse as a version (e.g. "dev" @@ -150,7 +150,7 @@ port = "4566" stdout, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "--config", configFile, "start") require.Error(t, err, "expected lstk start to fail when license check fails") - assert.Contains(t, stderr, "license validation failed", - "stdout: %s", stdout) + assert.Contains(t, stdout, "License validation failed", + "stderr: %s", stderr) assert.Equal(t, "4.0.0", *capturedVersion, "pinned tag should be sent directly to the license API without image inspection") } From c8ded92127cb8904930f6eb7d953fd00cfe5cc42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Wed, 22 Jul 2026 15:23:52 +0200 Subject: [PATCH 2/9] fix(license): keep product and version in the rejection error title The structured license-rejection error dropped the product:version context the previous raw error carried. Thread it through a typed licenseRejectedError so the ErrorEvent title reads "License validation failed for localstack-pro:2026.7.0". Co-Authored-By: Claude Fable 5 --- internal/container/start.go | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index 26fa1dc7..dc691789 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -89,8 +89,8 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start opts.Telemetry.SetAuthToken(token) version, err := startOnce(ctx, rt, sink, opts, interactive, token, licenseFilePath, false) - var licErr *api.LicenseError - if err == nil || !errors.As(err, &licErr) { + var rejErr *licenseRejectedError + if err == nil || !errors.As(err, &rejErr) { return version, err } @@ -98,7 +98,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start // already dropped the cached license.json). The rejected token may simply // predate a license purchase or plan change (DEVX-658), so offer a fresh // login instead of requiring a manual `lstk logout` before the next run. - if interactive && promptRelogin(ctx, sink, licErr) { + if interactive && promptRelogin(ctx, sink, rejErr.licErr) { newToken, loginErr := a.Relogin(ctx) if loginErr != nil { return "", loginErr @@ -108,8 +108,8 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start } sink.Emit(output.ErrorEvent{ - Title: "License validation failed", - Summary: licErr.Message, + Title: fmt.Sprintf("License validation failed for %s:%s", rejErr.productName, rejErr.version), + Summary: rejErr.licErr.Message, Actions: []output.ErrorAction{ {Label: "Log in again to refresh your credentials:", Value: "lstk logout && lstk login"}, {Label: "Or provide a valid token via the environment variable:", Value: "LOCALSTACK_AUTH_TOKEN"}, @@ -118,6 +118,21 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start return "", output.NewSilentError(err) } +// licenseRejectedError carries the product/version context of a definitive +// license rejection so the top-level handler can render it (and offer the +// re-login recovery) without re-deriving the container details. +type licenseRejectedError struct { + productName string + version string + licErr *api.LicenseError +} + +func (e *licenseRejectedError) Error() string { + return fmt.Sprintf("license validation failed for %s:%s: %v", e.productName, e.version, e.licErr) +} + +func (e *licenseRejectedError) Unwrap() error { return e.licErr } + func startOnce(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, interactive bool, token, licenseFilePath string, forceLicenseValidation bool) (string, error) { tel := opts.Telemetry @@ -979,7 +994,7 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c ErrorCode: telemetry.ErrCodeLicenseInvalid, ErrorMsg: err.Error(), }) - return false, fmt.Errorf("license validation failed for %s:%s: %w", containerConfig.ProductName, version, err) + return false, &licenseRejectedError{productName: containerConfig.ProductName, version: version, licErr: licErr} } sink.Emit(output.SpinnerStop()) From de8e446b815e064531c9b5b5839aec203a5f2d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Wed, 22 Jul 2026 15:43:40 +0200 Subject: [PATCH 3/9] test(license): give the interactive re-login test a realistic CI budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retried start pulls the image, runs a login round-trip, and boots the emulator, which overran the default 2-minute test context on CI runners — and the interactive startup monitor's 20s "keep waiting?" prompt paused the boot with nobody answering it. Use a 6-minute context and override LSTK_STARTUP_TIMEOUT so only the prompts under test need answering. Co-Authored-By: Claude Fable 5 --- test/integration/license_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/integration/license_test.go b/test/integration/license_test.go index 8402b78e..d8f09df3 100644 --- a/test/integration/license_test.go +++ b/test/integration/license_test.go @@ -159,8 +159,15 @@ func TestLicenseRejectionOffersReloginAndRetries(t *testing.T) { })) defer mockServer.Close() - ctx := testContext(t) + // A generous budget: this flow pulls the image, runs a full login round-trip, + // and boots the emulator — the default 2-minute test context is too tight on + // CI runners. LSTK_STARTUP_TIMEOUT keeps the interactive "keep waiting?" + // prompt (20s default) from pausing the start while the emulator boots, + // since this test only answers the re-login and login prompts. + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) + t.Cleanup(cancel) environ, _ := fakeBrowserOpener(t, env.With(env.AuthToken, staleToken).With(env.APIEndpoint, mockServer.URL).With(env.WebAppURL, mockServer.URL)) + environ = append(environ, "LSTK_STARTUP_TIMEOUT=5m") // An explicit config prevents firstRun=true, which would block the TUI on the // emulator selection prompt before the license check runs. @@ -181,9 +188,10 @@ func TestLicenseRejectionOffersReloginAndRetries(t *testing.T) { }() // The stale token is rejected; the re-login prompt appears. Press ENTER. + // The wait covers a cold image pull on CI runners. require.Eventually(t, func() bool { return bytes.Contains(out.Bytes(), []byte("Log in again")) - }, 90*time.Second, 100*time.Millisecond, "the re-login prompt should appear after the license rejection") + }, 3*time.Minute, 100*time.Millisecond, "the re-login prompt should appear after the license rejection") _, err = ptmx.Write([]byte("\r")) require.NoError(t, err) From d8dc4e7838fe7da93a586424f4d4f29cf5e5a090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Wed, 22 Jul 2026 16:04:34 +0200 Subject: [PATCH 4/9] test(license): dump container diagnostics when the re-login test fails The retried start times out on CI with the emulator running but never healthy, and the PTY transcript alone cannot explain why. Capture the container state, its log tail, and a health-endpoint probe before cleanup removes the container. Co-Authored-By: Claude Fable 5 --- test/integration/license_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/integration/license_test.go b/test/integration/license_test.go index d8f09df3..329db0cb 100644 --- a/test/integration/license_test.go +++ b/test/integration/license_test.go @@ -12,6 +12,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "sync/atomic" "testing" "time" @@ -204,6 +205,25 @@ func TestLicenseRejectionOffersReloginAndRetries(t *testing.T) { err = cmd.Wait() <-outputCh + if err != nil { + // The PTY transcript cannot explain a container that never became + // healthy — capture the emulator's own view before cleanup removes it. + diagCtx, diagCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer diagCancel() + if inspectOut, ierr := exec.CommandContext(diagCtx, "docker", "inspect", "--format", "{{.State.Status}} exit={{.State.ExitCode}}", containerName).CombinedOutput(); ierr == nil { + t.Logf("container state: %s", strings.TrimSpace(string(inspectOut))) + } + if logsOut, lerr := exec.CommandContext(diagCtx, "docker", "logs", "--tail", "150", containerName).CombinedOutput(); lerr == nil { + t.Logf("container logs (tail):\n%s", string(logsOut)) + } + if resp, herr := http.Get("http://localhost:4566/_localstack/health"); herr == nil { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + _ = resp.Body.Close() + t.Logf("health endpoint: HTTP %d %s", resp.StatusCode, string(body)) + } else { + t.Logf("health endpoint unreachable: %v", herr) + } + } require.NoError(t, err, "start should succeed after re-login: %s", out.String()) assert.True(t, staleRejected.Load(), "the stale token must have been rejected by the license server first") assert.Contains(t, out.String(), "Valid license") From 00f146b614fcb007876d38d8805ec5837dd0e2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Wed, 22 Jul 2026 16:24:49 +0200 Subject: [PATCH 5/9] test(license): answer the post-start AWS profile prompt in the re-login test Diagnostics showed the emulator healthy within seconds while lstk waited forever: the interactive post-start AWS profile setup asked its Y/n question (the CI runner's ~/.aws has no matching profile) and nothing answered it. Decline it from a watcher, since the prompt is skipped entirely when the profile already matches. Co-Authored-By: Claude Fable 5 --- test/integration/license_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/integration/license_test.go b/test/integration/license_test.go index 329db0cb..a41bb121 100644 --- a/test/integration/license_test.go +++ b/test/integration/license_test.go @@ -203,6 +203,25 @@ func TestLicenseRejectionOffersReloginAndRetries(t *testing.T) { _, err = ptmx.Write([]byte("\r")) require.NoError(t, err) + // After a successful start, the post-start AWS profile setup asks a Y/n + // question when the runner's ~/.aws has no matching profile. It is + // conditional (skipped when the profile already matches), so watch for it + // and decline instead of requiring it. + go func() { + var answered atomic.Bool + for { + select { + case <-outputCh: + return + case <-time.After(200 * time.Millisecond): + if !answered.Load() && bytes.Contains(out.Bytes(), []byte("~/.aws? [Y/n]")) { + answered.Store(true) + _, _ = ptmx.Write([]byte("n")) + } + } + } + }() + err = cmd.Wait() <-outputCh if err != nil { From 8ef2f298bef049fb0db309fec04c2becce3cdb98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Wed, 22 Jul 2026 16:52:04 +0200 Subject: [PATCH 6/9] test(license): read the stored token from the config-relative file keyring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced the CI failure in a headless Linux container: the re-login stores the token fine, but the file-keyring fallback writes it next to the active config file (ConfigDir follows --config), which is this test's temp dir — not the default config dir the shared GetAuthTokenFromKeyring helper reads. Assert against the actual storage location per backend. Co-Authored-By: Claude Fable 5 --- test/integration/license_test.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/integration/license_test.go b/test/integration/license_test.go index a41bb121..005a1c88 100644 --- a/test/integration/license_test.go +++ b/test/integration/license_test.go @@ -251,8 +251,19 @@ func TestLicenseRejectionOffersReloginAndRetries(t *testing.T) { require.NoError(t, err, "failed to inspect container") assert.True(t, inspect.Container.State.Running, "container should be running after the retried start") - storedToken, err := GetAuthTokenFromKeyring() - require.NoError(t, err) + // lstk's file-keyring fallback stores the token next to the active config + // file (ConfigDir follows --config), so on headless CI runners the token + // lands in this test's temp dir — not in the default config dir the shared + // GetAuthTokenFromKeyring helper reads. + var storedToken string + if useFileKeyring { + data, rerr := os.ReadFile(filepath.Join(filepath.Dir(configFile), authTokenFile)) + require.NoError(t, rerr, "the re-logged-in token should be stored in the file keyring next to the config file") + storedToken = string(data) + } else { + storedToken, err = GetAuthTokenFromKeyring() + require.NoError(t, err, "the re-logged-in token should be stored in the system keyring") + } assert.Equal(t, realToken, storedToken, "the fresh token must replace the rejected one") } From d507d4104a0d24ecd041644625c2ebf31e28365d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Wed, 22 Jul 2026 17:28:04 +0200 Subject: [PATCH 7/9] fix(license): render the rejection reason on the error line itself "License validation failed for localstack-pro:2026.7.0: invalid, ..." reads as one sentence; the summary-line split put an odd newline between the title and the reason. Co-Authored-By: Claude Fable 5 --- internal/container/start.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index dc691789..c5af7d00 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -108,8 +108,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start } sink.Emit(output.ErrorEvent{ - Title: fmt.Sprintf("License validation failed for %s:%s", rejErr.productName, rejErr.version), - Summary: rejErr.licErr.Message, + Title: fmt.Sprintf("License validation failed for %s:%s: %s", rejErr.productName, rejErr.version, rejErr.licErr.Message), Actions: []output.ErrorAction{ {Label: "Log in again to refresh your credentials:", Value: "lstk logout && lstk login"}, {Label: "Or provide a valid token via the environment variable:", Value: "LOCALSTACK_AUTH_TOKEN"}, From 2b40ec22c46e5c55a71c68c31e2f2f5011a83e32 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Fri, 24 Jul 2026 16:48:53 +0200 Subject: [PATCH 8/9] fix(license): route a second re-login rejection through the sink A definitive license rejection that survives the DEVX-658 re-login retry was returned as a raw error instead of the styled ErrorEvent, falling through to the unstyled stderr fallback instead of the actionable message. Co-Authored-By: Claude --- internal/container/start.go | 17 ++++++- internal/container/start_test.go | 82 ++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index c5af7d00..c3e21e0f 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -104,9 +104,22 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start return "", loginErr } opts.Telemetry.SetAuthToken(newToken) - return startOnce(ctx, rt, sink, opts, interactive, newToken, licenseFilePath, true) + version, err = startOnce(ctx, rt, sink, opts, interactive, newToken, licenseFilePath, true) + if err == nil || !errors.As(err, &rejErr) { + return version, err + } + // The freshly logged-in token was rejected too: render it the same way + // as a first rejection instead of surfacing the raw error, below. } + return "", renderLicenseRejection(sink, rejErr, err) +} + +// renderLicenseRejection emits the actionable ErrorEvent for a definitive +// license rejection and returns a silent error wrapping err, so a rejection +// renders identically whether it's the initial failure or a retry after +// re-login came back rejected too. +func renderLicenseRejection(sink output.Sink, rejErr *licenseRejectedError, err error) error { sink.Emit(output.ErrorEvent{ Title: fmt.Sprintf("License validation failed for %s:%s: %s", rejErr.productName, rejErr.version, rejErr.licErr.Message), Actions: []output.ErrorAction{ @@ -114,7 +127,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start {Label: "Or provide a valid token via the environment variable:", Value: "LOCALSTACK_AUTH_TOKEN"}, }, }) - return "", output.NewSilentError(err) + return output.NewSilentError(err) } // licenseRejectedError carries the product/version context of a definitive diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 9e616a76..8fd111f2 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -3,6 +3,7 @@ package container import ( "bytes" "context" + "encoding/json" "errors" "io" "net" @@ -18,6 +19,7 @@ import ( "time" "github.com/localstack/lstk/internal/api" + "github.com/localstack/lstk/internal/auth" "github.com/localstack/lstk/internal/caller" "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/log" @@ -1481,3 +1483,83 @@ func TestStartWithLicenseRetry_NoRetryWithoutCachedLicense(t *testing.T) { assert.True(t, output.IsSilent(err), "the failure must be rendered by the regular startup error path") assert.NotContains(t, out.String(), "refreshing the cached license") } + +// TestStart_SecondLicenseRejectionAfterReloginRendersErrorEvent covers a gap in +// DEVX-658: if the freshly re-logged-in token is rejected too (the license +// server's answer didn't change), the second rejection must render through the +// same styled ErrorEvent as the first — not bypass the sink and fall through to +// the top-level "Error: %v" fallback (cmd/root.go), which is reserved for +// failures no sink was available to render. +func TestStart_SecondLicenseRejectionAfterReloginRendersErrorEvent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + const authReqID = "second-rejection-auth-req" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/request": + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]string{ + "id": authReqID, "code": "TEST123", "exchange_token": "exchange-token", + }) + case r.Method == http.MethodGet && r.URL.Path == "/v1/auth/request/"+authReqID: + _ = json.NewEncoder(w).Encode(map[string]bool{"confirmed": true}) + case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/request/"+authReqID+"/exchange": + _ = json.NewEncoder(w).Encode(map[string]string{"id": authReqID, "auth_token": "Bearer test-bearer"}) + case r.Method == http.MethodGet && r.URL.Path == "/v1/license/credentials": + _ = json.NewEncoder(w).Encode(map[string]string{"token": "fresh-token"}) + case r.Method == http.MethodPost && r.URL.Path == "/v1/license/request": + // Every license check is rejected — both the stale token on the first + // attempt and the freshly re-logged-in one on the retry. + w.WriteHeader(http.StatusForbidden) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockServer.Close() + + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsHealthy(gomock.Any()).Return(nil) + mockRT.EXPECT().SocketPath().Return("").AnyTimes() + mockRT.EXPECT().IsRunning(gomock.Any(), gomock.Any()).Return(false, nil).AnyTimes() + mockRT.EXPECT().FindRunningByImage(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mockRT.EXPECT().ImageExists(gomock.Any(), gomock.Any()).Return(false, nil).AnyTimes() + + var mu sync.Mutex + var out bytes.Buffer + sink := output.SinkFunc(func(event output.Event) { + mu.Lock() + if line, ok := output.FormatEventLine(event); ok { + out.WriteString(line + "\n") + } + mu.Unlock() + // Auto-answer every prompt (the re-login confirmation, then the login + // flow's "press any key" completion prompt) as if the user pressed enter. + if req, ok := event.(output.UserInputRequestEvent); ok { + req.ResponseCh <- output.InputResponse{SelectedKey: "enter"} + } + }) + + opts := StartOptions{ + PlatformClient: api.NewPlatformClient(mockServer.URL, log.Nop()), + WebAppURL: mockServer.URL, + AuthToken: "stale-token-predating-license-purchase", + ForceFileKeyring: true, + Logger: log.Nop(), + Telemetry: telemetry.New("", true), + AuthOptions: []auth.Option{auth.WithBrowserOpener(func(string) error { return nil })}, + Containers: []config.ContainerConfig{ + {Type: config.EmulatorAWS, Port: "48213", Tag: "2026.4"}, + }, + } + + _, err := Start(context.Background(), mockRT, sink, opts, true) + + require.Error(t, err) + assert.True(t, output.IsSilent(err), "a second rejection must render through the sink, not the raw stderr fallback") + mu.Lock() + got := out.String() + mu.Unlock() + assert.Contains(t, got, "License validation failed", "the second rejection must render the same actionable error as the first") + assert.Contains(t, got, "lstk logout && lstk login") +} From d9bcb51592d323279a21744828cf3c229f47e34d Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Fri, 24 Jul 2026 17:01:00 +0200 Subject: [PATCH 9/9] fix(license): stop repeating the rejection reason on a declined re-login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit promptRelogin emitted the failure reason as its own warning line, then renderLicenseRejection restated it in the final error if the user declined — fold it into the prompt text instead, shown once. Co-Authored-By: Claude --- internal/container/start.go | 8 +++++--- internal/container/start_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index c3e21e0f..e9d31ae7 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -1039,12 +1039,14 @@ func isDefinitiveLicenseRejection(status int) bool { // promptRelogin asks the user whether to run a fresh login after a definitive // license rejection. Only call in interactive mode: the plain sink never -// answers input requests, so waiting on one would hang. +// answers input requests, so waiting on one would hang. The rejection reason is +// folded into the prompt itself (rather than emitted as a separate message +// first) so a decline doesn't show "License validation failed" twice — once +// here and again in the final ErrorEvent if the user says no. func promptRelogin(ctx context.Context, sink output.Sink, licErr *api.LicenseError) bool { - sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("License validation failed: %s", licErr.Message)}) responseCh := make(chan output.InputResponse, 1) sink.Emit(output.UserInputRequestEvent{ - Prompt: "Log in again to refresh your credentials?", + Prompt: fmt.Sprintf("License validation failed: %s. Log in again to refresh your credentials?", licErr.Message), Options: []output.InputOption{{Key: "enter", Label: "Press ENTER to log in again"}}, ResponseCh: responseCh, }) diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 8fd111f2..97a00c73 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -1563,3 +1563,29 @@ func TestStart_SecondLicenseRejectionAfterReloginRendersErrorEvent(t *testing.T) assert.Contains(t, got, "License validation failed", "the second rejection must render the same actionable error as the first") assert.Contains(t, got, "lstk logout && lstk login") } + +// TestPromptRelogin_FoldsReasonIntoThePromptWithoutASeparateWarning covers a +// duplication bug: promptRelogin used to emit the rejection reason as its own +// warning message before asking the question, so a decline showed "License +// validation failed" a second time in the final ErrorEvent. The reason must be +// folded into the prompt itself instead, so it's stated exactly once. +func TestPromptRelogin_FoldsReasonIntoThePromptWithoutASeparateWarning(t *testing.T) { + licErr := &api.LicenseError{Message: "invalid, inactive, or expired authentication token or subscription", Status: http.StatusForbidden} + + var events []output.Event + sink := output.SinkFunc(func(event output.Event) { + events = append(events, event) + if req, ok := event.(output.UserInputRequestEvent); ok { + req.ResponseCh <- output.InputResponse{Cancelled: true} + } + }) + + accepted := promptRelogin(context.Background(), sink, licErr) + + require.False(t, accepted, "declining must report false") + require.Len(t, events, 1, "the rejection reason must be folded into the prompt, not emitted as a separate message first") + req, ok := events[0].(output.UserInputRequestEvent) + require.True(t, ok, "the only event emitted must be the prompt itself") + assert.Contains(t, req.Prompt, licErr.Message, "the prompt must explain why the user is being asked to log in again") + assert.Contains(t, req.Prompt, "Log in again to refresh your credentials?") +}