Skip to content

Commit ebbcc83

Browse files
committed
fix: Escape interpolation in string inputs when type is verified
1 parent cde6f1d commit ebbcc83

17 files changed

Lines changed: 489 additions & 173 deletions

File tree

docs/src/content/docs/04-reference/01-hcl/03-attributes.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ sidebar:
77
---
88

99
import FileTree from '@components/vendored/starlight/FileTree.astro';
10+
import Since from '@components/Since.astro';
1011

1112
Terragrunt HCL configuration uses [attributes](https://github.com/hashicorp/hcl/blob/main/hclsyntax/spec.md#attribute-definitions) when there are values that need to be defined for Terragrunt as a whole.
1213

@@ -24,6 +25,15 @@ when crossing the boundary between Terragrunt and OpenTofu/Terraform. You must s
2425
constraint](https://opentofu.org/docs/language/values/variables/#type-constraints) on the variable in OpenTofu/Terraform in
2526
order for OpenTofu/Terraform to process the inputs as the right type.
2627

28+
<Since version="v1.1.4">
29+
30+
The type constraint also decides how the value is read. A variable declared as `string`, or declared with no type at
31+
all, receives the value verbatim. Every other type constraint makes OpenTofu/Terraform read the value as an HCL
32+
expression, where `${...}` means interpolation. Terragrunt escapes those sequences for you when the module reads a value
33+
as HCL, so text like `${just_a_string}` reaches the module as written.
34+
35+
</Since>
36+
2737
Example:
2838

2939
```hcl
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
version: "v1.1.4"
3+
category: "bug-fixes"
4+
---
5+
6+
#### String inputs reach modules with `${...}` intact
7+
8+
Passing a string input that contains `${...}` to a variable declared with a type other than `string` used to fail with `Variables not allowed`, because OpenTofu/Terraform parse those values as HCL expressions and read `${...}` as an interpolation. Reading a JSON or YAML file into an input hit this whenever the file happened to contain that sequence:
9+
10+
```hcl
11+
inputs = {
12+
config = file("./config.json")
13+
}
14+
```
15+
16+
Terragrunt now escapes interpolation sequences in string inputs when the module declares the variable with a type that makes the value parse as HCL, so `${...}` arrives as literal text instead of failing the run. Variables declared as `string`, and variables declared with no type at all, are read verbatim by OpenTofu/Terraform, and their values are still passed through untouched.

internal/cli/commands/hcl/validate/validate.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -410,13 +410,11 @@ func runValidateInputs(
410410
opts *options.TerragruntOptions,
411411
cfg *config.TerragruntConfig,
412412
) error {
413-
required, optional, err := tf.ModuleVariables(opts.WorkingDir)
413+
declared, err := tf.ModuleVariables(v.FS, opts.WorkingDir)
414414
if err != nil {
415415
return err
416416
}
417417

418-
allVars := slices.Concat(required, optional)
419-
420418
allInputs, err := getDefinedTerragruntInputs(l, v, opts, cfg)
421419
if err != nil {
422420
return err
@@ -426,16 +424,16 @@ func runValidateInputs(
426424
unusedVars := []string{}
427425

428426
for _, varName := range allInputs {
429-
if !slices.Contains(allVars, varName) {
427+
if _, ok := declared[varName]; !ok {
430428
unusedVars = append(unusedVars, varName)
431429
}
432430
}
433431

434432
// Missing variables are those that are required by the terraform config, but not defined in terragrunt.
435433
missingVars := []string{}
436434

437-
for _, varName := range required {
438-
if !slices.Contains(allInputs, varName) {
435+
for _, varName := range slices.Sorted(maps.Keys(declared)) {
436+
if !declared[varName].HasDefault && !slices.Contains(allInputs, varName) {
439437
missingVars = append(missingVars, varName)
440438
}
441439
}

internal/prepare/prepare.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ func PrepareInputsAsEnvVars(
217217
return err
218218
}
219219

220-
return run.SetTerragruntInputsAsEnvVars(l, v.Env, cfg)
220+
return run.SetTerragruntInputsAsEnvVars(l, v.FS, v.Env, runOpts.CacheDir, cfg)
221221
}
222222

223223
// PrepareInit runs terraform init if needed. This is the final preparation stage.
@@ -237,7 +237,7 @@ func PrepareInit(
237237
return err
238238
}
239239

240-
if err := run.SetTerragruntInputsAsEnvVars(l, v.Env, cfg); err != nil {
240+
if err := run.SetTerragruntInputsAsEnvVars(l, v.FS, v.Env, runOpts.CacheDir, cfg); err != nil {
241241
return err
242242
}
243243

internal/runner/run/debug.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ package run
33
import (
44
"encoding/json"
55
"fmt"
6+
"maps"
67
"os"
78
"path/filepath"
89
"slices"
910
"strings"
1011

1112
"github.com/gruntwork-io/terragrunt/internal/runner/runcfg"
1213
"github.com/gruntwork-io/terragrunt/internal/tf"
14+
"github.com/gruntwork-io/terragrunt/internal/vfs"
1315
"github.com/gruntwork-io/terragrunt/pkg/log"
1416
)
1517

@@ -21,6 +23,7 @@ const defaultPermissions = int(0600)
2123
// that terragrunt invokes the module, so that users can debug issues with the terragrunt config.
2224
func WriteTerragruntDebugFile(
2325
l log.Logger,
26+
fsys vfs.FS,
2427
env map[string]string,
2528
opts *Options,
2629
cfg *runcfg.RunConfig,
@@ -31,12 +34,12 @@ func WriteTerragruntDebugFile(
3134
opts.CacheDir,
3235
)
3336

34-
required, optional, err := tf.ModuleVariables(opts.CacheDir)
37+
declared, err := tf.ModuleVariables(fsys, opts.CacheDir)
3538
if err != nil {
3639
return err
3740
}
3841

39-
variables := slices.Concat(required, optional)
42+
variables := slices.Sorted(maps.Keys(declared))
4043

4144
tofuImpl := "tofu"
4245
if opts.TofuImplementation != "" {

internal/runner/run/run.go

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ func Run(
192192
// We do the debug file generation here, after all the terragrunt generated terraform files are created so that we
193193
// can ensure the tfvars json file only includes the vars that are defined in the module.
194194
if updatedOpts.Debug {
195-
if err := WriteTerragruntDebugFile(l, v.Env, updatedOpts, cfg); err != nil {
195+
if err := WriteTerragruntDebugFile(l, v.FS, v.Env, updatedOpts, cfg); err != nil {
196196
return err
197197
}
198198
}
@@ -309,7 +309,7 @@ func runTerragruntWithConfig(
309309
maps.Copy(v.Env, extraEnvVars)
310310
}
311311

312-
if err := SetTerragruntInputsAsEnvVars(l, v.Env, cfg); err != nil {
312+
if err := SetTerragruntInputsAsEnvVars(l, v.FS, v.Env, opts.CacheDir, cfg); err != nil {
313313
return err
314314
}
315315

@@ -495,14 +495,20 @@ func RunActionWithHooks(
495495
// Requires a non-nil env: it is the destination the entries are written into.
496496
func SetTerragruntInputsAsEnvVars(
497497
l log.Logger,
498+
fsys vfs.FS,
498499
env map[string]string,
500+
modulePath string,
499501
cfg *runcfg.RunConfig,
500502
) error {
501503
if env == nil {
502504
panic(venv.ErrVenvEnvUnset)
503505
}
504506

505-
asEnvVars, err := ToTerraformEnvVars(l, cfg.Inputs)
507+
asEnvVars, err := ToTerraformEnvVars(
508+
l,
509+
cfg.Inputs,
510+
declaredVariables(l, fsys, modulePath, cfg.Inputs),
511+
)
506512
if err != nil {
507513
return err
508514
}
@@ -708,7 +714,15 @@ func FilterTerraformExtraArgs(l log.Logger, fsys vfs.FS, opts *Options, cfg *run
708714
// ToTerraformEnvVars converts the given variables to a map of environment variables that will expose those variables to Terraform. The
709715
// keys will be of the format TF_VAR_xxx and the values will be converted to JSON, which Terraform knows how to read
710716
// natively.
711-
func ToTerraformEnvVars(l log.Logger, vars map[string]any) (map[string]string, error) {
717+
//
718+
// A string value only has its interpolation sequences escaped when the module declares the matching
719+
// variable with a type constraint that makes OpenTofu/Terraform parse the value as HCL. Escaping a
720+
// value that is read literally would deliver $${...} to the module instead of ${...}.
721+
func ToTerraformEnvVars(
722+
l log.Logger,
723+
vars map[string]any,
724+
declared map[string]tf.ModuleVariable,
725+
) (map[string]string, error) {
712726
out := map[string]string{}
713727

714728
for varName, varValue := range vars {
@@ -718,6 +732,10 @@ func ToTerraformEnvVars(l log.Logger, vars map[string]any) (map[string]string, e
718732

719733
envVarName := fmt.Sprintf(tf.EnvNameTFVarFmt, varName)
720734

735+
if str, ok := varValue.(string); ok && declared[varName].ParsingMode == tf.VariableParseHCL {
736+
varValue = util.EscapeInterpolationInString(str)
737+
}
738+
721739
envVarValue, err := util.AsTerraformEnvVarJSONValue(varValue)
722740
if err != nil {
723741
return nil, err
@@ -729,6 +747,41 @@ func ToTerraformEnvVars(l log.Logger, vars map[string]any) (map[string]string, e
729747
return out, nil
730748
}
731749

750+
// declaredVariables reads how the module at modulePath declared its variables, so that only the
751+
// values OpenTofu/Terraform parse as HCL get escaped.
752+
//
753+
// Reading the module is skipped unless an input actually carries an interpolation sequence, and a
754+
// module that cannot be read yields no declarations at all: values are then passed through
755+
// untouched, and OpenTofu/Terraform report the malformed module themselves.
756+
func declaredVariables(
757+
l log.Logger,
758+
fsys vfs.FS,
759+
modulePath string,
760+
inputs map[string]any,
761+
) map[string]tf.ModuleVariable {
762+
needsDeclarations := false
763+
764+
for _, value := range inputs {
765+
if str, ok := value.(string); ok && strings.Contains(str, "${") {
766+
needsDeclarations = true
767+
break
768+
}
769+
}
770+
771+
if !needsDeclarations {
772+
return nil
773+
}
774+
775+
declared, err := tf.ModuleVariables(fsys, modulePath)
776+
if err != nil {
777+
l.Debugf("Failed to read variable declarations in %s: %v", modulePath, err)
778+
779+
return nil
780+
}
781+
782+
return declared
783+
}
784+
732785
// filterTerraformEnvVarsFromExtraArgsRunCfg extracts terraform env vars from extra args using runcfg types.
733786
func filterTerraformEnvVarsFromExtraArgsRunCfg(
734787
opts *Options,

internal/runner/run/run_test.go

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/gruntwork-io/terragrunt/internal/iacargs"
1111
"github.com/gruntwork-io/terragrunt/internal/runner/run"
1212
"github.com/gruntwork-io/terragrunt/internal/runner/runcfg"
13+
"github.com/gruntwork-io/terragrunt/internal/tf"
1314
"github.com/gruntwork-io/terragrunt/internal/util"
1415
"github.com/gruntwork-io/terragrunt/internal/vfs"
1516
"github.com/gruntwork-io/terragrunt/pkg/log"
@@ -26,6 +27,7 @@ func TestSetTerragruntInputsAsEnvVars(t *testing.T) {
2627
testCases := []struct {
2728
envVarsInOpts map[string]string
2829
inputsInConfig map[string]any
30+
moduleFiles map[string]string
2931
expected map[string]string
3032
description string
3133
}{
@@ -96,6 +98,30 @@ func TestSetTerragruntInputsAsEnvVars(t *testing.T) {
9698
"TF_VAR_map": `{"a":"b"}`,
9799
},
98100
},
101+
{
102+
description: "input with an interpolation pattern for a string variable",
103+
inputsInConfig: map[string]any{"foo": `{"a": "${b}"}`},
104+
moduleFiles: map[string]string{"main.tf": `variable "foo" { type = string }`},
105+
expected: map[string]string{"TF_VAR_foo": `{"a": "${b}"}`},
106+
},
107+
{
108+
description: "input with an interpolation pattern for an untyped variable",
109+
inputsInConfig: map[string]any{"foo": `{"a": "${b}"}`},
110+
moduleFiles: map[string]string{"main.tf": `variable "foo" {}`},
111+
expected: map[string]string{"TF_VAR_foo": `{"a": "${b}"}`},
112+
},
113+
{
114+
description: "input with an interpolation pattern for a variable of any type",
115+
inputsInConfig: map[string]any{"foo": `{"a": "${b}"}`},
116+
moduleFiles: map[string]string{"main.tf": `variable "foo" { type = any }`},
117+
expected: map[string]string{"TF_VAR_foo": `{"a": "$${b}"}`},
118+
},
119+
{
120+
description: "input with an interpolation pattern for an unparseable module",
121+
inputsInConfig: map[string]any{"foo": `{"a": "${b}"}`},
122+
moduleFiles: map[string]string{"main.tf": `variable "foo" {`},
123+
expected: map[string]string{"TF_VAR_foo": `{"a": "${b}"}`},
124+
},
99125
}
100126

101127
for _, tc := range testCases {
@@ -111,8 +137,18 @@ func TestSetTerragruntInputsAsEnvVars(t *testing.T) {
111137
env = map[string]string{}
112138
}
113139

140+
fsys := vfs.NewMemMapFS()
141+
moduleDir := "/module"
142+
143+
require.NoError(t, fsys.MkdirAll(moduleDir, 0755))
144+
145+
for filename, content := range tc.moduleFiles {
146+
path := filepath.Join(moduleDir, filename)
147+
require.NoError(t, vfs.WriteFile(fsys, path, []byte(content), 0644))
148+
}
149+
114150
l := logger.CreateLogger()
115-
require.NoError(t, run.SetTerragruntInputsAsEnvVars(l, env, cfg))
151+
require.NoError(t, run.SetTerragruntInputsAsEnvVars(l, fsys, env, moduleDir, cfg))
116152

117153
assert.Equal(t, tc.expected, env)
118154
})
@@ -223,6 +259,7 @@ func TestToTerraformEnvVars(t *testing.T) {
223259

224260
testCases := []struct {
225261
vars map[string]any
262+
declared map[string]tf.ModuleVariable
226263
expected map[string]string
227264
description string
228265
}{
@@ -286,8 +323,26 @@ func TestToTerraformEnvVars(t *testing.T) {
286323
expected: map[string]string{"TF_VAR_stuff": `{"foo":"test $${bar} test"}`},
287324
},
288325
{
289-
description: "plain string with interpolation pattern not escaped",
326+
description: "string with interpolation pattern for a literally read variable",
327+
vars: map[string]any{"mystr": "plain ${bar} string"},
328+
expected: map[string]string{"TF_VAR_mystr": `plain ${bar} string`},
329+
},
330+
{
331+
description: "string with interpolation pattern for an HCL parsed variable",
332+
vars: map[string]any{"mystr": "plain ${bar} string"},
333+
declared: map[string]tf.ModuleVariable{"mystr": {ParsingMode: tf.VariableParseHCL}},
334+
expected: map[string]string{"TF_VAR_mystr": `plain $${bar} string`},
335+
},
336+
{
337+
description: "already escaped string for an HCL parsed variable",
338+
vars: map[string]any{"mystr": "plain $${bar} string"},
339+
declared: map[string]tf.ModuleVariable{"mystr": {ParsingMode: tf.VariableParseHCL}},
340+
expected: map[string]string{"TF_VAR_mystr": `plain $${bar} string`},
341+
},
342+
{
343+
description: "declarations of other variables leave a string alone",
290344
vars: map[string]any{"mystr": "plain ${bar} string"},
345+
declared: map[string]tf.ModuleVariable{"other": {ParsingMode: tf.VariableParseHCL}},
291346
expected: map[string]string{"TF_VAR_mystr": `plain ${bar} string`},
292347
},
293348
{
@@ -302,7 +357,7 @@ func TestToTerraformEnvVars(t *testing.T) {
302357
t.Parallel()
303358

304359
l := logger.CreateLogger()
305-
actual, err := run.ToTerraformEnvVars(l, tc.vars)
360+
actual, err := run.ToTerraformEnvVars(l, tc.vars, tc.declared)
306361
require.NoError(t, err)
307362
assert.Equal(t, tc.expected, actual)
308363
})

0 commit comments

Comments
 (0)