Repository: oras-project/oras (go)

Issue:
support discovering referrers in OCI image layouts
Currently `oras discover` doesn't support OCI image layout target. Artifact users should be able to use it to view referrers of a subject in their OCI layout folders or tar archives.

Pull request:
feat: support artifact discovering in OCI image layout
This PR adds OCI image layout support for `oras discover` command and resolves #760. The UX is consistent with remote registry and looks like below:

```
# 0. Copy the test DAG to local OCI image layout folder
$ oras cp jinzha1.azurecr.io/oci-root:foo -r --to-oci-layout oci-root:foo

# 1. Discovering referrers
$ oras discover --oci-layout oci-root:foo
Discovered 2 artifacts referencing jinzha1.azurecr.io/oci-root:foo
Digest: sha256:d415a6dda5872dedf2e080fded1dc40276fbfb71ce054f70bb57e2431d6a8b23

Artifact Type   Digest
type.artifact   sha256:3d050f891f461ce021dcf5b1bc6586c4d6d7e3087867cf859ee613e8b304dfb3
type.image      sha256:cd13e0cb5a8d5f42c3fbcfe53171ad25fc430879a190dc71121ec7b7f7b83386

# 2. Discovering referrers and filtering
$ oras discover --oci-layout oci-root:foo  --artifact-type type.image
Discovered 1 artifact referencing foo
Digest: sha256:d415a6dda5872dedf2e080fded1dc40276fbfb71ce054f70bb57e2431d6a8b23

Artifact Type   Digest
type.image      sha256:cd13e0cb5a8d5f42c3fbcfe53171ad25fc430879a190dc71121ec7b7f7b83386
```

Signed-off-by: Billy Zha <jinzha1@microsoft.com>

Patch:
diff --git a/cmd/oras/discover.go b/cmd/oras/discover.go
index e34af85..e3c254d 100644
--- a/cmd/oras/discover.go
+++ b/cmd/oras/discover.go
@@ -24,9 +24,8 @@ import (
 
 	"gopkg.in/yaml.v3"
 	"oras.land/oras-go/v2"
-	"oras.land/oras-go/v2/registry/remote"
-	"oras.land/oras/cmd/oras/internal/errors"
 	"oras.land/oras/cmd/oras/internal/option"
+	"oras.land/oras/internal/graph"
 
 	"github.com/need-being/go-tree"
 	"github.com/opencontainers/image-spec/specs-go"
@@ -36,10 +35,9 @@ import (
 
 type discoverOptions struct {
 	option.Common
-	option.Remote
 	option.Platform
+	option.Target
 
-	targetRef    string
 	artifactType string
 	outputType   string
 }
@@ -70,13 +68,17 @@ Example - Discover all the referrers of manifest with annotations, displayed in
 
 Example - Discover referrers with type 'test-artifact' of manifest 'hello:v1' in registry 'localhost:5000':
   oras discover --artifact-type test-artifact localhost:5000/hello:v1
+
+Example - Discover referrers of the manifest tagged 'v1' in an OCI layout folder 'layout-dir':
+  oras discover --oci-layout layout-dir:v1
+  oras discover --oci-layout -v -o tree layout-dir:v1
 `,
 		Args: cobra.ExactArgs(1),
 		PreRunE: func(cmd *cobra.Command, args []string) error {
+			opts.RawReference = args[0]
 			return option.Parse(&opts)
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
-			opts.targetRef = args[0]
 			return runDiscover(opts)
 		},
 	}
@@ -90,24 +92,24 @@ Example - Discover referrers with type 'test-artifact' of manifest 'hello:v1' in
 
 func runDiscover(opts discoverOptions) error {
 	ctx, _ := opts.SetLoggerLevel()
-	repo, err := opts.NewRepository(opts.targetRef, opts.Common)
+	repo, err := opts.NewReadonlyTarget(ctx, opts.Common)
 	if err != nil {
 		return err
 	}
-	if repo.Reference.Reference == "" {
-		return errors.NewErrInvalidReference(repo.Reference)
+	if err := opts.EnsureReferenceNotEmpty(); err != nil {
+		return err
 	}
 
 	// discover artifacts
 	resolveOpts := oras.DefaultResolveOptions
 	resolveOpts.TargetPlatform = opts.Platform.Platform
-	desc, err := oras.Resolve(ctx, repo, repo.Reference.Reference, resolveOpts)
+	desc, err := oras.Resolve(ctx, repo, opts.Reference, resolveOpts)
 	if err != nil {
 		return err
 	}
 
 	if opts.outputType == "tree" {
-		root := tree.New(repo.Reference.String())
+		root := tree.New(opts.Reference)
 		err = fetchAllReferrers(ctx, repo, desc, opts.artifactType, root, &opts)
 		if err != nil {
 			return err
@@ -115,7 +117,7 @@ func runDiscover(opts discoverOptions) error {
 		return tree.Print(root)
 	}
 
-	refs, err := fetchReferrers(ctx, repo, desc, opts.artifactType)
+	refs, err := graph.Referrers(ctx, repo, desc, opts.artifactType)
 	if err != nil {
 		return err
 	}
@@ -124,9 +126,9 @@ func runDiscover(opts discoverOptions) error {
 	}
 
 	if n := len(refs); n > 1 {
-		fmt.Println("Discovered", n, "artifacts referencing", repo.Reference)
+		fmt.Println("Discovered", n, "artifacts referencing", opts.Reference)
 	} else {
-		fmt.Println("Discovered", n, "artifact referencing", repo.Reference)
+		fmt.Println("Discovered", n, "artifact referencing", opts.Reference)
 	}
 	fmt.Println("Digest:", desc.Digest)
 	if len(refs) > 0 {
@@ -136,20 +138,8 @@ func runDiscover(opts discoverOptions) error {
 	return nil
 }
 
-func fetchReferrers(ctx context.Context, repo *remote.Repository, desc ocispec.Descriptor, artifactType string) ([]ocispec.Descriptor, error) {
-	results := []ocispec.Descriptor{}
-	err := repo.Referrers(ctx, desc, artifactType, func(referrers []ocispec.Descriptor) error {
-		results = append(results, referrers...)
-		return nil
-	})
-	if err != nil {
-		return nil, err
-	}
-	return results, nil
-}
-
-func fetchAllReferrers(ctx context.Context, repo *remote.Repository, desc ocispec.Descriptor, artifactType string, node *tree.Node, opts *discoverOptions) error {
-	results, err := fetchReferrers(ctx, repo, desc, artifactType)
+func fetchAllReferrers(ctx context.Context, repo oras.ReadOnlyGraphTarget, desc ocispec.Descriptor, artifactType string, node *tree.Node, opts *discoverOptions) error {
+	results, err := graph.Referrers(ctx, repo, desc, artifactType)
 	if err != nil {
 		return err
 	}
diff --git a/internal/graph/graph.go b/internal/graph/graph.go
index 41b02c9..84d5c96 100644
--- a/internal/graph/graph.go
+++ b/internal/graph/graph.go
@@ -20,7 +20,9 @@ import (
 	"encoding/json"
 
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+	"oras.land/oras-go/v2"
 	"oras.land/oras-go/v2/content"
+	"oras.land/oras-go/v2/registry"
 	"oras.land/oras/internal/docker"
 )
 
@@ -59,3 +61,72 @@ func Successors(ctx context.Context, fetcher content.Fetcher, node ocispec.Descr
 	}
 	return
 }
+
+// Referrers returns referrer nodes of desc in target.
+func Referrers(ctx context.Context, target oras.ReadOnlyGraphTarget, desc ocispec.Descriptor, artifactType string) ([]ocispec.Descriptor, error) {
+	var results []ocispec.Descriptor
+	if repo, ok := target.(registry.ReferrerLister); ok {
+		// get referrers directly
+		err := repo.Referrers(ctx, desc, artifactType, func(referrers []ocispec.Descriptor) error {
+			results = append(results, referrers...)
+			return nil
+		})
+		if err != nil {
+			return nil, err
+		}
+		return results, nil
+	}
+
+	// find matched referrers in all predecessors
+	predecessors, err := target.Predecessors(ctx, desc)
+	if err != nil {
+		return nil, err
+	}
+	for _, node := range predecessors {
+		switch node.MediaType {
+		case ocispec.MediaTypeArtifactManifest:
+			fetched, err := fetchBytes(ctx, target, node)
+			if err != nil {
+				return nil, err
+			}
+			var artifact ocispec.Artifact
+			if err := json.Unmarshal(fetched, &artifact); err != nil {
+				return nil, err
+			}
+			if artifact.Subject == nil || !content.Equal(*artifact.Subject, desc) {
+				continue
+			}
+			node.ArtifactType = artifact.ArtifactType
+			node.Annotations = artifact.Annotations
+		case ocispec.MediaTypeImageManifest:
+			fetched, err := fetchBytes(ctx, target, node)
+			if err != nil {
+				return nil, err
+			}
+			var image ocispec.Manifest
+			if err := json.Unmarshal(fetched, &image); err != nil {
+				return nil, err
+			}
+			if image.Subject == nil || !content.Equal(*image.Subject, desc) {
+				continue
+			}
+			node.ArtifactType = image.Config.MediaType
+			node.Annotations = image.Annotations
+		default:
+			continue
+		}
+		if node.ArtifactType != "" && (artifactType == "" || artifactType == node.ArtifactType) {
+			results = append(results, node)
+		}
+	}
+	return results, nil
+}
+
+func fetchBytes(ctx context.Context, fetcher content.Fetcher, desc ocispec.Descriptor) ([]byte, error) {
+	rc, err := fetcher.Fetch(ctx, desc)
+	if err != nil {
+		return nil, err
+	}
+	defer rc.Close()
+	return content.ReadAll(rc, desc)
+}


Test patch:
diff --git a/internal/graph/graph_test.go b/internal/graph/graph_test.go
new file mode 100644
index 0000000..be2b4ff
--- /dev/null
+++ b/internal/graph/graph_test.go
@@ -0,0 +1,174 @@
+/*
+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 graph
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"reflect"
+	"testing"
+
+	"github.com/opencontainers/go-digest"
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+	"oras.land/oras-go/v2"
+	"oras.land/oras-go/v2/content/memory"
+)
+
+type errLister struct {
+	oras.ReadOnlyGraphTarget
+}
+
+func (e *errLister) Referrers(ctx context.Context, desc ocispec.Descriptor, artifactType string, fn func(referrers []ocispec.Descriptor) error) error {
+	return errors.New("")
+}
+
+type refLister struct {
+	referrers []ocispec.Descriptor
+	oras.ReadOnlyGraphTarget
+}
+
+func (m *refLister) Referrers(ctx context.Context, desc ocispec.Descriptor, artifactType string, fn func(referrers []ocispec.Descriptor) error) error {
+	return fn(m.referrers)
+}
+
+type predecessorFinder struct {
+	*memory.Store
+}
+
+func TestReferrers(t *testing.T) {
+	ctx := context.Background()
+	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)),
+		})
+	}
+	generateImage := func(subject *ocispec.Descriptor, annotations map[string]string, config ocispec.Descriptor, layers ...ocispec.Descriptor) {
+		manifest := ocispec.Manifest{
+			Subject:     subject,
+			Config:      config,
+			Layers:      layers,
+			Annotations: annotations,
+		}
+		manifestJSON, err := json.Marshal(manifest)
+		if err != nil {
+			t.Fatal(err)
+		}
+		appendBlob(ocispec.MediaTypeImageManifest, manifestJSON)
+	}
+	generateArtifact := func(artifactType string, subject *ocispec.Descriptor, annotations map[string]string, blobs ...ocispec.Descriptor) {
+		manifest := ocispec.Artifact{
+			Subject:      subject,
+			Blobs:        blobs,
+			Annotations:  annotations,
+			ArtifactType: artifactType,
+		}
+		manifestJSON, err := json.Marshal(manifest)
+		if err != nil {
+			t.Fatal(err)
+		}
+		appendBlob(ocispec.MediaTypeArtifactManifest, manifestJSON)
+	}
+	generateIndex := func(manifests ...ocispec.Descriptor) {
+		index := ocispec.Index{
+			Manifests: manifests,
+		}
+		manifestJSON, err := json.Marshal(index)
+		if err != nil {
+			t.Fatal(err)
+		}
+		appendBlob(ocispec.MediaTypeImageIndex, manifestJSON)
+	}
+	const (
+		subject = iota
+		imgConfig
+		image
+		artifact
+		index
+	)
+	anno := map[string]string{"test": "foo"}
+	appendBlob(ocispec.MediaTypeArtifactManifest, []byte("subject content"))
+	imageType := "test.image"
+	appendBlob(imageType, []byte("config content"))
+	generateImage(&descs[subject], anno, descs[imgConfig])
+	imageDesc := descs[image]
+	imageDesc.Annotations = anno
+	imageDesc.ArtifactType = imageType
+	artifactType := "test.artifact"
+	generateArtifact(artifactType, &descs[subject], anno)
+	generateIndex(descs[subject])
+	artifactDesc := descs[artifact]
+	artifactDesc.Annotations = anno
+	artifactDesc.ArtifactType = artifactType
+
+	referrers := []ocispec.Descriptor{descs[image], descs[image]}
+	memory := memory.New()
+	for i := range descs {
+		memory.Push(ctx, descs[i], bytes.NewReader(blobs[i]))
+	}
+	finder := &predecessorFinder{Store: memory}
+
+	type args struct {
+		ctx          context.Context
+		target       oras.ReadOnlyGraphTarget
+		desc         ocispec.Descriptor
+		artifactType string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    []ocispec.Descriptor
+		wantErr bool
+	}{
+		{"should fail when a referrer lister failed to get referrers", args{ctx, &errLister{}, ocispec.Descriptor{}, ""}, nil, true},
+		{"should return referrers when target is a referrer lister", args{ctx, &refLister{referrers: referrers}, ocispec.Descriptor{}, ""}, referrers, false},
+		{"should return nil for index node", args{ctx, finder, descs[index], ""}, nil, false},
+		{"should return nil for config node", args{ctx, finder, descs[imgConfig], ""}, nil, false},
+		{"should find filtered image referrer", args{ctx, finder, descs[subject], imageType}, []ocispec.Descriptor{imageDesc}, false},
+		{"should find filtered artifact referrer", args{ctx, finder, descs[subject], artifactType}, []ocispec.Descriptor{artifactDesc}, false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := Referrers(tt.args.ctx, tt.args.target, tt.args.desc, tt.args.artifactType)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("Referrers() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("Referrers() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+
+	t.Run("should find referrers in predecessors", func(t *testing.T) {
+		want1 := []ocispec.Descriptor{artifactDesc, imageDesc}
+		want2 := []ocispec.Descriptor{imageDesc, artifactDesc}
+		got, err := Referrers(ctx, finder, descs[subject], "")
+		if err != nil {
+			t.Errorf("Referrers() error = %v", err)
+			return
+		}
+		if !reflect.DeepEqual(got, want1) && !reflect.DeepEqual(got, want2) {
+			t.Errorf("Referrers() = %v, want %v", got, want1)
+		}
+	})
+}