Skip to content

Commit 4081b57

Browse files
authored
feat: add custom health check support for containers (#816)
Closes #814
1 parent 15dfe2a commit 4081b57

File tree

8 files changed

+331
-11
lines changed

8 files changed

+331
-11
lines changed

testcontainers/src/core.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
pub use self::image::ReuseDirective;
33
pub use self::{
44
containers::*,
5+
healthcheck::Healthcheck,
56
image::{ContainerState, ExecCommand, Image, ImageExt},
67
mounts::{AccessMode, Mount, MountType},
78
ports::{ContainerPort, IntoContainerPort},
@@ -16,6 +17,7 @@ pub(crate) mod containers;
1617
pub(crate) mod copy;
1718
pub(crate) mod env;
1819
pub mod error;
20+
pub(crate) mod healthcheck;
1921
pub mod logs;
2022
pub(crate) mod mounts;
2123
pub(crate) mod network;

testcontainers/src/core/containers/async_container.rs

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -463,13 +463,57 @@ where
463463
mod tests {
464464
use tokio::io::AsyncBufReadExt;
465465

466+
#[cfg(feature = "http_wait")]
467+
use crate::core::{wait::HttpWaitStrategy, ContainerPort, ContainerState, ExecCommand};
466468
use crate::{
467-
core::{ContainerPort, ContainerState, ExecCommand, WaitFor},
468-
images::generic::GenericImage,
469-
runners::AsyncRunner,
470-
Image, ImageExt,
469+
core::WaitFor, images::generic::GenericImage, runners::AsyncRunner, Image, ImageExt,
471470
};
472471

472+
#[tokio::test]
473+
async fn async_custom_healthcheck_is_applied() -> anyhow::Result<()> {
474+
use std::time::Duration;
475+
476+
use crate::core::Healthcheck;
477+
478+
let healthcheck = Healthcheck::cmd_shell("test -f /etc/passwd")
479+
.with_interval(Duration::from_secs(1))
480+
.with_timeout(Duration::from_secs(1))
481+
.with_retries(2);
482+
483+
let container = GenericImage::new("alpine", "latest")
484+
.with_cmd(["sleep", "30"])
485+
.with_health_check(healthcheck)
486+
.with_ready_conditions(vec![WaitFor::healthcheck()])
487+
.start()
488+
.await?;
489+
490+
let inspect_info = container.docker_client.inspect(container.id()).await?;
491+
assert!(inspect_info.config.is_some());
492+
493+
let config = inspect_info
494+
.config
495+
.expect("Container config must be present");
496+
assert!(config.healthcheck.is_some());
497+
498+
let healthcheck_config = config
499+
.healthcheck
500+
.expect("Healthcheck config must be present");
501+
assert_eq!(
502+
healthcheck_config.test,
503+
Some(vec![
504+
"CMD-SHELL".to_string(),
505+
"test -f /etc/passwd".to_string()
506+
])
507+
);
508+
assert_eq!(healthcheck_config.interval, Some(1_000_000_000));
509+
assert_eq!(healthcheck_config.timeout, Some(1_000_000_000));
510+
assert_eq!(healthcheck_config.retries, Some(2));
511+
assert_eq!(healthcheck_config.start_period, None);
512+
513+
assert!(container.is_running().await?);
514+
Ok(())
515+
}
516+
473517
#[tokio::test]
474518
async fn async_logs_are_accessible() -> anyhow::Result<()> {
475519
let image = GenericImage::new("testcontainers/helloworld", "1.1.0");
@@ -721,8 +765,6 @@ mod tests {
721765
#[cfg(feature = "http_wait")]
722766
#[tokio::test]
723767
async fn exec_before_ready_is_ran() {
724-
use crate::core::wait::HttpWaitStrategy;
725-
726768
struct ExecBeforeReady {}
727769

728770
impl Image for ExecBeforeReady {

testcontainers/src/core/containers/request.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ use bollard_stubs::models::ResourcesUlimits;
1010

1111
use crate::{
1212
core::{
13-
copy::CopyToContainer, logs::consumer::LogConsumer, mounts::Mount, ports::ContainerPort,
14-
ContainerState, ExecCommand, WaitFor,
13+
copy::CopyToContainer, healthcheck::Healthcheck, logs::consumer::LogConsumer,
14+
mounts::Mount, ports::ContainerPort, ContainerState, ExecCommand, WaitFor,
1515
},
1616
Image, TestcontainersError,
1717
};
@@ -47,6 +47,7 @@ pub struct ContainerRequest<I: Image> {
4747
pub(crate) reuse: crate::ReuseDirective,
4848
pub(crate) user: Option<String>,
4949
pub(crate) ready_conditions: Option<Vec<WaitFor>>,
50+
pub(crate) health_check: Option<Healthcheck>,
5051
}
5152

5253
/// Represents a port mapping between a host's external port and the internal port of a container.
@@ -211,6 +212,11 @@ impl<I: Image> ContainerRequest<I> {
211212
pub fn readonly_rootfs(&self) -> bool {
212213
self.readonly_rootfs
213214
}
215+
216+
/// Returns the custom health check configuration for the container.
217+
pub fn health_check(&self) -> Option<&Healthcheck> {
218+
self.health_check.as_ref()
219+
}
214220
}
215221

216222
impl<I: Image> From<I> for ContainerRequest<I> {
@@ -244,6 +250,7 @@ impl<I: Image> From<I> for ContainerRequest<I> {
244250
reuse: crate::ReuseDirective::Never,
245251
user: None,
246252
ready_conditions: None,
253+
health_check: None,
247254
}
248255
}
249256
}
@@ -290,7 +297,8 @@ impl<I: Image + Debug> Debug for ContainerRequest<I> {
290297
.field("startup_timeout", &self.startup_timeout)
291298
.field("working_dir", &self.working_dir)
292299
.field("user", &self.user)
293-
.field("ready_conditions", &self.ready_conditions);
300+
.field("ready_conditions", &self.ready_conditions)
301+
.field("health_check", &self.health_check);
294302

295303
#[cfg(feature = "reusable-containers")]
296304
repr.field("reusable", &self.reuse);

testcontainers/src/core/containers/sync_container.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ impl<I: Image> Drop for Container<I> {
250250
#[cfg(test)]
251251
mod test {
252252
use super::*;
253-
use crate::{core::WaitFor, runners::SyncRunner, GenericImage};
253+
use crate::{core::WaitFor, runners::SyncRunner, GenericImage, ImageExt};
254254

255255
#[derive(Debug, Default)]
256256
pub struct HelloWorld;
@@ -276,6 +276,52 @@ mod test {
276276

277277
fn assert_send_and_sync<T: Send + Sync>() {}
278278

279+
#[test]
280+
fn sync_custom_healthcheck_is_applied() -> anyhow::Result<()> {
281+
use std::time::Duration;
282+
283+
use crate::core::Healthcheck;
284+
285+
let healthcheck = Healthcheck::cmd_shell("test -f /etc/passwd")
286+
.with_interval(Duration::from_secs(1))
287+
.with_timeout(Duration::from_secs(1))
288+
.with_retries(2);
289+
290+
let container = GenericImage::new("alpine", "latest")
291+
.with_cmd(["sleep", "30"])
292+
.with_health_check(healthcheck)
293+
.with_ready_conditions(vec![WaitFor::healthcheck()])
294+
.start()?;
295+
296+
let inspect_info = container
297+
.rt()
298+
.block_on(container.async_impl().docker_client.inspect(container.id()))?;
299+
300+
assert!(inspect_info.config.is_some());
301+
let config = inspect_info
302+
.config
303+
.expect("Container config must be present");
304+
assert!(config.healthcheck.is_some());
305+
306+
let healthcheck_config = config
307+
.healthcheck
308+
.expect("Healthcheck config must be present");
309+
assert_eq!(
310+
healthcheck_config.test,
311+
Some(vec![
312+
"CMD-SHELL".to_string(),
313+
"test -f /etc/passwd".to_string()
314+
])
315+
);
316+
assert_eq!(healthcheck_config.interval, Some(1_000_000_000));
317+
assert_eq!(healthcheck_config.timeout, Some(1_000_000_000));
318+
assert_eq!(healthcheck_config.retries, Some(2));
319+
assert_eq!(healthcheck_config.start_period, None);
320+
321+
assert!(container.is_running()?);
322+
Ok(())
323+
}
324+
279325
#[test]
280326
fn sync_logs_are_accessible() -> anyhow::Result<()> {
281327
let image = GenericImage::new("testcontainers/helloworld", "1.1.0");
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
use std::time::Duration;
2+
3+
use bollard_stubs::models::HealthConfig;
4+
5+
/// Represents a custom health check configuration for a container.
6+
///
7+
/// This mirrors the options available in Docker's `HEALTHCHECK` instruction,
8+
/// allowing users to define custom health checks at runtime.
9+
///
10+
/// # Example
11+
///
12+
/// ```rust,no_run
13+
/// use std::time::Duration;
14+
/// use testcontainers::core::Healthcheck;
15+
///
16+
/// let healthcheck = Healthcheck::cmd_shell("mysqladmin ping -h localhost -u root -proot")
17+
/// .with_interval(Duration::from_secs(2))
18+
/// .with_timeout(Duration::from_secs(1))
19+
/// .with_retries(5)
20+
/// .with_start_period(Duration::from_secs(10));
21+
/// ```
22+
#[derive(Debug, Clone)]
23+
pub struct Healthcheck {
24+
/// The test command to run.
25+
test: Vec<String>,
26+
/// The time to wait between health checks.
27+
interval: Option<Duration>,
28+
/// The time to wait before considering the health check failed.
29+
timeout: Option<Duration>,
30+
/// The number of consecutive failures needed to consider a container as unhealthy.
31+
retries: Option<u32>,
32+
/// Start period for the container to initialize before starting health-retries countdown.
33+
start_period: Option<Duration>,
34+
/// The time to wait between health checks during the start period.
35+
start_interval: Option<Duration>,
36+
}
37+
38+
impl Healthcheck {
39+
/// Creates a new `Healthcheck` that disables the health check for the container.
40+
///
41+
/// This is equivalent to `HEALTHCHECK NONE` in a Dockerfile.
42+
pub fn none() -> Self {
43+
Self {
44+
test: vec!["NONE".to_string()],
45+
interval: None,
46+
timeout: None,
47+
retries: None,
48+
start_period: None,
49+
start_interval: None,
50+
}
51+
}
52+
53+
/// Creates a new `Healthcheck` with the specified shell command.
54+
///
55+
/// This is equivalent to `HEALTHCHECK CMD-SHELL <command>` in the Docker API.
56+
pub fn cmd_shell(command: impl Into<String>) -> Self {
57+
Self {
58+
test: vec!["CMD-SHELL".to_string(), command.into()],
59+
interval: None,
60+
timeout: None,
61+
retries: None,
62+
start_period: None,
63+
start_interval: None,
64+
}
65+
}
66+
67+
/// Creates a new `Healthcheck` with the specified command and arguments.
68+
///
69+
/// This is equivalent to `HEALTHCHECK CMD ["<command>", "<arg1>", ...]` in the Docker API.
70+
/// The command can be any iterator that yields string-like items.
71+
pub fn cmd<I, S>(command: I) -> Self
72+
where
73+
I: IntoIterator<Item = S>,
74+
S: AsRef<str>,
75+
{
76+
let mut test = vec!["CMD".to_string()];
77+
test.extend(command.into_iter().map(|s| s.as_ref().to_owned()));
78+
Self {
79+
test,
80+
interval: None,
81+
timeout: None,
82+
retries: None,
83+
start_period: None,
84+
start_interval: None,
85+
}
86+
}
87+
88+
/// Creates an empty healthcheck configuration to customize an image's existing healthcheck.
89+
///
90+
/// This keeps the original healthcheck command from the image, but allows overriding
91+
/// other parameters like `interval` or `retries`. In the Docker API, this is achieved
92+
/// by sending an empty `test` field along with the other desired values.
93+
pub fn empty() -> Self {
94+
Self {
95+
test: vec![],
96+
interval: None,
97+
timeout: None,
98+
retries: None,
99+
start_period: None,
100+
start_interval: None,
101+
}
102+
}
103+
104+
/// Sets the interval between health checks.
105+
///
106+
/// Passing `None` will clear the value and use the Docker default.
107+
pub fn with_interval(mut self, interval: impl Into<Option<Duration>>) -> Self {
108+
self.interval = interval.into();
109+
self
110+
}
111+
112+
/// Sets the timeout for each health check.
113+
///
114+
/// Passing `None` will clear the value and use the Docker default.
115+
pub fn with_timeout(mut self, timeout: impl Into<Option<Duration>>) -> Self {
116+
self.timeout = timeout.into();
117+
self
118+
}
119+
120+
/// Sets the number of consecutive failures needed to consider the container unhealthy.
121+
///
122+
/// Passing `None` will clear the value and use the Docker default.
123+
pub fn with_retries(mut self, retries: impl Into<Option<u32>>) -> Self {
124+
self.retries = retries.into();
125+
self
126+
}
127+
128+
/// Sets the start period for the container to initialize before starting health checks.
129+
///
130+
/// Passing `None` will clear the value and use the Docker default.
131+
pub fn with_start_period(mut self, start_period: impl Into<Option<Duration>>) -> Self {
132+
self.start_period = start_period.into();
133+
self
134+
}
135+
136+
/// Sets the interval between health checks during the start period.
137+
///
138+
/// Passing `None` will clear the value and use the Docker default.
139+
pub fn with_start_interval(mut self, interval: impl Into<Option<Duration>>) -> Self {
140+
self.start_interval = interval.into();
141+
self
142+
}
143+
144+
/// Returns the test command as a vector of strings.
145+
pub fn test(&self) -> &[String] {
146+
&self.test
147+
}
148+
149+
/// Returns the interval between health checks.
150+
pub fn interval(&self) -> Option<Duration> {
151+
self.interval
152+
}
153+
154+
/// Returns the timeout for each health check.
155+
pub fn timeout(&self) -> Option<Duration> {
156+
self.timeout
157+
}
158+
159+
/// Returns the number of retries before considering the container unhealthy.
160+
pub fn retries(&self) -> Option<u32> {
161+
self.retries
162+
}
163+
164+
/// Returns the start period before health checks begin.
165+
pub fn start_period(&self) -> Option<Duration> {
166+
self.start_period
167+
}
168+
169+
/// Returns the interval between health checks during the start period.
170+
pub fn start_interval(&self) -> Option<Duration> {
171+
self.start_interval
172+
}
173+
174+
/// Converts this `Healthcheck` into a bollard `HealthConfig` for use with Docker API.
175+
pub(crate) fn into_health_config(self) -> HealthConfig {
176+
// Helper to convert Duration to i64 nanoseconds, capping at i64::MAX.
177+
// Docker interprets 0 as the default value (e.g., 30s for interval).
178+
// A negative value would disable the healthcheck, but our `Duration` type ensures it's always non-negative.
179+
let to_nanos = |d: Duration| -> i64 { d.as_nanos().try_into().unwrap_or(i64::MAX) };
180+
181+
HealthConfig {
182+
test: Some(self.test),
183+
interval: self.interval.map(to_nanos),
184+
timeout: self.timeout.map(to_nanos),
185+
retries: self.retries.map(|r| r as i64),
186+
start_period: self.start_period.map(to_nanos),
187+
start_interval: self.start_interval.map(to_nanos),
188+
}
189+
}
190+
}

0 commit comments

Comments
 (0)