444 lines
14 KiB
Go
444 lines
14 KiB
Go
//go:build !plan9 && !solaris && !js
|
|
|
|
package oracleobjectstorage
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ncw/swift/v2"
|
|
"github.com/rclone/rclone/lib/multipart"
|
|
"github.com/rclone/rclone/lib/pool"
|
|
"golang.org/x/net/http/httpguts"
|
|
|
|
"github.com/oracle/oci-go-sdk/v65/common"
|
|
"github.com/oracle/oci-go-sdk/v65/objectstorage"
|
|
"github.com/rclone/rclone/fs"
|
|
"github.com/rclone/rclone/fs/chunksize"
|
|
"github.com/rclone/rclone/fs/hash"
|
|
)
|
|
|
|
var warnStreamUpload sync.Once
|
|
|
|
// Info needed for an upload
|
|
type uploadInfo struct {
|
|
req *objectstorage.PutObjectRequest
|
|
md5sumHex string
|
|
}
|
|
|
|
type objectChunkWriter struct {
|
|
chunkSize int64
|
|
size int64
|
|
f *Fs
|
|
bucket *string
|
|
key *string
|
|
uploadID *string
|
|
partsToCommit []objectstorage.CommitMultipartUploadPartDetails
|
|
partsToCommitMu sync.Mutex
|
|
existingParts map[int]objectstorage.MultipartUploadPartSummary
|
|
eTag string
|
|
md5sMu sync.Mutex
|
|
md5s []byte
|
|
ui uploadInfo
|
|
o *Object
|
|
}
|
|
|
|
func (o *Object) uploadMultipart(ctx context.Context, src fs.ObjectInfo, in io.Reader, options ...fs.OpenOption) error {
|
|
_, err := multipart.UploadMultipart(ctx, src, in, multipart.UploadMultipartOptions{
|
|
Open: o.fs,
|
|
OpenOptions: options,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// OpenChunkWriter returns the chunk size and a ChunkWriter
|
|
//
|
|
// Pass in the remote and the src object
|
|
// You can also use options to hint at the desired chunk size
|
|
func (f *Fs) OpenChunkWriter(
|
|
ctx context.Context,
|
|
remote string,
|
|
src fs.ObjectInfo,
|
|
options ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) {
|
|
// Temporary Object under construction
|
|
o := &Object{
|
|
fs: f,
|
|
remote: remote,
|
|
}
|
|
ui, err := o.prepareUpload(ctx, src, options)
|
|
if err != nil {
|
|
return info, nil, fmt.Errorf("failed to prepare upload: %w", err)
|
|
}
|
|
|
|
uploadParts := f.opt.MaxUploadParts
|
|
if uploadParts < 1 {
|
|
uploadParts = 1
|
|
} else if uploadParts > maxUploadParts {
|
|
uploadParts = maxUploadParts
|
|
}
|
|
size := src.Size()
|
|
|
|
// calculate size of parts
|
|
chunkSize := f.opt.ChunkSize
|
|
|
|
// size can be -1 here meaning we don't know the size of the incoming file. We use ChunkSize
|
|
// buffers here (default 5 MiB). With a maximum number of parts (10,000) this will be a file of
|
|
// 48 GiB which seems like a not too unreasonable limit.
|
|
if size == -1 {
|
|
warnStreamUpload.Do(func() {
|
|
fs.Logf(f, "Streaming uploads using chunk size %v will have maximum file size of %v",
|
|
f.opt.ChunkSize, fs.SizeSuffix(int64(chunkSize)*int64(uploadParts)))
|
|
})
|
|
} else {
|
|
chunkSize = chunksize.Calculator(src, size, uploadParts, chunkSize)
|
|
}
|
|
|
|
uploadID, existingParts, err := o.createMultipartUpload(ctx, ui.req)
|
|
if err != nil {
|
|
return info, nil, fmt.Errorf("create multipart upload request failed: %w", err)
|
|
}
|
|
bucketName, bucketPath := o.split()
|
|
chunkWriter := &objectChunkWriter{
|
|
chunkSize: int64(chunkSize),
|
|
size: size,
|
|
f: f,
|
|
bucket: &bucketName,
|
|
key: &bucketPath,
|
|
uploadID: &uploadID,
|
|
existingParts: existingParts,
|
|
ui: ui,
|
|
o: o,
|
|
}
|
|
info = fs.ChunkWriterInfo{
|
|
ChunkSize: int64(chunkSize),
|
|
Concurrency: o.fs.opt.UploadConcurrency,
|
|
LeavePartsOnError: o.fs.opt.LeavePartsOnError,
|
|
}
|
|
fs.Debugf(o, "open chunk writer: started multipart upload: %v", uploadID)
|
|
return info, chunkWriter, err
|
|
}
|
|
|
|
// WriteChunk will write chunk number with reader bytes, where chunk number >= 0
|
|
func (w *objectChunkWriter) WriteChunk(ctx context.Context, chunkNumber int, reader io.ReadSeeker) (bytesWritten int64, err error) {
|
|
if chunkNumber < 0 {
|
|
err := fmt.Errorf("invalid chunk number provided: %v", chunkNumber)
|
|
return -1, err
|
|
}
|
|
// Only account after the checksum reads have been done
|
|
if do, ok := reader.(pool.DelayAccountinger); ok {
|
|
// To figure out this number, do a transfer and if the accounted size is 0 or a
|
|
// multiple of what it should be, increase or decrease this number.
|
|
do.DelayAccounting(2)
|
|
}
|
|
m := md5.New()
|
|
currentChunkSize, err := io.Copy(m, reader)
|
|
if err != nil {
|
|
return -1, err
|
|
}
|
|
// If no data read, don't write the chunk
|
|
if currentChunkSize == 0 {
|
|
return 0, nil
|
|
}
|
|
md5sumBinary := m.Sum([]byte{})
|
|
w.addMd5(&md5sumBinary, int64(chunkNumber))
|
|
md5sum := base64.StdEncoding.EncodeToString(md5sumBinary[:])
|
|
|
|
// Object storage requires 1 <= PartNumber <= 10000
|
|
ossPartNumber := chunkNumber + 1
|
|
if existing, ok := w.existingParts[ossPartNumber]; ok {
|
|
if md5sum == *existing.Md5 {
|
|
fs.Debugf(w.o, "matched uploaded part found, part num %d, skipping part, md5=%v", *existing.PartNumber, md5sum)
|
|
w.addCompletedPart(existing.PartNumber, existing.Etag)
|
|
return currentChunkSize, nil
|
|
}
|
|
}
|
|
req := objectstorage.UploadPartRequest{
|
|
NamespaceName: common.String(w.f.opt.Namespace),
|
|
BucketName: w.bucket,
|
|
ObjectName: w.key,
|
|
UploadId: w.uploadID,
|
|
UploadPartNum: common.Int(ossPartNumber),
|
|
ContentLength: common.Int64(currentChunkSize),
|
|
ContentMD5: common.String(md5sum),
|
|
}
|
|
w.o.applyPartUploadOptions(w.ui.req, &req)
|
|
var resp objectstorage.UploadPartResponse
|
|
err = w.f.pacer.Call(func() (bool, error) {
|
|
// req.UploadPartBody = io.NopCloser(bytes.NewReader(buf))
|
|
// rewind the reader on retry and after reading md5
|
|
_, err = reader.Seek(0, io.SeekStart)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
req.UploadPartBody = io.NopCloser(reader)
|
|
resp, err = w.f.srv.UploadPart(ctx, req)
|
|
if err != nil {
|
|
if ossPartNumber <= 8 {
|
|
return shouldRetry(ctx, resp.HTTPResponse(), err)
|
|
}
|
|
// retry all chunks once have done the first few
|
|
return true, err
|
|
}
|
|
return false, err
|
|
})
|
|
if err != nil {
|
|
fs.Errorf(w.o, "multipart upload failed to upload part:%d err: %v", ossPartNumber, err)
|
|
return -1, fmt.Errorf("multipart upload failed to upload part: %w", err)
|
|
}
|
|
w.addCompletedPart(&ossPartNumber, resp.ETag)
|
|
return currentChunkSize, err
|
|
|
|
}
|
|
|
|
// add a part number and etag to the completed parts
|
|
func (w *objectChunkWriter) addCompletedPart(partNum *int, eTag *string) {
|
|
w.partsToCommitMu.Lock()
|
|
defer w.partsToCommitMu.Unlock()
|
|
w.partsToCommit = append(w.partsToCommit, objectstorage.CommitMultipartUploadPartDetails{
|
|
PartNum: partNum,
|
|
Etag: eTag,
|
|
})
|
|
}
|
|
|
|
func (w *objectChunkWriter) Close(ctx context.Context) (err error) {
|
|
req := objectstorage.CommitMultipartUploadRequest{
|
|
NamespaceName: common.String(w.f.opt.Namespace),
|
|
BucketName: w.bucket,
|
|
ObjectName: w.key,
|
|
UploadId: w.uploadID,
|
|
}
|
|
req.PartsToCommit = w.partsToCommit
|
|
var resp objectstorage.CommitMultipartUploadResponse
|
|
err = w.f.pacer.Call(func() (bool, error) {
|
|
resp, err = w.f.srv.CommitMultipartUpload(ctx, req)
|
|
// if multipart is corrupted, we will abort the uploadId
|
|
if isMultiPartUploadCorrupted(err) {
|
|
fs.Debugf(w.o, "multipart uploadId %v is corrupted, aborting...", *w.uploadID)
|
|
_ = w.Abort(ctx)
|
|
return false, err
|
|
}
|
|
return shouldRetry(ctx, resp.HTTPResponse(), err)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w.eTag = *resp.ETag
|
|
hashOfHashes := md5.Sum(w.md5s)
|
|
wantMultipartMd5 := fmt.Sprintf("%s-%d", base64.StdEncoding.EncodeToString(hashOfHashes[:]), len(w.partsToCommit))
|
|
gotMultipartMd5 := *resp.OpcMultipartMd5
|
|
if wantMultipartMd5 != gotMultipartMd5 {
|
|
fs.Errorf(w.o, "multipart upload corrupted: multipart md5 differ: expecting %s but got %s", wantMultipartMd5, gotMultipartMd5)
|
|
return fmt.Errorf("multipart upload corrupted: md5 differ: expecting %s but got %s", wantMultipartMd5, gotMultipartMd5)
|
|
}
|
|
fs.Debugf(w.o, "multipart upload %v md5 matched: expecting %s and got %s", *w.uploadID, wantMultipartMd5, gotMultipartMd5)
|
|
return nil
|
|
}
|
|
|
|
func isMultiPartUploadCorrupted(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
// Check if this oci-err object, and if it is multipart commit error
|
|
if ociError, ok := err.(common.ServiceError); ok {
|
|
// If it is a timeout then we want to retry that
|
|
if ociError.GetCode() == "InvalidUploadPart" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (w *objectChunkWriter) Abort(ctx context.Context) error {
|
|
fs.Debugf(w.o, "Cancelling multipart upload")
|
|
err := w.o.fs.abortMultiPartUpload(
|
|
ctx,
|
|
w.bucket,
|
|
w.key,
|
|
w.uploadID)
|
|
if err != nil {
|
|
fs.Debugf(w.o, "Failed to cancel multipart upload: %v", err)
|
|
} else {
|
|
fs.Debugf(w.o, "canceled and aborted multipart upload: %v", *w.uploadID)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// addMd5 adds a binary md5 to the md5 calculated so far
|
|
func (w *objectChunkWriter) addMd5(md5binary *[]byte, chunkNumber int64) {
|
|
w.md5sMu.Lock()
|
|
defer w.md5sMu.Unlock()
|
|
start := chunkNumber * md5.Size
|
|
end := start + md5.Size
|
|
if extend := end - int64(len(w.md5s)); extend > 0 {
|
|
w.md5s = append(w.md5s, make([]byte, extend)...)
|
|
}
|
|
copy(w.md5s[start:end], (*md5binary)[:])
|
|
}
|
|
|
|
func (o *Object) prepareUpload(ctx context.Context, src fs.ObjectInfo, options []fs.OpenOption) (ui uploadInfo, err error) {
|
|
bucket, bucketPath := o.split()
|
|
|
|
ui.req = &objectstorage.PutObjectRequest{
|
|
NamespaceName: common.String(o.fs.opt.Namespace),
|
|
BucketName: common.String(bucket),
|
|
ObjectName: common.String(bucketPath),
|
|
}
|
|
|
|
// Set the mtime in the metadata
|
|
modTime := src.ModTime(ctx)
|
|
// Fetch metadata if --metadata is in use
|
|
meta, err := fs.GetMetadataOptions(ctx, o.fs, src, options)
|
|
if err != nil {
|
|
return ui, fmt.Errorf("failed to read metadata from source object: %w", err)
|
|
}
|
|
ui.req.OpcMeta = make(map[string]string, len(meta)+2)
|
|
// merge metadata into request and user metadata
|
|
for k, v := range meta {
|
|
pv := common.String(v)
|
|
k = strings.ToLower(k)
|
|
switch k {
|
|
case "cache-control":
|
|
ui.req.CacheControl = pv
|
|
case "content-disposition":
|
|
ui.req.ContentDisposition = pv
|
|
case "content-encoding":
|
|
ui.req.ContentEncoding = pv
|
|
case "content-language":
|
|
ui.req.ContentLanguage = pv
|
|
case "content-type":
|
|
ui.req.ContentType = pv
|
|
case "tier":
|
|
// ignore
|
|
case "mtime":
|
|
// mtime in meta overrides source ModTime
|
|
metaModTime, err := time.Parse(time.RFC3339Nano, v)
|
|
if err != nil {
|
|
fs.Debugf(o, "failed to parse metadata %s: %q: %v", k, v, err)
|
|
} else {
|
|
modTime = metaModTime
|
|
}
|
|
case "btime":
|
|
// write as metadata since we can't set it
|
|
ui.req.OpcMeta[k] = v
|
|
default:
|
|
ui.req.OpcMeta[k] = v
|
|
}
|
|
}
|
|
|
|
// Set the mtime in the metadata
|
|
ui.req.OpcMeta[metaMtime] = swift.TimeToFloatString(modTime)
|
|
|
|
// read the md5sum if available
|
|
// - for non-multipart
|
|
// - so we can add a ContentMD5
|
|
// - so we can add the md5sum in the metadata as metaMD5Hash if using SSE/SSE-C
|
|
// - for multipart provided checksums aren't disabled
|
|
// - so we can add the md5sum in the metadata as metaMD5Hash
|
|
size := src.Size()
|
|
isMultipart := size < 0 || size >= int64(o.fs.opt.UploadCutoff)
|
|
var md5sumBase64 string
|
|
if !isMultipart || !o.fs.opt.DisableChecksum {
|
|
ui.md5sumHex, err = src.Hash(ctx, hash.MD5)
|
|
if err == nil && matchMd5.MatchString(ui.md5sumHex) {
|
|
hashBytes, err := hex.DecodeString(ui.md5sumHex)
|
|
if err == nil {
|
|
md5sumBase64 = base64.StdEncoding.EncodeToString(hashBytes)
|
|
if isMultipart && !o.fs.opt.DisableChecksum {
|
|
// Set the md5sum as metadata on the object if
|
|
// - a multipart upload
|
|
// - the ETag is not an MD5, e.g. when using SSE/SSE-C
|
|
// provided checksums aren't disabled
|
|
ui.req.OpcMeta[metaMD5Hash] = md5sumBase64
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Set the content type if it isn't set already
|
|
if ui.req.ContentType == nil {
|
|
ui.req.ContentType = common.String(fs.MimeType(ctx, src))
|
|
}
|
|
if size >= 0 {
|
|
ui.req.ContentLength = common.Int64(size)
|
|
}
|
|
if md5sumBase64 != "" {
|
|
ui.req.ContentMD5 = &md5sumBase64
|
|
}
|
|
o.applyPutOptions(ui.req, options...)
|
|
useBYOKPutObject(o.fs, ui.req)
|
|
if o.fs.opt.StorageTier != "" {
|
|
storageTier, ok := objectstorage.GetMappingPutObjectStorageTierEnum(o.fs.opt.StorageTier)
|
|
if !ok {
|
|
return ui, fmt.Errorf("not a valid storage tier: %v", o.fs.opt.StorageTier)
|
|
}
|
|
ui.req.StorageTier = storageTier
|
|
}
|
|
// Check metadata keys and values are valid
|
|
for key, value := range ui.req.OpcMeta {
|
|
if !httpguts.ValidHeaderFieldName(key) {
|
|
fs.Errorf(o, "Dropping invalid metadata key %q", key)
|
|
delete(ui.req.OpcMeta, key)
|
|
} else if value == "" {
|
|
fs.Errorf(o, "Dropping nil metadata value for key %q", key)
|
|
delete(ui.req.OpcMeta, key)
|
|
} else if !httpguts.ValidHeaderFieldValue(value) {
|
|
fs.Errorf(o, "Dropping invalid metadata value %q for key %q", value, key)
|
|
delete(ui.req.OpcMeta, key)
|
|
}
|
|
}
|
|
return ui, nil
|
|
}
|
|
|
|
func (o *Object) createMultipartUpload(ctx context.Context, putReq *objectstorage.PutObjectRequest) (
|
|
uploadID string, existingParts map[int]objectstorage.MultipartUploadPartSummary, err error) {
|
|
bucketName, bucketPath := o.split()
|
|
err = o.fs.makeBucket(ctx, bucketName)
|
|
if err != nil {
|
|
fs.Errorf(o, "failed to create bucket: %v, err: %v", bucketName, err)
|
|
return uploadID, existingParts, err
|
|
}
|
|
if o.fs.opt.AttemptResumeUpload {
|
|
fs.Debugf(o, "attempting to resume upload for %v (if any)", o.remote)
|
|
resumeUploads, err := o.fs.findLatestMultipartUpload(ctx, bucketName, bucketPath)
|
|
if err == nil && len(resumeUploads) > 0 {
|
|
uploadID = *resumeUploads[0].UploadId
|
|
existingParts, err = o.fs.listMultipartUploadParts(ctx, bucketName, bucketPath, uploadID)
|
|
if err == nil {
|
|
fs.Debugf(o, "resuming with existing upload id: %v", uploadID)
|
|
return uploadID, existingParts, err
|
|
}
|
|
}
|
|
}
|
|
req := objectstorage.CreateMultipartUploadRequest{
|
|
NamespaceName: common.String(o.fs.opt.Namespace),
|
|
BucketName: common.String(bucketName),
|
|
}
|
|
req.Object = common.String(bucketPath)
|
|
if o.fs.opt.StorageTier != "" {
|
|
storageTier, ok := objectstorage.GetMappingStorageTierEnum(o.fs.opt.StorageTier)
|
|
if !ok {
|
|
return "", nil, fmt.Errorf("not a valid storage tier: %v", o.fs.opt.StorageTier)
|
|
}
|
|
req.StorageTier = storageTier
|
|
}
|
|
o.applyMultipartUploadOptions(putReq, &req)
|
|
|
|
var resp objectstorage.CreateMultipartUploadResponse
|
|
err = o.fs.pacer.Call(func() (bool, error) {
|
|
resp, err = o.fs.srv.CreateMultipartUpload(ctx, req)
|
|
return shouldRetry(ctx, resp.HTTPResponse(), err)
|
|
})
|
|
if err != nil {
|
|
return "", existingParts, err
|
|
}
|
|
existingParts = make(map[int]objectstorage.MultipartUploadPartSummary)
|
|
uploadID = *resp.UploadId
|
|
fs.Debugf(o, "created new upload id: %v", uploadID)
|
|
return uploadID, existingParts, err
|
|
}
|