Skip to content
Merged
Show file tree
Hide file tree
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
138 changes: 138 additions & 0 deletions src/cli/generator/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
) -> 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
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Expand Down Expand Up @@ -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);
}
}
56 changes: 55 additions & 1 deletion src/console/commands/cli/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,32 @@ fn normalize_generated_compose_paths(compose_path: &Path) -> Result<(), CliError
Ok(())
}

fn compose_env_keys(config: &StackerConfig) -> std::collections::HashSet<String> {
let mut keys: std::collections::HashSet<String> = 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,
Expand Down Expand Up @@ -3556,7 +3582,20 @@ fn run_deploy_with_credentials_manager<S: CredentialStore>(
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, &parameterized)?;
}
// 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
Expand All @@ -3575,6 +3614,21 @@ fn run_deploy_with_credentials_manager<S: CredentialStore>(
(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(
Expand Down
Loading