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/backend.go b/api/server/router/image/backend.go index 167d03af2c..c8919825c1 100644 --- a/api/server/router/image/backend.go +++ b/api/server/router/image/backend.go @@ -22,7 +22,7 @@ type Backend interface { } type imageBackend interface { - ImageDelete(ctx context.Context, imageRef string, force, prune bool) ([]image.DeleteResponse, error) + ImageDelete(ctx context.Context, imageRef string, options image.RemoveOptions) ([]image.DeleteResponse, error) ImageHistory(ctx context.Context, imageName string, platform *ocispec.Platform) ([]*image.HistoryResponseItem, error) Images(ctx context.Context, opts image.ListOptions) ([]*image.Summary, error) GetImage(ctx context.Context, refOrID string, options backend.GetImageOpts) (*dockerimage.Image, error) diff --git a/api/server/router/image/image_routes.go b/api/server/router/image/image_routes.go index 98cb7e5241..9d077657eb 100644 --- a/api/server/router/image/image_routes.go +++ b/api/server/router/image/image_routes.go @@ -326,7 +326,20 @@ func (ir *imageRouter) deleteImages(ctx context.Context, w http.ResponseWriter, force := httputils.BoolValue(r, "force") prune := !httputils.BoolValue(r, "noprune") - list, err := ir.backend.ImageDelete(ctx, name, force, prune) + 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 034d283ba0..619b4470eb 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -9818,6 +9818,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/fake_service_test.go b/daemon/containerd/fake_service_test.go index 8b200d1c5a..8bd5077eb9 100644 --- a/daemon/containerd/fake_service_test.go +++ b/daemon/containerd/fake_service_test.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "sync" "testing" "time" @@ -244,48 +243,3 @@ func (s *delayedStore) Update(ctx context.Context, info content.Info, fieldpaths s.delay() return s.store.Update(ctx, info, fieldpaths...) } - -type memoryLabelStore struct { - mu sync.Mutex - labels map[digest.Digest]map[string]string -} - -// Get returns all the labels for the given digest -func (s *memoryLabelStore) Get(dgst digest.Digest) (map[string]string, error) { - s.mu.Lock() - labels := s.labels[dgst] - s.mu.Unlock() - return labels, nil -} - -// Set sets all the labels for a given digest -func (s *memoryLabelStore) Set(dgst digest.Digest, labels map[string]string) error { - s.mu.Lock() - if s.labels == nil { - s.labels = make(map[digest.Digest]map[string]string) - } - s.labels[dgst] = labels - s.mu.Unlock() - return nil -} - -// Update replaces the given labels for a digest, -// a key with an empty value removes a label. -func (s *memoryLabelStore) Update(dgst digest.Digest, update map[string]string) (map[string]string, error) { - s.mu.Lock() - defer s.mu.Unlock() - - labels, ok := s.labels[dgst] - if !ok { - labels = map[string]string{} - } - for k, v := range update { - labels[k] = v - } - if s.labels == nil { - s.labels = map[digest.Digest]map[string]string{} - } - s.labels[dgst] = labels - - return labels, nil -} diff --git a/daemon/containerd/image_delete.go b/daemon/containerd/image_delete.go index 8308150eff..ef281020fb 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,12 +52,12 @@ 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. // // TODO(thaJeztah): image delete should send prometheus counters; see https://github.com/moby/moby/issues/45268 -func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, force, prune bool) (response []imagetypes.DeleteResponse, retErr error) { +func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, options imagetypes.RemoveOptions) (response []imagetypes.DeleteResponse, retErr error) { start := time.Now() defer func() { if retErr == nil { @@ -60,6 +65,13 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, force, } }() + if len(options.Platforms) > 0 && !options.Force { + return nil, cerrdefs.ErrInvalidArgument.WithMessage("Content will be removed from all images referencing this variant. Use —-force to force delete.") + } + + force := options.Force + prune := options.PruneChildren + var c conflictType if !force { c |= conflictSoft @@ -96,25 +108,18 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, force, c &= ^conflictActiveReference } if named != nil && len(sameRef) > 0 && len(sameRef) != len(all) { - var records []imagetypes.DeleteResponse - for _, ref := range sameRef { - // TODO: Add with target - err := i.images.Delete(ctx, ref.Name) - if err != nil { - return nil, err - } - if nn, err := reference.ParseNormalizedNamed(ref.Name); err == nil { - familiarRef := reference.FamiliarString(nn) - i.logImageEvent(ref, familiarRef, events.ActionUnTag) - records = append(records, imagetypes.DeleteResponse{Untagged: familiarRef}) - } + if len(options.Platforms) > 0 { + return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms) } - return records, nil + 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) @@ -127,20 +132,10 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, force, return nil, err } if len(sameRef) != len(all) { - var records []imagetypes.DeleteResponse - for _, ref := range sameRef { - // TODO: Add with target - err := i.images.Delete(ctx, ref.Name) - if err != nil { - return nil, err - } - if nn, err := reference.ParseNormalizedNamed(ref.Name); err == nil { - familiarRef := reference.FamiliarString(nn) - i.logImageEvent(ref, familiarRef, events.ActionUnTag) - records = append(records, imagetypes.DeleteResponse{Untagged: familiarRef}) - } + if len(options.Platforms) > 0 { + return i.deleteImagePlatforms(ctx, img, imgID, options.Platforms) } - return records, nil + return i.untagReferences(ctx, sameRef) } else if len(all) > 1 && !force { // Since only a single used reference, remove all active // TODO: Consider keeping the conflict and changing active @@ -150,7 +145,17 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, force, 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 { @@ -182,6 +187,10 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, force, 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 { @@ -194,9 +203,71 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, force, } } + 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 @@ -406,6 +477,24 @@ func (idc *imageDeleteConflict) Error() string { func (*imageDeleteConflict) Conflict() {} +// 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 + for _, ref := range refs { + // TODO: Add with target + err := i.images.Delete(ctx, ref.Name) + if err != nil { + return nil, err + } + if nn, err := reference.ParseNormalizedNamed(ref.Name); err == nil { + familiarRef := reference.FamiliarString(nn) + i.logImageEvent(ref, familiarRef, events.ActionUnTag) + records = append(records, imagetypes.DeleteResponse{Untagged: familiarRef}) + } + } + 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 diff --git a/daemon/containerd/image_delete_test.go b/daemon/containerd/image_delete_test.go index 7b830facb4..f07202b466 100644 --- a/daemon/containerd/image_delete_test.go +++ b/daemon/containerd/image_delete_test.go @@ -8,6 +8,7 @@ import ( "github.com/containerd/containerd/v2/core/metadata" "github.com/containerd/containerd/v2/pkg/namespaces" "github.com/containerd/log/logtest" + "github.com/docker/docker/api/types/image" "github.com/docker/docker/container" daemonevents "github.com/docker/docker/daemon/events" dimages "github.com/docker/docker/daemon/images" @@ -234,7 +235,7 @@ func TestImageDelete(t *testing.T) { } } - _, err := service.ImageDelete(ctx, tc.ref, false, false) + _, err := service.ImageDelete(ctx, tc.ref, image.RemoveOptions{}) if tc.err == nil { assert.NilError(t, err) } else { diff --git a/daemon/containerd/image_load_test.go b/daemon/containerd/image_load_test.go index b36d36e532..399c4c9a85 100644 --- a/daemon/containerd/image_load_test.go +++ b/daemon/containerd/image_load_test.go @@ -13,6 +13,7 @@ import ( "github.com/containerd/containerd/v2/plugins/content/local" "github.com/containerd/platforms" "github.com/docker/docker/errdefs" + "github.com/docker/docker/internal/testutils/labelstore" "github.com/docker/docker/internal/testutils/specialimage" "github.com/moby/go-archive" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -27,7 +28,7 @@ func TestImageLoadMissing(t *testing.T) { ctx := namespaces.WithNamespace(context.TODO(), "testing-"+t.Name()) - store, err := local.NewLabeledStore(t.TempDir(), &memoryLabelStore{}) + store, err := local.NewLabeledStore(t.TempDir(), &labelstore.InMemory{}) assert.NilError(t, err) imgSvc := fakeImageService(t, ctx, store) diff --git a/daemon/image_service.go b/daemon/image_service.go index e362df5e27..f4d572b6b0 100644 --- a/daemon/image_service.go +++ b/daemon/image_service.go @@ -29,7 +29,7 @@ type ImageService interface { PullImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error PushImage(ctx context.Context, ref reference.Named, platform *ocispec.Platform, metaHeaders map[string][]string, authConfig *registry.AuthConfig, outStream io.Writer) error CreateImage(ctx context.Context, config []byte, parent string, contentStoreDigest digest.Digest) (builder.Image, error) - ImageDelete(ctx context.Context, imageRef string, force, prune bool) ([]imagetype.DeleteResponse, error) + ImageDelete(ctx context.Context, imageRef string, options imagetype.RemoveOptions) ([]imagetype.DeleteResponse, error) ExportImage(ctx context.Context, names []string, platform *ocispec.Platform, outStream io.Writer) error LoadImage(ctx context.Context, inTar io.ReadCloser, platform *ocispec.Platform, outStream io.Writer, quiet bool) error Images(ctx context.Context, opts imagetype.ListOptions) ([]*imagetype.Summary, error) diff --git a/daemon/images/image_delete.go b/daemon/images/image_delete.go index b9ccd1c227..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, force, prune bool) ([]imagetypes.DeleteResponse, error) { +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 } @@ -90,6 +100,8 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, force, return false } + force := options.Force + prune := options.PruneChildren var removedRepositoryRef bool if !isImageIDPrefix(imgID.String(), imageRef) { // A repository reference was given and should be removed diff --git a/daemon/images/image_prune.go b/daemon/images/image_prune.go index 1dd47c7521..1d2ceb8ccd 100644 --- a/daemon/images/image_prune.go +++ b/daemon/images/image_prune.go @@ -114,7 +114,9 @@ deleteImagesLoop: if shouldDelete { for _, ref := range refs { - imgDel, err := i.ImageDelete(ctx, ref.String(), false, true) + imgDel, err := i.ImageDelete(ctx, ref.String(), imagetypes.RemoveOptions{ + PruneChildren: true, + }) if imageDeleteFailed(ref.String(), err) { continue } @@ -123,7 +125,9 @@ deleteImagesLoop: } } else { hex := id.Digest().Encoded() - imgDel, err := i.ImageDelete(ctx, hex, false, true) + imgDel, err := i.ImageDelete(ctx, hex, imagetypes.RemoveOptions{ + PruneChildren: true, + }) if imageDeleteFailed(hex, err) { continue } diff --git a/distribution/manifest_test.go b/distribution/manifest_test.go index cf64e2a65d..10d52e8574 100644 --- a/distribution/manifest_test.go +++ b/distribution/manifest_test.go @@ -3,7 +3,6 @@ package distribution import ( "context" "encoding/json" - "sync" "testing" "github.com/containerd/containerd/v2/core/content" @@ -15,6 +14,7 @@ import ( "github.com/docker/distribution/manifest/manifestlist" "github.com/docker/distribution/manifest/ocischema" "github.com/docker/distribution/manifest/schema2" + "github.com/docker/docker/internal/testutils/labelstore" "github.com/google/go-cmp/cmp/cmpopts" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -42,51 +42,6 @@ func (m *mockManifestGetter) Exists(ctx context.Context, dgst digest.Digest) (bo return ok, nil } -type memoryLabelStore struct { - mu sync.Mutex - labels map[digest.Digest]map[string]string -} - -// Get returns all the labels for the given digest -func (s *memoryLabelStore) Get(dgst digest.Digest) (map[string]string, error) { - s.mu.Lock() - labels := s.labels[dgst] - s.mu.Unlock() - return labels, nil -} - -// Set sets all the labels for a given digest -func (s *memoryLabelStore) Set(dgst digest.Digest, labels map[string]string) error { - s.mu.Lock() - if s.labels == nil { - s.labels = make(map[digest.Digest]map[string]string) - } - s.labels[dgst] = labels - s.mu.Unlock() - return nil -} - -// Update replaces the given labels for a digest, -// a key with an empty value removes a label. -func (s *memoryLabelStore) Update(dgst digest.Digest, update map[string]string) (map[string]string, error) { - s.mu.Lock() - defer s.mu.Unlock() - - labels, ok := s.labels[dgst] - if !ok { - labels = map[string]string{} - } - for k, v := range update { - labels[k] = v - } - if s.labels == nil { - s.labels = map[digest.Digest]map[string]string{} - } - s.labels[dgst] = labels - - return labels, nil -} - type testingContentStoreWrapper struct { ContentStore errorOnWriter error @@ -134,7 +89,7 @@ func TestManifestStore(t *testing.T) { t.Helper() root := t.TempDir() - cs, err := local.NewLabeledStore(root, &memoryLabelStore{}) + cs, err := local.NewLabeledStore(root, &labelstore.InMemory{}) assert.NilError(t, err) mg := &mockManifestGetter{manifests: make(map[digest.Digest]distribution.Manifest)} diff --git a/docs/api/version-history.md b/docs/api/version-history.md index 474ae0ef7f..2caf12e737 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..4fb63ccccf 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,83 @@ 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}, + Force: true, + }) + 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)) + } + } + } +} diff --git a/internal/testutils/labelstore/memory_label_store.go b/internal/testutils/labelstore/memory_label_store.go new file mode 100644 index 0000000000..979cc9f060 --- /dev/null +++ b/internal/testutils/labelstore/memory_label_store.go @@ -0,0 +1,52 @@ +package labelstore + +import ( + "sync" + + "github.com/opencontainers/go-digest" +) + +type InMemory struct { + mu sync.Mutex + labels map[digest.Digest]map[string]string +} + +// Get returns all the labels for the given digest +func (s *InMemory) Get(dgst digest.Digest) (map[string]string, error) { + s.mu.Lock() + labels := s.labels[dgst] + s.mu.Unlock() + return labels, nil +} + +// Set sets all the labels for a given digest +func (s *InMemory) Set(dgst digest.Digest, labels map[string]string) error { + s.mu.Lock() + if s.labels == nil { + s.labels = make(map[digest.Digest]map[string]string) + } + s.labels[dgst] = labels + s.mu.Unlock() + return nil +} + +// Update replaces the given labels for a digest, +// a key with an empty value removes a label. +func (s *InMemory) Update(dgst digest.Digest, update map[string]string) (map[string]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + labels, ok := s.labels[dgst] + if !ok { + labels = map[string]string{} + } + for k, v := range update { + labels[k] = v + } + if s.labels == nil { + s.labels = map[digest.Digest]map[string]string{} + } + s.labels[dgst] = labels + + return labels, nil +}