diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 07d944243cd..5aca9e624b3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -18,6 +18,7 @@ body: - Cassandra - Clickhouse - CockroachDB + - Consul - Couchbase - DB2 - Dynalite diff --git a/.github/ISSUE_TEMPLATE/enhancement.yaml b/.github/ISSUE_TEMPLATE/enhancement.yaml index ea190cefc80..ba5b1f88006 100644 --- a/.github/ISSUE_TEMPLATE/enhancement.yaml +++ b/.github/ISSUE_TEMPLATE/enhancement.yaml @@ -18,6 +18,7 @@ body: - Cassandra - Clickhouse - CockroachDB + - Consul - Couchbase - DB2 - Dynalite diff --git a/.github/ISSUE_TEMPLATE/feature.yaml b/.github/ISSUE_TEMPLATE/feature.yaml index 09810d3e56f..981f5613a1e 100644 --- a/.github/ISSUE_TEMPLATE/feature.yaml +++ b/.github/ISSUE_TEMPLATE/feature.yaml @@ -18,6 +18,7 @@ body: - Cassandra - Clickhouse - CockroachDB + - Consul - Couchbase - DB2 - Dynalite diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2d877f664ab..22437e15825 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -42,6 +42,11 @@ updates: schedule: interval: "monthly" open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/consul" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 - package-ecosystem: "gradle" directory: "/modules/couchbase" schedule: diff --git a/.github/labeler.yml b/.github/labeler.yml index 826e5c16215..cf75af99d26 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -15,6 +15,8 @@ - modules/clickhouse/* "modules/cockroachdb": - modules/cockroachdb/* +"modules/consul": + - modules/consul/* "modules/couchbase": - modules/couchbase/* "modules/db2": diff --git a/docs/modules/consul.md b/docs/modules/consul.md new file mode 100644 index 00000000000..1580690cad7 --- /dev/null +++ b/docs/modules/consul.md @@ -0,0 +1,36 @@ +# Hashicorp Consul Module + +Testcontainers module for [Consul](https://github.com/hashicorp/consul). Consul is a tool for managing key value properties. More information on Consul [here](https://www.consul.io/). + +## Usage example + + +[Running Consul in your Junit tests](../../modules/consul/src/test/java/org/testcontainers/consul/ConsulContainerTest.java) + + +## Why Consul in Junit tests? + +With the increasing popularity of Consul and config externalization, applications are now needing to source properties from Consul. +This can prove challenging in the development phase without a running Consul instance readily on hand. This library +aims to solve your apps integration testing with Consul. You can also use it to +test how your application behaves with Consul by writing different test scenarios in Junit. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +```groovy tab='Gradle' +testImplementation "org.testcontainers:consul:{{latest_version}}" +``` + +```xml tab='Maven' + + org.testcontainers + consul + {{latest_version}} + test + +``` + +See [AUTHORS](https://raw.githubusercontent.com/testcontainers/testcontainers-java/master/modules/consul/AUTHORS) for contributors. + diff --git a/mkdocs.yml b/mkdocs.yml index 9491ebe6019..a836e0a2ff7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,6 +66,7 @@ nav: - modules/databases/tidb.md - modules/databases/trino.md - modules/azure.md + - modules/consul.md - modules/docker_compose.md - modules/elasticsearch.md - modules/gcloud.md diff --git a/modules/consul/build.gradle b/modules/consul/build.gradle new file mode 100644 index 00000000000..0d0d6e7e992 --- /dev/null +++ b/modules/consul/build.gradle @@ -0,0 +1,9 @@ +description = "Testcontainers :: Consul" + +dependencies { + api project(':testcontainers') + + testImplementation 'com.ecwid.consul:consul-api:1.4.5' + testImplementation 'io.rest-assured:rest-assured:4.4.0' + testImplementation 'org.assertj:assertj-core:3.21.0' +} diff --git a/modules/consul/src/main/java/org/testcontainers/consul/ConsulContainer.java b/modules/consul/src/main/java/org/testcontainers/consul/ConsulContainer.java new file mode 100644 index 00000000000..0c9d2607072 --- /dev/null +++ b/modules/consul/src/main/java/org/testcontainers/consul/ConsulContainer.java @@ -0,0 +1,100 @@ +package org.testcontainers.consul; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.Capability; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Testcontainers implementation for Consul. + */ +public class ConsulContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("consul"); + + private static final int CONSUL_HTTP_PORT = 8500; + + private static final int CONSUL_GRPC_PORT = 8502; + + private List initCommands = new ArrayList<>(); + + private String[] startConsulCmd = new String[] { "agent", "-dev", "-client", "0.0.0.0" }; + + public ConsulContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public ConsulContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + // Use the status leader endpoint to verify if consul is running. + setWaitStrategy(Wait.forHttp("/v1/status/leader").forPort(CONSUL_HTTP_PORT).forStatusCode(200)); + + withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withCapAdd(Capability.IPC_LOCK)); + withEnv("CONSUL_ADDR", "http://0.0.0.0:" + CONSUL_HTTP_PORT); + withCommand(startConsulCmd); + withExposedPorts(CONSUL_HTTP_PORT, CONSUL_GRPC_PORT); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + runConsulCommands(); + } + + private void runConsulCommands() { + if (!initCommands.isEmpty()) { + String commands = initCommands + .stream() + .map(command -> "consul " + command) + .collect(Collectors.joining(" && ")); + try { + ExecResult execResult = this.execInContainer(new String[] { "/bin/sh", "-c", commands }); + if (execResult.getExitCode() != 0) { + logger() + .error( + "Failed to execute these init commands {}. Exit code {}. Stdout {}. Stderr {}", + initCommands, + execResult.getExitCode(), + execResult.getStdout(), + execResult.getStderr() + ); + } + } catch (IOException | InterruptedException e) { + logger() + .error( + "Failed to execute these init commands {}. Exception message: {}", + initCommands, + e.getMessage() + ); + } + } + } + + /** + * Run consul commands using the consul cli. + * + * Useful for enableing more secret engines like: + *
+     *     .withConsulCommand("secrets enable pki")
+     *     .withConsulCommand("secrets enable transit")
+     * 
+ * or register specific K/V like: + *
+     *     .withConsulCommand("kv put config/testing1 value123")
+     * 
+ * @param commands The commands to send to the consul cli + * @return this + */ + public ConsulContainer withConsulCommand(String... commands) { + initCommands.addAll(Arrays.asList(commands)); + return self(); + } +} diff --git a/modules/consul/src/test/java/org/testcontainers/consul/ConsulContainerTest.java b/modules/consul/src/test/java/org/testcontainers/consul/ConsulContainerTest.java new file mode 100644 index 00000000000..e29ff7ffbd8 --- /dev/null +++ b/modules/consul/src/test/java/org/testcontainers/consul/ConsulContainerTest.java @@ -0,0 +1,78 @@ +package org.testcontainers.consul; + +import com.ecwid.consul.v1.ConsulClient; +import com.ecwid.consul.v1.Response; +import com.ecwid.consul.v1.kv.model.GetValue; +import io.restassured.RestAssured; +import org.assertj.core.api.Assertions; +import org.hamcrest.CoreMatchers; +import org.hamcrest.MatcherAssert; +import org.junit.ClassRule; +import org.junit.Test; +import org.testcontainers.containers.GenericContainer; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +/** + * This test shows the pattern to use the ConsulContainer @ClassRule for a junit test. It also has tests that ensure + * the properties were added correctly by reading from Consul with the CLI and over HTTP. + */ +public class ConsulContainerTest { + + @ClassRule + public static ConsulContainer consulContainer = new ConsulContainer(ConsulTestImages.CONSUL_IMAGE) + .withConsulCommand("kv put config/testing1 value123"); + + @Test + public void readFirstPropertyPathWithCli() throws IOException, InterruptedException { + GenericContainer.ExecResult result = consulContainer.execInContainer("consul", "kv", "get", "config/testing1"); + final String output = result.getStdout().replaceAll("\\r?\\n", ""); + MatcherAssert.assertThat(output, CoreMatchers.containsString("value123")); + } + + @Test + public void readFirstSecretPathOverHttpApi() throws InterruptedException { + RestAssured + .given() + .when() + .get("http://" + getHostAndPort() + "/v1/kv/config/testing1") + .then() + .assertThat() + .body( + "[0].Value", + CoreMatchers.equalTo(Base64.getEncoder().encodeToString("value123".getBytes(StandardCharsets.UTF_8))) + ); + } + + @Test + public void writeAndReadMultipleValuesUsingClient() { + final ConsulClient consulClient = new ConsulClient( + consulContainer.getHost(), + consulContainer.getFirstMappedPort() + ); + + final Map properties = new HashMap<>(); + properties.put("value", "world"); + properties.put("other_value", "another world"); + + // Write operation + properties.forEach((key, value) -> { + Response writeResponse = consulClient.setKVValue(key, value); + Assertions.assertThat(writeResponse.getValue()).isTrue(); + }); + + // Read operation + properties.forEach((key, value) -> { + Response readResponse = consulClient.getKVValue(key); + Assertions.assertThat(readResponse.getValue().getDecodedValue()).isEqualTo(value); + }); + } + + private String getHostAndPort() { + return consulContainer.getHost() + ":" + consulContainer.getMappedPort(8500); + } +} diff --git a/modules/consul/src/test/java/org/testcontainers/consul/ConsulTestImages.java b/modules/consul/src/test/java/org/testcontainers/consul/ConsulTestImages.java new file mode 100644 index 00000000000..de2b9702066 --- /dev/null +++ b/modules/consul/src/test/java/org/testcontainers/consul/ConsulTestImages.java @@ -0,0 +1,7 @@ +package org.testcontainers.consul; + +import org.testcontainers.utility.DockerImageName; + +public interface ConsulTestImages { + DockerImageName CONSUL_IMAGE = DockerImageName.parse("consul:1.10.12"); +} diff --git a/modules/consul/src/test/resources/logback-test.xml b/modules/consul/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..535e406fc13 --- /dev/null +++ b/modules/consul/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + +