Browse Source

Merge pull request #46840 from dmcgowan/c8d-rmi-cleanup

containerd: Image delete fixes and cleanup
Sebastiaan van Stijn 1 year ago
parent
commit
43a82dab90

+ 1 - 2
daemon/containerd/handlers.go

@@ -12,8 +12,7 @@ import (
 // walkPresentChildren is a simple wrapper for containerdimages.Walk with presentChildrenHandler.
 // This is only a convenient helper to reduce boilerplate.
 func (i *ImageService) walkPresentChildren(ctx context.Context, target ocispec.Descriptor, f func(context.Context, ocispec.Descriptor) error) error {
-	store := i.client.ContentStore()
-	return containerdimages.Walk(ctx, presentChildrenHandler(store, containerdimages.HandlerFunc(
+	return containerdimages.Walk(ctx, presentChildrenHandler(i.content, containerdimages.HandlerFunc(
 		func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
 			return nil, f(ctx, desc)
 		})), target)

+ 158 - 9
daemon/containerd/image.go

@@ -29,6 +29,8 @@ import (
 
 var truncatedID = regexp.MustCompile(`^(sha256:)?([a-f0-9]{4,64})$`)
 
+var errInconsistentData error = errors.New("consistency error: data changed during operation, retry")
+
 // GetImage returns an image corresponding to the image referred to by refOrID.
 func (i *ImageService) GetImage(ctx context.Context, refOrID string, options imagetype.GetImageOpts) (*image.Image, error) {
 	desc, err := i.resolveImage(ctx, refOrID)
@@ -41,8 +43,6 @@ func (i *ImageService) GetImage(ctx context.Context, refOrID string, options ima
 		platform = cplatforms.OnlyStrict(*options.Platform)
 	}
 
-	cs := i.client.ContentStore()
-
 	var presentImages []imagespec.DockerOCIImage
 	err = i.walkImageManifests(ctx, desc, func(img *ImageManifest) error {
 		conf, err := img.Config(ctx)
@@ -57,7 +57,7 @@ func (i *ImageService) GetImage(ctx context.Context, refOrID string, options ima
 		}
 
 		var ociimage imagespec.DockerOCIImage
-		if err := readConfig(ctx, cs, conf, &ociimage); err != nil {
+		if err := readConfig(ctx, i.content, conf, &ociimage); err != nil {
 			if cerrdefs.IsNotFound(err) {
 				log.G(ctx).WithFields(log.Fields{
 					"manifestDescriptor": img.Target(),
@@ -99,7 +99,7 @@ func (i *ImageService) GetImage(ctx context.Context, refOrID string, options ima
 			return nil, err
 		}
 
-		tagged, err := i.client.ImageService().List(ctx, "target.digest=="+desc.Target.Digest.String())
+		tagged, err := i.images.List(ctx, "target.digest=="+desc.Target.Digest.String())
 		if err != nil {
 			return nil, err
 		}
@@ -264,11 +264,9 @@ func (i *ImageService) resolveImage(ctx context.Context, refOrID string) (contai
 		return containerdimages.Image{}, errdefs.InvalidParameter(err)
 	}
 
-	is := i.client.ImageService()
-
 	digested, ok := parsed.(reference.Digested)
 	if ok {
-		imgs, err := is.List(ctx, "target.digest=="+digested.Digest().String())
+		imgs, err := i.images.List(ctx, "target.digest=="+digested.Digest().String())
 		if err != nil {
 			return containerdimages.Image{}, errors.Wrap(err, "failed to lookup digest")
 		}
@@ -298,7 +296,7 @@ func (i *ImageService) resolveImage(ctx context.Context, refOrID string) (contai
 	}
 
 	ref := reference.TagNameOnly(parsed.(reference.Named)).String()
-	img, err := is.Get(ctx, ref)
+	img, err := i.images.Get(ctx, ref)
 	if err == nil {
 		return img, nil
 	} else {
@@ -315,7 +313,7 @@ func (i *ImageService) resolveImage(ctx context.Context, refOrID string) (contai
 			fmt.Sprintf("name==%q", ref), // Or it could just look like one.
 			"target.digest~=" + strconv.Quote(fmt.Sprintf(`^sha256:%s[0-9a-fA-F]{%d}$`, regexp.QuoteMeta(idWithoutAlgo), 64-len(idWithoutAlgo))),
 		}
-		imgs, err := is.List(ctx, filters...)
+		imgs, err := i.images.List(ctx, filters...)
 		if err != nil {
 			return containerdimages.Image{}, err
 		}
@@ -382,3 +380,154 @@ func (i *ImageService) getImageLabelByDigest(ctx context.Context, target digest.
 
 	return value, nil
 }
+
+func convertError(err error) error {
+	// TODO: Convert containerd error to Docker error
+	return err
+}
+
+// resolveAllReferences resolves the reference name or ID to an image and returns all the images with
+// the same target.
+//
+// Returns:
+//
+// 1: *(github.com/containerd/containerd/images).Image
+//
+//	An image match from the image store with the provided refOrID
+//
+// 2: [](github.com/containerd/containerd/images).Image
+//
+//	List of all images with the same target that matches the refOrID. If the first argument is
+//	non-nil, the image list will all have the same target as the matched image. If the first
+//	argument is nil but the list is non-empty, this value is a list of all the images with a
+//	target that matches the digest provided in the refOrID, but none are an image name match
+//	to refOrID.
+//
+// 3: error
+//
+//	An error looking up refOrID or no images found with matching name or target. Note that the first
+//	argument may be nil with a nil error if the second argument is non-empty.
+func (i *ImageService) resolveAllReferences(ctx context.Context, refOrID string) (*containerdimages.Image, []containerdimages.Image, error) {
+	parsed, err := reference.ParseAnyReference(refOrID)
+	if err != nil {
+		return nil, nil, errdefs.InvalidParameter(err)
+	}
+	var dgst digest.Digest
+	var img *containerdimages.Image
+
+	if truncatedID.MatchString(refOrID) {
+		if d, ok := parsed.(reference.Digested); ok {
+			if cimg, err := i.images.Get(ctx, d.String()); err == nil {
+				img = &cimg
+				dgst = d.Digest()
+				if cimg.Target.Digest != dgst {
+					// Ambiguous image reference, use reference name
+					log.G(ctx).WithField("image", refOrID).WithField("target", cimg.Target.Digest).Warn("digest reference points to image with a different digest")
+					dgst = cimg.Target.Digest
+				}
+			} else if !cerrdefs.IsNotFound(err) {
+				return nil, nil, convertError(err)
+			} else {
+				dgst = d.Digest()
+			}
+		} else {
+			idWithoutAlgo := strings.TrimPrefix(refOrID, "sha256:")
+			name := reference.TagNameOnly(parsed.(reference.Named)).String()
+			filters := []string{
+				fmt.Sprintf("name==%q", name), // Or it could just look like one.
+				"target.digest~=" + strconv.Quote(fmt.Sprintf(`^sha256:%s[0-9a-fA-F]{%d}$`, regexp.QuoteMeta(idWithoutAlgo), 64-len(idWithoutAlgo))),
+			}
+			imgs, err := i.images.List(ctx, filters...)
+			if err != nil {
+				return nil, nil, convertError(err)
+			}
+
+			if len(imgs) == 0 {
+				return nil, nil, images.ErrImageDoesNotExist{Ref: parsed}
+			}
+
+			for _, limg := range imgs {
+				if limg.Name == name {
+					copyImg := limg
+					img = &copyImg
+				}
+				if dgst != "" {
+					if limg.Target.Digest != dgst {
+						return nil, nil, errdefs.NotFound(errors.New("ambiguous reference"))
+					}
+				} else {
+					dgst = limg.Target.Digest
+				}
+			}
+
+			// Return immediately if target digest matches already included
+			if img == nil || len(imgs) > 1 {
+				return img, imgs, nil
+			}
+		}
+	} else {
+		named, ok := parsed.(reference.Named)
+		if !ok {
+			return nil, nil, errdefs.InvalidParameter(errors.New("invalid name reference"))
+		}
+
+		digested, ok := parsed.(reference.Digested)
+		if ok {
+			dgst = digested.Digest()
+		}
+
+		name := reference.TagNameOnly(named).String()
+
+		cimg, err := i.images.Get(ctx, name)
+		if err != nil {
+			if !cerrdefs.IsNotFound(err) {
+				return nil, nil, convertError(err)
+			}
+			// If digest is given, continue looking up for matching targets.
+			// There will be no exact match found but the caller may attempt
+			// to match across images with the matching target.
+			if dgst == "" {
+				return nil, nil, images.ErrImageDoesNotExist{Ref: parsed}
+			}
+		} else {
+			img = &cimg
+			if dgst != "" && img.Target.Digest != dgst {
+				// Ambiguous image reference, use reference name
+				log.G(ctx).WithField("image", name).WithField("target", cimg.Target.Digest).Warn("digest reference points to image with a different digest")
+			}
+			dgst = img.Target.Digest
+		}
+	}
+
+	// Lookup up all associated images and check for consistency with first reference
+	// Ideally operations dependent on multiple values will rely on the garbage collector,
+	// this logic will just check for consistency and throw an error
+	imgs, err := i.images.List(ctx, "target.digest=="+dgst.String())
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "failed to lookup digest")
+	}
+	if len(imgs) == 0 {
+		if img == nil {
+			return nil, nil, images.ErrImageDoesNotExist{Ref: parsed}
+		}
+		err = errInconsistentData
+	} else if img != nil {
+		// Check to ensure the original img is in the list still
+		err = errInconsistentData
+		for _, rimg := range imgs {
+			if rimg.Name == img.Name {
+				err = nil
+				break
+			}
+		}
+	}
+	if errors.Is(err, errInconsistentData) {
+		if retries, ok := ctx.Value(errInconsistentData).(int); !ok || retries < 3 {
+			log.G(ctx).WithFields(log.Fields{"retry": retries, "ref": refOrID}).Info("image changed during lookup, retrying")
+			return i.resolveAllReferences(context.WithValue(ctx, errInconsistentData, retries+1), refOrID)
+		}
+		return nil, nil, err
+	}
+
+	return img, imgs, nil
+}

+ 6 - 8
daemon/containerd/image_children.go

@@ -17,7 +17,7 @@ import (
 
 // Children returns a slice of image IDs that are children of the `id` image
 func (i *ImageService) Children(ctx context.Context, id image.ID) ([]image.ID, error) {
-	imgs, err := i.client.ImageService().List(ctx, "labels."+imageLabelClassicBuilderParent+"=="+string(id))
+	imgs, err := i.images.List(ctx, "labels."+imageLabelClassicBuilderParent+"=="+string(id))
 	if err != nil {
 		return []image.ID{}, errdefs.System(errors.Wrap(err, "failed to list all images"))
 	}
@@ -88,16 +88,14 @@ func (i *ImageService) parents(ctx context.Context, id image.ID) ([]imageWithRoo
 		return nil, errors.Wrap(err, "failed to get child image")
 	}
 
-	cs := i.client.ContentStore()
-
-	allPlatforms, err := containerdimages.Platforms(ctx, cs, target)
+	allPlatforms, err := containerdimages.Platforms(ctx, i.content, target)
 	if err != nil {
 		return nil, errdefs.System(errors.Wrap(err, "failed to list platforms supported by image"))
 	}
 
 	var childRootFS []ocispec.RootFS
 	for _, platform := range allPlatforms {
-		rootfs, err := platformRootfs(ctx, cs, target, platform)
+		rootfs, err := platformRootfs(ctx, i.content, target, platform)
 		if err != nil {
 			if cerrdefs.IsNotFound(err) {
 				continue
@@ -108,7 +106,7 @@ func (i *ImageService) parents(ctx context.Context, id image.ID) ([]imageWithRoo
 		childRootFS = append(childRootFS, rootfs)
 	}
 
-	imgs, err := i.client.ImageService().List(ctx)
+	imgs, err := i.images.List(ctx)
 	if err != nil {
 		return nil, errdefs.System(errors.Wrap(err, "failed to list all images"))
 	}
@@ -117,7 +115,7 @@ func (i *ImageService) parents(ctx context.Context, id image.ID) ([]imageWithRoo
 	for _, img := range imgs {
 	nextImage:
 		for _, platform := range allPlatforms {
-			rootfs, err := platformRootfs(ctx, cs, img.Target, platform)
+			rootfs, err := platformRootfs(ctx, i.content, img.Target, platform)
 			if err != nil {
 				if cerrdefs.IsNotFound(err) {
 					continue
@@ -158,7 +156,7 @@ func (i *ImageService) getParentsByBuilderLabel(ctx context.Context, img contain
 		return nil, nil
 	}
 
-	return i.client.ImageService().List(ctx, "target.digest=="+dgst.String())
+	return i.images.List(ctx, "target.digest=="+dgst.String())
 }
 
 type imageWithRootfs struct {

+ 225 - 91
daemon/containerd/image_delete.go

@@ -5,13 +5,16 @@ import (
 	"fmt"
 	"sort"
 	"strings"
+	"time"
 
+	cerrdefs "github.com/containerd/containerd/errdefs"
 	"github.com/containerd/containerd/images"
 	"github.com/containerd/log"
 	"github.com/distribution/reference"
 	"github.com/docker/docker/api/types/events"
 	imagetypes "github.com/docker/docker/api/types/image"
 	"github.com/docker/docker/container"
+	dimages "github.com/docker/docker/daemon/images"
 	"github.com/docker/docker/image"
 	"github.com/docker/docker/internal/compatcontext"
 	"github.com/docker/docker/pkg/stringid"
@@ -53,123 +56,188 @@ import (
 //
 // TODO(thaJeztah): image delete should send prometheus counters; see https://github.com/moby/moby/issues/45268
 func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, force, prune bool) ([]imagetypes.DeleteResponse, error) {
-	parsedRef, err := reference.ParseNormalizedNamed(imageRef)
-	if err != nil {
-		return nil, err
+	var c conflictType
+	if !force {
+		c |= conflictSoft
 	}
 
-	img, err := i.resolveImage(ctx, imageRef)
+	img, all, err := i.resolveAllReferences(ctx, imageRef)
 	if err != nil {
 		return nil, err
 	}
 
-	imgID := image.ID(img.Target.Digest)
-
-	explicitDanglingRef := strings.HasPrefix(imageRef, imageNameDanglingPrefix) && isDanglingImage(img)
-	if isImageIDPrefix(imgID.String(), imageRef) || explicitDanglingRef {
-		return i.deleteAll(ctx, img, force, prune)
-	}
-
-	singleRef, err := i.isSingleReference(ctx, img)
-	if err != nil {
-		return nil, err
-	}
-	if !singleRef {
-		err := i.client.ImageService().Delete(ctx, img.Name)
+	var imgID image.ID
+	if img == nil {
+		if len(all) == 0 {
+			parsed, _ := reference.ParseAnyReference(imageRef)
+			return nil, dimages.ErrImageDoesNotExist{Ref: parsed}
+		}
+		imgID = image.ID(all[0].Target.Digest)
+		var named reference.Named
+		if !isImageIDPrefix(imgID.String(), imageRef) {
+			if nn, err := reference.ParseNormalizedNamed(imageRef); err == nil {
+				named = nn
+			}
+		}
+		sameRef, err := i.getSameReferences(ctx, named, all)
 		if err != nil {
 			return nil, err
 		}
-		i.LogImageEvent(imgID.String(), imgID.String(), events.ActionUnTag)
-		records := []imagetypes.DeleteResponse{{Untagged: reference.FamiliarString(reference.TagNameOnly(parsedRef))}}
-		return records, nil
-	}
 
-	using := func(c *container.Container) bool {
-		return c.ImageID == imgID
-	}
-	ctr := i.containers.First(using)
-	if ctr != nil {
-		if !force {
-			// If we removed the repository reference then
-			// this image would remain "dangling" and since
-			// we really want to avoid that the client must
-			// explicitly force its removal.
-			refString := reference.FamiliarString(reference.TagNameOnly(parsedRef))
-			err := &imageDeleteConflict{
-				reference: refString,
-				used:      true,
-				message: fmt.Sprintf("container %s is using its referenced image %s",
-					stringid.TruncateID(ctr.ID),
-					stringid.TruncateID(imgID.String())),
+		if len(sameRef) == 0 && named != nil {
+			return nil, dimages.ErrImageDoesNotExist{Ref: named}
+		}
+
+		if len(sameRef) == len(all) && !force {
+			c &= ^conflictActiveReference
+		}
+		if named != nil && len(sameRef) > 0 && len(sameRef) != len(all) {
+			var records []imagetypes.DeleteResponse
+			for _, ref := range sameRef {
+				// TODO: Add with target
+				err := i.images.Delete(ctx, ref.Name)
+				if err != nil {
+					return nil, err
+				}
+				if nn, err := reference.ParseNormalizedNamed(ref.Name); err == nil {
+					familiarRef := reference.FamiliarString(nn)
+					i.logImageEvent(ref, familiarRef, events.ActionUnTag)
+					records = append(records, imagetypes.DeleteResponse{Untagged: familiarRef})
+				}
 			}
+			return records, nil
+		}
+	} else {
+		imgID = image.ID(img.Target.Digest)
+		explicitDanglingRef := strings.HasPrefix(imageRef, imageNameDanglingPrefix) && isDanglingImage(*img)
+		if isImageIDPrefix(imgID.String(), imageRef) || explicitDanglingRef {
+			return i.deleteAll(ctx, imgID, all, c, prune)
+		}
+		parsedRef, err := reference.ParseNormalizedNamed(img.Name)
+		if err != nil {
 			return nil, err
 		}
 
-		err := i.softImageDelete(ctx, img)
+		sameRef, err := i.getSameReferences(ctx, parsedRef, all)
 		if err != nil {
 			return nil, err
 		}
+		if len(sameRef) != len(all) {
+			var records []imagetypes.DeleteResponse
+			for _, ref := range sameRef {
+				// TODO: Add with target
+				err := i.images.Delete(ctx, ref.Name)
+				if err != nil {
+					return nil, err
+				}
+				if nn, err := reference.ParseNormalizedNamed(ref.Name); err == nil {
+					familiarRef := reference.FamiliarString(nn)
+					i.logImageEvent(ref, familiarRef, events.ActionUnTag)
+					records = append(records, imagetypes.DeleteResponse{Untagged: familiarRef})
+				}
+			}
+			return records, nil
+		} else if len(all) > 1 && !force {
+			// Since only a single used reference, remove all active
+			// TODO: Consider keeping the conflict and changing active
+			// reference calculation in image checker.
+			c &= ^conflictActiveReference
+		}
+
+		using := func(c *container.Container) bool {
+			return c.ImageID == imgID
+		}
+		// TODO: Should this also check parentage here?
+		ctr := i.containers.First(using)
+		if ctr != nil {
+			familiarRef := reference.FamiliarString(parsedRef)
+			if !force {
+				// If we removed the repository reference then
+				// this image would remain "dangling" and since
+				// we really want to avoid that the client must
+				// explicitly force its removal.
+				err := &imageDeleteConflict{
+					reference: familiarRef,
+					used:      true,
+					message: fmt.Sprintf("container %s is using its referenced image %s",
+						stringid.TruncateID(ctr.ID),
+						stringid.TruncateID(imgID.String())),
+				}
+				return nil, err
+			}
+
+			// Delete all images
+			err := i.softImageDelete(ctx, *img, all)
+			if err != nil {
+				return nil, err
+			}
 
-		i.LogImageEvent(imgID.String(), imgID.String(), events.ActionUnTag)
-		records := []imagetypes.DeleteResponse{{Untagged: reference.FamiliarString(reference.TagNameOnly(parsedRef))}}
-		return records, nil
+			i.logImageEvent(*img, familiarRef, events.ActionUnTag)
+			records := []imagetypes.DeleteResponse{{Untagged: familiarRef}}
+			return records, nil
+		}
 	}
 
-	return i.deleteAll(ctx, img, force, prune)
+	return i.deleteAll(ctx, imgID, all, c, prune)
 }
 
 // deleteAll deletes the image from the daemon, and if prune is true,
 // also deletes dangling parents if there is no conflict in doing so.
 // Parent images are removed quietly, and if there is any issue/conflict
 // it is logged but does not halt execution/an error is not returned.
-func (i *ImageService) deleteAll(ctx context.Context, img images.Image, force, prune bool) ([]imagetypes.DeleteResponse, error) {
-	var records []imagetypes.DeleteResponse
-
+func (i *ImageService) deleteAll(ctx context.Context, imgID image.ID, all []images.Image, c conflictType, prune bool) (records []imagetypes.DeleteResponse, err error) {
 	// Workaround for: https://github.com/moby/buildkit/issues/3797
 	possiblyDeletedConfigs := map[digest.Digest]struct{}{}
-	err := i.walkPresentChildren(ctx, img.Target, func(_ context.Context, d ocispec.Descriptor) error {
-		if images.IsConfigType(d.MediaType) {
-			possiblyDeletedConfigs[d.Digest] = struct{}{}
+	if len(all) > 0 && i.content != nil {
+		handled := map[digest.Digest]struct{}{}
+		for _, img := range all {
+			if _, ok := handled[img.Target.Digest]; ok {
+				continue
+			} else {
+				handled[img.Target.Digest] = struct{}{}
+			}
+			err := i.walkPresentChildren(ctx, img.Target, func(_ context.Context, d ocispec.Descriptor) error {
+				if images.IsConfigType(d.MediaType) {
+					possiblyDeletedConfigs[d.Digest] = struct{}{}
+				}
+				return nil
+			})
+			if err != nil {
+				return nil, err
+			}
 		}
-		return nil
-	})
-	if err != nil {
-		return nil, err
 	}
 	defer func() {
-		if err := i.unleaseSnapshotsFromDeletedConfigs(compatcontext.WithoutCancel(ctx), possiblyDeletedConfigs); err != nil {
-			log.G(ctx).WithError(err).Warn("failed to unlease snapshots")
+		if len(possiblyDeletedConfigs) > 0 {
+			if err := i.unleaseSnapshotsFromDeletedConfigs(compatcontext.WithoutCancel(ctx), possiblyDeletedConfigs); err != nil {
+				log.G(ctx).WithError(err).Warn("failed to unlease snapshots")
+			}
 		}
 	}()
 
-	imgID := img.Target.Digest.String()
-
 	var parents []imageWithRootfs
 	if prune {
-		parents, err = i.parents(ctx, image.ID(imgID))
+		// TODO(dmcgowan): Consider using GC labels to walk for deletion
+		parents, err = i.parents(ctx, imgID)
 		if err != nil {
 			log.G(ctx).WithError(err).Warn("failed to get image parents")
 		}
 		sortParentsByAffinity(parents)
 	}
 
-	imageRefs, err := i.client.ImageService().List(ctx, "target.digest=="+imgID)
-	if err != nil {
-		return nil, err
-	}
-	for _, imageRef := range imageRefs {
-		if err := i.imageDeleteHelper(ctx, imageRef, &records, force); err != nil {
+	for _, imageRef := range all {
+		if err := i.imageDeleteHelper(ctx, imageRef, all, &records, c); err != nil {
 			return records, err
 		}
 	}
-	i.LogImageEvent(imgID, imgID, events.ActionDelete)
-	records = append(records, imagetypes.DeleteResponse{Deleted: imgID})
+	i.LogImageEvent(imgID.String(), imgID.String(), events.ActionDelete)
+	records = append(records, imagetypes.DeleteResponse{Deleted: imgID.String()})
 
 	for _, parent := range parents {
 		if !isDanglingImage(parent.img) {
 			break
 		}
-		err = i.imageDeleteHelper(ctx, parent.img, &records, false)
+		err = i.imageDeleteHelper(ctx, parent.img, all, &records, conflictSoft)
 		if err != nil {
 			log.G(ctx).WithError(err).Warn("failed to remove image parent")
 			break
@@ -205,19 +273,71 @@ func sortParentsByAffinity(parents []imageWithRootfs) {
 	})
 }
 
-// isSingleReference returns true if there are no other images in the
-// daemon targeting the same content as `img` that are not dangling.
-func (i *ImageService) isSingleReference(ctx context.Context, img images.Image) (bool, error) {
-	refs, err := i.client.ImageService().List(ctx, "target.digest=="+img.Target.Digest.String())
-	if err != nil {
-		return false, err
+// getSameReferences returns the set of images which are the same as:
+// - the provided img if non-nil
+// - OR the first named image found in the provided image set
+// - OR the full set of provided images if no named references in the set
+//
+// References are considered the same if:
+// - Both contain the same name and tag
+// - Both contain the same name, one is untagged and no other differing tags in set
+// - One is dangling
+//
+// Note: All imgs should have the same target, only the image name will be considered
+// for determining whether images are the same.
+func (i *ImageService) getSameReferences(ctx context.Context, named reference.Named, imgs []images.Image) ([]images.Image, error) {
+	var (
+		tag        string
+		sameRef    []images.Image
+		digestRefs = []images.Image{}
+		allTags    bool
+	)
+	if named != nil {
+		if tagged, ok := named.(reference.Tagged); ok {
+			tag = tagged.Tag()
+		} else if _, ok := named.(reference.Digested); ok {
+			// If digest is explicitly provided, match all tags
+			allTags = true
+		}
 	}
-	for _, ref := range refs {
-		if !isDanglingImage(ref) && ref.Name != img.Name {
-			return false, nil
+	for _, ref := range imgs {
+		if !isDanglingImage(ref) {
+			if repoRef, err := reference.ParseNamed(ref.Name); err == nil {
+				if named == nil {
+					named = repoRef
+					if tagged, ok := named.(reference.Tagged); ok {
+						tag = tagged.Tag()
+					}
+				} else if named.Name() != repoRef.Name() {
+					continue
+				} else if !allTags {
+					if tagged, ok := repoRef.(reference.Tagged); ok {
+						if tag == "" {
+							tag = tagged.Tag()
+						} else if tag != tagged.Tag() {
+							// Same repo, different tag, do not include digest refs
+							digestRefs = nil
+							continue
+						}
+					} else {
+						if digestRefs != nil {
+							digestRefs = append(digestRefs, ref)
+						}
+						// Add digest refs at end if no other tags in the same name
+						continue
+					}
+				}
+			} else {
+				// Ignore names which do not parse
+				log.G(ctx).WithError(err).WithField("image", ref.Name).Info("failed to parse image name, ignoring")
+			}
 		}
+		sameRef = append(sameRef, ref)
 	}
-	return true, nil
+	if digestRefs != nil {
+		sameRef = append(sameRef, digestRefs...)
+	}
+	return sameRef, nil
 }
 
 type conflictType int
@@ -238,17 +358,14 @@ const (
 // images and untagged references are appended to the given records. If any
 // error or conflict is encountered, it will be returned immediately without
 // deleting the image.
-func (i *ImageService) imageDeleteHelper(ctx context.Context, img images.Image, records *[]imagetypes.DeleteResponse, force bool) error {
+func (i *ImageService) imageDeleteHelper(ctx context.Context, img images.Image, all []images.Image, records *[]imagetypes.DeleteResponse, extra conflictType) error {
 	// First, determine if this image has any conflicts. Ignore soft conflicts
 	// if force is true.
-	c := conflictHard
-	if !force {
-		c |= conflictSoft
-	}
+	c := conflictHard | extra
 
 	imgID := image.ID(img.Target.Digest)
 
-	err := i.checkImageDeleteConflict(ctx, imgID, c)
+	err := i.checkImageDeleteConflict(ctx, imgID, all, c)
 	if err != nil {
 		return err
 	}
@@ -257,13 +374,33 @@ func (i *ImageService) imageDeleteHelper(ctx context.Context, img images.Image,
 	if err != nil {
 		return err
 	}
-	err = i.client.ImageService().Delete(ctx, img.Name, images.SynchronousDelete())
+
+	if !isDanglingImage(img) && len(all) == 1 && extra&conflictActiveReference != 0 {
+		children, err := i.Children(ctx, imgID)
+		if err != nil {
+			return err
+		}
+		if len(children) > 0 {
+			img := images.Image{
+				Name:      danglingImageName(img.Target.Digest),
+				Target:    img.Target,
+				CreatedAt: time.Now(),
+				Labels:    img.Labels,
+			}
+			if _, err = i.client.ImageService().Create(ctx, img); err != nil && !cerrdefs.IsAlreadyExists(err) {
+				return fmt.Errorf("failed to create dangling image: %w", err)
+			}
+		}
+	}
+
+	// TODO: Add target option
+	err = i.images.Delete(ctx, img.Name, images.SynchronousDelete())
 	if err != nil {
 		return err
 	}
 
 	if !isDanglingImage(img) {
-		i.LogImageEvent(imgID.String(), imgID.String(), events.ActionUnTag)
+		i.logImageEvent(img, reference.FamiliarString(untaggedRef), events.ActionUnTag)
 		*records = append(*records, imagetypes.DeleteResponse{Untagged: reference.FamiliarString(untaggedRef)})
 	}
 
@@ -299,7 +436,7 @@ func (imageDeleteConflict) Conflict() {}
 // nil if there are none. It takes a bitmask representing a
 // filter for which conflict types the caller cares about,
 // and will only check for these conflict types.
-func (i *ImageService) checkImageDeleteConflict(ctx context.Context, imgID image.ID, mask conflictType) error {
+func (i *ImageService) checkImageDeleteConflict(ctx context.Context, imgID image.ID, all []images.Image, mask conflictType) error {
 	if mask&conflictRunningContainer != 0 {
 		running := func(c *container.Container) bool {
 			return c.ImageID == imgID && c.IsRunning()
@@ -328,11 +465,8 @@ func (i *ImageService) checkImageDeleteConflict(ctx context.Context, imgID image
 	}
 
 	if mask&conflictActiveReference != 0 {
-		refs, err := i.client.ImageService().List(ctx, "target.digest=="+imgID.String())
-		if err != nil {
-			return err
-		}
-		if len(refs) > 1 {
+		// TODO: Count unexpired references...
+		if len(all) > 1 {
 			return &imageDeleteConflict{
 				reference: stringid.TruncateID(imgID.String()),
 				message:   "image is referenced in multiple repositories",

+ 286 - 0
daemon/containerd/image_delete_test.go

@@ -0,0 +1,286 @@
+package containerd
+
+import (
+	"context"
+	"testing"
+
+	"github.com/containerd/containerd/images"
+	"github.com/containerd/containerd/metadata"
+	"github.com/containerd/containerd/namespaces"
+	"github.com/containerd/log/logtest"
+	"github.com/docker/docker/container"
+	daemonevents "github.com/docker/docker/daemon/events"
+	dimages "github.com/docker/docker/daemon/images"
+	"gotest.tools/v3/assert"
+	is "gotest.tools/v3/assert/cmp"
+)
+
+func TestImageDelete(t *testing.T) {
+	ctx := namespaces.WithNamespace(context.TODO(), "testing")
+
+	for _, tc := range []struct {
+		ref       string
+		starting  []images.Image
+		remaining []images.Image
+		err       error
+		// TODO: Records
+		// TODO: Containers
+		// TODO: Events
+	}{
+		{
+			ref: "nothingthere",
+			err: dimages.ErrImageDoesNotExist{Ref: nameTag("nothingthere", "latest")},
+		},
+		{
+			ref: "justoneimage",
+			starting: []images.Image{
+				{
+					Name:   "docker.io/library/justoneimage:latest",
+					Target: desc(10),
+				},
+			},
+		},
+		{
+			ref: "justoneref",
+			starting: []images.Image{
+				{
+					Name:   "docker.io/library/justoneref:latest",
+					Target: desc(10),
+				},
+				{
+					Name:   "docker.io/library/differentrepo:latest",
+					Target: desc(10),
+				},
+			},
+			remaining: []images.Image{
+				{
+					Name:   "docker.io/library/differentrepo:latest",
+					Target: desc(10),
+				},
+			},
+		},
+		{
+			ref: "hasdigest",
+			starting: []images.Image{
+				{
+					Name:   "docker.io/library/hasdigest:latest",
+					Target: desc(10),
+				},
+				{
+					Name:   "docker.io/library/hasdigest@" + digestFor(10).String(),
+					Target: desc(10),
+				},
+			},
+		},
+		{
+			ref: digestFor(11).String(),
+			starting: []images.Image{
+				{
+					Name:   "docker.io/library/byid:latest",
+					Target: desc(11),
+				},
+				{
+					Name:   "docker.io/library/byid@" + digestFor(11).String(),
+					Target: desc(11),
+				},
+			},
+		},
+		{
+			ref: "bydigest@" + digestFor(12).String(),
+			starting: []images.Image{
+				{
+					Name:   "docker.io/library/bydigest:latest",
+					Target: desc(12),
+				},
+				{
+					Name:   "docker.io/library/bydigest@" + digestFor(12).String(),
+					Target: desc(12),
+				},
+			},
+		},
+		{
+			ref: "onerefoftwo",
+			starting: []images.Image{
+				{
+					Name:   "docker.io/library/onerefoftwo:latest",
+					Target: desc(12),
+				},
+				{
+					Name:   "docker.io/library/onerefoftwo:other",
+					Target: desc(12),
+				},
+				{
+					Name:   "docker.io/library/onerefoftwo@" + digestFor(12).String(),
+					Target: desc(12),
+				},
+			},
+			remaining: []images.Image{
+				{
+					Name:   "docker.io/library/onerefoftwo:other",
+					Target: desc(12),
+				},
+				{
+					Name:   "docker.io/library/onerefoftwo@" + digestFor(12).String(),
+					Target: desc(12),
+				},
+			},
+		},
+		{
+			ref: "otherreporemaining",
+			starting: []images.Image{
+				{
+					Name:   "docker.io/library/otherreporemaining:latest",
+					Target: desc(12),
+				},
+				{
+					Name:   "docker.io/library/otherreporemaining@" + digestFor(12).String(),
+					Target: desc(12),
+				},
+				{
+					Name:   "docker.io/library/someotherrepo:latest",
+					Target: desc(12),
+				},
+			},
+			remaining: []images.Image{
+				{
+					Name:   "docker.io/library/someotherrepo:latest",
+					Target: desc(12),
+				},
+			},
+		},
+		{
+			ref: "repoanddigest@" + digestFor(15).String(),
+			starting: []images.Image{
+				{
+					Name:   "docker.io/library/repoanddigest:latest",
+					Target: desc(15),
+				},
+				{
+					Name:   "docker.io/library/repoanddigest:latest@" + digestFor(15).String(),
+					Target: desc(15),
+				},
+				{
+					Name:   "docker.io/library/someotherrepo:latest",
+					Target: desc(15),
+				},
+			},
+			remaining: []images.Image{
+				{
+					Name:   "docker.io/library/someotherrepo:latest",
+					Target: desc(15),
+				},
+			},
+		},
+		{
+			ref: "repoanddigestothertags@" + digestFor(15).String(),
+			starting: []images.Image{
+				{
+					Name:   "docker.io/library/repoanddigestothertags:v1",
+					Target: desc(15),
+				},
+				{
+					Name:   "docker.io/library/repoanddigestothertags:v1@" + digestFor(15).String(),
+					Target: desc(15),
+				},
+				{
+					Name:   "docker.io/library/repoanddigestothertags:v2",
+					Target: desc(15),
+				},
+				{
+					Name:   "docker.io/library/repoanddigestothertags:v2@" + digestFor(15).String(),
+					Target: desc(15),
+				},
+				{
+					Name:   "docker.io/library/someotherrepo:latest",
+					Target: desc(15),
+				},
+			},
+			remaining: []images.Image{
+				{
+					Name:   "docker.io/library/someotherrepo:latest",
+					Target: desc(15),
+				},
+			},
+		},
+		{
+			ref: "repoanddigestzerocase@" + digestFor(16).String(),
+			starting: []images.Image{
+				{
+					Name:   "docker.io/library/someotherrepo:latest",
+					Target: desc(16),
+				},
+			},
+			remaining: []images.Image{
+				{
+					Name:   "docker.io/library/someotherrepo:latest",
+					Target: desc(16),
+				},
+			},
+			err: dimages.ErrImageDoesNotExist{Ref: nameDigest("repoanddigestzerocase", digestFor(16))},
+		},
+	} {
+		tc := tc
+		t.Run(tc.ref, func(t *testing.T) {
+			t.Parallel()
+			ctx := logtest.WithT(ctx, t)
+			mdb := newTestDB(ctx, t)
+			service := &ImageService{
+				images:        metadata.NewImageStore(mdb),
+				containers:    emptyTestContainerStore(),
+				eventsService: daemonevents.New(),
+			}
+			for _, img := range tc.starting {
+				if _, err := service.images.Create(ctx, img); err != nil {
+					t.Fatalf("failed to create image %q: %v", img.Name, err)
+				}
+			}
+
+			_, err := service.ImageDelete(ctx, tc.ref, false, false)
+			if tc.err == nil {
+				assert.NilError(t, err)
+			} else {
+				assert.Error(t, err, tc.err.Error())
+			}
+
+			all, err := service.images.List(ctx)
+			assert.NilError(t, err)
+			assert.Assert(t, is.Len(tc.remaining, len(all)))
+
+			// Order should match
+			for i := range all {
+				assert.Check(t, is.Equal(all[i].Name, tc.remaining[i].Name), "image[%d]", i)
+				assert.Check(t, is.Equal(all[i].Target.Digest, tc.remaining[i].Target.Digest), "image[%d]", i)
+				// TODO: Check labels too
+			}
+		})
+	}
+
+}
+
+type testContainerStore struct{}
+
+func emptyTestContainerStore() container.Store {
+	return &testContainerStore{}
+}
+
+func (*testContainerStore) Add(string, *container.Container) {}
+
+func (*testContainerStore) Get(string) *container.Container {
+	return nil
+}
+
+func (*testContainerStore) Delete(string) {}
+
+func (*testContainerStore) List() []*container.Container {
+	return []*container.Container{}
+}
+
+func (*testContainerStore) Size() int {
+	return 0
+}
+
+func (*testContainerStore) First(container.StoreFilter) *container.Container {
+	return nil
+}
+
+func (*testContainerStore) ApplyAll(container.StoreReducer) {}

+ 13 - 0
daemon/containerd/image_events.go

@@ -3,6 +3,7 @@ package containerd
 import (
 	"context"
 
+	"github.com/containerd/containerd/images"
 	"github.com/docker/docker/api/types/events"
 	imagetypes "github.com/docker/docker/api/types/image"
 )
@@ -27,6 +28,18 @@ func (i *ImageService) LogImageEvent(imageID, refName string, action events.Acti
 	})
 }
 
+// logImageEvent generates an event related to an image with only name attribute.
+func (i *ImageService) logImageEvent(img images.Image, refName string, action events.Action) {
+	attributes := map[string]string{}
+	if refName != "" {
+		attributes["name"] = refName
+	}
+	i.eventsService.Log(action, events.ImageEventType, events.Actor{
+		ID:         img.Target.Digest.String(),
+		Attributes: attributes,
+	})
+}
+
 // copyAttributes guarantees that labels are not mutated by event triggers.
 func copyAttributes(attributes, labels map[string]string) {
 	if labels == nil {

+ 6 - 11
daemon/containerd/image_prune.go

@@ -64,10 +64,8 @@ func (i *ImageService) ImagesPrune(ctx context.Context, fltrs filters.Args) (*ty
 
 func (i *ImageService) pruneUnused(ctx context.Context, filterFunc imageFilterFunc, danglingOnly bool) (*types.ImagesPruneReport, error) {
 	report := types.ImagesPruneReport{}
-	is := i.client.ImageService()
-	store := i.client.ContentStore()
 
-	allImages, err := i.client.ImageService().List(ctx)
+	allImages, err := i.images.List(ctx)
 	if err != nil {
 		return nil, err
 	}
@@ -173,7 +171,7 @@ func (i *ImageService) pruneUnused(ctx context.Context, filterFunc imageFilterFu
 			}
 			continue
 		}
-		err = is.Delete(ctx, img.Name, containerdimages.SynchronousDelete())
+		err = i.images.Delete(ctx, img.Name, containerdimages.SynchronousDelete())
 		if err != nil && !cerrdefs.IsNotFound(err) {
 			errs = multierror.Append(errs, err)
 			if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
@@ -190,7 +188,7 @@ func (i *ImageService) pruneUnused(ctx context.Context, filterFunc imageFilterFu
 
 		// Check which blobs have been deleted and sum their sizes
 		for _, blob := range blobs {
-			_, err := store.ReaderAt(ctx, blob)
+			_, err := i.content.ReaderAt(ctx, blob)
 
 			if cerrdefs.IsNotFound(err) {
 				report.ImagesDeleted = append(report.ImagesDeleted,
@@ -211,10 +209,7 @@ func (i *ImageService) pruneUnused(ctx context.Context, filterFunc imageFilterFu
 // This is a temporary solution to the rootfs snapshot not being deleted when there's a buildkit history
 // item referencing an image config.
 func (i *ImageService) unleaseSnapshotsFromDeletedConfigs(ctx context.Context, possiblyDeletedConfigs map[digest.Digest]struct{}) error {
-	is := i.client.ImageService()
-	store := i.client.ContentStore()
-
-	all, err := is.List(ctx)
+	all, err := i.images.List(ctx)
 	if err != nil {
 		return errors.Wrap(err, "failed to list images during snapshot lease removal")
 	}
@@ -238,7 +233,7 @@ func (i *ImageService) unleaseSnapshotsFromDeletedConfigs(ctx context.Context, p
 
 	// At this point, all configs that are used by any image has been removed from the slice
 	for cfgDigest := range possiblyDeletedConfigs {
-		info, err := store.Info(ctx, cfgDigest)
+		info, err := i.content.Info(ctx, cfgDigest)
 		if err != nil {
 			if cerrdefs.IsNotFound(err) {
 				log.G(ctx).WithField("config", cfgDigest).Debug("config already gone")
@@ -254,7 +249,7 @@ func (i *ImageService) unleaseSnapshotsFromDeletedConfigs(ctx context.Context, p
 		label := "containerd.io/gc.ref.snapshot." + i.StorageDriver()
 
 		delete(info.Labels, label)
-		_, err = store.Update(ctx, info, "labels."+label)
+		_, err = i.content.Update(ctx, info, "labels."+label)
 		if err != nil {
 			errs = multierror.Append(errs, errors.Wrapf(err, "failed to remove gc.ref.snapshot label from %s", cfgDigest))
 			if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {

+ 5 - 2
daemon/containerd/image_tag.go

@@ -2,6 +2,7 @@ package containerd
 
 import (
 	"context"
+	"fmt"
 
 	cerrdefs "github.com/containerd/containerd/errdefs"
 	containerdimages "github.com/containerd/containerd/images"
@@ -34,9 +35,11 @@ func (i *ImageService) TagImage(ctx context.Context, imageID image.ID, newTag re
 			return errdefs.System(errors.Wrapf(err, "failed to create image with name %s and target %s", newImg.Name, newImg.Target.Digest.String()))
 		}
 
-		replacedImg, err := is.Get(ctx, newImg.Name)
+		replacedImg, all, err := i.resolveAllReferences(ctx, newImg.Name)
 		if err != nil {
 			return errdefs.Unknown(errors.Wrapf(err, "creating image %s failed because it already exists, but accessing it also failed", newImg.Name))
+		} else if replacedImg == nil {
+			return errdefs.Unknown(fmt.Errorf("creating image %s failed because it already exists, but failed to resolve", newImg.Name))
 		}
 
 		// Check if image we would replace already resolves to the same target.
@@ -47,7 +50,7 @@ func (i *ImageService) TagImage(ctx context.Context, imageID image.ID, newTag re
 		}
 
 		// If there already exists an image with this tag, delete it
-		if err := i.softImageDelete(ctx, replacedImg); err != nil {
+		if err := i.softImageDelete(ctx, *replacedImg, all); err != nil {
 			return errors.Wrapf(err, "failed to delete previous image %s", replacedImg.Name)
 		}
 

+ 298 - 0
daemon/containerd/image_test.go

@@ -0,0 +1,298 @@
+package containerd
+
+import (
+	"context"
+	"io"
+	"math/rand"
+	"path/filepath"
+	"testing"
+
+	"github.com/containerd/containerd/images"
+	"github.com/containerd/containerd/metadata"
+	"github.com/containerd/containerd/namespaces"
+	"github.com/containerd/log/logtest"
+	"github.com/distribution/reference"
+	dockerimages "github.com/docker/docker/daemon/images"
+	"github.com/opencontainers/go-digest"
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+
+	"go.etcd.io/bbolt"
+
+	"gotest.tools/v3/assert"
+	is "gotest.tools/v3/assert/cmp"
+)
+
+func TestLookup(t *testing.T) {
+	ctx := namespaces.WithNamespace(context.TODO(), "testing")
+	ctx = logtest.WithT(ctx, t)
+	mdb := newTestDB(ctx, t)
+	service := &ImageService{
+		images: metadata.NewImageStore(mdb),
+	}
+
+	ubuntuLatest := images.Image{
+		Name:   "docker.io/library/ubuntu:latest",
+		Target: desc(10),
+	}
+	ubuntuLatestWithDigest := images.Image{
+		Name:   "docker.io/library/ubuntu:latest@" + digestFor(10).String(),
+		Target: desc(10),
+	}
+	ubuntuLatestWithOldDigest := images.Image{
+		Name:   "docker.io/library/ubuntu:latest@" + digestFor(11).String(),
+		Target: desc(11),
+	}
+	ambiguousShortName := images.Image{
+		Name:   "docker.io/library/abcdef:latest",
+		Target: desc(12),
+	}
+	ambiguousShortNameWithDigest := images.Image{
+		Name:   "docker.io/library/abcdef:latest@" + digestFor(12).String(),
+		Target: desc(12),
+	}
+	shortNameIsHashAlgorithm := images.Image{
+		Name:   "docker.io/library/sha256:defcab",
+		Target: desc(13),
+	}
+
+	testImages := []images.Image{
+		ubuntuLatest,
+		ubuntuLatestWithDigest,
+		ubuntuLatestWithOldDigest,
+		ambiguousShortName,
+		ambiguousShortNameWithDigest,
+		shortNameIsHashAlgorithm,
+		{
+			Name:   "docker.io/test/volatile:retried",
+			Target: desc(14),
+		},
+		{
+			Name:   "docker.io/test/volatile:inconsistent",
+			Target: desc(15),
+		},
+	}
+	for _, img := range testImages {
+		if _, err := service.images.Create(ctx, img); err != nil {
+			t.Fatalf("failed to create image %q: %v", img.Name, err)
+		}
+	}
+
+	for _, tc := range []struct {
+		lookup string
+		img    *images.Image
+		all    []images.Image
+		err    error
+	}{
+		{
+			// Get ubuntu images with default "latest" tag
+			lookup: "ubuntu",
+			img:    &ubuntuLatest,
+			all:    []images.Image{ubuntuLatest, ubuntuLatestWithDigest},
+		},
+		{
+			// Get all images by image id
+			lookup: ubuntuLatest.Target.Digest.String(),
+			img:    nil,
+			all:    []images.Image{ubuntuLatest, ubuntuLatestWithDigest},
+		},
+		{
+			// Fail to lookup reference with no tag, reference has both tag and digest
+			lookup: "ubuntu@" + ubuntuLatestWithOldDigest.Target.Digest.String(),
+			img:    nil,
+			all:    []images.Image{ubuntuLatestWithOldDigest},
+		},
+		{
+			// Get all image with both tag and digest
+			lookup: "ubuntu:latest@" + ubuntuLatestWithOldDigest.Target.Digest.String(),
+			img:    &ubuntuLatestWithOldDigest,
+			all:    []images.Image{ubuntuLatestWithOldDigest},
+		},
+		{
+			// Fail to lookup reference with no tag for digest that doesn't exist
+			lookup: "ubuntu@" + digestFor(20).String(),
+			err:    dockerimages.ErrImageDoesNotExist{Ref: nameDigest("ubuntu", digestFor(20))},
+		},
+		{
+			// Fail to lookup reference with nonexistent tag
+			lookup: "ubuntu:nonexistent",
+			err:    dockerimages.ErrImageDoesNotExist{Ref: nameTag("ubuntu", "nonexistent")},
+		},
+		{
+			// Get abcdef image which also matches short image id
+			lookup: "abcdef",
+			img:    &ambiguousShortName,
+			all:    []images.Image{ambiguousShortName, ambiguousShortNameWithDigest},
+		},
+		{
+			// Fail to lookup image named "sha256" with tag that doesn't exist
+			lookup: "sha256:abcdef",
+			err:    dockerimages.ErrImageDoesNotExist{Ref: nameTag("sha256", "abcdef")},
+		},
+		{
+			// Lookup with shortened image id
+			lookup: ambiguousShortName.Target.Digest.Encoded()[:8],
+			img:    nil,
+			all:    []images.Image{ambiguousShortName, ambiguousShortNameWithDigest},
+		},
+		{
+			// Lookup an actual image named "sha256" in the default namespace
+			lookup: "sha256:defcab",
+			img:    &shortNameIsHashAlgorithm,
+			all:    []images.Image{shortNameIsHashAlgorithm},
+		},
+	} {
+		tc := tc
+		t.Run(tc.lookup, func(t *testing.T) {
+			t.Parallel()
+			img, all, err := service.resolveAllReferences(ctx, tc.lookup)
+			if tc.err == nil {
+				assert.NilError(t, err)
+			} else {
+				assert.Error(t, err, tc.err.Error())
+			}
+			if tc.img == nil {
+				assert.Assert(t, is.Nil(img))
+			} else {
+				assert.Assert(t, img != nil)
+				assert.Check(t, is.Equal(img.Name, tc.img.Name))
+				assert.Check(t, is.Equal(img.Target.Digest, tc.img.Target.Digest))
+			}
+
+			assert.Assert(t, is.Len(tc.all, len(all)))
+
+			// Order should match
+			for i := range all {
+				assert.Check(t, is.Equal(all[i].Name, tc.all[i].Name), "image[%d]", i)
+				assert.Check(t, is.Equal(all[i].Target.Digest, tc.all[i].Target.Digest), "image[%d]", i)
+			}
+		})
+	}
+
+	t.Run("fail-inconsistency", func(t *testing.T) {
+		service := &ImageService{
+			images: &mutateOnGetImageStore{
+				Store: service.images,
+				getMutations: []images.Image{
+					{
+						Name:   "docker.io/test/volatile:inconsistent",
+						Target: desc(18),
+					},
+					{
+						Name:   "docker.io/test/volatile:inconsistent",
+						Target: desc(19),
+					},
+					{
+						Name:   "docker.io/test/volatile:inconsistent",
+						Target: desc(20),
+					},
+					{
+						Name:   "docker.io/test/volatile:inconsistent",
+						Target: desc(21),
+					},
+					{
+						Name:   "docker.io/test/volatile:inconsistent",
+						Target: desc(22),
+					},
+				},
+				t: t,
+			},
+		}
+
+		_, _, err := service.resolveAllReferences(ctx, "test/volatile:inconsistent")
+		assert.ErrorIs(t, err, errInconsistentData)
+	})
+
+	t.Run("retry-inconsistency", func(t *testing.T) {
+		service := &ImageService{
+			images: &mutateOnGetImageStore{
+				Store: service.images,
+				getMutations: []images.Image{
+					{
+						Name:   "docker.io/test/volatile:retried",
+						Target: desc(16),
+					},
+					{
+						Name:   "docker.io/test/volatile:retried",
+						Target: desc(17),
+					},
+				},
+				t: t,
+			},
+		}
+
+		img, all, err := service.resolveAllReferences(ctx, "test/volatile:retried")
+		assert.NilError(t, err)
+
+		assert.Assert(t, img != nil)
+		assert.Check(t, is.Equal(img.Name, "docker.io/test/volatile:retried"))
+		assert.Check(t, is.Equal(img.Target.Digest, digestFor(17)))
+		assert.Assert(t, is.Len(all, 1))
+		assert.Check(t, is.Equal(all[0].Name, "docker.io/test/volatile:retried"))
+		assert.Check(t, is.Equal(all[0].Target.Digest, digestFor(17)))
+	})
+}
+
+type mutateOnGetImageStore struct {
+	images.Store
+	getMutations []images.Image
+	t            *testing.T
+}
+
+func (m *mutateOnGetImageStore) Get(ctx context.Context, name string) (images.Image, error) {
+	img, err := m.Store.Get(ctx, name)
+	if len(m.getMutations) > 0 {
+		m.Store.Update(ctx, m.getMutations[0])
+		m.getMutations = m.getMutations[1:]
+		m.t.Logf("Get %s", name)
+	}
+	return img, err
+}
+
+func nameDigest(name string, dgst digest.Digest) reference.Reference {
+	named, _ := reference.WithName(name)
+	digested, _ := reference.WithDigest(named, dgst)
+	return digested
+}
+
+func nameTag(name, tag string) reference.Reference {
+	named, _ := reference.WithName(name)
+	tagged, _ := reference.WithTag(named, tag)
+	return tagged
+}
+
+func desc(size int64) ocispec.Descriptor {
+	return ocispec.Descriptor{
+		Digest:    digestFor(size),
+		Size:      size,
+		MediaType: ocispec.MediaTypeImageIndex,
+	}
+
+}
+
+func digestFor(i int64) digest.Digest {
+	r := rand.New(rand.NewSource(i))
+	dgstr := digest.SHA256.Digester()
+	_, err := io.Copy(dgstr.Hash(), io.LimitReader(r, i))
+	if err != nil {
+		panic(err)
+	}
+	return dgstr.Digest()
+}
+
+func newTestDB(ctx context.Context, t *testing.T) *metadata.DB {
+	t.Helper()
+
+	p := filepath.Join(t.TempDir(), "metadata")
+	bdb, err := bbolt.Open(p, 0600, &bbolt.Options{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { bdb.Close() })
+
+	mdb := metadata.NewDB(bdb, nil, nil)
+	if err := mdb.Init(ctx); err != nil {
+		t.Fatal(err)
+	}
+
+	return mdb
+}

+ 9 - 3
daemon/containerd/service.go

@@ -6,7 +6,9 @@ import (
 	"sync/atomic"
 
 	"github.com/containerd/containerd"
+	"github.com/containerd/containerd/content"
 	cerrdefs "github.com/containerd/containerd/errdefs"
+	"github.com/containerd/containerd/images"
 	"github.com/containerd/containerd/plugin"
 	"github.com/containerd/containerd/remotes/docker"
 	"github.com/containerd/containerd/snapshots"
@@ -14,7 +16,7 @@ import (
 	"github.com/distribution/reference"
 	"github.com/docker/docker/container"
 	daemonevents "github.com/docker/docker/daemon/events"
-	"github.com/docker/docker/daemon/images"
+	dimages "github.com/docker/docker/daemon/images"
 	"github.com/docker/docker/daemon/snapshotter"
 	"github.com/docker/docker/errdefs"
 	"github.com/docker/docker/image"
@@ -27,6 +29,8 @@ import (
 // ImageService implements daemon.ImageService
 type ImageService struct {
 	client          *containerd.Client
+	images          images.Store
+	content         content.Store
 	containers      container.Store
 	snapshotter     string
 	registryHosts   docker.RegistryHosts
@@ -59,6 +63,8 @@ type ImageServiceConfig struct {
 func NewService(config ImageServiceConfig) *ImageService {
 	return &ImageService{
 		client:          config.Client,
+		images:          config.Client.ImageService(),
+		content:         config.Client.ContentStore(),
 		containers:      config.Containers,
 		snapshotter:     config.Snapshotter,
 		registryHosts:   config.RegistryHosts,
@@ -70,8 +76,8 @@ func NewService(config ImageServiceConfig) *ImageService {
 }
 
 // DistributionServices return services controlling daemon image storage.
-func (i *ImageService) DistributionServices() images.DistributionServices {
-	return images.DistributionServices{}
+func (i *ImageService) DistributionServices() dimages.DistributionServices {
+	return dimages.DistributionServices{}
 }
 
 // CountImages returns the number of images stored by ImageService

+ 6 - 14
daemon/containerd/soft_delete.go

@@ -17,23 +17,13 @@ const imageNameDanglingPrefix = "moby-dangling@"
 // softImageDelete deletes the image, making sure that there are other images
 // that reference the content of the deleted image.
 // If no other image exists, a dangling one is created.
-func (i *ImageService) softImageDelete(ctx context.Context, img containerdimages.Image) error {
-	is := i.client.ImageService()
-
-	// If the image already exists, persist it as dangling image
-	// but only if no other image has the same target.
-	dgst := img.Target.Digest.String()
-	imgs, err := is.List(ctx, "target.digest=="+dgst)
-	if err != nil {
-		return errdefs.System(errors.Wrapf(err, "failed to check if there are images targeting digest %s", dgst))
-	}
-
+func (i *ImageService) softImageDelete(ctx context.Context, img containerdimages.Image, imgs []containerdimages.Image) error {
 	// From this point explicitly ignore the passed context
 	// and don't allow to interrupt operation in the middle.
 
 	// Create dangling image if this is the last image pointing to this target.
 	if len(imgs) == 1 {
-		err = i.ensureDanglingImage(compatcontext.WithoutCancel(ctx), img)
+		err := i.ensureDanglingImage(compatcontext.WithoutCancel(ctx), img)
 
 		// Error out in case we couldn't persist the old image.
 		if err != nil {
@@ -43,7 +33,8 @@ func (i *ImageService) softImageDelete(ctx context.Context, img containerdimages
 	}
 
 	// Free the target name.
-	err = is.Delete(compatcontext.WithoutCancel(ctx), img.Name)
+	// TODO: Add with target option
+	err := i.images.Delete(compatcontext.WithoutCancel(ctx), img.Name)
 	if err != nil {
 		if !cerrdefs.IsNotFound(err) {
 			return errdefs.System(errors.Wrapf(err, "failed to delete image %s which existed a moment before", img.Name))
@@ -67,7 +58,7 @@ func (i *ImageService) ensureDanglingImage(ctx context.Context, from containerdi
 	}
 	danglingImage.Name = danglingImageName(from.Target.Digest)
 
-	_, err := i.client.ImageService().Create(compatcontext.WithoutCancel(ctx), danglingImage)
+	_, err := i.images.Create(compatcontext.WithoutCancel(ctx), danglingImage)
 	// If it already exists, then just continue.
 	if cerrdefs.IsAlreadyExists(err) {
 		return nil
@@ -81,5 +72,6 @@ func danglingImageName(digest digest.Digest) string {
 }
 
 func isDanglingImage(image containerdimages.Image) bool {
+	// TODO: Also check for expired
 	return image.Name == danglingImageName(image.Target.Digest)
 }

+ 1 - 1
integration-cli/docker_cli_rmi_test.go

@@ -295,7 +295,7 @@ RUN echo 2 #layer2
 	// should not be untagged without the -f flag
 	assert.ErrorContains(c, err, "")
 	assert.Assert(c, strings.Contains(out, cID[:12]))
-	assert.Assert(c, strings.Contains(out, "(must force)"))
+	assert.Assert(c, strings.Contains(out, "(must force)") || strings.Contains(out, "(must be forced)"))
 	// Add the -f flag and test again.
 	out = cli.DockerCmd(c, "rmi", "-f", newTag).Combined()
 	// should be allowed to untag with the -f flag

+ 56 - 0
vendor/github.com/containerd/log/logtest/context.go

@@ -0,0 +1,56 @@
+/*
+   Copyright The containerd 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 logtest
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"path/filepath"
+	"runtime"
+	"testing"
+
+	"github.com/containerd/log"
+	"github.com/sirupsen/logrus"
+)
+
+// WithT adds a logging hook for the given test
+// Changes debug level to debug, clears output, and
+// outputs all log messages as test logs.
+func WithT(ctx context.Context, t testing.TB) context.Context {
+	// Create a new logger to avoid adding hooks from multiple tests
+	l := logrus.New()
+
+	// Increase debug level for tests
+	l.SetLevel(logrus.DebugLevel)
+	l.SetOutput(io.Discard)
+	l.SetReportCaller(true)
+
+	// Add testing hook
+	l.AddHook(&testHook{
+		t: t,
+		fmt: &logrus.TextFormatter{
+			DisableColors:   true,
+			TimestampFormat: log.RFC3339NanoFixed,
+			CallerPrettyfier: func(frame *runtime.Frame) (string, string) {
+				return filepath.Base(frame.Function), fmt.Sprintf("%s:%d", frame.File, frame.Line)
+			},
+		},
+	})
+
+	return log.WithLogger(ctx, logrus.NewEntry(l).WithField("testcase", t.Name()))
+}

+ 50 - 0
vendor/github.com/containerd/log/logtest/log_hook.go

@@ -0,0 +1,50 @@
+/*
+   Copyright The containerd 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 logtest
+
+import (
+	"bytes"
+	"sync"
+	"testing"
+
+	"github.com/sirupsen/logrus"
+)
+
+type testHook struct {
+	t   testing.TB
+	fmt logrus.Formatter
+	mu  sync.Mutex
+}
+
+func (*testHook) Levels() []logrus.Level {
+	return logrus.AllLevels
+}
+
+func (h *testHook) Fire(e *logrus.Entry) error {
+	s, err := h.fmt.Format(e)
+	if err != nil {
+		return err
+	}
+
+	// Because the logger could be called from multiple goroutines,
+	// but t.Log() is not designed for.
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	h.t.Log(string(bytes.TrimRight(s, "\n")))
+
+	return nil
+}

+ 1 - 0
vendor/modules.txt

@@ -359,6 +359,7 @@ github.com/containerd/go-runc
 # github.com/containerd/log v0.1.0
 ## explicit; go 1.20
 github.com/containerd/log
+github.com/containerd/log/logtest
 # github.com/containerd/nydus-snapshotter v0.8.2
 ## explicit; go 1.19
 github.com/containerd/nydus-snapshotter/pkg/converter