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>
| ... | ... |
@@ -162,3 +162,22 @@ func DecodePlatform(platformJSON string) (*ocispec.Platform, error) {
|
| 162 | 162 |
|
| 163 | 163 |
return &p, nil |
| 164 | 164 |
} |
| 165 |
+ |
|
| 166 |
+// DecodePlatforms decodes the OCI platform JSON string into a Platform struct. |
|
| 167 |
+// |
|
| 168 |
+// Typically, the argument is a value of: r.Form["platform"] |
|
| 169 |
+func DecodePlatforms(platformJSONs []string) ([]ocispec.Platform, error) {
|
|
| 170 |
+ if len(platformJSONs) == 0 {
|
|
| 171 |
+ return nil, nil |
|
| 172 |
+ } |
|
| 173 |
+ |
|
| 174 |
+ var output []ocispec.Platform |
|
| 175 |
+ for _, platform := range platformJSONs {
|
|
| 176 |
+ p, err := DecodePlatform(platform) |
|
| 177 |
+ if err != nil {
|
|
| 178 |
+ return nil, err |
|
| 179 |
+ } |
|
| 180 |
+ output = append(output, *p) |
|
| 181 |
+ } |
|
| 182 |
+ return output, nil |
|
| 183 |
+} |
| ... | ... |
@@ -323,9 +323,19 @@ func (ir *imageRouter) deleteImages(ctx context.Context, w http.ResponseWriter, |
| 323 | 323 |
force := httputils.BoolValue(r, "force") |
| 324 | 324 |
prune := !httputils.BoolValue(r, "noprune") |
| 325 | 325 |
|
| 326 |
+ var platforms []ocispec.Platform |
|
| 327 |
+ if versions.GreaterThanOrEqualTo(httputils.VersionFromContext(ctx), "1.50") {
|
|
| 328 |
+ p, err := httputils.DecodePlatforms(r.Form["platforms"]) |
|
| 329 |
+ if err != nil {
|
|
| 330 |
+ return err |
|
| 331 |
+ } |
|
| 332 |
+ platforms = p |
|
| 333 |
+ } |
|
| 334 |
+ |
|
| 326 | 335 |
list, err := ir.backend.ImageDelete(ctx, name, imagetypes.RemoveOptions{
|
| 327 | 336 |
Force: force, |
| 328 | 337 |
PruneChildren: prune, |
| 338 |
+ Platforms: platforms, |
|
| 329 | 339 |
}) |
| 330 | 340 |
if err != nil {
|
| 331 | 341 |
return err |
| ... | ... |
@@ -9960,6 +9960,18 @@ paths: |
| 9960 | 9960 |
description: "Do not delete untagged parent images" |
| 9961 | 9961 |
type: "boolean" |
| 9962 | 9962 |
default: false |
| 9963 |
+ - name: "platforms" |
|
| 9964 |
+ in: "query" |
|
| 9965 |
+ description: | |
|
| 9966 |
+ Select platform-specific content to delete. |
|
| 9967 |
+ Multiple values are accepted. |
|
| 9968 |
+ Each platform is a OCI platform encoded as a JSON string. |
|
| 9969 |
+ type: "array" |
|
| 9970 |
+ items: |
|
| 9971 |
+ # This should be OCIPlatform |
|
| 9972 |
+ # but $ref is not supported for array in query in Swagger 2.0 |
|
| 9973 |
+ # $ref: "#/definitions/OCIPlatform" |
|
| 9974 |
+ type: "string" |
|
| 9963 | 9975 |
tags: ["Image"] |
| 9964 | 9976 |
/images/search: |
| 9965 | 9977 |
get: |
| ... | ... |
@@ -19,6 +19,14 @@ func (cli *Client) ImageRemove(ctx context.Context, imageID string, options imag |
| 19 | 19 |
query.Set("noprune", "1")
|
| 20 | 20 |
} |
| 21 | 21 |
|
| 22 |
+ if len(options.Platforms) > 0 {
|
|
| 23 |
+ p, err := encodePlatforms(options.Platforms...) |
|
| 24 |
+ if err != nil {
|
|
| 25 |
+ return nil, err |
|
| 26 |
+ } |
|
| 27 |
+ query["platforms"] = p |
|
| 28 |
+ } |
|
| 29 |
+ |
|
| 22 | 30 |
resp, err := cli.delete(ctx, "/images/"+imageID, query, nil) |
| 23 | 31 |
defer ensureReaderClosed(resp) |
| 24 | 32 |
if err != nil {
|
| ... | ... |
@@ -12,6 +12,7 @@ import ( |
| 12 | 12 |
|
| 13 | 13 |
cerrdefs "github.com/containerd/errdefs" |
| 14 | 14 |
"github.com/docker/docker/api/types/image" |
| 15 |
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
|
| 15 | 16 |
"gotest.tools/v3/assert" |
| 16 | 17 |
is "gotest.tools/v3/assert/cmp" |
| 17 | 18 |
) |
| ... | ... |
@@ -40,6 +41,7 @@ func TestImageRemove(t *testing.T) {
|
| 40 | 40 |
removeCases := []struct {
|
| 41 | 41 |
force bool |
| 42 | 42 |
pruneChildren bool |
| 43 |
+ platform *ocispec.Platform |
|
| 43 | 44 |
expectedQueryParams map[string]string |
| 44 | 45 |
}{
|
| 45 | 46 |
{
|
| ... | ... |
@@ -49,7 +51,8 @@ func TestImageRemove(t *testing.T) {
|
| 49 | 49 |
"force": "", |
| 50 | 50 |
"noprune": "1", |
| 51 | 51 |
}, |
| 52 |
- }, {
|
|
| 52 |
+ }, |
|
| 53 |
+ {
|
|
| 53 | 54 |
force: true, |
| 54 | 55 |
pruneChildren: true, |
| 55 | 56 |
expectedQueryParams: map[string]string{
|
| ... | ... |
@@ -57,6 +60,15 @@ func TestImageRemove(t *testing.T) {
|
| 57 | 57 |
"noprune": "", |
| 58 | 58 |
}, |
| 59 | 59 |
}, |
| 60 |
+ {
|
|
| 61 |
+ platform: &ocispec.Platform{
|
|
| 62 |
+ Architecture: "amd64", |
|
| 63 |
+ OS: "linux", |
|
| 64 |
+ }, |
|
| 65 |
+ expectedQueryParams: map[string]string{
|
|
| 66 |
+ "platforms": `{"architecture":"amd64","os":"linux"}`,
|
|
| 67 |
+ }, |
|
| 68 |
+ }, |
|
| 60 | 69 |
} |
| 61 | 70 |
for _, removeCase := range removeCases {
|
| 62 | 71 |
client := &Client{
|
| ... | ... |
@@ -92,10 +104,16 @@ func TestImageRemove(t *testing.T) {
|
| 92 | 92 |
}, nil |
| 93 | 93 |
}), |
| 94 | 94 |
} |
| 95 |
- imageDeletes, err := client.ImageRemove(context.Background(), "image_id", image.RemoveOptions{
|
|
| 95 |
+ |
|
| 96 |
+ opts := image.RemoveOptions{
|
|
| 96 | 97 |
Force: removeCase.force, |
| 97 | 98 |
PruneChildren: removeCase.pruneChildren, |
| 98 |
- }) |
|
| 99 |
+ } |
|
| 100 |
+ if removeCase.platform != nil {
|
|
| 101 |
+ opts.Platforms = []ocispec.Platform{*removeCase.platform}
|
|
| 102 |
+ } |
|
| 103 |
+ |
|
| 104 |
+ imageDeletes, err := client.ImageRemove(context.Background(), "image_id", opts) |
|
| 99 | 105 |
assert.NilError(t, err) |
| 100 | 106 |
assert.Check(t, is.Len(imageDeletes, 2)) |
| 101 | 107 |
} |
| ... | ... |
@@ -1,3 +1,6 @@ |
| 1 |
+// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: |
|
| 2 |
+//go:build go1.23 |
|
| 3 |
+ |
|
| 1 | 4 |
package containerd |
| 2 | 5 |
|
| 3 | 6 |
import ( |
| ... | ... |
@@ -9,6 +12,7 @@ import ( |
| 9 | 9 |
c8dimages "github.com/containerd/containerd/v2/core/images" |
| 10 | 10 |
cerrdefs "github.com/containerd/errdefs" |
| 11 | 11 |
"github.com/containerd/log" |
| 12 |
+ "github.com/containerd/platforms" |
|
| 12 | 13 |
"github.com/distribution/reference" |
| 13 | 14 |
"github.com/docker/docker/api/types/events" |
| 14 | 15 |
imagetypes "github.com/docker/docker/api/types/image" |
| ... | ... |
@@ -17,6 +21,7 @@ import ( |
| 17 | 17 |
"github.com/docker/docker/image" |
| 18 | 18 |
"github.com/docker/docker/internal/metrics" |
| 19 | 19 |
"github.com/docker/docker/pkg/stringid" |
| 20 |
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
|
| 20 | 21 |
) |
| 21 | 22 |
|
| 22 | 23 |
// ImageDelete deletes the image referenced by the given imageRef from this |
| ... | ... |
@@ -26,11 +31,11 @@ import ( |
| 26 | 26 |
// imageRef is a repository reference or not. |
| 27 | 27 |
// |
| 28 | 28 |
// If the given imageRef is a repository reference then that repository |
| 29 |
-// reference will be removed. However, if there exists any containers which |
|
| 29 |
+// reference is removed. However, if there exists any containers which |
|
| 30 | 30 |
// were created using the same image reference then the repository reference |
| 31 | 31 |
// cannot be removed unless either there are other repository references to the |
| 32 | 32 |
// same image or force is true. Following removal of the repository reference, |
| 33 |
-// the referenced image itself will attempt to be deleted as described below |
|
| 33 |
+// the referenced image itself is attempted to be deleted as described below |
|
| 34 | 34 |
// but quietly, meaning any image delete conflicts will cause the image to not |
| 35 | 35 |
// be deleted and the conflict will not be reported. |
| 36 | 36 |
// |
| ... | ... |
@@ -47,7 +52,7 @@ import ( |
| 47 | 47 |
// The image cannot be removed if there are any hard conflicts and can be |
| 48 | 48 |
// removed if there are soft conflicts only if force is true. |
| 49 | 49 |
// |
| 50 |
-// If prune is true, ancestor images will each attempt to be deleted quietly, |
|
| 50 |
+// If prune is true, ancestor images are attempted to be deleted quietly, |
|
| 51 | 51 |
// meaning any delete conflicts will cause the image to not be deleted and the |
| 52 | 52 |
// conflict will not be reported. |
| 53 | 53 |
// |
| ... | ... |
@@ -99,12 +104,18 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options |
| 99 | 99 |
c &= ^conflictActiveReference |
| 100 | 100 |
} |
| 101 | 101 |
if named != nil && len(sameRef) > 0 && len(sameRef) != len(all) {
|
| 102 |
+ if len(options.Platforms) > 0 {
|
|
| 103 |
+ return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms) |
|
| 104 |
+ } |
|
| 102 | 105 |
return i.untagReferences(ctx, sameRef) |
| 103 | 106 |
} |
| 104 | 107 |
} else {
|
| 105 | 108 |
imgID = image.ID(img.Target.Digest) |
| 106 | 109 |
explicitDanglingRef := strings.HasPrefix(imageRef, imageNameDanglingPrefix) && isDanglingImage(*img) |
| 107 | 110 |
if isImageIDPrefix(imgID.String(), imageRef) || explicitDanglingRef {
|
| 111 |
+ if len(options.Platforms) > 0 {
|
|
| 112 |
+ return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms) |
|
| 113 |
+ } |
|
| 108 | 114 |
return i.deleteAll(ctx, imgID, all, c, prune) |
| 109 | 115 |
} |
| 110 | 116 |
parsedRef, err := reference.ParseNormalizedNamed(img.Name) |
| ... | ... |
@@ -117,6 +128,9 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options |
| 117 | 117 |
return nil, err |
| 118 | 118 |
} |
| 119 | 119 |
if len(sameRef) != len(all) {
|
| 120 |
+ if len(options.Platforms) > 0 {
|
|
| 121 |
+ return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms) |
|
| 122 |
+ } |
|
| 120 | 123 |
return i.untagReferences(ctx, sameRef) |
| 121 | 124 |
} else if len(all) > 1 && !force {
|
| 122 | 125 |
// Since only a single used reference, remove all active |
| ... | ... |
@@ -127,7 +141,17 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options |
| 127 | 127 |
|
| 128 | 128 |
using := func(c *container.Container) bool {
|
| 129 | 129 |
if c.ImageID == imgID {
|
| 130 |
- return true |
|
| 130 |
+ if len(options.Platforms) == 0 {
|
|
| 131 |
+ return true |
|
| 132 |
+ } |
|
| 133 |
+ for _, p := range options.Platforms {
|
|
| 134 |
+ pm := platforms.OnlyStrict(p) |
|
| 135 |
+ if pm.Match(c.ImagePlatform) {
|
|
| 136 |
+ return true |
|
| 137 |
+ } |
|
| 138 |
+ } |
|
| 139 |
+ |
|
| 140 |
+ // No match for the image reference, but continue to check if used as mounted image |
|
| 131 | 141 |
} |
| 132 | 142 |
|
| 133 | 143 |
for _, mp := range c.MountPoints {
|
| ... | ... |
@@ -159,6 +183,10 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options |
| 159 | 159 |
return nil, err |
| 160 | 160 |
} |
| 161 | 161 |
|
| 162 |
+ if len(options.Platforms) > 0 {
|
|
| 163 |
+ return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms) |
|
| 164 |
+ } |
|
| 165 |
+ |
|
| 162 | 166 |
// Delete all images |
| 163 | 167 |
err := i.softImageDelete(ctx, *img, all) |
| 164 | 168 |
if err != nil {
|
| ... | ... |
@@ -171,9 +199,71 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options |
| 171 | 171 |
} |
| 172 | 172 |
} |
| 173 | 173 |
|
| 174 |
+ if len(options.Platforms) > 0 {
|
|
| 175 |
+ return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms) |
|
| 176 |
+ } |
|
| 174 | 177 |
return i.deleteAll(ctx, imgID, all, c, prune) |
| 175 | 178 |
} |
| 176 | 179 |
|
| 180 |
+// deleteImagePlatforms iterates over a slice of platforms and deletes each one for the given image. |
|
| 181 |
+func (i *ImageService) deleteImagePlatforms(ctx context.Context, img *c8dimages.Image, imgID image.ID, platformsToDel []ocispec.Platform) ([]imagetypes.DeleteResponse, error) {
|
|
| 182 |
+ var accumulatedResponses []imagetypes.DeleteResponse |
|
| 183 |
+ for _, p := range platformsToDel {
|
|
| 184 |
+ responses, err := i.deleteImagePlatformByImageID(ctx, img, imgID, &p) |
|
| 185 |
+ if err != nil {
|
|
| 186 |
+ return nil, fmt.Errorf("failed to delete platform %s for image %s: %w", platforms.Format(p), imgID.String(), err)
|
|
| 187 |
+ } |
|
| 188 |
+ accumulatedResponses = append(accumulatedResponses, responses...) |
|
| 189 |
+ } |
|
| 190 |
+ return accumulatedResponses, nil |
|
| 191 |
+} |
|
| 192 |
+ |
|
| 193 |
+func (i *ImageService) deleteImagePlatformByImageID(ctx context.Context, img *c8dimages.Image, imgID image.ID, platform *ocispec.Platform) ([]imagetypes.DeleteResponse, error) {
|
|
| 194 |
+ pm := platforms.OnlyStrict(*platform) |
|
| 195 |
+ var target ocispec.Descriptor |
|
| 196 |
+ if img == nil {
|
|
| 197 |
+ // Find any image with the same target |
|
| 198 |
+ // We're deleting by digest anyway so it doesn't matter - we just |
|
| 199 |
+ // need a c8d image object to pass to getBestPresentImageManifest |
|
| 200 |
+ i, err := i.resolveImage(ctx, imgID.String()) |
|
| 201 |
+ if err != nil {
|
|
| 202 |
+ return nil, err |
|
| 203 |
+ } |
|
| 204 |
+ img = &i |
|
| 205 |
+ } |
|
| 206 |
+ imgMfst, err := i.getBestPresentImageManifest(ctx, *img, pm) |
|
| 207 |
+ if err != nil {
|
|
| 208 |
+ return nil, err |
|
| 209 |
+ } |
|
| 210 |
+ target = imgMfst.Target() |
|
| 211 |
+ |
|
| 212 |
+ var toDelete []ocispec.Descriptor |
|
| 213 |
+ err = i.walkPresentChildren(ctx, target, func(ctx context.Context, d ocispec.Descriptor) error {
|
|
| 214 |
+ toDelete = append(toDelete, d) |
|
| 215 |
+ return nil |
|
| 216 |
+ }) |
|
| 217 |
+ if err != nil {
|
|
| 218 |
+ return nil, err |
|
| 219 |
+ } |
|
| 220 |
+ |
|
| 221 |
+ // TODO: Check if these are not used by other images with different |
|
| 222 |
+ // target root images. |
|
| 223 |
+ // The same manifest can be referenced by different image indexes. |
|
| 224 |
+ var response []imagetypes.DeleteResponse |
|
| 225 |
+ for _, d := range toDelete {
|
|
| 226 |
+ if err := i.content.Delete(ctx, d.Digest); err != nil {
|
|
| 227 |
+ if cerrdefs.IsNotFound(err) {
|
|
| 228 |
+ continue |
|
| 229 |
+ } |
|
| 230 |
+ return nil, err |
|
| 231 |
+ } |
|
| 232 |
+ if c8dimages.IsIndexType(d.MediaType) || c8dimages.IsManifestType(d.MediaType) {
|
|
| 233 |
+ response = append(response, imagetypes.DeleteResponse{Deleted: d.Digest.String()})
|
|
| 234 |
+ } |
|
| 235 |
+ } |
|
| 236 |
+ return response, nil |
|
| 237 |
+} |
|
| 238 |
+ |
|
| 177 | 239 |
// deleteAll deletes the image from the daemon, and if prune is true, |
| 178 | 240 |
// also deletes dangling parents if there is no conflict in doing so. |
| 179 | 241 |
// Parent images are removed quietly, and if there is any issue/conflict |
| ... | ... |
@@ -383,12 +473,6 @@ func (idc *imageDeleteConflict) Error() string {
|
| 383 | 383 |
|
| 384 | 384 |
func (*imageDeleteConflict) Conflict() {}
|
| 385 | 385 |
|
| 386 |
-// checkImageDeleteConflict returns a conflict representing |
|
| 387 |
-// any issue preventing deletion of the given image ID, and |
|
| 388 |
-// nil if there are none. It takes a bitmask representing a |
|
| 389 |
-// filter for which conflict types the caller cares about, |
|
| 390 |
-// and will only check for these conflict types. |
|
| 391 |
- |
|
| 392 | 386 |
// untagReferences deletes the given image references and returns the appropriate response records |
| 393 | 387 |
func (i *ImageService) untagReferences(ctx context.Context, refs []c8dimages.Image) ([]imagetypes.DeleteResponse, error) {
|
| 394 | 388 |
var records []imagetypes.DeleteResponse |
| ... | ... |
@@ -407,6 +491,11 @@ func (i *ImageService) untagReferences(ctx context.Context, refs []c8dimages.Ima |
| 407 | 407 |
return records, nil |
| 408 | 408 |
} |
| 409 | 409 |
|
| 410 |
+// checkImageDeleteConflict returns a conflict representing |
|
| 411 |
+// any issue preventing deletion of the given image ID, and |
|
| 412 |
+// nil if there are none. It takes a bitmask representing a |
|
| 413 |
+// filter for which conflict types the caller cares about, |
|
| 414 |
+// and will only check for these conflict types. |
|
| 410 | 415 |
func (i *ImageService) checkImageDeleteConflict(ctx context.Context, imgID image.ID, all []c8dimages.Image, mask conflictType) error {
|
| 411 | 416 |
if mask&conflictRunningContainer != 0 {
|
| 412 | 417 |
running := func(c *container.Container) bool {
|
| ... | ... |
@@ -15,6 +15,7 @@ import ( |
| 15 | 15 |
"github.com/docker/docker/image" |
| 16 | 16 |
"github.com/docker/docker/internal/metrics" |
| 17 | 17 |
"github.com/docker/docker/pkg/stringid" |
| 18 |
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
|
| 18 | 19 |
"github.com/pkg/errors" |
| 19 | 20 |
) |
| 20 | 21 |
|
| ... | ... |
@@ -39,7 +40,7 @@ const ( |
| 39 | 39 |
// reference will be removed. However, if there exists any containers which |
| 40 | 40 |
// were created using the same image reference then the repository reference |
| 41 | 41 |
// cannot be removed unless either there are other repository references to the |
| 42 |
-// same image or force is true. Following removal of the repository reference, |
|
| 42 |
+// same image or options.Force is true. Following removal of the repository reference, |
|
| 43 | 43 |
// the referenced image itself will attempt to be deleted as described below |
| 44 | 44 |
// but quietly, meaning any image delete conflicts will cause the image to not |
| 45 | 45 |
// be deleted and the conflict will not be reported. |
| ... | ... |
@@ -57,16 +58,25 @@ const ( |
| 57 | 57 |
// - any repository tag or digest references to the image. |
| 58 | 58 |
// |
| 59 | 59 |
// The image cannot be removed if there are any hard conflicts and can be |
| 60 |
-// removed if there are soft conflicts only if force is true. |
|
| 60 |
+// removed if there are soft conflicts only if options.Force is true. |
|
| 61 | 61 |
// |
| 62 |
-// If prune is true, ancestor images will each attempt to be deleted quietly, |
|
| 62 |
+// If options.PruneChildren is true, ancestor images are attempted to be deleted quietly, |
|
| 63 | 63 |
// meaning any delete conflicts will cause the image to not be deleted and the |
| 64 | 64 |
// conflict will not be reported. |
| 65 | 65 |
func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options imagetypes.RemoveOptions) ([]imagetypes.DeleteResponse, error) {
|
| 66 | 66 |
start := time.Now() |
| 67 | 67 |
records := []imagetypes.DeleteResponse{}
|
| 68 | 68 |
|
| 69 |
- img, err := i.GetImage(ctx, imageRef, backend.GetImageOpts{})
|
|
| 69 |
+ var platform *ocispec.Platform |
|
| 70 |
+ switch len(options.Platforms) {
|
|
| 71 |
+ case 0: |
|
| 72 |
+ case 1: |
|
| 73 |
+ platform = &options.Platforms[0] |
|
| 74 |
+ default: |
|
| 75 |
+ return nil, errdefs.InvalidParameter(errors.New("multiple platforms are not supported"))
|
|
| 76 |
+ } |
|
| 77 |
+ |
|
| 78 |
+ img, err := i.GetImage(ctx, imageRef, backend.GetImageOpts{Platform: platform})
|
|
| 70 | 79 |
if err != nil {
|
| 71 | 80 |
return nil, err |
| 72 | 81 |
} |
| ... | ... |
@@ -115,7 +115,6 @@ deleteImagesLoop: |
| 115 | 115 |
if shouldDelete {
|
| 116 | 116 |
for _, ref := range refs {
|
| 117 | 117 |
imgDel, err := i.ImageDelete(ctx, ref.String(), imagetypes.RemoveOptions{
|
| 118 |
- Force: false, |
|
| 119 | 118 |
PruneChildren: true, |
| 120 | 119 |
}) |
| 121 | 120 |
if imageDeleteFailed(ref.String(), err) {
|
| ... | ... |
@@ -127,7 +126,6 @@ deleteImagesLoop: |
| 127 | 127 |
} else {
|
| 128 | 128 |
hex := id.Digest().Encoded() |
| 129 | 129 |
imgDel, err := i.ImageDelete(ctx, hex, imagetypes.RemoveOptions{
|
| 130 |
- Force: false, |
|
| 131 | 130 |
PruneChildren: true, |
| 132 | 131 |
}) |
| 133 | 132 |
if imageDeleteFailed(hex, err) {
|
| ... | ... |
@@ -21,6 +21,9 @@ keywords: "API, Docker, rcli, REST, documentation" |
| 21 | 21 |
`DeviceInfo` objects, each providing details about a device discovered by a |
| 22 | 22 |
device driver. |
| 23 | 23 |
Currently only the CDI device driver is supported. |
| 24 |
+* `DELETE /images/{name}` now supports a `platforms` query parameter. It accepts
|
|
| 25 |
+ an array of JSON-encoded OCI Platform objects, allowing for selecting specific |
|
| 26 |
+ platforms to delete content for. |
|
| 24 | 27 |
* Deprecated: The `BridgeNfIptables` and `BridgeNfIp6tables` fields in the |
| 25 | 28 |
`GET /info` response were deprecated in API v1.48, and are now omitted |
| 26 | 29 |
in API v1.50. |
| ... | ... |
@@ -4,10 +4,14 @@ import ( |
| 4 | 4 |
"strings" |
| 5 | 5 |
"testing" |
| 6 | 6 |
|
| 7 |
+ "github.com/containerd/platforms" |
|
| 8 |
+ |
|
| 7 | 9 |
containertypes "github.com/docker/docker/api/types/container" |
| 8 | 10 |
"github.com/docker/docker/api/types/image" |
| 9 | 11 |
"github.com/docker/docker/errdefs" |
| 10 | 12 |
"github.com/docker/docker/integration/internal/container" |
| 13 |
+ "github.com/docker/docker/internal/testutils/specialimage" |
|
| 14 |
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
|
| 11 | 15 |
"gotest.tools/v3/assert" |
| 12 | 16 |
is "gotest.tools/v3/assert/cmp" |
| 13 | 17 |
"gotest.tools/v3/skip" |
| ... | ... |
@@ -91,3 +95,82 @@ func TestRemoveByDigest(t *testing.T) {
|
| 91 | 91 |
assert.Check(t, is.ErrorType(err, errdefs.IsNotFound)) |
| 92 | 92 |
assert.Check(t, is.DeepEqual(inspect, image.InspectResponse{}))
|
| 93 | 93 |
} |
| 94 |
+ |
|
| 95 |
+func TestRemoveWithPlatform(t *testing.T) {
|
|
| 96 |
+ skip.If(t, !testEnv.UsingSnapshotter()) |
|
| 97 |
+ |
|
| 98 |
+ ctx := setupTest(t) |
|
| 99 |
+ apiClient := testEnv.APIClient() |
|
| 100 |
+ |
|
| 101 |
+ imgName := strings.ToLower(t.Name()) + ":latest" |
|
| 102 |
+ |
|
| 103 |
+ platformHost := platforms.Normalize(ocispec.Platform{
|
|
| 104 |
+ Architecture: testEnv.DaemonInfo.Architecture, |
|
| 105 |
+ OS: testEnv.DaemonInfo.OSType, |
|
| 106 |
+ }) |
|
| 107 |
+ someOtherPlatform := platforms.Platform{
|
|
| 108 |
+ OS: "other", |
|
| 109 |
+ Architecture: "some", |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 112 |
+ var imageIdx *ocispec.Index |
|
| 113 |
+ var descs []ocispec.Descriptor |
|
| 114 |
+ specialimage.Load(ctx, t, apiClient, func(dir string) (*ocispec.Index, error) {
|
|
| 115 |
+ idx, d, err := specialimage.MultiPlatform(dir, imgName, []ocispec.Platform{
|
|
| 116 |
+ platformHost, |
|
| 117 |
+ {
|
|
| 118 |
+ OS: "linux", |
|
| 119 |
+ Architecture: "test", Variant: "1", |
|
| 120 |
+ }, |
|
| 121 |
+ {
|
|
| 122 |
+ OS: "linux", |
|
| 123 |
+ Architecture: "test", Variant: "2", |
|
| 124 |
+ }, |
|
| 125 |
+ someOtherPlatform, |
|
| 126 |
+ }) |
|
| 127 |
+ descs = d |
|
| 128 |
+ imageIdx = idx |
|
| 129 |
+ return idx, err |
|
| 130 |
+ }) |
|
| 131 |
+ _ = imageIdx |
|
| 132 |
+ |
|
| 133 |
+ for _, tc := range []struct {
|
|
| 134 |
+ platform *ocispec.Platform |
|
| 135 |
+ deleted ocispec.Descriptor |
|
| 136 |
+ }{
|
|
| 137 |
+ {&platformHost, descs[0]},
|
|
| 138 |
+ {&someOtherPlatform, descs[3]},
|
|
| 139 |
+ } {
|
|
| 140 |
+ resp, err := apiClient.ImageRemove(ctx, imgName, image.RemoveOptions{
|
|
| 141 |
+ Platforms: []ocispec.Platform{*tc.platform},
|
|
| 142 |
+ }) |
|
| 143 |
+ assert.NilError(t, err) |
|
| 144 |
+ assert.Check(t, is.Len(resp, 1)) |
|
| 145 |
+ for _, r := range resp {
|
|
| 146 |
+ assert.Check(t, is.Equal(r.Untagged, ""), "No image should be untagged") |
|
| 147 |
+ } |
|
| 148 |
+ checkPlatformDeleted(t, imageIdx, resp, tc.deleted) |
|
| 149 |
+ } |
|
| 150 |
+ |
|
| 151 |
+ // Delete the rest |
|
| 152 |
+ resp, err := apiClient.ImageRemove(ctx, imgName, image.RemoveOptions{})
|
|
| 153 |
+ assert.NilError(t, err) |
|
| 154 |
+ |
|
| 155 |
+ assert.Check(t, is.Len(resp, 2)) |
|
| 156 |
+ assert.Check(t, is.Equal(resp[0].Untagged, imgName)) |
|
| 157 |
+ assert.Check(t, is.Equal(resp[1].Deleted, imageIdx.Manifests[0].Digest.String())) |
|
| 158 |
+ // TODO: Should it also include platform-specific manifests? |
|
| 159 |
+} |
|
| 160 |
+ |
|
| 161 |
+func checkPlatformDeleted(t *testing.T, imageIdx *ocispec.Index, resp []image.DeleteResponse, mfstDesc ocispec.Descriptor) {
|
|
| 162 |
+ for _, r := range resp {
|
|
| 163 |
+ if r.Deleted != "" {
|
|
| 164 |
+ if assert.Check(t, is.Equal(r.Deleted, mfstDesc.Digest.String())) {
|
|
| 165 |
+ continue |
|
| 166 |
+ } |
|
| 167 |
+ if r.Deleted == imageIdx.Manifests[0].Digest.String() {
|
|
| 168 |
+ t.Log("Root image was deleted, expected only platform:", platforms.FormatAll(*mfstDesc.Platform))
|
|
| 169 |
+ } |
|
| 170 |
+ } |
|
| 171 |
+ } |
|
| 172 |
+} |