summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJakob Odersky <jakob@odersky.com>2017-04-01 13:21:15 -0700
committerJakob Odersky <jakob@odersky.com>2017-04-01 13:21:15 -0700
commit26aa8adc30a84d983d020e34b488ac22a31cb544 (patch)
tree5e2cee54da5f64011f56a85c4b09bd1add4eacc7
parent8a4ebe76200a2e570bd959d8780c3c0a0bf71d5c (diff)
downloadcrashbox-ci-26aa8adc30a84d983d020e34b488ac22a31cb544.tar.gz
crashbox-ci-26aa8adc30a84d983d020e34b488ac22a31cb544.tar.bz2
crashbox-ci-26aa8adc30a84d983d020e34b488ac22a31cb544.zip
Add yaml parser and docker executor
-rw-r--r--crashboxd/build.sbt15
-rw-r--r--crashboxd/src/main/scala/io/crashbox/ci/DockerExecutor.scala140
-rw-r--r--crashboxd/src/main/scala/io/crashbox/ci/Parser.scala44
-rw-r--r--crashboxd/src/main/scala/io/crashbox/ci/defs.scala7
-rw-r--r--crashboxd/src/main/scala/io/crashbox/ci/yaml/CompositeReaders.scala29
-rw-r--r--crashboxd/src/main/scala/io/crashbox/ci/yaml/DefaultReaders.scala6
-rw-r--r--crashboxd/src/main/scala/io/crashbox/ci/yaml/SimpleReaders.scala46
-rw-r--r--crashboxd/src/main/scala/io/crashbox/ci/yaml/Yaml.scala36
-rw-r--r--crashboxd/src/main/scala/io/crashbox/ci/yaml/YamlFormatException.scala4
-rw-r--r--crashboxd/src/main/scala/io/crashbox/ci/yaml/YamlReader.scala24
-rw-r--r--crashboxd/src/main/scala/io/crashbox/ci/yaml/values.scala12
-rw-r--r--crashboxd/src/test/scala/io/crashbox/ci/DockerExecutorSpec.scala122
-rw-r--r--crashboxd/src/test/scala/io/crashbox/ci/ParserSpec.scala19
-rw-r--r--crashboxd/src/test/scala/io/crashbox/ci/yaml/CompositeReadersSpec.scala26
-rw-r--r--crashboxd/src/test/scala/io/crashbox/ci/yaml/SimpleReadersSpec.scala23
-rw-r--r--crashboxd/src/test/scala/io/crashbox/ci/yaml/YamlSpec.scala40
-rw-r--r--project/Dependencies.scala2
-rw-r--r--project/plugins.sbt2
18 files changed, 596 insertions, 1 deletions
diff --git a/crashboxd/build.sbt b/crashboxd/build.sbt
new file mode 100644
index 0000000..ada2433
--- /dev/null
+++ b/crashboxd/build.sbt
@@ -0,0 +1,15 @@
+import crashbox._
+
+libraryDependencies ++= Seq(
+ Dependencies.akkaActor,
+ Dependencies.akkaHttp,
+ Dependencies.akkaHttpCore,
+ Dependencies.akkaHttpSpray,
+ Dependencies.akkaStream,
+ Dependencies.jgitArchive,
+ Dependencies.jgitServer,
+ Dependencies.dockerClient,
+ Dependencies.slick,
+ Dependencies.yaml,
+ Dependencies.scalatest % Test
+)
diff --git a/crashboxd/src/main/scala/io/crashbox/ci/DockerExecutor.scala b/crashboxd/src/main/scala/io/crashbox/ci/DockerExecutor.scala
new file mode 100644
index 0000000..3c24b06
--- /dev/null
+++ b/crashboxd/src/main/scala/io/crashbox/ci/DockerExecutor.scala
@@ -0,0 +1,140 @@
+package io.crashbox.ci
+
+import java.io.{File, OutputStream}
+
+import scala.collection.JavaConverters._
+import scala.concurrent.Future
+import scala.concurrent.duration._
+
+import akka.actor.ActorSystem
+import com.spotify.docker.client.DefaultDockerClient
+import com.spotify.docker.client.DockerClient.{
+ AttachParameter,
+ ListContainersParam
+}
+import com.spotify.docker.client.LogStream
+import com.spotify.docker.client.exceptions.ContainerNotFoundException
+import com.spotify.docker.client.messages.{ContainerConfig, HostConfig}
+import com.spotify.docker.client.messages.HostConfig.Bind
+
+object DockerExecutor {
+
+ case class ExecutionId(id: String) extends AnyVal
+
+ def containerUser = "crashbox"
+ def containerWorkDirectory = "/home/crashbox"
+ def containerKillTimeout = 5.seconds
+
+}
+
+class DockerExecutor(uri: String = "unix:///run/docker.sock")(
+ implicit system: ActorSystem) {
+ import DockerExecutor._
+ import system.log
+
+ val dockerClient = {
+ val c = DefaultDockerClient.builder().uri(uri).build()
+ system.registerOnTermination {
+ c.close()
+ }
+ c
+ }
+
+ /*
+ def makeImage() = {
+ val returnedImageId = dockerClient.build(
+ Paths.get("docker directory"), "test", new ProgressHandler() {
+ override def progress(message: ProgressMessage) = {
+ val imageId = message.buildImageId()
+ message.buildImageId()
+ if (imageId != null) {
+ //imageIdFromMessage.set(imageId);
+ }
+ }
+ })
+ //dockerClient.build
+ }*/
+
+ def start(
+ image: String,
+ script: String,
+ buildDirectory: File,
+ out: OutputStream
+ ): Future[ExecutionId] =
+ Future {
+ val volume = Bind
+ .builder()
+ .from(buildDirectory.getAbsolutePath)
+ .to(containerWorkDirectory)
+ .build()
+ val hostConfig = HostConfig.builder().binds(volume).build()
+ val containerConfig = ContainerConfig
+ .builder()
+ .labels(Map("crashbox" -> "build").asJava)
+ .hostConfig(hostConfig)
+ .tty(true) // combine stdout and stderr into stdout
+ .image(image)
+ .user(containerUser)
+ .workingDir(containerWorkDirectory)
+ .entrypoint("/bin/sh", "-c")
+ .cmd(script)
+ .build()
+ val container = dockerClient.createContainer(containerConfig).id
+
+ log.debug(s"Starting container $container")
+ dockerClient.startContainer(container)
+
+ log.debug(s"Attaching log stream of container $container")
+ system.dispatcher execute new Runnable {
+ override def run() = {
+ var stream: LogStream = null
+ try {
+ stream = dockerClient.attachContainer(
+ container,
+ AttachParameter.LOGS,
+ AttachParameter.STDOUT,
+ AttachParameter.STREAM
+ )
+ stream.attach(out, null, true)
+ } finally {
+ if (stream != null) stream.close()
+ }
+ }
+ }
+ ExecutionId(container)
+ }(system.dispatcher)
+
+ def result(id: ExecutionId): Future[Int] =
+ Future {
+ log.debug(s"Waiting for container $id to exit")
+ val res: Int = dockerClient.waitContainer(id.id).statusCode()
+ stop(id)
+ res
+ }(system.dispatcher)
+
+ def stop(id: ExecutionId): Unit = {
+ try {
+ log.debug(s"Stopping container $id")
+ dockerClient.stopContainer(id.id,
+ containerKillTimeout.toUnit(SECONDS).toInt)
+ log.debug(s"Removing container $id")
+ dockerClient.removeContainer(id.id)
+ } catch {
+ case _: ContainerNotFoundException => // build already cancelled
+ }
+ }
+
+ def reapDeadBuilds(): Unit = {
+ val stale = dockerClient
+ .listContainers(
+ ListContainersParam.withLabel("crashbox"),
+ ListContainersParam.withStatusExited()
+ )
+ .asScala
+ stale.foreach { container =>
+ log.warning(s"Removing stale container ${container.id}")
+ dockerClient.removeContainer(container.id)
+ }
+ }
+
+}
diff --git a/crashboxd/src/main/scala/io/crashbox/ci/Parser.scala b/crashboxd/src/main/scala/io/crashbox/ci/Parser.scala
new file mode 100644
index 0000000..5a58612
--- /dev/null
+++ b/crashboxd/src/main/scala/io/crashbox/ci/Parser.scala
@@ -0,0 +1,44 @@
+package io.crashbox.ci
+
+import io.crashbox.ci.yaml._
+import io.crashbox.ci.yaml.DefaultReaders._
+
+object Parser {
+ sealed trait Result
+ case class Success(buildDef: BuildDef) extends Result
+ case class Failure(error: String) extends Result
+
+ implicit object TaskDefReader extends YamlReader[TaskDef] {
+ def read(value: YamlValue) = {
+ val items = value.convertTo[Map[String, YamlValue]]
+ val image = items
+ .getOrElse("image",
+ throw new YamlFormatException("no image specified"))
+ .convertTo[String]
+ val script = items
+ .getOrElse("script",
+ throw new YamlFormatException("no script specified"))
+ .convertTo[String]
+ TaskDef(DockerEnvironment(image), script)
+ }
+ }
+
+ implicit object BuildDefReader extends YamlReader[BuildDef] {
+ def read(value: YamlValue) = {
+ val items = value.convertTo[Map[String, YamlValue]]
+ val tasks = items
+ .getOrElse("tasks",
+ throw new YamlFormatException("no tasks specified"))
+ .convertTo[Map[String, TaskDef]]
+ BuildDef(tasks.values.toSeq)
+ }
+ }
+
+ def parse(build: String): Result =
+ try {
+ Success(Yaml.parse(build).convertTo[BuildDef])
+ } catch {
+ case ex: YamlFormatException => Failure(ex.toString)
+ }
+
+}
diff --git a/crashboxd/src/main/scala/io/crashbox/ci/defs.scala b/crashboxd/src/main/scala/io/crashbox/ci/defs.scala
new file mode 100644
index 0000000..df014a0
--- /dev/null
+++ b/crashboxd/src/main/scala/io/crashbox/ci/defs.scala
@@ -0,0 +1,7 @@
+package io.crashbox.ci
+
+sealed trait Environment
+case class DockerEnvironment(image: String) extends Environment
+
+case class TaskDef(environment: Environment, script: String)
+case class BuildDef(tasks: Seq[TaskDef])
diff --git a/crashboxd/src/main/scala/io/crashbox/ci/yaml/CompositeReaders.scala b/crashboxd/src/main/scala/io/crashbox/ci/yaml/CompositeReaders.scala
new file mode 100644
index 0000000..4df72e1
--- /dev/null
+++ b/crashboxd/src/main/scala/io/crashbox/ci/yaml/CompositeReaders.scala
@@ -0,0 +1,29 @@
+package io.crashbox.ci
+package yaml
+
+trait CompositeReaders {
+
+ implicit def mapReader[V: YamlReader] = new YamlReader[Map[String, V]] {
+ override def read(yml: YamlValue) = yml match {
+ case YamlMap(m) =>
+ m.map {
+ case (key, value) =>
+ key -> value.convertTo[V]
+ }
+ case YamlString.Empty => Map.empty[String, V]
+ case _ => formatError(yml, "mapping")
+ }
+ }
+
+ implicit def seqReader[A: YamlReader] = new YamlReader[Seq[A]] {
+ override def read(yml: YamlValue) = yml match {
+ case YamlSeq(elements) =>
+ elements.map { v =>
+ v.convertTo[A]
+ }
+ case YamlString.Empty => Seq.empty[A]
+ case _ => formatError(yml, "sequence")
+ }
+ }
+
+}
diff --git a/crashboxd/src/main/scala/io/crashbox/ci/yaml/DefaultReaders.scala b/crashboxd/src/main/scala/io/crashbox/ci/yaml/DefaultReaders.scala
new file mode 100644
index 0000000..9d40bad
--- /dev/null
+++ b/crashboxd/src/main/scala/io/crashbox/ci/yaml/DefaultReaders.scala
@@ -0,0 +1,6 @@
+package io.crashbox.ci
+package yaml
+
+trait DefaultReaders extends SimpleReaders with CompositeReaders
+
+object DefaultReaders extends DefaultReaders
diff --git a/crashboxd/src/main/scala/io/crashbox/ci/yaml/SimpleReaders.scala b/crashboxd/src/main/scala/io/crashbox/ci/yaml/SimpleReaders.scala
new file mode 100644
index 0000000..df30a33
--- /dev/null
+++ b/crashboxd/src/main/scala/io/crashbox/ci/yaml/SimpleReaders.scala
@@ -0,0 +1,46 @@
+package io.crashbox.ci
+package yaml
+
+import scala.util.Try
+
+trait SimpleReaders {
+
+ class YamlStringReader[A](expected: String)(extract: String => Option[A])
+ extends YamlReader[A] {
+ def read(yml: YamlValue) = yml match {
+ case YamlString(value) =>
+ extract(value) match {
+ case Some(a) => a
+ case None =>
+ throw new YamlFormatException(
+ s"""expected $expected, but found string type "$value"""")
+ }
+ case _ => formatError(yml, expected)
+ }
+ }
+
+ implicit object valueReader extends YamlReader[YamlValue] {
+ def read(yaml: YamlValue) = yaml
+ }
+
+ implicit object stringReader
+ extends YamlStringReader[String]("string")(s => Some(s))
+
+ implicit object byteReader
+ extends YamlStringReader[Byte]("byte")(s => Try { s.toByte }.toOption)
+ implicit object shortReader
+ extends YamlStringReader[Short]("short")(s => Try { s.toShort }.toOption)
+ implicit object intReader
+ extends YamlStringReader[Int]("integer")(s => Try { s.toInt }.toOption)
+ implicit object longReader
+ extends YamlStringReader[Long]("long")(s => Try { s.toLong }.toOption)
+ implicit object floatReader
+ extends YamlStringReader[Float]("float")(s => Try { s.toFloat }.toOption)
+ implicit object doubleReader
+ extends YamlStringReader[Double]("double")(s =>
+ Try { s.toDouble }.toOption)
+ implicit object booleanReader
+ extends YamlStringReader[Boolean]("boolean")(s =>
+ Try { s.toBoolean }.toOption)
+
+}
diff --git a/crashboxd/src/main/scala/io/crashbox/ci/yaml/Yaml.scala b/crashboxd/src/main/scala/io/crashbox/ci/yaml/Yaml.scala
new file mode 100644
index 0000000..0370c76
--- /dev/null
+++ b/crashboxd/src/main/scala/io/crashbox/ci/yaml/Yaml.scala
@@ -0,0 +1,36 @@
+package io.crashbox.ci
+package yaml
+
+import java.util.{List => JList, Map => JMap}
+
+import scala.collection.JavaConverters._
+
+import org.yaml.snakeyaml.{DumperOptions, Yaml => SYaml}
+import org.yaml.snakeyaml.constructor.Constructor
+import org.yaml.snakeyaml.representer.Representer
+import org.yaml.snakeyaml.resolver.Resolver
+
+object Yaml {
+
+ private def toYaml(yml: Any): YamlValue = yml match {
+ case m: JMap[_, _] =>
+ YamlMap(m.asScala.toMap.map { case (k, v) => k.toString -> toYaml(v) })
+ case l: JList[_] => YamlSeq(l.asScala.toList.map(toYaml(_)))
+ case s: String => YamlString(s)
+ case other => throw new YamlFormatException("Unknown YAML type: " + other)
+ }
+
+ /** Strict parsing */
+ def parse(data: String): YamlValue = {
+ val resolver = new Resolver {
+ override def addImplicitResolvers: Unit = {}
+ }
+ val yml = new SYaml(new Constructor(),
+ new Representer(),
+ new DumperOptions(),
+ resolver)
+ val node = yml.load(data)
+ toYaml(node)
+ }
+
+}
diff --git a/crashboxd/src/main/scala/io/crashbox/ci/yaml/YamlFormatException.scala b/crashboxd/src/main/scala/io/crashbox/ci/yaml/YamlFormatException.scala
new file mode 100644
index 0000000..1700900
--- /dev/null
+++ b/crashboxd/src/main/scala/io/crashbox/ci/yaml/YamlFormatException.scala
@@ -0,0 +1,4 @@
+package io.crashbox.ci
+package yaml
+
+class YamlFormatException(message: String) extends RuntimeException(message)
diff --git a/crashboxd/src/main/scala/io/crashbox/ci/yaml/YamlReader.scala b/crashboxd/src/main/scala/io/crashbox/ci/yaml/YamlReader.scala
new file mode 100644
index 0000000..f486676
--- /dev/null
+++ b/crashboxd/src/main/scala/io/crashbox/ci/yaml/YamlReader.scala
@@ -0,0 +1,24 @@
+package io.crashbox.ci
+package yaml
+
+trait YamlReader[A] {
+
+ def read(yml: YamlValue): A
+
+ protected def formatError(found: YamlValue, required: String) = {
+ val foundType = found match {
+ case _: YamlString => "string"
+ case _: YamlSeq => "sequence"
+ case _: YamlMap => "mapping"
+ }
+
+ throw new YamlFormatException(
+ s"$found is of type $foundType, required: $required"
+ )
+ }
+
+ protected def readError(node: YamlValue, msg: String) = {
+ throw new YamlFormatException(node.toString + ": " + msg)
+ }
+
+}
diff --git a/crashboxd/src/main/scala/io/crashbox/ci/yaml/values.scala b/crashboxd/src/main/scala/io/crashbox/ci/yaml/values.scala
new file mode 100644
index 0000000..dd4fb0f
--- /dev/null
+++ b/crashboxd/src/main/scala/io/crashbox/ci/yaml/values.scala
@@ -0,0 +1,12 @@
+package io.crashbox.ci
+package yaml
+
+sealed trait YamlValue {
+ def convertTo[A: YamlReader]: A = implicitly[YamlReader[A]].read(this)
+}
+case class YamlString(value: String) extends YamlValue
+object YamlString {
+ val Empty = YamlString("")
+}
+case class YamlMap(fields: Map[String, YamlValue]) extends YamlValue
+case class YamlSeq(elements: Seq[YamlValue]) extends YamlValue
diff --git a/crashboxd/src/test/scala/io/crashbox/ci/DockerExecutorSpec.scala b/crashboxd/src/test/scala/io/crashbox/ci/DockerExecutorSpec.scala
new file mode 100644
index 0000000..2b6dce7
--- /dev/null
+++ b/crashboxd/src/test/scala/io/crashbox/ci/DockerExecutorSpec.scala
@@ -0,0 +1,122 @@
+package io.crashbox.ci
+
+import java.io.{ByteArrayOutputStream, File}
+import java.nio.file.Files
+
+import scala.collection.JavaConverters._
+import scala.concurrent.Await
+import scala.concurrent.duration._
+
+import akka.actor.ActorSystem
+import org.scalatest._
+
+class DockerExecutorSpec
+ extends FlatSpec
+ with Matchers
+ with BeforeAndAfterAll {
+
+ val image = "crashbox"
+
+ val timeout = 30.seconds
+
+ implicit val system = ActorSystem("docker-test")
+ import system.dispatcher
+ val exec = new DockerExecutor
+
+ override def beforeAll: Unit = {
+ println("Pulling base docker image for running docker tests")
+ val base = "debian:jessie-backports"
+ exec.dockerClient.pull(base)
+
+ withTmp { dir =>
+ println("Adapting base image for tests")
+ val modifications = s"""|FROM $base
+ |RUN adduser crashbox
+ |USER crashbox
+ |""".stripMargin
+ Files.write((new File(dir, "Dockerfile")).toPath, modifications.getBytes)
+ exec.dockerClient.build(dir.toPath, image)
+ }
+
+ }
+
+ override def afterAll: Unit = {
+ system.terminate()
+ }
+
+ def withTmp[A](action: File => A): A = {
+ val dir = Files.createTempDirectory("crashbox-docker-test").toFile
+ try action(dir)
+ finally dir.delete()
+ }
+
+ def run[A](script: String)(tests: (Int, File, String) => A): A = withTmp {
+ dir =>
+ val out = new ByteArrayOutputStream(1024)
+ val awaitable = for (id <- exec.start(image, script, dir, out);
+ status <- exec.result(id)) yield {
+ status
+ }
+ val status = Await.result(awaitable, timeout)
+ tests(status, dir, new String(out.toByteArray()).trim())
+ }
+
+ "DockerExecutor" should "return expected exit codes" in {
+ run("true") {
+ case (status, _, _) =>
+ assert(status == 0)
+ }
+ run("false") {
+ case (status, _, _) =>
+ assert(status == 1)
+ }
+ run("nonexistant") {
+ case (status, _, _) =>
+ assert(status == 127)
+ }
+ }
+
+ it should "print the expected output" in {
+ run("echo hello world") {
+ case (_, _, out) =>
+ assert(out == "hello world")
+ }
+ run("echo hello world >&2") {
+ case (_, _, out) =>
+ assert(out == "hello world")
+ }
+ run("echo hello world > /dev/null") {
+ case (_, _, out) =>
+ assert(out == "")
+ }
+ }
+
+ it should "create expected files" in {
+ run("echo hello world > data") {
+ case (_, dir, _) =>
+ val data = Files
+ .lines((new File(dir, "data")).toPath)
+ .iterator()
+ .asScala
+ .mkString("\n")
+ assert(data == "hello world")
+ }
+ }
+
+ it should "allow cancellation" in {
+ withTmp { dir =>
+ val script = "while true; do sleep 1; echo sleeping; done"
+ val out = new ByteArrayOutputStream(1024)
+
+ val id = Await.result(exec.start(image, script, dir, out), timeout)
+ val check = exec.result(id).map { res =>
+ assert(res == 137)
+ }
+ exec.stop(id)
+ //TODO check if resoruces were cleaned up properly
+
+ Await.result(check, timeout)
+ }
+ }
+
+}
diff --git a/crashboxd/src/test/scala/io/crashbox/ci/ParserSpec.scala b/crashboxd/src/test/scala/io/crashbox/ci/ParserSpec.scala
new file mode 100644
index 0000000..8834af9
--- /dev/null
+++ b/crashboxd/src/test/scala/io/crashbox/ci/ParserSpec.scala
@@ -0,0 +1,19 @@
+package io.crashbox.ci
+
+import org.scalatest._
+
+class ParserSpec extends FlatSpec with Matchers {
+
+ val build = """|tasks:
+ | main:
+ | image: foo/bar
+ | script: echo "hello world"
+ |""".stripMargin
+
+ val parsed = BuildDef(
+ Seq(TaskDef(DockerEnvironment("foo/bar"), "echo \"hello world\"")))
+
+ "Parser" should "parse build definitions" in {
+ assert(Parser.parse(build) == Parser.Success(parsed))
+ }
+}
diff --git a/crashboxd/src/test/scala/io/crashbox/ci/yaml/CompositeReadersSpec.scala b/crashboxd/src/test/scala/io/crashbox/ci/yaml/CompositeReadersSpec.scala
new file mode 100644
index 0000000..02a13ca
--- /dev/null
+++ b/crashboxd/src/test/scala/io/crashbox/ci/yaml/CompositeReadersSpec.scala
@@ -0,0 +1,26 @@
+package io.crashbox.ci
+package yaml
+
+import org.scalatest._
+
+class CompositeReadersSpec
+ extends FlatSpec
+ with Matchers
+ with CompositeReaders
+ with SimpleReaders {
+
+ "CompositeReaders" should "convert yaml" in {
+ assert(
+ Yaml.parse("hello: world").convertTo[Map[String, String]] == Map(
+ "hello" -> "world"))
+ assert(
+ Yaml.parse("hello: 42").convertTo[Map[String, Int]] == Map(
+ "hello" -> 42))
+
+ assert(Yaml.parse("- 42").convertTo[Seq[Int]] == Seq(42))
+ assert(
+ Yaml.parse("hello:\n - 42").convertTo[Map[String, Seq[Int]]] == Map(
+ "hello" -> Seq(42)))
+ }
+
+}
diff --git a/crashboxd/src/test/scala/io/crashbox/ci/yaml/SimpleReadersSpec.scala b/crashboxd/src/test/scala/io/crashbox/ci/yaml/SimpleReadersSpec.scala
new file mode 100644
index 0000000..198e921
--- /dev/null
+++ b/crashboxd/src/test/scala/io/crashbox/ci/yaml/SimpleReadersSpec.scala
@@ -0,0 +1,23 @@
+package io.crashbox.ci
+package yaml
+
+import org.scalatest._
+
+class SimpleReadersSpec extends FlatSpec with Matchers with SimpleReaders {
+
+ "SimpleReaders" should "convert yaml" in {
+ assert(Yaml.parse("hello").convertTo[String] == "hello")
+ assert(Yaml.parse("42").convertTo[Byte] == 42.toByte)
+ assert(Yaml.parse("42").convertTo[Short] == 42.toShort)
+ assert(Yaml.parse("42").convertTo[Int] == 42)
+ assert(Yaml.parse("42").convertTo[Long] == 42l)
+ assert(Yaml.parse("42.0").convertTo[Float] == 42f)
+ assert(Yaml.parse("42.0").convertTo[Double] == 42.0)
+ assert(Yaml.parse("true").convertTo[Boolean] == true)
+ assert(Yaml.parse("false").convertTo[Boolean] == false)
+ }
+
+ "SimpleReaders" should "fail to convert invalid yaml" in {
+ intercept[YamlFormatException](Yaml.parse("foo").convertTo[Boolean])
+ }
+}
diff --git a/crashboxd/src/test/scala/io/crashbox/ci/yaml/YamlSpec.scala b/crashboxd/src/test/scala/io/crashbox/ci/yaml/YamlSpec.scala
new file mode 100644
index 0000000..6d1de66
--- /dev/null
+++ b/crashboxd/src/test/scala/io/crashbox/ci/yaml/YamlSpec.scala
@@ -0,0 +1,40 @@
+package io.crashbox.ci
+package yaml
+
+import org.scalatest._
+
+class YamlSpec extends FlatSpec with Matchers {
+
+ val yml = """|---
+ |foo: bar
+ |buz: qux
+ |list:
+ | - elem1
+ | - elem2
+ |map:
+ | elem1: foo
+ | elem2: bar
+ | elem3:
+ |""".stripMargin
+
+ val tree = YamlMap(
+ Map(
+ "foo" -> YamlString("bar"),
+ "buz" -> YamlString("qux"),
+ "list" -> YamlSeq(
+ Seq(
+ YamlString("elem1"),
+ YamlString("elem2")
+ )),
+ "map" -> YamlMap(
+ Map(
+ "elem1" -> YamlString("foo"),
+ "elem2" -> YamlString("bar"),
+ "elem3" -> YamlString("")
+ ))
+ ))
+
+ "Yaml" should "parse valid yaml" in {
+ assert(Yaml.parse(yml) == tree)
+ }
+}
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index 6038711..1c5c12a 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -20,4 +20,6 @@ object Dependencies {
val scalatest = "org.scalatest" %% "scalatest" % "3.0.1"
+ val yaml = "org.yaml" % "snakeyaml" % "1.18"
+
}
diff --git a/project/plugins.sbt b/project/plugins.sbt
index c0f528d..111a274 100644
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ -2,4 +2,4 @@
addSbtPlugin("io.spray" % "sbt-revolver" % "0.8.0")
// format source code
-addSbtPlugin("com.geirsson" % "sbt-scalafmt" % "0.5.6")
+addSbtPlugin("com.geirsson" % "sbt-scalafmt" % "0.6.6")