├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .jvmopts ├── .scalafmt.conf ├── LICENSE ├── README.md ├── build.sbt ├── core └── src │ └── main │ └── scala │ └── com │ └── whisk │ └── docker │ └── testkit │ ├── BaseContainer.scala │ ├── Container.scala │ ├── ContainerCommandExecutor.scala │ ├── ContainerPort.scala │ ├── ContainerSpec.scala │ ├── DockerContainerManager.scala │ ├── DockerReadyChecker.scala │ ├── DockerTestTimeouts.scala │ └── package.scala ├── notes ├── 0.10.0.markdown ├── 0.11.0.markdown ├── 0.3.0.markdown ├── 0.4.0.markdown ├── 0.5.4.markdown ├── 0.6.1.markdown ├── 0.9.0.markdown ├── 0.9.5.markdown ├── 0.9.9.markdown └── about.markdown ├── project ├── build.properties └── plugins.sbt ├── samples └── src │ └── main │ └── scala │ └── com │ └── whisk │ └── docker │ └── testkit │ ├── DockerClickhouseService.scala │ ├── DockerElasticsearchService.scala │ ├── DockerMongodbService.scala │ ├── DockerMysqlService.scala │ └── DockerPostgresService.scala ├── scalatest └── src │ └── main │ └── scala │ └── com │ └── whisk │ └── docker │ └── testkit │ └── scalatest │ └── DockerTestKitForAll.scala └── tests └── src └── test └── scala └── com └── whisk └── docker └── testkit └── test ├── ClickhouseServiceTest.scala ├── ElasticsearchServiceTest.scala ├── MongodbServiceTest.scala ├── MultiContainerTest.scala ├── MysqlServiceTest.scala └── PostgresServiceTest.scala /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | [ push, workflow_dispatch ] 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@v2 10 | - name: Setup JDK 11 | uses: actions/setup-java@v2 12 | with: 13 | distribution: temurin 14 | java-version: 11 15 | - name: Build and Test 16 | run: sbt -v +test 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | target-shaded/ 3 | project/sbt-launch-*.jar 4 | .idea/ 5 | .DS_Store 6 | .bsp/ 7 | -------------------------------------------------------------------------------- /.jvmopts: -------------------------------------------------------------------------------- 1 | -Xms512M 2 | -Xmx4096M 3 | -Xss2M 4 | -XX:MaxMetaspaceSize=1024M 5 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "2.6.4" 2 | style = default 3 | maxColumn = 100 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 WhiskLabs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | docker-it-scala 2 | ============= 3 | 4 | [![CI](https://github.com/whisklabs/docker-it-scala/actions/workflows/ci.yaml/badge.svg)](https://github.com/whisklabs/docker-it-scala/actions/workflows/ci.yaml) 5 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.whisk/docker-testkit-core_2.12/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.whisk/docker-testkit-core_2.12) 6 | [![Join the chat at https://gitter.im/whisklabs/docker-it-scala](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/whisklabs/docker-it-scala?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 7 | 8 | Set of utility classes to make integration testing with dockerised services in Scala easy. 9 | 10 | You can read about reasoning behind it at [Finely Distributed](https://finelydistributed.io/integration-testing-with-docker-and-scala-85659d037740#.8mbrg311p). 11 | 12 | ## Setup 13 | 14 | docker-it-scala works with Spotify's docker-client to communicate to docker engine through *REST API* or *unix socket*. 15 | - [Spotify's docker-client](https://github.com/spotify/docker-client) (used in Whisk) 16 | 17 | ```scala 18 | libraryDependencies ++= Seq( 19 | "com.whisk" %% "docker-testkit-scalatest" % "0.11.0" % "test" 20 | ``` 21 | 22 | ### Configuration 23 | 24 | You should be able to provide configuration purely through environment variables. 25 | 26 | Examples: 27 | 28 | ``` 29 | export DOCKER_HOST=tcp://127.0.0.1:2375 30 | ``` 31 | 32 | ``` 33 | export DOCKER_HOST=unix:///var/run/docker.sock 34 | ``` 35 | 36 | 37 | # Sample Services 38 | 39 | - [Elasticsearch](https://github.com/whisklabs/docker-it-scala/blob/master/samples/src/main/scala/com/whisk/docker/testkit/DockerElasticsearchService.scala) 40 | - [Mongodb](https://github.com/whisklabs/docker-it-scala/blob/master/samples/src/main/scala/com/whisk/docker/testkit/DockerMongodbService.scala) 41 | - [MySQL](https://github.com/whisklabs/docker-it-scala/blob/master/samples/src/main/scala/com/whisk/docker/testkit/DockerMysqlService.scala) 42 | - [Postgres](https://github.com/whisklabs/docker-it-scala/blob/master/samples/src/main/scala/com/whisk/docker/testkit/DockerPostgresService.scala) 43 | - [Clickhouse](https://github.com/whisklabs/docker-it-scala/blob/master/samples/src/main/scala/com/whisk/docker/testkit/DockerClickhouseService.scala) 44 | - [Multi container test](https://github.com/whisklabs/docker-it-scala/blob/master/tests/src/test/scala/com/whisk/docker/testkit/test/MultiContainerTest.scala) 45 | # Defining Containers 46 | 47 | There are two ways to define a docker container. 48 | 49 | Code based definitions and via `typesafe-config`. 50 | 51 | ## Code based definitions 52 | 53 | ```scala 54 | import com.whisk.docker.testkit.scalatest.DockerTestKitForAll 55 | import org.scalatest.Suite 56 | 57 | trait DockerMongodbService extends DockerTestKitForAll { 58 | self: Suite => 59 | 60 | val DefaultMongodbPort = 27017 61 | 62 | val mongodbContainer = ContainerSpec("mongo:3.4.8") 63 | .withExposedPorts(DefaultMongodbPort) 64 | .withReadyChecker(DockerReadyChecker.LogLineContains("waiting for connections on port")) 65 | .toContainer 66 | 67 | override val managedContainers: ManagedContainers = mongodbContainer.toManagedContainer 68 | } 69 | ``` 70 | 71 | You can check [usage example](https://github.com/whisklabs/docker-it-scala/blob/master/tests/src/test/scala/com/whisk/docker/testkit/test/MongodbServiceTest.scala) 72 | 73 | ### Container Paths 74 | 75 | - Elasticsearch => `docker.elasticsearch` 76 | - Mongodb => `docker.mongo` 77 | - Neo4j => `docker.mysql` 78 | - Postgres => `docker.postgres` 79 | - Clickhouse => `docker.clickhouse` 80 | ### Fields 81 | 82 | - `image-name` required (String) 83 | - `container-name` optional (String) 84 | - `command` optional (Array of Strings) 85 | - `entrypoint` optional (Array of Strings) 86 | - `environmental-variables` optional (Array of Strings) 87 | - `ready-checker` optional structure 88 | - `log-line` optional (String) 89 | - `http-response-code` 90 | - `code` optional (Int - defaults to `200`) 91 | - `port` required (Int) 92 | - `path` optional (String - defaults to `/`) 93 | - `within` optional (Int) 94 | - `looped` optional structure 95 | - `attempts` required (Int) 96 | - `delay` required (Int) 97 | - `port-maps` optional structure (list of structures) 98 | - `SOME_MAPPING_NAME` 99 | - `internal` required (Int) 100 | - `external` optional (Int) 101 | - `volume-maps` optional structure (list of structures) 102 | - `container` required (String) 103 | - `host` required (String) 104 | - `rw` optional (Boolean - default:false) 105 | - `memory` optional (Long) 106 | - `memory-reservation` optional (Long) 107 | 108 | # Testkit 109 | 110 | There are two testkits available -- one for `scalatest` and one for 111 | `specs2`. 112 | 113 | Both set up the necessary docker containers and check that they are 114 | ready **BEFORE** any test is run, and doesn't close the container 115 | until **ALL** the tests are run. 116 | 117 | 118 | ## Using in ScalaTest: 119 | 120 | ```scala 121 | class MyMongoSpec extends FlatSpec with Matchers with DockerMongodbService { 122 | ... 123 | } 124 | ``` 125 | 126 | ### With Multiple containers: 127 | 128 | ```scala 129 | class MultiContainerTest 130 | extends AnyFunSuite 131 | with DockerElasticsearchService 132 | with DockerMongodbService { 133 | 134 | override val managedContainers: ContainerGroup = 135 | ContainerGroup.of(elasticsearchContainer, mongodbContainer) 136 | 137 | test("both containers should be ready") { 138 | assert( 139 | elasticsearchContainer.state().isInstanceOf[ContainerState.Ready], 140 | "elasticsearch container is ready" 141 | ) 142 | assert(elasticsearchContainer.mappedPortOpt(9200).nonEmpty, "elasticsearch port is exposed") 143 | 144 | assert(mongodbContainer.state().isInstanceOf[ContainerState.Ready], "mongodb is ready") 145 | assert(mongodbContainer.mappedPortOpt(27017).nonEmpty, "port 2017 is exposed") 146 | } 147 | } 148 | ``` -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | lazy val commonSettings = Seq( 2 | organization := "com.whisk", 3 | version := "0.12.0", 4 | scalaVersion := "2.13.6", 5 | crossScalaVersions := Seq("2.13.6", "2.12.15", "2.11.12", "3.0.2"), 6 | scalacOptions ++= Seq("-feature", "-deprecation"), 7 | Test / fork := true, 8 | licenses += ("MIT", url("http://opensource.org/licenses/MIT")), 9 | sonatypeProfileName := "com.whisk", 10 | publishMavenStyle := true, 11 | publishTo := Some(Opts.resolver.sonatypeStaging), 12 | pomExtra in Global := { 13 | https://github.com/whisklabs/docker-it-scala 14 | 15 | scm:git:github.com/whisklabs/docker-it-scala.git 16 | scm:git:git@github.com:whisklabs/docker-it-scala.git 17 | github.com/whisklabs/docker-it-scala.git 18 | 19 | 20 | 21 | viktortnk 22 | Viktor Taranenko 23 | https://github.com/viktortnk 24 | 25 | 26 | alari 27 | Dmitry Kurinskiy 28 | https://github.com/alari 29 | 30 | 31 | } 32 | ) 33 | 34 | lazy val root = 35 | project 36 | .in(file(".")) 37 | .settings(commonSettings: _*) 38 | .settings(publish := {}, publishLocal := {}, packagedArtifacts := Map.empty) 39 | .aggregate(core, scalatest, samples, coreShaded) 40 | 41 | lazy val core = 42 | project 43 | .settings(commonSettings: _*) 44 | .settings( 45 | name := "docker-testkit-core", 46 | libraryDependencies ++= Seq( 47 | "org.slf4j" % "slf4j-api" % "1.7.25", 48 | "com.spotify" % "docker-client" % "8.16.0", 49 | "com.google.code.findbugs" % "jsr305" % "3.0.1" 50 | ) 51 | ) 52 | 53 | lazy val scalatest = 54 | project 55 | .settings(commonSettings: _*) 56 | .settings( 57 | name := "docker-testkit-scalatest", 58 | libraryDependencies ++= Seq( 59 | "org.scalatest" %% "scalatest" % "3.2.9", 60 | "ch.qos.logback" % "logback-classic" % "1.2.3" % "test" 61 | ) 62 | ) 63 | .dependsOn(core) 64 | 65 | lazy val samples = 66 | project 67 | .settings(commonSettings: _*) 68 | .settings(name := "docker-testkit-samples") 69 | .dependsOn(core, scalatest) 70 | 71 | lazy val tests = 72 | project 73 | .settings(commonSettings: _*) 74 | .settings( 75 | name := "docker-testkit-tests", 76 | libraryDependencies ++= Seq( 77 | "org.postgresql" % "postgresql" % "42.1.4" % "test", 78 | "mysql" % "mysql-connector-java" % "5.1.44" % "test", 79 | "com.clickhouse" % "clickhouse-jdbc" % "0.5.0" % "test" 80 | ) 81 | ) 82 | .dependsOn(core, scalatest, samples % "test") 83 | 84 | lazy val coreShaded = 85 | project 86 | .in(file("core")) 87 | .settings(commonSettings: _*) 88 | .settings( 89 | name := "docker-testkit-core-shaded", 90 | libraryDependencies ++= 91 | Seq( 92 | "com.spotify" % "docker-client" % "8.16.0" classifier "shaded", 93 | "com.google.code.findbugs" % "jsr305" % "3.0.1" 94 | ), 95 | target := baseDirectory.value / "target-shaded" 96 | ) 97 | -------------------------------------------------------------------------------- /core/src/main/scala/com/whisk/docker/testkit/BaseContainer.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit 2 | 3 | import java.util.concurrent.atomic.AtomicReference 4 | 5 | import com.spotify.docker.client.messages.ContainerInfo 6 | import org.slf4j.LoggerFactory 7 | 8 | import scala.collection.JavaConverters._ 9 | 10 | sealed trait ContainerState 11 | 12 | object ContainerState { 13 | 14 | trait HasId extends ContainerState { 15 | val id: String 16 | } 17 | 18 | trait IsRunning extends HasId { 19 | val info: ContainerInfo 20 | override val id: String = info.id 21 | } 22 | 23 | object NotStarted extends ContainerState 24 | case class Created(id: String) extends ContainerState with HasId 25 | case class Starting(id: String) extends ContainerState with HasId 26 | case class Running(info: ContainerInfo) extends ContainerState with IsRunning 27 | case class Ready(info: ContainerInfo) extends ContainerState with IsRunning 28 | case class Failed(id: String) extends ContainerState 29 | object Stopped extends ContainerState 30 | } 31 | 32 | abstract class BaseContainer { 33 | 34 | def spec: ContainerSpec 35 | 36 | private lazy val log = LoggerFactory.getLogger(this.getClass) 37 | 38 | private val _state = new AtomicReference[ContainerState](ContainerState.NotStarted) 39 | 40 | def state(): ContainerState = { 41 | _state.get() 42 | } 43 | 44 | private def updateState(state: ContainerState): Unit = { 45 | _state.set(state) 46 | } 47 | 48 | private[docker] def created(id: String): Unit = { 49 | updateState(ContainerState.Created(id)) 50 | } 51 | 52 | private[docker] def starting(id: String): Unit = { 53 | updateState(ContainerState.Starting(id)) 54 | } 55 | 56 | private[docker] def running(info: ContainerInfo): Unit = { 57 | updateState(ContainerState.Running(info)) 58 | } 59 | 60 | private[docker] def ready(info: ContainerInfo): Unit = { 61 | updateState(ContainerState.Ready(info)) 62 | } 63 | 64 | private def addresses(info: ContainerInfo): Seq[String] = { 65 | val addrs: Iterable[String] = for { 66 | networks <- Option(info.networkSettings().networks()).map(_.asScala).toSeq 67 | (key, network) <- networks 68 | ip <- Option(network.ipAddress) 69 | } yield { 70 | ip 71 | } 72 | addrs.toList 73 | } 74 | 75 | private def portsFrom(info: ContainerInfo): Map[Int, Int] = { 76 | info 77 | .networkSettings() 78 | .ports() 79 | .asScala 80 | .collect { 81 | case (portStr, bindings) if Option(bindings).exists(!_.isEmpty) => 82 | val port = ContainerPort.parsed(portStr).port 83 | val hostPort = bindings.get(0).hostPort().toInt 84 | port -> hostPort 85 | } 86 | .toMap 87 | } 88 | 89 | def ipAddresses(): Seq[String] = { 90 | state() match { 91 | case s: ContainerState.IsRunning => 92 | addresses(s.info) 93 | case _ => 94 | throw new Exception("can't get addresses of not running container") 95 | } 96 | } 97 | 98 | def mappedPorts(): Map[Int, Int] = { 99 | state() match { 100 | case s: ContainerState.IsRunning => 101 | portsFrom(s.info) 102 | case _ => 103 | throw new Exception("can't get ports of not running container") 104 | } 105 | } 106 | 107 | def mappedPort(port: Int): Int = { 108 | mappedPorts().apply(port) 109 | } 110 | 111 | def mappedPortOpt(port: Int): Option[Int] = { 112 | mappedPorts().get(port) 113 | } 114 | 115 | def toManagedContainer: SingleContainer = SingleContainer(this) 116 | } 117 | -------------------------------------------------------------------------------- /core/src/main/scala/com/whisk/docker/testkit/Container.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit 2 | 3 | class Container(override val spec: ContainerSpec) extends BaseContainer 4 | -------------------------------------------------------------------------------- /core/src/main/scala/com/whisk/docker/testkit/ContainerCommandExecutor.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit 2 | 3 | import java.nio.charset.StandardCharsets 4 | import java.util.concurrent.TimeUnit 5 | 6 | import com.google.common.io.Closeables 7 | import com.spotify.docker.client.DockerClient.{AttachParameter, RemoveContainerParam} 8 | import com.spotify.docker.client.messages._ 9 | import com.spotify.docker.client.{DockerClient, LogMessage, LogStream} 10 | 11 | import scala.concurrent.{ExecutionContext, Future, Promise} 12 | 13 | class StartFailedException(msg: String) extends Exception(msg) 14 | 15 | class ContainerCommandExecutor(val client: DockerClient) { 16 | 17 | def createContainer( 18 | spec: ContainerSpec 19 | )(implicit ec: ExecutionContext): Future[ContainerCreation] = { 20 | Future( 21 | scala.concurrent.blocking(client.createContainer(spec.containerConfig(), spec.name.orNull)) 22 | ) 23 | } 24 | 25 | def startContainer(id: String)(implicit ec: ExecutionContext): Future[Unit] = { 26 | Future(scala.concurrent.blocking(client.startContainer(id))) 27 | } 28 | 29 | def runningContainer(id: String)(implicit ec: ExecutionContext): Future[ContainerInfo] = { 30 | def inspect() = { 31 | Future(scala.concurrent.blocking(client.inspectContainer(id))).flatMap { info => 32 | val status = info.state().status() 33 | val badStates = Set("removing", "paused", "exited", "dead") 34 | if (status == "running") { 35 | Future.successful(info) 36 | } else if (badStates(status)) { 37 | Future.failed(new StartFailedException("container is in unexpected state: " + status)) 38 | } else { 39 | Future.failed(new Exception("not running yet")) 40 | } 41 | } 42 | } 43 | 44 | def attempt(rest: Int): Future[ContainerInfo] = { 45 | inspect().recoverWith { 46 | case e: StartFailedException => Future.failed(e) 47 | case _ if rest > 0 => 48 | RetryUtils.withDelay(TimeUnit.SECONDS.toMillis(1))(attempt(rest - 1)) 49 | case _ => 50 | Future.failed(new StartFailedException("failed to get container in running state")) 51 | } 52 | } 53 | 54 | attempt(10) 55 | } 56 | 57 | private def logStreamFuture(id: String, withErr: Boolean)(implicit 58 | ec: ExecutionContext 59 | ): Future[LogStream] = { 60 | val baseParams = List(AttachParameter.STDOUT, AttachParameter.STREAM, AttachParameter.LOGS) 61 | val logParams = if (withErr) AttachParameter.STDERR :: baseParams else baseParams 62 | Future(scala.concurrent.blocking(client.attachContainer(id, logParams: _*))) 63 | } 64 | 65 | def withLogStreamLines(id: String, withErr: Boolean)( 66 | f: String => Unit 67 | )(implicit ec: ExecutionContext): Unit = { 68 | 69 | logStreamFuture(id, withErr).foreach { stream => 70 | stream.forEachRemaining(new java.util.function.Consumer[LogMessage] { 71 | 72 | override def accept(t: LogMessage): Unit = { 73 | val str = StandardCharsets.US_ASCII.decode(t.content()).toString 74 | f(s"[$id] $str") 75 | } 76 | }) 77 | } 78 | } 79 | 80 | def withLogStreamLinesRequirement(id: String, withErr: Boolean)( 81 | f: String => Boolean 82 | )(implicit ec: ExecutionContext): Future[Unit] = { 83 | 84 | logStreamFuture(id, withErr).flatMap { stream => 85 | val p = Promise[Unit]() 86 | Future { 87 | stream.forEachRemaining(new java.util.function.Consumer[LogMessage] { 88 | 89 | override def accept(t: LogMessage): Unit = { 90 | val str = StandardCharsets.US_ASCII.decode(t.content()).toString 91 | if (f(str)) { 92 | p.trySuccess(()) 93 | Closeables.close(stream, true) 94 | } 95 | } 96 | }) 97 | } 98 | p.future 99 | } 100 | } 101 | 102 | def remove(id: String, force: Boolean, removeVolumes: Boolean)(implicit 103 | ec: ExecutionContext 104 | ): Future[Unit] = { 105 | Future( 106 | scala.concurrent.blocking( 107 | client.removeContainer( 108 | id, 109 | RemoveContainerParam.forceKill(force), 110 | RemoveContainerParam.removeVolumes(removeVolumes) 111 | ) 112 | ) 113 | ) 114 | } 115 | 116 | def close(): Unit = { 117 | Closeables.close(client, true) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /core/src/main/scala/com/whisk/docker/testkit/ContainerPort.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit 2 | 3 | object PortProtocol extends Enumeration { 4 | val TCP, UDP = Value 5 | } 6 | 7 | case class ContainerPort(port: Int, protocol: PortProtocol.Value) 8 | 9 | object ContainerPort { 10 | def parsed(str: String): ContainerPort = { 11 | val Array(p, rest @ _*) = str.split("/") 12 | val proto = rest.headOption 13 | .flatMap(pr => PortProtocol.values.find(_.toString.equalsIgnoreCase(pr))) 14 | .getOrElse(PortProtocol.TCP) 15 | ContainerPort(p.toInt, proto) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /core/src/main/scala/com/whisk/docker/testkit/ContainerSpec.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit 2 | 3 | import java.util.Collections 4 | 5 | import com.spotify.docker.client.messages.{ContainerConfig, HostConfig, PortBinding} 6 | import com.spotify.docker.client.messages.HostConfig.Bind 7 | 8 | import scala.collection.JavaConverters._ 9 | 10 | case class ContainerSpec(image: String) { 11 | 12 | private val builder: ContainerConfig.Builder = ContainerConfig.builder().image(image) 13 | private val hostConfigBuilder: HostConfig.Builder = HostConfig.builder() 14 | 15 | private var _readyChecker: Option[DockerReadyChecker] = None 16 | private var _name: Option[String] = None 17 | 18 | def withCommand(cmd: String*): ContainerSpec = { 19 | builder.cmd(cmd: _*) 20 | this 21 | } 22 | 23 | def withExposedPorts(ports: Int*): ContainerSpec = { 24 | val binds: Seq[(Int, PortBinding)] = 25 | ports.map(p => p -> PortBinding.randomPort("0.0.0.0")).toSeq 26 | withPortBindings(binds: _*) 27 | } 28 | 29 | def withPortBindings(ps: (Int, PortBinding)*): ContainerSpec = { 30 | val binds: Map[String, java.util.List[PortBinding]] = ps.map { 31 | case (guestPort, binding) => 32 | guestPort.toString -> Collections.singletonList(binding) 33 | }.toMap 34 | 35 | hostConfigBuilder.portBindings(binds.asJava) 36 | builder.exposedPorts(binds.keySet.asJava) 37 | this 38 | } 39 | 40 | def withVolumeBindings(vs: Bind*): ContainerSpec = { 41 | hostConfigBuilder.binds(vs: _*) 42 | this 43 | } 44 | 45 | def withReadyChecker(checker: DockerReadyChecker): ContainerSpec = { 46 | _readyChecker = Some(checker) 47 | this 48 | } 49 | 50 | def withName(name: String): ContainerSpec = { 51 | _name = Some(name) 52 | this 53 | } 54 | 55 | def withEnv(env: String*): ContainerSpec = { 56 | builder.env(env: _*) 57 | this 58 | } 59 | 60 | def withConfiguration( 61 | withBuilder: ContainerConfig.Builder => ContainerConfig.Builder 62 | ): ContainerSpec = { 63 | withBuilder(builder) 64 | this 65 | } 66 | 67 | def withHostConfiguration( 68 | withBuilder: HostConfig.Builder => HostConfig.Builder 69 | ): ContainerSpec = { 70 | withBuilder(hostConfigBuilder) 71 | this 72 | } 73 | 74 | def name: Option[String] = _name 75 | 76 | def readyChecker: Option[DockerReadyChecker] = _readyChecker 77 | 78 | def containerConfig(): ContainerConfig = { 79 | builder.hostConfig(hostConfigBuilder.build()).build() 80 | } 81 | 82 | def toContainer: Container = new Container(this) 83 | 84 | def toManagedContainer: SingleContainer = SingleContainer(this.toContainer) 85 | } 86 | -------------------------------------------------------------------------------- /core/src/main/scala/com/whisk/docker/testkit/DockerContainerManager.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit 2 | 3 | import java.util.concurrent.{ConcurrentHashMap, TimeUnit} 4 | 5 | import com.spotify.docker.client.exceptions.ImageNotFoundException 6 | import com.spotify.docker.client.messages.ContainerCreation 7 | import org.slf4j.LoggerFactory 8 | 9 | import scala.concurrent.{Await, ExecutionContext, Future} 10 | import scala.collection.JavaConverters._ 11 | import scala.language.postfixOps 12 | 13 | trait ManagedContainers 14 | 15 | case class SingleContainer(container: BaseContainer) extends ManagedContainers 16 | 17 | case class ContainerGroup(containers: Seq[BaseContainer]) extends ManagedContainers { 18 | require(containers.nonEmpty, "container group should be non-empty") 19 | } 20 | 21 | object ContainerGroup { 22 | 23 | def of(containers: BaseContainer*): ContainerGroup = ContainerGroup(containers) 24 | } 25 | 26 | class DockerContainerManager( 27 | managedContainers: ManagedContainers, 28 | executor: ContainerCommandExecutor, 29 | dockerTestTimeouts: DockerTestTimeouts, 30 | executionContext: ExecutionContext 31 | ) { 32 | 33 | private implicit val ec: ExecutionContext = executionContext 34 | 35 | private lazy val log = LoggerFactory.getLogger(this.getClass) 36 | 37 | private val registeredContainers = new ConcurrentHashMap[String, String]() 38 | 39 | private def waitUntilReady(container: BaseContainer): Future[Unit] = { 40 | container.spec.readyChecker match { 41 | case None => 42 | Future.successful(()) 43 | case Some(checker) => 44 | checker(container)(executor, executionContext) 45 | } 46 | } 47 | 48 | private def printWarningsIfExist(creation: ContainerCreation): Unit = { 49 | Option(creation.warnings()) 50 | .map(_.asScala.toList) 51 | .getOrElse(Nil) 52 | .foreach(w => log.warn(s"creating container: $w")) 53 | } 54 | 55 | private def ensureImage(image: String): Future[Unit] = { 56 | Future(scala.concurrent.blocking(executor.client.inspectImage(image))) 57 | .map(_ => ()) 58 | .recoverWith { 59 | case x: ImageNotFoundException => 60 | log.info(s"image [$image] not found. pulling...") 61 | Future(scala.concurrent.blocking(executor.client.pull(image))) 62 | } 63 | } 64 | 65 | //TODO log listeners 66 | def startContainer(container: BaseContainer): Future[Unit] = { 67 | val image = container.spec.image 68 | val startTime = System.nanoTime() 69 | log.debug("Starting container: {}", image) 70 | for { 71 | creation <- executor.createContainer(container.spec) 72 | id = creation.id() 73 | _ = registeredContainers.put(id, image) 74 | _ = container.created(id) 75 | _ = printWarningsIfExist(creation) 76 | _ = log.info(s"starting container with id: $id") 77 | _ <- executor.startContainer(id) 78 | _ = container.starting(id) 79 | _ = log.info(s"container is starting. id=$id") 80 | runningContainer <- executor.runningContainer(id) 81 | _ = log.debug(s"container entered running state. id=$id") 82 | _ = container.running(runningContainer) 83 | _ = log.debug(s"preparing to execute ready check for container") 84 | res <- waitUntilReady(container) 85 | _ = log.debug(s"container is ready. id=$id") 86 | } yield { 87 | container.ready(runningContainer) 88 | val timeTaken = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime) 89 | log.info(s"container $image is ready after ${timeTaken / 1000.0}s") 90 | res 91 | } 92 | 93 | } 94 | 95 | def start(): Unit = { 96 | log.debug("Starting containers") 97 | val containers: Seq[BaseContainer] = managedContainers match { 98 | case SingleContainer(c) => Seq(c) 99 | case ContainerGroup(cs) => cs 100 | case _ => throw new Exception("unsupported type of managed containers") 101 | } 102 | 103 | val imagesF = Future.traverse(containers.map(_.spec.image))(ensureImage) 104 | Await.result(imagesF, dockerTestTimeouts.pull) 105 | 106 | val startedContainersF = Future.traverse(containers)(startContainer) 107 | 108 | sys.addShutdownHook( 109 | stop() 110 | ) 111 | 112 | try { 113 | Await.result(startedContainersF, dockerTestTimeouts.init) 114 | } catch { 115 | case e: Exception => 116 | log.error("Exception during container initialization", e) 117 | stop() 118 | throw new RuntimeException("Cannot run all required containers") 119 | } 120 | } 121 | 122 | def stop(): Unit = { 123 | try { 124 | Await.ready(stopRmAll(), dockerTestTimeouts.stop) 125 | } catch { 126 | case e: Throwable => 127 | log.error(e.getMessage, e) 128 | } 129 | } 130 | 131 | def stopRmAll(): Future[Unit] = { 132 | val future = Future.traverse(registeredContainers.asScala.toSeq) { 133 | case (cid, _) => 134 | executor.remove(cid, force = true, removeVolumes = true) 135 | } 136 | future.onComplete { _ => 137 | executor.close() 138 | } 139 | future.map(_ => ()) 140 | } 141 | 142 | } 143 | -------------------------------------------------------------------------------- /core/src/main/scala/com/whisk/docker/testkit/DockerReadyChecker.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit 2 | 3 | import java.net.{HttpURLConnection, URL} 4 | import java.sql.DriverManager 5 | import java.util.{Timer, TimerTask} 6 | 7 | import scala.concurrent.duration.FiniteDuration 8 | import scala.concurrent.{ExecutionContext, Future, Promise, TimeoutException} 9 | 10 | class FailFastCheckException(m: String) extends Exception(m) 11 | 12 | trait DockerReadyChecker { 13 | 14 | def apply( 15 | container: BaseContainer 16 | )(implicit docker: ContainerCommandExecutor, ec: ExecutionContext): Future[Unit] 17 | 18 | def within(duration: FiniteDuration): DockerReadyChecker = { 19 | DockerReadyChecker.TimeLimited(this, duration) 20 | } 21 | 22 | def looped(attempts: Int, delay: FiniteDuration): DockerReadyChecker = { 23 | DockerReadyChecker.Looped(this, attempts, delay) 24 | } 25 | } 26 | 27 | object RetryUtils { 28 | 29 | def withDelay[T](delay: Long)(f: => Future[T]): Future[T] = { 30 | val timer = new Timer() 31 | val promise = Promise[T]() 32 | timer.schedule( 33 | new TimerTask { 34 | override def run(): Unit = { 35 | promise.completeWith(f) 36 | timer.cancel() 37 | } 38 | }, 39 | delay 40 | ) 41 | promise.future 42 | } 43 | 44 | def runWithin[T](future: => Future[T], deadline: FiniteDuration)(implicit 45 | ec: ExecutionContext 46 | ): Future[T] = { 47 | val bail = Promise[T]() 48 | withDelay(deadline.toMillis)( 49 | bail 50 | .tryCompleteWith(Future.failed(new TimeoutException(s"timed out after $deadline"))) 51 | .future 52 | ) 53 | Future.firstCompletedOf(future :: bail.future :: Nil) 54 | } 55 | 56 | def looped[T](future: => Future[T], attempts: Int, delay: FiniteDuration)(implicit 57 | ec: ExecutionContext 58 | ): Future[T] = { 59 | def attempt(rest: Int): Future[T] = { 60 | future.recoverWith { 61 | case e: FailFastCheckException => Future.failed(e) 62 | case e if rest > 0 => 63 | withDelay(delay.toMillis)(attempt(rest - 1)) 64 | case e => 65 | Future.failed(e) 66 | } 67 | } 68 | 69 | attempt(attempts) 70 | } 71 | } 72 | 73 | object DockerReadyChecker { 74 | 75 | case class And(r1: DockerReadyChecker, r2: DockerReadyChecker) extends DockerReadyChecker { 76 | 77 | override def apply( 78 | container: BaseContainer 79 | )(implicit docker: ContainerCommandExecutor, ec: ExecutionContext): Future[Unit] = { 80 | val aF = r1(container) 81 | val bF = r2(container) 82 | for { 83 | a <- aF 84 | b <- bF 85 | } yield { 86 | () 87 | } 88 | } 89 | } 90 | 91 | object Always extends DockerReadyChecker { 92 | override def apply( 93 | container: BaseContainer 94 | )(implicit docker: ContainerCommandExecutor, ec: ExecutionContext): Future[Unit] = 95 | Future.successful(()) 96 | } 97 | 98 | case class HttpResponseCode( 99 | port: Int, 100 | path: String = "/", 101 | host: Option[String] = None, 102 | code: Int = 200 103 | ) extends DockerReadyChecker { 104 | 105 | override def apply( 106 | container: BaseContainer 107 | )(implicit docker: ContainerCommandExecutor, ec: ExecutionContext): Future[Unit] = { 108 | 109 | val p = container.mappedPorts()(port) 110 | val url = new URL("http", host.getOrElse(docker.client.getHost), p, path) 111 | Future { 112 | scala.concurrent.blocking { 113 | val con = url.openConnection().asInstanceOf[HttpURLConnection] 114 | try { 115 | if (con.getResponseCode != code) 116 | throw new Exception("unexpected response code: " + con.getResponseCode) 117 | } catch { 118 | case e: java.net.ConnectException => 119 | throw e 120 | } 121 | } 122 | } 123 | } 124 | } 125 | 126 | case class LogLineContains(str: String) extends DockerReadyChecker { 127 | 128 | override def apply( 129 | container: BaseContainer 130 | )(implicit docker: ContainerCommandExecutor, ec: ExecutionContext): Future[Unit] = { 131 | container.state() match { 132 | case ContainerState.Ready(_) => 133 | Future.successful(()) 134 | case state: ContainerState.HasId => 135 | docker 136 | .withLogStreamLinesRequirement(state.id, withErr = true)(_.contains(str)) 137 | .map(_ => ()) 138 | case _ => 139 | Future.failed( 140 | new FailFastCheckException("can't initialise LogStream to container without Id") 141 | ) 142 | } 143 | } 144 | } 145 | 146 | private[docker] case class TimeLimited(underlying: DockerReadyChecker, duration: FiniteDuration) 147 | extends DockerReadyChecker { 148 | 149 | override def apply( 150 | container: BaseContainer 151 | )(implicit docker: ContainerCommandExecutor, ec: ExecutionContext): Future[Unit] = { 152 | RetryUtils.runWithin(underlying(container), duration) 153 | } 154 | } 155 | 156 | private[docker] case class Looped( 157 | underlying: DockerReadyChecker, 158 | attempts: Int, 159 | delay: FiniteDuration 160 | ) extends DockerReadyChecker { 161 | 162 | override def apply( 163 | container: BaseContainer 164 | )(implicit docker: ContainerCommandExecutor, ec: ExecutionContext): Future[Unit] = { 165 | 166 | def attempt(attemptsLeft: Int): Future[Unit] = { 167 | underlying(container) 168 | .recoverWith { 169 | case e: FailFastCheckException => Future.failed(e) 170 | case e if attemptsLeft > 0 => 171 | RetryUtils.withDelay(delay.toMillis)(attempt(attemptsLeft - 1)) 172 | case e => 173 | Future.failed(e) 174 | } 175 | } 176 | 177 | attempt(attempts) 178 | } 179 | } 180 | 181 | private[docker] case class F(f: BaseContainer => Future[Unit]) extends DockerReadyChecker { 182 | override def apply( 183 | container: BaseContainer 184 | )(implicit docker: ContainerCommandExecutor, ec: ExecutionContext): Future[Unit] = 185 | f(container) 186 | } 187 | 188 | case class Jdbc( 189 | driverClass: String, 190 | user: String, 191 | password: Option[String], 192 | database: Option[String] = None, 193 | port: Option[Int] = None 194 | ) extends DockerReadyChecker { 195 | 196 | private val driverLower = driverClass.toLowerCase 197 | private[Jdbc] val dbms: String = if (driverLower.contains("mysql")) { 198 | "mysql" 199 | } else if (driverLower.contains("postgres")) { 200 | "postgresql" 201 | } else if (driverLower.contains("clickhouse")) { 202 | "clickhouse" 203 | } else { 204 | throw new IllegalArgumentException("unsupported database for ready check") 205 | } 206 | 207 | override def apply( 208 | container: BaseContainer 209 | )(implicit docker: ContainerCommandExecutor, ec: ExecutionContext): Future[Unit] = { 210 | 211 | Future(scala.concurrent.blocking { 212 | try { 213 | Class.forName(driverClass) 214 | val p = port match { 215 | case Some(v) => container.mappedPort(v) 216 | case None => container.mappedPorts().head._2 217 | } 218 | 219 | val url = "jdbc:" + dbms + "://" + docker.client.getHost + ":" + p + "/" + database 220 | .getOrElse("") 221 | 222 | val connection = Option(DriverManager.getConnection(url, user, password.orNull)) 223 | connection.foreach(_.close()) 224 | if (connection.isEmpty) { 225 | throw new Exception(s"can't establish jdbc connection to $url") 226 | } 227 | } catch { 228 | case e: ClassNotFoundException => 229 | throw new FailFastCheckException(s"jdbc class $driverClass not found") 230 | } 231 | }) 232 | } 233 | } 234 | 235 | } 236 | -------------------------------------------------------------------------------- /core/src/main/scala/com/whisk/docker/testkit/DockerTestTimeouts.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit 2 | 3 | import scala.concurrent.duration._ 4 | 5 | case class DockerTestTimeouts( 6 | pull: FiniteDuration = 5.minutes, 7 | init: FiniteDuration = 60.seconds, 8 | stop: FiniteDuration = 10.seconds 9 | ) 10 | 11 | object DockerTestTimeouts { 12 | 13 | val Default: DockerTestTimeouts = DockerTestTimeouts() 14 | } 15 | -------------------------------------------------------------------------------- /core/src/main/scala/com/whisk/docker/testkit/package.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker 2 | 3 | import java.util.concurrent.atomic.AtomicBoolean 4 | 5 | import scala.concurrent.{Future, Promise} 6 | 7 | /** 8 | * General utility functions 9 | */ 10 | package object testkit { 11 | implicit class OptionalOps[A](val content: A) extends AnyVal { 12 | def withOption[B](optional: Option[B])(f: (A, B) => A): A = 13 | optional match { 14 | case None => content 15 | case Some(x) => f(content, x) 16 | } 17 | } 18 | 19 | private[docker] class SinglePromise[T] { 20 | val promise: Promise[T] = Promise[T]() 21 | 22 | def future: Future[T] = promise.future 23 | 24 | val flag = new AtomicBoolean(false) 25 | 26 | def init(f: => Future[T]): Future[T] = { 27 | if (!flag.getAndSet(true)) { 28 | promise.tryCompleteWith(f) 29 | } 30 | future 31 | } 32 | } 33 | 34 | private[docker] object SinglePromise { 35 | def apply[T] = new SinglePromise[T] 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /notes/0.10.0.markdown: -------------------------------------------------------------------------------- 1 | #scala #docker #integration-testing 2 | 3 | ## Highlights 4 | 5 | * global inner refactoring, packages were changed 6 | * removed publishing of next artifacts: 7 | - docker-testkit-config 8 | - docker-testkit-impl-spotify 9 | - docker-testkit-impl-spotify-shaded 10 | - docker-testkit-impl-docker-java 11 | * `"com.spotify" % "docker-client" % "8.16.0"` used as default backend 12 | -------------------------------------------------------------------------------- /notes/0.11.0.markdown: -------------------------------------------------------------------------------- 1 | #scala #docker #integration-testing 2 | 3 | ## Highlights 4 | 5 | * add cross-compilation for scala [3](https://github.com/whisklabs/docker-it-scala/pull/146) 6 | * proper [timeouts handling](https://github.com/whisklabs/docker-it-scala/pull/133) 7 | * updated [sbt with plugins](https://github.com/whisklabs/docker-it-scala/pull/147) 8 | * create Github Action [CI](https://github.com/whisklabs/docker-it-scala/pull/147) 9 | * docs and small code improvements 10 | -------------------------------------------------------------------------------- /notes/0.3.0.markdown: -------------------------------------------------------------------------------- 1 | #scala #docker #integration-testing 2 | 3 | * [Specs2](https://etorreborre.github.io/specs2/) support (in addition to [Scalatest](http://scalatest.org/)) ([@jacobono](https://github.com/jacobono)) 4 | * [Typesafe Config](https://github.com/typesafehub/config)-based containers definition ([@jacobono](https://github.com/jacobono)) 5 | * publishing to Bintray 6 | * minor improvements 7 | -------------------------------------------------------------------------------- /notes/0.4.0.markdown: -------------------------------------------------------------------------------- 1 | #scala #docker #integration-testing 2 | 3 | * All containers definitions don't require implicit `Docker` instance 4 | * Make possible to check containers definition equality 5 | * Make scalatest and specs2 module independent from `config` 6 | * Clean duplicate tests 7 | -------------------------------------------------------------------------------- /notes/0.5.4.markdown: -------------------------------------------------------------------------------- 1 | #scala #docker #integration-testing 2 | 3 | * `DockerKafkaService` to use spotify/docker container with embedded Zookeeper 4 | * upgrade [docker-java](https://github.com/docker-java/docker-java) dependency to 2.1.4 5 | * removed dependency from `undelay` and `odelay-core` to avoid requirement on external resolver 6 | * clean duplicate tests 7 | * use docker inspect command to make getting container info more efficient 8 | * releasing to Maven Central 9 | -------------------------------------------------------------------------------- /notes/0.6.1.markdown: -------------------------------------------------------------------------------- 1 | #scala #docker #integration-testing 2 | 3 | * moving containers definitions outside `core` module 4 | * rename `core` module published artifact 5 | -------------------------------------------------------------------------------- /notes/0.9.0.markdown: -------------------------------------------------------------------------------- 1 | #scala #docker #integration-testing 2 | 3 | ## Highlights 4 | 5 | * introduce exectutor based on Spotify's [docker-client](https://github.com/spotify/docker-client) 6 | * separate docker exectutors library from core 7 | * support for unix-socket 8 | * support for linked containers (e.g. Zookeeper + Kafka) 9 | * support for Scala 2.12 10 | 11 | -------------------------------------------------------------------------------- /notes/0.9.5.markdown: -------------------------------------------------------------------------------- 1 | #scala #docker #integration-testing 2 | 3 | ## Highlights 4 | 5 | * extend `HostConfig` with `memory` and `memoryReservation` settings (credit [Chris Birchall](https://github.com/cb372)) 6 | * allowing to override entrypoint (credit [Chris Birchall](https://github.com/cb372)) 7 | * Spotify's [docker-client](https://github.com/spotify/docker-client) updated to 8.9.0 8 | * [docker-client](https://github.com/docker-java/docker-java) updated to 3.0.13 9 | * move project to sbt 1.x 10 | 11 | -------------------------------------------------------------------------------- /notes/0.9.9.markdown: -------------------------------------------------------------------------------- 1 | #scala #docker #integration-testing 2 | 3 | ## Highlights 4 | 5 | * add cross-compilation for scala [2.13.0](https://github.com/scala/scala/releases/tag/v2.13.0) 6 | * updated [sbt](https://github.com/sbt/sbt) to 1.2.8 7 | * updated [scalatest](https://github.com/scalatest/scalatest) to 3.0.8 8 | * updated [specs2-core](https://github.com/etorreborre/specs2) to 4.5.1 9 | * updated [ficus](https://github.com/iheartradio/ficus) to 1.4.7 10 | -------------------------------------------------------------------------------- /notes/about.markdown: -------------------------------------------------------------------------------- 1 | [docker-it-scala](https://github.com/whisklabs/docker-it-scala) is a set of utility classes to simplify integration testing with dockerized services in Scala. -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.9.8 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.10") 2 | 3 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.1.2") 4 | 5 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.5") 6 | 7 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.18-1") 8 | -------------------------------------------------------------------------------- /samples/src/main/scala/com/whisk/docker/testkit/DockerClickhouseService.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit 2 | 3 | import com.spotify.docker.client.messages.PortBinding 4 | import com.whisk.docker.testkit.scalatest.DockerTestKitForAll 5 | import org.scalatest.Suite 6 | 7 | import scala.concurrent.duration._ 8 | 9 | trait DockerClickhouseService extends DockerTestKitForAll { self: Suite => 10 | override val dockerTestTimeouts: DockerTestTimeouts = DockerTestTimeouts(pull = 10.minutes, init = 10.minutes, stop = 1.minutes) 11 | 12 | def ClickhouseAdvertisedPort = 8123 13 | def ClickhouseExposedPort = 8123 14 | 15 | val ClickhouseUser = "default" 16 | val ClickhousePassword = "" 17 | 18 | val clickhouseContainer = ContainerSpec("clickhouse/clickhouse-server:23.6") 19 | .withEnv(s"CLICKHOUSE_USER=$ClickhouseUser", s"CLICKHOUSE_PASSWORD=$ClickhousePassword") 20 | .withPortBindings((ClickhouseAdvertisedPort, PortBinding.of("0.0.0.0", ClickhouseExposedPort))) 21 | .withReadyChecker( 22 | DockerReadyChecker 23 | .Jdbc( 24 | driverClass = "com.clickhouse.jdbc.ClickHouseDriver", 25 | user = ClickhouseUser, 26 | password = Some(ClickhousePassword) 27 | ) 28 | .looped(15, 1.second) 29 | ) 30 | .toContainer 31 | 32 | override val managedContainers: ManagedContainers = clickhouseContainer.toManagedContainer 33 | } 34 | -------------------------------------------------------------------------------- /samples/src/main/scala/com/whisk/docker/testkit/DockerElasticsearchService.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit 2 | 3 | import java.util.UUID 4 | 5 | import com.whisk.docker.testkit.scalatest.DockerTestKitForAll 6 | import org.scalatest.Suite 7 | 8 | import scala.concurrent.duration._ 9 | 10 | trait DockerElasticsearchService extends DockerTestKitForAll { 11 | self: Suite => 12 | 13 | val DefaultElasticsearchHttpPort = 9200 14 | val DefaultElasticsearchClientPort = 9300 15 | val EsClusterName: String = UUID.randomUUID().toString 16 | 17 | protected val elasticsearchContainer = 18 | ContainerSpec("docker.elastic.co/elasticsearch/elasticsearch:6.2.4") 19 | .withExposedPorts(DefaultElasticsearchHttpPort, DefaultElasticsearchClientPort) 20 | .withEnv( 21 | "http.host=0.0.0.0", 22 | "xpack.security.enabled=false", 23 | "http.cors.enabled: true", 24 | "http.cors.allow-origin: \"*\"", 25 | s"cluster.name=$EsClusterName", 26 | "discovery.type=single-node", 27 | "ES_JAVA_OPTS=-Xms512m -Xmx512m" 28 | ) 29 | .withReadyChecker( 30 | DockerReadyChecker 31 | .HttpResponseCode(DefaultElasticsearchHttpPort, "/") 32 | .within(100.millis) 33 | .looped(20, 1250.millis) 34 | ) 35 | .toContainer 36 | 37 | override val managedContainers: ManagedContainers = elasticsearchContainer.toManagedContainer 38 | } 39 | -------------------------------------------------------------------------------- /samples/src/main/scala/com/whisk/docker/testkit/DockerMongodbService.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit 2 | 3 | import com.whisk.docker.testkit.scalatest.DockerTestKitForAll 4 | import org.scalatest.Suite 5 | 6 | trait DockerMongodbService extends DockerTestKitForAll { 7 | self: Suite => 8 | 9 | val DefaultMongodbPort = 27017 10 | 11 | val mongodbContainer = ContainerSpec("mongo:3.4.8") 12 | .withExposedPorts(DefaultMongodbPort) 13 | .withReadyChecker(DockerReadyChecker.LogLineContains("waiting for connections on port")) 14 | .toContainer 15 | 16 | override val managedContainers: ManagedContainers = mongodbContainer.toManagedContainer 17 | } 18 | -------------------------------------------------------------------------------- /samples/src/main/scala/com/whisk/docker/testkit/DockerMysqlService.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit 2 | 3 | import com.whisk.docker.testkit.scalatest.DockerTestKitForAll 4 | import org.scalatest.Suite 5 | 6 | import scala.concurrent.duration._ 7 | 8 | class MysqlContainer(image: String) extends BaseContainer { 9 | 10 | val AdvertisedPort = 3306 11 | val User = "root" 12 | val Password = "test" 13 | val Database = "test" 14 | 15 | override val spec: ContainerSpec = { 16 | ContainerSpec(image) 17 | .withExposedPorts(AdvertisedPort) 18 | .withReadyChecker( 19 | DockerReadyChecker 20 | .Jdbc( 21 | driverClass = "com.mysql.jdbc.Driver", 22 | user = User, 23 | password = Some(Password), 24 | database = Some(Database) 25 | ) 26 | .looped(25, 1.second) 27 | ) 28 | } 29 | } 30 | 31 | trait DockerMysqlService extends DockerTestKitForAll { self: Suite => 32 | 33 | val mysqlContainer = new MysqlContainer("quay.io/whisk/fastboot-mysql:5.7.19") 34 | 35 | override val managedContainers: ManagedContainers = mysqlContainer.toManagedContainer 36 | } 37 | -------------------------------------------------------------------------------- /samples/src/main/scala/com/whisk/docker/testkit/DockerPostgresService.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit 2 | 3 | import com.spotify.docker.client.messages.PortBinding 4 | import com.whisk.docker.testkit.scalatest.DockerTestKitForAll 5 | import org.scalatest.Suite 6 | 7 | import scala.concurrent.duration._ 8 | 9 | trait DockerPostgresService extends DockerTestKitForAll { self: Suite => 10 | 11 | def PostgresAdvertisedPort = 5432 12 | def PostgresExposedPort = 44444 13 | val PostgresUser = "nph" 14 | val PostgresPassword = "suitup" 15 | 16 | val postgresContainer = ContainerSpec("postgres:9.6.5") 17 | .withPortBindings((PostgresAdvertisedPort, PortBinding.of("0.0.0.0", PostgresExposedPort))) 18 | .withEnv(s"POSTGRES_USER=$PostgresUser", s"POSTGRES_PASSWORD=$PostgresPassword") 19 | .withReadyChecker( 20 | DockerReadyChecker 21 | .Jdbc( 22 | driverClass = "org.postgresql.Driver", 23 | user = PostgresUser, 24 | password = Some(PostgresPassword) 25 | ) 26 | .looped(15, 1.second) 27 | ) 28 | .toContainer 29 | 30 | override val managedContainers: ManagedContainers = postgresContainer.toManagedContainer 31 | } 32 | -------------------------------------------------------------------------------- /scalatest/src/main/scala/com/whisk/docker/testkit/scalatest/DockerTestKitForAll.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit.scalatest 2 | 3 | import java.util.concurrent.ForkJoinPool 4 | 5 | import com.spotify.docker.client.{DefaultDockerClient, DockerClient} 6 | import com.whisk.docker.testkit._ 7 | import org.scalatest.{Args, Status, Suite, SuiteMixin} 8 | 9 | import scala.concurrent.ExecutionContext 10 | import scala.language.implicitConversions 11 | 12 | trait DockerTestKitForAll extends SuiteMixin { self: Suite => 13 | 14 | val dockerClient: DockerClient = DefaultDockerClient.fromEnv().build() 15 | 16 | val dockerExecutionContext: ExecutionContext = ExecutionContext.fromExecutor(new ForkJoinPool()) 17 | 18 | val managedContainers: ManagedContainers 19 | 20 | val dockerTestTimeouts: DockerTestTimeouts = DockerTestTimeouts.Default 21 | 22 | implicit lazy val dockerExecutor: ContainerCommandExecutor = 23 | new ContainerCommandExecutor(dockerClient) 24 | 25 | lazy val containerManager = new DockerContainerManager( 26 | managedContainers, 27 | dockerExecutor, 28 | dockerTestTimeouts, 29 | dockerExecutionContext 30 | ) 31 | 32 | abstract override def run(testName: Option[String], args: Args): Status = { 33 | containerManager.start() 34 | afterStart() 35 | try { 36 | super.run(testName, args) 37 | } finally { 38 | try { 39 | beforeStop() 40 | } finally { 41 | containerManager.stop() 42 | } 43 | } 44 | } 45 | 46 | def afterStart(): Unit = {} 47 | 48 | def beforeStop(): Unit = {} 49 | 50 | } 51 | -------------------------------------------------------------------------------- /tests/src/test/scala/com/whisk/docker/testkit/test/ClickhouseServiceTest.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit.test 2 | 3 | import com.whisk.docker.testkit.{ContainerState, DockerClickhouseService} 4 | import org.scalatest.funsuite.AnyFunSuite 5 | 6 | class ClickhouseServiceTest extends AnyFunSuite with DockerClickhouseService { 7 | test("test container started") { 8 | assert(clickhouseContainer.state().isInstanceOf[ContainerState.Ready], "clickhouse is ready") 9 | assert(clickhouseContainer.mappedPortOpt(ClickhouseAdvertisedPort) === Some(ClickhouseExposedPort), 10 | "clickhouse port exposed") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/src/test/scala/com/whisk/docker/testkit/test/ElasticsearchServiceTest.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit.test 2 | 3 | import com.whisk.docker.testkit.{ContainerState, DockerElasticsearchService} 4 | import org.scalatest.funsuite.AnyFunSuite 5 | 6 | class ElasticsearchServiceTest extends AnyFunSuite with DockerElasticsearchService { 7 | 8 | test("test container started") { 9 | assert(elasticsearchContainer.state().isInstanceOf[ContainerState.Ready], 10 | "elasticsearch container is ready") 11 | assert(elasticsearchContainer.mappedPortOpt(9200).nonEmpty, "elasticsearch port is exposed") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/src/test/scala/com/whisk/docker/testkit/test/MongodbServiceTest.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit.test 2 | 3 | import com.whisk.docker.testkit.{ContainerState, DockerMongodbService} 4 | import org.scalatest.funsuite.AnyFunSuite 5 | 6 | class MongodbServiceTest extends AnyFunSuite with DockerMongodbService { 7 | 8 | test("test container started") { 9 | assert(mongodbContainer.state().isInstanceOf[ContainerState.Ready], "mongodb is ready") 10 | assert(mongodbContainer.mappedPortOpt(27017).nonEmpty, "port 2017 is exposed") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/src/test/scala/com/whisk/docker/testkit/test/MultiContainerTest.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit.test 2 | 3 | import com.whisk.docker.testkit._ 4 | import org.scalatest.funsuite.AnyFunSuite 5 | 6 | class MultiContainerTest 7 | extends AnyFunSuite 8 | with DockerElasticsearchService 9 | with DockerMongodbService { 10 | 11 | override val managedContainers: ContainerGroup = 12 | ContainerGroup.of(elasticsearchContainer, mongodbContainer) 13 | 14 | test("both containers should be ready") { 15 | assert( 16 | elasticsearchContainer.state().isInstanceOf[ContainerState.Ready], 17 | "elasticsearch container is ready" 18 | ) 19 | assert(elasticsearchContainer.mappedPortOpt(9200).nonEmpty, "elasticsearch port is exposed") 20 | 21 | assert(mongodbContainer.state().isInstanceOf[ContainerState.Ready], "mongodb is ready") 22 | assert(mongodbContainer.mappedPortOpt(27017).nonEmpty, "port 2017 is exposed") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/src/test/scala/com/whisk/docker/testkit/test/MysqlServiceTest.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit.test 2 | 3 | import com.whisk.docker.testkit.{ContainerState, DockerMysqlService} 4 | import org.scalatest.funsuite.AnyFunSuite 5 | 6 | class MysqlServiceTest extends AnyFunSuite with DockerMysqlService { 7 | 8 | test("test container started") { 9 | assert(mysqlContainer.state().isInstanceOf[ContainerState.Ready], "mysql is ready") 10 | assert(mysqlContainer.mappedPortOpt(mysqlContainer.AdvertisedPort).nonEmpty, 11 | "mysql port exposed") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/src/test/scala/com/whisk/docker/testkit/test/PostgresServiceTest.scala: -------------------------------------------------------------------------------- 1 | package com.whisk.docker.testkit.test 2 | 3 | import com.whisk.docker.testkit.{ContainerState, DockerPostgresService} 4 | import org.scalatest.funsuite.AnyFunSuite 5 | 6 | class PostgresServiceTest extends AnyFunSuite with DockerPostgresService { 7 | 8 | test("test container started") { 9 | assert(postgresContainer.state().isInstanceOf[ContainerState.Ready], "postgres is ready") 10 | assert(postgresContainer.mappedPortOpt(PostgresAdvertisedPort) === Some(PostgresExposedPort), 11 | "postgres port exposed") 12 | } 13 | } 14 | --------------------------------------------------------------------------------