Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve multi platform build #139

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
87 changes: 61 additions & 26 deletions src/main/scala/sbtdocker/DockerBuild.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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) = {
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/main/scala/sbtdocker/DockerKeys.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
83 changes: 83 additions & 0 deletions src/main/scala/sbtdocker/DockerMultiPlatformBuilder.scala
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions src/main/scala/sbtdocker/DockerPlugin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
36 changes: 18 additions & 18 deletions src/main/scala/sbtdocker/DockerSettings.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +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 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)
},
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 {
Expand Down Expand Up @@ -52,6 +51,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
)

Expand All @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion src/main/scala/sbtdocker/DockerTag.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ 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)
val processOutput = Process(command).lineStream(processLogger)
processOutput.foreach { line =>
log.info(line)
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/scala/sbtdocker/Instructions.scala
Original file line number Diff line number Diff line change
@@ -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
Expand Down
54 changes: 54 additions & 0 deletions src/main/scala/sbtdocker/ProcessRunner.scala
Original file line number Diff line number Diff line change
@@ -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

}
}
7 changes: 7 additions & 0 deletions src/main/scala/sbtdocker/models.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading