Skip to main content

xbp_deploy/
compose.rs

1//! Compose runtime env + config files for deploy (from .env, service env, deploy.envs).
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::error::{DeployError, Result};
8use crate::types::{ConfigFileMount, ServiceConfigView, ServiceDeployEnvView};
9
10/// Result of composing env + config for one service in one deploy env.
11#[derive(Debug, Clone, Default)]
12pub struct ComposedRuntime {
13    pub env: BTreeMap<String, String>,
14    pub config_mounts: Vec<ConfigFileMount>,
15    pub container_port: Option<u16>,
16    pub loaded_env_files: Vec<PathBuf>,
17    pub missing_required: Vec<String>,
18}
19
20/// Compose env maps and config mounts for a service deploy environment.
21///
22/// Merge order (later wins):
23/// 1. auto or explicit `env_files` (dotenv)
24/// 2. `services[].environment`
25/// 3. `deploy.envs.<env>.env`
26/// 4. process environment (only for keys already present or required)
27///
28/// When `strict_env_files` is false (Cloudflare / non-k8s destinations), auto-discovered
29/// dotenv parse failures are skipped instead of failing the whole deploy. Explicit
30/// `deploy.envs.*.env_files` always hard-fail so misconfiguration stays visible.
31pub fn compose_service_runtime(
32    project_root: &Path,
33    svc: &ServiceConfigView,
34    env_name: &str,
35    env_cfg: &ServiceDeployEnvView,
36) -> Result<ComposedRuntime> {
37    compose_service_runtime_with_opts(project_root, svc, env_name, env_cfg, true)
38}
39
40/// Same as [`compose_service_runtime`], with control over auto dotenv strictness.
41pub fn compose_service_runtime_with_opts(
42    project_root: &Path,
43    svc: &ServiceConfigView,
44    env_name: &str,
45    env_cfg: &ServiceDeployEnvView,
46    strict_env_files: bool,
47) -> Result<ComposedRuntime> {
48    let service_root = resolve_service_root(project_root, svc);
49    let mut file_env = BTreeMap::new();
50    let mut loaded = Vec::new();
51
52    let explicit = !env_cfg.env_files.is_empty();
53    let env_files = resolve_env_file_list(project_root, &service_root, env_name, &env_cfg.env_files);
54    for path in &env_files {
55        if !path.is_file() {
56            continue;
57        }
58        match parse_dotenv_file(path) {
59            Ok(parsed) => {
60                for (k, v) in parsed {
61                    file_env.insert(k, v);
62                }
63                loaded.push(path.clone());
64            }
65            Err(e) => {
66                // Explicit env_files or k8s-bound compose: hard fail.
67                // Auto-discovered .env* on Worker-only deploys: skip (BOM / local junk).
68                if explicit || strict_env_files {
69                    return Err(DeployError::Validation(format!(
70                        "compose env file {}: {e}",
71                        path.display()
72                    )));
73                }
74                // Soft skip: leave file out of loaded list.
75            }
76        }
77    }
78
79    // Templates first (services.environment often uses `${VAR}`), then dotenv
80    // fills concrete values, then deploy.envs.env overrides.
81    let mut merged = BTreeMap::new();
82    for (k, v) in &svc.environment {
83        merged.insert(k.clone(), v.clone());
84    }
85    for (k, v) in file_env {
86        merged.insert(k, v);
87    }
88    for (k, v) in &env_cfg.env {
89        merged.insert(k.clone(), v.clone());
90    }
91
92    // Resolve ${VAR} using merged map + process env.
93    let resolved = resolve_placeholders_map(&merged);
94
95    // Process env can fill remaining empty placeholders / required keys.
96    let mut final_env = BTreeMap::new();
97    for (k, v) in resolved {
98        let value = if v.trim().is_empty() {
99            std::env::var(&k).unwrap_or(v)
100        } else {
101            v
102        };
103        // Drop pure unresolved ${FOO} that stayed empty after process lookup
104        if is_unresolved_placeholder(&value) {
105            continue;
106        }
107        if value.trim().is_empty() {
108            continue;
109        }
110        final_env.insert(k, value);
111    }
112
113    let missing_required: Vec<String> = env_cfg
114        .required_env
115        .iter()
116        .filter(|k| {
117            final_env
118                .get(k.as_str())
119                .map(|v| v.trim().is_empty())
120                .unwrap_or(true)
121        })
122        .cloned()
123        .collect();
124
125    let config_mounts = resolve_config_mounts(
126        project_root,
127        &service_root,
128        &env_cfg.config_files,
129    )?;
130
131    let container_port = env_cfg.container_port.or(svc.port);
132
133    Ok(ComposedRuntime {
134        env: final_env,
135        config_mounts,
136        container_port,
137        loaded_env_files: loaded,
138        missing_required,
139    })
140}
141
142fn resolve_service_root(project_root: &Path, svc: &ServiceConfigView) -> PathBuf {
143    match svc.root_directory.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
144        Some(root) => {
145            let p = PathBuf::from(root);
146            if p.is_absolute() {
147                p
148            } else {
149                project_root.join(p)
150            }
151        }
152        None => project_root.to_path_buf(),
153    }
154}
155
156fn resolve_env_file_list(
157    project_root: &Path,
158    service_root: &Path,
159    env_name: &str,
160    configured: &[String],
161) -> Vec<PathBuf> {
162    let mut out = Vec::new();
163    if !configured.is_empty() {
164        for raw in configured {
165            out.extend(candidate_paths(project_root, service_root, raw));
166        }
167        return out;
168    }
169    // Auto-discover common dotenv files (first existing wins per name, both roots tried).
170    let auto = [
171        ".env".to_string(),
172        format!(".env.{env_name}"),
173        ".env.local".to_string(),
174        format!(".env.{env_name}.local"),
175    ];
176    for name in auto {
177        for path in candidate_paths(project_root, service_root, &name) {
178            if path.is_file() {
179                out.push(path);
180            }
181        }
182    }
183    out
184}
185
186fn candidate_paths(project_root: &Path, service_root: &Path, raw: &str) -> Vec<PathBuf> {
187    let p = PathBuf::from(raw.trim());
188    if p.is_absolute() {
189        return vec![p];
190    }
191    let mut v = vec![service_root.join(&p), project_root.join(&p)];
192    v.dedup();
193    v
194}
195
196fn resolve_config_mounts(
197    project_root: &Path,
198    service_root: &Path,
199    configured: &[String],
200) -> Result<Vec<ConfigFileMount>> {
201    let mut names: Vec<String> = if configured.is_empty() {
202        vec!["config.yaml".into(), "config.yml".into()]
203    } else {
204        configured.to_vec()
205    };
206
207    let mut mounts = Vec::new();
208    let mut seen_mount = std::collections::BTreeSet::new();
209
210    for raw in names.drain(..) {
211        let path = candidate_paths(project_root, service_root, &raw)
212            .into_iter()
213            .find(|p| p.is_file());
214        let Some(source) = path else {
215            if !configured.is_empty() {
216                return Err(DeployError::Validation(format!(
217                    "compose config file not found: {raw}"
218                )));
219            }
220            continue;
221        };
222        let file_name = source
223            .file_name()
224            .and_then(|s| s.to_str())
225            .unwrap_or("config")
226            .to_string();
227        let key = sanitize_configmap_key(&file_name);
228        let mount_path = if raw.contains('/') || raw.contains('\\') {
229            // Relative path → mount under /app/
230            format!("/app/{}", file_name)
231        } else {
232            format!("/app/{file_name}")
233        };
234        if !seen_mount.insert(mount_path.clone()) {
235            continue;
236        }
237        mounts.push(ConfigFileMount {
238            source,
239            mount_path,
240            key,
241        });
242        // Auto mode: only first existing config.yaml/yml
243        if configured.is_empty() {
244            break;
245        }
246    }
247    Ok(mounts)
248}
249
250fn sanitize_configmap_key(name: &str) -> String {
251    name.chars()
252        .map(|c| {
253            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
254                c
255            } else {
256                '-'
257            }
258        })
259        .collect()
260}
261
262/// Strip a leading UTF-8 BOM (U+FEFF). Windows editors often prepend this to `.env*`.
263fn strip_utf8_bom(content: &str) -> &str {
264    content.strip_prefix('\u{feff}').unwrap_or(content)
265}
266
267/// Normalize an env key: trim + drop BOM / zero-width junk.
268fn normalize_env_key(raw: &str) -> String {
269    strip_utf8_bom(raw.trim())
270        .chars()
271        .filter(|ch| {
272            !matches!(
273                ch,
274                '\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{2060}' | '\u{feff}'
275            )
276        })
277        .collect::<String>()
278        .trim()
279        .to_string()
280}
281
282/// Minimal dotenv parser (KEY=VALUE, # comments, optional export, quoted values).
283///
284/// Tolerates a leading UTF-8 BOM (common on Windows). Lines that are not
285/// `KEY=VALUE` after trim are skipped (not hard errors) so multi-line PEM bodies
286/// and editor junk do not abort CF Worker deploys that only auto-discover `.env*`.
287pub fn parse_dotenv_file(path: &Path) -> std::result::Result<BTreeMap<String, String>, String> {
288    let text = fs::read_to_string(path).map_err(|e| e.to_string())?;
289    parse_dotenv_str(&text)
290}
291
292pub fn parse_dotenv_str(text: &str) -> std::result::Result<BTreeMap<String, String>, String> {
293    let text = strip_utf8_bom(text);
294    let mut map = BTreeMap::new();
295    for raw_line in text.lines() {
296        let line = strip_utf8_bom(raw_line.trim());
297        if line.is_empty() || line.starts_with('#') {
298            continue;
299        }
300        let line = line.strip_prefix("export ").unwrap_or(line).trim();
301        let line = strip_utf8_bom(line);
302        let Some((key, value)) = line.split_once('=') else {
303            // Soft-skip: continuation lines / PEM tails / non-dotenv junk.
304            continue;
305        };
306        let key = normalize_env_key(key);
307        if key.is_empty() {
308            continue;
309        }
310        let value = unquote_env_value(value.trim());
311        map.insert(key, value);
312    }
313    Ok(map)
314}
315
316fn unquote_env_value(value: &str) -> String {
317    let v = strip_utf8_bom(value.trim());
318    if v.len() >= 2 {
319        let bytes = v.as_bytes();
320        if (bytes[0] == b'"' && bytes[v.len() - 1] == b'"')
321            || (bytes[0] == b'\'' && bytes[v.len() - 1] == b'\'')
322        {
323            return v[1..v.len() - 1].to_string();
324        }
325    }
326    // Strip inline comments for unquoted values: FOO=bar # comment
327    if let Some((left, _)) = v.split_once(" #") {
328        return left.trim().to_string();
329    }
330    v.to_string()
331}
332
333fn resolve_placeholders_map(input: &BTreeMap<String, String>) -> BTreeMap<String, String> {
334    let mut out = BTreeMap::new();
335    for (k, v) in input {
336        out.insert(k.clone(), resolve_placeholder_value(v, input));
337    }
338    out
339}
340
341fn resolve_placeholder_value(value: &str, map: &BTreeMap<String, String>) -> String {
342    let mut result = value.to_string();
343    // Simple multi-pass ${VAR} substitution (max 8).
344    for _ in 0..8 {
345        let mut changed = false;
346        let mut rebuilt = String::new();
347        let mut rest = result.as_str();
348        while let Some(start) = rest.find("${") {
349            rebuilt.push_str(&rest[..start]);
350            let after = &rest[start + 2..];
351            if let Some(end) = after.find('}') {
352                let name = &after[..end];
353                let replacement = map
354                    .get(name)
355                    .cloned()
356                    .or_else(|| std::env::var(name).ok())
357                    .unwrap_or_else(|| format!("${{{name}}}"));
358                if !replacement.starts_with("${") {
359                    changed = true;
360                }
361                rebuilt.push_str(&replacement);
362                rest = &after[end + 1..];
363            } else {
364                rebuilt.push_str(&rest[start..]);
365                rest = "";
366                break;
367            }
368        }
369        rebuilt.push_str(rest);
370        result = rebuilt;
371        if !changed {
372            break;
373        }
374    }
375    result
376}
377
378fn is_unresolved_placeholder(value: &str) -> bool {
379    let t = value.trim();
380    t.starts_with("${") && t.ends_with('}') && !t[2..t.len().saturating_sub(1)].contains("${")
381}
382
383/// True if a key name looks secret (for plan redaction).
384pub fn env_key_looks_secret(key: &str) -> bool {
385    let k = key.to_ascii_uppercase();
386    k.contains("SECRET")
387        || k.contains("PASSWORD")
388        || k.contains("TOKEN")
389        || k.contains("PRIVATE")
390        || k.contains("API_KEY")
391        || k.contains("WEBHOOK")
392        || k.ends_with("_KEY")
393        || k.contains("URI")
394        || k.contains("URL")
395            && (k.contains("POSTGRES")
396                || k.contains("DATABASE")
397                || k.contains("REDIS")
398                || k.contains("WEBHOOK")
399                || k.contains("DISCORD"))
400        || k.contains("POSTGRES")
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn parses_dotenv_quotes_and_export() {
409        let text = r#"
410# comment
411export FOO=bar
412BAZ="hello world"
413QUX='x'
414EMPTY=
415"#;
416        let m = parse_dotenv_str(text).unwrap();
417        assert_eq!(m.get("FOO").map(String::as_str), Some("bar"));
418        assert_eq!(m.get("BAZ").map(String::as_str), Some("hello world"));
419        assert_eq!(m.get("QUX").map(String::as_str), Some("x"));
420    }
421
422    #[test]
423    fn parses_dotenv_with_utf8_bom_and_skips_junk_lines() {
424        // Windows Notepad-style BOM made line 1 look like "\u{feff}# Cloudflare"
425        // which failed "expected KEY=VALUE" and blocked CF-only deploys.
426        let text = "\u{feff}# Cloudflare\nFOO=bar\n-----BEGIN RSA PRIVATE KEY-----\nBAZ=qux\n";
427        let m = parse_dotenv_str(text).unwrap();
428        assert_eq!(m.get("FOO").map(String::as_str), Some("bar"));
429        assert_eq!(m.get("BAZ").map(String::as_str), Some("qux"));
430        assert!(!m.keys().any(|k| k.contains('\u{feff}')));
431    }
432
433    #[test]
434    fn resolves_placeholders() {
435        let mut m = BTreeMap::new();
436        m.insert("A".into(), "1".into());
437        m.insert("B".into(), "${A}/two".into());
438        let r = resolve_placeholders_map(&m);
439        assert_eq!(r.get("B").map(String::as_str), Some("1/two"));
440    }
441
442    #[test]
443    fn classifies_webhook_env_keys_as_secrets() {
444        assert!(env_key_looks_secret("DISCORD_WEBHOOK_URL"));
445        assert!(env_key_looks_secret("SLACK_WEBHOOK"));
446        assert!(env_key_looks_secret("MOLLIE_WEBHOOK_SINK_SECRET"));
447        assert!(!env_key_looks_secret("PORT"));
448        assert!(!env_key_looks_secret("NODE_ENV"));
449        assert!(!env_key_looks_secret("FILE_URL_CACHE_TTL_SECONDS"));
450    }
451}