mirror of https://github.com/rust-lang/cargo
286 lines
8.9 KiB
Rust
286 lines
8.9 KiB
Rust
//! 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().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<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
|
|
}
|
|
}
|