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 <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-07-30 10:40:06 +02:00
committed by Guillaume Lours
parent 1c7930e91a
commit c9266e6297
6 changed files with 147 additions and 0 deletions
+3
View File
@@ -636,6 +636,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
}
+14
View File
@@ -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
}
+49
View File
@@ -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)
})
}
}
+3
View File
@@ -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 {
+32
View File
@@ -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")
}
+46
View File
@@ -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