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) {