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