Skip to content

Commit eaa8df0

Browse files
committed
Fix duplicate image entries in k8s.io namespaces
The same imageId underk8s.io is showing multiple results: repo:tag, repo:digest, configID. We expect to display only repo:tag, consistent with other namespaces and CRI. e.g. nerdctl -n k8s.io images REPOSITORY TAG IMAGE ID CREATED PLATFORM SIZE BLOB SIZE centos 7 be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB centos <none> be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB <none> <none> be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB expect: nerdctl --kube-hide-dupe -n k8s.io images REPOSITORY TAG IMAGE ID CREATED PLATFORM SIZE BLOB SIZE centos 7 be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB Of course, even after deduplicating the images displayed, there are still issues with deleting the images. It is necessary to distinguish between repo:tag and configId, as well as repoDigest. Considering the situation with tags, we need to ensure that all repo:tags under the same imageId are cleaned up before proceeding to clean up the configId and repoDigest. see: containerd#3702 Signed-off-by: fengwei0328 <[email protected]>
1 parent 1f81225 commit eaa8df0

File tree

9 files changed

+460
-4
lines changed

9 files changed

+460
-4
lines changed

cmd/nerdctl/helpers/flagutil.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ func ProcessRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error)
103103
if err != nil {
104104
return types.GlobalCommandOptions{}, err
105105
}
106+
kubeHideDupe, err := cmd.Flags().GetBool("kube-hide-dupe")
107+
if err != nil {
108+
return types.GlobalCommandOptions{}, err
109+
}
106110
return types.GlobalCommandOptions{
107111
Debug: debug,
108112
DebugFull: debugFull,
@@ -118,6 +122,7 @@ func ProcessRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error)
118122
Experimental: experimental,
119123
HostGatewayIP: hostGatewayIP,
120124
BridgeIP: bridgeIP,
125+
KubeHideDupe: kubeHideDupe,
121126
}, nil
122127
}
123128

cmd/nerdctl/image/image_list_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,70 @@ CMD ["echo", "nerdctl-build-notag-string"]
317317

318318
testCase.Run(t)
319319
}
320+
321+
func TestImagesKubeWithKubeHideDupe(t *testing.T) {
322+
nerdtest.Setup()
323+
324+
testCase := &test.Case{
325+
Require: test.Require(
326+
nerdtest.OnlyKubernetes,
327+
),
328+
Setup: func(data test.Data, helpers test.Helpers) {
329+
helpers.Ensure("pull", "--quiet", testutil.BusyboxImage)
330+
},
331+
SubTests: []*test.Case{
332+
{
333+
Description: "The same imageID will not print no-repo:tag in k8s.io with kube-hide-dupe",
334+
Command: test.Command("--kube-hide-dupe", "images"),
335+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
336+
return &test.Expected{
337+
Output: func(stdout string, info string, t *testing.T) {
338+
var imageID string
339+
var skipLine int
340+
lines := strings.Split(strings.TrimSpace(stdout), "\n")
341+
header := "REPOSITORY\tTAG\tIMAGE ID\tCREATED\tPLATFORM\tSIZE\tBLOB SIZE"
342+
if nerdtest.IsDocker() {
343+
header = "REPOSITORY\tTAG\tIMAGE ID\tCREATED\tSIZE"
344+
}
345+
tab := tabutil.NewReader(header)
346+
err := tab.ParseHeader(lines[0])
347+
assert.NilError(t, err, info)
348+
found := true
349+
for i, line := range lines[1:] {
350+
repo, _ := tab.ReadRow(line, "REPOSITORY")
351+
tag, _ := tab.ReadRow(line, "TAG")
352+
if repo+":"+tag == testutil.BusyboxImage {
353+
skipLine = i
354+
imageID, _ = tab.ReadRow(line, "IMAGE ID")
355+
break
356+
}
357+
}
358+
for i, line := range lines[1:] {
359+
if i == skipLine {
360+
continue
361+
}
362+
id, _ := tab.ReadRow(line, "IMAGE ID")
363+
if id == imageID {
364+
found = false
365+
break
366+
}
367+
}
368+
assert.Assert(t, found, info)
369+
},
370+
}
371+
},
372+
},
373+
{
374+
Description: "the same imageId will print no-repo:tag in k8s.io without kube-hide-dupe",
375+
Command: test.Command("images"),
376+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
377+
return &test.Expected{
378+
Output: test.Contains("<none>"),
379+
}
380+
},
381+
},
382+
},
383+
}
384+
385+
testCase.Run(t)
386+
}

cmd/nerdctl/image/image_remove_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,3 +351,173 @@ func TestIssue3016(t *testing.T) {
351351

352352
testCase.Run(t)
353353
}
354+
355+
func TestRemoveKubeWithKubeHideDupe(t *testing.T) {
356+
var numTags, numNoTags int
357+
testCase := nerdtest.Setup()
358+
testCase.NoParallel = true
359+
testCase.Cleanup = func(data test.Data, helpers test.Helpers) {
360+
helpers.Anyhow("--kube-hide-dupe", "rmi", "-f", testutil.BusyboxImage)
361+
}
362+
testCase.Setup = func(data test.Data, helpers test.Helpers) {
363+
numTags = len(strings.Split(strings.TrimSpace(helpers.Capture("--kube-hide-dupe", "images")), "\n"))
364+
numNoTags = len(strings.Split(strings.TrimSpace(helpers.Capture("images")), "\n"))
365+
}
366+
testCase.Require = test.Require(
367+
nerdtest.OnlyKubernetes,
368+
)
369+
testCase.SubTests = []*test.Case{
370+
{
371+
Description: "After removing the tag without kube-hide-dupe, repodigest is shown as <none>",
372+
NoParallel: true,
373+
Setup: func(data test.Data, helpers test.Helpers) {
374+
helpers.Ensure("pull", testutil.BusyboxImage)
375+
},
376+
Command: test.Command("rmi", "-f", testutil.BusyboxImage),
377+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
378+
return &test.Expected{
379+
ExitCode: 0,
380+
Errors: []error{},
381+
Output: func(stdout string, info string, t *testing.T) {
382+
helpers.Command("--kube-hide-dupe", "images").Run(&test.Expected{
383+
Output: func(stdout string, info string, t *testing.T) {
384+
lines := strings.Split(strings.TrimSpace(stdout), "\n")
385+
assert.Assert(t, len(lines) == numTags+1, info)
386+
},
387+
})
388+
helpers.Command("images").Run(&test.Expected{
389+
Output: func(stdout string, info string, t *testing.T) {
390+
lines := strings.Split(strings.TrimSpace(stdout), "\n")
391+
assert.Assert(t, len(lines) == numNoTags+1, info)
392+
},
393+
})
394+
},
395+
}
396+
},
397+
},
398+
{
399+
Description: "If there are other tags, the Repodigest will not be deleted",
400+
NoParallel: true,
401+
Cleanup: func(data test.Data, helpers test.Helpers) {
402+
helpers.Anyhow("--kube-hide-dupe", "rmi", data.Identifier())
403+
},
404+
Setup: func(data test.Data, helpers test.Helpers) {
405+
helpers.Ensure("pull", testutil.BusyboxImage)
406+
helpers.Ensure("tag", testutil.BusyboxImage, data.Identifier())
407+
},
408+
Command: test.Command("--kube-hide-dupe", "rmi", testutil.BusyboxImage),
409+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
410+
return &test.Expected{
411+
ExitCode: 0,
412+
Errors: []error{},
413+
Output: func(stdout string, info string, t *testing.T) {
414+
helpers.Command("--kube-hide-dupe", "images").Run(&test.Expected{
415+
Output: func(stdout string, info string, t *testing.T) {
416+
lines := strings.Split(strings.TrimSpace(stdout), "\n")
417+
assert.Assert(t, len(lines) == numTags+1, info)
418+
},
419+
})
420+
helpers.Command("images").Run(&test.Expected{
421+
Output: func(stdout string, info string, t *testing.T) {
422+
lines := strings.Split(strings.TrimSpace(stdout), "\n")
423+
assert.Assert(t, len(lines) == numNoTags+2, info)
424+
},
425+
})
426+
},
427+
}
428+
},
429+
},
430+
{
431+
Description: "After deleting all repo:tag entries, all repodigests will be cleaned up",
432+
NoParallel: true,
433+
Setup: func(data test.Data, helpers test.Helpers) {
434+
helpers.Ensure("pull", testutil.BusyboxImage)
435+
helpers.Ensure("tag", testutil.BusyboxImage, data.Identifier())
436+
},
437+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
438+
helpers.Ensure("--kube-hide-dupe", "rmi", "-f", testutil.BusyboxImage)
439+
return helpers.Command("--kube-hide-dupe", "rmi", "-f", data.Identifier())
440+
},
441+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
442+
return &test.Expected{
443+
Output: func(stdout string, info string, t *testing.T) {
444+
helpers.Command("--kube-hide-dupe", "images").Run(&test.Expected{
445+
Output: func(stdout string, info string, t *testing.T) {
446+
lines := strings.Split(strings.TrimSpace(stdout), "\n")
447+
assert.Assert(t, len(lines) == numTags, info)
448+
},
449+
})
450+
helpers.Command("images").Run(&test.Expected{
451+
Output: func(stdout string, info string, t *testing.T) {
452+
lines := strings.Split(strings.TrimSpace(stdout), "\n")
453+
assert.Assert(t, len(lines) == numNoTags, info)
454+
},
455+
})
456+
},
457+
}
458+
},
459+
},
460+
{
461+
Description: "Test multiple IDs found with provided prefix and force with shortID",
462+
NoParallel: true,
463+
Setup: func(data test.Data, helpers test.Helpers) {
464+
helpers.Ensure("pull", testutil.BusyboxImage)
465+
helpers.Ensure("tag", testutil.BusyboxImage, data.Identifier())
466+
},
467+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
468+
return helpers.Command("--kube-hide-dupe", "images", testutil.BusyboxImage, "-q")
469+
},
470+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
471+
return &test.Expected{
472+
Output: func(stdout string, info string, t *testing.T) {
473+
helpers.Command("--kube-hide-dupe", "rmi", stdout[0:12]).Run(&test.Expected{
474+
ExitCode: 1,
475+
Errors: []error{errors.New("multiple IDs found with provided prefix: ")},
476+
})
477+
helpers.Command("--kube-hide-dupe", "rmi", "--force", stdout[0:12]).Run(&test.Expected{
478+
ExitCode: 0,
479+
})
480+
helpers.Command("images").Run(&test.Expected{
481+
Output: func(stdout string, info string, t *testing.T) {
482+
lines := strings.Split(strings.TrimSpace(stdout), "\n")
483+
assert.Assert(t, len(lines) == numNoTags, info)
484+
},
485+
})
486+
},
487+
}
488+
},
489+
},
490+
{
491+
Description: "Test remove image with digestID",
492+
NoParallel: true,
493+
Setup: func(data test.Data, helpers test.Helpers) {
494+
helpers.Ensure("pull", testutil.BusyboxImage)
495+
helpers.Ensure("tag", testutil.BusyboxImage, data.Identifier())
496+
},
497+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
498+
return helpers.Command("--kube-hide-dupe", "images", testutil.BusyboxImage, "-q", "--no-trunc")
499+
},
500+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
501+
return &test.Expected{
502+
Output: func(stdout string, info string, t *testing.T) {
503+
imgID := strings.Split(stdout, "\n")
504+
helpers.Command("--kube-hide-dupe", "rmi", imgID[0]).Run(&test.Expected{
505+
ExitCode: 1,
506+
Errors: []error{errors.New("multiple IDs found with provided prefix: ")},
507+
})
508+
helpers.Command("--kube-hide-dupe", "rmi", "--force", imgID[0]).Run(&test.Expected{
509+
ExitCode: 0,
510+
})
511+
helpers.Command("images").Run(&test.Expected{
512+
Output: func(stdout string, info string, t *testing.T) {
513+
lines := strings.Split(strings.TrimSpace(stdout), "\n")
514+
assert.Assert(t, len(lines) == numNoTags, info)
515+
},
516+
})
517+
},
518+
}
519+
},
520+
},
521+
}
522+
testCase.Run(t)
523+
}

cmd/nerdctl/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ func initRootCmdFlags(rootCmd *cobra.Command, tomlPath string) (*pflag.FlagSet,
184184
helpers.AddPersistentBoolFlag(rootCmd, "experimental", nil, nil, cfg.Experimental, "NERDCTL_EXPERIMENTAL", "Control experimental: https://github.com/containerd/nerdctl/blob/main/docs/experimental.md")
185185
helpers.AddPersistentStringFlag(rootCmd, "host-gateway-ip", nil, nil, nil, aliasToBeInherited, cfg.HostGatewayIP, "NERDCTL_HOST_GATEWAY_IP", "IP address that the special 'host-gateway' string in --add-host resolves to. Defaults to the IP address of the host. It has no effect without setting --add-host")
186186
helpers.AddPersistentStringFlag(rootCmd, "bridge-ip", nil, nil, nil, aliasToBeInherited, cfg.BridgeIP, "NERDCTL_BRIDGE_IP", "IP address for the default nerdctl bridge network")
187+
rootCmd.PersistentFlags().Bool("kube-hide-dupe", cfg.KubeHideDupe, "Deduplicate images for Kubernetes with namespace k8s.io")
187188
return aliasToBeInherited, nil
188189
}
189190

docs/config.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ experimental = true
4646
| `experimental` | `--experimental` | `NERDCTL_EXPERIMENTAL` | Enable [experimental features](experimental.md) | Since 0.22.3 |
4747
| `host_gateway_ip` | `--host-gateway-ip` | `NERDCTL_HOST_GATEWAY_IP` | IP address that the special 'host-gateway' string in --add-host resolves to. Defaults to the IP address of the host. It has no effect without setting --add-host | Since 1.3.0 |
4848
| `bridge_ip` | `--bridge-ip` | `NERDCTL_BRIDGE_IP` | IP address for the default nerdctl bridge network, e.g., 10.1.100.1/24 | Since 2.0.1 |
49+
| `kube-hide-dupe` | `--kube-hide-dupe` | | Deduplicate images for Kubernetes with namespace k8s.io, no more redundant <none> ones are displayed | Since 2.0.3 |
4950

5051
The properties are parsed in the following precedence:
5152
1. CLI flag

pkg/cmd/image/list.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"time"
3131

3232
"github.com/docker/go-units"
33+
"github.com/opencontainers/go-digest"
3334
"github.com/opencontainers/image-spec/identity"
3435
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
3536

@@ -44,6 +45,7 @@ import (
4445
"github.com/containerd/nerdctl/v2/pkg/containerdutil"
4546
"github.com/containerd/nerdctl/v2/pkg/formatter"
4647
"github.com/containerd/nerdctl/v2/pkg/imgutil"
48+
"github.com/containerd/nerdctl/v2/pkg/referenceutil"
4749
)
4850

4951
// ListCommandHandler `List` and print images matching filters in `options`.
@@ -128,6 +130,46 @@ type imagePrintable struct {
128130

129131
func printImages(ctx context.Context, client *containerd.Client, imageList []images.Image, options *types.ImageListOptions) error {
130132
w := options.Stdout
133+
var finalImageList []images.Image
134+
/*
135+
the same imageId under k8s.io is showing multiple results: repo:tag, repo:digest, configID.
136+
We expect to display only repo:tag, consistent with other namespaces and CRI
137+
e.g.
138+
nerdctl -n k8s.io images
139+
REPOSITORY TAG IMAGE ID CREATED PLATFORM SIZE BLOB SIZE
140+
centos 7 be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB
141+
centos <none> be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB
142+
<none> <none> be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB
143+
expect:
144+
nerdctl --kube-hide-dupe -n k8s.io images
145+
REPOSITORY TAG IMAGE ID CREATED PLATFORM SIZE BLOB SIZE
146+
centos 7 be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB
147+
*/
148+
if options.GOptions.KubeHideDupe && options.GOptions.Namespace == "k8s.io" {
149+
imageDigest := make(map[digest.Digest]bool)
150+
var imageNoTag []images.Image
151+
for _, img := range imageList {
152+
parsed, err := referenceutil.Parse(img.Name)
153+
if err != nil {
154+
continue
155+
}
156+
if parsed.Tag != "" {
157+
finalImageList = append(finalImageList, img)
158+
imageDigest[img.Target.Digest] = true
159+
continue
160+
}
161+
imageNoTag = append(imageNoTag, img)
162+
}
163+
//Ensure that dangling images without a repo:tag are displayed correctly.
164+
for _, ima := range imageNoTag {
165+
if !imageDigest[ima.Target.Digest] {
166+
finalImageList = append(finalImageList, ima)
167+
imageDigest[ima.Target.Digest] = true
168+
}
169+
}
170+
} else {
171+
finalImageList = imageList
172+
}
131173
digestsFlag := options.Digests
132174
if options.Format == "wide" {
133175
digestsFlag = true
@@ -174,7 +216,7 @@ func printImages(ctx context.Context, client *containerd.Client, imageList []ima
174216
snapshotter: containerdutil.SnapshotService(client, options.GOptions.Snapshotter),
175217
}
176218

177-
for _, img := range imageList {
219+
for _, img := range finalImageList {
178220
if err := printer.printImage(ctx, img); err != nil {
179221
log.G(ctx).Warn(err)
180222
}

0 commit comments

Comments
 (0)