From d9d7624d157293038b9a8cb64b9aaca19cd9d41f Mon Sep 17 00:00:00 2001 From: "REDMOND\\zoeyli" Date: Wed, 20 Jul 2022 10:05:19 +0800 Subject: [PATCH] feat: Support platform selection on Copy Signed-off-by: REDMOND\zoeyli --- copy.go | 37 ++++++++++ copy_test.go | 104 ++++++++++++++++++++++------- internal/platform/platform.go | 67 +++++++++++++++++++ internal/platform/platform_test.go | 102 ++++++++++++++++++++++++++++ 4 files changed, 285 insertions(+), 25 deletions(-) create mode 100644 internal/platform/platform.go create mode 100644 internal/platform/platform_test.go diff --git a/copy.go b/copy.go index 9fc66ff17..a391ee9a2 100644 --- a/copy.go +++ b/copy.go @@ -27,7 +27,9 @@ import ( "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/cas" "oras.land/oras-go/v2/internal/descriptor" + "oras.land/oras-go/v2/internal/docker" "oras.land/oras-go/v2/internal/graph" + "oras.land/oras-go/v2/internal/platform" "oras.land/oras-go/v2/internal/registryutil" "oras.land/oras-go/v2/internal/status" "oras.land/oras-go/v2/registry" @@ -54,6 +56,41 @@ type CopyOptions struct { MapRoot func(ctx context.Context, src content.Storage, root ocispec.Descriptor) (ocispec.Descriptor, error) } +// selectPlatform implements platform filter and returns the descriptor of +// the first matched manifest from the manifest list / image index +func (o *CopyOptions) selectPlatform(ctx context.Context, src content.Storage, root ocispec.Descriptor, p *ocispec.Platform) (ocispec.Descriptor, error) { + if root.MediaType == docker.MediaTypeManifestList || root.MediaType == ocispec.MediaTypeImageIndex { + manifests, err := content.Successors(ctx, src, root) + if err != nil { + return ocispec.Descriptor{}, errdef.ErrNotFound + } + + // platform filter + for _, m := range manifests { + matched := platform.MatchPlatform(m.Platform, p) + if matched { + return m, nil + } + } + return ocispec.Descriptor{}, errdef.ErrNotFound + } + return ocispec.Descriptor{}, errdef.ErrNotFound +} + +// AddPlatformFilter adds the selectPlatform func into the MapRoot func +func (o *CopyOptions) AddPlatformFilter(p *ocispec.Platform) { + mapRoot := o.MapRoot + o.MapRoot = func(ctx context.Context, src content.Storage, root ocispec.Descriptor) (ocispec.Descriptor, error) { + var err error + if mapRoot != nil { + if root, err = mapRoot(ctx, src, root); err != nil { + return ocispec.Descriptor{}, err + } + } + return o.selectPlatform(ctx, src, root, p) + } +} + // CopyGraphOptions contains parameters for oras.CopyGraph. type CopyGraphOptions struct { // Concurrency limits the maximum number of concurrent copy tasks. diff --git a/copy_test.go b/copy_test.go index 5ac567261..0588394ae 100644 --- a/copy_test.go +++ b/copy_test.go @@ -369,7 +369,7 @@ func TestCopy_WithOptions(t *testing.T) { Size: int64(len(blob)), }) } - appendManifest := func(arc, os string, mediaType string, blob []byte) { + appendManifest := func(arc, os, variant string, mediaType string, blob []byte) { blobs = append(blobs, blob) descs = append(descs, ocispec.Descriptor{ MediaType: mediaType, @@ -378,10 +378,11 @@ func TestCopy_WithOptions(t *testing.T) { Platform: &ocispec.Platform{ Architecture: arc, OS: os, + Variant: variant, }, }) } - generateManifest := func(arc, os string, config ocispec.Descriptor, layers ...ocispec.Descriptor) { + generateManifest := func(arc, os, variant string, config ocispec.Descriptor, layers ...ocispec.Descriptor) { manifest := ocispec.Manifest{ Config: config, Layers: layers, @@ -390,7 +391,7 @@ func TestCopy_WithOptions(t *testing.T) { if err != nil { t.Fatal(err) } - appendManifest(arc, os, ocispec.MediaTypeImageManifest, manifestJSON) + appendManifest(arc, os, variant, ocispec.MediaTypeImageManifest, manifestJSON) } generateIndex := func(manifests ...ocispec.Descriptor) { index := ocispec.Index{ @@ -403,13 +404,15 @@ func TestCopy_WithOptions(t *testing.T) { appendBlob(ocispec.MediaTypeImageIndex, indexJSON) } - appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 - appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 - appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 - generateManifest("test-arc-1", "test-os-1", descs[0], descs[1:3]...) // Blob 3 - appendBlob(ocispec.MediaTypeImageLayer, []byte("hello")) // Blob 4 - generateManifest("test-arc-2", "test-os-2", descs[0], descs[4]) // Blob 5 - generateIndex(descs[3], descs[5]) // Blob 6 + appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0 + appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1 + appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2 + generateManifest("test-arc-1", "test-os-1", "v1", descs[0], descs[1:3]...) // Blob 3 + appendBlob(ocispec.MediaTypeImageLayer, []byte("hello1")) // Blob 4 + generateManifest("test-arc-2", "test-os-2", "v1", descs[0], descs[4]) // Blob 5 + appendBlob(ocispec.MediaTypeImageLayer, []byte("hello2")) // Blob 6 + generateManifest("test-arc-1", "test-os-1", "v2", descs[0], descs[6]) // Blob 7 + generateIndex(descs[3], descs[5], descs[7]) // Blob 8 ctx := context.Background() for i := range blobs { @@ -419,7 +422,7 @@ func TestCopy_WithOptions(t *testing.T) { } } - root := descs[6] + root := descs[8] ref := "foobar" err := src.Tag(ctx, root, ref) if err != nil { @@ -469,20 +472,6 @@ func TestCopy_WithOptions(t *testing.T) { preCopyCount := int64(0) postCopyCount := int64(0) opts = oras.CopyOptions{ - MapRoot: func(ctx context.Context, src content.Storage, root ocispec.Descriptor) (ocispec.Descriptor, error) { - manifests, err := content.Successors(ctx, src, root) - if err != nil { - return ocispec.Descriptor{}, errdef.ErrNotFound - } - - // platform filter - for _, m := range manifests { - if m.Platform.Architecture == "test-arc-2" && m.Platform.OS == "test-os-2" { - return m, nil - } - } - return ocispec.Descriptor{}, errdef.ErrNotFound - }, CopyGraphOptions: oras.CopyGraphOptions{ PreCopy: func(ctx context.Context, desc ocispec.Descriptor) error { atomic.AddInt64(&preCopyCount, 1) @@ -494,6 +483,11 @@ func TestCopy_WithOptions(t *testing.T) { }, }, } + targetPlatform := ocispec.Platform{ + Architecture: "test-arc-2", + OS: "test-os-2", + } + opts.AddPlatformFilter(&targetPlatform) wantDesc := descs[5] gotDesc, err = oras.Copy(ctx, src, ref, dst, "", opts) if err != nil { @@ -531,6 +525,66 @@ func TestCopy_WithOptions(t *testing.T) { t.Errorf("count(PostCopy()) = %v, want %v", got, want) } + // test copy with platform filter, if the multiple manifests match the required platform, + // return the first matching entry + dst = memory.New() + targetPlatform = ocispec.Platform{ + Architecture: "test-arc-1", + OS: "test-os-1", + } + opts = oras.CopyOptions{} + opts.AddPlatformFilter(&targetPlatform) + wantDesc = descs[3] + gotDesc, err = oras.Copy(ctx, src, ref, dst, "", opts) + if err != nil { + t.Fatalf("Copy() error = %v, wantErr %v", err, false) + } + if !reflect.DeepEqual(gotDesc, wantDesc) { + t.Errorf("Copy() = %v, want %v", gotDesc, wantDesc) + } + + // verify contents + for i, desc := range append([]ocispec.Descriptor{descs[0]}, descs[1:3]...) { + exists, err := dst.Exists(ctx, desc) + if err != nil { + t.Fatalf("dst.Exists(%d) error = %v", i, err) + } + if !exists { + t.Errorf("dst.Exists(%d) = %v, want %v", i, exists, true) + } + } + + // verify tag + gotDesc, err = dst.Resolve(ctx, ref) + if err != nil { + t.Fatal("dst.Resolve() error =", err) + } + if !reflect.DeepEqual(gotDesc, wantDesc) { + t.Errorf("dst.Resolve() = %v, want %v", gotDesc, wantDesc) + } + + // test copy with platform filter and exisiting MapRoot func, but no matching node can be found + dst = memory.New() + opts = oras.CopyOptions{ + MapRoot: func(ctx context.Context, src content.Storage, root ocispec.Descriptor) (ocispec.Descriptor, error) { + if root.MediaType == ocispec.MediaTypeImageIndex { + return root, nil + } else { + return ocispec.Descriptor{}, errdef.ErrNotFound + } + }, + } + targetPlatform = ocispec.Platform{ + Architecture: "test-arc-1", + OS: "test-os-3", + } + opts.AddPlatformFilter(&targetPlatform) + + _, err = oras.Copy(ctx, src, ref, dst, "", opts) + if !errors.Is(err, errdef.ErrNotFound) { + t.Fatalf("Copy() error = %v, wantErr %v", err, errdef.ErrNotFound) + } + // test copy with root filter, but no matching node can be found dst = memory.New() opts = oras.CopyOptions{ diff --git a/internal/platform/platform.go b/internal/platform/platform.go new file mode 100644 index 000000000..c081896c7 --- /dev/null +++ b/internal/platform/platform.go @@ -0,0 +1,67 @@ +/* +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 platform + +import ( + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// MatchPlatform checks whether the current platform matches the target platform. +// MatchPlatform will return true if: +// - Architecture and OS exactly match. +// - Variant and OSVersion exactly match if target platform provided. +// - OSFeatures of the target platform are the subsets of the OSFeatures array +// of the current platform. +// Note: Variant, OSVersion and OSFeatures are optional fields, will skip the +// comparison if the target platform does not provide specfic value. +func MatchPlatform(curr *ocispec.Platform, target *ocispec.Platform) bool { + if curr.Architecture != target.Architecture || curr.OS != target.OS { + return false + } + + if target.OSVersion != "" && curr.OSVersion != target.OSVersion { + return false + } + + if target.Variant != "" && curr.Variant != target.Variant { + return false + } + + if len(target.OSFeatures) != 0 && !isSubset(curr.OSFeatures, target.OSFeatures) { + return false + } + + return true +} + +// isSubset returns true if all items in target slice are present in current slice +func isSubset(curr, target []string) bool { + if len(curr) < len(target) { + return false + } + + set := make(map[string]bool) + for _, v := range curr { + set[v] = true + } + for _, v := range target { + if _, ok := set[v]; !ok { + return false + } + } + + return true +} diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go new file mode 100644 index 000000000..0bad39136 --- /dev/null +++ b/internal/platform/platform_test.go @@ -0,0 +1,102 @@ +/* +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 platform + +import ( + "encoding/json" + "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +func TestMatches(t *testing.T) { + tests := []struct { + curr ocispec.Platform + target ocispec.Platform + want bool + }{{ + ocispec.Platform{Architecture: "amd64", OS: "linux"}, + ocispec.Platform{Architecture: "amd64", OS: "linux"}, + true, + }, { + ocispec.Platform{Architecture: "amd64", OS: "linux"}, + ocispec.Platform{Architecture: "amd64", OS: "LINUX"}, + false, + }, { + ocispec.Platform{Architecture: "amd64", OS: "linux"}, + ocispec.Platform{Architecture: "arm64", OS: "linux"}, + false, + }, { + ocispec.Platform{Architecture: "arm", OS: "linux"}, + ocispec.Platform{Architecture: "arm", OS: "linux", Variant: "v7"}, + false, + }, { + ocispec.Platform{Architecture: "arm", OS: "linux", Variant: "v7"}, + ocispec.Platform{Architecture: "arm", OS: "linux"}, + true, + }, { + ocispec.Platform{Architecture: "arm", OS: "linux", Variant: "v7"}, + ocispec.Platform{Architecture: "arm", OS: "linux", Variant: "v7"}, + true, + }, { + ocispec.Platform{Architecture: "amd64", OS: "windows", OSVersion: "10.0.20348.768"}, + ocispec.Platform{Architecture: "amd64", OS: "windows", OSVersion: "10.0.20348.700"}, + false, + }, { + ocispec.Platform{Architecture: "amd64", OS: "windows"}, + ocispec.Platform{Architecture: "amd64", OS: "windows", OSVersion: "10.0.20348.768"}, + false, + }, { + ocispec.Platform{Architecture: "amd64", OS: "windows", OSVersion: "10.0.20348.768"}, + ocispec.Platform{Architecture: "amd64", OS: "windows"}, + true, + }, { + ocispec.Platform{Architecture: "amd64", OS: "windows", OSVersion: "10.0.20348.768"}, + ocispec.Platform{Architecture: "amd64", OS: "windows", OSVersion: "10.0.20348.768"}, + true, + }, { + ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"a", "d"}}, + ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"a", "c"}}, + false, + }, { + ocispec.Platform{Architecture: "arm", OS: "linux"}, + ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"a"}}, + false, + }, { + ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"a"}}, + ocispec.Platform{Architecture: "arm", OS: "linux"}, + true, + }, { + ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"a", "b"}}, + ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"a", "b"}}, + true, + }, { + ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"a", "d", "c", "b"}}, + ocispec.Platform{Architecture: "arm", OS: "linux", OSFeatures: []string{"d", "c", "a", "b"}}, + true, + }} + + for _, tt := range tests { + currPlatforJSON, _ := json.Marshal(tt.curr) + targetPlatforJSON, _ := json.Marshal(tt.target) + name := string(currPlatforJSON) + string(targetPlatforJSON) + t.Run(name, func(t *testing.T) { + if got := MatchPlatform(&tt.curr, &tt.target); got != tt.want { + t.Errorf("Matches() = %v, want %v", got, tt.want) + } + }) + } +}