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.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.travis.yml +3 -0
- data/Gemfile +3 -0
- data/LICENSE +42 -0
- data/README.md +451 -0
- data/Rakefile +24 -0
- data/additional-docs/example-configurations.md +62 -0
- data/additional-docs/setting-up-aws-credentials.md +51 -0
- data/assembly.sbt +3 -0
- data/bin/s3_website +80 -0
- data/build.sbt +33 -0
- data/changelog.md +215 -0
- data/features/as-library.feature +29 -0
- data/features/cassettes/cucumber_tags/create-redirect.yml +384 -0
- data/features/cassettes/cucumber_tags/empty-bucket.yml +89 -0
- data/features/cassettes/cucumber_tags/new-and-changed-files.yml +303 -0
- data/features/cassettes/cucumber_tags/new-files-for-sydney.yml +211 -0
- data/features/cassettes/cucumber_tags/new-files.yml +355 -0
- data/features/cassettes/cucumber_tags/no-new-or-changed-files.yml +359 -0
- data/features/cassettes/cucumber_tags/one-file-to-delete.yml +390 -0
- data/features/cassettes/cucumber_tags/only-changed-files.yml +411 -0
- data/features/cassettes/cucumber_tags/s3-and-cloudfront-after-deleting-a-file.yml +434 -0
- data/features/cassettes/cucumber_tags/s3-and-cloudfront-when-updating-a-file.yml +435 -0
- data/features/cassettes/cucumber_tags/s3-and-cloudfront.yml +290 -0
- data/features/cloudfront.feature +54 -0
- data/features/command-line-help.feature +54 -0
- data/features/delete.feature +19 -0
- data/features/error_reporting.feature +24 -0
- data/features/instructions-for-new-user.feature +154 -0
- data/features/jekyll-support.feature +20 -0
- data/features/nanoc-support.feature +20 -0
- data/features/push.feature +115 -0
- data/features/redirects.feature +14 -0
- data/features/security.feature +15 -0
- data/features/step_definitions/steps.rb +86 -0
- data/features/support/env.rb +26 -0
- data/features/support/test_site_dirs/cdn-powered.blog.fi/_site/css/styles.css +3 -0
- data/features/support/test_site_dirs/cdn-powered.blog.fi/_site/index.html +5 -0
- data/features/support/test_site_dirs/cdn-powered.blog.fi/s3_website.yml +4 -0
- data/features/support/test_site_dirs/cdn-powered.when-deleted-a-file.blog.fi/_site/index.html +10 -0
- data/features/support/test_site_dirs/cdn-powered.when-deleted-a-file.blog.fi/s3_website.yml +5 -0
- data/features/support/test_site_dirs/cdn-powered.with-one-change.blog.fi/_site/css/styles.css +3 -0
- data/features/support/test_site_dirs/cdn-powered.with-one-change.blog.fi/_site/index.html +10 -0
- data/features/support/test_site_dirs/cdn-powered.with-one-change.blog.fi/s3_website.yml +4 -0
- data/features/support/test_site_dirs/create-redirects/_site/.gitkeep +0 -0
- data/features/support/test_site_dirs/create-redirects/s3_website.yml +6 -0
- data/features/support/test_site_dirs/ignored-files.com/_site/css/styles.css +4 -0
- data/features/support/test_site_dirs/ignored-files.com/_site/index.html +8 -0
- data/features/support/test_site_dirs/ignored-files.com/s3_website.yml +5 -0
- data/features/support/test_site_dirs/index-and-assets.blog.fi/_site/assets/picture.gif +0 -0
- data/features/support/test_site_dirs/index-and-assets.blog.fi/_site/css/styles.css +3 -0
- data/features/support/test_site_dirs/index-and-assets.blog.fi/_site/index.html +5 -0
- data/features/support/test_site_dirs/index-and-assets.blog.fi/s3_website.yml +3 -0
- data/features/support/test_site_dirs/jekyllrb.com/_site/css/styles.css +3 -0
- data/features/support/test_site_dirs/jekyllrb.com/_site/index.html +5 -0
- data/features/support/test_site_dirs/jekyllrb.com/s3_website.yml +3 -0
- data/features/support/test_site_dirs/my.blog-with-clean-urls.com/_site/css/styles.css +3 -0
- data/features/support/test_site_dirs/my.blog-with-clean-urls.com/_site/index +5 -0
- data/features/support/test_site_dirs/my.blog-with-clean-urls.com/s3_website.yml +3 -0
- data/features/support/test_site_dirs/my.blog.com/_site/css/styles.css +3 -0
- data/features/support/test_site_dirs/my.blog.com/_site/index.html +5 -0
- data/features/support/test_site_dirs/my.blog.com/s3_website.yml +3 -0
- data/features/support/test_site_dirs/my.sydney.blog.au/_site/css/styles.css +3 -0
- data/features/support/test_site_dirs/my.sydney.blog.au/_site/index.html +5 -0
- data/features/support/test_site_dirs/my.sydney.blog.au/s3_website.yml +4 -0
- data/features/support/test_site_dirs/nanoc.ws/public/output/css/styles.css +3 -0
- data/features/support/test_site_dirs/nanoc.ws/public/output/index.html +5 -0
- data/features/support/test_site_dirs/nanoc.ws/s3_website.yml +3 -0
- data/features/support/test_site_dirs/new-and-changed-files.com/_site/css/styles.css +4 -0
- data/features/support/test_site_dirs/new-and-changed-files.com/_site/index.html +8 -0
- data/features/support/test_site_dirs/new-and-changed-files.com/s3_website.yml +3 -0
- data/features/support/test_site_dirs/no-new-or-changed-files.com/_site/css/styles.css +3 -0
- data/features/support/test_site_dirs/no-new-or-changed-files.com/_site/index.html +5 -0
- data/features/support/test_site_dirs/no-new-or-changed-files.com/s3_website.yml +3 -0
- data/features/support/test_site_dirs/only-changed-files.com/_site/css/styles.css +3 -0
- data/features/support/test_site_dirs/only-changed-files.com/_site/index.html +9 -0
- data/features/support/test_site_dirs/only-changed-files.com/s3_website.yml +3 -0
- data/features/support/test_site_dirs/site-that-contains-s3-website-file.com/_site/s3_website.yml +3 -0
- data/features/support/test_site_dirs/site-that-contains-s3-website-file.com/s3_website.yml +3 -0
- data/features/support/test_site_dirs/site-with-text-doc.com/_site/file.txt +1 -0
- data/features/support/test_site_dirs/site.with.css-maxage.com/_site/css/styles.css +3 -0
- data/features/support/test_site_dirs/site.with.css-maxage.com/_site/index.html +5 -0
- data/features/support/test_site_dirs/site.with.css-maxage.com/s3_website.yml +5 -0
- data/features/support/test_site_dirs/site.with.gzipped-and-max-aged-content.com/_site/css/styles.css +3 -0
- data/features/support/test_site_dirs/site.with.gzipped-and-max-aged-content.com/_site/index.html +5 -0
- data/features/support/test_site_dirs/site.with.gzipped-and-max-aged-content.com/s3_website.yml +5 -0
- data/features/support/test_site_dirs/site.with.gzipped-html.com/_site/css/styles.css +3 -0
- data/features/support/test_site_dirs/site.with.gzipped-html.com/_site/index.html +5 -0
- data/features/support/test_site_dirs/site.with.gzipped-html.com/s3_website.yml +5 -0
- data/features/support/test_site_dirs/site.with.maxage.com/_site/css/styles.css +3 -0
- data/features/support/test_site_dirs/site.with.maxage.com/_site/index.html +5 -0
- data/features/support/test_site_dirs/site.with.maxage.com/s3_website.yml +4 -0
- data/features/support/test_site_dirs/unpublish-a-post.com/_site/css/styles.css +3 -0
- data/features/support/test_site_dirs/unpublish-a-post.com/s3_website.yml +3 -0
- data/features/support/vcr.rb +20 -0
- data/features/website-performance.feature +57 -0
- data/lib/cloudfront/invalidator.rb +37 -0
- data/lib/s3_website/config_loader.rb +55 -0
- data/lib/s3_website/diff_helper.rb +113 -0
- data/lib/s3_website/endpoint.rb +37 -0
- data/lib/s3_website/errors.rb +42 -0
- data/lib/s3_website/jekyll.rb +5 -0
- data/lib/s3_website/keyboard.rb +27 -0
- data/lib/s3_website/nanoc.rb +5 -0
- data/lib/s3_website/parallelism.rb +25 -0
- data/lib/s3_website/paths.rb +39 -0
- data/lib/s3_website/retry.rb +19 -0
- data/lib/s3_website/tasks.rb +36 -0
- data/lib/s3_website/upload.rb +137 -0
- data/lib/s3_website/uploader.rb +177 -0
- data/lib/s3_website.rb +34 -0
- data/project/assembly.sbt +1 -0
- data/project/build.properties +0 -0
- data/project/plugins.sbt +1 -0
- data/project/sbt-launch-0.13.2.jar +0 -0
- data/resources/configuration_file_template.yml +56 -0
- data/s3_website.gemspec +41 -0
- data/sbt +4 -0
- data/spec/lib/cloudfront/invalidator_spec.rb +60 -0
- data/spec/lib/config_loader_spec.rb +20 -0
- data/spec/lib/endpoint_spec.rb +31 -0
- data/spec/lib/error_spec.rb +21 -0
- data/spec/lib/keyboard_spec.rb +62 -0
- data/spec/lib/parallelism_spec.rb +81 -0
- data/spec/lib/paths_spec.rb +7 -0
- data/spec/lib/retry_spec.rb +34 -0
- data/spec/lib/upload_spec.rb +303 -0
- data/spec/lib/uploader_spec.rb +37 -0
- data/spec/sample_files/hyde_site/_site/.vimrc +5 -0
- data/spec/sample_files/hyde_site/_site/css/styles.css +3 -0
- data/spec/sample_files/hyde_site/_site/index.html +1 -0
- data/spec/sample_files/hyde_site/s3_website.yml +3 -0
- data/spec/sample_files/tokyo_site/_site/.vimrc +5 -0
- data/spec/sample_files/tokyo_site/_site/css/styles.css +3 -0
- data/spec/sample_files/tokyo_site/_site/index.html +1 -0
- data/spec/sample_files/tokyo_site/s3_website.yml +4 -0
- data/spec/spec_helper.rb +1 -0
- data/src/main/scala/s3/website/CloudFront.scala +96 -0
- data/src/main/scala/s3/website/Diff.scala +42 -0
- data/src/main/scala/s3/website/Implicits.scala +7 -0
- data/src/main/scala/s3/website/Push.scala +191 -0
- data/src/main/scala/s3/website/Ruby.scala +12 -0
- data/src/main/scala/s3/website/S3.scala +139 -0
- data/src/main/scala/s3/website/model/Config.scala +152 -0
- data/src/main/scala/s3/website/model/S3Endpoint.scala +22 -0
- data/src/main/scala/s3/website/model/Site.scala +68 -0
- data/src/main/scala/s3/website/model/errors.scala +11 -0
- data/src/main/scala/s3/website/model/push.scala +192 -0
- data/src/test/scala/s3/website/S3WebsiteSpec.scala +445 -0
- 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
|
+
}
|