Skip to content

feat(config): substitute ${VAR} env vars in declarative configs#64

Open
shreemaan-abhishek wants to merge 1 commit into
masterfrom
feat/env-var-substitution
Open

feat(config): substitute ${VAR} env vars in declarative configs#64
shreemaan-abhishek wants to merge 1 commit into
masterfrom
feat/env-var-substitution

Conversation

@shreemaan-abhishek
Copy link
Copy Markdown
Contributor

@shreemaan-abhishek shreemaan-abhishek commented May 28, 2026

Summary

  • After parsing a config file, recursively walk every string field (incl. nested plugin configs, label values, SSL certs/keys) and replace \${VAR} with the corresponding environment variable.
  • \\\${VAR} escapes the substitution and yields a literal \${VAR}.
  • Unset env vars leave the literal in place (does not collapse to empty string — adc collapses, we keep literal so unset vars are loud rather than silent).
  • Applied in ReadConfigFile so diff, sync, and validate all benefit without per-command plumbing.
  • Map keys are intentionally not substituted (matches adc behavior).

Part of the adc → a7 parity gap tracked in docs/adc-test-parity-plan.md.

Test plan

  • 10 unit tests in pkg/cmd/config/configutil/env_test.go covering: service name, escape, plugin-config value, SSL cert/key, nested global rule, unset var, route nested fields, plugin metadata nested value, numbers/bools untouched, string labels
  • TestReadConfigFile_AppliesSubstitution end-to-end
  • TestConfigSync_SubstitutesEnvVarsInRequestBody integration (RegisterResponder captures resolved values in the PUT body)
  • go test ./pkg/... ./internal/... passes
  • go vet ./...; gofmt -l . clean

Open question

Unset-var policy diverges from adc (we keep \${VAR} literal; adc returns empty string). Argument for ours: silent empty strings have caused production incidents elsewhere (auth headers becoming empty, etc). Argument for theirs: bug-for-bug compat. Happy to flip.

Summary by CodeRabbit

Release Notes

  • New Features

    • Config files now support ${VAR} environment variable substitution across all supported fields. Escaped syntax \${VAR} preserves literal values. Unset variables remain unchanged.
  • Tests

    • Added comprehensive test coverage validating environment variable substitution in configuration loading and sync operations.

Review Change Stack

After parsing a config file, recursively walk every string field
(including nested plugin configs and label values) and replace
\${VAR} with the corresponding environment variable. \\\${VAR}
escapes the substitution and yields a literal \${VAR}. Unset env
vars leave the literal in place rather than collapsing to empty.

Applied in ReadConfigFile so diff, sync, and validate all benefit
without per-command plumbing. Map keys are intentionally not
substituted (matches adc behavior).
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

📝 Walkthrough

Walkthrough

This PR adds ${VAR} environment-variable substitution to config file parsing. The feature implements regex-based string transformation with reflection-based traversal of parsed config structures, integrates substitution into ReadConfigFile, and includes comprehensive unit and integration test coverage.

Changes

Environment Variable Substitution in Config Files

Layer / File(s) Summary
Substitution algorithm and traversal
pkg/cmd/config/configutil/env.go
substituteEnvString applies left-to-right ${VAR} replacement via os.LookupEnv, handles escaped \${VAR} as literals, and preserves unset variables as-is. applyEnvSubstitution and walkValue traverse *api.ConfigFile via reflection, mutating addressable strings and rebuilding map/interface values. walkAny handles dynamic types like map[string]interface{} and []interface{}.
Config file integration
pkg/cmd/config/configutil/configutil.go
ReadConfigFile now calls applyEnvSubstitution after parsing JSON/YAML and returns an error if substitution fails.
Unit tests for substitution
pkg/cmd/config/configutil/env_test.go
Tests verify substitution across service names, nested plugin maps, SSL cert/key/SNI fields, routes with plugin headers, plugin metadata, and route labels; also cover escaped variables becoming literal ${...}, unset variables remaining as placeholders, type preservation (only strings substituted), and ReadConfigFile integration with YAML disk loading.
End-to-end sync test
pkg/cmd/config/sync/sync_test.go
Integration test validates the sync command applies substitution by capturing name and uri from PUT request payloads and asserting they resolve to configured environment variables.

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning Only 1 E2E test (sync command); diff and validate commands lack E2E tests. Null map value bug from review unfixed and untested. Add E2E tests for diff and validate commands. Fix null map handling in walkValue using reflect.Zero() and add regression test.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately and concisely describes the main change: implementing environment-variable substitution for ${VAR} patterns in declarative config files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Check ✅ Passed No critical security vulnerabilities found. No new logging of secrets, no database exposure, no auth bypass, no unintended sensitive data leakage vectors introduced by env var substitution feature.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/env-var-substitution

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/cmd/config/configutil/env_test.go`:
- Around line 107-119: Add a regression test that ensures map[string]interface{}
entries explicitly set to nil/null are preserved by applyEnvSubstitution: create
a new test (e.g., TestApplyEnvSubstitution_PreserveNullMapValues) that builds a
cfg with a service or arbitrary map field containing a key mapped to nil
(interface{}(nil)), run applyEnvSubstitution(cfg), and assert the key still
exists with a nil value (not removed or converted) — reference
applyEnvSubstitution and use the same test style as
TestApplyEnvSubstitution_UnsetVarKeepsLiteral to locate where to add it.

In `@pkg/cmd/config/configutil/env.go`:
- Around line 109-118: The map-walking code currently does v.SetMapIndex(k,
reflect.ValueOf(newVal)) which passes an invalid reflect.Value when newVal ==
nil and causes the key to be deleted instead of storing a typed nil; update the
reflect.Map branch in walkValue (the MapRange loop that calls walkAny) to detect
when newVal == nil and in that case use reflect.Zero(v.Type().Elem()) to create
a typed nil/zero for the map element type, otherwise continue using
reflect.ValueOf(newVal); this preserves explicit nulls while keeping existing
behavior for non-nil replacements.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 04c6a937-5dba-4547-b014-1a1c4da48159

📥 Commits

Reviewing files that changed from the base of the PR and between d99f5fa and f86a8ef.

📒 Files selected for processing (4)
  • pkg/cmd/config/configutil/configutil.go
  • pkg/cmd/config/configutil/env.go
  • pkg/cmd/config/configutil/env_test.go
  • pkg/cmd/config/sync/sync_test.go

Comment on lines +107 to +119
func TestApplyEnvSubstitution_UnsetVarKeepsLiteral(t *testing.T) {
// Make sure the variable is genuinely unset for this test.
require.NoError(t, os.Unsetenv("DEFINITELY_NOT_SET_DAILY_VAR"))

cfg := &api.ConfigFile{
Services: []api.Service{
{Name: "before-${DEFINITELY_NOT_SET_DAILY_VAR}-after"},
},
}
require.NoError(t, applyEnvSubstitution(cfg))

assert.Equal(t, "before-${DEFINITELY_NOT_SET_DAILY_VAR}-after", cfg.Services[0].Name)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a regression test for explicit null map values.

The suite doesn’t currently assert that map[string]interface{} entries set to null remain present after substitution. A focused test here would catch key deletion regressions in the map traversal path.

Also applies to: 172-202

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cmd/config/configutil/env_test.go` around lines 107 - 119, Add a
regression test that ensures map[string]interface{} entries explicitly set to
nil/null are preserved by applyEnvSubstitution: create a new test (e.g.,
TestApplyEnvSubstitution_PreserveNullMapValues) that builds a cfg with a service
or arbitrary map field containing a key mapped to nil (interface{}(nil)), run
applyEnvSubstitution(cfg), and assert the key still exists with a nil value (not
removed or converted) — reference applyEnvSubstitution and use the same test
style as TestApplyEnvSubstitution_UnsetVarKeepsLiteral to locate where to add
it.

Comment on lines +109 to +118
case reflect.Map:
iter := v.MapRange()
for iter.Next() {
k := iter.Key()
val := iter.Value()
// Map values are not addressable; build a replacement and
// reassign it under the same key.
newVal := walkAny(val.Interface())
v.SetMapIndex(k, reflect.ValueOf(newVal))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Go's reflect package, what is the behavior difference in Value.SetMapIndex when the element is (a) reflect.ValueOf(nil) and (b) reflect.Zero(mapElemType)?

💡 Result:

In Go's reflect package, the behavior of Value.SetMapIndex depends on whether the element (the second argument) is the zero Value or a valid Value representing a zero-valued instance of the map's element type [1][2]. (a) reflect.ValueOf(nil) When you call reflect.ValueOf(nil), it returns the zero Value [3]. A zero Value is defined as an invalid Value (its IsValid method returns false) [3]. When this zero Value is passed as the element to SetMapIndex, the function interprets it as a command to delete the entry associated with the specified key from the map [1][2]. This is the standard mechanism in reflection to remove map keys [4]. (b) reflect.Zero(mapElemType) When you call reflect.Zero(mapElemType), it returns a valid Value (IsValid returns true) that represents the zero value for the specified type (e.g., 0 for an int, nil for a pointer, or an empty struct) [3]. When this is passed to SetMapIndex, the function does not delete the key. Instead, it explicitly sets the entry for the specified key to that zero value [1][2]. Summary of Difference The critical difference is that the zero Value (returned by reflect.ValueOf(nil) or reflect.Value{}) triggers a deletion operation, whereas reflect.Zero(mapElemType) triggers an assignment operation, effectively setting the map key to its type's zero value [1][2]. Note that if the map's element type is a reference type (like a pointer, map, or slice) or an interface, reflect.Zero will store a nil value for that type, which remains a stored entry in the map rather than removing the key [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Show relevant parts of env.go around the reported lines.
file="pkg/cmd/config/configutil/env.go"
nl -ba "$file" | sed -n '70,200p'

Repository: api7/a7

Length of output: 94


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Also show the full walkAny implementation area if separated further down.
file="pkg/cmd/config/configutil/env.go"
nl -ba "$file" | sed -n '200,340p'

Repository: api7/a7

Length of output: 94


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Check for any comments describing null behavior.
file="pkg/cmd/config/configutil/env.go"
rg -n "null|nil" "$file"

Repository: api7/a7

Length of output: 213


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="pkg/cmd/config/configutil/env.go"
nl -ba "$file" | sed -n '70,200p'

Repository: api7/a7

Length of output: 94


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="pkg/cmd/config/configutil/env.go"
nl -ba "$file" | sed -n '200,340p'

Repository: api7/a7

Length of output: 94


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="pkg/cmd/config/configutil/env.go"
rg -n "null|nil" "$file"

Repository: api7/a7

Length of output: 213


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="pkg/cmd/config/configutil/env.go"
sed -n '90,150p' "$file"

Repository: api7/a7

Length of output: 1611


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="pkg/cmd/config/configutil/env.go"
sed -n '140,210p' "$file"

Repository: api7/a7

Length of output: 894


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="pkg/cmd/config/configutil/env.go"
sed -n '1,110p' "$file" | rg -n "nulls are left untouched|Map keys are NOT substituted"

Repository: api7/a7

Length of output: 130


Preserve explicit null map values instead of deleting map entries

  • walkValue’s reflect.Map branch assigns with v.SetMapIndex(k, reflect.ValueOf(newVal)); when newVal == nil, reflect.ValueOf(nil) is an invalid reflect.Value, and SetMapIndex deletes the key rather than storing a typed “null”.
  • This conflicts with the file’s “nulls are left untouched” intent, and walkAny returns nil for case nil (and the map walker passes that through directly).
Proposed fix
 case reflect.Map:
 	iter := v.MapRange()
 	for iter.Next() {
 		k := iter.Key()
 		val := iter.Value()
 		// Map values are not addressable; build a replacement and
 		// reassign it under the same key.
 		newVal := walkAny(val.Interface())
-		v.SetMapIndex(k, reflect.ValueOf(newVal))
+		if newVal == nil {
+			// Preserve explicit null instead of deleting the key.
+			v.SetMapIndex(k, reflect.Zero(v.Type().Elem()))
+			continue
+		}
+		v.SetMapIndex(k, reflect.ValueOf(newVal))
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case reflect.Map:
iter := v.MapRange()
for iter.Next() {
k := iter.Key()
val := iter.Value()
// Map values are not addressable; build a replacement and
// reassign it under the same key.
newVal := walkAny(val.Interface())
v.SetMapIndex(k, reflect.ValueOf(newVal))
}
case reflect.Map:
iter := v.MapRange()
for iter.Next() {
k := iter.Key()
val := iter.Value()
// Map values are not addressable; build a replacement and
// reassign it under the same key.
newVal := walkAny(val.Interface())
if newVal == nil {
// Preserve explicit null instead of deleting the key.
v.SetMapIndex(k, reflect.Zero(v.Type().Elem()))
continue
}
v.SetMapIndex(k, reflect.ValueOf(newVal))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cmd/config/configutil/env.go` around lines 109 - 118, The map-walking
code currently does v.SetMapIndex(k, reflect.ValueOf(newVal)) which passes an
invalid reflect.Value when newVal == nil and causes the key to be deleted
instead of storing a typed nil; update the reflect.Map branch in walkValue (the
MapRange loop that calls walkAny) to detect when newVal == nil and in that case
use reflect.Zero(v.Type().Elem()) to create a typed nil/zero for the map element
type, otherwise continue using reflect.ValueOf(newVal); this preserves explicit
nulls while keeping existing behavior for non-nil replacements.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant