diff --git a/api/server/router/image/backend.go b/api/server/router/image/backend.go index ecac8b3225..b6fa9a2f13 100644 --- a/api/server/router/image/backend.go +++ b/api/server/router/image/backend.go @@ -39,7 +39,7 @@ type importExportBackend interface { type registryBackend interface { PullImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error - PushImage(ctx context.Context, ref reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error + PushImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error } type Searcher interface { diff --git a/api/server/router/image/image_routes.go b/api/server/router/image/image_routes.go index b941cab69f..56d185a682 100644 --- a/api/server/router/image/image_routes.go +++ b/api/server/router/image/image_routes.go @@ -187,6 +187,7 @@ func (ir *imageRouter) postImagesPush(ctx context.Context, w http.ResponseWriter img := vars["name"] tag := r.Form.Get("tag") + platformStr := r.Form.Get("platform") var ref reference.Named @@ -205,7 +206,19 @@ func (ir *imageRouter) postImagesPush(ctx context.Context, w http.ResponseWriter ref = r } - if err := ir.backend.PushImage(ctx, ref, metaHeaders, authConfig, output); err != nil { + var platform *ocispec.Platform + if platformStr != "" { + if versions.LessThan(httputils.VersionFromContext(ctx), "1.46") { + return errdefs.InvalidParameter(errors.New("platform parameter is not supported in API version < 1.46")) + } + p, err := parsePlatform(platformStr) + if err != nil { + return errdefs.InvalidParameter(err) + } + platform = &p + } + + if err := ir.backend.PushImage(ctx, ref, platform, metaHeaders, authConfig, output); err != nil { if !output.Flushed() { return err } @@ -537,3 +550,10 @@ func validateRepoName(name reference.Named) error { } return nil } + +func parsePlatform(platformStr string) (ocispec.Platform, error) { + if platformStr == "remote" { + return platforms.DefaultSpec(), nil + } + return platforms.Parse(platformStr) +} diff --git a/client/image_push.go b/client/image_push.go index e6a6b11eea..bfa50b12ef 100644 --- a/client/image_push.go +++ b/client/image_push.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" + "github.com/containerd/containerd/platforms" "github.com/distribution/reference" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/registry" @@ -36,6 +37,14 @@ func (cli *Client) ImagePush(ctx context.Context, image string, options image.Pu } } + if options.Platform != "" { + if options.Platform == "local" { + query.Set("platform", platforms.DefaultString()) + } else { + query.Set("platform", options.Platform) + } + } + resp, err := cli.tryImagePush(ctx, name, query, options.RegistryAuth) if errdefs.IsUnauthorized(err) && options.PrivilegeFunc != nil { newAuthHeader, privilegeErr := options.PrivilegeFunc() diff --git a/daemon/containerd/image_manifest.go b/daemon/containerd/image_manifest.go index f4fe77e444..ce4d3b7547 100644 --- a/daemon/containerd/image_manifest.go +++ b/daemon/containerd/image_manifest.go @@ -6,6 +6,7 @@ import ( "github.com/containerd/containerd" "github.com/containerd/containerd/content" + cerrdefs "github.com/containerd/containerd/errdefs" "github.com/containerd/containerd/images" containerdimages "github.com/containerd/containerd/images" "github.com/containerd/containerd/platforms" @@ -21,8 +22,7 @@ var ( ) // walkImageManifests calls the handler for each locally present manifest in -// the image. The image implements the containerd.Image interface, but all -// operations act on the specific manifest instead of the index. +// the image. func (i *ImageService) walkImageManifests(ctx context.Context, img containerdimages.Image, handler func(img *ImageManifest) error) error { desc := img.Target @@ -48,6 +48,52 @@ func (i *ImageService) walkImageManifests(ctx context.Context, img containerdima return errNotManifestOrIndex } +// walkReachableImageManifests calls the handler for each manifest in the +// multiplatform image that can be reached from the given image. +// The image might not be present locally, but its descriptor is known. +func (i *ImageService) walkReachableImageManifests(ctx context.Context, img containerdimages.Image, handler func(img *ImageManifest) error) error { + desc := img.Target + + handleManifest := func(ctx context.Context, d ocispec.Descriptor) error { + platformImg, err := i.NewImageManifest(ctx, img, d) + if err != nil { + if err == errNotManifest { + return nil + } + return err + } + return handler(platformImg) + } + + if containerdimages.IsManifestType(desc.MediaType) { + return handleManifest(ctx, desc) + } + + if containerdimages.IsIndexType(desc.MediaType) { + return containerdimages.Walk(ctx, containerdimages.HandlerFunc( + func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { + err := handleManifest(ctx, desc) + if err != nil { + return nil, err + } + + descs, err := containerdimages.Children(ctx, i.content, desc) + if err != nil { + if cerrdefs.IsNotFound(err) { + return nil, nil + } + return nil, err + } + return descs, nil + }), desc) + } + + return errNotManifestOrIndex +} + +// ImageManifest implements the containerd.Image interface, but all operations +// act on the specific manifest instead of the index as opposed to the struct +// returned by containerd.NewImageWithPlatform. type ImageManifest struct { containerd.Image @@ -78,21 +124,31 @@ func (im *ImageManifest) Metadata() containerdimages.Image { return md } +func (im *ImageManifest) IsAttestation() bool { + // Quick check for buildkit attestation manifests + // https://github.com/moby/buildkit/blob/v0.11.4/docs/attestations/attestation-storage.md + // This would have also been caught by the layer check below, but it requires + // an additional content read and deserialization of Manifest. + if _, has := im.Target().Annotations[attestation.DockerAnnotationReferenceType]; has { + return true + } + return false +} + // IsPseudoImage returns false if the manifest has no layers or any of its layers is a known image layer. // Some manifests use the image media type for compatibility, even if they are not a real image. func (im *ImageManifest) IsPseudoImage(ctx context.Context) (bool, error) { desc := im.Target() - // Quick check for buildkit attestation manifests - // https://github.com/moby/buildkit/blob/v0.11.4/docs/attestations/attestation-storage.md - // This would have also been caught by the layer check below, but it requires - // an additional content read and deserialization of Manifest. - if _, has := desc.Annotations[attestation.DockerAnnotationReferenceType]; has { + if im.IsAttestation() { return true, nil } mfst, err := im.Manifest(ctx) if err != nil { + if cerrdefs.IsNotFound(err) { + return false, errdefs.NotFound(errors.Wrapf(err, "failed to read manifest %v", desc.Digest)) + } return true, err } if len(mfst.Layers) == 0 { @@ -149,3 +205,21 @@ func readManifest(ctx context.Context, store content.Provider, desc ocispec.Desc return mfst, nil } + +// ImagePlatform returns the platform of the image manifest. +// If the manifest list doesn't have a platform filled, it will be read from the config. +func (m *ImageManifest) ImagePlatform(ctx context.Context) (ocispec.Platform, error) { + target := m.Target() + if target.Platform != nil { + return *target.Platform, nil + } + + configDesc, err := m.Config(ctx) + if err != nil { + return ocispec.Platform{}, err + } + + var out ocispec.Platform + err = readConfig(ctx, m.ContentStore(), configDesc, &out) + return out, err +} diff --git a/daemon/containerd/image_push.go b/daemon/containerd/image_push.go index 150e3c61c5..a778abac8d 100644 --- a/daemon/containerd/image_push.go +++ b/daemon/containerd/image_push.go @@ -41,7 +41,7 @@ import ( // pointing to the new target repository. This will allow subsequent pushes // to perform cross-repo mounts of the shared content when pushing to a different // repository on the same registry. -func (i *ImageService) PushImage(ctx context.Context, sourceRef reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) (retErr error) { +func (i *ImageService) PushImage(ctx context.Context, sourceRef reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) (retErr error) { start := time.Now() defer func() { if retErr == nil { @@ -76,7 +76,7 @@ func (i *ImageService) PushImage(ctx context.Context, sourceRef reference.Named, continue } - if err := i.pushRef(ctx, named, metaHeaders, authConfig, out); err != nil { + if err := i.pushRef(ctx, named, platform, metaHeaders, authConfig, out); err != nil { return err } } @@ -85,10 +85,62 @@ func (i *ImageService) PushImage(ctx context.Context, sourceRef reference.Named, } } - return i.pushRef(ctx, sourceRef, metaHeaders, authConfig, out) + return i.pushRef(ctx, sourceRef, platform, metaHeaders, authConfig, out) } -func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, out progress.Output) (retErr error) { +func (i *ImageService) getPushTarget(ctx context.Context, targetRef reference.Named, platform *ocispec.Platform) (ocispec.Descriptor, error) { + img, err := i.images.Get(ctx, targetRef.String()) + if cerrdefs.IsNotFound(err) { + return ocispec.Descriptor{}, errdefs.NotFound(fmt.Errorf("tag does not exist: %s", reference.FamiliarString(targetRef))) + } + + im, err := i.getImageManifestForPlatform(ctx, img, platform) + if err != nil { + return ocispec.Descriptor{}, err + } + + return im.Target(), nil +} + +func (i *ImageService) getImageManifestForPlatform(ctx context.Context, img containerdimages.Image, platform *ocispec.Platform) (*ImageManifest, error) { + if platform == nil { + return i.NewImageManifest(ctx, img, img.Target) + } + + pm := platforms.OnlyStrict(*platform) + var result *ImageManifest + err := i.walkReachableImageManifests(ctx, img, func(im *ImageManifest) error { + if im.IsAttestation() { + return nil + } + + imgPlatform, err := im.ImagePlatform(ctx) + if err != nil { + return err + } + + if !pm.Match(imgPlatform) { + return nil + } + + if result != nil { + return errdefs.Conflict(errors.Errorf("multiple manifests found for platform %s", *platform)) + } + + result = im + return nil + }) + if result == nil || cerrdefs.IsNotFound(err) { + return nil, errdefs.NotFound(fmt.Errorf("manifest not found for platform %s", *platform)) + } + if err != nil { + return nil, err + } + + return result, nil +} + +func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, out progress.Output) (retErr error) { leasedCtx, release, err := i.client.WithLease(ctx) if err != nil { return err @@ -99,17 +151,12 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m } }() - img, err := i.images.Get(ctx, targetRef.String()) + target, err := i.getPushTarget(ctx, targetRef, platform) if err != nil { - if cerrdefs.IsNotFound(err) { - return errdefs.NotFound(fmt.Errorf("tag does not exist: %s", reference.FamiliarString(targetRef))) - } - return errdefs.NotFound(err) + return err } - target := img.Target store := i.content - resolver, tracker := i.newResolverFromAuthConfig(ctx, authConfig, targetRef) pp := pushProgress{Tracker: tracker} jobsQueue := newJobs() @@ -121,7 +168,7 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m finishProgress() if retErr == nil { if tagged, ok := targetRef.(reference.Tagged); ok { - progress.Messagef(out, "", "%s: digest: %s size: %d", tagged.Tag(), target.Digest, img.Target.Size) + progress.Messagef(out, "", "%s: digest: %s size: %d", tagged.Tag(), target.Digest, target.Size) } } }() @@ -169,8 +216,9 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m "missing content: %w\n"+ "Note: You're trying to push a manifest list/index which "+ "references multiple platform specific manifests, but not all of them are available locally "+ - "or available to the remote repository.\n"+ - "Make sure you have all the referenced content and try again.", + "or available to the remote repository.\n\n"+ + "Make sure you have all the referenced content and try again.\n"+ + "You can also push only a single platform specific manifest directly by specifying the platform you want to push.", err)) } return err diff --git a/daemon/image_service.go b/daemon/image_service.go index 12a61b4022..eed6f43d6a 100644 --- a/daemon/image_service.go +++ b/daemon/image_service.go @@ -28,7 +28,7 @@ type ImageService interface { // Images PullImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error - PushImage(ctx context.Context, ref reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error + PushImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error CreateImage(ctx context.Context, config []byte, parent string, contentStoreDigest digest.Digest) (builder.Image, error) ImageDelete(ctx context.Context, imageRef string, force, prune bool) ([]imagetype.DeleteResponse, error) ExportImage(ctx context.Context, names []string, outStream io.Writer) error diff --git a/daemon/images/image_push.go b/daemon/images/image_push.go index 30a4cb9ba3..af2e64b598 100644 --- a/daemon/images/image_push.go +++ b/daemon/images/image_push.go @@ -2,6 +2,7 @@ package images // import "github.com/docker/docker/daemon/images" import ( "context" + "errors" "io" "time" @@ -10,11 +11,16 @@ import ( "github.com/docker/docker/api/types/registry" "github.com/docker/docker/distribution" progressutils "github.com/docker/docker/distribution/utils" + "github.com/docker/docker/errdefs" "github.com/docker/docker/pkg/progress" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) // PushImage initiates a push operation on the repository named localName. -func (i *ImageService) PushImage(ctx context.Context, ref reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error { +func (i *ImageService) PushImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error { + if platform != nil { + return errdefs.NotImplemented(errors.New("selecting platform is not supported with graphdriver backed image store")) + } start := time.Now() // Include a buffer so that slow client connections don't affect // transfer performance.