Skip to content

Commit

Permalink
Add Toxiproxy module (#1330)
Browse files Browse the repository at this point in the history
Based on https://gist.github.com/rnorth/4c3666d62fa93bf0daa813b282e4ebff, in turn based on [hotels.com’s blog post](https://medium.com/hotels-com-technology/i-dont-know-about-resilience-testing-and-so-can-you-b3c59d80012d) where they show use of Testcontainers with Toxiproxy.

Having seen Toxiproxy and experimented with it, I'm inclined to go with this and suggest that we not move forward with #283 (which is a great PR, but we've unfortunately failed to land after quite a long time). While Pumba may be nice and transparent, there are a lot of moving parts and complexity under the covers, vs Toxiproxy, which is a bit less magical but easier to understand.
  • Loading branch information
rnorth committed Mar 22, 2019
1 parent 31b3610 commit 41987ef
Show file tree
Hide file tree
Showing 6 changed files with 318 additions and 4 deletions.
4 changes: 0 additions & 4 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@ language: java
jdk:
- oraclejdk8

branches:
only:
- master

sudo: required
services:
- docker
Expand Down
76 changes: 76 additions & 0 deletions docs/modules/toxiproxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Toxiproxy Module

Testcontainers module for Shopify's [Toxiproxy](https://github.com/Shopify/toxiproxy).
This TCP proxy can be used to simulate network failure conditions in between tests and containers.

[toxiproxy-java](https://github.com/trekawek/toxiproxy-java) is used as a client.

## Usage example

A Toxiproxy container can be placed in between test code and a container, or in between containers.
In either scenario, it is necessary to create a `ToxiproxyContainer` instance on the same Docker network, as follows:

<!--codeinclude-->
[Creating a Toxiproxy container](../../modules/toxiproxy/src/test/java/org/testcontainers/containers/ToxiproxyTest.java) inside_block:creatingProxy
<!--/codeinclude-->

Next, it is necessary to instruct Toxiproxy to start proxying connections.
Each `ToxiproxyContainer` can proxy to many target containers if necessary.

We do this as follows:

<!--codeinclude-->
[Starting proxying connections to a target container](../../modules/toxiproxy/src/test/java/org/testcontainers/containers/ToxiproxyTest.java) inside_block:obtainProxyObject
<!--/codeinclude-->

Then, to establish a connection via Toxiproxy, we obtain **Toxiproxy's** proxy host IP and port:

<!--codeinclude-->
[Obtaining proxied host and port](../../modules/toxiproxy/src/test/java/org/testcontainers/containers/ToxiproxyTest.java) inside_block:obtainProxiedHostAndPort
<!--/codeinclude-->

Code under test, or other containers, should connect to this proxied host IP and port.

Having done this, it is possible to trigger failure conditions ('Toxics') through the `proxy.toxics()` object:

* `bandwidth` - Limit a connection to a maximum number of kilobytes per second.
* `latency` - Add a delay to all data going through the proxy. The delay is equal to `latency +/- jitter`.
* `slicer` - Slices TCP data up into small bits, optionally adding a delay between each sliced "packet".
* `slowClose` - Delay the TCP socket from closing until `delay` milliseconds has elapsed.
* `timeout` - Stops all data from getting through, and closes the connection after `timeout`. If `timeout` is `0`, the connection won't close, and data will be delayed until the toxic is removed.
* `limitData` - Closes connection when transmitted data exceeded limit.

Please see the [Toxiproxy documentation](https://github.com/Shopify/toxiproxy#toxics) for full details on the available Toxics.

As one example, we can introduce latency and random jitter to proxied connections as follows:

<!--codeinclude-->
[Adding latency to a connection](../../modules/toxiproxy/src/test/java/org/testcontainers/containers/ToxiproxyTest.java) inside_block:addingLatency
<!--/codeinclude-->

Additionally we can disable the proxy to simulate a complete interruption to the network connection:

<!--codeinclude-->
[Cutting a connection](../../modules/toxiproxy/src/test/java/org/testcontainers/containers/ToxiproxyTest.java) inside_block:disableProxy
<!--/codeinclude-->

## Adding this module to your project dependencies

Add the following dependency to your `pom.xml`/`build.gradle` file:

```groovy tab='Gradle'
testCompile "org.testcontainers:toxiproxy:{{latest_version}}"
```

```xml tab='Maven'
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>toxiproxy</artifactId>
<version>{{latest_version}}</version>
<scope>test</scope>
</dependency>
```

## Acknowledgements

This module was inspired by a [hotels.com blog post](https://medium.com/hotels-com-technology/i-dont-know-about-resilience-testing-and-so-can-you-b3c59d80012d).
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ nav:
- modules/pulsar.md
- modules/localstack.md
- modules/mockserver.md
- modules/toxiproxy.md
- modules/nginx.md
- modules/vault.md
- Test framework integration:
Expand Down
9 changes: 9 additions & 0 deletions modules/toxiproxy/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
description = "Testcontainers :: Toxiproxy"

dependencies {
compile project(':testcontainers')
compile 'eu.rekawek.toxiproxy:toxiproxy-java:2.1.3'

testCompile 'redis.clients:jedis:3.0.1'
testCompile 'org.rnorth.visible-assertions:visible-assertions:2.1.2'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package org.testcontainers.containers;

import com.github.dockerjava.api.command.InspectContainerResponse;
import eu.rekawek.toxiproxy.Proxy;
import eu.rekawek.toxiproxy.ToxiproxyClient;
import eu.rekawek.toxiproxy.model.ToxicDirection;
import eu.rekawek.toxiproxy.model.ToxicList;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.testcontainers.containers.wait.strategy.HttpWaitStrategy;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

/**
* Container for resiliency testing using <a href="https://github.com/Shopify/toxiproxy">Toxiproxy</a>.
*/
public class ToxiproxyContainer extends GenericContainer<ToxiproxyContainer> {

private static final String IMAGE_NAME = "shopify/toxiproxy:2.1.0";
private static final int TOXIPROXY_CONTROL_PORT = 8474;
private static final int FIRST_PROXIED_PORT = 8666;
private static final int LAST_PROXIED_PORT = 8666 + 31;

private ToxiproxyClient client;
private final Map<String, ContainerProxy> proxies = new HashMap<>();
private final AtomicInteger nextPort = new AtomicInteger(FIRST_PROXIED_PORT);

public ToxiproxyContainer() {
this(IMAGE_NAME);
}

public ToxiproxyContainer(String imageName) {
super(imageName);
addExposedPorts(TOXIPROXY_CONTROL_PORT);
setWaitStrategy(new HttpWaitStrategy().forPath("/version").forPort(TOXIPROXY_CONTROL_PORT));

// allow up to 32 ports to be proxied (arbitrary value). Here we make the ports exposed; whether or not
// Toxiproxy will listen is controlled at runtime using getProxy(...)
for (int i = FIRST_PROXIED_PORT; i <= LAST_PROXIED_PORT; i++) {
addExposedPort(i);
}
}

@Override
protected void containerIsStarted(InspectContainerResponse containerInfo) {
client = new ToxiproxyClient(getContainerIpAddress(), getMappedPort(TOXIPROXY_CONTROL_PORT));
}

/**
* Obtain a {@link ContainerProxy} instance for target container that is managed by Testcontainers. The target
* container should be routable from this <b>from this {@link ToxiproxyContainer} instance</b> (e.g. on the same
* Docker {@link Network}).
*
* @param container target container
* @param port port number on the target service that should be proxied
* @return a {@link ContainerProxy} instance
*/
public ContainerProxy getProxy(GenericContainer<?> container, int port) {
return this.getProxy(container.getNetworkAliases().get(0), port);
}

/**
* Obtain a {@link ContainerProxy} instance for a specific hostname and port, which can be for any host
* that is routable <b>from this {@link ToxiproxyContainer} instance</b> (e.g. on the same
* Docker {@link Network} or on routable from the Docker host).
*
* <p><em>It is expected that {@link ToxiproxyContainer#getProxy(GenericContainer, int)} will be more
* useful in most scenarios, but this method is present to allow use of Toxiproxy in front of containers
* or external servers that are not managed by Testcontainers.</em></p>
*
* @param hostname hostname of target server to be proxied
* @param port port number on the target server that should be proxied
* @return a {@link ContainerProxy} instance
*/
public ContainerProxy getProxy(String hostname, int port) {
String upstream = hostname + ":" + port;

return proxies.computeIfAbsent(upstream, __ -> {
try {
final int toxiPort = nextPort.getAndIncrement();
if (toxiPort > LAST_PROXIED_PORT) {
throw new IllegalStateException("Maximum number of proxies exceeded");
}

final Proxy proxy = client.createProxy("name", "0.0.0.0:" + toxiPort, upstream);
return new ContainerProxy(proxy, getContainerIpAddress(), getMappedPort(toxiPort));
} catch (IOException e) {
throw new RuntimeException("Proxy could not be created", e);
}
});
}

@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public static class ContainerProxy {
private static final String CUT_CONNECTION_DOWNSTREAM = "CUT_CONNECTION_DOWNSTREAM";
private static final String CUT_CONNECTION_UPSTREAM = "CUT_CONNECTION_UPSTREAM";
private final Proxy toxi;
@Getter private final String containerIpAddress;
@Getter private final int proxyPort;
private boolean isCurrentlyCut;

public ToxicList toxics() {
return toxi.toxics();
}

/**
* Cuts the connection by setting bandwidth in both directions to zero.
* @param shouldCutConnection true if the connection should be cut, or false if it should be re-enabled
*/
public void setConnectionCut(boolean shouldCutConnection) {
try {
if (shouldCutConnection) {
toxics().bandwidth(CUT_CONNECTION_DOWNSTREAM, ToxicDirection.DOWNSTREAM, 0);
toxics().bandwidth(CUT_CONNECTION_UPSTREAM, ToxicDirection.UPSTREAM, 0);
isCurrentlyCut = true;
} else if (isCurrentlyCut) {
toxics().get(CUT_CONNECTION_DOWNSTREAM).remove();
toxics().get(CUT_CONNECTION_UPSTREAM).remove();
isCurrentlyCut = false;
}
} catch (IOException e) {
throw new RuntimeException("Could not control proxy", e);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package org.testcontainers.containers;

import eu.rekawek.toxiproxy.model.ToxicDirection;
import org.junit.Rule;
import org.junit.Test;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.exceptions.JedisConnectionException;

import java.io.IOException;

import static java.lang.String.format;
import static org.rnorth.visibleassertions.VisibleAssertions.*;

public class ToxiproxyTest {

// creatingProxy {
// Create a common docker network so that containers can communicate
@Rule
public Network network = Network.newNetwork();

// the target container - this could be anything
@Rule
public GenericContainer redis = new GenericContainer("redis:5.0.4")
.withExposedPorts(6379)
.withNetwork(network);

// Toxiproxy container, which will be used as a TCP proxy
@Rule
public ToxiproxyContainer toxiproxy = new ToxiproxyContainer()
.withNetwork(network);
// }

@Test
public void testDirect() {
final Jedis jedis = new Jedis(redis.getContainerIpAddress(), redis.getFirstMappedPort());
jedis.set("somekey", "somevalue");

final String s = jedis.get("somekey");
assertEquals("direct access to the container works OK", "somevalue", s);
}

@Test
public void testLatencyViaProxy() throws IOException {
// obtainProxyObject {
final ToxiproxyContainer.ContainerProxy proxy = toxiproxy.getProxy(redis, 6379);
// }

// obtainProxiedHostAndPort {
final String ipAddressViaToxiproxy = proxy.getContainerIpAddress();
final int portViaToxiproxy = proxy.getProxyPort();
// }

final Jedis jedis = new Jedis(ipAddressViaToxiproxy, portViaToxiproxy);
jedis.set("somekey", "somevalue");

checkCallWithLatency(jedis, "without interference", 0, 250);

// addingLatency {
proxy.toxics()
.latency("latency", ToxicDirection.DOWNSTREAM, 1_100)
.setJitter(100);
// from now on the connection latency should be from 1000-1200 ms.
// }

checkCallWithLatency(jedis, "with interference", 1_000, 1_500);
}

@Test
public void testConnectionCut() {
final ToxiproxyContainer.ContainerProxy proxy = toxiproxy.getProxy(redis, 6379);
final Jedis jedis = new Jedis(proxy.getContainerIpAddress(), proxy.getProxyPort());
jedis.set("somekey", "somevalue");

assertEquals("access to the container works OK before cutting the connection", "somevalue", jedis.get("somekey"));

// disableProxy {
proxy.setConnectionCut(true);

// for example, expect failure when the connection is cut
assertThrows("calls fail when the connection is cut",
JedisConnectionException.class, () -> {
jedis.get("somekey");
});

proxy.setConnectionCut(false);

// and with the connection re-established, expect success
assertEquals("access to the container works OK after re-establishing the connection", "somevalue", jedis.get("somekey"));
// }
}

private void checkCallWithLatency(Jedis jedis, final String description, int expectedMinLatency, long expectedMaxLatency) {
final long start = System.currentTimeMillis();
String s = jedis.get("somekey");
final long end = System.currentTimeMillis();
final long duration = end - start;

assertEquals(format("access to the container %s works OK", description), "somevalue", s);
assertTrue(format("%s there is at least %dms latency", description, expectedMinLatency), duration >= expectedMinLatency);
assertTrue(format("%s there is no more than %dms latency", description, expectedMaxLatency), duration < expectedMaxLatency);
}
}

0 comments on commit 41987ef

Please sign in to comment.