From c024750b074f5786ea97a9c5dbed927dd40ead2d Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 15 Sep 2026 13:56:22 +0200 Subject: [PATCH] fix: type=image volume source is always a docker image reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WithImagesResolved silently skipped digest resolution whenever a type=image volume source happened to match the name of another service in the project (active or profile-disabled), treating it as an implicit reference to that service's locally built image. This was never part of the Compose Spec: source for a type=image mount is documented (05-services.md) as "a docker image reference", nothing else. The behavior was an incorrectly implemented side effect introduced in #894/#899 while wiring up digest resolution for dependent images, never reviewed as a feature in its own right, and never reflected in the spec or schema. It also has no integration with the rest of the dependency machinery (DependsOn, service selection, build ordering): the referenced service is not treated as a dependency anywhere else, so relying on this silently produced broken results outside of narrow, coincidental setups. Remove the special-casing: a type=image source is now always resolved as a plain image reference, like any other. An invalid one (e.g. one that happens to reuse a mixed-case service name) now fails resolution instead of being silently passed through. If a mount sourced from another service's build output is wanted as a real feature, it should be a new, explicit reference form (e.g. `source: service:xxx`), consistent with how other inter-service references are expressed in the Compose Spec (e.g. `additional_contexts: [service:xxx]`) — not an implicit fallback on name collision. Found while reviewing docker/compose#13811. Signed-off-by: Nicolas De Loof --- types/project.go | 10 ++-------- types/project_test.go | 21 ++++++++++++--------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/types/project.go b/types/project.go index fad8f985..852361be 100644 --- a/types/project.go +++ b/types/project.go @@ -698,8 +698,8 @@ func (p *Project) WithServicesDisabled(names ...string) *Project { // It returns a new Project instance with the changes and keep the original Project unchanged. // Besides the service image, this also resolves the images services depend on: // - pre_start hook images, which run as ephemeral init containers with their own image -// - `type: image` volume sources, unless they reference another service by name (those are -// resolved to a locally built image rather than a registry digest) +// - `type: image` volume sources — source is always a docker image reference, not +// a reference to another service (there is no such thing in the Compose Spec) func (p *Project) WithImagesResolved(resolver func(named reference.Named) (godigest.Digest, error)) (*Project, error) { // Deduplicate resolutions per raw image string across the whole call, on two axes: // - cache (sync.Map) memoizes results for the whole call, so images resolved at @@ -752,12 +752,6 @@ func (p *Project) WithImagesResolved(resolver func(named reference.Named) (godig if vol.Type != VolumeTypeImage { continue } - if _, ok := p.Services[vol.Source]; ok { - continue - } - if _, ok := p.DisabledServices[vol.Source]; ok { - continue - } image, err := resolve(vol.Source) if err != nil { return service, err diff --git a/types/project_test.go b/types/project_test.go index 8d9c6849..3b8af339 100644 --- a/types/project_test.go +++ b/types/project_test.go @@ -321,7 +321,10 @@ func Test_ResolveImages_imageVolumes(t *testing.T) { Volumes: []ServiceVolumeConfig{ // external image reference: must be resolved to a digest {Type: VolumeTypeImage, Source: "alpine:3.19", Target: "/data"}, - // reference to another service: resolved to a local image, left untouched + // a `type: image` source is always a docker image reference, + // resolved like any other one — even though a project service + // happens to share its name, it is NOT treated as a reference + // to that service (there is no such thing in the Compose Spec) {Type: VolumeTypeImage, Source: "builder", Target: "/from-builder"}, // regular named volume: left untouched {Type: VolumeTypeVolume, Source: "vol", Target: "/vol"}, @@ -336,7 +339,7 @@ func Test_ResolveImages_imageVolumes(t *testing.T) { volumes := p.Services["service_1"].Volumes assert.Equal(t, volumes[0].Source, "docker.io/library/alpine:3.19@"+digested) - assert.Equal(t, volumes[1].Source, "builder") + assert.Equal(t, volumes[1].Source, "docker.io/library/builder:latest@"+digested) assert.Equal(t, volumes[2].Source, "vol") } @@ -362,10 +365,11 @@ func Test_ResolveImages_preStartHookError(t *testing.T) { assert.Error(t, err, "registry unreachable") } -func Test_ResolveImages_imageVolumeDisabledService(t *testing.T) { - // A `type: image` volume referencing a profile-disabled service is treated as a - // service reference: the resolver must never be called for it, and the source is - // left unchanged. The uppercase name would also trip reference.ParseDockerRef. +func Test_ResolveImages_imageVolumeInvalidSourceErrors(t *testing.T) { + // A `type: image` source is always a docker image reference: it is resolved + // like any other one, even when it happens to match a (possibly + // profile-disabled) project service name. An uppercase name is not a valid + // docker reference, so resolution fails rather than being silently skipped. resolver := func(named reference.Named) (digest.Digest, error) { return "", fmt.Errorf("resolver must not be called for %s", named) } @@ -385,9 +389,8 @@ func Test_ResolveImages_imageVolumeDisabledService(t *testing.T) { }, } - p, err := p.WithImagesResolved(resolver) - assert.NilError(t, err) - assert.Equal(t, p.Services["service_1"].Volumes[0].Source, "Builder") + _, err := p.WithImagesResolved(resolver) + assert.ErrorContains(t, err, "invalid reference format") } func Test_ResolveImages_deduplicated(t *testing.T) {