diff --git a/cmd/nerdctl/helpers/flagutil.go b/cmd/nerdctl/helpers/flagutil.go index ef3dd22aada..871b018a024 100644 --- a/cmd/nerdctl/helpers/flagutil.go +++ b/cmd/nerdctl/helpers/flagutil.go @@ -103,6 +103,10 @@ func ProcessRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error) if err != nil { return types.GlobalCommandOptions{}, err } + kubeHideDupe, err := cmd.Flags().GetBool("kube-hide-dupe") + if err != nil { + return types.GlobalCommandOptions{}, err + } return types.GlobalCommandOptions{ Debug: debug, DebugFull: debugFull, @@ -118,6 +122,7 @@ func ProcessRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error) Experimental: experimental, HostGatewayIP: hostGatewayIP, BridgeIP: bridgeIP, + KubeHideDupe: kubeHideDupe, }, nil } diff --git a/cmd/nerdctl/image/image_list_test.go b/cmd/nerdctl/image/image_list_test.go index 6aae9354a90..2028d164b82 100644 --- a/cmd/nerdctl/image/image_list_test.go +++ b/cmd/nerdctl/image/image_list_test.go @@ -317,3 +317,39 @@ CMD ["echo", "nerdctl-build-notag-string"] testCase.Run(t) } + +func TestImagesKubeWithKubeHideDupe(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: test.Require( + nerdtest.OnlyKubernetes, + test.Not(nerdtest.Docker), + ), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.NginxAlpineImage) + }, + SubTests: []*test.Case{ + { + Description: "the same imageId will not print no-repo:tag in k8s.io with kube-hide-dupe", + Command: test.Command("--kube-hide-dupe", "images"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.DoesNotContain(""), + } + }, + }, + { + Description: "the same imageId will print no-repo:tag in k8s.io without kube-hide-dupe", + Command: test.Command("images"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(""), + } + }, + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/image/image_remove_test.go b/cmd/nerdctl/image/image_remove_test.go index ff3d33cbb71..4be9d148f6c 100644 --- a/cmd/nerdctl/image/image_remove_test.go +++ b/cmd/nerdctl/image/image_remove_test.go @@ -351,3 +351,110 @@ func TestIssue3016(t *testing.T) { testCase.Run(t) } + +func TestRemoveKubeWithKubeHideDupe(t *testing.T) { + var numTags, numNoTags int + testCase := nerdtest.Setup() + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("--kube-hide-dupe", "rmi", "-f", testutil.NginxAlpineImage) + numTags = len(strings.Split(strings.TrimSpace(helpers.Capture("--kube-hide-dupe", "images")), "\n")) + numNoTags = len(strings.Split(strings.TrimSpace(helpers.Capture("images")), "\n")) + } + testCase.Require = test.Require( + nerdtest.OnlyKubernetes, + test.Not(nerdtest.Docker), + ) + testCase.SubTests = []*test.Case{ + { + Description: "After removing the tag without kube-hide-dupe, repodigest is shown as ", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", testutil.NginxAlpineImage) + }, + Command: test.Command("rmi", "-f", testutil.NginxAlpineImage), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Errors: []error{}, + Output: func(stdout string, info string, t *testing.T) { + helpers.Command("--kube-hide-dupe", "images").Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) == numTags+1, info) + }, + }) + helpers.Command("images").Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) == numNoTags+1, info) + }, + }) + }, + } + }, + }, + { + Description: "If there are other tags, the Repodigest will not be deleted", + NoParallel: true, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("--kube-hide-dupe", "rmi", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", testutil.NginxAlpineImage) + helpers.Ensure("tag", testutil.NginxAlpineImage, data.Identifier()) + }, + Command: test.Command("--kube-hide-dupe", "rmi", testutil.NginxAlpineImage), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Errors: []error{}, + Output: func(stdout string, info string, t *testing.T) { + helpers.Command("--kube-hide-dupe", "images").Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) == numTags+1, info) + }, + }) + helpers.Command("images").Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) == numNoTags+2, info) + }, + }) + }, + } + }, + }, + { + Description: "After deleting all repo:tag entries, all repodigests will be cleaned up", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", testutil.NginxAlpineImage) + helpers.Ensure("tag", testutil.NginxAlpineImage, data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + helpers.Ensure("--kube-hide-dupe", "rmi", "-f", testutil.NginxAlpineImage) + return helpers.Command("--kube-hide-dupe", "rmi", "-f", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + helpers.Command("--kube-hide-dupe", "images").Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) == numTags, info) + }, + }) + helpers.Command("images").Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) == numNoTags, info) + }, + }) + }, + } + }, + }, + } + testCase.Run(t) +} diff --git a/cmd/nerdctl/main.go b/cmd/nerdctl/main.go index 50797e5b804..678fb5cf424 100644 --- a/cmd/nerdctl/main.go +++ b/cmd/nerdctl/main.go @@ -184,6 +184,7 @@ func initRootCmdFlags(rootCmd *cobra.Command, tomlPath string) (*pflag.FlagSet, helpers.AddPersistentBoolFlag(rootCmd, "experimental", nil, nil, cfg.Experimental, "NERDCTL_EXPERIMENTAL", "Control experimental: https://github.com/containerd/nerdctl/blob/main/docs/experimental.md") 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") helpers.AddPersistentStringFlag(rootCmd, "bridge-ip", nil, nil, nil, aliasToBeInherited, cfg.BridgeIP, "NERDCTL_BRIDGE_IP", "IP address for the default nerdctl bridge network") + rootCmd.PersistentFlags().Bool("kube-hide-dupe", cfg.KubeHideDupe, "Deduplicate images for Kubernetes with namespace k8s.io") return aliasToBeInherited, nil } diff --git a/docs/config.md b/docs/config.md index d31f404b205..1f0b4cd15e5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -46,6 +46,7 @@ experimental = true | `experimental` | `--experimental` | `NERDCTL_EXPERIMENTAL` | Enable [experimental features](experimental.md) | Since 0.22.3 | | `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 | | `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 | +| `kube-hide-dupe` | `--kube-hide-dupe` | | Deduplicate images for Kubernetes with namespace k8s.io, no more redundant ones are displayed | Since 2.0.3 | The properties are parsed in the following precedence: 1. CLI flag diff --git a/pkg/cmd/image/list.go b/pkg/cmd/image/list.go index 19905645c59..23d216d3dd4 100644 --- a/pkg/cmd/image/list.go +++ b/pkg/cmd/image/list.go @@ -29,7 +29,9 @@ import ( "text/template" "time" + "github.com/distribution/reference" "github.com/docker/go-units" + "github.com/opencontainers/go-digest" "github.com/opencontainers/image-spec/identity" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -128,6 +130,46 @@ type imagePrintable struct { func printImages(ctx context.Context, client *containerd.Client, imageList []images.Image, options *types.ImageListOptions) error { w := options.Stdout + var ImageList []images.Image + /* + the same imageId under k8s.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 be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB + 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 + */ + if options.GOptions.KubeHideDupe && options.GOptions.Namespace == "k8s.io" { + imageDigest := make(map[digest.Digest]bool) + var ImageNoTag []images.Image + for _, img := range imageList { + parsed, err := reference.ParseAnyReference(img.Name) + if err != nil { + continue + } + if _, ok := parsed.(reference.Tagged); !ok { + ImageNoTag = append(ImageNoTag, img) + continue + } + ImageList = append(ImageList, img) + imageDigest[img.Target.Digest] = true + } + //Ensure that dangling images without a repo:tag are displayed correctly. + for _, ima := range ImageNoTag { + if !imageDigest[ima.Target.Digest] { + ImageList = append(ImageList, ima) + imageDigest[ima.Target.Digest] = true + } + } + } else { + ImageList = imageList + } digestsFlag := options.Digests if options.Format == "wide" { digestsFlag = true @@ -174,7 +216,7 @@ func printImages(ctx context.Context, client *containerd.Client, imageList []ima snapshotter: containerdutil.SnapshotService(client, options.GOptions.Snapshotter), } - for _, img := range imageList { + for _, img := range ImageList { if err := printer.printImage(ctx, img); err != nil { log.G(ctx).Warn(err) } diff --git a/pkg/cmd/image/remove.go b/pkg/cmd/image/remove.go index 8075a928659..6b9f78fd757 100644 --- a/pkg/cmd/image/remove.go +++ b/pkg/cmd/image/remove.go @@ -111,12 +111,64 @@ func Remove(ctx context.Context, client *containerd.Client, args []string, optio } return nil }, + OnFoundCriRm: func(ctx context.Context, found imagewalker.Found) (bool, error) { + if found.NameMatchIndex == -1 { + // if found multiple images, return error unless in force-mode and + // there is only 1 unique image. + if found.MatchCount > 1 && !(options.Force && found.UniqueImages == 1) { + return false, fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) + } + } else if found.NameMatchIndex != found.MatchIndex { + // when there is an image with a name matching the argument but the argument is a digest short id, + // the deletion process is not performed. + return false, nil + } + + if cid, ok := runningImages[found.Image.Name]; ok { + if options.Force { + if err = is.Delete(ctx, found.Image.Name); err != nil { + return false, err + } + fmt.Fprintf(options.Stdout, "Untagged: %s\n", found.Image.Name) + fmt.Fprintf(options.Stdout, "Untagged: %s\n", found.Image.Target.Digest.String()) + + found.Image.Name = ":" + if _, err = is.Create(ctx, found.Image); err != nil { + return false, err + } + return false, nil + } + return false, fmt.Errorf("conflict: unable to delete %s (cannot be forced) - image is being used by running container %s", found.Req, cid) + } + if cid, ok := usedImages[found.Image.Name]; ok && !options.Force { + return false, fmt.Errorf("conflict: unable to delete %s (must be forced) - image is being used by stopped container %s", found.Req, cid) + } + // digests is used only for emulating human-readable output of `docker rmi` + digests, err := found.Image.RootFS(ctx, cs, platforms.DefaultStrict()) + if err != nil { + log.G(ctx).WithError(err).Warning("failed to enumerate rootfs") + } + + if err := is.Delete(ctx, found.Image.Name, delOpts...); err != nil { + return false, err + } + fmt.Fprintf(options.Stdout, "Untagged: %s@%s\n", found.Image.Name, found.Image.Target.Digest) + for _, digest := range digests { + fmt.Fprintf(options.Stdout, "Deleted: %s\n", digest) + } + return true, nil + }, } var errs []string var fatalErr bool for _, req := range args { - n, err := walker.Walk(ctx, req) + var n int + if options.GOptions.KubeHideDupe && options.GOptions.Namespace == "k8s.io" { + n, err = walker.WalkCriRm(ctx, req) + } else { + n, err = walker.Walk(ctx, req) + } if err != nil { fatalErr = true } diff --git a/pkg/config/config.go b/pkg/config/config.go index e37e9e0134c..1666ab61a0e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -40,6 +40,7 @@ type Config struct { Experimental bool `toml:"experimental"` HostGatewayIP string `toml:"host_gateway_ip"` BridgeIP string `toml:"bridge_ip, omitempty"` + KubeHideDupe bool `toml:"kube_hide_dupe"` } // New creates a default Config object statically, @@ -59,5 +60,6 @@ func New() *Config { HostsDir: ncdefaults.HostsDirs(), Experimental: true, HostGatewayIP: ncdefaults.HostGatewayIP(), + KubeHideDupe: false, } } diff --git a/pkg/idutil/imagewalker/imagewalker.go b/pkg/idutil/imagewalker/imagewalker.go index 4103711ed91..af111641a67 100644 --- a/pkg/idutil/imagewalker/imagewalker.go +++ b/pkg/idutil/imagewalker/imagewalker.go @@ -22,6 +22,7 @@ import ( "regexp" "strings" + "github.com/distribution/reference" "github.com/opencontainers/go-digest" containerd "github.com/containerd/containerd/v2/client" @@ -41,9 +42,28 @@ type Found struct { type OnFound func(ctx context.Context, found Found) error +/* +In order to resolve the issue with OnFoundCriRm, the same imageId under +k8s.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 be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB + be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB + +The boolean value will return true only when the repo:tag is successfully +deleted for each image. Once all repo:tag entries are deleted, it is necessary +to clean up the remaining repo:digest and configID. +*/ +type OnFoundCriRm func(ctx context.Context, found Found) (bool, error) + type ImageWalker struct { - Client *containerd.Client - OnFound OnFound + Client *containerd.Client + OnFound OnFound + OnFoundCriRm OnFoundCriRm } // Walk walks images and calls w.OnFound . @@ -98,6 +118,101 @@ func (w *ImageWalker) Walk(ctx context.Context, req string) (int, error) { return matchCount, nil } +// WalkCriRm walks images and calls w.OnFoundCriRm . +// Only effective when in the k8s.io namespace and kube-hide-dupe is enabled. +// The WalkCriRm deletes non-repo:tag items such as repo:digest when in the no-other-repo:tag scenario. +func (w *ImageWalker) WalkCriRm(ctx context.Context, req string) (int, error) { + var filters []string + var parsedReferenceStr, repo string + var imageTag, imagesRepo []images.Image + var tagNum int + + parsedReference, err := referenceutil.Parse(req) + if err == nil { + parsedReferenceStr = parsedReference.String() + filters = append(filters, fmt.Sprintf("name==%s", parsedReferenceStr)) + } + //Get the image ID , if reg == imageTag use + image, err := w.Client.GetImage(ctx, parsedReferenceStr) + if err != nil { + repo = req + } else { + repo = strings.Split(image.Target().Digest.String(), ":")[1][:12] + } + + filters = append(filters, + fmt.Sprintf("name==%s", req), + fmt.Sprintf("target.digest~=^sha256:%s.*$", regexp.QuoteMeta(repo)), + fmt.Sprintf("target.digest~=^%s.*$", regexp.QuoteMeta(repo)), + ) + + images, err := w.Client.ImageService().List(ctx, filters...) + if err != nil { + return -1, err + } + + // to handle the `rmi -f` case where returned images are different but + // have the same short prefix. + uniqueImages := make(map[digest.Digest]bool) + nameMatchIndex := -1 + + //Distinguish between tag and non-tag + for _, img := range images { + ref := img.Name + parsed, err := reference.ParseAnyReference(ref) + if err != nil { + continue + } + if _, ok := parsed.(reference.Tagged); ok { + imageTag = append(imageTag, img) + tagNum++ + uniqueImages[img.Target.Digest] = true + // to get target image index for `nerdctl rmi `. + if (parsedReferenceStr != "" && img.Name == parsedReferenceStr) || img.Name == req { + nameMatchIndex = len(imageTag) - 1 + } + + } else { + imagesRepo = append(imagesRepo, img) + } + } + + matchCount := len(imageTag) + + for i, img := range imageTag { + f := Found{ + Image: img, + Req: req, + MatchIndex: i, + MatchCount: matchCount, + UniqueImages: len(uniqueImages), + NameMatchIndex: nameMatchIndex, + } + if ok, e := w.OnFoundCriRm(ctx, f); e != nil { + return -1, e + } else if ok { + tagNum = tagNum - 1 + } + } + //If the corresponding imageTag does not exist, delete the repoDigests + if tagNum == 0 { + for i, img := range imagesRepo { + f := Found{ + Image: img, + Req: req, + MatchIndex: i, + MatchCount: 1, + UniqueImages: 1, + NameMatchIndex: -1, + } + if _, e := w.OnFoundCriRm(ctx, f); e != nil { + return -1, e + } + } + } + return matchCount, nil +} + // WalkAll calls `Walk` for each req in `reqs`. // // It can be used when the matchCount is not important (e.g., only care if there