Skip to content

Commit

Permalink
c8d/push: Support platform selection
Browse files Browse the repository at this point in the history
Add a OCI platform fields as parameters to the `POST /images/{id}/push`
that allow to specify a specific-platform manifest to be pushed instead
of the whole image index.

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
  • Loading branch information
vvoland committed May 10, 2024
1 parent a281060 commit e8d00ce
Show file tree
Hide file tree
Showing 11 changed files with 537 additions and 30 deletions.
33 changes: 33 additions & 0 deletions api/server/httputils/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ package httputils // import "github.com/docker/docker/api/server/httputils"
import (
"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 +114,31 @@ 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

p.OS = form.Get("os")
p.Architecture = form.Get("architecture")
p.Variant = form.Get("variant")
p.OSVersion = form.Get("osversion")
p.OSFeatures = form["osfeatures"]

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
}
58 changes: 58 additions & 0 deletions api/server/httputils/form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import (
"net/http"
"net/url"
"testing"

"github.com/containerd/containerd/platforms"

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

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

func TestParsePlatform(t *testing.T) {
fillForm := func(p ocispec.Platform) url.Values {
v := url.Values{}
v.Set("os", p.OS)
v.Set("architecture", p.Architecture)

if p.Variant != "" {
v.Set("variant", p.Variant)
}

if p.OSVersion != "" {
v.Set("osversion", p.OSVersion)
}
for _, f := range p.OSFeatures {
v.Add("osfeatures", f)
}
return v
}

for _, tc := range []ocispec.Platform{
platforms.MustParse("linux/amd64"),
platforms.MustParse("linux/arm64"),
platforms.MustParse("linux/arm/v5"),
platforms.MustParse("linux/arm/v7"),
platforms.MustParse("windows/amd64"),
platforms.MustParse("darwin/arm64"),
{
OS: "windows",
Architecture: "amd64",
OSVersion: "1.2.3",
OSFeatures: []string{"a", "b"},
},
{
OS: "linux",
Architecture: "amd64",
OSVersion: "12.0",
},
} {
t.Run(platforms.Format(tc), func(t *testing.T) {
v := fillForm(tc)

p, err := ParsePlatform(v)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}

assert.Assert(t, p != nil)
assert.Check(t, is.DeepEqual(*p, tc))
})
}
}
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
7 changes: 6 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,12 @@ 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 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
16 changes: 16 additions & 0 deletions client/image_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ func (cli *Client) ImagePush(ctx context.Context, image string, options image.Pu
}
}

if options.Platform != nil {
p := *options.Platform
query.Set("os", p.OS)
query.Set("architecture", p.Architecture)

if p.Variant != "" {
query.Set("variant", p.Variant)
}
if p.OSVersion != "" {
query.Set("osversion", p.OSVersion)
}
for _, f := range p.OSFeatures {
query.Add("osfeatures", f)
}
}

resp, err := cli.tryImagePush(ctx, name, query, options.RegistryAuth)
if errdefs.IsUnauthorized(err) && options.PrivilegeFunc != nil {
newAuthHeader, privilegeErr := options.PrivilegeFunc(ctx)
Expand Down
113 changes: 100 additions & 13 deletions daemon/containerd/image_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
Expand All @@ -85,10 +85,10 @@ 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) 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
Expand All @@ -100,16 +100,16 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m
}()

img, err := i.images.Get(ctx, targetRef.String())
if cerrdefs.IsNotFound(err) {
return errdefs.NotFound(fmt.Errorf("tag does not exist: %s", reference.FamiliarString(targetRef)))
}

target, err := i.getPushDescriptor(ctx, img, 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()
Expand All @@ -121,7 +121,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)
}
}
}()
Expand Down Expand Up @@ -169,8 +169,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
Expand All @@ -183,6 +184,92 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m
return nil
}

func (i *ImageService) getPushDescriptor(ctx context.Context, img containerdimages.Image, platform *ocispec.Platform) (ocispec.Descriptor, error) {
hostPlatform := i.hostPlatform()
pm := matchAllWithPreference(hostPlatform)
if platform != nil {
pm = platforms.OnlyStrict(*platform)
}

anyMissing := false

var bestMatchPlatform ocispec.Platform
var bestMatch *ImageManifest
var presentMatchingManifests []*ImageManifest
err := i.walkReachableImageManifests(ctx, img, func(im *ImageManifest) error {
available, err := im.CheckContentAvailable(ctx)
if err != nil {
return fmt.Errorf("failed to determine availability of image manifest %s: %w", im.Target().Digest, err)
}

if !available {
anyMissing = true
return nil
}

if im.IsAttestation() {
return nil
}

imgPlatform, err := im.ImagePlatform(ctx)
if err != nil {
return fmt.Errorf("failed to determine platform of image %s: %w", img.Name, err)
}

if !pm.Match(imgPlatform) {
return nil
}

presentMatchingManifests = append(presentMatchingManifests, im)
if bestMatch == nil || pm.Less(imgPlatform, bestMatchPlatform) {
bestMatchPlatform = imgPlatform
bestMatch = im
}

return nil
})
if err != nil {
return ocispec.Descriptor{}, err
}

switch len(presentMatchingManifests) {
case 0:
return ocispec.Descriptor{}, errdefs.NotFound(fmt.Errorf("no suitable image manifest found for platform %s", *platform))
case 1:
// Only one manifest is available AND matching the requested platform.

if platform != nil {
// Explicit platform was requested
return presentMatchingManifests[0].Target(), nil
}

// No specific platform was requested, but only one manifest is available.
if anyMissing {
return presentMatchingManifests[0].Target(), nil
}

// Index has only one manifest anyway, select the full index.
return img.Target, nil
default:
if platform == nil {
if !anyMissing {
// No specific platform requested, and all manifests are available, select the full index.
return img.Target, nil
}

// No specific platform requested and not all manifests are available.
// Select the manifest that matches the host platform the best.
if bestMatch != nil && hostPlatform.Match(bestMatchPlatform) {
return bestMatch.Target(), nil
}

return ocispec.Descriptor{}, errdefs.Conflict(errors.Errorf("multiple matching manifests found but no specific platform requested"))
}

return ocispec.Descriptor{}, errdefs.Conflict(errors.Errorf("multiple manifests found for platform %s", *platform))
}
}

func appendDistributionSourceLabel(ctx context.Context, realStore content.Store, targetRef reference.Named, target ocispec.Descriptor) {
appendSource, err := docker.AppendDistributionSourceLabel(realStore, targetRef.String())
if err != nil {
Expand Down

0 comments on commit e8d00ce

Please sign in to comment.