Skip to content

Add support for .with_network and .with_network_aliases #84

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions core/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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!
Expand Down
1 change: 1 addition & 0 deletions core/lib/testcontainers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require "open3"
require "uri"
require "testcontainers/docker_container"
require "testcontainers/network"
require_relative "testcontainers/version"

module Testcontainers
Expand Down
101 changes: 82 additions & 19 deletions core/lib/testcontainers/docker_container.rb
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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

Expand All @@ -39,9 +54,11 @@ class DockerContainer
# @param env [Array<String>, 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<String>, 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
Expand All @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -229,7 +248,7 @@ def add_healthcheck(options = {})
test = options[:test]

if test.nil?
@healthcheck = {"Test" => ["NONE"]}
@healthcheck = { "Test" => ["NONE"] }
return @healthcheck
end

Expand Down Expand Up @@ -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<String>] 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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -1082,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)
Expand Down Expand Up @@ -1126,6 +1172,10 @@ def docker_host
nil
end

def network_name
@network_name ||= network&.name
end

def _container_create_options
{
"name" => @name,
Expand All @@ -1139,11 +1189,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
Expand Down
72 changes: 72 additions & 0 deletions core/lib/testcontainers/network.rb
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions core/test/docker_container_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading