middleman-s3_sync 4.5.0 → 4.6.1
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/.s3_sync.sample +22 -0
- data/Changelog.md +59 -0
- data/README.md +217 -36
- data/lib/middleman/s3_sync/cloudfront.rb +211 -0
- data/lib/middleman/s3_sync/version.rb +1 -1
- data/lib/middleman/s3_sync.rb +34 -3
- data/lib/middleman-s3_sync/commands.rb +39 -0
- data/lib/middleman-s3_sync/extension.rb +7 -0
- data/middleman-s3_sync.gemspec +1 -0
- data/spec/cloudfront_spec.rb +511 -0
- data/spec/s3_sync_integration_spec.rb +132 -0
- metadata +21 -2
data/lib/middleman/s3_sync.rb
CHANGED
@@ -5,6 +5,7 @@ require 'middleman/s3_sync/options'
|
|
5
5
|
require 'middleman/s3_sync/caching_policy'
|
6
6
|
require 'middleman/s3_sync/status'
|
7
7
|
require 'middleman/s3_sync/resource'
|
8
|
+
require 'middleman/s3_sync/cloudfront'
|
8
9
|
require 'middleman-s3_sync/extension'
|
9
10
|
require 'middleman/redirect'
|
10
11
|
require 'parallel'
|
@@ -25,13 +26,22 @@ module Middleman
|
|
25
26
|
attr_reader :app
|
26
27
|
|
27
28
|
THREADS_COUNT = 8
|
29
|
+
|
30
|
+
# Track paths that were changed during sync for CloudFront invalidation
|
31
|
+
attr_accessor :invalidation_paths
|
28
32
|
|
29
33
|
def sync()
|
30
34
|
@app ||= ::Middleman::Application.new
|
35
|
+
@invalidation_paths = []
|
31
36
|
|
32
37
|
say_status "Let's see if there's work to be done..."
|
33
38
|
unless work_to_be_done?
|
34
39
|
say_status "All S3 files are up to date."
|
40
|
+
|
41
|
+
# Still run CloudFront invalidation if requested for all paths
|
42
|
+
if s3_sync_options.cloudfront_invalidate && s3_sync_options.cloudfront_invalidate_all
|
43
|
+
CloudFront.invalidate([], s3_sync_options)
|
44
|
+
end
|
35
45
|
return
|
36
46
|
end
|
37
47
|
|
@@ -45,6 +55,11 @@ module Middleman
|
|
45
55
|
create_resources
|
46
56
|
update_resources
|
47
57
|
delete_resources
|
58
|
+
|
59
|
+
# Invalidate CloudFront cache if requested
|
60
|
+
if s3_sync_options.cloudfront_invalidate
|
61
|
+
CloudFront.invalidate(@invalidation_paths, s3_sync_options)
|
62
|
+
end
|
48
63
|
end
|
49
64
|
|
50
65
|
def bucket
|
@@ -60,6 +75,13 @@ module Middleman
|
|
60
75
|
def add_local_resource(mm_resource)
|
61
76
|
s3_sync_resources[mm_resource.destination_path] = S3Sync::Resource.new(mm_resource, remote_resource_for_path(mm_resource.destination_path)).tap(&:status)
|
62
77
|
end
|
78
|
+
|
79
|
+
def add_invalidation_path(path)
|
80
|
+
@invalidation_paths ||= []
|
81
|
+
# Normalize path for CloudFront (ensure it starts with /)
|
82
|
+
normalized_path = path.start_with?('/') ? path : "/#{path}"
|
83
|
+
@invalidation_paths << normalized_path unless @invalidation_paths.include?(normalized_path)
|
84
|
+
end
|
63
85
|
|
64
86
|
def remote_only_paths
|
65
87
|
paths - s3_sync_resources.keys
|
@@ -168,15 +190,24 @@ module Middleman
|
|
168
190
|
end
|
169
191
|
|
170
192
|
def create_resources
|
171
|
-
Parallel.map(files_to_create, in_threads: THREADS_COUNT
|
193
|
+
Parallel.map(files_to_create, in_threads: THREADS_COUNT) do |resource|
|
194
|
+
resource.create!
|
195
|
+
add_invalidation_path(resource.path)
|
196
|
+
end
|
172
197
|
end
|
173
198
|
|
174
199
|
def update_resources
|
175
|
-
Parallel.map(files_to_update, in_threads: THREADS_COUNT
|
200
|
+
Parallel.map(files_to_update, in_threads: THREADS_COUNT) do |resource|
|
201
|
+
resource.update!
|
202
|
+
add_invalidation_path(resource.path)
|
203
|
+
end
|
176
204
|
end
|
177
205
|
|
178
206
|
def delete_resources
|
179
|
-
Parallel.map(files_to_delete, in_threads: THREADS_COUNT
|
207
|
+
Parallel.map(files_to_delete, in_threads: THREADS_COUNT) do |resource|
|
208
|
+
resource.destroy!
|
209
|
+
add_invalidation_path(resource.path)
|
210
|
+
end
|
180
211
|
end
|
181
212
|
|
182
213
|
def ignore_resources
|
@@ -65,6 +65,38 @@ module Middleman
|
|
65
65
|
type: :string,
|
66
66
|
desc: 'Print instrument messages.'
|
67
67
|
|
68
|
+
class_option :cloudfront_distribution_id,
|
69
|
+
aliases: '-d',
|
70
|
+
type: :string,
|
71
|
+
desc: 'CloudFront distribution ID for invalidation.'
|
72
|
+
|
73
|
+
class_option :cloudfront_invalidate,
|
74
|
+
aliases: '-c',
|
75
|
+
type: :boolean,
|
76
|
+
desc: 'Invalidate CloudFront cache after sync.'
|
77
|
+
|
78
|
+
class_option :cloudfront_invalidate_all,
|
79
|
+
aliases: '-a',
|
80
|
+
type: :boolean,
|
81
|
+
desc: 'Invalidate all paths (/*) instead of only changed files.'
|
82
|
+
|
83
|
+
class_option :cloudfront_invalidation_batch_size,
|
84
|
+
type: :numeric,
|
85
|
+
desc: 'Maximum number of paths to invalidate in a single request (default: 1000).'
|
86
|
+
|
87
|
+
class_option :cloudfront_invalidation_max_retries,
|
88
|
+
type: :numeric,
|
89
|
+
desc: 'Maximum number of retries for rate-limited invalidation requests (default: 5).'
|
90
|
+
|
91
|
+
class_option :cloudfront_invalidation_batch_delay,
|
92
|
+
type: :numeric,
|
93
|
+
desc: 'Delay in seconds between invalidation batches (default: 2).'
|
94
|
+
|
95
|
+
class_option :cloudfront_wait,
|
96
|
+
aliases: '-w',
|
97
|
+
type: :boolean,
|
98
|
+
desc: 'Wait for CloudFront invalidation to complete before exiting.'
|
99
|
+
|
68
100
|
def s3_sync
|
69
101
|
env = options[:environment].to_s.to_sym
|
70
102
|
verbose = options[:verbose] ? 0 : 1
|
@@ -99,6 +131,13 @@ module Middleman
|
|
99
131
|
s3_sync_options.prefix = s3_sync_options.prefix.end_with?('/') ? s3_sync_options.prefix : s3_sync_options.prefix + '/'
|
100
132
|
end
|
101
133
|
s3_sync_options.dry_run = options[:dry_run] if options[:dry_run]
|
134
|
+
s3_sync_options.cloudfront_distribution_id = options[:cloudfront_distribution_id] if options[:cloudfront_distribution_id]
|
135
|
+
s3_sync_options.cloudfront_invalidate = options[:cloudfront_invalidate] if options[:cloudfront_invalidate]
|
136
|
+
s3_sync_options.cloudfront_invalidate_all = options[:cloudfront_invalidate_all] if options[:cloudfront_invalidate_all]
|
137
|
+
s3_sync_options.cloudfront_invalidation_batch_size = options[:cloudfront_invalidation_batch_size] if options[:cloudfront_invalidation_batch_size]
|
138
|
+
s3_sync_options.cloudfront_invalidation_max_retries = options[:cloudfront_invalidation_max_retries] if options[:cloudfront_invalidation_max_retries]
|
139
|
+
s3_sync_options.cloudfront_invalidation_batch_delay = options[:cloudfront_invalidation_batch_delay] if options[:cloudfront_invalidation_batch_delay]
|
140
|
+
s3_sync_options.cloudfront_wait = options[:cloudfront_wait] if options[:cloudfront_wait]
|
102
141
|
|
103
142
|
::Middleman::S3Sync.sync()
|
104
143
|
end
|
@@ -29,6 +29,13 @@ module Middleman
|
|
29
29
|
option :error_document, nil, 'S3 custom error document path'
|
30
30
|
option :content_types, {}, 'Custom content types'
|
31
31
|
option :ignore_paths, [], 'Paths that should be ignored during sync, strings or regex are allowed'
|
32
|
+
option :cloudfront_distribution_id, nil, 'CloudFront distribution ID for invalidation'
|
33
|
+
option :cloudfront_invalidate, false, 'Whether to invalidate CloudFront cache after sync'
|
34
|
+
option :cloudfront_invalidate_all, false, 'Whether to invalidate all paths (/*) or only changed files'
|
35
|
+
option :cloudfront_invalidation_batch_size, 1000, 'Maximum number of paths to invalidate in a single request'
|
36
|
+
option :cloudfront_invalidation_max_retries, 5, 'Maximum number of retries for rate-limited invalidation requests'
|
37
|
+
option :cloudfront_invalidation_batch_delay, 2, 'Delay in seconds between invalidation batches'
|
38
|
+
option :cloudfront_wait, false, 'Whether to wait for CloudFront invalidation to complete'
|
32
39
|
|
33
40
|
expose_to_config :s3_sync_options, :default_caching_policy, :caching_policy
|
34
41
|
|
data/middleman-s3_sync.gemspec
CHANGED
@@ -22,6 +22,7 @@ Gem::Specification.new do |gem|
|
|
22
22
|
gem.add_runtime_dependency 'middleman-cli'
|
23
23
|
gem.add_runtime_dependency 'unf'
|
24
24
|
gem.add_runtime_dependency 'aws-sdk-s3'
|
25
|
+
gem.add_runtime_dependency 'aws-sdk-cloudfront'
|
25
26
|
gem.add_runtime_dependency 'map'
|
26
27
|
gem.add_runtime_dependency 'parallel'
|
27
28
|
gem.add_runtime_dependency 'ruby-progressbar'
|
@@ -0,0 +1,511 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'middleman/s3_sync/cloudfront'
|
3
|
+
|
4
|
+
describe Middleman::S3Sync::CloudFront do
|
5
|
+
let(:options) do
|
6
|
+
double(
|
7
|
+
cloudfront_invalidate: true,
|
8
|
+
cloudfront_distribution_id: 'E1234567890123',
|
9
|
+
cloudfront_invalidate_all: false,
|
10
|
+
cloudfront_invalidation_batch_size: 1000,
|
11
|
+
cloudfront_invalidation_max_retries: 5,
|
12
|
+
cloudfront_invalidation_batch_delay: 2,
|
13
|
+
cloudfront_wait: false,
|
14
|
+
aws_access_key_id: 'test_key',
|
15
|
+
aws_secret_access_key: 'test_secret',
|
16
|
+
aws_session_token: nil,
|
17
|
+
dry_run: false,
|
18
|
+
verbose: false
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
let(:invalidation_response) do
|
23
|
+
double(
|
24
|
+
invalidation: double(id: 'I1234567890123')
|
25
|
+
)
|
26
|
+
end
|
27
|
+
|
28
|
+
before do
|
29
|
+
allow(described_class).to receive(:say_status)
|
30
|
+
end
|
31
|
+
|
32
|
+
describe '.invalidate' do
|
33
|
+
context 'when CloudFront invalidation is disabled' do
|
34
|
+
let(:options) do
|
35
|
+
double(cloudfront_invalidate: false)
|
36
|
+
end
|
37
|
+
|
38
|
+
it 'returns early without doing anything' do
|
39
|
+
expect(Aws::CloudFront::Client).not_to receive(:new)
|
40
|
+
result = described_class.invalidate(['/path1', '/path2'], options)
|
41
|
+
expect(result).to be_nil
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
context 'when no distribution ID is provided' do
|
46
|
+
let(:options) do
|
47
|
+
double(
|
48
|
+
cloudfront_invalidate: true,
|
49
|
+
cloudfront_distribution_id: nil
|
50
|
+
)
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'skips invalidation and shows warning' do
|
54
|
+
expect(described_class).to receive(:say_status).with(
|
55
|
+
match(/CloudFront invalidation skipped.*no distribution ID/)
|
56
|
+
)
|
57
|
+
expect(Aws::CloudFront::Client).not_to receive(:new)
|
58
|
+
result = described_class.invalidate(['/path1', '/path2'], options)
|
59
|
+
expect(result).to be_nil
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
context 'when dry run is enabled' do
|
64
|
+
let(:options) do
|
65
|
+
double(
|
66
|
+
cloudfront_invalidate: true,
|
67
|
+
cloudfront_distribution_id: 'E1234567890123',
|
68
|
+
cloudfront_invalidate_all: false,
|
69
|
+
dry_run: true,
|
70
|
+
verbose: true
|
71
|
+
)
|
72
|
+
end
|
73
|
+
|
74
|
+
it 'shows what would be invalidated without making API calls' do
|
75
|
+
expect(described_class).to receive(:say_status).with(
|
76
|
+
'Invalidating CloudFront distribution E1234567890123'
|
77
|
+
)
|
78
|
+
expect(described_class).to receive(:say_status).with(
|
79
|
+
match(/DRY RUN.*Would invalidate 2 paths/)
|
80
|
+
)
|
81
|
+
expect(described_class).to receive(:say_status).with(' /path1')
|
82
|
+
expect(described_class).to receive(:say_status).with(' /path2')
|
83
|
+
expect(Aws::CloudFront::Client).not_to receive(:new)
|
84
|
+
|
85
|
+
result = described_class.invalidate(['/path1', '/path2'], options)
|
86
|
+
expect(result).to be_nil
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
context 'when invalidating all paths' do
|
91
|
+
let(:options) do
|
92
|
+
double(
|
93
|
+
cloudfront_invalidate: true,
|
94
|
+
cloudfront_distribution_id: 'E1234567890123',
|
95
|
+
cloudfront_invalidate_all: true,
|
96
|
+
cloudfront_invalidation_batch_size: 1000,
|
97
|
+
cloudfront_invalidation_max_retries: 5,
|
98
|
+
cloudfront_invalidation_batch_delay: 2,
|
99
|
+
cloudfront_wait: false,
|
100
|
+
aws_access_key_id: 'test_key',
|
101
|
+
aws_secret_access_key: 'test_secret',
|
102
|
+
aws_session_token: nil,
|
103
|
+
dry_run: false,
|
104
|
+
verbose: false
|
105
|
+
)
|
106
|
+
end
|
107
|
+
|
108
|
+
it 'invalidates all paths with /*' do
|
109
|
+
client = double('cloudfront_client')
|
110
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
111
|
+
expect(client).to receive(:create_invalidation).with({
|
112
|
+
distribution_id: 'E1234567890123',
|
113
|
+
invalidation_batch: {
|
114
|
+
paths: {
|
115
|
+
quantity: 1,
|
116
|
+
items: ['/*']
|
117
|
+
},
|
118
|
+
caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
|
119
|
+
}
|
120
|
+
}).and_return(invalidation_response)
|
121
|
+
|
122
|
+
result = described_class.invalidate(['/path1', '/path2'], options)
|
123
|
+
expect(result).to eq(['I1234567890123'])
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
context 'when invalidating specific paths' do
|
128
|
+
it 'creates invalidation for the provided paths' do
|
129
|
+
client = double('cloudfront_client')
|
130
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
131
|
+
expect(client).to receive(:create_invalidation).with({
|
132
|
+
distribution_id: 'E1234567890123',
|
133
|
+
invalidation_batch: {
|
134
|
+
paths: {
|
135
|
+
quantity: 2,
|
136
|
+
items: ['/path1', '/path2']
|
137
|
+
},
|
138
|
+
caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
|
139
|
+
}
|
140
|
+
}).and_return(invalidation_response)
|
141
|
+
|
142
|
+
result = described_class.invalidate(['/path1', '/path2'], options)
|
143
|
+
expect(result).to eq(['I1234567890123'])
|
144
|
+
end
|
145
|
+
|
146
|
+
it 'normalizes paths to start with /' do
|
147
|
+
client = double('cloudfront_client')
|
148
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
149
|
+
expect(client).to receive(:create_invalidation).with({
|
150
|
+
distribution_id: 'E1234567890123',
|
151
|
+
invalidation_batch: {
|
152
|
+
paths: {
|
153
|
+
quantity: 2,
|
154
|
+
items: ['/path1', '/path2']
|
155
|
+
},
|
156
|
+
caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
|
157
|
+
}
|
158
|
+
}).and_return(invalidation_response)
|
159
|
+
|
160
|
+
result = described_class.invalidate(['path1', '/path2'], options)
|
161
|
+
expect(result).to eq(['I1234567890123'])
|
162
|
+
end
|
163
|
+
|
164
|
+
it 'removes duplicate paths' do
|
165
|
+
client = double('cloudfront_client')
|
166
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
167
|
+
expect(client).to receive(:create_invalidation).with({
|
168
|
+
distribution_id: 'E1234567890123',
|
169
|
+
invalidation_batch: {
|
170
|
+
paths: {
|
171
|
+
quantity: 2,
|
172
|
+
items: ['/path1', '/path2']
|
173
|
+
},
|
174
|
+
caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
|
175
|
+
}
|
176
|
+
}).and_return(invalidation_response)
|
177
|
+
|
178
|
+
result = described_class.invalidate(['/path1', 'path1', '/path2'], options)
|
179
|
+
expect(result).to eq(['I1234567890123'])
|
180
|
+
end
|
181
|
+
|
182
|
+
it 'removes double slashes from paths' do
|
183
|
+
client = double('cloudfront_client')
|
184
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
185
|
+
expect(client).to receive(:create_invalidation).with({
|
186
|
+
distribution_id: 'E1234567890123',
|
187
|
+
invalidation_batch: {
|
188
|
+
paths: {
|
189
|
+
quantity: 1,
|
190
|
+
items: ['/path/to/file.html']
|
191
|
+
},
|
192
|
+
caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
|
193
|
+
}
|
194
|
+
}).and_return(invalidation_response)
|
195
|
+
|
196
|
+
result = described_class.invalidate(['//path//to//file.html'], options)
|
197
|
+
expect(result).to eq(['I1234567890123'])
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
context 'with large batch sizes' do
|
202
|
+
let(:options) do
|
203
|
+
double(
|
204
|
+
cloudfront_invalidate: true,
|
205
|
+
cloudfront_distribution_id: 'E1234567890123',
|
206
|
+
cloudfront_invalidate_all: false,
|
207
|
+
cloudfront_invalidation_batch_size: 2,
|
208
|
+
cloudfront_invalidation_max_retries: 5,
|
209
|
+
cloudfront_invalidation_batch_delay: 1,
|
210
|
+
cloudfront_wait: false,
|
211
|
+
aws_access_key_id: 'test_key',
|
212
|
+
aws_secret_access_key: 'test_secret',
|
213
|
+
aws_session_token: nil,
|
214
|
+
dry_run: false,
|
215
|
+
verbose: false
|
216
|
+
)
|
217
|
+
end
|
218
|
+
|
219
|
+
it 'splits paths into multiple batches' do
|
220
|
+
client = double('cloudfront_client')
|
221
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
222
|
+
paths = ['/path1', '/path2', '/path3', '/path4']
|
223
|
+
|
224
|
+
expect(client).to receive(:create_invalidation).twice.and_return(invalidation_response)
|
225
|
+
expect(described_class).to receive(:sleep).with(1)
|
226
|
+
|
227
|
+
result = described_class.invalidate(paths, options)
|
228
|
+
expect(result).to eq(['I1234567890123', 'I1234567890123'])
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
context 'when CloudFront API returns an error' do
|
233
|
+
let(:error) { Aws::CloudFront::Errors::ServiceError.new(nil, 'Distribution not found') }
|
234
|
+
|
235
|
+
it 'handles API errors gracefully' do
|
236
|
+
client = double('cloudfront_client')
|
237
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
238
|
+
expect(client).to receive(:create_invalidation).and_raise(error)
|
239
|
+
expect(described_class).to receive(:say_status).with(
|
240
|
+
match(/Failed to create CloudFront invalidation.*Distribution not found/)
|
241
|
+
)
|
242
|
+
|
243
|
+
expect {
|
244
|
+
described_class.invalidate(['/path1'], options)
|
245
|
+
}.to raise_error(Aws::CloudFront::Errors::ServiceError)
|
246
|
+
end
|
247
|
+
|
248
|
+
context 'when rate limit is exceeded' do
|
249
|
+
let(:rate_error) { Aws::CloudFront::Errors::ServiceError.new(nil, 'Rate exceeded') }
|
250
|
+
|
251
|
+
it 'retries with exponential backoff' do
|
252
|
+
client = double('cloudfront_client')
|
253
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
254
|
+
|
255
|
+
# Fail twice with rate limit, then succeed
|
256
|
+
call_count = 0
|
257
|
+
allow(client).to receive(:create_invalidation) do
|
258
|
+
call_count += 1
|
259
|
+
if call_count <= 2
|
260
|
+
raise rate_error
|
261
|
+
else
|
262
|
+
invalidation_response
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
# Allow normal status messages but expect retry messages
|
267
|
+
allow(described_class).to receive(:say_status)
|
268
|
+
expect(described_class).to receive(:say_status).with(
|
269
|
+
match(/Rate limit hit, retrying in \d+ seconds.*attempt 1\/5/)
|
270
|
+
).ordered
|
271
|
+
expect(described_class).to receive(:say_status).with(
|
272
|
+
match(/Rate limit hit, retrying in \d+ seconds.*attempt 2\/5/)
|
273
|
+
).ordered
|
274
|
+
|
275
|
+
# Expect sleep calls for backoff
|
276
|
+
expect(described_class).to receive(:sleep).twice
|
277
|
+
|
278
|
+
result = described_class.invalidate(['/path1'], options)
|
279
|
+
expect(result).to eq(['I1234567890123'])
|
280
|
+
end
|
281
|
+
|
282
|
+
it 'gives up after max retries and raises error' do
|
283
|
+
rate_limited_options = double(
|
284
|
+
cloudfront_invalidate: true,
|
285
|
+
cloudfront_distribution_id: 'E1234567890123',
|
286
|
+
cloudfront_invalidate_all: false,
|
287
|
+
cloudfront_invalidation_batch_size: 1000,
|
288
|
+
cloudfront_invalidation_max_retries: 2,
|
289
|
+
cloudfront_invalidation_batch_delay: 2,
|
290
|
+
cloudfront_wait: false,
|
291
|
+
aws_access_key_id: 'test_key',
|
292
|
+
aws_secret_access_key: 'test_secret',
|
293
|
+
aws_session_token: nil,
|
294
|
+
dry_run: false,
|
295
|
+
verbose: false
|
296
|
+
)
|
297
|
+
|
298
|
+
client = double('cloudfront_client')
|
299
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
300
|
+
|
301
|
+
# Fail max_retries + 1 times
|
302
|
+
expect(client).to receive(:create_invalidation).exactly(3).times.and_raise(rate_error)
|
303
|
+
|
304
|
+
# Allow normal status messages
|
305
|
+
allow(described_class).to receive(:say_status)
|
306
|
+
|
307
|
+
# Expect retry status messages
|
308
|
+
expect(described_class).to receive(:say_status).with(
|
309
|
+
match(/Rate limit hit, retrying in \d+ seconds.*attempt 1\/2/)
|
310
|
+
)
|
311
|
+
expect(described_class).to receive(:say_status).with(
|
312
|
+
match(/Rate limit hit, retrying in \d+ seconds.*attempt 2\/2/)
|
313
|
+
)
|
314
|
+
expect(described_class).to receive(:say_status).with(
|
315
|
+
match(/Failed to create CloudFront invalidation.*Rate exceeded/)
|
316
|
+
)
|
317
|
+
|
318
|
+
# Expect sleep calls for backoff
|
319
|
+
expect(described_class).to receive(:sleep).twice
|
320
|
+
|
321
|
+
expect {
|
322
|
+
described_class.invalidate(['/path1'], rate_limited_options)
|
323
|
+
}.to raise_error(Aws::CloudFront::Errors::ServiceError)
|
324
|
+
end
|
325
|
+
|
326
|
+
it 'handles throttling errors the same as rate exceeded' do
|
327
|
+
throttling_error = Aws::CloudFront::Errors::ServiceError.new(nil, 'Throttling: Request was throttled')
|
328
|
+
|
329
|
+
client = double('cloudfront_client')
|
330
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
331
|
+
|
332
|
+
call_count = 0
|
333
|
+
allow(client).to receive(:create_invalidation) do
|
334
|
+
call_count += 1
|
335
|
+
if call_count == 1
|
336
|
+
raise throttling_error
|
337
|
+
else
|
338
|
+
invalidation_response
|
339
|
+
end
|
340
|
+
end
|
341
|
+
|
342
|
+
# Allow normal status messages but expect retry message
|
343
|
+
allow(described_class).to receive(:say_status)
|
344
|
+
expect(described_class).to receive(:say_status).with(
|
345
|
+
match(/Rate limit hit, retrying in \d+ seconds.*attempt 1\/5/)
|
346
|
+
).ordered
|
347
|
+
expect(described_class).to receive(:sleep).once
|
348
|
+
|
349
|
+
result = described_class.invalidate(['/path1'], options)
|
350
|
+
expect(result).to eq(['I1234567890123'])
|
351
|
+
end
|
352
|
+
end
|
353
|
+
|
354
|
+
context 'when verbose mode is enabled' do
|
355
|
+
let(:options) do
|
356
|
+
double(
|
357
|
+
cloudfront_invalidate: true,
|
358
|
+
cloudfront_distribution_id: 'E1234567890123',
|
359
|
+
cloudfront_invalidate_all: false,
|
360
|
+
cloudfront_invalidation_batch_size: 1000,
|
361
|
+
cloudfront_invalidation_max_retries: 5,
|
362
|
+
cloudfront_invalidation_batch_delay: 2,
|
363
|
+
cloudfront_wait: false,
|
364
|
+
aws_access_key_id: 'test_key',
|
365
|
+
aws_secret_access_key: 'test_secret',
|
366
|
+
aws_session_token: nil,
|
367
|
+
dry_run: false,
|
368
|
+
verbose: true
|
369
|
+
)
|
370
|
+
end
|
371
|
+
|
372
|
+
it 'does not re-raise errors in verbose mode' do
|
373
|
+
client = double('cloudfront_client')
|
374
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
375
|
+
expect(client).to receive(:create_invalidation).and_raise(error)
|
376
|
+
expect(described_class).to receive(:say_status).with(
|
377
|
+
match(/Failed to create CloudFront invalidation/)
|
378
|
+
)
|
379
|
+
|
380
|
+
expect {
|
381
|
+
described_class.invalidate(['/path1'], options)
|
382
|
+
}.not_to raise_error
|
383
|
+
end
|
384
|
+
end
|
385
|
+
end
|
386
|
+
|
387
|
+
context 'with empty paths and invalidate_all false' do
|
388
|
+
it 'returns early without making API calls' do
|
389
|
+
expect(Aws::CloudFront::Client).not_to receive(:new)
|
390
|
+
result = described_class.invalidate([], options)
|
391
|
+
expect(result).to be_nil
|
392
|
+
end
|
393
|
+
end
|
394
|
+
|
395
|
+
context 'when cloudfront_wait is enabled' do
|
396
|
+
let(:options) do
|
397
|
+
double(
|
398
|
+
cloudfront_invalidate: true,
|
399
|
+
cloudfront_distribution_id: 'E1234567890123',
|
400
|
+
cloudfront_invalidate_all: false,
|
401
|
+
cloudfront_invalidation_batch_size: 1000,
|
402
|
+
cloudfront_invalidation_max_retries: 5,
|
403
|
+
cloudfront_invalidation_batch_delay: 2,
|
404
|
+
cloudfront_wait: true,
|
405
|
+
aws_access_key_id: 'test_key',
|
406
|
+
aws_secret_access_key: 'test_secret',
|
407
|
+
aws_session_token: nil,
|
408
|
+
dry_run: false,
|
409
|
+
verbose: false
|
410
|
+
)
|
411
|
+
end
|
412
|
+
|
413
|
+
it 'waits for invalidation to complete' do
|
414
|
+
client = double('cloudfront_client')
|
415
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
416
|
+
expect(client).to receive(:create_invalidation).and_return(invalidation_response)
|
417
|
+
expect(client).to receive(:wait_until).with(:invalidation_completed,
|
418
|
+
distribution_id: 'E1234567890123',
|
419
|
+
id: 'I1234567890123'
|
420
|
+
)
|
421
|
+
expect(described_class).to receive(:say_status).with(
|
422
|
+
'Waiting for CloudFront invalidation(s) to complete...'
|
423
|
+
)
|
424
|
+
expect(described_class).to receive(:say_status).with(
|
425
|
+
'CloudFront invalidation(s) completed successfully'
|
426
|
+
)
|
427
|
+
|
428
|
+
result = described_class.invalidate(['/path1'], options)
|
429
|
+
expect(result).to eq(['I1234567890123'])
|
430
|
+
end
|
431
|
+
end
|
432
|
+
end
|
433
|
+
|
434
|
+
describe 'CloudFront client configuration' do
|
435
|
+
it 'creates client with correct credentials' do
|
436
|
+
client = double('cloudfront_client')
|
437
|
+
expect(Aws::CloudFront::Client).to receive(:new).with({
|
438
|
+
region: 'us-east-1',
|
439
|
+
access_key_id: 'test_key',
|
440
|
+
secret_access_key: 'test_secret'
|
441
|
+
}).and_return(client)
|
442
|
+
|
443
|
+
expect(client).to receive(:create_invalidation).and_return(invalidation_response)
|
444
|
+
|
445
|
+
described_class.invalidate(['/test'], options)
|
446
|
+
end
|
447
|
+
|
448
|
+
context 'with session token' do
|
449
|
+
let(:options) do
|
450
|
+
double(
|
451
|
+
cloudfront_invalidate: true,
|
452
|
+
cloudfront_distribution_id: 'E1234567890123',
|
453
|
+
cloudfront_invalidate_all: false,
|
454
|
+
cloudfront_invalidation_batch_size: 1000,
|
455
|
+
cloudfront_invalidation_max_retries: 5,
|
456
|
+
cloudfront_invalidation_batch_delay: 2,
|
457
|
+
cloudfront_wait: false,
|
458
|
+
aws_access_key_id: 'test_key',
|
459
|
+
aws_secret_access_key: 'test_secret',
|
460
|
+
aws_session_token: 'test_token',
|
461
|
+
dry_run: false,
|
462
|
+
verbose: false
|
463
|
+
)
|
464
|
+
end
|
465
|
+
|
466
|
+
it 'includes session token in client configuration' do
|
467
|
+
client = double('cloudfront_client')
|
468
|
+
expect(Aws::CloudFront::Client).to receive(:new).with({
|
469
|
+
region: 'us-east-1',
|
470
|
+
access_key_id: 'test_key',
|
471
|
+
secret_access_key: 'test_secret',
|
472
|
+
session_token: 'test_token'
|
473
|
+
}).and_return(client)
|
474
|
+
|
475
|
+
expect(client).to receive(:create_invalidation).and_return(invalidation_response)
|
476
|
+
|
477
|
+
described_class.invalidate(['/test'], options)
|
478
|
+
end
|
479
|
+
end
|
480
|
+
|
481
|
+
context 'without explicit credentials' do
|
482
|
+
let(:options) do
|
483
|
+
double(
|
484
|
+
cloudfront_invalidate: true,
|
485
|
+
cloudfront_distribution_id: 'E1234567890123',
|
486
|
+
cloudfront_invalidate_all: false,
|
487
|
+
cloudfront_invalidation_batch_size: 1000,
|
488
|
+
cloudfront_invalidation_max_retries: 5,
|
489
|
+
cloudfront_invalidation_batch_delay: 2,
|
490
|
+
cloudfront_wait: false,
|
491
|
+
aws_access_key_id: nil,
|
492
|
+
aws_secret_access_key: nil,
|
493
|
+
aws_session_token: nil,
|
494
|
+
dry_run: false,
|
495
|
+
verbose: false
|
496
|
+
)
|
497
|
+
end
|
498
|
+
|
499
|
+
it 'creates client without explicit credentials (uses default chain)' do
|
500
|
+
client = double('cloudfront_client')
|
501
|
+
expect(Aws::CloudFront::Client).to receive(:new).with({
|
502
|
+
region: 'us-east-1'
|
503
|
+
}).and_return(client)
|
504
|
+
|
505
|
+
expect(client).to receive(:create_invalidation).and_return(invalidation_response)
|
506
|
+
|
507
|
+
described_class.invalidate(['/test'], options)
|
508
|
+
end
|
509
|
+
end
|
510
|
+
end
|
511
|
+
end
|