Repository: regclient/regclient
Pull request #669 (2024-02-23 00:01:32)

Add regctl blob delete and ocidir blob delete API
<!--

Commits must be signed indicating your agreement to the [DCO](https://developercertificate.org/).
See [DCO missing](https://github.com/src-d/guide/blob/master/developer-community/fix-DCO.md) for steps to fix a missing signoff.

-->

### Fixes issue

<!-- If this is a bug fix, include "fixes #xxxx", or "closes #xxxx" -->

### Describe the change

This adds support for `BlobDelete` on an ocidir reference. And it adds the `regctl blob delete` CLI. This should typically not be used, in favor of letting the registry run a garbage collection. However, it may be useful for repairing a corrupt registry.
<!-- Include the type of change: bug fix, new feature, breaking change, documentation update -->
<!-- Describe what was changed, why the change was made, and how it was implemented -->

### How to verify it

```shell
digest="$(echo "hello world" | regctl blob put localhost:5000/test)"
regctl blob get localhost:5000/test $digest
regctl blob delete localhost:5000/test $digest
regctl blob get localhost:5000/test $digest
```
<!-- Include steps that can be taken to verify the change -->

### Changelog text

- Feature: Add `BlobDelete` support for ocidir references.
- Feature: Add `regctl blob delete` command.
<!-- If the release changelog should have an entry for this, include it here -->

### Please verify and check that the pull request fulfills the following requirements

<!-- Mark the following with an [X] to verify they are included -->

- [X] Tests have been added or not applicable
- [X] Documentation has been added, updated, or not applicable
- [X] Changes have been rebased to main
- [X] Multiple commits to the same code have been squashed

<!-- markdownlint-disable-file MD041 -->



Patch:
diff --git a/cmd/regctl/blob.go b/cmd/regctl/blob.go
index 0490b6c..52cf9db 100644
--- a/cmd/regctl/blob.go
+++ b/cmd/regctl/blob.go
@@ -47,6 +47,22 @@ func NewBlobCmd(rootOpts *rootCmd) *cobra.Command {
 		Aliases: []string{"layer"},
 		Short:   "manage image blobs/layers",
 	}
+	var blobDeleteCmd = &cobra.Command{
+		Use:     "delete <repository> <digest>",
+		Aliases: []string{"del", "rm"},
+		Short:   "delete a blob",
+		Long: `Delete a blob from the registry. This is rarely needed since registries should
+have their own garbage collection algorithms and may clean unreferenced blobs
+automatically. This command is useful for repairing a corrupt registry. The
+blob or layer digest can be found in the image manifest.`,
+		Example: `
+# delete a blob
+regctl blob delete registry.example.org/repo \
+  sha256:a58ecd4f0c864650a4286c3c2d49c7219a3f2fc8d7a0bf478aa9834acfe14ae7`,
+		Args:      cobra.ExactArgs(2),
+		ValidArgs: []string{}, // do not auto complete repository or digest
+		RunE:      blobOpts.runBlobDelete,
+	}
 	var blobDiffConfigCmd = &cobra.Command{
 		Use:   "diff-config <repository> <digest> <repository> <digest>",
 		Short: "diff two image configs",
@@ -178,6 +194,7 @@ regctl blob copy alpine registry.example.org/library/alpine \
 	_ = blobPutCmd.RegisterFlagCompletionFunc("digest", completeArgNone)
 	_ = blobPutCmd.Flags().MarkHidden("content-type")
 
+	blobTopCmd.AddCommand(blobDeleteCmd)
 	blobTopCmd.AddCommand(blobDiffConfigCmd)
 	blobTopCmd.AddCommand(blobDiffLayerCmd)
 	blobTopCmd.AddCommand(blobGetCmd)
@@ -189,6 +206,27 @@ regctl blob copy alpine registry.example.org/library/alpine \
 	return blobTopCmd
 }
 
+func (blobOpts *blobCmd) runBlobDelete(cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	r, err := ref.New(args[0])
+	if err != nil {
+		return err
+	}
+	d, err := digest.Parse(args[1])
+	if err != nil {
+		return err
+	}
+	rc := blobOpts.rootOpts.newRegClient()
+	defer rc.Close(ctx, r)
+
+	log.WithFields(logrus.Fields{
+		"host":       r.Registry,
+		"repository": r.Repository,
+		"digest":     args[1],
+	}).Debug("Deleting blob")
+	return rc.BlobDelete(ctx, r, types.Descriptor{Digest: d})
+}
+
 func (blobOpts *blobCmd) runBlobDiffConfig(cmd *cobra.Command, args []string) error {
 	diffOpts := []diff.Opt{}
 	if blobOpts.diffCtx > 0 {
diff --git a/scheme/ocidir/blob.go b/scheme/ocidir/blob.go
index 7c69192..bf93746 100644
--- a/scheme/ocidir/blob.go
+++ b/scheme/ocidir/blob.go
@@ -21,9 +21,12 @@ import (
 	"github.com/regclient/regclient/types/ref"
 )
 
-// BlobDelete removes a blob from the repository
+// BlobDelete removes a blob from the repository.
+// This method does not verify that blobs are unused.
+// Calling the [OCIDir.Close] method to trigger the garbage collection is preferred.
 func (o *OCIDir) BlobDelete(ctx context.Context, r ref.Ref, d types.Descriptor) error {
-	return types.ErrNotImplemented
+	file := path.Join(r.Path, "blobs", d.Digest.Algorithm().String(), d.Digest.Encoded())
+	return o.fs.Remove(file)
 }
 
 // BlobGet retrieves a blob, returning a reader


Test patch:
diff --git a/cmd/regctl/blob_test.go b/cmd/regctl/blob_test.go
index 902e14e..a5d01c8 100644
--- a/cmd/regctl/blob_test.go
+++ b/cmd/regctl/blob_test.go
@@ -53,7 +53,7 @@ func TestBlob(t *testing.T) {
 		}
 	})
 
-	t.Run("Put", func(t *testing.T) {
+	t.Run("Put and Delete", func(t *testing.T) {
 		dir := t.TempDir()
 		bufStr := "hello world"
 		cobraOpts := cobraTestOpts{
@@ -62,7 +62,7 @@ func TestBlob(t *testing.T) {
 		// put a blob
 		dig, err := cobraTest(t, &cobraOpts, "blob", "put", "--format", "{{println .Digest}}", "ocidir://"+dir)
 		if err != nil {
-			t.Errorf("failed to blob copy: %v", err)
+			t.Errorf("failed to put blob: %v", err)
 		}
 		// get the blob from the tempdir
 		out, err := cobraTest(t, nil, "blob", "get", "--format", "{{printPretty .}}", "ocidir://"+dir, dig)
@@ -72,6 +72,16 @@ func TestBlob(t *testing.T) {
 		if out != bufStr {
 			t.Errorf("unexpected blob output, expected %s, received %s", bufStr, out)
 		}
+		// delete the blob
+		_, err = cobraTest(t, nil, "blob", "delete", "ocidir://"+dir, dig)
+		if err != nil {
+			t.Errorf("failed to delete blob: %v", err)
+		}
+		// verify blob was deleted
+		_, err = cobraTest(t, nil, "blob", "get", "--format", "{{printPretty .}}", "ocidir://"+dir, dig)
+		if err == nil {
+			t.Errorf("get deleted blob did not fail")
+		}
 	})
 
 	t.Run("Copy", func(t *testing.T) {
diff --git a/scheme/ocidir/blob_test.go b/scheme/ocidir/blob_test.go
index c552d07..a6c87a5 100644
--- a/scheme/ocidir/blob_test.go
+++ b/scheme/ocidir/blob_test.go
@@ -136,15 +136,23 @@ func TestBlob(t *testing.T) {
 	if err != nil {
 		t.Errorf("blob put open file: %v", err)
 	}
-	defer fd.Close()
 	fBytes, err := io.ReadAll(fd)
+	_ = fd.Close()
 	if err != nil {
 		t.Errorf("blob put readall: %v", err)
 	}
 	if !bytes.Equal(fBytes, bBytes) {
 		t.Errorf("blob put bytes, expected %s, saw %s", string(bBytes), string(fBytes))
 	}
-
+	// blob delete (from memfs)
+	err = om.BlobDelete(ctx, r, cd)
+	if err != nil {
+		t.Errorf("failed to delete blob: %v", err)
+	}
+	_, err = fm.Stat(fmt.Sprintf("testdata/regctl/blobs/%s/%s", cd.Digest.Algorithm().String(), cd.Digest.Encoded()))
+	if err == nil {
+		t.Errorf("stat of a deleted blob did not fail")
+	}
 	// concurrent blob put, without the descriptor to test for races
 	rPut, err := ref.New(fmt.Sprintf("%s@%s", "ocidir://testdata/put:latest", dl[0].Digest))
 	if err != nil {
@@ -176,8 +184,8 @@ func TestBlob(t *testing.T) {
 	if err != nil {
 		t.Errorf("blob put open file: %v", err)
 	}
-	defer fd.Close()
 	fBytes, err = io.ReadAll(fd)
+	_ = fd.Close()
 	if err != nil {
 		t.Errorf("blob put readall: %v", err)
 	}