diff --git a/cmd/nerdctl/image_inspect_test.go b/cmd/nerdctl/image_inspect_test.go index f55fd575593..dd28da16df8 100644 --- a/cmd/nerdctl/image_inspect_test.go +++ b/cmd/nerdctl/image_inspect_test.go @@ -17,10 +17,15 @@ package main import ( + "encoding/json" + "runtime" + "strings" "testing" - "github.com/containerd/nerdctl/v2/pkg/testutil" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestImageInspectContainsSomeStuff(t *testing.T) { @@ -39,9 +44,134 @@ func TestImageInspectWithFormat(t *testing.T) { base := testutil.NewBase(t) base.Cmd("pull", testutil.CommonImage).AssertOK() + // test RawFormat support base.Cmd("image", "inspect", testutil.CommonImage, "--format", "{{.Id}}").AssertOK() // test typedFormat support base.Cmd("image", "inspect", testutil.CommonImage, "--format", "{{.ID}}").AssertOK() } + +func inspectImageHelper(base *testutil.Base, identifier ...string) []dockercompat.Image { + args := append([]string{"image", "inspect"}, identifier...) + cmdResult := base.Cmd(args...).Run() + assert.Equal(base.T, cmdResult.ExitCode, 0) + var dc []dockercompat.Image + if err := json.Unmarshal([]byte(cmdResult.Stdout()), &dc); err != nil { + base.T.Fatal(err) + } + return dc +} + +func TestImageInspectDifferentValidReferencesForTheSameImage(t *testing.T) { + testutil.DockerIncompatible(t) + + if runtime.GOOS == "windows" { + t.Skip("Windows is not supported for this test right now") + } + + base := testutil.NewBase(t) + + // Overall, we need a clean slate before doing these lookups. + // More specifically, because we trigger https://github.com/containerd/nerdctl/issues/3016 + // we cannot do selective rmi, so, just nuke everything + ids := base.Cmd("image", "list", "-q").Out() + allIds := strings.Split(ids, "\n") + for _, id := range allIds { + id = strings.TrimSpace(id) + if id != "" { + base.Cmd("rmi", "-f", id).Run() + } + } + + base.Cmd("pull", "alpine", "--platform", "linux/amd64").AssertOK() + base.Cmd("pull", "busybox", "--platform", "linux/amd64").AssertOK() + base.Cmd("pull", "busybox:stable", "--platform", "linux/amd64").AssertOK() + base.Cmd("pull", "registry-1.docker.io/library/busybox", "--platform", "linux/amd64").AssertOK() + base.Cmd("pull", "registry-1.docker.io/library/busybox:stable", "--platform", "linux/amd64").AssertOK() + + tags := []string{ + "", + ":latest", + ":stable", + } + names := []string{ + "busybox", + "library/busybox", + "docker.io/library/busybox", + "registry-1.docker.io/library/busybox", + } + + // Build reference values for comparison + reference := inspectImageHelper(base, "busybox") + assert.Equal(base.T, 1, len(reference)) + // Extract image sha + sha := strings.TrimPrefix(reference[0].RepoDigests[0], "busybox@sha256:") + + differentReference := inspectImageHelper(base, "alpine") + assert.Equal(base.T, 1, len(differentReference)) + + // Testing all name and tags variants + for _, name := range names { + for _, tag := range tags { + t.Logf("Testing %s", name+tag) + result := inspectImageHelper(base, name+tag) + assert.Equal(base.T, 1, len(result)) + assert.Equal(base.T, reference[0].ID, result[0].ID) + } + } + + // Testing all name and tags variants, with a digest + for _, name := range names { + for _, tag := range tags { + t.Logf("Testing %s", name+tag+"@"+sha) + result := inspectImageHelper(base, name+tag+"@sha256:"+sha) + assert.Equal(base.T, 1, len(result)) + assert.Equal(base.T, reference[0].ID, result[0].ID) + } + } + + // Testing repo digest and short digest with or without prefix + for _, id := range []string{"sha256:" + sha, sha, sha[0:8], "sha256:" + sha[0:8]} { + t.Logf("Testing %s", id) + result := inspectImageHelper(base, id) + assert.Equal(base.T, 1, len(result)) + assert.Equal(base.T, reference[0].ID, result[0].ID) + } + + // Demonstrate image name precedence over digest lookup + // Using the shortened sha should no longer get busybox, but rather the newly tagged Alpine + t.Logf("Testing (alpine tagged) %s", sha[0:8]) + // Tag a different image with the short id + base.Cmd("tag", "alpine", sha[0:8]).AssertOK() + result := inspectImageHelper(base, sha[0:8]) + assert.Equal(base.T, 1, len(result)) + assert.Equal(base.T, differentReference[0].ID, result[0].ID) + + // Prove that wrong references with an existing digest do not get retrieved when asking by digest + for _, id := range []string{"doesnotexist", "doesnotexist:either", "busybox:bogustag"} { + t.Logf("Testing %s", id+"@"+sha) + args := append([]string{"image", "inspect"}, id+"@"+sha) + cmdResult := base.Cmd(args...).Run() + assert.Equal(base.T, cmdResult.ExitCode, 0) + assert.Equal(base.T, cmdResult.Stdout(), "") + } + + // Prove that invalid reference return no result without crashing + for _, id := range []string{"∞∞∞∞∞∞∞∞∞∞", "busybox:∞∞∞∞∞∞∞∞∞∞"} { + t.Logf("Testing %s", id) + args := append([]string{"image", "inspect"}, id) + cmdResult := base.Cmd(args...).Run() + assert.Equal(base.T, cmdResult.ExitCode, 0) + assert.Equal(base.T, cmdResult.Stdout(), "") + } + + // Retrieving multiple entries at once + t.Logf("Testing %s", "busybox busybox busybox:stable") + result = inspectImageHelper(base, "busybox", "busybox", "busybox:stable") + assert.Equal(base.T, 3, len(result)) + assert.Equal(base.T, reference[0].ID, result[0].ID) + assert.Equal(base.T, reference[0].ID, result[1].ID) + assert.Equal(base.T, reference[0].ID, result[2].ID) + +} diff --git a/go.mod b/go.mod index 9d7c20c1e6a..43f6d476590 100644 --- a/go.mod +++ b/go.mod @@ -76,7 +76,7 @@ require ( github.com/containerd/ttrpc v1.2.3 // indirect github.com/containerd/typeurl v1.0.3-0.20220422153119-7f6e6d160d67 // indirect github.com/containers/ocicrypt v1.1.10 // indirect - github.com/distribution/reference v0.6.0 // indirect + github.com/distribution/reference v0.6.0 github.com/djherbis/times v1.6.0 // indirect github.com/docker/docker-credential-helpers v0.7.0 // indirect github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect diff --git a/pkg/cmd/image/inspect.go b/pkg/cmd/image/inspect.go index f37e9e0e217..14b571f581e 100644 --- a/pkg/cmd/image/inspect.go +++ b/pkg/cmd/image/inspect.go @@ -19,58 +19,182 @@ package image import ( "context" "fmt" + "regexp" + "strings" "time" "github.com/containerd/containerd" + "github.com/containerd/containerd/images" "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/formatter" - "github.com/containerd/nerdctl/v2/pkg/idutil/imagewalker" "github.com/containerd/nerdctl/v2/pkg/imageinspector" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" + "github.com/distribution/reference" ) +func inspectIdentifier(ctx context.Context, client *containerd.Client, identifier string) ([]images.Image, string, string, error) { + // Figure out what we have here - digest, tag, name + parsedIdentifier, err := referenceutil.ParseAnyReference(identifier) + if err != nil { + return nil, "", "", fmt.Errorf("invalid identifier %s: %w", identifier, err) + } + digest := "" + if identifierDigest, hasDigest := parsedIdentifier.(reference.Digested); hasDigest { + digest = identifierDigest.Digest().String() + } + name := "" + if identifierName, hasName := parsedIdentifier.(reference.Named); hasName { + name = identifierName.Name() + } + tag := "latest" + if identifierTag, hasTag := parsedIdentifier.(reference.Tagged); hasTag && identifierTag.Tag() != "" { + tag = identifierTag.Tag() + } + + // Initialize filters + var filters []string + // This will hold the final image list, if any + var imageList []images.Image + + // No digest in the request? Then assume it is a name + if digest == "" { + filters = []string{fmt.Sprintf("name==%s:%s", name, tag)} + // Query it + imageList, err = client.ImageService().List(ctx, filters...) + if err != nil { + return nil, "", "", fmt.Errorf("containerd image service failed: %w", err) + } + // Nothing? Then it could be a short id (aka truncated digest) - we are going to use this + if len(imageList) == 0 { + digest = fmt.Sprintf("sha256:%s.*", regexp.QuoteMeta(strings.TrimPrefix(identifier, "sha256:"))) + name = "" + tag = "" + } else { + // Otherwise, we found one by name. Get the digest from it. + digest = imageList[0].Target.Digest.String() + } + } + + // At this point, we DO have a digest (or short id), so, that is what we are retrieving + filters = []string{fmt.Sprintf("target.digest~=^%s$", digest)} + imageList, err = client.ImageService().List(ctx, filters...) + if err != nil { + return nil, "", "", fmt.Errorf("containerd image service failed: %w", err) + } + + // TODO: docker does allow retrieving images by Id, so implement as a last ditch effort (probably look-up the store) + + // Return the list we found, along with normalized name and tag + return imageList, name, tag, nil +} + // Inspect prints detailed information of each image in `images`. -func Inspect(ctx context.Context, client *containerd.Client, images []string, options types.ImageInspectOptions) error { - f := &imageInspector{ - mode: options.Mode, +func Inspect(ctx context.Context, client *containerd.Client, identifiers []string, options types.ImageInspectOptions) error { + // Verify we have a valid mode + // TODO: move this out of here, to Cobra command line arg validation + if options.Mode != "native" && options.Mode != "dockercompat" { + return fmt.Errorf("unknown mode %q", options.Mode) } - walker := &imagewalker.ImageWalker{ - Client: client, - OnFound: func(ctx context.Context, found imagewalker.Found) error { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - n, err := imageinspector.Inspect(ctx, client, found.Image, options.GOptions.Snapshotter) + // Set a timeout + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + // Will hold the final answers + var entries []interface{} + + // We have to query per provided identifier, as we need to post-process results for the case name + digest + for _, identifier := range identifiers { + candidateImageList, requestedName, requestedTag, err := inspectIdentifier(ctx, client, identifier) + if err != nil { + log.G(ctx).WithError(err).WithField("identifier", identifier).Error("failure calling inspect") + continue + } + + var validatedImage *dockercompat.Image + var repoTags []string + var repoDigests []string + + // Go through the candidates + for _, candidateImage := range candidateImageList { + // Inspect the image + candidateNativeImage, err := imageinspector.Inspect(ctx, client, candidateImage, options.GOptions.Snapshotter) if err != nil { - return err + log.G(ctx).WithError(err).WithField("name", candidateImage.Name).Error("failure inspecting image") + continue } - switch f.mode { - case "native": - f.entries = append(f.entries, n) - case "dockercompat": - d, err := dockercompat.ImageFromNative(n) + + // If native, we just add everything in there and that's it + if options.Mode == "native" { + entries = append(entries, candidateNativeImage) + continue + } + + // If dockercompat: does the candidate have a name? Get it if so + candidateRef, err := referenceutil.ParseAnyReference(candidateNativeImage.Image.Name) + if err != nil { + log.G(ctx).WithError(err).WithField("name", candidateNativeImage.Image.Name).Error("the found image has an unparsable name") + continue + } + parsedCandidateNameTag, candidateHasAName := candidateRef.(reference.NamedTagged) + + // If we were ALSO asked for a specific name on top of the digest, we need to make sure we keep only the image with that name + if requestedName != "" { + // If the candidate did not have a name, then we should ignore this one and continue + if !candidateHasAName { + continue + } + + // Otherwise, the candidate has a name. If it is the one we want, store it and continue, otherwise, fall through + candidateTag := parsedCandidateNameTag.Tag() + if candidateTag == "" { + candidateTag = "latest" + } + if parsedCandidateNameTag.Name() == requestedName && candidateTag == requestedTag { + validatedImage, err = dockercompat.ImageFromNative(candidateNativeImage) + if err != nil { + log.G(ctx).WithError(err).WithField("name", candidateNativeImage.Image.Name).Error("could not get a docker compat version of the native image") + } + continue + } + } else if validatedImage == nil { + // Alternatively, we got a request by digest only, so, if we do not know about it already, store it and continue + validatedImage, err = dockercompat.ImageFromNative(candidateNativeImage) if err != nil { - return err + log.G(ctx).WithError(err).WithField("name", candidateNativeImage.Image.Name).Error("could not get a docker compat version of the native image") } - f.entries = append(f.entries, d) - default: - return fmt.Errorf("unknown mode %q", f.mode) + continue + } + + // Fallthrough cases: + // - we got a request by digest, but we already had the image stored + // - we got a request by name, and the name of the candidate did not match the requested name + // Now, check if the candidate has a name - if it does, populate repoTags and repoDigests + if candidateHasAName { + repoTags = append(repoTags, fmt.Sprintf("%s:%s", reference.FamiliarName(parsedCandidateNameTag), parsedCandidateNameTag.Tag())) + repoDigests = append(repoDigests, fmt.Sprintf("%s@%s", reference.FamiliarName(parsedCandidateNameTag), candidateImage.Target.Digest.String())) } - return nil - }, + } + + // Done iterating through candidates. Did we find anything that matches? + if validatedImage != nil { + // Then slap in the repoTags and repoDigests we found from the other candidates + validatedImage.RepoTags = append(validatedImage.RepoTags, repoTags...) + validatedImage.RepoDigests = append(validatedImage.RepoDigests, repoDigests...) + // Store our image + // foundImages[validatedDigest] = validatedImage + entries = append(entries, validatedImage) + } } - err := walker.WalkAll(ctx, images, true) - if len(f.entries) > 0 { - if formatErr := formatter.FormatSlice(options.Format, options.Stdout, f.entries); formatErr != nil { + // Display + if len(entries) > 0 { + if formatErr := formatter.FormatSlice(options.Format, options.Stdout, entries); formatErr != nil { log.G(ctx).Error(formatErr) } } - return err -} -type imageInspector struct { - mode string - entries []interface{} + return nil } diff --git a/pkg/inspecttypes/dockercompat/dockercompat.go b/pkg/inspecttypes/dockercompat/dockercompat.go index da9f714c9df..bd3320977b1 100644 --- a/pkg/inspecttypes/dockercompat/dockercompat.go +++ b/pkg/inspecttypes/dockercompat/dockercompat.go @@ -37,7 +37,7 @@ import ( "github.com/containerd/containerd" "github.com/containerd/containerd/runtime/restart" - gocni "github.com/containerd/go-cni" + "github.com/containerd/go-cni" "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/imgutil" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" @@ -47,28 +47,39 @@ import ( "github.com/tidwall/gjson" ) -// Image mimics a `docker image inspect` object. -// From https://github.com/moby/moby/blob/v20.10.1/api/types/types.go#L340-L374 +// From https://github.com/moby/moby/blob/v26.1.2/api/types/types.go#L34-L140 type Image struct { - ID string `json:"Id"` - RepoTags []string - RepoDigests []string - // TODO: Parent string - Comment string - Created string - // TODO: Container string - // TODO: ContainerConfig *container.Config - // TODO: DockerVersion string - Author string - Config *Config - Architecture string - // TODO: Variant string `json:",omitempty"` - Os string + ID string `json:"Id"` + RepoTags []string + RepoDigests []string + Parent string + Comment string + Created string + DockerVersion string + Author string + Config *Config + Architecture string + Variant string `json:",omitempty"` + Os string + // TODO: OsVersion string `json:",omitempty"` - Size int64 // Size is the unpacked size of the image - // TODO: GraphDriver GraphDriverData + + Size int64 // Size is the unpacked size of the image + VirtualSize int64 `json:"VirtualSize,omitempty"` // Deprecated + + // TODO: GraphDriver GraphDriverData + RootFS RootFS Metadata ImageMetadata + + // Deprecated: TODO: Container string + // Deprecated: TODO: ContainerConfig *container.Config +} + +// From: https://github.com/moby/moby/blob/v26.1.2/api/types/graph_driver_data.go +type GraphDriverData struct { + Data map[string]string `json:"Data"` + Name string `json:"Name"` } type RootFS struct { @@ -294,48 +305,51 @@ func ContainerFromNative(n *native.Container) (*Container, error) { return c, nil } -func ImageFromNative(n *native.Image) (*Image, error) { - i := &Image{} - - imgoci := n.ImageConfig +func ImageFromNative(nativeImage *native.Image) (*Image, error) { + imgOCI := nativeImage.ImageConfig + repository, tag := imgutil.ParseRepoTag(nativeImage.Image.Name) + + image := &Image{ + // Docker ID (digest of platform-specific config), not containerd ID (digest of multi-platform index or manifest) + ID: nativeImage.ImageConfigDesc.Digest.String(), + Parent: nativeImage.Image.Labels["org.mobyproject.image.parent"], + Architecture: imgOCI.Architecture, + Variant: imgOCI.Platform.Variant, + Os: imgOCI.OS, + Size: nativeImage.Size, + VirtualSize: nativeImage.Size, + RepoTags: []string{fmt.Sprintf("%s:%s", repository, tag)}, + RepoDigests: []string{fmt.Sprintf("%s@%s", repository, nativeImage.Image.Target.Digest.String())}, + } - i.RootFS.Type = imgoci.RootFS.Type - diffIDs := imgoci.RootFS.DiffIDs - for _, d := range diffIDs { - i.RootFS.Layers = append(i.RootFS.Layers, d.String()) + if len(imgOCI.History) > 0 { + image.Comment = imgOCI.History[len(imgOCI.History)-1].Comment + image.Created = imgOCI.History[len(imgOCI.History)-1].Created.Format(time.RFC3339Nano) + image.Author = imgOCI.History[len(imgOCI.History)-1].Author } - if len(imgoci.History) > 0 { - i.Comment = imgoci.History[len(imgoci.History)-1].Comment - i.Created = imgoci.History[len(imgoci.History)-1].Created.Format(time.RFC3339Nano) - i.Author = imgoci.History[len(imgoci.History)-1].Author + + image.RootFS.Type = imgOCI.RootFS.Type + for _, d := range imgOCI.RootFS.DiffIDs { + image.RootFS.Layers = append(image.RootFS.Layers, d.String()) } - i.Architecture = imgoci.Architecture - i.Os = imgoci.OS portSet := make(nat.PortSet) - for k := range imgoci.Config.ExposedPorts { + for k := range imgOCI.Config.ExposedPorts { portSet[nat.Port(k)] = struct{}{} } - i.Config = &Config{ - Cmd: imgoci.Config.Cmd, - Volumes: imgoci.Config.Volumes, - Env: imgoci.Config.Env, - User: imgoci.Config.User, - WorkingDir: imgoci.Config.WorkingDir, - Entrypoint: imgoci.Config.Entrypoint, - Labels: imgoci.Config.Labels, + image.Config = &Config{ + Cmd: imgOCI.Config.Cmd, + Volumes: imgOCI.Config.Volumes, + Env: imgOCI.Config.Env, + User: imgOCI.Config.User, + WorkingDir: imgOCI.Config.WorkingDir, + Entrypoint: imgOCI.Config.Entrypoint, + Labels: imgOCI.Config.Labels, ExposedPorts: portSet, } - i.ID = n.ImageConfigDesc.Digest.String() // Docker ID (digest of platform-specific config), not containerd ID (digest of multi-platform index or manifest) - - repository, tag := imgutil.ParseRepoTag(n.Image.Name) - - i.RepoTags = []string{fmt.Sprintf("%s:%s", repository, tag)} - i.RepoDigests = []string{fmt.Sprintf("%s@%s", repository, n.Image.Target.Digest.String())} - i.Size = n.Size - return i, nil + return image, nil } // mountsFromNative only filters bind mount to transform from native container. @@ -411,7 +425,7 @@ func networkSettingsFromNative(n *native.NetNS, sp *specs.Spec) (*NetworkSetting res.Networks[fakeDockerNetworkName] = nes if portsLabel, ok := sp.Annotations[labels.Ports]; ok { - var ports []gocni.PortMapping + var ports []cni.PortMapping err := json.Unmarshal([]byte(portsLabel), &ports) if err != nil { return nil, err @@ -437,7 +451,7 @@ func networkSettingsFromNative(n *native.NetNS, sp *specs.Spec) (*NetworkSetting return res, nil } -func convertToNatPort(portMappings []gocni.PortMapping) (*nat.PortMap, error) { +func convertToNatPort(portMappings []cni.PortMapping) (*nat.PortMap, error) { portMap := make(nat.PortMap) for _, portMapping := range portMappings { ports := []nat.PortBinding{} diff --git a/pkg/referenceutil/referenceutil.go b/pkg/referenceutil/referenceutil.go index 86de6558feb..9507bad23eb 100644 --- a/pkg/referenceutil/referenceutil.go +++ b/pkg/referenceutil/referenceutil.go @@ -32,6 +32,19 @@ type Reference interface { String() string } +// ParseAnyReference parses the passed reference as IPFS, CID, or a classic reference. +// Unlike ParseAny, it is not limited to the DockerRef limitations (being either tagged or digested) +// and should be used instead. +func ParseAnyReference(rawRef string) (Reference, error) { + if scheme, ref, err := ParseIPFSRefWithScheme(rawRef); err == nil { + return Reference(stringRef{scheme: scheme, s: ref}), nil + } + if c, err := cid.Decode(rawRef); err == nil { + return c, nil + } + return refdocker.ParseAnyReference(rawRef) +} + // ParseAny parses the passed reference with allowing it to be non-docker reference. // If the ref has IPFS scheme or can be parsed as CID, it's parsed as an IPFS reference. // Otherwise it's parsed as a docker reference.