Skip to content

Commit 01b2a57

Browse files
authored
Fix a race condition when evaluating on the root context (#2096)
* Add an E2E test for race conditions * Fix a race condition when evaluating on the root context The root runner's Evaluator is shared across goroutines, so side effects on the CallStack are not goroutine safe. We solve this problem by moving the CallStack into a Scope for each evaluation.
1 parent 5833005 commit 01b2a57

File tree

11 files changed

+272
-76
lines changed

11 files changed

+272
-76
lines changed

.github/workflows/e2e.yml

+15
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,18 @@ jobs:
3333
run: make e2e
3434
env:
3535
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36+
race:
37+
name: race
38+
# go test -race is slow and only runs on ubuntu
39+
runs-on: ubuntu-latest
40+
steps:
41+
- name: Checkout
42+
uses: actions/checkout@v4
43+
with:
44+
submodules: true
45+
- name: Set up Go
46+
uses: actions/setup-go@v5
47+
with:
48+
go-version-file: 'go.mod'
49+
- name: Run e2e-race
50+
run: make e2e-race

Makefile

+4-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ install:
1616
go install
1717

1818
e2e: prepare install
19-
go test -timeout 5m ./integrationtest/...
19+
go test -timeout 5m $$(go list ./integrationtest/... | grep -v race)
20+
21+
e2e-race: prepare
22+
go test --race --timeout 5m ./integrationtest/race
2023

2124
lint:
2225
golangci-lint run ./...
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
plugin "testing" {
2+
enabled = true
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
locals {
2+
dns_name = "www.example.com"
3+
}
4+
5+
resource "aws_route53_record" "www" {
6+
zone_id = aws_route53_zone.primary.zone_id
7+
name = local.dns_name
8+
type = "A"
9+
ttl = 300
10+
records = [aws_eip.lb.public_ip]
11+
}
12+
13+
module "route53_records" {
14+
count = 10
15+
16+
source = "./module"
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
resource "aws_route53_record" "help" {
2+
zone_id = aws_route53_zone.primary.zone_id
3+
name = "help.example.com"
4+
type = "A"
5+
ttl = 300
6+
records = [aws_eip.lb.public_ip]
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"issues": [
3+
{
4+
"rule": {
5+
"name": "aws_route53_record_eval_on_root_ctx_example",
6+
"severity": "error",
7+
"link": ""
8+
},
9+
"message": "record name (root): \"www.example.com\"",
10+
"range": {
11+
"filename": "main.tf",
12+
"start": {
13+
"line": 7,
14+
"column": 13
15+
},
16+
"end": {
17+
"line": 7,
18+
"column": 27
19+
}
20+
},
21+
"callers": []
22+
}
23+
],
24+
"errors": []
25+
}

integrationtest/race/race_test.go

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"log"
9+
"os"
10+
"path/filepath"
11+
"runtime"
12+
"strings"
13+
"testing"
14+
"text/template"
15+
16+
"github.com/google/go-cmp/cmp"
17+
"github.com/terraform-linters/tflint/cmd"
18+
"github.com/terraform-linters/tflint/formatter"
19+
"github.com/terraform-linters/tflint/tflint"
20+
)
21+
22+
func TestMain(m *testing.M) {
23+
log.SetOutput(io.Discard)
24+
os.Exit(m.Run())
25+
}
26+
27+
type meta struct {
28+
Version string
29+
}
30+
31+
func TestIntegration(t *testing.T) {
32+
cases := []struct {
33+
Name string
34+
Command string
35+
Dir string
36+
}{
37+
{
38+
// @see https://github.com/terraform-linters/tflint/issues/2094
39+
Name: "eval locals on the root context in parallel runners",
40+
Command: "tflint --format json",
41+
Dir: "eval_locals_on_root_ctx",
42+
},
43+
}
44+
45+
// Disable the bundled plugin because the `os.Executable()` is go(1) in the tests
46+
tflint.DisableBundledPlugin = true
47+
defer func() {
48+
tflint.DisableBundledPlugin = false
49+
}()
50+
51+
dir, _ := os.Getwd()
52+
for _, tc := range cases {
53+
t.Run(tc.Name, func(t *testing.T) {
54+
testDir := filepath.Join(dir, tc.Dir)
55+
56+
defer func() {
57+
if err := os.Chdir(dir); err != nil {
58+
t.Fatal(err)
59+
}
60+
}()
61+
if err := os.Chdir(testDir); err != nil {
62+
t.Fatal(err)
63+
}
64+
65+
outStream, errStream := new(bytes.Buffer), new(bytes.Buffer)
66+
cli, err := cmd.NewCLI(outStream, errStream)
67+
if err != nil {
68+
t.Fatal(err)
69+
}
70+
args := strings.Split(tc.Command, " ")
71+
72+
cli.Run(args)
73+
74+
rawWant, err := readResultFile(testDir)
75+
if err != nil {
76+
t.Fatal(err)
77+
}
78+
var want *formatter.JSONOutput
79+
if err := json.Unmarshal(rawWant, &want); err != nil {
80+
t.Fatal(err)
81+
}
82+
83+
var got *formatter.JSONOutput
84+
if err := json.Unmarshal(outStream.Bytes(), &got); err != nil {
85+
t.Fatal(err)
86+
}
87+
88+
if diff := cmp.Diff(got, want); diff != "" {
89+
t.Fatal(diff)
90+
}
91+
})
92+
}
93+
}
94+
95+
func readResultFile(dir string) ([]byte, error) {
96+
resultFile := "result.json"
97+
if runtime.GOOS == "windows" {
98+
if _, err := os.Stat(filepath.Join(dir, "result_windows.json")); !os.IsNotExist(err) {
99+
resultFile = "result_windows.json"
100+
}
101+
}
102+
if _, err := os.Stat(fmt.Sprintf("%s.tmpl", resultFile)); !os.IsNotExist(err) {
103+
resultFile = fmt.Sprintf("%s.tmpl", resultFile)
104+
}
105+
106+
if !strings.HasSuffix(resultFile, ".tmpl") {
107+
return os.ReadFile(filepath.Join(dir, resultFile))
108+
}
109+
110+
want := new(bytes.Buffer)
111+
tmpl := template.Must(template.ParseFiles(filepath.Join(dir, resultFile)))
112+
if err := tmpl.Execute(want, meta{Version: tflint.Version.String()}); err != nil {
113+
return nil, err
114+
}
115+
return want.Bytes(), nil
116+
}

terraform/evaluator.go

+27-72
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"fmt"
55
"os"
66
"path/filepath"
7-
"strings"
87

98
"github.com/agext/levenshtein"
109
"github.com/hashicorp/hcl/v2"
@@ -21,64 +20,11 @@ type ContextMeta struct {
2120
OriginalWorkingDir string
2221
}
2322

24-
type CallStack struct {
25-
addrs map[string]addrs.Reference
26-
stack []string
27-
}
28-
29-
func NewCallStack() *CallStack {
30-
return &CallStack{
31-
addrs: make(map[string]addrs.Reference),
32-
stack: make([]string, 0),
33-
}
34-
}
35-
36-
func (g *CallStack) Push(addr addrs.Reference) hcl.Diagnostics {
37-
g.stack = append(g.stack, addr.Subject.String())
38-
39-
if _, exists := g.addrs[addr.Subject.String()]; exists {
40-
return hcl.Diagnostics{
41-
{
42-
Severity: hcl.DiagError,
43-
Summary: "circular reference found",
44-
Detail: g.String(),
45-
Subject: addr.SourceRange.Ptr(),
46-
},
47-
}
48-
}
49-
g.addrs[addr.Subject.String()] = addr
50-
return hcl.Diagnostics{}
51-
}
52-
53-
func (g *CallStack) Pop() {
54-
if g.Empty() {
55-
panic("cannot pop from empty stack")
56-
}
57-
58-
addr := g.stack[len(g.stack)-1]
59-
g.stack = g.stack[:len(g.stack)-1]
60-
delete(g.addrs, addr)
61-
}
62-
63-
func (g *CallStack) String() string {
64-
return strings.Join(g.stack, " -> ")
65-
}
66-
67-
func (g *CallStack) Empty() bool {
68-
return len(g.stack) == 0
69-
}
70-
71-
func (g *CallStack) Clear() {
72-
g.addrs = make(map[string]addrs.Reference)
73-
g.stack = make([]string, 0)
74-
}
75-
7623
type Evaluator struct {
7724
Meta *ContextMeta
7825
ModulePath addrs.ModuleInstance
7926
Config *Config
8027
VariableValues map[string]map[string]cty.Value
81-
CallStack *CallStack
8228
}
8329

8430
// EvaluateExpr takes the given HCL expression and evaluates it to produce a value.
@@ -101,18 +47,27 @@ func (e *Evaluator) ExpandBlock(body hcl.Body, schema *hclext.BodySchema) (hcl.B
10147
return e.scope().ExpandBlock(body, schema)
10248
}
10349

50+
// scope creates a new evaluation scope.
51+
// The difference with Evaluator is that each evaluation is independent
52+
// and is not shared between goroutines.
10453
func (e *Evaluator) scope() *lang.Scope {
105-
return &lang.Scope{
106-
Data: &evaluationData{
107-
Evaluator: e,
108-
ModulePath: e.ModulePath,
109-
},
54+
scope := &lang.Scope{CallStack: lang.NewCallStack()}
55+
scope.Data = &evaluationData{
56+
Scope: scope,
57+
Meta: e.Meta,
58+
ModulePath: e.ModulePath,
59+
Config: e.Config,
60+
VariableValues: e.VariableValues,
11061
}
62+
return scope
11163
}
11264

11365
type evaluationData struct {
114-
Evaluator *Evaluator
115-
ModulePath addrs.ModuleInstance
66+
Scope *lang.Scope
67+
Meta *ContextMeta
68+
ModulePath addrs.ModuleInstance
69+
Config *Config
70+
VariableValues map[string]map[string]cty.Value
11671
}
11772

11873
var _ lang.Data = (*evaluationData)(nil)
@@ -142,7 +97,7 @@ func (d *evaluationData) GetForEachAttr(addr addrs.ForEachAttr, rng hcl.Range) (
14297
func (d *evaluationData) GetInputVariable(addr addrs.InputVariable, rng hcl.Range) (cty.Value, hcl.Diagnostics) {
14398
var diags hcl.Diagnostics
14499

145-
moduleConfig := d.Evaluator.Config.DescendentForInstance(d.ModulePath)
100+
moduleConfig := d.Config.DescendentForInstance(d.ModulePath)
146101
if moduleConfig == nil {
147102
// should never happen, since we can't be evaluating in a module
148103
// that wasn't mentioned in configuration.
@@ -172,7 +127,7 @@ func (d *evaluationData) GetInputVariable(addr addrs.InputVariable, rng hcl.Rang
172127
}
173128

174129
moduleAddrStr := d.ModulePath.String()
175-
vals := d.Evaluator.VariableValues[moduleAddrStr]
130+
vals := d.VariableValues[moduleAddrStr]
176131
if vals == nil {
177132
return cty.UnknownVal(config.Type), diags
178133
}
@@ -229,7 +184,7 @@ func (d *evaluationData) GetLocalValue(addr addrs.LocalValue, rng hcl.Range) (ct
229184

230185
// First we'll make sure the requested value is declared in configuration,
231186
// so we can produce a nice message if not.
232-
moduleConfig := d.Evaluator.Config.DescendentForInstance(d.ModulePath)
187+
moduleConfig := d.Config.DescendentForInstance(d.ModulePath)
233188
if moduleConfig == nil {
234189
// should never happen, since we can't be evaluating in a module
235190
// that wasn't mentioned in configuration.
@@ -257,13 +212,13 @@ func (d *evaluationData) GetLocalValue(addr addrs.LocalValue, rng hcl.Range) (ct
257212
}
258213

259214
// Build a call stack for circular reference detection only when getting a local value.
260-
if diags := d.Evaluator.CallStack.Push(addrs.Reference{Subject: addr, SourceRange: rng}); diags.HasErrors() {
215+
if diags := d.Scope.CallStack.Push(addrs.Reference{Subject: addr, SourceRange: rng}); diags.HasErrors() {
261216
return cty.UnknownVal(cty.DynamicPseudoType), diags
262217
}
263218

264-
val, diags := d.Evaluator.EvaluateExpr(config.Expr, cty.DynamicPseudoType)
219+
val, diags := d.Scope.EvalExpr(config.Expr, cty.DynamicPseudoType)
265220

266-
d.Evaluator.CallStack.Pop()
221+
d.Scope.CallStack.Pop()
267222
return val, diags
268223
}
269224

@@ -274,10 +229,10 @@ func (d *evaluationData) GetPathAttr(addr addrs.PathAttr, rng hcl.Range) (cty.Va
274229
case "cwd":
275230
var err error
276231
var wd string
277-
if d.Evaluator.Meta != nil {
232+
if d.Meta != nil {
278233
// Meta is always non-nil in the normal case, but some test cases
279234
// are not so realistic.
280-
wd = d.Evaluator.Meta.OriginalWorkingDir
235+
wd = d.Meta.OriginalWorkingDir
281236
}
282237
if wd == "" {
283238
wd, err = os.Getwd()
@@ -308,7 +263,7 @@ func (d *evaluationData) GetPathAttr(addr addrs.PathAttr, rng hcl.Range) (cty.Va
308263
return cty.StringVal(filepath.ToSlash(wd)), diags
309264

310265
case "module":
311-
moduleConfig := d.Evaluator.Config.DescendentForInstance(d.ModulePath)
266+
moduleConfig := d.Config.DescendentForInstance(d.ModulePath)
312267
if moduleConfig == nil {
313268
// should never happen, since we can't be evaluating in a module
314269
// that wasn't mentioned in configuration.
@@ -318,7 +273,7 @@ func (d *evaluationData) GetPathAttr(addr addrs.PathAttr, rng hcl.Range) (cty.Va
318273
return cty.StringVal(filepath.ToSlash(sourceDir)), diags
319274

320275
case "root":
321-
sourceDir := d.Evaluator.Config.Module.SourceDir
276+
sourceDir := d.Config.Module.SourceDir
322277
return cty.StringVal(filepath.ToSlash(sourceDir)), diags
323278

324279
default:
@@ -341,7 +296,7 @@ func (d *evaluationData) GetTerraformAttr(addr addrs.TerraformAttr, rng hcl.Rang
341296
switch addr.Name {
342297

343298
case "workspace":
344-
workspaceName := d.Evaluator.Meta.Env
299+
workspaceName := d.Meta.Env
345300
return cty.StringVal(workspaceName), diags
346301

347302
case "env":

0 commit comments

Comments
 (0)