diff --git a/cli/command/image/cmd.go b/cli/command/image/cmd.go index c035da17f0..ed52717a25 100644 --- a/cli/command/image/cmd.go +++ b/cli/command/image/cmd.go @@ -27,6 +27,7 @@ func NewImageCommand(dockerCli command.Cli) *cobra.Command { newRemoveCommand(dockerCli), newInspectCommand(dockerCli), NewPruneCommand(dockerCli), + NewConvertCommand(dockerCli), ) return cmd } diff --git a/cli/command/image/convert.go b/cli/command/image/convert.go new file mode 100644 index 0000000000..ca4677c566 --- /dev/null +++ b/cli/command/image/convert.go @@ -0,0 +1,199 @@ +package image + +import ( + "context" + "errors" + "fmt" + + "github.com/containerd/platforms" + "github.com/distribution/reference" + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/docker/api/types/filters" + imagetypes "github.com/docker/docker/api/types/image" + "github.com/morikuni/aec" + "github.com/opencontainers/image-spec/specs-go" + v1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/spf13/cobra" +) + +type convertArgs struct { + Src string + Dst []string + Platforms []string + NoAttestations bool + OnlyAvailable bool +} + +func NewConvertCommand(dockerCli command.Cli) *cobra.Command { + var args convertArgs + + cmd := &cobra.Command{ + Use: "convert [OPTIONS]", + Short: "Convert multi-platform images", + Args: cli.ExactArgs(0), + RunE: func(cmd *cobra.Command, _ []string) error { + return runConvert(cmd.Context(), dockerCli, args) + }, + Aliases: []string{"convert"}, + Annotations: map[string]string{ + "aliases": "docker image convert, docker convert", + }, + } + + flags := cmd.Flags() + flags.StringArrayVar(&args.Platforms, "platforms", nil, "Include only the specified platforms in the destination image") + flags.BoolVar(&args.NoAttestations, "no-attestations", false, "Do not include image attestations") + flags.BoolVar(&args.OnlyAvailable, "available", false, "Only include manifests which blobs are available locally") + flags.StringArrayVar(&args.Dst, "to", nil, "Target image references") + flags.StringVar(&args.Src, "from", "", "Source image reference") + + return cmd +} + +type convertFilter = func(mfst imagetypes.ImageManifestSummary) bool + +func runConvert(ctx context.Context, dockerCLI command.Cli, args convertArgs) error { + if len(args.Dst) == 0 { + return errors.New("no destination image specified") + } + if args.Src == "" { + return errors.New("no source image specified") + } + + matchesFilters, err := parseConvertFilters(args) + if err != nil { + return err + } + + list, err := dockerCLI.Client().ImageList(ctx, imagetypes.ListOptions{ + All: true, + Filters: filters.NewArgs(filters.Arg("reference", args.Src)), + }) + if err != nil { + return err + } + + if len(list) == 0 { + return fmt.Errorf("no such image: %s", args.Src) + } + + newManifests := make([]imagetypes.ImageManifestSummary, 0, len(list[0].Manifests)) + for _, mfst := range list[0].Manifests { + if !matchesFilters(mfst) { + continue + } + newManifests = append(newManifests, mfst) + } + + dstRefs := make([]reference.NamedTagged, 0, len(args.Dst)) + for _, dst := range args.Dst { + dstRef, err := reference.ParseNormalizedNamed(dst) + if err != nil { + return fmt.Errorf("invalid destination image reference: %s: %w", dst, err) + } + + dstRef = reference.TagNameOnly(dstRef) + dstRefTagged := dstRef.(reference.NamedTagged) + + dstRefs = append(dstRefs, dstRefTagged) + } + + newIndex := createIndex(newManifests) + + desc, err := dockerCLI.Client().ImageCreateFromOCIIndex(ctx, dstRefs[0], newIndex) + if err != nil { + return err + } + + fmt.Println(aec.Bold.Apply("New image digest:"), desc.Digest.String()) + for idx, dst := range dstRefs { + ref := reference.FamiliarString(dst) + if idx > 0 { + err := dockerCLI.Client().ImageTag(ctx, dstRefs[0].String(), dst.String()) + if err != nil { + fmt.Print(aec.LightRedF.Apply(" ✗ "), ref+" - "+aec.LightRedF.Apply(" tag failed: "+err.Error())) + continue + } + } + + fmt.Println(aec.LightGreenF.Apply(" ✓ "), ref) + } + + return nil +} + +func createIndex(manifests []imagetypes.ImageManifestSummary) v1.Index { + idx := v1.Index{ + Versioned: specs.Versioned{ + SchemaVersion: 2, + }, + MediaType: v1.MediaTypeImageIndex, + } + + for _, mfst := range manifests { + idx.Manifests = append(idx.Manifests, mfst.Descriptor) + } + return idx +} + +func parseConvertFilters(args convertArgs) (convertFilter, error) { + var flts []convertFilter + + // args.Platforms + if len(args.Platforms) > 0 { + f, err := filterPlatforms(args.Platforms) + if err != nil { + return nil, err + } + flts = append(flts, f) + } + + // args.NoAttestations + if args.NoAttestations { + flts = append(flts, func(mfst imagetypes.ImageManifestSummary) bool { + return mfst.Kind != imagetypes.ImageManifestKindAttestation + }) + } + + // args.OnlyAvailablePlatforms + if args.OnlyAvailable { + flts = append(flts, func(mfst imagetypes.ImageManifestSummary) bool { + return mfst.Available + }) + } + + matchesFilters := func(mfst imagetypes.ImageManifestSummary) bool { + for _, f := range flts { + if !f(mfst) { + return false + } + } + return true + } + return matchesFilters, nil +} + +func filterPlatforms(platformStrs []string) (convertFilter, error) { + p := make([]v1.Platform, 0, len(platformStrs)) + for _, platform := range platformStrs { + pl, err := platforms.Parse(platform) + if err != nil { + return nil, err + } + p = append(p, pl) + } + pm := platforms.Any(p...) + + return func(mfst imagetypes.ImageManifestSummary) bool { + if mfst.Descriptor.Platform != nil { + return pm.Match(*mfst.Descriptor.Platform) + } + + if mfst.Kind != imagetypes.ImageManifestKindImage { + return false + } + + return pm.Match(mfst.ImageData.Platform) + }, nil +} diff --git a/docs/reference/commandline/image.md b/docs/reference/commandline/image.md index 3aa0843c24..8c08a41d38 100644 --- a/docs/reference/commandline/image.md +++ b/docs/reference/commandline/image.md @@ -8,6 +8,7 @@ Manage images | Name | Description | |:------------------------------|:-------------------------------------------------------------------------| | [`build`](image_build.md) | Build an image from a Dockerfile | +| [`convert`](image_convert.md) | Convert multi-platform images | | [`history`](image_history.md) | Show the history of an image | | [`import`](image_import.md) | Import the contents from a tarball to create a filesystem image | | [`inspect`](image_inspect.md) | Display detailed information on one or more images | diff --git a/docs/reference/commandline/image_convert.md b/docs/reference/commandline/image_convert.md new file mode 100644 index 0000000000..41dae19054 --- /dev/null +++ b/docs/reference/commandline/image_convert.md @@ -0,0 +1,22 @@ +# docker image convert + + +Convert multi-platform images + +### Aliases + +`docker image convert`, `docker convert` + +### Options + +| Name | Type | Default | Description | +|:--------------------|:--------------|:--------|:--------------------------------------------------------------| +| `--available` | `bool` | | Only include manifests which blobs are available locally | +| `--from` | `string` | | Source image reference | +| `--no-attestations` | `bool` | | Do not include image attestations | +| `--platforms` | `stringArray` | | Include only the specified platforms in the destination image | +| `--to` | `stringArray` | | Target image references | + + + +