diff --git a/api/server/router/image/image_routes.go b/api/server/router/image/image_routes.go index 86fd2caf84..4550507680 100644 --- a/api/server/router/image/image_routes.go +++ b/api/server/router/image/image_routes.go @@ -358,10 +358,16 @@ func (ir *imageRouter) getImagesJSON(ctx context.Context, w http.ResponseWriter, sharedSize = httputils.BoolValue(r, "shared-size") } + var manifests bool + if versions.GreaterThanOrEqualTo(version, "1.47") { + manifests = httputils.BoolValue(r, "manifests") + } + images, err := ir.backend.Images(ctx, imagetypes.ListOptions{ All: httputils.BoolValue(r, "all"), Filters: imageFilters, SharedSize: sharedSize, + Manifests: manifests, }) if err != nil { return err diff --git a/api/swagger.yaml b/api/swagger.yaml index 1da17851c5..756068cb8b 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -2265,6 +2265,19 @@ definitions: x-nullable: false type: "integer" example: 2 + Manifests: + description: | + Manifests is a list of 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. + + WARNING: This is experimental and may change at any time without any backward + compatibility. + type: "array" + x-nullable: false + x-omitempty: true + items: + $ref: "#/definitions/ImageManifestSummary" AuthConfig: type: "object" @@ -5323,7 +5336,7 @@ definitions: description: | The default (and highest) API version that is supported by the daemon type: "string" - example: "1.46" + example: "1.47" MinAPIVersion: description: | The minimum API version that is supported by the daemon @@ -6649,6 +6662,120 @@ definitions: additionalProperties: type: "string" + ImageManifestSummary: + x-go-name: "ManifestSummary" + description: | + ImageManifestSummary represents a summary of an image manifest. + type: "object" + required: ["ID", "Descriptor", "Available", "Size", "Kind"] + properties: + ID: + description: | + ID is the content-addressable ID of an image and is the same as the + digest of the image manifest. + type: "string" + example: "sha256:95869fbcf224d947ace8d61d0e931d49e31bb7fc67fffbbe9c3198c33aa8e93f" + Descriptor: + $ref: "#/definitions/OCIDescriptor" + Available: + description: Indicates whether all the child content (image config, layers) is fully available locally. + type: "boolean" + example: true + Size: + type: "object" + x-nullable: false + required: ["Content", "Total"] + properties: + Total: + type: "integer" + format: "int64" + example: 8213251 + description: | + Total is the total size (in bytes) of all the locally present + data (both distributable and non-distributable) that's related to + this manifest and its children. + This equal to the sum of [Content] size AND all the sizes in the + [Size] struct present in the Kind-specific data struct. + For example, for an image kind (Kind == "image") + this would include the size of the image content and unpacked + image snapshots ([Size.Content] + [ImageData.Size.Unpacked]). + Content: + description: | + Content is the size (in bytes) of all the locally present + content in the content store (e.g. image config, layers) + referenced by this manifest and its children. + This only includes blobs in the content store. + type: "integer" + format: "int64" + example: 3987495 + Kind: + type: "string" + example: "image" + enum: + - "image" + - "attestation" + - "unknown" + description: | + The kind of the manifest. + + kind | description + -------------|----------------------------------------------------------- + image | Image manifest that can be used to start a container. + attestation | Attestation manifest produced by the Buildkit builder for a specific image manifest. + ImageData: + description: | + The image data for the image manifest. + This field is only populated when Kind is "image". + type: "object" + x-nullable: true + x-omitempty: true + required: ["Platform", "Containers", "Size", "UnpackedSize"] + properties: + Platform: + $ref: "#/definitions/OCIPlatform" + description: | + OCI platform of the image. This will be the platform specified in the + manifest descriptor from the index/manifest list. + If it's not available, it will be obtained from the image config. + Containers: + description: | + The IDs of the containers that are using this image. + type: "array" + items: + type: "string" + example: ["ede54ee1fda366ab42f824e8a5ffd195155d853ceaec74a927f249ea270c7430", "abadbce344c096744d8d6071a90d474d28af8f1034b5ea9fb03c3f4bfc6d005e"] + Size: + type: "object" + x-nullable: false + required: ["Unpacked"] + properties: + Unpacked: + type: "integer" + format: "int64" + example: 3987495 + description: | + Unpacked is the size (in bytes) of the locally unpacked + (uncompressed) image content that's directly usable by the containers + running this image. + It's independent of the distributable content - e.g. + the image might still have an unpacked data that's still used by + some container even when the distributable/compressed content is + already gone. + AttestationData: + description: | + The image data for the attestation manifest. + This field is only populated when Kind is "attestation". + type: "object" + x-nullable: true + x-omitempty: true + required: ["For"] + properties: + For: + description: | + The digest of the image manifest that this attestation is for. + type: "string" + example: "sha256:95869fbcf224d947ace8d61d0e931d49e31bb7fc67fffbbe9c3198c33aa8e93f" + paths: /containers/json: get: @@ -8627,6 +8754,11 @@ paths: description: "Show digest information as a `RepoDigests` field on each image." type: "boolean" default: false + - name: "manifests" + in: "query" + description: "Include `Manifests` in the image summary." + type: "boolean" + default: false tags: ["Image"] /build: post: diff --git a/api/types/image/manifest.go b/api/types/image/manifest.go new file mode 100644 index 0000000000..db8a00830e --- /dev/null +++ b/api/types/image/manifest.go @@ -0,0 +1,99 @@ +package image + +import ( + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +type ManifestKind string + +const ( + ManifestKindImage ManifestKind = "image" + ManifestKindAttestation ManifestKind = "attestation" + ManifestKindUnknown ManifestKind = "unknown" +) + +type ManifestSummary struct { + // ID is the content-addressable ID of an image and is the same as the + // digest of the image manifest. + // + // Required: true + ID string `json:"ID"` + + // Descriptor is the OCI descriptor of the image. + // + // Required: true + Descriptor ocispec.Descriptor `json:"Descriptor"` + + // Indicates whether all the child content (image config, layers) is + // fully available locally + // + // Required: true + Available bool `json:"Available"` + + // Size is the size information of the content related to this manifest. + // Note: These sizes only take the locally available content into account. + // + // Required: true + Size struct { + // Content is the size (in bytes) of all the locally present + // content in the content store (e.g. image config, layers) + // referenced by this manifest and its children. + // This only includes blobs in the content store. + Content int64 `json:"Content"` + + // Total is the total size (in bytes) of all the locally present + // data (both distributable and non-distributable) that's related to + // this manifest and its children. + // This equal to the sum of [Content] size AND all the sizes in the + // [Size] struct present in the Kind-specific data struct. + // For example, for an image kind (Kind == ManifestKindImage), + // this would include the size of the image content and unpacked + // image snapshots ([Size.Content] + [ImageData.Size.Unpacked]). + Total int64 `json:"Total"` + } `json:"Size"` + + // Kind is the kind of the image manifest. + // + // Required: true + Kind ManifestKind `json:"Kind"` + + // Fields below are specific to the kind of the image manifest. + + // Present only if Kind == ManifestKindImage. + ImageData *ImageProperties `json:"ImageData,omitempty"` + + // Present only if Kind == ManifestKindAttestation. + AttestationData *AttestationProperties `json:"AttestationData,omitempty"` +} + +type ImageProperties struct { + // Platform is the OCI platform object describing the platform of the image. + // + // Required: true + Platform ocispec.Platform `json:"Platform"` + + Size struct { + // Unpacked is the size (in bytes) of the locally unpacked + // (uncompressed) image content that's directly usable by the containers + // running this image. + // It's independent of the distributable content - e.g. + // the image might still have an unpacked data that's still used by + // some container even when the distributable/compressed content is + // already gone. + // + // Required: true + Unpacked int64 `json:"Unpacked"` + } + + // Containers is an array containing the IDs of the containers that are + // using this image. + // + // Required: true + Containers []string `json:"Containers"` +} + +type AttestationProperties struct { + // For is the digest of the image manifest that this attestation is for. + For digest.Digest `json:"For"` +} diff --git a/api/types/image/opts.go b/api/types/image/opts.go index 8e32c9af86..923ebe5a06 100644 --- a/api/types/image/opts.go +++ b/api/types/image/opts.go @@ -76,6 +76,9 @@ type ListOptions struct { // ContainerCount indicates whether container count should be computed. ContainerCount bool + + // Manifests indicates whether the image manifests should be returned. + Manifests bool } // RemoveOptions holds parameters to remove images. diff --git a/api/types/image/summary.go b/api/types/image/summary.go index f1e3e2ef01..c7168fe62e 100644 --- a/api/types/image/summary.go +++ b/api/types/image/summary.go @@ -1,10 +1,5 @@ package image -// This file was generated by the swagger tool. -// Editing this file might prove futile when you re-run the swagger generate command - -// Summary summary -// swagger:model Summary type Summary struct { // Number of containers using this image. Includes both stopped and running @@ -47,6 +42,14 @@ type Summary struct { // Required: true ParentID string `json:"ParentId"` + // 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. + // + // WARNING: This is experimental and may change at any time without any backward + // compatibility. + Manifests []ManifestSummary `json:"Manifests,omitempty"` + // List of content-addressable digests of locally available image manifests // that the image is referenced from. Multiple manifests can refer to the // same image. diff --git a/client/image_list.go b/client/image_list.go index a9cc1e21e5..bef679431d 100644 --- a/client/image_list.go +++ b/client/image_list.go @@ -11,6 +11,11 @@ import ( ) // ImageList returns a list of images in the docker host. +// +// Experimental: Setting the [options.Manifest] will populate +// [image.Summary.Manifests] with information about image manifests. +// This is experimental and might change in the future without any backward +// compatibility. func (cli *Client) ImageList(ctx context.Context, options image.ListOptions) ([]image.Summary, error) { var images []image.Summary @@ -47,6 +52,9 @@ func (cli *Client) ImageList(ctx context.Context, options image.ListOptions) ([] if options.SharedSize && versions.GreaterThanOrEqualTo(cli.version, "1.42") { query.Set("shared-size", "1") } + if options.Manifests && versions.GreaterThanOrEqualTo(cli.version, "1.47") { + query.Set("manifests", "1") + } serverResp, err := cli.get(ctx, "/images/json", query, nil) defer ensureReaderClosed(serverResp) diff --git a/daemon/containerd/image_list.go b/daemon/containerd/image_list.go index 531baf1048..7cee2a890f 100644 --- a/daemon/containerd/image_list.go +++ b/daemon/containerd/image_list.go @@ -23,6 +23,7 @@ import ( timetypes "github.com/docker/docker/api/types/time" "github.com/docker/docker/container" "github.com/docker/docker/errdefs" + "github.com/moby/buildkit/util/attestation" dockerspec "github.com/moby/docker-image-spec/specs-go/v1" "github.com/opencontainers/go-digest" "github.com/opencontainers/image-spec/identity" @@ -209,6 +210,8 @@ func (i *ImageService) Images(ctx context.Context, opts imagetypes.ListOptions) func (i *ImageService) imageSummary(ctx context.Context, img images.Image, platformMatcher platforms.MatchComparer, opts imagetypes.ListOptions, tagsByDigest map[digest.Digest][]string, ) (_ *imagetypes.Summary, allChainIDs []digest.Digest, _ error) { + var manifestSummaries []imagetypes.ManifestSummary + // Total size of the image including all its platform var totalSize int64 @@ -222,67 +225,137 @@ func (i *ImageService) imageSummary(ctx context.Context, img images.Image, platf var best *ImageManifest var bestPlatform ocispec.Platform - err := i.walkImageManifests(ctx, img, func(img *ImageManifest) error { - if isPseudo, err := img.IsPseudoImage(ctx); isPseudo || err != nil { + err := i.walkReachableImageManifests(ctx, img, func(img *ImageManifest) error { + target := img.Target() + + logger := log.G(ctx).WithFields(log.Fields{ + "image": img.Name(), + "digest": target.Digest, + "manifest": target, + }) + + available, err := img.CheckContentAvailable(ctx) + if err != nil && !errdefs.IsNotFound(err) { + logger.WithError(err).Warn("checking availability of platform specific manifest failed") return nil } - available, err := img.CheckContentAvailable(ctx) + mfstSummary := imagetypes.ManifestSummary{ + ID: target.Digest.String(), + Available: available, + Descriptor: target, + Kind: imagetypes.ManifestKindUnknown, + } + + if opts.Manifests { + defer func() { + // If the platform is available, prepend it to the list of platforms + // otherwise append it at the end. + if available { + manifestSummaries = append([]imagetypes.ManifestSummary{mfstSummary}, manifestSummaries...) + } else { + manifestSummaries = append(manifestSummaries, mfstSummary) + } + }() + } + + contentSize, err := img.Size(ctx) if err != nil { - log.G(ctx).WithFields(log.Fields{ - "error": err, - "manifest": img.Target(), - "image": img.Name(), - }).Warn("checking availability of platform specific manifest failed") + if !cerrdefs.IsNotFound(err) { + logger.WithError(err).Warn("failed to determine size") + } + } else { + mfstSummary.Size.Content = contentSize + totalSize += contentSize + mfstSummary.Size.Total = totalSize + } + + isPseudo, err := img.IsPseudoImage(ctx) + + // Ignore not found error as it's expected in case where the image is + // not fully available. Otherwise, just continue to the next manifest, + // so we don't error out the whole list in case the error is related to + // the content itself (e.g. corrupted data) or just manifest kind that + // we don't know about (yet). + if err != nil && !errdefs.IsNotFound(err) { + logger.WithError(err).Debug("pseudo image check failed") return nil } + logger = logger.WithField("isPseudo", isPseudo) + if isPseudo { + if img.IsAttestation() { + if s := target.Annotations[attestation.DockerAnnotationReferenceDigest]; s != "" { + dgst, err := digest.Parse(s) + if err != nil { + logger.WithError(err).Warn("failed to parse attestation digest") + return nil + } + + mfstSummary.Kind = imagetypes.ManifestKindAttestation + mfstSummary.AttestationData = &imagetypes.AttestationProperties{For: dgst} + } + } + + return nil + } + + mfstSummary.Kind = imagetypes.ManifestKindImage + mfstSummary.ImageData = &imagetypes.ImageProperties{} + if target.Platform != nil { + mfstSummary.ImageData.Platform = *target.Platform + } + if !available { return nil } - conf, err := img.Config(ctx) - if err != nil { - return err - } - var dockerImage dockerspec.DockerOCIImage - if err := readConfig(ctx, i.content, conf, &dockerImage); err != nil { - return err + if err := img.ReadConfig(ctx, &dockerImage); err != nil { + logger.WithError(err).Warn("failed to read image config") + return nil } - target := img.Target() + if target.Platform == nil { + mfstSummary.ImageData.Platform = dockerImage.Platform + } - diffIDs, err := img.RootFS(ctx) + chainIDs := identity.ChainIDs(dockerImage.RootFS.DiffIDs) + + prevContentSize := contentSize + unpackedSize, contentSize, err := i.singlePlatformSize(ctx, img) if err != nil { - return err + logger.WithError(err).Warn("failed to determine platform specific size") + return nil } - chainIDs := identity.ChainIDs(diffIDs) + // If the image-specific content size calculation produces different result + // than the "generic" one, adjust the total size with the difference. + if prevContentSize != contentSize { + logger.WithFields(log.Fields{ + "prevSize": prevContentSize, + "contentSize": contentSize, + }).Debug("content size calculation mismatch") - ts, _, err := i.singlePlatformSize(ctx, img) - if err != nil { - return err + totalSize += contentSize - prevContentSize } - totalSize += ts + totalSize += unpackedSize + mfstSummary.Size.Total = totalSize + mfstSummary.ImageData.Size.Unpacked = unpackedSize + allChainsIDs = append(allChainsIDs, chainIDs...) if opts.ContainerCount { i.containers.ApplyAll(func(c *container.Container) { if c.ImageManifest != nil && c.ImageManifest.Digest == target.Digest { + mfstSummary.ImageData.Containers = append(mfstSummary.ImageData.Containers, c.ID) containersCount++ } }) } - var platform ocispec.Platform - if target.Platform != nil { - platform = *target.Platform - } else { - platform = dockerImage.Platform - } - + platform := mfstSummary.ImageData.Platform // Filter out platforms that don't match the requested platform. Do it // after the size, container count and chainIDs are summed up to have // the single combined entry still represent the whole multi-platform @@ -322,6 +395,7 @@ func (i *ImageService) imageSummary(ctx context.Context, img images.Image, platf return nil, nil, err } image.Size = totalSize + image.Manifests = manifestSummaries if opts.ContainerCount { image.Containers = containersCount @@ -329,7 +403,7 @@ func (i *ImageService) imageSummary(ctx context.Context, img images.Image, platf return image, allChainsIDs, nil } -func (i *ImageService) singlePlatformSize(ctx context.Context, imgMfst *ImageManifest) (totalSize int64, contentSize int64, _ error) { +func (i *ImageService) singlePlatformSize(ctx context.Context, imgMfst *ImageManifest) (unpackedSize int64, contentSize int64, _ error) { // TODO(thaJeztah): do we need to take multiple snapshotters into account? See https://github.com/moby/moby/issues/45273 snapshotter := i.snapshotterService(i.snapshotter) @@ -355,10 +429,7 @@ func (i *ImageService) singlePlatformSize(ctx context.Context, imgMfst *ImageMan return -1, -1, err } - // totalSize is the size of the image's packed layers and snapshots - // (unpacked layers) combined. - totalSize = contentSize + unpackedUsage.Size - return totalSize, contentSize, nil + return unpackedUsage.Size, contentSize, nil } func (i *ImageService) singlePlatformImage(ctx context.Context, contentStore content.Store, repoTags []string, imageManifest *ImageManifest) (*imagetypes.Summary, error) { @@ -400,11 +471,15 @@ func (i *ImageService) singlePlatformImage(ctx context.Context, contentStore con return nil, err } - totalSize, _, err := i.singlePlatformSize(ctx, imageManifest) + unpackedSize, contentSize, err := i.singlePlatformSize(ctx, imageManifest) if err != nil { return nil, errors.Wrapf(err, "failed to calculate size of image %s", imageManifest.Name()) } + // totalSize is the size of the image's packed layers and snapshots + // (unpacked layers) combined. + totalSize := contentSize + unpackedSize + summary := &imagetypes.Summary{ ParentID: rawImg.Labels[imageLabelClassicBuilderParent], ID: target.String(), diff --git a/daemon/containerd/image_list_test.go b/daemon/containerd/image_list_test.go index a92adcb941..4be827a2dc 100644 --- a/daemon/containerd/image_list_test.go +++ b/daemon/containerd/image_list_test.go @@ -123,6 +123,10 @@ func TestImageList(t *testing.T) { assert.Check(t, is.Equal(all[0].ID, multilayer.Manifests[0].Digest.String())) assert.Check(t, is.DeepEqual(all[0].RepoTags, []string{"multilayer:latest"})) + + assert.Check(t, is.Len(all[0].Manifests, 1)) + assert.Check(t, all[0].Manifests[0].Available) + assert.Check(t, is.Equal(all[0].Manifests[0].Kind, imagetypes.ManifestKindImage)) }, }, { @@ -133,6 +137,18 @@ func TestImageList(t *testing.T) { assert.Check(t, is.Equal(all[0].ID, twoplatform.Manifests[0].Digest.String())) assert.Check(t, is.DeepEqual(all[0].RepoTags, []string{"twoplatform:latest"})) + + i := all[0] + assert.Check(t, is.Len(i.Manifests, 2)) + + assert.Check(t, is.Equal(i.Manifests[0].Kind, imagetypes.ManifestKindImage)) + if assert.Check(t, i.Manifests[0].ImageData != nil) { + assert.Check(t, is.Equal(i.Manifests[0].ImageData.Platform.Architecture, "arm64")) + } + assert.Check(t, is.Equal(i.Manifests[1].Kind, imagetypes.ManifestKindImage)) + if assert.Check(t, i.Manifests[1].ImageData != nil) { + assert.Check(t, is.Equal(i.Manifests[1].ImageData.Platform.Architecture, "amd64")) + } }, }, { @@ -146,6 +162,14 @@ func TestImageList(t *testing.T) { assert.Check(t, is.Equal(all[1].ID, twoplatform.Manifests[0].Digest.String())) assert.Check(t, is.DeepEqual(all[1].RepoTags, []string{"twoplatform:latest"})) + + assert.Check(t, is.Len(all[0].Manifests, 1)) + assert.Check(t, is.Len(all[1].Manifests, 2)) + + assert.Check(t, is.Equal(all[0].Manifests[0].Kind, imagetypes.ManifestKindImage)) + + assert.Check(t, is.Equal(all[1].Manifests[0].Kind, imagetypes.ManifestKindImage)) + assert.Check(t, is.Equal(all[1].Manifests[1].Kind, imagetypes.ManifestKindImage)) }, }, { @@ -176,7 +200,9 @@ func TestImageList(t *testing.T) { assert.NilError(t, err) } - all, err := service.Images(ctx, tc.opts) + opts := tc.opts + opts.Manifests = true + all, err := service.Images(ctx, opts) assert.NilError(t, err) sort.Slice(all, func(i, j int) bool { diff --git a/docs/api/version-history.md b/docs/api/version-history.md index 86931c8e9d..01da617c01 100644 --- a/docs/api/version-history.md +++ b/docs/api/version-history.md @@ -22,6 +22,13 @@ keywords: "API, Docker, rcli, REST, documentation" daemon has experimental features enabled. * `GET /networks/{id}` now returns an `EnableIPv4` field showing whether the network has IPv4 IPAM enabled. +* `GET /images/json` response now includes `Manifests` field, which contains + information about the sub-manifests included in the image index. This + includes things like platform-specific manifests and build attestations. + The new field will only be populated if the request also sets the `manifests` + query parameter to `true`. + WARNING: This is experimental and may change at any time without any backward + compatibility. ## v1.46 API changes diff --git a/hack/generate-swagger-api.sh b/hack/generate-swagger-api.sh index 7136142bee..06f9bb3b29 100755 --- a/hack/generate-swagger-api.sh +++ b/hack/generate-swagger-api.sh @@ -26,8 +26,9 @@ swagger generate model -f api/swagger.yaml \ swagger generate model -f api/swagger.yaml \ -t api -m types/image --skip-validator -C api/swagger-gen.yaml \ - -n ImageDeleteResponseItem \ - -n ImageSummary + -n ImageDeleteResponseItem +#-n ImageSummary TODO: Restore when go-swagger is updated +# See https://github.com/moby/moby/pull/47526#discussion_r1551800022 swagger generate model -f api/swagger.yaml \ -t api -m types/network --skip-validator -C api/swagger-gen.yaml \ diff --git a/integration/image/list_test.go b/integration/image/list_test.go index 54d725315f..9953e4bd0b 100644 --- a/integration/image/list_test.go +++ b/integration/image/list_test.go @@ -2,13 +2,17 @@ package image // import "github.com/docker/docker/integration/image" import ( "fmt" + "slices" "strings" "testing" "time" + "github.com/docker/docker/api" containertypes "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/versions" + "github.com/docker/docker/client" "github.com/docker/docker/integration/internal/container" "github.com/docker/docker/internal/testutils/specialimage" "github.com/docker/docker/testutil" @@ -227,3 +231,72 @@ func TestAPIImagesListSizeShared(t *testing.T) { _, err := client.ImageList(ctx, image.ListOptions{SharedSize: true}) assert.NilError(t, err) } + +func TestAPIImagesListManifests(t *testing.T) { + skip.If(t, !testEnv.UsingSnapshotter()) + // Sub-daemons not supported on Windows + skip.If(t, testEnv.DaemonInfo.OSType == "windows") + + ctx := setupTest(t) + + d := daemon.New(t) + d.Start(t) + defer d.Stop(t) + + apiClient := d.NewClientT(t) + + testPlatforms := []ocispec.Platform{ + {OS: "windows", Architecture: "amd64"}, + {OS: "linux", Architecture: "arm", Variant: "v7"}, + {OS: "darwin", Architecture: "arm64"}, + } + specialimage.Load(ctx, t, apiClient, func(dir string) (*ocispec.Index, error) { + return specialimage.MultiPlatform(dir, "multiplatform:latest", testPlatforms) + }) + + t.Run("unsupported before 1.47", func(t *testing.T) { + // TODO: Remove when MinSupportedAPIVersion >= 1.47 + c := d.NewClientT(t, client.WithVersion(api.MinSupportedAPIVersion)) + + images, err := c.ImageList(ctx, image.ListOptions{Manifests: true}) + assert.NilError(t, err) + + assert.Assert(t, is.Len(images, 1)) + assert.Check(t, is.Nil(images[0].Manifests)) + }) + + skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), "1.47")) + + api147 := d.NewClientT(t, client.WithVersion("1.47")) + + t.Run("no manifests if not requested", func(t *testing.T) { + images, err := api147.ImageList(ctx, image.ListOptions{}) + assert.NilError(t, err) + + assert.Assert(t, is.Len(images, 1)) + assert.Check(t, is.Nil(images[0].Manifests)) + }) + + images, err := api147.ImageList(ctx, image.ListOptions{Manifests: true}) + assert.NilError(t, err) + + assert.Check(t, is.Len(images, 1)) + assert.Check(t, images[0].Manifests != nil) + assert.Check(t, is.Len(images[0].Manifests, 3)) + + for _, mfst := range images[0].Manifests { + // All manifests should be image manifests + assert.Check(t, is.Equal(mfst.Kind, image.ManifestKindImage)) + + // Full image was loaded so all manifests should be available + assert.Check(t, mfst.Available) + + // The platform should be one of the test platforms + if assert.Check(t, is.Contains(testPlatforms, mfst.ImageData.Platform)) { + testPlatforms = slices.DeleteFunc(testPlatforms, func(p ocispec.Platform) bool { + op := mfst.ImageData.Platform + return p.OS == op.OS && p.Architecture == op.Architecture && p.Variant == op.Variant + }) + } + } +}