diff --git a/cmd/skopeo/copy.go b/cmd/skopeo/copy.go index d0af11319..b02808b6e 100644 --- a/cmd/skopeo/copy.go +++ b/cmd/skopeo/copy.go @@ -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 diff --git a/cmd/skopeo/copy_test.go b/cmd/skopeo/copy_test.go index 68248dc88..eb966adea 100644 --- a/cmd/skopeo/copy_test.go +++ b/cmd/skopeo/copy_test.go @@ -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) + }) + } +} diff --git a/docs/skopeo-copy.1.md b/docs/skopeo-copy.1.md index b34fb57d7..ed9cae6a0 100644 --- a/docs/skopeo-copy.1.md +++ b/docs/skopeo-copy.1.md @@ -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 diff --git a/integration/copy_test.go b/integration/copy_test.go index 83ed27f34..09eef81e0 100644 --- a/integration/copy_test.go +++ b/integration/copy_test.go @@ -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()