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,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
+ }