diff --git a/lib/container.ex b/lib/container.ex index 38a329f..d7c6070 100644 --- a/lib/container.ex +++ b/lib/container.ex @@ -170,6 +170,16 @@ defmodule Testcontainers.Container do %__MODULE__{config | auto_remove: auto_remove} end + @doc """ + Sets whether the container should run in privileged mode. + + Required for containers that need access to the Docker/Podman socket + when running under SELinux (e.g., Ryuk on Podman). + """ + def with_privileged(%__MODULE__{} = config, privileged) when is_boolean(privileged) do + %__MODULE__{config | privileged: privileged} + end + @doc """ Sets whether the container should be reused if it is already running. """ diff --git a/lib/testcontainers.ex b/lib/testcontainers.ex index 3e7665f..de76eea 100644 --- a/lib/testcontainers.ex +++ b/lib/testcontainers.ex @@ -50,15 +50,21 @@ defmodule Testcontainers do defp setup(options) do {conn, docker_host_url, docker_host} = Connection.get_connection(options) + # Read properties first so we can configure Ryuk appropriately + {:ok, properties} = PropertiesParser.read_property_sources() + session_id = :crypto.hash(:sha, "#{inspect(self())}#{DateTime.utc_now() |> DateTime.to_string()}") |> Base.encode16() + ryuk_privileged = Map.get(properties, "ryuk.container.privileged", "false") == "true" + ryuk_config = Container.new("testcontainers/ryuk:#{Constants.ryuk_version()}") |> Container.with_exposed_port(8080) |> then(&apply_docker_socket_volume_binding(&1, docker_host)) |> Container.with_auto_remove(true) + |> Container.with_privileged(ryuk_privileged) with {:ok, _} <- Api.pull_image(ryuk_config.image, conn), {:ok, docker_hostname} <- get_docker_hostname(docker_host_url, conn), @@ -66,8 +72,7 @@ defmodule Testcontainers do :ok <- Api.start_container(ryuk_container_id, conn), {:ok, container} <- Api.get_container(ryuk_container_id, conn), {:ok, socket} <- create_ryuk_socket(container, docker_hostname), - :ok <- register_ryuk_filter(session_id, socket), - {:ok, properties} <- PropertiesParser.read_property_file() do + :ok <- register_ryuk_filter(session_id, socket) do Logger.info("Testcontainers initialized") {:ok, diff --git a/lib/util/properties.ex b/lib/util/properties.ex index 3c1dd68..5abff4c 100644 --- a/lib/util/properties.ex +++ b/lib/util/properties.ex @@ -1,9 +1,11 @@ defmodule Testcontainers.Util.PropertiesParser do @moduledoc false - @file_path "~/.testcontainers.properties" + @user_file "~/.testcontainers.properties" + @project_file ".testcontainers.properties" + @env_prefix "TESTCONTAINERS_" - def read_property_file(file_path \\ @file_path) do + def read_property_file(file_path \\ @user_file) do if File.exists?(Path.expand(file_path)) do with {:ok, content} <- File.read(Path.expand(file_path)), properties <- parse_properties(content) do @@ -18,6 +20,76 @@ defmodule Testcontainers.Util.PropertiesParser do end end + @doc """ + Reads properties from all sources with proper precedence. + + Configuration is read from three sources with the following precedence + (highest to lowest): + + 1. Environment variables (TESTCONTAINERS_* prefix) + 2. User file (~/.testcontainers.properties) + 3. Project file (.testcontainers.properties) + + Environment variables are converted from TESTCONTAINERS_PROPERTY_NAME format + to property.name format (uppercase to lowercase, underscores to dots, prefix removed). + + ## Options + + - `:user_file` - path to user properties file (default: ~/.testcontainers.properties) + - `:project_file` - path to project properties file (default: .testcontainers.properties) + - `:env_prefix` - environment variable prefix (default: TESTCONTAINERS_) + + Returns `{:ok, map}` with merged properties. + """ + def read_property_sources(opts \\ []) do + user_file = Keyword.get(opts, :user_file, @user_file) + project_file = Keyword.get(opts, :project_file, @project_file) + env_prefix = Keyword.get(opts, :env_prefix, @env_prefix) + + project_props = read_file_silent(project_file) + user_props = read_file_silent(user_file) + env_props = read_env_vars(env_prefix) + + # Merge in order of lowest to highest precedence + merged = + project_props + |> Map.merge(user_props) + |> Map.merge(env_props) + + {:ok, merged} + end + + defp read_file_silent(file_path) do + expanded = Path.expand(file_path) + + if File.exists?(expanded) do + case File.read(expanded) do + {:ok, content} -> parse_properties(content) + {:error, _} -> %{} + end + else + %{} + end + end + + defp read_env_vars(prefix) do + System.get_env() + |> Enum.filter(fn {key, _value} -> String.starts_with?(key, prefix) end) + |> Enum.map(&env_to_property(&1, prefix)) + |> Map.new() + end + + # Converts TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED to ryuk.container.privileged + defp env_to_property({key, value}, prefix) do + property_key = + key + |> String.replace_prefix(prefix, "") + |> String.downcase() + |> String.replace("_", ".") + + {property_key, value} + end + defp parse_properties(content) do content |> String.split("\n") diff --git a/test/container_test.exs b/test/container_test.exs index 9240fad..dfbcf4a 100644 --- a/test/container_test.exs +++ b/test/container_test.exs @@ -205,4 +205,24 @@ defmodule Testcontainers.ContainerTest do end end end + + describe "with_privileged/2" do + test "sets privileged to true" do + container = Container.new("my-image") + assert container.privileged == false + + updated_container = Container.with_privileged(container, true) + + assert updated_container.privileged == true + end + + test "sets privileged to false" do + container = + Container.new("my-image") + |> Container.with_privileged(true) + |> Container.with_privileged(false) + + assert container.privileged == false + end + end end diff --git a/test/util/properties_test.exs b/test/util/properties_test.exs new file mode 100644 index 0000000..efcd791 --- /dev/null +++ b/test/util/properties_test.exs @@ -0,0 +1,70 @@ +defmodule Testcontainers.Util.PropertiesParserTest do + use ExUnit.Case, async: false + + alias Testcontainers.Util.PropertiesParser + + describe "read_property_sources/0" do + test "returns empty map when no files or env vars exist" do + # Clean env vars that might interfere + System.get_env() + |> Enum.filter(fn {k, _} -> String.starts_with?(k, "TESTCONTAINERS_") end) + |> Enum.each(fn {k, _} -> System.delete_env(k) end) + + {:ok, props} = PropertiesParser.read_property_sources() + + # Should at least return a map (may have project file props) + assert is_map(props) + end + + test "reads environment variables with TESTCONTAINERS_ prefix" do + System.put_env("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED", "true") + System.put_env("TESTCONTAINERS_SOME_OTHER_PROPERTY", "value") + + {:ok, props} = PropertiesParser.read_property_sources() + + assert props["ryuk.container.privileged"] == "true" + assert props["some.other.property"] == "value" + + # Cleanup + System.delete_env("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED") + System.delete_env("TESTCONTAINERS_SOME_OTHER_PROPERTY") + end + + test "environment variables take precedence over file properties" do + # Set env var + System.put_env("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED", "from_env") + + {:ok, props} = PropertiesParser.read_property_sources() + + # Env should win over any file-based setting + assert props["ryuk.container.privileged"] == "from_env" + + # Cleanup + System.delete_env("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED") + end + end + + describe "read_property_file/0" do + test "defaults to user file path" do + {:ok, props} = PropertiesParser.read_property_file() + + # Should return a map (empty if user file doesn't exist) + assert is_map(props) + end + end + + describe "read_property_file/1" do + test "reads properties from specified file" do + {:ok, props} = PropertiesParser.read_property_file("test/fixtures/.testcontainers.properties") + + assert is_map(props) + assert props["tc.host"] == "tcp://localhost:9999" + end + + test "returns empty map for nonexistent file" do + {:ok, props} = PropertiesParser.read_property_file("/nonexistent/path/.testcontainers.properties") + + assert props == %{} + end + end +end