diff --git a/tests/integration/baikal.rs b/tests/integration/baikal.rs index e200421..2ac60dd 100644 --- a/tests/integration/baikal.rs +++ b/tests/integration/baikal.rs @@ -76,7 +76,8 @@ impl Baikal { let request = image .with_exposed_port(HTTP_PORT.tcp()) .with_wait_for(WaitFor::message_on_stderr("start worker process")) - .with_startup_timeout(Duration::from_secs(180)); + .with_startup_timeout(Duration::from_secs(180)) + .with_labels([(super::OWNER_LABEL, "1")]); let container = request.start()?; let host = container.get_host()?.to_string(); diff --git a/tests/integration/cyrus.rs b/tests/integration/cyrus.rs index 5103423..6564408 100644 --- a/tests/integration/cyrus.rs +++ b/tests/integration/cyrus.rs @@ -154,7 +154,8 @@ impl Cyrus { let request = image .with_exposed_port(IMAP_PORT.tcp()) .with_wait_for(WaitFor::message_on_stdout("cyrus provisioned")) - .with_startup_timeout(Duration::from_secs(180)); + .with_startup_timeout(Duration::from_secs(180)) + .with_labels([(super::OWNER_LABEL, "1")]); let container = request.start()?; let host = container.get_host()?.to_string(); diff --git a/tests/integration/dav_client.rs b/tests/integration/dav_client.rs index 6934af5..8414b99 100644 --- a/tests/integration/dav_client.rs +++ b/tests/integration/dav_client.rs @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 OR MIT */ +use std::io; use std::time::Duration; use base64::Engine; @@ -56,24 +57,35 @@ impl DavSeed { ) -> ContainerResult<(u16, Vec)> { let parsed = Method::from_bytes(method.as_bytes()) .map_err(|e| ContainerError::Protocol(format!("bad method {method}: {e}")))?; - let mut builder = Request::builder() - .method(parsed) - .uri(url) - .header("Authorization", &self.auth); - if let Some(d) = depth { - builder = builder.header("Depth", d.to_string()); - } - if let Some(ct) = content_type { - builder = builder.header("Content-Type", ct); - } let payload: Vec = body.map(|b| b.to_vec()).unwrap_or_default(); - let request = builder - .body(payload) - .map_err(|e| ContainerError::Protocol(format!("build request: {e}")))?; - let mut response = self.agent.run(request)?; - let status = response.status().as_u16(); - let bytes = response.body_mut().read_to_vec()?; - Ok((status, bytes)) + let mut attempt = 0u32; + loop { + let mut builder = Request::builder() + .method(parsed.clone()) + .uri(url) + .header("Authorization", &self.auth); + if let Some(d) = depth { + builder = builder.header("Depth", d.to_string()); + } + if let Some(ct) = content_type { + builder = builder.header("Content-Type", ct); + } + let request = builder + .body(payload.clone()) + .map_err(|e| ContainerError::Protocol(format!("build request: {e}")))?; + match self.agent.run(request) { + Ok(mut response) => { + let status = response.status().as_u16(); + let bytes = response.body_mut().read_to_vec()?; + return Ok((status, bytes)); + } + Err(e) if attempt < 20 && is_transient(&e) => { + attempt += 1; + std::thread::sleep(Duration::from_millis(250)); + } + Err(e) => return Err(e.into()), + } + } } pub fn mkcol(&self, path: &str, body: Option<&str>) -> ContainerResult { @@ -154,6 +166,21 @@ impl DavSeed { } } +fn is_transient(e: &ureq::Error) -> bool { + match e { + ureq::Error::ConnectionFailed => true, + ureq::Error::Io(io) => matches!( + io.kind(), + io::ErrorKind::UnexpectedEof + | io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::ConnectionRefused + | io::ErrorKind::BrokenPipe + ), + _ => false, + } +} + fn is_collection_created(status: u16) -> bool { matches!(status, 200 | 201 | 204) } diff --git a/tests/integration/dovecot.rs b/tests/integration/dovecot.rs index 170fb71..6ab5568 100644 --- a/tests/integration/dovecot.rs +++ b/tests/integration/dovecot.rs @@ -132,7 +132,8 @@ impl Dovecot { .with_exposed_port(IMAP_PORT.tcp()) .with_exposed_port(SIEVE_PORT.tcp()) .with_wait_for(WaitFor::message_on_stderr("starting up")) - .with_startup_timeout(Duration::from_secs(120)); + .with_startup_timeout(Duration::from_secs(120)) + .with_labels([(super::OWNER_LABEL, "1")]); let container = request.start()?; let host = container.get_host()?.to_string(); diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index abcfc90..05c77f1 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -22,6 +22,8 @@ pub mod webdav; pub use error::{ContainerError, ContainerResult}; +pub const OWNER_LABEL: &str = "art.stalw.vandelay.itest"; + use std::sync::Once; static CRYPTO_INIT: Once = Once::new(); diff --git a/tests/integration/radicale.rs b/tests/integration/radicale.rs index f35e630..a0a2ef5 100644 --- a/tests/integration/radicale.rs +++ b/tests/integration/radicale.rs @@ -5,7 +5,7 @@ */ use std::collections::HashSet; -use std::time::Duration; +use std::time::{Duration, Instant}; use testcontainers::core::{IntoContainerPort, WaitFor}; use testcontainers::runners::SyncRunner; @@ -56,12 +56,15 @@ impl Radicale { let request = image .with_copy_to("/config/config", CONFIG.as_bytes().to_vec()) .with_copy_to("/config/users", users.into_bytes()) - .with_startup_timeout(Duration::from_secs(90)); + .with_startup_timeout(Duration::from_secs(90)) + .with_labels([(super::OWNER_LABEL, "1")]); let container = request.start()?; let host = container.get_host()?.to_string(); let port = container.get_host_port_ipv4(RADICALE_PORT.tcp())?; + wait_ready(&format!("http://{host}:{port}/"), Duration::from_secs(30))?; + let accounts: Vec = layouts::accounts() .iter() .map(|name| Account { @@ -338,6 +341,26 @@ impl AccountSeed { } } +fn wait_ready(base: &str, total: Duration) -> ContainerResult<()> { + let agent = ureq::Agent::config_builder() + .http_status_as_error(false) + .timeout_global(Some(Duration::from_secs(5))) + .build() + .new_agent(); + let deadline = Instant::now() + total; + let mut last_err = String::from("no probe attempted"); + while Instant::now() < deadline { + match agent.get(base).call() { + Ok(_) => return Ok(()), + Err(e) => last_err = e.to_string(), + } + std::thread::sleep(Duration::from_millis(250)); + } + Err(ContainerError::Protocol(format!( + "radicale did not become ready in {total:?}: {last_err}" + ))) +} + fn collection_segment(prefix: &str, idx: usize, name: &str) -> String { let mut out = format!("{prefix}-{:02}-", idx); for c in name.chars() { diff --git a/tests/integration/stalwart.rs b/tests/integration/stalwart.rs index 6b0a54a..0ad4a45 100644 --- a/tests/integration/stalwart.rs +++ b/tests/integration/stalwart.rs @@ -5,6 +5,7 @@ */ use std::net::TcpListener; +use std::process::Command; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; @@ -17,6 +18,7 @@ use ureq::Agent; use ureq::config::RedirectAuthHeaders; use ureq::tls::{TlsConfig, TlsProvider}; +use super::OWNER_LABEL; use super::error::{ContainerError, ContainerResult}; const IMAGE_NAME: &str = "stalwartlabs/stalwart"; @@ -45,6 +47,7 @@ pub struct Stalwart { impl Stalwart { pub fn start() -> ContainerResult { + sweep_abandoned_containers(); let https_port = pick_free_port()?; let imap_port = pick_free_port()?; let imaps_port = pick_free_port()?; @@ -63,6 +66,7 @@ impl Stalwart { let request = image .with_env_var("STALWART_PUBLIC_URL", &public_url) .with_env_var("STALWART_RECOVERY_ADMIN", "admin:admin") + .with_labels([(OWNER_LABEL, "1")]) .with_copy_to(CONFIG_PATH, CONFIG_JSON.as_bytes().to_vec()) .with_mapped_port(https_port, HTTPS_PORT.tcp()) .with_mapped_port(imap_port, IMAP_PORT.tcp()) @@ -132,6 +136,24 @@ pub fn shared() -> &'static Stalwart { SHARED.get_or_init(|| Stalwart::start().expect("start shared stalwart container")) } +fn sweep_abandoned_containers() { + let Ok(listed) = Command::new("docker") + .args(["ps", "-aq", "--filter", &format!("label={OWNER_LABEL}")]) + .output() + else { + return; + }; + let ids: Vec = String::from_utf8_lossy(&listed.stdout) + .split_whitespace() + .map(String::from) + .collect(); + if ids.is_empty() { + return; + } + eprintln!("removing {} abandoned test container(s)", ids.len()); + let _ = Command::new("docker").args(["rm", "-f"]).args(&ids).output(); +} + fn pick_free_port() -> ContainerResult { let listener = TcpListener::bind("127.0.0.1:0")?; Ok(listener.local_addr()?.port()) diff --git a/tests/integration/webdav.rs b/tests/integration/webdav.rs index 9e52954..bf45beb 100644 --- a/tests/integration/webdav.rs +++ b/tests/integration/webdav.rs @@ -148,7 +148,8 @@ impl WebDav { let request = image .with_exposed_port(HTTP_PORT.tcp()) .with_wait_for(WaitFor::message_on_stderr("AH00558")) - .with_startup_timeout(Duration::from_secs(120)); + .with_startup_timeout(Duration::from_secs(120)) + .with_labels([(super::OWNER_LABEL, "1")]); let container = request.start()?; let host = container.get_host()?.to_string();