docs+test: disambiguate content digests from distribution digests

Compose manipulates two digest kinds that must never be conflated:
the platform-specific content digest (localContentDigest) is ONLY an
identity to compare a running container with a fresh build/pull, while
pinning image references in a reproducible compose model (publish /
config --resolve-image-digests) must keep the registry descriptor
digest — the multi-platform index — through ImageDigestResolver, or
the published file would be bound to the platform of whoever resolved
it. Document the distinction on both producers and lock it with a test
that fails if the resolver ever goes through a local image inspect.

Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-08-13 12:03:29 +02:00
committed by Guillaume Lours
parent a2e7fe6590
commit b32c5b9a94
3 changed files with 52 additions and 1 deletions
+7
View File
@@ -264,6 +264,13 @@ func (s *composeService) canonicalBuiltDigest(ctx context.Context, imageRef, pla
// pre-manifest behavior. Every image identity compose records for staleness
// comparison must be computed through here, whatever the image's provenance
// (pulled, built, already local), so any two runs produce comparable values.
//
// The converse also holds: this platform-specific digest is ONLY an identity
// for staleness comparison. Pinning image references in a reproducible
// compose model (publish / config --resolve-image-digests) must keep using
// the registry descriptor digest — the multi-platform index — through
// ImageDigestResolver, or the published file would be bound to the platform
// of whoever resolved it.
func localContentDigest(inspect client.ImageInspectResult, platform string) (string, bool, error) {
var matcher platforms.Matcher = platforms.Default()
pinned := platform != ""
+11 -1
View File
@@ -335,7 +335,17 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser
return nil
}
// ImageDigestResolver creates a func able to resolve image digest from a docker ref,
// ImageDigestResolver creates a func able to resolve image digest from a
// docker ref, for pinning image references in a reproducible compose model
// (`compose publish` / `config --resolve-image-digests`).
//
// It deliberately returns the registry descriptor digest — the multi-platform
// index digest for multi-arch images — via DistributionInspect: a published
// compose file must stay deployable on any platform. This is NOT the same
// digest kind as localContentDigest, which selects the platform-specific
// runnable manifest to compare a running container with a fresh build/pull;
// never funnel this resolution through the local content-digest producer, and
// never pin a published reference with a per-platform digest.
func ImageDigestResolver(ctx context.Context, file *configfile.ConfigFile, apiClient client.APIClient) func(named reference.Named) (digest.Digest, error) {
return func(named reference.Named) (digest.Digest, error) {
auth, err := encodedAuth(named, file)
+34
View File
@@ -25,13 +25,19 @@ import (
"time"
"github.com/compose-spec/compose-go/v2/types"
"github.com/distribution/reference"
"github.com/docker/cli/cli/config/configfile"
"github.com/moby/moby/api/types/image"
"github.com/moby/moby/api/types/jsonstream"
registrytypes "github.com/moby/moby/api/types/registry"
"github.com/moby/moby/client"
"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"go.uber.org/mock/gomock"
"gotest.tools/v3/assert"
"github.com/docker/compose/v5/pkg/api"
"github.com/docker/compose/v5/pkg/mocks"
)
// scheduledHookImages runs addPreStartHookPulls and returns the hook image
@@ -294,3 +300,31 @@ func TestShouldPullImageProvider(t *testing.T) {
assert.NilError(t, err)
assert.Assert(t, !pull, "provider service without image has nothing to pull")
}
// TestImageDigestResolverUsesDistributionDigest locks the distinction between
// the two digest kinds compose manipulates: pinning a reference for a
// reproducible compose model (publish / config --resolve-image-digests) must
// use the registry descriptor digest — the multi-platform index — and never
// the local platform-specific content digest used for staleness comparison.
// The mock declares no ImageInspect expectation, so any attempt to resolve
// through the local content-digest producer fails the test.
func TestImageDigestResolverUsesDistributionDigest(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
mockAPI := mocks.NewMockAPIClient(mockCtrl)
const indexDigest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
mockAPI.EXPECT().
DistributionInspect(gomock.Any(), "docker.io/library/foo:1", gomock.Any()).
Return(client.DistributionInspectResult{
DistributionInspect: registrytypes.DistributionInspect{
Descriptor: ocispec.Descriptor{Digest: digest.Digest(indexDigest)},
},
}, nil)
named, err := reference.ParseDockerRef("foo:1")
assert.NilError(t, err)
resolved, err := ImageDigestResolver(t.Context(), &configfile.ConfigFile{}, mockAPI)(named)
assert.NilError(t, err)
assert.Equal(t, resolved.String(), indexDigest)
}