Skip to content
Open
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
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ Created automatically on first run with defaults. Supports emulator types: `aws`

Each `[[containers]]` block may set an optional `image` to override the default Docker Hub image (e.g. an internal registry mirror or a locally loaded offline image). `ContainerConfig.Image()` returns `image` as-is when it already carries a tag, otherwise it appends `tag` (or `latest`); the default `localstack/<product>:<tag>` is used when `image` is unset.

# Offline / Enterprise Environments

There is no `--offline` flag. Instead `container.Start` degrades gracefully when internet requests fail (the common enterprise blockers: Docker Hub unreachable, proxy/TLS interception, license server unreachable):

- **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.
- **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.

# Emulator Setup Commands

Use `lstk setup <emulator>` to set up CLI integration for an emulator type:
Expand Down
61 changes: 50 additions & 11 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,10 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start
return "", fmt.Errorf("failed to determine license file path: %w", err)
}

// Validate licenses before pulling. Pinned tags are validated immediately;
// "latest" tags are deferred to post-pull validation via image inspection.
postPullContainers, err := tryPrePullLicenseValidation(ctx, sink, opts, containers, token, licenseFilePath)
// 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)
if err != nil {
return "", err
}
Expand Down Expand Up @@ -338,6 +339,19 @@ func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *
}()
if err := rt.PullImage(ctx, c.Image, progress); err != nil {
sink.Emit(output.SpinnerStop())
// A cancelled caller context (e.g. Ctrl+C) is not an offline condition —
// propagate it instead of probing for a local image or reporting a pull
// failure, which would also emit a spurious start-error telemetry event.
if ctx.Err() != nil {
return nil, ctx.Err()
}
// The registry may be unreachable (offline, proxy, or TLS interception in
// enterprise networks). If the image is already available locally, fall back
// to it instead of failing — the image carries its own license.
if exists, existsErr := rt.ImageExists(ctx, c.Image); existsErr == nil && exists {
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("Could not pull %s; using the local image", c.Image)})
continue
}
sink.Emit(output.ErrorEvent{
Title: fmt.Sprintf("Failed to pull %s", c.Image),
Summary: err.Error(),
Expand All @@ -360,14 +374,22 @@ func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *

// Validates licenses before pulling for containers with pinned tags.
// "latest" and empty tags are deferred to post-pull validation via image inspection.
func tryPrePullLicenseValidation(ctx context.Context, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string) ([]runtime.ContainerConfig, error) {
func tryPrePullLicenseValidation(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string) ([]runtime.ContainerConfig, error) {
var needsPostPull []runtime.ContainerConfig
for _, c := range containers {
if c.EmulatorType.SelfValidatesLicense() {
continue
}

if c.Tag != "" && c.Tag != "latest" {
// A pinned image already present locally is not pulled (see pullImages),
// so skip the license pre-flight too: the check is redundant — and a hard
// blocker in offline/enterprise environments — when no network round-trip
// happens at all and the container validates its own bundled 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 err := validateLicense(ctx, sink, opts, c, token, licenseFilePath); err != nil {
return nil, err
}
Expand Down Expand Up @@ -612,14 +634,31 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c
licenseResp, err := opts.PlatformClient.GetLicense(ctx, licenseReq)
if err != nil {
sink.Emit(output.SpinnerStop())
// A cancelled caller context (e.g. Ctrl+C) is not an offline condition —
// 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()
}
var licErr *api.LicenseError
if errors.As(err, &licErr) {
if licErr.Detail != "" {
opts.Logger.Error("license server response (HTTP %d): %s", licErr.Status, licErr.Detail)
}
if licErr.IsUnsupportedTag {
err = errors.New(config.UnsupportedTagMessage())
}
if !errors.As(err, &licErr) {
// The license server responded with no definitive verdict — the request
// itself failed (offline, proxy, or TLS interception in enterprise
// networks). Skip the pre-flight check and let the container validate its
// own bundled license at startup instead of blocking the start.
opts.Logger.Info("license server unreachable, continuing with the image's bundled license: %v", err)
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: "Could not reach the license server; continuing with the image's bundled license"})
return nil
}
// Known limitation: any *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 (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)
}
if licErr.IsUnsupportedTag {
err = errors.New(config.UnsupportedTagMessage())
}
opts.Telemetry.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{
EventType: telemetry.LifecycleStartError,
Expand Down
242 changes: 242 additions & 0 deletions internal/container/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import (
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"sync/atomic"
"testing"

"github.com/localstack/lstk/internal/api"
"github.com/localstack/lstk/internal/caller"
"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/log"
Expand Down Expand Up @@ -383,6 +386,245 @@ func TestAgentEnv(t *testing.T) {
"an agent running inside CI still forwards its AI_AGENT identity")
}

func TestPullImages_FallsBackToLocalImageWhenPullFails(t *testing.T) {
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)

c := runtime.ContainerConfig{
Image: "my-registry.internal/localstack-pro:latest",
Name: "localstack-aws",
EmulatorType: config.EmulatorAWS,
}

mockRT.EXPECT().Remove(gomock.Any(), c.Name).Return(nil)
mockRT.EXPECT().PullImage(gomock.Any(), c.Image, gomock.Any()).DoAndReturn(
func(_ context.Context, _ string, progress chan<- runtime.PullProgress) error {
close(progress)
return errors.New("tls: failed to verify certificate")
})
mockRT.EXPECT().ImageExists(gomock.Any(), c.Image).Return(true, nil)

var out bytes.Buffer
sink := output.NewPlainSink(&out)
tel := telemetry.New("", true)

pulled, err := pullImages(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c})

require.NoError(t, err, "a pull failure must not be fatal when the image is available locally")
assert.False(t, pulled[c.Name], "the image was not pulled, only found locally")
assert.Contains(t, out.String(), "using the local image")
}

func TestPullImages_FailsWhenPullFailsAndImageMissing(t *testing.T) {
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)

c := runtime.ContainerConfig{
Image: "localstack/localstack-pro:latest",
Name: "localstack-aws",
EmulatorType: config.EmulatorAWS,
}

mockRT.EXPECT().Remove(gomock.Any(), c.Name).Return(nil)
mockRT.EXPECT().PullImage(gomock.Any(), c.Image, gomock.Any()).DoAndReturn(
func(_ context.Context, _ string, progress chan<- runtime.PullProgress) error {
close(progress)
return errors.New("tls: failed to verify certificate")
})
mockRT.EXPECT().ImageExists(gomock.Any(), c.Image).Return(false, nil)

var out bytes.Buffer
sink := output.NewPlainSink(&out)
tel := telemetry.New("", true)

_, err := pullImages(context.Background(), mockRT, sink, tel, []runtime.ContainerConfig{c})

require.Error(t, err)
assert.True(t, output.IsSilent(err), "error should be silent since an ErrorEvent was emitted")
assert.Contains(t, out.String(), "Failed to pull localstack/localstack-pro:latest")
}

func TestPullImages_PropagatesContextCancellation(t *testing.T) {
// A cancelled caller context (Ctrl+C) during a pull must surface as
// cancellation — not be reported as a pull failure nor probed as a local-image
// fallback (which would also emit a spurious start-error telemetry event).
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)

c := runtime.ContainerConfig{
Image: "localstack/localstack-pro:latest",
Name: "localstack-aws",
EmulatorType: config.EmulatorAWS,
}

ctx, cancel := context.WithCancel(context.Background())

mockRT.EXPECT().Remove(gomock.Any(), c.Name).Return(nil)
mockRT.EXPECT().PullImage(gomock.Any(), c.Image, gomock.Any()).DoAndReturn(
func(_ context.Context, _ string, progress chan<- runtime.PullProgress) error {
cancel() // the user interrupts mid-pull
close(progress)
return context.Canceled
})
// ImageExists must NOT be called once the context is cancelled; gomock fails
// the test if it is (no EXPECT registered).

var out bytes.Buffer
sink := output.NewPlainSink(&out)
tel := telemetry.New("", true)

_, err := pullImages(ctx, mockRT, sink, tel, []runtime.ContainerConfig{c})

require.ErrorIs(t, err, context.Canceled)
assert.False(t, output.IsSilent(err), "a user cancel is not a silent start error")
assert.NotContains(t, out.String(), "Failed to pull", "a cancel must not be reported as a pull failure")
assert.NotContains(t, out.String(), "using the local image")
}

func TestValidateLicense_ContinuesWhenServerUnreachable(t *testing.T) {
// A closed port yields a transport-level failure (not an *api.LicenseError),
// which models an offline/proxy environment that cannot reach the license server.
opts := StartOptions{
PlatformClient: api.NewPlatformClient("http://127.0.0.1:1", 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, "an unreachable license server must not block the start")
assert.Contains(t, out.String(), "Could not reach the license server")
}

func TestValidateLicense_FailsOnServerRejection(t *testing.T) {
// A definitive rejection (HTTP 403 -> *api.LicenseError) must remain fatal.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
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",
}

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")
}

func TestValidateLicense_PropagatesContextCancellation(t *testing.T) {
// A cancelled caller context (Ctrl+C) must surface as cancellation, not be
// mistaken for an offline license server.
opts := StartOptions{
PlatformClient: api.NewPlatformClient("http://127.0.0.1:1", 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",
}

ctx, cancel := context.WithCancel(context.Background())
cancel()

var out bytes.Buffer
sink := output.NewPlainSink(&out)

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",
"a cancellation must not be reported as an unreachable license server")
}

func TestTryPrePullLicenseValidation_SkipsCheckWhenImageIsLocal(t *testing.T) {
// A pinned image already present locally is not pulled (see pullImages), so the
// CLI must not run a license pre-flight either — the container validates its own
// bundled license at startup. This keeps the local-image start fully offline.
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)

c := runtime.ContainerConfig{
Image: "my-registry.internal/localstack-pro:2026.4",
Name: "localstack-aws",
EmulatorType: config.EmulatorAWS,
ProductName: "localstack-pro",
Tag: "2026.4",
}
mockRT.EXPECT().ImageExists(gomock.Any(), c.Image).Return(true, nil)

var licenseHits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&licenseHits, 1)
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()

opts := StartOptions{
PlatformClient: api.NewPlatformClient(srv.URL, log.Nop()),
Logger: log.Nop(),
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"))

require.NoError(t, err)
assert.Empty(t, postPull, "a pinned local image needs no post-pull validation")
assert.Equal(t, int32(0), atomic.LoadInt32(&licenseHits), "the license server must not be contacted for a local image")
}

func TestTryPrePullLicenseValidation_ChecksWhenImageMissing(t *testing.T) {
// The pre-flight check still runs (and stays fatal on rejection) when the pinned
// image is not present locally — a pull will happen, so failing fast is correct.
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",
}
mockRT.EXPECT().ImageExists(gomock.Any(), c.Image).Return(false, nil)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()

opts := StartOptions{
PlatformClient: api.NewPlatformClient(srv.URL, log.Nop()),
Logger: log.Nop(),
Telemetry: telemetry.New("", true),
}

_, err := tryPrePullLicenseValidation(context.Background(), mockRT, output.NewPlainSink(io.Discard), opts, []runtime.ContainerConfig{c}, "tok", filepath.Join(t.TempDir(), "license.json"))

require.Error(t, err, "a missing local image must still fail fast on a server rejection")
}

func TestStartContainers_SnowflakeLicenseError(t *testing.T) {
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)
Expand Down
10 changes: 6 additions & 4 deletions internal/runtime/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ func windowsDockerStartCommand(getenv func(string) string, lookPath func(string)
}

func (d *DockerRuntime) PullImage(ctx context.Context, imageName string, progress chan<- PullProgress) error {
// Close progress on every return path, including when ImagePull itself fails
// (e.g. the registry is unreachable), so callers ranging over it never block.
if progress != nil {
defer close(progress)
}

reader, err := d.client.ImagePull(ctx, imageName, client.ImagePullOptions{})
if err != nil {
return err
Expand All @@ -191,10 +197,6 @@ func (d *DockerRuntime) PullImage(ctx context.Context, imageName string, progres
}
}()

if progress != nil {
defer close(progress)
}

decoder := json.NewDecoder(reader)
for {
var msg struct {
Expand Down
Loading
Loading