omry_integration/
flora_stack.rs

1use 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
15/// Default tracing level for the flora test server.
16const DEFAULT_TRACING_LEVEL: Level = Level::DEBUG;
17
18/// Default interval at which the flora test server will sync new records to Typesense.
19const DEFAULT_SYNC_INTERVAL: Duration = Duration::from_secs(10);
20
21/// Value that corresponds to the host machine in Docker.
22const DOCKER_HOST_INTERNAL: &str = "host.docker.internal";
23
24/// Optional parameters for [`FloraStack`].
25#[derive(Debug, Clone)]
26pub struct FloraStackOptions {
27    /// The tracing level to set for the test flora server.
28    ///
29    /// Corresponds to `FLORA_TRACING_LEVEL`.
30    pub tracing_level: Level,
31
32    /// The interval at which the test flora server will sync
33    /// new records to Typesense.
34    ///
35    /// Corresponds to `FLORA_SEARCH_SYNC_INTERVAL_SECS`.
36    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/// Flora stack (flora server and Typesense), running in testcontainers, for integration testing.
49#[derive(Debug)]
50pub struct FloraStack {
51    /// Container for the Omry server.
52    flora_server: FloraContainer,
53
54    /// Typesense container.
55    typesense: TestTypesense,
56}
57
58impl FloraStack {
59    /// Creates a new instance of the Omry stack.
60    ///
61    /// ## Errors
62    /// Returns [`anyhow::Error`] if we can't:
63    ///
64    /// - Start the Typesense container
65    /// - Get the client config to connect to Typesense
66    /// - Map the Typesense host to Docker's platform-specific localhost address (this differs between Linux and macOS)
67    /// - Start the Omry container
68    #[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        // Workaround for connecting within docker.
88        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    /// Stops the Docker containers in this Omry stack.
123    ///
124    /// ## Errors
125    /// Returns [`anyhow::Result`] if we can't:
126    ///
127    /// - Stop the Omry container
128    /// - Stop the Typesense container
129    #[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    /// Returns a reference to the flora server test container
137    /// running as part of this stack.
138    #[must_use]
139    pub fn flora_container(&self) -> &FloraContainer {
140        &self.flora_server
141    }
142}
143
144/// Spawns a new [`FloraServiceClient`] connected to the given socket.
145///
146/// ## Errors
147///
148/// Returns [`anyhow::Error`] if we can't initialize the [`tarpc`] transport.
149#[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
158/// Attempts to use [`testcontainers::ContainerAsync`] APIs
159/// to get the host and host-side port for the given container.
160///
161/// This may not work if the container is configured to use host networking.
162///
163/// ## Errors
164/// Returns [`anyhow::Error`] if we can't get the container host or port.
165pub 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/// Spawns a new [`FloraServiceClient`] connected to the Omry server
193/// running in the given Docker container.
194///
195/// ## Errors
196///
197/// Returns [`anyhow::Error`] if we can't spawn the Omry service client.
198#[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            // If the host port is not `None`, that means we're running on localhost.
209            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        // Give the flora server time to start.
259        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        // Give the flora server time to start.
285        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}