diff --git a/pkg/compose/images.go b/pkg/compose/images.go index 8612cdaf1..efddc5755 100644 --- a/pkg/compose/images.go +++ b/pkg/compose/images.go @@ -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 != "" diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index b2b3676dc..3c2aac623 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -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) diff --git a/pkg/compose/pull_test.go b/pkg/compose/pull_test.go index 4f70be781..606ea175b 100644 --- a/pkg/compose/pull_test.go +++ b/pkg/compose/pull_test.go @@ -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) +}