use std::{
borrow::Cow,
path::{Path, PathBuf},
str::FromStr,
};
use etcetera::BaseStrategy;
use crate::core::env::GetEnvValue;
#[derive(Debug, thiserror::Error)]
pub enum ConfigurationError {
#[error("invalid DOCKER_HOST: {0}")]
InvalidDockerHost(String),
#[error("unknown command '{0}' provided via TESTCONTAINERS_COMMAND env variable")]
UnknownCommand(String),
#[cfg(feature = "properties-config")]
#[error("failed to load testcontainers properties: {0}")]
WrongPropertiesFormat(#[from] serde_java_properties::de::Error),
#[cfg(feature = "reusable-containers")]
#[error("container name or labels must be provided when reusing containers")]
MissingContainerNameAndLabels,
}
const DEFAULT_DOCKER_CONFIG_PATH: &str = ".docker";
const DOCKER_CONFIG_FILE: &str = "config.json";
#[cfg(feature = "properties-config")]
const TESTCONTAINERS_PROPERTIES: &str = ".testcontainers.properties";
#[cfg(unix)]
pub const DEFAULT_DOCKER_HOST: &str = "unix:///var/run/docker.sock";
#[cfg(windows)]
pub const DEFAULT_DOCKER_HOST: &str = "npipe:////./pipe/docker_engine";
#[derive(Debug, Default)]
pub(crate) struct Config {
tc_host: Option<String>,
host: Option<String>,
tls_verify: Option<bool>,
cert_path: Option<PathBuf>,
command: Option<Command>,
docker_auth_config: Option<String>,
platform: Option<String>,
}
#[cfg(feature = "properties-config")]
#[serde_with::serde_as]
#[derive(Debug, Default, serde::Deserialize)]
struct TestcontainersProperties {
#[serde(rename = "tc.host")]
tc_host: Option<String>,
#[serde(rename = "docker.host")]
host: Option<String>,
#[serde_as(as = "Option<serde_with::BoolFromInt>")]
#[serde(rename = "docker.tls.verify")]
tls_verify: Option<bool>,
#[serde(rename = "docker.cert.path")]
cert_path: Option<PathBuf>,
}
#[cfg(feature = "properties-config")]
impl TestcontainersProperties {
async fn load() -> Option<Result<Self, ConfigurationError>> {
let home_dir = home_dir()?;
let properties_path = home_dir.join(TESTCONTAINERS_PROPERTIES);
let content = tokio::fs::read(properties_path).await.ok()?;
let properties =
serde_java_properties::from_slice(&content).map_err(ConfigurationError::from);
Some(properties)
}
}
impl Config {
pub(crate) async fn load<E>() -> Result<Self, ConfigurationError>
where
E: GetEnvValue,
{
#[cfg(feature = "properties-config")]
{
let env_config = Self::load_from_env_config::<E>().await?;
let properties = TestcontainersProperties::load()
.await
.transpose()?
.unwrap_or_default();
Ok(Self {
tc_host: env_config.tc_host.or(properties.tc_host),
host: env_config.host.or(properties.host),
tls_verify: env_config.tls_verify.or(properties.tls_verify),
cert_path: env_config.cert_path.or(properties.cert_path),
command: env_config.command,
docker_auth_config: env_config.docker_auth_config,
platform: env_config.platform,
})
}
#[cfg(not(feature = "properties-config"))]
Self::load_from_env_config::<E>().await
}
async fn load_from_env_config<E>() -> Result<Self, ConfigurationError>
where
E: GetEnvValue,
{
let host = E::get_env_value("DOCKER_HOST");
let tls_verify = E::get_env_value("DOCKER_TLS_VERIFY").map(|v| v == "1");
let cert_path = E::get_env_value("DOCKER_CERT_PATH").map(PathBuf::from);
let command = E::get_env_value("TESTCONTAINERS_COMMAND")
.filter(|v| !v.trim().is_empty())
.map(|v| v.parse())
.transpose()?;
let platform = E::get_env_value("DOCKER_DEFAULT_PLATFORM").filter(|v| !v.trim().is_empty());
let docker_auth_config = read_docker_auth_config::<E>().await;
Ok(Config {
host,
tc_host: None,
command,
tls_verify,
cert_path,
docker_auth_config,
platform,
})
}
pub(crate) fn docker_host(&self) -> Cow<'_, str> {
self.tc_host
.as_deref()
.or(self.host.as_deref())
.map(Cow::Borrowed)
.unwrap_or_else(|| {
if cfg!(unix) {
validate_path("/var/run/docker.sock".into())
.or_else(|| {
runtime_dir().and_then(|dir| {
validate_path(format!("{}/.docker/run/docker.sock", dir.display()))
})
})
.or_else(|| {
home_dir().and_then(|dir| {
validate_path(format!("{}/.docker/run/docker.sock", dir.display()))
})
})
.or_else(|| {
home_dir().and_then(|dir| {
validate_path(format!(
"{}/.docker/desktop/docker.sock",
dir.display()
))
})
})
.map(|p| format!("unix://{p}"))
.map(Cow::Owned)
.unwrap_or(DEFAULT_DOCKER_HOST.into())
} else {
DEFAULT_DOCKER_HOST.into()
}
})
}
pub(crate) fn tls_verify(&self) -> bool {
self.tls_verify.unwrap_or_default()
}
pub(crate) fn cert_path(&self) -> Option<&Path> {
self.cert_path.as_deref()
}
pub(crate) fn command(&self) -> Command {
self.command.unwrap_or_default()
}
pub(crate) fn docker_auth_config(&self) -> Option<&str> {
self.docker_auth_config.as_deref()
}
pub(crate) fn platform(&self) -> Option<&str> {
self.platform.as_deref()
}
}
fn validate_path(path: String) -> Option<String> {
if Path::new(&path).exists() {
Some(path)
} else {
None
}
}
fn home_dir() -> Option<PathBuf> {
etcetera::home_dir().ok()
}
fn runtime_dir() -> Option<PathBuf> {
etcetera::choose_base_strategy().ok()?.runtime_dir()
}
async fn read_docker_auth_config<E>() -> Option<String>
where
E: GetEnvValue,
{
match E::get_env_value("DOCKER_AUTH_CONFIG") {
Some(cfg) => Some(cfg),
None => {
let mut path_to_config = match E::get_env_value("DOCKER_CONFIG").map(PathBuf::from) {
Some(path_to_config) => path_to_config,
None => {
let home_dir = home_dir()?;
home_dir.join(DEFAULT_DOCKER_CONFIG_PATH)
}
};
path_to_config.push(DOCKER_CONFIG_FILE);
tokio::fs::read_to_string(path_to_config).await.ok()
}
}
}
#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
pub(crate) enum Command {
Keep,
#[default]
Remove,
}
impl FromStr for Command {
type Err = ConfigurationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"keep" => Ok(Command::Keep),
"remove" => Ok(Command::Remove),
other => Err(ConfigurationError::UnknownCommand(other.to_string())),
}
}
}
#[cfg(feature = "properties-config")]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_java_properties() {
let tc_host = "http://tc-host".into();
let docker_host = "http://docker-host".into();
let tls_verify = 1;
let cert_path = "/path/to/cert";
let file_content = format!(
r"
tc.host={tc_host}
docker.host={docker_host}
docker.tls.verify={tls_verify}
docker.cert.path={cert_path}
"
);
let properties: TestcontainersProperties =
serde_java_properties::from_slice(file_content.as_bytes())
.expect("Failed to parse properties");
assert_eq!(properties.tc_host, Some(tc_host));
assert_eq!(properties.host, Some(docker_host));
assert_eq!(properties.tls_verify, Some(tls_verify == 1));
assert_eq!(properties.cert_path, Some(PathBuf::from(cert_path)));
}
}