From 24f4942d94c626942e279eed7f6670063ef0ba45 Mon Sep 17 00:00:00 2001 From: Artyom Tetyukhin <51746822+arttet@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:52:08 +0400 Subject: [PATCH] fix(executor): resolve forward references in envfile templates render-env now runs a multi-pass resolution phase before rendering, so variables defined later in the file are available to tokens that appear earlier. Mirrors the stabilisation loop used by resolve-generators in plan.nu. --- src/engine/executor.nu | 67 ++++++++++++++--------- tests/integration/generate_test.nu | 87 ++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 24 deletions(-) diff --git a/src/engine/executor.nu b/src/engine/executor.nu index 1055150..8a87a38 100644 --- a/src/engine/executor.nu +++ b/src/engine/executor.nu @@ -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] { diff --git a/tests/integration/generate_test.nu b/tests/integration/generate_test.nu index 152f0b5..bdc58dc 100644 --- a/tests/integration/generate_test.nu +++ b/tests/integration/generate_test.nu @@ -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 @@ -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 {