Skip to content

Commit de42ea5

Browse files
authored
Merge branch 'main' into dependabot/cargo/etcetera-0.11.0
2 parents 614338b + 593913c commit de42ea5

File tree

20 files changed

+735
-59
lines changed

20 files changed

+735
-59
lines changed

.config/nextest.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[profile.default]
2+
# Enable automatic retries for flaky tests with exponential backoff.
3+
retries = { backoff = "exponential", count = 2, delay = "1s" }

.github/workflows/audit.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
security_audit:
1010
runs-on: ubuntu-latest
1111
steps:
12-
- uses: actions/checkout@v5
12+
- uses: actions/checkout@v6
1313
- name: Generate Cargo.lock if doesn't exist
1414
run: |
1515
if [ ! -f Cargo.lock ]; then

.github/workflows/ci.yml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
runs-on: ubuntu-latest
1717
steps:
1818
- name: Checkout sources
19-
uses: actions/checkout@v5
19+
uses: actions/checkout@v6
2020
- uses: Swatinem/rust-cache@v2
2121
- name: Get current MSRV from Cargo.toml
2222
id: current_msrv
@@ -50,7 +50,7 @@ jobs:
5050
- 4/4
5151
steps:
5252
- name: Checkout sources
53-
uses: actions/checkout@v5
53+
uses: actions/checkout@v6
5454
- uses: Swatinem/rust-cache@v2
5555
- name: Setup Rust
5656
uses: dtolnay/rust-toolchain@master
@@ -66,15 +66,21 @@ jobs:
6666
- uses: taiki-e/install-action@v2
6767
with:
6868
tool: cargo-hack
69+
- uses: taiki-e/install-action@v2
70+
with:
71+
tool: cargo-nextest
6972
- name: Tests
70-
run: cargo hack test --feature-powerset --depth 2 --clean-per-run --partition ${{ matrix.partition }}
73+
run: cargo hack nextest run --feature-powerset --depth 2 --clean-per-run --partition ${{ matrix.partition }}
74+
- name: Doc tests
75+
if: matrix.toolchain == 'stable' && matrix.partition == '1/4'
76+
run: cargo test --doc --workspace --all-features
7177

7278
fmt:
7379
name: Rustfmt check
7480
runs-on: ubuntu-latest
7581
steps:
7682
- name: Checkout sources
77-
uses: actions/checkout@v5
83+
uses: actions/checkout@v6
7884
- uses: Swatinem/rust-cache@v2
7985
- uses: actions-rs/toolchain@v1
8086
with:
@@ -99,7 +105,7 @@ jobs:
99105
- nightly
100106
steps:
101107
- name: Checkout sources
102-
uses: actions/checkout@v5
108+
uses: actions/checkout@v6
103109
- uses: Swatinem/rust-cache@v2
104110
- name: Setup Rust
105111
uses: dtolnay/rust-toolchain@master
@@ -117,7 +123,7 @@ jobs:
117123
runs-on: ubuntu-latest
118124
if: github.event_name == 'pull_request'
119125
steps:
120-
- uses: actions/checkout@v5
126+
- uses: actions/checkout@v6
121127
with:
122128
fetch-depth: 0
123129
- uses: CondeNast/[email protected]

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
runs-on: ubuntu-latest
1515
steps:
1616
- name: Checkout repository
17-
uses: actions/checkout@v5
17+
uses: actions/checkout@v6
1818
with:
1919
fetch-depth: 0
2020
- name: Install Rust toolchain

docs/features/files.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Files and Mounts
2+
3+
Rust Testcontainers lets you seed container filesystems before startup, collect artifacts produced inside containers, and bind host paths at runtime. The APIs deliver smooth ergonomics while staying idiomatic to Rust.
4+
5+
## Copying Files Into Containers (Before Startup)
6+
7+
Use `ImageExt::with_copy_to` to stage files or directories before the container starts. Content can come from raw bytes or host paths:
8+
9+
```rust
10+
// Example: copying inline bytes and directories into a container
11+
use testcontainers::{GenericImage, WaitFor};
12+
13+
let project_assets = std::path::Path::new("tests/fixtures/assets");
14+
let image = GenericImage::new("alpine", "latest")
15+
.with_wait_for(WaitFor::seconds(1))
16+
.with_copy_to("/opt/app/config.yaml", br#"mode = "test""#.to_vec())
17+
.with_copy_to("/opt/app/assets", project_assets);
18+
```
19+
20+
Everything is packed into a TAR archive, preserving nested directories. The helper accepts either `Vec<u8>` or any path-like value implementing `CopyDataSource`.
21+
By default, the destination path inherits the mode of the source file on Unix hosts (or falls back to `0o644` elsewhere). Use `CopyTargetOptions` when you need to override per-copy metadata such as permissions:
22+
23+
```rust
24+
use testcontainers::{CopyTargetOptions, GenericImage, ImageExt};
25+
26+
let image = GenericImage::new("alpine", "latest")
27+
.with_copy_to(
28+
CopyTargetOptions::new("/opt/app/secret.yaml").with_mode(0o600),
29+
"./fixtures/secret.yaml",
30+
)
31+
.with_copy_to(
32+
CopyTargetOptions::new("/opt/app/blob.bin").with_mode(0o640),
33+
br"raw bytes".to_vec(),
34+
);
35+
```
36+
37+
`CopyTargetOptions::new` wraps any path-like target and keeps backward compatibility with string literals—existing code continues to compile. Symbolic links still follow Docker’s TAR semantics; the `mode` override only applies to the final file entry recorded in the archive.
38+
39+
## Copying Files From Containers (After Execution)
40+
41+
Use `copy_file_from` to pull data produced inside the container:
42+
43+
```rust
44+
// Example: copying a file from a running container to the host
45+
use tempfile::tempdir;
46+
use testcontainers::{GenericImage, WaitFor};
47+
48+
#[tokio::test]
49+
async fn copy_example() -> anyhow::Result<()> {
50+
let container = GenericImage::new("alpine", "latest")
51+
.with_cmd(["sh", "-c", "echo '42' > /tmp/result.txt && sleep 10"])
52+
.with_wait_for(WaitFor::seconds(1))
53+
.start()
54+
.await?;
55+
56+
let destination = tempdir()?.path().join("result.txt");
57+
container
58+
.copy_file_from("/tmp/result.txt", destination.as_path())
59+
.await?;
60+
assert_eq!(tokio::fs::read_to_string(&destination).await?, "42\n");
61+
Ok(())
62+
}
63+
```
64+
65+
- `copy_file_from` streams file contents into any destination implementing `CopyFileFromContainer` (for example `&Path` or `&mut Vec<u8>`). When the requested path is not a regular file you’ll receive a `CopyFromContainerError`.
66+
- Targets like `Vec<u8>` and filesystem paths overwrite existing data: vectors are cleared before writing, and files are truncated or recreated if they already exist.
67+
- To capture the contents in memory:
68+
```rust
69+
let mut bytes = Vec::new();
70+
container.copy_file_from("/tmp/result.txt", &mut bytes).await?;
71+
```
72+
73+
The blocking `Container` type provides the same `copy_file_from` API.
74+
75+
## Using Mounts for Writable Workspaces
76+
77+
When a bind or tmpfs mount fits better than copy semantics, use the `Mount` helpers:
78+
79+
```rust
80+
// Example: mounting a host directory for read/write access
81+
use std::path::Path;
82+
use testcontainers::core::{mounts::Mount, AccessMode, MountType};
83+
84+
let host_data = Path::new("/var/tmp/integration-data");
85+
let mount = Mount::bind(host_data, "/workspace")
86+
.with_mode(AccessMode::ReadWrite)
87+
.with_type(MountType::Bind);
88+
89+
let image = GenericImage::new("python", "3.13")
90+
.with_mount(mount)
91+
.with_cmd(["python", "/workspace/run.py"]);
92+
```
93+
94+
Bind mounts share host state directly. Tmpfs mounts create ephemeral in-memory storage useful for scratch data or caches.
95+
96+
## Selecting an Approach
97+
98+
- **Copy before startup** — for deterministic inputs.
99+
- **Copy from containers** — to capture build artifacts, logs, or test fixtures produced during a run.
100+
- **Use mounts** — when containers need to read/write large amounts of data efficiently without re-tarring.
101+
102+
Mixing these tools keeps tests hermetic (isolated and reproducible) while letting you inspect outputs locally.
103+
Document each choice in code so teammates know whether data is ephemeral (`tmpfs`), seeded once (`with_copy_to`), or captured for later assertions (`copy_file_from`).

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ nav:
4444
- features/configuration.md
4545
- features/wait_strategies.md
4646
- features/exec_commands.md
47+
- features/files.md
4748
- features/networking.md
4849
- features/building_images.md
4950
- features/docker_compose.md

testcontainers/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ tokio = { version = "1", features = ["macros", "fs", "rt-multi-thread", "process
4646
tokio-stream = "0.1.15"
4747
astral-tokio-tar = "0.5.6"
4848
tokio-util = { version = "0.7.10", features = ["io"] }
49-
ferroid = { version = "0.8.2", features = ["std", "ulid", "base32"] }
49+
ferroid = { version = "0.8.7", features = ["std", "ulid", "base32"] }
5050
url = { version = "2", features = ["serde"] }
5151
uuid = { version = "1.8.0", features = ["v4"], optional = true }
5252

testcontainers/src/core.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ pub use self::{
66
buildable::BuildableImage,
77
},
88
containers::*,
9-
copy::{CopyDataSource, CopyToContainer, CopyToContainerCollection, CopyToContainerError},
9+
copy::{
10+
CopyDataSource, CopyFileFromContainer, CopyFromContainerError, CopyToContainer,
11+
CopyToContainerCollection, CopyToContainerError,
12+
},
1013
healthcheck::Healthcheck,
1114
image::{ContainerState, ExecCommand, Image, ImageExt},
1215
mounts::{AccessMode, Mount, MountTmpfsOptions, MountType},

testcontainers/src/core/client.rs

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
use std::{
2-
collections::HashMap,
3-
io::{self},
4-
str::FromStr,
5-
sync::Arc,
6-
};
1+
use std::{collections::HashMap, io, str::FromStr, sync::Arc};
72

83
use bollard::{
94
auth::DockerCredentials,
@@ -17,21 +12,30 @@ use bollard::{
1712
},
1813
query_parameters::{
1914
BuildImageOptionsBuilder, BuilderVersion, CreateContainerOptions,
20-
CreateImageOptionsBuilder, InspectContainerOptions, InspectContainerOptionsBuilder,
21-
InspectNetworkOptions, InspectNetworkOptionsBuilder, ListContainersOptionsBuilder,
22-
ListNetworksOptions, LogsOptionsBuilder, RemoveContainerOptionsBuilder,
23-
StartContainerOptions, StopContainerOptionsBuilder, UploadToContainerOptionsBuilder,
15+
CreateImageOptionsBuilder, DownloadFromContainerOptionsBuilder, InspectContainerOptions,
16+
InspectContainerOptionsBuilder, InspectNetworkOptions, InspectNetworkOptionsBuilder,
17+
ListContainersOptionsBuilder, ListNetworksOptions, LogsOptionsBuilder,
18+
RemoveContainerOptionsBuilder, StartContainerOptions, StopContainerOptionsBuilder,
19+
UploadToContainerOptionsBuilder,
2420
},
2521
Docker,
2622
};
2723
use ferroid::{base32::Base32UlidExt, id::ULID};
28-
use futures::{StreamExt, TryStreamExt};
29-
use tokio::sync::{Mutex, OnceCell};
24+
use futures::{pin_mut, StreamExt, TryStreamExt};
25+
use tokio::{
26+
io::AsyncRead,
27+
sync::{Mutex, OnceCell},
28+
};
29+
use tokio_tar::{Archive as AsyncTarArchive, EntryType};
30+
use tokio_util::io::StreamReader;
3031
use url::Url;
3132

3233
use crate::core::{
3334
client::exec::ExecResult,
34-
copy::{CopyToContainer, CopyToContainerCollection, CopyToContainerError},
35+
copy::{
36+
CopyFileFromContainer, CopyFromContainerError, CopyToContainer, CopyToContainerCollection,
37+
CopyToContainerError,
38+
},
3539
env::{self, ConfigurationError},
3640
logs::{
3741
stream::{LogStream, RawLogStream},
@@ -127,6 +131,8 @@ pub enum ClientError {
127131
UploadToContainerError(BollardError),
128132
#[error("failed to prepare data for copy-to-container: {0}")]
129133
CopyToContainerError(CopyToContainerError),
134+
#[error("failed to handle data copied from container: {0}")]
135+
CopyFromContainerError(CopyFromContainerError),
130136
}
131137

132138
/// The internal client.
@@ -404,6 +410,65 @@ impl Client {
404410
.map_err(ClientError::UploadToContainerError)
405411
}
406412

413+
pub(crate) async fn copy_file_from_container<T>(
414+
&self,
415+
container_id: impl AsRef<str>,
416+
container_path: impl AsRef<str>,
417+
target: T,
418+
) -> Result<T::Output, ClientError>
419+
where
420+
T: CopyFileFromContainer,
421+
{
422+
let container_id = container_id.as_ref();
423+
let options = DownloadFromContainerOptionsBuilder::new()
424+
.path(container_path.as_ref())
425+
.build();
426+
427+
let stream = self
428+
.bollard
429+
.download_from_container(container_id, Some(options))
430+
.map_err(|err| io::Error::new(io::ErrorKind::Other, err));
431+
let reader = StreamReader::new(stream);
432+
Self::extract_file_entry(reader, target)
433+
.await
434+
.map_err(ClientError::CopyFromContainerError)
435+
}
436+
437+
async fn extract_file_entry<R, T>(
438+
reader: R,
439+
target: T,
440+
) -> Result<T::Output, CopyFromContainerError>
441+
where
442+
R: AsyncRead + Unpin,
443+
T: CopyFileFromContainer,
444+
{
445+
let mut archive = AsyncTarArchive::new(reader);
446+
let entries = archive.entries().map_err(CopyFromContainerError::Io)?;
447+
448+
pin_mut!(entries);
449+
450+
while let Some(entry) = entries
451+
.try_next()
452+
.await
453+
.map_err(CopyFromContainerError::Io)?
454+
{
455+
match entry.header().entry_type() {
456+
EntryType::GNULongName
457+
| EntryType::GNULongLink
458+
| EntryType::XGlobalHeader
459+
| EntryType::XHeader
460+
| EntryType::GNUSparse => continue, // skip metadata entries
461+
EntryType::Directory => return Err(CopyFromContainerError::IsDirectory),
462+
EntryType::Regular | EntryType::Continuous => {
463+
return target.copy_from_reader(entry).await
464+
}
465+
et => return Err(CopyFromContainerError::UnsupportedEntry(et)),
466+
}
467+
}
468+
469+
Err(CopyFromContainerError::EmptyArchive)
470+
}
471+
407472
pub(crate) async fn container_is_running(
408473
&self,
409474
container_id: &str,
@@ -490,7 +555,7 @@ impl Client {
490555
.await
491556
.map_err(ClientError::CopyToContainerError)?;
492557

493-
let session = ULID::from_datetime(std::time::SystemTime::now()).encode();
558+
let session = ULID::now().encode();
494559

495560
let mut builder = BuildImageOptionsBuilder::new()
496561
.dockerfile("Dockerfile")

testcontainers/src/core/containers/async_container.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ use tokio_stream::StreamExt;
55
#[cfg(feature = "host-port-exposure")]
66
use super::host::HostPortExposure;
77
use crate::{
8-
core::{async_drop, client::Client, env, error::Result, network::Network, ContainerState},
8+
core::{
9+
async_drop, client::Client, copy::CopyFileFromContainer, env, error::Result,
10+
network::Network, ContainerState,
11+
},
912
ContainerRequest, Image,
1013
};
1114

@@ -179,6 +182,25 @@ where
179182
Ok(exit_code)
180183
}
181184

185+
/// Copies a single file from the container into an arbitrary target implementing [`CopyFileFromContainer`].
186+
///
187+
/// # Behavior
188+
/// - Regular files are streamed directly into the target (e.g. `PathBuf`, `Vec<u8>`).
189+
/// - Additional archive entries (metadata or other files) are skipped after the first regular file.
190+
/// - If `container_path` resolves to a directory, an error is returned and no data is written.
191+
/// - Symlink handling follows Docker's `GET /containers/{id}/archive` endpoint behavior without extra processing.
192+
pub async fn copy_file_from<T>(
193+
&self,
194+
container_path: impl Into<String>,
195+
target: T,
196+
) -> Result<T::Output>
197+
where
198+
T: CopyFileFromContainer,
199+
{
200+
let container_path = container_path.into();
201+
self.raw.copy_file_from(container_path, target).await
202+
}
203+
182204
/// Removes the container.
183205
pub async fn rm(mut self) -> Result<()> {
184206
log::debug!("Deleting docker container {}", self.id());

0 commit comments

Comments
 (0)