Browse code

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>

Paweł Gronowski authored on 2025/05/14 01:46:18
Showing 11 changed files
... ...
@@ -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:
... ...
@@ -83,6 +83,7 @@ type ListOptions struct {
83 83
 
84 84
 // RemoveOptions holds parameters to remove images.
85 85
 type RemoveOptions struct {
86
+	Platforms     []ocispec.Platform
86 87
 	Force         bool
87 88
 	PruneChildren bool
88 89
 }
... ...
@@ -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
+}