omry_integration/
flora_stack.rs1use anyhow::{Context, Result};
2use omry_service::tarpc::client;
3use omry_service::tarpc::tokio_serde::formats::Bincode;
4use omry_service::{FloraServiceClient, tarpc};
5use std::env::consts;
6use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
7use std::time::Duration;
8use testcontainers::Image;
9use tracing::{Level, instrument};
10
11use crate::containers::{ContainerConfig, ImageConfig, ImageExtConfig, get_bridge_gateway_ip};
12use crate::flora_image::{FloraConfig, FloraContainer, start_flora_container};
13use crate::typesense::TestTypesense;
14
15const DEFAULT_TRACING_LEVEL: Level = Level::DEBUG;
17
18const DEFAULT_SYNC_INTERVAL: Duration = Duration::from_secs(10);
20
21const DOCKER_HOST_INTERNAL: &str = "host.docker.internal";
23
24#[derive(Debug, Clone)]
26pub struct FloraStackOptions {
27 pub tracing_level: Level,
31
32 pub search_sync_interval: Duration,
37}
38
39impl Default for FloraStackOptions {
40 fn default() -> Self {
41 Self {
42 tracing_level: DEFAULT_TRACING_LEVEL,
43 search_sync_interval: DEFAULT_SYNC_INTERVAL,
44 }
45 }
46}
47
48#[derive(Debug)]
50pub struct FloraStack {
51 flora_server: FloraContainer,
53
54 typesense: TestTypesense,
56}
57
58impl FloraStack {
59 #[instrument]
69 pub async fn new(stack_options: Option<FloraStackOptions>) -> Result<Self> {
70 let typesense_img_ext_conf = ImageExtConfig::default();
71 let typesense_con_conf =
72 ContainerConfig::new(ImageConfig::default(), typesense_img_ext_conf);
73 let typesense = TestTypesense::new(typesense_con_conf)
74 .await
75 .map_err(|error| {
76 anyhow::anyhow!("Couldn't start the test Typesense container: {error}")
77 })?;
78
79 let client_config = typesense
80 .make_client_config()
81 .await
82 .map_err(|error| anyhow::anyhow!("{error}"))?;
83 let Some(mut typesense_url) = client_config.url else {
84 anyhow::bail!("Test Typesense client config doesn't have a URL value.");
85 };
86
87 let remapped_host = match typesense_url.host_str() {
89 Some("127.0.0.1" | "localhost") => match consts::OS {
90 "macos" => Some(DOCKER_HOST_INTERNAL.to_string()),
91 "linux" => Some(get_bridge_gateway_ip().await?),
92 os => {
93 tracing::warn!("your OS ({os}) may be unsupported for docker tests");
94 None
95 }
96 },
97 _ => None,
98 };
99
100 if let Some(host) = remapped_host {
101 typesense_url.set_host(Some(&host))?;
102 }
103
104 let options = stack_options.unwrap_or_default();
105 let flora_conf = FloraConfig::new(&typesense_url, client_config.api_key)
106 .with_tracing_level(options.tracing_level);
107
108 let flora_image_conf = ImageConfig::default();
109 let flora_image_ext_conf = ImageExtConfig::default();
110 let flora_con_conf = ContainerConfig::new(flora_image_conf, flora_image_ext_conf);
111
112 let flora_server = start_flora_container(flora_conf, flora_con_conf)
113 .await
114 .map_err(|error| anyhow::anyhow!("{error}"))?;
115
116 Ok(FloraStack {
117 flora_server,
118 typesense,
119 })
120 }
121
122 #[instrument]
130 pub async fn stop(&self) -> Result<()> {
131 self.flora_server.stop().await?;
132 self.typesense.container().stop().await?;
133 Ok(())
134 }
135
136 #[must_use]
139 pub fn flora_container(&self) -> &FloraContainer {
140 &self.flora_server
141 }
142}
143
144#[instrument(skip(socket))]
150pub async fn spawn_client(socket: impl tokio::net::ToSocketAddrs) -> Result<FloraServiceClient> {
151 let mut transport = tarpc::serde_transport::tcp::connect(socket, Bincode::default);
152 transport.config_mut().max_frame_length(usize::MAX);
153
154 let client = FloraServiceClient::new(client::Config::default(), transport.await?).spawn();
155 Ok(client)
156}
157
158pub async fn get_host_and_port(container: &FloraContainer) -> Result<(Ipv4Addr, u16)> {
166 let container_host = container
167 .get_host()
168 .await
169 .context("couldn't get the Omry container host")?
170 .to_string();
171
172 let host_ip = if container_host == "localhost" {
173 Ipv4Addr::LOCALHOST
174 } else {
175 anyhow::bail!("Non-localhost host is not supported yet: {container_host}")
176 };
177
178 let container_port = container
179 .image()
180 .expose_ports()
181 .first()
182 .ok_or_else(|| anyhow::anyhow!("Omry container doesn't expose any ports"))?;
183
184 let host_port = container
185 .get_host_port_ipv4(*container_port)
186 .await
187 .context("couldn't get the host-side port for the Omry container")?;
188
189 Ok((host_ip, host_port))
190}
191
192#[instrument]
199pub async fn flora_client(container: &FloraContainer) -> Result<FloraServiceClient> {
200 let (host_ip, host_port) = match get_host_and_port(container).await {
201 Ok(result) => result,
202 Err(error) => {
203 tracing::warn!(
204 "couldn't get the host port using the testcontainers APIs; trying workaround\
205 \noriginal error: {error}"
206 );
207
208 let Some(port) = container.image().host_port() else {
210 anyhow::bail!("couldn't get the host-side port for the flora container")
211 };
212 (Ipv4Addr::LOCALHOST, port)
213 }
214 };
215
216 let socket_addr = SocketAddr::V4(SocketAddrV4::new(host_ip, host_port));
217 spawn_client(socket_addr).await
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::helpers;
224 use omry_service::PONG;
225 use omry_service::tarpc::context;
226 use pretty_assertions::assert_eq;
227 use std::time::Duration;
228 use tokio::time;
229 use url::Url;
230
231 #[test]
232 fn can_create_flora_config_with_options() -> Result<()> {
233 let options = FloraStackOptions {
234 tracing_level: Level::WARN,
235 search_sync_interval: Duration::from_secs(37),
236 };
237 let url: Url = "https://my.test.typesense".try_into()?;
238 let api_key = "very-secret".into();
239
240 let flora_config =
241 FloraConfig::new(&url, api_key).with_tracing_level(options.tracing_level);
242 insta::assert_debug_snapshot!(flora_config);
243
244 Ok(())
245 }
246
247 #[tokio::test]
248 async fn can_start_flora_stack() {
249 assert!(FloraStack::new(None).await.is_ok());
250 }
251
252 #[tokio::test]
253 async fn can_spawn_flora_client_connected_to_test_server() -> Result<()> {
254 helpers::init_tracing_subscriber()?;
255
256 let flora_stack = FloraStack::new(None).await?;
257
258 time::sleep(Duration::from_secs(5)).await;
260
261 let flora_container = flora_stack.flora_container();
262
263 let flora_client = flora_client(flora_container)
264 .await
265 .context("couldn't spawn the test client")?;
266
267 let ping_response = flora_client
268 .ping(context::current())
269 .await
270 .context("couldn't send the ping request")?;
271
272 assert_eq!(ping_response, PONG);
273
274 Ok(())
275 }
276
277 #[tokio::test]
278 async fn can_configure_flora_stack() -> Result<()> {
279 helpers::init_tracing_subscriber()?;
280
281 let options = FloraStackOptions::default();
282 let flora_stack = FloraStack::new(Some(options)).await?;
283
284 time::sleep(Duration::from_secs(5)).await;
286
287 let flora_container = flora_stack.flora_container();
288
289 let flora_client = flora_client(flora_container)
290 .await
291 .context("couldn't spawn the test client")?;
292
293 let ping_response = flora_client
294 .ping(context::current())
295 .await
296 .context("couldn't send the ping request")?;
297
298 insta::assert_snapshot!(ping_response, PONG);
299
300 Ok(())
301 }
302}