Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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) {
_, _ = fmt.Fprintln(dockerCli.Out(), img)
}
}
return nil
}
Expand Down
14 changes: 14 additions & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
ndeloof marked this conversation as resolved.
Outdated
49 changes: 49 additions & 0 deletions pkg/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 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) {
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")
}
46 changes: 46 additions & 0 deletions pkg/compose/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Comment thread
ndeloof marked this conversation as resolved.
Outdated
n := fmt.Sprintf("%s:pre_start %d", name, i)
needPull[n] = types.ServiceConfig{
Name: n,
Image: img,
}
}
}
Comment thread
ndeloof marked this conversation as resolved.
Outdated
}
if len(needPull) == 0 {
return nil
Expand Down
Loading