-
Notifications
You must be signed in to change notification settings - Fork 151
feat: docker-compose support #774
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,7 +8,7 @@ members = [ | |
[workspace.package] | ||
authors = ["Thomas Eizinger", "Artem Medvedev <[email protected]>", "Mervyn McCreight"] | ||
edition = "2021" | ||
keywords = ["docker", "testcontainers"] | ||
keywords = ["docker", "testcontainers", "docker-compose"] | ||
license = "MIT OR Apache-2.0" | ||
readme = "README.md" | ||
repository = "https://github.com/testcontainers/testcontainers-rs" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
use std::{fmt, path::PathBuf}; | ||
|
||
use crate::core::async_container::raw::RawContainer; | ||
Check warning on line 3 in testcontainers/src/compose/client.rs
|
||
|
||
pub(super) mod containerised; | ||
pub(super) mod local; | ||
|
||
pub(super) enum ComposeClient { | ||
Local(local::LocalComposeCli), | ||
Containerised(containerised::ContainerisedComposeCli), | ||
} | ||
Check warning on line 11 in testcontainers/src/compose/client.rs
|
||
|
||
impl ComposeClient { | ||
pub(super) fn new_local(compose_files: Vec<PathBuf>) -> Self { | ||
ComposeClient::Local(local::LocalComposeCli::new(compose_files)) | ||
} | ||
|
||
pub(super) async fn new_containerised(compose_files: Vec<PathBuf>) -> Self { | ||
ComposeClient::Containerised( | ||
containerised::ContainerisedComposeCli::new(compose_files).await, | ||
) | ||
} | ||
} | ||
|
||
pub(super) struct UpCommand { | ||
pub(super) project_name: String, | ||
pub(super) wait_timeout: std::time::Duration, | ||
} | ||
|
||
pub(super) struct DownCommand { | ||
pub(super) project_name: String, | ||
pub(super) rmi: bool, | ||
pub(super) volumes: bool, | ||
} | ||
|
||
pub(super) trait ComposeInterface { | ||
async fn up(&self, command: UpCommand) -> Result<(), std::io::Error>; | ||
async fn down(&self, command: DownCommand) -> Result<(), std::io::Error>; | ||
} | ||
|
||
impl ComposeInterface for ComposeClient { | ||
async fn up(&self, command: UpCommand) -> Result<(), std::io::Error> { | ||
match self { | ||
ComposeClient::Local(client) => client.up(command).await, | ||
ComposeClient::Containerised(client) => client.up(command).await, | ||
} | ||
} | ||
|
||
async fn down(&self, command: DownCommand) -> Result<(), std::io::Error> { | ||
match self { | ||
ComposeClient::Local(client) => client.down(command).await, | ||
ComposeClient::Containerised(client) => client.down(command).await, | ||
} | ||
} | ||
} | ||
|
||
impl fmt::Debug for ComposeClient { | ||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
match self { | ||
ComposeClient::Local(_) => write!(f, "LocalComposeCli"), | ||
ComposeClient::Containerised(_) => write!(f, "ContainerisedComposeCli"), | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
use std::{io::Error, path::PathBuf}; | ||
|
||
use crate::{ | ||
compose::client::{ComposeInterface, DownCommand, UpCommand}, | ||
core::{CmdWaitFor, ExecCommand, Mount}, | ||
images::docker_cli::DockerCli, | ||
runners::AsyncRunner, | ||
ContainerAsync, ContainerRequest, ImageExt, | ||
}; | ||
|
||
pub(crate) struct ContainerisedComposeCli { | ||
container: ContainerAsync<DockerCli>, | ||
compose_files_in_container: Vec<String>, | ||
} | ||
|
||
impl ContainerisedComposeCli { | ||
pub(super) async fn new(compose_files: Vec<PathBuf>) -> Self { | ||
let mut image = ContainerRequest::from(DockerCli::new("/var/run/docker.sock")); | ||
|
||
let compose_files_in_container: Vec<String> = compose_files | ||
.iter() | ||
.enumerate() | ||
.map(|(i, _)| format!("/docker-compose-{i}.yml")) | ||
.collect(); | ||
let mounts: Vec<_> = compose_files | ||
.iter() | ||
.zip(compose_files_in_container.iter()) | ||
.map(|(path, file_name)| Mount::bind_mount(path.to_str().unwrap(), file_name)) | ||
.collect(); | ||
|
||
for mount in mounts { | ||
image = image.with_mount(mount); | ||
} | ||
|
||
let container = image.start().await.expect("TODO: Handle error"); | ||
|
||
Self { | ||
container, | ||
compose_files_in_container, | ||
} | ||
} | ||
} | ||
|
||
impl ComposeInterface for ContainerisedComposeCli { | ||
async fn up(&self, command: UpCommand) -> Result<(), Error> { | ||
let mut cmd = vec![ | ||
"docker".to_string(), | ||
"compose".to_string(), | ||
"--project-name".to_string(), | ||
command.project_name.clone(), | ||
]; | ||
|
||
for file in &self.compose_files_in_container { | ||
cmd.push("-f".to_string()); | ||
cmd.push(file.to_string()); | ||
} | ||
|
||
cmd.push("up".to_string()); | ||
cmd.push("--wait".to_string()); | ||
// add timeout | ||
cmd.push("--wait-timeout".to_string()); | ||
cmd.push(command.wait_timeout.as_secs().to_string()); | ||
|
||
let exec = ExecCommand::new(cmd); | ||
// todo: error handling | ||
self.container.exec(exec).await.map_err(Error::other)?; | ||
|
||
Ok(()) | ||
} | ||
|
||
async fn down(&self, command: DownCommand) -> Result<(), Error> { | ||
let mut cmd = vec![ | ||
"docker".to_string(), | ||
"compose".to_string(), | ||
"--project-name".to_string(), | ||
command.project_name.clone(), | ||
"down".to_string(), | ||
]; | ||
|
||
if command.volumes { | ||
cmd.push("--volumes".to_string()); | ||
} | ||
if command.rmi { | ||
cmd.push("--rmi".to_string()); | ||
} | ||
|
||
let exec = ExecCommand::new(cmd).with_cmd_ready_condition(CmdWaitFor::exit_code(0)); | ||
self.container.exec(exec).await.map_err(Error::other)?; | ||
Ok(()) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
use std::{ | ||
io::Error, | ||
path::{Path, PathBuf}, | ||
}; | ||
|
||
use crate::compose::client::{ComposeInterface, DownCommand, UpCommand}; | ||
|
||
#[derive(Debug)] | ||
pub(crate) struct LocalComposeCli { | ||
compose_files: Vec<PathBuf>, | ||
working_dir: PathBuf, | ||
} | ||
|
||
impl LocalComposeCli { | ||
pub(super) fn new(compose_files: Vec<PathBuf>) -> Self { | ||
let working_dir = Self::extract_current_dir(&compose_files).to_path_buf(); | ||
|
||
Self { | ||
compose_files, | ||
working_dir, | ||
} | ||
} | ||
|
||
fn extract_current_dir(compose_files: &[PathBuf]) -> &Path { | ||
// TODO: error handling | ||
compose_files | ||
.first() | ||
.expect("At least one compose file is required") | ||
.parent() | ||
.expect("Compose file path must be absolute") | ||
} | ||
} | ||
|
||
impl ComposeInterface for LocalComposeCli { | ||
async fn up(&self, command: UpCommand) -> Result<(), Error> { | ||
let mut cmd = tokio::process::Command::new("docker"); | ||
cmd.current_dir(self.working_dir.as_path()) | ||
.arg("compose") | ||
.arg("--project-name") | ||
.arg(&command.project_name); | ||
|
||
for compose_file in &self.compose_files { | ||
cmd.arg("-f").arg(compose_file); | ||
} | ||
cmd.arg("up") | ||
.arg("--wait") | ||
.arg("--wait-timeout") | ||
.arg(command.wait_timeout.as_secs().to_string()); | ||
|
||
cmd.output().await?; | ||
|
||
Ok(()) | ||
} | ||
|
||
async fn down(&self, command: DownCommand) -> Result<(), Error> { | ||
let mut cmd = tokio::process::Command::new("docker"); | ||
cmd.current_dir(self.working_dir.as_path()) | ||
.arg("compose") | ||
.arg("--project-name") | ||
.arg(&command.project_name) | ||
.arg("down"); | ||
|
||
if command.volumes { | ||
cmd.arg("--volumes"); | ||
} | ||
if command.rmi { | ||
cmd.arg("--rmi"); | ||
} | ||
|
||
cmd.output().await?; | ||
|
||
Ok(()) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
use std::{path::Path, sync::Arc}; | ||
|
||
use crate::{compose::client::ComposeInterface, core::async_drop}; | ||
|
||
mod client; | ||
|
||
#[derive(Debug)] | ||
pub struct DockerCompose { | ||
project_name: String, | ||
client: Arc<client::ComposeClient>, | ||
remove_volumes: bool, | ||
remove_images: bool, | ||
} | ||
|
||
impl DockerCompose { | ||
/// Create a new docker compose with a local client (using docker-cli installed locally) | ||
/// If you don't have docker-cli installed, you can use `with_containerised_client` instead | ||
pub fn with_local_client(compose_files: &[impl AsRef<Path>]) -> Self { | ||
let compose_files = compose_files | ||
.iter() | ||
.map(|p| p.as_ref().to_path_buf()) | ||
.collect(); | ||
|
||
let client = Arc::new(client::ComposeClient::new_local(compose_files)); | ||
|
||
Self::new(client) | ||
} | ||
|
||
/// Create a new docker compose with a containerised client (doesn't require docker-cli installed locally) | ||
pub async fn with_containerised_client(compose_files: &[impl AsRef<Path>]) -> Self { | ||
let compose_files = compose_files | ||
.iter() | ||
.map(|p| p.as_ref().to_path_buf()) | ||
.collect(); | ||
|
||
let client = Arc::new(client::ComposeClient::new_containerised(compose_files).await); | ||
|
||
Self::new(client) | ||
} | ||
|
||
/// Start the docker compose | ||
pub async fn up(&self) { | ||
self.client | ||
.up(client::UpCommand { | ||
project_name: self.project_name.clone(), | ||
wait_timeout: std::time::Duration::from_secs(60), | ||
}) | ||
.await | ||
.expect("TODO: error handling"); | ||
} | ||
|
||
/// Remove volumes when dropping the docker compose or not | ||
pub fn with_remove_volumes(&mut self, remove_volumes: bool) -> &mut Self { | ||
self.remove_volumes = remove_volumes; | ||
self | ||
} | ||
|
||
/// Remove images when dropping the docker compose or not | ||
pub fn with_remove_images(&mut self, remove_images: bool) -> &mut Self { | ||
self.remove_images = remove_images; | ||
self | ||
} | ||
|
||
fn new(client: Arc<client::ComposeClient>) -> Self { | ||
let project_name = uuid::Uuid::new_v4().to_string(); | ||
|
||
Self { | ||
project_name, | ||
client, | ||
remove_volumes: true, | ||
remove_images: false, | ||
} | ||
} | ||
} | ||
|
||
impl Drop for DockerCompose { | ||
fn drop(&mut self) { | ||
let project_name = self.project_name.clone(); | ||
let client = self.client.clone(); | ||
let rmi = self.remove_images; | ||
let volumes = self.remove_volumes; | ||
let drop_task = async move { | ||
let res = client | ||
.down(client::DownCommand { | ||
project_name, | ||
rmi, | ||
volumes, | ||
}) | ||
.await; | ||
|
||
match res { | ||
Ok(()) => log::info!("docker compose successfully dropped"), | ||
Err(e) => log::error!("failed to drop docker compose: {}", e), | ||
} | ||
}; | ||
|
||
async_drop::async_drop(drop_task); | ||
} | ||
} | ||
|
||
#[cfg(test)] | ||
mod tests { | ||
use std::path::PathBuf; | ||
|
||
use super::*; | ||
|
||
// #[tokio::test] | ||
// async fn test_containerised_docker_compose() { | ||
// let path_to_compose = PathBuf::from(format!( | ||
// "{}/tests/test-compose.yml", | ||
// env!("CARGO_MANIFEST_DIR") | ||
// )); | ||
// let docker_compose = | ||
// DockerCompose::with_containerised_client(&[path_to_compose.as_path()]).await; | ||
// docker_compose.up().await; | ||
// tokio::time::sleep(std::time::Duration::from_secs(1)).await; | ||
// let res = reqwest::get("http://localhost:8081/").await.unwrap(); | ||
// assert!(res.status().is_success()); | ||
// } | ||
|
||
#[tokio::test] | ||
async fn test_local_docker_compose() { | ||
let path_to_compose = PathBuf::from(format!( | ||
"{}/tests/test-compose.yml", | ||
env!("CARGO_MANIFEST_DIR") | ||
)); | ||
let docker_compose = DockerCompose::with_local_client(&[path_to_compose.as_path()]); | ||
docker_compose.up().await; | ||
let client = reqwest::get("http://localhost:8081").await.unwrap(); | ||
tokio::time::sleep(std::time::Duration::from_secs(15)).await; | ||
} | ||
Comment on lines
+107
to
+131
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both tests work already, but they should be "serial" to avoid a port conflict. |
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As an option, it should return a list of "raw" containers.
So we can expose method
compose.get_container(service_name) -> &RawContainer
(or something like that)