Skip to content

Commit 3f5a3c0

Browse files
Merge pull request #93 from testcontainers/docker-networks-support
Add support for .with_network and .with_network_aliases
2 parents dd19954 + 1b35d01 commit 3f5a3c0

File tree

9 files changed

+511
-27
lines changed

9 files changed

+511
-27
lines changed

core/lib/testcontainers.rb

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
require "logger"
55
require "open3"
66
require "uri"
7+
require "testcontainers/docker_client"
8+
require "testcontainers/network"
79
require "testcontainers/docker_container"
810
require_relative "testcontainers/version"
911

@@ -31,9 +33,4 @@ def logger
3133
@logger ||= Logger.new($stdout, level: :info)
3234
end
3335
end
34-
35-
# Configure Docker API with custom User-Agent
36-
Docker.options ||= {}
37-
Docker.options[:headers] ||= {}
38-
Docker.options[:headers]["User-Agent"] = "tc-ruby/#{VERSION}"
3936
end
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
require "java-properties"
4+
require_relative "version"
5+
6+
module Testcontainers
7+
module DockerClient
8+
module_function
9+
10+
def connection
11+
configure
12+
Docker.connection
13+
end
14+
15+
def configure
16+
configure_from_properties unless current_connection
17+
configure_user_agent
18+
end
19+
20+
def current_connection
21+
Docker.instance_variable_get(:@connection)
22+
end
23+
24+
def configure_from_properties
25+
properties = load_properties
26+
tc_host = ENV["TESTCONTAINERS_HOST"] || properties[:"tc.host"]
27+
Docker.url = tc_host if tc_host && !tc_host.empty?
28+
end
29+
30+
def load_properties
31+
path = properties_path
32+
return {} unless File.exist?(path)
33+
34+
JavaProperties.load(path)
35+
end
36+
37+
def configure_user_agent
38+
Docker.options ||= {}
39+
Docker.options[:headers] ||= {}
40+
Docker.options[:headers]["User-Agent"] ||= "tc-ruby/#{Testcontainers::VERSION}"
41+
end
42+
43+
def properties_path
44+
File.expand_path("~/.testcontainers.properties")
45+
end
46+
47+
private_class_method :configure_from_properties, :configure_user_agent, :properties_path, :load_properties
48+
end
49+
end

core/lib/testcontainers/docker_container.rb

Lines changed: 142 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
require "java-properties"
2-
31
module Testcontainers
42
# The DockerContainer class is used to manage Docker containers.
53
# It provides an interface to create, start, stop, and manipulate containers
@@ -24,7 +22,7 @@ class DockerContainer
2422
attr_accessor :name, :image, :command, :entrypoint, :exposed_ports, :port_bindings, :volumes, :filesystem_binds,
2523
:env, :labels, :working_dir, :healthcheck, :wait_for
2624
attr_accessor :logger
27-
attr_reader :_container, :_id
25+
attr_reader :_container, :_id, :networks
2826

2927
# Initializes a new DockerContainer instance.
3028
#
@@ -62,6 +60,8 @@ def initialize(image, name: nil, command: nil, entrypoint: nil, exposed_ports: n
6260
@_container = nil
6361
@_id = nil
6462
@_created_at = nil
63+
@networks = {}
64+
@pending_network_aliases = []
6565
end
6666

6767
# Add environment variables to the container configuration.
@@ -458,6 +458,34 @@ def with_wait_for(method = nil, *args, **kwargs, &block)
458458
self
459459
end
460460

461+
# Attach the container to a Docker network.
462+
#
463+
# @param network [String, Docker::Network, Testcontainers::Network] The network to attach to.
464+
# @param aliases [Array<String>, nil] Optional aliases for the container on this network.
465+
# @return [DockerContainer] The updated DockerContainer instance.
466+
def with_network(network, aliases: nil)
467+
add_network(network, aliases: aliases)
468+
self
469+
end
470+
471+
# Attach the container to multiple Docker networks.
472+
#
473+
# @param networks [Array<String, Docker::Network, Testcontainers::Network>] Networks to attach.
474+
# @return [DockerContainer] The updated DockerContainer instance.
475+
def with_networks(*networks)
476+
networks.flatten.compact.each { |net| add_network(net) }
477+
self
478+
end
479+
480+
# Assign aliases for the container on its primary network.
481+
#
482+
# @param aliases [Array<String>] Aliases to add.
483+
# @return [DockerContainer] The updated DockerContainer instance.
484+
def with_network_aliases(*aliases)
485+
add_network_aliases(aliases)
486+
self
487+
end
488+
461489
# Starts the container, yields the container instance to the block, and stops the container.
462490
#
463491
# @yield [DockerContainer] The container instance.
@@ -475,17 +503,7 @@ def use
475503
# @raise [ConnectionError] If the connection to the Docker daemon fails.
476504
# @raise [NotFoundError] If Docker is unable to find the image.
477505
def start
478-
expanded_path = File.expand_path("~/.testcontainers.properties")
479-
480-
properties = File.exist?(expanded_path) ? JavaProperties.load(expanded_path) : {}
481-
482-
tc_host = ENV["TESTCONTAINERS_HOST"] || properties[:"tc.host"]
483-
484-
if tc_host && !tc_host.empty?
485-
Docker.url = tc_host
486-
end
487-
488-
connection = Docker::Connection.new(Docker.url, Docker.options)
506+
connection = Testcontainers::DockerClient.connection
489507

490508
image_options = {"fromImage" => @image}.merge(@image_create_options)
491509
image_reference = (image_options["fromImage"] || image_options[:fromImage] || @image).to_s
@@ -500,7 +518,9 @@ def start
500518
Docker::Image.create(image_options, connection)
501519
end
502520

503-
@_container ||= Docker::Container.create(_container_create_options)
521+
ensure_networks_created
522+
523+
@_container ||= Docker::Container.create(_container_create_options, connection)
504524
@_container.start
505525

506526
@_id = @_container.id
@@ -1101,11 +1121,13 @@ def process_env_input(env_or_key, value = nil)
11011121
end
11021122

11031123
def container_bridge_ip
1104-
@_container&.json&.dig("NetworkSettings", "Networks", "bridge", "IPAddress")
1124+
network_key = primary_network_name || "bridge"
1125+
@_container&.json&.dig("NetworkSettings", "Networks", network_key, "IPAddress")
11051126
end
11061127

11071128
def container_gateway_ip
1108-
@_container&.json&.dig("NetworkSettings", "Networks", "bridge", "Gateway")
1129+
network_key = primary_network_name || "bridge"
1130+
@_container&.json&.dig("NetworkSettings", "Networks", network_key, "Gateway")
11091131
end
11101132

11111133
def container_port(port)
@@ -1157,11 +1179,109 @@ def _container_create_options
11571179
"Labels" => @labels,
11581180
"WorkingDir" => @working_dir,
11591181
"Healthcheck" => @healthcheck,
1160-
"HostConfig" => {
1161-
"PortBindings" => @port_bindings,
1162-
"Binds" => @filesystem_binds
1163-
}.compact
1164-
}.compact
1182+
"HostConfig" => host_config_options.compact
1183+
}.compact.tap do |options|
1184+
networking = networking_config
1185+
options["NetworkingConfig"] = networking if networking
1186+
end
1187+
end
1188+
1189+
def host_config_options
1190+
host_config = {
1191+
"PortBindings" => @port_bindings,
1192+
"Binds" => @filesystem_binds
1193+
}
1194+
1195+
primary = primary_network_name
1196+
host_config["NetworkMode"] = primary if primary
1197+
1198+
host_config
1199+
end
1200+
1201+
def networking_config
1202+
return if @networks.nil? || @networks.empty?
1203+
1204+
endpoints = {}
1205+
@networks.each do |name, config|
1206+
endpoint = {}
1207+
aliases = config[:aliases]
1208+
endpoint["Aliases"] = aliases if aliases && !aliases.empty?
1209+
endpoints[name] = endpoint
1210+
end
1211+
1212+
return if endpoints.empty?
1213+
1214+
{"EndpointsConfig" => endpoints}
1215+
end
1216+
1217+
def primary_network_name
1218+
return nil if @networks.nil? || @networks.empty?
1219+
1220+
@networks.keys.first
1221+
end
1222+
1223+
def add_network(network, aliases: nil)
1224+
name, network_object = resolve_network(network)
1225+
@networks[name] ||= {aliases: [], object: network_object}
1226+
@networks[name][:object] ||= network_object if network_object
1227+
1228+
new_aliases = normalize_aliases(aliases)
1229+
unless new_aliases.empty?
1230+
@networks[name][:aliases] = (@networks[name][:aliases] + new_aliases).uniq
1231+
end
1232+
1233+
if @networks.length == 1 && @pending_network_aliases.any?
1234+
@networks[name][:aliases] = (@networks[name][:aliases] + @pending_network_aliases).uniq
1235+
@pending_network_aliases.clear
1236+
end
1237+
1238+
self
1239+
end
1240+
1241+
def add_network_aliases(aliases)
1242+
normalized = normalize_aliases(aliases)
1243+
return if normalized.empty?
1244+
1245+
if @networks.nil? || @networks.empty?
1246+
@pending_network_aliases = (@pending_network_aliases + normalized).uniq
1247+
else
1248+
primary = primary_network_name
1249+
@networks[primary][:aliases] = (@networks[primary][:aliases] + normalized).uniq
1250+
end
1251+
end
1252+
1253+
def normalize_aliases(aliases)
1254+
Array(aliases).flatten.compact.filter_map do |alias_value|
1255+
value = alias_value.to_s.strip
1256+
value unless value.empty?
1257+
end.uniq
1258+
end
1259+
1260+
def resolve_network(network)
1261+
case network
1262+
when Testcontainers::Network
1263+
network.create
1264+
[network.name, network]
1265+
when Docker::Network
1266+
info = network.info || {}
1267+
name = info["Name"] || network.id
1268+
[name, network]
1269+
when String
1270+
[network, nil]
1271+
else
1272+
raise ArgumentError, "Unsupported network type: #{network.inspect}"
1273+
end
1274+
end
1275+
1276+
def ensure_networks_created
1277+
return if @networks.nil? || @networks.empty?
1278+
1279+
@networks.each_value do |config|
1280+
network_object = config[:object]
1281+
next unless network_object
1282+
1283+
network_object.create if network_object.respond_to?(:create)
1284+
end
11651285
end
11661286
end
11671287

0 commit comments

Comments
 (0)