Skip to content

Commit

Permalink
Add RabbitMQ container module (#85)
Browse files Browse the repository at this point in the history
* Add RabbitMQ container module

* Add note to module doc about starting command.

* Update docs for amqp URI specification regarding scheme and virtual host

* Allow overriding of default port in RabbitMQ node in container
  • Loading branch information
zteln committed May 9, 2024
1 parent ca21d14 commit f6e1bd0
Show file tree
Hide file tree
Showing 4 changed files with 343 additions and 1 deletion.
270 changes: 270 additions & 0 deletions lib/container/rabbitmq_container.ex
Original file line number Diff line number Diff line change
@@ -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 """

Check warning on line 211 in lib/container/rabbitmq_container.ex

View workflow job for this annotation

GitHub Actions / Test example projects

defp virtual_host_segment/1 is private, @doc attribute is always discarded for private functions/macros/types

Check warning on line 211 in lib/container/rabbitmq_container.ex

View workflow job for this annotation

GitHub Actions / Test example projects

defp virtual_host_segment/1 is private, @doc attribute is always discarded for private functions/macros/types
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
4 changes: 3 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
Expand Down Expand Up @@ -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"},
Expand Down
64 changes: 64 additions & 0 deletions test/container/rabbitmq_container_test.exs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit f6e1bd0

Please sign in to comment.