omry_integration/
flora_image.rs1use 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
14const DATA_PATH_CONTAINER: &str = "/usr/local/etc/flora";
16
17const IMAGE_NAME: &str = "localhost:8777/omry-server";
19
20const IMAGE_TAG: &str = "latest";
22
23const CONTAINER_PORT_RPC: u16 = 37373;
25
26const CONTAINER_PORT_WEB: u16 = 37374;
28
29const READY_MESSAGE: &str = "flora is ready!";
31
32#[derive(Debug)]
35pub enum EnvVar {
36 Plain {
38 key: String,
40
41 value: String,
43 },
44
45 Secret {
47 key: String,
49
50 value: SecretBox<str>,
52 },
53}
54
55impl EnvVar {
56 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#[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 #[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 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 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 #[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 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#[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 #[allow(dead_code)]
158 data_temp_path: TempPathWithString,
159}
160
161impl FloraImage {
162 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 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 #[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 fn expose_ports(&self) -> &[ContainerPort] {
235 &self.container_ports
236 }
237}
238
239pub type FloraContainer = ContainerAsync<FloraImage>;
241
242pub 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}