s3_website_monadic 0.0.31 → 0.0.32

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: 240e4d2a14000e5eed029857d69e426cf7439c4f
4
- data.tar.gz: ad0d71c36c1f0a8add0cca08f1de9f49467c2019
3
+ metadata.gz: 87c870c1dae2ea36e452f7df0b0f7565c5cec616
4
+ data.tar.gz: 4e8324a2207a37a3f991490d187c27d35139c04b
5
5
  SHA512:
6
- metadata.gz: 17edd14e1510e3b82604e6942a6bca4dc92b3bc34e6189fb4db5f62f2f930b32ea385794f19c1032d53f6d8c8d4600b5bd6cfc8f3ffd08f22f75e05b4b43544f
7
- data.tar.gz: b3b818d8d18f40f36cf8b1661785da1fd38b8f4e9afeb78a33a37e39c5cd016bbd0824429330729e65fe01351506c044e51c9c80e372875fd4debebeaf83539f
6
+ metadata.gz: 5fdcff5e2fad65a9a895bc7669db2690f70c9dd4e7ab54be0962a99a279dda4f88542373eb476e7a6750c2580554888f329aa240ff9d346f26648128adc2a38c
7
+ data.tar.gz: 0221384872bd4914d9585f5f63074f0b9286600491abdce62f411c5e545d42bd41a484bdf04c686be79350fcb5d71e28d865004f371ef0e32f8caafc04660800
data/changelog.md CHANGED
@@ -6,6 +6,8 @@ This project uses [Semantic Versioning](http://semver.org).
6
6
 
7
7
  ### New features
8
8
 
9
+ * Faster uploads for extra large sites
10
+
9
11
  * Simulate deployments with `push --dry-run`
10
12
 
11
13
  * Support CloudFront invalidations when the site contains over 3000 files
@@ -1,3 +1,3 @@
1
1
  module S3Website
2
- VERSION = '0.0.31'
2
+ VERSION = '0.0.32'
3
3
  end
@@ -1,22 +1,18 @@
1
1
  package s3.website
2
2
 
3
- import s3.website.model.{Update, Config}
3
+ import s3.website.model.{FileUpdate, Config}
4
4
  import com.amazonaws.services.cloudfront.{AmazonCloudFrontClient, AmazonCloudFront}
5
- import s3.website.CloudFront.{CloudFrontSetting, SuccessfulInvalidation, FailedInvalidation}
6
5
  import com.amazonaws.services.cloudfront.model.{TooManyInvalidationsInProgressException, Paths, InvalidationBatch, CreateInvalidationRequest}
7
6
  import scala.collection.JavaConversions._
8
7
  import scala.concurrent.duration._
9
8
  import s3.website.S3.{SuccessfulDelete, PushSuccessReport, SuccessfulUpload}
10
9
  import com.amazonaws.auth.BasicAWSCredentials
11
10
  import java.net.URI
12
- import Utils._
13
11
  import scala.concurrent.{ExecutionContextExecutor, Future}
14
12
 
15
- class CloudFront(implicit cloudFrontSettings: CloudFrontSetting, config: Config, logger: Logger, pushMode: PushMode) {
16
- val cloudFront = cloudFrontSettings.cfClient(config)
17
-
13
+ object CloudFront {
18
14
  def invalidate(invalidationBatch: InvalidationBatch, distributionId: String, attempt: Attempt = 1)
19
- (implicit ec: ExecutionContextExecutor): InvalidationResult =
15
+ (implicit ec: ExecutionContextExecutor, cloudFrontSettings: CloudFrontSetting, config: Config, logger: Logger, pushMode: PushMode): InvalidationResult =
20
16
  Future {
21
17
  if (!pushMode.dryRun) cloudFront createInvalidation new CreateInvalidationRequest(distributionId, invalidationBatch)
22
18
  val result = SuccessfulInvalidation(invalidationBatch.getPaths.getItems.size())
@@ -29,7 +25,8 @@ class CloudFront(implicit cloudFrontSettings: CloudFrontSetting, config: Config,
29
25
  ))
30
26
 
31
27
  def tooManyInvalidationsRetry(invalidationBatch: InvalidationBatch, distributionId: String, attempt: Attempt)
32
- (implicit ec: ExecutionContextExecutor, logger: Logger): PartialFunction[Throwable, InvalidationResult] = {
28
+ (implicit ec: ExecutionContextExecutor, logger: Logger, cloudFrontSettings: CloudFrontSetting, config: Config, pushMode: PushMode):
29
+ PartialFunction[Throwable, InvalidationResult] = {
33
30
  case e: TooManyInvalidationsInProgressException =>
34
31
  val duration: Duration = Duration(
35
32
  (fibs drop attempt).head min 15, /* CloudFront invalidations complete within 15 minutes */
@@ -52,10 +49,9 @@ class CloudFront(implicit cloudFrontSettings: CloudFrontSetting, config: Config,
52
49
  basicInfo
53
50
  }
54
51
 
55
- type InvalidationResult = Future[Either[FailedInvalidation, SuccessfulInvalidation]]
56
- }
52
+ def cloudFront(implicit config: Config, cloudFrontSettings: CloudFrontSetting) = cloudFrontSettings.cfClient(config)
57
53
 
58
- object CloudFront {
54
+ type InvalidationResult = Future[Either[FailedInvalidation, SuccessfulInvalidation]]
59
55
 
60
56
  type CloudFrontClientProvider = (Config) => AmazonCloudFront
61
57
 
@@ -121,10 +117,7 @@ object CloudFront {
121
117
 
122
118
 
123
119
  def needsInvalidation: PartialFunction[PushSuccessReport, Boolean] = {
124
- case SuccessfulUpload(upload, _, _) => upload.uploadType match {
125
- case Update => true
126
- case _ => false
127
- }
120
+ case SuccessfulUpload(localFile, _, _) => localFile.left.exists(_.uploadType == FileUpdate)
128
121
  case SuccessfulDelete(_) => true
129
122
  case _ => false
130
123
  }
@@ -2,38 +2,277 @@ package s3.website
2
2
 
3
3
  import s3.website.model._
4
4
  import s3.website.Ruby.rubyRegexMatches
5
- import s3.website._
5
+ import scala.concurrent.{ExecutionContextExecutor, Future}
6
+ import scala.util.{Failure, Success, Try}
7
+ import java.io.File
8
+ import org.apache.commons.io.FileUtils._
9
+ import org.apache.commons.codec.digest.DigestUtils._
10
+ import scala.io.Source
11
+ import s3.website.Diff.LocalFileDatabase.resolveDiffAgainstLocalDb
12
+ import s3.website.Diff.UploadBatch
13
+
14
+ case class Diff(
15
+ unchanged: Future[Either[ErrorReport, Seq[S3Key]]],
16
+ uploads: Seq[UploadBatch],
17
+ persistenceError: Future[Option[ErrorReport]]
18
+ )
6
19
 
7
20
  object Diff {
8
21
 
9
- def resolveDeletes(localFiles: Seq[LocalFile], s3Files: Seq[S3File], redirects: Seq[Upload with UploadTypeResolved])
10
- (implicit config: Config, logger: Logger): Seq[S3File] = {
11
- val keysNotToBeDeleted: Set[String] = (localFiles ++ redirects).map(_.s3Key).toSet
12
- s3Files.filterNot { s3File =>
13
- val ignoreOnServer = config.ignore_on_server.exists(_.fold(
14
- (ignoreRegex: String) => rubyRegexMatches(s3File.s3Key, ignoreRegex),
15
- (ignoreRegexes: Seq[String]) => ignoreRegexes.exists(rubyRegexMatches(s3File.s3Key, _))
16
- ))
17
- if (ignoreOnServer) logger.debug(s"Ignoring ${s3File.s3Key} on server")
18
- keysNotToBeDeleted.exists(_ == s3File.s3Key) || ignoreOnServer
22
+ type UploadBatch = Future[Either[ErrorReport, Seq[LocalFileFromDisk]]]
23
+
24
+ def resolveDiff(s3FilesFuture: Future[Either[ErrorReport, Seq[S3File]]])
25
+ (implicit site: Site, logger: Logger, executor: ExecutionContextExecutor): Either[ErrorReport, Diff] =
26
+ if (LocalFileDatabase.hasRecords) resolveDiffAgainstLocalDb(s3FilesFuture)
27
+ else resolveDiffAgainstGetBucketResponse(s3FilesFuture)
28
+
29
+ private def resolveDiffAgainstGetBucketResponse(s3FilesFuture: Future[Either[ErrorReport, Seq[S3File]]])
30
+ (implicit site: Site, logger: Logger, executor: ExecutionContextExecutor): Either[ErrorReport, Diff] = {
31
+ val diffSrc = s3FilesFuture.map { errorOrS3Files =>
32
+ errorOrS3Files.right.flatMap { s3Files =>
33
+ Try {
34
+ val s3KeyIndex = s3Files.map(_.s3Key).toSet
35
+ val s3Md5Index = s3Files.map(_.md5).toSet
36
+ val siteFiles = Files.listSiteFiles
37
+ val nameExistsOnS3 = (f: File) => s3KeyIndex contains site.resolveS3Key(f)
38
+ val newFiles = siteFiles
39
+ .filterNot(nameExistsOnS3)
40
+ .map { f => LocalFileFromDisk(f, uploadType = NewFile)}
41
+ val changedFiles =
42
+ siteFiles
43
+ .filter(nameExistsOnS3)
44
+ .map(f => LocalFileFromDisk(f, uploadType = FileUpdate))
45
+ .filterNot(localFile => s3Md5Index contains localFile.md5)
46
+ val unchangedFiles = {
47
+ val newOrChangedFiles = (changedFiles ++ newFiles).map(_.originalFile).toSet
48
+ siteFiles.filterNot(f => newOrChangedFiles contains f)
49
+ }
50
+ val allFiles: Seq[Either[DbRecord, LocalFileFromDisk]] = unchangedFiles.map {
51
+ f => Left(DbRecord(f))
52
+ } ++ (changedFiles ++ newFiles).map {
53
+ Right(_)
54
+ }
55
+ LocalFileDatabase persist allFiles
56
+ allFiles
57
+ } match {
58
+ case Success(ok) => Right(ok)
59
+ case Failure(err) => Left(ErrorReport(err))
60
+ }
61
+ }
19
62
  }
63
+ def collectResult[B](pf: PartialFunction[Either[DbRecord, LocalFileFromDisk],B]) =
64
+ diffSrc.map { errorOrDiffSource =>
65
+ errorOrDiffSource.right map (_ collect pf)
66
+ }
67
+ val unchanged = collectResult {
68
+ case Left(dbRecord) => dbRecord.s3Key
69
+ }
70
+ val uploads: UploadBatch = collectResult {
71
+ case Right(localFile) => localFile
72
+ }
73
+ Right(Diff(unchanged, uploads :: Nil, persistenceError = Future(None)))
20
74
  }
21
75
 
22
- def resolveNewFiles(localFiles: Seq[LocalFile], s3Files: Seq[S3File])(implicit config: Config):
23
- Stream[Either[ErrorReport, Upload with UploadTypeResolved]] = {
24
- val remoteS3KeysIndex = s3Files.map(_.s3Key).toSet
25
- localFiles
26
- .toStream // Load lazily, because the MD5 computation for the local file requires us to read the whole file
27
- .map(resolveUploadSource)
28
- .collect {
29
- case errorOrUpload if errorOrUpload.right.exists(isNewUpload(remoteS3KeysIndex)) =>
30
- for (upload <- errorOrUpload.right) yield upload withUploadType NewFile
76
+ def resolveDeletes(diff: Diff, s3Files: Future[Either[ErrorReport, Seq[S3File]]], redirects: Seq[Redirect])
77
+ (implicit config: Config, logger: Logger, executor: ExecutionContextExecutor): Future[Either[ErrorReport, Seq[S3Key]]] = {
78
+ val localKeys = for {
79
+ errorOrUnchanged <- diff.unchanged
80
+ errorsOrChanges <- Future.sequence(diff.uploads)
81
+ } yield {
82
+ errorsOrChanges.foldLeft(errorOrUnchanged: Either[ErrorReport, Seq[S3Key]]) { (memo, errorOrChanges) =>
83
+ for {
84
+ mem <- memo.right
85
+ keysToDelete <- errorOrChanges.right
86
+ } yield {
87
+ mem ++ keysToDelete.map(_.s3Key)
88
+ }
89
+ }
31
90
  }
91
+ s3Files zip localKeys map { (s3Files: Either[ErrorReport, Seq[S3File]], errorOrLocalKeys: Either[ErrorReport, Seq[S3Key]]) =>
92
+ for {
93
+ localS3Keys <- errorOrLocalKeys.right
94
+ remoteS3Keys <- s3Files.right.map(_ map (_.s3Key)).right
95
+ } yield {
96
+ val keysToRetain = (localS3Keys ++ (redirects map { _.s3Key })).toSet
97
+ remoteS3Keys filterNot { s3Key =>
98
+ val ignoreOnServer = config.ignore_on_server.exists(_.fold(
99
+ (ignoreRegex: String) => rubyRegexMatches(s3Key, ignoreRegex),
100
+ (ignoreRegexes: Seq[String]) => ignoreRegexes.exists(rubyRegexMatches(s3Key, _))
101
+ ))
102
+ if (ignoreOnServer) logger.debug(s"Ignoring $s3Key on server")
103
+ (keysToRetain contains s3Key) || ignoreOnServer
104
+ }
105
+ }
106
+ }.tupled
32
107
  }
33
108
 
34
- def isNewUpload(remoteS3KeysIndex: Set[String])(u: Upload) = !remoteS3KeysIndex.exists(_ == u.s3Key)
109
+ object LocalFileDatabase {
110
+ def hasRecords(implicit site: Site, logger: Logger) =
111
+ (for {
112
+ dbFile <- getOrCreateDbFile
113
+ databaseIndices <- loadDbFromFile(dbFile)
114
+ } yield databaseIndices.fullIndex.headOption.isDefined) getOrElse false
115
+
116
+ def resolveDiffAgainstLocalDb(s3FilesFuture: Future[Either[ErrorReport, Seq[S3File]]])
117
+ (implicit site: Site, logger: Logger, executor: ExecutionContextExecutor): Either[ErrorReport, Diff] = {
118
+ val localDiff: Either[ErrorReport, Seq[Either[DbRecord, LocalFileFromDisk]]] =
119
+ (for {
120
+ dbFile <- getOrCreateDbFile
121
+ databaseIndices <- loadDbFromFile(dbFile)
122
+ } yield {
123
+ val siteFiles = Files.listSiteFiles
124
+ val recordsOrChangedFiles = siteFiles.foldLeft(Seq(): Seq[Either[DbRecord, LocalFileFromDisk]]) { (localFiles, file) =>
125
+ val truncatedKey = TruncatedDbRecord(file)
126
+ val fileIsUnchanged = databaseIndices.truncatedIndex contains truncatedKey
127
+ if (fileIsUnchanged)
128
+ localFiles :+ Left(databaseIndices.fullIndex find (_.truncated == truncatedKey) get)
129
+ else {
130
+ val uploadType =
131
+ if (databaseIndices.s3KeyIndex contains truncatedKey.s3Key) FileUpdate
132
+ else NewFile
133
+ localFiles :+ Right(LocalFileFromDisk(file, uploadType))
134
+ }
135
+ }
136
+ logger.debug(s"Discovered ${siteFiles.length} files on the local site, of which ${recordsOrChangedFiles count (_.isRight)} are new or changed")
137
+ recordsOrChangedFiles
138
+ }) match {
139
+ case Success(ok) => Right(ok)
140
+ case Failure(err) => Left(ErrorReport(err))
141
+ }
35
142
 
36
- def resolveUploadSource(localFile: LocalFile)(implicit config: Config): Either[ErrorReport, Upload] =
37
- for (upload <- LocalFile.toUpload(localFile).right)
38
- yield upload
39
- }
143
+ localDiff.right map { localDiffResult =>
144
+ val unchangedAccordingToLocalDiff = localDiffResult collect {
145
+ case Left(f) => f
146
+ }
147
+
148
+ val uploadsResolvedByLocalDiff = localDiffResult collect {
149
+ case Right(f) => f
150
+ }
151
+
152
+ val changesMissedByLocalDiff: Future[Either[ErrorReport, Seq[LocalFileFromDisk]]] = s3FilesFuture.map { errorOrS3Files =>
153
+ for (s3Files <- errorOrS3Files.right) yield {
154
+ val localS3Keys = unchangedAccordingToLocalDiff.map(_.s3Key).toSet
155
+ val localMd5 = unchangedAccordingToLocalDiff.map(_.uploadFileMd5).toSet
156
+ val changedOnS3 = s3Files.filter { s3File =>
157
+ (localS3Keys contains s3File.s3Key) && !(localMd5 contains s3File.md5)
158
+ }
159
+ logger.debug(s"Detected ${changedOnS3.length} object(s) that have changed on S3 but not on the local site")
160
+ changedOnS3 map { s3File =>
161
+ LocalFileFromDisk(site resolveFile s3File, FileUpdate)
162
+ }
163
+ }
164
+ }
165
+
166
+ val errorOrDiffAgainstS3 =
167
+ changesMissedByLocalDiff map { errorOrUploads =>
168
+ errorOrUploads.right map { uploadsMissedByLocalDiff =>
169
+ val uploadsS3KeyIndex = uploadsMissedByLocalDiff.map(_.s3Key).toSet
170
+ val unchangedAccordingToLocalAndS3Diff = unchangedAccordingToLocalDiff.filterNot(uploadsS3KeyIndex contains _.s3Key)
171
+ (unchangedAccordingToLocalAndS3Diff, uploadsMissedByLocalDiff)
172
+ }
173
+ }
174
+
175
+ val unchangedFilesFinal = errorOrDiffAgainstS3 map {
176
+ _ fold (
177
+ (error: ErrorReport) => Left(error),
178
+ (syncResult: (Seq[DbRecord], Seq[LocalFileFromDisk])) => Right(syncResult._1)
179
+ )
180
+ }
181
+
182
+ val changedAccordingToS3Diff = errorOrDiffAgainstS3.map {
183
+ _ fold (
184
+ (error: ErrorReport) => Left(error),
185
+ (syncResult: (Seq[DbRecord], Seq[LocalFileFromDisk])) => Right(syncResult._2)
186
+ )
187
+ }
188
+ val persistenceError: Future[Either[ErrorReport, _]] = for {
189
+ unchanged <- unchangedFilesFinal
190
+ changedAccordingToS3 <- changedAccordingToS3Diff
191
+ } yield
192
+ for {
193
+ records1 <- unchanged.right
194
+ records2 <- changedAccordingToS3.right
195
+ } yield
196
+ persist(records1.map(Left(_)) ++ records2.map(Right(_)) ++ uploadsResolvedByLocalDiff.map(Right(_))) match {
197
+ case Success(_) => Unit
198
+ case Failure(err) => ErrorReport(err)
199
+ }
200
+ Diff(
201
+ unchangedFilesFinal map (_.right.map(_ map (_.s3Key))),
202
+ uploads = Future(Right(uploadsResolvedByLocalDiff)) :: changedAccordingToS3Diff :: Nil,
203
+ persistenceError = persistenceError map (_.left.toOption)
204
+ )
205
+ }
206
+ }
207
+
208
+ private def getOrCreateDbFile(implicit site: Site, logger: Logger) =
209
+ Try {
210
+ val dbFile = new File(getTempDirectory, "s3_website_local_db_" + sha256Hex(site.rootDirectory))
211
+ if (!dbFile.exists()) logger.debug("Creating a new database in " + dbFile.getName)
212
+ dbFile.createNewFile()
213
+ dbFile
214
+ }
215
+
216
+ case class DbIndices(
217
+ s3KeyIndex: Set[S3Key],
218
+ truncatedIndex: Set[TruncatedDbRecord],
219
+ fullIndex: Set[DbRecord]
220
+ )
221
+
222
+ private def loadDbFromFile(databaseFile: File)(implicit site: Site, logger: Logger): Try[DbIndices] =
223
+ Try {
224
+ // record format: "s3Key(file.path)|length(file)|mtime(file)|md5Hex(file.encoded)"
225
+ val RecordRegex = "(.*?)\\|(\\d+)\\|(\\d+)\\|([a-f0-9]{32})".r
226
+ val fullIndex = Source
227
+ .fromFile(databaseFile, "utf-8")
228
+ .getLines()
229
+ .toStream
230
+ .map {
231
+ case RecordRegex(s3Key, fileLength, fileModified, md5) =>
232
+ DbRecord(s3Key, fileLength.toLong, fileModified.toLong, md5)
233
+ }
234
+ .toSet
235
+ DbIndices(
236
+ s3KeyIndex = fullIndex map (_.s3Key),
237
+ truncatedIndex = fullIndex map (TruncatedDbRecord(_)),
238
+ fullIndex
239
+ )
240
+ }
241
+
242
+ def persist(recordsOrChangedFiles: Seq[Either[DbRecord, LocalFileFromDisk]])(implicit site: Site, logger: Logger): Try[Seq[Either[DbRecord, LocalFileFromDisk]]] =
243
+ getOrCreateDbFile flatMap { dbFile =>
244
+ Try {
245
+ val dbFileContents = recordsOrChangedFiles.map { recordOrChangedFile =>
246
+ val record: DbRecord = recordOrChangedFile fold(
247
+ record => record,
248
+ changedFile => DbRecord(changedFile.s3Key, changedFile.originalFile.length, changedFile.originalFile.lastModified, changedFile.md5)
249
+ )
250
+ record.s3Key :: record.fileLength :: record.fileModified :: record.uploadFileMd5 :: Nil mkString "|"
251
+ } mkString "\n"
252
+
253
+ write(dbFile, dbFileContents)
254
+ recordsOrChangedFiles
255
+ }
256
+ }
257
+ }
258
+
259
+ case class TruncatedDbRecord(s3Key: String, fileLength: Long, fileModified: Long)
260
+
261
+ object TruncatedDbRecord {
262
+ def apply(dbRecord: DbRecord): TruncatedDbRecord = TruncatedDbRecord(dbRecord.s3Key, dbRecord.fileLength, dbRecord.fileModified)
263
+
264
+ def apply(file: File)(implicit site: Site): TruncatedDbRecord = TruncatedDbRecord(site resolveS3Key file, file.length, file.lastModified)
265
+ }
266
+
267
+ /**
268
+ * @param uploadFileMd5 if the file is gzipped, this checksum should be calculated on the gzipped file, not the original file
269
+ */
270
+ case class DbRecord(s3Key: String, fileLength: Long, fileModified: Long, uploadFileMd5: MD5) {
271
+ lazy val truncated = TruncatedDbRecord(s3Key, fileLength, fileModified)
272
+ }
273
+
274
+ object DbRecord {
275
+ def apply(original: File)(implicit site: Site): DbRecord =
276
+ DbRecord(site resolveS3Key original, original.length, original.lastModified, LocalFileFromDisk.md5(original))
277
+ }
278
+ }
@@ -0,0 +1,61 @@
1
+ package s3.website
2
+
3
+ class Logger(val verboseOutput: Boolean, logMessage: (String) => Unit = println) {
4
+ def debug(msg: String) = if (verboseOutput) log(Debug, msg)
5
+ def info(msg: String) = log(Info, msg)
6
+ def fail(msg: String) = log(Failure, msg)
7
+
8
+ def info(report: SuccessReport) = log(Success, report.reportMessage)
9
+ def info(report: FailureReport) = fail(report.reportMessage)
10
+
11
+ def pending(msg: String) = log(Wait, msg)
12
+
13
+ private def log(logType: LogType, msgRaw: String) {
14
+ val msg = msgRaw.replaceAll("\\n", "\n ") // Indent new lines, so that they arrange nicely with other log lines
15
+ logMessage(s"[$logType] $msg")
16
+ }
17
+
18
+ sealed trait LogType {
19
+ val prefix: String
20
+ override def toString = prefix
21
+ }
22
+ case object Debug extends LogType {
23
+ val prefix = "debg".cyan
24
+ }
25
+ case object Info extends LogType {
26
+ val prefix = "info".blue
27
+ }
28
+ case object Success extends LogType {
29
+ val prefix = "succ".green
30
+ }
31
+ case object Failure extends LogType {
32
+ val prefix = "fail".red
33
+ }
34
+ case object Wait extends LogType {
35
+ val prefix = "wait".yellow
36
+ }
37
+
38
+ /**
39
+ * Idea copied from https://github.com/ktoso/scala-rainbow.
40
+ */
41
+ implicit class RainbowString(val s: String) {
42
+ import Console._
43
+
44
+ /** Colorize the given string foreground to ANSI black */
45
+ def black = BLACK + s + RESET
46
+ /** Colorize the given string foreground to ANSI red */
47
+ def red = RED + s + RESET
48
+ /** Colorize the given string foreground to ANSI red */
49
+ def green = GREEN + s + RESET
50
+ /** Colorize the given string foreground to ANSI red */
51
+ def yellow = YELLOW + s + RESET
52
+ /** Colorize the given string foreground to ANSI red */
53
+ def blue = BLUE + s + RESET
54
+ /** Colorize the given string foreground to ANSI red */
55
+ def magenta = MAGENTA + s + RESET
56
+ /** Colorize the given string foreground to ANSI red */
57
+ def cyan = CYAN + s + RESET
58
+ /** Colorize the given string foreground to ANSI red */
59
+ def white = WHITE + s + RESET
60
+ }
61
+ }