Repository: oras-project/oras
Pull request #489 (2022-08-11 05:58:07)

feat: support push a blob to a remote registry
Add a command to push a blob to a remote registry

Resolves: https://github.com/oras-project/oras/issues/476
Signed-off-by: Zoey Li <zoeyli@microsoft.com>

qweeah: Please mark the command `blob` and `blob push` preview. You may refer to other comamnds like `attach` or `copy`.
codecov-commenter: # [Codecov](https://codecov.io/gh/oras-project/oras/pull/489?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) Report
> Merging [#489](https://codecov.io/gh/oras-project/oras/pull/489?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) (278691d) 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 `3.76%`.
> The diff coverage is `42.85%`.

```diff
@@            Coverage Diff             @@
##             main     #489      +/-   ##
==========================================
- Coverage   73.43%   69.67%   -3.77%     
==========================================
  Files           9       12       +3     
  Lines         399      455      +56     
==========================================
+ Hits          293      317      +24     
- Misses         85      112      +27     
- Partials       21       26       +5     
```


| [Impacted Files](https://codecov.io/gh/oras-project/oras/pull/489?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project) | Coverage Δ | |
|---|---|---|
| [cmd/oras/internal/option/descriptor.go](https://codecov.io/gh/oras-project/oras/pull/489/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project#diff-Y21kL29yYXMvaW50ZXJuYWwvb3B0aW9uL2Rlc2NyaXB0b3IuZ28=) | `0.00% <0.00%> (ø)` | |
| [internal/file/file.go](https://codecov.io/gh/oras-project/oras/pull/489/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project#diff-aW50ZXJuYWwvZmlsZS9maWxlLmdv) | `41.66% <41.66%> (ø)` | |
| [cmd/oras/internal/option/pretty.go](https://codecov.io/gh/oras-project/oras/pull/489/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=oras-project#diff-Y21kL29yYXMvaW50ZXJuYWwvb3B0aW9uL3ByZXR0eS5nbw==) | `75.00% <75.00%> (ø)` | |

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
index 0ebf471..85193f7 100644
--- a/cmd/oras/blob/cmd.go
+++ b/cmd/oras/blob/cmd.go
@@ -27,6 +27,7 @@ func Cmd() *cobra.Command {
 
 	cmd.AddCommand(
 		fetchCmd(),
+		pushCmd(),
 	)
 	return cmd
 }
diff --git a/cmd/oras/blob/fetch.go b/cmd/oras/blob/fetch.go
index 8c8808d..ecff2d3 100644
--- a/cmd/oras/blob/fetch.go
+++ b/cmd/oras/blob/fetch.go
@@ -16,7 +16,6 @@ limitations under the License.
 package blob
 
 import (
-	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -150,7 +149,7 @@ func fetchBlob(opts fetchBlobOptions) (fetchErr error) {
 
 	// outputs blob's descriptor if `--descriptor` is used
 	if opts.OutputDescriptor {
-		descJSON, err := json.Marshal(desc)
+		descJSON, err := opts.Marshal(desc)
 		if err != nil {
 			return err
 		}
diff --git a/cmd/oras/blob/push.go b/cmd/oras/blob/push.go
new file mode 100644
index 0000000..e79d064
--- /dev/null
+++ b/cmd/oras/blob/push.go
@@ -0,0 +1,146 @@
+/*
+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 (
+	"errors"
+	"fmt"
+	"os"
+
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+	"github.com/spf13/cobra"
+	"oras.land/oras/cmd/oras/internal/display"
+	"oras.land/oras/cmd/oras/internal/option"
+	"oras.land/oras/internal/file"
+)
+
+type pushBlobOptions struct {
+	option.Common
+	option.Descriptor
+	option.Pretty
+	option.Remote
+
+	fileRef   string
+	mediaType string
+	size      int64
+	targetRef string
+}
+
+func pushCmd() *cobra.Command {
+	var opts pushBlobOptions
+	cmd := &cobra.Command{
+		Use:   "push [flags] name[@digest] file",
+		Short: "[Preview] Push a blob to a remote registry",
+		Long: `[Preview] Push a blob to a remote registry
+
+** This command is in preview and under development. **
+
+Example - Push blob "hi.txt":
+  oras blob push localhost:5000/hello hi.txt
+
+Example - Push blob "hi.txt" with the specific digest:
+  oras blob push localhost:5000/hello@sha256:9a201d228ebd966211f7d1131be19f152be428bd373a92071c71d8deaf83b3e5 hi.txt
+
+Example - Push blob from stdin with blob size and digest:
+  oras blob push --size 12 localhost:5000/hello@sha256:9a201d228ebd966211f7d1131be19f152be428bd373a92071c71d8deaf83b3e5 -
+
+Example - Push blob "hi.txt" and output the descriptor:
+  oras blob push --descriptor localhost:5000/hello hi.txt
+
+Example - Push blob "hi.txt" with the specific returned media type in the descriptor:
+  oras blob push --media-type application/vnd.oci.image.config.v1+json --descriptor localhost:5000/hello hi.txt
+
+Example - Push blob "hi.txt" and output the prettified descriptor:
+  oras blob push --descriptor --pretty localhost:5000/hello hi.txt
+
+Example - Push blob without TLS:
+  oras blob push --insecure localhost:5000/hello hi.txt
+`,
+		Args: cobra.ExactArgs(2),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			opts.targetRef = args[0]
+			opts.fileRef = args[1]
+			if opts.fileRef == "-" {
+				if opts.PasswordFromStdin {
+					return errors.New("`-` read file from input and `--password-stdin` read password from input cannot be both used")
+				}
+				if opts.size < 0 {
+					return errors.New("`--size` must be provided if the blob is read from stdin")
+				}
+			}
+			return opts.ReadPassword()
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return pushBlob(opts)
+		},
+	}
+
+	cmd.Flags().Int64VarP(&opts.size, "size", "", -1, "provide the blob size")
+	cmd.Flags().StringVarP(&opts.mediaType, "media-type", "", ocispec.MediaTypeImageLayer, "specify the returned media type in the descriptor if `--descriptor` is used")
+	option.ApplyFlags(&opts, cmd.Flags())
+	return cmd
+}
+
+func pushBlob(opts pushBlobOptions) (err error) {
+	ctx, _ := opts.SetLoggerLevel()
+
+	repo, err := opts.NewRepository(opts.targetRef, opts.Common)
+	if err != nil {
+		return err
+	}
+
+	// prepare blob content
+	desc, rc, err := file.PrepareContent(opts.fileRef, opts.mediaType, repo.Reference.Reference, opts.size)
+	if err != nil {
+		return err
+	}
+	defer rc.Close()
+
+	exists, err := repo.Exists(ctx, desc)
+	if err != nil {
+		return err
+	}
+	verbose := opts.Verbose && !opts.OutputDescriptor
+	if exists {
+		if err := display.PrintStatus(desc, "Exists", verbose); err != nil {
+			return err
+		}
+	} else {
+		if err := display.PrintStatus(desc, "Uploading", verbose); err != nil {
+			return err
+		}
+		if err = repo.Push(ctx, desc, rc); err != nil {
+			return err
+		}
+		if err := display.PrintStatus(desc, "Uploaded ", verbose); err != nil {
+			return err
+		}
+	}
+
+	// outputs blob's descriptor
+	if opts.OutputDescriptor {
+		descJSON, err := opts.Marshal(desc)
+		if err != nil {
+			return err
+		}
+		return opts.Output(os.Stdout, descJSON)
+	}
+
+	fmt.Println("Pushed", opts.targetRef)
+	fmt.Println("Digest:", desc.Digest)
+
+	return nil
+}
diff --git a/cmd/oras/file.go b/cmd/oras/file.go
index ffdfeaa..5b5d1ac 100644
--- a/cmd/oras/file.go
+++ b/cmd/oras/file.go
@@ -28,11 +28,13 @@ func loadFiles(ctx context.Context, store *file.Store, annotations map[string]ma
 	var files []ocispec.Descriptor
 	for _, fileRef := range fileRefs {
 		filename, mediaType := parseFileReference(fileRef, "")
+
+		// get shortest absolute path as unique name
 		name := filepath.Clean(filename)
 		if !filepath.IsAbs(name) {
-			// convert to slash-separated path unless it is absolute path
 			name = filepath.ToSlash(name)
 		}
+
 		if verbose {
 			fmt.Println("Preparing", name)
 		}
diff --git a/internal/file/file.go b/internal/file/file.go
new file mode 100644
index 0000000..987f2c4
--- /dev/null
+++ b/internal/file/file.go
@@ -0,0 +1,96 @@
+/*
+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 file
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"os"
+
+	digest "github.com/opencontainers/go-digest"
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+)
+
+// PrepareContent prepares the content descriptor from the file path or stdin.
+// Use the input digest and size if they are provided. Will return error if the
+// content is from stdin but the content digest and size are missing.
+func PrepareContent(path string, mediaType string, dgstStr string, size int64) (desc ocispec.Descriptor, rc io.ReadCloser, prepareErr error) {
+	if path == "" {
+		return ocispec.Descriptor{}, nil, errors.New("missing file name")
+	}
+
+	// validate digest
+	var dgst digest.Digest
+	if dgstStr != "" {
+		var err error
+		dgst, err = digest.Parse(dgstStr)
+		if err != nil {
+			return ocispec.Descriptor{}, nil, fmt.Errorf("invalid digest %s: %w", dgstStr, err)
+		}
+	}
+
+	// prepares the content descriptor from stdin
+	if path == "-" {
+		// throw err if size or digest is not provided.
+		if size < 0 {
+			return ocispec.Descriptor{}, nil, errors.New("content size must be provided if it is read from stdin")
+		}
+		if dgst == "" {
+			return ocispec.Descriptor{}, nil, errors.New("content digest must be provided if it is read from stdin")
+		}
+		return ocispec.Descriptor{
+			MediaType: mediaType,
+			Digest:    dgst,
+			Size:      size,
+		}, os.Stdin, nil
+	}
+
+	file, err := os.Open(path)
+	if err != nil {
+		return ocispec.Descriptor{}, nil, fmt.Errorf("failed to open %s: %w", path, err)
+	}
+	defer func() {
+		if prepareErr != nil {
+			file.Close()
+		}
+	}()
+
+	fi, err := file.Stat()
+	if err != nil {
+		return ocispec.Descriptor{}, nil, fmt.Errorf("failed to stat %s: %w", path, err)
+	}
+	actualSize := fi.Size()
+	if size >= 0 && size != actualSize {
+		return ocispec.Descriptor{}, nil, fmt.Errorf("input size %d does not match the actual content size %d", size, actualSize)
+	}
+
+	if dgst == "" {
+		dgst, err = digest.FromReader(file)
+		if err != nil {
+			return ocispec.Descriptor{}, nil, err
+		}
+		if _, err = file.Seek(0, io.SeekStart); err != nil {
+			return ocispec.Descriptor{}, nil, err
+		}
+	}
+
+	return ocispec.Descriptor{
+		MediaType: mediaType,
+		Digest:    dgst,
+		Size:      actualSize,
+	}, file, nil
+}


Test patch:
diff --git a/internal/file/file_test.go b/internal/file/file_test.go
new file mode 100644
index 0000000..32e060c
--- /dev/null
+++ b/internal/file/file_test.go
@@ -0,0 +1,193 @@
+/*
+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 file_test
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"reflect"
+	"strings"
+	"testing"
+
+	digest "github.com/opencontainers/go-digest"
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+	"oras.land/oras/internal/file"
+)
+
+const blobMediaType = "application/mock-octet-stream"
+
+func TestFile_PrepareContent(t *testing.T) {
+	// generate test content
+	tempDir := t.TempDir()
+	content := []byte("hello world!")
+	fileName := "test.txt"
+	path := filepath.Join(tempDir, fileName)
+	if err := os.WriteFile(path, content, 0444); err != nil {
+		t.Fatal("error calling WriteFile(), error =", err)
+	}
+
+	want := ocispec.Descriptor{
+		MediaType: blobMediaType,
+		Digest:    digest.FromBytes(content),
+		Size:      int64(len(content)),
+	}
+
+	// test PrepareContent
+	got, rc, err := file.PrepareContent(path, blobMediaType, "", -1)
+	if err != nil {
+		t.Fatal("PrepareContent() error=", err)
+	}
+	if !reflect.DeepEqual(got, want) {
+		t.Errorf("PrepareContent() = %v, want %v", got, want)
+	}
+	actualContent, err := io.ReadAll(rc)
+	if err != nil {
+		t.Fatal("PrepareContent(): not able to read content from rc, error=", err)
+	}
+	err = rc.Close()
+	if err != nil {
+		t.Fatal("error calling rc.Close(), error =", err)
+	}
+	if !reflect.DeepEqual(actualContent, content) {
+		t.Errorf("PrepareContent() = %v, want %v", actualContent, content)
+	}
+
+	// test PrepareContent with provided digest and size
+	dgstStr := "sha256:9a201d228ebd966211f7d1131be19f152be428bd373a92071c71d8deaf83b3e5"
+	size := int64(12)
+	got, rc, err = file.PrepareContent(path, blobMediaType, dgstStr, size)
+	if err != nil {
+		t.Fatal("PrepareContent() error=", err)
+	}
+	want = ocispec.Descriptor{
+		MediaType: blobMediaType,
+		Digest:    digest.Digest(dgstStr),
+		Size:      size,
+	}
+	if !reflect.DeepEqual(got, want) {
+		t.Errorf("PrepareContent() = %v, want %v", got, want)
+	}
+	actualContent, err = io.ReadAll(rc)
+	if err != nil {
+		t.Fatal("PrepareContent(): not able to read content from rc, error=", err)
+	}
+	err = rc.Close()
+	if err != nil {
+		t.Fatal("error calling rc.Close(), error =", err)
+	}
+	if !reflect.DeepEqual(actualContent, content) {
+		t.Errorf("PrepareContent() = %v, want %v", actualContent, content)
+	}
+
+	// test PrepareContent with provided size, but the size does not match the
+	// actual content size
+	_, _, err = file.PrepareContent(path, blobMediaType, "", 15)
+	expected := fmt.Sprintf("input size %d does not match the actual content size %d", 15, size)
+	if err.Error() != expected {
+		t.Fatalf("PrepareContent() error = %v, wantErr %v", err, expected)
+	}
+}
+
+func TestFile_PrepareContent_fromStdin(t *testing.T) {
+	// generate test content
+	content := []byte("hello world!")
+	tempDir := t.TempDir()
+	fileName := "test.txt"
+	path := filepath.Join(tempDir, fileName)
+	tmpfile, err := os.Create(path)
+	if err != nil {
+		t.Fatal("error calling os.Create(), error =", err)
+	}
+	defer tmpfile.Close()
+
+	if _, err := tmpfile.Write(content); err != nil {
+		t.Fatal("error calling Write(), error =", err)
+	}
+	if _, err := tmpfile.Seek(0, 0); err != nil {
+		t.Fatal("error calling Seek(), error =", err)
+	}
+
+	defer func(stdin *os.File) { os.Stdin = stdin }(os.Stdin)
+
+	os.Stdin = tmpfile
+	dgst := digest.FromBytes(content)
+	size := int64(len(content))
+	wantDesc := ocispec.Descriptor{
+		MediaType: blobMediaType,
+		Digest:    digest.FromBytes(content),
+		Size:      int64(len(content)),
+	}
+
+	// test PrepareContent with provided digest and size
+	gotDesc, gotRc, err := file.PrepareContent("-", blobMediaType, string(dgst), size)
+	defer gotRc.Close()
+	if err != nil {
+		t.Fatal("PrepareContent() error=", err)
+	}
+	if !reflect.DeepEqual(gotDesc, wantDesc) {
+		t.Errorf("PrepareContent() = %v, want %v", gotDesc, wantDesc)
+	}
+	if _, err = tmpfile.Seek(0, io.SeekStart); err != nil {
+		t.Fatal("error calling Seek(), error =", err)
+	}
+	if !reflect.DeepEqual(gotRc, tmpfile) {
+		t.Errorf("PrepareContent() = %v, want %v", gotRc, tmpfile)
+	}
+
+	// test PrepareContent from stdin with missing size
+	_, _, err = file.PrepareContent("-", blobMediaType, "", -1)
+	expected := "content size must be provided if it is read from stdin"
+	if err.Error() != expected {
+		t.Fatalf("PrepareContent() error = %v, wantErr %v", err, expected)
+	}
+
+	// test PrepareContent from stdin with missing digest
+	_, _, err = file.PrepareContent("-", blobMediaType, "", 5)
+	expected = "content digest must be provided if it is read from stdin"
+	if err.Error() != expected {
+		t.Fatalf("PrepareContent() error = %v, wantErr %v", err, expected)
+	}
+}
+
+func TestFile_PrepareContent_errDigestInvalidFormat(t *testing.T) {
+	// test PrepareContent from stdin with invalid digest
+	invalidDgst := "xyz"
+	_, _, err := file.PrepareContent("-", blobMediaType, invalidDgst, 12)
+	if !errors.Is(err, digest.ErrDigestInvalidFormat) {
+		t.Fatalf("PrepareContent() error = %v, wantErr %v", err, digest.ErrDigestInvalidFormat)
+	}
+}
+
+func TestFile_PrepareContent_errMissingFileName(t *testing.T) {
+	// test PrepareContent with missing file name
+	_, _, err := file.PrepareContent("", blobMediaType, "", -1)
+	expected := "missing file name"
+	if err.Error() != expected {
+		t.Fatalf("PrepareContent() error = %v, wantErr %v", err, expected)
+	}
+}
+
+func TestFile_PrepareContent_errOpenFile(t *testing.T) {
+	// test PrepareContent with nonexistent file
+	_, _, err := file.PrepareContent("nonexistent.txt", blobMediaType, "", -1)
+	expected := "failed to open nonexistent.txt"
+	if !strings.Contains(err.Error(), expected) {
+		t.Fatalf("PrepareContent() error = %v, wantErr %v", err, expected)
+	}
+}