Skip to content

Commit

Permalink
feat!: support UDP and SCTP port mappings (#655)
Browse files Browse the repository at this point in the history
Resolves #234

### Migration guide

A new way of defining exposed ports and port mappings is introduced to
define the protocol, which could be
Tcp, Udp or Sctp. Changes impact the following methods:

- `GenericImage.with_exposed_port`
- `GenericImage.with_mapped_port`
- `Container.get_host_port_ipv4`
- `Container.get_host_port_ipv6`
- `ContainerAsync.get_host_port_ipv4`
- `ContainerAsync.get_host_port_ipv6`

#### Exposed ports

Having the following container configuration, that defines a Redis
container running on TCP 6379 port:

```
use testcontainers::{core::{WaitFor}, runners::AsyncRunner, GenericImage};

async fn test_redis() {
     let container = GenericImage::new("redis", "7.2.4")
         .with_exposed_port(6379)
         .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
         .start()
         .await;
 }
```

From now on, we should use `ExposedPort` enum instead, as follows:

```
use testcontainers::{core::{WaitFor, ExposedPort}, runners::AsyncRunner, GenericImage};

async fn test_redis() {
     let container = GenericImage::new("redis", "7.2.4")
         .with_exposed_port(ExposedPort::Tcp(6379))
         .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
         .start()
         .await;
 }
```

Notice the `ExposedPort::Tcp(6379)`, where we explicitly say which
protocol we want to map the port. Most of your use
cases would use Tcp, like Redis container, but we recommend to check the
containers documentation to be sure.

#### Port mappings

Having the following container configuration, that defines a Redis
container running on TCP 6379 internal port, mapped to
1000 local port:

```
use testcontainers::{core::{WaitFor}, runners::AsyncRunner, GenericImage};

async fn test_redis() {
     let container = GenericImage::new("redis", "7.2.4")
         .with_mapped_port((1000, 6379))
         .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
         .start()
         .await;
 }
```

From now on, we should use `ExposedPort` enum instead, as follows:

```
use testcontainers::{core::{WaitFor, ExposedPort}, runners::AsyncRunner, GenericImage};

async fn test_redis() {
     let container = GenericImage::new("redis", "7.2.4")
         .with_mapped_port((1000, ExposedPort::Tcp(6379)))
         .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
         .start()
         .await;
 }
```

Notice the `ExposedPort::Tcp(6379)`, where we explicitly say which
protocol we want to map the port. Most of your use
cases would use Tcp, like Redis container, but we recommend to check the
containers documentation to be sure.

If you are interested on getting the local port mapped to an internal
port, before you use the following:

```
use testcontainers::{core::{WaitFor}, runners::AsyncRunner, GenericImage};

async fn test_redis() {
     let container = GenericImage::new("redis", "7.2.4")
         .with_mapped_port((1000, 6379))
         .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
         .start()
         .await;
         
     let local_port = container.get_host_port_ipv4(6379).await?;
 }
```

From now on, you must specify the protocol, as follows:

```
use testcontainers::{core::{WaitFor}, runners::AsyncRunner, GenericImage};

async fn test_redis() {
     let container = GenericImage::new("redis", "7.2.4")
         .with_mapped_port((1000, ExposedPort::Tcp(6379)))
         .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
         .start()
         .await;
         
     let local_port = container.get_host_port_ipv4(ExposedPort::Tcp(6379)).await?;
 }
```

Notice the `ExposedPort::Tcp(6379)` in the `get_host_port_ipv4`
invocation.
  • Loading branch information
estigma88 authored Jun 14, 2024
1 parent 28c4d0e commit 7789466
Show file tree
Hide file tree
Showing 12 changed files with 181 additions and 63 deletions.
1 change: 1 addition & 0 deletions testcontainers/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub use self::{
containers::*,
image::{CmdWaitFor, ContainerState, ExecCommand, Image, ImageExt, WaitFor},
mounts::{AccessMode, Mount, MountType},
ports::ExposedPort,
};

mod image;
Expand Down
6 changes: 3 additions & 3 deletions testcontainers/src/core/containers/async_container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::{
macros,
network::Network,
ports::Ports,
ContainerState, ExecCommand, WaitFor,
ContainerState, ExecCommand, ExposedPort, WaitFor,
},
ContainerRequest, Image,
};
Expand Down Expand Up @@ -92,7 +92,7 @@ where
///
/// This method does **not** magically expose the given port, it simply performs a mapping on
/// the already exposed ports. If a docker container does not expose a port, this method will return an error.
pub async fn get_host_port_ipv4(&self, internal_port: u16) -> Result<u16> {
pub async fn get_host_port_ipv4(&self, internal_port: ExposedPort) -> Result<u16> {
self.ports()
.await?
.map_to_host_port_ipv4(internal_port)
Expand All @@ -107,7 +107,7 @@ where
///
/// This method does **not** magically expose the given port, it simply performs a mapping on
/// the already exposed ports. If a docker container does not expose a port, this method will return an error.
pub async fn get_host_port_ipv6(&self, internal_port: u16) -> Result<u16> {
pub async fn get_host_port_ipv6(&self, internal_port: ExposedPort) -> Result<u16> {
self.ports()
.await?
.map_to_host_port_ipv6(internal_port)
Expand Down
9 changes: 5 additions & 4 deletions testcontainers/src/core/containers/request.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{borrow::Cow, collections::BTreeMap, net::IpAddr, time::Duration};

use crate::core::ports::ExposedPort;
use crate::{
core::{mounts::Mount, ContainerState, ExecCommand, WaitFor},
Image, TestcontainersError,
Expand Down Expand Up @@ -30,7 +31,7 @@ pub struct ContainerRequest<I: Image> {
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PortMapping {
pub local: u16,
pub internal: u16,
pub internal: ExposedPort,
}

#[derive(parse_display::Display, Debug, Clone)]
Expand Down Expand Up @@ -129,7 +130,7 @@ impl<I: Image> ContainerRequest<I> {
self.image.ready_conditions()
}

pub fn expose_ports(&self) -> &[u16] {
pub fn expose_ports(&self) -> &[ExposedPort] {
self.image.expose_ports()
}

Expand Down Expand Up @@ -168,8 +169,8 @@ impl<I: Image> From<I> for ContainerRequest<I> {
}
}

impl From<(u16, u16)> for PortMapping {
fn from((local, internal): (u16, u16)) -> Self {
impl From<(u16, ExposedPort)> for PortMapping {
fn from((local, internal): (u16, ExposedPort)) -> Self {
PortMapping { local, internal }
}
}
6 changes: 3 additions & 3 deletions testcontainers/src/core/containers/sync_container.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::{fmt, io::BufRead, net::IpAddr, sync::Arc};

use crate::{
core::{env, error::Result, ports::Ports, ExecCommand},
core::{env, error::Result, ports::Ports, ExecCommand, ExposedPort},
ContainerAsync, Image,
};

Expand Down Expand Up @@ -82,7 +82,7 @@ where
///
/// This method does **not** magically expose the given port, it simply performs a mapping on
/// the already exposed ports. If a docker container does not expose a port, this method returns an error.
pub fn get_host_port_ipv4(&self, internal_port: u16) -> Result<u16> {
pub fn get_host_port_ipv4(&self, internal_port: ExposedPort) -> Result<u16> {
self.rt()
.block_on(self.async_impl().get_host_port_ipv4(internal_port))
}
Expand All @@ -92,7 +92,7 @@ where
///
/// This method does **not** magically expose the given port, it simply performs a mapping on
/// the already exposed ports. If a docker container does not expose a port, this method returns an error.
pub fn get_host_port_ipv6(&self, internal_port: u16) -> Result<u16> {
pub fn get_host_port_ipv6(&self, internal_port: ExposedPort) -> Result<u16> {
self.rt()
.block_on(self.async_impl().get_host_port_ipv6(internal_port))
}
Expand Down
4 changes: 2 additions & 2 deletions testcontainers/src/core/error.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::error::Error;

use crate::core::logs::WaitLogError;
pub use crate::core::{client::ClientError, env::ConfigurationError};
pub use crate::core::{client::ClientError, env::ConfigurationError, ExposedPort};

pub type Result<T> = std::result::Result<T, TestcontainersError>;

Expand All @@ -15,7 +15,7 @@ pub enum TestcontainersError {
WaitContainer(#[from] WaitContainerError),
/// Represents an error when a container does not expose a specified port
#[error("container '{id}' does not expose port {port}")]
PortNotExposed { id: String, port: u16 },
PortNotExposed { id: String, port: ExposedPort },
/// Represents an error when a container is missing some information
#[error(transparent)]
MissingInfo(#[from] ContainerMissingInfo),
Expand Down
8 changes: 4 additions & 4 deletions testcontainers/src/core/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pub use exec::{CmdWaitFor, ExecCommand};
pub use image_ext::ImageExt;
pub use wait_for::WaitFor;

use super::ports::Ports;
use super::ports::{ExposedPort, Ports};
use crate::{core::mounts::Mount, TestcontainersError};

mod exec;
Expand Down Expand Up @@ -69,7 +69,7 @@ where
///
/// This method is useful when there is a need to expose some ports, but there is
/// no `EXPOSE` instruction in the Dockerfile of an image.
fn expose_ports(&self) -> &[u16] {
fn expose_ports(&self) -> &[ExposedPort] {
&[]
}

Expand Down Expand Up @@ -107,7 +107,7 @@ impl ContainerState {
/// Returns the host port for the given internal port (`IPv4`).
///
/// Results in an error ([`TestcontainersError::PortNotExposed`]) if the port is not exposed.
pub fn host_port_ipv4(&self, internal_port: u16) -> Result<u16, TestcontainersError> {
pub fn host_port_ipv4(&self, internal_port: ExposedPort) -> Result<u16, TestcontainersError> {
self.ports
.map_to_host_port_ipv4(internal_port)
.ok_or_else(|| TestcontainersError::PortNotExposed {
Expand All @@ -119,7 +119,7 @@ impl ContainerState {
/// Returns the host port for the given internal port (`IPv6`).
///
/// Results in an error ([`TestcontainersError::PortNotExposed`]) if the port is not exposed.
pub fn host_port_ipv6(&self, internal_port: u16) -> Result<u16, TestcontainersError> {
pub fn host_port_ipv6(&self, internal_port: ExposedPort) -> Result<u16, TestcontainersError> {
self.ports
.map_to_host_port_ipv6(internal_port)
.ok_or_else(|| TestcontainersError::PortNotExposed {
Expand Down
50 changes: 34 additions & 16 deletions testcontainers/src/core/ports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ use std::{collections::HashMap, net::IpAddr, num::ParseIntError};

use bollard_stubs::models::{PortBinding, PortMap};

#[derive(
parse_display::Display, parse_display::FromStr, Debug, Clone, Copy, Eq, PartialEq, Hash,
)]
pub enum ExposedPort {
#[display("{0}/tcp")]
Tcp(u16),
#[display("{0}/udp")]
Udp(u16),
#[display("{0}/sctp")]
Sctp(u16),
}

#[derive(Debug, thiserror::Error)]
pub enum PortMappingError {
#[error("failed to parse port: {0}")]
Expand All @@ -11,8 +23,8 @@ pub enum PortMappingError {
/// The exposed ports of a running container.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Ports {
ipv4_mapping: HashMap<u16, u16>,
ipv6_mapping: HashMap<u16, u16>,
ipv4_mapping: HashMap<ExposedPort, u16>,
ipv6_mapping: HashMap<ExposedPort, u16>,
}

impl Ports {
Expand Down Expand Up @@ -41,12 +53,12 @@ impl Ports {
}

/// Returns the host port for the given internal port, on the host's IPv4 interfaces.
pub fn map_to_host_port_ipv4(&self, internal_port: u16) -> Option<u16> {
pub fn map_to_host_port_ipv4(&self, internal_port: ExposedPort) -> Option<u16> {
self.ipv4_mapping.get(&internal_port).cloned()
}

/// Returns the host port for the given internal port, on the host's IPv6 interfaces.
pub fn map_to_host_port_ipv6(&self, internal_port: u16) -> Option<u16> {
pub fn map_to_host_port_ipv6(&self, internal_port: ExposedPort) -> Option<u16> {
self.ipv6_mapping.get(&internal_port).cloned()
}
}
Expand All @@ -59,11 +71,7 @@ impl TryFrom<PortMap> for Ports {
let mut ipv6_mapping = HashMap::new();
for (internal, external) in ports {
// internal is of the form '8332/tcp', split off the protocol ...
let internal_port = if let Some(internal) = internal.split('/').next() {
internal.parse()?
} else {
continue;
};
let internal_port = internal.parse::<ExposedPort>().expect("Internal port");

// get the `HostPort` of each external port binding
for binding in external.into_iter().flatten() {
Expand Down Expand Up @@ -284,15 +292,15 @@ mod tests {
"HostPort": "33076"
}
],
"18333/tcp": [
"18333/udp": [
{
"HostIp": "0.0.0.0",
"HostPort": "33075"
}
],
"18443/tcp": null,
"18444/tcp": null,
"8332/tcp": [
"8332/sctp": [
{
"HostIp": "0.0.0.0",
"HostPort": "33078"
Expand Down Expand Up @@ -352,11 +360,21 @@ mod tests {
.unwrap_or_default();

let mut expected_ports = Ports::default();
expected_ports.ipv4_mapping.insert(18332, 33076);
expected_ports.ipv4_mapping.insert(18333, 33075);
expected_ports.ipv4_mapping.insert(8332, 33078);
expected_ports.ipv4_mapping.insert(8333, 33077);
expected_ports.ipv6_mapping.insert(8333, 49718);
expected_ports
.ipv6_mapping
.insert(ExposedPort::Tcp(8333), 49718);
expected_ports
.ipv4_mapping
.insert(ExposedPort::Sctp(8332), 33078);
expected_ports
.ipv4_mapping
.insert(ExposedPort::Tcp(18332), 33076);
expected_ports
.ipv4_mapping
.insert(ExposedPort::Tcp(8333), 33077);
expected_ports
.ipv4_mapping
.insert(ExposedPort::Udp(18333), 33075);

assert_eq!(parsed_ports, expected_ports)
}
Expand Down
7 changes: 4 additions & 3 deletions testcontainers/src/images/generic.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::core::ports::ExposedPort;
use crate::{core::WaitFor, Image};

#[must_use]
Expand All @@ -7,7 +8,7 @@ pub struct GenericImage {
tag: String,
wait_for: Vec<WaitFor>,
entrypoint: Option<String>,
exposed_ports: Vec<u16>,
exposed_ports: Vec<ExposedPort>,
}

impl GenericImage {
Expand All @@ -31,7 +32,7 @@ impl GenericImage {
self
}

pub fn with_exposed_port(mut self, port: u16) -> Self {
pub fn with_exposed_port(mut self, port: ExposedPort) -> Self {
self.exposed_ports.push(port);
self
}
Expand All @@ -54,7 +55,7 @@ impl Image for GenericImage {
self.entrypoint.as_deref()
}

fn expose_ports(&self) -> &[u16] {
fn expose_ports(&self) -> &[ExposedPort] {
&self.exposed_ports
}
}
Expand Down
Loading

0 comments on commit 7789466

Please sign in to comment.