Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Runtime healthcheck definition support #1736

Draft
wants to merge 18 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
68dda03
style(config): fix test styling for HealthCheckConfiguration and mino…
poikilotherm Dec 12, 2023
80550cb
feat(config): extend HealthCheckConfiguration with duration parser
poikilotherm Dec 12, 2023
510fdd2
feat(config): extend HealthCheckMode with new shell mode
poikilotherm Dec 12, 2023
3ba7023
feat(config): extend HealthCheckConfiguration validation with sanity …
poikilotherm Dec 12, 2023
75ee5bd
feat(config): enable provisioning healthchecks via <run><healthCheck>…
poikilotherm Dec 14, 2023
ce2901a
feat(config): validate healthcheck passed in run config #1733
poikilotherm Dec 14, 2023
e3f393d
feat(config): require Docker API 1.24+ when adding <healthCheck> to <…
poikilotherm Dec 14, 2023
7957d09
feat(config): wire healthcheck to Docker Create Container API wrapper…
poikilotherm Dec 14, 2023
d6f0a33
feat(assembly): ensure users do not use healthcheck shell mode at bui…
poikilotherm Dec 14, 2023
35ade1a
feat(config): add runtime healthcheck mode inherit
poikilotherm Dec 25, 2023
71afdd5
feat(config): different healthcheck default modes for build and runtime
poikilotherm Dec 25, 2023
e3c8468
style(config): address small coding issues detected by Sonarcloud
poikilotherm Dec 25, 2023
c0f24de
feat(config): add more validations to HealthCheckConfiguration
poikilotherm Dec 25, 2023
d7da526
fix(config): make HealthCheckConfiguration.DurationParser comply with…
poikilotherm Jan 8, 2024
87667a3
fix(config): repair testing for DockerFileBuilder
poikilotherm Jan 8, 2024
d629ffe
fix(config): use builder to create empty HealthCheckConfiguration due…
poikilotherm Jan 8, 2024
937a731
fix(config): re-enable serialization of HealthCheckConfiguration
poikilotherm Jan 8, 2024
a809bed
refactor(config): make ConfigHelper add image name when config valida…
poikilotherm Jan 8, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ public ContainerCreateConfig hostConfig(ContainerHostConfig startConfig) {
public ContainerCreateConfig networkingConfig(ContainerNetworkingConfig networkingConfig) {
return add("NetworkingConfig", networkingConfig.toJsonObject());
}

public ContainerCreateConfig healthCheck(ContainerHealthCheckConfig healthCheckConfig) {
return add("Healthcheck", healthCheckConfig.toJsonObject());
}

/**
* Get JSON which is used for <em>creating</em> a container
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package io.fabric8.maven.docker.access;

import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.fabric8.maven.docker.config.HealthCheckConfiguration;
import io.fabric8.maven.docker.config.HealthCheckConfiguration.DurationParser;

public class ContainerHealthCheckConfig {

private final JsonObject healthcheck = new JsonObject();

public ContainerHealthCheckConfig(HealthCheckConfiguration configuration) {
JsonArray test = new JsonArray();
switch (configuration.getMode()) {
case none:
test.add("NONE");
break;
case cmd:
test.add("CMD");
for (String arg : configuration.getCmd().asStrings()) {
test.add(arg);
}
break;
case shell:
test.add("CMD-SHELL");
test.add(configuration.getCmd().getShell());
break;
case inherit:
// case inherit can be ignored here - equivalent to an empty JSON array
}
this.healthcheck.add("Test", test);

if (configuration.getInterval() != null) {
this.healthcheck.addProperty("Interval", DurationParser.parseDuration(configuration.getInterval()).toNanos());
}
if (configuration.getTimeout() != null) {
this.healthcheck.addProperty("Timeout", DurationParser.parseDuration(configuration.getTimeout()).toNanos());
}
if (configuration.getStartPeriod() != null) {
this.healthcheck.addProperty("StartPeriod", DurationParser.parseDuration(configuration.getStartPeriod()).toNanos());
}
if (configuration.getRetries() != null) {
this.healthcheck.addProperty("Retries", configuration.getRetries());
}
}

public String toJson() {
return healthcheck.toString();
}

public JsonObject toJsonObject() {
return healthcheck;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import com.google.common.base.Joiner;
import io.fabric8.maven.docker.config.Arguments;
import io.fabric8.maven.docker.config.BuildImageConfiguration;
import io.fabric8.maven.docker.config.HealthCheckConfiguration;

import org.codehaus.plexus.util.FileUtils;
Expand Down Expand Up @@ -134,20 +135,24 @@ private void addUser(StringBuilder b) {
private void addHealthCheck(StringBuilder b) {
if (healthCheck != null) {
StringBuilder healthString = new StringBuilder();


// Context is image building, thus default to Dockerfile CMD mode (unequal to runtime version!)
// Note: usually done via BuildImageConfiguration.initAndValidate(), but not with low-level unit tests.
healthCheck.setModeIfNotPresent(BuildImageConfiguration.HC_BUILDTIME_DEFAULT);

switch (healthCheck.getMode()) {
case cmd:
buildOption(healthString, DockerFileOption.HEALTHCHECK_INTERVAL, healthCheck.getInterval());
buildOption(healthString, DockerFileOption.HEALTHCHECK_TIMEOUT, healthCheck.getTimeout());
buildOption(healthString, DockerFileOption.HEALTHCHECK_START_PERIOD, healthCheck.getStartPeriod());
buildOption(healthString, DockerFileOption.HEALTHCHECK_RETRIES, healthCheck.getRetries());
buildArguments(healthString, DockerFileKeyword.CMD, false, healthCheck.getCmd());
break;
case none:
DockerFileKeyword.NONE.addTo(healthString, false);
break;
default:
throw new IllegalArgumentException("Unsupported health check mode: " + healthCheck.getMode());
case cmd:
buildOption(healthString, DockerFileOption.HEALTHCHECK_INTERVAL, healthCheck.getInterval());
buildOption(healthString, DockerFileOption.HEALTHCHECK_TIMEOUT, healthCheck.getTimeout());
buildOption(healthString, DockerFileOption.HEALTHCHECK_START_PERIOD, healthCheck.getStartPeriod());
buildOption(healthString, DockerFileOption.HEALTHCHECK_RETRIES, healthCheck.getRetries());
buildArguments(healthString, DockerFileKeyword.CMD, false, healthCheck.getCmd());
break;
case none:
DockerFileKeyword.NONE.addTo(healthString, false);
break;
default:
throw new IllegalArgumentException("Unsupported build time health check mode: " + healthCheck.getMode() + " - use 'cmd' or 'none'");
}

DockerFileKeyword.HEALTHCHECK.addTo(b, healthString.toString());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,8 @@ public BuildImageConfiguration build() {
return config;
}
}

public static final HealthCheckMode HC_BUILDTIME_DEFAULT = HealthCheckMode.cmd;

public String initAndValidate(Logger log) throws IllegalArgumentException {
if (entryPoint != null) {
Expand All @@ -762,6 +764,8 @@ public String initAndValidate(Logger log) throws IllegalArgumentException {
cmd.validate();
}
if (healthCheck != null) {
// Context is image building, thus default to Dockerfile CMD mode (unequal to runtime version!)
healthCheck.setModeIfNotPresent(HC_BUILDTIME_DEFAULT);
healthCheck.validate();
}

Expand Down
10 changes: 9 additions & 1 deletion src/main/java/io/fabric8/maven/docker/config/ConfigHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,20 @@ public static String getExternalConfigActivationProperty(MavenProject project) {
* @param nameFormatter formatter for image names
* @param log a logger for printing out diagnostic messages
* @return the minimal API Docker API required to be used for the given configuration.
* @throws IllegalArgumentException When an image validation fails, the thrown exception will contain
* the image's name as its message and wrap the underlying exception as cause.
*/
public static String initAndValidate(List<ImageConfiguration> images, String apiVersion, NameFormatter nameFormatter,
Logger log) {
// Init and validate configs. After this step, getResolvedImages() contains the valid configuration.
for (ImageConfiguration imageConfiguration : images) {
apiVersion = EnvUtil.extractLargerVersion(apiVersion, imageConfiguration.initAndValidate(nameFormatter, log));
try {
apiVersion = EnvUtil.extractLargerVersion(apiVersion, imageConfiguration.initAndValidate(nameFormatter, log));
} catch (IllegalArgumentException e) {
// Wrap the underlying validation and add the image's name/alias.
// Maven will properly unpack the wrapped exception.
throw new IllegalArgumentException(imageConfiguration.getName(), e);
}
}
return apiVersion;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package io.fabric8.maven.docker.config;

import org.apache.commons.lang3.StringUtils;

import java.io.Serializable;
import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Build configuration for health checks.
*/
public class HealthCheckConfiguration implements Serializable {

private HealthCheckMode mode = HealthCheckMode.cmd;
// Default values are applied differently in build or runtime context, no default here
private HealthCheckMode mode;

private String interval;

Expand All @@ -19,6 +25,7 @@ public class HealthCheckConfiguration implements Serializable {

private Arguments cmd;

// This constructor must remain "public" as this class is deserialized from XML config
public HealthCheckConfiguration() {}

public String getInterval() {
Expand Down Expand Up @@ -48,7 +55,19 @@ public Arguments getCmd() {
public HealthCheckMode getMode() {
return mode;
}


/**
* Use this method to apply a default mode depending on context (build or runtime)
* @param mode The default mode to set
* @return The configuration, making the call chainable
*/
public HealthCheckConfiguration setModeIfNotPresent(HealthCheckMode mode) {
if (this.mode == null) {
this.mode = mode;
}
return this;
}

public Integer getRetries() {
return retries;
}
Expand All @@ -59,23 +78,59 @@ public void validate() throws IllegalArgumentException {
}

switch(mode) {
case none:
if (interval != null || timeout != null || startPeriod != null || retries != null || cmd != null) {
throw new IllegalArgumentException("HealthCheck: no parameters are allowed when the health check mode is set to 'none'");
}
break;
case cmd:
if (cmd == null) {
throw new IllegalArgumentException("HealthCheck: the parameter 'cmd' is mandatory when the health check mode is set to 'cmd' (default)");
}
case none:
if (interval != null || timeout != null || startPeriod != null || retries != null || cmd != null) {
throw new IllegalArgumentException("HealthCheck: no parameters are allowed when the health check mode is set to 'none'");
}
break;
case cmd:
case shell:
if (cmd == null) {
throw new IllegalArgumentException("HealthCheck: parameter 'cmd' is mandatory for mode set to 'cmd' (default for builds) or 'shell'");
}
// cmd.getExec() == null can be ignored here - we will simply parse the string into arguments
if (mode == HealthCheckMode.shell && cmd.getShell() == null) {
throw new IllegalArgumentException("HealthCheck: parameter 'cmd' for mode 'shell' must be given as one string, not arguments");
}
// Now fallthrough to mode inherit (which has needs the same validations for options, but not the test)
case inherit:
if (retries != null && retries < 0) {
throw new IllegalArgumentException("HealthCheck: the parameter 'retries' may not be negative");
}
if (interval != null && ! DurationParser.matchesDuration(interval)) {
throw new IllegalArgumentException("HealthCheck: illegal duration specified for interval");
}
if (timeout != null && ! DurationParser.matchesDuration(timeout)) {
throw new IllegalArgumentException("HealthCheck: illegal duration specified for timeout");
}
if (startPeriod != null && ! DurationParser.matchesDuration(startPeriod)) {
throw new IllegalArgumentException("HealthCheck: illegal duration specified for start period");
}
// Must limit check to inherit *again* because shell and cmd fall through to this case!
if (mode == HealthCheckMode.inherit && cmd != null) {
throw new IllegalArgumentException("HealthCheck: parameter 'cmd' not allowed for mode set to 'inherit'");
}
break;
}
}


@Override
public String toString() {
return "HealthCheckConfiguration{" +
"mode=" + mode +
", interval='" + interval + '\'' +
", timeout='" + timeout + '\'' +
", startPeriod='" + startPeriod + '\'' +
", retries=" + retries +
", cmd=" + cmd +
'}';
}

// ===========================================

public static class Builder {

private HealthCheckConfiguration config = new HealthCheckConfiguration();
private final HealthCheckConfiguration config;

public Builder() {
this.config = new HealthCheckConfiguration();
Expand Down Expand Up @@ -109,7 +164,7 @@ public Builder retries(Integer retries) {
}

public Builder mode(String mode) {
return this.mode(mode != null ? HealthCheckMode.valueOf(mode) : (HealthCheckMode) null);
return this.mode(mode != null ? HealthCheckMode.valueOf(mode) : null);
}

public Builder mode(HealthCheckMode mode) {
Expand All @@ -121,4 +176,77 @@ public HealthCheckConfiguration build() {
return config;
}
}

public static final class DurationParser {

// No instances allowed
private DurationParser() {}

/**
* This complex regex allows duration in the special Docker format,
* which is not ISO-8601 compatible, and thus not parseable directly.
* (For example, it does not allow using days or even longer periods)
* @implSpec See <a href="https://docs.docker.com/compose/compose-file/compose-file-v2/#specifying-durations">Docker Compose durations</a> for supported duration formats.
* <a href="https://docs.docker.com/engine/reference/builder/#healthcheck">Dockerfile HEALTHCHECK</a> has only very limited specification about allowed duration formatting.
* @implNote Note that the Docker API requires nanosecond precision (int64/long).
* A conversion is easily done using {@link Duration#toNanos()}.
* Examples of allowed values: 23h17m1s, 10ms, 1s, 0h10ms, 1h2m1.3432s
*/
@SuppressWarnings("java:S5843")
private static final String DURATION_REGEX = "^((?<hours>0\\d|1\\d|2[0-3]|\\d)h)?((?<mins>[0-5]?\\d)m)?(((?<secs>[0-5]?\\d)s)?((?<msecs>\\d{1,3})ms)?((?<usecs>\\d{1,3})us)?|(?<fsecs>[0-5]?\\d)\\.(?<fraction>\\d{1,9})s)$";
private static final Matcher durationMatcher = Pattern.compile(DURATION_REGEX).matcher("");

public static boolean matchesDuration(String durationString) {
if (durationString == null || durationString.isEmpty()) {
return false;
}
return durationMatcher.reset(durationString).matches() || durationString.equals("0");
}

public static Duration parseDuration(String durationString) {
if (durationString == null || durationString.isEmpty()) {
return null;
}

if (durationString.equals("0")) {
return Duration.ZERO;
}

if (durationMatcher.reset(durationString).matches()) {
Duration duration = Duration.ZERO;
// Add hours
if (durationMatcher.group("hours") != null) {
duration = duration.plusHours(Long.parseLong(durationMatcher.group("hours")));
}
// Add minutes
if (durationMatcher.group("mins") != null) {
duration = duration.plusMinutes(Long.parseLong(durationMatcher.group("mins")));
}
// When seconds are given as an (optional) fraction
if (durationMatcher.group("fsecs") != null) {
duration = duration.plusSeconds(Long.parseLong(durationMatcher.group("fsecs")));

String fraction = durationMatcher.group("fraction");
// Append enough zeros to make it nanosecond precision, then tune the duration
fraction += StringUtils.repeat("0", 9 - fraction.length());
duration = duration.plusNanos(Long.parseLong(fraction));
} else {
// Add seconds
if (durationMatcher.group("secs") != null) {
duration = duration.plusSeconds(Long.parseLong(durationMatcher.group("secs")));
}
// Add milliseconds
if (durationMatcher.group("msecs") != null) {
duration = duration.plusMillis(Long.parseLong(durationMatcher.group("msecs")));
}
// Add microseconds (make them fake nanoseconds first, as Duration does not support adding micros)
if (durationMatcher.group("usecs") != null) {
duration = duration.plusNanos(Long.parseLong(durationMatcher.group("usecs") + "000"));
}
}
return duration;
}
return null;
}
}
}
18 changes: 16 additions & 2 deletions src/main/java/io/fabric8/maven/docker/config/HealthCheckMode.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
package io.fabric8.maven.docker.config;


@SuppressWarnings("java:S115")
public enum HealthCheckMode {

/**
* Mainly used to disable any health check provided by the base image.
* This mode is supported at build and run time.
*/
none,

/**
* A command based health check.
* A command-based health check.
* This mode is supported at build and run time.
*/
cmd;
cmd,

/**
* A shell-wrapped command-based health check.
* This mode is supported at runtime only.
*/
shell,

/**
* Runtime-only mode, used to change options, but not the test itself
*/
inherit

}
Loading
Loading