Browse code

client/image_(inspect,history,load,save): Wrap return values

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>

Paweł Gronowski authored on 2025/10/21 06:37:29
Showing 29 changed files
... ...
@@ -119,10 +119,10 @@ type ImageAPIClient interface {
119 119
 	ImageTag(ctx context.Context, image, ref string) error
120 120
 	ImagesPrune(ctx context.Context, opts ImagePruneOptions) (ImagePruneResult, error)
121 121
 
122
-	ImageInspect(ctx context.Context, image string, _ ...ImageInspectOption) (image.InspectResponse, error)
123
-	ImageHistory(ctx context.Context, image string, _ ...ImageHistoryOption) ([]image.HistoryResponseItem, error)
124
-	ImageLoad(ctx context.Context, input io.Reader, _ ...ImageLoadOption) (LoadResponse, error)
125
-	ImageSave(ctx context.Context, images []string, _ ...ImageSaveOption) (io.ReadCloser, error)
122
+	ImageInspect(ctx context.Context, image string, _ ...ImageInspectOption) (ImageInspectResult, error)
123
+	ImageHistory(ctx context.Context, image string, _ ...ImageHistoryOption) (ImageHistoryResult, error)
124
+	ImageLoad(ctx context.Context, input io.Reader, _ ...ImageLoadOption) (ImageLoadResult, error)
125
+	ImageSave(ctx context.Context, images []string, _ ...ImageSaveOption) (ImageSaveResult, error)
126 126
 }
127 127
 
128 128
 // NetworkAPIClient defines API client methods for the networks
... ...
@@ -6,7 +6,6 @@ import (
6 6
 	"fmt"
7 7
 	"net/url"
8 8
 
9
-	"github.com/moby/moby/api/types/image"
10 9
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
11 10
 )
12 11
 
... ...
@@ -22,24 +21,24 @@ func ImageHistoryWithPlatform(platform ocispec.Platform) ImageHistoryOption {
22 22
 }
23 23
 
24 24
 // ImageHistory returns the changes in an image in history format.
25
-func (cli *Client) ImageHistory(ctx context.Context, imageID string, historyOpts ...ImageHistoryOption) ([]image.HistoryResponseItem, error) {
25
+func (cli *Client) ImageHistory(ctx context.Context, imageID string, historyOpts ...ImageHistoryOption) (ImageHistoryResult, error) {
26 26
 	query := url.Values{}
27 27
 
28 28
 	var opts imageHistoryOpts
29 29
 	for _, o := range historyOpts {
30 30
 		if err := o.Apply(&opts); err != nil {
31
-			return nil, err
31
+			return ImageHistoryResult{}, err
32 32
 		}
33 33
 	}
34 34
 
35 35
 	if opts.apiOptions.Platform != nil {
36 36
 		if err := cli.NewVersionError(ctx, "1.48", "platform"); err != nil {
37
-			return nil, err
37
+			return ImageHistoryResult{}, err
38 38
 		}
39 39
 
40 40
 		p, err := encodePlatform(opts.apiOptions.Platform)
41 41
 		if err != nil {
42
-			return nil, err
42
+			return ImageHistoryResult{}, err
43 43
 		}
44 44
 		query.Set("platform", p)
45 45
 	}
... ...
@@ -47,10 +46,10 @@ func (cli *Client) ImageHistory(ctx context.Context, imageID string, historyOpts
47 47
 	resp, err := cli.get(ctx, "/images/"+imageID+"/history", query, nil)
48 48
 	defer ensureReaderClosed(resp)
49 49
 	if err != nil {
50
-		return nil, err
50
+		return ImageHistoryResult{}, err
51 51
 	}
52 52
 
53
-	var history []image.HistoryResponseItem
54
-	err = json.NewDecoder(resp.Body).Decode(&history)
53
+	var history ImageHistoryResult
54
+	err = json.NewDecoder(resp.Body).Decode(&history.Items)
55 55
 	return history, err
56 56
 }
... ...
@@ -1,6 +1,9 @@
1 1
 package client
2 2
 
3
-import ocispec "github.com/opencontainers/image-spec/specs-go/v1"
3
+import (
4
+	"github.com/moby/moby/api/types/image"
5
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
6
+)
4 7
 
5 8
 // ImageHistoryOption is a type representing functional options for the image history operation.
6 9
 type ImageHistoryOption interface {
... ...
@@ -20,3 +23,7 @@ type imageHistoryOptions struct {
20 20
 	// Platform from the manifest list to use for history.
21 21
 	Platform *ocispec.Platform
22 22
 }
23
+
24
+type ImageHistoryResult struct {
25
+	Items []image.HistoryResponseItem
26
+}
... ...
@@ -36,16 +36,17 @@ func TestImageHistory(t *testing.T) {
36 36
 		}, nil
37 37
 	}))
38 38
 	assert.NilError(t, err)
39
-	expected := []image.HistoryResponseItem{
40
-		{
41
-			ID:   "image_id1",
42
-			Tags: []string{"tag1", "tag2"},
43
-		},
44
-		{
45
-			ID:   "image_id2",
46
-			Tags: []string{"tag1", "tag2"},
47
-		},
48
-	}
39
+	expected := ImageHistoryResult{
40
+		Items: []image.HistoryResponseItem{
41
+			{
42
+				ID:   "image_id1",
43
+				Tags: []string{"tag1", "tag2"},
44
+			},
45
+			{
46
+				ID:   "image_id2",
47
+				Tags: []string{"tag1", "tag2"},
48
+			},
49
+		}}
49 50
 
50 51
 	imageHistories, err := client.ImageHistory(context.Background(), "image_id", ImageHistoryWithPlatform(ocispec.Platform{
51 52
 		Architecture: "arm64",
... ...
@@ -7,38 +7,36 @@ import (
7 7
 	"fmt"
8 8
 	"io"
9 9
 	"net/url"
10
-
11
-	"github.com/moby/moby/api/types/image"
12 10
 )
13 11
 
14 12
 // ImageInspect returns the image information.
15
-func (cli *Client) ImageInspect(ctx context.Context, imageID string, inspectOpts ...ImageInspectOption) (image.InspectResponse, error) {
13
+func (cli *Client) ImageInspect(ctx context.Context, imageID string, inspectOpts ...ImageInspectOption) (ImageInspectResult, error) {
16 14
 	if imageID == "" {
17
-		return image.InspectResponse{}, objectNotFoundError{object: "image", id: imageID}
15
+		return ImageInspectResult{}, objectNotFoundError{object: "image", id: imageID}
18 16
 	}
19 17
 
20 18
 	var opts imageInspectOpts
21 19
 	for _, opt := range inspectOpts {
22 20
 		if err := opt.Apply(&opts); err != nil {
23
-			return image.InspectResponse{}, fmt.Errorf("error applying image inspect option: %w", err)
21
+			return ImageInspectResult{}, fmt.Errorf("error applying image inspect option: %w", err)
24 22
 		}
25 23
 	}
26 24
 
27 25
 	query := url.Values{}
28 26
 	if opts.apiOptions.Manifests {
29 27
 		if err := cli.NewVersionError(ctx, "1.48", "manifests"); err != nil {
30
-			return image.InspectResponse{}, err
28
+			return ImageInspectResult{}, err
31 29
 		}
32 30
 		query.Set("manifests", "1")
33 31
 	}
34 32
 
35 33
 	if opts.apiOptions.Platform != nil {
36 34
 		if err := cli.NewVersionError(ctx, "1.49", "platform"); err != nil {
37
-			return image.InspectResponse{}, err
35
+			return ImageInspectResult{}, err
38 36
 		}
39 37
 		platform, err := encodePlatform(opts.apiOptions.Platform)
40 38
 		if err != nil {
41
-			return image.InspectResponse{}, err
39
+			return ImageInspectResult{}, err
42 40
 		}
43 41
 		query.Set("platform", platform)
44 42
 	}
... ...
@@ -46,7 +44,7 @@ func (cli *Client) ImageInspect(ctx context.Context, imageID string, inspectOpts
46 46
 	resp, err := cli.get(ctx, "/images/"+imageID+"/json", query, nil)
47 47
 	defer ensureReaderClosed(resp)
48 48
 	if err != nil {
49
-		return image.InspectResponse{}, err
49
+		return ImageInspectResult{}, err
50 50
 	}
51 51
 
52 52
 	buf := opts.raw
... ...
@@ -55,10 +53,10 @@ func (cli *Client) ImageInspect(ctx context.Context, imageID string, inspectOpts
55 55
 	}
56 56
 
57 57
 	if _, err := io.Copy(buf, resp.Body); err != nil {
58
-		return image.InspectResponse{}, err
58
+		return ImageInspectResult{}, err
59 59
 	}
60 60
 
61
-	var response image.InspectResponse
61
+	var response ImageInspectResult
62 62
 	err = json.Unmarshal(buf.Bytes(), &response)
63 63
 	return response, err
64 64
 }
... ...
@@ -3,6 +3,7 @@ package client
3 3
 import (
4 4
 	"bytes"
5 5
 
6
+	"github.com/moby/moby/api/types/image"
6 7
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
7 8
 )
8 9
 
... ...
@@ -62,3 +63,7 @@ type imageInspectOptions struct {
62 62
 	// This option is only available for API version 1.49 and up.
63 63
 	Platform *ocispec.Platform
64 64
 }
65
+
66
+type ImageInspectResult struct {
67
+	image.InspectResponse
68
+}
... ...
@@ -9,16 +9,16 @@ import (
9 9
 
10 10
 // ImageLoad loads an image in the docker host from the client host.
11 11
 // It's up to the caller to close the [io.ReadCloser] in the
12
-// [image.LoadResponse] returned by this function.
12
+// [ImageLoadResult] returned by this function.
13 13
 //
14 14
 // Platform is an optional parameter that specifies the platform to load from
15 15
 // the provided multi-platform image. Passing a platform only has an effect
16 16
 // if the input image is a multi-platform image.
17
-func (cli *Client) ImageLoad(ctx context.Context, input io.Reader, loadOpts ...ImageLoadOption) (LoadResponse, error) {
17
+func (cli *Client) ImageLoad(ctx context.Context, input io.Reader, loadOpts ...ImageLoadOption) (ImageLoadResult, error) {
18 18
 	var opts imageLoadOpts
19 19
 	for _, opt := range loadOpts {
20 20
 		if err := opt.Apply(&opts); err != nil {
21
-			return LoadResponse{}, err
21
+			return ImageLoadResult{}, err
22 22
 		}
23 23
 	}
24 24
 
... ...
@@ -29,12 +29,12 @@ func (cli *Client) ImageLoad(ctx context.Context, input io.Reader, loadOpts ...I
29 29
 	}
30 30
 	if len(opts.apiOptions.Platforms) > 0 {
31 31
 		if err := cli.NewVersionError(ctx, "1.48", "platform"); err != nil {
32
-			return LoadResponse{}, err
32
+			return ImageLoadResult{}, err
33 33
 		}
34 34
 
35 35
 		p, err := encodePlatforms(opts.apiOptions.Platforms...)
36 36
 		if err != nil {
37
-			return LoadResponse{}, err
37
+			return ImageLoadResult{}, err
38 38
 		}
39 39
 		query["platform"] = p
40 40
 	}
... ...
@@ -43,10 +43,10 @@ func (cli *Client) ImageLoad(ctx context.Context, input io.Reader, loadOpts ...I
43 43
 		"Content-Type": {"application/x-tar"},
44 44
 	})
45 45
 	if err != nil {
46
-		return LoadResponse{}, err
46
+		return ImageLoadResult{}, err
47 47
 	}
48
-	return LoadResponse{
49
-		Body: resp.Body,
48
+	return ImageLoadResult{
49
+		body: resp.Body,
50 50
 		JSON: resp.Header.Get("Content-Type") == "application/json",
51 51
 	}, nil
52 52
 }
... ...
@@ -73,8 +73,19 @@ func (cli *Client) ImageLoad(ctx context.Context, input io.Reader, loadOpts ...I
73 73
 //
74 74
 // We should deprecated the "quiet" option, as it's really a client
75 75
 // responsibility.
76
-type LoadResponse struct {
76
+type ImageLoadResult struct {
77 77
 	// Body must be closed to avoid a resource leak
78
-	Body io.ReadCloser
78
+	body io.ReadCloser
79 79
 	JSON bool
80 80
 }
81
+
82
+func (r ImageLoadResult) Read(p []byte) (n int, err error) {
83
+	return r.body.Read(p)
84
+}
85
+
86
+func (r ImageLoadResult) Close() error {
87
+	if r.body == nil {
88
+		return nil
89
+	}
90
+	return r.body.Close()
91
+}
... ...
@@ -101,7 +101,7 @@ func TestImageLoad(t *testing.T) {
101 101
 			assert.NilError(t, err)
102 102
 			assert.Check(t, is.Equal(imageLoadResponse.JSON, tc.expectedResponseJSON))
103 103
 
104
-			body, err := io.ReadAll(imageLoadResponse.Body)
104
+			body, err := io.ReadAll(imageLoadResponse)
105 105
 			assert.NilError(t, err)
106 106
 			assert.Check(t, is.Equal(string(body), expectedOutput))
107 107
 		})
... ...
@@ -2,21 +2,20 @@ package client
2 2
 
3 3
 import (
4 4
 	"context"
5
-	"io"
6 5
 	"net/url"
7 6
 )
8 7
 
9 8
 // ImageSave retrieves one or more images from the docker host as an
10
-// [io.ReadCloser].
9
+// [ImageSaveResult].
11 10
 //
12 11
 // Platforms is an optional parameter that specifies the platforms to save
13 12
 // from the image. Passing a platform only has an effect if the input image
14 13
 // is a multi-platform image.
15
-func (cli *Client) ImageSave(ctx context.Context, imageIDs []string, saveOpts ...ImageSaveOption) (io.ReadCloser, error) {
14
+func (cli *Client) ImageSave(ctx context.Context, imageIDs []string, saveOpts ...ImageSaveOption) (ImageSaveResult, error) {
16 15
 	var opts imageSaveOpts
17 16
 	for _, opt := range saveOpts {
18 17
 		if err := opt.Apply(&opts); err != nil {
19
-			return nil, err
18
+			return ImageSaveResult{}, err
20 19
 		}
21 20
 	}
22 21
 
... ...
@@ -26,18 +25,18 @@ func (cli *Client) ImageSave(ctx context.Context, imageIDs []string, saveOpts ..
26 26
 
27 27
 	if len(opts.apiOptions.Platforms) > 0 {
28 28
 		if err := cli.NewVersionError(ctx, "1.48", "platform"); err != nil {
29
-			return nil, err
29
+			return ImageSaveResult{}, err
30 30
 		}
31 31
 		p, err := encodePlatforms(opts.apiOptions.Platforms...)
32 32
 		if err != nil {
33
-			return nil, err
33
+			return ImageSaveResult{}, err
34 34
 		}
35 35
 		query["platform"] = p
36 36
 	}
37 37
 
38 38
 	resp, err := cli.get(ctx, "/images/get", query, nil)
39 39
 	if err != nil {
40
-		return nil, err
40
+		return ImageSaveResult{}, err
41 41
 	}
42
-	return resp.Body, nil
42
+	return newImageSaveResult(resp.Body), nil
43 43
 }
... ...
@@ -2,6 +2,8 @@ package client
2 2
 
3 3
 import (
4 4
 	"fmt"
5
+	"io"
6
+	"sync"
5 7
 
6 8
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
7 9
 )
... ...
@@ -36,3 +38,34 @@ type imageSaveOptions struct {
36 36
 	// multi-platform image and has multiple variants.
37 37
 	Platforms []ocispec.Platform
38 38
 }
39
+
40
+func newImageSaveResult(rc io.ReadCloser) ImageSaveResult {
41
+	if rc == nil {
42
+		panic("nil io.ReadCloser")
43
+	}
44
+	return ImageSaveResult{
45
+		rc:    rc,
46
+		close: sync.OnceValue(rc.Close),
47
+	}
48
+}
49
+
50
+type ImageSaveResult struct {
51
+	rc    io.ReadCloser
52
+	close func() error
53
+}
54
+
55
+// Read implements io.ReadCloser
56
+func (r ImageSaveResult) Read(p []byte) (n int, err error) {
57
+	if r.rc == nil {
58
+		return 0, io.EOF
59
+	}
60
+	return r.rc.Read(p)
61
+}
62
+
63
+// Close implements io.ReadCloser
64
+func (r ImageSaveResult) Close() error {
65
+	if r.close == nil {
66
+		return nil
67
+	}
68
+	return r.close()
69
+}
... ...
@@ -111,6 +111,6 @@ func TestBuildSquashParent(t *testing.T) {
111 111
 
112 112
 	inspect, err = apiClient.ImageInspect(ctx, name)
113 113
 	assert.NilError(t, err)
114
-	assert.Check(t, is.Len(testHistory, len(origHistory)+1))
114
+	assert.Check(t, is.Len(testHistory.Items, len(origHistory.Items)+1))
115 115
 	assert.Check(t, is.Len(inspect.RootFS.Layers, 2))
116 116
 }
... ...
@@ -105,9 +105,9 @@ func TestBuildUserNamespaceValidateCapabilitiesAreV2(t *testing.T) {
105 105
 	tarReader := bufio.NewReader(tarFile)
106 106
 	loadResp, err := clientNoUserRemap.ImageLoad(ctx, tarReader)
107 107
 	assert.NilError(t, err, "failed to load image tar file")
108
-	defer loadResp.Body.Close()
108
+	defer loadResp.Close()
109 109
 	buf = bytes.NewBuffer(nil)
110
-	err = jsonmessage.DisplayJSONMessagesStream(loadResp.Body, buf, 0, false, nil)
110
+	err = jsonmessage.DisplayJSONMessagesStream(loadResp, buf, 0, false, nil)
111 111
 	assert.NilError(t, err)
112 112
 
113 113
 	cid := container.Run(ctx, t, clientNoUserRemap,
... ...
@@ -148,8 +148,8 @@ func TestMigrateSaveLoad(t *testing.T) {
148 148
 	// Import
149 149
 	lr, err := apiClient.ImageLoad(ctx, bytes.NewReader(buf.Bytes()), client.ImageLoadWithQuiet(true))
150 150
 	assert.NilError(t, err)
151
-	io.Copy(io.Discard, lr.Body)
152
-	lr.Body.Close()
151
+	io.Copy(io.Discard, lr)
152
+	lr.Close()
153 153
 
154 154
 	result := container.RunAttach(ctx, t, apiClient, func(c *container.TestContainerConfig) {
155 155
 		c.Name = "Migration-save-load-" + snapshotter
... ...
@@ -25,13 +25,13 @@ func TestAPIImagesHistory(t *testing.T) {
25 25
 
26 26
 	imgID := build.Do(ctx, t, client, fakecontext.New(t, t.TempDir(), fakecontext.WithDockerfile(dockerfile)))
27 27
 
28
-	historydata, err := client.ImageHistory(ctx, imgID)
28
+	res, err := client.ImageHistory(ctx, imgID)
29 29
 	assert.NilError(t, err)
30 30
 
31
-	assert.Assert(t, len(historydata) != 0)
31
+	assert.Assert(t, len(res.Items) != 0)
32 32
 
33 33
 	var found bool
34
-	for _, imageLayer := range historydata {
34
+	for _, imageLayer := range res.Items {
35 35
 		if imageLayer.ID == imgID {
36 36
 			found = true
37 37
 			break
... ...
@@ -107,20 +107,20 @@ func TestAPIImageHistoryCrossPlatform(t *testing.T) {
107 107
 		t.Run(tc.name, func(t *testing.T) {
108 108
 			ctx := testutil.StartSpan(ctx, t)
109 109
 
110
-			hist, err := apiClient.ImageHistory(ctx, tc.imageRef, tc.options...)
110
+			res, err := apiClient.ImageHistory(ctx, tc.imageRef, tc.options...)
111 111
 
112 112
 			assert.NilError(t, err)
113 113
 			found := false
114
-			for _, layer := range hist {
114
+			for _, layer := range res.Items {
115 115
 				if layer.ID == imgID {
116 116
 					found = true
117 117
 					break
118 118
 				}
119 119
 			}
120 120
 			assert.Assert(t, found, "History should contain the built image ID")
121
-			assert.Assert(t, is.Len(hist, 3))
121
+			assert.Assert(t, is.Len(res.Items, 3))
122 122
 
123
-			for i, layer := range hist {
123
+			for i, layer := range res.Items {
124 124
 				assert.Assert(t, layer.Size >= 0, "Layer %d should not have negative size", i)
125 125
 			}
126 126
 		})
... ...
@@ -145,29 +145,29 @@ func TestPruneDontDeleteUsedImage(t *testing.T) {
145 145
 	} {
146 146
 		for _, tc := range []struct {
147 147
 			name    string
148
-			imageID func(t *testing.T, inspect image.InspectResponse) string
148
+			imageID func(t *testing.T, inspect client.ImageInspectResult) string
149 149
 		}{
150 150
 			{
151 151
 				name: "full id",
152
-				imageID: func(t *testing.T, inspect image.InspectResponse) string {
152
+				imageID: func(t *testing.T, inspect client.ImageInspectResult) string {
153 153
 					return inspect.ID
154 154
 				},
155 155
 			},
156 156
 			{
157 157
 				name: "full id without sha256 prefix",
158
-				imageID: func(t *testing.T, inspect image.InspectResponse) string {
158
+				imageID: func(t *testing.T, inspect client.ImageInspectResult) string {
159 159
 					return strings.TrimPrefix(inspect.ID, "sha256:")
160 160
 				},
161 161
 			},
162 162
 			{
163 163
 				name: "truncated id (without sha256 prefix)",
164
-				imageID: func(t *testing.T, inspect image.InspectResponse) string {
164
+				imageID: func(t *testing.T, inspect client.ImageInspectResult) string {
165 165
 					return strings.TrimPrefix(inspect.ID, "sha256:")[:8]
166 166
 				},
167 167
 			},
168 168
 			{
169 169
 				name: "repo and digest without tag",
170
-				imageID: func(t *testing.T, inspect image.InspectResponse) string {
170
+				imageID: func(t *testing.T, inspect client.ImageInspectResult) string {
171 171
 					skip.If(t, !testEnv.UsingSnapshotter())
172 172
 
173 173
 					return "busybox@" + inspect.ID
... ...
@@ -175,7 +175,7 @@ func TestPruneDontDeleteUsedImage(t *testing.T) {
175 175
 			},
176 176
 			{
177 177
 				name: "tagged and digested",
178
-				imageID: func(t *testing.T, inspect image.InspectResponse) string {
178
+				imageID: func(t *testing.T, inspect client.ImageInspectResult) string {
179 179
 					skip.If(t, !testEnv.UsingSnapshotter())
180 180
 
181 181
 					return "busybox:latest@" + inspect.ID
... ...
@@ -183,7 +183,7 @@ func TestPruneDontDeleteUsedImage(t *testing.T) {
183 183
 			},
184 184
 			{
185 185
 				name: "repo digest",
186
-				imageID: func(t *testing.T, inspect image.InspectResponse) string {
186
+				imageID: func(t *testing.T, inspect client.ImageInspectResult) string {
187 187
 					// graphdriver won't have a repo digest
188 188
 					skip.If(t, len(inspect.RepoDigests) == 0, "no repo digest")
189 189
 
... ...
@@ -94,7 +94,7 @@ func TestRemoveByDigest(t *testing.T) {
94 94
 
95 95
 	inspect, err = apiClient.ImageInspect(ctx, "test-remove-by-digest")
96 96
 	assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
97
-	assert.Check(t, is.DeepEqual(inspect, image.InspectResponse{}))
97
+	assert.Check(t, is.DeepEqual(inspect, client.ImageInspectResult{}))
98 98
 }
99 99
 
100 100
 func TestRemoveWithPlatform(t *testing.T) {
... ...
@@ -328,8 +328,8 @@ func TestSaveAndLoadPlatform(t *testing.T) {
328 328
 			// load the full exported image (all platforms in it)
329 329
 			resp, err := apiClient.ImageLoad(ctx, rdr)
330 330
 			assert.NilError(t, err)
331
-			_, err = io.ReadAll(resp.Body)
332
-			resp.Body.Close()
331
+			_, err = io.ReadAll(resp)
332
+			resp.Close()
333 333
 			assert.NilError(t, err)
334 334
 
335 335
 			rdr.Close()
... ...
@@ -366,8 +366,8 @@ func TestSaveAndLoadPlatform(t *testing.T) {
366 366
 			// load the exported image on the specified platforms only
367 367
 			resp, err = apiClient.ImageLoad(ctx, rdr, client.ImageLoadWithPlatforms(tc.loadPlatforms...))
368 368
 			assert.NilError(t, err)
369
-			_, err = io.ReadAll(resp.Body)
370
-			resp.Body.Close()
369
+			_, err = io.ReadAll(resp)
370
+			resp.Close()
371 371
 			assert.NilError(t, err)
372 372
 
373 373
 			rdr.Close()
... ...
@@ -30,10 +30,10 @@ func Load(ctx context.Context, t *testing.T, apiClient client.APIClient, imageFu
30 30
 	resp, err := apiClient.ImageLoad(ctx, rc, client.ImageLoadWithQuiet(true))
31 31
 	assert.NilError(t, err, "Failed to load dangling image")
32 32
 
33
-	defer resp.Body.Close()
33
+	defer resp.Close()
34 34
 
35 35
 	if !assert.Check(t, err) {
36
-		respBody, err := io.ReadAll(resp.Body)
36
+		respBody, err := io.ReadAll(resp)
37 37
 		if err != nil {
38 38
 			t.Fatalf("Failed to read response body: %v", err)
39 39
 			return ""
... ...
@@ -41,7 +41,7 @@ func Load(ctx context.Context, t *testing.T, apiClient client.APIClient, imageFu
41 41
 		t.Fatalf("Failed load: %s", string(respBody))
42 42
 	}
43 43
 
44
-	all, err := io.ReadAll(resp.Body)
44
+	all, err := io.ReadAll(resp)
45 45
 	assert.NilError(t, err)
46 46
 
47 47
 	decoder := json.NewDecoder(bytes.NewReader(all))
... ...
@@ -448,7 +448,7 @@ func imageLoad(ctx context.Context, apiClient client.APIClient, path string) err
448 448
 	if err != nil {
449 449
 		return err
450 450
 	}
451
-	defer response.Body.Close()
451
+	defer response.Close()
452 452
 	return nil
453 453
 }
454 454
 
... ...
@@ -886,7 +886,7 @@ func (d *Daemon) LoadImage(ctx context.Context, t testing.TB, img string) {
886 886
 
887 887
 	resp, err := c.ImageLoad(ctx, reader, client.ImageLoadWithQuiet(true))
888 888
 	assert.NilError(t, err, "[%s] failed to load %s", d.id, img)
889
-	defer resp.Body.Close()
889
+	defer resp.Close()
890 890
 }
891 891
 
892 892
 func (d *Daemon) getClientConfig() (*clientConfig, error) {
... ...
@@ -114,9 +114,9 @@ func loadFrozenImages(ctx context.Context, apiClient client.APIClient) error {
114 114
 	if err != nil {
115 115
 		return errors.Wrap(err, "failed to load frozen images")
116 116
 	}
117
-	defer resp.Body.Close()
117
+	defer resp.Close()
118 118
 	fd, isTerminal := term.GetFdInfo(os.Stdout)
119
-	return jsonmessage.DisplayJSONMessagesStream(resp.Body, os.Stdout, fd, isTerminal, nil)
119
+	return jsonmessage.DisplayJSONMessagesStream(resp, os.Stdout, fd, isTerminal, nil)
120 120
 }
121 121
 
122 122
 func pullImages(ctx context.Context, client client.APIClient, images []string) error {
... ...
@@ -119,10 +119,10 @@ type ImageAPIClient interface {
119 119
 	ImageTag(ctx context.Context, image, ref string) error
120 120
 	ImagesPrune(ctx context.Context, opts ImagePruneOptions) (ImagePruneResult, error)
121 121
 
122
-	ImageInspect(ctx context.Context, image string, _ ...ImageInspectOption) (image.InspectResponse, error)
123
-	ImageHistory(ctx context.Context, image string, _ ...ImageHistoryOption) ([]image.HistoryResponseItem, error)
124
-	ImageLoad(ctx context.Context, input io.Reader, _ ...ImageLoadOption) (LoadResponse, error)
125
-	ImageSave(ctx context.Context, images []string, _ ...ImageSaveOption) (io.ReadCloser, error)
122
+	ImageInspect(ctx context.Context, image string, _ ...ImageInspectOption) (ImageInspectResult, error)
123
+	ImageHistory(ctx context.Context, image string, _ ...ImageHistoryOption) (ImageHistoryResult, error)
124
+	ImageLoad(ctx context.Context, input io.Reader, _ ...ImageLoadOption) (ImageLoadResult, error)
125
+	ImageSave(ctx context.Context, images []string, _ ...ImageSaveOption) (ImageSaveResult, error)
126 126
 }
127 127
 
128 128
 // NetworkAPIClient defines API client methods for the networks
... ...
@@ -6,7 +6,6 @@ import (
6 6
 	"fmt"
7 7
 	"net/url"
8 8
 
9
-	"github.com/moby/moby/api/types/image"
10 9
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
11 10
 )
12 11
 
... ...
@@ -22,24 +21,24 @@ func ImageHistoryWithPlatform(platform ocispec.Platform) ImageHistoryOption {
22 22
 }
23 23
 
24 24
 // ImageHistory returns the changes in an image in history format.
25
-func (cli *Client) ImageHistory(ctx context.Context, imageID string, historyOpts ...ImageHistoryOption) ([]image.HistoryResponseItem, error) {
25
+func (cli *Client) ImageHistory(ctx context.Context, imageID string, historyOpts ...ImageHistoryOption) (ImageHistoryResult, error) {
26 26
 	query := url.Values{}
27 27
 
28 28
 	var opts imageHistoryOpts
29 29
 	for _, o := range historyOpts {
30 30
 		if err := o.Apply(&opts); err != nil {
31
-			return nil, err
31
+			return ImageHistoryResult{}, err
32 32
 		}
33 33
 	}
34 34
 
35 35
 	if opts.apiOptions.Platform != nil {
36 36
 		if err := cli.NewVersionError(ctx, "1.48", "platform"); err != nil {
37
-			return nil, err
37
+			return ImageHistoryResult{}, err
38 38
 		}
39 39
 
40 40
 		p, err := encodePlatform(opts.apiOptions.Platform)
41 41
 		if err != nil {
42
-			return nil, err
42
+			return ImageHistoryResult{}, err
43 43
 		}
44 44
 		query.Set("platform", p)
45 45
 	}
... ...
@@ -47,10 +46,10 @@ func (cli *Client) ImageHistory(ctx context.Context, imageID string, historyOpts
47 47
 	resp, err := cli.get(ctx, "/images/"+imageID+"/history", query, nil)
48 48
 	defer ensureReaderClosed(resp)
49 49
 	if err != nil {
50
-		return nil, err
50
+		return ImageHistoryResult{}, err
51 51
 	}
52 52
 
53
-	var history []image.HistoryResponseItem
54
-	err = json.NewDecoder(resp.Body).Decode(&history)
53
+	var history ImageHistoryResult
54
+	err = json.NewDecoder(resp.Body).Decode(&history.Items)
55 55
 	return history, err
56 56
 }
... ...
@@ -1,6 +1,9 @@
1 1
 package client
2 2
 
3
-import ocispec "github.com/opencontainers/image-spec/specs-go/v1"
3
+import (
4
+	"github.com/moby/moby/api/types/image"
5
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
6
+)
4 7
 
5 8
 // ImageHistoryOption is a type representing functional options for the image history operation.
6 9
 type ImageHistoryOption interface {
... ...
@@ -20,3 +23,7 @@ type imageHistoryOptions struct {
20 20
 	// Platform from the manifest list to use for history.
21 21
 	Platform *ocispec.Platform
22 22
 }
23
+
24
+type ImageHistoryResult struct {
25
+	Items []image.HistoryResponseItem
26
+}
... ...
@@ -7,38 +7,36 @@ import (
7 7
 	"fmt"
8 8
 	"io"
9 9
 	"net/url"
10
-
11
-	"github.com/moby/moby/api/types/image"
12 10
 )
13 11
 
14 12
 // ImageInspect returns the image information.
15
-func (cli *Client) ImageInspect(ctx context.Context, imageID string, inspectOpts ...ImageInspectOption) (image.InspectResponse, error) {
13
+func (cli *Client) ImageInspect(ctx context.Context, imageID string, inspectOpts ...ImageInspectOption) (ImageInspectResult, error) {
16 14
 	if imageID == "" {
17
-		return image.InspectResponse{}, objectNotFoundError{object: "image", id: imageID}
15
+		return ImageInspectResult{}, objectNotFoundError{object: "image", id: imageID}
18 16
 	}
19 17
 
20 18
 	var opts imageInspectOpts
21 19
 	for _, opt := range inspectOpts {
22 20
 		if err := opt.Apply(&opts); err != nil {
23
-			return image.InspectResponse{}, fmt.Errorf("error applying image inspect option: %w", err)
21
+			return ImageInspectResult{}, fmt.Errorf("error applying image inspect option: %w", err)
24 22
 		}
25 23
 	}
26 24
 
27 25
 	query := url.Values{}
28 26
 	if opts.apiOptions.Manifests {
29 27
 		if err := cli.NewVersionError(ctx, "1.48", "manifests"); err != nil {
30
-			return image.InspectResponse{}, err
28
+			return ImageInspectResult{}, err
31 29
 		}
32 30
 		query.Set("manifests", "1")
33 31
 	}
34 32
 
35 33
 	if opts.apiOptions.Platform != nil {
36 34
 		if err := cli.NewVersionError(ctx, "1.49", "platform"); err != nil {
37
-			return image.InspectResponse{}, err
35
+			return ImageInspectResult{}, err
38 36
 		}
39 37
 		platform, err := encodePlatform(opts.apiOptions.Platform)
40 38
 		if err != nil {
41
-			return image.InspectResponse{}, err
39
+			return ImageInspectResult{}, err
42 40
 		}
43 41
 		query.Set("platform", platform)
44 42
 	}
... ...
@@ -46,7 +44,7 @@ func (cli *Client) ImageInspect(ctx context.Context, imageID string, inspectOpts
46 46
 	resp, err := cli.get(ctx, "/images/"+imageID+"/json", query, nil)
47 47
 	defer ensureReaderClosed(resp)
48 48
 	if err != nil {
49
-		return image.InspectResponse{}, err
49
+		return ImageInspectResult{}, err
50 50
 	}
51 51
 
52 52
 	buf := opts.raw
... ...
@@ -55,10 +53,10 @@ func (cli *Client) ImageInspect(ctx context.Context, imageID string, inspectOpts
55 55
 	}
56 56
 
57 57
 	if _, err := io.Copy(buf, resp.Body); err != nil {
58
-		return image.InspectResponse{}, err
58
+		return ImageInspectResult{}, err
59 59
 	}
60 60
 
61
-	var response image.InspectResponse
61
+	var response ImageInspectResult
62 62
 	err = json.Unmarshal(buf.Bytes(), &response)
63 63
 	return response, err
64 64
 }
... ...
@@ -3,6 +3,7 @@ package client
3 3
 import (
4 4
 	"bytes"
5 5
 
6
+	"github.com/moby/moby/api/types/image"
6 7
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
7 8
 )
8 9
 
... ...
@@ -62,3 +63,7 @@ type imageInspectOptions struct {
62 62
 	// This option is only available for API version 1.49 and up.
63 63
 	Platform *ocispec.Platform
64 64
 }
65
+
66
+type ImageInspectResult struct {
67
+	image.InspectResponse
68
+}
... ...
@@ -9,16 +9,16 @@ import (
9 9
 
10 10
 // ImageLoad loads an image in the docker host from the client host.
11 11
 // It's up to the caller to close the [io.ReadCloser] in the
12
-// [image.LoadResponse] returned by this function.
12
+// [ImageLoadResult] returned by this function.
13 13
 //
14 14
 // Platform is an optional parameter that specifies the platform to load from
15 15
 // the provided multi-platform image. Passing a platform only has an effect
16 16
 // if the input image is a multi-platform image.
17
-func (cli *Client) ImageLoad(ctx context.Context, input io.Reader, loadOpts ...ImageLoadOption) (LoadResponse, error) {
17
+func (cli *Client) ImageLoad(ctx context.Context, input io.Reader, loadOpts ...ImageLoadOption) (ImageLoadResult, error) {
18 18
 	var opts imageLoadOpts
19 19
 	for _, opt := range loadOpts {
20 20
 		if err := opt.Apply(&opts); err != nil {
21
-			return LoadResponse{}, err
21
+			return ImageLoadResult{}, err
22 22
 		}
23 23
 	}
24 24
 
... ...
@@ -29,12 +29,12 @@ func (cli *Client) ImageLoad(ctx context.Context, input io.Reader, loadOpts ...I
29 29
 	}
30 30
 	if len(opts.apiOptions.Platforms) > 0 {
31 31
 		if err := cli.NewVersionError(ctx, "1.48", "platform"); err != nil {
32
-			return LoadResponse{}, err
32
+			return ImageLoadResult{}, err
33 33
 		}
34 34
 
35 35
 		p, err := encodePlatforms(opts.apiOptions.Platforms...)
36 36
 		if err != nil {
37
-			return LoadResponse{}, err
37
+			return ImageLoadResult{}, err
38 38
 		}
39 39
 		query["platform"] = p
40 40
 	}
... ...
@@ -43,10 +43,10 @@ func (cli *Client) ImageLoad(ctx context.Context, input io.Reader, loadOpts ...I
43 43
 		"Content-Type": {"application/x-tar"},
44 44
 	})
45 45
 	if err != nil {
46
-		return LoadResponse{}, err
46
+		return ImageLoadResult{}, err
47 47
 	}
48
-	return LoadResponse{
49
-		Body: resp.Body,
48
+	return ImageLoadResult{
49
+		body: resp.Body,
50 50
 		JSON: resp.Header.Get("Content-Type") == "application/json",
51 51
 	}, nil
52 52
 }
... ...
@@ -73,8 +73,19 @@ func (cli *Client) ImageLoad(ctx context.Context, input io.Reader, loadOpts ...I
73 73
 //
74 74
 // We should deprecated the "quiet" option, as it's really a client
75 75
 // responsibility.
76
-type LoadResponse struct {
76
+type ImageLoadResult struct {
77 77
 	// Body must be closed to avoid a resource leak
78
-	Body io.ReadCloser
78
+	body io.ReadCloser
79 79
 	JSON bool
80 80
 }
81
+
82
+func (r ImageLoadResult) Read(p []byte) (n int, err error) {
83
+	return r.body.Read(p)
84
+}
85
+
86
+func (r ImageLoadResult) Close() error {
87
+	if r.body == nil {
88
+		return nil
89
+	}
90
+	return r.body.Close()
91
+}
... ...
@@ -2,21 +2,20 @@ package client
2 2
 
3 3
 import (
4 4
 	"context"
5
-	"io"
6 5
 	"net/url"
7 6
 )
8 7
 
9 8
 // ImageSave retrieves one or more images from the docker host as an
10
-// [io.ReadCloser].
9
+// [ImageSaveResult].
11 10
 //
12 11
 // Platforms is an optional parameter that specifies the platforms to save
13 12
 // from the image. Passing a platform only has an effect if the input image
14 13
 // is a multi-platform image.
15
-func (cli *Client) ImageSave(ctx context.Context, imageIDs []string, saveOpts ...ImageSaveOption) (io.ReadCloser, error) {
14
+func (cli *Client) ImageSave(ctx context.Context, imageIDs []string, saveOpts ...ImageSaveOption) (ImageSaveResult, error) {
16 15
 	var opts imageSaveOpts
17 16
 	for _, opt := range saveOpts {
18 17
 		if err := opt.Apply(&opts); err != nil {
19
-			return nil, err
18
+			return ImageSaveResult{}, err
20 19
 		}
21 20
 	}
22 21
 
... ...
@@ -26,18 +25,18 @@ func (cli *Client) ImageSave(ctx context.Context, imageIDs []string, saveOpts ..
26 26
 
27 27
 	if len(opts.apiOptions.Platforms) > 0 {
28 28
 		if err := cli.NewVersionError(ctx, "1.48", "platform"); err != nil {
29
-			return nil, err
29
+			return ImageSaveResult{}, err
30 30
 		}
31 31
 		p, err := encodePlatforms(opts.apiOptions.Platforms...)
32 32
 		if err != nil {
33
-			return nil, err
33
+			return ImageSaveResult{}, err
34 34
 		}
35 35
 		query["platform"] = p
36 36
 	}
37 37
 
38 38
 	resp, err := cli.get(ctx, "/images/get", query, nil)
39 39
 	if err != nil {
40
-		return nil, err
40
+		return ImageSaveResult{}, err
41 41
 	}
42
-	return resp.Body, nil
42
+	return newImageSaveResult(resp.Body), nil
43 43
 }
... ...
@@ -2,6 +2,8 @@ package client
2 2
 
3 3
 import (
4 4
 	"fmt"
5
+	"io"
6
+	"sync"
5 7
 
6 8
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
7 9
 )
... ...
@@ -36,3 +38,34 @@ type imageSaveOptions struct {
36 36
 	// multi-platform image and has multiple variants.
37 37
 	Platforms []ocispec.Platform
38 38
 }
39
+
40
+func newImageSaveResult(rc io.ReadCloser) ImageSaveResult {
41
+	if rc == nil {
42
+		panic("nil io.ReadCloser")
43
+	}
44
+	return ImageSaveResult{
45
+		rc:    rc,
46
+		close: sync.OnceValue(rc.Close),
47
+	}
48
+}
49
+
50
+type ImageSaveResult struct {
51
+	rc    io.ReadCloser
52
+	close func() error
53
+}
54
+
55
+// Read implements io.ReadCloser
56
+func (r ImageSaveResult) Read(p []byte) (n int, err error) {
57
+	if r.rc == nil {
58
+		return 0, io.EOF
59
+	}
60
+	return r.rc.Read(p)
61
+}
62
+
63
+// Close implements io.ReadCloser
64
+func (r ImageSaveResult) Close() error {
65
+	if r.close == nil {
66
+		return nil
67
+	}
68
+	return r.close()
69
+}