From 209476972ce1e0ad6c3a6255ffeb84f2dbc6637c Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 13 Jul 2026 09:45:53 +0200 Subject: [PATCH 1/3] Resolve pre_start hook images alongside service images pre_start hooks run as ephemeral init containers with their own image (ServiceHook.Image), but that image was ignored by image resolution: `config --images` didn't list it, `pull` didn't fetch it, and `up` failed at runtime with "No such image" when it wasn't already present locally. Add a GetDependentImages helper that returns a service's pre_start hook images, and use it wherever service images are collected/pulled: getLocalImagesDigests, pullRequiredImages (up path), the pull command, and `config --images`. Hook images inherit the parent service pull policy. post_start/pre_stop hooks run via ExecCreate inside the service container and never use hook.Image, so they are intentionally out of scope. Digest resolution/locking (--resolve-image-digests / --lock-image-digests) is not covered: compose-go's WithImagesResolved only resolves service.Image (needs an upstream change), and the --lock-image-digests override merges pre_start by concatenation, which would duplicate hooks. Signed-off-by: Nicolas De Loof --- cmd/compose/config.go | 3 +++ pkg/api/api.go | 14 +++++++++++ pkg/api/api_test.go | 49 +++++++++++++++++++++++++++++++++++++++ pkg/compose/build.go | 3 +++ pkg/compose/build_test.go | 32 +++++++++++++++++++++++++ pkg/compose/pull.go | 46 ++++++++++++++++++++++++++++++++++++ 6 files changed, 147 insertions(+) diff --git a/cmd/compose/config.go b/cmd/compose/config.go index cdf53f4b588..2cb8493b738 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) { + _, _ = fmt.Fprintln(dockerCli.Out(), img) + } } return nil } diff --git a/pkg/api/api.go b/pkg/api/api.go index bcd81c8cec0..c543ef38694 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -760,3 +760,17 @@ 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 (an empty hook image falls +// back to the service image, which is already accounted for elsewhere). +func GetDependentImages(service types.ServiceConfig) []string { + var images []string + for _, hook := range service.PreStart { + if hook.Image != "" { + images = append(images, hook.Image) + } + } + return images +} diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index fc44abe7f1a..12c12f63f70 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -36,3 +36,52 @@ func TestRunOptionsEnvironmentMap(t *testing.T) { assert.Equal(t, *env["ZOT"], "") assert.Check(t, env["QIX"] == nil) } + +func TestGetDependentImages(t *testing.T) { + 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: "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), tt.expected) + }) + } +} diff --git a/pkg/compose/build.go b/pkg/compose/build.go index 7b9cc76f96c..6e5a3a5f8ba 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) { + 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 48f41464b1a..00f80dc82c6 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 ce14a5b5ce8..368704b9b7d 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -135,6 +135,42 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts i++ } + // pre_start hook images run as ephemeral init containers with their own + // image, so they must be pulled too. They have no pull policy of their own, + // so we inherit the parent service's policy for skip decisions. + for name, service := range project.Services { + if service.PullPolicy == types.PullPolicyNever || service.PullPolicy == types.PullPolicyBuild { + continue + } + for _, img := range api.GetDependentImages(service) { + switch service.PullPolicy { + case types.PullPolicyMissing, types.PullPolicyIfNotPresent: + 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 { @@ -313,6 +349,16 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types. } } + for i, img := range api.GetDependentImages(service) { + if _, ok := images[img]; !ok { + // 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, + } + } + } } if len(needPull) == 0 { return nil From dd2ff0fb8018d62f293956a258ee83ffe6c684df Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 16 Jul 2026 16:47:37 +0200 Subject: [PATCH 2/3] Dedup pre_start hook images against service and each other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on pre_start hook image resolution: 1. GetDependentImages now skips a hook image equal to the service image (resolved via GetImageNameOrDefault), so `config --images` no longer prints a duplicate line and pullRequiredImages no longer schedules a redundant pull for it. 2. pullRequiredImages (up/create path) now dedups dependent images by reference via a `scheduled` set, so several hooks/services sharing the same missing image don't schedule concurrent redundant pulls. The hook pass moved to a helper (addPreStartHookPulls) to keep complexity in check. 3. The `pull` command no longer skips hook images under `pull_policy: build`. A hook image is a registry image that can't be built, so only `never` justifies skipping it — making `pull` consistent with the `up` path. Signed-off-by: Nicolas De Loof --- cmd/compose/config.go | 2 +- pkg/api/api.go | 10 +++++--- pkg/api/api_test.go | 25 +++++++++++++++++- pkg/compose/build.go | 2 +- pkg/compose/pull.go | 60 +++++++++++++++++++++++++++++++------------ 5 files changed, 76 insertions(+), 23 deletions(-) diff --git a/cmd/compose/config.go b/cmd/compose/config.go index 2cb8493b738..596ef75d9f0 100644 --- a/cmd/compose/config.go +++ b/cmd/compose/config.go @@ -514,7 +514,7 @@ 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) { + for _, img := range api.GetDependentImages(s, project.Name) { _, _ = fmt.Fprintln(dockerCli.Out(), img) } } diff --git a/pkg/api/api.go b/pkg/api/api.go index c543ef38694..9a1430df3c2 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -763,12 +763,14 @@ 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 (an empty hook image falls -// back to the service image, which is already accounted for elsewhere). -func GetDependentImages(service types.ServiceConfig) []string { +// 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 != "" { + if hook.Image != "" && hook.Image != serviceImage { images = append(images, hook.Image) } } diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index 12c12f63f70..4fb5890f763 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -38,6 +38,7 @@ func TestRunOptionsEnvironmentMap(t *testing.T) { } func TestGetDependentImages(t *testing.T) { + const projectName = "demo" tests := []struct { name string service types.ServiceConfig @@ -69,6 +70,28 @@ func TestGetDependentImages(t *testing.T) { }, 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{ @@ -81,7 +104,7 @@ func TestGetDependentImages(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.DeepEqual(t, GetDependentImages(tt.service), tt.expected) + assert.DeepEqual(t, GetDependentImages(tt.service, projectName), tt.expected) }) } } diff --git a/pkg/compose/build.go b/pkg/compose/build.go index 6e5a3a5f8ba..3d4a50ffd1b 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -198,7 +198,7 @@ func (s *composeService) getLocalImagesDigests(ctx context.Context, project *typ imageNames.Add(volume.Source) } } - for _, img := range api.GetDependentImages(s) { + for _, img := range api.GetDependentImages(s, project.Name) { imageNames.Add(img) } } diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index 368704b9b7d..8e436e44ffb 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -136,15 +136,17 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts } // pre_start hook images run as ephemeral init containers with their own - // image, so they must be pulled too. They have no pull policy of their own, - // so we inherit the parent service's policy for skip decisions. + // 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 || service.PullPolicy == types.PullPolicyBuild { + if service.PullPolicy == types.PullPolicyNever { continue } - for _, img := range api.GetDependentImages(service) { + for _, img := range api.GetDependentImages(service, project.Name) { switch service.PullPolicy { - case types.PullPolicyMissing, types.PullPolicyIfNotPresent: + case types.PullPolicyMissing, types.PullPolicyIfNotPresent, types.PullPolicyBuild: if imageAlreadyPresent(img, images) { s.events.On(api.Resource{ ID: "Image " + img, @@ -328,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 { @@ -335,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 { @@ -345,21 +351,14 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types. Name: n, Image: vol.Source, } - } - } - } - - for i, img := range api.GetDependentImages(service) { - if _, ok := images[img]; !ok { - // 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, + scheduled[vol.Source] = true } } } } + + addPreStartHookPulls(project, images, needPull, scheduled) + if len(needPull) == 0 { return nil } @@ -394,6 +393,35 @@ 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) { + 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 From 9d2dad52a3340cb80889829fdb0d4b6b5de05575 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 23 Jul 2026 20:52:38 +0200 Subject: [PATCH 3/3] Force-pull pre_start hook images under pull_policy: always MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addPreStartHookPulls skipped any hook image already present locally, regardless of pull policy. As a result a service with pull_policy: always had its own image force-pulled on every up while its pre_start hook images were left stale — diverging from both the service image and the `pull` command path (which already re-pulls hooks under always). Skip the "already present" shortcut when the parent service is pull_policy: always, so hook images get the same force-pull treatment. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Nicolas De Loof --- pkg/compose/pull.go | 9 ++- pkg/compose/pull_test.go | 117 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 pkg/compose/pull_test.go diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index 8e436e44ffb..62aa57da6ec 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -405,8 +405,13 @@ func addPreStartHookPulls(project *types.Project, images map[string]api.ImageSum continue } for i, img := range api.GetDependentImages(service, project.Name) { - if _, ok := images[img]; ok { - continue + // 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 diff --git a/pkg/compose/pull_test.go b/pkg/compose/pull_test.go new file mode 100644 index 00000000000..c1af0caa596 --- /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"}) +}