s3_website_monadic 0.0.9 → 0.0.11
Sign up to get free protection for your applications and to get access to all the features.
- 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()
|