Skip to content

Commit eb001fa

Browse files
committed
Image inspect rework
Signed-off-by: apostasie <[email protected]>
1 parent 4fff6b2 commit eb001fa

File tree

5 files changed

+365
-84
lines changed

5 files changed

+365
-84
lines changed

Diff for: cmd/nerdctl/image_inspect_test.go

+131-1
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,15 @@
1717
package main
1818

1919
import (
20+
"encoding/json"
21+
"runtime"
22+
"strings"
2023
"testing"
2124

22-
"github.com/containerd/nerdctl/v2/pkg/testutil"
2325
"gotest.tools/v3/assert"
26+
27+
"github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat"
28+
"github.com/containerd/nerdctl/v2/pkg/testutil"
2429
)
2530

2631
func TestImageInspectContainsSomeStuff(t *testing.T) {
@@ -39,9 +44,134 @@ func TestImageInspectWithFormat(t *testing.T) {
3944
base := testutil.NewBase(t)
4045

4146
base.Cmd("pull", testutil.CommonImage).AssertOK()
47+
4248
// test RawFormat support
4349
base.Cmd("image", "inspect", testutil.CommonImage, "--format", "{{.Id}}").AssertOK()
4450

4551
// test typedFormat support
4652
base.Cmd("image", "inspect", testutil.CommonImage, "--format", "{{.ID}}").AssertOK()
4753
}
54+
55+
func inspectImageHelper(base *testutil.Base, identifier ...string) []dockercompat.Image {
56+
args := append([]string{"image", "inspect"}, identifier...)
57+
cmdResult := base.Cmd(args...).Run()
58+
assert.Equal(base.T, cmdResult.ExitCode, 0)
59+
var dc []dockercompat.Image
60+
if err := json.Unmarshal([]byte(cmdResult.Stdout()), &dc); err != nil {
61+
base.T.Fatal(err)
62+
}
63+
return dc
64+
}
65+
66+
func TestImageInspectDifferentValidReferencesForTheSameImage(t *testing.T) {
67+
testutil.DockerIncompatible(t)
68+
69+
if runtime.GOOS == "windows" {
70+
t.Skip("Windows is not supported for this test right now")
71+
}
72+
73+
base := testutil.NewBase(t)
74+
75+
// Overall, we need a clean slate before doing these lookups.
76+
// More specifically, because we trigger https://github.com/containerd/nerdctl/issues/3016
77+
// we cannot do selective rmi, so, just nuke everything
78+
ids := base.Cmd("image", "list", "-q").Out()
79+
allIds := strings.Split(ids, "\n")
80+
for _, id := range allIds {
81+
id = strings.TrimSpace(id)
82+
if id != "" {
83+
base.Cmd("rmi", "-f", id).Run()
84+
}
85+
}
86+
87+
base.Cmd("pull", "alpine", "--platform", "linux/amd64").AssertOK()
88+
base.Cmd("pull", "busybox", "--platform", "linux/amd64").AssertOK()
89+
base.Cmd("pull", "busybox:stable", "--platform", "linux/amd64").AssertOK()
90+
base.Cmd("pull", "registry-1.docker.io/library/busybox", "--platform", "linux/amd64").AssertOK()
91+
base.Cmd("pull", "registry-1.docker.io/library/busybox:stable", "--platform", "linux/amd64").AssertOK()
92+
93+
tags := []string{
94+
"",
95+
":latest",
96+
":stable",
97+
}
98+
names := []string{
99+
"busybox",
100+
"library/busybox",
101+
"docker.io/library/busybox",
102+
"registry-1.docker.io/library/busybox",
103+
}
104+
105+
// Build reference values for comparison
106+
reference := inspectImageHelper(base, "busybox")
107+
assert.Equal(base.T, 1, len(reference))
108+
// Extract image sha
109+
sha := strings.TrimPrefix(reference[0].RepoDigests[0], "busybox@sha256:")
110+
111+
differentReference := inspectImageHelper(base, "alpine")
112+
assert.Equal(base.T, 1, len(differentReference))
113+
114+
// Testing all name and tags variants
115+
for _, name := range names {
116+
for _, tag := range tags {
117+
t.Logf("Testing %s", name+tag)
118+
result := inspectImageHelper(base, name+tag)
119+
assert.Equal(base.T, 1, len(result))
120+
assert.Equal(base.T, reference[0].ID, result[0].ID)
121+
}
122+
}
123+
124+
// Testing all name and tags variants, with a digest
125+
for _, name := range names {
126+
for _, tag := range tags {
127+
t.Logf("Testing %s", name+tag+"@"+sha)
128+
result := inspectImageHelper(base, name+tag+"@sha256:"+sha)
129+
assert.Equal(base.T, 1, len(result))
130+
assert.Equal(base.T, reference[0].ID, result[0].ID)
131+
}
132+
}
133+
134+
// Testing repo digest and short digest with or without prefix
135+
for _, id := range []string{"sha256:" + sha, sha, sha[0:8], "sha256:" + sha[0:8]} {
136+
t.Logf("Testing %s", id)
137+
result := inspectImageHelper(base, id)
138+
assert.Equal(base.T, 1, len(result))
139+
assert.Equal(base.T, reference[0].ID, result[0].ID)
140+
}
141+
142+
// Demonstrate image name precedence over digest lookup
143+
// Using the shortened sha should no longer get busybox, but rather the newly tagged Alpine
144+
t.Logf("Testing (alpine tagged) %s", sha[0:8])
145+
// Tag a different image with the short id
146+
base.Cmd("tag", "alpine", sha[0:8]).AssertOK()
147+
result := inspectImageHelper(base, sha[0:8])
148+
assert.Equal(base.T, 1, len(result))
149+
assert.Equal(base.T, differentReference[0].ID, result[0].ID)
150+
151+
// Prove that wrong references with an existing digest do not get retrieved when asking by digest
152+
for _, id := range []string{"doesnotexist", "doesnotexist:either", "busybox:bogustag"} {
153+
t.Logf("Testing %s", id+"@"+sha)
154+
args := append([]string{"image", "inspect"}, id+"@"+sha)
155+
cmdResult := base.Cmd(args...).Run()
156+
assert.Equal(base.T, cmdResult.ExitCode, 0)
157+
assert.Equal(base.T, cmdResult.Stdout(), "")
158+
}
159+
160+
// Prove that invalid reference return no result without crashing
161+
for _, id := range []string{"∞∞∞∞∞∞∞∞∞∞", "busybox:∞∞∞∞∞∞∞∞∞∞"} {
162+
t.Logf("Testing %s", id)
163+
args := append([]string{"image", "inspect"}, id)
164+
cmdResult := base.Cmd(args...).Run()
165+
assert.Equal(base.T, cmdResult.ExitCode, 0)
166+
assert.Equal(base.T, cmdResult.Stdout(), "")
167+
}
168+
169+
// Retrieving multiple entries at once
170+
t.Logf("Testing %s", "busybox busybox busybox:stable")
171+
result = inspectImageHelper(base, "busybox", "busybox", "busybox:stable")
172+
assert.Equal(base.T, 3, len(result))
173+
assert.Equal(base.T, reference[0].ID, result[0].ID)
174+
assert.Equal(base.T, reference[0].ID, result[1].ID)
175+
assert.Equal(base.T, reference[0].ID, result[2].ID)
176+
177+
}

Diff for: go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ require (
7676
github.com/containerd/ttrpc v1.2.3 // indirect
7777
github.com/containerd/typeurl v1.0.3-0.20220422153119-7f6e6d160d67 // indirect
7878
github.com/containers/ocicrypt v1.1.10 // indirect
79-
github.com/distribution/reference v0.6.0 // indirect
79+
github.com/distribution/reference v0.6.0
8080
github.com/djherbis/times v1.6.0 // indirect
8181
github.com/docker/docker-credential-helpers v0.7.0 // indirect
8282
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect

Diff for: pkg/cmd/image/inspect.go

+154-30
Original file line numberDiff line numberDiff line change
@@ -19,58 +19,182 @@ package image
1919
import (
2020
"context"
2121
"fmt"
22+
"regexp"
23+
"strings"
2224
"time"
2325

2426
"github.com/containerd/containerd"
27+
"github.com/containerd/containerd/images"
2528
"github.com/containerd/log"
2629
"github.com/containerd/nerdctl/v2/pkg/api/types"
2730
"github.com/containerd/nerdctl/v2/pkg/formatter"
28-
"github.com/containerd/nerdctl/v2/pkg/idutil/imagewalker"
2931
"github.com/containerd/nerdctl/v2/pkg/imageinspector"
3032
"github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat"
33+
"github.com/containerd/nerdctl/v2/pkg/referenceutil"
34+
"github.com/distribution/reference"
3135
)
3236

37+
func inspectIdentifier(ctx context.Context, client *containerd.Client, identifier string) ([]images.Image, string, string, error) {
38+
// Figure out what we have here - digest, tag, name
39+
parsedIdentifier, err := referenceutil.ParseAnyReference(identifier)
40+
if err != nil {
41+
return nil, "", "", fmt.Errorf("invalid identifier %s: %w", identifier, err)
42+
}
43+
digest := ""
44+
if identifierDigest, hasDigest := parsedIdentifier.(reference.Digested); hasDigest {
45+
digest = identifierDigest.Digest().String()
46+
}
47+
name := ""
48+
if identifierName, hasName := parsedIdentifier.(reference.Named); hasName {
49+
name = identifierName.Name()
50+
}
51+
tag := "latest"
52+
if identifierTag, hasTag := parsedIdentifier.(reference.Tagged); hasTag && identifierTag.Tag() != "" {
53+
tag = identifierTag.Tag()
54+
}
55+
56+
// Initialize filters
57+
var filters []string
58+
// This will hold the final image list, if any
59+
var imageList []images.Image
60+
61+
// No digest in the request? Then assume it is a name
62+
if digest == "" {
63+
filters = []string{fmt.Sprintf("name==%s:%s", name, tag)}
64+
// Query it
65+
imageList, err = client.ImageService().List(ctx, filters...)
66+
if err != nil {
67+
return nil, "", "", fmt.Errorf("containerd image service failed: %w", err)
68+
}
69+
// Nothing? Then it could be a short id (aka truncated digest) - we are going to use this
70+
if len(imageList) == 0 {
71+
digest = fmt.Sprintf("sha256:%s.*", regexp.QuoteMeta(strings.TrimPrefix(identifier, "sha256:")))
72+
name = ""
73+
tag = ""
74+
} else {
75+
// Otherwise, we found one by name. Get the digest from it.
76+
digest = imageList[0].Target.Digest.String()
77+
}
78+
}
79+
80+
// At this point, we DO have a digest (or short id), so, that is what we are retrieving
81+
filters = []string{fmt.Sprintf("target.digest~=^%s$", digest)}
82+
imageList, err = client.ImageService().List(ctx, filters...)
83+
if err != nil {
84+
return nil, "", "", fmt.Errorf("containerd image service failed: %w", err)
85+
}
86+
87+
// TODO: docker does allow retrieving images by Id, so implement as a last ditch effort (probably look-up the store)
88+
89+
// Return the list we found, along with normalized name and tag
90+
return imageList, name, tag, nil
91+
}
92+
3393
// Inspect prints detailed information of each image in `images`.
34-
func Inspect(ctx context.Context, client *containerd.Client, images []string, options types.ImageInspectOptions) error {
35-
f := &imageInspector{
36-
mode: options.Mode,
94+
func Inspect(ctx context.Context, client *containerd.Client, identifiers []string, options types.ImageInspectOptions) error {
95+
// Verify we have a valid mode
96+
// TODO: move this out of here, to Cobra command line arg validation
97+
if options.Mode != "native" && options.Mode != "dockercompat" {
98+
return fmt.Errorf("unknown mode %q", options.Mode)
3799
}
38-
walker := &imagewalker.ImageWalker{
39-
Client: client,
40-
OnFound: func(ctx context.Context, found imagewalker.Found) error {
41-
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
42-
defer cancel()
43100

44-
n, err := imageinspector.Inspect(ctx, client, found.Image, options.GOptions.Snapshotter)
101+
// Set a timeout
102+
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
103+
defer cancel()
104+
105+
// Will hold the final answers
106+
var entries []interface{}
107+
108+
// We have to query per provided identifier, as we need to post-process results for the case name + digest
109+
for _, identifier := range identifiers {
110+
candidateImageList, requestedName, requestedTag, err := inspectIdentifier(ctx, client, identifier)
111+
if err != nil {
112+
log.G(ctx).WithError(err).WithField("identifier", identifier).Error("failure calling inspect")
113+
continue
114+
}
115+
116+
var validatedImage *dockercompat.Image
117+
var repoTags []string
118+
var repoDigests []string
119+
120+
// Go through the candidates
121+
for _, candidateImage := range candidateImageList {
122+
// Inspect the image
123+
candidateNativeImage, err := imageinspector.Inspect(ctx, client, candidateImage, options.GOptions.Snapshotter)
45124
if err != nil {
46-
return err
125+
log.G(ctx).WithError(err).WithField("name", candidateImage.Name).Error("failure inspecting image")
126+
continue
47127
}
48-
switch f.mode {
49-
case "native":
50-
f.entries = append(f.entries, n)
51-
case "dockercompat":
52-
d, err := dockercompat.ImageFromNative(n)
128+
129+
// If native, we just add everything in there and that's it
130+
if options.Mode == "native" {
131+
entries = append(entries, candidateNativeImage)
132+
continue
133+
}
134+
135+
// If dockercompat: does the candidate have a name? Get it if so
136+
candidateRef, err := referenceutil.ParseAnyReference(candidateNativeImage.Image.Name)
137+
if err != nil {
138+
log.G(ctx).WithError(err).WithField("name", candidateNativeImage.Image.Name).Error("the found image has an unparsable name")
139+
continue
140+
}
141+
parsedCandidateNameTag, candidateHasAName := candidateRef.(reference.NamedTagged)
142+
143+
// 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
144+
if requestedName != "" {
145+
// If the candidate did not have a name, then we should ignore this one and continue
146+
if !candidateHasAName {
147+
continue
148+
}
149+
150+
// Otherwise, the candidate has a name. If it is the one we want, store it and continue, otherwise, fall through
151+
candidateTag := parsedCandidateNameTag.Tag()
152+
if candidateTag == "" {
153+
candidateTag = "latest"
154+
}
155+
if parsedCandidateNameTag.Name() == requestedName && candidateTag == requestedTag {
156+
validatedImage, err = dockercompat.ImageFromNative(candidateNativeImage)
157+
if err != nil {
158+
log.G(ctx).WithError(err).WithField("name", candidateNativeImage.Image.Name).Error("could not get a docker compat version of the native image")
159+
}
160+
continue
161+
}
162+
} else if validatedImage == nil {
163+
// Alternatively, we got a request by digest only, so, if we do not know about it already, store it and continue
164+
validatedImage, err = dockercompat.ImageFromNative(candidateNativeImage)
53165
if err != nil {
54-
return err
166+
log.G(ctx).WithError(err).WithField("name", candidateNativeImage.Image.Name).Error("could not get a docker compat version of the native image")
55167
}
56-
f.entries = append(f.entries, d)
57-
default:
58-
return fmt.Errorf("unknown mode %q", f.mode)
168+
continue
169+
}
170+
171+
// Fallthrough cases:
172+
// - we got a request by digest, but we already had the image stored
173+
// - we got a request by name, and the name of the candidate did not match the requested name
174+
// Now, check if the candidate has a name - if it does, populate repoTags and repoDigests
175+
if candidateHasAName {
176+
repoTags = append(repoTags, fmt.Sprintf("%s:%s", reference.FamiliarName(parsedCandidateNameTag), parsedCandidateNameTag.Tag()))
177+
repoDigests = append(repoDigests, fmt.Sprintf("%s@%s", reference.FamiliarName(parsedCandidateNameTag), candidateImage.Target.Digest.String()))
59178
}
60-
return nil
61-
},
179+
}
180+
181+
// Done iterating through candidates. Did we find anything that matches?
182+
if validatedImage != nil {
183+
// Then slap in the repoTags and repoDigests we found from the other candidates
184+
validatedImage.RepoTags = append(validatedImage.RepoTags, repoTags...)
185+
validatedImage.RepoDigests = append(validatedImage.RepoDigests, repoDigests...)
186+
// Store our image
187+
// foundImages[validatedDigest] = validatedImage
188+
entries = append(entries, validatedImage)
189+
}
62190
}
63191

64-
err := walker.WalkAll(ctx, images, true)
65-
if len(f.entries) > 0 {
66-
if formatErr := formatter.FormatSlice(options.Format, options.Stdout, f.entries); formatErr != nil {
192+
// Display
193+
if len(entries) > 0 {
194+
if formatErr := formatter.FormatSlice(options.Format, options.Stdout, entries); formatErr != nil {
67195
log.G(ctx).Error(formatErr)
68196
}
69197
}
70-
return err
71-
}
72198

73-
type imageInspector struct {
74-
mode string
75-
entries []interface{}
199+
return nil
76200
}

0 commit comments

Comments
 (0)