aws-sdk-s3 1.213.0 → 1.216.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.
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aws
4
+ module S3
5
+ # @api private
6
+ # This is a one-shot class that downloads objects from a bucket to a local directory.
7
+ # This works as follows:
8
+ # * ObjectProducer runs in a background thread, calling `list_objects_v2` and
9
+ # pushing entries into a SizedQueue (max: 100).
10
+ # * An internal executor pulls from that queue and posts work. Each task uses
11
+ # FileDownloader to download objects then signals completion via `completion_queue`.
12
+ #
13
+ # We track how many tasks we posted, then pop that many times from `completion_queue`
14
+ # to wait for everything to finish.
15
+ #
16
+ # Errors are collected in a mutex-protected array. On failure (unless ignore_failure is set),
17
+ # we call abort which closes the queue - the producer catches ClosedQueueError and exits cleanly.
18
+ class DirectoryDownloader
19
+ def initialize(options = {})
20
+ @client = options[:client] || Client.new
21
+ @executor = options[:executor] || DefaultExecutor.new
22
+ @logger = options[:logger]
23
+ @producer = nil
24
+ @mutex = Mutex.new
25
+ end
26
+
27
+ attr_reader :client, :executor
28
+
29
+ def abort
30
+ @producer&.close
31
+ end
32
+
33
+ def download(destination, bucket:, **options)
34
+ if File.exist?(destination)
35
+ raise ArgumentError, 'invalid destination, expected a directory' unless File.directory?(destination)
36
+ else
37
+ FileUtils.mkdir_p(destination)
38
+ end
39
+
40
+ download_opts = build_download_opts(destination, options)
41
+ @producer = ObjectProducer.new(build_producer_opts(destination, bucket, options))
42
+ downloader = FileDownloader.new(client: @client, executor: @executor)
43
+ downloads, errors = process_download_queue(downloader, download_opts)
44
+ build_result(downloads, errors)
45
+ end
46
+
47
+ private
48
+
49
+ def build_download_opts(destination, opts)
50
+ {
51
+ destination: destination,
52
+ ignore_failure: opts[:ignore_failure] || false
53
+ }
54
+ end
55
+
56
+ def build_producer_opts(destination, bucket, opts)
57
+ {
58
+ client: @client,
59
+ directory_downloader: self,
60
+ destination: destination,
61
+ bucket: bucket,
62
+ s3_prefix: opts[:s3_prefix],
63
+ filter_callback: opts[:filter_callback],
64
+ request_callback: opts[:request_callback]
65
+ }
66
+ end
67
+
68
+ def build_result(download_count, errors)
69
+ if @producer&.closed?
70
+ msg = "directory download failed: #{errors.map(&:message).join('; ')}"
71
+ raise DirectoryDownloadError.new(msg, errors)
72
+ else
73
+ {
74
+ completed_downloads: [download_count - errors.count, 0].max,
75
+ failed_downloads: errors.count,
76
+ errors: errors.any? ? errors : nil
77
+ }.compact
78
+ end
79
+ end
80
+
81
+ def download_object(entry, downloader, errors, opts)
82
+ raise entry.error if entry.error
83
+
84
+ FileUtils.mkdir_p(File.dirname(entry.path)) unless Dir.exist?(File.dirname(entry.path))
85
+ downloader.download(entry.path, entry.params)
86
+ @logger&.debug("Downloaded #{entry.params[:key]} from #{entry.params[:bucket]} to #{entry.path}")
87
+ rescue StandardError => e
88
+ @logger&.warn("Failed to download #{entry.params[:key]} from #{entry.params[:bucket]}: #{e.message}")
89
+ @mutex.synchronize { errors << e }
90
+ abort unless opts[:ignore_failure]
91
+ end
92
+
93
+ def process_download_queue(downloader, opts)
94
+ queue_executor = DefaultExecutor.new(max_threads: 2)
95
+ completion_queue = Queue.new
96
+ posted_count = 0
97
+ errors = []
98
+ begin
99
+ @producer.each do |object|
100
+ queue_executor.post(object) do |o|
101
+ download_object(o, downloader, errors, opts)
102
+ ensure
103
+ completion_queue << :done
104
+ end
105
+ posted_count += 1
106
+ end
107
+ rescue ClosedQueueError
108
+ # abort already requested
109
+ rescue StandardError => e
110
+ @mutex.synchronize { errors << e }
111
+ abort
112
+ end
113
+ posted_count.times { completion_queue.pop }
114
+ [posted_count, errors]
115
+ ensure
116
+ queue_executor&.shutdown
117
+ end
118
+
119
+ # @api private
120
+ class ObjectProducer
121
+ include Enumerable
122
+
123
+ DEFAULT_QUEUE_SIZE = 100
124
+ DONE_MARKER = :done
125
+
126
+ def initialize(opts = {})
127
+ @directory_downloader = opts[:directory_downloader]
128
+ @destination_dir = opts[:destination]
129
+ @bucket = opts[:bucket]
130
+ @client = opts[:client]
131
+ @s3_prefix = opts[:s3_prefix]
132
+ @filter_callback = opts[:filter_callback]
133
+ @request_callback = opts[:request_callback]
134
+ @object_queue = SizedQueue.new(DEFAULT_QUEUE_SIZE)
135
+ end
136
+
137
+ def closed?
138
+ @object_queue.closed?
139
+ end
140
+
141
+ def close
142
+ @object_queue.close
143
+ @object_queue.clear
144
+ end
145
+
146
+ def each
147
+ producer_thread = Thread.new do
148
+ stream_objects
149
+ @object_queue << DONE_MARKER
150
+ rescue ClosedQueueError
151
+ # abort requested
152
+ rescue StandardError => e
153
+ close
154
+ raise e
155
+ end
156
+
157
+ while (object = @object_queue.shift) && object != DONE_MARKER
158
+ yield object
159
+ end
160
+ ensure
161
+ producer_thread.value
162
+ end
163
+
164
+ private
165
+
166
+ def apply_request_callback(key, params)
167
+ callback_params = @request_callback.call(key, params.dup)
168
+ return params unless callback_params.is_a?(Hash) && callback_params.any?
169
+
170
+ params.merge(callback_params)
171
+ end
172
+
173
+ def build_object_entry(key)
174
+ params = { bucket: @bucket, key: key }
175
+ params = apply_request_callback(key, params) if @request_callback
176
+ error = validate_key(key)
177
+ return DownloadEntry.new(path: '', params: params, error: error) if error
178
+
179
+ full_path = normalize_path(File.join(@destination_dir, key))
180
+ DownloadEntry.new(path: full_path, params: params, error: error)
181
+ end
182
+
183
+ def include_object?(obj)
184
+ return true unless @filter_callback
185
+
186
+ @filter_callback.call(obj)
187
+ end
188
+
189
+ def directory_marker?(obj)
190
+ obj.key.end_with?('/') && obj.size.zero?
191
+ end
192
+
193
+ def normalize_path(path)
194
+ return path if File::SEPARATOR == '/'
195
+
196
+ path.tr('/', File::SEPARATOR)
197
+ end
198
+
199
+ def stream_objects(continuation_token: nil)
200
+ resp = @client.list_objects_v2(bucket: @bucket, prefix: @s3_prefix, continuation_token: continuation_token)
201
+ resp.contents&.each do |o|
202
+ next if directory_marker?(o)
203
+ next unless include_object?(o)
204
+
205
+ @object_queue << build_object_entry(o.key)
206
+ end
207
+ stream_objects(continuation_token: resp.next_continuation_token) if resp.next_continuation_token
208
+ end
209
+
210
+ def validate_key(key)
211
+ segments = key.split('/')
212
+ return unless segments.any? { |s| %w[. ..].include?(s) }
213
+
214
+ DirectoryDownloadError.new("invalid key '#{key}': contains '.' or '..' path segments")
215
+ end
216
+
217
+ # @api private
218
+ class DownloadEntry
219
+ def initialize(opts = {})
220
+ @path = opts[:path]
221
+ @params = opts[:params]
222
+ @error = opts[:error]
223
+ end
224
+
225
+ attr_reader :path, :params, :error
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aws
4
+ module S3
5
+ # Raised when DirectoryUploader fails to upload files to S3 bucket
6
+ class DirectoryUploadError < StandardError
7
+ def initialize(message, errors = [])
8
+ @errors = errors
9
+ super(message)
10
+ end
11
+
12
+ # @return [Array<StandardError>] The list of errors encountered when uploading files
13
+ attr_reader :errors
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Aws
6
+ module S3
7
+ # @api private
8
+ # This is a one-shot class that uploads files from a local directory to a bucket.
9
+ # This works as follows:
10
+ # * FileProducer runs in a background thread, scanning the directory and
11
+ # pushing entries into a SizedQueue (max: 100).
12
+ # * An internal executor pulls from that queue and posts work. Each task uses
13
+ # FileUploader to upload files then signals completion via `completion_queue`.
14
+ #
15
+ # We track how many tasks we posted, then pop that many times from `completion_queue`
16
+ # to wait for everything to finish.
17
+ #
18
+ # Errors are collected in a mutex-protected array. On failure (unless ignore_failure is set),
19
+ # we call abort which closes the queue - the producer catches ClosedQueueError and exits cleanly.
20
+ class DirectoryUploader
21
+ def initialize(options = {})
22
+ @client = options[:client] || Client.new
23
+ @executor = options[:executor] || DefaultExecutor.new
24
+ @logger = options[:logger]
25
+ @producer = nil
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ attr_reader :client, :executor
30
+
31
+ def abort
32
+ @producer&.close
33
+ end
34
+
35
+ def upload(source_directory, bucket, **opts)
36
+ raise ArgumentError, 'Invalid directory' unless Dir.exist?(source_directory)
37
+
38
+ uploader = FileUploader.new(
39
+ multipart_threshold: opts.delete(:multipart_threshold),
40
+ http_chunk_size: opts.delete(:http_chunk_size),
41
+ client: @client,
42
+ executor: @executor
43
+ )
44
+ upload_opts = build_upload_opts(opts)
45
+ @producer = FileProducer.new(build_producer_opts(source_directory, bucket, opts))
46
+ uploads, errors = process_upload_queue(uploader, upload_opts)
47
+ build_result(uploads, errors)
48
+ end
49
+
50
+ private
51
+
52
+ def build_upload_opts(opts)
53
+ { ignore_failure: opts[:ignore_failure] || false }
54
+ end
55
+
56
+ def build_producer_opts(source_directory, bucket, opts)
57
+ {
58
+ directory_uploader: self,
59
+ source_dir: source_directory,
60
+ bucket: bucket,
61
+ s3_prefix: opts[:s3_prefix],
62
+ recursive: opts[:recursive] || false,
63
+ follow_symlinks: opts[:follow_symlinks] || false,
64
+ filter_callback: opts[:filter_callback],
65
+ request_callback: opts[:request_callback]
66
+ }
67
+ end
68
+
69
+ def build_result(upload_count, errors)
70
+ if @producer&.closed?
71
+ msg = "directory upload failed: #{errors.map(&:message).join('; ')}"
72
+ raise DirectoryUploadError.new(msg, errors)
73
+ else
74
+ {
75
+ completed_uploads: [upload_count - errors.count, 0].max,
76
+ failed_uploads: errors.count,
77
+ errors: errors.any? ? errors : nil
78
+ }.compact
79
+ end
80
+ end
81
+
82
+ def process_upload_queue(uploader, opts)
83
+ queue_executor = DefaultExecutor.new(max_threads: 2)
84
+ completion_queue = Queue.new
85
+ posted_count = 0
86
+ errors = []
87
+ begin
88
+ @producer.each do |file|
89
+ queue_executor.post(file) do |f|
90
+ upload_file(f, uploader, errors, opts)
91
+ ensure
92
+ completion_queue << :done
93
+ end
94
+ posted_count += 1
95
+ end
96
+ rescue ClosedQueueError
97
+ # abort already requested
98
+ rescue StandardError => e
99
+ @mutex.synchronize { errors << e }
100
+ abort
101
+ end
102
+ posted_count.times { completion_queue.pop }
103
+ [posted_count, errors]
104
+ ensure
105
+ queue_executor&.shutdown
106
+ end
107
+
108
+ def upload_file(entry, uploader, errors, opts)
109
+ uploader.upload(entry.path, entry.params)
110
+ @logger&.debug("Uploaded #{entry.path} to #{entry.params[:bucket]} as #{entry.params[:key]}")
111
+ rescue StandardError => e
112
+ @logger&.warn("Failed to upload #{entry.path} to #{entry.params[:bucket]}: #{e.message}")
113
+ @mutex.synchronize { errors << e }
114
+ abort unless opts[:ignore_failure]
115
+ end
116
+
117
+ # @api private
118
+ class FileProducer
119
+ include Enumerable
120
+
121
+ DEFAULT_QUEUE_SIZE = 100
122
+ DONE_MARKER = :done
123
+
124
+ def initialize(opts = {})
125
+ @directory_uploader = opts[:directory_uploader]
126
+ @source_dir = opts[:source_dir]
127
+ @bucket = opts[:bucket]
128
+ @s3_prefix = opts[:s3_prefix]
129
+ @recursive = opts[:recursive]
130
+ @follow_symlinks = opts[:follow_symlinks]
131
+ @filter_callback = opts[:filter_callback]
132
+ @request_callback = opts[:request_callback]
133
+ @file_queue = SizedQueue.new(DEFAULT_QUEUE_SIZE)
134
+ end
135
+
136
+ def closed?
137
+ @file_queue.closed?
138
+ end
139
+
140
+ def close
141
+ @file_queue.close
142
+ @file_queue.clear
143
+ end
144
+
145
+ def each
146
+ producer_thread = Thread.new do
147
+ if @recursive
148
+ find_recursively
149
+ else
150
+ find_directly
151
+ end
152
+ @file_queue << DONE_MARKER
153
+ rescue ClosedQueueError
154
+ # abort requested
155
+ rescue StandardError => e
156
+ # encountered a traversal error, we must abort immediately
157
+ close
158
+ raise DirectoryUploadError, "Directory traversal failed for '#{@source_dir}': #{e.message}"
159
+ end
160
+
161
+ while (file = @file_queue.shift) && file != DONE_MARKER
162
+ yield file
163
+ end
164
+ ensure
165
+ producer_thread.value
166
+ end
167
+
168
+ private
169
+
170
+ def apply_request_callback(file_path, params)
171
+ callback_params = @request_callback.call(file_path, params.dup)
172
+ return params unless callback_params.is_a?(Hash) && callback_params.any?
173
+
174
+ params.merge(callback_params)
175
+ end
176
+
177
+ def build_upload_entry(file_path, key)
178
+ params = { bucket: @bucket, key: @s3_prefix ? File.join(@s3_prefix, key) : key }
179
+ params = apply_request_callback(file_path, params) if @request_callback
180
+ UploadEntry.new(path: file_path, params: params)
181
+ end
182
+
183
+ def find_directly
184
+ Dir.each_child(@source_dir) do |entry|
185
+ entry_path = File.join(@source_dir, entry)
186
+ stat = nil
187
+
188
+ if @follow_symlinks
189
+ stat = File.stat(entry_path)
190
+ next if stat.directory?
191
+ else
192
+ stat = File.lstat(entry_path)
193
+ next if stat.symlink? || stat.directory?
194
+ end
195
+
196
+ next unless stat.file?
197
+ next unless include_file?(entry_path, entry)
198
+
199
+ @file_queue << build_upload_entry(entry_path, entry)
200
+ end
201
+ end
202
+
203
+ def find_recursively
204
+ if @follow_symlinks
205
+ ancestors = Set.new
206
+ ancestors << File.stat(@source_dir).ino
207
+ scan_directory(@source_dir, ancestors: ancestors)
208
+ else
209
+ scan_directory(@source_dir)
210
+ end
211
+ end
212
+
213
+ def include_file?(file_path, file_name)
214
+ return true unless @filter_callback
215
+
216
+ @filter_callback.call(file_path, file_name)
217
+ end
218
+
219
+ def scan_directory(dir_path, key_prefix: '', ancestors: nil)
220
+ Dir.each_child(dir_path) do |entry|
221
+ full_path = File.join(dir_path, entry)
222
+ next unless include_file?(full_path, entry)
223
+
224
+ stat = get_file_stat(full_path)
225
+ next unless stat
226
+
227
+ if stat.directory?
228
+ handle_directory(full_path, entry, key_prefix, ancestors)
229
+ elsif stat.file? # skip non-file types
230
+ key = key_prefix.empty? ? entry : File.join(key_prefix, entry)
231
+ @file_queue << build_upload_entry(full_path, key)
232
+ end
233
+ end
234
+ end
235
+
236
+ def get_file_stat(full_path)
237
+ return File.stat(full_path) if @follow_symlinks
238
+
239
+ lstat = File.lstat(full_path)
240
+ return if lstat.symlink?
241
+
242
+ lstat
243
+ end
244
+
245
+ def handle_directory(dir_path, dir_name, key_prefix, ancestors)
246
+ ino = nil
247
+ if @follow_symlinks && ancestors
248
+ ino = File.stat(dir_path).ino
249
+ return if ancestors.include?(ino) # cycle detected - skip
250
+
251
+ ancestors.add(ino)
252
+ end
253
+ new_prefix = key_prefix.empty? ? dir_name : File.join(key_prefix, dir_name)
254
+ scan_directory(dir_path, key_prefix: new_prefix, ancestors: ancestors)
255
+ ancestors.delete(ino) if @follow_symlinks && ancestors
256
+ end
257
+
258
+ # @api private
259
+ class UploadEntry
260
+ def initialize(opts = {})
261
+ @path = opts[:path]
262
+ @params = opts[:params]
263
+ end
264
+
265
+ attr_reader :path, :params
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
@@ -64,6 +64,7 @@ module Aws::S3
64
64
  # grant_write_acp: "GrantWriteACP",
65
65
  # object_lock_enabled_for_bucket: false,
66
66
  # object_ownership: "BucketOwnerPreferred", # accepts BucketOwnerPreferred, ObjectWriter, BucketOwnerEnforced
67
+ # bucket_namespace: "account-regional", # accepts account-regional, global
67
68
  # })
68
69
  # @param [Hash] options ({})
69
70
  # @option options [String] :acl
@@ -171,6 +172,33 @@ module Aws::S3
171
172
  #
172
173
  #
173
174
  # [1]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html
175
+ # @option options [String] :bucket_namespace
176
+ # Specifies the namespace where you want to create your general purpose
177
+ # bucket. When you create a general purpose bucket, you can choose to
178
+ # create a bucket in the shared global namespace or you can choose to
179
+ # create a bucket in your account regional namespace. Your account
180
+ # regional namespace is a subdivision of the global namespace that only
181
+ # your account can create buckets in. For more information on bucket
182
+ # namespaces, see [Namespaces for general purpose buckets][1].
183
+ #
184
+ # General purpose buckets in your account regional namespace must follow
185
+ # a specific naming convention. These buckets consist of a bucket name
186
+ # prefix that you create, and a suffix that contains your 12-digit
187
+ # Amazon Web Services Account ID, the Amazon Web Services Region code,
188
+ # and ends with `-an`. Bucket names must follow the format
189
+ # `bucket-name-prefix-accountId-region-an` (for example,
190
+ # `amzn-s3-demo-bucket-111122223333-us-west-2-an`). For information
191
+ # about bucket naming restrictions, see [Account regional namespace
192
+ # naming rules][2] in the *Amazon S3 User Guide*.
193
+ #
194
+ # <note markdown="1"> This functionality is not supported for directory buckets.
195
+ #
196
+ # </note>
197
+ #
198
+ #
199
+ #
200
+ # [1]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/gpbucketnamespaces.html
201
+ # [2]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html#account-regional-naming-rules
174
202
  # @return [Bucket]
175
203
  def create_bucket(options = {})
176
204
  Aws::Plugins::UserAgent.metric('RESOURCE_MODEL') do