Skip to content

Commit 842629d

Browse files
hellocn9Copilot
andcommitted
feat(copy): Support copying artifacts across multiple platforms.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: hellocn9 <zhangyancn9@126.com>
1 parent 992543e commit 842629d

2 files changed

Lines changed: 265 additions & 11 deletions

File tree

cmd/oras/internal/option/platform.go

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
type Platform struct {
3030
platform string
3131
Platform *ocispec.Platform
32+
Platforms []*ocispec.Platform // Added to support multiple platforms
3233
FlagDescription string
3334
}
3435

@@ -37,7 +38,7 @@ func (opts *Platform) ApplyFlags(fs *pflag.FlagSet) {
3738
if opts.FlagDescription == "" {
3839
opts.FlagDescription = "request platform"
3940
}
40-
fs.StringVarP(&opts.platform, "platform", "", "", opts.FlagDescription+" in the form of `os[/arch][/variant][:os_version]`")
41+
fs.StringVarP(&opts.platform, "platform", "", "", opts.FlagDescription+" in the form of `os[/arch][/variant][:os_version]` or comma-separated list for multiple platforms")
4142
}
4243

4344
// Parse parses the input platform flag to an oci platform type.
@@ -46,12 +47,62 @@ func (opts *Platform) Parse(*cobra.Command) error {
4647
return nil
4748
}
4849

50+
// Split by comma to support multiple platforms
51+
platformStrings := strings.Split(opts.platform, ",")
52+
if len(platformStrings) == 1 {
53+
// Single platform case - existing behavior
54+
return opts.parseSinglePlatform(opts.platform)
55+
}
56+
57+
// Multiple platforms case
58+
opts.Platforms = make([]*ocispec.Platform, 0, len(platformStrings))
59+
for _, platformStr := range platformStrings {
60+
platformStr = strings.TrimSpace(platformStr)
61+
if platformStr == "" {
62+
continue
63+
}
64+
65+
var p ocispec.Platform
66+
platformPart, osVersion, _ := strings.Cut(platformStr, ":")
67+
parts := strings.Split(platformPart, "/")
68+
switch len(parts) {
69+
case 3:
70+
p.Variant = parts[2]
71+
fallthrough
72+
case 2:
73+
p.Architecture = parts[1]
74+
case 1:
75+
p.Architecture = runtime.GOARCH
76+
default:
77+
return fmt.Errorf("failed to parse platform %q: expected format os[/arch[/variant]]", platformStr)
78+
}
79+
p.OS = parts[0]
80+
if p.OS == "" {
81+
return fmt.Errorf("invalid platform: OS cannot be empty")
82+
}
83+
if p.Architecture == "" {
84+
return fmt.Errorf("invalid platform: Architecture cannot be empty")
85+
}
86+
p.OSVersion = osVersion
87+
opts.Platforms = append(opts.Platforms, &p)
88+
}
89+
90+
// Set the first platform as the primary one for backward compatibility
91+
if len(opts.Platforms) > 0 {
92+
opts.Platform = opts.Platforms[0]
93+
}
94+
95+
return nil
96+
}
97+
98+
// parseSinglePlatform maintains the original parsing behavior for a single platform
99+
func (opts *Platform) parseSinglePlatform(platformStr string) error {
49100
// OS[/Arch[/Variant]][:OSVersion]
50101
// If Arch is not provided, will use GOARCH instead
51-
var platformStr string
102+
var platformPart string
52103
var p ocispec.Platform
53-
platformStr, p.OSVersion, _ = strings.Cut(opts.platform, ":")
54-
parts := strings.Split(platformStr, "/")
104+
platformPart, p.OSVersion, _ = strings.Cut(platformStr, ":")
105+
parts := strings.Split(platformPart, "/")
55106
switch len(parts) {
56107
case 3:
57108
p.Variant = parts[2]
@@ -61,7 +112,7 @@ func (opts *Platform) Parse(*cobra.Command) error {
61112
case 1:
62113
p.Architecture = runtime.GOARCH
63114
default:
64-
return fmt.Errorf("failed to parse platform %q: expected format os[/arch[/variant]]", opts.platform)
115+
return fmt.Errorf("failed to parse platform %q: expected format os[/arch[/variant]]", platformStr)
65116
}
66117
p.OS = parts[0]
67118
if p.OS == "" {

cmd/oras/root/cp.go

Lines changed: 209 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"oras.land/oras/cmd/oras/internal/argument"
3434
"oras.land/oras/cmd/oras/internal/command"
3535
"oras.land/oras/cmd/oras/internal/display"
36+
"oras.land/oras/cmd/oras/internal/display/metadata"
3637
"oras.land/oras/cmd/oras/internal/display/status"
3738
oerrors "oras.land/oras/cmd/oras/internal/errors"
3839
"oras.land/oras/cmd/oras/internal/option"
@@ -85,6 +86,9 @@ Example - Copy an artifact and referrers using specific methods for the Referrer
8586
Example - Copy certain platform of an artifact:
8687
oras cp --platform linux/arm/v5 localhost:5000/net-monitor:v1 localhost:6000/net-monitor-copy:v1
8788
89+
Example - Copy certain platforms of an artifact:
90+
oras cp --platform linux/amd64,linux/arm64,linux/arm/v7 localhost:5000/net-monitor:v1 localhost:6000/net-monitor-copy:v1
91+
8892
Example - Copy an artifact with multiple tags:
8993
oras cp localhost:5000/net-monitor:v1 localhost:6000/net-monitor-copy:tag1,tag2,tag3
9094
@@ -138,20 +142,181 @@ func runCopy(cmd *cobra.Command, opts *copyOptions) error {
138142
ctx = registryutil.WithScopeHint(ctx, dst, auth.ActionPull, auth.ActionPush)
139143
statusHandler, metadataHandler := display.NewCopyHandler(opts.Printer, opts.TTY, dst)
140144

141-
desc, err := doCopy(ctx, statusHandler, src, dst, opts)
145+
// Check if multiple platforms are specified
146+
if len(opts.Platform.Platforms) > 1 && !opts.recursive {
147+
// Handle multiple platforms - copy manifests that match the specified platforms
148+
return copyMultiplePlatforms(ctx, statusHandler, metadataHandler, src, dst, opts)
149+
} else {
150+
// Original behavior for single platform or recursive mode
151+
desc, err := doCopy(ctx, statusHandler, src, dst, opts)
152+
if err != nil {
153+
return err
154+
}
155+
156+
if from, err := digest.Parse(opts.From.Reference); err == nil && from != desc.Digest {
157+
// correct source digest
158+
opts.From.RawReference = fmt.Sprintf("%s@%s", opts.From.Path, desc.Digest.String())
159+
}
160+
161+
if err := metadataHandler.OnCopied(&opts.BinaryTarget, desc); err != nil {
162+
return err
163+
}
164+
165+
if len(opts.extraRefs) != 0 {
166+
tagNOpts := oras.DefaultTagNOptions
167+
tagNOpts.Concurrency = opts.concurrency
168+
tagListener := listener.NewTaggedListener(dst, metadataHandler.OnTagged)
169+
if _, err = oras.TagN(ctx, tagListener, opts.To.Reference, opts.extraRefs, tagNOpts); err != nil {
170+
return err
171+
}
172+
}
173+
174+
return metadataHandler.Render()
175+
}
176+
}
177+
178+
// copyMultiplePlatforms handles copying when multiple platforms are specified
179+
func copyMultiplePlatforms(ctx context.Context, statusHandler status.CopyHandler, metadataHandler metadata.CopyHandler, src oras.ReadOnlyGraphTarget, dst oras.GraphTarget, opts *copyOptions) error {
180+
// Resolve the source reference to get the root descriptor
181+
resolveOpts := oras.DefaultResolveOptions
182+
// We don't set TargetPlatform here since we want to get the full index/list
183+
root, err := oras.Resolve(ctx, src, opts.From.Reference, resolveOpts)
142184
if err != nil {
143-
return err
185+
return fmt.Errorf("failed to resolve %s: %w", opts.From.Reference, err)
186+
}
187+
188+
// Check if the resolved descriptor is an index/manifest list
189+
isIndex := root.MediaType == ocispec.MediaTypeImageIndex || root.MediaType == docker.MediaTypeManifestList
190+
if !isIndex {
191+
// If not an index, fall back to single platform behavior with first platform
192+
tempOpts := *opts
193+
tempOpts.Platform.Platform = opts.Platform.Platforms[0]
194+
desc, err := doCopy(ctx, statusHandler, src, dst, &tempOpts)
195+
if err != nil {
196+
return err
197+
}
198+
199+
if from, err := digest.Parse(opts.From.Reference); err == nil && from != desc.Digest {
200+
opts.From.RawReference = fmt.Sprintf("%s@%s", opts.From.Path, desc.Digest.String())
201+
}
202+
203+
if err := metadataHandler.OnCopied(&opts.BinaryTarget, desc); err != nil {
204+
return err
205+
}
206+
207+
if len(opts.extraRefs) != 0 {
208+
tagNOpts := oras.DefaultTagNOptions
209+
tagNOpts.Concurrency = opts.concurrency
210+
tagListener := listener.NewTaggedListener(dst, metadataHandler.OnTagged)
211+
if _, err = oras.TagN(ctx, tagListener, opts.To.Reference, opts.extraRefs, tagNOpts); err != nil {
212+
return err
213+
}
214+
}
215+
216+
return metadataHandler.Render()
217+
}
218+
219+
// For indexes/lists, fetch the index content
220+
indexContent, err := content.FetchAll(ctx, src, root)
221+
if err != nil {
222+
return fmt.Errorf("failed to fetch index: %w", err)
223+
}
224+
225+
var index ocispec.Index
226+
if err := json.Unmarshal(indexContent, &index); err != nil {
227+
return fmt.Errorf("failed to parse index: %w", err)
228+
}
229+
230+
// Filter manifests based on the specified platforms
231+
var filteredManifests []ocispec.Descriptor
232+
for _, manifest := range index.Manifests {
233+
if manifest.Platform != nil && matchesAnyPlatform(manifest.Platform, opts.Platform.Platforms) {
234+
filteredManifests = append(filteredManifests, manifest)
235+
}
236+
}
237+
238+
if len(filteredManifests) == 0 {
239+
requestedPlatforms := opts.Platform.Platforms
240+
var availablePlatforms []string
241+
for _, manifest := range index.Manifests {
242+
if manifest.Platform != nil {
243+
availablePlatforms = append(availablePlatforms, fmt.Sprintf("%s/%s", manifest.Platform.OS, manifest.Platform.Architecture))
244+
}
245+
}
246+
availableDesc := "none"
247+
if len(availablePlatforms) > 0 {
248+
availableDesc = strings.Join(availablePlatforms, ", ")
249+
}
250+
return fmt.Errorf("no manifests match the requested platforms %v; available platforms in index: %s", requestedPlatforms, availableDesc)
251+
}
252+
253+
// Create a new index with only the filtered manifests
254+
newIndex := ocispec.Index{
255+
Versioned: index.Versioned,
256+
MediaType: index.MediaType,
257+
Manifests: filteredManifests,
258+
Annotations: index.Annotations,
259+
}
260+
261+
// Marshal the new index
262+
newIndexContent, err := json.Marshal(newIndex)
263+
if err != nil {
264+
return fmt.Errorf("failed to marshal new index: %w", err)
144265
}
145266

146-
if from, err := digest.Parse(opts.From.Reference); err == nil && from != desc.Digest {
147-
// correct source digest
148-
opts.From.RawReference = fmt.Sprintf("%s@%s", opts.From.Path, desc.Digest.String())
267+
// Create a descriptor for the new index
268+
newIndexDesc := ocispec.Descriptor{
269+
MediaType: index.MediaType,
270+
Digest: digest.FromBytes(newIndexContent),
271+
Size: int64(len(newIndexContent)),
272+
Annotations: index.Annotations,
149273
}
150274

151-
if err := metadataHandler.OnCopied(&opts.BinaryTarget, desc); err != nil {
275+
// Prepare copy options
276+
extendedCopyGraphOptions := oras.DefaultExtendedCopyGraphOptions
277+
extendedCopyGraphOptions.Concurrency = opts.concurrency
278+
extendedCopyGraphOptions.FindPredecessors = func(ctx context.Context, src content.ReadOnlyGraphStorage, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
279+
return registry.Referrers(ctx, src, desc, "")
280+
}
281+
282+
if mountRepo, canMount := getMountPoint(src, dst, opts); canMount {
283+
extendedCopyGraphOptions.MountFrom = func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) {
284+
return []string{mountRepo}, nil
285+
}
286+
}
287+
dst, err = statusHandler.StartTracking(dst)
288+
if err != nil {
152289
return err
153290
}
291+
defer func() {
292+
_ = statusHandler.StopTracking()
293+
}()
294+
extendedCopyGraphOptions.OnCopySkipped = statusHandler.OnCopySkipped
295+
extendedCopyGraphOptions.PreCopy = statusHandler.PreCopy
296+
extendedCopyGraphOptions.PostCopy = statusHandler.PostCopy
297+
extendedCopyGraphOptions.OnMounted = statusHandler.OnMounted
298+
299+
// Copy all matching manifests and their content
300+
for _, manifestDesc := range filteredManifests {
301+
// Copy the manifest itself
302+
if err := oras.CopyGraph(ctx, src, dst, manifestDesc, extendedCopyGraphOptions.CopyGraphOptions); err != nil {
303+
return fmt.Errorf("failed to copy manifest %s: %w", manifestDesc.Digest, err)
304+
}
305+
}
306+
307+
// Push the new index to the destination
308+
if err := dst.Push(ctx, newIndexDesc, strings.NewReader(string(newIndexContent))); err != nil {
309+
return fmt.Errorf("failed to push new index: %w", err)
310+
}
311+
312+
// Tag the new index if needed
313+
if opts.To.Reference != "" {
314+
if err := dst.Tag(ctx, newIndexDesc, opts.To.Reference); err != nil {
315+
return fmt.Errorf("failed to tag new index: %w", err)
316+
}
317+
}
154318

319+
// Handle extra references
155320
if len(opts.extraRefs) != 0 {
156321
tagNOpts := oras.DefaultTagNOptions
157322
tagNOpts.Concurrency = opts.concurrency
@@ -161,9 +326,47 @@ func runCopy(cmd *cobra.Command, opts *copyOptions) error {
161326
}
162327
}
163328

329+
// Update reference if needed
330+
if from, err := digest.Parse(opts.From.Reference); err == nil && from != newIndexDesc.Digest {
331+
opts.From.RawReference = fmt.Sprintf("%s@%s", opts.From.Path, newIndexDesc.Digest.String())
332+
}
333+
334+
if err := metadataHandler.OnCopied(&opts.BinaryTarget, newIndexDesc); err != nil {
335+
return err
336+
}
337+
164338
return metadataHandler.Render()
165339
}
166340

341+
// matchesAnyPlatform checks if a manifest platform matches any of the specified platforms
342+
func matchesAnyPlatform(manifestPlatform *ocispec.Platform, platforms []*ocispec.Platform) bool {
343+
for _, platform := range platforms {
344+
if platformMatches(manifestPlatform, platform) {
345+
return true
346+
}
347+
}
348+
return false
349+
}
350+
351+
// platformMatches checks if two platforms match
352+
func platformMatches(a, b *ocispec.Platform) bool {
353+
if a.OS != b.OS || a.Architecture != b.Architecture {
354+
return false
355+
}
356+
357+
// Variant is optional; only treat it as a mismatch if both variants are non-empty and different.
358+
if a.Variant != "" && b.Variant != "" && a.Variant != b.Variant {
359+
return false
360+
}
361+
362+
// OSVersion is optional; only treat it as a mismatch if both OSVersions are non-empty and different.
363+
if a.OSVersion != "" && b.OSVersion != "" && a.OSVersion != b.OSVersion {
364+
return false
365+
}
366+
367+
return true
368+
}
369+
167370
func doCopy(ctx context.Context, copyHandler status.CopyHandler, src oras.ReadOnlyGraphTarget, dst oras.GraphTarget, opts *copyOptions) (desc ocispec.Descriptor, err error) {
168371
// Prepare copy options
169372
extendedCopyGraphOptions := oras.DefaultExtendedCopyGraphOptions

0 commit comments

Comments
 (0)