diff --git a/lib/container/rabbitmq_container.ex b/lib/container/rabbitmq_container.ex new file mode 100644 index 0000000..575b536 --- /dev/null +++ b/lib/container/rabbitmq_container.ex @@ -0,0 +1,270 @@ +defmodule Testcontainers.RabbitMQContainer do + @moduledoc """ + Provides functionality for creating and managing RabbitMQ container configurations. + + NOTE: The default starting command is `chmod 400 /var/lib/rabbitmq/.erlang.cookie; rabbitmq-server`. + `chmod 400 /var/lib/rabbitmq/.erlang.cookie` is necessary for the waiting strategy, which calls the command `rabbitmq-diagnostics check_running`; otherwise CLI tools cannot communicate with the RabbitMQ node. + """ + alias Testcontainers.ContainerBuilder + alias Testcontainers.Container + alias Testcontainers.CommandWaitStrategy + alias Testcontainers.RabbitMQContainer + + @default_image "rabbitmq" + @default_tag "3-alpine" + @default_image_with_tag "#{@default_image}:#{@default_tag}" + @default_port 5672 + @default_username "guest" + @default_password "guest" + @default_virtual_host "/" + @default_command [ + "sh", + "-c", + "chmod 400 /var/lib/rabbitmq/.erlang.cookie; rabbitmq-server" + ] + @default_wait_timeout 60_000 + + @enforce_keys [:image, :port, :wait_timeout] + defstruct [:image, :port, :username, :password, :virtual_host, :cmd, :wait_timeout] + + @doc """ + Creates a new `RabbitMQContainer` struct with default configurations. + """ + def new, + do: %__MODULE__{ + image: @default_image_with_tag, + port: @default_port, + username: @default_username, + password: @default_password, + virtual_host: @default_virtual_host, + cmd: @default_command, + wait_timeout: @default_wait_timeout + } + + @doc """ + Overrides the default image use for the RabbitMQ container. + + ## Examples + + iex> config = RabbitMQContainer.new() |> RabbitMQContainer.with_image("rabbitmq:xyz") + iex> config.image + "rabbitmq:xyz" + """ + def with_image(%__MODULE__{} = config, image) do + %{config | image: image} + end + + @doc """ + Overrides the default port used for the RabbitMQ container. + + ## Examples + + iex> config = RabbitMQContainer.new() |> RabbitMQContainer.with_port(1111) + iex> config.port + 1111 + """ + def with_port(%__MODULE__{} = config, port) when is_integer(port) do + %{config | port: port} + end + + @doc """ + Overrides the default wait timeout used for the RabbitMQ container. + + Note: this timeout will be used for each individual wait strategy. + + ## Examples + + iex> config = RabbitMQContainer.new() |> RabbitMQContainer.with_wait_timeout(60000) + iex> config.wait_timeout + 60000 + """ + def with_wait_timeout(%__MODULE__{} = config, wait_timeout) when is_integer(wait_timeout) do + %{config | wait_timeout: wait_timeout} + end + + @doc """ + Overrides the default user used for the RabbitMQ container. + + ## Examples + + iex> config = RabbitMQContainer.new() |> RabbitMQContainer.with_username("rabbitmq") + iex> config.username + "rabbitmq" + """ + def with_username(%__MODULE__{} = config, username) when is_binary(username) do + %{config | username: username} + end + + @doc """ + Overrides the default password used for the RabbitMQ container. + + ## Examples + + iex> config = RabbitMQContainer.new() |> RabbitMQContainer.with_password("rabbitmq") + iex> config.password + "rabbitmq" + """ + def with_password(%__MODULE__{} = config, password) when is_binary(password) do + %{config | password: password} + end + + @doc """ + Overrides the default virtual host used for the RabbitMQ container. + + ## Examples + + iex> config = RabbitMQContainer.new() |> RabbitMQContainer.with_virtual_host("/") + iex> config.password + "/" + """ + def with_virtual_host(%__MODULE__{} = config, virtual_host) when is_binary(virtual_host) do + %{config | virtual_host: virtual_host} + end + + @doc """ + Overrides the default command used for the RabbitMQ container. + + ## Examples + + iex> config = RabbitMQContainer.new() |> RabbitMQContainer.with_cmd(["sh", "-c", "rabbitmq-server"]) + iex> config.cmd + ["sh", "-c", "rabbitmq-server"] + """ + def with_cmd(%__MODULE__{} = config, cmd) when is_list(cmd) do + %{config | cmd: cmd} + end + + @doc """ + Retrieves the default Docker image for the RabbitMQ container + """ + def default_image, do: @default_image + + @doc """ + Retrieves the default exposed port for the RabbitMQ container + """ + def default_port, do: @default_port + + @doc """ + Retrieves the default Docker image including tag for the RabbitMQ container + """ + def default_image_with_tag, do: @default_image <> ":" <> @default_tag + + @doc """ + Returns the port on the _host machine_ where the RabbitMQ container is listening. + """ + def port(%Container{} = container), + do: + Container.mapped_port( + container, + String.to_integer(container.environment[:RABBITMQ_NODE_PORT]) + ) + + @doc """ + Generates the connection URL for accessing the RabbitMQ service running within the container. + + This URI is based on the AMQP 0-9-1, and has the following scheme: + amqp://username:password@host:port/vhost + + ## Parameters + + - `container`: The active RabbitMQ container instance in the form of a %Container{} struct. + + ## Examples + + iex> RabbitMQContainer.connection_url(container) + "amqp://guest:guest@localhost:32768" + iex> RabbitMQContainer.connection_url(container_with_vhost) + "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)}" + end + + @doc """ + Returns the connection parameters to connect to RabbitMQ from the _host machine_. + + ## Parameters + + - `container`: The active RabbitMQ container instance in the form of a %Container{} struct. + + ## Examples + + iex> RabbitMQContainer.connection_parameters(container) + [ + host: "localhost", + port: 32768, + username: "guest", + password: "guest", + vhost: "/" + ] + """ + def connection_parameters(%Container{} = container) do + [ + host: Testcontainers.get_host(), + port: port(container), + username: container.environment[:RABBITMQ_DEFAULT_USER], + password: container.environment[:RABBITMQ_DEFAULT_PASS], + virtual_host: container.environment[:RABBITMQ_DEFAULT_VHOST] + ] + end + + @doc """ + 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 + "/" -> "" + vhost -> "/" <> vhost + end + end + + defimpl ContainerBuilder do + import Container + + @doc """ + Implementation of the `ContainerBuilder` protocol specific to `RabbitMQContainer`. + + This function builds a new container configuration, ensuring the RabbitMQ image is compatible, setting environment variables, and applying a waiting strategy for the container to be ready. + + The build process raises an `ArgumentError` if the specified container image is not compatible with the expected RabbitMQ image. + + ## Examples + + # Assuming `ContainerBuilder.build/2` is called from somewhere in the application with a `RabbitMQContainer` configuration: + iex> config = RabbitMQContainer.new() + iex> built_container = ContainerBuilder.build(config, []) + # `built_container` is now a ready-to-use `%Container{}` configured specifically for RabbitMQ. + + ## Errors + + - Raises `ArgumentError` if the provided image is not compatible with the default RabbitMQ image. + """ + @impl true + @spec build(%RabbitMQContainer{}) :: %Container{} + def build(%RabbitMQContainer{} = config) do + if not String.starts_with?(config.image, RabbitMQContainer.default_image()) do + raise ArgumentError, + message: + "Image #{config.image} is not compatible with #{RabbitMQContainer.default_image()}" + end + + new(config.image) + |> with_exposed_port(config.port) + |> with_environment(:RABBITMQ_DEFAULT_USER, config.username) + |> with_environment(:RABBITMQ_DEFAULT_PASS, config.password) + |> with_environment(:RABBITMQ_DEFAULT_VHOST, config.virtual_host) + |> with_environment(:RABBITMQ_NODE_PORT, to_string(config.port)) + |> with_cmd(config.cmd) + |> with_waiting_strategy( + CommandWaitStrategy.new( + ["rabbitmq-diagnostics", "check_running"], + config.wait_timeout + ) + ) + end + + @impl true + @spec is_starting(%RabbitMQContainer{}, %Container{}, %Tesla.Env{}) :: :ok + def is_starting(_config, _container, _conn), do: :ok + end +end diff --git a/mix.exs b/mix.exs index 32b62ab..71b4068 100644 --- a/mix.exs +++ b/mix.exs @@ -82,7 +82,9 @@ defmodule TestcontainersElixir.MixProject do # kafka {:kafka_ex, "~> 0.13", only: [:dev, :test]}, # Zookeeper - {:erlzk, "~> 0.6.2", only: [:dev, :test]} + {:erlzk, "~> 0.6.2", only: [:dev, :test]}, + # RabbitMQ + {:amqp, "~> 3.3", only: [:dev, :test]} ] end diff --git a/mix.lock b/mix.lock index 36d58b2..727fede 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,10 @@ %{ + "amqp": {:hex, :amqp, "3.3.0", "056d9f4bac96c3ab5a904b321e70e78b91ba594766a1fc2f32afd9c016d9f43b", [:mix], [{:amqp_client, "~> 3.9", [hex: :amqp_client, repo: "hexpm", optional: false]}], "hexpm", "8d3ae139d2646c630d674a1b8d68c7f85134f9e8b2a1c3dd5621616994b10a8b"}, + "amqp_client": {:hex, :amqp_client, "3.12.13", "6fc6a7c681e53fed4cbd3f5bcdda342a2b46976345e460ef85414c63698cfe70", [:make, :rebar3], [{:credentials_obfuscation, "3.4.0", [hex: :credentials_obfuscation, repo: "hexpm", optional: false]}, {:rabbit_common, "3.12.13", [hex: :rabbit_common, repo: "hexpm", optional: false]}], "hexpm", "76f41bff0792193f00e0062128db51eb68bcee0eb8236139247a7d1866438d03"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, "crc32cer": {:hex, :crc32cer, "0.1.10", "fb87abbf34b72f180f8c3a908cd1826c6cb9a59787d156a29e05de9e98be385e", [:rebar3], [], "hexpm", "5b1f47efd0a1b4b7411f1f35e14d3c8c6da6e6a2a725ec8f2cf1ab13703e5f38"}, + "credentials_obfuscation": {:hex, :credentials_obfuscation, "3.4.0", "34e18b126b3aefd6e8143776fbe1ceceea6792307c99ac5ee8687911f048cfd7", [:rebar3], [], "hexpm", "738ace0ed5545d2710d3f7383906fc6f6b582d019036e5269c4dbd85dbced566"}, "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, "decimal": {:hex, :decimal, "1.9.0", "83e8daf59631d632b171faabafb4a9f4242c514b0a06ba3df493951c08f64d07", [:mix], [], "hexpm", "b1f2343568eed6928f3e751cf2dffde95bfaa19dd95d09e8a9ea92ccfd6f7d85"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, @@ -30,11 +33,14 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "postgrex": {:hex, :postgrex, "0.17.5", "0483d054938a8dc069b21bdd636bf56c487404c241ce6c319c1f43588246b281", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "50b8b11afbb2c4095a3ba675b4f055c416d0f3d7de6633a595fc131a828a67eb"}, + "rabbit_common": {:hex, :rabbit_common, "3.12.13", "a163432b377411d6033344d5f6a8b12443d67c897c9374b9738cc609cab3161c", [:make, :rebar3], [{:credentials_obfuscation, "3.4.0", [hex: :credentials_obfuscation, repo: "hexpm", optional: false]}, {:recon, "2.5.3", [hex: :recon, repo: "hexpm", optional: false]}, {:thoas, "1.0.0", [hex: :thoas, repo: "hexpm", optional: false]}], "hexpm", "26a400f76976e66efd9cdab29a36dd4b129466d431c4e014aae9d2e36fefef44"}, + "recon": {:hex, :recon, "2.5.3", "739107b9050ea683c30e96de050bc59248fd27ec147696f79a8797ff9fa17153", [:mix, :rebar3], [], "hexpm", "6c6683f46fd4a1dfd98404b9f78dcabc7fcd8826613a89dcb984727a8c3099d7"}, "redix": {:hex, :redix, "1.2.1", "edf7392c0fa08708f5869e301aad20445f72ebc1949ea1c2496eaf344c845a0d", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "62608edd20a47b458a30737fd734e6f73d1f1665f3ca7821c1ee8f9abc725f11"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "tesla": {:hex, :tesla, "1.8.0", "d511a4f5c5e42538d97eef7c40ec4f3e44effdc5068206f42ed859e09e51d1fd", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "10501f360cd926a309501287470372af1a6e1cbed0f43949203a4c13300bc79f"}, + "thoas": {:hex, :thoas, "1.0.0", "567c03902920827a18a89f05b79a37b5bf93553154b883e0131801600cf02ce0", [:rebar3], [], "hexpm", "fc763185b932ecb32a554fb735ee03c3b6b1b31366077a2427d2a97f3bd26735"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, "varint": {:hex, :varint, "1.2.0", "61bffd9dcc2d5242d59f75694506b4d4013bb103f6a23e34b94f89cebb0c1ab3", [:mix], [], "hexpm", "d94941ed8b9d1a5fdede9103a5e52035bd0aaf35081d44e67713a36799927e47"}, diff --git a/test/container/rabbitmq_container_test.exs b/test/container/rabbitmq_container_test.exs new file mode 100644 index 0000000..7df53a4 --- /dev/null +++ b/test/container/rabbitmq_container_test.exs @@ -0,0 +1,64 @@ +defmodule Testcontainers.Container.RabbitMQContainerTest do + use ExUnit.Case, async: true + import Testcontainers.ExUnit + + alias Testcontainers.RabbitMQContainer + + @moduletag timeout: 300_000 + + describe "with default configuration" do + container(:rabbitmq, RabbitMQContainer.new()) + + test "provides a ready-to-use rabbitmq container by using connection parameters", %{ + rabbitmq: rabbitmq + } do + {:ok, connection} = + RabbitMQContainer.connection_parameters(rabbitmq) + |> AMQP.Connection.open() + + do_assertion(connection) + end + + test "provides a ready-to-use rabbitmq container by using connection URL", %{ + rabbitmq: rabbitmq + } do + {:ok, connection} = + RabbitMQContainer.connection_url(rabbitmq) + |> AMQP.Connection.open() + + do_assertion(connection) + end + end + + describe "with custom configuration" do + @custom_rabbitmq RabbitMQContainer.new() + |> RabbitMQContainer.with_image("rabbitmq:3-management-alpine") + |> RabbitMQContainer.with_port(5671) + |> RabbitMQContainer.with_username("custom-user") + |> RabbitMQContainer.with_password("custom_password") + |> RabbitMQContainer.with_virtual_host("custom-virtual-host") + + container(:rabbitmq, @custom_rabbitmq) + + test "provides a rabbitmq container compliant with specified configuration", %{ + rabbitmq: rabbitmq + } do + {:ok, connection} = + RabbitMQContainer.connection_parameters(rabbitmq) + |> AMQP.Connection.open() + + do_assertion(connection) + end + end + + defp do_assertion(connection) do + {:ok, channel} = AMQP.Channel.open(connection) + AMQP.Queue.declare(channel, "channel") + AMQP.Basic.publish(channel, "", "channel", "Hello") + AMQP.Basic.consume(channel, "channel", nil, no_ack: true) + + assert_receive {:basic_consume_ok, %{consumer_tag: _consumer_tag}} + assert_receive {:basic_deliver, "Hello", _meta} + AMQP.Connection.close(connection) + end +end