From 6706100f1b92baa3e1989a45c6e8e1bc80c424e5 Mon Sep 17 00:00:00 2001 From: Akira Komamura Date: Mon, 18 May 2026 01:49:23 +0900 Subject: [PATCH 1/2] feat(containers): add MinistackContainer Co-Authored-By: Codex --- lib/container/ministack_container.ex | 103 ++++++++++++++++++++ test/container/ministack_container_test.exs | 27 +++++ 2 files changed, 130 insertions(+) create mode 100644 lib/container/ministack_container.ex create mode 100644 test/container/ministack_container_test.exs diff --git a/lib/container/ministack_container.ex b/lib/container/ministack_container.ex new file mode 100644 index 0000000..401c378 --- /dev/null +++ b/lib/container/ministack_container.ex @@ -0,0 +1,103 @@ +defmodule Testcontainers.MinistackContainer do + @moduledoc """ + Provides functionality for creating and managing Ministack container configurations. + """ + + alias Testcontainers.Container + alias Testcontainers.ContainerBuilder + alias Testcontainers.LogWaitStrategy + alias Testcontainers.MinistackContainer + + @default_image "ministackorg/ministack" + @default_tag "1.3.42" + @default_image_with_tag "#{@default_image}:#{@default_tag}" + @default_username "111111111111" + @default_password "anything" + @default_s3_port 4566 + @default_ui_port 2222 + @default_wait_timeout 60_000 + + @type t :: %__MODULE__{} + + @enforce_keys [:image, :username, :password, :wait_timeout] + defstruct [ + :image, + :username, + :password, + :wait_timeout, + reuse: false + ] + + def new, + do: %__MODULE__{ + image: @default_image_with_tag, + username: @default_username, + password: @default_password, + wait_timeout: @default_wait_timeout + } + + @doc """ + Set the reuse flag to reuse the container if it is already running. + """ + def with_reuse(%__MODULE__{} = config, reuse) when is_boolean(reuse) do + %__MODULE__{config | reuse: reuse} + end + + def get_username, do: @default_username + def get_password, do: @default_password + def default_ui_port, do: @default_ui_port + def default_s3_port, do: @default_s3_port + + @doc """ + Retrieves the port mapped by the Docker host for the Ministack container. + """ + def port(%Container{} = container), do: Testcontainers.get_port(container, @default_s3_port) + + @doc """ + Generates the connection URL for accessing the Ministack service running within the container. + """ + def connection_url(%Container{} = container) do + "http://#{Testcontainers.get_host(container)}:#{port(container)}" + end + + @doc """ + Generates the connection options for accessing the Ministack service running within the container. + Compatible with what ex_aws expects in `ExAws.request(options)` + """ + def connection_opts(%Container{} = container) do + [ + port: MinistackContainer.port(container), + scheme: "http://", + host: Testcontainers.get_host(container), + 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(MinistackContainer.t()) :: Container.t() + @impl true + def build(%MinistackContainer{} = config) do + new(config.image) + |> with_exposed_ports([ + MinistackContainer.default_s3_port(), + MinistackContainer.default_ui_port() + ]) + |> with_environment(:AWS_ACCESS_KEY_ID, config.username) + |> with_environment(:AWS_SECRET_ACCESS_KEY, config.password) + |> with_reuse(config.reuse) + |> with_waiting_strategy( + LogWaitStrategy.new( + ~r/.*Ready .* services available on port #{MinistackContainer.default_s3_port()}\./, + config.wait_timeout, + 1000 + ) + ) + end + + @impl true + def after_start(_config, _container, _conn), do: :ok + end +end diff --git a/test/container/ministack_container_test.exs b/test/container/ministack_container_test.exs new file mode 100644 index 0000000..5cd50f5 --- /dev/null +++ b/test/container/ministack_container_test.exs @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: MIT +defmodule Testcontainers.Container.MinistackContainerTest do + use ExUnit.Case, async: true + + import Testcontainers.ExUnit + alias Testcontainers.ContainerBuilder + alias Testcontainers.LogWaitStrategy + alias Testcontainers.MinistackContainer + + @ministack_container MinistackContainer.new() + + container(:ministack, @ministack_container) + + test "creates and starts ministack container", %{ministack: ministack} do + conn_opts = MinistackContainer.connection_opts(ministack) + + {:ok, _result} = + ExAws.S3.put_bucket("my-bucket", "") + |> ExAws.request(conn_opts) + + {:ok, %{body: %{buckets: [first_bucket | _rest]}}} = + ExAws.S3.list_buckets() + |> ExAws.request(conn_opts) + + assert first_bucket.name == "my-bucket" + end +end From b44ecd523ecca20bee75cf85376213636b1866e1 Mon Sep 17 00:00:00 2001 From: Akira Komamura Date: Fri, 22 May 2026 02:54:17 +0900 Subject: [PATCH 2/2] add more comprehensive test cases for ministack Co-Authored-By: Codex --- test/container/ministack_container_test.exs | 104 +++++++++++++++++--- 1 file changed, 93 insertions(+), 11 deletions(-) diff --git a/test/container/ministack_container_test.exs b/test/container/ministack_container_test.exs index 5cd50f5..befac43 100644 --- a/test/container/ministack_container_test.exs +++ b/test/container/ministack_container_test.exs @@ -3,25 +3,107 @@ defmodule Testcontainers.Container.MinistackContainerTest do use ExUnit.Case, async: true import Testcontainers.ExUnit + alias Testcontainers.ContainerBuilder - alias Testcontainers.LogWaitStrategy alias Testcontainers.MinistackContainer @ministack_container MinistackContainer.new() - container(:ministack, @ministack_container) + describe "new/0 and builder options" do + test "returns default ministack configuration" do + config = MinistackContainer.new() + + assert config.image == "ministackorg/ministack:1.3.42" + assert config.username == "111111111111" + assert config.password == "anything" + assert config.wait_timeout == 60_000 + assert config.reuse == false + end + + test "exposes default S3 and UI ports and sets AWS credentials" do + container = + MinistackContainer.new() + |> MinistackContainer.with_reuse(true) + |> ContainerBuilder.build() + + assert {MinistackContainer.default_s3_port(), nil} in container.exposed_ports + assert {MinistackContainer.default_ui_port(), nil} in container.exposed_ports + assert container.environment[:AWS_ACCESS_KEY_ID] == MinistackContainer.get_username() + assert container.environment[:AWS_SECRET_ACCESS_KEY] == MinistackContainer.get_password() + assert container.reuse == true + end + end + + describe "runtime behavior" do + container(:ministack, @ministack_container) + + test "provides connection helpers", %{ + ministack: ministack + } do + host = Testcontainers.get_host(ministack) + port = MinistackContainer.port(ministack) + conn_opts = MinistackContainer.connection_opts(ministack) + + assert is_integer(port) + assert MinistackContainer.connection_url(ministack) == "http://#{host}:#{port}" + + assert conn_opts == [ + port: port, + scheme: "http://", + host: host, + access_key_id: MinistackContainer.get_username(), + secret_access_key: MinistackContainer.get_password() + ] + end + + test "responds from health-check endpoint", %{ + ministack: ministack + } do + health_url = "#{MinistackContainer.connection_url(ministack)}/_ministack/health" - test "creates and starts ministack container", %{ministack: ministack} do - conn_opts = MinistackContainer.connection_opts(ministack) + {:ok, %{status: 200, body: body}} = Tesla.get(health_url) + {:ok, health} = Jason.decode(body) - {:ok, _result} = - ExAws.S3.put_bucket("my-bucket", "") - |> ExAws.request(conn_opts) + assert is_map(health) + assert map_size(health) > 0 + end - {:ok, %{body: %{buckets: [first_bucket | _rest]}}} = - ExAws.S3.list_buckets() - |> ExAws.request(conn_opts) + test "supports bucket and file object operations", %{ + ministack: ministack + } do + conn_opts = MinistackContainer.connection_opts(ministack) + + bucket = bucket_name("files") + object_key = "fixtures/hello.txt" + file_contents = "Hello from a Ministack-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, file_contents) + |> ExAws.request(conn_opts) + + {:ok, %{body: %{contents: objects}}} = + ExAws.S3.list_objects(bucket, prefix: "fixtures/") + |> ExAws.request(conn_opts) + + assert Enum.any?(objects, &(&1.key == object_key)) + + {:ok, %{body: ^file_contents}} = + ExAws.S3.get_object(bucket, object_key) + |> ExAws.request(conn_opts) + end + end - assert first_bucket.name == "my-bucket" + defp bucket_name(prefix) do + "ministack-#{prefix}-#{System.unique_integer([:positive])}" end end