Browse code

Add support for multiple platforms in image export and loading.

Currently the image export and load APIs can be used to export or load all
platforms for the image, or a single specified platform.

This commit updates the API so that it accepts a list of platforms to export or
load, thereby giving clients the ability to export only selected platforms of an
image into a tar file, or load selected platforms from a tar file.

Unit and integration tests were updated accordingly.

As this requires a daemon API change, the API version was bumped.

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>

Cesar Talledo authored on 2025/05/29 05:53:08
Showing 12 changed files
... ...
@@ -10443,7 +10443,10 @@ paths:
10443 10443
           type: "string"
10444 10444
           required: true
10445 10445
         - name: "platform"
10446
-          type: "string"
10446
+          type: "array"
10447
+          items:
10448
+            type: "string"
10449
+          collectionFormat: "multi"
10447 10450
           in: "query"
10448 10451
           description: |
10449 10452
             JSON encoded OCI platform describing a platform which will be used
... ...
@@ -10488,13 +10491,16 @@ paths:
10488 10488
           items:
10489 10489
             type: "string"
10490 10490
         - name: "platform"
10491
-          type: "string"
10491
+          type: "array"
10492
+          items:
10493
+            type: "string"
10494
+          collectionFormat: "multi"
10492 10495
           in: "query"
10493 10496
           description: |
10494
-            JSON encoded OCI platform describing a platform which will be used
10495
-            to select a platform-specific image to be saved if the image is
10496
-            multi-platform.
10497
-            If not provided, the full multi-platform image will be saved.
10497
+            JSON encoded OCI platform(s) which will be used to select the
10498
+            platform-specific image(s) to be saved if the image is
10499
+            multi-platform. If not provided, the full multi-platform image
10500
+            will be saved.
10498 10501
 
10499 10502
             Example: `{"os": "linux", "architecture": "arm", "variant": "v5"}`
10500 10503
       tags: ["Image"]
... ...
@@ -10530,13 +10536,16 @@ paths:
10530 10530
           type: "boolean"
10531 10531
           default: false
10532 10532
         - name: "platform"
10533
-          type: "string"
10533
+          type: "array"
10534
+          items:
10535
+            type: "string"
10536
+          collectionFormat: "multi"
10534 10537
           in: "query"
10535 10538
           description: |
10536
-            JSON encoded OCI platform describing a platform which will be used
10537
-            to select a platform-specific image to be load if the image is
10538
-            multi-platform.
10539
-            If not provided, the full multi-platform image will be loaded.
10539
+            JSON encoded OCI platform(s) which will be used to select the
10540
+            platform-specific image(s) to load if the image is
10541
+            multi-platform. If not provided, the full multi-platform image
10542
+            will be loaded.
10540 10543
 
10541 10544
             Example: `{"os": "linux", "architecture": "arm", "variant": "v5"}`
10542 10545
       tags: ["Image"]
... ...
@@ -31,8 +31,15 @@ import (
31 31
 // outStream is the writer which the images are written to.
32 32
 //
33 33
 // TODO(thaJeztah): produce JSON stream progress response and image events; see https://github.com/moby/moby/issues/43910
34
-func (i *ImageService) ExportImage(ctx context.Context, names []string, platform *ocispec.Platform, outStream io.Writer) error {
35
-	pm := i.matchRequestedOrDefault(platforms.OnlyStrict, platform)
34
+func (i *ImageService) ExportImage(ctx context.Context, names []string, platformList []ocispec.Platform, outStream io.Writer) error {
35
+	var pm platforms.MatchComparer
36
+
37
+	// Get the platform matcher for the requested platforms
38
+	if len(platformList) == 0 {
39
+		pm = matchAllWithPreference(i.hostPlatformMatcher())
40
+	} else {
41
+		pm = matchAnyWithPreference(i.hostPlatformMatcher(), platformList)
42
+	}
36 43
 
37 44
 	opts := []archive.ExportOpt{
38 45
 		archive.WithSkipNonDistributableBlobs(),
... ...
@@ -65,8 +72,12 @@ func (i *ImageService) ExportImage(ctx context.Context, names []string, platform
65 65
 	exportImage := func(ctx context.Context, img c8dimages.Image, ref reference.Named) error {
66 66
 		target := img.Target
67 67
 
68
-		if platform != nil {
69
-			newTarget, err := i.getPushDescriptor(ctx, img, platform)
68
+		// If a single platform is requested, export the manifest for the specific platform only
69
+		// (single-level index). Otherwise export the full index (two-level, nested). Note that
70
+		// since opts includes WithPlatform and WithSkipMissing, the index will contain the
71
+		// requested platforms only, and only if they are available in the content store.
72
+		if len(platformList) == 1 {
73
+			newTarget, err := i.getPushDescriptor(ctx, img, &platformList[0])
70 74
 			if err != nil {
71 75
 				return errors.Wrap(err, "no suitable export target found")
72 76
 			}
... ...
@@ -229,14 +240,23 @@ func (i *ImageService) leaseContent(ctx context.Context, store content.Store, de
229 229
 // LoadImage uploads a set of images into the repository. This is the
230 230
 // complement of ExportImage.  The input stream is an uncompressed tar
231 231
 // ball containing images and metadata.
232
-func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platform *ocispec.Platform, outStream io.Writer, quiet bool) error {
232
+func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platformList []ocispec.Platform, outStream io.Writer, quiet bool) error {
233 233
 	decompressed, err := compression.DecompressStream(inTar)
234 234
 	if err != nil {
235 235
 		return errors.Wrap(err, "failed to decompress input tar archive")
236 236
 	}
237 237
 	defer decompressed.Close()
238 238
 
239
-	pm := i.matchRequestedOrDefault(platforms.OnlyStrict, platform)
239
+	specificPlatforms := len(platformList) > 0
240
+
241
+	// Get the platform matcher for the requested platforms
242
+	var pm platforms.MatchComparer
243
+	if specificPlatforms {
244
+		pm = platforms.Any(platformList...)
245
+	} else {
246
+		// All platforms
247
+		pm = matchAllWithPreference(i.hostPlatformMatcher())
248
+	}
240 249
 
241 250
 	opts := []containerd.ImportOpt{
242 251
 		containerd.WithImportPlatform(pm),
... ...
@@ -266,16 +286,19 @@ func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platf
266 266
 		}),
267 267
 	}
268 268
 
269
-	if platform == nil {
269
+	if !specificPlatforms {
270 270
 		// Allow variants to be missing if no specific platform is requested.
271 271
 		opts = append(opts, containerd.WithSkipMissing())
272 272
 	}
273 273
 
274 274
 	imgs, err := i.client.Import(ctx, decompressed, opts...)
275 275
 	if err != nil {
276
-		if platform != nil {
277
-			p := platforms.FormatAll(*platform)
278
-			log.G(ctx).WithFields(log.Fields{"error": err, "platform": p}).Debug("failed to import image to containerd")
276
+		if specificPlatforms {
277
+			platformNames := make([]string, 0, len(platformList))
278
+			for _, p := range platformList {
279
+				platformNames = append(platformNames, platforms.FormatAll(p))
280
+			}
281
+			log.G(ctx).WithFields(log.Fields{"error": err, "platform(s)": platformNames}).Debug("failed to import image to containerd")
279 282
 
280 283
 			// Note: ErrEmptyWalk will not be returned in most cases as
281 284
 			// index.json will contain a descriptor of the actual OCI index or
... ...
@@ -284,22 +307,26 @@ func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platf
284 284
 			// doesn't have a platform set, so it won't be filtered out by the
285 285
 			// FilterPlatform containerd handler.
286 286
 			if errors.Is(err, c8dimages.ErrEmptyWalk) {
287
-				return errdefs.NotFound(errors.Wrapf(err, "requested platform (%s) not found", p))
287
+				return errdefs.NotFound(errors.Wrapf(err, "requested platform(s) (%v) not found", platformNames))
288 288
 			}
289 289
 			if cerrdefs.IsNotFound(err) {
290
-				return errdefs.NotFound(errors.Wrapf(err, "requested platform (%s) found, but some content is missing", p))
290
+				return errdefs.NotFound(errors.Wrapf(err, "requested platform(s) (%v) found, but some content is missing", platformNames))
291 291
 			}
292 292
 		}
293 293
 		log.G(ctx).WithError(err).Debug("failed to import image to containerd")
294 294
 		return errdefs.System(err)
295 295
 	}
296 296
 
297
-	if platform != nil {
298
-		// Verify that the requested platform is available for the loaded images.
297
+	if specificPlatforms {
298
+		// Verify that the requested platform(s) are available for the loaded images.
299 299
 		// While the ideal behavior here would be to verify whether the input
300 300
 		// archive actually supplied them, we're not able to determine that
301 301
 		// as the imported index is not returned by the import operation.
302
-		if err := i.verifyImagesProvidePlatform(ctx, imgs, *platform, pm); err != nil {
302
+		platformNames := make([]string, 0, len(platformList))
303
+		for _, p := range platformList {
304
+			platformNames = append(platformNames, platforms.FormatAll(p))
305
+		}
306
+		if err := i.verifyImagesProvidePlatform(ctx, imgs, platformNames, pm); err != nil {
303 307
 			return err
304 308
 		}
305 309
 	}
... ...
@@ -308,7 +335,7 @@ func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platf
308 308
 	// Unpack only an image of the host platform
309 309
 	unpackPm := i.hostPlatformMatcher()
310 310
 	// If a load of specific platform is requested, unpack it
311
-	if platform != nil {
311
+	if specificPlatforms {
312 312
 		unpackPm = pm
313 313
 	}
314 314
 
... ...
@@ -378,9 +405,9 @@ func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platf
378 378
 
379 379
 // verifyImagesProvidePlatform checks if the requested platform is loaded.
380 380
 // If the requested platform is not loaded, it returns an error.
381
-func (i *ImageService) verifyImagesProvidePlatform(ctx context.Context, imgs []c8dimages.Image, platform ocispec.Platform, pm platforms.Matcher) error {
381
+func (i *ImageService) verifyImagesProvidePlatform(ctx context.Context, imgs []c8dimages.Image, platformNames []string, pm platforms.Matcher) error {
382 382
 	if len(imgs) == 0 {
383
-		return errdefs.NotFound(fmt.Errorf("no images providing the requested platform %s found", platforms.FormatAll(platform)))
383
+		return errdefs.NotFound(fmt.Errorf("no images providing the requested platform(s) found: %v", platformNames))
384 384
 	}
385 385
 	var incompleteImgs []string
386 386
 	for _, img := range imgs {
... ...
@@ -399,7 +426,7 @@ func (i *ImageService) verifyImagesProvidePlatform(ctx context.Context, imgs []c
399 399
 			}
400 400
 			available, err := platformImg.CheckContentAvailable(ctx)
401 401
 			if err != nil {
402
-				return errors.Wrapf(err, "failed to determine image content availability for platform %s", platforms.FormatAll(platform))
402
+				return errors.Wrapf(err, "failed to determine image content availability for platform(s) %s", platformNames)
403 403
 			}
404 404
 
405 405
 			if available {
... ...
@@ -427,5 +454,5 @@ func (i *ImageService) verifyImagesProvidePlatform(ctx context.Context, imgs []c
427 427
 		msg = "images [%s] were loaded, but don't provide the requested platform (%s)"
428 428
 	}
429 429
 
430
-	return errdefs.NotFound(fmt.Errorf(msg, strings.Join(incompleteImgs, ", "), platforms.FormatAll(platform)))
430
+	return errdefs.NotFound(fmt.Errorf(msg, strings.Join(incompleteImgs, ", "), platformNames))
431 431
 }
... ...
@@ -3,6 +3,7 @@ package containerd
3 3
 import (
4 4
 	"bytes"
5 5
 	"context"
6
+	"fmt"
6 7
 	"math/rand"
7 8
 	"os"
8 9
 	"path/filepath"
... ...
@@ -16,15 +17,18 @@ import (
16 16
 	"github.com/docker/docker/internal/testutils/labelstore"
17 17
 	"github.com/docker/docker/internal/testutils/specialimage"
18 18
 	"github.com/moby/go-archive"
19
+	"github.com/moby/moby/api/types/backend"
20
+	"github.com/moby/moby/api/types/image"
19 21
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
20 22
 	"gotest.tools/v3/assert"
21 23
 	is "gotest.tools/v3/assert/cmp"
22 24
 )
23 25
 
24
-func TestImageLoadMissing(t *testing.T) {
26
+func TestImageLoad(t *testing.T) {
25 27
 	linuxAmd64 := ocispec.Platform{OS: "linux", Architecture: "amd64"}
26 28
 	linuxArm64 := ocispec.Platform{OS: "linux", Architecture: "arm64"}
27 29
 	linuxArmv5 := ocispec.Platform{OS: "linux", Architecture: "arm", Variant: "v5"}
30
+	linuxRiscv64 := ocispec.Platform{OS: "linux", Architecture: "riskv64"}
28 31
 
29 32
 	ctx := namespaces.WithNamespace(context.TODO(), "testing-"+t.Name())
30 33
 
... ...
@@ -35,7 +39,7 @@ func TestImageLoadMissing(t *testing.T) {
35 35
 	// Mock the daemon platform.
36 36
 	imgSvc.defaultPlatformOverride = platforms.Only(linuxAmd64)
37 37
 
38
-	tryLoad := func(ctx context.Context, t *testing.T, dir string, platform ocispec.Platform) error {
38
+	tryLoad := func(ctx context.Context, t *testing.T, dir string, platformList []ocispec.Platform) error {
39 39
 		tarRc, err := archive.Tar(dir, archive.Uncompressed)
40 40
 		assert.NilError(t, err)
41 41
 		defer tarRc.Close()
... ...
@@ -46,10 +50,19 @@ func TestImageLoadMissing(t *testing.T) {
46 46
 			t.Log(buf.String())
47 47
 		}()
48 48
 
49
-		return imgSvc.LoadImage(ctx, tarRc, &platform, &buf, true)
49
+		return imgSvc.LoadImage(ctx, tarRc, platformList, &buf, true)
50 50
 	}
51 51
 
52
-	clearStore := func(ctx context.Context, t *testing.T) {
52
+	cleanup := func(ctx context.Context, t *testing.T) {
53
+		// Remove all existing images to start fresh
54
+		images, err := imgSvc.Images(ctx, image.ListOptions{})
55
+		assert.NilError(t, err)
56
+		for _, img := range images {
57
+			_, err := imgSvc.ImageDelete(ctx, img.ID, image.RemoveOptions{PruneChildren: true})
58
+			assert.NilError(t, err)
59
+		}
60
+
61
+		// Remove all content from the store
53 62
 		assert.NilError(t, store.Walk(ctx, func(info content.Info) error {
54 63
 			return store.Delete(ctx, info.Digest)
55 64
 		}), "failed to delete all content")
... ...
@@ -60,39 +73,64 @@ func TestImageLoadMissing(t *testing.T) {
60 60
 		_, err := specialimage.EmptyIndex(imgDataDir)
61 61
 		assert.NilError(t, err)
62 62
 
63
-		err = tryLoad(ctx, t, imgDataDir, linuxAmd64)
64
-		assert.Check(t, is.Error(err, "image emptyindex:latest was loaded, but doesn't provide the requested platform (linux/amd64)"))
63
+		err = tryLoad(ctx, t, imgDataDir, []ocispec.Platform{linuxAmd64})
64
+		assert.Check(t, is.Error(err, "image emptyindex:latest was loaded, but doesn't provide the requested platform ([linux/amd64])"))
65 65
 		assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
66 66
 	})
67
-	clearStore(ctx, t)
67
+	cleanup(ctx, t)
68 68
 
69 69
 	t.Run("single platform", func(t *testing.T) {
70 70
 		imgDataDir := t.TempDir()
71 71
 		r := rand.NewSource(0x9127371238)
72
-		_, err := specialimage.RandomSinglePlatform(imgDataDir, linuxAmd64, r)
72
+		_, err = specialimage.RandomSinglePlatform(imgDataDir, linuxAmd64, r)
73 73
 		assert.NilError(t, err)
74 74
 
75
-		err = tryLoad(ctx, t, imgDataDir, linuxArm64)
76
-		assert.Check(t, is.ErrorContains(err, "doesn't provide the requested platform (linux/arm64)"))
75
+		platforms := []ocispec.Platform{linuxAmd64}
76
+		err = tryLoad(ctx, t, imgDataDir, platforms)
77
+		assert.NilError(t, err)
78
+
79
+		err = tryLoad(ctx, t, imgDataDir, []ocispec.Platform{linuxArm64})
80
+		assert.Check(t, is.ErrorContains(err, "doesn't provide the requested platform ([linux/arm64])"))
77 81
 		assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
78 82
 	})
83
+	cleanup(ctx, t)
79 84
 
80
-	clearStore(ctx, t)
81
-
82
-	t.Run("2 platform image", func(t *testing.T) {
85
+	t.Run("multi-platform image", func(t *testing.T) {
83 86
 		imgDataDir := t.TempDir()
84
-		_, mfstDescs, err := specialimage.MultiPlatform(imgDataDir, "multiplatform:latest", []ocispec.Platform{linuxAmd64, linuxArm64})
87
+		imgRef := "multiplatform:latest"
88
+		_, mfstDescs, err := specialimage.MultiPlatform(imgDataDir, imgRef, []ocispec.Platform{linuxAmd64, linuxArm64, linuxRiscv64})
85 89
 		assert.NilError(t, err)
86 90
 
91
+		t.Run("one platform in index", func(t *testing.T) {
92
+			platforms := []ocispec.Platform{linuxAmd64}
93
+			err = tryLoad(ctx, t, imgDataDir, platforms)
94
+			assert.NilError(t, err)
95
+
96
+			// verify that the loaded image has the correct platform
97
+			err = verifyImagePlatforms(ctx, imgSvc, imgRef, platforms)
98
+			assert.NilError(t, err)
99
+		})
100
+		cleanup(ctx, t)
101
+
102
+		t.Run("all platforms in index", func(t *testing.T) {
103
+			platforms := []ocispec.Platform{linuxAmd64, linuxArm64, linuxRiscv64}
104
+			err = tryLoad(ctx, t, imgDataDir, platforms)
105
+			assert.NilError(t, err)
106
+
107
+			// verify that the loaded image has the correct platforms
108
+			err = verifyImagePlatforms(ctx, imgSvc, imgRef, platforms)
109
+			assert.NilError(t, err)
110
+		})
111
+		cleanup(ctx, t)
112
+
87 113
 		t.Run("platform not included in index", func(t *testing.T) {
88
-			err = tryLoad(ctx, t, imgDataDir, linuxArmv5)
89
-			assert.Check(t, is.Error(err, "image multiplatform:latest was loaded, but doesn't provide the requested platform (linux/arm/v5)"))
114
+			err = tryLoad(ctx, t, imgDataDir, []ocispec.Platform{linuxArmv5})
115
+			assert.Check(t, is.Error(err, "image multiplatform:latest was loaded, but doesn't provide the requested platform ([linux/arm/v5])"))
90 116
 			assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
91 117
 		})
118
+		cleanup(ctx, t)
92 119
 
93
-		clearStore(ctx, t)
94
-
95
-		t.Run("platform blobs missing", func(t *testing.T) {
120
+		t.Run("platform included but blobs missing", func(t *testing.T) {
96 121
 			// Assumption: arm64 image is second in the index (implementation detail of specialimage.MultiPlatform)
97 122
 			mfstDesc := mfstDescs[1]
98 123
 			assert.Assert(t, mfstDesc.Platform.Architecture == linuxArm64.Architecture)
... ...
@@ -104,9 +142,37 @@ func TestImageLoadMissing(t *testing.T) {
104 104
 			mfstPath := filepath.Join(imgDataDir, "blobs/sha256", mfstDesc.Digest.Encoded())
105 105
 			assert.NilError(t, os.Remove(mfstPath))
106 106
 
107
-			err = tryLoad(ctx, t, imgDataDir, linuxArm64)
108
-			assert.Check(t, is.ErrorContains(err, "requested platform (linux/arm64) found, but some content is missing"))
107
+			err = tryLoad(ctx, t, imgDataDir, []ocispec.Platform{linuxArm64})
108
+			assert.Check(t, is.ErrorContains(err, "requested platform(s) ([linux/arm64]) found, but some content is missing"))
109 109
 			assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
110 110
 		})
111
+		cleanup(ctx, t)
111 112
 	})
112 113
 }
114
+
115
+func verifyImagePlatforms(ctx context.Context, imgSvc *ImageService, imgRef string, expectedPlatforms []ocispec.Platform) error {
116
+	// get the manifest(s) for the image
117
+	img, err := imgSvc.ImageInspect(ctx, imgRef, backend.ImageInspectOpts{Manifests: true})
118
+	if err != nil {
119
+		return err
120
+	}
121
+	// verify that the image manifest has the expected platforms
122
+	for _, ep := range expectedPlatforms {
123
+		want := platforms.FormatAll(ep)
124
+		found := false
125
+		for _, m := range img.Manifests {
126
+			if m.Descriptor.Platform != nil {
127
+				got := platforms.FormatAll(*m.Descriptor.Platform)
128
+				if got == want {
129
+					found = true
130
+					break
131
+				}
132
+			}
133
+		}
134
+		if !found {
135
+			return fmt.Errorf("expected platform %q not found in loaded images", want)
136
+		}
137
+	}
138
+
139
+	return nil
140
+}
... ...
@@ -31,12 +31,17 @@ func TestImageMultiplatformSaveShallowWithNative(t *testing.T) {
31 31
 		Architecture: "arm64",
32 32
 	}
33 33
 
34
+	riscv64 := platforms.Platform{
35
+		OS:           "linux",
36
+		Architecture: "riscv64",
37
+	}
38
+
34 39
 	imgSvc := fakeImageService(t, ctx, store)
35 40
 	// Mock the native platform.
36 41
 	imgSvc.defaultPlatformOverride = platforms.Only(native)
37 42
 
38 43
 	idx, _, err := specialimage.PartialMultiPlatform(contentDir, "partial-with-native:latest", specialimage.PartialOpts{
39
-		Stored:  []ocispec.Platform{native},
44
+		Stored:  []ocispec.Platform{native, riscv64},
40 45
 		Missing: []ocispec.Platform{arm64},
41 46
 	})
42 47
 	assert.NilError(t, err)
... ...
@@ -49,13 +54,21 @@ func TestImageMultiplatformSaveShallowWithNative(t *testing.T) {
49 49
 		assert.NilError(t, err)
50 50
 	})
51 51
 	t.Run("export native", func(t *testing.T) {
52
-		err = imgSvc.ExportImage(ctx, []string{img.Name}, &native, io.Discard)
52
+		err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{native}, io.Discard)
53
+		assert.NilError(t, err)
54
+	})
55
+	t.Run("export multiple platforms", func(t *testing.T) {
56
+		err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{native, riscv64}, io.Discard)
53 57
 		assert.NilError(t, err)
54 58
 	})
55 59
 	t.Run("export missing", func(t *testing.T) {
56
-		err = imgSvc.ExportImage(ctx, []string{img.Name}, &arm64, io.Discard)
60
+		err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{arm64}, io.Discard)
57 61
 		assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
58 62
 	})
63
+	t.Run("export multiple platforms with some missing", func(t *testing.T) {
64
+		err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{arm64, riscv64}, io.Discard)
65
+		assert.NilError(t, err)
66
+	})
59 67
 }
60 68
 
61 69
 func TestImageMultiplatformSaveShallowWithoutNative(t *testing.T) {
... ...
@@ -74,12 +87,22 @@ func TestImageMultiplatformSaveShallowWithoutNative(t *testing.T) {
74 74
 		Architecture: "arm64",
75 75
 	}
76 76
 
77
+	riscv64 := platforms.Platform{
78
+		OS:           "linux",
79
+		Architecture: "riscv64",
80
+	}
81
+
82
+	s390x := platforms.Platform{
83
+		OS:           "linux",
84
+		Architecture: "s390x",
85
+	}
86
+
77 87
 	imgSvc := fakeImageService(t, ctx, store)
78 88
 	// Mock the native platform.
79 89
 	imgSvc.defaultPlatformOverride = platforms.Only(native)
80 90
 
81 91
 	idx, _, err := specialimage.PartialMultiPlatform(contentDir, "partial-without-native:latest", specialimage.PartialOpts{
82
-		Stored:  []ocispec.Platform{arm64},
92
+		Stored:  []ocispec.Platform{arm64, riscv64},
83 93
 		Missing: []ocispec.Platform{native},
84 94
 	})
85 95
 	assert.NilError(t, err)
... ...
@@ -93,11 +116,23 @@ func TestImageMultiplatformSaveShallowWithoutNative(t *testing.T) {
93 93
 		assert.NilError(t, err)
94 94
 	})
95 95
 	t.Run("export native", func(t *testing.T) {
96
-		err = imgSvc.ExportImage(ctx, []string{img.Name}, &native, io.Discard)
96
+		err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{native}, io.Discard)
97 97
 		assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
98 98
 	})
99 99
 	t.Run("export arm64", func(t *testing.T) {
100
-		err = imgSvc.ExportImage(ctx, []string{img.Name}, &arm64, io.Discard)
100
+		err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{arm64}, io.Discard)
101
+		assert.NilError(t, err)
102
+	})
103
+	t.Run("export multiple platforms", func(t *testing.T) {
104
+		err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{arm64, riscv64}, io.Discard)
101 105
 		assert.NilError(t, err)
102 106
 	})
107
+	t.Run("export multiple platforms with some missing", func(t *testing.T) {
108
+		err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{arm64, native}, io.Discard)
109
+		assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
110
+	})
111
+	t.Run("export non existing platform", func(t *testing.T) {
112
+		err = imgSvc.ExportImage(ctx, []string{img.Name}, []ocispec.Platform{s390x}, io.Discard)
113
+		assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
114
+	})
103 115
 }
... ...
@@ -5,13 +5,14 @@ import (
5 5
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
6 6
 )
7 7
 
8
+// allPlatformsWithPreferenceMatcher returns a platform matcher that matches all
9
+// platforms but orders platforms to match the preferred matcher first.
10
+// It implements the platforms.MatchComparer interface.
8 11
 type allPlatformsWithPreferenceMatcher struct {
9 12
 	preferred platforms.MatchComparer
10 13
 }
11 14
 
12
-// matchAllWithPreference will return a platform matcher that matches all
13
-// platforms but will order platforms matching the preferred matcher first.
14
-func matchAllWithPreference(preferred platforms.MatchComparer) platforms.MatchComparer {
15
+func matchAllWithPreference(preferred platforms.MatchComparer) allPlatformsWithPreferenceMatcher {
15 16
 	return allPlatformsWithPreferenceMatcher{
16 17
 		preferred: preferred,
17 18
 	}
... ...
@@ -25,6 +26,29 @@ func (c allPlatformsWithPreferenceMatcher) Less(p1, p2 ocispec.Platform) bool {
25 25
 	return c.preferred.Less(p1, p2)
26 26
 }
27 27
 
28
+// platformsWithPreferenceMatcher is a platform matcher that matches any of the
29
+// given platforms, but orders platforms to match the preferred matcher first.
30
+// It implements the platforms.MatchComparer interface.
31
+type platformsWithPreferenceMatcher struct {
32
+	platformList []ocispec.Platform
33
+	preferred    platforms.MatchComparer
34
+}
35
+
36
+func matchAnyWithPreference(preferred platforms.MatchComparer, platformList []ocispec.Platform) platformsWithPreferenceMatcher {
37
+	return platformsWithPreferenceMatcher{
38
+		platformList: platformList,
39
+		preferred:    preferred,
40
+	}
41
+}
42
+
43
+func (c platformsWithPreferenceMatcher) Match(p ocispec.Platform) bool {
44
+	return platforms.Any(c.platformList...).Match(p)
45
+}
46
+
47
+func (c platformsWithPreferenceMatcher) Less(p1, p2 ocispec.Platform) bool {
48
+	return c.preferred.Less(p1, p2)
49
+}
50
+
28 51
 // platformMatcherWithRequestedPlatform is a platform matcher that also
29 52
 // contains the platform that was requested by the user in the context
30 53
 // in which the matcher was created.
... ...
@@ -36,6 +60,7 @@ type platformMatcherWithRequestedPlatform struct {
36 36
 
37 37
 type matchComparerProvider func(ocispec.Platform) platforms.MatchComparer
38 38
 
39
+// TODO(ctalledo): move this to a more appropriate place (e.g., next to the other ImageService methods).
39 40
 func (i *ImageService) matchRequestedOrDefault(
40 41
 	fpm matchComparerProvider, // function to create a platform matcher if platform is not nil
41 42
 	platform *ocispec.Platform, // input platform, nil if not specified
... ...
@@ -1,6 +1,7 @@
1 1
 package containerd
2 2
 
3 3
 import (
4
+	"reflect"
4 5
 	"runtime"
5 6
 	"testing"
6 7
 
... ...
@@ -167,3 +168,34 @@ func testOnlyAndOnlyStrict(t *testing.T, daemonPlatform platforms.MatchComparer,
167 167
 		}
168 168
 	})
169 169
 }
170
+func TestPlatformsWithPreferenceMatcher(t *testing.T) {
171
+	platformList := []ocispec.Platform{
172
+		pLinuxAmd64,
173
+		pLinuxArmv5,
174
+		pLinuxArmv6,
175
+		pLinuxArm64,
176
+		pWindowsAmd64,
177
+	}
178
+
179
+	// Use pLinuxArm64 as the preferred platform
180
+	preferred := platforms.Only(pLinuxArm64)
181
+	matcher := matchAnyWithPreference(preferred, platformList)
182
+
183
+	// Should match all platforms in the list
184
+	for _, p := range platformList {
185
+		assert.Assert(t, matcher.Match(p), "matcher should match platform: %v", platforms.Format(p))
186
+	}
187
+
188
+	// Should not match a platform not in the list
189
+	notInList := ocispec.Platform{OS: "linux", Architecture: "s390x"}
190
+	assert.Assert(t, !matcher.Match(notInList), "matcher should not match platform: %v", platforms.Format(notInList))
191
+
192
+	// Test Less: preferred should be less than others
193
+	for _, p := range platformList {
194
+		if reflect.DeepEqual(p, pLinuxArm64) {
195
+			continue
196
+		}
197
+		assert.Assert(t, matcher.Less(pLinuxArm64, p), "preferred platform should be less than %v", platforms.Format(p))
198
+		assert.Assert(t, !matcher.Less(p, pLinuxArm64), "%v should not be less than preferred platform", platforms.Format(p))
199
+	}
200
+}
... ...
@@ -30,8 +30,8 @@ type ImageService interface {
30 30
 	PushImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error
31 31
 	CreateImage(ctx context.Context, config []byte, parent string, contentStoreDigest digest.Digest) (builder.Image, error)
32 32
 	ImageDelete(ctx context.Context, imageRef string, options imagetype.RemoveOptions) ([]imagetype.DeleteResponse, error)
33
-	ExportImage(ctx context.Context, names []string, platform *ocispec.Platform, outStream io.Writer) error
34
-	LoadImage(ctx context.Context, inTar io.ReadCloser, platform *ocispec.Platform, outStream io.Writer, quiet bool) error
33
+	ExportImage(ctx context.Context, names []string, platformList []ocispec.Platform, outStream io.Writer) error
34
+	LoadImage(ctx context.Context, inTar io.ReadCloser, platformList []ocispec.Platform, outStream io.Writer, quiet bool) error
35 35
 	Images(ctx context.Context, opts imagetype.ListOptions) ([]*imagetype.Summary, error)
36 36
 	LogImageEvent(ctx context.Context, imageID, refName string, action events.Action)
37 37
 	CountImages(ctx context.Context) int
... ...
@@ -4,8 +4,10 @@ import (
4 4
 	"context"
5 5
 	"io"
6 6
 
7
+	"github.com/docker/docker/errdefs"
7 8
 	"github.com/docker/docker/image/tarexport"
8 9
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
10
+	"github.com/pkg/errors"
9 11
 )
10 12
 
11 13
 // ExportImage exports a list of images to the given output stream. The
... ...
@@ -13,7 +15,15 @@ import (
13 13
 // stream. All images with the given tag and all versions containing
14 14
 // the same tag are exported. names is the set of tags to export, and
15 15
 // outStream is the writer which the images are written to.
16
-func (i *ImageService) ExportImage(ctx context.Context, names []string, platform *ocispec.Platform, outStream io.Writer) error {
16
+func (i *ImageService) ExportImage(ctx context.Context, names []string, platformList []ocispec.Platform, outStream io.Writer) error {
17
+	var platform *ocispec.Platform
18
+
19
+	if len(platformList) > 1 {
20
+		return errdefs.InvalidParameter(errors.New("multiple platforms not supported for this image store; use a multi-platform image store such as containerd-snapshotter"))
21
+	} else if len(platformList) == 1 {
22
+		platform = &platformList[0]
23
+	}
24
+
17 25
 	imageExporter := tarexport.NewTarExporter(i.imageStore, i.layerStore, i.referenceStore, i, platform)
18 26
 	return imageExporter.Save(ctx, names, outStream)
19 27
 }
... ...
@@ -21,7 +31,15 @@ func (i *ImageService) ExportImage(ctx context.Context, names []string, platform
21 21
 // LoadImage uploads a set of images into the repository. This is the
22 22
 // complement of ExportImage.  The input stream is an uncompressed tar
23 23
 // ball containing images and metadata.
24
-func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platform *ocispec.Platform, outStream io.Writer, quiet bool) error {
24
+func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platformList []ocispec.Platform, outStream io.Writer, quiet bool) error {
25
+	var platform *ocispec.Platform
26
+
27
+	if len(platformList) > 1 {
28
+		return errdefs.InvalidParameter(errors.New("multiple platforms not supported for this image store; use a multi-platform image store such as containerd-snapshotter"))
29
+	} else if len(platformList) == 1 {
30
+		platform = &platformList[0]
31
+	}
32
+
25 33
 	imageExporter := tarexport.NewTarExporter(i.imageStore, i.layerStore, i.referenceStore, i, platform)
26 34
 	return imageExporter.Load(ctx, inTar, outStream, quiet)
27 35
 }
... ...
@@ -32,9 +32,9 @@ type imageBackend interface {
32 32
 }
33 33
 
34 34
 type importExportBackend interface {
35
-	LoadImage(ctx context.Context, inTar io.ReadCloser, platform *ocispec.Platform, outStream io.Writer, quiet bool) error
35
+	LoadImage(ctx context.Context, inTar io.ReadCloser, platformList []ocispec.Platform, outStream io.Writer, quiet bool) error
36 36
 	ImportImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, msg string, layerReader io.Reader, changes []string) (dockerimage.ID, error)
37
-	ExportImage(ctx context.Context, names []string, platform *ocispec.Platform, outStream io.Writer) error
37
+	ExportImage(ctx context.Context, names []string, platformList []ocispec.Platform, outStream io.Writer) error
38 38
 }
39 39
 
40 40
 type registryBackend interface {
... ...
@@ -235,6 +235,7 @@ func (ir *imageRouter) getImagesGet(ctx context.Context, w http.ResponseWriter,
235 235
 
236 236
 	output := ioutils.NewWriteFlusher(w)
237 237
 	defer output.Close()
238
+
238 239
 	var names []string
239 240
 	if name, ok := vars["name"]; ok {
240 241
 		names = []string{name}
... ...
@@ -242,27 +243,28 @@ func (ir *imageRouter) getImagesGet(ctx context.Context, w http.ResponseWriter,
242 242
 		names = r.Form["names"]
243 243
 	}
244 244
 
245
-	var platform *ocispec.Platform
245
+	var platformList []ocispec.Platform
246
+	// platform param was introduce in API version 1.48
246 247
 	if versions.GreaterThanOrEqualTo(httputils.VersionFromContext(ctx), "1.48") {
247
-		if formPlatforms := r.Form["platform"]; len(formPlatforms) > 1 {
248
-			// TODO(thaJeztah): remove once we support multiple platforms: see https://github.com/moby/moby/issues/48759
249
-			return errdefs.InvalidParameter(errors.New("multiple platform parameters not supported"))
248
+		var err error
249
+		formPlatforms := r.Form["platform"]
250
+		// multi-platform params were introduced in API version 1.51
251
+		if versions.LessThan(httputils.VersionFromContext(ctx), "1.51") && len(formPlatforms) > 1 {
252
+			return errdefs.InvalidParameter(errors.New("multiple platform parameters are not supported in this API version; use API version 1.51 or later."))
250 253
 		}
251
-		if formPlatform := r.Form.Get("platform"); formPlatform != "" {
252
-			p, err := httputils.DecodePlatform(formPlatform)
253
-			if err != nil {
254
-				return err
255
-			}
256
-			platform = p
254
+		platformList, err = httputils.DecodePlatforms(formPlatforms)
255
+		if err != nil {
256
+			return err
257 257
 		}
258 258
 	}
259 259
 
260
-	if err := ir.backend.ExportImage(ctx, names, platform, output); err != nil {
260
+	if err := ir.backend.ExportImage(ctx, names, platformList, output); err != nil {
261 261
 		if !output.Flushed() {
262 262
 			return err
263 263
 		}
264 264
 		_, _ = output.Write(streamformatter.FormatError(err))
265 265
 	}
266
+
266 267
 	return nil
267 268
 }
268 269
 
... ...
@@ -271,18 +273,18 @@ func (ir *imageRouter) postImagesLoad(ctx context.Context, w http.ResponseWriter
271 271
 		return err
272 272
 	}
273 273
 
274
-	var platform *ocispec.Platform
274
+	var platformList []ocispec.Platform
275
+	// platform param was introduce in API version 1.48
275 276
 	if versions.GreaterThanOrEqualTo(httputils.VersionFromContext(ctx), "1.48") {
276
-		if formPlatforms := r.Form["platform"]; len(formPlatforms) > 1 {
277
-			// TODO(thaJeztah): remove once we support multiple platforms: see https://github.com/moby/moby/issues/48759
278
-			return errdefs.InvalidParameter(errors.New("multiple platform parameters not supported"))
277
+		var err error
278
+		formPlatforms := r.Form["platform"]
279
+		// multi-platform params were introduced in API version 1.51
280
+		if versions.LessThan(httputils.VersionFromContext(ctx), "1.51") && len(formPlatforms) > 1 {
281
+			return errdefs.InvalidParameter(errors.New("multiple platform parameters are not supported in this API version; use API version 1.51 or later."))
279 282
 		}
280
-		if formPlatform := r.Form.Get("platform"); formPlatform != "" {
281
-			p, err := httputils.DecodePlatform(formPlatform)
282
-			if err != nil {
283
-				return err
284
-			}
285
-			platform = p
283
+		platformList, err = httputils.DecodePlatforms(formPlatforms)
284
+		if err != nil {
285
+			return err
286 286
 		}
287 287
 	}
288 288
 	quiet := httputils.BoolValueOrDefault(r, "quiet", true)
... ...
@@ -291,7 +293,8 @@ func (ir *imageRouter) postImagesLoad(ctx context.Context, w http.ResponseWriter
291 291
 
292 292
 	output := ioutils.NewWriteFlusher(w)
293 293
 	defer output.Close()
294
-	if err := ir.backend.LoadImage(ctx, r.Body, platform, output, quiet); err != nil {
294
+
295
+	if err := ir.backend.LoadImage(ctx, r.Body, platformList, output, quiet); err != nil {
295 296
 		_, _ = output.Write(streamformatter.FormatError(err))
296 297
 	}
297 298
 	return nil
... ...
@@ -13,7 +13,6 @@ import (
13 13
 	"testing"
14 14
 	"time"
15 15
 
16
-	cerrdefs "github.com/containerd/errdefs"
17 16
 	"github.com/cpuguy83/tar2go"
18 17
 	"github.com/docker/docker/integration/internal/build"
19 18
 	"github.com/docker/docker/integration/internal/container"
... ...
@@ -23,6 +22,7 @@ import (
23 23
 	"github.com/docker/docker/testutil/fakecontext"
24 24
 	"github.com/moby/go-archive/compression"
25 25
 	containertypes "github.com/moby/moby/api/types/container"
26
+	"github.com/moby/moby/api/types/image"
26 27
 	"github.com/moby/moby/api/types/versions"
27 28
 	"github.com/moby/moby/client"
28 29
 	"github.com/opencontainers/go-digest"
... ...
@@ -213,23 +213,167 @@ func TestSaveOCI(t *testing.T) {
213 213
 	}
214 214
 }
215 215
 
216
-// TODO(thaJeztah): this test currently only checks invalid cases; update this test to use a table-test and test both valid and invalid platform options.
217
-func TestSavePlatform(t *testing.T) {
218
-	ctx := setupTest(t)
216
+func TestSaveAndLoadPlatform(t *testing.T) {
217
+	skip.If(t, testEnv.DaemonInfo.OSType == "windows", "The test image is a Linux image")
219 218
 
220
-	t.Parallel()
219
+	ctx := setupTest(t)
221 220
 	apiClient := testEnv.APIClient()
222 221
 
223
-	const repoName = "busybox:latest"
224
-	_, err := apiClient.ImageInspect(ctx, repoName)
225
-	assert.NilError(t, err)
222
+	const repoName = "alpine:latest"
223
+
224
+	type testCase struct {
225
+		testName                string
226
+		containerdStoreOnly     bool
227
+		pullPlatforms           []string
228
+		savePlatforms           []ocispec.Platform
229
+		loadPlatforms           []ocispec.Platform
230
+		expectedSavedPlatforms  []ocispec.Platform
231
+		expectedLoadedPlatforms []ocispec.Platform // expected platforms to be saved, if empty, all pulled platforms are expected to be saved
232
+	}
233
+
234
+	testCases := []testCase{
235
+		{
236
+			testName:            "With no platforms specified",
237
+			containerdStoreOnly: true,
238
+			pullPlatforms:       []string{"linux/amd64", "linux/riscv64", "linux/arm64/v8"},
239
+			savePlatforms:       nil,
240
+			loadPlatforms:       nil,
241
+			expectedSavedPlatforms: []ocispec.Platform{
242
+				{OS: "linux", Architecture: "amd64"},
243
+				{OS: "linux", Architecture: "riscv64"},
244
+				{OS: "linux", Architecture: "arm64", Variant: "v8"},
245
+			},
246
+			expectedLoadedPlatforms: []ocispec.Platform{
247
+				{OS: "linux", Architecture: "amd64"},
248
+				{OS: "linux", Architecture: "riscv64"},
249
+				{OS: "linux", Architecture: "arm64", Variant: "v8"},
250
+			},
251
+		},
252
+		{
253
+			testName:                "With single pulled platform",
254
+			pullPlatforms:           []string{"linux/amd64"},
255
+			savePlatforms:           []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
256
+			loadPlatforms:           []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
257
+			expectedSavedPlatforms:  []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
258
+			expectedLoadedPlatforms: []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
259
+		},
260
+		{
261
+			testName:                "With single platform save and load",
262
+			containerdStoreOnly:     true,
263
+			pullPlatforms:           []string{"linux/amd64", "linux/riscv64", "linux/arm64/v8"},
264
+			savePlatforms:           []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
265
+			loadPlatforms:           []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
266
+			expectedSavedPlatforms:  []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
267
+			expectedLoadedPlatforms: []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
268
+		},
269
+		{
270
+			testName:            "With multiple platforms save and load",
271
+			containerdStoreOnly: true,
272
+			pullPlatforms:       []string{"linux/amd64", "linux/riscv64", "linux/arm64/v8"},
273
+			savePlatforms: []ocispec.Platform{
274
+				{OS: "linux", Architecture: "arm64", Variant: "v8"},
275
+				{OS: "linux", Architecture: "riscv64"},
276
+			},
277
+			loadPlatforms: []ocispec.Platform{
278
+				{OS: "linux", Architecture: "arm64", Variant: "v8"},
279
+				{OS: "linux", Architecture: "riscv64"},
280
+			},
281
+			expectedSavedPlatforms: []ocispec.Platform{
282
+				{OS: "linux", Architecture: "arm64", Variant: "v8"},
283
+				{OS: "linux", Architecture: "riscv64"},
284
+			},
285
+			expectedLoadedPlatforms: []ocispec.Platform{
286
+				{OS: "linux", Architecture: "arm64", Variant: "v8"},
287
+				{OS: "linux", Architecture: "riscv64"},
288
+			},
289
+		},
290
+		{
291
+			testName:            "With mixed platform save and load",
292
+			containerdStoreOnly: true,
293
+			pullPlatforms:       []string{"linux/amd64", "linux/riscv64", "linux/arm64/v8"},
294
+			savePlatforms: []ocispec.Platform{
295
+				{OS: "linux", Architecture: "arm64", Variant: "v8"},
296
+				{OS: "linux", Architecture: "riscv64"},
297
+			},
298
+			loadPlatforms: []ocispec.Platform{
299
+				{OS: "linux", Architecture: "riscv64"},
300
+			},
301
+			expectedSavedPlatforms: []ocispec.Platform{
302
+				{OS: "linux", Architecture: "arm64", Variant: "v8"},
303
+				{OS: "linux", Architecture: "riscv64"},
304
+			},
305
+			expectedLoadedPlatforms: []ocispec.Platform{
306
+				{OS: "linux", Architecture: "riscv64"},
307
+			},
308
+		},
309
+	}
310
+
311
+	for _, tc := range testCases {
312
+		if tc.containerdStoreOnly && !testEnv.UsingSnapshotter() {
313
+			continue
314
+		}
315
+		t.Run(tc.testName, func(t *testing.T) {
316
+			// pull the image
317
+			for _, p := range tc.pullPlatforms {
318
+				resp, err := apiClient.ImagePull(ctx, repoName, image.PullOptions{Platform: p})
319
+				assert.NilError(t, err)
320
+				_, err = io.ReadAll(resp)
321
+				assert.NilError(t, err)
322
+				resp.Close()
323
+			}
324
+
325
+			// export the image
326
+			rdr, err := apiClient.ImageSave(ctx, []string{repoName}, client.ImageSaveWithPlatforms(tc.savePlatforms...))
327
+			assert.NilError(t, err)
328
+
329
+			// remove the pulled image
330
+			_, err = apiClient.ImageRemove(ctx, repoName, image.RemoveOptions{})
331
+			assert.NilError(t, err)
332
+
333
+			// load the full exported image (all platforms in it)
334
+			_, err = apiClient.ImageLoad(ctx, rdr)
335
+			assert.NilError(t, err)
336
+			rdr.Close()
337
+
338
+			// verify the loaded image has all the expected saved platforms
339
+			for _, p := range tc.expectedSavedPlatforms {
340
+				inspectResponse, err := apiClient.ImageInspect(ctx, repoName, client.ImageInspectWithPlatform(&p))
341
+				assert.NilError(t, err)
342
+				assert.Check(t, is.Equal(inspectResponse.Os, p.OS))
343
+				assert.Check(t, is.Equal(inspectResponse.Architecture, p.Architecture))
344
+			}
345
+
346
+			// pull the image again (start fresh)
347
+			for _, p := range tc.pullPlatforms {
348
+				resp, err := apiClient.ImagePull(ctx, repoName, image.PullOptions{Platform: p})
349
+				assert.NilError(t, err)
350
+				_, err = io.ReadAll(resp)
351
+				assert.NilError(t, err)
352
+				resp.Close()
353
+			}
226 354
 
227
-	_, err = apiClient.ImageSave(ctx, []string{repoName}, client.ImageSaveWithPlatforms(
228
-		ocispec.Platform{Architecture: "amd64", OS: "linux"},
229
-		ocispec.Platform{Architecture: "arm64", OS: "linux", Variant: "v8"},
230
-	))
231
-	assert.Check(t, is.ErrorType(err, cerrdefs.IsInvalidArgument))
232
-	assert.Check(t, is.Error(err, "Error response from daemon: multiple platform parameters not supported"))
355
+			// export the image
356
+			rdr, err = apiClient.ImageSave(ctx, []string{repoName}, client.ImageSaveWithPlatforms(tc.savePlatforms...))
357
+			assert.NilError(t, err)
358
+
359
+			// remove the pulled image
360
+			_, err = apiClient.ImageRemove(ctx, repoName, image.RemoveOptions{})
361
+			assert.NilError(t, err)
362
+
363
+			// load the exported image on the specified platforms only
364
+			_, err = apiClient.ImageLoad(ctx, rdr, client.ImageLoadWithPlatforms(tc.loadPlatforms...))
365
+			assert.NilError(t, err)
366
+			rdr.Close()
367
+
368
+			// verify the image was loaded for the specified platforms
369
+			for _, p := range tc.expectedLoadedPlatforms {
370
+				inspectResponse, err := apiClient.ImageInspect(ctx, repoName, client.ImageInspectWithPlatform(&p))
371
+				assert.NilError(t, err)
372
+				assert.Check(t, is.Equal(inspectResponse.Os, p.OS))
373
+				assert.Check(t, is.Equal(inspectResponse.Architecture, p.Architecture))
374
+			}
375
+		})
376
+	}
233 377
 }
234 378
 
235 379
 func TestSaveRepoWithMultipleImages(t *testing.T) {
... ...
@@ -10443,7 +10443,10 @@ paths:
10443 10443
           type: "string"
10444 10444
           required: true
10445 10445
         - name: "platform"
10446
-          type: "string"
10446
+          type: "array"
10447
+          items:
10448
+            type: "string"
10449
+          collectionFormat: "multi"
10447 10450
           in: "query"
10448 10451
           description: |
10449 10452
             JSON encoded OCI platform describing a platform which will be used
... ...
@@ -10488,13 +10491,16 @@ paths:
10488 10488
           items:
10489 10489
             type: "string"
10490 10490
         - name: "platform"
10491
-          type: "string"
10491
+          type: "array"
10492
+          items:
10493
+            type: "string"
10494
+          collectionFormat: "multi"
10492 10495
           in: "query"
10493 10496
           description: |
10494
-            JSON encoded OCI platform describing a platform which will be used
10495
-            to select a platform-specific image to be saved if the image is
10496
-            multi-platform.
10497
-            If not provided, the full multi-platform image will be saved.
10497
+            JSON encoded OCI platform(s) which will be used to select the
10498
+            platform-specific image(s) to be saved if the image is
10499
+            multi-platform. If not provided, the full multi-platform image
10500
+            will be saved.
10498 10501
 
10499 10502
             Example: `{"os": "linux", "architecture": "arm", "variant": "v5"}`
10500 10503
       tags: ["Image"]
... ...
@@ -10530,13 +10536,16 @@ paths:
10530 10530
           type: "boolean"
10531 10531
           default: false
10532 10532
         - name: "platform"
10533
-          type: "string"
10533
+          type: "array"
10534
+          items:
10535
+            type: "string"
10536
+          collectionFormat: "multi"
10534 10537
           in: "query"
10535 10538
           description: |
10536
-            JSON encoded OCI platform describing a platform which will be used
10537
-            to select a platform-specific image to be load if the image is
10538
-            multi-platform.
10539
-            If not provided, the full multi-platform image will be loaded.
10539
+            JSON encoded OCI platform(s) which will be used to select the
10540
+            platform-specific image(s) to load if the image is
10541
+            multi-platform. If not provided, the full multi-platform image
10542
+            will be loaded.
10540 10543
 
10541 10544
             Example: `{"os": "linux", "architecture": "arm", "variant": "v5"}`
10542 10545
       tags: ["Image"]