From 1b35d01e9d4442276a76f6d1e5246fcf9f39b972 Mon Sep 17 00:00:00 2001 From: Guillermo Iguaran Date: Sun, 19 Oct 2025 01:00:28 -0500 Subject: [PATCH] Add support for .with_network and .with_network_aliases Co-authored-by: Dieter S. <101627195+dieter-medium@users.noreply.github.com> --- core/lib/testcontainers.rb | 7 +- core/lib/testcontainers/docker_client.rb | 49 ++++++ core/lib/testcontainers/docker_container.rb | 164 +++++++++++++++++--- core/lib/testcontainers/network.rb | 111 +++++++++++++ core/test/docker_client_test.rb | 63 ++++++++ core/test/docker_container_test.rb | 55 +++++++ core/test/network_test.rb | 59 +++++++ core/test/testcontainers_test.rb | 1 + docs/QuickStart.md | 29 ++++ 9 files changed, 511 insertions(+), 27 deletions(-) create mode 100644 core/lib/testcontainers/docker_client.rb create mode 100644 core/lib/testcontainers/network.rb create mode 100644 core/test/docker_client_test.rb create mode 100644 core/test/network_test.rb diff --git a/core/lib/testcontainers.rb b/core/lib/testcontainers.rb index b7e0799..4450850 100644 --- a/core/lib/testcontainers.rb +++ b/core/lib/testcontainers.rb @@ -4,6 +4,8 @@ require "logger" require "open3" require "uri" +require "testcontainers/docker_client" +require "testcontainers/network" require "testcontainers/docker_container" require_relative "testcontainers/version" @@ -31,9 +33,4 @@ def logger @logger ||= Logger.new($stdout, level: :info) end end - - # Configure Docker API with custom User-Agent - Docker.options ||= {} - Docker.options[:headers] ||= {} - Docker.options[:headers]["User-Agent"] = "tc-ruby/#{VERSION}" end diff --git a/core/lib/testcontainers/docker_client.rb b/core/lib/testcontainers/docker_client.rb new file mode 100644 index 0000000..b53cd43 --- /dev/null +++ b/core/lib/testcontainers/docker_client.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "java-properties" +require_relative "version" + +module Testcontainers + module DockerClient + module_function + + def connection + configure + Docker.connection + end + + def configure + configure_from_properties unless current_connection + configure_user_agent + end + + def current_connection + Docker.instance_variable_get(:@connection) + end + + def configure_from_properties + properties = load_properties + tc_host = ENV["TESTCONTAINERS_HOST"] || properties[:"tc.host"] + Docker.url = tc_host if tc_host && !tc_host.empty? + end + + def load_properties + path = properties_path + return {} unless File.exist?(path) + + JavaProperties.load(path) + end + + def configure_user_agent + Docker.options ||= {} + Docker.options[:headers] ||= {} + Docker.options[:headers]["User-Agent"] ||= "tc-ruby/#{Testcontainers::VERSION}" + end + + def properties_path + File.expand_path("~/.testcontainers.properties") + end + + private_class_method :configure_from_properties, :configure_user_agent, :properties_path, :load_properties + end +end diff --git a/core/lib/testcontainers/docker_container.rb b/core/lib/testcontainers/docker_container.rb index c357169..1d5e3c2 100644 --- a/core/lib/testcontainers/docker_container.rb +++ b/core/lib/testcontainers/docker_container.rb @@ -1,5 +1,3 @@ -require "java-properties" - module Testcontainers # The DockerContainer class is used to manage Docker containers. # It provides an interface to create, start, stop, and manipulate containers @@ -24,7 +22,7 @@ class DockerContainer attr_accessor :name, :image, :command, :entrypoint, :exposed_ports, :port_bindings, :volumes, :filesystem_binds, :env, :labels, :working_dir, :healthcheck, :wait_for attr_accessor :logger - attr_reader :_container, :_id + attr_reader :_container, :_id, :networks # Initializes a new DockerContainer instance. # @@ -62,6 +60,8 @@ def initialize(image, name: nil, command: nil, entrypoint: nil, exposed_ports: n @_container = nil @_id = nil @_created_at = nil + @networks = {} + @pending_network_aliases = [] end # Add environment variables to the container configuration. @@ -458,6 +458,34 @@ def with_wait_for(method = nil, *args, **kwargs, &block) self end + # Attach the container to a Docker network. + # + # @param network [String, Docker::Network, Testcontainers::Network] The network to attach to. + # @param aliases [Array, nil] Optional aliases for the container on this network. + # @return [DockerContainer] The updated DockerContainer instance. + def with_network(network, aliases: nil) + add_network(network, aliases: aliases) + self + end + + # Attach the container to multiple Docker networks. + # + # @param networks [Array] Networks to attach. + # @return [DockerContainer] The updated DockerContainer instance. + def with_networks(*networks) + networks.flatten.compact.each { |net| add_network(net) } + self + end + + # Assign aliases for the container on its primary network. + # + # @param aliases [Array] Aliases to add. + # @return [DockerContainer] The updated DockerContainer instance. + def with_network_aliases(*aliases) + add_network_aliases(aliases) + self + end + # Starts the container, yields the container instance to the block, and stops the container. # # @yield [DockerContainer] The container instance. @@ -475,17 +503,7 @@ def use # @raise [ConnectionError] If the connection to the Docker daemon fails. # @raise [NotFoundError] If Docker is unable to find the image. def start - expanded_path = File.expand_path("~/.testcontainers.properties") - - properties = File.exist?(expanded_path) ? JavaProperties.load(expanded_path) : {} - - tc_host = ENV["TESTCONTAINERS_HOST"] || properties[:"tc.host"] - - if tc_host && !tc_host.empty? - Docker.url = tc_host - end - - connection = Docker::Connection.new(Docker.url, Docker.options) + connection = Testcontainers::DockerClient.connection image_options = {"fromImage" => @image}.merge(@image_create_options) image_reference = (image_options["fromImage"] || image_options[:fromImage] || @image).to_s @@ -500,7 +518,9 @@ def start Docker::Image.create(image_options, connection) end - @_container ||= Docker::Container.create(_container_create_options) + ensure_networks_created + + @_container ||= Docker::Container.create(_container_create_options, connection) @_container.start @_id = @_container.id @@ -1101,11 +1121,13 @@ def process_env_input(env_or_key, value = nil) end def container_bridge_ip - @_container&.json&.dig("NetworkSettings", "Networks", "bridge", "IPAddress") + network_key = primary_network_name || "bridge" + @_container&.json&.dig("NetworkSettings", "Networks", network_key, "IPAddress") end def container_gateway_ip - @_container&.json&.dig("NetworkSettings", "Networks", "bridge", "Gateway") + network_key = primary_network_name || "bridge" + @_container&.json&.dig("NetworkSettings", "Networks", network_key, "Gateway") end def container_port(port) @@ -1157,11 +1179,109 @@ def _container_create_options "Labels" => @labels, "WorkingDir" => @working_dir, "Healthcheck" => @healthcheck, - "HostConfig" => { - "PortBindings" => @port_bindings, - "Binds" => @filesystem_binds - }.compact - }.compact + "HostConfig" => host_config_options.compact + }.compact.tap do |options| + networking = networking_config + options["NetworkingConfig"] = networking if networking + end + end + + def host_config_options + host_config = { + "PortBindings" => @port_bindings, + "Binds" => @filesystem_binds + } + + primary = primary_network_name + host_config["NetworkMode"] = primary if primary + + host_config + end + + def networking_config + return if @networks.nil? || @networks.empty? + + endpoints = {} + @networks.each do |name, config| + endpoint = {} + aliases = config[:aliases] + endpoint["Aliases"] = aliases if aliases && !aliases.empty? + endpoints[name] = endpoint + end + + return if endpoints.empty? + + {"EndpointsConfig" => endpoints} + end + + def primary_network_name + return nil if @networks.nil? || @networks.empty? + + @networks.keys.first + end + + def add_network(network, aliases: nil) + name, network_object = resolve_network(network) + @networks[name] ||= {aliases: [], object: network_object} + @networks[name][:object] ||= network_object if network_object + + new_aliases = normalize_aliases(aliases) + unless new_aliases.empty? + @networks[name][:aliases] = (@networks[name][:aliases] + new_aliases).uniq + end + + if @networks.length == 1 && @pending_network_aliases.any? + @networks[name][:aliases] = (@networks[name][:aliases] + @pending_network_aliases).uniq + @pending_network_aliases.clear + end + + self + end + + def add_network_aliases(aliases) + normalized = normalize_aliases(aliases) + return if normalized.empty? + + if @networks.nil? || @networks.empty? + @pending_network_aliases = (@pending_network_aliases + normalized).uniq + else + primary = primary_network_name + @networks[primary][:aliases] = (@networks[primary][:aliases] + normalized).uniq + end + end + + def normalize_aliases(aliases) + Array(aliases).flatten.compact.filter_map do |alias_value| + value = alias_value.to_s.strip + value unless value.empty? + end.uniq + end + + def resolve_network(network) + case network + when Testcontainers::Network + network.create + [network.name, network] + when Docker::Network + info = network.info || {} + name = info["Name"] || network.id + [name, network] + when String + [network, nil] + else + raise ArgumentError, "Unsupported network type: #{network.inspect}" + end + end + + def ensure_networks_created + return if @networks.nil? || @networks.empty? + + @networks.each_value do |config| + network_object = config[:object] + next unless network_object + + network_object.create if network_object.respond_to?(:create) + end end end diff --git a/core/lib/testcontainers/network.rb b/core/lib/testcontainers/network.rb new file mode 100644 index 0000000..af7747a --- /dev/null +++ b/core/lib/testcontainers/network.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "securerandom" +module Testcontainers + # Lightweight wrapper for Docker networks with convenience helpers + class Network + DEFAULT_DRIVER = "bridge" + SHARED_NAME = "testcontainers-shared-network" + + class << self + def new_network(name: nil, driver: DEFAULT_DRIVER, options: {}) + network = build(name: name, driver: driver, options: options) + network.create + network + end + + def shared + SHARED + end + + def generate_name + "testcontainers-network-#{SecureRandom.uuid}" + end + + private + + def build(name: nil, driver: DEFAULT_DRIVER, options: {}, shared: false) + network = new(name: name, driver: driver, options: options) + if shared + network.instance_variable_set(:@shared, true) + network.send(:register_shared_cleanup) + end + network + end + end + + attr_reader :name, :driver, :options + + def initialize(name: nil, driver: DEFAULT_DRIVER, options: {}) + @shared = false + @name = name || default_name + @driver = driver + @options = options + @mutex = Mutex.new + @docker_network = nil + end + + def create + @mutex.synchronize do + return @docker_network if @docker_network + + payload = {"Driver" => @driver, "CheckDuplicate" => true} + payload["Options"] = @options if @options && !@options.empty? + connection = Testcontainers::DockerClient.connection + @docker_network = Docker::Network.create(@name, payload, connection) + end + end + + def docker_network + create unless @docker_network + @docker_network + end + + def created? + !!@docker_network + end + + def info + docker_network.json + end + + def close(force: false) + return self if shared? && !force + + @mutex.synchronize do + return unless @docker_network + + begin + force ? @docker_network.delete : @docker_network.remove + rescue Docker::Error::NotFoundError + # Swallow missing network errors so cleanup stays idempotent + ensure + @docker_network = nil + end + end + end + + def force_close + close(force: true) + end + + def shared? + @shared + end + + private + + def default_name + shared? ? SHARED_NAME : self.class.generate_name + end + + def register_shared_cleanup + return if self.class.instance_variable_get(:@shared_cleanup_registered) + + at_exit { force_close } + self.class.instance_variable_set(:@shared_cleanup_registered, true) + end + end + + Network::SHARED = Network.__send__(:build, name: Network::SHARED_NAME, shared: true) +end diff --git a/core/test/docker_client_test.rb b/core/test/docker_client_test.rb new file mode 100644 index 0000000..8180fad --- /dev/null +++ b/core/test/docker_client_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "test_helper" +require "tmpdir" + +class DockerClientTest < TestcontainersTest + def setup + super + @original_env = ENV["TESTCONTAINERS_HOST"] + ENV.delete("TESTCONTAINERS_HOST") + Docker.reset! + end + + def teardown + Docker.reset! + if @original_env + ENV["TESTCONTAINERS_HOST"] = @original_env + else + ENV.delete("TESTCONTAINERS_HOST") + end + super + end + + def test_connection_configures_user_agent_and_env_host + ENV["TESTCONTAINERS_HOST"] = "tcp://example.test:2375" + fake_connection = Object.new + + Docker::Connection.stub(:new, fake_connection) do + connection = Testcontainers::DockerClient.connection + assert_same fake_connection, connection + end + + assert_equal "tcp://example.test:2375", Docker.url + assert_equal "tc-ruby/#{Testcontainers::VERSION}", Docker.options[:headers]["User-Agent"] + end + + def test_connection_reads_properties_file_when_env_absent + Tempfile.create("tc-properties") do |file| + file.write("tc.host=tcp://from-properties:1234\n") + file.flush + + fake_connection = Object.new + Docker::Connection.stub(:new, fake_connection) do + Testcontainers::DockerClient.stub(:properties_path, file.path) do + Testcontainers::DockerClient.connection + end + end + + assert_equal "tcp://from-properties:1234", Docker.url + end + end + + def test_configure_preserves_existing_connection_but_sets_user_agent + existing_connection = Object.new + Docker.instance_variable_set(:@connection, existing_connection) + Docker.instance_variable_set(:@options, {}) + + connection = Testcontainers::DockerClient.connection + + assert_same existing_connection, connection + assert_equal "tc-ruby/#{Testcontainers::VERSION}", Docker.options[:headers]["User-Agent"] + end +end diff --git a/core/test/docker_container_test.rb b/core/test/docker_container_test.rb index fa0614d..e8af6a5 100644 --- a/core/test/docker_container_test.rb +++ b/core/test/docker_container_test.rb @@ -78,6 +78,61 @@ def test_it_uses_locally_built_image_before_pulling end end + def test_it_attaches_to_custom_network_with_aliases + network = Testcontainers::Network.new_network + container = Testcontainers::DockerContainer.new("alpine:latest", command: %w[sleep 60]) + .with_network(network, aliases: ["web"]) + + container.start + + network_info = container.info.dig("NetworkSettings", "Networks", network.name) + assert network_info + assert_includes network_info["Aliases"], "web" + assert_equal network.name, container.info.dig("HostConfig", "NetworkMode") + ensure + container&.stop! if container&.running? + container&.remove if container&.exists? + network&.force_close + end + + def test_it_applies_pending_aliases_before_network_assignment + network = Testcontainers::Network.new_network + container = Testcontainers::DockerContainer.new("alpine:latest", command: %w[sleep 60]) + container.with_network_aliases("app") + container.with_network(network) + + container.start + + network_info = container.info.dig("NetworkSettings", "Networks", network.name) + assert_includes network_info["Aliases"], "app" + ensure + container&.stop! if container&.running? + container&.remove if container&.exists? + network&.force_close + end + + def test_it_attaches_to_multiple_networks + primary_network = Testcontainers::Network.new_network + secondary_network = Testcontainers::Network.new_network + container = Testcontainers::DockerContainer + .new("alpine:latest", command: %w[sleep 60]) + .with_network(primary_network, aliases: ["primary"]) + .with_network(secondary_network, aliases: ["secondary"]) + + container.start + + networks = container.info.fetch("NetworkSettings").fetch("Networks") + assert_includes networks.keys, primary_network.name + assert_includes networks.keys, secondary_network.name + assert_includes networks[primary_network.name]["Aliases"], "primary" + assert_includes networks[secondary_network.name]["Aliases"], "secondary" + ensure + container&.stop! if container&.running? + container&.remove if container&.exists? + primary_network&.force_close + secondary_network&.force_close + end + def test_it_returns_the_container_image assert_equal "hello-world", @container.image end diff --git a/core/test/network_test.rb b/core/test/network_test.rb new file mode 100644 index 0000000..f432d8f --- /dev/null +++ b/core/test/network_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "test_helper" + +class NetworkTest < TestcontainersTest + def test_new_network_creates_docker_network + network = Testcontainers::Network.new_network + + assert network.created? + docker_network = Docker::Network.get(network.name) + assert_equal network.name, docker_network.info["Name"] + ensure + network&.force_close + end + + def test_close_is_idempotent + network = Testcontainers::Network.new_network + network.force_close + + assert_raises(Docker::Error::NotFoundError) { Docker::Network.get(network.name) } + network.close + ensure + network&.force_close + end + + def test_shared_network_is_singleton + shared = Testcontainers::Network::SHARED + + assert_same shared, Testcontainers::Network.shared + assert shared.shared? + assert_equal "testcontainers-shared-network", shared.name + ensure + shared.force_close + end + + def test_shared_network_close_is_noop + shared = Testcontainers::Network::SHARED + shared.force_close + + shared.create + shared.close + + docker_network = Docker::Network.get(shared.name) + assert_equal shared.name, docker_network.info["Name"] + ensure + shared.force_close + end + + def test_shared_network_force_close_removes_network + shared = Testcontainers::Network::SHARED + shared.create + + shared.force_close + + assert_raises(Docker::Error::NotFoundError) { Docker::Network.get(shared.name) } + ensure + shared.force_close + end +end diff --git a/core/test/testcontainers_test.rb b/core/test/testcontainers_test.rb index f0ed0c7..e2b07fc 100644 --- a/core/test/testcontainers_test.rb +++ b/core/test/testcontainers_test.rb @@ -8,6 +8,7 @@ def test_that_it_has_a_version_number end def test_that_it_sets_user_agent_header + Testcontainers::DockerClient.configure assert_equal "tc-ruby/#{::Testcontainers::VERSION}", Docker.options[:headers]["User-Agent"] end end diff --git a/docs/QuickStart.md b/docs/QuickStart.md index 613027e..ff69654 100644 --- a/docs/QuickStart.md +++ b/docs/QuickStart.md @@ -71,3 +71,32 @@ end With these changes, your test suite will automatically start a new Redis container for each test, ensuring a clean and isolated environment. The container will be stopped after each test is completed. You can re-use containers between tests as well (e.g using `after(:suite)` / `before(:suite)` blocks in RSpec). Take a look to the files [examples/redis_backed_cache_minitest.rb](https://github.com/testcontainers/testcontainers-ruby/blob/main/examples/redis_backed_cache_minitest.rb) and [examples/redis_backed_cache_rspec.rb](https://github.com/testcontainers/testcontainers-ruby/blob/main/examples/redis_backed_cache_rspec.rb) for full examples. + +## Configuring the Docker client + +Testcontainers automatically configures the Docker client the first time it is needed. It sets a descriptive `User-Agent` header and looks for the Docker daemon URL in the following order: + +1. `ENV["TESTCONTAINERS_HOST"]` +2. `~/.testcontainers.properties` (`tc.host` key) +3. Docker’s defaults (e.g. `/var/run/docker.sock`) + +If you need to configure the client explicitly before creating containers—for example in a test helper—you can call: + +```ruby +Testcontainers::DockerClient.configure +``` + +You may also update the environment variable or properties file before invoking `configure` to point at a remote Docker host. + +### Shared container networks + +When multiple containers need to communicate, you can attach them to the built-in shared network: + +```ruby +network = Testcontainers::Network::SHARED + +redis = Testcontainers::DockerContainer.new("redis:6.2-alpine").with_network(network) +nginx = Testcontainers::DockerContainer.new("nginx:alpine").with_network(network) +``` + +`Testcontainers::Network::SHARED` is a singleton that remains available for the lifetime of your test suite, and the library cleans it up automatically when the process exits. You can still create additional ad-hoc networks with `Testcontainers::Network.new_network` when you need isolated environments.