mirror of
https://github.com/moby/moby.git
synced 2026-01-11 10:41:43 +00:00
c8d/delete: Support deleting specific platforms
This change adds the ability to delete a specific platform from a multi-platform image. Previously, image deletion was an all-or-nothing operation - when deleting a multi-platform image, all platforms would be removed together. This change allows users to selectively remove individual platforms from a multi-architecture image while keeping other platforms intact. Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
This commit is contained in:
@@ -162,3 +162,22 @@ func DecodePlatform(platformJSON string) (*ocispec.Platform, error) {
|
||||
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// DecodePlatforms decodes the OCI platform JSON string into a Platform struct.
|
||||
//
|
||||
// Typically, the argument is a value of: r.Form["platform"]
|
||||
func DecodePlatforms(platformJSONs []string) ([]ocispec.Platform, error) {
|
||||
if len(platformJSONs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var output []ocispec.Platform
|
||||
for _, platform := range platformJSONs {
|
||||
p, err := DecodePlatform(platform)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output = append(output, *p)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
@@ -323,9 +323,19 @@ func (ir *imageRouter) deleteImages(ctx context.Context, w http.ResponseWriter,
|
||||
force := httputils.BoolValue(r, "force")
|
||||
prune := !httputils.BoolValue(r, "noprune")
|
||||
|
||||
var platforms []ocispec.Platform
|
||||
if versions.GreaterThanOrEqualTo(httputils.VersionFromContext(ctx), "1.50") {
|
||||
p, err := httputils.DecodePlatforms(r.Form["platforms"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
platforms = p
|
||||
}
|
||||
|
||||
list, err := ir.backend.ImageDelete(ctx, name, imagetypes.RemoveOptions{
|
||||
Force: force,
|
||||
PruneChildren: prune,
|
||||
Platforms: platforms,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -9960,6 +9960,18 @@ paths:
|
||||
description: "Do not delete untagged parent images"
|
||||
type: "boolean"
|
||||
default: false
|
||||
- name: "platforms"
|
||||
in: "query"
|
||||
description: |
|
||||
Select platform-specific content to delete.
|
||||
Multiple values are accepted.
|
||||
Each platform is a OCI platform encoded as a JSON string.
|
||||
type: "array"
|
||||
items:
|
||||
# This should be OCIPlatform
|
||||
# but $ref is not supported for array in query in Swagger 2.0
|
||||
# $ref: "#/definitions/OCIPlatform"
|
||||
type: "string"
|
||||
tags: ["Image"]
|
||||
/images/search:
|
||||
get:
|
||||
|
||||
@@ -83,6 +83,7 @@ type ListOptions struct {
|
||||
|
||||
// RemoveOptions holds parameters to remove images.
|
||||
type RemoveOptions struct {
|
||||
Platforms []ocispec.Platform
|
||||
Force bool
|
||||
PruneChildren bool
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ func (cli *Client) ImageRemove(ctx context.Context, imageID string, options imag
|
||||
query.Set("noprune", "1")
|
||||
}
|
||||
|
||||
if len(options.Platforms) > 0 {
|
||||
p, err := encodePlatforms(options.Platforms...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query["platforms"] = p
|
||||
}
|
||||
|
||||
resp, err := cli.delete(ctx, "/images/"+imageID, query, nil)
|
||||
defer ensureReaderClosed(resp)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"gotest.tools/v3/assert"
|
||||
is "gotest.tools/v3/assert/cmp"
|
||||
)
|
||||
@@ -40,6 +41,7 @@ func TestImageRemove(t *testing.T) {
|
||||
removeCases := []struct {
|
||||
force bool
|
||||
pruneChildren bool
|
||||
platform *ocispec.Platform
|
||||
expectedQueryParams map[string]string
|
||||
}{
|
||||
{
|
||||
@@ -49,7 +51,8 @@ func TestImageRemove(t *testing.T) {
|
||||
"force": "",
|
||||
"noprune": "1",
|
||||
},
|
||||
}, {
|
||||
},
|
||||
{
|
||||
force: true,
|
||||
pruneChildren: true,
|
||||
expectedQueryParams: map[string]string{
|
||||
@@ -57,6 +60,15 @@ func TestImageRemove(t *testing.T) {
|
||||
"noprune": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
platform: &ocispec.Platform{
|
||||
Architecture: "amd64",
|
||||
OS: "linux",
|
||||
},
|
||||
expectedQueryParams: map[string]string{
|
||||
"platforms": `{"architecture":"amd64","os":"linux"}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, removeCase := range removeCases {
|
||||
client := &Client{
|
||||
@@ -92,10 +104,16 @@ func TestImageRemove(t *testing.T) {
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
imageDeletes, err := client.ImageRemove(context.Background(), "image_id", image.RemoveOptions{
|
||||
|
||||
opts := image.RemoveOptions{
|
||||
Force: removeCase.force,
|
||||
PruneChildren: removeCase.pruneChildren,
|
||||
})
|
||||
}
|
||||
if removeCase.platform != nil {
|
||||
opts.Platforms = []ocispec.Platform{*removeCase.platform}
|
||||
}
|
||||
|
||||
imageDeletes, err := client.ImageRemove(context.Background(), "image_id", opts)
|
||||
assert.NilError(t, err)
|
||||
assert.Check(t, is.Len(imageDeletes, 2))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
|
||||
//go:build go1.23
|
||||
|
||||
package containerd
|
||||
|
||||
import (
|
||||
@@ -9,6 +12,7 @@ import (
|
||||
c8dimages "github.com/containerd/containerd/v2/core/images"
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/containerd/log"
|
||||
"github.com/containerd/platforms"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/docker/api/types/events"
|
||||
imagetypes "github.com/docker/docker/api/types/image"
|
||||
@@ -17,6 +21,7 @@ import (
|
||||
"github.com/docker/docker/image"
|
||||
"github.com/docker/docker/internal/metrics"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// ImageDelete deletes the image referenced by the given imageRef from this
|
||||
@@ -26,11 +31,11 @@ import (
|
||||
// imageRef is a repository reference or not.
|
||||
//
|
||||
// If the given imageRef is a repository reference then that repository
|
||||
// reference will be removed. However, if there exists any containers which
|
||||
// reference is removed. However, if there exists any containers which
|
||||
// were created using the same image reference then the repository reference
|
||||
// cannot be removed unless either there are other repository references to the
|
||||
// same image or force is true. Following removal of the repository reference,
|
||||
// the referenced image itself will attempt to be deleted as described below
|
||||
// the referenced image itself is attempted to be deleted as described below
|
||||
// but quietly, meaning any image delete conflicts will cause the image to not
|
||||
// be deleted and the conflict will not be reported.
|
||||
//
|
||||
@@ -47,7 +52,7 @@ import (
|
||||
// The image cannot be removed if there are any hard conflicts and can be
|
||||
// removed if there are soft conflicts only if force is true.
|
||||
//
|
||||
// If prune is true, ancestor images will each attempt to be deleted quietly,
|
||||
// If prune is true, ancestor images are attempted to be deleted quietly,
|
||||
// meaning any delete conflicts will cause the image to not be deleted and the
|
||||
// conflict will not be reported.
|
||||
//
|
||||
@@ -99,12 +104,18 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options
|
||||
c &= ^conflictActiveReference
|
||||
}
|
||||
if named != nil && len(sameRef) > 0 && len(sameRef) != len(all) {
|
||||
if len(options.Platforms) > 0 {
|
||||
return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms)
|
||||
}
|
||||
return i.untagReferences(ctx, sameRef)
|
||||
}
|
||||
} else {
|
||||
imgID = image.ID(img.Target.Digest)
|
||||
explicitDanglingRef := strings.HasPrefix(imageRef, imageNameDanglingPrefix) && isDanglingImage(*img)
|
||||
if isImageIDPrefix(imgID.String(), imageRef) || explicitDanglingRef {
|
||||
if len(options.Platforms) > 0 {
|
||||
return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms)
|
||||
}
|
||||
return i.deleteAll(ctx, imgID, all, c, prune)
|
||||
}
|
||||
parsedRef, err := reference.ParseNormalizedNamed(img.Name)
|
||||
@@ -117,6 +128,9 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options
|
||||
return nil, err
|
||||
}
|
||||
if len(sameRef) != len(all) {
|
||||
if len(options.Platforms) > 0 {
|
||||
return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms)
|
||||
}
|
||||
return i.untagReferences(ctx, sameRef)
|
||||
} else if len(all) > 1 && !force {
|
||||
// Since only a single used reference, remove all active
|
||||
@@ -127,7 +141,17 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options
|
||||
|
||||
using := func(c *container.Container) bool {
|
||||
if c.ImageID == imgID {
|
||||
return true
|
||||
if len(options.Platforms) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, p := range options.Platforms {
|
||||
pm := platforms.OnlyStrict(p)
|
||||
if pm.Match(c.ImagePlatform) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// No match for the image reference, but continue to check if used as mounted image
|
||||
}
|
||||
|
||||
for _, mp := range c.MountPoints {
|
||||
@@ -159,6 +183,10 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(options.Platforms) > 0 {
|
||||
return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms)
|
||||
}
|
||||
|
||||
// Delete all images
|
||||
err := i.softImageDelete(ctx, *img, all)
|
||||
if err != nil {
|
||||
@@ -171,9 +199,71 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options
|
||||
}
|
||||
}
|
||||
|
||||
if len(options.Platforms) > 0 {
|
||||
return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms)
|
||||
}
|
||||
return i.deleteAll(ctx, imgID, all, c, prune)
|
||||
}
|
||||
|
||||
// deleteImagePlatforms iterates over a slice of platforms and deletes each one for the given image.
|
||||
func (i *ImageService) deleteImagePlatforms(ctx context.Context, img *c8dimages.Image, imgID image.ID, platformsToDel []ocispec.Platform) ([]imagetypes.DeleteResponse, error) {
|
||||
var accumulatedResponses []imagetypes.DeleteResponse
|
||||
for _, p := range platformsToDel {
|
||||
responses, err := i.deleteImagePlatformByImageID(ctx, img, imgID, &p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to delete platform %s for image %s: %w", platforms.Format(p), imgID.String(), err)
|
||||
}
|
||||
accumulatedResponses = append(accumulatedResponses, responses...)
|
||||
}
|
||||
return accumulatedResponses, nil
|
||||
}
|
||||
|
||||
func (i *ImageService) deleteImagePlatformByImageID(ctx context.Context, img *c8dimages.Image, imgID image.ID, platform *ocispec.Platform) ([]imagetypes.DeleteResponse, error) {
|
||||
pm := platforms.OnlyStrict(*platform)
|
||||
var target ocispec.Descriptor
|
||||
if img == nil {
|
||||
// Find any image with the same target
|
||||
// We're deleting by digest anyway so it doesn't matter - we just
|
||||
// need a c8d image object to pass to getBestPresentImageManifest
|
||||
i, err := i.resolveImage(ctx, imgID.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img = &i
|
||||
}
|
||||
imgMfst, err := i.getBestPresentImageManifest(ctx, *img, pm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target = imgMfst.Target()
|
||||
|
||||
var toDelete []ocispec.Descriptor
|
||||
err = i.walkPresentChildren(ctx, target, func(ctx context.Context, d ocispec.Descriptor) error {
|
||||
toDelete = append(toDelete, d)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: Check if these are not used by other images with different
|
||||
// target root images.
|
||||
// The same manifest can be referenced by different image indexes.
|
||||
var response []imagetypes.DeleteResponse
|
||||
for _, d := range toDelete {
|
||||
if err := i.content.Delete(ctx, d.Digest); err != nil {
|
||||
if cerrdefs.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if c8dimages.IsIndexType(d.MediaType) || c8dimages.IsManifestType(d.MediaType) {
|
||||
response = append(response, imagetypes.DeleteResponse{Deleted: d.Digest.String()})
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -383,12 +473,6 @@ func (idc *imageDeleteConflict) Error() string {
|
||||
|
||||
func (*imageDeleteConflict) Conflict() {}
|
||||
|
||||
// checkImageDeleteConflict returns a conflict representing
|
||||
// any issue preventing deletion of the given image ID, and
|
||||
// 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.
|
||||
|
||||
// untagReferences deletes the given image references and returns the appropriate response records
|
||||
func (i *ImageService) untagReferences(ctx context.Context, refs []c8dimages.Image) ([]imagetypes.DeleteResponse, error) {
|
||||
var records []imagetypes.DeleteResponse
|
||||
@@ -407,6 +491,11 @@ func (i *ImageService) untagReferences(ctx context.Context, refs []c8dimages.Ima
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// checkImageDeleteConflict returns a conflict representing
|
||||
// any issue preventing deletion of the given image ID, and
|
||||
// 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, all []c8dimages.Image, mask conflictType) error {
|
||||
if mask&conflictRunningContainer != 0 {
|
||||
running := func(c *container.Container) bool {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/docker/docker/image"
|
||||
"github.com/docker/docker/internal/metrics"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@@ -39,7 +40,7 @@ const (
|
||||
// reference will be removed. However, if there exists any containers which
|
||||
// were created using the same image reference then the repository reference
|
||||
// cannot be removed unless either there are other repository references to the
|
||||
// same image or force is true. Following removal of the repository reference,
|
||||
// same image or options.Force is true. Following removal of the repository reference,
|
||||
// the referenced image itself will attempt to be deleted as described below
|
||||
// but quietly, meaning any image delete conflicts will cause the image to not
|
||||
// be deleted and the conflict will not be reported.
|
||||
@@ -57,16 +58,25 @@ const (
|
||||
// - any repository tag or digest references to the image.
|
||||
//
|
||||
// The image cannot be removed if there are any hard conflicts and can be
|
||||
// removed if there are soft conflicts only if force is true.
|
||||
// removed if there are soft conflicts only if options.Force is true.
|
||||
//
|
||||
// If prune is true, ancestor images will each attempt to be deleted quietly,
|
||||
// If options.PruneChildren is true, ancestor images are attempted to be deleted quietly,
|
||||
// meaning any delete conflicts will cause the image to not be deleted and the
|
||||
// conflict will not be reported.
|
||||
func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options imagetypes.RemoveOptions) ([]imagetypes.DeleteResponse, error) {
|
||||
start := time.Now()
|
||||
records := []imagetypes.DeleteResponse{}
|
||||
|
||||
img, err := i.GetImage(ctx, imageRef, backend.GetImageOpts{})
|
||||
var platform *ocispec.Platform
|
||||
switch len(options.Platforms) {
|
||||
case 0:
|
||||
case 1:
|
||||
platform = &options.Platforms[0]
|
||||
default:
|
||||
return nil, errdefs.InvalidParameter(errors.New("multiple platforms are not supported"))
|
||||
}
|
||||
|
||||
img, err := i.GetImage(ctx, imageRef, backend.GetImageOpts{Platform: platform})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -115,7 +115,6 @@ deleteImagesLoop:
|
||||
if shouldDelete {
|
||||
for _, ref := range refs {
|
||||
imgDel, err := i.ImageDelete(ctx, ref.String(), imagetypes.RemoveOptions{
|
||||
Force: false,
|
||||
PruneChildren: true,
|
||||
})
|
||||
if imageDeleteFailed(ref.String(), err) {
|
||||
@@ -127,7 +126,6 @@ deleteImagesLoop:
|
||||
} else {
|
||||
hex := id.Digest().Encoded()
|
||||
imgDel, err := i.ImageDelete(ctx, hex, imagetypes.RemoveOptions{
|
||||
Force: false,
|
||||
PruneChildren: true,
|
||||
})
|
||||
if imageDeleteFailed(hex, err) {
|
||||
|
||||
@@ -21,6 +21,9 @@ keywords: "API, Docker, rcli, REST, documentation"
|
||||
`DeviceInfo` objects, each providing details about a device discovered by a
|
||||
device driver.
|
||||
Currently only the CDI device driver is supported.
|
||||
* `DELETE /images/{name}` now supports a `platforms` query parameter. It accepts
|
||||
an array of JSON-encoded OCI Platform objects, allowing for selecting specific
|
||||
platforms to delete content for.
|
||||
* Deprecated: The `BridgeNfIptables` and `BridgeNfIp6tables` fields in the
|
||||
`GET /info` response were deprecated in API v1.48, and are now omitted
|
||||
in API v1.50.
|
||||
|
||||
@@ -4,10 +4,14 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/containerd/platforms"
|
||||
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/errdefs"
|
||||
"github.com/docker/docker/integration/internal/container"
|
||||
"github.com/docker/docker/internal/testutils/specialimage"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"gotest.tools/v3/assert"
|
||||
is "gotest.tools/v3/assert/cmp"
|
||||
"gotest.tools/v3/skip"
|
||||
@@ -91,3 +95,82 @@ func TestRemoveByDigest(t *testing.T) {
|
||||
assert.Check(t, is.ErrorType(err, errdefs.IsNotFound))
|
||||
assert.Check(t, is.DeepEqual(inspect, image.InspectResponse{}))
|
||||
}
|
||||
|
||||
func TestRemoveWithPlatform(t *testing.T) {
|
||||
skip.If(t, !testEnv.UsingSnapshotter())
|
||||
|
||||
ctx := setupTest(t)
|
||||
apiClient := testEnv.APIClient()
|
||||
|
||||
imgName := strings.ToLower(t.Name()) + ":latest"
|
||||
|
||||
platformHost := platforms.Normalize(ocispec.Platform{
|
||||
Architecture: testEnv.DaemonInfo.Architecture,
|
||||
OS: testEnv.DaemonInfo.OSType,
|
||||
})
|
||||
someOtherPlatform := platforms.Platform{
|
||||
OS: "other",
|
||||
Architecture: "some",
|
||||
}
|
||||
|
||||
var imageIdx *ocispec.Index
|
||||
var descs []ocispec.Descriptor
|
||||
specialimage.Load(ctx, t, apiClient, func(dir string) (*ocispec.Index, error) {
|
||||
idx, d, err := specialimage.MultiPlatform(dir, imgName, []ocispec.Platform{
|
||||
platformHost,
|
||||
{
|
||||
OS: "linux",
|
||||
Architecture: "test", Variant: "1",
|
||||
},
|
||||
{
|
||||
OS: "linux",
|
||||
Architecture: "test", Variant: "2",
|
||||
},
|
||||
someOtherPlatform,
|
||||
})
|
||||
descs = d
|
||||
imageIdx = idx
|
||||
return idx, err
|
||||
})
|
||||
_ = imageIdx
|
||||
|
||||
for _, tc := range []struct {
|
||||
platform *ocispec.Platform
|
||||
deleted ocispec.Descriptor
|
||||
}{
|
||||
{&platformHost, descs[0]},
|
||||
{&someOtherPlatform, descs[3]},
|
||||
} {
|
||||
resp, err := apiClient.ImageRemove(ctx, imgName, image.RemoveOptions{
|
||||
Platforms: []ocispec.Platform{*tc.platform},
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
assert.Check(t, is.Len(resp, 1))
|
||||
for _, r := range resp {
|
||||
assert.Check(t, is.Equal(r.Untagged, ""), "No image should be untagged")
|
||||
}
|
||||
checkPlatformDeleted(t, imageIdx, resp, tc.deleted)
|
||||
}
|
||||
|
||||
// Delete the rest
|
||||
resp, err := apiClient.ImageRemove(ctx, imgName, image.RemoveOptions{})
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Check(t, is.Len(resp, 2))
|
||||
assert.Check(t, is.Equal(resp[0].Untagged, imgName))
|
||||
assert.Check(t, is.Equal(resp[1].Deleted, imageIdx.Manifests[0].Digest.String()))
|
||||
// TODO: Should it also include platform-specific manifests?
|
||||
}
|
||||
|
||||
func checkPlatformDeleted(t *testing.T, imageIdx *ocispec.Index, resp []image.DeleteResponse, mfstDesc ocispec.Descriptor) {
|
||||
for _, r := range resp {
|
||||
if r.Deleted != "" {
|
||||
if assert.Check(t, is.Equal(r.Deleted, mfstDesc.Digest.String())) {
|
||||
continue
|
||||
}
|
||||
if r.Deleted == imageIdx.Manifests[0].Digest.String() {
|
||||
t.Log("Root image was deleted, expected only platform:", platforms.FormatAll(*mfstDesc.Platform))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user