Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RabbitMQ container module #85

Merged
merged 4 commits into from
May 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
jarlah marked this conversation as resolved.
Show resolved Hide resolved
]
@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
jarlah marked this conversation as resolved.
Show resolved Hide resolved
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
Loading