diff --git a/.devcontainer/dev-container-setup.sh b/.devcontainer/dev-container-setup.sh new file mode 100755 index 0000000..18fb131 --- /dev/null +++ b/.devcontainer/dev-container-setup.sh @@ -0,0 +1,42 @@ +#!/bin/sh + +set -e +# set -x + +export DEBIAN_FRONTEND=noninteractive + +# Install dependencies +apt-get update -y -qq +apt-get install -y -qq \ + bash \ + curl \ + unzip \ + time \ + gettext \ + ca-certificates \ + gnupg \ + lsb-release + +# Install docker +# from https://docs.docker.com/engine/install/debian/ +#shellcheck disable=SC2174 +mkdir -m 0755 -p /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \ + $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list >/dev/null +apt-get update -y -qq +apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +# docker --version + +# Clean up +time rm -rf /var/lib/apt/lists + +# Install Rust complementary tool +rustup --version +rustup toolchain list +rustup component add clippy rustfmt + +cargo --version +cargo clippy --version +cargo fmt --version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0f1505..ba97bf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,8 @@ - on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] name: Continuous integration @@ -35,6 +34,28 @@ jobs: - name: Tests run: cargo test --verbose + tests_with_podman: + runs-on: ubuntu-24.04 + strategy: + matrix: + include: + - rust: stable + + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + - name: Install podman compose + run: | + pipx install podman-compose + - name: Build + run: cargo build --verbose --features ensure-podman + - name: Documentation + run: cargo doc --verbose --features ensure-podman + - name: Tests + run: cargo test --verbose --features ensure-podman + clippy: runs-on: ubuntu-latest steps: @@ -62,3 +83,18 @@ jobs: - run: cargo +nightly hack generate-lockfile --remove-dev-deps -Z direct-minimal-versions - name: Build run: cargo build --verbose --all-features + + docker_in_docker: + runs-on: ubuntu-latest + container: + image: public.ecr.aws/docker/library/rust:1.76 + + steps: + - uses: actions/checkout@v4 + - name: Setup container + run: | + .devcontainer/dev-container-setup.sh + - name: Build + run: cargo build --verbose + - name: Tests + run: cargo test -- --test should_work_dind --nocapture diff --git a/Cargo.toml b/Cargo.toml index 701a600..3bc1feb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,9 @@ unsafe_code = "forbid" missing_docs = "warn" [workspace.lints.clippy] -perf = "warn" -pedantic = "warn" -cargo = "warn" +perf = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } undocumented_unsafe_blocks = "deny" diff --git a/rustainers/Cargo.toml b/rustainers/Cargo.toml index 051b1b3..cfbb072 100644 --- a/rustainers/Cargo.toml +++ b/rustainers/Cargo.toml @@ -30,6 +30,7 @@ very-long-tests = [] async-trait = "0.1.74" hex = { version = "0.4.3", features = ["serde"] } indexmap = "2.1.0" +ipnetwork = "0.20.0" path-absolutize = "3.1.1" regex = { version = "1.10.3", optional = true } reqwest = { version = "0.11.24" } diff --git a/rustainers/README.md b/rustainers/README.md index b1a2589..ed2d089 100644 --- a/rustainers/README.md +++ b/rustainers/README.md @@ -57,7 +57,7 @@ async fn do_something_with_postgres(url: String) -> anyhow::Result<()> { } ``` -## Livecycle of a container +## Lifecycle of a container When you start a _runnable image_, the runner first check the state of the container. It may already exists, or we may need to create it. diff --git a/rustainers/examples/minio.rs b/rustainers/examples/minio.rs index 1c821f8..3d377a6 100644 --- a/rustainers/examples/minio.rs +++ b/rustainers/examples/minio.rs @@ -11,6 +11,7 @@ use tracing::{info, Level}; use rustainers::images::Minio; use rustainers::runner::{RunOption, Runner}; +use rustainers::Container; mod common; pub use self::common::*; @@ -38,7 +39,7 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -async fn do_something_in_minio(minio: &Minio, bucket_name: &str) -> anyhow::Result<()> { +async fn do_something_in_minio(minio: &Container, bucket_name: &str) -> anyhow::Result<()> { let endpoint = minio.endpoint().await?; info!("Using MinIO at {endpoint}"); let s3 = AmazonS3Builder::from_env() diff --git a/rustainers/examples/mongo.rs b/rustainers/examples/mongo.rs index 75e51d5..f1c27ff 100644 --- a/rustainers/examples/mongo.rs +++ b/rustainers/examples/mongo.rs @@ -9,6 +9,7 @@ use mongodb::bson::{doc, Document}; use mongodb::{options::ClientOptions, Client}; use rustainers::images::Mongo; use rustainers::runner::{RunOption, Runner}; +use rustainers::Container; mod common; pub use self::common::*; @@ -30,7 +31,7 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -async fn do_something_in_mongo(mongo: &Mongo) -> anyhow::Result<()> { +async fn do_something_in_mongo(mongo: &Container) -> anyhow::Result<()> { let endpoint = mongo.endpoint().await?; info!("Using Mongo at {endpoint}"); diff --git a/rustainers/examples/postgres.rs b/rustainers/examples/postgres.rs index ca46b69..7019019 100644 --- a/rustainers/examples/postgres.rs +++ b/rustainers/examples/postgres.rs @@ -8,6 +8,7 @@ use tracing::{info, warn, Level}; use rustainers::images::Postgres; use rustainers::runner::{RunOption, Runner}; +use rustainers::Container; mod common; pub use self::common::*; @@ -30,7 +31,7 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -async fn do_something_in_postgres(pg: &Postgres) -> anyhow::Result<()> { +async fn do_something_in_postgres(pg: &Container) -> anyhow::Result<()> { let config = pg.config().await?; // Connect to the database. diff --git a/rustainers/examples/redis.rs b/rustainers/examples/redis.rs index f86a1e0..d61bc0d 100644 --- a/rustainers/examples/redis.rs +++ b/rustainers/examples/redis.rs @@ -7,6 +7,7 @@ use tracing::{info, Level}; use rustainers::images::Redis; use rustainers::runner::{RunOption, Runner}; +use rustainers::Container; mod common; pub use self::common::*; @@ -30,7 +31,7 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -async fn do_something_in_redis(redis: &Redis) -> anyhow::Result<()> { +async fn do_something_in_redis(redis: &Container) -> anyhow::Result<()> { let endpoint = redis.endpoint().await?; info!("Using Redis at {endpoint}"); let client = Client::open(endpoint)?; diff --git a/rustainers/src/container/id.rs b/rustainers/src/container/id.rs index e9d08d6..4a786ac 100644 --- a/rustainers/src/container/id.rs +++ b/rustainers/src/container/id.rs @@ -1,4 +1,5 @@ use std::fmt::{Debug, Display}; +use std::ops::Deref; use std::str::FromStr; use serde::{Deserialize, Serialize}; @@ -17,9 +18,17 @@ use crate::{Id, IdError}; /// /// Note that the [`Display`] view truncate the id, /// to have the full [`String`] you need to use the [`Into`] or [`From`] implementation. -#[derive(Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Hash)] pub struct ContainerId(Id); +impl Deref for ContainerId { + type Target = Id; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + impl From for String { fn from(value: ContainerId) -> Self { String::from(value.0) diff --git a/rustainers/src/container/network.rs b/rustainers/src/container/network.rs index ebad2f6..81cba52 100644 --- a/rustainers/src/container/network.rs +++ b/rustainers/src/container/network.rs @@ -1,3 +1,4 @@ +use ipnetwork::IpNetwork; use std::borrow::Cow; use std::fmt::Display; use std::net::Ipv4Addr; @@ -137,17 +138,57 @@ mod serde_ip { } } +/// A Network as described by the runner inspect command on .NetworkSettings.Networks #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub(crate) struct ContainerNetwork { +pub(crate) struct NetworkDetails { #[serde(alias = "IPAddress")] + /// Network Ip address pub(crate) ip_address: Option, + + /// Network gateway + #[serde(alias = "Gateway")] + pub(crate) gateway: Option, + + /// Network id + #[serde(alias = "NetworkID")] + pub(crate) id: Option, +} + +/// A Container as described by the runner inspect command on .Containers +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub(crate) struct HostContainer { + #[serde(alias = "Name")] + /// Container name + pub(crate) name: Option, +} + +/// A Network as described by the runner network command +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct NetworkInfo { + /// Name of the network + #[serde(alias = "Name")] + pub(crate) name: String, + + /// Id of the network + #[serde(alias = "ID")] + pub(crate) id: ContainerId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct IpamNetworkConfig { + #[serde(alias = "Subnet")] + pub(crate) subnet: Option, + + #[serde(alias = "Gateway")] + pub(crate) gateway: Option, } #[cfg(test)] #[allow(clippy::ignored_unit_patterns)] mod tests { - use assert2::check; + use assert2::{check, let_assert}; use rstest::rstest; + use std::collections::HashMap; use super::*; @@ -162,11 +203,36 @@ mod tests { check!(arg.as_ref() == expected); } - #[test] - fn should_deserialize_container_network() { - let json = include_str!("../../tests/assets/docker-inspect-network.json"); - let result = serde_json::from_str::(json).expect("json"); - let ip = result.ip_address.expect("IP v4").0; + #[rstest] + #[case::docker(include_str!("../../tests/assets/docker-inspect-network.json"))] + #[case::podman(include_str!("../../tests/assets/podman-inspect-network.json"))] + fn should_deserialize_network_details(#[case] json: &str) { + let result = serde_json::from_str::(json); + let_assert!(Ok(network_detail) = result); + let ip = network_detail.ip_address.expect("IP v4").0; check!(ip == Ipv4Addr::from([172_u8, 29, 0, 2])); } + + #[test] + fn should_deserialize_network_info() { + let json = include_str!("../../tests/assets/docker-network.json"); + let result = serde_json::from_str::(json); + let_assert!(Ok(network_info) = result); + let expected = "b79a7ee6fe69".parse::(); + let_assert!(Ok(expected_id) = expected); + check!(network_info.id == expected_id); + } + + #[test] + fn should_deserialize_host_containers() { + let json = include_str!("../../tests/assets/docker-inspect-containers.json"); + let result = serde_json::from_str::>(json); + let_assert!(Ok(containers) = result); + let id = "f7bbcdb277f7cc880b84219c959a5d28169ebb8c41dd32c08a9195a3c79e8d5e" + .parse::(); + let_assert!(Ok(container_id) = id); + let_assert!(Some(host) = containers.get(&container_id)); + let_assert!(Some(container_name) = &host.name); + check!(container_name == &"dockerindocker".to_string()); + } } diff --git a/rustainers/src/container/wait_condition.rs b/rustainers/src/container/wait_condition.rs index c114fdb..7fc7654 100644 --- a/rustainers/src/container/wait_condition.rs +++ b/rustainers/src/container/wait_condition.rs @@ -41,7 +41,7 @@ pub enum WaitStrategy { /// Wait until log match a pattern LogMatch { - /// + /// the type of io io: StdIoKind, /// The matcher matcher: LogMatcher, diff --git a/rustainers/src/id.rs b/rustainers/src/id.rs index 7da5e5c..6f776cf 100644 --- a/rustainers/src/id.rs +++ b/rustainers/src/id.rs @@ -1,4 +1,5 @@ use std::fmt::{Debug, Display}; +use std::hash::Hash; use std::str::FromStr; use hex::{decode, encode, FromHex}; @@ -13,13 +14,24 @@ use crate::IdError; /// Note because some version of Docker CLI return truncated value, /// we need to store the size of the id. /// +/// `PartialEq`, `Eq` and `Hash` implementation is based on all fields (size included) +/// /// Most usage of this type is done with the string representation. /// /// Note that the [`Display`] view truncate the id, /// to have the full [`String`] you need to use the [`Into`] or [`From`] implementation. -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct Id([u8; 32], usize); +impl Id { + /// Is ids are the same, they could have different size + #[allow(clippy::indexing_slicing)] + pub fn same(&self, other: &Self) -> bool { + let size = self.1.min(other.1); + self.0[..size] == other.0[..size] + } +} + impl From for String { fn from(value: Id) -> Self { let Id(data, size) = value; @@ -148,6 +160,21 @@ mod tests { check!(id.to_string() == short); } + #[test] + fn should_compare_prefix() { + let id0 = "c94f6f8d4ef2".parse::().expect("valid id"); + let id1 = "c94f6f8d4ef25b80584b9457ca24b964032681895b3a6fd7cd24fd40fad4895e" + .parse::() + .expect("valid id"); + check!(id0.same(&id1) == true, "same prefix"); + + let id0 = "c94f6f8d4ef200".parse::().expect("valid id"); + let id1 = "c94f6f8d4ef25b80584b9457ca24b964032681895b3a6fd7cd24fd40fad4895e" + .parse::() + .expect("valid id"); + check!(id0.same(&id1) == false, "different prefix"); + } + #[rstest] #[case::normal("\"c94f6f8d4ef25b80584b9457ca24b964032681895b3a6fd7cd24fd40fad4895e\"")] #[case::short("\"637ceb59b7a0\"")] diff --git a/rustainers/src/images/minio.rs b/rustainers/src/images/minio.rs index 5359043..fd78470 100644 --- a/rustainers/src/images/minio.rs +++ b/rustainers/src/images/minio.rs @@ -58,6 +58,13 @@ impl Minio { image.set_digest(digest); Self { image, ..self } } + + /// Set the port mapping + #[must_use] + pub fn with_port(mut self, port: ExposedPort) -> Self { + self.port = port; + self + } } impl Minio { @@ -78,6 +85,23 @@ impl Minio { pub fn secret_access_key(&self) -> &str { "minioadmin" } +} + +impl Container { + /// Create a bucket + /// + /// # Errors + /// + /// Could fail if we cannot create the bucket + pub async fn create_s3_bucket(&self, name: &str) -> Result<(), RunnerError> { + let bucket = format!("{DATA}/{name}"); + self.runner.exec(self, ["mc", "mb", &bucket]).await?; + self.runner + .exec(self, ["mc", "anonymous", "set", "public", &bucket]) + .await?; + + Ok(()) + } /// Get endpoint URL /// @@ -86,7 +110,9 @@ impl Minio { /// Could fail if the port is not bind pub async fn endpoint(&self) -> Result { let port = self.port.host_port().await?; - let url = format!("http://localhost:{port}"); + + let host_ip = self.runner.container_host_ip().await?; + let url = format!("http://{host_ip}:{port}"); Ok(url) } @@ -98,29 +124,13 @@ impl Minio { /// Could fail if the console port is not bind pub async fn console_endpoint(&self) -> Result { let port = self.console_port.host_port().await?; - let url = format!("http://localhost:{port}"); + let host_ip = self.runner.container_host_ip().await?; + let url = format!("http://{host_ip}:{port}"); Ok(url) } } -impl Container { - /// Create a bucket - /// - /// # Errors - /// - /// Could fail if we cannot create the bucket - pub async fn create_s3_bucket(&self, name: &str) -> Result<(), RunnerError> { - let bucket = format!("{DATA}/{name}"); - self.runner.exec(self, ["mc", "mb", &bucket]).await?; - self.runner - .exec(self, ["mc", "anonymous", "set", "public", &bucket]) - .await?; - - Ok(()) - } -} - impl Default for Minio { fn default() -> Self { Minio { @@ -146,22 +156,3 @@ impl ToRunnableContainer for Minio { .build() } } - -#[cfg(test)] -#[allow(clippy::ignored_unit_patterns)] -mod tests { - - use super::*; - use assert2::{check, let_assert}; - - #[tokio::test] - async fn should_create_endpoint() { - let image = Minio { - port: ExposedPort::fixed(PORT, Port::new(9123)), - ..Default::default() - }; - let result = image.endpoint().await; - let_assert!(Ok(endpoint) = result); - check!(endpoint == "http://localhost:9123"); - } -} diff --git a/rustainers/src/images/mongo.rs b/rustainers/src/images/mongo.rs index 40d0142..46afa3c 100644 --- a/rustainers/src/images/mongo.rs +++ b/rustainers/src/images/mongo.rs @@ -1,6 +1,6 @@ use crate::{ - ExposedPort, ImageName, Port, PortError, RunnableContainer, RunnableContainerBuilder, - ToRunnableContainer, WaitStrategy, + Container, ExposedPort, ImageName, Port, PortError, RunnableContainer, + RunnableContainerBuilder, ToRunnableContainer, WaitStrategy, }; const MONGO_IMAGE: &ImageName = &ImageName::new("mongo"); @@ -49,9 +49,16 @@ impl Mongo { image.set_digest(digest); Self { image, ..self } } + + /// Set the port mapping + #[must_use] + pub fn with_port(mut self, port: ExposedPort) -> Self { + self.port = port; + self + } } -impl Mongo { +impl Container { /// Get endpoint URL /// /// # Errors @@ -59,7 +66,8 @@ impl Mongo { /// Could fail if the port is not bind pub async fn endpoint(&self) -> Result { let port = self.port.host_port().await?; - let url = format!("mongodb://localhost:{port}"); + let host_ip = self.runner.container_host_ip().await?; + let url = format!("mongodb://{host_ip}:{port}"); Ok(url) } @@ -83,22 +91,3 @@ impl ToRunnableContainer for Mongo { .build() } } - -#[cfg(test)] -#[allow(clippy::ignored_unit_patterns)] -mod tests { - - use super::*; - use assert2::{check, let_assert}; - - #[tokio::test] - async fn should_create_endpoint() { - let image = Mongo { - port: ExposedPort::fixed(PORT, Port::new(9123)), - ..Default::default() - }; - let result = image.endpoint().await; - let_assert!(Ok(endpoint) = result); - check!(endpoint == "mongodb://localhost:9123"); - } -} diff --git a/rustainers/src/images/mosquitto.rs b/rustainers/src/images/mosquitto.rs index bf633fc..3ae3a04 100644 --- a/rustainers/src/images/mosquitto.rs +++ b/rustainers/src/images/mosquitto.rs @@ -1,8 +1,8 @@ use std::time::Duration; use crate::{ - ExposedPort, ImageName, Port, PortError, RunnableContainer, RunnableContainerBuilder, - ToRunnableContainer, WaitStrategy, + Container, ExposedPort, ImageName, Port, PortError, RunnableContainer, + RunnableContainerBuilder, ToRunnableContainer, WaitStrategy, }; const MOSQUITTO_IMAGE: &ImageName = &ImageName::new("eclipse-mosquitto"); @@ -51,9 +51,16 @@ impl Mosquitto { image.set_digest(digest); Self { image, ..self } } + + /// Set the port mapping + #[must_use] + pub fn with_port(mut self, port: ExposedPort) -> Self { + self.port = port; + self + } } -impl Mosquitto { +impl Container { /// Get endpoint URL /// /// # Errors @@ -61,12 +68,12 @@ impl Mosquitto { /// Could fail if the port is not bind pub async fn endpoint(&self) -> Result { let port = self.port.host_port().await?; - let url = format!("mqtt://localhost:{port}"); + let host_ip = self.runner.container_host_ip().await?; + let url = format!("mqtt://{host_ip}:{port}"); Ok(url) } } - impl Default for Mosquitto { fn default() -> Self { Self { @@ -89,22 +96,3 @@ impl ToRunnableContainer for Mosquitto { .build() } } - -#[cfg(test)] -#[allow(clippy::ignored_unit_patterns)] -mod tests { - - use super::*; - use assert2::{check, let_assert}; - - #[tokio::test] - async fn should_create_endpoint() { - let image = Mosquitto { - port: ExposedPort::fixed(PORT, Port::new(9123)), - ..Default::default() - }; - let result = image.endpoint().await; - let_assert!(Ok(endpoint) = result); - check!(endpoint == "mqtt://localhost:9123"); - } -} diff --git a/rustainers/src/images/postgres.rs b/rustainers/src/images/postgres.rs index 3e016b1..b5bd79a 100644 --- a/rustainers/src/images/postgres.rs +++ b/rustainers/src/images/postgres.rs @@ -1,7 +1,7 @@ use std::time::Duration; use crate::{ - ExposedPort, HealthCheck, ImageName, Port, PortError, RunnableContainer, + Container, ExposedPort, HealthCheck, ImageName, Port, PortError, RunnableContainer, RunnableContainerBuilder, ToRunnableContainer, }; @@ -86,51 +86,57 @@ impl Postgres { let db = db.into(); Self { db, ..self } } + + /// Set the port mapping + #[must_use] + pub fn with_port(mut self, port: ExposedPort) -> Self { + self.port = port; + self + } } -impl Postgres { - /// Get connection URL - /// +impl Default for Postgres { + fn default() -> Self { + Self { + image: POSTGRES_IMAGE.clone(), + user: String::from(POSTGRES_USER), + password: String::from(POSTGRES_PASSWORD), + db: String::from(POSTGRES_DATABASE), + port: ExposedPort::new(PORT), + } + } +} + +impl Container { /// # Errors /// /// Could fail if the port is not bind - pub async fn url(&self) -> Result { + pub async fn config(&self) -> Result { let user = &self.user; let password = &self.password; + let host_ip = self.runner.container_host_ip().await?; let port = self.port.host_port().await?; let database = &self.db; - let url = format!("postgresql://{user}:{password}@localhost:{port}/{database}"); - Ok(url) + let config = + format!("host={host_ip} user={user} password={password} port={port} dbname={database}"); + Ok(config) } - /// Get connection string + /// Get connection URL /// /// # Errors /// /// Could fail if the port is not bind - pub async fn config(&self) -> Result { + pub async fn url(&self) -> Result { let user = &self.user; let password = &self.password; let port = self.port.host_port().await?; + let host_ip = self.runner.container_host_ip().await?; let database = &self.db; - let config = - format!("host=localhost user={user} password={password} port={port} dbname={database}"); - Ok(config) - } -} - -impl Default for Postgres { - fn default() -> Self { - Self { - image: POSTGRES_IMAGE.clone(), - user: String::from(POSTGRES_USER), - password: String::from(POSTGRES_PASSWORD), - db: String::from(POSTGRES_DATABASE), - port: ExposedPort::new(PORT), - } + let url = format!("postgresql://{user}:{password}@{host_ip}:{port}/{database}"); + Ok(url) } } - impl ToRunnableContainer for Postgres { fn to_runnable(&self, builder: RunnableContainerBuilder) -> RunnableContainer { builder @@ -152,32 +158,3 @@ impl ToRunnableContainer for Postgres { .build() } } - -#[cfg(test)] -#[allow(clippy::ignored_unit_patterns)] -mod tests { - - use assert2::check; - - use super::*; - - #[tokio::test] - async fn should_build_config() { - let image = Postgres { - port: ExposedPort::fixed(PORT, Port::new(5432)), - ..Default::default() - }; - let result = image.config().await.expect("config"); - check!(result == "host=localhost user=postgres password=passwd port=5432 dbname=postgres"); - } - - #[tokio::test] - async fn should_build_url() { - let image = Postgres { - port: ExposedPort::fixed(PORT, Port::new(5432)), - ..Default::default() - }; - let result = image.url().await.expect("url"); - check!(result == "postgresql://postgres:passwd@localhost:5432/postgres"); - } -} diff --git a/rustainers/src/images/redis.rs b/rustainers/src/images/redis.rs index 4bc3bbe..bf6e274 100644 --- a/rustainers/src/images/redis.rs +++ b/rustainers/src/images/redis.rs @@ -1,7 +1,7 @@ use std::time::Duration; use crate::{ - ExposedPort, HealthCheck, ImageName, Port, PortError, RunnableContainer, + Container, ExposedPort, HealthCheck, ImageName, Port, PortError, RunnableContainer, RunnableContainerBuilder, ToRunnableContainer, }; @@ -51,22 +51,17 @@ impl Redis { image.set_digest(digest); Self { image, ..self } } -} -impl Redis { - /// Get endpoint URL - /// - /// # Errors - /// - /// Could fail if the port is not bind - pub async fn endpoint(&self) -> Result { - let port = self.port.host_port().await?; - let url = format!("redis://localhost:{port}"); - - Ok(url) + /// Set the port mapping + #[must_use] + pub fn with_port(mut self, port: ExposedPort) -> Self { + self.port = port; + self } } +impl Redis {} + impl Default for Redis { fn default() -> Self { Self { @@ -76,6 +71,20 @@ impl Default for Redis { } } +impl Container { + /// Get endpoint URL + /// + /// # Errors + /// + /// Could fail if the port is not bind + pub async fn endpoint(&self) -> Result { + let port = self.port.host_port().await?; + let host_ip = self.runner.container_host_ip().await?; + let url = format!("redis://{host_ip}:{port}"); + + Ok(url) + } +} impl ToRunnableContainer for Redis { fn to_runnable(&self, builder: RunnableContainerBuilder) -> RunnableContainer { builder @@ -91,22 +100,3 @@ impl ToRunnableContainer for Redis { .build() } } - -#[cfg(test)] -#[allow(clippy::ignored_unit_patterns)] -mod tests { - - use super::*; - use assert2::{check, let_assert}; - - #[tokio::test] - async fn should_create_endpoint() { - let image = Redis { - port: ExposedPort::fixed(PORT, Port::new(9123)), - ..Default::default() - }; - let result = image.endpoint().await; - let_assert!(Ok(endpoint) = result); - check!(endpoint == "redis://localhost:9123"); - } -} diff --git a/rustainers/src/port/error.rs b/rustainers/src/port/error.rs index 7d6862b..dfa5900 100644 --- a/rustainers/src/port/error.rs +++ b/rustainers/src/port/error.rs @@ -1,3 +1,4 @@ +use crate::runner::RunnerError; use crate::Port; /// Port error @@ -11,4 +12,8 @@ pub enum PortError { /// The port is not yet bind #[error("Container port {0} not bind")] PortNotBindYet(Port), + + /// The container is failing + #[error(transparent)] + RunnerError(#[from] RunnerError), } diff --git a/rustainers/src/runner/docker.rs b/rustainers/src/runner/docker.rs index 6a75f19..ad8f3bd 100644 --- a/rustainers/src/runner/docker.rs +++ b/rustainers/src/runner/docker.rs @@ -11,7 +11,7 @@ use crate::version::Version; use super::InnerRunner; const MINIMAL_VERSION: Version = Version::new(1, 20); -const COMPOSE_MINIMAL_VERSION: Version = Version::new(2, 10); +const COMPOSE_MINIMAL_VERSION: Version = Version::new(2, 6); /// A Docker runner /// diff --git a/rustainers/src/runner/error.rs b/rustainers/src/runner/error.rs index fee15bb..36c4002 100644 --- a/rustainers/src/runner/error.rs +++ b/rustainers/src/runner/error.rs @@ -1,3 +1,4 @@ +use std::env::VarError; use std::path::PathBuf; use crate::cmd::CommandError; @@ -76,9 +77,29 @@ pub enum RunnerError { }, /// Fail to retrieve container IP in a specific network - #[error( - "Fail to retrieve container {container} IP for network '{network}' because {source}\nrunner: {runner}" - )] + #[error("Fail to inspect container {container} networks because {source}\nrunner: {runner}")] + InspectNetworkError { + /// The runner + runner: Runner, + + /// The container, + container: Box, + /// The source error + source: Box, + }, + + /// Fail to retrieve container IP in a specific network + #[error("Fail to list network because {source}\nrunner: {runner}")] + ListNetworkError { + /// The runner + runner: Runner, + + /// The source error + source: Box, + }, + + /// Fail to retrieve network IP + #[error("Fail to retrieve network named '{network}' IP for container {container} because {source}\nrunner: {runner}")] FindNetworkIpError { /// The runner runner: Runner, @@ -123,6 +144,15 @@ pub enum RunnerError { source: Box, }, + /// Fail to retrieve host ip address + #[error("Can not fetch host because {source}\nrunner: {runner}")] + HostIpError { + /// The runner + runner: Runner, + /// The source error + source: Box, + }, + /// Fail to run compose #[error("Fail run compose in {path:?} because {source}\nrunner: {runner}")] ComposeError { @@ -196,4 +226,16 @@ pub enum ContainerError { /// Volume error #[error(transparent)] VolumeError(#[from] VolumeError), + + /// Environment variable error + #[error(transparent)] + EnvVarError(#[from] VarError), + + /// No gateway error + #[error("No gateway")] + NoGateway, + + /// No network error + #[error("No host network")] + NoNetwork, } diff --git a/rustainers/src/runner/inner.rs b/rustainers/src/runner/inner.rs index eef0c85..c5ebe92 100644 --- a/rustainers/src/runner/inner.rs +++ b/rustainers/src/runner/inner.rs @@ -1,5 +1,9 @@ +use std::borrow::Cow; +use std::collections::HashMap; +use std::env; use std::fmt::{Debug, Display}; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::Path; use std::time::Duration; use async_trait::async_trait; @@ -12,9 +16,9 @@ use tracing::{debug, info, trace, warn}; use crate::cmd::Cmd; use crate::io::StdIoKind; use crate::{ - ContainerHealth, ContainerId, ContainerNetwork, ContainerProcess, ContainerState, - ContainerStatus, ExposedPort, HealthCheck, Network, Port, RunnableContainer, Volume, - WaitStrategy, + ContainerHealth, ContainerId, ContainerProcess, ContainerState, ContainerStatus, ExposedPort, + HealthCheck, HostContainer, Ip, IpamNetworkConfig, Network, NetworkDetails, NetworkInfo, Port, + RunnableContainer, Volume, WaitStrategy, }; use super::{ContainerError, RunOption}; @@ -181,15 +185,62 @@ pub(crate) trait InnerRunner: Display + Debug + Send + Sync { self.inspect(id, ".State").await } + #[tracing::instrument(level = "debug", skip(self), fields(runner = %self))] async fn network_ip( &self, id: ContainerId, network: &str, - ) -> Result { - let path = format!(".NetworkSettings.Networks.{network}"); + ) -> Result { + let mut networks = self.inspect_container_networks(id).await?; + networks.remove(network).ok_or(ContainerError::NoNetwork) + } + + #[tracing::instrument(level = "debug", skip(self), fields(runner = %self))] + async fn inspect_container_networks( + &self, + id: ContainerId, + ) -> Result, ContainerError> { + let path = ".NetworkSettings.Networks".to_string(); self.inspect(id, &path).await } + #[tracing::instrument(level = "debug", skip(self), fields(runner = %self))] + async fn inspect_network_containers( + &self, + network_id: String, + ) -> Result, ContainerError> { + let mut cmd = self.command(); + cmd.push_args([ + "inspect", + "--type", + "network", + "--format={{json .Containers}}", + &network_id, + ]); + let result = cmd.json().await?; + Ok(result) + } + + #[tracing::instrument(level = "debug", skip(self), fields(runner = %self))] + async fn list_custom_networks(&self) -> Result, ContainerError> { + let mut cmd = self.command(); + cmd.push_args(["network", "ls", "--no-trunc", "--format={{json .}}"]); + let mut result = cmd.json_stream::().await?; + result.retain(|x| ["bridge", "host", "none"].contains(&x.name.as_str())); + Ok(result) + } + + #[tracing::instrument(level = "debug", skip(self), fields(runner = %self))] + async fn list_network_config( + &self, + network_id: ContainerId, + ) -> Result, ContainerError> { + let results = self + .inspect::>>(network_id, ".IPAM.Config") + .await?; + Ok(results.unwrap_or_default()) + } + #[tracing::instrument(level = "debug", skip(self, id), fields(runner = %self, id = %id))] async fn wait_ready( &self, @@ -287,6 +338,67 @@ pub(crate) trait InnerRunner: Display + Debug + Send + Sync { Ok(()) } + fn get_docker_host(&self) -> Option { + env::var("DOCKER_HOST").ok() + } + + #[tracing::instrument(skip(self),fields(runner = %self))] + async fn find_host_network(&self) -> Result, ContainerError> { + // If we're docker in docker running on a custom network, we need to inherit the + // network settings, so we can access the resulting container. + let docker_host = self.host().await?; + let custom_networks = self.list_custom_networks().await?; + for network in custom_networks { + let network_configs = self.list_network_config(network.id).await?; + let network = network_configs.iter().find_map(|x| { + x.subnet.and_then(|x| { + x.contains(IpAddr::V4(docker_host.0)) + .then(|| Network::Custom(network.name.clone())) + }) + }); + if network.is_some() { + return Ok(network); + } + } + return Ok(None); + } + + #[tracing::instrument(skip(self),fields(runner = %self))] + async fn host(&self) -> Result { + if self.is_inside_container() { + self.default_gateway_ip().await + } else { + Ok(Ip(Ipv4Addr::LOCALHOST)) + } + } + + #[tracing::instrument(skip(self), fields(runner = %self))] + async fn default_gateway_ip(&self) -> Result { + let hostname = env::var("HOSTNAME")?; + let host_id = hostname.parse::()?; + let networks = self.inspect_container_networks(host_id).await?; + // Filter when values are defined + let networks = networks + .into_iter() + .filter_map(|(_, network)| network.id.zip(network.gateway)) + .collect::>(); + for (network_id, net_gateway) in networks { + let containers = self.inspect_network_containers(network_id).await?; + // Due to short id vs long id + if containers + .keys() + .any(|container_id| container_id.same(&host_id)) + { + return Ok(net_gateway); + } + } + Err(ContainerError::NoGateway) + } + + fn is_inside_container(&self) -> bool { + Path::new("/.dockerenv").exists() + } + async fn watch_logs( &self, id: ContainerId, @@ -395,7 +507,18 @@ pub(crate) trait InnerRunner: Display + Debug + Send + Sync { .await? } // Need to create and start the container - Some((ContainerStatus::Unknown | ContainerStatus::Removing, _)) | None => { + Some((ContainerStatus::Unknown | ContainerStatus::Removing, _)) => { + self.create_and_start(CreateAndStartOption::new(image, &options)) + .await? + } + None => { + let mut options = Cow::Borrowed(&options); + // If the user has specified a network, we'll assume the user knows best + if options.network.is_none() & self.get_docker_host().is_none() { + // Otherwise we'll try to find the docker host for dind usage. + let host_network = self.find_host_network().await?; + options.to_mut().network = host_network; + } self.create_and_start(CreateAndStartOption::new(image, &options)) .await? } @@ -460,7 +583,7 @@ pub(crate) struct CreateAndStartOption<'a> { ports: &'a [ExposedPort], remove: bool, name: Option<&'a str>, - network: &'a Network, + network: Cow<'a, Network>, volumes: &'a [Volume], env: IndexMap<&'a str, &'a str>, command: &'a [String], @@ -478,7 +601,10 @@ impl<'a> CreateAndStartOption<'a> { let ports = &image.port_mappings; let remove = option.remove; let name = option.name(); - let network = &option.network; + let network = option + .network + .as_ref() + .map_or_else(|| Cow::Owned(Network::default()), Cow::Borrowed); let volumes = option.volumes.as_slice(); let env = image .env diff --git a/rustainers/src/runner/mod.rs b/rustainers/src/runner/mod.rs index 8380794..fd2368d 100644 --- a/rustainers/src/runner/mod.rs +++ b/rustainers/src/runner/mod.rs @@ -272,6 +272,24 @@ impl Runner { Ok(ip.0) } + /// Get the container host ip + /// + /// # Errors + /// + /// Could fail if we cannot execute the inspect command + pub async fn container_host_ip(&self) -> Result { + let host_ip = match self { + Self::Docker(runner) => runner.host().await, + Self::Podman(runner) => runner.host().await, + Self::Nerdctl(runner) => runner.host().await, + } + .map_err(|source| RunnerError::HostIpError { + runner: self.clone(), + source: Box::new(source), + })?; + Ok(host_ip.0) + } + /// Execute a command into the container /// /// # Errors diff --git a/rustainers/src/runner/options.rs b/rustainers/src/runner/options.rs index 918e0a9..55fb2a0 100644 --- a/rustainers/src/runner/options.rs +++ b/rustainers/src/runner/options.rs @@ -31,7 +31,7 @@ pub struct RunOption { /// The network #[builder(default, setter(into))] - pub(crate) network: Network, + pub(crate) network: Option, /// Volumes #[builder(default, setter(transform = |args: impl IntoIterator>| args.into_iter().map(Into::into).collect()))] diff --git a/rustainers/src/runner/podman.rs b/rustainers/src/runner/podman.rs index ab3a5e4..f87d588 100644 --- a/rustainers/src/runner/podman.rs +++ b/rustainers/src/runner/podman.rs @@ -2,14 +2,17 @@ use std::fmt::Display; use async_trait::async_trait; use serde::{Deserialize, Serialize}; +use std::path::Path; use tracing::{debug, info}; use crate::cmd::Cmd; use crate::version::Version; +use crate::ContainerId; use crate::ContainerProcess; +use crate::IpamNetworkConfig; +use crate::NetworkInfo; use super::{ContainerError, InnerRunner, RunnerError}; - const MINIMAL_VERSION: Version = Version::new(4, 0); const COMPOSE_MINIMAL_VERSION: Version = Version::new(1, 0); @@ -35,6 +38,28 @@ impl InnerRunner for Podman { Cmd::new("podman") } + #[tracing::instrument(level = "info", skip(self), fields(runner = %self))] + fn is_inside_container(&self) -> bool { + Path::new("/run/.containerenv").exists() + } + + #[tracing::instrument(level = "debug", skip(self), fields(runner = %self))] + async fn list_custom_networks(&self) -> Result, ContainerError> { + let mut cmd: Cmd<'_> = self.command(); + cmd.push_args(["network", "ls", "--no-trunc", "--format={{json .}}"]); + let mut result = cmd.json_stream::().await?; + result.retain(|x| "podman" == x.name); + Ok(result) + } + + #[tracing::instrument(level = "debug", skip(self), fields(runner = %self))] + async fn list_network_config( + &self, + network_id: ContainerId, + ) -> Result, ContainerError> { + self.inspect(network_id, ".Subnets").await + } + #[tracing::instrument(level = "debug", skip(self), fields(runner = %self))] async fn ps(&self, name: &str) -> Result, ContainerError> { let mut cmd = self.command(); @@ -67,15 +92,15 @@ pub(super) fn create() -> Result { let mut cmd = Cmd::new("podman"); cmd.push_args(["version", "--format", "json"]); let Ok(Some(version)) = cmd.json_blocking::>() else { - return Err(RunnerError::CommandNotAvailable(String::from("docker"))); + return Err(RunnerError::CommandNotAvailable(String::from("podman"))); }; // Check client version let current = version.client.api_version; - debug!("Found docker version: {current}"); + debug!("Found podman version: {current}"); if current < MINIMAL_VERSION { return Err(RunnerError::UnsupportedVersion { - command: String::from("docker"), + command: String::from("podman"), current, minimal: MINIMAL_VERSION, }); diff --git a/rustainers/tests/assets/docker-inspect-containers.json b/rustainers/tests/assets/docker-inspect-containers.json new file mode 100644 index 0000000..5d7507a --- /dev/null +++ b/rustainers/tests/assets/docker-inspect-containers.json @@ -0,0 +1,9 @@ +{ + "f7bbcdb277f7cc880b84219c959a5d28169ebb8c41dd32c08a9195a3c79e8d5e": { + "Name": "dockerindocker", + "EndpointID": "cf1f6dfdbf9c57a5baba9d4fc66fc7965a02a798c95476ad605c1eabfb936c4a", + "MacAddress": "02:42:ac:15:00:02", + "IPv4Address": "172.21.0.2/16", + "IPv6Address": "" + } +} diff --git a/rustainers/tests/assets/docker-inspect-network.json b/rustainers/tests/assets/docker-inspect-network.json index add46b6..639b5a1 100644 --- a/rustainers/tests/assets/docker-inspect-network.json +++ b/rustainers/tests/assets/docker-inspect-network.json @@ -1 +1,17 @@ -{"IPAMConfig":null,"Links":null,"Aliases":["4a20c51a344e"],"NetworkID":"59e490fde2fdcc36e4dbb3ff6fd0292dddaecb3b24af46307eba7e417af567b2","EndpointID":"7ff2990a998c4e5c2065097acd78c05a8f90b964ec78d211ab3b262fadc6b599","Gateway":"172.29.0.1","IPAddress":"172.29.0.2","IPPrefixLen":16,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"MacAddress":"02:42:ac:1d:00:02","DriverOpts":null} +{ + "IPAMConfig": null, + "Links": null, + "Aliases": [ + "4a20c51a344e" + ], + "NetworkID": "59e490fde2fdcc36e4dbb3ff6fd0292dddaecb3b24af46307eba7e417af567b2", + "EndpointID": "7ff2990a998c4e5c2065097acd78c05a8f90b964ec78d211ab3b262fadc6b599", + "Gateway": "172.29.0.1", + "IPAddress": "172.29.0.2", + "IPPrefixLen": 16, + "IPv6Gateway": "", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "MacAddress": "02:42:ac:1d:00:02", + "DriverOpts": null +} \ No newline at end of file diff --git a/rustainers/tests/assets/docker-network.json b/rustainers/tests/assets/docker-network.json new file mode 100644 index 0000000..b7e8e80 --- /dev/null +++ b/rustainers/tests/assets/docker-network.json @@ -0,0 +1,10 @@ +{ + "CreatedAt": "2024-06-25 08:14:25.429540456 +0000 UTC", + "Driver": "bridge", + "ID": "b79a7ee6fe69", + "IPv6": "false", + "Internal": "false", + "Labels": "", + "Name": "my_network_01J177BPYNKYS21JF4YQG3BDFW", + "Scope": "local" +} diff --git a/rustainers/tests/assets/podman-inspect-network.json b/rustainers/tests/assets/podman-inspect-network.json new file mode 100644 index 0000000..0b88aa5 --- /dev/null +++ b/rustainers/tests/assets/podman-inspect-network.json @@ -0,0 +1,17 @@ +{ + "EndpointID": "", + "Gateway": "172.29.0.1", + "IPAddress": "172.29.0.2", + "IPPrefixLen": 24, + "IPv6Gateway": "", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "MacAddress": "4e:de:c4:26:11:dc", + "NetworkID": "my_network_01J1Q681647VBM5DG1C9AAKDFG", + "DriverOpts": null, + "IPAMConfig": null, + "Links": null, + "Aliases": [ + "0192bdda76b9" + ] +} \ No newline at end of file diff --git a/rustainers/tests/common/images.rs b/rustainers/tests/common/images.rs index 1013929..45f6d15 100644 --- a/rustainers/tests/common/images.rs +++ b/rustainers/tests/common/images.rs @@ -26,8 +26,8 @@ impl ToRunnableContainer for InternalWebServer { // Curl #[derive(Debug)] -struct Curl { - url: String, +pub struct Curl { + pub url: String, } /// cURL in a container diff --git a/rustainers/tests/common/mod.rs b/rustainers/tests/common/mod.rs index 8b7b06a..b7e123f 100644 --- a/rustainers/tests/common/mod.rs +++ b/rustainers/tests/common/mod.rs @@ -24,8 +24,14 @@ pub fn init_test_tracing(level: Level) { pub fn runner() -> Runner { init_test_tracing(Level::INFO); - #[allow(clippy::expect_used)] - let runner = Runner::auto().expect("Should find a valid runner"); + let runner = if cfg!(feature = "ensure-podman") { + #[allow(clippy::expect_used)] + Runner::podman().expect("Should find a valid runner") + } else { + #[allow(clippy::expect_used)] + Runner::auto().expect("Should find a valid runner") + }; + debug!("Using runner {runner:?}"); runner } diff --git a/rustainers/tests/images.rs b/rustainers/tests/images.rs index b4b7f4b..5b93017 100644 --- a/rustainers/tests/images.rs +++ b/rustainers/tests/images.rs @@ -2,13 +2,14 @@ use std::time::SystemTime; -use assert2::check; +use assert2::{check, let_assert}; use rstest::rstest; use tokio::task::JoinSet; use tracing::{debug, info}; -use rustainers::images::{Minio, Mongo, Postgres, Redis}; +use rustainers::images::{Minio, Mongo, Mosquitto, Postgres, Redis}; use rustainers::runner::{RunOption, Runner}; +use rustainers::{ExposedPort, Port}; mod common; pub use self::common::*; @@ -25,6 +26,22 @@ async fn test_image_postgres(runner: &Runner) -> anyhow::Result<()> { Ok(()) } +#[rstest] +#[tokio::test] +async fn test_postgres_build_config(runner: &Runner) -> anyhow::Result<()> { + let options = RunOption::builder().with_remove(true).build(); + let image = Postgres::default().with_port(ExposedPort::fixed(Port::new(5432), Port::new(5432))); + let container = runner.start_with_options(image, options).await?; + debug!("Started {container}"); + + let result = container.config().await.expect("config"); + check!(result == "host=127.0.0.1 user=postgres password=passwd port=5432 dbname=postgres"); + + let result = container.url().await.expect("url"); + check!(result == "postgresql://postgres:passwd@127.0.0.1:5432/postgres"); + Ok(()) +} + #[rstest] #[tokio::test] async fn test_image_minio(runner: &Runner) -> anyhow::Result<()> { @@ -38,6 +55,20 @@ async fn test_image_minio(runner: &Runner) -> anyhow::Result<()> { Ok(()) } +#[rstest] +#[tokio::test] +async fn test_minio_endpoint(runner: &Runner) -> anyhow::Result<()> { + let options = RunOption::builder().with_remove(true).build(); + let image = Minio::default().with_port(ExposedPort::fixed(Port::new(9000), Port::new(9124))); + let container = runner.start_with_options(image, options).await?; + debug!("Started {container}"); + + let result = container.endpoint().await; + let_assert!(Ok(endpoint) = result); + check!(endpoint == "http://127.0.0.1:9124"); + Ok(()) +} + #[rstest] #[tokio::test] async fn test_image_redis(runner: &Runner) -> anyhow::Result<()> { @@ -50,6 +81,20 @@ async fn test_image_redis(runner: &Runner) -> anyhow::Result<()> { Ok(()) } +#[rstest] +#[tokio::test] +async fn test_redis_endpoint(runner: &Runner) -> anyhow::Result<()> { + let options = RunOption::builder().with_remove(true).build(); + let image = Redis::default().with_port(ExposedPort::fixed(Port::new(6379), Port::new(9125))); + let container = runner.start_with_options(image, options).await?; + debug!("Started {container}"); + + let result = container.endpoint().await; + let_assert!(Ok(endpoint) = result); + check!(endpoint == "redis://127.0.0.1:9125"); + Ok(()) +} + #[rstest] #[tokio::test] async fn test_image_mongo(runner: &Runner) -> anyhow::Result<()> { @@ -61,11 +106,24 @@ async fn test_image_mongo(runner: &Runner) -> anyhow::Result<()> { container.endpoint().await?; Ok(()) } +#[rstest] +#[tokio::test] +async fn test_mongo_endpoint(runner: &Runner) -> anyhow::Result<()> { + let options = RunOption::builder().with_remove(true).build(); + let image = Mongo::default().with_port(ExposedPort::fixed(Port::new(27017), Port::new(9126))); + let container = runner.start_with_options(image, options).await?; + debug!("Started {container}"); + + let result = container.endpoint().await; + let_assert!(Ok(endpoint) = result); + check!(endpoint == "mongodb://127.0.0.1:9126"); + Ok(()) +} #[rstest] #[tokio::test] async fn test_run_in_multiple_tasks(runner: &Runner) -> anyhow::Result<()> { - if let Runner::Docker(_) = &runner { + if let Runner::Podman(_) = &runner { // Work with docker, but fail with podman // FIXME find a solution return Ok(()); @@ -98,3 +156,18 @@ async fn test_run_in_multiple_tasks(runner: &Runner) -> anyhow::Result<()> { Ok(()) } + +#[rstest] +#[tokio::test] +async fn test_mosquitto_endpoint(runner: &Runner) -> anyhow::Result<()> { + let options = RunOption::builder().with_remove(true).build(); + let image = + Mosquitto::default().with_port(ExposedPort::fixed(Port::new(6379), Port::new(9127))); + let container = runner.start_with_options(image, options).await?; + debug!("Started {container}"); + + let result = container.endpoint().await; + let_assert!(Ok(endpoint) = result); + check!(endpoint == "mqtt://127.0.0.1:9127"); + Ok(()) +} diff --git a/rustainers/tests/network.rs b/rustainers/tests/network.rs index 0770e0c..212a21e 100644 --- a/rustainers/tests/network.rs +++ b/rustainers/tests/network.rs @@ -6,6 +6,7 @@ use ulid::Ulid; mod common; pub use self::common::*; use self::images::InternalWebServer; +use crate::images::Curl; #[rstest] #[tokio::test] @@ -76,6 +77,32 @@ async fn should_work_with_network_ip(runner: &Runner) -> anyhow::Result<()> { Ok(()) } +#[rstest] +#[tokio::test] +async fn should_work_dind(runner: &Runner) -> anyhow::Result<()> { + let id = Ulid::new(); + + // Container A inside network + let server_options = RunOption::builder() + .with_name(format!("web-server_{id}")) + .with_remove(true) + .build(); + + let _ = runner + .start_with_options(InternalWebServer, server_options) + .await?; + + let_assert!(Ok(host) = runner.container_host_ip().await); + let client_options = RunOption::builder() + .with_name(format!("client_{id}")) + .build(); + let url = format!("http://{host}:80"); + let image = Curl { url }; + let _ = runner.start_with_options(image, client_options).await?; + + Ok(()) +} + #[rstest] #[tokio::test] async fn should_not_work_without_network(runner: &Runner) -> anyhow::Result<()> {