Repository: 7mind/izumi
Pull request #1005 (2020-04-17 02:02:36)

Update cats-effect to 2.1.3
Updates [org.typelevel:cats-effect](https://github.com/typelevel/cats-effect) from 2.1.2 to 2.1.3.
[GitHub Release Notes](https://github.com/typelevel/cats-effect/releases/tag/v2.1.3) - [Version Diff](https://github.com/typelevel/cats-effect/compare/v2.1.2...v2.1.3)

I'll automatically update this PR to resolve conflicts as long as you don't change it yourself.

If you'd like to skip this version, you can just close this PR. If you have any feedback, just mention me in the comments below.

Configure Scala Steward for your repository with a [`.scala-steward.conf`](https://github.com/fthomas/scala-steward/blob/3a42f4618729a130494e7f95f629006cab8a2615/docs/repo-specific-configuration.md) file.

Have a fantastic day writing Scala!

<details>
<summary>Ignore future updates</summary>

Add this to your `.scala-steward.conf` file to ignore future updates of this dependency:
```
updates.ignore = [ { groupId = "org.typelevel", artifactId = "cats-effect" } ]
```
</details>

labels: library-update, semver-patch


Patch:
diff --git a/distage/distage-extension-logstage/src/main/scala/izumi/logstage/distage/PlanningObserverLoggingImpl.scala b/distage/distage-extension-logstage/src/main/scala/izumi/logstage/distage/PlanningObserverLoggingImpl.scala
index ccf42f18c..474cd1ea2 100644
--- a/distage/distage-extension-logstage/src/main/scala/izumi/logstage/distage/PlanningObserverLoggingImpl.scala
+++ b/distage/distage-extension-logstage/src/main/scala/izumi/logstage/distage/PlanningObserverLoggingImpl.scala
@@ -7,31 +7,31 @@ import izumi.logstage.api.IzLogger
 
 class PlanningObserverLoggingImpl(log: IzLogger) extends PlanningObserver {
   override def onSuccessfulStep(next: PrePlan): Unit = {
-    log.trace(s"performed planning step:\n$next")
+    //log.trace(s"performed planning step:\n$next")
   }
 
   override def onPhase00PlanCompleted(plan: PrePlan): Unit = {
-    log.debug(s"[onPhase00PlanCompleted]:\n$plan")
+    log.trace(s"[onPhase00PlanCompleted]:\n$plan")
   }
 
   override def onPhase05PreGC(plan: SemiPlan): Unit = {
-    log.debug(s"[onPhase05PreFinalization]:\n$plan")
+    log.trace(s"[onPhase05PreFinalization]:\n$plan")
   }
 
   override def onPhase10PostGC(plan: SemiPlan): Unit = {
-    log.debug(s"[onPhase10PostOrdering]:\n$plan")
+    log.trace(s"[onPhase10PostOrdering]:\n$plan")
   }
 
   override def onPhase20Customization(plan: SemiPlan): Unit = {
-    log.debug(s"[onPhase15PostOrdering]:\n$plan")
+    log.trace(s"[onPhase15PostOrdering]:\n$plan")
   }
 
   override def onPhase50PreForwarding(plan: SemiPlan): Unit = {
-    log.debug(s"[onPhase20PreForwarding]:\n$plan")
+    log.trace(s"[onPhase20PreForwarding]:\n$plan")
   }
 
   override def onPhase90AfterForwarding(plan: OrderedPlan): Unit = {
-    log.debug(s"[onPhase30AfterForwarding]:\n$plan")
+    log.trace(s"[onPhase30AfterForwarding]:\n$plan")
   }
 }
 
diff --git a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/ContainerDef.scala b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/ContainerDef.scala
index 88931a3f2..57dbe7ffe 100644
--- a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/ContainerDef.scala
+++ b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/ContainerDef.scala
@@ -34,7 +34,7 @@ trait ContainerDef {
     * }}}
     *
     */
-  final def make[F[_]: TagK](implicit tag: distage.Tag[Tag]): ProviderMagnet[DockerContainer.ContainerResource[F, Tag] with DIResourceBase[F, Container]] = {
+  final def make[F[_]: TagK](implicit tag: distage.Tag[Tag]): ProviderMagnet[ContainerResource[F, Tag] with DIResourceBase[F, Container]] = {
     tag.discard()
     DockerContainer.resource[F](this)
   }
diff --git a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/ContainerHealthCheck.scala b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/ContainerHealthCheck.scala
deleted file mode 100644
index c94ff965b..000000000
--- a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/ContainerHealthCheck.scala
+++ /dev/null
@@ -1,47 +0,0 @@
-package izumi.distage.docker
-
-import izumi.distage.docker.Docker.{AvailablePort, DockerPort, HealthCheckResult}
-import izumi.fundamentals.platform.integration.{PortCheck, ResourceCheck}
-import izumi.logstage.api.IzLogger
-
-trait ContainerHealthCheck[Tag] {
-  def check(logger: IzLogger, container: DockerContainer[Tag]): HealthCheckResult
-}
-
-object ContainerHealthCheck {
-  def dontCheckPorts[T]: ContainerHealthCheck[T] = (_, _) => HealthCheckResult.JustRunning
-
-  /**
-    * Warning: this port probe will not check UDP ports
-    */
-  def checkAllPorts[T]: ContainerHealthCheck[T] = {
-    (logger: IzLogger, container: DockerContainer[T]) =>
-      val check = new PortCheck(container.containerConfig.portProbeTimeout.toMillis.toInt)
-      val availablePorts = container.ports
-        .collect {
-          case (port: DockerPort.TCP, bindings) =>
-            logger.debug(s"Probing $port on $container...")
-            val availablePorts = bindings.flatMap {
-              servicePort =>
-                val candidateHosts = if (servicePort.listenOnV4 != "0.0.0.0") {
-                  Seq(servicePort.listenOnV4) ++ servicePort.containerAddressesV4
-                } else {
-                  Seq("127.0.0.1") ++ servicePort.containerAddressesV4
-                }
-
-                candidateHosts.map {
-                  host =>
-                    (AvailablePort(host, servicePort.port), check.checkPort(host, servicePort.port, s"open port $port on ${container.id}"))
-                }
-            }.collect { case (p, _: ResourceCheck.Success) => p }
-
-            (port: DockerPort, availablePorts)
-        }
-
-      if (availablePorts.exists(_._2.isEmpty)) {
-        HealthCheckResult.Unknown
-      } else {
-        HealthCheckResult.WithPorts(availablePorts)
-      }
-  }
-}
diff --git a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/ContainerNetworkDef.scala b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/ContainerNetworkDef.scala
index 505eafc79..7ec2a86d5 100644
--- a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/ContainerNetworkDef.scala
+++ b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/ContainerNetworkDef.scala
@@ -37,7 +37,7 @@ object ContainerNetworkDef {
     prefixName: String,
     logger: IzLogger,
   ) extends DIResource[F, ContainerNetwork[T]] {
-    private[this] val client = clientw.client
+    private[this] val client = clientw.rawClient
     private[this] val prefix = prefixName.camelToUnderscores.drop(1).replace("$", "")
     private[this] val stableLabels: Map[String, String] = Map(
       "distage.reuse" -> shouldReuse(config),
diff --git a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/ContainerResource.scala b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/ContainerResource.scala
new file mode 100644
index 000000000..92aed7741
--- /dev/null
+++ b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/ContainerResource.scala
@@ -0,0 +1,303 @@
+package izumi.distage.docker
+
+import java.util.concurrent.{TimeUnit, TimeoutException}
+
+import com.github.dockerjava.api.command.InspectContainerResponse
+import com.github.dockerjava.api.model._
+import com.github.ghik.silencer.silent
+import izumi.distage.docker.Docker._
+import izumi.distage.docker.healthcheck.ContainerHealthCheck.{HealthCheckResult, VerifiedContainerConnectivity}
+import izumi.distage.framework.model.exceptions.IntegrationCheckException
+import izumi.distage.model.definition.DIResource
+import izumi.distage.model.effect.DIEffect.syntax._
+import izumi.distage.model.effect.{DIEffect, DIEffectAsync}
+import izumi.functional.Value
+import izumi.fundamentals.collections.nonempty.NonEmptyList
+import izumi.fundamentals.platform.integration.ResourceCheck
+import izumi.fundamentals.platform.network.IzSockets
+import izumi.logstage.api.IzLogger
+
+import scala.concurrent.duration._
+import scala.jdk.CollectionConverters._
+
+case class ContainerResource[F[_], T](
+                                       config: Docker.ContainerConfig[T],
+                                       clientw: DockerClientWrapper[F],
+                                       logger: IzLogger,
+                                     )(
+                                       implicit
+                                       val F: DIEffect[F],
+                                       val P: DIEffectAsync[F]
+                                     ) extends DIResource[F, DockerContainer[T]] {
+
+  import ContainerResource._
+
+  private[this] val client = clientw.rawClient
+
+  private[this] def toExposedPort(port: DockerPort): ExposedPort = {
+    port match {
+      case DockerPort.TCP(number) =>
+        ExposedPort.tcp(number)
+      case DockerPort.UDP(number) =>
+        ExposedPort.udp(number)
+    }
+  }
+
+  private[this] def shouldReuse(config: Docker.ContainerConfig[T]): Boolean = {
+    config.reuse && clientw.clientConfig.allowReuse
+  }
+
+  override def acquire: F[DockerContainer[T]] = F.suspendF {
+    val ports = config.ports.map {
+      containerPort =>
+        val local = IzSockets.temporaryLocalPort()
+        val bp = toExposedPort(containerPort)
+        val binding = new PortBinding(Ports.Binding.bindPort(local), bp)
+        val stableLabels = Map(
+          "distage.reuse" -> shouldReuse(config).toString,
+          s"distage.port.${containerPort.protocol}.${containerPort.number}.defined" -> "true",
+        )
+        val labels = Map(
+          s"distage.port.${containerPort.protocol}.${containerPort.number}" -> local.toString,
+        )
+        PortDecl(containerPort, local, binding, stableLabels ++ labels)
+    }
+
+    if (shouldReuse(config)) {
+      runReused(ports)
+    } else {
+      doRun(ports)
+    }
+  }
+
+  override def release(resource: DockerContainer[T]): F[Unit] = {
+    if (!shouldReuse(resource.containerConfig)) {
+      clientw.destroyContainer(resource.id)
+    } else {
+      F.unit
+    }
+  }
+
+
+  def await(container: DockerContainer[T], attempt: Int): F[DockerContainer[T]] = {
+    F.maybeSuspend {
+      logger.debug(s"Awaiting until alive: $container...")
+      try {
+        val status = client.inspectContainerCmd(container.id.name).exec()
+        if (status.getState.getRunning) {
+          logger.debug(s"Trying healthcheck on running $container...")
+          Right(config.healthCheck.check(logger, container))
+        } else {
+          Left(new RuntimeException(s"Container exited: ${container.id}, full status: $status"))
+        }
+      } catch {
+        case t: Throwable =>
+          Left(t)
+      }
+    }.flatMap {
+      case Right(HealthCheckResult.Ignored) =>
+        F.maybeSuspend {
+          logger.info(s"Continuing without port checks: $container")
+          container
+        }
+
+      case Right(status: HealthCheckResult.PortStatus) =>
+        if (status.requiredPortsAccessible) {
+          val out = container.copy(availablePorts = status.availablePorts)
+          F.maybeSuspend {
+            logger.info(s"Looks good: ${out -> "container"}")
+            out
+          }
+        } else {
+          val max = config.healthCheckMaxAttempts
+          val next = attempt + 1
+          if (max >= next) {
+            logger.debug(s"Healthcheck uncertain, retrying $next/$max on $container...")
+            P.sleep(config.healthCheckInterval).flatMap(_ => await(container, next))
+          } else {
+            F.fail(new TimeoutException(s"Failed to start after $max attempts: $container"))
+          }
+        }
+
+
+      case Left(t) =>
+        F.fail(new RuntimeException(s"Container failed: ${container.id}", t))
+    }
+  }
+
+  private[this] def runReused(ports: Seq[PortDecl]): F[DockerContainer[T]] = {
+    logger.info(s"About to start or find container ${config.image}, ${config.pullTimeout -> "max lock retries"}...")
+    FileLockMutex.withLocalMutex(logger)(
+      s"${config.image.replace("/", "_")}:${config.ports.mkString(";")}",
+      waitFor = 1.second,
+      maxAttempts = config.pullTimeout.toSeconds.toInt
+    ) {
+      for {
+        containers <- F.maybeSuspend {
+          // FIXME: temporary hack to allow missing containers to skip tests (happens when both DockerWrapper & integration check that depends on Docker.Container are memoized)
+          try {
+            client
+              .listContainersCmd()
+              .withAncestorFilter(List(config.image).asJava)
+              .withStatusFilter(List("running").asJava)
+              .exec()
+          } catch {
+            case c: Throwable =>
+              throw new IntegrationCheckException(Seq(ResourceCheck.ResourceUnavailable(c.getMessage, Some(c))))
+          }
+        }.map(_.asScala.toList.sortBy(_.getId))
+
+        candidates = {
+          val portSet = ports.map(_.port).toSet
+          containers.iterator.flatMap {
+            c =>
+              val inspection = client.inspectContainerCmd(c.getId).exec()
+              mapContainerPorts(inspection) match {
+                case Left(value) =>
+                  logger.info(s"Container ${c.getId} missing ports $value so will not be reused")
+                  Seq.empty
+                case Right(value) =>
+                  Seq((c, inspection, value))
+              }
+          }.find {
+            case (_, _, eports) =>
+              portSet.diff(eports.dockerPorts.keySet).isEmpty
+          }
+        }
+        existing <- candidates match {
+          case Some((c, inspection, existingPorts)) =>
+            val unverified = DockerContainer[T](
+              id = ContainerId(c.getId),
+              name = inspection.getName,
+              connectivity = existingPorts,
+              containerConfig = config,
+              clientConfig = clientw.clientConfig,
+              availablePorts = VerifiedContainerConnectivity.empty,
+              hostName = inspection.getConfig.getHostName,
+            )
+            logger.debug(s"Matching container found: ${config.image}->${unverified.name}, will try to reuse...")
+            await(unverified, 0)
+          case None =>
+            logger.debug(s"No existring container found for ${config.image}, will run...")
+            doRun(ports)
+        }
+      } yield existing
+    }
+  }
+
+  private[this] def doRun(ports: Seq[PortDecl]): F[DockerContainer[T]] = {
+    val allPortLabels = ports.flatMap(p => p.labels).toMap
+    val baseCmd = client
+      .createContainerCmd(config.image)
+      .withLabels((clientw.labels ++ allPortLabels).asJava)
+
+    val volumes = config.mounts.map {
+      case Docker.Mount(h, c, true) => new Bind(h, new Volume(c), true)
+      case Docker.Mount(h, c, _) => new Bind(h, new Volume(c))
+    }
+
+    for {
+      out <- F.maybeSuspend {
+        @silent("method.*Bind.*deprecated")
+        val cmd = Value(baseCmd)
+          .mut(config.name) { case (n, c) => c.withName(n) }
+          .mut(ports.nonEmpty)(_.withExposedPorts(ports.map(_.binding.getExposedPort).asJava))
+          .mut(ports.nonEmpty)(_.withPortBindings(ports.map(_.binding).asJava))
+          .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)
+          .start()
+          .awaitCompletion(config.pullTimeout.toMillis, TimeUnit.MILLISECONDS);
+
+        logger.debug(s"Going to create container from image `${config.image}`...")
+        val res = cmd.exec()
+
+        logger.debug(s"Going to start container ${res.getId -> "id"}...")
+        client.startContainerCmd(res.getId).exec()
+
+        val inspection = client.inspectContainerCmd(res.getId).exec()
+        val hostName = inspection.getConfig.getHostName
+        val maybeMappedPorts = mapContainerPorts(inspection)
+
+        maybeMappedPorts match {
+          case Left(value) =>
+            throw new RuntimeException(s"Created container from `${config.image}` with ${res.getId -> "id"}, but ports are missing: $value!")
+
+          case Right(mappedPorts) =>
+            val container = DockerContainer[T](
+              ContainerId(res.getId),
+              inspection.getName,
+              hostName,
+              mappedPorts,
+              config,
+              clientw.clientConfig,
+              VerifiedContainerConnectivity.empty,
+            )
+            logger.debug(s"Created $container from ${config.image}...")
+            logger.debug(s"Going to attach container ${res.getId -> "id"} to ${config.networks -> "networks"}")
+            config.networks.foreach {
+              network =>
+                client
+                  .connectToNetworkCmd()
+                  .withContainerId(container.id.name)
+                  .withNetworkId(network.id)
+                  .exec()
+            }
+
+            container
+        }
+      }
+      result <- await(out, 0)
+    } yield result
+  }
+
+  private def mapContainerPorts(inspection: InspectContainerResponse): Either[UnmappedPorts, ContainerConnectivity] = {
+    val network = inspection.getNetworkSettings
+
+    val ports = config.ports.map {
+      containerPort =>
+        val mappings = for {
+          exposed <- Option(network.getPorts.getBindings.get(toExposedPort(containerPort))).toSeq
+          port <- exposed
+        } yield {
+          ServicePort(port.getHostIp, Integer.parseInt(port.getHostPortSpec))
+        }
+
+        (containerPort, NonEmptyList.from(mappings))
+    }
+    val unmapped = ports.collect { case (cp, None) => cp }
+
+    if (unmapped.nonEmpty) {
+      Left(UnmappedPorts(unmapped))
+    } else {
+      val dockerHost = clientw.rawClientConfig.getDockerHost.getHost
+      val networkAddresses = network.getNetworks.asScala.values.toList.map(_.getIpAddress)
+      val mapped = ports.collect { case (cp, Some(lst)) => (cp, lst) }
+
+      Right(ContainerConnectivity(
+        Option(dockerHost),
+        networkAddresses,
+        mapped.toMap,
+      ))
+    }
+
+  }
+
+}
+
+object ContainerResource {
+
+  private final case class PortDecl(port: DockerPort, localFree: Int, binding: PortBinding, labels: Map[String, String])
+
+}
\ No newline at end of file
diff --git a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/Docker.scala b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/Docker.scala
index 217dd6cfc..9cbe1af8f 100644
--- a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/Docker.scala
+++ b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/Docker.scala
@@ -4,6 +4,8 @@ import java.util.concurrent.TimeUnit
 
 import izumi.distage.config.codec.DIConfigReader
 import izumi.distage.docker.ContainerNetworkDef.ContainerNetwork
+import izumi.distage.docker.healthcheck.ContainerHealthCheck
+import izumi.fundamentals.collections.nonempty.NonEmptyList
 
 import scala.concurrent.duration.FiniteDuration
 
@@ -14,7 +16,7 @@ object Docker {
     def hostPort(host: String, port: Int): AvailablePort = AvailablePort(host, port)
   }
 
-  final case class ServicePort(containerAddressesV4: Seq[String], listenOnV4: String, port: Int)
+  final case class ServicePort(listenOnV4: String, port: Int)
 
   final case class ContainerId(name: String) extends AnyVal
 
@@ -71,21 +73,22 @@ object Docker {
     *
     */
   final case class ContainerConfig[T](
-    image: String,
-    ports: Seq[DockerPort],
-    name: Option[String] = None,
-    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,
-    networks: Set[ContainerNetwork[_]] = Set.empty,
-    reuse: Boolean = true,
-    healthCheckInterval: FiniteDuration = FiniteDuration(1, TimeUnit.SECONDS),
-    pullTimeout: FiniteDuration = FiniteDuration(120, TimeUnit.SECONDS),
-    healthCheck: ContainerHealthCheck[T] = ContainerHealthCheck.checkAllPorts[T],
-    portProbeTimeout: FiniteDuration = FiniteDuration(200, TimeUnit.MILLISECONDS)
+                                       image: String,
+                                       ports: Seq[DockerPort],
+                                       name: Option[String] = None,
+                                       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,
+                                       networks: Set[ContainerNetwork[_]] = Set.empty,
+                                       reuse: Boolean = true,
+                                       healthCheckInterval: FiniteDuration = FiniteDuration(1, TimeUnit.SECONDS),
+                                       healthCheckMaxAttempts: Int = 120,
+                                       pullTimeout: FiniteDuration = FiniteDuration(120, TimeUnit.SECONDS),
+                                       healthCheck: ContainerHealthCheck[T] = ContainerHealthCheck.checkTCPOnly[T],
+                                       portProbeTimeout: FiniteDuration = FiniteDuration(200, TimeUnit.MILLISECONDS)
   )
 
   /**
@@ -127,16 +130,19 @@ object Docker {
 
   final case class DockerRegistryConfig(url: String, username: String, password: String, email: String)
 
-  sealed trait HealthCheckResult
-  object HealthCheckResult {
-    case object Unknown extends HealthCheckResult
 
-    final case class Failed(t: Throwable) extends HealthCheckResult
 
-    sealed trait Running extends HealthCheckResult
-    case object JustRunning extends Running
-    final case class WithPorts(availablePorts: Map[DockerPort, Seq[AvailablePort]]) extends Running
+  final case class Mount(host: String, container: String, noCopy: Boolean = false)
+
+  case class UnmappedPorts(ports: Seq[DockerPort])
+
+  case class ContainerConnectivity(
+                                    dockerHost: Option[String],
+                                    containerAddressesV4: Seq[String],
+                                    dockerPorts: Map[DockerPort, NonEmptyList[ServicePort]],
+                                  ) {
+    override def toString: String = s"{host: $dockerHost; addresses=$containerAddressesV4; ports=$dockerPorts}"
   }
 
-  final case class Mount(host: String, container: String, noCopy: Boolean = false)
+
 }
diff --git a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/DockerClientWrapper.scala b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/DockerClientWrapper.scala
index 035f8a882..da98ec2cb 100644
--- a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/DockerClientWrapper.scala
+++ b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/DockerClientWrapper.scala
@@ -4,7 +4,7 @@ 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 com.github.dockerjava.core.{DefaultDockerClientConfig, DockerClientBuilder, DockerClientConfig}
 import izumi.distage.model.definition.DIResource
 import izumi.distage.model.effect.DIEffect
 import izumi.distage.model.effect.DIEffect.syntax._
@@ -19,7 +19,8 @@ import scala.jdk.CollectionConverters._
 
 class DockerClientWrapper[F[_]]
 (
-  val client: DockerClient,
+  val rawClient: DockerClient,
+  val rawClientConfig: DockerClientConfig,
   val clientConfig: ClientConfig,
   val labelsBase: Map[String, String],
   val labelsUnique: Map[String, String],
@@ -35,12 +36,12 @@ class DockerClientWrapper[F[_]]
         logger.info(s"Going to destroy $container...")
 
         try {
-          client
+          rawClient
             .stopContainerCmd(container.name)
             .exec()
             .discard()
         } finally {
-          client
+          rawClient
             .removeContainerCmd(container.name)
             .withForce(true)
             .exec()
@@ -63,13 +64,13 @@ object DockerClientWrapper {
   ) extends DIResource[F, DockerClientWrapper[F]]
     with IntegrationCheck {
 
-    private[this] lazy val dcc = Value(DefaultDockerClientConfig.createDefaultConfigBuilder())
+    private[this] lazy val rawClientConfig = Value(DefaultDockerClientConfig.createDefaultConfigBuilder())
       .mut(clientConfig.remote.filter(_ => clientConfig.useRemote))((c, b) => b.withDockerHost(c.host).withDockerTlsVerify(c.tlsVerify).withDockerCertPath(c.certPath).withDockerConfig(c.config))
       .mut(clientConfig.registry.filter(_ => clientConfig.useRegistry))((c, b) => b.withRegistryUrl(c.url).withRegistryUsername(c.username).withRegistryPassword(c.password).withRegistryEmail(c.email))
       .get.build()
 
     private[this] lazy val client = DockerClientBuilder
-      .getInstance(dcc)
+      .getInstance(rawClientConfig)
       .withDockerCmdExecFactory(factory)
       .build
 
@@ -86,7 +87,8 @@ object DockerClientWrapper {
     override def acquire: F[DockerClientWrapper[F]] = {
       DIEffect[F].maybeSuspend {
         new DockerClientWrapper[F](
-          client = client,
+          rawClient = client,
+          rawClientConfig = rawClientConfig,
           labelsBase = Map("distage.type" -> "testkit"),
           labelsUnique = Map("distage.run" -> UUID.randomUUID().toString),
           logger = logger,
@@ -97,9 +99,9 @@ object DockerClientWrapper {
 
     override def release(resource: DockerClientWrapper[F]): F[Unit] = {
       for {
-        containers <- DIEffect[F].maybeSuspend(resource.client.listContainersCmd().withLabelFilter(resource.labels.asJava).exec())
+        containers <- DIEffect[F].maybeSuspend(resource.rawClient.listContainersCmd().withLabelFilter(resource.labels.asJava).exec())
         _ <- DIEffect[F].traverse_(containers.asScala.filterNot(_.getLabels.getOrDefault("distage.reuse", "false") == "true"))(c => resource.destroyContainer(ContainerId(c.getId)))
-        _ <- DIEffect[F].maybeSuspend(resource.client.close())
+        _ <- DIEffect[F].maybeSuspend(resource.rawClient.close())
       } yield ()
     }
   }
diff --git a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/DockerContainer.scala b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/DockerContainer.scala
index 5ee6389a2..e4d14370a 100644
--- a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/DockerContainer.scala
+++ b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/DockerContainer.scala
@@ -1,35 +1,31 @@
 package izumi.distage.docker
 
-import java.util.concurrent.TimeUnit
-
-import com.github.dockerjava.api.command.InspectContainerResponse
-import com.github.dockerjava.api.model._
-import com.github.ghik.silencer.silent
 import izumi.distage.docker.Docker._
-import izumi.distage.framework.model.exceptions.IntegrationCheckException
-import izumi.distage.model.definition.DIResource
-import izumi.distage.model.effect.DIEffect.syntax._
+import izumi.distage.docker.healthcheck.ContainerHealthCheck.VerifiedContainerConnectivity
 import izumi.distage.model.effect.{DIEffect, DIEffectAsync}
 import izumi.distage.model.providers.ProviderMagnet
-import izumi.functional.Value
-import izumi.fundamentals.platform.integration.ResourceCheck
 import izumi.fundamentals.platform.language.Quirks._
-import izumi.fundamentals.platform.network.IzSockets
 import izumi.logstage.api.IzLogger
 
-import scala.concurrent.duration._
-import scala.jdk.CollectionConverters._
-
 final case class DockerContainer[Tag](
-  id: Docker.ContainerId,
-  name: String,
-  hostName: String,
-  ports: Map[Docker.DockerPort, Seq[ServicePort]],
-  containerConfig: Docker.ContainerConfig[Tag],
-  clientConfig: ClientConfig,
-  availablePorts: Map[Docker.DockerPort, Seq[AvailablePort]],
-) {
-  override def toString: String = s"$name:${id.name} ports=${ports.mkString("{", ", ", "}")} available=${availablePorts.mkString("{", ", ", "}")}"
+                                       id: Docker.ContainerId,
+                                       name: String,
+                                       hostName: String,
+                                       connectivity: ContainerConnectivity,
+                                       containerConfig: Docker.ContainerConfig[Tag],
+                                       clientConfig: ClientConfig,
+                                       availablePorts: VerifiedContainerConnectivity,
+                                     ) {
+  override def toString: String = {
+    val out = new StringBuilder()
+    out.append(s"$name@${connectivity.dockerHost.getOrElse("localhost")}")
+    if (availablePorts.availablePorts.nonEmpty) {
+      out.append(" {")
+      out.append(availablePorts.toString)
+      out.append('}')
+    }
+    out.toString()
+  }
 }
 
 object DockerContainer {
@@ -57,8 +53,8 @@ object DockerContainer {
       *
       */
     def modifyConfig(
-      modify: ProviderMagnet[Docker.ContainerConfig[T] => Docker.ContainerConfig[T]]
-    )(implicit tag1: distage.Tag[ContainerResource[F, T]], tag2: distage.Tag[Docker.ContainerConfig[T]]): ProviderMagnet[ContainerResource[F, T]] = {
+                      modify: ProviderMagnet[Docker.ContainerConfig[T] => Docker.ContainerConfig[T]]
+                    )(implicit tag1: distage.Tag[ContainerResource[F, T]], tag2: distage.Tag[Docker.ContainerConfig[T]]): ProviderMagnet[ContainerResource[F, T]] = {
       tag2.discard()
       self.zip(modify).map {
         case (that, f) =>
@@ -76,276 +72,36 @@ object DockerContainer {
     }
 
     def connectToNetwork[T2](
-      implicit tag1: distage.Tag[ContainerNetworkDef.ContainerNetwork[T2]],
-      tag2: distage.Tag[ContainerResource[F, T]],
-      tag3: distage.Tag[Docker.ContainerConfig[T]]
-    ): ProviderMagnet[ContainerResource[F, T]] = {
+                              implicit tag1: distage.Tag[ContainerNetworkDef.ContainerNetwork[T2]],
+                              tag2: distage.Tag[ContainerResource[F, T]],
+                              tag3: distage.Tag[Docker.ContainerConfig[T]]
+                            ): ProviderMagnet[ContainerResource[F, T]] = {
       tag1.discard()
       modifyConfig {
-        net: ContainerNetworkDef.ContainerNetwork[T2] => old: Docker.ContainerConfig[T] =>
-          old.copy(networks = old.networks + net)
+        net: ContainerNetworkDef.ContainerNetwork[T2] =>
+          old: Docker.ContainerConfig[T] =>
+            old.copy(networks = old.networks + net)
       }
     }
 
     def connectToNetwork(
-      networkDecl: ContainerNetworkDef
-    )(
-      implicit tag1: distage.Tag[ContainerNetworkDef.ContainerNetwork[networkDecl.Tag]],
-      tag2: distage.Tag[ContainerResource[F, T]],
-      tag3: distage.Tag[Docker.ContainerConfig[T]]
-    ): ProviderMagnet[ContainerResource[F, T]] = {
+                          networkDecl: ContainerNetworkDef
+                        )(
+                          implicit tag1: distage.Tag[ContainerNetworkDef.ContainerNetwork[networkDecl.Tag]],
+                          tag2: distage.Tag[ContainerResource[F, T]],
+                          tag3: distage.Tag[Docker.ContainerConfig[T]]
+                        ): ProviderMagnet[ContainerResource[F, T]] = {
       tag1.discard()
       modifyConfig {
-        net: ContainerNetworkDef.ContainerNetwork[networkDecl.Tag] => old: Docker.ContainerConfig[T] =>
-          old.copy(networks = old.networks + net)
+        net: ContainerNetworkDef.ContainerNetwork[networkDecl.Tag] =>
+          old: Docker.ContainerConfig[T] =>
+            old.copy(networks = old.networks + net)
       }
     }
   }
 
-  private[this] final case class PortDecl(port: DockerPort, localFree: Int, binding: PortBinding, labels: Map[String, String])
-
-  case class ContainerResource[F[_], T](
-    config: Docker.ContainerConfig[T],
-    clientw: DockerClientWrapper[F],
-    logger: IzLogger,
-  )(
-    implicit
-    val F: DIEffect[F],
-    val P: DIEffectAsync[F]
-  ) extends DIResource[F, DockerContainer[T]] {
-
-    private[this] val client = clientw.client
-
-    private[this] def toExposedPort(port: DockerPort): ExposedPort = {
-      port match {
-        case DockerPort.TCP(number) =>
-          ExposedPort.tcp(number)
-        case DockerPort.UDP(number) =>
-          ExposedPort.udp(number)
-      }
-    }
-
-    private[this] def shouldReuse(config: Docker.ContainerConfig[T]): Boolean = {
-      config.reuse && clientw.clientConfig.allowReuse
-    }
-
-    override def acquire: F[DockerContainer[T]] = F.suspendF {
-      val ports = config.ports.map {
-        containerPort =>
-          val local = IzSockets.temporaryLocalPort()
-          val bp = toExposedPort(containerPort)
-          val binding = new PortBinding(Ports.Binding.bindPort(local), bp)
-          val stableLabels = Map(
-            "distage.reuse" -> shouldReuse(config).toString,
-            s"distage.port.${containerPort.protocol}.${containerPort.number}.defined" -> "true",
-          )
-          val labels = Map(
-            s"distage.port.${containerPort.protocol}.${containerPort.number}" -> local.toString,
-          )
-          PortDecl(containerPort, local, binding, stableLabels ++ labels)
-      }
-
-      if (shouldReuse(config)) {
-        runReused(ports)
-      } else {
-        doRun(ports)
-      }
-    }
-    override def release(resource: DockerContainer[T]): F[Unit] = {
-      if (!shouldReuse(resource.containerConfig)) {
-        clientw.destroyContainer(resource.id)
-      } else {
-        F.unit
-      }
-    }
-
-    def await(container: DockerContainer[T]): F[DockerContainer[T]] = {
-      F.maybeSuspend {
-        logger.debug(s"Awaiting until $container gets alive")
-        try {
-          val status = client.inspectContainerCmd(container.id.name).exec()
-          if (status.getState.getRunning) {
-            config.healthCheck.check(logger, container)
-          } else {
-            HealthCheckResult.Failed(new RuntimeException(s"Container exited: ${container.id}, full status: $status"))
-          }
-        } catch {
-          case t: Throwable =>
-            HealthCheckResult.Failed(t)
-        }
-      }.flatMap {
-        case HealthCheckResult.JustRunning =>
-          F.maybeSuspend {
-            logger.info(s"$container looks alive...")
-            container
-          }
-
-        case HealthCheckResult.WithPorts(ports) =>
-          val out = container.copy(availablePorts = ports)
-          F.maybeSuspend {
-            logger.info(s"${out -> "container"} looks good...")
-            out
-          }
-
-        case HealthCheckResult.Failed(t) =>
-          F.fail(new RuntimeException(s"Container failed: ${container.id}", t))
-
-        case HealthCheckResult.Unknown =>
-          P.sleep(config.healthCheckInterval).flatMap(_ => await(container))
-      }
-    }
 
-    private[this] def runReused(ports: Seq[PortDecl]): F[DockerContainer[T]] = {
-      logger.info(s"Running container with reused option with ${config.pullTimeout}.")
-      FileLockMutex.withLocalMutex(logger)(
-        s"${config.image.replace("/", "_")}:${config.ports.mkString(";")}",
-        waitFor = 1.second,
-        maxAttempts = config.pullTimeout.toSeconds.toInt
-      ) {
-        for {
-          containers <- F.maybeSuspend {
-            // FIXME: temporary hack to allow missing containers to skip tests (happens when both DockerWrapper & integration check that depends on Docker.Container are memoized)
-            try {
-              client
-                .listContainersCmd()
-                .withAncestorFilter(List(config.image).asJava)
-                .withStatusFilter(List("running").asJava)
-                .exec()
-            } catch {
-              case c: Throwable =>
-                throw new IntegrationCheckException(Seq(ResourceCheck.ResourceUnavailable(c.getMessage, Some(c))))
-            }
-          }.map(_.asScala.toList.sortBy(_.getId))
-
-          candidates = {
-            val portSet = ports.map(_.port).toSet
-            containers.iterator.flatMap {
-              c =>
-                val inspection = client.inspectContainerCmd(c.getId).exec()
-                mapContainerPorts(inspection) match {
-                  case Left(value) =>
-                    logger.info(s"Container ${c.getId} missing ports $value so will not be reused")
-                    Seq.empty
-                  case Right(value) =>
-                    Seq((c, inspection, value))
-                }
-            }.find {
-              case (_, _, eports) =>
-                portSet.diff(eports.keySet).isEmpty
-            }
-          }
-          existing <- candidates match {
-            case Some((c, inspection, existingPorts)) =>
-              val unverified = DockerContainer[T](
-                id = ContainerId(c.getId),
-                name = inspection.getName,
-                ports = existingPorts,
-                containerConfig = config,
-                clientConfig = clientw.clientConfig,
-                availablePorts = Map.empty,
-                hostName = inspection.getConfig.getHostName
-              )
-              await(unverified)
-            case None =>
-              doRun(ports)
-          }
-        } yield existing
-      }
-    }
-
-    private[this] def doRun(ports: Seq[PortDecl]): F[DockerContainer[T]] = {
-      val allPortLabels = ports.flatMap(p => p.labels).toMap
-      val baseCmd = client
-        .createContainerCmd(config.image)
-        .withLabels((clientw.labels ++ allPortLabels).asJava)
-
-      val volumes = config.mounts.map {
-        case Docker.Mount(h, c, true) => new Bind(h, new Volume(c), true)
-        case Docker.Mount(h, c, _) => new Bind(h, new Volume(c))
-      }
-
-      for {
-        out <- F.maybeSuspend {
-          @silent("method.*Bind.*deprecated")
-          val cmd = Value(baseCmd)
-            .mut(config.name) { case (n, c) => c.withName(n) }
-            .mut(ports.nonEmpty)(_.withExposedPorts(ports.map(_.binding.getExposedPort).asJava))
-            .mut(ports.nonEmpty)(_.withPortBindings(ports.map(_.binding).asJava))
-            .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)
-            .start()
-            .awaitCompletion(config.pullTimeout.toMillis, TimeUnit.MILLISECONDS);
-
-          logger.debug(s"Going to create container from image `${config.image}`...")
-          val res = cmd.exec()
-
-          logger.debug(s"Going to start container ${res.getId -> "id"}...")
-          client.startContainerCmd(res.getId).exec()
-
-          val inspection = client.inspectContainerCmd(res.getId).exec()
-          val hostName = inspection.getConfig.getHostName
-          val maybeMappedPorts = mapContainerPorts(inspection)
-
-          maybeMappedPorts match {
-            case Left(value) =>
-              throw new RuntimeException(s"Created container from `${config.image}` with ${res.getId -> "id"}, but ports are missing: $value!")
-
-            case Right(mappedPorts) =>
-              val container = DockerContainer[T](ContainerId(res.getId), inspection.getName, hostName, mappedPorts, config, clientw.clientConfig, Map.empty)
-              logger.debug(s"Created $container from ${config.image}...")
-              logger.debug(s"Going to attach container ${res.getId -> "id"} to ${config.networks -> "networks"}")
-              config.networks.foreach {
-                network =>
-                  client
-                    .connectToNetworkCmd()
-                    .withContainerId(container.id.name)
-                    .withNetworkId(network.id)
-                    .exec()
-              }
-
-              container
-          }
-        }
-        result <- await(out)
-      } yield result
-    }
-
-    private def mapContainerPorts(inspection: InspectContainerResponse): Either[Seq[DockerPort], Map[DockerPort, Seq[ServicePort]]] = {
-      val network = inspection.getNetworkSettings
-      val networkAddresses = network.getNetworks.asScala.values.toList
-
-      val ports = config.ports.map {
-        containerPort =>
-          val mappings = for {
-            exposed <- Option(network.getPorts.getBindings.get(toExposedPort(containerPort))).toSeq
-            port <- exposed
-          } yield port
-          (containerPort, mappings)
-      }
+}
 
-      val (good, bad) = ports.partition(_._2.nonEmpty)
-      if (bad.isEmpty) {
-        Right(good.map {
-          case (containerPort, ports) =>
-            containerPort -> ports.map(port => ServicePort(networkAddresses.map(_.getIpAddress), port.getHostIp, Integer.parseInt(port.getHostPortSpec)))
-        }.toMap)
-      } else {
-        Left(bad.map(_._1))
-      }
 
-    }
-  }
 
-}
diff --git a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/healthcheck/ContainerHealthCheck.scala b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/healthcheck/ContainerHealthCheck.scala
new file mode 100644
index 000000000..b248e6b3d
--- /dev/null
+++ b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/healthcheck/ContainerHealthCheck.scala
@@ -0,0 +1,40 @@
+package izumi.distage.docker.healthcheck
+
+import izumi.distage.docker.Docker._
+import izumi.distage.docker.DockerContainer
+import izumi.distage.docker.healthcheck.ContainerHealthCheck.HealthCheckResult
+import izumi.fundamentals.collections.nonempty.NonEmptyList
+import izumi.logstage.api.IzLogger
+
+trait ContainerHealthCheck[Tag] {
+  def check(logger: IzLogger, container: DockerContainer[Tag]): HealthCheckResult
+}
+
+
+object ContainerHealthCheck {
+  sealed trait HealthCheckResult
+  object HealthCheckResult {
+    case object Ignored extends HealthCheckResult
+
+    final case class PortStatus(
+                                 availablePorts: VerifiedContainerConnectivity,
+                                 unavailablePorts: UnavailablePorts,
+                                 unverifiedPorts: Set[DockerPort],
+                                 requiredPortsAccessible: Boolean,
+                               ) extends HealthCheckResult
+  }
+
+  case class UnavailablePorts(unavailablePorts: Map[DockerPort, List[(AvailablePort, Option[Throwable])]])
+
+  case class VerifiedContainerConnectivity(availablePorts: Map[DockerPort, NonEmptyList[AvailablePort]]) {
+    override def toString: String = s"accessible=$availablePorts"
+  }
+
+  object VerifiedContainerConnectivity {
+    def empty: VerifiedContainerConnectivity = VerifiedContainerConnectivity(Map.empty)
+  }
+
+  def dontCheckPorts[T]: ContainerHealthCheck[T] = (_, _) => HealthCheckResult.Ignored
+
+  def checkTCPOnly[T]: ContainerHealthCheck[T] = new TCPContainerHealthCheck[T]
+}
diff --git a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/healthcheck/ContainerHealthCheckBase.scala b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/healthcheck/ContainerHealthCheckBase.scala
new file mode 100644
index 000000000..b74605038
--- /dev/null
+++ b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/healthcheck/ContainerHealthCheckBase.scala
@@ -0,0 +1,75 @@
+package izumi.distage.docker.healthcheck
+
+import izumi.distage.docker.Docker.{AvailablePort, _}
+import izumi.distage.docker.DockerContainer
+import izumi.distage.docker.healthcheck.ContainerHealthCheck.{HealthCheckResult, VerifiedContainerConnectivity}
+import izumi.distage.docker.healthcheck.ContainerHealthCheckBase.PortCandidate
+import izumi.fundamentals.collections.nonempty.NonEmptyList
+import izumi.logstage.api.IzLogger
+
+
+abstract class ContainerHealthCheckBase[Tag] extends ContainerHealthCheck[Tag] {
+  override final def check(logger: IzLogger, container: DockerContainer[Tag]): HealthCheckResult = {
+
+    val tcpPorts: Map[DockerPort.TCP, NonEmptyList[ServicePort]] =
+      container.connectivity.dockerPorts
+        .collect {
+          case (port: DockerPort.TCP, bindings) =>
+            (port, bindings)
+        }
+
+    val udpPorts: Map[DockerPort.UDP, NonEmptyList[ServicePort]] =
+      container.connectivity.dockerPorts
+        .collect {
+          case (port: DockerPort.UDP, bindings) =>
+            (port, bindings)
+        }
+
+    perform(logger, container, tcpPorts, udpPorts)
+
+  }
+
+  protected def perform(logger: IzLogger, container: DockerContainer[Tag], tcpPorts: Map[DockerPort.TCP, NonEmptyList[ServicePort]], udpPorts: Map[DockerPort.UDP, NonEmptyList[ServicePort]]): HealthCheckResult.PortStatus
+
+  protected def findContainerInternalCandidates[T <: DockerPort](container: DockerContainer[Tag], ports: Map[T, NonEmptyList[ServicePort]]): Seq[PortCandidate[T]] = {
+    ports.toSeq.flatMap {
+      case (mappedPort, _) =>
+        container.connectivity.containerAddressesV4.map {
+          internalContainerAddress =>
+            PortCandidate(mappedPort, AvailablePort(internalContainerAddress, mappedPort.number))
+        }
+    }
+  }
+
+  protected def findDockerHostCandidates[T <: DockerPort](container: DockerContainer[Tag], ports: Map[T, NonEmptyList[ServicePort]]): Seq[PortCandidate[T]] = {
+    ports.toSeq.flatMap {
+      case (mappedPort, internalBindings) =>
+        internalBindings
+          .toList
+          .flatMap {
+            ib =>
+              List(
+                PortCandidate(mappedPort, AvailablePort(container.connectivity.dockerHost.getOrElse("127.0.0.1"), ib.port)),
+                PortCandidate(mappedPort, AvailablePort(ib.listenOnV4, ib.port)),
+              )
+          }
+    }
+  }
+
+  protected def tcpPortsGood[T](container: DockerContainer[T], good: VerifiedContainerConnectivity): Boolean = {
+    val tcpPorts = container.containerConfig.ports.collect { case t: DockerPort.TCP => t: DockerPort }.toSet
+    tcpPorts.diff(good.availablePorts.keySet).isEmpty
+  }
+
+}
+
+object ContainerHealthCheckBase {
+
+
+  case class GoodPort(port: DockerPort, maybeAvailable: AvailablePort)
+
+  case class PortCandidate[+T <: DockerPort](port: T, maybeAvailable: AvailablePort)
+
+  case class FailedPort(port: DockerPort, candidate: PortCandidate[DockerPort], issue: Option[Throwable])
+
+}
\ No newline at end of file
diff --git a/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/healthcheck/TCPContainerHealthCheck.scala b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/healthcheck/TCPContainerHealthCheck.scala
new file mode 100644
index 000000000..a84224488
--- /dev/null
+++ b/distage/distage-framework-docker/src/main/scala/izumi/distage/docker/healthcheck/TCPContainerHealthCheck.scala
@@ -0,0 +1,73 @@
+package izumi.distage.docker.healthcheck
+
+import izumi.distage.docker.Docker._
+import izumi.distage.docker.DockerContainer
+import izumi.distage.docker.healthcheck.ContainerHealthCheck.{HealthCheckResult, UnavailablePorts, VerifiedContainerConnectivity}
+import izumi.distage.docker.healthcheck.ContainerHealthCheckBase.{FailedPort, GoodPort, PortCandidate}
+import izumi.functional.IzEither._
+import izumi.fundamentals.collections.IzCollections._
+import izumi.fundamentals.collections.nonempty.NonEmptyList
+import izumi.fundamentals.platform.integration.{PortCheck, ResourceCheck}
+import izumi.fundamentals.platform.strings.IzString._
+import izumi.logstage.api.IzLogger
+
+
+class TCPContainerHealthCheck[Tag] extends ContainerHealthCheckBase[Tag] {
+
+  override protected def perform(logger: IzLogger, container: DockerContainer[Tag], tcpPorts: Map[DockerPort.TCP, NonEmptyList[ServicePort]], udpPorts: Map[DockerPort.UDP, NonEmptyList[ServicePort]]): HealthCheckResult.PortStatus = {
+    val check = new PortCheck(container.containerConfig.portProbeTimeout.toMillis.toInt)
+
+    val dockerHostCandidates = findDockerHostCandidates(container, tcpPorts)
+    val containerCandidates = findContainerInternalCandidates(container, tcpPorts)
+
+    val allCandidates = (dockerHostCandidates ++ containerCandidates)
+      .distinct
+      .filterNot(_.maybeAvailable.hostV4 == "0.0.0.0")
+
+    logger.debug(s"going to check ports on $container: ${allCandidates.map { case PortCandidate(k, v) => s"if $k is available at $v" }.niceList() -> "port mappings"}")
+
+
+    val checks = allCandidates.map {
+      case PortCandidate(dp, ap) =>
+        check.checkPort(ap.hostV4, ap.port, s"open port ${ap.hostV4}:${ap.port} for ${container.id}") match {
+          case ResourceCheck.Success() =>
+            Right(GoodPort(dp, ap))
+          case ResourceCheck.ResourceUnavailable(_, cause) =>
+            Left(FailedPort(dp, PortCandidate(dp, ap), cause))
+        }
+    }
+
+    val (bad, good) = checks.lrPartition
+
+    val errors = UnavailablePorts(
+      bad
+        .map { case FailedPort(a, b, c) => (a, (b.maybeAvailable, c)) }
+        .toMultimap
+        .toSeq
+        .map {
+          case (dp, ap) =>
+            (dp: DockerPort, ap.toList)
+        }
+        .toMap)
+
+    val available = VerifiedContainerConnectivity(
+      good
+        .map { case GoodPort(a, b) => (a, b) }
+        .toMultimap
+        .toSeq
+        .map {
+          case (dp, ap) =>
+            (dp: DockerPort, NonEmptyList(ap.head, ap.tail.toList: _*))
+        }
+        .toMap
+    )
+
+    HealthCheckResult.PortStatus(
+      available,
+      errors,
+      udpPorts.keySet.map(p => p: DockerPort),
+      tcpPortsGood(container, available)
+    )
+  }
+}
+
diff --git a/fundamentals/fundamentals-collections/src/main/scala/izumi/fundamentals/collections/IzTraversables.scala b/fundamentals/fundamentals-collections/src/main/scala/izumi/fundamentals/collections/IzTraversables.scala
index 7e229499b..d6d35651c 100644
--- a/fundamentals/fundamentals-collections/src/main/scala/izumi/fundamentals/collections/IzTraversables.scala
+++ b/fundamentals/fundamentals-collections/src/main/scala/izumi/fundamentals/collections/IzTraversables.scala
@@ -3,6 +3,7 @@ package izumi.fundamentals.collections
 import  scala.collection.compat._
 
 final class IzTraversables[A](private val list: IterableOnce[A]) extends AnyVal {
+
   def maxOr(default: A)(implicit cmp: Ordering[A]): A = {
     val iterator = list.iterator
     if (iterator.nonEmpty) {
@@ -11,6 +12,7 @@ final class IzTraversables[A](private val list: IterableOnce[A]) extends AnyVal
       default
     }
   }
+
   def minOr(default: A)(implicit cmp: Ordering[A]): A = {
     val iterator = list.iterator
     if (iterator.nonEmpty) {
diff --git a/fundamentals/fundamentals-platform/src/main/scala/izumi/fundamentals/platform/integration/ResourceCheck.scala b/fundamentals/fundamentals-platform/src/main/scala/izumi/fundamentals/platform/integration/ResourceCheck.scala
index f1e12e76e..8f8d81365 100644
--- a/fundamentals/fundamentals-platform/src/main/scala/izumi/fundamentals/platform/integration/ResourceCheck.scala
+++ b/fundamentals/fundamentals-platform/src/main/scala/izumi/fundamentals/platform/integration/ResourceCheck.scala
@@ -23,10 +23,4 @@ object ResourceCheck {
     }
   }
 
-//  object ResourceUnavailable {
-//    def apply(description: String, cause: Option[Throwable]): ResourceUnavailable = {
-//      new ResourceUnavailable(description, cause)
-//    }
-//  }
-
 }
diff --git a/project/Versions.scala b/project/Versions.scala
index d12e54f13..0c9e92163 100644
--- a/project/Versions.scala
+++ b/project/Versions.scala
@@ -8,7 +8,7 @@ object V {
   val scalatest = "3.1.1"
 
   val cats = "2.1.1"
-  val cats_effect = "2.1.2"
+  val cats_effect = "2.1.3"
   val zio = "1.0.0-RC18-2"
   val zio_interop_cats = "2.0.0.0-RC12"
 
@@ -26,7 +26,7 @@ object V {
   val scalamock = "4.4.0"
 
   // java-only dependencies below
-  val classgraph = "4.8.69"
+  val classgraph = "4.8.70"
   val slf4j = "1.7.30"
   val typesafe_config = "1.4.0"
 
diff --git a/version.sbt b/version.sbt
index a1cd5f148..7cb7b14c2 100644
--- a/version.sbt
+++ b/version.sbt
@@ -1,1 +1,1 @@
-version in ThisBuild := "0.10.3-SNAPSHOT"
+version in ThisBuild := "0.10.4-SNAPSHOT"


Test patch:
diff --git a/distage/distage-framework-docker/src/test/scala/izumi/distage/testkit/docker/DockerContainerProviderTest.scala b/distage/distage-framework-docker/src/test/scala/izumi/distage/testkit/docker/DockerContainerProviderTest.scala
index 58c7488a9..17270fc74 100644
--- a/distage/distage-framework-docker/src/test/scala/izumi/distage/testkit/docker/DockerContainerProviderTest.scala
+++ b/distage/distage-framework-docker/src/test/scala/izumi/distage/testkit/docker/DockerContainerProviderTest.scala
@@ -1,7 +1,7 @@
 package izumi.distage.testkit.docker
 
 import distage.SafeType
-import izumi.distage.docker.DockerContainer.ContainerResource
+import izumi.distage.docker.ContainerResource
 import izumi.distage.docker.examples.PostgresDocker
 import izumi.fundamentals.platform.functional.Identity
 import org.scalatest.wordspec.AnyWordSpec
diff --git a/distage/distage-framework-docker/src/test/scala/izumi/distage/testkit/docker/fixtures/DockerPlugin.scala b/distage/distage-framework-docker/src/test/scala/izumi/distage/testkit/docker/fixtures/DockerPlugin.scala
index cad54024a..14c9b38b9 100644
--- a/distage/distage-framework-docker/src/test/scala/izumi/distage/testkit/docker/fixtures/DockerPlugin.scala
+++ b/distage/distage-framework-docker/src/test/scala/izumi/distage/testkit/docker/fixtures/DockerPlugin.scala
@@ -36,12 +36,12 @@ object DockerPlugin extends DockerContainerModule[Task] with PluginDef {
 
   make[AvailablePort].named("cs").tagged(Env.Test).from {
     cs: CassandraDocker.Container =>
-      cs.availablePorts(CassandraDocker.primaryPort).head
+      cs.availablePorts.availablePorts(CassandraDocker.primaryPort).head
   }
 
   make[AvailablePort].named("kafka").tagged(Env.Test).from {
     kafka: KafkaDocker.Container =>
-      kafka.availablePorts(KafkaDocker.primaryPort).head
+      kafka.availablePorts.availablePorts(KafkaDocker.primaryPort).head
   }
 
   // this container will start once `DynamoContainer` is up and running
@@ -52,11 +52,11 @@ object DockerPlugin extends DockerContainerModule[Task] with PluginDef {
   // these lines are for test scope
   make[AvailablePort].named("pg").tagged(Env.Test).from {
     pg: PostgresDocker.Container =>
-      pg.availablePorts(PostgresDocker.primaryPort).head
+      pg.availablePorts.availablePorts(PostgresDocker.primaryPort).head
   }
   make[AvailablePort].named("ddb").tagged(Env.Test).from {
     dn: DynamoDocker.Container =>
-      dn.availablePorts(DynamoDocker.primaryPort).head
+      dn.availablePorts.availablePorts(DynamoDocker.primaryPort).head
   }
 
   // and this one is for production