-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
6 changed files
with
318 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,10 +2,6 @@ language: java | |
jdk: | ||
- oraclejdk8 | ||
|
||
branches: | ||
only: | ||
- master | ||
|
||
sudo: required | ||
services: | ||
- docker | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
} |
130 changes: 130 additions & 0 deletions
130
modules/toxiproxy/src/main/java/org/testcontainers/containers/ToxiproxyContainer.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} | ||
} |
102 changes: 102 additions & 0 deletions
102
modules/toxiproxy/src/test/java/org/testcontainers/containers/ToxiproxyTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |