From 54e80e44264c0565cf3825730c17ded00e3ac57c Mon Sep 17 00:00:00 2001 From: Artem Medvedev Date: Sun, 26 May 2024 20:25:35 +0200 Subject: [PATCH] feat!: add container startup timeout with default of 1 minute (#643) Closes #247 - Sets default startup timeout to 1 minute - Allows to override with `RunnableImage::with_startup_timeout` Note: breaking change because of default timeout For now, ENV variable to configure global timeout isn't introduced. First of all, that's quote similar to other languages, (e.g [Go](https://golang.testcontainers.org/features/wait/introduction/#startup-timeout-and-poll-interval), [Java](https://java.testcontainers.org/features/startup_and_waits/)) Another question is which one should take precedence: ENV over `with_startup_timeout` or vice versa? We can extend it later in compatible way, so not that important at the moment. --- testcontainers/src/core/error.rs | 2 + .../src/core/image/runnable_image.rs | 17 +- testcontainers/src/runners/async_runner.rs | 312 +++++++++--------- 3 files changed, 179 insertions(+), 152 deletions(-) diff --git a/testcontainers/src/core/error.rs b/testcontainers/src/core/error.rs index c516645a..d1d754fe 100644 --- a/testcontainers/src/core/error.rs +++ b/testcontainers/src/core/error.rs @@ -58,6 +58,8 @@ pub enum WaitContainerError { HealthCheckNotConfigured(String), #[error("container is unhealthy")] Unhealthy, + #[error("container startup timeout")] + StartupTimeout, } impl TestcontainersError { diff --git a/testcontainers/src/core/image/runnable_image.rs b/testcontainers/src/core/image/runnable_image.rs index 2faf1310..c994b98e 100644 --- a/testcontainers/src/core/image/runnable_image.rs +++ b/testcontainers/src/core/image/runnable_image.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, net::IpAddr}; +use std::{collections::BTreeMap, net::IpAddr, time::Duration}; use crate::{ core::{mounts::Mount, ContainerState, ExecCommand, WaitFor}, @@ -23,6 +23,7 @@ pub struct RunnableImage { shm_size: Option, cgroupns_mode: Option, userns_mode: Option, + startup_timeout: Option, } /// Represents a port mapping between a local port and the internal port of a container. @@ -124,6 +125,11 @@ impl RunnableImage { ) -> Result, TestcontainersError> { self.image.exec_after_start(cs) } + + /// Returns the startup timeout for the container. + pub fn startup_timeout(&self) -> Option { + self.startup_timeout + } } impl RunnableImage { @@ -246,6 +252,14 @@ impl RunnableImage { ..self } } + + /// Sets the startup timeout for the container. The default is 60 seconds. + pub fn with_startup_timeout(self, timeout: Duration) -> Self { + Self { + startup_timeout: Some(timeout), + ..self + } + } } impl From for RunnableImage @@ -275,6 +289,7 @@ impl From<(I, I::Args)> for RunnableImage { shm_size: None, cgroupns_mode: None, userns_mode: None, + startup_timeout: None, } } } diff --git a/testcontainers/src/runners/async_runner.rs b/testcontainers/src/runners/async_runner.rs index 2a66a642..8f73a616 100644 --- a/testcontainers/src/runners/async_runner.rs +++ b/testcontainers/src/runners/async_runner.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, time::Duration}; use async_trait::async_trait; use bollard::{ @@ -10,7 +10,7 @@ use bollard_stubs::models::HostConfigCgroupnsModeEnum; use crate::{ core::{ client::{Client, ClientError}, - error::Result, + error::{Result, WaitContainerError}, mounts::{AccessMode, Mount, MountType}, network::Network, CgroupnsMode, ContainerState, @@ -18,6 +18,8 @@ use crate::{ ContainerAsync, Image, ImageArgs, RunnableImage, }; +const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(60); + #[async_trait] /// Helper trait to start containers asynchronously. /// @@ -50,175 +52,183 @@ where I: Image, { async fn start(self) -> Result> { - let client = Client::lazy_client().await?; let runnable_image = self.into(); - let mut create_options: Option> = None; - - let extra_hosts: Vec<_> = runnable_image - .hosts() - .map(|(key, value)| format!("{key}:{value}")) - .collect(); - - let mut config: Config = Config { - image: Some(runnable_image.descriptor()), - host_config: Some(HostConfig { - privileged: Some(runnable_image.privileged()), - extra_hosts: Some(extra_hosts), - cgroupns_mode: runnable_image.cgroupns_mode().map(|mode| mode.into()), - userns_mode: runnable_image.userns_mode(), - ..Default::default() - }), - ..Default::default() - }; + let startup_timeout = runnable_image + .startup_timeout() + .unwrap_or(DEFAULT_STARTUP_TIMEOUT); - // shared memory - if let Some(bytes) = runnable_image.shm_size() { - config.host_config = config.host_config.map(|mut host_config| { - host_config.shm_size = Some(bytes as i64); - host_config - }); - } - - // create network and add it to container creation - let network = if let Some(network) = runnable_image.network() { - config.host_config = config.host_config.map(|mut host_config| { - host_config.network_mode = Some(network.to_string()); - host_config - }); - Network::new(network, client.clone()).await? - } else { - None - }; + tokio::time::timeout(startup_timeout, async { + let client = Client::lazy_client().await?; + let mut create_options: Option> = None; - // name of the container - if let Some(name) = runnable_image.container_name() { - create_options = Some(CreateContainerOptions { - name: name.to_owned(), - platform: None, - }) - } + let extra_hosts: Vec<_> = runnable_image + .hosts() + .map(|(key, value)| format!("{key}:{value}")) + .collect(); - // handle environment variables - let envs: Vec = runnable_image - .env_vars() - .map(|(k, v)| format!("{k}={v}")) - .collect(); - config.env = Some(envs); - - // mounts and volumes - let mounts: Vec<_> = runnable_image.mounts().map(Into::into).collect(); - - if !mounts.is_empty() { - config.host_config = config.host_config.map(|mut host_config| { - host_config.mounts = Some(mounts); - host_config - }); - } + let mut config: Config = Config { + image: Some(runnable_image.descriptor()), + host_config: Some(HostConfig { + privileged: Some(runnable_image.privileged()), + extra_hosts: Some(extra_hosts), + cgroupns_mode: runnable_image.cgroupns_mode().map(|mode| mode.into()), + userns_mode: runnable_image.userns_mode(), + ..Default::default() + }), + ..Default::default() + }; - // entrypoint - if let Some(entrypoint) = runnable_image.entrypoint() { - config.entrypoint = Some(vec![entrypoint]); - } + // shared memory + if let Some(bytes) = runnable_image.shm_size() { + config.host_config = config.host_config.map(|mut host_config| { + host_config.shm_size = Some(bytes as i64); + host_config + }); + } - let is_container_networked = runnable_image - .network() - .as_ref() - .map(|network| network.starts_with("container:")) - .unwrap_or(false); + // create network and add it to container creation + let network = if let Some(network) = runnable_image.network() { + config.host_config = config.host_config.map(|mut host_config| { + host_config.network_mode = Some(network.to_string()); + host_config + }); + Network::new(network, client.clone()).await? + } else { + None + }; + + // name of the container + if let Some(name) = runnable_image.container_name() { + create_options = Some(CreateContainerOptions { + name: name.to_owned(), + platform: None, + }) + } - // expose ports - if !is_container_networked { - let mapped_ports = runnable_image - .ports() - .as_ref() - .map(|ports| ports.iter().map(|p| p.internal).collect::>()) - .unwrap_or_default(); - - let ports_to_expose = runnable_image - .expose_ports() - .iter() - .copied() - .chain(mapped_ports) - .map(|p| (format!("{p}/tcp"), HashMap::new())) + // handle environment variables + let envs: Vec = runnable_image + .env_vars() + .map(|(k, v)| format!("{k}={v}")) .collect(); + config.env = Some(envs); - // exposed ports of the image + mapped ports - config.exposed_ports = Some(ports_to_expose); - } + // mounts and volumes + let mounts: Vec<_> = runnable_image.mounts().map(Into::into).collect(); - // ports - if runnable_image.ports().is_some() { - let empty: Vec<_> = Vec::new(); - let bindings = runnable_image - .ports() - .as_ref() - .unwrap_or(&empty) - .iter() - .map(|p| { - ( - format!("{}/tcp", p.internal), - Some(vec![PortBinding { - host_ip: None, - host_port: Some(p.local.to_string()), - }]), - ) + if !mounts.is_empty() { + config.host_config = config.host_config.map(|mut host_config| { + host_config.mounts = Some(mounts); + host_config }); + } - config.host_config = config.host_config.map(|mut host_config| { - host_config.port_bindings = Some(bindings.collect()); - host_config - }); - } else if !is_container_networked { - config.host_config = config.host_config.map(|mut host_config| { - host_config.publish_all_ports = Some(true); - host_config - }); - } + // entrypoint + if let Some(entrypoint) = runnable_image.entrypoint() { + config.entrypoint = Some(vec![entrypoint]); + } - let args = runnable_image - .args() - .clone() - .into_iterator() - .collect::>(); - if !args.is_empty() { - config.cmd = Some(args); - } + let is_container_networked = runnable_image + .network() + .as_ref() + .map(|network| network.starts_with("container:")) + .unwrap_or(false); + + // expose ports + if !is_container_networked { + let mapped_ports = runnable_image + .ports() + .as_ref() + .map(|ports| ports.iter().map(|p| p.internal).collect::>()) + .unwrap_or_default(); + + let ports_to_expose = runnable_image + .expose_ports() + .iter() + .copied() + .chain(mapped_ports) + .map(|p| (format!("{p}/tcp"), HashMap::new())) + .collect(); + + // exposed ports of the image + mapped ports + config.exposed_ports = Some(ports_to_expose); + } + + // ports + if runnable_image.ports().is_some() { + let empty: Vec<_> = Vec::new(); + let bindings = runnable_image + .ports() + .as_ref() + .unwrap_or(&empty) + .iter() + .map(|p| { + ( + format!("{}/tcp", p.internal), + Some(vec![PortBinding { + host_ip: None, + host_port: Some(p.local.to_string()), + }]), + ) + }); + + config.host_config = config.host_config.map(|mut host_config| { + host_config.port_bindings = Some(bindings.collect()); + host_config + }); + } else if !is_container_networked { + config.host_config = config.host_config.map(|mut host_config| { + host_config.publish_all_ports = Some(true); + host_config + }); + } - // create the container with options - let create_result = client - .create_container(create_options.clone(), config.clone()) - .await; - let container_id = match create_result { - Ok(id) => Ok(id), - Err(ClientError::CreateContainer( - bollard::errors::Error::DockerResponseServerError { - status_code: 404, .. - }, - )) => { - client.pull_image(&runnable_image.descriptor()).await?; - client.create_container(create_options, config).await + let args = runnable_image + .args() + .clone() + .into_iterator() + .collect::>(); + if !args.is_empty() { + config.cmd = Some(args); } - res => res, - }?; - #[cfg(feature = "watchdog")] - if client.config.command() == crate::core::env::Command::Remove { - crate::watchdog::register(container_id.clone()); - } + // create the container with options + let create_result = client + .create_container(create_options.clone(), config.clone()) + .await; + let container_id = match create_result { + Ok(id) => Ok(id), + Err(ClientError::CreateContainer( + bollard::errors::Error::DockerResponseServerError { + status_code: 404, .. + }, + )) => { + client.pull_image(&runnable_image.descriptor()).await?; + client.create_container(create_options, config).await + } + res => res, + }?; + + #[cfg(feature = "watchdog")] + if client.config.command() == crate::core::env::Command::Remove { + crate::watchdog::register(container_id.clone()); + } - client.start_container(&container_id).await?; + client.start_container(&container_id).await?; - let container = - ContainerAsync::new(container_id, client.clone(), runnable_image, network).await?; + let container = + ContainerAsync::new(container_id, client.clone(), runnable_image, network).await?; - for cmd in container - .image() - .exec_after_start(ContainerState::new(container.ports().await?))? - { - container.exec(cmd).await?; - } + for cmd in container + .image() + .exec_after_start(ContainerState::new(container.ports().await?))? + { + container.exec(cmd).await?; + } - Ok(container) + Ok(container) + }) + .await + .map_err(|_| WaitContainerError::StartupTimeout)? } async fn pull_image(self) -> Result> {