github.com/minio/minio-go/v6@v6.0.57/api-compose-object.go (about) 1 /* 2 * MinIO Go Library for Amazon S3 Compatible Cloud Storage 3 * Copyright 2017, 2018 MinIO, Inc. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18 package minio 19 20 import ( 21 "context" 22 "fmt" 23 "io" 24 "io/ioutil" 25 "net/http" 26 "net/url" 27 "strconv" 28 "strings" 29 "time" 30 31 "github.com/minio/minio-go/v6/pkg/encrypt" 32 "github.com/minio/minio-go/v6/pkg/s3utils" 33 ) 34 35 // DestinationInfo - type with information about the object to be 36 // created via server-side copy requests, using the Compose API. 37 type DestinationInfo struct { 38 bucket, object string 39 opts DestInfoOptions 40 } 41 42 // DestInfoOptions represents options specified by user for NewDestinationInfo call 43 type DestInfoOptions struct { 44 // `Encryption` is the key info for server-side-encryption with customer 45 // provided key. If it is nil, no encryption is performed. 46 Encryption encrypt.ServerSide 47 48 // `userMeta` is the user-metadata key-value pairs to be set on the 49 // destination. The keys are automatically prefixed with `x-amz-meta-` 50 // if needed. If nil is passed, and if only a single source (of any 51 // size) is provided in the ComposeObject call, then metadata from the 52 // source is copied to the destination. 53 // if no user-metadata is provided, it is copied from source 54 // (when there is only once source object in the compose 55 // request) 56 UserMeta map[string]string 57 58 // `userTags` is the user defined object tags to be set on destination. 59 // This will be set only if the `replaceTags` field is set to true. 60 // Otherwise this field is ignored 61 UserTags map[string]string 62 ReplaceTags bool 63 64 // Specifies whether you want to apply a Legal Hold to the copied object. 65 LegalHold LegalHoldStatus 66 67 // Object Retention related fields 68 Mode RetentionMode 69 RetainUntilDate time.Time 70 } 71 72 // Process custom-metadata to remove a `x-amz-meta-` prefix if 73 // present and validate that keys are distinct (after this 74 // prefix removal). 75 func filterCustomMeta(userMeta map[string]string) (map[string]string, error) { 76 m := make(map[string]string) 77 for k, v := range userMeta { 78 if strings.HasPrefix(strings.ToLower(k), "x-amz-meta-") { 79 k = k[len("x-amz-meta-"):] 80 } 81 if _, ok := m[k]; ok { 82 return nil, ErrInvalidArgument(fmt.Sprintf("Cannot add both %s and x-amz-meta-%s keys as custom metadata", k, k)) 83 } 84 m[k] = v 85 } 86 return m, nil 87 } 88 89 // NewDestinationInfo - creates a compose-object/copy-source 90 // destination info object. 91 // 92 // `sse` is the key info for server-side-encryption with customer 93 // provided key. If it is nil, no encryption is performed. 94 // 95 // `userMeta` is the user-metadata key-value pairs to be set on the 96 // destination. The keys are automatically prefixed with `x-amz-meta-` 97 // if needed. If nil is passed, and if only a single source (of any 98 // size) is provided in the ComposeObject call, then metadata from the 99 // source is copied to the destination. 100 func NewDestinationInfo(bucket, object string, sse encrypt.ServerSide, userMeta map[string]string) (d DestinationInfo, err error) { 101 // Input validation. 102 if err = s3utils.CheckValidBucketName(bucket); err != nil { 103 return d, err 104 } 105 if err = s3utils.CheckValidObjectName(object); err != nil { 106 return d, err 107 } 108 m, err := filterCustomMeta(userMeta) 109 if err != nil { 110 return d, err 111 } 112 opts := DestInfoOptions{ 113 Encryption: sse, 114 UserMeta: m, 115 UserTags: nil, 116 ReplaceTags: false, 117 LegalHold: LegalHoldStatus(""), 118 Mode: RetentionMode(""), 119 } 120 return DestinationInfo{ 121 bucket: bucket, 122 object: object, 123 opts: opts, 124 }, nil 125 } 126 127 // NewDestinationInfoWithOptions - creates a compose-object/copy-source 128 // destination info object. 129 func NewDestinationInfoWithOptions(bucket, object string, destOpts DestInfoOptions) (d DestinationInfo, err error) { 130 // Input validation. 131 if err = s3utils.CheckValidBucketName(bucket); err != nil { 132 return d, err 133 } 134 if err = s3utils.CheckValidObjectName(object); err != nil { 135 return d, err 136 } 137 destOpts.UserMeta, err = filterCustomMeta(destOpts.UserMeta) 138 if err != nil { 139 return d, err 140 } 141 return DestinationInfo{ 142 bucket: bucket, 143 object: object, 144 opts: destOpts, 145 }, nil 146 } 147 148 // getUserMetaHeadersMap - construct appropriate key-value pairs to send 149 // as headers from metadata map to pass into copy-object request. For 150 // single part copy-object (i.e. non-multipart object), enable the 151 // withCopyDirectiveHeader to set the `x-amz-metadata-directive` to 152 // `REPLACE`, so that metadata headers from the source are not copied 153 // over. 154 func (d *DestinationInfo) getUserMetaHeadersMap(withCopyDirectiveHeader bool) map[string]string { 155 if len(d.opts.UserMeta) == 0 { 156 return nil 157 } 158 r := make(map[string]string) 159 if withCopyDirectiveHeader { 160 r["x-amz-metadata-directive"] = "REPLACE" 161 } 162 for k, v := range d.opts.UserMeta { 163 if isAmzHeader(k) || isStandardHeader(k) || isStorageClassHeader(k) { 164 r[k] = v 165 } else { 166 r["x-amz-meta-"+k] = v 167 } 168 } 169 return r 170 } 171 172 // SourceInfo - represents a source object to be copied, using 173 // server-side copying APIs. 174 type SourceInfo struct { 175 bucket, object string 176 start, end int64 177 encryption encrypt.ServerSide 178 // Headers to send with the upload-part-copy request involving 179 // this source object. 180 Headers http.Header 181 } 182 183 // NewSourceInfo - create a compose-object/copy-object source info 184 // object. 185 // 186 // `decryptSSEC` is the decryption key using server-side-encryption 187 // with customer provided key. It may be nil if the source is not 188 // encrypted. 189 func NewSourceInfo(bucket, object string, sse encrypt.ServerSide) SourceInfo { 190 r := SourceInfo{ 191 bucket: bucket, 192 object: object, 193 start: -1, // range is unspecified by default 194 encryption: sse, 195 Headers: make(http.Header), 196 } 197 198 // Set the source header 199 r.Headers.Set("x-amz-copy-source", s3utils.EncodePath(bucket+"/"+object)) 200 return r 201 } 202 203 // SetRange - Set the start and end offset of the source object to be 204 // copied. If this method is not called, the whole source object is 205 // copied. 206 func (s *SourceInfo) SetRange(start, end int64) error { 207 if start > end || start < 0 { 208 return ErrInvalidArgument("start must be non-negative, and start must be at most end.") 209 } 210 // Note that 0 <= start <= end 211 s.start, s.end = start, end 212 return nil 213 } 214 215 // SetMatchETagCond - Set ETag match condition. The object is copied 216 // only if the etag of the source matches the value given here. 217 func (s *SourceInfo) SetMatchETagCond(etag string) error { 218 if etag == "" { 219 return ErrInvalidArgument("ETag cannot be empty.") 220 } 221 s.Headers.Set("x-amz-copy-source-if-match", etag) 222 return nil 223 } 224 225 // SetMatchETagExceptCond - Set the ETag match exception 226 // condition. The object is copied only if the etag of the source is 227 // not the value given here. 228 func (s *SourceInfo) SetMatchETagExceptCond(etag string) error { 229 if etag == "" { 230 return ErrInvalidArgument("ETag cannot be empty.") 231 } 232 s.Headers.Set("x-amz-copy-source-if-none-match", etag) 233 return nil 234 } 235 236 // SetModifiedSinceCond - Set the modified since condition. 237 func (s *SourceInfo) SetModifiedSinceCond(modTime time.Time) error { 238 if modTime.IsZero() { 239 return ErrInvalidArgument("Input time cannot be 0.") 240 } 241 s.Headers.Set("x-amz-copy-source-if-modified-since", modTime.Format(http.TimeFormat)) 242 return nil 243 } 244 245 // SetUnmodifiedSinceCond - Set the unmodified since condition. 246 func (s *SourceInfo) SetUnmodifiedSinceCond(modTime time.Time) error { 247 if modTime.IsZero() { 248 return ErrInvalidArgument("Input time cannot be 0.") 249 } 250 s.Headers.Set("x-amz-copy-source-if-unmodified-since", modTime.Format(http.TimeFormat)) 251 return nil 252 } 253 254 // Helper to fetch size and etag of an object using a StatObject call. 255 func (s *SourceInfo) getProps(c Client) (size int64, etag string, userMeta map[string]string, err error) { 256 // Get object info - need size and etag here. Also, decryption 257 // headers are added to the stat request if given. 258 var objInfo ObjectInfo 259 opts := StatObjectOptions{GetObjectOptions{ServerSideEncryption: encrypt.SSE(s.encryption)}} 260 objInfo, err = c.statObject(context.Background(), s.bucket, s.object, opts) 261 if err != nil { 262 err = ErrInvalidArgument(fmt.Sprintf("Could not stat object - %s/%s: %v", s.bucket, s.object, err)) 263 } else { 264 size = objInfo.Size 265 etag = objInfo.ETag 266 userMeta = make(map[string]string) 267 for k, v := range objInfo.Metadata { 268 if strings.HasPrefix(k, "x-amz-meta-") { 269 if len(v) > 0 { 270 userMeta[k] = v[0] 271 } 272 } 273 } 274 } 275 return 276 } 277 278 // Low level implementation of CopyObject API, supports only upto 5GiB worth of copy. 279 func (c Client) copyObjectDo(ctx context.Context, srcBucket, srcObject, destBucket, destObject string, 280 metadata map[string]string) (ObjectInfo, error) { 281 282 // Build headers. 283 headers := make(http.Header) 284 285 // Set all the metadata headers. 286 for k, v := range metadata { 287 headers.Set(k, v) 288 } 289 290 // Set the source header 291 headers.Set("x-amz-copy-source", s3utils.EncodePath(srcBucket+"/"+srcObject)) 292 293 // Send upload-part-copy request 294 resp, err := c.executeMethod(ctx, "PUT", requestMetadata{ 295 bucketName: destBucket, 296 objectName: destObject, 297 customHeader: headers, 298 }) 299 defer closeResponse(resp) 300 if err != nil { 301 return ObjectInfo{}, err 302 } 303 304 // Check if we got an error response. 305 if resp.StatusCode != http.StatusOK { 306 return ObjectInfo{}, httpRespToErrorResponse(resp, srcBucket, srcObject) 307 } 308 309 cpObjRes := copyObjectResult{} 310 err = xmlDecoder(resp.Body, &cpObjRes) 311 if err != nil { 312 return ObjectInfo{}, err 313 } 314 315 objInfo := ObjectInfo{ 316 Key: destObject, 317 ETag: strings.Trim(cpObjRes.ETag, "\""), 318 LastModified: cpObjRes.LastModified, 319 } 320 return objInfo, nil 321 } 322 323 func (c Client) copyObjectPartDo(ctx context.Context, srcBucket, srcObject, destBucket, destObject string, uploadID string, 324 partID int, startOffset int64, length int64, metadata map[string]string) (p CompletePart, err error) { 325 326 headers := make(http.Header) 327 328 // Set source 329 headers.Set("x-amz-copy-source", s3utils.EncodePath(srcBucket+"/"+srcObject)) 330 331 if startOffset < 0 { 332 return p, ErrInvalidArgument("startOffset must be non-negative") 333 } 334 335 if length >= 0 { 336 headers.Set("x-amz-copy-source-range", fmt.Sprintf("bytes=%d-%d", startOffset, startOffset+length-1)) 337 } 338 339 for k, v := range metadata { 340 headers.Set(k, v) 341 } 342 343 queryValues := make(url.Values) 344 queryValues.Set("partNumber", strconv.Itoa(partID)) 345 queryValues.Set("uploadId", uploadID) 346 347 resp, err := c.executeMethod(ctx, "PUT", requestMetadata{ 348 bucketName: destBucket, 349 objectName: destObject, 350 customHeader: headers, 351 queryValues: queryValues, 352 }) 353 defer closeResponse(resp) 354 if err != nil { 355 return 356 } 357 358 // Check if we got an error response. 359 if resp.StatusCode != http.StatusOK { 360 return p, httpRespToErrorResponse(resp, destBucket, destObject) 361 } 362 363 // Decode copy-part response on success. 364 cpObjRes := copyObjectResult{} 365 err = xmlDecoder(resp.Body, &cpObjRes) 366 if err != nil { 367 return p, err 368 } 369 p.PartNumber, p.ETag = partID, cpObjRes.ETag 370 return p, nil 371 } 372 373 // uploadPartCopy - helper function to create a part in a multipart 374 // upload via an upload-part-copy request 375 // https://docs.aws.amazon.com/AmazonS3/latest/API/mpUploadUploadPartCopy.html 376 func (c Client) uploadPartCopy(ctx context.Context, bucket, object, uploadID string, partNumber int, 377 headers http.Header) (p CompletePart, err error) { 378 379 // Build query parameters 380 urlValues := make(url.Values) 381 urlValues.Set("partNumber", strconv.Itoa(partNumber)) 382 urlValues.Set("uploadId", uploadID) 383 384 // Send upload-part-copy request 385 resp, err := c.executeMethod(ctx, "PUT", requestMetadata{ 386 bucketName: bucket, 387 objectName: object, 388 customHeader: headers, 389 queryValues: urlValues, 390 }) 391 defer closeResponse(resp) 392 if err != nil { 393 return p, err 394 } 395 396 // Check if we got an error response. 397 if resp.StatusCode != http.StatusOK { 398 return p, httpRespToErrorResponse(resp, bucket, object) 399 } 400 401 // Decode copy-part response on success. 402 cpObjRes := copyObjectResult{} 403 err = xmlDecoder(resp.Body, &cpObjRes) 404 if err != nil { 405 return p, err 406 } 407 p.PartNumber, p.ETag = partNumber, cpObjRes.ETag 408 return p, nil 409 } 410 411 // ComposeObjectWithProgress - creates an object using server-side copying of 412 // existing objects. It takes a list of source objects (with optional 413 // offsets) and concatenates them into a new object using only 414 // server-side copying operations. Optionally takes progress reader hook 415 // for applications to look at current progress. 416 func (c Client) ComposeObjectWithProgress(dst DestinationInfo, srcs []SourceInfo, progress io.Reader) error { 417 if len(srcs) < 1 || len(srcs) > maxPartsCount { 418 return ErrInvalidArgument("There must be as least one and up to 10000 source objects.") 419 } 420 ctx := context.Background() 421 srcSizes := make([]int64, len(srcs)) 422 var totalSize, size, totalParts int64 423 var srcUserMeta map[string]string 424 etags := make([]string, len(srcs)) 425 var err error 426 for i, src := range srcs { 427 size, etags[i], srcUserMeta, err = src.getProps(c) 428 if err != nil { 429 return err 430 } 431 432 // Error out if client side encryption is used in this source object when 433 // more than one source objects are given. 434 if len(srcs) > 1 && src.Headers.Get("x-amz-meta-x-amz-key") != "" { 435 return ErrInvalidArgument( 436 fmt.Sprintf("Client side encryption is used in source object %s/%s", src.bucket, src.object)) 437 } 438 439 // Check if a segment is specified, and if so, is the 440 // segment within object bounds? 441 if src.start != -1 { 442 // Since range is specified, 443 // 0 <= src.start <= src.end 444 // so only invalid case to check is: 445 if src.end >= size { 446 return ErrInvalidArgument( 447 fmt.Sprintf("SourceInfo %d has invalid segment-to-copy [%d, %d] (size is %d)", 448 i, src.start, src.end, size)) 449 } 450 size = src.end - src.start + 1 451 } 452 453 // Only the last source may be less than `absMinPartSize` 454 if size < absMinPartSize && i < len(srcs)-1 { 455 return ErrInvalidArgument( 456 fmt.Sprintf("SourceInfo %d is too small (%d) and it is not the last part", i, size)) 457 } 458 459 // Is data to copy too large? 460 totalSize += size 461 if totalSize > maxMultipartPutObjectSize { 462 return ErrInvalidArgument(fmt.Sprintf("Cannot compose an object of size %d (> 5TiB)", totalSize)) 463 } 464 465 // record source size 466 srcSizes[i] = size 467 468 // calculate parts needed for current source 469 totalParts += partsRequired(size) 470 // Do we need more parts than we are allowed? 471 if totalParts > maxPartsCount { 472 return ErrInvalidArgument(fmt.Sprintf( 473 "Your proposed compose object requires more than %d parts", maxPartsCount)) 474 } 475 } 476 477 // Single source object case (i.e. when only one source is 478 // involved, it is being copied wholly and at most 5GiB in 479 // size, emptyfiles are also supported). 480 if (totalParts == 1 && srcs[0].start == -1 && totalSize <= maxPartSize) || (totalSize == 0) { 481 return c.CopyObjectWithProgress(dst, srcs[0], progress) 482 } 483 484 // Now, handle multipart-copy cases. 485 486 // 1. Ensure that the object has not been changed while 487 // we are copying data. 488 for i, src := range srcs { 489 if src.Headers.Get("x-amz-copy-source-if-match") == "" { 490 src.SetMatchETagCond(etags[i]) 491 } 492 } 493 494 // 2. Initiate a new multipart upload. 495 496 // Set user-metadata on the destination object. If no 497 // user-metadata is specified, and there is only one source, 498 // (only) then metadata from source is copied. 499 userMeta := dst.getUserMetaHeadersMap(false) 500 metaMap := userMeta 501 if len(userMeta) == 0 && len(srcs) == 1 { 502 metaMap = srcUserMeta 503 } 504 metaHeaders := make(map[string]string) 505 for k, v := range metaMap { 506 metaHeaders[k] = v 507 } 508 509 uploadID, err := c.newUploadID(ctx, dst.bucket, dst.object, PutObjectOptions{ServerSideEncryption: dst.opts.Encryption, UserMetadata: metaHeaders}) 510 if err != nil { 511 return err 512 } 513 514 // 3. Perform copy part uploads 515 objParts := []CompletePart{} 516 partIndex := 1 517 for i, src := range srcs { 518 h := src.Headers 519 if src.encryption != nil { 520 encrypt.SSECopy(src.encryption).Marshal(h) 521 } 522 // Add destination encryption headers 523 if dst.opts.Encryption != nil { 524 dst.opts.Encryption.Marshal(h) 525 } 526 527 // calculate start/end indices of parts after 528 // splitting. 529 startIdx, endIdx := calculateEvenSplits(srcSizes[i], src) 530 for j, start := range startIdx { 531 end := endIdx[j] 532 533 // Add (or reset) source range header for 534 // upload part copy request. 535 h.Set("x-amz-copy-source-range", 536 fmt.Sprintf("bytes=%d-%d", start, end)) 537 538 // make upload-part-copy request 539 complPart, err := c.uploadPartCopy(ctx, dst.bucket, 540 dst.object, uploadID, partIndex, h) 541 if err != nil { 542 return err 543 } 544 if progress != nil { 545 io.CopyN(ioutil.Discard, progress, end-start+1) 546 } 547 objParts = append(objParts, complPart) 548 partIndex++ 549 } 550 } 551 552 // 4. Make final complete-multipart request. 553 _, err = c.completeMultipartUpload(ctx, dst.bucket, dst.object, uploadID, 554 completeMultipartUpload{Parts: objParts}) 555 if err != nil { 556 return err 557 } 558 return nil 559 } 560 561 // ComposeObject - creates an object using server-side copying of 562 // existing objects. It takes a list of source objects (with optional 563 // offsets) and concatenates them into a new object using only 564 // server-side copying operations. 565 func (c Client) ComposeObject(dst DestinationInfo, srcs []SourceInfo) error { 566 return c.ComposeObjectWithProgress(dst, srcs, nil) 567 } 568 569 // partsRequired is maximum parts possible with 570 // max part size of ceiling(maxMultipartPutObjectSize / (maxPartsCount - 1)) 571 func partsRequired(size int64) int64 { 572 maxPartSize := maxMultipartPutObjectSize / (maxPartsCount - 1) 573 r := size / int64(maxPartSize) 574 if size%int64(maxPartSize) > 0 { 575 r++ 576 } 577 return r 578 } 579 580 // calculateEvenSplits - computes splits for a source and returns 581 // start and end index slices. Splits happen evenly to be sure that no 582 // part is less than 5MiB, as that could fail the multipart request if 583 // it is not the last part. 584 func calculateEvenSplits(size int64, src SourceInfo) (startIndex, endIndex []int64) { 585 if size == 0 { 586 return 587 } 588 589 reqParts := partsRequired(size) 590 startIndex = make([]int64, reqParts) 591 endIndex = make([]int64, reqParts) 592 // Compute number of required parts `k`, as: 593 // 594 // k = ceiling(size / copyPartSize) 595 // 596 // Now, distribute the `size` bytes in the source into 597 // k parts as evenly as possible: 598 // 599 // r parts sized (q+1) bytes, and 600 // (k - r) parts sized q bytes, where 601 // 602 // size = q * k + r (by simple division of size by k, 603 // so that 0 <= r < k) 604 // 605 start := src.start 606 if start == -1 { 607 start = 0 608 } 609 quot, rem := size/reqParts, size%reqParts 610 nextStart := start 611 for j := int64(0); j < reqParts; j++ { 612 curPartSize := quot 613 if j < rem { 614 curPartSize++ 615 } 616 617 cStart := nextStart 618 cEnd := cStart + curPartSize - 1 619 nextStart = cEnd + 1 620 621 startIndex[j], endIndex[j] = cStart, cEnd 622 } 623 return 624 }