s3_website_monadic 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
}
|