Skip to content

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

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions testcontainers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ docker_credential = "1.3.1"
either = "1.12.0"
etcetera = "0.8.0"
futures = "0.3"
itertools = "0.14"
log = "0.4"
memchr = "2.7.2"
parse-display = "0.9.0"
Expand All @@ -42,6 +43,7 @@ tokio-tar = "0.3.1"
tokio-util = { version = "0.7.10", features = ["io"] }
ulid = { version = "1.1.3", optional = true }
url = { version = "2", features = ["serde"] }
uuid = { version = "1.8.0", features = ["v4"] }

[features]
default = []
Expand Down
64 changes: 64 additions & 0 deletions testcontainers/src/compose/client.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

View workflow job for this annotation

GitHub Actions / clippy

unused import: `crate::core::async_container::raw::RawContainer`

warning: unused import: `crate::core::async_container::raw::RawContainer` --> testcontainers/src/compose/client.rs:3:5 | 3 | use crate::core::async_container::raw::RawContainer; | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: `#[warn(unused_imports)]` on by default

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

View workflow job for this annotation

GitHub Actions / clippy

large size difference between variants

warning: large size difference between variants --> testcontainers/src/compose/client.rs:8:1 | 8 | / pub(super) enum ComposeClient { 9 | | Local(local::LocalComposeCli), | | ----------------------------- the second-largest variant contains at least 48 bytes 10 | | Containerised(containerised::ContainerisedComposeCli), | | ----------------------------------------------------- the largest variant contains at least 568 bytes 11 | | } | |_^ the entire enum is at least 568 bytes | = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#large_enum_variant help: consider boxing the large fields to reduce the total size of the enum | 10 - Containerised(containerised::ContainerisedComposeCli), 10 + Containerised(Box<containerised::ContainerisedComposeCli>), |

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> {
Copy link
Collaborator Author

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)

Suggested change
async fn up(&self, command: UpCommand) -> Result<(), std::io::Error> {
async fn up(&self, command: UpCommand) -> Result<Vec<RawContainer>, 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"),
}
}
}
91 changes: 91 additions & 0 deletions testcontainers/src/compose/client/containerised.rs
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(())
}
}
74 changes: 74 additions & 0 deletions testcontainers/src/compose/client/local.rs
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(())
}
}
132 changes: 132 additions & 0 deletions testcontainers/src/compose/mod.rs
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
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.
Just not finished because there is no stable interface to access containers from compose

}
Loading
Loading