Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/compose/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
16 changes: 16 additions & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
72 changes: 72 additions & 0 deletions pkg/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
3 changes: 3 additions & 0 deletions pkg/compose/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions pkg/compose/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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")
}
76 changes: 75 additions & 1 deletion pkg/compose/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -292,13 +330,17 @@ 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 {
return err
}
if pull {
needPull[name] = service
scheduled[service.Image] = true
}
for i, vol := range service.Volumes {
if vol.Type == types.VolumeTypeImage {
Expand All @@ -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
}
Expand Down Expand Up @@ -348,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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] addPreStartHookPulls silently skips hook images when service has pull_policy: always

addPreStartHookPulls guards against pulling already-present images with a plain existence check:

if _, ok := images[img]; ok {
    continue  // skips regardless of pull policy
}

This means that when a service has pull_policy: always, its own image is force-pulled every time (because mustPull returns true for PullPolicyAlways), but its pre_start hook images are silently skipped if they are already present locally — they never get the same "always re-pull" treatment.

A user who sets pull_policy: always to ensure freshness on every docker compose up will see the service image updated while stale hook images are left behind, which can lead to subtle divergence between the service container and its init hooks.

The fix is to also respect PullPolicyAlways in addPreStartHookPulls, analogous to how mustPull handles it for service images:

Suggested change
if _, ok := images[img]; ok {
for name, service := range project.Services {
if service.PullPolicy == types.PullPolicyNever {
continue
}
for i, img := range api.GetDependentImages(service, project.Name) {
if service.PullPolicy != types.PullPolicyAlways {
if _, ok := images[img]; ok {
continue
}
}
if scheduled[img] {
continue
}
Confidence Score
🟡 moderate 67/100

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
Expand Down