diff --git a/LICENSE-BUSL b/LICENSE-BUSL new file mode 100644 index 000000000..8142708df --- /dev/null +++ b/LICENSE-BUSL @@ -0,0 +1,92 @@ +License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved. +"Business Source License" is a trademark of MariaDB Corporation Ab. + +Parameters + +Licensor: HashiCorp, Inc. +Licensed Work: Terraform Version 1.6.0 or later. The Licensed Work is (c) 2024 + HashiCorp, Inc. +Additional Use Grant: You may make production use of the Licensed Work, provided + Your use does not include offering the Licensed Work to third + parties on a hosted or embedded basis in order to compete with + HashiCorp's paid version(s) of the Licensed Work. For purposes + of this license: + + A "competitive offering" is a Product that is offered to third + parties on a paid basis, including through paid support + arrangements, that significantly overlaps with the capabilities + of HashiCorp's paid version(s) of the Licensed Work. If Your + Product is not a competitive offering when You first make it + generally available, it will not become a competitive offering + later due to HashiCorp releasing a new version of the Licensed + Work with additional capabilities. In addition, Products that + are not provided on a paid basis are not competitive. + + "Product" means software that is offered to end users to manage + in their own environments or offered as a service on a hosted + basis. + + "Embedded" means including the source code or executable code + from the Licensed Work in a competitive offering. "Embedded" + also means packaging the competitive offering in such a way + that the Licensed Work must be accessed or downloaded for the + competitive offering to operate. + + Hosting or using the Licensed Work(s) for internal purposes + within an organization is not considered a competitive + offering. HashiCorp considers your organization to include all + of your affiliates under common control. + + For binding interpretive guidance on using HashiCorp products + under the Business Source License, please visit our FAQ. + (https://www.hashicorp.com/license-faq) +Change Date: Four years from the date the Licensed Work is published. +Change License: MPL 2.0 + +For information about alternative licensing arrangements for the Licensed Work, +please contact licensing@hashicorp.com. + +Notice + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. diff --git a/README.md b/README.md index 0b1f97de3..213bef7ef 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Build Status](https://github.com/terraform-linters/tflint/workflows/build/badge.svg?branch=master)](https://github.com/terraform-linters/tflint/actions) [![GitHub release](https://img.shields.io/github/release/terraform-linters/tflint.svg)](https://github.com/terraform-linters/tflint/releases/latest) [![Terraform Compatibility](https://img.shields.io/badge/terraform-%3E%3D%201.0-blue)](docs/user-guide/compatibility.md) -[![License: MPL 2.0](https://img.shields.io/badge/License-MPL%202.0-blue.svg)](LICENSE) +[![License: MPL 2.0 + BUSL 1.1](https://img.shields.io/badge/License-MPL%202.0%20+%20BUSL%201.1-blue.svg)](#license) [![Go Report Card](https://goreportcard.com/badge/github.com/terraform-linters/tflint)](https://goreportcard.com/report/github.com/terraform-linters/tflint) [![Homebrew](https://img.shields.io/badge/dynamic/json.svg?url=https://formulae.brew.sh/api/formula/tflint.json&query=$.versions.stable&label=homebrew)](https://formulae.brew.sh/formula/tflint) @@ -159,6 +159,14 @@ See [Developer Guide](docs/developer-guide). If you find a security vulnerability, please refer our [security policy](SECURITY.md). +## License + +Please note that although much of this project is licensed under MPL 2.0, some files in the `terraform` package are licensed under BUSL 1.1. + +For the reasons stated above, the executable forms (release binaries) is bound by both licenses. + +See also https://discuss.hashicorp.com/t/hashicorp-projects-changing-license-to-business-source-license-v1-1/57106/7 + ## Stargazers over time [![Stargazers over time](https://starchart.cc/terraform-linters/tflint.svg)](https://starchart.cc/terraform-linters/tflint) diff --git a/docs/user-guide/compatibility.md b/docs/user-guide/compatibility.md index 2e4f0e3a6..9e4324b4c 100644 --- a/docs/user-guide/compatibility.md +++ b/docs/user-guide/compatibility.md @@ -4,6 +4,8 @@ TFLint interprets the [Terraform language](https://developer.hashicorp.com/terra The parser supports Terraform v1.x syntax and semantics. The language compatibility on Terraform v1.x is defined by [Compatibility Promises](https://developer.hashicorp.com/terraform/language/v1-compatibility-promises). TFLint follows this promise. New features are only supported in newer TFLint versions, and bug and experimental features compatibility are not guaranteed. +The latest supported version is Terraform v1.8. + ## Input Variables Like Terraform, TFLint supports the `--var`,` --var-file` options, environment variables (`TF_VAR_*`), and automatically loading variable definitions (`terraform.tfvars` and `*.auto.tfvars`) files. See [Input Variables](https://developer.hashicorp.com/terraform/language/values/variables). @@ -117,9 +119,11 @@ The values below are state-dependent and cannot be determined statically, so TFL - `data..` - `self` -## Built-in Functions +## Functions + +[Built-in Functions](https://developer.hashicorp.com/terraform/language/functions) are fully supported. However, functions such as [`plantimestamp`](https://developer.hashicorp.com/terraform/language/functions/plantimestamp) whose return value cannot be determined statically will return an unknown value. -[Built-in Functions](https://developer.hashicorp.com/terraform/language/functions) are fully supported. +[Provider-defined functions](https://www.hashicorp.com/blog/terraform-1-8-adds-provider-functions-for-aws-google-cloud-and-kubernetes) always return unknown values, except for `provider::terraform::*` functions. ## Dynamic Blocks diff --git a/go.mod b/go.mod index 47f287691..e1fd9b846 100644 --- a/go.mod +++ b/go.mod @@ -28,8 +28,10 @@ require ( github.com/terraform-linters/tflint-ruleset-terraform v0.5.0 github.com/xeipuuv/gojsonschema v1.2.0 github.com/zclconf/go-cty v1.14.4 + github.com/zclconf/go-cty-debug v0.0.0-20240417160409-8c45e122ae1a github.com/zclconf/go-cty-yaml v1.0.3 golang.org/x/crypto v0.22.0 + golang.org/x/net v0.23.0 golang.org/x/oauth2 v0.19.0 golang.org/x/text v0.14.0 google.golang.org/grpc v1.63.2 @@ -81,7 +83,6 @@ require ( go.opentelemetry.io/otel/metric v1.22.0 // indirect go.opentelemetry.io/otel/trace v1.22.0 // indirect golang.org/x/mod v0.12.0 // indirect - golang.org/x/net v0.23.0 // indirect golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.19.0 // indirect golang.org/x/time v0.5.0 // indirect diff --git a/go.sum b/go.sum index 0bb6e8663..75c3575cb 100644 --- a/go.sum +++ b/go.sum @@ -491,8 +491,8 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= -github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b h1:FosyBZYxY34Wul7O/MSKey3txpPYyCqVO5ZyceuQJEI= -github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= +github.com/zclconf/go-cty-debug v0.0.0-20240417160409-8c45e122ae1a h1:/o/Emn22dZIQ7AhyA0aLOKo528WG/WRAM5tqzIoQIOs= +github.com/zclconf/go-cty-debug v0.0.0-20240417160409-8c45e122ae1a/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.0.3 h1:og/eOQ7lvA/WWhHGFETVWNduJM7Rjsv2RRpx1sdFMLc= github.com/zclconf/go-cty-yaml v1.0.3/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= diff --git a/integrationtest/inspection/expand/main.tf b/integrationtest/inspection/expand/main.tf index de552d34b..27d76f566 100644 --- a/integrationtest/inspection/expand/main.tf +++ b/integrationtest/inspection/expand/main.tf @@ -48,3 +48,9 @@ resource "aws_instance" "tags" { sensitive = var.sensitive } } + +resource "aws_instance" "provider_function" { + count = 1 + + instance_type = "${count.index}.${provider::tflint::instance_type()}" +} diff --git a/integrationtest/inspection/functions/.tflint.hcl b/integrationtest/inspection/functions/.tflint.hcl new file mode 100644 index 000000000..e19f589dd --- /dev/null +++ b/integrationtest/inspection/functions/.tflint.hcl @@ -0,0 +1,3 @@ +plugin "testing" { + enabled = true +} diff --git a/integrationtest/inspection/functions/main.tf b/integrationtest/inspection/functions/main.tf new file mode 100644 index 000000000..bb0e14ab3 --- /dev/null +++ b/integrationtest/inspection/functions/main.tf @@ -0,0 +1,15 @@ +resource "aws_instance" "core" { + instance_type = upper("hello") +} + +resource "aws_instance" "core_with_namespace" { + instance_type = core::upper("hello") +} + +resource "aws_instance" "provider" { + instance_type = provider::tflint::instance_type() +} + +resource "aws_instance" "builtin_provider" { + instance_type = provider::terraform::tfvarsencode({ a = 1 }) +} diff --git a/integrationtest/inspection/functions/result.json b/integrationtest/inspection/functions/result.json new file mode 100644 index 000000000..2ddb7e7d4 --- /dev/null +++ b/integrationtest/inspection/functions/result.json @@ -0,0 +1,65 @@ +{ + "issues": [ + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is HELLO", + "range": { + "filename": "main.tf", + "start": { + "line": 2, + "column": 19 + }, + "end": { + "line": 2, + "column": 33 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is HELLO", + "range": { + "filename": "main.tf", + "start": { + "line": 6, + "column": 19 + }, + "end": { + "line": 6, + "column": 39 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is a = 1\n", + "range": { + "filename": "main.tf", + "start": { + "line": 14, + "column": 19 + }, + "end": { + "line": 14, + "column": 63 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integrationtest/inspection/inspection_test.go b/integrationtest/inspection/inspection_test.go index 259cf99cc..a875a639c 100644 --- a/integrationtest/inspection/inspection_test.go +++ b/integrationtest/inspection/inspection_test.go @@ -205,6 +205,11 @@ func TestIntegration(t *testing.T) { Command: "tflint --chdir dir --var-file from_cli.tfvars --format json", Dir: "chdir", }, + { + Name: "functions", + Command: "tflint --format json", + Dir: "functions", + }, } // Disable the bundled plugin because the `os.Executable()` is go(1) in the tests diff --git a/terraform/LICENSE-BUSL b/terraform/LICENSE-BUSL new file mode 100644 index 000000000..8142708df --- /dev/null +++ b/terraform/LICENSE-BUSL @@ -0,0 +1,92 @@ +License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved. +"Business Source License" is a trademark of MariaDB Corporation Ab. + +Parameters + +Licensor: HashiCorp, Inc. +Licensed Work: Terraform Version 1.6.0 or later. The Licensed Work is (c) 2024 + HashiCorp, Inc. +Additional Use Grant: You may make production use of the Licensed Work, provided + Your use does not include offering the Licensed Work to third + parties on a hosted or embedded basis in order to compete with + HashiCorp's paid version(s) of the Licensed Work. For purposes + of this license: + + A "competitive offering" is a Product that is offered to third + parties on a paid basis, including through paid support + arrangements, that significantly overlaps with the capabilities + of HashiCorp's paid version(s) of the Licensed Work. If Your + Product is not a competitive offering when You first make it + generally available, it will not become a competitive offering + later due to HashiCorp releasing a new version of the Licensed + Work with additional capabilities. In addition, Products that + are not provided on a paid basis are not competitive. + + "Product" means software that is offered to end users to manage + in their own environments or offered as a service on a hosted + basis. + + "Embedded" means including the source code or executable code + from the Licensed Work in a competitive offering. "Embedded" + also means packaging the competitive offering in such a way + that the Licensed Work must be accessed or downloaded for the + competitive offering to operate. + + Hosting or using the Licensed Work(s) for internal purposes + within an organization is not considered a competitive + offering. HashiCorp considers your organization to include all + of your affiliates under common control. + + For binding interpretive guidance on using HashiCorp products + under the Business Source License, please visit our FAQ. + (https://www.hashicorp.com/license-faq) +Change Date: Four years from the date the Licensed Work is published. +Change License: MPL 2.0 + +For information about alternative licensing arrangements for the Licensed Work, +please contact licensing@hashicorp.com. + +Notice + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. diff --git a/terraform/evaluator_test.go b/terraform/evaluator_test.go index 3b5fdbfbf..a2a0b8e36 100644 --- a/terraform/evaluator_test.go +++ b/terraform/evaluator_test.go @@ -142,6 +142,27 @@ variable "string_var" { want: `cty.StringVal("acbd18db4cc2f85cedef654fccc4a4d8")`, errCheck: neverHappend, }, + { + name: "built-in function with namespace", + expr: expr(`core::md5("foo")`), + ty: cty.String, + want: `cty.StringVal("acbd18db4cc2f85cedef654fccc4a4d8")`, + errCheck: neverHappend, + }, + { + name: "provider-defined functions", + expr: expr(`provider::tflint::echo("Hello", "World!")`), + ty: cty.String, + want: `cty.UnknownVal(cty.String)`, + errCheck: neverHappend, + }, + { + name: "built-in provider-defined functions", + expr: expr(`provider::terraform::tfvarsdecode("a = 1")`), + ty: cty.Object(map[string]cty.Type{"a": cty.Number}), + want: `cty.ObjectVal(map[string]cty.Value{"a":cty.NumberIntVal(1)})`, + errCheck: neverHappend, + }, { name: "terraform workspace", expr: expr(`terraform.workspace`), @@ -928,6 +949,19 @@ resource "aws_instance" "main" { config: ` resource "aws_instance" "main" { count = module.meta.count +}`, + schema: &hclext.BodySchema{ + Blocks: []hclext.BlockSchema{ + {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{}}, + }, + }, + want: &hclext.BodyContent{Attributes: hclext.Attributes{}, Blocks: hclext.Blocks{}}, + }, + { + name: "count is using provider-defined functions", + config: ` +resource "aws_instance" "main" { + count = provider::tflint::count() }`, schema: &hclext.BodySchema{ Blocks: []hclext.BlockSchema{ @@ -1021,6 +1055,29 @@ resource "aws_instance" "main" { }, }, }, + { + name: "count.index and provider-defined functions", + config: ` +resource "aws_instance" "main" { + count = 1 + value = [count.index, provider::tflint::sum(1, 2, 3)] +}`, + schema: &hclext.BodySchema{ + Blocks: []hclext.BlockSchema{ + {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}}, + }, + }, + want: &hclext.BodyContent{ + Attributes: hclext.Attributes{}, + Blocks: hclext.Blocks{ + { + Type: "resource", + Labels: []string{"aws_instance", "main"}, + Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.TupleVal([]cty.Value{cty.NumberIntVal(0), cty.DynamicVal}), hcl.Range{})}}, Blocks: hclext.Blocks{}}, + }, + }, + }, + }, { name: "for_each is not empty (literal)", config: ` @@ -1086,10 +1143,23 @@ resource "aws_instance" "main" { want: &hclext.BodyContent{Attributes: hclext.Attributes{}, Blocks: hclext.Blocks{}}, }, { - name: "for_each is evaluable", + name: "for_each is unevaluable", config: ` resource "aws_instance" "main" { for_each = module.meta.for_each +}`, + schema: &hclext.BodySchema{ + Blocks: []hclext.BlockSchema{ + {Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{}}, + }, + }, + want: &hclext.BodyContent{Attributes: hclext.Attributes{}, Blocks: hclext.Blocks{}}, + }, + { + name: "for_each is using provider-defined functions", + config: ` +resource "aws_instance" "main" { + for_each = provider::tflint::for_each() }`, schema: &hclext.BodySchema{ Blocks: []hclext.BlockSchema{ @@ -1415,6 +1485,47 @@ resource "aws_instance" "main" { value = "${ebs_block_device.key}-${ebs_block_device.value}" } } +}`, + schema: &hclext.BodySchema{ + Blocks: []hclext.BlockSchema{ + { + Type: "resource", + LabelNames: []string{"type", "name"}, + Body: &hclext.BodySchema{ + Blocks: []hclext.BlockSchema{ + { + Type: "ebs_block_device", + Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}, + }, + }, + }, + }, + }, + }, + want: &hclext.BodyContent{ + Attributes: hclext.Attributes{}, + Blocks: hclext.Blocks{ + { + Type: "resource", + Labels: []string{"aws_instance", "main"}, + Body: &hclext.BodyContent{ + Attributes: hclext.Attributes{}, + Blocks: hclext.Blocks{}, + }, + }, + }, + }, + }, + { + name: "dynamic blocks with provider-defined functions", + config: ` +resource "aws_instance" "main" { + dynamic "ebs_block_device" { + for_each = provider::tflint::for_each() + content { + value = "${ebs_block_device.key}-${ebs_block_device.value}" + } + } }`, schema: &hclext.BodySchema{ Blocks: []hclext.BlockSchema{ diff --git a/terraform/lang/eval.go b/terraform/lang/eval.go index 44a0ad982..fbde08a77 100644 --- a/terraform/lang/eval.go +++ b/terraform/lang/eval.go @@ -16,13 +16,21 @@ import ( // Note that Terraform only expands dynamic blocks, but TFLint also expands // count/for_each here. // -// Expressions in expanded blocks are evaluated immediately, so all variables -// contained in attributes specified in the body schema are gathered. +// Expressions in expanded blocks are evaluated immediately, so all variables and +// function calls contained in attributes specified in the body schema are gathered. func (s *Scope) ExpandBlock(body hcl.Body, schema *hclext.BodySchema) (hcl.Body, hcl.Diagnostics) { traversals := tfhcl.ExpandVariablesHCLExt(body, schema) refs, diags := References(traversals) - ctx, ctxDiags := s.EvalContext(refs) + exprs := tfhcl.ExpandExpressionsHCLExt(body, schema) + funcCalls := []*FunctionCall{} + for _, expr := range exprs { + calls, funcDiags := FunctionCallsInExpr(expr) + diags = diags.Extend(funcDiags) + funcCalls = append(funcCalls, calls...) + } + + ctx, ctxDiags := s.EvalContext(refs, funcCalls) diags = diags.Extend(ctxDiags) return tfhcl.Expand(body, ctx), diags @@ -40,8 +48,10 @@ func (s *Scope) ExpandBlock(body hcl.Body, schema *hclext.BodySchema) (hcl.Body, // incomplete, but will always be of the requested type. func (s *Scope) EvalExpr(expr hcl.Expression, wantType cty.Type) (cty.Value, hcl.Diagnostics) { refs, diags := ReferencesInExpr(expr) + funcCalls, funcDiags := FunctionCallsInExpr(expr) + diags = diags.Extend(funcDiags) - ctx, ctxDiags := s.EvalContext(refs) + ctx, ctxDiags := s.EvalContext(refs, funcCalls) diags = diags.Extend(ctxDiags) if diags.HasErrors() { // We'll stop early if we found problems in the references, because @@ -72,16 +82,17 @@ func (s *Scope) EvalExpr(expr hcl.Expression, wantType cty.Type) (cty.Value, hcl } // EvalContext constructs a HCL expression evaluation context whose variable -// scope contains sufficient values to satisfy the given set of references. +// scope contains sufficient values to satisfy the given set of references +// and function calls. // // Most callers should prefer to use the evaluation helper methods that // this type offers, but this is here for less common situations where the // caller will handle the evaluation calls itself. -func (s *Scope) EvalContext(refs []*addrs.Reference) (*hcl.EvalContext, hcl.Diagnostics) { - return s.evalContext(refs, s.SelfAddr) +func (s *Scope) EvalContext(refs []*addrs.Reference, funcCalls []*FunctionCall) (*hcl.EvalContext, hcl.Diagnostics) { + return s.evalContext(refs, s.SelfAddr, funcCalls) } -func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceable) (*hcl.EvalContext, hcl.Diagnostics) { +func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceable, funcCalls []*FunctionCall) (*hcl.EvalContext, hcl.Diagnostics) { if s == nil { panic("attempt to construct EvalContext for nil Scope") } @@ -89,6 +100,20 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl var diags hcl.Diagnostics vals := make(map[string]cty.Value) funcs := s.Functions() + // Provider-defined functions introduced in Terraform v1.8 cannot be + // evaluated statically in many cases. Here, we avoid the error by dynamically + // generating an evaluation context in which the provider-defined functions + // in the given expression are replaced with mock functions. + for _, call := range funcCalls { + if !call.IsProviderDefined() { + continue + } + // Some provider-defined functions are supported, + // so only generate mocks for undefined functions + if _, exists := funcs[call.Name]; !exists { + funcs[call.Name] = NewMockFunction(call) + } + } ctx := &hcl.EvalContext{ Variables: vals, Functions: funcs, diff --git a/terraform/lang/eval_test.go b/terraform/lang/eval_test.go index 569fde1f9..0a6f928c4 100644 --- a/terraform/lang/eval_test.go +++ b/terraform/lang/eval_test.go @@ -215,10 +215,15 @@ func TestScopeEvalContext(t *testing.T) { t.Fatal(refsDiags) } + funcCalls, funcDiags := FunctionCallsInExpr(expr) + if funcDiags.HasErrors() { + t.Fatal(funcDiags) + } + scope := &Scope{ Data: data, } - ctx, ctxDiags := scope.EvalContext(refs) + ctx, ctxDiags := scope.EvalContext(refs, funcCalls) if ctxDiags.HasErrors() { t.Fatal(ctxDiags) } diff --git a/terraform/lang/funcs/cidr.go b/terraform/lang/funcs/cidr.go index 8103680c8..f486d501a 100644 --- a/terraform/lang/funcs/cidr.go +++ b/terraform/lang/funcs/cidr.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package funcs import ( @@ -24,7 +27,8 @@ var CidrHostFunc = function.New(&function.Spec{ Type: cty.Number, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { var hostNum *big.Int if err := gocty.FromCtyValue(args[1], &hostNum); err != nil { @@ -53,7 +57,8 @@ var CidrNetmaskFunc = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { _, network, err := ipaddr.ParseCIDR(args[0].AsString()) if err != nil { @@ -85,7 +90,8 @@ var CidrSubnetFunc = function.New(&function.Spec{ Type: cty.Number, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { var newbits int if err := gocty.FromCtyValue(args[1], &newbits); err != nil { @@ -123,7 +129,8 @@ var CidrSubnetsFunc = function.New(&function.Spec{ Name: "newbits", Type: cty.Number, }, - Type: function.StaticReturnType(cty.List(cty.String)), + Type: function.StaticReturnType(cty.List(cty.String)), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { _, network, err := ipaddr.ParseCIDR(args[0].AsString()) if err != nil { diff --git a/terraform/lang/funcs/collection.go b/terraform/lang/funcs/collection.go index 2b772f35c..b6cf0ad10 100644 --- a/terraform/lang/funcs/collection.go +++ b/terraform/lang/funcs/collection.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package funcs import ( @@ -32,6 +35,7 @@ var LengthFunc = function.New(&function.Spec{ return cty.Number, errors.New("argument must be a string, a collection type, or a structural type") } }, + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { coll := args[0] collTy := args[0].Type() @@ -68,7 +72,8 @@ var AllTrueFunc = function.New(&function.Spec{ Type: cty.List(cty.Bool), }, }, - Type: function.StaticReturnType(cty.Bool), + Type: function.StaticReturnType(cty.Bool), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { result := cty.True for it := args[0].ElementIterator(); it.Next(); { @@ -97,7 +102,8 @@ var AnyTrueFunc = function.New(&function.Spec{ Type: cty.List(cty.Bool), }, }, - Type: function.StaticReturnType(cty.Bool), + Type: function.StaticReturnType(cty.Bool), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { result := cty.False var hasUnknown bool @@ -135,6 +141,7 @@ var CoalesceFunc = function.New(&function.Spec{ AllowDynamicType: true, AllowNull: true, }, + RefineResult: refineNotNull, Type: func(args []cty.Value) (ret cty.Type, err error) { argTypes := make([]cty.Type, len(args)) for i, val := range args { @@ -178,7 +185,8 @@ var IndexFunc = function.New(&function.Spec{ Type: cty.DynamicPseudoType, }, }, - Type: function.StaticReturnType(cty.Number), + Type: function.StaticReturnType(cty.Number), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { if !(args[0].Type().IsListType() || args[0].Type().IsTupleType()) { return cty.NilVal, errors.New("argument must be a list or tuple") @@ -343,6 +351,7 @@ var MatchkeysFunc = function.New(&function.Spec{ // the return type is based on args[0] (values) return args[0].Type(), nil }, + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { if !args[0].IsKnown() { return cty.UnknownVal(cty.List(retType.ElementType())), nil @@ -486,7 +495,8 @@ var SumFunc = function.New(&function.Spec{ Type: cty.DynamicPseudoType, }, }, - Type: function.StaticReturnType(cty.Number), + Type: function.StaticReturnType(cty.Number), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { if !args[0].CanIterateElements() { @@ -555,7 +565,8 @@ var TransposeFunc = function.New(&function.Spec{ Type: cty.Map(cty.List(cty.String)), }, }, - Type: function.StaticReturnType(cty.Map(cty.List(cty.String))), + Type: function.StaticReturnType(cty.Map(cty.List(cty.String))), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { inputMap := args[0] if !inputMap.IsWhollyKnown() { @@ -671,7 +682,8 @@ func Index(list, value cty.Value) (cty.Value, error) { } // List takes any number of list arguments and returns a list containing those -// values in the same order. +// +// values in the same order. func List(args ...cty.Value) (cty.Value, error) { return ListFunc.Call(args) } diff --git a/terraform/lang/funcs/collection_test.go b/terraform/lang/funcs/collection_test.go index ff7814650..422e67e2b 100644 --- a/terraform/lang/funcs/collection_test.go +++ b/terraform/lang/funcs/collection_test.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package funcs import ( @@ -68,11 +71,15 @@ func TestLength(t *testing.T) { }, { cty.UnknownVal(cty.List(cty.Bool)), - cty.UnknownVal(cty.Number).Refine().NotNull().NumberRangeInclusive(cty.NumberIntVal(0), cty.NumberIntVal(math.MaxInt64)).NewValue(), + cty.UnknownVal(cty.Number).Refine(). + NotNull(). + NumberRangeLowerBound(cty.Zero, true). + NumberRangeUpperBound(cty.NumberIntVal(math.MaxInt), true). + NewValue(), }, { cty.DynamicVal, - cty.UnknownVal(cty.Number), + cty.UnknownVal(cty.Number).RefineNotNull(), }, { cty.StringVal("hello"), @@ -117,11 +124,10 @@ func TestLength(t *testing.T) { }, { cty.UnknownVal(cty.String), - cty.UnknownVal(cty.Number).Refine().NotNull().NumberRangeLowerBound(cty.NumberIntVal(0), true).NewValue(), - }, - { - cty.DynamicVal, - cty.UnknownVal(cty.Number), + cty.UnknownVal(cty.Number).Refine(). + NotNull(). + NumberRangeLowerBound(cty.Zero, true). + NewValue(), }, { // Marked collections return a marked length cty.ListVal([]cty.Value{ @@ -226,7 +232,7 @@ func TestAllTrue(t *testing.T) { }, { cty.ListVal([]cty.Value{cty.UnknownVal(cty.Bool)}), - cty.UnknownVal(cty.Bool), + cty.UnknownVal(cty.Bool).RefineNotNull(), false, }, { @@ -234,12 +240,12 @@ func TestAllTrue(t *testing.T) { cty.UnknownVal(cty.Bool), cty.UnknownVal(cty.Bool), }), - cty.UnknownVal(cty.Bool), + cty.UnknownVal(cty.Bool).RefineNotNull(), false, }, { cty.UnknownVal(cty.List(cty.Bool)), - cty.UnknownVal(cty.Bool), + cty.UnknownVal(cty.Bool).RefineNotNull(), false, }, { @@ -307,7 +313,7 @@ func TestAnyTrue(t *testing.T) { }, { cty.ListVal([]cty.Value{cty.UnknownVal(cty.Bool)}), - cty.UnknownVal(cty.Bool), + cty.UnknownVal(cty.Bool).RefineNotNull(), false, }, { @@ -315,7 +321,7 @@ func TestAnyTrue(t *testing.T) { cty.UnknownVal(cty.Bool), cty.False, }), - cty.UnknownVal(cty.Bool), + cty.UnknownVal(cty.Bool).RefineNotNull(), false, }, { @@ -328,7 +334,7 @@ func TestAnyTrue(t *testing.T) { }, { cty.UnknownVal(cty.List(cty.Bool)), - cty.UnknownVal(cty.Bool), + cty.UnknownVal(cty.Bool).RefineNotNull(), false, }, { @@ -406,17 +412,17 @@ func TestCoalesce(t *testing.T) { }, { []cty.Value{cty.UnknownVal(cty.Bool), cty.True}, - cty.UnknownVal(cty.Bool), + cty.UnknownVal(cty.Bool).RefineNotNull(), false, }, { []cty.Value{cty.UnknownVal(cty.Bool), cty.StringVal("hello")}, - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), false, }, { []cty.Value{cty.DynamicVal, cty.True}, - cty.UnknownVal(cty.Bool), + cty.UnknownVal(cty.Bool).RefineNotNull(), false, }, { @@ -1062,7 +1068,7 @@ func TestMatchkeys(t *testing.T) { cty.ListVal([]cty.Value{ cty.StringVal("ref1"), }), - cty.UnknownVal(cty.List(cty.String)), + cty.UnknownVal(cty.List(cty.String)).RefineNotNull(), false, }, { // different types that can be unified @@ -1526,7 +1532,7 @@ func TestSum(t *testing.T) { cty.StringVal("b"), cty.StringVal("c"), }), - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), "argument must be list, set, or tuple of number values", }, { @@ -1580,7 +1586,7 @@ func TestSum(t *testing.T) { cty.StringVal("a"), cty.NumberIntVal(38), }), - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), "argument must be list, set, or tuple of number values", }, { @@ -1600,17 +1606,17 @@ func TestSum(t *testing.T) { }, { cty.UnknownVal(cty.Number), - cty.UnknownVal(cty.Number), + cty.UnknownVal(cty.Number).RefineNotNull(), "", }, { cty.UnknownVal(cty.List(cty.Number)), - cty.UnknownVal(cty.Number), + cty.UnknownVal(cty.Number).RefineNotNull(), "", }, { // known list containing unknown values cty.ListVal([]cty.Value{cty.UnknownVal(cty.Number)}), - cty.UnknownVal(cty.Number), + cty.UnknownVal(cty.Number).RefineNotNull(), "", }, { // numbers too large to represent as float64 @@ -1704,7 +1710,7 @@ func TestTranspose(t *testing.T) { cty.MapVal(map[string]cty.Value{ "key1": cty.UnknownVal(cty.List(cty.String)), }), - cty.UnknownVal(cty.Map(cty.List(cty.String))), + cty.UnknownVal(cty.Map(cty.List(cty.String))).RefineNotNull(), false, }, { // bad map - empty value diff --git a/terraform/lang/funcs/crypto.go b/terraform/lang/funcs/crypto.go index 7c4ba4ada..0b7bef984 100644 --- a/terraform/lang/funcs/crypto.go +++ b/terraform/lang/funcs/crypto.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package funcs import ( @@ -24,8 +27,9 @@ import ( ) var UUIDFunc = function.New(&function.Spec{ - Params: []function.Parameter{}, - Type: function.StaticReturnType(cty.String), + Params: []function.Parameter{}, + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { result, err := uuid.GenerateUUID() if err != nil { @@ -46,7 +50,8 @@ var UUIDV5Func = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { var namespace uuidv5.UUID switch { @@ -100,7 +105,8 @@ var BcryptFunc = function.New(&function.Spec{ Name: "cost", Type: cty.Number, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { defaultCost := 10 @@ -147,7 +153,8 @@ var RsaDecryptFunc = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { s := args[0].AsString() key := args[1].AsString() @@ -222,7 +229,8 @@ func makeStringHashFunction(hf func() hash.Hash, enc func([]byte) string) functi Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { s := args[0].AsString() h := hf() @@ -241,7 +249,8 @@ func makeFileHashFunction(baseDir string, hf func() hash.Hash, enc func([]byte) Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { path := args[0].AsString() f, err := openFile(baseDir, path) diff --git a/terraform/lang/funcs/datetime.go b/terraform/lang/funcs/datetime.go index 373d12795..b6df93051 100644 --- a/terraform/lang/funcs/datetime.go +++ b/terraform/lang/funcs/datetime.go @@ -13,8 +13,9 @@ import ( // TimestampFunc constructs a function that returns a string representation of the current date and time. var TimestampFunc = function.New(&function.Spec{ - Params: []function.Parameter{}, - Type: function.StaticReturnType(cty.String), + Params: []function.Parameter{}, + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { return cty.StringVal(time.Now().UTC().Format(time.RFC3339)), nil }, @@ -42,7 +43,8 @@ var TimeAddFunc = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { ts, err := parseTimestamp(args[0].AsString()) if err != nil { @@ -69,7 +71,8 @@ var TimeCmpFunc = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.Number), + Type: function.StaticReturnType(cty.Number), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { tsA, err := parseTimestamp(args[0].AsString()) if err != nil { diff --git a/terraform/lang/funcs/datetime_test.go b/terraform/lang/funcs/datetime_test.go index f20e59bfa..c792aa5fc 100644 --- a/terraform/lang/funcs/datetime_test.go +++ b/terraform/lang/funcs/datetime_test.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package funcs import ( @@ -53,13 +56,13 @@ func TestTimeadd(t *testing.T) { { // Invalid format timestamp cty.StringVal("2017-11-22"), cty.StringVal("-1h"), - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), true, }, { // Invalid format duration (day is not supported by ParseDuration) cty.StringVal("2017-11-22T00:00:00Z"), cty.StringVal("1d"), - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), true, }, } @@ -129,31 +132,31 @@ func TestTimeCmp(t *testing.T) { { cty.StringVal("2017-11-22T00:00:00Z"), cty.StringVal("bloop"), - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), `not a valid RFC3339 timestamp: cannot use "bloop" as year`, }, { cty.StringVal("2017-11-22 00:00:00Z"), cty.StringVal("2017-11-22T00:00:00Z"), - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), `not a valid RFC3339 timestamp: missing required time introducer 'T'`, }, { cty.StringVal("2017-11-22T00:00:00Z"), cty.UnknownVal(cty.String), - cty.UnknownVal(cty.Number), + cty.UnknownVal(cty.Number).RefineNotNull(), ``, }, { cty.UnknownVal(cty.String), cty.StringVal("2017-11-22T00:00:00Z"), - cty.UnknownVal(cty.Number), + cty.UnknownVal(cty.Number).RefineNotNull(), ``, }, { cty.UnknownVal(cty.String), cty.UnknownVal(cty.String), - cty.UnknownVal(cty.Number), + cty.UnknownVal(cty.Number).RefineNotNull(), ``, }, } diff --git a/terraform/lang/funcs/encoding.go b/terraform/lang/funcs/encoding.go index 2e67ebc8b..8001fe97d 100644 --- a/terraform/lang/funcs/encoding.go +++ b/terraform/lang/funcs/encoding.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package funcs import ( @@ -23,7 +26,8 @@ var Base64DecodeFunc = function.New(&function.Spec{ AllowMarked: true, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { str, strMarks := args[0].Unmark() s := str.AsString() @@ -47,7 +51,8 @@ var Base64EncodeFunc = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { return cty.StringVal(base64.StdEncoding.EncodeToString([]byte(args[0].AsString()))), nil }, @@ -65,7 +70,8 @@ var TextEncodeBase64Func = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { encoding, err := ianaindex.IANA.Encoding(args[1].AsString()) if err != nil || encoding == nil { @@ -108,7 +114,8 @@ var TextDecodeBase64Func = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { encoding, err := ianaindex.IANA.Encoding(args[1].AsString()) if err != nil || encoding == nil { @@ -151,7 +158,8 @@ var Base64GzipFunc = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { s := args[0].AsString() @@ -178,7 +186,8 @@ var URLEncodeFunc = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { return cty.StringVal(url.QueryEscape(args[0].AsString())), nil }, diff --git a/terraform/lang/funcs/encoding_test.go b/terraform/lang/funcs/encoding_test.go index 625eb8c8a..c121c69c7 100644 --- a/terraform/lang/funcs/encoding_test.go +++ b/terraform/lang/funcs/encoding_test.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package funcs import ( @@ -232,25 +235,25 @@ func TestBase64TextEncode(t *testing.T) { { cty.StringVal("abc123!?$*&()'-=@~"), cty.StringVal("NOT-EXISTS"), - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), `"NOT-EXISTS" is not a supported IANA encoding name or alias in this Terraform version`, }, { cty.StringVal("🤔"), cty.StringVal("cp437"), - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), `the given string contains characters that cannot be represented in IBM437`, }, { cty.UnknownVal(cty.String), cty.StringVal("windows-1250"), - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), ``, }, { cty.StringVal("hello world"), cty.UnknownVal(cty.String), - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), ``, }, } @@ -306,13 +309,13 @@ func TestBase64TextDecode(t *testing.T) { { cty.StringVal("doesn't matter"), cty.StringVal("NOT-EXISTS"), - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), `"NOT-EXISTS" is not a supported IANA encoding name or alias in this Terraform version`, }, { cty.StringVal(""), cty.StringVal("cp437"), - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), `the given value is has an invalid base64 symbol at offset 0`, }, { @@ -324,13 +327,13 @@ func TestBase64TextDecode(t *testing.T) { { cty.UnknownVal(cty.String), cty.StringVal("windows-1250"), - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), ``, }, { cty.StringVal("YQBiAGMAMQAyADMAIQA/ACQAKgAmACgAKQAnAC0APQBAAH4A"), cty.UnknownVal(cty.String), - cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String).RefineNotNull(), ``, }, } diff --git a/terraform/lang/funcs/filesystem.go b/terraform/lang/funcs/filesystem.go index e5de7907c..5447d698e 100644 --- a/terraform/lang/funcs/filesystem.go +++ b/terraform/lang/funcs/filesystem.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + package funcs import ( @@ -28,7 +31,8 @@ func MakeFileFunc(baseDir string, encBase64 bool) function.Function { AllowMarked: true, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { pathArg, pathMarks := args[0].Unmark() path := pathArg.AsString() @@ -131,7 +135,7 @@ func MakeTemplateFileFunc(baseDir string, funcsCb func() map[string]function.Fun givenFuncs := funcsCb() // this callback indirection is to avoid chicken/egg problems funcs := make(map[string]function.Function, len(givenFuncs)) for name, fn := range givenFuncs { - if name == "templatefile" { + if name == "templatefile" || name == "core::templatefile" { // We stub this one out to prevent recursive calls. funcs[name] = function.New(&function.Spec{ Params: params, @@ -198,7 +202,8 @@ func MakeFileExistsFunc(baseDir string) function.Function { AllowMarked: true, }, }, - Type: function.StaticReturnType(cty.Bool), + Type: function.StaticReturnType(cty.Bool), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { pathArg, pathMarks := args[0].Unmark() path := pathArg.AsString() @@ -270,7 +275,8 @@ func MakeFileSetFunc(baseDir string) function.Function { AllowMarked: true, }, }, - Type: function.StaticReturnType(cty.Set(cty.String)), + Type: function.StaticReturnType(cty.Set(cty.String)), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { pathArg, pathMarks := args[0].Unmark() path := pathArg.AsString() @@ -337,7 +343,8 @@ var BasenameFunc = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { return cty.StringVal(filepath.Base(args[0].AsString())), nil }, @@ -352,7 +359,8 @@ var DirnameFunc = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { return cty.StringVal(filepath.Dir(args[0].AsString())), nil }, @@ -366,7 +374,8 @@ var AbsPathFunc = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { absPath, err := filepath.Abs(args[0].AsString()) return cty.StringVal(filepath.ToSlash(absPath)), err @@ -381,7 +390,8 @@ var PathExpandFunc = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { homePath, err := homedir.Expand(args[0].AsString()) diff --git a/terraform/lang/funcs/filesystem_test.go b/terraform/lang/funcs/filesystem_test.go index ec694be80..d3559eac7 100644 --- a/terraform/lang/funcs/filesystem_test.go +++ b/terraform/lang/funcs/filesystem_test.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + package funcs import ( @@ -149,6 +152,12 @@ func TestTemplateFile(t *testing.T) { cty.NilVal, `testdata/recursive.tmpl:1,3-16: Error in function call; Call to function "templatefile" failed: cannot recursively call templatefile from inside templatefile call.`, }, + { + cty.StringVal("testdata/recursive_namespaced.tmpl"), + cty.MapValEmpty(cty.String), + cty.NilVal, + `testdata/recursive_namespaced.tmpl:1,3-22: Error in function call; Call to function "core::templatefile" failed: cannot recursively call templatefile from inside templatefile call.`, + }, { cty.StringVal("testdata/list.tmpl"), cty.ObjectVal(map[string]cty.Value{ @@ -181,8 +190,10 @@ func TestTemplateFile(t *testing.T) { templateFileFn := MakeTemplateFileFunc(".", func() map[string]function.Function { return map[string]function.Function{ - "join": stdlib.JoinFunc, - "templatefile": MakeFileFunc(".", false), // just a placeholder, since templatefile itself overrides this + "join": stdlib.JoinFunc, + "core::join": stdlib.JoinFunc, + "templatefile": MakeFileFunc(".", false), // just a placeholder, since templatefile itself overrides this + "core::templatefile": MakeFileFunc(".", false), // just a placeholder, since templatefile itself overrides this } }) diff --git a/terraform/lang/funcs/number.go b/terraform/lang/funcs/number.go index d95870610..8a8f1410f 100644 --- a/terraform/lang/funcs/number.go +++ b/terraform/lang/funcs/number.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package funcs import ( @@ -21,7 +24,8 @@ var LogFunc = function.New(&function.Spec{ Type: cty.Number, }, }, - Type: function.StaticReturnType(cty.Number), + Type: function.StaticReturnType(cty.Number), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { var num float64 if err := gocty.FromCtyValue(args[0], &num); err != nil { @@ -49,7 +53,8 @@ var PowFunc = function.New(&function.Spec{ Type: cty.Number, }, }, - Type: function.StaticReturnType(cty.Number), + Type: function.StaticReturnType(cty.Number), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { var num float64 if err := gocty.FromCtyValue(args[0], &num); err != nil { @@ -74,7 +79,8 @@ var SignumFunc = function.New(&function.Spec{ Type: cty.Number, }, }, - Type: function.StaticReturnType(cty.Number), + Type: function.StaticReturnType(cty.Number), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { var num int if err := gocty.FromCtyValue(args[0], &num); err != nil { @@ -112,6 +118,7 @@ var ParseIntFunc = function.New(&function.Spec{ } return cty.Number, nil }, + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { var numstr string diff --git a/terraform/lang/funcs/refinements.go b/terraform/lang/funcs/refinements.go new file mode 100644 index 000000000..1ae17e007 --- /dev/null +++ b/terraform/lang/funcs/refinements.go @@ -0,0 +1,9 @@ +package funcs + +import ( + "github.com/zclconf/go-cty/cty" +) + +func refineNotNull(b *cty.RefinementBuilder) *cty.RefinementBuilder { + return b.NotNull() +} diff --git a/terraform/lang/funcs/sensitive.go b/terraform/lang/funcs/sensitive.go index ec01c442e..905fa3d6c 100644 --- a/terraform/lang/funcs/sensitive.go +++ b/terraform/lang/funcs/sensitive.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + package funcs import ( @@ -49,15 +52,30 @@ var NonsensitiveFunc = function.New(&function.Spec{ return args[0].Type(), nil }, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { - if args[0].IsKnown() && !args[0].HasMark(marks.Sensitive) { - return cty.DynamicVal, function.NewArgErrorf(0, "the given value is not sensitive, so this call is redundant") - } v, m := args[0].Unmark() delete(m, marks.Sensitive) // remove the sensitive marking return v.WithMarks(m), nil }, }) +var IssensitiveFunc = function.New(&function.Spec{ + Params: []function.Parameter{{ + Name: "value", + Type: cty.DynamicPseudoType, + AllowUnknown: true, + AllowNull: true, + AllowMarked: true, + AllowDynamicType: true, + }}, + Type: func(args []cty.Value) (cty.Type, error) { + return cty.Bool, nil + }, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + s := args[0].HasMark(marks.Sensitive) + return cty.BoolVal(s), nil + }, +}) + func Sensitive(v cty.Value) (cty.Value, error) { return SensitiveFunc.Call([]cty.Value{v}) } @@ -65,3 +83,7 @@ func Sensitive(v cty.Value) (cty.Value, error) { func Nonsensitive(v cty.Value) (cty.Value, error) { return NonsensitiveFunc.Call([]cty.Value{v}) } + +func Issensitive(v cty.Value) (cty.Value, error) { + return IssensitiveFunc.Call([]cty.Value{v}) +} diff --git a/terraform/lang/funcs/sensitive_test.go b/terraform/lang/funcs/sensitive_test.go index b047b48b9..7aba097de 100644 --- a/terraform/lang/funcs/sensitive_test.go +++ b/terraform/lang/funcs/sensitive_test.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + package funcs import ( @@ -127,16 +130,16 @@ func TestNonsensitive(t *testing.T) { ``, }, - // Passing a value that is already non-sensitive is an error, - // because this function should always be used with specific - // intention, not just as a "make everything visible" hammer. + // Passing a value that is already non-sensitive is not an error, + // as this function may be used with specific to ensure that all + // values are indeed non-sensitive { cty.NumberIntVal(1), - `the given value is not sensitive, so this call is redundant`, + ``, }, { cty.NullVal(cty.String), - `the given value is not sensitive, so this call is redundant`, + ``, }, // Unknown values may become sensitive once they are known, so we @@ -177,3 +180,75 @@ func TestNonsensitive(t *testing.T) { }) } } + +func TestIssensitive(t *testing.T) { + tests := []struct { + Input cty.Value + Sensitive bool + WantErr string + }{ + { + cty.NumberIntVal(1).Mark(marks.Sensitive), + true, + ``, + }, + { + cty.NumberIntVal(1), + false, + ``, + }, + { + cty.DynamicVal.Mark(marks.Sensitive), + true, + ``, + }, + { + cty.UnknownVal(cty.String).Mark(marks.Sensitive), + true, + ``, + }, + { + cty.NullVal(cty.EmptyObject).Mark(marks.Sensitive), + true, + ``, + }, + { + cty.NullVal(cty.String), + false, + ``, + }, + { + cty.DynamicVal, + false, + ``, + }, + { + cty.UnknownVal(cty.String), + false, + ``, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("issensitive(%#v)", test.Input), func(t *testing.T) { + got, err := Issensitive(test.Input) + + if test.WantErr != "" { + if err == nil { + t.Fatal("succeeded; want error") + } + if got, want := err.Error(), test.WantErr; got != want { + t.Fatalf("wrong error\ngot: %s\nwant: %s", got, want) + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if (got.True() && !test.Sensitive) || (got.False() && test.Sensitive) { + t.Errorf("wrong result \ngot: %#v\nwant: %#v", got, test.Sensitive) + } + }) + } + +} diff --git a/terraform/lang/funcs/string.go b/terraform/lang/funcs/string.go index ea7ada1be..454c118a4 100644 --- a/terraform/lang/funcs/string.go +++ b/terraform/lang/funcs/string.go @@ -16,19 +16,43 @@ import ( var StartsWithFunc = function.New(&function.Spec{ Params: []function.Parameter{ { - Name: "str", - Type: cty.String, + Name: "str", + Type: cty.String, + AllowUnknown: true, }, { Name: "prefix", Type: cty.String, }, }, - Type: function.StaticReturnType(cty.Bool), + Type: function.StaticReturnType(cty.Bool), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { - str := args[0].AsString() prefix := args[1].AsString() + if !args[0].IsKnown() { + // If the unknown value has a known prefix then we might be + // able to still produce a known result. + if prefix == "" { + // The empty string is a prefix of any string. + return cty.True, nil + } + if knownPrefix := args[0].Range().StringPrefix(); knownPrefix != "" { + if strings.HasPrefix(knownPrefix, prefix) { + return cty.True, nil + } + if len(knownPrefix) >= len(prefix) { + // If the prefix we're testing is no longer than the known + // prefix and it didn't match then the full string with + // that same prefix can't match either. + return cty.False, nil + } + } + return cty.UnknownVal(cty.Bool), nil + } + + str := args[0].AsString() + if strings.HasPrefix(str, prefix) { return cty.True, nil } @@ -50,7 +74,8 @@ var EndsWithFunc = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.Bool), + Type: function.StaticReturnType(cty.Bool), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { str := args[0].AsString() suffix := args[1].AsString() @@ -80,7 +105,8 @@ var ReplaceFunc = function.New(&function.Spec{ Type: cty.String, }, }, - Type: function.StaticReturnType(cty.String), + Type: function.StaticReturnType(cty.String), + RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { str := args[0].AsString() substr := args[1].AsString() @@ -101,12 +127,6 @@ var ReplaceFunc = function.New(&function.Spec{ }, }) -// Replace searches a given string for another given substring, -// and replaces all occurences with a given replacement string. -func Replace(str, substr, replace cty.Value) (cty.Value, error) { - return ReplaceFunc.Call([]cty.Value{str, substr, replace}) -} - // StrContainsFunc searches a given string for another given substring, // if found the function returns true, otherwise returns false. var StrContainsFunc = function.New(&function.Spec{ @@ -132,3 +152,13 @@ var StrContainsFunc = function.New(&function.Spec{ return cty.False, nil }, }) + +// Replace searches a given string for another given substring, +// and replaces all occurences with a given replacement string. +func Replace(str, substr, replace cty.Value) (cty.Value, error) { + return ReplaceFunc.Call([]cty.Value{str, substr, replace}) +} + +func StrContains(str, substr cty.Value) (cty.Value, error) { + return StrContainsFunc.Call([]cty.Value{str, substr}) +} diff --git a/terraform/lang/funcs/string_test.go b/terraform/lang/funcs/string_test.go index d5c5996d9..c89d17a67 100644 --- a/terraform/lang/funcs/string_test.go +++ b/terraform/lang/funcs/string_test.go @@ -134,6 +134,122 @@ func TestStrContains(t *testing.T) { } } -func StrContains(str, substr cty.Value) (cty.Value, error) { - return StrContainsFunc.Call([]cty.Value{str, substr}) +func TestStartsWith(t *testing.T) { + tests := []struct { + String, Prefix cty.Value + Want cty.Value + WantError string + }{ + { + cty.StringVal("hello world"), + cty.StringVal("hello"), + cty.True, + ``, + }, + { + cty.StringVal("hey world"), + cty.StringVal("hello"), + cty.False, + ``, + }, + { + cty.StringVal(""), + cty.StringVal(""), + cty.True, + ``, + }, + { + cty.StringVal("a"), + cty.StringVal(""), + cty.True, + ``, + }, + { + cty.StringVal(""), + cty.StringVal("a"), + cty.False, + ``, + }, + { + cty.UnknownVal(cty.String), + cty.StringVal("a"), + cty.UnknownVal(cty.Bool).RefineNotNull(), + ``, + }, + { + cty.UnknownVal(cty.String), + cty.StringVal(""), + cty.True, + ``, + }, + { + cty.UnknownVal(cty.String).Refine().StringPrefix("https:").NewValue(), + cty.StringVal(""), + cty.True, + ``, + }, + { + cty.UnknownVal(cty.String).Refine().StringPrefix("https:").NewValue(), + cty.StringVal("a"), + cty.False, + ``, + }, + { + cty.UnknownVal(cty.String).Refine().StringPrefix("https:").NewValue(), + cty.StringVal("ht"), + cty.True, + ``, + }, + { + cty.UnknownVal(cty.String).Refine().StringPrefix("https:").NewValue(), + cty.StringVal("https:"), + cty.True, + ``, + }, + { + cty.UnknownVal(cty.String).Refine().StringPrefix("https:").NewValue(), + cty.StringVal("https-"), + cty.False, + ``, + }, + { + cty.UnknownVal(cty.String).Refine().StringPrefix("https:").NewValue(), + cty.StringVal("https://"), + cty.UnknownVal(cty.Bool).RefineNotNull(), + ``, + }, + { + // Unicode combining characters edge-case: we match the prefix + // in terms of unicode code units rather than grapheme clusters, + // which is inconsistent with our string processing elsewhere but + // would be a breaking change to fix that bug now. + cty.StringVal("\U0001f937\u200d\u2642"), // "Man Shrugging" is encoded as "Person Shrugging" followed by zero-width joiner and then the masculine gender presentation modifier + cty.StringVal("\U0001f937"), // Just the "Person Shrugging" character without any modifiers + cty.True, + ``, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("StartsWith(%#v, %#v)", test.String, test.Prefix), func(t *testing.T) { + got, err := StartsWithFunc.Call([]cty.Value{test.String, test.Prefix}) + + if test.WantError != "" { + gotErr := fmt.Sprintf("%s", err) + if gotErr != test.WantError { + t.Errorf("wrong error\ngot: %s\nwant: %s", gotErr, test.WantError) + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf( + "wrong result\nstring: %#v\nprefix: %#v\ngot: %#v\nwant: %#v", + test.String, test.Prefix, got, test.Want, + ) + } + }) + } } diff --git a/terraform/lang/funcs/terraform/functions.go b/terraform/lang/funcs/terraform/functions.go new file mode 100644 index 000000000..c5c5bb03b --- /dev/null +++ b/terraform/lang/funcs/terraform/functions.go @@ -0,0 +1,205 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "bytes" + "fmt" + "sort" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +var TFVarsEncodeFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "value", + Type: cty.DynamicPseudoType, + AllowNull: true, + AllowDynamicType: true, + AllowUnknown: true, // to perform refinements + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + // These error checks should not be hit in practice because the language + // runtime should check them before calling, so this is just for robustness + // and completeness. + if len(args) > 1 { + return cty.NilVal, function.NewArgErrorf(1, "too many arguments; only one expected") + } + if len(args) == 0 { + return cty.NilVal, fmt.Errorf("exactly one argument is required") + } + + v := args[0] + ty := v.Type() + + if v.IsNull() { + // Our functions schema does not say we allow null values, so we should + // not get to this error message if the caller respects the schema. + return cty.NilVal, function.NewArgErrorf(1, "cannot encode a null value in tfvars syntax") + } + if !v.IsWhollyKnown() { + return cty.UnknownVal(cty.String).RefineNotNull(), nil + } + + var keys []string + switch { + case ty.IsObjectType(): + atys := ty.AttributeTypes() + keys = make([]string, 0, len(atys)) + for key := range atys { + keys = append(keys, key) + } + case ty.IsMapType(): + keys = make([]string, 0, v.LengthInt()) + for it := v.ElementIterator(); it.Next(); { + k, _ := it.Element() + keys = append(keys, k.AsString()) + } + default: + return cty.NilVal, function.NewArgErrorf(1, "invalid value to encode: must be an object whose attribute names will become the encoded variable names") + } + sort.Strings(keys) + + f := hclwrite.NewEmptyFile() + body := f.Body() + for _, key := range keys { + if !hclsyntax.ValidIdentifier(key) { + // We can only encode valid identifiers as tfvars keys, since + // the HCL argument grammar requires them to be identifiers. + return cty.NilVal, function.NewArgErrorf(1, "invalid variable name %q: must be a valid identifier, per Terraform's rules for input variable declarations", key) + } + + // This index should not fail because we know that "key" is a valid + // index from the logic above. + v, _ := hcl.Index(v, cty.StringVal(key), nil) + body.SetAttributeValue(key, v) + } + + result := f.Bytes() + return cty.StringVal(string(result)), nil + }, +}) + +var TFVarsDecodeFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "src", + Type: cty.String, + AllowNull: true, + }, + }, + Type: function.StaticReturnType(cty.DynamicPseudoType), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + // These error checks should not be hit in practice because the language + // runtime should check them before calling, so this is just for robustness + // and completeness. + if len(args) > 1 { + return cty.NilVal, function.NewArgErrorf(1, "too many arguments; only one expected") + } + if len(args) == 0 { + return cty.NilVal, fmt.Errorf("exactly one argument is required") + } + if args[0].Type() != cty.String { + return cty.NilVal, fmt.Errorf("argument must be a string") + } + if args[0].IsNull() { + return cty.NilVal, fmt.Errorf("cannot decode tfvars from a null value") + } + if !args[0].IsKnown() { + // If our input isn't known then we can't even predict the result + // type, since it will be an object type decided based on which + // arguments and values we find in the string. + return cty.DynamicVal, nil + } + + // If we get here then we know that: + // - there's exactly one element in args + // - it's a string + // - it is known and non-null + // So therefore the following is guaranteed to succeed. + src := []byte(args[0].AsString()) + + // As usual when we wrap HCL stuff up in functions, we end up needing to + // stuff HCL diagnostics into plain string error messages. This produces + // a non-ideal result but is still better than hiding the HCL-provided + // diagnosis altogether. + f, hclDiags := hclsyntax.ParseConfig(src, "", hcl.InitialPos) + if hclDiags.HasErrors() { + return cty.NilVal, fmt.Errorf("invalid tfvars syntax: %s", hclDiags.Error()) + } + attrs, hclDiags := f.Body.JustAttributes() + if hclDiags.HasErrors() { + return cty.NilVal, fmt.Errorf("invalid tfvars content: %s", hclDiags.Error()) + } + retAttrs := make(map[string]cty.Value, len(attrs)) + for name, attr := range attrs { + // Evaluating the expression with no EvalContext achieves the same + // interpretation as Terraform CLI makes of .tfvars files, rejecting + // any function calls or references to symbols. + v, hclDiags := attr.Expr.Value(nil) + if hclDiags.HasErrors() { + return cty.NilVal, fmt.Errorf("invalid expression for variable %q: %s", name, hclDiags.Error()) + } + retAttrs[name] = v + } + + return cty.ObjectVal(retAttrs), nil + }, +}) + +var ExprEncodeFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "value", + Type: cty.DynamicPseudoType, + AllowNull: true, + AllowDynamicType: true, + AllowUnknown: true, // to perform refinements + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + // These error checks should not be hit in practice because the language + // runtime should check them before calling, so this is just for robustness + // and completeness. + if len(args) > 1 { + return cty.NilVal, function.NewArgErrorf(1, "too many arguments; only one expected") + } + if len(args) == 0 { + return cty.NilVal, fmt.Errorf("exactly one argument is required") + } + + v := args[0] + if !v.IsWhollyKnown() { + ret := cty.UnknownVal(cty.String).RefineNotNull() + // For some types we can refine further due to the HCL grammar, + // as long as w eknow the value isn't null. + if !v.Range().CouldBeNull() { + ty := v.Type() + switch { + case ty.IsObjectType() || ty.IsMapType(): + ret = ret.Refine().StringPrefixFull("{").NewValue() + case ty.IsTupleType() || ty.IsListType() || ty.IsSetType(): + ret = ret.Refine().StringPrefixFull("[").NewValue() + case ty == cty.String: + ret = ret.Refine().StringPrefixFull(`"`).NewValue() + } + } + return ret, nil + } + + // This bytes.TrimSpace is to ensure that future changes to HCL, that + // might for some reason add extra spaces before the expression (!) + // can't invalidate our unknown value prefix refinements above. + src := bytes.TrimSpace(hclwrite.TokensForValue(v).Bytes()) + return cty.StringVal(string(src)), nil + }, +}) diff --git a/terraform/lang/funcs/terraform/functions_test.go b/terraform/lang/funcs/terraform/functions_test.go new file mode 100644 index 000000000..7ffc7c192 --- /dev/null +++ b/terraform/lang/funcs/terraform/functions_test.go @@ -0,0 +1,382 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" +) + +func TestTFVarsEncode(t *testing.T) { + tests := []struct { + Input cty.Value + Want cty.Value + WantErr string + }{ + { + Input: cty.ObjectVal(map[string]cty.Value{ + "string": cty.StringVal("hello"), + "number": cty.NumberIntVal(5), + "bool": cty.True, + "set": cty.SetVal([]cty.Value{cty.StringVal("beep"), cty.StringVal("boop")}), + "list": cty.SetVal([]cty.Value{cty.StringVal("bleep"), cty.StringVal("bloop")}), + "tuple": cty.SetVal([]cty.Value{cty.StringVal("bibble"), cty.StringVal("wibble")}), + "map": cty.MapVal(map[string]cty.Value{"one": cty.NumberIntVal(1)}), + "object": cty.ObjectVal(map[string]cty.Value{"one": cty.NumberIntVal(1), "true": cty.True}), + "null": cty.NullVal(cty.String), + }), + Want: cty.StringVal( + `bool = true +list = ["bleep", "bloop"] +map = { + one = 1 +} +null = null +number = 5 +object = { + one = 1 + true = true +} +set = ["beep", "boop"] +string = "hello" +tuple = ["bibble", "wibble"] +`), + }, + { + Input: cty.EmptyObjectVal, + Want: cty.StringVal(``), + }, + { + Input: cty.MapVal(map[string]cty.Value{ + "one": cty.NumberIntVal(1), + "two": cty.NumberIntVal(2), + "three": cty.NumberIntVal(3), + }), + Want: cty.StringVal( + `one = 1 +three = 3 +two = 2 +`), + }, + { + Input: cty.MapValEmpty(cty.String), + Want: cty.StringVal(``), + }, + { + Input: cty.UnknownVal(cty.EmptyObject), + Want: cty.UnknownVal(cty.String).RefineNotNull(), + }, + { + Input: cty.UnknownVal(cty.Map(cty.String)), + Want: cty.UnknownVal(cty.String).RefineNotNull(), + }, + { + Input: cty.ObjectVal(map[string]cty.Value{ + "string": cty.UnknownVal(cty.String), + }), + Want: cty.UnknownVal(cty.String).RefineNotNull(), + }, + { + Input: cty.MapVal(map[string]cty.Value{ + "string": cty.UnknownVal(cty.String), + }), + Want: cty.UnknownVal(cty.String).RefineNotNull(), + }, + { + Input: cty.NullVal(cty.EmptyObject), + WantErr: `cannot encode a null value in tfvars syntax`, + }, + { + Input: cty.NullVal(cty.Map(cty.String)), + WantErr: `cannot encode a null value in tfvars syntax`, + }, + { + Input: cty.StringVal("nope"), + WantErr: `invalid value to encode: must be an object whose attribute names will become the encoded variable names`, + }, + { + Input: cty.Zero, + WantErr: `invalid value to encode: must be an object whose attribute names will become the encoded variable names`, + }, + { + Input: cty.False, + WantErr: `invalid value to encode: must be an object whose attribute names will become the encoded variable names`, + }, + { + Input: cty.ListValEmpty(cty.String), + WantErr: `invalid value to encode: must be an object whose attribute names will become the encoded variable names`, + }, + { + Input: cty.SetValEmpty(cty.String), + WantErr: `invalid value to encode: must be an object whose attribute names will become the encoded variable names`, + }, + { + Input: cty.EmptyTupleVal, + WantErr: `invalid value to encode: must be an object whose attribute names will become the encoded variable names`, + }, + { + Input: cty.ObjectVal(map[string]cty.Value{ + "not valid identifier": cty.StringVal("!"), + }), + WantErr: `invalid variable name "not valid identifier": must be a valid identifier, per Terraform's rules for input variable declarations`, + }, + } + + for _, test := range tests { + t.Run(test.Input.GoString(), func(t *testing.T) { + got, err := TFVarsEncodeFunc.Call([]cty.Value{test.Input}) + if test.WantErr != "" { + if err == nil { + t.Fatalf("unexpected success for %#v; want error\ngot: %#v", test.Input, got) + } + if err.Error() != test.WantErr { + t.Errorf("wrong error\ngot: %s\nwant: %s", err.Error(), test.WantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if diff := cmp.Diff(test.Want, got, ctydebug.CmpOptions); diff != "" { + t.Errorf("wrong result for %#v\n%s", test.Input, diff) + } + }) + } +} + +func TestTFVarsDecode(t *testing.T) { + tests := []struct { + Input cty.Value + Want cty.Value + WantErr string + }{ + { + Input: cty.StringVal(`string = "hello" +number = 2`), + Want: cty.ObjectVal(map[string]cty.Value{ + "string": cty.StringVal("hello"), + "number": cty.NumberIntVal(2), + }), + }, + { + Input: cty.StringVal(``), + Want: cty.EmptyObjectVal, + }, + { + Input: cty.UnknownVal(cty.String), + Want: cty.UnknownVal(cty.DynamicPseudoType), + }, + { + Input: cty.NullVal(cty.String), + WantErr: `cannot decode tfvars from a null value`, + }, + { + Input: cty.StringVal(`not valid syntax`), + // This is actually not a very good diagnosis for this error, + // since we're expecting HCL arguments rather than HCL blocks, + // but that's something we'd need to address in HCL. + WantErr: `invalid tfvars syntax: :1,17-17: Invalid block definition; Either a quoted string block label or an opening brace ("{") is expected here.`, + }, + { + Input: cty.StringVal(`foo = not valid syntax`), + WantErr: `invalid tfvars syntax: :1,11-16: Missing newline after argument; An argument definition must end with a newline.`, + }, + { + Input: cty.StringVal(`foo = var.whatever`), + WantErr: `invalid expression for variable "foo": :1,7-10: Variables not allowed; Variables may not be used here.`, + }, + { + Input: cty.StringVal(`foo = whatever()`), + WantErr: `invalid expression for variable "foo": :1,7-17: Function calls not allowed; Functions may not be called here.`, + }, + } + + for _, test := range tests { + t.Run(test.Input.GoString(), func(t *testing.T) { + got, err := TFVarsDecodeFunc.Call([]cty.Value{test.Input}) + if test.WantErr != "" { + if err == nil { + t.Fatalf("unexpected success for %#v; want error\ngot: %#v", test.Input, got) + } + if err.Error() != test.WantErr { + t.Errorf("wrong error\ngot: %s\nwant: %s", err.Error(), test.WantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if diff := cmp.Diff(test.Want, got, ctydebug.CmpOptions); diff != "" { + t.Errorf("wrong result for %#v\n%s", test.Input, diff) + } + }) + } +} + +func TestExprEncode(t *testing.T) { + tests := []struct { + Input cty.Value + Want cty.Value + WantErr string + }{ + { + Input: cty.StringVal("hello"), + Want: cty.StringVal(`"hello"`), + }, + { + Input: cty.StringVal("hello\nworld\n"), + Want: cty.StringVal(`"hello\nworld\n"`), + // NOTE: If HCL changes the above to be a heredoc in future (which + // would make this test fail) then our function's refinement + // that unknown strings encode with the prefix " will become + // invalid, and should be removed. + }, + { + Input: cty.StringVal("hel${lo"), + Want: cty.StringVal(`"hel$${lo"`), // Escape template interpolation sequence + }, + { + Input: cty.StringVal("hel%{lo"), + Want: cty.StringVal(`"hel%%{lo"`), // Escape template control sequence + }, + { + Input: cty.StringVal(`boop\boop`), + Want: cty.StringVal(`"boop\\boop"`), // Escape literal backslash + }, + { + Input: cty.StringVal(""), + Want: cty.StringVal(`""`), + }, + { + Input: cty.NumberIntVal(2), + Want: cty.StringVal(`2`), + }, + { + Input: cty.True, + Want: cty.StringVal(`true`), + }, + { + Input: cty.False, + Want: cty.StringVal(`false`), + }, + { + Input: cty.EmptyObjectVal, + Want: cty.StringVal(`{}`), + }, + { + Input: cty.ObjectVal(map[string]cty.Value{ + "number": cty.NumberIntVal(5), + "string": cty.StringVal("..."), + }), + Want: cty.StringVal(`{ + number = 5 + string = "..." +}`), + }, + { + Input: cty.MapVal(map[string]cty.Value{ + "one": cty.NumberIntVal(1), + "two": cty.NumberIntVal(2), + }), + Want: cty.StringVal(`{ + one = 1 + two = 2 +}`), + }, + { + Input: cty.EmptyTupleVal, + Want: cty.StringVal(`[]`), + }, + { + Input: cty.TupleVal([]cty.Value{ + cty.NumberIntVal(5), + cty.StringVal("..."), + }), + Want: cty.StringVal(`[5, "..."]`), + }, + { + Input: cty.SetVal([]cty.Value{ + cty.NumberIntVal(1), + cty.NumberIntVal(5), + cty.NumberIntVal(20), + cty.NumberIntVal(55), + }), + Want: cty.StringVal(`[1, 5, 20, 55]`), + }, + { + Input: cty.DynamicVal, + Want: cty.UnknownVal(cty.String).RefineNotNull(), + }, + { + Input: cty.UnknownVal(cty.Number).RefineNotNull(), + Want: cty.UnknownVal(cty.String).RefineNotNull(), + }, + { + Input: cty.UnknownVal(cty.String).RefineNotNull(), + Want: cty.UnknownVal(cty.String).Refine(). + NotNull(). + StringPrefixFull(`"`). + NewValue(), + }, + { + Input: cty.UnknownVal(cty.EmptyObject).RefineNotNull(), + Want: cty.UnknownVal(cty.String).Refine(). + NotNull(). + StringPrefixFull(`{`). + NewValue(), + }, + { + Input: cty.UnknownVal(cty.Map(cty.String)).RefineNotNull(), + Want: cty.UnknownVal(cty.String).Refine(). + NotNull(). + StringPrefixFull(`{`). + NewValue(), + }, + { + Input: cty.UnknownVal(cty.EmptyTuple).RefineNotNull(), + Want: cty.UnknownVal(cty.String).Refine(). + NotNull(). + StringPrefixFull(`[`). + NewValue(), + }, + { + Input: cty.UnknownVal(cty.List(cty.String)).RefineNotNull(), + Want: cty.UnknownVal(cty.String).Refine(). + NotNull(). + StringPrefixFull(`[`). + NewValue(), + }, + { + Input: cty.UnknownVal(cty.Set(cty.String)).RefineNotNull(), + Want: cty.UnknownVal(cty.String).Refine(). + NotNull(). + StringPrefixFull(`[`). + NewValue(), + }, + } + + for _, test := range tests { + t.Run(test.Input.GoString(), func(t *testing.T) { + got, err := ExprEncodeFunc.Call([]cty.Value{test.Input}) + if test.WantErr != "" { + if err == nil { + t.Fatalf("unexpected success for %#v; want error\ngot: %#v", test.Input, got) + } + if err.Error() != test.WantErr { + t.Errorf("wrong error\ngot: %s\nwant: %s", err.Error(), test.WantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if diff := cmp.Diff(test.Want, got, ctydebug.CmpOptions); diff != "" { + t.Errorf("wrong result for %#v\n%s", test.Input, diff) + } + }) + } +} diff --git a/terraform/lang/funcs/testdata/recursive_namespaced.tmpl b/terraform/lang/funcs/testdata/recursive_namespaced.tmpl new file mode 100644 index 000000000..d7e065835 --- /dev/null +++ b/terraform/lang/funcs/testdata/recursive_namespaced.tmpl @@ -0,0 +1 @@ +${core::templatefile("recursive_namespaced.tmpl", {})} diff --git a/terraform/lang/function_calls.go b/terraform/lang/function_calls.go new file mode 100644 index 000000000..a761307a5 --- /dev/null +++ b/terraform/lang/function_calls.go @@ -0,0 +1,109 @@ +package lang + +import ( + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/json" + "github.com/zclconf/go-cty/cty" +) + +// FunctionCall represents a function call in an HCL expression. +// The difference with hclsyntax.FunctionCallExpr is that +// function calls are also available in JSON syntax. +type FunctionCall struct { + Name string + ArgsCount int +} + +// FunctionCallsInExpr finds all of the function calls in the given expression. +func FunctionCallsInExpr(expr hcl.Expression) ([]*FunctionCall, hcl.Diagnostics) { + if expr == nil { + return nil, nil + } + + // For JSON syntax, walker is not implemented, + // so extract the hclsyntax.Node that we can walk on. + // See https://github.com/hashicorp/hcl/issues/543 + nodes, diags := walkableNodesInExpr(expr) + ret := []*FunctionCall{} + + for _, node := range nodes { + visitDiags := hclsyntax.VisitAll(node, func(n hclsyntax.Node) hcl.Diagnostics { + if funcCallExpr, ok := n.(*hclsyntax.FunctionCallExpr); ok { + ret = append(ret, &FunctionCall{ + Name: funcCallExpr.Name, + ArgsCount: len(funcCallExpr.Args), + }) + } + return nil + }) + diags = diags.Extend(visitDiags) + } + return ret, diags +} + +// IsProviderDefined returns true if the function is provider-defined. +func (f *FunctionCall) IsProviderDefined() bool { + return strings.HasPrefix(f.Name, "provider::") +} + +// walkableNodesInExpr returns hclsyntax.Node from the given expression. +// If the expression is an hclsyntax expression, it is returned as is. +// If the expression is a JSON expression, it is parsed and +// hclsyntax.Node it contains is returned. +func walkableNodesInExpr(expr hcl.Expression) ([]hclsyntax.Node, hcl.Diagnostics) { + nodes := []hclsyntax.Node{} + + expr = hcl.UnwrapExpressionUntil(expr, func(expr hcl.Expression) bool { + _, native := expr.(hclsyntax.Expression) + return native || json.IsJSONExpression(expr) + }) + if expr == nil { + return nil, nil + } + + if json.IsJSONExpression(expr) { + // HACK: For JSON expressions, we can get the JSON value as a literal + // without any prior HCL parsing by evaluating it in a nil context. + // We can take advantage of this property to walk through cty.Value + // that may contain HCL expressions instead of walking through + // expression nodes directly. + // See https://github.com/hashicorp/hcl/issues/642 + val, diags := expr.Value(nil) + if diags.HasErrors() { + return nodes, diags + } + + err := cty.Walk(val, func(path cty.Path, v cty.Value) (bool, error) { + if v.Type() != cty.String || v.IsNull() || !v.IsKnown() { + return true, nil + } + + node, parseDiags := hclsyntax.ParseTemplate([]byte(v.AsString()), expr.Range().Filename, expr.Range().Start) + if diags.HasErrors() { + diags = diags.Extend(parseDiags) + return true, nil + } + + nodes = append(nodes, node) + return true, nil + }) + if err != nil { + return nodes, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Failed to walk the expression value", + Detail: err.Error(), + Subject: expr.Range().Ptr(), + }} + } + + return nodes, diags + } + + // The JSON syntax is already processed, so it's guaranteed to be native syntax. + nodes = append(nodes, expr.(hclsyntax.Expression)) + + return nodes, nil +} diff --git a/terraform/lang/function_calls_test.go b/terraform/lang/function_calls_test.go new file mode 100644 index 000000000..4a2398252 --- /dev/null +++ b/terraform/lang/function_calls_test.go @@ -0,0 +1,155 @@ +package lang + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/json" + "github.com/terraform-linters/tflint-plugin-sdk/hclext" + "github.com/zclconf/go-cty/cty" +) + +func TestFunctionCallsInExpr(t *testing.T) { + parse := func(src string) hcl.Expression { + expr, diags := hclsyntax.ParseExpression([]byte(src), "", hcl.InitialPos) + if diags.HasErrors() { + t.Fatalf("failed to parse `%s`, %s", src, diags) + } + return expr + } + parseJSON := func(src string) hcl.Expression { + expr, diags := json.ParseExpression([]byte(src), "") + if diags.HasErrors() { + t.Fatalf("failed to parse `%s`, %s", src, diags) + } + return expr + } + + tests := []struct { + name string + expr hcl.Expression + want []*FunctionCall + }{ + { + name: "nil expression", + expr: nil, + want: nil, + }, + { + name: "string", + expr: parse(`"string"`), + want: []*FunctionCall{}, + }, + { + name: "string (JSON)", + expr: parseJSON(`"string"`), + want: []*FunctionCall{}, + }, + { + name: "number (JSON)", + expr: parseJSON(`123`), + want: []*FunctionCall{}, + }, + { + name: "null (JSON)", + expr: parseJSON(`null`), + want: []*FunctionCall{}, + }, + { + name: "unknown (JSON)", + expr: parseJSON(`"${var.foo}"`), + want: []*FunctionCall{}, + }, + { + name: "single function call", + expr: parse(`md5("hello")`), + want: []*FunctionCall{ + {Name: "md5", ArgsCount: 1}, + }, + }, + { + name: "single function call (JSON)", + expr: parseJSON(`"${md5(\"hello\")}"`), + want: []*FunctionCall{ + {Name: "md5", ArgsCount: 1}, + }, + }, + { + name: "multiple function calls", + expr: parse(`[md5("hello"), "world", provider::tflint::world()]`), + want: []*FunctionCall{ + {Name: "md5", ArgsCount: 1}, + {Name: "provider::tflint::world", ArgsCount: 0}, + }, + }, + { + name: "multiple function calls (JSON)", + expr: parseJSON(`["${md5(\"hello\")}", "world", "${provider::tflint::world()}"]`), + want: []*FunctionCall{ + {Name: "md5", ArgsCount: 1}, + {Name: "provider::tflint::world", ArgsCount: 0}, + }, + }, + { + name: "bound expr with native syntax", + expr: hclext.BindValue(cty.StringVal("foo-Hello, John and Mike"), parse(`"foo-${hello("John", "Mike")}"`)), + want: []*FunctionCall{ + {Name: "hello", ArgsCount: 2}, + }, + }, + { + name: "bound expr with JSON syntax", + expr: hclext.BindValue(cty.StringVal("foo-Hello, John and Mike"), parseJSON(`"foo-${hello(\"John\", \"Mike\")}"`)), + want: []*FunctionCall{ + {Name: "hello", ArgsCount: 2}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, diags := FunctionCallsInExpr(test.expr) + if diags.HasErrors() { + t.Fatal(diags) + } + + if diff := cmp.Diff(test.want, got); diff != "" { + t.Errorf(diff) + } + }) + } +} + +func TestIsProviderDefined(t *testing.T) { + tests := []struct { + name string + want bool + }{ + { + name: "md5", + want: false, + }, + { + name: "core::md5", + want: false, + }, + { + name: "provider::tflint::echo", + want: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := &FunctionCall{Name: test.name} + + got := f.IsProviderDefined() + + if got != test.want { + t.Errorf("got %t, want %t", got, test.want) + } + }) + } +} diff --git a/terraform/lang/functions.go b/terraform/lang/functions.go index c4a498beb..5da9404eb 100644 --- a/terraform/lang/functions.go +++ b/terraform/lang/functions.go @@ -1,5 +1,5 @@ // Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 +// SPDX-License-Identifier: BUSL-1.1 package lang @@ -11,6 +11,7 @@ import ( "github.com/zclconf/go-cty/cty/function/stdlib" "github.com/terraform-linters/tflint/terraform/lang/funcs" + "github.com/terraform-linters/tflint/terraform/lang/funcs/terraform" ) var impureFunctions = []string{ @@ -31,7 +32,7 @@ func (s *Scope) Functions() map[string]function.Function { // later if the functionality seems to be something domain-agnostic // that would be useful to all applications using cty functions. - s.funcs = map[string]function.Function{ + coreFuncs := map[string]function.Function{ "abs": stdlib.AbsoluteFunc, "abspath": funcs.AbsPathFunc, "alltrue": funcs.AllTrueFunc, @@ -106,6 +107,7 @@ func (s *Scope) Functions() map[string]function.Function { "rsadecrypt": funcs.RsaDecryptFunc, "sensitive": funcs.SensitiveFunc, "nonsensitive": funcs.NonsensitiveFunc, + "issensitive": funcs.IssensitiveFunc, "setintersection": stdlib.SetIntersectionFunc, "setproduct": stdlib.SetProductFunc, "setsubtract": stdlib.SetSubtractFunc, @@ -150,9 +152,10 @@ func (s *Scope) Functions() map[string]function.Function { "zipmap": stdlib.ZipmapFunc, } - s.funcs["templatefile"] = funcs.MakeTemplateFileFunc(s.BaseDir, func() map[string]function.Function { + coreFuncs["templatefile"] = funcs.MakeTemplateFileFunc(s.BaseDir, func() map[string]function.Function { // The templatefile function prevents recursive calls to itself - // by copying this map and overwriting the "templatefile" entry. + // by copying this map and overwriting the "templatefile" and + // "core:templatefile" entries. return s.funcs }) @@ -160,11 +163,49 @@ func (s *Scope) Functions() map[string]function.Function { // Force our few impure functions to return unknown so that we // can defer evaluating them until a later pass. for _, name := range impureFunctions { - s.funcs[name] = function.Unpredictable(s.funcs[name]) + coreFuncs[name] = function.Unpredictable(s.funcs[name]) } } + + // All of the built-in functions are also available under the "core::" + // namespace, to distinguish from the "provider::" and "module::" + // namespaces that can serve as external extension points. + s.funcs = make(map[string]function.Function, len(coreFuncs)*2) + for name, fn := range coreFuncs { + s.funcs[name] = fn + s.funcs["core::"+name] = fn + } + + // Built-in Terraform provider-defined functions are typically obtained dynamically, + // but given that they are built-ins, they are provided just like regular functions. + s.funcs["provider::terraform::tfvarsencode"] = terraform.TFVarsEncodeFunc + s.funcs["provider::terraform::tfvarsdecode"] = terraform.TFVarsDecodeFunc + s.funcs["provider::terraform::exprencode"] = terraform.ExprEncodeFunc } s.funcsLock.Unlock() return s.funcs } + +// NewMockFunction creates a mock function that returns a dynamic value. +// This is primarily used to replace provider-defined functions. +func NewMockFunction(call *FunctionCall) function.Function { + params := make([]function.Parameter, call.ArgsCount) + for idx := 0; idx < call.ArgsCount; idx++ { + params[idx] = function.Parameter{ + Type: cty.DynamicPseudoType, + AllowNull: true, + AllowUnknown: true, + AllowDynamicType: true, + AllowMarked: true, + } + } + + return function.New(&function.Spec{ + Params: params, + Type: function.StaticReturnType(cty.DynamicPseudoType), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return cty.DynamicVal, nil + }, + }) +} diff --git a/terraform/lang/functions_test.go b/terraform/lang/functions_test.go index 280c98c15..546e1f81b 100644 --- a/terraform/lang/functions_test.go +++ b/terraform/lang/functions_test.go @@ -1,5 +1,5 @@ // Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 +// SPDX-License-Identifier: BUSL-1.1 package lang @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "github.com/hashicorp/hcl/v2" @@ -480,6 +481,13 @@ func TestFunctions(t *testing.T) { }, }, + "issensitive": { + { + `issensitive(1)`, + cty.False, + }, + }, + "join": { { `join(" ", ["Hello", "World"])`, @@ -898,6 +906,10 @@ func TestFunctions(t *testing.T) { `templatefile("hello.tmpl", {name = "Jodie"})`, cty.StringVal("Hello, Jodie!"), }, + { + `core::templatefile("hello.tmpl", {name = "Namespaced Jodie"})`, + cty.StringVal("Hello, Namespaced Jodie!"), + }, }, "timeadd": { @@ -1038,6 +1050,10 @@ func TestFunctions(t *testing.T) { `upper("hello")`, cty.StringVal("HELLO"), }, + { + `core::upper("hello")`, + cty.StringVal("HELLO"), + }, }, "urlencode": { @@ -1138,6 +1154,11 @@ func TestFunctions(t *testing.T) { delete(allFunctions, impureFunc) } for f := range scope.Functions() { + if strings.Contains(f, "::") { + // Only non-namespaced functions are absolutely required to + // have at least one test. (Others _may_ have tests.) + continue + } if _, ok := tests[f]; !ok { t.Errorf("Missing test for function %s\n", f) } @@ -1216,3 +1237,83 @@ Had'em E.E. Cummings` ) + +func TestNewMockFunction(t *testing.T) { + tests := []struct { + name string + call *FunctionCall + args []cty.Value + }{ + { + name: "no args", + call: &FunctionCall{ + Name: "foo", + ArgsCount: 0, + }, + args: []cty.Value{}, + }, + { + name: "single arg", + call: &FunctionCall{ + Name: "bar", + ArgsCount: 1, + }, + args: []cty.Value{cty.StringVal("hello")}, + }, + { + name: "multiple args", + call: &FunctionCall{ + Name: "baz", + ArgsCount: 2, + }, + args: []cty.Value{cty.BoolVal(false), cty.NumberIntVal(1)}, + }, + { + name: "null arg", + call: &FunctionCall{ + Name: "null", + ArgsCount: 1, + }, + args: []cty.Value{cty.NullVal(cty.String)}, + }, + { + name: "unknown arg", + call: &FunctionCall{ + Name: "unknown", + ArgsCount: 1, + }, + args: []cty.Value{cty.UnknownVal(cty.Number)}, + }, + { + name: "dynamic value arg", + call: &FunctionCall{ + Name: "dynamic", + ArgsCount: 1, + }, + args: []cty.Value{cty.DynamicVal}, + }, + { + name: "marked value arg", + call: &FunctionCall{ + Name: "marked", + ArgsCount: 1, + }, + args: []cty.Value{cty.StringVal("marked").Mark(marks.Sensitive)}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fn := NewMockFunction(test.call) + + got, err := fn.Call(test.args) + if err != nil { + t.Fatal(err) + } + + if !got.RawEquals(cty.DynamicVal) { + t.Errorf("want: cty.DynamicVal, got: %s", got.GoString()) + } + }) + } +} diff --git a/terraform/tfhcl/expressions_hclext.go b/terraform/tfhcl/expressions_hclext.go new file mode 100644 index 000000000..5e0a8699e --- /dev/null +++ b/terraform/tfhcl/expressions_hclext.go @@ -0,0 +1,150 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfhcl + +import ( + "github.com/hashicorp/hcl/v2" + "github.com/terraform-linters/tflint-plugin-sdk/hclext" +) + +// ExpandExpressionsHCLExt is ExpandVariablesHCLExt which returns +// []hcl.Expression instead of []hcl.Traversal. +func ExpandExpressionsHCLExt(body hcl.Body, schema *hclext.BodySchema) []hcl.Expression { + rootNode := WalkExpandExpressions(body) + return walkExpressionsWithHCLExt(rootNode, schema) +} + +func walkExpressionsWithHCLExt(node WalkExpressionsNode, schema *hclext.BodySchema) []hcl.Expression { + exprs, children := node.Visit(extendSchema(asHCLSchema(schema))) + + if len(children) > 0 { + childSchemas := childBlockTypes(schema) + for _, child := range children { + if childSchema, exists := childSchemas[child.BlockTypeName]; exists { + exprs = append(exprs, walkExpressionsWithHCLExt(child.Node, childSchema.Body)...) + } + } + } + + return exprs +} + +// WalkExpandExpressions is dynblock.WalkExpandVariables for expressions. +func WalkExpandExpressions(body hcl.Body) WalkExpressionsNode { + return WalkExpressionsNode{body: body} +} + +type WalkExpressionsNode struct { + body hcl.Body +} + +type WalkExpressionsChild struct { + BlockTypeName string + Node WalkExpressionsNode +} + +// Visit returns the expressions required for any "dynamic" blocks +// directly in the body associated with this node, and also returns any child +// nodes that must be visited in order to continue the walk. +// +// Each child node has its associated block type name given in its BlockTypeName +// field, which the calling application should use to determine the appropriate +// schema for the content of each child node and pass it to the child node's +// own Visit method to continue the walk recursively. +func (n WalkExpressionsNode) Visit(schema *hcl.BodySchema) (exprs []hcl.Expression, children []WalkExpressionsChild) { + extSchema := n.extendSchema(schema) + container, _, _ := n.body.PartialContent(extSchema) + if container == nil { + return exprs, children + } + + children = make([]WalkExpressionsChild, 0, len(container.Blocks)) + + for _, attr := range container.Attributes { + exprs = append(exprs, attr.Expr) + } + + for _, block := range container.Blocks { + switch block.Type { + + case "dynamic": + blockTypeName := block.Labels[0] + inner, _, _ := block.Body.PartialContent(variableDetectionInnerSchema) + if inner == nil { + continue + } + + if attr, exists := inner.Attributes["for_each"]; exists { + exprs = append(exprs, attr.Expr) + } + if attr, exists := inner.Attributes["labels"]; exists { + exprs = append(exprs, attr.Expr) + } + + for _, contentBlock := range inner.Blocks { + // We only request "content" blocks in our schema, so we know + // any blocks we find here will be content blocks. We require + // exactly one content block for actual expansion, but we'll + // be more liberal here so that callers can still collect + // expressions from erroneous "dynamic" blocks. + children = append(children, WalkExpressionsChild{ + BlockTypeName: blockTypeName, + Node: WalkExpressionsNode{ + body: contentBlock.Body, + }, + }) + } + + default: + children = append(children, WalkExpressionsChild{ + BlockTypeName: block.Type, + Node: WalkExpressionsNode{ + body: block.Body, + }, + }) + + } + } + + return exprs, children +} + +func (c WalkExpressionsNode) extendSchema(schema *hcl.BodySchema) *hcl.BodySchema { + // We augment the requested schema to also include our special "dynamic" + // block type, since then we'll get instances of it interleaved with + // all of the literal child blocks we must also include. + extSchema := &hcl.BodySchema{ + Attributes: schema.Attributes, + Blocks: make([]hcl.BlockHeaderSchema, len(schema.Blocks), len(schema.Blocks)+1), + } + copy(extSchema.Blocks, schema.Blocks) + extSchema.Blocks = append(extSchema.Blocks, dynamicBlockHeaderSchema) + + return extSchema +} + +// This is a more relaxed schema than what's in schema.go, since we +// want to maximize the amount of variables we can find even if there +// are erroneous blocks. +var variableDetectionInnerSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "for_each", + Required: false, + }, + { + Name: "labels", + Required: false, + }, + { + Name: "iterator", + Required: false, + }, + }, + Blocks: []hcl.BlockHeaderSchema{ + { + Type: "content", + }, + }, +}