use secrecy::{ExposeSecret, SecretString};
use std::borrow::Cow;
use std::path::Path;
use testcontainers::core::{ContainerPort, ContainerRequest, IntoContainerPort, Mount, WaitFor};
use testcontainers::runners::AsyncRunner;
use testcontainers::{ContainerAsync, Image, ImageExt};
use tracing::Level;
use url::Url;
use crate::containers::{ContainerConfig, ContainerLogConsumer, ImageConfig};
use crate::env_keys;
use crate::helpers::{ContainerMount, HostPorts, TempPathWithString};
const DATA_PATH_CONTAINER: &str = "/usr/local/etc/flora";
const IMAGE_NAME: &str = "localhost:8777/omry-server";
const IMAGE_TAG: &str = "latest";
const CONTAINER_PORT_RPC: u16 = 47473;
const CONTAINER_PORT_WEB: u16 = 47474;
const READY_MESSAGE: &str = "flora is ready!";
#[derive(Debug)]
pub enum EnvVar {
Plain {
key: String,
value: String,
},
Secret {
key: String,
value: SecretString,
},
}
impl EnvVar {
fn as_key_value_pair(&self) -> (&str, &str) {
match self {
Self::Plain { key, value } => (key, value),
Self::Secret { key, value } => (key, value.expose_secret()),
}
}
}
#[allow(clippy::missing_docs_in_private_items)]
#[derive(Debug)]
pub struct FloraConfig {
auth_token: EnvVar,
database_url: EnvVar,
ready_message: EnvVar,
rpc_port: EnvVar,
tracing_level: Option<EnvVar>,
typesense_api_key: EnvVar,
typesense_url: EnvVar,
web_port: EnvVar,
}
impl FloraConfig {
#[must_use]
pub fn new(
auth_token: SecretString,
typesense_url: &Url,
typesense_api_key: SecretString,
) -> Self {
let auth_token = EnvVar::Secret {
key: env_keys::AUTH_TOKEN.to_string(),
value: auth_token,
};
let typesense_api_key = EnvVar::Secret {
key: env_keys::TYPESENSE_API_KEY.to_string(),
value: typesense_api_key,
};
let typesense_url = EnvVar::Plain {
key: env_keys::TYPESENSE_URL.to_string(),
value: typesense_url.to_string(),
};
let ready_message = EnvVar::Plain {
key: env_keys::READY_MESSAGE_KEY.to_string(),
value: READY_MESSAGE.to_string(),
};
let db_path = Path::new(DATA_PATH_CONTAINER)
.join("flora.db")
.display()
.to_string();
let database_url = EnvVar::Plain {
key: env_keys::DATABASE_URL.into(),
value: db_path,
};
let rpc_port = EnvVar::Plain {
key: env_keys::SERVER_PORT_RPC.into(),
value: CONTAINER_PORT_RPC.to_string(),
};
let web_port = EnvVar::Plain {
key: env_keys::SERVER_PORT_WEB.into(),
value: CONTAINER_PORT_WEB.to_string(),
};
Self {
auth_token,
database_url,
ready_message,
rpc_port,
typesense_api_key,
typesense_url,
tracing_level: None,
web_port,
}
}
#[must_use]
pub fn with_tracing_level(mut self, tracing_level: Level) -> Self {
let tracing_level = EnvVar::Plain {
key: env_keys::TRACING_LEVEL.into(),
value: tracing_level.to_string(),
};
self.tracing_level = Some(tracing_level);
self
}
fn into_vec(self) -> Vec<EnvVar> {
let mut vars = vec![
self.auth_token,
self.database_url,
self.ready_message,
self.rpc_port,
self.typesense_url,
self.typesense_api_key,
self.web_port,
];
if let Some(tracing_level) = self.tracing_level {
vars.push(tracing_level);
}
vars
}
}
#[allow(clippy::missing_docs_in_private_items)]
#[derive(Debug)]
pub struct FloraImage {
name: String,
tag: String,
env: Vec<EnvVar>,
mounts: Vec<Mount>,
container_ports: Vec<ContainerPort>,
ready_message: String,
host_ports: Option<HostPorts>,
#[allow(dead_code)]
data_temp_path: Option<TempPathWithString>,
}
impl FloraImage {
pub fn new(flora_config: FloraConfig, image_config: ImageConfig) -> anyhow::Result<Self> {
let container_mount = ContainerMount::new(DATA_PATH_CONTAINER)?;
let container_ports = if image_config.should_expose_ports {
vec![
ContainerPort::Udp(CONTAINER_PORT_RPC),
ContainerPort::Tcp(CONTAINER_PORT_WEB),
]
} else {
vec![]
};
let (_, ready_message) = flora_config.ready_message.as_key_value_pair();
let ready_message = ready_message.into();
Ok(Self {
name: IMAGE_NAME.to_string(),
tag: IMAGE_TAG.to_string(),
env: flora_config.into_vec(),
data_temp_path: container_mount.temp_path,
mounts: vec![container_mount.mount],
container_ports,
ready_message,
host_ports: None,
})
}
fn with_host_ports(mut self, ports: HostPorts) -> ContainerRequest<Self> {
self.host_ports = Some(ports);
self.with_mapped_port(ports.rpc, CONTAINER_PORT_RPC.udp())
.with_mapped_port(ports.web, CONTAINER_PORT_WEB.tcp())
}
fn with_auto_host_ports(self) -> ContainerRequest<Self> {
let (host_ports, _dummy_listeners) = HostPorts::auto();
self.with_host_ports(host_ports)
}
#[must_use]
pub fn host_ports(&self) -> Option<HostPorts> {
self.host_ports
}
}
impl Image for FloraImage {
fn name(&self) -> &str {
&self.name
}
fn tag(&self) -> &str {
&self.tag
}
fn ready_conditions(&self) -> Vec<WaitFor> {
vec![WaitFor::message_on_stdout(&self.ready_message)]
}
fn env_vars(
&self,
) -> impl IntoIterator<Item = (impl Into<Cow<'_, str>>, impl Into<Cow<'_, str>>)> {
self.env.iter().map(EnvVar::as_key_value_pair)
}
fn mounts(&self) -> impl IntoIterator<Item = &Mount> {
&self.mounts
}
fn expose_ports(&self) -> &[ContainerPort] {
&self.container_ports
}
}
pub type FloraContainer = ContainerAsync<FloraImage>;
pub async fn start_flora_container<L>(
flora_config: FloraConfig,
container_config: ContainerConfig<L>,
) -> anyhow::Result<FloraContainer>
where
L: ContainerLogConsumer,
{
let flora_container =
FloraImage::new(flora_config, container_config.image_config)?.with_auto_host_ports();
let flora_container = container_config.configure_container(flora_container);
Ok(flora_container.start().await?)
}