Repository: oras-project/oras (go)

Issue:
return success when force deleting a non-existent blob/manifest
When force deleting via `oras manifest/blob delete`, if the target reference is not found in the remote registry, then user will see an error message like below:

```
$ oras blob delete localhost:5000/command/blob/delete/1671102430@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa --force
Error: localhost:5000/command/blob/delete/1671102430@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: the specified blob does not exist
```

The returned value should indicate success if successfully resolving the reference and registry returns not found.

@FeynmanZhou

Pull request description:
feat!: force deleting a non-existent blob/manifest
Resolves #722 

Signed-off-by: Billy Zha <jinzha1@microsoft.com>

Patch:
diff --git a/cmd/oras/blob/delete.go b/cmd/oras/blob/delete.go
index 88bf88c..3ce58df 100644
--- a/cmd/oras/blob/delete.go
+++ b/cmd/oras/blob/delete.go
@@ -56,7 +56,7 @@ Example - Delete a blob and print its descriptor:
   `,
 		Args: cobra.ExactArgs(1),
 		PreRunE: func(cmd *cobra.Command, args []string) error {
-			if opts.OutputDescriptor && !opts.Confirmed {
+			if opts.OutputDescriptor && !opts.Force {
 				return errors.New("must apply --force to confirm the deletion if the descriptor is outputted")
 			}
 			return option.Parse(&opts)
@@ -86,6 +86,11 @@ func deleteBlob(opts deleteBlobOptions) (err error) {
 	desc, err := blobs.Resolve(ctx, opts.targetRef)
 	if err != nil {
 		if errors.Is(err, errdef.ErrNotFound) {
+			if opts.Force && !opts.OutputDescriptor {
+				// ignore nonexistent
+				fmt.Println("Missing", opts.targetRef)
+				return nil
+			}
 			return fmt.Errorf("%s: the specified blob does not exist", opts.targetRef)
 		}
 		return err
diff --git a/cmd/oras/internal/option/confirmation.go b/cmd/oras/internal/option/confirmation.go
index 8780829..d7da2d2 100644
--- a/cmd/oras/internal/option/confirmation.go
+++ b/cmd/oras/internal/option/confirmation.go
@@ -26,18 +26,18 @@ import (
 
 // Confirmation option struct.
 type Confirmation struct {
-	Confirmed bool
+	Force bool
 }
 
 // ApplyFlags applies flags to a command flag set.
 func (opts *Confirmation) ApplyFlags(fs *pflag.FlagSet) {
-	fs.BoolVarP(&opts.Confirmed, "force", "f", false, "do not prompt for confirmation")
+	fs.BoolVarP(&opts.Force, "force", "f", false, "ignore nonexistent references, never prompt")
 }
 
 // AskForConfirmation prints a propmt to ask for confirmation before doing an
 // action and takes user input as response.
 func (opts *Confirmation) AskForConfirmation(r io.Reader, prompt string) (bool, error) {
-	if opts.Confirmed {
+	if opts.Force {
 		return true, nil
 	}
 
diff --git a/cmd/oras/manifest/delete.go b/cmd/oras/manifest/delete.go
index 09751eb..6285f82 100644
--- a/cmd/oras/manifest/delete.go
+++ b/cmd/oras/manifest/delete.go
@@ -60,7 +60,7 @@ Example - Delete a manifest by digest 'sha256:99e4703fbf30916f549cd6bfa9cdbab614
 `,
 		Args: cobra.ExactArgs(1),
 		PreRunE: func(cmd *cobra.Command, args []string) error {
-			if opts.OutputDescriptor && !opts.Confirmed {
+			if opts.OutputDescriptor && !opts.Force {
 				return errors.New("must apply --force to confirm the deletion if the descriptor is outputted")
 			}
 			return option.Parse(&opts)
@@ -90,6 +90,11 @@ func deleteManifest(opts deleteOptions) error {
 	desc, err := manifests.Resolve(ctx, opts.targetRef)
 	if err != nil {
 		if errors.Is(err, errdef.ErrNotFound) {
+			if opts.Force && !opts.OutputDescriptor {
+				// ignore nonexistent
+				fmt.Println("Missing", opts.targetRef)
+				return nil
+			}
 			return fmt.Errorf("%s: the specified manifest does not exist", opts.targetRef)
 		}
 		return err


Test patch:
diff --git a/cmd/oras/internal/option/confirmation_test.go b/cmd/oras/internal/option/confirmation_test.go
index 77e82b1..95c2cfe 100644
--- a/cmd/oras/internal/option/confirmation_test.go
+++ b/cmd/oras/internal/option/confirmation_test.go
@@ -26,14 +26,14 @@ import (
 func TestConfirmation_ApplyFlags(t *testing.T) {
 	var test struct{ Confirmation }
 	ApplyFlags(&test, pflag.NewFlagSet("oras-test", pflag.ExitOnError))
-	if test.Confirmation.Confirmed != false {
-		t.Fatalf("expecting Confirmed to be false but got: %v", test.Confirmation.Confirmed)
+	if test.Confirmation.Force != false {
+		t.Fatalf("expecting Confirmed to be false but got: %v", test.Confirmation.Force)
 	}
 }
 
 func TestConfirmation_AskForConfirmation_forciblyConfirmed(t *testing.T) {
 	opts := Confirmation{
-		Confirmed: true,
+		Force: true,
 	}
 	r := strings.NewReader("")
 
@@ -48,7 +48,7 @@ func TestConfirmation_AskForConfirmation_forciblyConfirmed(t *testing.T) {
 
 func TestConfirmation_AskForConfirmation_manuallyConfirmed(t *testing.T) {
 	opts := Confirmation{
-		Confirmed: false,
+		Force: false,
 	}
 
 	r := strings.NewReader("yes")
diff --git a/test/e2e/suite/command/blob.go b/test/e2e/suite/command/blob.go
index d7c3870..9ba7e75 100644
--- a/test/e2e/suite/command/blob.go
+++ b/test/e2e/suite/command/blob.go
@@ -26,7 +26,7 @@ import (
 const (
 	pushContent      = "test-blob"
 	pushDigest       = "sha256:e1ca41574914ba00e8ed5c8fc78ec8efdfd48941c7e48ad74dad8ada7f2066d8"
-	wrongDigest      = "sha256:e1ca41574914ba00e8ed5c8fc78ec8efdfd48941c7e48ad74dad8ada7f2066d9"
+	invalidDigest    = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
 	pushDescFmt      = `{"mediaType":"%s","digest":"sha256:e1ca41574914ba00e8ed5c8fc78ec8efdfd48941c7e48ad74dad8ada7f2066d8","size":9}`
 	deleteDigest     = "sha256:fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9"
 	deleteDescriptor = `{"mediaType":"application/octet-stream","digest":"sha256:fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9","size":3}`
@@ -63,7 +63,7 @@ var _ = Describe("ORAS beginners:", func() {
 
 			It("should fail to push a blob from stdin if invalid digest provided", func() {
 				repo := fmt.Sprintf(repoFmt, "push", "invalid-stdin-digest")
-				ORAS("blob", "push", Reference(Host, repo, wrongDigest), "-", "--size", strconv.Itoa(len(pushContent))).
+				ORAS("blob", "push", Reference(Host, repo, invalidDigest), "-", "--size", strconv.Itoa(len(pushContent))).
 					WithInput(strings.NewReader(pushContent)).ExpectFailure().
 					Exec()
 			})
@@ -79,7 +79,7 @@ var _ = Describe("ORAS beginners:", func() {
 			It("should fail to push a blob from file if invalid digest provided", func() {
 				repo := fmt.Sprintf(repoFmt, "push", "invalid-stdin-size")
 				blobPath := WriteTempFile("blob", pushContent)
-				ORAS("blob", "push", Reference(Host, repo, wrongDigest), blobPath, "--size", strconv.Itoa(len(pushContent))).
+				ORAS("blob", "push", Reference(Host, repo, invalidDigest), blobPath, "--size", strconv.Itoa(len(pushContent))).
 					WithInput(strings.NewReader(pushContent)).ExpectFailure().
 					Exec()
 			})
@@ -139,7 +139,7 @@ var _ = Describe("ORAS beginners:", func() {
 			ORAS("blob", "fetch", Reference(Host, dstRepo, deleteDigest), "--output", "-").MatchContent(deleteContent).Exec()
 		})
 
-		It("should fail if no confirmation flag and descriptor flag is provided", func() {
+		It("should fail if no force flag and descriptor flag is provided", func() {
 			dstRepo := fmt.Sprintf(repoFmt, "delete", "no-confirm")
 			ORAS("cp", Reference(Host, Repo, FoobarImageDigest), Reference(Host, dstRepo, FoobarImageDigest)).Exec()
 			ORAS("blob", "delete", Reference(Host, dstRepo, deleteDigest), "--descriptor").ExpectFailure().Exec()
@@ -152,6 +152,22 @@ var _ = Describe("ORAS beginners:", func() {
 			ORAS("blob", "delete", fmt.Sprintf("%s/%s:%s", Host, dstRepo, "test"), "--descriptor", "--force").ExpectFailure().Exec()
 			ORAS("blob", "delete", fmt.Sprintf("%s/%s@%s", Host, dstRepo, "test"), "--descriptor", "--force").ExpectFailure().Exec()
 		})
+
+		It("should fail to delete a non-existent blob without force flag set", func() {
+			toDeleteRef := Reference(Host, Repo, invalidDigest)
+			ORAS("blob", "delete", toDeleteRef).
+				ExpectFailure().
+				MatchErrKeyWords(toDeleteRef, "the specified blob does not exist").
+				Exec()
+		})
+
+		It("should fail to delete a non-existent blob and output descriptor, with force flag set", func() {
+			toDeleteRef := Reference(Host, Repo, invalidDigest)
+			ORAS("blob", "delete", toDeleteRef, "--force", "--descriptor").
+				ExpectFailure().
+				MatchErrKeyWords(toDeleteRef, "the specified blob does not exist").
+				Exec()
+		})
 	})
 })
 
@@ -166,19 +182,25 @@ var _ = Describe("Common registry users:", func() {
 				WithInput(strings.NewReader("y")).
 				MatchKeyWords("Deleted", toDeleteRef).Exec()
 			ORAS("blob", "delete", toDeleteRef).
+				WithDescription("validate").
 				WithInput(strings.NewReader("y")).
 				ExpectFailure().
 				MatchErrKeyWords("Error:", toDeleteRef, "the specified blob does not exist").Exec()
 		})
 
-		It("should delete a blob with confirmation flag and output descriptor", func() {
+		It("should delete a blob with force flag and output descriptor", func() {
 			dstRepo := fmt.Sprintf(repoFmt, "delete", "flag-confirmation")
 			ORAS("cp", Reference(Host, Repo, FoobarImageDigest), Reference(Host, dstRepo, FoobarImageDigest)).Exec()
 			toDeleteRef := Reference(Host, dstRepo, deleteDigest)
 			ORAS("blob", "delete", toDeleteRef, "--force", "--descriptor").MatchContent(deleteDescriptor).Exec()
-			ORAS("blob", "delete", toDeleteRef, "--force", "--descriptor").
-				ExpectFailure().
-				MatchErrKeyWords("Error:", toDeleteRef, "the specified blob does not exist").Exec()
+			ORAS("blob", "delete", toDeleteRef).WithDescription("validate").ExpectFailure().MatchErrKeyWords("Error:", toDeleteRef, "the specified blob does not exist").Exec()
+		})
+
+		It("should return success when deleting a non-existent blob with force flag set", func() {
+			toDeleteRef := Reference(Host, Repo, invalidDigest)
+			ORAS("blob", "delete", toDeleteRef, "--force").
+				MatchKeyWords("Missing", toDeleteRef).
+				Exec()
 		})
 	})
 	When("running `blob push`", func() {
diff --git a/test/e2e/suite/command/manifest.go b/test/e2e/suite/command/manifest.go
index 2f79d9b..d1c4450 100644
--- a/test/e2e/suite/command/manifest.go
+++ b/test/e2e/suite/command/manifest.go
@@ -89,6 +89,30 @@ var _ = Describe("ORAS beginners:", func() {
 				ORAS("manifest", "delete", Reference(Host, dstRepo, tempTag), "--descriptor").ExpectFailure().Exec()
 			})
 
+			It("should fail to delete a non-existent manifest via digest without force flag set", func() {
+				toDeleteRef := Reference(Host, Repo, invalidDigest)
+				ORAS("manifest", "delete", toDeleteRef).
+					ExpectFailure().
+					MatchErrKeyWords(toDeleteRef, "the specified manifest does not exist").
+					Exec()
+			})
+
+			It("should fail to delete a non-existent manifest and output descriptor via digest, with force flag set", func() {
+				toDeleteRef := Reference(Host, Repo, invalidDigest)
+				ORAS("manifest", "delete", toDeleteRef, "--force", "--descriptor").
+					ExpectFailure().
+					MatchErrKeyWords(toDeleteRef, "the specified manifest does not exist").
+					Exec()
+			})
+
+			It("should fail to delete a non-existent manifest and output descriptor via tag, without force flag set", func() {
+				toDeleteRef := Reference(Host, Repo, "this.tag.should-not.be-existed")
+				ORAS("manifest", "delete", toDeleteRef, "--force", "--descriptor").
+					ExpectFailure().
+					MatchErrKeyWords(toDeleteRef, "the specified manifest does not exist").
+					Exec()
+			})
+
 			It("should fail if no blob reference provided", func() {
 				dstRepo := fmt.Sprintf(repoFmt, "delete", "no-reference")
 				prepare(Reference(Host, Repo, FoobarImageTag), Reference(Host, dstRepo, tempTag))
@@ -320,5 +344,12 @@ var _ = Describe("Common registry users:", func() {
 				WithDescription("cancel without confirmation").Exec()
 			validate(Reference(Host, dstRepo, ""), tempTag, true)
 		})
+
+		It("should succeed when deleting a non-existent manifest with force flag set", func() {
+			toDeleteRef := Reference(Host, Repo, invalidDigest)
+			ORAS("manifest", "delete", toDeleteRef, "--force").
+				MatchKeyWords("Missing", toDeleteRef).
+				Exec()
+		})
 	})
 })