diff --git a/internal/container/start.go b/internal/container/start.go index 5da3d270..84fe145c 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -314,6 +314,20 @@ func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel * return nil, fmt.Errorf("failed to remove existing container %s: %w", c.Name, err) } + // Reuse a locally present image for pinned tags instead of re-pulling. + // Floating "latest"/empty tags always pull until pull_policy support lands. + if c.Tag != "" && c.Tag != "latest" { + exists, err := rt.ImageExists(ctx, c.Image) + if err != nil { + return nil, fmt.Errorf("failed to check for local image %s: %w", c.Image, err) + } + if exists { + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Using local image %s", c.Image)}) + pulled[c.Name] = false + continue + } + } + sink.Emit(output.SpinnerStart(fmt.Sprintf("Pulling %s", c.Image))) sink.Emit(output.ContainerStatusEvent{Phase: "pulling", Container: c.Image}) progress := make(chan runtime.PullProgress) diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 822f9695..4d25bf4f 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -474,3 +474,59 @@ func TestStartContainers_AzureLicenseError(t *testing.T) { t.Fatal("no telemetry event received") } } + +func TestPullImages_ReusesLocalImageWhenPresent(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockTel := telemetry.New("", true) + + c := runtime.ContainerConfig{ + Image: "localstack/localstack-pro:3.5.0", + Name: "localstack-aws", + EmulatorType: config.EmulatorAWS, + Tag: "3.5.0", + } + + mockRT.EXPECT().Remove(gomock.Any(), c.Name).Return(nil) + mockRT.EXPECT().ImageExists(gomock.Any(), c.Image).Return(true, nil) + // No PullImage call expected when the image is already present. + + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + pulled, err := pullImages(context.Background(), mockRT, sink, mockTel, []runtime.ContainerConfig{c}) + + require.NoError(t, err) + assert.False(t, pulled[c.Name], "telemetry must not count a reused image as pulled") + assert.Contains(t, out.String(), "Using local image localstack/localstack-pro:3.5.0") +} + +func TestPullImages_PullsWhenImageMissing(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockTel := telemetry.New("", true) + + c := runtime.ContainerConfig{ + Image: "localstack/localstack-pro:3.5.0", + Name: "localstack-aws", + EmulatorType: config.EmulatorAWS, + Tag: "3.5.0", + } + + mockRT.EXPECT().Remove(gomock.Any(), c.Name).Return(nil) + mockRT.EXPECT().ImageExists(gomock.Any(), c.Image).Return(false, nil) + mockRT.EXPECT().PullImage(gomock.Any(), c.Image, gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, progress chan<- runtime.PullProgress) error { + close(progress) + return nil + }) + + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + pulled, err := pullImages(context.Background(), mockRT, sink, mockTel, []runtime.ContainerConfig{c}) + + require.NoError(t, err) + assert.True(t, pulled[c.Name], "a freshly pulled image must be counted as pulled") + assert.Contains(t, out.String(), "Pulled localstack/localstack-pro:3.5.0") +} diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index 6040228b..0dad2eb5 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -471,3 +471,13 @@ func (d *DockerRuntime) GetImageVersion(ctx context.Context, imageName string) ( return "", fmt.Errorf("LOCALSTACK_BUILD_VERSION not found in image environment") } + +func (d *DockerRuntime) ImageExists(ctx context.Context, image string) (bool, error) { + if _, err := d.client.ImageInspect(ctx, image); err != nil { + if errdefs.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("failed to inspect image: %w", err) + } + return true, nil +} diff --git a/internal/runtime/mock_runtime.go b/internal/runtime/mock_runtime.go index b47a6b7b..0fbe8e62 100644 --- a/internal/runtime/mock_runtime.go +++ b/internal/runtime/mock_runtime.go @@ -43,21 +43,6 @@ func (m *MockRuntime) EXPECT() *MockRuntimeMockRecorder { return m.recorder } -// FindRunningByImage mocks base method. -func (m *MockRuntime) FindRunningByImage(ctx context.Context, imageRepos []string, containerPort string) (*RunningContainer, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "FindRunningByImage", ctx, imageRepos, containerPort) - ret0, _ := ret[0].(*RunningContainer) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// FindRunningByImage indicates an expected call of FindRunningByImage. -func (mr *MockRuntimeMockRecorder) FindRunningByImage(ctx, imageRepos, containerPort any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindRunningByImage", reflect.TypeOf((*MockRuntime)(nil).FindRunningByImage), ctx, imageRepos, containerPort) -} - // ContainerEnv mocks base method. func (m *MockRuntime) ContainerEnv(ctx context.Context, containerName string) ([]string, error) { m.ctrl.T.Helper() @@ -100,8 +85,23 @@ func (mr *MockRuntimeMockRecorder) EmitUnhealthyError(sink, err any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EmitUnhealthyError", reflect.TypeOf((*MockRuntime)(nil).EmitUnhealthyError), sink, err) } +// FindRunningByImage mocks base method. +func (m *MockRuntime) FindRunningByImage(ctx context.Context, imageRepos []string, containerPort string) (*RunningContainer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindRunningByImage", ctx, imageRepos, containerPort) + ret0, _ := ret[0].(*RunningContainer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindRunningByImage indicates an expected call of FindRunningByImage. +func (mr *MockRuntimeMockRecorder) FindRunningByImage(ctx, imageRepos, containerPort any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindRunningByImage", reflect.TypeOf((*MockRuntime)(nil).FindRunningByImage), ctx, imageRepos, containerPort) +} + // GetBoundPort mocks base method. -func (m *MockRuntime) GetBoundPort(ctx context.Context, containerName string, containerPort string) (string, error) { +func (m *MockRuntime) GetBoundPort(ctx context.Context, containerName, containerPort string) (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetBoundPort", ctx, containerName, containerPort) ret0, _ := ret[0].(string) @@ -130,6 +130,21 @@ func (mr *MockRuntimeMockRecorder) GetImageVersion(ctx, imageName any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetImageVersion", reflect.TypeOf((*MockRuntime)(nil).GetImageVersion), ctx, imageName) } +// ImageExists mocks base method. +func (m *MockRuntime) ImageExists(ctx context.Context, image string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ImageExists", ctx, image) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ImageExists indicates an expected call of ImageExists. +func (mr *MockRuntimeMockRecorder) ImageExists(ctx, image any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ImageExists", reflect.TypeOf((*MockRuntime)(nil).ImageExists), ctx, image) +} + // IsHealthy mocks base method. func (m *MockRuntime) IsHealthy(ctx context.Context) error { m.ctrl.T.Helper() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index c8bfe37b..649c05da 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -64,6 +64,8 @@ type Runtime interface { Logs(ctx context.Context, containerID string, tail int) (string, error) StreamLogs(ctx context.Context, containerID string, out io.Writer, follow bool) error GetImageVersion(ctx context.Context, imageName string) (string, error) + // ImageExists reports whether the given image is already present locally. + ImageExists(ctx context.Context, image string) (bool, error) // GetBoundPort returns the host port bound to the given container port (e.g. "4566/tcp"). GetBoundPort(ctx context.Context, containerName string, containerPort string) (string, error) FindRunningByImage(ctx context.Context, imageRepos []string, containerPort string) (*RunningContainer, error) diff --git a/test/integration/start_test.go b/test/integration/start_test.go index c595a8ec..bfc3ab4e 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -51,6 +51,49 @@ func TestStartCommandSucceedsWithValidToken(t *testing.T) { "persistence bullet must be omitted when --persist is not set") } +// PRO-323: a pinned image already present locally must be reused, not re-pulled. +// Tags the lightweight test image under a pinned localstack-pro tag so the image +// is present locally; lstk should skip the pull and emit "Using local image". +// We only assert the pull decision (emitted before the container starts) — the +// stand-in image is not a real emulator, so the subsequent start fails fast when +// it exits. A dedicated host port keeps this off the shared 4566 used by the +// other container tests. +func TestStartCommandReusesLocalImageWhenPresent(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + + const pinnedTag = "reuse-local-test" + const pinnedImage = "localstack/localstack-pro:" + pinnedTag + reader, err := dockerClient.ImagePull(ctx, testImage, client.ImagePullOptions{}) + require.NoError(t, err, "failed to pull test image") + _, _ = io.Copy(io.Discard, reader) + _ = reader.Close() + _, err = dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: testImage, Target: pinnedImage}) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = dockerClient.ImageRemove(context.Background(), pinnedImage, client.ImageRemoveOptions{}) + }) + + home := t.TempDir() + configFile := filepath.Join(home, "config.toml") + require.NoError(t, os.WriteFile(configFile, + []byte(fmt.Sprintf("[[containers]]\ntype = \"aws\"\ntag = %q\nport = \"4599\"\n", pinnedTag)), 0644)) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + e := append(testEnvWithHome(home, ""), + string(env.APIEndpoint)+"="+mockServer.URL, + string(env.AuthToken)+"=fake-token") + stdout, _, _ := runLstk(t, ctx, "", e, "--config", configFile, "start") + + assert.Contains(t, stdout, "Using local image "+pinnedImage, "a pinned image present locally should be reused") + assert.NotContains(t, stdout, "Pulling", "lstk must not re-pull an image that is already present") +} + func TestStartCommandSucceedsWithKeyringToken(t *testing.T) { requireDocker(t) cleanup()