s3_website_revived 4.0.0

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