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.
- 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
|
+
}
|