| //! 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<MkFile>, |
| } |
| |
| /// 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<u16, u16>, |
| } |
| |
| 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() |
| .map_err(|_| panic!("previous docker build failed, unable to run test")); |
| 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()); |
| ar.sparse(false); |
| 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<u16, u16> { |
| 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<u16, u16>) { |
| 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<u8>, |
| 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<Vec<u8>>) -> 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 |
| } |
| } |