From 59169d0f9746cca5a6fc302eac33e65ca00dd14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Wed, 5 Mar 2025 15:42:11 +0100 Subject: [PATCH] image/inspect: Add platform selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /image/{name}/json` now supports `platform` parameter allowing to specify which platform variant of a multi-platform image to inspect. For servers that do not use containerd image store integration, this option will cause an error if the requested platform doesn't match the image's actual platform Signed-off-by: Paweł Gronowski --- api/server/router/image/image_routes.go | 14 ++ api/types/backend/backend.go | 1 + api/types/image/image_inspect.go | 5 +- api/types/image/opts.go | 5 + client/image_inspect.go | 11 ++ client/image_inspect_opts.go | 12 ++ client/image_inspect_test.go | 45 +++++++ daemon/containerd/image_inspect.go | 8 +- daemon/containerd/image_inspect_test.go | 34 +++++ daemon/images/image_inspect.go | 4 +- docs/api/version-history.md | 5 + integration/image/inspect_test.go | 121 ++++++++++++++++++ .../testutils/specialimage/multiplatform.go | 2 +- internal/testutils/specialimage/partial.go | 2 +- internal/testutils/specialimage/random.go | 32 +++++ .../testutils/specialimage/twoplatform.go | 23 ++-- 16 files changed, 306 insertions(+), 18 deletions(-) diff --git a/api/server/router/image/image_routes.go b/api/server/router/image/image_routes.go index 231527836c..be46e52fc7 100644 --- a/api/server/router/image/image_routes.go +++ b/api/server/router/image/image_routes.go @@ -341,8 +341,22 @@ func (ir *imageRouter) getImagesByName(ctx context.Context, w http.ResponseWrite manifests = httputils.BoolValue(r, "manifests") } + var platform *ocispec.Platform + if r.Form.Get("platform") != "" && versions.GreaterThanOrEqualTo(httputils.VersionFromContext(ctx), "1.49") { + p, err := httputils.DecodePlatform(r.Form.Get("platform")) + if err != nil { + return errdefs.InvalidParameter(err) + } + platform = p + } + + if manifests && platform != nil { + return errdefs.InvalidParameter(errors.New("conflicting options: manifests and platform options cannot both be set")) + } + imageInspect, err := ir.backend.ImageInspect(ctx, vars["name"], backend.ImageInspectOpts{ Manifests: manifests, + Platform: platform, }) if err != nil { return err diff --git a/api/types/backend/backend.go b/api/types/backend/backend.go index 63b272773c..4982bce366 100644 --- a/api/types/backend/backend.go +++ b/api/types/backend/backend.go @@ -153,6 +153,7 @@ type GetImageOpts struct { // ImageInspectOpts holds parameters to inspect an image. type ImageInspectOpts struct { Manifests bool + Platform *ocispec.Platform } // CommitConfig is the configuration for creating an image as part of a build. diff --git a/api/types/image/image_inspect.go b/api/types/image/image_inspect.go index 78e81f052c..40d1f97a31 100644 --- a/api/types/image/image_inspect.go +++ b/api/types/image/image_inspect.go @@ -128,11 +128,12 @@ type InspectResponse struct { // compatibility. Descriptor *ocispec.Descriptor `json:"Descriptor,omitempty"` - // Manifests is a list of image manifests available in this image. It + // Manifests is a list of image manifests available in this image. It // provides a more detailed view of the platform-specific image manifests or // other image-attached data like build attestations. // - // Only available if the daemon provides a multi-platform image store. + // Only available if the daemon provides a multi-platform image store, the client + // requests manifests AND does not request a specific platform. // // WARNING: This is experimental and may change at any time without any backward // compatibility. diff --git a/api/types/image/opts.go b/api/types/image/opts.go index 919510fe37..57800e0d47 100644 --- a/api/types/image/opts.go +++ b/api/types/image/opts.go @@ -106,6 +106,11 @@ type LoadOptions struct { type InspectOptions struct { // Manifests returns the image manifests. Manifests bool + + // Platform selects the specific platform of a multi-platform image to inspect. + // + // This option is only available for API version 1.49 and up. + Platform *ocispec.Platform } // SaveOptions holds parameters to save images. diff --git a/client/image_inspect.go b/client/image_inspect.go index 1161195467..d88f0f1410 100644 --- a/client/image_inspect.go +++ b/client/image_inspect.go @@ -32,6 +32,17 @@ func (cli *Client) ImageInspect(ctx context.Context, imageID string, inspectOpts query.Set("manifests", "1") } + if opts.apiOptions.Platform != nil { + if err := cli.NewVersionError(ctx, "1.49", "platform"); err != nil { + return image.InspectResponse{}, err + } + platform, err := encodePlatform(opts.apiOptions.Platform) + if err != nil { + return image.InspectResponse{}, err + } + query.Set("platform", platform) + } + resp, err := cli.get(ctx, "/images/"+imageID+"/json", query, nil) defer ensureReaderClosed(resp) if err != nil { diff --git a/client/image_inspect_opts.go b/client/image_inspect_opts.go index 2607f36789..655cbf0b7a 100644 --- a/client/image_inspect_opts.go +++ b/client/image_inspect_opts.go @@ -4,6 +4,7 @@ import ( "bytes" "github.com/docker/docker/api/types/image" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) // ImageInspectOption is a type representing functional options for the image inspect operation. @@ -36,6 +37,17 @@ func ImageInspectWithManifests(manifests bool) ImageInspectOption { }) } +// ImageInspectWithPlatform sets platform API option for the image inspect operation. +// This option is only available for API version 1.49 and up. +// With this option set, the image inspect operation will return information for the +// specified platform variant of the multi-platform image. +func ImageInspectWithPlatform(platform *ocispec.Platform) ImageInspectOption { + return imageInspectOptionFunc(func(clientOpts *imageInspectOpts) error { + clientOpts.apiOptions.Platform = platform + return nil + }) +} + // ImageInspectWithAPIOpts sets the API options for the image inspect operation. func ImageInspectWithAPIOpts(opts image.InspectOptions) ImageInspectOption { return imageInspectOptionFunc(func(clientOpts *imageInspectOpts) error { diff --git a/client/image_inspect_test.go b/client/image_inspect_test.go index a59c8b771c..05f062bd81 100644 --- a/client/image_inspect_test.go +++ b/client/image_inspect_test.go @@ -14,6 +14,7 @@ import ( "github.com/docker/docker/api/types/image" "github.com/docker/docker/errdefs" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" ) @@ -79,3 +80,47 @@ func TestImageInspect(t *testing.T) { t.Fatalf("expected `%v`, got %v", expectedTags, imageInspect.RepoTags) } } + +func TestImageInspectWithPlatform(t *testing.T) { + expectedURL := "/images/image_id/json" + requestedPlatform := &ocispec.Platform{ + OS: "linux", + Architecture: "arm64", + } + + expectedPlatform, err := encodePlatform(requestedPlatform) + assert.NilError(t, err) + + client := &Client{ + client: newMockClient(func(req *http.Request) (*http.Response, error) { + if !strings.HasPrefix(req.URL.Path, expectedURL) { + return nil, fmt.Errorf("Expected URL '%s', got '%s'", expectedURL, req.URL) + } + + // Check if platform parameter is passed correctly + platform := req.URL.Query().Get("platform") + if platform != expectedPlatform { + return nil, fmt.Errorf("Expected platform '%s', got '%s'", expectedPlatform, platform) + } + + content, err := json.Marshal(image.InspectResponse{ + ID: "image_id", + Architecture: "arm64", + Os: "linux", + }) + if err != nil { + return nil, err + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(content)), + }, nil + }), + } + + imageInspect, err := client.ImageInspect(context.Background(), "image_id", ImageInspectWithPlatform(requestedPlatform)) + assert.NilError(t, err) + assert.Equal(t, imageInspect.ID, "image_id") + assert.Equal(t, imageInspect.Architecture, "arm64") + assert.Equal(t, imageInspect.Os, "linux") +} diff --git a/daemon/containerd/image_inspect.go b/daemon/containerd/image_inspect.go index 0e6663e151..e612e79ba4 100644 --- a/daemon/containerd/image_inspect.go +++ b/daemon/containerd/image_inspect.go @@ -23,8 +23,7 @@ import ( ) func (i *ImageService) ImageInspect(ctx context.Context, refOrID string, opts backend.ImageInspectOpts) (*imagetypes.InspectResponse, error) { - // TODO: Pass in opts - var requestedPlatform *ocispec.Platform + requestedPlatform := opts.Platform c8dImg, err := i.resolveImage(ctx, refOrID) if err != nil { @@ -60,7 +59,6 @@ func (i *ImageService) ImageInspect(ctx context.Context, refOrID string, opts ba return nil, err } - //nolint:govet // TODO: requestedPlatform is always nil, but should be passed by the caller if multi.Best == nil && requestedPlatform != nil { return nil, &errPlatformNotFound{ imageRef: refOrID, @@ -87,6 +85,10 @@ func (i *ImageService) ImageInspect(ctx context.Context, refOrID string, opts ba repoTags, repoDigests := collectRepoTagsAndDigests(ctx, tagged) + if requestedPlatform != nil { + target = multi.Best.Target() + } + resp := &imagetypes.InspectResponse{ ID: target.Digest.String(), RepoTags: repoTags, diff --git a/daemon/containerd/image_inspect_test.go b/daemon/containerd/image_inspect_test.go index 15260389d2..bef2b092ee 100644 --- a/daemon/containerd/image_inspect_test.go +++ b/daemon/containerd/image_inspect_test.go @@ -61,4 +61,38 @@ func TestImageInspect(t *testing.T) { }) } }) + + t.Run("inspect image with platform parameter", func(t *testing.T) { + ctx := logtest.WithT(ctx, t) + service := fakeImageService(t, ctx, cs) + + multiPlatformImage := toContainerdImage(t, func(dir string) (*ocispec.Index, error) { + idx, _, err := specialimage.MultiPlatform(dir, "multiplatform:latest", []ocispec.Platform{ + {OS: "linux", Architecture: "amd64"}, + {OS: "linux", Architecture: "arm64"}, + }) + return idx, err + }) + + _, err := service.images.Create(ctx, multiPlatformImage) + assert.NilError(t, err) + + // Test with amd64 platform + amd64Platform := &ocispec.Platform{OS: "linux", Architecture: "amd64"} + inspectAmd64, err := service.ImageInspect(ctx, multiPlatformImage.Name, backend.ImageInspectOpts{ + Platform: amd64Platform, + }) + assert.NilError(t, err) + assert.Equal(t, inspectAmd64.Architecture, "amd64") + assert.Equal(t, inspectAmd64.Os, "linux") + + // Test with arm64 platform + arm64Platform := &ocispec.Platform{OS: "linux", Architecture: "arm64"} + inspectArm64, err := service.ImageInspect(ctx, multiPlatformImage.Name, backend.ImageInspectOpts{ + Platform: arm64Platform, + }) + assert.NilError(t, err) + assert.Equal(t, inspectArm64.Architecture, "arm64") + assert.Equal(t, inspectArm64.Os, "linux") + }) } diff --git a/daemon/images/image_inspect.go b/daemon/images/image_inspect.go index a84dacd9d9..a8147b4c18 100644 --- a/daemon/images/image_inspect.go +++ b/daemon/images/image_inspect.go @@ -12,8 +12,8 @@ import ( "github.com/docker/docker/layer" ) -func (i *ImageService) ImageInspect(ctx context.Context, refOrID string, _ backend.ImageInspectOpts) (*imagetypes.InspectResponse, error) { - img, err := i.GetImage(ctx, refOrID, backend.GetImageOpts{}) +func (i *ImageService) ImageInspect(ctx context.Context, refOrID string, opts backend.ImageInspectOpts) (*imagetypes.InspectResponse, error) { + img, err := i.GetImage(ctx, refOrID, backend.GetImageOpts{Platform: opts.Platform}) if err != nil { return nil, err } diff --git a/docs/api/version-history.md b/docs/api/version-history.md index bf50f15f3a..a78bba88ee 100644 --- a/docs/api/version-history.md +++ b/docs/api/version-history.md @@ -17,6 +17,11 @@ keywords: "API, Docker, rcli, REST, documentation" [Docker Engine API v1.49](https://docs.docker.com/reference/api/engine/version/v1.49/) documentation +* `GET /images/{name}/json` now supports a `platform` parameter (JSON + encoded OCI Platform type) allowing to specify a platform of the multi-platform + image to inspect. + This option is mutually exclusive with the `manifests` option. + ## v1.48 API changes [Docker Engine API v1.48](https://docs.docker.com/reference/api/engine/version/v1.48/) documentation diff --git a/integration/image/inspect_test.go b/integration/image/inspect_test.go index c2d9942c71..a0a700c36f 100644 --- a/integration/image/inspect_test.go +++ b/integration/image/inspect_test.go @@ -8,6 +8,7 @@ import ( "github.com/docker/docker/api/types/image" "github.com/docker/docker/client" "github.com/docker/docker/internal/testutils/specialimage" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" "gotest.tools/v3/skip" @@ -80,3 +81,123 @@ func TestImageInspectDescriptor(t *testing.T) { assert.Check(t, inspect.Descriptor.Digest.String() == inspect.ID) assert.Check(t, inspect.Descriptor.Size > 0) } + +func TestImageInspectWithPlatform(t *testing.T) { + skip.If(t, testEnv.DaemonInfo.OSType == "windows", "The test image is a Linux image") + ctx := setupTest(t) + + apiClient := testEnv.APIClient() + + nativePlatform := ocispec.Platform{ + OS: testEnv.DaemonInfo.OSType, + Architecture: testEnv.DaemonInfo.Architecture, + } + + // Create a platform that does not match the host platform + differentOS := "linux" + if nativePlatform.OS == "linux" { + differentOS = "windows" + } + differentPlatform := ocispec.Platform{ + OS: differentOS, + Architecture: "amd64", + } + + imageID := specialimage.Load(ctx, t, apiClient, func(dir string) (*ocispec.Index, error) { + i, descs, err := specialimage.MultiPlatform(dir, "multiplatform:latest", []ocispec.Platform{nativePlatform, differentPlatform}) + assert.NilError(t, err) + + err = specialimage.LegacyManifest(dir, "multiplatform:latest", descs[0]) + assert.NilError(t, err) + + return i, err + }) + + for _, tc := range []struct { + name string + requestedPlatform *ocispec.Platform + expectedPlatform *ocispec.Platform + expectedError string + withManifests bool + snapshotterOnly bool + graphdriverOnly bool + }{ + { + name: "default", + requestedPlatform: nil, + expectedPlatform: &nativePlatform, + }, + { + name: "snapshotter/with-manifests", + requestedPlatform: nil, + expectedPlatform: &nativePlatform, + snapshotterOnly: true, + withManifests: true, + }, + { + name: "native", + requestedPlatform: &nativePlatform, + expectedPlatform: &nativePlatform, + }, + { + name: "different", + requestedPlatform: &differentPlatform, + expectedPlatform: &differentPlatform, + snapshotterOnly: true, + }, + { + name: "different not supported on graphdriver", + requestedPlatform: &differentPlatform, + graphdriverOnly: true, + // image with reference multiplatform:latest was found but its platform (linux/aarch64) does not match the specified platform (windows/amd64) + expectedError: "image with reference multiplatform:latest was found but its platform", + }, + } { + if tc.snapshotterOnly && !testEnv.UsingSnapshotter() { + continue + } + if tc.graphdriverOnly && testEnv.UsingSnapshotter() { + continue + } + + t.Run(tc.name, func(t *testing.T) { + var opts []client.ImageInspectOption + if tc.requestedPlatform != nil { + opts = append(opts, client.ImageInspectWithPlatform(tc.requestedPlatform)) + } + if tc.withManifests { + opts = append(opts, client.ImageInspectWithManifests(true)) + } + inspect, err := apiClient.ImageInspect(ctx, imageID, opts...) + if tc.expectedError != "" { + assert.Assert(t, is.ErrorContains(err, tc.expectedError)) + return + } + assert.NilError(t, err) + + assert.Check(t, is.Equal(inspect.Architecture, tc.expectedPlatform.Architecture)) + assert.Check(t, is.Equal(inspect.Os, tc.expectedPlatform.OS)) + + if testEnv.UsingSnapshotter() { + assert.Assert(t, inspect.Descriptor != nil) + if tc.requestedPlatform != nil { + if assert.Check(t, inspect.Descriptor.Platform != nil) { + assert.Check(t, is.DeepEqual(*inspect.Descriptor.Platform, *tc.expectedPlatform)) + } + } + } else { + assert.Check(t, inspect.Descriptor == nil) + } + + if tc.withManifests { + t.Run("has manifests", func(t *testing.T) { + assert.Check(t, is.Len(inspect.Manifests, 2)) + }) + } else { + t.Run("has no manifests", func(t *testing.T) { + assert.Check(t, is.Nil(inspect.Manifests)) + }) + } + }) + } +} diff --git a/internal/testutils/specialimage/multiplatform.go b/internal/testutils/specialimage/multiplatform.go index deabf44141..00d56ed3f4 100644 --- a/internal/testutils/specialimage/multiplatform.go +++ b/internal/testutils/specialimage/multiplatform.go @@ -17,7 +17,7 @@ func MultiPlatform(dir string, imageRef string, imagePlatforms []ocispec.Platfor for _, platform := range imagePlatforms { ps := platforms.Format(platform) - manifestDesc, err := oneLayerPlatformManifest(dir, platform, FileInLayer{Path: "bash", Content: []byte("layer-" + ps)}) + manifestDesc, _, err := oneLayerPlatformManifest(dir, platform, FileInLayer{Path: "bash", Content: []byte("layer-" + ps)}) if err != nil { return nil, nil, err } diff --git a/internal/testutils/specialimage/partial.go b/internal/testutils/specialimage/partial.go index 0e73823e5e..13a873d232 100644 --- a/internal/testutils/specialimage/partial.go +++ b/internal/testutils/specialimage/partial.go @@ -27,7 +27,7 @@ func PartialMultiPlatform(dir string, imageRef string, opts PartialOpts) (*ocisp for _, platform := range opts.Stored { ps := platforms.Format(platform) - manifestDesc, err := oneLayerPlatformManifest(dir, platform, FileInLayer{Path: "bash", Content: []byte("layer-" + ps)}) + manifestDesc, _, err := oneLayerPlatformManifest(dir, platform, FileInLayer{Path: "bash", Content: []byte("layer-" + ps)}) if err != nil { return nil, nil, err } diff --git a/internal/testutils/specialimage/random.go b/internal/testutils/specialimage/random.go index 89409bcf48..f0d35e213d 100644 --- a/internal/testutils/specialimage/random.go +++ b/internal/testutils/specialimage/random.go @@ -1,7 +1,10 @@ package specialimage import ( + "encoding/json" "math/rand" + "os" + "path/filepath" "strconv" "github.com/distribution/reference" @@ -75,3 +78,32 @@ func blobPaths(descriptors []ocispec.Descriptor) []string { } return paths } + +func readJson(path string, v any) error { + content, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(content, v) +} + +func LegacyManifest(dir string, imageRef string, mfstDesc ocispec.Descriptor) error { + legacyManifests := []manifestItem{} + + var mfst ocispec.Manifest + if err := readJson(filepath.Join(dir, blobPath(mfstDesc)), &mfst); err != nil { + return err + } + + legacyManifests = append(legacyManifests, manifestItem{ + Config: blobPath(mfst.Config), + RepoTags: []string{imageRef}, + Layers: blobPaths(mfst.Layers), + }) + + if err := writeJson(legacyManifests, filepath.Join(dir, "manifest.json")); err != nil { + return err + } + + return nil +} diff --git a/internal/testutils/specialimage/twoplatform.go b/internal/testutils/specialimage/twoplatform.go index 11f3d78c4d..a52652f65a 100644 --- a/internal/testutils/specialimage/twoplatform.go +++ b/internal/testutils/specialimage/twoplatform.go @@ -18,12 +18,12 @@ func TwoPlatform(dir string) (*ocispec.Index, error) { return nil, err } - manifest1Desc, err := oneLayerPlatformManifest(dir, platforms.MustParse("linux/amd64"), FileInLayer{Path: "bash", Content: []byte("layer1")}) + manifest1Desc, _, err := oneLayerPlatformManifest(dir, platforms.MustParse("linux/amd64"), FileInLayer{Path: "bash", Content: []byte("layer1")}) if err != nil { return nil, err } - manifest2Desc, err := oneLayerPlatformManifest(dir, platforms.MustParse("linux/arm64"), FileInLayer{Path: "bash", Content: []byte("layer2")}) + manifest2Desc, _, err := oneLayerPlatformManifest(dir, platforms.MustParse("linux/arm64"), FileInLayer{Path: "bash", Content: []byte("layer2")}) if err != nil { return nil, err } @@ -40,13 +40,13 @@ type FileInLayer struct { Content []byte } -func oneLayerPlatformManifest(dir string, platform ocispec.Platform, f FileInLayer) (ocispec.Descriptor, error) { +func oneLayerPlatformManifest(dir string, platform ocispec.Platform, f FileInLayer) (ocispec.Descriptor, manifestItem, error) { layerDesc, err := writeLayerWithOneFile(dir, f.Path, f.Content) if err != nil { - return ocispec.Descriptor{}, err + return ocispec.Descriptor{}, manifestItem{}, err } - configDesc, err := writeJsonBlob(dir, ocispec.MediaTypeImageConfig, ocispec.Image{ + img := ocispec.Image{ Platform: platform, Config: ocispec.ImageConfig{ Env: []string{"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"}, @@ -55,9 +55,11 @@ func oneLayerPlatformManifest(dir string, platform ocispec.Platform, f FileInLay Type: "layers", DiffIDs: []digest.Digest{layerDesc.Digest}, }, - }) + } + + configDesc, err := writeJsonBlob(dir, ocispec.MediaTypeImageConfig, img) if err != nil { - return ocispec.Descriptor{}, err + return ocispec.Descriptor{}, manifestItem{}, err } manifestDesc, err := writeJsonBlob(dir, ocispec.MediaTypeImageManifest, ocispec.Manifest{ @@ -66,11 +68,14 @@ func oneLayerPlatformManifest(dir string, platform ocispec.Platform, f FileInLay Layers: []ocispec.Descriptor{layerDesc}, }) if err != nil { - return ocispec.Descriptor{}, err + return ocispec.Descriptor{}, manifestItem{}, err } manifestDesc.Platform = &platform - return manifestDesc, nil + return manifestDesc, manifestItem{ + Config: blobPath(configDesc), + Layers: []string{blobPath(layerDesc)}, + }, nil } func multiPlatformImage(dir string, ref reference.Named, target ocispec.Index) (*ocispec.Index, error) {