use anyhow::{Context, Result};
use omry_service::tarpc::client;
use omry_service::tarpc::tokio_serde::formats::Bincode;
use omry_service::{FloraServiceClient, tarpc};
use std::env::consts;
use std::net::{Ipv4Addr, SocketAddr, ToSocketAddrs};
use std::time::Duration;
use testcontainers::Image;
use tracing::{Level, instrument};
use crate::containers::{ContainerConfig, ImageConfig, ImageExtConfig, get_bridge_gateway_ip};
use crate::flora_image::{FloraConfig, FloraContainer, start_flora_container};
use crate::helpers::{DockerHost, HostPorts};
use crate::typesense::TestTypesense;
const DEFAULT_TRACING_LEVEL: Level = Level::DEBUG;
const DEFAULT_SYNC_INTERVAL: Duration = Duration::from_secs(10);
const DOCKER_HOST_INTERNAL: &str = "host.docker.internal";
#[derive(Debug, Clone)]
pub struct FloraStackOptions {
pub tracing_level: Level,
pub search_sync_interval: Duration,
}
impl Default for FloraStackOptions {
fn default() -> Self {
Self {
tracing_level: DEFAULT_TRACING_LEVEL,
search_sync_interval: DEFAULT_SYNC_INTERVAL,
}
}
}
#[derive(Debug)]
pub struct FloraStack {
flora_server: FloraContainer,
typesense: TestTypesense,
}
impl FloraStack {
#[instrument]
pub async fn new(stack_options: Option<FloraStackOptions>) -> Result<Self> {
let typesense_img_ext_conf = ImageExtConfig::default();
let typesense_con_conf =
ContainerConfig::new(ImageConfig::default(), typesense_img_ext_conf);
let typesense = TestTypesense::new(typesense_con_conf)
.await
.map_err(|error| {
anyhow::anyhow!("Couldn't start the test Typesense container: {error}")
})?;
let client_config = typesense
.make_client_config()
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let Some(mut typesense_url) = client_config.url else {
anyhow::bail!("Test Typesense client config doesn't have a URL value.");
};
let remapped_host = match typesense_url.host_str() {
Some("127.0.0.1" | "localhost") => match consts::OS {
"macos" => Some(DOCKER_HOST_INTERNAL.to_string()),
"linux" => Some(get_bridge_gateway_ip().await?),
os => {
tracing::warn!("your OS ({os}) may be unsupported for docker tests");
None
}
},
_ => None,
};
if let Some(host) = remapped_host {
typesense_url.set_host(Some(&host))?;
}
let options = stack_options.unwrap_or_default();
let flora_conf = FloraConfig::new(&typesense_url, client_config.api_key)
.with_tracing_level(options.tracing_level);
let flora_image_conf = ImageConfig::default();
let flora_image_ext_conf = ImageExtConfig::default();
let flora_con_conf = ContainerConfig::new(flora_image_conf, flora_image_ext_conf);
let flora_server = start_flora_container(flora_conf, flora_con_conf)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
Ok(FloraStack {
flora_server,
typesense,
})
}
#[instrument]
pub async fn stop(&self) -> Result<()> {
self.flora_server.stop().await?;
self.typesense.container().stop().await?;
Ok(())
}
#[must_use]
pub fn flora_container(&self) -> &FloraContainer {
&self.flora_server
}
}
pub async fn get_host_and_ports(container: &FloraContainer) -> Result<(String, HostPorts)> {
let [tarpc_port, web_port] = container.image().expose_ports() else {
anyhow::bail!("Omry container doesn't expose ports for tarpc and web");
};
let host = if let Ok(docker_host) = DockerHost::from_env()
&& docker_host.is_remote()
{
docker_host
.host()
.ok_or_else(|| anyhow::anyhow!("Remote Docker host without a host component"))?
.to_string()
} else {
container
.get_host()
.await
.context("couldn't get the Omry container host")?
.to_string()
};
let host_port_tarpc = container
.get_host_port_ipv4(*tarpc_port)
.await
.context("couldn't get the tarpc host-side port for the Omry container")?;
let host_port_web = container
.get_host_port_ipv4(*web_port)
.await
.context("couldn't get the web host-side port for the Omry container")?;
let ports = HostPorts {
rpc: host_port_tarpc,
web: host_port_web,
};
Ok((host, ports))
}
#[instrument(skip(socket))]
pub async fn spawn_client(socket: impl tokio::net::ToSocketAddrs) -> Result<FloraServiceClient> {
let mut transport = tarpc::serde_transport::tcp::connect(socket, Bincode::default);
transport.config_mut().max_frame_length(usize::MAX);
let client = FloraServiceClient::new(client::Config::default(), transport.await?).spawn();
Ok(client)
}
#[derive(Debug, Copy, Clone)]
pub struct OmryAddrs {
pub rpc: SocketAddr,
pub web: SocketAddr,
}
impl OmryAddrs {
#[instrument]
pub async fn new(container: &FloraContainer) -> Result<Self> {
let (host, ports) = match get_host_and_ports(container).await {
Ok((host, ports)) => (host, ports),
Err(error) => {
tracing::warn!(
"couldn't get the host port using the testcontainers APIs; trying workaround\
\noriginal error: {error}"
);
let Some(ports) = container.image().host_ports() else {
anyhow::bail!("couldn't get the host-side port for the flora container");
};
(Ipv4Addr::LOCALHOST.to_string(), ports)
}
};
let HostPorts { rpc, web } = ports;
let rpc = format!("{host}:{rpc}")
.to_socket_addrs()?
.next()
.ok_or_else(|| anyhow::anyhow!("No socket addresses"))
.context("Couldn't create a socket address for RPC.")?;
let web = format!("{host}:{web}")
.to_socket_addrs()?
.next()
.ok_or_else(|| anyhow::anyhow!("No socket addresses"))
.context("Couldn't create a socket address for web.")?;
Ok(Self { rpc, web })
}
}
#[instrument]
pub async fn flora_client(container: &FloraContainer) -> Result<FloraServiceClient> {
let OmryAddrs { rpc, web: _ } = OmryAddrs::new(container).await?;
spawn_client(rpc).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::helpers;
use omry_service::PONG;
use omry_service::tarpc::context;
use pretty_assertions::assert_eq;
use std::time::Duration;
use tokio::time;
use url::Url;
#[test]
fn can_create_flora_config_with_options() -> Result<()> {
let options = FloraStackOptions {
tracing_level: Level::WARN,
search_sync_interval: Duration::from_secs(37),
};
let url: Url = "https://my.test.typesense".try_into()?;
let api_key = "very-secret".into();
let flora_config =
FloraConfig::new(&url, api_key).with_tracing_level(options.tracing_level);
insta::assert_debug_snapshot!(flora_config);
Ok(())
}
#[tokio::test]
async fn can_start_flora_stack() {
assert!(FloraStack::new(None).await.is_ok());
}
#[tokio::test]
async fn can_spawn_flora_client_connected_to_test_server() -> Result<()> {
helpers::init_tracing_subscriber()?;
let flora_stack = FloraStack::new(None).await?;
time::sleep(Duration::from_secs(5)).await;
let flora_container = flora_stack.flora_container();
let flora_client = flora_client(flora_container)
.await
.context("couldn't spawn the test client")?;
let ping_response = flora_client
.ping(context::current())
.await
.context("couldn't send the ping request")?;
assert_eq!(ping_response, PONG);
Ok(())
}
#[tokio::test]
async fn can_configure_flora_stack() -> Result<()> {
helpers::init_tracing_subscriber()?;
let options = FloraStackOptions::default();
let flora_stack = FloraStack::new(Some(options)).await?;
time::sleep(Duration::from_secs(5)).await;
let flora_container = flora_stack.flora_container();
let flora_client = flora_client(flora_container)
.await
.context("couldn't spawn the test client")?;
let ping_response = flora_client
.ping(context::current())
.await
.context("couldn't send the ping request")?;
insta::assert_snapshot!(ping_response, PONG);
Ok(())
}
}