middleman-s3_sync 4.5.0 → 4.6.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/.s3_sync.sample +20 -0
- data/README.md +210 -36
- data/lib/middleman/s3_sync/cloudfront.rb +192 -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 +29 -0
- data/lib/middleman-s3_sync/extension.rb +5 -0
- data/middleman-s3_sync.gemspec +1 -0
- data/spec/cloudfront_spec.rb +391 -0
- data/spec/s3_sync_integration_spec.rb +132 -0
- metadata +21 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7d5baff2d2863949eb480b7ac94d105d3fdcc2c7df67fab66c649f575dd686a4
|
4
|
+
data.tar.gz: 70c12f42e60eabb0bf063d47a3e42f2766ea883bf47c17557cf323b15a9e546a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 663ab12a585095543adb3843976e1d24bbf5d3e6bb52a5285b3ce7de5cc7796cb916b72dc330d110225e191325add7e6eed81797abccae9c4e66d0b1d84c8608
|
7
|
+
data.tar.gz: b9b720083616f701318747b661aa04d9257c9539136ae0a2d153cc314eced337baa7ca158cb6e10de1d86ef1bd29cb90cd49b7ee1160c69bdf7b9bab09d4c27f
|
data/.s3_sync.sample
CHANGED
@@ -1,3 +1,23 @@
|
|
1
1
|
---
|
2
2
|
aws_access_key_id: <AWS Access Key>
|
3
3
|
aws_secret_access_key: <AWS Secret Access Key>
|
4
|
+
bucket: <S3 Bucket Name>
|
5
|
+
region: us-east-1
|
6
|
+
delete: true
|
7
|
+
after_build: false
|
8
|
+
prefer_gzip: true
|
9
|
+
path_style: true
|
10
|
+
reduced_redundancy_storage: false
|
11
|
+
acl: public-read
|
12
|
+
encryption: false
|
13
|
+
prefix: ''
|
14
|
+
version_bucket: false
|
15
|
+
index_document: index.html
|
16
|
+
error_document: 404.html
|
17
|
+
|
18
|
+
# CloudFront Invalidation Settings
|
19
|
+
cloudfront_distribution_id: <CloudFront Distribution ID> # e.g., E1234567890123
|
20
|
+
cloudfront_invalidate: false # Set to true to enable
|
21
|
+
cloudfront_invalidate_all: false # Set to true to invalidate all paths (/*)
|
22
|
+
cloudfront_invalidation_batch_size: 1000 # Max paths per invalidation request
|
23
|
+
cloudfront_wait: false # Set to true to wait for invalidation to complete
|
data/README.md
CHANGED
@@ -54,6 +54,9 @@ activate :s3_sync do |s3_sync|
|
|
54
54
|
s3_sync.version_bucket = false
|
55
55
|
s3_sync.index_document = 'index.html'
|
56
56
|
s3_sync.error_document = '404.html'
|
57
|
+
s3_sync.cloudfront_distribution_id = 'E1234567890123' # CloudFront distribution ID
|
58
|
+
s3_sync.cloudfront_invalidate = false # Enable CloudFront invalidation
|
59
|
+
s3_sync.cloudfront_invalidate_all = false # Invalidate all paths (/*) or only changed files
|
57
60
|
end
|
58
61
|
```
|
59
62
|
|
@@ -76,44 +79,45 @@ The following defaults apply to the configuration items:
|
|
76
79
|
| encryption | ```false``` |
|
77
80
|
| acl | ```'public-read'``` |
|
78
81
|
| version_bucket | ```false``` |
|
82
|
+
| cloudfront_distribution_id | - |
|
83
|
+
| cloudfront_invalidate | ```false``` |
|
84
|
+
| cloudfront_invalidate_all | ```false``` |
|
85
|
+
| cloudfront_wait | ```false``` |
|
79
86
|
|
80
87
|
## Setting AWS Credentials
|
81
88
|
|
82
|
-
There are several ways to provide
|
83
|
-
S3 bucket. However, you can still use the following methods::We
|
89
|
+
There are several secure ways to provide AWS credentials for s3_sync. Using temporary, least-privilege credentials is strongly recommended.
|
84
90
|
|
85
|
-
####
|
91
|
+
#### Best Practices for AWS Credentials (Recommended)
|
86
92
|
|
87
|
-
|
88
|
-
that is passed to the activate method.
|
93
|
+
##### 1. AWS IAM Roles (Most Secure)
|
89
94
|
|
90
|
-
|
91
|
-
|
95
|
+
###### For CI/CD and Cloud Environments
|
96
|
+
- **EC2 Instance Profiles**: If running on EC2, use IAM roles attached to your instance. Credentials are automatically rotated and managed by AWS.
|
97
|
+
- **ECS Task Roles**: For container workloads, use task roles to provide permissions to specific containers.
|
98
|
+
- **CI/CD Service Roles**: Most CI/CD services (GitHub Actions, CircleCI, etc.) offer native AWS integrations that support assuming IAM roles.
|
92
99
|
|
93
|
-
|
100
|
+
###### For Local Development
|
101
|
+
- **AWS IAM Identity Center (SSO)** and configured profiles in your AWS config file
|
102
|
+
- **AWS CLI credential process** to integrate with external identity providers
|
103
|
+
- **Role assumption** with short-lived credentials through `aws sts assume-role`
|
94
104
|
|
95
|
-
|
96
|
-
The credentials are passed in the YAML format. The keys match the
|
97
|
-
options keys.
|
98
|
-
|
99
|
-
The .s3_sync file takes precedence to the configuration passed in the
|
100
|
-
activate method.
|
101
|
-
|
102
|
-
A sample `.s3_sync` file is included at the root of this repo.
|
103
|
-
|
104
|
-
> Make sure to add .s3_sync to your ignore list if you choose this approach. Not doing so may expose
|
105
|
-
> credentials to the world.
|
105
|
+
To use these methods, you don't need to specify credentials in your Middleman configuration. The AWS SDK will automatically detect and use them.
|
106
106
|
|
107
|
-
|
107
|
+
##### 2. Environment Variables with Temporary Credentials
|
108
108
|
|
109
|
-
|
110
|
-
`--aws_access_key_id` (`-k`) and `--aws_secret_access_key` (`-s`). They should override
|
111
|
-
any other settings if specified.
|
109
|
+
Using environment variables with short-lived credentials from role assumption:
|
112
110
|
|
113
|
-
|
111
|
+
```bash
|
112
|
+
# Obtain temporary credentials via assume-role or similar
|
113
|
+
# Then set these environment variables
|
114
|
+
export AWS_ACCESS_KEY_ID="temporary-access-key"
|
115
|
+
export AWS_SECRET_ACCESS_KEY="temporary-secret-key"
|
116
|
+
export AWS_SESSION_TOKEN="temporary-session-token"
|
117
|
+
export AWS_BUCKET="your-bucket-name"
|
118
|
+
```
|
114
119
|
|
115
|
-
|
116
|
-
map to the following values:
|
120
|
+
These environment variables are used when credentials are not otherwise specified:
|
117
121
|
|
118
122
|
| Setting | Environment Variable |
|
119
123
|
| --------------------- | ---------------------------------- |
|
@@ -122,37 +126,207 @@ map to the following values:
|
|
122
126
|
| aws_session_token | ```ENV['AWS_SESSION_TOKEN']``` |
|
123
127
|
| bucket | ```ENV['AWS_BUCKET']``` |
|
124
128
|
|
125
|
-
|
126
|
-
method or passed through the ```.s3_sync``` configuration file.
|
129
|
+
#### Alternative Methods (Not Recommended for Production)
|
127
130
|
|
128
|
-
|
131
|
+
The following methods are less secure and should be avoided in production environments:
|
129
132
|
|
130
|
-
|
133
|
+
##### Through `.s3_sync` File
|
131
134
|
|
132
|
-
|
135
|
+
You can create a `.s3_sync` at the root of your middleman project.
|
136
|
+
The credentials are passed in the YAML format. The keys match the options keys.
|
137
|
+
|
138
|
+
A sample `.s3_sync` file is included at the root of this repo.
|
139
|
+
|
140
|
+
> **SECURITY WARNING**: If using this approach, ensure you add `.s3_sync` to your `.gitignore` to prevent
|
141
|
+
> accidentally committing credentials to your repository. Consider using this only for local development
|
142
|
+
> and only with temporary credentials.
|
143
|
+
|
144
|
+
##### Through `config.rb`
|
145
|
+
|
146
|
+
You can set the AWS credentials in the activation block, but this is strongly discouraged:
|
147
|
+
|
148
|
+
> **SECURITY WARNING**: This method could lead to credentials being committed to version control,
|
149
|
+
> potentially exposing sensitive information. Never use long-lived credentials with this method.
|
150
|
+
|
151
|
+
##### Through Command Line
|
152
|
+
|
153
|
+
Credentials can be passed via command line options, but this may expose them in shell history:
|
154
|
+
|
155
|
+
> **SECURITY WARNING**: Command line parameters may be visible in process listings or shell history.
|
156
|
+
> Consider using environment variables or IAM roles instead.
|
157
|
+
|
158
|
+
## CloudFront Invalidation
|
133
159
|
|
134
|
-
|
135
|
-
contained in a bucket named "mysite.com":
|
160
|
+
The gem can automatically invalidate CloudFront cache after a successful sync. This ensures that your CloudFront distribution serves the latest content immediately after deployment.
|
136
161
|
|
162
|
+
### Configuration
|
163
|
+
|
164
|
+
```ruby
|
165
|
+
activate :s3_sync do |s3_sync|
|
166
|
+
# ... other configuration ...
|
167
|
+
s3_sync.cloudfront_distribution_id = 'E1234567890123' # Your CloudFront distribution ID
|
168
|
+
s3_sync.cloudfront_invalidate = true # Enable invalidation
|
169
|
+
s3_sync.cloudfront_invalidate_all = false # Invalidate only changed files
|
170
|
+
end
|
137
171
|
```
|
172
|
+
|
173
|
+
### Configuration Options
|
174
|
+
|
175
|
+
| Setting | Default | Description |
|
176
|
+
| --------------------------------- | ----------- | ----------- |
|
177
|
+
| cloudfront_distribution_id | - | CloudFront distribution ID to invalidate |
|
178
|
+
| cloudfront_invalidate | ```false``` | Enable CloudFront invalidation after sync |
|
179
|
+
| cloudfront_invalidate_all | ```false``` | Invalidate all paths (/*) instead of only changed files |
|
180
|
+
| cloudfront_invalidation_batch_size| ```1000``` | Maximum paths per invalidation request |
|
181
|
+
| cloudfront_wait | ```false``` | Wait for CloudFront invalidation to complete |
|
182
|
+
</edits>
|
183
|
+
|
184
|
+
### Command Line Options
|
185
|
+
|
186
|
+
You can also control CloudFront invalidation via command line:
|
187
|
+
|
188
|
+
```bash
|
189
|
+
# Enable CloudFront invalidation for this sync
|
190
|
+
middleman s3_sync --cloudfront-invalidate --cloudfront-distribution-id E1234567890123
|
191
|
+
|
192
|
+
# Invalidate all paths instead of just changed files
|
193
|
+
middleman s3_sync --cloudfront-invalidate-all --cloudfront-distribution-id E1234567890123
|
194
|
+
|
195
|
+
# Wait for invalidation to complete before exiting
|
196
|
+
middleman s3_sync --cloudfront-invalidate --cloudfront-wait --cloudfront-distribution-id E1234567890123
|
197
|
+
|
198
|
+
# Custom batch size for large numbers of files
|
199
|
+
middleman s3_sync --cloudfront-invalidate --cloudfront-invalidation-batch-size 500 --cloudfront-distribution-id E1234567890123
|
200
|
+
|
201
|
+
# Short aliases
|
202
|
+
middleman s3_sync -c -d E1234567890123 # Basic invalidation
|
203
|
+
middleman s3_sync -a -d E1234567890123 # Invalidate all paths
|
204
|
+
middleman s3_sync -c -w -d E1234567890123 # Invalidate and wait
|
205
|
+
middleman s3_sync -c -a -w -d E1234567890123 # Invalidate all and wait
|
206
|
+
```
|
207
|
+
|
208
|
+
#### Available CloudFront Command Line Options
|
209
|
+
|
210
|
+
| Option | Short | Description |
|
211
|
+
| ------ | ----- | ----------- |
|
212
|
+
| `--cloudfront-distribution-id` | `-d` | CloudFront distribution ID |
|
213
|
+
| `--cloudfront-invalidate` | `-c` | Enable CloudFront invalidation |
|
214
|
+
| `--cloudfront-invalidate-all` | `-a` | Invalidate all paths (/*) |
|
215
|
+
| `--cloudfront-invalidation-batch-size` | - | Max paths per request (default: 1000) |
|
216
|
+
| `--cloudfront-wait` | `-w` | Wait for invalidation to complete |
|
217
|
+
|
218
|
+
### How It Works
|
219
|
+
|
220
|
+
1. **Smart Invalidation**: By default, only files that were created, updated, or deleted during the sync are invalidated
|
221
|
+
2. **Path Optimization**: Duplicate paths are removed and redundant paths (covered by wildcards) are eliminated
|
222
|
+
3. **Batch Processing**: Large numbers of paths are split into multiple invalidation requests to respect CloudFront limits
|
223
|
+
4. **Error Handling**: Invalidation failures are logged but don't stop the sync process
|
224
|
+
5. **Dry Run Support**: Use `--dry-run` to see what would be invalidated without making actual API calls
|
225
|
+
|
226
|
+
### IAM Permissions
|
227
|
+
|
228
|
+
Your AWS credentials need CloudFront permissions in addition to S3:
|
229
|
+
|
230
|
+
```json
|
231
|
+
{
|
232
|
+
"Version": "2012-10-17",
|
233
|
+
"Statement": [
|
234
|
+
{
|
235
|
+
"Effect": "Allow",
|
236
|
+
"Action": [
|
237
|
+
"cloudfront:CreateInvalidation",
|
238
|
+
"cloudfront:GetInvalidation",
|
239
|
+
"cloudfront:ListInvalidations"
|
240
|
+
],
|
241
|
+
"Resource": "arn:aws:cloudfront::*:distribution/E1234567890123"
|
242
|
+
}
|
243
|
+
]
|
244
|
+
}
|
245
|
+
```
|
246
|
+
|
247
|
+
### Cost Considerations
|
248
|
+
|
249
|
+
- CloudFront allows 1,000 free invalidation paths per month
|
250
|
+
- Additional invalidations cost $0.005 per path
|
251
|
+
- Use `cloudfront_invalidate_all: true` for major updates to minimize costs (counts as 1 path)
|
252
|
+
- Consider the trade-off between immediate cache invalidation and cost
|
253
|
+
|
254
|
+
#### IAM Policy
|
255
|
+
|
256
|
+
Here's a sample IAM policy with least-privilege permissions that will allow syncing to a bucket named "mysite.com":
|
257
|
+
|
258
|
+
```json
|
138
259
|
{
|
139
260
|
"Version": "2012-10-17",
|
140
261
|
"Statement": [
|
141
262
|
{
|
142
263
|
"Effect": "Allow",
|
143
|
-
"Action":
|
264
|
+
"Action": [
|
265
|
+
"s3:ListBucket",
|
266
|
+
"s3:GetBucketLocation",
|
267
|
+
"s3:GetBucketVersioning"
|
268
|
+
],
|
144
269
|
"Resource": "arn:aws:s3:::mysite.com"
|
145
270
|
},
|
146
271
|
{
|
147
272
|
"Effect": "Allow",
|
148
|
-
"Action":
|
273
|
+
"Action": [
|
274
|
+
"s3:PutObject",
|
275
|
+
"s3:PutObjectAcl",
|
276
|
+
"s3:GetObject",
|
277
|
+
"s3:GetObjectAcl",
|
278
|
+
"s3:DeleteObject",
|
279
|
+
"s3:HeadObject"
|
280
|
+
],
|
149
281
|
"Resource": "arn:aws:s3:::mysite.com/*"
|
150
282
|
}
|
151
283
|
]
|
152
284
|
}
|
153
285
|
```
|
154
286
|
|
155
|
-
|
287
|
+
If you're using additional features, you may need these permissions as well:
|
288
|
+
|
289
|
+
```json
|
290
|
+
{
|
291
|
+
"Version": "2012-10-17",
|
292
|
+
"Statement": [
|
293
|
+
{
|
294
|
+
"Effect": "Allow",
|
295
|
+
"Action": [
|
296
|
+
"s3:PutBucketWebsite",
|
297
|
+
"s3:PutBucketVersioning"
|
298
|
+
],
|
299
|
+
"Resource": "arn:aws:s3:::mysite.com",
|
300
|
+
"Condition": {
|
301
|
+
"Bool": {
|
302
|
+
"aws:SecureTransport": "true"
|
303
|
+
}
|
304
|
+
}
|
305
|
+
}
|
306
|
+
]
|
307
|
+
}
|
308
|
+
```
|
309
|
+
|
310
|
+
This policy grants only the specific permissions needed:
|
311
|
+
|
312
|
+
- **For the bucket itself**:
|
313
|
+
- `s3:ListBucket`: To list objects in the bucket
|
314
|
+
- `s3:GetBucketLocation`: To determine the bucket's region
|
315
|
+
- `s3:GetBucketVersioning`: To check versioning status
|
316
|
+
- `s3:PutBucketVersioning`: If using the `version_bucket` option
|
317
|
+
- `s3:PutBucketWebsite`: If using website configuration (index/error documents)
|
318
|
+
|
319
|
+
- **For objects in the bucket**:
|
320
|
+
- `s3:PutObject`: To create/update objects
|
321
|
+
- `s3:PutObjectAcl`: To set ACLs on objects
|
322
|
+
- `s3:GetObject`: To retrieve objects for comparison
|
323
|
+
- `s3:GetObjectAcl`: To read existing ACLs
|
324
|
+
- `s3:DeleteObject`: To delete stray objects (when `delete: true`)
|
325
|
+
- `s3:HeadObject`: To retrieve object metadata via HEAD requests
|
326
|
+
|
327
|
+
The source code shows that middleman-s3_sync uses HEAD requests (`object.head`) to compare resources, checks and sets bucket versioning when configured, and can set website configuration for index and error documents.
|
328
|
+
|
329
|
+
Note: You can further restrict these permissions by adding conditions or limiting them to specific prefixes if you're only publishing to a subdirectory of the bucket.
|
156
330
|
|
157
331
|
## Command Line Usage
|
158
332
|
|
@@ -0,0 +1,192 @@
|
|
1
|
+
require 'aws-sdk-cloudfront'
|
2
|
+
require 'securerandom'
|
3
|
+
|
4
|
+
module Middleman
|
5
|
+
module S3Sync
|
6
|
+
module CloudFront
|
7
|
+
class << self
|
8
|
+
include Status
|
9
|
+
|
10
|
+
def invalidate(invalidation_paths, options)
|
11
|
+
return unless should_invalidate?(options)
|
12
|
+
return if invalidation_paths.empty? && !options.cloudfront_invalidate_all
|
13
|
+
|
14
|
+
paths = prepare_invalidation_paths(invalidation_paths, options)
|
15
|
+
return if paths.empty?
|
16
|
+
|
17
|
+
say_status "Invalidating CloudFront distribution #{options.cloudfront_distribution_id}"
|
18
|
+
|
19
|
+
if options.dry_run
|
20
|
+
say_status "#{ANSI.yellow{'DRY RUN:'}} Would invalidate #{paths.length} paths in CloudFront"
|
21
|
+
paths.each { |path| say_status " #{path}" } if options.verbose
|
22
|
+
return
|
23
|
+
end
|
24
|
+
|
25
|
+
# Split paths into batches to respect CloudFront limits
|
26
|
+
batch_size = [options.cloudfront_invalidation_batch_size, 3000].min
|
27
|
+
path_batches = paths.each_slice(batch_size).to_a
|
28
|
+
|
29
|
+
invalidation_ids = []
|
30
|
+
|
31
|
+
path_batches.each_with_index do |batch, index|
|
32
|
+
say_status "Creating invalidation batch #{index + 1}/#{path_batches.length} (#{batch.length} paths)"
|
33
|
+
|
34
|
+
invalidation_id = create_invalidation(batch, options)
|
35
|
+
invalidation_ids << invalidation_id if invalidation_id
|
36
|
+
|
37
|
+
# Add a small delay between batches to avoid rate limiting
|
38
|
+
sleep(1) if path_batches.length > 1 && index < path_batches.length - 1
|
39
|
+
end
|
40
|
+
|
41
|
+
if invalidation_ids.any?
|
42
|
+
say_status "CloudFront invalidation(s) created: #{invalidation_ids.join(', ')}"
|
43
|
+
|
44
|
+
if options.cloudfront_wait
|
45
|
+
say_status "Waiting for CloudFront invalidation(s) to complete..."
|
46
|
+
wait_for_invalidations(invalidation_ids, options)
|
47
|
+
say_status "CloudFront invalidation(s) completed successfully"
|
48
|
+
else
|
49
|
+
say_status "Invalidations may take 10-15 minutes to complete"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
invalidation_ids
|
54
|
+
rescue Aws::CloudFront::Errors::ServiceError => e
|
55
|
+
say_status "#{ANSI.red{'CloudFront invalidation failed:'}} #{e.message}"
|
56
|
+
raise e unless options.verbose # Re-raise unless we're being verbose
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def should_invalidate?(options)
|
62
|
+
return false unless options.cloudfront_invalidate
|
63
|
+
|
64
|
+
unless options.cloudfront_distribution_id
|
65
|
+
say_status "#{ANSI.yellow{'CloudFront invalidation skipped:'}} no distribution ID provided"
|
66
|
+
return false
|
67
|
+
end
|
68
|
+
|
69
|
+
true
|
70
|
+
end
|
71
|
+
|
72
|
+
def prepare_invalidation_paths(invalidation_paths, options)
|
73
|
+
if options.cloudfront_invalidate_all
|
74
|
+
return ['/*']
|
75
|
+
end
|
76
|
+
|
77
|
+
# Normalize paths for CloudFront
|
78
|
+
paths = invalidation_paths.map do |path|
|
79
|
+
# Ensure path starts with /
|
80
|
+
normalized_path = path.start_with?('/') ? path : "/#{path}"
|
81
|
+
|
82
|
+
# Remove any double slashes
|
83
|
+
normalized_path.gsub(/\/+/, '/')
|
84
|
+
end.uniq.sort
|
85
|
+
|
86
|
+
# Remove any paths that would be covered by a wildcard
|
87
|
+
if paths.include?('/*')
|
88
|
+
paths = ['/*']
|
89
|
+
else
|
90
|
+
# Remove redundant paths (e.g., if we have /path/* and /path/file.html)
|
91
|
+
paths = remove_redundant_paths(paths)
|
92
|
+
end
|
93
|
+
|
94
|
+
say_status "Prepared #{paths.length} paths for CloudFront invalidation" if options.verbose
|
95
|
+
|
96
|
+
paths
|
97
|
+
end
|
98
|
+
|
99
|
+
def remove_redundant_paths(paths)
|
100
|
+
# Sort paths to ensure wildcards come before specific files
|
101
|
+
sorted_paths = paths.sort
|
102
|
+
result = []
|
103
|
+
|
104
|
+
sorted_paths.each do |path|
|
105
|
+
# Check if this path is already covered by a wildcard we've added
|
106
|
+
is_redundant = result.any? do |existing_path|
|
107
|
+
if existing_path.end_with?('/*')
|
108
|
+
# Check if current path is under this wildcard
|
109
|
+
wildcard_prefix = existing_path[0..-3] # Remove /*
|
110
|
+
path.start_with?(wildcard_prefix + '/')
|
111
|
+
else
|
112
|
+
false
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
result << path unless is_redundant
|
117
|
+
end
|
118
|
+
|
119
|
+
result
|
120
|
+
end
|
121
|
+
|
122
|
+
def create_invalidation(paths, options)
|
123
|
+
caller_reference = "middleman-s3_sync-#{Time.now.to_i}-#{SecureRandom.hex(4)}"
|
124
|
+
|
125
|
+
response = cloudfront_client(options).create_invalidation({
|
126
|
+
distribution_id: options.cloudfront_distribution_id,
|
127
|
+
invalidation_batch: {
|
128
|
+
paths: {
|
129
|
+
quantity: paths.length,
|
130
|
+
items: paths
|
131
|
+
},
|
132
|
+
caller_reference: caller_reference
|
133
|
+
}
|
134
|
+
})
|
135
|
+
|
136
|
+
response.invalidation.id
|
137
|
+
rescue Aws::CloudFront::Errors::ServiceError => e
|
138
|
+
say_status "#{ANSI.red{'Failed to create CloudFront invalidation:'}} #{e.message}"
|
139
|
+
say_status "Paths: #{paths.join(', ')}" if options.verbose
|
140
|
+
raise e unless options.verbose
|
141
|
+
nil
|
142
|
+
end
|
143
|
+
|
144
|
+
def cloudfront_client(options)
|
145
|
+
client_options = {
|
146
|
+
region: 'us-east-1' # CloudFront is always in us-east-1
|
147
|
+
}
|
148
|
+
|
149
|
+
# Use the same credentials as S3 if available
|
150
|
+
if options.aws_access_key_id && options.aws_secret_access_key
|
151
|
+
client_options.merge!({
|
152
|
+
access_key_id: options.aws_access_key_id,
|
153
|
+
secret_access_key: options.aws_secret_access_key
|
154
|
+
})
|
155
|
+
|
156
|
+
# If using an assumed role
|
157
|
+
client_options.merge!({
|
158
|
+
session_token: options.aws_session_token
|
159
|
+
}) if options.aws_session_token
|
160
|
+
end
|
161
|
+
|
162
|
+
Aws::CloudFront::Client.new(client_options)
|
163
|
+
end
|
164
|
+
|
165
|
+
def wait_for_invalidations(invalidation_ids, options)
|
166
|
+
invalidation_ids.each do |invalidation_id|
|
167
|
+
say_status "Waiting for invalidation #{invalidation_id}..."
|
168
|
+
|
169
|
+
client = cloudfront_client(options)
|
170
|
+
client.wait_until(:invalidation_completed,
|
171
|
+
distribution_id: options.cloudfront_distribution_id,
|
172
|
+
id: invalidation_id
|
173
|
+
) do |waiter|
|
174
|
+
waiter.max_attempts = 30 # Wait up to 30 minutes (30 * 60s checks)
|
175
|
+
waiter.delay = 60 # Check every 60 seconds
|
176
|
+
|
177
|
+
waiter.before_attempt do |attempt|
|
178
|
+
say_status "Checking invalidation status (attempt #{attempt}/30)..." if options.verbose
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
rescue Aws::Waiters::Errors::WaiterFailed => e
|
183
|
+
say_status "#{ANSI.yellow{'Warning:'}} CloudFront invalidation wait timed out: #{e.message}"
|
184
|
+
say_status "Invalidation is still in progress but sync will continue"
|
185
|
+
rescue Aws::CloudFront::Errors::ServiceError => e
|
186
|
+
say_status "#{ANSI.yellow{'Warning:'}} CloudFront invalidation wait failed: #{e.message}"
|
187
|
+
say_status "Invalidation may still be in progress"
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
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,30 @@ 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_wait,
|
88
|
+
aliases: '-w',
|
89
|
+
type: :boolean,
|
90
|
+
desc: 'Wait for CloudFront invalidation to complete before exiting.'
|
91
|
+
|
68
92
|
def s3_sync
|
69
93
|
env = options[:environment].to_s.to_sym
|
70
94
|
verbose = options[:verbose] ? 0 : 1
|
@@ -99,6 +123,11 @@ module Middleman
|
|
99
123
|
s3_sync_options.prefix = s3_sync_options.prefix.end_with?('/') ? s3_sync_options.prefix : s3_sync_options.prefix + '/'
|
100
124
|
end
|
101
125
|
s3_sync_options.dry_run = options[:dry_run] if options[:dry_run]
|
126
|
+
s3_sync_options.cloudfront_distribution_id = options[:cloudfront_distribution_id] if options[:cloudfront_distribution_id]
|
127
|
+
s3_sync_options.cloudfront_invalidate = options[:cloudfront_invalidate] if options[:cloudfront_invalidate]
|
128
|
+
s3_sync_options.cloudfront_invalidate_all = options[:cloudfront_invalidate_all] if options[:cloudfront_invalidate_all]
|
129
|
+
s3_sync_options.cloudfront_invalidation_batch_size = options[:cloudfront_invalidation_batch_size] if options[:cloudfront_invalidation_batch_size]
|
130
|
+
s3_sync_options.cloudfront_wait = options[:cloudfront_wait] if options[:cloudfront_wait]
|
102
131
|
|
103
132
|
::Middleman::S3Sync.sync()
|
104
133
|
end
|
@@ -29,6 +29,11 @@ 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_wait, false, 'Whether to wait for CloudFront invalidation to complete'
|
32
37
|
|
33
38
|
expose_to_config :s3_sync_options, :default_caching_policy, :caching_policy
|
34
39
|
|
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,391 @@
|
|
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_wait: false,
|
12
|
+
aws_access_key_id: 'test_key',
|
13
|
+
aws_secret_access_key: 'test_secret',
|
14
|
+
aws_session_token: nil,
|
15
|
+
dry_run: false,
|
16
|
+
verbose: false
|
17
|
+
)
|
18
|
+
end
|
19
|
+
|
20
|
+
let(:invalidation_response) do
|
21
|
+
double(
|
22
|
+
invalidation: double(id: 'I1234567890123')
|
23
|
+
)
|
24
|
+
end
|
25
|
+
|
26
|
+
before do
|
27
|
+
allow(described_class).to receive(:say_status)
|
28
|
+
end
|
29
|
+
|
30
|
+
describe '.invalidate' do
|
31
|
+
context 'when CloudFront invalidation is disabled' do
|
32
|
+
let(:options) do
|
33
|
+
double(cloudfront_invalidate: false)
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'returns early without doing anything' do
|
37
|
+
expect(Aws::CloudFront::Client).not_to receive(:new)
|
38
|
+
result = described_class.invalidate(['/path1', '/path2'], options)
|
39
|
+
expect(result).to be_nil
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
context 'when no distribution ID is provided' do
|
44
|
+
let(:options) do
|
45
|
+
double(
|
46
|
+
cloudfront_invalidate: true,
|
47
|
+
cloudfront_distribution_id: nil
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'skips invalidation and shows warning' do
|
52
|
+
expect(described_class).to receive(:say_status).with(
|
53
|
+
match(/CloudFront invalidation skipped.*no distribution ID/)
|
54
|
+
)
|
55
|
+
expect(Aws::CloudFront::Client).not_to receive(:new)
|
56
|
+
result = described_class.invalidate(['/path1', '/path2'], options)
|
57
|
+
expect(result).to be_nil
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
context 'when dry run is enabled' do
|
62
|
+
let(:options) do
|
63
|
+
double(
|
64
|
+
cloudfront_invalidate: true,
|
65
|
+
cloudfront_distribution_id: 'E1234567890123',
|
66
|
+
cloudfront_invalidate_all: false,
|
67
|
+
dry_run: true,
|
68
|
+
verbose: true
|
69
|
+
)
|
70
|
+
end
|
71
|
+
|
72
|
+
it 'shows what would be invalidated without making API calls' do
|
73
|
+
expect(described_class).to receive(:say_status).with(
|
74
|
+
'Invalidating CloudFront distribution E1234567890123'
|
75
|
+
)
|
76
|
+
expect(described_class).to receive(:say_status).with(
|
77
|
+
match(/DRY RUN.*Would invalidate 2 paths/)
|
78
|
+
)
|
79
|
+
expect(described_class).to receive(:say_status).with(' /path1')
|
80
|
+
expect(described_class).to receive(:say_status).with(' /path2')
|
81
|
+
expect(Aws::CloudFront::Client).not_to receive(:new)
|
82
|
+
|
83
|
+
result = described_class.invalidate(['/path1', '/path2'], options)
|
84
|
+
expect(result).to be_nil
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
context 'when invalidating all paths' do
|
89
|
+
let(:options) do
|
90
|
+
double(
|
91
|
+
cloudfront_invalidate: true,
|
92
|
+
cloudfront_distribution_id: 'E1234567890123',
|
93
|
+
cloudfront_invalidate_all: true,
|
94
|
+
cloudfront_invalidation_batch_size: 1000,
|
95
|
+
cloudfront_wait: false,
|
96
|
+
aws_access_key_id: 'test_key',
|
97
|
+
aws_secret_access_key: 'test_secret',
|
98
|
+
aws_session_token: nil,
|
99
|
+
dry_run: false,
|
100
|
+
verbose: false
|
101
|
+
)
|
102
|
+
end
|
103
|
+
|
104
|
+
it 'invalidates all paths with /*' do
|
105
|
+
client = double('cloudfront_client')
|
106
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
107
|
+
expect(client).to receive(:create_invalidation).with({
|
108
|
+
distribution_id: 'E1234567890123',
|
109
|
+
invalidation_batch: {
|
110
|
+
paths: {
|
111
|
+
quantity: 1,
|
112
|
+
items: ['/*']
|
113
|
+
},
|
114
|
+
caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
|
115
|
+
}
|
116
|
+
}).and_return(invalidation_response)
|
117
|
+
|
118
|
+
result = described_class.invalidate(['/path1', '/path2'], options)
|
119
|
+
expect(result).to eq(['I1234567890123'])
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
context 'when invalidating specific paths' do
|
124
|
+
it 'creates invalidation for the provided paths' do
|
125
|
+
client = double('cloudfront_client')
|
126
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
127
|
+
expect(client).to receive(:create_invalidation).with({
|
128
|
+
distribution_id: 'E1234567890123',
|
129
|
+
invalidation_batch: {
|
130
|
+
paths: {
|
131
|
+
quantity: 2,
|
132
|
+
items: ['/path1', '/path2']
|
133
|
+
},
|
134
|
+
caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
|
135
|
+
}
|
136
|
+
}).and_return(invalidation_response)
|
137
|
+
|
138
|
+
result = described_class.invalidate(['/path1', '/path2'], options)
|
139
|
+
expect(result).to eq(['I1234567890123'])
|
140
|
+
end
|
141
|
+
|
142
|
+
it 'normalizes paths to start with /' do
|
143
|
+
client = double('cloudfront_client')
|
144
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
145
|
+
expect(client).to receive(:create_invalidation).with({
|
146
|
+
distribution_id: 'E1234567890123',
|
147
|
+
invalidation_batch: {
|
148
|
+
paths: {
|
149
|
+
quantity: 2,
|
150
|
+
items: ['/path1', '/path2']
|
151
|
+
},
|
152
|
+
caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
|
153
|
+
}
|
154
|
+
}).and_return(invalidation_response)
|
155
|
+
|
156
|
+
result = described_class.invalidate(['path1', '/path2'], options)
|
157
|
+
expect(result).to eq(['I1234567890123'])
|
158
|
+
end
|
159
|
+
|
160
|
+
it 'removes duplicate paths' do
|
161
|
+
client = double('cloudfront_client')
|
162
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
163
|
+
expect(client).to receive(:create_invalidation).with({
|
164
|
+
distribution_id: 'E1234567890123',
|
165
|
+
invalidation_batch: {
|
166
|
+
paths: {
|
167
|
+
quantity: 2,
|
168
|
+
items: ['/path1', '/path2']
|
169
|
+
},
|
170
|
+
caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
|
171
|
+
}
|
172
|
+
}).and_return(invalidation_response)
|
173
|
+
|
174
|
+
result = described_class.invalidate(['/path1', 'path1', '/path2'], options)
|
175
|
+
expect(result).to eq(['I1234567890123'])
|
176
|
+
end
|
177
|
+
|
178
|
+
it 'removes double slashes from paths' do
|
179
|
+
client = double('cloudfront_client')
|
180
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
181
|
+
expect(client).to receive(:create_invalidation).with({
|
182
|
+
distribution_id: 'E1234567890123',
|
183
|
+
invalidation_batch: {
|
184
|
+
paths: {
|
185
|
+
quantity: 1,
|
186
|
+
items: ['/path/to/file.html']
|
187
|
+
},
|
188
|
+
caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
|
189
|
+
}
|
190
|
+
}).and_return(invalidation_response)
|
191
|
+
|
192
|
+
result = described_class.invalidate(['//path//to//file.html'], options)
|
193
|
+
expect(result).to eq(['I1234567890123'])
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
context 'with large batch sizes' do
|
198
|
+
let(:options) do
|
199
|
+
double(
|
200
|
+
cloudfront_invalidate: true,
|
201
|
+
cloudfront_distribution_id: 'E1234567890123',
|
202
|
+
cloudfront_invalidate_all: false,
|
203
|
+
cloudfront_invalidation_batch_size: 2,
|
204
|
+
cloudfront_wait: false,
|
205
|
+
aws_access_key_id: 'test_key',
|
206
|
+
aws_secret_access_key: 'test_secret',
|
207
|
+
aws_session_token: nil,
|
208
|
+
dry_run: false,
|
209
|
+
verbose: false
|
210
|
+
)
|
211
|
+
end
|
212
|
+
|
213
|
+
it 'splits paths into multiple batches' do
|
214
|
+
client = double('cloudfront_client')
|
215
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
216
|
+
paths = ['/path1', '/path2', '/path3', '/path4']
|
217
|
+
|
218
|
+
expect(client).to receive(:create_invalidation).twice.and_return(invalidation_response)
|
219
|
+
expect(described_class).to receive(:sleep).with(1)
|
220
|
+
|
221
|
+
result = described_class.invalidate(paths, options)
|
222
|
+
expect(result).to eq(['I1234567890123', 'I1234567890123'])
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
context 'when CloudFront API returns an error' do
|
227
|
+
let(:error) { Aws::CloudFront::Errors::ServiceError.new(nil, 'Distribution not found') }
|
228
|
+
|
229
|
+
it 'handles API errors gracefully' do
|
230
|
+
client = double('cloudfront_client')
|
231
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
232
|
+
expect(client).to receive(:create_invalidation).and_raise(error)
|
233
|
+
expect(described_class).to receive(:say_status).with(
|
234
|
+
match(/Failed to create CloudFront invalidation.*Distribution not found/)
|
235
|
+
)
|
236
|
+
|
237
|
+
expect {
|
238
|
+
described_class.invalidate(['/path1'], options)
|
239
|
+
}.to raise_error(Aws::CloudFront::Errors::ServiceError)
|
240
|
+
end
|
241
|
+
|
242
|
+
context 'when verbose mode is enabled' do
|
243
|
+
let(:options) do
|
244
|
+
double(
|
245
|
+
cloudfront_invalidate: true,
|
246
|
+
cloudfront_distribution_id: 'E1234567890123',
|
247
|
+
cloudfront_invalidate_all: false,
|
248
|
+
cloudfront_invalidation_batch_size: 1000,
|
249
|
+
cloudfront_wait: false,
|
250
|
+
aws_access_key_id: 'test_key',
|
251
|
+
aws_secret_access_key: 'test_secret',
|
252
|
+
aws_session_token: nil,
|
253
|
+
dry_run: false,
|
254
|
+
verbose: true
|
255
|
+
)
|
256
|
+
end
|
257
|
+
|
258
|
+
it 'does not re-raise errors in verbose mode' do
|
259
|
+
client = double('cloudfront_client')
|
260
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
261
|
+
expect(client).to receive(:create_invalidation).and_raise(error)
|
262
|
+
expect(described_class).to receive(:say_status).with(
|
263
|
+
match(/Failed to create CloudFront invalidation/)
|
264
|
+
)
|
265
|
+
|
266
|
+
expect {
|
267
|
+
described_class.invalidate(['/path1'], options)
|
268
|
+
}.not_to raise_error
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
context 'with empty paths and invalidate_all false' do
|
274
|
+
it 'returns early without making API calls' do
|
275
|
+
expect(Aws::CloudFront::Client).not_to receive(:new)
|
276
|
+
result = described_class.invalidate([], options)
|
277
|
+
expect(result).to be_nil
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
context 'when cloudfront_wait is enabled' do
|
282
|
+
let(:options) do
|
283
|
+
double(
|
284
|
+
cloudfront_invalidate: true,
|
285
|
+
cloudfront_distribution_id: 'E1234567890123',
|
286
|
+
cloudfront_invalidate_all: false,
|
287
|
+
cloudfront_invalidation_batch_size: 1000,
|
288
|
+
cloudfront_wait: true,
|
289
|
+
aws_access_key_id: 'test_key',
|
290
|
+
aws_secret_access_key: 'test_secret',
|
291
|
+
aws_session_token: nil,
|
292
|
+
dry_run: false,
|
293
|
+
verbose: false
|
294
|
+
)
|
295
|
+
end
|
296
|
+
|
297
|
+
it 'waits for invalidation to complete' do
|
298
|
+
client = double('cloudfront_client')
|
299
|
+
allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
|
300
|
+
expect(client).to receive(:create_invalidation).and_return(invalidation_response)
|
301
|
+
expect(client).to receive(:wait_until).with(:invalidation_completed,
|
302
|
+
distribution_id: 'E1234567890123',
|
303
|
+
id: 'I1234567890123'
|
304
|
+
)
|
305
|
+
expect(described_class).to receive(:say_status).with(
|
306
|
+
'Waiting for CloudFront invalidation(s) to complete...'
|
307
|
+
)
|
308
|
+
expect(described_class).to receive(:say_status).with(
|
309
|
+
'CloudFront invalidation(s) completed successfully'
|
310
|
+
)
|
311
|
+
|
312
|
+
result = described_class.invalidate(['/path1'], options)
|
313
|
+
expect(result).to eq(['I1234567890123'])
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
describe 'CloudFront client configuration' do
|
319
|
+
it 'creates client with correct credentials' do
|
320
|
+
client = double('cloudfront_client')
|
321
|
+
expect(Aws::CloudFront::Client).to receive(:new).with({
|
322
|
+
region: 'us-east-1',
|
323
|
+
access_key_id: 'test_key',
|
324
|
+
secret_access_key: 'test_secret'
|
325
|
+
}).and_return(client)
|
326
|
+
|
327
|
+
expect(client).to receive(:create_invalidation).and_return(invalidation_response)
|
328
|
+
|
329
|
+
described_class.invalidate(['/test'], options)
|
330
|
+
end
|
331
|
+
|
332
|
+
context 'with session token' do
|
333
|
+
let(:options) do
|
334
|
+
double(
|
335
|
+
cloudfront_invalidate: true,
|
336
|
+
cloudfront_distribution_id: 'E1234567890123',
|
337
|
+
cloudfront_invalidate_all: false,
|
338
|
+
cloudfront_invalidation_batch_size: 1000,
|
339
|
+
cloudfront_wait: false,
|
340
|
+
aws_access_key_id: 'test_key',
|
341
|
+
aws_secret_access_key: 'test_secret',
|
342
|
+
aws_session_token: 'test_token',
|
343
|
+
dry_run: false,
|
344
|
+
verbose: false
|
345
|
+
)
|
346
|
+
end
|
347
|
+
|
348
|
+
it 'includes session token in client configuration' do
|
349
|
+
client = double('cloudfront_client')
|
350
|
+
expect(Aws::CloudFront::Client).to receive(:new).with({
|
351
|
+
region: 'us-east-1',
|
352
|
+
access_key_id: 'test_key',
|
353
|
+
secret_access_key: 'test_secret',
|
354
|
+
session_token: 'test_token'
|
355
|
+
}).and_return(client)
|
356
|
+
|
357
|
+
expect(client).to receive(:create_invalidation).and_return(invalidation_response)
|
358
|
+
|
359
|
+
described_class.invalidate(['/test'], options)
|
360
|
+
end
|
361
|
+
end
|
362
|
+
|
363
|
+
context 'without explicit credentials' do
|
364
|
+
let(:options) do
|
365
|
+
double(
|
366
|
+
cloudfront_invalidate: true,
|
367
|
+
cloudfront_distribution_id: 'E1234567890123',
|
368
|
+
cloudfront_invalidate_all: false,
|
369
|
+
cloudfront_invalidation_batch_size: 1000,
|
370
|
+
cloudfront_wait: false,
|
371
|
+
aws_access_key_id: nil,
|
372
|
+
aws_secret_access_key: nil,
|
373
|
+
aws_session_token: nil,
|
374
|
+
dry_run: false,
|
375
|
+
verbose: false
|
376
|
+
)
|
377
|
+
end
|
378
|
+
|
379
|
+
it 'creates client without explicit credentials (uses default chain)' do
|
380
|
+
client = double('cloudfront_client')
|
381
|
+
expect(Aws::CloudFront::Client).to receive(:new).with({
|
382
|
+
region: 'us-east-1'
|
383
|
+
}).and_return(client)
|
384
|
+
|
385
|
+
expect(client).to receive(:create_invalidation).and_return(invalidation_response)
|
386
|
+
|
387
|
+
described_class.invalidate(['/test'], options)
|
388
|
+
end
|
389
|
+
end
|
390
|
+
end
|
391
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'middleman/s3_sync'
|
3
|
+
|
4
|
+
describe 'S3Sync CloudFront Integration' do
|
5
|
+
let(:app) { double('middleman_app') }
|
6
|
+
let(:s3_sync_options) do
|
7
|
+
double(
|
8
|
+
cloudfront_invalidate: true,
|
9
|
+
cloudfront_distribution_id: 'E1234567890123',
|
10
|
+
cloudfront_invalidate_all: false,
|
11
|
+
cloudfront_invalidation_batch_size: 1000,
|
12
|
+
aws_access_key_id: 'test_key',
|
13
|
+
aws_secret_access_key: 'test_secret',
|
14
|
+
aws_session_token: nil,
|
15
|
+
dry_run: false,
|
16
|
+
verbose: false,
|
17
|
+
delete: true,
|
18
|
+
bucket: 'test-bucket'
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
before do
|
23
|
+
allow(::Middleman::Application).to receive(:new).and_return(app)
|
24
|
+
allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(s3_sync_options)
|
25
|
+
allow(Middleman::S3Sync).to receive(:say_status)
|
26
|
+
allow(Middleman::S3Sync).to receive(:work_to_be_done?).and_return(true)
|
27
|
+
allow(Middleman::S3Sync).to receive(:update_bucket_versioning)
|
28
|
+
allow(Middleman::S3Sync).to receive(:update_bucket_website)
|
29
|
+
allow(Middleman::S3Sync).to receive(:ignore_resources)
|
30
|
+
allow(Middleman::S3Sync).to receive(:create_resources)
|
31
|
+
allow(Middleman::S3Sync).to receive(:update_resources)
|
32
|
+
allow(Middleman::S3Sync).to receive(:delete_resources)
|
33
|
+
allow(Middleman::S3Sync::CloudFront).to receive(:invalidate)
|
34
|
+
end
|
35
|
+
|
36
|
+
describe 'CloudFront invalidation integration' do
|
37
|
+
it 'calls CloudFront invalidation after sync operations' do
|
38
|
+
# Reset invalidation paths for this test
|
39
|
+
Middleman::S3Sync.instance_variable_set(:@invalidation_paths, [])
|
40
|
+
|
41
|
+
expect(Middleman::S3Sync::CloudFront).to receive(:invalidate).with(
|
42
|
+
[], # Initially empty, gets populated during resource operations
|
43
|
+
s3_sync_options
|
44
|
+
)
|
45
|
+
|
46
|
+
Middleman::S3Sync.sync
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'calls CloudFront invalidation with collected paths' do
|
50
|
+
# Mock the methods to inject paths during sync
|
51
|
+
allow(Middleman::S3Sync).to receive(:create_resources) do
|
52
|
+
Middleman::S3Sync.add_invalidation_path('/updated/file.html')
|
53
|
+
end
|
54
|
+
allow(Middleman::S3Sync).to receive(:update_resources) do
|
55
|
+
Middleman::S3Sync.add_invalidation_path('/new/file.css')
|
56
|
+
end
|
57
|
+
|
58
|
+
expect(Middleman::S3Sync::CloudFront).to receive(:invalidate) do |paths, options|
|
59
|
+
expect(paths).to include('/updated/file.html')
|
60
|
+
expect(paths).to include('/new/file.css')
|
61
|
+
expect(options).to eq(s3_sync_options)
|
62
|
+
end
|
63
|
+
|
64
|
+
Middleman::S3Sync.sync
|
65
|
+
end
|
66
|
+
|
67
|
+
context 'when cloudfront_invalidate_all is true' do
|
68
|
+
let(:s3_sync_options) do
|
69
|
+
double(
|
70
|
+
cloudfront_invalidate: true,
|
71
|
+
cloudfront_distribution_id: 'E1234567890123',
|
72
|
+
cloudfront_invalidate_all: true,
|
73
|
+
bucket: 'test-bucket'
|
74
|
+
)
|
75
|
+
end
|
76
|
+
|
77
|
+
it 'still calls CloudFront invalidation even when no work is needed' do
|
78
|
+
allow(Middleman::S3Sync).to receive(:work_to_be_done?).and_return(false)
|
79
|
+
|
80
|
+
expect(Middleman::S3Sync::CloudFront).to receive(:invalidate).with(
|
81
|
+
[], s3_sync_options
|
82
|
+
)
|
83
|
+
|
84
|
+
Middleman::S3Sync.sync
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
context 'when CloudFront invalidation is disabled' do
|
89
|
+
let(:s3_sync_options) do
|
90
|
+
double(
|
91
|
+
cloudfront_invalidate: false,
|
92
|
+
bucket: 'test-bucket'
|
93
|
+
)
|
94
|
+
end
|
95
|
+
|
96
|
+
it 'does not call CloudFront invalidation' do
|
97
|
+
expect(Middleman::S3Sync::CloudFront).not_to receive(:invalidate)
|
98
|
+
|
99
|
+
Middleman::S3Sync.sync
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
describe 'path tracking during resource operations' do
|
105
|
+
before do
|
106
|
+
# Reset invalidation paths before each path tracking test
|
107
|
+
Middleman::S3Sync.instance_variable_set(:@invalidation_paths, [])
|
108
|
+
end
|
109
|
+
|
110
|
+
it 'adds paths to invalidation list when resources are processed' do
|
111
|
+
# Test the add_invalidation_path method directly
|
112
|
+
Middleman::S3Sync.add_invalidation_path('test/file.html')
|
113
|
+
Middleman::S3Sync.add_invalidation_path('images/photo.jpg')
|
114
|
+
|
115
|
+
expect(Middleman::S3Sync.invalidation_paths).to include('/test/file.html')
|
116
|
+
expect(Middleman::S3Sync.invalidation_paths).to include('/images/photo.jpg')
|
117
|
+
end
|
118
|
+
|
119
|
+
it 'normalizes paths when adding to invalidation list' do
|
120
|
+
Middleman::S3Sync.add_invalidation_path('no-leading-slash.html')
|
121
|
+
|
122
|
+
expect(Middleman::S3Sync.invalidation_paths).to include('/no-leading-slash.html')
|
123
|
+
end
|
124
|
+
|
125
|
+
it 'does not add duplicate paths' do
|
126
|
+
Middleman::S3Sync.add_invalidation_path('/same/path.html')
|
127
|
+
Middleman::S3Sync.add_invalidation_path('/same/path.html')
|
128
|
+
|
129
|
+
expect(Middleman::S3Sync.invalidation_paths.count('/same/path.html')).to eq(1)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: middleman-s3_sync
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 4.
|
4
|
+
version: 4.6.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Frederic Jean
|
8
8
|
- Will Koehler
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-05-26 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: middleman-core
|
@@ -66,6 +66,20 @@ dependencies:
|
|
66
66
|
- - ">="
|
67
67
|
- !ruby/object:Gem::Version
|
68
68
|
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: aws-sdk-cloudfront
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
69
83
|
- !ruby/object:Gem::Dependency
|
70
84
|
name: map
|
71
85
|
requirement: !ruby/object:Gem::Requirement
|
@@ -312,6 +326,7 @@ files:
|
|
312
326
|
- lib/middleman/redirect.rb
|
313
327
|
- lib/middleman/s3_sync.rb
|
314
328
|
- lib/middleman/s3_sync/caching_policy.rb
|
329
|
+
- lib/middleman/s3_sync/cloudfront.rb
|
315
330
|
- lib/middleman/s3_sync/options.rb
|
316
331
|
- lib/middleman/s3_sync/resource.rb
|
317
332
|
- lib/middleman/s3_sync/status.rb
|
@@ -319,7 +334,9 @@ files:
|
|
319
334
|
- lib/middleman_extension.rb
|
320
335
|
- middleman-s3_sync.gemspec
|
321
336
|
- spec/caching_policy_spec.rb
|
337
|
+
- spec/cloudfront_spec.rb
|
322
338
|
- spec/resource_spec.rb
|
339
|
+
- spec/s3_sync_integration_spec.rb
|
323
340
|
- spec/spec_helper.rb
|
324
341
|
homepage: http://github.com/fredjean/middleman-s3_sync
|
325
342
|
licenses:
|
@@ -344,5 +361,7 @@ specification_version: 4
|
|
344
361
|
summary: Tries really, really hard not to push files to S3.
|
345
362
|
test_files:
|
346
363
|
- spec/caching_policy_spec.rb
|
364
|
+
- spec/cloudfront_spec.rb
|
347
365
|
- spec/resource_spec.rb
|
366
|
+
- spec/s3_sync_integration_spec.rb
|
348
367
|
- spec/spec_helper.rb
|