Skip to content

Commit 909d4af

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

File tree

6 files changed

+367
-84
lines changed

6 files changed

+367
-84
lines changed

Diff for: README.md

+4
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,10 @@ docker build -t test-integration --target test-integration .
268268
docker run -t --rm --privileged test-integration
269269
```
270270

271+
To run a single integration test (in this case, `image_inspect_test`):
272+
273+
`go test -exec sudo -v ./cmd/nerdctl/main_test.go ./cmd/nerdctl/image_inspect_test.go `
274+
271275
#### Running integration test suite against Docker
272276

273277
Run `go test -exec sudo -v ./cmd/nerdctl/... -args -test.target=docker` to ensure that the test suite is compatible with Docker.

Diff for: cmd/nerdctl/image_inspect_test.go

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

1919
import (
20+
"encoding/json"
21+
"strings"
2022
"testing"
2123

22-
"github.com/containerd/nerdctl/v2/pkg/testutil"
2324
"gotest.tools/v3/assert"
25+
26+
"github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat"
27+
"github.com/containerd/nerdctl/v2/pkg/testutil"
2428
)
2529

2630
func TestImageInspectContainsSomeStuff(t *testing.T) {
@@ -39,9 +43,132 @@ func TestImageInspectWithFormat(t *testing.T) {
3943
base := testutil.NewBase(t)
4044

4145
base.Cmd("pull", testutil.CommonImage).AssertOK()
46+
4247
// test RawFormat support
4348
base.Cmd("image", "inspect", testutil.CommonImage, "--format", "{{.Id}}").AssertOK()
4449

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

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)