Skip to content

Commit 4ee7153

Browse files
committed
feat: Add toxiproxy module
1 parent ba73759 commit 4ee7153

File tree

9 files changed

+430
-0
lines changed

9 files changed

+430
-0
lines changed

docs/modules/toxiproxy.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Toxiproxy Module
2+
3+
Testcontainers module for Shopify's [Toxiproxy](https://github.com/Shopify/toxiproxy).
4+
This TCP proxy can be used to simulate network failure conditions.
5+
6+
You can simulate network failures:
7+
8+
* between NodeJS code and containers, ideal for testing resilience features of client code
9+
* between containers, for testing resilience and emergent behaviour of multi-container systems
10+
* if desired, between NodeJS code/containers and external resources (non-Dockerized!), for scenarios where not all dependencies can be/have been dockerized
11+
12+
Testcontainers Toxiproxy support allows resilience features to be easily verified as part of isolated dev/CI testing. This allows earlier testing of resilience features, and broader sets of failure conditions to be covered.
13+
14+
## Install
15+
```bash
16+
npm install @testcontainers/toxiproxy --save-dev
17+
```
18+
19+
## Usage example
20+
21+
A Toxiproxy container can be placed in between test code and a container, or in between containers.
22+
In either scenario, it is necessary to create a `ToxiProxyContainer` instance on the same Docker network.
23+
24+
Next, it is necessary to instruct Toxiproxy to start proxying connections.
25+
Each `ToxiProxyContainer` can proxy to many target containers if necessary.
26+
27+
A proxy is created by calling `createProxy` on the `ToxiProxyContainer` instance.
28+
29+
The client connecting to the proxied endpoint then needs to use the exposed port from the returned proxy.
30+
31+
All of this is done as follows:
32+
<!--codeinclude-->
33+
[Creating, starting and using the container:](../../packages/modules/toxiproxy/src/toxiproxy-container.test.ts) inside_block:create_proxy
34+
<!--/codeinclude-->
35+
36+
!!! note
37+
Currently, `ToxiProxyContainer` will reserve 31 ports, starting at 8666. After this, trying to create a new proxy instance will throw an error.
38+
39+
40+
Having done all of this, it is possible to trigger failure conditions ('Toxics') through the `proxy.instance.addToxic<TPClient.TOXIC_TYPE>()` object:
41+
42+
`TPClient` is the internal `toxiproxy-node-client` re-exported in this package.
43+
44+
* `bandwidth` - Limit a connection to a maximum number of kilobytes per second.
45+
* `latency` - Add a delay to all data going through the proxy. The delay is equal to `latency +/- jitter`.
46+
* `slicer` - Slices TCP data up into small bits, optionally adding a delay between each sliced "packet".
47+
* `slow_close` - Delay the TCP socket from closing until `delay` milliseconds has elapsed.
48+
* `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.
49+
* `limit_data` - Closes connection when transmitted data exceeded limit.
50+
* `reset_peer` - Simulate TCP RESET (Connection reset by peer) on the connections
51+
52+
Please see the [Toxiproxy documentation](https://github.com/Shopify/toxiproxy#toxics) and the [toxiproxy-node-client](https://github.com/ihsw/toxiproxy-node-client) for full details on the available Toxics.
53+
54+
As one example, we can introduce latency and random jitter to proxied connections as follows:
55+
56+
<!--codeinclude-->
57+
[Adding latency to a connection](../../packages/modules/toxiproxy/src/toxiproxy-container.test.ts) inside_block:adding_toxic
58+
<!--/codeinclude-->
59+
60+
There is also a helper method to enable / disable specific proxy instances. This can also be done by calling the `proxy.instance.update` method, however
61+
you'll need to supply the upstream again and the internal listening port.
62+
63+
<!--codeinclude-->
64+
[Enable and disable the proxy:](../../packages/modules/toxiproxy/src/toxiproxy-container.test.ts) inside_block:enabled_disabled
65+
<!--/codeinclude-->
66+
67+
## Acknowledgements
68+
69+
This module was inspired by the Java implementation, and under the hood uses the [toxiproxy-node-client](https://github.com/ihsw/toxiproxy-node-client).

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,6 @@ nav:
6767
- Redpanda: modules/redpanda.md
6868
- ScyllaDB: modules/scylladb.md
6969
- Selenium: modules/selenium.md
70+
- ToxiProxy: modules/toxiproxy.md
7071
- Weaviate: modules/weaviate.md
7172
- Configuration: configuration.md
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { Config } from "jest";
2+
import * as path from "path";
3+
4+
const config: Config = {
5+
preset: "ts-jest",
6+
moduleNameMapper: {
7+
"^testcontainers$": path.resolve(__dirname, "../../testcontainers/src"),
8+
},
9+
};
10+
11+
export default config;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"name": "@testcontainers/toxiproxy",
3+
"version": "10.16.0",
4+
"license": "MIT",
5+
"keywords": [
6+
"toxiproxy",
7+
"testing",
8+
"docker",
9+
"testcontainers"
10+
],
11+
"description": "Toxiproxy module for Testcontainers",
12+
"homepage": "https://github.com/testcontainers/testcontainers-node#readme",
13+
"repository": {
14+
"type": "git",
15+
"url": "https://github.com/testcontainers/testcontainers-node"
16+
},
17+
"bugs": {
18+
"url": "https://github.com/testcontainers/testcontainers-node/issues"
19+
},
20+
"main": "build/index.js",
21+
"files": [
22+
"build"
23+
],
24+
"publishConfig": {
25+
"access": "public"
26+
},
27+
"scripts": {
28+
"prepack": "shx cp ../../../README.md . && shx cp ../../../LICENSE .",
29+
"build": "tsc --project tsconfig.build.json"
30+
},
31+
"dependencies": {
32+
"testcontainers": "^10.16.0",
33+
"toxiproxy-node-client": "^4.0.0"
34+
},
35+
"devDependencies": {
36+
"@testcontainers/redis": "^10.16.0",
37+
"redis": "^4.7.0"
38+
}
39+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ToxiProxyContainer, StartedToxiProxyContainer, CreatedProxy } from "./toxiproxy-container";
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { ToxiProxyContainer, TPClient } from "./toxiproxy-container";
2+
import { GenericContainer, Network } from "testcontainers";
3+
import { createClient } from "redis";
4+
5+
describe("ToxiProxyContainer", () => {
6+
jest.setTimeout(240_000);
7+
8+
// Helper to connect to redis
9+
async function connectTo(url: string) {
10+
const client = createClient({
11+
url,
12+
});
13+
client.on("error", () => {}); // Ignore errors
14+
await client.connect();
15+
expect(client.isOpen).toBeTruthy();
16+
return client;
17+
}
18+
19+
// create_proxy {
20+
it("Should create a proxy to an endpoint", async () => {
21+
const containerNetwork = await new Network().start();
22+
const redisContainer = await new GenericContainer("redis:7.2")
23+
.withNetwork(containerNetwork)
24+
.withNetworkAliases("redis")
25+
.start();
26+
27+
const toxiproxyContainer = await new ToxiProxyContainer().withNetwork(containerNetwork).start();
28+
29+
// Create the proxy between Toxiproxy and Redis
30+
const redisProxy = await toxiproxyContainer.createProxy({
31+
name: "redis",
32+
upstream: "redis:6379",
33+
});
34+
35+
const url = `redis://${redisProxy.host}:${redisProxy.port}`;
36+
const client = await connectTo(url);
37+
await client.set("key", "val");
38+
expect(await client.get("key")).toBe("val");
39+
40+
await client.disconnect();
41+
await toxiproxyContainer.stop();
42+
await redisContainer.stop();
43+
});
44+
// }
45+
46+
// enabled_disabled {
47+
it("Should enable and disable a proxy", async () => {
48+
const containerNetwork = await new Network().start();
49+
const redisContainer = await new GenericContainer("redis:7.2")
50+
.withNetwork(containerNetwork)
51+
.withNetworkAliases("redis")
52+
.start();
53+
54+
const toxiproxyContainer = await new ToxiProxyContainer().withNetwork(containerNetwork).start();
55+
56+
// Create the proxy between Toxiproxy and Redis
57+
const redisProxy = await toxiproxyContainer.createProxy({
58+
name: "redis",
59+
upstream: "redis:6379",
60+
});
61+
62+
const url = `redis://${redisProxy.host}:${redisProxy.port}`;
63+
const client = await connectTo(url);
64+
65+
await client.set("key", "val");
66+
expect(await client.get("key")).toBe("val");
67+
68+
// Disable any new connections to the proxy
69+
await redisProxy.setEnabled(false);
70+
71+
await expect(client.ping()).rejects.toThrow();
72+
73+
// Enable the proxy again
74+
await redisProxy.setEnabled(true);
75+
76+
expect(await client.ping()).toBe("PONG");
77+
78+
await client.disconnect();
79+
await toxiproxyContainer.stop();
80+
await redisContainer.stop();
81+
});
82+
// }
83+
84+
// adding_toxic {
85+
it("Should add a toxic to a proxy and then remove", async () => {
86+
const containerNetwork = await new Network().start();
87+
const redisContainer = await new GenericContainer("redis:7.2")
88+
.withNetwork(containerNetwork)
89+
.withNetworkAliases("redis")
90+
.start();
91+
92+
const toxiproxyContainer = await new ToxiProxyContainer().withNetwork(containerNetwork).start();
93+
94+
// Create the proxy between Toxiproxy and Redis
95+
const redisProxy = await toxiproxyContainer.createProxy({
96+
name: "redis",
97+
upstream: "redis:6379",
98+
});
99+
100+
const url = `redis://${redisProxy.host}:${redisProxy.port}`;
101+
const client = await connectTo(url);
102+
103+
// See https://github.com/ihsw/toxiproxy-node-client for details on the instance interface
104+
const toxic = await redisProxy.instance.addToxic<TPClient.Latency>({
105+
attributes: {
106+
jitter: 50,
107+
latency: 1500,
108+
},
109+
name: "upstream-latency",
110+
stream: "upstream",
111+
toxicity: 1, // 1 is 100%
112+
type: "latency",
113+
});
114+
115+
const before = Date.now();
116+
await client.ping();
117+
const after = Date.now();
118+
expect(after - before).toBeGreaterThan(1000);
119+
120+
await toxic.remove();
121+
122+
await client.disconnect();
123+
await toxiproxyContainer.stop();
124+
await redisContainer.stop();
125+
});
126+
// }
127+
128+
it("Should create multiple proxies", async () => {
129+
const containerNetwork = await new Network().start();
130+
const redisContainer = await new GenericContainer("redis:7.2")
131+
.withNetwork(containerNetwork)
132+
.withNetworkAliases("redis")
133+
.start();
134+
135+
const toxiproxyContainer = await new ToxiProxyContainer().withNetwork(containerNetwork).start();
136+
137+
// Create the proxy between Toxiproxy and Redis
138+
const redisProxy = await toxiproxyContainer.createProxy({
139+
name: "redis",
140+
upstream: "redis:6379",
141+
});
142+
143+
// Create the proxy between Toxiproxy and Redis
144+
const redisProxy2 = await toxiproxyContainer.createProxy({
145+
name: "redis2",
146+
upstream: "redis:6379",
147+
});
148+
149+
const url = `redis://${redisProxy.host}:${redisProxy.port}`;
150+
const client = await connectTo(url);
151+
await client.set("key", "val");
152+
expect(await client.get("key")).toBe("val");
153+
154+
const url2 = `redis://${redisProxy2.host}:${redisProxy2.port}`;
155+
const client2 = await connectTo(url2);
156+
expect(await client2.get("key")).toBe("val");
157+
158+
await client.disconnect();
159+
await client2.disconnect();
160+
await toxiproxyContainer.stop();
161+
await redisContainer.stop();
162+
});
163+
164+
it("Throws an error when too many proxies are created", async () => {
165+
const toxiproxyContainer = await new ToxiProxyContainer().start();
166+
167+
for (let i = 0; i < 32; i++) {
168+
await toxiproxyContainer.createProxy({
169+
name: "test-" + i,
170+
upstream: `google.com:80`,
171+
});
172+
}
173+
174+
await expect(
175+
toxiproxyContainer.createProxy({
176+
name: "test-32",
177+
upstream: `google.com:80`,
178+
})
179+
).rejects.toThrow();
180+
181+
await toxiproxyContainer.stop();
182+
});
183+
});

0 commit comments

Comments
 (0)