Repository: 7mind/izumi
Pull request #732 (2019-11-17 22:28:17)

Docker resources

codecov-io: # [Codecov](https://codecov.io/gh/7mind/izumi/pull/732?src=pr&el=h1) Report
> Merging [#732](https://codecov.io/gh/7mind/izumi/pull/732?src=pr&el=desc) into [develop](https://codecov.io/gh/7mind/izumi/commit/d8c87f96c8b423388e1e98a8749afd7598a5fa2c?src=pr&el=desc) will **decrease** coverage by `0.13%`.
> The diff coverage is `12.9%`.

[![Impacted file tree graph](https://codecov.io/gh/7mind/izumi/pull/732/graphs/tree.svg?width=650&token=77pa3UU1Je&height=150&src=pr)](https://codecov.io/gh/7mind/izumi/pull/732?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           develop    #732      +/-   ##
==========================================
- Coverage    74.73%   74.6%   -0.14%     
==========================================
  Files          532     533       +1     
  Lines        13198   13229      +31     
  Branches       635     639       +4     
==========================================
+ Hits          9864    9869       +5     
- Misses        3334    3360      +26
```


| [Impacted Files](https://codecov.io/gh/7mind/izumi/pull/732?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [.../testkit/integration/DockerContainerResource.scala](https://codecov.io/gh/7mind/izumi/pull/732/diff?src=pr&el=tree#diff-ZGlzdGFnZS9kaXN0YWdlLXRlc3RraXQvc3JjL21haW4vc2NhbGEvaXp1bWkvZGlzdGFnZS90ZXN0a2l0L2ludGVncmF0aW9uL0RvY2tlckNvbnRhaW5lclJlc291cmNlLnNjYWxh) | `12.9% <12.9%> (ø)` | |
| [...izumi/fundamentals/platform/language/IzScala.scala](https://codecov.io/gh/7mind/izumi/pull/732/diff?src=pr&el=tree#diff-ZnVuZGFtZW50YWxzL2Z1bmRhbWVudGFscy1wbGF0Zm9ybS9zcmMvbWFpbi9zY2FsYS9penVtaS9mdW5kYW1lbnRhbHMvcGxhdGZvcm0vbGFuZ3VhZ2UvSXpTY2FsYS5zY2FsYQ==) | `60% <0%> (-10%)` | :arrow_down: |
| [...rvices/st/dtest/DistageAbstractScalatestSpec.scala](https://codecov.io/gh/7mind/izumi/pull/732/diff?src=pr&el=tree#diff-ZGlzdGFnZS9kaXN0YWdlLXRlc3RraXQvc3JjL21haW4vc2NhbGEvaXp1bWkvZGlzdGFnZS90ZXN0a2l0L3NlcnZpY2VzL3N0L2R0ZXN0L0Rpc3RhZ2VBYnN0cmFjdFNjYWxhdGVzdFNwZWMuc2NhbGE=) | `81.39% <0%> (+4.65%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/7mind/izumi/pull/732?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/7mind/izumi/pull/732?src=pr&el=footer). Last update [d8c87f9...0766239](https://codecov.io/gh/7mind/izumi/pull/732?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/build.sbt b/build.sbt
index f85b254f4..31fa89c81 100644
--- a/build.sbt
+++ b/build.sbt
@@ -1209,6 +1209,7 @@ lazy val `distage-testkit` = project.in(file("distage/distage-testkit"))
       "org.scala-lang.modules" %% "scala-collection-compat" % V.collection_compat,
       "org.scalatest" %% "scalatest" % V.scalatest % Test,
       "org.scalatest" %% "scalatest" % V.scalatest,
+      "com.github.docker-java" % "docker-java" % V.docker_java,
       "org.typelevel" %% "cats-core" % V.cats % Optional,
       "org.typelevel" %% "cats-effect" % V.cats_effect % Optional,
       "dev.zio" %% "zio" % V.zio % Optional
diff --git a/distage/distage-model/src/main/scala/izumi/distage/model/monadic/DIEffectAsync.scala b/distage/distage-model/src/main/scala/izumi/distage/model/monadic/DIEffectAsync.scala
index e952aa178..662a29f39 100644
--- a/distage/distage-model/src/main/scala/izumi/distage/model/monadic/DIEffectAsync.scala
+++ b/distage/distage-model/src/main/scala/izumi/distage/model/monadic/DIEffectAsync.scala
@@ -6,8 +6,8 @@ import izumi.distage.model.monadic.LowPriorityDIEffectAsyncInstances.{_Parallel,
 import izumi.functional.bio.{BIOAsync, F}
 import izumi.fundamentals.platform.functional.Identity
 
-import scala.concurrent.duration.FiniteDuration
-import scala.concurrent.{ExecutionContext, Future}
+import scala.concurrent.duration.{Duration, FiniteDuration}
+import scala.concurrent.{Await, ExecutionContext, Future}
 
 trait DIEffectAsync[F[_]] {
   def parTraverse_[A](l: Iterable[A])(f: A => F[Unit]): F[Unit]
@@ -20,7 +20,8 @@ object DIEffectAsync extends LowPriorityDIEffectAsyncInstances {
   implicit val diEffectParIdentity: DIEffectAsync[Identity] = {
     new DIEffectAsync[Identity] {
       override def parTraverse_[A](l: Iterable[A])(f: A => Unit): Unit = {
-        l.foreach(a => Future(f(a))(ExecutionContext.global))
+        val future = Future.sequence(l.map(a => Future(f(a))(ExecutionContext.global)))(implicitly, ExecutionContext.global)
+        Await.result(future, Duration.Inf)
       }
       override def sleep(duration: FiniteDuration): Identity[Unit] = {
         Thread.sleep(duration.toMillis)
diff --git a/distage/distage-model/src/main/scala/izumi/distage/model/providers/ProviderMagnet.scala b/distage/distage-model/src/main/scala/izumi/distage/model/providers/ProviderMagnet.scala
index b4a8a76cc..108c435d4 100644
--- a/distage/distage-model/src/main/scala/izumi/distage/model/providers/ProviderMagnet.scala
+++ b/distage/distage-model/src/main/scala/izumi/distage/model/providers/ProviderMagnet.scala
@@ -78,16 +78,26 @@ import scala.language.implicitConversions
   *
   * @see [[izumi.distage.model.reflection.macros.ProviderMagnetMacro]]
   */
-final case class ProviderMagnet[+R](get: Provider) {
-  def map[B: Tag](f: R => B): ProviderMagnet[B] = {
-    copy[B](get = get.unsafeMap(SafeType.get[B], (any: Any) => f(any.asInstanceOf[R])))
+final case class ProviderMagnet[+A](get: Provider) {
+  def map[B: Tag](f: A => B): ProviderMagnet[B] = {
+    copy[B](get = get.unsafeMap(SafeType.get[B], (any: Any) => f(any.asInstanceOf[A])))
   }
 
-  def zip[B: Tag](that: ProviderMagnet[B]): ProviderMagnet[(R, B)] = {
-    implicit val rTag: Tag[R] = Tag.unsafeFromSafeType(Tag[B].tpe.mirror)(get.ret)
+  def zip[B: Tag](that: ProviderMagnet[B]): ProviderMagnet[(A, B)] = {
+    implicit val rTag: Tag[A] = Tag.unsafeFromSafeType(Tag[B].tpe.mirror)(get.ret)
     rTag.discard() // scalac can't detect usage in TagMacro assembling Tag[(R, B)] below
 
-    copy[(R, B)](get = get.unsafeZip(SafeType.get[(R, B)], that.get))
+    copy[(A, B)](get = get.unsafeZip(SafeType.get[(A, B)], that.get))
+  }
+
+  def map2[B: Tag, C: Tag](that: ProviderMagnet[B])(f: (A, B) => C): ProviderMagnet[C] = {
+    zip(that).map[C](f.tupled)
+  }
+
+  /** Add `B` as an unused dependency for this constructor */
+  def addDependency[B: Tag]: ProviderMagnet[A] = {
+    implicit val rTag: Tag[A] = Tag.unsafeFromSafeType(Tag[B].tpe.mirror)(get.ret)
+    map2[B, A](ProviderMagnet.identity[B])((a, _) => a)
   }
 }
 
diff --git a/fundamentals/fundamentals-functional/src/main/scala/izumi/functional/Value.scala b/fundamentals/fundamentals-functional/src/main/scala/izumi/functional/Value.scala
index 37e1a32c9..283206cb2 100644
--- a/fundamentals/fundamentals-functional/src/main/scala/izumi/functional/Value.scala
+++ b/fundamentals/fundamentals-functional/src/main/scala/izumi/functional/Value.scala
@@ -5,6 +5,23 @@ final class Value[A] private (private val value: A) extends AnyVal {
     new Value(f(this.value))
   }
 
+  @inline def mut[C, B](context: Option[C])(f: (C, A) => A): Value[A] = {
+    context match {
+      case Some(ctx) =>
+        new Value(f(ctx, this.value))
+      case None =>
+        this
+    }
+  }
+
+  @inline def mut[B](cond: Boolean)(f: A => A): Value[A] = {
+    if (cond) {
+      new Value(f(this.value))
+    } else {
+      this
+    }
+  }
+
   @inline def eff(f: A => Unit): Value[A] = {
     f(value)
     this
diff --git a/project/Deps.sc b/project/Deps.sc
index 9cc28047a..4524c015a 100644
--- a/project/Deps.sc
+++ b/project/Deps.sc
@@ -27,6 +27,7 @@ object Izumi {
     val typesafe_config = Version.VExpr("V.typesafe_config")
     val cglib_nodep = Version.VExpr("V.cglib_nodep")
     val scala_java_time = Version.VExpr("V.scala_java_time")
+    val docker_java = Version.VExpr("V.docker_java")
   }
 
   object PV {
@@ -119,6 +120,7 @@ object Izumi {
     val http4s_all = (http4s_server ++ http4s_client)
 
     val asynchttpclient = Library("org.asynchttpclient", "async-http-client", V.asynchttpclient, LibraryType.Invariant)
+    val docker_java = Library("com.github.docker-java", "docker-java", V.docker_java, LibraryType.Invariant)
   }
 
   import Deps._
@@ -439,7 +441,7 @@ object Izumi {
       ),
       Artifact(
         name = Projects.distage.testkit,
-        libs = Seq(scalatest.dependency in Scope.Compile.all) ++ allMonadsOptional,
+        libs = Seq(scalatest.dependency, docker_java).map(_ in Scope.Compile.all) ++ allMonadsOptional,
         depends =
           Seq(Projects.distage.config, Projects.distage.roles, Projects.logstage.di).map(_ in Scope.Compile.all) ++
             Seq(Projects.distage.core, Projects.distage.plugins).map(_ tin Scope.Compile.all),
diff --git a/project/Versions.scala b/project/Versions.scala
index 3f1081b58..d37cae3e8 100644
--- a/project/Versions.scala
+++ b/project/Versions.scala
@@ -36,6 +36,6 @@ object V {
 
   // good to drop - java
   val cglib_nodep = "3.3.0"
-
   val scala_java_time = "2.0.0-RC3"
+  val docker_java = "3.2.0-rc1"
 }


Test patch:
diff --git a/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/Docker.scala b/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/Docker.scala
new file mode 100644
index 000000000..84df7b6fd
--- /dev/null
+++ b/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/Docker.scala
@@ -0,0 +1,78 @@
+package izumi.distage.testkit.integration.docker
+
+import java.util.concurrent.TimeUnit
+
+import izumi.fundamentals.platform.integration.{PortCheck, ResourceCheck}
+
+import scala.concurrent.duration.FiniteDuration
+
+object Docker {
+
+  final case class ContainerId(name: String) extends AnyVal
+
+  trait DockerPort {
+    def number: Int
+  }
+  object DockerPort {
+    final case class TCP(number: Int) extends DockerPort
+  }
+
+  final case class ClientConfig(readTimeoutMs: Int, connectTimeoutMs: Int)
+
+  sealed trait HealthCheckResult
+  object HealthCheckResult {
+    case object Running extends HealthCheckResult
+    case object Uknnown extends HealthCheckResult
+    final case class Failed(t: Throwable) extends HealthCheckResult
+  }
+
+  trait ContainerHealthCheck[Tag] {
+    def check(container: DockerContainer[Tag]): HealthCheckResult
+  }
+  object ContainerHealthCheck {
+    def dontCheckPorts[T]: ContainerHealthCheck[T] = _ => HealthCheckResult.Running
+
+    def checkFirstPort[T](timeout: FiniteDuration = FiniteDuration(1, TimeUnit.SECONDS)): ContainerHealthCheck[T] = {
+      container: DockerContainer[T] =>
+        container.containerConfig.ports.headOption match {
+          case Some(value) =>
+            checkPort[T](value, timeout).check(container)
+          case None =>
+            HealthCheckResult.Running
+        }
+    }
+
+    def checkPort[T](exposedPort: DockerPort, timeout: FiniteDuration = FiniteDuration(1, TimeUnit.SECONDS)): ContainerHealthCheck[T] = {
+      container: DockerContainer[T] =>
+        container.mapping.get(exposedPort) match {
+          case Some(value) =>
+            new PortCheck(timeout.toMillis.intValue()).checkPort("localhost", value, s"open port ${exposedPort} on ${container.id}") match {
+              case _: ResourceCheck.Success =>
+                HealthCheckResult.Running
+              case f: ResourceCheck.Failure =>
+                println(f)
+                HealthCheckResult.Uknnown
+            }
+          case None =>
+            HealthCheckResult.Failed(new RuntimeException(s"Port ${exposedPort} is not mapped!"))
+        }
+    }
+  }
+
+  final case class Mount(host: String, container: String)
+
+  final case class ContainerConfig[T](
+                                       image: String,
+                                       ports: Seq[DockerPort],
+                                       env: Map[String, String] = Map.empty,
+                                       cmd: Seq[String] = Seq.empty,
+                                       entrypoint: Seq[String] = Seq.empty,
+                                       cwd: Option[String] = None,
+                                       user: Option[String] = None,
+                                       mounts: Seq[Mount] = Seq.empty,
+                                       healthCheckInterval: FiniteDuration = FiniteDuration(1, TimeUnit.SECONDS),
+                                       pullTimeout: FiniteDuration = FiniteDuration(120, TimeUnit.SECONDS),
+                                       healthCheck: ContainerHealthCheck[T] = ContainerHealthCheck.checkFirstPort[T](),
+                                     )
+
+}
diff --git a/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/DockerClientWrapper.scala b/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/DockerClientWrapper.scala
new file mode 100644
index 000000000..eedd4a3f7
--- /dev/null
+++ b/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/DockerClientWrapper.scala
@@ -0,0 +1,92 @@
+package izumi.distage.testkit.integration.docker
+
+import java.util.UUID
+
+import com.github.dockerjava.api.DockerClient
+import com.github.dockerjava.api.command.DockerCmdExecFactory
+import com.github.dockerjava.core.{DefaultDockerClientConfig, DockerClientBuilder}
+import izumi.distage.model.definition.DIResource
+import izumi.distage.model.monadic.DIEffect.syntax._
+import izumi.distage.model.monadic.{DIEffect, DIEffectAsync}
+import izumi.distage.roles.model.IntegrationCheck
+import izumi.distage.testkit.integration.docker.Docker.ContainerId
+import izumi.fundamentals.platform.integration.ResourceCheck
+import izumi.fundamentals.platform.language.Quirks._
+import izumi.logstage.api.IzLogger
+
+import scala.jdk.CollectionConverters._
+
+class DockerClientWrapper[F[_]]
+(
+  val client: DockerClient,
+  val labels: Map[String, String],
+  logger: IzLogger,
+)(
+  implicit
+  F: DIEffect[F],
+) {
+  def destroyContainer(container: ContainerId): F[Unit] = {
+    F.maybeSuspend {
+      logger.info(s"Going to destroy $container...")
+
+      try {
+        client
+          .stopContainerCmd(container.name)
+          .exec()
+          .discard()
+      } finally {
+        client
+          .removeContainerCmd(container.name)
+          .withForce(true)
+          .exec()
+          .discard()
+      }
+    }
+  }
+}
+
+object DockerClientWrapper {
+
+  class Resource[F[_]: DIEffect: DIEffectAsync]
+  (
+    factory: DockerCmdExecFactory,
+    logger: IzLogger,
+  ) extends DIResource[F, DockerClientWrapper[F]]
+    with IntegrationCheck {
+
+    private[this] lazy val dcc = DefaultDockerClientConfig.createDefaultConfigBuilder().build()
+    private[this] lazy val client = DockerClientBuilder
+      .getInstance(dcc)
+      .withDockerCmdExecFactory(factory)
+      .build
+
+    override def resourcesAvailable(): ResourceCheck = {
+      try {
+        client.infoCmd().exec()
+        ResourceCheck.Success()
+      } catch {
+        case t: Throwable =>
+          ResourceCheck.ResourceUnavailable("Docker daemon is unavailable", Some(t))
+      }
+    }
+
+    override def acquire: F[DockerClientWrapper[F]] = {
+      DIEffect[F].maybeSuspend {
+        new DockerClientWrapper[F](
+          client = client,
+          labels = Map("type" -> "distage-testkit", "distage-run" -> UUID.randomUUID().toString),
+          logger = logger,
+        )
+      }
+    }
+
+    override def release(resource: DockerClientWrapper[F]): F[Unit] = {
+      for {
+        containers <- DIEffect[F].maybeSuspend(resource.client.listContainersCmd().withLabelFilter(resource.labels.asJava).exec())
+        _ <- DIEffect[F].traverse_(containers.asScala)(c => resource.destroyContainer(ContainerId(c.getId)))
+        _ <- DIEffect[F].maybeSuspend(resource.client.close())
+      } yield ()
+    }
+  }
+
+}
diff --git a/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/DockerCmdExecFactoryResource.scala b/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/DockerCmdExecFactoryResource.scala
new file mode 100644
index 000000000..7ced182b4
--- /dev/null
+++ b/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/DockerCmdExecFactoryResource.scala
@@ -0,0 +1,17 @@
+package izumi.distage.testkit.integration.docker
+
+import com.github.dockerjava.api.command.DockerCmdExecFactory
+import com.github.dockerjava.netty.NettyDockerCmdExecFactory
+import izumi.distage.model.definition.DIResource
+import izumi.distage.model.monadic.DIEffect
+
+class DockerCmdExecFactoryResource[F[_]: DIEffect]
+(
+  config: Docker.ClientConfig,
+) extends DIResource.FromAutoCloseable[F, DockerCmdExecFactory](
+  DIEffect[F].maybeSuspend {
+    new NettyDockerCmdExecFactory()
+      .withReadTimeout(config.readTimeoutMs)
+      .withConnectTimeout(config.connectTimeoutMs)
+  }
+)
diff --git a/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/DockerContainer.scala b/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/DockerContainer.scala
new file mode 100644
index 000000000..022d088d2
--- /dev/null
+++ b/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/DockerContainer.scala
@@ -0,0 +1,162 @@
+package izumi.distage.testkit.integration.docker
+
+import java.util.concurrent.TimeUnit
+
+import com.github.dockerjava.api.model._
+import com.github.dockerjava.core.command.PullImageResultCallback
+import distage.TagK
+import izumi.distage.model.definition.DIResource
+import izumi.distage.model.monadic.DIEffect.syntax._
+import izumi.distage.model.monadic.{DIEffect, DIEffectAsync}
+import izumi.distage.model.providers.ProviderMagnet
+import izumi.distage.testkit.integration.docker.Docker.{ContainerConfig, ContainerId, DockerPort, HealthCheckResult}
+import izumi.functional.Value
+import izumi.fundamentals.platform.language.Quirks._
+import izumi.fundamentals.platform.network.IzSockets
+import izumi.logstage.api.IzLogger
+
+trait ContainerDef {
+  type Tag
+  type Container = DockerContainer[Tag]
+  type Config = ContainerConfig[Tag]
+
+  def config: Config
+
+  /** For binding in `ModuleDef`:
+    *
+    * {{{
+    * object KafkaDocker extends ContainerDef
+    * object ZookeeperDocker extends ContainerDef
+    *
+    * make[KafkaDocker.Container].fromResource {
+    *   KafkaDocker
+    *     .make[F]
+    *     .dependOnDocker(ZookeeperDocker)
+    * }
+    * }}}
+    */
+  final def make[F[_]: TagK](implicit tag: distage.Tag[Tag]): ProviderMagnet[DIResource[F, DockerContainer[Tag]]] = {
+    tag.discard()
+    DockerContainer.resource[F](this)
+  }
+}
+object ContainerDef {
+  type Aux[T] = ContainerDef {type Tag = T}
+}
+
+case class DockerContainer[Tag](
+                                 id: Docker.ContainerId,
+                                 mapping: Map[Docker.DockerPort, Int],
+                                 containerConfig: ContainerConfig[Tag],
+                               )
+object DockerContainer {
+  def resource[F[_]](conf: ContainerDef): (DockerClientWrapper[F], IzLogger, DIEffect[F], DIEffectAsync[F]) => DIResource[F, DockerContainer[conf.Tag]] = {
+    new Resource[F, conf.Tag](conf)(_, _)(_, _)
+  }
+
+  implicit final class DockerProviderExtensions[F[_], T](private val self: ProviderMagnet[DIResource[F, DockerContainer[T]]]) extends AnyVal {
+    def dependOnDocker(containerDecl: ContainerDef)(implicit tag: distage.Tag[DockerContainer[containerDecl.Tag]]): ProviderMagnet[DIResource[F, DockerContainer[T]]] = {
+      self.addDependency[DockerContainer[containerDecl.Tag]]
+    }
+    def dependOnDocker[T2](implicit tag: distage.Tag[DockerContainer[T2]]): ProviderMagnet[DIResource[F, DockerContainer[T]]] = {
+      self.addDependency[DockerContainer[T2]]
+    }
+  }
+
+  class Resource[F[_]: DIEffect: DIEffectAsync, T]
+  (
+    containerDecl: ContainerDef.Aux[T],
+  )(
+    clientw: DockerClientWrapper[F],
+    logger: IzLogger,
+  ) extends DIResource[F, DockerContainer[T]] {
+    private val client = clientw.client
+    private val config = containerDecl.config
+
+    override def acquire: F[DockerContainer[T]] = {
+      val ports = config.ports.map(_ -> IzSockets.temporaryLocalPort())
+
+      val specs = ports.map {
+        case (container, local) =>
+
+          val p = container match {
+            case DockerPort.TCP(number) =>
+              ExposedPort.tcp(number)
+          }
+          new PortBinding(Ports.Binding.bindPort(local), p)
+      }
+
+      val exposed = config.ports.map {
+        case DockerPort.TCP(number) =>
+          ExposedPort.tcp(number)
+      }
+      import scala.jdk.CollectionConverters._
+
+      val baseCmd = client
+        .createContainerCmd(config.image)
+        .withLabels(clientw.labels.asJava)
+
+      val volumes = config.mounts.map(m => new Bind(m.host, new Volume(m.container), true))
+
+      def await(c: DockerContainer[T]): F[Unit] = {
+        DIEffect[F].maybeSuspend {
+          try {
+            val status = client.inspectContainerCmd(c.id.name).exec()
+            if (status.getState.getRunning) {
+              config.healthCheck.check(c)
+            } else {
+              HealthCheckResult.Failed(new RuntimeException(s"Container exited: ${c.id}, full status: $status"))
+            }
+          } catch {
+            case t: Throwable =>
+              HealthCheckResult.Failed(t)
+          }
+        }.flatMap {
+          case HealthCheckResult.Running =>
+            DIEffect[F].unit
+          case HealthCheckResult.Failed(t) =>
+            DIEffect[F].fail(new RuntimeException(s"Container failed: ${c.id}", t))
+          case HealthCheckResult.Uknnown =>
+            DIEffectAsync[F].sleep(config.healthCheckInterval).flatMap(_ => await(c))
+        }
+      }
+
+      for {
+        out <- DIEffect[F].maybeSuspend {
+          val cmd = Value(baseCmd)
+            .mut(exposed.nonEmpty)(_.withExposedPorts(exposed: _*))
+            .mut(specs.nonEmpty)(_.withPortBindings(specs: _*))
+            .mut(config.env.nonEmpty)(_.withEnv(config.env.map {
+              case (k, v) => s"$k=$v"
+            }.toList.asJava))
+            .mut(config.cmd.nonEmpty)(_.withCmd(config.cmd.toList.asJava))
+            .mut(config.entrypoint.nonEmpty)(_.withEntrypoint(config.cmd.toList.asJava))
+            .mut(config.cwd)((cwd, cmd) => cmd.withWorkingDir(cwd))
+            .mut(config.user)((user, cmd) => cmd.withUser(user))
+            .mut(volumes.nonEmpty)(_.withVolumes(volumes.map(_.getVolume).asJava))
+            .mut(volumes.nonEmpty)(_.withBinds(volumes.toList.asJava))
+            .get
+
+          logger.info(s"Going to pull `${config.image}`...")
+          client
+            .pullImageCmd(config.image)
+            .exec(new PullImageResultCallback())
+            .awaitCompletion(config.pullTimeout.toMillis, TimeUnit.MILLISECONDS);
+
+          logger.debug(s"Going to create container from image `${config.image}`...")
+          val res = cmd.exec()
+          logger.info(s"Created container from `${config.image}` with ${res.getId -> "id"} ...")
+
+          client.startContainerCmd(res.getId).exec()
+
+          DockerContainer[T](ContainerId(res.getId), ports.toMap, config)
+        }
+        _ <- await(out)
+      } yield out
+    }
+
+    override def release(resource: DockerContainer[T]): F[Unit] = {
+      clientw.destroyContainer(resource.id)
+    }
+  }
+}
diff --git a/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/examples/DynamoDocker.scala b/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/examples/DynamoDocker.scala
new file mode 100644
index 000000000..55ba74bd8
--- /dev/null
+++ b/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/examples/DynamoDocker.scala
@@ -0,0 +1,22 @@
+package izumi.distage.testkit.integration.docker.examples
+
+import distage.{ModuleDef, TagK}
+import izumi.distage.testkit.integration.docker.ContainerDef
+import izumi.distage.testkit.integration.docker.Docker.{ContainerConfig, DockerPort}
+
+object DynamoDocker extends ContainerDef {
+  val primaryPort: DockerPort = DockerPort.TCP(8042)
+
+  override def config: Config = {
+    ContainerConfig(
+      image = "amazon/dynamodb-local:latest",
+      ports = Seq(primaryPort),
+    )
+  }
+}
+
+class DynamoDockerModule[F[_]: TagK] extends ModuleDef {
+  make[DynamoDocker.Container].fromResource {
+    DynamoDocker.make[F]
+  }
+}
diff --git a/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/examples/PostgresDocker.scala b/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/examples/PostgresDocker.scala
new file mode 100644
index 000000000..4da8a0507
--- /dev/null
+++ b/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/examples/PostgresDocker.scala
@@ -0,0 +1,23 @@
+package izumi.distage.testkit.integration.docker.examples
+
+import izumi.distage.model.definition.ModuleDef
+import izumi.distage.testkit.integration.docker.ContainerDef
+import izumi.distage.testkit.integration.docker.Docker.{ContainerConfig, DockerPort}
+import zio.Task
+
+object PostgresDocker extends ContainerDef {
+  val primaryPort: DockerPort = DockerPort.TCP(5432)
+
+  override def config: Config = {
+    ContainerConfig(
+      image = "library/postgres:latest",
+      ports = Seq(primaryPort),
+    )
+  }
+}
+
+class PostgresDockerModule extends ModuleDef {
+  make[PostgresDocker.Container].fromResource {
+    PostgresDocker.make[Task]
+  }
+}
diff --git a/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/modules/DockerContainerModule.scala b/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/modules/DockerContainerModule.scala
new file mode 100644
index 000000000..ee7c83145
--- /dev/null
+++ b/distage/distage-testkit/src/main/scala/izumi/distage/testkit/integration/docker/modules/DockerContainerModule.scala
@@ -0,0 +1,26 @@
+package izumi.distage.testkit.integration.docker.modules
+
+import com.github.dockerjava.api.command.DockerCmdExecFactory
+import distage.TagK
+import izumi.distage.config.annotations.ConfPath
+import izumi.distage.model.definition.ModuleDef
+import izumi.distage.model.monadic.DIEffect
+import izumi.distage.testkit.integration.docker.{Docker, DockerClientWrapper, DockerCmdExecFactoryResource}
+
+class DockerContainerModule[F[_]: DIEffect: TagK] extends ModuleDef {
+  // expose DockerClientWrapper.Resource to execute its IntegrationCheck https://github.com/7mind/izumi/issues/563
+  make[DockerClientWrapper.Resource[F]]
+  make[DockerClientWrapper[F]].refResource[DockerClientWrapper.Resource[F]]
+  make[DockerCmdExecFactory].fromResource[DockerCmdExecFactoryResource[F]]
+
+  include(DockerContainerModule.config)
+}
+
+object DockerContainerModule {
+  final val config = new ModuleDef {
+    make[Docker.ClientConfig].from {
+      conf: Docker.ClientConfig @ConfPath("docker") =>
+        conf
+    }
+  }
+}
diff --git a/distage/distage-testkit/src/test/resources/common-reference-dev.conf b/distage/distage-testkit/src/test/resources/common-reference-dev.conf
new file mode 100644
index 000000000..3b9f93dd4
--- /dev/null
+++ b/distage/distage-testkit/src/test/resources/common-reference-dev.conf
@@ -0,0 +1,4 @@
+docker {
+  readTimeoutMs = 5000
+  connectTimeoutMs = 1000
+}
\ No newline at end of file
diff --git a/distage/distage-testkit/src/test/scala/izumi/distage/testkit/distagesuite/DistageTestExample.scala b/distage/distage-testkit/src/test/scala/izumi/distage/testkit/distagesuite/DistageTestExample.scala
index d9cfa0401..a3948e430 100644
--- a/distage/distage-testkit/src/test/scala/izumi/distage/testkit/distagesuite/DistageTestExample.scala
+++ b/distage/distage-testkit/src/test/scala/izumi/distage/testkit/distagesuite/DistageTestExample.scala
@@ -1,10 +1,13 @@
 package izumi.distage.testkit.distagesuite
 
 import cats.effect.{IO => CIO}
-import distage.DIKey
+import distage._
+import izumi.distage.model.monadic.DIEffect
+import izumi.distage.model.monadic.DIEffect.syntax._
 import izumi.distage.testkit.distagesuite.fixtures.{ApplePaymentProvider, MockCache, MockCachedUserService, MockUserRepository}
 import izumi.distage.testkit.services.st.dtest.{DistageAbstractScalatestSpec, TestConfig}
 import izumi.distage.testkit.st.specs.{DistageBIOSpecScalatest, DistageSpecScalatest}
+import izumi.fundamentals.platform.functional.Identity
 import zio.Task
 
 trait DistageMemoizeExample[F[_]] { this: DistageAbstractScalatestSpec[F] =>
@@ -30,66 +33,39 @@ class DistageTestExampleBIO extends DistageBIOSpecScalatest[zio.IO] with Distage
 
 }
 
-class DistageTestExample extends DistageSpecScalatest[CIO] with DistageMemoizeExample[CIO] {
-  "distage test runner" should {
-    "test 1" in {
-      service: MockUserRepository[CIO] =>
-        for {
-          _ <- CIO.delay(assert(service != null))
-          _ <- CIO.delay(println("test2"))
-        } yield ()
-    }
-
-    "test 2" in {
-      service: MockCachedUserService[CIO] =>
-        for {
-          _ <- CIO.delay(assert(service != null))
-          _ <- CIO.delay(println("test1"))
-        } yield ()
-    }
-
-    "test 3" in {
-      service: MockCachedUserService[CIO] =>
-        CIO.delay(assert(service != null))
-    }
+final class DistageTestExampleCIO extends DistageTestExampleBase[CIO]
+final class DistageTestExampleZIO extends DistageTestExampleBase[Task]
+final class DistageTestExampleId extends DistageTestExampleBase[Identity]
 
-    "test 4 (should be ignored)" in {
-      _: ApplePaymentProvider[CIO] =>
-//        ???
-    }
-  }
-
-}
-
-class DistageTestExample1 extends DistageSpecScalatest[CIO] with DistageMemoizeExample[CIO] {
+abstract class DistageTestExampleBase[F[_]: TagK](implicit F: DIEffect[F]) extends DistageSpecScalatest[F] with DistageMemoizeExample[F] {
 
   "distage test custom runner" should {
     "test 1" in {
-      service: MockUserRepository[CIO] =>
+      service: MockUserRepository[F] =>
         for {
-          _ <- CIO.delay(assert(service != null))
-          _ <- CIO.delay(println("test2"))
+          _ <- F.maybeSuspend(assert(service != null))
+          _ <- F.maybeSuspend(println("test2"))
         } yield ()
     }
 
     "test 2" in {
-      service: MockCachedUserService[CIO] =>
+      service: MockCachedUserService[F] =>
         for {
-          _ <- CIO.delay(assert(service != null))
-          _ <- CIO.delay(println("test1"))
+          _ <- F.maybeSuspend(assert(service != null))
+          _ <- F.maybeSuspend(println("test1"))
         } yield ()
     }
 
     "test 3" in {
-      service: MockCachedUserService[CIO] =>
-        CIO.delay(assert(service != null))
+      service: MockCachedUserService[F] =>
+        F.maybeSuspend(assert(service != null))
     }
 
-    "test 4" in {
-      _: ApplePaymentProvider[CIO] =>
-        //???
+    "test 4 (should be ignored)" in {
+      _: ApplePaymentProvider[F] =>
+        ???
     }
   }
 
-
 }
+
diff --git a/distage/distage-testkit/src/test/scala/izumi/distage/testkit/distagesuite/fixtures/Fixtures.scala b/distage/distage-testkit/src/test/scala/izumi/distage/testkit/distagesuite/fixtures/Fixtures.scala
index 342d22ccd..8dd949d9f 100644
--- a/distage/distage-testkit/src/test/scala/izumi/distage/testkit/distagesuite/fixtures/Fixtures.scala
+++ b/distage/distage-testkit/src/test/scala/izumi/distage/testkit/distagesuite/fixtures/Fixtures.scala
@@ -2,19 +2,22 @@ package izumi.distage.testkit.distagesuite.fixtures
 
 import java.util.concurrent.atomic.AtomicInteger
 
-import cats.effect.IO
+import cats.effect.{IO => CIO}
 import distage.TagK
 import izumi.distage.monadic.modules.{CatsDIEffectModule, ZIODIEffectModule}
 import izumi.distage.plugins.PluginDef
 import izumi.distage.roles.model.IntegrationCheck
+import izumi.fundamentals.platform.functional.Identity
 import izumi.fundamentals.platform.integration.ResourceCheck
+import zio.Task
 
 object MonadPlugin extends PluginDef
   with CatsDIEffectModule
   with ZIODIEffectModule
 
-object MockAppCatsIOPlugin extends MockAppPlugin[IO]
-object MockAppZioPlugin extends MockAppPlugin[zio.IO[Throwable, ?]]
+object MockAppCatsIOPlugin extends MockAppPlugin[CIO]
+object MockAppZioPlugin extends MockAppPlugin[Task]
+object MockAppIdPlugin extends MockAppPlugin[Identity]
 
 abstract class MockAppPlugin[F[_] : TagK] extends PluginDef {
   make[MockPostgresDriver[F]]
diff --git a/distage/distage-testkit/src/test/scala/izumi/distage/testkit/docker/DistageTestDockerBIO.scala b/distage/distage-testkit/src/test/scala/izumi/distage/testkit/docker/DistageTestDockerBIO.scala
new file mode 100644
index 000000000..9f8248d8f
--- /dev/null
+++ b/distage/distage-testkit/src/test/scala/izumi/distage/testkit/docker/DistageTestDockerBIO.scala
@@ -0,0 +1,35 @@
+package izumi.distage.testkit.docker
+
+import distage.DIKey
+import izumi.distage.testkit.docker.fixtures.PgSvcExample
+import izumi.distage.testkit.integration.docker.examples.{DynamoDocker, PostgresDocker}
+import izumi.distage.testkit.services.st.dtest.TestConfig
+import izumi.distage.testkit.st.specs.DistageBIOSpecScalatest
+import zio.IO
+
+final class DistageTestDockerBIO extends DistageBIOSpecScalatest[IO] {
+
+  "distage test runner" should {
+    "support docker resources" in {
+      service: PgSvcExample =>
+        for {
+          _ <- IO(println(s"ports: pg=${service.pg} ddb=${service.ddb} "))
+        } yield ()
+    }
+
+    "support memoization" in {
+      service: PgSvcExample =>
+        for {
+          _ <- IO(println(s"ports: pg=${service.pg} ddb=${service.ddb} "))
+        } yield ()
+    }
+  }
+
+  override protected def config: TestConfig = {
+    TestConfig(
+      memoizedKeys = Set(
+        DIKey.get[DynamoDocker.Container],
+        DIKey.get[PostgresDocker.Container],
+      ))
+  }
+}
diff --git a/distage/distage-testkit/src/test/scala/izumi/distage/testkit/docker/fixtures/DockerPlugin.scala b/distage/distage-testkit/src/test/scala/izumi/distage/testkit/docker/fixtures/DockerPlugin.scala
new file mode 100644
index 000000000..4257ae7a0
--- /dev/null
+++ b/distage/distage-testkit/src/test/scala/izumi/distage/testkit/docker/fixtures/DockerPlugin.scala
@@ -0,0 +1,48 @@
+package izumi.distage.testkit.docker.fixtures
+
+import distage.config.ConfPath
+import izumi.distage.model.definition.Id
+import izumi.distage.model.definition.StandardAxis.Env
+import izumi.distage.monadic.modules.{CatsDIEffectModule, ZIODIEffectModule}
+import izumi.distage.plugins.PluginDef
+import izumi.distage.testkit.integration.docker.examples.{DynamoDocker, PostgresDocker}
+import izumi.distage.testkit.integration.docker.modules.DockerContainerModule
+import zio.Task
+
+case class ServicePort(number: Int)
+
+class PgSvcExample(val pg: ServicePort@Id("pg"), val ddb: ServicePort@Id("ddb"))
+
+object MonadPlugin extends PluginDef
+  with CatsDIEffectModule
+  with ZIODIEffectModule
+
+object DockerPlugin extends DockerContainerModule[Task] with PluginDef {
+  make[DynamoDocker.Container].fromResource {
+    DynamoDocker.make[Task]
+  }
+
+  // this container will start once `DynamoContainer` is up and running
+  make[PostgresDocker.Container].fromResource {
+    PostgresDocker.make[Task]
+      .dependOnDocker(DynamoDocker)
+  }
+
+  // these lines are for test scope
+  make[ServicePort].named("pg").tagged(Env.Test).from {
+    pg: PostgresDocker.Container =>
+      ServicePort(pg.mapping(PostgresDocker.primaryPort))
+  }
+  make[ServicePort].named("ddb").tagged(Env.Test).from {
+    dn: DynamoDocker.Container =>
+      ServicePort(dn.mapping(DynamoDocker.primaryPort))
+  }
+
+  // and this one is for production
+  make[ServicePort].named("pg").tagged(Env.Prod).from {
+    pgPort: Int @ConfPath("postgres.port") =>
+      ServicePort(pgPort)
+  }
+
+  make[PgSvcExample]
+}
diff --git a/distage/distage-testkit/src/test/scala/izumi/distage/testkit/st/fixtures/Fixtures.scala b/distage/distage-testkit/src/test/scala/izumi/distage/testkit/st/fixtures/Fixtures.scala
index 446dd1d3c..1678975ab 100644
--- a/distage/distage-testkit/src/test/scala/izumi/distage/testkit/st/fixtures/Fixtures.scala
+++ b/distage/distage-testkit/src/test/scala/izumi/distage/testkit/st/fixtures/Fixtures.scala
@@ -9,6 +9,7 @@ import izumi.distage.model.definition.StandardAxis._
 import izumi.distage.monadic.modules.{CatsDIEffectModule, ZIODIEffectModule}
 import izumi.distage.plugins.PluginDef
 import izumi.distage.roles.model.IntegrationCheck
+import izumi.distage.testkit.integration.docker.modules.DockerContainerModule
 import izumi.fundamentals.platform.integration.ResourceCheck
 import izumi.fundamentals.platform.language.Quirks
 import izumi.logstage.api.IzLogger