s3_website_revived 4.0.0

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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +15 -0
  3. data/.travis.yml +5 -0
  4. data/Gemfile +3 -0
  5. data/LICENSE +42 -0
  6. data/README.md +591 -0
  7. data/Rakefile +2 -0
  8. data/additional-docs/debugging.md +21 -0
  9. data/additional-docs/development.md +29 -0
  10. data/additional-docs/example-configurations.md +113 -0
  11. data/additional-docs/running-from-ec2-with-dropbox.md +6 -0
  12. data/additional-docs/setting-up-aws-credentials.md +52 -0
  13. data/assembly.sbt +3 -0
  14. data/bin/s3_website +285 -0
  15. data/build.sbt +48 -0
  16. data/changelog.md +596 -0
  17. data/lib/s3_website/version.rb +3 -0
  18. data/lib/s3_website.rb +7 -0
  19. data/project/assembly.sbt +1 -0
  20. data/project/build.properties +1 -0
  21. data/project/plugins.sbt +1 -0
  22. data/release +41 -0
  23. data/resources/configuration_file_template.yml +67 -0
  24. data/resources/s3_website.jar.md5 +1 -0
  25. data/s3_website-4.0.0.jar +0 -0
  26. data/s3_website.gemspec +34 -0
  27. data/sbt +3 -0
  28. data/src/main/resources/log4j.properties +6 -0
  29. data/src/main/scala/s3/website/ByteHelper.scala +18 -0
  30. data/src/main/scala/s3/website/CloudFront.scala +144 -0
  31. data/src/main/scala/s3/website/Logger.scala +67 -0
  32. data/src/main/scala/s3/website/Push.scala +246 -0
  33. data/src/main/scala/s3/website/Ruby.scala +14 -0
  34. data/src/main/scala/s3/website/S3.scala +239 -0
  35. data/src/main/scala/s3/website/UploadHelper.scala +76 -0
  36. data/src/main/scala/s3/website/model/Config.scala +249 -0
  37. data/src/main/scala/s3/website/model/S3Endpoint.scala +35 -0
  38. data/src/main/scala/s3/website/model/Site.scala +159 -0
  39. data/src/main/scala/s3/website/model/push.scala +225 -0
  40. data/src/main/scala/s3/website/model/ssg.scala +30 -0
  41. data/src/main/scala/s3/website/package.scala +182 -0
  42. data/src/test/scala/s3/website/AwsSdkSpec.scala +15 -0
  43. data/src/test/scala/s3/website/ConfigSpec.scala +150 -0
  44. data/src/test/scala/s3/website/S3EndpointSpec.scala +15 -0
  45. data/src/test/scala/s3/website/S3WebsiteSpec.scala +1480 -0
  46. data/src/test/scala/s3/website/UnitTest.scala +11 -0
  47. data/vagrant/Vagrantfile +25 -0
  48. metadata +195 -0
@@ -0,0 +1,67 @@
1
+ # You can remove the first two lines to have credentials read from
2
+ # the environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY or
3
+ # ~/.aws/credentials.
4
+ s3_id: YOUR_AWS_S3_ACCESS_KEY_ID
5
+ s3_secret: YOUR_AWS_S3_SECRET_ACCESS_KEY
6
+ s3_bucket: your.blog.bucket.com
7
+
8
+ # set session_token if using temporary credentials with a session token (eg: when assuming a role)
9
+ # session_token: YOUR_AWS_S3_SESSION_TOKEN
10
+
11
+ # Below are examples of all the available configurations.
12
+ # See README for more detailed info on each of them.
13
+
14
+ # site: path-to-your-website
15
+
16
+ # index_document: index.html
17
+ # error_document: error.html
18
+
19
+ # max_age:
20
+ # "assets/*": 6000
21
+ # "*": 300
22
+
23
+ # gzip:
24
+ # - .html
25
+ # - .css
26
+ # - .md
27
+ # gzip_zopfli: true
28
+
29
+ # See http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region for valid endpoints
30
+ # s3_endpoint: ap-northeast-1
31
+
32
+ # ignore_on_server: that_folder_of_stuff_i_dont_keep_locally
33
+
34
+ # exclude_from_upload:
35
+ # - those_folders_of_stuff
36
+ # - i_wouldnt_want_to_upload
37
+
38
+ # s3_reduced_redundancy: true
39
+
40
+ # cloudfront_distribution_id: your-dist-id
41
+
42
+ # cloudfront_distribution_config:
43
+ # default_cache_behavior:
44
+ # min_ttl: <%= 60 * 60 * 24 %>
45
+ # aliases:
46
+ # quantity: 1
47
+ # items:
48
+ # - your.website.com
49
+
50
+ # cloudfront_invalidate_root: true
51
+
52
+ cloudfront_wildcard_invalidation: true
53
+
54
+ # concurrency_level: 5
55
+
56
+ # redirects:
57
+ # index.php: /
58
+ # about.php: about.html
59
+ # music-files/promo.mp4: http://www.youtube.com/watch?v=dQw4w9WgXcQ
60
+
61
+ # routing_rules:
62
+ # - condition:
63
+ # key_prefix_equals: blog/some_path
64
+ # redirect:
65
+ # host_name: blog.example.com
66
+ # replace_key_prefix_with: some_new_path/
67
+ # http_redirect_code: 301
@@ -0,0 +1 @@
1
+ 02bbb61fa5042bf4a79b24b660aab227 s3_website-4.0.0.jar
Binary file
@@ -0,0 +1,34 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.join([File.dirname(__FILE__),'lib','s3_website','version.rb'])
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "s3_website_revived"
6
+ s.version = S3Website::VERSION
7
+ s.platform = Gem::Platform::RUBY
8
+ s.authors = ["Ivo Anjo", "Lauri Lehmijoki"]
9
+ s.email = ["ivo@ivoanjo.me"]
10
+ s.homepage = "https://github.com/ivoanjo/s3_website_revived"
11
+ s.summary = %q{Revived fork of s3_website - Manage your S3 website}
12
+ s.description = %q{
13
+ Revived fork of s3_website. Sync website files, set redirects, use HTTP performance optimisations, deliver via
14
+ CloudFront.
15
+ }
16
+ s.license = 'MIT'
17
+
18
+ s.add_dependency 'thor', '~> 0.18'
19
+ s.add_dependency 'configure-s3-website', '= 2.3.0'
20
+ s.add_dependency 'colored', '1.2'
21
+ s.add_dependency 'dotenv', '~> 1.0'
22
+
23
+ s.add_development_dependency 'rake', '10.1.1'
24
+ s.add_development_dependency 'octokit', '3.1.0'
25
+ s.add_development_dependency 'mime-types'
26
+
27
+ s.files = `git ls-files`
28
+ .split("\n")
29
+ .reject { |f| f.match('sbt-launch.jar') } # Reject the SBT jar, as it is a big file
30
+ .push('resources/s3_website.jar.md5') # Include the checksum file in the gem
31
+ s.test_files = `git ls-files -- src/test/*`.split("\n")
32
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
33
+ s.require_paths = ["lib"]
34
+ end
data/sbt ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+
3
+ java -jar `dirname $0`/project/sbt-launch.jar "$@"
@@ -0,0 +1,6 @@
1
+ log4j.rootLogger=WARN, A1
2
+ log4j.appender.A1=org.apache.log4j.ConsoleAppender
3
+ log4j.appender.A1.layout=org.apache.log4j.PatternLayout
4
+ log4j.appender.A1.layout.ConversionPattern=%d [%t] %-5p %c - %m%n
5
+ # Or you can explicitly enable WARN and ERROR messages for the AWS Java clients
6
+ log4j.logger.com.amazonaws=WARN
@@ -0,0 +1,18 @@
1
+ package s3.website
2
+
3
+ object ByteHelper {
4
+
5
+ // Adapted from http://stackoverflow.com/a/3758880/219947
6
+ def humanReadableByteCount(bytes: Long): String = {
7
+ val si: Boolean = true
8
+ val unit: Int = if (si) 1000 else 1024
9
+ if (bytes < unit) {
10
+ bytes + " B"
11
+ } else {
12
+ val exp: Int = (Math.log(bytes) / Math.log(unit)).asInstanceOf[Int]
13
+ val pre: String = (if (si) "kMGTPE" else "KMGTPE").charAt(exp - 1) + (if (si) "" else "i")
14
+ val formatArgs = (bytes / Math.pow(unit, exp)).asInstanceOf[AnyRef] :: pre :: Nil
15
+ String.format("%.1f %sB", formatArgs.toArray:_*)
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,144 @@
1
+ package s3.website
2
+
3
+ import s3.website.ErrorReport._
4
+ import s3.website.model.{FileUpdate, Config}
5
+ import com.amazonaws.services.cloudfront.{AmazonCloudFrontClient, AmazonCloudFront}
6
+ import com.amazonaws.services.cloudfront.model.{TooManyInvalidationsInProgressException, Paths, InvalidationBatch, CreateInvalidationRequest}
7
+ import scala.collection.JavaConversions._
8
+ import scala.concurrent.duration._
9
+ import s3.website.S3.{SuccessfulDelete, PushSuccessReport, SuccessfulUpload}
10
+ import com.amazonaws.auth.BasicAWSCredentials
11
+ import java.net.{URLEncoder, URI}
12
+ import scala.concurrent.{ExecutionContextExecutor, Future}
13
+ import s3.website.model.Config.awsCredentials
14
+
15
+ object CloudFront {
16
+ def invalidate(invalidationBatch: InvalidationBatch, distributionId: String, attempt: Attempt = 1)
17
+ (implicit ec: ExecutionContextExecutor, cloudFrontSettings: CloudFrontSetting, config: Config, logger: Logger, pushOptions: PushOptions): InvalidationResult =
18
+ Future {
19
+ if (!pushOptions.dryRun) cloudFront createInvalidation new CreateInvalidationRequest(distributionId, invalidationBatch)
20
+ val result = SuccessfulInvalidation(invalidationBatch.getPaths.getItems.size())
21
+ logger.debug(invalidationBatch.getPaths.getItems.map(item => s"${Invalidated.renderVerb} $item") mkString "\n")
22
+ logger.info(result)
23
+ Right(result)
24
+ } recoverWith (tooManyInvalidationsRetry(invalidationBatch, distributionId, attempt) orElse retry(attempt)(
25
+ createFailureReport = error => FailedInvalidation(error),
26
+ retryAction = nextAttempt => invalidate(invalidationBatch, distributionId, nextAttempt)
27
+ ))
28
+
29
+ def tooManyInvalidationsRetry(invalidationBatch: InvalidationBatch, distributionId: String, attempt: Attempt)
30
+ (implicit ec: ExecutionContextExecutor, logger: Logger, cloudFrontSettings: CloudFrontSetting, config: Config, pushOptions: PushOptions):
31
+ PartialFunction[Throwable, InvalidationResult] = {
32
+ case e: TooManyInvalidationsInProgressException =>
33
+ val duration: Duration = Duration(
34
+ (fibs drop attempt).head min 15, /* CloudFront invalidations complete within 15 minutes */
35
+ cloudFrontSettings.retryTimeUnit
36
+ )
37
+ logger.pending(maxInvalidationsExceededInfo(duration, attempt))
38
+ Thread.sleep(duration.toMillis)
39
+ invalidate(invalidationBatch, distributionId, attempt + 1)
40
+ }
41
+
42
+ def maxInvalidationsExceededInfo(sleepDuration: Duration, attempt: Int) = {
43
+ val basicInfo = s"The maximum amount of CloudFront invalidations has exceeded. Trying again in $sleepDuration, please wait."
44
+ val extendedInfo =
45
+ s"""|$basicInfo
46
+ |For more information, see http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Invalidation.html#InvalidationLimits"""
47
+ .stripMargin
48
+ if (attempt == 1)
49
+ extendedInfo
50
+ else
51
+ basicInfo
52
+ }
53
+
54
+ def cloudFront(implicit config: Config, cloudFrontSettings: CloudFrontSetting) = cloudFrontSettings.cfClient(config)
55
+
56
+ type InvalidationResult = Future[Either[FailedInvalidation, SuccessfulInvalidation]]
57
+
58
+ type CloudFrontClientProvider = (Config) => AmazonCloudFront
59
+
60
+ case class SuccessfulInvalidation(invalidatedItemsCount: Int)(implicit pushOptions: PushOptions) extends SuccessReport {
61
+ def reportMessage = s"${Invalidated.renderVerb} ${invalidatedItemsCount ofType "item"} on CloudFront"
62
+ }
63
+
64
+ case class FailedInvalidation(error: Throwable)(implicit logger: Logger) extends ErrorReport {
65
+ def reportMessage = errorMessage(s"Failed to invalidate the CloudFront distribution", error)
66
+ }
67
+
68
+ def awsCloudFrontClient(config: Config) = new AmazonCloudFrontClient(awsCredentials(config))
69
+
70
+ def toInvalidationBatches(pushSuccessReports: Seq[PushSuccessReport])(implicit config: Config): Seq[InvalidationBatch] = {
71
+ def callerReference = s"s3_website gem ${System.currentTimeMillis()}"
72
+ if (config.cloudfront_wildcard_invalidation.contains(true) && pushSuccessReports.exists(needsInvalidation)) {
73
+ return Seq(new InvalidationBatch withPaths(new Paths withItems "/*" withQuantity 1) withCallerReference callerReference)
74
+ }
75
+ def defaultPath(paths: Seq[String]): Option[String] = {
76
+ // This is how we support the Default Root Object @ CloudFront (http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DefaultRootObject.html)
77
+ // We could do this more accurately by fetching the distribution config (http://docs.aws.amazon.com/AmazonCloudFront/latest/APIReference/GetConfig.html)
78
+ // and reading the Default Root Object from there.
79
+ val containsPotentialDefaultRootObject = paths
80
+ .exists(
81
+ _
82
+ .replaceFirst("^/", "") // S3 keys do not begin with a slash
83
+ .contains("/") == false // See if the S3 key is a top-level key (i.e., it is not within a directory)
84
+ )
85
+ if (containsPotentialDefaultRootObject) Some("/") else None
86
+ }
87
+ val indexPath = config.cloudfront_invalidate_root collect {
88
+ case true if pushSuccessReports.nonEmpty => config.s3_key_prefix.map(prefix => s"/$prefix").getOrElse("") + "/index.html"
89
+ }
90
+
91
+ val invalidationPaths: Seq[String] = {
92
+ val paths = pushSuccessReports
93
+ .filter(needsInvalidation)
94
+ .map(toInvalidationPath)
95
+ .map(encodeUnsafeChars)
96
+ .map(applyInvalidateRootSetting)
97
+
98
+ val extraPathItems = defaultPath(paths) :: indexPath :: Nil collect {
99
+ case Some(path) => path
100
+ }
101
+
102
+ paths ++ extraPathItems
103
+ }
104
+
105
+ invalidationPaths
106
+ .grouped(1000) // CloudFront supports max 1000 invalidations in one request (http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Invalidation.html#InvalidationLimits)
107
+ .map { batchKeys =>
108
+ new InvalidationBatch() withPaths
109
+ (new Paths() withItems batchKeys withQuantity batchKeys.size) withCallerReference callerReference
110
+ }
111
+ .toSeq
112
+ }
113
+
114
+ def applyInvalidateRootSetting(path: String)(implicit config: Config) =
115
+ if (config.cloudfront_invalidate_root.contains(true))
116
+ path.replaceFirst("/index.html$", "/")
117
+ else
118
+ path
119
+
120
+ def toInvalidationPath(report: PushSuccessReport) = "/" + report.s3Key
121
+
122
+ def encodeUnsafeChars(invalidationPath: String) =
123
+ new URI("http", "cloudfront", invalidationPath, "")
124
+ .toURL
125
+ .getPath
126
+ .replaceAll("'", URLEncoder.encode("'", "UTF-8")) // CloudFront does not accept ' in invalidation path
127
+ .flatMap(chr => {
128
+ if (("[^\\x00-\\x7F]".r findFirstIn chr.toString).isDefined)
129
+ URLEncoder.encode(chr.toString, "UTF-8")
130
+ else
131
+ chr.toString
132
+ })
133
+
134
+ def needsInvalidation: PartialFunction[PushSuccessReport, Boolean] = {
135
+ case succ: SuccessfulUpload => succ.details.fold(_.uploadType, _.uploadType) == FileUpdate
136
+ case SuccessfulDelete(_) => true
137
+ case _ => false
138
+ }
139
+
140
+ case class CloudFrontSetting(
141
+ cfClient: CloudFrontClientProvider = CloudFront.awsCloudFrontClient,
142
+ retryTimeUnit: TimeUnit = MINUTES
143
+ ) extends RetrySetting
144
+ }
@@ -0,0 +1,67 @@
1
+ package s3.website
2
+
3
+ class Logger(val verboseOutput: Boolean, onLog: Option[(String) => _] = None) {
4
+ def debug(msg: String) = if (verboseOutput) log(Debug, msg)
5
+ def info(msg: String) = log(Info, msg)
6
+ def fail(msg: String) = log(Failure, msg)
7
+ def warn(msg: String) = log(Warn, msg)
8
+
9
+ def info(report: SuccessReport) = log(Success, report.reportMessage)
10
+ def info(report: ErrorReport) = fail(report.reportMessage)
11
+
12
+ def pending(msg: String) = log(Wait, msg)
13
+
14
+ private def log(logType: LogType, msgRaw: String) = {
15
+ val msg = msgRaw.replaceAll("\\n", "\n ") // Indent new lines, so that they arrange nicely with other log lines
16
+ val decoratedLogMessage = s"[$logType] $msg"
17
+ onLog.foreach(_(decoratedLogMessage))
18
+ println(decoratedLogMessage)
19
+ }
20
+
21
+ sealed trait LogType {
22
+ val prefix: String
23
+ override def toString = prefix
24
+ }
25
+ case object Debug extends LogType {
26
+ val prefix = "debg".cyan
27
+ }
28
+ case object Info extends LogType {
29
+ val prefix = "info".blue
30
+ }
31
+ case object Success extends LogType {
32
+ val prefix = "succ".green
33
+ }
34
+ case object Failure extends LogType {
35
+ val prefix = "fail".red
36
+ }
37
+ case object Wait extends LogType {
38
+ val prefix = "wait".yellow
39
+ }
40
+ case object Warn extends LogType {
41
+ val prefix = "warn".yellow
42
+ }
43
+
44
+ /**
45
+ * Idea copied from https://github.com/ktoso/scala-rainbow.
46
+ */
47
+ implicit class RainbowString(val s: String) {
48
+ import Console._
49
+
50
+ /** Colorize the given string foreground to ANSI black */
51
+ def black = BLACK + s + RESET
52
+ /** Colorize the given string foreground to ANSI red */
53
+ def red = RED + s + RESET
54
+ /** Colorize the given string foreground to ANSI red */
55
+ def green = GREEN + s + RESET
56
+ /** Colorize the given string foreground to ANSI red */
57
+ def yellow = YELLOW + s + RESET
58
+ /** Colorize the given string foreground to ANSI red */
59
+ def blue = BLUE + s + RESET
60
+ /** Colorize the given string foreground to ANSI red */
61
+ def magenta = MAGENTA + s + RESET
62
+ /** Colorize the given string foreground to ANSI red */
63
+ def cyan = CYAN + s + RESET
64
+ /** Colorize the given string foreground to ANSI red */
65
+ def white = WHITE + s + RESET
66
+ }
67
+ }
@@ -0,0 +1,246 @@
1
+ package s3.website
2
+
3
+ import s3.website.model.Config.S3_website_yml
4
+ import s3.website.model.Redirect.{Redirects, resolveRedirects}
5
+ import s3.website.model.Site._
6
+ import scala.concurrent.{ExecutionContextExecutor, Future, Await}
7
+ import scala.concurrent.duration._
8
+ import scala.language.postfixOps
9
+ import s3.website.UploadHelper.{FutureUploads, resolveDeletes, resolveUploads}
10
+ import s3.website.S3._
11
+ import scala.concurrent.ExecutionContext.fromExecutor
12
+ import java.util.concurrent.Executors.newFixedThreadPool
13
+ import java.util.concurrent.ExecutorService
14
+ import s3.website.model._
15
+ import s3.website.model.FileUpdate
16
+ import s3.website.model.NewFile
17
+ import s3.website.S3.PushSuccessReport
18
+ import scala.collection.mutable.ArrayBuffer
19
+ import s3.website.CloudFront._
20
+ import s3.website.S3.SuccessfulDelete
21
+ import s3.website.CloudFront.SuccessfulInvalidation
22
+ import s3.website.S3.S3Setting
23
+ import s3.website.CloudFront.CloudFrontSetting
24
+ import s3.website.S3.SuccessfulUpload
25
+ import s3.website.CloudFront.FailedInvalidation
26
+ import java.io.File
27
+ import com.lexicalscope.jewel.cli.CliFactory.parseArguments
28
+ import s3.website.ByteHelper.humanReadableByteCount
29
+ import s3.website.S3.SuccessfulUpload.humanizeUploadSpeed
30
+
31
+ object Push {
32
+
33
+ def main(args: Array[String]) {
34
+ implicit val cliArgs = parseArguments(classOf[CliArgs], args:_*)
35
+ implicit val logger: Logger = new Logger(cliArgs.verbose)
36
+ implicit val s3Settings = S3Setting()
37
+ implicit val cloudFrontSettings = CloudFrontSetting()
38
+ implicit val workingDirectory = new File(System.getProperty("user.dir")).getAbsoluteFile
39
+
40
+ System exit push
41
+ }
42
+
43
+ trait CliArgs {
44
+ import com.lexicalscope.jewel.cli.Option
45
+
46
+ @Option(defaultToNull = true) def site: String
47
+ @Option(longName = Array("config-dir"), defaultToNull = true) def configDir: String
48
+ @Option def verbose: Boolean
49
+ @Option(longName = Array("dry-run")) def dryRun: Boolean
50
+ @Option(longName = Array("force")) def force: Boolean
51
+ }
52
+
53
+ def push(implicit cliArgs: CliArgs, s3Settings: S3Setting, cloudFrontSettings: CloudFrontSetting, workingDirectory: File, logger: Logger): ExitCode = {
54
+ implicit val pushOptions = new PushOptions {
55
+ def dryRun = cliArgs.dryRun
56
+ def force = cliArgs.force
57
+ }
58
+
59
+ implicit val yamlConfig = S3_website_yml(new File(Option(cliArgs.configDir).getOrElse(workingDirectory.getPath) + "/s3_website.yml"))
60
+
61
+ val errorOrPushStatus = for (
62
+ loadedSite <- loadSite.right
63
+ ) yield {
64
+ implicit val site = loadedSite
65
+ val threadPool = newFixedThreadPool(site.config.concurrency_level)
66
+ implicit val executor = fromExecutor(threadPool)
67
+ val pushStatus = pushSite
68
+ threadPool.shutdownNow()
69
+ pushStatus
70
+ }
71
+
72
+ errorOrPushStatus.left foreach (err => logger.fail(s"Could not load the site: ${err.reportMessage}"))
73
+ errorOrPushStatus fold((err: ErrorReport) => 1, pushStatus => pushStatus)
74
+ }
75
+
76
+ def pushSite(
77
+ implicit site: Site,
78
+ executor: ExecutionContextExecutor,
79
+ s3Settings: S3Setting,
80
+ cloudFrontSettings: CloudFrontSetting,
81
+ logger: Logger,
82
+ pushOptions: PushOptions
83
+ ): ExitCode = {
84
+ logger.info(s"${Deploy.renderVerb} ${site.rootDirectory}/* to ${site.config.s3_bucket}")
85
+ val s3FilesFuture = resolveS3Files()
86
+ val redirectsFuture = resolveRedirects(s3FilesFuture)
87
+ val redirectReports = redirectsFuture.map { errOrRedirects =>
88
+ errOrRedirects.right.map(_.filter(_.needsUpload).map(S3 uploadRedirect _))
89
+ }
90
+
91
+ val uploadReports = for {
92
+ errorOrUploads <- resolveUploads(s3FilesFuture)
93
+ } yield errorOrUploads.right.map(_.map(S3 uploadFile _))
94
+
95
+ val deleteReports = redirectsFuture flatMap { errOrRedirects =>
96
+ errOrRedirects.fold(
97
+ err => Future(Left(err)),
98
+ redirects => resolveDeletes(s3FilesFuture, redirects)
99
+ )
100
+ } map { deletes =>
101
+ deletes.right.map(keysToDelete => keysToDelete.map(S3 delete _))
102
+ }
103
+
104
+ val allReports = Future.sequence(redirectReports :: uploadReports :: deleteReports :: Nil) map { reports =>
105
+ reports.foldLeft(Nil: PushReports) { (memo, report: Either[ErrorReport, Seq[Future[PushErrorOrSuccess]]]) =>
106
+ report match {
107
+ case Left(err) =>
108
+ memo :+ Left(err)
109
+ case Right(pushResults: Seq[Future[PushErrorOrSuccess]]) =>
110
+ memo ++ pushResults.map(Right(_))
111
+ }
112
+ }
113
+ }
114
+ val finishedPushOps = awaitForResults(Await.result(allReports, 1 day))
115
+ val invalidationSucceeded = invalidateCloudFrontItems(finishedPushOps)
116
+
117
+ report(finishedPushOps, invalidationSucceeded)
118
+ }
119
+
120
+ def invalidateCloudFrontItems
121
+ (finishedPushOperations: FinishedPushOperations)
122
+ (implicit config: Config, cloudFrontSettings: CloudFrontSetting, ec: ExecutionContextExecutor, logger: Logger, pushOptions: PushOptions):
123
+ Option[InvalidationSucceeded] =
124
+ config.cloudfront_distribution_id.map { distributionId =>
125
+ val pushSuccessReports =
126
+ finishedPushOperations.map {
127
+ ops =>
128
+ for {
129
+ failedOrSucceededPushes <- ops.right
130
+ successfulPush <- failedOrSucceededPushes.right
131
+ } yield successfulPush
132
+ }.foldLeft(Seq(): Seq[PushSuccessReport]) {
133
+ (reports, failOrSucc) =>
134
+ failOrSucc.fold(
135
+ _ => reports,
136
+ (pushSuccessReport: PushSuccessReport) => reports :+ pushSuccessReport
137
+ )
138
+ }
139
+ val invalidationResults: Seq[Either[FailedInvalidation, SuccessfulInvalidation]] =
140
+ toInvalidationBatches(pushSuccessReports) map { invalidationBatch =>
141
+ Await.result(
142
+ CloudFront.invalidate(invalidationBatch, distributionId),
143
+ atMost = 1 day
144
+ )
145
+ }
146
+ if (invalidationResults.exists(_.isLeft))
147
+ false // If one of the invalidations failed, mark the whole process as failed
148
+ else
149
+ true
150
+ }
151
+
152
+ type InvalidationSucceeded = Boolean
153
+
154
+ def report(finishedPushOps: FinishedPushOperations, invalidationSucceeded: Option[Boolean])
155
+ (implicit config: Config, logger: Logger, pushOptions: PushOptions): ExitCode = {
156
+ val pushCounts = resolvePushCounts(finishedPushOps)
157
+ logger.info(s"Summary: ${pushCountsToString(pushCounts)}")
158
+ val pushOpExitCode = finishedPushOps.foldLeft(0) { (memo, finishedUpload) =>
159
+ memo + finishedUpload.fold(
160
+ (error: ErrorReport) => 1,
161
+ (failedOrSucceededUpload: Either[PushFailureReport, PushSuccessReport]) =>
162
+ if (failedOrSucceededUpload.isLeft) 1 else 0
163
+ )
164
+ } min 1
165
+ val cloudFrontInvalidationExitCode = invalidationSucceeded.fold(0)(allInvalidationsSucceeded =>
166
+ if (allInvalidationsSucceeded) 0 else 1
167
+ )
168
+
169
+ val exitCode = (pushOpExitCode + cloudFrontInvalidationExitCode) min 1
170
+
171
+ exitCode match {
172
+ case 0 if !pushOptions.dryRun && pushCounts.thereWasSomethingToPush =>
173
+ logger.info(s"Successfully pushed the website to http://${config.s3_bucket}.${config.s3_endpoint.s3WebsiteHostname}")
174
+ case 1 =>
175
+ logger.fail(s"Failed to push the website to http://${config.s3_bucket}.${config.s3_endpoint.s3WebsiteHostname}")
176
+ case _ =>
177
+ }
178
+ exitCode
179
+ }
180
+
181
+ def awaitForResults(uploadReports: PushReports)(implicit executor: ExecutionContextExecutor): FinishedPushOperations =
182
+ uploadReports map (_.right.map {
183
+ rep => Await.result(rep, 1 day)
184
+ })
185
+
186
+ def resolvePushCounts(implicit finishedOperations: FinishedPushOperations) = finishedOperations.foldLeft(PushCounts()) {
187
+ (counts: PushCounts, uploadReport) =>
188
+ uploadReport.fold(
189
+ (error: ErrorReport) => counts.copy(failures = counts.failures + 1),
190
+ failureOrSuccess => failureOrSuccess.fold(
191
+ (failureReport: PushFailureReport) => counts.copy(failures = counts.failures + 1),
192
+ (successReport: PushSuccessReport) =>
193
+ successReport match {
194
+ case succ: SuccessfulUpload => succ.details.fold(_.uploadType, _.uploadType) match {
195
+ case NewFile => counts.copy(newFiles = counts.newFiles + 1).addTransferStats(succ) // TODO nasty repetition here
196
+ case FileUpdate => counts.copy(updates = counts.updates + 1).addTransferStats(succ)
197
+ case RedirectFile => counts.copy(redirects = counts.redirects + 1).addTransferStats(succ)
198
+ }
199
+ case SuccessfulDelete(_) => counts.copy(deletes = counts.deletes + 1)
200
+ }
201
+ )
202
+ )
203
+ }
204
+
205
+ def pushCountsToString(pushCounts: PushCounts)(implicit pushOptions: PushOptions): String =
206
+ pushCounts match {
207
+ case PushCounts(updates, newFiles, failures, redirects, deletes, _, _)
208
+ if updates == 0 && newFiles == 0 && failures == 0 && redirects == 0 && deletes == 0 =>
209
+ PushNothing.renderVerb
210
+ case PushCounts(updates, newFiles, failures, redirects, deletes, uploadedBytes, uploadDurations) =>
211
+ val reportClauses: scala.collection.mutable.ArrayBuffer[String] = ArrayBuffer()
212
+ if (updates > 0) reportClauses += s"${Updated.renderVerb} ${updates ofType "file"}."
213
+ if (newFiles > 0) reportClauses += s"${Created.renderVerb} ${newFiles ofType "file"}."
214
+ if (failures > 0) reportClauses += s"${failures ofType "operation"} failed." // This includes both failed uploads and deletes.
215
+ if (redirects > 0) reportClauses += s"${Applied.renderVerb} ${redirects ofType "redirect"}."
216
+ if (deletes > 0) reportClauses += s"${Deleted.renderVerb} ${deletes ofType "file"}."
217
+ if (uploadedBytes > 0) {
218
+ val transferSuffix = humanizeUploadSpeed(uploadedBytes, uploadDurations: _*).fold(".")(speed => s", $speed.")
219
+ reportClauses += s"${Transferred.renderVerb} ${humanReadableByteCount(uploadedBytes)}$transferSuffix"
220
+ }
221
+ reportClauses.mkString(" ")
222
+ }
223
+
224
+ case class PushCounts(
225
+ updates: Int = 0,
226
+ newFiles: Int = 0,
227
+ failures: Int = 0,
228
+ redirects: Int = 0,
229
+ deletes: Int = 0,
230
+ uploadedBytes: Long = 0,
231
+ uploadDurations: Seq[UploadDuration] = Nil
232
+ ) {
233
+ val thereWasSomethingToPush = updates + newFiles + redirects + deletes > 0
234
+
235
+ def addTransferStats(successfulUpload: SuccessfulUpload): PushCounts =
236
+ copy(
237
+ uploadedBytes = uploadedBytes + (successfulUpload.uploadSize getOrElse 0L),
238
+ uploadDurations = uploadDurations ++ successfulUpload.details.fold(_.uploadDuration, _ => None)
239
+ )
240
+ }
241
+
242
+ type FinishedPushOperations = Seq[Either[ErrorReport, PushErrorOrSuccess]]
243
+ type PushReports = Seq[Either[ErrorReport, Future[PushErrorOrSuccess]]]
244
+ case class PushResult(threadPool: ExecutorService, uploadReports: PushReports)
245
+ type ExitCode = Int
246
+ }
@@ -0,0 +1,14 @@
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
+ val z = rubyRuntime.evalScriptlet(
8
+ s"""# encoding: utf-8
9
+ !!Regexp.new("$regex").match("$text")"""
10
+ )
11
+ z.toJava(classOf[Boolean]).asInstanceOf[Boolean]
12
+ }
13
+
14
+ }