Skip to content

Commit 90daffd

Browse files
Reuse local image when already present for pinned tags (#339)
Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
1 parent c07621c commit 90daffd

6 files changed

Lines changed: 156 additions & 16 deletions

File tree

internal/container/start.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,20 @@ func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *
314314
return nil, fmt.Errorf("failed to remove existing container %s: %w", c.Name, err)
315315
}
316316

317+
// Reuse a locally present image for pinned tags instead of re-pulling.
318+
// Floating "latest"/empty tags always pull until pull_policy support lands.
319+
if c.Tag != "" && c.Tag != "latest" {
320+
exists, err := rt.ImageExists(ctx, c.Image)
321+
if err != nil {
322+
return nil, fmt.Errorf("failed to check for local image %s: %w", c.Image, err)
323+
}
324+
if exists {
325+
sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Using local image %s", c.Image)})
326+
pulled[c.Name] = false
327+
continue
328+
}
329+
}
330+
317331
sink.Emit(output.SpinnerStart(fmt.Sprintf("Pulling %s", c.Image)))
318332
sink.Emit(output.ContainerStatusEvent{Phase: "pulling", Container: c.Image})
319333
progress := make(chan runtime.PullProgress)

internal/container/start_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,3 +474,59 @@ func TestStartContainers_AzureLicenseError(t *testing.T) {
474474
t.Fatal("no telemetry event received")
475475
}
476476
}
477+
478+
func TestPullImages_ReusesLocalImageWhenPresent(t *testing.T) {
479+
ctrl := gomock.NewController(t)
480+
mockRT := runtime.NewMockRuntime(ctrl)
481+
mockTel := telemetry.New("", true)
482+
483+
c := runtime.ContainerConfig{
484+
Image: "localstack/localstack-pro:3.5.0",
485+
Name: "localstack-aws",
486+
EmulatorType: config.EmulatorAWS,
487+
Tag: "3.5.0",
488+
}
489+
490+
mockRT.EXPECT().Remove(gomock.Any(), c.Name).Return(nil)
491+
mockRT.EXPECT().ImageExists(gomock.Any(), c.Image).Return(true, nil)
492+
// No PullImage call expected when the image is already present.
493+
494+
var out bytes.Buffer
495+
sink := output.NewPlainSink(&out)
496+
497+
pulled, err := pullImages(context.Background(), mockRT, sink, mockTel, []runtime.ContainerConfig{c})
498+
499+
require.NoError(t, err)
500+
assert.False(t, pulled[c.Name], "telemetry must not count a reused image as pulled")
501+
assert.Contains(t, out.String(), "Using local image localstack/localstack-pro:3.5.0")
502+
}
503+
504+
func TestPullImages_PullsWhenImageMissing(t *testing.T) {
505+
ctrl := gomock.NewController(t)
506+
mockRT := runtime.NewMockRuntime(ctrl)
507+
mockTel := telemetry.New("", true)
508+
509+
c := runtime.ContainerConfig{
510+
Image: "localstack/localstack-pro:3.5.0",
511+
Name: "localstack-aws",
512+
EmulatorType: config.EmulatorAWS,
513+
Tag: "3.5.0",
514+
}
515+
516+
mockRT.EXPECT().Remove(gomock.Any(), c.Name).Return(nil)
517+
mockRT.EXPECT().ImageExists(gomock.Any(), c.Image).Return(false, nil)
518+
mockRT.EXPECT().PullImage(gomock.Any(), c.Image, gomock.Any()).
519+
DoAndReturn(func(_ context.Context, _ string, progress chan<- runtime.PullProgress) error {
520+
close(progress)
521+
return nil
522+
})
523+
524+
var out bytes.Buffer
525+
sink := output.NewPlainSink(&out)
526+
527+
pulled, err := pullImages(context.Background(), mockRT, sink, mockTel, []runtime.ContainerConfig{c})
528+
529+
require.NoError(t, err)
530+
assert.True(t, pulled[c.Name], "a freshly pulled image must be counted as pulled")
531+
assert.Contains(t, out.String(), "Pulled localstack/localstack-pro:3.5.0")
532+
}

internal/runtime/docker.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,3 +471,13 @@ func (d *DockerRuntime) GetImageVersion(ctx context.Context, imageName string) (
471471

472472
return "", fmt.Errorf("LOCALSTACK_BUILD_VERSION not found in image environment")
473473
}
474+
475+
func (d *DockerRuntime) ImageExists(ctx context.Context, image string) (bool, error) {
476+
if _, err := d.client.ImageInspect(ctx, image); err != nil {
477+
if errdefs.IsNotFound(err) {
478+
return false, nil
479+
}
480+
return false, fmt.Errorf("failed to inspect image: %w", err)
481+
}
482+
return true, nil
483+
}

internal/runtime/mock_runtime.go

Lines changed: 31 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/runtime/runtime.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ type Runtime interface {
6464
Logs(ctx context.Context, containerID string, tail int) (string, error)
6565
StreamLogs(ctx context.Context, containerID string, out io.Writer, follow bool) error
6666
GetImageVersion(ctx context.Context, imageName string) (string, error)
67+
// ImageExists reports whether the given image is already present locally.
68+
ImageExists(ctx context.Context, image string) (bool, error)
6769
// GetBoundPort returns the host port bound to the given container port (e.g. "4566/tcp").
6870
GetBoundPort(ctx context.Context, containerName string, containerPort string) (string, error)
6971
FindRunningByImage(ctx context.Context, imageRepos []string, containerPort string) (*RunningContainer, error)

test/integration/start_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,49 @@ func TestStartCommandSucceedsWithValidToken(t *testing.T) {
5151
"persistence bullet must be omitted when --persist is not set")
5252
}
5353

54+
// PRO-323: a pinned image already present locally must be reused, not re-pulled.
55+
// Tags the lightweight test image under a pinned localstack-pro tag so the image
56+
// is present locally; lstk should skip the pull and emit "Using local image".
57+
// We only assert the pull decision (emitted before the container starts) — the
58+
// stand-in image is not a real emulator, so the subsequent start fails fast when
59+
// it exits. A dedicated host port keeps this off the shared 4566 used by the
60+
// other container tests.
61+
func TestStartCommandReusesLocalImageWhenPresent(t *testing.T) {
62+
requireDocker(t)
63+
cleanup()
64+
t.Cleanup(cleanup)
65+
66+
ctx := testContext(t)
67+
68+
const pinnedTag = "reuse-local-test"
69+
const pinnedImage = "localstack/localstack-pro:" + pinnedTag
70+
reader, err := dockerClient.ImagePull(ctx, testImage, client.ImagePullOptions{})
71+
require.NoError(t, err, "failed to pull test image")
72+
_, _ = io.Copy(io.Discard, reader)
73+
_ = reader.Close()
74+
_, err = dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: testImage, Target: pinnedImage})
75+
require.NoError(t, err)
76+
t.Cleanup(func() {
77+
_, _ = dockerClient.ImageRemove(context.Background(), pinnedImage, client.ImageRemoveOptions{})
78+
})
79+
80+
home := t.TempDir()
81+
configFile := filepath.Join(home, "config.toml")
82+
require.NoError(t, os.WriteFile(configFile,
83+
[]byte(fmt.Sprintf("[[containers]]\ntype = \"aws\"\ntag = %q\nport = \"4599\"\n", pinnedTag)), 0644))
84+
85+
mockServer := createMockLicenseServer(true)
86+
defer mockServer.Close()
87+
88+
e := append(testEnvWithHome(home, ""),
89+
string(env.APIEndpoint)+"="+mockServer.URL,
90+
string(env.AuthToken)+"=fake-token")
91+
stdout, _, _ := runLstk(t, ctx, "", e, "--config", configFile, "start")
92+
93+
assert.Contains(t, stdout, "Using local image "+pinnedImage, "a pinned image present locally should be reused")
94+
assert.NotContains(t, stdout, "Pulling", "lstk must not re-pull an image that is already present")
95+
}
96+
5497
func TestStartCommandSucceedsWithKeyringToken(t *testing.T) {
5598
requireDocker(t)
5699
cleanup()

0 commit comments

Comments
 (0)