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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 92e9b4b0029ead9d9f441f2a5fdb2687f535a729
4
- data.tar.gz: ea74c668aa1f6accc39a1541e56af5b16781f507
3
+ metadata.gz: 83042f3d9526d801a4c3ca1b843b8bbf3e03f02e
4
+ data.tar.gz: a5e5ac7ae8fcc22d519d8d3503d74734050ac415
5
5
  SHA512:
6
- metadata.gz: 866b36966c21be8e2dd88748e9ca3955807668b3bd4efe9a33c70d1458c3d45ed4dc81d8a2b26b2399dfaf7c2a874ebd72006bc9324d7326a43f4457a2629415
7
- data.tar.gz: 25613874bf0d7bc119f0307aabf6eecb70a958d010ab486a9ab931b2ae3b6183ebda195c888040f6a0c660b8c50be0cbf9aea5d22e90f3aa11dec2c7cf2a250a
6
+ metadata.gz: 8625012df6b394782179cd5795f512f8dc6c99335eaa9d382b750b564acd0c697f9c4fbb652730499dc79d55fcecbb5be8d94c4f47c6afa9e80415b6bb3b6fda
7
+ data.tar.gz: 98d5ac27855f7b212ed644e7df6d2122bd7fba976206643fd398fe8275e157e50f3b44127180ecdfd36d2cb11b74ad5b38357715041e4ab8c24e4f0c61043ccb
@@ -3,7 +3,7 @@ $:.push File.expand_path("../lib", __FILE__)
3
3
 
4
4
  Gem::Specification.new do |s|
5
5
  s.name = "s3_website_monadic"
6
- s.version = "0.0.9"
6
+ s.version = "0.0.11"
7
7
  s.platform = Gem::Platform::RUBY
8
8
  s.authors = ["Lauri Lehmijoki"]
9
9
  s.email = ["lauri.lehmijoki@iki.fi"]
@@ -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, CloudFrontClientProvider, SuccessfulInvalidation, FailedInvalidation}
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): InvalidationResult = {
23
- def tryInvalidate(implicit attempt: Int = 1): Try[SuccessfulInvalidation] =
24
- Try {
25
- val invalidationReq = new CreateInvalidationRequest(distributionId, invalidationBatch)
26
- cloudFront.createInvalidation(invalidationReq)
27
- val result = SuccessfulInvalidation(invalidationBatch.getPaths.getItems.size())
28
- info(result)
29
- result
30
- } recoverWith {
31
- case e: TooManyInvalidationsInProgressException =>
32
- implicit val duration: Duration = Duration(
33
- (fibs drop attempt).head min 15, /* AWS docs way that invalidations complete in 15 minutes */
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
- tryInvalidate() match {
42
- case Success(res) =>
43
- Right(res)
44
- case Failure(err) =>
45
- val report = FailedInvalidation(err)
46
- info(report)
47
- Left(report)
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(implicit sleepDuration: Duration, attempt: Int) = {
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
- .filterNot(isRedirect) // Assume that redirect objects are never cached.
85
- .map(report =>
86
- new URI(
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 isRedirect: PartialFunction[PushSuccessReport, Boolean] = {
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 Redirect => true
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
- cloudFrontSleepTimeUnit: TimeUnit = MINUTES
118
- )
117
+ retryTimeUnit: TimeUnit = MINUTES
118
+ ) extends RetrySettings
119
119
  }
@@ -17,26 +17,20 @@ object Diff {
17
17
  }
18
18
  }
19
19
 
20
- def resolveUploads(localFiles: Seq[LocalFile], s3Files: Seq[S3File])(implicit config: Config):
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.{resolveUploads, resolveDeletes}
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
- s3Files <- Await.result(resolveS3Files(), 1 minutes).right
46
- localFiles <- resolveLocalFiles.right
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 resolveUploads(localFiles, s3Files)
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 (new CloudFront().invalidate(_, distributionId))
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, Either[PushFailureReport, PushSuccessReport]]]
168
- type PushReports = ParSeq[Either[ErrorReport, Future[Either[PushFailureReport, PushSuccessReport]]]]
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 s3.website.Utils._
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)(implicit a: Attempt = 1, config: Config): Future[Either[FailedUpload, SuccessfulUpload]] =
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)(newAttempt, config)
30
+ retryAction = newAttempt => this.upload(upload, newAttempt)
34
31
  )
35
32
 
36
- def delete(s3Key: String)(implicit a: Attempt = 1, config: Config): Future[Either[FailedDelete, SuccessfulDelete]] =
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)(newAttempt, config)
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 resolveS3Files(nextMarker: Option[String] = None, alreadyResolved: Seq[S3File] = Nil)
87
- (implicit attempt: Attempt = 1, config: Config, s3Settings: S3Settings, ec: ExecutionContextExecutor): ObjectListingResult = Future {
88
- nextMarker.foreach(m => info(s"Fetching the next part of the object listing from S3 (starting from $m)"))
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
- } flatMap { objects =>
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)): ObjectListingResult)(nextMarker => resolveS3Files(Some(nextMarker), s3Files))
100
- } recoverWith retry(
101
- createFailureReport = error => UserError(s"Failed to fetch an object listing (${error.getMessage})"),
102
- retryAction = nextAttempt => resolveS3Files(nextMarker, alreadyResolved)(nextAttempt, config, s3Settings, ec)
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 ObjectListingResult = Future[Either[ErrorReport, Seq[S3File]]]
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
- retrySleepTimeUnit: TimeUnit = SECONDS
157
- )
166
+ retryTimeUnit: TimeUnit = SECONDS
167
+ ) extends RetrySettings
158
168
  }
@@ -18,6 +18,7 @@ object Utils {
18
18
 
19
19
  object Logger {
20
20
  import Rainbow._
21
+ def debug(msg: String) = println(s"[${"debg".cyan}] $msg")
21
22
  def info(msg: String) = println(s"[${"info".blue}] $msg")
22
23
  def fail(msg: String) = println(s"[${"fail".red}] $msg")
23
24
 
@@ -33,7 +33,7 @@ object Config {
33
33
  })
34
34
  }
35
35
 
36
- yamlValue getOrElse Left(UserError(s"The key $key has to have a boolean or [string] value"))
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(UserError(s"The key $key has to have a string or [string] value"))
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(UserError(s"The key $key has to have an int or (string -> int) value"))
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(UserError(s"The key $key has to have a (string -> string) value"))
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(UserError(s"The key $key has to have a string value"))
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(UserError(s"The key $key has to have a string value"))
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(UserError(s"The key $key has to have a boolean value"))
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(UserError(s"The key $key has to have an integer value"))
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[UserError, S3Endpoint] = locationConstraint match {
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(UserError(s"Unrecognised endpoint: $locationConstraint"))
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 UserError(reportMessage: String) extends ErrorReport
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
- sourceFile: File,
58
+ originalFile: File,
59
59
  encodingOnS3: Option[Either[Gzip, Zopfli]]
60
- ) extends S3KeyProvider
60
+ ) extends S3KeyProvider {
61
61
 
62
- object LocalFile {
63
- def toUpload(localFile: LocalFile)(implicit config: Config): Either[ErrorReport, Upload] = Try {
64
- def fis(file: File): InputStream = new FileInputStream(file)
65
- def using[T <: Closeable, R](cl: T)(f: (T) => R): R = try f(cl) finally cl.close()
66
- val sourceFile: File = localFile
67
- .encodingOnS3
68
- .fold(localFile.sourceFile)(algorithm => {
69
- val tempFile = File.createTempFile(localFile.sourceFile.getName, "gzip")
70
- tempFile.deleteOnExit()
71
- using(new GZIPOutputStream(new FileOutputStream(tempFile))) { stream =>
72
- IOUtils.copy(fis(localFile.sourceFile), stream)
73
- }
74
- tempFile
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 = sourceFile.length(),
99
+ contentLength = localFile.length,
87
100
  maxAge = resolveMaxAge(localFile),
88
- contentType = resolveContentType(localFile.sourceFile),
89
- openInputStream = () => new FileInputStream(sourceFile)
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 { _.sourceFile.getName == "s3_website.yml" } // For security reasons, the s3_website.yml should never be pushed
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 = (1 to 1002).map { i => s"file-$i"}
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 invalidation fails"in new SiteDirectory with MockAWS {
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" :: "articles/index.html" :: Nil
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
- "**" -> 150,
344
- "assets/**" -> 86400
371
+ "assets/*" -> 150,
372
+ "assets/*.gif" -> 86400
345
373
  )))),
346
- localFiles = "index.html" :: "assets/picture.gif" :: Nil
374
+ localFiles = "assets/jquery.js" :: "assets/picture.gif" :: Nil
347
375
  )
348
376
  Push.pushSite
349
- sentPutObjectRequests.find(_.getKey == "index.html").get.getMetadata.getCacheControl must equalTo("max-age=150")
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
- cloudFrontSleepTimeUnit = MICROSECONDS
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
- retrySleepTimeUnit = MICROSECONDS
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()
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: s3_website_monadic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.9
4
+ version: 0.0.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lauri Lehmijoki