From 5d63c7fc111185fa42cc0be193c4bc9a0f2c62af Mon Sep 17 00:00:00 2001 From: digia Date: Mon, 22 Dec 2025 22:13:13 -0500 Subject: [PATCH] feat: support multiple property sources with precedence 1. Environment variables (TESTCONTAINERS_* prefix) 2. User file (~/.testcontainers.properties) 3. Project file (.testcontainers.properties) --- lib/util/properties.ex | 76 ++++++++++++++++++++++++++++++++++- test/util/properties_test.exs | 70 ++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 test/util/properties_test.exs diff --git a/lib/util/properties.ex b/lib/util/properties.ex index 3c1dd68a..5abff4cc 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/util/properties_test.exs b/test/util/properties_test.exs new file mode 100644 index 00000000..efcd791e --- /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