s3_website_revived 4.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +15 -0
  3. data/.travis.yml +5 -0
  4. data/Gemfile +3 -0
  5. data/LICENSE +42 -0
  6. data/README.md +591 -0
  7. data/Rakefile +2 -0
  8. data/additional-docs/debugging.md +21 -0
  9. data/additional-docs/development.md +29 -0
  10. data/additional-docs/example-configurations.md +113 -0
  11. data/additional-docs/running-from-ec2-with-dropbox.md +6 -0
  12. data/additional-docs/setting-up-aws-credentials.md +52 -0
  13. data/assembly.sbt +3 -0
  14. data/bin/s3_website +285 -0
  15. data/build.sbt +48 -0
  16. data/changelog.md +596 -0
  17. data/lib/s3_website/version.rb +3 -0
  18. data/lib/s3_website.rb +7 -0
  19. data/project/assembly.sbt +1 -0
  20. data/project/build.properties +1 -0
  21. data/project/plugins.sbt +1 -0
  22. data/release +41 -0
  23. data/resources/configuration_file_template.yml +67 -0
  24. data/resources/s3_website.jar.md5 +1 -0
  25. data/s3_website-4.0.0.jar +0 -0
  26. data/s3_website.gemspec +34 -0
  27. data/sbt +3 -0
  28. data/src/main/resources/log4j.properties +6 -0
  29. data/src/main/scala/s3/website/ByteHelper.scala +18 -0
  30. data/src/main/scala/s3/website/CloudFront.scala +144 -0
  31. data/src/main/scala/s3/website/Logger.scala +67 -0
  32. data/src/main/scala/s3/website/Push.scala +246 -0
  33. data/src/main/scala/s3/website/Ruby.scala +14 -0
  34. data/src/main/scala/s3/website/S3.scala +239 -0
  35. data/src/main/scala/s3/website/UploadHelper.scala +76 -0
  36. data/src/main/scala/s3/website/model/Config.scala +249 -0
  37. data/src/main/scala/s3/website/model/S3Endpoint.scala +35 -0
  38. data/src/main/scala/s3/website/model/Site.scala +159 -0
  39. data/src/main/scala/s3/website/model/push.scala +225 -0
  40. data/src/main/scala/s3/website/model/ssg.scala +30 -0
  41. data/src/main/scala/s3/website/package.scala +182 -0
  42. data/src/test/scala/s3/website/AwsSdkSpec.scala +15 -0
  43. data/src/test/scala/s3/website/ConfigSpec.scala +150 -0
  44. data/src/test/scala/s3/website/S3EndpointSpec.scala +15 -0
  45. data/src/test/scala/s3/website/S3WebsiteSpec.scala +1480 -0
  46. data/src/test/scala/s3/website/UnitTest.scala +11 -0
  47. data/vagrant/Vagrantfile +25 -0
  48. metadata +195 -0
@@ -0,0 +1,1480 @@
1
+ package s3.website
2
+
3
+ import java.io._
4
+ import java.nio.charset.StandardCharsets
5
+ import java.util.concurrent.atomic.AtomicInteger
6
+ import java.util.zip.{GZIPInputStream, GZIPOutputStream}
7
+
8
+ import com.amazonaws.AmazonServiceException
9
+ import com.amazonaws.services.cloudfront.AmazonCloudFront
10
+ import com.amazonaws.services.cloudfront.model.{CreateInvalidationRequest, CreateInvalidationResult, TooManyInvalidationsInProgressException}
11
+ import com.amazonaws.services.s3.AmazonS3
12
+ import com.amazonaws.services.s3.model._
13
+ import org.apache.commons.codec.digest.DigestUtils._
14
+ import org.apache.commons.io.FileUtils._
15
+ import org.apache.commons.io.IOUtils.{write => _}
16
+ import org.apache.commons.io.{FileUtils, IOUtils}
17
+ import org.mockito.Mockito._
18
+ import org.mockito.invocation.InvocationOnMock
19
+ import org.mockito.stubbing.Answer
20
+ import org.mockito.{ArgumentCaptor, Matchers, Mockito}
21
+ import org.specs2.mutable.{BeforeAfter, Specification}
22
+ import org.specs2.specification.Scope
23
+ import s3.website.CloudFront.CloudFrontSetting
24
+ import s3.website.Push.CliArgs
25
+ import s3.website.S3.S3Setting
26
+ import s3.website.UploadHelper.DELETE_NOTHING_MAGIC_WORD
27
+ import s3.website.model.Config.S3_website_yml
28
+ import s3.website.model.Ssg.automaticallySupportedSiteGenerators
29
+ import s3.website.model._
30
+
31
+ import scala.collection.JavaConversions._
32
+ import scala.collection.mutable
33
+ import scala.concurrent.duration._
34
+ import scala.util.Random
35
+
36
+ class S3WebsiteSpec extends Specification {
37
+
38
+ "gzip: true" should {
39
+ "update a gzipped S3 object if the contents has changed" in new BasicSetup {
40
+ config = "gzip: true"
41
+ setLocalFileWithContent(("styles.css", "<h1>hi again</h1>"))
42
+ setS3File("styles.css", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */)
43
+ push()
44
+ sentPutObjectRequest.getKey must equalTo("styles.css")
45
+ }
46
+
47
+ "gzip a file" in new BasicSetup {
48
+ val htmlString = "<h1>hi again</h1>"
49
+ config = "gzip: true"
50
+ setLocalFileWithContent(("index.html", htmlString))
51
+ setS3File("styles.css", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */)
52
+ val putObjectRequestCaptor = ArgumentCaptor.forClass(classOf[PutObjectRequest])
53
+ push()
54
+ sentPutObjectRequest.getKey must equalTo("index.html")
55
+ verify(amazonS3Client).putObject(putObjectRequestCaptor.capture())
56
+
57
+ val bytesToS3: InputStream = putObjectRequestCaptor.getValue.getInputStream
58
+ val unzippedBytesToS3 = new GZIPInputStream(bytesToS3)
59
+ val unzippedString = IOUtils.toString(unzippedBytesToS3, StandardCharsets.UTF_8)
60
+
61
+ unzippedString must equalTo(htmlString)
62
+ }
63
+
64
+ "apply the correct mime type on an already gzipped file" in new BasicSetup {
65
+ val htmlString = "<html><body><h1>Hello world</h1></body></html>"
66
+ val gzippedHtml = gzip(htmlString.getBytes(StandardCharsets.UTF_8))
67
+ config = "gzip: true"
68
+ setLocalFileWithContent("index.html", gzippedHtml)
69
+ push()
70
+ sentPutObjectRequest.getMetadata.getContentType must equalTo("text/html; charset=utf-8")
71
+ }
72
+
73
+ "apply the correct mime type on an already gzipped .json file" in new BasicSetup {
74
+ val jsonString = """" {"json": true} """
75
+ val gzippedJson = gzip(jsonString.getBytes(StandardCharsets.UTF_8))
76
+ config = """
77
+ |gzip:
78
+ | - .json
79
+ """
80
+ setLocalFileWithContent("test.json", gzippedJson)
81
+ push()
82
+ sentPutObjectRequest.getMetadata.getContentType must equalTo("application/json; charset=utf-8")
83
+ }
84
+
85
+ "not gzip the file if it's already gzipped" in new BasicSetup {
86
+ config = "gzip: true"
87
+
88
+ val cssString = "body { color: red }"
89
+ val gzippedCss = gzip(cssString.getBytes(StandardCharsets.UTF_8))
90
+ setLocalFileWithContent("styles.css", gzippedCss)
91
+ val putObjectRequestCaptor = ArgumentCaptor.forClass(classOf[PutObjectRequest])
92
+ push()
93
+ sentPutObjectRequest.getKey must equalTo("styles.css")
94
+ verify(amazonS3Client).putObject(putObjectRequestCaptor.capture())
95
+
96
+ val bytesToS3: InputStream = putObjectRequestCaptor.getValue.getInputStream
97
+ val unzippedBytesToS3 = new GZIPInputStream(bytesToS3)
98
+ val unzippedString = IOUtils.toString(unzippedBytesToS3, StandardCharsets.UTF_8)
99
+
100
+ unzippedString must equalTo(cssString)
101
+ }
102
+
103
+ def gzip(data: Array[Byte]): Array[Byte] = {
104
+ def using[T <: Closeable, R](cl: T)(f: (T) => R): R = try f(cl) finally cl.close()
105
+
106
+ val gzippedOutputStream: ByteArrayOutputStream = new ByteArrayOutputStream
107
+ using(new GZIPOutputStream(gzippedOutputStream)) { stream =>
108
+ IOUtils.copy(new ByteArrayInputStream(data), stream)
109
+ }
110
+ gzippedOutputStream.toByteArray
111
+ }
112
+
113
+ "not update a gzipped S3 object if the contents has not changed" in new BasicSetup {
114
+ config = "gzip: true"
115
+ setLocalFileWithContent(("styles.css", "<h1>hi</h1>"))
116
+ setS3File("styles.css", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */)
117
+ push()
118
+ noUploadsOccurred must beTrue
119
+ }
120
+
121
+ (".html" :: ".css" :: ".js" :: ".txt" :: ".ico" :: Nil).foreach((fileExtension) =>
122
+ s"gzip files that end with $fileExtension" in new BasicSetup {
123
+ config = "gzip: true"
124
+ setLocalFileWithContent((s"file.$fileExtension", "file contents"))
125
+ push()
126
+ sentPutObjectRequest.getMetadata.getContentEncoding must equalTo("gzip")
127
+ }
128
+ )
129
+ }
130
+
131
+ """
132
+ gzip:
133
+ - .xml
134
+ """ should {
135
+ "update a gzipped S3 object if the contents has changed" in new BasicSetup {
136
+ config = """
137
+ |gzip:
138
+ | - .xml
139
+ """.stripMargin
140
+ setLocalFileWithContent(("file.xml", "<h1>hi again</h1>"))
141
+ setS3File("file.xml", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */)
142
+ push()
143
+ sentPutObjectRequest.getKey must equalTo("file.xml")
144
+ }
145
+ }
146
+
147
+ "push" should {
148
+ "not upload a file if it has not changed" in new BasicSetup {
149
+ setLocalFileWithContent(("index.html", "<div>hello</div>"))
150
+ setS3File("index.html", md5Hex("<div>hello</div>"))
151
+ push()
152
+ noUploadsOccurred must beTrue
153
+ }
154
+
155
+ "not upload a file if it has not changed and s3_key_prefix is defined" in new BasicSetup {
156
+ config = "s3_key_prefix: test"
157
+ setLocalFileWithContent(("index.html", "<div>hello</div>"))
158
+ setS3File("test/index.html", md5Hex("<div>hello</div>"))
159
+ push()
160
+ noUploadsOccurred must beTrue
161
+ }
162
+
163
+ "detect a changed file even though another file has the same contents as the changed file" in new BasicSetup {
164
+ setLocalFilesWithContent(("1.txt", "foo"), ("2.txt", "foo"))
165
+ setS3File("1.txt", md5Hex("bar"))
166
+ setS3File("2.txt", md5Hex("foo"))
167
+ push()
168
+ sentPutObjectRequest.getKey must equalTo("1.txt")
169
+ }
170
+
171
+ "update a file if it has changed" in new BasicSetup {
172
+ setLocalFileWithContent(("index.html", "<h1>old text</h1>"))
173
+ setS3File("index.html", md5Hex("<h1>new text</h1>"))
174
+ push()
175
+ sentPutObjectRequest.getKey must equalTo("index.html")
176
+ }
177
+
178
+ "create a file if does not exist on S3" in new BasicSetup {
179
+ setLocalFile("index.html")
180
+ push()
181
+ sentPutObjectRequest.getKey must equalTo("index.html")
182
+ }
183
+
184
+ "delete files that are on S3 but not on local file system" in new BasicSetup {
185
+ setS3File("old.html", md5Hex("<h1>old text</h1>"))
186
+ push()
187
+ sentDelete must equalTo("old.html")
188
+ }
189
+
190
+ "delete files that match the s3_key_prefix" in new BasicSetup {
191
+ config = "s3_key_prefix: production"
192
+ setS3File("production/old.html", md5Hex("<h1>old text</h1>"))
193
+ push()
194
+ sentDelete must equalTo("production/old.html")
195
+ }
196
+
197
+ "retain files that do not match the s3_key_prefix" in new BasicSetup {
198
+ config = "s3_key_prefix: production"
199
+ setS3File("old.html", md5Hex("<h1>old text</h1>"))
200
+ push()
201
+ noDeletesOccurred
202
+ }
203
+
204
+ "retain files that do not match the s3_key_prefix" in new BasicSetup {
205
+ config = "s3_key_prefix: test"
206
+ setS3File("test1.html")
207
+ push()
208
+ noDeletesOccurred
209
+ }
210
+
211
+ "try again if the upload fails" in new BasicSetup {
212
+ setLocalFile("index.html")
213
+ uploadFailsAndThenSucceeds(howManyFailures = 5)
214
+ push()
215
+ verify(amazonS3Client, times(6)).putObject(Matchers.any(classOf[PutObjectRequest]))
216
+ }
217
+
218
+ "not try again if the upload fails on because of invalid credentials" in new BasicSetup {
219
+ setLocalFile("index.html")
220
+ when(amazonS3Client.putObject(Matchers.any(classOf[PutObjectRequest]))).thenThrow {
221
+ val e = new AmazonServiceException("your credentials are incorrect")
222
+ e.setStatusCode(403)
223
+ e
224
+ }
225
+ push()
226
+ verify(amazonS3Client, times(1)).putObject(Matchers.any(classOf[PutObjectRequest]))
227
+ }
228
+
229
+ "try again if the request times out" in new BasicSetup {
230
+ var attempt = 0
231
+ when(amazonS3Client putObject Matchers.any(classOf[PutObjectRequest])) thenAnswer new Answer[PutObjectResult] {
232
+ def answer(invocation: InvocationOnMock) = {
233
+ attempt += 1
234
+ if (attempt < 2) {
235
+ val e = new AmazonServiceException("Too long a request")
236
+ e.setStatusCode(400)
237
+ e.setErrorCode("RequestTimeout")
238
+ throw e
239
+ } else {
240
+ new PutObjectResult
241
+ }
242
+ }
243
+ }
244
+ setLocalFile("index.html")
245
+ val exitStatus = push()
246
+ verify(amazonS3Client, times(2)).putObject(Matchers.any(classOf[PutObjectRequest]))
247
+ }
248
+
249
+ "try again if the delete fails" in new BasicSetup {
250
+ setS3File("old.html", md5Hex("<h1>old text</h1>"))
251
+ deleteFailsAndThenSucceeds(howManyFailures = 5)
252
+ push()
253
+ verify(amazonS3Client, times(6)).deleteObject(Matchers.anyString(), Matchers.anyString())
254
+ }
255
+
256
+ "try again if the object listing fails" in new BasicSetup {
257
+ setS3File("old.html", md5Hex("<h1>old text</h1>"))
258
+ objectListingFailsAndThenSucceeds(howManyFailures = 5)
259
+ push()
260
+ verify(amazonS3Client, times(6)).listObjects(Matchers.any(classOf[ListObjectsRequest]))
261
+ }
262
+ }
263
+
264
+ "push with CloudFront" should {
265
+ "invalidate the updated CloudFront items" in new BasicSetup {
266
+ config = "cloudfront_distribution_id: EGM1J2JJX9Z"
267
+ setLocalFiles("css/test.css", "articles/index.html")
268
+ setOutdatedS3Keys("css/test.css", "articles/index.html")
269
+ push()
270
+ sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/css/test.css" :: "/articles/index.html" :: Nil).sorted)
271
+ }
272
+
273
+ "not send CloudFront invalidation requests on new objects" in new BasicSetup {
274
+ config = "cloudfront_distribution_id: EGM1J2JJX9Z"
275
+ setLocalFile("newfile.js")
276
+ push()
277
+ noInvalidationsOccurred must beTrue
278
+ }
279
+
280
+ "not send CloudFront invalidation requests on redirect objects" in new BasicSetup {
281
+ config = """
282
+ |cloudfront_distribution_id: EGM1J2JJX9Z
283
+ |redirects:
284
+ | /index.php: index.html
285
+ """.stripMargin
286
+ push()
287
+ noInvalidationsOccurred must beTrue
288
+ }
289
+
290
+ "retry CloudFront responds with TooManyInvalidationsInProgressException" in new BasicSetup {
291
+ setTooManyInvalidationsInProgress(4)
292
+ config = "cloudfront_distribution_id: EGM1J2JJX9Z"
293
+ setLocalFile("test.css")
294
+ setOutdatedS3Keys("test.css")
295
+ push() must equalTo(0) // The retries should finally result in a success
296
+ sentInvalidationRequests.length must equalTo(4)
297
+ }
298
+
299
+ "retry if CloudFront is temporarily unreachable" in new BasicSetup {
300
+ invalidationsFailAndThenSucceed(5)
301
+ config = "cloudfront_distribution_id: EGM1J2JJX9Z"
302
+ setLocalFile("test.css")
303
+ setOutdatedS3Keys("test.css")
304
+ push()
305
+ sentInvalidationRequests.length must equalTo(6)
306
+ }
307
+
308
+ "encode unsafe characters in the keys" in new BasicSetup {
309
+ config = "cloudfront_distribution_id: EGM1J2JJX9Z"
310
+ setLocalFile("articles/arnold's file.html")
311
+ setOutdatedS3Keys("articles/arnold's file.html")
312
+ push()
313
+ sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/articles/arnold%27s%20file.html" :: Nil).sorted)
314
+ }
315
+
316
+ "encode unsafe characters in the keys" in new BasicSetup {
317
+ config = "cloudfront_distribution_id: EGM1J2JJX9Z"
318
+ setLocalFile("articles/Äöüßñé.html")
319
+ setOutdatedS3Keys("articles/Äöüßñé.html")
320
+ push()
321
+ sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/articles/%C3%84%C3%B6%C3%BC%C3%9F%C3%B1%C3%A9.html" :: Nil).sorted)
322
+ }
323
+
324
+ "invalidate the root object '/' if a top-level object is updated or deleted" in new BasicSetup {
325
+ config = "cloudfront_distribution_id: EGM1J2JJX9Z"
326
+ setLocalFile("maybe-index.html")
327
+ setOutdatedS3Keys("maybe-index.html")
328
+ push()
329
+ sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/" :: "/maybe-index.html" :: Nil).sorted)
330
+ }
331
+
332
+ "work with s3_key_prefix" in new BasicSetup {
333
+ config = """
334
+ |cloudfront_distribution_id: EGM1J2JJX9Z
335
+ |s3_key_prefix: production
336
+ """.stripMargin
337
+ setLocalFile("index.html")
338
+ setOutdatedS3Keys("production/index.html")
339
+ push()
340
+ sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/production/index.html" :: Nil).sorted)
341
+ }
342
+ }
343
+
344
+ "cloudfront_invalidate_root: true" should {
345
+ "convert CloudFront invalidation paths with the '/index.html' suffix into '/'" in new BasicSetup {
346
+ config = """
347
+ |cloudfront_distribution_id: EGM1J2JJX9Z
348
+ |cloudfront_invalidate_root: true
349
+ """.stripMargin
350
+ setLocalFile("articles/index.html")
351
+ setOutdatedS3Keys("articles/index.html")
352
+ push()
353
+ sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq must contain("/articles/")
354
+ }
355
+
356
+ "always invalidate the root path" in new BasicSetup {
357
+ config = """
358
+ |cloudfront_distribution_id: EGM1J2JJX9Z
359
+ |cloudfront_invalidate_root: true
360
+ """.stripMargin
361
+ setLocalFile("articles/index.html")
362
+ setOutdatedS3Keys("articles/index.html")
363
+ push()
364
+ sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq must contain("/index.html")
365
+ }
366
+
367
+ "treat the s3_key_prefix as the root path" in new BasicSetup {
368
+ config = """
369
+ |cloudfront_distribution_id: EGM1J2JJX9Z
370
+ |cloudfront_invalidate_root: true
371
+ |s3_key_prefix: test
372
+ """.stripMargin
373
+ setLocalFile("articles/index.html")
374
+ setOutdatedS3Keys("test/articles/index.html")
375
+ push()
376
+ sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(
377
+ ("/test/index.html" :: "/test/articles/" :: Nil).sorted
378
+ )
379
+ }
380
+
381
+ "not invalidate anything if there was nothing to push" in new BasicSetup {
382
+ config = """
383
+ |cloudfront_distribution_id: EGM1J2JJX9Z
384
+ |cloudfront_invalidate_root: true
385
+ """.stripMargin
386
+ push()
387
+ noInvalidationsOccurred
388
+ }
389
+ }
390
+
391
+ "a site with over 1000 items" should {
392
+ "split the CloudFront invalidation requests into batches of 1000 items" in new BasicSetup {
393
+ val files = (1 to 1002).map { i => s"lots-of-files/file-$i"}
394
+ config = "cloudfront_distribution_id: EGM1J2JJX9Z"
395
+ setLocalFiles(files:_*)
396
+ setOutdatedS3Keys(files:_*)
397
+ push()
398
+ sentInvalidationRequests.length must equalTo(2)
399
+ sentInvalidationRequests(0).getInvalidationBatch.getPaths.getItems.length must equalTo(1000)
400
+ sentInvalidationRequests(1).getInvalidationBatch.getPaths.getItems.length must equalTo(2)
401
+ }
402
+ }
403
+
404
+ "push exit status" should {
405
+ "be 0 all uploads succeed" in new BasicSetup {
406
+ setLocalFiles("file.txt")
407
+ push() must equalTo(0)
408
+ }
409
+
410
+ "be 1 if any of the uploads fails" in new BasicSetup {
411
+ setLocalFiles("file.txt")
412
+ when(amazonS3Client.putObject(Matchers.any(classOf[PutObjectRequest]))).thenThrow(new AmazonServiceException("AWS failed"))
413
+ push() must equalTo(1)
414
+ }
415
+
416
+ "be 1 if any of the redirects fails" in new BasicSetup {
417
+ config = """
418
+ |redirects:
419
+ | index.php: /index.html
420
+ """.stripMargin
421
+ when(amazonS3Client.putObject(Matchers.any(classOf[PutObjectRequest]))).thenThrow(new AmazonServiceException("AWS failed"))
422
+ push() must equalTo(1)
423
+ }
424
+
425
+ "be 0 if CloudFront invalidations and uploads succeed"in new BasicSetup {
426
+ config = "cloudfront_distribution_id: EGM1J2JJX9Z"
427
+ setLocalFile("test.css")
428
+ setOutdatedS3Keys("test.css")
429
+ push() must equalTo(0)
430
+ }
431
+
432
+ "be 1 if CloudFront is unreachable or broken"in new BasicSetup {
433
+ setCloudFrontAsInternallyBroken()
434
+ config = "cloudfront_distribution_id: EGM1J2JJX9Z"
435
+ setLocalFile("test.css")
436
+ setOutdatedS3Keys("test.css")
437
+ push() must equalTo(1)
438
+ }
439
+
440
+ "be 0 if upload retry succeeds" in new BasicSetup {
441
+ setLocalFile("index.html")
442
+ uploadFailsAndThenSucceeds(howManyFailures = 1)
443
+ push() must equalTo(0)
444
+ }
445
+
446
+ "be 1 if delete retry fails" in new BasicSetup {
447
+ setLocalFile("index.html")
448
+ uploadFailsAndThenSucceeds(howManyFailures = 6)
449
+ push() must equalTo(1)
450
+ }
451
+
452
+ "be 1 if an object listing fails" in new BasicSetup {
453
+ setS3File("old.html", md5Hex("<h1>old text</h1>"))
454
+ objectListingFailsAndThenSucceeds(howManyFailures = 6)
455
+ push() must equalTo(1)
456
+ }
457
+ }
458
+
459
+ "s3_key_prefix in config" should {
460
+ "apply the prefix into all the S3 keys" in new BasicSetup {
461
+ config = "s3_key_prefix: production"
462
+ setLocalFile("index.html")
463
+ push()
464
+ sentPutObjectRequest.getKey must equalTo("production/index.html")
465
+ }
466
+
467
+ "work with slash" in new BasicSetup {
468
+ config = "s3_key_prefix: production/"
469
+ setLocalFile("index.html")
470
+ push()
471
+ sentPutObjectRequest.getKey must equalTo("production/index.html")
472
+ }
473
+ }
474
+
475
+ "s3_website.yml file" should {
476
+ "never be uploaded" in new BasicSetup {
477
+ setLocalFile("s3_website.yml")
478
+ push()
479
+ noUploadsOccurred must beTrue
480
+ }
481
+
482
+ "never be uploaded even when s3_key_prefix is defined" in new BasicSetup {
483
+ config = "s3_key_prefix: production"
484
+ setLocalFile("s3_website.yml")
485
+ push()
486
+ noUploadsOccurred must beTrue
487
+ }
488
+ }
489
+
490
+ ".env file" should { // The .env file is the https://github.com/bkeepers/dotenv file
491
+ "never be uploaded" in new BasicSetup {
492
+ setLocalFile(".env")
493
+ push()
494
+ noUploadsOccurred must beTrue
495
+ }
496
+
497
+ "never be uploaded even when s3_key_prefix is defined" in new BasicSetup {
498
+ config = "s3_key_prefix: production"
499
+ setLocalFile(".env")
500
+ push()
501
+ noUploadsOccurred must beTrue
502
+ }
503
+ }
504
+
505
+ "exclude_from_upload: string" should {
506
+ "result in matching files not being uploaded" in new BasicSetup {
507
+ config = "exclude_from_upload: .DS_.*?"
508
+ setLocalFile(".DS_Store")
509
+ push()
510
+ noUploadsOccurred must beTrue
511
+ }
512
+
513
+ "work with s3_key_prefix" in new BasicSetup {
514
+ config = """
515
+ |s3_key_prefix: production
516
+ |exclude_from_upload: hello.txt
517
+ """.stripMargin
518
+ setLocalFile("hello.txt")
519
+ push()
520
+ noUploadsOccurred must beTrue
521
+ }
522
+ }
523
+
524
+ """
525
+ exclude_from_upload:
526
+ - regex
527
+ - another_exclusion
528
+ """ should {
529
+ "result in matching files not being uploaded" in new BasicSetup {
530
+ config = """
531
+ |exclude_from_upload:
532
+ | - .DS_.*?
533
+ | - logs
534
+ """.stripMargin
535
+ setLocalFiles(".DS_Store", "logs/test.log")
536
+ push()
537
+ noUploadsOccurred must beTrue
538
+ }
539
+
540
+ "work with s3_key_prefix" in new BasicSetup {
541
+ config = """
542
+ |s3_key_prefix: production
543
+ |exclude_from_upload:
544
+ |- hello.txt
545
+ """.stripMargin
546
+ setLocalFile("hello.txt")
547
+ push()
548
+ noUploadsOccurred must beTrue
549
+ }
550
+ }
551
+
552
+ "ignore_on_server: value" should {
553
+ "not delete the S3 objects that match the ignore value" in new BasicSetup {
554
+ config = "ignore_on_server: logs"
555
+ setS3File("logs/log.txt")
556
+ push()
557
+ noDeletesOccurred must beTrue
558
+ }
559
+
560
+ "support non-US-ASCII files" in new BasicSetup {
561
+ setS3File("tags/笔记/test.html", "")
562
+ config = "ignore_on_server: tags/笔记/test.html"
563
+ push()
564
+ noDeletesOccurred must beTrue
565
+ }
566
+
567
+ "work with s3_key_prefix" in new BasicSetup {
568
+ config = """
569
+ |s3_key_prefix: production
570
+ |ignore_on_server: hello.txt
571
+ """.stripMargin
572
+ setS3File("hello.txt")
573
+ push()
574
+ noDeletesOccurred must beTrue
575
+ }
576
+ }
577
+
578
+ "ignore_on_server: _DELETE_NOTHING_ON_THE_S3_BUCKET_" should {
579
+ "result in no files being deleted on the S3 bucket" in new BasicSetup {
580
+ config = s"""
581
+ |ignore_on_server: $DELETE_NOTHING_MAGIC_WORD
582
+ """.stripMargin
583
+ setS3File("file.txt")
584
+ push()
585
+ noDeletesOccurred
586
+ }
587
+
588
+ "work with s3_key_prefix" in new BasicSetup {
589
+ config = s"""
590
+ |s3_key_prefix: production
591
+ |ignore_on_server: $DELETE_NOTHING_MAGIC_WORD
592
+ """.stripMargin
593
+ setS3File("file.txt")
594
+ push()
595
+ noDeletesOccurred
596
+ }
597
+ }
598
+
599
+ """
600
+ ignore_on_server:
601
+ - regex
602
+ - another_ignore
603
+ """ should {
604
+ "not delete the S3 objects that match the ignore value" in new BasicSetup {
605
+ config = """
606
+ |ignore_on_server:
607
+ | - .*txt
608
+ """.stripMargin
609
+ setS3File("logs/log.txt", "")
610
+ push()
611
+ noDeletesOccurred must beTrue
612
+ }
613
+
614
+ "support non-US-ASCII files" in new BasicSetup {
615
+ setS3File("tags/笔记/test.html", "")
616
+ config = """
617
+ |ignore_on_server:
618
+ | - tags/笔记/test.html
619
+ """.stripMargin
620
+ push()
621
+ noDeletesOccurred must beTrue
622
+ }
623
+
624
+ "work with s3_key_prefix" in new BasicSetup {
625
+ config = """
626
+ |s3_key_prefix: production
627
+ |ignore_on_server:
628
+ |- hello.*
629
+ """.stripMargin
630
+ setS3File("hello.txt")
631
+ push()
632
+ noDeletesOccurred must beTrue
633
+ }
634
+ }
635
+
636
+ "error message" should {
637
+ "be helpful when the site setting in s3_website.yml points to a non-existing file" in new BasicSetup {
638
+ config = "site: nonexisting_site_dir"
639
+ val logEntries = new mutable.MutableList[String]
640
+ push(logCapturer = Some((logEntry: String) =>
641
+ logEntries += logEntry
642
+ ))
643
+ logEntries must contain(
644
+ s"Could not find a website. (The site setting in s3_website.yml points to a non-existing file $siteDirectory/nonexisting_site_dir)"
645
+ )
646
+ }
647
+ }
648
+
649
+ "site in config" should {
650
+ "let the user deploy a site from a custom location" in new CustomSiteDirectory with EmptySite with MockAWS with DefaultRunMode {
651
+ config = s"site: $siteDirectory"
652
+ setLocalFile(".vimrc")
653
+
654
+ new File(siteDirectory, ".vimrc").exists() must beTrue // Sanity check
655
+ siteDirectory must not equalTo workingDirectory // Sanity check
656
+
657
+ push()
658
+ sentPutObjectRequest.getKey must equalTo(".vimrc")
659
+ }
660
+
661
+ "not override the --site command-line switch" in new BasicSetup {
662
+ config = s"site: ${System.getProperty("java.io.tmpdir")}"
663
+ setLocalFile(".vimrc") // This creates a file in the directory into which the --site CLI arg points
664
+ push()
665
+ sentPutObjectRequest.getKey must equalTo(".vimrc")
666
+ }
667
+
668
+ automaticallySupportedSiteGenerators foreach { siteGenerator =>
669
+ "override an automatically detected site" in new CustomSiteDirectory with EmptySite with MockAWS with DefaultRunMode {
670
+ addContentToAutomaticallyDetectedSite(workingDirectory)
671
+ config = s"site: $siteDirectory"
672
+ setLocalFile(".vimrc") // Add content to the custom site directory
673
+ push()
674
+ sentPutObjectRequest.getKey must equalTo(".vimrc")
675
+ }
676
+
677
+ def addContentToAutomaticallyDetectedSite(workingDirectory: File) {
678
+ val automaticallyDetectedSiteDir = new File(workingDirectory, siteGenerator.outputDirectory)
679
+ automaticallyDetectedSiteDir.mkdirs()
680
+ write(new File(automaticallyDetectedSiteDir, ".bashrc"), "echo hello")
681
+ }
682
+ }
683
+ }
684
+
685
+ "cache_control in config" should {
686
+ "be applied to all files" in new BasicSetup {
687
+ config = "cache_control: public, no-transform, max-age=1200, s-maxage=1200"
688
+ setLocalFile("index.html")
689
+ push()
690
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("public, no-transform, max-age=1200, s-maxage=1200")
691
+ }
692
+
693
+ "work with s3_key_prefix" in new BasicSetup {
694
+ config =
695
+ """
696
+ |cache_control: public, no-transform, max-age=1200, s-maxage=1200
697
+ |s3_key_prefix: foo
698
+ """.stripMargin
699
+ setLocalFile("index.html")
700
+ push()
701
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("public, no-transform, max-age=1200, s-maxage=1200")
702
+ }
703
+
704
+ "should take precedence over max_age" in new BasicSetup {
705
+ config = """
706
+ |max_age: 120
707
+ |cache_control: public, max-age=90
708
+ """.stripMargin
709
+ setLocalFile("index.html")
710
+ push()
711
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("public, max-age=90")
712
+ }
713
+
714
+ "log a warning if both cache_control and max_age are present" in new BasicSetup {
715
+ val logEntries = new mutable.MutableList[String]
716
+ config = """
717
+ |max_age: 120
718
+ |cache_control: public, max-age=90
719
+ """.stripMargin
720
+ setLocalFile("index.html")
721
+ push(logCapturer = Some((logEntry: String) =>
722
+ logEntries += logEntry
723
+ ))
724
+ logEntries must contain("[\u001B[33mwarn\u001B[0m] Overriding the max_age setting with the cache_control settin")
725
+ }
726
+
727
+ "supports all valid URI characters in the glob setting" in new BasicSetup {
728
+ config = """
729
+ |cache_control:
730
+ | "*.html": public, max-age=120
731
+ """.stripMargin
732
+ val allValidUrlCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=" // See http://stackoverflow.com/a/1547940/990356 for discussion
733
+ setLocalFile(s"$allValidUrlCharacters.html")
734
+ push()
735
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("public, max-age=120")
736
+ }
737
+
738
+ "be applied to files that match the glob" in new BasicSetup {
739
+ config = """
740
+ |cache_control:
741
+ | "*.html": no-store
742
+ """.stripMargin
743
+ setLocalFile("index.html")
744
+ push()
745
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("no-store")
746
+ }
747
+
748
+ "be applied to directories that match the glob" in new BasicSetup {
749
+ config = """
750
+ |cache_control:
751
+ | "assets/**/*.js": no-cache, no-store
752
+ """.stripMargin
753
+ setLocalFile("assets/lib/jquery.js")
754
+ push()
755
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("no-cache, no-store")
756
+ }
757
+
758
+ "not be applied if the glob doesn't match" in new BasicSetup {
759
+ config = """
760
+ |cache_control:
761
+ | "*.js": max-age=120
762
+ """.stripMargin
763
+ setLocalFile("index.html")
764
+ push()
765
+ sentPutObjectRequest.getMetadata.getCacheControl must beNull
766
+ }
767
+
768
+ "support non-US-ASCII directory names" in new BasicSetup {
769
+ config = """
770
+ |cache_control:
771
+ | "*": no-cache
772
+ """.stripMargin
773
+ setLocalFile("tags/笔记/index.html")
774
+ push() must equalTo(0)
775
+ }
776
+
777
+ "have overlapping definitions in the glob, and then the most specific glob will win" in new BasicSetup {
778
+ config = """
779
+ |cache_control:
780
+ | "*.js": no-cache, no-store
781
+ | "assets/**/*.js": public, must-revalidate, max-age=120
782
+ """.stripMargin
783
+ setLocalFile("assets/lib/jquery.js")
784
+ push()
785
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("public, must-revalidate, max-age=120")
786
+ }
787
+
788
+ "work with s3_key_prefix" in new BasicSetup {
789
+ config =
790
+ """
791
+ |cache_control:
792
+ | "*.html": public, no-transform, max-age=1200, s-maxage=1200
793
+ |s3_key_prefix: foo
794
+ """.stripMargin
795
+ setLocalFile("index.html")
796
+ push()
797
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("public, no-transform, max-age=1200, s-maxage=1200")
798
+ }
799
+ }
800
+
801
+ "cache control" can {
802
+ "be undefined" in new BasicSetup {
803
+ setLocalFile("index.html")
804
+ push()
805
+ sentPutObjectRequest.getMetadata.getCacheControl must beNull
806
+ }
807
+ }
808
+
809
+ "max_age in config" can {
810
+ "be applied to all files" in new BasicSetup {
811
+ config = "max_age: 60"
812
+ setLocalFile("index.html")
813
+ push()
814
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=60")
815
+ }
816
+
817
+ "work with s3_key_prefix" in new BasicSetup {
818
+ config =
819
+ """
820
+ |max_age: 60
821
+ |s3_key_prefix: test
822
+ """.stripMargin
823
+ setLocalFile("index.html")
824
+ push()
825
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=60")
826
+ }
827
+
828
+ "supports all valid URI characters in the glob setting" in new BasicSetup {
829
+ config = """
830
+ |max_age:
831
+ | "*.html": 90
832
+ """.stripMargin
833
+ val allValidUrlCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=" // See http://stackoverflow.com/a/1547940/990356 for discussion
834
+ setLocalFile(s"$allValidUrlCharacters.html")
835
+ push()
836
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=90")
837
+ }
838
+
839
+ "be applied to files that match the glob" in new BasicSetup {
840
+ config = """
841
+ |max_age:
842
+ | "*.html": 90
843
+ """.stripMargin
844
+ setLocalFile("index.html")
845
+ push()
846
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=90")
847
+ }
848
+
849
+ "be applied to directories that match the glob" in new BasicSetup {
850
+ config = """
851
+ |max_age:
852
+ | "assets/**/*.js": 90
853
+ """.stripMargin
854
+ setLocalFile("assets/lib/jquery.js")
855
+ push()
856
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=90")
857
+ }
858
+
859
+ "not be applied if the glob doesn't match" in new BasicSetup {
860
+ config = """
861
+ |max_age:
862
+ | "*.js": 90
863
+ """.stripMargin
864
+ setLocalFile("index.html")
865
+ push()
866
+ sentPutObjectRequest.getMetadata.getCacheControl must beNull
867
+ }
868
+
869
+ "be used to disable caching" in new BasicSetup {
870
+ config = "max_age: 0"
871
+ setLocalFile("index.html")
872
+ push()
873
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("no-cache, max-age=0")
874
+ }
875
+
876
+ "support non-US-ASCII directory names" in new BasicSetup {
877
+ config = """
878
+ |max_age:
879
+ | "*": 21600
880
+ """.stripMargin
881
+ setLocalFile("tags/笔记/index.html")
882
+ push() must equalTo(0)
883
+ }
884
+
885
+ "have overlapping definitions in the glob, and then the most specific glob will win" in new BasicSetup {
886
+ config = """
887
+ |max_age:
888
+ | "*.js": 33
889
+ | "assets/**/*.js": 90
890
+ """.stripMargin
891
+ setLocalFile("assets/lib/jquery.js")
892
+ push()
893
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=90")
894
+ }
895
+
896
+ "respect the more specific glob" in new BasicSetup {
897
+ config = """
898
+ |max_age:
899
+ | "assets/*": 150
900
+ | "assets/*.gif": 86400
901
+ """.stripMargin
902
+ setLocalFiles("assets/jquery.js", "assets/picture.gif")
903
+ push()
904
+ sentPutObjectRequests.find(_.getKey == "assets/jquery.js").get.getMetadata.getCacheControl must equalTo("max-age=150")
905
+ sentPutObjectRequests.find(_.getKey == "assets/picture.gif").get.getMetadata.getCacheControl must equalTo("max-age=86400")
906
+ }
907
+
908
+ "work with s3_key_prefix" in new BasicSetup {
909
+ config =
910
+ """
911
+ |max_age:
912
+ | "*.html": 60
913
+ |s3_key_prefix: test
914
+ """.stripMargin
915
+ setLocalFile("index.html")
916
+ push()
917
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=60")
918
+ }
919
+ }
920
+
921
+ "s3_reduced_redundancy: true in config" should {
922
+ "result in uploads being marked with reduced redundancy" in new BasicSetup {
923
+ config = "s3_reduced_redundancy: true"
924
+ setLocalFile("file.exe")
925
+ push()
926
+ sentPutObjectRequest.getStorageClass must equalTo("REDUCED_REDUNDANCY")
927
+ }
928
+ }
929
+
930
+ "s3_reduced_redundancy: false in config" should {
931
+ "result in uploads being marked with the default storage class" in new BasicSetup {
932
+ config = "s3_reduced_redundancy: false"
933
+ setLocalFile("file.exe")
934
+ push()
935
+ sentPutObjectRequest.getStorageClass must beNull
936
+ }
937
+ }
938
+
939
+ "redirect in config" should {
940
+ "result in a redirect instruction that is sent to AWS" in new BasicSetup {
941
+ config = """
942
+ |redirects:
943
+ | index.php: /index.html
944
+ """.stripMargin
945
+ push()
946
+ sentPutObjectRequest.getRedirectLocation must equalTo("/index.html")
947
+ }
948
+
949
+ "refer to site root when the s3_key_prefix is defined and the redirect target starts with a slash" in new BasicSetup {
950
+ config = """
951
+ |s3_key_prefix: production
952
+ |redirects:
953
+ | index.php: /index.html
954
+ """.stripMargin
955
+ push()
956
+ sentPutObjectRequest.getKey must equalTo("production/index.php")
957
+ sentPutObjectRequest.getRedirectLocation must equalTo("/index.html")
958
+ }
959
+
960
+ "use s3_key_prefix as the root when the redirect target does not start with a slash" in new BasicSetup {
961
+ config = """
962
+ |s3_key_prefix: production
963
+ |redirects:
964
+ | index.php: index.html
965
+ """.stripMargin
966
+ push()
967
+ sentPutObjectRequest.getKey must equalTo("production/index.php")
968
+ sentPutObjectRequest.getRedirectLocation must equalTo("/production/index.html")
969
+ }
970
+
971
+ "add slash to the redirect target" in new BasicSetup {
972
+ config = """
973
+ |redirects:
974
+ | index.php: index.html
975
+ """.stripMargin
976
+ push()
977
+ sentPutObjectRequest.getRedirectLocation must equalTo("/index.html")
978
+ }
979
+
980
+ "support external redirects" in new BasicSetup {
981
+ config = """
982
+ |redirects:
983
+ | index.php: http://www.youtube.com/watch?v=dQw4w9WgXcQ
984
+ """.stripMargin
985
+ push()
986
+ sentPutObjectRequest.getRedirectLocation must equalTo("http://www.youtube.com/watch?v=dQw4w9WgXcQ")
987
+ }
988
+
989
+ "support external redirects that point to an HTTPS target" in new BasicSetup {
990
+ config = """
991
+ |redirects:
992
+ | index.php: https://www.youtube.com/watch?v=dQw4w9WgXcQ
993
+ """.stripMargin
994
+ push()
995
+ sentPutObjectRequest.getRedirectLocation must equalTo("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
996
+ }
997
+
998
+ "result in max-age=0 Cache-Control header on the object" in new BasicSetup {
999
+ config = """
1000
+ |redirects:
1001
+ | index.php: /index.html
1002
+ """.stripMargin
1003
+ push()
1004
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=0, no-cache")
1005
+ }
1006
+ }
1007
+
1008
+ "redirect in config and an object on the S3 bucket" should {
1009
+ "not result in the S3 object being deleted" in new BasicSetup {
1010
+ config = """
1011
+ |redirects:
1012
+ | index.php: /index.html
1013
+ """.stripMargin
1014
+ setLocalFile("index.php")
1015
+ setS3File("index.php", "md5")
1016
+ push()
1017
+ noDeletesOccurred must beTrue
1018
+ }
1019
+ }
1020
+
1021
+ "dotfiles" should {
1022
+ "be included in the pushed files" in new BasicSetup {
1023
+ setLocalFile(".vimrc")
1024
+ push()
1025
+ sentPutObjectRequest.getKey must equalTo(".vimrc")
1026
+ }
1027
+ }
1028
+
1029
+ "content type inference" should {
1030
+ "add charset=utf-8 to all html documents" in new BasicSetup {
1031
+ setLocalFile("index.html")
1032
+ push()
1033
+ sentPutObjectRequest.getMetadata.getContentType must equalTo("text/html; charset=utf-8")
1034
+ }
1035
+
1036
+ "add charset=utf-8 to all text documents" in new BasicSetup {
1037
+ setLocalFile("index.txt")
1038
+ push()
1039
+ sentPutObjectRequest.getMetadata.getContentType must equalTo("text/plain; charset=utf-8")
1040
+ }
1041
+
1042
+ "add charset=utf-8 to all json documents" in new BasicSetup {
1043
+ setLocalFile("data.json")
1044
+ push()
1045
+ sentPutObjectRequest.getMetadata.getContentType must equalTo("application/json; charset=utf-8")
1046
+ }
1047
+
1048
+ "resolve the content type from file contents" in new BasicSetup {
1049
+ setLocalFileWithContent(("index", "<html><body><h1>hi</h1></body></html>"))
1050
+ push()
1051
+ sentPutObjectRequest.getMetadata.getContentType must equalTo("text/html; charset=utf-8")
1052
+ }
1053
+ }
1054
+
1055
+ "content_type in config file" should {
1056
+ "override tika's opinion" in new BasicSetup {
1057
+ config = """
1058
+ |content_type:
1059
+ | "*.html": text/foobar
1060
+ """.stripMargin
1061
+ setLocalFileWithContent(("index.html", "<html><body><h1>hi</h1></body></html>"))
1062
+ push()
1063
+ sentPutObjectRequest.getMetadata.getContentType must equalTo("text/foobar; charset=utf-8")
1064
+ }
1065
+ }
1066
+
1067
+ "ERB in config file" should {
1068
+ "be evaluated" in new BasicSetup {
1069
+ config = """
1070
+ |redirects:
1071
+ |<%= ('a'..'f').to_a.map do |t| ' '+t+ ': /'+t+'.html' end.join('\n')%>
1072
+ """.stripMargin
1073
+ push()
1074
+ sentPutObjectRequests.length must equalTo(6)
1075
+ sentPutObjectRequests.forall(_.getRedirectLocation != null) must beTrue
1076
+ }
1077
+ }
1078
+
1079
+ "push --force" should {
1080
+ "push all the files whether they have changed or not" in new ForcePush {
1081
+ setLocalFileWithContent(("index.html", "<h1>hi</h1>"))
1082
+ setS3File("index.html", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */)
1083
+ push()
1084
+ sentPutObjectRequest.getKey must equalTo("index.html")
1085
+ }
1086
+ }
1087
+
1088
+ "dry run" should {
1089
+ "not push updates" in new DryRun {
1090
+ setLocalFileWithContent(("index.html", "<div>new</div>"))
1091
+ setS3File("index.html", md5Hex("<div>old</div>"))
1092
+ push()
1093
+ noUploadsOccurred must beTrue
1094
+ }
1095
+
1096
+ "not push redirects" in new DryRun {
1097
+ config =
1098
+ """
1099
+ |redirects:
1100
+ | index.php: /index.html
1101
+ """.stripMargin
1102
+ push()
1103
+ noUploadsOccurred must beTrue
1104
+ }
1105
+
1106
+ "not push deletes" in new DryRun {
1107
+ setS3File("index.html", md5Hex("<div>old</div>"))
1108
+ push()
1109
+ noUploadsOccurred must beTrue
1110
+ }
1111
+
1112
+ "not push new files" in new DryRun {
1113
+ setLocalFile("index.html")
1114
+ push()
1115
+ noUploadsOccurred must beTrue
1116
+ }
1117
+
1118
+ "not invalidate files" in new DryRun {
1119
+ config = "cloudfront_invalidation_id: AABBCC"
1120
+ setS3File("index.html", md5Hex("<div>old</div>"))
1121
+ push()
1122
+ noInvalidationsOccurred must beTrue
1123
+ }
1124
+ }
1125
+
1126
+ "Jekyll site" should {
1127
+ "be detected automatically" in new JekyllSite with EmptySite with MockAWS with DefaultRunMode {
1128
+ setLocalFile("index.html")
1129
+ push()
1130
+ sentPutObjectRequests.length must equalTo(1)
1131
+ }
1132
+ }
1133
+
1134
+ "Nanoc site" should {
1135
+ "be detected automatically" in new NanocSite with EmptySite with MockAWS with DefaultRunMode {
1136
+ setLocalFile("index.html")
1137
+ push()
1138
+ sentPutObjectRequests.length must equalTo(1)
1139
+ }
1140
+ }
1141
+
1142
+ "Middleman site" should {
1143
+ "be detected automatically" in new MiddlemanSite with EmptySite with MockAWS with DefaultRunMode {
1144
+ setLocalFile("index.html")
1145
+ push()
1146
+ sentPutObjectRequests.length must equalTo(1)
1147
+ }
1148
+ }
1149
+
1150
+ "the setting treat_zero_length_objects_as_redirects" should {
1151
+ "skip application of redirect on a zero-length S3 object" in new BasicSetup {
1152
+ config =
1153
+ """
1154
+ |treat_zero_length_objects_as_redirects: true
1155
+ |redirects:
1156
+ | index.php: /index.html
1157
+ """.stripMargin
1158
+ setRedirectObject("index.php")
1159
+ push()
1160
+ noUploadsOccurred
1161
+ }
1162
+
1163
+ "not delete the redirect objects on the bucket" in new BasicSetup {
1164
+ config =
1165
+ """
1166
+ |treat_zero_length_objects_as_redirects: true
1167
+ |redirects:
1168
+ | index.php: /index.html
1169
+ """.stripMargin
1170
+ setRedirectObject("index.php")
1171
+ push()
1172
+ noDeletesOccurred
1173
+ }
1174
+
1175
+ "apply redirects that are missing from S3" in new BasicSetup {
1176
+ config =
1177
+ """
1178
+ |treat_zero_length_objects_as_redirects: true
1179
+ |redirects:
1180
+ | index.php: /index.html
1181
+ """.stripMargin
1182
+ push()
1183
+ sentPutObjectRequest.getRedirectLocation must equalTo("/index.html")
1184
+ }
1185
+
1186
+ "apply all redirects when the user invokes `push --force`" in new ForcePush {
1187
+ config =
1188
+ """
1189
+ |treat_zero_length_objects_as_redirects: true
1190
+ |redirects:
1191
+ | index.php: /index.html
1192
+ """.stripMargin
1193
+ setRedirectObject("index.php")
1194
+ push()
1195
+ sentPutObjectRequest.getRedirectLocation must equalTo("/index.html")
1196
+ }
1197
+ }
1198
+
1199
+ trait BasicSetup extends SiteLocationFromCliArg with EmptySite with MockAWS with DefaultRunMode
1200
+
1201
+ trait ForcePush extends SiteLocationFromCliArg with EmptySite with MockAWS with ForcePushMode
1202
+
1203
+ trait DryRun extends SiteLocationFromCliArg with EmptySite with MockAWS with DryRunMode
1204
+
1205
+ trait DefaultRunMode {
1206
+ implicit def pushOptions: PushOptions = new PushOptions {
1207
+ def dryRun = false
1208
+ def force = false
1209
+ }
1210
+ }
1211
+
1212
+ trait DryRunMode {
1213
+ implicit def pushOptions: PushOptions = new PushOptions {
1214
+ def dryRun = true
1215
+ def force = false
1216
+ }
1217
+ }
1218
+
1219
+ trait ForcePushMode {
1220
+ implicit def pushOptions: PushOptions = new PushOptions {
1221
+ def dryRun = false
1222
+ def force = true
1223
+ }
1224
+ }
1225
+
1226
+
1227
+ trait MockAWS extends MockS3 with MockCloudFront with Scope
1228
+
1229
+ trait MockCloudFront extends MockAWSHelper {
1230
+ val amazonCloudFrontClient = mock(classOf[AmazonCloudFront])
1231
+ implicit val cfSettings: CloudFrontSetting = CloudFrontSetting(
1232
+ cfClient = _ => amazonCloudFrontClient,
1233
+ retryTimeUnit = MICROSECONDS
1234
+ )
1235
+
1236
+ def sentInvalidationRequests: Seq[CreateInvalidationRequest] = {
1237
+ val createInvalidationReq = ArgumentCaptor.forClass(classOf[CreateInvalidationRequest])
1238
+ verify(amazonCloudFrontClient, Mockito.atLeastOnce()).createInvalidation(createInvalidationReq.capture())
1239
+ createInvalidationReq.getAllValues
1240
+ }
1241
+
1242
+ def sentInvalidationRequest = sentInvalidationRequests.ensuring(_.length == 1).head
1243
+
1244
+ def noInvalidationsOccurred = {
1245
+ verify(amazonCloudFrontClient, Mockito.never()).createInvalidation(Matchers.any(classOf[CreateInvalidationRequest]))
1246
+ true // Mockito is based on exceptions
1247
+ }
1248
+
1249
+ def invalidationsFailAndThenSucceed(implicit howManyFailures: Int, callCount: AtomicInteger = new AtomicInteger(0)) {
1250
+ doAnswer(temporaryFailure(classOf[CreateInvalidationResult]))
1251
+ .when(amazonCloudFrontClient)
1252
+ .createInvalidation(Matchers.anyObject())
1253
+ }
1254
+
1255
+ def setTooManyInvalidationsInProgress(attemptWhenInvalidationSucceeds: Int) {
1256
+ var callCount = 0
1257
+ doAnswer(new Answer[CreateInvalidationResult] {
1258
+ override def answer(invocation: InvocationOnMock): CreateInvalidationResult = {
1259
+ callCount += 1
1260
+ if (callCount < attemptWhenInvalidationSucceeds)
1261
+ throw new TooManyInvalidationsInProgressException("just too many, man")
1262
+ else
1263
+ mock(classOf[CreateInvalidationResult])
1264
+ }
1265
+ }).when(amazonCloudFrontClient).createInvalidation(Matchers.anyObject())
1266
+ }
1267
+
1268
+ def setCloudFrontAsInternallyBroken() {
1269
+ when(amazonCloudFrontClient.createInvalidation(Matchers.anyObject())).thenThrow(new AmazonServiceException("CloudFront is down"))
1270
+ }
1271
+ }
1272
+
1273
+ trait MockS3 extends MockAWSHelper {
1274
+ val amazonS3Client = mock(classOf[AmazonS3])
1275
+ implicit val s3Settings: S3Setting = S3Setting(
1276
+ s3Client = _ => amazonS3Client,
1277
+ retryTimeUnit = MICROSECONDS
1278
+ )
1279
+ val s3ObjectListing = new ObjectListing
1280
+ when(amazonS3Client.listObjects(Matchers.any(classOf[ListObjectsRequest]))).thenReturn(s3ObjectListing)
1281
+
1282
+ // Simulate the situation where the file on S3 is outdated (as compared to the local file)
1283
+ def setOutdatedS3Keys(s3Keys: String*) {
1284
+ s3Keys.map(setS3File(_))
1285
+ }
1286
+
1287
+ def setRedirectObject(s3Key: String) {
1288
+ setS3File(s3Key, size = 0)
1289
+ }
1290
+
1291
+ def setS3File(s3Key: String, md5: String = "", size: Int = 10) {
1292
+ s3ObjectListing.getObjectSummaries.add({
1293
+ val summary = new S3ObjectSummary
1294
+ summary.setETag(md5)
1295
+ summary.setKey(s3Key)
1296
+ summary.setSize(size)
1297
+ summary
1298
+ })
1299
+ }
1300
+
1301
+ def setS3Files(s3Files: S3File*) {
1302
+ s3Files.foreach { s3File =>
1303
+ setS3File(s3File.s3Key.key, s3File.md5)
1304
+ }
1305
+ }
1306
+
1307
+ def removeAllFilesFromS3() {
1308
+ setS3Files(Nil: _*) // This corresponds to the situation where the S3 bucket is empty
1309
+ }
1310
+
1311
+ def uploadFailsAndThenSucceeds(implicit howManyFailures: Int, callCount: AtomicInteger = new AtomicInteger(0)) {
1312
+ doAnswer(temporaryFailure(classOf[PutObjectResult]))
1313
+ .when(amazonS3Client)
1314
+ .putObject(Matchers.anyObject())
1315
+ }
1316
+
1317
+ def deleteFailsAndThenSucceeds(implicit howManyFailures: Int, callCount: AtomicInteger = new AtomicInteger(0)) {
1318
+ doAnswer(temporaryFailure(classOf[DeleteObjectRequest]))
1319
+ .when(amazonS3Client)
1320
+ .deleteObject(Matchers.anyString(), Matchers.anyString())
1321
+ }
1322
+
1323
+ def objectListingFailsAndThenSucceeds(implicit howManyFailures: Int, callCount: AtomicInteger = new AtomicInteger(0)) {
1324
+ doAnswer(temporaryFailure(classOf[ObjectListing]))
1325
+ .when(amazonS3Client)
1326
+ .listObjects(Matchers.any(classOf[ListObjectsRequest]))
1327
+ }
1328
+
1329
+ def sentPutObjectRequests: Seq[PutObjectRequest] = {
1330
+ val req = ArgumentCaptor.forClass(classOf[PutObjectRequest])
1331
+ verify(amazonS3Client, Mockito.atLeast(1)).putObject(req.capture())
1332
+ req.getAllValues
1333
+ }
1334
+
1335
+ def sentPutObjectRequest = sentPutObjectRequests.ensuring(_.length == 1).head
1336
+
1337
+ def sentDeletes: Seq[S3Key] = {
1338
+ val deleteKey = ArgumentCaptor.forClass(classOf[S3Key])
1339
+ verify(amazonS3Client).deleteObject(Matchers.anyString(), deleteKey.capture())
1340
+ deleteKey.getAllValues
1341
+ }
1342
+
1343
+ def sentDelete = sentDeletes.ensuring(_.length == 1).head
1344
+
1345
+ def noDeletesOccurred = {
1346
+ verify(amazonS3Client, never()).deleteObject(Matchers.anyString(), Matchers.anyString())
1347
+ true // Mockito is based on exceptions
1348
+ }
1349
+
1350
+ def noUploadsOccurred = {
1351
+ verify(amazonS3Client, never()).putObject(Matchers.any(classOf[PutObjectRequest]))
1352
+ true // Mockito is based on exceptions
1353
+ }
1354
+
1355
+ type S3Key = String
1356
+ }
1357
+
1358
+ trait MockAWSHelper {
1359
+ def temporaryFailure[T](clazz: Class[T])(implicit callCount: AtomicInteger, howManyFailures: Int) = new Answer[T] {
1360
+ def answer(invocation: InvocationOnMock) = {
1361
+ callCount.incrementAndGet()
1362
+ if (callCount.get() <= howManyFailures)
1363
+ throw new AmazonServiceException("AWS is temporarily down")
1364
+ else
1365
+ mock(clazz)
1366
+ }
1367
+ }
1368
+ }
1369
+
1370
+ trait Directories extends BeforeAfter {
1371
+ def randomDir() = new File(FileUtils.getTempDirectory, "s3_website_dir" + Random.nextLong())
1372
+ implicit final val workingDirectory: File = randomDir()
1373
+ implicit def yamlConfig: S3_website_yml = S3_website_yml(new File(workingDirectory, "s3_website.yml"))
1374
+ val siteDirectory: File
1375
+ val configDirectory: File = workingDirectory // Represents the --config-dir=X option
1376
+
1377
+ def before {
1378
+ workingDirectory :: siteDirectory :: configDirectory :: Nil foreach forceMkdir
1379
+ }
1380
+
1381
+ def after {
1382
+ (workingDirectory :: siteDirectory :: configDirectory :: Nil) foreach { dir =>
1383
+ if (dir.exists) forceDelete(dir)
1384
+ }
1385
+ }
1386
+ }
1387
+
1388
+ trait SiteLocationFromCliArg extends Directories {
1389
+ val siteDirectory = workingDirectory
1390
+ val siteDirFromCLIArg = true
1391
+ }
1392
+
1393
+ trait JekyllSite extends Directories {
1394
+ val siteDirectory = new File(workingDirectory, "_site")
1395
+ val siteDirFromCLIArg = false
1396
+ }
1397
+
1398
+ trait NanocSite extends Directories {
1399
+ val siteDirectory = new File(workingDirectory, "public/output")
1400
+ val siteDirFromCLIArg = false
1401
+ }
1402
+
1403
+ trait MiddlemanSite extends Directories {
1404
+ val siteDirectory = new File(workingDirectory, "build")
1405
+ val siteDirFromCLIArg = false
1406
+ }
1407
+
1408
+ trait CustomSiteDirectory extends Directories {
1409
+ val siteDirectory = randomDir()
1410
+ val siteDirFromCLIArg = false
1411
+ }
1412
+
1413
+ trait EmptySite extends Directories {
1414
+ val siteDirFromCLIArg: Boolean
1415
+ type LocalFileWithContent = (String, String)
1416
+
1417
+ def setLocalFile(fileName: String) = setLocalFileWithContent((fileName, ""))
1418
+ def setLocalFiles(fileNames: String*) = fileNames foreach setLocalFile
1419
+ def setLocalFilesWithContent(fileNamesAndContent: LocalFileWithContent*) = fileNamesAndContent foreach setLocalFileWithContent
1420
+ def setLocalFileWithContent(fileNameAndContent: LocalFileWithContent) = {
1421
+ val file = new File(siteDirectory, fileNameAndContent._1)
1422
+ forceMkdir(file.getParentFile)
1423
+ file.createNewFile()
1424
+ write(file, fileNameAndContent._2)
1425
+ }
1426
+
1427
+ def setLocalFileWithContent(fileName: String, contents: Array[Byte]) = {
1428
+ val file = new File(siteDirectory, fileName)
1429
+ forceMkdir(file.getParentFile)
1430
+ file.createNewFile()
1431
+ FileUtils.writeByteArrayToFile(file, contents)
1432
+ }
1433
+
1434
+ var config = ""
1435
+ val baseConfig =
1436
+ """
1437
+ |s3_id: foo
1438
+ |s3_secret: bar
1439
+ |s3_bucket: bucket
1440
+ """.stripMargin
1441
+
1442
+ implicit def configString: ConfigString =
1443
+ ConfigString(
1444
+ s"""
1445
+ |$baseConfig
1446
+ |$config
1447
+ """.stripMargin
1448
+ )
1449
+
1450
+ def pushOptions: PushOptions
1451
+
1452
+ implicit def cliArgs: CliArgs =
1453
+ new CliArgs {
1454
+ def verbose = true
1455
+
1456
+ def dryRun = pushOptions.dryRun
1457
+
1458
+ def site = if (siteDirFromCLIArg) siteDirectory.getAbsolutePath else null
1459
+
1460
+ def configDir = configDirectory.getAbsolutePath
1461
+
1462
+ def force = pushOptions.force
1463
+ }
1464
+ }
1465
+
1466
+ def push(logCapturer: Option[(String) => _] = None)
1467
+ (implicit
1468
+ emptyYamlConfig: S3_website_yml,
1469
+ configString: ConfigString,
1470
+ cliArgs: CliArgs,
1471
+ s3Settings: S3Setting,
1472
+ cloudFrontSettings: CloudFrontSetting,
1473
+ workingDirectory: File) = {
1474
+ write(emptyYamlConfig.file, configString.yaml) // Write the yaml config lazily, so that the tests can override the default yaml config
1475
+ implicit val logger = new Logger(verboseOutput = cliArgs.verbose, logCapturer)
1476
+ Push.push
1477
+ }
1478
+
1479
+ case class ConfigString(yaml: String)
1480
+ }