Skip to content

Commit 01f921a

Browse files
committed
Refactor Testcontainers::Network to make it more Ruby idiomatic
1 parent 1232e6f commit 01f921a

5 files changed

Lines changed: 350 additions & 58 deletions

File tree

core/lib/testcontainers/docker_container.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1271,7 +1271,7 @@ def normalize_aliases(aliases)
12711271
def resolve_network(network)
12721272
case network
12731273
when Testcontainers::Network
1274-
network.create
1274+
network.create!
12751275
[network.name, network]
12761276
when Docker::Network
12771277
info = network.info || {}

core/lib/testcontainers/network.rb

Lines changed: 153 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,220 @@
11
# frozen_string_literal: true
22

33
require "securerandom"
4+
require "singleton"
5+
require "forwardable"
6+
47
module Testcontainers
8+
# Custom error classes for network operations
9+
class NetworkError < StandardError; end
10+
class NetworkNotFoundError < NetworkError; end
11+
class NetworkAlreadyExistsError < NetworkError; end
12+
class NetworkInUseError < NetworkError; end
13+
514
# Lightweight wrapper for Docker networks with convenience helpers
615
class Network
16+
extend Forwardable
17+
include Enumerable
718
DEFAULT_DRIVER = "bridge"
819
SHARED_NAME = "testcontainers-shared-network"
920

21+
# Delegate methods to the underlying Docker::Network object
22+
def_delegators :docker_network, :id, :json
23+
1024
class << self
11-
def new_network(name: nil, driver: DEFAULT_DRIVER, options: {})
12-
network = build(name: name, driver: driver, options: options)
13-
network.create
14-
network
25+
# Creates and initializes a new Docker network
26+
#
27+
# @param name [String, nil] Custom network name (auto-generated if nil)
28+
# @param driver [String] Network driver (default: "bridge")
29+
# @param options [Hash] Additional Docker network options
30+
# @yield [Network] Optionally yields the network for block-based resource management
31+
# @return [Network] The created network (or result of block if given)
32+
#
33+
# @example Basic usage
34+
# network = Network.create(name: "my-network")
35+
#
36+
# @example Block-based usage with automatic cleanup
37+
# Network.create(name: "test-net") do |network|
38+
# container.with_network(network).start
39+
# # Run tests...
40+
# end # network automatically closed
41+
def create(name: nil, driver: DEFAULT_DRIVER, options: {})
42+
network = new(name: name, driver: driver, options: options)
43+
network.create!
44+
45+
if block_given?
46+
begin
47+
yield network
48+
ensure
49+
network.close
50+
end
51+
else
52+
network
53+
end
1554
end
1655

56+
# Returns the singleton shared network instance
57+
#
58+
# @return [SharedNetwork] The shared network singleton
1759
def shared
18-
SHARED
60+
SharedNetwork.instance
1961
end
2062

63+
# Generates a unique network name
64+
#
65+
# @return [String] A unique network name
2166
def generate_name
2267
"testcontainers-network-#{SecureRandom.uuid}"
2368
end
24-
25-
private
26-
27-
def build(name: nil, driver: DEFAULT_DRIVER, options: {}, shared: false)
28-
network = new(name: name, driver: driver, options: options)
29-
if shared
30-
network.instance_variable_set(:@shared, true)
31-
network.send(:register_shared_cleanup)
32-
end
33-
network
34-
end
3569
end
3670

3771
attr_reader :name, :driver, :options
3872

3973
def initialize(name: nil, driver: DEFAULT_DRIVER, options: {})
40-
@shared = false
41-
@name = name || default_name
74+
@name = name || self.class.generate_name
4275
@driver = driver
4376
@options = options
4477
@mutex = Mutex.new
4578
@docker_network = nil
4679
end
4780

48-
def create
81+
# Creates the Docker network (idempotent)
82+
#
83+
# @return [self] Returns self for method chaining
84+
# @raise [NetworkAlreadyExistsError] if network with same name already exists
85+
def create!
4986
@mutex.synchronize do
50-
return @docker_network if @docker_network
51-
52-
payload = {"Driver" => @driver, "CheckDuplicate" => true}
53-
payload["Options"] = @options if @options && !@options.empty?
54-
connection = Testcontainers::DockerClient.connection
55-
@docker_network = Docker::Network.create(@name, payload, connection)
87+
@docker_network ||= begin
88+
payload = {
89+
"Driver" => @driver,
90+
"CheckDuplicate" => true,
91+
"Options" => @options.empty? ? nil : @options
92+
}.compact
93+
94+
connection = Testcontainers::DockerClient.connection
95+
Docker::Network.create(@name, payload, connection)
96+
rescue Docker::Error::ConflictError => e
97+
raise NetworkAlreadyExistsError, "Network '#{@name}' already exists: #{e.message}"
98+
end
5699
end
100+
101+
self
57102
end
58103

104+
# Returns the underlying Docker::Network object, creating it if necessary
105+
#
106+
# @return [Docker::Network] The Docker network object
59107
def docker_network
60-
create unless @docker_network
61-
@docker_network
108+
@mutex.synchronize do
109+
@docker_network || create!.instance_variable_get(:@docker_network)
110+
end
62111
end
63112

113+
# Checks if the network has been created
114+
#
115+
# @return [Boolean] true if network is created, false otherwise
64116
def created?
65-
!!@docker_network
117+
!@docker_network.nil?
66118
end
67119

120+
# Returns network information from Docker
121+
#
122+
# @return [Hash] Network information
68123
def info
69124
docker_network.json
70125
end
71126

127+
# Iterates over containers connected to this network
128+
#
129+
# @yield [Hash] Container information for each connected container
130+
# @return [Enumerator] if no block is given
131+
#
132+
# @example
133+
# network.each { |container| puts container["Name"] }
134+
# network.map { |c| c["IPv4Address"] }
135+
def each(&block)
136+
return to_enum(:each) unless block_given?
137+
138+
containers.each(&block)
139+
end
140+
141+
# Returns containers connected to this network
142+
#
143+
# @return [Array<Hash>] Array of container information hashes
144+
def containers
145+
info.dig("Containers")&.values || []
146+
end
147+
148+
# Closes and removes the network (idempotent)
149+
#
150+
# @param force [Boolean] If true, forcefully removes the network
151+
# @return [self] Returns self for method chaining
152+
# @raise [NetworkInUseError] if network is in use and force is false
72153
def close(force: false)
73154
return self if shared? && !force
74155

75156
@mutex.synchronize do
76-
return unless @docker_network
77-
78-
begin
79-
force ? @docker_network.delete : @docker_network.remove
80-
rescue Docker::Error::NotFoundError
81-
# Swallow missing network errors so cleanup stays idempotent
82-
ensure
83-
@docker_network = nil
157+
@docker_network&.tap do |net|
158+
begin
159+
removal_method = force ? :delete : :remove
160+
net.public_send(removal_method)
161+
rescue Docker::Error::NotFoundError
162+
# Swallow missing network errors so cleanup stays idempotent
163+
rescue Docker::Error::ConflictError, Excon::Error::Forbidden => e
164+
raise NetworkInUseError, "Network '#{@name}' is in use: #{e.message}"
165+
end
84166
end
167+
ensure
168+
@docker_network = nil
85169
end
170+
171+
self
86172
end
87173

174+
# Forcefully closes and removes the network
175+
#
176+
# @return [self] Returns self for method chaining
88177
def force_close
89178
close(force: true)
90179
end
91180

181+
# Checks if this is the shared singleton network
182+
#
183+
# @return [Boolean] true if this is the shared network
92184
def shared?
93-
@shared
185+
false
94186
end
95187

96-
private
188+
# Alias for close (more explicit naming)
189+
alias_method :destroy, :close
190+
alias_method :remove, :close
191+
end
192+
193+
# Singleton shared network for multi-container test scenarios
194+
#
195+
# @example Using the shared network
196+
# shared = Network.shared
197+
# container1.with_network(shared, aliases: ["service1"])
198+
# container2.with_network(shared, aliases: ["service2"])
199+
class SharedNetwork < Network
200+
include Singleton
201+
202+
def initialize
203+
super(name: SHARED_NAME)
204+
register_cleanup
205+
end
97206

98-
def default_name
99-
shared? ? SHARED_NAME : self.class.generate_name
207+
def shared?
208+
true
100209
end
101210

102-
def register_shared_cleanup
103-
return if self.class.instance_variable_get(:@shared_cleanup_registered)
211+
private
104212

213+
def register_cleanup
105214
at_exit { force_close }
106-
self.class.instance_variable_set(:@shared_cleanup_registered, true)
107215
end
108216
end
109217

110-
Network::SHARED = Network.__send__(:build, name: Network::SHARED_NAME, shared: true)
218+
# Backward compatibility: SHARED constant points to the singleton instance
219+
Network::SHARED = SharedNetwork.instance
111220
end

core/test/docker_container_test.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def test_it_uses_locally_built_image_before_pulling
7979
end
8080

8181
def test_it_attaches_to_custom_network_with_aliases
82-
network = Testcontainers::Network.new_network
82+
network = Testcontainers::Network.create
8383
container = Testcontainers::DockerContainer.new("alpine:latest", command: %w[sleep 60])
8484
.with_network(network, aliases: ["web"])
8585

@@ -96,7 +96,7 @@ def test_it_attaches_to_custom_network_with_aliases
9696
end
9797

9898
def test_it_applies_pending_aliases_before_network_assignment
99-
network = Testcontainers::Network.new_network
99+
network = Testcontainers::Network.create
100100
container = Testcontainers::DockerContainer.new("alpine:latest", command: %w[sleep 60])
101101
container.with_network_aliases("app")
102102
container.with_network(network)
@@ -112,8 +112,8 @@ def test_it_applies_pending_aliases_before_network_assignment
112112
end
113113

114114
def test_it_attaches_to_multiple_networks
115-
primary_network = Testcontainers::Network.new_network
116-
secondary_network = Testcontainers::Network.new_network
115+
primary_network = Testcontainers::Network.create
116+
secondary_network = Testcontainers::Network.create
117117
container = Testcontainers::DockerContainer
118118
.new("alpine:latest", command: %w[sleep 60])
119119
.with_network(primary_network, aliases: ["primary"])

0 commit comments

Comments
 (0)