//! Support for testing using Docker containers. //! //! The [`Container`] type is a builder for configuring a container to run. //! After you call `launch`, you can use the [`ContainerHandle`] to interact //! with the running container. //! //! Tests using containers must use `#[cargo_test(container_test)]` to disable //! them unless the CARGO_CONTAINER_TESTS environment variable is set. use cargo_util::ProcessBuilder; use std::collections::HashMap; use std::io::Read; use std::path::PathBuf; use std::process::Command; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Mutex; use tar::Header; /// A builder for configuring a container to run. pub struct Container { /// The host directory that forms the basis of the Docker image. build_context: PathBuf, /// Files to copy over to the image. files: Vec, } /// A handle to a running container. /// /// You can use this to interact with the container. pub struct ContainerHandle { /// The name of the container. name: String, /// The IP address of the container. /// /// NOTE: This is currently unused, but may be useful so I left it in. /// This can only be used on Linux. macOS and Windows docker doesn't allow /// direct connection to the container. pub ip_address: String, /// Port mappings of container_port to host_port for ports exposed via EXPOSE. pub port_mappings: HashMap, } impl Container { pub fn new(context_dir: &str) -> Container { assert!(std::env::var_os("CARGO_CONTAINER_TESTS").is_some()); let mut build_context = PathBuf::from(env!("CARGO_MANIFEST_DIR")); build_context.push("containers"); build_context.push(context_dir); Container { build_context, files: Vec::new(), } } /// Adds a file to be copied into the container. pub fn file(mut self, file: MkFile) -> Self { self.files.push(file); self } /// Starts the container. pub fn launch(mut self) -> ContainerHandle { static NEXT_ID: AtomicUsize = AtomicUsize::new(0); let id = NEXT_ID.fetch_add(1, Ordering::SeqCst); let name = format!("cargo_test_{id}"); remove_if_exists(&name); self.create_container(&name); self.copy_files(&name); self.start_container(&name); let info = self.container_inspect(&name); let ip_address = if cfg!(target_os = "linux") { info[0]["NetworkSettings"]["IPAddress"] .as_str() .unwrap() .to_string() } else { // macOS and Windows can't make direct connections to the // container. It only works through exposed ports or mapped ports. "127.0.0.1".to_string() }; let port_mappings = self.port_mappings(&info); self.wait_till_ready(&port_mappings); ContainerHandle { name, ip_address, port_mappings, } } fn create_container(&self, name: &str) { static BUILD_LOCK: Mutex<()> = Mutex::new(()); let image_base = self.build_context.file_name().unwrap(); let image_name = format!("cargo-test-{}", image_base.to_str().unwrap()); let _lock = BUILD_LOCK.lock().unwrap(); ProcessBuilder::new("docker") .args(&["build", "--tag", image_name.as_str()]) .arg(&self.build_context) .exec_with_output() .unwrap(); ProcessBuilder::new("docker") .args(&[ "container", "create", "--publish-all", "--rm", "--name", name, ]) .arg(image_name) .exec_with_output() .unwrap(); } fn copy_files(&mut self, name: &str) { if self.files.is_empty() { return; } let mut ar = tar::Builder::new(Vec::new()); let files = std::mem::replace(&mut self.files, Vec::new()); for mut file in files { ar.append_data(&mut file.header, &file.path, file.contents.as_slice()) .unwrap(); } let ar = ar.into_inner().unwrap(); ProcessBuilder::new("docker") .args(&["cp", "-"]) .arg(format!("{name}:/")) .stdin(ar) .exec_with_output() .unwrap(); } fn start_container(&self, name: &str) { ProcessBuilder::new("docker") .args(&["container", "start"]) .arg(name) .exec_with_output() .unwrap(); } fn container_inspect(&self, name: &str) -> serde_json::Value { let output = ProcessBuilder::new("docker") .args(&["inspect", name]) .exec_with_output() .unwrap(); serde_json::from_slice(&output.stdout).unwrap() } /// Returns the mapping of container_port->host_port for ports that were /// exposed with EXPOSE. fn port_mappings(&self, info: &serde_json::Value) -> HashMap { info[0]["NetworkSettings"]["Ports"] .as_object() .unwrap() .iter() .map(|(key, value)| { let key = key .strip_suffix("/tcp") .expect("expected TCP only ports") .parse() .unwrap(); let values = value.as_array().unwrap(); let value = values .iter() .find(|value| value["HostIp"].as_str().unwrap() == "0.0.0.0") .expect("expected localhost IP"); let host_port = value["HostPort"].as_str().unwrap().parse().unwrap(); (key, host_port) }) .collect() } fn wait_till_ready(&self, port_mappings: &HashMap) { for port in port_mappings.values() { let mut ok = false; for _ in 0..30 { match std::net::TcpStream::connect(format!("127.0.0.1:{port}")) { Ok(_) => { ok = true; break; } Err(e) => { if e.kind() != std::io::ErrorKind::ConnectionRefused { panic!("unexpected localhost connection error: {e:?}"); } std::thread::sleep(std::time::Duration::new(1, 0)); } } } if !ok { panic!("no listener on localhost port {port}"); } } } } impl ContainerHandle { /// Executes a program inside a running container. pub fn exec(&self, args: &[&str]) -> std::process::Output { ProcessBuilder::new("docker") .args(&["container", "exec", &self.name]) .args(args) .exec_with_output() .unwrap() } /// Returns the contents of a file inside the container. pub fn read_file(&self, path: &str) -> String { let output = ProcessBuilder::new("docker") .args(&["cp", &format!("{}:{}", self.name, path), "-"]) .exec_with_output() .unwrap(); let mut ar = tar::Archive::new(output.stdout.as_slice()); let mut entry = ar.entries().unwrap().next().unwrap().unwrap(); let mut contents = String::new(); entry.read_to_string(&mut contents).unwrap(); contents } } impl Drop for ContainerHandle { fn drop(&mut self) { // To help with debugging, this will keep the container alive. if std::env::var_os("CARGO_CONTAINER_TEST_KEEP").is_some() { return; } remove_if_exists(&self.name); } } fn remove_if_exists(name: &str) { if let Err(e) = Command::new("docker") .args(&["container", "rm", "--force", name]) .output() { panic!("failed to run docker: {e}"); } } /// Builder for configuring a file to copy into a container. pub struct MkFile { path: String, contents: Vec, header: Header, } impl MkFile { /// Defines a file to add to the container. /// /// This should be passed to `Container::file`. /// /// The path is the path inside the container to create the file. pub fn path(path: &str) -> MkFile { MkFile { path: path.to_string(), contents: Vec::new(), header: Header::new_gnu(), } } pub fn contents(mut self, contents: impl Into>) -> Self { self.contents = contents.into(); self.header.set_size(self.contents.len() as u64); self } pub fn mode(mut self, mode: u32) -> Self { self.header.set_mode(mode); self } pub fn uid(mut self, uid: u64) -> Self { self.header.set_uid(uid); self } pub fn gid(mut self, gid: u64) -> Self { self.header.set_gid(gid); self } }