From d596b6323efa2bf55358ce81eeff00b06a70be56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jarl=20Andr=C3=A9=20H=C3=BCbenthal?= Date: Wed, 22 Apr 2026 07:56:13 +0200 Subject: [PATCH] feat(pull_policy): add pull_if_missing and make it the default Adds PullPolicy.pull_if_missing/0 which inspects the local Docker daemon and only pulls the image if it is not already present, avoiding Docker Hub rate limits. Changes the default pull policy (when neither set on the container config nor overridden via the "pull.policy" property) from always_pull to pull_if_missing, matching testcontainers-java behavior. Also addresses all credo --strict and dialyzer warnings across the codebase and wires `mix credo --strict` into the Elixir CI workflow. --- .github/workflows/elixir.yml | 2 + lib/connection/connection.ex | 15 +- .../docker_socket_path.ex | 50 ++-- .../docker_host_strategy_evaluator.ex | 3 +- lib/container.ex | 9 +- lib/container/cassandra_container.ex | 6 +- lib/container/ceph_container.ex | 8 +- lib/container/emqx_container.ex | 7 +- lib/container/kafka_container.ex | 6 +- lib/container/minio_container.ex | 6 +- lib/container/mongo_container.ex | 6 +- lib/container/mysql_container.ex | 6 +- lib/container/postgres_container.ex | 6 +- lib/container/protocols/container_builder.ex | 4 +- .../protocols/container_builder_helper.ex | 7 +- lib/container/rabbitmq_container.ex | 11 +- lib/container/redis_container.ex | 8 +- lib/container/selenium_container.ex | 10 +- lib/copy_to.ex | 1 + lib/docker/api.ex | 26 ++- lib/mix/tasks/testcontainers/run.ex | 2 +- lib/mix/tasks/testcontainers/test.ex | 1 + lib/pull_policy.ex | 24 +- lib/testcontainers.ex | 218 ++++++++++-------- lib/util/constants.ex | 2 +- lib/util/hash.ex | 1 + lib/util/struct.ex | 1 + lib/wait_strategy/command_wait_strategy.ex | 9 +- lib/wait_strategy/http_wait_strategy.ex | 2 +- lib/wait_strategy/log_wait_strategy.ex | 7 +- lib/wait_strategy/port_wait_strategy.ex | 2 +- lib/wait_strategy/protocols/wait_strategy.ex | 2 +- mix.exs | 1 + mix.lock | 3 + test/compose/cli_test.exs | 18 +- test/compose/compose_integration_test.exs | 2 +- .../docker_host_from_env_test.exs | 2 +- .../docker_host_from_properties_test.exs | 2 +- test/constants_test.exs | 2 +- .../container_builder_helper_test.exs | 4 +- test/container/emqx_container_test.exs | 2 +- test/container/kafka_container_test.exs | 2 +- test/container/mongo_container_test.exs | 2 +- test/container/toxiproxy_container_test.exs | 2 +- test/container_test.exs | 12 +- test/generic_container_test.exs | 6 +- test/pull_policy_test.exs | 60 +++-- test/support/nginx_container.ex | 1 + test/support/test_helper.ex | 1 + test/testcontainers_test.exs | 2 +- .../wait_strategy/http_wait_strategy_test.exs | 2 +- 51 files changed, 366 insertions(+), 228 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 461ef9ef..ce050f20 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -52,6 +52,8 @@ jobs: restore-keys: ${{ runner.os }}-mix- - name: Install dependencies run: mix deps.get + - name: Run credo + run: mix credo --strict - name: Run tests run: MIX_ENV=test mix citest - name: Verify version diff --git a/lib/connection/connection.ex b/lib/connection/connection.ex index 966c8d3d..5f1ae6e7 100644 --- a/lib/connection/connection.ex +++ b/lib/connection/connection.ex @@ -4,13 +4,13 @@ defmodule Testcontainers.Connection do require Logger + alias DockerEngineAPI.Connection alias Testcontainers.Constants - alias Testcontainers.DockerUrl + alias Testcontainers.DockerHostFromEnvStrategy + alias Testcontainers.DockerHostFromPropertiesStrategy alias Testcontainers.DockerHostStrategyEvaluator alias Testcontainers.DockerSocketPathStrategy - alias Testcontainers.DockerHostFromPropertiesStrategy - alias Testcontainers.DockerHostFromEnvStrategy - alias DockerEngineAPI.Connection + alias Testcontainers.DockerUrl @timeout 300_000 @@ -29,9 +29,10 @@ defmodule Testcontainers.Connection do end defp get_docker_host_url do - with {:ok, docker_host} <- get_docker_host() do - {DockerUrl.construct(docker_host), docker_host} - else + case get_docker_host() do + {:ok, docker_host} -> + {DockerUrl.construct(docker_host), docker_host} + {:error, error} -> exit(error) end diff --git a/lib/connection/docker_host_strategy/docker_socket_path.ex b/lib/connection/docker_host_strategy/docker_socket_path.ex index 17e9e085..e81d2e6d 100644 --- a/lib/connection/docker_host_strategy/docker_socket_path.ex +++ b/lib/connection/docker_host_strategy/docker_socket_path.ex @@ -28,30 +28,34 @@ defmodule Testcontainers.DockerSocketPathStrategy do end def execute(strategy, _input) do - Enum.reduce_while( - if length(strategy.socket_paths) == 0 do - default_socket_paths() - else - strategy.socket_paths - end, - {:error, {:docker_socket_not_found, []}}, - fn path, {:error, {:docker_socket_not_found, tried_paths}} -> - if path != nil && File.exists?(path) do - path_with_scheme = "unix://" <> path - - case DockerUrl.test_docker_host(path_with_scheme) do - :ok -> - {:halt, {:ok, path_with_scheme}} - - {:error, reason} -> - Logger.debug("Docker socket path #{path} failed: #{reason}") - {:cont, {:error, {:docker_socket_not_found, tried_paths ++ [path]}}} - end - else - {:cont, {:error, {:docker_socket_not_found, tried_paths ++ [path]}}} - end + paths = + case strategy.socket_paths do + [] -> default_socket_paths() + paths -> paths end - ) + + Enum.reduce_while(paths, {:error, {:docker_socket_not_found, []}}, &try_socket_path/2) + end + + defp try_socket_path(path, {:error, {:docker_socket_not_found, tried_paths}}) do + if path != nil && File.exists?(path) do + probe_socket(path, tried_paths) + else + {:cont, {:error, {:docker_socket_not_found, tried_paths ++ [path]}}} + end + end + + defp probe_socket(path, tried_paths) do + path_with_scheme = "unix://" <> path + + case DockerUrl.test_docker_host(path_with_scheme) do + :ok -> + {:halt, {:ok, path_with_scheme}} + + {:error, reason} -> + Logger.debug("Docker socket path #{path} failed: #{reason}") + {:cont, {:error, {:docker_socket_not_found, tried_paths ++ [path]}}} + end end end end diff --git a/lib/connection/docker_host_strategy_evaluator.ex b/lib/connection/docker_host_strategy_evaluator.ex index d88315c1..eafa1c22 100644 --- a/lib/connection/docker_host_strategy_evaluator.ex +++ b/lib/connection/docker_host_strategy_evaluator.ex @@ -24,7 +24,6 @@ defmodule Testcontainers.DockerHostStrategyEvaluator do defp format_errors(errors) do errors |> Enum.reverse() - |> Enum.map(fn {:error, error} -> inspect(error) end) - |> Enum.join(", ") + |> Enum.map_join(", ", fn {:error, error} -> inspect(error) end) end end diff --git a/lib/container.ex b/lib/container.ex index 34a1c75e..6c605c73 100644 --- a/lib/container.ex +++ b/lib/container.ex @@ -8,6 +8,8 @@ defmodule Testcontainers.Container do require Logger + @type t :: %__MODULE__{} + @enforce_keys [:image] defstruct [ :image, @@ -51,7 +53,7 @@ defmodule Testcontainers.Container do when is_atom(name) and name == @os_type @dialyzer {:nowarn_function, os_type: 0} - def os_type() do + def os_type do cond do is_os(:linux) -> :linux is_os(:macos) -> :macos @@ -335,7 +337,8 @@ defmodule Testcontainers.Container do Available options: - * `TestContainers.PullPolicy.always_pull()` - default, always pulls image from the remote repository + * `TestContainers.PullPolicy.pull_if_missing()` - default, pulls image only if not already present locally (avoids registry rate limits) + * `TestContainers.PullPolicy.always_pull()` - always pulls image from the remote repository * `TestContainers.PullPolicy.never_pull()` - does not pull images, use when working with local images * `TestContainers.PullPolicy.pull_condition(expr)` - pulls image if expression returns `true` """ @@ -353,7 +356,7 @@ defmodule Testcontainers.Container do Do stuff after container has started. """ @impl true - @spec after_start(%Testcontainers.Container{}, %Testcontainers.Container{}, %Tesla.Env{}) :: + @spec after_start(Testcontainers.Container.t(), Testcontainers.Container.t(), Tesla.Env.t()) :: :ok def after_start(_config, _container, _conn), do: :ok end diff --git a/lib/container/cassandra_container.ex b/lib/container/cassandra_container.ex index 787322d0..92f48251 100644 --- a/lib/container/cassandra_container.ex +++ b/lib/container/cassandra_container.ex @@ -6,8 +6,8 @@ defmodule Testcontainers.CassandraContainer do alias Testcontainers.CassandraContainer alias Testcontainers.CommandWaitStrategy - alias Testcontainers.ContainerBuilder alias Testcontainers.Container + alias Testcontainers.ContainerBuilder import Testcontainers.Container, only: [is_valid_image: 1] @@ -19,6 +19,8 @@ defmodule Testcontainers.CassandraContainer do @default_port 9042 @default_wait_timeout 60_000 + @type t :: %__MODULE__{} + @enforce_keys [:image, :wait_timeout] defstruct [ :image, @@ -75,7 +77,7 @@ defmodule Testcontainers.CassandraContainer do import Container @impl true - @spec build(%CassandraContainer{}) :: %Container{} + @spec build(CassandraContainer.t()) :: Container.t() def build(%CassandraContainer{} = config) do new(config.image) |> with_exposed_port(CassandraContainer.default_port()) diff --git a/lib/container/ceph_container.ex b/lib/container/ceph_container.ex index b301e83a..a4c57a35 100644 --- a/lib/container/ceph_container.ex +++ b/lib/container/ceph_container.ex @@ -4,10 +4,10 @@ defmodule Testcontainers.CephContainer do Provides functionality for creating and managing Ceph container configurations. """ - alias Testcontainers.LogWaitStrategy alias Testcontainers.CephContainer - alias Testcontainers.ContainerBuilder alias Testcontainers.Container + alias Testcontainers.ContainerBuilder + alias Testcontainers.LogWaitStrategy import Testcontainers.Container, only: [is_valid_image: 1] @@ -20,6 +20,8 @@ defmodule Testcontainers.CephContainer do @default_port 8080 @default_wait_timeout 300_000 + @type t :: %__MODULE__{} + @enforce_keys [:image, :access_key, :secret_key, :bucket, :port, :wait_timeout] defstruct [ :image, @@ -230,7 +232,7 @@ defmodule Testcontainers.CephContainer do - Raises `ArgumentError` if the provided image is not compatible with the default Ceph image. """ - @spec build(%CephContainer{}) :: %Container{} + @spec build(CephContainer.t()) :: Container.t() @impl true def build(%CephContainer{} = config) do new(config.image) diff --git a/lib/container/emqx_container.ex b/lib/container/emqx_container.ex index 58803d2d..49e50291 100644 --- a/lib/container/emqx_container.ex +++ b/lib/container/emqx_container.ex @@ -3,10 +3,10 @@ defmodule Testcontainers.EmqxContainer do Provides functionality for creating and managing EMQX container configurations. """ - alias Testcontainers.ContainerBuilder alias Testcontainers.Container - alias Testcontainers.PortWaitStrategy + alias Testcontainers.ContainerBuilder alias Testcontainers.EmqxContainer + alias Testcontainers.PortWaitStrategy import Testcontainers.Container, only: [is_valid_image: 1] @@ -17,7 +17,7 @@ defmodule Testcontainers.EmqxContainer do @default_mqtts_port 8883 @default_mqtt_over_ws_port 8083 @default_mqtt_over_wss_port 8084 - @default_dashboard_port 18083 + @default_dashboard_port 18_083 @default_wait_timeout 60_000 @enforce_keys [:image, :mqtt_port, :wait_timeout] @@ -160,7 +160,6 @@ defmodule Testcontainers.EmqxContainer do ] @impl true - # TODO Implement the `after_start/3` function for the `ContainerBuilder` protocol. def after_start(_config, _container, _conn), do: :ok end end diff --git a/lib/container/kafka_container.ex b/lib/container/kafka_container.ex index 48e935e8..d7d2363d 100644 --- a/lib/container/kafka_container.ex +++ b/lib/container/kafka_container.ex @@ -45,6 +45,8 @@ defmodule Testcontainers.KafkaContainer do @default_wait_timeout 60_000 @default_cluster_id "4L6g3nShT-eMCtK--X86sw" + @type t :: %__MODULE__{} + @enforce_keys [ :image, :kafka_port, @@ -73,7 +75,7 @@ defmodule Testcontainers.KafkaContainer do """ def new do # Select a random port in a high range to minimize conflicts - kafka_port = Enum.random(29000..29999) + kafka_port = Enum.random(29_000..29_999) %__MODULE__{ image: @default_image_with_tag, @@ -170,7 +172,7 @@ defmodule Testcontainers.KafkaContainer do import Container @impl true - @spec build(%KafkaContainer{}) :: %Container{} + @spec build(KafkaContainer.t()) :: Container.t() def build(%KafkaContainer{} = config) do host = Testcontainers.get_host() diff --git a/lib/container/minio_container.ex b/lib/container/minio_container.ex index cc594922..67b9409a 100644 --- a/lib/container/minio_container.ex +++ b/lib/container/minio_container.ex @@ -4,9 +4,9 @@ defmodule Testcontainers.MinioContainer do """ alias Testcontainers.Container - alias Testcontainers.MinioContainer alias Testcontainers.ContainerBuilder alias Testcontainers.LogWaitStrategy + alias Testcontainers.MinioContainer @default_image "minio/minio" @default_tag "RELEASE.2023-11-11T08-14-41Z" @@ -17,6 +17,8 @@ defmodule Testcontainers.MinioContainer do @default_ui_port 9001 @default_wait_timeout 60_000 + @type t :: %__MODULE__{} + @enforce_keys [:image, :username, :password, :wait_timeout] defstruct [ :image, @@ -75,7 +77,7 @@ defmodule Testcontainers.MinioContainer do defimpl ContainerBuilder do import Container - @spec build(%MinioContainer{}) :: %Container{} + @spec build(MinioContainer.t()) :: Container.t() @impl true def build(%MinioContainer{} = config) do new(config.image) diff --git a/lib/container/mongo_container.ex b/lib/container/mongo_container.ex index dadc7914..84eedf61 100644 --- a/lib/container/mongo_container.ex +++ b/lib/container/mongo_container.ex @@ -18,9 +18,11 @@ defmodule Testcontainers.MongoContainer do @default_user "test" @default_password "test" @default_database "test" - @default_port 27017 + @default_port 27_017 @default_wait_timeout 180_000 + @type t :: %__MODULE__{} + @enforce_keys [:image, :user, :password, :database, :port, :wait_timeout, :persistent_volume] defstruct [ :image, @@ -251,7 +253,7 @@ defmodule Testcontainers.MongoContainer do - Raises `ArgumentError` if the provided image is not compatible with the default Mongo image. """ - @spec build(%MongoContainer{}) :: %Container{} + @spec build(MongoContainer.t()) :: Container.t() @impl true def build(%MongoContainer{} = config) do new(config.image) diff --git a/lib/container/mysql_container.ex b/lib/container/mysql_container.ex index 876856dd..47b00851 100644 --- a/lib/container/mysql_container.ex +++ b/lib/container/mysql_container.ex @@ -9,8 +9,8 @@ defmodule Testcontainers.MySqlContainer do alias Testcontainers.Container alias Testcontainers.ContainerBuilder - alias Testcontainers.MySqlContainer alias Testcontainers.LogWaitStrategy + alias Testcontainers.MySqlContainer import Testcontainers.Container, only: [is_valid_image: 1] @@ -23,6 +23,8 @@ defmodule Testcontainers.MySqlContainer do @default_port 3306 @default_wait_timeout 180_000 + @type t :: %__MODULE__{} + @enforce_keys [:image, :user, :password, :database, :port, :wait_timeout, :persistent_volume] defstruct [ :image, @@ -211,7 +213,7 @@ defmodule Testcontainers.MySqlContainer do - Raises `ArgumentError` if the provided image is not compatible with the default MySql image. """ - @spec build(%MySqlContainer{}) :: %Container{} + @spec build(MySqlContainer.t()) :: Container.t() @impl true def build(%MySqlContainer{} = config) do new(config.image) diff --git a/lib/container/postgres_container.ex b/lib/container/postgres_container.ex index 7b54e315..ff71319c 100644 --- a/lib/container/postgres_container.ex +++ b/lib/container/postgres_container.ex @@ -8,9 +8,9 @@ defmodule Testcontainers.PostgresContainer do """ alias Testcontainers.CommandWaitStrategy - alias Testcontainers.PostgresContainer alias Testcontainers.Container alias Testcontainers.ContainerBuilder + alias Testcontainers.PostgresContainer import Testcontainers.Container, only: [is_valid_image: 1] @@ -23,6 +23,8 @@ defmodule Testcontainers.PostgresContainer do @default_port 5432 @default_wait_timeout 60_000 + @type t :: %__MODULE__{} + @enforce_keys [:image, :user, :password, :database, :port, :wait_timeout, :persistent_volume] defstruct [ :image, @@ -211,7 +213,7 @@ defmodule Testcontainers.PostgresContainer do - Raises `ArgumentError` if the provided image is not compatible with the default Postgres image. """ - @spec build(%PostgresContainer{}) :: %Container{} + @spec build(PostgresContainer.t()) :: Container.t() @impl true def build(%PostgresContainer{} = config) do new(config.image) diff --git a/lib/container/protocols/container_builder.ex b/lib/container/protocols/container_builder.ex index 26ace0e2..04b6e032 100644 --- a/lib/container/protocols/container_builder.ex +++ b/lib/container/protocols/container_builder.ex @@ -2,12 +2,12 @@ defprotocol Testcontainers.ContainerBuilder do @moduledoc """ All types of predefined containers must implement this protocol. """ - @spec build(t()) :: %Testcontainers.Container{} + @spec build(t()) :: Testcontainers.Container.t() def build(builder) @doc """ Do stuff after container has started. """ - @spec after_start(t(), %Testcontainers.Container{}, %Tesla.Env{}) :: :ok | {:error, term()} + @spec after_start(t(), Testcontainers.Container.t(), Tesla.Env.t()) :: :ok | {:error, term()} def after_start(builder, container, connection) end diff --git a/lib/container/protocols/container_builder_helper.ex b/lib/container/protocols/container_builder_helper.ex index cd1b610c..c30a8a11 100644 --- a/lib/container/protocols/container_builder_helper.ex +++ b/lib/container/protocols/container_builder_helper.ex @@ -1,8 +1,9 @@ defmodule Testcontainers.ContainerBuilderHelper do + @moduledoc false import Testcontainers.Constants - alias Testcontainers.Util.Hash alias Testcontainers.Container alias Testcontainers.ContainerBuilder + alias Testcontainers.Util.Hash def build(builder, state) when is_map(state) and is_struct(builder) do config = @@ -18,13 +19,13 @@ defmodule Testcontainers.ContainerBuilderHelper do config |> Container.with_label(container_reuse(), "true") |> Container.with_label(container_reuse_hash_label(), hash) - |> Container.with_label(container_sessionId_label(), state.session_id) + |> Container.with_label(container_session_id_label(), state.session_id) |> Container.with_label(container_version_label(), library_version()) |> Kernel.then(&{:reuse, &1, hash}) else config |> Container.with_label(container_reuse(), "false") - |> Container.with_label(container_sessionId_label(), state.session_id) + |> Container.with_label(container_session_id_label(), state.session_id) |> Container.with_label(container_version_label(), library_version()) |> Kernel.then(&{:noreuse, &1, nil}) end diff --git a/lib/container/rabbitmq_container.ex b/lib/container/rabbitmq_container.ex index ffe93de1..ee283b35 100644 --- a/lib/container/rabbitmq_container.ex +++ b/lib/container/rabbitmq_container.ex @@ -7,9 +7,9 @@ defmodule Testcontainers.RabbitMQContainer do NOTE: Currently untested, any developer who tries to add improvements on this container should consider moving this container implementation to a separate Elixir package. """ - alias Testcontainers.ContainerBuilder - alias Testcontainers.Container alias Testcontainers.CommandWaitStrategy + alias Testcontainers.Container + alias Testcontainers.ContainerBuilder alias Testcontainers.RabbitMQContainer import Testcontainers.Container, only: [is_valid_image: 1] @@ -28,6 +28,8 @@ defmodule Testcontainers.RabbitMQContainer do ] @default_wait_timeout 60_000 + @type t :: %__MODULE__{} + @enforce_keys [:image, :port, :wait_timeout] defstruct [ :image, @@ -241,7 +243,8 @@ defmodule Testcontainers.RabbitMQContainer do ] end - # Provides the virtual host segment used in the AMQP URI specification defined in the AMQP 0-9-1, and interprets the virtual host for the connection URL based on the default value. + # Provides the virtual host segment used in the AMQP URI specification defined in the AMQP 0-9-1, + # and interprets the virtual host for the connection URL based on the default value. defp virtual_host_segment(container) do case container.environment[:RABBITMQ_DEFAULT_VHOST] do "/" -> "" @@ -271,7 +274,7 @@ defmodule Testcontainers.RabbitMQContainer do - Raises `ArgumentError` if the provided image is not compatible with the default RabbitMQ image. """ @impl true - @spec build(%RabbitMQContainer{}) :: %Container{} + @spec build(RabbitMQContainer.t()) :: Container.t() def build(%RabbitMQContainer{} = config) do new(config.image) |> with_exposed_port(config.port) diff --git a/lib/container/redis_container.ex b/lib/container/redis_container.ex index 0487394f..254ce63a 100644 --- a/lib/container/redis_container.ex +++ b/lib/container/redis_container.ex @@ -6,9 +6,9 @@ defmodule Testcontainers.RedisContainer do Provides functionality for creating and managing Redis container configurations. """ - alias Testcontainers.ContainerBuilder - alias Testcontainers.Container alias Testcontainers.CommandWaitStrategy + alias Testcontainers.Container + alias Testcontainers.ContainerBuilder alias Testcontainers.RedisContainer import Testcontainers.Container, only: [is_valid_image: 1] @@ -19,6 +19,8 @@ defmodule Testcontainers.RedisContainer do @default_port 6379 @default_wait_timeout 60_000 + @type t :: %__MODULE__{} + @enforce_keys [:image, :port, :wait_timeout] defstruct [ :image, @@ -165,7 +167,7 @@ defmodule Testcontainers.RedisContainer do - Raises `ArgumentError` if the provided image is not compatible with the default Redis image. """ - @spec build(%RedisContainer{}) :: %Container{} + @spec build(RedisContainer.t()) :: Container.t() @impl true def build(%RedisContainer{} = config) do container = diff --git a/lib/container/selenium_container.ex b/lib/container/selenium_container.ex index 083972d7..1caab9ac 100644 --- a/lib/container/selenium_container.ex +++ b/lib/container/selenium_container.ex @@ -4,11 +4,11 @@ defmodule Testcontainers.SeleniumContainer do Work in progress. Not stable for use yet. Not yet documented for this very reason. Can use https://github.com/stuart/elixir-webdriver for client in tests """ - alias Testcontainers.ContainerBuilder alias Testcontainers.Container - alias Testcontainers.SeleniumContainer - alias Testcontainers.PortWaitStrategy + alias Testcontainers.ContainerBuilder alias Testcontainers.LogWaitStrategy + alias Testcontainers.PortWaitStrategy + alias Testcontainers.SeleniumContainer import Testcontainers.Container, only: [is_valid_image: 1] @@ -19,6 +19,8 @@ defmodule Testcontainers.SeleniumContainer do @default_port2 4400 @default_wait_timeout 120_000 + @type t :: %__MODULE__{} + @enforce_keys [:image, :port1, :port2, :wait_timeout] defstruct [ :image, @@ -72,7 +74,7 @@ defmodule Testcontainers.SeleniumContainer do defimpl ContainerBuilder do import Container - @spec build(%SeleniumContainer{}) :: %Container{} + @spec build(SeleniumContainer.t()) :: Container.t() @impl true def build(%SeleniumContainer{} = config) do new(config.image) diff --git a/lib/copy_to.ex b/lib/copy_to.ex index 00a811a1..5b9e3552 100644 --- a/lib/copy_to.ex +++ b/lib/copy_to.ex @@ -1,4 +1,5 @@ defmodule Testcontainers.CopyTo do + @moduledoc false alias Testcontainers.Docker @doc """ diff --git a/lib/docker/api.ex b/lib/docker/api.ex index db699455..1b1ee63a 100644 --- a/lib/docker/api.ex +++ b/lib/docker/api.ex @@ -4,9 +4,9 @@ defmodule Testcontainers.Docker.Api do Internal docker api. Only for direct use by `Testcontainers` """ + alias DockerEngineAPI.Api alias DockerEngineAPI.Model.ExecConfig alias DockerEngineAPI.Model.HostConfig - alias DockerEngineAPI.Api alias Testcontainers.Container def get_container(container_id, conn) @@ -64,6 +64,22 @@ defmodule Testcontainers.Docker.Api do end end + def image_exists?(image, conn) when is_binary(image) do + case Api.Image.image_inspect(conn, image) do + {:ok, %DockerEngineAPI.Model.ImageInspect{}} -> + {:ok, true} + + {:ok, %DockerEngineAPI.Model.ErrorResponse{}} -> + {:ok, false} + + {:error, %Tesla.Env{status: 404}} -> + {:ok, false} + + {:error, %Tesla.Env{status: other}} -> + {:error, {:http_error, other}} + end + end + def delete_image(image, conn) when is_binary(image) do case Api.Image.image_delete(conn, image, force: true) do {:ok, _} -> :ok @@ -177,11 +193,9 @@ defmodule Testcontainers.Docker.Api do config |> Enum.filter(fn cfg -> Map.get(cfg, :Gateway, nil) != nil end) - if length(with_gateway) > 0 do - gateway = with_gateway |> Kernel.hd() |> Map.get(:Gateway) - {:ok, gateway} - else - {:error, :no_gateway} + case with_gateway do + [] -> {:error, :no_gateway} + [first | _] -> {:ok, Map.get(first, :Gateway)} end {:error, reason} -> diff --git a/lib/mix/tasks/testcontainers/run.ex b/lib/mix/tasks/testcontainers/run.ex index 1810629d..9773994a 100644 --- a/lib/mix/tasks/testcontainers/run.ex +++ b/lib/mix/tasks/testcontainers/run.ex @@ -1,7 +1,7 @@ defmodule Mix.Tasks.Testcontainers.Run do use Mix.Task - alias Testcontainers.PostgresContainer alias Testcontainers.MySqlContainer + alias Testcontainers.PostgresContainer @shortdoc "Runs a Mix sub-task (test, phx.server, etc) with a database container" @moduledoc """ diff --git a/lib/mix/tasks/testcontainers/test.ex b/lib/mix/tasks/testcontainers/test.ex index ddc95362..4c826674 100644 --- a/lib/mix/tasks/testcontainers/test.ex +++ b/lib/mix/tasks/testcontainers/test.ex @@ -1,4 +1,5 @@ defmodule Mix.Tasks.Testcontainers.Test do + @moduledoc false use Mix.Task @shortdoc "Runs mix test with Testcontainers (backward compatibility)" diff --git a/lib/pull_policy.ex b/lib/pull_policy.ex index b1e389df..030f4cfe 100644 --- a/lib/pull_policy.ex +++ b/lib/pull_policy.ex @@ -1,23 +1,39 @@ defmodule Testcontainers.PullPolicy do + @moduledoc """ + Pull policies that control whether an image is fetched from a remote registry + before starting a container. + """ + alias Testcontainers.PullPolicy - defstruct [:always_pull, :pull_condition] + @type t :: %__MODULE__{ + always_pull: boolean() | nil, + pull_if_missing: boolean() | nil, + pull_condition: (struct(), Tesla.Env.t() -> boolean()) | nil + } + + defstruct [:always_pull, :pull_if_missing, :pull_condition] - @spec always_pull() :: %PullPolicy{} + @spec always_pull() :: PullPolicy.t() def always_pull do %__MODULE__{always_pull: true} end - @spec never_pull() :: %PullPolicy{} + @spec never_pull() :: PullPolicy.t() def never_pull do %__MODULE__{} end + @spec pull_if_missing() :: PullPolicy.t() + def pull_if_missing do + %__MODULE__{pull_if_missing: true} + end + @spec pull_condition( expr :: (config :: struct(), conn :: Tesla.Env.t() -> true | false) ) :: - %PullPolicy{} + PullPolicy.t() def pull_condition(expr) do %__MODULE__{pull_condition: expr} end diff --git a/lib/testcontainers.ex b/lib/testcontainers.ex index 0f9d17d9..31c2d578 100644 --- a/lib/testcontainers.ex +++ b/lib/testcontainers.ex @@ -9,19 +9,19 @@ defmodule Testcontainers do require Logger - alias Testcontainers.CopyTo - alias Testcontainers.Constants - alias Testcontainers.WaitStrategy - alias Testcontainers.Docker.Api + alias Testcontainers.Compose.Cli, as: ComposeCli + alias Testcontainers.Compose.ComposeEnvironment + alias Testcontainers.Compose.ComposeService alias Testcontainers.Connection + alias Testcontainers.Constants alias Testcontainers.Container alias Testcontainers.ContainerBuilder + alias Testcontainers.CopyTo + alias Testcontainers.Docker.Api + alias Testcontainers.DockerCompose alias Testcontainers.PullPolicy alias Testcontainers.Util.PropertiesParser - alias Testcontainers.DockerCompose - alias Testcontainers.Compose.Cli, as: ComposeCli - alias Testcontainers.Compose.ComposeService - alias Testcontainers.Compose.ComposeEnvironment + alias Testcontainers.WaitStrategy import Testcontainers.Constants import Testcontainers.Container, only: [os_type: 0] @@ -94,7 +94,7 @@ defmodule Testcontainers do end @doc false - def get_host(), do: wait_for_call(:get_host, __MODULE__) + def get_host, do: wait_for_call(:get_host, __MODULE__) @doc """ Returns the host to use for connecting to the given container. @@ -334,22 +334,7 @@ defmodule Testcontainers do Task.async(fn -> result = start_and_wait(config_builder, state) - - case result do - {:ok, container} -> - GenServer.cast(self_pid, {:track_container, container.container_id, container.image}) - - _ -> - # Track the image even on failure so it gets cleaned up on terminate - case config_builder do - %Container{image: image} when is_binary(image) -> - GenServer.cast(self_pid, {:track_image, image}) - - _ -> - :ok - end - end - + track_result(self_pid, config_builder, result) GenServer.reply(from, result) end) @@ -376,7 +361,7 @@ defmodule Testcontainers do @impl true def handle_call({:create_network, network_name}, from, state) do labels = %{ - Constants.container_sessionId_label() => state.session_id, + Constants.container_session_id_label() => state.session_id, Constants.container_version_label() => Constants.library_version(), Constants.container_lang_label() => Constants.container_lang_value(), Constants.container_label() => "true", @@ -551,31 +536,40 @@ defmodule Testcontainers do 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 - nil -> - {:halt, {:error, {:service_not_found, service_name}}} - - %ComposeService{container_id: container_id} -> - case Api.get_container(container_id, state.conn) do - {:ok, container} -> - result = - Enum.reduce(strategies, :ok, fn - strategy, :ok -> - WaitStrategy.wait_until_container_is_ready(strategy, container, state.conn) - - _, error -> - error - end) - - case result do - :ok -> {:cont, :ok} - error -> {:halt, error} - end - - {:error, _} = error -> - {:halt, error} - end - end + run_service_wait_strategies(service_name, strategies, services, state) + end) + end + + defp run_service_wait_strategies(service_name, strategies, services, state) do + case Map.get(services, service_name) do + nil -> + {:halt, {:error, {:service_not_found, service_name}}} + + %ComposeService{container_id: container_id} -> + apply_wait_strategies_to_container(container_id, strategies, state) + end + end + + defp apply_wait_strategies_to_container(container_id, strategies, state) do + case Api.get_container(container_id, state.conn) do + {:ok, container} -> + case reduce_wait_strategies(strategies, container, state) do + :ok -> {:cont, :ok} + error -> {:halt, error} + end + + {:error, _} = error -> + {:halt, error} + end + end + + defp reduce_wait_strategies(strategies, container, state) do + Enum.reduce(strategies, :ok, fn + strategy, :ok -> + WaitStrategy.wait_until_container_is_ready(strategy, container, state.conn) + + _, error -> + error end) end @@ -599,38 +593,51 @@ defmodule Testcontainers do {:ok, uri.host} uri when uri.scheme == "http+unix" -> - 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)}. 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") - {:ok, "localhost"} - end + resolve_unix_docker_hostname(conn) + end + end + + defp resolve_unix_docker_hostname(conn) do + if running_in_container?() do + Logger.debug("Running in docker environment, trying to get bridge network gateway") + resolve_bridge_gateway(conn) + else + Logger.debug("Not running in docker environment, using localhost") + {:ok, "localhost"} + end + end + + defp resolve_bridge_gateway(conn) do + case Api.get_bridge_gateway(conn) do + {:ok, gateway} -> + {:ok, gateway} + + {:error, reason} -> + Logger.debug("Failed to get bridge gateway: #{inspect(reason)}. Trying /proc/net/route") + resolve_gateway_from_proc_route() + end + end + + defp resolve_gateway_from_proc_route do + case File.read("/proc/net/route") do + {:ok, content} -> + resolve_gateway_from_content(content) + + {:error, _} -> + Logger.debug("Cannot read /proc/net/route. Using localhost") + {:ok, "localhost"} + end + end + + defp resolve_gateway_from_content(content) do + 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 end @@ -643,14 +650,14 @@ defmodule Testcontainers do case ryuk_disabled do true -> - ryukDisabledMessage = + ryuk_disabled_message = """ ******************************************************************************** Ryuk has been disabled. This can cause unexpected behavior in your environment. ******************************************************************************** """ - IO.puts(ryukDisabledMessage) + IO.puts(ryuk_disabled_message) {:ok} @@ -741,7 +748,7 @@ defmodule Testcontainers do :binary, active: false, packet: :line, - send_timeout: 10000 + send_timeout: 10_000 ], 5000) end @@ -766,7 +773,7 @@ defmodule Testcontainers do defp register_ryuk_filter(value, socket) do :gen_tcp.send( socket, - "label=#{container_sessionId_label()}=#{value}&" <> + "label=#{container_session_id_label()}=#{value}&" <> "label=#{container_version_label()}=#{library_version()}&" <> "label=#{container_lang_label()}=#{container_lang_value()}&" <> "label=#{container_label()}=#{true}&" <> @@ -782,6 +789,16 @@ defmodule Testcontainers do end end + defp track_result(self_pid, _config_builder, {:ok, container}) do + GenServer.cast(self_pid, {:track_container, container.container_id, container.image}) + end + + defp track_result(self_pid, %Container{image: image}, _result) when is_binary(image) do + GenServer.cast(self_pid, {:track_image, image}) + end + + defp track_result(_self_pid, _config_builder, _result), do: :ok + defp start_and_wait(config_builder, state) do case Testcontainers.ContainerBuilderHelper.build(config_builder, state) do {:reuse, config, hash} -> @@ -825,10 +842,10 @@ defmodule Testcontainers do defp resolve_pull_policy(%{pull_policy: nil} = config, properties) do pull_policy = - case Map.get(properties, "pull.policy", "always") do - "missing" -> PullPolicy.never_pull() + case Map.get(properties, "pull.policy", "missing") do + "always" -> PullPolicy.always_pull() "never" -> PullPolicy.never_pull() - _ -> PullPolicy.always_pull() + _ -> PullPolicy.pull_if_missing() end %{config | pull_policy: pull_policy} @@ -850,14 +867,31 @@ defmodule Testcontainers do end end - defp maybe_pull_image(config = %{pull_policy: %{always_pull: true}}, conn) do + defp maybe_pull_image(%{pull_policy: %{always_pull: true}} = config, conn) do case Api.pull_image(config.image, conn, auth: config.auth) do {:ok, _nil} -> :ok error -> error end end - defp maybe_pull_image(config = %{pull_policy: %{pull_condition: expr}}, conn) + defp maybe_pull_image(%{pull_policy: %{pull_if_missing: true}} = config, conn) do + case Api.image_exists?(config.image, conn) do + {:ok, true} -> + Logger.debug("Image #{config.image} already present locally, skipping pull") + :ok + + {:ok, false} -> + case Api.pull_image(config.image, conn, auth: config.auth) do + {:ok, _nil} -> :ok + error -> error + end + + error -> + error + end + end + + defp maybe_pull_image(%{pull_policy: %{pull_condition: expr}} = config, conn) when is_function(expr) do with {:eval, true} <- {:eval, expr.(config, conn)}, {:ok, _nil} <- Api.pull_image(config.image, conn, auth: config.auth) do diff --git a/lib/util/constants.ex b/lib/util/constants.ex index 201241f4..1746375f 100644 --- a/lib/util/constants.ex +++ b/lib/util/constants.ex @@ -9,7 +9,7 @@ defmodule Testcontainers.Constants do def container_reuse_hash_label, do: "org.testcontainers.reuse-hash" def container_reuse, do: "org.testcontainers.reuse" def container_lang_value, do: Elixir |> Atom.to_string() |> String.downcase() - def container_sessionId_label, do: "org.testcontainers.session-id" + def container_session_id_label, do: "org.testcontainers.session-id" def container_version_label, do: "org.testcontainers.version" def user_agent, do: "tc-elixir/" <> __MODULE__.library_version() end diff --git a/lib/util/hash.ex b/lib/util/hash.ex index 39576b21..10fbf444 100644 --- a/lib/util/hash.ex +++ b/lib/util/hash.ex @@ -1,4 +1,5 @@ defmodule Testcontainers.Util.Hash do + @moduledoc false alias Testcontainers.Util.ListFromDeepStruct def struct_to_hash(struct) when is_struct(struct) do diff --git a/lib/util/struct.ex b/lib/util/struct.ex index 6a841dc2..6a8d3f1e 100644 --- a/lib/util/struct.ex +++ b/lib/util/struct.ex @@ -1,4 +1,5 @@ defmodule Testcontainers.Util.ListFromDeepStruct do + @moduledoc false def from_deep_struct(%{} = map), do: convert(map) defp convert(%Regex{} = data) do diff --git a/lib/wait_strategy/command_wait_strategy.ex b/lib/wait_strategy/command_wait_strategy.ex index e3f83166..af7e1432 100644 --- a/lib/wait_strategy/command_wait_strategy.ex +++ b/lib/wait_strategy/command_wait_strategy.ex @@ -34,9 +34,10 @@ defmodule Testcontainers.CommandWaitStrategy do # Main loop for waiting strategy defp perform_recursive_wait(wait_strategy, container_id, conn, started_at) do - with {:ok, 0} <- execute_command_and_wait(wait_strategy, container_id, conn) do - :ok - else + case execute_command_and_wait(wait_strategy, container_id, conn) do + {:ok, 0} -> + :ok + {:ok, exit_code} -> handle_non_zero_exit(wait_strategy, container_id, exit_code, conn, started_at) @@ -88,7 +89,7 @@ defmodule Testcontainers.CommandWaitStrategy do end end - defp get_current_time_millis(), do: System.monotonic_time(:millisecond) + defp get_current_time_millis, do: System.monotonic_time(:millisecond) defp timed_out?(started_at, timeout), do: get_current_time_millis() - started_at > timeout diff --git a/lib/wait_strategy/http_wait_strategy.ex b/lib/wait_strategy/http_wait_strategy.ex index 25a82c91..df4fe42f 100644 --- a/lib/wait_strategy/http_wait_strategy.ex +++ b/lib/wait_strategy/http_wait_strategy.ex @@ -69,8 +69,8 @@ defmodule Testcontainers.HttpWaitStrategy do # Private functions and implementations defimpl Testcontainers.WaitStrategy do - alias Testcontainers.HttpWaitStrategy alias Testcontainers.Container + alias Testcontainers.HttpWaitStrategy @impl true def wait_until_container_is_ready(wait_strategy, container, _conn) do diff --git a/lib/wait_strategy/log_wait_strategy.ex b/lib/wait_strategy/log_wait_strategy.ex index 8b09e68f..e4a3c1f6 100644 --- a/lib/wait_strategy/log_wait_strategy.ex +++ b/lib/wait_strategy/log_wait_strategy.ex @@ -51,14 +51,13 @@ defmodule Testcontainers.LogWaitStrategy do end defp log_matches?(container_id, log_regex, conn) do - with {:ok, log_output} <- Docker.Api.stdout_logs(container_id, conn) do - Regex.match?(log_regex, log_output) - else + case Docker.Api.stdout_logs(container_id, conn) do + {:ok, log_output} -> Regex.match?(log_regex, log_output) _ -> false end end - defp get_current_time_millis(), do: System.monotonic_time(:millisecond) + defp get_current_time_millis, do: System.monotonic_time(:millisecond) defp reached_timeout?(start_time, timeout), do: get_current_time_millis() - start_time > timeout diff --git a/lib/wait_strategy/port_wait_strategy.ex b/lib/wait_strategy/port_wait_strategy.ex index 38175172..6a3f32cf 100644 --- a/lib/wait_strategy/port_wait_strategy.ex +++ b/lib/wait_strategy/port_wait_strategy.ex @@ -73,7 +73,7 @@ defmodule Testcontainers.PortWaitStrategy do end end - defp current_time_millis(), do: System.monotonic_time(:millisecond) + defp current_time_millis, do: System.monotonic_time(:millisecond) defp reached_timeout?(timeout, start_time), do: current_time_millis() - start_time > timeout diff --git a/lib/wait_strategy/protocols/wait_strategy.ex b/lib/wait_strategy/protocols/wait_strategy.ex index ff48d280..c5d43e4a 100644 --- a/lib/wait_strategy/protocols/wait_strategy.ex +++ b/lib/wait_strategy/protocols/wait_strategy.ex @@ -7,7 +7,7 @@ defprotocol Testcontainers.WaitStrategy do """ alias Testcontainers.Container - @spec wait_until_container_is_ready(t(), %Container{}, Tesla.Env.client()) :: + @spec wait_until_container_is_ready(t(), Container.t(), Tesla.Env.client()) :: :ok | {:error, atom()} def wait_until_container_is_ready(wait_strategy, container, conn) end diff --git a/mix.exs b/mix.exs index 80aead77..ac221213 100644 --- a/mix.exs +++ b/mix.exs @@ -50,6 +50,7 @@ defmodule TestcontainersElixir.MixProject do defp deps do [ {:uniq, "~> 0.6"}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.3", only: [:dev], runtime: false}, {:ex_doc, "~> 0.30", only: :dev, runtime: false}, {:tesla, "~> 1.7"}, diff --git a/mix.lock b/mix.lock index 4bebf6ed..a4d74297 100644 --- a/mix.lock +++ b/mix.lock @@ -1,8 +1,10 @@ %{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "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"}, + "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, @@ -12,6 +14,7 @@ "ex_aws": {:hex, :ex_aws, "2.6.1", "194582c7b09455de8a5ab18a0182e6dd937d53df82be2e63c619d01bddaccdfa", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "67842a08c90a1d9a09dbe4ac05754175c7ca253abe4912987c759395d4bd9d26"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.9", "862b7792f2e60d7010e2920d79964e3fab289bc0fd951b0ba8457a3f7f9d1199", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "a480d2bb2da64610014021629800e1e9457ca5e4a62f6775bffd963360c2bf90"}, "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "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"}, diff --git a/test/compose/cli_test.exs b/test/compose/cli_test.exs index 7576daa5..460ead8e 100644 --- a/test/compose/cli_test.exs +++ b/test/compose/cli_test.exs @@ -202,12 +202,12 @@ defmodule Testcontainers.Compose.CliTest do %{ "URL" => "0.0.0.0", "TargetPort" => 6379, - "PublishedPort" => 32768, + "PublishedPort" => 32_768, "Protocol" => "tcp" } ] - assert Cli.parse_publishers(publishers) == [{6379, 32768}] + assert Cli.parse_publishers(publishers) == [{6379, 32_768}] end test "filters out publishers with PublishedPort of 0" do @@ -228,21 +228,21 @@ defmodule Testcontainers.Compose.CliTest do %{ "URL" => "0.0.0.0", "TargetPort" => 6379, - "PublishedPort" => 32768, + "PublishedPort" => 32_768, "Protocol" => "tcp" }, %{ "URL" => "0.0.0.0", "TargetPort" => 5432, - "PublishedPort" => 32769, + "PublishedPort" => 32_769, "Protocol" => "tcp" } ] result = Cli.parse_publishers(publishers) assert length(result) == 2 - assert {6379, 32768} in result - assert {5432, 32769} in result + assert {6379, 32_768} in result + assert {5432, 32_769} in result end test "deduplicates port tuples" do @@ -250,18 +250,18 @@ defmodule Testcontainers.Compose.CliTest do %{ "URL" => "0.0.0.0", "TargetPort" => 6379, - "PublishedPort" => 32768, + "PublishedPort" => 32_768, "Protocol" => "tcp" }, %{ "URL" => "::", "TargetPort" => 6379, - "PublishedPort" => 32768, + "PublishedPort" => 32_768, "Protocol" => "tcp" } ] - assert Cli.parse_publishers(publishers) == [{6379, 32768}] + assert Cli.parse_publishers(publishers) == [{6379, 32_768}] end test "handles nil publishers" do diff --git a/test/compose/compose_integration_test.exs b/test/compose/compose_integration_test.exs index 376d9ee4..40817239 100644 --- a/test/compose/compose_integration_test.exs +++ b/test/compose/compose_integration_test.exs @@ -3,8 +3,8 @@ defmodule Testcontainers.Compose.ComposeIntegrationTest do @moduletag :integration - alias Testcontainers.DockerCompose alias Testcontainers.Compose.ComposeEnvironment + alias Testcontainers.DockerCompose @fixtures_path Path.expand("../fixtures", __DIR__) diff --git a/test/connection/docker_host_strategy/docker_host_from_env_test.exs b/test/connection/docker_host_strategy/docker_host_from_env_test.exs index ae3b34ae..c9c378ee 100644 --- a/test/connection/docker_host_strategy/docker_host_from_env_test.exs +++ b/test/connection/docker_host_strategy/docker_host_from_env_test.exs @@ -1,8 +1,8 @@ defmodule Testcontainers.Connection.DockerHostStrategy.DockerHostFromEnvTest do use ExUnit.Case, async: true - alias Testcontainers.DockerHostStrategyEvaluator alias Testcontainers.DockerHostFromEnvStrategy + alias Testcontainers.DockerHostStrategyEvaluator describe "DockerHostFromEnvStrategy" do setup do diff --git a/test/connection/docker_host_strategy/docker_host_from_properties_test.exs b/test/connection/docker_host_strategy/docker_host_from_properties_test.exs index 1046bd34..1646116f 100644 --- a/test/connection/docker_host_strategy/docker_host_from_properties_test.exs +++ b/test/connection/docker_host_strategy/docker_host_from_properties_test.exs @@ -1,8 +1,8 @@ defmodule Testcontainers.Connection.DockerHostStrategy.DockerHostFromPropertiesTest do use ExUnit.Case, async: true - alias Testcontainers.DockerHostStrategyEvaluator alias Testcontainers.DockerHostFromPropertiesStrategy + alias Testcontainers.DockerHostStrategyEvaluator describe "DockerHostFromPropertiesStrategy" do test "should return :econnrefused response if property file exist but is not an open url" do diff --git a/test/constants_test.exs b/test/constants_test.exs index 024b9288..5c3a7f0c 100644 --- a/test/constants_test.exs +++ b/test/constants_test.exs @@ -3,7 +3,7 @@ defmodule Testcontainers.ConstantsTest do test "have correct values" do assert Testcontainers.Constants.container_label() == "org.testcontainers" - assert Testcontainers.Constants.container_sessionId_label() == "org.testcontainers.session-id" + assert Testcontainers.Constants.container_session_id_label() == "org.testcontainers.session-id" assert Testcontainers.Constants.container_reuse_hash_label() == "org.testcontainers.reuse-hash" diff --git a/test/container/container_builder_helper_test.exs b/test/container/container_builder_helper_test.exs index 22911b32..60114eec 100644 --- a/test/container/container_builder_helper_test.exs +++ b/test/container/container_builder_helper_test.exs @@ -12,7 +12,7 @@ defmodule Testcontainers.ContainerBuilderHelperTest do {:noreuse, built, nil} = ContainerBuilderHelper.build(builder, state) assert Map.get(built.labels, container_reuse()) == "false" assert Map.get(built.labels, container_reuse_hash_label()) == nil - assert Map.get(built.labels, container_sessionId_label()) == "123" + assert Map.get(built.labels, container_session_id_label()) == "123" assert Map.get(built.labels, container_version_label()) == library_version() assert Map.get(built.labels, container_lang_label()) == container_lang_value() assert Map.get(built.labels, container_label()) == "true" @@ -27,7 +27,7 @@ defmodule Testcontainers.ContainerBuilderHelperTest do assert hash != nil assert Map.get(built.labels, container_reuse()) == "true" assert Map.get(built.labels, container_reuse_hash_label()) != nil - assert Map.get(built.labels, container_sessionId_label()) == "123" + assert Map.get(built.labels, container_session_id_label()) == "123" assert Map.get(built.labels, container_version_label()) == library_version() assert Map.get(built.labels, container_lang_label()) == container_lang_value() assert Map.get(built.labels, container_label()) == "true" diff --git a/test/container/emqx_container_test.exs b/test/container/emqx_container_test.exs index a7a18e63..6a995820 100644 --- a/test/container/emqx_container_test.exs +++ b/test/container/emqx_container_test.exs @@ -27,7 +27,7 @@ defmodule Testcontainers.Container.EmqxContainerTest do :emqx, EmqxContainer.new() |> EmqxContainer.with_image("emqx:5.5.1") - |> EmqxContainer.with_ports(1884, 8883, 8083, 8084, 18084) + |> EmqxContainer.with_ports(1884, 8883, 8083, 8084, 18_084) ) @tag :dood_limitation diff --git a/test/container/kafka_container_test.exs b/test/container/kafka_container_test.exs index 3b66fc7b..71acfb39 100644 --- a/test/container/kafka_container_test.exs +++ b/test/container/kafka_container_test.exs @@ -9,7 +9,7 @@ defmodule Testcontainers.Container.KafkaContainerTest do config = KafkaContainer.new() assert config.image == "apache/kafka:3.9.0" - assert config.kafka_port >= 29000 and config.kafka_port <= 29999 + assert config.kafka_port >= 29_000 and config.kafka_port <= 29_999 assert config.internal_kafka_port == 9092 assert config.controller_port == 9093 assert config.node_id == 1 diff --git a/test/container/mongo_container_test.exs b/test/container/mongo_container_test.exs index 53720d2c..bf12570b 100644 --- a/test/container/mongo_container_test.exs +++ b/test/container/mongo_container_test.exs @@ -16,7 +16,7 @@ defmodule Testcontainers.Container.MongoContainerTest do assert config.user == "test" assert config.password == "test" assert config.database == "test" - assert config.port == 27017 + assert config.port == 27_017 assert config.wait_timeout == 180_000 end diff --git a/test/container/toxiproxy_container_test.exs b/test/container/toxiproxy_container_test.exs index 99bc268b..fdf02e19 100644 --- a/test/container/toxiproxy_container_test.exs +++ b/test/container/toxiproxy_container_test.exs @@ -234,8 +234,8 @@ defmodule Testcontainers.Container.ToxiproxyContainerTest do @tag :dood_limitation test "can proxy and inject faults into Redis traffic", %{network_name: network_name} do - alias Testcontainers.RedisContainer alias Testcontainers.ContainerBuilder + alias Testcontainers.RedisContainer # Start Redis on the network redis_config = diff --git a/test/container_test.exs b/test/container_test.exs index 31c8be58..80bc16ce 100644 --- a/test/container_test.exs +++ b/test/container_test.exs @@ -1,8 +1,10 @@ defmodule Testcontainers.ContainerTest do use ExUnit.Case, async: true - alias Testcontainers.ContainerBuilder alias Testcontainers.Container + alias Testcontainers.ContainerBuilder + alias Testcontainers.PostgresContainer + alias Testcontainers.Util.Hash describe "with reuse" do test "sets reuse to true" do @@ -17,13 +19,13 @@ defmodule Testcontainers.ContainerTest do describe "hash" do test "returns the same hash for the same container" do - container1 = ContainerBuilder.build(Testcontainers.PostgresContainer.new()) - container2 = ContainerBuilder.build(Testcontainers.PostgresContainer.new()) + container1 = ContainerBuilder.build(PostgresContainer.new()) + container2 = ContainerBuilder.build(PostgresContainer.new()) - assert Testcontainers.Util.Hash.struct_to_hash(container1) == + assert Hash.struct_to_hash(container1) == "a0b9a403e485c224323eabc27b2b8e94a6353c785cdc27f9ac2b8a9b67a47cb1" - assert Testcontainers.Util.Hash.struct_to_hash(container2) == + assert Hash.struct_to_hash(container2) == "a0b9a403e485c224323eabc27b2b8e94a6353c785cdc27f9ac2b8a9b67a47cb1" end end diff --git a/test/generic_container_test.exs b/test/generic_container_test.exs index b4df1190..834c39d3 100644 --- a/test/generic_container_test.exs +++ b/test/generic_container_test.exs @@ -17,14 +17,14 @@ defmodule Testcontainers.GenericContainerTest do @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") - else + if is_os(:linux) do config = %Testcontainers.Container{image: "redis:latest", network_mode: "host"} assert {:ok, container} = Testcontainers.start_container(config) Process.sleep(5000) assert :ok = port_open?("127.0.0.1", 6379) assert :ok = Testcontainers.stop_container(container.container_id) + else + Logger.warning("Host is not Linux, therefore not running network_mode test") end end end diff --git a/test/pull_policy_test.exs b/test/pull_policy_test.exs index 0816b3e0..e466c9b6 100644 --- a/test/pull_policy_test.exs +++ b/test/pull_policy_test.exs @@ -1,12 +1,17 @@ defmodule Testcontainers.PullPolicyTest do use ExUnit.Case, async: true + alias Testcontainers.Connection + alias Testcontainers.Container + alias Testcontainers.Docker.Api + alias Testcontainers.PullPolicy + @moduletag :needs_registry test "always_pull/0 fetches image from remote repository" do - config = %Testcontainers.Container{ + config = %Container{ image: "alpine:latest", - pull_policy: Testcontainers.PullPolicy.always_pull() + pull_policy: PullPolicy.always_pull() } assert {:ok, container} = Testcontainers.start_container(config) @@ -14,13 +19,13 @@ defmodule Testcontainers.PullPolicyTest do end test "never_pull/0 does not fetch image from remote repository" do - {conn, _url, _host} = Testcontainers.Connection.get_connection() - {:ok, _nil} = Testcontainers.Docker.Api.pull_image("alpine:latest", conn) - {:ok, _name} = Testcontainers.Docker.Api.tag_image("alpine", "local_alpine", "latest", conn) + {conn, _url, _host} = Connection.get_connection() + {:ok, _nil} = Api.pull_image("alpine:latest", conn) + {:ok, _name} = Api.tag_image("alpine", "local_alpine", "latest", conn) - config = %Testcontainers.Container{ + config = %Container{ image: "local_alpine:latest", - pull_policy: Testcontainers.PullPolicy.never_pull() + pull_policy: PullPolicy.never_pull() } assert {:ok, container} = Testcontainers.start_container(config) @@ -28,10 +33,10 @@ defmodule Testcontainers.PullPolicyTest do end test "pull_condition/1 fetches image if expression evaluates to true" do - config = %Testcontainers.Container{ + config = %Container{ image: "alpine:latest", pull_policy: - Testcontainers.PullPolicy.pull_condition(fn _config, _conn -> + PullPolicy.pull_condition(fn _config, _conn -> true end) } @@ -41,14 +46,14 @@ defmodule Testcontainers.PullPolicyTest do end test "pull_condition/1 does not fetch image if expression evaluates to a falsey value" do - {conn, _url, _host} = Testcontainers.Connection.get_connection() - {:ok, _nil} = Testcontainers.Docker.Api.pull_image("alpine:latest", conn) - {:ok, _name} = Testcontainers.Docker.Api.tag_image("alpine", "local_alpine2", "latest", conn) + {conn, _url, _host} = Connection.get_connection() + {:ok, _nil} = Api.pull_image("alpine:latest", conn) + {:ok, _name} = Api.tag_image("alpine", "local_alpine2", "latest", conn) - config = %Testcontainers.Container{ + config = %Container{ image: "local_alpine2:latest", pull_policy: - Testcontainers.PullPolicy.pull_condition(fn _config, _conn -> + PullPolicy.pull_condition(fn _config, _conn -> false end) } @@ -56,4 +61,31 @@ defmodule Testcontainers.PullPolicyTest do assert {:ok, container} = Testcontainers.start_container(config) assert :ok = Testcontainers.stop_container(container.container_id) end + + test "pull_if_missing/0 starts a container when image already exists locally" do + {conn, _url, _host} = Connection.get_connection() + {:ok, _nil} = Api.pull_image("alpine:latest", conn) + {:ok, _name} = Api.tag_image("alpine", "local_alpine3", "latest", conn) + + config = %Container{ + image: "local_alpine3:latest", + pull_policy: PullPolicy.pull_if_missing() + } + + assert {:ok, container} = Testcontainers.start_container(config) + assert :ok = Testcontainers.stop_container(container.container_id) + end + + test "pull_if_missing/0 fetches image when not present locally" do + {conn, _url, _host} = Connection.get_connection() + _ = Api.delete_image("alpine:3.19", conn) + + config = %Container{ + image: "alpine:3.19", + pull_policy: PullPolicy.pull_if_missing() + } + + assert {:ok, container} = Testcontainers.start_container(config) + assert :ok = Testcontainers.stop_container(container.container_id) + end end diff --git a/test/support/nginx_container.ex b/test/support/nginx_container.ex index d2df0f7a..1676a674 100644 --- a/test/support/nginx_container.ex +++ b/test/support/nginx_container.ex @@ -1,4 +1,5 @@ defmodule Test.NginxContainer do + @moduledoc false defstruct [] defimpl Testcontainers.ContainerBuilder do diff --git a/test/support/test_helper.ex b/test/support/test_helper.ex index 3176c160..a0b61433 100644 --- a/test/support/test_helper.ex +++ b/test/support/test_helper.ex @@ -1,4 +1,5 @@ defmodule TestHelper do + @moduledoc false @doc """ Waits for the specified GenServer to change its running state (either start or stop). diff --git a/test/testcontainers_test.exs b/test/testcontainers_test.exs index 5f0f676d..c140c997 100644 --- a/test/testcontainers_test.exs +++ b/test/testcontainers_test.exs @@ -1,7 +1,7 @@ defmodule TestcontainersTest do alias Testcontainers.Connection - alias Testcontainers.Docker alias Testcontainers.Container + alias Testcontainers.Docker use ExUnit.Case, async: true test "cleans up containers on terminate" do diff --git a/test/wait_strategy/http_wait_strategy_test.exs b/test/wait_strategy/http_wait_strategy_test.exs index c0cc0035..bd0b448a 100644 --- a/test/wait_strategy/http_wait_strategy_test.exs +++ b/test/wait_strategy/http_wait_strategy_test.exs @@ -1,6 +1,6 @@ defmodule Testcontainers.HttpWaitStrategyTest do - alias Testcontainers.HttpWaitStrategy alias Testcontainers.Container + alias Testcontainers.HttpWaitStrategy use ExUnit.Case, async: true test "can wait for a http request and retrieve content" do