Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion pkg/validate/consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,11 @@ var (
// image list where different tags refer to the same image.
testDifferentTagSameImageList []string

// image list with the same image from different registries.
testSameImageDifferentRegistries []string

// pod sandbox to use when pulling images.
testImagePodSandbox *runtimeapi.PodSandboxConfig

// Linux defaults.
testLinuxDifferentTagDifferentImageList = []string{
registry + "test-image-1:latest",
Expand Down Expand Up @@ -177,6 +179,10 @@ var _ = framework.AddBeforeSuiteCallback(func() {
testDifferentTagDifferentImageList = testWindowsDifferentTagDifferentImageList
testDifferentTagSameImageList = testWindowsDifferentTagSameImageList
}
testSameImageDifferentRegistries = []string{
"registry.k8s.io/pause:3.9",
"k8s.gcr.io/pause:3.9",
}
testImagePodSandbox = &runtimeapi.PodSandboxConfig{
Labels: framework.DefaultPodLabels,
}
Expand Down
110 changes: 101 additions & 9 deletions pkg/validate/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ var _ = framework.KubeDescribe("Image Manager", func() {
c = f.CRIClient.CRIImageClient
})

It("public image with tag should be pulled and removed [Conformance]", func() {
It("public image with tag should be pulled and removed [Conformance]", Serial, func() {
testPullPublicImage(c, testImageWithTag, testImagePodSandbox, func(s *runtimeapi.Image) {
Expect(s.GetRepoTags()).To(Equal([]string{testImageWithTag}))
})
})

It("public image should timeout if requested [Conformance]", func() {
It("public image should timeout if requested [Conformance]", Serial, func() {
imageName := framework.PrepareImageName(testImageWithTag)

ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
Expand All @@ -64,20 +64,20 @@ var _ = framework.KubeDescribe("Image Manager", func() {
Expect(statusErr.Code()).To(Equal(codes.DeadlineExceeded))
})

It("public image without tag should be pulled and removed [Conformance]", func() {
It("public image without tag should be pulled and removed [Conformance]", Serial, func() {
testPullPublicImage(c, testImageWithoutTag, testImagePodSandbox, func(s *runtimeapi.Image) {
Expect(s.GetRepoTags()).To(Equal([]string{testImageWithoutTag + ":latest"}))
})
})

It("public image with digest should be pulled and removed [Conformance]", func() {
It("public image with digest should be pulled and removed [Conformance]", Serial, func() {
testPullPublicImage(c, testImageWithDigest, testImagePodSandbox, func(s *runtimeapi.Image) {
Expect(s.GetRepoTags()).To(BeEmpty())
Expect(s.GetRepoDigests()).To(Equal([]string{testImageWithDigest}))
})
})

It("image status should support all kinds of references [Conformance]", func() {
It("image status should support all kinds of references [Conformance]", Serial, func() {
imageName := testImageWithAllReferences
// Make sure image does not exist before testing.
removeImage(c, imageName)
Expand All @@ -101,7 +101,7 @@ var _ = framework.KubeDescribe("Image Manager", func() {
})

if runtime.GOOS != framework.OSWindows || framework.TestContext.IsLcow {
It("image status get image fields should not have Uid|Username empty [Conformance]", func() {
It("image status get image fields should not have Uid|Username empty [Conformance]", Serial, func() {
for _, item := range []struct {
description string
image string
Expand Down Expand Up @@ -143,7 +143,7 @@ var _ = framework.KubeDescribe("Image Manager", func() {
})
}

It("listImage should get exactly 3 image in the result list [Conformance]", func() {
It("listImage should get exactly 3 image in the result list [Conformance]", Serial, func() {
// Make sure test image does not exist.
removeImageList(c, testDifferentTagDifferentImageList)
ids := pullImageList(c, testDifferentTagDifferentImageList, testImagePodSandbox)
Expand All @@ -165,7 +165,7 @@ var _ = framework.KubeDescribe("Image Manager", func() {
}
})

It("listImage should get exactly 3 repoTags in the result image [Conformance]", func() {
It("listImage should get exactly 3 repoTags in the result image [Conformance]", Serial, func() {
// Make sure test image does not exist.
removeImageList(c, testDifferentTagSameImageList)
ids := pullImageList(c, testDifferentTagSameImageList, testImagePodSandbox)
Expand All @@ -187,12 +187,104 @@ var _ = framework.KubeDescribe("Image Manager", func() {
}
}
})

It("removing image by one tag should remove all tags [Conformance]", Serial, func() {
imageName1 := testDifferentTagSameImageList[0]
imageName2 := testDifferentTagSameImageList[1]
imageName3 := testDifferentTagSameImageList[2]

// Ensure images are absent before test
removeImageList(c, []string{imageName1, imageName2, imageName3})

By("Pulling image with multiple tags")
pullImageList(c, []string{imageName1, imageName2, imageName3}, testImagePodSandbox)

By("Verifying all tags are present on a single image")
images := framework.ListImage(c, &runtimeapi.ImageFilter{})
var foundImage *runtimeapi.Image
for _, img := range images {
// Check if the image has one of our tags. Since they all point to the same image, finding one is enough.
if slices.Contains(img.GetRepoTags(), imageName1) {
foundImage = img
}
if foundImage != nil {
break
}
}
Expect(foundImage).NotTo(BeNil(), "Should find the pulled image")
Expect(foundImage.GetRepoTags()).To(HaveLen(3), "Should have exactly three tags")
Expect(foundImage.GetRepoTags()).To(ContainElements(imageName1, imageName2, imageName3), "Should contain all three tags")

imageID := foundImage.GetId() // Get the ID for later verification

By("Removing image by a single tag: " + imageName1)
removeImage(c, imageName1)

By("Verifying the image is completely removed")
status1 := framework.ImageStatus(c, imageName1)
Expect(status1).To(BeNil(), "Image should be gone when checking by first tag")
status2 := framework.ImageStatus(c, imageName2)
Expect(status2).To(BeNil(), "Image should be gone when checking by second tag")
status3 := framework.ImageStatus(c, imageName3)
Expect(status3).To(BeNil(), "Image should be gone when checking by third tag")

idStatus := framework.ImageStatus(c, imageID)
Expect(idStatus).To(BeNil(), "Image should be gone when checking by its ID")
})

It("removing image from one registry should remove all tags from other registries [Conformance]", Serial, func() {
imageName1 := testSameImageDifferentRegistries[0]
imageName2 := testSameImageDifferentRegistries[1]

// Ensure images are absent before test
removeImageList(c, []string{imageName1, imageName2})

By("Pulling the same image from different registries")
pullImageList(c, []string{imageName1, imageName2}, testImagePodSandbox)

By("Verifying all tags are present on a single image")
images := framework.ListImage(c, &runtimeapi.ImageFilter{})
var foundImage *runtimeapi.Image
for _, img := range images {
// Check if the image has one of our tags. Since they all point to the same image, finding one is enough.
if slices.Contains(img.GetRepoTags(), imageName1) {
foundImage = img
}
if foundImage != nil {
break
}
}
Expect(foundImage).NotTo(BeNil(), "Should find the pulled image")
Expect(foundImage.GetRepoTags()).To(HaveLen(2), "Should have exactly two tags")
Expect(foundImage.GetRepoTags()).To(ContainElements(imageName1, imageName2), "Should contain tags from both registries")

imageID := foundImage.GetId() // Get the ID for later verification

By("Removing image by a single tag: " + imageName1)
removeImage(c, imageName1)

By("Verifying the image is completely removed")
status1 := framework.ImageStatus(c, imageName1)
Expect(status1).To(BeNil(), "Image should be gone when checking by first tag")
status2 := framework.ImageStatus(c, imageName2)
Expect(status2).To(BeNil(), "Image should be gone when checking by second tag")

idStatus := framework.ImageStatus(c, imageID)
Expect(idStatus).To(BeNil(), "Image should be gone when checking by its ID")
})
})

// testRemoveImage removes the image name imageName and check if it successes.
func testRemoveImage(c internalapi.ImageManagerService, imageName string) {
By("Remove image : " + imageName)
removeImage(c, imageName)
image, err := c.ImageStatus(context.TODO(), &runtimeapi.ImageSpec{Image: imageName}, false)
framework.ExpectNoError(err, "failed to get image status: %v", err)

if image.GetImage() != nil {
By("Remove image by ID : " + image.GetImage().GetId())
err = c.RemoveImage(context.TODO(), &runtimeapi.ImageSpec{Image: image.GetImage().GetId()})
framework.ExpectNoError(err, "failed to remove image: %v", err)
}

By("Check image list empty")

Expand Down
161 changes: 161 additions & 0 deletions pkg/validate/image_consistency.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
Copyright 2026 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package validate

import (
"context"
"slices"
"sync"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
internalapi "k8s.io/cri-api/pkg/apis"
runtimeapi "k8s.io/cri-api/pkg/apis/runtime/v1"

"sigs.k8s.io/cri-tools/pkg/framework"
)

var _ = framework.KubeDescribe("Image Consistency", func() {
f := framework.NewDefaultCRIFramework()

var c internalapi.ImageManagerService

BeforeEach(func() {
c = f.CRIClient.CRIImageClient
})

// Test: Immediate call to ListImages (and other methods like GetImage) after removing the image must not have this image information
It("should not list or get image status immediately after removal [Conformance]", Serial, func() {
imageName := testImageWithTag

// Ensure image is absent before test
removeImage(c, imageName)

By("Pulling image: " + imageName)
framework.PullPublicImage(c, imageName, testImagePodSandbox)

By("Removing image: " + imageName)
removeImage(c, imageName)

By("Verifying image is not listed")
images := framework.ListImage(c, &runtimeapi.ImageFilter{})
found := false
for _, img := range images {
if slices.Contains(img.GetRepoTags(), imageName) {
found = true
}
if found {
break
}
}
Expect(found).To(BeFalse(), "Image %q should not be listed after removal", imageName)

By("Verifying image status is nil")
imageStatus := framework.ImageStatus(c, imageName)
Expect(imageStatus).To(BeNil(), "Image status for %q should be nil after removal", imageName)
})

It("should list and get image status immediately after pulling [Conformance]", Serial, func() {
imageName := testImageWithTag

// Ensure image is absent before test
removeImage(c, imageName)

By("Pulling image: " + imageName)
framework.PullPublicImage(c, imageName, testImagePodSandbox)
// Defer removal to ensure cleanup even if test fails
defer removeImage(c, imageName)

By("Verifying image is listed")
images := framework.ListImage(c, &runtimeapi.ImageFilter{})
found := false
for _, img := range images {
if slices.Contains(img.GetRepoTags(), imageName) {
found = true
}
if found {
break
}
}
Expect(found).To(BeTrue(), "Image %q should be listed after pulling", imageName)

By("Verifying image status is not nil")
imageStatus := framework.ImageStatus(c, imageName)
Expect(imageStatus).NotTo(BeNil(), "Image status for %q should be available after pulling", imageName)
})

// TODO: Implement ImageFsInfo tests
//
// It("ImageFsInfo usage should increase momentarily when an image is pulled", func() {
// // 1. Get initial ImageFsInfo.
// // 2. Pull a new, unique image (e.g., by digest) to ensure it's not cached.
// // 3. Immediately (or within a very short timeout) check that the ImageFsInfo usage has increased.
// // This validates that the runtime updates its stats promptly after a pull.
// })
//
// It("ImageFsInfo usage should decrease eventually when an image is removed", func() {
// // 1. Pull a new, unique image.
// // 2. Get the ImageFsInfo after the pull.
// // 3. Remove the image.
// // 4. Poll (with a reasonable timeout) until the ImageFsInfo usage decreases.
// // This validates that the runtime reclaims space and updates its stats after removal,
// // acknowledging that the cleanup might be asynchronous.
// })

It("should not fail on simultaneous RemoveImage calls [Conformance]", Serial, func() {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@haircommander @saschagrunert can it be that the failure in CI indicate an actual different behavior of CRI-O. In logs I seems to observe that the one of remove images finished, but after this image status still returns the image.

Copy link
Member

@saschagrunert saschagrunert Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like an issue in CRI-O, I have to investigate this. Ref: cri-o/cri-o#9717

imageName := testImageWithTag
removeImage(c, imageName) // Ensure image is not present

By("Pulling an image to be removed")
imageID := framework.PullPublicImage(c, imageName, testImagePodSandbox)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SergeyKanzhelev Can we use image ID returned by CRI, not image reference? Just as image GC does.

https://github.com/kubernetes/kubernetes/blob/6ec02c3061d47462ac1a07a0edd136b26d79383e/staging/src/k8s.io/cri-api/pkg/apis/runtime/v1/api.proto#L1587-L1588

The behavior of CRI-O when deleting an image with its digest is a bit complicated and it may take some time to get consensus about how we fix it.


By("Concurrently removing the same image")
var wg sync.WaitGroup
// Channel to collect results from each goroutine
type removeResult struct {
err error
imageFound bool
}
results := make(chan removeResult, 5)

for range 5 {
wg.Go(func() {
// Use the specific image ID for removal to avoid ambiguity
remErr := c.RemoveImage(context.Background(), &runtimeapi.ImageSpec{Image: imageID})

// Immediately check image status after removal attempt
status := framework.ImageStatus(c, imageID)
imageFound := (status != nil)

results <- removeResult{err: remErr, imageFound: imageFound}
})
}
wg.Wait()
close(results)

// Verify results: all calls should succeed, and image should be gone immediately after each call
for res := range results {
Expect(res.err).NotTo(HaveOccurred(), "Concurrent RemoveImage calls should not return an error")
// Assert immediate disappearance of the image
Expect(res.imageFound).To(BeFalse(), "Image should be missing immediately after RemoveImage call returns")
}

By("Verifying the image is completely removed (final check)")
status := framework.ImageStatus(c, imageID)
Expect(status).To(BeNil(), "Image should be removed after all concurrent calls")
})
})
Loading