rightsize_modules/rabbitmq.rs
1//! A single-node RabbitMQ container with the management plugin enabled. Defaults to a
2//! `guest`/`guest` credential pair (the image's own default) so
3//! [`RabbitMqGuard::amqp_url`] is usable with zero configuration; call
4//! [`RabbitMqContainer::with_username`]/[`RabbitMqContainer::with_password`] before
5//! `start()` to override either.
6//!
7//! ### Readiness — verified against a real 4.x boot
8//!
9//! `rabbitmq:4-management-alpine` still prints the same `"Server startup complete"`
10//! line the 3.x series used (captured verbatim from a real boot with this module's
11//! env):
12//!
13//! ```text
14//! ...
15//! 2026-07-04 08:47:17.936423+00:00 [info] <0.1036.0> started TCP listener on [::]:5672
16//! completed with 4 plugins.
17//! 2026-07-04 08:47:18.001311+00:00 [info] <0.900.0> Server startup complete; 4 plugins started.
18//! 2026-07-04 08:47:18.001311+00:00 [info] <0.900.0> * rabbitmq_prometheus
19//! 2026-07-04 08:47:18.001311+00:00 [info] <0.900.0> * rabbitmq_management
20//! 2026-07-04 08:47:18.001311+00:00 [info] <0.900.0> * rabbitmq_management_agent
21//! 2026-07-04 08:47:18.001311+00:00 [info] <0.900.0> * rabbitmq_web_dispatch
22//! ```
23//!
24//! The line appears exactly once, so `for_log_message` at `times=1` is unambiguous —
25//! unlike Postgres/MySQL/MariaDB, there is no double-boot restart to race here. The
26//! management API's own `/api/health/checks/...` endpoints require authenticated
27//! requests, so the log line is the simpler and equally reliable readiness signal.
28//!
29//! No control characters were found in the image's baked env (checked
30//! via `docker image inspect`), so no env override is needed here — unlike
31//! [`crate::postgres::PostgresContainer`].
32//!
33//! No `with_memory_limit` override: booted clean on msb's default ~450M microVM RAM
34//! (observed ~5.5s IT round-trip on both backends — an Erlang VM, not a JVM, so no
35//! Paketo/QuickStart-style heap demand; no memory-ladder escalation was needed).
36//!
37//! ### A 4.x behavior change worth knowing (not this module's concern, but bites naive clients)
38//!
39//! RabbitMQ 4.x deprecates `transient_nonexcl_queues` and, per the broker's own startup
40//! warning, "this feature can still be used for now" but a client that declares a
41//! **non-durable, non-exclusive** queue (`durable=false, exclusive=false`) may be
42//! rejected with `reply-code=541 INTERNAL_ERROR` depending on the deployed policy —
43//! reproduced directly against this module's pinned image. Declare durable,
44//! non-exclusive queues (or exclusive transient ones) from client code exercising this
45//! container; this module itself declares no queues.
46
47use rightsize::{Container, ContainerGuard, Result, Wait};
48
49const AMQP_PORT: u16 = 5672;
50const MANAGEMENT_PORT: u16 = 15672;
51
52/// A single-node RabbitMQ container with the management plugin enabled.
53pub struct RabbitMqContainer {
54 container: Container,
55 username: String,
56 password: String,
57}
58
59impl RabbitMqContainer {
60 /// Builds a container from the pinned default image
61 /// (`rabbitmq:4-management-alpine`).
62 pub fn new() -> Self {
63 Self::with_image("rabbitmq:4-management-alpine")
64 }
65
66 /// Builds a container from a caller-chosen image.
67 pub fn with_image(image: &str) -> Self {
68 let username = "guest".to_string();
69 let password = "guest".to_string();
70 let container = Container::new(image)
71 .with_exposed_ports(&[AMQP_PORT, MANAGEMENT_PORT])
72 .with_env("RABBITMQ_DEFAULT_USER", &username)
73 .with_env("RABBITMQ_DEFAULT_PASS", &password)
74 // Exactly-once log line (see the module doc for the captured excerpt) —
75 // no restart race.
76 .waiting_for(Wait::for_log_message(".*Server startup complete.*", 1));
77 Self {
78 container,
79 username,
80 password,
81 }
82 }
83
84 /// Overrides `RABBITMQ_DEFAULT_USER` (default `guest`).
85 pub fn with_username(mut self, username: &str) -> Self {
86 self.username = username.to_string();
87 self.container = self.container.with_env("RABBITMQ_DEFAULT_USER", username);
88 self
89 }
90
91 /// Overrides `RABBITMQ_DEFAULT_PASS` (default `guest`).
92 pub fn with_password(mut self, password: &str) -> Self {
93 self.password = password.to_string();
94 self.container = self.container.with_env("RABBITMQ_DEFAULT_PASS", password);
95 self
96 }
97
98 /// Boots the container.
99 pub async fn start(self) -> Result<RabbitMqGuard> {
100 crate::register_default_backends();
101 let guard = self.container.start().await?;
102 Ok(RabbitMqGuard {
103 guard,
104 username: self.username,
105 password: self.password,
106 })
107 }
108}
109
110impl Default for RabbitMqContainer {
111 fn default() -> Self {
112 Self::new()
113 }
114}
115
116/// The running guard for a [`RabbitMqContainer`].
117pub struct RabbitMqGuard {
118 guard: ContainerGuard,
119 username: String,
120 password: String,
121}
122
123impl RabbitMqGuard {
124 /// The configured management/AMQP user (default `guest`).
125 pub fn username(&self) -> &str {
126 &self.username
127 }
128
129 /// The configured management/AMQP password (default `guest`).
130 pub fn password(&self) -> &str {
131 &self.password
132 }
133
134 /// An `amqp://` URL (with credentials) for the running container's AMQP listener.
135 pub fn amqp_url(&self) -> String {
136 format!(
137 "amqp://{}:{}@{}:{}",
138 self.username,
139 self.password,
140 self.guard.host(),
141 self.guard.get_mapped_port(AMQP_PORT).unwrap()
142 )
143 }
144
145 /// The management UI/API base URI for the running container.
146 pub fn management_url(&self) -> String {
147 format!(
148 "http://{}:{}",
149 self.guard.host(),
150 self.guard.get_mapped_port(MANAGEMENT_PORT).unwrap()
151 )
152 }
153
154 /// Stops and removes the container, releasing its host ports.
155 pub async fn stop(self) -> Result<()> {
156 self.guard.stop().await
157 }
158}
159
160impl std::ops::Deref for RabbitMqGuard {
161 type Target = ContainerGuard;
162 fn deref(&self) -> &ContainerGuard {
163 &self.guard
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn defaults_are_the_guest_pair() {
173 let c = RabbitMqContainer::new();
174 assert_eq!(c.username, "guest");
175 assert_eq!(c.password, "guest");
176 }
177
178 #[test]
179 fn builders_override_the_defaults() {
180 let c = RabbitMqContainer::new()
181 .with_username("alice")
182 .with_password("s3cret");
183 assert_eq!(c.username, "alice");
184 assert_eq!(c.password, "s3cret");
185 }
186}