diff --git a/lib/container.ex b/lib/container.ex index a5d284f..8c240dc 100644 --- a/lib/container.ex +++ b/lib/container.ex @@ -25,6 +25,8 @@ defmodule Testcontainers.Container do container_id: nil, check_image: nil, network_mode: nil, + network: nil, + hostname: nil, reuse: false, force_reuse: false, pull_policy: Testcontainers.PullPolicy.always_pull() @@ -259,6 +261,26 @@ defmodule Testcontainers.Container do %__MODULE__{config | network_mode: mode} end + @doc """ + Sets the Docker network for the container to join. + + Containers on the same network can communicate with each other using their + hostnames. Use `with_hostname/2` to set a custom hostname for the container. + """ + def with_network(%__MODULE__{} = config, network_name) when is_binary(network_name) do + %__MODULE__{config | network: network_name} + end + + @doc """ + Sets the hostname for the container. + + This is useful when containers need to communicate with each other by hostname + on a shared Docker network. + """ + def with_hostname(%__MODULE__{} = config, hostname) when is_binary(hostname) do + %__MODULE__{config | hostname: hostname} + end + @doc """ Gets the host port on the container for the given exposed port. """ diff --git a/lib/container/minio_container.ex b/lib/container/minio_container.ex index 8b09b24..8610e1d 100644 --- a/lib/container/minio_container.ex +++ b/lib/container/minio_container.ex @@ -83,7 +83,12 @@ defmodule Testcontainers.MinioContainer do |> with_environment(:MINIO_ROOT_USER, config.username) |> with_environment(:MINIO_ROOT_PASSWORD, config.password) |> with_reuse(config.reuse) - |> with_cmd(["server", "--console-address", ":#{MinioContainer.default_ui_port()}", "/data"]) + |> with_cmd([ + "server", + "--console-address", + ":#{MinioContainer.default_ui_port()}", + "/data" + ]) |> with_waiting_strategy( LogWaitStrategy.new(~r/.*Status: 1 Online, 0 Offline..*/, config.wait_timeout) ) diff --git a/lib/container/selenium_container.ex b/lib/container/selenium_container.ex index 19ef7af..083972d 100644 --- a/lib/container/selenium_container.ex +++ b/lib/container/selenium_container.ex @@ -78,7 +78,11 @@ defmodule Testcontainers.SeleniumContainer do new(config.image) |> with_exposed_ports([config.port1, config.port2]) |> with_waiting_strategies([ - LogWaitStrategy.new(~r/.*(RemoteWebDriver instances should connect to|Selenium Server is up and running|Started Selenium Standalone).*\n/, config.wait_timeout, 1000), + LogWaitStrategy.new( + ~r/.*(RemoteWebDriver instances should connect to|Selenium Server is up and running|Started Selenium Standalone).*\n/, + config.wait_timeout, + 1000 + ), PortWaitStrategy.new("127.0.0.1", config.port1, config.wait_timeout, 1000), PortWaitStrategy.new("127.0.0.1", config.port2, config.wait_timeout, 1000) ]) diff --git a/lib/container/toxiproxy_container.ex b/lib/container/toxiproxy_container.ex new file mode 100644 index 0000000..a21083f --- /dev/null +++ b/lib/container/toxiproxy_container.ex @@ -0,0 +1,287 @@ +# SPDX-License-Identifier: MIT +defmodule Testcontainers.ToxiproxyContainer do + @moduledoc """ + Provides functionality for creating and managing Toxiproxy container configurations. + + Toxiproxy is a framework for simulating network conditions. It's made specifically + to work in testing, CI and development environments, supporting deterministic tampering + with connections, but with support for randomized chaos and customization. + """ + + alias Testcontainers.Container + alias Testcontainers.ContainerBuilder + alias Testcontainers.PortWaitStrategy + alias Testcontainers.ToxiproxyContainer + + @default_image "ghcr.io/shopify/toxiproxy" + @default_tag "2.9.0" + @default_image_with_tag "#{@default_image}:#{@default_tag}" + + # Toxiproxy control/API port + @control_port 8474 + + @first_proxy_port 8666 + @proxy_port_count 31 + + @default_wait_timeout 60_000 + + @enforce_keys [:image, :wait_timeout] + defstruct [:image, :wait_timeout, check_image: @default_image, reuse: false] + + @doc """ + Creates a new `ToxiproxyContainer` struct with default configurations. + """ + def new do + %__MODULE__{ + image: @default_image_with_tag, + wait_timeout: @default_wait_timeout + } + end + + @doc """ + Overrides the default image used for the Toxiproxy container. + """ + def with_image(%__MODULE__{} = config, image) when is_binary(image) do + %{config | image: image} + end + + @doc """ + Overrides the default wait timeout used for the Toxiproxy container. + """ + def with_wait_timeout(%__MODULE__{} = config, wait_timeout) when is_integer(wait_timeout) do + %{config | wait_timeout: wait_timeout} + end + + @doc """ + Set the reuse flag to reuse the container if it is already running. + """ + def with_reuse(%__MODULE__{} = config, reuse) when is_boolean(reuse) do + %__MODULE__{config | reuse: reuse} + end + + @doc """ + Retrieves the default Docker image for the Toxiproxy container. + """ + def default_image, do: @default_image_with_tag + + @doc """ + Returns the control port number (for the Toxiproxy HTTP API). + """ + def control_port, do: @control_port + + @doc """ + Returns the first proxy port number. + """ + def first_proxy_port, do: @first_proxy_port + + @doc """ + Returns the mapped control port on the host for the running container. + """ + def mapped_control_port(%Container{} = container) do + Container.mapped_port(container, @control_port) + end + + @doc """ + Returns the URI for the Toxiproxy API. + + This can be used with ToxiproxyEx: + + ToxiproxyContainer.api_url(container) + |> then(&Application.put_env(:toxiproxy_ex, :host, &1)) + """ + def api_url(%Container{} = container) do + host = Testcontainers.get_host() + port = mapped_control_port(container) + "http://#{host}:#{port}" + end + + @doc """ + Configures the ToxiproxyEx library to use this container. + + This sets the `:toxiproxy_ex` application environment to point to + the running container's API endpoint. + + ## Example + + {:ok, toxiproxy} = Testcontainers.start_container(ToxiproxyContainer.new()) + :ok = ToxiproxyContainer.configure_toxiproxy_ex(toxiproxy) + + # Now ToxiproxyEx will use this container + ToxiproxyEx.get!("my_proxy") |> ToxiproxyEx.down!(fn -> ... end) + """ + def configure_toxiproxy_ex(%Container{} = container) do + Application.put_env(:toxiproxy_ex, :host, api_url(container)) + :ok + end + + @doc """ + Creates a proxy in Toxiproxy that routes traffic from a container port to an upstream service. + + ## Parameters + + - `container` - The running Toxiproxy container + - `name` - A unique name for the proxy + - `upstream` - The upstream address in format "host:port" (as seen from Toxiproxy container) + - `opts` - Optional keyword list: + - `:listen_port` - Specific port to listen on (default: auto-allocated from 8666+) + """ + def create_proxy(%Container{} = container, name, upstream, opts \\ []) do + listen_port = Keyword.get(opts, :listen_port, @first_proxy_port) + + host = Testcontainers.get_host() + api_port = mapped_control_port(container) + + :inets.start() + + url = ~c"http://#{host}:#{api_port}/proxies" + + body = + Jason.encode!(%{ + name: name, + listen: "0.0.0.0:#{listen_port}", + upstream: upstream + }) + + headers = [{~c"content-type", ~c"application/json"}] + + case :httpc.request(:post, {url, headers, ~c"application/json", body}, [], []) do + {:ok, {{_, code, _}, _, _}} when code in [200, 201] -> + # Return the mapped port on the host + {:ok, Container.mapped_port(container, listen_port)} + + {:ok, {{_, 409, _}, _, _}} -> + # Proxy already exists, return the port + {:ok, Container.mapped_port(container, listen_port)} + + {:ok, {{_, code, _}, _, response_body}} -> + {:error, {:http_error, code, response_body}} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Creates a proxy for another container on the same network. + + This is a convenience function that creates a proxy using the target container's + hostname and port. + + ## Parameters + + - `toxiproxy` - The running Toxiproxy container + - `name` - A unique name for the proxy + - `target_container` - The target container to proxy to + - `target_port` - The port on the target container + - `opts` - Optional keyword list (see `create_proxy/4`) + """ + def create_proxy_for_container( + %Container{} = toxiproxy, + name, + %Container{} = target_container, + target_port, + opts \\ [] + ) do + # Use the target container's IP address on the Docker network + upstream = "#{target_container.ip_address}:#{target_port}" + create_proxy(toxiproxy, name, upstream, opts) + end + + @doc """ + Deletes a proxy from Toxiproxy. + """ + def delete_proxy(%Container{} = container, name) do + host = Testcontainers.get_host() + api_port = mapped_control_port(container) + + :inets.start() + + url = ~c"http://#{host}:#{api_port}/proxies/#{name}" + + case :httpc.request(:delete, {url, []}, [], []) do + {:ok, {{_, 204, _}, _, _}} -> :ok + {:ok, {{_, 404, _}, _, _}} -> {:error, :not_found} + {:ok, {{_, code, _}, _, body}} -> {:error, {:http_error, code, body}} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Resets Toxiproxy, removing all toxics and re-enabling all proxies. + """ + def reset(%Container{} = container) do + host = Testcontainers.get_host() + api_port = mapped_control_port(container) + + :inets.start() + + url = ~c"http://#{host}:#{api_port}/reset" + + case :httpc.request(:post, {url, [], ~c"application/json", "{}"}, [], []) do + {:ok, {{_, 204, _}, _, _}} -> :ok + {:ok, {{_, code, _}, _, body}} -> {:error, {:http_error, code, body}} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Lists all proxies configured in Toxiproxy. + + Returns a map of proxy names to their configurations. + """ + def list_proxies(%Container{} = container) do + host = Testcontainers.get_host() + api_port = mapped_control_port(container) + + :inets.start() + + url = ~c"http://#{host}:#{api_port}/proxies" + + case :httpc.request(:get, {url, []}, [], []) do + {:ok, {{_, 200, _}, _, body}} -> + {:ok, Jason.decode!(to_string(body))} + + {:ok, {{_, code, _}, _, body}} -> + {:error, {:http_error, code, body}} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Returns the number of proxy ports reserved. + """ + def proxy_port_count, do: @proxy_port_count + + # ContainerBuilder implementation + defimpl ContainerBuilder do + import Container + + @impl true + def build(%ToxiproxyContainer{} = config) do + # Build list of ports to expose: control port + proxy ports + proxy_ports = + Enum.to_list( + ToxiproxyContainer.first_proxy_port()..(ToxiproxyContainer.first_proxy_port() + + ToxiproxyContainer.proxy_port_count() - 1) + ) + + all_ports = [ToxiproxyContainer.control_port() | proxy_ports] + + new(config.image) + |> with_exposed_ports(all_ports) + |> with_waiting_strategy( + PortWaitStrategy.new( + "127.0.0.1", + ToxiproxyContainer.control_port(), + config.wait_timeout + ) + ) + |> with_reuse(config.reuse) + end + + @impl true + def after_start(_config, _container, _conn), do: :ok + end +end diff --git a/lib/docker/api.ex b/lib/docker/api.ex index ed4ee76..a252e78 100644 --- a/lib/docker/api.ex +++ b/lib/docker/api.ex @@ -182,6 +182,69 @@ defmodule Testcontainers.Docker.Api do end end + @doc """ + Creates a Docker network. + """ + # Suppress Dialyzer warnings - runtime behavior may differ from generated specs + @dialyzer {:nowarn_function, create_network: 3} + def create_network(name, conn, opts \\ []) when is_binary(name) do + driver = Keyword.get(opts, :driver, "bridge") + + body = %DockerEngineAPI.Model.NetworkCreateRequest{ + Name: name, + Driver: driver, + CheckDuplicate: true + } + + case Api.Network.network_create(conn, body) do + {:ok, %DockerEngineAPI.Model.NetworkCreateResponse{Id: id}} -> + {:ok, id} + + {:ok, %DockerEngineAPI.Model.ErrorResponse{message: message}} -> + {:error, {:failed_to_create_network, message}} + + {_, %Tesla.Env{status: 409}} -> + {:ok, :already_exists} + + {_, %Tesla.Env{status: status}} -> + {:error, {:http_error, status}} + end + end + + @doc """ + Removes a Docker network. + """ + # Suppress Dialyzer warnings - runtime behavior may differ from generated specs + @dialyzer {:nowarn_function, remove_network: 2} + def remove_network(name, conn) when is_binary(name) do + case Api.Network.network_delete(conn, name) do + {:ok, nil} -> + :ok + + {_, %Tesla.Env{status: 204}} -> + :ok + + {_, %Tesla.Env{status: 404}} -> + {:error, :network_not_found} + + {:ok, %DockerEngineAPI.Model.ErrorResponse{message: message}} -> + {:error, {:failed_to_remove_network, message}} + + {_, %Tesla.Env{status: status}} -> + {:error, {:http_error, status}} + end + end + + @doc """ + Checks if a network exists. + """ + def network_exists?(name, conn) when is_binary(name) do + case Api.Network.network_inspect(conn, name) do + {:ok, %DockerEngineAPI.Model.Network{}} -> true + _ -> false + end + end + def tag_image(image, repo, tag, conn) do case Api.Image.image_tag(conn, image, repo: repo, tag: tag) do {:ok, %Tesla.Env{status: 201}} -> @@ -203,21 +266,35 @@ defmodule Testcontainers.Docker.Api do end defp container_create_request(%Container{} = container_config) do - %DockerEngineAPI.Model.ContainerCreateRequest{ + base_request = %DockerEngineAPI.Model.ContainerCreateRequest{ Image: container_config.image, Cmd: container_config.cmd, ExposedPorts: map_exposed_ports(container_config), Env: map_env(container_config), Labels: container_config.labels, + Hostname: container_config.hostname, HostConfig: %HostConfig{ AutoRemove: container_config.auto_remove, PortBindings: map_port_bindings(container_config), Privileged: container_config.privileged, Binds: map_binds(container_config), Mounts: map_volumes(container_config), - NetworkMode: container_config.network_mode + NetworkMode: container_config.network_mode || container_config.network } } + + # Add NetworkingConfig if a network is specified + if container_config.network do + endpoint_config = %{ + container_config.network => %DockerEngineAPI.Model.EndpointSettings{} + } + + Map.put(base_request, :NetworkingConfig, %DockerEngineAPI.Model.NetworkingConfig{ + EndpointsConfig: endpoint_config + }) + else + base_request + end end defp map_exposed_ports(%Container{} = container_config) do @@ -264,12 +341,30 @@ defmodule Testcontainers.Docker.Api do end) end + defp from(%DockerEngineAPI.Model.ContainerInspectResponse{ + Id: container_id, + Image: image, + NetworkSettings: %{IPAddress: ip_address, Ports: ports, Networks: networks}, + Config: %{Env: env, Labels: labels} + }) do + # For custom networks, the IP address is in Networks..IPAddress + # The default bridge IPAddress will be empty for custom networks + resolved_ip = resolve_ip_address(ip_address, networks) + + make_container(container_id, image, labels, resolved_ip, ports, env) + end + + # Also handle when Networks key is missing defp from(%DockerEngineAPI.Model.ContainerInspectResponse{ Id: container_id, Image: image, NetworkSettings: %{IPAddress: ip_address, Ports: ports}, Config: %{Env: env, Labels: labels} }) do + make_container(container_id, image, labels, ip_address, ports, env) + end + + defp make_container(container_id, image, labels, ip_address, ports, env) do %Container{ container_id: container_id, image: image, @@ -291,6 +386,22 @@ defmodule Testcontainers.Docker.Api do } end + # Resolve IP address, preferring custom network IPs if default is empty + defp resolve_ip_address(nil, networks), do: get_ip_from_networks(networks) + defp resolve_ip_address("", networks), do: get_ip_from_networks(networks) + defp resolve_ip_address(ip, _networks) when is_binary(ip) and ip != "", do: ip + + defp get_ip_from_networks(nil), do: nil + + defp get_ip_from_networks(networks) when is_map(networks) do + # Get the first non-empty IP from any network + networks + |> Enum.find_value(fn + {_name, %{IPAddress: ip}} when is_binary(ip) and ip != "" -> ip + _ -> nil + end) + end + defp create_exec(container_id, command, conn) do data = %ExecConfig{Cmd: command} diff --git a/lib/mix/tasks/testcontainers/run.ex b/lib/mix/tasks/testcontainers/run.ex index de83fc2..e92a12a 100644 --- a/lib/mix/tasks/testcontainers/run.ex +++ b/lib/mix/tasks/testcontainers/run.ex @@ -51,9 +51,9 @@ defmodule Mix.Tasks.Testcontainers.Run do run_sub_task_and_exit(sub_task, sub_task_args, env) end - @spec run_sub_task_and_exit(String.t(), list(String.t()), list({String.t(), String.t()})) :: no_return() + @spec run_sub_task_and_exit(String.t(), list(String.t()), list({String.t(), String.t()})) :: + no_return() defp run_sub_task_and_exit(sub_task, sub_task_args, env) do - IO.puts("Starting mix task: #{sub_task} #{Enum.join(sub_task_args, " ")}") Enum.each(env, fn {k, v} -> System.put_env(k, v) end) @@ -97,11 +97,13 @@ defmodule Mix.Tasks.Testcontainers.Run do end defp maybe_with_host_port(config, nil, _exposed_port, _module), do: config + defp maybe_with_host_port(config, host_port, exposed_port, module) do module.with_port(config, {exposed_port, host_port}) end defp maybe_with_persistent_volume(config, nil, _module), do: config + defp maybe_with_persistent_volume(config, db_volume, module) do module.with_persistent_volume(config, db_volume) end diff --git a/lib/testcontainers.ex b/lib/testcontainers.ex index de76eea..f6e724d 100644 --- a/lib/testcontainers.ex +++ b/lib/testcontainers.ex @@ -81,7 +81,8 @@ defmodule Testcontainers do conn: conn, docker_hostname: docker_hostname, session_id: session_id, - properties: properties + properties: properties, + networks: MapSet.new() }} else error -> @@ -145,6 +146,44 @@ defmodule Testcontainers do wait_for_call({:stop_container, container_id}, name) end + @doc """ + Creates a Docker network. + + Networks allow containers to communicate with each other using hostnames. + Use `Container.with_network/2` to attach a container to a network. + + ## Parameters + + - `network_name`: The name of the network to create. + - `name`: The name of the Testcontainers GenServer (defaults to `Testcontainers`). + + ## Returns + + - `{:ok, network_id}` if the network is created successfully. + - `{:ok, :already_exists}` if the network already exists. + - `{:error, reason}` on failure. + """ + def create_network(network_name, name \\ __MODULE__) when is_binary(network_name) do + wait_for_call({:create_network, network_name}, name) + end + + @doc """ + Removes a Docker network. + + ## Parameters + + - `network_name`: The name of the network to remove. + - `name`: The name of the Testcontainers GenServer (defaults to `Testcontainers`). + + ## Returns + + - `:ok` if the network is removed successfully. + - `{:error, reason}` on failure. + """ + def remove_network(network_name, name \\ __MODULE__) when is_binary(network_name) do + wait_for_call({:remove_network, network_name}, name) + end + @impl true def handle_info(_msg, state) do {:noreply, state} @@ -170,6 +209,26 @@ defmodule Testcontainers do {:reply, state.docker_hostname, state} end + @impl true + def handle_call({:create_network, network_name}, from, state) do + Task.async(fn -> + result = Api.create_network(network_name, state.conn) + GenServer.reply(from, result) + end) + + {:noreply, %{state | networks: MapSet.put(state.networks, network_name)}} + end + + @impl true + def handle_call({:remove_network, network_name}, from, state) do + Task.async(fn -> + result = Api.remove_network(network_name, state.conn) + GenServer.reply(from, result) + end) + + {:noreply, %{state | networks: MapSet.delete(state.networks, network_name)}} + end + # private functions defp get_docker_hostname(docker_host_url, conn) do diff --git a/mix.exs b/mix.exs index 9ea2605..f66b184 100644 --- a/mix.exs +++ b/mix.exs @@ -77,6 +77,8 @@ defmodule TestcontainersElixir.MixProject do {:erlzk, "~> 0.6.2", only: [:dev, :test]}, # EMQX {:tortoise311, "~> 0.12.0", only: [:dev, :test]}, + # Toxiproxy (for fault injection tests) + {:toxiproxy_ex, "~> 2.0", only: [:dev, :test]}, # For watching directories for file changes in mix task {:fs, "~> 11.4"} ] diff --git a/mix.lock b/mix.lock index f5b9c0e..6890bd3 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,7 @@ %{ "amqp": {:hex, :amqp, "3.3.2", "6cad7469957b29c517a26a27474828f1db28278a13bcc2e7970db9854a3d3080", [:mix], [{:amqp_client, "~> 3.9", [hex: :amqp_client, repo: "hexpm", optional: false]}], "hexpm", "f977c41d81b65a21234a9158e6491b2296f8bd5bda48d5b611a64b6e0d2c3f31"}, "amqp_client": {:hex, :amqp_client, "3.12.14", "2b677bc3f2e2234ba7517042b25d72071a79735042e91f9116bd3c176854b622", [:make, :rebar3], [{:credentials_obfuscation, "3.4.0", [hex: :credentials_obfuscation, repo: "hexpm", optional: false]}, {:rabbit_common, "3.12.14", [hex: :rabbit_common, repo: "hexpm", optional: false]}], "hexpm", "5f70b6c3b1a739790080da4fddc94a867e99f033c4b1edc20d6ff8b8fb4bd160"}, + "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, "crc32cer": {:hex, :crc32cer, "0.1.12", "b018bd5dcbba9c35972822f53ad40b6b483d453204ef67daf92af3a314bbfbf6", [:rebar3], [], "hexpm", "56ad9380651c2c4cb21d7741c91cbcc4709e032fd31a98a33f007ee30e526972"}, @@ -19,6 +20,7 @@ "fs": {:hex, :fs, "11.4.1", "11fb3153bb2e1de851b8263bb5698d526894853c73a525ebeb5e69108b2d25cd", [:rebar3], [], "hexpm", "dd00a61d89eac01d16d3fc51d5b0eb5f0722ef8e3c1a3a547cd086957f3260a9"}, "gen_state_machine": {:hex, :gen_state_machine, "3.0.0", "1e57f86a494e5c6b14137ebef26a7eb342b3b0070c7135f2d6768ed3f6b6cdff", [:mix], [], "hexpm", "0a59652574bebceb7309f6b749d2a41b45fdeda8dbb4da0791e355dd19f0ed15"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "kafka_ex": {:hex, :kafka_ex, "0.15.0", "c32c8ee1a1da488e3f36f1b4ffcdfdc9a3f989138535bd97c1a7edef70ad7f73", [:mix], [{:kayrock, "~> 0.2.0-rc.1", [hex: :kayrock, repo: "hexpm", optional: false]}], "hexpm", "0b845ddc5b2263ac76f14cf339365752fd2848ba3cfc67730495742de7bec8a9"}, @@ -29,6 +31,7 @@ "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "myxql": {:hex, :myxql, "0.8.0", "60c60e87c7320d2f5759416aa1758c8e7534efbae07b192861977f8455e35acd", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4 or ~> 4.0", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "1ec0ceb26fb3cd0f8756519cf4f0e4f9348177a020705223bdf4742a2c44d774"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, @@ -43,6 +46,7 @@ "tesla": {:hex, :tesla, "1.15.3", "3a2b5c37f09629b8dcf5d028fbafc9143c0099753559d7fe567eaabfbd9b8663", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "98bb3d4558abc67b92fb7be4cd31bb57ca8d80792de26870d362974b58caeda7"}, "thoas": {:hex, :thoas, "1.0.0", "567c03902920827a18a89f05b79a37b5bf93553154b883e0131801600cf02ce0", [:rebar3], [], "hexpm", "fc763185b932ecb32a554fb735ee03c3b6b1b31366077a2427d2a97f3bd26735"}, "tortoise311": {:hex, :tortoise311, "0.12.2", "465fa03409ed81cc51d3420a8e1984652648eeee7e11e8b6c1dff5daf7ed8c51", [:mix], [{:gen_state_machine, "~> 2.0 or ~> 3.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "42b1e5e278c9c261c8453ece2a68da7b0275c41526a283d54c967d581e8c1bd7"}, + "toxiproxy_ex": {:hex, :toxiproxy_ex, "2.0.1", "20191a883232b63d43d7ec505a378d00d5a716538e4ea137edb7c3ef8afe5b45", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "044b617e12cb6d8b247711056f2bd0f54f43ecfe6b16a6db964b2191341b914f"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "uniq": {:hex, :uniq, "0.6.2", "51846518c037134c08bc5b773468007b155e543d53c8b39bafe95b0af487e406", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "95aa2a41ea331ef0a52d8ed12d3e730ef9af9dbc30f40646e6af334fbd7bc0fc"}, "varint": {:hex, :varint, "1.5.1", "17160c70d0428c3f8a7585e182468cac10bbf165c2360cf2328aaa39d3fb1795", [:mix], [], "hexpm", "24f3deb61e91cb988056de79d06f01161dd01be5e0acae61d8d936a552f1be73"}, diff --git a/test/container/toxiproxy_container_test.exs b/test/container/toxiproxy_container_test.exs new file mode 100644 index 0000000..b16e48b --- /dev/null +++ b/test/container/toxiproxy_container_test.exs @@ -0,0 +1,309 @@ +# SPDX-License-Identifier: MIT +defmodule Testcontainers.Container.ToxiproxyContainerTest do + use ExUnit.Case, async: true + import Testcontainers.ExUnit + + alias Testcontainers.Container + alias Testcontainers.ToxiproxyContainer + + @moduletag timeout: 120_000 + + describe "new/0" do + test "creates a new ToxiproxyContainer struct with default configurations" do + config = ToxiproxyContainer.new() + + assert config.image == "ghcr.io/shopify/toxiproxy:2.9.0" + assert config.wait_timeout == 60_000 + assert config.reuse == false + end + end + + describe "with_image/2" do + test "overrides the default image" do + config = ToxiproxyContainer.new() + new_config = ToxiproxyContainer.with_image(config, "ghcr.io/shopify/toxiproxy:2.8.0") + + assert new_config.image == "ghcr.io/shopify/toxiproxy:2.8.0" + end + + test "raises if the image is not a binary" do + config = ToxiproxyContainer.new() + assert_raise FunctionClauseError, fn -> ToxiproxyContainer.with_image(config, 123) end + end + end + + describe "with_wait_timeout/2" do + test "overrides the default wait timeout" do + config = ToxiproxyContainer.new() + new_config = ToxiproxyContainer.with_wait_timeout(config, 30_000) + + assert new_config.wait_timeout == 30_000 + end + + test "raises if the wait timeout is not an integer" do + config = ToxiproxyContainer.new() + + assert_raise FunctionClauseError, fn -> + ToxiproxyContainer.with_wait_timeout(config, "30000") + end + end + end + + describe "with_reuse/2" do + test "sets the reuse flag to true" do + config = ToxiproxyContainer.new() + new_config = ToxiproxyContainer.with_reuse(config, true) + + assert new_config.reuse == true + end + + test "sets the reuse flag to false" do + config = + ToxiproxyContainer.new() + |> ToxiproxyContainer.with_reuse(true) + |> ToxiproxyContainer.with_reuse(false) + + assert config.reuse == false + end + + test "raises if reuse is not a boolean" do + config = ToxiproxyContainer.new() + + assert_raise FunctionClauseError, fn -> + ToxiproxyContainer.with_reuse(config, "true") + end + end + end + + describe "default_image/0" do + test "returns the default image with tag" do + assert ToxiproxyContainer.default_image() == "ghcr.io/shopify/toxiproxy:2.9.0" + end + end + + describe "control_port/0" do + test "returns the control port" do + assert ToxiproxyContainer.control_port() == 8474 + end + end + + describe "first_proxy_port/0" do + test "returns the first proxy port" do + assert ToxiproxyContainer.first_proxy_port() == 8666 + end + end + + describe "proxy_port_count/0" do + test "returns the number of proxy ports" do + assert ToxiproxyContainer.proxy_port_count() == 31 + end + end + + describe "with default configuration" do + container(:toxiproxy, ToxiproxyContainer.new()) + + test "provides a ready-to-use toxiproxy container", %{toxiproxy: toxiproxy} do + # Verify the container is running and has the expected ports + assert toxiproxy.container_id != nil + + # Verify control port is mapped + control_port = Container.mapped_port(toxiproxy, ToxiproxyContainer.control_port()) + assert is_integer(control_port) + assert control_port > 0 + + # Verify first proxy port is mapped + proxy_port = Container.mapped_port(toxiproxy, ToxiproxyContainer.first_proxy_port()) + assert is_integer(proxy_port) + assert proxy_port > 0 + end + + test "can access toxiproxy API", %{toxiproxy: toxiproxy} do + :inets.start() + + host = Testcontainers.get_host() + port = ToxiproxyContainer.mapped_control_port(toxiproxy) + url = ~c"http://#{host}:#{port}/version" + + {:ok, {{_, 200, _}, _, body}} = :httpc.request(:get, {url, []}, [], []) + assert to_string(body) =~ "2.9.0" + end + + test "api_url/1 returns correct URL", %{toxiproxy: toxiproxy} do + url = ToxiproxyContainer.api_url(toxiproxy) + + assert url =~ "http://" + assert url =~ ":#{ToxiproxyContainer.mapped_control_port(toxiproxy)}" + end + + test "configure_toxiproxy_ex/1 sets application env", %{toxiproxy: toxiproxy} do + :ok = ToxiproxyContainer.configure_toxiproxy_ex(toxiproxy) + + assert Application.get_env(:toxiproxy_ex, :host) == ToxiproxyContainer.api_url(toxiproxy) + end + + test "can create and list proxies", %{toxiproxy: toxiproxy} do + # Create a proxy + {:ok, proxy_port} = + ToxiproxyContainer.create_proxy(toxiproxy, "test_proxy", "localhost:12345") + + assert is_integer(proxy_port) + + # List proxies + {:ok, proxies} = ToxiproxyContainer.list_proxies(toxiproxy) + assert Map.has_key?(proxies, "test_proxy") + assert proxies["test_proxy"]["upstream"] == "localhost:12345" + + # Delete proxy + :ok = ToxiproxyContainer.delete_proxy(toxiproxy, "test_proxy") + + # Verify deleted + {:ok, proxies_after} = ToxiproxyContainer.list_proxies(toxiproxy) + refute Map.has_key?(proxies_after, "test_proxy") + end + + test "create_proxy/4 handles already existing proxy", %{toxiproxy: toxiproxy} do + # Create proxy twice - second should succeed (409 is handled) + {:ok, _} = ToxiproxyContainer.create_proxy(toxiproxy, "duplicate_proxy", "localhost:54321") + {:ok, _} = ToxiproxyContainer.create_proxy(toxiproxy, "duplicate_proxy", "localhost:54321") + + # Cleanup + ToxiproxyContainer.delete_proxy(toxiproxy, "duplicate_proxy") + end + + test "delete_proxy/2 returns error for non-existent proxy", %{toxiproxy: toxiproxy} do + assert {:error, :not_found} = + ToxiproxyContainer.delete_proxy(toxiproxy, "non_existent_proxy") + end + + test "reset/1 clears all toxics", %{toxiproxy: toxiproxy} do + # Create a proxy + {:ok, _} = ToxiproxyContainer.create_proxy(toxiproxy, "reset_test_proxy", "localhost:11111") + + # Reset should succeed + :ok = ToxiproxyContainer.reset(toxiproxy) + + # Cleanup + ToxiproxyContainer.delete_proxy(toxiproxy, "reset_test_proxy") + end + end + + describe "create_proxy_for_container/5" do + container(:toxiproxy, ToxiproxyContainer.new()) + + test "creates proxy using container IP", %{toxiproxy: toxiproxy} do + # Create a mock container struct with IP + mock_container = %Container{ + container_id: "mock_id", + image: "mock:latest", + ip_address: "172.17.0.5", + exposed_ports: [] + } + + {:ok, proxy_port} = + ToxiproxyContainer.create_proxy_for_container( + toxiproxy, + "container_proxy", + mock_container, + 6379 + ) + + assert is_integer(proxy_port) + + # Verify the upstream is correct + {:ok, proxies} = ToxiproxyContainer.list_proxies(toxiproxy) + assert proxies["container_proxy"]["upstream"] == "172.17.0.5:6379" + + # Cleanup + ToxiproxyContainer.delete_proxy(toxiproxy, "container_proxy") + end + end + + describe "integration with real container (Redis)" do + @redis_port 6379 + + setup do + # Create a unique network for container communication + network_name = "toxiproxy-integration-#{:rand.uniform(100_000)}" + {:ok, _} = Testcontainers.create_network(network_name) + + on_exit(fn -> + Testcontainers.remove_network(network_name) + end) + + {:ok, network_name: network_name} + end + + test "can proxy and inject faults into Redis traffic", %{network_name: network_name} do + alias Testcontainers.RedisContainer + alias Testcontainers.ContainerBuilder + + # Start Redis on the network + redis_config = + RedisContainer.new() + |> ContainerBuilder.build() + |> Container.with_network(network_name) + |> Container.with_hostname("redis") + + {:ok, redis} = Testcontainers.start_container(redis_config) + + # Start Toxiproxy on the same network + toxiproxy_config = + ToxiproxyContainer.new() + |> ContainerBuilder.build() + |> Container.with_network(network_name) + |> Container.with_hostname("toxiproxy") + + {:ok, toxiproxy} = Testcontainers.start_container(toxiproxy_config) + + # Create proxy from Toxiproxy to Redis + {:ok, proxy_port} = + ToxiproxyContainer.create_proxy_for_container( + toxiproxy, + "redis_integration", + redis, + @redis_port + ) + + # Configure ToxiproxyEx client + :ok = ToxiproxyContainer.configure_toxiproxy_ex(toxiproxy) + + # Connect to Redis through the proxy + host = Testcontainers.get_host() + {:ok, conn} = Redix.start_link(host: host, port: proxy_port) + + # Verify normal operation through proxy + assert Redix.command!(conn, ["PING"]) == "PONG" + assert Redix.command!(conn, ["SET", "test_key", "test_value"]) == "OK" + assert Redix.command!(conn, ["GET", "test_key"]) == "test_value" + + # Inject latency and verify it affects response time + ToxiproxyEx.get!("redis_integration") + |> ToxiproxyEx.toxic(:latency, latency: 500) + |> ToxiproxyEx.apply!(fn -> + {time_us, result} = + :timer.tc(fn -> + Redix.command!(conn, ["PING"]) + end) + + assert result == "PONG" + # Should take at least 400ms (allowing some tolerance) + assert time_us > 400_000, "Expected > 400ms latency, got #{time_us / 1000}ms" + end) + + # Take down proxy and verify connection fails + ToxiproxyEx.get!("redis_integration") + |> ToxiproxyEx.down!(fn -> + result = Redix.command(conn, ["PING"]) + assert match?({:error, _}, result), "Expected error when proxy is down" + end) + + # After proxy re-enabled, new connection should work + Redix.stop(conn) + {:ok, conn2} = Redix.start_link(host: host, port: proxy_port) + assert Redix.command!(conn2, ["PING"]) == "PONG" + assert Redix.command!(conn2, ["GET", "test_key"]) == "test_value" + + Redix.stop(conn2) + end + end +end diff --git a/test/network_test.exs b/test/network_test.exs new file mode 100644 index 0000000..b1aff0e --- /dev/null +++ b/test/network_test.exs @@ -0,0 +1,139 @@ +defmodule Testcontainers.NetworkTest do + use ExUnit.Case, async: false + + alias Testcontainers.Container + alias Testcontainers.ContainerBuilder + alias Testcontainers.RedisContainer + + @moduletag timeout: 180_000 + + describe "create_network/1" do + test "creates a new Docker network" do + network_name = "test-network-#{:rand.uniform(100_000)}" + + result = Testcontainers.create_network(network_name) + assert match?({:ok, _}, result) + + # Cleanup + Testcontainers.remove_network(network_name) + end + + test "handles duplicate network creation gracefully" do + network_name = "test-network-dup-#{:rand.uniform(100_000)}" + + # Create network first time + {:ok, _} = Testcontainers.create_network(network_name) + + # Creating again should succeed (returns :already_exists) + result = Testcontainers.create_network(network_name) + assert match?({:ok, _}, result) + + # Cleanup + Testcontainers.remove_network(network_name) + end + end + + describe "remove_network/1" do + test "removes an existing network" do + network_name = "test-network-remove-#{:rand.uniform(100_000)}" + + {:ok, _} = Testcontainers.create_network(network_name) + result = Testcontainers.remove_network(network_name) + + assert result == :ok + end + + test "handles removing non-existent network" do + network_name = "non-existent-network-#{:rand.uniform(100_000)}" + + # Should handle gracefully (not crash) + result = Testcontainers.remove_network(network_name) + # Returns error with message when network doesn't exist + assert match?({:error, {:failed_to_remove_network, _}}, result) or result == :ok + end + end + + describe "Container.with_network/2" do + test "container can be configured with network" do + container = + Container.new("alpine:latest") + |> Container.with_network("my-network") + + assert container.network == "my-network" + end + + test "container can be configured with hostname" do + container = + Container.new("alpine:latest") + |> Container.with_hostname("my-host") + + assert container.hostname == "my-host" + end + end + + describe "containers on shared network" do + test "containers can communicate via hostname" do + network_name = "test-network-comm-#{:rand.uniform(100_000)}" + + # Create network + {:ok, _} = Testcontainers.create_network(network_name) + + # Start Redis container on the network + redis_config = + RedisContainer.new() + |> ContainerBuilder.build() + |> Container.with_network(network_name) + |> Container.with_hostname("redis-server") + + {:ok, redis} = Testcontainers.start_container(redis_config) + assert redis.ip_address != nil + + # Start an Alpine container that will ping Redis + alpine_config = + Container.new("alpine:latest") + |> Container.with_network(network_name) + |> Container.with_hostname("alpine-client") + |> Container.with_cmd(["sleep", "60"]) + + {:ok, alpine} = Testcontainers.start_container(alpine_config) + + # Both containers should be on the same network + assert redis.ip_address != nil + assert alpine.ip_address != nil + + # Verify Redis is accessible from the host + host = Testcontainers.get_host() + port = RedisContainer.port(redis) + {:ok, conn} = Redix.start_link(host: host, port: port) + assert Redix.command!(conn, ["PING"]) == "PONG" + Redix.stop(conn) + + # Cleanup + Testcontainers.stop_container(alpine.container_id) + Testcontainers.stop_container(redis.container_id) + Testcontainers.remove_network(network_name) + end + + test "containers get IP addresses when on network" do + network_name = "test-network-ip-#{:rand.uniform(100_000)}" + + {:ok, _} = Testcontainers.create_network(network_name) + + config = + Container.new("alpine:latest") + |> Container.with_network(network_name) + |> Container.with_cmd(["sleep", "30"]) + + {:ok, container} = Testcontainers.start_container(config) + + # Container should have an IP address + assert container.ip_address != nil + assert is_binary(container.ip_address) + assert container.ip_address =~ ~r/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/ + + # Cleanup + Testcontainers.stop_container(container.container_id) + Testcontainers.remove_network(network_name) + end + end +end