Skip to content

Commit

Permalink
feat: reusable containers (#757)
Browse files Browse the repository at this point in the history
  • Loading branch information
the-wondersmith authored Dec 17, 2024
1 parent 3dc6773 commit af21727
Show file tree
Hide file tree
Showing 9 changed files with 421 additions and 23 deletions.
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
2 changes: 2 additions & 0 deletions testcontainers/src/core.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#[cfg(feature = "reusable-containers")]
pub use self::image::ReuseDirective;
pub use self::{
containers::*,
image::{ContainerState, ExecCommand, Image, ImageExt},
Expand Down
61 changes: 59 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,60 @@ 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 {
all: false,
size: false,
limit: None,
filters: filters.clone(),
});

let containers = self
.bollard
.list_containers(options)
.await
.map_err(ClientError::ListContainers)?;

if containers.len() > 1 {
log::warn!(
"Found {} containers matching filters: {:?}",
containers.len(),
filters
);
}

Ok(containers
.into_iter()
// Use `max_by_key()` instead of `next()` to ensure we're
// returning the id of most recently created container.
.max_by_key(|container| container.created.unwrap_or(i64::MIN))
.and_then(|container| container.id))
}
}

impl<BS> From<BS> for LogStream
Expand Down
215 changes: 207 additions & 8 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: crate::ReuseDirective,
}

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 @@ -339,11 +356,18 @@ where
I: fmt::Debug + Image,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ContainerAsync")
.field("id", &self.id)
let mut repr = f.debug_struct("ContainerAsync");

repr.field("id", &self.id)
.field("image", &self.image)
.field("command", &self.docker_client.config.command())
.finish()
.field("network", &self.network)
.field("dropped", &self.dropped);

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

repr.finish()
}
}

Expand All @@ -352,6 +376,17 @@ where
I: Image,
{
fn drop(&mut self) {
#[cfg(feature = "reusable-containers")]
{
use crate::ReuseDirective::{Always, CurrentSession};

if !self.dropped && matches!(self.reuse, Always | CurrentSession) {
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 +416,6 @@ where

#[cfg(test)]
mod tests {

use tokio::io::AsyncBufReadExt;

use crate::{images::generic::GenericImage, runners::AsyncRunner};
Expand Down Expand Up @@ -468,4 +502,169 @@ 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(crate::ReuseDirective::CurrentSession)
.with_labels(labels);

let reused_image = initial_image
.image
.clone()
.with_reuse(crate::ReuseDirective::CurrentSession)
.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.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, ReuseDirective};

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(ReuseDirective::Always)
.with_labels(labels);

let similar_image = initial_image
.image
.clone()
.with_reuse(ReuseDirective::Never)
.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()])
.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, ReuseDirective};

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

let image = GenericImage::new("testcontainers/helloworld", "1.1.0")
.with_reuse(ReuseDirective::Always)
.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);
assert_eq!(container.reuse, ReuseDirective::Always);

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

0 comments on commit af21727

Please sign in to comment.