diff --git a/api/server/httputils/form.go b/api/server/httputils/form.go index 98d619454e..1f62c8df65 100644 --- a/api/server/httputils/form.go +++ b/api/server/httputils/form.go @@ -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 +} diff --git a/api/server/router/image/image_routes.go b/api/server/router/image/image_routes.go index dca6f70cd5..3a70758ef5 100644 --- a/api/server/router/image/image_routes.go +++ b/api/server/router/image/image_routes.go @@ -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 diff --git a/api/swagger.yaml b/api/swagger.yaml index 5a17aed5c0..a81c37827c 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -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: diff --git a/api/types/image/opts.go b/api/types/image/opts.go index 57800e0d47..fd038557c0 100644 --- a/api/types/image/opts.go +++ b/api/types/image/opts.go @@ -83,6 +83,7 @@ type ListOptions struct { // RemoveOptions holds parameters to remove images. type RemoveOptions struct { + Platforms []ocispec.Platform Force bool PruneChildren bool } diff --git a/client/image_remove.go b/client/image_remove.go index b0c87ca09c..0d769139b8 100644 --- a/client/image_remove.go +++ b/client/image_remove.go @@ -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 { diff --git a/client/image_remove_test.go b/client/image_remove_test.go index 9d9c01b5c2..83a8cf7dee 100644 --- a/client/image_remove_test.go +++ b/client/image_remove_test.go @@ -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)) } diff --git a/daemon/containerd/image_delete.go b/daemon/containerd/image_delete.go index afab19800f..6bf38ee37c 100644 --- a/daemon/containerd/image_delete.go +++ b/daemon/containerd/image_delete.go @@ -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 { diff --git a/daemon/images/image_delete.go b/daemon/images/image_delete.go index ca9f5b6411..d81dbe969b 100644 --- a/daemon/images/image_delete.go +++ b/daemon/images/image_delete.go @@ -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 } diff --git a/daemon/images/image_prune.go b/daemon/images/image_prune.go index 35970fcaa0..1d2ceb8ccd 100644 --- a/daemon/images/image_prune.go +++ b/daemon/images/image_prune.go @@ -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) { diff --git a/docs/api/version-history.md b/docs/api/version-history.md index 41dde8e175..07e3ad4019 100644 --- a/docs/api/version-history.md +++ b/docs/api/version-history.md @@ -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. diff --git a/integration/image/remove_test.go b/integration/image/remove_test.go index 99111bd67d..ea4bc34ca6 100644 --- a/integration/image/remove_test.go +++ b/integration/image/remove_test.go @@ -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)) + } + } + } +}