diff --git a/cmd/compose/config.go b/cmd/compose/config.go index cdf53f4b58..596ef75d9f 100644 --- a/cmd/compose/config.go +++ b/cmd/compose/config.go @@ -514,6 +514,9 @@ func runConfigImages(ctx context.Context, dockerCli command.Cli, opts configOpti for _, s := range project.Services { _, _ = fmt.Fprintln(dockerCli.Out(), api.GetImageNameOrDefault(s, project.Name)) + for _, img := range api.GetDependentImages(s, project.Name) { + _, _ = fmt.Fprintln(dockerCli.Out(), img) + } } return nil } diff --git a/pkg/api/api.go b/pkg/api/api.go index bcd81c8cec..9a1430df3c 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -760,3 +760,19 @@ func GetImageNameOrDefault(service types.ServiceConfig, projectName string) stri } return imageName } + +// 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. +func GetDependentImages(service types.ServiceConfig, projectName string) []string { + serviceImage := GetImageNameOrDefault(service, projectName) + var images []string + for _, hook := range service.PreStart { + if hook.Image != "" && hook.Image != serviceImage { + images = append(images, hook.Image) + } + } + return images +} diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index fc44abe7f1..4fb5890f76 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -36,3 +36,75 @@ func TestRunOptionsEnvironmentMap(t *testing.T) { assert.Equal(t, *env["ZOT"], "") assert.Check(t, env["QIX"] == nil) } + +func TestGetDependentImages(t *testing.T) { + const projectName = "demo" + tests := []struct { + name string + service types.ServiceConfig + expected []string + }{ + { + name: "no hooks", + service: types.ServiceConfig{Image: "alpine:3.20"}, + expected: nil, + }, + { + name: "pre_start hook with explicit image", + service: types.ServiceConfig{ + Image: "alpine:3.20", + PreStart: []types.ServiceHook{ + {Image: "alpine:3.19", Command: types.ShellCommand{"echo", "init"}}, + }, + }, + expected: []string{"alpine:3.19"}, + }, + { + name: "pre_start hook without image is ignored", + service: types.ServiceConfig{ + Image: "alpine:3.20", + PreStart: []types.ServiceHook{ + {Image: "busybox", Command: types.ShellCommand{"echo", "a"}}, + {Command: types.ShellCommand{"echo", "b"}}, + }, + }, + expected: []string{"busybox"}, + }, + { + name: "pre_start hook reusing the service image is ignored", + service: types.ServiceConfig{ + Image: "alpine:3.20", + PreStart: []types.ServiceHook{ + {Image: "alpine:3.20", Command: types.ShellCommand{"echo", "same"}}, + {Image: "alpine:3.19", Command: types.ShellCommand{"echo", "other"}}, + }, + }, + expected: []string{"alpine:3.19"}, + }, + { + name: "pre_start hook reusing the default (build) image name is ignored", + service: types.ServiceConfig{ + Name: "web", + Build: &types.BuildConfig{Context: "."}, + PreStart: []types.ServiceHook{ + {Image: "demo-web", Command: types.ShellCommand{"echo", "same"}}, + }, + }, + expected: nil, + }, + { + name: "post_start and pre_stop hooks are not collected", + service: types.ServiceConfig{ + Image: "alpine:3.20", + PostStart: []types.ServiceHook{{Image: "ignored:post", Command: types.ShellCommand{"echo"}}}, + PreStop: []types.ServiceHook{{Image: "ignored:stop", Command: types.ShellCommand{"echo"}}}, + }, + expected: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.DeepEqual(t, GetDependentImages(tt.service, projectName), tt.expected) + }) + } +} diff --git a/pkg/compose/build.go b/pkg/compose/build.go index 7b9cc76f96..3d4a50ffd1 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -198,6 +198,9 @@ func (s *composeService) getLocalImagesDigests(ctx context.Context, project *typ imageNames.Add(volume.Source) } } + for _, img := range api.GetDependentImages(s, project.Name) { + imageNames.Add(img) + } } imgs, err := s.getImageSummaries(ctx, imageNames.Elements()) if err != nil { diff --git a/pkg/compose/build_test.go b/pkg/compose/build_test.go index 48f41464b1..00f80dc82c 100644 --- a/pkg/compose/build_test.go +++ b/pkg/compose/build_test.go @@ -21,6 +21,9 @@ import ( "testing" "github.com/compose-spec/compose-go/v2/types" + "github.com/moby/moby/api/types/image" + "github.com/moby/moby/client" + "go.uber.org/mock/gomock" "gotest.tools/v3/assert" ) @@ -107,3 +110,32 @@ func Test_addBuildDependencies(t *testing.T) { slices.Sort(expected) assert.DeepEqual(t, services, expected) } + +// TestGetLocalImagesDigests_PreStartHook ensures pre_start hook images are +// inspected alongside the service image so they get resolved (see issue #13924). +func TestGetLocalImagesDigests_PreStartHook(t *testing.T) { + tested, apiClient := newPreStartTestService(t) + + project := &types.Project{ + Name: "demo", + Services: types.Services{ + "web": types.ServiceConfig{ + Name: "web", + Image: "alpine:3.20", + PreStart: []types.ServiceHook{ + {Image: "alpine:3.19", Command: types.ShellCommand{"echo", "init"}}, + }, + }, + }, + } + + apiClient.EXPECT().ImageInspect(gomock.Any(), "alpine:3.20"). + Return(client.ImageInspectResult{InspectResponse: image.InspectResponse{ID: "sha256:service"}}, nil) + apiClient.EXPECT().ImageInspect(gomock.Any(), "alpine:3.19"). + Return(client.ImageInspectResult{InspectResponse: image.InspectResponse{ID: "sha256:hook"}}, nil) + + images, err := tested.getLocalImagesDigests(t.Context(), project) + assert.NilError(t, err) + assert.Equal(t, images["alpine:3.20"].ID, "sha256:service") + assert.Equal(t, images["alpine:3.19"].ID, "sha256:hook") +} diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index ce14a5b5ce..62aa57da6e 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -135,6 +135,44 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts i++ } + // pre_start hook images 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. Unlike the service image, a + // hook image can't be built, so `build` does not exempt it from pulling — + // only `never` does (consistent with the `up`/create path). + for name, service := range project.Services { + if service.PullPolicy == types.PullPolicyNever { + continue + } + for _, img := range api.GetDependentImages(service, project.Name) { + switch service.PullPolicy { + case types.PullPolicyMissing, types.PullPolicyIfNotPresent, types.PullPolicyBuild: + if imageAlreadyPresent(img, images) { + s.events.On(api.Resource{ + ID: "Image " + img, + Status: api.Done, + Text: "Skipped", + Details: "Image is already present locally", + }) + continue + } + } + if _, ok := imagesBeingPulled[img]; ok { + continue + } + imagesBeingPulled[img] = name + hookService := types.ServiceConfig{Name: name, Image: img} + eg.Go(func() error { + _, err := s.pullServiceImage(ctx, hookService, opts.Quiet, project.Environment["DOCKER_DEFAULT_PLATFORM"]) + if err != nil && !opts.IgnoreFailures { + // fail fast: a hook image can't be built as a fallback + return err + } + return nil + }) + } + } + err = eg.Wait() if len(mustBuild) > 0 { @@ -292,6 +330,9 @@ func encodedAuth(ref reference.Named, configFile authProvider) (string, error) { func (s *composeService) pullRequiredImages(ctx context.Context, project *types.Project, images map[string]api.ImageSummary, quietPull bool) error { needPull := map[string]types.ServiceConfig{} + // track image references already scheduled for pull so dependent images + // (volume/hook images shared across services) aren't pulled more than once + scheduled := map[string]bool{} for name, service := range project.Services { pull, err := mustPull(service, images) if err != nil { @@ -299,6 +340,7 @@ 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 { @@ -309,11 +351,14 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types. Name: n, Image: vol.Source, } + scheduled[vol.Source] = true } } } - } + + addPreStartHookPulls(project, images, needPull, scheduled) + if len(needPull) == 0 { return nil } @@ -348,6 +393,40 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types. return err } +// addPreStartHookPulls schedules pulls for missing pre_start hook images. +// pre_start hooks run as ephemeral init containers with their own registry +// image, which can't be built, so we pull them unless the parent service pull +// policy explicitly forbids any pull (never). 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) { + for name, service := range project.Services { + if service.PullPolicy == types.PullPolicyNever { + continue + } + for i, img := range api.GetDependentImages(service, project.Name) { + // Honor `pull_policy: always` for hook images the same way mustPull + // does for the service image: force a re-pull even when the image is + // already present locally. Other policies only pull when missing. + if service.PullPolicy != types.PullPolicyAlways { + if _, ok := images[img]; ok { + continue + } + } + if scheduled[img] { + 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) + needPull[n] = types.ServiceConfig{ + Name: n, + Image: img, + } + } + } +} + func mustPull(service types.ServiceConfig, images map[string]api.ImageSummary) (bool, error) { if service.Provider != nil { return false, nil diff --git a/pkg/compose/pull_test.go b/pkg/compose/pull_test.go new file mode 100644 index 0000000000..c1af0caa59 --- /dev/null +++ b/pkg/compose/pull_test.go @@ -0,0 +1,117 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "sort" + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/pkg/api" +) + +// 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 { + t.Helper() + needPull := map[string]types.ServiceConfig{} + scheduled := map[string]bool{} + // Seed scheduled with the service images, as pullRequiredImages does before + // calling addPreStartHookPulls. + for _, service := range project.Services { + scheduled[service.Image] = true + } + addPreStartHookPulls(project, present, needPull, scheduled) + var images []string + for _, s := range needPull { + images = append(images, s.Image) + } + sort.Strings(images) + return images +} + +func serviceWithHook(name, image, policy string) types.ServiceConfig { + return types.ServiceConfig{ + Name: name, + Image: image, + PullPolicy: policy, + PreStart: []types.ServiceHook{{Image: "init:latest"}}, + } +} + +// TestAddPreStartHookPulls_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) { + 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"}) +} + +// TestAddPreStartHookPulls_MissingPolicySkipsPresentHook verifies a present hook +// image is not re-pulled under the default (pull-if-missing) behavior. +func TestAddPreStartHookPulls_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) +} + +// TestAddPreStartHookPulls_MissingPolicyPullsAbsentHook verifies an absent hook +// image is pulled under the default policy. +func TestAddPreStartHookPulls_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"}) +} + +// TestAddPreStartHookPulls_NeverSkips verifies `pull_policy: never` never +// schedules a hook pull, even for an absent image. +func TestAddPreStartHookPulls_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) +} + +// TestAddPreStartHookPulls_DedupsSharedHookImage verifies a hook image shared by +// several services is scheduled at most once. +func TestAddPreStartHookPulls_DedupsSharedHookImage(t *testing.T) { + project := &types.Project{ + Name: "demo", + Services: types.Services{ + "web": serviceWithHook("web", "web:latest", types.PullPolicyMissing), + "api": serviceWithHook("api", "api:latest", types.PullPolicyMissing), + }, + } + + assert.DeepEqual(t, scheduledHookImages(t, project, map[string]api.ImageSummary{}), []string{"init:latest"}) +}