Skip to content
Open
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
10 changes: 10 additions & 0 deletions lib/container.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down
9 changes: 7 additions & 2 deletions lib/testcontainers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -50,24 +50,29 @@ 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),
{:ok, ryuk_container_id} <- Api.create_container(ryuk_config, conn),
: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,
Expand Down
76 changes: 74 additions & 2 deletions lib/util/properties.ex
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation should follow the project's standard format by using a proper "## Returns" section header instead of inline text. This maintains consistency with other documented functions in the codebase (see lib/testcontainers.ex and lib/ecto.ex for examples).

Suggested change
Returns `{:ok, map}` with merged properties.
## Returns
- `{:ok, map}` with merged properties.

Copilot uses AI. Check for mistakes.
"""
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")
Expand Down
20 changes: 20 additions & 0 deletions test/container_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
70 changes: 70 additions & 0 deletions test/util/properties_test.exs
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +7 to +17
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name claims "returns empty map when no files or env vars exist" but the test doesn't ensure no files exist and doesn't verify the map is empty. The comment on line 15 acknowledges this by noting "may have project file props". Consider either renaming the test to accurately describe what it tests (e.g., "returns a map with merged properties from all sources") or modify the test to actually verify an empty map when no sources exist by mocking the file system and environment.

Copilot uses AI. Check for mistakes.

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
Comment on lines +19 to +31
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test lacks proper cleanup if an assertion fails. Use ExUnit's on_exit/1 callback or setup/teardown to ensure environment variables are always cleaned up, even when tests fail. This prevents test isolation issues where a failing test could pollute the environment for subsequent tests.

Copilot uses AI. Check for mistakes.

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
Comment on lines +33 to +44
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test lacks proper cleanup if an assertion fails. Use ExUnit's on_exit/1 callback or setup/teardown to ensure environment variables are always cleaned up, even when tests fail.

Copilot uses AI. Check for mistakes.
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