diff --git a/lib/images/mirror.go b/lib/images/mirror.go index 1889ed5e..17df7c8d 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 30fe0d3c..e0c55370 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()) +}