middleman-s3_sync 4.6.4 → 4.7.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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +29 -0
- data/.github/workflows/release.yml +53 -0
- data/Changelog.md +12 -0
- data/README.md +88 -0
- data/WARP.md +5 -1
- data/lib/middleman/s3_sync/cloudfront.rb +50 -26
- data/lib/middleman/s3_sync/options.rb +12 -1
- data/lib/middleman/s3_sync/resource.rb +72 -25
- data/lib/middleman/s3_sync/version.rb +1 -1
- data/lib/middleman/s3_sync.rb +163 -13
- data/lib/middleman-s3_sync/commands.rb +3 -1
- data/lib/middleman-s3_sync/extension.rb +22 -5
- data/middleman-s3_sync.gemspec +22 -18
- data/spec/aws_sdk_parameters_spec.rb +70 -6
- data/spec/cloudfront_spec.rb +2 -0
- data/spec/indifferent_hash_spec.rb +278 -0
- data/spec/resource_spec.rb +206 -0
- data/spec/s3_sync_integration_spec.rb +362 -7
- metadata +80 -64
data/lib/middleman/s3_sync.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'aws-sdk-s3'
|
|
2
2
|
require 'digest/md5'
|
|
3
|
+
require 'mime/types'
|
|
3
4
|
require 'middleman/s3_sync/version'
|
|
4
5
|
require 'middleman/s3_sync/options'
|
|
5
6
|
require 'middleman/s3_sync/caching_policy'
|
|
@@ -11,6 +12,7 @@ require 'middleman/redirect'
|
|
|
11
12
|
require 'parallel'
|
|
12
13
|
require 'ruby-progressbar'
|
|
13
14
|
require 'thread'
|
|
15
|
+
require 'set'
|
|
14
16
|
|
|
15
17
|
module Middleman
|
|
16
18
|
module S3Sync
|
|
@@ -20,6 +22,7 @@ module Middleman
|
|
|
20
22
|
|
|
21
23
|
@@bucket_lock = Mutex.new
|
|
22
24
|
@@bucket_files_lock = Mutex.new
|
|
25
|
+
@@invalidation_paths_lock = Mutex.new
|
|
23
26
|
|
|
24
27
|
attr_accessor :s3_sync_options
|
|
25
28
|
attr_accessor :mm_resources
|
|
@@ -28,11 +31,18 @@ module Middleman
|
|
|
28
31
|
THREADS_COUNT = 8
|
|
29
32
|
|
|
30
33
|
# Track paths that were changed during sync for CloudFront invalidation
|
|
31
|
-
|
|
34
|
+
# Using a Set for O(1) lookups
|
|
35
|
+
attr_reader :invalidation_paths
|
|
32
36
|
|
|
33
37
|
def sync()
|
|
34
38
|
@app ||= ::Middleman::Application.new
|
|
35
|
-
@invalidation_paths =
|
|
39
|
+
@invalidation_paths = Set.new
|
|
40
|
+
|
|
41
|
+
# Ensure sitemap is fully populated before syncing
|
|
42
|
+
# This catches resources from extensions activated in :build mode
|
|
43
|
+
if @app.respond_to?(:sitemap) && @app.sitemap.respond_to?(:ensure_resource_list_updated!)
|
|
44
|
+
@app.sitemap.ensure_resource_list_updated!
|
|
45
|
+
end
|
|
36
46
|
|
|
37
47
|
say_status "Let's see if there's work to be done..."
|
|
38
48
|
unless work_to_be_done?
|
|
@@ -58,8 +68,11 @@ module Middleman
|
|
|
58
68
|
|
|
59
69
|
# Invalidate CloudFront cache if requested
|
|
60
70
|
if s3_sync_options.cloudfront_invalidate
|
|
61
|
-
CloudFront.invalidate(@invalidation_paths, s3_sync_options)
|
|
71
|
+
CloudFront.invalidate(@invalidation_paths.to_a, s3_sync_options)
|
|
62
72
|
end
|
|
73
|
+
|
|
74
|
+
# Run after_s3_sync callback if provided
|
|
75
|
+
run_after_s3_sync_callback
|
|
63
76
|
end
|
|
64
77
|
|
|
65
78
|
def bucket
|
|
@@ -77,10 +90,12 @@ module Middleman
|
|
|
77
90
|
end
|
|
78
91
|
|
|
79
92
|
def add_invalidation_path(path)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
93
|
+
@@invalidation_paths_lock.synchronize do
|
|
94
|
+
@invalidation_paths ||= Set.new
|
|
95
|
+
# Normalize path for CloudFront (ensure it starts with /)
|
|
96
|
+
normalized_path = path.start_with?('/') ? path : "/#{path}"
|
|
97
|
+
@invalidation_paths.add(normalized_path)
|
|
98
|
+
end
|
|
84
99
|
end
|
|
85
100
|
|
|
86
101
|
def remote_only_paths
|
|
@@ -95,6 +110,44 @@ module Middleman
|
|
|
95
110
|
@content_types || {}
|
|
96
111
|
end
|
|
97
112
|
|
|
113
|
+
# Run the after_s3_sync callback if configured
|
|
114
|
+
def run_after_s3_sync_callback
|
|
115
|
+
callback = s3_sync_options.after_s3_sync
|
|
116
|
+
return unless callback
|
|
117
|
+
|
|
118
|
+
say_status 'callback', 'Running after_s3_sync callback...'
|
|
119
|
+
|
|
120
|
+
# Build sync results hash
|
|
121
|
+
results = {
|
|
122
|
+
created: files_to_create.size,
|
|
123
|
+
updated: files_to_update.size,
|
|
124
|
+
deleted: files_to_delete.size,
|
|
125
|
+
invalidation_paths: @invalidation_paths.to_a
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
begin
|
|
129
|
+
if callback.respond_to?(:call)
|
|
130
|
+
# Lambda/Proc callback - check arity to decide whether to pass results
|
|
131
|
+
if callback.arity == 0 || callback.arity == -1 && callback.parameters.empty?
|
|
132
|
+
callback.call
|
|
133
|
+
else
|
|
134
|
+
callback.call(results)
|
|
135
|
+
end
|
|
136
|
+
elsif callback.is_a?(Symbol) && @app.respond_to?(callback)
|
|
137
|
+
# Symbol method name - call on app
|
|
138
|
+
if @app.method(callback).arity == 0
|
|
139
|
+
@app.send(callback)
|
|
140
|
+
else
|
|
141
|
+
@app.send(callback, results)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
say_status 'callback', 'after_s3_sync completed successfully'
|
|
146
|
+
rescue => e
|
|
147
|
+
say_status 'error', "after_s3_sync callback failed: #{e.message}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
98
151
|
protected
|
|
99
152
|
def update_bucket_versioning
|
|
100
153
|
s3_client.put_bucket_versioning({
|
|
@@ -110,10 +163,41 @@ module Middleman
|
|
|
110
163
|
opts[:index_document] = { suffix: s3_sync_options.index_document } if s3_sync_options.index_document
|
|
111
164
|
opts[:error_document] = { key: s3_sync_options.error_document } if s3_sync_options.error_document
|
|
112
165
|
|
|
166
|
+
# Add routing rules if specified
|
|
167
|
+
if s3_sync_options.routing_rules && !s3_sync_options.routing_rules.empty?
|
|
168
|
+
opts[:routing_rules] = s3_sync_options.routing_rules.map do |rule|
|
|
169
|
+
routing_rule = {}
|
|
170
|
+
|
|
171
|
+
# Handle condition (optional)
|
|
172
|
+
if rule[:condition] || rule['condition']
|
|
173
|
+
condition = rule[:condition] || rule['condition']
|
|
174
|
+
routing_rule[:condition] = {}
|
|
175
|
+
routing_rule[:condition][:key_prefix_equals] = condition[:key_prefix_equals] || condition['key_prefix_equals'] if condition[:key_prefix_equals] || condition['key_prefix_equals']
|
|
176
|
+
routing_rule[:condition][:http_error_code_returned_equals] = condition[:http_error_code_returned_equals] || condition['http_error_code_returned_equals'] if condition[:http_error_code_returned_equals] || condition['http_error_code_returned_equals']
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Handle redirect (required)
|
|
180
|
+
redirect = rule[:redirect] || rule['redirect']
|
|
181
|
+
routing_rule[:redirect] = {}
|
|
182
|
+
routing_rule[:redirect][:host_name] = redirect[:host_name] || redirect['host_name'] if redirect[:host_name] || redirect['host_name']
|
|
183
|
+
routing_rule[:redirect][:http_redirect_code] = redirect[:http_redirect_code] || redirect['http_redirect_code'] if redirect[:http_redirect_code] || redirect['http_redirect_code']
|
|
184
|
+
routing_rule[:redirect][:protocol] = redirect[:protocol] || redirect['protocol'] if redirect[:protocol] || redirect['protocol']
|
|
185
|
+
routing_rule[:redirect][:replace_key_prefix_with] = redirect[:replace_key_prefix_with] || redirect['replace_key_prefix_with'] if redirect[:replace_key_prefix_with] || redirect['replace_key_prefix_with']
|
|
186
|
+
routing_rule[:redirect][:replace_key_with] = redirect[:replace_key_with] || redirect['replace_key_with'] if redirect[:replace_key_with] || redirect['replace_key_with']
|
|
187
|
+
|
|
188
|
+
routing_rule
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
113
192
|
if opts[:error_document] && !opts[:index_document]
|
|
114
193
|
raise 'S3 requires `index_document` if `error_document` is specified'
|
|
115
194
|
end
|
|
116
195
|
|
|
196
|
+
# S3 requires index_document if routing_rules are specified
|
|
197
|
+
if opts[:routing_rules] && !opts[:index_document]
|
|
198
|
+
raise 'S3 requires `index_document` if `routing_rules` are specified'
|
|
199
|
+
end
|
|
200
|
+
|
|
117
201
|
unless opts.empty?
|
|
118
202
|
say_status "Putting bucket website: #{opts.to_json}"
|
|
119
203
|
s3_client.put_bucket_website({
|
|
@@ -204,10 +288,22 @@ module Middleman
|
|
|
204
288
|
end
|
|
205
289
|
|
|
206
290
|
def delete_resources
|
|
207
|
-
|
|
208
|
-
|
|
291
|
+
resources = files_to_delete
|
|
292
|
+
return if resources.empty?
|
|
293
|
+
|
|
294
|
+
# Print status messages for all resources being deleted
|
|
295
|
+
resources.each do |resource|
|
|
296
|
+
say_status "#{ANSI.red{"Deleting"}} #{resource.remote_path}"
|
|
209
297
|
add_invalidation_path(resource.path)
|
|
210
298
|
end
|
|
299
|
+
|
|
300
|
+
# Batch delete using S3's delete_objects API (up to 1000 objects per request)
|
|
301
|
+
unless s3_sync_options.dry_run
|
|
302
|
+
resources.each_slice(1000) do |batch|
|
|
303
|
+
objects_to_delete = batch.map { |r| { key: r.remote_path.sub(/^\//, '') } }
|
|
304
|
+
bucket.delete_objects(delete: { objects: objects_to_delete })
|
|
305
|
+
end
|
|
306
|
+
end
|
|
211
307
|
end
|
|
212
308
|
|
|
213
309
|
def ignore_resources
|
|
@@ -217,6 +313,11 @@ module Middleman
|
|
|
217
313
|
def work_to_be_done?
|
|
218
314
|
Parallel.each(mm_resources, in_threads: THREADS_COUNT, progress: "Processing sitemap") { |mm_resource| add_local_resource(mm_resource) }
|
|
219
315
|
|
|
316
|
+
# Scan build directory for orphan files (not in sitemap)
|
|
317
|
+
if s3_sync_options.scan_build_dir
|
|
318
|
+
discover_orphan_files
|
|
319
|
+
end
|
|
320
|
+
|
|
220
321
|
Parallel.each(remote_only_paths, in_threads: THREADS_COUNT, progress: "Processing remote files") do |remote_path|
|
|
221
322
|
s3_sync_resources[remote_path] ||= S3Sync::Resource.new(nil, remote_resource_for_path(remote_path)).tap(&:status)
|
|
222
323
|
end
|
|
@@ -224,24 +325,73 @@ module Middleman
|
|
|
224
325
|
!(files_to_create.empty? && files_to_update.empty? && files_to_delete.empty?)
|
|
225
326
|
end
|
|
226
327
|
|
|
328
|
+
# Discover files in build directory that are not in the sitemap
|
|
329
|
+
# This handles files generated by after_build callbacks, image optimizers, etc.
|
|
330
|
+
def discover_orphan_files
|
|
331
|
+
return unless build_dir && File.directory?(build_dir)
|
|
332
|
+
|
|
333
|
+
orphan_files = []
|
|
334
|
+
|
|
335
|
+
Dir.glob(File.join(build_dir, '**', '*')).each do |file_path|
|
|
336
|
+
next if File.directory?(file_path)
|
|
337
|
+
|
|
338
|
+
# Get path relative to build_dir
|
|
339
|
+
relative_path = file_path.sub(/^#{Regexp.escape(build_dir)}\/?/, '')
|
|
340
|
+
|
|
341
|
+
# Skip if already in sitemap resources
|
|
342
|
+
next if s3_sync_resources.key?(relative_path)
|
|
343
|
+
|
|
344
|
+
orphan_files << relative_path
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
return if orphan_files.empty?
|
|
348
|
+
|
|
349
|
+
say_status "Found #{orphan_files.size} files outside sitemap"
|
|
350
|
+
|
|
351
|
+
Parallel.each(orphan_files, in_threads: THREADS_COUNT, progress: "Processing orphan files") do |relative_path|
|
|
352
|
+
# Create a Resource with nil mm_resource but explicit path
|
|
353
|
+
# It will use mime-types for content type detection
|
|
354
|
+
remote = remote_resource_for_path(relative_path)
|
|
355
|
+
resource = S3Sync::Resource.new(nil, remote, path: relative_path)
|
|
356
|
+
s3_sync_resources[relative_path] = resource.tap(&:status)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Single-pass categorization of resources by status
|
|
361
|
+
# Avoids multiple iterations over s3_sync_resources
|
|
362
|
+
def categorized_resources
|
|
363
|
+
@categorized_resources ||= begin
|
|
364
|
+
result = { create: [], update: [], delete: [], ignore: [] }
|
|
365
|
+
s3_sync_resources.values.each do |resource|
|
|
366
|
+
case
|
|
367
|
+
when resource.to_create? then result[:create] << resource
|
|
368
|
+
when resource.to_update? then result[:update] << resource
|
|
369
|
+
when resource.to_delete? then result[:delete] << resource
|
|
370
|
+
when resource.to_ignore? then result[:ignore] << resource
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
result
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
227
377
|
def files_to_delete
|
|
228
378
|
if s3_sync_options.delete
|
|
229
|
-
|
|
379
|
+
categorized_resources[:delete]
|
|
230
380
|
else
|
|
231
381
|
[]
|
|
232
382
|
end
|
|
233
383
|
end
|
|
234
384
|
|
|
235
385
|
def files_to_create
|
|
236
|
-
|
|
386
|
+
categorized_resources[:create]
|
|
237
387
|
end
|
|
238
388
|
|
|
239
389
|
def files_to_update
|
|
240
|
-
|
|
390
|
+
categorized_resources[:update]
|
|
241
391
|
end
|
|
242
392
|
|
|
243
393
|
def files_to_ignore
|
|
244
|
-
|
|
394
|
+
categorized_resources[:ignore]
|
|
245
395
|
end
|
|
246
396
|
|
|
247
397
|
def build_dir
|
|
@@ -102,7 +102,9 @@ module Middleman
|
|
|
102
102
|
verbose = options[:verbose] ? 0 : 1
|
|
103
103
|
instrument = options[:instrument]
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
# Always use :build mode to ensure build-time extensions are active
|
|
106
|
+
# (e.g., asset_hash in `configure :build` blocks)
|
|
107
|
+
mode = :build
|
|
106
108
|
|
|
107
109
|
::Middleman::S3Sync.app = ::Middleman::Application.new do
|
|
108
110
|
config[:mode] = mode
|
|
@@ -26,8 +26,10 @@ module Middleman
|
|
|
26
26
|
option :dry_run, false, 'Whether to perform a dry-run'
|
|
27
27
|
option :index_document, nil, 'S3 custom index document path'
|
|
28
28
|
option :error_document, nil, 'S3 custom error document path'
|
|
29
|
+
option :routing_rules, [], 'S3 website routing rules (array of rule hashes)'
|
|
29
30
|
option :content_types, {}, 'Custom content types'
|
|
30
31
|
option :ignore_paths, [], 'Paths that should be ignored during sync, strings or regex are allowed'
|
|
32
|
+
option :scan_build_dir, false, 'Scan build directory for files not in sitemap (e.g., after_build files)'
|
|
31
33
|
option :cloudfront_distribution_id, nil, 'CloudFront distribution ID for invalidation'
|
|
32
34
|
option :cloudfront_invalidate, false, 'Whether to invalidate CloudFront cache after sync'
|
|
33
35
|
option :cloudfront_invalidate_all, false, 'Whether to invalidate all paths (/*) or only changed files'
|
|
@@ -35,6 +37,7 @@ module Middleman
|
|
|
35
37
|
option :cloudfront_invalidation_max_retries, 5, 'Maximum number of retries for rate-limited invalidation requests'
|
|
36
38
|
option :cloudfront_invalidation_batch_delay, 2, 'Delay in seconds between invalidation batches'
|
|
37
39
|
option :cloudfront_wait, false, 'Whether to wait for CloudFront invalidation to complete'
|
|
40
|
+
option :after_s3_sync, nil, 'Proc/lambda to call after sync completes (receives changed_paths array)'
|
|
38
41
|
|
|
39
42
|
expose_to_config :s3_sync_options, :default_caching_policy, :caching_policy
|
|
40
43
|
|
|
@@ -87,16 +90,30 @@ module Middleman
|
|
|
87
90
|
true
|
|
88
91
|
end
|
|
89
92
|
|
|
90
|
-
# Delegate option readers to the options object
|
|
93
|
+
# Delegate option readers and writers to the options object
|
|
91
94
|
def method_missing(method, *args, &block)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
method_str = method.to_s
|
|
96
|
+
|
|
97
|
+
# Handle setter methods (e.g., verbose=)
|
|
98
|
+
if method_str.end_with?('=')
|
|
99
|
+
option_name = method_str.chomp('=').to_sym
|
|
100
|
+
if options.respond_to?(option_name)
|
|
101
|
+
options[option_name] = args.first
|
|
102
|
+
return args.first
|
|
103
|
+
end
|
|
104
|
+
elsif options.respond_to?(method)
|
|
105
|
+
return options.send(method, *args, &block)
|
|
96
106
|
end
|
|
107
|
+
|
|
108
|
+
super
|
|
97
109
|
end
|
|
98
110
|
|
|
99
111
|
def respond_to_missing?(method, include_private = false)
|
|
112
|
+
method_str = method.to_s
|
|
113
|
+
if method_str.end_with?('=')
|
|
114
|
+
option_name = method_str.chomp('=').to_sym
|
|
115
|
+
return options.respond_to?(option_name)
|
|
116
|
+
end
|
|
100
117
|
options.respond_to?(method) || super
|
|
101
118
|
end
|
|
102
119
|
|
data/middleman-s3_sync.gemspec
CHANGED
|
@@ -13,28 +13,32 @@ Gem::Specification.new do |gem|
|
|
|
13
13
|
gem.homepage = "http://github.com/fredjean/middleman-s3_sync"
|
|
14
14
|
gem.license = 'MIT'
|
|
15
15
|
|
|
16
|
+
gem.required_ruby_version = '>= 3.0'
|
|
17
|
+
|
|
16
18
|
gem.files = `git ls-files`.split($/)
|
|
17
19
|
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
|
18
20
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
|
19
21
|
gem.require_paths = ["lib"]
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
gem.add_runtime_dependency 'middleman-
|
|
23
|
-
gem.add_runtime_dependency '
|
|
24
|
-
gem.add_runtime_dependency 'aws-sdk-
|
|
25
|
-
gem.add_runtime_dependency '
|
|
26
|
-
gem.add_runtime_dependency '
|
|
27
|
-
gem.add_runtime_dependency '
|
|
28
|
-
gem.add_runtime_dependency '
|
|
29
|
-
gem.add_runtime_dependency '
|
|
23
|
+
# Runtime dependencies
|
|
24
|
+
gem.add_runtime_dependency 'middleman-core', '~> 4.4'
|
|
25
|
+
gem.add_runtime_dependency 'middleman-cli', '~> 4.4'
|
|
26
|
+
gem.add_runtime_dependency 'aws-sdk-s3', '~> 1.187', '>= 1.187.0'
|
|
27
|
+
gem.add_runtime_dependency 'aws-sdk-cloudfront', '~> 1.0'
|
|
28
|
+
gem.add_runtime_dependency 'parallel', '~> 1.20'
|
|
29
|
+
gem.add_runtime_dependency 'ruby-progressbar', '~> 1.11'
|
|
30
|
+
gem.add_runtime_dependency 'ansi', '~> 1.5'
|
|
31
|
+
gem.add_runtime_dependency 'mime-types', '~> 3.4'
|
|
32
|
+
gem.add_runtime_dependency 'nokogiri', '~> 1.18', '>= 1.18.4'
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
gem.add_development_dependency '
|
|
33
|
-
gem.add_development_dependency 'pry
|
|
34
|
-
gem.add_development_dependency '
|
|
35
|
-
gem.add_development_dependency 'rspec
|
|
36
|
-
gem.add_development_dependency 'rspec-
|
|
37
|
-
gem.add_development_dependency 'rspec-
|
|
38
|
-
gem.add_development_dependency '
|
|
39
|
-
gem.add_development_dependency '
|
|
34
|
+
# Development dependencies
|
|
35
|
+
gem.add_development_dependency 'rake', '~> 13.0'
|
|
36
|
+
gem.add_development_dependency 'pry', '~> 0.14'
|
|
37
|
+
gem.add_development_dependency 'pry-byebug', '~> 3.10'
|
|
38
|
+
gem.add_development_dependency 'rspec', '~> 3.12'
|
|
39
|
+
gem.add_development_dependency 'rspec-support', '~> 3.12'
|
|
40
|
+
gem.add_development_dependency 'rspec-its', '~> 2.0'
|
|
41
|
+
gem.add_development_dependency 'rspec-mocks', '~> 3.12'
|
|
42
|
+
gem.add_development_dependency 'timerizer', '~> 0.3'
|
|
43
|
+
gem.add_development_dependency 'webrick', '~> 1.8'
|
|
40
44
|
end
|
|
@@ -87,6 +87,60 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
87
87
|
}.to raise_error('S3 requires `index_document` if `error_document` is specified')
|
|
88
88
|
end
|
|
89
89
|
end
|
|
90
|
+
|
|
91
|
+
context 'when routing_rules are set' do
|
|
92
|
+
before do
|
|
93
|
+
options.routing_rules = [
|
|
94
|
+
{
|
|
95
|
+
condition: { key_prefix_equals: 'docs/' },
|
|
96
|
+
redirect: { replace_key_prefix_with: 'documents/' }
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
condition: { http_error_code_returned_equals: '404' },
|
|
100
|
+
redirect: { host_name: 'example.com', replace_key_with: 'error.html' }
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'includes routing_rules in website configuration' do
|
|
106
|
+
expect(s3_client).to receive(:put_bucket_website) do |params|
|
|
107
|
+
expect(params[:website_configuration]).to have_key(:routing_rules)
|
|
108
|
+
rules = params[:website_configuration][:routing_rules]
|
|
109
|
+
expect(rules).to be_an(Array)
|
|
110
|
+
expect(rules.length).to eq(2)
|
|
111
|
+
|
|
112
|
+
# First rule
|
|
113
|
+
expect(rules[0][:condition][:key_prefix_equals]).to eq('docs/')
|
|
114
|
+
expect(rules[0][:redirect][:replace_key_prefix_with]).to eq('documents/')
|
|
115
|
+
|
|
116
|
+
# Second rule
|
|
117
|
+
expect(rules[1][:condition][:http_error_code_returned_equals]).to eq('404')
|
|
118
|
+
expect(rules[1][:redirect][:host_name]).to eq('example.com')
|
|
119
|
+
expect(rules[1][:redirect][:replace_key_with]).to eq('error.html')
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
Middleman::S3Sync.send(:update_bucket_website)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
context 'when routing_rules are set without index_document' do
|
|
127
|
+
before do
|
|
128
|
+
options.index_document = nil
|
|
129
|
+
options.error_document = nil
|
|
130
|
+
options.routing_rules = [
|
|
131
|
+
{
|
|
132
|
+
condition: { key_prefix_equals: 'old/' },
|
|
133
|
+
redirect: { replace_key_prefix_with: 'new/' }
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'raises an error because S3 requires index_document if routing_rules are specified' do
|
|
139
|
+
expect {
|
|
140
|
+
Middleman::S3Sync.send(:update_bucket_website)
|
|
141
|
+
}.to raise_error('S3 requires `index_document` if `routing_rules` are specified')
|
|
142
|
+
end
|
|
143
|
+
end
|
|
90
144
|
end
|
|
91
145
|
|
|
92
146
|
describe 'put_bucket_versioning parameters' do
|
|
@@ -128,14 +182,17 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
128
182
|
allow(File).to receive(:exist?).with('build/test/file.html.gz').and_return(false)
|
|
129
183
|
allow(File).to receive(:read).with('build/test/file.html').and_return('test content')
|
|
130
184
|
allow(File).to receive(:directory?).with('build/test/file.html').and_return(false)
|
|
185
|
+
# Stub File.open to return a StringIO for streaming upload tests
|
|
186
|
+
allow(File).to receive(:open).with('build/test/file.html', 'rb').and_yield(StringIO.new('test content'))
|
|
131
187
|
allow(s3_object).to receive(:head).and_return(nil)
|
|
132
188
|
options.dry_run = false
|
|
133
189
|
end
|
|
134
190
|
|
|
135
191
|
it 'uses correct metadata key format' do
|
|
136
192
|
expect(s3_object).to receive(:put) do |upload_options|
|
|
137
|
-
# Verify
|
|
138
|
-
expect(upload_options[:body]).to
|
|
193
|
+
# Verify body is a readable IO object (for streaming)
|
|
194
|
+
expect(upload_options[:body]).to respond_to(:read)
|
|
195
|
+
expect(upload_options[:body].read).to eq('test content')
|
|
139
196
|
expect(upload_options[:content_type]).to eq('text/html')
|
|
140
197
|
expect(upload_options[:acl]).to eq('public-read')
|
|
141
198
|
|
|
@@ -160,7 +217,7 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
160
217
|
it 'does not include acl parameter in upload' do
|
|
161
218
|
expect(s3_object).to receive(:put) do |upload_options|
|
|
162
219
|
expect(upload_options).not_to have_key(:acl)
|
|
163
|
-
expect(upload_options[:body]).to
|
|
220
|
+
expect(upload_options[:body]).to respond_to(:read)
|
|
164
221
|
expect(upload_options[:content_type]).to eq('text/html')
|
|
165
222
|
end
|
|
166
223
|
|
|
@@ -176,7 +233,7 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
176
233
|
it 'does not include acl parameter in upload' do
|
|
177
234
|
expect(s3_object).to receive(:put) do |upload_options|
|
|
178
235
|
expect(upload_options).not_to have_key(:acl)
|
|
179
|
-
expect(upload_options[:body]).to
|
|
236
|
+
expect(upload_options[:body]).to respond_to(:read)
|
|
180
237
|
expect(upload_options[:content_type]).to eq('text/html')
|
|
181
238
|
end
|
|
182
239
|
|
|
@@ -192,6 +249,10 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
192
249
|
|
|
193
250
|
it 'automatically retries without ACL when AccessControlListNotSupported error occurs' do
|
|
194
251
|
call_count = 0
|
|
252
|
+
# Use a reusable StringIO that can be rewound
|
|
253
|
+
file_io = StringIO.new('test content')
|
|
254
|
+
allow(File).to receive(:open).with('build/test/file.html', 'rb').and_yield(file_io)
|
|
255
|
+
|
|
195
256
|
expect(s3_object).to receive(:put).twice do |upload_options|
|
|
196
257
|
call_count += 1
|
|
197
258
|
if call_count == 1
|
|
@@ -201,7 +262,7 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
201
262
|
else
|
|
202
263
|
# Second call should not include ACL
|
|
203
264
|
expect(upload_options).not_to have_key(:acl)
|
|
204
|
-
expect(upload_options[:body]).to
|
|
265
|
+
expect(upload_options[:body]).to respond_to(:read)
|
|
205
266
|
expect(upload_options[:content_type]).to eq('text/html')
|
|
206
267
|
end
|
|
207
268
|
end
|
|
@@ -219,7 +280,7 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
219
280
|
expect(upload_options[:acl]).to eq('public-read')
|
|
220
281
|
raise Aws::S3::Errors::AccessControlListNotSupported.new(nil, 'The bucket does not allow ACLs')
|
|
221
282
|
else
|
|
222
|
-
#
|
|
283
|
+
# Subsequent calls should succeed without ACL
|
|
223
284
|
expect(upload_options).not_to have_key(:acl)
|
|
224
285
|
true
|
|
225
286
|
end
|
|
@@ -241,6 +302,7 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
241
302
|
allow(File).to receive(:read).with('build/test/file.html.gz').and_return('gzipped content')
|
|
242
303
|
allow(File).to receive(:exist?).with('build/test/file.html').and_return(true)
|
|
243
304
|
allow(File).to receive(:read).with('build/test/file.html').and_return('original content')
|
|
305
|
+
allow(File).to receive(:open).with('build/test/file.html.gz', 'rb').and_yield(StringIO.new('gzipped content'))
|
|
244
306
|
|
|
245
307
|
# Mock the HEAD response to avoid calling it during redirect?
|
|
246
308
|
head_response = double(
|
|
@@ -305,6 +367,7 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
305
367
|
allow(File).to receive(:exist?).with('build/redirect/file.html').and_return(true)
|
|
306
368
|
allow(File).to receive(:exist?).with('build/redirect/file.html.gz').and_return(false)
|
|
307
369
|
allow(File).to receive(:read).with('build/redirect/file.html').and_return('redirect content')
|
|
370
|
+
allow(File).to receive(:open).with('build/redirect/file.html', 'rb').and_yield(StringIO.new('redirect content'))
|
|
308
371
|
allow(File).to receive(:directory?).with('build/redirect/file.html').and_return(false)
|
|
309
372
|
allow(s3_object).to receive(:head).and_return(nil)
|
|
310
373
|
allow(resource).to receive(:redirect?).and_return(true)
|
|
@@ -478,6 +541,7 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
478
541
|
allow(File).to receive(:exist?).with('build/test/file.html').and_return(true)
|
|
479
542
|
allow(File).to receive(:exist?).with('build/test/file.html.gz').and_return(false)
|
|
480
543
|
allow(File).to receive(:read).with('build/test/file.html').and_return('test content')
|
|
544
|
+
allow(File).to receive(:open).with('build/test/file.html', 'rb').and_yield(StringIO.new('test content'))
|
|
481
545
|
allow(File).to receive(:directory?).with('build/test/file.html').and_return(false)
|
|
482
546
|
allow(s3_object).to receive(:head).and_return(nil)
|
|
483
547
|
options.dry_run = false
|
data/spec/cloudfront_spec.rb
CHANGED
|
@@ -27,6 +27,8 @@ describe Middleman::S3Sync::CloudFront do
|
|
|
27
27
|
|
|
28
28
|
before do
|
|
29
29
|
allow(described_class).to receive(:say_status)
|
|
30
|
+
# Reset cached CloudFront client between tests to prevent double leakage
|
|
31
|
+
described_class.send(:reset_cloudfront_client!)
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
describe '.invalidate' do
|