omry_integration/
flora_image.rs

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