|
| 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 |
0 commit comments