From 3a29e420b75ca401f2a3ff3753c3aaa9861c1f41 Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Tue, 28 Nov 2023 23:03:14 -0500 Subject: [PATCH 1/2] ISSUE-1204: Add HEALTHCHECK instruction for Dockerfiles --- .../image/DockerfileFunctionalTest.groovy | 6 + .../gradle/docker/tasks/image/Dockerfile.java | 227 +++++++++++++++++- .../docker/tasks/image/DockerfileTest.groovy | 13 +- 3 files changed, 241 insertions(+), 5 deletions(-) diff --git a/src/functTest/groovy/com/bmuschko/gradle/docker/tasks/image/DockerfileFunctionalTest.groovy b/src/functTest/groovy/com/bmuschko/gradle/docker/tasks/image/DockerfileFunctionalTest.groovy index e8ea0172c..c6942813d 100644 --- a/src/functTest/groovy/com/bmuschko/gradle/docker/tasks/image/DockerfileFunctionalTest.groovy +++ b/src/functTest/groovy/com/bmuschko/gradle/docker/tasks/image/DockerfileFunctionalTest.groovy @@ -155,6 +155,7 @@ LABEL maintainer=benjamin.muschko@gmail.com workingDir('/tmp') onBuild('RUN echo "Hello World"') instruction('LABEL env=prod') + healthcheck(new Dockerfile.Healthcheck('/bin/check-running')) } """ @@ -180,6 +181,7 @@ USER root WORKDIR /tmp ONBUILD RUN echo "Hello World" LABEL env=prod +HEALTHCHECK CMD /bin/check-running """) } @@ -202,6 +204,7 @@ LABEL env=prod workingDir(project.provider { '/path/to/workdir' }) onBuild(project.provider { 'ADD . /app/src' }) instruction(project.provider { 'LABEL env=prod' }) + healthcheck(project.provider { new Dockerfile.Healthcheck('/bin/check-running') }) } """ @@ -224,6 +227,7 @@ USER patrick WORKDIR /path/to/workdir ONBUILD ADD . /app/src LABEL env=prod +HEALTHCHECK CMD /bin/check-running """) } @@ -233,6 +237,7 @@ LABEL env=prod task ${DOCKERFILE_TASK_NAME}(type: Dockerfile) { instructions.add(new Dockerfile.FromInstruction(new Dockerfile.From('$TEST_IMAGE_WITH_TAG'))) instructions.add(new Dockerfile.LabelInstruction(['maintainer': 'benjamin.muschko@gmail.com'])) + instructions.add(new Dockerfile.HealthcheckInstruction(new Dockerfile.Healthcheck('/bin/check-running'))) } """ @@ -242,6 +247,7 @@ LABEL env=prod then: assertDockerfileContent("""FROM $TEST_IMAGE_WITH_TAG LABEL maintainer=benjamin.muschko@gmail.com +HEALTHCHECK CMD /bin/check-running """) } diff --git a/src/main/java/com/bmuschko/gradle/docker/tasks/image/Dockerfile.java b/src/main/java/com/bmuschko/gradle/docker/tasks/image/Dockerfile.java index 29a61b523..fd1a46a95 100644 --- a/src/main/java/com/bmuschko/gradle/docker/tasks/image/Dockerfile.java +++ b/src/main/java/com/bmuschko/gradle/docker/tasks/image/Dockerfile.java @@ -33,11 +33,13 @@ import org.gradle.api.tasks.OutputFile; import org.gradle.api.tasks.TaskAction; +import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.io.FileNotFoundException; import java.io.IOException; import java.io.PrintWriter; import java.nio.file.Files; +import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -296,7 +298,7 @@ public void from(String image) { * FROM ubuntu:14.04 * * - * @param from From definition + * @param from From definition * @see #from(String) * @see #from(Provider) */ @@ -1104,6 +1106,62 @@ public void label(Provider> provider) { instructions.add(new LabelInstruction(provider)); } + /** + * The HEALTHCHECK instruction tells + * Docker how to test a container to check that it is still working. + * + *

+ * Example in Groovy DSL: + *

+ *

+     * task createDockerfile(type: Dockerfile) {
+     *     healthcheck(new Healthcheck("curl -f http://localhost/ || exit 1").withRetries(5))
+     * }
+     * 
+ * The produced instruction looks as follows: + *

+ *

+     * HEALTHCHECK --retries=5 CMD curl -f http://localhost/ || exit 1
+     * 
+ * + * @param healthcheck the healthcheck configuration + * @see #healthcheck(Provider) + * @see Healthcheck + */ + public void healthcheck(Healthcheck healthcheck) { + instructions.add(new HealthcheckInstruction(healthcheck)); + } + + /** + * The HEALTHCHECK instruction tells + * Docker how to test a container to check that it is still working. + * + *

+ * Example in Groovy DSL: + *

+ *

+     * task createDockerfile(type: Dockerfile) {
+     *     from(project.provider(new Callable<Dockerfile.Healthcheck>() {
+     *         {@literal @}Override
+     *         Dockerfile.Healthcheck call() throws Exception {
+     *             new Dockerfile.Healthcheck("curl -f http://localhost/ || exit 1")
+     *         }
+     *     }))
+     * }
+     * 
+ * The produced instruction looks as follows: + *

+ *

+     * HEALTHCHECK CMD curl -f http://localhost/ || exit 1
+     * 
+ * + * @param provider Healthcheck information as Provider + * @see #healthcheck(Healthcheck) + */ + public void healthcheck(Provider provider) { + instructions.add(new HealthcheckInstruction(provider)); + } + /** * A representation of an instruction in a Dockerfile. */ @@ -1243,7 +1301,7 @@ private interface ItemJoiner { private static class MultiItemJoiner implements ItemJoiner { @Override public String join(Map map) { - return map.entrySet().stream().map( entry -> { + return map.entrySet().stream().map(entry -> { String key = ItemJoinerUtil.isUnquotedStringWithWhitespaces(entry.getKey()) ? ItemJoinerUtil.toQuotedString(entry.getKey()) : entry.getKey(); String value = ItemJoinerUtil.isUnquotedStringWithWhitespaces(entry.getValue()) ? ItemJoinerUtil.toQuotedString(entry.getValue()) : entry.getValue(); value = value.replaceAll("(\r)*\n", "\\\\\n"); @@ -1296,7 +1354,7 @@ public String getText() { private void validateKeysAreNotBlank(Map command) throws IllegalArgumentException { command.entrySet().forEach(entry -> { - if (entry.getKey().trim().length() == 0) { + if (entry.getKey().trim().isEmpty()) { throw new IllegalArgumentException("blank keys for a key=value pair are not allowed: please check instruction " + getKeyword() + " and given pair `" + String.valueOf(entry) + "`"); } }); @@ -1756,6 +1814,59 @@ public String getKeyword() { } } + public static class HealthcheckInstruction implements Instruction { + + public static final String KEYWORD = "HEALTHCHECK"; + + private final Provider provider; + + public HealthcheckInstruction(Healthcheck healthcheck) { + this.provider = Providers.ofNullable(healthcheck); + } + + public HealthcheckInstruction(Provider provider) { + this.provider = provider; + } + + @Nullable + @Override + public String getKeyword() { + return KEYWORD; + } + + @Nullable + @Override + public String getText() { + return buildTextInstruction(provider.getOrNull()); + } + + private String buildTextInstruction(Healthcheck healthcheck) { + if (healthcheck != null) { + StringBuilder result = new StringBuilder(getKeyword()); + if (healthcheck.getInterval() != null) { + result.append(" --interval=").append(healthcheck.getInterval().toSeconds()).append("s"); + } + if (healthcheck.getTimeout() != null) { + result.append(" --timeout=").append(healthcheck.getTimeout().toSeconds()).append("s"); + } + if (healthcheck.getStartPeriod() != null) { + result.append(" --start-period=").append(healthcheck.getStartPeriod().toSeconds()).append("s"); + } + if (healthcheck.getStartInterval() != null) { + result.append(" --start-interval=").append(healthcheck.getStartInterval().toSeconds()).append("s"); + } + + if (healthcheck.getRetries() != null) { + result.append(" --retries=").append(healthcheck.getRetries()); + } + + result.append(" CMD ").append(healthcheck.getCmd()); + return result.toString(); + } + return null; + } + } + /** * Input data for a {@link AddFileInstruction} or {@link CopyFileInstruction}. * @@ -1916,4 +2027,114 @@ public String getPlatform() { return platform; } } + + /** + * Input data for a {@link HealthcheckInstruction}. + * + * @see Dockerfile reference / HEALTHCHECK. + * @since ??? + */ + public static class Healthcheck { + @Nullable + private Duration interval; + @Nullable + private Duration timeout; + @Nullable + private Duration startPeriod; + @Nullable + private Duration startInterval = null; + @Nullable + private Integer retries; + @Nonnull + private final String cmd; + + public Healthcheck(@Nonnull String cmd) { + this.cmd = cmd; + } + + /** + * Sets the healthcheck interval by adding {@code --interval} to Healthcheck instruction. + * + * @param interval a {@link Duration} in seconds. + * @return this healthcheck. + */ + public Healthcheck withInterval(Duration interval) { + this.interval = interval; + return this; + } + + /** + * Sets the healthcheck timeout by adding {@code --timeout} to Healthcheck instruction. + * + * @param timeout a {@link Duration} in seconds. + * @return this healthcheck. + */ + public Healthcheck withTimeout(Duration timeout) { + this.timeout = timeout; + return this; + } + + /** + * Sets the healthcheck startPeriod by adding {@code --start-period} to Healthcheck instruction. + * + * @param startPeriod a {@link Duration} in seconds. + * @return this healthcheck. + */ + public Healthcheck withStartPeriod(Duration startPeriod) { + this.startPeriod = startPeriod; + return this; + } + + /** + * This option requires Docker Engine version 25.0 or later. + * Sets the healthcheck startInterval by adding {@code --start-interval} to Healthcheck instruction. + * + * @param startInterval a {@link Duration} in seconds. + * @return this healthcheck. + */ + public Healthcheck withStartInterval(@Nullable Duration startInterval) { + this.startInterval = startInterval; + return this; + } + + /** + * Sets the healthcheck number of retries by adding {@code --retries} to Healthcheck instruction. + * + * @param retries the number of retries. Must be greater than 0, or it will fallback to the default (3). + * @return this healthcheck. + */ + public Healthcheck withRetries(int retries) { + this.retries = retries; + return this; + } + + @Nullable + public Duration getInterval() { + return interval; + } + + @Nullable + public Duration getTimeout() { + return timeout; + } + + @Nullable + public Duration getStartPeriod() { + return startPeriod; + } + + @Nullable + public Duration getStartInterval() { + return startInterval; + } + + @Nullable + public Integer getRetries() { + return retries; + } + + public String getCmd() { + return cmd; + } + } } diff --git a/src/test/groovy/com/bmuschko/gradle/docker/tasks/image/DockerfileTest.groovy b/src/test/groovy/com/bmuschko/gradle/docker/tasks/image/DockerfileTest.groovy index 118b13de3..e7ef4f8b2 100644 --- a/src/test/groovy/com/bmuschko/gradle/docker/tasks/image/DockerfileTest.groovy +++ b/src/test/groovy/com/bmuschko/gradle/docker/tasks/image/DockerfileTest.groovy @@ -7,6 +7,7 @@ import java.util.logging.Level import java.util.logging.Logger import static com.bmuschko.gradle.docker.tasks.image.Dockerfile.* +import static java.time.Duration.ofSeconds class DockerfileTest extends Specification { private static final Logger LOG = Logger.getLogger(DockerfileTest.class.getCanonicalName()) @@ -55,11 +56,11 @@ class DockerfileTest extends Specification { new EnvironmentVariableInstruction(' ', 'Linux') | 'ENV' | IllegalArgumentException.class new EnvironmentVariableInstruction('OS', '"Linux"') | 'ENV' | 'ENV OS="Linux"' new EnvironmentVariableInstruction('OS', 'Linux or Windows') | 'ENV' | 'ENV OS="Linux or Windows"' - new EnvironmentVariableInstruction('long', '''Multiple line env + new EnvironmentVariableInstruction('long', '''Multiple line env with linebreaks in between''') | 'ENV' | "ENV long=\"Multiple line env \\\n\ with linebreaks in between\"" new EnvironmentVariableInstruction(['OS': 'Linux']) | 'ENV' | 'ENV OS=Linux' - new EnvironmentVariableInstruction(['long': '''Multiple line env + new EnvironmentVariableInstruction(['long': '''Multiple line env with linebreaks in between''']) | 'ENV' | "ENV long=\"Multiple line env \\\n\ with linebreaks in between\"" new EnvironmentVariableInstruction(['OS': 'Linux', 'TZ': 'UTC']) | 'ENV' | 'ENV OS=Linux TZ=UTC' @@ -74,5 +75,13 @@ with linebreaks in between\"" new LabelInstruction(['description': 'Single label' ]) | 'LABEL' | 'LABEL description="Single label"' new LabelInstruction(['"un subscribe"': 'true' ]) | 'LABEL' | 'LABEL "un subscribe"=true' new LabelInstruction(['description': 'Multiple labels', 'version': '1.0' ]) | 'LABEL' | 'LABEL description="Multiple labels" version=1.0' + new HealthcheckInstruction(new Healthcheck("/bin/check-running")) | 'HEALTHCHECK' | 'HEALTHCHECK CMD /bin/check-running' + new HealthcheckInstruction(new Healthcheck("/bin/check-running").withInterval(ofSeconds(10))) | 'HEALTHCHECK' | 'HEALTHCHECK --interval=10s CMD /bin/check-running' + new HealthcheckInstruction(new Healthcheck("/bin/check-running").withTimeout(ofSeconds(20))) | 'HEALTHCHECK' | 'HEALTHCHECK --timeout=20s CMD /bin/check-running' + new HealthcheckInstruction(new Healthcheck("/bin/check-running") + .withStartInterval(ofSeconds(30))) | 'HEALTHCHECK' | 'HEALTHCHECK --start-interval=30s CMD /bin/check-running' + new HealthcheckInstruction(new Healthcheck("/bin/check-running") + .withStartPeriod(ofSeconds(40))) | 'HEALTHCHECK' | 'HEALTHCHECK --start-period=40s CMD /bin/check-running' + new HealthcheckInstruction(new Healthcheck("/bin/check-running").withRetries(5)) | 'HEALTHCHECK' | 'HEALTHCHECK --retries=5 CMD /bin/check-running' } } From 4e22552bdced11881c667988cd63b99e84b665b6 Mon Sep 17 00:00:00 2001 From: Marcos Pereira Date: Tue, 28 Nov 2023 23:10:37 -0500 Subject: [PATCH 2/2] Fix reformating issue --- .../bmuschko/gradle/docker/tasks/image/DockerfileTest.groovy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/groovy/com/bmuschko/gradle/docker/tasks/image/DockerfileTest.groovy b/src/test/groovy/com/bmuschko/gradle/docker/tasks/image/DockerfileTest.groovy index e7ef4f8b2..19f78c488 100644 --- a/src/test/groovy/com/bmuschko/gradle/docker/tasks/image/DockerfileTest.groovy +++ b/src/test/groovy/com/bmuschko/gradle/docker/tasks/image/DockerfileTest.groovy @@ -56,11 +56,11 @@ class DockerfileTest extends Specification { new EnvironmentVariableInstruction(' ', 'Linux') | 'ENV' | IllegalArgumentException.class new EnvironmentVariableInstruction('OS', '"Linux"') | 'ENV' | 'ENV OS="Linux"' new EnvironmentVariableInstruction('OS', 'Linux or Windows') | 'ENV' | 'ENV OS="Linux or Windows"' - new EnvironmentVariableInstruction('long', '''Multiple line env + new EnvironmentVariableInstruction('long', '''Multiple line env with linebreaks in between''') | 'ENV' | "ENV long=\"Multiple line env \\\n\ with linebreaks in between\"" new EnvironmentVariableInstruction(['OS': 'Linux']) | 'ENV' | 'ENV OS=Linux' - new EnvironmentVariableInstruction(['long': '''Multiple line env + new EnvironmentVariableInstruction(['long': '''Multiple line env with linebreaks in between''']) | 'ENV' | "ENV long=\"Multiple line env \\\n\ with linebreaks in between\"" new EnvironmentVariableInstruction(['OS': 'Linux', 'TZ': 'UTC']) | 'ENV' | 'ENV OS=Linux TZ=UTC'