From 842629d6a6802434f1d103c579d42fc36cd8654e Mon Sep 17 00:00:00 2001 From: hellocn9 Date: Thu, 8 Jan 2026 22:50:26 +0800 Subject: [PATCH 1/4] feat(copy): Support copying artifacts across multiple platforms. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: hellocn9 --- cmd/oras/internal/option/platform.go | 61 +++++++- cmd/oras/root/cp.go | 215 ++++++++++++++++++++++++++- 2 files changed, 265 insertions(+), 11 deletions(-) diff --git a/cmd/oras/internal/option/platform.go b/cmd/oras/internal/option/platform.go index 78c4fcbf5..06a066bfb 100644 --- a/cmd/oras/internal/option/platform.go +++ b/cmd/oras/internal/option/platform.go @@ -29,6 +29,7 @@ import ( type Platform struct { platform string Platform *ocispec.Platform + Platforms []*ocispec.Platform // Added to support multiple platforms FlagDescription string } @@ -37,7 +38,7 @@ 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.StringVarP(&opts.platform, "platform", "", "", opts.FlagDescription+" in the form of `os[/arch][/variant][:os_version]` or comma-separated list for multiple platforms") } // Parse parses the input platform flag to an oci platform type. @@ -46,12 +47,62 @@ func (opts *Platform) Parse(*cobra.Command) error { return nil } + // Split by comma to support multiple platforms + platformStrings := strings.Split(opts.platform, ",") + if len(platformStrings) == 1 { + // Single platform case - existing behavior + return opts.parseSinglePlatform(opts.platform) + } + + // Multiple platforms case + 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) + } + + // Set the first platform as the primary one for backward compatibility + if len(opts.Platforms) > 0 { + opts.Platform = opts.Platforms[0] + } + + return nil +} + +// parseSinglePlatform maintains the original parsing behavior for a single platform +func (opts *Platform) parseSinglePlatform(platformStr string) error { // OS[/Arch[/Variant]][:OSVersion] // If Arch is not provided, will use GOARCH instead - var platformStr string + var platformPart string var p ocispec.Platform - platformStr, p.OSVersion, _ = strings.Cut(opts.platform, ":") - parts := strings.Split(platformStr, "/") + platformPart, p.OSVersion, _ = strings.Cut(platformStr, ":") + parts := strings.Split(platformPart, "/") switch len(parts) { case 3: p.Variant = parts[2] @@ -61,7 +112,7 @@ func (opts *Platform) Parse(*cobra.Command) error { case 1: p.Architecture = runtime.GOARCH default: - return fmt.Errorf("failed to parse platform %q: expected format os[/arch[/variant]]", opts.platform) + return fmt.Errorf("failed to parse platform %q: expected format os[/arch[/variant]]", platformStr) } p.OS = parts[0] if p.OS == "" { diff --git a/cmd/oras/root/cp.go b/cmd/oras/root/cp.go index 7852f47eb..61eacec53 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,20 +142,181 @@ 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) - desc, err := doCopy(ctx, statusHandler, src, dst, opts) + // 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) + } else { + // Original behavior for single platform or recursive mode + desc, err := doCopy(ctx, statusHandler, src, dst, opts) + if err != nil { + return err + } + + if from, err := digest.Parse(opts.From.Reference); err == nil && from != desc.Digest { + // correct source digest + opts.From.RawReference = fmt.Sprintf("%s@%s", opts.From.Path, desc.Digest.String()) + } + + if err := metadataHandler.OnCopied(&opts.BinaryTarget, desc); err != nil { + return err + } + + 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 + } + } + + 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 err + 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, fall back to single platform behavior with first platform + tempOpts := *opts + tempOpts.Platform.Platform = opts.Platform.Platforms[0] + desc, err := doCopy(ctx, statusHandler, src, dst, &tempOpts) + if err != nil { + return err + } + + if from, err := digest.Parse(opts.From.Reference); err == nil && from != desc.Digest { + opts.From.RawReference = fmt.Sprintf("%s@%s", opts.From.Path, desc.Digest.String()) + } + + if err := metadataHandler.OnCopied(&opts.BinaryTarget, desc); err != nil { + return err + } + + 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 + } + } + + return metadataHandler.Render() + } + + // For indexes/lists, fetch the index content + indexContent, err := content.FetchAll(ctx, src, root) + if err != nil { + return fmt.Errorf("failed to fetch index: %w", err) + } + + var index ocispec.Index + if err := json.Unmarshal(indexContent, &index); err != nil { + return fmt.Errorf("failed to parse index: %w", err) + } + + // Filter manifests based on the specified platforms + var filteredManifests []ocispec.Descriptor + for _, manifest := range index.Manifests { + if manifest.Platform != nil && matchesAnyPlatform(manifest.Platform, opts.Platform.Platforms) { + filteredManifests = append(filteredManifests, manifest) + } + } + + if len(filteredManifests) == 0 { + requestedPlatforms := opts.Platform.Platforms + var availablePlatforms []string + for _, manifest := range index.Manifests { + if manifest.Platform != nil { + availablePlatforms = append(availablePlatforms, fmt.Sprintf("%s/%s", manifest.Platform.OS, manifest.Platform.Architecture)) + } + } + availableDesc := "none" + if len(availablePlatforms) > 0 { + availableDesc = strings.Join(availablePlatforms, ", ") + } + return fmt.Errorf("no manifests match the requested platforms %v; available platforms in index: %s", requestedPlatforms, availableDesc) + } + + // Create a new index with only the filtered manifests + newIndex := ocispec.Index{ + Versioned: index.Versioned, + MediaType: index.MediaType, + Manifests: filteredManifests, + Annotations: index.Annotations, + } + + // Marshal the new index + newIndexContent, err := json.Marshal(newIndex) + if err != nil { + return fmt.Errorf("failed to marshal new index: %w", err) } - if from, err := digest.Parse(opts.From.Reference); err == nil && from != desc.Digest { - // correct source digest - opts.From.RawReference = fmt.Sprintf("%s@%s", opts.From.Path, desc.Digest.String()) + // Create a descriptor for the new index + newIndexDesc := ocispec.Descriptor{ + MediaType: index.MediaType, + Digest: digest.FromBytes(newIndexContent), + Size: int64(len(newIndexContent)), + Annotations: index.Annotations, } - if err := metadataHandler.OnCopied(&opts.BinaryTarget, desc); err != nil { + // 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 @@ -161,9 +326,47 @@ func runCopy(cmd *cobra.Command, opts *copyOptions) error { } } + // 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 { + for _, platform := range platforms { + if platformMatches(manifestPlatform, platform) { + return true + } + } + return false +} + +// platformMatches checks if two platforms match +func platformMatches(a, b *ocispec.Platform) bool { + if a.OS != b.OS || a.Architecture != b.Architecture { + return false + } + + // Variant is optional; only treat it as a mismatch if both variants are non-empty and different. + if a.Variant != "" && b.Variant != "" && a.Variant != b.Variant { + return false + } + + // OSVersion is optional; only treat it as a mismatch if both OSVersions are non-empty and different. + if a.OSVersion != "" && b.OSVersion != "" && a.OSVersion != b.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 From bd5a7b015e7a2e91916b28de36e73d4b40f01273 Mon Sep 17 00:00:00 2001 From: hellocn9 Date: Sun, 18 Jan 2026 16:04:36 +0800 Subject: [PATCH 2/4] refactor(copy): Optimize platform copying logic to support multi-platform filtering and error handling Signed-off-by: hellocn9 --- cmd/oras/internal/option/platform.go | 21 ++--- cmd/oras/root/cp.go | 115 ++++++++++++--------------- 2 files changed, 61 insertions(+), 75 deletions(-) diff --git a/cmd/oras/internal/option/platform.go b/cmd/oras/internal/option/platform.go index 06a066bfb..578ce5a5a 100644 --- a/cmd/oras/internal/option/platform.go +++ b/cmd/oras/internal/option/platform.go @@ -27,9 +27,9 @@ import ( // Platform option struct. type Platform struct { - platform string + platform []string Platform *ocispec.Platform - Platforms []*ocispec.Platform // Added to support multiple platforms + Platforms []*ocispec.Platform FlagDescription string } @@ -38,22 +38,25 @@ 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]` or comma-separated list for multiple platforms") + fs.StringSliceVarP(&opts.platform, "platform", "", []string{}, opts.FlagDescription+" in the form of `os[/arch][/variant][:os_version]` or comma-separated list for multiple platforms") } // 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 } - // Split by comma to support multiple platforms - platformStrings := strings.Split(opts.platform, ",") - if len(platformStrings) == 1 { + if len(opts.platform) == 1 { // Single platform case - existing behavior - return opts.parseSinglePlatform(opts.platform) + return opts.parseSinglePlatform(opts.platform[0]) } + return opts.parseMultiplePlatform(opts.platform) +} + +// parseMultiplePlatform parses multiple platforms +func (opts *Platform) parseMultiplePlatform(platformStrings []string) error { // Multiple platforms case opts.Platforms = make([]*ocispec.Platform, 0, len(platformStrings)) for _, platformStr := range platformStrings { @@ -133,5 +136,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", "", []string{}, "[Experimental] "+opts.FlagDescription+" in the form of `os[/arch][/variant][:os_version]`") } diff --git a/cmd/oras/root/cp.go b/cmd/oras/root/cp.go index 61eacec53..05dc652fb 100644 --- a/cmd/oras/root/cp.go +++ b/cmd/oras/root/cp.go @@ -146,33 +146,37 @@ func runCopy(cmd *cobra.Command, opts *copyOptions) error { 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) - } else { - // Original behavior for single platform or recursive mode - desc, err := doCopy(ctx, statusHandler, src, dst, opts) - if err != nil { - return err - } + } - if from, err := digest.Parse(opts.From.Reference); err == nil && from != desc.Digest { - // correct source digest - opts.From.RawReference = fmt.Sprintf("%s@%s", opts.From.Path, desc.Digest.String()) - } + // Handle single platform or recursive mode + return copySinglePlatformOrRecursive(ctx, statusHandler, metadataHandler, src, dst, opts) +} - if err := metadataHandler.OnCopied(&opts.BinaryTarget, desc); err != nil { - return err - } +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 + } - 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 - } - } + if from, err := digest.Parse(opts.From.Reference); err == nil && from != desc.Digest { + // correct source digest + opts.From.RawReference = fmt.Sprintf("%s@%s", opts.From.Path, desc.Digest.String()) + } + + if err := metadataHandler.OnCopied(&opts.BinaryTarget, desc); err != nil { + return err + } - return metadataHandler.Render() + 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 + } } + + return metadataHandler.Render() } // copyMultiplePlatforms handles copying when multiple platforms are specified @@ -188,32 +192,8 @@ func copyMultiplePlatforms(ctx context.Context, statusHandler status.CopyHandler // 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, fall back to single platform behavior with first platform - tempOpts := *opts - tempOpts.Platform.Platform = opts.Platform.Platforms[0] - desc, err := doCopy(ctx, statusHandler, src, dst, &tempOpts) - if err != nil { - return err - } - - if from, err := digest.Parse(opts.From.Reference); err == nil && from != desc.Digest { - opts.From.RawReference = fmt.Sprintf("%s@%s", opts.From.Path, desc.Digest.String()) - } - - if err := metadataHandler.OnCopied(&opts.BinaryTarget, desc); err != nil { - return err - } - - 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 - } - } - - return metadataHandler.Render() + // If not an index, return an error + return fmt.Errorf("source reference %s is not an index or manifest list", opts.From.Reference) } // For indexes/lists, fetch the index content @@ -227,36 +207,39 @@ func copyMultiplePlatforms(ctx context.Context, statusHandler status.CopyHandler return fmt.Errorf("failed to parse index: %w", err) } + var availablePlatforms []string // Filter manifests based on the specified platforms var filteredManifests []ocispec.Descriptor + matchedPlatforms := make(map[string]bool) for _, manifest := range index.Manifests { - if manifest.Platform != nil && matchesAnyPlatform(manifest.Platform, opts.Platform.Platforms) { + if manifest.Platform == nil { + continue + } + availablePlatforms = append(availablePlatforms, fmt.Sprintf("%s/%s", manifest.Platform.OS, manifest.Platform.Architecture)) + if matchesAnyPlatform(manifest.Platform, opts.Platform.Platforms) { filteredManifests = append(filteredManifests, manifest) + matchedPlatforms[fmt.Sprintf("%s/%s", manifest.Platform.OS, manifest.Platform.Architecture)] = true } } - if len(filteredManifests) == 0 { - requestedPlatforms := opts.Platform.Platforms - var availablePlatforms []string - for _, manifest := range index.Manifests { - if manifest.Platform != nil { - availablePlatforms = append(availablePlatforms, fmt.Sprintf("%s/%s", manifest.Platform.OS, manifest.Platform.Architecture)) + if len(filteredManifests) != len(opts.Platform.Platforms) { + + var unmatchedPlatforms []string + for _, platform := range opts.Platform.Platforms { + platformStr := fmt.Sprintf("%s/%s", platform.OS, platform.Architecture) + if !matchedPlatforms[platformStr] { + unmatchedPlatforms = append(unmatchedPlatforms, platformStr) } } - availableDesc := "none" - if len(availablePlatforms) > 0 { - availableDesc = strings.Join(availablePlatforms, ", ") - } - return fmt.Errorf("no manifests match the requested platforms %v; available platforms in index: %s", requestedPlatforms, availableDesc) + + // Return error with details about unmatched platforms + return fmt.Errorf("only %d of %d requested platforms were matched: unmatched platforms: [%s]; available platforms in index: [%s]", + len(filteredManifests), len(opts.Platform.Platforms), strings.Join(unmatchedPlatforms, ", "), strings.Join(availablePlatforms, ", ")) } // Create a new index with only the filtered manifests - newIndex := ocispec.Index{ - Versioned: index.Versioned, - MediaType: index.MediaType, - Manifests: filteredManifests, - Annotations: index.Annotations, - } + newIndex := index + newIndex.Manifests = filteredManifests // Marshal the new index newIndexContent, err := json.Marshal(newIndex) From 8b9cbc3782463faa025a2288cf522f28dee15e5e Mon Sep 17 00:00:00 2001 From: hellocn9 Date: Sun, 18 Jan 2026 16:46:11 +0800 Subject: [PATCH 3/4] test(copy): add related unit tests and E2E tests for multi-platform copying Signed-off-by: hellocn9 --- cmd/oras/root/cp_test.go | 374 +++++++++++++++++++++ test/e2e/suite/command/cp_multiplatform.go | 144 ++++++++ 2 files changed, 518 insertions(+) create mode 100644 test/e2e/suite/command/cp_multiplatform.go 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()) + }) + }) +}) From 70e0d2323e2f28cd49183b810145f074cbf67659 Mon Sep 17 00:00:00 2001 From: hellocn9 Date: Tue, 20 Jan 2026 11:30:58 +0800 Subject: [PATCH 4/4] refactor(copy): Optimize platform copying logic to support multi-platform filtering Signed-off-by: hellocn9 --- cmd/oras/internal/option/platform.go | 49 +--------- cmd/oras/internal/option/platform_test.go | 26 +++--- cmd/oras/root/cp.go | 108 ++++++++++++++-------- 3 files changed, 89 insertions(+), 94 deletions(-) diff --git a/cmd/oras/internal/option/platform.go b/cmd/oras/internal/option/platform.go index 578ce5a5a..d0d618b8f 100644 --- a/cmd/oras/internal/option/platform.go +++ b/cmd/oras/internal/option/platform.go @@ -38,7 +38,7 @@ func (opts *Platform) ApplyFlags(fs *pflag.FlagSet) { if opts.FlagDescription == "" { opts.FlagDescription = "request platform" } - fs.StringSliceVarP(&opts.platform, "platform", "", []string{}, opts.FlagDescription+" in the form of `os[/arch][/variant][:os_version]` or comma-separated list for multiple platforms") + 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. @@ -46,25 +46,17 @@ func (opts *Platform) Parse(*cobra.Command) error { if len(opts.platform) == 0 { return nil } - - if len(opts.platform) == 1 { - // Single platform case - existing behavior - return opts.parseSinglePlatform(opts.platform[0]) - } - - return opts.parseMultiplePlatform(opts.platform) + return opts.parsePlatform(opts.platform) } -// parseMultiplePlatform parses multiple platforms -func (opts *Platform) parseMultiplePlatform(platformStrings []string) error { - // Multiple platforms case +// 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, "/") @@ -94,37 +86,6 @@ func (opts *Platform) parseMultiplePlatform(platformStrings []string) error { if len(opts.Platforms) > 0 { opts.Platform = opts.Platforms[0] } - - return nil -} - -// parseSinglePlatform maintains the original parsing behavior for a single platform -func (opts *Platform) parseSinglePlatform(platformStr string) error { - // OS[/Arch[/Variant]][:OSVersion] - // If Arch is not provided, will use GOARCH instead - var platformPart string - var p ocispec.Platform - platformPart, p.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") - } - opts.Platform = &p return nil } @@ -136,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.StringSliceVarP(&opts.platform, "artifact-platform", "", []string{}, "[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 05dc652fb..3157bb295 100644 --- a/cmd/oras/root/cp.go +++ b/cmd/oras/root/cp.go @@ -196,47 +196,81 @@ func copyMultiplePlatforms(ctx context.Context, statusHandler status.CopyHandler 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 fmt.Errorf("failed to fetch index: %w", err) + 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 fmt.Errorf("failed to parse index: %w", err) + if err = json.Unmarshal(indexContent, &index); err != nil { + return ocispec.Index{}, nil, fmt.Errorf("failed to parse index: %w", err) } - var availablePlatforms []string // Filter manifests based on the specified platforms + var availablePlatforms []string var filteredManifests []ocispec.Descriptor - matchedPlatforms := make(map[string]bool) for _, manifest := range index.Manifests { if manifest.Platform == nil { continue } - availablePlatforms = append(availablePlatforms, fmt.Sprintf("%s/%s", manifest.Platform.OS, manifest.Platform.Architecture)) + 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) - matchedPlatforms[fmt.Sprintf("%s/%s", manifest.Platform.OS, manifest.Platform.Architecture)] = true } } - if len(filteredManifests) != len(opts.Platform.Platforms) { - - var unmatchedPlatforms []string - for _, platform := range opts.Platform.Platforms { - platformStr := fmt.Sprintf("%s/%s", platform.OS, platform.Architecture) - if !matchedPlatforms[platformStr] { - unmatchedPlatforms = append(unmatchedPlatforms, platformStr) - } + // 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 + }) } - - // Return error with details about unmatched platforms - return fmt.Errorf("only %d of %d requested platforms were matched: unmatched platforms: [%s]; available platforms in index: [%s]", - len(filteredManifests), len(opts.Platform.Platforms), strings.Join(unmatchedPlatforms, ", "), strings.Join(availablePlatforms, ", ")) + 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 @@ -261,7 +295,6 @@ func copyMultiplePlatforms(ctx context.Context, statusHandler status.CopyHandler 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 @@ -282,19 +315,19 @@ func copyMultiplePlatforms(ctx context.Context, statusHandler status.CopyHandler // 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 { + 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 { + 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 { + if err = dst.Tag(ctx, newIndexDesc, opts.To.Reference); err != nil { return fmt.Errorf("failed to tag new index: %w", err) } } @@ -313,37 +346,38 @@ func copyMultiplePlatforms(ctx context.Context, statusHandler status.CopyHandler 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 { + 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 { - for _, platform := range platforms { - if platformMatches(manifestPlatform, platform) { - return true - } - } - return false + return slices.ContainsFunc(platforms, func(platform *ocispec.Platform) bool { + return platformMatches(manifestPlatform, platform) + }) } // platformMatches checks if two platforms match -func platformMatches(a, b *ocispec.Platform) bool { - if a.OS != b.OS || a.Architecture != b.Architecture { +func platformMatches(manifestPlatform, targetPlatform *ocispec.Platform) bool { + if manifestPlatform.OS != targetPlatform.OS || manifestPlatform.Architecture != targetPlatform.Architecture { return false } - // Variant is optional; only treat it as a mismatch if both variants are non-empty and different. - if a.Variant != "" && b.Variant != "" && a.Variant != b.Variant { + // 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 a.OSVersion != "" && b.OSVersion != "" && a.OSVersion != b.OSVersion { + if manifestPlatform.OSVersion != "" && targetPlatform.OSVersion != "" && manifestPlatform.OSVersion != targetPlatform.OSVersion { return false }