Skip to content

Commit

Permalink
feat: Support platform selection on Copy
Browse files Browse the repository at this point in the history
Signed-off-by: REDMOND\zoeyli <zoeyli@microsoft.com>
  • Loading branch information
lizMSFT committed Jul 20, 2022
1 parent 4660638 commit d9d7624
Show file tree
Hide file tree
Showing 4 changed files with 285 additions and 25 deletions.
37 changes: 37 additions & 0 deletions copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down
104 changes: 79 additions & 25 deletions copy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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{
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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{
Expand Down
67 changes: 67 additions & 0 deletions internal/platform/platform.go
Original file line number Diff line number Diff line change
@@ -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
}
102 changes: 102 additions & 0 deletions internal/platform/platform_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}

0 comments on commit d9d7624

Please sign in to comment.