Skip to content

Commit 350fc2a

Browse files
committed
S3 buckets parsing
1 parent 30dc2b6 commit 350fc2a

File tree

8 files changed

+254
-13
lines changed

8 files changed

+254
-13
lines changed

internal/remotestate/backend/gcs/config.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"github.com/gruntwork-io/terragrunt/internal/errors"
88
"github.com/gruntwork-io/terragrunt/internal/remotestate/backend"
99
"github.com/gruntwork-io/terragrunt/pkg/log"
10-
"github.com/mitchellh/mapstructure"
1110
"golang.org/x/exp/slices"
1211

1312
"maps"
@@ -56,11 +55,11 @@ func (cfg Config) ParseExtendedGCSConfig() (*ExtendedRemoteStateConfigGCS, error
5655
extendedConfig ExtendedRemoteStateConfigGCS
5756
)
5857

59-
if err := mapstructure.WeakDecode(cfg, &gcsConfig); err != nil {
58+
if err := util.DecodeWithStringBoolHook(cfg, &gcsConfig); err != nil {
6059
return nil, errors.New(err)
6160
}
6261

63-
if err := mapstructure.WeakDecode(cfg, &extendedConfig); err != nil {
62+
if err := util.DecodeWithStringBoolHook(cfg, &extendedConfig); err != nil {
6463
return nil, errors.New(err)
6564
}
6665

internal/remotestate/backend/gcs/config_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,17 @@ func TestParseExtendedGCSConfig_StringBoolCoercion(t *testing.T) {
183183
})
184184
}
185185
}
186+
187+
// TestParseExtendedGCSConfig_InvalidStringBool verifies invalid string values
188+
// for bool fields are rejected (e.g. "maybe" is not a valid bool).
189+
func TestParseExtendedGCSConfig_InvalidStringBool(t *testing.T) {
190+
t.Parallel()
191+
192+
cfg := gcsbackend.Config{
193+
"bucket": "my-bucket",
194+
"skip_bucket_versioning": "maybe",
195+
}
196+
197+
_, err := cfg.ParseExtendedGCSConfig()
198+
require.Error(t, err)
199+
}

internal/remotestate/backend/s3/config.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"github.com/gruntwork-io/terragrunt/internal/errors"
88
"github.com/gruntwork-io/terragrunt/internal/hclhelper"
99
"github.com/gruntwork-io/terragrunt/pkg/log"
10-
"github.com/mitchellh/mapstructure"
1110

1211
"maps"
1312

@@ -103,11 +102,11 @@ func (cfg Config) ParseExtendedS3Config() (*ExtendedRemoteStateConfigS3, error)
103102
extendedConfig ExtendedRemoteStateConfigS3
104103
)
105104

106-
if err := mapstructure.WeakDecode(cfg, &s3Config); err != nil {
105+
if err := util.DecodeWithStringBoolHook(cfg, &s3Config); err != nil {
107106
return nil, errors.New(err)
108107
}
109108

110-
if err := mapstructure.WeakDecode(cfg, &extendedConfig); err != nil {
109+
if err := util.DecodeWithStringBoolHook(cfg, &extendedConfig); err != nil {
111110
return nil, errors.New(err)
112111
}
113112

internal/util/mapstructure.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package util
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
"strings"
7+
8+
"github.com/mitchellh/mapstructure"
9+
)
10+
11+
// DecodeWithStringBoolHook decodes input into output using mapstructure,
12+
// allowing only string -> bool coercion for "true"/"false" values.
13+
func DecodeWithStringBoolHook(input, output any) error {
14+
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
15+
Result: output,
16+
DecodeHook: mapstructure.DecodeHookFuncType(func(from reflect.Type, to reflect.Type, data any) (any, error) {
17+
if from.Kind() != reflect.String || to.Kind() != reflect.Bool {
18+
return data, nil
19+
}
20+
21+
strValue := strings.TrimSpace(fmt.Sprintf("%v", data))
22+
23+
switch {
24+
case strings.EqualFold(strValue, "true"):
25+
return true, nil
26+
case strings.EqualFold(strValue, "false"):
27+
return false, nil
28+
default:
29+
return nil, fmt.Errorf("invalid boolean string %q, expected \"true\" or \"false\"", strValue)
30+
}
31+
}),
32+
})
33+
if err != nil {
34+
return err
35+
}
36+
37+
return decoder.Decode(input)
38+
}

internal/util/mapstructure_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package util_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/gruntwork-io/terragrunt/internal/util"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
type decodeStringBoolConfig struct {
12+
PointerValue *bool `mapstructure:"pointer_value"`
13+
StringValue string `mapstructure:"string_value"`
14+
BoolValue bool `mapstructure:"bool_value"`
15+
}
16+
17+
func TestDecodeWithStringBoolHook(t *testing.T) {
18+
t.Parallel()
19+
20+
input := map[string]any{
21+
"bool_value": "true",
22+
"pointer_value": "false",
23+
"string_value": "plain-string",
24+
}
25+
26+
var output decodeStringBoolConfig
27+
28+
err := util.DecodeWithStringBoolHook(input, &output)
29+
require.NoError(t, err)
30+
31+
assert.True(t, output.BoolValue)
32+
require.NotNil(t, output.PointerValue)
33+
assert.False(t, *output.PointerValue)
34+
assert.Equal(t, "plain-string", output.StringValue)
35+
}
36+
37+
func TestDecodeWithStringBoolHook_InvalidBoolStringsRejected(t *testing.T) {
38+
t.Parallel()
39+
40+
testCases := []struct {
41+
input any
42+
name string
43+
}{
44+
{
45+
name: "one-string",
46+
input: map[string]any{
47+
"bool_value": "1",
48+
},
49+
},
50+
{
51+
name: "empty-string",
52+
input: map[string]any{
53+
"bool_value": "",
54+
},
55+
},
56+
{
57+
name: "arbitrary-string",
58+
input: map[string]any{
59+
"bool_value": "maybe",
60+
},
61+
},
62+
}
63+
64+
for _, tc := range testCases {
65+
t.Run(tc.name, func(t *testing.T) {
66+
t.Parallel()
67+
68+
var output decodeStringBoolConfig
69+
70+
err := util.DecodeWithStringBoolHook(tc.input, &output)
71+
require.Error(t, err)
72+
assert.Contains(t, err.Error(), "invalid boolean string")
73+
})
74+
}
75+
}
76+
77+
func TestDecodeWithStringBoolHook_NonStringBoolsRejected(t *testing.T) {
78+
t.Parallel()
79+
80+
input := map[string]any{
81+
"bool_value": 1,
82+
}
83+
84+
var output decodeStringBoolConfig
85+
86+
err := util.DecodeWithStringBoolHook(input, &output)
87+
require.Error(t, err)
88+
assert.Contains(t, err.Error(), "unconvertible type 'int'")
89+
}

pkg/config/config.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ import (
2626
"github.com/gruntwork-io/terragrunt/internal/remotestate"
2727
"github.com/gruntwork-io/terragrunt/internal/strict/controls"
2828

29-
"github.com/mitchellh/mapstructure"
30-
3129
"github.com/hashicorp/go-getter"
3230
"github.com/hashicorp/hcl/v2"
3331
"github.com/hashicorp/hcl/v2/hclsyntax"
@@ -1681,8 +1679,8 @@ func convertToTerragruntConfig(ctx context.Context, pctx *ParsingContext, config
16811679
}
16821680

16831681
var config *remotestate.Config
1684-
// WeakDecode: HCL ternary type unification sends string "true" for bool fields. See #5475.
1685-
if err := mapstructure.WeakDecode(remoteStateMap, &config); err != nil {
1682+
// Decode with strict string->bool coercion for HCL ternary type unification. See #5475.
1683+
if err := util.DecodeWithStringBoolHook(remoteStateMap, &config); err != nil {
16861684
return nil, err
16871685
}
16881686

@@ -1793,8 +1791,8 @@ func convertToTerragruntConfig(ctx context.Context, pctx *ParsingContext, config
17931791

17941792
for name, block := range generateMap {
17951793
var generateBlock terragruntGenerateBlock
1796-
// WeakDecode: HCL ternary type unification sends string "true" for bool fields. See #5475.
1797-
if err := mapstructure.WeakDecode(block, &generateBlock); err != nil {
1794+
// Decode with strict string->bool coercion for HCL ternary type unification. See #5475.
1795+
if err := util.DecodeWithStringBoolHook(block, &generateBlock); err != nil {
17981796
return nil, err
17991797
}
18001798

pkg/config/config_helpers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1369,7 +1369,7 @@ func sopsDecryptFileImpl(ctx context.Context, pctx *ParsingContext, l log.Logger
13691369
// credentials with empty auth-provider values.
13701370
env := pctx.TerragruntOptions.Env
13711371

1372-
var setKeys []string
1372+
setKeys := make([]string, 0, len(env))
13731373

13741374
for k, v := range env {
13751375
if _, exists := os.LookupEnv(k); exists {

pkg/config/config_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,110 @@ remote_state = {
8484
}
8585
}
8686

87+
func TestParseTerragruntConfigRemoteStateAttrStringBoolCoercion(t *testing.T) {
88+
t.Parallel()
89+
90+
cfg := `
91+
locals {
92+
enable_flags = true
93+
}
94+
95+
remote_state = {
96+
backend = "s3"
97+
disable_init = local.enable_flags ? "true" : "false"
98+
disable_dependency_optimization = local.enable_flags ? "false" : "true"
99+
config = {
100+
bucket = "my-bucket"
101+
key = "terraform.tfstate"
102+
region = "us-east-1"
103+
}
104+
}
105+
`
106+
107+
l := createLogger()
108+
109+
ctx, pctx := config.NewParsingContext(t.Context(), l, mockOptionsForTest(t))
110+
terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil)
111+
require.NoError(t, err)
112+
113+
if assert.NotNil(t, terragruntConfig.RemoteState) {
114+
assert.True(t, terragruntConfig.RemoteState.DisableInit)
115+
assert.False(t, terragruntConfig.RemoteState.DisableDependencyOptimization)
116+
}
117+
}
118+
119+
func TestParseTerragruntConfigRemoteStateAttrInvalidStringBool(t *testing.T) {
120+
t.Parallel()
121+
122+
cfg := `
123+
remote_state = {
124+
backend = "s3"
125+
disable_init = "maybe"
126+
config = {}
127+
}
128+
`
129+
130+
l := createLogger()
131+
132+
ctx, pctx := config.NewParsingContext(t.Context(), l, mockOptionsForTest(t))
133+
_, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil)
134+
require.Error(t, err)
135+
assert.Contains(t, err.Error(), "invalid boolean string")
136+
}
137+
138+
func TestParseTerragruntConfigGenerateAttrStringBoolCoercion(t *testing.T) {
139+
t.Parallel()
140+
141+
cfg := `
142+
locals {
143+
enable_flags = true
144+
}
145+
146+
generate = {
147+
provider = {
148+
path = "provider.tf"
149+
if_exists = "overwrite"
150+
contents = "provider \"aws\" {}"
151+
disable_signature = local.enable_flags ? "true" : "false"
152+
disable = local.enable_flags ? "false" : "true"
153+
}
154+
}
155+
`
156+
157+
l := createLogger()
158+
159+
ctx, pctx := config.NewParsingContext(t.Context(), l, mockOptionsForTest(t))
160+
terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil)
161+
require.NoError(t, err)
162+
163+
providerGenerateConfig, ok := terragruntConfig.GenerateConfigs["provider"]
164+
require.True(t, ok)
165+
assert.True(t, providerGenerateConfig.DisableSignature)
166+
assert.False(t, providerGenerateConfig.Disable)
167+
}
168+
169+
func TestParseTerragruntConfigGenerateAttrInvalidStringBool(t *testing.T) {
170+
t.Parallel()
171+
172+
cfg := `
173+
generate = {
174+
provider = {
175+
path = "provider.tf"
176+
if_exists = "overwrite"
177+
contents = "provider \"aws\" {}"
178+
disable_signature = "maybe"
179+
}
180+
}
181+
`
182+
183+
l := createLogger()
184+
185+
ctx, pctx := config.NewParsingContext(t.Context(), l, mockOptionsForTest(t))
186+
_, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil)
187+
require.Error(t, err)
188+
assert.Contains(t, err.Error(), "invalid boolean string")
189+
}
190+
87191
func TestParseTerragruntJsonConfigRemoteStateMinimalConfig(t *testing.T) {
88192
t.Parallel()
89193

0 commit comments

Comments
 (0)