diff --git a/src/cli/generator/compose.rs b/src/cli/generator/compose.rs index 745a7b33..41780286 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -773,6 +773,83 @@ impl fmt::Display for ComposeDefinition { } } +/// Replace literal environment values with `${VAR}` references for a set of +/// env var names. This is used during **bake** so the snapshot's compose file +/// never contains the author's secrets — Docker Compose resolves `${VAR}` from +/// the env file (`/etc/stacker/env`) at runtime. +/// +/// Only environment blocks inside service definitions are touched; other parts +/// of the compose file (labels, volumes, etc.) are left alone. +/// +/// `env_keys` is the set of env var names whose values should be +/// parameterized. Typically this is every key declared in the author's +/// `config_contract` with `mutability: generated` plus any `provided` fields. +pub fn parameterize_compose_env_vars( + compose_content: &str, + env_keys: &std::collections::HashSet, +) -> String { + if env_keys.is_empty() { + return compose_content.to_string(); + } + + let mut in_environment = false; + let mut env_indent = 0usize; + let mut result = String::with_capacity(compose_content.len()); + + for line in compose_content.lines() { + let trimmed = line.trim_start(); + let indent = line.len() - trimmed.len(); + + // 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. + if trimmed == "environment:" { + in_environment = true; + env_indent = indent; + result.push_str(line); + result.push('\n'); + continue; + } + + if in_environment { + // A blank line or a line at the same / shallower indent ends the block. + if trimmed.is_empty() || indent <= env_indent { + in_environment = false; + } + } + + if in_environment { + // Match " KEY: value" — the key must be a valid env identifier. + if let Some((key, _rest)) = trimmed.split_once(':') { + let key = key.trim(); + if is_env_identifier(key) && env_keys.contains(key) { + // 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(key); + result.push_str("}\n"); + continue; + } + } + } + + result.push_str(line); + result.push('\n'); + } + + result +} + +/// Returns `true` when `s` looks like a POSIX env-variable name. +fn is_env_identifier(s: &str) -> bool { + !s.is_empty() + && s.chars().enumerate().all(|(i, c)| { + c.is_ascii_alphanumeric() || c == '_' || (i == 0 && c == '.') + }) +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Tests // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -1861,4 +1938,65 @@ services: output ); } + + #[test] + fn parameterize_replaces_secret_values_with_env_refs() { + let compose = "\ +services: + app: + image: trydirect/stackpilot:latest + ports: + - \"8080:8000\" + environment: + ADMIN_PASSWORD: 4f4237dd9bfe8e1622706cac7bab63c7 + ADMIN_USER: admin + DATABASE_URL: postgresql://stackpilot:2213a996143863b99a0f2d3e22907690@db:5432/stackpilot + SECRET_KEY: b838f1f22379b8c268a4d3e0268459761c18947e1576956a6d9b1f3928070df4 + OLLAMA_MODEL: llama3.1 + restart: unless-stopped +"; + let mut keys = std::collections::HashSet::new(); + keys.insert("ADMIN_PASSWORD".to_string()); + keys.insert("SECRET_KEY".to_string()); + keys.insert("DATABASE_URL".to_string()); + + let result = parameterize_compose_env_vars(compose, &keys); + + assert!(result.contains("ADMIN_PASSWORD: ${ADMIN_PASSWORD}"), "admin pw:\n{result}"); + assert!(result.contains("SECRET_KEY: ${SECRET_KEY}"), "secret key:\n{result}"); + assert!(result.contains("DATABASE_URL: ${DATABASE_URL}"), "db url:\n{result}"); + // Non-secret values stay as-is. + assert!(result.contains("ADMIN_USER: admin"), "admin user:\n{result}"); + assert!(result.contains("OLLAMA_MODEL: llama3.1"), "model:\n{result}"); + } + + #[test] + fn parameterize_leaves_non_env_blocks_alone() { + let compose = "\ +services: + app: + image: myapp:latest + environment: + SECRET_KEY: abc123 + labels: + my.stacker.service: myapp + volumes: + - app_data:/app/data +"; + let mut keys = std::collections::HashSet::new(); + keys.insert("SECRET_KEY".to_string()); + + let result = parameterize_compose_env_vars(compose, &keys); + + assert!(result.contains("SECRET_KEY: ${SECRET_KEY}"), "secret:\n{result}"); + assert!(result.contains("my.stacker.service: myapp"), "label:\n{result}"); + assert!(result.contains("- app_data:/app/data"), "volume:\n{result}"); + } + + #[test] + fn parameterize_no_keys_returns_original() { + let compose = "services:\n app:\n environment:\n FOO: bar\n"; + let keys = std::collections::HashSet::new(); + assert_eq!(parameterize_compose_env_vars(compose, &keys), compose); + } } diff --git a/src/console/commands/cli/deploy.rs b/src/console/commands/cli/deploy.rs index 2c9ff383..37481d7d 100644 --- a/src/console/commands/cli/deploy.rs +++ b/src/console/commands/cli/deploy.rs @@ -686,6 +686,32 @@ fn normalize_generated_compose_paths(compose_path: &Path) -> Result<(), CliError Ok(()) } +fn compose_env_keys(config: &StackerConfig) -> std::collections::HashSet { + let mut keys: std::collections::HashSet = config.env.keys().cloned().collect(); + + // Include policy-declared fields even when they are defined only in + // app.environment or services[].environment rather than top-level env. + if let Ok(contract) = serde_json::to_value(&config.config_contract) { + if let Some(services) = contract.get("services").and_then(|v| v.as_object()) { + for service in services.values() { + if let Some(fields) = service.get("fields").and_then(|v| v.as_object()) { + for (name, policy) in fields { + let protected = matches!( + policy.get("mutability").and_then(|v| v.as_str()), + Some("generated") | Some("provided") + ); + if protected { + keys.insert(name.clone()); + } + } + } + } + } + } + + keys +} + /// A compose service that declares a `build:` section. struct ComposeBuildService { name: String, @@ -3556,7 +3582,20 @@ fn run_deploy_with_credentials_manager( let compose = ComposeDefinition::try_from(&config)?; // `write_to` refuses to clobber an existing file unless told to, // so a staleness-driven regeneration must opt in explicitly. - compose.write_to(&compose_out, force_rebuild || compose_is_stale)?; + // Parameterize secret env vars: replace literal values with + // `${VAR}` references so the compose file never contains the + // author's secrets. Docker Compose resolves them from the + // co-located `.env` file at runtime. + let rendered = compose.render(); + let env_keys = compose_env_keys(&config); + let parameterized = + crate::cli::generator::compose::parameterize_compose_env_vars( + &rendered, + &env_keys, + ); + if force_rebuild || compose_is_stale || !compose_out.exists() { + std::fs::write(&compose_out, ¶meterized)?; + } // The synthesized caddy/nginx proxy service mounts a config file // (./Caddyfile, ./nginx/conf.d) from the compose directory. For // local/server deploys the tfa proxy role does NOT run, so the @@ -3575,6 +3614,21 @@ fn run_deploy_with_credentials_manager( (compose_out, false) }; + // Parameterize an existing generated compose file as well. This prevents + // a previously rendered file with literal secrets from bypassing the + // protection merely because it was considered up to date. + if !compose_is_user_supplied { + let env_keys = compose_env_keys(&config); + if !env_keys.is_empty() { + let content = std::fs::read_to_string(&compose_path)?; + let parameterized = + crate::cli::generator::compose::parameterize_compose_env_vars(&content, &env_keys); + if parameterized != content { + std::fs::write(&compose_path, parameterized)?; + } + } + } + normalize_generated_compose_paths(&compose_path)?; validate_compose_for_deploy(&compose_path)?; reject_build_sections_for_cloud(