From 80d6cb78ddcc975770b1db6993944048cb8dcb1d Mon Sep 17 00:00:00 2001 From: Kazuma Watanabe Date: Sun, 9 Oct 2022 14:15:59 +0000 Subject: [PATCH] Add support for count/each value --- go.mod | 2 +- go.sum | 4 +- .../inspection/conditional/result.json | 20 + .../expand/.terraform/modules/modules.json | 1 + integrationtest/inspection/expand/.tflint.hcl | 7 + integrationtest/inspection/expand/main.tf | 41 +++ .../inspection/expand/module/main.tf | 5 + integrationtest/inspection/expand/result.json | 257 +++++++++++++ .../inspection/expand/result_windows.json | 257 +++++++++++++ integrationtest/inspection/inspection_test.go | 5 + integrationtest/inspection/module/result.json | 108 ++++++ .../inspection/module/result_windows.json | 108 ++++++ plugin/server.go | 4 +- terraform/evaluator.go | 167 ++++----- terraform/evaluator_test.go | 56 ++- terraform/expandable.go | 208 +++++++++++ terraform/lang/data.go | 2 + terraform/lang/data_test.go | 10 + terraform/lang/eval.go | 16 +- terraform/lang/eval_test.go | 65 ++-- terraform/module.go | 35 +- terraform/module_call.go | 3 +- terraform/module_test.go | 344 +++++++++++++++--- terraform/resource.go | 8 +- tflint/issue.go | 2 +- tflint/runner.go | 41 +-- tflint/runner_test.go | 16 +- .../.terraform/modules/modules.json | 2 +- .../module_with_count_for_each/module.tf | 9 +- 29 files changed, 1580 insertions(+), 223 deletions(-) create mode 100644 integrationtest/inspection/expand/.terraform/modules/modules.json create mode 100644 integrationtest/inspection/expand/.tflint.hcl create mode 100644 integrationtest/inspection/expand/main.tf create mode 100644 integrationtest/inspection/expand/module/main.tf create mode 100644 integrationtest/inspection/expand/result.json create mode 100644 integrationtest/inspection/expand/result_windows.json create mode 100644 terraform/expandable.go diff --git a/go.mod b/go.mod index 4bd79b907..7d58f9354 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/sourcegraph/go-lsp v0.0.0-20200429204803-219e11d77f5d github.com/sourcegraph/jsonrpc2 v0.1.0 github.com/spf13/afero v1.9.2 - github.com/terraform-linters/tflint-plugin-sdk v0.13.1-0.20221007143453-76cc99146499 + github.com/terraform-linters/tflint-plugin-sdk v0.13.1-0.20221015091031-4476483c2384 github.com/terraform-linters/tflint-ruleset-terraform v0.1.1 github.com/xeipuuv/gojsonschema v1.2.0 github.com/zclconf/go-cty v1.11.0 diff --git a/go.sum b/go.sum index e69c73d10..fd0ae0760 100644 --- a/go.sum +++ b/go.sum @@ -260,8 +260,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/terraform-linters/tflint-plugin-sdk v0.13.1-0.20221007143453-76cc99146499 h1:4abfVUQM2L3teOoJE7asKqzkG/NLvX+F2xuWpqaTeG8= -github.com/terraform-linters/tflint-plugin-sdk v0.13.1-0.20221007143453-76cc99146499/go.mod h1:oYEwDLOQrn3mIUQwiyXPBOnTqO1DcvRPnl/zOqHNu8A= +github.com/terraform-linters/tflint-plugin-sdk v0.13.1-0.20221015091031-4476483c2384 h1:GVruMct/2jcBEpxmJybIijL3wGl09VdTqf33yaaUnZM= +github.com/terraform-linters/tflint-plugin-sdk v0.13.1-0.20221015091031-4476483c2384/go.mod h1:oYEwDLOQrn3mIUQwiyXPBOnTqO1DcvRPnl/zOqHNu8A= github.com/terraform-linters/tflint-ruleset-terraform v0.1.1 h1:dAi25kUMZ3+c1aiQZlP+ifxXnRv+WNpSVSMBroUxeOI= github.com/terraform-linters/tflint-ruleset-terraform v0.1.1/go.mod h1:+iOphcKeOXXNPqjc3STxHvJ+4km5y8Zo+qqqPLgqFFw= github.com/ulikunitz/xz v0.5.8 h1:ERv8V6GKqVi23rgu5cj9pVfVzJbOqAY2Ntl88O6c2nQ= diff --git a/integrationtest/inspection/conditional/result.json b/integrationtest/inspection/conditional/result.json index 019c15482..fb8e5bdc6 100644 --- a/integrationtest/inspection/conditional/result.json +++ b/integrationtest/inspection/conditional/result.json @@ -60,6 +60,26 @@ }, "callers": [] }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t2.micro", + "range": { + "filename": "template.tf", + "start": { + "line": 61, + "column": 19 + }, + "end": { + "line": 61, + "column": 29 + } + }, + "callers": [] + }, { "rule": { "name": "aws_iam_policy_example", diff --git a/integrationtest/inspection/expand/.terraform/modules/modules.json b/integrationtest/inspection/expand/.terraform/modules/modules.json new file mode 100644 index 000000000..5c6f84a09 --- /dev/null +++ b/integrationtest/inspection/expand/.terraform/modules/modules.json @@ -0,0 +1 @@ +{"Modules":[{"Key":"","Source":"","Dir":"."},{"Key":"count","Source":"./module","Dir":"module"},{"Key":"for_each","Source":"./module","Dir":"module"}]} \ No newline at end of file diff --git a/integrationtest/inspection/expand/.tflint.hcl b/integrationtest/inspection/expand/.tflint.hcl new file mode 100644 index 000000000..d166aa4cb --- /dev/null +++ b/integrationtest/inspection/expand/.tflint.hcl @@ -0,0 +1,7 @@ +plugin "testing" { + enabled = true +} + +plugin "terraform" { + enabled = false +} diff --git a/integrationtest/inspection/expand/main.tf b/integrationtest/inspection/expand/main.tf new file mode 100644 index 000000000..4898d90b5 --- /dev/null +++ b/integrationtest/inspection/expand/main.tf @@ -0,0 +1,41 @@ +resource "aws_instance" "count" { + count = 2 + + instance_type = "t${count.index}.micro" +} + +resource "aws_instance" "for_each" { + for_each = { + v1 = "micro" + v2 = "medium" + } + + instance_type = "${each.key}.${each.value}" +} + +module "count" { + source = "./module" + count = 2 + + instance_type = "t${count.index}.micro" +} + +module "for_each" { + source = "./module" + for_each = { + v1 = "micro" + v2 = "medium" + } + + instance_type = "${each.key}.${each.value}" +} + +variable "sensitive" { + sensitive = true +} + +resource "aws_instance" "sensitive" { + count = 1 + + instance_type = "${count.index}.${var.sensitive}" +} diff --git a/integrationtest/inspection/expand/module/main.tf b/integrationtest/inspection/expand/module/main.tf new file mode 100644 index 000000000..2f2b0fb7b --- /dev/null +++ b/integrationtest/inspection/expand/module/main.tf @@ -0,0 +1,5 @@ +variable "instance_type" {} + +resource "aws_instance" "foo" { + instance_type = var.instance_type +} diff --git a/integrationtest/inspection/expand/result.json b/integrationtest/inspection/expand/result.json new file mode 100644 index 000000000..8be5edcad --- /dev/null +++ b/integrationtest/inspection/expand/result.json @@ -0,0 +1,257 @@ +{ + "issues": [ + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t0.micro", + "range": { + "filename": "main.tf", + "start": { + "line": 4, + "column": 19 + }, + "end": { + "line": 4, + "column": 42 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t1.micro", + "range": { + "filename": "main.tf", + "start": { + "line": 4, + "column": 19 + }, + "end": { + "line": 4, + "column": 42 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is v1.micro", + "range": { + "filename": "main.tf", + "start": { + "line": 13, + "column": 19 + }, + "end": { + "line": 13, + "column": 46 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is v2.medium", + "range": { + "filename": "main.tf", + "start": { + "line": 13, + "column": 19 + }, + "end": { + "line": 13, + "column": 46 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t0.micro", + "range": { + "filename": "main.tf", + "start": { + "line": 20, + "column": 19 + }, + "end": { + "line": 20, + "column": 42 + } + }, + "callers": [ + { + "filename": "main.tf", + "start": { + "line": 20, + "column": 19 + }, + "end": { + "line": 20, + "column": 42 + } + }, + { + "filename": "module/main.tf", + "start": { + "line": 4, + "column": 19 + }, + "end": { + "line": 4, + "column": 36 + } + } + ] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t1.micro", + "range": { + "filename": "main.tf", + "start": { + "line": 20, + "column": 19 + }, + "end": { + "line": 20, + "column": 42 + } + }, + "callers": [ + { + "filename": "main.tf", + "start": { + "line": 20, + "column": 19 + }, + "end": { + "line": 20, + "column": 42 + } + }, + { + "filename": "module/main.tf", + "start": { + "line": 4, + "column": 19 + }, + "end": { + "line": 4, + "column": 36 + } + } + ] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is v1.micro", + "range": { + "filename": "main.tf", + "start": { + "line": 30, + "column": 19 + }, + "end": { + "line": 30, + "column": 46 + } + }, + "callers": [ + { + "filename": "main.tf", + "start": { + "line": 30, + "column": 19 + }, + "end": { + "line": 30, + "column": 46 + } + }, + { + "filename": "module/main.tf", + "start": { + "line": 4, + "column": 19 + }, + "end": { + "line": 4, + "column": 36 + } + } + ] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is v2.medium", + "range": { + "filename": "main.tf", + "start": { + "line": 30, + "column": 19 + }, + "end": { + "line": 30, + "column": 46 + } + }, + "callers": [ + { + "filename": "main.tf", + "start": { + "line": 30, + "column": 19 + }, + "end": { + "line": 30, + "column": 46 + } + }, + { + "filename": "module/main.tf", + "start": { + "line": 4, + "column": 19 + }, + "end": { + "line": 4, + "column": 36 + } + } + ] + } + ], + "errors": [] +} diff --git a/integrationtest/inspection/expand/result_windows.json b/integrationtest/inspection/expand/result_windows.json new file mode 100644 index 000000000..b37ecc1b1 --- /dev/null +++ b/integrationtest/inspection/expand/result_windows.json @@ -0,0 +1,257 @@ +{ + "issues": [ + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t0.micro", + "range": { + "filename": "main.tf", + "start": { + "line": 4, + "column": 19 + }, + "end": { + "line": 4, + "column": 42 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t1.micro", + "range": { + "filename": "main.tf", + "start": { + "line": 4, + "column": 19 + }, + "end": { + "line": 4, + "column": 42 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is v1.micro", + "range": { + "filename": "main.tf", + "start": { + "line": 13, + "column": 19 + }, + "end": { + "line": 13, + "column": 46 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is v2.medium", + "range": { + "filename": "main.tf", + "start": { + "line": 13, + "column": 19 + }, + "end": { + "line": 13, + "column": 46 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t0.micro", + "range": { + "filename": "main.tf", + "start": { + "line": 20, + "column": 19 + }, + "end": { + "line": 20, + "column": 42 + } + }, + "callers": [ + { + "filename": "main.tf", + "start": { + "line": 20, + "column": 19 + }, + "end": { + "line": 20, + "column": 42 + } + }, + { + "filename": "module\\main.tf", + "start": { + "line": 4, + "column": 19 + }, + "end": { + "line": 4, + "column": 36 + } + } + ] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t1.micro", + "range": { + "filename": "main.tf", + "start": { + "line": 20, + "column": 19 + }, + "end": { + "line": 20, + "column": 42 + } + }, + "callers": [ + { + "filename": "main.tf", + "start": { + "line": 20, + "column": 19 + }, + "end": { + "line": 20, + "column": 42 + } + }, + { + "filename": "module\\main.tf", + "start": { + "line": 4, + "column": 19 + }, + "end": { + "line": 4, + "column": 36 + } + } + ] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is v1.micro", + "range": { + "filename": "main.tf", + "start": { + "line": 30, + "column": 19 + }, + "end": { + "line": 30, + "column": 46 + } + }, + "callers": [ + { + "filename": "main.tf", + "start": { + "line": 30, + "column": 19 + }, + "end": { + "line": 30, + "column": 46 + } + }, + { + "filename": "module\\main.tf", + "start": { + "line": 4, + "column": 19 + }, + "end": { + "line": 4, + "column": 36 + } + } + ] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is v2.medium", + "range": { + "filename": "main.tf", + "start": { + "line": 30, + "column": 19 + }, + "end": { + "line": 30, + "column": 46 + } + }, + "callers": [ + { + "filename": "main.tf", + "start": { + "line": 30, + "column": 19 + }, + "end": { + "line": 30, + "column": 46 + } + }, + { + "filename": "module\\main.tf", + "start": { + "line": 4, + "column": 19 + }, + "end": { + "line": 4, + "column": 36 + } + } + ] + } + ], + "errors": [] +} diff --git a/integrationtest/inspection/inspection_test.go b/integrationtest/inspection/inspection_test.go index bc5ea9af2..2eb803c75 100644 --- a/integrationtest/inspection/inspection_test.go +++ b/integrationtest/inspection/inspection_test.go @@ -188,6 +188,11 @@ func TestIntegration(t *testing.T) { Command: "tflint --format json", Dir: "incompatible-host", }, + { + Name: "expand resources/modules", + Command: "tflint --module --format json", + Dir: "expand", + }, } // Disable the bundled plugin because the `os.Executable()` is go(1) in the tests diff --git a/integrationtest/inspection/module/result.json b/integrationtest/inspection/module/result.json index 356c5aabe..6a7992e5f 100644 --- a/integrationtest/inspection/module/result.json +++ b/integrationtest/inspection/module/result.json @@ -107,6 +107,114 @@ } } ] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t1.4xlarge", + "range": { + "filename": "module.tf", + "start": { + "line": 21, + "column": 12 + }, + "end": { + "line": 21, + "column": 16 + } + }, + "callers": [ + { + "filename": "module.tf", + "start": { + "line": 21, + "column": 12 + }, + "end": { + "line": 21, + "column": 16 + } + }, + { + "filename": "module/template.tf", + "start": { + "line": 15, + "column": 12 + }, + "end": { + "line": 15, + "column": 22 + } + }, + { + "filename": "module/module/instance.tf", + "start": { + "line": 9, + "column": 19 + }, + "end": { + "line": 9, + "column": 62 + } + } + ] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t1.4xlarge", + "range": { + "filename": "module.tf", + "start": { + "line": 22, + "column": 19 + }, + "end": { + "line": 22, + "column": 27 + } + }, + "callers": [ + { + "filename": "module.tf", + "start": { + "line": 22, + "column": 19 + }, + "end": { + "line": 22, + "column": 27 + } + }, + { + "filename": "module/template.tf", + "start": { + "line": 16, + "column": 19 + }, + "end": { + "line": 16, + "column": 36 + } + }, + { + "filename": "module/module/instance.tf", + "start": { + "line": 9, + "column": 19 + }, + "end": { + "line": 9, + "column": 62 + } + } + ] } ], "errors": [] diff --git a/integrationtest/inspection/module/result_windows.json b/integrationtest/inspection/module/result_windows.json index 1568b38e4..964e6af62 100644 --- a/integrationtest/inspection/module/result_windows.json +++ b/integrationtest/inspection/module/result_windows.json @@ -107,6 +107,114 @@ } } ] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t1.4xlarge", + "range": { + "filename": "module.tf", + "start": { + "line": 21, + "column": 12 + }, + "end": { + "line": 21, + "column": 16 + } + }, + "callers": [ + { + "filename": "module.tf", + "start": { + "line": 21, + "column": 12 + }, + "end": { + "line": 21, + "column": 16 + } + }, + { + "filename": "module\\template.tf", + "start": { + "line": 15, + "column": 12 + }, + "end": { + "line": 15, + "column": 22 + } + }, + { + "filename": "module\\module\\instance.tf", + "start": { + "line": 9, + "column": 19 + }, + "end": { + "line": 9, + "column": 62 + } + } + ] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t1.4xlarge", + "range": { + "filename": "module.tf", + "start": { + "line": 22, + "column": 19 + }, + "end": { + "line": 22, + "column": 27 + } + }, + "callers": [ + { + "filename": "module.tf", + "start": { + "line": 22, + "column": 19 + }, + "end": { + "line": 22, + "column": 27 + } + }, + { + "filename": "module\\template.tf", + "start": { + "line": 16, + "column": 19 + }, + "end": { + "line": 16, + "column": 36 + } + }, + { + "filename": "module\\module\\instance.tf", + "start": { + "line": 9, + "column": 19 + }, + "end": { + "line": 9, + "column": 62 + } + } + ] } ], "errors": [] diff --git a/plugin/server.go b/plugin/server.go index 7e67620d2..34fb7fd15 100644 --- a/plugin/server.go +++ b/plugin/server.go @@ -115,7 +115,9 @@ func (s *GRPCServer) EvaluateExpr(expr hcl.Expression, opts sdk.EvaluateExprOpti runner = s.rootRunner } - val, diags := runner.Ctx.EvaluateExpr(expr, *opts.WantType) + // We always use EvalDataForNoInstanceKey here because an expression that depend on + // an instance key, such as `each.key` and `count.index`, is already bound. + val, diags := runner.Ctx.EvaluateExpr(expr, *opts.WantType, terraform.EvalDataForNoInstanceKey) if diags.HasErrors() { return val, diags } diff --git a/terraform/evaluator.go b/terraform/evaluator.go index 4f2ee4232..114f16920 100644 --- a/terraform/evaluator.go +++ b/terraform/evaluator.go @@ -70,117 +70,117 @@ type Evaluator struct { CallGraph *CallGraph } -func (e *Evaluator) EvaluateExpr(expr hcl.Expression, wantType cty.Type) (cty.Value, hcl.Diagnostics) { +func (e *Evaluator) EvaluateExpr(expr hcl.Expression, wantType cty.Type, keyData InstanceKeyEvalData) (cty.Value, hcl.Diagnostics) { scope := &lang.Scope{ Data: &evaluationData{ - Evaluator: e, - ModulePath: e.ModulePath, + Evaluator: e, + ModulePath: e.ModulePath, + InstanceKeyData: keyData, }, } return scope.EvalExpr(expr, wantType) } -// ResourceIsEvaluable checks whether the passed resource meta-arguments -// (count/for_each) indicate the resource will be evaluated. -// -// If `count` is 0 or `for_each` is empty, Terraform will not evaluate -// the attributes of that resource. Terraform doesn't expect to pass null -// for these attributes (it will cause an error), but we'll treat them as -// if they were undefined. -func (e *Evaluator) ResourceIsEvaluable(resource *Resource) (bool, hcl.Diagnostics) { - if resource.Count != nil { - return e.countIsEvaluable(resource.Count) - } - - if resource.ForEach != nil { - return e.forEachIsEvaluable(resource.ForEach) - } - - // If `count` or `for_each` is not defined, it will be evaluated by default - return true, hcl.Diagnostics{} +type InstanceKeyEvalData struct { + CountIndex cty.Value + EachKey, EachValue cty.Value } -func (e *Evaluator) ModuleCallIsEvaluable(moduleCall *ModuleCall) (bool, hcl.Diagnostics) { - if moduleCall.Count != nil { - return e.countIsEvaluable(moduleCall.Count) - } - - if moduleCall.ForEach != nil { - return e.forEachIsEvaluable(moduleCall.ForEach) - } +var EvalDataForNoInstanceKey = InstanceKeyEvalData{} - // If `count` or `for_each` is not defined, it will be evaluated by default - return true, nil +type evaluationData struct { + Evaluator *Evaluator + ModulePath addrs.ModuleInstance + InstanceKeyData InstanceKeyEvalData } -func (e *Evaluator) countIsEvaluable(expr hcl.Expression) (bool, hcl.Diagnostics) { +var _ lang.Data = (*evaluationData)(nil) + +func (d *evaluationData) GetCountAttr(addr addrs.CountAttr, rng hcl.Range) (cty.Value, hcl.Diagnostics) { var diags hcl.Diagnostics - val, diags := e.EvaluateExpr(expr, cty.DynamicPseudoType) - if diags.HasErrors() { - return false, diags + // Even when evaluating an expression that already has the value of `count.*` bound to it, + // it still tries to create an EvalContext because it contains `count.*` as a reference. + // In that case it returns an unknown value without returning an error. + if d.InstanceKeyData == EvalDataForNoInstanceKey { + return cty.UnknownVal(cty.Number), diags } - val, _ = val.Unmark() - if val.IsNull() { - // null value means that attribute is not set - return true, diags - } - if !val.IsKnown() { - // unknown value is non-deterministic - return false, diags - } + switch addr.Name { - if val.Equals(cty.NumberIntVal(0)).True() { - // `count = 0` is not evaluated - return false, diags + case "index": + idxVal := d.InstanceKeyData.CountIndex + if idxVal == cty.NilVal { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Reference to "count" in non-counted context`, + Detail: `The "count" object can only be used in "module", "resource", and "data" blocks, and only when the "count" argument is set.`, + Subject: rng.Ptr(), + }) + return cty.UnknownVal(cty.Number), diags + } + return idxVal, diags + + default: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Invalid "count" attribute`, + Detail: fmt.Sprintf(`The "count" object does not have an attribute named %q. The only supported attribute is count.index, which is the index of each instance of a resource block that has the "count" argument set.`, addr.Name), + Subject: rng.Ptr(), + }) + return cty.DynamicVal, diags } - // `count > 1` is evaluated` - return true, diags } -func (e *Evaluator) forEachIsEvaluable(expr hcl.Expression) (bool, hcl.Diagnostics) { +func (d *evaluationData) GetForEachAttr(addr addrs.ForEachAttr, rng hcl.Range) (cty.Value, hcl.Diagnostics) { var diags hcl.Diagnostics - val, diags := e.EvaluateExpr(expr, cty.DynamicPseudoType) - if diags.HasErrors() { - return false, diags + // Even when evaluating an expression that already has the value of `each.*` bound to it, + // it still tries to create an EvalContext because it contains `each.*` as a reference. + // In that case it returns an unknown value without returning an error. + if d.InstanceKeyData == EvalDataForNoInstanceKey { + return cty.UnknownVal(cty.DynamicPseudoType), diags } - if val.IsNull() { - // null value means that attribute is not set - return true, diags - } - if !val.IsKnown() { - // unknown value is non-deterministic - return false, diags - } - if !val.CanIterateElements() { - // uniteratable values (string, number, etc.) are - return false, hcl.Diagnostics{ - { + var returnVal cty.Value + switch addr.Name { + + case "key": + returnVal = d.InstanceKeyData.EachKey + case "value": + returnVal = d.InstanceKeyData.EachValue + + if returnVal == cty.NilVal { + diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, - Summary: "The `for_each` value is not iterable", - Detail: fmt.Sprintf("`%s` is not iterable", val.GoString()), - Subject: expr.Range().Ptr(), - }, + Summary: `each.value cannot be used in this context`, + Detail: `A reference to "each.value" has been used in a context in which it unavailable, such as when the configuration no longer contains the value in its "for_each" expression. Remove this reference to each.value in your configuration to work around this error.`, + Subject: rng.Ptr(), + }) + return cty.UnknownVal(cty.DynamicPseudoType), diags } + default: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Invalid "each" attribute`, + Detail: fmt.Sprintf(`The "each" object does not have an attribute named %q. The supported attributes are each.key and each.value, the current key and value pair of the "for_each" attribute set.`, addr.Name), + Subject: rng.Ptr(), + }) + return cty.DynamicVal, diags } - if val.LengthInt() == 0 { - // empty `for_each` is not evaluated - return false, diags - } - // `for_each` with non-empty elements is evaluated - return true, diags -} -type evaluationData struct { - Evaluator *Evaluator - ModulePath addrs.ModuleInstance + if returnVal == cty.NilVal { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Reference to "each" in context without for_each`, + Detail: `The "each" object can be used only in "module" or "resource" blocks, and only when the "for_each" argument is set.`, + Subject: rng.Ptr(), + }) + return cty.UnknownVal(cty.DynamicPseudoType), diags + } + return returnVal, diags } -var _ lang.Data = (*evaluationData)(nil) - func (d *evaluationData) GetInputVariable(addr addrs.InputVariable, rng hcl.Range) (cty.Value, hcl.Diagnostics) { var diags hcl.Diagnostics @@ -304,7 +304,10 @@ func (d *evaluationData) GetLocalValue(addr addrs.LocalValue, rng hcl.Range) (ct return cty.UnknownVal(cty.DynamicPseudoType), diags } - val, diags := d.Evaluator.EvaluateExpr(config.Expr, cty.DynamicPseudoType) + // Always use EvalDataForNoInstanceKey because local values cannot use expressions + // that depend on instance keys, such as `count.*` and `each.*`. + val, diags := d.Evaluator.EvaluateExpr(config.Expr, cty.DynamicPseudoType, EvalDataForNoInstanceKey) + // Since we build a callgraph for each local value, // we clear the callgraph when the local value is finally resolved. if entry { diff --git a/terraform/evaluator_test.go b/terraform/evaluator_test.go index 574ec7893..fe8a3ca27 100644 --- a/terraform/evaluator_test.go +++ b/terraform/evaluator_test.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/spf13/afero" + "github.com/terraform-linters/tflint-plugin-sdk/hclext" "github.com/zclconf/go-cty/cty" ) @@ -37,6 +38,7 @@ func TestEvaluateExpr(t *testing.T) { inputs []InputValues expr hcl.Expression ty cty.Type + keyData InstanceKeyEvalData want string errCheck func(hcl.Diagnostics) bool }{ @@ -584,6 +586,58 @@ locals { return diags.Error() != `main.tf:4,9-18: circular reference found; local.foo -> local.bar -> local.foo` }, }, + { + name: "count.index in non-counted context", + expr: expr(`count.index`), + ty: cty.Number, + want: `cty.UnknownVal(cty.Number)`, + errCheck: neverHappend, + }, + { + name: "count.index in counted context", + expr: expr(`count.index`), + ty: cty.Number, + keyData: InstanceKeyEvalData{CountIndex: cty.NumberIntVal(1)}, + want: `cty.NumberIntVal(1)`, + errCheck: neverHappend, + }, + { + name: "each.key in non-forEach context", + expr: expr(`each.key`), + ty: cty.String, + want: `cty.UnknownVal(cty.String)`, + errCheck: neverHappend, + }, + { + name: "each.key in forEach context", + expr: expr(`each.key`), + ty: cty.String, + keyData: InstanceKeyEvalData{EachKey: cty.StringVal("foo"), EachValue: cty.StringVal("bar")}, + want: `cty.StringVal("foo")`, + errCheck: neverHappend, + }, + { + name: "each.value in non-forEach context", + expr: expr(`each.value`), + ty: cty.String, + want: `cty.UnknownVal(cty.String)`, + errCheck: neverHappend, + }, + { + name: "each.value in forEach context", + expr: expr(`each.value`), + ty: cty.String, + keyData: InstanceKeyEvalData{EachKey: cty.StringVal("foo"), EachValue: cty.StringVal("bar")}, + want: `cty.StringVal("bar")`, + errCheck: neverHappend, + }, + { + name: "bound expr without key data", + expr: hclext.BindValue(cty.StringVal("foo"), expr(`each.value`)), + ty: cty.String, + want: `cty.StringVal("foo")`, + errCheck: neverHappend, + }, } for _, test := range tests { @@ -615,7 +669,7 @@ locals { CallGraph: NewCallGraph(), } - got, diags := evaluator.EvaluateExpr(test.expr, test.ty) + got, diags := evaluator.EvaluateExpr(test.expr, test.ty, test.keyData) if test.errCheck(diags) { t.Fatal(diags) } diff --git a/terraform/expandable.go b/terraform/expandable.go new file mode 100644 index 000000000..3acd4e81d --- /dev/null +++ b/terraform/expandable.go @@ -0,0 +1,208 @@ +package terraform + +import ( + "fmt" + + "github.com/hashicorp/hcl/v2" + "github.com/terraform-linters/tflint-plugin-sdk/hclext" + "github.com/terraform-linters/tflint/terraform/addrs" + "github.com/terraform-linters/tflint/terraform/lang" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/gocty" +) + +type expandable struct { + Count hcl.Expression + ForEach hcl.Expression +} + +// expandBlock returns multiple blocks based on the meta-arguments (count/for_each). +// +// This function returns no blocks if `count` is 0, if `for_each` is empty, or if they are unknown. +// Otherwise it returns the number of blocks according to the value. +// +// Expressions containing `count.*` or `each.*` are evaluated here when expanding blocks. +// Make an instance key and bind the evaluation result based on it to the expression. +// Note that sensitive values are not bound. This is a limitation in value decoding. +// This means that `count.*`, `each.*` with sensitive values will resolve to unknown values. +func (e *expandable) expandBlock(ctx *Evaluator, block *hclext.Block) (hclext.Blocks, hcl.Diagnostics) { + if e.Count != nil { + return e.expandBlockByCount(ctx, block) + } + + if e.ForEach != nil { + return e.expandBlockByForEach(ctx, block) + } + + return hclext.Blocks{block}, hcl.Diagnostics{} +} + +func (e *expandable) expandBlockByCount(ctx *Evaluator, block *hclext.Block) (hclext.Blocks, hcl.Diagnostics) { + var diags hcl.Diagnostics + + countVal, countDiags := ctx.EvaluateExpr(e.Count, cty.Number, EvalDataForNoInstanceKey) + diags = diags.Extend(countDiags) + if diags.HasErrors() { + return hclext.Blocks{}, diags + } + countVal, _ = countVal.Unmark() + + if countVal.IsNull() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid count argument", + Detail: `The given "count" argument value is null. An integer is required.`, + Subject: e.Count.Range().Ptr(), + }) + return hclext.Blocks{}, diags + } + if !countVal.IsKnown() { + // If count is unknown, no blocks are returned + return hclext.Blocks{}, diags + } + + var count int + err := gocty.FromCtyValue(countVal, &count) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid count argument", + Detail: fmt.Sprintf(`The given "count" argument value is unsuitable: %s.`, err), + Subject: e.Count.Range().Ptr(), + }) + return hclext.Blocks{}, diags + } + if count < 0 { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid count argument", + Detail: `The given "count" argument value is unsuitable: negative numbers are not supported.`, + Subject: e.Count.Range().Ptr(), + }) + return hclext.Blocks{}, diags + } + + blocks := make(hclext.Blocks, count) + for i := 0; i < count; i++ { + expanded := block.Copy() + keyData := InstanceKeyEvalData{CountIndex: cty.NumberIntVal(int64(i))} + + walkDiags := expanded.Body.WalkAttributes(func(attr *hclext.Attribute) hcl.Diagnostics { + var diags hcl.Diagnostics + + refs, refsDiags := lang.ReferencesInExpr(attr.Expr) + if refsDiags.HasErrors() { + diags = diags.Extend(refsDiags) + return diags + } + + var contain bool + for _, ref := range refs { + if _, ok := ref.Subject.(addrs.CountAttr); ok { + contain = true + } + } + + if contain { + val, evalDiags := ctx.EvaluateExpr(attr.Expr, cty.DynamicPseudoType, keyData) + if evalDiags.HasErrors() { + diags = diags.Extend(evalDiags) + return diags + } + // If marked as sensitive, the cty.Value cannot be marshaled in the message pack, + // so only bind it if it is unmarked. + if !val.IsMarked() { + // Even if there is no instance key later, the evaluated result is bound to + // the expression so that it can be referenced by EvaluateExpr. + attr.Expr = hclext.BindValue(val, attr.Expr) + } + } + return diags + }) + + diags = diags.Extend(walkDiags) + blocks[i] = expanded + } + + return blocks, diags +} + +func (e *expandable) expandBlockByForEach(ctx *Evaluator, block *hclext.Block) (hclext.Blocks, hcl.Diagnostics) { + var diags hcl.Diagnostics + + forEach, forEachDiags := ctx.EvaluateExpr(e.ForEach, cty.DynamicPseudoType, EvalDataForNoInstanceKey) + diags = diags.Extend(forEachDiags) + if diags.HasErrors() { + return hclext.Blocks{}, diags + } + + if forEach.IsNull() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid for_each argument", + Detail: `The given "for_each" argument value is unsuitable: the given "for_each" argument value is null. A map, or set of strings is allowed.`, + Subject: e.ForEach.Range().Ptr(), + }) + return hclext.Blocks{}, diags + } + if !forEach.IsKnown() { + // If for_each is unknown, no blocks are returned + return hclext.Blocks{}, diags + } + if !forEach.CanIterateElements() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "The `for_each` value is not iterable", + Detail: fmt.Sprintf("`%s` is not iterable", forEach.GoString()), + Subject: e.ForEach.Range().Ptr(), + }) + return hclext.Blocks{}, diags + } + + blocks := make(hclext.Blocks, forEach.LengthInt()) + it := forEach.ElementIterator() + for i := 0; it.Next(); i++ { + expanded := block.Copy() + + key, value := it.Element() + keyData := InstanceKeyEvalData{EachKey: key, EachValue: value} + + walkDiags := expanded.Body.WalkAttributes(func(attr *hclext.Attribute) hcl.Diagnostics { + var diags hcl.Diagnostics + + refs, refsDiags := lang.ReferencesInExpr(attr.Expr) + if refsDiags.HasErrors() { + diags = diags.Extend(refsDiags) + return diags + } + + var contain bool + for _, ref := range refs { + if _, ok := ref.Subject.(addrs.ForEachAttr); ok { + contain = true + } + } + + if contain { + val, evalDiags := ctx.EvaluateExpr(attr.Expr, cty.DynamicPseudoType, keyData) + if evalDiags.HasErrors() { + diags = diags.Extend(evalDiags) + return diags + } + // If marked as sensitive, the cty.Value cannot be marshaled in the message pack, + // so only bind it if it is unmarked. + if !val.IsMarked() { + // Even if there is no instance key later, the evaluated result is bound to + // the expression so that it can be referenced by EvaluateExpr. + attr.Expr = hclext.BindValue(val, attr.Expr) + } + } + return diags + }) + + diags = diags.Extend(walkDiags) + blocks[i] = expanded + } + + return blocks, diags +} diff --git a/terraform/lang/data.go b/terraform/lang/data.go index d7bf0eace..ada362aa0 100644 --- a/terraform/lang/data.go +++ b/terraform/lang/data.go @@ -20,6 +20,8 @@ import ( // cases where it's not possible to even determine a suitable result type, // cty.DynamicVal is returned along with errors describing the problem. type Data interface { + GetCountAttr(addrs.CountAttr, hcl.Range) (cty.Value, hcl.Diagnostics) + GetForEachAttr(addrs.ForEachAttr, hcl.Range) (cty.Value, hcl.Diagnostics) GetLocalValue(addrs.LocalValue, hcl.Range) (cty.Value, hcl.Diagnostics) GetPathAttr(addrs.PathAttr, hcl.Range) (cty.Value, hcl.Diagnostics) GetTerraformAttr(addrs.TerraformAttr, hcl.Range) (cty.Value, hcl.Diagnostics) diff --git a/terraform/lang/data_test.go b/terraform/lang/data_test.go index 24a745819..50a15ea23 100644 --- a/terraform/lang/data_test.go +++ b/terraform/lang/data_test.go @@ -7,6 +7,8 @@ import ( ) type dataForTests struct { + CountAttrs map[string]cty.Value + ForEachAttrs map[string]cty.Value LocalValues map[string]cty.Value PathAttrs map[string]cty.Value TerraformAttrs map[string]cty.Value @@ -15,6 +17,14 @@ type dataForTests struct { var _ Data = &dataForTests{} +func (d *dataForTests) GetCountAttr(addr addrs.CountAttr, rng hcl.Range) (cty.Value, hcl.Diagnostics) { + return d.CountAttrs[addr.Name], nil +} + +func (d *dataForTests) GetForEachAttr(addr addrs.ForEachAttr, rng hcl.Range) (cty.Value, hcl.Diagnostics) { + return d.ForEachAttrs[addr.Name], nil +} + func (d *dataForTests) GetInputVariable(addr addrs.InputVariable, rng hcl.Range) (cty.Value, hcl.Diagnostics) { return d.InputVariables[addr.Name], nil } diff --git a/terraform/lang/eval.go b/terraform/lang/eval.go index 0558f2bec..20cfbfcea 100644 --- a/terraform/lang/eval.go +++ b/terraform/lang/eval.go @@ -94,6 +94,8 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl localValues := map[string]cty.Value{} pathAttrs := map[string]cty.Value{} terraformAttrs := map[string]cty.Value{} + countAttrs := map[string]cty.Value{} + forEachAttrs := map[string]cty.Value{} for _, ref := range refs { rng := ref.SourceRange @@ -136,6 +138,16 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl val, valDiags := normalizeRefValue(s.Data.GetTerraformAttr(subj, rng)) diags = diags.Extend(valDiags) terraformAttrs[subj.Name] = val + + case addrs.CountAttr: + val, valDiags := normalizeRefValue(s.Data.GetCountAttr(subj, rng)) + diags = diags.Extend(valDiags) + countAttrs[subj.Name] = val + + case addrs.ForEachAttr: + val, valDiags := normalizeRefValue(s.Data.GetForEachAttr(subj, rng)) + diags = diags.Extend(valDiags) + forEachAttrs[subj.Name] = val } } @@ -150,13 +162,13 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl vals["local"] = cty.ObjectVal(localValues) vals["path"] = cty.ObjectVal(pathAttrs) vals["terraform"] = cty.ObjectVal(terraformAttrs) + vals["count"] = cty.ObjectVal(countAttrs) + vals["each"] = cty.ObjectVal(forEachAttrs) // The following are unknown values as they are not supported by TFLint. vals["resource"] = cty.UnknownVal(cty.DynamicPseudoType) vals["data"] = cty.UnknownVal(cty.DynamicPseudoType) vals["module"] = cty.UnknownVal(cty.DynamicPseudoType) - vals["count"] = cty.UnknownVal(cty.DynamicPseudoType) - vals["each"] = cty.UnknownVal(cty.DynamicPseudoType) vals["self"] = cty.UnknownVal(cty.DynamicPseudoType) return ctx, diags diff --git a/terraform/lang/eval_test.go b/terraform/lang/eval_test.go index e4c41cbc1..569fde1f9 100644 --- a/terraform/lang/eval_test.go +++ b/terraform/lang/eval_test.go @@ -14,6 +14,13 @@ import ( func TestScopeEvalContext(t *testing.T) { data := &dataForTests{ + CountAttrs: map[string]cty.Value{ + "index": cty.NumberIntVal(0), + }, + ForEachAttrs: map[string]cty.Value{ + "key": cty.StringVal("a"), + "value": cty.NumberIntVal(1), + }, LocalValues: map[string]cty.Value{ "foo": cty.StringVal("bar"), }, @@ -36,6 +43,42 @@ func TestScopeEvalContext(t *testing.T) { `12`, map[string]cty.Value{}, }, + { + `count.index`, + map[string]cty.Value{ + "count": cty.ObjectVal(map[string]cty.Value{ + "index": cty.NumberIntVal(0), + }), + "resource": cty.DynamicVal, + "data": cty.DynamicVal, + "module": cty.DynamicVal, + "self": cty.DynamicVal, + }, + }, + { + `each.key`, + map[string]cty.Value{ + "each": cty.ObjectVal(map[string]cty.Value{ + "key": cty.StringVal("a"), + }), + "resource": cty.DynamicVal, + "data": cty.DynamicVal, + "module": cty.DynamicVal, + "self": cty.DynamicVal, + }, + }, + { + `each.value`, + map[string]cty.Value{ + "each": cty.ObjectVal(map[string]cty.Value{ + "value": cty.NumberIntVal(1), + }), + "resource": cty.DynamicVal, + "data": cty.DynamicVal, + "module": cty.DynamicVal, + "self": cty.DynamicVal, + }, + }, { `local.foo`, map[string]cty.Value{ @@ -45,8 +88,6 @@ func TestScopeEvalContext(t *testing.T) { "resource": cty.DynamicVal, "data": cty.DynamicVal, "module": cty.DynamicVal, - "count": cty.DynamicVal, - "each": cty.DynamicVal, "self": cty.DynamicVal, }, }, @@ -57,8 +98,6 @@ func TestScopeEvalContext(t *testing.T) { "resource": cty.DynamicVal, "data": cty.DynamicVal, "module": cty.DynamicVal, - "count": cty.DynamicVal, - "each": cty.DynamicVal, "self": cty.DynamicVal, }, }, @@ -69,8 +108,6 @@ func TestScopeEvalContext(t *testing.T) { "resource": cty.DynamicVal, "data": cty.DynamicVal, "module": cty.DynamicVal, - "count": cty.DynamicVal, - "each": cty.DynamicVal, "self": cty.DynamicVal, }, }, @@ -81,8 +118,6 @@ func TestScopeEvalContext(t *testing.T) { "resource": cty.DynamicVal, "data": cty.DynamicVal, "module": cty.DynamicVal, - "count": cty.DynamicVal, - "each": cty.DynamicVal, "self": cty.DynamicVal, }, }, @@ -93,8 +128,6 @@ func TestScopeEvalContext(t *testing.T) { "resource": cty.DynamicVal, "data": cty.DynamicVal, "module": cty.DynamicVal, - "count": cty.DynamicVal, - "each": cty.DynamicVal, "self": cty.DynamicVal, }, }, @@ -105,8 +138,6 @@ func TestScopeEvalContext(t *testing.T) { "resource": cty.DynamicVal, "data": cty.DynamicVal, "module": cty.DynamicVal, - "count": cty.DynamicVal, - "each": cty.DynamicVal, "self": cty.DynamicVal, }, }, @@ -117,8 +148,6 @@ func TestScopeEvalContext(t *testing.T) { "resource": cty.DynamicVal, "data": cty.DynamicVal, "module": cty.DynamicVal, - "count": cty.DynamicVal, - "each": cty.DynamicVal, "self": cty.DynamicVal, }, }, @@ -129,8 +158,6 @@ func TestScopeEvalContext(t *testing.T) { "resource": cty.DynamicVal, "data": cty.DynamicVal, "module": cty.DynamicVal, - "count": cty.DynamicVal, - "each": cty.DynamicVal, "self": cty.DynamicVal, }, }, @@ -143,8 +170,6 @@ func TestScopeEvalContext(t *testing.T) { "resource": cty.DynamicVal, "data": cty.DynamicVal, "module": cty.DynamicVal, - "count": cty.DynamicVal, - "each": cty.DynamicVal, "self": cty.DynamicVal, }, }, @@ -157,8 +182,6 @@ func TestScopeEvalContext(t *testing.T) { "resource": cty.DynamicVal, "data": cty.DynamicVal, "module": cty.DynamicVal, - "count": cty.DynamicVal, - "each": cty.DynamicVal, "self": cty.DynamicVal, }, }, @@ -171,8 +194,6 @@ func TestScopeEvalContext(t *testing.T) { "resource": cty.DynamicVal, "data": cty.DynamicVal, "module": cty.DynamicVal, - "count": cty.DynamicVal, - "each": cty.DynamicVal, "self": cty.DynamicVal, }, }, diff --git a/terraform/module.go b/terraform/module.go index 8a19ff18b..9f0660b3e 100644 --- a/terraform/module.go +++ b/terraform/module.go @@ -2,7 +2,6 @@ package terraform import ( "fmt" - "log" "strings" "github.com/hashicorp/hcl/v2" @@ -82,7 +81,7 @@ func (m *Module) build() hcl.Diagnostics { // https://www.terraform.io/language/expressions/dynamic-blocks // 2. Supports overriding files // https://www.terraform.io/language/files/override -// 3. Resources not created by count or for_each will be ignored +// 3. Expands resource/module depends on the meta-arguments // https://www.terraform.io/language/meta-arguments/count // https://www.terraform.io/language/meta-arguments/for_each // @@ -123,8 +122,6 @@ func (m *Module) PartialContent(schema *hclext.BodySchema, ctx *Evaluator) (*hcl } // expandBlocks expands resource/module blocks depending on evaluation context. -// Currently, only decrementing block expansions, such as when count is 0 or for_each is empty, -// are supported, not incrementing expansions. func (m *Module) expandBlocks(content *hclext.BodyContent, ctx *Evaluator) (*hclext.BodyContent, hcl.Diagnostics) { out := &hclext.BodyContent{Attributes: content.Attributes} diags := hcl.Diagnostics{} @@ -136,33 +133,19 @@ func (m *Module) expandBlocks(content *hclext.BodyContent, ctx *Evaluator) (*hcl resourceName := block.Labels[1] resource := m.Resources[resourceType][resourceName] - evaluable, evalDiags := ctx.ResourceIsEvaluable(resource) - if evalDiags.HasErrors() { - diags = diags.Extend(evalDiags) - continue - } - - if !evaluable { - log.Printf("[WARN] Skip walking `%s` because it may not be created", resourceType+"."+resourceName) - continue - } + blocks, expandDiags := resource.expandBlock(ctx, block) + diags = diags.Extend(expandDiags) + out.Blocks = append(out.Blocks, blocks...) case "module": name := block.Labels[0] module := m.ModuleCalls[name] - evaluable, evalDiags := ctx.ModuleCallIsEvaluable(module) - if evalDiags.HasErrors() { - diags = diags.Extend(evalDiags) - continue - } - - if !evaluable { - log.Printf("[WARN] Skip walking `module.%s` because it may not be created", name) - continue - } + blocks, expandDiags := module.expandBlock(ctx, block) + diags = diags.Extend(expandDiags) + out.Blocks = append(out.Blocks, blocks...) + default: + out.Blocks = append(out.Blocks, block) } - - out.Blocks = append(out.Blocks, block) } return out, diags diff --git a/terraform/module_call.go b/terraform/module_call.go index 94343b1ee..4e6d336b5 100644 --- a/terraform/module_call.go +++ b/terraform/module_call.go @@ -10,8 +10,7 @@ type ModuleCall struct { Name string SourceAddrRaw string - Count hcl.Expression - ForEach hcl.Expression + expandable DeclRange hcl.Range } diff --git a/terraform/module_test.go b/terraform/module_test.go index cd22d5905..6cd2e8a29 100644 --- a/terraform/module_test.go +++ b/terraform/module_test.go @@ -10,6 +10,8 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/spf13/afero" "github.com/terraform-linters/tflint-plugin-sdk/hclext" + "github.com/terraform-linters/tflint/terraform/lang/marks" + "github.com/zclconf/go-cty/cty" ) func TestPartialContent(t *testing.T) { @@ -145,7 +147,7 @@ locals { }, }, { - name: "contains not created resource", + name: "expand resources", files: map[string]string{ "main.tf": ` resource "aws_instance" "foo" { @@ -153,7 +155,7 @@ resource "aws_instance" "foo" { instance_type = "t2.micro" } resource "aws_instance" "bar" { - count = 1 + count = 2 instance_type = "m5.2xlarge" }`, }, @@ -174,6 +176,16 @@ resource "aws_instance" "bar" { Labels: []string{"aws_instance", "bar"}, Body: &hclext.BodyContent{ Attributes: hclext.Attributes{"instance_type": &hclext.Attribute{Name: "instance_type", Range: hcl.Range{Filename: "main.tf"}}}, + Blocks: hclext.Blocks{}, + }, + DefRange: hcl.Range{Filename: "main.tf"}, + }, + { + Type: "resource", + Labels: []string{"aws_instance", "bar"}, + Body: &hclext.BodyContent{ + Attributes: hclext.Attributes{"instance_type": &hclext.Attribute{Name: "instance_type", Range: hcl.Range{Filename: "main.tf"}}}, + Blocks: hclext.Blocks{}, }, DefRange: hcl.Range{Filename: "main.tf"}, }, @@ -181,15 +193,15 @@ resource "aws_instance" "bar" { }, }, { - name: "contains not created module", + name: "expand modules", files: map[string]string{ "main.tf": ` -module "not_created" { +module "foo" { count = 0 instance_type = "t2.micro" } -module "created" { - count = 1 +module "bar" { + count = 2 instance_type = "m5.2xlarge" }`, }, @@ -207,9 +219,19 @@ module "created" { Blocks: hclext.Blocks{ { Type: "module", - Labels: []string{"created"}, + Labels: []string{"bar"}, Body: &hclext.BodyContent{ Attributes: hclext.Attributes{"instance_type": &hclext.Attribute{Name: "instance_type", Range: hcl.Range{Filename: "main.tf"}}}, + Blocks: hclext.Blocks{}, + }, + DefRange: hcl.Range{Filename: "main.tf"}, + }, + { + Type: "module", + Labels: []string{"bar"}, + Body: &hclext.BodyContent{ + Attributes: hclext.Attributes{"instance_type": &hclext.Attribute{Name: "instance_type", Range: hcl.Range{Filename: "main.tf"}}}, + Blocks: hclext.Blocks{}, }, DefRange: hcl.Range{Filename: "main.tf"}, }, @@ -375,20 +397,30 @@ module "aws_instance" {} config: ` resource "aws_instance" "main" { count = 1 + value = count.index } module "aws_instance" { count = 1 + value = count.index }`, schema: &hclext.BodySchema{ Blocks: []hclext.BlockSchema{ - {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{}}, - {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{}}, + {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, + {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, }, }, want: &hclext.BodyContent{ Blocks: hclext.Blocks{ - {Type: "resource", Labels: []string{"aws_instance", "main"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, - {Type: "module", Labels: []string{"aws_instance"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, + { + Type: "resource", + Labels: []string{"aws_instance", "main"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.NumberIntVal(0), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + }, + { + Type: "module", + Labels: []string{"aws_instance"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.NumberIntVal(0), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + }, }, }, }, @@ -400,20 +432,76 @@ variable "count" { } resource "aws_instance" "main" { count = var.count + value = count.index } module "aws_instance" { count = var.count + value = count.index }`, schema: &hclext.BodySchema{ Blocks: []hclext.BlockSchema{ - {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{}}, - {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{}}, + {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, + {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, }, }, want: &hclext.BodyContent{ Blocks: hclext.Blocks{ - {Type: "resource", Labels: []string{"aws_instance", "main"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, - {Type: "module", Labels: []string{"aws_instance"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, + { + Type: "resource", + Labels: []string{"aws_instance", "main"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.NumberIntVal(0), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + }, + { + Type: "module", + Labels: []string{"aws_instance"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.NumberIntVal(0), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + }, + }, + }, + }, + { + name: "count is greater than 1", + config: ` +resource "aws_instance" "main" { + count = 2 + value = count.index +} +module "aws_instance" { + count = 2 + value = count.index +}`, + schema: &hclext.BodySchema{ + Blocks: []hclext.BlockSchema{ + {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, + {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, + }, + }, + want: &hclext.BodyContent{ + Blocks: hclext.Blocks{ + { + Type: "resource", + Labels: []string{"aws_instance", "main"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.NumberIntVal(0), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + DefRange: hcl.Range{Start: hcl.Pos{Line: 2}}, + }, + { + Type: "resource", + Labels: []string{"aws_instance", "main"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.NumberIntVal(1), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + DefRange: hcl.Range{Start: hcl.Pos{Line: 2}}, + }, + { + Type: "module", + Labels: []string{"aws_instance"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.NumberIntVal(0), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + DefRange: hcl.Range{Start: hcl.Pos{Line: 6}}, + }, + { + Type: "module", + Labels: []string{"aws_instance"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.NumberIntVal(1), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + DefRange: hcl.Range{Start: hcl.Pos{Line: 6}}, + }, }, }, }, @@ -457,8 +545,8 @@ module "aws_instance" { }, want: &hclext.BodyContent{ Blocks: hclext.Blocks{ - {Type: "resource", Labels: []string{"aws_instance", "main"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, - {Type: "module", Labels: []string{"aws_instance"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, + {Type: "resource", Labels: []string{"aws_instance", "main"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}, Blocks: hclext.Blocks{}}}, + {Type: "module", Labels: []string{"aws_instance"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}, Blocks: hclext.Blocks{}}}, }, }, }, @@ -497,25 +585,40 @@ module "aws_instance" { want: &hclext.BodyContent{}, }, { - // HINT: Terraform does not allow null as `count` - name: "count is null", + name: "count.index and sensitive value", config: ` +variable "sensitive" { + sensitive = true + default = "foo" +} resource "aws_instance" "main" { - count = null + count = 1 + value = "${count.index}-${var.sensitive}" } module "aws_instance" { - count = null + count = 1 + value = "${count.index}-${var.sensitive}" }`, schema: &hclext.BodySchema{ Blocks: []hclext.BlockSchema{ - {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{}}, - {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{}}, + {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, + {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, }, }, want: &hclext.BodyContent{ Blocks: hclext.Blocks{ - {Type: "resource", Labels: []string{"aws_instance", "main"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, - {Type: "module", Labels: []string{"aws_instance"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, + { + Type: "resource", + Labels: []string{"aws_instance", "main"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.UnknownVal(cty.String).Mark(marks.Sensitive), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + DefRange: hcl.Range{Start: hcl.Pos{Line: 6}}, + }, + { + Type: "module", + Labels: []string{"aws_instance"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.UnknownVal(cty.String).Mark(marks.Sensitive), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + DefRange: hcl.Range{Start: hcl.Pos{Line: 10}}, + }, }, }, }, @@ -524,20 +627,30 @@ module "aws_instance" { config: ` resource "aws_instance" "main" { for_each = { foo = "bar" } + value = "${each.key}-${each.value}" } module "aws_instance" { for_each = { foo = "bar" } + value = "${each.key}-${each.value}" }`, schema: &hclext.BodySchema{ Blocks: []hclext.BlockSchema{ - {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{}}, - {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{}}, + {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, + {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, }, }, want: &hclext.BodyContent{ Blocks: hclext.Blocks{ - {Type: "resource", Labels: []string{"aws_instance", "main"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, - {Type: "module", Labels: []string{"aws_instance"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, + { + Type: "resource", + Labels: []string{"aws_instance", "main"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.StringVal("foo-bar"), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + }, + { + Type: "module", + Labels: []string{"aws_instance"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.StringVal("foo-bar"), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + }, }, }, }, @@ -549,20 +662,30 @@ variable "for_each" { } resource "aws_instance" "main" { for_each = var.for_each + value = "${each.key}-${each.value}" } module "aws_instance" { for_each = var.for_each + value = "${each.key}-${each.value}" }`, schema: &hclext.BodySchema{ Blocks: []hclext.BlockSchema{ - {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{}}, - {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{}}, + {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, + {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, }, }, want: &hclext.BodyContent{ Blocks: hclext.Blocks{ - {Type: "resource", Labels: []string{"aws_instance", "main"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, - {Type: "module", Labels: []string{"aws_instance"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, + { + Type: "resource", + Labels: []string{"aws_instance", "main"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.StringVal("foo-bar"), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + }, + { + Type: "module", + Labels: []string{"aws_instance"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.StringVal("foo-bar"), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + }, }, }, }, @@ -610,23 +733,79 @@ resource "aws_instance" "main" { known = "known" unknown = module.meta.unknown } + value = [each.key, each.value] } module "aws_instance" { for_each = { known = "known" unknown = module.meta.unknown } + value = [each.key, each.value] }`, schema: &hclext.BodySchema{ Blocks: []hclext.BlockSchema{ - {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{}}, - {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{}}, + {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, + {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, }, }, want: &hclext.BodyContent{ Blocks: hclext.Blocks{ - {Type: "resource", Labels: []string{"aws_instance", "main"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, - {Type: "module", Labels: []string{"aws_instance"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, + { + Type: "resource", + Labels: []string{"aws_instance", "main"}, + Body: &hclext.BodyContent{ + Attributes: hclext.Attributes{ + "value": { + Name: "value", + Expr: hcl.StaticExpr(cty.TupleVal([]cty.Value{cty.StringVal("known"), cty.StringVal("known")}), hcl.Range{}), + }, + }, + Blocks: hclext.Blocks{}, + }, + DefRange: hcl.Range{Start: hcl.Pos{Line: 2}}, + }, + { + Type: "resource", + Labels: []string{"aws_instance", "main"}, + Body: &hclext.BodyContent{ + Attributes: hclext.Attributes{ + "value": { + Name: "value", + Expr: hcl.StaticExpr(cty.TupleVal([]cty.Value{cty.StringVal("unknown"), cty.DynamicVal}), hcl.Range{}), + }, + }, + Blocks: hclext.Blocks{}, + }, + DefRange: hcl.Range{Start: hcl.Pos{Line: 2}}, + }, + { + Type: "module", + Labels: []string{"aws_instance"}, + Body: &hclext.BodyContent{ + Attributes: hclext.Attributes{ + "value": { + Name: "value", + Expr: hcl.StaticExpr(cty.TupleVal([]cty.Value{cty.StringVal("known"), cty.StringVal("known")}), hcl.Range{}), + }, + }, + Blocks: hclext.Blocks{}, + }, + DefRange: hcl.Range{Start: hcl.Pos{Line: 9}}, + }, + { + Type: "module", + Labels: []string{"aws_instance"}, + Body: &hclext.BodyContent{ + Attributes: hclext.Attributes{ + "value": { + Name: "value", + Expr: hcl.StaticExpr(cty.TupleVal([]cty.Value{cty.StringVal("unknown"), cty.DynamicVal}), hcl.Range{}), + }, + }, + Blocks: hclext.Blocks{}, + }, + DefRange: hcl.Range{Start: hcl.Pos{Line: 9}}, + }, }, }, }, @@ -652,20 +831,44 @@ module "aws_instance" { config: ` resource "aws_instance" "main" { for_each = toset(["foo", "bar"]) + value = each.key } module "aws_instance" { for_each = toset(["foo", "bar"]) + value = each.key }`, schema: &hclext.BodySchema{ Blocks: []hclext.BlockSchema{ - {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{}}, - {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{}}, + {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, + {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, }, }, want: &hclext.BodyContent{ Blocks: hclext.Blocks{ - {Type: "resource", Labels: []string{"aws_instance", "main"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, - {Type: "module", Labels: []string{"aws_instance"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, + { + Type: "resource", + Labels: []string{"aws_instance", "main"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.StringVal("foo"), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + DefRange: hcl.Range{Start: hcl.Pos{Line: 2}}, + }, + { + Type: "resource", + Labels: []string{"aws_instance", "main"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.StringVal("bar"), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + DefRange: hcl.Range{Start: hcl.Pos{Line: 2}}, + }, + { + Type: "module", + Labels: []string{"aws_instance"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.StringVal("foo"), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + DefRange: hcl.Range{Start: hcl.Pos{Line: 6}}, + }, + { + Type: "module", + Labels: []string{"aws_instance"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.StringVal("bar"), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + DefRange: hcl.Range{Start: hcl.Pos{Line: 6}}, + }, }, }, }, @@ -687,25 +890,40 @@ module "aws_instance" { want: &hclext.BodyContent{}, }, { - // HINT: Terraform does not allow null as `for_each` - name: "for_each is null", + name: "each.key/each.value and sensitive value", config: ` +variable "sensitive" { + sensitive = true + default = "foo" +} resource "aws_instance" "main" { - for_each = null + for_each = { foo = "bar" } + value = "${each.key}-${each.value}-${var.sensitive}" } module "aws_instance" { - for_each = null + for_each = { foo = "bar" } + value = "${each.key}-${each.value}-${var.sensitive}" }`, schema: &hclext.BodySchema{ Blocks: []hclext.BlockSchema{ - {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{}}, - {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{}}, + {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, + {Type: "module", LabelNames: []string{"name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, }, }, want: &hclext.BodyContent{ Blocks: hclext.Blocks{ - {Type: "resource", Labels: []string{"aws_instance", "main"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, - {Type: "module", Labels: []string{"aws_instance"}, Body: &hclext.BodyContent{Attributes: hclext.Attributes{}}}, + { + Type: "resource", + Labels: []string{"aws_instance", "main"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.UnknownVal(cty.String).Mark(marks.Sensitive), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + DefRange: hcl.Range{Start: hcl.Pos{Line: 6}}, + }, + { + Type: "module", + Labels: []string{"aws_instance"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.UnknownVal(cty.String).Mark(marks.Sensitive), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + DefRange: hcl.Range{Start: hcl.Pos{Line: 10}}, + }, }, }, }, @@ -746,11 +964,37 @@ module "aws_instance" { opts := cmp.Options{ cmpopts.IgnoreFields(hclext.Block{}, "TypeRange", "LabelRanges"), - cmpopts.IgnoreFields(hclext.Attribute{}, "Expr", "NameRange"), + cmpopts.IgnoreFields(hclext.Attribute{}, "NameRange"), cmpopts.IgnoreFields(hcl.Range{}, "Start", "End", "Filename"), cmpopts.SortSlices(func(i, j *hclext.Block) bool { + if i.DefRange.String() == j.DefRange.String() { + ia, iaExists := i.Body.Attributes["value"] + ja, jaExists := j.Body.Attributes["value"] + if iaExists && jaExists { + iv, diags := ia.Expr.Value(nil) + if diags.HasErrors() { + t.Fatal(diags) + } + jv, diags := ja.Expr.Value(nil) + if diags.HasErrors() { + t.Fatal(diags) + } + return iv.GoString() < jv.GoString() + } + } return i.DefRange.String() < j.DefRange.String() }), + cmp.Comparer(func(x, y hcl.Expression) bool { + xv, diags := ctx.EvaluateExpr(x, cty.DynamicPseudoType, EvalDataForNoInstanceKey) + if diags.HasErrors() { + t.Fatal(diags) + } + yv, diags := ctx.EvaluateExpr(y, cty.DynamicPseudoType, EvalDataForNoInstanceKey) + if diags.HasErrors() { + t.Fatal(diags) + } + return xv.RawEquals(yv) + }), } if diff := cmp.Diff(got, test.want, opts); diff != "" { t.Error(diff) diff --git a/terraform/resource.go b/terraform/resource.go index ecb52a934..4c9de88ef 100644 --- a/terraform/resource.go +++ b/terraform/resource.go @@ -6,10 +6,10 @@ import ( ) type Resource struct { - Name string - Type string - Count hcl.Expression - ForEach hcl.Expression + Name string + Type string + + expandable DeclRange hcl.Range TypeRange hcl.Range diff --git a/tflint/issue.go b/tflint/issue.go index 968876dc3..2ce294c65 100644 --- a/tflint/issue.go +++ b/tflint/issue.go @@ -50,7 +50,7 @@ func (issues Issues) Sort() Issues { if iRange.End.Column != jRange.End.Column { return iRange.End.Column > jRange.End.Column } - return issues[i].Rule.Name() < issues[j].Rule.Name() + return issues[i].Message < issues[j].Message }) return issues } diff --git a/tflint/runner.go b/tflint/runner.go index b53df6486..eec464085 100644 --- a/tflint/runner.go +++ b/tflint/runner.go @@ -106,21 +106,18 @@ func NewModuleRunners(parent *Runner) ([]*Runner, error) { if diags.HasErrors() { return runners, diags } - var moduleCallBody *hclext.BodyContent + var moduleCallBodies []*hclext.BodyContent for _, block := range moduleCalls.Blocks { if moduleCall.Name == block.Labels[0] { - moduleCallBody = block.Body + moduleCallBodies = append(moduleCallBodies, block.Body) } } - if moduleCallBody == nil { - // If count is 0 or for_each is empty, the module is absent, so ignore - continue - } - modVars := map[string]*moduleVariable{} - for varName, attribute := range moduleCallBody.Attributes { - if rawVar, exists := cfg.Module.Variables[varName]; exists { - val, diags := parent.Ctx.EvaluateExpr(attribute.Expr, cty.DynamicPseudoType) + for _, body := range moduleCallBodies { + modVars := map[string]*moduleVariable{} + inputs := terraform.InputValues{} + for varName, attribute := range body.Attributes { + val, diags := parent.Ctx.EvaluateExpr(attribute.Expr, cty.DynamicPseudoType, terraform.EvalDataForNoInstanceKey) if diags.HasErrors() { err := fmt.Errorf( "failed to eval an expression in %s:%d; %w", @@ -131,7 +128,7 @@ func NewModuleRunners(parent *Runner) ([]*Runner, error) { log.Printf("[ERROR] %s", err) return runners, err } - rawVar.Default = val + inputs[varName] = &terraform.InputValue{Value: val} if parent.TFConfig.Path.IsRoot() { modVars[varName] = &moduleVariable{ @@ -151,19 +148,19 @@ func NewModuleRunners(parent *Runner) ([]*Runner, error) { } } } - } - runner, err := NewRunner(parent.config, parent.annotations, cfg) - if err != nil { - return runners, err - } - runner.modVars = modVars - runners = append(runners, runner) - moduleRunners, err := NewModuleRunners(runner) - if err != nil { - return runners, err + runner, err := NewRunner(parent.config, parent.annotations, cfg, inputs) + if err != nil { + return runners, err + } + runner.modVars = modVars + runners = append(runners, runner) + moduleRunners, err := NewModuleRunners(runner) + if err != nil { + return runners, err + } + runners = append(runners, moduleRunners...) } - runners = append(runners, moduleRunners...) } return runners, nil diff --git a/tflint/runner_test.go b/tflint/runner_test.go index d1d5c37f6..f95ffc6a8 100644 --- a/tflint/runner_test.go +++ b/tflint/runner_test.go @@ -138,7 +138,7 @@ func Test_NewModuleRunners_nestedModules(t *testing.T) { }) } -func Test_NewModuleRunners_withZeroCount(t *testing.T) { +func Test_NewModuleRunners_withCountForEach(t *testing.T) { withinFixtureDir(t, "module_with_count_for_each", func() { runner := testRunnerWithOsFs(t, moduleConfig()) @@ -147,15 +147,21 @@ func Test_NewModuleRunners_withZeroCount(t *testing.T) { t.Fatalf("Unexpected error occurred: %s", err) } - if len(runners) != 2 { - t.Fatalf("This function must return 2 runners, but returned %d", len(runners)) + if len(runners) != 5 { + t.Fatalf("This function must return 5 runners, but returned %d", len(runners)) } - moduleNames := make([]string, 2) + moduleNames := make([]string, 5) for idx, r := range runners { moduleNames[idx] = r.TFConfig.Path.String() } - expected := []string{"module.count_is_one", "module.for_each_is_not_empty"} + expected := []string{ + "module.count_is_one", + "module.count_is_two", + "module.count_is_two", + "module.for_each_is_not_empty", + "module.for_each_is_not_empty", + } less := func(a, b string) bool { return a < b } if diff := cmp.Diff(moduleNames, expected, cmpopts.SortSlices(less)); diff != "" { t.Fatal(diff) diff --git a/tflint/test-fixtures/module_with_count_for_each/.terraform/modules/modules.json b/tflint/test-fixtures/module_with_count_for_each/.terraform/modules/modules.json index 19c3f22b0..e7bbb2393 100644 --- a/tflint/test-fixtures/module_with_count_for_each/.terraform/modules/modules.json +++ b/tflint/test-fixtures/module_with_count_for_each/.terraform/modules/modules.json @@ -1 +1 @@ -{"Modules":[{"Key":"","Source":"","Dir":"."},{"Key":"count_is_zero","Source":"./module","Dir":"module"},{"Key":"for_each_is_empty","Source":"./module","Dir":"module"},{"Key":"count_is_one","Source":"./module","Dir":"module"},{"Key":"for_each_is_not_empty","Source":"./module","Dir":"module"}]} \ No newline at end of file +{"Modules":[{"Key":"count_is_two","Source":"./module","Dir":"module"},{"Key":"","Source":"","Dir":"."},{"Key":"count_is_zero","Source":"./module","Dir":"module"},{"Key":"for_each_is_empty","Source":"./module","Dir":"module"},{"Key":"count_is_one","Source":"./module","Dir":"module"},{"Key":"for_each_is_not_empty","Source":"./module","Dir":"module"}]} \ No newline at end of file diff --git a/tflint/test-fixtures/module_with_count_for_each/module.tf b/tflint/test-fixtures/module_with_count_for_each/module.tf index 811790930..d26d07b28 100644 --- a/tflint/test-fixtures/module_with_count_for_each/module.tf +++ b/tflint/test-fixtures/module_with_count_for_each/module.tf @@ -17,6 +17,13 @@ module "count_is_one" { instance_type = "t2.micro" } +module "count_is_two" { + source = "./module" + count = var.config != null ? 0 : 2 + + instance_type = "t${count.index}.micro" +} + variable "instance_types" { type = list(string) default = [] @@ -31,7 +38,7 @@ module "for_each_is_empty" { variable "instance_types_with_default" { type = list(string) - default = ["t2.micro"] + default = ["t2.micro", "t3.nano"] } module "for_each_is_not_empty" {