Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

c8d: Add ImageConvert #47678

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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, dsts []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
39 changes: 39 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,45 @@ 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")

var dstRefs []reference.NamedTagged
for _, t := range r.Form["to"] {
ref, err := reference.ParseNamed(t)
if err != nil {
return errdefs.InvalidParameter(fmt.Errorf("invalid 'to' parameter: %w", err))
}

tagged := reference.TagNameOnly(ref).(reference.NamedTagged)
dstRefs = append(dstRefs, tagged)
}

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, dstRefs, 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
8 changes: 8 additions & 0 deletions api/types/image/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"

"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.
Expand Down Expand Up @@ -59,3 +60,10 @@ type RemoveOptions struct {
Force bool
PruneChildren bool
}

// ConvertOptions holds parameters to convert images.
type ConvertOptions struct {
OnlyAvailablePlatforms bool
Platforms []ocispec.Platform
NoAttestations bool
}
50 changes: 50 additions & 0 deletions client/image_convert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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, dsts []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)
for _, dst := range dsts {
query.Add("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
184 changes: 184 additions & 0 deletions daemon/containerd/image_convert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package containerd

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

"github.com/containerd/containerd/content"
"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"
"github.com/moby/buildkit/util/attestation"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)

func (i *ImageService) ImageConvert(ctx context.Context, src string, dsts []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 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
newManifests, err := i.convertManifests(ctx, srcImg, opts)
if err != nil {
return err
}
if len(newManifests) == 0 {
return errdefs.InvalidParameter(errors.New("refusing to create an empty image"))
}

newImg.Target = newManifests[0]
if len(newManifests) > 1 {
newIndex := oldIndex
newIndex.Manifests = newManifests
t, 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 = t
}

newImg.CreatedAt = time.Now()
newImg.UpdatedAt = newImg.CreatedAt

for _, dst := range dsts {
newImg.Name = dst.String()

if err := i.forceCreateImage(ctx, newImg); err != nil {
return err
}
}
return nil
}

var errConvertNoop = errors.New("no conversion performed")

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
}

// Key: Manifest digest, Value: OCI descriptor of the attestation
manifestToAttestationDesc := map[string]ocispec.Descriptor{}

// Collect attestation descriptors
if !opts.NoAttestations {
err := walker(ctx, srcImg, func(im *ImageManifest) error {
if im.IsAttestation() {
desc := im.Target()
typ := desc.Annotations[attestation.DockerAnnotationReferenceType]
if typ != attestation.DockerAnnotationReferenceTypeDefault {
log.G(ctx).WithFields(log.Fields{
"digest": desc.Digest,
"type": typ,
}).Debug("skipping attestation manifest with unknown type")
return images.ErrSkipDesc
}

mfstDgst := im.Target().Annotations[attestation.DockerAnnotationReferenceDigest]
manifestToAttestationDesc[mfstDgst] = desc
}
return nil
})
if err != nil {
return nil, err
}
}

err := walker(ctx, srcImg, func(m *ImageManifest) error {
if m.IsAttestation() {
return images.ErrSkipDesc
} else {
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)
log.G(ctx).WithFields(log.Fields{
"platform": mplatform,
"digest": mtarget.Digest,
}).Debug("add platform-specific image manifest")

attestation, hasAttestation := manifestToAttestationDesc[mtarget.Digest.String()]
if hasAttestation {
newManifests = append(newManifests, attestation)
log.G(ctx).WithFields(log.Fields{
"platform": mplatform,
"manifest": mtarget.Digest,
"attestation": attestation.Digest,
}).Debug("add attestation")
}
}

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

if !changed {
return newManifests, errConvertNoop
}

return newManifests, nil
}

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
}