Skip to content

Commit c646c32

Browse files
authored
add terraform_json_syntax rule (#297)
1 parent c6cd22d commit c646c32

File tree

5 files changed

+481
-0
lines changed

5 files changed

+481
-0
lines changed

docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ All rules are enabled by default, but by setting `preset = "recommended"`, you c
1313
|[terraform_documented_outputs](terraform_documented_outputs.md)|Disallow `output` declarations without description||
1414
|[terraform_documented_variables](terraform_documented_variables.md)|Disallow `variable` declarations without description||
1515
|[terraform_empty_list_equality](terraform_empty_list_equality.md)|Disallow comparisons with `[]` when checking if a collection is empty||
16+
|[terraform_json_syntax](terraform_json_syntax.md)|Enforce the official Terraform JSON syntax that uses a root object||
1617
|[terraform_map_duplicate_keys](terraform_map_duplicate_keys.md)|Disallow duplicate keys in a map object||
1718
|[terraform_module_pinned_source](terraform_module_pinned_source.md)|Disallow specifying a git or mercurial repository as a module source without pinning to a version||
1819
|[terraform_module_shallow_clone](terraform_module_shallow_clone.md)|Require pinned Git-hosted Terraform modules to use shallow cloning||
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# terraform_json_syntax
2+
3+
Enforce the official Terraform JSON syntax that uses a root object with keys for each block type.
4+
5+
## Example
6+
7+
```json
8+
[{"resource": {"aws_instance": {"example": {"ami": "ami-12345678"}}}}]
9+
```
10+
11+
```
12+
$ tflint
13+
1 issue(s) found:
14+
15+
Warning: JSON configuration uses array syntax at root, expected object (terraform_json_syntax)
16+
17+
on main.tf.json line 1:
18+
1: [{"resource": {"aws_instance": {"example": {"ami": "ami-12345678"}}}}]
19+
20+
Reference: https://github.com/terraform-linters/tflint-ruleset-terraform/blob/v0.1.0/docs/rules/terraform_json_syntax.md
21+
```
22+
23+
## Why
24+
25+
The [Terraform JSON syntax documentation](https://developer.hashicorp.com/terraform/language/syntax/json#json-file-structure) states:
26+
27+
> At the root of any JSON-based Terraform configuration is a JSON object. The properties of this object correspond to the top-level block types of the Terraform language.
28+
29+
While Terraform's underlying HCL parser supports flattening arrays, the documented and supported standard is to use a root object with top-level keys for Terraform's block types: `resource`, `variable`, `output`, etc. Using the official syntax ensures compatibility with third party tools that implement the documented standard.
30+
31+
## How To Fix
32+
33+
Convert your array-based JSON configuration to use a root object. Instead of wrapping configuration in an array, use an object with appropriate top-level keys.
34+
35+
### Before
36+
37+
```json
38+
[
39+
{"resource": {"aws_instance": {"example": {"ami": "ami-12345678"}}}},
40+
{"variable": {"region": {"type": "string"}}}
41+
]
42+
```
43+
44+
### After
45+
46+
```json
47+
{
48+
"resource": {
49+
"aws_instance": {
50+
"example": {
51+
"ami": "ami-12345678"
52+
}
53+
}
54+
},
55+
"variable": {
56+
"region": {
57+
"type": "string"
58+
}
59+
}
60+
}
61+
```

rules/preset.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ var PresetRules = map[string][]tflint.Rule{
1111
NewTerraformDocumentedOutputsRule(),
1212
NewTerraformDocumentedVariablesRule(),
1313
NewTerraformEmptyListEqualityRule(),
14+
NewTerraformJSONSyntaxRule(),
1415
NewTerraformMapDuplicateKeysRule(),
1516
NewTerraformModulePinnedSourceRule(),
1617
NewTerraformModuleShallowCloneRule(),
@@ -29,6 +30,7 @@ var PresetRules = map[string][]tflint.Rule{
2930
NewTerraformDeprecatedInterpolationRule(),
3031
NewTerraformDeprecatedLookupRule(),
3132
NewTerraformEmptyListEqualityRule(),
33+
NewTerraformJSONSyntaxRule(),
3234
NewTerraformMapDuplicateKeysRule(),
3335
NewTerraformModulePinnedSourceRule(),
3436
NewTerraformModuleVersionRule(),

rules/terraform_json_syntax.go

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package rules
2+
3+
import (
4+
stdjson "encoding/json"
5+
"strings"
6+
7+
"github.com/hashicorp/hcl/v2"
8+
"github.com/hashicorp/hcl/v2/json"
9+
"github.com/terraform-linters/tflint-plugin-sdk/tflint"
10+
"github.com/terraform-linters/tflint-ruleset-terraform/project"
11+
)
12+
13+
// deepMerge recursively merges src into dst
14+
func deepMerge(dst, src map[string]any) {
15+
for key, srcVal := range src {
16+
if dstVal, exists := dst[key]; exists {
17+
// If both are maps, merge recursively
18+
srcMap, srcIsMap := srcVal.(map[string]any)
19+
dstMap, dstIsMap := dstVal.(map[string]any)
20+
if srcIsMap && dstIsMap {
21+
deepMerge(dstMap, srcMap)
22+
continue
23+
}
24+
}
25+
// Otherwise, src overwrites dst
26+
dst[key] = srcVal
27+
}
28+
}
29+
30+
// canMergeValues checks if values can be safely merged without data loss.
31+
// Values can be merged if they are all maps and any overlapping keys can also be merged recursively.
32+
func canMergeValues(values []any) bool {
33+
if len(values) == 0 {
34+
return false
35+
}
36+
37+
// All values must be maps
38+
maps := make([]map[string]any, 0, len(values))
39+
for _, val := range values {
40+
m, ok := val.(map[string]any)
41+
if !ok {
42+
return false
43+
}
44+
maps = append(maps, m)
45+
}
46+
47+
// Collect all values by key across all maps
48+
keyValues := make(map[string][]any)
49+
for _, m := range maps {
50+
for key, val := range m {
51+
keyValues[key] = append(keyValues[key], val)
52+
}
53+
}
54+
55+
// Check each key's values
56+
for _, vals := range keyValues {
57+
if len(vals) == 1 {
58+
continue // Single value, no conflict
59+
}
60+
61+
// Multiple values for this key - check if they're all maps
62+
allMaps := true
63+
for _, v := range vals {
64+
if _, ok := v.(map[string]any); !ok {
65+
allMaps = false
66+
break
67+
}
68+
}
69+
70+
if !allMaps {
71+
return false // Can't merge non-map values
72+
}
73+
74+
// Recursively check if these nested maps can be merged
75+
if !canMergeValues(vals) {
76+
return false
77+
}
78+
}
79+
80+
return true
81+
}
82+
83+
// TerraformJSONSyntaxRule checks whether JSON configuration uses the official syntax
84+
type TerraformJSONSyntaxRule struct {
85+
tflint.DefaultRule
86+
}
87+
88+
// NewTerraformJSONSyntaxRule returns a new rule
89+
func NewTerraformJSONSyntaxRule() *TerraformJSONSyntaxRule {
90+
return &TerraformJSONSyntaxRule{}
91+
}
92+
93+
// Name returns the rule name
94+
func (r *TerraformJSONSyntaxRule) Name() string {
95+
return "terraform_json_syntax"
96+
}
97+
98+
// Enabled returns whether the rule is enabled by default
99+
func (r *TerraformJSONSyntaxRule) Enabled() bool {
100+
return true
101+
}
102+
103+
// Severity returns the rule severity
104+
func (r *TerraformJSONSyntaxRule) Severity() tflint.Severity {
105+
return tflint.WARNING
106+
}
107+
108+
// Link returns the rule reference link
109+
func (r *TerraformJSONSyntaxRule) Link() string {
110+
return project.ReferenceLink(r.Name())
111+
}
112+
113+
// Check checks whether JSON configurations use object syntax at root
114+
func (r *TerraformJSONSyntaxRule) Check(runner tflint.Runner) error {
115+
path, err := runner.GetModulePath()
116+
if err != nil {
117+
return err
118+
}
119+
if !path.IsRoot() {
120+
// This rule does not evaluate child modules.
121+
return nil
122+
}
123+
124+
files, err := runner.GetFiles()
125+
if err != nil {
126+
return err
127+
}
128+
for name, file := range files {
129+
if err := r.checkJSONSyntax(runner, name, file); err != nil {
130+
return err
131+
}
132+
}
133+
134+
return nil
135+
}
136+
137+
func (r *TerraformJSONSyntaxRule) checkJSONSyntax(runner tflint.Runner, filename string, file *hcl.File) error {
138+
if !strings.HasSuffix(filename, ".tf.json") {
139+
return nil
140+
}
141+
142+
// Check if this is a JSON body
143+
if !json.IsJSONBody(file.Body) {
144+
return nil
145+
}
146+
147+
// Unmarshal the file bytes to detect the root type
148+
var root any
149+
if err := stdjson.Unmarshal(file.Bytes, &root); err != nil {
150+
// If we can't parse it, skip (HCL will report the error)
151+
return nil
152+
}
153+
154+
// Check if root is an array
155+
if arr, isArray := root.([]any); isArray {
156+
// Calculate the range covering the entire file
157+
lines := strings.Count(string(file.Bytes), "\n") + 1
158+
lastLineLen := len(strings.TrimRight(string(file.Bytes), "\n"))
159+
if idx := strings.LastIndex(string(file.Bytes), "\n"); idx >= 0 {
160+
lastLineLen = len(file.Bytes) - idx - 1
161+
}
162+
163+
fileRange := hcl.Range{
164+
Filename: filename,
165+
Start: hcl.Pos{Line: 1, Column: 1, Byte: 0},
166+
End: hcl.Pos{Line: lines, Column: lastLineLen + 1, Byte: len(file.Bytes)},
167+
}
168+
169+
if err := runner.EmitIssueWithFix(
170+
r,
171+
"JSON configuration uses array syntax at root, expected object",
172+
file.Body.MissingItemRange(),
173+
func(f tflint.Fixer) error {
174+
// First pass: collect all values by key
175+
keyValues := make(map[string][]any)
176+
for _, item := range arr {
177+
if obj, ok := item.(map[string]any); ok {
178+
for key, val := range obj {
179+
keyValues[key] = append(keyValues[key], val)
180+
}
181+
}
182+
}
183+
184+
// Second pass: decide whether to merge or collect into array
185+
merged := make(map[string]any)
186+
for key, values := range keyValues {
187+
if len(values) == 1 {
188+
// Single value, just use it
189+
merged[key] = values[0]
190+
} else if canMergeValues(values) {
191+
// Values can be merged without conflicts
192+
result := make(map[string]any)
193+
for _, val := range values {
194+
if valMap, ok := val.(map[string]any); ok {
195+
deepMerge(result, valMap)
196+
}
197+
}
198+
merged[key] = result
199+
} else {
200+
// Values conflict, collect into array
201+
merged[key] = values
202+
}
203+
}
204+
205+
// Marshal back to JSON with indentation
206+
fixed, err := stdjson.MarshalIndent(merged, "", " ")
207+
if err != nil {
208+
return err
209+
}
210+
211+
// Add trailing newline
212+
fixedStr := string(fixed) + "\n"
213+
214+
// Replace entire file content
215+
return f.ReplaceText(fileRange, fixedStr)
216+
},
217+
); err != nil {
218+
return err
219+
}
220+
}
221+
222+
return nil
223+
}

0 commit comments

Comments
 (0)