From 6fafbb1b2cddff7aefa519f88430133cc55ab17b Mon Sep 17 00:00:00 2001 From: Eder Ruiz Date: Tue, 1 Oct 2024 11:37:53 -0300 Subject: [PATCH 1/5] Add debug logging --- src/main/scala/sbtdocker/DockerTag.scala | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/scala/sbtdocker/DockerTag.scala b/src/main/scala/sbtdocker/DockerTag.scala index 6a70276..8f8cb0b 100644 --- a/src/main/scala/sbtdocker/DockerTag.scala +++ b/src/main/scala/sbtdocker/DockerTag.scala @@ -19,6 +19,7 @@ object DockerTag { log.info(s"Tagging image $id with name: $name") val command = dockerPath :: "tag" :: id.id :: name.toString :: Nil + log.debug(s"Running command: '${command.mkString(" ")}'") val processOutput = Process(command).lines(processLogger) processOutput.foreach { line => From 2cb06a4b1d7096856c25ba783bc89ccbac393994 Mon Sep 17 00:00:00 2001 From: Eder Ruiz Date: Thu, 3 Oct 2024 10:36:58 -0300 Subject: [PATCH 2/5] Fix & remove deprecation warnings --- build.sbt | 4 ++-- src/main/scala/sbtdocker/DockerSettings.scala | 2 +- src/main/scala/sbtdocker/DockerTag.scala | 2 +- src/main/scala/sbtdocker/Instructions.scala | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/build.sbt b/build.sbt index a8510d0..f1abc1d 100644 --- a/build.sbt +++ b/build.sbt @@ -12,8 +12,8 @@ lazy val root = (project in file(".")) ) libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % "3.2.11" % "test", - "org.apache.commons" % "commons-lang3" % "3.12.0" + "org.scalatest" %% "scalatest" % "3.2.19" % "test", + "org.apache.commons" % "commons-text" % "1.12.0" ) scalacOptions := Seq("-deprecation", "-unchecked", "-feature") diff --git a/src/main/scala/sbtdocker/DockerSettings.scala b/src/main/scala/sbtdocker/DockerSettings.scala index 2bcd7fc..3d3e374 100644 --- a/src/main/scala/sbtdocker/DockerSettings.scala +++ b/src/main/scala/sbtdocker/DockerSettings.scala @@ -68,7 +68,7 @@ object DockerSettings { (docker / Keys.mainClass).or(Compile / Keys.packageBin / Keys.mainClass).value }, docker / dockerfile := { - val maybeMainClass = Keys.mainClass.in(docker).value + val maybeMainClass = (docker / Keys.mainClass).value maybeMainClass match { case None => sys.error( diff --git a/src/main/scala/sbtdocker/DockerTag.scala b/src/main/scala/sbtdocker/DockerTag.scala index 8f8cb0b..4972ac3 100644 --- a/src/main/scala/sbtdocker/DockerTag.scala +++ b/src/main/scala/sbtdocker/DockerTag.scala @@ -21,7 +21,7 @@ object DockerTag { val command = dockerPath :: "tag" :: id.id :: name.toString :: Nil log.debug(s"Running command: '${command.mkString(" ")}'") - val processOutput = Process(command).lines(processLogger) + val processOutput = Process(command).lineStream(processLogger) processOutput.foreach { line => log.info(line) } diff --git a/src/main/scala/sbtdocker/Instructions.scala b/src/main/scala/sbtdocker/Instructions.scala index 23ab0cc..e1a0d57 100644 --- a/src/main/scala/sbtdocker/Instructions.scala +++ b/src/main/scala/sbtdocker/Instructions.scala @@ -1,6 +1,6 @@ package sbtdocker -import org.apache.commons.lang3.StringEscapeUtils +import org.apache.commons.text.StringEscapeUtils import sbtdocker.staging.SourceFile import scala.concurrent.duration.FiniteDuration From 78cfe345e3b81dfbf31fee2859e34dc2bd5d407b Mon Sep 17 00:00:00 2001 From: Eder Ruiz Date: Thu, 3 Oct 2024 10:54:26 -0300 Subject: [PATCH 3/5] Introduce DockerMultiPlatformBuilder --- src/main/scala/sbtdocker/DockerBuild.scala | 87 +++++++++++++------ src/main/scala/sbtdocker/DockerKeys.scala | 1 + .../DockerMultiPlatformBuilder.scala | 83 ++++++++++++++++++ src/main/scala/sbtdocker/DockerPlugin.scala | 3 + src/main/scala/sbtdocker/DockerSettings.scala | 4 +- src/main/scala/sbtdocker/ProcessRunner.scala | 54 ++++++++++++ src/main/scala/sbtdocker/models.scala | 7 ++ .../scala/sbtdocker/DockerBuildSpec.scala | 52 +++++++++++ 8 files changed, 264 insertions(+), 27 deletions(-) create mode 100644 src/main/scala/sbtdocker/DockerMultiPlatformBuilder.scala create mode 100644 src/main/scala/sbtdocker/ProcessRunner.scala diff --git a/src/main/scala/sbtdocker/DockerBuild.scala b/src/main/scala/sbtdocker/DockerBuild.scala index 500322e..1b87c72 100644 --- a/src/main/scala/sbtdocker/DockerBuild.scala +++ b/src/main/scala/sbtdocker/DockerBuild.scala @@ -23,49 +23,65 @@ object DockerBuild { processor: DockerfileProcessor, imageNames: Seq[ImageName], buildOptions: BuildOptions, + platforms: Set[Platform], buildArguments: Map[String, String], stageDir: File, dockerPath: String, log: Logger ): ImageId = { - dockerfile match { - case NativeDockerfile(path) => - buildAndTag(imageNames, path, dockerPath, buildOptions, buildArguments, log) - case dockerfileLike: DockerfileLike => - val staged = processor(dockerfileLike, stageDir) + (dockerfile, platforms.isEmpty) match { - apply(staged, imageNames, buildOptions, buildArguments, stageDir, dockerPath, log) + case (NativeDockerfile(dockerfilePath), true) => + buildAndTag( + imageNames = imageNames, + dockerfilePath = dockerfilePath, + dockerPath = dockerPath, + buildOptions = buildOptions, + buildArguments = buildArguments, + log = log + ) + case (dockerfileLike: DockerfileLike, true) => + buildAndTag( + imageNames = imageNames, + dockerfilePath = buildDockerFile(processor, dockerfileLike, stageDir, log), + dockerPath = dockerPath, + buildOptions = buildOptions, + buildArguments = buildArguments, + log = log + ) + case (NativeDockerfile(dockerfilePath), false) => + multiPlatformBuild( + dockerPath = dockerPath, + dockerfilePath = dockerfilePath, + platforms = platforms, + imageNames = imageNames, + log = log + ) + case (dockerfileLike: DockerfileLike, false) => + multiPlatformBuild( + dockerPath = dockerPath, + dockerfilePath = buildDockerFile(processor, dockerfileLike, stageDir, log), + platforms = platforms, + imageNames = imageNames, + log = log + ) } } - /** - * Build a Dockerfile using a provided docker binary. - * - * @param staged a staged Dockerfile to build. - * @param imageNames names of the resulting image - * @param stageDir stage dir - * @param dockerPath path to the docker binary - * @param buildOptions options for the build command - * @param log logger - */ - def apply( - staged: StagedDockerfile, - imageNames: Seq[ImageName], - buildOptions: BuildOptions, - buildArguments: Map[String, String], + private[sbtdocker] def buildDockerFile( + processor: DockerfileProcessor, + dockerfileLike: DockerfileLike, stageDir: File, - dockerPath: String, log: Logger - ): ImageId = { + ): File = { + val staged = processor(dockerfileLike, stageDir) log.debug("Building Dockerfile:\n" + staged.instructionsString) - log.debug(s"Preparing stage directory '${stageDir.getPath}'") - clean(stageDir) val dockerfilePath = createDockerfile(staged, stageDir) prepareFiles(staged) - buildAndTag(imageNames, dockerfilePath, dockerPath, buildOptions, buildArguments, log) + dockerfilePath } private[sbtdocker] def clean(stageDir: File) = { @@ -102,6 +118,25 @@ object DockerBuild { imageId } + private[sbtdocker] def multiPlatformBuild( + dockerPath: String, + dockerfilePath: File, + platforms: Set[Platform], + imageNames: Seq[ImageName], + log: Logger + ): ImageId = { + + val builder = new DockerMultiPlatformBuilder( + dockerPath, + dockerfilePath, + platforms, + imageNames, + log + ) + + builder.run() + } + private[sbtdocker] def build( dockerfilePath: File, dockerPath: String, diff --git a/src/main/scala/sbtdocker/DockerKeys.scala b/src/main/scala/sbtdocker/DockerKeys.scala index fee08b6..bd9106e 100644 --- a/src/main/scala/sbtdocker/DockerKeys.scala +++ b/src/main/scala/sbtdocker/DockerKeys.scala @@ -14,6 +14,7 @@ object DockerKeys { val imageNames = taskKey[Seq[ImageName]]("Names of the built image.") val dockerPath = settingKey[String]("Path to the Docker binary.") val buildOptions = settingKey[BuildOptions]("Options for the Docker build command.") + val platforms = settingKey[Set[Platform]]("Platform list for multi platform build.") val dockerBuildArguments = settingKey[Map[String, String]]( "Set build-time arguments for Docker image. Reference the argument keys with ARG and ENV instructions in the Dockerfile." diff --git a/src/main/scala/sbtdocker/DockerMultiPlatformBuilder.scala b/src/main/scala/sbtdocker/DockerMultiPlatformBuilder.scala new file mode 100644 index 0000000..9dfd36c --- /dev/null +++ b/src/main/scala/sbtdocker/DockerMultiPlatformBuilder.scala @@ -0,0 +1,83 @@ +package sbtdocker + +import sbt.* +import scala.sys.process.Process +import scala.sys.process + +import sbtdocker.ProcessRunner.* + +class DockerMultiPlatformBuilder( + dockerPath: String, + dockerfilePath: File, + platforms: Set[Platform], + imageNames: Seq[ImageName], + log: Logger +) { + import DockerMultiPlatformBuilder.* + + private def buildxProcess(args: String): process.ProcessBuilder = { + val command = s"$dockerPath buildx $args" + log.debug(s"command: '$command'") + Process(command) + } + + private val paramPlatforms = s"--platform=${platforms.map(_.value).mkString(",")}" + private val paramFile = s"--file ${dockerfilePath.getAbsoluteFile.getPath}" + private val paramsTags = imageNames.map(_.toString).map(str => s"--tag $str").mkString(" ") + private val argContext = s"${dockerfilePath.getAbsoluteFile.getParentFile.getPath}" + + private def createDockerBuilder() = buildxProcess { + s"create --use $paramPlatforms --name $DOCKER_BUILDER_NAME" + } + + private def inspectDockerBuilder() = buildxProcess { + s"inspect --bootstrap" + } + + private def buildDockerImage() = buildxProcess { + s"build --push --progress=plain $paramPlatforms $paramFile $paramsTags $argContext" + } + + private def removeDockerBuilder() = buildxProcess { + s"rm $DOCKER_BUILDER_NAME" + } + + def run(): ImageId = { + + ProcessRunner.run(removeDockerBuilder()) + + val script: process.ProcessBuilder = + createDockerBuilder() #&& + inspectDockerBuilder() #&& + buildDockerImage() #&& + removeDockerBuilder() + + ProcessRunner.run(script, log) match { + case (0, logLines) => + parseImageId(logLines) match { + case Some(imageId) => + log.info(s"ImageId: ${imageId.id}") + imageId + case None => throw new DockerMultiPlatformBuildException(s"Parse imageId failed") + } + case (n, _) => + throw new DockerMultiPlatformBuildException(s"Build failed, exitCode: $n") + } + + } + +} + +object DockerMultiPlatformBuilder { + private val DOCKER_BUILDER_NAME = "sbtdocker-builder" + + private val imageIdRegex = ".* exporting manifest list sha256:([0-9a-f]{64}).*\\bdone$".r + + def parseImageId(logLines: List[LogLine]): Option[ImageId] = + parseImageId(logLines.map(_.line)) + + def parseImageId(logLines: Seq[String]): Option[ImageId] = + logLines.collect { case imageIdRegex(id) => ImageId(id) }.lastOption +} + +class DockerMultiPlatformBuildException(message: String) extends RuntimeException(message) diff --git a/src/main/scala/sbtdocker/DockerPlugin.scala b/src/main/scala/sbtdocker/DockerPlugin.scala index 9574409..e83214d 100644 --- a/src/main/scala/sbtdocker/DockerPlugin.scala +++ b/src/main/scala/sbtdocker/DockerPlugin.scala @@ -15,6 +15,7 @@ object DockerPlugin extends AutoPlugin { val imageName = DockerKeys.imageName val imageNames = DockerKeys.imageNames val buildOptions = DockerKeys.buildOptions + val platforms = DockerKeys.platforms val dockerBuildArguments = DockerKeys.dockerBuildArguments type Dockerfile = sbtdocker.Dockerfile @@ -26,6 +27,8 @@ object DockerPlugin extends AutoPlugin { type ImageName = sbtdocker.ImageName val BuildOptions = sbtdocker.BuildOptions type BuildOptions = sbtdocker.BuildOptions + val Platfrom = sbtdocker.Platform + type Platform = sbtdocker.Platform val CopyFile = sbtdocker.staging.CopyFile type CopyFile = sbtdocker.staging.CopyFile diff --git a/src/main/scala/sbtdocker/DockerSettings.scala b/src/main/scala/sbtdocker/DockerSettings.scala index 3d3e374..c2d0e7e 100644 --- a/src/main/scala/sbtdocker/DockerSettings.scala +++ b/src/main/scala/sbtdocker/DockerSettings.scala @@ -12,11 +12,12 @@ object DockerSettings { val log = Keys.streams.value.log val dockerPath = (docker / DockerKeys.dockerPath).value val buildOptions = (docker / DockerKeys.buildOptions).value + val platforms = (docker / DockerKeys.platforms).value val stageDir = (docker / target).value val dockerfile = (docker / DockerKeys.dockerfile).value val imageNames = (docker / DockerKeys.imageNames).value val buildArguments = (docker / DockerKeys.dockerBuildArguments).value - DockerBuild(dockerfile, DefaultDockerfileProcessor, imageNames, buildOptions, buildArguments, stageDir, dockerPath, log) + DockerBuild(dockerfile, DefaultDockerfileProcessor, imageNames, buildOptions, platforms, buildArguments, stageDir, dockerPath, log) }, dockerPush := { val log = Keys.streams.value.log @@ -52,6 +53,7 @@ object DockerSettings { }, docker / dockerPath := sys.env.get("DOCKER").filter(_.nonEmpty).getOrElse("docker"), docker / buildOptions := BuildOptions(), + docker / platforms := Set.empty[Platform], docker / dockerBuildArguments := Map.empty ) diff --git a/src/main/scala/sbtdocker/ProcessRunner.scala b/src/main/scala/sbtdocker/ProcessRunner.scala new file mode 100644 index 0000000..8e3022a --- /dev/null +++ b/src/main/scala/sbtdocker/ProcessRunner.scala @@ -0,0 +1,54 @@ +package sbtdocker + +import sbt.Logger +import scala.sys.process +import scala.sys.process.ProcessLogger + +object ProcessRunner { + + sealed trait LogLine extends Product with Serializable { + def line: String + } + + object LogLine { + + final case class Info(line: String) extends LogLine { + override def toString: String = s"info | $line" + } + + final case class Err(line: String) extends LogLine { + override def toString: String = s"error | $line" + } + + def info(line: String): LogLine = Info(line) + def err(line: String): LogLine = Err(line) + } + + def run(processBuilder: process.ProcessBuilder, log: Logger): (Int, List[LogLine]) = { + val logLineBuilder = List.newBuilder[LogLine] + val processLogger: ProcessLogger = ProcessLogger( + { line => + logLineBuilder += LogLine.info(line) + log.info(line) + }, + { line => + logLineBuilder += LogLine.err(line) + log.err(line) + } + ) + + val ret = processBuilder ! processLogger + val logLines = logLineBuilder.result() + (ret, logLines) + } + + def run(processBuilder: process.ProcessBuilder): Int = { + val processLogger: ProcessLogger = ProcessLogger( + { _ => () }, + { _ => () } + ) + + processBuilder ! processLogger + + } +} diff --git a/src/main/scala/sbtdocker/models.scala b/src/main/scala/sbtdocker/models.scala index 8998b1e..1e16603 100644 --- a/src/main/scala/sbtdocker/models.scala +++ b/src/main/scala/sbtdocker/models.scala @@ -42,6 +42,13 @@ final case class BuildOptions( additionalArguments: Seq[String] = Seq.empty ) +sealed abstract class Platform(val value: String) {} + +object Platform { + final case object LinuxAmd64 extends Platform("linux/amd64") + final case object LinuxArm64 extends Platform("linux/arm64") +} + /** * Id of an Docker image. * @param id Id as a hexadecimal digit string. diff --git a/src/test/scala/sbtdocker/DockerBuildSpec.scala b/src/test/scala/sbtdocker/DockerBuildSpec.scala index c9b7389..98ca731 100644 --- a/src/test/scala/sbtdocker/DockerBuildSpec.scala +++ b/src/test/scala/sbtdocker/DockerBuildSpec.scala @@ -137,6 +137,58 @@ class DockerBuildSpec extends AnyFreeSpec with Matchers { } + "Docker buildx output with multi platform build" in { + val lines = Seq( + "#1 [internal] load build definition from Dockerfile", + "#1 transferring dockerfile: 76B done", + "#1 DONE 0.0s", + "#2 [linux/amd64 internal] load metadata for docker.io/library/eclipse-temurin:17-jre", + "#2 ...", + "#3 [auth] library/eclipse-temurin:pull token for registry-1.docker.io", + "#3 DONE 0.0s", + "#4 [linux/arm64 internal] load metadata for docker.io/library/eclipse-temurin:17-jre", + "#4 ...", + "#2 [linux/amd64 internal] load metadata for docker.io/library/eclipse-temurin:17-jre", + "#2 DONE 3.3s", + "#5 [internal] load .dockerignore", + "#5 transferring context: 2B done", + "#5 DONE 0.0s", + "#4 [linux/arm64 internal] load metadata for docker.io/library/eclipse-temurin:17-jre", + "#4 DONE 3.4s", + "#6 [linux/arm64 1/1] FROM docker.io/library/eclipse-temurin:17-jre@sha256:5bc826c8e0e248515161ebdb3cc7ea3f50a09d5570155493f7a933bcb5f7a644", + "#6 resolve docker.io/library/eclipse-temurin:17-jre@sha256:5bc826c8e0e248515161ebdb3cc7ea3f50a09d5570155493f7a933bcb5f7a644 done", + "#6 DONE 0.0s", + "#7 [linux/amd64 1/1] FROM docker.io/library/eclipse-temurin:17-jre@sha256:5bc826c8e0e248515161ebdb3cc7ea3f50a09d5570155493f7a933bcb5f7a644", + "#7 resolve docker.io/library/eclipse-temurin:17-jre@sha256:5bc826c8e0e248515161ebdb3cc7ea3f50a09d5570155493f7a933bcb5f7a644 done", + "#7 DONE 0.0s", + "#8 exporting to image", + "#8 exporting layers done", + "#8 exporting manifest sha256:415511a6b7d9c469b3d55f2a1a731ca825a150b761d6d3a8d693f6933b73543e done", + "#8 exporting config sha256:b8b30c8f60e3022c3796910d2f6766556241f06b376a1452e1008aad90eb1a56 done", + "#8 exporting attestation manifest sha256:00307e30e3b303797f6ec731ce97ca6193e01538e1bf2e385e44ce9d7e320e96 done", + "#8 exporting manifest sha256:7ae21b1e3165c5607bf526507e27c6b2f96370e1e5d2af30e34dadf91a0f2fb6", + "#8 exporting manifest sha256:7ae21b1e3165c5607bf526507e27c6b2f96370e1e5d2af30e34dadf91a0f2fb6 done", + "#8 exporting config sha256:c321bebec67be335397d1cef5215319e6c39c180aa45ad35220a2914e97ad569 done", + "#8 exporting attestation manifest sha256:e7b403b53ce05cb79be85e78042607b589a3abbd5885d116bef8dac120f4824f done", + "#8 exporting manifest list sha256:e715e540a6e109611077f9577010c953bd8316bd58286fe4fbec2c9f0b034fc1 done", + "#8 pushing layers", + "#8 ...", + "#9 [auth] test:pull,push token for test.com", + "#9 DONE 0.0s", + "#8 exporting to image", + "#8 pushing layers 6.8s done", + "#8 pushing manifest for test.com/test:latest@sha256:e715e540a6e109611077f9577010c953bd8316bd58286fe4fbec2c9f0b034fc1", + "#8 pushing manifest for test.com/test:latest@sha256:e715e540a6e109611077f9577010c953bd8316bd58286fe4fbec2c9f0b034fc1 1.9s done", + "#8 pushing layers 0.7s done", + "#8 pushing manifest for test.com/test:v1.0.0@sha256:e715e540a6e109611077f9577010c953bd8316bd58286fe4fbec2c9f0b034fc1", + "#8 pushing manifest for test.com/test:v1.0.0@sha256:e715e540a6e109611077f9577010c953bd8316bd58286fe4fbec2c9f0b034fc1 1.3s done", + "#8 DONE 10.7s" + ) + DockerMultiPlatformBuilder.parseImageId(lines) shouldEqual Some( + ImageId("e715e540a6e109611077f9577010c953bd8316bd58286fe4fbec2c9f0b034fc1") + ) + } + "Docker build output version 20.10.10" in { val lines = Seq( "#6 exporting to image", From 604fbc25fbac4976dc13053d95dd4b7da998cafb Mon Sep 17 00:00:00 2001 From: Eder Ruiz Date: Thu, 3 Oct 2024 11:36:37 -0300 Subject: [PATCH 4/5] Improve style --- src/main/scala/sbtdocker/DockerSettings.scala | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/main/scala/sbtdocker/DockerSettings.scala b/src/main/scala/sbtdocker/DockerSettings.scala index c2d0e7e..aa30c79 100644 --- a/src/main/scala/sbtdocker/DockerSettings.scala +++ b/src/main/scala/sbtdocker/DockerSettings.scala @@ -8,24 +8,22 @@ import sbtdocker.staging.DefaultDockerfileProcessor object DockerSettings { lazy val baseDockerSettings = Seq( - docker := { - val log = Keys.streams.value.log - val dockerPath = (docker / DockerKeys.dockerPath).value - val buildOptions = (docker / DockerKeys.buildOptions).value - val platforms = (docker / DockerKeys.platforms).value - val stageDir = (docker / target).value - val dockerfile = (docker / DockerKeys.dockerfile).value - val imageNames = (docker / DockerKeys.imageNames).value - val buildArguments = (docker / DockerKeys.dockerBuildArguments).value - DockerBuild(dockerfile, DefaultDockerfileProcessor, imageNames, buildOptions, platforms, buildArguments, stageDir, dockerPath, log) - }, - dockerPush := { - val log = Keys.streams.value.log - val dockerPath = (docker / DockerKeys.dockerPath).value - val imageNames = (docker / DockerKeys.imageNames).value - - DockerPush(dockerPath, imageNames, log) - }, + docker := DockerBuild( + dockerfile = (docker / DockerKeys.dockerfile).value, + processor = DefaultDockerfileProcessor, + imageNames = (docker / DockerKeys.imageNames).value, + buildOptions = (docker / DockerKeys.buildOptions).value, + platforms = (docker / DockerKeys.platforms).value, + buildArguments = (docker / DockerKeys.dockerBuildArguments).value, + stageDir = (docker / target).value, + dockerPath = (docker / DockerKeys.dockerPath).value, + log = Keys.streams.value.log + ), + dockerPush := DockerPush( + dockerPath = (docker / DockerKeys.dockerPath).value, + imageNames = (docker / DockerKeys.imageNames).value, + log = Keys.streams.value.log + ), dockerBuildAndPush := Def.taskDyn { docker.value Def.task { From b98c4e0494b771cead32ae84649ec44f3fcc2dbb Mon Sep 17 00:00:00 2001 From: Eder Ruiz Date: Thu, 3 Oct 2024 21:58:50 -0300 Subject: [PATCH 5/5] Fix README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1ea026f..e60a280 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Setup Add sbt-docker as a dependency in `project/plugins.sbt`: ```text -addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.9.0") +addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.11.0") ``` ### Getting started