Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 0a31437

Browse files
committed
c8d/push: Support platform selection
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. When no platform was requested and pushing whole index failed, fallback to pushing a platform-specific manifest with a best candidate (if it's possible to choose one). Signed-off-by: Paweł Gronowski <[email protected]>
1 parent 999f1c6 commit 0a31437

15 files changed

Lines changed: 569 additions & 20 deletions

File tree

api/server/httputils/form.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package httputils // import "github.com/docker/docker/api/server/httputils"
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"net/http"
67
"strconv"
78
"strings"
89

910
"github.com/distribution/reference"
11+
"github.com/docker/docker/errdefs"
12+
"github.com/pkg/errors"
13+
14+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
1015
)
1116

1217
// BoolValue transforms a form value in different formats into a boolean type.
@@ -109,3 +114,24 @@ func ArchiveFormValues(r *http.Request, vars map[string]string) (ArchiveOptions,
109114
}
110115
return ArchiveOptions{name, path}, nil
111116
}
117+
118+
// DecodePlatform decodes the OCI platform JSON string into a Platform struct.
119+
func DecodePlatform(platformJSON string) (*ocispec.Platform, error) {
120+
var p ocispec.Platform
121+
122+
if err := json.Unmarshal([]byte(platformJSON), &p); err != nil {
123+
return nil, errdefs.InvalidParameter(errors.Wrap(err, "failed to parse platform"))
124+
}
125+
126+
hasAnyOptional := (p.Variant != "" || p.OSVersion != "" || len(p.OSFeatures) > 0)
127+
128+
if p.OS == "" && p.Architecture == "" && hasAnyOptional {
129+
return nil, errdefs.InvalidParameter(errors.New("optional platform fields provided, but OS and Architecture are missing"))
130+
}
131+
132+
if p.OS == "" || p.Architecture == "" {
133+
return nil, errdefs.InvalidParameter(errors.New("both OS and Architecture must be provided"))
134+
}
135+
136+
return &p, nil
137+
}

api/server/httputils/form_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
package httputils // import "github.com/docker/docker/api/server/httputils"
22

33
import (
4+
"encoding/json"
45
"net/http"
56
"net/url"
67
"testing"
8+
9+
"github.com/containerd/containerd/platforms"
10+
"github.com/docker/docker/errdefs"
11+
12+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
13+
"gotest.tools/v3/assert"
714
)
815

916
func TestBoolValue(t *testing.T) {
@@ -103,3 +110,23 @@ func TestInt64ValueOrDefaultWithError(t *testing.T) {
103110
t.Fatal("Expected an error.")
104111
}
105112
}
113+
114+
func TestParsePlatformInvalid(t *testing.T) {
115+
for _, tc := range []ocispec.Platform{
116+
{
117+
OSVersion: "1.2.3",
118+
OSFeatures: []string{"a", "b"},
119+
},
120+
{OSVersion: "12.0"},
121+
{OS: "linux"},
122+
{Architecture: "amd64"},
123+
} {
124+
t.Run(platforms.Format(tc), func(t *testing.T) {
125+
js, err := json.Marshal(tc)
126+
assert.NilError(t, err)
127+
128+
_, err = DecodePlatform(string(js))
129+
assert.Check(t, errdefs.IsInvalidParameter(err))
130+
})
131+
}
132+
}

api/server/router/image/backend.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ type importExportBackend interface {
3939

4040
type registryBackend interface {
4141
PullImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error
42-
PushImage(ctx context.Context, ref reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error
42+
PushImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error
4343
}
4444

4545
type Searcher interface {

api/server/router/image/image_routes.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,21 @@ func (ir *imageRouter) postImagesPush(ctx context.Context, w http.ResponseWriter
205205
ref = r
206206
}
207207

208-
if err := ir.backend.PushImage(ctx, ref, metaHeaders, authConfig, output); err != nil {
208+
var platform *ocispec.Platform
209+
if formPlatform := r.Form.Get("platform"); formPlatform != "" {
210+
if versions.LessThan(httputils.VersionFromContext(ctx), "1.46") {
211+
return errdefs.InvalidParameter(errors.New("selecting platform is not supported in API version < 1.46"))
212+
}
213+
214+
p, err := httputils.DecodePlatform(formPlatform)
215+
if err != nil {
216+
return err
217+
}
218+
219+
platform = p
220+
}
221+
222+
if err := ir.backend.PushImage(ctx, ref, platform, metaHeaders, authConfig, output); err != nil {
209223
if !output.Flushed() {
210224
return err
211225
}

api/swagger.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8752,6 +8752,11 @@ paths:
87528752
details.
87538753
type: "string"
87548754
required: true
8755+
- name: "platform"
8756+
in: "query"
8757+
description: "Select a platform-specific manifest to be pushed. OCI platform (JSON encoded)"
8758+
type: "string"
8759+
x-nullable: true
87558760
tags: ["Image"]
87568761
/images/{name}/tag:
87578762
post:

api/types/image/opts.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55

66
"github.com/docker/docker/api/types/filters"
7+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
78
)
89

910
// ImportOptions holds information to import images from the client host.
@@ -36,7 +37,23 @@ type PullOptions struct {
3637
}
3738

3839
// PushOptions holds information to push images.
39-
type PushOptions PullOptions
40+
type PushOptions struct {
41+
All bool
42+
RegistryAuth string // RegistryAuth is the base64 encoded credentials for the registry
43+
44+
// PrivilegeFunc is a function that clients can supply to retry operations
45+
// after getting an authorization error. This function returns the registry
46+
// authentication header value in base64 encoded format, or an error if the
47+
// privilege request fails.
48+
//
49+
// Also see [github.com/docker/docker/api/types.RequestPrivilegeFunc].
50+
PrivilegeFunc func(context.Context) (string, error)
51+
52+
// Platform is an optional field that selects a specific platform to push
53+
// when the image is a multi-platform image.
54+
// Using this will only push a single platform-specific manifest.
55+
Platform *ocispec.Platform `json:",omitempty"`
56+
}
4057

4158
// ListOptions holds parameters to list images with.
4259
type ListOptions struct {

client/image_push.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package client // import "github.com/docker/docker/client"
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
7+
"fmt"
68
"io"
79
"net/http"
810
"net/url"
@@ -36,6 +38,20 @@ func (cli *Client) ImagePush(ctx context.Context, image string, options image.Pu
3638
}
3739
}
3840

41+
if options.Platform != nil {
42+
if err := cli.NewVersionError(ctx, "1.46", "platform"); err != nil {
43+
return nil, err
44+
}
45+
46+
p := *options.Platform
47+
pJson, err := json.Marshal(p)
48+
if err != nil {
49+
return nil, fmt.Errorf("invalid platform: %v", err)
50+
}
51+
52+
query.Set("platform", string(pJson))
53+
}
54+
3955
resp, err := cli.tryImagePush(ctx, name, query, options.RegistryAuth)
4056
if errdefs.IsUnauthorized(err) && options.PrivilegeFunc != nil {
4157
newAuthHeader, privilegeErr := options.PrivilegeFunc(ctx)

daemon/containerd/image_push.go

Lines changed: 136 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import (
4141
// pointing to the new target repository. This will allow subsequent pushes
4242
// to perform cross-repo mounts of the shared content when pushing to a different
4343
// repository on the same registry.
44-
func (i *ImageService) PushImage(ctx context.Context, sourceRef reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) (retErr error) {
44+
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) {
4545
start := time.Now()
4646
defer func() {
4747
if retErr == nil {
@@ -76,7 +76,7 @@ func (i *ImageService) PushImage(ctx context.Context, sourceRef reference.Named,
7676
continue
7777
}
7878

79-
if err := i.pushRef(ctx, named, metaHeaders, authConfig, out); err != nil {
79+
if err := i.pushRef(ctx, named, platform, metaHeaders, authConfig, out); err != nil {
8080
return err
8181
}
8282
}
@@ -85,10 +85,10 @@ func (i *ImageService) PushImage(ctx context.Context, sourceRef reference.Named,
8585
}
8686
}
8787

88-
return i.pushRef(ctx, sourceRef, metaHeaders, authConfig, out)
88+
return i.pushRef(ctx, sourceRef, platform, metaHeaders, authConfig, out)
8989
}
9090

91-
func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, out progress.Output) (retErr error) {
91+
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) {
9292
leasedCtx, release, err := i.client.WithLease(ctx)
9393
if err != nil {
9494
return err
@@ -104,12 +104,18 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m
104104
if cerrdefs.IsNotFound(err) {
105105
return errdefs.NotFound(fmt.Errorf("tag does not exist: %s", reference.FamiliarString(targetRef)))
106106
}
107-
return errdefs.NotFound(err)
107+
return errdefs.System(err)
108108
}
109109

110110
target := img.Target
111-
store := i.content
111+
if platform != nil {
112+
target, err = i.getPushDescriptor(ctx, img, platform)
113+
if err != nil {
114+
return err
115+
}
116+
}
112117

118+
store := i.content
113119
resolver, tracker := i.newResolverFromAuthConfig(ctx, authConfig, targetRef)
114120
pp := pushProgress{Tracker: tracker}
115121
jobsQueue := newJobs()
@@ -121,7 +127,7 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m
121127
finishProgress()
122128
if retErr == nil {
123129
if tagged, ok := targetRef.(reference.Tagged); ok {
124-
progress.Messagef(out, "", "%s: digest: %s size: %d", tagged.Tag(), target.Digest, img.Target.Size)
130+
progress.Messagef(out, "", "%s: digest: %s size: %d", tagged.Tag(), target.Digest, target.Size)
125131
}
126132
}
127133
}()
@@ -164,16 +170,44 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m
164170

165171
err = remotes.PushContent(ctx, pusher, target, store, limiter, platforms.All, handlerWrapper)
166172
if err != nil {
167-
if containerdimages.IsIndexType(target.MediaType) && cerrdefs.IsNotFound(err) {
173+
// If push failed because of a missing content, no specific platform was requested
174+
// and the target is an index, select a platform-specific manifest to push instead.
175+
if cerrdefs.IsNotFound(err) && containerdimages.IsIndexType(target.MediaType) && platform == nil {
176+
var newTarget ocispec.Descriptor
177+
newTarget, err = i.getPushDescriptor(ctx, img, nil)
178+
if err != nil {
179+
return err
180+
}
181+
182+
// Retry only if the new push candidate is different from the previous one.
183+
if newTarget.Digest != target.Digest {
184+
target = newTarget
185+
pp.TurnNotStartedIntoUnavailable()
186+
err = remotes.PushContent(ctx, pusher, target, store, limiter, platforms.All, handlerWrapper)
187+
188+
if err == nil {
189+
progress.Aux(out, map[string]string{
190+
"Stripped": "true",
191+
"Index": img.Target.Digest.String(),
192+
"Manifest": target.Digest.String(),
193+
})
194+
}
195+
}
196+
}
197+
198+
if err != nil {
199+
if !cerrdefs.IsNotFound(err) {
200+
return errdefs.System(err)
201+
}
168202
return errdefs.NotFound(fmt.Errorf(
169203
"missing content: %w\n"+
170204
"Note: You're trying to push a manifest list/index which "+
171205
"references multiple platform specific manifests, but not all of them are available locally "+
172-
"or available to the remote repository.\n"+
173-
"Make sure you have all the referenced content and try again.",
206+
"or available to the remote repository.\n\n"+
207+
"Make sure you have all the referenced content and try again.\n"+
208+
"You can also push only a single platform specific manifest directly by specifying the platform you want to push.",
174209
err))
175210
}
176-
return err
177211
}
178212

179213
appendDistributionSourceLabel(ctx, realStore, targetRef, target)
@@ -183,6 +217,97 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m
183217
return nil
184218
}
185219

220+
func (i *ImageService) getPushDescriptor(ctx context.Context, img containerdimages.Image, platform *ocispec.Platform) (ocispec.Descriptor, error) {
221+
// Allow to override the host platform for testing purposes.
222+
hostPlatform := i.defaultPlatformOverride
223+
if hostPlatform == nil {
224+
hostPlatform = platforms.Default()
225+
}
226+
227+
pm := matchAllWithPreference(hostPlatform)
228+
if platform != nil {
229+
pm = platforms.OnlyStrict(*platform)
230+
}
231+
232+
anyMissing := false
233+
234+
var bestMatchPlatform ocispec.Platform
235+
var bestMatch *ImageManifest
236+
var presentMatchingManifests []*ImageManifest
237+
err := i.walkReachableImageManifests(ctx, img, func(im *ImageManifest) error {
238+
available, err := im.CheckContentAvailable(ctx)
239+
if err != nil {
240+
return fmt.Errorf("failed to determine availability of image manifest %s: %w", im.Target().Digest, err)
241+
}
242+
243+
if !available {
244+
anyMissing = true
245+
return nil
246+
}
247+
248+
if im.IsAttestation() {
249+
return nil
250+
}
251+
252+
imgPlatform, err := im.ImagePlatform(ctx)
253+
if err != nil {
254+
return fmt.Errorf("failed to determine platform of image %s: %w", img.Name, err)
255+
}
256+
257+
if !pm.Match(imgPlatform) {
258+
return nil
259+
}
260+
261+
presentMatchingManifests = append(presentMatchingManifests, im)
262+
if bestMatch == nil || pm.Less(imgPlatform, bestMatchPlatform) {
263+
bestMatchPlatform = imgPlatform
264+
bestMatch = im
265+
}
266+
267+
return nil
268+
})
269+
if err != nil {
270+
return ocispec.Descriptor{}, err
271+
}
272+
273+
switch len(presentMatchingManifests) {
274+
case 0:
275+
return ocispec.Descriptor{}, errdefs.NotFound(fmt.Errorf("no suitable image manifest found for platform %s", *platform))
276+
case 1:
277+
// Only one manifest is available AND matching the requested platform.
278+
279+
if platform != nil {
280+
// Explicit platform was requested
281+
return presentMatchingManifests[0].Target(), nil
282+
}
283+
284+
// No specific platform was requested, but only one manifest is available.
285+
if anyMissing {
286+
return presentMatchingManifests[0].Target(), nil
287+
}
288+
289+
// Index has only one manifest anyway, select the full index.
290+
return img.Target, nil
291+
default:
292+
if platform == nil {
293+
if !anyMissing {
294+
// No specific platform requested, and all manifests are available, select the full index.
295+
return img.Target, nil
296+
}
297+
298+
// No specific platform requested and not all manifests are available.
299+
// Select the manifest that matches the host platform the best.
300+
if bestMatch != nil && hostPlatform.Match(bestMatchPlatform) {
301+
return bestMatch.Target(), nil
302+
}
303+
304+
return ocispec.Descriptor{}, errdefs.Conflict(errors.Errorf("multiple matching manifests found but no specific platform requested"))
305+
}
306+
307+
return ocispec.Descriptor{}, errdefs.Conflict(errors.Errorf("multiple manifests found for platform %s", *platform))
308+
}
309+
}
310+
186311
func appendDistributionSourceLabel(ctx context.Context, realStore content.Store, targetRef reference.Named, target ocispec.Descriptor) {
187312
appendSource, err := docker.AppendDistributionSourceLabel(realStore, targetRef.String())
188313
if err != nil {

0 commit comments

Comments
 (0)