accepted = new ArrayList<>();
+ try {
+ while (!server.isClosed()) {
+ Socket socket = server.accept();
+ accepted.add(socket);
+ handler.handle(socket);
+ }
+ } catch (IOException e) {
+ // the server socket was closed, the test is over
+ } finally {
+ accepted.forEach(HttpExecutionFailoverTest::closeQuietly);
+ }
+ }, "test-acceptor-" + server.getLocalPort());
+ acceptor.setDaemon(true);
+ acceptor.start();
+ }
+
+ private static void closeQuietly(Socket socket) {
+ try {
+ socket.close();
+ } catch (IOException e) {
+ // nothing useful to do while tearing a test down
+ }
+ }
+}
diff --git a/maas-client/client/src/test/java/com/netcracker/cloud/maas/client/impl/kafka/KafkaMaaSClientImplTest.java b/maas-client/client/src/test/java/com/netcracker/cloud/maas/client/impl/kafka/KafkaMaaSClientImplTest.java
index f831130e17..61b6144bdc 100644
--- a/maas-client/client/src/test/java/com/netcracker/cloud/maas/client/impl/kafka/KafkaMaaSClientImplTest.java
+++ b/maas-client/client/src/test/java/com/netcracker/cloud/maas/client/impl/kafka/KafkaMaaSClientImplTest.java
@@ -21,6 +21,7 @@
import org.mockserver.matchers.Times;
import org.mockserver.mock.action.ExpectationResponseCallback;
import org.mockserver.model.HttpRequest;
+import org.mockserver.verify.VerificationTimes;
import com.netcracker.cloud.maas.client.Utils;
import com.netcracker.cloud.maas.client.api.Classifier;
@@ -43,6 +44,41 @@
@Slf4j
// TODO tests with kafka SSL+password
class KafkaMaaSClientImplTest {
+ /** maas-agent's answer for the `orders' topic, shared by the tests that just need a valid one. */
+ private static final String ORDERS_TOPIC_RESPONSE = """
+ {
+ "addresses": {
+ "PLAINTEXT": [
+ "my-kafka.kafka-cluster:9092"
+ ]
+ },
+ "name": "maas.core_dev.orders.1234567",
+ "classifier": {
+ "name": "orders",
+ "namespace": "core-dev",
+ "tenantId": "d047619f-6886-4842-81a7-3f87cb748ac1"
+ },
+ "namespace": "core-dev",
+ "instance": "default",
+ "requestedSettings": {
+ "numPartitions": 1,
+ "replicationFactor": 1,
+ "replicaAssignment": null,
+ "configs": null
+ },
+ "actualSettings": {
+ "numPartitions": 1,
+ "replicationFactor": 1,
+ "replicaAssignment": {
+ "0": [ 0 ]
+ },
+ "configs": {
+ "cleanup.policy": "delete"
+ }
+ }
+ }
+ """;
+
@BeforeEach
public void setup(ClientAndServer mockServer) {
mockServer.reset();
@@ -102,37 +138,7 @@ public void testGetTenantTopic(ClientAndServer mockServer) {
).respond(
response()
.withStatusCode(200)
- .withBody("{\n" +
- " \"addresses\": {\n" +
- " \"PLAINTEXT\": [\n" +
- " \"my-kafka.kafka-cluster:9092\"\n" +
- " ]\n" +
- " }, \n" +
- " \"name\": \"maas.core_dev.orders.1234567\",\n" +
- " \"classifier\": {\n" +
- " \"name\": \"orders\",\n" +
- " \"namespace\": \"core-dev\",\n" +
- " \"tenantId\": \"d047619f-6886-4842-81a7-3f87cb748ac1\"\n" +
- " }, \n" +
- " \"namespace\": \"core-dev\",\n" +
- " \"instance\": \"default\",\n" +
- " \"requestedSettings\": {\n" +
- " \"numPartitions\": 1,\n" +
- " \"replicationFactor\": 1,\n" +
- " \"replicaAssignment\": null,\n" +
- " \"configs\": null\n" +
- " },\n" +
- " \"actualSettings\": {\n" +
- " \"numPartitions\": 1,\n" +
- " \"replicationFactor\": 1,\n" +
- " \"replicaAssignment\": {\n" +
- " \"0\": [ 0 ]\n" +
- " },\n" +
- " \"configs\": {\n" +
- " \"cleanup.policy\": \"delete\"\n" +
- " }\n" +
- " } \n" +
- "}\n")
+ .withBody(ORDERS_TOPIC_RESPONSE)
);
// run test
@@ -245,37 +251,7 @@ public void testNoNullValuesInCache(ClientAndServer mockServer) {
).respond(
response()
.withStatusCode(200)
- .withBody("{\n" +
- " \"addresses\": {\n" +
- " \"PLAINTEXT\": [\n" +
- " \"my-kafka.kafka-cluster:9092\"\n" +
- " ]\n" +
- " }, \n" +
- " \"name\": \"maas.core_dev.orders.1234567\",\n" +
- " \"classifier\": {\n" +
- " \"name\": \"orders\",\n" +
- " \"namespace\": \"core-dev\",\n" +
- " \"tenantId\": \"d047619f-6886-4842-81a7-3f87cb748ac1\"\n" +
- " }, \n" +
- " \"namespace\": \"core-dev\",\n" +
- " \"instance\": \"default\",\n" +
- " \"requestedSettings\": {\n" +
- " \"numPartitions\": 1,\n" +
- " \"replicationFactor\": 1,\n" +
- " \"replicaAssignment\": null,\n" +
- " \"configs\": null\n" +
- " },\n" +
- " \"actualSettings\": {\n" +
- " \"numPartitions\": 1,\n" +
- " \"replicationFactor\": 1,\n" +
- " \"replicaAssignment\": {\n" +
- " \"0\": [ 0 ]\n" +
- " },\n" +
- " \"configs\": {\n" +
- " \"cleanup.policy\": \"delete\"\n" +
- " }\n" +
- " } \n" +
- "}\n")
+ .withBody(ORDERS_TOPIC_RESPONSE)
);
topicAddress = client.getTopic(new Classifier("orders").tenantId("d047619f-6886-4842-81a7-3f87cb748ac1"));
@@ -303,37 +279,7 @@ public void testGetOrCreateLazyTenantTopic(ClientAndServer mockServer) {
).respond(
response()
.withStatusCode(200)
- .withBody("{\n" +
- " \"addresses\": {\n" +
- " \"PLAINTEXT\": [\n" +
- " \"my-kafka.kafka-cluster:9092\"\n" +
- " ]\n" +
- " }, \n" +
- " \"name\": \"maas.core_dev.orders.1234567\",\n" +
- " \"classifier\": {\n" +
- " \"name\": \"orders\",\n" +
- " \"namespace\": \"core-dev\",\n" +
- " \"tenantId\": \"d047619f-6886-4842-81a7-3f87cb748ac1\"\n" +
- " }, \n" +
- " \"namespace\": \"core-dev\",\n" +
- " \"instance\": \"default\",\n" +
- " \"requestedSettings\": {\n" +
- " \"numPartitions\": 1,\n" +
- " \"replicationFactor\": 1,\n" +
- " \"replicaAssignment\": null,\n" +
- " \"configs\": null\n" +
- " },\n" +
- " \"actualSettings\": {\n" +
- " \"numPartitions\": 1,\n" +
- " \"replicationFactor\": 1,\n" +
- " \"replicaAssignment\": {\n" +
- " \"0\": [ 0 ]\n" +
- " },\n" +
- " \"configs\": {\n" +
- " \"cleanup.policy\": \"delete\"\n" +
- " }\n" +
- " } \n" +
- "}\n")
+ .withBody(ORDERS_TOPIC_RESPONSE)
);
// run test
@@ -450,12 +396,11 @@ public void testGetOrCreateTopicWithRetry(ClientAndServer mockServer) throws IOE
"}\n")
);
- // run test
+ // default options: maas-service resolves the classifier before it looks at
+ // onTopicExists, so the retry after the timed-out first attempt gets the same topic
var client = createKafkaClient("http://localhost:" + mockServer.getPort());
TopicAddress topicAddress = client.getOrCreateTopic(new Classifier("orders"),
- TopicCreateOptions.builder()
- .name("user-test1")
- .build());
+ TopicCreateOptions.builder().name("user-test1").build());
assertEquals("user-test1", topicAddress.getTopicName());
});
});
@@ -596,6 +541,66 @@ void testTopicDeleteSuccess(ClientAndServer mockServer) throws Exception {
});
}
+ /** An empty 200 used to reach the response fields and throw NPE instead of answering "nothing deleted". */
+ @Test
+ void testTopicDeleteEmptyBody(ClientAndServer mockServer) {
+ withProp(Env.PROP_NAMESPACE, "cloud-dev", () -> {
+ withProp(Env.PROP_MAAS_AGENT_URL, "http://localhost:" + mockServer.getPort(), () -> {
+
+ mockServer.when(
+ request().withMethod("DELETE").withPath("/api/v2/kafka/topic"), Times.once()
+ ).respond(response().withBody(""));
+
+ KafkaMaaSClient kafkaClient = new MaaSAPIClientImpl(() -> "faketoken", null, null).getKafkaClient();
+ assertFalse(kafkaClient.deleteTopic(new Classifier("orders")));
+ });
+ });
+ }
+
+ /**
+ * A retry after the server completed a delete comes back with empty lists, which the client
+ * reads as "nothing was deleted".
+ */
+ @Test
+ void testTopicDeleteIsNotRetried(ClientAndServer mockServer) {
+ withProp(Env.PROP_NAMESPACE, "cloud-dev", () -> {
+ withProp(Env.PROP_MAAS_AGENT_URL, "http://localhost:" + mockServer.getPort(), () -> {
+
+ mockServer.when(request().withMethod("DELETE").withPath("/api/v2/kafka/topic"), Times.unlimited())
+ .respond(response().withStatusCode(500).withBody("{\"error\":\"agent down\"}"));
+
+ KafkaMaaSClient kafkaClient = new MaaSAPIClientImpl(() -> "faketoken", null, null).getKafkaClient();
+ Classifier orders = new Classifier("orders");
+ assertThrows(MaaSException.class, () -> kafkaClient.deleteTopic(orders));
+
+ mockServer.verify(request().withMethod("DELETE").withPath("/api/v2/kafka/topic"),
+ VerificationTimes.exactly(1));
+ });
+ });
+ }
+
+ /** Create is the operation a switchover interrupts most often, and it retries on any options. */
+ @Test
+ void testGetOrCreateTopicIsRetriedOnDefaultOptions(ClientAndServer mockServer) {
+ withProp(Env.PROP_NAMESPACE, "cloud-dev", () -> {
+ withProp(Env.PROP_MAAS_AGENT_URL, "http://localhost:" + mockServer.getPort(), () -> {
+ withProp(Env.PROP_HTTP_RETRY_MAX_TOTAL_DURATION_MS, "1000", () -> {
+
+ mockServer.when(request().withMethod("POST").withPath("/api/v2/kafka/topic"), Times.unlimited())
+ .respond(response().withStatusCode(500).withBody("{\"error\":\"agent down\"}"));
+
+ KafkaMaaSClient kafkaClient = new MaaSAPIClientImpl(() -> "faketoken", null, null).getKafkaClient();
+ Classifier orders = new Classifier("orders");
+ assertThrows(MaaSException.class,
+ () -> kafkaClient.getOrCreateTopic(orders, TopicCreateOptions.DEFAULTS));
+
+ mockServer.verify(request().withMethod("POST").withPath("/api/v2/kafka/topic"),
+ VerificationTimes.atLeast(2));
+ });
+ });
+ });
+ }
+
@Test
void testTopicDeleteError(ClientAndServer mockServer) throws Exception {
withProp(Env.PROP_NAMESPACE, "cloud-dev", () -> {
@@ -775,6 +780,16 @@ void testClose(ClientAndServer mockServer) {
});
}
+ @Test
+ void testWatchTopicCreateThrowsAfterClose(ClientAndServer mockServer) {
+ withProp(Env.PROP_NAMESPACE, "cloud-dev", () -> {
+ KafkaMaaSClientImpl client = createKafkaClient("http://localhost:" + mockServer.getPort());
+ client.close();
+
+ assertThrows(IllegalStateException.class, () -> client.watchTopicCreate("orders", addr -> {}));
+ });
+ }
+
private KafkaMaaSClientImpl createKafkaClient(String agentUrl) {
System.setProperty(Env.PROP_MAAS_AGENT_URL, agentUrl);
var httpClient = HttpClient.getMaasClient(() -> "faketoken");
diff --git a/maas-client/client/src/test/java/com/netcracker/cloud/maas/client/impl/kafka/KafkaMaaSClientWatchBackoffTest.java b/maas-client/client/src/test/java/com/netcracker/cloud/maas/client/impl/kafka/KafkaMaaSClientWatchBackoffTest.java
new file mode 100644
index 0000000000..9f0d1572fb
--- /dev/null
+++ b/maas-client/client/src/test/java/com/netcracker/cloud/maas/client/impl/kafka/KafkaMaaSClientWatchBackoffTest.java
@@ -0,0 +1,135 @@
+package com.netcracker.cloud.maas.client.impl.kafka;
+
+import static com.netcracker.cloud.maas.client.Utils.withProp;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.netcracker.cloud.maas.client.impl.ApiUrlProvider;
+import com.netcracker.cloud.maas.client.impl.Env;
+import com.netcracker.cloud.maas.client.impl.apiversion.ServerApiVersion;
+import com.netcracker.cloud.maas.client.impl.http.HttpClient;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Pins the invariant that a failing {@code watch-create} long poll is retried with a backoff
+ * rather than in a hot loop.
+ *
+ * Without one, a maas-agent that is down gets polled as fast as the socket can refuse the
+ * connection — hammering it exactly while it is coming back up.
+ */
+class KafkaMaaSClientWatchBackoffTest {
+
+ private static final String WATCHED_TOPIC = "orders";
+ private static final String NAMESPACE = "cloud-dev";
+
+ /** Three polls are enough to see the pause between them grow. */
+ private static final int OBSERVED_POLLS = 3;
+
+ private final List pollMillis = Collections.synchronizedList(new ArrayList<>());
+ private final List pollQueries = Collections.synchronizedList(new ArrayList<>());
+ private final CountDownLatch pollsObserved = new CountDownLatch(OBSERVED_POLLS);
+ private HttpServer agentStub;
+ private KafkaMaaSClientImpl client;
+
+ @BeforeEach
+ void startAgentStub() throws IOException {
+ agentStub = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
+ agentStub.createContext("/api-version", exchange -> respond(exchange, 200, "{\"major\": 2, \"minor\": 8}"));
+ agentStub.createContext("/api/v2/kafka/topic/watch-create", this::failWatchPoll);
+ agentStub.start();
+ }
+
+ @AfterEach
+ void stopClientAndStub() {
+ if (client != null) {
+ client.close();
+ }
+ agentStub.stop(0);
+ }
+
+ /**
+ * A window that outlasts the read timeout means the empty 200 ending a quiet poll never
+ * arrives, so every such poll fails locally and walks the backoff up to its cap.
+ */
+ @Test
+ void watchWindowFormulaStaysBelowTheReadTimeout() {
+ for (long readTimeoutSeconds : new long[]{2, 5, 6, 10, 30, 60, 120}) {
+ Duration readTimeout = Duration.ofSeconds(readTimeoutSeconds);
+ Duration window = KafkaMaaSClientImpl.watchTimeout(readTimeout);
+ assertTrue(window.compareTo(readTimeout) < 0,
+ "a " + readTimeoutSeconds + "s read timeout must leave room for the answer, got " + window);
+ assertFalse(window.isZero() || window.isNegative(),
+ "the window must stay positive, got " + window);
+ }
+ }
+
+ @Test
+ void failingWatchPollIsBackedOffInsteadOfHotLooping() {
+ withProp(Env.PROP_NAMESPACE, NAMESPACE, () -> {
+ String agentUrl = "http://localhost:" + agentStub.getAddress().getPort();
+ withProp(Env.PROP_MAAS_AGENT_URL, agentUrl, () -> {
+ client = createKafkaClient(agentUrl);
+ client.watchTopicCreate(WATCHED_TOPIC, addr -> { /* never created in this test */ });
+
+ assertTrue(pollsObserved.await(30, TimeUnit.SECONDS),
+ "the watch loop reached the agent stub only " + pollMillis.size()
+ + " times out of " + OBSERVED_POLLS + ", so nothing was measured");
+
+ for (int poll = 1; poll < OBSERVED_POLLS; poll++) {
+ long pause = pollMillis.get(poll) - pollMillis.get(poll - 1);
+ assertTrue(pause > 500,
+ "expected the watch loop to pause after a failure, but poll " + poll
+ + " followed the previous one in " + pause + "ms");
+ }
+
+ assertEquals("timeout=25s", pollQueries.get(0),
+ "the poll must carry the window derived from maas.http.timeout");
+ });
+ });
+ }
+
+ private static KafkaMaaSClientImpl createKafkaClient(String agentUrl) {
+ var httpClient = HttpClient.getMaasClient(() -> "faketoken");
+ var serverApiVersion = new ServerApiVersion(httpClient, agentUrl);
+
+ return new KafkaMaaSClientImpl(httpClient,
+ () -> { throw new UnsupportedOperationException("tenant manager is not used in this test"); },
+ new ApiUrlProvider(serverApiVersion, agentUrl));
+ }
+
+ /** Answers every poll with 500, the code maas-agent returns when it cannot reach maas-service. */
+ private void failWatchPoll(HttpExchange exchange) throws IOException {
+ pollMillis.add(System.currentTimeMillis());
+ pollQueries.add(exchange.getRequestURI().getQuery());
+ pollsObserved.countDown();
+ respond(exchange, 500, "{\"error\":\"error proxying request: maas-service unavailable\"}");
+ }
+
+ private static void respond(HttpExchange exchange, int status, String body) throws IOException {
+ exchange.getRequestBody().readAllBytes();
+
+ byte[] payload = body.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(status, payload.length);
+ try (OutputStream response = exchange.getResponseBody()) {
+ response.write(payload);
+ }
+ }
+}
diff --git a/maas-client/client/src/test/java/com/netcracker/cloud/maas/client/impl/rabbit/RabbitFailoverTest.java b/maas-client/client/src/test/java/com/netcracker/cloud/maas/client/impl/rabbit/RabbitFailoverTest.java
new file mode 100644
index 0000000000..6dcbbe2cb4
--- /dev/null
+++ b/maas-client/client/src/test/java/com/netcracker/cloud/maas/client/impl/rabbit/RabbitFailoverTest.java
@@ -0,0 +1,106 @@
+package com.netcracker.cloud.maas.client.impl.rabbit;
+
+import com.netcracker.cloud.maas.client.api.Classifier;
+import com.netcracker.cloud.maas.client.api.MaaSHttpException;
+import com.netcracker.cloud.maas.client.impl.ApiUrlProvider;
+import com.netcracker.cloud.maas.client.impl.Env;
+import com.netcracker.cloud.maas.client.impl.apiversion.ServerApiVersion;
+import com.netcracker.cloud.maas.client.impl.http.HttpClient;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.mockserver.integration.ClientAndServer;
+import org.mockserver.junit.jupiter.MockServerExtension;
+import org.mockserver.matchers.Times;
+import org.mockserver.verify.VerificationTimes;
+
+import static com.netcracker.cloud.maas.client.Utils.withProp;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
+
+@ExtendWith(MockServerExtension.class)
+class RabbitFailoverTest {
+
+ private static final String PATH = "/api/v2/rabbit/vhost";
+
+ private String savedAgentUrl;
+
+ @BeforeEach
+ void reset(ClientAndServer mockServer) {
+ savedAgentUrl = System.getProperty(Env.PROP_MAAS_AGENT_URL);
+ mockServer.reset();
+ mockServer.when(request().withPath("/api-version"))
+ .respond(response().withBody("{\"major\":2, \"minor\": 16}"));
+ }
+
+ // the agent url points at a mock server port that is gone once this class is done,
+ // so it must not leak into the rest of the JVM
+ @AfterEach
+ void restoreAgentUrl() {
+ if (savedAgentUrl == null) {
+ System.clearProperty(Env.PROP_MAAS_AGENT_URL);
+ } else {
+ System.setProperty(Env.PROP_MAAS_AGENT_URL, savedAgentUrl);
+ }
+ }
+
+ @ParameterizedTest(name = "{0} is retried on the vhost path")
+ @CsvSource(delimiter = '|', textBlock = """
+ a read-only database | 405 | {"code":"MAAS-0600","reason":"database is in read-only mode"}
+ an unreachable agent | 500 | {"error":"error proxying request: connection refused"}
+ """)
+ void testFailover_RetryableResponseSucceedsOnRetry(String description, int status, String body,
+ ClientAndServer mockServer) {
+ mockServer.when(request().withMethod("POST").withPath(PATH), Times.exactly(2))
+ .respond(response().withStatusCode(status).withBody(body));
+ mockServer.when(request().withMethod("POST").withPath(PATH), Times.unlimited())
+ .respond(response().withStatusCode(200).withBody("""
+ {
+ "cnn": "ampq://rabbit-cluster:4321/maas.core-dev.123456",
+ "username": "testuser",
+ "password": "plain:testpassword"
+ }
+ """));
+
+ withProp(Env.PROP_NAMESPACE, "core-dev", () ->
+ withFastRetries(() -> {
+ RabbitMaaSClientImpl client = createRabbitClient("http://localhost:" + mockServer.getPort());
+ assertNotNull(client.getOrCreateVirtualHost(new Classifier("commands")));
+ }));
+
+ mockServer.verify(request().withMethod("POST").withPath(PATH), VerificationTimes.exactly(3));
+ }
+
+ @Test
+ void testFailover_400NotRetried(ClientAndServer mockServer) {
+ mockServer.when(request().withMethod("POST").withPath(PATH), Times.unlimited())
+ .respond(response().withStatusCode(400).withBody("{\"error\":\"bad request\"}"));
+
+ withProp(Env.PROP_NAMESPACE, "core-dev", () ->
+ withFastRetries(() -> {
+ RabbitMaaSClientImpl client = createRabbitClient("http://localhost:" + mockServer.getPort());
+ Classifier classifier = new Classifier("commands");
+ assertThrows(MaaSHttpException.class, () -> client.getOrCreateVirtualHost(classifier));
+ }));
+
+ mockServer.verify(request().withMethod("POST").withPath(PATH), VerificationTimes.exactly(1));
+ }
+
+ // A short total duration is now the only lever: it bounds both the number of attempts
+ // and the pauses between them (the cap is derived as a quarter of it).
+ private static void withFastRetries(Runnable test) {
+ withProp(Env.PROP_HTTP_RETRY_MAX_TOTAL_DURATION_MS, "5000", test::run);
+ }
+
+ private static RabbitMaaSClientImpl createRabbitClient(String agentUrl) {
+ System.setProperty(Env.PROP_MAAS_AGENT_URL, agentUrl);
+ var httpClient = HttpClient.getMaasClient(() -> "faketoken");
+ var serverApiVersion = new ServerApiVersion(httpClient, agentUrl);
+ return new RabbitMaaSClientImpl(httpClient, new ApiUrlProvider(serverApiVersion, agentUrl));
+ }
+}
diff --git a/maas-client/pom.xml b/maas-client/pom.xml
index c847670231..8473452884 100644
--- a/maas-client/pom.xml
+++ b/maas-client/pom.xml
@@ -25,6 +25,7 @@
1.1.3-SNAPSHOT
4.2.1
+ 3.3.2
${project.basedir}/../report-aggregate/target/site/jacoco-aggregate/jacoco.xml
@@ -93,6 +94,11 @@
kafka-streams
${kafka.version}