omry_integration/
flora_image.rs

1use std::borrow::Cow;
2use std::path::Path;
3
4use secrecy::{ExposeSecret, SecretBox};
5use testcontainers::core::{ContainerPort, ContainerRequest, Mount, WaitFor};
6use testcontainers::runners::AsyncRunner;
7use testcontainers::{ContainerAsync, Image, ImageExt};
8use tracing::Level;
9use url::Url;
10
11use crate::containers::{ContainerConfig, ContainerLogConsumer, ImageConfig};
12use crate::helpers::{self, TempPathWithString};
13
14/// Database URL.
15const DATABASE_URL_KEY: &str = "FLORA_DATABASE_URL";
16
17/// The environment variable name for
18/// the message the Omry server should print to stdout when it's ready to receive requests.
19const READY_MESSAGE_KEY: &str = "FLORA_READY_MESSAGE";
20
21/// The tracing level for the Omry server.
22const TRACING_LEVEL_KEY: &str = "FLORA_TRACING_LEVEL";
23
24/// The Typesense API key used by the Omry server.
25const TYPESENSE_API_KEY_KEY: &str = "FLORA_TYPESENSE_API_KEY";
26
27/// Typesense URL the Omry server should connect to.
28const TYPESENSE_URL_KEY: &str = "FLORA_TYPESENSE_URL";
29
30/// The path to the data directory used by Omry inside the container.
31const DATA_PATH_CONTAINER: &str = "/usr/local/etc/flora";
32
33/// This image name assumes the image is stored in the local registry.
34const IMAGE_NAME: &str = "localhost:8777/omry-server";
35
36/// Omry server image tag.
37const IMAGE_TAG: &str = "latest";
38
39/// The port the Omry server should bind to inside the container.
40const CONTAINER_PORT: u16 = 37373;
41
42/// The message the Omry server should print to stdout when it's ready to receive requests.
43const READY_MESSAGE: &str = "flora is ready!";
44
45/// Enum to encode plain or secret environment variables
46/// for the flora server image.
47#[derive(Debug)]
48pub enum EnvVar {
49    /// Plain (not secret) environment variable.
50    Plain {
51        /// Key (variable name).
52        key: String,
53
54        /// Value.
55        value: String,
56    },
57
58    /// Secret environment variable, whose value should not be unintentionally exposed.
59    Secret {
60        /// Key (variable name).
61        key: String,
62
63        /// Value (will be stored in [`SecretBox`]).
64        value: SecretBox<str>,
65    },
66}
67
68impl EnvVar {
69    /// Returns this environment variable as a string (key, value) pair.
70    fn as_key_value_pair(&self) -> (&str, &str) {
71        match self {
72            Self::Plain { key, value } => (key, value),
73            Self::Secret { key, value } => (key, value.expose_secret()),
74        }
75    }
76}
77
78/// flora image config.
79#[allow(clippy::missing_docs_in_private_items)]
80#[derive(Debug)]
81pub struct FloraConfig {
82    database_url: EnvVar,
83    ready_message: EnvVar,
84    tracing_level: Option<EnvVar>,
85    typesense_api_key: EnvVar,
86    typesense_url: EnvVar,
87}
88
89impl FloraConfig {
90    /// Initializes a new flora image config with the given parameters.
91    #[must_use]
92    pub fn new(typesense_url: &Url, typesense_api_key: SecretBox<str>) -> Self {
93        let typesense_api_key = EnvVar::Secret {
94            key: TYPESENSE_API_KEY_KEY.to_string(),
95            value: typesense_api_key,
96        };
97        let typesense_url = EnvVar::Plain {
98            key: TYPESENSE_URL_KEY.to_string(),
99            value: typesense_url.to_string(),
100        };
101
102        // Hardcode the ready message until we have a reason to let the caller configure it.
103        let ready_message = EnvVar::Plain {
104            key: READY_MESSAGE_KEY.to_string(),
105            value: READY_MESSAGE.to_string(),
106        };
107
108        let db_path = Path::new(DATA_PATH_CONTAINER)
109            .join("flora.db")
110            .display()
111            .to_string();
112
113        // ditto with database URL.
114        let database_url = EnvVar::Plain {
115            key: DATABASE_URL_KEY.into(),
116            value: db_path,
117        };
118
119        Self {
120            typesense_api_key,
121            typesense_url,
122            ready_message,
123            database_url,
124            tracing_level: None,
125        }
126    }
127
128    /// Sets the tracing level for the flora RPC server.
129    #[must_use]
130    pub fn with_tracing_level(mut self, tracing_level: Level) -> Self {
131        let tracing_level = EnvVar::Plain {
132            key: TRACING_LEVEL_KEY.into(),
133            value: tracing_level.to_string(),
134        };
135        self.tracing_level = Some(tracing_level);
136        self
137    }
138
139    /// Returns the values in this config as a vec of environment variables.
140    fn into_vec(self) -> Vec<EnvVar> {
141        let mut vars = vec![
142            self.database_url,
143            self.ready_message,
144            self.typesense_url,
145            self.typesense_api_key,
146        ];
147
148        if let Some(tracing_level) = self.tracing_level {
149            vars.push(tracing_level);
150        }
151
152        vars
153    }
154}
155
156/// Omry server image for integration tests.
157#[allow(clippy::missing_docs_in_private_items)]
158#[derive(Debug)]
159pub struct FloraImage {
160    name: String,
161    tag: String,
162    env: Vec<EnvVar>,
163    mounts: Vec<Mount>,
164    container_ports: Vec<ContainerPort>,
165    ready_message: String,
166    host_port: Option<u16>,
167    // We need to store this on the struct so the temp directory
168    // sticks around until the struct is dropped.
169    #[allow(dead_code)]
170    data_temp_path: TempPathWithString,
171}
172
173impl FloraImage {
174    /// Creates a new flora image instance with the given flora server config and
175    /// Docker image config.
176    ///
177    /// ## Errors
178    /// Returns [`anyhow::Error`] if we can't create a temporary path for Omry's data.
179    pub fn new(flora_config: FloraConfig, image_config: ImageConfig) -> anyhow::Result<Self> {
180        let data_temp_path = TempPathWithString::new()?;
181        let mount = Mount::bind_mount(data_temp_path.as_str(), DATA_PATH_CONTAINER);
182        let container_ports = if image_config.should_expose_ports {
183            vec![ContainerPort::Tcp(CONTAINER_PORT)]
184        } else {
185            vec![]
186        };
187
188        let (_, ready_message) = flora_config.ready_message.as_key_value_pair();
189        let ready_message = ready_message.into();
190
191        Ok(Self {
192            name: IMAGE_NAME.to_string(),
193            tag: IMAGE_TAG.to_string(),
194            env: flora_config.into_vec(),
195            data_temp_path,
196            mounts: vec![mount],
197            container_ports,
198            ready_message,
199            host_port: None,
200        })
201    }
202
203    /// Configures this container to request any free port from the host OS.
204    fn with_auto_host_port(mut self) -> ContainerRequest<Self> {
205        let (_, host_port) = helpers::free_port_ipv6();
206        self.host_port = Some(host_port);
207        self.with_mapped_port(host_port, CONTAINER_PORT.into())
208    }
209
210    /// Returns the host-side port this container is bound to (if any).
211    #[must_use]
212    pub fn host_port(&self) -> Option<u16> {
213        self.host_port
214    }
215}
216
217impl Image for FloraImage {
218    fn name(&self) -> &str {
219        &self.name
220    }
221
222    fn tag(&self) -> &str {
223        &self.tag
224    }
225
226    fn ready_conditions(&self) -> Vec<WaitFor> {
227        vec![WaitFor::message_on_stdout(&self.ready_message)]
228    }
229
230    fn env_vars(
231        &self,
232    ) -> impl IntoIterator<Item = (impl Into<Cow<'_, str>>, impl Into<Cow<'_, str>>)> {
233        self.env.iter().map(EnvVar::as_key_value_pair)
234    }
235
236    fn mounts(&self) -> impl IntoIterator<Item = &Mount> {
237        &self.mounts
238    }
239
240    fn expose_ports(&self) -> &[ContainerPort] {
241        &self.container_ports
242    }
243}
244
245/// Type alias to reduce the type signature.
246pub type FloraContainer = ContainerAsync<FloraImage>;
247
248/// Starts an Omry RPC server container for integration tests.
249///
250/// ## Errors
251/// Returns [`anyhow::Error`] if we can't start the Omry server.
252pub async fn start_flora_container<L>(
253    flora_config: FloraConfig,
254    container_config: ContainerConfig<L>,
255) -> anyhow::Result<FloraContainer>
256where
257    L: ContainerLogConsumer,
258{
259    let flora_container =
260        FloraImage::new(flora_config, container_config.image_config)?.with_auto_host_port();
261    let flora_container = container_config.configure_container(flora_container);
262    Ok(flora_container.start().await?)
263}