Repository: peak/s5cmd (go)

Issue:
feature: add remove bucket command
Currently s5cmd can't remove bucket, like `s5cmd rb s3://bucket`🙄

Pull request:
Implement 'remove bucket' feature
Resolves #303

Patch:
diff --git a/README.md b/README.md
index 74a549e..83b2963 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ storage services and local filesystems.
 - Set Server Side Encryption using AWS Key Management Service (KMS)
 - Set Access Control List (ACL) for objects/files on the upload, copy, move.
 - Print object contents to stdout
-- Create buckets
+- Create or remove buckets
 - Summarize objects sizes, grouping by storage class
 - Wildcard support for all operations
 - Multiple arguments support for delete operation
diff --git a/command/app.go b/command/app.go
index 1011abb..3a9b286 100644
--- a/command/app.go
+++ b/command/app.go
@@ -149,6 +149,7 @@ func Main(ctx context.Context, args []string) error {
 		deleteCommand,
 		moveCommand,
 		makeBucketCommand,
+		removeBucketCommand,
 		sizeCommand,
 		catCommand,
 		runCommand,
diff --git a/command/rb.go b/command/rb.go
new file mode 100644
index 0000000..e222b1a
--- /dev/null
+++ b/command/rb.go
@@ -0,0 +1,88 @@
+package command
+
+import (
+	"context"
+
+	"github.com/urfave/cli/v2"
+
+	"github.com/peak/s5cmd/log"
+	"github.com/peak/s5cmd/log/stat"
+	"github.com/peak/s5cmd/storage"
+	"github.com/peak/s5cmd/storage/url"
+)
+
+var removeBucketHelpTemplate = `Name:
+	{{.HelpName}} - {{.Usage}}
+
+Usage:
+	{{.HelpName}} bucketname
+
+Options:
+	{{range .VisibleFlags}}{{.}}
+	{{end}}
+Examples:
+	1. Deletes S3 bucket with given name
+		 > s5cmd {{.HelpName}} bucketname
+`
+
+var removeBucketCommand = &cli.Command{
+	Name:               "rb",
+	HelpName:           "rb",
+	Usage:              "remove bucket",
+	CustomHelpTemplate: removeBucketHelpTemplate,
+	Before: func(c *cli.Context) error {
+		err := validateMBCommand(c) // uses same validation function with make bucket command.
+		if err != nil {
+			printError(givenCommand(c), c.Command.Name, err)
+		}
+		return err
+	},
+	Action: func(c *cli.Context) (err error) {
+		defer stat.Collect(c.Command.FullName(), &err)()
+
+		return RemoveBucket{
+			src:         c.Args().First(),
+			op:          c.Command.Name,
+			fullCommand: givenCommand(c),
+
+			storageOpts: NewStorageOpts(c),
+		}.Run(c.Context)
+	},
+}
+
+// RemoveBucket holds bucket deletion operation flags and states.
+type RemoveBucket struct {
+	src         string
+	op          string
+	fullCommand string
+
+	storageOpts storage.Options
+}
+
+// Run removes a bucket.
+func (b RemoveBucket) Run(ctx context.Context) error {
+	bucket, err := url.New(b.src)
+	if err != nil {
+		printError(b.fullCommand, b.op, err)
+		return err
+	}
+
+	client, err := storage.NewRemoteClient(ctx, &url.URL{}, b.storageOpts)
+	if err != nil {
+		printError(b.fullCommand, b.op, err)
+		return err
+	}
+
+	if err := client.RemoveBucket(ctx, bucket.Bucket); err != nil {
+		printError(b.fullCommand, b.op, err)
+		return err
+	}
+
+	msg := log.InfoMessage{
+		Operation: b.op,
+		Source:    bucket,
+	}
+	log.Info(msg)
+
+	return nil
+}
diff --git a/storage/s3.go b/storage/s3.go
index 9b57985..15eef5e 100644
--- a/storage/s3.go
+++ b/storage/s3.go
@@ -605,6 +605,18 @@ func (s *S3) MakeBucket(ctx context.Context, name string) error {
 	return err
 }
 
+// RemoveBucket removes an S3 bucket with the given name.
+func (s *S3) RemoveBucket(ctx context.Context, name string) error {
+	if s.dryRun {
+		return nil
+	}
+
+	_, err := s.api.DeleteBucketWithContext(ctx, &s3.DeleteBucketInput{
+		Bucket: aws.String(name),
+	})
+	return err
+}
+
 // SessionCache holds session.Session according to s3Opts and it synchronizes
 // access/modification.
 type SessionCache struct {


Test patch:
diff --git a/e2e/rb_test.go b/e2e/rb_test.go
new file mode 100644
index 0000000..9109df0
--- /dev/null
+++ b/e2e/rb_test.go
@@ -0,0 +1,132 @@
+package e2e
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/s3"
+
+	"gotest.tools/v3/icmd"
+)
+
+func TestRemoveBucketSuccess(t *testing.T) {
+
+	t.Parallel()
+	s3client, s5cmd, cleanup := setup(t)
+	defer cleanup()
+
+	bucketName := "bucket"
+	src := fmt.Sprintf("s3://%v", bucketName)
+
+	createBucket(t, s3client, bucketName)
+
+	cmd := s5cmd("rb", src)
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Success)
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: equals(`rb %v`, src),
+	})
+
+	_, err := s3client.HeadBucket(&s3.HeadBucketInput{Bucket: aws.String(bucketName)})
+
+	if err == nil {
+		t.Errorf("bucket still exists after remove bucket operation\n")
+	}
+}
+
+func TestRemoveBucketSuccessJson(t *testing.T) {
+	t.Parallel()
+	s3client, s5cmd, cleanup := setup(t)
+	defer cleanup()
+
+	bucketName := "bucket"
+	src := fmt.Sprintf("s3://%v", bucketName)
+
+	createBucket(t, s3client, bucketName)
+
+	cmd := s5cmd("--json", "rb", src)
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Success)
+
+	jsonText := `
+		{
+			"operation": "rb",
+			"success": true,
+			"source": "%v"
+		}
+	`
+
+	assertLines(t, result.Stdout(), map[int]compareFunc{
+		0: json(jsonText, src),
+	}, jsonCheck(true))
+
+	_, err := s3client.HeadBucket(&s3.HeadBucketInput{Bucket: aws.String(bucketName)})
+	if err == nil {
+		t.Errorf("bucket still exists after remove bucket operation\n")
+	}
+}
+
+func TestRemoveBucketFailure(t *testing.T) {
+	t.Parallel()
+	_, s5cmd, cleanup := setup(t)
+	defer cleanup()
+
+	bucketName := "invalid/bucket/name"
+	src := fmt.Sprintf("s3://%s", bucketName)
+	cmd := s5cmd("rb", src)
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Expected{ExitCode: 1})
+
+	assertLines(t, result.Stderr(), map[int]compareFunc{
+		0: equals(`ERROR "rb %v": invalid s3 bucket`, src),
+	})
+}
+
+func TestRemoveBucketFailureJson(t *testing.T) {
+	t.Parallel()
+	_, s5cmd, cleanup := setup(t)
+	defer cleanup()
+
+	bucketName := "invalid/bucket/name"
+	src := fmt.Sprintf("s3://%s", bucketName)
+	cmd := s5cmd("--json", "rb", src)
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Expected{ExitCode: 1})
+
+	assertLines(t, result.Stderr(), map[int]compareFunc{
+		0: equals(`{"operation":"rb","command":"rb %v","error":"invalid s3 bucket"}`, src),
+	}, jsonCheck(true))
+}
+
+func TestRemoveBucketWithObject(t *testing.T) {
+	t.Parallel()
+	const (
+		bucket      = "test-bucket"
+		fileContent = "this is a file content"
+		fileName    = "file1.txt"
+	)
+
+	s3client, s5cmd, cleanup := setup(t)
+	defer cleanup()
+
+	createBucket(t, s3client, bucket)
+	putFile(t, s3client, bucket, fileName, fileContent)
+
+	bucketName := fmt.Sprintf("s3://%v", bucket)
+	cmd := s5cmd("rb", bucketName)
+	result := icmd.RunCmd(cmd)
+
+	result.Assert(t, icmd.Expected{ExitCode: 1})
+
+	expected := fmt.Sprintf(`ERROR "rb %v": BucketNotEmpty:`, bucketName) // error due to non-empty bucket.
+
+	assertLines(t, result.Stderr(), map[int]compareFunc{
+		0: match(expected),
+	})
+}