diff --git a/lib/container/cassandra_container.ex b/lib/container/cassandra_container.ex index 28c2643..787322d 100644 --- a/lib/container/cassandra_container.ex +++ b/lib/container/cassandra_container.ex @@ -62,13 +62,13 @@ defmodule Testcontainers.CassandraContainer do @doc """ Retrieves the port mapped by the Docker host for the Cassandra container. """ - def port(%Container{} = container), do: Container.mapped_port(container, @default_port) + def port(%Container{} = container), do: Testcontainers.get_port(container, @default_port) @doc """ Generates the connection URL for accessing the Cassandra service running within the container. """ def connection_uri(%Container{} = container) do - "#{Testcontainers.get_host()}:#{port(container)}" + "#{Testcontainers.get_host(container)}:#{port(container)}" end defimpl ContainerBuilder do diff --git a/lib/container/ceph_container.ex b/lib/container/ceph_container.ex index baa7e06..b301e83 100644 --- a/lib/container/ceph_container.ex +++ b/lib/container/ceph_container.ex @@ -175,7 +175,7 @@ defmodule Testcontainers.CephContainer do iex> CephContainer.port(container) 32768 # This value will be different depending on the mapped port. """ - def port(%Container{} = container), do: Container.mapped_port(container, @default_port) + def port(%Container{} = container), do: Testcontainers.get_port(container, @default_port) @doc """ Generates the connection URL for accessing the Ceph service running within the container. @@ -192,7 +192,7 @@ defmodule Testcontainers.CephContainer do "http://localhost:32768" # This value will be different depending on the mapped port. """ def connection_url(%Container{} = container) do - "http://#{Testcontainers.get_host()}:#{port(container)}" + "http://#{Testcontainers.get_host(container)}:#{port(container)}" end @doc """ @@ -203,7 +203,7 @@ defmodule Testcontainers.CephContainer do [ port: CephContainer.port(container), scheme: "http://", - host: Testcontainers.get_host(), + host: Testcontainers.get_host(container), access_key_id: container.environment[:CEPH_DEMO_ACCESS_KEY], secret_access_key: container.environment[:CEPH_DEMO_SECRET_KEY] ] diff --git a/lib/container/emqx_container.ex b/lib/container/emqx_container.ex index c29c31e..58803d2 100644 --- a/lib/container/emqx_container.ex +++ b/lib/container/emqx_container.ex @@ -108,7 +108,7 @@ defmodule Testcontainers.EmqxContainer do Returns the port on the _host machine_ where the Emqx container is listening. """ def mqtt_port(%Container{} = container), - do: Container.mapped_port(container, @default_mqtt_port) + do: Testcontainers.get_port(container, @default_mqtt_port) defimpl ContainerBuilder do import Container diff --git a/lib/container/kafka_container.ex b/lib/container/kafka_container.ex index ecd870a..48e935e 100644 --- a/lib/container/kafka_container.ex +++ b/lib/container/kafka_container.ex @@ -156,15 +156,15 @@ defmodule Testcontainers.KafkaContainer do Returns the bootstrap servers string for connecting to the Kafka container. """ def bootstrap_servers(%Container{} = container) do - port = Container.mapped_port(container, @default_internal_kafka_port) - "#{Testcontainers.get_host()}:#{port}" + port = Testcontainers.get_port(container, @default_internal_kafka_port) + "#{Testcontainers.get_host(container)}:#{port}" end @doc """ Returns the port on the host machine where the Kafka container is listening. """ def port(%Container{} = container), - do: Container.mapped_port(container, @default_internal_kafka_port) + do: Testcontainers.get_port(container, @default_internal_kafka_port) defimpl Testcontainers.ContainerBuilder do import Container diff --git a/lib/container/minio_container.ex b/lib/container/minio_container.ex index 8610e1d..cc59492 100644 --- a/lib/container/minio_container.ex +++ b/lib/container/minio_container.ex @@ -49,13 +49,13 @@ defmodule Testcontainers.MinioContainer do @doc """ Retrieves the port mapped by the Docker host for the Minio container. """ - def port(%Container{} = container), do: Container.mapped_port(container, @default_s3_port) + def port(%Container{} = container), do: Testcontainers.get_port(container, @default_s3_port) @doc """ Generates the connection URL for accessing the Minio service running within the container. """ def connection_url(%Container{} = container) do - "http://#{Testcontainers.get_host()}:#{port(container)}" + "http://#{Testcontainers.get_host(container)}:#{port(container)}" end @doc """ @@ -66,7 +66,7 @@ defmodule Testcontainers.MinioContainer do [ port: MinioContainer.port(container), scheme: "http://", - host: Testcontainers.get_host(), + host: Testcontainers.get_host(container), access_key_id: container.environment[:MINIO_ROOT_USER], secret_access_key: container.environment[:MINIO_ROOT_PASSWORD] ] diff --git a/lib/container/mysql_container.ex b/lib/container/mysql_container.ex index ba11c4a..876856d 100644 --- a/lib/container/mysql_container.ex +++ b/lib/container/mysql_container.ex @@ -175,14 +175,14 @@ defmodule Testcontainers.MySqlContainer do @doc """ Returns the port on the _host machine_ where the MySql container is listening. """ - def port(%Container{} = container), do: Container.mapped_port(container, @default_port) + def port(%Container{} = container), do: Testcontainers.get_port(container, @default_port) @doc """ Returns the connection parameters to connect to the database from the _host machine_. """ def connection_parameters(%Container{} = container) do [ - hostname: Testcontainers.get_host(), + hostname: Testcontainers.get_host(container), port: port(container), username: container.environment[:MYSQL_USER], password: container.environment[:MYSQL_PASSWORD], diff --git a/lib/container/postgres_container.ex b/lib/container/postgres_container.ex index 2a37480..7b54e31 100644 --- a/lib/container/postgres_container.ex +++ b/lib/container/postgres_container.ex @@ -175,14 +175,14 @@ defmodule Testcontainers.PostgresContainer do @doc """ Returns the port on the _host machine_ where the Postgres container is listening. """ - def port(%Container{} = container), do: Container.mapped_port(container, @default_port) + def port(%Container{} = container), do: Testcontainers.get_port(container, @default_port) @doc """ Returns the connection parameters to connect to the database from the _host machine_. """ def connection_parameters(%Container{} = container) do [ - hostname: Testcontainers.get_host(), + hostname: Testcontainers.get_host(container), port: port(container), username: container.environment[:POSTGRES_USER], password: container.environment[:POSTGRES_PASSWORD], diff --git a/lib/container/rabbitmq_container.ex b/lib/container/rabbitmq_container.ex index fe598a3..ffe93de 100644 --- a/lib/container/rabbitmq_container.ex +++ b/lib/container/rabbitmq_container.ex @@ -187,7 +187,7 @@ defmodule Testcontainers.RabbitMQContainer do """ def port(%Container{} = container), do: - Container.mapped_port( + Testcontainers.get_port( container, String.to_integer(container.environment[:RABBITMQ_NODE_PORT]) ) @@ -210,7 +210,7 @@ defmodule Testcontainers.RabbitMQContainer do "amqp://guest:guest@localhost:32768/vhost" """ def connection_url(%Container{} = container) do - "amqp://#{container.environment[:RABBITMQ_DEFAULT_USER]}:#{container.environment[:RABBITMQ_DEFAULT_PASS]}@#{Testcontainers.get_host()}:#{port(container)}#{virtual_host_segment(container)}" + "amqp://#{container.environment[:RABBITMQ_DEFAULT_USER]}:#{container.environment[:RABBITMQ_DEFAULT_PASS]}@#{Testcontainers.get_host(container)}:#{port(container)}#{virtual_host_segment(container)}" end @doc """ @@ -233,7 +233,7 @@ defmodule Testcontainers.RabbitMQContainer do """ def connection_parameters(%Container{} = container) do [ - host: Testcontainers.get_host(), + host: Testcontainers.get_host(container), port: port(container), username: container.environment[:RABBITMQ_DEFAULT_USER], password: container.environment[:RABBITMQ_DEFAULT_PASS], diff --git a/lib/container/redis_container.ex b/lib/container/redis_container.ex index 2450991..0487394 100644 --- a/lib/container/redis_container.ex +++ b/lib/container/redis_container.ex @@ -122,7 +122,7 @@ defmodule Testcontainers.RedisContainer do @doc """ Returns the port on the _host machine_ where the Redis container is listening. """ - def port(%Container{} = container), do: Container.mapped_port(container, @default_port) + def port(%Container{} = container), do: Testcontainers.get_port(container, @default_port) @doc """ Generates the connection URL for accessing the Redis service running within the container. @@ -141,7 +141,7 @@ defmodule Testcontainers.RedisContainer do def connection_url(%Container{} = container) do password = container.environment[:REDIS_PASSWORD] auth_part = if password, do: ":#{password}@", else: "" - "redis://#{auth_part}#{Testcontainers.get_host()}:#{port(container)}/" + "redis://#{auth_part}#{Testcontainers.get_host(container)}:#{port(container)}/" end defimpl ContainerBuilder do diff --git a/lib/container/toxiproxy_container.ex b/lib/container/toxiproxy_container.ex index 9a5e94b..e48fdcb 100644 --- a/lib/container/toxiproxy_container.ex +++ b/lib/container/toxiproxy_container.ex @@ -80,7 +80,7 @@ defmodule Testcontainers.ToxiproxyContainer do 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) + Testcontainers.get_port(container, @control_port) end @doc """ @@ -92,7 +92,7 @@ defmodule Testcontainers.ToxiproxyContainer do |> then(&Application.put_env(:toxiproxy_ex, :host, &1)) """ def api_url(%Container{} = container) do - host = Testcontainers.get_host() + host = Testcontainers.get_host(container) port = mapped_control_port(container) "http://#{host}:#{port}" end @@ -130,7 +130,7 @@ defmodule Testcontainers.ToxiproxyContainer do def create_proxy(%Container{} = container, name, upstream, opts \\ []) do listen_port = Keyword.get(opts, :listen_port, @first_proxy_port) - host = Testcontainers.get_host() + host = Testcontainers.get_host(container) api_port = mapped_control_port(container) :inets.start() @@ -149,11 +149,11 @@ defmodule Testcontainers.ToxiproxyContainer do case httpc_request_with_retry(: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, Testcontainers.get_port(container, listen_port)} {:ok, {{_, 409, _}, _, _}} -> # Proxy already exists, return the port - {:ok, Container.mapped_port(container, listen_port)} + {:ok, Testcontainers.get_port(container, listen_port)} {:ok, {{_, code, _}, _, response_body}} -> {:error, {:http_error, code, response_body}} @@ -193,7 +193,7 @@ defmodule Testcontainers.ToxiproxyContainer do Deletes a proxy from Toxiproxy. """ def delete_proxy(%Container{} = container, name) do - host = Testcontainers.get_host() + host = Testcontainers.get_host(container) api_port = mapped_control_port(container) :inets.start() @@ -212,7 +212,7 @@ defmodule Testcontainers.ToxiproxyContainer do Resets Toxiproxy, removing all toxics and re-enabling all proxies. """ def reset(%Container{} = container) do - host = Testcontainers.get_host() + host = Testcontainers.get_host(container) api_port = mapped_control_port(container) :inets.start() @@ -232,7 +232,7 @@ defmodule Testcontainers.ToxiproxyContainer do Returns a map of proxy names to their configurations. """ def list_proxies(%Container{} = container) do - host = Testcontainers.get_host() + host = Testcontainers.get_host(container) api_port = mapped_control_port(container) :inets.start() diff --git a/lib/mix/tasks/testcontainers/run.ex b/lib/mix/tasks/testcontainers/run.ex index e92a12a..1810629 100644 --- a/lib/mix/tasks/testcontainers/run.ex +++ b/lib/mix/tasks/testcontainers/run.ex @@ -76,7 +76,7 @@ defmodule Mix.Tasks.Testcontainers.Run do {:ok, container} = Testcontainers.start_container(container_def) port = PostgresContainer.port(container) - {container, create_env(port)} + {container, create_env(container, port)} "mysql" -> container_def = @@ -89,7 +89,7 @@ defmodule Mix.Tasks.Testcontainers.Run do {:ok, container} = Testcontainers.start_container(container_def) port = MySqlContainer.port(container) - {container, create_env(port)} + {container, create_env(container, port)} _ -> raise("Unsupported database: #{database}") @@ -108,13 +108,13 @@ defmodule Mix.Tasks.Testcontainers.Run do module.with_persistent_volume(config, db_volume) end - defp create_env(port) do + defp create_env(container, port) do [ - {"DATABASE_URL", "ecto://test:test@#{Testcontainers.get_host()}:#{port}/test"}, + {"DATABASE_URL", "ecto://test:test@#{Testcontainers.get_host(container)}:#{port}/test"}, # for backward compability, will be removed in future releases {"DB_USER", "test"}, {"DB_PASSWORD", "test"}, - {"DB_HOST", Testcontainers.get_host()}, + {"DB_HOST", Testcontainers.get_host(container)}, {"DB_PORT", Integer.to_string(port)} ] end diff --git a/lib/testcontainers.ex b/lib/testcontainers.ex index 72660c8..0f9d17d 100644 --- a/lib/testcontainers.ex +++ b/lib/testcontainers.ex @@ -63,14 +63,23 @@ defmodule Testcontainers do :crypto.hash(:sha, "#{inspect(self())}#{DateTime.utc_now() |> DateTime.to_string()}") |> Base.encode16() - with {:ok, docker_hostname} <- get_docker_hostname(docker_host_url, conn), + with {:ok, docker_hostname} <- get_docker_hostname(docker_host_url, conn, properties), + use_container_ip <- should_use_container_ip?(docker_hostname), {:ok} <- start_reaper(conn, session_id, properties, docker_host, docker_hostname) do - Logger.info("Testcontainers initialized") + if use_container_ip do + Logger.info( + "Testcontainers initialized in container networking mode " <> + "(using container IPs directly)" + ) + else + Logger.info("Testcontainers initialized") + end {:ok, %{ conn: conn, docker_hostname: docker_hostname, + use_container_ip: use_container_ip, session_id: session_id, properties: properties, networks: MapSet.new(), @@ -85,7 +94,57 @@ defmodule Testcontainers do end @doc false - def get_host(name \\ __MODULE__), do: wait_for_call(:get_host, name) + def get_host(), do: wait_for_call(:get_host, __MODULE__) + + @doc """ + Returns the host to use for connecting to the given container. + + In standard mode, returns the same as `get_host/0` (the Docker host). + In container networking mode (DooD), returns the container's internal IP + since mapped ports on the bridge gateway may be unreachable. + """ + def get_host(%Container{} = container), do: get_host(container, __MODULE__) + def get_host(name) when is_atom(name), do: wait_for_call(:get_host, name) + + def get_host(%Container{} = container, name) do + if use_container_ip?(container, name) do + container.ip_address + else + wait_for_call(:get_host, name) + end + end + + @doc """ + Returns the port to use for connecting to the given container on the specified internal port. + + In standard mode, returns the host-mapped port (same as `Container.mapped_port/2`). + In container networking mode (DooD), returns the internal port directly + since we connect to the container's IP on the bridge network. + """ + def get_port(%Container{} = container, port), do: get_port(container, port, __MODULE__) + + def get_port(%Container{} = container, port, name) do + if use_container_ip?(container, name) do + port + else + Container.mapped_port(container, port) + end + end + + # Returns true when we should use the container's internal IP and port. + # Only applies when in container_ip mode AND the container is on the default + # bridge network. Containers on custom networks are not reachable from the + # test container via internal IP. + defp use_container_ip?(%Container{} = container, name) do + case wait_for_call(:get_connection_mode, name) do + :container_ip -> + is_binary(container.ip_address) and container.ip_address != "" and + is_nil(container.network) + + _ -> + false + end + end @doc """ Starts a new container based on the provided configuration, applying any specified wait strategies. @@ -308,6 +367,12 @@ defmodule Testcontainers do {:reply, state.docker_hostname, state} end + @impl true + def handle_call(:get_connection_mode, _from, state) do + mode = if state.use_container_ip, do: :container_ip, else: :mapped_port + {:reply, mode, state} + end + @impl true def handle_call({:create_network, network_name}, from, state) do labels = %{ @@ -372,6 +437,51 @@ defmodule Testcontainers do # private functions + defp should_use_container_ip?(docker_hostname) do + if running_in_container?() and docker_hostname != "localhost" do + # Probe the bridge gateway to check if mapped ports are reachable. + # If hairpin NAT is blocked (common in DooD), we fall back to + # connecting to containers via their internal IPs. + case :gen_tcp.connect(~c"#{docker_hostname}", 0, [], 2000) do + {:ok, socket} -> + :gen_tcp.close(socket) + false + + {:error, :econnrefused} -> + # Connection refused means the host IS reachable, just no service on port 0 + false + + {:error, reason} -> + Logger.info( + "Bridge gateway #{docker_hostname} unreachable (#{inspect(reason)}). " <> + "Switching to container networking mode (direct IP access)" + ) + + true + end + else + false + end + end + + @doc false + def running_in_container?( + dockerenv_path \\ "/.dockerenv", + cgroup_path \\ "/proc/1/cgroup" + ) do + if File.exists?(dockerenv_path) do + true + else + case File.read(cgroup_path) do + {:ok, content} -> + Regex.match?(~r/(docker|kubepods|lxc|containerd)/, content) + + {:error, _} -> + false + end + end + end + defp start_compose_env(%DockerCompose{} = compose, state) do with :ok <- ComposeCli.up(compose), {:ok, ps_entries} <- ComposeCli.ps(compose) do @@ -407,6 +517,38 @@ defmodule Testcontainers do end end + @doc false + def parse_gateway_from_proc_route(content) do + content + |> String.split("\n") + |> Enum.drop(1) + |> Enum.map(&String.split(&1, "\t")) + |> Enum.find(fn + [_iface, destination | _rest] -> destination == "00000000" + _ -> false + end) + |> case do + [_iface, _destination, gateway_hex | _rest] -> + decode_hex_gateway(gateway_hex) + + _ -> + {:error, :no_default_route} + end + end + + defp decode_hex_gateway(hex) when byte_size(hex) == 8 do + {value, ""} = Integer.parse(hex, 16) + + a = Bitwise.band(value, 0xFF) + b = Bitwise.band(Bitwise.bsr(value, 8), 0xFF) + c = Bitwise.band(Bitwise.bsr(value, 16), 0xFF) + d = Bitwise.band(Bitwise.bsr(value, 24), 0xFF) + + {:ok, "#{a}.#{b}.#{c}.#{d}"} + end + + defp decode_hex_gateway(_), do: {:error, :invalid_gateway} + defp run_compose_wait_strategies(%DockerCompose{} = compose, services, state) do Enum.reduce_while(compose.wait_strategies, :ok, fn {service_name, strategies}, :ok -> case Map.get(services, service_name) do @@ -437,21 +579,53 @@ defmodule Testcontainers do end) end - defp get_docker_hostname(docker_host_url, conn) do + defp get_docker_hostname(docker_host_url, conn, properties) do + # Check for explicit host override first + host_override = + Map.get(properties, "tc.host.override") || + System.get_env("TESTCONTAINERS_HOST_OVERRIDE") + + if host_override do + Logger.debug("Using host override: #{host_override}") + {:ok, host_override} + else + do_get_docker_hostname(docker_host_url, conn) + end + end + + defp do_get_docker_hostname(docker_host_url, conn) do case URI.parse(docker_host_url) do uri when uri.scheme == "http" or uri.scheme == "https" -> {:ok, uri.host} uri when uri.scheme == "http+unix" -> - if File.exists?("/.dockerenv") do + if running_in_container?() do Logger.debug("Running in docker environment, trying to get bridge network gateway") with {:ok, gateway} <- Api.get_bridge_gateway(conn) do {:ok, gateway} else {:error, reason} -> - Logger.debug("Failed to get bridge gateway: #{inspect(reason)}. Using localhost") - {:ok, "localhost"} + Logger.debug( + "Failed to get bridge gateway: #{inspect(reason)}. Trying /proc/net/route" + ) + + case File.read("/proc/net/route") do + {:ok, content} -> + case parse_gateway_from_proc_route(content) do + {:ok, gateway} -> + Logger.debug("Found gateway from /proc/net/route: #{gateway}") + {:ok, gateway} + + {:error, _} -> + Logger.debug("Failed to parse /proc/net/route. Using localhost") + {:ok, "localhost"} + end + + {:error, _} -> + Logger.debug("Cannot read /proc/net/route. Using localhost") + {:ok, "localhost"} + end end else Logger.debug("Not running in docker environment, using localhost") @@ -534,22 +708,26 @@ defmodule Testcontainers do when reattempt_count < 5 do host_port = Container.mapped_port(container, 8080) - case :gen_tcp.connect(~c"#{docker_hostname}", host_port, [ - :binary, - active: false, - packet: :line, - send_timeout: 10000 - ]) do + case try_tcp_connect(docker_hostname, host_port) do {:ok, connected} -> {:ok, connected} {:error, reason} -> - Logger.info( - "Connection failed with #{inspect(reason)}. Retrying... Attempt #{reattempt_count + 1}/5" - ) + # If connecting via docker_hostname:mapped_port fails and we're in a container, + # try the container's internal IP on its internal port. In DooD (Docker-outside-of-Docker) + # both containers are on the same bridge network, so direct IP access works. + case try_container_internal_connect(container, 8080, reason) do + {:ok, connected} -> + {:ok, connected} + + {:error, _} -> + Logger.info( + "Connection to Ryuk failed (#{inspect(reason)}). Retrying... Attempt #{reattempt_count + 1}/5" + ) - :timer.sleep(1000) - create_ryuk_socket(container, docker_hostname, reattempt_count + 1) + :timer.sleep(1000) + create_ryuk_socket(container, docker_hostname, reattempt_count + 1) + end end end @@ -558,6 +736,33 @@ defmodule Testcontainers do {:error, :econnrefused} end + defp try_tcp_connect(host, port) do + :gen_tcp.connect(~c"#{host}", port, [ + :binary, + active: false, + packet: :line, + send_timeout: 10000 + ], 5000) + end + + defp try_container_internal_connect(%Container{ip_address: ip}, internal_port, original_reason) + when is_binary(ip) and ip != "" do + if running_in_container?() do + Logger.info( + "Connection via mapped port failed (#{inspect(original_reason)}). " <> + "Trying container internal IP #{ip}:#{internal_port}" + ) + + try_tcp_connect(ip, internal_port) + else + {:error, original_reason} + end + end + + defp try_container_internal_connect(_container, _internal_port, original_reason) do + {:error, original_reason} + end + defp register_ryuk_filter(value, socket) do :gen_tcp.send( socket, @@ -695,8 +900,21 @@ defmodule Testcontainers do end defp apply_docker_socket_volume_binding(config, docker_host) do - case {os_type(), URI.parse(docker_host)} do - {os, uri} -> handle_docker_socket_binding(config, os, uri) + socket_override = System.get_env("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE") + + if socket_override do + Logger.debug("Using docker socket override: #{socket_override}") + + Container.with_bind_mount( + config, + socket_override, + "/var/run/docker.sock", + "rw" + ) + else + case {os_type(), URI.parse(docker_host)} do + {os, uri} -> handle_docker_socket_binding(config, os, uri) + end end end diff --git a/lib/wait_strategy/http_wait_strategy.ex b/lib/wait_strategy/http_wait_strategy.ex index 3132158..25a82c9 100644 --- a/lib/wait_strategy/http_wait_strategy.ex +++ b/lib/wait_strategy/http_wait_strategy.ex @@ -141,9 +141,9 @@ defmodule Testcontainers.HttpWaitStrategy do end defp get_base_url(%HttpWaitStrategy{} = wait_strategy, %Container{} = container) do - port = Container.mapped_port(container, wait_strategy.port) + port = Testcontainers.get_port(container, wait_strategy.port) - "#{wait_strategy.protocol}://#{Testcontainers.get_host()}:#{port}/" + "#{wait_strategy.protocol}://#{Testcontainers.get_host(container)}:#{port}/" end end end diff --git a/lib/wait_strategy/port_wait_strategy.ex b/lib/wait_strategy/port_wait_strategy.ex index 5082ec8..3817517 100644 --- a/lib/wait_strategy/port_wait_strategy.ex +++ b/lib/wait_strategy/port_wait_strategy.ex @@ -21,13 +21,15 @@ defmodule Testcontainers.PortWaitStrategy do # Private functions and implementations defimpl Testcontainers.WaitStrategy do - alias Testcontainers.Container - @impl true def wait_until_container_is_ready(wait_strategy, container, _conn) do - with host_port when not is_nil(host_port) <- - Container.mapped_port(container, wait_strategy.port), - do: perform_port_check(wait_strategy, host_port) + host = Testcontainers.get_host(container) + host_port = Testcontainers.get_port(container, wait_strategy.port) + + case {host, host_port} do + {_, nil} -> {:error, :port_not_mapped, wait_strategy} + _ -> perform_port_check(%{wait_strategy | ip: host}, host_port) + end end defp perform_port_check(wait_strategy, host_port) do diff --git a/test/container/emqx_container_test.exs b/test/container/emqx_container_test.exs index 979034c..a7a18e6 100644 --- a/test/container/emqx_container_test.exs +++ b/test/container/emqx_container_test.exs @@ -30,6 +30,7 @@ defmodule Testcontainers.Container.EmqxContainerTest do |> EmqxContainer.with_ports(1884, 8883, 8083, 8084, 18084) ) + @tag :dood_limitation test "provides a ready-to-use emqx container" do host = EmqxContainer.host() port = 1884 diff --git a/test/container/kafka_container_test.exs b/test/container/kafka_container_test.exs index aaa7585..3b66fc7 100644 --- a/test/container/kafka_container_test.exs +++ b/test/container/kafka_container_test.exs @@ -2,7 +2,6 @@ defmodule Testcontainers.Container.KafkaContainerTest do use ExUnit.Case, async: false import Testcontainers.ExUnit - alias Testcontainers.Container alias Testcontainers.KafkaContainer describe "new/0" do @@ -133,10 +132,11 @@ defmodule Testcontainers.Container.KafkaContainerTest do describe "kafka container" do container(:kafka, KafkaContainer.new()) + @tag :dood_limitation test "provides a ready-to-use kafka container", %{kafka: kafka} do worker_name = :worker topic_name = "test_topic" - uris = [{"localhost", Container.mapped_port(kafka, 9092)}] + uris = [{Testcontainers.get_host(kafka), Testcontainers.get_port(kafka, 9092)}] {:ok, pid} = KafkaEx.create_worker(:worker, uris: uris, consumer_group: "kafka_ex") on_exit(fn -> :ok = KafkaEx.stop_worker(pid) end) @@ -154,10 +154,11 @@ defmodule Testcontainers.Container.KafkaContainerTest do describe "kafka container with automatic topic creation" do container(:kafka, KafkaContainer.new() |> KafkaContainer.with_topics(["auto_topic"])) + @tag :dood_limitation test "creates topics automatically", %{kafka: kafka} do worker_name = :auto_worker topic_name = "auto_topic" - uris = [{"localhost", Container.mapped_port(kafka, 9092)}] + uris = [{Testcontainers.get_host(kafka), Testcontainers.get_port(kafka, 9092)}] {:ok, pid} = KafkaEx.create_worker(worker_name, uris: uris, consumer_group: "kafka_ex") on_exit(fn -> :ok = KafkaEx.stop_worker(pid) end) @@ -182,7 +183,7 @@ defmodule Testcontainers.Container.KafkaContainerTest do test "bootstrap_servers returns the correct connection string", %{kafka: kafka} do bootstrap = KafkaContainer.bootstrap_servers(kafka) - assert bootstrap =~ ~r/^localhost:\d+$/ + assert bootstrap =~ ~r/^[\w\.]+:\d+$/ end test "port returns the mapped port", %{kafka: kafka} do diff --git a/test/container/selenium_container_test.exs b/test/container/selenium_container_test.exs index a4c36a9..6a98f2f 100644 --- a/test/container/selenium_container_test.exs +++ b/test/container/selenium_container_test.exs @@ -9,6 +9,7 @@ defmodule Testcontainers.Container.SeleniumContainerTest do describe "with default configuration" do container(:selenium, SeleniumContainer.new()) + @tag :dood_limitation test "provides a ready-to-use selenium container", %{selenium: selenium} do assert Container.mapped_port(selenium, 4400) > 0 assert Container.mapped_port(selenium, 4400) != 4400 diff --git a/test/container/toxiproxy_container_test.exs b/test/container/toxiproxy_container_test.exs index 2891577..99bc268 100644 --- a/test/container/toxiproxy_container_test.exs +++ b/test/container/toxiproxy_container_test.exs @@ -118,8 +118,8 @@ defmodule Testcontainers.Container.ToxiproxyContainerTest do test "can access toxiproxy API", %{toxiproxy: toxiproxy} do :inets.start() - host = Testcontainers.get_host() - port = ToxiproxyContainer.mapped_control_port(toxiproxy) + host = Testcontainers.get_host(toxiproxy) + port = Testcontainers.get_port(toxiproxy, ToxiproxyContainer.control_port()) url = ~c"http://#{host}:#{port}/version" {:ok, {{_, 200, _}, _, body}} = :httpc.request(:get, {url, []}, [], []) @@ -232,6 +232,7 @@ defmodule Testcontainers.Container.ToxiproxyContainerTest do {:ok, network_name: network_name} end + @tag :dood_limitation test "can proxy and inject faults into Redis traffic", %{network_name: network_name} do alias Testcontainers.RedisContainer alias Testcontainers.ContainerBuilder diff --git a/test/copy_to_test.exs b/test/copy_to_test.exs index b4e1278..2278123 100644 --- a/test/copy_to_test.exs +++ b/test/copy_to_test.exs @@ -14,8 +14,9 @@ defmodule CopyToTest do assert {:ok, container} = Testcontainers.start_container(config) - mapped_port = Testcontainers.Container.mapped_port(container, port) - {:ok, %{body: body}} = Tesla.get("http://127.0.0.1:#{mapped_port}/hello.txt") + host = Testcontainers.get_host(container) + mapped_port = Testcontainers.get_port(container, port) + {:ok, %{body: body}} = Tesla.get("http://#{host}:#{mapped_port}/hello.txt") assert contents == body assert :ok = Testcontainers.stop_container(container.container_id) diff --git a/test/docker_host_detection_test.exs b/test/docker_host_detection_test.exs new file mode 100644 index 0000000..c33b2c3 --- /dev/null +++ b/test/docker_host_detection_test.exs @@ -0,0 +1,126 @@ +defmodule Testcontainers.DockerHostDetectionTest do + use ExUnit.Case, async: true + + describe "parse_gateway_from_proc_route/1" do + test "parses hex-encoded gateway from /proc/net/route content" do + content = """ + Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT + eth0\t00000000\t0102A8C0\t0003\t0\t0\t0\t00000000\t0\t0\t0 + eth0\t0002A8C0\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0 + """ + + assert {:ok, "192.168.2.1"} = Testcontainers.parse_gateway_from_proc_route(content) + end + + test "parses another gateway address" do + content = """ + Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT + eth0\t00000000\t0100000A\t0003\t0\t0\t0\t00000000\t0\t0\t0 + """ + + assert {:ok, "10.0.0.1"} = Testcontainers.parse_gateway_from_proc_route(content) + end + + test "returns error when no default route exists" do + content = """ + Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT + eth0\t0002A8C0\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0 + """ + + assert {:error, :no_default_route} = Testcontainers.parse_gateway_from_proc_route(content) + end + + test "returns error for empty content" do + assert {:error, :no_default_route} = Testcontainers.parse_gateway_from_proc_route("") + end + end + + describe "running_in_container?/2" do + test "returns true when dockerenv file exists" do + tmp_path = Path.join(System.tmp_dir!(), "test_dockerenv_#{:rand.uniform(100_000)}") + File.write!(tmp_path, "") + + try do + assert Testcontainers.running_in_container?(tmp_path, "/nonexistent/cgroup") + after + File.rm(tmp_path) + end + end + + test "returns true when cgroup contains docker pattern" do + tmp_path = Path.join(System.tmp_dir!(), "test_cgroup_#{:rand.uniform(100_000)}") + + File.write!(tmp_path, """ + 12:memory:/docker/abc123def456 + 11:cpu:/docker/abc123def456 + """) + + try do + assert Testcontainers.running_in_container?("/nonexistent/dockerenv", tmp_path) + after + File.rm(tmp_path) + end + end + + test "returns true when cgroup contains kubepods pattern" do + tmp_path = Path.join(System.tmp_dir!(), "test_cgroup_kube_#{:rand.uniform(100_000)}") + + File.write!(tmp_path, """ + 12:memory:/kubepods/besteffort/pod123 + """) + + try do + assert Testcontainers.running_in_container?("/nonexistent/dockerenv", tmp_path) + after + File.rm(tmp_path) + end + end + + test "returns true when cgroup contains lxc pattern" do + tmp_path = Path.join(System.tmp_dir!(), "test_cgroup_lxc_#{:rand.uniform(100_000)}") + + File.write!(tmp_path, """ + 12:memory:/lxc/container-name + """) + + try do + assert Testcontainers.running_in_container?("/nonexistent/dockerenv", tmp_path) + after + File.rm(tmp_path) + end + end + + test "returns true when cgroup contains containerd pattern" do + tmp_path = Path.join(System.tmp_dir!(), "test_cgroup_containerd_#{:rand.uniform(100_000)}") + + File.write!(tmp_path, """ + 12:memory:/system.slice/containerd.service + """) + + try do + assert Testcontainers.running_in_container?("/nonexistent/dockerenv", tmp_path) + after + File.rm(tmp_path) + end + end + + test "returns false when neither dockerenv nor cgroup exist" do + refute Testcontainers.running_in_container?("/nonexistent/dockerenv", "/nonexistent/cgroup") + end + + test "returns false when cgroup exists but has no container patterns" do + tmp_path = Path.join(System.tmp_dir!(), "test_cgroup_empty_#{:rand.uniform(100_000)}") + + File.write!(tmp_path, """ + 12:memory:/user.slice/user-1000.slice + 11:cpu:/user.slice/user-1000.slice + """) + + try do + refute Testcontainers.running_in_container?("/nonexistent/dockerenv", tmp_path) + after + File.rm(tmp_path) + end + end + end +end diff --git a/test/generic_container_test.exs b/test/generic_container_test.exs index f020ba8..b4df119 100644 --- a/test/generic_container_test.exs +++ b/test/generic_container_test.exs @@ -15,6 +15,7 @@ defmodule Testcontainers.GenericContainerTest do # This doesnt work on rootless docker, because binding ports to host requires root (i guess) # run test with --exclude needs_root if you are running rootless @tag :needs_root + @tag :dood_limitation test "can start and stop generic container with network mode set to host" do if not is_os(:linux) do Logger.warning("Host is not Linux, therefore not running network_mode test") diff --git a/test/network_test.exs b/test/network_test.exs index 6616f77..1c28b38 100644 --- a/test/network_test.exs +++ b/test/network_test.exs @@ -70,6 +70,7 @@ defmodule Testcontainers.NetworkTest do end describe "containers on shared network" do + @tag :dood_limitation test "containers can communicate via hostname" do network_name = "test-network-comm-#{:rand.uniform(100_000)}" diff --git a/test/test_helper.exs b/test/test_helper.exs index 9d722c7..add75b9 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,10 @@ Testcontainers.start_link() -ExUnit.start(timeout: 300_000) +exclude = + if Testcontainers.running_in_container?() do + [:dood_limitation] + else + [] + end + +ExUnit.start(timeout: 300_000, exclude: exclude) diff --git a/test/wait_strategy/http_wait_strategy_test.exs b/test/wait_strategy/http_wait_strategy_test.exs index 926aba8..c0cc003 100644 --- a/test/wait_strategy/http_wait_strategy_test.exs +++ b/test/wait_strategy/http_wait_strategy_test.exs @@ -13,8 +13,9 @@ defmodule Testcontainers.HttpWaitStrategyTest do assert {:ok, container} = Testcontainers.start_container(config) - host_port = Container.mapped_port(container, port) - url = ~c"http://localhost:#{host_port}/" + host = Testcontainers.get_host(container) + host_port = Testcontainers.get_port(container, port) + url = ~c"http://#{host}:#{host_port}/" {:ok, {_status, _headers, body}} = :httpc.request(:get, {url, []}, [], []) assert to_string(body) =~ "Welcome to nginx!"