fog-aws 3.6.6 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,10 @@ module Fog
4
4
  module AWS
5
5
  class Storage
6
6
  class File < Fog::Model
7
+ MIN_MULTIPART_CHUNK_SIZE = 5242880
8
+ MAX_SINGLE_PUT_SIZE = 5368709120
9
+ MULTIPART_COPY_THRESHOLD = 15728640
10
+
7
11
  # @see AWS Object docs http://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectOps.html
8
12
 
9
13
  identity :key, :aliases => 'Key'
@@ -27,14 +31,54 @@ module Fog
27
31
  attribute :kms_key_id, :aliases => 'x-amz-server-side-encryption-aws-kms-key-id'
28
32
  attribute :tags, :aliases => 'x-amz-tagging'
29
33
 
34
+ UploadPartData = Struct.new(:part_number, :upload_options, :etag)
35
+
36
+ class PartList
37
+ def initialize(parts = [])
38
+ @parts = parts
39
+ @mutex = Mutex.new
40
+ end
41
+
42
+ def push(part)
43
+ @mutex.synchronize { @parts.push(part) }
44
+ end
45
+
46
+ def shift
47
+ @mutex.synchronize { @parts.shift }
48
+ end
49
+
50
+ def clear!
51
+ @mutex.synchronize { @parts.clear }
52
+ end
53
+
54
+ def size
55
+ @mutex.synchronize { @parts.size }
56
+ end
57
+
58
+ def to_a
59
+ @mutex.synchronize { @parts.dup }
60
+ end
61
+ end
62
+
30
63
  # @note Chunk size to use for multipart uploads.
31
64
  # Use small chunk sizes to minimize memory. E.g. 5242880 = 5mb
32
65
  attr_reader :multipart_chunk_size
33
66
  def multipart_chunk_size=(mp_chunk_size)
34
- raise ArgumentError.new("minimum multipart_chunk_size is 5242880") if mp_chunk_size < 5242880
67
+ raise ArgumentError.new("minimum multipart_chunk_size is #{MIN_MULTIPART_CHUNK_SIZE}") if mp_chunk_size < MIN_MULTIPART_CHUNK_SIZE
35
68
  @multipart_chunk_size = mp_chunk_size
36
69
  end
37
70
 
71
+ # @note Number of threads used to copy files.
72
+ def concurrency=(concurrency)
73
+ raise ArgumentError.new('minimum concurrency is 1') if concurrency.to_i < 1
74
+
75
+ @concurrency = concurrency.to_i
76
+ end
77
+
78
+ def concurrency
79
+ @concurrency || 1
80
+ end
81
+
38
82
  def acl
39
83
  requires :directory, :key
40
84
  service.get_object_acl(directory.key, key).body['AccessControlList']
@@ -99,7 +143,18 @@ module Fog
99
143
  #
100
144
  def copy(target_directory_key, target_file_key, options = {})
101
145
  requires :directory, :key
102
- service.copy_object(directory.key, key, target_directory_key, target_file_key, options)
146
+
147
+ # With a single PUT operation you can upload objects up to 5 GB in size. Automatically set MP for larger objects.
148
+ self.multipart_chunk_size = MIN_MULTIPART_CHUNK_SIZE * 2 if !multipart_chunk_size && self.content_length.to_i > MAX_SINGLE_PUT_SIZE
149
+
150
+ if multipart_chunk_size && self.content_length.to_i >= multipart_chunk_size
151
+ upload_part_options = options.select { |key, _| ALLOWED_UPLOAD_PART_OPTIONS.include?(key.to_sym) }
152
+ upload_part_options = upload_part_options.merge({ 'x-amz-copy-source' => "#{directory.key}/#{key}" })
153
+ multipart_copy(options, upload_part_options, target_directory_key, target_file_key)
154
+ else
155
+ service.copy_object(directory.key, key, target_directory_key, target_file_key, options)
156
+ end
157
+
103
158
  target_directory = service.directories.new(:key => target_directory_key)
104
159
  target_directory.files.head(target_file_key)
105
160
  end
@@ -214,7 +269,7 @@ module Fog
214
269
  options.merge!(encryption_headers)
215
270
 
216
271
  # With a single PUT operation you can upload objects up to 5 GB in size. Automatically set MP for larger objects.
217
- self.multipart_chunk_size = 5242880 if !multipart_chunk_size && Fog::Storage.get_body_size(body) > 5368709120
272
+ self.multipart_chunk_size = MIN_MULTIPART_CHUNK_SIZE if !multipart_chunk_size && Fog::Storage.get_body_size(body) > MAX_SINGLE_PUT_SIZE
218
273
 
219
274
  if multipart_chunk_size && Fog::Storage.get_body_size(body) >= multipart_chunk_size && body.respond_to?(:read)
220
275
  data = multipart_save(options)
@@ -294,6 +349,30 @@ module Fog
294
349
  service.complete_multipart_upload(directory.key, key, upload_id, part_tags)
295
350
  end
296
351
 
352
+ def multipart_copy(options, upload_part_options, target_directory_key, target_file_key)
353
+ # Initiate the upload
354
+ res = service.initiate_multipart_upload(target_directory_key, target_file_key, options)
355
+ upload_id = res.body["UploadId"]
356
+
357
+ # Store ETags of upload parts
358
+ part_tags = []
359
+ pending = PartList.new(create_part_list(upload_part_options))
360
+ thread_count = self.concurrency
361
+ completed = PartList.new
362
+ errors = upload_in_threads(target_directory_key, target_file_key, upload_id, pending, completed, thread_count)
363
+
364
+ raise errors.first if errors.any?
365
+
366
+ part_tags = completed.to_a.sort_by { |part| part.part_number }.map(&:etag)
367
+ rescue => e
368
+ # Abort the upload & reraise
369
+ service.abort_multipart_upload(target_directory_key, target_file_key, upload_id) if upload_id
370
+ raise
371
+ else
372
+ # Complete the upload
373
+ service.complete_multipart_upload(target_directory_key, target_file_key, upload_id, part_tags)
374
+ end
375
+
297
376
  def encryption_headers
298
377
  if encryption && encryption_key
299
378
  encryption_customer_key_headers
@@ -318,6 +397,49 @@ module Fog
318
397
  'x-amz-server-side-encryption-customer-key-md5' => Base64.encode64(OpenSSL::Digest::MD5.digest(encryption_key.to_s)).chomp!
319
398
  }
320
399
  end
400
+
401
+ def create_part_list(upload_part_options)
402
+ current_pos = 0
403
+ count = 0
404
+ pending = []
405
+
406
+ while current_pos < self.content_length do
407
+ start_pos = current_pos
408
+ end_pos = [current_pos + self.multipart_chunk_size, self.content_length - 1].min
409
+ range = "bytes=#{start_pos}-#{end_pos}"
410
+ part_options = upload_part_options.dup
411
+ part_options['x-amz-copy-source-range'] = range
412
+ pending << UploadPartData.new(count + 1, part_options, nil)
413
+ count += 1
414
+ current_pos = end_pos + 1
415
+ end
416
+
417
+ pending
418
+ end
419
+
420
+ def upload_in_threads(target_directory_key, target_file_key, upload_id, pending, completed, thread_count)
421
+ threads = []
422
+
423
+ thread_count.times do
424
+ thread = Thread.new do
425
+ begin
426
+ while part = pending.shift
427
+ part_upload = service.upload_part_copy(target_directory_key, target_file_key, upload_id, part.part_number, part.upload_options)
428
+ part.etag = part_upload.body['ETag']
429
+ completed.push(part)
430
+ end
431
+ rescue => error
432
+ pending.clear!
433
+ error
434
+ end
435
+ end
436
+
437
+ thread.abort_on_exception = true
438
+ threads << thread
439
+ end
440
+
441
+ threads.map(&:value).compact
442
+ end
321
443
  end
322
444
  end
323
445
  end
@@ -17,6 +17,15 @@ module Fog
17
17
 
18
18
  model Fog::AWS::Storage::File
19
19
 
20
+ DASHED_HEADERS = %w(
21
+ Cache-Control
22
+ Content-Disposition
23
+ Content-Encoding
24
+ Content-Length
25
+ Content-MD5
26
+ Content-Type
27
+ ).freeze
28
+
20
29
  def all(options = {})
21
30
  requires :directory
22
31
  options = {
@@ -114,8 +123,29 @@ module Fog
114
123
  end
115
124
 
116
125
  def normalize_headers(data)
117
- data.headers['Last-Modified'] = Time.parse(data.get_header('Last-Modified'))
118
- data.headers['ETag'] = data.get_header('ETag').gsub('"','')
126
+ data.headers['Last-Modified'] = Time.parse(fetch_and_delete_header(data, 'Last-Modified'))
127
+
128
+ etag = fetch_and_delete_header(data, 'ETag').gsub('"','')
129
+ data.headers['ETag'] = etag
130
+
131
+ DASHED_HEADERS.each do |header|
132
+ value = fetch_and_delete_header(data, header)
133
+ data.headers[header] = value if value
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ def fetch_and_delete_header(response, header)
140
+ value = response.get_header(header)
141
+
142
+ return unless value
143
+
144
+ response.headers.keys.each do |key|
145
+ response.headers.delete(key) if key.downcase == header.downcase
146
+ end
147
+
148
+ value
119
149
  end
120
150
  end
121
151
  end
@@ -0,0 +1,18 @@
1
+ module Fog
2
+ module Parsers
3
+ module AWS
4
+ module Storage
5
+ class UploadPartCopyObject < Fog::Parsers::Base
6
+ def end_element(name)
7
+ case name
8
+ when 'ETag'
9
+ @response[name] = value.gsub('"', '')
10
+ when 'LastModified'
11
+ @response[name] = Time.parse(value)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -113,7 +113,7 @@ module Fog
113
113
  raise Fog::AWS::Compute::Error.new(message)
114
114
  end
115
115
 
116
- if !image_id.match(/^ami-[a-f0-9]{8}$/)
116
+ if !image_id.match(/^ami-[a-f0-9]{8,17}$/)
117
117
  message = "The image id '[#{image_id}]' does not exist"
118
118
  raise Fog::AWS::Compute::NotFound.new(message)
119
119
  end
@@ -36,13 +36,20 @@ module Fog
36
36
  data << "<Quiet>true</Quiet>" if headers.delete(:quiet)
37
37
  version_ids = headers.delete('versionId')
38
38
  object_names.each do |object_name|
39
- data << "<Object>"
40
- data << "<Key>#{CGI.escapeHTML(object_name)}</Key>"
41
39
  object_version = version_ids.nil? ? nil : version_ids[object_name]
42
40
  if object_version
43
- data << "<VersionId>#{CGI.escapeHTML(object_version)}</VersionId>"
41
+ object_version = object_version.is_a?(String) ? [object_version] : object_version
42
+ object_version.each do |version_id|
43
+ data << "<Object>"
44
+ data << "<Key>#{CGI.escapeHTML(object_name)}</Key>"
45
+ data << "<VersionId>#{CGI.escapeHTML(version_id)}</VersionId>"
46
+ data << "</Object>"
47
+ end
48
+ else
49
+ data << "<Object>"
50
+ data << "<Key>#{CGI.escapeHTML(object_name)}</Key>"
51
+ data << "</Object>"
44
52
  end
45
- data << "</Object>"
46
53
  end
47
54
  data << "</Delete>"
48
55
 
@@ -72,10 +79,13 @@ module Fog
72
79
  response.body = { 'DeleteResult' => [] }
73
80
  version_ids = headers.delete('versionId')
74
81
  object_names.each do |object_name|
75
- object_version = version_ids.nil? ? nil : version_ids[object_name]
76
- response.body['DeleteResult'] << delete_object_helper(bucket,
77
- object_name,
78
- object_version)
82
+ object_version = version_ids.nil? ? [nil] : version_ids[object_name]
83
+ object_version = object_version.is_a?(String) ? [object_version] : object_version
84
+ object_version.each do |version_id|
85
+ response.body['DeleteResult'] << delete_object_helper(bucket,
86
+ object_name,
87
+ version_id)
88
+ end
79
89
  end
80
90
  else
81
91
  response.status = 404
@@ -15,6 +15,7 @@ module Fog
15
15
  def parse_mock_data(data)
16
16
  data = Fog::Storage.parse_data(data)
17
17
  unless data[:body].is_a?(String)
18
+ data[:body].rewind if data[:body].eof?
18
19
  data[:body] = data[:body].read
19
20
  end
20
21
  data
@@ -0,0 +1,119 @@
1
+ module Fog
2
+ module AWS
3
+ class Storage
4
+ # From https://docs.aws.amazon.com/AmazonS3/latest/API/API_UploadPartCopy.html
5
+ ALLOWED_UPLOAD_PART_OPTIONS = %i(
6
+ x-amz-copy-source
7
+ x-amz-copy-source-if-match
8
+ x-amz-copy-source-if-modified-since
9
+ x-amz-copy-source-if-none-match
10
+ x-amz-copy-source-if-unmodified-since
11
+ x-amz-copy-source-range
12
+ x-amz-copy-source-server-side-encryption-customer-algorithm
13
+ x-amz-copy-source-server-side-encryption-customer-key
14
+ x-amz-copy-source-server-side-encryption-customer-key-MD5
15
+ x-amz-expected-bucket-owner
16
+ x-amz-request-payer
17
+ x-amz-server-side-encryption-customer-algorithm
18
+ x-amz-server-side-encryption-customer-key
19
+ x-amz-server-side-encryption-customer-key-MD5
20
+ x-amz-source-expected-bucket-owner
21
+ ).freeze
22
+
23
+ class Real
24
+ require 'fog/aws/parsers/storage/upload_part_copy_object'
25
+
26
+ # Upload a part for a multipart copy
27
+ #
28
+ # @param target_bucket_name [String] Name of bucket to create copy in
29
+ # @param target_object_name [String] Name for new copy of object
30
+ # @param upload_id [String] Id of upload to add part to
31
+ # @param part_number [String] Index of part in upload
32
+ # @param options [Hash]:
33
+ # @option options [String] x-amz-metadata-directive Specifies whether to copy metadata from source or replace with data in request. Must be in ['COPY', 'REPLACE']
34
+ # @option options [String] x-amz-copy_source-if-match Copies object if its etag matches this value
35
+ # @option options [Time] x-amz-copy_source-if-modified_since Copies object it it has been modified since this time
36
+ # @option options [String] x-amz-copy_source-if-none-match Copies object if its etag does not match this value
37
+ # @option options [Time] x-amz-copy_source-if-unmodified-since Copies object it it has not been modified since this time
38
+ # @option options [Time] x-amz-copy-source-range Specifes the range of bytes to copy from the source object
39
+ #
40
+ # @return [Excon::Response]
41
+ # * body [Hash]:
42
+ # * ETag [String] - etag of new object
43
+ # * LastModified [Time] - date object was last modified
44
+ #
45
+ # @see https://docs.aws.amazon.com/AmazonS3/latest/API/API_UploadPartCopy.html
46
+ #
47
+ def upload_part_copy(target_bucket_name, target_object_name, upload_id, part_number, options = {})
48
+ headers = options
49
+ request({
50
+ :expects => 200,
51
+ :idempotent => true,
52
+ :headers => headers,
53
+ :bucket_name => target_bucket_name,
54
+ :object_name => target_object_name,
55
+ :method => 'PUT',
56
+ :query => {'uploadId' => upload_id, 'partNumber' => part_number},
57
+ :parser => Fog::Parsers::AWS::Storage::UploadPartCopyObject.new,
58
+ })
59
+ end
60
+ end # Real
61
+
62
+ class Mock # :nodoc:all
63
+ require 'fog/aws/requests/storage/shared_mock_methods'
64
+ include Fog::AWS::Storage::SharedMockMethods
65
+
66
+ def upload_part_copy(target_bucket_name, target_object_name, upload_id, part_number, options = {})
67
+ validate_options!(options)
68
+
69
+ copy_source = options['x-amz-copy-source']
70
+ copy_range = options['x-amz-copy-source-range']
71
+
72
+ raise 'No x-amz-copy-source header provided' unless copy_source
73
+ raise 'No x-amz-copy-source-range header provided' unless copy_range
74
+
75
+ source_bucket_name, source_object_name = copy_source.split('/', 2)
76
+ verify_mock_bucket_exists(source_bucket_name)
77
+
78
+ source_bucket = self.data[:buckets][source_bucket_name]
79
+ source_object = source_bucket && source_bucket[:objects][source_object_name] && source_bucket[:objects][source_object_name].first
80
+ upload_info = get_upload_info(target_bucket_name, upload_id)
81
+
82
+ response = Excon::Response.new
83
+
84
+ if source_object
85
+ start_pos, end_pos = byte_range(copy_range, source_object[:body].length)
86
+ upload_info[:parts][part_number] = source_object[:body][start_pos..end_pos]
87
+
88
+ response.status = 200
89
+ response.body = {
90
+ # just use the part number as the ETag, for simplicity
91
+ 'ETag' => part_number.to_i,
92
+ 'LastModified' => Time.parse(source_object['Last-Modified'])
93
+ }
94
+ response
95
+ else
96
+ response.status = 404
97
+ raise(Excon::Errors.status_error({:expects => 200}, response))
98
+ end
99
+ end
100
+
101
+ def byte_range(range, size)
102
+ matches = range.match(/bytes=(\d*)-(\d*)/)
103
+
104
+ return nil unless matches
105
+
106
+ end_pos = [matches[2].to_i, size].min
107
+
108
+ [matches[1].to_i, end_pos]
109
+ end
110
+
111
+ def validate_options!(options)
112
+ options.keys.each do |key|
113
+ raise "Invalid UploadPart option: #{key}" unless ::Fog::AWS::Storage::ALLOWED_UPLOAD_PART_OPTIONS.include?(key.to_sym)
114
+ end
115
+ end
116
+ end # Mock
117
+ end # Storage
118
+ end # AWS
119
+ end # Fog
@@ -112,6 +112,7 @@ module Fog
112
112
  request :put_request_payment
113
113
  request :sync_clock
114
114
  request :upload_part
115
+ request :upload_part_copy
115
116
 
116
117
  module Utils
117
118
  attr_accessor :region
@@ -227,7 +228,7 @@ module Fog
227
228
  when %r{\Acn-.*}
228
229
  "s3.#{region}.amazonaws.com.cn"
229
230
  else
230
- "s3-#{region}.amazonaws.com"
231
+ "s3.#{region}.amazonaws.com"
231
232
  end
232
233
  end
233
234
 
@@ -283,10 +284,10 @@ module Fog
283
284
  path_style = params.fetch(:path_style, @path_style)
284
285
  if !path_style
285
286
  if COMPLIANT_BUCKET_NAMES !~ bucket_name
286
- Fog::Logger.warning("fog: the specified s3 bucket name(#{bucket_name}) is not a valid dns name, which will negatively impact performance. For details see: http://docs.amazonwebservices.com/AmazonS3/latest/dev/BucketRestrictions.html")
287
+ Fog::Logger.warning("fog: the specified s3 bucket name(#{bucket_name}) is not a valid dns name, which will negatively impact performance. For details see: https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html")
287
288
  path_style = true
288
289
  elsif scheme == 'https' && !path_style && bucket_name =~ /\./
289
- Fog::Logger.warning("fog: the specified s3 bucket name(#{bucket_name}) contains a '.' so is not accessible over https as a virtual hosted bucket, which will negatively impact performance. For details see: http://docs.amazonwebservices.com/AmazonS3/latest/dev/BucketRestrictions.html")
290
+ Fog::Logger.warning("fog: the specified s3 bucket name(#{bucket_name}) contains a '.' so is not accessible over https as a virtual hosted bucket, which will negatively impact performance. For details see: https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html")
290
291
  path_style = true
291
292
  end
292
293
  end
@@ -297,6 +298,8 @@ module Fog
297
298
  host = params.fetch(:cname, bucket_name)
298
299
  elsif path_style
299
300
  path = bucket_to_path bucket_name, path
301
+ elsif host.start_with?("#{bucket_name}.")
302
+ # no-op
300
303
  else
301
304
  host = [bucket_name, host].join('.')
302
305
  end