From f2d2875a6ec361066e06f5b6a7b8aaf31575bd65 Mon Sep 17 00:00:00 2001 From: mpuig Date: Tue, 11 Aug 2026 16:49:49 +0200 Subject: [PATCH] images: mirror digest-pinned indexes byte for byte A Dockerfile that pins its base image by digest almost always pins the multi-arch *index* digest -- it is the digest `docker pull` and Docker Hub print. MirrorBaseImage resolved that reference to the platform image and pushed it under the index digest, which the local registry correctly refuses: push to local registry: PUT .../v2/library/python/manifests/sha256:9b4929a7...: unexpected status code 400 Bad Request: digest mismatch: expected sha256:9b4929a7..., got sha256:1e58d36e... The mirror failure is only a WARN, so the build then proceeds and fails later with an unrelated error -- every digest-pinned FROM is unbuildable, and supply-chain-pinned Dockerfiles are exactly the ones that hit it. An index named by digest is now mirrored as the index itself (children first, then the index manifest), so the destination stores content whose digest is exactly the one the caller pinned. Tag references keep the existing platform-image behavior, which is what saves storage; a digest reference that names a plain manifest is also unchanged. The push logic moves into pushMirrored so the property is testable against in-memory registries: the new tests pin a random two-manifest index by digest, mirror it, and prove the pinned digest resolves at the destination. --- lib/images/mirror.go | 61 +++++++++++++++++++++++----- lib/images/mirror_test.go | 84 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 9 deletions(-) diff --git a/lib/images/mirror.go b/lib/images/mirror.go index 1889ed5ea..17df7c8d1 100644 --- a/lib/images/mirror.go +++ b/lib/images/mirror.go @@ -7,6 +7,7 @@ import ( "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" ) @@ -47,8 +48,16 @@ func MirrorBaseImage(ctx context.Context, registryURL string, req MirrorRequest, return nil, err } - // Pull the image from source - img, err := remote.Image(srcRef, + // Fetch the source descriptor first: what gets pushed depends on what the + // reference names. A digest reference can name a multi-arch *index*, and + // the platform-resolved image inside it has a different digest than the + // index — pushing that image under the index digest fails content-address + // verification at the destination ("digest mismatch"), which is exactly + // what any Dockerfile with a digest-pinned FROM produces. An index named + // by digest is therefore mirrored as the index itself, byte for byte. + // Tag references keep the existing behavior of mirroring only the + // requested platform, which is what saves storage. + desc, err := remote.Get(srcRef, remote.WithContext(ctx), remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithPlatform(platform.ToGCR())) @@ -56,11 +65,7 @@ func MirrorBaseImage(ctx context.Context, registryURL string, req MirrorRequest, return nil, fmt.Errorf("pull source image: %w", ClassifyRegistryError(err)) } - // Get the digest - digest, err := img.Digest() - if err != nil { - return nil, fmt.Errorf("get image digest: %w", err) - } + _, isDigestRef := srcRef.(name.Digest) // Build the local reference under bases/ namespace // Normalize the source to strip docker.io/ prefix for cleaner local refs @@ -87,8 +92,9 @@ func MirrorBaseImage(ctx context.Context, registryURL string, req MirrorRequest, opts = append(opts, remote.WithAuth(authn.FromConfig(*authConfig))) } - if err := remote.Write(dstRef, img, opts...); err != nil { - return nil, fmt.Errorf("push to local registry: %w", ClassifyRegistryError(err)) + digest, err := pushMirrored(desc, isDigestRef, dstRef, opts...) + if err != nil { + return nil, err } return &MirrorResult{ @@ -98,6 +104,43 @@ func MirrorBaseImage(ctx context.Context, registryURL string, req MirrorRequest, }, nil } +// pushMirrored writes the fetched content to dstRef and returns the digest it +// is retrievable under. +// +// An index named by digest is written as the index itself — children first, +// then the index manifest — so the destination stores content whose digest is +// exactly the one the caller pinned. Everything else keeps the original +// behavior: the (platform-resolved) image is written, under its own digest. +func pushMirrored(desc *remote.Descriptor, isDigestRef bool, dstRef name.Reference, opts ...remote.Option) (v1.Hash, error) { + if isDigestRef && desc.MediaType.IsIndex() { + idx, err := desc.ImageIndex() + if err != nil { + return v1.Hash{}, fmt.Errorf("load source index: %w", err) + } + digest, err := idx.Digest() + if err != nil { + return v1.Hash{}, fmt.Errorf("get image digest: %w", err) + } + if err := remote.WriteIndex(dstRef, idx, opts...); err != nil { + return v1.Hash{}, fmt.Errorf("push to local registry: %w", ClassifyRegistryError(err)) + } + return digest, nil + } + + img, err := desc.Image() + if err != nil { + return v1.Hash{}, fmt.Errorf("pull source image: %w", ClassifyRegistryError(err)) + } + digest, err := img.Digest() + if err != nil { + return v1.Hash{}, fmt.Errorf("get image digest: %w", err) + } + if err := remote.Write(dstRef, img, opts...); err != nil { + return v1.Hash{}, fmt.Errorf("push to local registry: %w", ClassifyRegistryError(err)) + } + return digest, nil +} + // normalizeToLocalRef converts a source image reference to a normalized local reference. // It strips the docker.io/ prefix but preserves the library/ prefix for official images. // The library/ prefix is kept because BuildKit's mirror protocol requests official images diff --git a/lib/images/mirror_test.go b/lib/images/mirror_test.go index 30fe0d3c9..e0c553707 100644 --- a/lib/images/mirror_test.go +++ b/lib/images/mirror_test.go @@ -1,9 +1,14 @@ package images import ( + "net/http/httptest" + "strings" "testing" "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -89,3 +94,82 @@ func TestStripScheme(t *testing.T) { }) } } + +// A digest-pinned FROM usually pins the multi-arch *index* digest (it is the +// digest `docker pull` prints), while the mirror pulls the platform-resolved +// image whose digest differs. Pushing that image under the index digest is +// refused by any registry that verifies content addresses — so an index named +// by digest must arrive at the local registry as the index itself, retrievable +// under exactly the digest the caller pinned. +// +// Tested at the pushMirrored seam: httptest registries live on 127.0.0.1:port, +// and a host:port cannot ride through normalizeToLocalRef as a path prefix the +// way docker.io/gcr.io sources do in production. +func TestPushMirroredPreservesIndexDigest(t *testing.T) { + src := httptest.NewServer(registry.New()) + defer src.Close() + dst := httptest.NewServer(registry.New()) + defer dst.Close() + srcHost := strings.TrimPrefix(src.URL, "http://") + dstHost := strings.TrimPrefix(dst.URL, "http://") + + // A two-manifest index, pushed to the source registry and then referenced + // by its digest — the exact shape of a digest-pinned Docker Hub image. + idx, err := random.Index(1024, 1, 2) + require.NoError(t, err) + indexDigest, err := idx.Digest() + require.NoError(t, err) + seed, err := name.ParseReference(srcHost + "/library/python:3.13-alpine") + require.NoError(t, err) + require.NoError(t, remote.WriteIndex(seed, idx)) + + srcRef, err := name.ParseReference(srcHost + "/library/python@" + indexDigest.String()) + require.NoError(t, err) + desc, err := remote.Get(srcRef) + require.NoError(t, err) + + dstRef, err := name.ParseReference(dstHost + "/library/python@" + indexDigest.String()) + require.NoError(t, err) + digest, err := pushMirrored(desc, true, dstRef) + require.NoError(t, err) + assert.Equal(t, indexDigest, digest, + "the mirrored digest must be the digest the caller pinned") + + // The proof the old code could not give: the content is retrievable from + // the destination under the pinned digest, i.e. it verified on push. + mirrored, err := remote.Get(dstRef) + require.NoError(t, err, "the pinned digest must resolve at the local registry") + assert.Equal(t, indexDigest, mirrored.Digest) + assert.True(t, mirrored.MediaType.IsIndex(), "an index must arrive as an index") +} + +// The storage-saving path is unchanged: a tag reference mirrors only the +// (platform-resolved) image, not the whole index. +func TestPushMirroredByTagStillMirrorsImage(t *testing.T) { + src := httptest.NewServer(registry.New()) + defer src.Close() + dst := httptest.NewServer(registry.New()) + defer dst.Close() + srcHost := strings.TrimPrefix(src.URL, "http://") + dstHost := strings.TrimPrefix(dst.URL, "http://") + + img, err := random.Image(1024, 1) + require.NoError(t, err) + imgDigest, err := img.Digest() + require.NoError(t, err) + srcRef, err := name.ParseReference(srcHost + "/library/alpine:3.21") + require.NoError(t, err) + require.NoError(t, remote.Write(srcRef, img)) + + desc, err := remote.Get(srcRef) + require.NoError(t, err) + dstRef, err := name.ParseReference(dstHost + "/library/alpine:3.21") + require.NoError(t, err) + digest, err := pushMirrored(desc, false, dstRef) + require.NoError(t, err) + assert.Equal(t, imgDigest, digest) + + mirrored, err := remote.Get(dstRef) + require.NoError(t, err) + assert.False(t, mirrored.MediaType.IsIndex()) +}