Skip to content

Commit 0b37530

Browse files
committed
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>
1 parent 96e39d2 commit 0b37530

File tree

10 files changed

+258
-5
lines changed

10 files changed

+258
-5
lines changed

api/server/httputils/form.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,22 @@ func DecodePlatform(platformJSON string) (*ocispec.Platform, error) {
162162

163163
return &p, nil
164164
}
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+
}

api/server/router/image/image_routes.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,9 +323,19 @@ func (ir *imageRouter) deleteImages(ctx context.Context, w http.ResponseWriter,
323323
force := httputils.BoolValue(r, "force")
324324
prune := !httputils.BoolValue(r, "noprune")
325325

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+
326335
list, err := ir.backend.ImageDelete(ctx, name, imagetypes.RemoveOptions{
327336
Force: force,
328337
PruneChildren: prune,
338+
Platforms: platforms,
329339
})
330340
if err != nil {
331341
return err

api/swagger.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9960,6 +9960,18 @@ paths:
99609960
description: "Do not delete untagged parent images"
99619961
type: "boolean"
99629962
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"
99639975
tags: ["Image"]
99649976
/images/search:
99659977
get:

api/types/image/opts.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ type ListOptions struct {
8383

8484
// RemoveOptions holds parameters to remove images.
8585
type RemoveOptions struct {
86+
Platforms []ocispec.Platform
8687
Force bool
8788
PruneChildren bool
8889
}

client/image_remove.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ func (cli *Client) ImageRemove(ctx context.Context, imageID string, options imag
1919
query.Set("noprune", "1")
2020
}
2121

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+
2230
resp, err := cli.delete(ctx, "/images/"+imageID, query, nil)
2331
defer ensureReaderClosed(resp)
2432
if err != nil {

client/image_remove_test.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/docker/docker/api/types/image"
1414
"github.com/docker/docker/errdefs"
15+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
1516
"gotest.tools/v3/assert"
1617
is "gotest.tools/v3/assert/cmp"
1718
)
@@ -40,6 +41,7 @@ func TestImageRemove(t *testing.T) {
4041
removeCases := []struct {
4142
force bool
4243
pruneChildren bool
44+
platform *ocispec.Platform
4345
expectedQueryParams map[string]string
4446
}{
4547
{
@@ -49,14 +51,24 @@ func TestImageRemove(t *testing.T) {
4951
"force": "",
5052
"noprune": "1",
5153
},
52-
}, {
54+
},
55+
{
5356
force: true,
5457
pruneChildren: true,
5558
expectedQueryParams: map[string]string{
5659
"force": "1",
5760
"noprune": "",
5861
},
5962
},
63+
{
64+
platform: &ocispec.Platform{
65+
Architecture: "amd64",
66+
OS: "linux",
67+
},
68+
expectedQueryParams: map[string]string{
69+
"platforms": `{"architecture":"amd64","os":"linux"}`,
70+
},
71+
},
6072
}
6173
for _, removeCase := range removeCases {
6274
client := &Client{
@@ -92,10 +104,16 @@ func TestImageRemove(t *testing.T) {
92104
}, nil
93105
}),
94106
}
95-
imageDeletes, err := client.ImageRemove(context.Background(), "image_id", image.RemoveOptions{
107+
108+
opts := image.RemoveOptions{
96109
Force: removeCase.force,
97110
PruneChildren: removeCase.pruneChildren,
98-
})
111+
}
112+
if removeCase.platform != nil {
113+
opts.Platforms = []ocispec.Platform{*removeCase.platform}
114+
}
115+
116+
imageDeletes, err := client.ImageRemove(context.Background(), "image_id", opts)
99117
assert.NilError(t, err)
100118
assert.Check(t, is.Len(imageDeletes, 2))
101119
}

daemon/containerd/image_delete.go

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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+
14
package containerd
25

36
import (
@@ -9,6 +12,7 @@ import (
912
c8dimages "github.com/containerd/containerd/v2/core/images"
1013
cerrdefs "github.com/containerd/errdefs"
1114
"github.com/containerd/log"
15+
"github.com/containerd/platforms"
1216
"github.com/distribution/reference"
1317
"github.com/docker/docker/api/types/events"
1418
imagetypes "github.com/docker/docker/api/types/image"
@@ -17,6 +21,7 @@ import (
1721
"github.com/docker/docker/image"
1822
"github.com/docker/docker/internal/metrics"
1923
"github.com/docker/docker/pkg/stringid"
24+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
2025
)
2126

2227
// ImageDelete deletes the image referenced by the given imageRef from this
@@ -99,12 +104,18 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options
99104
c &= ^conflictActiveReference
100105
}
101106
if named != nil && len(sameRef) > 0 && len(sameRef) != len(all) {
107+
if len(options.Platforms) > 0 {
108+
return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms)
109+
}
102110
return i.untagReferences(ctx, sameRef)
103111
}
104112
} else {
105113
imgID = image.ID(img.Target.Digest)
106114
explicitDanglingRef := strings.HasPrefix(imageRef, imageNameDanglingPrefix) && isDanglingImage(*img)
107115
if isImageIDPrefix(imgID.String(), imageRef) || explicitDanglingRef {
116+
if len(options.Platforms) > 0 {
117+
return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms)
118+
}
108119
return i.deleteAll(ctx, imgID, all, c, prune)
109120
}
110121
parsedRef, err := reference.ParseNormalizedNamed(img.Name)
@@ -117,6 +128,9 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options
117128
return nil, err
118129
}
119130
if len(sameRef) != len(all) {
131+
if len(options.Platforms) > 0 {
132+
return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms)
133+
}
120134
return i.untagReferences(ctx, sameRef)
121135
} else if len(all) > 1 && !force {
122136
// Since only a single used reference, remove all active
@@ -127,7 +141,16 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options
127141

128142
using := func(c *container.Container) bool {
129143
if c.ImageID == imgID {
130-
return true
144+
if len(options.Platforms) > 0 {
145+
for _, p := range options.Platforms {
146+
pm := platforms.OnlyStrict(p)
147+
if pm.Match(c.ImagePlatform) {
148+
return true
149+
}
150+
}
151+
} else {
152+
return true
153+
}
131154
}
132155

133156
for _, mp := range c.MountPoints {
@@ -159,6 +182,10 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options
159182
return nil, err
160183
}
161184

185+
if len(options.Platforms) > 0 {
186+
return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms)
187+
}
188+
162189
// Delete all images
163190
err := i.softImageDelete(ctx, *img, all)
164191
if err != nil {
@@ -171,9 +198,71 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options
171198
}
172199
}
173200

201+
if len(options.Platforms) > 0 {
202+
return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms)
203+
}
174204
return i.deleteAll(ctx, imgID, all, c, prune)
175205
}
176206

207+
// deleteImagePlatforms iterates over a slice of platforms and deletes each one for the given image.
208+
func (i *ImageService) deleteImagePlatforms(ctx context.Context, img *c8dimages.Image, imgID image.ID, platformsToDel []ocispec.Platform) ([]imagetypes.DeleteResponse, error) {
209+
var accumulatedResponses []imagetypes.DeleteResponse
210+
for _, p := range platformsToDel {
211+
responses, err := i.deleteImagePlatformByImageID(ctx, img, imgID, &p)
212+
if err != nil {
213+
return nil, fmt.Errorf("failed to delete platform %s for image %s: %w", platforms.Format(p), imgID.String(), err)
214+
}
215+
accumulatedResponses = append(accumulatedResponses, responses...)
216+
}
217+
return accumulatedResponses, nil
218+
}
219+
220+
func (i *ImageService) deleteImagePlatformByImageID(ctx context.Context, img *c8dimages.Image, imgID image.ID, platform *ocispec.Platform) ([]imagetypes.DeleteResponse, error) {
221+
pm := platforms.OnlyStrict(*platform)
222+
var target ocispec.Descriptor
223+
if img == nil {
224+
// Find any image with the same target
225+
// We're deleting by digest anyway so it doesn't matter - we just
226+
// need a c8d image object to pass to getBestPresentImageManifest
227+
i, err := i.resolveImage(ctx, imgID.String())
228+
if err != nil {
229+
return nil, err
230+
}
231+
img = &i
232+
}
233+
imgMfst, err := i.getBestPresentImageManifest(ctx, *img, pm)
234+
if err != nil {
235+
return nil, err
236+
}
237+
target = imgMfst.Target()
238+
239+
var toDelete []ocispec.Descriptor
240+
err = i.walkPresentChildren(ctx, target, func(ctx context.Context, d ocispec.Descriptor) error {
241+
toDelete = append(toDelete, d)
242+
return nil
243+
})
244+
if err != nil {
245+
return nil, err
246+
}
247+
248+
// TODO: Check if these are not used by other images with different
249+
// target root images.
250+
// The same manifest can be referenced by different image indexes.
251+
var response []imagetypes.DeleteResponse
252+
for _, d := range toDelete {
253+
if err := i.content.Delete(ctx, d.Digest); err != nil {
254+
if cerrdefs.IsNotFound(err) {
255+
continue
256+
}
257+
return nil, err
258+
}
259+
if c8dimages.IsIndexType(d.MediaType) || c8dimages.IsManifestType(d.MediaType) {
260+
response = append(response, imagetypes.DeleteResponse{Deleted: d.Digest.String()})
261+
}
262+
}
263+
return response, nil
264+
}
265+
177266
// deleteAll deletes the image from the daemon, and if prune is true,
178267
// also deletes dangling parents if there is no conflict in doing so.
179268
// Parent images are removed quietly, and if there is any issue/conflict

daemon/images/image_delete.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/docker/docker/image"
1616
"github.com/docker/docker/internal/metrics"
1717
"github.com/docker/docker/pkg/stringid"
18+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
1819
"github.com/pkg/errors"
1920
)
2021

@@ -66,7 +67,16 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options
6667
start := time.Now()
6768
records := []imagetypes.DeleteResponse{}
6869

69-
img, err := i.GetImage(ctx, imageRef, backend.GetImageOpts{})
70+
var platform *ocispec.Platform
71+
switch len(options.Platforms) {
72+
case 0:
73+
case 1:
74+
platform = &options.Platforms[0]
75+
default:
76+
return nil, errdefs.InvalidParameter(errors.New("multiple platforms are not supported"))
77+
}
78+
79+
img, err := i.GetImage(ctx, imageRef, backend.GetImageOpts{Platform: platform})
7080
if err != nil {
7181
return nil, err
7282
}

docs/api/version-history.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ keywords: "API, Docker, rcli, REST, documentation"
2121
`DeviceInfo` objects, each providing details about a device discovered by a
2222
device driver.
2323
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 a specific
26+
platforms to delete content for.
2427
* Deprecated: The `BridgeNfIptables` and `BridgeNfIp6tables` fields in the
2528
`GET /info` response were deprecated in API v1.48, and are now omitted
2629
in API v1.50.

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy