Skip to content
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
12 changes: 8 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ jobs:
- uses: taiki-e/install-action@v2
with:
tool: cargo-hack
- name: Build
run: cargo hack build --feature-powerset --depth 2 --keep-going
- name: Build (without host-port-exposure)
run: cargo hack build --feature-powerset --depth 2 --keep-going --exclude-features host-port-exposure
- name: Build (host-port-exposure with TLS backend)
run: cargo hack build --feature-powerset --depth 2 --keep-going --include-features host-port-exposure,ring,aws-lc-rs --at-least-one-of ring,aws-lc-rs

test:
name: Test
Expand Down Expand Up @@ -66,8 +68,10 @@ jobs:
- uses: taiki-e/install-action@v2
with:
tool: cargo-hack
- name: Tests
run: cargo hack test --feature-powerset --depth 2 --clean-per-run --partition ${{ matrix.partition }}
- name: Tests (without host-port-exposure)
run: cargo hack test --feature-powerset --depth 2 --clean-per-run --partition ${{ matrix.partition }} --exclude-features host-port-exposure
- name: Tests (host-port-exposure with TLS backend)
run: cargo hack test --feature-powerset --depth 2 --clean-per-run --partition ${{ matrix.partition }} --include-features host-port-exposure,ring,aws-lc-rs --at-least-one-of ring,aws-lc-rs

fmt:
name: Rustfmt check
Expand Down
117 changes: 117 additions & 0 deletions docs/features/networking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Networking

Testcontainers for Rust includes several helpers to connect containers with the outside world. This page covers two complementary workflows:

- mapping container ports to the host so your test code can reach services inside the container;
- exposing host ports inside a container so the container can call back into services running on your machine.

## Exposing Container Ports to the Host

Calling `with_exposed_port` on an image request asks the container runtime to publish that container port on a random free port on the host (similar to `docker run -p`). This is the most common way to make a containerised service available to your tests.

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

#[tokio::test]
async fn talks_to_web_service() -> Result<(), Box<dyn std::error::Error>> {
let container = GenericImage::new("ghcr.io/testcontainers/examples", "web")
.with_exposed_port(8080.tcp())
.with_wait_for(WaitFor::message_on_stdout("listening on 0.0.0.0:8080"))
.start()
.await?;

let host = container.get_host()?;
let port = container.get_host_port_ipv4(8080.tcp())?;

let body = reqwest::get(format!("http://{host}:{port}/health"))
.await?
.text()
.await?;
assert_eq!(body, "OK");

Ok(())
}
```

The mapped host port is assigned dynamically. Retrieve it using `get_host_port_ipv4` or `get_host_port_ipv6`, depending on the protocol you need. If you must pin a specific host port, switch to `with_mapped_port(host_port, container_port)` instead of `with_exposed_port`.

## Exposing Host Ports to a Container

Some integration tests require containers to reach services running directly on the host. Enabling the optional `host-port-exposure` feature spins up a lightweight SSH sidecar and creates a reverse tunnel for each host port you request.

### Enable the Feature

Declare the feature flag when adding the dependency:

```sh
cargo add testcontainers --features host-port-exposure
```

It can be combined with other common crate features. Host port exposure does not support `reusable-containers`, preventing tunnels from leaking across test sessions.

### Request Host Port Access

Use `with_exposed_host_port` or `with_exposed_host_ports` to request access to host ports that are already open. Testcontainers automatically injects the `host.testcontainers.internal` alias into the container and points it at the tunnel.

```rust
use std::io::Write;
use std::net::TcpListener;
use std::thread;

use testcontainers::{
core::{ExecCommand, IntoContainerPort},
runners::AsyncRunner,
GenericImage,
};

const HOST_ALIAS: &str = "host.testcontainers.internal";

#[tokio::test]
async fn container_reaches_host_service() -> Result<(), Box<dyn std::error::Error>> {
// Start a temporary HTTP server on the host.
let listener = TcpListener::bind("127.0.0.1:0")?;
let host_port = listener.local_addr()?.port();
thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\n\r\nhello-from-host",
);
}
});

let container = GenericImage::new("alpine", "3.19")
.with_entrypoint("/bin/sh")
.with_cmd(["-c", "sleep 30"])
.with_exposed_host_port(host_port)
.start()
.await?;

// Verify that the container can reach the host.
let mut exec = container
.exec(ExecCommand::new(vec![
"wget".into(),
"-qO-".into(),
format!("http://{HOST_ALIAS}:{host_port}"),
]))
.await?;
let output = String::from_utf8(exec.stdout_to_vec().await?)?;
assert_eq!(output, "hello-from-host");

Ok(())
}
```

All requested ports share the `host.testcontainers.internal` alias. Call `with_exposed_host_ports([port_a, port_b])` to expose multiple services at once; each container keeps its tunnels scoped to itself.

### Usage Notes

- Requested ports must be greater than zero and cannot include `22`, which is reserved for the SSH sidecar.
- Do not predefine the `host.testcontainers.internal` host entry; the feature manages it automatically.
- Host port exposure does not support Docker's `host` network mode or `container:<id>` network sharing.
- Reusable containers are rejected to avoid tunnel conflicts between test runs.

With these tools you can validate both inbound and outbound network flows without leaving the comfort of your Rust test suite.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ nav:
- features/configuration.md
- features/wait_strategies.md
- features/exec_commands.md
- features/networking.md
- System Requirements:
- system_requirements/docker.md
- Contributing:
Expand Down
6 changes: 4 additions & 2 deletions testcontainers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ log = "0.4"
memchr = "2.7.2"
parse-display = "0.9.0"
pin-project-lite = "0.2.14"
russh = { version = "0.54.4", default-features = false, optional = true }
reqwest = { version = "0.12.5", features = [
"rustls-tls",
"rustls-tls-native-roots",
Expand All @@ -51,15 +52,16 @@ url = { version = "2", features = ["serde"] }

[features]
default = ["ring"]
ring = ["bollard/ssl"]
aws-lc-rs = ["bollard/aws-lc-rs"]
ring = ["bollard/ssl", "russh?/ring"]
aws-lc-rs = ["bollard/aws-lc-rs", "russh?/aws-lc-rs"]
ssl = ["bollard/ssl_providerless"]
blocking = []
watchdog = ["signal-hook", "conquer-once"]
http_wait = ["reqwest"]
properties-config = ["serde-java-properties"]
reusable-containers = []
device-requests = []
host-port-exposure = ["dep:russh"]

[dev-dependencies]
anyhow = "1.0.86"
Expand Down
28 changes: 27 additions & 1 deletion testcontainers/src/core/containers/async_container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use std::{fmt, net::IpAddr, pin::Pin, str::FromStr, sync::Arc, time::Duration};
use tokio::io::{AsyncBufRead, AsyncReadExt};
use tokio_stream::StreamExt;

#[cfg(feature = "host-port-exposure")]
use super::host::HostPortExposure;
use crate::{
core::{
async_drop,
Expand Down Expand Up @@ -42,6 +44,8 @@ pub struct ContainerAsync<I: Image> {
#[allow(dead_code)]
network: Option<Arc<Network>>,
dropped: bool,
#[cfg(feature = "host-port-exposure")]
host_port_exposure: Option<HostPortExposure>,
#[cfg(feature = "reusable-containers")]
reuse: crate::ReuseDirective,
}
Expand All @@ -57,9 +61,17 @@ where
docker_client: Arc<Client>,
container_req: ContainerRequest<I>,
network: Option<Arc<Network>>,
#[cfg(feature = "host-port-exposure")] host_port_exposure: Option<HostPortExposure>,
) -> Result<ContainerAsync<I>> {
let ready_conditions = container_req.ready_conditions();
let container = Self::construct(id, docker_client, container_req, network);
let container = Self::construct(
id,
docker_client,
container_req,
network,
#[cfg(feature = "host-port-exposure")]
host_port_exposure,
);
let state = ContainerState::from_container(&container).await?;
for cmd in container.image().exec_before_ready(state)? {
container.exec(cmd).await?;
Expand All @@ -73,6 +85,7 @@ where
docker_client: Arc<Client>,
mut container_req: ContainerRequest<I>,
network: Option<Arc<Network>>,
#[cfg(feature = "host-port-exposure")] host_port_exposure: Option<HostPortExposure>,
) -> ContainerAsync<I> {
#[cfg(feature = "reusable-containers")]
let reuse = container_req.reuse();
Expand All @@ -84,6 +97,8 @@ where
docker_client,
network,
dropped: false,
#[cfg(feature = "host-port-exposure")]
host_port_exposure,
#[cfg(feature = "reusable-containers")]
reuse,
};
Expand Down Expand Up @@ -409,6 +424,12 @@ where
.field("network", &self.network)
.field("dropped", &self.dropped);

#[cfg(feature = "host-port-exposure")]
repr.field(
"host_port_exposure",
&self.host_port_exposure.as_ref().map(|_| true),
);

#[cfg(feature = "reusable-containers")]
repr.field("reuse", &self.reuse);

Expand All @@ -432,6 +453,11 @@ where
}
}

#[cfg(feature = "host-port-exposure")]
if let Some(mut exposure) = self.host_port_exposure.take() {
exposure.shutdown();
}

if !self.dropped {
let id = self.id.clone();
let client = self.docker_client.clone();
Expand Down
Loading
Loading