From 8b97451d87dd01e398dbbfa183cb45a83f522175 Mon Sep 17 00:00:00 2001 From: Divyanshu Pandey Date: Thu, 30 Jul 2026 23:20:24 +0530 Subject: [PATCH] Pull images used in volumes with type=image This extends GetDependentImages to also return type=image volume sources, so that docker compose pull and up pull these images uniformly alongside pre_start hook images. Dependent images that are defined as service images in the project follow their service-level pull policy and build configuration, ensuring buildable volume images are skipped when --ignore-buildable is used and avoiding duplicate pulls. Fixes #13809 Signed-off-by: Divyanshu Pandey --- pkg/api/api.go | 12 +- pkg/api/api_test.go | 52 ++++++ pkg/compose/pull.go | 88 +++++----- pkg/compose/pull_test.go | 335 +++++++++++++++++++++++++++++++++++---- 4 files changed, 418 insertions(+), 69 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 39db0897812..dbe24154ebb 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -870,10 +870,9 @@ func GetImageNameOrDefault(service types.ServiceConfig, projectName string) stri } // GetDependentImages returns the additional images a service depends on beyond -// its main image. Currently this is the set of pre_start hook images, which run -// as ephemeral init containers with their own image. A hook without an explicit -// image, or one reusing the service image, is skipped as that image is already -// accounted for as the service image. +// its main image. This includes pre_start hook images and type=image volume +// sources. Images that match the service image are skipped as they are already +// accounted for. func GetDependentImages(service types.ServiceConfig, projectName string) []string { serviceImage := GetImageNameOrDefault(service, projectName) var images []string @@ -882,5 +881,10 @@ func GetDependentImages(service types.ServiceConfig, projectName string) []strin images = append(images, hook.Image) } } + for _, vol := range service.Volumes { + if vol.Type == types.VolumeTypeImage && vol.Source != serviceImage { + images = append(images, vol.Source) + } + } return images } diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index 65f1fbbb85b..8a7a506a608 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -116,6 +116,58 @@ func TestGetDependentImages(t *testing.T) { }, expected: nil, }, + { + name: "type=image volume source is collected", + service: types.ServiceConfig{ + ContainerSpec: types.ContainerSpec{ + Image: "nginx:alpine", + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeImage, Source: "myorg/assets:latest", Target: "/srv/static"}, + }, + }, + }, + expected: []string{"myorg/assets:latest"}, + }, + { + name: "type=image volume reusing the service image is ignored", + service: types.ServiceConfig{ + ContainerSpec: types.ContainerSpec{ + Image: "myorg/shared:latest", + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeImage, Source: "myorg/shared:latest", Target: "/data"}, + }, + }, + }, + expected: nil, + }, + { + name: "non-image volume types are ignored", + service: types.ServiceConfig{ + ContainerSpec: types.ContainerSpec{ + Image: "alpine:3.20", + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeBind, Source: "/host/path", Target: "/container"}, + {Type: types.VolumeTypeVolume, Source: "myvolume", Target: "/data"}, + }, + }, + }, + expected: nil, + }, + { + name: "hook and volume images are both collected", + service: types.ServiceConfig{ + PreStart: []types.PreStartHook{ + {ContainerSpec: types.ContainerSpec{Image: "init:latest", Command: types.ShellCommand{"echo", "init"}}}, + }, + ContainerSpec: types.ContainerSpec{ + Image: "alpine:3.20", + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeImage, Source: "myorg/data:1.0", Target: "/data"}, + }, + }, + }, + expected: []string{"init:latest", "myorg/data:1.0"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index e55655a5c10..fb1d38856b7 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -84,7 +84,7 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts err = p.pullServiceImages(ctx) if err == nil { - err = p.pullHookImages(ctx) + err = p.pullDependentImages(ctx) } if err != nil { // join already-scheduled pulls before returning: bailing out with @@ -135,10 +135,12 @@ func (p *imagePuller) pullServiceImages(ctx context.Context) error { } if !pullRequired { p.eventSkippedPull("Image "+service.Image, skipReason) + p.scheduled[service.Image] = service.Name continue } if service.Build != nil && p.opts.IgnoreBuildable { p.eventSkippedPull("Image "+service.Image, "Image can be built") + p.scheduled[service.Image] = service.Name continue } if _, ok := p.scheduled[service.Image]; ok { @@ -179,19 +181,22 @@ func (p *imagePuller) runServicePull(ctx context.Context, idx int, service types return nil } -// pullHookImages schedules pulls for pre_start hook images, which run as -// ephemeral init containers with their own registry image. They have no pull -// policy of their own, so we inherit the parent service's policy for skip -// decisions — through the same shouldPullImage decision as the service image. -// Unlike the service image, a hook image can't be built, so `build` falls -// back to pull-if-missing instead of exempting it from pulling. -func (p *imagePuller) pullHookImages(ctx context.Context) error { +// pullDependentImages schedules pulls for dependent images (pre_start hooks +// and type=image volume sources). They have no pull policy of their own, so we +// inherit the parent service's policy for skip decisions — through the same +// shouldPullImage decision as the service image. A dependent image that is +// produced by a buildable service in the project is skipped when +// --ignore-buildable is set, consistent with how service images are handled. +func (p *imagePuller) pullDependentImages(ctx context.Context) error { for name, service := range p.project.Services { hookPolicy := service.PullPolicy if hookPolicy == types.PullPolicyBuild { hookPolicy = types.PullPolicyMissing } for _, img := range api.GetDependentImages(service, p.project.Name) { + if isServiceImage(img, p.project.Services) { + continue + } pullRequired, skipReason, err := shouldPullImage(types.ServiceConfig{Name: name, ContainerSpec: types.ContainerSpec{Image: img, PullPolicy: hookPolicy}}, p.images) if err != nil { return err @@ -202,15 +207,23 @@ func (p *imagePuller) pullHookImages(ctx context.Context) error { } continue } + if p.opts.IgnoreBuildable && isServiceImageToBuild(types.ServiceConfig{ContainerSpec: types.ContainerSpec{Image: img}}, p.project.Services) { + p.eventSkippedPull("Image "+img, "Image can be built") + continue + } if _, ok := p.scheduled[img]; ok { continue } p.scheduled[img] = name - hookService := types.ServiceConfig{Name: name, ContainerSpec: types.ContainerSpec{Image: img}} + depService := types.ServiceConfig{Name: name, ContainerSpec: types.ContainerSpec{Image: img}} p.eg.Go(func() error { - err := p.pullServiceImage(ctx, hookService, p.opts.Quiet, p.project.Environment["DOCKER_DEFAULT_PLATFORM"]) + err := p.pullServiceImage(ctx, depService, p.opts.Quiet, p.project.Environment["DOCKER_DEFAULT_PLATFORM"]) if err != nil && !p.opts.IgnoreFailures { - // fail fast: a hook image can't be built as a fallback + if isServiceImageToBuild(depService, p.project.Services) { + // image can be built, so we can ignore pull failure + return nil + } + // fail fast: dependent image can't be built as a fallback return err } return nil @@ -420,24 +433,13 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types. } if pull { needPull[name] = service - scheduled[service.Image] = true } - for i, vol := range service.Volumes { - if vol.Type == types.VolumeTypeImage { - if _, ok := images[vol.Source]; !ok { - // Hack: create a fake ServiceConfig so we pull missing volume image - n := fmt.Sprintf("%s:volume %d", name, i) - needPull[n] = types.ServiceConfig{ - Name: n, - ContainerSpec: types.ContainerSpec{Image: vol.Source}, - } - scheduled[vol.Source] = true - } - } + if service.Image != "" { + scheduled[service.Image] = true } } - if err := addPreStartHookPulls(project, images, needPull, scheduled); err != nil { + if err := addDependentImagePulls(project, images, needPull, scheduled); err != nil { return err } @@ -504,16 +506,13 @@ func (s *composeService) resolvePulledImages(ctx context.Context, needPull map[s return errs } -// addPreStartHookPulls schedules pulls for pre_start hook images. -// pre_start hooks run as ephemeral init containers with their own registry -// image; they have no pull policy of their own, so they inherit the parent -// service's — interpreted by the same mustPull the service image goes -// through, so `up` and `compose pull` agree on hook images too. A hook image -// can't be built, so `build` falls back to pull-if-missing instead of -// exempting it from pulling; only `never` skips entirely. Running as a second -// pass over the services keeps the dedup against service/volume images (via -// scheduled) independent of service iteration order. -func addPreStartHookPulls(project *types.Project, images map[string]api.ImageSummary, needPull map[string]types.ServiceConfig, scheduled map[string]bool) error { +// addDependentImagePulls schedules pulls for dependent images (pre_start hooks +// and type=image volume sources) that are not yet present locally. Dependent +// images that are produced by a buildable service in the project are skipped, +// as they will be created during the build step. Running as a second pass over +// the services keeps the dedup against service images (via scheduled) +// independent of service iteration order. +func addDependentImagePulls(project *types.Project, images map[string]api.ImageSummary, needPull map[string]types.ServiceConfig, scheduled map[string]bool) error { for name, service := range project.Services { if service.PullPolicy == types.PullPolicyNever { continue @@ -523,6 +522,12 @@ func addPreStartHookPulls(project *types.Project, images map[string]api.ImageSum hookPolicy = types.PullPolicyMissing } for i, img := range api.GetDependentImages(service, project.Name) { + if isServiceImage(img, project.Services) { + continue + } + if isServiceImageToBuild(types.ServiceConfig{ContainerSpec: types.ContainerSpec{Image: img}}, project.Services) { + continue + } pull, err := mustPull(types.ServiceConfig{Name: name, ContainerSpec: types.ContainerSpec{Image: img, PullPolicy: hookPolicy}}, images) if err != nil { return err @@ -531,8 +536,8 @@ func addPreStartHookPulls(project *types.Project, images map[string]api.ImageSum continue } scheduled[img] = true - // Hack: create a fake ServiceConfig so we pull missing pre_start hook image - n := fmt.Sprintf("%s:pre_start %d", name, i) + // Hack: create a fake ServiceConfig so we pull the dependent image + n := fmt.Sprintf("%s:dependent %d", name, i) needPull[n] = types.ServiceConfig{ Name: n, ContainerSpec: types.ContainerSpec{Image: img}, @@ -571,6 +576,15 @@ func mustPull(service types.ServiceConfig, images map[string]api.ImageSummary) ( } } +func isServiceImage(image string, services types.Services) bool { + for _, svc := range services { + if svc.Image == image { + return true + } + } + return false +} + func isServiceImageToBuild(service types.ServiceConfig, services types.Services) bool { if service.Build != nil { return true diff --git a/pkg/compose/pull_test.go b/pkg/compose/pull_test.go index f9a353968ca..eae896c2e7e 100644 --- a/pkg/compose/pull_test.go +++ b/pkg/compose/pull_test.go @@ -25,6 +25,7 @@ import ( "time" "github.com/compose-spec/compose-go/v2/types" + "github.com/containerd/errdefs" "github.com/distribution/reference" "github.com/docker/cli/cli/config/configfile" "github.com/moby/moby/api/types/image" @@ -40,18 +41,19 @@ import ( "github.com/docker/compose/v5/pkg/mocks" ) -// scheduledHookImages runs addPreStartHookPulls and returns the hook image -// references it scheduled for pull, sorted for deterministic assertions. -func scheduledHookImages(t *testing.T, project *types.Project, present map[string]api.ImageSummary) []string { +// scheduledDependentImages runs addDependentImagePulls and returns the +// dependent image references it scheduled for pull, sorted for deterministic +// assertions. +func scheduledDependentImages(t *testing.T, project *types.Project, present map[string]api.ImageSummary) []string { t.Helper() needPull := map[string]types.ServiceConfig{} scheduled := map[string]bool{} // Seed scheduled with the service images, as pullRequiredImages does before - // calling addPreStartHookPulls. + // calling addDependentImagePulls. for _, service := range project.Services { scheduled[service.Image] = true } - if err := addPreStartHookPulls(project, present, needPull, scheduled); err != nil { + if err := addDependentImagePulls(project, present, needPull, scheduled); err != nil { t.Fatal(err) } var images []string @@ -64,60 +66,163 @@ func scheduledHookImages(t *testing.T, project *types.Project, present map[strin func serviceWithHook(name, img, policy string) types.ServiceConfig { return types.ServiceConfig{ - Name: name, + Name: name, + PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Image: "init:latest"}}}, + ContainerSpec: types.ContainerSpec{ + Image: img, + PullPolicy: policy, + }, + } +} - PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Image: "init:latest"}}}, ContainerSpec: types.ContainerSpec{ +func serviceWithVolume(name, img, policy, volSource string) types.ServiceConfig { + return types.ServiceConfig{ + Name: name, + ContainerSpec: types.ContainerSpec{ Image: img, PullPolicy: policy, + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeImage, Source: volSource, Target: "/data"}, + }, }, } } -// TestAddPreStartHookPulls_AlwaysForcesPresentHook covers the docker-agent +// TestAddDependentImagePulls_AlwaysForcesPresentHook covers the docker-agent // finding: `pull_policy: always` must re-pull a hook image even when it is // already present locally, mirroring how the service image is force-pulled. -func TestAddPreStartHookPulls_AlwaysForcesPresentHook(t *testing.T) { +func TestAddDependentImagePulls_AlwaysForcesPresentHook(t *testing.T) { project := &types.Project{ Name: "demo", Services: types.Services{"web": serviceWithHook("web", "web:latest", types.PullPolicyAlways)}, } present := map[string]api.ImageSummary{"init:latest": {ID: "sha256:present"}} - assert.DeepEqual(t, scheduledHookImages(t, project, present), []string{"init:latest"}) + assert.DeepEqual(t, scheduledDependentImages(t, project, present), []string{"init:latest"}) } -// TestAddPreStartHookPulls_MissingPolicySkipsPresentHook verifies a present hook -// image is not re-pulled under the default (pull-if-missing) behavior. -func TestAddPreStartHookPulls_MissingPolicySkipsPresentHook(t *testing.T) { +// TestAddDependentImagePulls_MissingPolicySkipsPresentHook verifies a present +// hook image is not re-pulled under the default (pull-if-missing) behavior. +func TestAddDependentImagePulls_MissingPolicySkipsPresentHook(t *testing.T) { project := &types.Project{ Name: "demo", Services: types.Services{"web": serviceWithHook("web", "web:latest", types.PullPolicyMissing)}, } present := map[string]api.ImageSummary{"init:latest": {ID: "sha256:present"}} - assert.Equal(t, len(scheduledHookImages(t, project, present)), 0) + assert.Equal(t, len(scheduledDependentImages(t, project, present)), 0) } -// TestAddPreStartHookPulls_MissingPolicyPullsAbsentHook verifies an absent hook -// image is pulled under the default policy. -func TestAddPreStartHookPulls_MissingPolicyPullsAbsentHook(t *testing.T) { +// TestAddDependentImagePulls_MissingPolicyPullsAbsentHook verifies an absent +// hook image is pulled under the default policy. +func TestAddDependentImagePulls_MissingPolicyPullsAbsentHook(t *testing.T) { project := &types.Project{ Name: "demo", Services: types.Services{"web": serviceWithHook("web", "web:latest", types.PullPolicyMissing)}, } - assert.DeepEqual(t, scheduledHookImages(t, project, map[string]api.ImageSummary{}), []string{"init:latest"}) + assert.DeepEqual(t, scheduledDependentImages(t, project, map[string]api.ImageSummary{}), []string{"init:latest"}) } -// TestAddPreStartHookPulls_NeverSkips verifies `pull_policy: never` never +// TestAddDependentImagePulls_NeverSkips verifies `pull_policy: never` never // schedules a hook pull, even for an absent image. -func TestAddPreStartHookPulls_NeverSkips(t *testing.T) { +func TestAddDependentImagePulls_NeverSkips(t *testing.T) { project := &types.Project{ Name: "demo", Services: types.Services{"web": serviceWithHook("web", "web:latest", types.PullPolicyNever)}, } - assert.Equal(t, len(scheduledHookImages(t, project, map[string]api.ImageSummary{})), 0) + assert.Equal(t, len(scheduledDependentImages(t, project, map[string]api.ImageSummary{})), 0) +} + +// TestAddDependentImagePulls_VolumeOnlyImage verifies that a type=image volume +// source that is not present locally gets scheduled for pull. +func TestAddDependentImagePulls_VolumeOnlyImage(t *testing.T) { + project := &types.Project{ + Name: "demo", + Services: types.Services{ + "web": serviceWithVolume("web", "nginx:alpine", types.PullPolicyMissing, "myorg/assets:latest"), + }, + } + + assert.DeepEqual(t, scheduledDependentImages(t, project, map[string]api.ImageSummary{}), []string{"myorg/assets:latest"}) +} + +// TestAddDependentImagePulls_ServiceAndVolumeDedup verifies that when the same +// image is used by a service and as a volume source, the service entry wins +// (via the scheduled map) and the image is not pulled twice. +func TestAddDependentImagePulls_ServiceAndVolumeDedup(t *testing.T) { + project := &types.Project{ + Name: "demo", + Services: types.Services{ + "assets": { + Name: "assets", + ContainerSpec: types.ContainerSpec{ + Image: "myorg/assets:latest", + PullPolicy: types.PullPolicyAlways, + }, + }, + "web": serviceWithVolume("web", "nginx:alpine", types.PullPolicyMissing, "myorg/assets:latest"), + }, + } + // The service image "myorg/assets:latest" is already in scheduled (seeded + // by scheduledDependentImages), so the volume reference is deduplicated. + assert.Equal(t, len(scheduledDependentImages(t, project, map[string]api.ImageSummary{})), 0) +} + +// TestAddDependentImagePulls_BuildableVolumeSkipped verifies that a volume +// image produced by a buildable service in the project is not scheduled for +// pull, avoiding the --ignore-buildable regression in the up path. +func TestAddDependentImagePulls_BuildableVolumeSkipped(t *testing.T) { + project := &types.Project{ + Name: "demo", + Services: types.Services{ + "assets": { + Name: "assets", + ContainerSpec: types.ContainerSpec{ + Image: "demo/assets:dev", + PullPolicy: types.PullPolicyMissing, + }, + WorkloadSpec: types.WorkloadSpec{ + Build: &types.BuildConfig{Context: "."}, + }, + }, + "web": serviceWithVolume("web", "nginx:alpine", types.PullPolicyMissing, "demo/assets:dev"), + }, + } + // Even though demo/assets:dev is not present locally, it should be skipped + // because the "assets" service can build it. + needPull := map[string]types.ServiceConfig{} + scheduled := map[string]bool{} + scheduled["nginx:alpine"] = true + err := addDependentImagePulls(project, map[string]api.ImageSummary{}, needPull, scheduled) + assert.NilError(t, err) + assert.Equal(t, len(needPull), 0) +} + +// TestAddDependentImagePulls_ServicePolicyNeverWins verifies that when an image +// is defined by a service with pull_policy: never, a volume referencing the +// same image is not scheduled for pull in the up path. +func TestAddDependentImagePulls_ServicePolicyNeverWins(t *testing.T) { + project := &types.Project{ + Name: "demo", + Services: types.Services{ + "assets": { + Name: "assets", + ContainerSpec: types.ContainerSpec{ + Image: "myorg/assets:latest", + PullPolicy: types.PullPolicyNever, + }, + }, + "web": serviceWithVolume("web", "nginx:alpine", types.PullPolicyMissing, "myorg/assets:latest"), + }, + } + needPull := map[string]types.ServiceConfig{} + scheduled := map[string]bool{} + scheduled["nginx:alpine"] = true + err := addDependentImagePulls(project, map[string]api.ImageSummary{}, needPull, scheduled) + assert.NilError(t, err) + assert.Equal(t, len(needPull), 0) } // fakePullResponse is an empty, already-complete pull stream. @@ -178,9 +283,9 @@ func TestPullRequiredImagesUsesContentDigest(t *testing.T) { assert.Equal(t, images[ref].ID, "sha256:image") } -// TestAddPreStartHookPulls_DedupsSharedHookImage verifies a hook image shared by +// TestAddDependentImagePulls_DedupsSharedHookImage verifies a hook image shared by // several services is scheduled at most once. -func TestAddPreStartHookPulls_DedupsSharedHookImage(t *testing.T) { +func TestAddDependentImagePulls_DedupsSharedHookImage(t *testing.T) { project := &types.Project{ Name: "demo", Services: types.Services{ @@ -189,7 +294,7 @@ func TestAddPreStartHookPulls_DedupsSharedHookImage(t *testing.T) { }, } - assert.DeepEqual(t, scheduledHookImages(t, project, map[string]api.ImageSummary{}), []string{"init:latest"}) + assert.DeepEqual(t, scheduledDependentImages(t, project, map[string]api.ImageSummary{}), []string{"init:latest"}) } func TestShouldPullImage(t *testing.T) { @@ -261,10 +366,10 @@ func TestShouldPullImage(t *testing.T) { }) } -// TestAddPreStartHookPulls_RefreshWindow: hook images inherit the parent +// TestAddDependentImagePulls_RefreshWindow: hook images inherit the parent // service's daily/weekly/every_N policy through the same mustPull interpreter // as the service image, so `up` honors the refresh window for them too. -func TestAddPreStartHookPulls_RefreshWindow(t *testing.T) { +func TestAddDependentImagePulls_RefreshWindow(t *testing.T) { project := &types.Project{ Name: "demo", Services: types.Services{"web": serviceWithHook("web", "web:latest", "daily")}, @@ -273,12 +378,12 @@ func TestAddPreStartHookPulls_RefreshWindow(t *testing.T) { fresh := map[string]api.ImageSummary{ "init:latest": {ID: "sha256:present", LastTagTime: time.Now()}, } - assert.DeepEqual(t, scheduledHookImages(t, project, fresh), []string(nil)) + assert.DeepEqual(t, scheduledDependentImages(t, project, fresh), []string(nil)) stale := map[string]api.ImageSummary{ "init:latest": {ID: "sha256:present", LastTagTime: time.Now().Add(-48 * time.Hour)}, } - assert.DeepEqual(t, scheduledHookImages(t, project, stale), []string{"init:latest"}) + assert.DeepEqual(t, scheduledDependentImages(t, project, stale), []string{"init:latest"}) } // TestShouldPullImageProvider: a service declaring both provider: and image: @@ -331,3 +436,177 @@ func TestImageDigestResolverUsesDistributionDigest(t *testing.T) { assert.NilError(t, err) assert.Equal(t, resolved.String(), indexDigest) } + +// TestPull_IgnoreBuildable_SkipsBuildableVolumeImage verifies that running +// `compose pull --ignore-buildable` skips dependent volume images produced by +// buildable services in the project, avoiding registry pull attempts and failures. +func TestPull_IgnoreBuildable_SkipsBuildableVolumeImage(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockAPI, tested := newTestComposeService(t, mockCtrl, "1.48") + + mockAPI.EXPECT(). + ImageInspect(anyCancellableContext(), gomock.Any(), gomock.Any()). + Return(client.ImageInspectResult{}, errdefs.ErrNotFound). + AnyTimes() + + project := &types.Project{ + Name: "demo", + Services: types.Services{ + "assets": { + Name: "assets", + ContainerSpec: types.ContainerSpec{Image: "demo/assets:dev"}, + WorkloadSpec: types.WorkloadSpec{Build: &types.BuildConfig{Context: "."}}, + }, + "web": { + Name: "web", + ContainerSpec: types.ContainerSpec{ + Image: "nginx:alpine", + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeImage, Source: "demo/assets:dev", Target: "/srv/static"}, + }, + }, + }, + }, + } + + // nginx:alpine must be pulled, but demo/assets:dev must NOT be pulled + mockAPI.EXPECT(). + ImagePull(anyCancellableContext(), "nginx:alpine", gomock.Any()). + Return(fakePullResponse{}, nil). + Times(1) + + err := tested.Pull(t.Context(), project, api.PullOptions{IgnoreBuildable: true}) + assert.NilError(t, err) +} + +// TestPull_VolumeOnlyImage verifies that running compose pull pulls an image +// used only as a type: image volume. +func TestPull_VolumeOnlyImage(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockAPI, tested := newTestComposeService(t, mockCtrl, "1.48") + + mockAPI.EXPECT(). + ImageInspect(anyCancellableContext(), gomock.Any(), gomock.Any()). + Return(client.ImageInspectResult{}, errdefs.ErrNotFound). + AnyTimes() + + project := &types.Project{ + Name: "demo", + Services: types.Services{ + "web": { + Name: "web", + ContainerSpec: types.ContainerSpec{ + Image: "nginx:alpine", + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeImage, Source: "myorg/assets:latest", Target: "/srv/static"}, + }, + }, + }, + }, + } + + mockAPI.EXPECT(). + ImagePull(anyCancellableContext(), "nginx:alpine", gomock.Any()). + Return(fakePullResponse{}, nil). + Times(1) + mockAPI.EXPECT(). + ImagePull(anyCancellableContext(), "myorg/assets:latest", gomock.Any()). + Return(fakePullResponse{}, nil). + Times(1) + + err := tested.Pull(t.Context(), project, api.PullOptions{}) + assert.NilError(t, err) +} + +// TestPull_SameImageUsedByServiceAndVolume verifies that when the same image is +// used by a service and as a volume source, it is pulled only once (deduplicated). +func TestPull_SameImageUsedByServiceAndVolume(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockAPI, tested := newTestComposeService(t, mockCtrl, "1.48") + + mockAPI.EXPECT(). + ImageInspect(anyCancellableContext(), gomock.Any(), gomock.Any()). + Return(client.ImageInspectResult{}, errdefs.ErrNotFound). + AnyTimes() + + project := &types.Project{ + Name: "demo", + Services: types.Services{ + "assets": { + Name: "assets", + ContainerSpec: types.ContainerSpec{ + Image: "myorg/assets:latest", + }, + }, + "web": { + Name: "web", + ContainerSpec: types.ContainerSpec{ + Image: "nginx:alpine", + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeImage, Source: "myorg/assets:latest", Target: "/srv/static"}, + }, + }, + }, + }, + } + + mockAPI.EXPECT(). + ImagePull(anyCancellableContext(), "myorg/assets:latest", gomock.Any()). + Return(fakePullResponse{}, nil). + Times(1) + mockAPI.EXPECT(). + ImagePull(anyCancellableContext(), "nginx:alpine", gomock.Any()). + Return(fakePullResponse{}, nil). + Times(1) + + err := tested.Pull(t.Context(), project, api.PullOptions{}) + assert.NilError(t, err) +} + +// TestPull_SameImageUsedByServiceAndVolume_ServicePolicyWins verifies that when +// the service defines pull_policy: never, the service policy wins and the volume +// image is not pulled. +func TestPull_SameImageUsedByServiceAndVolume_ServicePolicyWins(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockAPI, tested := newTestComposeService(t, mockCtrl, "1.48") + + mockAPI.EXPECT(). + ImageInspect(anyCancellableContext(), gomock.Any(), gomock.Any()). + Return(client.ImageInspectResult{}, errdefs.ErrNotFound). + AnyTimes() + + project := &types.Project{ + Name: "demo", + Services: types.Services{ + "assets": { + Name: "assets", + ContainerSpec: types.ContainerSpec{ + Image: "myorg/assets:latest", + PullPolicy: types.PullPolicyNever, + }, + }, + "web": { + Name: "web", + ContainerSpec: types.ContainerSpec{ + Image: "nginx:alpine", + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeImage, Source: "myorg/assets:latest", Target: "/srv/static"}, + }, + }, + }, + }, + } + + // Only nginx:alpine should be pulled; myorg/assets:latest must NOT be pulled + mockAPI.EXPECT(). + ImagePull(anyCancellableContext(), "nginx:alpine", gomock.Any()). + Return(fakePullResponse{}, nil). + Times(1) + + err := tested.Pull(t.Context(), project, api.PullOptions{}) + assert.NilError(t, err) +}