Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

c8d/inspect: Add Manifests field #48264

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion api/server/router/image/image_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,17 @@ func (ir *imageRouter) deleteImages(ctx context.Context, w http.ResponseWriter,
}

func (ir *imageRouter) getImagesByName(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
imageInspect, err := ir.backend.ImageInspect(ctx, vars["name"], backend.ImageInspectOpts{})
if err := httputils.ParseForm(r); err != nil {
return err
}

if r.Form.Get("manifests") != "" && versions.LessThan(httputils.VersionFromContext(ctx), "1.48") {
return errdefs.InvalidParameter(errors.New("manifests parameter is not supported for API version < 1.48"))
}
Comment on lines +339 to +341
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably fine to have checks on both clients and API. I was considering that this is an option that we could consider to be ignored on the daemon side, i.e., I think the client would be able to gracefully handle responses where this information is not present.

No need to change, just as a comment, in case we want to revisit this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm right, older daemons that don't understand the new API wouldn't complain about this, so I think there's no point to make newer daemon fail in this case?


imageInspect, err := ir.backend.ImageInspect(ctx, vars["name"], backend.ImageInspectOpts{
Manifests: httputils.BoolValue(r, "manifests"),
})
if err != nil {
return err
}
Expand Down
21 changes: 21 additions & 0 deletions api/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2004,6 +2004,21 @@ definitions:
compatibility.
x-nullable: true
$ref: "#/definitions/OCIDescriptor"
Manifests:
description: |
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
and the `manifests` option is set in the inspect request.

WARNING: This is experimental and may change at any time without any backward
compatibility.
type: "array"
x-nullable: true
items:
$ref: "#/definitions/ImageManifestSummary"
RepoTags:
description: |
List of image names/tags in the local image cache that reference this
Expand Down Expand Up @@ -9617,6 +9632,12 @@ paths:
description: "Image name or id"
type: "string"
required: true
- name: "manifests"
in: "query"
description: "Include Manifests in the image summary."
type: "boolean"
default: false
required: false
tags: ["Image"]
/images/{name}/history:
get:
Expand Down
4 changes: 3 additions & 1 deletion api/types/backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ type GetImageOpts struct {
}

// ImageInspectOpts holds parameters to inspect an image.
type ImageInspectOpts struct{}
type ImageInspectOpts struct {
Manifests bool
}

// CommitConfig is the configuration for creating an image as part of a build.
type CommitConfig struct {
Expand Down
10 changes: 10 additions & 0 deletions api/types/image/image_inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,14 @@ type InspectResponse struct {
// WARNING: This is experimental and may change at any time without any backward
// compatibility.
Descriptor *ocispec.Descriptor `json:"Descriptor,omitempty"`

// 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.
//
// WARNING: This is experimental and may change at any time without any backward
// compatibility.
Manifests []ManifestSummary `json:"Manifests,omitempty"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This field should probably be gated by API version.

Slightly wondering if this should be (at least currently?) an optional field; similar to how we have the "tree view" optional.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right, added a version gate.

Why should it be optional though? You can only inspect one image anyway, so it won't bloat the transmitted data too much.

}
5 changes: 5 additions & 0 deletions api/types/image/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ type LoadOptions struct {
Platforms []ocispec.Platform
}

type InspectOptions struct {
// Manifests returns the image manifests.
Manifests bool
}

Comment on lines +106 to +110
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It just occurred to me that there's 2 option structs now;

  • backend.ImageInspectOptions (used by the API server)
  • image.InspectOptions (used by the client)

The last one, by design, is not exported; it's made internal through imageInspectClientOpt.apiOptions (non-exported type AND non-exported field)

Given that those are not exported currently, perhaps we should (for now at least) move this type to the client, and un-export it?

⚠️ 👉 edit: ignore me; there's ImageInspectWithOpts, which allows all options to be passed as a struct

// SaveOptions holds parameters to save images.
type SaveOptions struct {
// Platforms selects the platforms to save if the image is a
Expand Down
50 changes: 39 additions & 11 deletions client/image_inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,56 @@ import (
"context"
"encoding/json"
"io"
"net/url"

"github.com/docker/docker/api/types/image"
)

// ImageInspectWithRaw returns the image information and its raw representation.
func (cli *Client) ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) {
type ImageInspectOpts struct {
image.InspectOptions

// Raw is an optional writer to write the raw inspect response to.
Raw io.Writer
}

// ImageInspect returns the image information.
func (cli *Client) ImageInspect(ctx context.Context, imageID string, opts ImageInspectOpts) (image.InspectResponse, error) {
if imageID == "" {
return image.InspectResponse{}, nil, objectNotFoundError{object: "image", id: imageID}
return image.InspectResponse{}, objectNotFoundError{object: "image", id: imageID}
}
serverResp, err := cli.get(ctx, "/images/"+imageID+"/json", nil, nil)

query := url.Values{}
if opts.Manifests {
if err := cli.NewVersionError(ctx, "1.48", "manifests"); err != nil {
return image.InspectResponse{}, err
}
query.Set("manifests", "1")
}

serverResp, err := cli.get(ctx, "/images/"+imageID+"/json", query, nil)
defer ensureReaderClosed(serverResp)
if err != nil {
return image.InspectResponse{}, nil, err
return image.InspectResponse{}, err
}

body, err := io.ReadAll(serverResp.body)
if err != nil {
return image.InspectResponse{}, nil, err
var reader io.Reader = serverResp.body
if opts.Raw != nil {
reader = io.TeeReader(serverResp.body, opts.Raw)
}

var response image.InspectResponse
rdr := bytes.NewReader(body)
err = json.NewDecoder(rdr).Decode(&response)
return response, body, err
err = json.NewDecoder(reader).Decode(&response)
return response, err
}

// ImageInspectWithRaw returns the image information and its raw representation.
//
// TODeprecate: Use [ImageInspect] instead.
func (cli *Client) ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) {
var buf bytes.Buffer
resp, err := cli.ImageInspect(ctx, imageID, ImageInspectOpts{Raw: &buf})
if err != nil {
return image.InspectResponse{}, nil, err
}
return resp, buf.Bytes(), err
}
1 change: 1 addition & 0 deletions client/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ type ImageAPIClient interface {
ImageHistory(ctx context.Context, image string, opts image.HistoryOptions) ([]image.HistoryResponseItem, error)
ImageImport(ctx context.Context, source image.ImportSource, ref string, options image.ImportOptions) (io.ReadCloser, error)
ImageInspectWithRaw(ctx context.Context, image string) (image.InspectResponse, []byte, error)
ImageInspect(ctx context.Context, image string, opts ImageInspectOpts) (image.InspectResponse, error)
ImageList(ctx context.Context, options image.ListOptions) ([]image.Summary, error)
ImageLoad(ctx context.Context, input io.Reader, opts image.LoadOptions) (image.LoadResponse, error)
ImagePull(ctx context.Context, ref string, options image.PullOptions) (io.ReadCloser, error)
Expand Down
147 changes: 96 additions & 51 deletions daemon/containerd/image_inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,77 +16,57 @@ import (
"github.com/docker/docker/api/types/backend"
imagetypes "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/storage"
"github.com/docker/docker/internal/sliceutil"
imagespec "github.com/moby/docker-image-spec/specs-go/v1"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"golang.org/x/sync/semaphore"
)

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) {
c8dImg, err := i.resolveImage(ctx, refOrID)
if err != nil {
return nil, err
}

lastUpdated := time.Unix(0, 0)

tagged, err := i.images.List(ctx, "target.digest=="+img.ImageID())
target := c8dImg.Target
tagged, err := i.images.List(ctx, "target.digest=="+target.Digest.String())
if err != nil {
return nil, err
}

// This could happen only if the image was deleted after the GetImage call above.
// This could happen only if the image was deleted after the resolveImage call above.
if len(tagged) == 0 {
return nil, errInconsistentData
}

platform := matchAllWithPreference(platforms.Default())

size, err := i.size(ctx, tagged[0].Target, platform)
if err != nil {
return nil, err
}
target := tagged[0].Target

repoTags := make([]string, 0, len(tagged))
repoDigests := make([]string, 0, len(tagged))
lastUpdated := time.Unix(0, 0)
for _, i := range tagged {
if i.UpdatedAt.After(lastUpdated) {
lastUpdated = i.UpdatedAt
}
if isDanglingImage(i) {
if len(tagged) > 1 {
// This is unexpected - dangling image should be deleted
// as soon as another image with the same target is created.
// Log a warning, but don't error out the whole operation.
log.G(ctx).WithField("refs", tagged).Warn("multiple images have the same target, but one of them is still dangling")
}
continue
}
}

name, err := reference.ParseNamed(i.Name)
if err != nil {
log.G(ctx).WithField("name", name).WithError(err).Error("failed to parse image name as reference")
// Include the malformed name in RepoTags to be consistent with `docker image ls`.
repoTags = append(repoTags, i.Name)
continue
}
platform := matchAllWithPreference(platforms.Default())
size, err := i.size(ctx, target, platform)
if err != nil {
return nil, err
}

repoTags = append(repoTags, reference.FamiliarString(name))
if _, ok := name.(reference.Digested); ok {
repoDigests = append(repoDigests, reference.FamiliarString(name))
// Image name is a digested reference already, so no need to create a digested reference.
continue
}
multi, err := i.multiPlatformSummary(ctx, c8dImg, platform)
if err != nil {
return nil, err
}

digested, err := reference.WithDigest(reference.TrimNamed(name), target.Digest)
if err != nil {
// This could only happen if digest is invalid, but considering that
// we get it from the Descriptor it's highly unlikely.
// Log error just in case.
log.G(ctx).WithError(err).Error("failed to create digested reference")
continue
if multi.Best == nil {
return nil, &errPlatformNotFound{
wanted: platforms.DefaultSpec(),
imageRef: refOrID,
}
repoDigests = append(repoDigests, reference.FamiliarString(digested))
}

best := multi.Best
var img imagespec.DockerOCIImage
if err := best.ReadConfig(ctx, &img); err != nil {
return nil, err
}

var comment string
Expand All @@ -104,22 +84,35 @@ func (i *ImageService) ImageInspect(ctx context.Context, refOrID string, _ backe
layers = append(layers, layer.String())
}

parent, err := i.getImageLabelByDigest(ctx, target.Digest, imageLabelClassicBuilderParent)
if err != nil {
log.G(ctx).WithError(err).Warn("failed to determine Parent property")
}

var manifests []imagetypes.ManifestSummary
if opts.Manifests {
manifests = multi.Manifests
}

repoTags, repoDigests := collectRepoTagsAndDigests(ctx, tagged)

return &imagetypes.InspectResponse{
ID: img.ImageID(),
ID: target.Digest.String(),
RepoTags: repoTags,
Descriptor: &target,
RepoDigests: sliceutil.Dedup(repoDigests),
Parent: img.Parent.String(),
RepoDigests: repoDigests,
Parent: parent,
Comment: comment,
Created: created,
DockerVersion: "",
Author: img.Author,
Config: img.Config,
Config: dockerOCIImageConfigToContainerConfig(img.Config),
Architecture: img.Architecture,
Variant: img.Variant,
Os: img.OS,
OsVersion: img.OSVersion,
Size: size,
Manifests: manifests,
GraphDriver: storage.DriverData{
Name: i.snapshotter,
Data: nil,
Expand All @@ -134,6 +127,58 @@ func (i *ImageService) ImageInspect(ctx context.Context, refOrID string, _ backe
}, nil
}

func collectRepoTagsAndDigests(ctx context.Context, tagged []c8dimages.Image) (repoTags []string, repoDigests []string) {
repoTagsMap := make(map[string]struct{})
repoDigestsMap := make(map[string]struct{})

for _, img := range tagged {
if isDanglingImage(img) {
if len(tagged) > 1 {
// This is unexpected - dangling image should be deleted
// as soon as another image with the same target is created.
// Log a warning, but don't error out the whole operation.
log.G(ctx).WithField("refs", tagged).Warn("multiple images have the same target, but one of them is still dangling")
}
continue
}

name, err := reference.ParseNamed(img.Name)
if err != nil {
log.G(ctx).WithField("name", name).WithError(err).Error("failed to parse image name as reference")
// Include the malformed name in RepoTags to be consistent with `docker image ls`.
repoTagsMap[img.Name] = struct{}{}
continue
}

repoTagsMap[reference.FamiliarString(name)] = struct{}{}
if _, ok := name.(reference.Digested); ok {
repoDigestsMap[reference.FamiliarString(name)] = struct{}{}
// Image name is a digested reference already, so no need to create a digested reference.
continue
}

digested, err := reference.WithDigest(reference.TrimNamed(name), img.Target.Digest)
if err != nil {
// This could only happen if digest is invalid, but considering that
// we get it from the Descriptor it's highly unlikely.
// Log error just in case.
log.G(ctx).WithError(err).Error("failed to create digested reference")
continue
}
repoDigestsMap[reference.FamiliarString(digested)] = struct{}{}
}

repoTags = make([]string, 0, len(repoTagsMap))
repoDigests = make([]string, 0, len(repoDigestsMap))
for tag := range repoTagsMap {
repoTags = append(repoTags, tag)
}
for digest := range repoDigestsMap {
repoDigests = append(repoDigests, digest)
}
return repoTags, repoDigests
}

// size returns the total size of the image's packed resources.
func (i *ImageService) size(ctx context.Context, desc ocispec.Descriptor, platform platforms.MatchComparer) (int64, error) {
var size atomic.Int64
Expand Down
Loading
Loading