diff --git a/cmd/compose/config.go b/cmd/compose/config.go index cdf53f4b58..f953ab3910 100644 --- a/cmd/compose/config.go +++ b/cmd/compose/config.go @@ -513,7 +513,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 _, imageName := range api.GetImageNamesForService(s, project.Name) { + _, _ = fmt.Fprintln(dockerCli.Out(), imageName) + } } return nil } diff --git a/cmd/compose/pull.go b/cmd/compose/pull.go index 694731155f..87170a4a7a 100644 --- a/cmd/compose/pull.go +++ b/cmd/compose/pull.go @@ -88,7 +88,7 @@ func (opts pullOptions) apply(project *types.Project, services []string) (*types if opts.policy != "" { for i, service := range project.Services { - if service.Image == "" { + if service.Image == "" && !hasPreStartHookImage(service) { continue } service.PullPolicy = opts.policy @@ -98,6 +98,15 @@ func (opts pullOptions) apply(project *types.Project, services []string) (*types return project, nil } +func hasPreStartHookImage(service types.ServiceConfig) bool { + for _, hook := range service.PreStart { + if hook.Image != "" { + return true + } + } + return false +} + func runPull(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts pullOptions, services []string) error { backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...) if err != nil { diff --git a/cmd/compose/pullOptions_test.go b/cmd/compose/pullOptions_test.go index 05dd868edf..dbf0075b71 100644 --- a/cmd/compose/pullOptions_test.go +++ b/cmd/compose/pullOptions_test.go @@ -44,6 +44,13 @@ func TestApplyPullOptions(t *testing.T) { Name: "must-pull", Image: "registry.example.com/another-service", }, + "hook-only": { + Name: "hook-only", + Build: &types.BuildConfig{ + Context: ".", + }, + PreStart: []types.ServiceHook{{Image: "registry.example.com/hook"}}, + }, }, } project, err := pullOptions{ @@ -54,4 +61,5 @@ func TestApplyPullOptions(t *testing.T) { assert.Equal(t, project.Services["must-build"].PullPolicy, "") // still default assert.Equal(t, project.Services["has-build"].PullPolicy, types.PullPolicyMissing) assert.Equal(t, project.Services["must-pull"].PullPolicy, types.PullPolicyMissing) + assert.Equal(t, project.Services["hook-only"].PullPolicy, types.PullPolicyMissing) } diff --git a/pkg/api/api.go b/pkg/api/api.go index bcd81c8cec..6a7e795211 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -760,3 +760,27 @@ func GetImageNameOrDefault(service types.ServiceConfig, projectName string) stri } return imageName } + +// GetImageNamesForService returns the image names required by a service. +// +// This includes the service image (or its default build image name), plus any +// distinct image explicitly referenced by pre_start lifecycle hooks. post_start +// and pre_stop hooks are intentionally excluded because Compose executes those +// hooks inside the service container instead of creating a container from the +// hook image field. +func GetImageNamesForService(service types.ServiceConfig, projectName string) []string { + imageNames := []string{GetImageNameOrDefault(service, projectName)} + seen := map[string]struct{}{imageNames[0]: {}} + + for _, hook := range service.PreStart { + if hook.Image == "" { + continue + } + if _, ok := seen[hook.Image]; ok { + continue + } + imageNames = append(imageNames, hook.Image) + seen[hook.Image] = struct{}{} + } + return imageNames +} diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index fc44abe7f1..46193ff93a 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -36,3 +36,20 @@ func TestRunOptionsEnvironmentMap(t *testing.T) { assert.Equal(t, *env["ZOT"], "") assert.Check(t, env["QIX"] == nil) } + +func TestGetImageNamesForServiceIncludesPreStartHooks(t *testing.T) { + service := types.ServiceConfig{ + Name: "demo", + Image: "alpine:3.20", + PreStart: []types.ServiceHook{ + {Image: "alpine:3.20"}, + {Image: "alpine:3.19"}, + {Image: "alpine:3.19"}, + {}, + }, + PostStart: []types.ServiceHook{{Image: "unused-post-start"}}, + PreStop: []types.ServiceHook{{Image: "unused-pre-stop"}}, + } + + assert.DeepEqual(t, GetImageNamesForService(service, "hooktest"), []string{"alpine:3.20", "alpine:3.19"}) +} diff --git a/pkg/compose/build.go b/pkg/compose/build.go index 7b9cc76f96..bb86a95140 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -192,7 +192,9 @@ func resolveImageVolumes(service *types.ServiceConfig, images map[string]api.Ima func (s *composeService) getLocalImagesDigests(ctx context.Context, project *types.Project) (map[string]api.ImageSummary, error) { imageNames := utils.Set[string]{} for _, s := range project.Services { - imageNames.Add(api.GetImageNameOrDefault(s, project.Name)) + for _, imageName := range api.GetImageNamesForService(s, project.Name) { + imageNames.Add(imageName) + } for _, volume := range s.Volumes { if volume.Type == types.VolumeTypeImage { imageNames.Add(volume.Source) diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index ce14a5b5ce..3c677b6b16 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -56,27 +56,21 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts return err } - eg, ctx := errgroup.WithContext(ctx) - eg.SetLimit(s.maxConcurrency) - var ( mustBuild []string - pullErrors = make([]error, len(project.Services)) imagesBeingPulled = map[string]string{} + targets []types.ServiceConfig ) - i := 0 - for name, service := range project.Services { - if service.Image == "" { - s.events.On(api.Resource{ - ID: name, - Status: api.Done, - Text: "Skipped", - Details: "No image to be pulled", - }) - continue + queuePull := func(service types.ServiceConfig) { + if _, ok := imagesBeingPulled[service.Image]; ok { + return } + imagesBeingPulled[service.Image] = service.Name + targets = append(targets, service) + } + maybePull := func(service types.ServiceConfig) { switch service.PullPolicy { case types.PullPolicyNever, types.PullPolicyBuild: s.events.On(api.Resource{ @@ -84,7 +78,7 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts Status: api.Done, Text: "Skipped", }) - continue + return case types.PullPolicyMissing, types.PullPolicyIfNotPresent: if imageAlreadyPresent(service.Image, images) { s.events.On(api.Resource{ @@ -93,7 +87,7 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts Text: "Skipped", Details: "Image is already present locally", }) - continue + return } } @@ -104,16 +98,44 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts Text: "Skipped", Details: "Image can be built", }) - continue + return } - if _, ok := imagesBeingPulled[service.Image]; ok { - continue + queuePull(service) + } + + for name, service := range project.Services { + if service.Image == "" { + s.events.On(api.Resource{ + ID: name, + Status: api.Done, + Text: "Skipped", + Details: "No image to be pulled", + }) + } else { + maybePull(service) } - imagesBeingPulled[service.Image] = service.Name + preStartImages := map[string]struct{}{} + for i, hook := range service.PreStart { + hookService, ok := preStartHookImageService(service, i, hook) + if !ok || hookService.Image == service.Image { + continue + } + if _, ok := preStartImages[hookService.Image]; ok { + continue + } + preStartImages[hookService.Image] = struct{}{} + maybePull(hookService) + } + } + eg, ctx := errgroup.WithContext(ctx) + eg.SetLimit(s.maxConcurrency) + pullErrors := make([]error, len(targets)) + for i, service := range targets { idx := i + service := service eg.Go(func() error { _, err := s.pullServiceImage(ctx, service, opts.Quiet, project.Environment["DOCKER_DEFAULT_PLATFORM"]) if err != nil { @@ -132,11 +154,9 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts } return nil }) - i++ } err = eg.Wait() - if len(mustBuild) > 0 { logrus.Warnf("WARNING: Some service image(s) must be built from source by running:\n docker compose build %s", strings.Join(mustBuild, " ")) } @@ -313,6 +333,24 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types. } } + preStartImages := map[string]struct{}{} + for i, hook := range service.PreStart { + hookService, ok := preStartHookImageService(service, i, hook) + if !ok || hookService.Image == service.Image { + continue + } + if _, ok := preStartImages[hookService.Image]; ok || pullTargetForImageExists(needPull, hookService.Image) { + continue + } + preStartImages[hookService.Image] = struct{}{} + pull, err := mustPull(hookService, images) + if err != nil { + return err + } + if pull { + needPull[hookService.Name] = hookService + } + } } if len(needPull) == 0 { return nil @@ -348,6 +386,28 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types. return err } +func preStartHookImageService(service types.ServiceConfig, index int, hook types.ServiceHook) (types.ServiceConfig, bool) { + if hook.Image == "" { + return types.ServiceConfig{}, false + } + hookName := fmt.Sprintf("%s:pre_start %d", service.Name, index) + return types.ServiceConfig{ + Name: hookName, + Image: hook.Image, + PullPolicy: service.PullPolicy, + Platform: service.Platform, + }, true +} + +func pullTargetForImageExists(targets map[string]types.ServiceConfig, imageName string) bool { + for _, target := range targets { + if target.Image == imageName { + return true + } + } + return false +} + 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..e723b86d6a --- /dev/null +++ b/pkg/compose/pull_test.go @@ -0,0 +1,122 @@ +/* + Copyright 2026 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 ( + "context" + "io" + "iter" + "strings" + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/containerd/errdefs" + "github.com/docker/cli/cli/config/configfile" + "github.com/moby/moby/api/types/image" + "github.com/moby/moby/api/types/jsonstream" + "github.com/moby/moby/client" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" + + composeapi "github.com/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/mocks" +) + +func TestPullIncludesPreStartHookImage(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + apiClient, cli := prepareMocks(mockCtrl) + cli.EXPECT().ConfigFile().Return(&configfile.ConfigFile{}).AnyTimes() + tested, err := NewComposeService(cli) + assert.NilError(t, err) + + project := &types.Project{ + Name: "hooktest", + Services: types.Services{ + "demo": { + Name: "demo", + Image: "alpine:3.20", + PullPolicy: types.PullPolicyMissing, + PreStart: []types.ServiceHook{ + {Image: "alpine:3.19", Command: types.ShellCommand{"echo", "hook"}}, + }, + }, + }, + } + + 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{}, errdefs.ErrNotFound.WithMessage("missing hook image")) + expectSuccessfulImagePull(apiClient, "alpine:3.19", "sha256:hook") + + err = tested.(*composeService).pull(t.Context(), project, composeapi.PullOptions{}) + assert.NilError(t, err) +} + +func TestPullRequiredImagesIncludesPreStartHookImage(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + apiClient, cli := prepareMocks(mockCtrl) + cli.EXPECT().ConfigFile().Return(&configfile.ConfigFile{}).AnyTimes() + tested, err := NewComposeService(cli) + assert.NilError(t, err) + + project := &types.Project{ + Name: "hooktest", + Services: types.Services{ + "demo": { + Name: "demo", + Image: "alpine:3.20", + PullPolicy: types.PullPolicyMissing, + PreStart: []types.ServiceHook{ + {Image: "alpine:3.19", Command: types.ShellCommand{"echo", "hook"}}, + }, + }, + }, + } + images := map[string]composeapi.ImageSummary{ + "alpine:3.20": {ID: "sha256:service"}, + } + + expectSuccessfulImagePull(apiClient, "alpine:3.19", "sha256:hook") + + err = tested.(*composeService).pullRequiredImages(t.Context(), project, images, true) + assert.NilError(t, err) + assert.Equal(t, images["alpine:3.19"].ID, "sha256:hook") +} + +func expectSuccessfulImagePull(apiClient *mocks.MockAPIClient, ref string, id string) { + apiClient.EXPECT().ImagePull(gomock.Any(), ref, gomock.Any()). + Return(testImagePullResponse{ReadCloser: io.NopCloser(strings.NewReader(""))}, nil) + apiClient.EXPECT().ImageInspect(gomock.Any(), ref). + Return(client.ImageInspectResult{InspectResponse: image.InspectResponse{ID: id}}, nil) +} + +type testImagePullResponse struct { + io.ReadCloser +} + +func (r testImagePullResponse) JSONMessages(ctx context.Context) iter.Seq2[jsonstream.Message, error] { + return func(yield func(jsonstream.Message, error) bool) {} +} + +func (r testImagePullResponse) Wait(ctx context.Context) error { + return nil +}