use crate::container::RunningContainer;
use crate::engine::{bootstrap, Debris, Engine, Orbiting};
use crate::utils::{connect_with_local_or_tls_defaults, generate_random_string};
use crate::{DockerTest, DockerTestError};
use bollard::{
network::{CreateNetworkOptions, DisconnectNetworkOptions},
volume::RemoveVolumeOptions,
Docker,
};
use futures::future::{join_all, Future};
use tracing::{error, event, trace, Level};
use std::any::Any;
use std::clone::Clone;
use std::collections::HashMap;
use std::panic;
pub(crate) struct Runner {
client: Docker,
config: DockerTest,
named_volumes: Vec<String>,
network: String,
pub(crate) id: String,
}
#[derive(Clone)]
pub struct DockerOperations {
engine: Engine<Orbiting>,
}
enum PruneStrategy {
RunningRegardless,
RunningOnFailure,
StopOnFailure,
RemoveRegardless,
}
impl DockerOperations {
fn try_handle<'a>(&'a self, handle: &'a str) -> Result<&'a RunningContainer, DockerTestError> {
if self.engine.handle_collision(handle) {
return Err(DockerTestError::TestBody(format!(
"handle '{}' defined multiple times",
handle
)));
}
self.engine.resolve_handle(handle).ok_or_else(|| {
DockerTestError::TestBody(format!("container with handle '{}' not found", handle))
})
}
pub fn handle<'a>(&'a self, handle: &'a str) -> &'a RunningContainer {
event!(Level::DEBUG, "requesting handle '{}", handle);
match self.try_handle(handle) {
Ok(h) => h,
Err(e) => {
event!(Level::ERROR, "{}", e.to_string());
panic!("{}", e);
}
}
}
pub fn failure(&self, msg: &str) {
event!(Level::ERROR, "test failure: {}", msg);
panic!("test failure: {}", msg);
}
}
impl Runner {
pub fn new(config: DockerTest) -> Runner {
Self::try_new(config).unwrap()
}
pub fn try_new(config: DockerTest) -> Result<Runner, DockerTestError> {
let client = connect_with_local_or_tls_defaults()?;
let id = generate_random_string(20);
Ok(Runner {
client,
named_volumes: Vec::new(),
network: config
.external_network
.as_ref()
.cloned()
.unwrap_or_else(|| format!("dockertest-rs-{}", id)),
id,
config,
})
}
pub async fn run_impl<T, Fut>(mut self, test: T) -> Result<(), DockerTestError>
where
T: FnOnce(DockerOperations) -> Fut,
Fut: Future<Output = ()> + Send + 'static,
{
self.check_if_inside_container();
self.resolve_named_volumes().await?;
let compositions = std::mem::take(&mut self.config.compositions);
let mut engine = bootstrap(compositions);
engine.resolve_final_container_name(&self.config.namespace);
let mut engine = engine.fuel();
engine.resolve_inject_container_name_env()?;
engine
.pull_images(&self.client, &self.config.default_source)
.await?;
if self.config.external_network.is_none() {
self.create_network().await?;
}
let engine = match engine
.ignite(
&self.client,
&self.network,
self.config.external_network.is_some(),
)
.await
{
Ok(e) => e,
Err(engine) => {
let mut creation_failures = engine.creation_failures();
let total = creation_failures.len();
creation_failures.iter().enumerate().for_each(|(i, e)| {
trace!("container {} of {} creation failures: {}", i + 1, total, e);
});
let engine = engine.decommission();
self.teardown(engine, false).await;
return Err(creation_failures
.pop()
.expect("dockertest bug: cleanup path expected container creation error"));
}
};
let mut engine = match engine.orbiting().await {
Ok(e) => e,
Err((engine, e)) => {
let engine = engine.decommission();
self.teardown(engine, false).await;
return Err(e);
}
};
if let Err(mut errors) = engine.inspect(&self.client, &self.network).await {
let total = errors.len();
errors.iter().enumerate().for_each(|(i, e)| {
trace!("container {} of {} inspect failures: {}", i + 1, total, e);
});
let engine = engine.decommission();
self.teardown(engine, false).await;
return Err(errors
.pop()
.expect("dockertest bug: cleanup path expected container inspect error"));
};
let ops = DockerOperations {
engine: engine.clone(),
};
let result: Result<(), Option<Box<dyn Any + Send + 'static>>> =
match tokio::spawn(test(ops)).await {
Ok(_) => {
event!(Level::DEBUG, "test body success");
Ok(())
}
Err(e) => {
event!(
Level::DEBUG,
"test body failed (cancelled: {}, panicked: {})",
e.is_cancelled(),
e.is_panic()
);
Err(e.try_into_panic().ok())
}
};
let engine = engine.decommission();
if let Err(e) = engine.handle_logs(result.is_err()).await {
error!("{e}");
}
self.teardown(engine, result.is_err()).await;
if let Err(option) = result {
match option {
Some(panic) => panic::resume_unwind(panic),
None => panic!("test future cancelled"),
}
}
Ok(())
}
fn check_if_inside_container(&mut self) {
if let Ok(id) = std::env::var("DOCKERTEST_CONTAINER_ID_INJECT_TO_NETWORK") {
event!(
Level::TRACE,
"dockertest container id env is set, we are running inside a container, id: {}",
id
);
self.config.container_id = Some(id);
} else {
event!(
Level::TRACE,
"dockertest container id env is not set, running native on host"
);
}
}
async fn create_network(&self) -> Result<(), DockerTestError> {
let config = CreateNetworkOptions {
name: self.network.as_str(),
..Default::default()
};
event!(Level::TRACE, "creating network {}", self.network);
let res = self
.client
.create_network(config)
.await
.map(|_| ())
.map_err(|e| {
DockerTestError::Startup(format!("creating docker network failed: {}", e))
});
event!(
Level::TRACE,
"finished created network with result: {}",
res.is_ok()
);
if let Some(id) = self.config.container_id.clone() {
if let Err(e) = self.add_self_to_network(id).await {
if let Err(e) = self.client.remove_network(&self.network).await {
event!(
Level::ERROR,
"unable to remove docker network `{}`: {}",
self.network,
e
);
}
return Err(e);
}
}
res
}
async fn add_self_to_network(&self, id: String) -> Result<(), DockerTestError> {
event!(
Level::TRACE,
"adding dockertest container to created network, container_id: {}, network_id: {}",
&id,
&self.network
);
let opts = bollard::network::ConnectNetworkOptions {
container: id,
endpoint_config: bollard::models::EndpointSettings::default(),
};
self.client
.connect_network(&self.network, opts)
.await
.map_err(|e| {
DockerTestError::Startup(format!(
"failed to add internal container to dockertest network: {}",
e
))
})
}
async fn teardown(&self, engine: Engine<Debris>, test_failed: bool) {
engine
.disconnect_static_containers(
&self.client,
&self.network,
self.config.external_network.is_some(),
)
.await;
match env_prune_strategy() {
PruneStrategy::RunningRegardless => {
event!(
Level::DEBUG,
"Leave all containers running regardless of outcome"
);
}
PruneStrategy::RunningOnFailure if test_failed => {
event!(
Level::DEBUG,
"Leaving all containers running due to test failure"
);
}
PruneStrategy::StopOnFailure if test_failed => {
engine.stop_containers(&self.client).await;
if self.config.external_network.is_none() {
self.teardown_network().await;
}
}
PruneStrategy::StopOnFailure
| PruneStrategy::RunningOnFailure
| PruneStrategy::RemoveRegardless => {
event!(Level::DEBUG, "forcefully removing all containers");
engine.remove_containers(&self.client).await;
if self.config.external_network.is_none() {
self.teardown_network().await;
}
self.remove_volumes().await;
}
}
}
async fn remove_volumes(&self) {
join_all(
self.named_volumes
.iter()
.map(|v| {
event!(Level::INFO, "removing named volume: {:?}", &v);
let options = Some(RemoveVolumeOptions { force: true });
self.client.remove_volume(v, options)
})
.collect::<Vec<_>>(),
)
.await;
}
async fn teardown_network(&self) {
if let Some(id) = self.config.container_id.clone() {
let opts = DisconnectNetworkOptions::<&str> {
container: &id,
force: true,
};
if let Err(e) = self.client.disconnect_network(&self.network, opts).await {
event!(
Level::ERROR,
"unable to remove dockertest-container from network: {}",
e
);
}
}
if let Err(e) = self.client.remove_network(&self.network).await {
event!(
Level::ERROR,
"unable to remove docker network `{}`: {}",
self.network,
e
);
}
}
async fn resolve_named_volumes(&mut self) -> Result<(), DockerTestError> {
let mut volume_name_map: HashMap<String, String> = HashMap::new();
let suffix = self.id.clone();
self.config.compositions.iter_mut().for_each(|mut c| {
let mut volume_names_with_path: Vec<String> = Vec::new();
c.named_volumes.iter().for_each(|(id, path)| {
if let Some(suffixed_name) = volume_name_map.get(id) {
volume_names_with_path.push(format!("{}:{}", &suffixed_name, &path));
} else {
let volume_name_with_path = format!("{}-{}:{}", id, &suffix, path);
volume_names_with_path.push(volume_name_with_path);
let suffixed_volume_name = format!("{}-{}", id, &suffix);
volume_name_map.insert(id.to_string(), suffixed_volume_name);
}
});
c.final_named_volume_names = volume_names_with_path;
});
self.named_volumes = volume_name_map.drain().map(|(_k, v)| v).collect();
event!(
Level::DEBUG,
"added named volumes to cleanup list: {:?}",
&self.named_volumes
);
Ok(())
}
}
fn env_prune_strategy() -> PruneStrategy {
match std::env::var_os("DOCKERTEST_PRUNE") {
Some(val) => match val.to_string_lossy().to_lowercase().as_str() {
"stop_on_failure" => PruneStrategy::StopOnFailure,
"never" => PruneStrategy::RunningRegardless,
"running_on_failure" => PruneStrategy::RunningOnFailure,
"always" => PruneStrategy::RemoveRegardless,
_ => {
event!(Level::WARN, "unrecognized `DOCKERTEST_PRUNE = {:?}`", val);
event!(Level::DEBUG, "defaulting to prune stategy RemoveRegardless");
PruneStrategy::RemoveRegardless
}
},
None => PruneStrategy::RemoveRegardless,
}
}