Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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")
}
10 changes: 9 additions & 1 deletion internal/container/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading