From aa518b9c3abe587f9bd8e9e2d8ec8fcf28fc901c Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 23 Jul 2026 20:52:38 +0200 Subject: [PATCH] Force-pull pre_start hook images under pull_policy: always MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Nicolas De Loof --- pkg/compose/pull.go | 9 ++- pkg/compose/pull_test.go | 117 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 pkg/compose/pull_test.go diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index 8e436e44f..62aa57da6 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -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 diff --git a/pkg/compose/pull_test.go b/pkg/compose/pull_test.go new file mode 100644 index 000000000..c1af0caa5 --- /dev/null +++ b/pkg/compose/pull_test.go @@ -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"}) +}