Merge pull request #2874 from aguidirh/add-platform-based-filtering

RUN-4630: copy: add platform-based filtering via --multi-arch flag
This commit is contained in:
Miloslav Trmač
2026-05-14 16:55:23 +02:00
committed by GitHub
4 changed files with 152 additions and 13 deletions

View File

@@ -78,7 +78,7 @@ See skopeo(1) section "IMAGE NAMES" for the expected format
flags.StringSliceVar(&opts.additionalTags, "additional-tag", []string{}, "additional tags (supports docker-archive)")
flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Suppress output information when copying images")
flags.BoolVarP(&opts.all, "all", "a", false, "Copy all images if SOURCE-IMAGE is a list")
flags.Var(commonFlag.NewOptionalStringValue(&opts.multiArch), "multi-arch", `How to handle multi-architecture images (system, all, or index-only)`)
flags.Var(commonFlag.NewOptionalStringValue(&opts.multiArch), "multi-arch", `How to handle multi-architecture images (system, all, index-only, or comma-separated platform list like linux/amd64,linux/arm64)`)
flags.StringVar(&opts.signIdentity, "sign-identity", "", "Identity of signed image, must be a fully specified docker reference. Defaults to the target docker reference.")
flags.StringVar(&opts.digestFile, "digestfile", "", "Write the digest of the pushed image to the specified file")
flags.StringSliceVar(&opts.encryptionKeys, "encryption-key", []string{}, "*Experimental* key with the encryption protocol to use needed to encrypt the image (e.g. jwe:/path/to/key.pem)")
@@ -89,23 +89,37 @@ See skopeo(1) section "IMAGE NAMES" for the expected format
}
// parseMultiArch parses the list processing selection
// It returns the copy.ImageListSelection to use with image.Copy option
func parseMultiArch(multiArch string) (copy.ImageListSelection, error) {
// It returns the copy.ImageListSelection to use with image.Copy option,
// and optionally a list of platform filters if specific platforms were requested
func parseMultiArch(multiArch string) (copy.ImageListSelection, []copy.InstancePlatformFilter, error) {
switch multiArch {
case "system":
return copy.CopySystemImage, nil
return copy.CopySystemImage, nil, nil
case "all":
return copy.CopyAllImages, nil
return copy.CopyAllImages, nil, nil
// There is no CopyNoImages value in copy.ImageListSelection, but because we
// don't provide an option to select a set of images to copy, we can use
// CopySpecificImages.
case "index-only":
return copy.CopySpecificImages, nil
// We don't expose CopySpecificImages other than index-only above, because
// we currently don't provide an option to choose the images to copy. That
// could be added in the future.
return copy.CopySpecificImages, nil, nil
default:
return copy.CopySystemImage, fmt.Errorf("unknown multi-arch option %q. Choose one of the supported options: 'system', 'all', or 'index-only'", multiArch)
if !strings.Contains(multiArch, "/") {
return copy.CopySystemImage, nil, fmt.Errorf("unknown multi-arch option %q. Choose one of the supported options: 'system', 'all', 'index-only', or a comma-separated platform list like 'linux/amd64,linux/arm64'", multiArch)
}
// Parse comma-separated platform list
var platforms []copy.InstancePlatformFilter
for platform := range strings.SplitSeq(multiArch, ",") {
platform = strings.TrimSpace(platform)
parts := strings.Split(platform, "/")
if len(parts) != 2 {
return copy.CopySystemImage, nil, fmt.Errorf("invalid platform format %q in --multi-arch, expected OS/Architecture (e.g., linux/amd64)", platform)
}
platforms = append(platforms, copy.InstancePlatformFilter{
OS: parts[0],
Architecture: parts[1],
})
}
return copy.CopySpecificImages, platforms, nil
}
}
@@ -168,11 +182,12 @@ func (opts *copyOptions) run(args []string, stdout io.Writer) (retErr error) {
}
imageListSelection := copy.CopySystemImage
var instancePlatforms []copy.InstancePlatformFilter
if opts.multiArch.Present() && opts.all {
return fmt.Errorf("Cannot use --all and --multi-arch flags together")
}
if opts.multiArch.Present() {
imageListSelection, err = parseMultiArch(opts.multiArch.Value())
imageListSelection, instancePlatforms, err = parseMultiArch(opts.multiArch.Value())
if err != nil {
return err
}
@@ -236,6 +251,7 @@ func (opts *copyOptions) run(args []string, stdout io.Writer) (retErr error) {
copyOpts.SourceCtx = sourceCtx
copyOpts.DestinationCtx = destinationCtx
copyOpts.ImageListSelection = imageListSelection
copyOpts.InstancePlatforms = instancePlatforms
copyOpts.OciDecryptConfig = decConfig
copyOpts.OciEncryptLayers = encLayers
copyOpts.OciEncryptConfig = encConfig

View File

@@ -1,6 +1,12 @@
package main
import "testing"
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.podman.io/image/v5/copy"
)
func TestCopy(t *testing.T) {
// Invalid command-line arguments
@@ -16,3 +22,95 @@ func TestCopy(t *testing.T) {
// FIXME: Much more test coverage
// Actual feature tests exist in integration and systemtest
}
func TestParseMultiArch(t *testing.T) {
tests := []struct {
name string
input string
expectedSelection copy.ImageListSelection
expectedPlatforms []copy.InstancePlatformFilter
expectError bool
}{
{
name: "system option",
input: "system",
expectedSelection: copy.CopySystemImage,
expectedPlatforms: nil,
expectError: false,
},
{
name: "all option",
input: "all",
expectedSelection: copy.CopyAllImages,
expectedPlatforms: nil,
expectError: false,
},
{
name: "index-only option",
input: "index-only",
expectedSelection: copy.CopySpecificImages,
expectedPlatforms: nil,
expectError: false,
},
{
name: "single platform",
input: "linux/amd64",
expectedSelection: copy.CopySpecificImages,
expectedPlatforms: []copy.InstancePlatformFilter{
{OS: "linux", Architecture: "amd64"},
},
expectError: false,
},
{
name: "multiple platforms",
input: "linux/amd64,linux/arm64",
expectedSelection: copy.CopySpecificImages,
expectedPlatforms: []copy.InstancePlatformFilter{
{OS: "linux", Architecture: "amd64"},
{OS: "linux", Architecture: "arm64"},
},
expectError: false,
},
{
name: "platforms with whitespace",
input: "linux/amd64, linux/arm64 , windows/amd64",
expectedSelection: copy.CopySpecificImages,
expectedPlatforms: []copy.InstancePlatformFilter{
{OS: "linux", Architecture: "amd64"},
{OS: "linux", Architecture: "arm64"},
{OS: "windows", Architecture: "amd64"},
},
expectError: false,
},
{
name: "invalid option",
input: "invalid",
expectError: true,
},
{
name: "invalid platform format - no slash",
input: "linux-amd64",
expectError: true,
},
{
name: "invalid platform format - too many parts",
input: "linux/amd64/extra",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selection, platforms, err := parseMultiArch(tt.input)
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expectedSelection, selection)
assert.Equal(t, tt.expectedPlatforms, platforms)
})
}
}

View File

@@ -83,8 +83,11 @@ Options:
- system: Copy only the image that matches the system architecture
- all: Copy the full multi-architecture image
- index-only: Copy only the index
- _platform-list_: Copy only specific platforms (comma-separated list of OS/Architecture pairs, e.g., `linux/amd64,linux/arm64`)
The index-only option usually fails unless the referenced per-architecture images are already present in the destination, or the target registry supports sparse indexes.
The index-only option and platform-list both create sparse manifest lists, which usually fail unless the referenced per-architecture images are already present in the destination, or the target registry supports sparse indexes.
When specifying a platform list, all compression variants and other variations for each platform are copied.
**--quiet**, **-q**
@@ -267,6 +270,11 @@ To copy and sign an image:
$ skopeo copy --sign-by dev@example.com containers-storage:example/busybox:streaming docker://example/busybox:gold
```
To copy only specific platforms from a multi-architecture image (creates a sparse manifest list):
```console
$ skopeo copy --multi-arch=linux/amd64,linux/arm64 docker://quay.io/skopeo/stable:latest docker://registry.example.com/skopeo:latest
```
To encrypt an image:
```console
$ skopeo copy docker://docker.io/library/nginx:1.17.8 oci:local_nginx:1.17.8

View File

@@ -187,6 +187,23 @@ func (s *copySuite) TestCopyNoneWithManifestList() {
assert.Equal(t, "manifest.json\nversion\n", out)
}
func (s *copySuite) TestCopyWithPlatformList() {
t := s.T()
dir1 := t.TempDir()
assertSkopeoSucceeds(t, "", "copy", "--retry-times", "3", "--multi-arch=linux/amd64,linux/arm64", knownListImage, "dir:"+dir1)
manifestPath := filepath.Join(dir1, "manifest.json")
readManifest, err := os.ReadFile(manifestPath)
require.NoError(t, err)
mimeType := manifest.GuessMIMEType(readManifest)
assert.Equal(t, "application/vnd.docker.distribution.manifest.list.v2+json", mimeType)
// Verify that we copied exactly 2 platform manifests (manifest list + 2 platforms = 3 manifest files)
manifestFiles, err := filepath.Glob(filepath.Join(dir1, "*manifest.json"))
require.NoError(t, err)
assert.Equal(t, 3, len(manifestFiles), "Expected manifest list + 2 platform manifests")
}
func (s *copySuite) TestCopyWithManifestListConverge() {
t := s.T()
oci1 := t.TempDir()