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
4 changes: 3 additions & 1 deletion cmd/compose/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,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 _, imageName := range api.GetImageNamesForService(s, project.Name) {
_, _ = fmt.Fprintln(dockerCli.Out(), imageName)
}
}
return nil
}
Expand Down
11 changes: 10 additions & 1 deletion cmd/compose/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (opts pullOptions) apply(project *types.Project, services []string) (*types

if opts.policy != "" {
for i, service := range project.Services {
if service.Image == "" {
if service.Image == "" && !hasPreStartHookImage(service) {
continue
}
service.PullPolicy = opts.policy
Expand All @@ -98,6 +98,15 @@ func (opts pullOptions) apply(project *types.Project, services []string) (*types
return project, nil
}

func hasPreStartHookImage(service types.ServiceConfig) bool {
for _, hook := range service.PreStart {
if hook.Image != "" {
return true
}
}
return false
}

func runPull(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts pullOptions, services []string) error {
backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...)
if err != nil {
Expand Down
8 changes: 8 additions & 0 deletions cmd/compose/pullOptions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ func TestApplyPullOptions(t *testing.T) {
Name: "must-pull",
Image: "registry.example.com/another-service",
},
"hook-only": {
Name: "hook-only",
Build: &types.BuildConfig{
Context: ".",
},
PreStart: []types.ServiceHook{{Image: "registry.example.com/hook"}},
},
},
}
project, err := pullOptions{
Expand All @@ -54,4 +61,5 @@ func TestApplyPullOptions(t *testing.T) {
assert.Equal(t, project.Services["must-build"].PullPolicy, "") // still default
assert.Equal(t, project.Services["has-build"].PullPolicy, types.PullPolicyMissing)
assert.Equal(t, project.Services["must-pull"].PullPolicy, types.PullPolicyMissing)
assert.Equal(t, project.Services["hook-only"].PullPolicy, types.PullPolicyMissing)
}
24 changes: 24 additions & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -760,3 +760,27 @@ func GetImageNameOrDefault(service types.ServiceConfig, projectName string) stri
}
return imageName
}

// GetImageNamesForService returns the image names required by a service.
//
// This includes the service image (or its default build image name), plus any
// distinct image explicitly referenced by pre_start lifecycle hooks. post_start
// and pre_stop hooks are intentionally excluded because Compose executes those
// hooks inside the service container instead of creating a container from the
// hook image field.
func GetImageNamesForService(service types.ServiceConfig, projectName string) []string {
imageNames := []string{GetImageNameOrDefault(service, projectName)}
seen := map[string]struct{}{imageNames[0]: {}}

for _, hook := range service.PreStart {
if hook.Image == "" {
continue
}
if _, ok := seen[hook.Image]; ok {
continue
}
imageNames = append(imageNames, hook.Image)
seen[hook.Image] = struct{}{}
}
return imageNames
}
17 changes: 17 additions & 0 deletions pkg/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,20 @@ func TestRunOptionsEnvironmentMap(t *testing.T) {
assert.Equal(t, *env["ZOT"], "")
assert.Check(t, env["QIX"] == nil)
}

func TestGetImageNamesForServiceIncludesPreStartHooks(t *testing.T) {
service := types.ServiceConfig{
Name: "demo",
Image: "alpine:3.20",
PreStart: []types.ServiceHook{
{Image: "alpine:3.20"},
{Image: "alpine:3.19"},
{Image: "alpine:3.19"},
{},
},
PostStart: []types.ServiceHook{{Image: "unused-post-start"}},
PreStop: []types.ServiceHook{{Image: "unused-pre-stop"}},
}

assert.DeepEqual(t, GetImageNamesForService(service, "hooktest"), []string{"alpine:3.20", "alpine:3.19"})
}
4 changes: 3 additions & 1 deletion pkg/compose/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@ func resolveImageVolumes(service *types.ServiceConfig, images map[string]api.Ima
func (s *composeService) getLocalImagesDigests(ctx context.Context, project *types.Project) (map[string]api.ImageSummary, error) {
imageNames := utils.Set[string]{}
for _, s := range project.Services {
imageNames.Add(api.GetImageNameOrDefault(s, project.Name))
for _, imageName := range api.GetImageNamesForService(s, project.Name) {
imageNames.Add(imageName)
}
for _, volume := range s.Volumes {
if volume.Type == types.VolumeTypeImage {
imageNames.Add(volume.Source)
Expand Down
104 changes: 82 additions & 22 deletions pkg/compose/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,35 +56,29 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
return err
}

eg, ctx := errgroup.WithContext(ctx)
eg.SetLimit(s.maxConcurrency)

var (
mustBuild []string
pullErrors = make([]error, len(project.Services))
imagesBeingPulled = map[string]string{}
targets []types.ServiceConfig
)

i := 0
for name, service := range project.Services {
if service.Image == "" {
s.events.On(api.Resource{
ID: name,
Status: api.Done,
Text: "Skipped",
Details: "No image to be pulled",
})
continue
queuePull := func(service types.ServiceConfig) {
if _, ok := imagesBeingPulled[service.Image]; ok {
return
}
imagesBeingPulled[service.Image] = service.Name
targets = append(targets, service)
}

maybePull := func(service types.ServiceConfig) {
switch service.PullPolicy {
case types.PullPolicyNever, types.PullPolicyBuild:
s.events.On(api.Resource{
ID: "Image " + service.Image,
Status: api.Done,
Text: "Skipped",
})
continue
return
case types.PullPolicyMissing, types.PullPolicyIfNotPresent:
if imageAlreadyPresent(service.Image, images) {
s.events.On(api.Resource{
Expand All @@ -93,7 +87,7 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
Text: "Skipped",
Details: "Image is already present locally",
})
continue
return
}
}

Expand All @@ -104,16 +98,44 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
Text: "Skipped",
Details: "Image can be built",
})
continue
return
}

if _, ok := imagesBeingPulled[service.Image]; ok {
continue
queuePull(service)
}

for name, service := range project.Services {
if service.Image == "" {
s.events.On(api.Resource{
ID: name,
Status: api.Done,
Text: "Skipped",
Details: "No image to be pulled",
})
} else {
maybePull(service)
}

imagesBeingPulled[service.Image] = service.Name
preStartImages := map[string]struct{}{}
for i, hook := range service.PreStart {
hookService, ok := preStartHookImageService(service, i, hook)
if !ok || hookService.Image == service.Image {
continue
}
if _, ok := preStartImages[hookService.Image]; ok {
continue
}
preStartImages[hookService.Image] = struct{}{}
maybePull(hookService)
}
}

eg, ctx := errgroup.WithContext(ctx)
eg.SetLimit(s.maxConcurrency)
pullErrors := make([]error, len(targets))
for i, service := range targets {
idx := i
service := service
eg.Go(func() error {
_, err := s.pullServiceImage(ctx, service, opts.Quiet, project.Environment["DOCKER_DEFAULT_PLATFORM"])
if err != nil {
Expand All @@ -132,11 +154,9 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
}
return nil
})
i++
}

err = eg.Wait()

if len(mustBuild) > 0 {
logrus.Warnf("WARNING: Some service image(s) must be built from source by running:\n docker compose build %s", strings.Join(mustBuild, " "))
}
Expand Down Expand Up @@ -313,6 +333,24 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types.
}
}

preStartImages := map[string]struct{}{}
for i, hook := range service.PreStart {
hookService, ok := preStartHookImageService(service, i, hook)
if !ok || hookService.Image == service.Image {
continue
}
if _, ok := preStartImages[hookService.Image]; ok || pullTargetForImageExists(needPull, hookService.Image) {
continue
}
preStartImages[hookService.Image] = struct{}{}
pull, err := mustPull(hookService, images)
if err != nil {
return err
}
if pull {
needPull[hookService.Name] = hookService
}
}
}
if len(needPull) == 0 {
return nil
Expand Down Expand Up @@ -348,6 +386,28 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types.
return err
}

func preStartHookImageService(service types.ServiceConfig, index int, hook types.ServiceHook) (types.ServiceConfig, bool) {
if hook.Image == "" {
return types.ServiceConfig{}, false
}
hookName := fmt.Sprintf("%s:pre_start %d", service.Name, index)
return types.ServiceConfig{
Name: hookName,
Image: hook.Image,
PullPolicy: service.PullPolicy,
Platform: service.Platform,
}, true
}

func pullTargetForImageExists(targets map[string]types.ServiceConfig, imageName string) bool {
for _, target := range targets {
if target.Image == imageName {
return true
}
}
return false
}

func mustPull(service types.ServiceConfig, images map[string]api.ImageSummary) (bool, error) {
if service.Provider != nil {
return false, nil
Expand Down
Loading