Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: reusable containers #757

Merged
merged 22 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2a458f4
feat: add support for user-configured labels
the-wondersmith Nov 20, 2024
5c2f7d2
style(clippy-lint): remove unnecessary path prefix
the-wondersmith Nov 20, 2024
10b735b
style(clippy-lint): use `.first()` instead of `.get(0)`
the-wondersmith Nov 20, 2024
b834b8e
chore: add `experimental` and `reusable-containers` feature flags
the-wondersmith Nov 22, 2024
5be452b
chore: add optional `ulid` dependency
the-wondersmith Nov 22, 2024
6965f34
chore: add guard against enabling experimental features without the `…
the-wondersmith Nov 22, 2024
bca0395
doc(test): add clarifying comment
the-wondersmith Nov 22, 2024
047e6aa
feat: add support for reusable containers
the-wondersmith Nov 22, 2024
62f8114
feat: add singular `with_label` method
the-wondersmith Dec 6, 2024
5bc6d1e
style: standardize all internally applied labels to use the `org.test…
the-wondersmith Dec 6, 2024
b9f29cc
Merge remote-tracking branch 'origin/feat/container-labels' into feat…
the-wondersmith Dec 6, 2024
eedd05e
style: standardize all internally applied labels to use the `org.test…
the-wondersmith Dec 6, 2024
b9801e8
Merge remote-tracking branch 'upstream/main' into feat/reusable-conta…
the-wondersmith Dec 6, 2024
a682b3b
style: rustfmt
the-wondersmith Dec 6, 2024
820db74
chore: resolve merge conflict
the-wondersmith Dec 6, 2024
4114fab
fix: fix incorrect failure of `cargo hack --each-feature` runs
the-wondersmith Dec 6, 2024
35fd138
fix: remove unnecessary `experimental` feature flag
the-wondersmith Dec 10, 2024
23bdbfd
revert: revert unnecessary changes to CI config
the-wondersmith Dec 10, 2024
75328e0
refactor: implement `ReuseDirective` enum
the-wondersmith Dec 11, 2024
d7655c4
style: make `with_reuse` argument constraint stricter
the-wondersmith Dec 16, 2024
751393a
refactor: simplify debug implementations with feature-flagged fields
the-wondersmith Dec 16, 2024
bc3142f
Merge branch 'testcontainers:main' into feat/reusable-containers
the-wondersmith Dec 16, 2024
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
2 changes: 2 additions & 0 deletions testcontainers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ tokio = { version = "1", features = ["macros", "fs", "rt-multi-thread"] }
tokio-stream = "0.1.15"
tokio-tar = "0.3.1"
tokio-util = { version = "0.7.10", features = ["io"] }
ulid = { version = "1.1.3", optional = true }
url = { version = "2", features = ["serde"] }

[features]
Expand All @@ -48,6 +49,7 @@ blocking = []
watchdog = ["signal-hook", "conquer-once"]
http_wait = ["reqwest"]
properties-config = ["serde-java-properties"]
reusable-containers = ["dep:ulid"]

[dev-dependencies]
anyhow = "1.0.86"
Expand Down
49 changes: 47 additions & 2 deletions testcontainers/src/core/client.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
use std::{
collections::HashMap,
io::{self},
str::FromStr,
};

use bollard::{
auth::DockerCredentials,
container::{
Config, CreateContainerOptions, LogOutput, LogsOptions, RemoveContainerOptions,
UploadToContainerOptions,
Config, CreateContainerOptions, ListContainersOptions, LogOutput, LogsOptions,
RemoveContainerOptions, UploadToContainerOptions,
},
errors::Error as BollardError,
exec::{CreateExecOptions, StartExecOptions, StartExecResults},
Expand Down Expand Up @@ -66,6 +67,8 @@ pub enum ClientError {
#[error("failed to map ports: {0}")]
PortMapping(#[from] PortMappingError),

#[error("failed to list containers: {0}")]
ListContainers(BollardError),
#[error("failed to create a container: {0}")]
CreateContainer(BollardError),
#[error("failed to remove a container: {0}")]
Expand Down Expand Up @@ -417,6 +420,48 @@ impl Client {

Some(bollard_credentials)
}

/// Get the `id` of the first running container whose `name`, `network`,
/// and `labels` match the supplied values
#[cfg_attr(not(feature = "reusable-containers"), allow(dead_code))]
pub(crate) async fn get_running_container_id(
&self,
name: Option<&str>,
network: Option<&str>,
labels: &HashMap<String, String>,
) -> Result<Option<String>, ClientError> {
let filters = [
Some(("status".to_string(), vec!["running".to_string()])),
name.map(|value| ("name".to_string(), vec![value.to_string()])),
network.map(|value| ("network".to_string(), vec![value.to_string()])),
Some((
"label".to_string(),
labels
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect(),
)),
]
.into_iter()
.flatten()
.collect::<HashMap<_, _>>();

let options = Some(ListContainersOptions {
filters,
all: false,
size: false,
limit: Some(1),
});

Ok(self
.bollard
.list_containers(options)
.await
.map_err(ClientError::ListContainers)?
.into_iter()
.next()
.and_then(|container| container.id))
the-wondersmith marked this conversation as resolved.
Show resolved Hide resolved
}
}

impl<BS> From<BS> for LogStream
Expand Down
206 changes: 201 additions & 5 deletions testcontainers/src/core/containers/async_container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ pub struct ContainerAsync<I: Image> {
#[allow(dead_code)]
network: Option<Arc<Network>>,
dropped: bool,
#[cfg(feature = "reusable-containers")]
reuse: bool,
}

impl<I> ContainerAsync<I>
Expand All @@ -53,16 +55,33 @@ where
pub(crate) async fn new(
id: String,
docker_client: Arc<Client>,
mut container_req: ContainerRequest<I>,
container_req: ContainerRequest<I>,
network: Option<Arc<Network>>,
) -> Result<ContainerAsync<I>> {
let container = Self::construct(id, docker_client, container_req, network);
let ready_conditions = container.image().ready_conditions();
container.block_until_ready(ready_conditions).await?;
Ok(container)
}

pub(crate) fn construct(
id: String,
docker_client: Arc<Client>,
mut container_req: ContainerRequest<I>,
network: Option<Arc<Network>>,
) -> ContainerAsync<I> {
#[cfg(feature = "reusable-containers")]
let reuse = container_req.reuse();

let log_consumers = std::mem::take(&mut container_req.log_consumers);
let container = ContainerAsync {
id,
image: container_req,
docker_client,
network,
dropped: false,
#[cfg(feature = "reusable-containers")]
reuse,
};

if !log_consumers.is_empty() {
Expand All @@ -87,9 +106,7 @@ where
});
}

let ready_conditions = container.image().ready_conditions();
container.block_until_ready(ready_conditions).await?;
Ok(container)
container
}

/// Returns the id of this container.
Expand Down Expand Up @@ -352,6 +369,15 @@ where
I: Image,
{
fn drop(&mut self) {
#[cfg(feature = "reusable-containers")]
{
if self.reuse && !self.dropped {
log::debug!("Declining to reap container marked for reuse: {}", &self.id);

return;
}
}

if !self.dropped {
let id = self.id.clone();
let client = self.docker_client.clone();
Expand Down Expand Up @@ -381,7 +407,6 @@ where

#[cfg(test)]
mod tests {

use tokio::io::AsyncBufReadExt;

use crate::{images::generic::GenericImage, runners::AsyncRunner};
Expand Down Expand Up @@ -468,4 +493,175 @@ mod tests {

Ok(())
}

#[cfg(feature = "reusable-containers")]
#[tokio::test]
async fn async_containers_are_reused() -> anyhow::Result<()> {
use crate::ImageExt;

let labels = [
("foo", "bar"),
("baz", "qux"),
("test-name", "async_containers_are_reused"),
];

let initial_image = GenericImage::new("testcontainers/helloworld", "1.1.0")
.with_reuse(true)
.with_labels(labels);

let reused_image = initial_image
.image
.clone()
.with_reuse(true)
.with_labels(labels);

let initial_container = initial_image.start().await?;
let reused_container = reused_image.start().await?;

assert_eq!(initial_container.id(), reused_container.id());

let client = crate::core::client::docker_client_instance().await?;

let options = bollard::container::ListContainersOptions {
all: false,
limit: Some(2),
size: false,
filters: std::collections::HashMap::from_iter([(
"label".to_string(),
labels
.iter()
.map(|(key, value)| format!("{key}={value}"))
.chain([
"org.testcontainers.reuse=true".to_string(),
"org.testcontainers.managed-by=testcontainers".to_string(),
format!(
"org.testcontainers.session-id={}",
crate::runners::async_runner::session_id()
),
])
.collect(),
)]),
};

let containers = client.list_containers(Some(options)).await?;

assert_eq!(containers.len(), 1);

assert_eq!(
Some(initial_container.id()),
containers.first().unwrap().id.as_deref()
);

reused_container.rm().await.map_err(anyhow::Error::from)
}

#[cfg(feature = "reusable-containers")]
#[tokio::test]
async fn async_reused_containers_are_not_confused() -> anyhow::Result<()> {
use std::collections::HashSet;

use crate::ImageExt;

let labels = [
("foo", "bar"),
("baz", "qux"),
("test-name", "async_reused_containers_are_not_confused"),
];

let initial_image = GenericImage::new("testcontainers/helloworld", "1.1.0")
.with_reuse(true)
.with_labels(labels);

let similar_image = initial_image
.image
.clone()
.with_reuse(false)
.with_labels(&initial_image.labels);

let initial_container = initial_image.start().await?;
let similar_container = similar_image.start().await?;

assert_ne!(initial_container.id(), similar_container.id());

let client = crate::core::client::docker_client_instance().await?;

let options = bollard::container::ListContainersOptions {
all: false,
limit: Some(2),
size: false,
filters: std::collections::HashMap::from_iter([(
"label".to_string(),
labels
.iter()
.map(|(key, value)| format!("{key}={value}"))
.chain([
"org.testcontainers.managed-by=testcontainers".to_string(),
format!(
"org.testcontainers.session-id={}",
crate::runners::async_runner::session_id()
),
])
.collect(),
)]),
};

let containers = client.list_containers(Some(options)).await?;

assert_eq!(containers.len(), 2);

let container_ids = containers
.iter()
.filter_map(|container| container.id.as_deref())
.collect::<std::collections::HashSet<_>>();

assert_eq!(
container_ids,
HashSet::from_iter([initial_container.id(), similar_container.id()])
);

initial_container.rm().await?;
similar_container.rm().await.map_err(anyhow::Error::from)
}

#[cfg(feature = "reusable-containers")]
#[tokio::test]
async fn async_reusable_containers_are_not_dropped() -> anyhow::Result<()> {
use crate::ImageExt;

let client = crate::core::client::docker_client_instance().await?;

let image = GenericImage::new("testcontainers/helloworld", "1.1.0")
.with_reuse(true)
.with_labels([
("foo", "bar"),
("baz", "qux"),
("test-name", "async_reusable_containers_are_not_dropped"),
]);

let container_id = {
let container = image.start().await?;

assert!(!container.dropped && container.reuse);

container.id().to_string()
};

assert!(client
.inspect_container(&container_id, None)
.await?
.state
.and_then(|state| state.running)
.unwrap_or(false));

client
.remove_container(
&container_id,
Some(bollard::container::RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
.map_err(anyhow::Error::from)
}
}
Loading
Loading