use std::any::{type_name, Any, TypeId};
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::rc::Rc;
use std::time::Duration;
use crate::cleanup::Cleanup;
use bollard::Docker;
use futures::future::{join_all, try_join_all};
use tokio::runtime::{Handle, RuntimeFlavor};
use crate::config::Config;
use crate::container::Container;
use crate::error::TestError;
use crate::host::Host;
use crate::network::Network;
use crate::runner::Test;
use crate::service::Service;
use crate::services::httpmock::HttpMockConfig;
struct UntypedConfig {
erased: Rc<dyn Any>,
source: Rc<dyn Config>,
}
impl UntypedConfig {
fn new<T: Config + 'static>(config: T) -> Self {
let erased: Rc<dyn Any> = Rc::new(config);
let source: Rc<dyn Config> = erased.clone().downcast::<T>().unwrap();
Self { erased, source }
}
fn upcast(&self) -> &dyn Config {
self.source.as_ref()
}
fn downcast<T: Config + 'static>(&self) -> &T {
self.erased.downcast_ref().unwrap()
}
}
struct Inner {
configs: HashMap<TypeId, HashMap<String, UntypedConfig>>,
containers: HashMap<String, Container>,
network: Network,
test: Rc<Test>,
}
impl Inner {
fn configs<T: Service + 'static>(&self) -> Result<&HashMap<String, UntypedConfig>, TestError> {
self.configs
.get(&TypeId::of::<T::Config>())
.ok_or(TestError::UnknownService(type_name::<T>()))
}
fn service<T: Service + 'static>(&self) -> Result<T, TestError> {
let config = self.configs::<T>()?.values().next().unwrap();
let container = self.containers.get(config.upcast().hostname()).unwrap();
Ok(T::new(config.downcast(), container))
}
fn service_by_hostname<T: Service + 'static>(&self, hostname: &str) -> Result<T, TestError> {
let config = self
.configs::<T>()?
.get(hostname)
.ok_or_else(|| TestError::UnknownServiceHostname(hostname.to_string()))?;
let container = self.containers.get(config.upcast().hostname()).unwrap();
Ok(T::new(config.downcast(), container))
}
}
pub struct TestComposite {
inner: Option<Inner>,
}
impl TestComposite {
pub fn builder() -> TestCompositeBuilder {
TestCompositeBuilder::new()
}
pub fn service<T: Service + 'static>(&self) -> Result<T, TestError> {
self.inner().service()
}
pub fn service_by_hostname<T: Service + 'static>(&self, name: &str) -> Result<T, TestError> {
self.inner().service_by_hostname(name)
}
fn inner(&self) -> &Inner {
self.inner.as_ref().unwrap()
}
}
fn check_runtime() -> Result<(), TestError> {
let handle = Handle::try_current()?;
if !matches!(handle.runtime_flavor(), RuntimeFlavor::MultiThread) {
return Err(TestError::UnavailableMultiThread);
}
Ok(())
}
async fn check_docker(docker: &Docker) -> Result<(), TestError> {
match docker.ping().await {
Ok(_) => Ok(()),
Err(err) => Err(TestError::UnavailableDocker(err.into())),
}
}
pub struct TestCompositeBuilder {
configs: HashMap<TypeId, HashMap<String, UntypedConfig>>,
}
impl TestCompositeBuilder {
fn new() -> Self {
Self {
configs: HashMap::new(),
}
}
pub fn with_service<C: Config + 'static>(mut self, config: C) -> Self {
let entry = self
.configs
.entry(TypeId::of::<C>())
.or_default()
.entry(config.hostname().to_string());
match entry {
Entry::Occupied(_) => panic!("Name {} configured twice", config.hostname()),
Entry::Vacant(e) => e.insert(UntypedConfig::new(config)),
};
self
}
pub async fn build(self) -> Result<TestComposite, TestError> {
check_runtime()?;
let httpmock_configs_len = self
.configs
.get(&TypeId::of::<HttpMockConfig>())
.map(|f| f.len())
.unwrap_or(0);
if httpmock_configs_len > 1 {
return Err(TestError::NotSupportedConfig(
"Only 1 HttpMock can be defined per test".to_string(),
));
}
let test = Test::current()?;
log::info!(
"Framework starting environment module={} test={}",
test.module(),
test.name()
);
let docker = Docker::connect_with_local_defaults()?;
check_docker(&docker).await?;
log::debug!("Framework docker ping OK");
let host = Host::current(&docker).await?;
log::debug!("Framework host mode = {:?}", host.mode());
Cleanup::new(docker.clone()).purge().await?;
let mut network = Network::new(docker.clone()).await?;
log::info!("Framework created docker network id={}", network.id());
if let Some(host_container) = host.container() {
log::info!("Creating testing environment in containerized mode.");
network.connect(host_container.id()).await?;
} else {
log::info!("Creating testing environment in standalone mode.");
}
let starts = self.configs.iter().flat_map(|(_, configs)| {
configs.values().map(|config| {
log::info!(
"Framework initializing service hostname={}",
config.upcast().hostname()
);
Container::initialized(
docker.clone(),
test.clone(),
host.mode(),
&network,
config.upcast(),
)
})
});
let containers = try_join_all(starts)
.await?
.into_iter()
.map(|c| (c.config().hostname().to_string(), c));
Ok(TestComposite {
inner: Some(Inner {
configs: self.configs,
containers: containers.collect(),
network,
test: test.clone(),
}),
})
}
}
impl Drop for TestComposite {
fn drop(&mut self) {
let Inner {
mut network,
containers,
test,
..
} = self.inner.take().unwrap();
tokio::task::block_in_place(|| {
log::info!("Dropping testing environment.");
Handle::current().block_on(async {
if !test.is_success() {
tokio::time::sleep(Duration::from_secs(1)).await;
}
join_all(containers.into_values().map(|mut container| async move {
container.dispose().await;
}))
.await;
network.remove().await;
})
});
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use bollard::container::{CreateContainerOptions, NetworkingConfig};
use bollard::errors::Error as BollardError;
use bollard::network::CreateNetworkOptions;
use bollard::secret::EndpointSettings;
use bollard::Docker;
use crate::constants::NETWORK_NAME;
use crate::error::TestError;
use crate::image::Image;
use crate::runner::Test;
use crate::services::httpbin::HttpBinConfig;
use super::TestComposite;
#[tokio::test]
async fn multi_thread_required_error() {
let result = TestComposite::builder().build().await;
assert!(matches!(result, Err(TestError::UnavailableMultiThread)));
}
#[test]
fn runtime_required() {
let result = futures::executor::block_on(TestComposite::builder().build());
assert!(matches!(result, Err(TestError::UnavailableRuntime(_))));
}
#[test]
fn create_container_logs() {
let test = Test::builder().module("foo").name("bar").build();
let target_dir = test.target_dir().to_owned();
let _ = test.run(async {
let s1 = HttpBinConfig::builder().hostname("service-1").build();
let s2 = HttpBinConfig::builder().hostname("service-2").build();
let _ = TestComposite::builder()
.with_service(s1)
.with_service(s2)
.build()
.await?;
assert!(target_dir.join("service-1.log").exists());
assert!(target_dir.join("service-2.log").exists());
Ok::<_, TestError>(())
});
assert!(!target_dir.join("service-1.log").exists());
assert!(!target_dir.join("service-2.log").exists());
}
#[test]
fn drop_network() {
let docker = Docker::connect_with_local_defaults().unwrap();
let test = Test::builder().module("foo").name("bar").build();
let _ = test.run(async {
let s1 = HttpBinConfig::builder().hostname("service-1").build();
let s2 = HttpBinConfig::builder().hostname("service-2").build();
let _tc = TestComposite::builder()
.with_service(s1)
.with_service(s2)
.build()
.await?;
let result = docker.inspect_network::<String>(NETWORK_NAME, None).await;
assert!(result.is_ok());
Ok::<_, TestError>(())
});
let runtime = tokio::runtime::Runtime::new().unwrap();
let result = runtime.block_on(docker.inspect_network::<String>(NETWORK_NAME, None));
assert!(matches!(
result,
Err(BollardError::DockerResponseServerError {
status_code: 404,
..
})
));
}
#[test]
fn purge_test_assets() -> Result<(), TestError> {
let test = Test::builder().module("foo").name("bar").build();
test.run(async {
let docker = bollard::Docker::connect_with_local_defaults()?;
let hello_world_image = Image::from_repository("hello-world").with_version("linux");
hello_world_image.pull(&docker).await?;
let network = docker
.create_network(CreateNetworkOptions {
name: "pdk-test-network",
driver: "bridge",
labels: HashMap::from([("CreatedBy", "pdk-test")]),
..Default::default()
})
.await?;
let net_id = network.id;
let hello_world_locator = hello_world_image.locator();
let hello_world_name = "hello-world";
let container = docker
.create_container(
Some(CreateContainerOptions {
name: hello_world_name,
platform: None,
}),
bollard::container::Config {
image: Some(hello_world_locator.as_str()),
hostname: Some("helloWorld"),
network_disabled: Some(false),
networking_config: Some(NetworkingConfig {
endpoints_config: HashMap::from([(
net_id.as_str(),
EndpointSettings {
..Default::default()
},
)]),
}),
labels: Some(HashMap::from([("CreatedBy", "pdk-test")])),
..Default::default()
},
)
.await?;
docker.start_container::<&str>(&container.id, None).await?;
let hello_world_inspect = docker.inspect_container(hello_world_name, None).await;
assert!(hello_world_inspect.is_ok());
let httpbin_config = HttpBinConfig::builder().hostname("httpbin").build();
let _composite = TestComposite::builder()
.with_service(httpbin_config)
.build()
.await?;
let hello_world_inspect = docker.inspect_container(hello_world_name, None).await;
assert!(matches!(
hello_world_inspect,
Err(BollardError::DockerResponseServerError {
status_code: 404,
..
})
));
Ok(())
})
}
}