Skip to content

Commit f143f4e

Browse files
committed
image/save&load: Support Platform parameter
Add `Platform` parameter that allows to select a specific platform to save/load. This is a breaking change to the Go client as it changes the signatures of `ImageLoad` and `ImageSave`. Signed-off-by: Paweł Gronowski <[email protected]>
1 parent ab075ec commit f143f4e

20 files changed

Lines changed: 164 additions & 46 deletions

File tree

api/server/router/image/backend.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ type imageBackend interface {
3232
}
3333

3434
type importExportBackend interface {
35-
LoadImage(ctx context.Context, inTar io.ReadCloser, outStream io.Writer, quiet bool) error
35+
LoadImage(ctx context.Context, inTar io.ReadCloser, platform *ocispec.Platform, outStream io.Writer, quiet bool) error
3636
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, outStream io.Writer) error
37+
ExportImage(ctx context.Context, names []string, platform *ocispec.Platform, outStream io.Writer) error
3838
}
3939

4040
type registryBackend interface {

api/server/router/image/image_routes.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,18 @@ func (ir *imageRouter) getImagesGet(ctx context.Context, w http.ResponseWriter,
246246
names = r.Form["names"]
247247
}
248248

249-
if err := ir.backend.ExportImage(ctx, names, output); err != nil {
249+
var platform *ocispec.Platform
250+
if versions.GreaterThanOrEqualTo(httputils.VersionFromContext(ctx), "1.48") {
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
257+
}
258+
}
259+
260+
if err := ir.backend.ExportImage(ctx, names, platform, output); err != nil {
250261
if !output.Flushed() {
251262
return err
252263
}
@@ -259,13 +270,24 @@ func (ir *imageRouter) postImagesLoad(ctx context.Context, w http.ResponseWriter
259270
if err := httputils.ParseForm(r); err != nil {
260271
return err
261272
}
273+
274+
var platform *ocispec.Platform
275+
if versions.GreaterThanOrEqualTo(httputils.VersionFromContext(ctx), "1.47") {
276+
if formPlatform := r.Form.Get("platform"); formPlatform != "" {
277+
p, err := httputils.DecodePlatform(formPlatform)
278+
if err != nil {
279+
return err
280+
}
281+
platform = p
282+
}
283+
}
262284
quiet := httputils.BoolValueOrDefault(r, "quiet", true)
263285

264286
w.Header().Set("Content-Type", "application/json")
265287

266288
output := ioutils.NewWriteFlusher(w)
267289
defer output.Close()
268-
if err := ir.backend.LoadImage(ctx, r.Body, output, quiet); err != nil {
290+
if err := ir.backend.LoadImage(ctx, r.Body, platform, output, quiet); err != nil {
269291
_, _ = output.Write(streamformatter.FormatError(err))
270292
}
271293
return nil

api/swagger.yaml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9956,7 +9956,16 @@ paths:
99569956
description: "Image name or ID"
99579957
type: "string"
99589958
required: true
9959-
tags: ["Image"]
9959+
- name: "platform"
9960+
type: "string"
9961+
in: "query"
9962+
description: |
9963+
JSON encoded OCI platform describing a platform which will be used
9964+
to select a platform-specific image to be saved if the image is
9965+
multi-platform.
9966+
If not provided, the full multi-platform image will be saved.
9967+
9968+
Example: `{"os": "linux", "architecture": "arm", "variant": "v5"}`
99609969
/images/get:
99619970
get:
99629971
summary: "Export several images"
@@ -10023,6 +10032,16 @@ paths:
1002310032
description: "Suppress progress details during load."
1002410033
type: "boolean"
1002510034
default: false
10035+
- name: "platform"
10036+
type: "string"
10037+
in: "query"
10038+
description: |
10039+
JSON encoded OCI platform describing a platform which will be used
10040+
to select a platform-specific image to be load if the image is
10041+
multi-platform.
10042+
If not provided, the full multi-platform image will be loaded.
10043+
10044+
Example: `{"os": "linux", "architecture": "arm", "variant": "v5"}`
1002610045
tags: ["Image"]
1002710046
/containers/{id}/exec:
1002810047
post:

api/types/image/opts.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,18 @@ type HistoryOptions struct {
9292
// Platform from the manifest list to use for history.
9393
Platform *ocispec.Platform
9494
}
95+
96+
// LoadOptions holds parameters to load images.
97+
type LoadOptions struct {
98+
// Quiet suppresses progress output
99+
Quiet bool
100+
101+
// Platform is a specific platform to load when the image is a multi-platform
102+
Platform *ocispec.Platform
103+
}
104+
105+
// SaveOptions holds parameters to save images.
106+
type SaveOptions struct {
107+
// Platform is a specific platform to save if the image is a multi-platform image.
108+
Platform *ocispec.Platform
109+
}

client/image_load.go

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

33
import (
44
"context"
5+
"encoding/json"
56
"io"
67
"net/http"
78
"net/url"
@@ -12,12 +13,28 @@ import (
1213
// ImageLoad loads an image in the docker host from the client host.
1314
// It's up to the caller to close the io.ReadCloser in the
1415
// ImageLoadResponse returned by this function.
15-
func (cli *Client) ImageLoad(ctx context.Context, input io.Reader, quiet bool) (image.LoadResponse, error) {
16+
//
17+
// Platform is an optional parameter that specifies the platform to load from
18+
// the provided multi-platform image. This is only has effect if the input image
19+
// is a multi-platform image.
20+
func (cli *Client) ImageLoad(ctx context.Context, input io.Reader, opts image.LoadOptions) (image.LoadResponse, error) {
1621
v := url.Values{}
1722
v.Set("quiet", "0")
18-
if quiet {
23+
if opts.Quiet {
1924
v.Set("quiet", "1")
2025
}
26+
if opts.Platform != nil {
27+
if err := cli.NewVersionError(ctx, "1.48", "platform"); err != nil {
28+
return image.LoadResponse{}, err
29+
}
30+
31+
p, err := json.Marshal(*opts.Platform)
32+
if err != nil {
33+
return image.LoadResponse{}, err
34+
}
35+
v.Set("platform", string(p))
36+
}
37+
2138
resp, err := cli.postRaw(ctx, "/images/load", v, input, http.Header{
2239
"Content-Type": {"application/x-tar"},
2340
})

client/image_load_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010
"testing"
1111

12+
"github.com/docker/docker/api/types/image"
1213
"github.com/docker/docker/errdefs"
1314
"gotest.tools/v3/assert"
1415
is "gotest.tools/v3/assert/cmp"
@@ -19,7 +20,7 @@ func TestImageLoadError(t *testing.T) {
1920
client: newMockClient(errorMock(http.StatusInternalServerError, "Server error")),
2021
}
2122

22-
_, err := client.ImageLoad(context.Background(), nil, true)
23+
_, err := client.ImageLoad(context.Background(), nil, image.LoadOptions{Quiet: true})
2324
assert.Check(t, is.ErrorType(err, errdefs.IsSystem))
2425
}
2526

@@ -78,7 +79,7 @@ func TestImageLoad(t *testing.T) {
7879
}
7980

8081
input := bytes.NewReader([]byte(expectedInput))
81-
imageLoadResponse, err := client.ImageLoad(context.Background(), input, loadCase.quiet)
82+
imageLoadResponse, err := client.ImageLoad(context.Background(), input, image.LoadOptions{Quiet: loadCase.quiet})
8283
if err != nil {
8384
t.Fatal(err)
8485
}

client/image_save.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,33 @@ package client // import "github.com/docker/docker/client"
22

33
import (
44
"context"
5+
"encoding/json"
6+
"fmt"
57
"io"
68
"net/url"
9+
10+
"github.com/docker/docker/api/types/image"
711
)
812

913
// ImageSave retrieves one or more images from the docker host as an io.ReadCloser.
1014
// It's up to the caller to store the images and close the stream.
11-
func (cli *Client) ImageSave(ctx context.Context, imageIDs []string) (io.ReadCloser, error) {
15+
func (cli *Client) ImageSave(ctx context.Context, imageIDs []string, opts image.SaveOptions) (io.ReadCloser, error) {
1216
query := url.Values{
1317
"names": imageIDs,
1418
}
1519

20+
if opts.Platform != nil {
21+
if err := cli.NewVersionError(ctx, "1.48", "platform"); err != nil {
22+
return nil, err
23+
}
24+
25+
p, err := json.Marshal(*opts.Platform)
26+
if err != nil {
27+
return nil, fmt.Errorf("invalid platform: %v", err)
28+
}
29+
query.Set("platform", string(p))
30+
}
31+
1632
resp, err := cli.get(ctx, "/images/get", query, nil)
1733
if err != nil {
1834
return nil, err

client/image_save_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111
"testing"
1212

13+
"github.com/docker/docker/api/types/image"
1314
"github.com/docker/docker/errdefs"
1415
"gotest.tools/v3/assert"
1516
is "gotest.tools/v3/assert/cmp"
@@ -19,7 +20,7 @@ func TestImageSaveError(t *testing.T) {
1920
client := &Client{
2021
client: newMockClient(errorMock(http.StatusInternalServerError, "Server error")),
2122
}
22-
_, err := client.ImageSave(context.Background(), []string{"nothing"})
23+
_, err := client.ImageSave(context.Background(), []string{"nothing"}, image.SaveOptions{})
2324
assert.Check(t, is.ErrorType(err, errdefs.IsSystem))
2425
}
2526

@@ -43,7 +44,7 @@ func TestImageSave(t *testing.T) {
4344
}, nil
4445
}),
4546
}
46-
saveResponse, err := client.ImageSave(context.Background(), []string{"image_id1", "image_id2"})
47+
saveResponse, err := client.ImageSave(context.Background(), []string{"image_id1", "image_id2"}, image.SaveOptions{})
4748
if err != nil {
4849
t.Fatal(err)
4950
}

client/interface.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,12 @@ type ImageAPIClient interface {
9595
ImageImport(ctx context.Context, source image.ImportSource, ref string, options image.ImportOptions) (io.ReadCloser, error)
9696
ImageInspectWithRaw(ctx context.Context, image string) (image.InspectResponse, []byte, error)
9797
ImageList(ctx context.Context, options image.ListOptions) ([]image.Summary, error)
98-
ImageLoad(ctx context.Context, input io.Reader, quiet bool) (image.LoadResponse, error)
98+
ImageLoad(ctx context.Context, input io.Reader, opts image.LoadOptions) (image.LoadResponse, error)
9999
ImagePull(ctx context.Context, ref string, options image.PullOptions) (io.ReadCloser, error)
100100
ImagePush(ctx context.Context, ref string, options image.PushOptions) (io.ReadCloser, error)
101101
ImageRemove(ctx context.Context, image string, options image.RemoveOptions) ([]image.DeleteResponse, error)
102+
ImageSave(ctx context.Context, images []string, opts image.SaveOptions) (io.ReadCloser, error)
102103
ImageSearch(ctx context.Context, term string, options registry.SearchOptions) ([]registry.SearchResult, error)
103-
ImageSave(ctx context.Context, images []string) (io.ReadCloser, error)
104104
ImageTag(ctx context.Context, image, ref string) error
105105
ImagesPrune(ctx context.Context, pruneFilter filters.Args) (image.PruneReport, error)
106106
}

daemon/containerd/image_exporter.go

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,8 @@ func (i *ImageService) PerformWithBaseFS(ctx context.Context, c *container.Conta
4747
// outStream is the writer which the images are written to.
4848
//
4949
// TODO(thaJeztah): produce JSON stream progress response and image events; see https://github.com/moby/moby/issues/43910
50-
func (i *ImageService) ExportImage(ctx context.Context, names []string, outStream io.Writer) error {
51-
// TODO: Pass as argument
52-
var requestedPlatform *ocispec.Platform
53-
pm := i.matchRequestedOrDefault(platforms.OnlyStrict, requestedPlatform)
50+
func (i *ImageService) ExportImage(ctx context.Context, names []string, platform *ocispec.Platform, outStream io.Writer) error {
51+
pm := i.matchRequestedOrDefault(platforms.OnlyStrict, platform)
5452

5553
opts := []archive.ExportOpt{
5654
archive.WithSkipNonDistributableBlobs(),
@@ -85,7 +83,17 @@ func (i *ImageService) ExportImage(ctx context.Context, names []string, outStrea
8583
return leaseContent(ctx, i.content, leasesManager, lease, target)
8684
}
8785

88-
exportImage := func(ctx context.Context, target ocispec.Descriptor, ref reference.Named) error {
86+
exportImage := func(ctx context.Context, img containerdimages.Image, ref reference.Named) error {
87+
target := img.Target
88+
89+
if platform != nil {
90+
newTarget, err := i.getPushDescriptor(ctx, img, platform)
91+
if err != nil {
92+
return errors.Wrap(err, "no suitable export target found for platform "+platforms.FormatAll(*platform))
93+
}
94+
target = newTarget
95+
}
96+
8997
if err := addLease(ctx, target); err != nil {
9098
return err
9199
}
@@ -142,7 +150,7 @@ func (i *ImageService) ExportImage(ctx context.Context, names []string, outStrea
142150
continue
143151
}
144152

145-
if err := exportImage(ctx, img.Target, ref); err != nil {
153+
if err := exportImage(ctx, img, ref); err != nil {
146154
return err
147155
}
148156
}
@@ -151,19 +159,20 @@ func (i *ImageService) ExportImage(ctx context.Context, names []string, outStrea
151159
}
152160

153161
for _, name := range names {
154-
target, resolveErr := i.resolveDescriptor(ctx, name)
162+
img, resolveErr := i.resolveImage(ctx, name)
155163

156164
// Check if the requested name is a truncated digest of the resolved descriptor.
157165
// If yes, that means that the user specified a specific image ID so
158166
// it's not referencing a repository.
159167
specificDigestResolved := false
160168
if resolveErr == nil {
161-
nameWithoutDigestAlgorithm := strings.TrimPrefix(name, target.Digest.Algorithm().String()+":")
162-
specificDigestResolved = strings.HasPrefix(target.Digest.Encoded(), nameWithoutDigestAlgorithm)
169+
nameWithoutDigestAlgorithm := strings.TrimPrefix(name, img.Target.Digest.Algorithm().String()+":")
170+
specificDigestResolved = strings.HasPrefix(img.Target.Digest.Encoded(), nameWithoutDigestAlgorithm)
163171
}
164172

165173
log.G(ctx).WithFields(log.Fields{
166174
"name": name,
175+
"img": img,
167176
"resolveErr": resolveErr,
168177
"specificDigestResolved": specificDigestResolved,
169178
}).Debug("export requested")
@@ -199,7 +208,8 @@ func (i *ImageService) ExportImage(ctx context.Context, names []string, outStrea
199208
if specificDigestResolved {
200209
ref = nil
201210
}
202-
if err := exportImage(ctx, target, ref); err != nil {
211+
212+
if err := exportImage(ctx, img, ref); err != nil {
203213
return err
204214
}
205215
}
@@ -234,16 +244,17 @@ func leaseContent(ctx context.Context, store content.Store, leasesManager leases
234244
// LoadImage uploads a set of images into the repository. This is the
235245
// complement of ExportImage. The input stream is an uncompressed tar
236246
// ball containing images and metadata.
237-
func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, outStream io.Writer, quiet bool) error {
247+
func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platform *ocispec.Platform, outStream io.Writer, quiet bool) error {
238248
decompressed, err := dockerarchive.DecompressStream(inTar)
239249
if err != nil {
240250
return errors.Wrap(err, "failed to decompress input tar archive")
241251
}
242252
defer decompressed.Close()
243253

254+
pm := i.matchRequestedOrDefault(platforms.OnlyStrict, platform)
255+
244256
opts := []containerd.ImportOpt{
245-
// TODO(vvoland): Allow user to pass platform
246-
containerd.WithImportPlatform(platforms.All),
257+
containerd.WithImportPlatform(pm),
247258

248259
containerd.WithSkipMissing(),
249260

0 commit comments

Comments
 (0)