Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 133 additions & 15 deletions src/cli/generator/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> {
// 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());
}
}
}
Expand All @@ -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<String> {
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.
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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#"
Expand Down
Loading