diff --git a/lib/container/floci_container.ex b/lib/container/floci_container.ex new file mode 100644 index 0000000..92ff29b --- /dev/null +++ b/lib/container/floci_container.ex @@ -0,0 +1,213 @@ +defmodule Testcontainers.FlociContainer do + @moduledoc """ + Provides functionality for creating and managing Floci container configurations. + + Floci is a local AWS emulator. All emulated services are available through a + single endpoint returned by `connection_url/1`. + + Docker assigns the endpoint's host port after the container is created, so it + cannot be injected into Floci before startup. If a test follows absolute URLs + returned by Floci, configure `with_base_url/2` with an address reachable in + that test's fixed-port or shared-network topology. + """ + + alias Testcontainers.Container + alias Testcontainers.ContainerBuilder + alias Testcontainers.FlociContainer + alias Testcontainers.LogWaitStrategy + + import Testcontainers.Container, only: [is_valid_image: 1] + + @default_image "floci/floci" + @default_tag "1.7.0" + @default_image_with_tag "#{@default_image}:#{@default_tag}" + @default_region "us-east-1" + @default_availability_zone "us-east-1a" + @default_account_id "000000000000" + @default_access_key "test" + @default_secret_key "test" + @default_port 4566 + @default_wait_timeout 60_000 + + @type t :: %__MODULE__{} + + @enforce_keys [ + :image, + :region, + :availability_zone, + :account_id, + :access_key, + :secret_key, + :wait_timeout + ] + defstruct [ + :image, + :region, + :availability_zone, + :account_id, + :access_key, + :secret_key, + :wait_timeout, + base_url: nil, + check_image: @default_image, + reuse: false + ] + + @doc """ + Creates a new Floci container configuration with default settings. + """ + def new, + do: %__MODULE__{ + image: @default_image_with_tag, + region: @default_region, + availability_zone: @default_availability_zone, + account_id: @default_account_id, + access_key: @default_access_key, + secret_key: @default_secret_key, + wait_timeout: @default_wait_timeout + } + + @doc """ + Overrides the Docker image used for the Floci container. + """ + def with_image(%__MODULE__{} = config, image) when is_binary(image) do + %__MODULE__{config | image: image} + end + + @doc """ + Sets the default AWS region advertised by Floci. + """ + def with_region(%__MODULE__{} = config, region) when is_binary(region) do + %__MODULE__{config | region: region} + end + + @doc """ + Sets the default AWS availability zone advertised by Floci. + """ + def with_availability_zone(%__MODULE__{} = config, availability_zone) + when is_binary(availability_zone) do + %__MODULE__{config | availability_zone: availability_zone} + end + + @doc """ + Sets the default AWS account ID advertised by Floci. + """ + def with_account_id(%__MODULE__{} = config, account_id) when is_binary(account_id) do + %__MODULE__{config | account_id: account_id} + end + + @doc """ + Sets the base URL Floci embeds in generated absolute URLs. + + This is useful for APIs such as SQS that return URLs in their responses. The + URL must be reachable by the test client, typically through a fixed host port + or a shared Docker network. For ordinary AWS SDK requests, use the dynamic + endpoint returned by `connection_url/1` instead. + """ + def with_base_url(%__MODULE__{} = config, base_url) when is_binary(base_url) do + %__MODULE__{config | base_url: base_url} + end + + @doc """ + Sets the dummy AWS access key returned by `connection_opts/1`. + """ + def with_access_key(%__MODULE__{} = config, access_key) when is_binary(access_key) do + %__MODULE__{config | access_key: access_key} + end + + @doc """ + Sets the dummy AWS secret key returned by `connection_opts/1`. + """ + def with_secret_key(%__MODULE__{} = config, secret_key) when is_binary(secret_key) do + %__MODULE__{config | secret_key: secret_key} + end + + @doc """ + Overrides the maximum time to wait for Floci to become ready. + """ + def with_wait_timeout(%__MODULE__{} = config, wait_timeout) when is_integer(wait_timeout) do + %__MODULE__{config | wait_timeout: wait_timeout} + end + + @doc """ + Sets the regular expression used to validate the configured image. + """ + def with_check_image(%__MODULE__{} = config, check_image) when is_valid_image(check_image) do + %__MODULE__{config | check_image: check_image} + end + + @doc """ + Sets whether the container should be reused when reuse is enabled globally. + """ + def with_reuse(%__MODULE__{} = config, reuse) when is_boolean(reuse) do + %__MODULE__{config | reuse: reuse} + end + + def default_image, do: @default_image + def default_region, do: @default_region + def default_availability_zone, do: @default_availability_zone + def default_account_id, do: @default_account_id + def default_access_key, do: @default_access_key + def default_secret_key, do: @default_secret_key + def default_port, do: @default_port + + @doc """ + Returns the port on the Docker host where Floci is listening. + """ + def port(%Container{} = container), do: Testcontainers.get_port(container, @default_port) + + @doc """ + Returns the endpoint URL to use with AWS SDK clients. + """ + def connection_url(%Container{} = container) do + "http://#{Testcontainers.get_host(container)}:#{port(container)}" + end + + @doc """ + Returns connection options compatible with `ExAws.request/2`. + """ + def connection_opts(%Container{} = container) do + [ + port: FlociContainer.port(container), + scheme: "http://", + host: Testcontainers.get_host(container), + region: container.environment[:FLOCI_DEFAULT_REGION], + access_key_id: container.environment[:AWS_ACCESS_KEY_ID], + secret_access_key: container.environment[:AWS_SECRET_ACCESS_KEY] + ] + end + + defimpl ContainerBuilder do + import Container + + @spec build(FlociContainer.t()) :: Container.t() + @impl true + def build(%FlociContainer{} = config) do + new(config.image) + |> with_exposed_port(FlociContainer.default_port()) + |> with_environment(:FLOCI_DEFAULT_REGION, config.region) + |> with_environment(:FLOCI_DEFAULT_AVAILABILITY_ZONE, config.availability_zone) + |> with_environment(:FLOCI_DEFAULT_ACCOUNT_ID, config.account_id) + |> with_environment(:AWS_ACCESS_KEY_ID, config.access_key) + |> with_environment(:AWS_SECRET_ACCESS_KEY, config.secret_key) + |> then(fn container -> + if config.base_url do + with_environment(container, :FLOCI_BASE_URL, config.base_url) + else + container + end + end) + |> with_check_image(config.check_image) + |> with_reuse(config.reuse) + |> with_waiting_strategy( + # Floci exposes its init endpoint before ready hooks have completed. The + # readiness log is emitted only after all configured ready hooks finish. + LogWaitStrategy.new(~r/.*Ready\..*/, config.wait_timeout) + ) + |> valid_image!() + end + + @impl true + def after_start(_config, _container, _conn), do: :ok + end +end diff --git a/test/container/floci_container_test.exs b/test/container/floci_container_test.exs new file mode 100644 index 0000000..582067a --- /dev/null +++ b/test/container/floci_container_test.exs @@ -0,0 +1,267 @@ +# SPDX-License-Identifier: MIT +defmodule Testcontainers.Container.FlociContainerTest do + use ExUnit.Case, async: true + + import Testcontainers.ExUnit + + alias Testcontainers.ContainerBuilder + alias Testcontainers.FlociContainer + alias Testcontainers.LogWaitStrategy + + @floci_container FlociContainer.new() + + describe "new/0" do + test "returns the default Floci configuration" do + config = FlociContainer.new() + + assert config.image == "floci/floci:1.7.0" + assert config.region == "us-east-1" + assert config.availability_zone == "us-east-1a" + assert config.account_id == "000000000000" + assert config.access_key == "test" + assert config.secret_key == "test" + assert config.wait_timeout == 60_000 + assert config.base_url == nil + assert config.check_image == "floci/floci" + assert config.reuse == false + end + end + + describe "with_image/2" do + test "overrides the default image" do + config = FlociContainer.new() + + new_config = FlociContainer.with_image(config, "floci/floci:latest") + + assert new_config.image == "floci/floci:latest" + end + + test "raises if the image is not a binary" do + config = FlociContainer.new() + + assert_raise FunctionClauseError, fn -> FlociContainer.with_image(config, 123) end + end + end + + describe "with_region/2" do + test "overrides the default region" do + config = FlociContainer.new() + + new_config = FlociContainer.with_region(config, "eu-west-1") + + assert new_config.region == "eu-west-1" + end + end + + describe "with_availability_zone/2" do + test "overrides the default availability zone" do + config = FlociContainer.new() + + new_config = FlociContainer.with_availability_zone(config, "eu-west-1b") + + assert new_config.availability_zone == "eu-west-1b" + end + end + + describe "with_account_id/2" do + test "overrides the default account ID" do + config = FlociContainer.new() + + new_config = FlociContainer.with_account_id(config, "111111111111") + + assert new_config.account_id == "111111111111" + end + end + + describe "with_access_key/2" do + test "overrides the default access key" do + config = FlociContainer.new() + + new_config = FlociContainer.with_access_key(config, "access-key") + + assert new_config.access_key == "access-key" + end + end + + describe "with_base_url/2" do + test "sets the URL Floci embeds in responses" do + config = FlociContainer.new() + + new_config = FlociContainer.with_base_url(config, "http://floci:4566") + + assert new_config.base_url == "http://floci:4566" + end + end + + describe "with_secret_key/2" do + test "overrides the default secret key" do + config = FlociContainer.new() + + new_config = FlociContainer.with_secret_key(config, "secret-key") + + assert new_config.secret_key == "secret-key" + end + end + + describe "with_wait_timeout/2" do + test "overrides the default wait timeout" do + config = FlociContainer.new() + + new_config = FlociContainer.with_wait_timeout(config, 30_000) + + assert new_config.wait_timeout == 30_000 + end + + test "raises if the wait timeout is not an integer" do + config = FlociContainer.new() + + assert_raise FunctionClauseError, fn -> + FlociContainer.with_wait_timeout(config, "30000") + end + end + end + + describe "with_check_image/2" do + test "overrides the image validation pattern" do + config = FlociContainer.new() + + new_config = FlociContainer.with_check_image(config, ~r/^custom-floci\/floci/) + + assert Regex.source(new_config.check_image) == "^custom-floci/floci" + end + end + + describe "with_reuse/2" do + test "sets the reuse flag" do + config = FlociContainer.new() + + new_config = FlociContainer.with_reuse(config, true) + + assert new_config.reuse == true + end + + test "raises if reuse is not a boolean" do + config = FlociContainer.new() + + assert_raise FunctionClauseError, fn -> + FlociContainer.with_reuse(config, "true") + end + end + end + + describe "ContainerBuilder.build/1" do + test "builds a container using the configured image" do + container = + FlociContainer.new() + |> FlociContainer.with_image("floci/floci:latest") + |> ContainerBuilder.build() + + assert container.image == "floci/floci:latest" + end + + test "exposes the Floci edge port" do + container = + FlociContainer.new() + |> ContainerBuilder.build() + + assert {FlociContainer.default_port(), nil} in container.exposed_ports + end + + test "sets the configured Floci environment" do + container = + FlociContainer.new() + |> FlociContainer.with_region("eu-west-1") + |> FlociContainer.with_availability_zone("eu-west-1b") + |> FlociContainer.with_account_id("111111111111") + |> FlociContainer.with_base_url("http://floci:4566") + |> FlociContainer.with_access_key("access-key") + |> FlociContainer.with_secret_key("secret-key") + |> ContainerBuilder.build() + + assert container.environment[:FLOCI_DEFAULT_REGION] == "eu-west-1" + assert container.environment[:FLOCI_DEFAULT_AVAILABILITY_ZONE] == "eu-west-1b" + assert container.environment[:FLOCI_DEFAULT_ACCOUNT_ID] == "111111111111" + assert container.environment[:FLOCI_BASE_URL] == "http://floci:4566" + assert container.environment[:AWS_ACCESS_KEY_ID] == "access-key" + assert container.environment[:AWS_SECRET_ACCESS_KEY] == "secret-key" + end + + test "keeps the configured reuse flag" do + container = + FlociContainer.new() + |> FlociContainer.with_reuse(true) + |> ContainerBuilder.build() + + assert container.reuse == true + end + + test "waits until Floci reports completed initialization" do + container = + FlociContainer.new() + |> FlociContainer.with_wait_timeout(30_000) + |> ContainerBuilder.build() + + assert [ + %LogWaitStrategy{ + log_regex: log_regex, + timeout: 30_000 + } + ] = container.wait_strategies + + assert Regex.match?(log_regex, "Ready.") + end + end + + describe "runtime behavior" do + container(:floci, @floci_container) + + test "provides connection helpers", %{floci: floci} do + host = Testcontainers.get_host(floci) + port = FlociContainer.port(floci) + + assert is_integer(port) + assert FlociContainer.connection_url(floci) == "http://#{host}:#{port}" + + assert FlociContainer.connection_opts(floci) == [ + port: port, + scheme: "http://", + host: host, + region: "us-east-1", + access_key_id: "test", + secret_access_key: "test" + ] + end + + test "reports completed initialization", %{floci: floci} do + init_url = "#{FlociContainer.connection_url(floci)}/_floci/init" + + {:ok, %{status: 200, body: body}} = Tesla.get(init_url) + {:ok, %{"completed" => %{"ready" => true}}} = Jason.decode(body) + end + + test "supports bucket and object operations", %{floci: floci} do + conn_opts = FlociContainer.connection_opts(floci) + bucket = "floci-#{System.unique_integer([:positive])}" + object_key = "fixtures/hello.txt" + contents = "Hello from a Floci-backed S3 object" + + {:ok, _result} = + ExAws.S3.put_bucket(bucket, "") + |> ExAws.request(conn_opts) + + {:ok, %{body: %{buckets: buckets}}} = + ExAws.S3.list_buckets() + |> ExAws.request(conn_opts) + + assert Enum.any?(buckets, &(&1.name == bucket)) + + {:ok, _result} = + ExAws.S3.put_object(bucket, object_key, contents) + |> ExAws.request(conn_opts) + + {:ok, %{body: ^contents}} = + ExAws.S3.get_object(bucket, object_key) + |> ExAws.request(conn_opts) + end + end +end