diff --git a/docs/features/creating_container.md b/docs/features/creating_container.md new file mode 100644 index 00000000..91deddef --- /dev/null +++ b/docs/features/creating_container.md @@ -0,0 +1,98 @@ +# Creating containers + +Build a container request by chaining `ImageExt` methods on an image, then start it with +`AsyncRunner` or `SyncRunner`. + +```rust +use testcontainers::{ + core::{IntoContainerPort, WaitFor}, + runners::AsyncRunner, + GenericImage, ImageExt, +}; + +let _container = GenericImage::new("redis", "7.2.4") + .with_exposed_port(6379.tcp()) + .with_env_var("DEBUG", "1") + .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections")) + .start() + .await + .unwrap(); +``` + +## Common settings + +- Ports and networking: use `with_exposed_port`, `with_mapped_port`, or `with_network` + (see [networking](networking.md)). +- Files and mounts: use `with_copy_to` and `with_mount` (see [files](files.md)). +- Readiness: use `with_wait_for` or health checks (see [wait strategies](wait_strategies.md)). +- Execution hooks: use `exec_after_start` or `exec_before_ready` on images + (see [exec commands](exec_commands.md)). + +## Custom images + +If you want a strongly-typed image with reusable defaults, define your own type and implement +`Image` for it, then use it like any other image (for example, `RedisImage` or `PostgresImage`). +Before rolling your own, check the community modules that already package popular services: +[`community modules`][community-modules] and the +[`testcontainers-rs-modules-community` repo][community-modules-repo]. + +```rust +use testcontainers::{ + core::{ContainerPort, WaitFor}, + runners::AsyncRunner, + Image, +}; + +#[derive(Debug, Clone)] +struct RedisImage { + ports: [ContainerPort; 1], +} + +impl Default for RedisImage { + fn default() -> Self { + Self { + ports: [ContainerPort::Tcp(6379)], + } + } +} + +impl Image for RedisImage { + fn name(&self) -> &str { + "redis" + } + + fn tag(&self) -> &str { + "7.2.4" + } + + fn ready_conditions(&self) -> Vec { + vec![WaitFor::message_on_stdout("Ready to accept connections")] + } + + fn expose_ports(&self) -> &[ContainerPort] { + &self.ports + } +} + +let _container = RedisImage::default().start().await.unwrap(); +``` + +## Advanced settings + +If you need to tweak Docker `HostConfig` fields that are not exposed by the high-level API, use +`ImageExt::with_host_config_modifier` to apply a single callback just before container creation. +The modifier runs after `testcontainers` fills in its defaults. If you call it multiple times, +the last modifier wins. + +```rust +use testcontainers::{GenericImage, ImageExt}; + +let image = GenericImage::new("testcontainers/helloworld", "1.3.0") + .with_host_config_modifier(|host_config| { + host_config.cpu_period = Some(100_000); + host_config.cpu_quota = Some(200_000); + }); +``` + +[community-modules]: ../quickstart/community_modules.md +[community-modules-repo]: https://github.com/testcontainers/testcontainers-rs-modules-community diff --git a/mkdocs.yml b/mkdocs.yml index 5fcabc41..ca9c3eee 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ nav: - quickstart/community_modules.md - Features: - features/configuration.md + - features/creating_container.md - features/wait_strategies.md - features/exec_commands.md - features/files.md diff --git a/testcontainers/src/core/containers/request.rs b/testcontainers/src/core/containers/request.rs index cbee7e09..37bb26ce 100644 --- a/testcontainers/src/core/containers/request.rs +++ b/testcontainers/src/core/containers/request.rs @@ -3,12 +3,13 @@ use std::{ collections::BTreeMap, fmt::{Debug, Formatter}, net::IpAddr, + sync::Arc, time::Duration, }; #[cfg(feature = "device-requests")] use bollard::models::DeviceRequest; -use bollard::models::ResourcesUlimits; +use bollard::models::{HostConfig, ResourcesUlimits}; use crate::{ core::{ @@ -18,6 +19,8 @@ use crate::{ Image, TestcontainersError, }; +pub(crate) type HostConfigModifier = Arc; + /// Represents a request to start a container, allowing customization of the container. #[must_use] pub struct ContainerRequest { @@ -49,6 +52,7 @@ pub struct ContainerRequest { pub(crate) startup_timeout: Option, pub(crate) working_dir: Option, pub(crate) log_consumers: Vec>, + pub(crate) host_config_modifier: Option, #[cfg(feature = "reusable-containers")] pub(crate) reuse: crate::ReuseDirective, pub(crate) user: Option, @@ -239,6 +243,10 @@ impl ContainerRequest { self.health_check.as_ref() } + pub fn host_config_modifier(&self) -> Option<&HostConfigModifier> { + self.host_config_modifier.as_ref() + } + #[cfg(feature = "device-requests")] pub fn device_requests(&self) -> Option<&[DeviceRequest]> { self.device_requests.as_deref() @@ -276,6 +284,7 @@ impl From for ContainerRequest { startup_timeout: None, working_dir: None, log_consumers: vec![], + host_config_modifier: None, #[cfg(feature = "reusable-containers")] reuse: crate::ReuseDirective::Never, user: None, @@ -336,7 +345,11 @@ impl Debug for ContainerRequest { .field("working_dir", &self.working_dir) .field("user", &self.user) .field("ready_conditions", &self.ready_conditions) - .field("health_check", &self.health_check); + .field("health_check", &self.health_check) + .field( + "has_host_config_modifier", + &self.host_config_modifier.is_some(), + ); #[cfg(feature = "reusable-containers")] repr.field("reusable", &self.reuse); diff --git a/testcontainers/src/core/image/image_ext.rs b/testcontainers/src/core/image/image_ext.rs index 402a7d19..b5b97107 100644 --- a/testcontainers/src/core/image/image_ext.rs +++ b/testcontainers/src/core/image/image_ext.rs @@ -1,8 +1,8 @@ -use std::time::Duration; +use std::{sync::Arc, time::Duration}; #[cfg(feature = "device-requests")] use bollard::models::DeviceRequest; -use bollard::models::ResourcesUlimits; +use bollard::models::{HostConfig, ResourcesUlimits}; use crate::{ core::{ @@ -210,6 +210,15 @@ pub trait ImageExt { /// Allows to follow the container logs for the whole lifecycle of the container, starting from the creation. fn with_log_consumer(self, log_consumer: impl LogConsumer + 'static) -> ContainerRequest; + /// Applies a custom modifier to the Docker `HostConfig` used for container creation. + /// + /// The modifier runs after `testcontainers` finishes applying its defaults and settings. + /// If called multiple times, the last modifier replaces the previous one. + fn with_host_config_modifier( + self, + modifier: impl Fn(&mut HostConfig) + Send + Sync + 'static, + ) -> ContainerRequest; + /// Flag the container as being exempt from the default `testcontainers` remove-on-drop lifecycle, /// indicating that the container should be kept running, and that executions with the same configuration /// reuse it instead of starting a "fresh" container instance. @@ -526,6 +535,17 @@ impl>, I: Image> ImageExt for RI { container_req } + fn with_host_config_modifier( + self, + modifier: impl Fn(&mut HostConfig) + Send + Sync + 'static, + ) -> ContainerRequest { + let container_req = self.into(); + ContainerRequest { + host_config_modifier: Some(Arc::new(modifier)), + ..container_req + } + } + #[cfg(feature = "reusable-containers")] fn with_reuse(self, reuse: ReuseDirective) -> ContainerRequest { ContainerRequest { diff --git a/testcontainers/src/runners/async_runner.rs b/testcontainers/src/runners/async_runner.rs index 616feffa..6494b4c7 100644 --- a/testcontainers/src/runners/async_runner.rs +++ b/testcontainers/src/runners/async_runner.rs @@ -326,6 +326,13 @@ where } } + if let Some(modifier) = container_req.host_config_modifier() { + config.host_config = config.host_config.map(|mut host_config| { + modifier(&mut host_config); + host_config + }); + } + let cmd: Vec<_> = container_req.cmd().map(|v| v.to_string()).collect(); if !cmd.is_empty() { config.cmd = Some(cmd); diff --git a/testcontainers/src/runners/sync_runner.rs b/testcontainers/src/runners/sync_runner.rs index 49a51584..042106a5 100644 --- a/testcontainers/src/runners/sync_runner.rs +++ b/testcontainers/src/runners/sync_runner.rs @@ -329,6 +329,24 @@ mod tests { Ok(()) } + #[test] + fn sync_run_command_should_apply_host_config_modifier() -> anyhow::Result<()> { + let image = GenericImage::new("testcontainers/helloworld", "1.3.0"); + let container = image + .with_host_config_modifier(|host_config| { + host_config.cpu_period = Some(100_000); + host_config.cpu_quota = Some(200_000); + }) + .start()?; + + let container_details = inspect(container.id()); + let host_config = container_details.host_config.expect("HostConfig"); + + assert_eq!(host_config.cpu_period, Some(100_000)); + assert_eq!(host_config.cpu_quota, Some(200_000)); + Ok(()) + } + #[test] fn sync_should_create_network_if_image_needs_it_and_drop_it_in_the_end() -> anyhow::Result<()> { {