use std::sync::OnceLock;
use anyhow::{Context, Result, bail};
use testcontainers::{ContainerAsync, runners::AsyncRunner};
use testcontainers_modules::rabbitmq::RabbitMq;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::testing::must::testcontainers_enabled;
use super::MaeContainer;
pub const RABBITMQ_AMQP_PORT: u16 = 5672;
pub const RABBITMQ_MGMT_PORT: u16 = 15672;
const DEFAULT_USER: &str = "guest";
const DEFAULT_PASS: &str = "guest";
pub struct Inner {
pub container: ContainerAsync<RabbitMq>,
pub amqp_port: u16,
pub mgmt_port: u16
}
static SINGLETON: OnceLock<Mutex<Option<Inner>>> = OnceLock::new();
fn rabbitmq_mutex() -> &'static Mutex<Option<Inner>> {
SINGLETON.get_or_init(|| Mutex::new(None))
}
pub struct RabbitMqScope {
pub vhost: String,
pub amqp_url: String,
pub mgmt_base_url: String
}
impl RabbitMqScope {
pub fn amqp_url(&self) -> &str {
&self.amqp_url
}
pub fn vhost(&self) -> &str {
&self.vhost
}
pub async fn delete_vhost(&self) -> Result<()> {
delete_vhost(&self.mgmt_base_url, &self.vhost).await
}
}
pub struct RabbitMqContainer;
impl MaeContainer for RabbitMqContainer {
type Scope = RabbitMqScope;
async fn start() -> Option<()> {
rabbitmq_singleton().await.lock().await.as_ref().map(|_| ())
}
async fn scope() -> Result<RabbitMqScope> {
spawn_scoped_vhost().await
}
async fn teardown() {
teardown().await;
}
}
pub async fn get_rabbitmq_amqp() -> Result<(String, u16)> {
ensure_started().await?;
let guard = rabbitmq_mutex().lock().await;
let c = guard.as_ref().context("rabbitmq container is not running — this is a bug")?;
let url = amqp_url(c.amqp_port, "/");
Ok((url, c.amqp_port))
}
pub async fn rabbitmq_singleton() -> &'static Mutex<Option<Inner>> {
let _ = ensure_started().await;
rabbitmq_mutex()
}
pub async fn spawn_scoped_vhost() -> Result<RabbitMqScope> {
ensure_started().await?;
let (amqp_port, mgmt_port) = {
let guard = rabbitmq_mutex().lock().await;
let c = guard.as_ref().context("rabbitmq container is not running — this is a bug")?;
(c.amqp_port, c.mgmt_port)
};
let vhost_id = Uuid::new_v4().to_string().replace('-', "");
let vhost = format!("mae_test_{vhost_id}");
let mgmt_base_url = format!("http://localhost:{mgmt_port}");
create_vhost(&mgmt_base_url, &vhost).await?;
set_permissions(&mgmt_base_url, &vhost, DEFAULT_USER).await?;
let url = amqp_url(amqp_port, &vhost);
Ok(RabbitMqScope { vhost, amqp_url: url, mgmt_base_url })
}
pub async fn teardown() {
let mut guard = rabbitmq_mutex().lock().await;
if let Some(c) = guard.take() {
let _ = c.container.stop().await;
drop(c);
}
}
async fn ensure_started() -> Result<()> {
if !testcontainers_enabled() {
bail!(
"testcontainers disabled — set MAE_TESTCONTAINERS=1 to run RabbitMQ tests. \
In Concourse, ensure the task has Docker socket access (DinD)."
);
}
let mut guard = rabbitmq_mutex().lock().await;
if guard.is_none() {
let container: ContainerAsync<RabbitMq> =
RabbitMq::default().start().await.context("failed to start RabbitMQ container")?;
let amqp_port = container
.get_host_port_ipv4(RABBITMQ_AMQP_PORT)
.await
.context("failed to get RabbitMQ AMQP port")?;
let mgmt_port = container
.get_host_port_ipv4(RABBITMQ_MGMT_PORT)
.await
.context("failed to get RabbitMQ management port")?;
*guard = Some(Inner { container, amqp_port, mgmt_port });
}
Ok(())
}
fn amqp_url(port: u16, vhost: &str) -> String {
let encoded_vhost = if vhost == "/" { "%2f".to_string() } else { vhost.to_string() };
format!("amqp://{DEFAULT_USER}:{DEFAULT_PASS}@localhost:{port}/{encoded_vhost}")
}
async fn create_vhost(mgmt_base_url: &str, vhost: &str) -> Result<()> {
let url = format!("{mgmt_base_url}/api/vhosts/{vhost}");
let client = reqwest::Client::new();
let resp = client
.put(&url)
.basic_auth(DEFAULT_USER, Some(DEFAULT_PASS))
.header("content-type", "application/json")
.body("{}")
.send()
.await
.with_context(|| format!("failed to create RabbitMQ vhost `{vhost}`"))?;
anyhow::ensure!(
resp.status().is_success() || resp.status().as_u16() == 204,
"RabbitMQ management API returned {} when creating vhost `{}`",
resp.status(),
vhost
);
Ok(())
}
async fn set_permissions(mgmt_base_url: &str, vhost: &str, user: &str) -> Result<()> {
let url = format!("{mgmt_base_url}/api/permissions/{vhost}/{user}");
let client = reqwest::Client::new();
let resp = client
.put(&url)
.basic_auth(DEFAULT_USER, Some(DEFAULT_PASS))
.header("content-type", "application/json")
.body(r#"{"configure":".*","write":".*","read":".*"}"#)
.send()
.await
.with_context(|| format!("failed to set permissions on vhost `{vhost}`"))?;
anyhow::ensure!(
resp.status().is_success() || resp.status().as_u16() == 204,
"RabbitMQ management API returned {} when setting permissions on `{}`",
resp.status(),
vhost
);
Ok(())
}
async fn delete_vhost(mgmt_base_url: &str, vhost: &str) -> Result<()> {
let url = format!("{mgmt_base_url}/api/vhosts/{vhost}");
let client = reqwest::Client::new();
let resp = client
.delete(&url)
.basic_auth(DEFAULT_USER, Some(DEFAULT_PASS))
.send()
.await
.with_context(|| format!("failed to delete RabbitMQ vhost `{vhost}`"))?;
anyhow::ensure!(
resp.status().is_success() || resp.status().as_u16() == 204,
"RabbitMQ management API returned {} when deleting vhost `{}`",
resp.status(),
vhost
);
Ok(())
}