Merge pull request #49586 from vvoland/image-inspect-platform

image/inspect: Add platform selection
This commit is contained in:
Sebastiaan van Stijn
2025-04-07 16:29:57 +02:00
committed by GitHub
16 changed files with 306 additions and 18 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

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

View File

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

View File

@@ -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")
}

View File

@@ -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,

View File

@@ -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")
})
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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))
})
}
})
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

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