s3_website_revived 4.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.travis.yml +5 -0
- data/Gemfile +3 -0
- data/LICENSE +42 -0
- data/README.md +591 -0
- data/Rakefile +2 -0
- data/additional-docs/debugging.md +21 -0
- data/additional-docs/development.md +29 -0
- data/additional-docs/example-configurations.md +113 -0
- data/additional-docs/running-from-ec2-with-dropbox.md +6 -0
- data/additional-docs/setting-up-aws-credentials.md +52 -0
- data/assembly.sbt +3 -0
- data/bin/s3_website +285 -0
- data/build.sbt +48 -0
- data/changelog.md +596 -0
- data/lib/s3_website/version.rb +3 -0
- data/lib/s3_website.rb +7 -0
- data/project/assembly.sbt +1 -0
- data/project/build.properties +1 -0
- data/project/plugins.sbt +1 -0
- data/release +41 -0
- data/resources/configuration_file_template.yml +67 -0
- data/resources/s3_website.jar.md5 +1 -0
- data/s3_website-4.0.0.jar +0 -0
- data/s3_website.gemspec +34 -0
- data/sbt +3 -0
- data/src/main/resources/log4j.properties +6 -0
- data/src/main/scala/s3/website/ByteHelper.scala +18 -0
- data/src/main/scala/s3/website/CloudFront.scala +144 -0
- data/src/main/scala/s3/website/Logger.scala +67 -0
- data/src/main/scala/s3/website/Push.scala +246 -0
- data/src/main/scala/s3/website/Ruby.scala +14 -0
- data/src/main/scala/s3/website/S3.scala +239 -0
- data/src/main/scala/s3/website/UploadHelper.scala +76 -0
- data/src/main/scala/s3/website/model/Config.scala +249 -0
- data/src/main/scala/s3/website/model/S3Endpoint.scala +35 -0
- data/src/main/scala/s3/website/model/Site.scala +159 -0
- data/src/main/scala/s3/website/model/push.scala +225 -0
- data/src/main/scala/s3/website/model/ssg.scala +30 -0
- data/src/main/scala/s3/website/package.scala +182 -0
- data/src/test/scala/s3/website/AwsSdkSpec.scala +15 -0
- data/src/test/scala/s3/website/ConfigSpec.scala +150 -0
- data/src/test/scala/s3/website/S3EndpointSpec.scala +15 -0
- data/src/test/scala/s3/website/S3WebsiteSpec.scala +1480 -0
- data/src/test/scala/s3/website/UnitTest.scala +11 -0
- data/vagrant/Vagrantfile +25 -0
- 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
|
data/s3_website.gemspec
ADDED
@@ -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,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
|
+
}
|