diff --git a/src/main/scala/sbtdocker/DockerfileCommands.scala b/src/main/scala/sbtdocker/DockerfileCommands.scala index 891f473..9e2e2ea 100644 --- a/src/main/scala/sbtdocker/DockerfileCommands.scala +++ b/src/main/scala/sbtdocker/DockerfileCommands.scala @@ -4,6 +4,8 @@ import sbt._ import sbtdocker.Instructions._ import sbtdocker.staging.{CopyFile, SourceFile} +import scala.concurrent.duration.FiniteDuration + trait DockerfileLike extends DockerfileCommands { type T <: DockerfileLike @@ -244,4 +246,36 @@ trait DockerfileCommands { def onBuild(instruction: DockerfileInstruction): T = addInstruction(Instructions.OnBuild(instruction)) + def healthCheck(commands: Seq[String], + interval: Option[FiniteDuration] = None, + timeout: Option[FiniteDuration] = None, + startPeriod: Option[FiniteDuration] = None, + retries: Option[Int] = None): T = { + if (commands.nonEmpty) addInstruction(HealthCheck.exec( + commands = commands, + interval = interval, + timeout = timeout, + startPeriod = startPeriod, + retries = retries)) + else self + } + + def healthCheck(commands: String*): T = healthCheck(commands) + + def healthCheckShell(commands: Seq[String], + interval: Option[FiniteDuration] = None, + timeout: Option[FiniteDuration] = None, + startPeriod: Option[FiniteDuration] = None, + retries: Option[Int] = None): T = { + if (commands.nonEmpty) addInstruction(HealthCheck.shell(commands = commands, + interval = interval, + timeout = timeout, + startPeriod = startPeriod, + retries = retries)) + else self + } + + def healthCheckShell(commands: String*): T = healthCheckShell(commands) + + def healthCheckNone(): T = addInstruction(HealthCheckNone) } diff --git a/src/main/scala/sbtdocker/Instructions.scala b/src/main/scala/sbtdocker/Instructions.scala index 2a5b2fa..76f0c06 100644 --- a/src/main/scala/sbtdocker/Instructions.scala +++ b/src/main/scala/sbtdocker/Instructions.scala @@ -3,6 +3,8 @@ package sbtdocker import org.apache.commons.lang3.StringEscapeUtils import sbtdocker.staging.SourceFile +import scala.concurrent.duration.FiniteDuration + sealed trait Instruction /** @@ -298,6 +300,78 @@ object Instructions { */ case class StageFiles(sources: Seq[SourceFile], destination: String) extends FileStagingInstruction + object HealthCheck { + /** + * Command to execute for health check. + * @param commands Command list. + */ + def exec(commands: Seq[String], + interval: Option[FiniteDuration], + timeout: Option[FiniteDuration], + startPeriod: Option[FiniteDuration], + retries: Option[Int]) = + HealthCheck( + command = jsonArrayString(commands), + interval = interval, + timeout = timeout, + startPeriod = startPeriod, + retries = retries) + + /** + * Command to execute through a shell (`/bin/sh`) for health check. + * @param commands Command list. + */ + def shell(commands: Seq[String], + interval: Option[FiniteDuration], + timeout: Option[FiniteDuration], + startPeriod: Option[FiniteDuration], + retries: Option[Int]) = + HealthCheck( + command = shellCommandString(commands), + interval = interval, + timeout = timeout, + startPeriod = startPeriod, + retries = retries) + + def none() = HealthCheckNone + } + + /** + * Defines health check command to run inside the container to check that it is still working. + * @param command Command to execute for health check + * @param interval The health check will first run interval seconds after the container is started, + * and then again interval seconds after each previous check completes. + * @param timeout If a single run of the check takes longer than specified then the check is considered to have failed. + * @param startPeriod Provides initialization time for containers that need time to bootstrap. + * @param retries How many consecutive failures of the health check for the container to be considered unhealthy. + */ + case class HealthCheck( + command: String, + interval: Option[FiniteDuration] = None, + timeout: Option[FiniteDuration] = None, + startPeriod: Option[FiniteDuration] = None, + retries: Option[Int] = None + ) extends DockerfileInstruction { + + override def instructionName = "HEALTHCHECK" + + override def arguments: String = Seq( + interval.map(x => s"--interval=${x.toSeconds}s"), + timeout.map(x => s"--timeout=${x.toSeconds}s"), + startPeriod.map(x => s"--start-period=${x.toSeconds}s"), + retries.map(x => s"--retries=$x"), + Some(s"CMD $command") + ).flatten.mkString(" ") + } + + /** + * Disable any health check inherited from the base image. + */ + case object HealthCheckNone extends DockerfileInstruction { + override def instructionName = "HEALTHCHECK" + override def arguments = "NONE" + } + /** * This class allows the user to specify a raw Dockerfile instruction. * Example: diff --git a/src/test/scala/sbtdocker/DockerfileLikeSuite.scala b/src/test/scala/sbtdocker/DockerfileLikeSuite.scala index e3a108c..96894f4 100644 --- a/src/test/scala/sbtdocker/DockerfileLikeSuite.scala +++ b/src/test/scala/sbtdocker/DockerfileLikeSuite.scala @@ -3,7 +3,9 @@ package sbtdocker import org.scalatest.{FunSuite, Matchers} import sbt.file import sbtdocker.Instructions._ -import staging.{CopyFile, DefaultDockerfileProcessor, StagedDockerfile} +import sbtdocker.staging.{CopyFile, DefaultDockerfileProcessor, StagedDockerfile} + +import scala.concurrent.duration._ class DockerfileLikeSuite extends FunSuite with Matchers { val allInstructions = Seq( @@ -23,7 +25,12 @@ class DockerfileLikeSuite extends FunSuite with Matchers { Volume("mountpoint"), User("marcus"), WorkDir("path"), - OnBuild(Run.exec(Seq("echo", "123"))) + OnBuild(Run.exec(Seq("echo", "123"))), + HealthCheck.exec(Seq("cmd", "arg"), interval = Some(20.seconds), timeout = Some(10.seconds), + startPeriod = Some(1.second), retries = Some(3)), + HealthCheck.shell(Seq("cmd", "arg"), interval = Some(20.seconds), timeout = Some(10.seconds), + startPeriod = Some(1.second), retries = Some(3)), + HealthCheck.none() ) test("Instructions string is in correct order and matches instructions") { @@ -46,7 +53,10 @@ class DockerfileLikeSuite extends FunSuite with Matchers { |VOLUME ["mountpoint"] |USER marcus |WORKDIR path - |ONBUILD RUN ["echo", "123"]""".stripMargin + |ONBUILD RUN ["echo", "123"] + |HEALTHCHECK --interval=20s --timeout=10s --start-period=1s --retries=3 CMD ["cmd", "arg"] + |HEALTHCHECK --interval=20s --timeout=10s --start-period=1s --retries=3 CMD cmd arg + |HEALTHCHECK NONE""".stripMargin } def staged(dockerfile: immutable.Dockerfile): StagedDockerfile = { @@ -85,11 +95,16 @@ class DockerfileLikeSuite extends FunSuite with Matchers { .user("marcus") .workDir("path") .onBuild(Run.exec(Seq("echo", "123"))) + .healthCheck(Seq("cmd", "arg"), interval = Some(20.seconds), timeout = Some(10.seconds), + startPeriod = Some(1.second), retries = Some(3)) + .healthCheckShell(Seq("cmd", "arg"), interval = Some(20.seconds), timeout = Some(10.seconds), + startPeriod = Some(1.second), retries = Some(3)) + .healthCheckNone() withMethods shouldEqual predefined } - test("Run, Cmd and EntryPoint instructions should handle arguments with whitespace") { + test("Run, Cmd, EntryPoint and HealthCheck instructions should handle arguments with whitespace") { val dockerfile = immutable.Dockerfile.empty .run("echo", "arg \"with\t\nspaces") .runShell("echo", "arg \"with\t\nspaces") @@ -97,6 +112,8 @@ class DockerfileLikeSuite extends FunSuite with Matchers { .cmdShell("echo", "arg \"with\t\nspaces") .entryPoint("echo", "arg \"with\t\nspaces") .entryPointShell("echo", "arg \"with\t\nspaces") + .healthCheck(Seq("echo", "arg \"with\t\nspaces")) + .healthCheckShell(Seq("echo", "arg \"with\t\nspaces")) staged(dockerfile).instructionsString shouldEqual """RUN ["echo", "arg \"with\t\nspaces"] @@ -104,7 +121,9 @@ class DockerfileLikeSuite extends FunSuite with Matchers { |CMD ["echo", "arg \"with\t\nspaces"] |CMD echo arg\ \"with\t\nspaces |ENTRYPOINT ["echo", "arg \"with\t\nspaces"] - |ENTRYPOINT echo arg\ \"with\t\nspaces""".stripMargin + |ENTRYPOINT echo arg\ \"with\t\nspaces + |HEALTHCHECK CMD ["echo", "arg \"with\t\nspaces"] + |HEALTHCHECK CMD echo arg\ \"with\t\nspaces""".stripMargin } test("Add and copy a file to /") { @@ -149,6 +168,8 @@ class DockerfileLikeSuite extends FunSuite with Matchers { .entryPointShell() .volume() .maintainer("test") + .healthCheck() + .healthCheckShell() dockerfile shouldEqual immutable.Dockerfile.empty.maintainer("test") } diff --git a/src/test/scala/sbtdocker/InstructionsSpec.scala b/src/test/scala/sbtdocker/InstructionsSpec.scala index 5f8055c..d324484 100644 --- a/src/test/scala/sbtdocker/InstructionsSpec.scala +++ b/src/test/scala/sbtdocker/InstructionsSpec.scala @@ -3,6 +3,8 @@ package sbtdocker import org.scalatest.{FlatSpec, Matchers} import sbtdocker.Instructions._ +import scala.concurrent.duration._ + class InstructionsSpec extends FlatSpec with Matchers { "From" should "create a correct string" in { From("image").toString shouldEqual "FROM image" @@ -78,6 +80,24 @@ class InstructionsSpec extends FlatSpec with Matchers { OnBuild(Run.exec(Seq("echo", "123"))).toString shouldEqual "ONBUILD RUN [\"echo\", \"123\"]" } + "HealthCheck" should "create a correct string with exec format" in { + HealthCheck.exec(Seq("cmd", "arg"), interval = Some(20.seconds), timeout = Some(10.seconds), + startPeriod = Some(1.second), retries = Some(3)).toString shouldEqual + "HEALTHCHECK --interval=20s --timeout=10s --start-period=1s --retries=3 CMD [\"cmd\", \"arg\"]" + HealthCheck.exec(Seq("1 \t3", "\"ö'\\a"), None, None, None, None).toString shouldEqual "HEALTHCHECK CMD [\"1 \\t3\", \"\\\"\\u00F6'\\\\a\"]" + } + + it should "create a correct string with shell format" in { + HealthCheck.shell(Seq("cmd", "arg"), interval = Some(20.seconds), timeout = Some(10.seconds), + startPeriod = Some(1.second), retries = Some(3)).toString shouldEqual + "HEALTHCHECK --interval=20s --timeout=10s --start-period=1s --retries=3 CMD cmd arg" + HealthCheck.shell(Seq("1 \t3", "\"ö'\\a"), None, None, None, None).toString shouldEqual "HEALTHCHECK CMD 1\\ \\t3 \\\"ö'\\a" + } + + it should "create a correct string for NONE parameter" in { + HealthCheck.none().toString shouldEqual "HEALTHCHECK NONE" + } + "Label" should "create a correct label string" in { Label("foo", "bar").toString shouldEqual """LABEL foo="bar"""" Label("com.example.bar", "foo").toString shouldEqual """LABEL com.example.bar="foo"""" diff --git a/src/test/scala/sbtdocker/immutable/ImmutableDockerfileSpec.scala b/src/test/scala/sbtdocker/immutable/ImmutableDockerfileSpec.scala index c41b228..71852e3 100644 --- a/src/test/scala/sbtdocker/immutable/ImmutableDockerfileSpec.scala +++ b/src/test/scala/sbtdocker/immutable/ImmutableDockerfileSpec.scala @@ -7,6 +7,8 @@ import sbt.file import sbtdocker.ImageName import sbtdocker.staging.CopyFile +import scala.concurrent.duration._ + class ImmutableDockerfileSpec extends FlatSpec with Matchers { import sbtdocker.Instructions._ @@ -49,6 +51,11 @@ class ImmutableDockerfileSpec extends FlatSpec with Matchers { .user("marcus") .workDir("/srv") .onBuild(Run.exec(Seq("echo", "text"))) + .healthCheck(Seq("healthcheck.sh", "1"), interval = Some(20.seconds), timeout = Some(10.seconds), + startPeriod = Some(1.second), retries = Some(3)) + .healthCheckShell(Seq("healthcheck.sh", "2"), interval = Some(20.seconds), timeout = Some(10.seconds), + startPeriod = Some(1.second), retries = Some(3)) + .healthCheckNone() val instructions = Seq( From("image"), @@ -72,7 +79,12 @@ class ImmutableDockerfileSpec extends FlatSpec with Matchers { Volume("/srv"), User("marcus"), WorkDir("/srv"), - OnBuild(Run.exec(Seq("echo", "text")))) + OnBuild(Run.exec(Seq("echo", "text"))), + HealthCheck.exec(Seq("healthcheck.sh", "1"), interval = Some(20.seconds), timeout = Some(10.seconds), + startPeriod = Some(1.second), retries = Some(3)), + HealthCheck.shell(Seq("healthcheck.sh", "2"), interval = Some(20.seconds), timeout = Some(10.seconds), + startPeriod = Some(1.second), retries = Some(3)), + HealthCheckNone) dockerfile.instructions should contain theSameElementsInOrderAs instructions } diff --git a/src/test/scala/sbtdocker/mutable/MutableDockerfileSpec.scala b/src/test/scala/sbtdocker/mutable/MutableDockerfileSpec.scala index 8eff09c..f1651fe 100644 --- a/src/test/scala/sbtdocker/mutable/MutableDockerfileSpec.scala +++ b/src/test/scala/sbtdocker/mutable/MutableDockerfileSpec.scala @@ -5,6 +5,8 @@ import sbt._ import sbtdocker.ImageName import sbtdocker.staging.CopyFile +import scala.concurrent.duration._ + class MutableDockerfileSpec extends FlatSpec with Matchers { import sbtdocker.Instructions._ @@ -48,6 +50,11 @@ class MutableDockerfileSpec extends FlatSpec with Matchers { user("marcus") workDir("/srv") onBuild(Run.exec(Seq("echo", "text"))) + healthCheck(Seq("healthcheck.sh", "1"), interval = Some(20.seconds), timeout = Some(10.seconds), + startPeriod = Some(1.second), retries = Some(3)) + healthCheckShell(Seq("healthcheck.sh", "2"), interval = Some(20.seconds), timeout = Some(10.seconds), + startPeriod = Some(1.second), retries = Some(3)) + healthCheckNone() } val instructions = Seq( @@ -72,7 +79,12 @@ class MutableDockerfileSpec extends FlatSpec with Matchers { Volume("/srv"), User("marcus"), WorkDir("/srv"), - OnBuild(Run.exec(Seq("echo", "text")))) + OnBuild(Run.exec(Seq("echo", "text"))), + HealthCheck.exec(Seq("healthcheck.sh", "1"), interval = Some(20.seconds), timeout = Some(10.seconds), + startPeriod = Some(1.second), retries = Some(3)), + HealthCheck.shell(Seq("healthcheck.sh", "2"), interval = Some(20.seconds), timeout = Some(10.seconds), + startPeriod = Some(1.second), retries = Some(3)), + HealthCheckNone) dockerfile.instructions should contain theSameElementsInOrderAs instructions }