Skip to content

Instantly share code, notes, and snippets.

@bpholt
Last active November 7, 2019 07:51
Show Gist options
  • Save bpholt/56de2cb580fab5586cafc2f1babaa26d to your computer and use it in GitHub Desktop.
Save bpholt/56de2cb580fab5586cafc2f1babaa26d to your computer and use it in GitHub Desktop.
sbt InputTask chaining

Chaining InputTasks

(This gist represents my current understanding of how this works… which is likely to be wrong or at least not completely right! 😀)

Each time .parsed or .evaluated is invoked, sbt consumes some input from the user, so it's not enough to do something like

inputA := parser.parsed
inputB := parser.parsed
combined := {
  inputA.evaluated + inputB.evaluated
}

You can map an InputTask to another task, but I couldn't make the typing work when mapping more than one.

However, with Def.inputTaskDyn, we can parse the input once for the dependent task, and then recreate the original input and pass it into the InputTasks that need it.

    cloudformationOptions := cloudFormationOptionParser.parsed,
    awsAccountId := cloudformationOptions.map(plugin.find[AwsAccountId]).evaluated,
    awsRoleName := cloudformationOptions.map(plugin.find[AwsRoleName]).evaluated,
    stackRoleArn := Def.inputTaskDyn {
      // parse the input string into a sequence of our case classes
      // using the parser this way means we get the benefit of tab completion and error messaging,
      // as opposed to simply parsing a space-separated Seq[String], e.g. `DefaultParsers.spaceDelimited("<args>")`
      val args = CloudFormationStackParsers.cloudFormationOptionParser.parsed

      Def.taskDyn {
        // recreate the input string and pass it as programmatic input to the upstream tasks
        // unfortunately we can't extract `args.mkString(…)` without an Illegal Dynamic Reference error
        val maybeAccountId: Option[String] = awsAccountId.toTask(args.mkString(" ", " ", "")).value
        val maybeRoleName: Option[String] = awsRoleName.toTask(args.mkString(" ", " ", "")).value

        // now that we have all the input values we have, actually define the task at hand
        Def.task {
          plugin.roleArn(maybeAccountId, maybeRoleName)
        }
      }
    }.evaluated

In the example, cloudformationOptions, awsAccountId, and awsRoleName are mainly there to make each piece of the parsed input available for inspection in the sbt console.

$ sbt
[info] Loading project definition from /Users/bholt/sbt-input-task-chaining/project
[info] Set current project to sbt-input-task-chaining (in build file:/Users/bholt/sbt-input-task-chaining/)
> show awsAccountId 123456789012 role/myRole Sandbox
[info] Some(123456789012)
[success] Total time: 0 s, completed Mar 12, 2017 3:10:18 PM
> show awsRoleName 123456789012 role/myRole Sandbox
[info] Some(myRole)
[success] Total time: 0 s, completed Mar 12, 2017 3:10:27 PM
> show environment 123456789012 role/myRole Sandbox
[info] Some(Sandbox)
[success] Total time: 0 s, completed Mar 12, 2017 3:10:33 PM
> show roleArn 123456789012 role/myRole Sandbox
[info] Some(arn:aws:iam::123456789012:role/myRole)
[success] Total time: 0 s, completed Mar 12, 2017 3:10:40 PM
package com.dwolla.sbt.cloudformation
import com.dwolla.awssdk.cloudformation.CloudFormationClient
import com.dwolla.sbt.cloudformation.CloudFormationStackParsers._
import sbt.IO.{read, utf8}
import sbt.Keys._
import sbt._
import scala.language.{implicitConversions, postfixOps}
object CloudFormationStack extends AutoPlugin {
object autoImport extends CloudFormationStackKeys
import autoImport._
lazy val plugin = new CloudFormationStackPlugin
lazy val defaultValues = Seq(
templateJsonFilename := plugin.defaultTemplateJsonFilename,
templateJson := target.value / templateJsonFilename.value,
stackParameters := plugin.defaultStackParameters,
cloudformationClient := CloudFormationClient(),
stackName := normalizedName.value,
cloudformationOptions := cloudFormationOptionParser.parsed,
awsAccountId := cloudformationOptions.map(plugin.find[AwsAccountId]).evaluated,
awsRoleName := cloudformationOptions.map(plugin.find[AwsRoleName]).evaluated,
stackRoleArn := Def.inputTaskDyn {
val args = CloudFormationStackParsers.cloudFormationOptionParser.parsed
Def.taskDyn {
val maybeAccountId: Option[String] = awsAccountId.toTask(" " + args.mkString(" ")).value
val maybeRoleName: Option[String] = awsRoleName.toTask(" " + args.mkString(" ")).value
Def.task {
plugin.roleArn(maybeAccountId, maybeRoleName)
}
}
}.evaluated
)
lazy val tasks = Seq(
generateStack := plugin.runStackTemplateBuilder((mainClass in run in Compile).value, templateJson.value, (runner in run).value, (fullClasspath in Runtime).value, streams.value),
generatedStackFile := read(generateStack.toTask("").value, utf8),
deployStack := plugin.deployStack(stackName.value, generatedStackFile.value, stackParameters.value, stackRoleArn.evaluated, cloudformationClient.value)
)
private lazy val generatedStackFile = taskKey[String]("generatedStackFile")
lazy val pluginSettings = defaultValues ++ tasks
override lazy val projectSettings = pluginSettings
}
sealed trait CloudFormationOption {
val value: String
}
case class AwsAccountId(value: String) extends CloudFormationOption {
override def toString: String = value
}
case class AwsRoleName(value: String) extends CloudFormationOption {
override def toString: String = s"role/$value"
}
case class Environment(value: String) extends CloudFormationOption {
override def toString: String = value
}
package com.dwolla.sbt.cloudformation
import com.dwolla.awssdk.cloudformation.CloudFormationClient
import sbt._
import scala.language.postfixOps
trait CloudFormationStackKeys {
lazy val generateStack = InputKey[File]("generate")
lazy val deployStack = InputKey[String]("deploy")
lazy val stackParameters = TaskKey[List[(String, String)]]("parameters")
lazy val stackName = SettingKey[String]("stackName", "Name of the stack to deploy")
lazy val templateJson = SettingKey[File]("templateJson", "File location where the CloudFormation stack template will be output")
lazy val templateJsonFilename = settingKey[String]("Filename where the CloudFormation stack template will be output")
lazy val cloudformationOptions = InputKey[Seq[CloudFormationOption]]("cloudformationOptions")
lazy val awsAccountId = InputKey[Option[String]]("awsAccountId", "Optional Account ID used to help populate the Stack Role ARN")
lazy val awsRoleName = InputKey[Option[String]]("awsRoleName", "Optional role name used to help populate the Stack Role ARN")
lazy val stackRoleArn = InputKey[Option[String]]("stackRoleArn", "Optional Role ARN used by CloudFormation to execute the changes required by the stack")
lazy val cloudformationClient = settingKey[CloudFormationClient]("cloudformation client")
}
package com.dwolla.sbt.cloudformation
import sbt.complete.DefaultParsers._
import sbt.complete.Parser
object CloudFormationStackParsers {
val awsAccountIdParser: Parser[AwsAccountId] = charClass(_.isDigit, "digit").+.map(_.mkString).filter(_.length == 12, s ⇒ s"`$s` is not a 12-digit AWS Account ID").map(AwsAccountId)
val awsRoleNameParser: Parser[AwsRoleName] = ("role/" ~> (charClass(_.isLetterOrDigit, "alphanumeric") | chars("+=,.@_-")).+.map(_.mkString)).map(AwsRoleName)
val environmentParser: Parser[Environment] = ("Sandbox" | "DevInt" | "Uat" | "Prod" | "Admin").map(Environment)
val maybeAwsAccountIdParser: Parser[Option[AwsAccountId]] = awsAccountIdParser.?
val maybeAwsRoleNameParser: Parser[Option[AwsRoleName]] = awsRoleNameParser.?
val maybeEnvironment: Parser[Option[Environment]] = environmentParser.?
def select1(items: Seq[Parser[CloudFormationOption]]): Parser[CloudFormationOption] = {
val combined: Parser[CloudFormationOption] = items.reduceLeft(_ | _)
token(Space ~> combined)
}
def selectSome(items: Seq[(String, Parser[CloudFormationOption])]): Parser[Seq[CloudFormationOption]] = {
select1(items.map(_._2)).flatMap { (v: CloudFormationOption) ⇒
val remaining = items.filter { tuple ⇒
tuple._1 != v.getClass.getCanonicalName
}
if (remaining.isEmpty)
success(v :: Nil)
else
selectSome(remaining).?.map(v +: _.getOrElse(Seq()))
}
}
// these tuples are a terrible hack
val cloudFormationOptionParser: Parser[Seq[CloudFormationOption]] = OptSpace ~> selectSome(Seq(
classOf[AwsRoleName].getCanonicalName → awsRoleNameParser,
classOf[AwsAccountId].getCanonicalName → awsAccountIdParser,
classOf[Environment].getCanonicalName → environmentParser
)).?.map {
case Some(x) ⇒ x
case None ⇒ Seq.empty[CloudFormationOption]
}
private val awsRoleNameCharacters = "([-A-Za-z0-9+=,.@_]+)".r
}
package com.dwolla.sbt.cloudformation
import com.dwolla.awssdk.cloudformation.CloudFormationClient
import sbt.Attributed._
import sbt.Keys._
import sbt.{File, _}
import scala.concurrent.Await
import scala.concurrent.duration.Duration
import scala.language.reflectiveCalls
import scala.reflect.ClassTag
class CloudFormationStackPlugin {
val defaultStackParameters = List.empty[(String, String)]
val defaultTemplateJsonFilename = "cloudformation-template.json"
def runStackTemplateBuilder(maybeMainClass: Option[String], outputFile: File, scalaRun: ScalaRun, classpath: Seq[Attributed[File]], streams: TaskStreams): File = {
maybeMainClass.fold(throw new NoMainClassDetectedException) { mainClass ⇒
toError(scalaRun.run(mainClass, data(classpath), Seq(outputFile.getCanonicalPath), streams.log))
outputFile
}
}
def deployStack(projectName: String, input: String, params: List[(String, String)], maybeRoleArn: Option[String], client: CloudFormationClient): String = {
Await.result(client.createOrUpdateTemplate(projectName, input, params, maybeRoleArn), Duration.Inf)
}
def roleArn(maybeAccountId: Option[String], maybeRoleName: Option[String]): Option[String] = for {
accountId ← maybeAccountId
roleName ← maybeRoleName
} yield s"arn:aws:iam::$accountId:role/$roleName"
def find[T <: CloudFormationOption](seq: Seq[CloudFormationOption])(implicit tag: ClassTag[T]): Option[String] = seq.find {
case _: T ⇒ true
case _ ⇒ false
}.map(_.value)
}
class NoMainClassDetectedException extends RuntimeException("No main class detected.")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment