Skip to content

Commit dea0229

Browse files
committed
Add toxiproxy support
1 parent 81ba611 commit dea0229

4 files changed

Lines changed: 605 additions & 3 deletions

File tree

lib/container/selenium_container.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ defmodule Testcontainers.SeleniumContainer do
7878
new(config.image)
7979
|> with_exposed_ports([config.port1, config.port2])
8080
|> with_waiting_strategies([
81-
LogWaitStrategy.new(~r/.*(RemoteWebDriver instances should connect to|Selenium Server is up and running|Started Selenium Standalone).*\n/, config.wait_timeout, 1000),
81+
LogWaitStrategy.new(
82+
~r/.*(RemoteWebDriver instances should connect to|Selenium Server is up and running|Started Selenium Standalone).*\n/,
83+
config.wait_timeout,
84+
1000
85+
),
8286
PortWaitStrategy.new("127.0.0.1", config.port1, config.wait_timeout, 1000),
8387
PortWaitStrategy.new("127.0.0.1", config.port2, config.wait_timeout, 1000)
8488
])
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
# SPDX-License-Identifier: MIT
2+
defmodule Testcontainers.ToxiproxyContainer do
3+
@moduledoc """
4+
Provides functionality for creating and managing Toxiproxy container configurations.
5+
6+
Toxiproxy is a framework for simulating network conditions. It's made specifically
7+
to work in testing, CI and development environments, supporting deterministic tampering
8+
with connections, but with support for randomized chaos and customization.
9+
"""
10+
11+
alias Testcontainers.Container
12+
alias Testcontainers.ContainerBuilder
13+
alias Testcontainers.PortWaitStrategy
14+
alias Testcontainers.ToxiproxyContainer
15+
16+
@default_image "ghcr.io/shopify/toxiproxy"
17+
@default_tag "2.9.0"
18+
@default_image_with_tag "#{@default_image}:#{@default_tag}"
19+
20+
# Toxiproxy control/API port
21+
@control_port 8474
22+
23+
@first_proxy_port 8666
24+
@proxy_port_count 31
25+
26+
@default_wait_timeout 60_000
27+
28+
@enforce_keys [:image, :wait_timeout]
29+
defstruct [:image, :wait_timeout, check_image: @default_image, reuse: false]
30+
31+
@doc """
32+
Creates a new `ToxiproxyContainer` struct with default configurations.
33+
"""
34+
def new do
35+
%__MODULE__{
36+
image: @default_image_with_tag,
37+
wait_timeout: @default_wait_timeout
38+
}
39+
end
40+
41+
@doc """
42+
Overrides the default image used for the Toxiproxy container.
43+
"""
44+
def with_image(%__MODULE__{} = config, image) when is_binary(image) do
45+
%{config | image: image}
46+
end
47+
48+
@doc """
49+
Overrides the default wait timeout used for the Toxiproxy container.
50+
"""
51+
def with_wait_timeout(%__MODULE__{} = config, wait_timeout) when is_integer(wait_timeout) do
52+
%{config | wait_timeout: wait_timeout}
53+
end
54+
55+
@doc """
56+
Set the reuse flag to reuse the container if it is already running.
57+
"""
58+
def with_reuse(%__MODULE__{} = config, reuse) when is_boolean(reuse) do
59+
%__MODULE__{config | reuse: reuse}
60+
end
61+
62+
@doc """
63+
Retrieves the default Docker image for the Toxiproxy container.
64+
"""
65+
def default_image, do: @default_image_with_tag
66+
67+
@doc """
68+
Returns the control port number (for the Toxiproxy HTTP API).
69+
"""
70+
def control_port, do: @control_port
71+
72+
@doc """
73+
Returns the first proxy port number.
74+
"""
75+
def first_proxy_port, do: @first_proxy_port
76+
77+
@doc """
78+
Returns the mapped control port on the host for the running container.
79+
"""
80+
def mapped_control_port(%Container{} = container) do
81+
Container.mapped_port(container, @control_port)
82+
end
83+
84+
@doc """
85+
Returns the URI for the Toxiproxy API.
86+
87+
This can be used with ToxiproxyEx:
88+
89+
ToxiproxyContainer.api_url(container)
90+
|> then(&Application.put_env(:toxiproxy_ex, :host, &1))
91+
"""
92+
def api_url(%Container{} = container) do
93+
host = Testcontainers.get_host()
94+
port = mapped_control_port(container)
95+
"http://#{host}:#{port}"
96+
end
97+
98+
@doc """
99+
Configures the ToxiproxyEx library to use this container.
100+
101+
This sets the `:toxiproxy_ex` application environment to point to
102+
the running container's API endpoint.
103+
104+
## Example
105+
106+
{:ok, toxiproxy} = Testcontainers.start_container(ToxiproxyContainer.new())
107+
:ok = ToxiproxyContainer.configure_toxiproxy_ex(toxiproxy)
108+
109+
# Now ToxiproxyEx will use this container
110+
ToxiproxyEx.get!("my_proxy") |> ToxiproxyEx.down!(fn -> ... end)
111+
"""
112+
def configure_toxiproxy_ex(%Container{} = container) do
113+
Application.put_env(:toxiproxy_ex, :host, api_url(container))
114+
:ok
115+
end
116+
117+
@doc """
118+
Creates a proxy in Toxiproxy that routes traffic from a container port to an upstream service.
119+
120+
## Parameters
121+
122+
- `container` - The running Toxiproxy container
123+
- `name` - A unique name for the proxy
124+
- `upstream` - The upstream address in format "host:port" (as seen from Toxiproxy container)
125+
- `opts` - Optional keyword list:
126+
- `:listen_port` - Specific port to listen on (default: auto-allocated from 8666+)
127+
"""
128+
def create_proxy(%Container{} = container, name, upstream, opts \\ []) do
129+
listen_port = Keyword.get(opts, :listen_port, @first_proxy_port)
130+
131+
host = Testcontainers.get_host()
132+
api_port = mapped_control_port(container)
133+
134+
:inets.start()
135+
136+
url = ~c"http://#{host}:#{api_port}/proxies"
137+
138+
body =
139+
Jason.encode!(%{
140+
name: name,
141+
listen: "0.0.0.0:#{listen_port}",
142+
upstream: upstream
143+
})
144+
145+
headers = [{~c"content-type", ~c"application/json"}]
146+
147+
case :httpc.request(:post, {url, headers, ~c"application/json", body}, [], []) do
148+
{:ok, {{_, code, _}, _, _}} when code in [200, 201] ->
149+
# Return the mapped port on the host
150+
{:ok, Container.mapped_port(container, listen_port)}
151+
152+
{:ok, {{_, 409, _}, _, _}} ->
153+
# Proxy already exists, return the port
154+
{:ok, Container.mapped_port(container, listen_port)}
155+
156+
{:ok, {{_, code, _}, _, response_body}} ->
157+
{:error, {:http_error, code, response_body}}
158+
159+
{:error, reason} ->
160+
{:error, reason}
161+
end
162+
end
163+
164+
@doc """
165+
Creates a proxy for another container on the same network.
166+
167+
This is a convenience function that creates a proxy using the target container's
168+
hostname and port.
169+
170+
## Parameters
171+
172+
- `toxiproxy` - The running Toxiproxy container
173+
- `name` - A unique name for the proxy
174+
- `target_container` - The target container to proxy to
175+
- `target_port` - The port on the target container
176+
- `opts` - Optional keyword list (see `create_proxy/4`)
177+
"""
178+
def create_proxy_for_container(
179+
%Container{} = toxiproxy,
180+
name,
181+
%Container{} = target_container,
182+
target_port,
183+
opts \\ []
184+
) do
185+
# Use the target container's IP address on the Docker network
186+
upstream = "#{target_container.ip_address}:#{target_port}"
187+
create_proxy(toxiproxy, name, upstream, opts)
188+
end
189+
190+
@doc """
191+
Deletes a proxy from Toxiproxy.
192+
"""
193+
def delete_proxy(%Container{} = container, name) do
194+
host = Testcontainers.get_host()
195+
api_port = mapped_control_port(container)
196+
197+
:inets.start()
198+
199+
url = ~c"http://#{host}:#{api_port}/proxies/#{name}"
200+
201+
case :httpc.request(:delete, {url, []}, [], []) do
202+
{:ok, {{_, 204, _}, _, _}} -> :ok
203+
{:ok, {{_, 404, _}, _, _}} -> {:error, :not_found}
204+
{:ok, {{_, code, _}, _, body}} -> {:error, {:http_error, code, body}}
205+
{:error, reason} -> {:error, reason}
206+
end
207+
end
208+
209+
@doc """
210+
Resets Toxiproxy, removing all toxics and re-enabling all proxies.
211+
"""
212+
def reset(%Container{} = container) do
213+
host = Testcontainers.get_host()
214+
api_port = mapped_control_port(container)
215+
216+
:inets.start()
217+
218+
url = ~c"http://#{host}:#{api_port}/reset"
219+
220+
case :httpc.request(:post, {url, [], ~c"application/json", "{}"}, [], []) do
221+
{:ok, {{_, 204, _}, _, _}} -> :ok
222+
{:ok, {{_, code, _}, _, body}} -> {:error, {:http_error, code, body}}
223+
{:error, reason} -> {:error, reason}
224+
end
225+
end
226+
227+
@doc """
228+
Lists all proxies configured in Toxiproxy.
229+
230+
Returns a map of proxy names to their configurations.
231+
"""
232+
def list_proxies(%Container{} = container) do
233+
host = Testcontainers.get_host()
234+
api_port = mapped_control_port(container)
235+
236+
:inets.start()
237+
238+
url = ~c"http://#{host}:#{api_port}/proxies"
239+
240+
case :httpc.request(:get, {url, []}, [], []) do
241+
{:ok, {{_, 200, _}, _, body}} ->
242+
{:ok, Jason.decode!(to_string(body))}
243+
244+
{:ok, {{_, code, _}, _, body}} ->
245+
{:error, {:http_error, code, body}}
246+
247+
{:error, reason} ->
248+
{:error, reason}
249+
end
250+
end
251+
252+
@doc """
253+
Returns the number of proxy ports reserved.
254+
"""
255+
def proxy_port_count, do: @proxy_port_count
256+
257+
# ContainerBuilder implementation
258+
defimpl ContainerBuilder do
259+
import Container
260+
261+
@impl true
262+
def build(%ToxiproxyContainer{} = config) do
263+
# Build list of ports to expose: control port + proxy ports
264+
proxy_ports =
265+
Enum.to_list(
266+
ToxiproxyContainer.first_proxy_port()..(ToxiproxyContainer.first_proxy_port() +
267+
ToxiproxyContainer.proxy_port_count() - 1)
268+
)
269+
270+
all_ports = [ToxiproxyContainer.control_port() | proxy_ports]
271+
272+
new(config.image)
273+
|> with_exposed_ports(all_ports)
274+
|> with_waiting_strategy(
275+
PortWaitStrategy.new(
276+
"127.0.0.1",
277+
ToxiproxyContainer.control_port(),
278+
config.wait_timeout
279+
)
280+
)
281+
|> with_reuse(config.reuse)
282+
end
283+
284+
@impl true
285+
def after_start(_config, _container, _conn), do: :ok
286+
end
287+
end

lib/mix/tasks/testcontainers/run.ex

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ defmodule Mix.Tasks.Testcontainers.Run do
5151
run_sub_task_and_exit(sub_task, sub_task_args, env)
5252
end
5353

54-
@spec run_sub_task_and_exit(String.t(), list(String.t()), list({String.t(), String.t()})) :: no_return()
54+
@spec run_sub_task_and_exit(String.t(), list(String.t()), list({String.t(), String.t()})) ::
55+
no_return()
5556
defp run_sub_task_and_exit(sub_task, sub_task_args, env) do
56-
5757
IO.puts("Starting mix task: #{sub_task} #{Enum.join(sub_task_args, " ")}")
5858

5959
Enum.each(env, fn {k, v} -> System.put_env(k, v) end)
@@ -97,11 +97,13 @@ defmodule Mix.Tasks.Testcontainers.Run do
9797
end
9898

9999
defp maybe_with_host_port(config, nil, _exposed_port, _module), do: config
100+
100101
defp maybe_with_host_port(config, host_port, exposed_port, module) do
101102
module.with_port(config, {exposed_port, host_port})
102103
end
103104

104105
defp maybe_with_persistent_volume(config, nil, _module), do: config
106+
105107
defp maybe_with_persistent_volume(config, db_volume, module) do
106108
module.with_persistent_volume(config, db_volume)
107109
end

0 commit comments

Comments
 (0)