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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e217a8648de15ba3454749ebf5373e9e2bf31591078be5ae0f5128aeac21d79
4
- data.tar.gz: 4f82b54882d96b1ece6585e2075a19d8ef54af78fb8aaec42c07ca14cf41fc7d
3
+ metadata.gz: 7d5baff2d2863949eb480b7ac94d105d3fdcc2c7df67fab66c649f575dd686a4
4
+ data.tar.gz: 70c12f42e60eabb0bf063d47a3e42f2766ea883bf47c17557cf323b15a9e546a
5
5
  SHA512:
6
- metadata.gz: 2640264fb1f0d4c2be50526d966842176ab9997537a4d2f271b0e1e0e29e4398da44f2c66969b08d9f9f4ddeaeef2014a2b2afe3eb68c95986725aa399524f3c
7
- data.tar.gz: b9fa2b48b0f63638627750a532e7c7f1fd94a15cf1af4bd9a9826a24021b9fce288d83c9e5d240b4483a0e0488f57b0a7726ede24af9a861d922095bd26eabda
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 the AWS credentials for s3_sync. I strongly recommend using some form of federation to assume a role with permissions to publish to your
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
- #### Through `config.rb`
91
+ #### Best Practices for AWS Credentials (Recommended)
86
92
 
87
- You can set the aws_access_key_id and aws_secret_access_key in the block
88
- that is passed to the activate method.
93
+ ##### 1. AWS IAM Roles (Most Secure)
89
94
 
90
- > I strongly discourage using this method. This will lead you to add and commit these changes
91
- > to your SCM and potentially expose sensitive information to the world.
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
- #### Through `.s3_sync` File
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
- You can create a `.s3_sync` at the root of your middleman project.
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
- #### Through the Command Line
107
+ ##### 2. Environment Variables with Temporary Credentials
108
108
 
109
- The aws credentials can also be passed via a command line options
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
- #### Through Environment
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
- You can also pass the credentials through environment variables. They
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
- The environment is used when the credentials are not set in the activate
126
- method or passed through the ```.s3_sync``` configuration file.
129
+ #### Alternative Methods (Not Recommended for Production)
127
130
 
128
- #### Through IAM role
131
+ The following methods are less secure and should be avoided in production environments:
129
132
 
130
- Alternatively, if you are running builds on EC2 instance which has approrpiate IAM role, then you don't need to think about specifying credentials at all – they will be pulled from AWS metadata service.
133
+ ##### Through `.s3_sync` File
131
134
 
132
- #### IAM Policy
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
- Here's a sample IAM policy that will allow a user to update the site
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": "s3:*",
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": "s3:*",
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
- This will give full access to both the bucket and it's contents.
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
@@ -1,5 +1,5 @@
1
1
  module Middleman
2
2
  module S3Sync
3
- VERSION = "4.5.0"
3
+ VERSION = "4.6.0"
4
4
  end
5
5
  end
@@ -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, &:create!)
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, &:update!)
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, &:destroy!)
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
 
@@ -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.5.0
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-04-28 00:00:00.000000000 Z
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