Skip to main content

omry_integration/
flora_stack.rs

1use anyhow::{Context, Result};
2use omry_service::OmryApi;
3use omry_service::iroh::endpoint::presets;
4use omry_service::iroh::{Endpoint, EndpointAddr};
5use secrecy::SecretString;
6use std::env::consts;
7use std::net::{Ipv4Addr, SocketAddr, ToSocketAddrs};
8use std::time::Duration;
9use testcontainers::Image;
10use tracing::{Level, instrument};
11
12use crate::containers::{ContainerConfig, ImageConfig, ImageExtConfig, get_bridge_gateway_ip};
13use crate::flora_image::{FloraConfig, FloraContainer, start_flora_container};
14use crate::full_stack::App;
15use crate::helpers::{self, DockerHost, HostPorts, token};
16use crate::typesense::TestTypesense;
17
18/// Default tracing level for the flora test server.
19const DEFAULT_TRACING_LEVEL: Level = Level::DEBUG;
20
21/// Default interval at which the flora test server will sync new records to Typesense.
22const DEFAULT_SYNC_INTERVAL: Duration = Duration::from_secs(10);
23
24/// Value that corresponds to the host machine in Docker.
25const DOCKER_HOST_INTERNAL: &str = "host.docker.internal";
26
27/// Length of the RPC auth token.
28const AUTH_TOKEN_LEN: usize = 20;
29
30/// Optional parameters for [`FloraStack`].
31#[derive(Debug, Clone)]
32pub struct FloraStackOptions {
33    /// The tracing level to set for the test flora server.
34    ///
35    /// Corresponds to `FLORA_TRACING_LEVEL`.
36    pub tracing_level: Level,
37
38    /// The interval at which the test flora server will sync
39    /// new records to Typesense.
40    ///
41    /// Corresponds to `FLORA_SEARCH_SYNC_INTERVAL_SECS`.
42    pub search_sync_interval: Duration,
43}
44
45impl Default for FloraStackOptions {
46    fn default() -> Self {
47        Self {
48            tracing_level: DEFAULT_TRACING_LEVEL,
49            search_sync_interval: DEFAULT_SYNC_INTERVAL,
50        }
51    }
52}
53
54/// Flora stack (flora server and Typesense), running in testcontainers, for integration testing.
55#[derive(Debug)]
56pub struct FloraStack {
57    /// RPC auth token.
58    auth_token: String,
59
60    /// Container for the Omry server.
61    flora_server: FloraContainer,
62
63    /// Typesense container.
64    typesense: TestTypesense,
65}
66
67impl FloraStack {
68    /// Creates a new instance of the Omry stack.
69    ///
70    /// ## Errors
71    /// Returns [`anyhow::Error`] if we can't:
72    ///
73    /// - Start the Typesense container
74    /// - Get the client config to connect to Typesense
75    /// - Map the Typesense host to Docker's platform-specific localhost address (this differs between Linux and macOS)
76    /// - Start the Omry container
77    #[instrument]
78    pub async fn new(stack_options: Option<FloraStackOptions>) -> Result<Self> {
79        let typesense_img_ext_conf = ImageExtConfig::default();
80        let typesense_con_conf =
81            ContainerConfig::new(ImageConfig::default(), typesense_img_ext_conf);
82        let typesense = TestTypesense::new(typesense_con_conf)
83            .await
84            .map_err(|error| {
85                anyhow::anyhow!("Couldn't start the test Typesense container: {error}")
86            })?;
87
88        let client_config = typesense
89            .make_client_config()
90            .await
91            .map_err(|error| anyhow::anyhow!("{error}"))?;
92        let Some(mut typesense_url) = client_config.url else {
93            anyhow::bail!("Test Typesense client config doesn't have a URL value.");
94        };
95
96        // Workaround for connecting within docker.
97        let remapped_host = match typesense_url.host_str() {
98            Some("127.0.0.1" | "localhost") => match consts::OS {
99                "macos" => Some(DOCKER_HOST_INTERNAL.to_string()),
100                "linux" => Some(get_bridge_gateway_ip().await?),
101                os => {
102                    tracing::warn!("your OS ({os}) may be unsupported for docker tests");
103                    None
104                }
105            },
106            _ => None,
107        };
108
109        if let Some(host) = remapped_host {
110            typesense_url.set_host(Some(&host))?;
111        }
112
113        let options = stack_options.unwrap_or_default();
114        let auth_token = token::generate(AUTH_TOKEN_LEN);
115        let flora_conf = FloraConfig::new(
116            SecretString::new(auth_token.clone().into()),
117            &typesense_url,
118            client_config.api_key,
119        )
120        .with_tracing_level(options.tracing_level);
121
122        let flora_image_conf = ImageConfig::default();
123        let flora_image_ext_conf = ImageExtConfig::default();
124        let flora_con_conf = ContainerConfig::new(flora_image_conf, flora_image_ext_conf);
125
126        let flora_server = start_flora_container(flora_conf, flora_con_conf)
127            .await
128            .map_err(|error| anyhow::anyhow!("{error}"))?;
129
130        Ok(FloraStack {
131            auth_token,
132            flora_server,
133            typesense,
134        })
135    }
136
137    /// Stops the Docker containers in this Omry stack.
138    ///
139    /// ## Errors
140    /// Returns [`anyhow::Result`] if we can't:
141    ///
142    /// - Stop the Omry container
143    /// - Stop the Typesense container
144    #[instrument]
145    pub async fn stop(&self) -> Result<()> {
146        self.flora_server.stop().await?;
147        self.typesense.container().stop().await?;
148        Ok(())
149    }
150
151    /// Returns a reference to the flora server test container
152    /// running as part of this stack.
153    #[must_use]
154    pub fn flora_container(&self) -> &FloraContainer {
155        &self.flora_server
156    }
157
158    /// Returns the RPC auth token.
159    #[must_use]
160    pub fn auth_token(&self) -> &str {
161        &self.auth_token
162    }
163}
164
165/// Attempts to use [`testcontainers::ContainerAsync`] APIs
166/// to get the host and host-side port for the given container.
167///
168/// This may not work if the container is configured to use host networking.
169///
170/// ## Errors
171/// Returns [`anyhow::Error`] if we can't get the container host or port.
172pub async fn get_host_and_ports(container: &FloraContainer) -> Result<(String, HostPorts)> {
173    let [tarpc_port, web_port] = container.image().expose_ports() else {
174        anyhow::bail!("Omry container doesn't expose ports for tarpc and web");
175    };
176
177    let host = if let Ok(docker_host) = DockerHost::from_env()
178        && docker_host.is_remote()
179    {
180        docker_host
181            .host()
182            .ok_or_else(|| anyhow::anyhow!("Remote Docker host without a host component"))?
183            .to_string()
184    } else {
185        container
186            .get_host()
187            .await
188            .context("couldn't get the Omry container host")?
189            .to_string()
190    };
191
192    let host_port_tarpc = container
193        .get_host_port_ipv4(*tarpc_port)
194        .await
195        .context("couldn't get the tarpc host-side port for the Omry container")?;
196
197    let host_port_web = container
198        .get_host_port_ipv4(*web_port)
199        .await
200        .context("couldn't get the web host-side port for the Omry container")?;
201
202    let ports = HostPorts {
203        rpc: host_port_tarpc,
204        web: host_port_web,
205    };
206
207    Ok((host, ports))
208}
209
210/// `irpc` and web socket addresses for the Omry server.
211#[derive(Debug, Clone)]
212pub struct OmryAddrs {
213    /// `irpc` endpoint address.
214    pub rpc: EndpointAddr,
215
216    /// Web socket address.
217    pub web: SocketAddr,
218}
219
220impl OmryAddrs {
221    /// Gets the host and ports for the Omry server via its container.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if:
226    ///
227    /// - The container is running on localhost, and we can't get its ports
228    /// - We can't create socket addresses from the host and ports
229    ///   (should never happen in practice)
230    #[instrument]
231    pub async fn new(container: &FloraContainer) -> Result<Self> {
232        let (host, ports) = match get_host_and_ports(container).await {
233            Ok((host, ports)) => (host, ports),
234            Err(error) => {
235                tracing::warn!(
236                    "couldn't get the host port using the testcontainers APIs; trying workaround\
237                \noriginal error: {error}"
238                );
239
240                // If we're running on localhost, we need to have host ports.
241                let Some(ports) = container.image().host_ports() else {
242                    anyhow::bail!("couldn't get the host-side port for the flora container");
243                };
244
245                (Ipv4Addr::LOCALHOST.to_string(), ports)
246            }
247        };
248
249        let HostPorts { rpc: _, web } = ports;
250        let web = format!("{host}:{web}")
251            .to_socket_addrs()?
252            .next()
253            .ok_or_else(|| anyhow::anyhow!("No socket addresses"))
254            .context("Couldn't create a socket address for web.")?;
255
256        let web_url = format!("http://{web}");
257        let rpc = helpers::endpoint_addr(&web_url).await?;
258
259        Ok(Self { rpc, web })
260    }
261}
262
263/// Spawns a new [`OmryApi`] connected to the given endpoint.
264///
265/// ## Errors
266///
267/// Returns [`anyhow::Error`] if we can't initialize the RPC connection.
268#[instrument]
269async fn spawn_client(server_addr: EndpointAddr) -> Result<OmryApi> {
270    tracing::info!("connecting API");
271    let endpoint = Endpoint::bind(presets::N0).await?;
272    let api = OmryApi::connect(endpoint, server_addr);
273    Ok(api)
274}
275
276/// Spawns a new [`OmryApi`] connected to the given address,
277/// and authenticates with the given token.
278#[allow(clippy::missing_errors_doc)]
279#[instrument(skip(auth_token))]
280pub async fn spawn_and_auth(server_addr: EndpointAddr, auth_token: String) -> Result<OmryApi> {
281    let api = spawn_client(server_addr).await?;
282    api.auth(auth_token).await?;
283    Ok(api)
284}
285
286/// Spawns a new API instance connected to the Omry server
287/// running in the given Docker container.
288///
289/// ## Errors
290///
291/// Returns [`anyhow::Error`] if we can't spawn the Omry service client.
292#[instrument]
293pub async fn api_container(stack: &FloraStack) -> Result<OmryApi> {
294    let OmryAddrs { rpc, web: _ } = OmryAddrs::new(stack.flora_container()).await?;
295    spawn_and_auth(rpc, stack.auth_token().into()).await
296}
297
298/// Returns an app configured to connect to the server
299/// running in the given container.
300///
301/// # Errors
302/// Returns [`anyhow::Error`] if we can't create the test API instance.
303pub async fn app_container(stack: &FloraStack) -> Result<App> {
304    api_container(stack)
305        .await
306        .context("couldn't create the test API instance")
307        .map(App::new)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::bridge::ApiBridge;
314    use crate::helpers::{self, token};
315    use omry_service::PONG;
316    use pretty_assertions::assert_eq;
317    use secrecy::SecretString;
318    use std::time::Duration;
319    use tokio::time;
320    use url::Url;
321
322    #[test]
323    fn can_create_flora_config_with_options() -> Result<()> {
324        let options = FloraStackOptions {
325            tracing_level: Level::WARN,
326            search_sync_interval: Duration::from_secs(37),
327        };
328        let url: Url = "https://my.test.typesense".try_into()?;
329        let auth_token = SecretString::new(token::generate(20).into());
330        let api_key = "very-secret".into();
331
332        let flora_config =
333            FloraConfig::new(auth_token, &url, api_key).with_tracing_level(options.tracing_level);
334        insta::assert_debug_snapshot!(flora_config);
335
336        Ok(())
337    }
338
339    #[tokio::test]
340    async fn can_start_flora_stack() {
341        assert!(FloraStack::new(None).await.is_ok());
342    }
343
344    #[tokio::test]
345    async fn can_spawn_flora_client_connected_to_test_server() -> Result<()> {
346        helpers::init_tracing_subscriber()?;
347
348        let flora_stack = FloraStack::new(None).await?;
349
350        // Give the flora server time to start.
351        time::sleep(Duration::from_secs(5)).await;
352
353        let app = app_container(&flora_stack)
354            .await
355            .context("couldn't spawn the test app")?;
356
357        let ping_response = app.ping().await.context("couldn't send the ping request")?;
358        assert_eq!(ping_response, PONG);
359        Ok(())
360    }
361
362    #[tokio::test]
363    async fn can_configure_flora_stack() -> Result<()> {
364        helpers::init_tracing_subscriber()?;
365
366        let options = FloraStackOptions::default();
367        let flora_stack = FloraStack::new(Some(options)).await?;
368
369        // Give the server time to start.
370        tokio::time::sleep(Duration::from_secs(5)).await;
371
372        let app = app_container(&flora_stack)
373            .await
374            .context("couldn't spawn the test app")?;
375
376        tracing::trace!("sending ping request");
377        let ping_response = app.ping().await.context("couldn't send the ping request")?;
378
379        insta::assert_snapshot!(ping_response, PONG);
380
381        Ok(())
382    }
383}