Skip to content

Commit

Permalink
implement dockerPermissionStrategy
Browse files Browse the repository at this point in the history
Fixes sbt#1189

This implements a non-root Docker container that's safer by default and compatible with Red Hat OpenShift.
Current `ADD --chown=daemon:daemon opt /opt` nominally implements non-root image,
but by giving ownership of the working directory to the `daemon` user, it reduces the safety.
Instead we should keep the file ownership by Docker's `root`, and use `chmod` to grant access to `daemon`.

The challenge is calling `chmod` without incurring the fs layer overhead (sbt#883).
[Multi-stage builds](https://docs.docker.com/develop/develop-images/multistage-build/) can be used to pre-stage
the files with desired file permissions.

This adds new `dockerPermissionStrategy` setting which decides how file permissions are set for the working directory inside the Docker image generated by sbt-native-packager. The strategies are:

- `DockerPermissionStrategy.MultiStage` (default): uses multi-stage Docker build to call chmod ahead of time.
- `DockerPermissionStrategy.None`: does not attempt to change the file permissions, and use the host machine's file mode bits.
- `DockerPermissionStrategy.Run`: calls `RUN` in the Dockerfile. This has regression on the resulting Docker image file size.
- `DockerPermissionStrategy.CopyChown`: calls `COPY --chown` in the Dockerfile. Provided as a backward compatibility.

For `MultiStage` and `Run` strategies, `dockerChmodType` is used in addition to call `chmod` during Docker build.

- `DockerChmodType.UserGroupReadExecute`: chmod -R u=rX,g=rX
- `DockerChmodType.UserGroupWriteExecute`: chmod -R u=rwX,g=rwX
- `DockerChmodType.SyncGroupToUser`: chmod -R g=u
- `DockerChmodType.Custom`: Custom argument provided by the user.

Here's an example to change these settings:

```scala
import com.typesafe.sbt.packager.docker._

dockerPermissionStrategy := DockerPermissionStrategy.Run
dockerChmodType          := DockerChmodType.UserGroupWriteExecute
```

During `docker:stage`, Docker package validation is called to check if the selected strategy is compatible with the deteted Docker version.
This fixes the current repeatability issue reported as sbt#1187. If the incompatibility is detected, the user is advised to
either upgrade their Docker, pick another strategy, or override the `dockerVersion` setting.

`daemonGroup` is set to `root` instead of copying the value from the `daemonUser` setting.
This matches the semantics of `USER` as well as OpenShift, which uses gid=0.
  • Loading branch information
eed3si9n committed Jan 18, 2019
1 parent 80f85c5 commit 5280c1c
Show file tree
Hide file tree
Showing 13 changed files with 371 additions and 23 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.typesafe.sbt.packager.docker

/**
* This represents a strategy to change the file permissions.
*/
sealed trait DockerPermissionStrategy
object DockerPermissionStrategy {
/**
* `None` does not attempt to change the file permissions.
* This will inherit the host machine's group bits.
*/
case object None extends DockerPermissionStrategy

/**
* `Run` calls `RUN` in the `Dockerfile`.
* This could double the size of the resulting Docker image
* because of the extra layer it creates.
*/
case object Run extends DockerPermissionStrategy

/**
* `MultiStage` uses multi-stage Docker build to change
* the file permissions.
* https://docs.docker.com/develop/develop-images/multistage-build/
*/
case object MultiStage extends DockerPermissionStrategy

/**
* `CopyChown` calls `COPY --chown` in the `Dockerfile`.
* This option is provided for backward compatibility.
* This will inherit the host machine's file mode.
* Note that this option is not compatible with OpenShift which ignores
* USER command and uses an arbitrary user to run the container.
*/
case object CopyChown extends DockerPermissionStrategy
}


/**
* This represents a type of file permission changes to run on the working directory.
* Note that group file mode bits must be effective to be OpenShift compatible.
*/
sealed trait DockerChmodType {
def argument: String
}
object DockerChmodType {
/**
* Gives read permission to users and groups.
* Gives execute permission to users and groups, if +x flag is on for any.
*/
case object UserGroupReadExecute extends DockerChmodType {
def argument: String = "u=rX,g=rX"
}

/**
* Gives read and write permissions to users and groups.
* Gives execute permission to users and groups, if +x flag is on for any.
*/
case object UserGroupWriteExecute extends DockerChmodType {
def argument: String = "u=rwX,g=rwX"
}

/**
* Copies user file mode bits to group file mode bits.
*/
case object SyncGroupToUser extends DockerChmodType {
def argument: String = "g=u"
}

/**
* Use custom argument.
*/
case class Custom(argument: String) extends DockerChmodType
}
167 changes: 144 additions & 23 deletions src/main/scala/com/typesafe/sbt/packager/docker/DockerPlugin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ object DockerPlugin extends AutoPlugin {
import autoImport._

/**
* The separator used by makeAdd should be always forced to UNIX separator.
* The separator used by makeCopy should be always forced to UNIX separator.
* The separator doesn't depend on the OS where Dockerfile is being built.
*/
val UnixSeparatorChar = '/'
Expand All @@ -66,6 +66,11 @@ object DockerPlugin extends AutoPlugin {

override def projectConfigurations: Seq[Configuration] = Seq(Docker)

override lazy val globalSettings: Seq[Setting[_]] = Seq(
dockerPermissionStrategy := DockerPermissionStrategy.MultiStage,
dockerChmodType := DockerChmodType.UserGroupReadExecute
)

override lazy val projectSettings: Seq[Setting[_]] = Seq(
dockerBaseImage := "openjdk:8",
dockerExposedPorts := Seq(),
Expand Down Expand Up @@ -102,19 +107,52 @@ object DockerPlugin extends AutoPlugin {
dockerRmiCommand := dockerExecCommand.value ++ Seq("rmi"),
dockerBuildCommand := dockerExecCommand.value ++ Seq("build") ++ dockerBuildOptions.value ++ Seq("."),
dockerCommands := {
val strategy = dockerPermissionStrategy.value
val dockerBaseDirectory = (defaultLinuxInstallLocation in Docker).value
val user = (daemonUser in Docker).value
val group = (daemonGroup in Docker).value
val base = dockerBaseImage.value
val uid = 1001
val gid = 0

val generalCommands = makeFrom(base) +: makeMaintainer((maintainer in Docker).value).toSeq
val stage0name = "stage0"
val stage0: Seq[CmdLike] = strategy match {
case DockerPermissionStrategy.MultiStage =>
Seq(
makeFromAs(base, stage0name),
makeWorkdir(dockerBaseDirectory),
makeUserAdd(user, uid, gid),
makeCopy(dockerBaseDirectory),
makeChmod(dockerChmodType.value, Seq(dockerBaseDirectory)),
DockerStageBreak)
case _ => Seq()
}

val generalCommands = makeFrom(dockerBaseImage.value) +: makeMaintainer((maintainer in Docker).value).toSeq

generalCommands ++
Seq(makeWorkdir(dockerBaseDirectory)) ++ makeAdd(dockerVersion.value, dockerBaseDirectory, user, group) ++
val stage1: Seq[CmdLike] = generalCommands ++
Seq(
makeUserAdd(user, uid, gid),
makeWorkdir(dockerBaseDirectory)) ++
(strategy match {
case DockerPermissionStrategy.MultiStage =>
Seq(makeCopyFrom(dockerBaseDirectory, stage0name, user, group))
case DockerPermissionStrategy.Run =>
Seq(makeCopy(dockerBaseDirectory), makeChmod(dockerChmodType.value, Seq(dockerBaseDirectory)))
case DockerPermissionStrategy.CopyChown =>
Seq(makeCopyChown(dockerBaseDirectory, user, group))
case DockerPermissionStrategy.None =>
Seq(makeCopy(dockerBaseDirectory))
}) ++
dockerLabels.value.map(makeLabel) ++
dockerEnvVars.value.map(makeEnvVar) ++
makeExposePorts(dockerExposedPorts.value, dockerExposedUdpPorts.value) ++
makeVolumes(dockerExposedVolumes.value, user, group) ++
Seq(makeUser(user), makeEntrypoint(dockerEntrypoint.value), makeCmd(dockerCmd.value))
Seq(
makeUser(uid),
makeEntrypoint(dockerEntrypoint.value),
makeCmd(dockerCmd.value))

stage0 ++ stage1
}
) ++ mapGenericFilesToDocker ++ inConfig(Docker)(
Seq(
Expand Down Expand Up @@ -153,15 +191,16 @@ object DockerPlugin extends AutoPlugin {
stagingDirectory := (target in Docker).value / "stage",
target := target.value / "docker",
daemonUser := "daemon",
daemonGroup := daemonUser.value,
daemonGroup := "root",
defaultLinuxInstallLocation := "/opt/docker",
validatePackage := Validation
.runAndThrow(validatePackageValidators.value, streams.value.log),
validatePackageValidators := Seq(
nonEmptyMappings((mappings in Docker).value),
filesExist((mappings in Docker).value),
validateExposedPorts(dockerExposedPorts.value, dockerExposedUdpPorts.value),
validateDockerVersion(dockerVersion.value)
validateDockerVersion(dockerVersion.value),
validateDockerPermissionStrategy(dockerPermissionStrategy.value, dockerVersion.value)
),
dockerPackageMappings := MappingsHelper.contentOf(sourceDirectory.value),
dockerGenerateConfig := {
Expand All @@ -185,6 +224,14 @@ object DockerPlugin extends AutoPlugin {
private final def makeFrom(dockerBaseImage: String): CmdLike =
Cmd("FROM", dockerBaseImage)

/**
* @param dockerBaseImage
* @param name
* @return FROM command
*/
private final def makeFromAs(dockerBaseImage: String, name: String): CmdLike =
Cmd("FROM", dockerBaseImage, "as", name)

/**
* @param label
* @return LABEL command
Expand All @@ -210,29 +257,46 @@ object DockerPlugin extends AutoPlugin {
Cmd("WORKDIR", dockerBaseDirectory)

/**
* @param dockerVersion
* @param dockerBaseDirectory the installation directory
* @return COPY command copying all files inside the installation directory
*/
private final def makeCopy(dockerBaseDirectory: String): CmdLike = {

/**
* This is the file path of the file in the Docker image, and does not depend on the OS where the image
* is being built. This means that it needs to be the Unix file separator even when the image is built
* on e.g. Windows systems.
*/
val files = dockerBaseDirectory.split(UnixSeparatorChar)(1)
Cmd("COPY", s"$files /$files")
}

/**
* @param dockerBaseDirectory the installation directory
* @param from files are copied from the given build stage
* @param daemonUser
* @param daemonGroup
* @return ADD command adding all files inside the installation directory
* @return COPY command copying all files inside the directory from another build stage.
*/
private final def makeAdd(dockerVersion: Option[DockerVersion],
dockerBaseDirectory: String,
daemonUser: String,
daemonGroup: String): Seq[CmdLike] = {
private final def makeCopyFrom(dockerBaseDirectory: String, from: String, daemonUser: String, daemonGroup: String): CmdLike =
Cmd("COPY", s"--from=$from --chown=$daemonUser:$daemonGroup $dockerBaseDirectory $dockerBaseDirectory")

/**
* @param dockerBaseDirectory the installation directory
* @param from files are copied from the given build stage
* @param daemonUser
* @param daemonGroup
* @return COPY command copying all files inside the directory from another build stage.
*/
private final def makeCopyChown(dockerBaseDirectory: String, daemonUser: String, daemonGroup: String): CmdLike = {

/**
* This is the file path of the file in the Docker image, and does not depend on the OS where the image
* is being built. This means that it needs to be the Unix file separator even when the image is built
* on e.g. Windows systems.
*/
val files = dockerBaseDirectory.split(UnixSeparatorChar)(1)

if (dockerVersion.exists(DockerSupport.chownFlag)) {
Seq(Cmd("ADD", s"--chown=$daemonUser:$daemonGroup $files /$files"))
} else {
Seq(Cmd("ADD", s"$files /$files"), makeChown(daemonUser, daemonGroup, "." :: Nil))
}
Cmd("COPY", s"--chown=$daemonUser:$daemonGroup $files /$files")
}

/**
Expand All @@ -243,12 +307,29 @@ object DockerPlugin extends AutoPlugin {
private final def makeChown(daemonUser: String, daemonGroup: String, directories: Seq[String]): CmdLike =
ExecCmd("RUN", Seq("chown", "-R", s"$daemonUser:$daemonGroup") ++ directories: _*)

/**
* @return chown command, owning the installation directory with the daemonuser
*/
private final def makeChmod(chmodType: DockerChmodType, directories: Seq[String]): CmdLike = {
ExecCmd("RUN", Seq("chmod", "-R", chmodType.argument) ++ directories: _*)
}

/**
* @param daemonUser
* @param userId
* @param groupId
* @return useradd to create the daemon user with the given userId and groupId
*/
private final def makeUserAdd(daemonUser: String, userId: Int, groupId: Int): CmdLike =
Cmd("RUN", "id", "-u", daemonUser, "||",
"useradd", "--system", "--create-home", "--uid", userId.toString, "--gid", groupId.toString, daemonUser)

/**
* @param userId userId of the daemon user
* @return USER docker command
*/
private final def makeUser(daemonUser: String): CmdLike =
Cmd("USER", daemonUser)
private final def makeUser(userId: Int): CmdLike =
Cmd("USER", userId.toString)

/**
* @param entrypoint
Expand Down Expand Up @@ -467,10 +548,50 @@ object DockerPlugin extends AutoPlugin {
|As a last resort you could hard code the docker version, but it's not recommended!!
|
| import com.typesafe.sbt.packager.docker.DockerVersion
| dockerVersion := Some(DockerVersion(17, 5, 0, Some("ce"))
| dockerVersion := Some(DockerVersion(18, 9, 0, Some("ce"))
""".stripMargin
)
)
}
}

private[this] def validateDockerPermissionStrategy(
strategy: DockerPermissionStrategy,
dockerVersion: Option[DockerVersion]): Validation.Validator = () => {
(strategy, dockerVersion) match {
case (DockerPermissionStrategy.MultiStage, Some(ver)) if !DockerSupport.multiStage(ver) =>
List(
ValidationError(
description =
s"The detected Docker version $ver is not compatible with DockerPermissionStrategy.MultiStage",
howToFix = """|sbt-native packager tries to parse the `docker version` output.
|To use multi-stage build, upgrade your Docker, pick another strategy, or override dockerVersion:
|
| import com.typesafe.sbt.packager.docker.DockerPermissionStrategy
| dockerPermissionStrategy := DockerPermissionStrategy.Run
|
| import com.typesafe.sbt.packager.docker.DockerVersion
| dockerVersion := Some(DockerVersion(18, 9, 0, Some("ce"))
""".stripMargin
)
)
case (DockerPermissionStrategy.CopyChown, Some(ver)) if !DockerSupport.chownFlag(ver) =>
List(
ValidationError(
description =
s"The detected Docker version $ver is not compatible with DockerPermissionStrategy.CopyChown",
howToFix = """|sbt-native packager tries to parse the `docker version` output.
|To use --chown flag, upgrade your Docker, pick another strategy, or override dockerVersion:
|
| import com.typesafe.sbt.packager.docker.DockerPermissionStrategy
| dockerPermissionStrategy := DockerPermissionStrategy.Run
|
| import com.typesafe.sbt.packager.docker.DockerVersion
| dockerVersion := Some(DockerVersion(18, 9, 0, Some("ce"))
""".stripMargin
)
)
case _ => List.empty
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ object DockerSupport {
def chownFlag(version: DockerVersion): Boolean =
(version.major == 17 && version.minor >= 9) || version.major > 17

def multiStage(version: DockerVersion): Boolean =
(version.major == 17 && version.minor >= 5) || version.major > 17
}
3 changes: 3 additions & 0 deletions src/main/scala/com/typesafe/sbt/packager/docker/Keys.scala
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,7 @@ trait DockerKeys {
SettingKey[Seq[String]]("dockerRmiCommand", "Command for removing the Docker image from the local registry")

val dockerCommands = TaskKey[Seq[CmdLike]]("dockerCommands", "List of docker commands that form the Dockerfile")

lazy val dockerPermissionStrategy = settingKey[DockerPermissionStrategy]("The strategy to change file permissions.")
lazy val dockerChmodType = settingKey[DockerChmodType]("The file permissions for the files copied into Docker image.")
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ case class CombinedCmd(cmd: String, arg: CmdLike) extends CmdLike {
def makeContent: String = "%s %s\n" format (cmd, arg.makeContent)
}

/**
* A break in Dockerfile to express multi-stage build.
* https://docs.docker.com/develop/develop-images/multistage-build/
*/
case object DockerStageBreak extends CmdLike {
def makeContent: String = "\n"
}

/** Represents dockerfile used by docker when constructing packages. */
case class Dockerfile(commands: CmdLike*) {
def makeContent: String = {
Expand Down
Loading

0 comments on commit 5280c1c

Please sign in to comment.