diff --git a/CLAUDE.md b/CLAUDE.md index 650b8a7d..e721457f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/:` 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 ` to set up CLI integration for an emulator type: diff --git a/internal/container/start.go b/internal/container/start.go index 84fe145c..d8a19ffb 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -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 } @@ -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(), @@ -360,7 +374,7 @@ 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() { @@ -368,6 +382,14 @@ func tryPrePullLicenseValidation(ctx context.Context, sink output.Sink, opts Sta } 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 } @@ -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, diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 4d25bf4f..a74a1c39 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -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" @@ -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) diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index 0dad2eb5..f4186ae8 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -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 @@ -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 { diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 13e0671d..fa867e5c 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -15,6 +15,7 @@ import ( "runtime" "strconv" "strings" + "sync/atomic" "testing" "time" @@ -693,6 +694,174 @@ image = "lstk-nonexistent-custom-image" assert.Contains(t, combined, "Failed to pull lstk-nonexistent-custom-image:latest") } +// TestStartFallsBackToLocalImageWhenPullFails verifies the offline degradation +// path for image pulls: when the configured image cannot be pulled (registry +// unreachable, or the image was never published) but is already present locally, +// lstk warns and starts the local image instead of failing. +// +// The scenario is reproduced without cutting off the network by tagging a real +// LocalStack image under a name no registry can serve: the pull fails, but +// ImageExists reports the image locally, so the fallback fires. A valid token is +// still required for the (real) container to activate and become healthy. +func TestStartFallsBackToLocalImageWhenPullFails(t *testing.T) { + requireDocker(t) + authToken := env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + + const sourceImage = "localstack/localstack-pro:latest" + const localImage = "lstk-offline-fallback-test" + reader, err := dockerClient.ImagePull(ctx, sourceImage, client.ImagePullOptions{}) + require.NoError(t, err, "failed to pull source image") + _, _ = io.Copy(io.Discard, reader) + _ = reader.Close() + + _, err = dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: sourceImage, Target: localImage + ":latest"}) + require.NoError(t, err, "failed to tag local image") + t.Cleanup(func() { + _, _ = dockerClient.ImageRemove(context.Background(), localImage+":latest", client.ImageRemoveOptions{Force: true}) + }) + + configContent := fmt.Sprintf(` +[[containers]] +type = "aws" +tag = "latest" +port = "4566" +image = %q +`, localImage) + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + // Use the real (inherited) HOME like the other fresh-start container tests + // (TestStartCommandSucceedsWithValidToken et al.): a started container writes + // root-owned files into $HOME/.cache/lstk/volume that t.TempDir's cleanup — + // running as the unprivileged test user — cannot unlink. Config is still + // isolated via --config below. + e := env.With(env.APIEndpoint, mockServer.URL). + With(env.AuthToken, authToken) + stdout, stderr, err := runLstk(t, ctx, "", e, "--config", configFile, "--non-interactive", "start") + require.NoError(t, err, "lstk start should fall back to the local image: %s", stderr) + requireExitCode(t, 0, err) + + assert.Contains(t, stdout+stderr, "using the local image", "expected the local-image fallback warning") + + 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 from the local image") +} + +// TestStartContinuesWhenLicenseServerUnreachable verifies the offline degradation +// path for license validation: when the license server cannot be reached — a +// transport-level failure (offline/proxy/cert), not a definitive rejection — lstk +// skips the pre-flight check and lets the container validate its own bundled +// license instead of blocking the start. +// +// The endpoint is made unreachable by closing the mock server immediately, so the +// pre-flight request is refused at the transport level rather than returning an +// *api.LicenseError. A "latest" tag defers validation until after the (successful) +// pull, so the unreachable endpoint is hit at the post-pull check. +func TestStartContinuesWhenLicenseServerUnreachable(t *testing.T) { + requireDocker(t) + authToken := env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + + unreachable := createMockLicenseServer(true) + unreachableURL := unreachable.URL + unreachable.Close() + + // Use the real (inherited) HOME like the other fresh-start container tests: a + // started container writes root-owned files into $HOME/.cache/lstk/volume that + // t.TempDir's cleanup — running as the unprivileged test user — cannot unlink. + e := env.With(env.APIEndpoint, unreachableURL). + With(env.AuthToken, authToken) + stdout, stderr, err := runLstk(t, ctx, "", e, "--non-interactive", "start") + require.NoError(t, err, "lstk start should continue when the license server is unreachable: %s", stderr) + requireExitCode(t, 0, err) + + assert.Contains(t, stdout+stderr, "Could not reach the license server", "expected the license-unreachable warning") + + 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") +} + +// TestStartSkipsPullAndLicenseCheckWhenImageIsLocal verifies the offline-friendly +// success path from the #325 review: when a pinned image is configured and already +// present locally, lstk pulls nothing and never contacts the license server — the +// container validates its own bundled license at startup. Covers all four bullets: +// image set in config, found locally and started, no pull, no CLI license check. +// +// A real LocalStack image is tagged under a custom, pinned reference that no registry +// can serve (so any pull would fail loudly), and the license endpoint points at a +// server that fails the test if it is ever contacted. +func TestStartSkipsPullAndLicenseCheckWhenImageIsLocal(t *testing.T) { + requireDocker(t) + authToken := env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + + const sourceImage = "localstack/localstack-pro:latest" + const localImage = "lstk-local-only-test" + const pinnedTag = "1.0.0" + reader, err := dockerClient.ImagePull(ctx, sourceImage, client.ImagePullOptions{}) + require.NoError(t, err, "failed to pull source image") + _, _ = io.Copy(io.Discard, reader) + _ = reader.Close() + + _, err = dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: sourceImage, Target: localImage + ":" + pinnedTag}) + require.NoError(t, err, "failed to tag local image") + t.Cleanup(func() { + _, _ = dockerClient.ImageRemove(context.Background(), localImage+":"+pinnedTag, client.ImageRemoveOptions{Force: true}) + }) + + var licenseHits int32 + licenseServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&licenseHits, 1) + w.WriteHeader(http.StatusForbidden) // would make a pre-flight check fatal + })) + defer licenseServer.Close() + + configContent := fmt.Sprintf(` +[[containers]] +type = "aws" +tag = %q +port = "4566" +image = %q +`, pinnedTag, localImage) + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + // Real (inherited) HOME like the other fresh-start container tests: the started + // container writes root-owned files into $HOME/.cache/lstk/volume. Config is + // isolated via --config below. + e := env.With(env.APIEndpoint, licenseServer.URL).With(env.AuthToken, authToken) + stdout, stderr, err := runLstk(t, ctx, "", e, "--config", configFile, "--non-interactive", "start") + require.NoError(t, err, "lstk start should use the local image without a license check: %s", stderr) + requireExitCode(t, 0, err) + + combined := stdout + stderr + assert.Contains(t, combined, "Using local image", "the local pinned image must be reused, not pulled") + assert.NotContains(t, combined, "Pulling", "no image should be pulled when it is present locally") + assert.Equal(t, int32(0), atomic.LoadInt32(&licenseHits), "the license server must never be contacted for a local image") + + 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 from the local image") +} + func cleanup() { ctx := context.Background() // ContainerRemove with Force already SIGKILLs the container; an explicit