diff --git a/CHANGELOG.md b/CHANGELOG.md index e98b6a467..7b176b589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- storage: add `copy` command for copying objects within and across buckets (#887) + ### Bug fixes - compute: honor the selected template's minimum disk size when creating instances and instance pools (#882) diff --git a/cmd/storage/storage_copy.go b/cmd/storage/storage_copy.go new file mode 100644 index 000000000..70afbe54f --- /dev/null +++ b/cmd/storage/storage_copy.go @@ -0,0 +1,43 @@ +package storage + +import ( + "github.com/spf13/cobra" +) + +var storageCopyCmd = &cobra.Command{ + Use: "copy sos://BUCKET/[OBJECT|PREFIX/] sos://BUCKET/[OBJECT|PREFIX/]", + Aliases: []string{"cp"}, + Short: "Copy objects within a bucket or across buckets", + Long: `Copy objects within a bucket or across buckets. + +This command copies objects server-side without downloading them. Object +metadata, headers, and ACLs are preserved. Existing destination objects are +overwritten. + +Multi-object prefix copies are processed serially. A trailing slash on the +source selects prefix mode; -r controls recursion into subdirectories. + +Examples: + + exo storage copy sos://my-bucket/file-a sos://my-bucket/folder/file-a + + exo storage copy sos://my-bucket/file-a sos://other-bucket/file-a + + exo storage copy -r sos://my-bucket/prefix/ sos://other-bucket/prefix/ + + exo storage copy -n sos://my-bucket/file-a sos://other-bucket/file-a +`, + PreRunE: validateStorageTransferCommand, + RunE: func(cmd *cobra.Command, args []string) error { + return runStorageTransfer(cmd, args, false) + }, +} + +func init() { + storageCopyCmd.Flags().BoolP("dry-run", "n", false, "simulate the copy operation") + storageCopyCmd.Flags().BoolP("force", "f", false, "skip confirmation prompt") + storageCopyCmd.Flags().BoolP("recursive", "r", false, "copy objects recursively") + storageCopyCmd.Flags().BoolP("verbose", "v", false, "output copied objects") + storageCopyCmd.Flags().Int("multipart-concurrency", 4, "number of concurrent parts for multipart copies") + storageCmd.AddCommand(storageCopyCmd) +} diff --git a/cmd/storage/storage_move.go b/cmd/storage/storage_move.go index 9fa73962c..6635b54c8 100644 --- a/cmd/storage/storage_move.go +++ b/cmd/storage/storage_move.go @@ -15,8 +15,9 @@ import ( ) var storageMoveCmd = &cobra.Command{ - Use: "move sos://BUCKET/[OBJECT|PREFIX/] sos://BUCKET/[OBJECT|PREFIX/]", - Short: "Move objects within a bucket or across buckets", + Use: "move sos://BUCKET/[OBJECT|PREFIX/] sos://BUCKET/[OBJECT|PREFIX/]", + Aliases: []string{"mv"}, + Short: "Move objects within a bucket or across buckets", Long: `Move objects within a bucket or across buckets. This command moves objects by performing a server-side copy followed by @@ -32,73 +33,17 @@ source selects prefix mode; -r controls recursion into subdirectories. Examples: - exo storage move sos://my-bucket/file-a sos://my-bucket/folder/ + exo storage move sos://my-bucket/file-a sos://my-bucket/folder/file-a exo storage move sos://my-bucket/file-a sos://other-bucket/file-a exo storage move -r sos://my-bucket/prefix/ sos://other-bucket/prefix/ - exo storage move -n sos://my-bucket/file-a sos://other-bucket/ + exo storage move -n sos://my-bucket/file-a sos://other-bucket/file-a `, - PreRunE: func(cmd *cobra.Command, args []string) error { - if len(args) != 2 { - exocmd.CmdExitOnUsageError(cmd, "invalid arguments") - } - return validateMoveArgs(args) - }, - + PreRunE: validateStorageTransferCommand, RunE: func(cmd *cobra.Command, args []string) error { - recursive, err := cmd.Flags().GetBool("recursive") - if err != nil { - return err - } - force, err := cmd.Flags().GetBool("force") - if err != nil { - return err - } - multipartConcurrency, err := cmd.Flags().GetInt("multipart-concurrency") - if err != nil { - return err - } - verbose, err := cmd.Flags().GetBool("verbose") - if err != nil { - return err - } - dryRun, err := cmd.Flags().GetBool("dry-run") - if err != nil { - return err - } - - srcBucket, srcKey := parseBucketKey(args[0]) - dstBucket, dstKey := parseBucketKey(args[1]) - - storage, err := sos.NewStorageClient( - exocmd.GContext, - sos.ClientOptZoneFromBucket(exocmd.GContext, srcBucket), - ) - if err != nil { - return fmt.Errorf("unable to initialize storage client: %w", err) - } - - isPrefix := strings.HasSuffix(srcKey, "/") || recursive - - if !force && !dryRun && isPrefix { - if !utils.AskQuestion(exocmd.GContext, fmt.Sprintf( - "Are you sure you want to move all objects from %s%s/%s to %s%s/%s?", - sos.BucketPrefix, srcBucket, srcKey, sos.BucketPrefix, dstBucket, dstKey)) { - return nil - } - } - - if dryRun { - fmt.Println("[DRY-RUN]") - } - - if !isPrefix { - return runSingleObjectMove(storage, srcBucket, srcKey, dstBucket, dstKey, multipartConcurrency, verbose, dryRun) - } - - return runPrefixMove(storage, srcBucket, srcKey, dstBucket, dstKey, multipartConcurrency, recursive, verbose, dryRun) + return runStorageTransfer(cmd, args, true) }, } @@ -111,9 +56,23 @@ func init() { storageCmd.AddCommand(storageMoveCmd) } -func validateMoveArgs(args []string) error { +func validateStorageTransferCommand(cmd *cobra.Command, args []string) error { + if len(args) != 2 { + exocmd.CmdExitOnUsageError(cmd, "invalid arguments") + } + + recursive, err := cmd.Flags().GetBool("recursive") + if err != nil { + return err + } + + return validateStorageTransferArgs(args, recursive) +} + +func validateStorageTransferArgs(args []string, recursive bool) error { srcBucket, srcKey := parseBucketKey(args[0]) dstBucket, dstKey := parseBucketKey(args[1]) + isPrefix := strings.HasSuffix(srcKey, "/") || recursive if srcBucket == "" { return fmt.Errorf("source must include a bucket name: %s", args[0]) @@ -124,9 +83,15 @@ func validateMoveArgs(args []string) error { if srcKey == "" && dstKey == "" { return fmt.Errorf("at least one of source/destination must include an object key or prefix") } - if srcKey != "" && dstKey == "" { + if !isPrefix && srcKey != "" && dstKey == "" { return fmt.Errorf("destination must include an object key when source is a single object: %s", args[1]) } + if srcBucket == dstBucket && srcKey == dstKey { + return fmt.Errorf("source and destination must differ") + } + if isPrefix && srcBucket == dstBucket && (strings.HasPrefix(dstKey, srcKey) || strings.HasPrefix(srcKey, dstKey)) { + return fmt.Errorf("source and destination prefixes must not overlap") + } return nil } @@ -141,32 +106,91 @@ func parseBucketKey(url string) (bucket, key string) { return } -func runSingleObjectMove(storage *sos.Client, srcBucket, srcKey, dstBucket, dstKey string, multipartConcurrency int, verbose, dryRun bool) error { +func runStorageTransfer(cmd *cobra.Command, args []string, move bool) error { + recursive, err := cmd.Flags().GetBool("recursive") + if err != nil { + return err + } + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + multipartConcurrency, err := cmd.Flags().GetInt("multipart-concurrency") + if err != nil { + return err + } + verbose, err := cmd.Flags().GetBool("verbose") + if err != nil { + return err + } + dryRun, err := cmd.Flags().GetBool("dry-run") + if err != nil { + return err + } + + srcBucket, srcKey := parseBucketKey(args[0]) + dstBucket, dstKey := parseBucketKey(args[1]) + + storage, err := sos.NewStorageClient( + exocmd.GContext, + sos.ClientOptZoneFromBucket(exocmd.GContext, srcBucket), + ) + if err != nil { + return fmt.Errorf("unable to initialize storage client: %w", err) + } + + action := cmd.Name() + pastTense := "copied" + if move { + pastTense = "moved" + } + isPrefix := strings.HasSuffix(srcKey, "/") || recursive + + if !force && !dryRun && isPrefix { + if !utils.AskQuestion(exocmd.GContext, fmt.Sprintf( + "Are you sure you want to %s all objects from %s%s/%s to %s%s/%s?", + action, sos.BucketPrefix, srcBucket, srcKey, sos.BucketPrefix, dstBucket, dstKey)) { + return nil + } + } + + if dryRun { + fmt.Println("[DRY-RUN]") + } + + if !isPrefix { + return runSingleObjectTransfer(storage, srcBucket, srcKey, dstBucket, dstKey, action, pastTense, multipartConcurrency, verbose, dryRun, move) + } + + return runPrefixTransfer(storage, srcBucket, srcKey, dstBucket, dstKey, action, pastTense, multipartConcurrency, recursive, verbose, dryRun, move) +} + +func runSingleObjectTransfer(storage *sos.Client, srcBucket, srcKey, dstBucket, dstKey, action, pastTense string, multipartConcurrency int, verbose, dryRun, move bool) error { if srcKey == "" { - return fmt.Errorf("source must be an object key, not just a bucket: use a trailing slash for prefix moves") + return fmt.Errorf("source must be an object key, not just a bucket: use --recursive for bucket prefixes") } if dryRun { - fmt.Printf("move %s%s/%s -> %s%s/%s\n", sos.BucketPrefix, srcBucket, srcKey, sos.BucketPrefix, dstBucket, dstKey) + fmt.Printf("%s %s%s/%s -> %s%s/%s\n", action, sos.BucketPrefix, srcBucket, srcKey, sos.BucketPrefix, dstBucket, dstKey) return nil } - if err := storage.MoveObject(exocmd.GContext, srcBucket, srcKey, dstBucket, dstKey, multipartConcurrency, verbose); err != nil { - return fmt.Errorf("move failed: %w", err) + if err := transferObject(storage, srcBucket, srcKey, dstBucket, dstKey, multipartConcurrency, verbose, move); err != nil { + return fmt.Errorf("%s failed: %w", action, err) } if verbose { showObj, err := storage.ShowObject(exocmd.GContext, dstBucket, dstKey) if err == nil { - fmt.Printf("moved: %s -> %s (%d bytes, %s)\n", srcKey, showObj.URL, showObj.Size, showObj.LastModified) + fmt.Printf("%s: %s -> %s (%d bytes, %s)\n", pastTense, srcKey, showObj.URL, showObj.Size, showObj.LastModified) } } return nil } -func runPrefixMove(storage *sos.Client, srcBucket, srcKey, dstBucket, dstKey string, multipartConcurrency int, recursive, verbose, dryRun bool) error { - var moved, failed int +func runPrefixTransfer(storage *sos.Client, srcBucket, srcKey, dstBucket, dstKey, action, pastTense string, multipartConcurrency int, recursive, verbose, dryRun, move bool) error { + var transferred, failed int err := storage.ForEachObject(exocmd.GContext, srcBucket, srcKey, recursive, func(o *types.Object) error { if o.Key == nil { return nil @@ -177,39 +201,47 @@ func runPrefixMove(storage *sos.Client, srcBucket, srcKey, dstBucket, dstKey str dstObjectKey := dstKey + srcObjectKeyTrimmed if dryRun { - fmt.Printf("move %s%s/%s -> %s%s/%s\n", sos.BucketPrefix, srcBucket, srcObjectKey, sos.BucketPrefix, dstBucket, dstObjectKey) + fmt.Printf("%s %s%s/%s -> %s%s/%s\n", action, sos.BucketPrefix, srcBucket, srcObjectKey, sos.BucketPrefix, dstBucket, dstObjectKey) return nil } - if err := storage.MoveObject(exocmd.GContext, srcBucket, srcObjectKey, dstBucket, dstObjectKey, multipartConcurrency, verbose); err != nil { - fmt.Fprintf(os.Stderr, "move failed for %s: %v\n", srcObjectKey, err) + if err := transferObject(storage, srcBucket, srcObjectKey, dstBucket, dstObjectKey, multipartConcurrency, verbose, move); err != nil { + fmt.Fprintf(os.Stderr, "%s failed for %s: %v\n", action, srcObjectKey, err) failed++ return nil } - moved++ + transferred++ if verbose && !globalstate.Quiet { - fmt.Printf("moved: %s%s/%s -> %s%s/%s\n", sos.BucketPrefix, srcBucket, srcObjectKey, sos.BucketPrefix, dstBucket, dstObjectKey) + fmt.Printf("%s: %s%s/%s -> %s%s/%s\n", pastTense, sos.BucketPrefix, srcBucket, srcObjectKey, sos.BucketPrefix, dstBucket, dstObjectKey) } return nil }) if err != nil { - return fmt.Errorf("move failed: %w", err) + return fmt.Errorf("%s failed: %w", action, err) } if failed > 0 { - return fmt.Errorf("%d object(s) failed to move", failed) + return fmt.Errorf("%d object(s) failed to %s", failed, action) } - if moved == 0 && !dryRun && !globalstate.Quiet { + if transferred == 0 && !dryRun && !globalstate.Quiet { fmt.Printf("no objects exist at %q\n", srcKey) } - if verbose && !globalstate.Quiet && moved > 0 { - fmt.Printf("moved %d objects\n", moved) + if verbose && !globalstate.Quiet && transferred > 0 { + fmt.Printf("%s %d objects\n", pastTense, transferred) } return nil } + +func transferObject(storage *sos.Client, srcBucket, srcKey, dstBucket, dstKey string, multipartConcurrency int, verbose, move bool) error { + if move { + return storage.MoveObject(exocmd.GContext, srcBucket, srcKey, dstBucket, dstKey, multipartConcurrency, verbose) + } + + return storage.CopyObjectTo(exocmd.GContext, srcBucket, srcKey, dstBucket, dstKey, multipartConcurrency, verbose) +} diff --git a/cmd/storage/storage_move_test.go b/cmd/storage/storage_move_test.go new file mode 100644 index 000000000..134213504 --- /dev/null +++ b/cmd/storage/storage_move_test.go @@ -0,0 +1,62 @@ +package storage + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateStorageTransferArgs(t *testing.T) { + tests := []struct { + name string + args []string + recursive bool + err string + }{ + { + name: "single object", + args: []string{"sos://bucket/source", "sos://bucket/destination"}, + }, + { + name: "prefix to another bucket root", + args: []string{"sos://bucket/source/", "sos://other-bucket/"}, + }, + { + name: "identical source and destination", + args: []string{"sos://bucket/source", "sos://bucket/source"}, + err: "source and destination must differ", + }, + { + name: "destination within source prefix", + args: []string{"sos://bucket/source/", "sos://bucket/source/backup/"}, + err: "source and destination prefixes must not overlap", + }, + { + name: "destination is parent of source prefix", + args: []string{"sos://bucket/source/nested/", "sos://bucket/source/"}, + err: "source and destination prefixes must not overlap", + }, + { + name: "recursive destination within source prefix", + args: []string{"sos://bucket/source", "sos://bucket/source-backup"}, + recursive: true, + err: "source and destination prefixes must not overlap", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateStorageTransferArgs(tt.args, tt.recursive) + if tt.err == "" { + assert.NoError(t, err) + return + } + assert.EqualError(t, err, tt.err) + }) + } +} + +func TestStorageTransferAliases(t *testing.T) { + assert.Contains(t, storageCopyCmd.Aliases, "cp") + assert.Contains(t, storageMoveCmd.Aliases, "mv") +} diff --git a/pkg/storage/sos/move.go b/pkg/storage/sos/move.go index 8562bc33e..dc25eef25 100644 --- a/pkg/storage/sos/move.go +++ b/pkg/storage/sos/move.go @@ -11,17 +11,17 @@ import ( ) const ( - moveLargeObjectThreshold = 5 * 1024 * 1024 * 1024 // 5 GiB - moveDefaultPartSize = 100 * 1024 * 1024 // 100 MiB - moveMaxConcurrency = 10 + copyLargeObjectThreshold = 5 * 1024 * 1024 * 1024 // 5 GiB + copyDefaultPartSize = 100 * 1024 * 1024 // 100 MiB + copyMaxConcurrency = 10 ) -func (c *Client) MoveObject(ctx context.Context, srcBucket, srcKey, dstBucket, dstKey string, multipartConcurrency int, verbose bool) error { +func (c *Client) CopyObjectTo(ctx context.Context, srcBucket, srcKey, dstBucket, dstKey string, multipartConcurrency int, verbose bool) error { if multipartConcurrency <= 0 { multipartConcurrency = 1 } - if multipartConcurrency > moveMaxConcurrency { - multipartConcurrency = moveMaxConcurrency + if multipartConcurrency > copyMaxConcurrency { + multipartConcurrency = copyMaxConcurrency } headRes, err := c.S3Client.HeadObject(ctx, &s3.HeadObjectInput{ @@ -34,14 +34,34 @@ func (c *Client) MoveObject(ctx context.Context, srcBucket, srcKey, dstBucket, d size := headRes.ContentLength - if *size > moveLargeObjectThreshold { - return c.moveLargeObject(ctx, srcBucket, srcKey, dstBucket, dstKey, headRes, multipartConcurrency, verbose) + if *size > copyLargeObjectThreshold { + return c.copyLargeObjectTo(ctx, srcBucket, srcKey, dstBucket, dstKey, headRes, multipartConcurrency, verbose) + } + + return c.copyObjectTo(ctx, srcBucket, srcKey, dstBucket, dstKey, headRes, verbose) +} + +func (c *Client) MoveObject(ctx context.Context, srcBucket, srcKey, dstBucket, dstKey string, multipartConcurrency int, verbose bool) error { + if srcBucket == dstBucket && srcKey == dstKey { + return fmt.Errorf("source and destination must differ") + } + + if err := c.CopyObjectTo(ctx, srcBucket, srcKey, dstBucket, dstKey, multipartConcurrency, verbose); err != nil { + return err + } + + if verbose { + fmt.Printf("deleting: sos://%s/%s\n", srcBucket, srcKey) + } + + if err := c.DeleteObject(ctx, srcBucket, srcKey); err != nil { + return fmt.Errorf("delete source: %w", err) } - return c.moveObject(ctx, srcBucket, srcKey, dstBucket, dstKey, headRes, verbose) + return nil } -func (c *Client) moveObject(ctx context.Context, srcBucket, srcKey, dstBucket, dstKey string, headRes *s3.HeadObjectOutput, verbose bool) error { +func (c *Client) copyObjectTo(ctx context.Context, srcBucket, srcKey, dstBucket, dstKey string, headRes *s3.HeadObjectOutput, verbose bool) error { srcURL := fmt.Sprintf("sos://%s/%s", srcBucket, srcKey) dstURL := fmt.Sprintf("sos://%s/%s", dstBucket, dstKey) @@ -90,14 +110,6 @@ func (c *Client) moveObject(ctx context.Context, srcBucket, srcKey, dstBucket, d return fmt.Errorf("copy: %w", err) } - if verbose { - fmt.Printf("deleting: %s\n", srcURL) - } - - if err := c.DeleteObject(ctx, srcBucket, srcKey); err != nil { - return fmt.Errorf("delete source: %w", err) - } - return nil } @@ -119,7 +131,7 @@ func copySource(bucket, key string) string { // grants are mapped. func getACLFromGrants(grants []s3types.Grant) s3types.ObjectCannedACL { for _, grant := range grants { - if grant.Grantee.Type != s3types.TypeGroup { + if grant.Permission != s3types.PermissionRead || grant.Grantee.Type != s3types.TypeGroup { continue } uri := aws.ToString(grant.Grantee.URI) diff --git a/pkg/storage/sos/move_multipart.go b/pkg/storage/sos/move_multipart.go index 57b55b4b8..af90776b5 100644 --- a/pkg/storage/sos/move_multipart.go +++ b/pkg/storage/sos/move_multipart.go @@ -12,7 +12,7 @@ import ( "github.com/hashicorp/go-multierror" ) -func (c *Client) moveLargeObject(ctx context.Context, srcBucket, srcKey, dstBucket, dstKey string, headRes *s3.HeadObjectOutput, concurrency int, verbose bool) error { +func (c *Client) copyLargeObjectTo(ctx context.Context, srcBucket, srcKey, dstBucket, dstKey string, headRes *s3.HeadObjectOutput, concurrency int, verbose bool) error { srcURL := fmt.Sprintf("sos://%s/%s", srcBucket, srcKey) dstURL := fmt.Sprintf("sos://%s/%s", dstBucket, dstKey) @@ -93,19 +93,11 @@ func (c *Client) moveLargeObject(ctx context.Context, srcBucket, srcKey, dstBuck return fmt.Errorf("complete multipart upload: %w", err) } - if verbose { - fmt.Printf("deleting: %s\n", srcURL) - } - - if err := c.DeleteObject(ctx, srcBucket, srcKey); err != nil { - return fmt.Errorf("delete source: %w", err) - } - return nil } func (c *Client) uploadParts(ctx context.Context, srcBucket, srcKey, dstBucket, dstKey, uploadID string, size int64, concurrency int) ([]s3types.CompletedPart, error) { - partSize := int64(moveDefaultPartSize) + partSize := int64(copyDefaultPartSize) if partSize > size { partSize = size } diff --git a/pkg/storage/sos/move_test.go b/pkg/storage/sos/move_test.go index de07a453f..0065bc80a 100644 --- a/pkg/storage/sos/move_test.go +++ b/pkg/storage/sos/move_test.go @@ -108,6 +108,15 @@ func TestMoveObject_SingleObject(t *testing.T) { }, expectError: true, }, + { + name: "rejects identical source and destination", + srcBucket: "test-bucket", + srcKey: "source-key", + dstBucket: "test-bucket", + dstKey: "source-key", + setupMocks: func(*MockS3API) {}, + expectError: true, + }, } for _, tt := range tests { @@ -130,8 +139,39 @@ func TestMoveObject_SingleObject(t *testing.T) { } } -func TestMoveObject_Multipart(t *testing.T) { - t.Run("successful multipart move", func(t *testing.T) { +func TestCopyObjectToDoesNotDeleteSource(t *testing.T) { + mockS3API := &MockS3API{ + mockHeadObject: func(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error) { + return &s3.HeadObjectOutput{ContentLength: aws.Int64(1024)}, nil + }, + mockGetObjectAcl: func(ctx context.Context, params *s3.GetObjectAclInput, optFns ...func(*s3.Options)) (*s3.GetObjectAclOutput, error) { + return &s3.GetObjectAclOutput{Grants: []types.Grant{ + { + Grantee: &types.Grantee{Type: types.TypeGroup, URI: aws.String(sos.ACLGranteeAllUsers)}, + Permission: types.PermissionReadAcp, + }, + }}, nil + }, + mockCopyObject: func(ctx context.Context, params *s3.CopyObjectInput, optFns ...func(*s3.Options)) (*s3.CopyObjectOutput, error) { + assert.Equal(t, "dst-bucket", *params.Bucket) + assert.Equal(t, "dest-key", *params.Key) + assert.Equal(t, "src-bucket/source-key", *params.CopySource) + assert.Equal(t, types.ObjectCannedACLPrivate, params.ACL) + return &s3.CopyObjectOutput{}, nil + }, + mockDeleteObject: func(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { + t.Fatal("copy deleted the source object") + return nil, nil + }, + } + + client := &sos.Client{S3Client: mockS3API} + err := client.CopyObjectTo(context.Background(), "src-bucket", "source-key", "dst-bucket", "dest-key", 1, false) + assert.NoError(t, err) +} + +func TestCopyObjectTo_Multipart(t *testing.T) { + t.Run("successful multipart copy", func(t *testing.T) { mockS3API := &MockS3API{ mockHeadObject: func(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error) { return &s3.HeadObjectOutput{ @@ -161,7 +201,8 @@ func TestMoveObject_Multipart(t *testing.T) { return &s3.CompleteMultipartUploadOutput{}, nil }, mockDeleteObject: func(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { - return &s3.DeleteObjectOutput{}, nil + t.Fatal("copy deleted the source object") + return nil, nil }, } @@ -170,7 +211,7 @@ func TestMoveObject_Multipart(t *testing.T) { Zone: "test-zone", } - err := client.MoveObject(context.Background(), "src-bucket", "large-file", "dst-bucket", "large-file", 2, false) + err := client.CopyObjectTo(context.Background(), "src-bucket", "large-file", "dst-bucket", "large-file", 2, false) assert.NoError(t, err) }) @@ -200,7 +241,7 @@ func TestMoveObject_Multipart(t *testing.T) { Zone: "test-zone", } - err := client.MoveObject(context.Background(), "src-bucket", "large-file", "dst-bucket", "large-file", 1, false) + err := client.CopyObjectTo(context.Background(), "src-bucket", "large-file", "dst-bucket", "large-file", 1, false) assert.Error(t, err) assert.True(t, abortCalled) assert.Contains(t, err.Error(), "upload failed")