diff --git a/content.go b/content.go index 4fefc1d58..69143d490 100644 --- a/content.go +++ b/content.go @@ -17,8 +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" ) @@ -44,33 +48,53 @@ func Tag(ctx context.Context, target Target, src, dst string) error { return target.Tag(ctx, desc, dst) } -var ( - // DefaultResolveOptions provides the default ResolveOptions. - DefaultResolveOptions = ResolveOptions{ - TargetPlatform: nil, - } -) +// DefaultResolveOptions provides the default ResolveOptions. +var DefaultResolveOptions ResolveOptions // ResolveOptions contains parameters for oras.Resolve. type ResolveOptions struct { - // TargetPlatform is the target platform. - // Will do the platform selection if specified. + // 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) { - desc, err := target.Resolve(ctx, ref) - if err != nil { - return ocispec.Descriptor{}, err + if opts.TargetPlatform == nil { + return target.Resolve(ctx, ref) } - if opts.TargetPlatform != nil { - desc, err = selectPlatform(ctx, target, desc, opts.TargetPlatform) + 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: + store := cas.NewMemory() + err = store.Push(ctx, desc, rc) + if err != nil { + return ocispec.Descriptor{}, err + } + + // create a proxy for the target storage, use the store as the cache + proxy := cas.NewProxy(target, store) + proxy.StopCaching = true + 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 desc, nil + return selectPlatform(ctx, target, desc, opts.TargetPlatform) } diff --git a/content_test.go b/content_test.go index 01c245312..442c311e6 100644 --- a/content_test.go +++ b/content_test.go @@ -235,7 +235,7 @@ func TestResolve_WithOptions(t *testing.T) { t.Fatal("fail to tag manifestDesc node", err) } - // test Resolve with TargetPlatform + // test Resolve with default resolve options resolveOptions := oras.DefaultResolveOptions gotDesc, err := oras.Resolve(ctx, target, ref, resolveOptions) @@ -247,7 +247,7 @@ func TestResolve_WithOptions(t *testing.T) { } } -func TestResolve_WithTargetPlatformOptions(t *testing.T) { +func TestResolve_Memory_WithTargetPlatformOptions(t *testing.T) { target := memory.New() arc_1 := "test-arc-1" os_1 := "test-os-1" @@ -295,7 +295,7 @@ func TestResolve_WithTargetPlatformOptions(t *testing.T) { "author":"test author", "architecture":"test-arc-1", "os":"test-os-1", -"variant":"test-variant"}`)) // Blob 0 +"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 @@ -345,3 +345,95 @@ func TestResolve_WithTargetPlatformOptions(t *testing.T) { 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) + } +} diff --git a/copy.go b/copy.go index 58a750473..757f67c4e 100644 --- a/copy.go +++ b/copy.go @@ -57,10 +57,30 @@ 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 if the root is a manifest list / image index. -// If the root is a manifest, then return the root descriptor if platform -// matches. +// getPlatformFromConfig returns a platform object which is made up from the +// fields in config blob. +func getPlatformFromConfig(ctx context.Context, src content.Storage, desc ocispec.Descriptor, targetConfigMediaType string) (*ocispec.Platform, error) { + if desc.MediaType != targetConfigMediaType { + return nil, fmt.Errorf("mismatch MediaType %s: expect %s", desc.MediaType, targetConfigMediaType) + } + + rc, err := src.Fetch(ctx, desc) + if err != nil { + return nil, err + } + defer rc.Close() + + var platform *ocispec.Platform + if err = json.NewDecoder(rc).Decode(&platform); err != nil { + return nil, err + } + + return platform, nil +} + +// selectPlatform implements platform filter and returns the descriptor of the +// first matched manifest if the root is a manifest list. If the root is a +// manifest, then return the root descriptor if platform matches. func selectPlatform(ctx context.Context, src content.Storage, root ocispec.Descriptor, p *ocispec.Platform) (ocispec.Descriptor, error) { switch root.MediaType { case docker.MediaTypeManifestList, ocispec.MediaTypeImageIndex: @@ -82,22 +102,21 @@ func selectPlatform(ctx context.Context, src content.Storage, root ocispec.Descr return ocispec.Descriptor{}, err } - for _, desc := range descs { - if desc.MediaType == docker.MediaTypeImage || desc.MediaType == ocispec.MediaTypeImageConfig { - rc, err := src.Fetch(ctx, desc) - if err != nil { - return ocispec.Descriptor{}, err - } - defer rc.Close() - var currPlatform ocispec.Platform - if err = json.NewDecoder(rc).Decode(&currPlatform); err != nil { - return ocispec.Descriptor{}, err - } + configMediaType := docker.MediaTypeConfig + if root.MediaType == ocispec.MediaTypeImageManifest { + configMediaType = ocispec.MediaTypeImageConfig + } - if platform.Match(&currPlatform, p) { - return root, nil - } - } + cfgPlatform, err := getPlatformFromConfig(ctx, src, descs[0], configMediaType) + if err != nil { + return ocispec.Descriptor{}, err + } + if cfgPlatform == nil { + return ocispec.Descriptor{}, fmt.Errorf("%s: missing platform info", descs[0].Digest) + } + + if platform.Match(cfgPlatform, p) { + return root, nil } return ocispec.Descriptor{}, errdef.ErrNotFound default: @@ -105,8 +124,8 @@ func selectPlatform(ctx context.Context, src content.Storage, root ocispec.Descr } } -// WithPlatformFilter adds the check on the platform attributes. -func (o *CopyOptions) WithPlatformFilter(p *ocispec.Platform) { +// WithTargetPlatform adds the check on the platform attributes. +func (o *CopyOptions) WithTargetPlatform(p *ocispec.Platform) { mapRoot := o.MapRoot o.MapRoot = func(ctx context.Context, src content.Storage, root ocispec.Descriptor) (desc ocispec.Descriptor, err error) { if mapRoot != nil { diff --git a/copy_test.go b/copy_test.go index a2ac35218..cbfdb49e4 100644 --- a/copy_test.go +++ b/copy_test.go @@ -21,6 +21,7 @@ import ( _ "crypto/sha256" "encoding/json" "errors" + "fmt" "io" "reflect" "sync/atomic" @@ -34,6 +35,7 @@ import ( "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/internal/cas" + "oras.land/oras-go/v2/internal/docker" ) // storageTracker tracks storage API counts. @@ -659,7 +661,7 @@ func TestCopy_WithOptions(t *testing.T) { } } -func TestCopy_WithPlatformFilterOptions(t *testing.T) { +func TestCopy_WithTargetPlatformOptions(t *testing.T) { src := memory.New() arc_1 := "test-arc-1" os_1 := "test-os-1" @@ -719,7 +721,7 @@ func TestCopy_WithPlatformFilterOptions(t *testing.T) { "author":"test author", "architecture":"test-arc-1", "os":"test-os-1", -"variant":"test-variant"}`)) // Blob 0 +"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 @@ -751,7 +753,7 @@ func TestCopy_WithPlatformFilterOptions(t *testing.T) { Architecture: arc_2, OS: os_2, } - opts.WithPlatformFilter(&targetPlatform) + opts.WithTargetPlatform(&targetPlatform) wantDesc := descs[5] gotDesc, err := oras.Copy(ctx, src, ref, dst, "", opts) if err != nil { @@ -782,15 +784,15 @@ func TestCopy_WithPlatformFilterOptions(t *testing.T) { } // test copy with platform filter for the image index, and multiple - // manifests match the required platform. Should return the first - // matching entry. + // manifests match the required platform. Should return the first matching + // entry. dst = memory.New() targetPlatform = ocispec.Platform{ Architecture: arc_1, OS: os_1, } opts = oras.CopyOptions{} - opts.WithPlatformFilter(&targetPlatform) + opts.WithTargetPlatform(&targetPlatform) wantDesc = descs[3] gotDesc, err = oras.Copy(ctx, src, ref, dst, "", opts) if err != nil { @@ -820,9 +822,8 @@ func TestCopy_WithPlatformFilterOptions(t *testing.T) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, wantDesc) } - // test copy with platform filter and existing MapRoot func for the - // image index, but there is no matching node. Should return not found - // error. + // test copy with platform filter and existing MapRoot func for the image + // index, but there is no matching node. Should return not found error. dst = memory.New() opts = oras.CopyOptions{ MapRoot: func(ctx context.Context, src content.Storage, root ocispec.Descriptor) (ocispec.Descriptor, error) { @@ -837,7 +838,7 @@ func TestCopy_WithPlatformFilterOptions(t *testing.T) { Architecture: arc_1, OS: os_2, } - opts.WithPlatformFilter(&targetPlatform) + opts.WithTargetPlatform(&targetPlatform) _, err = oras.Copy(ctx, src, ref, dst, "", opts) if !errors.Is(err, errdef.ErrNotFound) { @@ -851,7 +852,7 @@ func TestCopy_WithPlatformFilterOptions(t *testing.T) { Architecture: arc_1, OS: os_1, } - opts.WithPlatformFilter(&targetPlatform) + opts.WithTargetPlatform(&targetPlatform) root = descs[7] err = src.Tag(ctx, root, ref) @@ -888,8 +889,8 @@ func TestCopy_WithPlatformFilterOptions(t *testing.T) { t.Errorf("dst.Resolve() = %v, want %v", gotDesc, wantDesc) } - // test copy with platform filter for the manifest, but there is no - // matching node. Should return not found error. + // test copy with platform filter for the manifest, but there is no matching + // node. Should return not found error. dst = memory.New() opts = oras.CopyOptions{} targetPlatform = ocispec.Platform{ @@ -897,7 +898,7 @@ func TestCopy_WithPlatformFilterOptions(t *testing.T) { OS: os_1, Variant: variant_2, } - opts.WithPlatformFilter(&targetPlatform) + opts.WithTargetPlatform(&targetPlatform) _, err = oras.Copy(ctx, src, ref, dst, "", opts) if !errors.Is(err, errdef.ErrNotFound) { @@ -912,7 +913,7 @@ func TestCopy_WithPlatformFilterOptions(t *testing.T) { Architecture: arc_1, OS: os_1, } - opts.WithPlatformFilter(&targetPlatform) + opts.WithTargetPlatform(&targetPlatform) root = descs[1] err = src.Tag(ctx, root, ref) @@ -924,6 +925,50 @@ func TestCopy_WithPlatformFilterOptions(t *testing.T) { if !errors.Is(err, errdef.ErrUnsupported) { t.Fatalf("Copy() error = %v, wantErr %v", err, errdef.ErrUnsupported) } + + // generate incorrect test content + blobs = nil + descs = nil + appendBlob(docker.MediaTypeConfig, []byte(`{"mediaType":"application/vnd.oci.image.config.v1+json", +"created":"2022-07-29T08:13:55Z", +"author":"test author 1", +"architecture":"test-arc-1", +"os":"test-os-1", +"variant":"v1"}`)) // Blob 0 + appendBlob(ocispec.MediaTypeImageLayer, []byte("foo1")) // Blob 1 + generateManifest(arc_1, os_1, variant_1, descs[0], descs[1]) // Blob 2 + generateIndex(descs[2]) // Blob 3 + + ctx = context.Background() + for i := range blobs { + err := src.Push(ctx, descs[i], bytes.NewReader(blobs[i])) + if err != nil { + t.Fatalf("failed to push test content to src: %d: %v", i, err) + } + } + + dst = memory.New() + opts = oras.CopyOptions{} + targetPlatform = ocispec.Platform{ + Architecture: arc_1, + OS: os_1, + } + opts.WithTargetPlatform(&targetPlatform) + + // test copy with platform filter for the manifest, but the manifest is + // invalid by having docker mediaType config in the manifest and oci + // mediaType in the image config. Should return error. + root = descs[2] + err = src.Tag(ctx, root, ref) + if err != nil { + t.Fatal("fail to tag root node", err) + } + + _, err = oras.Copy(ctx, src, ref, dst, "", opts) + expected := fmt.Sprintf("mismatch MediaType %s: expect %s", docker.MediaTypeConfig, ocispec.MediaTypeImageConfig) + if err.Error() != expected { + t.Fatalf("Copy() error = %v, wantErr %v", err, expected) + } } func TestCopy_RestoreDuplicates(t *testing.T) { diff --git a/internal/docker/mediatype.go b/internal/docker/mediatype.go index 5febba7cd..24ec26ac7 100644 --- a/internal/docker/mediatype.go +++ b/internal/docker/mediatype.go @@ -17,7 +17,7 @@ package docker // docker media types const ( - MediaTypeImage = "application/vnd.docker.container.image.v1+json" + MediaTypeConfig = "application/vnd.docker.container.image.v1+json" MediaTypeManifestList = "application/vnd.docker.distribution.manifest.list.v2+json" MediaTypeManifest = "application/vnd.docker.distribution.manifest.v2+json" )