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