Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <emulator>` to set up CLI integration for an emulator type:
Expand Down
23 changes: 23 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
41 changes: 41 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -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")
}
Loading
Loading