Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support platform selection on Copy #244

Merged
merged 8 commits into from
Aug 8, 2022
56 changes: 56 additions & 0 deletions content.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ package oras

import (
"context"
"fmt"

ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"oras.land/oras-go/v2/errdef"
"oras.land/oras-go/v2/internal/cas"
"oras.land/oras-go/v2/internal/docker"
"oras.land/oras-go/v2/registry"
)

Expand All @@ -42,3 +47,54 @@ func Tag(ctx context.Context, target Target, src, dst string) error {
}
return target.Tag(ctx, desc, dst)
}

lizMSFT marked this conversation as resolved.
Show resolved Hide resolved
// DefaultResolveOptions provides the default ResolveOptions.
var DefaultResolveOptions ResolveOptions

// ResolveOptions contains parameters for oras.Resolve.
type ResolveOptions struct {
// TargetPlatform ensures the resolved content matches the target platform
// if the node is a manifest, or selects the first resolved content that
// matches the target platform if the node is a manifest list.
TargetPlatform *ocispec.Platform
}

// Resolve resolves a descriptor with provided reference from the target.
func Resolve(ctx context.Context, target Target, ref string, opts ResolveOptions) (ocispec.Descriptor, error) {
if opts.TargetPlatform == nil {
return target.Resolve(ctx, ref)
}

if refFetcher, ok := target.(registry.ReferenceFetcher); ok {
// optimize performance for ReferenceFetcher targets
desc, rc, err := refFetcher.FetchReference(ctx, ref)
if err != nil {
return ocispec.Descriptor{}, err
}
defer rc.Close()

switch desc.MediaType {
case docker.MediaTypeManifestList, ocispec.MediaTypeImageIndex,
docker.MediaTypeManifest, ocispec.MediaTypeImageManifest:
// create a proxy to cache the fetched descriptor
store := cas.NewMemory()
err = store.Push(ctx, desc, rc)
if err != nil {
return ocispec.Descriptor{}, err
}

proxy := cas.NewProxy(target, store)
proxy.StopCaching = true
lizMSFT marked this conversation as resolved.
Show resolved Hide resolved
return selectPlatform(ctx, proxy, desc, opts.TargetPlatform)
default:
return ocispec.Descriptor{}, fmt.Errorf("%s: %s: %w", desc.Digest, desc.MediaType, errdef.ErrUnsupported)
}
}

desc, err := target.Resolve(ctx, ref)
if err != nil {
return ocispec.Descriptor{}, err
}

return selectPlatform(ctx, target, desc, opts.TargetPlatform)
}
251 changes: 251 additions & 0 deletions content_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
Expand All @@ -30,6 +31,7 @@ import (
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"oras.land/oras-go/v2"
"oras.land/oras-go/v2/content/memory"
"oras.land/oras-go/v2/errdef"
"oras.land/oras-go/v2/registry/remote"
)

Expand Down Expand Up @@ -186,3 +188,252 @@ func TestTag_Repository(t *testing.T) {
t.Errorf("Repository.TagReference() = %v, want %v", gotIndex, index)
}
}

func TestResolve_WithOptions(t *testing.T) {
target := memory.New()

// generate test content
var blobs [][]byte
var descs []ocispec.Descriptor
appendBlob := func(mediaType string, blob []byte) {
blobs = append(blobs, blob)
descs = append(descs, ocispec.Descriptor{
MediaType: mediaType,
Digest: digest.FromBytes(blob),
Size: int64(len(blob)),
})
}
generateManifest := func(config ocispec.Descriptor, layers ...ocispec.Descriptor) {
manifest := ocispec.Manifest{
Config: config,
Layers: layers,
}
manifestJSON, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
appendBlob(ocispec.MediaTypeImageManifest, manifestJSON)
}

appendBlob(ocispec.MediaTypeImageConfig, []byte("config")) // Blob 0
appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1
appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2
generateManifest(descs[0], descs[1:3]...) // Blob 3

ctx := context.Background()
for i := range blobs {
err := target.Push(ctx, descs[i], bytes.NewReader(blobs[i]))
if err != nil {
t.Fatalf("failed to push test content to src: %d: %v", i, err)
}
}

manifestDesc := descs[3]
ref := "foobar"
err := target.Tag(ctx, manifestDesc, ref)
if err != nil {
t.Fatal("fail to tag manifestDesc node", err)
}

// test Resolve with default resolve options
resolveOptions := oras.DefaultResolveOptions
gotDesc, err := oras.Resolve(ctx, target, ref, resolveOptions)

if err != nil {
t.Fatal("oras.Resolve() error =", err)
}
if !reflect.DeepEqual(gotDesc, manifestDesc) {
t.Errorf("oras.Resolve() = %v, want %v", gotDesc, manifestDesc)
}
}

func TestResolve_Memory_WithTargetPlatformOptions(t *testing.T) {
target := memory.New()
arc_1 := "test-arc-1"
os_1 := "test-os-1"
variant_1 := "v1"
variant_2 := "v2"

// generate test content
var blobs [][]byte
var descs []ocispec.Descriptor
appendBlob := func(mediaType string, blob []byte) {
blobs = append(blobs, blob)
descs = append(descs, ocispec.Descriptor{
MediaType: mediaType,
Digest: digest.FromBytes(blob),
Size: int64(len(blob)),
})
}
appendManifest := func(arc, os, variant string, mediaType string, blob []byte) {
blobs = append(blobs, blob)
descs = append(descs, ocispec.Descriptor{
MediaType: mediaType,
Digest: digest.FromBytes(blob),
Size: int64(len(blob)),
Platform: &ocispec.Platform{
Architecture: arc,
OS: os,
Variant: variant,
},
})
}
generateManifest := func(arc, os, variant string, config ocispec.Descriptor, layers ...ocispec.Descriptor) {
manifest := ocispec.Manifest{
Config: config,
Layers: layers,
}
manifestJSON, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
appendManifest(arc, os, variant, ocispec.MediaTypeImageManifest, manifestJSON)
}

appendBlob(ocispec.MediaTypeImageConfig, []byte(`{"mediaType":"application/vnd.oci.image.config.v1+json",
"created":"2022-07-29T08:13:55Z",
"author":"test author",
"architecture":"test-arc-1",
"os":"test-os-1",
"variant":"v1"}`)) // Blob 0
appendBlob(ocispec.MediaTypeImageLayer, []byte("foo")) // Blob 1
appendBlob(ocispec.MediaTypeImageLayer, []byte("bar")) // Blob 2
generateManifest(arc_1, os_1, variant_1, descs[0], descs[1:3]...) // Blob 3

ctx := context.Background()
for i := range blobs {
err := target.Push(ctx, descs[i], bytes.NewReader(blobs[i]))
if err != nil {
t.Fatalf("failed to push test content to src: %d: %v", i, err)
}
}

manifestDesc := descs[3]
ref := "foobar"
err := target.Tag(ctx, manifestDesc, ref)
if err != nil {
t.Fatal("fail to tag manifestDesc node", err)
}

// test Resolve with TargetPlatform
resolveOptions := oras.ResolveOptions{
TargetPlatform: &ocispec.Platform{
Architecture: arc_1,
OS: os_1,
},
}
gotDesc, err := oras.Resolve(ctx, target, ref, resolveOptions)

if err != nil {
t.Fatal("oras.Resolve() error =", err)
}
if !reflect.DeepEqual(gotDesc, manifestDesc) {
t.Errorf("oras.Resolve() = %v, want %v", gotDesc, manifestDesc)
}

// test Resolve with TargetPlatform but there is no matching node
// Should return not found error
resolveOptions = oras.ResolveOptions{
TargetPlatform: &ocispec.Platform{
Architecture: arc_1,
OS: os_1,
Variant: variant_2,
},
}
_, err = oras.Resolve(ctx, target, ref, resolveOptions)
if !errors.Is(err, errdef.ErrNotFound) {
t.Fatalf("oras.Resolve() error = %v, wantErr %v", err, errdef.ErrNotFound)
}
}

func TestResolve_Repository_WithTargetPlatformOptions(t *testing.T) {
arc_1 := "test-arc-1"
arc_2 := "test-arc-2"
os_1 := "test-os-1"
var digest_1 digest.Digest = "sha256:11ec3af9dfeb49c89ef71877ba85249be527e4dda9d1d74d99dc618d1a5fa151"

manifestDesc := ocispec.Descriptor{
MediaType: ocispec.MediaTypeImageManifest,
Digest: digest_1,
Size: 484,
Platform: &ocispec.Platform{
Architecture: arc_1,
OS: os_1,
},
}

index := []byte(`{"manifests":[{
"mediaType":"application/vnd.oci.image.manifest.v1+json",
"digest":"sha256:11ec3af9dfeb49c89ef71877ba85249be527e4dda9d1d74d99dc618d1a5fa151",
"size":484,
"platform":{"architecture":"test-arc-1","os":"test-os-1"}},{
"mediaType":"application/vnd.oci.image.manifest.v1+json",
"digest":"sha256:b955aefa63749f07fad84ab06a45a951368e3ac79799bc44a158fac1bb8ca208",
"size":337,
"platform":{"architecture":"test-arc-2","os":"test-os-2"}}]}`)
indexDesc := ocispec.Descriptor{
MediaType: ocispec.MediaTypeImageIndex,
Digest: digest.FromBytes(index),
Size: int64(len(index)),
}
src := "foobar"
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && (r.URL.Path == "/v2/test/manifests/"+indexDesc.Digest.String() || r.URL.Path == "/v2/test/manifests/"+src):
if accept := r.Header.Get("Accept"); !strings.Contains(accept, indexDesc.MediaType) {
t.Errorf("manifest not convertable: %s", accept)
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", indexDesc.MediaType)
w.Header().Set("Docker-Content-Digest", indexDesc.Digest.String())
if _, err := w.Write(index); err != nil {
t.Errorf("failed to write %q: %v", r.URL, err)
}
default:
t.Errorf("unexpected access: %s %s", r.Method, r.URL)
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
uri, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("invalid test http server: %v", err)
}

repoName := uri.Host + "/test"
repo, err := remote.NewRepository(repoName)
if err != nil {
t.Fatalf("NewRepository() error = %v", err)
}
repo.PlainHTTP = true
ctx := context.Background()

// test Resolve with TargetPlatform
resolveOptions := oras.ResolveOptions{
TargetPlatform: &ocispec.Platform{
Architecture: arc_1,
OS: os_1,
},
}
gotDesc, err := oras.Resolve(ctx, repo, src, resolveOptions)
if err != nil {
t.Fatal("oras.Resolve() error =", err)
}
if !reflect.DeepEqual(gotDesc, manifestDesc) {
t.Errorf("oras.Resolve() = %v, want %v", gotDesc, manifestDesc)
}

// test Resolve with TargetPlatform but there is no matching node
// Should return not found error
resolveOptions = oras.ResolveOptions{
TargetPlatform: &ocispec.Platform{
Architecture: arc_1,
OS: arc_2,
},
}
_, err = oras.Resolve(ctx, repo, src, resolveOptions)
if !errors.Is(err, errdef.ErrNotFound) {
t.Fatalf("oras.Resolve() error = %v, wantErr %v", err, errdef.ErrNotFound)
}
}
Loading