omry_integration/
flora_image.rs1use 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
14const DATABASE_URL_KEY: &str = "FLORA_DATABASE_URL";
16
17const READY_MESSAGE_KEY: &str = "FLORA_READY_MESSAGE";
20
21const TRACING_LEVEL_KEY: &str = "FLORA_TRACING_LEVEL";
23
24const TYPESENSE_API_KEY_KEY: &str = "FLORA_TYPESENSE_API_KEY";
26
27const TYPESENSE_URL_KEY: &str = "FLORA_TYPESENSE_URL";
29
30const DATA_PATH_CONTAINER: &str = "/usr/local/etc/flora";
32
33const IMAGE_NAME: &str = "localhost:8777/omry-server";
35
36const IMAGE_TAG: &str = "latest";
38
39const CONTAINER_PORT: u16 = 37373;
41
42const READY_MESSAGE: &str = "flora is ready!";
44
45#[derive(Debug)]
48pub enum EnvVar {
49 Plain {
51 key: String,
53
54 value: String,
56 },
57
58 Secret {
60 key: String,
62
63 value: SecretBox<str>,
65 },
66}
67
68impl EnvVar {
69 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#[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 #[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 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 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 #[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 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#[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 #[allow(dead_code)]
170 data_temp_path: TempPathWithString,
171}
172
173impl FloraImage {
174 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 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 #[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
245pub type FloraContainer = ContainerAsync<FloraImage>;
247
248pub 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}