s3_website_monadic 0.0.9 → 0.0.11
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 +4 -4
- data/s3_website.gemspec +1 -1
- data/src/main/scala/s3/website/CloudFront.scala +56 -56
- data/src/main/scala/s3/website/Diff.scala +1 -7
- data/src/main/scala/s3/website/Push.scala +18 -11
- data/src/main/scala/s3/website/S3.scala +49 -39
- data/src/main/scala/s3/website/Utils.scala +1 -0
- data/src/main/scala/s3/website/model/Config.scala +8 -8
- data/src/main/scala/s3/website/model/S3Endpoint.scala +2 -2
- data/src/main/scala/s3/website/model/errors.scala +1 -1
- data/src/main/scala/s3/website/model/push.scala +37 -24
- data/src/main/scala/s3/website/package.scala +31 -0
- data/src/test/scala/s3/website/S3WebsiteSpec.scala +69 -23
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA1:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 83042f3d9526d801a4c3ca1b843b8bbf3e03f02e
|
|
4
|
+
data.tar.gz: a5e5ac7ae8fcc22d519d8d3503d74734050ac415
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8625012df6b394782179cd5795f512f8dc6c99335eaa9d382b750b564acd0c697f9c4fbb652730499dc79d55fcecbb5be8d94c4f47c6afa9e80415b6bb3b6fda
|
|
7
|
+
data.tar.gz: 98d5ac27855f7b212ed644e7df6d2122bd7fba976206643fd398fe8275e157e50f3b44127180ecdfd36d2cb11b74ad5b38357715041e4ab8c24e4f0c61043ccb
|
data/s3_website.gemspec
CHANGED
|
@@ -1,54 +1,47 @@
|
|
|
1
1
|
package s3.website
|
|
2
2
|
|
|
3
|
-
import s3.website.model.{Redirect, Config}
|
|
3
|
+
import s3.website.model.{Update, Redirect, Config}
|
|
4
4
|
import com.amazonaws.services.cloudfront.{AmazonCloudFrontClient, AmazonCloudFront}
|
|
5
|
-
import s3.website.CloudFront.{CloudFrontSettings,
|
|
6
|
-
import scala.util.Try
|
|
5
|
+
import s3.website.CloudFront.{CloudFrontSettings, SuccessfulInvalidation, FailedInvalidation}
|
|
7
6
|
import com.amazonaws.services.cloudfront.model.{TooManyInvalidationsInProgressException, Paths, InvalidationBatch, CreateInvalidationRequest}
|
|
8
7
|
import scala.collection.JavaConversions._
|
|
9
8
|
import scala.concurrent.duration._
|
|
10
|
-
import s3.website.S3.PushSuccessReport
|
|
9
|
+
import s3.website.S3.{SuccessfulDelete, PushSuccessReport, SuccessfulUpload}
|
|
11
10
|
import com.amazonaws.auth.BasicAWSCredentials
|
|
12
11
|
import s3.website.Logger._
|
|
13
|
-
import s3.website.S3.SuccessfulUpload
|
|
14
|
-
import scala.util.Failure
|
|
15
|
-
import scala.util.Success
|
|
16
12
|
import java.net.URI
|
|
17
13
|
import Utils._
|
|
14
|
+
import scala.concurrent.{ExecutionContextExecutor, Future}
|
|
18
15
|
|
|
19
16
|
class CloudFront(implicit cloudFrontSettings: CloudFrontSettings, config: Config) {
|
|
20
17
|
val cloudFront = cloudFrontSettings.cfClient(config)
|
|
21
18
|
|
|
22
|
-
def invalidate(invalidationBatch: InvalidationBatch, distributionId: String
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
cloudFrontSettings.cloudFrontSleepTimeUnit
|
|
35
|
-
)
|
|
36
|
-
pending(maxInvalidationsExceededInfo)
|
|
37
|
-
Thread.sleep(duration.toMillis)
|
|
38
|
-
tryInvalidate(attempt + 1)
|
|
39
|
-
}
|
|
19
|
+
def invalidate(invalidationBatch: InvalidationBatch, distributionId: String, attempt: Attempt = 1)
|
|
20
|
+
(implicit ec: ExecutionContextExecutor): InvalidationResult =
|
|
21
|
+
Future {
|
|
22
|
+
val invalidationReq = new CreateInvalidationRequest(distributionId, invalidationBatch)
|
|
23
|
+
cloudFront.createInvalidation(invalidationReq)
|
|
24
|
+
val result = SuccessfulInvalidation(invalidationBatch.getPaths.getItems.size())
|
|
25
|
+
info(result)
|
|
26
|
+
Right(result)
|
|
27
|
+
} recoverWith (tooManyInvalidationsRetry(invalidationBatch, distributionId, attempt) orElse retry(attempt)(
|
|
28
|
+
createFailureReport = error => FailedInvalidation(error),
|
|
29
|
+
retryAction = nextAttempt => invalidate(invalidationBatch, distributionId, nextAttempt)
|
|
30
|
+
))
|
|
40
31
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
32
|
+
def tooManyInvalidationsRetry(invalidationBatch: InvalidationBatch, distributionId: String, attempt: Attempt)
|
|
33
|
+
(implicit ec: ExecutionContextExecutor): PartialFunction[Throwable, InvalidationResult] = {
|
|
34
|
+
case e: TooManyInvalidationsInProgressException =>
|
|
35
|
+
val duration: Duration = Duration(
|
|
36
|
+
(fibs drop attempt).head min 15, /* CloudFront invalidations complete within 15 minutes */
|
|
37
|
+
cloudFrontSettings.retryTimeUnit
|
|
38
|
+
)
|
|
39
|
+
pending(maxInvalidationsExceededInfo(duration, attempt))
|
|
40
|
+
Thread.sleep(duration.toMillis)
|
|
41
|
+
invalidate(invalidationBatch, distributionId, attempt + 1)
|
|
49
42
|
}
|
|
50
43
|
|
|
51
|
-
def maxInvalidationsExceededInfo(
|
|
44
|
+
def maxInvalidationsExceededInfo(sleepDuration: Duration, attempt: Int) = {
|
|
52
45
|
val basicInfo = s"The maximum amount of CloudFront invalidations has exceeded. Trying again in $sleepDuration, please wait."
|
|
53
46
|
val extendedInfo =
|
|
54
47
|
s"""|$basicInfo
|
|
@@ -60,8 +53,7 @@ class CloudFront(implicit cloudFrontSettings: CloudFrontSettings, config: Config
|
|
|
60
53
|
basicInfo
|
|
61
54
|
}
|
|
62
55
|
|
|
63
|
-
type InvalidationResult = Either[FailedInvalidation, SuccessfulInvalidation]
|
|
64
|
-
|
|
56
|
+
type InvalidationResult = Future[Either[FailedInvalidation, SuccessfulInvalidation]]
|
|
65
57
|
}
|
|
66
58
|
|
|
67
59
|
object CloudFront {
|
|
@@ -72,7 +64,7 @@ object CloudFront {
|
|
|
72
64
|
def reportMessage = s"Invalidated $invalidatedItemsCount item(s) on CloudFront"
|
|
73
65
|
}
|
|
74
66
|
|
|
75
|
-
case class FailedInvalidation(error: Throwable) extends FailureReport{
|
|
67
|
+
case class FailedInvalidation(error: Throwable) extends FailureReport {
|
|
76
68
|
def reportMessage = s"Failed to invalidate the CloudFront distribution (${error.getMessage})"
|
|
77
69
|
}
|
|
78
70
|
|
|
@@ -81,21 +73,9 @@ object CloudFront {
|
|
|
81
73
|
|
|
82
74
|
def toInvalidationBatches(pushSuccessReports: Seq[PushSuccessReport])(implicit config: Config): Seq[InvalidationBatch] =
|
|
83
75
|
pushSuccessReports
|
|
84
|
-
.
|
|
85
|
-
.map(
|
|
86
|
-
|
|
87
|
-
"http",
|
|
88
|
-
"cloudfront", // We want to use the encoder in the URI class. These must be passed in.
|
|
89
|
-
"/" + report.s3Key, // CloudFront keys have the slash in front
|
|
90
|
-
null
|
|
91
|
-
).toURL.getPath // The URL class encodes the unsafe characters
|
|
92
|
-
)
|
|
93
|
-
.map { path =>
|
|
94
|
-
if (config.cloudfront_invalidate_root.exists(_ == true))
|
|
95
|
-
path.replaceFirst("/index.html$", "/")
|
|
96
|
-
else
|
|
97
|
-
path
|
|
98
|
-
}
|
|
76
|
+
.filter(needsInvalidation) // Assume that redirect objects are never cached.
|
|
77
|
+
.map(toInvalidationPath)
|
|
78
|
+
.map (applyInvalidateRootSetting)
|
|
99
79
|
.grouped(1000) // CloudFront supports max 1000 invalidations in one request (http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Invalidation.html#InvalidationLimits)
|
|
100
80
|
.map { batchKeys =>
|
|
101
81
|
new InvalidationBatch() withPaths
|
|
@@ -104,16 +84,36 @@ object CloudFront {
|
|
|
104
84
|
}
|
|
105
85
|
.toSeq
|
|
106
86
|
|
|
107
|
-
def
|
|
87
|
+
def applyInvalidateRootSetting(path: String)(implicit config: Config) =
|
|
88
|
+
if (config.cloudfront_invalidate_root.exists(_ == true))
|
|
89
|
+
path.replaceFirst("/index.html$", "/")
|
|
90
|
+
else
|
|
91
|
+
path
|
|
92
|
+
|
|
93
|
+
def toInvalidationPath(report: PushSuccessReport) = {
|
|
94
|
+
def encodeUnsafeChars(path: String) =
|
|
95
|
+
new URI(
|
|
96
|
+
"http",
|
|
97
|
+
"cloudfront", // We want to use the encoder in the URI class. These must be passed in.
|
|
98
|
+
"/" + report.s3Key, // CloudFront keys have the slash in front
|
|
99
|
+
path
|
|
100
|
+
).toURL.getPath // The URL class encodes the unsafe characters
|
|
101
|
+
val invalidationPath = "/" + report.s3Key // CloudFront keys have the slash in front
|
|
102
|
+
encodeUnsafeChars(invalidationPath)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def needsInvalidation: PartialFunction[PushSuccessReport, Boolean] = {
|
|
108
107
|
case SuccessfulUpload(upload) => upload.uploadType match {
|
|
109
|
-
case
|
|
108
|
+
case Update => true
|
|
110
109
|
case _ => false
|
|
111
110
|
}
|
|
111
|
+
case SuccessfulDelete(_) => true
|
|
112
112
|
case _ => false
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
case class CloudFrontSettings(
|
|
116
116
|
cfClient: CloudFrontClientProvider = CloudFront.awsCloudFrontClient,
|
|
117
|
-
|
|
118
|
-
)
|
|
117
|
+
retryTimeUnit: TimeUnit = MINUTES
|
|
118
|
+
) extends RetrySettings
|
|
119
119
|
}
|
|
@@ -17,26 +17,20 @@ object Diff {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
def
|
|
20
|
+
def resolveNewFiles(localFiles: Seq[LocalFile], s3Files: Seq[S3File])(implicit config: Config):
|
|
21
21
|
Stream[Either[ErrorReport, Upload with UploadTypeResolved]] = {
|
|
22
22
|
val remoteS3KeysIndex = s3Files.map(_.s3Key).toSet
|
|
23
|
-
val remoteMd5Index = s3Files.map(_.md5).toSet
|
|
24
23
|
localFiles
|
|
25
24
|
.toStream // Load lazily, because the MD5 computation for the local file requires us to read the whole file
|
|
26
25
|
.map(resolveUploadSource)
|
|
27
26
|
.collect {
|
|
28
27
|
case errorOrUpload if errorOrUpload.right.exists(isNewUpload(remoteS3KeysIndex)) =>
|
|
29
28
|
for (upload <- errorOrUpload.right) yield upload withUploadType NewFile
|
|
30
|
-
case errorOrUpload if errorOrUpload.right.exists(isUpdate(remoteS3KeysIndex, remoteMd5Index)) =>
|
|
31
|
-
for (upload <- errorOrUpload.right) yield upload withUploadType Update
|
|
32
29
|
}
|
|
33
30
|
}
|
|
34
31
|
|
|
35
32
|
def isNewUpload(remoteS3KeysIndex: Set[String])(u: Upload) = !remoteS3KeysIndex.exists(_ == u.s3Key)
|
|
36
33
|
|
|
37
|
-
def isUpdate(remoteS3KeysIndex: Set[String], remoteMd5Index: Set[String])(u: Upload) =
|
|
38
|
-
remoteS3KeysIndex.exists(_ == u.s3Key) && !remoteMd5Index.exists(remoteMd5 => u.essence.right.exists(_.md5 == remoteMd5))
|
|
39
|
-
|
|
40
34
|
def resolveUploadSource(localFile: LocalFile)(implicit config: Config): Either[ErrorReport, Upload] =
|
|
41
35
|
for (upload <- LocalFile.toUpload(localFile).right)
|
|
42
36
|
yield upload
|
|
@@ -5,7 +5,7 @@ import scala.concurrent.{ExecutionContextExecutor, Future, Await}
|
|
|
5
5
|
import scala.concurrent.duration._
|
|
6
6
|
import com.lexicalscope.jewel.cli.CliFactory
|
|
7
7
|
import scala.language.postfixOps
|
|
8
|
-
import s3.website.Diff.{
|
|
8
|
+
import s3.website.Diff.{resolveNewFiles, resolveDeletes}
|
|
9
9
|
import s3.website.S3._
|
|
10
10
|
import scala.concurrent.ExecutionContext.fromExecutor
|
|
11
11
|
import java.util.concurrent.Executors.newFixedThreadPool
|
|
@@ -39,18 +39,20 @@ object Push {
|
|
|
39
39
|
val utils: Utils = new Utils
|
|
40
40
|
|
|
41
41
|
val redirects = Redirect.resolveRedirects
|
|
42
|
-
val redirectResults = redirects.map(new S3() upload)
|
|
42
|
+
val redirectResults = redirects.map(new S3() upload(_))
|
|
43
43
|
|
|
44
44
|
val errorsOrReports = for {
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
localFiles <- resolveLocalFiles.right
|
|
46
|
+
errorOrS3FilesAndUpdateFutures <- Await.result(resolveS3FilesAndUpdates(localFiles)(), 7 days).right
|
|
47
|
+
s3Files <- errorOrS3FilesAndUpdateFutures._1.right
|
|
47
48
|
} yield {
|
|
49
|
+
val updateReports: PushReports = errorOrS3FilesAndUpdateFutures._2.par
|
|
48
50
|
val deleteReports: PushReports = utils toParSeq resolveDeletes(localFiles, s3Files, redirects)
|
|
49
51
|
.map { s3File => new S3() delete s3File.s3Key }
|
|
50
52
|
.map { Right(_) } // To make delete reports type-compatible with upload reports
|
|
51
|
-
val uploadReports: PushReports = utils toParSeq
|
|
52
|
-
.map { _.right.map(new S3() upload) }
|
|
53
|
-
uploadReports ++ deleteReports ++ redirectResults.map(Right(_))
|
|
53
|
+
val uploadReports: PushReports = utils toParSeq resolveNewFiles(localFiles, s3Files)
|
|
54
|
+
.map { _.right.map(new S3() upload(_)) }
|
|
55
|
+
uploadReports ++ deleteReports ++ updateReports ++ redirectResults.map(Right(_))
|
|
54
56
|
}
|
|
55
57
|
val errorsOrFinishedPushOps: Either[ErrorReport, FinishedPushOperations] = errorsOrReports.right map {
|
|
56
58
|
uploadReports => awaitForUploads(uploadReports)
|
|
@@ -62,7 +64,7 @@ object Push {
|
|
|
62
64
|
|
|
63
65
|
def invalidateCloudFrontItems
|
|
64
66
|
(errorsOrFinishedPushOps: Either[ErrorReport, FinishedPushOperations])
|
|
65
|
-
(implicit config: Config, cloudFrontSettings: CloudFrontSettings): Option[InvalidationSucceeded] = {
|
|
67
|
+
(implicit config: Config, cloudFrontSettings: CloudFrontSettings, ec: ExecutionContextExecutor): Option[InvalidationSucceeded] = {
|
|
66
68
|
config.cloudfront_distribution_id.map {
|
|
67
69
|
distributionId =>
|
|
68
70
|
val pushSuccessReports = errorsOrFinishedPushOps.fold(
|
|
@@ -84,7 +86,12 @@ object Push {
|
|
|
84
86
|
}
|
|
85
87
|
)
|
|
86
88
|
val invalidationResults: Seq[Either[FailedInvalidation, SuccessfulInvalidation]] =
|
|
87
|
-
toInvalidationBatches(pushSuccessReports) map
|
|
89
|
+
toInvalidationBatches(pushSuccessReports) map { invalidationBatch =>
|
|
90
|
+
Await.result(
|
|
91
|
+
new CloudFront().invalidate(invalidationBatch, distributionId),
|
|
92
|
+
atMost = 1 day
|
|
93
|
+
)
|
|
94
|
+
}
|
|
88
95
|
if (invalidationResults.exists(_.isLeft))
|
|
89
96
|
false // If one of the invalidations failed, mark the whole process as failed
|
|
90
97
|
else
|
|
@@ -164,8 +171,8 @@ object Push {
|
|
|
164
171
|
redirects: Int = 0,
|
|
165
172
|
deletes: Int = 0
|
|
166
173
|
)
|
|
167
|
-
type FinishedPushOperations = ParSeq[Either[ErrorReport,
|
|
168
|
-
type PushReports = ParSeq[Either[ErrorReport, Future[
|
|
174
|
+
type FinishedPushOperations = ParSeq[Either[ErrorReport, PushErrorOrSuccess]]
|
|
175
|
+
type PushReports = ParSeq[Either[ErrorReport, Future[PushErrorOrSuccess]]]
|
|
169
176
|
case class PushResult(threadPool: ExecutorService, uploadReports: PushReports)
|
|
170
177
|
type ExitCode = Int
|
|
171
178
|
|
|
@@ -6,11 +6,9 @@ import com.amazonaws.auth.BasicAWSCredentials
|
|
|
6
6
|
import com.amazonaws.services.s3.model._
|
|
7
7
|
import scala.collection.JavaConversions._
|
|
8
8
|
import scala.concurrent.{ExecutionContextExecutor, Future}
|
|
9
|
-
import s3.website.S3._
|
|
10
9
|
import com.amazonaws.services.s3.model.StorageClass.ReducedRedundancy
|
|
11
10
|
import s3.website.Logger._
|
|
12
|
-
import
|
|
13
|
-
import scala.concurrent.duration.{Duration, TimeUnit}
|
|
11
|
+
import scala.concurrent.duration.TimeUnit
|
|
14
12
|
import java.util.concurrent.TimeUnit.SECONDS
|
|
15
13
|
import s3.website.S3.SuccessfulUpload
|
|
16
14
|
import s3.website.S3.SuccessfulDelete
|
|
@@ -18,30 +16,29 @@ import s3.website.S3.FailedUpload
|
|
|
18
16
|
import scala.Some
|
|
19
17
|
import s3.website.S3.FailedDelete
|
|
20
18
|
import s3.website.S3.S3Settings
|
|
21
|
-
import s3.website.model.Error.isClientError
|
|
22
19
|
|
|
23
20
|
class S3(implicit s3Settings: S3Settings, executor: ExecutionContextExecutor) {
|
|
24
21
|
|
|
25
|
-
def upload(upload: Upload with UploadTypeResolved
|
|
22
|
+
def upload(upload: Upload with UploadTypeResolved, a: Attempt = 1)(implicit config: Config): Future[Either[FailedUpload, SuccessfulUpload]] =
|
|
26
23
|
Future {
|
|
27
24
|
s3Settings.s3Client(config) putObject toPutObjectRequest(upload)
|
|
28
25
|
val report = SuccessfulUpload(upload)
|
|
29
26
|
info(report)
|
|
30
27
|
Right(report)
|
|
31
|
-
} recoverWith retry(
|
|
28
|
+
} recoverWith retry(a)(
|
|
32
29
|
createFailureReport = error => FailedUpload(upload.s3Key, error),
|
|
33
|
-
retryAction = newAttempt => this.upload(upload
|
|
30
|
+
retryAction = newAttempt => this.upload(upload, newAttempt)
|
|
34
31
|
)
|
|
35
32
|
|
|
36
|
-
def delete(s3Key: String
|
|
33
|
+
def delete(s3Key: String, a: Attempt = 1)(implicit config: Config): Future[Either[FailedDelete, SuccessfulDelete]] =
|
|
37
34
|
Future {
|
|
38
35
|
s3Settings.s3Client(config) deleteObject(config.s3_bucket, s3Key)
|
|
39
36
|
val report = SuccessfulDelete(s3Key)
|
|
40
37
|
info(report)
|
|
41
38
|
Right(report)
|
|
42
|
-
} recoverWith retry(
|
|
39
|
+
} recoverWith retry(a)(
|
|
43
40
|
createFailureReport = error => FailedDelete(s3Key, error),
|
|
44
|
-
retryAction = newAttempt => this.delete(s3Key
|
|
41
|
+
retryAction = newAttempt => this.delete(s3Key, newAttempt)
|
|
45
42
|
)
|
|
46
43
|
|
|
47
44
|
def toPutObjectRequest(upload: Upload)(implicit config: Config) =
|
|
@@ -83,49 +80,62 @@ class S3(implicit s3Settings: S3Settings, executor: ExecutionContextExecutor) {
|
|
|
83
80
|
object S3 {
|
|
84
81
|
def awsS3Client(config: Config) = new AmazonS3Client(new BasicAWSCredentials(config.s3_id, config.s3_secret))
|
|
85
82
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
def resolveS3FilesAndUpdates(localFiles: Seq[LocalFile])
|
|
84
|
+
(nextMarker: Option[String] = None, alreadyResolved: Seq[S3File] = Nil, attempt: Attempt = 1, onFlightUpdateFutures: UpdateFutures = Nil)
|
|
85
|
+
(implicit config: Config, s3Settings: S3Settings, ec: ExecutionContextExecutor):
|
|
86
|
+
ErrorOrS3FilesAndUpdates = Future {
|
|
87
|
+
debug(nextMarker.fold
|
|
88
|
+
("Querying S3 files")
|
|
89
|
+
{m => s"Querying more S3 files (starting from $m)"}
|
|
90
|
+
)
|
|
89
91
|
val objects: ObjectListing = s3Settings.s3Client(config).listObjects({
|
|
90
92
|
val req = new ListObjectsRequest()
|
|
91
93
|
req.setBucketName(config.s3_bucket)
|
|
92
94
|
nextMarker.foreach(req.setMarker)
|
|
93
95
|
req
|
|
94
96
|
})
|
|
95
|
-
objects
|
|
96
|
-
|
|
97
|
+
val summaryIndex = objects.getObjectSummaries.map { summary => (summary.getETag, summary.getKey) }.toSet // Index to avoid O(n^2) lookups
|
|
98
|
+
def isUpdate(lf: LocalFile) =
|
|
99
|
+
summaryIndex.exists((md5AndS3Key) =>
|
|
100
|
+
md5AndS3Key._1 != lf.md5 && md5AndS3Key._2 == lf.s3Key
|
|
101
|
+
)
|
|
102
|
+
val updateFutures: UpdateFutures = localFiles.collect {
|
|
103
|
+
case lf: LocalFile if isUpdate(lf) =>
|
|
104
|
+
val errorOrUpdate = LocalFile
|
|
105
|
+
.toUpload(lf)
|
|
106
|
+
.right
|
|
107
|
+
.map { (upload: Upload) =>
|
|
108
|
+
upload.withUploadType(Update)
|
|
109
|
+
}
|
|
110
|
+
errorOrUpdate.right.map(update => new S3 upload update)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
(objects, onFlightUpdateFutures ++ updateFutures)
|
|
114
|
+
} flatMap { (objectsAndUpdateFutures) =>
|
|
115
|
+
val objects: ObjectListing = objectsAndUpdateFutures._1
|
|
116
|
+
val updateFutures: UpdateFutures = objectsAndUpdateFutures._2
|
|
97
117
|
val s3Files = alreadyResolved ++ (objects.getObjectSummaries.toIndexedSeq.toSeq map (S3File(_)))
|
|
98
118
|
Option(objects.getNextMarker)
|
|
99
|
-
.fold(Future(Right(s3Files)):
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
.fold(Future(Right((Right(s3Files), updateFutures))): ErrorOrS3FilesAndUpdates) // We've received all the S3 keys from the bucket
|
|
120
|
+
{ nextMarker => // There are more S3 keys on the bucket. Fetch them.
|
|
121
|
+
resolveS3FilesAndUpdates(localFiles)(Some(nextMarker), s3Files, attempt = attempt, updateFutures)
|
|
122
|
+
}
|
|
123
|
+
} recoverWith retry(attempt)(
|
|
124
|
+
createFailureReport = error => ClientError(s"Failed to fetch an object listing (${error.getMessage})"),
|
|
125
|
+
retryAction = nextAttempt => resolveS3FilesAndUpdates(localFiles)(nextMarker, alreadyResolved, nextAttempt, onFlightUpdateFutures)
|
|
103
126
|
)
|
|
104
127
|
|
|
105
|
-
type
|
|
128
|
+
type S3FilesAndUpdates = (ErrorOrS3Files, UpdateFutures)
|
|
129
|
+
type S3FilesAndUpdatesFuture = Future[S3FilesAndUpdates]
|
|
130
|
+
type ErrorOrS3FilesAndUpdates = Future[Either[ErrorReport, S3FilesAndUpdates]]
|
|
131
|
+
type UpdateFutures = Seq[Either[ErrorReport, Future[PushErrorOrSuccess]]]
|
|
132
|
+
type ErrorOrS3Files = Either[ErrorReport, Seq[S3File]]
|
|
106
133
|
|
|
107
134
|
sealed trait PushFailureReport extends FailureReport
|
|
108
135
|
sealed trait PushSuccessReport extends SuccessReport {
|
|
109
136
|
def s3Key: String
|
|
110
137
|
}
|
|
111
138
|
|
|
112
|
-
def retry[L <: Report, R](createFailureReport: (Throwable) => L, retryAction: (Attempt) => Future[Either[L, R]])
|
|
113
|
-
(implicit attempt: Attempt, s3Settings: S3Settings, ec: ExecutionContextExecutor):
|
|
114
|
-
PartialFunction[Throwable, Future[Either[L, R]]] = {
|
|
115
|
-
case error: Throwable if attempt == 6 || isClientError(error) =>
|
|
116
|
-
val failureReport = createFailureReport(error)
|
|
117
|
-
fail(failureReport.reportMessage)
|
|
118
|
-
Future(Left(failureReport))
|
|
119
|
-
case error: Throwable =>
|
|
120
|
-
val failureReport = createFailureReport(error)
|
|
121
|
-
val sleepDuration = Duration(fibs.drop(attempt + 1).head, s3Settings.retrySleepTimeUnit)
|
|
122
|
-
pending(s"${failureReport.reportMessage}. Trying again in $sleepDuration.")
|
|
123
|
-
Thread.sleep(sleepDuration.toMillis)
|
|
124
|
-
retryAction(attempt + 1)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
type Attempt = Int
|
|
128
|
-
|
|
129
139
|
case class SuccessfulUpload(upload: Upload with UploadTypeResolved) extends PushSuccessReport {
|
|
130
140
|
def reportMessage =
|
|
131
141
|
upload.uploadType match {
|
|
@@ -153,6 +163,6 @@ object S3 {
|
|
|
153
163
|
|
|
154
164
|
case class S3Settings(
|
|
155
165
|
s3Client: S3ClientProvider = S3.awsS3Client,
|
|
156
|
-
|
|
157
|
-
)
|
|
166
|
+
retryTimeUnit: TimeUnit = SECONDS
|
|
167
|
+
) extends RetrySettings
|
|
158
168
|
}
|
|
@@ -33,7 +33,7 @@ object Config {
|
|
|
33
33
|
})
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
yamlValue getOrElse Left(
|
|
36
|
+
yamlValue getOrElse Left(ClientError(s"The key $key has to have a boolean or [string] value"))
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
def loadOptionalStringOrStringSeq(key: String)(implicit unsafeYaml: UnsafeYaml): Either[ErrorReport, Option[Either[String, Seq[String]]]] = {
|
|
@@ -46,7 +46,7 @@ object Config {
|
|
|
46
46
|
})
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
yamlValue getOrElse Left(
|
|
49
|
+
yamlValue getOrElse Left(ClientError(s"The key $key has to have a string or [string] value"))
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
def loadMaxAge(implicit unsafeYaml: UnsafeYaml): Either[ErrorReport, Option[Either[Int, Map[String, Int]]]] = {
|
|
@@ -60,7 +60,7 @@ object Config {
|
|
|
60
60
|
})
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
yamlValue getOrElse Left(
|
|
63
|
+
yamlValue getOrElse Left(ClientError(s"The key $key has to have an int or (string -> int) value"))
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
def loadEndpoint(implicit unsafeYaml: UnsafeYaml): Either[ErrorReport, Option[S3Endpoint]] =
|
|
@@ -79,7 +79,7 @@ object Config {
|
|
|
79
79
|
redirects <- Try(redirectsOption.map(_.asInstanceOf[java.util.Map[String,String]].toMap))
|
|
80
80
|
} yield Right(redirects)
|
|
81
81
|
|
|
82
|
-
yamlValue getOrElse Left(
|
|
82
|
+
yamlValue getOrElse Left(ClientError(s"The key $key has to have a (string -> string) value"))
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
def loadRequiredString(key: String)(implicit unsafeYaml: UnsafeYaml): Either[ErrorReport, String] = {
|
|
@@ -91,7 +91,7 @@ object Config {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
yamlValue getOrElse {
|
|
94
|
-
Left(
|
|
94
|
+
Left(ClientError(s"The key $key has to have a string value"))
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
@@ -104,7 +104,7 @@ object Config {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
yamlValueOption getOrElse {
|
|
107
|
-
Left(
|
|
107
|
+
Left(ClientError(s"The key $key has to have a string value"))
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
@@ -117,7 +117,7 @@ object Config {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
yamlValueOption getOrElse {
|
|
120
|
-
Left(
|
|
120
|
+
Left(ClientError(s"The key $key has to have a boolean value"))
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
@@ -130,7 +130,7 @@ object Config {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
yamlValueOption getOrElse {
|
|
133
|
-
Left(
|
|
133
|
+
Left(ClientError(s"The key $key has to have an integer value"))
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
@@ -8,7 +8,7 @@ case class S3Endpoint(
|
|
|
8
8
|
object S3Endpoint {
|
|
9
9
|
val defaultEndpoint = S3Endpoint("s3-website-us-east-1.amazonaws.com", "s3.amazonaws.com")
|
|
10
10
|
|
|
11
|
-
def forString(locationConstraint: String): Either[
|
|
11
|
+
def forString(locationConstraint: String): Either[ClientError, S3Endpoint] = locationConstraint match {
|
|
12
12
|
case "EU" | "eu-west-1" => Right(S3Endpoint("s3-website-eu-west-1.amazonaws.com", "s3-eu-west-1.amazonaws.com"))
|
|
13
13
|
case "us-east-1" => Right(defaultEndpoint)
|
|
14
14
|
case "us-west-1" => Right(S3Endpoint("s3-website-us-west-1.amazonaws.com", "s3-us-west-1.amazonaws.com"))
|
|
@@ -17,6 +17,6 @@ object S3Endpoint {
|
|
|
17
17
|
case "ap-southeast-2" => Right(S3Endpoint("s3-website-ap-southeast-2.amazonaws.com", "s3-ap-southeast-2.amazonaws.com"))
|
|
18
18
|
case "ap-northeast-1" => Right(S3Endpoint("s3-website-ap-northeast-1.amazonaws.com", "s3-ap-northeast-1.amazonaws.com"))
|
|
19
19
|
case "sa-east-1" => Right(S3Endpoint("s3-website-sa-east-1.amazonaws.com", "s3-sa-east-1.amazonaws.com"))
|
|
20
|
-
case _ => Left(
|
|
20
|
+
case _ => Left(ClientError(s"Unrecognised endpoint: $locationConstraint"))
|
|
21
21
|
}
|
|
22
22
|
}
|
|
@@ -9,7 +9,7 @@ object Error {
|
|
|
9
9
|
error.isInstanceOf[AmazonServiceException] && error.asInstanceOf[AmazonServiceException].getErrorType == Client
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
case class
|
|
12
|
+
case class ClientError(reportMessage: String) extends ErrorReport
|
|
13
13
|
|
|
14
14
|
case class IOError(exception: Throwable) extends ErrorReport {
|
|
15
15
|
def reportMessage = exception.getMessage
|
|
@@ -55,38 +55,51 @@ case object Update extends UploadType
|
|
|
55
55
|
|
|
56
56
|
case class LocalFile(
|
|
57
57
|
s3Key: String,
|
|
58
|
-
|
|
58
|
+
originalFile: File,
|
|
59
59
|
encodingOnS3: Option[Either[Gzip, Zopfli]]
|
|
60
|
-
) extends S3KeyProvider
|
|
60
|
+
) extends S3KeyProvider {
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
val md5 = using(fis(sourceFile)) { inputStream =>
|
|
77
|
-
DigestUtils.md5Hex(inputStream)
|
|
62
|
+
// May throw an exception, so remember to call this in a Try or Future monad
|
|
63
|
+
lazy val length = uploadFile.length()
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* This is the file we should upload, because it contains the potentially gzipped contents of the original file.
|
|
67
|
+
*
|
|
68
|
+
* May throw an exception, so remember to call this in a Try or Future monad
|
|
69
|
+
*/
|
|
70
|
+
lazy val uploadFile: File = encodingOnS3
|
|
71
|
+
.fold(originalFile)(algorithm => {
|
|
72
|
+
val tempFile = File.createTempFile(originalFile.getName, "gzip")
|
|
73
|
+
tempFile.deleteOnExit()
|
|
74
|
+
using(new GZIPOutputStream(new FileOutputStream(tempFile))) { stream =>
|
|
75
|
+
IOUtils.copy(fis(originalFile), stream)
|
|
78
76
|
}
|
|
77
|
+
tempFile
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* May throw an exception, so remember to call this in a Try or Future monad
|
|
82
|
+
*/
|
|
83
|
+
lazy val md5 = using(fis(uploadFile)) { inputStream =>
|
|
84
|
+
DigestUtils.md5Hex(inputStream)
|
|
85
|
+
}
|
|
79
86
|
|
|
87
|
+
private[this] def fis(file: File): InputStream = new FileInputStream(file)
|
|
88
|
+
private[this] def using[T <: Closeable, R](cl: T)(f: (T) => R): R = try f(cl) finally cl.close()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
object LocalFile {
|
|
92
|
+
def toUpload(localFile: LocalFile)(implicit config: Config): Either[ErrorReport, Upload] = Try {
|
|
80
93
|
Upload(
|
|
81
94
|
s3Key = localFile.s3Key,
|
|
82
95
|
essence = Right(
|
|
83
96
|
UploadBody(
|
|
84
|
-
md5 = md5,
|
|
97
|
+
md5 = localFile.md5,
|
|
85
98
|
contentEncoding = localFile.encodingOnS3.map(_ => "gzip"),
|
|
86
|
-
contentLength =
|
|
99
|
+
contentLength = localFile.length,
|
|
87
100
|
maxAge = resolveMaxAge(localFile),
|
|
88
|
-
contentType = resolveContentType(localFile.
|
|
89
|
-
openInputStream = () => new FileInputStream(
|
|
101
|
+
contentType = resolveContentType(localFile.originalFile),
|
|
102
|
+
openInputStream = () => new FileInputStream(localFile.uploadFile)
|
|
90
103
|
)
|
|
91
104
|
)
|
|
92
105
|
)
|
|
@@ -109,7 +122,7 @@ object LocalFile {
|
|
|
109
122
|
type GlobsMap = Map[String, Int]
|
|
110
123
|
config.max_age.flatMap { (intOrGlobs: Either[Int, GlobsMap]) =>
|
|
111
124
|
type GlobsSeq = Seq[(String, Int)]
|
|
112
|
-
def respectMostSpecific(globs: GlobsMap): GlobsSeq = globs.toSeq.sortBy(_._1).reverse
|
|
125
|
+
def respectMostSpecific(globs: GlobsMap): GlobsSeq = globs.toSeq.sortBy(_._1.length).reverse
|
|
113
126
|
intOrGlobs
|
|
114
127
|
.right.map(respectMostSpecific)
|
|
115
128
|
.fold(
|
|
@@ -135,7 +148,7 @@ object LocalFile {
|
|
|
135
148
|
(exclusionRegex: String) => rubyRegexMatches(file.s3Key, exclusionRegex),
|
|
136
149
|
(exclusionRegexes: Seq[String]) => exclusionRegexes exists (rubyRegexMatches(file.s3Key, _))
|
|
137
150
|
) }
|
|
138
|
-
} filterNot { _.
|
|
151
|
+
} filterNot { _.originalFile.getName == "s3_website.yml" } // For security reasons, the s3_website.yml should never be pushed
|
|
139
152
|
} match {
|
|
140
153
|
case Success(localFiles) =>
|
|
141
154
|
Right(
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
package s3
|
|
2
2
|
|
|
3
|
+
import scala.concurrent.{ExecutionContextExecutor, Future}
|
|
4
|
+
import s3.website.model.Error._
|
|
5
|
+
import s3.website.Logger._
|
|
6
|
+
import scala.concurrent.duration.{TimeUnit, Duration}
|
|
7
|
+
import s3.website.Utils._
|
|
8
|
+
import s3.website.S3.{PushSuccessReport, PushFailureReport}
|
|
9
|
+
|
|
3
10
|
package object website {
|
|
4
11
|
trait Report {
|
|
5
12
|
def reportMessage: String
|
|
@@ -9,4 +16,28 @@ package object website {
|
|
|
9
16
|
trait FailureReport extends Report
|
|
10
17
|
|
|
11
18
|
trait ErrorReport extends Report
|
|
19
|
+
|
|
20
|
+
trait RetrySettings {
|
|
21
|
+
def retryTimeUnit: TimeUnit
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type PushErrorOrSuccess = Either[PushFailureReport, PushSuccessReport]
|
|
25
|
+
|
|
26
|
+
type Attempt = Int
|
|
27
|
+
|
|
28
|
+
def retry[L <: Report, R](attempt: Attempt)
|
|
29
|
+
(createFailureReport: (Throwable) => L, retryAction: (Attempt) => Future[Either[L, R]])
|
|
30
|
+
(implicit retrySettings: RetrySettings, ec: ExecutionContextExecutor):
|
|
31
|
+
PartialFunction[Throwable, Future[Either[L, R]]] = {
|
|
32
|
+
case error: Throwable if attempt == 6 || isClientError(error) =>
|
|
33
|
+
val failureReport = createFailureReport(error)
|
|
34
|
+
fail(failureReport.reportMessage)
|
|
35
|
+
Future(Left(failureReport))
|
|
36
|
+
case error: Throwable =>
|
|
37
|
+
val failureReport = createFailureReport(error)
|
|
38
|
+
val sleepDuration = Duration(fibs.drop(attempt + 1).head, retrySettings.retryTimeUnit)
|
|
39
|
+
pending(s"${failureReport.reportMessage}. Trying again in $sleepDuration.")
|
|
40
|
+
Thread.sleep(sleepDuration.toMillis)
|
|
41
|
+
retryAction(attempt + 1)
|
|
42
|
+
}
|
|
12
43
|
}
|
|
@@ -27,6 +27,7 @@ import org.mockito.stubbing.Answer
|
|
|
27
27
|
import org.mockito.invocation.InvocationOnMock
|
|
28
28
|
import com.amazonaws.AmazonServiceException.ErrorType
|
|
29
29
|
import java.util.concurrent.atomic.AtomicInteger
|
|
30
|
+
import scala.collection.immutable.IndexedSeq
|
|
30
31
|
|
|
31
32
|
class S3WebsiteSpec extends Specification {
|
|
32
33
|
|
|
@@ -132,15 +133,25 @@ class S3WebsiteSpec extends Specification {
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
"push with CloudFront" should {
|
|
135
|
-
"invalidate the CloudFront items" in new SiteDirectory with MockAWS {
|
|
136
|
+
"invalidate the updated CloudFront items" in new SiteDirectory with MockAWS {
|
|
136
137
|
implicit val site = siteWithFiles(
|
|
137
138
|
config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z")),
|
|
138
139
|
localFiles = "test.css" :: "articles/index.html" :: Nil
|
|
139
140
|
)
|
|
141
|
+
setOutdatedS3Keys("test.css", "articles/index.html")
|
|
140
142
|
Push.pushSite
|
|
141
143
|
sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/test.css" :: "/articles/index.html" :: Nil).sorted)
|
|
142
144
|
}
|
|
143
145
|
|
|
146
|
+
"not send CloudFront invalidation requests on new objects" in new SiteDirectory with MockAWS {
|
|
147
|
+
implicit val site = siteWithFiles(
|
|
148
|
+
config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z")),
|
|
149
|
+
localFiles = "newfile.js" :: Nil
|
|
150
|
+
)
|
|
151
|
+
Push.pushSite
|
|
152
|
+
noInvalidationsOccurred must beTrue
|
|
153
|
+
}
|
|
154
|
+
|
|
144
155
|
"not send CloudFront invalidation requests on redirect objects" in new SiteDirectory with MockAWS {
|
|
145
156
|
implicit val site = buildSite(
|
|
146
157
|
config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z"), redirects = Some(Map("/index.php" -> "index.html")))
|
|
@@ -155,15 +166,28 @@ class S3WebsiteSpec extends Specification {
|
|
|
155
166
|
config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z")),
|
|
156
167
|
localFiles = "test.css" :: Nil
|
|
157
168
|
)
|
|
169
|
+
setOutdatedS3Keys("test.css")
|
|
158
170
|
Push.pushSite must equalTo(0) // The retries should finally result in a success
|
|
159
171
|
sentInvalidationRequests.length must equalTo(4)
|
|
160
172
|
}
|
|
161
173
|
|
|
174
|
+
"retry if CloudFront is temporarily unreachable" in new SiteDirectory with MockAWS {
|
|
175
|
+
invalidationsFailAndThenSucceed(5)
|
|
176
|
+
implicit val site = siteWithFiles(
|
|
177
|
+
config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z")),
|
|
178
|
+
localFiles = "test.css" :: Nil
|
|
179
|
+
)
|
|
180
|
+
setOutdatedS3Keys("test.css")
|
|
181
|
+
Push.pushSite
|
|
182
|
+
sentInvalidationRequests.length must equalTo(6)
|
|
183
|
+
}
|
|
184
|
+
|
|
162
185
|
"encode unsafe characters in the keys" in new SiteDirectory with MockAWS {
|
|
163
186
|
implicit val site = siteWithFiles(
|
|
164
187
|
config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z")),
|
|
165
188
|
localFiles = "articles/arnold's file.html" :: Nil
|
|
166
189
|
)
|
|
190
|
+
setOutdatedS3Keys("articles/arnold's file.html")
|
|
167
191
|
Push.pushSite
|
|
168
192
|
sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/articles/arnold's%20file.html" :: Nil).sorted)
|
|
169
193
|
}
|
|
@@ -175,6 +199,7 @@ class S3WebsiteSpec extends Specification {
|
|
|
175
199
|
config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z"), cloudfront_invalidate_root = Some(true)),
|
|
176
200
|
localFiles = "index.html" :: "articles/index.html" :: Nil
|
|
177
201
|
)
|
|
202
|
+
setOutdatedS3Keys("index.html", "articles/index.html")
|
|
178
203
|
Push.pushSite
|
|
179
204
|
sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/" :: "/articles/" :: Nil).sorted)
|
|
180
205
|
}
|
|
@@ -182,10 +207,12 @@ class S3WebsiteSpec extends Specification {
|
|
|
182
207
|
|
|
183
208
|
"a site with over 1000 items" should {
|
|
184
209
|
"split the CloudFront invalidation requests into batches of 1000 items" in new SiteDirectory with MockAWS {
|
|
210
|
+
val files = (1 to 1002).map { i => s"file-$i"}
|
|
185
211
|
implicit val site = siteWithFiles(
|
|
186
212
|
config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z")),
|
|
187
|
-
localFiles =
|
|
213
|
+
localFiles = files
|
|
188
214
|
)
|
|
215
|
+
setOutdatedS3Keys(files:_*)
|
|
189
216
|
Push.pushSite
|
|
190
217
|
sentInvalidationRequests.length must equalTo(2)
|
|
191
218
|
sentInvalidationRequests(0).getInvalidationBatch.getPaths.getItems.length must equalTo(1000)
|
|
@@ -219,12 +246,13 @@ class S3WebsiteSpec extends Specification {
|
|
|
219
246
|
Push.pushSite must equalTo(0)
|
|
220
247
|
}
|
|
221
248
|
|
|
222
|
-
"be 1 if CloudFront
|
|
249
|
+
"be 1 if CloudFront is unreachable or broken"in new SiteDirectory with MockAWS {
|
|
223
250
|
setCloudFrontAsInternallyBroken()
|
|
224
251
|
implicit val site = siteWithFiles(
|
|
225
252
|
config = defaultConfig.copy(cloudfront_distribution_id = Some("EGM1J2JJX9Z")),
|
|
226
|
-
localFiles = "test.css" ::
|
|
253
|
+
localFiles = "test.css" :: Nil
|
|
227
254
|
)
|
|
255
|
+
setOutdatedS3Keys("test.css")
|
|
228
256
|
Push.pushSite must equalTo(1)
|
|
229
257
|
}
|
|
230
258
|
|
|
@@ -340,13 +368,13 @@ class S3WebsiteSpec extends Specification {
|
|
|
340
368
|
"respect the more specific glob" in new SiteDirectory with MockAWS {
|
|
341
369
|
implicit val site = siteWithFiles(
|
|
342
370
|
defaultConfig.copy(max_age = Some(Right(Map(
|
|
343
|
-
"
|
|
344
|
-
"assets
|
|
371
|
+
"assets/*" -> 150,
|
|
372
|
+
"assets/*.gif" -> 86400
|
|
345
373
|
)))),
|
|
346
|
-
localFiles = "
|
|
374
|
+
localFiles = "assets/jquery.js" :: "assets/picture.gif" :: Nil
|
|
347
375
|
)
|
|
348
376
|
Push.pushSite
|
|
349
|
-
sentPutObjectRequests.find(_.getKey == "
|
|
377
|
+
sentPutObjectRequests.find(_.getKey == "assets/jquery.js").get.getMetadata.getCacheControl must equalTo("max-age=150")
|
|
350
378
|
sentPutObjectRequests.find(_.getKey == "assets/picture.gif").get.getMetadata.getCacheControl must equalTo("max-age=86400")
|
|
351
379
|
}
|
|
352
380
|
}
|
|
@@ -429,11 +457,11 @@ class S3WebsiteSpec extends Specification {
|
|
|
429
457
|
|
|
430
458
|
trait MockAWS extends MockS3 with MockCloudFront with Scope
|
|
431
459
|
|
|
432
|
-
trait MockCloudFront {
|
|
460
|
+
trait MockCloudFront extends MockAWSHelper {
|
|
433
461
|
val amazonCloudFrontClient = mock(classOf[AmazonCloudFront])
|
|
434
462
|
implicit val cfSettings: CloudFrontSettings = CloudFrontSettings(
|
|
435
463
|
cfClient = _ => amazonCloudFrontClient,
|
|
436
|
-
|
|
464
|
+
retryTimeUnit = MICROSECONDS
|
|
437
465
|
)
|
|
438
466
|
|
|
439
467
|
def sentInvalidationRequests: Seq[CreateInvalidationRequest] = {
|
|
@@ -449,6 +477,12 @@ class S3WebsiteSpec extends Specification {
|
|
|
449
477
|
true // Mockito is based on exceptions
|
|
450
478
|
}
|
|
451
479
|
|
|
480
|
+
def invalidationsFailAndThenSucceed(implicit howManyFailures: Int, callCount: AtomicInteger = new AtomicInteger(0)) {
|
|
481
|
+
doAnswer(temporaryFailure(classOf[CreateInvalidationResult]))
|
|
482
|
+
.when(amazonCloudFrontClient)
|
|
483
|
+
.createInvalidation(Matchers.anyObject())
|
|
484
|
+
}
|
|
485
|
+
|
|
452
486
|
def setTooManyInvalidationsInProgress(attemptWhenInvalidationSucceeds: Int) {
|
|
453
487
|
var callCount = 0
|
|
454
488
|
doAnswer(new Answer[CreateInvalidationResult] {
|
|
@@ -462,20 +496,30 @@ class S3WebsiteSpec extends Specification {
|
|
|
462
496
|
}).when(amazonCloudFrontClient).createInvalidation(Matchers.anyObject())
|
|
463
497
|
}
|
|
464
498
|
|
|
499
|
+
|
|
500
|
+
|
|
465
501
|
def setCloudFrontAsInternallyBroken() {
|
|
466
502
|
when(amazonCloudFrontClient.createInvalidation(Matchers.anyObject())).thenThrow(new AmazonServiceException("CloudFront is down"))
|
|
467
503
|
}
|
|
468
504
|
}
|
|
469
505
|
|
|
470
|
-
trait MockS3 {
|
|
506
|
+
trait MockS3 extends MockAWSHelper {
|
|
471
507
|
val amazonS3Client = mock(classOf[AmazonS3])
|
|
472
508
|
implicit val s3Settings: S3Settings = S3Settings(
|
|
473
509
|
s3Client = _ => amazonS3Client,
|
|
474
|
-
|
|
510
|
+
retryTimeUnit = MICROSECONDS
|
|
475
511
|
)
|
|
476
512
|
val s3ObjectListing = new ObjectListing
|
|
477
513
|
when(amazonS3Client.listObjects(Matchers.any(classOf[ListObjectsRequest]))).thenReturn(s3ObjectListing)
|
|
478
514
|
|
|
515
|
+
def setOutdatedS3Keys(s3Keys: String*) {
|
|
516
|
+
s3Keys
|
|
517
|
+
.map(key =>
|
|
518
|
+
S3File(key, md5Hex(Random.nextLong().toString)) // Simulate the situation where the file on S3 is outdated (as compared to the local file)
|
|
519
|
+
)
|
|
520
|
+
.foreach (setS3Files(_))
|
|
521
|
+
}
|
|
522
|
+
|
|
479
523
|
def setS3Files(s3Files: S3File*) {
|
|
480
524
|
s3Files.foreach { s3File =>
|
|
481
525
|
s3ObjectListing.getObjectSummaries.add({
|
|
@@ -507,16 +551,6 @@ class S3WebsiteSpec extends Specification {
|
|
|
507
551
|
.listObjects(Matchers.any(classOf[ListObjectsRequest]))
|
|
508
552
|
}
|
|
509
553
|
|
|
510
|
-
def temporaryFailure[T](clazz: Class[T])(implicit callCount: AtomicInteger, howManyFailures: Int) = new Answer[T] {
|
|
511
|
-
def answer(invocation: InvocationOnMock) = {
|
|
512
|
-
callCount.incrementAndGet()
|
|
513
|
-
if (callCount.get() <= howManyFailures)
|
|
514
|
-
throw new AmazonServiceException("AWS is temporarily down")
|
|
515
|
-
else
|
|
516
|
-
mock(clazz)
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
554
|
def asSeenByS3Client(upload: Upload)(implicit config: Config): PutObjectRequest = {
|
|
521
555
|
Await.ready(s3.upload(upload withUploadType NewFile), Duration("1 s"))
|
|
522
556
|
val req = ArgumentCaptor.forClass(classOf[PutObjectRequest])
|
|
@@ -552,7 +586,19 @@ class S3WebsiteSpec extends Specification {
|
|
|
552
586
|
|
|
553
587
|
type S3Key = String
|
|
554
588
|
}
|
|
555
|
-
|
|
589
|
+
|
|
590
|
+
trait MockAWSHelper {
|
|
591
|
+
def temporaryFailure[T](clazz: Class[T])(implicit callCount: AtomicInteger, howManyFailures: Int) = new Answer[T] {
|
|
592
|
+
def answer(invocation: InvocationOnMock) = {
|
|
593
|
+
callCount.incrementAndGet()
|
|
594
|
+
if (callCount.get() <= howManyFailures)
|
|
595
|
+
throw new AmazonServiceException("AWS is temporarily down")
|
|
596
|
+
else
|
|
597
|
+
mock(clazz)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
556
602
|
trait SiteDirectory extends After {
|
|
557
603
|
val siteDir = new File(FileUtils.getTempDirectory, "site" + Random.nextLong())
|
|
558
604
|
siteDir.mkdir()
|