s3_website_monadic 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.travis.yml +3 -0
  4. data/Gemfile +3 -0
  5. data/LICENSE +42 -0
  6. data/README.md +451 -0
  7. data/Rakefile +24 -0
  8. data/additional-docs/example-configurations.md +62 -0
  9. data/additional-docs/setting-up-aws-credentials.md +51 -0
  10. data/assembly.sbt +3 -0
  11. data/bin/s3_website +80 -0
  12. data/build.sbt +33 -0
  13. data/changelog.md +215 -0
  14. data/features/as-library.feature +29 -0
  15. data/features/cassettes/cucumber_tags/create-redirect.yml +384 -0
  16. data/features/cassettes/cucumber_tags/empty-bucket.yml +89 -0
  17. data/features/cassettes/cucumber_tags/new-and-changed-files.yml +303 -0
  18. data/features/cassettes/cucumber_tags/new-files-for-sydney.yml +211 -0
  19. data/features/cassettes/cucumber_tags/new-files.yml +355 -0
  20. data/features/cassettes/cucumber_tags/no-new-or-changed-files.yml +359 -0
  21. data/features/cassettes/cucumber_tags/one-file-to-delete.yml +390 -0
  22. data/features/cassettes/cucumber_tags/only-changed-files.yml +411 -0
  23. data/features/cassettes/cucumber_tags/s3-and-cloudfront-after-deleting-a-file.yml +434 -0
  24. data/features/cassettes/cucumber_tags/s3-and-cloudfront-when-updating-a-file.yml +435 -0
  25. data/features/cassettes/cucumber_tags/s3-and-cloudfront.yml +290 -0
  26. data/features/cloudfront.feature +54 -0
  27. data/features/command-line-help.feature +54 -0
  28. data/features/delete.feature +19 -0
  29. data/features/error_reporting.feature +24 -0
  30. data/features/instructions-for-new-user.feature +154 -0
  31. data/features/jekyll-support.feature +20 -0
  32. data/features/nanoc-support.feature +20 -0
  33. data/features/push.feature +115 -0
  34. data/features/redirects.feature +14 -0
  35. data/features/security.feature +15 -0
  36. data/features/step_definitions/steps.rb +86 -0
  37. data/features/support/env.rb +26 -0
  38. data/features/support/test_site_dirs/cdn-powered.blog.fi/_site/css/styles.css +3 -0
  39. data/features/support/test_site_dirs/cdn-powered.blog.fi/_site/index.html +5 -0
  40. data/features/support/test_site_dirs/cdn-powered.blog.fi/s3_website.yml +4 -0
  41. data/features/support/test_site_dirs/cdn-powered.when-deleted-a-file.blog.fi/_site/index.html +10 -0
  42. data/features/support/test_site_dirs/cdn-powered.when-deleted-a-file.blog.fi/s3_website.yml +5 -0
  43. data/features/support/test_site_dirs/cdn-powered.with-one-change.blog.fi/_site/css/styles.css +3 -0
  44. data/features/support/test_site_dirs/cdn-powered.with-one-change.blog.fi/_site/index.html +10 -0
  45. data/features/support/test_site_dirs/cdn-powered.with-one-change.blog.fi/s3_website.yml +4 -0
  46. data/features/support/test_site_dirs/create-redirects/_site/.gitkeep +0 -0
  47. data/features/support/test_site_dirs/create-redirects/s3_website.yml +6 -0
  48. data/features/support/test_site_dirs/ignored-files.com/_site/css/styles.css +4 -0
  49. data/features/support/test_site_dirs/ignored-files.com/_site/index.html +8 -0
  50. data/features/support/test_site_dirs/ignored-files.com/s3_website.yml +5 -0
  51. data/features/support/test_site_dirs/index-and-assets.blog.fi/_site/assets/picture.gif +0 -0
  52. data/features/support/test_site_dirs/index-and-assets.blog.fi/_site/css/styles.css +3 -0
  53. data/features/support/test_site_dirs/index-and-assets.blog.fi/_site/index.html +5 -0
  54. data/features/support/test_site_dirs/index-and-assets.blog.fi/s3_website.yml +3 -0
  55. data/features/support/test_site_dirs/jekyllrb.com/_site/css/styles.css +3 -0
  56. data/features/support/test_site_dirs/jekyllrb.com/_site/index.html +5 -0
  57. data/features/support/test_site_dirs/jekyllrb.com/s3_website.yml +3 -0
  58. data/features/support/test_site_dirs/my.blog-with-clean-urls.com/_site/css/styles.css +3 -0
  59. data/features/support/test_site_dirs/my.blog-with-clean-urls.com/_site/index +5 -0
  60. data/features/support/test_site_dirs/my.blog-with-clean-urls.com/s3_website.yml +3 -0
  61. data/features/support/test_site_dirs/my.blog.com/_site/css/styles.css +3 -0
  62. data/features/support/test_site_dirs/my.blog.com/_site/index.html +5 -0
  63. data/features/support/test_site_dirs/my.blog.com/s3_website.yml +3 -0
  64. data/features/support/test_site_dirs/my.sydney.blog.au/_site/css/styles.css +3 -0
  65. data/features/support/test_site_dirs/my.sydney.blog.au/_site/index.html +5 -0
  66. data/features/support/test_site_dirs/my.sydney.blog.au/s3_website.yml +4 -0
  67. data/features/support/test_site_dirs/nanoc.ws/public/output/css/styles.css +3 -0
  68. data/features/support/test_site_dirs/nanoc.ws/public/output/index.html +5 -0
  69. data/features/support/test_site_dirs/nanoc.ws/s3_website.yml +3 -0
  70. data/features/support/test_site_dirs/new-and-changed-files.com/_site/css/styles.css +4 -0
  71. data/features/support/test_site_dirs/new-and-changed-files.com/_site/index.html +8 -0
  72. data/features/support/test_site_dirs/new-and-changed-files.com/s3_website.yml +3 -0
  73. data/features/support/test_site_dirs/no-new-or-changed-files.com/_site/css/styles.css +3 -0
  74. data/features/support/test_site_dirs/no-new-or-changed-files.com/_site/index.html +5 -0
  75. data/features/support/test_site_dirs/no-new-or-changed-files.com/s3_website.yml +3 -0
  76. data/features/support/test_site_dirs/only-changed-files.com/_site/css/styles.css +3 -0
  77. data/features/support/test_site_dirs/only-changed-files.com/_site/index.html +9 -0
  78. data/features/support/test_site_dirs/only-changed-files.com/s3_website.yml +3 -0
  79. data/features/support/test_site_dirs/site-that-contains-s3-website-file.com/_site/s3_website.yml +3 -0
  80. data/features/support/test_site_dirs/site-that-contains-s3-website-file.com/s3_website.yml +3 -0
  81. data/features/support/test_site_dirs/site-with-text-doc.com/_site/file.txt +1 -0
  82. data/features/support/test_site_dirs/site.with.css-maxage.com/_site/css/styles.css +3 -0
  83. data/features/support/test_site_dirs/site.with.css-maxage.com/_site/index.html +5 -0
  84. data/features/support/test_site_dirs/site.with.css-maxage.com/s3_website.yml +5 -0
  85. data/features/support/test_site_dirs/site.with.gzipped-and-max-aged-content.com/_site/css/styles.css +3 -0
  86. data/features/support/test_site_dirs/site.with.gzipped-and-max-aged-content.com/_site/index.html +5 -0
  87. data/features/support/test_site_dirs/site.with.gzipped-and-max-aged-content.com/s3_website.yml +5 -0
  88. data/features/support/test_site_dirs/site.with.gzipped-html.com/_site/css/styles.css +3 -0
  89. data/features/support/test_site_dirs/site.with.gzipped-html.com/_site/index.html +5 -0
  90. data/features/support/test_site_dirs/site.with.gzipped-html.com/s3_website.yml +5 -0
  91. data/features/support/test_site_dirs/site.with.maxage.com/_site/css/styles.css +3 -0
  92. data/features/support/test_site_dirs/site.with.maxage.com/_site/index.html +5 -0
  93. data/features/support/test_site_dirs/site.with.maxage.com/s3_website.yml +4 -0
  94. data/features/support/test_site_dirs/unpublish-a-post.com/_site/css/styles.css +3 -0
  95. data/features/support/test_site_dirs/unpublish-a-post.com/s3_website.yml +3 -0
  96. data/features/support/vcr.rb +20 -0
  97. data/features/website-performance.feature +57 -0
  98. data/lib/cloudfront/invalidator.rb +37 -0
  99. data/lib/s3_website/config_loader.rb +55 -0
  100. data/lib/s3_website/diff_helper.rb +113 -0
  101. data/lib/s3_website/endpoint.rb +37 -0
  102. data/lib/s3_website/errors.rb +42 -0
  103. data/lib/s3_website/jekyll.rb +5 -0
  104. data/lib/s3_website/keyboard.rb +27 -0
  105. data/lib/s3_website/nanoc.rb +5 -0
  106. data/lib/s3_website/parallelism.rb +25 -0
  107. data/lib/s3_website/paths.rb +39 -0
  108. data/lib/s3_website/retry.rb +19 -0
  109. data/lib/s3_website/tasks.rb +36 -0
  110. data/lib/s3_website/upload.rb +137 -0
  111. data/lib/s3_website/uploader.rb +177 -0
  112. data/lib/s3_website.rb +34 -0
  113. data/project/assembly.sbt +1 -0
  114. data/project/build.properties +0 -0
  115. data/project/plugins.sbt +1 -0
  116. data/project/sbt-launch-0.13.2.jar +0 -0
  117. data/resources/configuration_file_template.yml +56 -0
  118. data/s3_website.gemspec +41 -0
  119. data/sbt +4 -0
  120. data/spec/lib/cloudfront/invalidator_spec.rb +60 -0
  121. data/spec/lib/config_loader_spec.rb +20 -0
  122. data/spec/lib/endpoint_spec.rb +31 -0
  123. data/spec/lib/error_spec.rb +21 -0
  124. data/spec/lib/keyboard_spec.rb +62 -0
  125. data/spec/lib/parallelism_spec.rb +81 -0
  126. data/spec/lib/paths_spec.rb +7 -0
  127. data/spec/lib/retry_spec.rb +34 -0
  128. data/spec/lib/upload_spec.rb +303 -0
  129. data/spec/lib/uploader_spec.rb +37 -0
  130. data/spec/sample_files/hyde_site/_site/.vimrc +5 -0
  131. data/spec/sample_files/hyde_site/_site/css/styles.css +3 -0
  132. data/spec/sample_files/hyde_site/_site/index.html +1 -0
  133. data/spec/sample_files/hyde_site/s3_website.yml +3 -0
  134. data/spec/sample_files/tokyo_site/_site/.vimrc +5 -0
  135. data/spec/sample_files/tokyo_site/_site/css/styles.css +3 -0
  136. data/spec/sample_files/tokyo_site/_site/index.html +1 -0
  137. data/spec/sample_files/tokyo_site/s3_website.yml +4 -0
  138. data/spec/spec_helper.rb +1 -0
  139. data/src/main/scala/s3/website/CloudFront.scala +96 -0
  140. data/src/main/scala/s3/website/Diff.scala +42 -0
  141. data/src/main/scala/s3/website/Implicits.scala +7 -0
  142. data/src/main/scala/s3/website/Push.scala +191 -0
  143. data/src/main/scala/s3/website/Ruby.scala +12 -0
  144. data/src/main/scala/s3/website/S3.scala +139 -0
  145. data/src/main/scala/s3/website/model/Config.scala +152 -0
  146. data/src/main/scala/s3/website/model/S3Endpoint.scala +22 -0
  147. data/src/main/scala/s3/website/model/Site.scala +68 -0
  148. data/src/main/scala/s3/website/model/errors.scala +11 -0
  149. data/src/main/scala/s3/website/model/push.scala +192 -0
  150. data/src/test/scala/s3/website/S3WebsiteSpec.scala +445 -0
  151. metadata +508 -0
@@ -0,0 +1,192 @@
1
+ package s3.website.model
2
+
3
+ import com.amazonaws.services.s3.model.S3ObjectSummary
4
+ import java.io._
5
+ import scala.util.Try
6
+ import s3.website.model.Encoding._
7
+ import org.apache.commons.codec.digest.DigestUtils
8
+ import java.util.zip.GZIPOutputStream
9
+ import org.apache.commons.io.IOUtils
10
+ import org.apache.tika.Tika
11
+ import s3.website.Ruby._
12
+ import s3.website.model.Encoding.Gzip
13
+ import scala.util.Failure
14
+ import scala.Some
15
+ import scala.util.Success
16
+ import s3.website.model.Encoding.Zopfli
17
+
18
+ object Encoding {
19
+
20
+ val defaultGzipExtensions = ".html" :: ".css" :: ".js" :: ".txt" :: Nil
21
+
22
+ case class Gzip()
23
+ case class Zopfli()
24
+
25
+ def encodingOnS3(path: String)(implicit site: Site): Option[Either[Gzip, Zopfli]] =
26
+ site.config.gzip.flatMap { (gzipSetting: Either[Boolean, Seq[String]]) =>
27
+ val shouldZipThisFile = gzipSetting.fold(
28
+ shouldGzip => defaultGzipExtensions exists path.endsWith,
29
+ fileExtensions => fileExtensions exists path.endsWith
30
+ )
31
+ if (shouldZipThisFile && site.config.gzip_zopfli.isDefined)
32
+ Some(Right(Zopfli()))
33
+ else if (shouldZipThisFile)
34
+ Some(Left(Gzip()))
35
+ else
36
+ None
37
+ }
38
+
39
+ type MD5 = String
40
+ }
41
+
42
+ sealed trait S3KeyProvider {
43
+ def s3Key: String
44
+ }
45
+
46
+ trait UploadTypeResolved {
47
+ def uploadType: UploadType
48
+ }
49
+
50
+ sealed trait UploadType // Sealed, so that we can avoid inexhaustive pattern matches more easily
51
+
52
+ case object NewFile extends UploadType
53
+ case object Update extends UploadType
54
+
55
+ case class LocalFile(
56
+ s3Key: String,
57
+ sourceFile: File,
58
+ encodingOnS3: Option[Either[Gzip, Zopfli]]
59
+ ) extends S3KeyProvider
60
+
61
+ object LocalFile {
62
+ def toUpload(localFile: LocalFile)(implicit config: Config): Either[Error, Upload] = Try {
63
+ def fis(file: File): InputStream = new FileInputStream(file)
64
+ def using[T <: Closeable, R](cl: T)(f: (T) => R): R = try f(cl) finally cl.close()
65
+ val sourceFile: File = localFile
66
+ .encodingOnS3
67
+ .fold(localFile.sourceFile)(algorithm => {
68
+ val tempFile = File.createTempFile(localFile.sourceFile.getName, "gzip")
69
+ tempFile.deleteOnExit()
70
+ using(new GZIPOutputStream(new FileOutputStream(tempFile))) { stream =>
71
+ IOUtils.copy(fis(localFile.sourceFile), stream)
72
+ }
73
+ tempFile
74
+ })
75
+ val md5 = using(fis(sourceFile)) { inputStream =>
76
+ DigestUtils.md5Hex(inputStream)
77
+ }
78
+ val maxAge = config.max_age.flatMap { maxAgeIntOrGlob =>
79
+ maxAgeIntOrGlob.fold(
80
+ (seconds: Int) => Some(seconds),
81
+ (globs2Ints: Map[String, Int]) =>
82
+ globs2Ints.find { globAndInt =>
83
+ (rubyRuntime evalScriptlet s"File.fnmatch('${globAndInt._1}', '${localFile.s3Key}')")
84
+ .toJava(classOf[Boolean])
85
+ .asInstanceOf[Boolean]
86
+ } map (_._2)
87
+ )
88
+ }
89
+
90
+ Upload(
91
+ s3Key = localFile.s3Key,
92
+ essence = Right(
93
+ UploadBody(
94
+ md5 = md5,
95
+ contentEncoding = localFile.encodingOnS3.map(_ => "gzip"),
96
+ contentLength = sourceFile.length(),
97
+ maxAge = maxAge,
98
+ contentType = tika.detect(localFile.sourceFile),
99
+ openInputStream = () => new FileInputStream(sourceFile)
100
+ )
101
+ )
102
+ )
103
+ } match {
104
+ case Success(upload) => Right(upload)
105
+ case Failure(error) => Left(IOError(error))
106
+ }
107
+
108
+ lazy val tika = new Tika()
109
+
110
+ def resolveLocalFiles(implicit site: Site): Either[Error, Seq[LocalFile]] = Try {
111
+ val files = recursiveListFiles(new File(site.rootDirectory)).filterNot(_.isDirectory)
112
+ files map { file =>
113
+ val s3Key = site.resolveS3Key(file)
114
+ LocalFile(s3Key, file, encodingOnS3(s3Key))
115
+ } filterNot { file =>
116
+ site.config.exclude_from_upload exists { _.fold(
117
+ // For backward compatibility, use Ruby regex matching
118
+ (exclusionRegex: String) => rubyRegexMatches(file.s3Key, exclusionRegex),
119
+ (exclusionRegexes: Seq[String]) => exclusionRegexes exists (rubyRegexMatches(file.s3Key, _))
120
+ ) }
121
+ } filterNot { _.sourceFile.getName == "s3_website.yml" } // For security reasons, the s3_website.yml should never be pushed
122
+ } match {
123
+ case Success(localFiles) =>
124
+ Right(
125
+ // Sort by key, because this will improve the performance when pushing existing sites.
126
+ // The lazy-loading diff take advantage of this arrangement.
127
+ localFiles sortBy (_.s3Key)
128
+ )
129
+ case Failure(error) =>
130
+ Left(IOError(error))
131
+ }
132
+
133
+ def recursiveListFiles(f: File): Seq[File] = {
134
+ val these = f.listFiles
135
+ these ++ these.filter(_.isDirectory).flatMap(recursiveListFiles)
136
+ }
137
+ }
138
+
139
+ case class OverrideExisting()
140
+ case class CreateNew()
141
+
142
+ case class Redirect(key: String, redirectTarget: String)
143
+
144
+ object Redirect extends UploadType {
145
+ def resolveRedirects(implicit config: Config): Seq[Upload with UploadTypeResolved] = {
146
+ val redirects = config.redirects.fold(Nil: Seq[Redirect]) {
147
+ sourcesToTargets =>
148
+ sourcesToTargets.foldLeft(Seq(): Seq[Redirect]) {
149
+ (redirects, sourceToTarget) =>
150
+ redirects :+ Redirect(sourceToTarget._1, sourceToTarget._2)
151
+ }
152
+ }
153
+ redirects.map { redirect =>
154
+ Upload.apply(redirect)
155
+ }
156
+ }
157
+ }
158
+
159
+ case class Upload(
160
+ s3Key: String,
161
+ essence: Either[Redirect, UploadBody]
162
+ ) extends S3KeyProvider {
163
+
164
+ def withUploadType(ut: UploadType) =
165
+ new Upload(s3Key, essence) with UploadTypeResolved {
166
+ def uploadType = ut
167
+ }
168
+ }
169
+
170
+ object Upload {
171
+ def apply(redirect: Redirect): Upload with UploadTypeResolved = new Upload(redirect.key, Left(redirect)) with UploadTypeResolved {
172
+ def uploadType = Redirect
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Represents a bunch of data that should be stored into an S3 objects body.
178
+ */
179
+ case class UploadBody(
180
+ md5: MD5,
181
+ contentLength: Long,
182
+ contentEncoding: Option[String],
183
+ maxAge: Option[Int],
184
+ contentType: String,
185
+ openInputStream: () => InputStream // It's in the caller's responsibility to close this stream
186
+ )
187
+
188
+ case class S3File(s3Key: String, md5: MD5)
189
+
190
+ object S3File {
191
+ def apply(summary: S3ObjectSummary): S3File = S3File(summary.getKey, summary.getETag)
192
+ }
@@ -0,0 +1,445 @@
1
+ package s3.website
2
+
3
+ import org.specs2.mutable.{After, Specification}
4
+ import s3.website.model._
5
+ import org.specs2.specification.Scope
6
+ import org.apache.commons.io.FileUtils
7
+ import org.apache.commons.codec.digest.DigestUtils
8
+ import java.io.File
9
+ import scala.util.Random
10
+ import org.mockito.{Mockito, Matchers, ArgumentCaptor}
11
+ import org.mockito.Mockito._
12
+ import com.amazonaws.services.s3.AmazonS3
13
+ import com.amazonaws.services.s3.model._
14
+ import scala.concurrent.ExecutionContext.Implicits.global
15
+ import scala.concurrent.Await
16
+ import scala.concurrent.duration._
17
+ import s3.website.S3.S3ClientProvider
18
+ import scala.collection.JavaConversions._
19
+ import s3.website.model.NewFile
20
+ import scala.Some
21
+ import com.amazonaws.AmazonServiceException
22
+ import org.apache.commons.codec.digest.DigestUtils.md5Hex
23
+ import s3.website.CloudFront.CloudFrontClientProvider
24
+ import com.amazonaws.services.cloudfront.AmazonCloudFront
25
+ import com.amazonaws.services.cloudfront.model.{CreateInvalidationResult, CreateInvalidationRequest, TooManyInvalidationsInProgressException}
26
+ import org.mockito.stubbing.Answer
27
+ import org.mockito.invocation.InvocationOnMock
28
+
29
+ class S3WebsiteSpec extends Specification {
30
+
31
+ "gzip: true" should {
32
+ "update a gzipped S3 object if the contents has changed" in new SiteDirectory with MockAWS {
33
+ implicit val site = siteWithFilesAndContent(
34
+ config = defaultConfig.copy(gzip = Some(Left(true))),
35
+ localFilesWithContent = ("styles.css", "<h1>hi again</h1>") :: Nil
36
+ )
37
+ setS3Files(S3File("styles.css", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */))
38
+ Push.pushSite
39
+ sentPutObjectRequest.getKey must equalTo("styles.css")
40
+ }
41
+
42
+ "not update a gzipped S3 object if the contents has not changed" in new SiteDirectory with MockAWS {
43
+ implicit val site = siteWithFilesAndContent(
44
+ config = defaultConfig.copy(gzip = Some(Left(true))),
45
+ localFilesWithContent = ("styles.css", "<h1>hi</h1>") :: Nil
46
+ )
47
+ setS3Files(S3File("styles.css", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */))
48
+ Push.pushSite
49
+ noUploadsOccurred must beTrue
50
+ }
51
+ }
52
+
53
+ """
54
+ gzip:
55
+ - .xml
56
+ """ should {
57
+ "update a gzipped S3 object if the contents has changed" in new SiteDirectory with MockAWS {
58
+ implicit val site = siteWithFilesAndContent(
59
+ config = defaultConfig.copy(gzip = Some(Right(".xml" :: Nil))),
60
+ localFilesWithContent = ("file.xml", "<h1>hi again</h1>") :: Nil
61
+ )
62
+ setS3Files(S3File("file.xml", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */))
63
+ Push.pushSite
64
+ sentPutObjectRequest.getKey must equalTo("file.xml")
65
+ }
66
+ }
67
+
68
+
69
+ "push" should {
70
+ "not upload a file if it has not changed" in new SiteDirectory with MockAWS {
71
+ implicit val site = siteWithFilesAndContent(localFilesWithContent = ("index.html", "<div>hello</div>") :: Nil)
72
+ setS3Files(S3File("index.html", md5Hex("<div>hello</div>")))
73
+ Push.pushSite
74
+ noUploadsOccurred must beTrue
75
+ }
76
+
77
+ "update a file if it has changed" in new SiteDirectory with MockAWS {
78
+ implicit val site = siteWithFilesAndContent(localFilesWithContent = ("index.html", "<h1>old text</h1>") :: Nil)
79
+ setS3Files(S3File("index.html", md5Hex("<h1>new text</h1>")))
80
+ Push.pushSite
81
+ sentPutObjectRequest.getKey must equalTo("index.html")
82
+ }
83
+
84
+ "create a file if does not exist on S3" in new SiteDirectory with MockAWS {
85
+ implicit val site = siteWithFilesAndContent(localFilesWithContent = ("index.html", "<h1>hello</h1>") :: Nil)
86
+ Push.pushSite
87
+ sentPutObjectRequest.getKey must equalTo("index.html")
88
+ }
89
+
90
+ "delete files that are on S3 but not on local file system" in new SiteDirectory with MockAWS {
91
+ implicit val site = buildSite()
92
+ setS3Files(S3File("old.html", md5Hex("<h1>old text</h1>")))
93
+ Push.pushSite
94
+ sentDelete must equalTo("old.html")
95
+ }
96
+
97
+ "invalidate the CloudFront items" in new SiteDirectory with MockAWS {
98
+ implicit val site = siteWithFiles(
99
+ config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z")),
100
+ localFiles = "test.css" :: "articles/index.html" :: Nil
101
+ )
102
+ Push.pushSite
103
+ sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/test.css" :: "/articles/index.html" :: Nil).sorted)
104
+ }
105
+
106
+ "not send CloudFront invalidation requests on redirect objects" in new SiteDirectory with MockAWS {
107
+ implicit val site = buildSite(
108
+ config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z"), redirects = Some(Map("/index.php" -> "index.html")))
109
+ )
110
+ Push.pushSite
111
+ noInvalidationsOccurred must beTrue
112
+ }
113
+
114
+ "retry CloudFront responds with TooManyInvalidationsInProgressException" in new SiteDirectory with MockAWS {
115
+ setTooManyInvalidationsInProgress(4)
116
+ implicit val site = siteWithFiles(
117
+ config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z")),
118
+ localFiles = "test.css" :: Nil
119
+ )
120
+ Push.pushSite must equalTo(0) // The retries should finally result in a success
121
+ sentInvalidationRequests.length must equalTo(4)
122
+ }
123
+ }
124
+
125
+ "cloudfront_invalidate_root: true" should {
126
+ "convert CloudFront invalidation paths with the '/index.html' suffix into '/'" in new SiteDirectory with MockAWS {
127
+ implicit val site = siteWithFiles(
128
+ config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z"), cloudfront_invalidate_root = Some(true)),
129
+ localFiles = "index.html" :: "articles/index.html" :: Nil
130
+ )
131
+ Push.pushSite
132
+ sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/" :: "/articles/" :: Nil).sorted)
133
+ }
134
+ }
135
+
136
+ "a site with over 1000 items" should {
137
+ "split the CloudFront invalidation requests into batches of 1000 items" in new SiteDirectory with MockAWS {
138
+ implicit val site = siteWithFiles(
139
+ config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z")),
140
+ localFiles = (1 to 1002).map { i => s"file-$i"}
141
+ )
142
+ Push.pushSite
143
+ sentInvalidationRequests.length must equalTo(2)
144
+ sentInvalidationRequests(0).getInvalidationBatch.getPaths.getItems.length must equalTo(1000)
145
+ sentInvalidationRequests(1).getInvalidationBatch.getPaths.getItems.length must equalTo(2)
146
+ }
147
+ }
148
+
149
+ "push exit status" should {
150
+ "be 0 all uploads succeed" in new SiteDirectory with MockAWS {
151
+ implicit val site = siteWithFilesAndContent(localFilesWithContent = ("index.html", "<h1>hello</h1>") :: Nil)
152
+ Push.pushSite must equalTo(0)
153
+ }
154
+
155
+ "be 1 if any of the uploads fails" in new SiteDirectory with MockAWS {
156
+ implicit val site = siteWithFilesAndContent(localFilesWithContent = ("index.html", "<h1>hello</h1>") :: Nil)
157
+ when(amazonS3Client.putObject(Matchers.any(classOf[PutObjectRequest]))).thenThrow(new AmazonServiceException("AWS failed"))
158
+ Push.pushSite must equalTo(1)
159
+ }
160
+
161
+ "be 0 if CloudFront invalidations and uploads succeed"in new SiteDirectory with MockAWS {
162
+ implicit val site = siteWithFiles(
163
+ config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z")),
164
+ localFiles = "test.css" :: "articles/index.html" :: Nil
165
+ )
166
+ Push.pushSite must equalTo(0)
167
+ }
168
+
169
+ "be 1 if CloudFront invalidation fails"in new SiteDirectory with MockAWS {
170
+ setCloudFrontAsInternallyBroken()
171
+ implicit val site = siteWithFiles(
172
+ config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z")),
173
+ localFiles = "test.css" :: "articles/index.html" :: Nil
174
+ )
175
+ Push.pushSite must equalTo(1)
176
+ }
177
+ }
178
+
179
+ "s3_website.yml file" should {
180
+ "never be uploaded" in new SiteDirectory with MockAWS {
181
+ implicit val site = siteWithFiles(localFiles = "s3_website.yml" :: Nil)
182
+ Push.pushSite
183
+ noUploadsOccurred must beTrue
184
+ }
185
+ }
186
+
187
+ "exclude_from_upload: string" should {
188
+ "result in matching files not being uploaded" in new SiteDirectory with MockAWS {
189
+ implicit val site = siteWithFiles(
190
+ config = defaultConfig.copy(exclude_from_upload = Some(Left(".DS_.*?"))),
191
+ localFiles = ".DS_Store" :: Nil
192
+ )
193
+ Push.pushSite
194
+ noUploadsOccurred must beTrue
195
+ }
196
+ }
197
+
198
+ """
199
+ exclude_from_upload:
200
+ - regex
201
+ - another_exclusion
202
+ """ should {
203
+ "result in matching files not being uploaded" in new SiteDirectory with MockAWS {
204
+ implicit val site = siteWithFiles(
205
+ config = defaultConfig.copy(exclude_from_upload = Some(Right(".DS_.*?" :: "logs" :: Nil))),
206
+ localFiles = ".DS_Store" :: "logs/test.log" :: Nil
207
+ )
208
+ Push.pushSite
209
+ noUploadsOccurred must beTrue
210
+ }
211
+ }
212
+
213
+ "ignore_on_server: value" should {
214
+ "not delete the S3 objects that match the ignore value" in new SiteDirectory with MockAWS {
215
+ implicit val site = buildSite(config = defaultConfig.copy(ignore_on_server = Some(Left("logs"))))
216
+ setS3Files(S3File("logs/log.txt", ""))
217
+ Push.pushSite
218
+ noDeletesOccurred must beTrue
219
+ }
220
+ }
221
+
222
+ """
223
+ ignore_on_server:
224
+ - regex
225
+ - another_ignore
226
+ """ should {
227
+ "not delete the S3 objects that match the ignore value" in new SiteDirectory with MockAWS {
228
+ implicit val site = buildSite(config = defaultConfig.copy(ignore_on_server = Some(Right(".*txt" :: Nil))))
229
+ setS3Files(S3File("logs/log.txt", ""))
230
+ Push.pushSite
231
+ noDeletesOccurred must beTrue
232
+ }
233
+ }
234
+
235
+ "max-age in config" can {
236
+ "be applied to all files" in new SiteDirectory with MockAWS {
237
+ implicit val site = siteWithFiles(defaultConfig.copy(max_age = Some(Left(60))), localFiles = "index.html" :: Nil)
238
+ Push.pushSite
239
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=60")
240
+ }
241
+
242
+ "be applied to files that match the glob" in new SiteDirectory with MockAWS {
243
+ implicit val site = siteWithFiles(defaultConfig.copy(max_age = Some(Right(Map("*.html" -> 90)))), localFiles = "index.html" :: Nil)
244
+ Push.pushSite
245
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=90")
246
+ }
247
+
248
+ "be applied to directories that match the glob" in new SiteDirectory with MockAWS {
249
+ implicit val site = siteWithFiles(defaultConfig.copy(max_age = Some(Right(Map("assets/**/*.js" -> 90)))), localFiles = "assets/lib/jquery.js" :: Nil)
250
+ Push.pushSite
251
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=90")
252
+ }
253
+
254
+ "not be applied if the glob doesn't match" in new SiteDirectory with MockAWS {
255
+ implicit val site = siteWithFiles(defaultConfig.copy(max_age = Some(Right(Map("*.js" -> 90)))), localFiles = "index.html" :: Nil)
256
+ Push.pushSite
257
+ sentPutObjectRequest.getMetadata.getCacheControl must beNull
258
+ }
259
+ }
260
+
261
+ "s3_reduced_redundancy: true in config" should {
262
+ "result in uploads being marked with reduced redundancy" in new SiteDirectory with MockAWS {
263
+ implicit val site = siteWithFiles(defaultConfig.copy(s3_reduced_redundancy = Some(true)), localFiles = "index.html" :: Nil)
264
+ Push.pushSite
265
+ sentPutObjectRequest.getStorageClass must equalTo("REDUCED_REDUNDANCY")
266
+ }
267
+ }
268
+
269
+ "s3_reduced_redundancy: false in config" should {
270
+ "result in uploads being marked with the default storage class" in new SiteDirectory with MockAWS {
271
+ implicit val site = siteWithFiles(defaultConfig.copy(s3_reduced_redundancy = Some(false)), localFiles = "index.html" :: Nil)
272
+ Push.pushSite
273
+ sentPutObjectRequest.getStorageClass must beNull
274
+ }
275
+ }
276
+
277
+ "redirect in config" should {
278
+ "result in a redirect instruction that is sent to AWS" in new SiteDirectory with MockAWS {
279
+ implicit val site = buildSite(defaultConfig.copy(redirects = Some(Map("index.php" -> "/index.html"))))
280
+ Push.pushSite
281
+ sentPutObjectRequest.getRedirectLocation must equalTo("/index.html")
282
+ }
283
+
284
+ "result in max-age=0 Cache-Control header on the object" in new SiteDirectory with MockAWS {
285
+ implicit val site = buildSite(defaultConfig.copy(redirects = Some(Map("index.php" -> "/index.html"))))
286
+ Push.pushSite
287
+ sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=0, no-cache")
288
+ }
289
+ }
290
+
291
+ "redirect in config and an object on the S3 bucket" should {
292
+ "not result in the S3 object being deleted" in new SiteDirectory with MockAWS {
293
+ implicit val site = siteWithFiles(
294
+ localFiles = "index.html" :: Nil,
295
+ config = defaultConfig.copy(redirects = Some(Map("index.php" -> "/index.html")))
296
+ )
297
+ setS3Files(S3File("index.php", "md5"))
298
+ Push.pushSite
299
+ noDeletesOccurred must beTrue
300
+ }
301
+ }
302
+
303
+ "dotfiles" should {
304
+ "be included in the pushed files" in new SiteDirectory with MockAWS {
305
+ implicit val site = siteWithFiles(localFiles = ".vimrc" :: Nil)
306
+ Push.pushSite
307
+ sentPutObjectRequest.getKey must equalTo(".vimrc")
308
+ }
309
+ }
310
+
311
+ trait MockAWS extends MockS3 with MockCloudFront with Scope
312
+
313
+ trait MockCloudFront {
314
+ val amazonCloudFrontClient = mock(classOf[AmazonCloudFront])
315
+ implicit val cfClientProvider: CloudFrontClientProvider = _ => amazonCloudFrontClient
316
+ implicit val cloudFrontSleepTimeUnit: TimeUnit = MILLISECONDS
317
+
318
+ def sentInvalidationRequests: Seq[CreateInvalidationRequest] = {
319
+ val createInvalidationReq = ArgumentCaptor.forClass(classOf[CreateInvalidationRequest])
320
+ verify(amazonCloudFrontClient, Mockito.atLeastOnce()).createInvalidation(createInvalidationReq.capture())
321
+ createInvalidationReq.getAllValues
322
+ }
323
+
324
+ def sentInvalidationRequest = sentInvalidationRequests.ensuring(_.length == 1).head
325
+
326
+ def noInvalidationsOccurred = {
327
+ verify(amazonCloudFrontClient, Mockito.never()).createInvalidation(Matchers.any(classOf[CreateInvalidationRequest]))
328
+ true // Mockito is based on exceptions
329
+ }
330
+
331
+ def setTooManyInvalidationsInProgress(attemptWhenInvalidationSucceeds: Int) {
332
+ var callCount = 0
333
+ doAnswer(new Answer[CreateInvalidationResult] {
334
+ override def answer(invocation: InvocationOnMock): CreateInvalidationResult = {
335
+ callCount += 1
336
+ if (callCount < attemptWhenInvalidationSucceeds)
337
+ throw new TooManyInvalidationsInProgressException("just too many, man")
338
+ else
339
+ mock(classOf[CreateInvalidationResult])
340
+ }
341
+ }).when(amazonCloudFrontClient).createInvalidation(Matchers.anyObject())
342
+ }
343
+
344
+ def setCloudFrontAsInternallyBroken() {
345
+ when(amazonCloudFrontClient.createInvalidation(Matchers.anyObject())).thenThrow(new AmazonServiceException("CloudFront is down"))
346
+ }
347
+ }
348
+
349
+ trait MockS3 {
350
+ val amazonS3Client = mock(classOf[AmazonS3])
351
+ implicit val s3ClientProvider: S3ClientProvider = _ => amazonS3Client
352
+ val s3ObjectListing = new ObjectListing
353
+ when(amazonS3Client.listObjects(Matchers.any(classOf[ListObjectsRequest]))).thenReturn(s3ObjectListing)
354
+
355
+ def setS3Files(s3Files: S3File*) {
356
+ s3Files.foreach { s3File =>
357
+ s3ObjectListing.getObjectSummaries.add({
358
+ val summary = new S3ObjectSummary
359
+ summary.setETag(s3File.md5)
360
+ summary.setKey(s3File.s3Key)
361
+ summary
362
+ })
363
+ }
364
+ }
365
+
366
+ val s3 = new S3()
367
+
368
+ def asSeenByS3Client(upload: Upload)(implicit config: Config): PutObjectRequest = {
369
+ Await.ready(s3.upload(upload withUploadType NewFile), Duration("1 s"))
370
+ val req = ArgumentCaptor.forClass(classOf[PutObjectRequest])
371
+ verify(amazonS3Client).putObject(req.capture())
372
+ req.getValue
373
+ }
374
+
375
+ def sentPutObjectRequests: Seq[PutObjectRequest] = {
376
+ val req = ArgumentCaptor.forClass(classOf[PutObjectRequest])
377
+ verify(amazonS3Client).putObject(req.capture())
378
+ req.getAllValues
379
+ }
380
+
381
+ def sentPutObjectRequest = sentPutObjectRequests.ensuring(_.length == 1).head
382
+
383
+ def sentDeletes: Seq[S3Key] = {
384
+ val deleteKey = ArgumentCaptor.forClass(classOf[S3Key])
385
+ verify(amazonS3Client).deleteObject(Matchers.anyString(), deleteKey.capture())
386
+ deleteKey.getAllValues
387
+ }
388
+
389
+ def sentDelete = sentDeletes.ensuring(_.length == 1).head
390
+
391
+ def noDeletesOccurred = {
392
+ verify(amazonS3Client, never()).deleteObject(Matchers.anyString(), Matchers.anyString())
393
+ true // Mockito is based on exceptions
394
+ }
395
+
396
+ def noUploadsOccurred = {
397
+ verify(amazonS3Client, never()).putObject(Matchers.any(classOf[PutObjectRequest]))
398
+ true // Mockito is based on exceptions
399
+ }
400
+
401
+ type S3Key = String
402
+ }
403
+
404
+ trait SiteDirectory extends After {
405
+ val siteDir = new File(FileUtils.getTempDirectory, "site" + Random.nextLong())
406
+ siteDir.mkdir()
407
+
408
+ def after {
409
+ FileUtils.forceDelete(siteDir)
410
+ }
411
+
412
+ def buildSite(config: Config = defaultConfig): Site = Site(siteDir.getAbsolutePath, config)
413
+
414
+ def siteWithFilesAndContent(config: Config = defaultConfig, localFilesWithContent: Seq[(String, String)]): Site = {
415
+ localFilesWithContent.foreach {
416
+ case (filePath, content) =>
417
+ val file = new File(siteDir, filePath)
418
+ FileUtils.forceMkdir(file.getParentFile)
419
+ file.createNewFile()
420
+ FileUtils.write(file, content)
421
+ }
422
+ buildSite(config)
423
+ }
424
+
425
+ def siteWithFiles(config: Config = defaultConfig, localFiles: Seq[String]): Site =
426
+ siteWithFilesAndContent(config, localFilesWithContent = localFiles.map((_, "file contents")))
427
+ }
428
+
429
+ val defaultConfig = Config(
430
+ s3_id = "foo",
431
+ s3_secret = "bar",
432
+ s3_bucket = "bucket",
433
+ s3_endpoint = S3Endpoint.defaultEndpoint,
434
+ max_age = None,
435
+ gzip = None,
436
+ gzip_zopfli = None,
437
+ ignore_on_server = None,
438
+ exclude_from_upload = None,
439
+ s3_reduced_redundancy = None,
440
+ cloudfront_distribution_id = None,
441
+ cloudfront_invalidate_root = None,
442
+ redirects = None,
443
+ concurrency_level = 1
444
+ )
445
+ }