s3_website_monadic 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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,191 @@
1
+ package s3.website
2
+
3
+ import s3.website.model.Site._
4
+ import scala.concurrent.{ExecutionContextExecutor, Future, Await}
5
+ import scala.collection.parallel.ForkJoinTaskSupport
6
+ import scala.concurrent.forkjoin.ForkJoinPool
7
+ import scala.concurrent.duration._
8
+ import com.lexicalscope.jewel.cli.CliFactory
9
+ import scala.language.postfixOps
10
+ import s3.website.Diff.{resolveUploads, resolveDeletes}
11
+ import s3.website.S3._
12
+ import scala.concurrent.ExecutionContext.fromExecutor
13
+ import java.util.concurrent.Executors.newFixedThreadPool
14
+ import s3.website.model.LocalFile.resolveLocalFiles
15
+ import scala.collection.parallel.ParSeq
16
+ import java.util.concurrent.ExecutorService
17
+ import s3.website.model._
18
+ import s3.website.Implicits._
19
+ import s3.website.model.Update
20
+ import s3.website.model.NewFile
21
+ import s3.website.S3.PushSuccessReport
22
+ import scala.collection.mutable.ArrayBuffer
23
+ import s3.website.CloudFront.{SuccessfulInvalidation, FailedInvalidation, CloudFrontClientProvider, toInvalidationBatches}
24
+ import s3.website.S3.SuccessfulDelete
25
+ import s3.website.CloudFront.SuccessfulInvalidation
26
+ import s3.website.S3.SuccessfulUpload
27
+ import s3.website.CloudFront.FailedInvalidation
28
+
29
+ object Push {
30
+
31
+ def pushSite(
32
+ implicit site: Site,
33
+ executor: ExecutionContextExecutor,
34
+ s3ClientProvider: S3ClientProvider = S3.awsS3Client,
35
+ cloudFrontClientProvider: CloudFrontClientProvider = CloudFront.awsCloudFrontClient,
36
+ cloudFrontSleepTimeUnit: TimeUnit = MINUTES
37
+ ): ExitCode = {
38
+ println(s"Deploying ${site.rootDirectory}/* to ${site.config.s3_bucket}")
39
+
40
+ val errorsOrReports = for {
41
+ s3Files <- resolveS3Files.right
42
+ localFiles <- resolveLocalFiles.right
43
+ } yield {
44
+ val redirects = Redirect.resolveRedirects
45
+ val deleteReports: PushReports = resolveDeletes(localFiles, s3Files, redirects)
46
+ .map { s3File => new S3() delete s3File.s3Key }
47
+ .map { Right(_) /* To make delete reports type-compatible with upload reports */ }
48
+ .par
49
+ deleteReports.tasksupport_=(new ForkJoinTaskSupport(new ForkJoinPool(site.config.concurrency_level)))
50
+ val uploadReports: PushReports = (redirects.toStream.map(Right(_)) ++ resolveUploads(localFiles, s3Files))
51
+ .map { errorOrUpload => errorOrUpload.right.map(new S3() upload ) }
52
+ .par
53
+ uploadReports.tasksupport_=(new ForkJoinTaskSupport(new ForkJoinPool(site.config.concurrency_level)))
54
+ uploadReports ++ deleteReports
55
+ }
56
+ val errorsOrFinishedPushOps: Either[Error, FinishedPushOperations] = errorsOrReports.right map {
57
+ uploadReports => awaitForUploads(uploadReports)
58
+ }
59
+ val invalidationSucceeded = invalidateCloudFrontItems(errorsOrFinishedPushOps)
60
+
61
+ afterPushFinished(errorsOrFinishedPushOps, invalidationSucceeded)
62
+ }
63
+
64
+ def invalidateCloudFrontItems
65
+ (errorsOrFinishedPushOps: Either[Error, FinishedPushOperations])
66
+ (implicit config: Config, cloudFrontClientProvider: CloudFrontClientProvider, cloudFrontSleepTimeUnit: TimeUnit): Option[InvalidationSucceeded] = {
67
+ config.cloudfront_distribution_id.map {
68
+ distributionId =>
69
+ val pushSuccessReports = errorsOrFinishedPushOps.fold(
70
+ errors => Nil,
71
+ finishedPushOps => {
72
+ finishedPushOps.map {
73
+ ops =>
74
+ for {
75
+ failedOrSucceededPushes <- ops.right
76
+ successfulPush <- failedOrSucceededPushes.right
77
+ } yield successfulPush
78
+ }.foldLeft(Seq(): Seq[PushSuccessReport]) {
79
+ (reports, failOrSucc) =>
80
+ failOrSucc.fold(
81
+ _ => reports,
82
+ (pushSuccessReport: PushSuccessReport) => reports :+ pushSuccessReport
83
+ )
84
+ }
85
+ }
86
+ )
87
+ val invalidationResults: Seq[Either[FailedInvalidation, SuccessfulInvalidation]] =
88
+ toInvalidationBatches(pushSuccessReports) map (new CloudFront().invalidate(_, distributionId))
89
+ if (invalidationResults.exists(_.isLeft))
90
+ false // If one of the invalidations failed, mark the whole process as failed
91
+ else
92
+ true
93
+ }
94
+ }
95
+
96
+ type InvalidationSucceeded = Boolean
97
+
98
+ def afterPushFinished(errorsOrFinishedUploads: Either[Error, FinishedPushOperations], invalidationSucceeded: Option[Boolean])(implicit config: Config): ExitCode = {
99
+ errorsOrFinishedUploads.right.foreach { finishedUploads =>
100
+ val pushCounts = pushCountsToString(resolvePushCounts(finishedUploads))
101
+ println(s"$pushCounts")
102
+ println(s"Go visit: http://${config.s3_bucket}.${config.s3_endpoint.s3WebsiteHostname}")
103
+ }
104
+ errorsOrFinishedUploads.left foreach (err => println(s"Failed to push the site: ${err.message}"))
105
+ errorsOrFinishedUploads.fold(
106
+ _ => 1,
107
+ finishedUploads => finishedUploads.foldLeft(0) { (memo, finishedUpload) =>
108
+ memo + finishedUpload.fold(
109
+ (error: Error) => 1,
110
+ (failedOrSucceededUpload: Either[PushFailureReport, PushSuccessReport]) =>
111
+ if (failedOrSucceededUpload.isLeft) 1 else 0
112
+ )
113
+ } min 1
114
+ ) max invalidationSucceeded.fold(0)(allInvalidationsSucceeded =>
115
+ if (allInvalidationsSucceeded) 0 else 1
116
+ )
117
+ }
118
+
119
+ def awaitForUploads(uploadReports: PushReports)(implicit executor: ExecutionContextExecutor): FinishedPushOperations =
120
+ uploadReports map (_.right.map {
121
+ rep => Await.result(rep, 1 day)
122
+ })
123
+
124
+ def resolvePushCounts(implicit finishedOperations: FinishedPushOperations) = finishedOperations.foldLeft(PushCounts()) {
125
+ (counts: PushCounts, uploadReport) => uploadReport.fold(
126
+ (error: model.Error) => counts.copy(failures = counts.failures + 1),
127
+ failureOrSuccess => failureOrSuccess.fold(
128
+ (failureReport: PushFailureReport) => counts.copy(failures = counts.failures + 1),
129
+ (successReport: PushSuccessReport) => successReport match {
130
+ case SuccessfulUpload(upload) => upload.uploadType match {
131
+ case NewFile => counts.copy(newFiles = counts.newFiles + 1)
132
+ case Update => counts.copy(updates = counts.updates + 1)
133
+ case Redirect => counts.copy(redirects = counts.redirects + 1)
134
+ }
135
+ case SuccessfulDelete(_) => counts.copy(deletes = counts.deletes + 1)
136
+ }
137
+ )
138
+ )
139
+ }
140
+
141
+ def pushCountsToString(pushCounts: PushCounts): String =
142
+ pushCounts match {
143
+ case PushCounts(updates, newFiles, failures, redirects, deletes)
144
+ if updates == 0 && newFiles == 0 && failures == 0 && redirects == 0 && deletes == 0 =>
145
+ "There was nothing to push."
146
+ case PushCounts(updates, newFiles, failures, redirects, deletes) =>
147
+ val reportClauses: scala.collection.mutable.ArrayBuffer[String] = ArrayBuffer()
148
+ if (updates > 0) reportClauses += s"Updated $updates file(s)."
149
+ if (newFiles > 0) reportClauses += s"Created $newFiles file(s)."
150
+ if (failures > 0) reportClauses += s"$failures operation(s) failed." // This includes both failed uploads and deletes.
151
+ if (redirects > 0) reportClauses += s"Applied $redirects redirect(s)."
152
+ if (deletes > 0) reportClauses += s"Deleted $deletes files(s)."
153
+ reportClauses.mkString(" ")
154
+ }
155
+
156
+ case class PushCounts(
157
+ updates: Int = 0,
158
+ newFiles: Int = 0,
159
+ failures: Int = 0,
160
+ redirects: Int = 0,
161
+ deletes: Int = 0
162
+ )
163
+ type FinishedPushOperations = ParSeq[Either[model.Error, Either[PushFailureReport, PushSuccessReport]]]
164
+ type PushReports = ParSeq[Either[model.Error, Future[Either[PushFailureReport, PushSuccessReport]]]]
165
+ case class PushResult(threadPool: ExecutorService, uploadReports: PushReports)
166
+ type ExitCode = Int
167
+
168
+ trait CliArgs {
169
+ import com.lexicalscope.jewel.cli.Option
170
+
171
+ @Option def site: String
172
+ @Option(longName = Array("config-dir")) def configDir: String
173
+
174
+ }
175
+
176
+ def main(args: Array[String]) {
177
+ val cliArgs = CliFactory.parseArguments(classOf[CliArgs], args:_*)
178
+ val errorOrPushStatus = loadSite(cliArgs.configDir + "/s3_website.yml", cliArgs.site)
179
+ .right
180
+ .map {
181
+ implicit site =>
182
+ val threadPool = newFixedThreadPool(site.config.concurrency_level)
183
+ implicit val executor = fromExecutor(threadPool)
184
+ val pushStatus = pushSite
185
+ threadPool.shutdownNow()
186
+ pushStatus
187
+ }
188
+ errorOrPushStatus.left foreach (err => println(s"Could not load the site: ${err.message}"))
189
+ System.exit(errorOrPushStatus.fold(_ => 1, pushStatus => pushStatus))
190
+ }
191
+ }
@@ -0,0 +1,12 @@
1
+ package s3.website
2
+
3
+ object Ruby {
4
+ lazy val rubyRuntime = org.jruby.Ruby.newInstance() // Instantiate heavy object
5
+
6
+ def rubyRegexMatches(text: String, regex: String) =
7
+ rubyRuntime.evalScriptlet(
8
+ s"""
9
+ !!Regexp.new('$regex').match('$text') # Use !! to force a boolean conversion
10
+ """
11
+ ).toJava(classOf[Boolean]).asInstanceOf[Boolean]
12
+ }
@@ -0,0 +1,139 @@
1
+ package s3.website
2
+
3
+ import s3.website.model._
4
+ import com.amazonaws.services.s3.{AmazonS3, AmazonS3Client}
5
+ import com.amazonaws.auth.BasicAWSCredentials
6
+ import com.amazonaws.services.s3.model._
7
+ import scala.collection.JavaConversions._
8
+ import scala.util.Try
9
+ import com.amazonaws.AmazonClientException
10
+ import scala.concurrent.{ExecutionContextExecutor, Future}
11
+ import s3.website.S3._
12
+ import com.amazonaws.services.s3.model.StorageClass.ReducedRedundancy
13
+ import s3.website.S3.SuccessfulUpload
14
+ import s3.website.S3.FailedUpload
15
+ import scala.util.Failure
16
+ import scala.Some
17
+ import s3.website.model.IOError
18
+ import scala.util.Success
19
+ import s3.website.model.UserError
20
+
21
+ class S3(implicit s3Client: S3ClientProvider) {
22
+
23
+ def upload(upload: Upload with UploadTypeResolved)(implicit config: Config, executor: ExecutionContextExecutor): Future[Either[FailedUpload, SuccessfulUpload]] =
24
+ Future {
25
+ s3Client(config) putObject toPutObjectRequest(upload)
26
+ val report = SuccessfulUpload(upload)
27
+ println(report.reportMessage)
28
+ Right(report)
29
+ } recover {
30
+ case error =>
31
+ val report = FailedUpload(upload.s3Key, error)
32
+ println(report.reportMessage)
33
+ Left(report)
34
+ }
35
+
36
+ def delete(s3Key: String)(implicit config: Config, executor: ExecutionContextExecutor): Future[Either[FailedDelete, SuccessfulDelete]] =
37
+ Future {
38
+ s3Client(config) deleteObject(config.s3_bucket, s3Key)
39
+ val report = SuccessfulDelete(s3Key)
40
+ println(report.reportMessage)
41
+ Right(report)
42
+ } recover {
43
+ case error =>
44
+ val report = FailedDelete(s3Key, error)
45
+ println(report.reportMessage)
46
+ Left(report)
47
+ }
48
+
49
+ def toPutObjectRequest(upload: Upload)(implicit config: Config) =
50
+ upload.essence.fold(
51
+ redirect => {
52
+ val req = new PutObjectRequest(config.s3_bucket, upload.s3Key, redirect.redirectTarget)
53
+ req.setMetadata({
54
+ val md = new ObjectMetadata()
55
+ md.setContentLength(0) // Otherwise the AWS SDK will log a warning
56
+ /*
57
+ * Instruct HTTP clients to always re-check the redirect. The 301 status code may override this, though.
58
+ * This is for the sake of simplicity.
59
+ */
60
+ md.setCacheControl("max-age=0, no-cache")
61
+ md
62
+ })
63
+ req
64
+ },
65
+ uploadBody => {
66
+ val md = new ObjectMetadata()
67
+ md setContentLength uploadBody.contentLength
68
+ md setContentType uploadBody.contentType
69
+ uploadBody.contentEncoding foreach md.setContentEncoding
70
+ uploadBody.maxAge foreach (seconds => md.setCacheControl(s"max-age=$seconds"))
71
+ val req = new PutObjectRequest(config.s3_bucket, upload.s3Key, uploadBody.openInputStream(), md)
72
+ config.s3_reduced_redundancy.filter(_ == true) foreach (_ => req setStorageClass ReducedRedundancy)
73
+ req
74
+ }
75
+ )
76
+ }
77
+
78
+ object S3 {
79
+ def awsS3Client(config: Config) = new AmazonS3Client(new BasicAWSCredentials(config.s3_id, config.s3_secret))
80
+
81
+ def resolveS3Files(implicit config: Config, s3ClientProvider: S3ClientProvider): Either[Error, Stream[S3File]] = Try {
82
+ objectSummaries()
83
+ } match {
84
+ case Success(remoteFiles) =>
85
+ Right(remoteFiles)
86
+ case Failure(error) if error.isInstanceOf[AmazonClientException] =>
87
+ Left(UserError(error.getMessage))
88
+ case Failure(error) =>
89
+ Left(IOError(error))
90
+ }
91
+
92
+ def objectSummaries(nextMarker: Option[String] = None)(implicit config: Config, s3ClientProvider: S3ClientProvider): Stream[S3File] = {
93
+ val objects: ObjectListing = s3ClientProvider(config).listObjects({
94
+ val req = new ListObjectsRequest()
95
+ req.setBucketName(config.s3_bucket)
96
+ nextMarker.foreach(req.setMarker)
97
+ req
98
+ })
99
+ val summaries = (objects.getObjectSummaries map (S3File(_))).toStream
100
+ if (objects.isTruncated)
101
+ summaries #::: objectSummaries(Some(objects.getNextMarker)) // Call the next Get Bucket request lazily
102
+ else
103
+ summaries
104
+ }
105
+
106
+ sealed trait PushItemReport {
107
+ def reportMessage: String
108
+ }
109
+
110
+ sealed trait PushFailureReport extends PushItemReport
111
+ sealed trait PushSuccessReport extends PushItemReport {
112
+ def s3Key: String
113
+ }
114
+
115
+ case class SuccessfulUpload(upload: Upload with UploadTypeResolved) extends PushSuccessReport {
116
+ def reportMessage =
117
+ upload.uploadType match {
118
+ case NewFile => s"Created ${upload.s3Key}"
119
+ case Update => s"Updated ${upload.s3Key}"
120
+ case Redirect => s"Redirecting ${upload.essence.left.get.key} to ${upload.essence.left.get.redirectTarget}"
121
+ }
122
+
123
+ def s3Key = upload.s3Key
124
+ }
125
+
126
+ case class SuccessfulDelete(s3Key: String) extends PushSuccessReport {
127
+ def reportMessage = s"Deleted $s3Key"
128
+ }
129
+
130
+ case class FailedUpload(s3Key: String, error: Throwable) extends PushFailureReport {
131
+ def reportMessage = s"Failed to upload $s3Key (${error.getMessage})"
132
+ }
133
+
134
+ case class FailedDelete(s3Key: String, error: Throwable) extends PushFailureReport {
135
+ def reportMessage = s"Failed to delete $s3Key (${error.getMessage})"
136
+ }
137
+
138
+ type S3ClientProvider = (Config) => AmazonS3
139
+ }
@@ -0,0 +1,152 @@
1
+ package s3.website.model
2
+
3
+ import scala.util.{Failure, Try}
4
+ import scala.collection.JavaConversions._
5
+ import s3.website.Ruby.rubyRuntime
6
+
7
+ case class Config(
8
+ s3_id: String,
9
+ s3_secret: String,
10
+ s3_bucket: String,
11
+ s3_endpoint: S3Endpoint,
12
+ max_age: Option[Either[Int, Map[String, Int]]],
13
+ gzip: Option[Either[Boolean, Seq[String]]],
14
+ gzip_zopfli: Option[Boolean],
15
+ ignore_on_server: Option[Either[String, Seq[String]]],
16
+ exclude_from_upload: Option[Either[String, Seq[String]]],
17
+ s3_reduced_redundancy: Option[Boolean],
18
+ cloudfront_distribution_id: Option[String],
19
+ cloudfront_invalidate_root: Option[Boolean],
20
+ redirects: Option[Map[String, String]],
21
+ concurrency_level: Int
22
+ )
23
+
24
+ object Config {
25
+ def loadOptionalBooleanOrStringSeq(key: String)(implicit unsafeYaml: UnsafeYaml): Either[Error, Option[Either[Boolean, Seq[String]]]] = {
26
+ val yamlValue = for {
27
+ optionalValue <- loadOptionalValue(key)
28
+ } yield {
29
+ Right(optionalValue.map {
30
+ case value if value.isInstanceOf[Boolean] => Left(value.asInstanceOf[Boolean])
31
+ case value if value.isInstanceOf[java.util.List[_]] => Right(value.asInstanceOf[java.util.List[String]].toIndexedSeq)
32
+ })
33
+ }
34
+
35
+ yamlValue getOrElse Left(UserError(s"The key $key has to have a boolean or [string] value"))
36
+ }
37
+
38
+ def loadOptionalStringOrStringSeq(key: String)(implicit unsafeYaml: UnsafeYaml): Either[Error, Option[Either[String, Seq[String]]]] = {
39
+ val yamlValue = for {
40
+ valueOption <- loadOptionalValue(key)
41
+ } yield {
42
+ Right(valueOption.map {
43
+ case value if value.isInstanceOf[String] => Left(value.asInstanceOf[String])
44
+ case value if value.isInstanceOf[java.util.List[_]] => Right(value.asInstanceOf[java.util.List[String]].toIndexedSeq)
45
+ })
46
+ }
47
+
48
+ yamlValue getOrElse Left(UserError(s"The key $key has to have a string or [string] value"))
49
+ }
50
+
51
+ def loadMaxAge(implicit unsafeYaml: UnsafeYaml): Either[Error, Option[Either[Int, Map[String, Int]]]] = {
52
+ val key = "max_age"
53
+ val yamlValue = for {
54
+ maxAgeOption <- loadOptionalValue(key)
55
+ } yield {
56
+ Right(maxAgeOption.map {
57
+ case maxAge if maxAge.isInstanceOf[Int] => Left(maxAge.asInstanceOf[Int])
58
+ case maxAge if maxAge.isInstanceOf[java.util.Map[_,_]] => Right(maxAge.asInstanceOf[java.util.Map[String,Int]].toMap)
59
+ })
60
+ }
61
+
62
+ yamlValue getOrElse Left(UserError(s"The key $key has to have an int or (string -> int) value"))
63
+ }
64
+
65
+ def loadEndpoint(implicit unsafeYaml: UnsafeYaml): Either[Error, Option[S3Endpoint]] =
66
+ loadOptionalString("s3_endpoint").right flatMap { endpointString =>
67
+ endpointString.map(S3Endpoint.forString) match {
68
+ case Some(Right(endpoint)) => Right(Some(endpoint))
69
+ case Some(Left(endpointError)) => Left(endpointError)
70
+ case None => Right(None)
71
+ }
72
+ }
73
+
74
+ def loadRedirects(implicit unsafeYaml: UnsafeYaml): Either[Error, Option[Map[String, String]]] = {
75
+ val key = "redirects"
76
+ val yamlValue = for {
77
+ redirectsOption <- loadOptionalValue(key)
78
+ redirects <- Try(redirectsOption.map(_.asInstanceOf[java.util.Map[String,String]].toMap))
79
+ } yield Right(redirects)
80
+
81
+ yamlValue getOrElse Left(UserError(s"The key $key has to have an int or (string -> int) value"))
82
+ }
83
+
84
+ def loadRequiredString(key: String)(implicit unsafeYaml: UnsafeYaml): Either[Error, String] = {
85
+ val yamlValue = for {
86
+ valueOption <- loadOptionalValue(key)
87
+ stringValue <- Try(valueOption.asInstanceOf[Option[String]].get)
88
+ } yield {
89
+ Right(stringValue)
90
+ }
91
+
92
+ yamlValue getOrElse {
93
+ Left(UserError(s"The key $key has to have a string value"))
94
+ }
95
+ }
96
+
97
+ def loadOptionalString(key: String)(implicit unsafeYaml: UnsafeYaml): Either[Error, Option[String]] = {
98
+ val yamlValueOption = for {
99
+ valueOption <- loadOptionalValue(key)
100
+ optionalString <- Try(valueOption.asInstanceOf[Option[String]])
101
+ } yield {
102
+ Right(optionalString)
103
+ }
104
+
105
+ yamlValueOption getOrElse {
106
+ Left(UserError(s"The key $key has to have a string value"))
107
+ }
108
+ }
109
+
110
+ def loadOptionalBoolean(key: String)(implicit unsafeYaml: UnsafeYaml): Either[Error, Option[Boolean]] = {
111
+ val yamlValueOption = for {
112
+ valueOption <- loadOptionalValue(key)
113
+ optionalBoolean <- Try(valueOption.asInstanceOf[Option[Boolean]])
114
+ } yield {
115
+ Right(optionalBoolean)
116
+ }
117
+
118
+ yamlValueOption getOrElse {
119
+ Left(UserError(s"The key $key has to have a boolean value"))
120
+ }
121
+ }
122
+
123
+ def loadOptionalInt(key: String)(implicit unsafeYaml: UnsafeYaml): Either[Error, Option[Int]] = {
124
+ val yamlValueOption = for {
125
+ valueOption <- loadOptionalValue(key)
126
+ optionalInt <- Try(valueOption.asInstanceOf[Option[Int]])
127
+ } yield {
128
+ Right(optionalInt)
129
+ }
130
+
131
+ yamlValueOption getOrElse {
132
+ Left(UserError(s"The key $key has to have an integer value"))
133
+ }
134
+ }
135
+
136
+ def loadOptionalValue(key: String)(implicit unsafeYaml: UnsafeYaml): Try[Option[_]] =
137
+ Try {
138
+ unsafeYaml.yamlObject.asInstanceOf[java.util.Map[String, _]].toMap get key
139
+ }
140
+
141
+ def erbEval(erbString: String): Try[String] = Try {
142
+ val erbStringWithoutComments = erbString.replaceAll("#.*", "")
143
+ rubyRuntime.evalScriptlet(
144
+ s"""
145
+ require 'erb'
146
+ ERB.new("$erbStringWithoutComments").result
147
+ """
148
+ ).asJavaString()
149
+ }
150
+
151
+ case class UnsafeYaml(yamlObject: AnyRef)
152
+ }
@@ -0,0 +1,22 @@
1
+ package s3.website.model
2
+
3
+ case class S3Endpoint(
4
+ s3WebsiteHostname: String,
5
+ s3Hostname: String
6
+ )
7
+
8
+ object S3Endpoint {
9
+ val defaultEndpoint = S3Endpoint("s3-website-us-east-1.amazonaws.com", "s3.amazonaws.com")
10
+
11
+ def forString(locationConstraint: String): Either[UserError, S3Endpoint] = locationConstraint match {
12
+ case "EU" | "eu-west-1" => Right(S3Endpoint("s3-website-eu-west-1.amazonaws.com", "s3-eu-west-1.amazonaws.com"))
13
+ case "us-east-1" => Right(defaultEndpoint)
14
+ case "us-west-1" => Right(S3Endpoint("s3-website-us-west-1.amazonaws.com", "s3-us-west-1.amazonaws.com"))
15
+ case "us-west-2" => Right(S3Endpoint("s3-website-us-west-2.amazonaws.com", "s3-us-west-2.amazonaws.com"))
16
+ case "ap-southeast-1" => Right(S3Endpoint("s3-website-ap-southeast-1.amazonaws.com", "s3-ap-southeast-1.amazonaws.com"))
17
+ case "ap-southeast-2" => Right(S3Endpoint("s3-website-ap-southeast-2.amazonaws.com", "s3-ap-southeast-2.amazonaws.com"))
18
+ case "ap-northeast-1" => Right(S3Endpoint("s3-website-ap-northeast-1.amazonaws.com", "s3-ap-northeast-1.amazonaws.com"))
19
+ case "sa-east-1" => Right(S3Endpoint("s3-website-sa-east-1.amazonaws.com", "s3-sa-east-1.amazonaws.com"))
20
+ case _ => Left(UserError(s"Unrecognised endpoint: $locationConstraint"))
21
+ }
22
+ }
@@ -0,0 +1,68 @@
1
+ package s3.website.model
2
+
3
+ import java.io.File
4
+ import scala.util.{Failure, Success, Try}
5
+ import org.yaml.snakeyaml.Yaml
6
+ import s3.website.model.Config._
7
+ import scala.io.Source.fromFile
8
+ import scala.language.postfixOps
9
+
10
+ case class Site(rootDirectory: String, config: Config) {
11
+ def resolveS3Key(file: File) = file.getAbsolutePath.replace(rootDirectory, "").replaceFirst("^/", "")
12
+ }
13
+
14
+ object Site {
15
+ def loadSite(yamlConfigPath: String, siteRootDirectory: String): Either[Error, Site] = {
16
+ val yamlObjectTry = for {
17
+ yamlString <- Try(fromFile(new File(yamlConfigPath)).mkString)
18
+ yamlWithErbEvaluated <- erbEval(yamlString)
19
+ yamlObject <- Try(new Yaml() load yamlWithErbEvaluated)
20
+ } yield yamlObject
21
+ yamlObjectTry match {
22
+ case Success(yamlObject) =>
23
+ implicit val unsafeYaml = UnsafeYaml(yamlObject)
24
+ val config: Either[Error, Config] = for {
25
+ s3_id <- loadRequiredString("s3_id").right
26
+ s3_secret <- loadRequiredString("s3_secret").right
27
+ s3_bucket <- loadRequiredString("s3_bucket").right
28
+ s3_endpoint <- loadEndpoint.right
29
+ max_age <- loadMaxAge.right
30
+ gzip <- loadOptionalBooleanOrStringSeq("gzip").right
31
+ gzip_zopfli <- loadOptionalBoolean("gzip_zopfli").right
32
+ extensionless_mime_type <- loadOptionalString("extensionless_mime_type").right
33
+ ignore_on_server <- loadOptionalStringOrStringSeq("ignore_on_server").right
34
+ exclude_from_upload <- loadOptionalStringOrStringSeq("exclude_from_upload").right
35
+ s3_reduced_redundancy <- loadOptionalBoolean("s3_reduced_redundancy").right
36
+ cloudfront_distribution_id <- loadOptionalString("cloudfront_distribution_id").right
37
+ cloudfront_invalidate_root <- loadOptionalBoolean("cloudfront_invalidate_root").right
38
+ concurrency_level <- loadOptionalInt("concurrency_level").right
39
+ redirects <- loadRedirects.right
40
+ } yield {
41
+ gzip_zopfli.foreach(_ => println("zopfli is not currently supported"))
42
+ extensionless_mime_type.foreach(_ => println(
43
+ s"Ignoring the extensionless_mime_type setting in $yamlConfigPath. Counting on Apache Tika to infer correct mime types.")
44
+ )
45
+ Config(
46
+ s3_id,
47
+ s3_secret,
48
+ s3_bucket,
49
+ s3_endpoint getOrElse S3Endpoint.defaultEndpoint,
50
+ max_age,
51
+ gzip,
52
+ gzip_zopfli,
53
+ ignore_on_server = ignore_on_server,
54
+ exclude_from_upload = exclude_from_upload,
55
+ s3_reduced_redundancy,
56
+ cloudfront_distribution_id,
57
+ cloudfront_invalidate_root,
58
+ redirects,
59
+ concurrency_level.fold(20)(_ max 20) // At minimum, run 20 concurrent operations
60
+ )
61
+ }
62
+
63
+ config.right.map(Site(siteRootDirectory, _))
64
+ case Failure(error) =>
65
+ Left(IOError(error))
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,11 @@
1
+ package s3.website.model
2
+
3
+ trait Error {
4
+ def message: String
5
+ }
6
+
7
+ case class UserError(message: String) extends Error
8
+
9
+ case class IOError(exception: Throwable) extends Error {
10
+ def message = exception.getMessage
11
+ }