Skip to content

Commit 81ba611

Browse files
committed
Add network support
1 parent e64bffe commit 81ba611

4 files changed

Lines changed: 350 additions & 3 deletions

File tree

lib/container.ex

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ defmodule Testcontainers.Container do
2525
container_id: nil,
2626
check_image: nil,
2727
network_mode: nil,
28+
network: nil,
29+
hostname: nil,
2830
reuse: false,
2931
force_reuse: false,
3032
pull_policy: Testcontainers.PullPolicy.always_pull()
@@ -259,6 +261,26 @@ defmodule Testcontainers.Container do
259261
%__MODULE__{config | network_mode: mode}
260262
end
261263

264+
@doc """
265+
Sets the Docker network for the container to join.
266+
267+
Containers on the same network can communicate with each other using their
268+
hostnames. Use `with_hostname/2` to set a custom hostname for the container.
269+
"""
270+
def with_network(%__MODULE__{} = config, network_name) when is_binary(network_name) do
271+
%__MODULE__{config | network: network_name}
272+
end
273+
274+
@doc """
275+
Sets the hostname for the container.
276+
277+
This is useful when containers need to communicate with each other by hostname
278+
on a shared Docker network.
279+
"""
280+
def with_hostname(%__MODULE__{} = config, hostname) when is_binary(hostname) do
281+
%__MODULE__{config | hostname: hostname}
282+
end
283+
262284
@doc """
263285
Gets the host port on the container for the given exposed port.
264286
"""

lib/docker/api.ex

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,68 @@ defmodule Testcontainers.Docker.Api do
182182
end
183183
end
184184

185+
@doc """
186+
Creates a Docker network.
187+
"""
188+
def create_network(name, conn, opts \\ []) when is_binary(name) do
189+
driver = Keyword.get(opts, :driver, "bridge")
190+
191+
body = %DockerEngineAPI.Model.NetworkCreateRequest{
192+
Name: name,
193+
Driver: driver,
194+
CheckDuplicate: true
195+
}
196+
197+
case Api.Network.network_create(conn, body) do
198+
{:ok, %DockerEngineAPI.Model.NetworkCreateResponse{Id: id}} ->
199+
{:ok, id}
200+
201+
{:ok, %DockerEngineAPI.Model.ErrorResponse{message: message}} ->
202+
{:error, {:failed_to_create_network, message}}
203+
204+
{:error, %Tesla.Env{status: 409}} ->
205+
{:ok, :already_exists}
206+
207+
{:error, %Tesla.Env{status: status}} ->
208+
{:error, {:http_error, status}}
209+
210+
{:error, reason} ->
211+
{:error, reason}
212+
end
213+
end
214+
215+
@doc """
216+
Removes a Docker network.
217+
"""
218+
def remove_network(name, conn) when is_binary(name) do
219+
case Api.Network.network_delete(conn, name) do
220+
{:ok, %Tesla.Env{status: 204}} ->
221+
:ok
222+
223+
{:ok, %Tesla.Env{status: 404}} ->
224+
{:error, :network_not_found}
225+
226+
{:ok, %DockerEngineAPI.Model.ErrorResponse{message: message}} ->
227+
{:error, {:failed_to_remove_network, message}}
228+
229+
{:error, %Tesla.Env{status: status}} ->
230+
{:error, {:http_error, status}}
231+
232+
{:error, reason} ->
233+
{:error, reason}
234+
end
235+
end
236+
237+
@doc """
238+
Checks if a network exists.
239+
"""
240+
def network_exists?(name, conn) when is_binary(name) do
241+
case Api.Network.network_inspect(conn, name) do
242+
{:ok, %DockerEngineAPI.Model.Network{}} -> true
243+
_ -> false
244+
end
245+
end
246+
185247
def tag_image(image, repo, tag, conn) do
186248
case Api.Image.image_tag(conn, image, repo: repo, tag: tag) do
187249
{:ok, %Tesla.Env{status: 201}} ->
@@ -203,21 +265,35 @@ defmodule Testcontainers.Docker.Api do
203265
end
204266

205267
defp container_create_request(%Container{} = container_config) do
206-
%DockerEngineAPI.Model.ContainerCreateRequest{
268+
base_request = %DockerEngineAPI.Model.ContainerCreateRequest{
207269
Image: container_config.image,
208270
Cmd: container_config.cmd,
209271
ExposedPorts: map_exposed_ports(container_config),
210272
Env: map_env(container_config),
211273
Labels: container_config.labels,
274+
Hostname: container_config.hostname,
212275
HostConfig: %HostConfig{
213276
AutoRemove: container_config.auto_remove,
214277
PortBindings: map_port_bindings(container_config),
215278
Privileged: container_config.privileged,
216279
Binds: map_binds(container_config),
217280
Mounts: map_volumes(container_config),
218-
NetworkMode: container_config.network_mode
281+
NetworkMode: container_config.network_mode || container_config.network
219282
}
220283
}
284+
285+
# Add NetworkingConfig if a network is specified
286+
if container_config.network do
287+
endpoint_config = %{
288+
container_config.network => %DockerEngineAPI.Model.EndpointSettings{}
289+
}
290+
291+
Map.put(base_request, :NetworkingConfig, %DockerEngineAPI.Model.NetworkingConfig{
292+
EndpointsConfig: endpoint_config
293+
})
294+
else
295+
base_request
296+
end
221297
end
222298

223299
defp map_exposed_ports(%Container{} = container_config) do
@@ -264,6 +340,38 @@ defmodule Testcontainers.Docker.Api do
264340
end)
265341
end
266342

343+
defp from(%DockerEngineAPI.Model.ContainerInspectResponse{
344+
Id: container_id,
345+
Image: image,
346+
NetworkSettings: %{IPAddress: ip_address, Ports: ports, Networks: networks},
347+
Config: %{Env: env, Labels: labels}
348+
}) do
349+
# For custom networks, the IP address is in Networks.<network_name>.IPAddress
350+
# The default bridge IPAddress will be empty for custom networks
351+
resolved_ip = resolve_ip_address(ip_address, networks)
352+
353+
%Container{
354+
container_id: container_id,
355+
image: image,
356+
labels: labels,
357+
ip_address: resolved_ip,
358+
exposed_ports:
359+
Enum.reduce(ports || [], [], fn {key, ports}, acc ->
360+
acc ++
361+
Enum.map(ports || [], fn %{"HostPort" => host_port} ->
362+
{key |> String.replace("/tcp", "") |> String.to_integer(),
363+
host_port |> String.to_integer()}
364+
end)
365+
end),
366+
environment:
367+
Enum.reduce(env || [], %{}, fn env, acc ->
368+
tokens = String.split(env, "=")
369+
Map.merge(acc, %{"#{List.first(tokens)}": List.last(tokens)})
370+
end)
371+
}
372+
end
373+
374+
# Also handle when Networks key is missing
267375
defp from(%DockerEngineAPI.Model.ContainerInspectResponse{
268376
Id: container_id,
269377
Image: image,
@@ -291,6 +399,25 @@ defmodule Testcontainers.Docker.Api do
291399
}
292400
end
293401

402+
# Resolve IP address, preferring custom network IPs if default is empty
403+
defp resolve_ip_address(nil, networks), do: get_ip_from_networks(networks)
404+
defp resolve_ip_address("", networks), do: get_ip_from_networks(networks)
405+
defp resolve_ip_address(ip, _networks) when is_binary(ip) and ip != "", do: ip
406+
407+
defp get_ip_from_networks(nil), do: nil
408+
409+
defp get_ip_from_networks(networks) when is_map(networks) do
410+
# Get the first non-empty IP from any network
411+
networks
412+
|> Enum.find_value(fn
413+
{_name, %{IPAddress: ip}} when is_binary(ip) and ip != "" -> ip
414+
{_name, %{"IPAddress" => ip}} when is_binary(ip) and ip != "" -> ip
415+
_ -> nil
416+
end)
417+
end
418+
419+
defp get_ip_from_networks(_), do: nil
420+
294421
defp create_exec(container_id, command, conn) do
295422
data = %ExecConfig{Cmd: command}
296423

lib/testcontainers.ex

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ defmodule Testcontainers do
8181
conn: conn,
8282
docker_hostname: docker_hostname,
8383
session_id: session_id,
84-
properties: properties
84+
properties: properties,
85+
networks: MapSet.new()
8586
}}
8687
else
8788
error ->
@@ -145,6 +146,44 @@ defmodule Testcontainers do
145146
wait_for_call({:stop_container, container_id}, name)
146147
end
147148

149+
@doc """
150+
Creates a Docker network.
151+
152+
Networks allow containers to communicate with each other using hostnames.
153+
Use `Container.with_network/2` to attach a container to a network.
154+
155+
## Parameters
156+
157+
- `network_name`: The name of the network to create.
158+
- `name`: The name of the Testcontainers GenServer (defaults to `Testcontainers`).
159+
160+
## Returns
161+
162+
- `{:ok, network_id}` if the network is created successfully.
163+
- `{:ok, :already_exists}` if the network already exists.
164+
- `{:error, reason}` on failure.
165+
"""
166+
def create_network(network_name, name \\ __MODULE__) when is_binary(network_name) do
167+
wait_for_call({:create_network, network_name}, name)
168+
end
169+
170+
@doc """
171+
Removes a Docker network.
172+
173+
## Parameters
174+
175+
- `network_name`: The name of the network to remove.
176+
- `name`: The name of the Testcontainers GenServer (defaults to `Testcontainers`).
177+
178+
## Returns
179+
180+
- `:ok` if the network is removed successfully.
181+
- `{:error, reason}` on failure.
182+
"""
183+
def remove_network(network_name, name \\ __MODULE__) when is_binary(network_name) do
184+
wait_for_call({:remove_network, network_name}, name)
185+
end
186+
148187
@impl true
149188
def handle_info(_msg, state) do
150189
{:noreply, state}
@@ -170,6 +209,26 @@ defmodule Testcontainers do
170209
{:reply, state.docker_hostname, state}
171210
end
172211

212+
@impl true
213+
def handle_call({:create_network, network_name}, from, state) do
214+
Task.async(fn ->
215+
result = Api.create_network(network_name, state.conn)
216+
GenServer.reply(from, result)
217+
end)
218+
219+
{:noreply, %{state | networks: MapSet.put(state.networks, network_name)}}
220+
end
221+
222+
@impl true
223+
def handle_call({:remove_network, network_name}, from, state) do
224+
Task.async(fn ->
225+
result = Api.remove_network(network_name, state.conn)
226+
GenServer.reply(from, result)
227+
end)
228+
229+
{:noreply, %{state | networks: MapSet.delete(state.networks, network_name)}}
230+
end
231+
173232
# private functions
174233

175234
defp get_docker_hostname(docker_host_url, conn) do

0 commit comments

Comments
 (0)