Force-pull pre_start hook images under pull_policy: always

addPreStartHookPulls skipped any hook image already present locally,
regardless of pull policy. As a result a service with pull_policy: always
had its own image force-pulled on every up while its pre_start hook images
were left stale — diverging from both the service image and the `pull`
command path (which already re-pulls hooks under always).

Skip the "already present" shortcut when the parent service is
pull_policy: always, so hook images get the same force-pull treatment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2026-07-23 20:52:38 +02:00 committed by Guillaume Lours
parent 1cc5120af8
commit aa518b9c3a
2 changed files with 124 additions and 2 deletions

View file

@ -405,8 +405,13 @@ func addPreStartHookPulls(project *types.Project, images map[string]api.ImageSum
continue
}
for i, img := range api.GetDependentImages(service, project.Name) {
if _, ok := images[img]; ok {
continue
// 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

117
pkg/compose/pull_test.go Normal file
View file

@ -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"})
}