diff --git a/cmd/crictl/image.go b/cmd/crictl/image.go index 6d0549261d..e632636e62 100644 --- a/cmd/crictl/image.go +++ b/cmd/crictl/image.go @@ -806,6 +806,13 @@ func ListImages(ctx context.Context, client internalapi.ImageManagerService, nam resp := &pb.ListImagesResponse{Images: res} logrus.Debugf("ListImagesResponse: %v", resp) + // The CRI runtime may not honor the image name filter, so apply it + // client-side to ensure correct results. See + // https://github.com/kubernetes-sigs/cri-tools/issues/2008 + if nameFilter != "" && len(resp.GetImages()) > 0 { + resp.Images = filterByName(nameFilter, resp.GetImages()) + } + slices.SortFunc(resp.GetImages(), func(a, b *pb.Image) int { if len(a.GetRepoTags()) > 0 && len(b.GetRepoTags()) > 0 { return cmp.Compare(a.GetRepoTags()[0], b.GetRepoTags()[0]) @@ -895,6 +902,58 @@ func filterByBeforeSince(filterValue string, imageList []*pb.Image) []*pb.Image return filtered } +// filterByName filters the image list to only include images matching the given +// name filter. The filter can be "repo", "repo:tag", or "repo@digest". +// This provides client-side filtering as a fallback when the CRI runtime does +// not honor the ListImages filter. +func filterByName(nameFilter string, imageList []*pb.Image) []*pb.Image { + var filtered []*pb.Image + + for _, img := range imageList { + for _, repoTag := range img.GetRepoTags() { + if matchesNameFilter(repoTag, nameFilter) { + filtered = append(filtered, img) + + goto next + } + } + + for _, repoDigest := range img.GetRepoDigests() { + if matchesNameFilter(repoDigest, nameFilter) { + filtered = append(filtered, img) + + goto next + } + } + next: + } + + return filtered +} + +// matchesNameFilter checks if an image reference matches the name filter. +// If the filter contains a tag (":") or digest ("@"), it must match exactly. +// Otherwise, only the repository part is compared. +func matchesNameFilter(imageRef, nameFilter string) bool { + if imageRef == nameFilter { + return true + } + + // If the filter has no tag/digest, match by repository name only. + if !strings.Contains(nameFilter, ":") && !strings.Contains(nameFilter, "@") { + repo := imageRef + if idx := strings.LastIndex(repo, ":"); idx != -1 { + repo = repo[:idx] + } else if idx := strings.LastIndex(repo, "@"); idx != -1 { + repo = repo[:idx] + } + + return repo == nameFilter + } + + return false +} + func filterByReference(filterValue string, imageList []*pb.Image) ([]*pb.Image, error) { filtered := []*pb.Image{} diff --git a/cmd/crictl/image_test.go b/cmd/crictl/image_test.go index b53131684e..0304255f9e 100644 --- a/cmd/crictl/image_test.go +++ b/cmd/crictl/image_test.go @@ -150,6 +150,61 @@ var _ = DescribeTable("normalizeRepoDigest", ), ) +func assertFilterByName(input []*pb.Image, nameFilter string, expectedIDs []string) { + actual := filterByName(nameFilter, input) + ids := make([]string, 0, len(actual)) + + for _, img := range actual { + ids = append(ids, img.GetId()) + } + + Expect(expectedIDs).To(Equal(ids)) +} + +var _ = DescribeTable("filterByName", assertFilterByName, + Entry("filters by exact repo:tag", + []*pb.Image{ + fakeImage("1", []string{"docker.io/library/busybox@sha256:1"}, []string{"docker.io/library/busybox:latest"}), + fakeImage("2", []string{"docker.io/library/nginx@sha256:2"}, []string{"docker.io/library/nginx:latest"}), + }, + "docker.io/library/busybox:latest", + []string{"1"}, + ), + Entry("filters by repo name only (no tag)", + []*pb.Image{ + fakeImage("1", []string{"docker.io/library/busybox@sha256:1"}, []string{"docker.io/library/busybox:latest"}), + fakeImage("2", []string{"docker.io/library/nginx@sha256:2"}, []string{"docker.io/library/nginx:1.0"}), + fakeImage("3", []string{"docker.io/library/busybox@sha256:3"}, []string{"docker.io/library/busybox:1.36"}), + }, + "docker.io/library/busybox", + []string{"1", "3"}, + ), + Entry("returns empty when no match", + []*pb.Image{ + fakeImage("1", []string{"docker.io/library/busybox@sha256:1"}, []string{"docker.io/library/busybox:latest"}), + fakeImage("2", []string{"docker.io/library/nginx@sha256:2"}, []string{"docker.io/library/nginx:latest"}), + }, + "docker.io/library/alpine:latest", + []string{}, + ), + Entry("matches by repo digest", + []*pb.Image{ + fakeImage("1", []string{"docker.io/library/busybox@sha256:abc123"}, []string{"docker.io/library/busybox:latest"}), + fakeImage("2", []string{"docker.io/library/nginx@sha256:def456"}, []string{"docker.io/library/nginx:latest"}), + }, + "docker.io/library/busybox@sha256:abc123", + []string{"1"}, + ), + Entry("filters by repo:tag with non-matching tag", + []*pb.Image{ + fakeImage("1", []string{"docker.io/kindest/kindnetd@sha256:1"}, []string{"docker.io/kindest/kindnetd:v20250214"}), + fakeImage("2", []string{"docker.io/kindest/local-path-helper@sha256:2"}, []string{"docker.io/kindest/local-path-helper:v20241212"}), + }, + "docker.io/kindest/kindnetd:v20250214", + []string{"1"}, + ), +) + var _ = DescribeTable("filterImagesListByDangling", assert, Entry("returns filtered images with dangling --filter=dangling=true", []*pb.Image{