Repository: oras-project/oras
Pull request #520 (2022-08-22 14:22:48)

feat: support fetch a blob from a remote registry
support fetch a blob from a remote registry

Resolves: #475 
Signed-off-by: Zoey Li <zoeyli@microsoft.com>

codecov-commenter: # [Codecov](https://codecov.io/gh/oras-project/oras/pull/520?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) Report
> Merging [#520](https://codecov.io/gh/oras-project/oras/pull/520?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) (eede481) into [main](https://codecov.io/gh/oras-project/oras/commit/fcdc7697e7c9562d1fa1faade594dcd0088abefb?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) (fcdc769) will **decrease** coverage by `0.57%`.
> The diff coverage is `50.00%`.

```diff
@@            Coverage Diff             @@
##             main     #520      +/-   ##
==========================================
- Coverage   73.43%   72.86%   -0.58%     
==========================================
  Files           9        9              
  Lines         399      409      +10     
==========================================
+ Hits          293      298       +5     
- Misses         85       89       +4     
- Partials       21       22       +1     
```


| [Impacted Files](https://codecov.io/gh/oras-project/oras/pull/520?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) | Coverage Δ | |
|---|---|---|
| [internal/cas/fetch.go](https://codecov.io/gh/oras-project/oras/pull/520/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project#diff-aW50ZXJuYWwvY2FzL2ZldGNoLmdv) | `81.39% <50.00%> (-9.52%)` | :arrow_down: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project)


Patch:
diff --git a/cmd/oras/blob/cmd.go b/cmd/oras/blob/cmd.go
new file mode 100644
index 0000000..0ebf471
--- /dev/null
+++ b/cmd/oras/blob/cmd.go
@@ -0,0 +1,32 @@
+/*
+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 blob
+
+import (
+	"github.com/spf13/cobra"
+)
+
+func Cmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "blob [command]",
+		Short: "[Preview] Blob operations",
+	}
+
+	cmd.AddCommand(
+		fetchCmd(),
+	)
+	return cmd
+}
diff --git a/cmd/oras/blob/fetch.go b/cmd/oras/blob/fetch.go
new file mode 100644
index 0000000..8c8808d
--- /dev/null
+++ b/cmd/oras/blob/fetch.go
@@ -0,0 +1,163 @@
+/*
+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 blob
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+	"github.com/spf13/cobra"
+	"oras.land/oras-go/v2"
+	"oras.land/oras-go/v2/content/oci"
+	"oras.land/oras/cmd/oras/internal/option"
+	"oras.land/oras/internal/cache"
+)
+
+type fetchBlobOptions struct {
+	option.Common
+	option.Descriptor
+	option.Pretty
+	option.Remote
+
+	cacheRoot  string
+	outputPath string
+	targetRef  string
+}
+
+func fetchCmd() *cobra.Command {
+	var opts fetchBlobOptions
+	cmd := &cobra.Command{
+		Use:   "fetch [flags] <name@digest>",
+		Short: "[Preview] Fetch a blob from a remote registry",
+		Long: `[Preview] Fetch a blob from a remote registry
+
+** This command is in preview and under development. **
+
+Example - Fetch the blob and save it to a local file:
+  oras blob fetch --output blob.tar.gz localhost:5000/hello@sha256:9a201d228ebd966211f7d1131be19f152be428bd373a92071c71d8deaf83b3e5
+
+Example - Fetch the blob and stdout the raw blob content:
+  oras blob fetch --output - localhost:5000/hello@sha256:9a201d228ebd966211f7d1131be19f152be428bd373a92071c71d8deaf83b3e5
+
+Example - Fetch and stdout the descriptor of a blob:
+  oras blob fetch --descriptor localhost:5000/hello@sha256:9a201d228ebd966211f7d1131be19f152be428bd373a92071c71d8deaf83b3e5
+
+Example - Fetch the blob, save it to a local file and stdout the descriptor:
+  oras blob fetch --output blob.tar.gz --descriptor localhost:5000/hello@sha256:9a201d228ebd966211f7d1131be19f152be428bd373a92071c71d8deaf83b3e5
+
+Example - Fetch blob from the insecure registry:
+  oras blob fetch --insecure localhost:5000/hello@sha256:9a201d228ebd966211f7d1131be19f152be428bd373a92071c71d8deaf83b3e5
+`,
+		Args: cobra.ExactArgs(1),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			if opts.outputPath == "" && !opts.OutputDescriptor {
+				return errors.New("either `--output` or `--descriptor` must be provided")
+			}
+
+			if opts.outputPath == "-" && opts.OutputDescriptor {
+				return errors.New("`--output -` cannot be used with `--descriptor` at the same time")
+			}
+
+			opts.cacheRoot = os.Getenv("ORAS_CACHE")
+			return opts.ReadPassword()
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			opts.targetRef = args[0]
+			return fetchBlob(opts)
+		},
+	}
+
+	cmd.Flags().StringVarP(&opts.outputPath, "output", "o", "", "output file path")
+	option.ApplyFlags(&opts, cmd.Flags())
+	return cmd
+}
+
+func fetchBlob(opts fetchBlobOptions) (fetchErr error) {
+	ctx, _ := opts.SetLoggerLevel()
+
+	repo, err := opts.NewRepository(opts.targetRef, opts.Common)
+	if err != nil {
+		return err
+	}
+
+	if _, err = repo.Reference.Digest(); err != nil {
+		return fmt.Errorf("%s: blob reference must be of the form <name@digest>", opts.targetRef)
+	}
+
+	var src oras.ReadOnlyTarget = repo.Blobs()
+	if opts.cacheRoot != "" {
+		ociStore, err := oci.New(opts.cacheRoot)
+		if err != nil {
+			return err
+		}
+		src = cache.New(src, ociStore)
+	}
+
+	var desc ocispec.Descriptor
+	if opts.outputPath == "" {
+		// fetch blob descriptor only
+		desc, err = oras.Resolve(ctx, src, opts.targetRef, oras.DefaultResolveOptions)
+		if err != nil {
+			return err
+		}
+	} else {
+		// fetch blob content
+		var rc io.ReadCloser
+		desc, rc, err = oras.Fetch(ctx, src, opts.targetRef, oras.DefaultFetchOptions)
+		if err != nil {
+			return err
+		}
+		defer rc.Close()
+
+		// outputs blob content if "--output -" is used
+		if opts.outputPath == "-" {
+			_, err := io.Copy(os.Stdout, rc)
+			return err
+		}
+
+		// save blob content into the local file if the output path is provided
+		file, err := os.Create(opts.outputPath)
+		if err != nil {
+			return err
+		}
+		defer func() {
+			if err := file.Close(); fetchErr == nil {
+				fetchErr = err
+			}
+		}()
+
+		if _, err := io.Copy(file, rc); err != nil {
+			return err
+		}
+	}
+
+	// outputs blob's descriptor if `--descriptor` is used
+	if opts.OutputDescriptor {
+		descJSON, err := json.Marshal(desc)
+		if err != nil {
+			return err
+		}
+		if err := opts.Output(os.Stdout, descJSON); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/cmd/oras/main.go b/cmd/oras/main.go
index cadce11..e85d872 100644
--- a/cmd/oras/main.go
+++ b/cmd/oras/main.go
@@ -19,6 +19,7 @@ import (
 	"os"
 
 	"github.com/spf13/cobra"
+	"oras.land/oras/cmd/oras/blob"
 	"oras.land/oras/cmd/oras/manifest"
 	"oras.land/oras/cmd/oras/repository"
 	"oras.land/oras/cmd/oras/tag"
@@ -38,6 +39,7 @@ func main() {
 		discoverCmd(),
 		copyCmd(),
 		attachCmd(),
+		blob.Cmd(),
 		manifest.Cmd(),
 		tag.TagCmd(),
 		repository.Cmd(),
diff --git a/internal/cache/target.go b/internal/cache/target.go
index eb3570e..e9f7758 100644
--- a/internal/cache/target.go
+++ b/internal/cache/target.go
@@ -133,6 +133,17 @@ func (t *referenceTarget) FetchReference(ctx context.Context, reference string)
 		return ocispec.Descriptor{}, nil, err
 	}
 	if exists {
+		err = rc.Close()
+		if err != nil {
+			return ocispec.Descriptor{}, nil, err
+		}
+
+		// get rc from the cache
+		rc, err = t.cache.Fetch(ctx, target)
+		if err != nil {
+			return ocispec.Descriptor{}, nil, err
+		}
+
 		// no need to do tee'd push
 		return target, rc, nil
 	}


Test patch:
diff --git a/internal/cache/target_test.go b/internal/cache/target_test.go
index 8f9cf22..cbc7cd0 100644
--- a/internal/cache/target_test.go
+++ b/internal/cache/target_test.go
@@ -175,7 +175,10 @@ func TestProxy_fetchReference(t *testing.T) {
 			w.Header().Set("Docker-Content-Digest", digest.String())
 			w.Header().Set("Content-Length", strconv.Itoa(len([]byte(blob))))
 			w.WriteHeader(http.StatusOK)
-			w.Write(blob)
+			// write data to the response if this is the first request
+			if requestCount == 1 {
+				w.Write(blob)
+			}
 			atomic.AddInt64(&successCount, 1)
 			return
 		}
@@ -222,6 +225,33 @@ func TestProxy_fetchReference(t *testing.T) {
 		t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount)
 	}
 
+	// second fetch reference, should get the rc from the cache
+	gotDesc, rc, err = p.(registry.ReferenceFetcher).FetchReference(ctx, repo.Reference.Reference)
+	if err != nil {
+		t.Fatal("ReferenceTarget.FetchReference() error =", err)
+	}
+	if !reflect.DeepEqual(gotDesc, desc) {
+		t.Fatalf("ReferenceTarget.FetchReference() got %v, want %v", gotDesc, desc)
+	}
+	got, err = io.ReadAll(rc)
+	if err != nil {
+		t.Fatal("io.ReadAll() error =", err)
+	}
+	err = rc.Close()
+	if err != nil {
+		t.Error("ReferenceTarget.FetchReference().Close() error =", err)
+	}
+
+	if !bytes.Equal(got, blob) {
+		t.Errorf("ReferenceTarget.Fetch() = %v, want %v", got, blob)
+	}
+	if wantRequestCount++; requestCount != wantRequestCount {
+		t.Errorf("unexpected number of requests: %d, want %d", requestCount, wantRequestCount)
+	}
+	if wantSuccessCount++; successCount != wantSuccessCount {
+		t.Errorf("unexpected number of successful requests: %d, want %d", successCount, wantSuccessCount)
+	}
+
 	// repeated fetch should not touch base CAS
 	p.(*referenceTarget).ReadOnlyTarget = nil
 	got, err = content.FetchAll(ctx, p, desc)