diff --git a/cmd/oras/internal/option/platform.go b/cmd/oras/internal/option/platform.go index 78c4fcbf5..d0d618b8f 100644 --- a/cmd/oras/internal/option/platform.go +++ b/cmd/oras/internal/option/platform.go @@ -27,8 +27,9 @@ import ( // Platform option struct. type Platform struct { - platform string + platform []string Platform *ocispec.Platform + Platforms []*ocispec.Platform FlagDescription string } @@ -37,40 +38,54 @@ func (opts *Platform) ApplyFlags(fs *pflag.FlagSet) { if opts.FlagDescription == "" { opts.FlagDescription = "request platform" } - fs.StringVarP(&opts.platform, "platform", "", "", opts.FlagDescription+" in the form of `os[/arch][/variant][:os_version]`") + fs.StringSliceVarP(&opts.platform, "platform", "", nil, opts.FlagDescription+" in the form of `os[/arch][/variant][:os_version]` or comma-separated list for multiple platforms (supported in oras cp only)") } // Parse parses the input platform flag to an oci platform type. func (opts *Platform) Parse(*cobra.Command) error { - if opts.platform == "" { + if len(opts.platform) == 0 { return nil } + return opts.parsePlatform(opts.platform) +} - // OS[/Arch[/Variant]][:OSVersion] - // If Arch is not provided, will use GOARCH instead - var platformStr string - var p ocispec.Platform - platformStr, p.OSVersion, _ = strings.Cut(opts.platform, ":") - parts := strings.Split(platformStr, "/") - switch len(parts) { - case 3: - p.Variant = parts[2] - fallthrough - case 2: - p.Architecture = parts[1] - case 1: - p.Architecture = runtime.GOARCH - default: - return fmt.Errorf("failed to parse platform %q: expected format os[/arch[/variant]]", opts.platform) - } - p.OS = parts[0] - if p.OS == "" { - return fmt.Errorf("invalid platform: OS cannot be empty") +// parsePlatform parses multiple platforms +func (opts *Platform) parsePlatform(platformStrings []string) error { + opts.Platforms = make([]*ocispec.Platform, 0, len(platformStrings)) + for _, platformStr := range platformStrings { + platformStr = strings.TrimSpace(platformStr) + if platformStr == "" { + continue + } + var p ocispec.Platform + platformPart, osVersion, _ := strings.Cut(platformStr, ":") + parts := strings.Split(platformPart, "/") + switch len(parts) { + case 3: + p.Variant = parts[2] + fallthrough + case 2: + p.Architecture = parts[1] + case 1: + p.Architecture = runtime.GOARCH + default: + return fmt.Errorf("failed to parse platform %q: expected format os[/arch[/variant]]", platformStr) + } + p.OS = parts[0] + if p.OS == "" { + return fmt.Errorf("invalid platform: OS cannot be empty") + } + if p.Architecture == "" { + return fmt.Errorf("invalid platform: Architecture cannot be empty") + } + p.OSVersion = osVersion + opts.Platforms = append(opts.Platforms, &p) } - if p.Architecture == "" { - return fmt.Errorf("invalid platform: Architecture cannot be empty") + + // Set the first platform as the primary one for backward compatibility + if len(opts.Platforms) > 0 { + opts.Platform = opts.Platforms[0] } - opts.Platform = &p return nil } @@ -82,5 +97,5 @@ type ArtifactPlatform struct { // ApplyFlags applies flags to a command flag set. func (opts *ArtifactPlatform) ApplyFlags(fs *pflag.FlagSet) { opts.FlagDescription = "set artifact platform" - fs.StringVarP(&opts.platform, "artifact-platform", "", "", "[Experimental] "+opts.FlagDescription+" in the form of `os[/arch][/variant][:os_version]`") + fs.StringSliceVarP(&opts.platform, "artifact-platform", "", nil, "[Experimental] "+opts.FlagDescription+" in the form of `os[/arch][/variant][:os_version]`") } diff --git a/cmd/oras/internal/option/platform_test.go b/cmd/oras/internal/option/platform_test.go index 587d36785..c4b65ca98 100644 --- a/cmd/oras/internal/option/platform_test.go +++ b/cmd/oras/internal/option/platform_test.go @@ -27,7 +27,7 @@ import ( func TestPlatform_ApplyFlags(t *testing.T) { var test struct{ Platform } ApplyFlags(&test, pflag.NewFlagSet("oras-test", pflag.ExitOnError)) - if test.platform != "" { + if len(test.platform) != 0 { t.Fatalf("expecting platform to be empty but got: %v", test.platform) } } @@ -37,11 +37,11 @@ func TestPlatform_Parse_err(t *testing.T) { name string opts *Platform }{ - {name: "empty arch 1", opts: &Platform{"os/", nil, ""}}, - {name: "empty arch 2", opts: &Platform{"os//variant", nil, ""}}, - {name: "empty os", opts: &Platform{"/arch", nil, ""}}, - {name: "empty os with variant", opts: &Platform{"/arch/variant", nil, ""}}, - {name: "trailing slash", opts: &Platform{"os/arch/variant/llama", nil, ""}}, + {name: "empty arch 1", opts: &Platform{platform: []string{"os/"}}}, + {name: "empty arch 2", opts: &Platform{platform: []string{"os//variant"}}}, + {name: "empty os", opts: &Platform{platform: []string{"/arch"}}}, + {name: "empty os with variant", opts: &Platform{platform: []string{"/arch/variant"}}}, + {name: "trailing slash", opts: &Platform{platform: []string{"os/arch/variant/llama"}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -60,13 +60,13 @@ func TestPlatform_Parse(t *testing.T) { opts *Platform want *ocispec.Platform }{ - {name: "empty", opts: &Platform{platform: ""}, want: nil}, - {name: "default arch", opts: &Platform{platform: "os"}, want: &ocispec.Platform{OS: "os", Architecture: runtime.GOARCH}}, - {name: "os&arch", opts: &Platform{platform: "os/aRcH"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH"}}, - {name: "empty variant", opts: &Platform{platform: "os/aRcH/"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH", Variant: ""}}, - {name: "os&arch&variant", opts: &Platform{platform: "os/aRcH/vAriAnt"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH", Variant: "vAriAnt"}}, - {name: "os version", opts: &Platform{platform: "os/aRcH/vAriAnt:osversion"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH", Variant: "vAriAnt", OSVersion: "osversion"}}, - {name: "long os version", opts: &Platform{platform: "os/aRcH"}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH"}}, + {name: "empty", opts: &Platform{platform: []string{}}, want: nil}, + {name: "default arch", opts: &Platform{platform: []string{"os"}}, want: &ocispec.Platform{OS: "os", Architecture: runtime.GOARCH}}, + {name: "os&arch", opts: &Platform{platform: []string{"os/aRcH"}}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH"}}, + {name: "empty variant", opts: &Platform{platform: []string{"os/aRcH/"}}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH", Variant: ""}}, + {name: "os&arch&variant", opts: &Platform{platform: []string{"os/aRcH/vAriAnt"}}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH", Variant: "vAriAnt"}}, + {name: "os version", opts: &Platform{platform: []string{"os/aRcH/vAriAnt:osversion"}}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH", Variant: "vAriAnt", OSVersion: "osversion"}}, + {name: "long os version", opts: &Platform{platform: []string{"os/aRcH"}}, want: &ocispec.Platform{OS: "os", Architecture: "aRcH"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/oras/root/cp.go b/cmd/oras/root/cp.go index 7852f47eb..3157bb295 100644 --- a/cmd/oras/root/cp.go +++ b/cmd/oras/root/cp.go @@ -33,6 +33,7 @@ import ( "oras.land/oras/cmd/oras/internal/argument" "oras.land/oras/cmd/oras/internal/command" "oras.land/oras/cmd/oras/internal/display" + "oras.land/oras/cmd/oras/internal/display/metadata" "oras.land/oras/cmd/oras/internal/display/status" oerrors "oras.land/oras/cmd/oras/internal/errors" "oras.land/oras/cmd/oras/internal/option" @@ -85,6 +86,9 @@ Example - Copy an artifact and referrers using specific methods for the Referrer Example - Copy certain platform of an artifact: oras cp --platform linux/arm/v5 localhost:5000/net-monitor:v1 localhost:6000/net-monitor-copy:v1 +Example - Copy certain platforms of an artifact: + oras cp --platform linux/amd64,linux/arm64,linux/arm/v7 localhost:5000/net-monitor:v1 localhost:6000/net-monitor-copy:v1 + Example - Copy an artifact with multiple tags: oras cp localhost:5000/net-monitor:v1 localhost:6000/net-monitor-copy:tag1,tag2,tag3 @@ -138,6 +142,17 @@ func runCopy(cmd *cobra.Command, opts *copyOptions) error { ctx = registryutil.WithScopeHint(ctx, dst, auth.ActionPull, auth.ActionPush) statusHandler, metadataHandler := display.NewCopyHandler(opts.Printer, opts.TTY, dst) + // Check if multiple platforms are specified + if len(opts.Platform.Platforms) > 1 && !opts.recursive { + // Handle multiple platforms - copy manifests that match the specified platforms + return copyMultiplePlatforms(ctx, statusHandler, metadataHandler, src, dst, opts) + } + + // Handle single platform or recursive mode + return copySinglePlatformOrRecursive(ctx, statusHandler, metadataHandler, src, dst, opts) +} + +func copySinglePlatformOrRecursive(ctx context.Context, statusHandler status.CopyHandler, metadataHandler metadata.CopyHandler, src oras.ReadOnlyGraphTarget, dst oras.GraphTarget, opts *copyOptions) error { desc, err := doCopy(ctx, statusHandler, src, dst, opts) if err != nil { return err @@ -164,6 +179,211 @@ func runCopy(cmd *cobra.Command, opts *copyOptions) error { return metadataHandler.Render() } +// copyMultiplePlatforms handles copying when multiple platforms are specified +func copyMultiplePlatforms(ctx context.Context, statusHandler status.CopyHandler, metadataHandler metadata.CopyHandler, src oras.ReadOnlyGraphTarget, dst oras.GraphTarget, opts *copyOptions) error { + // Resolve the source reference to get the root descriptor + resolveOpts := oras.DefaultResolveOptions + // We don't set TargetPlatform here since we want to get the full index/list + root, err := oras.Resolve(ctx, src, opts.From.Reference, resolveOpts) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", opts.From.Reference, err) + } + + // Check if the resolved descriptor is an index/manifest list + isIndex := root.MediaType == ocispec.MediaTypeImageIndex || root.MediaType == docker.MediaTypeManifestList + if !isIndex { + // If not an index, return an error + return fmt.Errorf("source reference %s is not an index or manifest list", opts.From.Reference) + } + + index, filteredManifests, err := filterManifestByPlatform(ctx, src, root, opts) + if err != nil { + return err + } + + // If all platforms are specified, we can just copy the root descriptor + if len(index.Manifests) == len(filteredManifests) { + opts.Platform.Platform = nil + return copySinglePlatformOrRecursive(ctx, statusHandler, metadataHandler, src, dst, opts) + } + + // Perform multiple copies + return doMultipleCopy(ctx, statusHandler, metadataHandler, src, dst, opts, index, filteredManifests) +} + +func filterManifestByPlatform(ctx context.Context, src oras.ReadOnlyGraphTarget, root ocispec.Descriptor, opts *copyOptions) (ocispec.Index, []ocispec.Descriptor, error) { + // For indexes/lists, fetch the index content + indexContent, err := content.FetchAll(ctx, src, root) + if err != nil { + return ocispec.Index{}, nil, fmt.Errorf("failed to fetch index: %w", err) + } + + var index ocispec.Index + if err = json.Unmarshal(indexContent, &index); err != nil { + return ocispec.Index{}, nil, fmt.Errorf("failed to parse index: %w", err) + } + + // Filter manifests based on the specified platforms + var availablePlatforms []string + var filteredManifests []ocispec.Descriptor + for _, manifest := range index.Manifests { + if manifest.Platform == nil { + continue + } + var platformStr string + if manifest.Platform.Variant != "" { + platformStr = fmt.Sprintf("%s/%s/%s", manifest.Platform.OS, manifest.Platform.Architecture, manifest.Platform.Variant) + } else { + platformStr = fmt.Sprintf("%s/%s", manifest.Platform.OS, manifest.Platform.Architecture) + } + availablePlatforms = append(availablePlatforms, platformStr) + if matchesAnyPlatform(manifest.Platform, opts.Platform.Platforms) { + filteredManifests = append(filteredManifests, manifest) + } + } + + // Check if any platforms were unmatched + var unmatchedPlatforms []string + for _, platform := range opts.Platform.Platforms { + var platformStr string + var contains bool + if platform.Variant != "" { + platformStr = fmt.Sprintf("%s/%s/%s", platform.OS, platform.Architecture, platform.Variant) + contains = slices.ContainsFunc(filteredManifests, func(manifest ocispec.Descriptor) bool { + return manifest.Platform.OS == platform.OS && manifest.Platform.Architecture == platform.Architecture && manifest.Platform.Variant == platform.Variant + }) + } else { + platformStr = fmt.Sprintf("%s/%s", platform.OS, platform.Architecture) + contains = slices.ContainsFunc(filteredManifests, func(manifest ocispec.Descriptor) bool { + return manifest.Platform.OS == platform.OS && manifest.Platform.Architecture == platform.Architecture + }) + } + if !contains { + unmatchedPlatforms = append(unmatchedPlatforms, platformStr) + } + } + // Return error with details about unmatched platforms + if len(unmatchedPlatforms) > 0 { + return ocispec.Index{}, nil, fmt.Errorf("some requested platforms were not matched; unmatched platforms: [%s]; available platforms in index: [%s]", + strings.Join(unmatchedPlatforms, ", "), strings.Join(availablePlatforms, ", ")) + } + return index, filteredManifests, nil +} + +func doMultipleCopy(ctx context.Context, statusHandler status.CopyHandler, metadataHandler metadata.CopyHandler, src oras.ReadOnlyGraphTarget, dst oras.GraphTarget, opts *copyOptions, index ocispec.Index, filteredManifests []ocispec.Descriptor) error { + // Create a new index with only the filtered manifests + newIndex := index + newIndex.Manifests = filteredManifests + + // Marshal the new index + newIndexContent, err := json.Marshal(newIndex) + if err != nil { + return fmt.Errorf("failed to marshal new index: %w", err) + } + + // Create a descriptor for the new index + newIndexDesc := ocispec.Descriptor{ + MediaType: index.MediaType, + Digest: digest.FromBytes(newIndexContent), + Size: int64(len(newIndexContent)), + Annotations: index.Annotations, + } + + // Prepare copy options + extendedCopyGraphOptions := oras.DefaultExtendedCopyGraphOptions + extendedCopyGraphOptions.Concurrency = opts.concurrency + extendedCopyGraphOptions.FindPredecessors = func(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { + return registry.Referrers(ctx, src, desc, "") + } + if mountRepo, canMount := getMountPoint(src, dst, opts); canMount { + extendedCopyGraphOptions.MountFrom = func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) { + return []string{mountRepo}, nil + } + } + dst, err = statusHandler.StartTracking(dst) + if err != nil { + return err + } + defer func() { + _ = statusHandler.StopTracking() + }() + extendedCopyGraphOptions.OnCopySkipped = statusHandler.OnCopySkipped + extendedCopyGraphOptions.PreCopy = statusHandler.PreCopy + extendedCopyGraphOptions.PostCopy = statusHandler.PostCopy + extendedCopyGraphOptions.OnMounted = statusHandler.OnMounted + + // Copy all matching manifests and their content + for _, manifestDesc := range filteredManifests { + // Copy the manifest itself + if err = oras.CopyGraph(ctx, src, dst, manifestDesc, extendedCopyGraphOptions.CopyGraphOptions); err != nil { + return fmt.Errorf("failed to copy manifest %s: %w", manifestDesc.Digest, err) + } + } + + // Push the new index to the destination + if err = dst.Push(ctx, newIndexDesc, strings.NewReader(string(newIndexContent))); err != nil { + return fmt.Errorf("failed to push new index: %w", err) + } + + // Tag the new index if needed + if opts.To.Reference != "" { + if err = dst.Tag(ctx, newIndexDesc, opts.To.Reference); err != nil { + return fmt.Errorf("failed to tag new index: %w", err) + } + } + + // Handle extra references + if len(opts.extraRefs) != 0 { + tagNOpts := oras.DefaultTagNOptions + tagNOpts.Concurrency = opts.concurrency + tagListener := listener.NewTaggedListener(dst, metadataHandler.OnTagged) + if _, err = oras.TagN(ctx, tagListener, opts.To.Reference, opts.extraRefs, tagNOpts); err != nil { + return err + } + } + + // Update reference if needed + if from, err := digest.Parse(opts.From.Reference); err == nil && from != newIndexDesc.Digest { + opts.From.RawReference = fmt.Sprintf("%s@%s", opts.From.Path, newIndexDesc.Digest.String()) + } + if err = metadataHandler.OnCopied(&opts.BinaryTarget, newIndexDesc); err != nil { + return err + } + return metadataHandler.Render() +} + +// matchesAnyPlatform checks if a manifest platform matches any of the specified platforms +func matchesAnyPlatform(manifestPlatform *ocispec.Platform, platforms []*ocispec.Platform) bool { + return slices.ContainsFunc(platforms, func(platform *ocispec.Platform) bool { + return platformMatches(manifestPlatform, platform) + }) +} + +// platformMatches checks if two platforms match +func platformMatches(manifestPlatform, targetPlatform *ocispec.Platform) bool { + if manifestPlatform.OS != targetPlatform.OS || manifestPlatform.Architecture != targetPlatform.Architecture { + return false + } + + // Variant: optional; if specified, must match exactly, otherwise it is ignored + //if targetPlatform.Variant == "" && manifestPlatform.Variant != "" { + // return false + //} + if targetPlatform.Variant != "" && manifestPlatform.Variant == "" { + return false + } + if targetPlatform.Variant != "" && manifestPlatform.Variant != "" && targetPlatform.Variant != manifestPlatform.Variant { + return false + } + + // OSVersion is optional; only treat it as a mismatch if both OSVersions are non-empty and different. + if manifestPlatform.OSVersion != "" && targetPlatform.OSVersion != "" && manifestPlatform.OSVersion != targetPlatform.OSVersion { + return false + } + + return true +} + func doCopy(ctx context.Context, copyHandler status.CopyHandler, src oras.ReadOnlyGraphTarget, dst oras.GraphTarget, opts *copyOptions) (desc ocispec.Descriptor, err error) { // Prepare copy options extendedCopyGraphOptions := oras.DefaultExtendedCopyGraphOptions diff --git a/cmd/oras/root/cp_test.go b/cmd/oras/root/cp_test.go index 8561693d7..b2eadb8f9 100644 --- a/cmd/oras/root/cp_test.go +++ b/cmd/oras/root/cp_test.go @@ -38,6 +38,7 @@ import ( "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/registry/remote" "oras.land/oras/cmd/oras/internal/display/status" + "oras.land/oras/cmd/oras/internal/option" "oras.land/oras/internal/testutils" ) @@ -452,3 +453,376 @@ func Test_getMountPoint(t *testing.T) { }) } } + +func Test_matchesAnyPlatform(t *testing.T) { + tests := []struct { + name string + manifest *ocispec.Platform + platforms []*ocispec.Platform + wantResult bool + }{ + { + name: "matching platform", + manifest: &ocispec.Platform{ + OS: "linux", + Architecture: "amd64", + }, + platforms: []*ocispec.Platform{ + { + OS: "linux", + Architecture: "amd64", + }, + }, + wantResult: true, + }, + { + name: "non-matching platform", + manifest: &ocispec.Platform{ + OS: "linux", + Architecture: "amd64", + }, + platforms: []*ocispec.Platform{ + { + OS: "windows", + Architecture: "amd64", + }, + }, + wantResult: false, + }, + { + name: "multiple platforms with match", + manifest: &ocispec.Platform{ + OS: "linux", + Architecture: "arm64", + }, + platforms: []*ocispec.Platform{ + { + OS: "linux", + Architecture: "amd64", + }, + { + OS: "linux", + Architecture: "arm64", + }, + }, + wantResult: true, + }, + { + name: "multiple platforms without match", + manifest: &ocispec.Platform{ + OS: "linux", + Architecture: "arm64", + }, + platforms: []*ocispec.Platform{ + { + OS: "linux", + Architecture: "amd64", + }, + { + OS: "windows", + Architecture: "arm64", + }, + }, + wantResult: false, + }, + { + name: "platform with variant match", + manifest: &ocispec.Platform{ + OS: "linux", + Architecture: "arm", + Variant: "v7", + }, + platforms: []*ocispec.Platform{ + { + OS: "linux", + Architecture: "arm", + Variant: "v7", + }, + }, + wantResult: true, + }, + { + name: "platform with variant mismatch", + manifest: &ocispec.Platform{ + OS: "linux", + Architecture: "arm", + Variant: "v7", + }, + platforms: []*ocispec.Platform{ + { + OS: "linux", + Architecture: "arm", + Variant: "v8", + }, + }, + wantResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matchesAnyPlatform(tt.manifest, tt.platforms) + if result != tt.wantResult { + t.Errorf("matchesAnyPlatform() = %v, want %v", result, tt.wantResult) + } + }) + } +} + +func Test_platformMatches(t *testing.T) { + tests := []struct { + name string + a *ocispec.Platform + b *ocispec.Platform + wantResult bool + }{ + { + name: "identical platforms", + a: &ocispec.Platform{ + OS: "linux", + Architecture: "amd64", + }, + b: &ocispec.Platform{ + OS: "linux", + Architecture: "amd64", + }, + wantResult: true, + }, + { + name: "different OS", + a: &ocispec.Platform{ + OS: "linux", + Architecture: "amd64", + }, + b: &ocispec.Platform{ + OS: "windows", + Architecture: "amd64", + }, + wantResult: false, + }, + { + name: "different architecture", + a: &ocispec.Platform{ + OS: "linux", + Architecture: "amd64", + }, + b: &ocispec.Platform{ + OS: "linux", + Architecture: "arm64", + }, + wantResult: false, + }, + { + name: "same platform with empty variants", + a: &ocispec.Platform{ + OS: "linux", + Architecture: "arm", + Variant: "", + }, + b: &ocispec.Platform{ + OS: "linux", + Architecture: "arm", + Variant: "", + }, + wantResult: true, + }, + { + name: "platform with empty variant matches platform with specific variant", + a: &ocispec.Platform{ + OS: "linux", + Architecture: "arm", + Variant: "", + }, + b: &ocispec.Platform{ + OS: "linux", + Architecture: "arm", + Variant: "v7", + }, + wantResult: true, + }, + { + name: "platform with specific variant matches platform with empty variant", + a: &ocispec.Platform{ + OS: "linux", + Architecture: "arm", + Variant: "v7", + }, + b: &ocispec.Platform{ + OS: "linux", + Architecture: "arm", + Variant: "", + }, + wantResult: true, + }, + { + name: "platform with specific variant matches same variant", + a: &ocispec.Platform{ + OS: "linux", + Architecture: "arm", + Variant: "v7", + }, + b: &ocispec.Platform{ + OS: "linux", + Architecture: "arm", + Variant: "v7", + }, + wantResult: true, + }, + { + name: "platform with different variants", + a: &ocispec.Platform{ + OS: "linux", + Architecture: "arm", + Variant: "v7", + }, + b: &ocispec.Platform{ + OS: "linux", + Architecture: "arm", + Variant: "v8", + }, + wantResult: false, + }, + { + name: "platform with empty OSVersion matches platform with specific OSVersion", + a: &ocispec.Platform{ + OS: "linux", + Architecture: "amd64", + OSVersion: "", + }, + b: &ocispec.Platform{ + OS: "linux", + Architecture: "amd64", + OSVersion: "18.04", + }, + wantResult: true, + }, + { + name: "platform with specific OSVersion matches same OSVersion", + a: &ocispec.Platform{ + OS: "linux", + Architecture: "amd64", + OSVersion: "18.04", + }, + b: &ocispec.Platform{ + OS: "linux", + Architecture: "amd64", + OSVersion: "18.04", + }, + wantResult: true, + }, + { + name: "platform with different OSVersion", + a: &ocispec.Platform{ + OS: "linux", + Architecture: "amd64", + OSVersion: "18.04", + }, + b: &ocispec.Platform{ + OS: "linux", + Architecture: "amd64", + OSVersion: "20.04", + }, + wantResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := platformMatches(tt.a, tt.b) + if result != tt.wantResult { + t.Errorf("platformMatches() = %v, want %v", result, tt.wantResult) + } + }) + } +} + +func Test_checkMultiplePlatformsFeatureEnabled(t *testing.T) { + // Test when only one platform is specified (should not trigger multiple platforms logic) + opts := ©Options{ + Platform: option.Platform{ + Platforms: []*ocispec.Platform{ + { + OS: "linux", + Architecture: "amd64", + }, + }, + }, + recursive: false, + } + + result := len(opts.Platform.Platforms) > 1 && !opts.recursive + if result { + t.Errorf("Expected single platform not to trigger multiple platforms logic, got %v", result) + } + + // Test when multiple platforms are specified (should trigger multiple platforms logic) + optsMultiple := ©Options{ + Platform: option.Platform{ + Platforms: []*ocispec.Platform{ + { + OS: "linux", + Architecture: "amd64", + }, + { + OS: "linux", + Architecture: "arm64", + }, + }, + }, + recursive: false, + } + + resultMultiple := len(optsMultiple.Platform.Platforms) > 1 && !optsMultiple.recursive + if !resultMultiple { + t.Errorf("Expected multiple platforms to trigger multiple platforms logic, got %v", resultMultiple) + } + + // Test when multiple platforms are specified but recursive is true (should not trigger multiple platforms logic) + optsRecursive := ©Options{ + Platform: option.Platform{ + Platforms: []*ocispec.Platform{ + { + OS: "linux", + Architecture: "amd64", + }, + { + OS: "linux", + Architecture: "arm64", + }, + }, + }, + recursive: true, + } + + resultRecursive := len(optsRecursive.Platform.Platforms) > 1 && !optsRecursive.recursive + if resultRecursive { + t.Errorf("Expected recursive mode with multiple platforms not to trigger multiple platforms logic, got %v", resultRecursive) + } +} + +func Test_formatPlatformString(t *testing.T) { + platform := &ocispec.Platform{ + OS: "linux", + Architecture: "amd64", + } + + expected := "linux/amd64" + result := fmt.Sprintf("%s/%s", platform.OS, platform.Architecture) + + if result != expected { + t.Errorf("formatPlatformString() = %v, want %v", result, expected) + } + + platformWithVariant := &ocispec.Platform{ + OS: "linux", + Architecture: "arm", + Variant: "v7", + } + + expectedWithVariant := "linux/arm" + resultWithVariant := fmt.Sprintf("%s/%s", platformWithVariant.OS, platformWithVariant.Architecture) + + if resultWithVariant != expectedWithVariant { + t.Errorf("formatPlatformString() with variant = %v, want %v", resultWithVariant, expectedWithVariant) + } +} diff --git a/test/e2e/suite/command/cp_multiplatform.go b/test/e2e/suite/command/cp_multiplatform.go new file mode 100644 index 000000000..3a2d44d51 --- /dev/null +++ b/test/e2e/suite/command/cp_multiplatform.go @@ -0,0 +1,144 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package command + +import ( + "encoding/json" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + ma "oras.land/oras/test/e2e/internal/testdata/multi_arch" + . "oras.land/oras/test/e2e/internal/utils" +) + +func cpMultiPlatformTestRepo(text string) string { + return fmt.Sprintf("command/copy/multiplatform/%d/%s", GinkgoRandomSeed(), text) +} + +var _ = Describe("Multi-platform copy users:", func() { + When("running `cp` with multiple platforms", func() { + It("should copy multiple platforms of image to a new repository", func() { + src := RegistryRef(ZOTHost, ImageRepo, ma.Tag) + dst := RegistryRef(ZOTHost, cpMultiPlatformTestRepo("multi-platform"), "copiedMulti") + + // Copy multiple platforms: linux/amd64 and linux/arm64 + ORAS("cp", src, dst, "--platform", "linux/amd64,linux/arm64"). + MatchKeyWords("linux/amd64", "linux/arm64"). + Exec() + + // validate + // Check that the resulting manifest index contains only the selected platforms + manifest := ORAS("manifest", "fetch", dst).Exec().Out.Contents() + var index ocispec.Index + Expect(json.Unmarshal(manifest, &index)).ShouldNot(HaveOccurred()) + Expect(len(index.Manifests)).To(Equal(2)) // Should have 2 manifests for the 2 selected platforms + platformsFound := make(map[string]bool) + for _, manifest := range index.Manifests { + if manifest.Platform != nil { + platformsFound[fmt.Sprintf("%s/%s", manifest.Platform.OS, manifest.Platform.Architecture)] = true + } + } + Expect(platformsFound["linux/amd64"]).To(BeTrue()) + Expect(platformsFound["linux/arm64"]).To(BeTrue()) + }) + + It("should fail to copy multiple platforms when some platforms are not available", func() { + src := RegistryRef(ZOTHost, ImageRepo, ma.Tag) + dst := RegistryRef(ZOTHost, cpMultiPlatformTestRepo("missing-platform"), "copiedMissing") + + // Attempt to copy platforms that include one that doesn't exist + ORAS("cp", src, dst, "--platform", "linux/amd64,linux/nonexistent"). + ExpectFailure(). + MatchErrKeyWords("only 1 of 2 requested platforms were matched", "unmatched platforms: [linux/nonexistent]"). + Exec() + }) + + It("should copy multiple platforms of image with recursive flag", func() { + src := RegistryRef(ZOTHost, ArtifactRepo, ma.Tag) + dstRepo := cpMultiPlatformTestRepo("multi-platform-recursive") + dst := RegistryRef(ZOTHost, dstRepo, "copiedMultiRecursive") + + // Copy multiple platforms with referrers: linux/amd64 and linux/arm64 + ORAS("cp", src, dst, "-r", "--platform", "linux/amd64,linux/arm64"). + Exec() + + // validate + // Check that the resulting manifest index contains only the selected platforms + manifest := ORAS("manifest", "fetch", dst).Exec().Out.Contents() + var index ocispec.Index + Expect(json.Unmarshal(manifest, &index)).ShouldNot(HaveOccurred()) + Expect(len(index.Manifests)).To(Equal(2)) // Should have 2 manifests for the 2 selected platforms + platformsFound := make(map[string]bool) + for _, manifest := range index.Manifests { + if manifest.Platform != nil { + platformsFound[fmt.Sprintf("%s/%s", manifest.Platform.OS, manifest.Platform.Architecture)] = true + } + } + Expect(platformsFound["linux/amd64"]).To(BeTrue()) + Expect(platformsFound["linux/arm64"]).To(BeTrue()) + + // Also check that referrers were copied for the selected platforms + ORAS("discover", dst, "--artifact-type", "signature").Exec() + }) + + It("should copy a single platform when only one platform is specified", func() { + src := RegistryRef(ZOTHost, ImageRepo, ma.Tag) + dst := RegistryRef(ZOTHost, cpMultiPlatformTestRepo("single-platform"), "copiedSingle") + + // Copy a single platform: linux/amd64 + ORAS("cp", src, dst, "--platform", "linux/amd64"). + Exec() + + // validate + manifest := ORAS("manifest", "fetch", dst).Exec().Out.Contents() + var index ocispec.Index + Expect(json.Unmarshal(manifest, &index)).ShouldNot(HaveOccurred()) + Expect(len(index.Manifests)).To(Equal(1)) // Should have 1 manifest for the selected platform + Expect(index.Manifests[0].Platform).ToNot(BeNil()) + Expect(index.Manifests[0].Platform.OS).To(Equal("linux")) + Expect(index.Manifests[0].Platform.Architecture).To(Equal("amd64")) + }) + + It("should copy multiple platforms with complex platform strings including variants", func() { + src := RegistryRef(ZOTHost, ImageRepo, ma.Tag) + dst := RegistryRef(ZOTHost, cpMultiPlatformTestRepo("complex-platform"), "copiedComplex") + + // Copy multiple platforms: linux/amd64 and linux/arm/v7 (with variant) + ORAS("cp", src, dst, "--platform", "linux/amd64,linux/arm/v7"). + Exec() + + // validate + manifest := ORAS("manifest", "fetch", dst).Exec().Out.Contents() + var index ocispec.Index + Expect(json.Unmarshal(manifest, &index)).ShouldNot(HaveOccurred()) + Expect(len(index.Manifests)).To(Equal(2)) // Should have 2 manifests for the 2 selected platforms + platformsFound := make(map[string]bool) + for _, manifest := range index.Manifests { + if manifest.Platform != nil { + platformStr := fmt.Sprintf("%s/%s", manifest.Platform.OS, manifest.Platform.Architecture) + if manifest.Platform.Variant != "" { + platformStr += "/" + manifest.Platform.Variant + } + platformsFound[platformStr] = true + } + } + Expect(platformsFound["linux/amd64"]).To(BeTrue()) + Expect(platformsFound["linux/arm/v7"]).To(BeTrue()) + }) + }) +})