Browse code

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 <pawel.gronowski@docker.com>

Paweł Gronowski authored on 2024/03/28 19:40:47
Showing 15 changed files
... ...
@@ -1,12 +1,17 @@
1 1
 package httputils // import "github.com/docker/docker/api/server/httputils"
2 2
 
3 3
 import (
4
+	"encoding/json"
4 5
 	"fmt"
5 6
 	"net/http"
6 7
 	"strconv"
7 8
 	"strings"
8 9
 
9 10
 	"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"
10 15
 )
11 16
 
12 17
 // 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,
109 109
 	}
110 110
 	return ArchiveOptions{name, path}, nil
111 111
 }
112
+
113
+// DecodePlatform decodes the OCI platform JSON string into a Platform struct.
114
+func DecodePlatform(platformJSON string) (*ocispec.Platform, error) {
115
+	var p ocispec.Platform
116
+
117
+	if err := json.Unmarshal([]byte(platformJSON), &p); err != nil {
118
+		return nil, errdefs.InvalidParameter(errors.Wrap(err, "failed to parse platform"))
119
+	}
120
+
121
+	hasAnyOptional := (p.Variant != "" || p.OSVersion != "" || len(p.OSFeatures) > 0)
122
+
123
+	if p.OS == "" && p.Architecture == "" && hasAnyOptional {
124
+		return nil, errdefs.InvalidParameter(errors.New("optional platform fields provided, but OS and Architecture are missing"))
125
+	}
126
+
127
+	if p.OS == "" || p.Architecture == "" {
128
+		return nil, errdefs.InvalidParameter(errors.New("both OS and Architecture must be provided"))
129
+	}
130
+
131
+	return &p, nil
132
+}
... ...
@@ -1,9 +1,16 @@
1 1
 package httputils // import "github.com/docker/docker/api/server/httputils"
2 2
 
3 3
 import (
4
+	"encoding/json"
4 5
 	"net/http"
5 6
 	"net/url"
6 7
 	"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"
7 14
 )
8 15
 
9 16
 func TestBoolValue(t *testing.T) {
... ...
@@ -103,3 +110,23 @@ func TestInt64ValueOrDefaultWithError(t *testing.T) {
103 103
 		t.Fatal("Expected an error.")
104 104
 	}
105 105
 }
106
+
107
+func TestParsePlatformInvalid(t *testing.T) {
108
+	for _, tc := range []ocispec.Platform{
109
+		{
110
+			OSVersion:  "1.2.3",
111
+			OSFeatures: []string{"a", "b"},
112
+		},
113
+		{OSVersion: "12.0"},
114
+		{OS: "linux"},
115
+		{Architecture: "amd64"},
116
+	} {
117
+		t.Run(platforms.Format(tc), func(t *testing.T) {
118
+			js, err := json.Marshal(tc)
119
+			assert.NilError(t, err)
120
+
121
+			_, err = DecodePlatform(string(js))
122
+			assert.Check(t, errdefs.IsInvalidParameter(err))
123
+		})
124
+	}
125
+}
... ...
@@ -39,7 +39,7 @@ type importExportBackend interface {
39 39
 
40 40
 type registryBackend interface {
41 41
 	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
43 43
 }
44 44
 
45 45
 type Searcher interface {
... ...
@@ -205,7 +205,21 @@ func (ir *imageRouter) postImagesPush(ctx context.Context, w http.ResponseWriter
205 205
 		ref = r
206 206
 	}
207 207
 
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 {
209 223
 		if !output.Flushed() {
210 224
 			return err
211 225
 		}
... ...
@@ -8752,6 +8752,11 @@ paths:
8752 8752
             details.
8753 8753
           type: "string"
8754 8754
           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
8755 8760
       tags: ["Image"]
8756 8761
   /images/{name}/tag:
8757 8762
     post:
... ...
@@ -4,6 +4,7 @@ import (
4 4
 	"context"
5 5
 
6 6
 	"github.com/docker/docker/api/types/filters"
7
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
7 8
 )
8 9
 
9 10
 // ImportOptions holds information to import images from the client host.
... ...
@@ -36,7 +37,23 @@ type PullOptions struct {
36 36
 }
37 37
 
38 38
 // PushOptions holds information to push images.
39
-type PushOptions PullOptions
39
+type PushOptions struct {
40
+	All          bool
41
+	RegistryAuth string // RegistryAuth is the base64 encoded credentials for the registry
42
+
43
+	// PrivilegeFunc is a function that clients can supply to retry operations
44
+	// after getting an authorization error. This function returns the registry
45
+	// authentication header value in base64 encoded format, or an error if the
46
+	// privilege request fails.
47
+	//
48
+	// Also see [github.com/docker/docker/api/types.RequestPrivilegeFunc].
49
+	PrivilegeFunc func(context.Context) (string, error)
50
+
51
+	// Platform is an optional field that selects a specific platform to push
52
+	// when the image is a multi-platform image.
53
+	// Using this will only push a single platform-specific manifest.
54
+	Platform *ocispec.Platform `json:",omitempty"`
55
+}
40 56
 
41 57
 // ListOptions holds parameters to list images with.
42 58
 type ListOptions struct {
... ...
@@ -2,7 +2,9 @@ package client // import "github.com/docker/docker/client"
2 2
 
3 3
 import (
4 4
 	"context"
5
+	"encoding/json"
5 6
 	"errors"
7
+	"fmt"
6 8
 	"io"
7 9
 	"net/http"
8 10
 	"net/url"
... ...
@@ -36,6 +38,20 @@ func (cli *Client) ImagePush(ctx context.Context, image string, options image.Pu
36 36
 		}
37 37
 	}
38 38
 
39
+	if options.Platform != nil {
40
+		if err := cli.NewVersionError(ctx, "1.46", "platform"); err != nil {
41
+			return nil, err
42
+		}
43
+
44
+		p := *options.Platform
45
+		pJson, err := json.Marshal(p)
46
+		if err != nil {
47
+			return nil, fmt.Errorf("invalid platform: %v", err)
48
+		}
49
+
50
+		query.Set("platform", string(pJson))
51
+	}
52
+
39 53
 	resp, err := cli.tryImagePush(ctx, name, query, options.RegistryAuth)
40 54
 	if errdefs.IsUnauthorized(err) && options.PrivilegeFunc != nil {
41 55
 		newAuthHeader, privilegeErr := options.PrivilegeFunc(ctx)
... ...
@@ -41,7 +41,7 @@ import (
41 41
 // pointing to the new target repository. This will allow subsequent pushes
42 42
 // to perform cross-repo mounts of the shared content when pushing to a different
43 43
 // 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) {
45 45
 	start := time.Now()
46 46
 	defer func() {
47 47
 		if retErr == nil {
... ...
@@ -76,7 +76,7 @@ func (i *ImageService) PushImage(ctx context.Context, sourceRef reference.Named,
76 76
 					continue
77 77
 				}
78 78
 
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 {
80 80
 					return err
81 81
 				}
82 82
 			}
... ...
@@ -85,10 +85,10 @@ func (i *ImageService) PushImage(ctx context.Context, sourceRef reference.Named,
85 85
 		}
86 86
 	}
87 87
 
88
-	return i.pushRef(ctx, sourceRef, metaHeaders, authConfig, out)
88
+	return i.pushRef(ctx, sourceRef, platform, metaHeaders, authConfig, out)
89 89
 }
90 90
 
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) {
92 92
 	leasedCtx, release, err := i.client.WithLease(ctx)
93 93
 	if err != nil {
94 94
 		return err
... ...
@@ -104,12 +104,18 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m
104 104
 		if cerrdefs.IsNotFound(err) {
105 105
 			return errdefs.NotFound(fmt.Errorf("tag does not exist: %s", reference.FamiliarString(targetRef)))
106 106
 		}
107
-		return errdefs.NotFound(err)
107
+		return errdefs.System(err)
108 108
 	}
109 109
 
110 110
 	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
+	}
112 117
 
118
+	store := i.content
113 119
 	resolver, tracker := i.newResolverFromAuthConfig(ctx, authConfig, targetRef)
114 120
 	pp := pushProgress{Tracker: tracker}
115 121
 	jobsQueue := newJobs()
... ...
@@ -121,7 +127,7 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m
121 121
 		finishProgress()
122 122
 		if retErr == nil {
123 123
 			if tagged, ok := targetRef.(reference.Tagged); ok {
124
-				progress.Messagef(out, "", "%s: digest: %s size: %d", tagged.Tag(), target.Digest, img.Target.Size)
124
+				progress.Messagef(out, "", "%s: digest: %s size: %d", tagged.Tag(), target.Digest, target.Size)
125 125
 			}
126 126
 		}
127 127
 	}()
... ...
@@ -164,16 +170,44 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m
164 164
 
165 165
 	err = remotes.PushContent(ctx, pusher, target, store, limiter, platforms.All, handlerWrapper)
166 166
 	if err != nil {
167
-		if containerdimages.IsIndexType(target.MediaType) && cerrdefs.IsNotFound(err) {
167
+		// If push failed because of a missing content, no specific platform was requested
168
+		// and the target is an index, select a platform-specific manifest to push instead.
169
+		if cerrdefs.IsNotFound(err) && containerdimages.IsIndexType(target.MediaType) && platform == nil {
170
+			var newTarget ocispec.Descriptor
171
+			newTarget, err = i.getPushDescriptor(ctx, img, nil)
172
+			if err != nil {
173
+				return err
174
+			}
175
+
176
+			// Retry only if the new push candidate is different from the previous one.
177
+			if newTarget.Digest != target.Digest {
178
+				target = newTarget
179
+				pp.TurnNotStartedIntoUnavailable()
180
+				err = remotes.PushContent(ctx, pusher, target, store, limiter, platforms.All, handlerWrapper)
181
+
182
+				if err == nil {
183
+					progress.Aux(out, map[string]string{
184
+						"Stripped": "true",
185
+						"Index":    img.Target.Digest.String(),
186
+						"Manifest": target.Digest.String(),
187
+					})
188
+				}
189
+			}
190
+		}
191
+
192
+		if err != nil {
193
+			if !cerrdefs.IsNotFound(err) {
194
+				return errdefs.System(err)
195
+			}
168 196
 			return errdefs.NotFound(fmt.Errorf(
169 197
 				"missing content: %w\n"+
170 198
 					"Note: You're trying to push a manifest list/index which "+
171 199
 					"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.",
200
+					"or available to the remote repository.\n\n"+
201
+					"Make sure you have all the referenced content and try again.\n"+
202
+					"You can also push only a single platform specific manifest directly by specifying the platform you want to push.",
174 203
 				err))
175 204
 		}
176
-		return err
177 205
 	}
178 206
 
179 207
 	appendDistributionSourceLabel(ctx, realStore, targetRef, target)
... ...
@@ -183,6 +217,97 @@ func (i *ImageService) pushRef(ctx context.Context, targetRef reference.Named, m
183 183
 	return nil
184 184
 }
185 185
 
186
+func (i *ImageService) getPushDescriptor(ctx context.Context, img containerdimages.Image, platform *ocispec.Platform) (ocispec.Descriptor, error) {
187
+	// Allow to override the host platform for testing purposes.
188
+	hostPlatform := i.defaultPlatformOverride
189
+	if hostPlatform == nil {
190
+		hostPlatform = platforms.Default()
191
+	}
192
+
193
+	pm := matchAllWithPreference(hostPlatform)
194
+	if platform != nil {
195
+		pm = platforms.OnlyStrict(*platform)
196
+	}
197
+
198
+	anyMissing := false
199
+
200
+	var bestMatchPlatform ocispec.Platform
201
+	var bestMatch *ImageManifest
202
+	var presentMatchingManifests []*ImageManifest
203
+	err := i.walkReachableImageManifests(ctx, img, func(im *ImageManifest) error {
204
+		available, err := im.CheckContentAvailable(ctx)
205
+		if err != nil {
206
+			return fmt.Errorf("failed to determine availability of image manifest %s: %w", im.Target().Digest, err)
207
+		}
208
+
209
+		if !available {
210
+			anyMissing = true
211
+			return nil
212
+		}
213
+
214
+		if im.IsAttestation() {
215
+			return nil
216
+		}
217
+
218
+		imgPlatform, err := im.ImagePlatform(ctx)
219
+		if err != nil {
220
+			return fmt.Errorf("failed to determine platform of image %s: %w", img.Name, err)
221
+		}
222
+
223
+		if !pm.Match(imgPlatform) {
224
+			return nil
225
+		}
226
+
227
+		presentMatchingManifests = append(presentMatchingManifests, im)
228
+		if bestMatch == nil || pm.Less(imgPlatform, bestMatchPlatform) {
229
+			bestMatchPlatform = imgPlatform
230
+			bestMatch = im
231
+		}
232
+
233
+		return nil
234
+	})
235
+	if err != nil {
236
+		return ocispec.Descriptor{}, err
237
+	}
238
+
239
+	switch len(presentMatchingManifests) {
240
+	case 0:
241
+		return ocispec.Descriptor{}, errdefs.NotFound(fmt.Errorf("no suitable image manifest found for platform %s", *platform))
242
+	case 1:
243
+		// Only one manifest is available AND matching the requested platform.
244
+
245
+		if platform != nil {
246
+			// Explicit platform was requested
247
+			return presentMatchingManifests[0].Target(), nil
248
+		}
249
+
250
+		// No specific platform was requested, but only one manifest is available.
251
+		if anyMissing {
252
+			return presentMatchingManifests[0].Target(), nil
253
+		}
254
+
255
+		// Index has only one manifest anyway, select the full index.
256
+		return img.Target, nil
257
+	default:
258
+		if platform == nil {
259
+			if !anyMissing {
260
+				// No specific platform requested, and all manifests are available, select the full index.
261
+				return img.Target, nil
262
+			}
263
+
264
+			// No specific platform requested and not all manifests are available.
265
+			// Select the manifest that matches the host platform the best.
266
+			if bestMatch != nil && hostPlatform.Match(bestMatchPlatform) {
267
+				return bestMatch.Target(), nil
268
+			}
269
+
270
+			return ocispec.Descriptor{}, errdefs.Conflict(errors.Errorf("multiple matching manifests found but no specific platform requested"))
271
+		}
272
+
273
+		return ocispec.Descriptor{}, errdefs.Conflict(errors.Errorf("multiple manifests found for platform %s", *platform))
274
+	}
275
+}
276
+
186 277
 func appendDistributionSourceLabel(ctx context.Context, realStore content.Store, targetRef reference.Named, target ocispec.Descriptor) {
187 278
 	appendSource, err := docker.AppendDistributionSourceLabel(realStore, targetRef.String())
188 279
 	if err != nil {
189 280
new file mode 100644
... ...
@@ -0,0 +1,288 @@
0
+// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
1
+//go:build go1.19
2
+
3
+package containerd
4
+
5
+import (
6
+	"context"
7
+	"fmt"
8
+	"path/filepath"
9
+	"testing"
10
+
11
+	containerdimages "github.com/containerd/containerd/images"
12
+	"github.com/containerd/containerd/namespaces"
13
+	"github.com/containerd/containerd/platforms"
14
+	"github.com/docker/docker/errdefs"
15
+	"github.com/docker/docker/internal/testutils/specialimage"
16
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
17
+	"golang.org/x/exp/slices"
18
+	"gotest.tools/v3/assert"
19
+	is "gotest.tools/v3/assert/cmp"
20
+)
21
+
22
+type pushTestCase struct {
23
+	name               string
24
+	indexPlatforms     []platforms.Platform // all platforms supported by the image
25
+	availablePlatforms []platforms.Platform // platforms available locally
26
+	requestPlatform    *platforms.Platform  // platform requested by the client (not the platform selected for push!)
27
+	check              func(t *testing.T, img containerdimages.Image, pushDescriptor ocispec.Descriptor, err error)
28
+	daemonPlatform     *platforms.Platform
29
+}
30
+
31
+func TestImagePushIndex(t *testing.T) {
32
+	ctx := namespaces.WithNamespace(context.TODO(), "testing-"+t.Name())
33
+
34
+	csDir := t.TempDir()
35
+	store := &blobsDirContentStore{blobs: filepath.Join(csDir, "blobs/sha256")}
36
+
37
+	linuxAmd64 := platforms.MustParse("linux/amd64")
38
+	darwinArm64 := platforms.MustParse("darwin/arm64")
39
+	windowsAmd64 := platforms.MustParse("windows/amd64")
40
+
41
+	linuxArm64 := platforms.MustParse("linux/arm64")
42
+	linuxArmv5 := platforms.MustParse("linux/arm/v5")
43
+	linuxArmv7 := platforms.MustParse("linux/arm/v7")
44
+
45
+	// Image service will have the daemon host platform mocked to linux/amd64.
46
+	// Unless test cases specify a different platform.
47
+	defaultDaemonPlatform := linuxAmd64
48
+
49
+	for _, tc := range []pushTestCase{
50
+		// No explicit platform requested
51
+		{
52
+			name: "none requested, all present",
53
+
54
+			indexPlatforms:     []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
55
+			availablePlatforms: []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
56
+			check:              wholeIndexSelected,
57
+		},
58
+		{
59
+			name: "none requested, one present",
60
+
61
+			indexPlatforms:     []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
62
+			availablePlatforms: []platforms.Platform{linuxAmd64},
63
+			check:              singleManifestSelected(linuxAmd64),
64
+		},
65
+		{
66
+			name: "none requested, two present, daemon platform available",
67
+
68
+			indexPlatforms:     []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
69
+			availablePlatforms: []platforms.Platform{linuxAmd64, darwinArm64},
70
+			check:              singleManifestSelected(linuxAmd64),
71
+		},
72
+		{
73
+			name: "none requested, two present, daemon platform NOT available",
74
+
75
+			indexPlatforms:     []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
76
+			availablePlatforms: []platforms.Platform{darwinArm64, windowsAmd64},
77
+			check:              multipleCandidates,
78
+		},
79
+
80
+		// Specific platform requested
81
+		{
82
+			name: "linux/amd64 requested, all present",
83
+
84
+			indexPlatforms:     []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
85
+			availablePlatforms: []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
86
+			requestPlatform:    &linuxAmd64,
87
+			check:              singleManifestSelected(linuxAmd64),
88
+		},
89
+		{
90
+			name: "linux/amd64 requested, but not present",
91
+
92
+			indexPlatforms:     []platforms.Platform{linuxAmd64, darwinArm64, windowsAmd64},
93
+			availablePlatforms: []platforms.Platform{darwinArm64, windowsAmd64},
94
+			requestPlatform:    &linuxAmd64,
95
+			check:              candidateNotFound,
96
+		},
97
+
98
+		// Variant tests
99
+		{
100
+			name: "linux/arm/v5 requested, but not in index",
101
+
102
+			indexPlatforms:     []platforms.Platform{linuxAmd64, linuxArmv7},
103
+			availablePlatforms: []platforms.Platform{linuxAmd64, linuxArmv7},
104
+			requestPlatform:    &linuxArmv5,
105
+			check:              candidateNotFound,
106
+		},
107
+		{
108
+			name: "linux/arm/v5 requested, but not available",
109
+
110
+			indexPlatforms:     []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
111
+			availablePlatforms: []platforms.Platform{linuxArm64, linuxArmv7},
112
+			requestPlatform:    &linuxArmv5,
113
+			check:              candidateNotFound,
114
+		},
115
+		{
116
+			name: "linux/arm/v7 requested, but not available",
117
+
118
+			indexPlatforms:     []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
119
+			availablePlatforms: []platforms.Platform{linuxArm64, linuxArmv5},
120
+			requestPlatform:    &linuxArmv7,
121
+			check:              candidateNotFound,
122
+		},
123
+		{
124
+			name: "linux/arm/v7 requested on v7 daemon, but not available",
125
+
126
+			indexPlatforms:     []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
127
+			availablePlatforms: []platforms.Platform{linuxArm64, linuxArmv5},
128
+			daemonPlatform:     &linuxArmv7,
129
+			requestPlatform:    &linuxArmv7,
130
+			check:              candidateNotFound,
131
+		},
132
+		{
133
+			name: "linux/arm/v7 requested on v5 daemon, all available",
134
+
135
+			indexPlatforms:     []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
136
+			availablePlatforms: []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
137
+			daemonPlatform:     &linuxArmv5,
138
+			requestPlatform:    &linuxArmv7,
139
+			check:              singleManifestSelected(linuxArmv7),
140
+		},
141
+		{
142
+			name: "linux/arm/v5 requested on v7 daemon, all available",
143
+
144
+			indexPlatforms:     []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
145
+			availablePlatforms: []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
146
+			daemonPlatform:     &linuxArmv7,
147
+			requestPlatform:    &linuxArmv5,
148
+			check:              singleManifestSelected(linuxArmv5),
149
+		},
150
+		{
151
+			name: "none requested on v5 daemon, arm64 not available",
152
+
153
+			indexPlatforms:     []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
154
+			availablePlatforms: []platforms.Platform{linuxArmv7, linuxArmv5},
155
+			daemonPlatform:     &linuxArmv5,
156
+			requestPlatform:    nil,
157
+			check:              singleManifestSelected(linuxArmv5),
158
+		},
159
+		{
160
+			name: "none requested on v7 daemon, arm64 not available",
161
+
162
+			indexPlatforms:     []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
163
+			availablePlatforms: []platforms.Platform{linuxArmv7, linuxArmv5},
164
+			daemonPlatform:     &linuxArmv7,
165
+			requestPlatform:    nil,
166
+			check:              singleManifestSelected(linuxArmv7),
167
+		},
168
+		{
169
+			name: "none requested on v7 daemon, v7 not available",
170
+
171
+			indexPlatforms:     []platforms.Platform{linuxArm64, linuxArmv7, linuxArmv5},
172
+			availablePlatforms: []platforms.Platform{linuxArm64, linuxArmv5},
173
+			daemonPlatform:     &linuxArmv7,
174
+			requestPlatform:    nil,
175
+			check:              singleManifestSelected(linuxArmv5), // Should it fail, because v5 can't be pushed?
176
+		},
177
+
178
+		{
179
+			name: "none requested on v7 daemon, v5 in index but not v7, all present",
180
+
181
+			indexPlatforms:     []platforms.Platform{linuxArm64, linuxArmv5},
182
+			availablePlatforms: []platforms.Platform{linuxArm64, linuxArmv5},
183
+			daemonPlatform:     &linuxArmv7,
184
+			requestPlatform:    nil,
185
+			check:              wholeIndexSelected,
186
+		},
187
+		{
188
+			name: "none requested on v7 daemon, v5 in index but not v7, v5 present",
189
+
190
+			indexPlatforms:     []platforms.Platform{linuxArm64, linuxArmv5},
191
+			availablePlatforms: []platforms.Platform{linuxArmv5},
192
+			daemonPlatform:     &linuxArmv7,
193
+			requestPlatform:    nil,
194
+			check:              singleManifestSelected(linuxArmv5),
195
+		},
196
+	} {
197
+		t.Run(tc.name, func(t *testing.T) {
198
+			imgSvc := fakeImageService(t, ctx, store)
199
+			// Mock the daemon platform.
200
+			if tc.daemonPlatform != nil {
201
+				imgSvc.defaultPlatformOverride = platforms.Only(*tc.daemonPlatform)
202
+			} else {
203
+				imgSvc.defaultPlatformOverride = platforms.Only(defaultDaemonPlatform)
204
+			}
205
+
206
+			idx, err := specialimage.MultiPlatform(csDir, "multiplatform:latest", tc.indexPlatforms)
207
+			assert.NilError(t, err)
208
+
209
+			imgs := imagesFromIndex(idx)
210
+			assert.Assert(t, is.Len(imgs, 1))
211
+
212
+			img := imgs[0]
213
+			_, err = imgSvc.images.Create(ctx, img)
214
+			assert.NilError(t, err)
215
+
216
+			for _, platform := range tc.indexPlatforms {
217
+				if slices.ContainsFunc(tc.availablePlatforms, platforms.OnlyStrict(platform).Match) {
218
+					continue
219
+				}
220
+				assert.NilError(t, deletePlatform(ctx, imgSvc, img, platform))
221
+			}
222
+
223
+			desc, err := imgSvc.getPushDescriptor(ctx, img, tc.requestPlatform)
224
+
225
+			tc.check(t, img, desc, err)
226
+		})
227
+	}
228
+}
229
+
230
+func deletePlatform(ctx context.Context, imgSvc *ImageService, img containerdimages.Image, platform platforms.Platform) error {
231
+	var blobs []ocispec.Descriptor
232
+	pm := platforms.OnlyStrict(platform)
233
+	err := imgSvc.walkImageManifests(ctx, img, func(im *ImageManifest) error {
234
+		imPlatform, err := im.ImagePlatform(ctx)
235
+		if err != nil {
236
+			return fmt.Errorf("failed to determine platform of image manifest %v: %w", im.Target(), err)
237
+		}
238
+
239
+		if !pm.Match(imPlatform) {
240
+			return nil
241
+		}
242
+
243
+		return imgSvc.walkPresentChildren(ctx, im.Target(), func(ctx context.Context, d ocispec.Descriptor) error {
244
+			blobs = append(blobs, d)
245
+			return nil
246
+		})
247
+	})
248
+	if err != nil {
249
+		return fmt.Errorf("failed to walk image manifests: %w", err)
250
+	}
251
+
252
+	for _, d := range blobs {
253
+		err := imgSvc.content.Delete(ctx, d.Digest)
254
+		if err != nil {
255
+			return fmt.Errorf("failed to delete blob %v: %w", d.Digest, err)
256
+		}
257
+	}
258
+
259
+	return nil
260
+}
261
+
262
+// wholeIndexSelected asserts that the push descriptor candidate is for the whole index.
263
+func wholeIndexSelected(t *testing.T, img containerdimages.Image, pushDescriptor ocispec.Descriptor, err error) {
264
+	assert.NilError(t, err)
265
+	assert.Check(t, is.Equal(pushDescriptor.Digest, img.Target.Digest))
266
+}
267
+
268
+// singleManifestSelected asserts that the push descriptor candidate is for a single platform-specific manifest.
269
+func singleManifestSelected(platform ocispec.Platform) func(t *testing.T, img containerdimages.Image, pushDescriptor ocispec.Descriptor, err error) {
270
+	pm := platforms.OnlyStrict(platform)
271
+	return func(t *testing.T, img containerdimages.Image, pushDescriptor ocispec.Descriptor, err error) {
272
+		assert.NilError(t, err)
273
+		assert.Assert(t, is.Equal(pushDescriptor.MediaType, ocispec.MediaTypeImageManifest), "the push descriptor isn't for a manifest")
274
+		assert.Assert(t, pushDescriptor.Platform != nil, "the push descriptor doesn't have a platform")
275
+		assert.Assert(t, pm.Match(*pushDescriptor.Platform), "the push descriptor isn't for the selected platform")
276
+	}
277
+}
278
+
279
+// candidateNotFound asserts that the no matching candidate was found.
280
+func candidateNotFound(t *testing.T, _ containerdimages.Image, desc ocispec.Descriptor, err error) {
281
+	assert.Check(t, errdefs.IsNotFound(err), "expected NotFound error, got %v, candidate: %v", err, desc.Platform)
282
+}
283
+
284
+// multipleCandidates asserts that multiple matching candidates were found and no decision could be made.
285
+func multipleCandidates(t *testing.T, _ containerdimages.Image, desc ocispec.Descriptor, err error) {
286
+	assert.Check(t, errdefs.IsConflict(err), "expected Conflict error, got %v, candidate: %v", err, desc.Platform)
287
+}
... ...
@@ -4,6 +4,7 @@ import (
4 4
 	"context"
5 5
 	"errors"
6 6
 	"sync"
7
+	"sync/atomic"
7 8
 	"time"
8 9
 
9 10
 	"github.com/containerd/containerd/content"
... ...
@@ -174,7 +175,13 @@ func (p pullProgress) UpdateProgress(ctx context.Context, ongoing *jobs, out pro
174 174
 }
175 175
 
176 176
 type pushProgress struct {
177
-	Tracker docker.StatusTracker
177
+	Tracker                         docker.StatusTracker
178
+	notStartedWaitingAreUnavailable atomic.Bool
179
+}
180
+
181
+// TurnNotStartedIntoUnavailable will mark all not started layers as "Unavailable" instead of "Waiting".
182
+func (p *pushProgress) TurnNotStartedIntoUnavailable() {
183
+	p.notStartedWaitingAreUnavailable.Store(true)
178 184
 }
179 185
 
180 186
 func (p *pushProgress) UpdateProgress(ctx context.Context, ongoing *jobs, out progress.Output, start time.Time) error {
... ...
@@ -183,7 +190,14 @@ func (p *pushProgress) UpdateProgress(ctx context.Context, ongoing *jobs, out pr
183 183
 		id := stringid.TruncateID(j.Digest.Encoded())
184 184
 
185 185
 		status, err := p.Tracker.GetStatus(key)
186
-		if err != nil {
186
+
187
+		notStarted := (status.Total > 0 && status.Offset == 0)
188
+		if err != nil || notStarted {
189
+			if p.notStartedWaitingAreUnavailable.Load() {
190
+				progress.Update(out, id, "Unavailable")
191
+				ongoing.Remove(j)
192
+				continue
193
+			}
187 194
 			if cerrdefs.IsNotFound(err) {
188 195
 				progress.Update(out, id, "Waiting")
189 196
 				continue
... ...
@@ -8,6 +8,7 @@ import (
8 8
 	"github.com/containerd/containerd"
9 9
 	"github.com/containerd/containerd/content"
10 10
 	"github.com/containerd/containerd/images"
11
+	"github.com/containerd/containerd/platforms"
11 12
 	"github.com/containerd/containerd/plugin"
12 13
 	"github.com/containerd/containerd/remotes/docker"
13 14
 	"github.com/containerd/containerd/snapshots"
... ...
@@ -39,6 +40,9 @@ type ImageService struct {
39 39
 	pruneRunning        atomic.Bool
40 40
 	refCountMounter     snapshotter.Mounter
41 41
 	idMapping           idtools.IdentityMapping
42
+
43
+	// defaultPlatformOverride is used in tests to override the host platform.
44
+	defaultPlatformOverride platforms.MatchComparer
42 45
 }
43 46
 
44 47
 type registryResolver interface {
... ...
@@ -28,7 +28,7 @@ type ImageService interface {
28 28
 	// Images
29 29
 
30 30
 	PullImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error
31
-	PushImage(ctx context.Context, ref reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error
31
+	PushImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error
32 32
 	CreateImage(ctx context.Context, config []byte, parent string, contentStoreDigest digest.Digest) (builder.Image, error)
33 33
 	ImageDelete(ctx context.Context, imageRef string, force, prune bool) ([]imagetype.DeleteResponse, error)
34 34
 	ExportImage(ctx context.Context, names []string, outStream io.Writer) error
... ...
@@ -2,19 +2,30 @@ package images // import "github.com/docker/docker/daemon/images"
2 2
 
3 3
 import (
4 4
 	"context"
5
+	"errors"
5 6
 	"io"
6 7
 	"time"
7 8
 
8 9
 	"github.com/distribution/reference"
9 10
 	"github.com/docker/distribution/manifest/schema2"
11
+	"github.com/docker/docker/api/types/backend"
10 12
 	"github.com/docker/docker/api/types/registry"
11 13
 	"github.com/docker/docker/distribution"
12 14
 	progressutils "github.com/docker/docker/distribution/utils"
15
+	"github.com/docker/docker/errdefs"
13 16
 	"github.com/docker/docker/pkg/progress"
17
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
14 18
 )
15 19
 
16 20
 // PushImage initiates a push operation on the repository named localName.
17
-func (i *ImageService) PushImage(ctx context.Context, ref reference.Named, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error {
21
+func (i *ImageService) PushImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error {
22
+	if platform != nil {
23
+		// Check if the image is actually the platform we want to push.
24
+		_, err := i.GetImage(ctx, ref.String(), backend.GetImageOpts{Platform: platform})
25
+		if err != nil {
26
+			return errdefs.InvalidParameter(errors.New("graphdriver backed image store doesn't support multiplatform images"))
27
+		}
28
+	}
18 29
 	start := time.Now()
19 30
 	// Include a buffer so that slow client connections don't affect
20 31
 	// transfer performance.
... ...
@@ -26,8 +26,10 @@ keywords: "API, Docker, rcli, REST, documentation"
26 26
   `net.ipv4.config.IFNAME.log_martians=1`. In API versions up-to 1.46, top level
27 27
   `--sysctl` settings for `eth0` will be migrated to `DriverOpts` when possible. 
28 28
   This automatic migration will be removed for API versions 1.47 and greater.
29
-
30 29
 * `GET /containers/json` now returns the annotations of containers.
30
+* `POST /images/{name}/push` now supports a `platform` parameter (JSON encoded
31
+  OCI Platform type) that allows selecting a specific platform manifest from
32
+  the multi-platform image.
31 33
 
32 34
 ## v1.45 API changes
33 35
 
... ...
@@ -99,6 +99,7 @@ require (
99 99
 	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0
100 100
 	go.opentelemetry.io/otel/sdk v1.21.0
101 101
 	go.opentelemetry.io/otel/trace v1.21.0
102
+	golang.org/x/exp v0.0.0-20231006140011-7918f672742d
102 103
 	golang.org/x/mod v0.17.0
103 104
 	golang.org/x/net v0.23.0
104 105
 	golang.org/x/sync v0.5.0
... ...
@@ -216,7 +217,6 @@ require (
216 216
 	go.uber.org/multierr v1.8.0 // indirect
217 217
 	go.uber.org/zap v1.21.0 // indirect
218 218
 	golang.org/x/crypto v0.21.0 // indirect
219
-	golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
220 219
 	golang.org/x/oauth2 v0.11.0 // indirect
221 220
 	golang.org/x/tools v0.16.0 // indirect
222 221
 	google.golang.org/api v0.128.0 // indirect