Browse code

client: prepare option-structs for multiple platforms

Some methods currently support a single platform only, but we may
be able to support multiple platforms.

This patch prepares the option-structs for multi-platform support,
but (for now) returning an error if multiple options are provided.

We need a similar check on the daemon-side, but still need to check
on the client, as older daemons will ignore multiple platforms, which
may be unexpected.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>

Sebastiaan van Stijn authored on 2025/10/30 22:16:31
Showing 22 changed files
... ...
@@ -8,8 +8,8 @@ import (
8 8
 	"net/http"
9 9
 	"net/url"
10 10
 	"strconv"
11
-	"strings"
12 11
 
12
+	cerrdefs "github.com/containerd/errdefs"
13 13
 	"github.com/moby/moby/api/types/container"
14 14
 	"github.com/moby/moby/api/types/network"
15 15
 )
... ...
@@ -154,8 +154,12 @@ func (cli *Client) imageBuildOptionsToQuery(_ context.Context, options ImageBuil
154 154
 	if options.SessionID != "" {
155 155
 		query.Set("session", options.SessionID)
156 156
 	}
157
-	if options.Platform != "" {
158
-		query.Set("platform", strings.ToLower(options.Platform))
157
+	if len(options.Platforms) > 0 {
158
+		if len(options.Platforms) > 1 {
159
+			// TODO(thaJeztah): update API spec and add equivalent check on the daemon. We need this still for older daemons, which would ignore it.
160
+			return query, cerrdefs.ErrInvalidArgument.WithMessage("specifying multiple platforms is not yet supported")
161
+		}
162
+		query.Set("platform", formatPlatform(options.Platforms[0]))
159 163
 	}
160 164
 	if options.BuildID != "" {
161 165
 		query.Set("buildid", options.BuildID)
... ...
@@ -6,6 +6,7 @@ import (
6 6
 	"github.com/moby/moby/api/types/build"
7 7
 	"github.com/moby/moby/api/types/container"
8 8
 	"github.com/moby/moby/api/types/registry"
9
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
9 10
 )
10 11
 
11 12
 // ImageBuildOptions holds the information
... ...
@@ -50,7 +51,9 @@ type ImageBuildOptions struct {
50 50
 	ExtraHosts  []string // List of extra hosts
51 51
 	Target      string
52 52
 	SessionID   string
53
-	Platform    string
53
+	// Platforms selects the platforms to build the image for. Multiple platforms
54
+	// can be provided if the daemon supports multi-platform builds.
55
+	Platforms []ocispec.Platform
54 56
 	// Version specifies the version of the underlying builder to use
55 57
 	Version build.BuilderVersion
56 58
 	// BuildID is an optional identifier that can be passed together with the
... ...
@@ -4,8 +4,8 @@ import (
4 4
 	"context"
5 5
 	"net/http"
6 6
 	"net/url"
7
-	"strings"
8 7
 
8
+	cerrdefs "github.com/containerd/errdefs"
9 9
 	"github.com/distribution/reference"
10 10
 	"github.com/moby/moby/api/types/registry"
11 11
 )
... ...
@@ -21,8 +21,12 @@ func (cli *Client) ImageCreate(ctx context.Context, parentReference string, opti
21 21
 	query := url.Values{}
22 22
 	query.Set("fromImage", ref.Name())
23 23
 	query.Set("tag", getAPITagFromNamedRef(ref))
24
-	if options.Platform != "" {
25
-		query.Set("platform", strings.ToLower(options.Platform))
24
+	if len(options.Platforms) > 0 {
25
+		if len(options.Platforms) > 1 {
26
+			// TODO(thaJeztah): update API spec and add equivalent check on the daemon. We need this still for older daemons, which would ignore it.
27
+			return ImageCreateResult{}, cerrdefs.ErrInvalidArgument.WithMessage("specifying multiple platforms is not yet supported")
28
+		}
29
+		query.Set("platform", formatPlatform(options.Platforms[0]))
26 30
 	}
27 31
 	resp, err := cli.tryImageCreate(ctx, query, staticAuth(options.RegistryAuth))
28 32
 	if err != nil {
... ...
@@ -1,11 +1,18 @@
1 1
 package client
2 2
 
3
-import "io"
3
+import (
4
+	"io"
5
+
6
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
7
+)
4 8
 
5 9
 // ImageCreateOptions holds information to create images.
6 10
 type ImageCreateOptions struct {
7 11
 	RegistryAuth string // RegistryAuth is the base64 encoded credentials for the registry.
8
-	Platform     string // Platform is the target platform of the image if it needs to be pulled from the registry.
12
+	// Platforms specifies the platforms to platform of the image if it needs
13
+	// to be pulled from the registry. Multiple platforms can be provided
14
+	// if the daemon supports multi-platform pulls.
15
+	Platforms []ocispec.Platform
9 16
 }
10 17
 
11 18
 // ImageCreateResult holds the response body returned by the daemon for image create.
... ...
@@ -3,7 +3,6 @@ package client
3 3
 import (
4 4
 	"context"
5 5
 	"net/url"
6
-	"strings"
7 6
 
8 7
 	"github.com/distribution/reference"
9 8
 )
... ...
@@ -31,8 +30,9 @@ func (cli *Client) ImageImport(ctx context.Context, source ImageImportSource, re
31 31
 	if options.Message != "" {
32 32
 		query.Set("message", options.Message)
33 33
 	}
34
-	if options.Platform != "" {
35
-		query.Set("platform", strings.ToLower(options.Platform))
34
+	if p := formatPlatform(options.Platform); p != "unknown" {
35
+		// TODO(thaJeztah): would we ever support mutiple platforms here? (would require multiple rootfs tars as well?)
36
+		query.Set("platform", p)
36 37
 	}
37 38
 	for _, change := range options.Changes {
38 39
 		query.Add("changes", change)
... ...
@@ -2,6 +2,8 @@ package client
2 2
 
3 3
 import (
4 4
 	"io"
5
+
6
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
5 7
 )
6 8
 
7 9
 // ImageImportSource holds source information for ImageImport
... ...
@@ -12,10 +14,10 @@ type ImageImportSource struct {
12 12
 
13 13
 // ImageImportOptions holds information to import images from the client host.
14 14
 type ImageImportOptions struct {
15
-	Tag      string   // Tag is the name to tag this image with. This attribute is deprecated.
16
-	Message  string   // Message is the message to tag the image with
17
-	Changes  []string // Changes are the raw changes to apply to this image
18
-	Platform string   // Platform is the target platform of the image
15
+	Tag      string           // Tag is the name to tag this image with. This attribute is deprecated.
16
+	Message  string           // Message is the message to tag the image with
17
+	Changes  []string         // Changes are the raw changes to apply to this image
18
+	Platform ocispec.Platform // Platform is the target platform of the image
19 19
 }
20 20
 
21 21
 // ImageImportResult holds the response body returned by the daemon for image import.
... ...
@@ -9,6 +9,7 @@ import (
9 9
 	"testing"
10 10
 
11 11
 	cerrdefs "github.com/containerd/errdefs"
12
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
12 13
 	"gotest.tools/v3/assert"
13 14
 	is "gotest.tools/v3/assert/cmp"
14 15
 )
... ...
@@ -55,7 +56,10 @@ func TestImageImport(t *testing.T) {
55 55
 		{
56 56
 			doc: "with platform",
57 57
 			options: ImageImportOptions{
58
-				Platform: "linux/amd64",
58
+				Platform: ocispec.Platform{
59
+					Architecture: "amd64",
60
+					OS:           "linux",
61
+				},
59 62
 			},
60 63
 			expectedQueryParams: url.Values{
61 64
 				"fromSrc":  {"image_source"},
... ...
@@ -5,7 +5,6 @@ import (
5 5
 	"io"
6 6
 	"iter"
7 7
 	"net/url"
8
-	"strings"
9 8
 
10 9
 	cerrdefs "github.com/containerd/errdefs"
11 10
 	"github.com/distribution/reference"
... ...
@@ -44,10 +43,13 @@ func (cli *Client) ImagePull(ctx context.Context, refStr string, options ImagePu
44 44
 	if !options.All {
45 45
 		query.Set("tag", getAPITagFromNamedRef(ref))
46 46
 	}
47
-	if options.Platform != "" {
48
-		query.Set("platform", strings.ToLower(options.Platform))
47
+	if len(options.Platforms) > 0 {
48
+		if len(options.Platforms) > 1 {
49
+			// TODO(thaJeztah): update API spec and add equivalent check on the daemon. We need this still for older daemons, which would ignore it.
50
+			return nil, cerrdefs.ErrInvalidArgument.WithMessage("specifying multiple platforms is not yet supported")
51
+		}
52
+		query.Set("platform", formatPlatform(options.Platforms[0]))
49 53
 	}
50
-
51 54
 	resp, err := cli.tryImageCreate(ctx, query, staticAuth(options.RegistryAuth))
52 55
 	if cerrdefs.IsUnauthorized(err) && options.PrivilegeFunc != nil {
53 56
 		resp, err = cli.tryImageCreate(ctx, query, options.PrivilegeFunc)
... ...
@@ -2,6 +2,8 @@ package client
2 2
 
3 3
 import (
4 4
 	"context"
5
+
6
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
5 7
 )
6 8
 
7 9
 // ImagePullOptions holds information to pull images.
... ...
@@ -16,5 +18,8 @@ type ImagePullOptions struct {
16 16
 	//
17 17
 	// For details, refer to [github.com/moby/moby/api/types/registry.RequestAuthConfig].
18 18
 	PrivilegeFunc func(context.Context) (string, error)
19
-	Platform      string
19
+
20
+	// Platforms selects the platforms to pull. Multiple platforms can be
21
+	// specified if the image ia a multi-platform image.
22
+	Platforms []ocispec.Platform
20 23
 }
... ...
@@ -22,6 +22,7 @@ import (
22 22
 	"github.com/moby/moby/client/pkg/jsonmessage"
23 23
 	"github.com/moby/moby/v2/internal/testutil"
24 24
 	"github.com/moby/moby/v2/internal/testutil/fakecontext"
25
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
25 26
 	"gotest.tools/v3/assert"
26 27
 	is "gotest.tools/v3/assert/cmp"
27 28
 	"gotest.tools/v3/skip"
... ...
@@ -662,7 +663,7 @@ func TestBuildPlatformInvalid(t *testing.T) {
662 662
 	_, err = testEnv.APIClient().ImageBuild(ctx, buf, client.ImageBuildOptions{
663 663
 		Remove:      true,
664 664
 		ForceRemove: true,
665
-		Platform:    "foobar",
665
+		Platforms:   []ocispec.Platform{{OS: "foobar"}},
666 666
 	})
667 667
 
668 668
 	assert.Check(t, is.ErrorContains(err, "unknown operating system or architecture"))
... ...
@@ -5,7 +5,6 @@ import (
5 5
 	"io"
6 6
 	"testing"
7 7
 
8
-	"github.com/containerd/platforms"
9 8
 	buildtypes "github.com/moby/moby/api/types/build"
10 9
 	"github.com/moby/moby/client"
11 10
 	build "github.com/moby/moby/v2/integration/internal/build"
... ...
@@ -19,13 +18,13 @@ import (
19 19
 
20 20
 func TestAPIImagesHistory(t *testing.T) {
21 21
 	ctx := setupTest(t)
22
-	client := testEnv.APIClient()
22
+	apiClient := testEnv.APIClient()
23 23
 
24 24
 	dockerfile := "FROM busybox\nENV FOO bar"
25 25
 
26
-	imgID := build.Do(ctx, t, client, fakecontext.New(t, t.TempDir(), fakecontext.WithDockerfile(dockerfile)))
26
+	imgID := build.Do(ctx, t, apiClient, fakecontext.New(t, t.TempDir(), fakecontext.WithDockerfile(dockerfile)))
27 27
 
28
-	res, err := client.ImageHistory(ctx, imgID)
28
+	res, err := apiClient.ImageHistory(ctx, imgID)
29 29
 	assert.NilError(t, err)
30 30
 
31 31
 	assert.Assert(t, len(res.Items) != 0)
... ...
@@ -69,9 +68,9 @@ func TestAPIImageHistoryCrossPlatform(t *testing.T) {
69 69
 
70 70
 	// Build the image for a non-native platform
71 71
 	resp, err := apiClient.ImageBuild(ctx, buildCtx.AsTarReader(t), client.ImageBuildOptions{
72
-		Version:  buildtypes.BuilderBuildKit,
73
-		Tags:     []string{"cross-platform-test"},
74
-		Platform: platforms.FormatAll(nonNativePlatform),
72
+		Version:   buildtypes.BuilderBuildKit,
73
+		Tags:      []string{"cross-platform-test"},
74
+		Platforms: []ocispec.Platform{nonNativePlatform},
75 75
 	})
76 76
 	assert.NilError(t, err)
77 77
 	defer resp.Body.Close()
... ...
@@ -128,7 +127,9 @@ func TestAPIImageHistoryCrossPlatform(t *testing.T) {
128 128
 }
129 129
 
130 130
 func pullImageForPlatform(t *testing.T, ctx context.Context, apiClient client.APIClient, ref string, platform ocispec.Platform) {
131
-	pullResp, err := apiClient.ImagePull(ctx, ref, client.ImagePullOptions{Platform: platforms.FormatAll(platform)})
131
+	pullResp, err := apiClient.ImagePull(ctx, ref, client.ImagePullOptions{
132
+		Platforms: []ocispec.Platform{platform},
133
+	})
132 134
 	assert.NilError(t, err)
133 135
 	_, _ = io.Copy(io.Discard, pullResp)
134 136
 
... ...
@@ -14,6 +14,7 @@ import (
14 14
 	"github.com/moby/moby/client"
15 15
 	"github.com/moby/moby/v2/internal/testutil"
16 16
 	"github.com/moby/moby/v2/internal/testutil/daemon"
17
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
17 18
 	"gotest.tools/v3/assert"
18 19
 	is "gotest.tools/v3/assert/cmp"
19 20
 	"gotest.tools/v3/skip"
... ...
@@ -70,33 +71,30 @@ func TestImportWithCustomPlatform(t *testing.T) {
70 70
 
71 71
 	tests := []struct {
72 72
 		name     string
73
-		platform string
74
-		expected platforms.Platform
73
+		platform ocispec.Platform
74
+		expected ocispec.Platform
75 75
 	}{
76 76
 		{
77
-			platform: "",
78
-			expected: platforms.Platform{
77
+			expected: ocispec.Platform{
79 78
 				OS:           runtime.GOOS,
80 79
 				Architecture: runtime.GOARCH, // this may fail on armhf due to normalization?
81 80
 			},
82 81
 		},
83 82
 		{
84
-			platform: runtime.GOOS,
85
-			expected: platforms.Platform{
83
+			platform: ocispec.Platform{
84
+				OS: runtime.GOOS,
85
+			},
86
+			expected: ocispec.Platform{
86 87
 				OS:           runtime.GOOS,
87 88
 				Architecture: runtime.GOARCH, // this may fail on armhf due to normalization?
88 89
 			},
89 90
 		},
90 91
 		{
91
-			platform: strings.ToUpper(runtime.GOOS),
92
-			expected: platforms.Platform{
92
+			platform: ocispec.Platform{
93 93
 				OS:           runtime.GOOS,
94
-				Architecture: runtime.GOARCH, // this may fail on armhf due to normalization?
94
+				Architecture: "sparc64",
95 95
 			},
96
-		},
97
-		{
98
-			platform: runtime.GOOS + "/sparc64",
99
-			expected: platforms.Platform{
96
+			expected: ocispec.Platform{
100 97
 				OS:           runtime.GOOS,
101 98
 				Architecture: "sparc64",
102 99
 			},
... ...
@@ -104,7 +102,7 @@ func TestImportWithCustomPlatform(t *testing.T) {
104 104
 	}
105 105
 
106 106
 	for i, tc := range tests {
107
-		t.Run(tc.platform, func(t *testing.T) {
107
+		t.Run(platforms.Format(tc.platform), func(t *testing.T) {
108 108
 			ctx := testutil.StartSpan(ctx, t)
109 109
 			reference := "import-with-platform:tc-" + strconv.Itoa(i)
110 110
 
... ...
@@ -140,36 +138,45 @@ func TestImportWithCustomPlatformReject(t *testing.T) {
140 140
 
141 141
 	tests := []struct {
142 142
 		name        string
143
-		platform    string
143
+		platform    ocispec.Platform
144 144
 		expectedErr string
145 145
 	}{
146 146
 		{
147
-			platform:    "       ",
148
-			expectedErr: "is an invalid OS component",
149
-		},
150
-		{
151
-			platform:    "/",
147
+			name: "whitespace-only platform",
148
+			platform: ocispec.Platform{
149
+				OS: "       ",
150
+			},
152 151
 			expectedErr: "is an invalid OS component",
153 152
 		},
154 153
 		{
155
-			platform:    "macos",
154
+			name: "valid, but unsupported os",
155
+			platform: ocispec.Platform{
156
+				OS: "macos",
157
+			},
156 158
 			expectedErr: "operating system is not supported",
157 159
 		},
158 160
 		{
159
-			platform:    "macos/arm64",
161
+			name: "valid, but unsupported os/arch",
162
+			platform: ocispec.Platform{
163
+				OS:           "macos",
164
+				Architecture: "arm64",
165
+			},
160 166
 			expectedErr: "operating system is not supported",
161 167
 		},
162 168
 		{
169
+			name: "valid, but unsupported os",
163 170
 			// TODO: platforms.Normalize() only validates os or arch if a single component is passed,
164 171
 			//       but ignores unknown os/arch in other cases. See:
165 172
 			//       https://github.com/containerd/containerd/blob/7d4891783aac5adf6cd83f657852574a71875631/platforms/platforms.go#L183-L209
166
-			platform:    "nintendo64",
173
+			platform: ocispec.Platform{
174
+				OS: "nintendo64",
175
+			},
167 176
 			expectedErr: "unknown operating system or architecture",
168 177
 		},
169 178
 	}
170 179
 
171 180
 	for i, tc := range tests {
172
-		t.Run(tc.platform, func(t *testing.T) {
181
+		t.Run(tc.name, func(t *testing.T) {
173 182
 			ctx := testutil.StartSpan(ctx, t)
174 183
 			reference := "import-with-platform:tc-" + strconv.Itoa(i)
175 184
 			_, err = apiClient.ImageImport(ctx,
... ...
@@ -33,7 +33,9 @@ func TestImagePullPlatformInvalid(t *testing.T) {
33 33
 
34 34
 	apiClient := testEnv.APIClient()
35 35
 
36
-	_, err := apiClient.ImagePull(ctx, "docker.io/library/hello-world:latest", client.ImagePullOptions{Platform: "foobar"})
36
+	_, err := apiClient.ImagePull(ctx, "docker.io/library/hello-world:latest", client.ImagePullOptions{
37
+		Platforms: []ocispec.Platform{{OS: "foobar"}},
38
+	})
37 39
 	assert.Assert(t, err != nil)
38 40
 	assert.Check(t, is.ErrorContains(err, "unknown operating system or architecture"))
39 41
 	assert.Check(t, is.ErrorType(err, cerrdefs.IsInvalidArgument))
... ...
@@ -219,7 +219,7 @@ func TestSaveAndLoadPlatform(t *testing.T) {
219 219
 	type testCase struct {
220 220
 		testName                string
221 221
 		containerdStoreOnly     bool
222
-		pullPlatforms           []string
222
+		pullPlatforms           []ocispec.Platform
223 223
 		savePlatforms           []ocispec.Platform
224 224
 		loadPlatforms           []ocispec.Platform
225 225
 		expectedSavedPlatforms  []ocispec.Platform
... ...
@@ -230,9 +230,13 @@ func TestSaveAndLoadPlatform(t *testing.T) {
230 230
 		{
231 231
 			testName:            "With no platforms specified",
232 232
 			containerdStoreOnly: true,
233
-			pullPlatforms:       []string{"linux/amd64", "linux/riscv64", "linux/arm64/v8"},
234
-			savePlatforms:       nil,
235
-			loadPlatforms:       nil,
233
+			pullPlatforms: []ocispec.Platform{
234
+				{OS: "linux", Architecture: "amd64"},
235
+				{OS: "linux", Architecture: "riscv64"},
236
+				{OS: "linux", Architecture: "arm64", Variant: "v8"},
237
+			},
238
+			savePlatforms: nil,
239
+			loadPlatforms: nil,
236 240
 			expectedSavedPlatforms: []ocispec.Platform{
237 241
 				{OS: "linux", Architecture: "amd64"},
238 242
 				{OS: "linux", Architecture: "riscv64"},
... ...
@@ -246,16 +250,20 @@ func TestSaveAndLoadPlatform(t *testing.T) {
246 246
 		},
247 247
 		{
248 248
 			testName:                "With single pulled platform",
249
-			pullPlatforms:           []string{"linux/amd64"},
249
+			pullPlatforms:           []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
250 250
 			savePlatforms:           []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
251 251
 			loadPlatforms:           []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
252 252
 			expectedSavedPlatforms:  []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
253 253
 			expectedLoadedPlatforms: []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
254 254
 		},
255 255
 		{
256
-			testName:                "With single platform save and load",
257
-			containerdStoreOnly:     true,
258
-			pullPlatforms:           []string{"linux/amd64", "linux/riscv64", "linux/arm64/v8"},
256
+			testName:            "With single platform save and load",
257
+			containerdStoreOnly: true,
258
+			pullPlatforms: []ocispec.Platform{
259
+				{OS: "linux", Architecture: "amd64"},
260
+				{OS: "linux", Architecture: "riscv64"},
261
+				{OS: "linux", Architecture: "arm64", Variant: "v8"},
262
+			},
259 263
 			savePlatforms:           []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
260 264
 			loadPlatforms:           []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
261 265
 			expectedSavedPlatforms:  []ocispec.Platform{{OS: "linux", Architecture: "amd64"}},
... ...
@@ -264,7 +272,11 @@ func TestSaveAndLoadPlatform(t *testing.T) {
264 264
 		{
265 265
 			testName:            "With multiple platforms save and load",
266 266
 			containerdStoreOnly: true,
267
-			pullPlatforms:       []string{"linux/amd64", "linux/riscv64", "linux/arm64/v8"},
267
+			pullPlatforms: []ocispec.Platform{
268
+				{OS: "linux", Architecture: "amd64"},
269
+				{OS: "linux", Architecture: "riscv64"},
270
+				{OS: "linux", Architecture: "arm64", Variant: "v8"},
271
+			},
268 272
 			savePlatforms: []ocispec.Platform{
269 273
 				{OS: "linux", Architecture: "arm64", Variant: "v8"},
270 274
 				{OS: "linux", Architecture: "riscv64"},
... ...
@@ -285,7 +297,11 @@ func TestSaveAndLoadPlatform(t *testing.T) {
285 285
 		{
286 286
 			testName:            "With mixed platform save and load",
287 287
 			containerdStoreOnly: true,
288
-			pullPlatforms:       []string{"linux/amd64", "linux/riscv64", "linux/arm64/v8"},
288
+			pullPlatforms: []ocispec.Platform{
289
+				{OS: "linux", Architecture: "amd64"},
290
+				{OS: "linux", Architecture: "riscv64"},
291
+				{OS: "linux", Architecture: "arm64", Variant: "v8"},
292
+			},
289 293
 			savePlatforms: []ocispec.Platform{
290 294
 				{OS: "linux", Architecture: "arm64", Variant: "v8"},
291 295
 				{OS: "linux", Architecture: "riscv64"},
... ...
@@ -310,7 +326,7 @@ func TestSaveAndLoadPlatform(t *testing.T) {
310 310
 		t.Run(tc.testName, func(t *testing.T) {
311 311
 			// pull the image
312 312
 			for _, p := range tc.pullPlatforms {
313
-				resp, err := apiClient.ImagePull(ctx, repoName, client.ImagePullOptions{Platform: p})
313
+				resp, err := apiClient.ImagePull(ctx, repoName, client.ImagePullOptions{Platforms: []ocispec.Platform{p}})
314 314
 				assert.NilError(t, err)
315 315
 				_, err = io.ReadAll(resp)
316 316
 				resp.Close()
... ...
@@ -348,10 +364,10 @@ func TestSaveAndLoadPlatform(t *testing.T) {
348 348
 
349 349
 			// pull the image again (start fresh)
350 350
 			for _, p := range tc.pullPlatforms {
351
-				resp, err := apiClient.ImagePull(ctx, repoName, client.ImagePullOptions{Platform: p})
351
+				pullRes, err := apiClient.ImagePull(ctx, repoName, client.ImagePullOptions{Platforms: []ocispec.Platform{p}})
352 352
 				assert.NilError(t, err)
353
-				_, err = io.ReadAll(resp)
354
-				resp.Close()
353
+				_, err = io.ReadAll(pullRes)
354
+				_ = pullRes.Close()
355 355
 				assert.NilError(t, err)
356 356
 			}
357 357
 
... ...
@@ -8,8 +8,8 @@ import (
8 8
 	"net/http"
9 9
 	"net/url"
10 10
 	"strconv"
11
-	"strings"
12 11
 
12
+	cerrdefs "github.com/containerd/errdefs"
13 13
 	"github.com/moby/moby/api/types/container"
14 14
 	"github.com/moby/moby/api/types/network"
15 15
 )
... ...
@@ -154,8 +154,12 @@ func (cli *Client) imageBuildOptionsToQuery(_ context.Context, options ImageBuil
154 154
 	if options.SessionID != "" {
155 155
 		query.Set("session", options.SessionID)
156 156
 	}
157
-	if options.Platform != "" {
158
-		query.Set("platform", strings.ToLower(options.Platform))
157
+	if len(options.Platforms) > 0 {
158
+		if len(options.Platforms) > 1 {
159
+			// TODO(thaJeztah): update API spec and add equivalent check on the daemon. We need this still for older daemons, which would ignore it.
160
+			return query, cerrdefs.ErrInvalidArgument.WithMessage("specifying multiple platforms is not yet supported")
161
+		}
162
+		query.Set("platform", formatPlatform(options.Platforms[0]))
159 163
 	}
160 164
 	if options.BuildID != "" {
161 165
 		query.Set("buildid", options.BuildID)
... ...
@@ -6,6 +6,7 @@ import (
6 6
 	"github.com/moby/moby/api/types/build"
7 7
 	"github.com/moby/moby/api/types/container"
8 8
 	"github.com/moby/moby/api/types/registry"
9
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
9 10
 )
10 11
 
11 12
 // ImageBuildOptions holds the information
... ...
@@ -50,7 +51,9 @@ type ImageBuildOptions struct {
50 50
 	ExtraHosts  []string // List of extra hosts
51 51
 	Target      string
52 52
 	SessionID   string
53
-	Platform    string
53
+	// Platforms selects the platforms to build the image for. Multiple platforms
54
+	// can be provided if the daemon supports multi-platform builds.
55
+	Platforms []ocispec.Platform
54 56
 	// Version specifies the version of the underlying builder to use
55 57
 	Version build.BuilderVersion
56 58
 	// BuildID is an optional identifier that can be passed together with the
... ...
@@ -4,8 +4,8 @@ import (
4 4
 	"context"
5 5
 	"net/http"
6 6
 	"net/url"
7
-	"strings"
8 7
 
8
+	cerrdefs "github.com/containerd/errdefs"
9 9
 	"github.com/distribution/reference"
10 10
 	"github.com/moby/moby/api/types/registry"
11 11
 )
... ...
@@ -21,8 +21,12 @@ func (cli *Client) ImageCreate(ctx context.Context, parentReference string, opti
21 21
 	query := url.Values{}
22 22
 	query.Set("fromImage", ref.Name())
23 23
 	query.Set("tag", getAPITagFromNamedRef(ref))
24
-	if options.Platform != "" {
25
-		query.Set("platform", strings.ToLower(options.Platform))
24
+	if len(options.Platforms) > 0 {
25
+		if len(options.Platforms) > 1 {
26
+			// TODO(thaJeztah): update API spec and add equivalent check on the daemon. We need this still for older daemons, which would ignore it.
27
+			return ImageCreateResult{}, cerrdefs.ErrInvalidArgument.WithMessage("specifying multiple platforms is not yet supported")
28
+		}
29
+		query.Set("platform", formatPlatform(options.Platforms[0]))
26 30
 	}
27 31
 	resp, err := cli.tryImageCreate(ctx, query, staticAuth(options.RegistryAuth))
28 32
 	if err != nil {
... ...
@@ -1,11 +1,18 @@
1 1
 package client
2 2
 
3
-import "io"
3
+import (
4
+	"io"
5
+
6
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
7
+)
4 8
 
5 9
 // ImageCreateOptions holds information to create images.
6 10
 type ImageCreateOptions struct {
7 11
 	RegistryAuth string // RegistryAuth is the base64 encoded credentials for the registry.
8
-	Platform     string // Platform is the target platform of the image if it needs to be pulled from the registry.
12
+	// Platforms specifies the platforms to platform of the image if it needs
13
+	// to be pulled from the registry. Multiple platforms can be provided
14
+	// if the daemon supports multi-platform pulls.
15
+	Platforms []ocispec.Platform
9 16
 }
10 17
 
11 18
 // ImageCreateResult holds the response body returned by the daemon for image create.
... ...
@@ -3,7 +3,6 @@ package client
3 3
 import (
4 4
 	"context"
5 5
 	"net/url"
6
-	"strings"
7 6
 
8 7
 	"github.com/distribution/reference"
9 8
 )
... ...
@@ -31,8 +30,9 @@ func (cli *Client) ImageImport(ctx context.Context, source ImageImportSource, re
31 31
 	if options.Message != "" {
32 32
 		query.Set("message", options.Message)
33 33
 	}
34
-	if options.Platform != "" {
35
-		query.Set("platform", strings.ToLower(options.Platform))
34
+	if p := formatPlatform(options.Platform); p != "unknown" {
35
+		// TODO(thaJeztah): would we ever support mutiple platforms here? (would require multiple rootfs tars as well?)
36
+		query.Set("platform", p)
36 37
 	}
37 38
 	for _, change := range options.Changes {
38 39
 		query.Add("changes", change)
... ...
@@ -2,6 +2,8 @@ package client
2 2
 
3 3
 import (
4 4
 	"io"
5
+
6
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
5 7
 )
6 8
 
7 9
 // ImageImportSource holds source information for ImageImport
... ...
@@ -12,10 +14,10 @@ type ImageImportSource struct {
12 12
 
13 13
 // ImageImportOptions holds information to import images from the client host.
14 14
 type ImageImportOptions struct {
15
-	Tag      string   // Tag is the name to tag this image with. This attribute is deprecated.
16
-	Message  string   // Message is the message to tag the image with
17
-	Changes  []string // Changes are the raw changes to apply to this image
18
-	Platform string   // Platform is the target platform of the image
15
+	Tag      string           // Tag is the name to tag this image with. This attribute is deprecated.
16
+	Message  string           // Message is the message to tag the image with
17
+	Changes  []string         // Changes are the raw changes to apply to this image
18
+	Platform ocispec.Platform // Platform is the target platform of the image
19 19
 }
20 20
 
21 21
 // ImageImportResult holds the response body returned by the daemon for image import.
... ...
@@ -5,7 +5,6 @@ import (
5 5
 	"io"
6 6
 	"iter"
7 7
 	"net/url"
8
-	"strings"
9 8
 
10 9
 	cerrdefs "github.com/containerd/errdefs"
11 10
 	"github.com/distribution/reference"
... ...
@@ -44,10 +43,13 @@ func (cli *Client) ImagePull(ctx context.Context, refStr string, options ImagePu
44 44
 	if !options.All {
45 45
 		query.Set("tag", getAPITagFromNamedRef(ref))
46 46
 	}
47
-	if options.Platform != "" {
48
-		query.Set("platform", strings.ToLower(options.Platform))
47
+	if len(options.Platforms) > 0 {
48
+		if len(options.Platforms) > 1 {
49
+			// TODO(thaJeztah): update API spec and add equivalent check on the daemon. We need this still for older daemons, which would ignore it.
50
+			return nil, cerrdefs.ErrInvalidArgument.WithMessage("specifying multiple platforms is not yet supported")
51
+		}
52
+		query.Set("platform", formatPlatform(options.Platforms[0]))
49 53
 	}
50
-
51 54
 	resp, err := cli.tryImageCreate(ctx, query, staticAuth(options.RegistryAuth))
52 55
 	if cerrdefs.IsUnauthorized(err) && options.PrivilegeFunc != nil {
53 56
 		resp, err = cli.tryImageCreate(ctx, query, options.PrivilegeFunc)
... ...
@@ -2,6 +2,8 @@ package client
2 2
 
3 3
 import (
4 4
 	"context"
5
+
6
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
5 7
 )
6 8
 
7 9
 // ImagePullOptions holds information to pull images.
... ...
@@ -16,5 +18,8 @@ type ImagePullOptions struct {
16 16
 	//
17 17
 	// For details, refer to [github.com/moby/moby/api/types/registry.RequestAuthConfig].
18 18
 	PrivilegeFunc func(context.Context) (string, error)
19
-	Platform      string
19
+
20
+	// Platforms selects the platforms to pull. Multiple platforms can be
21
+	// specified if the image ia a multi-platform image.
22
+	Platforms []ocispec.Platform
20 23
 }