diff --git a/CLAUDE.md b/CLAUDE.md index a1c56867..ced1f059 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,12 +94,20 @@ 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 the 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 the 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 the 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 the 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 (`licenseStartupError` from `awaitStartup`) while a cached `license.json` that this run did *not* refresh was mounted (pre-flight skipped, e.g. image already local), `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. +- `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. + # Emulator Setup Commands Use `lstk setup ` to set up CLI integration for an emulator type: 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/start.go b/internal/container/start.go index 06793b7d..89e2ed2b 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -46,6 +46,9 @@ type StartOptions struct { Persist bool 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) { @@ -66,11 +69,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 { @@ -79,6 +87,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 := filterHostEnv(os.Environ()) @@ -194,7 +233,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 } @@ -202,15 +241,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 } @@ -221,10 +255,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 == "" { @@ -236,18 +271,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); err != nil { + if err := startWithLicenseRetry(ctx, rt, sink, opts, containers, pulled, token, licenseFilePath, licenseRefreshed); err != nil { return "", err } @@ -460,10 +484,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 @@ -475,24 +502,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 @@ -500,17 +533,53 @@ 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, containers []runtime.ContainerConfig, pulled map[string]bool, token, licenseFilePath string, licenseRefreshed bool) error { + licenseMounted := mountCachedLicense(containers, licenseFilePath) + + err := startContainers(ctx, rt, sink, opts.Telemetry, containers, pulled) + if err == nil { + return nil + } + var licStartErr *licenseStartupError + if !errors.As(err, &licStartErr) || !licenseMounted || licenseRefreshed { + return err + } + + 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 } - return firstVersion, nil + 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) } func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig, pulled map[string]bool) error { @@ -554,6 +623,10 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, if err != nil { sink.Emit(output.SpinnerStop()) errCode := telemetry.ErrCodeStartFailed + var licStartErr *licenseStartupError + if errors.As(err, &licStartErr) { + errCode = telemetry.ErrCodeLicenseInvalid + } var licErr *licenseNotCoveredError if errors.As(err, &licErr) && c.EmulatorType.SelfValidatesLicense() { errCode = telemetry.ErrCodeLicenseInvalid @@ -719,7 +792,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")) @@ -746,7 +821,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) { @@ -756,7 +831,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 @@ -769,16 +844,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, @@ -786,7 +872,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()) @@ -801,10 +887,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 awaitStartup when the container exits @@ -815,6 +956,36 @@ func (e *licenseNotCoveredError) Error() string { return "license does not include this emulator" } +// licenseStartupError is returned by awaitStartup when the container exits with +// license-related failure output — e.g. after validating a stale cached +// license.json mounted from an earlier run. The start path retries once with a +// freshly fetched license when that cache is the likely culprit. +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 +} + // maxStartupLogBytes bounds how much of a failing container's log tail is buffered // to explain a crash during startup. const maxStartupLogBytes = 64 * 1024 @@ -847,6 +1018,9 @@ func awaitStartup(ctx context.Context, rt runtime.Runtime, sink output.Sink, con if strings.Contains(logs, "not covered by your license") { return &licenseNotCoveredError{} } + if logsIndicateLicenseFailure(logs) { + return &licenseStartupError{name: name, logs: logs} + } if logs == "" { return fmt.Errorf("%s exited unexpectedly", name) } diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 0246e021..2f6e799b 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" @@ -444,7 +445,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") @@ -469,7 +470,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") @@ -501,7 +502,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"`) @@ -530,7 +531,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", @@ -566,7 +567,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") @@ -599,7 +600,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") } @@ -976,3 +977,235 @@ 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", nil) + mockRT.EXPECT().StreamLogs(gomock.Any(), "cid1cid1cid1", gomock.Any(), true).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, error) { + secondStart = cfg + return "cid2cid2cid2", nil + }) + mockRT.EXPECT().StreamLogs(gomock.Any(), "cid2cid2cid2", gomock.Any(), true).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, []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. + 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", nil) + mockRT.EXPECT().StreamLogs(gomock.Any(), "cid1cid1cid1", gomock.Any(), true).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), + } + + err := startWithLicenseRetry(context.Background(), mockRT, output.NewPlainSink(io.Discard), opts, []runtime.ContainerConfig{c}, map[string]bool{}, "tok", licenseFilePath, true) + + require.Error(t, err) + var licStartErr *licenseStartupError + assert.ErrorAs(t, err, &licStartErr, "the startup license failure must surface unchanged") + 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 — no retry. + 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", nil) + mockRT.EXPECT().StreamLogs(gomock.Any(), "cid1cid1cid1", gomock.Any(), true).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), + } + + err := startWithLicenseRetry(context.Background(), mockRT, output.NewPlainSink(io.Discard), opts, []runtime.ContainerConfig{c}, map[string]bool{}, "tok", filepath.Join(t.TempDir(), "license.json"), false) + + require.Error(t, err) + var licStartErr *licenseStartupError + assert.ErrorAs(t, err, &licStartErr) +} 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 4dbaf2c7..9a894fba 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -127,10 +127,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") }