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
67 changes: 43 additions & 24 deletions src/engine/executor.nu
Original file line number Diff line number Diff line change
Expand Up @@ -97,41 +97,60 @@ def apply-write-env [action: record, plan: record, ctx: record] {
}

def render-env [content: string, ctx: record, ...excluded: string] {
# Use reduce so each resolved value is available to subsequent lines.
# e.g. SECRETS_DIR=/run/secrets followed by FILE={{SECRETS_DIR}}/pass resolves correctly.
let acc = (
$content | lines | reduce --fold {
lines: []
env_vars: ($ctx | get --optional env_vars | default {})
} { |line, acc|
let trimmed = ($line | str trim)
let lines = ($content | lines)
let base_vars = ($ctx | get --optional env_vars | default {})

# Phase 1 — multi-pass resolution to handle forward references.
# Each pass feeds newly resolved values back into env_vars so that a variable
# defined later in the file can satisfy a token that appeared earlier.
# Mirrors resolve-generators in plan.nu — runs until stable or 10 passes.
mut env_vars = $base_vars
mut passes = 0

loop {
if $passes >= 10 { break }
let prev = $env_vars

$env_vars = ($lines | reduce --fold $env_vars {|line, acc|
let trimmed = ($line | str trim)
if ($trimmed | is-empty) or ($trimmed | str starts-with "#") {
{
lines: ($acc.lines | append $line), env_vars: $acc.env_vars
}
$acc
} else {
let parts = ($line | split row "=" | collect)
let key = ($parts | first | str trim)
let val = ($parts | skip 1 | str join "=")

if ($key | is-empty) or ($key in $excluded) {
{
lines: ($acc.lines | append $line), env_vars: $acc.env_vars
}
$acc
} else {
let run_ctx = ($ctx | upsert env_vars $acc.env_vars)
let resolved = try { resolve $val $run_ctx } catch { $val }

{
lines: ($acc.lines | append $"($key)=($resolved)")
env_vars: ($acc.env_vars | upsert $key $resolved)
}
let resolved = try { resolve $val ($ctx | upsert env_vars $acc) } catch { $val }
$acc | upsert $key $resolved
}
}
})

if $env_vars == $prev { break }
$passes += 1
}

# Phase 2 — render output lines in original order using the fully resolved env_vars.
# Assign to immutable binding — Nushell disallows capturing mut vars in closures.
let resolved_vars = $env_vars
$lines | each {|line|
let trimmed = ($line | str trim)
if ($trimmed | is-empty) or ($trimmed | str starts-with "#") {
$line
} else {
let parts = ($line | split row "=" | collect)
let key = ($parts | first | str trim)
let val = ($parts | skip 1 | str join "=")
if ($key | is-empty) or ($key in $excluded) {
$line
} else {
let resolved = try { resolve $val ($ctx | upsert env_vars $resolved_vars) } catch { $val }
$"($key)=($resolved)"
}
}
)
$acc.lines | str join "\n"
} | str join "\n"
}

def apply-write-secret [action: record, ctx: record] {
Expand Down
87 changes: 87 additions & 0 deletions tests/integration/generate_test.nu
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,91 @@ def test_generate_all_dependency_order [] {
}
}

def test_envfile_forward_reference [] {
let old_pwd = $env.PWD
let envctl_path = ($env.PWD | path join envctl.nu)
let tmp = (mktemp --directory)

try {
cd $tmp

(
"[envfile]\n"
+ "file = \".env\"\n"
+ "pattern = \".env.example\"\n\n"
+ "[secrets]\n"
+ "base_dir = \".\"\n\n"
+ "[providers]\n"
+ "enabled = []\n"
) | save ".envctl.toml"

# GOOSE_HOST references PROXYSQL_HOST which is defined *later* in the file
(
"GOOSE_HOST={{ PROXYSQL_HOST }}\n"
+ "GOOSE_PORT={{ PROXYSQL_PORT }}\n"
+ "\n"
+ "PROXYSQL_HOST=proxysql\n"
+ "PROXYSQL_PORT=6033\n"
) | save ".env.example"

run-envctl $envctl_path envctl envfile generate

assert (".env" | path exists)
let content = (open --raw ".env")
assert ($content =~ "GOOSE_HOST=proxysql")
assert ($content =~ "GOOSE_PORT=6033")

cd $old_pwd
rm --recursive $tmp
} catch {|err|
cd $old_pwd
rm --recursive $tmp
error make {msg: $err.msg}
}
}

def test_envfile_chain_resolution [] {
let old_pwd = $env.PWD
let envctl_path = ($env.PWD | path join envctl.nu)
let tmp = (mktemp --directory)

try {
cd $tmp

(
"[envfile]\n"
+ "file = \".env\"\n"
+ "pattern = \".env.example\"\n\n"
+ "[secrets]\n"
+ "base_dir = \".\"\n\n"
+ "[providers]\n"
+ "enabled = []\n"
) | save ".envctl.toml"

# A → B → C, all defined out of order
(
"A={{ B }}\n"
+ "B={{ C }}\n"
+ "C=value\n"
) | save ".env.example"

run-envctl $envctl_path envctl envfile generate

assert (".env" | path exists)
let content = (open --raw ".env")
assert ($content =~ "A=value")
assert ($content =~ "B=value")
assert ($content =~ "C=value")

cd $old_pwd
rm --recursive $tmp
} catch {|err|
cd $old_pwd
rm --recursive $tmp
error make {msg: $err.msg}
}
}

def main [] {
print generate_test.nu
mut passed = 0
Expand All @@ -80,6 +165,8 @@ def main [] {
let tests = [
[name fn];
[test_generate_all_dependency_order { test_generate_all_dependency_order }]
[test_envfile_forward_reference { test_envfile_forward_reference }]
[test_envfile_chain_resolution { test_envfile_chain_resolution }]
]

for row in $tests {
Expand Down
Loading