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
14 changes: 14 additions & 0 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions internal/container/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
10 changes: 10 additions & 0 deletions internal/runtime/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
47 changes: 31 additions & 16 deletions internal/runtime/mock_runtime.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions test/integration/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading