1use std::{
2 env, fmt,
3 net::SocketAddr,
4 path::{Path, PathBuf},
5 process::Stdio,
6 thread,
7 time::Duration,
8};
9
10use hyper::StatusCode;
11use rustix::process::{Pid, Signal, kill_process};
12use serde_json::Value;
13use tempfile::TempDir;
14use tokio::{
15 fs,
16 process::{Child, Command},
17 time::{Instant, sleep},
18};
19
20use crate::http::HttpClient;
21
22const STARTUP_TIMEOUT: Duration = Duration::from_secs(15);
23const LOG_TAIL_LINES: usize = 50;
24const NOT_FOUND: &str =
25 "nats-server binary not found; set NATS_SERVER_BIN or add nats-server to PATH";
26
27#[derive(Debug, Default)]
31#[must_use]
32pub struct NatsServerBuilder {
33 jetstream: bool,
34 jetstream_domain: Option<String>,
35 websocket: bool,
36 users: Vec<(String, String)>,
37 max_payload: Option<u32>,
38 extra_config: Vec<String>,
39}
40
41#[derive(Debug)]
48pub struct NatsServer {
49 child: Child,
50 pid: Pid,
51 dir: TempDir,
52 client_url: String,
53 websocket_url: Option<String>,
54 monitoring_addr: SocketAddr,
55 http: HttpClient,
56}
57
58impl NatsServerBuilder {
59 pub fn jetstream(mut self) -> Self {
61 self.jetstream = true;
62 self
63 }
64
65 pub fn jetstream_domain(mut self, domain: impl Into<String>) -> Self {
67 self.jetstream = true;
68 self.jetstream_domain = Some(domain.into());
69 self
70 }
71
72 pub fn websocket(mut self) -> Self {
74 self.websocket = true;
75 self
76 }
77
78 pub fn user(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
82 self.users.push((username.into(), password.into()));
83 self
84 }
85
86 pub fn max_payload(mut self, max_payload: u32) -> Self {
88 self.max_payload = Some(max_payload);
89 self
90 }
91
92 pub fn config(mut self, config: impl Into<String>) -> Self {
94 self.extra_config.push(config.into());
95 self
96 }
97
98 #[must_use]
108 pub async fn start(self) -> Option<NatsServer> {
109 let Some(bin) = find_nats_server() else {
110 assert!(
111 env::var_os("CI").is_none_or(|ci| ci.is_empty()),
112 "{NOT_FOUND}"
113 );
114 eprintln!("SKIPPED: {NOT_FOUND}");
115 return None;
116 };
117
118 let dir = tempfile::Builder::new()
119 .prefix("watermelon-testkit-")
120 .tempdir()
121 .expect("create server temporary directory");
122 let config_path = dir.path().join("nats-server.conf");
123 fs::write(&config_path, self.render(dir.path()))
124 .await
125 .expect("write server config");
126
127 let child = Command::new(&bin)
128 .arg("--config")
129 .arg(&config_path)
130 .stdin(Stdio::null())
131 .stdout(Stdio::null())
132 .stderr(Stdio::null())
133 .kill_on_drop(true)
134 .spawn()
135 .unwrap_or_else(|err| panic!("spawn {}: {err}", bin.display()));
136 let pid = child
137 .id()
138 .and_then(|id| Pid::from_raw(id.try_into().ok()?))
139 .expect("nats-server pid");
140
141 let mut server = NatsServer {
142 child,
143 pid,
144 dir,
145 client_url: String::new(),
146 websocket_url: None,
147 monitoring_addr: SocketAddr::from(([0, 0, 0, 0], 0)),
148 http: HttpClient::new(),
149 };
150 server.wait_ready(self.jetstream).await;
151 Some(server)
152 }
153
154 fn render(&self, dir: &Path) -> String {
155 let mut config = String::new();
156 self.write_config(&mut config, dir)
157 .expect("formatting into a String never fails");
158 config
159 }
160
161 fn write_config(&self, w: &mut impl fmt::Write, dir: &Path) -> fmt::Result {
162 writeln!(w, r#"listen: "127.0.0.1:-1""#)?;
163 writeln!(w, r#"http: "127.0.0.1:-1""#)?;
164 writeln!(w, "ports_file_dir: {}", quote(dir))?;
165 writeln!(w, "log_file: {}", quote(dir.join("nats-server.log")))?;
166 writeln!(w, "logtime: true")?;
167 writeln!(w, "debug: true")?;
168 writeln!(w, "trace: true")?;
169 writeln!(w, "max_traced_msg_len: 256")?;
170
171 if self.jetstream {
172 write!(
173 w,
174 "jetstream {{ store_dir: {}",
175 quote(dir.join("jetstream"))
176 )?;
177 if let Some(domain) = &self.jetstream_domain {
178 write!(w, ", domain: {}", quote(domain))?;
179 }
180 writeln!(w, " }}")?;
181 }
182 if self.websocket {
183 writeln!(w, r#"websocket {{ listen: "127.0.0.1:-1", no_tls: true }}"#)?;
184 }
185 if !self.users.is_empty() {
186 write!(w, "authorization {{ users: [")?;
187 for (username, password) in &self.users {
188 write!(
189 w,
190 " {{ user: {}, password: {} }}",
191 quote(username),
192 quote(password)
193 )?;
194 }
195 writeln!(w, " ] }}")?;
196 }
197 if let Some(max_payload) = self.max_payload {
198 writeln!(w, "max_payload: {max_payload}")?;
199 }
200 for extra in &self.extra_config {
201 writeln!(w, "{extra}")?;
202 }
203
204 Ok(())
205 }
206}
207
208impl NatsServer {
209 pub fn builder() -> NatsServerBuilder {
211 NatsServerBuilder::default()
212 }
213
214 #[must_use]
216 pub fn client_url(&self) -> &str {
217 &self.client_url
218 }
219
220 #[must_use]
224 pub fn websocket_url(&self) -> Option<&str> {
225 self.websocket_url.as_deref()
226 }
227
228 #[must_use]
234 pub async fn monitor(&self, path_and_query: &str) -> Value {
235 let (status, body) = self
236 .http
237 .get(self.monitoring_addr, path_and_query)
238 .await
239 .unwrap_or_else(|err| panic!("GET {path_and_query}: {err}"));
240 assert_eq!(
241 status,
242 StatusCode::OK,
243 "GET {path_and_query}: {}",
244 String::from_utf8_lossy(&body)
245 );
246 serde_json::from_slice(&body)
247 .unwrap_or_else(|err| panic!("GET {path_and_query}: invalid JSON: {err}"))
248 }
249
250 #[must_use]
254 pub async fn connections(&self) -> Vec<Value> {
255 match self
256 .monitor("/connz?subs=1")
257 .await
258 .get_mut("connections")
259 .map(Value::take)
260 {
261 Some(Value::Array(connections)) => connections,
262 _ => Vec::new(),
263 }
264 }
265
266 pub fn lame_duck(&self) {
268 self.signal(Signal::USR2);
269 }
270
271 pub fn pause(&self) {
273 self.signal(Signal::STOP);
274 }
275
276 pub fn resume(&self) {
278 self.signal(Signal::CONT);
279 }
280
281 pub async fn shutdown(&mut self) {
287 self.signal(Signal::TERM);
288 self.child
289 .wait()
290 .await
291 .expect("wait for nats-server to exit");
292 }
293
294 pub async fn log(&self) -> String {
296 fs::read_to_string(self.log_path())
297 .await
298 .unwrap_or_default()
299 }
300
301 fn log_path(&self) -> PathBuf {
302 self.dir.path().join("nats-server.log")
303 }
304
305 fn signal(&self, signal: Signal) {
306 kill_process(self.pid, signal)
307 .unwrap_or_else(|err| panic!("send {signal:?} to nats-server: {err}"));
308 }
309
310 async fn wait_ready(&mut self, jetstream: bool) {
311 let deadline = Instant::now() + STARTUP_TIMEOUT;
312 let ports_path = self
313 .dir
314 .path()
315 .join(format!("nats-server_{}.ports", self.pid.as_raw_nonzero()));
316
317 let ports = loop {
320 if let Some(ports) = fs::read_to_string(&ports_path)
321 .await
322 .ok()
323 .and_then(|ports| serde_json::from_str::<Value>(&ports).ok())
324 {
325 break ports;
326 }
327 self.assert_starting(deadline).await;
328 };
329
330 let first_url = |kind: &str| ports[kind][0].as_str().map(str::to_owned);
331 self.client_url = first_url("nats").expect("ports file without a nats listener");
332 self.websocket_url = first_url("websocket");
333 self.monitoring_addr = first_url("monitoring")
334 .and_then(|url| url.strip_prefix("http://")?.parse().ok())
335 .expect("ports file without a monitoring listener");
336
337 let healthz = if jetstream {
338 "/healthz?js-enabled-only=true"
339 } else {
340 "/healthz"
341 };
342 while !matches!(
343 self.http.get(self.monitoring_addr, healthz).await,
344 Ok((StatusCode::OK, _))
345 ) {
346 self.assert_starting(deadline).await;
347 }
348 }
349
350 async fn assert_starting(&mut self, deadline: Instant) {
351 if let Some(status) = self.child.try_wait().expect("poll nats-server") {
352 panic!("nats-server exited during startup with {status}");
353 }
354 assert!(
355 Instant::now() < deadline,
356 "nats-server did not become ready within {STARTUP_TIMEOUT:?}"
357 );
358 sleep(Duration::from_millis(10)).await;
359 }
360}
361
362impl Drop for NatsServer {
363 fn drop(&mut self) {
364 let _ = self.child.start_kill();
366
367 if thread::panicking() {
368 self.dir.disable_cleanup(true);
369 let log = std::fs::read_to_string(self.log_path()).unwrap_or_default();
370 let lines = log.lines().collect::<Vec<_>>();
371 let tail = &lines[lines.len().saturating_sub(LOG_TAIL_LINES)..];
372 eprintln!(
373 "---- nats-server log tail (full log and config kept in {}) ----\n{}",
374 self.dir.path().display(),
375 tail.join("\n")
376 );
377 }
378 }
379}
380
381fn find_nats_server() -> Option<PathBuf> {
382 if let Some(bin) = env::var_os("NATS_SERVER_BIN")
383 .map(PathBuf::from)
384 .filter(|bin| bin.is_file())
385 {
386 return Some(bin);
387 }
388
389 env::split_paths(&env::var_os("PATH")?)
390 .map(|dir| dir.join("nats-server"))
391 .find(|bin| bin.is_file())
392}
393
394fn quote(value: impl AsRef<Path>) -> String {
396 let value = value.as_ref().to_str().expect("non UTF-8 path");
397 serde_json::to_string(value).expect("serialize string")
398}