From 31bbef64f79d71a67ddcd5e646663ec75fa0a60b Mon Sep 17 00:00:00 2001 From: "Dieter S." <101627195+dieter-medium@users.noreply.github.com> Date: Wed, 7 May 2025 16:54:05 +0200 Subject: [PATCH 1/2] Add support for .with_network and .with_network_aliases --- core/Gemfile.lock | 4 + core/lib/testcontainers.rb | 1 + core/lib/testcontainers/docker_container.rb | 93 ++++++++++++--- core/lib/testcontainers/network.rb | 72 ++++++++++++ core/test/docker_container_test.rb | 39 +++++++ core/test/network_test.rb | 121 ++++++++++++++++++++ core/testcontainers-core.gemspec | 2 + 7 files changed, 315 insertions(+), 17 deletions(-) create mode 100644 core/lib/testcontainers/network.rb create mode 100644 core/test/network_test.rb diff --git a/core/Gemfile.lock b/core/Gemfile.lock index 18d67af..0fae028 100644 --- a/core/Gemfile.lock +++ b/core/Gemfile.lock @@ -9,6 +9,7 @@ GEM remote: https://rubygems.org/ specs: ast (2.4.2) + base64 (0.2.0) docker-api (2.2.0) excon (>= 0.47.0) multi_json @@ -21,6 +22,7 @@ GEM minitest-hooks (1.5.0) minitest (> 5.3) multi_json (1.15.0) + mutex_m (0.3.0) parallel (1.23.0) parser (3.2.2.1) ast (~> 2.4.1) @@ -63,8 +65,10 @@ PLATFORMS x86_64-linux DEPENDENCIES + base64 (~> 0.2.0) minitest (~> 5.0) minitest-hooks (~> 1.5) + mutex_m (~> 0.3.0) rake (~> 13.0) standard (~> 1.3) testcontainers-core! diff --git a/core/lib/testcontainers.rb b/core/lib/testcontainers.rb index 77701b2..805fad9 100644 --- a/core/lib/testcontainers.rb +++ b/core/lib/testcontainers.rb @@ -5,6 +5,7 @@ require "open3" require "uri" require "testcontainers/docker_container" +require "testcontainers/network" require_relative "testcontainers/version" module Testcontainers diff --git a/core/lib/testcontainers/docker_container.rb b/core/lib/testcontainers/docker_container.rb index a868025..ba0b102 100644 --- a/core/lib/testcontainers/docker_container.rb +++ b/core/lib/testcontainers/docker_container.rb @@ -1,6 +1,7 @@ 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 # using the Docker API. @@ -21,8 +22,22 @@ module Testcontainers # @attr_reader _container [Docker::Container, nil] the underlying Docker::Container object # @attr_reader _id [String, nil] the container's ID class DockerContainer + class << self + def setup_docker + 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 + end + end + attr_accessor :name, :image, :command, :entrypoint, :exposed_ports, :port_bindings, :volumes, :filesystem_binds, - :env, :labels, :working_dir, :healthcheck, :wait_for + :env, :labels, :working_dir, :healthcheck, :wait_for, :aliases, :network attr_accessor :logger attr_reader :_container, :_id @@ -39,9 +54,11 @@ class DockerContainer # @param env [Array, Hash, nil] an array or a hash of environment variables for the container in the format KEY=VALUE # @param labels [Hash, nil] a hash of labels to be applied to the container # @param working_dir [String, nil] the working directory for the container + # @param network [Testcontainers::Network, nil] the network to attach the container to + # @param aliases [Array, nil] the aliases for the container in the network # @param logger [Logger] a logger instance for the container def initialize(image, name: nil, command: nil, entrypoint: nil, exposed_ports: nil, image_create_options: {}, port_bindings: nil, volumes: nil, filesystem_binds: nil, - env: nil, labels: nil, working_dir: nil, healthcheck: nil, wait_for: nil, logger: Testcontainers.logger) + env: nil, labels: nil, working_dir: nil, healthcheck: nil, wait_for: nil, network: nil, aliases: nil, logger: Testcontainers.logger) @image = image @name = name @@ -61,6 +78,8 @@ def initialize(image, name: nil, command: nil, entrypoint: nil, exposed_ports: n @_container = nil @_id = nil @_created_at = nil + @aliases = aliases + @network = network end # Add environment variables to the container configuration. @@ -87,7 +106,7 @@ def add_exposed_port(port) @exposed_ports ||= {} @port_bindings ||= {} @exposed_ports[port] ||= {} - @port_bindings[port] ||= [{"HostPort" => ""}] + @port_bindings[port] ||= [{ "HostPort" => "" }] @exposed_ports end @@ -119,7 +138,7 @@ def add_fixed_exposed_port(container_port, host_port = nil) @exposed_ports ||= {} @port_bindings ||= {} @exposed_ports[container_port] = {} - @port_bindings[container_port] = [{"HostPort" => host_port.to_s}] + @port_bindings[container_port] = [{ "HostPort" => host_port.to_s }] @port_bindings end @@ -229,7 +248,7 @@ def add_healthcheck(options = {}) test = options[:test] if test.nil? - @healthcheck = {"Test" => ["NONE"]} + @healthcheck = { "Test" => ["NONE"] } return @healthcheck end @@ -457,6 +476,34 @@ def with_wait_for(method = nil, *args, **kwargs, &block) self end + # Returns the container's ID. + # + def id + @_id + end + + # Returns the container's aliases within the network. + # + def aliases + @aliases ||= [] + end + + # Sets the container's network. + # + # @param network [Testcontainers::Network] The network to attach the container to. + def with_network(network) + @network = network + self + end + + # Sets the container's network aliases. + # + # @param aliases [Array] The aliases for the container in the network. + def with_network_aliases(*aliases) + self.aliases += aliases&.flatten + self + end + # Starts the container, yields the container instance to the block, and stops the container. # # @yield [DockerContainer] The container instance. @@ -474,19 +521,13 @@ 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 + self.class.setup_docker connection = Docker::Connection.new(Docker.url, Docker.options) - Docker::Image.create({"fromImage" => @image}.merge(@image_create_options), connection) + @network&.create + + Docker::Image.create({ "fromImage" => @image }.merge(@image_create_options), connection) @_container ||= Docker::Container.create(_container_create_options) @_container.start @@ -516,6 +557,7 @@ def start def stop(force: false) raise ContainerNotStartedError unless @_container @_container.stop(force: force) + @network&.close self rescue Excon::Error::Socket => e raise ConnectionError, e.message @@ -1026,7 +1068,7 @@ def normalize_port_bindings(port_bindings) return port_bindings if port_bindings.is_a?(Hash) && port_bindings.values.all? { |v| v.is_a?(Array) } port_bindings.each_with_object({}) do |(container_port, host_port), hash| - hash[normalize_port(container_port)] = [{"HostPort" => host_port.to_s}] + hash[normalize_port(container_port)] = [{ "HostPort" => host_port.to_s }] end end @@ -1126,6 +1168,10 @@ def docker_host nil end + def network_name + @network_name ||= network&.name + end + def _container_create_options { "name" => @name, @@ -1139,11 +1185,24 @@ def _container_create_options "WorkingDir" => @working_dir, "Healthcheck" => @healthcheck, "HostConfig" => { + "NetworkMode" => network_name, "PortBindings" => @port_bindings, "Binds" => @filesystem_binds - }.compact + }.compact, + "NetworkingConfig": _networking_config }.compact end + + def _networking_config + return nil unless network_name && !aliases&.empty? + { + "EndpointsConfig" => { + network_name => { + "Aliases" => aliases + } + } + } + end end # Alias for forward-compatibility diff --git a/core/lib/testcontainers/network.rb b/core/lib/testcontainers/network.rb new file mode 100644 index 0000000..2f68be0 --- /dev/null +++ b/core/lib/testcontainers/network.rb @@ -0,0 +1,72 @@ +module Testcontainers + class Network + class << self + def new_network(name: nil, driver: "bridge", options: {}) + new(name: name, driver: driver, options: options) + end + end + + attr_reader :name, :driver, :options + + def initialize(name: nil, driver: "bridge", options: {}) + @name = name || SecureRandom.uuid + @driver = driver + @options = options + @network = nil + end + + def create(conn = Docker.connection) + return network if created? + + ::Testcontainers::DockerContainer.setup_docker + + @network = Docker::Network.create name, options, conn + @created = true + network + end + + def created? + @created + end + + def close + _close + end + + def info + network&.json + end + + private + + def network + @network + end + + def _close + return unless created? + + begin + network.remove(force: true) + @created = false + rescue Docker::Error::NotFoundError + # Network already removed + end + end + + SHARED = Testcontainers::Network.new_network + + def SHARED.close + # prevent closing the shared network + end + + # Should be called when the process exits + def SHARED.force_close + _close + end + + at_exit do + SHARED.force_close + end + end +end \ No newline at end of file diff --git a/core/test/docker_container_test.rb b/core/test/docker_container_test.rb index ae174dc..6b3694b 100644 --- a/core/test/docker_container_test.rb +++ b/core/test/docker_container_test.rb @@ -454,4 +454,43 @@ def test_it_copies_files_from_container_using_a_filepath tempfile.unlink tempfile.close end + + def test_it_connects_a_container_to_a_custom_network + network = Testcontainers::Network.new_network + + container = Testcontainers::DockerContainer.new("hello-world").with_network(network) + container.start + + assert container_connected?(container, container.network.name) + ensure + container.stop if container.exists? && container.running? + container.remove({ v: true }) if container.exists? + network&.close + end + + def test_it_sets_all_aliases_for_a_container + network = Testcontainers::Network.new_network + + container = Testcontainers::DockerContainer.new("hello-world") + .with_network(network) + .with_network_aliases("alias1", "alias2") + + container.start + + assert_equal ["alias1", "alias2"].sort, all_aliases(container).sort + ensure + container.stop if container.exists? && container.running? + container.remove({ v: true }) if container.exists? + network&.close + end + + def container_connected?(container, network_name) + networks = container.info["NetworkSettings"]["Networks"] || {} + networks.key?(network_name) + end + + def all_aliases(container) + networks = container.info.dig("NetworkSettings", "Networks") || {} + networks.values.flat_map { |cfg| cfg["Aliases"] }.compact.uniq + end end diff --git a/core/test/network_test.rb b/core/test/network_test.rb new file mode 100644 index 0000000..2e17860 --- /dev/null +++ b/core/test/network_test.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require "test_helper" + +class NetworkTest < TestcontainersTest + def before_all + super + end + + def after_all + super + end + + class WhenCreatingNetwork < NetworkTest + def setup + super + @network = Testcontainers::Network.new_network + end + + def teardown + @network.close + super + end + + def test_it_creates_a_network + assert_kind_of Docker::Network, @network.create + end + + def test_it_returns_created_true_when_a_network_is_created + @network.create + + assert @network.created? + end + + def test_it_creates_a_network_within_docker + refute network_exists?(@network.name) + + @network.create + + assert network_exists?(@network.name) + end + end + + class WhenClosingNetwork < NetworkTest + def setup + super + @network = Testcontainers::Network.new_network + @network.create + end + + def teardown + @network.close + Testcontainers::Network::SHARED.force_close + super + end + + def test_it_removes_the_network_when_closed + @network.create + + assert network_exists?(@network.name) + + @network.close + + refute network_exists?(@network.name) + end + + def test_it_does_not_remove_the_shared_network + shared_network = Testcontainers::Network::SHARED + shared_network.create + + assert network_exists?(shared_network.name) + + shared_network.close + + assert network_exists?(shared_network.name) + end + + def test_it_does_remove_shared_network_when_forced + shared_network = Testcontainers::Network::SHARED + shared_network.create + + assert network_exists?(shared_network.name) + + shared_network.force_close + + refute network_exists?(shared_network.name) + end + end + + class NetworkExitTest < NetworkTest + + def test_at_exit_closes_shared_network + name = "test_exit_#{SecureRandom.uuid}" + script = <<~RUBY + require "testcontainers" + Testcontainers::Network::SHARED.create + # no explicit close – rely on at_exit + + $stderr.puts Testcontainers::Network::SHARED.info + puts Testcontainers::Network::SHARED.name + RUBY + + stdout_s, stderr_s, status = Open3.capture3("ruby", "-e", script) + assert status.success? + + assert_match /"Name"\s+=>\s+"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"/, stderr_s + + refute network_exists?(stdout_s.chomp) + end + end + + def network_exists?(name) + begin + Docker::Network.get(name) + true + rescue Docker::Error::NotFoundError + false + end + end + +end diff --git a/core/testcontainers-core.gemspec b/core/testcontainers-core.gemspec index 7ee45a4..aee20de 100644 --- a/core/testcontainers-core.gemspec +++ b/core/testcontainers-core.gemspec @@ -37,6 +37,8 @@ Gem::Specification.new do |spec| spec.add_development_dependency "minitest", "~> 5.0" spec.add_development_dependency "minitest-hooks", "~> 1.5" spec.add_development_dependency "standard", "~> 1.3" + spec.add_development_dependency "base64", "~> 0.2.0" + spec.add_development_dependency "mutex_m", "~> 0.3.0" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html From a2ee1acb2203d008104ef37e1aea8048de0ddfc9 Mon Sep 17 00:00:00 2001 From: "Dieter S." <101627195+dieter-medium@users.noreply.github.com> Date: Sat, 10 May 2025 19:50:35 +0200 Subject: [PATCH 2/2] Refactor network IP and gateway lookup to use custom network name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the container_bridge_ip and container_gateway_ip helpers always assumed the default “bridge” network. This change updates those methods (and related logic in _networking_config) to reference @network.name when present, ensuring correct IP and gateway retrieval for user-specified networks in docker_container.rb. --- core/lib/testcontainers/docker_container.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/lib/testcontainers/docker_container.rb b/core/lib/testcontainers/docker_container.rb index ba0b102..b7bb303 100644 --- a/core/lib/testcontainers/docker_container.rb +++ b/core/lib/testcontainers/docker_container.rb @@ -1124,11 +1124,15 @@ def process_env_input(env_or_key, value = nil) end def container_bridge_ip - @_container&.json&.dig("NetworkSettings", "Networks", "bridge", "IPAddress") + network_settings&.dig("IPAddress") end def container_gateway_ip - @_container&.json&.dig("NetworkSettings", "Networks", "bridge", "Gateway") + network_settings&.dig("Gateway") + end + + def network_settings + @_container&.json&.dig("NetworkSettings", "Networks", network&.name || "bridge") end def container_port(port)