use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::error::{DeployError, Result};
use crate::types::{ConfigFileMount, ServiceConfigView, ServiceDeployEnvView};
#[derive(Debug, Clone, Default)]
pub struct ComposedRuntime {
pub env: BTreeMap<String, String>,
pub config_mounts: Vec<ConfigFileMount>,
pub container_port: Option<u16>,
pub loaded_env_files: Vec<PathBuf>,
pub missing_required: Vec<String>,
}
pub fn compose_service_runtime(
project_root: &Path,
svc: &ServiceConfigView,
env_name: &str,
env_cfg: &ServiceDeployEnvView,
) -> Result<ComposedRuntime> {
compose_service_runtime_with_opts(project_root, svc, env_name, env_cfg, true)
}
pub fn compose_service_runtime_with_opts(
project_root: &Path,
svc: &ServiceConfigView,
env_name: &str,
env_cfg: &ServiceDeployEnvView,
strict_env_files: bool,
) -> Result<ComposedRuntime> {
let service_root = resolve_service_root(project_root, svc);
let mut file_env = BTreeMap::new();
let mut loaded = Vec::new();
let explicit = !env_cfg.env_files.is_empty();
let env_files = resolve_env_file_list(project_root, &service_root, env_name, &env_cfg.env_files);
for path in &env_files {
if !path.is_file() {
continue;
}
match parse_dotenv_file(path) {
Ok(parsed) => {
for (k, v) in parsed {
file_env.insert(k, v);
}
loaded.push(path.clone());
}
Err(e) => {
if explicit || strict_env_files {
return Err(DeployError::Validation(format!(
"compose env file {}: {e}",
path.display()
)));
}
}
}
}
let mut merged = BTreeMap::new();
for (k, v) in &svc.environment {
merged.insert(k.clone(), v.clone());
}
for (k, v) in file_env {
merged.insert(k, v);
}
for (k, v) in &env_cfg.env {
merged.insert(k.clone(), v.clone());
}
let resolved = resolve_placeholders_map(&merged);
let mut final_env = BTreeMap::new();
for (k, v) in resolved {
let value = if v.trim().is_empty() {
std::env::var(&k).unwrap_or(v)
} else {
v
};
if is_unresolved_placeholder(&value) {
continue;
}
if value.trim().is_empty() {
continue;
}
final_env.insert(k, value);
}
let missing_required: Vec<String> = env_cfg
.required_env
.iter()
.filter(|k| {
final_env
.get(k.as_str())
.map(|v| v.trim().is_empty())
.unwrap_or(true)
})
.cloned()
.collect();
let config_mounts = resolve_config_mounts(
project_root,
&service_root,
&env_cfg.config_files,
)?;
let container_port = env_cfg.container_port.or(svc.port);
Ok(ComposedRuntime {
env: final_env,
config_mounts,
container_port,
loaded_env_files: loaded,
missing_required,
})
}
fn resolve_service_root(project_root: &Path, svc: &ServiceConfigView) -> PathBuf {
match svc.root_directory.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(root) => {
let p = PathBuf::from(root);
if p.is_absolute() {
p
} else {
project_root.join(p)
}
}
None => project_root.to_path_buf(),
}
}
fn resolve_env_file_list(
project_root: &Path,
service_root: &Path,
env_name: &str,
configured: &[String],
) -> Vec<PathBuf> {
let mut out = Vec::new();
if !configured.is_empty() {
for raw in configured {
out.extend(candidate_paths(project_root, service_root, raw));
}
return out;
}
let auto = [
".env".to_string(),
format!(".env.{env_name}"),
".env.local".to_string(),
format!(".env.{env_name}.local"),
];
for name in auto {
for path in candidate_paths(project_root, service_root, &name) {
if path.is_file() {
out.push(path);
}
}
}
out
}
fn candidate_paths(project_root: &Path, service_root: &Path, raw: &str) -> Vec<PathBuf> {
let p = PathBuf::from(raw.trim());
if p.is_absolute() {
return vec![p];
}
let mut v = vec![service_root.join(&p), project_root.join(&p)];
v.dedup();
v
}
fn resolve_config_mounts(
project_root: &Path,
service_root: &Path,
configured: &[String],
) -> Result<Vec<ConfigFileMount>> {
let mut names: Vec<String> = if configured.is_empty() {
vec!["config.yaml".into(), "config.yml".into()]
} else {
configured.to_vec()
};
let mut mounts = Vec::new();
let mut seen_mount = std::collections::BTreeSet::new();
for raw in names.drain(..) {
let path = candidate_paths(project_root, service_root, &raw)
.into_iter()
.find(|p| p.is_file());
let Some(source) = path else {
if !configured.is_empty() {
return Err(DeployError::Validation(format!(
"compose config file not found: {raw}"
)));
}
continue;
};
let file_name = source
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("config")
.to_string();
let key = sanitize_configmap_key(&file_name);
let mount_path = if raw.contains('/') || raw.contains('\\') {
format!("/app/{}", file_name)
} else {
format!("/app/{file_name}")
};
if !seen_mount.insert(mount_path.clone()) {
continue;
}
mounts.push(ConfigFileMount {
source,
mount_path,
key,
});
if configured.is_empty() {
break;
}
}
Ok(mounts)
}
fn sanitize_configmap_key(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'-'
}
})
.collect()
}
fn strip_utf8_bom(content: &str) -> &str {
content.strip_prefix('\u{feff}').unwrap_or(content)
}
fn normalize_env_key(raw: &str) -> String {
strip_utf8_bom(raw.trim())
.chars()
.filter(|ch| {
!matches!(
ch,
'\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{2060}' | '\u{feff}'
)
})
.collect::<String>()
.trim()
.to_string()
}
pub fn parse_dotenv_file(path: &Path) -> std::result::Result<BTreeMap<String, String>, String> {
let text = fs::read_to_string(path).map_err(|e| e.to_string())?;
parse_dotenv_str(&text)
}
pub fn parse_dotenv_str(text: &str) -> std::result::Result<BTreeMap<String, String>, String> {
let text = strip_utf8_bom(text);
let mut map = BTreeMap::new();
for raw_line in text.lines() {
let line = strip_utf8_bom(raw_line.trim());
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line).trim();
let line = strip_utf8_bom(line);
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = normalize_env_key(key);
if key.is_empty() {
continue;
}
let value = unquote_env_value(value.trim());
map.insert(key, value);
}
Ok(map)
}
fn unquote_env_value(value: &str) -> String {
let v = strip_utf8_bom(value.trim());
if v.len() >= 2 {
let bytes = v.as_bytes();
if (bytes[0] == b'"' && bytes[v.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[v.len() - 1] == b'\'')
{
return v[1..v.len() - 1].to_string();
}
}
if let Some((left, _)) = v.split_once(" #") {
return left.trim().to_string();
}
v.to_string()
}
fn resolve_placeholders_map(input: &BTreeMap<String, String>) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for (k, v) in input {
out.insert(k.clone(), resolve_placeholder_value(v, input));
}
out
}
fn resolve_placeholder_value(value: &str, map: &BTreeMap<String, String>) -> String {
let mut result = value.to_string();
for _ in 0..8 {
let mut changed = false;
let mut rebuilt = String::new();
let mut rest = result.as_str();
while let Some(start) = rest.find("${") {
rebuilt.push_str(&rest[..start]);
let after = &rest[start + 2..];
if let Some(end) = after.find('}') {
let name = &after[..end];
let replacement = map
.get(name)
.cloned()
.or_else(|| std::env::var(name).ok())
.unwrap_or_else(|| format!("${{{name}}}"));
if !replacement.starts_with("${") {
changed = true;
}
rebuilt.push_str(&replacement);
rest = &after[end + 1..];
} else {
rebuilt.push_str(&rest[start..]);
rest = "";
break;
}
}
rebuilt.push_str(rest);
result = rebuilt;
if !changed {
break;
}
}
result
}
fn is_unresolved_placeholder(value: &str) -> bool {
let t = value.trim();
t.starts_with("${") && t.ends_with('}') && !t[2..t.len().saturating_sub(1)].contains("${")
}
pub fn env_key_looks_secret(key: &str) -> bool {
let k = key.to_ascii_uppercase();
k.contains("SECRET")
|| k.contains("PASSWORD")
|| k.contains("TOKEN")
|| k.contains("PRIVATE")
|| k.contains("API_KEY")
|| k.contains("WEBHOOK")
|| k.ends_with("_KEY")
|| k.contains("URI")
|| k.contains("URL")
&& (k.contains("POSTGRES")
|| k.contains("DATABASE")
|| k.contains("REDIS")
|| k.contains("WEBHOOK")
|| k.contains("DISCORD"))
|| k.contains("POSTGRES")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_dotenv_quotes_and_export() {
let text = r#"
# comment
export FOO=bar
BAZ="hello world"
QUX='x'
EMPTY=
"#;
let m = parse_dotenv_str(text).unwrap();
assert_eq!(m.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(m.get("BAZ").map(String::as_str), Some("hello world"));
assert_eq!(m.get("QUX").map(String::as_str), Some("x"));
}
#[test]
fn parses_dotenv_with_utf8_bom_and_skips_junk_lines() {
let text = "\u{feff}# Cloudflare\nFOO=bar\n-----BEGIN RSA PRIVATE KEY-----\nBAZ=qux\n";
let m = parse_dotenv_str(text).unwrap();
assert_eq!(m.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(m.get("BAZ").map(String::as_str), Some("qux"));
assert!(!m.keys().any(|k| k.contains('\u{feff}')));
}
#[test]
fn resolves_placeholders() {
let mut m = BTreeMap::new();
m.insert("A".into(), "1".into());
m.insert("B".into(), "${A}/two".into());
let r = resolve_placeholders_map(&m);
assert_eq!(r.get("B").map(String::as_str), Some("1/two"));
}
#[test]
fn classifies_webhook_env_keys_as_secrets() {
assert!(env_key_looks_secret("DISCORD_WEBHOOK_URL"));
assert!(env_key_looks_secret("SLACK_WEBHOOK"));
assert!(env_key_looks_secret("MOLLIE_WEBHOOK_SINK_SECRET"));
assert!(!env_key_looks_secret("PORT"));
assert!(!env_key_looks_secret("NODE_ENV"));
assert!(!env_key_looks_secret("FILE_URL_CACHE_TTL_SECONDS"));
}
}