diff --git a/CHANGELOG.md b/CHANGELOG.md index 0716130f6..6cd4d69a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Added `--show-fullpath` flag to `ls`. ([#596](https://github.com/peak/s5cmd/issues/596)) - Added `pipe` command. ([#182](https://github.com/peak/s5cmd/issues/182)) - Added `--show-progress` flag to `cp` to show a progress bar. ([#51](https://github.com/peak/s5cmd/issues/51)) +- Added `--metadata` flag to `cp` and `pipe` to set arbitrary metadata for the objects. ([#537](https://github.com/peak/s5cmd/issues/537)) - Added `--include` flag to `cp`, `rm` and `sync` commands. ([#516](https://github.com/peak/s5cmd/issues/516)) #### Improvements diff --git a/command/cp.go b/command/cp.go index 36f71a3fe..3e608daf4 100644 --- a/command/cp.go +++ b/command/cp.go @@ -29,6 +29,7 @@ const ( defaultCopyConcurrency = 5 defaultPartSize = 50 // MiB megabytes = 1024 * 1024 + kilobytes = 1024 ) var copyHelpTemplate = `Name: @@ -109,6 +110,9 @@ Examples: 23. Download the specific version of a remote object to working directory > s5cmd {{.HelpName}} --version-id VERSION_ID s3://bucket/prefix/object . + + 24. Pass arbitrary metadata to the object during upload or copy + > s5cmd {{.HelpName}} --metadata "camera=Nixon D750" --metadata "imageSize=6032x4032" flowers.png s3://bucket/prefix/flowers.png ` func NewSharedFlags() []cli.Flag { @@ -133,6 +137,10 @@ func NewSharedFlags() []cli.Flag { Value: defaultPartSize, Usage: "size of each part transferred between host and remote server, in MiB", }, + &MapFlag{ + Name: "metadata", + Usage: "set arbitrary metadata for the object, e.g. --metadata 'foo=bar' --metadata 'fizz=buzz'", + }, &cli.StringFlag{ Name: "sse", Usage: "perform server side encryption of the data at its destination, e.g. aws:kms", @@ -296,6 +304,7 @@ type Copy struct { contentType string contentEncoding string contentDisposition string + metadata map[string]string showProgress bool progressbar progressbar.ProgressBar @@ -338,6 +347,13 @@ func NewCopy(c *cli.Context, deleteSource bool) (*Copy, error) { commandProgressBar = &progressbar.NoOp{} } + metadata, ok := c.Value("metadata").(MapValue) + if !ok { + err := errors.New("metadata flag is not a map") + printError(fullCommand, c.Command.Name, err) + return nil, err + } + return &Copy{ src: src, dst: dst, @@ -365,6 +381,7 @@ func NewCopy(c *cli.Context, deleteSource bool) (*Copy, error) { contentType: c.String("content-type"), contentEncoding: c.String("content-encoding"), contentDisposition: c.String("content-disposition"), + metadata: metadata, showProgress: c.Bool("show-progress"), progressbar: commandProgressBar, @@ -497,11 +514,11 @@ func (c Copy) Run(ctx context.Context) error { switch { case srcurl.Type == c.dst.Type: // local->local or remote->remote - task = c.prepareCopyTask(ctx, srcurl, c.dst, isBatch) + task = c.prepareCopyTask(ctx, srcurl, c.dst, isBatch, c.metadata) case srcurl.IsRemote(): // remote->local task = c.prepareDownloadTask(ctx, srcurl, c.dst, isBatch) case c.dst.IsRemote(): // local->remote - task = c.prepareUploadTask(ctx, srcurl, c.dst, isBatch) + task = c.prepareUploadTask(ctx, srcurl, c.dst, isBatch, c.metadata) default: panic("unexpected src-dst pair") } @@ -518,10 +535,11 @@ func (c Copy) prepareCopyTask( srcurl *url.URL, dsturl *url.URL, isBatch bool, + metadata map[string]string, ) func() error { return func() error { dsturl = prepareRemoteDestination(srcurl, dsturl, c.flatten, isBatch) - err := c.doCopy(ctx, srcurl, dsturl) + err := c.doCopy(ctx, srcurl, dsturl, metadata) if err != nil { return &errorpkg.Error{ Op: c.op, @@ -565,10 +583,11 @@ func (c Copy) prepareUploadTask( srcurl *url.URL, dsturl *url.URL, isBatch bool, + metadata map[string]string, ) func() error { return func() error { dsturl = prepareRemoteDestination(srcurl, dsturl, c.flatten, isBatch) - err := c.doUpload(ctx, srcurl, dsturl) + err := c.doUpload(ctx, srcurl, dsturl, metadata) if err != nil { return &errorpkg.Error{ Op: c.op, @@ -644,7 +663,7 @@ func (c Copy) doDownload(ctx context.Context, srcurl *url.URL, dsturl *url.URL) return nil } -func (c Copy) doUpload(ctx context.Context, srcurl *url.URL, dsturl *url.URL) error { +func (c Copy) doUpload(ctx context.Context, srcurl *url.URL, dsturl *url.URL, extradata map[string]string) error { srcClient := storage.NewLocalClient(c.storageOpts) file, err := srcClient.Open(srcurl.Absolute()) @@ -670,29 +689,29 @@ func (c Copy) doUpload(ctx context.Context, srcurl *url.URL, dsturl *url.URL) er if err != nil { return err } + metadata := storage.Metadata{UserDefined: extradata} - metadata := storage.NewMetadata(). - SetStorageClass(string(c.storageClass)). - SetSSE(c.encryptionMethod). - SetSSEKeyID(c.encryptionKeyID). - SetACL(c.acl). - SetCacheControl(c.cacheControl). - SetExpires(c.expires) + if c.storageClass != "" { + metadata.StorageClass = string(c.storageClass) + } if c.contentType != "" { - metadata.SetContentType(c.contentType) + metadata.ContentType = c.contentType } else { - metadata.SetContentType(guessContentType(file)) + metadata.ContentType = guessContentType(file) } if c.contentEncoding != "" { - metadata.SetContentEncoding(c.contentEncoding) + metadata.ContentEncoding = c.contentEncoding } + if c.contentDisposition != "" { - metadata.SetContentDisposition(c.contentDisposition) + metadata.ContentDisposition = c.contentDisposition } + reader := newCountingReaderWriter(file, c.progressbar) err = dstClient.Put(ctx, reader, dsturl, metadata, c.concurrency, c.partSize) + if err != nil { return err } @@ -726,7 +745,7 @@ func (c Copy) doUpload(ctx context.Context, srcurl *url.URL, dsturl *url.URL) er return nil } -func (c Copy) doCopy(ctx context.Context, srcurl, dsturl *url.URL) error { +func (c Copy) doCopy(ctx context.Context, srcurl, dsturl *url.URL, extradata map[string]string) error { // override destination region if set if c.dstRegion != "" { c.storageOpts.SetRegion(c.dstRegion) @@ -736,22 +755,20 @@ func (c Copy) doCopy(ctx context.Context, srcurl, dsturl *url.URL) error { return err } - metadata := storage.NewMetadata(). - SetStorageClass(string(c.storageClass)). - SetSSE(c.encryptionMethod). - SetSSEKeyID(c.encryptionKeyID). - SetACL(c.acl). - SetCacheControl(c.cacheControl). - SetExpires(c.expires) + metadata := storage.Metadata{UserDefined: extradata} + if c.storageClass != "" { + metadata.StorageClass = string(c.storageClass) + } if c.contentType != "" { - metadata.SetContentType(c.contentType) + metadata.ContentType = c.contentType } + if c.contentEncoding != "" { - metadata.SetContentEncoding(c.contentEncoding) + metadata.ContentEncoding = c.contentEncoding } if c.contentDisposition != "" { - metadata.SetContentDisposition(c.contentDisposition) + metadata.ContentDisposition = c.contentDisposition } err = c.shouldOverride(ctx, srcurl, dsturl) diff --git a/command/flag.go b/command/flag.go index f29cdb08b..256b546f7 100644 --- a/command/flag.go +++ b/command/flag.go @@ -1,8 +1,11 @@ package command import ( + "flag" "fmt" "strings" + + "github.com/urfave/cli/v2" ) type EnumValue struct { @@ -41,3 +44,127 @@ func (e EnumValue) String() string { func (e EnumValue) Get() interface{} { return e } + +type MapValue map[string]string + +func (m MapValue) String() string { + if m == nil { + m = make(map[string]string) + } + + var s strings.Builder + for key, value := range m { + s.WriteString(fmt.Sprintf("%s=%s ", key, value)) + } + + return s.String() +} + +func (m MapValue) Set(s string) error { + if m == nil { + m = make(map[string]string) + } + + if len(s) == 0 { + return fmt.Errorf("flag can't be passed empty. Format: key=value") + } + + tokens := strings.Split(s, "=") + if len(tokens) <= 1 { + return fmt.Errorf("the key value pair(%s) has invalid format", tokens) + } + + key := tokens[0] + value := strings.Join(tokens[1:], "=") + + _, ok := m[key] + if ok { + return fmt.Errorf("key %q is already defined", key) + } + + m[key] = value + return nil +} + +func (m MapValue) Get() interface{} { + if m == nil { + m = make(map[string]string) + } + return m +} + +type MapFlag struct { + Name string + + Category string + DefaultText string + FilePath string + Usage string + + HasBeenSet bool + Required bool + Hidden bool + + Value MapValue +} + +var ( + _ cli.Flag = (*MapFlag)(nil) + _ cli.RequiredFlag = (*MapFlag)(nil) + _ cli.VisibleFlag = (*MapFlag)(nil) + _ cli.DocGenerationFlag = (*MapFlag)(nil) +) + +func (f *MapFlag) Apply(set *flag.FlagSet) error { + if f.Value == nil { + f.Value = make(map[string]string) + } + for _, name := range f.Names() { + set.Var(f.Value, name, f.Usage) + if len(f.Value) > 0 { + f.HasBeenSet = true + } + } + + return nil +} + +func (f *MapFlag) GetUsage() string { + return f.Usage +} + +func (f *MapFlag) Names() []string { + return []string{f.Name} +} + +func (f *MapFlag) IsSet() bool { + return f.HasBeenSet +} + +func (f *MapFlag) IsVisible() bool { + return true +} + +func (f *MapFlag) String() string { + return cli.FlagStringer(f) +} + +func (f *MapFlag) TakesValue() bool { + return true +} + +func (f *MapFlag) GetValue() string { + return f.Value.String() +} + +func (f *MapFlag) GetDefaultText() string { + return "" +} + +func (f *MapFlag) GetEnvVars() []string { + return []string{} +} + +func (f *MapFlag) IsRequired() bool { + return f.Required +} diff --git a/command/pipe.go b/command/pipe.go index 77370a18e..dfbaeb01e 100644 --- a/command/pipe.go +++ b/command/pipe.go @@ -2,6 +2,7 @@ package command import ( "context" + "errors" "fmt" "mime" "os" @@ -28,9 +29,11 @@ Options: Examples: 01. Stream stdin to an object > echo "content" | gzip | s5cmd {{.HelpName}} s3://bucket/prefix/object.gz - 02. Download an object and stream it to a bucket + 02. Pass arbitrary metadata to an object + > cat "flowers.png" | gzip | s5cmd {{.HelpName}} --metadata "imageSize=6032x4032" s3://bucket/prefix/flowers.gz + 03. Download an object and stream it to a bucket > curl https://github.com/peak/s5cmd/ | s5cmd {{.HelpName}} s3://bucket/s5cmd.html - 03. Compress an object and stream it to a bucket + 04. Compress an object and stream it to a bucket > tar -cf - file.bin | s5cmd {{.HelpName}} s3://bucket/file.bin.tar ` @@ -52,6 +55,10 @@ func NewPipeCommandFlags() []cli.Flag { Value: defaultPartSize, Usage: "size of each part transferred between host and remote server, in MiB", }, + &MapFlag{ + Name: "metadata", + Usage: "set arbitrary metadata for the object", + }, &cli.StringFlag{ Name: "sse", Usage: "perform server side encryption of the data at its destination, e.g. aws:kms", @@ -145,6 +152,7 @@ type Pipe struct { contentType string contentEncoding string contentDisposition string + metadata map[string]string // s3 options concurrency int @@ -162,6 +170,13 @@ func NewPipe(c *cli.Context, deleteSource bool) (*Pipe, error) { return nil, err } + metadata, ok := c.Value("metadata").(MapValue) + if !ok { + err := errors.New("metadata flag is not a map") + printError(fullCommand, c.Command.Name, err) + return nil, err + } + return &Pipe{ dst: dst, op: c.Command.Name, @@ -180,7 +195,7 @@ func NewPipe(c *cli.Context, deleteSource bool) (*Pipe, error) { contentType: c.String("content-type"), contentEncoding: c.String("content-encoding"), contentDisposition: c.String("content-disposition"), - + metadata: metadata, // s3 options storageOpts: NewStorageOpts(c), }, nil @@ -206,26 +221,22 @@ func (c Pipe) Run(ctx context.Context) error { return err } - metadata := storage.NewMetadata(). - SetStorageClass(string(c.storageClass)). - SetSSE(c.encryptionMethod). - SetSSEKeyID(c.encryptionKeyID). - SetACL(c.acl). - SetCacheControl(c.cacheControl). - SetExpires(c.expires) + metadata := storage.Metadata{UserDefined: c.metadata} + if c.storageClass != "" { + metadata.StorageClass = string(c.storageClass) + } if c.contentType != "" { - metadata.SetContentType(c.contentType) + metadata.ContentType = c.contentType } else { - metadata.SetContentType(guessContentTypeByExtension(c.dst)) + metadata.ContentType = guessContentTypeByExtension(c.dst) } if c.contentEncoding != "" { - metadata.SetContentEncoding(c.contentEncoding) + metadata.ContentEncoding = c.contentEncoding } - if c.contentDisposition != "" { - metadata.SetContentDisposition(c.contentDisposition) + metadata.ContentDisposition = c.contentDisposition } err = client.Put(ctx, &stdin{file: os.Stdin}, c.dst, metadata, c.concurrency, c.partSize) diff --git a/e2e/cp_test.go b/e2e/cp_test.go index 92773ede3..ed5a6f491 100644 --- a/e2e/cp_test.go +++ b/e2e/cp_test.go @@ -33,6 +33,7 @@ import ( "testing" "time" + "github.com/aws/aws-sdk-go/aws" "gotest.tools/v3/assert" "gotest.tools/v3/fs" "gotest.tools/v3/icmd" @@ -719,6 +720,98 @@ func TestCopySingleFileToS3(t *testing.T) { assert.Assert(t, ensureS3Object(s3client, bucket, filename, content, ensureContentType(expectedContentType), ensureContentDisposition(expectedContentDisposition))) } +// cp dir/file s3://bucket/ --metadata key1=val1 --metadata key2=val2 ... +func TestCopySingleFileToS3WithArbitraryMetadata(t *testing.T) { + t.Parallel() + + s3client, s5cmd := setup(t) + + bucket := s3BucketFromTestName(t) + createBucket(t, s3client, bucket) + + const ( + // make sure that Put reads the file header and guess Content-Type correctly. + filename = "index" + content = ` + +
+ + +