diff --git a/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/Container.java b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/Container.java new file mode 100644 index 00000000..95edeefa --- /dev/null +++ b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/Container.java @@ -0,0 +1,37 @@ +package org.jboss.eap.qe.ts.common.docker; + +/** + * Describes public interface available for a container (not an Arquillian one but the operating system level + * virtualization unit). + */ +public interface Container { + + /** + * Start this container + * + * @throws ContainerStartException thrown when start of container fails + */ + void start() throws ContainerStartException; + + /** + * @return Returns true if docker container is running. It does NOT check whether container is ready. + */ + boolean isRunning(); + + /** + * Stop this docker container using docker command. + * + * @throws ContainerStopException thrown when the stop command fails. This generally means that the command wasn't + * successful and container might be still running. + */ + void stop() throws ContainerStopException; + + /** + * Kill this docker container using docker command. Be aware that there might occur a situation when the docker + * command might fail. This might be caused for example by reaching file descriptors limit in system. + * + * @throws ContainerKillException thrown when the kill command fails. + */ + void kill() throws ContainerKillException; + +} diff --git a/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerKillException.java b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerKillException.java new file mode 100644 index 00000000..f84d2266 --- /dev/null +++ b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerKillException.java @@ -0,0 +1,23 @@ +package org.jboss.eap.qe.ts.common.docker; + +/** + * An exception thrown when killing a container fails + */ +public class ContainerKillException extends Exception { + + public ContainerKillException() { + super(); + } + + public ContainerKillException(String message) { + super(message); + } + + public ContainerKillException(String message, Throwable cause) { + super(message, cause); + } + + public ContainerKillException(Throwable cause) { + super(cause); + } +} diff --git a/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerReadyConditionException.java b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerReadyConditionException.java index 62a0ac66..35e18803 100644 --- a/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerReadyConditionException.java +++ b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerReadyConditionException.java @@ -1,7 +1,12 @@ package org.jboss.eap.qe.ts.common.docker; -public class ContainerReadyConditionException extends RuntimeException { +/** + * An exception thrown when checking for container readiness fails + */ +public class ContainerReadyConditionException extends Exception { + public ContainerReadyConditionException(String message, Throwable cause) { super(message, cause); } + } diff --git a/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerRemoveException.java b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerRemoveException.java new file mode 100644 index 00000000..f8b8ccd9 --- /dev/null +++ b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerRemoveException.java @@ -0,0 +1,23 @@ +package org.jboss.eap.qe.ts.common.docker; + +/** + * An exception thrown when container removal fails + */ +public class ContainerRemoveException extends Exception { + + public ContainerRemoveException() { + super(); + } + + public ContainerRemoveException(String message) { + super(message); + } + + public ContainerRemoveException(String message, Throwable cause) { + super(message, cause); + } + + public ContainerRemoveException(Throwable cause) { + super(cause); + } +} diff --git a/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerStartException.java b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerStartException.java new file mode 100644 index 00000000..95494a3c --- /dev/null +++ b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerStartException.java @@ -0,0 +1,23 @@ +package org.jboss.eap.qe.ts.common.docker; + +/** + * An exception thrown when container start fails + */ +public class ContainerStartException extends Exception { + + public ContainerStartException() { + super(); + } + + public ContainerStartException(String message) { + super(message); + } + + public ContainerStartException(String message, Throwable cause) { + super(message, cause); + } + + public ContainerStartException(Throwable cause) { + super(cause); + } +} diff --git a/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerStopException.java b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerStopException.java new file mode 100644 index 00000000..d4c7bf2f --- /dev/null +++ b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/ContainerStopException.java @@ -0,0 +1,23 @@ +package org.jboss.eap.qe.ts.common.docker; + +/** + * An exception thrown when container stop fails + */ +public class ContainerStopException extends Exception { + + public ContainerStopException() { + super(); + } + + public ContainerStopException(String message) { + super(message); + } + + public ContainerStopException(String message, Throwable cause) { + super(message, cause); + } + + public ContainerStopException(Throwable cause) { + super(cause); + } +} diff --git a/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/Docker.java b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/Docker.java index a4759da4..d1978bc3 100644 --- a/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/Docker.java +++ b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/Docker.java @@ -3,13 +3,16 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; +import java.io.PrintStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -23,27 +26,85 @@ *

* Intended to be used as a JUnit @ClassRule. Example of usage - see org.jboss.eap.qe.ts.common.docker.DockerTest test */ -public class Docker extends ExternalResource { +public class Docker extends ExternalResource implements Container { + + private final String uuid; + private final String name; + private final String image; + private final List ports; + private final Map environmentVariables; + private final List options; + private final List commandArguments; + private final ContainerReadyCondition containerReadyCondition; + private final long containerReadyTimeout; + private final PrintStream out; + + private ExecutorService outputPrintingThread; + + private Docker(Builder builder) { + this.uuid = builder.uuid; + this.name = builder.name; + this.image = builder.image; + this.ports = builder.ports; + this.options = builder.options; + this.environmentVariables = builder.environmentVariables; + this.commandArguments = builder.commandArguments; + this.containerReadyCondition = builder.containerReadyCondition; + this.containerReadyTimeout = builder.containerReadyTimeoutInMillis; + this.out = builder.out; + } + + /** + * Start this container + * + * @throws ContainerStartException thrown when start of container fails + */ + public void start() throws ContainerStartException { + if (!isDockerPresent()) { + throw new ContainerStartException("'docker' command is not present on this machine!"); + } - private String uuid; - private String name; - private String image; - private List ports = new ArrayList<>(); - private Map environmentVariables = new HashMap<>(); - private List options = new ArrayList<>(); - private List commandArguments = new ArrayList<>(); - private ContainerReadyCondition containerReadyCondition; - private long containerReadyTimeout; - private ExecutorService outputPrinter; - private Process dockerRunProcess; + this.out.println(Ansi.ansi().reset().a("Starting container ").fgCyan().a(name).reset() + .a(" with ID ").fgYellow().a(uuid).reset()); - private Docker() { - } // avoid instantiation, use Builder + final List dockerStartCommand = composeStartCommand(); + Process dockerRunProcess; + try { + dockerRunProcess = new ProcessBuilder() + .redirectErrorStream(true) + .command(dockerStartCommand) + .start(); + } catch (IOException e) { + throw new ContainerStartException("Failed to start the '" + String.join(" ", dockerStartCommand) + "' command"); + } - public void start() throws Exception { + this.outputPrintingThread = startPrinterThread(dockerRunProcess, this.out, this.name); - checkDockerPresent(); + long startTime = System.currentTimeMillis(); + try { + while (!isContainerReady(this.uuid, this.containerReadyCondition)) { + if (System.currentTimeMillis() - startTime > containerReadyTimeout) { + stop(); + removeDockerContainer(this.uuid); + throw new ContainerStartException(uuid + " - Container was not ready in " + containerReadyTimeout + " ms"); + } + // fail fast mechanism in case of malformed docker command, for example bad arguments, invalid format of port mapping, image version,... + if (!dockerRunProcess.isAlive() && dockerRunProcess.exitValue() != 0) { + throw new ContainerStartException( + uuid + " - Starting of docker container using command: \"" + String.join(" ", dockerStartCommand) + + "\" failed. Check that provided command is correct."); + } + } + } catch (ContainerStopException e) { + throw new ContainerStartException("Unable to stop container after failed start!", e); + } catch (ContainerRemoveException e) { + throw new ContainerStartException("Unable to remove container after failed start!", e); + } catch (ContainerReadyConditionException e) { + throw new ContainerStartException("There was a problem when checking container readiness!", e); + } + } + private List composeStartCommand() { List cmd = new ArrayList<>(); cmd.add("docker"); @@ -67,85 +128,83 @@ public void start() throws Exception { cmd.addAll(commandArguments); - System.out.println(Ansi.ansi().reset().a("Starting container ").fgCyan().a(name).reset() - .a(" with ID ").fgYellow().a(uuid).reset()); + return cmd; + } - dockerRunProcess = new ProcessBuilder() - .redirectErrorStream(true) - .command(cmd) - .start(); - outputPrinter = Executors.newSingleThreadExecutor(); + private ExecutorService startPrinterThread(final Process dockerRunProcess, final PrintStream out, + final String containerName) { + final ExecutorService outputPrinter = Executors.newSingleThreadExecutor(); outputPrinter.execute(() -> { try (BufferedReader reader = new BufferedReader( new InputStreamReader(dockerRunProcess.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { - System.out.println(Ansi.ansi().fgCyan().a(name).reset().a("> ").a(line)); + out.println(Ansi.ansi().fgCyan().a(containerName).reset().a("> ").a(line)); } } catch (IOException ignored) { // ignore as any stop of docker container breaks the reader stream // note that shutdown of docker would be already logged } }); - - long startTime = System.currentTimeMillis(); - while (!isContainerReady()) { - if (System.currentTimeMillis() - startTime > containerReadyTimeout) { - stop(); - throw new DockerTimeoutException(uuid + " - Container was not ready in " + containerReadyTimeout + " ms"); - } - // fail fast mechanism in case of malformed docker command, for example bad arguments, invalid format of port mapping, image version,... - if (!dockerRunProcess.isAlive() && dockerRunProcess.exitValue() != 0) { - throw new DockerException(uuid + " - Starting of docker container using command: \"" + String.join(" ", cmd) - + "\" failed. Check that provided command is correct."); - } - } + return outputPrinter; } - private boolean isContainerReady() throws Exception { - CompletableFuture condition = new CompletableFuture().supplyAsync(() -> containerReadyCondition.isReady()); + private boolean isContainerReady(final String uuid, final ContainerReadyCondition condition) + throws ContainerReadyConditionException { + final CompletableFuture conditionFuture = CompletableFuture.supplyAsync(condition::isReady); try { - return condition.get(containerReadyTimeout, TimeUnit.MILLISECONDS); + return conditionFuture.get(containerReadyTimeout, TimeUnit.MILLISECONDS); } catch (TimeoutException ex) { - stop(); - // in case condition hangs interrupt it so there are no zombie threads - condition.cancel(true); - throw new ContainerReadyConditionException(uuid + " - Provided ContainerReadyCondition.isReady() method took " + "longer than containerReadyTimeout: " + containerReadyTimeout + " ms. Check it does not hang and does " + "not take longer then containerReadyTimeout. It's expected that ContainerReadyCondition.isReady() method " + "is short lived (takes less than 1 second).", ex); + } catch (ExecutionException e) { + throw new ContainerReadyConditionException("There was an exception in thread which was checking the " + + "container readiness.", e); + } catch (InterruptedException e) { + throw new ContainerReadyConditionException("The thread waiting for container readiness was interrupted!", e); + } finally { + // in case condition hangs interrupt it so there are no zombie threads + conditionFuture.cancel(true); } } - private void checkDockerPresent() throws Exception { - Process dockerInfoProcess = new ProcessBuilder() - .redirectErrorStream(true) - .command(new String[] { "docker", "info" }) - .start(); - dockerInfoProcess.waitFor(); - if (dockerInfoProcess.exitValue() != 0) { - throw new DockerException("Docker is either not present or not installed on this machine. It must be installed " + - "and started up for executing tests with docker container."); + private boolean isDockerPresent() { + try { + Process dockerInfoProcess = new ProcessBuilder() + .redirectErrorStream(true) + .command(new String[] { "docker", "info" }) + .start(); + dockerInfoProcess.waitFor(); + return dockerInfoProcess.exitValue() == 0; + } catch (IOException | InterruptedException e) { + throw new IllegalStateException("There was an exception when checking for docker command presence!"); } } /** * @return Returns true if docker container is running. It does NOT check whether container is ready. */ - public boolean isRunning() throws Exception { - Process dockerRunProcess = new ProcessBuilder() - .redirectErrorStream(true) - .command(new String[] { "docker", "ps" }) - .start(); - - dockerRunProcess.waitFor(); + public boolean isRunning() { + Process dockerRunProcess; + try { + dockerRunProcess = new ProcessBuilder() + .redirectErrorStream(true) + .command(new String[] { "docker", "ps" }) + .start(); + + dockerRunProcess.waitFor(); + } catch (IOException | InterruptedException ignored) { + //when we cannot start the process for making the check container is not running + return false; + } try (BufferedReader reader = new BufferedReader( new InputStreamReader(dockerRunProcess.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { - if (line.contains(uuid)) { + if (line.contains(this.uuid)) { return true; } } @@ -156,44 +215,89 @@ public boolean isRunning() throws Exception { return false; } - public void stop() throws Exception { - System.out.println(Ansi.ansi().reset().a("Stopping container ").fgCyan().a(name).reset() - .a(" with ID ").fgYellow().a(uuid).reset()); + /** + * Stop this docker container using docker command. + * + * @throws ContainerStopException thrown when the stop command fails. This generally means that the command wasn't + * successful and container might be still running. + */ + public void stop() throws ContainerStopException { + this.out.println(Ansi.ansi().reset().a("Stopping container ").fgCyan().a(this.name).reset() + .a(" with ID ").fgYellow().a(this.uuid).reset()); + + try { + runDockerCommand("stop", this.uuid); + } catch (DockerCommandException e) { + throw new ContainerStopException("Failed to stop container '" + this.uuid + "'!", e); + } - new ProcessBuilder() - .command("docker", "stop", uuid) - .start() - .waitFor(10, TimeUnit.SECONDS); - terminateThreadPools(); - removeDockerContainer(); + try { + terminatePrintingThread(); + } catch (InterruptedException e) { + throw new ContainerStopException("A thread was interrupted while waiting for its termination!", e); + } } - public void kill() throws Exception { - System.out.println(Ansi.ansi().reset().a("Killing container ").fgCyan().a(name).reset() + /** + * Kill this docker container using docker command. Be aware that there might occur a situation when the docker + * command might fail. This might be caused for example by reaching file descriptors limit in system. + * + * @throws ContainerKillException thrown when the kill command fails. + */ + public void kill() throws ContainerKillException { + this.out.println(Ansi.ansi().reset().a("Killing container ").fgCyan().a(this.name).reset() .a(" with ID ").fgYellow().a(uuid).reset()); - new ProcessBuilder() - .command("docker", "kill", uuid) - .start() - .waitFor(10, TimeUnit.SECONDS); - terminateThreadPools(); - removeDockerContainer(); + try { + runDockerCommand("kill", this.uuid); + } catch (DockerCommandException e) { + throw new ContainerKillException("Failed to kill the container '" + this.uuid + "'!", e); + } + + try { + terminatePrintingThread(); + } catch (InterruptedException e) { + throw new ContainerKillException("Interrupted when waiting for printer thread termination!", e); + } } - private void terminateThreadPools() throws Exception { - outputPrinter.shutdown(); - outputPrinter.awaitTermination(10, TimeUnit.SECONDS); + private void terminatePrintingThread() throws InterruptedException { + outputPrintingThread.shutdown(); + outputPrintingThread.awaitTermination(10, TimeUnit.SECONDS); } - private void removeDockerContainer() throws Exception { - new ProcessBuilder() - .command("docker", "rm", uuid) - .start() - .waitFor(10, TimeUnit.SECONDS); + private void removeDockerContainer(final String uuid) throws ContainerRemoveException { + try { + runDockerCommand("rm", uuid); + } catch (DockerCommandException e) { + throw new ContainerRemoveException("Failed to remove the container '" + uuid + "'!", e); + } + } + + private int runDockerCommand(final String... commandArguments) throws DockerCommandException { + final String dockerCommand = "docker"; + final List cmd = new ArrayList<>(); + cmd.add(dockerCommand); + Collections.addAll(cmd, commandArguments); + try { + final Process process; + + process = new ProcessBuilder() + .command(cmd) + .start(); + + process.waitFor(10, TimeUnit.SECONDS); + + return process.exitValue(); + } catch (InterruptedException e) { + throw new DockerCommandException("Interrupted while waiting for '" + String.join(" ", cmd) + "' to return!", e); + } catch (IOException e) { + throw new DockerCommandException("Failed to start command '" + String.join(" ", cmd) + "'!", e); + } } @Override - protected void before() throws Throwable { + protected void before() throws ContainerStartException { start(); } @@ -201,9 +305,11 @@ protected void before() throws Throwable { protected void after() { try { stop(); - } catch (Exception e) { - System.out.println(Ansi.ansi().reset().a("Failed stopping container ").fgCyan().a(name).reset() - .a(" with ID ").fgYellow().a(uuid).reset()); + removeDockerContainer(this.uuid); + } catch (ContainerStopException e) { + throw new IllegalStateException("Failed to stop container '" + this.uuid + "'! ", e); + } catch (ContainerRemoveException e) { + throw new IllegalStateException("Failed to remove container '" + this.uuid + "'! ", e); } } @@ -216,6 +322,7 @@ public static class Builder { private List options = new ArrayList<>(); private List commandArguments = new ArrayList<>(); private long containerReadyTimeoutInMillis = 120_000; // 2 minutes + private PrintStream out = System.out; // by default - do not make any check private ContainerReadyCondition containerReadyCondition = () -> true; @@ -291,23 +398,24 @@ public Builder setContainerReadyCondition(ContainerReadyCondition containerReady return this; } + /** + * Set default output stream to which the container stdout will be written. Default is System.out. + * + * @param out an output stream + * @return instance of this builder + */ + public Builder standardOutputStream(final PrintStream out) { + this.out = out; + return this; + } + /** * Builds instance of Docker class. * * @return build Docker instance */ public Docker build() { - Docker docker = new Docker(); - docker.uuid = this.uuid; - docker.name = this.name; - docker.image = this.image; - docker.ports = this.ports; - docker.options = this.options; - docker.environmentVariables = this.environmentVariables; - docker.commandArguments = this.commandArguments; - docker.containerReadyCondition = containerReadyCondition; - docker.containerReadyTimeout = containerReadyTimeoutInMillis; - return docker; + return new Docker(this); } } } diff --git a/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/DockerCommandException.java b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/DockerCommandException.java new file mode 100644 index 00000000..ae27fb8f --- /dev/null +++ b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/DockerCommandException.java @@ -0,0 +1,23 @@ +package org.jboss.eap.qe.ts.common.docker; + +/** + * An exception thrown when docker command fails to return/execute + */ +public class DockerCommandException extends Exception { + + public DockerCommandException() { + super(); + } + + public DockerCommandException(String message) { + super(message); + } + + public DockerCommandException(String message, Throwable cause) { + super(message, cause); + } + + public DockerCommandException(Throwable cause) { + super(cause); + } +} diff --git a/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/DockerException.java b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/DockerException.java deleted file mode 100644 index 65f32681..00000000 --- a/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/DockerException.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.jboss.eap.qe.ts.common.docker; - -public class DockerException extends RuntimeException { - public DockerException(String message) { - super(message); - } - - public DockerException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/DockerTimeoutException.java b/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/DockerTimeoutException.java deleted file mode 100644 index d7ec98d4..00000000 --- a/tooling-docker/src/main/java/org/jboss/eap/qe/ts/common/docker/DockerTimeoutException.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.jboss.eap.qe.ts.common.docker; - -public class DockerTimeoutException extends RuntimeException { - public DockerTimeoutException(String message) { - super(message); - } - - public DockerTimeoutException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/tooling-docker/src/test/java/org/jboss/eap/qe/ts/common/docker/MalformedDockerConfigTest.java b/tooling-docker/src/test/java/org/jboss/eap/qe/ts/common/docker/MalformedDockerConfigTest.java index 7bb3ec43..6eca8cd9 100644 --- a/tooling-docker/src/test/java/org/jboss/eap/qe/ts/common/docker/MalformedDockerConfigTest.java +++ b/tooling-docker/src/test/java/org/jboss/eap/qe/ts/common/docker/MalformedDockerConfigTest.java @@ -1,8 +1,11 @@ package org.jboss.eap.qe.ts.common.docker; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.hasProperty; +import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.not; import java.util.concurrent.TimeUnit; @@ -25,7 +28,7 @@ public void testFailFastWithMalformedDockerCommand() throws Exception { .withPortMapping("bad:mapping") .build(); - thrown.expect(DockerException.class); + thrown.expect(ContainerStartException.class); thrown.expectMessage(containsString("Starting of docker container using command: \"docker run --name")); thrown.expectMessage(endsWith("failed. Check that provided command is correct.")); @@ -47,9 +50,10 @@ public void testContainerWithHangingReadyCondition() throws Exception { }) // it's expected that server never starts and fails fast thus return false .build(); - thrown.expect(ContainerReadyConditionException.class); - thrown.expectMessage( - containsString("Provided ContainerReadyCondition.isReady() method took longer than containerReadyTimeout")); + thrown.expect(ContainerStartException.class); + thrown.expectCause(allOf(instanceOf(ContainerReadyConditionException.class), + hasProperty("message", containsString( + "Provided ContainerReadyCondition.isReady() method took longer than containerReadyTimeout")))); // throws expected Exception try { containerWithHangingReadyCondition.start(); @@ -68,7 +72,7 @@ public void testContainerReadyTimeout() throws Exception { .setContainerReadyCondition(() -> false) // never ready .build(); - thrown.expect(DockerTimeoutException.class); + thrown.expect(ContainerStartException.class); thrown.expectMessage(containsString("Container was not ready in")); try {