Skip to content

Commit cf83e2d

Browse files
digiajarlahCopilot
authored
Support Container.with_privileged for Ryuk Privileged Mode (#221)
* feat: support multiple property sources with precedence 1. Environment variables (TESTCONTAINERS_* prefix) 2. User file (~/.testcontainers.properties) 3. Project file (.testcontainers.properties) * feat: add `with_privileged` for `Container` * feat: refine testcontainer properties setup Parse properties earlier to allow controlling Ryuk container config, specifically supporting the `with_privileged` flag. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Jarl André Hübenthal <jarlah@protonmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent e0031d2 commit cf83e2d

5 files changed

Lines changed: 183 additions & 4 deletions

File tree

lib/container.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,16 @@ defmodule Testcontainers.Container do
182182
%__MODULE__{config | auto_remove: auto_remove}
183183
end
184184

185+
@doc """
186+
Sets whether the container should run in privileged mode.
187+
188+
Required for containers that need access to the Docker/Podman socket
189+
when running under SELinux (e.g., Ryuk on Podman).
190+
"""
191+
def with_privileged(%__MODULE__{} = config, privileged) when is_boolean(privileged) do
192+
%__MODULE__{config | privileged: privileged}
193+
end
194+
185195
@doc """
186196
Sets whether the container should be reused if it is already running.
187197
"""

lib/testcontainers.ex

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,24 +50,29 @@ defmodule Testcontainers do
5050
defp setup(options) do
5151
{conn, docker_host_url, docker_host} = Connection.get_connection(options)
5252

53+
# Read properties first so we can configure Ryuk appropriately
54+
{:ok, properties} = PropertiesParser.read_property_sources()
55+
5356
session_id =
5457
:crypto.hash(:sha, "#{inspect(self())}#{DateTime.utc_now() |> DateTime.to_string()}")
5558
|> Base.encode16()
5659

60+
ryuk_privileged = Map.get(properties, "ryuk.container.privileged", "false") == "true"
61+
5762
ryuk_config =
5863
Container.new("testcontainers/ryuk:#{Constants.ryuk_version()}")
5964
|> Container.with_exposed_port(8080)
6065
|> then(&apply_docker_socket_volume_binding(&1, docker_host))
6166
|> Container.with_auto_remove(true)
67+
|> Container.with_privileged(ryuk_privileged)
6268

6369
with {:ok, _} <- Api.pull_image(ryuk_config.image, conn),
6470
{:ok, docker_hostname} <- get_docker_hostname(docker_host_url, conn),
6571
{:ok, ryuk_container_id} <- Api.create_container(ryuk_config, conn),
6672
:ok <- Api.start_container(ryuk_container_id, conn),
6773
{:ok, container} <- Api.get_container(ryuk_container_id, conn),
6874
{:ok, socket} <- create_ryuk_socket(container, docker_hostname),
69-
:ok <- register_ryuk_filter(session_id, socket),
70-
{:ok, properties} <- PropertiesParser.read_property_file() do
75+
:ok <- register_ryuk_filter(session_id, socket) do
7176
Logger.info("Testcontainers initialized")
7277

7378
{:ok,

lib/util/properties.ex

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
defmodule Testcontainers.Util.PropertiesParser do
22
@moduledoc false
33

4-
@file_path "~/.testcontainers.properties"
4+
@user_file "~/.testcontainers.properties"
5+
@project_file ".testcontainers.properties"
6+
@env_prefix "TESTCONTAINERS_"
57

6-
def read_property_file(file_path \\ @file_path) do
8+
def read_property_file(file_path \\ @user_file) do
79
if File.exists?(Path.expand(file_path)) do
810
with {:ok, content} <- File.read(Path.expand(file_path)),
911
properties <- parse_properties(content) do
@@ -18,6 +20,78 @@ defmodule Testcontainers.Util.PropertiesParser do
1820
end
1921
end
2022

23+
@doc """
24+
Reads properties from all sources with proper precedence.
25+
26+
Configuration is read from three sources with the following precedence
27+
(highest to lowest):
28+
29+
1. Environment variables (TESTCONTAINERS_* prefix)
30+
2. User file (~/.testcontainers.properties)
31+
3. Project file (.testcontainers.properties)
32+
33+
Environment variables are converted from TESTCONTAINERS_PROPERTY_NAME format
34+
to property.name format (uppercase to lowercase, underscores to dots, prefix removed).
35+
36+
## Options
37+
38+
- `:user_file` - path to user properties file (default: ~/.testcontainers.properties)
39+
- `:project_file` - path to project properties file (default: .testcontainers.properties)
40+
- `:env_prefix` - environment variable prefix (default: TESTCONTAINERS_)
41+
42+
## Returns
43+
44+
- `{:ok, map}` with merged properties.
45+
"""
46+
def read_property_sources(opts \\ []) do
47+
user_file = Keyword.get(opts, :user_file, @user_file)
48+
project_file = Keyword.get(opts, :project_file, @project_file)
49+
env_prefix = Keyword.get(opts, :env_prefix, @env_prefix)
50+
51+
project_props = read_file_silent(project_file)
52+
user_props = read_file_silent(user_file)
53+
env_props = read_env_vars(env_prefix)
54+
55+
# Merge in order of lowest to highest precedence
56+
merged =
57+
project_props
58+
|> Map.merge(user_props)
59+
|> Map.merge(env_props)
60+
61+
{:ok, merged}
62+
end
63+
64+
defp read_file_silent(file_path) do
65+
expanded = Path.expand(file_path)
66+
67+
if File.exists?(expanded) do
68+
case File.read(expanded) do
69+
{:ok, content} -> parse_properties(content)
70+
{:error, _} -> %{}
71+
end
72+
else
73+
%{}
74+
end
75+
end
76+
77+
defp read_env_vars(prefix) do
78+
System.get_env()
79+
|> Enum.filter(fn {key, _value} -> String.starts_with?(key, prefix) end)
80+
|> Enum.map(&env_to_property(&1, prefix))
81+
|> Map.new()
82+
end
83+
84+
# Converts TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED to ryuk.container.privileged
85+
defp env_to_property({key, value}, prefix) do
86+
property_key =
87+
key
88+
|> String.replace_prefix(prefix, "")
89+
|> String.downcase()
90+
|> String.replace("_", ".")
91+
92+
{property_key, value}
93+
end
94+
2195
defp parse_properties(content) do
2296
content
2397
|> String.split("\n")

test/container_test.exs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,4 +210,24 @@ defmodule Testcontainers.ContainerTest do
210210
end
211211
end
212212
end
213+
214+
describe "with_privileged/2" do
215+
test "sets privileged to true" do
216+
container = Container.new("my-image")
217+
assert container.privileged == false
218+
219+
updated_container = Container.with_privileged(container, true)
220+
221+
assert updated_container.privileged == true
222+
end
223+
224+
test "sets privileged to false" do
225+
container =
226+
Container.new("my-image")
227+
|> Container.with_privileged(true)
228+
|> Container.with_privileged(false)
229+
230+
assert container.privileged == false
231+
end
232+
end
213233
end

test/util/properties_test.exs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
defmodule Testcontainers.Util.PropertiesParserTest do
2+
use ExUnit.Case, async: false
3+
4+
alias Testcontainers.Util.PropertiesParser
5+
6+
describe "read_property_sources/0" do
7+
test "returns empty map when no files or env vars exist" do
8+
# Clean env vars that might interfere
9+
System.get_env()
10+
|> Enum.filter(fn {k, _} -> String.starts_with?(k, "TESTCONTAINERS_") end)
11+
|> Enum.each(fn {k, _} -> System.delete_env(k) end)
12+
13+
{:ok, props} = PropertiesParser.read_property_sources()
14+
15+
# Should at least return a map (may have project file props)
16+
assert is_map(props)
17+
end
18+
19+
test "reads environment variables with TESTCONTAINERS_ prefix" do
20+
System.put_env("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED", "true")
21+
System.put_env("TESTCONTAINERS_SOME_OTHER_PROPERTY", "value")
22+
23+
{:ok, props} = PropertiesParser.read_property_sources()
24+
25+
assert props["ryuk.container.privileged"] == "true"
26+
assert props["some.other.property"] == "value"
27+
28+
# Cleanup
29+
System.delete_env("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED")
30+
System.delete_env("TESTCONTAINERS_SOME_OTHER_PROPERTY")
31+
end
32+
33+
test "environment variables take precedence over file properties" do
34+
# Set env var
35+
System.put_env("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED", "from_env")
36+
37+
{:ok, props} = PropertiesParser.read_property_sources()
38+
39+
# Env should win over any file-based setting
40+
assert props["ryuk.container.privileged"] == "from_env"
41+
42+
# Cleanup
43+
System.delete_env("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED")
44+
end
45+
end
46+
47+
describe "read_property_file/0" do
48+
test "defaults to user file path" do
49+
{:ok, props} = PropertiesParser.read_property_file()
50+
51+
# Should return a map (empty if user file doesn't exist)
52+
assert is_map(props)
53+
end
54+
end
55+
56+
describe "read_property_file/1" do
57+
test "reads properties from specified file" do
58+
{:ok, props} = PropertiesParser.read_property_file("test/fixtures/.testcontainers.properties")
59+
60+
assert is_map(props)
61+
assert props["tc.host"] == "tcp://localhost:9999"
62+
end
63+
64+
test "returns empty map for nonexistent file" do
65+
{:ok, props} = PropertiesParser.read_property_file("/nonexistent/path/.testcontainers.properties")
66+
67+
assert props == %{}
68+
end
69+
end
70+
end

0 commit comments

Comments
 (0)