From 810202964a9affd7d436105beb81a186bdc7ef37 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Tue, 20 Oct 2026 11:50:59 +0300 Subject: [PATCH] Compose env references: each service keeps its own env_reference_aliases collected KEY -> VAR from the whole stacker.yml into one map and parameterize applied it to every service, so when two services declare one env name with different references, the last one won everywhere. Deployment #1363 (local-ai-packaged): Supabase's db got POSTGRES_PASSWORD: ${LANGFUSE_POSTGRES_PASSWORD}, initialised its roles with the Langfuse password, and auth/rest/storage restarted forever. Aliases are now per service ("service/KEY", from app.environment and services[].environment), plus by KEY alone only where every declaration of KEY references the same variable (the old behaviour for unambiguous names; the top-level env: counts there). parameterize looks up the compose service's own reference first. Tests first: one_env_name_keeps_each_services_own_reference failed, then passed; the existing alias tests pass unchanged. cargo test --lib: 2279. Co-Authored-By: Claude Opus 5.5 --- src/cli/generator/compose.rs | 148 +++++++++++++++++++++++++++++++---- 1 file changed, 133 insertions(+), 15 deletions(-) diff --git a/src/cli/generator/compose.rs b/src/cli/generator/compose.rs index 0a5fe3cb..6421461f 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -913,18 +913,61 @@ pub fn parameterize_compose_env_vars( /// `${VAR}` counts, since a value that merely embeds a variable (a DSN, a URL) /// cannot be replaced by a single reference. pub fn env_reference_aliases(raw_stacker_yml: &str) -> std::collections::HashMap { + // Per service ("service/KEY"), and by KEY alone only where every service + // that declares KEY references the same variable: one name may reference + // different variables in two services (deployment #1363, Supabase's db and + // Langfuse's postgres both set POSTGRES_PASSWORD). + let reference = regex::Regex::new(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$").expect("valid regex"); + let doc: serde_yaml::Value = serde_yaml::from_str(raw_stacker_yml).unwrap_or_default(); + + let mut declared: Vec<(String, String, String)> = Vec::new(); // (service, key, referenced) + let mut collect = |service: &str, env: Option<&serde_yaml::Value>| { + let Some(serde_yaml::Value::Mapping(map)) = env else { + return; + }; + for (key, value) in map { + let (Some(key), Some(value)) = (key.as_str(), value.as_str()) else { + continue; + }; + let referenced = reference + .captures(value.trim()) + .map(|caps| caps[1].to_string()) + .unwrap_or_default(); + declared.push((service.to_string(), key.to_string(), referenced)); + } + }; + collect("app", doc.get("app").and_then(|app| app.get("environment"))); + // The top-level `env:` is file-wide: no compose service is named "*", so it + // only counts towards the by-KEY aliases, as every reference did before. + collect("*", doc.get("env")); + if let Some(serde_yaml::Value::Sequence(services)) = doc.get("services") { + for svc in services { + if let Some(name) = svc.get("name").and_then(serde_yaml::Value::as_str) { + collect(name, svc.get("environment")); + } + } + } + let mut aliases = std::collections::HashMap::new(); - let re = regex::Regex::new( - r#"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*"?\$\{([A-Za-z_][A-Za-z0-9_]*)\}"?\s*$"#, - ) - .expect("valid regex"); - - for line in raw_stacker_yml.lines() { - if let Some(caps) = re.captures(line) { - let key = caps[1].to_string(); - let var = caps[2].to_string(); - if key != var { - aliases.insert(key, var); + let mut by_key: std::collections::HashMap<&str, std::collections::BTreeSet<&str>> = + std::collections::HashMap::new(); + for (service, key, referenced) in &declared { + // A literal value or an embedded reference counts as "itself" here. + let target = if referenced.is_empty() { + key.as_str() + } else { + referenced.as_str() + }; + by_key.entry(key.as_str()).or_default().insert(target); + if !referenced.is_empty() && referenced != key { + aliases.insert(format!("{service}/{key}"), referenced.clone()); + } + } + for (key, targets) in by_key { + if targets.len() == 1 { + let target = targets.into_iter().next().unwrap_or(key); + if target != key { + aliases.insert(key.to_string(), target.to_string()); } } } @@ -946,11 +989,27 @@ pub fn parameterize_compose_env_vars_with_aliases( let mut in_environment = false; let mut env_indent = 0usize; let mut result = String::with_capacity(compose_content.len()); + // The compose service the current line belongs to (` name:` under + // `services:`), so a service's own reference wins over the file-wide one. + let mut in_services = false; + let mut service = String::new(); + let lookup = |service: &str, key: &str| -> Option { + aliases + .get(&format!("{service}/{key}")) + .or_else(|| aliases.get(key)) + .cloned() + }; for line in compose_content.lines() { let trimmed = line.trim_start(); let indent = line.len() - trimmed.len(); + if indent == 0 && !trimmed.is_empty() { + in_services = trimmed == "services:"; + } else if in_services && indent == 2 && trimmed.ends_with(':') { + service = trimmed.trim_end_matches(':').to_string(); + } + // Track whether we're inside an `environment:` block belonging to a // service. The block ends when we hit a line at the same or shallower // indent that isn't blank. @@ -979,12 +1038,12 @@ pub fn parameterize_compose_env_vars_with_aliases( if let Some((key, _value)) = entry.split_once('=') { let key = key.trim(); if is_env_identifier(key) && env_keys.contains(key) { - let reference = aliases.get(key).map(String::as_str).unwrap_or(key); + let reference = lookup(&service, key).unwrap_or_else(|| key.to_string()); result.push_str(&line[..indent]); result.push_str("- "); result.push_str(key); result.push_str("=${"); - result.push_str(reference); + result.push_str(&reference); result.push_str("}\n"); continue; } @@ -993,13 +1052,13 @@ pub fn parameterize_compose_env_vars_with_aliases( // Match " KEY: value" — the key must be a valid env identifier. let key = key.trim(); if is_env_identifier(key) && env_keys.contains(key) { - let reference = aliases.get(key).map(String::as_str).unwrap_or(key); + let reference = lookup(&service, key).unwrap_or_else(|| key.to_string()); // Preserve the original indent and replace the value. let prefix = &line[..indent + key.len()]; // Find where the value starts (after "KEY: "). result.push_str(prefix); result.push_str(": ${"); - result.push_str(reference); + result.push_str(&reference); result.push_str("}\n"); continue; } @@ -2620,6 +2679,65 @@ services: ); } + /// Deployment #1363 (local-ai-packaged, 2026-10-15): Supabase's `db` and + /// Langfuse's `postgres` both declare POSTGRES_PASSWORD, referencing + /// different variables. One map for the whole file gave Supabase's db the + /// Langfuse password, and auth/rest/storage restarted forever. + #[test] + fn one_env_name_keeps_each_services_own_reference() { + let raw = r#" +services: + - name: db + environment: + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + - name: postgres + environment: + POSTGRES_PASSWORD: "${LANGFUSE_POSTGRES_PASSWORD}" + - name: auth + environment: + DB_PASS: ${POSTGRES_PASSWORD} +"#; + let compose = "\ +services: + db: + environment: + POSTGRES_PASSWORD: placeholder-value # pragma: allowlist secret + postgres: + environment: + POSTGRES_PASSWORD: placeholder-value # pragma: allowlist secret + auth: + environment: + - DB_PASS=placeholder-value # pragma: allowlist secret +"; + let mut keys = std::collections::HashSet::new(); + keys.insert("POSTGRES_PASSWORD".to_string()); + keys.insert("DB_PASS".to_string()); + + let result = + parameterize_compose_env_vars_with_aliases(compose, &keys, &env_reference_aliases(raw)); + + let db = result.split(" postgres:").next().unwrap(); + let postgres = result + .split(" postgres:") + .nth(1) + .unwrap() + .split(" auth:") + .next() + .unwrap(); + assert!( + db.contains("POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}"), + "{result}" + ); + assert!( + postgres.contains("POSTGRES_PASSWORD: ${LANGFUSE_POSTGRES_PASSWORD}"), + "{result}" + ); + assert!( + result.contains("- DB_PASS=${POSTGRES_PASSWORD}"), + "{result}" + ); + } + #[test] fn aliases_are_read_from_the_unresolved_stacker_yml() { let raw = r#"