Skip to content

Commit

Permalink
c8d: Add ImageConvert
Browse files Browse the repository at this point in the history
Introduce a new `ImageConvert` operation to the image service (only
supported by the containerd image store) to convert multi-platform image
indexes.

The intent behind adding a new operation is to make the reduction of a
multi-platform image to a single-platform image explicit to the user and
not force operation like push to create a new image index with a
different image digest if user wants to operate only on a subset of the
original multi-platform image.

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
  • Loading branch information
vvoland committed Apr 4, 2024
1 parent 2113259 commit e9de94f
Show file tree
Hide file tree
Showing 10 changed files with 380 additions and 1 deletion.
1 change: 1 addition & 0 deletions api/server/router/image/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type imageBackend interface {
GetImage(ctx context.Context, refOrID string, options backend.GetImageOpts) (*dockerimage.Image, error)
TagImage(ctx context.Context, id dockerimage.ID, newRef reference.Named) error
ImagesPrune(ctx context.Context, pruneFilters filters.Args) (*types.ImagesPruneReport, error)
ImageConvert(ctx context.Context, src string, dst reference.NamedTagged, options image.ConvertOptions) error
}

type importExportBackend interface {
Expand Down
1 change: 1 addition & 0 deletions api/server/router/image/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func (ir *imageRouter) initRoutes() {
router.NewPostRoute("/images/{name:.*}/push", ir.postImagesPush),
router.NewPostRoute("/images/{name:.*}/tag", ir.postImagesTag),
router.NewPostRoute("/images/prune", ir.postImagesPrune),
router.NewPostRoute("/images/convert", ir.postImagesConvert),
// DELETE
router.NewDeleteRoute("/images/{name:.*}", ir.deleteImages),
}
Expand Down
33 changes: 33 additions & 0 deletions api/server/router/image/image_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,39 @@ func (ir *imageRouter) postImagesPrune(ctx context.Context, w http.ResponseWrite
return httputils.WriteJSON(w, http.StatusOK, pruneReport)
}

func (ir *imageRouter) postImagesConvert(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
if err := httputils.ParseForm(r); err != nil {
return err
}

src := r.Form.Get("from")
dst, err := reference.ParseNamed(r.Form.Get("to"))
if err != nil {
return errdefs.InvalidParameter(fmt.Errorf("invalid 'to' parameter: %w", err))
}
dstRef := dst.(reference.NamedTagged)

opts := imagetypes.ConvertOptions{
OnlyAvailablePlatforms: httputils.BoolValue(r, "only-available-platforms"),
NoAttestations: httputils.BoolValue(r, "no-attestations"),
}

for _, p := range r.Form["platforms"] {
sp, err := platforms.Parse(p)
if err != nil {
return errdefs.InvalidParameter(fmt.Errorf("invalid platform: %w", err))
}
opts.Platforms = append(opts.Platforms, sp)
}

if err := ir.backend.ImageConvert(ctx, src, dstRef, opts); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return err
}
w.WriteHeader(http.StatusCreated)
return nil
}

// validateRepoName validates the name of a repository.
func validateRepoName(name reference.Named) error {
familiarName := reference.FamiliarName(name)
Expand Down
12 changes: 11 additions & 1 deletion api/types/image/opts.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package image

import "github.com/docker/docker/api/types/filters"
import (
"github.com/docker/docker/api/types/filters"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)

// ImportOptions holds information to import images from the client host.
type ImportOptions struct {
Expand Down Expand Up @@ -55,3 +58,10 @@ type RemoveOptions struct {
Force bool
PruneChildren bool
}

// ConvertOptions holds parameters to convert images.
type ConvertOptions struct {
OnlyAvailablePlatforms bool
Platforms []ocispec.Platform
NoAttestations bool
}
48 changes: 48 additions & 0 deletions client/image_convert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package client // import "github.com/docker/docker/client"

import (
"context"
"net/http"
"net/url"

"github.com/containerd/containerd/platforms"
"github.com/distribution/reference"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/errdefs"
"github.com/pkg/errors"
)

// ImageList converts image.
func (cli *Client) ImageConvert(ctx context.Context, src string, dst reference.NamedTagged, options image.ConvertOptions) error {
query := url.Values{}

if options.OnlyAvailablePlatforms && len(options.Platforms) > 0 {
return errdefs.InvalidParameter(errors.New("specifying both explicit platform list and only-available-platforms is not allowed"))
}

if options.OnlyAvailablePlatforms {
query.Set("only-available-platforms", "1")
}

if len(options.Platforms) > 0 {
for _, p := range options.Platforms {
query.Add("platforms", platforms.Format(p))
}
}

if options.NoAttestations {
query.Set("no-attestations", "1")
}

query.Set("from", src)
query.Set("to", dst.String())

serverResp, err := cli.post(ctx, "/images/convert", query, nil, nil)
ensureReaderClosed(serverResp)

if serverResp.statusCode != http.StatusCreated {
return errdefs.NotFound(errors.Wrapf(err, "failed to convert image %v", src))
}

return err
}
2 changes: 2 additions & 0 deletions client/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net"
"net/http"

"github.com/distribution/reference"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/events"
Expand Down Expand Up @@ -103,6 +104,7 @@ type ImageAPIClient interface {
ImageSave(ctx context.Context, images []string) (io.ReadCloser, error)
ImageTag(ctx context.Context, image, ref string) error
ImagesPrune(ctx context.Context, pruneFilter filters.Args) (types.ImagesPruneReport, error)
ImageConvert(ctx context.Context, src string, dst reference.NamedTagged, options image.ConvertOptions) error
}

// NetworkAPIClient defines API client methods for the networks
Expand Down
167 changes: 167 additions & 0 deletions daemon/containerd/image_convert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package containerd

import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"time"

"github.com/containerd/containerd/content"
cerrdefs "github.com/containerd/containerd/errdefs"
"github.com/containerd/containerd/images"
"github.com/containerd/containerd/platforms"
"github.com/containerd/log"
"github.com/distribution/reference"
imagetypes "github.com/docker/docker/api/types/image"
"github.com/docker/docker/errdefs"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)

func (i *ImageService) ImageConvert(ctx context.Context, src string, dst reference.NamedTagged, opts imagetypes.ConvertOptions) error {
log.G(ctx).Debugf("converting: %+v", opts)

srcImg, err := i.resolveImage(ctx, src)
if err != nil {
return err
}

if _, err := i.images.Get(ctx, dst.String()); !cerrdefs.IsNotFound(err) {
err := i.images.Delete(ctx, dst.String(), images.SynchronousDelete())
if err != nil {
return errdefs.System(fmt.Errorf("failed to delete existing image: %w", err))
}
}

if opts.OnlyAvailablePlatforms && len(opts.Platforms) > 0 {
return errdefs.InvalidParameter(errors.New("specifying both explicit platform list and only-available-platforms is not allowed"))
}

srcMediaType := srcImg.Target.MediaType
if !images.IsIndexType(srcMediaType) {
return errdefs.InvalidParameter(errors.New("cannot convert non-index image"))
}

oldIndex, info, err := readIndex(ctx, i.content, srcImg.Target)
if err != nil {
return err
}

newImg := srcImg
newImg.Name = dst.String()

n, err := i.convertManifests(ctx, srcImg, opts)
if err != nil {
return err
}
if n != nil {
newIndex := oldIndex
newIndex.Manifests = n
target, err := storeJson(ctx, i.content, newIndex.MediaType, newIndex, info.Labels)
if err != nil {
return errdefs.System(fmt.Errorf("failed to write modified image target: %w", err))
}

newImg.Target = target
newImg.CreatedAt = time.Now()
newImg.UpdatedAt = newImg.CreatedAt
}

if _, err := i.images.Create(ctx, newImg); err != nil {
return errdefs.System(fmt.Errorf("failed to create image: %w", err))
}
return nil
}

func (i *ImageService) convertManifests(ctx context.Context, srcImg images.Image, opts imagetypes.ConvertOptions) ([]ocispec.Descriptor, error) {
changed := false
pm := platforms.All
if len(opts.Platforms) > 0 {
pm = platforms.Any(opts.Platforms...)
}

var newManifests []ocispec.Descriptor
walker := i.walkReachableImageManifests
if opts.OnlyAvailablePlatforms {
walker = i.walkImageManifests
changed = true
}

err := walker(ctx, srcImg, func(m *ImageManifest) error {
if opts.NoAttestations && m.IsAttestation() {
return nil
}

mtarget := m.Target()
mplatform, err := m.ImagePlatform(ctx)
if err != nil {
return err
}
if !pm.Match(mplatform) {
changed = true
log.G(ctx).WithFields(log.Fields{
"platform": mplatform,
"digest": mtarget.Digest,
}).Debugf("skipping manifest %s due to platform mismatch", mtarget.Digest)
return images.ErrSkipDesc
}

newManifests = append(newManifests, mtarget)

return nil
})
if err != nil {
return nil, err
}

if changed {
return newManifests, nil
}

return nil, nil
}

func diffObj(old, new any) map[string]any {
oldBytes, _ := json.Marshal(old)
newBytes, _ := json.Marshal(new)

oldMap := make(map[string]any)
newMap := make(map[string]any)

_ = json.Unmarshal(oldBytes, &newMap)
_ = json.Unmarshal(newBytes, &oldMap)

diff := map[string]any{}
for k, v := range oldMap {
if reflect.DeepEqual(newMap[k], v) {
diff[k] = v
}
}
for k, v := range newMap {
if reflect.DeepEqual(oldMap[k], v) {
diff[k] = v
}
}

return diff
}

func readIndex(ctx context.Context, store content.InfoReaderProvider, desc ocispec.Descriptor) (ocispec.Index, content.Info, error) {
info, err := store.Info(ctx, desc.Digest)
if err != nil {
return ocispec.Index{}, content.Info{}, err
}

p, err := content.ReadBlob(ctx, store, desc)
if err != nil {
return ocispec.Index{}, info, err
}

var idx ocispec.Index
if err := json.Unmarshal(p, &idx); err != nil {
return ocispec.Index{}, info, err
}

return idx, info, nil
}

0 comments on commit e9de94f

Please sign in to comment.