Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

c8d/push: Support --platform switch #47679

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
37 changes: 37 additions & 0 deletions api/server/httputils/form.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package httputils // import "github.com/docker/docker/api/server/httputils"

import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"

"github.com/distribution/reference"
"github.com/docker/docker/errdefs"
"github.com/pkg/errors"

ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)

// BoolValue transforms a form value in different formats into a boolean type.
Expand Down Expand Up @@ -109,3 +115,34 @@ func ArchiveFormValues(r *http.Request, vars map[string]string) (ArchiveOptions,
}
return ArchiveOptions{name, path}, nil
}

// ParsePlatform parses the OCI platform from a form
func ParsePlatform(form url.Values) (*ocispec.Platform, error) {
var p ocispec.Platform

platformJson := form.Get("platform")
if platformJson == "" {
return nil, nil
}

if err := json.Unmarshal([]byte(platformJson), &p); err != nil {
return nil, errdefs.InvalidParameter(errors.Wrap(err, "failed to parse platform"))
}

hasAnyOptional := (p.Variant != "" || p.OSVersion != "" || len(p.OSFeatures) > 0)

if p.OS == "" && p.Architecture == "" && hasAnyOptional {
return nil, errdefs.InvalidParameter(errors.New("optional platform fields provided, but OS and Architecture are missing"))
}

if p.OS == "" && p.Architecture == "" {
// No platform specified
return nil, nil
}

if (p.OS != "") != (p.Architecture != "") {
return nil, errdefs.InvalidParameter(errors.New("both OS and Architecture must be provided"))
}

return &p, nil
}
30 changes: 30 additions & 0 deletions api/server/httputils/form_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
package httputils // import "github.com/docker/docker/api/server/httputils"

import (
"encoding/json"
"net/http"
"net/url"
"testing"

"github.com/containerd/containerd/platforms"
"github.com/docker/docker/errdefs"

ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"gotest.tools/v3/assert"
)

func TestBoolValue(t *testing.T) {
Expand Down Expand Up @@ -103,3 +110,26 @@ func TestInt64ValueOrDefaultWithError(t *testing.T) {
t.Fatal("Expected an error.")
}
}

func TestParsePlatformInvalid(t *testing.T) {
for _, tc := range []ocispec.Platform{
{
OSVersion: "1.2.3",
OSFeatures: []string{"a", "b"},
},
{OSVersion: "12.0"},
{OS: "linux"},
{Architecture: "amd64"},
} {
t.Run(platforms.Format(tc), func(t *testing.T) {
v := url.Values{}
js, err := json.Marshal(tc)
assert.NilError(t, err)

v.Set("platform", string(js))

_, err = ParsePlatform(v)
assert.Check(t, errdefs.IsInvalidParameter(err))
})
}
}
2 changes: 1 addition & 1 deletion api/server/router/image/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 12 additions & 1 deletion api/server/router/image/image_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,18 @@ func (ir *imageRouter) postImagesPush(ctx context.Context, w http.ResponseWriter
ref = r
}

if err := ir.backend.PushImage(ctx, ref, metaHeaders, authConfig, output); err != nil {
platform, err := httputils.ParsePlatform(r.Form)
if err != nil {
return err
}

if platform != nil {
if versions.LessThan(httputils.VersionFromContext(ctx), "1.46") {
return errdefs.InvalidParameter(errors.New("selecting platform is not supported in API version < 1.46"))
}
}

if err := ir.backend.PushImage(ctx, ref, platform, metaHeaders, authConfig, output); err != nil {
if !output.Flushed() {
return err
}
Expand Down
15 changes: 14 additions & 1 deletion api/types/image/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"

"github.com/docker/docker/api/types/filters"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)

// ImportOptions holds information to import images from the client host.
Expand Down Expand Up @@ -36,7 +37,19 @@ type PullOptions struct {
}

// PushOptions holds information to push images.
type PushOptions PullOptions
type PushOptions struct {
All bool
RegistryAuth string // RegistryAuth is the base64 encoded credentials for the registry

// PrivilegeFunc is a function that clients can supply to retry operations
// after getting an authorization error. This function returns the registry
// authentication header value in base64 encoded format, or an error if the
// privilege request fails.
//
// Also see [github.com/docker/docker/api/types.RequestPrivilegeFunc].
PrivilegeFunc func(context.Context) (string, error)
Platform *ocispec.Platform
}

// ListOptions holds parameters to list images with.
type ListOptions struct {
Expand Down
12 changes: 12 additions & 0 deletions client/image_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package client // import "github.com/docker/docker/client"

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
Expand Down Expand Up @@ -36,6 +38,16 @@ func (cli *Client) ImagePush(ctx context.Context, image string, options image.Pu
}
}

if options.Platform != nil {
p := *options.Platform
pJson, err := json.Marshal(p)
if err != nil {
return nil, fmt.Errorf("invalid platform: %v", err)
}

query.Set("platform", string(pJson))
}

resp, err := cli.tryImagePush(ctx, name, query, options.RegistryAuth)
if errdefs.IsUnauthorized(err) && options.PrivilegeFunc != nil {
newAuthHeader, privilegeErr := options.PrivilegeFunc(ctx)
Expand Down
11 changes: 10 additions & 1 deletion daemon/containerd/image_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package containerd

import (
"context"
"errors"
"fmt"
"math/rand"
"os"
Expand All @@ -13,6 +14,7 @@ import (

"github.com/containerd/containerd"
"github.com/containerd/containerd/content"
cerrdefs "github.com/containerd/containerd/errdefs"
"github.com/containerd/containerd/images"
"github.com/containerd/containerd/metadata"
"github.com/containerd/containerd/namespaces"
Expand Down Expand Up @@ -244,6 +246,9 @@ func (s *blobsDirContentStore) ReaderAt(ctx context.Context, desc ocispec.Descri
p := filepath.Join(s.blobs, desc.Digest.Encoded())
r, err := os.Open(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("not found %s: %w", desc.Digest, cerrdefs.ErrNotFound)
}
return nil, err
}
return &fileReaderAt{r}, nil
Expand All @@ -258,7 +263,8 @@ func (s *blobsDirContentStore) Status(ctx context.Context, _ string) (content.St
}

func (s *blobsDirContentStore) Delete(ctx context.Context, dgst digest.Digest) error {
return fmt.Errorf("read-only")
p := filepath.Join(s.blobs, dgst.Encoded())
return os.Remove(p)
}

func (s *blobsDirContentStore) ListStatuses(ctx context.Context, filters ...string) ([]content.Status, error) {
Expand Down Expand Up @@ -301,6 +307,9 @@ func (s *blobsDirContentStore) Walk(ctx context.Context, fn content.WalkFunc, fi
func (s *blobsDirContentStore) Info(ctx context.Context, dgst digest.Digest) (content.Info, error) {
f, err := os.Open(filepath.Join(s.blobs, dgst.Encoded()))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return content.Info{}, fmt.Errorf("not found %s: %w", dgst, cerrdefs.ErrNotFound)
}
return content.Info{}, err
}
defer f.Close()
Expand Down
99 changes: 92 additions & 7 deletions daemon/containerd/image_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

Expand All @@ -48,13 +48,61 @@ func (i *ImageService) walkImageManifests(ctx context.Context, img containerdima
return errors.Wrapf(errNotManifestOrIndex, "error walking manifest for %s", img.Name)
}

// 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

// Parent of the manifest (index/manifest list)
RealTarget ocispec.Descriptor

manifest *ocispec.Manifest

cachedImagePlatform *ocispec.Platform
}

func (i *ImageService) NewImageManifest(ctx context.Context, img containerdimages.Image, manifestDesc ocispec.Descriptor) (*ImageManifest, error) {
Expand All @@ -78,21 +126,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 {
Expand Down Expand Up @@ -149,3 +207,30 @@ 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) {
if m.cachedImagePlatform != nil {
return *m.cachedImagePlatform, nil
}

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)
if err != nil {
return ocispec.Platform{}, err
}

m.cachedImagePlatform = &out
return out, nil
}