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 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