Skip to content

Commit d8d8009

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 d8d8009

File tree

13 files changed

+371
-23
lines changed

13 files changed

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

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

+144-23
Original file line numberDiff line numberDiff line change
@@ -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,52 @@ 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+
case _ => Seq()
130+
}
108131

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) ++
132+
val stage1: Seq[CmdLike] = generalCommands ++
133+
Seq(
134+
makeUserAdd(user, uid, gid),
135+
makeWorkdir(dockerBaseDirectory)) ++
136+
(strategy match {
137+
case DockerPermissionStrategy.MultiStage =>
138+
Seq(makeCopyFrom(dockerBaseDirectory, stage0name, user, group))
139+
case DockerPermissionStrategy.Run =>
140+
Seq(makeCopy(dockerBaseDirectory), makeChmod(dockerChmodType.value, Seq(dockerBaseDirectory)))
141+
case DockerPermissionStrategy.CopyChown =>
142+
Seq(makeCopyChown(dockerBaseDirectory, user, group))
143+
case DockerPermissionStrategy.None =>
144+
Seq(makeCopy(dockerBaseDirectory))
145+
}) ++
113146
dockerLabels.value.map(makeLabel) ++
114147
dockerEnvVars.value.map(makeEnvVar) ++
115148
makeExposePorts(dockerExposedPorts.value, dockerExposedUdpPorts.value) ++
116149
makeVolumes(dockerExposedVolumes.value, user, group) ++
117-
Seq(makeUser(user), makeEntrypoint(dockerEntrypoint.value), makeCmd(dockerCmd.value))
150+
Seq(
151+
makeUser(uid),
152+
makeEntrypoint(dockerEntrypoint.value),
153+
makeCmd(dockerCmd.value))
154+
155+
stage0 ++ stage1
118156
}
119157
) ++ mapGenericFilesToDocker ++ inConfig(Docker)(
120158
Seq(
@@ -153,15 +191,16 @@ object DockerPlugin extends AutoPlugin {
153191
stagingDirectory := (target in Docker).value / "stage",
154192
target := target.value / "docker",
155193
daemonUser := "daemon",
156-
daemonGroup := daemonUser.value,
194+
daemonGroup := "root",
157195
defaultLinuxInstallLocation := "/opt/docker",
158196
validatePackage := Validation
159197
.runAndThrow(validatePackageValidators.value, streams.value.log),
160198
validatePackageValidators := Seq(
161199
nonEmptyMappings((mappings in Docker).value),
162200
filesExist((mappings in Docker).value),
163201
validateExposedPorts(dockerExposedPorts.value, dockerExposedUdpPorts.value),
164-
validateDockerVersion(dockerVersion.value)
202+
validateDockerVersion(dockerVersion.value),
203+
validateDockerPermissionStrategy(dockerPermissionStrategy.value, dockerVersion.value)
165204
),
166205
dockerPackageMappings := MappingsHelper.contentOf(sourceDirectory.value),
167206
dockerGenerateConfig := {
@@ -185,6 +224,14 @@ object DockerPlugin extends AutoPlugin {
185224
private final def makeFrom(dockerBaseImage: String): CmdLike =
186225
Cmd("FROM", dockerBaseImage)
187226

227+
/**
228+
* @param dockerBaseImage
229+
* @param name
230+
* @return FROM command
231+
*/
232+
private final def makeFromAs(dockerBaseImage: String, name: String): CmdLike =
233+
Cmd("FROM", dockerBaseImage, "as", name)
234+
188235
/**
189236
* @param label
190237
* @return LABEL command
@@ -210,29 +257,46 @@ object DockerPlugin extends AutoPlugin {
210257
Cmd("WORKDIR", dockerBaseDirectory)
211258

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

224293
/**
225294
* This is the file path of the file in the Docker image, and does not depend on the OS where the image
226295
* is being built. This means that it needs to be the Unix file separator even when the image is built
227296
* on e.g. Windows systems.
228297
*/
229298
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-
}
299+
Cmd("COPY", s"--chown=$daemonUser:$daemonGroup $files /$files")
236300
}
237301

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

310+
/**
311+
* @return chown command, owning the installation directory with the daemonuser
312+
*/
313+
private final def makeChmod(chmodType: DockerChmodType, directories: Seq[String]): CmdLike = {
314+
ExecCmd("RUN", Seq("chmod", "-R", chmodType.argument) ++ directories: _*)
315+
}
316+
246317
/**
247318
* @param daemonUser
319+
* @param userId
320+
* @param groupId
321+
* @return useradd to create the daemon user with the given userId and groupId
322+
*/
323+
private final def makeUserAdd(daemonUser: String, userId: Int, groupId: Int): CmdLike =
324+
Cmd("RUN", "id", "-u", daemonUser, "||",
325+
"useradd", "--system", "--create-home", "--uid", userId.toString, "--gid", groupId.toString, daemonUser)
326+
327+
/**
328+
* @param userId userId of the daemon user
248329
* @return USER docker command
249330
*/
250-
private final def makeUser(daemonUser: String): CmdLike =
251-
Cmd("USER", daemonUser)
331+
private final def makeUser(userId: Int): CmdLike =
332+
Cmd("USER", userId.toString)
252333

253334
/**
254335
* @param entrypoint
@@ -467,10 +548,50 @@ object DockerPlugin extends AutoPlugin {
467548
|As a last resort you could hard code the docker version, but it's not recommended!!
468549
|
469550
| import com.typesafe.sbt.packager.docker.DockerVersion
470-
| dockerVersion := Some(DockerVersion(17, 5, 0, Some("ce"))
551+
| dockerVersion := Some(DockerVersion(18, 9, 0, Some("ce"))
552+
""".stripMargin
553+
)
554+
)
555+
}
556+
}
557+
558+
private[this] def validateDockerPermissionStrategy(
559+
strategy: DockerPermissionStrategy,
560+
dockerVersion: Option[DockerVersion]): Validation.Validator = () => {
561+
(strategy, dockerVersion) match {
562+
case (DockerPermissionStrategy.MultiStage, Some(ver)) if !DockerSupport.multiStage(ver) =>
563+
List(
564+
ValidationError(
565+
description =
566+
s"The detected Docker version $ver is not compatible with DockerPermissionStrategy.MultiStage",
567+
howToFix = """|sbt-native packager tries to parse the `docker version` output.
568+
|To use multi-stage build, upgrade your Docker, pick another strategy, or override dockerVersion:
569+
|
570+
| import com.typesafe.sbt.packager.docker.DockerPermissionStrategy
571+
| dockerPermissionStrategy := DockerPermissionStrategy.Run
572+
|
573+
| import com.typesafe.sbt.packager.docker.DockerVersion
574+
| dockerVersion := Some(DockerVersion(18, 9, 0, Some("ce"))
575+
""".stripMargin
576+
)
577+
)
578+
case (DockerPermissionStrategy.CopyChown, Some(ver)) if !DockerSupport.chownFlag(ver) =>
579+
List(
580+
ValidationError(
581+
description =
582+
s"The detected Docker version $ver is not compatible with DockerPermissionStrategy.CopyChown",
583+
howToFix = """|sbt-native packager tries to parse the `docker version` output.
584+
|To use --chown flag, upgrade your Docker, pick another strategy, or override dockerVersion:
585+
|
586+
| import com.typesafe.sbt.packager.docker.DockerPermissionStrategy
587+
| dockerPermissionStrategy := DockerPermissionStrategy.Run
588+
|
589+
| import com.typesafe.sbt.packager.docker.DockerVersion
590+
| dockerVersion := Some(DockerVersion(18, 9, 0, Some("ce"))
471591
""".stripMargin
472592
)
473593
)
594+
case _ => List.empty
474595
}
475596
}
476597

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

+3
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,7 @@ trait DockerKeys {
4141
SettingKey[Seq[String]]("dockerRmiCommand", "Command for removing the Docker image from the local registry")
4242

4343
val dockerCommands = TaskKey[Seq[CmdLike]]("dockerCommands", "List of docker commands that form the Dockerfile")
44+
45+
lazy val dockerPermissionStrategy = settingKey[DockerPermissionStrategy]("The strategy to change file permissions.")
46+
lazy val dockerChmodType = settingKey[DockerChmodType]("The file permissions for the files copied into Docker image.")
4447
}

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)