diff --git a/src/main/scala/com/typesafe/sbt/packager/docker/DockerPermissionStrategy.scala b/src/main/scala/com/typesafe/sbt/packager/docker/DockerPermissionStrategy.scala new file mode 100644 index 000000000..1c1ab89fb --- /dev/null +++ b/src/main/scala/com/typesafe/sbt/packager/docker/DockerPermissionStrategy.scala @@ -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 +} diff --git a/src/main/scala/com/typesafe/sbt/packager/docker/DockerPlugin.scala b/src/main/scala/com/typesafe/sbt/packager/docker/DockerPlugin.scala index afe89279a..47c98c1e5 100644 --- a/src/main/scala/com/typesafe/sbt/packager/docker/DockerPlugin.scala +++ b/src/main/scala/com/typesafe/sbt/packager/docker/DockerPlugin.scala @@ -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 = '/' @@ -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(), @@ -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( @@ -153,7 +191,7 @@ 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), @@ -161,7 +199,8 @@ object DockerPlugin extends AutoPlugin { 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 := { @@ -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 @@ -210,16 +257,38 @@ 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 @@ -227,12 +296,7 @@ object DockerPlugin extends AutoPlugin { * 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") } /** @@ -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 @@ -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 } } diff --git a/src/main/scala/com/typesafe/sbt/packager/docker/DockerSupport.scala b/src/main/scala/com/typesafe/sbt/packager/docker/DockerSupport.scala index 024ca50e9..2ddc501f2 100644 --- a/src/main/scala/com/typesafe/sbt/packager/docker/DockerSupport.scala +++ b/src/main/scala/com/typesafe/sbt/packager/docker/DockerSupport.scala @@ -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 } diff --git a/src/main/scala/com/typesafe/sbt/packager/docker/Keys.scala b/src/main/scala/com/typesafe/sbt/packager/docker/Keys.scala index e80085f3c..4b6b1dc95 100644 --- a/src/main/scala/com/typesafe/sbt/packager/docker/Keys.scala +++ b/src/main/scala/com/typesafe/sbt/packager/docker/Keys.scala @@ -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.") } diff --git a/src/main/scala/com/typesafe/sbt/packager/docker/dockerfile.scala b/src/main/scala/com/typesafe/sbt/packager/docker/dockerfile.scala index e4fac23ad..34cf84163 100644 --- a/src/main/scala/com/typesafe/sbt/packager/docker/dockerfile.scala +++ b/src/main/scala/com/typesafe/sbt/packager/docker/dockerfile.scala @@ -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 = { diff --git a/src/sbt-test/docker/file-permission/build.sbt b/src/sbt-test/docker/file-permission/build.sbt new file mode 100644 index 000000000..58c5edd6c --- /dev/null +++ b/src/sbt-test/docker/file-permission/build.sbt @@ -0,0 +1,96 @@ +lazy val checkDockerfile = taskKey[Unit]("") +lazy val checkDockerfile2 = taskKey[Unit]("") +lazy val checkDockerfile3 = taskKey[Unit]("") +lazy val checkDockerfile4 = taskKey[Unit]("") +lazy val checkDockerfile5 = taskKey[Unit]("") + +lazy val root = (project in file(".")) + .enablePlugins(DockerPlugin, JavaAppPackaging) + .settings( + name := "file-permission-test", + version := "0.1.0", + checkDockerfile := { + val dockerfile = IO.read((stagingDirectory in Docker).value / "Dockerfile") + val lines = dockerfile.lines.toList + assertEquals(lines, + """FROM openjdk:8 as stage0 + |WORKDIR /opt/docker + |RUN id -u daemon || useradd --system --create-home --uid 1001 --gid 0 daemon + |COPY opt /opt + |RUN ["chmod", "-R", "u=rX,g=rX", "/opt/docker"] + | + |FROM openjdk:8 + |RUN id -u daemon || useradd --system --create-home --uid 1001 --gid 0 daemon + |WORKDIR /opt/docker + |COPY --from=stage0 --chown=daemon:root /opt/docker /opt/docker + |USER 1001 + |ENTRYPOINT ["/opt/docker/bin/file-permission-test"] + |CMD []""".stripMargin.lines.toList) + }, + + checkDockerfile2 := { + val dockerfile = IO.read((stagingDirectory in Docker).value / "Dockerfile") + val lines = dockerfile.lines.toList + assertEquals(lines, + """FROM openjdk:8 + |RUN id -u daemon || useradd --system --create-home --uid 1001 --gid 0 daemon + |WORKDIR /opt/docker + |COPY opt /opt + |USER 1001 + |ENTRYPOINT ["/opt/docker/bin/file-permission-test"] + |CMD []""".stripMargin.lines.toList) + }, + + checkDockerfile3 := { + val dockerfile = IO.read((stagingDirectory in Docker).value / "Dockerfile") + val lines = dockerfile.lines.toList + assertEquals(lines, + """FROM openjdk:8 + |RUN id -u daemon || useradd --system --create-home --uid 1001 --gid 0 daemon + |WORKDIR /opt/docker + |COPY opt /opt + |RUN ["chmod", "-R", "u=rX,g=rX", "/opt/docker"] + |USER 1001 + |ENTRYPOINT ["/opt/docker/bin/file-permission-test"] + |CMD []""".stripMargin.lines.toList) + }, + + checkDockerfile4 := { + val dockerfile = IO.read((stagingDirectory in Docker).value / "Dockerfile") + val lines = dockerfile.lines.toList + assertEquals(lines, + """FROM openjdk:8 + |RUN id -u daemon || useradd --system --create-home --uid 1001 --gid 0 daemon + |WORKDIR /opt/docker + |COPY --chown=daemon:root opt /opt + |USER 1001 + |ENTRYPOINT ["/opt/docker/bin/file-permission-test"] + |CMD []""".stripMargin.lines.toList) + }, + + checkDockerfile5 := { + val dockerfile = IO.read((stagingDirectory in Docker).value / "Dockerfile") + val lines = dockerfile.lines.toList + assertEquals(lines, + """FROM openjdk:8 as stage0 + |WORKDIR /opt/docker + |RUN id -u daemon || useradd --system --create-home --uid 1001 --gid 0 daemon + |COPY opt /opt + |RUN ["chmod", "-R", "u=rwX,g=rwX", "/opt/docker"] + | + |FROM openjdk:8 + |RUN id -u daemon || useradd --system --create-home --uid 1001 --gid 0 daemon + |WORKDIR /opt/docker + |COPY --from=stage0 --chown=daemon:root /opt/docker /opt/docker + |USER 1001 + |ENTRYPOINT ["/opt/docker/bin/file-permission-test"] + |CMD []""".stripMargin.lines.toList) + } + ) + +def assertEquals(left: List[String], right: List[String]) = + assert(left == right, + "\n" + ((left zip right) flatMap { case (a: String, b: String) => + if (a == b) Nil + else List("- " + a, "+ " + b) + }).mkString("\n")) diff --git a/src/sbt-test/docker/file-permission/changes/a.sbt b/src/sbt-test/docker/file-permission/changes/a.sbt new file mode 100644 index 000000000..34ae7cb35 --- /dev/null +++ b/src/sbt-test/docker/file-permission/changes/a.sbt @@ -0,0 +1,3 @@ +import com.typesafe.sbt.packager.docker._ + +dockerPermissionStrategy := DockerPermissionStrategy.None diff --git a/src/sbt-test/docker/file-permission/changes/b.sbt b/src/sbt-test/docker/file-permission/changes/b.sbt new file mode 100644 index 000000000..07c41802f --- /dev/null +++ b/src/sbt-test/docker/file-permission/changes/b.sbt @@ -0,0 +1,3 @@ +import com.typesafe.sbt.packager.docker._ + +dockerPermissionStrategy := DockerPermissionStrategy.Run diff --git a/src/sbt-test/docker/file-permission/changes/c.sbt b/src/sbt-test/docker/file-permission/changes/c.sbt new file mode 100644 index 000000000..6d56d90a8 --- /dev/null +++ b/src/sbt-test/docker/file-permission/changes/c.sbt @@ -0,0 +1,3 @@ +import com.typesafe.sbt.packager.docker._ + +dockerVersion := Some(DockerVersion(1, 13, 0, None)) diff --git a/src/sbt-test/docker/file-permission/changes/d.sbt b/src/sbt-test/docker/file-permission/changes/d.sbt new file mode 100644 index 000000000..6919ff3eb --- /dev/null +++ b/src/sbt-test/docker/file-permission/changes/d.sbt @@ -0,0 +1,3 @@ +import com.typesafe.sbt.packager.docker._ + +dockerPermissionStrategy := DockerPermissionStrategy.CopyChown diff --git a/src/sbt-test/docker/file-permission/changes/e.sbt b/src/sbt-test/docker/file-permission/changes/e.sbt new file mode 100644 index 000000000..bad9cfbca --- /dev/null +++ b/src/sbt-test/docker/file-permission/changes/e.sbt @@ -0,0 +1,4 @@ +import com.typesafe.sbt.packager.docker._ + +dockerPermissionStrategy := DockerPermissionStrategy.MultiStage +dockerChmodType := DockerChmodType.UserGroupWriteExecute diff --git a/src/sbt-test/docker/file-permission/project/plugins.sbt b/src/sbt-test/docker/file-permission/project/plugins.sbt new file mode 100644 index 000000000..b53de154c --- /dev/null +++ b/src/sbt-test/docker/file-permission/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % sys.props("project.version")) diff --git a/src/sbt-test/docker/file-permission/test b/src/sbt-test/docker/file-permission/test new file mode 100644 index 000000000..c6610944e --- /dev/null +++ b/src/sbt-test/docker/file-permission/test @@ -0,0 +1,27 @@ +# Stage the distribution and ensure files show up. +> docker:publishLocal +> checkDockerfile + +$ copy-file changes/a.sbt change.sbt +> reload +> docker:publishLocal +> checkDockerfile2 + +$ copy-file changes/b.sbt change.sbt +> reload +> docker:publishLocal +> checkDockerfile3 + +$ copy-file changes/c.sbt change.sbt +> reload +-> docker:stage + +$ copy-file changes/d.sbt change.sbt +> reload +> docker:publishLocal +> checkDockerfile4 + +$ copy-file changes/e.sbt change.sbt +> reload +> docker:publishLocal +> checkDockerfile5