Skip to content

Commit 12b5fcb

Browse files
committed
implement dockerPermissionStrategy
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 use `chmod` to default to read-only access unless the build user opts into writable working directory. 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` (default): 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. Some application will require writing files to the working directory. In that case the setting should be changed as follows: ```scala import com.typesafe.sbt.packager.docker.DockerChmodType 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.
1 parent 80f85c5 commit 12b5fcb

File tree

15 files changed

+392
-26
lines changed

15 files changed

+392
-26
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.typesafe.sbt.packager.docker
2+
3+
/**
4+
* This represents a strategy to change the file permissions.
5+
*/
6+
sealed trait DockerPermissionStrategy
7+
object DockerPermissionStrategy {
8+
9+
/**
10+
* `None` does not attempt to change the file permissions.
11+
* This will inherit the host machine's group bits.
12+
*/
13+
case object None extends DockerPermissionStrategy
14+
15+
/**
16+
* `Run` calls `RUN` in the `Dockerfile`.
17+
* This could double the size of the resulting Docker image
18+
* because of the extra layer it creates.
19+
*/
20+
case object Run extends DockerPermissionStrategy
21+
22+
/**
23+
* `MultiStage` uses multi-stage Docker build to change
24+
* the file permissions.
25+
* https://docs.docker.com/develop/develop-images/multistage-build/
26+
*/
27+
case object MultiStage extends DockerPermissionStrategy
28+
29+
/**
30+
* `CopyChown` calls `COPY --chown` in the `Dockerfile`.
31+
* This option is provided for backward compatibility.
32+
* This will inherit the host machine's file mode.
33+
* Note that this option is not compatible with OpenShift which ignores
34+
* USER command and uses an arbitrary user to run the container.
35+
*/
36+
case object CopyChown extends DockerPermissionStrategy
37+
}
38+
39+
/**
40+
* This represents a type of file permission changes to run on the working directory.
41+
* Note that group file mode bits must be effective to be OpenShift compatible.
42+
*/
43+
sealed trait DockerChmodType {
44+
def argument: String
45+
}
46+
object DockerChmodType {
47+
48+
/**
49+
* Gives read permission to users and groups.
50+
* Gives execute permission to users and groups, if +x flag is on for any.
51+
*/
52+
case object UserGroupReadExecute extends DockerChmodType {
53+
def argument: String = "u=rX,g=rX"
54+
}
55+
56+
/**
57+
* Gives read and write permissions to users and groups.
58+
* Gives execute permission to users and groups, if +x flag is on for any.
59+
*/
60+
case object UserGroupWriteExecute extends DockerChmodType {
61+
def argument: String = "u=rwX,g=rwX"
62+
}
63+
64+
/**
65+
* Copies user file mode bits to group file mode bits.
66+
*/
67+
case object SyncGroupToUser extends DockerChmodType {
68+
def argument: String = "g=u"
69+
}
70+
71+
/**
72+
* Use custom argument.
73+
*/
74+
case class Custom(argument: String) extends DockerChmodType
75+
}

src/main/scala/com/typesafe/sbt/packager/docker/DockerPlugin.scala

+157-24
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import scala.util.Try
4848
*/
4949
object DockerPlugin extends AutoPlugin {
5050

51-
object autoImport extends DockerKeys {
51+
object autoImport extends DockerKeysEx {
5252
val Docker: Configuration = config("docker")
5353

5454
val DockerAlias = com.typesafe.sbt.packager.docker.DockerAlias
@@ -57,7 +57,7 @@ object DockerPlugin extends AutoPlugin {
5757
import autoImport._
5858

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

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

69+
override lazy val globalSettings: Seq[Setting[_]] = Seq(
70+
dockerPermissionStrategy := DockerPermissionStrategy.MultiStage,
71+
dockerChmodType := DockerChmodType.UserGroupReadExecute
72+
)
73+
6974
override lazy val projectSettings: Seq[Setting[_]] = Seq(
7075
dockerBaseImage := "openjdk:8",
7176
dockerExposedPorts := Seq(),
@@ -102,19 +107,48 @@ object DockerPlugin extends AutoPlugin {
102107
dockerRmiCommand := dockerExecCommand.value ++ Seq("rmi"),
103108
dockerBuildCommand := dockerExecCommand.value ++ Seq("build") ++ dockerBuildOptions.value ++ Seq("."),
104109
dockerCommands := {
110+
val strategy = dockerPermissionStrategy.value
105111
val dockerBaseDirectory = (defaultLinuxInstallLocation in Docker).value
106112
val user = (daemonUser in Docker).value
107113
val group = (daemonGroup in Docker).value
114+
val base = dockerBaseImage.value
115+
val uid = 1001
116+
val gid = 0
117+
118+
val generalCommands = makeFrom(base) +: makeMaintainer((maintainer in Docker).value).toSeq
119+
val stage0name = "stage0"
120+
val stage0: Seq[CmdLike] = strategy match {
121+
case DockerPermissionStrategy.MultiStage =>
122+
Seq(
123+
makeFromAs(base, stage0name),
124+
makeWorkdir(dockerBaseDirectory),
125+
makeUserAdd(user, uid, gid),
126+
makeCopy(dockerBaseDirectory),
127+
makeChmod(dockerChmodType.value, Seq(dockerBaseDirectory)),
128+
DockerStageBreak
129+
)
130+
case _ => Seq()
131+
}
108132

109-
val generalCommands = makeFrom(dockerBaseImage.value) +: makeMaintainer((maintainer in Docker).value).toSeq
110-
111-
generalCommands ++
112-
Seq(makeWorkdir(dockerBaseDirectory)) ++ makeAdd(dockerVersion.value, dockerBaseDirectory, user, group) ++
133+
val stage1: Seq[CmdLike] = generalCommands ++
134+
Seq(makeUserAdd(user, uid, gid), makeWorkdir(dockerBaseDirectory)) ++
135+
(strategy match {
136+
case DockerPermissionStrategy.MultiStage =>
137+
Seq(makeCopyFrom(dockerBaseDirectory, stage0name, user, group))
138+
case DockerPermissionStrategy.Run =>
139+
Seq(makeCopy(dockerBaseDirectory), makeChmod(dockerChmodType.value, Seq(dockerBaseDirectory)))
140+
case DockerPermissionStrategy.CopyChown =>
141+
Seq(makeCopyChown(dockerBaseDirectory, user, group))
142+
case DockerPermissionStrategy.None =>
143+
Seq(makeCopy(dockerBaseDirectory))
144+
}) ++
113145
dockerLabels.value.map(makeLabel) ++
114146
dockerEnvVars.value.map(makeEnvVar) ++
115147
makeExposePorts(dockerExposedPorts.value, dockerExposedUdpPorts.value) ++
116148
makeVolumes(dockerExposedVolumes.value, user, group) ++
117-
Seq(makeUser(user), makeEntrypoint(dockerEntrypoint.value), makeCmd(dockerCmd.value))
149+
Seq(makeUser(uid), makeEntrypoint(dockerEntrypoint.value), makeCmd(dockerCmd.value))
150+
151+
stage0 ++ stage1
118152
}
119153
) ++ mapGenericFilesToDocker ++ inConfig(Docker)(
120154
Seq(
@@ -153,15 +187,16 @@ object DockerPlugin extends AutoPlugin {
153187
stagingDirectory := (target in Docker).value / "stage",
154188
target := target.value / "docker",
155189
daemonUser := "daemon",
156-
daemonGroup := daemonUser.value,
190+
daemonGroup := "root",
157191
defaultLinuxInstallLocation := "/opt/docker",
158192
validatePackage := Validation
159193
.runAndThrow(validatePackageValidators.value, streams.value.log),
160194
validatePackageValidators := Seq(
161195
nonEmptyMappings((mappings in Docker).value),
162196
filesExist((mappings in Docker).value),
163197
validateExposedPorts(dockerExposedPorts.value, dockerExposedUdpPorts.value),
164-
validateDockerVersion(dockerVersion.value)
198+
validateDockerVersion(dockerVersion.value),
199+
validateDockerPermissionStrategy(dockerPermissionStrategy.value, dockerVersion.value)
165200
),
166201
dockerPackageMappings := MappingsHelper.contentOf(sourceDirectory.value),
167202
dockerGenerateConfig := {
@@ -185,6 +220,14 @@ object DockerPlugin extends AutoPlugin {
185220
private final def makeFrom(dockerBaseImage: String): CmdLike =
186221
Cmd("FROM", dockerBaseImage)
187222

223+
/**
224+
* @param dockerBaseImage
225+
* @param name
226+
* @return FROM command
227+
*/
228+
private final def makeFromAs(dockerBaseImage: String, name: String): CmdLike =
229+
Cmd("FROM", dockerBaseImage, "as", name)
230+
188231
/**
189232
* @param label
190233
* @return LABEL command
@@ -210,29 +253,49 @@ object DockerPlugin extends AutoPlugin {
210253
Cmd("WORKDIR", dockerBaseDirectory)
211254

212255
/**
213-
* @param dockerVersion
214256
* @param dockerBaseDirectory the installation directory
257+
* @return COPY command copying all files inside the installation directory
258+
*/
259+
private final def makeCopy(dockerBaseDirectory: String): CmdLike = {
260+
261+
/**
262+
* This is the file path of the file in the Docker image, and does not depend on the OS where the image
263+
* is being built. This means that it needs to be the Unix file separator even when the image is built
264+
* on e.g. Windows systems.
265+
*/
266+
val files = dockerBaseDirectory.split(UnixSeparatorChar)(1)
267+
Cmd("COPY", s"$files /$files")
268+
}
269+
270+
/**
271+
* @param dockerBaseDirectory the installation directory
272+
* @param from files are copied from the given build stage
215273
* @param daemonUser
216274
* @param daemonGroup
217-
* @return ADD command adding all files inside the installation directory
275+
* @return COPY command copying all files inside the directory from another build stage.
218276
*/
219-
private final def makeAdd(dockerVersion: Option[DockerVersion],
220-
dockerBaseDirectory: String,
221-
daemonUser: String,
222-
daemonGroup: String): Seq[CmdLike] = {
277+
private final def makeCopyFrom(dockerBaseDirectory: String,
278+
from: String,
279+
daemonUser: String,
280+
daemonGroup: String): CmdLike =
281+
Cmd("COPY", s"--from=$from --chown=$daemonUser:$daemonGroup $dockerBaseDirectory $dockerBaseDirectory")
282+
283+
/**
284+
* @param dockerBaseDirectory the installation directory
285+
* @param from files are copied from the given build stage
286+
* @param daemonUser
287+
* @param daemonGroup
288+
* @return COPY command copying all files inside the directory from another build stage.
289+
*/
290+
private final def makeCopyChown(dockerBaseDirectory: String, daemonUser: String, daemonGroup: String): CmdLike = {
223291

224292
/**
225293
* This is the file path of the file in the Docker image, and does not depend on the OS where the image
226294
* is being built. This means that it needs to be the Unix file separator even when the image is built
227295
* on e.g. Windows systems.
228296
*/
229297
val files = dockerBaseDirectory.split(UnixSeparatorChar)(1)
230-
231-
if (dockerVersion.exists(DockerSupport.chownFlag)) {
232-
Seq(Cmd("ADD", s"--chown=$daemonUser:$daemonGroup $files /$files"))
233-
} else {
234-
Seq(Cmd("ADD", s"$files /$files"), makeChown(daemonUser, daemonGroup, "." :: Nil))
235-
}
298+
Cmd("COPY", s"--chown=$daemonUser:$daemonGroup $files /$files")
236299
}
237300

238301
/**
@@ -243,12 +306,41 @@ object DockerPlugin extends AutoPlugin {
243306
private final def makeChown(daemonUser: String, daemonGroup: String, directories: Seq[String]): CmdLike =
244307
ExecCmd("RUN", Seq("chown", "-R", s"$daemonUser:$daemonGroup") ++ directories: _*)
245308

309+
/**
310+
* @return chown command, owning the installation directory with the daemonuser
311+
*/
312+
private final def makeChmod(chmodType: DockerChmodType, directories: Seq[String]): CmdLike =
313+
ExecCmd("RUN", Seq("chmod", "-R", chmodType.argument) ++ directories: _*)
314+
246315
/**
247316
* @param daemonUser
317+
* @param userId
318+
* @param groupId
319+
* @return useradd to create the daemon user with the given userId and groupId
320+
*/
321+
private final def makeUserAdd(daemonUser: String, userId: Int, groupId: Int): CmdLike =
322+
Cmd(
323+
"RUN",
324+
"id",
325+
"-u",
326+
daemonUser,
327+
"||",
328+
"useradd",
329+
"--system",
330+
"--create-home",
331+
"--uid",
332+
userId.toString,
333+
"--gid",
334+
groupId.toString,
335+
daemonUser
336+
)
337+
338+
/**
339+
* @param userId userId of the daemon user
248340
* @return USER docker command
249341
*/
250-
private final def makeUser(daemonUser: String): CmdLike =
251-
Cmd("USER", daemonUser)
342+
private final def makeUser(userId: Int): CmdLike =
343+
Cmd("USER", userId.toString)
252344

253345
/**
254346
* @param entrypoint
@@ -467,11 +559,52 @@ object DockerPlugin extends AutoPlugin {
467559
|As a last resort you could hard code the docker version, but it's not recommended!!
468560
|
469561
| import com.typesafe.sbt.packager.docker.DockerVersion
470-
| dockerVersion := Some(DockerVersion(17, 5, 0, Some("ce"))
562+
| dockerVersion := Some(DockerVersion(18, 9, 0, Some("ce"))
471563
""".stripMargin
472564
)
473565
)
474566
}
475567
}
476568

569+
private[this] def validateDockerPermissionStrategy(strategy: DockerPermissionStrategy,
570+
dockerVersion: Option[DockerVersion]): Validation.Validator =
571+
() => {
572+
(strategy, dockerVersion) match {
573+
case (DockerPermissionStrategy.MultiStage, Some(ver)) if !DockerSupport.multiStage(ver) =>
574+
List(
575+
ValidationError(
576+
description =
577+
s"The detected Docker version $ver is not compatible with DockerPermissionStrategy.MultiStage",
578+
howToFix =
579+
"""|sbt-native packager tries to parse the `docker version` output.
580+
|To use multi-stage build, upgrade your Docker, pick another strategy, or override dockerVersion:
581+
|
582+
| import com.typesafe.sbt.packager.docker.DockerPermissionStrategy
583+
| dockerPermissionStrategy := DockerPermissionStrategy.Run
584+
|
585+
| import com.typesafe.sbt.packager.docker.DockerVersion
586+
| dockerVersion := Some(DockerVersion(18, 9, 0, Some("ce"))
587+
""".stripMargin
588+
)
589+
)
590+
case (DockerPermissionStrategy.CopyChown, Some(ver)) if !DockerSupport.chownFlag(ver) =>
591+
List(
592+
ValidationError(
593+
description =
594+
s"The detected Docker version $ver is not compatible with DockerPermissionStrategy.CopyChown",
595+
howToFix = """|sbt-native packager tries to parse the `docker version` output.
596+
|To use --chown flag, upgrade your Docker, pick another strategy, or override dockerVersion:
597+
|
598+
| import com.typesafe.sbt.packager.docker.DockerPermissionStrategy
599+
| dockerPermissionStrategy := DockerPermissionStrategy.Run
600+
|
601+
| import com.typesafe.sbt.packager.docker.DockerVersion
602+
| dockerVersion := Some(DockerVersion(18, 9, 0, Some("ce"))
603+
""".stripMargin
604+
)
605+
)
606+
case _ => List.empty
607+
}
608+
}
609+
477610
}

src/main/scala/com/typesafe/sbt/packager/docker/DockerSupport.scala

+2
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@ object DockerSupport {
55
def chownFlag(version: DockerVersion): Boolean =
66
(version.major == 17 && version.minor >= 9) || version.major > 17
77

8+
def multiStage(version: DockerVersion): Boolean =
9+
(version.major == 17 && version.minor >= 5) || version.major > 17
810
}

src/main/scala/com/typesafe/sbt/packager/docker/Keys.scala

+8
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import sbt._
77
/**
88
* Docker settings
99
*/
10+
@deprecated("Internal use only. Please don't extend this trait", "1.3.15")
1011
trait DockerKeys {
1112
val dockerGenerateConfig = TaskKey[File]("docker-generate-config", "Generates configuration file for Docker.")
1213
val dockerPackageMappings =
@@ -42,3 +43,10 @@ trait DockerKeys {
4243

4344
val dockerCommands = TaskKey[Seq[CmdLike]]("dockerCommands", "List of docker commands that form the Dockerfile")
4445
}
46+
47+
// Workaround to pass mima.
48+
// In the next version bump we should hide DockerKeys trait to package private.
49+
private[packager] trait DockerKeysEx extends DockerKeys {
50+
lazy val dockerPermissionStrategy = settingKey[DockerPermissionStrategy]("The strategy to change file permissions.")
51+
lazy val dockerChmodType = settingKey[DockerChmodType]("The file permissions for the files copied into Docker image.")
52+
}

src/main/scala/com/typesafe/sbt/packager/docker/dockerfile.scala

+8
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ case class CombinedCmd(cmd: String, arg: CmdLike) extends CmdLike {
7979
def makeContent: String = "%s %s\n" format (cmd, arg.makeContent)
8080
}
8181

82+
/**
83+
* A break in Dockerfile to express multi-stage build.
84+
* https://docs.docker.com/develop/develop-images/multistage-build/
85+
*/
86+
case object DockerStageBreak extends CmdLike {
87+
def makeContent: String = "\n"
88+
}
89+
8290
/** Represents dockerfile used by docker when constructing packages. */
8391
case class Dockerfile(commands: CmdLike*) {
8492
def makeContent: String = {

0 commit comments

Comments
 (0)