Skip to content

Commit

Permalink
Add option to override image validation (#108)
Browse files Browse the repository at this point in the history
* feat: move image validation to container configuration

Add methods to control when to perform image validation and define the
validation behavior on `Testcontainers.Container` module.

Also, adapt all containers to use these methods.

* feat: control image validation publicly

* fix: undo change in testcontainers

* test: ensure validation works properly

* chore: toying with alternatives

This is just a simple translation of the current validation. I will work
out a way to reduce the code duplication.

* chore: use regex to match image

With this update, it's easier to match parts of the image repository and
assume any registry or tag.

* chore: let the check_image regex fail

With this, we let the end user know that the string can't be converted
to a valid regular expression.

* chore: simplify regex compilation

* chore: reduce code duplication

Create `guard` clause to validate `check_image`.

* feat: example on how to use custom image

* feat: add `check_image` on `Testcontainers.Ecto`
  • Loading branch information
joaodubas authored Jul 6, 2024
1 parent d3adfae commit 04e6e2c
Show file tree
Hide file tree
Showing 12 changed files with 293 additions and 63 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ config = Testcontainers.RedisContainer.new()
{:ok, container} = Testcontainers.start_container(config)
```

If you want to use a predefined container, such as `RedisContainer`, with an alternative image, for example, `valkey/valkey`, it's possible:

```elixir
{:ok, _} = Testcontainers.start_link()
config =
Testcontainers.RedisContainer.new()
|> Testcontainers.RedisContainer.with_image("valkey/valkey:latest")
|> Testcontainers.RedisContainer.with_check_image("valkey/valkey")
{:ok, container} = Testcontainers.start_container(config)
```

### ExUnit tests

Given you have added Testcontainers.start_link() to test_helper.exs:
Expand Down
53 changes: 52 additions & 1 deletion lib/container.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,17 @@ defmodule Testcontainers.Container do
labels: %{},
auto_remove: false,
container_id: nil,
check_image: ~r/.*/,
network_mode: nil
]

@doc """
Returns `true` if `term` is a valid `check_image`, otherwise returns `false`.
"""
@doc guard: true
defguard is_valid_image(check_image)
when is_binary(check_image) or is_struct(check_image, Regex)

@doc """
A constructor function to make it easier to construct a container
"""
Expand Down Expand Up @@ -152,6 +160,20 @@ defmodule Testcontainers.Container do
%__MODULE__{config | auth: registry_auth_token}
end

@doc """
Set the regular expression to check the image validity.
When using a string, it will compile it to a regular expression. If the compilation fails, it will raise a `Regex.CompileError`.
"""
def with_check_image(%__MODULE__{} = config, check_image) when is_binary(check_image) do
regex = Regex.compile!(check_image)
with_check_image(config, regex)
end

def with_check_image(%__MODULE__{} = config, %Regex{} = check_image) do
%__MODULE__{config | check_image: check_image}
end

@doc """
Sets a network mode to apply to the container object in docker.
"""
Expand All @@ -173,10 +195,39 @@ defmodule Testcontainers.Container do
|> List.last()
end

@doc """
Check if the provided image is compatible with the expected default image.
Raises:
ArgumentError when image isn't compatible.
"""
def valid_image!(%__MODULE__{} = config) do
case valid_image(config) do
{:ok, config} ->
config

{:error, message} ->
raise ArgumentError, message: message
end
end

@doc """
Check if the provided image is compatible with the expected default image.
"""
def valid_image(%__MODULE__{image: image, check_image: check_image} = config) do
if Regex.match?(check_image, image) do
{:ok, config}
else
{:error,
"Unexpected image #{image}. If this is a valid image, provide a broader `check_image` regex to the container configuration."}
end
end

defimpl Testcontainers.ContainerBuilder do
@impl true
def build(%Testcontainers.Container{} = config) do
config
Testcontainers.Container.valid_image!(config)
end

@doc """
Expand Down
19 changes: 12 additions & 7 deletions lib/container/cassandra_container.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ defmodule Testcontainers.CassandraContainer do
alias Testcontainers.ContainerBuilder
alias Testcontainers.Container

import Testcontainers.Container, only: [is_valid_image: 1]

@default_image "cassandra"
@default_tag "3.11.2"
@default_image_with_tag "#{@default_image}:#{@default_tag}"
Expand All @@ -18,7 +20,7 @@ defmodule Testcontainers.CassandraContainer do
@default_wait_timeout 60_000

@enforce_keys [:image, :wait_timeout]
defstruct [:image, :wait_timeout]
defstruct [:image, :wait_timeout, check_image: @default_image]

def new,
do: %__MODULE__{
Expand All @@ -30,6 +32,13 @@ defmodule Testcontainers.CassandraContainer do
%{config | image: image}
end

@doc """
Set the regular expression to check the image validity.
"""
def with_check_image(%__MODULE__{} = config, check_image) when is_valid_image(check_image) do
%__MODULE__{config | check_image: check_image}
end

def default_image, do: @default_image

def default_port, do: @default_port
Expand All @@ -56,12 +65,6 @@ defmodule Testcontainers.CassandraContainer do
@impl true
@spec build(%CassandraContainer{}) :: %Container{}
def build(%CassandraContainer{} = config) do
if not String.starts_with?(config.image, CassandraContainer.default_image()) do
raise ArgumentError,
message:
"Image #{config.image} is not compatible with #{CassandraContainer.default_image()}"
end

new(config.image)
|> with_exposed_port(CassandraContainer.default_port())
|> with_environment(:CASSANDRA_SNITCH, "GossipingPropertyFileSnitch")
Expand All @@ -79,6 +82,8 @@ defmodule Testcontainers.CassandraContainer do
config.wait_timeout
)
)
|> with_check_image(config.check_image)
|> valid_image!()
end

@impl true
Expand Down
26 changes: 20 additions & 6 deletions lib/container/ceph_container.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ defmodule Testcontainers.CephContainer do
alias Testcontainers.ContainerBuilder
alias Testcontainers.Container

import Testcontainers.Container, only: [is_valid_image: 1]

@default_image "quay.io/ceph/demo"
@default_tag "latest-quincy"
@default_image_with_tag "#{@default_image}:#{@default_tag}"
Expand All @@ -19,7 +21,15 @@ defmodule Testcontainers.CephContainer do
@default_wait_timeout 300_000

@enforce_keys [:image, :access_key, :secret_key, :bucket, :port, :wait_timeout]
defstruct [:image, :access_key, :secret_key, :bucket, :port, :wait_timeout]
defstruct [
:image,
:access_key,
:secret_key,
:bucket,
:port,
:wait_timeout,
check_image: @default_image
]

@doc """
Creates a new `CephContainer` struct with default attributes.
Expand Down Expand Up @@ -128,6 +138,13 @@ defmodule Testcontainers.CephContainer do
%{config | wait_timeout: wait_timeout}
end

@doc """
Set the regular expression to check the image validity.
"""
def with_check_image(%__MODULE__{} = config, check_image) when is_valid_image(check_image) do
%__MODULE__{config | check_image: check_image}
end

@doc """
Retrieves the default Docker image used for the Ceph container.
Expand Down Expand Up @@ -208,11 +225,6 @@ defmodule Testcontainers.CephContainer do
@spec build(%CephContainer{}) :: %Container{}
@impl true
def build(%CephContainer{} = config) do
if not String.starts_with?(config.image, CephContainer.default_image()) do
raise ArgumentError,
message: "Image #{config.image} is not compatible with #{CephContainer.default_image()}"
end

new(config.image)
|> with_exposed_port(config.port)
|> with_environment(:CEPH_DEMO_UID, "demo")
Expand All @@ -229,6 +241,8 @@ defmodule Testcontainers.CephContainer do
5000
)
)
|> with_check_image(config.check_image)
|> valid_image!()
end

@impl true
Expand Down
19 changes: 13 additions & 6 deletions lib/container/emqx_container.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ defmodule Testcontainers.EmqxContainer do
alias Testcontainers.PortWaitStrategy
alias Testcontainers.EmqxContainer

import Testcontainers.Container, only: [is_valid_image: 1]

@default_image "emqx"
@default_tag "5.6.0"
@default_image_with_tag "#{@default_image}:#{@default_tag}"
Expand All @@ -26,7 +28,8 @@ defmodule Testcontainers.EmqxContainer do
:mqtt_over_ws_port,
:mqtt_over_wss_port,
:dashboard_port,
:wait_timeout
:wait_timeout,
check_image: @default_image
]

@doc """
Expand Down Expand Up @@ -76,6 +79,13 @@ defmodule Testcontainers.EmqxContainer do
}
end

@doc """
Set the regular expression to check the image validity.
"""
def with_check_image(%__MODULE__{} = config, check_image) when is_valid_image(check_image) do
%__MODULE__{config | check_image: check_image}
end

@doc """
Retrieves the default Docker image for the Emqx container.
"""
Expand All @@ -100,14 +110,11 @@ defmodule Testcontainers.EmqxContainer do
"""
@impl true
def build(%EmqxContainer{} = config) do
if not String.starts_with?(config.image, EmqxContainer.default_image()) do
raise ArgumentError,
message: "Image #{config.image} is not compatible with #{EmqxContainer.default_image()}"
end

new(config.image)
|> with_exposed_ports(exposed_ports(config))
|> with_waiting_strategies(waiting_strategies(config))
|> with_check_image(config.check_image)
|> valid_image!()
end

defp exposed_ports(config),
Expand Down
28 changes: 21 additions & 7 deletions lib/container/mysql_container.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ defmodule Testcontainers.MySqlContainer do
alias Testcontainers.MySqlContainer
alias Testcontainers.LogWaitStrategy

import Testcontainers.Container, only: [is_valid_image: 1]

@default_image "mysql"
@default_tag "8"
@default_image_with_tag "#{@default_image}:#{@default_tag}"
Expand All @@ -22,7 +24,16 @@ defmodule Testcontainers.MySqlContainer do
@default_wait_timeout 180_000

@enforce_keys [:image, :user, :password, :database, :port, :wait_timeout, :persistent_volume]
defstruct [:image, :user, :password, :database, :port, :wait_timeout, :persistent_volume]
defstruct [
:image,
:user,
:password,
:database,
:port,
:wait_timeout,
:persistent_volume,
check_image: @default_image
]

@doc """
Creates a new `MySqlContainer` struct with default configurations.
Expand Down Expand Up @@ -131,6 +142,13 @@ defmodule Testcontainers.MySqlContainer do
%{config | wait_timeout: wait_timeout}
end

@doc """
Set the regular expression to check the image validity.
"""
def with_check_image(%__MODULE__{} = config, check_image) when is_valid_image(check_image) do
%__MODULE__{config | check_image: check_image}
end

@doc """
Retrieves the default exposed port for the MySQL container.
"""
Expand Down Expand Up @@ -188,12 +206,6 @@ defmodule Testcontainers.MySqlContainer do
@spec build(%MySqlContainer{}) :: %Container{}
@impl true
def build(%MySqlContainer{} = config) do
if not String.starts_with?(config.image, MySqlContainer.default_image()) do
raise ArgumentError,
message:
"Image #{config.image} is not compatible with #{MySqlContainer.default_image()}"
end

new(config.image)
|> then(MySqlContainer.container_port_fun(config.port))
|> with_environment(:MYSQL_USER, config.user)
Expand All @@ -204,6 +216,8 @@ defmodule Testcontainers.MySqlContainer do
|> with_waiting_strategy(
LogWaitStrategy.new(~r/.*port: 3306 MySQL Community Server.*/, config.wait_timeout)
)
|> with_check_image(config.check_image)
|> valid_image!()
end

@impl true
Expand Down
28 changes: 21 additions & 7 deletions lib/container/postgres_container.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ defmodule Testcontainers.PostgresContainer do
alias Testcontainers.Container
alias Testcontainers.ContainerBuilder

import Testcontainers.Container, only: [is_valid_image: 1]

@default_image "postgres"
@default_tag "15-alpine"
@default_image_with_tag "#{@default_image}:#{@default_tag}"
Expand All @@ -22,7 +24,16 @@ defmodule Testcontainers.PostgresContainer do
@default_wait_timeout 60_000

@enforce_keys [:image, :user, :password, :database, :port, :wait_timeout, :persistent_volume]
defstruct [:image, :user, :password, :database, :port, :wait_timeout, :persistent_volume]
defstruct [
:image,
:user,
:password,
:database,
:port,
:wait_timeout,
:persistent_volume,
check_image: @default_image
]

@doc """
Creates a new `PostgresContainer` struct with default configurations.
Expand Down Expand Up @@ -131,6 +142,13 @@ defmodule Testcontainers.PostgresContainer do
%{config | wait_timeout: wait_timeout}
end

@doc """
Set the regular expression to check the image validity.
"""
def with_check_image(%__MODULE__{} = config, check_image) when is_valid_image(check_image) do
%__MODULE__{config | check_image: check_image}
end

@doc """
Retrieves the default exposed port for the Postgres container.
"""
Expand Down Expand Up @@ -188,12 +206,6 @@ defmodule Testcontainers.PostgresContainer do
@spec build(%PostgresContainer{}) :: %Container{}
@impl true
def build(%PostgresContainer{} = config) do
if not String.starts_with?(config.image, PostgresContainer.default_image()) do
raise ArgumentError,
message:
"Image #{config.image} is not compatible with #{PostgresContainer.default_image()}"
end

new(config.image)
|> then(PostgresContainer.container_port_fun(config.port))
|> with_environment(:POSTGRES_USER, config.user)
Expand All @@ -210,6 +222,8 @@ defmodule Testcontainers.PostgresContainer do
config.wait_timeout
)
)
|> with_check_image(config.check_image)
|> valid_image!()
end

@impl true
Expand Down
Loading

0 comments on commit 04e6e2c

Please sign in to comment.