imagetools: add platforms filter to imagetools create

Allows specifying platforms that should be included
in the new image, making it possible to reduce platforms
of existing multi-arch image. Previously the individual
image manifests needed to be used as sources, but that
dropped their related attestation manifests.

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
Tonis Tiigi
2025-09-23 08:10:11 -07:00
parent 4ad48f7737
commit f448f3e22b
6 changed files with 174 additions and 22 deletions
+145 -9
View File
@@ -7,6 +7,8 @@ import (
"os"
"strings"
"github.com/containerd/containerd/v2/core/images"
"github.com/containerd/platforms"
"github.com/distribution/reference"
"github.com/docker/buildx/builder"
"github.com/docker/buildx/util/buildflags"
@@ -14,6 +16,7 @@ import (
"github.com/docker/buildx/util/imagetools"
"github.com/docker/buildx/util/progress"
"github.com/docker/cli/cli/command"
"github.com/moby/buildkit/util/attestation"
"github.com/moby/buildkit/util/progress/progressui"
"github.com/opencontainers/go-digest"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
@@ -31,6 +34,7 @@ type createOptions struct {
actionAppend bool
progress string
preferIndex bool
platforms []string
}
func runCreate(ctx context.Context, dockerCli command.Cli, in createOptions, args []string) error {
@@ -67,6 +71,11 @@ func runCreate(ctx context.Context, dockerCli command.Cli, in createOptions, arg
return err
}
platforms, err := parsePlatforms(in.platforms)
if err != nil {
return err
}
repos := map[string]struct{}{}
for _, t := range tags {
@@ -160,7 +169,12 @@ func runCreate(ctx context.Context, dockerCli command.Cli, in createOptions, arg
return errors.Wrapf(err, "failed to parse annotations")
}
dt, desc, err := r.Combine(ctx, srcs, annotations, in.preferIndex)
dt, desc, srcMap, err := r.Combine(ctx, srcs, annotations, in.preferIndex)
if err != nil {
return err
}
dt, desc, manifests, err := filterPlatforms(dt, desc, srcMap, platforms)
if err != nil {
return err
}
@@ -170,6 +184,11 @@ func runCreate(ctx context.Context, dockerCli command.Cli, in createOptions, arg
return nil
}
// manifests can be nil only if pushing one single-platform desc directly
if manifests == nil {
manifests = []descWithSource{{Descriptor: desc, Source: srcs[0]}}
}
// new resolver cause need new auth
r = imagetools.New(imageopt)
@@ -187,17 +206,12 @@ func runCreate(ctx context.Context, dockerCli command.Cli, in createOptions, arg
eg.Go(func() error {
return progress.Wrap(fmt.Sprintf("pushing %s", t.String()), pw.Write, func(sub progress.SubLogger) error {
eg2, _ := errgroup.WithContext(ctx)
for _, s := range srcs {
if reference.Domain(s.Ref) == reference.Domain(t) && reference.Path(s.Ref) == reference.Path(t) {
continue
}
s := s
for _, desc := range manifests {
eg2.Go(func() error {
sub.Log(1, fmt.Appendf(nil, "copying %s from %s to %s\n", s.Desc.Digest.String(), s.Ref.String(), t.String()))
return r.Copy(ctx, s, t)
sub.Log(1, fmt.Appendf(nil, "copying %s from %s to %s\n", desc.Digest.String(), desc.Source.Ref.String(), t.String()))
return r.Copy(ctx, desc.Source, t)
})
}
if err := eg2.Wait(); err != nil {
return err
}
@@ -216,6 +230,107 @@ func runCreate(ctx context.Context, dockerCli command.Cli, in createOptions, arg
return err
}
type descWithSource struct {
ocispecs.Descriptor
Source *imagetools.Source
}
func filterPlatforms(dt []byte, desc ocispecs.Descriptor, srcMap map[digest.Digest]*imagetools.Source, plats []ocispecs.Platform) ([]byte, ocispecs.Descriptor, []descWithSource, error) {
if len(plats) == 0 {
return dt, desc, nil, nil
}
matcher := platforms.Any(plats...)
if !images.IsIndexType(desc.MediaType) {
var mfst ocispecs.Manifest
if err := json.Unmarshal(dt, &mfst); err != nil {
return nil, ocispecs.Descriptor{}, nil, errors.Wrapf(err, "failed to parse manifest")
}
if desc.Platform == nil {
return nil, ocispecs.Descriptor{}, nil, errors.Errorf("cannot filter platforms from a manifest without platform information")
}
if !matcher.Match(*desc.Platform) {
return nil, ocispecs.Descriptor{}, nil, errors.Errorf("input platform %s does not match any of the provided platforms", platforms.Format(*desc.Platform))
}
return dt, desc, nil, nil
}
var idx ocispecs.Index
if err := json.Unmarshal(dt, &idx); err != nil {
return nil, ocispecs.Descriptor{}, nil, errors.Wrapf(err, "failed to parse index")
}
manifestMap := map[digest.Digest]ocispecs.Descriptor{}
for _, m := range idx.Manifests {
manifestMap[m.Digest] = m
}
references := map[digest.Digest]struct{}{}
for _, m := range idx.Manifests {
if refType, ok := m.Annotations[attestation.DockerAnnotationReferenceType]; ok && refType == attestation.DockerAnnotationReferenceTypeDefault {
dgstStr, ok := m.Annotations[attestation.DockerAnnotationReferenceDigest]
if !ok {
continue
}
dgst, err := digest.Parse(dgstStr)
if err != nil {
continue
}
subject, ok := manifestMap[dgst]
if !ok {
continue
}
if subject.Platform == nil || matcher.Match(*subject.Platform) {
references[m.Digest] = struct{}{}
}
}
}
var mfsts []ocispecs.Descriptor
var mfstsWithSource []descWithSource
for _, m := range idx.Manifests {
if _, isRef := references[m.Digest]; isRef || m.Platform == nil || matcher.Match(*m.Platform) {
src, ok := srcMap[m.Digest]
if !ok {
defaultSource, ok := srcMap[desc.Digest]
if !ok {
return nil, ocispecs.Descriptor{}, nil, errors.Errorf("internal error: no source found for %s", m.Digest)
}
src = defaultSource
}
mfsts = append(mfsts, m)
mfstsWithSource = append(mfstsWithSource, descWithSource{
Descriptor: m,
Source: src,
})
}
}
if len(mfsts) == len(idx.Manifests) {
// all platforms matched, no need to rewrite index
return dt, desc, mfstsWithSource, nil
}
if len(mfsts) == 0 {
return nil, ocispecs.Descriptor{}, nil, errors.Errorf("none of the manifests match the provided platforms")
}
idx.Manifests = mfsts
idxBytes, err := json.MarshalIndent(&idx, "", " ")
if err != nil {
return nil, ocispecs.Descriptor{}, nil, errors.Wrap(err, "failed to marshal index")
}
desc = ocispecs.Descriptor{
MediaType: desc.MediaType,
Size: int64(len(idxBytes)),
Digest: digest.FromBytes(idxBytes),
Annotations: desc.Annotations,
}
return idxBytes, desc, mfstsWithSource, nil
}
func parseSources(in []string) ([]*imagetools.Source, error) {
out := make([]*imagetools.Source, len(in))
for i, in := range in {
@@ -228,6 +343,26 @@ func parseSources(in []string) ([]*imagetools.Source, error) {
return out, nil
}
func parsePlatforms(in []string) ([]ocispecs.Platform, error) {
out := make([]ocispecs.Platform, 0, len(in))
for _, p := range in {
if arr := strings.Split(p, ","); len(arr) > 1 {
v, err := parsePlatforms(arr)
if err != nil {
return nil, err
}
out = append(out, v...)
continue
}
plat, err := platforms.Parse(p)
if err != nil {
return nil, errors.Wrapf(err, "invalid platform %q", p)
}
out = append(out, plat)
}
return out, nil
}
func parseRefs(in []string) ([]reference.Named, error) {
refs := make([]reference.Named, len(in))
for i, in := range in {
@@ -291,6 +426,7 @@ func createCmd(dockerCli command.Cli, opts RootOptions) *cobra.Command {
flags.StringVar(&options.progress, "progress", "auto", `Set type of progress output ("auto", "plain", "tty", "rawjson"). Use plain to show container output`)
flags.StringArrayVarP(&options.annotations, "annotation", "", []string{}, "Add annotation to the image")
flags.BoolVar(&options.preferIndex, "prefer-index", true, "When only a single source is specified, prefer outputting an image index or manifest list instead of performing a carbon copy")
flags.StringArrayVarP(&options.platforms, "platform", "p", []string{}, "Filter specified platforms of target image")
return cmd
}