use std::net::TcpStream;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
pub struct Cluster {
pub name: &'static str,
pub compose_file: &'static str,
pub env_var: &'static str,
pub default_bootstrap: &'static str,
pub default_size: usize,
pub containers: &'static [&'static str],
pub internal_port: u16,
pub host_port: u16,
pub is_secure: bool,
}
pub mod clusters {
use super::Cluster;
pub const THREE_BROKER: Cluster = Cluster {
name: "3-broker",
compose_file: "docker-compose.yml",
env_var: "KAFKA_BOOTSTRAP",
default_bootstrap: "127.0.0.1:29093,127.0.0.1:29095,127.0.0.1:29097",
default_size: 3,
containers: &["kafka-1", "kafka-2", "kafka-3"],
internal_port: 9092,
host_port: 29093,
is_secure: false,
};
pub const SINGLE_NODE: Cluster = Cluster {
name: "single-node",
compose_file: "docker-compose.single.yml",
env_var: "KAFKA_BOOTSTRAP",
default_bootstrap: "127.0.0.1:29092",
default_size: 1,
containers: &["kafka-single"],
internal_port: 9092,
host_port: 29092,
is_secure: false,
};
pub const SASL: Cluster = Cluster {
name: "sasl",
compose_file: "docker-compose.sasl.yml",
env_var: "KAFKA_BOOTSTRAP_SASL",
default_bootstrap: "127.0.0.1:9094",
default_size: 1,
containers: &["kafka-sasl-broker"],
internal_port: 9094,
host_port: 9094,
is_secure: true,
};
pub const TLS: Cluster = Cluster {
name: "tls",
compose_file: "docker-compose.tls.yml",
env_var: "KAFKA_BOOTSTRAP_TLS",
default_bootstrap: "127.0.0.1:9093",
default_size: 1,
containers: &["kafka-tls-broker"],
internal_port: 9093,
host_port: 9093,
is_secure: true,
};
pub const KERBEROS: Cluster = Cluster {
name: "kerberos",
compose_file: "docker-compose.kerberos.yml",
env_var: "KAFKA_BOOTSTRAP_KERBEROS",
default_bootstrap: "127.0.0.1:9096",
default_size: 1,
containers: &["kdc-kerberos", "kafka-kerberos-broker"],
internal_port: 9096,
host_port: 9096,
is_secure: true,
};
}
static COMPOSE_STARTED: AtomicBool = AtomicBool::new(false);
pub async fn ensure(cluster: &Cluster) {
if is_externally_managed(cluster) {
return;
}
unsafe {
std::env::set_var(cluster.env_var, cluster.default_bootstrap);
std::env::set_var("KAFKA_CLUSTER_SIZE", cluster.default_size.to_string());
}
if COMPOSE_STARTED
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
eprintln!(">>> Compose already started by another test, reusing...");
return;
}
eprintln!(
">>> Starting compose cluster '{}' (file: {})...",
cluster.name, cluster.compose_file
);
start_compose(cluster).await;
wait_for_cluster(cluster).await;
}
pub fn is_externally_managed(cluster: &Cluster) -> bool {
std::env::var(cluster.env_var).is_ok()
}
async fn start_compose(cluster: &Cluster) {
let cli = detect_container_cli();
let test_dir = compose_dir();
let status = tokio::process::Command::new(&cli)
.args([
"compose",
"-f",
&format!("{}/{}", test_dir, cluster.compose_file),
"up",
"-d",
])
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.await
.expect(&format!("Failed to execute '{} compose'", cli));
assert!(
status.success(),
"'{} compose up' failed for '{}'",
cli,
cluster.compose_file
);
}
fn compose_dir() -> String {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
manifest_dir.join("tests").to_string_lossy().to_string()
}
fn detect_container_cli() -> String {
if let Ok(cli) = std::env::var("KAFKA_CLI") {
return cli;
}
for cli in &["podman", "docker"] {
if std::process::Command::new(cli)
.arg("ps")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
return cli.to_string();
}
}
"docker".to_string()
}
async fn wait_for_cluster(cluster: &Cluster) {
let addrs = resolve_bootstrap_addrs(cluster);
let timeout = Duration::from_secs(90);
let deadline = std::time::Instant::now() + timeout;
eprintln!(
" Waiting for {} container(s) (timeout={:?})...",
cluster.containers.len(),
timeout
);
for addr in &addrs {
let socket_addr: std::net::SocketAddr = addr
.parse()
.unwrap_or_else(|e| panic!("Invalid bootstrap address '{}': {}", addr, e));
loop {
if std::time::Instant::now() > deadline {
panic!("Timeout waiting for cluster '{}' at {}", cluster.name, addr);
}
if TcpStream::connect_timeout(&socket_addr, Duration::from_secs(2)).is_ok() {
eprintln!(" TCP port {} ready", socket_addr.port());
break;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
if cluster.is_secure {
tokio::time::sleep(Duration::from_secs(10)).await;
eprintln!(
" Secure cluster '{}': TCP ready + 10s grace, continuing",
cluster.name
);
} else {
for attempt in 1..=30 {
let client = kafka_client::Client::builder(addrs.clone())
.with_client_id("compose-wait")
.with_metadata_ttl(Duration::from_secs(5))
.build()
.await;
match client {
Ok(c) => {
let meta_result = c.refresh_metadata().await;
let _ = c.close().await;
if meta_result.is_ok() {
eprintln!(" Kafka API ready after ~{}s", attempt);
return;
}
}
Err(_) => {}
}
if std::time::Instant::now() + Duration::from_secs(3) > deadline {
panic!("Timeout waiting for Kafka API at {:?}", addrs);
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
eprintln!(" [WARN] Kafka API not confirmed ready, continuing optimistically");
}
}
fn resolve_bootstrap_addrs(cluster: &Cluster) -> Vec<String> {
let bootstrap_str = std::env::var(cluster.env_var)
.or_else(|_| std::env::var("KAFKA_BOOTSTRAP"))
.unwrap_or_else(|_| cluster.default_bootstrap.to_string());
bootstrap_str
.split(',')
.map(|s| s.trim().to_string())
.collect()
}