omry_integration/
flora_stack.rs1use 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
18const DEFAULT_TRACING_LEVEL: Level = Level::DEBUG;
20
21const DEFAULT_SYNC_INTERVAL: Duration = Duration::from_secs(10);
23
24const DOCKER_HOST_INTERNAL: &str = "host.docker.internal";
26
27const AUTH_TOKEN_LEN: usize = 20;
29
30#[derive(Debug, Clone)]
32pub struct FloraStackOptions {
33 pub tracing_level: Level,
37
38 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#[derive(Debug)]
56pub struct FloraStack {
57 auth_token: String,
59
60 flora_server: FloraContainer,
62
63 typesense: TestTypesense,
65}
66
67impl FloraStack {
68 #[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 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 #[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 #[must_use]
154 pub fn flora_container(&self) -> &FloraContainer {
155 &self.flora_server
156 }
157
158 #[must_use]
160 pub fn auth_token(&self) -> &str {
161 &self.auth_token
162 }
163}
164
165pub 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#[derive(Debug, Clone)]
212pub struct OmryAddrs {
213 pub rpc: EndpointAddr,
215
216 pub web: SocketAddr,
218}
219
220impl OmryAddrs {
221 #[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 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#[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#[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#[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
298pub 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 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 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}