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