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")
}
81 changes: 80 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,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
Expand Down
117 changes: 117 additions & 0 deletions pkg/compose/pull_test.go
Original file line number Diff line number Diff line change
@@ -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"})
}
Loading